Compare commits
430
Commits
+3
-2
@@ -26,8 +26,9 @@ config.yaml
|
|||||||
# Runtime data (mounted at runtime via volume)
|
# Runtime data (mounted at runtime via volume)
|
||||||
playbook_cache/
|
playbook_cache/
|
||||||
|
|
||||||
# Plugins (mounted at runtime via volume)
|
# NOTE: first-party plugins under plugins/ now ship IN the image
|
||||||
plugins/
|
# (Dockerfile `COPY plugins/`), so plugins/ must NOT be ignored here.
|
||||||
|
# Third-party plugins are mounted at runtime into /data/plugins instead.
|
||||||
|
|
||||||
# Deployment files not needed in image
|
# Deployment files not needed in image
|
||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
|
|||||||
+8
-3
@@ -1,6 +1,11 @@
|
|||||||
# Copy to .env for Docker / local development secrets
|
# Copy to .env for Docker / local development secrets
|
||||||
# These override values in config.yaml
|
# These override values in config.yaml
|
||||||
|
|
||||||
ROUNDTABLE_SECRET_KEY=change-me
|
STEWARD_DATABASE_URL=postgresql+asyncpg://steward:password@localhost/steward
|
||||||
ROUNDTABLE_DATABASE__URL=postgresql+asyncpg://roundtable:password@localhost/roundtable
|
STEWARD_SMTP__PASSWORD=
|
||||||
ROUNDTABLE_SMTP__PASSWORD=
|
|
||||||
|
# --- compose.deploy.yml (remote instance) only ---
|
||||||
|
# Password for the bundled Postgres; the steward service builds its
|
||||||
|
# STEWARD_DATABASE_URL from it. Leave STEWARD_SECRET_KEY above unset to let the
|
||||||
|
# app generate + persist one on the /data volume.
|
||||||
|
POSTGRES_PASSWORD=change-me
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
# CI lanes per FabledRulebook forgejo.md "CI philosophy":
|
||||||
|
# - lint: ruff only, no dep install — fast-fail for the common lint bounce.
|
||||||
|
# - unit: pytest -m "not integration"; no service containers, no DB.
|
||||||
|
# - integration: postgres service container; boots the real app + runs all
|
||||||
|
# (core + bundled-plugin) migrations.
|
||||||
|
# Single repo: first-party plugins are bundled in-tree under plugins/, so the
|
||||||
|
# unit lane imports them directly — no cross-repo checkout.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [dev, main]
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
# 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.
|
||||||
|
lint:
|
||||||
|
runs-on: python-ci
|
||||||
|
container:
|
||||||
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Ruff lint
|
||||||
|
run: ruff check steward/ plugins/ tests/
|
||||||
|
|
||||||
|
unit:
|
||||||
|
runs-on: python-ci
|
||||||
|
container:
|
||||||
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Install Steward + test deps
|
||||||
|
# uv: 5-10x faster wheel resolve than pip on cold caches. Falls back to
|
||||||
|
# pip on uv-missing runners. Steward declares its deps in pyproject; the
|
||||||
|
# [dev] extra adds pytest + pytest-asyncio.
|
||||||
|
run: |
|
||||||
|
if command -v uv >/dev/null 2>&1; then
|
||||||
|
uv pip install --system -e '.[dev]'
|
||||||
|
else
|
||||||
|
pip install -e '.[dev]'
|
||||||
|
fi
|
||||||
|
- name: Pytest (unit only — integration runs in its own lane)
|
||||||
|
run: pytest tests/ -v -m "not integration"
|
||||||
|
|
||||||
|
integration:
|
||||||
|
runs-on: python-ci
|
||||||
|
container:
|
||||||
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||||
|
env:
|
||||||
|
STEWARD_SECRET_KEY: ci_integration_placeholder
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: steward
|
||||||
|
POSTGRES_PASSWORD: steward
|
||||||
|
POSTGRES_DB: steward
|
||||||
|
# The data directory lives in RAM (#5746): postgres images declare
|
||||||
|
# VOLUME for it, so without a tmpfs each run left an anonymous volume
|
||||||
|
# that act_runner never removes, and 2,488 of them used up the runner
|
||||||
|
# host's inodes.
|
||||||
|
options: >-
|
||||||
|
--tmpfs /var/lib/postgresql/data
|
||||||
|
--health-cmd "pg_isready -U steward"
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Boot + migrate against Postgres
|
||||||
|
# act_runner (swarm-runner) puts service containers on the default bridge
|
||||||
|
# with no embedded DNS, so the hostname `postgres` won't resolve — we
|
||||||
|
# discover the service container's bridge IP via the docker socket. The
|
||||||
|
# job container and its Postgres share a task-number name prefix.
|
||||||
|
run: |
|
||||||
|
set -euxo pipefail
|
||||||
|
echo "=== container landscape (diagnostic for filter scoping) ==="
|
||||||
|
docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}'
|
||||||
|
echo "=== end landscape ==="
|
||||||
|
# The runner's daemon is shared: another repo's integration job can be
|
||||||
|
# running beside this one, and `name=integration` matches its Postgres
|
||||||
|
# too (run 8358 took the wrong one and every test failed to connect).
|
||||||
|
# Scope to this job's own task: act_runner names every container of a
|
||||||
|
# task GITEA-ACTIONS-TASK-<n>-…, so read <n> from our own container
|
||||||
|
# (its id is in the /etc/hostname bind mount) and require one match.
|
||||||
|
SELF=$(grep -o '/containers/[0-9a-f]\{64\}/' /proc/self/mountinfo | head -n1 | cut -d/ -f3 || true)
|
||||||
|
SELF=${SELF:-$(hostname)}
|
||||||
|
TASK=$(docker inspect -f '{{.Name}}' "$SELF" | grep -o 'GITEA-ACTIONS-TASK-[0-9]*-')
|
||||||
|
test -n "$TASK"
|
||||||
|
PG=$(docker ps --filter "name=$TASK" --filter "ancestor=postgres:16-alpine" -q)
|
||||||
|
test "$(printf '%s\n' "$PG" | grep -c .)" -eq 1
|
||||||
|
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
|
||||||
|
test -n "$PG_IP"
|
||||||
|
export STEWARD_DATABASE_URL="postgresql+asyncpg://steward:steward@$PG_IP:5432/steward"
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
(echo > "/dev/tcp/$PG_IP/5432") >/dev/null 2>&1 && break
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
if command -v uv >/dev/null 2>&1; then
|
||||||
|
uv pip install --system -e '.[dev,ansible]'
|
||||||
|
else
|
||||||
|
pip install -e '.[dev,ansible]'
|
||||||
|
fi
|
||||||
|
pytest tests/integration -v -m integration --durations=10
|
||||||
|
|
||||||
|
# Image-publish lane — FabledRulebook rule 46: every push to dev/main publishes
|
||||||
|
# an immutable commit-SHA image (the rollback unit); dev moves :dev, main moves
|
||||||
|
# :latest. Gated on the three test lanes via `needs:` so a red commit never
|
||||||
|
# produces a :dev image. Mechanics mirror CI-runner's build-ci-python.yml.
|
||||||
|
publish:
|
||||||
|
needs: [lint, unit, integration]
|
||||||
|
runs-on: python-ci
|
||||||
|
container:
|
||||||
|
# ci-builder carries docker + buildx + git. The docker socket is
|
||||||
|
# auto-mounted by act_runner (valid_volumes) — do NOT add a
|
||||||
|
# container.options "-v /var/run/docker.sock:..." mount; that duplicate
|
||||||
|
# mount fails the job at "Set up job".
|
||||||
|
image: git.fabledsword.com/bvandeusen/ci-builder:latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Registry login
|
||||||
|
# The injected GITHUB_TOKEN lacks write:package scope, so docker push
|
||||||
|
# 401s with it. REGISTRY_TOKEN is a repo Actions secret holding a
|
||||||
|
# Forgejo token scoped read:package + write:package.
|
||||||
|
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.fabledsword.com -u bvandeusen --password-stdin
|
||||||
|
- name: Build (commit-SHA always; :dev on dev, :latest on main)
|
||||||
|
run: |
|
||||||
|
set -eux
|
||||||
|
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" .
|
||||||
|
else
|
||||||
|
docker build -t "$IMAGE:${{ github.sha }}" .
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Rule 164's check, BETWEEN build and push (rule 177): an image that
|
||||||
|
# can't boot, migrate and serve with no internet never reaches a tag.
|
||||||
|
# An --internal network lets it reach its own Postgres and nothing else;
|
||||||
|
# --network none can't work for an app that needs a database to start.
|
||||||
|
# Embedded DNS works on a user-defined network, unlike the services:
|
||||||
|
# containers above (rule 79), so the app finds Postgres by name.
|
||||||
|
- name: The image boots with no internet
|
||||||
|
env:
|
||||||
|
SMOKE: steward-smoke-${{ github.run_id }}
|
||||||
|
run: |
|
||||||
|
set -eux
|
||||||
|
IMAGE=git.fabledsword.com/bvandeusen/steward:${{ github.sha }}
|
||||||
|
docker network create --internal "$SMOKE-net"
|
||||||
|
docker run -d --name "$SMOKE-pg" --network "$SMOKE-net" \
|
||||||
|
--tmpfs /var/lib/postgresql/data \
|
||||||
|
-e POSTGRES_USER=steward -e POSTGRES_PASSWORD=steward -e POSTGRES_DB=steward \
|
||||||
|
postgres:16-alpine
|
||||||
|
# Waits with deadlines (rule 156); pg_isready, not /dev/tcp, which is
|
||||||
|
# a bash builtin and never succeeds under sh.
|
||||||
|
ready=""
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
if docker exec "$SMOKE-pg" pg_isready -U steward -d steward -q; then ready=1; break; fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
test -n "$ready" || { echo "FATAL: postgres not ready within 120s"; exit 1; }
|
||||||
|
|
||||||
|
docker run -d --name "$SMOKE-app" --network "$SMOKE-net" \
|
||||||
|
-e "STEWARD_DATABASE_URL=postgresql+asyncpg://steward:steward@$SMOKE-pg:5432/steward" \
|
||||||
|
-e STEWARD_SECRET_KEY=smoke-test-placeholder \
|
||||||
|
"$IMAGE"
|
||||||
|
# The image has no curl; its own Python fetches. Prints the body.
|
||||||
|
fetch() {
|
||||||
|
docker exec "$SMOKE-app" python -c 'import sys, urllib.request
|
||||||
|
r = urllib.request.urlopen("http://127.0.0.1:5000" + sys.argv[1], timeout=5)
|
||||||
|
sys.stdout.write(r.read().decode())' "$1"
|
||||||
|
}
|
||||||
|
# Migrations run before the server starts, so /health answering
|
||||||
|
# means the schema applied against a real database.
|
||||||
|
up=""
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
if fetch /health >/dev/null 2>&1; then up=1; break; fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
if [ -z "$up" ]; then
|
||||||
|
echo "FATAL: Steward never answered /health with no internet access"
|
||||||
|
docker logs "$SMOKE-app" 2>&1 | tail -80
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fetch /health | grep -q '"ok"'
|
||||||
|
|
||||||
|
# tini is PID 1, so zombies are reaped and SIGTERM reaches Steward.
|
||||||
|
test "$(docker exec "$SMOKE-app" cat /proc/1/comm)" = "tini"
|
||||||
|
|
||||||
|
# The zone database is in the image, so a reader's zone resolves
|
||||||
|
# instead of quietly falling back to UTC (milestone 490).
|
||||||
|
docker exec "$SMOKE-app" python -c 'from zoneinfo import ZoneInfo; ZoneInfo("America/New_York")'
|
||||||
|
|
||||||
|
# A fresh database lands on first-run setup: a real page, its assets
|
||||||
|
# served by Steward itself, and no third-party host in it (rule 155).
|
||||||
|
fetch / > /tmp/smoke-page.html
|
||||||
|
grep -qi "<!doctype html" /tmp/smoke-page.html
|
||||||
|
grep -q "/static/" /tmp/smoke-page.html
|
||||||
|
if grep -Eqi 'https?://(fonts\.|cdn\.|unpkg\.|cdnjs\.|ajax\.)' /tmp/smoke-page.html; then
|
||||||
|
echo "FATAL: the page references a third-party host"
|
||||||
|
grep -Eoi 'https?://[^"'"'"' ]+' /tmp/smoke-page.html
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# A backup made by the image restores into an empty database, and
|
||||||
|
# Steward starts on it (milestone 507). It carries configuration
|
||||||
|
# (the marker setting) and leaves metrics' rows out.
|
||||||
|
sql() { docker exec "$SMOKE-pg" psql -U steward -v ON_ERROR_STOP=1 -tA "$@"; }
|
||||||
|
sql -d steward -c "INSERT INTO app_settings (key, value_json, updated_at) VALUES ('smoke.backup', '\"kept\"', now())"
|
||||||
|
sql -d steward -c "INSERT INTO plugin_metrics (id, source_module, resource_name, metric_name, value, recorded_at) VALUES ('smoke-metric', 'smoke', 'smoke', 'smoke', 1, now())"
|
||||||
|
docker exec -u app "$SMOKE-app" steward backup --to /tmp/backups
|
||||||
|
sql -d steward -c "CREATE DATABASE restored"
|
||||||
|
docker exec -u app \
|
||||||
|
-e "STEWARD_DATABASE_URL=postgresql+asyncpg://steward:steward@$SMOKE-pg:5432/restored" \
|
||||||
|
"$SMOKE-app" sh -c 'steward restore /tmp/backups/steward-*.tar'
|
||||||
|
test "$(sql -d restored -c "SELECT value_json FROM app_settings WHERE key = 'smoke.backup'")" = '"kept"'
|
||||||
|
test "$(sql -d restored -c "SELECT count(*) FROM plugin_metrics")" = "0"
|
||||||
|
test "$(sql -d restored -c "SELECT count(*) FROM alembic_version")" -ge 1
|
||||||
|
docker run -d --name "$SMOKE-restored" --network "$SMOKE-net" \
|
||||||
|
-e "STEWARD_DATABASE_URL=postgresql+asyncpg://steward:steward@$SMOKE-pg:5432/restored" \
|
||||||
|
-e STEWARD_SECRET_KEY=smoke-test-placeholder \
|
||||||
|
"$IMAGE"
|
||||||
|
up=""
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
if docker exec "$SMOKE-restored" python -c 'import urllib.request
|
||||||
|
urllib.request.urlopen("http://127.0.0.1:5000/health", timeout=5)' >/dev/null 2>&1; then up=1; break; fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
if [ -z "$up" ]; then
|
||||||
|
echo "FATAL: Steward never started on the restored database"
|
||||||
|
docker logs "$SMOKE-restored" 2>&1 | tail -80
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# A stop is a clean shutdown: SIGTERM through tini to Hypercorn, which
|
||||||
|
# finishes within its graceful timeout and exits 0.
|
||||||
|
docker stop -t 30 "$SMOKE-app"
|
||||||
|
code="$(docker inspect -f '{{.State.ExitCode}}' "$SMOKE-app")"
|
||||||
|
test "$code" = "0" || { echo "FATAL: Steward exited $code on SIGTERM"; exit 1; }
|
||||||
|
|
||||||
|
- name: Push
|
||||||
|
run: |
|
||||||
|
set -eux
|
||||||
|
IMAGE=git.fabledsword.com/bvandeusen/steward
|
||||||
|
docker push "$IMAGE:${{ github.sha }}"
|
||||||
|
if [ "${{ github.ref }}" = "refs/heads/dev" ]; then
|
||||||
|
docker push "$IMAGE:dev"
|
||||||
|
elif [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||||
|
docker push "$IMAGE:latest"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Smoke logs
|
||||||
|
if: always()
|
||||||
|
run: docker logs "steward-smoke-${{ github.run_id }}-app" 2>&1 | tail -60 || true
|
||||||
|
|
||||||
|
# A failed smoke must not leave a Postgres and an app on the shared daemon.
|
||||||
|
- name: Smoke clean-up
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
docker rm -fv "steward-smoke-${{ github.run_id }}-app" "steward-smoke-${{ github.run_id }}-restored" \
|
||||||
|
"steward-smoke-${{ github.run_id }}-pg" >/dev/null 2>&1 || true
|
||||||
|
docker network rm "steward-smoke-${{ github.run_id }}-net" >/dev/null 2>&1 || true
|
||||||
|
- name: Prune dangling layers
|
||||||
|
if: always()
|
||||||
|
run: docker image prune -f
|
||||||
+10
-3
@@ -1,9 +1,10 @@
|
|||||||
# Planning files
|
# Planning files
|
||||||
docs/superpowers/
|
docs/superpowers/
|
||||||
|
|
||||||
# Plugin directory — managed as a separate repo (bvandeusen/roundtable-plugins)
|
# First-party plugins are tracked in-tree under plugins/ and ship in the image.
|
||||||
# PLUGIN_DIR in config.yaml points here at runtime
|
# Third-party plugins are mounted at runtime into the external dir
|
||||||
/plugins/
|
# (STEWARD_PLUGIN_DIR, default /data/plugins) and are not tracked here.
|
||||||
|
/plugins/**/*.zip
|
||||||
|
|
||||||
# Python
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
@@ -50,3 +51,9 @@ Thumbs.db
|
|||||||
|
|
||||||
# Playbook cache (runtime data)
|
# Playbook cache (runtime data)
|
||||||
playbook_cache/
|
playbook_cache/
|
||||||
|
|
||||||
|
# Superpowers brainstorm scratch (visual companion mockups, state)
|
||||||
|
.superpowers/
|
||||||
|
|
||||||
|
# Local dev Ansible playbooks (operator content, mounted into the container)
|
||||||
|
/ansible-dev/
|
||||||
|
|||||||
+32
-7
@@ -1,10 +1,27 @@
|
|||||||
FROM python:3.13-slim
|
# The Python CI tests on (ci-python:3.14), so the image runs what was tested.
|
||||||
|
FROM python:3.14-slim
|
||||||
|
|
||||||
RUN DEBIAN_FRONTEND=noninteractive apt-get update \
|
RUN DEBIAN_FRONTEND=noninteractive apt-get update \
|
||||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||||
iputils-ping \
|
iputils-ping \
|
||||||
# gosu — minimal privilege-drop tool used by entrypoint.sh
|
# gosu — minimal privilege-drop tool used by entrypoint.sh
|
||||||
gosu \
|
gosu \
|
||||||
|
# openssh-client — ansible-playbook reaches remote hosts over ssh
|
||||||
|
openssh-client \
|
||||||
|
# sshpass — Ansible shells out to it for first-contact password SSH
|
||||||
|
# (provisioning a fresh host before the managed key is installed)
|
||||||
|
sshpass \
|
||||||
|
# git — clones and pulls Ansible git sources (Settings > Ansible)
|
||||||
|
git \
|
||||||
|
# tini — PID 1: reaps the zombies ssh and ansible leave behind and
|
||||||
|
# forwards SIGTERM to Steward so a stop is a graceful shutdown
|
||||||
|
tini \
|
||||||
|
# tzdata — the zone database, so times show in each reader's own zone
|
||||||
|
# (milestone 490); Debian's slim base no longer carries it
|
||||||
|
tzdata \
|
||||||
|
# postgresql-client-17 — pg_dump and pg_restore for Steward's own
|
||||||
|
# backups (milestone 507); 17 reads and writes a Postgres 16 server
|
||||||
|
postgresql-client-17 \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Upgrade pip to suppress the version notice
|
# Upgrade pip to suppress the version notice
|
||||||
@@ -13,21 +30,29 @@ RUN pip install --upgrade pip
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY pyproject.toml .
|
COPY pyproject.toml .
|
||||||
COPY roundtable/ roundtable/
|
COPY steward/ steward/
|
||||||
RUN pip install --no-cache-dir .
|
# Bundle the extras whose features ship in the image: [ansible] for the
|
||||||
|
# playbook runner, [snmp] (pysnmp) for the SNMP poller, [ldap] (ldap3, pure
|
||||||
|
# Python) for LDAP sign-in under Settings -> Auth. Without them those features
|
||||||
|
# load but fail for want of their runtime dependency.
|
||||||
|
RUN pip install --no-cache-dir '.[ansible,snmp,ldap]'
|
||||||
|
|
||||||
COPY alembic.ini .
|
COPY alembic.ini .
|
||||||
|
# First-party plugins ship inside the image (bundled root at /app/plugins).
|
||||||
|
# Third-party plugins are mounted at runtime into the external root
|
||||||
|
# (STEWARD_PLUGIN_DIR, default /data/plugins).
|
||||||
|
COPY plugins/ plugins/
|
||||||
COPY entrypoint.sh /entrypoint.sh
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
RUN chmod +x /entrypoint.sh
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
# Runtime directories — owned by app user. The container starts as root
|
# Runtime directories — owned by app user. The container starts as root
|
||||||
# so the entrypoint can fix up /data permissions if needed, then drops to
|
# so the entrypoint can fix up /data permissions if needed, then drops to
|
||||||
# 'app' (uid 1000) via gosu before launching roundtable.
|
# 'app' (uid 1000) via gosu before launching steward.
|
||||||
RUN useradd -m -u 1000 app \
|
RUN useradd -m -u 1000 app \
|
||||||
&& mkdir -p /data/playbook_cache /data/plugins \
|
&& mkdir -p /data/ansible/sources /data/ansible/runs /data/ansible/playbooks /data/plugins \
|
||||||
&& chown -R app:app /data /app
|
&& chown -R app:app /data /app
|
||||||
|
|
||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
|
|
||||||
ENTRYPOINT ["/entrypoint.sh"]
|
ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"]
|
||||||
CMD ["roundtable", "--host", "0.0.0.0", "--port", "5000"]
|
CMD ["steward", "--host", "0.0.0.0", "--port", "5000"]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Roundtable
|
# Steward
|
||||||
|
|
||||||
A self-hosted network monitoring and infrastructure management hub for home servers. Roundtable gives you a single pane of glass over your hosts, services, and automation — with live-updating dashboards, alerting, and Ansible integration.
|
A self-hosted network monitoring and infrastructure management hub for home servers. Steward gives you a single pane of glass over your hosts, services, and automation — with live-updating dashboards, alerting, and Ansible integration.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -21,13 +21,49 @@ No JavaScript framework. No build step. No external workers or message brokers.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# Set ROUNDTABLE_DATABASE_URL in .env
|
# Set STEWARD_DATABASE_URL in .env
|
||||||
|
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Open `http://localhost:5000`. On first run you'll be prompted to create the admin account.
|
Open `http://localhost:5000`. On first run you'll be prompted to create the admin account.
|
||||||
|
|
||||||
|
This compose file builds the image locally and bind-mounts the source for live editing. To run a persistent instance off the published CI image instead, see **Remote dev instance** below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Remote dev instance
|
||||||
|
|
||||||
|
For a long-lived instance on a remote server that tracks the `dev` CI line, use `compose.deploy.yml`. It pulls the published image (`git.fabledsword.com/bvandeusen/steward:dev`) rather than building, runs no source bind-mounts, and persists data in named volumes.
|
||||||
|
|
||||||
|
**One-time prerequisite — registry push token (on the Git host).** CI publishes the image, so the FabledSteward repo needs a push credential. The injected `GITHUB_TOKEN` lacks `write:package`, so create a Forgejo token scoped **`read:package` + `write:package`** and add it as the repo Actions secret **`REGISTRY_TOKEN`**. Until this exists, the `publish` CI job 401s and no `:dev` image is produced.
|
||||||
|
|
||||||
|
**Standup (on the server):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Authenticate to the registry (read:package token is enough to pull)
|
||||||
|
docker login git.fabledsword.com -u <user>
|
||||||
|
|
||||||
|
# 2. Configure secrets
|
||||||
|
cp .env.example .env
|
||||||
|
# Set POSTGRES_PASSWORD. Leave STEWARD_SECRET_KEY unset to let the app
|
||||||
|
# generate + persist one on the /data volume.
|
||||||
|
|
||||||
|
# 3. Pull + boot
|
||||||
|
docker compose -f compose.deploy.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://<server>:5000` and create the admin account on first run.
|
||||||
|
|
||||||
|
**Update to the latest dev build:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f compose.deploy.yml pull
|
||||||
|
docker compose -f compose.deploy.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Every push to `dev` that goes CI-green publishes a fresh `:dev` (and an immutable `:<commit-sha>` for rollback). To pin a specific commit, change the `image:` tag in `compose.deploy.yml` to `:<sha>`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick Start — Bare Metal
|
## Quick Start — Bare Metal
|
||||||
@@ -41,7 +77,7 @@ pip install -e .
|
|||||||
cp config.example.yaml config.yaml
|
cp config.example.yaml config.yaml
|
||||||
# Edit config.yaml — set database.url at minimum
|
# Edit config.yaml — set database.url at minimum
|
||||||
|
|
||||||
roundtable --host 0.0.0.0 --port 5000
|
steward --host 0.0.0.0 --port 5000
|
||||||
```
|
```
|
||||||
|
|
||||||
ICMP ping requires `CAP_NET_RAW` or the `setuid` ping binary. TCP mode (the default) needs no elevated privileges.
|
ICMP ping requires `CAP_NET_RAW` or the `setuid` ping binary. TCP mode (the default) needs no elevated privileges.
|
||||||
@@ -54,7 +90,7 @@ Only two things must be set to run the app:
|
|||||||
|
|
||||||
| What | How |
|
| What | How |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Database URL | `ROUNDTABLE_DATABASE_URL` env var or `database.url` in `config.yaml` |
|
| Database URL | `STEWARD_DATABASE_URL` env var or `database.url` in `config.yaml` |
|
||||||
| Secret key | Auto-generated on first run and saved to `/data/secret.key` |
|
| Secret key | Auto-generated on first run and saved to `/data/secret.key` |
|
||||||
|
|
||||||
Everything else — SMTP, webhooks, monitor intervals, Ansible sources, plugin settings — is configured through the web UI at `/settings/`.
|
Everything else — SMTP, webhooks, monitor intervals, Ansible sources, plugin settings — is configured through the web UI at `/settings/`.
|
||||||
@@ -83,7 +119,10 @@ See `docs/plugins/` for a full plugin development guide.
|
|||||||
| `docs/core/monitors.md` | Ping and DNS monitors: probe logic, metrics emitted, data models |
|
| `docs/core/monitors.md` | Ping and DNS monitors: probe logic, metrics emitted, data models |
|
||||||
| `docs/core/alerting.md` | Alert rules, state machine, notification channels, template variables |
|
| `docs/core/alerting.md` | Alert rules, state machine, notification channels, template variables |
|
||||||
| `docs/core/ansible.md` | Playbook sources, run lifecycle, SSE streaming |
|
| `docs/core/ansible.md` | Playbook sources, run lifecycle, SSE streaming |
|
||||||
|
| `docs/core/backups.md` | Steward's daily backup to an S3 bucket (Fabled Archives), what it holds, and `steward restore` |
|
||||||
| `docs/plugins/overview.md` | Plugin system: how loading works, plugin.yaml schema, required exports |
|
| `docs/plugins/overview.md` | Plugin system: how loading works, plugin.yaml schema, required exports |
|
||||||
| `docs/plugins/writing-a-plugin.md` | Step-by-step plugin development guide |
|
| `docs/plugins/writing-a-plugin.md` | Step-by-step plugin development guide |
|
||||||
| `docs/plugins/traefik.md` | Traefik plugin: config, metrics, alert rule examples |
|
| `docs/plugins/traefik.md` | Traefik plugin: config, metrics, alert rule examples |
|
||||||
|
| `docs/plugins/prometheus.md` | Prometheus plugin: scraping any /metrics endpoint, how series are stored, the Fabled Archives recipe |
|
||||||
|
| `docs/plugins/prometheus-widgets.md` | Prometheus widget templates: writing, sharing and copying one in, every block, the format reference |
|
||||||
| `docs/reference/code-map.md` | Where every key function, model, and route lives in the codebase |
|
| `docs/reference/code-map.md` | Where every key function, model, and route lives in the codebase |
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
[alembic]
|
[alembic]
|
||||||
script_location = roundtable/migrations
|
script_location = steward/migrations
|
||||||
prepend_sys_path = .
|
prepend_sys_path = .
|
||||||
version_path_separator = os
|
version_path_separator = os
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# CI Requirements — Steward
|
||||||
|
|
||||||
|
> Spec: https://git.fabledsword.com/bvandeusen/CI-runner/src/branch/main/docs/process.md
|
||||||
|
|
||||||
|
## Runtime image
|
||||||
|
|
||||||
|
git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||||
|
|
||||||
|
## Image deps used
|
||||||
|
|
||||||
|
- python 3.14
|
||||||
|
- ruff (analyzer for `steward/`, `plugins/`, `tests/`)
|
||||||
|
- docker CLI (integration lane: bridge-IP discovery of the Postgres service container)
|
||||||
|
|
||||||
|
## Per-job tool installs
|
||||||
|
|
||||||
|
- `uv pip install --system -e '.[dev]'` (pip fallback) — in the `unit` job.
|
||||||
|
- `uv pip install --system -e '.[dev,ansible]'` (pip fallback) — in the
|
||||||
|
`integration` job, which boots the real app and exercises the Ansible runner,
|
||||||
|
so it needs the `ansible` extra (`ansible>=10,<13`) at import time.
|
||||||
|
|
||||||
|
Steward declares its deps in `pyproject.toml`; the `[dev]` extra adds
|
||||||
|
`pytest` + `pytest-asyncio`. No `requirements.txt` is tracked.
|
||||||
|
|
||||||
|
## Lanes
|
||||||
|
|
||||||
|
- **lint** — `ruff check steward/ plugins/ tests/`, no dep install.
|
||||||
|
- **unit** — `pytest -m "not integration"`. The whole current suite runs here:
|
||||||
|
it builds the app with `testing=True`, which mocks `db_sessionmaker`, so no DB
|
||||||
|
is touched. First-party plugins are bundled in-tree under `plugins/`, so plugin
|
||||||
|
unit tests import them directly — no cross-repo checkout.
|
||||||
|
- **integration** — `pytest tests/integration -m integration` against a
|
||||||
|
`postgres:16-alpine` service. The single test boots the real app
|
||||||
|
(`create_app(testing=False)`), which runs core + every bundled plugin's
|
||||||
|
migrations, then round-trips the DB. This is the guard that the folded-in
|
||||||
|
plugin migration graph resolves.
|
||||||
|
- **publish** — builds the runtime `Dockerfile` and pushes to the package
|
||||||
|
registry. Runs in `ci-builder:latest` (docker + buildx, socket auto-mounted),
|
||||||
|
gated on `needs: [lint, unit, integration]` so a red commit never ships an
|
||||||
|
image. Publishes `git.fabledsword.com/bvandeusen/steward:<sha>` always, plus
|
||||||
|
`:dev` on `dev` and `:latest` on `main` (FabledRulebook rule 46). Needs the
|
||||||
|
`REGISTRY_TOKEN` repo Actions secret (`read:package` + `write:package`) —
|
||||||
|
the injected `GITHUB_TOKEN` can't push packages.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
- Integration uses Fabled-Git Actions `services:` + a socket-discovered bridge IP
|
||||||
|
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
|
||||||
|
pattern; Steward's is the same shape minus Redis and minus sharding.
|
||||||
|
- The job name `integration` has no underscore — `act_runner` strips underscores
|
||||||
|
from job names when building service-container labels, so the `docker ps`
|
||||||
|
name filter uses the bare job name.
|
||||||
|
- Migrations run via the app itself (`create_app` → `run_core_migrations`, async
|
||||||
|
through `asyncpg`), so the integration lane needs no separate `alembic upgrade`
|
||||||
|
step and no psycopg/sync driver.
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Remote deployment — pulls the published :dev image instead of building locally.
|
||||||
|
#
|
||||||
|
# This is NOT the local-dev compose. `docker-compose.yml` bind-mounts ./steward
|
||||||
|
# + ./plugins and runs --debug for live editing; THIS file runs the image as the
|
||||||
|
# source of truth (no bind-mounts, no reloader) for a persistent remote instance
|
||||||
|
# that tracks the dev CI line.
|
||||||
|
#
|
||||||
|
# Standup + update runbook: see README → "Remote dev instance".
|
||||||
|
|
||||||
|
services:
|
||||||
|
steward:
|
||||||
|
image: git.fabledsword.com/bvandeusen/steward:dev
|
||||||
|
container_name: steward
|
||||||
|
# Re-pull :dev on every `up` so restarting the stack picks up the latest
|
||||||
|
# green dev build without a manual `docker pull`.
|
||||||
|
pull_policy: always
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
networks:
|
||||||
|
- backend
|
||||||
|
volumes:
|
||||||
|
# /data holds the auto-generated secret key, third-party plugins, and the
|
||||||
|
# Ansible playbook cache — all of which must survive image updates. No
|
||||||
|
# source bind-mounts: core + first-party plugins live inside the image.
|
||||||
|
#
|
||||||
|
# The container runs as the unprivileged 'app' user (uid 1000), so /data
|
||||||
|
# MUST be writable by uid 1000. A named docker volume (below) gets that
|
||||||
|
# automatically. If you bind-mount a host/NFS path instead, chown it to
|
||||||
|
# 1000:1000 (and mind NFS root_squash) — otherwise the app can't persist
|
||||||
|
# its secret key, mints a fresh one each boot, and every encrypted secret
|
||||||
|
# (managed SSH key, SMTP/OIDC/LDAP creds) becomes unrecoverable. The app
|
||||||
|
# now refuses to start in that state rather than silently degrade.
|
||||||
|
- app_data:/data
|
||||||
|
environment:
|
||||||
|
- STEWARD_DATABASE_URL=postgresql+asyncpg://steward:${POSTGRES_PASSWORD:-steward}@db/steward
|
||||||
|
# STEWARD_SECRET_KEY is intentionally absent — the app generates a random
|
||||||
|
# key on first boot and persists it to /data/secret.key, which the app_data
|
||||||
|
# volume keeps across image updates. On multi-node Swarm with a shared bind
|
||||||
|
# mount, pin STEWARD_SECRET_KEY (env or a docker secret) instead, so the key
|
||||||
|
# never depends on filesystem ownership being correct on every node.
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
networks:
|
||||||
|
- backend
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: steward
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-steward}
|
||||||
|
POSTGRES_DB: steward
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
# No host port published — db is reachable only on the backend network.
|
||||||
|
# No healthcheck: a slow disk (NFS) failed pg_isready and the orchestrator
|
||||||
|
# killed a healthy database mid-write (#5275). Steward waits for the
|
||||||
|
# database itself, at startup and per request (steward/database.py).
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
networks:
|
||||||
|
backend:
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
|
app_data:
|
||||||
+11
-3
@@ -1,9 +1,9 @@
|
|||||||
# Roundtable — Bootstrap Configuration Example
|
# Steward — Bootstrap Configuration Example
|
||||||
#
|
#
|
||||||
# The only REQUIRED setting is the database URL.
|
# The only REQUIRED setting is the database URL.
|
||||||
# Set it via env var (recommended for Docker):
|
# Set it via env var (recommended for Docker):
|
||||||
#
|
#
|
||||||
# ROUNDTABLE_DATABASE_URL=postgresql+asyncpg://user:pass@host/db
|
# STEWARD_DATABASE_URL=postgresql+asyncpg://user:pass@host/db
|
||||||
#
|
#
|
||||||
# All other settings (SMTP, webhook, ansible, plugins, retention)
|
# All other settings (SMTP, webhook, ansible, plugins, retention)
|
||||||
# are stored in the database and managed via the Settings UI at /settings/.
|
# are stored in the database and managed via the Settings UI at /settings/.
|
||||||
@@ -11,7 +11,15 @@
|
|||||||
# config.yaml is optional. Use it only if you prefer file-based bootstrap.
|
# config.yaml is optional. Use it only if you prefer file-based bootstrap.
|
||||||
|
|
||||||
database:
|
database:
|
||||||
url: "postgresql+asyncpg://roundtable:password@localhost/roundtable"
|
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.
|
||||||
|
|||||||
+31
-17
@@ -1,35 +1,49 @@
|
|||||||
services:
|
services:
|
||||||
roundtable:
|
steward:
|
||||||
build: .
|
build: .
|
||||||
container_name: roundtable
|
container_name: steward
|
||||||
image: roundtable:latest
|
image: steward:latest
|
||||||
|
# Dev: --debug turns on Quart's auto-reloader so edits to the mounted source
|
||||||
|
# below take effect without a rebuild.
|
||||||
|
command: ["steward", "--host", "0.0.0.0", "--port", "5000", "--debug"]
|
||||||
ports:
|
ports:
|
||||||
- "5000:5000"
|
- "5000:5000"
|
||||||
volumes:
|
volumes:
|
||||||
- app_data:/data
|
- app_data:/data
|
||||||
- ./plugins:/app/plugins:ro
|
# Live-edit core + first-party plugins without rebuilding. PYTHONPATH=/app
|
||||||
- /mnt/Data/traefik/log:/var/log/traefik:ro
|
# (below) makes this mounted source win over the image-installed package.
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- ./steward:/app/steward
|
||||||
|
- ./plugins:/app/plugins
|
||||||
|
# Dev playbooks: edit on the host under ./ansible-dev/, register a *local*
|
||||||
|
# Ansible source pointing at /srv/ansible in Settings → Ansible.
|
||||||
|
- ./ansible-dev:/srv/ansible
|
||||||
|
# Optional host mounts — enable the matching plugin in Settings first, then
|
||||||
|
# uncomment (the traefik path must exist on this host):
|
||||||
|
# - /mnt/Data/traefik/log:/var/log/traefik:ro # traefik plugin
|
||||||
|
# - /var/run/docker.sock:/var/run/docker.sock:ro # docker plugin
|
||||||
environment:
|
environment:
|
||||||
- ROUNDTABLE_DATABASE_URL=postgresql+asyncpg://fabledscryer:fabledscryer@db/fabledscryer
|
- STEWARD_DATABASE_URL=postgresql+asyncpg://steward:steward@db/steward
|
||||||
|
# Dev-only fixed key so sessions survive restarts. Replace in production.
|
||||||
|
- STEWARD_SECRET_KEY=dev-secret-key-change-me
|
||||||
|
- PYTHONPATH=/app
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
- db
|
||||||
condition: service_healthy
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: fabledscryer
|
POSTGRES_USER: steward
|
||||||
POSTGRES_PASSWORD: fabledscryer
|
POSTGRES_PASSWORD: steward
|
||||||
POSTGRES_DB: fabledscryer
|
POSTGRES_DB: steward
|
||||||
|
ports:
|
||||||
|
# Exposed for dev convenience (psql from the host). Drop for prod.
|
||||||
|
- "5432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
healthcheck:
|
# No healthcheck: a slow disk (NFS) failed pg_isready and the orchestrator
|
||||||
test: ["CMD-SHELL", "pg_isready -U fabledscryer"]
|
# killed a healthy database mid-write (#5275). Steward waits for the
|
||||||
interval: 5s
|
# database itself, at startup and per request (steward/database.py).
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
+79
-13
@@ -1,12 +1,12 @@
|
|||||||
# Architecture
|
# Architecture
|
||||||
|
|
||||||
Roundtable is a single Quart (async Python) process. There are no separate worker processes, no message brokers, and no build pipeline for the frontend. All monitoring, scheduling, and request handling runs on a single asyncio event loop.
|
Steward is a single Quart (async Python) process. There are no separate worker processes, no message brokers, and no build pipeline for the frontend. All monitoring, scheduling, and request handling runs on a single asyncio event loop.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Startup Sequence
|
## Startup Sequence
|
||||||
|
|
||||||
`create_app()` in `roundtable/app.py` runs these steps synchronously before the event loop starts:
|
`create_app()` in `steward/app.py` runs these steps synchronously before the event loop starts:
|
||||||
|
|
||||||
| Step | What happens |
|
| Step | What happens |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -32,23 +32,47 @@ All routes are Quart Blueprints registered in `app.py`:
|
|||||||
|
|
||||||
| Prefix | Blueprint | Module |
|
| Prefix | Blueprint | Module |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `/auth/` | `auth_bp` | `roundtable/auth/routes.py` |
|
| `/auth/` | `auth_bp` | `steward/auth/routes.py` |
|
||||||
| `/` | `dashboard_bp` | `roundtable/dashboard/routes.py` |
|
| `/` | `dashboard_bp` | `steward/dashboard/routes.py` |
|
||||||
| `/hosts/` | `hosts_bp` | `roundtable/hosts/routes.py` |
|
| `/hosts/` | `hosts_bp` | `steward/hosts/routes.py` |
|
||||||
| `/ping/` | `ping_bp` | `roundtable/ping/routes.py` |
|
| `/ping/` | `ping_bp` | `steward/ping/routes.py` |
|
||||||
| `/dns/` | `dns_bp` | `roundtable/dns/routes.py` |
|
| `/dns/` | `dns_bp` | `steward/dns/routes.py` |
|
||||||
| `/alerts/` | `alerts_bp` | `roundtable/alerts/routes.py` |
|
| `/alerts/` | `alerts_bp` | `steward/alerts/routes.py` |
|
||||||
| `/ansible/` | `ansible_bp` | `roundtable/ansible/routes.py` |
|
| `/ansible/` | `ansible_bp` | `steward/ansible/routes.py` |
|
||||||
| `/settings/` | `settings_bp` | `roundtable/settings/routes.py` |
|
| `/settings/` | `settings_bp` | `steward/settings/routes.py` |
|
||||||
| `/plugins/<name>/` | plugin blueprint | `plugins/<name>/routes.py` |
|
| `/plugins/<name>/` | plugin blueprint | `plugins/<name>/routes.py` |
|
||||||
|
|
||||||
Plugin blueprints are mounted automatically by `load_plugins()` using the plugin directory name as the URL prefix.
|
Plugin blueprints are mounted automatically by `load_plugins()` using the plugin directory name as the URL prefix.
|
||||||
|
|
||||||
|
`/ai-access/` (`ai_access_bp`, `steward/ai_access/routes.py`) is where a signed-in user manages their API tokens.
|
||||||
|
|
||||||
|
### `/mcp`: outside the blueprint pipeline
|
||||||
|
|
||||||
|
`mount_mcp(app)` (`steward/mcp/server.py`) wraps `app.asgi_app`. A request to
|
||||||
|
`/mcp` is handed to the MCP SDK's streamable-HTTP app before Quart sees it, so
|
||||||
|
no blueprint, session cookie or `before_request` hook runs for it. Each
|
||||||
|
request then goes through these steps:
|
||||||
|
|
||||||
|
1. **Database gate.** If the database is unreachable, the request gets a 503
|
||||||
|
with a readable message.
|
||||||
|
2. **Bearer resolution.** `api_tokens.resolve_token()` turns the token into a
|
||||||
|
`Caller` (user, effective role, token id). A missing, revoked or expired
|
||||||
|
token gets a 401.
|
||||||
|
3. **Caller context.** The `Caller` and the app are set in ContextVars
|
||||||
|
(`steward/mcp/_context.py`). Tools read them through `current_caller()` and
|
||||||
|
`mcp_app()`, because Quart's `current_app` and `session` do not exist there.
|
||||||
|
4. **Role gate.** `StewardMCPServer.refusal()` hides and refuses tools above
|
||||||
|
the caller's role, and plugin tools whose plugin is disabled.
|
||||||
|
|
||||||
|
The SDK's session manager runs in Quart's `before_serving`/`after_serving`
|
||||||
|
lifecycle. The server is stateless: no MCP session outlives a request. See
|
||||||
|
[MCP server](core/mcp.md) for the security model and the tools.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Scheduler
|
## Scheduler
|
||||||
|
|
||||||
`roundtable/core/scheduler.py` exports two things:
|
`steward/core/scheduler.py` exports two things:
|
||||||
|
|
||||||
- `ScheduledTask` dataclass — holds a `name`, `coro_factory` (zero-argument callable returning a coroutine), `interval_seconds`, and optional `run_on_startup` flag
|
- `ScheduledTask` dataclass — holds a `name`, `coro_factory` (zero-argument callable returning a coroutine), `interval_seconds`, and optional `run_on_startup` flag
|
||||||
- `start_scheduler(tasks)` — async function that loops every second, calling `asyncio.create_task()` for each task whose interval has elapsed
|
- `start_scheduler(tasks)` — async function that loops every second, calling `asyncio.create_task()` for each task whose interval has elapsed
|
||||||
@@ -63,6 +87,48 @@ Core tasks registered in `app._task_registry`:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Metric Store
|
||||||
|
|
||||||
|
Every plugin and monitor records into one store keyed by (source, resource, metric):
|
||||||
|
- `plugin_metrics` holds raw samples for `metrics.retention.raw_days` (7 by default).
|
||||||
|
- `plugin_metric_latest` holds each key's newest value.
|
||||||
|
- `data_cleanup` rolls raw samples older than the raw window into `plugin_metrics_hourly`, kept for `metrics.retention.rollup_days` (90 by default).
|
||||||
|
|
||||||
|
Each rolled-up hour keeps three figures:
|
||||||
|
- `value_avg`: the hour's average.
|
||||||
|
- `value_max`: its peak.
|
||||||
|
- `value_p95`: the level it held for all but its briefest 5%, about 3 minutes.
|
||||||
|
|
||||||
|
A two-minute spike raises the peak and leaves the p95 low; an hour spent at that level raises both. Learned normals use this to tell a recurring spike from a held level. Hours rolled up before the p95 was kept have it NULL; read those as their average.
|
||||||
|
|
||||||
|
### Normals and anomalies
|
||||||
|
|
||||||
|
A metric a plugin registers with `learn_normal()` gets a normal per hour of the day, then per hour of the week once three weeks of history exist (`metric_norms`, relearned hourly by `metric_norms`; `steward/core/norms.py`).
|
||||||
|
|
||||||
|
Every minute, `metric_anomalies` averages each learned key's last 15 minutes and compares it with the normal for that hour. A level above the usual hold ×1.25, and above the metric's floor, opens an anomaly (`steward/core/anomalies.py`):
|
||||||
|
- `metric_anomalies` holds one row per item to review. A repeat while the row still waits joins it as another occurrence (`metric_anomaly_spans`).
|
||||||
|
- A row is `to_review` until someone marks it `normal` or `real` (Alerts → Anomalies, or MCP `review_anomaly`). An ended row nobody reviews within `anomalies.review_days` (14) becomes `lapsed`.
|
||||||
|
- Learning skips every hour an occurrence covers unless its anomaly was marked `normal`, so a new level is never learned without review.
|
||||||
|
- `data_cleanup` prunes reviewed and lapsed rows at `data.retention_days`; a row still to review is never pruned.
|
||||||
|
|
||||||
|
What learns a normal, and the floor each departure must clear (Settings → Thresholds → Normals, `norms.floor.*`; a floor may be a function, `floor_setting()`, read at each pass):
|
||||||
|
|
||||||
|
| Kind | Metric | Floor (default) |
|
||||||
|
|---|---|---|
|
||||||
|
| Host CPU | `host_agent` / `cpu_pct` | 10 points |
|
||||||
|
| Host memory | `host_agent` / `mem_used_pct` | 5 points |
|
||||||
|
| Host load per core | `host_agent` / `load_pct` (load_1m ÷ cores × 100, recorded at ingest) | 10 points |
|
||||||
|
| Container CPU | `docker` / `cpu_pct` (100% is one core) | 10 points of one core |
|
||||||
|
| Container memory | `docker` / `mem_pct` (of its limit, or of host RAM when it has none) | 5 points |
|
||||||
|
| Monitor response time | `<type>` / `response_ms` | 100 ms |
|
||||||
|
| Router 4xx share, p95 | `traefik` / `error_rate_4xx_pct`, `latency_p95_ms` | 2 points, 50 ms |
|
||||||
|
|
||||||
|
Host metrics learn with `parts=False`: the host learns, its cores (`<host>:core<N>`) don't. Disk space, a monitor that is down, an agent gone quiet and a router's 5xx share are failures and keep their fixed cutoffs.
|
||||||
|
|
||||||
|
Pages grade by the normal through `anomalies.graded()` (hosts' `host_grades` and `worst_reading(normals=)`, `docker.service.usage(thresholds=, norms=)`, `monitors.service.latency_grade`): a reading past its cutoff but within its normal reads healthy and says what is usual at this hour. The host, monitor, container and Traefik pages list their anomalies with `_anomaly_card.html`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Database Session Pattern
|
## Database Session Pattern
|
||||||
|
|
||||||
`app.db_sessionmaker` is an SQLAlchemy `async_sessionmaker`. All DB access follows this pattern:
|
`app.db_sessionmaker` is an SQLAlchemy `async_sessionmaker`. All DB access follows this pattern:
|
||||||
@@ -93,9 +159,9 @@ This means the only file you must touch to get the app running is the database U
|
|||||||
|
|
||||||
No JavaScript framework, no build step. The frontend is:
|
No JavaScript framework, no build step. The frontend is:
|
||||||
|
|
||||||
- **Jinja2 templates** rendered server-side (`roundtable/templates/`)
|
- **Jinja2 templates** rendered server-side (`steward/templates/`)
|
||||||
- **HTMX** for live-updating fragments (dashboard widgets, ping pills, DNS status)
|
- **HTMX** for live-updating fragments (dashboard widgets, ping pills, DNS status)
|
||||||
- A single CSS design system in `roundtable/templates/base.html` using CSS custom properties
|
- A single CSS design system in `steward/templates/base.html` using CSS custom properties
|
||||||
|
|
||||||
Live-updating widgets use HTMX polling:
|
Live-updating widgets use HTMX polling:
|
||||||
```html
|
```html
|
||||||
|
|||||||
+132
-14
@@ -8,13 +8,13 @@ Alert rules evaluate every metric that flows through `record_metric()`. There is
|
|||||||
|
|
||||||
When any monitor or plugin calls `record_metric()`:
|
When any monitor or plugin calls `record_metric()`:
|
||||||
|
|
||||||
1. The metric value is written to the `plugin_metrics` table
|
1. The metric value is written to the `plugin_metrics` table, and `plugin_metric_latest` moves to it if it is the newest sample of that metric
|
||||||
2. All enabled `AlertRule` rows matching `(source_module, resource_name, metric_name)` are loaded
|
2. All enabled `AlertRule` rows matching `(source_module, resource_name, metric_name)` are loaded
|
||||||
3. Each matching rule is evaluated against the new value
|
3. Each matching rule is evaluated against the new value
|
||||||
4. If a state transition occurs, an `AlertEvent` row is written
|
4. If a state transition occurs, an `AlertEvent` row is written
|
||||||
5. Notification I/O is deferred outside the transaction via `asyncio.create_task()`
|
5. Notification I/O is deferred outside the transaction via `asyncio.create_task()`
|
||||||
|
|
||||||
**Key function:** `roundtable/core/alerts.py` → `record_metric(session, source_module, resource_name, metric_name, value)`
|
**Key function:** `steward/core/alerts.py` → `record_metric(session, source_module, resource_name, metric_name, value)`
|
||||||
|
|
||||||
`record_metric()` must always be called inside an active transaction:
|
`record_metric()` must always be called inside an active transaction:
|
||||||
|
|
||||||
@@ -32,18 +32,25 @@ Notifications are sent via `asyncio.create_task()` after the function returns, s
|
|||||||
Each `AlertRule` has one associated `AlertState` row. The state transitions are:
|
Each `AlertRule` has one associated `AlertState` row. The state transitions are:
|
||||||
|
|
||||||
```
|
```
|
||||||
inactive ──(breached)──► pending ──(consecutive count met)──► FIRING
|
inactive ──(breached)──► pending ──(N breaching checks in a row)──► FIRING
|
||||||
│ │
|
▲ │ │
|
||||||
└──(recovered)──► inactive (no notification) │
|
└──(an OK check)───────┘ │
|
||||||
│
|
FIRING ──(acknowledged)──► ACKNOWLEDGED
|
||||||
FIRING ──(recovered)──► RESOLVED ──► inactive
|
FIRING / ACKNOWLEDGED ──(M OK checks in a row)──► RESOLVED ──► inactive
|
||||||
FIRING ──(acknowledged)──► ACKNOWLEDGED
|
|
||||||
ACKNOWLEDGED ──(recovered)──► RESOLVED ──► inactive
|
|
||||||
ACKNOWLEDGED ──(re-breached)──► FIRING
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
N is the rule's consecutive checks, M its checks to clear. An acknowledgement
|
||||||
|
holds for as long as the breach lasts: only a resolve clears it, and a breaching
|
||||||
|
check never sends an acknowledged alert back to firing.
|
||||||
|
|
||||||
`RESOLVED` is transient — the evaluator writes the event and immediately sets state back to `inactive` within the same transaction. `RESOLVED` never persists as a final state in the DB.
|
`RESOLVED` is transient — the evaluator writes the event and immediately sets state back to `inactive` within the same transaction. `RESOLVED` never persists as a final state in the DB.
|
||||||
|
|
||||||
|
Every transition is an `AlertEvent`, the history the Alerts → History page and
|
||||||
|
MCP `get_alert_history` read. A transition a person made carries them in
|
||||||
|
`actor_id`: an acknowledgement (firing → acknowledged), or a rule edit, disable
|
||||||
|
or enable that reset the alert (→ resolved, with a note such as "rule
|
||||||
|
disabled"). Transitions caused by readings, or by Steward itself, have none.
|
||||||
|
|
||||||
### State Definitions
|
### State Definitions
|
||||||
|
|
||||||
| State | Meaning |
|
| State | Meaning |
|
||||||
@@ -51,7 +58,7 @@ inactive ──(breached)──► pending ──(consecutive count met)──
|
|||||||
| `inactive` | Threshold not breached |
|
| `inactive` | Threshold not breached |
|
||||||
| `pending` | Threshold breached but consecutive count not yet met; no notification sent |
|
| `pending` | Threshold breached but consecutive count not yet met; no notification sent |
|
||||||
| `firing` | Consecutive count met; FIRING notification sent |
|
| `firing` | Consecutive count met; FIRING notification sent |
|
||||||
| `acknowledged` | Operator acknowledged; suppresses repeat notifications; auto-clears on recovery |
|
| `acknowledged` | Someone has seen it; no further notifications; clears when the readings recover |
|
||||||
| `resolved` | Transient — notification sent, immediately transitions to `inactive` |
|
| `resolved` | Transient — notification sent, immediately transitions to `inactive` |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -68,6 +75,39 @@ Alert rules are created in the UI at `/alerts/`. Each rule requires:
|
|||||||
- **Threshold** — numeric value
|
- **Threshold** — numeric value
|
||||||
- **Consecutive failures required** — how many consecutive breaches before FIRING (default 1)
|
- **Consecutive failures required** — how many consecutive breaches before FIRING (default 1)
|
||||||
|
|
||||||
|
The form offers only what has reported, read from `plugin_metric_latest`.
|
||||||
|
For the host agent, a **Host** is picked first, then a **Part** of it: "The
|
||||||
|
whole host", then its mounts, CPU cores, disks and so on, under a heading for
|
||||||
|
each kind. A part that reports nothing a rule can watch, such as an interface's
|
||||||
|
traffic, isn't listed. The **Metric** list holds only what the picked resource
|
||||||
|
reports. Under it, a preview shows the metric's current reading and its last
|
||||||
|
24 hours (lowest to highest, with an hourly sparkline; it loads just after the
|
||||||
|
page, since a day of samples can take a while to read), so the threshold can be
|
||||||
|
set against real numbers.
|
||||||
|
|
||||||
|
### Rules that cover hosts
|
||||||
|
|
||||||
|
A rule on `host_agent` can cover **every host**, or **every host in an
|
||||||
|
inventory group**, instead of naming one resource (#5464). Inventory groups
|
||||||
|
are Steward's grouping system: a host is in a group through its linked
|
||||||
|
Ansible target.
|
||||||
|
|
||||||
|
A covering rule has no state of its own. Steward keeps one ordinary rule per
|
||||||
|
covered host under it, named `<rule>: <host>`, so each host's alert keeps its
|
||||||
|
own state, history and acknowledgement. Each minute (the `alert_reconcile`
|
||||||
|
task), and whenever the rule is saved:
|
||||||
|
|
||||||
|
- a host that has reported in the last 7 days, and is covered, gets a rule;
|
||||||
|
- a host that is no longer covered loses its rule, and that rule's history;
|
||||||
|
- the hosts' rules take the covering rule's condition, checks, notification
|
||||||
|
setting, action and on/off.
|
||||||
|
|
||||||
|
A host's rule is changed through its covering rule; editing, disabling or
|
||||||
|
deleting it alone is refused. What a rule covers is fixed once it exists, but
|
||||||
|
a group rule can move to another group. Over MCP, `create_alert_rule` takes
|
||||||
|
`covers` ("all_hosts" or "group") and `group`, and `list_alert_rules` shows
|
||||||
|
each covering rule's `hosts` and each host's rule's `part_of`.
|
||||||
|
|
||||||
### Available Metrics by Module
|
### Available Metrics by Module
|
||||||
|
|
||||||
| `source_module` | `metric_name` | Description |
|
| `source_module` | `metric_name` | Description |
|
||||||
@@ -83,6 +123,24 @@ Alert rules are created in the UI at `/alerts/`. Each rule requires:
|
|||||||
| `traefik` | `latency_p95_ms` | Approximate p95 latency (ms) |
|
| `traefik` | `latency_p95_ms` | Approximate p95 latency (ms) |
|
||||||
| `traefik` | `latency_p99_ms` | Approximate p99 latency (ms) |
|
| `traefik` | `latency_p99_ms` | Approximate p99 latency (ms) |
|
||||||
|
|
||||||
|
#### Steward itself
|
||||||
|
|
||||||
|
Source `steward`, resource `process`, recorded once a minute by the
|
||||||
|
self-monitor tick (`steward/core/self_monitor.py`; the ingest tally is in
|
||||||
|
`steward/core/health.py`). Settings → System (`/settings/system/`, admin)
|
||||||
|
shows each one now and over the last day, with a verdict above that names
|
||||||
|
failed agent posts, a gap in watching, or open files near the limit.
|
||||||
|
|
||||||
|
| `metric_name` | Description |
|
||||||
|
|---|---|
|
||||||
|
| `ingest_posts` | Host-agent posts Steward took in the last minute |
|
||||||
|
| `ingest_failed` | Posts in the last minute that didn't land (handler raised, or the agent gave up) |
|
||||||
|
| `ingest_slowest_ms` | The slowest post in the last minute; agents give up at 10 s |
|
||||||
|
| `db_roundtrip_ms` | A `SELECT 1`, connection checkout included |
|
||||||
|
| `unwatched_secs` | The gap before this tick when it exceeds `watch.GAP_S` (a deploy or an outage); 0 when unbroken |
|
||||||
|
| `cleanup_secs` | How long the last hourly retention and rollup run took |
|
||||||
|
| `open_fds` / `open_fds_pct` | Steward's open files, and as a share of its limit (absent without `/proc`) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Notifications
|
## Notifications
|
||||||
@@ -104,6 +162,43 @@ Configured at `/settings/` under SMTP. Settings:
|
|||||||
|
|
||||||
Email is skipped if `smtp.host` is empty.
|
Email is skipped if `smtp.host` is empty.
|
||||||
|
|
||||||
|
Alerts that arrive together go out as one email. The first alert opens a
|
||||||
|
2-minute window. When it closes, each recipient gets one message listing every
|
||||||
|
alert in the window, oldest first, in their own zone, with a subject like
|
||||||
|
"[Steward] 3 alerts: 2 firing, 1 resolved". One alert on its own keeps its
|
||||||
|
usual email.
|
||||||
|
|
||||||
|
Every email Steward sends (alerts, the weekly report, invites, password
|
||||||
|
resets and the test buttons) waits in the database (`email_outbox`,
|
||||||
|
`core/outbox.py`) until the mail server accepts it, so nothing waiting is lost
|
||||||
|
when Steward restarts: the sender picks it up at boot. The sender runs every 15
|
||||||
|
seconds and settles each email on the server's answer:
|
||||||
|
|
||||||
|
- **Accepted:** sent.
|
||||||
|
- **Refused** (an SMTP 5xx, such as "no such mailbox"): failed at once, with
|
||||||
|
the server's words. Trying again would only repeat the refusal.
|
||||||
|
- **Temporary** (no connection, a timeout, a 4xx): tried again after 30 s,
|
||||||
|
1, 2, 5, 10 and then every 15 minutes. An email the server still hasn't
|
||||||
|
taken an hour after its first try is failed with the last error.
|
||||||
|
|
||||||
|
Once every email carrying an alert has settled, that alert's event records
|
||||||
|
whether the email went, and to whom it didn't.
|
||||||
|
|
||||||
|
Where to see it: each alert's row in **Alerts → History** says what its email
|
||||||
|
is doing (gathering, with when it sends; retrying, with the attempt, the next
|
||||||
|
try and the server's last answer; sent; failed, with who wasn't reached and
|
||||||
|
why), and `get_alert_history` over MCP reports the same. **Settings → System →
|
||||||
|
Outgoing email** lists every email of the last 7 days not yet sent, failures
|
||||||
|
first, and the page's verdict names failures and any email still retrying
|
||||||
|
after 5 minutes.
|
||||||
|
|
||||||
|
An email a person is waiting on is tried at once instead of on the next tick:
|
||||||
|
the weekly report's **Send now**, an invite, and the test buttons, so the page
|
||||||
|
can say what the server answered. A report or invite the server couldn't take
|
||||||
|
yet stays queued and is retried as above. A test (the Settings → Notifications
|
||||||
|
test email, a rule's **Send test**) is recorded but never retried: its one
|
||||||
|
answer is the result.
|
||||||
|
|
||||||
### Webhook
|
### Webhook
|
||||||
|
|
||||||
Configured at `/settings/` under Webhook. The body is a Jinja2 template rendered to JSON. Content-Type is always `application/json`. If the rendered template is not valid JSON, the delivery is logged as failed and no request is sent.
|
Configured at `/settings/` under Webhook. The body is a Jinja2 template rendered to JSON. Content-Type is always `application/json`. If the rendered template is not valid JSON, the delivery is logged as failed and no request is sent.
|
||||||
@@ -124,15 +219,38 @@ Default template (Discord-compatible):
|
|||||||
{"content": "**{{ alert.state }}** — {{ alert.resource }} — {{ alert.rule_name }} ({{ alert.metric }} = {{ alert.value }})"}
|
{"content": "**{{ alert.state }}** — {{ alert.resource }} — {{ alert.rule_name }} ({{ alert.metric }} = {{ alert.value }})"}
|
||||||
```
|
```
|
||||||
|
|
||||||
Webhook is skipped if `webhook.url` is empty.
|
Webhook is skipped if `webhook.url` is empty. Webhooks are not gathered into
|
||||||
|
bursts: each alert posts on its own, at once.
|
||||||
|
|
||||||
|
### Send test
|
||||||
|
|
||||||
|
**Send test** at the top of a rule's page (operator role), or `send_test_alert` over MCP,
|
||||||
|
sends a `TEST` alert for that rule now, by email and webhook, the same way a
|
||||||
|
real alert would go. It uses the rule's name and condition and the latest
|
||||||
|
reading of what the rule watches. A rule that covers hosts uses its first
|
||||||
|
host. Each channel's result shows under the button. Nothing fires, the rule's
|
||||||
|
history doesn't change, and the test is recorded in the audit log as
|
||||||
|
`alert_rule.tested`.
|
||||||
|
|
||||||
|
### On the dashboard
|
||||||
|
|
||||||
|
The **Alerts** widget (`/alerts/widget`) sits beside the Overview in the
|
||||||
|
recommended layout. It opens with a verdict ("2 firing, 1 silent." or
|
||||||
|
"Nothing firing: all 12 rules are quiet."). Below that it lists what is
|
||||||
|
firing or silent: each rule, what it watches, its reading now against what
|
||||||
|
it fires on, and since when. Acknowledged alerts follow, with who
|
||||||
|
acknowledged them and when. A line appears when alert email has failed in
|
||||||
|
the last 7 days or is still retrying, linking admins to Settings → System.
|
||||||
|
The quiet rules are counted at the end.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Data Models
|
## Data Models
|
||||||
|
|
||||||
Defined in `roundtable/models/alerts.py`:
|
Defined in `steward/models/alerts.py`:
|
||||||
|
|
||||||
- **`alert_rules`** — one row per configured rule
|
- **`alert_rules`** — one row per configured rule
|
||||||
- **`alert_states`** — one row per rule, tracks current state and consecutive failure count
|
- **`alert_states`** — one row per rule, tracks current state and consecutive failure count
|
||||||
- **`alert_events`** — append-only log of all state transitions and notification outcomes
|
- **`alert_events`** — append-only log of all state transitions and notification outcomes
|
||||||
- **`plugin_metrics`** — all metric values written by any monitor or plugin
|
- **`plugin_metrics`** — all metric values written by any monitor or plugin. Its series index carries the value (`ix_plugin_metrics_series_value`), so a day of one metric is read from the index alone even when nothing is cached; Steward builds it in the background after startup (`core/metric_index.py`), never during a migration
|
||||||
|
- **`plugin_metric_latest`** — the newest sample of each (source, resource, metric). Pages that show "what it is now" read this instead of searching history; retention drops a row once its sample leaves the raw window
|
||||||
|
|||||||
+16
-9
@@ -1,6 +1,6 @@
|
|||||||
# Ansible Integration
|
# Ansible Integration
|
||||||
|
|
||||||
Roundtable can browse, trigger, and stream output from Ansible playbooks directly from the web UI. Runs execute as asyncio tasks inside the same process — no Celery, no external workers.
|
Steward can browse, trigger, and stream output from Ansible playbooks directly from the web UI. Runs execute as asyncio tasks inside the same process — no Celery, no external workers.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -31,10 +31,9 @@ ansible.sources:
|
|||||||
url: "https://github.com/example/infra.git"
|
url: "https://github.com/example/infra.git"
|
||||||
branch: "main"
|
branch: "main"
|
||||||
pull_interval_seconds: 300
|
pull_interval_seconds: 300
|
||||||
cache_path: "/data/playbook_cache/infra-repo"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Git sources register a `ScheduledTask` named `ansible_git_pull_<name>` that runs `git pull` on the configured interval.
|
Each git source is checked out under `/data/ansible/sources/<name>` (override with `STEWARD_ANSIBLE_SOURCE_DIR`), on the persistent volume, so a redeploy keeps it. One scheduled task, `ansible_git_pulls`, ticks every 30 s and pulls each source once its `pull_interval_seconds` has passed; a source added in Settings is pulled within a tick, with no restart. A clone or pull that runs past 120 s is cut (its process group terminated), a clone that fails is removed so the next attempt starts clean, and git never prompts for credentials.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -102,12 +101,20 @@ If a client connects after the run has already completed, the endpoint immediate
|
|||||||
|
|
||||||
| Status | Description |
|
| Status | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| `queued` | Waiting for a host another run holds, or for a run slot |
|
||||||
| `running` | Execution in progress |
|
| `running` | Execution in progress |
|
||||||
| `success` | Playbook exited 0 |
|
| `success` | Playbook exited 0 |
|
||||||
| `failed` | Playbook exited non-zero |
|
| `failed` | Playbook exited non-zero |
|
||||||
| `interrupted` | App restarted while run was in progress |
|
| `interrupted` | App restarted while run was in progress |
|
||||||
|
| `cancelled` | Stopped by an operator, or by Steward shutting down mid-run |
|
||||||
|
|
||||||
On startup, the app marks any runs still in `running` state as `interrupted` (see `_mark_interrupted_runs()` in `app.py`).
|
On startup, the app marks any runs still `running` or `queued` as `interrupted` (see `_mark_interrupted_runs()` in `app.py`).
|
||||||
|
|
||||||
|
### Overlapping runs
|
||||||
|
|
||||||
|
- **One run per host.** Before it takes a run slot, a run claims every host it touches. Hosts come from the inventory Steward writes, keyed by address. A run on a source's own inventory file claims that file, because Steward doesn't read its hosts. While another run holds one of its hosts, the run shows `queued` and says which run it waits for. After an hour of waiting it fails and says so.
|
||||||
|
- **One run at a time per schedule.** A schedule that falls due while its previous run is still `queued` or `running` skips that firing. The Schedules page shows "Skipped: the previous run was still going" beside that run. The next firing comes an interval later.
|
||||||
|
- **One action run at a time per alert rule.** When a rule fires again while the action run from an earlier firing is still going, no new run starts. The firing's event says it was skipped and names the run.
|
||||||
|
|
||||||
Output stored in the DB is capped at 1 MB. If truncated, `[output truncated]` is appended to the DB column, but the live SSE stream continues unaffected.
|
Output stored in the DB is capped at 1 MB. If truncated, `[output truncated]` is appended to the DB column, but the live SSE stream continues unaffected.
|
||||||
|
|
||||||
@@ -115,7 +122,7 @@ Output stored in the DB is capped at 1 MB. If truncated, `[output truncated]` is
|
|||||||
|
|
||||||
## Data Model
|
## Data Model
|
||||||
|
|
||||||
`ansible_runs` table (defined in `roundtable/models/ansible.py`):
|
`ansible_runs` table (defined in `steward/models/ansible.py`):
|
||||||
|
|
||||||
| Column | Type | Description |
|
| Column | Type | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -137,7 +144,7 @@ Runs older than `data.retention_days` (default 90) are pruned by the `data_clean
|
|||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `roundtable/ansible/sources.py` | Source discovery, git pull logic |
|
| `steward/ansible/sources.py` | Source discovery, git pull logic |
|
||||||
| `roundtable/ansible/executor.py` | Subprocess execution and output streaming |
|
| `steward/ansible/executor.py` | Subprocess execution and output streaming |
|
||||||
| `roundtable/ansible/routes.py` | HTTP routes (browse, trigger, stream, history) |
|
| `steward/ansible/routes.py` | HTTP routes (browse, trigger, stream, history) |
|
||||||
| `roundtable/models/ansible.py` | `AnsibleRun` model |
|
| `steward/models/ansible.py` | `AnsibleRun` model |
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# Backups: Steward backing itself up, and coming back
|
||||||
|
|
||||||
|
Steward sends a backup of itself to an S3 bucket once a day. Fabled Archives
|
||||||
|
is the intended target. A backup is one file, and `steward restore` brings
|
||||||
|
Steward back from it into an empty database.
|
||||||
|
|
||||||
|
## What a backup holds
|
||||||
|
|
||||||
|
`steward-<UTC time>.tar`, containing:
|
||||||
|
|
||||||
|
| Member | What it is |
|
||||||
|
|---|---|
|
||||||
|
| `steward.dump` | `pg_dump --format=custom` of Steward's database |
|
||||||
|
| `secret.key` | The key Steward's stored passwords and tokens are sealed with |
|
||||||
|
| `manifest.json` | Format, Steward version, migration heads, time, and the tables whose rows were left out |
|
||||||
|
|
||||||
|
**Kept:**
|
||||||
|
- configuration: settings, users, API tokens, hosts, monitors, alert rules, maintenance windows, Ansible inventory and schedules, dashboards, SNMP devices, Prometheus targets, UniFi client names;
|
||||||
|
- the audit log;
|
||||||
|
- alert history.
|
||||||
|
|
||||||
|
**Left out** (the tables come back empty and fill again as Steward runs):
|
||||||
|
- metrics and their rollups;
|
||||||
|
- monitor check results;
|
||||||
|
- Docker, Traefik and UniFi history;
|
||||||
|
- Ansible run records;
|
||||||
|
- the email queue;
|
||||||
|
- password-reset links.
|
||||||
|
|
||||||
|
The list is `steward/backup/contents.py`. A test fails when a new table
|
||||||
|
appears in neither list, so whoever adds a table decides.
|
||||||
|
|
||||||
|
**The secret key is in the file.** That makes a restore complete on its own.
|
||||||
|
It also means anyone holding a copy can read every password and key Steward
|
||||||
|
keeps. Keep the bucket private.
|
||||||
|
|
||||||
|
## Setting it up with Fabled Archives
|
||||||
|
|
||||||
|
1. In Archives, make a backup target for Steward with an S3 key. The default
|
||||||
|
append-only key is enough: Steward only uploads and never deletes.
|
||||||
|
How long backups are kept is set on the Archives side.
|
||||||
|
2. In Steward, open **Settings → Backups** and fill in:
|
||||||
|
- **Endpoint**: the S3 address Archives shows for the target, without the
|
||||||
|
bucket (for example `http://archives:8081`). Requests are path-style:
|
||||||
|
`endpoint/bucket/file`.
|
||||||
|
- **Bucket**: the target's bucket name.
|
||||||
|
- **Access key** and **Secret key**: from Archives. The secret is sealed at
|
||||||
|
rest; a blank box on save keeps the stored one.
|
||||||
|
3. Press **Test the bucket**. A key that may not list ("AccessDenied") still
|
||||||
|
counts as reached: an append-only key can't list, and uploads work anyway.
|
||||||
|
4. Tick **Back up every day**, pick the hour (on your clock), and save.
|
||||||
|
**Back up now** makes one straight away.
|
||||||
|
|
||||||
|
A day missed while Steward was down is made up when it comes back. A failed
|
||||||
|
backup is tried again an hour later, never sooner. The last 30 runs are
|
||||||
|
listed on the page, and each one is in the audit log (`backup.made` /
|
||||||
|
`backup.failed`).
|
||||||
|
|
||||||
|
### Alert when backups stop
|
||||||
|
|
||||||
|
While backups are on, Steward records two metrics under the source
|
||||||
|
**Steward itself**, resource `backup`:
|
||||||
|
|
||||||
|
| Metric | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `backup_age_secs` ("Newest backup's age") | Seconds since the last backup was sent, updated every 15 minutes |
|
||||||
|
| `backup_bytes` ("Backup size") | The size of the last backup sent |
|
||||||
|
|
||||||
|
A rule such as `backup_age_secs > 93600` (26 hours) fires when a daily backup
|
||||||
|
has been missed.
|
||||||
|
|
||||||
|
## Making a backup by hand
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker exec -u app <steward container> steward backup --to /tmp/backups
|
||||||
|
```
|
||||||
|
|
||||||
|
This writes the file and sends it nowhere. It needs the same database access
|
||||||
|
as Steward itself.
|
||||||
|
|
||||||
|
## Restoring
|
||||||
|
|
||||||
|
`steward restore` writes into an **empty** database. Pass `--wipe` to drop
|
||||||
|
everything in the database's public schema first.
|
||||||
|
|
||||||
|
1. Stop Steward (scale its service to 0) so nothing writes during the restore.
|
||||||
|
2. Download the backup file from Archives.
|
||||||
|
3. Run the restore with the image, pointed at the database:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker run --rm \
|
||||||
|
-e STEWARD_DATABASE_URL=postgresql+asyncpg://steward:…@db:5432/steward \
|
||||||
|
-v steward_data:/data -v "$PWD":/restore \
|
||||||
|
git.fabledsword.com/bvandeusen/steward:latest \
|
||||||
|
steward restore /restore/steward-20261012T030000Z.tar
|
||||||
|
```
|
||||||
|
|
||||||
|
- The backup's secret key is written to `/data/secret.key`.
|
||||||
|
- If `STEWARD_SECRET_KEY` is set instead, it must equal the backup's key,
|
||||||
|
or the restore stops and says so. Read the key with
|
||||||
|
`tar -xOf <file> secret.key`.
|
||||||
|
4. Start Steward. On start it runs any migrations the backup is behind on.
|
||||||
|
|
||||||
|
**How the restore works.** pg_restore runs in its three sections: schema,
|
||||||
|
then data, then constraints and indexes. Between the data and the
|
||||||
|
constraints, a kept row that points at a left-out row is put right by its
|
||||||
|
foreign key's ON DELETE:
|
||||||
|
- an alert event's link to an Ansible run is cleared;
|
||||||
|
- a row whose foreign key would cascade or block is removed.
|
||||||
|
|
||||||
|
pg_restore 17 writes each section as a script for psql. It runs against
|
||||||
|
Postgres 16 with one setting line dropped (`transaction_timeout`, which 16
|
||||||
|
doesn't know).
|
||||||
|
|
||||||
|
CI checks the whole path on every build. The built image backs up its own
|
||||||
|
database, restores into a second one, checks a setting came back and the
|
||||||
|
metrics table is empty, then starts Steward on it.
|
||||||
+10
-12
@@ -1,6 +1,6 @@
|
|||||||
# Configuration
|
# Configuration
|
||||||
|
|
||||||
Roundtable uses a two-layer configuration system. Only the bare minimum needed to boot lives in files or environment variables. Everything else is stored in the database and managed through the Settings UI.
|
Steward uses a two-layer configuration system. Only the bare minimum needed to boot lives in files or environment variables. Everything else is stored in the database and managed through the Settings UI.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -10,31 +10,29 @@ These three values are read at startup from `config.yaml` and/or environment var
|
|||||||
|
|
||||||
| Key | Env var | Default | Description |
|
| Key | Env var | Default | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `database.url` | `ROUNDTABLE_DATABASE_URL` | — | PostgreSQL async URL. **Required.** |
|
| `database.url` | `STEWARD_DATABASE_URL` | — | PostgreSQL async URL. **Required.** |
|
||||||
| `secret_key` | `ROUNDTABLE_SECRET_KEY` | auto-generated | Flask/Quart session signing key. Auto-generated and saved to `/data/secret.key` if not set. |
|
| `secret_key` | `STEWARD_SECRET_KEY` | auto-generated | Flask/Quart session signing key. Auto-generated and saved to `/data/secret.key` if not set. |
|
||||||
| `plugin_dir` | `ROUNDTABLE_PLUGIN_DIR` | `plugins` | Path to the plugins directory. |
|
| `plugin_dir` | `STEWARD_PLUGIN_DIR` | `plugins` | Path to the plugins directory. |
|
||||||
|
|
||||||
**Resolution order for `database_url`:** env var `ROUNDTABLE_DATABASE_URL` → env var `ROUNDTABLE_DATABASE__URL` (legacy double-underscore) → `database.url` in `config.yaml`.
|
**Resolution order for `database_url`:** env var `STEWARD_DATABASE_URL` → env var `STEWARD_DATABASE__URL` (legacy double-underscore) → `database.url` in `config.yaml`.
|
||||||
|
|
||||||
**Resolution order for `secret_key`:** env var `ROUNDTABLE_SECRET_KEY` → `secret_key` in `config.yaml` → `/data/secret.key` file → auto-generate and write to `/data/secret.key`.
|
**Resolution order for `secret_key`:** env var `STEWARD_SECRET_KEY` → `secret_key` in `config.yaml` → `/data/secret.key` file → auto-generate and write to `/data/secret.key`.
|
||||||
|
|
||||||
### Minimal config.yaml
|
### Minimal config.yaml
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
database:
|
database:
|
||||||
url: "postgresql+asyncpg://user:password@localhost/roundtable"
|
url: "postgresql+asyncpg://user:password@localhost/steward"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Minimal env-only setup (Docker)
|
### Minimal env-only setup (Docker)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ROUNDTABLE_DATABASE_URL=postgresql+asyncpg://user:password@db/roundtable
|
STEWARD_DATABASE_URL=postgresql+asyncpg://user:password@db/steward
|
||||||
```
|
```
|
||||||
|
|
||||||
A `.env` file is loaded automatically if present.
|
A `.env` file is loaded automatically if present.
|
||||||
|
|
||||||
> **Legacy env vars:** `FABLEDSCRYER_*` env vars are still accepted as a fallback for existing deployments. They will be removed in a future release — migrate to `ROUNDTABLE_*` when convenient.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## App Settings (Database-backed)
|
## App Settings (Database-backed)
|
||||||
@@ -68,7 +66,7 @@ Default webhook template:
|
|||||||
### Reading and Writing Settings in Code
|
### Reading and Writing Settings in Code
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from roundtable.core.settings import get_setting, set_setting
|
from steward.core.settings import get_setting, set_setting
|
||||||
|
|
||||||
# Read
|
# Read
|
||||||
async with current_app.db_sessionmaker() as session:
|
async with current_app.db_sessionmaker() as session:
|
||||||
@@ -84,7 +82,7 @@ async with current_app.db_sessionmaker() as session:
|
|||||||
|
|
||||||
### The DEFAULTS Dict
|
### The DEFAULTS Dict
|
||||||
|
|
||||||
`roundtable/core/settings.py` contains the `DEFAULTS` dict — the canonical list of all recognised settings and their default values. Add new settings here to make them recognised by `get_all_settings()` and the settings UI.
|
`steward/core/settings.py` contains the `DEFAULTS` dict — the canonical list of all recognised settings and their default values. Add new settings here to make them recognised by `get_all_settings()` and the settings UI.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
# MCP server
|
||||||
|
|
||||||
|
Steward serves the [Model Context Protocol](https://modelcontextprotocol.io) at
|
||||||
|
`/mcp`. An AI client connected to it can look into everything Steward watches
|
||||||
|
(hosts, monitors, alerts, metrics, containers, playbook runs) and, with a
|
||||||
|
high enough role, change it. A token's role sets that ceiling.
|
||||||
|
|
||||||
|
**Source:** `steward/mcp/`. The tools live in `steward/mcp/tools/`, and a
|
||||||
|
plugin's tools live in `plugins/<name>/mcp_tools.py`.
|
||||||
|
**Transport:** streamable HTTP, stateless. Every request carries its own
|
||||||
|
token, so a client survives a Steward restart without reconnecting.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Connecting
|
||||||
|
|
||||||
|
1. In Steward, open **AI access** and create a token. Pick the lowest role
|
||||||
|
that does the job.
|
||||||
|
2. Copy the token (it is shown once) and use one of the recipes on that
|
||||||
|
page. They carry the real endpoint URL and the new token. For Claude Code:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
claude mcp add --transport http steward https://steward.example/mcp \
|
||||||
|
--header "Authorization: Bearer stw_…"
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Ask the client to call `whoami`. It answers with the user, the effective
|
||||||
|
role and what that role allows.
|
||||||
|
|
||||||
|
Claude Desktop connects through `mcp-remote`. Open WebUI and other clients
|
||||||
|
need an MCP server with the **Streamable HTTP** transport and bearer
|
||||||
|
authentication. The AI access page shows the recipes for both.
|
||||||
|
|
||||||
|
### Behind a reverse proxy
|
||||||
|
|
||||||
|
- The URL on the AI access page is **Settings → General → Public base URL**
|
||||||
|
plus `/mcp`. If that setting is empty, the URL is built from the request's
|
||||||
|
host instead.
|
||||||
|
- The proxy must pass the `Authorization` header through unchanged.
|
||||||
|
- It must not buffer responses. Replies may arrive as server-sent events
|
||||||
|
(`text/event-stream`). For nginx, set `proxy_buffering off;` on the
|
||||||
|
location.
|
||||||
|
- Steward does no Host-header (DNS-rebinding) check on `/mcp`, because the
|
||||||
|
bearer token is the security boundary. Serve it over HTTPS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security model
|
||||||
|
|
||||||
|
| Rule | How it is enforced |
|
||||||
|
|---|---|
|
||||||
|
| Bearer tokens only, no OAuth | `steward/core/api_tokens.py`. Only a hash is stored, and the token is shown once. |
|
||||||
|
| A token never acts above its user | Effective role = min(token role, user's **current** role). Lowering a user lowers every token they hold; disabling the user stops them all. |
|
||||||
|
| Default deny | A tool is reachable only if it was registered with a role (`register_tool(fn, role=..., read_only=...)`). `tools/list` shows a caller only the tools their role allows. A call above the role is refused, and the refusal names the role it needs. |
|
||||||
|
| Plugin tools follow the plugin | A disabled plugin's tools are hidden and refused. |
|
||||||
|
| Dry run first | `run_playbook`, `create_schedule` and `set_alert_action` default to Ansible check mode (`--check --diff`). Changes are applied only when the caller passes `check=false`. A playbook its author flagged `# steward:confirm: true` also needs `confirmed=true`. `prune_docker` previews unless `check=false`. |
|
||||||
|
| Secrets stay put | Playbook variables Steward detects as secret reach the run but are never stored, audited or returned. Schedules and alert actions refuse them, since they would have to store them. Inventory variables are listed by name only, and HTTP monitor header values are redacted. |
|
||||||
|
| Every change is audited | Each write records an audit event as `<user> via MCP`, with the token id in its detail. `list_audit_events(user="<user>")` includes those events. |
|
||||||
|
| Unknown arguments are refused | A misspelt argument is rejected with a "did you mean", rather than silently dropped. |
|
||||||
|
|
||||||
|
Tokens never mint or revoke tokens; that happens only on the AI access
|
||||||
|
page, in a browser session.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tools by role
|
||||||
|
|
||||||
|
Each role includes the tools of the roles above it in this list.
|
||||||
|
|
||||||
|
### Viewer: read everything
|
||||||
|
|
||||||
|
| Area | Tools |
|
||||||
|
|---|---|
|
||||||
|
| Start here | `whoami`, `get_overview` (what is wrong now, ranked, each finding with the call that shows its evidence) |
|
||||||
|
| Hosts and metrics | `list_hosts`, `get_host`, `get_host_metrics`, `query_metrics` |
|
||||||
|
| Monitors | `list_monitors`, `get_monitor` |
|
||||||
|
| Alerts | `list_alerts`, `get_alert_history`, `list_alert_rules`, `get_alert_rule_options`, `list_maintenance_windows` |
|
||||||
|
| Metric anomalies | `list_anomalies` (metrics that left their normal for the hour, to review first) |
|
||||||
|
| Automation | `list_playbooks`, `get_playbook`, `list_inventory`, `list_ansible_runs`, `get_ansible_run`, `wait_for_run`, `list_schedules` |
|
||||||
|
| Audit | `list_audit_events` |
|
||||||
|
| Docker plugin | `list_containers`, `get_container`, `list_swarm_services`, `search_container_logs`, `get_docker_disk_usage` |
|
||||||
|
| Host agent plugin | `list_agents` |
|
||||||
|
|
||||||
|
### Operator: run playbooks, change monitoring
|
||||||
|
|
||||||
|
| Area | Tools |
|
||||||
|
|---|---|
|
||||||
|
| Automation | `run_playbook`, `cancel_ansible_run`, `create_schedule`, `update_schedule`, `delete_schedule`, `update_group_members` (put hosts into an inventory group or take them out; a group alert rule follows within a minute) |
|
||||||
|
| Monitors | `create_monitor`, `update_monitor`, `set_monitor_enabled` |
|
||||||
|
| Alerts | `create_alert_rule`, `update_alert_rule`, `acknowledge_alert`, `send_test_alert`, `create_maintenance_window`, `end_maintenance_window` |
|
||||||
|
| Metric anomalies | `review_anomaly` (mark one normal, so its hours are learned, or real) |
|
||||||
|
|
||||||
|
### Admin: destructive and remediation
|
||||||
|
|
||||||
|
`set_alert_action` (the playbook a rule runs when it fires), `delete_monitor`,
|
||||||
|
`delete_alert_rule`, `prune_docker`.
|
||||||
|
|
||||||
|
The client sees each tool's full contract: arguments, what an empty value
|
||||||
|
means, and what comes back. That contract is the tool's docstring, so read the
|
||||||
|
docstrings in `steward/mcp/tools/` for the detail.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Response conventions
|
||||||
|
|
||||||
|
Every tool follows these conventions (`steward/mcp/tools/_shape.py`):
|
||||||
|
|
||||||
|
- **Times** are ISO-8601 UTC with a `Z`. Anything with a "when" also carries
|
||||||
|
`age_s`, so the client never does clock arithmetic.
|
||||||
|
- **Lists are bounded.** A list response carries `total` and `truncated`. When
|
||||||
|
it was cut, `more` names the argument that fetches the rest. Nothing is cut
|
||||||
|
silently.
|
||||||
|
- **Time series** come back as a summary (min/avg/max/p95/last) plus a series
|
||||||
|
of at most 60 points, never raw rows.
|
||||||
|
- **Lookup by name or id.** A miss suggests close matches and names the tool
|
||||||
|
that lists the valid choices.
|
||||||
|
- **Refusals are readable.** Bad input becomes a tool error that says what was
|
||||||
|
wrong, why, and what to do instead, and nothing is changed. The tools share
|
||||||
|
their validation with the web forms (`monitors/service.py`,
|
||||||
|
`alerts/service.py`), so a value the UI refuses is refused here for the same
|
||||||
|
reason.
|
||||||
|
- **Database outages** return a plain "Steward cannot reach its database"
|
||||||
|
rather than a stack trace.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adding tools
|
||||||
|
|
||||||
|
Core tools: add an `async def` to a module in `steward/mcp/tools/` and list it
|
||||||
|
in that module's `register()` with its minimum role and `read_only`. A plugin
|
||||||
|
exposes `register_mcp_tools(register)`, and optionally `mcp_overview(db)` to
|
||||||
|
contribute findings to `get_overview`. See
|
||||||
|
[Writing a plugin](../plugins/writing-a-plugin.md). A tool that changes
|
||||||
|
anything calls `audit_mcp(...)` from `steward/mcp/tools/_audit.py`.
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
# Core Monitors
|
# Core Monitors
|
||||||
|
|
||||||
Roundtable ships two built-in monitors: Ping and DNS. Both run as asyncio scheduled tasks on the same event loop as the web server, with no separate processes.
|
Steward ships two built-in monitors: Ping and DNS. Both run as asyncio scheduled tasks on the same event loop as the web server, with no separate processes.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Ping Monitor
|
## Ping Monitor
|
||||||
|
|
||||||
**Source:** `roundtable/monitors/ping.py`
|
**Source:** `steward/monitors/ping.py`
|
||||||
**Scheduler task:** `ping_monitor` in `roundtable/app.py`
|
**Scheduler task:** `ping_monitor` in `steward/app.py`
|
||||||
**Interval:** `monitors.poll_interval_seconds` (default 60s), runs on startup
|
**Interval:** `monitors.poll_interval_seconds` (default 60s), runs on startup
|
||||||
|
|
||||||
### How It Works
|
### How It Works
|
||||||
@@ -29,7 +29,7 @@ Each probe writes a `PingResult` row and calls `record_metric()`:
|
|||||||
|
|
||||||
### Data Model
|
### Data Model
|
||||||
|
|
||||||
`ping_results` table (defined in `roundtable/models/monitors.py`):
|
`ping_results` table (defined in `steward/models/monitors.py`):
|
||||||
|
|
||||||
| Column | Type | Description |
|
| Column | Type | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -51,8 +51,8 @@ Old rows are pruned by the `data_cleanup` task (default: 90 days).
|
|||||||
|
|
||||||
## DNS Monitor
|
## DNS Monitor
|
||||||
|
|
||||||
**Source:** `roundtable/monitors/dns.py`
|
**Source:** `steward/monitors/dns.py`
|
||||||
**Scheduler task:** `dns_monitor` in `roundtable/app.py`
|
**Scheduler task:** `dns_monitor` in `steward/app.py`
|
||||||
**Interval:** `monitors.poll_interval_seconds` (default 60s), runs on startup
|
**Interval:** `monitors.poll_interval_seconds` (default 60s), runs on startup
|
||||||
|
|
||||||
### How It Works
|
### How It Works
|
||||||
@@ -70,7 +70,7 @@ Each check writes a `DnsResult` row and calls `record_metric()`:
|
|||||||
|
|
||||||
### Data Model
|
### Data Model
|
||||||
|
|
||||||
`dns_results` table (defined in `roundtable/models/monitors.py`):
|
`dns_results` table (defined in `steward/models/monitors.py`):
|
||||||
|
|
||||||
| Column | Type | Description |
|
| Column | Type | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|||||||
+243
-108
@@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
**Date:** 2026-04-14
|
**Date:** 2026-04-14
|
||||||
**Status:** Approved, ready for implementation planning
|
**Status:** Approved, ready for implementation planning
|
||||||
**Scope:** Roundtable plugin for remote host resource monitoring (CPU, memory, storage, load, uptime) via a lightweight Python push agent.
|
**Scope:** Steward plugin for remote host resource monitoring (CPU, memory, storage, load, uptime) via a lightweight Python push agent.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
Give Roundtable a fleet-glance view of remote Linux hosts — current resource usage, history, and alert integration — without requiring agentless SSH/Ansible round-trips, a new toolchain, or SNMP gymnastics on every target.
|
Give Steward a fleet-glance view of remote Linux hosts — current resource usage, history, and alert integration — without requiring agentless SSH/Ansible round-trips, a new toolchain, or SNMP gymnastics on every target.
|
||||||
|
|
||||||
## Non-goals
|
## Non-goals
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ Give Roundtable a fleet-glance view of remote Linux hosts — current resource u
|
|||||||
|
|
||||||
```
|
```
|
||||||
┌──────────────┐ POST /plugins/host_agent/ingest ┌────────────────────────┐
|
┌──────────────┐ POST /plugins/host_agent/ingest ┌────────────────────────┐
|
||||||
│ Agent │ ──────────────────────────────────────────→ │ Roundtable │
|
│ Agent │ ──────────────────────────────────────────→ │ Steward │
|
||||||
│ (Python) │ Authorization: Bearer <per-host-token> │ host_agent plugin │
|
│ (Python) │ Authorization: Bearer <per-host-token> │ host_agent plugin │
|
||||||
│ on target │ JSON body: metrics snapshot │ │
|
│ on target │ JSON body: metrics snapshot │ │
|
||||||
│ host │ │ routes.py (ingest + │
|
│ host │ │ routes.py (ingest + │
|
||||||
@@ -48,8 +48,8 @@ Give Roundtable a fleet-glance view of remote Linux hosts — current resource u
|
|||||||
- **Agent** is a single Python file. No external dependencies beyond Python 3.8+ stdlib.
|
- **Agent** is a single Python file. No external dependencies beyond Python 3.8+ stdlib.
|
||||||
- **Plugin** is self-contained under `plugins/host_agent/`. Writes to the core `PluginMetric` time-series bus (the designed cross-plugin integration point) and its own private `host_agent_registrations` table. Writes to the core `Host` model for identity, but adds no new columns to it.
|
- **Plugin** is self-contained under `plugins/host_agent/`. Writes to the core `PluginMetric` time-series bus (the designed cross-plugin integration point) and its own private `host_agent_registrations` table. Writes to the core `Host` model for identity, but adds no new columns to it.
|
||||||
- **Auth** is per-host bearer tokens, minted on "Add host" in the plugin's settings page.
|
- **Auth** is per-host bearer tokens, minted on "Add host" in the plugin's settings page.
|
||||||
- **Install** is a one-line `curl | sh` command rendered per-host by Roundtable with the token already baked in.
|
- **Install** is a one-line `curl | sh` command rendered per-host by Steward with the token already baked in.
|
||||||
- **Failure** is handled at the agent (in-memory ring buffer + exponential backoff) so Roundtable outages don't lose brief-window data.
|
- **Failure** is handled at the agent (in-memory ring buffer + exponential backoff) so Steward outages don't lose brief-window data.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -58,10 +58,14 @@ Give Roundtable a fleet-glance view of remote Linux hosts — current resource u
|
|||||||
### File layout on the target host
|
### File layout on the target host
|
||||||
|
|
||||||
```
|
```
|
||||||
/usr/local/lib/roundtable-agent/agent.py # the script, target ~300 lines
|
/usr/local/lib/steward-agent/agent.py # the agent (runs as steward-agent)
|
||||||
/etc/roundtable-agent.conf # key=value config, 0640 root:roundtable-agent
|
/usr/local/lib/steward-agent/maint.py # the maintenance helper (runs as root, #5766)
|
||||||
/etc/systemd/system/roundtable-agent.service # unit file
|
/etc/steward-agent.conf # key=value config, 0640 root:steward-agent
|
||||||
# dedicated system user: roundtable-agent
|
/etc/systemd/system/steward-agent.service # the agent's unit
|
||||||
|
/etc/systemd/system/steward-maint.{path,service} # starts the helper on a request
|
||||||
|
/var/lib/steward-agent/requests/ # the one place the agent writes (0700 steward-agent)
|
||||||
|
/var/lib/steward-maint/results/ # the helper's outcomes (root, readable)
|
||||||
|
# dedicated system user: steward-agent
|
||||||
```
|
```
|
||||||
|
|
||||||
### Config file format
|
### Config file format
|
||||||
@@ -69,13 +73,20 @@ Give Roundtable a fleet-glance view of remote Linux hosts — current resource u
|
|||||||
Flat `key = value`, parsed by a ~20-line homegrown parser (no TOML/YAML dependency):
|
Flat `key = value`, parsed by a ~20-line homegrown parser (no TOML/YAML dependency):
|
||||||
|
|
||||||
```
|
```
|
||||||
url = https://roundtable.home.lan
|
url = https://steward.home.lan
|
||||||
token = a1b2c3d4...
|
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 +96,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.
|
||||||
|
|
||||||
@@ -99,9 +117,199 @@ Python 3.8+ stdlib only. `urllib.request` for the POST, plus `json`, `os`, `shut
|
|||||||
### Signals and lifecycle
|
### Signals and lifecycle
|
||||||
|
|
||||||
- **SIGHUP** → re-read config file without restart (lets admins change interval/mounts cleanly).
|
- **SIGHUP** → re-read config file without restart (lets admins change interval/mounts cleanly).
|
||||||
- **SIGTERM** (from systemd) → finish current poll cycle → flush buffer one last time → exit.
|
- **SIGTERM** (from systemd) → finish current poll cycle → flush buffer one last time, with a `stopping` event saying whether the host is going down too (`systemctl is-system-running` says `stopping`) → exit.
|
||||||
- **Systemd restart policy** → `Restart=on-failure`, `RestartSec=10`.
|
- **Systemd restart policy** → `Restart=on-failure`, `RestartSec=10`.
|
||||||
|
|
||||||
|
### Mounts that don't answer (1.14.0, #5760)
|
||||||
|
|
||||||
|
Each mount is read in a daemon thread under one 5 s deadline. A hard-mounted NFS share whose server stops blocks `statvfs` in the kernel with no timeout; read inline, that froze the whole agent. A mount that misses the deadline is sent as `{"mount", "stalled": true}`, and isn't probed again while its last probe is still blocked. Steward counts them as `mounts_stalled`.
|
||||||
|
|
||||||
|
### Every Docker read is bounded (1.15.0, #5764)
|
||||||
|
|
||||||
|
One Docker answer must arrive within 15 s and fit in 32 MiB, and one log answer within 5 s and 4 MiB. Past either limit the read is cut off, not waited on. A per-read timeout alone never ends a body that keeps trickling in. Container logs are read since each container's cursor, but only its newest 2,000 lines, all containers within 10 s. A container that writes more than that gets a `_steward` note saying older lines were skipped. A container whose answer is too big skips ahead to now. Before this, a 1.13.0 agent re-read a growing backlog every interval and went silent for a day at 2.1 GB. Each sample also carries the agent's own `agent` footprint (`rss_bytes`, `cpu_pct`), which Steward records as `agent_rss_bytes` and `agent_cpu_pct`.
|
||||||
|
|
||||||
|
### What the host has waiting (1.16.0, #5765)
|
||||||
|
|
||||||
|
Once an hour, and when the agent starts, a background thread asks the package manager what it would upgrade, so a slow apt never holds up a sample. On apt hosts it uses `/usr/lib/update-notifier/apt-check` (pending;security), or `apt list --upgradable` when that's missing. On dnf hosts it uses `dnf -C check-update`, `dnf -C updateinfo list --security`, and `needs-restarting -r` for whether to reboot. None of this needs root or the network: the commands read lists the system's own timers already fetched. Each command has 120 s. The last answer rides every sample as `updates`. Every sample also reads Debian's `/var/run/reboot-required` (and `.pkgs`) as `reboot`. Steward records `updates_pending`, `updates_security` and `reboot_required` (1/0) for rules, and keeps the newest on the registration. The host page's Agent card leads with "Reboot required since …", then "N updates waiting (M security)" or "Up to date", with when the check ran. `state["updates_due"] = True` asks for a check on the next sample; a maintenance job uses it once it has updated the host.
|
||||||
|
|
||||||
|
|
||||||
|
### A root helper for updates and reboots (1.17.0, #5766)
|
||||||
|
|
||||||
|
The agent can't update or reboot its host: it runs as `steward-agent` under
|
||||||
|
`NoNewPrivileges` and `ProtectSystem=strict`, so no sudo is possible, and that
|
||||||
|
is how it should stay. A separate helper, `maint.py`, runs as root, started by
|
||||||
|
systemd when the agent leaves it a request:
|
||||||
|
|
||||||
|
- The agent writes `<id>.json` = `{"id", "action", ["node"]}` into
|
||||||
|
`/var/lib/steward-agent/requests`, the one path its unit lets it write
|
||||||
|
(`ReadWritePaths=-…`; the `-` keeps an old host without the directory
|
||||||
|
starting). It writes a dot-file first and renames it, so the helper never
|
||||||
|
sees half a request.
|
||||||
|
- `steward-maint.path` (`PathExistsGlob=…/requests/*.json`) starts
|
||||||
|
`steward-maint.service`, a oneshot running `maint.py` as root.
|
||||||
|
- The helper opens each request with `O_NOFOLLOW`, wants a regular file under
|
||||||
|
4 KiB, and accepts only `update`, `reboot`, `drain` or `undrain`, an id of
|
||||||
|
letters, digits and dashes, and a node id in the swarm's own 25-character
|
||||||
|
form. It deletes every request, valid or not, and writes the outcome to
|
||||||
|
`/var/lib/steward-maint/results/<id>.json` (root's; kept a week). A request
|
||||||
|
whose id already has a result is not run again.
|
||||||
|
- **update**: `apt-get update` then `dist-upgrade` keeping existing config
|
||||||
|
files, with `NEEDRESTART_MODE=l` so needrestart only lists services rather
|
||||||
|
than restarting the Docker daemon under its containers; or
|
||||||
|
`dnf -y upgrade --refresh`. The result says how many packages changed and
|
||||||
|
whether a reboot is now required.
|
||||||
|
- **reboot**: writes `rebooting`, syncs, `systemctl reboot`. Any request after
|
||||||
|
it waits for the next boot.
|
||||||
|
- **drain / undrain**: `docker node update --availability drain|active`, on
|
||||||
|
the given node or this host's own. Only a manager can; a worker's helper says
|
||||||
|
so in its result.
|
||||||
|
|
||||||
|
The agent reports each result's new state once, as a `maintenance` event with
|
||||||
|
its next post that gets through (results older than a day are not news). When
|
||||||
|
an update finishes it re-checks what's waiting straight away rather than at
|
||||||
|
the next hourly check. Its metadata says `maint_helper: true` once the path
|
||||||
|
unit is installed and it can write its request directory; the host's Agent
|
||||||
|
panel shows that under Manage agent. **Update agent** installs the helper on a
|
||||||
|
host that has an older agent.
|
||||||
|
|
||||||
|
So a stolen agent token can make its host update, reboot or drain, and nothing
|
||||||
|
else. Steward handing a host a job and following it through the reboot is
|
||||||
|
#5767 (below); the cadence that does it one host at a time is #5768.
|
||||||
|
|
||||||
|
### A maintenance job, followed through the reboot (1.18.0, #5767)
|
||||||
|
|
||||||
|
Steward keeps one `host_maintenance_jobs` row per job and moves it on only from
|
||||||
|
what the agents post, so a restart of Steward, or a reboot of the host Steward
|
||||||
|
runs on, loses nothing. `plugins/host_agent/maintenance.py` has the states and
|
||||||
|
transitions (model-free, unit-tested) and the database side.
|
||||||
|
|
||||||
|
```
|
||||||
|
queued → updating → [draining →] rebooting → [undraining →] done
|
||||||
|
↘ done (no reboot needed, or the job may never reboot)
|
||||||
|
any state → failed (a step's result said so) | timed_out (its deadline passed)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Hand-off.** Steward's answer to an ingest post carries
|
||||||
|
`"maintenance": [{"id", "action", ["node"]}]`, the step that host's agent
|
||||||
|
should carry out now. It is repeated on every post until a result for that
|
||||||
|
step id arrives; the agent leaves each id for the helper once
|
||||||
|
(`take_maintenance`), and the helper refuses an id it already has a result
|
||||||
|
for, so a repeat, or a re-hand after the agent restarts, never runs twice.
|
||||||
|
A step id is `<job id>-<action>`.
|
||||||
|
- **Results** are the `maintenance` events of #5766, matched to the job by step
|
||||||
|
id, so a drain reported by a manager reaches the job of the node it drained.
|
||||||
|
- **The reboot completes** when the host's agent sends `started` with a
|
||||||
|
`boot_at` after the reboot was handed out (60 s of clock slack). An agent
|
||||||
|
restart on a host that stayed up has an old `boot_at` and changes nothing.
|
||||||
|
- **Reboot only when required**: the update's result says whether a reboot is
|
||||||
|
needed; a job made with "never reboot" stops after the update.
|
||||||
|
- **Swarm nodes** are drained first and made active after. The node is found
|
||||||
|
by hostname in the Docker plugin's swarm nodes; a manager drains itself, a
|
||||||
|
worker is drained by a manager whose agent has the helper. With no such
|
||||||
|
manager the job is refused rather than rebooting an undrained node.
|
||||||
|
- **Deadlines** (`DEADLINES`): queued 1 h, updating 90 min, draining 10 min,
|
||||||
|
rebooting 30 min, undraining 10 min. A minute tick (`host_agent_maintenance`)
|
||||||
|
times out a job past its deadline.
|
||||||
|
- **A maintenance window** scoped to the host's agent metrics
|
||||||
|
(`host_agent/<host>`) opens when the job starts, lasts as long as the rest of
|
||||||
|
the job could take, and ends when it does.
|
||||||
|
- Ingest applies all of this in a savepoint of its own: a job going wrong is
|
||||||
|
logged and never costs the host its samples.
|
||||||
|
|
||||||
|
On the host's Agent panel: a running job leads what the host has waiting, with
|
||||||
|
since-when; a finished one says what it did ("Done: 12 packages updated,
|
||||||
|
rebooted"). Under Manage agent, **Update now** (with "never reboot") starts a
|
||||||
|
job, or the panel says why one can't start.
|
||||||
|
|
||||||
|
### A maintenance cadence: a group, one host at a time (#5768)
|
||||||
|
|
||||||
|
`plugins/host_agent/cadence.py`, driven by the same minute tick as the jobs.
|
||||||
|
A **plan** (`host_maintenance_plans`) names an inventory group, a weekday, a
|
||||||
|
time in a time zone (the creator's, by default), a window and a reboot policy:
|
||||||
|
`if_required` (the default), `always` or `never`. The page is **Hosts →
|
||||||
|
Maintenance** (`/plugins/host_agent/maintenance`).
|
||||||
|
|
||||||
|
- **When.** A plan's slot is the latest weekday-and-time at or before now, in
|
||||||
|
its zone. A slot fires once (`last_slot_at`) and only while its window is
|
||||||
|
open, so a Steward that was down over the slot starts late, not a day late.
|
||||||
|
A new or resumed plan starts from the next slot. **Run now** starts a run
|
||||||
|
without touching the schedule (the Ansible ruling on Run now).
|
||||||
|
- **A run** (`host_maintenance_runs`) fixes its hosts at the start, by name:
|
||||||
|
the group's hosts through their linked Ansible targets. A host with no agent,
|
||||||
|
or no maintenance helper, is skipped and listed.
|
||||||
|
- **One at a time.** Each host gets a #5767 job (`run_id` on the job). The next
|
||||||
|
host starts only when the last is **back in service**: its agent posting in
|
||||||
|
the last 3 min and, for a swarm node, the node `ready`/`active` and every
|
||||||
|
replicated service at its replicas, judged only from swarm reports made
|
||||||
|
after the job finished. A host not back within 30 min fails the run, and
|
||||||
|
its job is marked failed with the reason.
|
||||||
|
- **Stops.** A failed or timed-out job fails the run. No host is started after
|
||||||
|
the window; the run then stops and says how many weren't reached.
|
||||||
|
- **Alerting** is an ordinary alert rule on `host_agent/<host>/maintenance_failed`:
|
||||||
|
1 when the host's latest job (in 30 days) failed, timed out or didn't come
|
||||||
|
back into service, 0 once a later one succeeds.
|
||||||
|
- The page leads with a running run and its hosts, then failed runs from the
|
||||||
|
last week, then each plan with its schedule, next slot and last five runs.
|
||||||
|
Each host's Agent panel shows its last maintenance job.
|
||||||
|
|
||||||
|
### The swarm's health, alertable (1.19.0, #5788)
|
||||||
|
|
||||||
|
Every manager's agent reports the whole swarm. From each report the docker plugin (`plugins/docker/swarm_health.py`) records three swarm-wide figures on resource `swarm`, source `docker`:
|
||||||
|
|
||||||
|
- `swarm_services_short`: services running fewer tasks than they want. A service scaled to 0 is never short.
|
||||||
|
- `swarm_nodes_down`: nodes not `ready`.
|
||||||
|
- `swarm_managers_unreachable`: managers the swarm reports as not `reachable`. Agent 1.19.0 sends `ManagerStatus.Reachability`; before that, a manager whose status isn't `ready` counts.
|
||||||
|
|
||||||
|
Drained nodes are left out of the last two. A node is drained on purpose (VDNT-Util01 always, any node during its maintenance), so its going away is expected, and its host's own "Agent offline" rule still covers it.
|
||||||
|
|
||||||
|
The figures are recorded at most once every 30 s, whichever manager sent them, so three managers make one series and losing one doesn't silence it.
|
||||||
|
|
||||||
|
A maintenance job on a swarm node opens a second window over `docker` / `swarm` beside its host's (`maintenance._swarm_window`). An alert the drain or reboot causes then waits for the job, and one still firing when the window ends is sent then.
|
||||||
|
|
||||||
|
### Drive health (1.20.0, #5789; 1.20.1, #5806)
|
||||||
|
|
||||||
|
Reading SMART needs root, so the root helper does it, as the one action on its fixed list that only reads: `drives`. Hourly, and at start, the agent leaves a `drives` request. The helper runs `smartctl --scan-open` to find the drives itself (the request names none), reads each with `smartctl --json -a -d <type> <dev>`, and writes a summary to `/var/lib/steward-maint/drives.json` (0644). It writes no job result, because a drive read is not a maintenance job. The agent's newest copy rides every sample, as updates do.
|
||||||
|
|
||||||
|
smartctl ≥ 7.0 answers in JSON, the same on every distribution and for SATA, SAS and NVMe drives. A missing or older smartctl is reported (`smartctl not installed`, `… too old`) and its text output is never parsed. The helper reports figures only:
|
||||||
|
|
||||||
|
- `passed` and `temp_c` for every drive;
|
||||||
|
- `critical_warning`, `life_used_pct`, `media_errors` and spare % against threshold for NVMe;
|
||||||
|
- reallocated / pending / offline-uncorrectable sectors for ATA, and life used where the drive reports it;
|
||||||
|
- grown defects for SCSI.
|
||||||
|
|
||||||
|
A drive with no SMART (a virtual disk, a bridge that hides it) reads `available: false`, and one smartctl couldn't open carries `error`. Disks behind a RAID controller share one path (`/dev/bus/0`) and differ by the slot in their type (`megaraid,3`), so the slot is part of the name: `bus0-megaraid3` (1.20.1, #5806). Steward also gives a repeated name an ordinal (`0#2`), so two drives never share a series.
|
||||||
|
|
||||||
|
Steward judges the figures in `drives.py`, so the judgment can change without an agent update:
|
||||||
|
|
||||||
|
- **failing**: the drive's self-assessment fails, an NVMe critical warning is raised, or spare blocks fall under threshold;
|
||||||
|
- **worn**: at least 90% of its rated life is used;
|
||||||
|
- **watch**: it has bad sectors or media errors but otherwise passes. Old disks carry a few for years, so a count alone isn't a failure.
|
||||||
|
|
||||||
|
The figures become metrics on `<host>:drive:<device>`: `drive_failing`, `drive_life_used_pct`, `drive_bad_sectors` and `drive_temp_c`. The host gets `drives_failing` and `drive_life_used_max_pct` for covering rules; they are recorded only when at least one drive was read, so a VM's virtual disks don't claim "0 failing". The registration keeps the newest report (`drive_health`), and the host page lists it under **Drive health**, what needs attention first.
|
||||||
|
|
||||||
|
**Installing smartctl on any common distribution.** `files/install-deps.sh` installs smartmontools with whichever package manager the host has: apt (without recommends, which would pull in a mail server), dnf, yum, zypper, pacman or apk. install.sh and the install / provision / update playbooks all fetch and run that one copy. A package that can't be installed is reported and never fails the install.
|
||||||
|
|
||||||
|
### What happened to the host (1.14.0, #5756)
|
||||||
|
|
||||||
|
A post may carry `events`, said once each, with the next post that gets through:
|
||||||
|
|
||||||
|
- `started` — the agent starting, with `boot_at` (from `/proc/uptime`).
|
||||||
|
- `stopping` — on SIGTERM, with `host_shutdown`: true, false, or null when systemd can't say.
|
||||||
|
- `lost_contact` — `from` the first post that got no answer (or a proxy's 5xx) `to` the post carrying it.
|
||||||
|
|
||||||
|
Steward turns them into the host's downtime (`host_downtime`): **reboot** (a stop, then a boot after it), **agent_stop** (the host stayed up), **unclean** (a boot after the agent was last heard that nobody announced: power loss or a crash) and **lost_contact**. An announced stop explains the silence for `reboot_grace_seconds` (Settings → Plugins → host_agent, 10 minutes): `seconds_since_seen` reads 0 until then, and the host's other rules go quiet with it, as when it is offline. Past the grace, agent offline fires. The events live in memory, so a span the agent saw is lost if it restarts mid-outage; the boot time still reads the restart as unclean.
|
||||||
|
|
||||||
|
### Which temperature sensors are thermometers (#5782, #5783)
|
||||||
|
|
||||||
|
A board's spare thermistor inputs (a Nuvoton's SYSTIN, AUXTIN*) are often wired to nothing, and the kernel reports them anyway, at whatever the open input reads. The host's temperature (`temp_c_max`) leaves a sensor out when:
|
||||||
|
|
||||||
|
- **its value is impossible** (at or below 0 °C, or at or above 125 °C; `counts_as_hottest`), or
|
||||||
|
- **it doesn't behave like a thermometer.** Once an hour, `sensors.refresh` judges each sensor against the host's CPU (Intel `Package id N`, AMD `Tctl`/`Tdie`/`TccdN`, else `PECI Agent N`), using the last 7 days of hourly averages. It needs at least 72 shared hours and a CPU that moved at least 10 °C. A sensor is ruled out when it **falls as the CPU warms** (Pearson r ≤ −0.5, the shape of an open input reading the chip's own reference) or **holds within 0.5 °C** while the CPU moved. A wired board or drive sensor that barely follows the CPU (r near 0) stays in.
|
||||||
|
|
||||||
|
The verdicts are kept in memory and rebuilt at startup, so ingest and the host page read them without a query. The host page lists the sensors left out under "Not counted", each with its reason. A host with no CPU reading, or too little history, falls back to the value rule alone.
|
||||||
|
|
||||||
|
`temp_c_max` can drive an alert rule (#5787), e.g. "Host running hot": all hosts, over 85 °C for 10 consecutive checks. So can each sensor's `temp_c`, and the per-interface and per-disk throughput. `tests/core/test_metric_catalog.py` holds every metric ingest writes to the alert catalog, except the few `NOT_ALERTABLE` names in `plugins/host_agent/__init__.py`.
|
||||||
|
|
||||||
### Logging
|
### Logging
|
||||||
|
|
||||||
To stderr only (systemd captures to journal). No file logging, no log rotation. Levels:
|
To stderr only (systemd captures to journal). No file logging, no log rotation. Levels:
|
||||||
@@ -111,7 +319,7 @@ To stderr only (systemd captures to journal). No file logging, no log rotation.
|
|||||||
|
|
||||||
### Identity
|
### Identity
|
||||||
|
|
||||||
The agent's self-reported hostname in the payload is advisory. The real identity is the bearer token — Roundtable looks up the `Host` row via `HostAgentRegistration.token_hash`, not via hostname. Changing a host's hostname does not break its identity, and two hosts accidentally sharing a hostname can't collide.
|
The agent's self-reported hostname in the payload is advisory. The real identity is the bearer token — Steward looks up the `Host` row via `HostAgentRegistration.token_hash`, not via hostname. Changing a host's hostname does not break its identity, and two hosts accidentally sharing a hostname can't collide.
|
||||||
|
|
||||||
### Failure behavior
|
### Failure behavior
|
||||||
|
|
||||||
@@ -143,7 +351,6 @@ plugins/host_agent/
|
|||||||
│ └── host_agent_001_initial.py # creates host_agent_registrations table
|
│ └── host_agent_001_initial.py # creates host_agent_registrations table
|
||||||
├── templates/
|
├── templates/
|
||||||
│ ├── detail.html # per-host detail page
|
│ ├── detail.html # per-host detail page
|
||||||
│ ├── widget_table.html # dashboard fleet table widget
|
|
||||||
│ ├── widget_history.html # dashboard history chart widget
|
│ ├── widget_history.html # dashboard history chart widget
|
||||||
│ ├── settings_list.html # plugin settings: list of registered hosts
|
│ ├── settings_list.html # plugin settings: list of registered hosts
|
||||||
│ └── install.sh.j2 # install script template (Jinja)
|
│ └── install.sh.j2 # install script template (Jinja)
|
||||||
@@ -192,9 +399,11 @@ One `ScheduledTask` running every 60 seconds that flags `HostAgentRegistration`
|
|||||||
|
|
||||||
**Note:** the existing alerts system can fire on "no metric for N seconds" which is arguably a cleaner signal. The scheduled task may end up redundant once alert rules exist. Build it anyway as a minimal safety net; it's one query plus one update.
|
**Note:** the existing alerts system can fire on "no metric for N seconds" which is arguably a cleaner signal. The scheduled task may end up redundant once alert rules exist. Build it anyway as a minimal safety net; it's one query plus one update.
|
||||||
|
|
||||||
|
**As built:** the task records `host_agent/<host>/seconds_since_seen` each minute, and "Agent offline" is an ordinary alert rule on it (`> 180`). It counts from the later of `last_seen_at` and the agent's newest post to *arrive* (kept in memory, `scheduler.arrivals`). `last_seen_at` is written in the ingest's own transaction, so a post Steward received but couldn't store (its storage stalled, #5514) would otherwise read as the agent's silence. Nothing counts from before Steward last started watching without a break (#5441).
|
||||||
|
|
||||||
### `METRIC_CATALOG` registration
|
### `METRIC_CATALOG` registration
|
||||||
|
|
||||||
Add to `roundtable/alerts/routes.py`:
|
Add to `steward/alerts/routes.py`:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
"host_agent": ["cpu_pct", "mem_used_pct", "mem_available_bytes",
|
"host_agent": ["cpu_pct", "mem_used_pct", "mem_available_bytes",
|
||||||
@@ -202,7 +411,7 @@ Add to `roundtable/alerts/routes.py`:
|
|||||||
"load_5m", "load_15m", "uptime_secs"],
|
"load_5m", "load_15m", "uptime_secs"],
|
||||||
```
|
```
|
||||||
|
|
||||||
This is the one edit outside the plugin directory, matching the pattern every other plugin already follows. Not ideal from a pure-plugin-independence standpoint but unavoidable until Roundtable core grows a plugin-registered catalog API — deferred as future core work.
|
This is the one edit outside the plugin directory, matching the pattern every other plugin already follows. Not ideal from a pure-plugin-independence standpoint but unavoidable until Steward core grows a plugin-registered catalog API — deferred as future core work.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -212,7 +421,7 @@ This is the one edit outside the plugin directory, matching the pattern every ot
|
|||||||
|
|
||||||
```http
|
```http
|
||||||
POST /plugins/host_agent/ingest HTTP/1.1
|
POST /plugins/host_agent/ingest HTTP/1.1
|
||||||
Host: roundtable.home.lan
|
Host: steward.home.lan
|
||||||
Authorization: Bearer a1b2c3d4e5f6...
|
Authorization: Bearer a1b2c3d4e5f6...
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
```
|
```
|
||||||
@@ -255,6 +464,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
|
||||||
|
|
||||||
@@ -311,7 +521,7 @@ Agent treats anything non-2xx as failure → ring buffer. Agent treats 401 speci
|
|||||||
### The one-liner (what the UI shows)
|
### The one-liner (what the UI shows)
|
||||||
|
|
||||||
```
|
```
|
||||||
curl -sSL 'https://roundtable.home.lan/plugins/host_agent/install.sh?token=a1b2c3...' | sudo sh
|
curl -sSL 'https://steward.home.lan/plugins/host_agent/install.sh?token=a1b2c3...' | sudo sh
|
||||||
```
|
```
|
||||||
|
|
||||||
The UI also offers a "review script before running" link that opens a modal showing the two-step form:
|
The UI also offers a "review script before running" link that opens a modal showing the two-step form:
|
||||||
@@ -324,93 +534,18 @@ sudo sh install.sh
|
|||||||
|
|
||||||
### Rendered script structure (Jinja template `install.sh.j2`)
|
### Rendered script structure (Jinja template `install.sh.j2`)
|
||||||
|
|
||||||
```sh
|
`plugins/host_agent/templates/install.sh.j2` is the script; it is not copied
|
||||||
#!/bin/sh
|
here, because a copy drifts. It checks it runs as root with systemd and
|
||||||
# Roundtable host agent installer
|
python3, handles `--uninstall`, creates the `steward-agent` user, fetches
|
||||||
# Generated for: {{ host_name }} ({{ host_address }})
|
`agent.py`, writes the config, then fetches the maintenance helper and the
|
||||||
# Roundtable URL: {{ url }}
|
three units from `/plugins/host_agent/files/` (the same files the playbooks
|
||||||
set -e
|
install), creates the maintenance directories, and enables
|
||||||
|
`steward-maint.path` and the agent.
|
||||||
ROUNDTABLE_URL="{{ url }}"
|
|
||||||
AGENT_TOKEN="{{ token }}"
|
|
||||||
AGENT_VERSION="{{ agent_version }}"
|
|
||||||
|
|
||||||
AGENT_USER="roundtable-agent"
|
|
||||||
AGENT_DIR="/usr/local/lib/roundtable-agent"
|
|
||||||
CONF_FILE="/etc/roundtable-agent.conf"
|
|
||||||
UNIT_FILE="/etc/systemd/system/roundtable-agent.service"
|
|
||||||
|
|
||||||
# ── preflight ────────────────────────────────────────────────────────────────
|
|
||||||
[ "$(id -u)" = "0" ] || { echo "must run as root (use sudo)"; exit 1; }
|
|
||||||
command -v systemctl >/dev/null 2>&1 || { echo "systemd not found — unsupported init system"; exit 1; }
|
|
||||||
command -v python3 >/dev/null 2>&1 || { echo "python3 not found — install python3 first"; exit 1; }
|
|
||||||
|
|
||||||
# Handle --uninstall
|
|
||||||
if [ "${1:-}" = "--uninstall" ]; then
|
|
||||||
systemctl disable --now roundtable-agent.service 2>/dev/null || true
|
|
||||||
rm -f "$UNIT_FILE" "$CONF_FILE"
|
|
||||||
rm -rf "$AGENT_DIR"
|
|
||||||
systemctl daemon-reload
|
|
||||||
# keep the system user — removing it risks orphaned files elsewhere
|
|
||||||
echo "uninstalled"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── create system user ───────────────────────────────────────────────────────
|
|
||||||
if ! id "$AGENT_USER" >/dev/null 2>&1; then
|
|
||||||
useradd --system --no-create-home --shell /usr/sbin/nologin "$AGENT_USER"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── drop the agent file ──────────────────────────────────────────────────────
|
|
||||||
mkdir -p "$AGENT_DIR"
|
|
||||||
curl -sSL "${ROUNDTABLE_URL}/plugins/host_agent/agent.py" -o "$AGENT_DIR/agent.py"
|
|
||||||
chmod 0755 "$AGENT_DIR/agent.py"
|
|
||||||
chown root:root "$AGENT_DIR/agent.py"
|
|
||||||
|
|
||||||
# ── write config ─────────────────────────────────────────────────────────────
|
|
||||||
cat > "$CONF_FILE" <<EOF
|
|
||||||
url = $ROUNDTABLE_URL
|
|
||||||
token = $AGENT_TOKEN
|
|
||||||
interval_seconds = 30
|
|
||||||
EOF
|
|
||||||
chown "root:$AGENT_USER" "$CONF_FILE"
|
|
||||||
chmod 0640 "$CONF_FILE"
|
|
||||||
|
|
||||||
# ── write systemd unit ───────────────────────────────────────────────────────
|
|
||||||
cat > "$UNIT_FILE" <<EOF
|
|
||||||
[Unit]
|
|
||||||
Description=Roundtable host agent
|
|
||||||
After=network-online.target
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=$AGENT_USER
|
|
||||||
ExecStart=/usr/bin/python3 $AGENT_DIR/agent.py
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=10
|
|
||||||
NoNewPrivileges=yes
|
|
||||||
ProtectSystem=strict
|
|
||||||
ProtectHome=yes
|
|
||||||
PrivateTmp=yes
|
|
||||||
ReadOnlyPaths=/proc /sys
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
EOF
|
|
||||||
|
|
||||||
systemctl daemon-reload
|
|
||||||
systemctl enable --now roundtable-agent.service
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "Roundtable host agent $AGENT_VERSION installed and running."
|
|
||||||
echo "Check status: systemctl status roundtable-agent"
|
|
||||||
echo "Logs: journalctl -u roundtable-agent -f"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Design points
|
### Design points
|
||||||
|
|
||||||
- **Agent binary served by Roundtable itself.** `GET /plugins/host_agent/agent.py` serves the script bundled with the plugin, so version drift between install-script and agent is impossible — re-running the installer picks up whatever agent the currently-running Roundtable ships.
|
- **One copy of every file a host installs.** The units and the maintenance helper live in `plugins/host_agent/files/` and are served at `/plugins/host_agent/files/<name>` (a fixed list; anything else is 404). install.sh and the install, provision and update playbooks all fetch them, so the unit can't differ between a host installed by hand and one installed by Ansible (#5766).
|
||||||
|
- **Agent binary served by Steward itself.** `GET /plugins/host_agent/agent.py` serves the script bundled with the plugin, so version drift between install-script and agent is impossible — re-running the installer picks up whatever agent the currently-running Steward ships.
|
||||||
- **Systemd hardening is cheap and correct.** `NoNewPrivileges`, `ProtectSystem=strict`, `ProtectHome`, `PrivateTmp`, `ReadOnlyPaths=/proc /sys`. The agent only needs read access to `/proc`, `/sys`, and mount points; denying everything else narrows blast radius.
|
- **Systemd hardening is cheap and correct.** `NoNewPrivileges`, `ProtectSystem=strict`, `ProtectHome`, `PrivateTmp`, `ReadOnlyPaths=/proc /sys`. The agent only needs read access to `/proc`, `/sys`, and mount points; denying everything else narrows blast radius.
|
||||||
- **Uninstall is a first-class flag**, not a separate script. Same one-liner with `--uninstall` appended.
|
- **Uninstall is a first-class flag**, not a separate script. Same one-liner with `--uninstall` appended.
|
||||||
- **Fail-fast on preflight.** Missing systemd or python3 → clear error, exit. No half-installed agent.
|
- **Fail-fast on preflight.** Missing systemd or python3 → clear error, exit. No half-installed agent.
|
||||||
@@ -421,9 +556,9 @@ echo "Logs: journalctl -u roundtable-agent -f"
|
|||||||
|
|
||||||
## UI surfaces
|
## UI surfaces
|
||||||
|
|
||||||
### Dashboard widgets (`roundtable/core/widgets.py`)
|
### Dashboard widgets (`steward/core/widgets.py`)
|
||||||
|
|
||||||
- **`host_resources`** — table widget. One row per monitored host: name, CPU %, mem %, disk % (worst mount), load 1m, "last seen Xs ago" with red/yellow/green coloring. Fleet glance.
|
- The fleet glance (CPU, mem, disk /, load 1m, stale mark) is the core **Hosts — Overview** widget (`hosts_overview`); the old `host_resources` table was folded into it (#5213).
|
||||||
- **`host_resource_history`** — chart widget for one host. CPU / mem / disk over a selectable time range (1h, 6h, 24h, 7d). Same pattern as every other history widget — Chart.js.
|
- **`host_resource_history`** — chart widget for one host. CPU / mem / disk over a selectable time range (1h, 6h, 24h, 7d). Same pattern as every other history widget — Chart.js.
|
||||||
|
|
||||||
### Per-host detail page
|
### Per-host detail page
|
||||||
@@ -447,9 +582,9 @@ List of registered hosts with their enable flags, an "Add host" button that open
|
|||||||
|
|
||||||
| Failure | Who handles it | Response |
|
| Failure | Who handles it | Response |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Agent can't reach Roundtable | Agent | Push to ring buffer, exponential backoff (30→60→120→300s cap), log WARN. Resets on success. |
|
| Agent can't reach Steward | Agent | Push to ring buffer, exponential backoff (30→60→120→300s cap), log WARN. Resets on success. |
|
||||||
| Roundtable rejects with 401 | Agent | Log ERROR with remediation hint, continue retry loop. Admin fixes via UI + conf edit; no restart needed. |
|
| Steward rejects with 401 | Agent | Log ERROR with remediation hint, continue retry loop. Admin fixes via UI + conf edit; no restart needed. |
|
||||||
| Roundtable rejects with 400 | Agent | Log ERROR, drop the sample (don't re-buffer), continue. Indicates agent/server version skew. |
|
| Steward rejects with 400 | Agent | Log ERROR, drop the sample (don't re-buffer), continue. Indicates agent/server version skew. |
|
||||||
| Ring buffer full | Agent | Drop oldest, keep newest. DEBUG log (expected during outages). |
|
| Ring buffer full | Agent | Drop oldest, keep newest. DEBUG log (expected during outages). |
|
||||||
| Config file missing or malformed | Agent | Log ERROR, exit non-zero. Systemd restarts after 10s. Repeated restarts are visible in journal. |
|
| Config file missing or malformed | Agent | Log ERROR, exit non-zero. Systemd restarts after 10s. Repeated restarts are visible in journal. |
|
||||||
| `/proc/stat` read fails between samples | Agent | Skip CPU for this cycle, still POST the rest. Partial samples allowed. |
|
| `/proc/stat` read fails between samples | Agent | Skip CPU for this cycle, still POST the rest. Partial samples allowed. |
|
||||||
@@ -528,7 +663,7 @@ These are not blockers; they are feature boundaries inherent to the design.
|
|||||||
6. Settings page: list, add-host flow, rotate-token, delete.
|
6. Settings page: list, add-host flow, rotate-token, delete.
|
||||||
7. Dashboard widgets: table + history chart. Register in `core/widgets.py`.
|
7. Dashboard widgets: table + history chart. Register in `core/widgets.py`.
|
||||||
8. Per-host detail page.
|
8. Per-host detail page.
|
||||||
9. `METRIC_CATALOG` entry in `roundtable/alerts/routes.py`.
|
9. `METRIC_CATALOG` entry in `steward/alerts/routes.py`.
|
||||||
10. Scheduler: stale-agent marker.
|
10. Scheduler: stale-agent marker.
|
||||||
11. Tests at every stage; final integration test to tie it together.
|
11. Tests at every stage; final integration test to tie it together.
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
**Goal:** Ship a `host_agent` Roundtable plugin plus a stdlib-only Python push agent that reports CPU/memory/storage/load/uptime from remote Linux hosts to Roundtable.
|
**Goal:** Ship a `host_agent` Steward plugin plus a stdlib-only Python push agent that reports CPU/memory/storage/load/uptime from remote Linux hosts to Steward.
|
||||||
|
|
||||||
**Architecture:** Plugin lives under `plugins/host_agent/` (mirrors `plugins/http/`). It owns a private `host_agent_registrations` table, writes time-series data into the core `PluginMetric` bus, serves its own agent source at `GET /plugins/host_agent/agent.py`, and renders a per-host install script at `GET /plugins/host_agent/install.sh`. The agent is a single ~300-line Python script with a 30s collect→POST loop, in-memory ring buffer, exponential backoff, and systemd unit installed by a curl one-liner.
|
**Architecture:** Plugin lives under `plugins/host_agent/` (mirrors `plugins/http/`). It owns a private `host_agent_registrations` table, writes time-series data into the core `PluginMetric` bus, serves its own agent source at `GET /plugins/host_agent/agent.py`, and renders a per-host install script at `GET /plugins/host_agent/install.sh`. The agent is a single ~300-line Python script with a 30s collect→POST loop, in-memory ring buffer, exponential backoff, and systemd unit installed by a curl one-liner.
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
## Execution note — path 1 (no DB-backed tests)
|
## Execution note — path 1 (no DB-backed tests)
|
||||||
|
|
||||||
Mid-execution discovery: the Roundtable test harness runs `create_app(testing=True)`, which mocks `db_sessionmaker` and skips all migrations. There is no existing DB-backed test fixture in this codebase, and no existing plugin ships with tests. The first execution attempt tried to invent a SQLite-backed override fixture, which cascaded into modifying a production migration (`0007_dashboard_ownership.py`) to remove an inline FK — unacceptable. That commit was reverted.
|
Mid-execution discovery: the Steward test harness runs `create_app(testing=True)`, which mocks `db_sessionmaker` and skips all migrations. There is no existing DB-backed test fixture in this codebase, and no existing plugin ships with tests. The first execution attempt tried to invent a SQLite-backed override fixture, which cascaded into modifying a production migration (`0007_dashboard_ownership.py`) to remove an inline FK — unacceptable. That commit was reverted.
|
||||||
|
|
||||||
**Revised test strategy:** test the agent exhaustively (everything that lives in `plugins/host_agent/agent.py` — pure Python, stdlib only, trivially unit-testable). Test the server-side metric-expansion function as a pure function that takes a sample dict + host name and returns a list of `(metric_name, resource_name, value)` tuples. Everything else — ingest route, install route, settings routes, widgets, detail page, scheduler — is verified manually against the dev server.
|
**Revised test strategy:** test the agent exhaustively (everything that lives in `plugins/host_agent/agent.py` — pure Python, stdlib only, trivially unit-testable). Test the server-side metric-expansion function as a pure function that takes a sample dict + host name and returns a list of `(metric_name, resource_name, value)` tuples. Everything else — ingest route, install route, settings routes, widgets, detail page, scheduler — is verified manually against the dev server.
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ Where code blocks in tasks below reference DB-backed tests, treat them as guidan
|
|||||||
- `plugins/host_agent/scheduler.py` — stale-agent marker task.
|
- `plugins/host_agent/scheduler.py` — stale-agent marker task.
|
||||||
- `plugins/host_agent/agent.py` — the Python agent script, served to targets.
|
- `plugins/host_agent/agent.py` — the Python agent script, served to targets.
|
||||||
- `plugins/host_agent/migrations/__init__.py`
|
- `plugins/host_agent/migrations/__init__.py`
|
||||||
- `plugins/host_agent/migrations/env.py` — alembic env (copy of http plugin's, but imports `roundtable.models.base`).
|
- `plugins/host_agent/migrations/env.py` — alembic env (copy of http plugin's, but imports `steward.models.base`).
|
||||||
- `plugins/host_agent/migrations/versions/__init__.py`
|
- `plugins/host_agent/migrations/versions/__init__.py`
|
||||||
- `plugins/host_agent/migrations/versions/host_agent_001_initial.py` — creates `host_agent_registrations`.
|
- `plugins/host_agent/migrations/versions/host_agent_001_initial.py` — creates `host_agent_registrations`.
|
||||||
- `plugins/host_agent/templates/install.sh.j2` — Jinja install script template.
|
- `plugins/host_agent/templates/install.sh.j2` — Jinja install script template.
|
||||||
@@ -68,8 +68,8 @@ Where code blocks in tasks below reference DB-backed tests, treat them as guidan
|
|||||||
|
|
||||||
**Modified files (core, small edits):**
|
**Modified files (core, small edits):**
|
||||||
|
|
||||||
- `roundtable/alerts/routes.py` — add `"host_agent"` entry to `METRIC_CATALOG`.
|
- `steward/alerts/routes.py` — add `"host_agent"` entry to `METRIC_CATALOG`.
|
||||||
- `roundtable/core/widgets.py` — add `host_resources` and `host_resource_history` entries.
|
- `steward/core/widgets.py` — add `host_resources` and `host_resource_history` entries.
|
||||||
- `docs/plugins/index.yaml.example` — add catalog entry.
|
- `docs/plugins/index.yaml.example` — add catalog entry.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -102,7 +102,7 @@ pytestmark = pytest.mark.asyncio
|
|||||||
|
|
||||||
async def test_host_agent_migration_creates_table(app):
|
async def test_host_agent_migration_creates_table(app):
|
||||||
# The app fixture runs all plugin migrations on startup.
|
# The app fixture runs all plugin migrations on startup.
|
||||||
from roundtable.core.db import get_engine
|
from steward.core.db import get_engine
|
||||||
engine = get_engine()
|
engine = get_engine()
|
||||||
async with engine.connect() as conn:
|
async with engine.connect() as conn:
|
||||||
def _check(sync_conn):
|
def _check(sync_conn):
|
||||||
@@ -127,11 +127,11 @@ Expected: FAIL (plugin not loaded, table missing).
|
|||||||
name: host_agent
|
name: host_agent
|
||||||
version: "1.0.0"
|
version: "1.0.0"
|
||||||
description: "Remote Linux host resource monitoring via a lightweight Python push agent (CPU, memory, storage, load, uptime)"
|
description: "Remote Linux host resource monitoring via a lightweight Python push agent (CPU, memory, storage, load, uptime)"
|
||||||
author: "Roundtable"
|
author: "Steward"
|
||||||
license: "MIT"
|
license: "MIT"
|
||||||
min_app_version: "0.1.0"
|
min_app_version: "0.1.0"
|
||||||
repository_url: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins"
|
repository_url: "https://git.fabledsword.com/bvandeusen/Steward-plugins"
|
||||||
homepage: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins/src/branch/main/host_agent"
|
homepage: "https://git.fabledsword.com/bvandeusen/Steward-plugins/src/branch/main/host_agent"
|
||||||
tags:
|
tags:
|
||||||
- host
|
- host
|
||||||
- monitoring
|
- monitoring
|
||||||
@@ -180,7 +180,7 @@ from __future__ import annotations
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from sqlalchemy import Column, String, DateTime, ForeignKey
|
from sqlalchemy import Column, String, DateTime, ForeignKey
|
||||||
from roundtable.models.base import Base
|
from steward.models.base import Base
|
||||||
|
|
||||||
|
|
||||||
def _uuid() -> str:
|
def _uuid() -> str:
|
||||||
@@ -269,8 +269,8 @@ from alembic import context
|
|||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
|
||||||
|
|
||||||
from roundtable.models.base import Base
|
from steward.models.base import Base
|
||||||
import roundtable.models # noqa: F401
|
import steward.models # noqa: F401
|
||||||
from plugins.host_agent.models import HostAgentRegistration # noqa: F401
|
from plugins.host_agent.models import HostAgentRegistration # noqa: F401
|
||||||
|
|
||||||
config = context.config
|
config = context.config
|
||||||
@@ -282,14 +282,14 @@ target_metadata = Base.metadata
|
|||||||
|
|
||||||
def _get_url() -> str:
|
def _get_url() -> str:
|
||||||
import yaml
|
import yaml
|
||||||
cfg_path = os.environ.get("ROUNDTABLE_CONFIG", "config.yaml")
|
cfg_path = os.environ.get("STEWARD_CONFIG", "config.yaml")
|
||||||
try:
|
try:
|
||||||
with open(cfg_path) as f:
|
with open(cfg_path) as f:
|
||||||
cfg = yaml.safe_load(f) or {}
|
cfg = yaml.safe_load(f) or {}
|
||||||
url = cfg.get("database", {}).get("url", "")
|
url = cfg.get("database", {}).get("url", "")
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
url = ""
|
url = ""
|
||||||
return os.environ.get("ROUNDTABLE_DATABASE__URL", url)
|
return os.environ.get("STEWARD_DATABASE__URL", url)
|
||||||
|
|
||||||
|
|
||||||
def run_migrations_offline() -> None:
|
def run_migrations_offline() -> None:
|
||||||
@@ -413,12 +413,12 @@ from plugins.host_agent.agent import read_config, ConfigError
|
|||||||
def test_parses_flat_key_value(tmp_path):
|
def test_parses_flat_key_value(tmp_path):
|
||||||
p = tmp_path / "agent.conf"
|
p = tmp_path / "agent.conf"
|
||||||
p.write_text(
|
p.write_text(
|
||||||
"url = https://roundtable.example\n"
|
"url = https://steward.example\n"
|
||||||
"token = abc123\n"
|
"token = abc123\n"
|
||||||
"interval_seconds = 45\n"
|
"interval_seconds = 45\n"
|
||||||
)
|
)
|
||||||
cfg = read_config(str(p))
|
cfg = read_config(str(p))
|
||||||
assert cfg["url"] == "https://roundtable.example"
|
assert cfg["url"] == "https://steward.example"
|
||||||
assert cfg["token"] == "abc123"
|
assert cfg["token"] == "abc123"
|
||||||
assert cfg["interval_seconds"] == 45
|
assert cfg["interval_seconds"] == 45
|
||||||
|
|
||||||
@@ -466,7 +466,7 @@ Expected: FAIL (`read_config` does not exist).
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
# plugins/host_agent/agent.py
|
# plugins/host_agent/agent.py
|
||||||
"""Roundtable host agent — pushes resource metrics to a Roundtable instance.
|
"""Steward host agent — pushes resource metrics to a Steward instance.
|
||||||
|
|
||||||
Python 3.8+ stdlib only. Target ~300 lines. Served to targets at
|
Python 3.8+ stdlib only. Target ~300 lines. Served to targets at
|
||||||
GET /plugins/host_agent/agent.py.
|
GET /plugins/host_agent/agent.py.
|
||||||
@@ -1060,7 +1060,7 @@ def post_payload(url: str, token: str, payload: dict) -> tuple[bool, int | None]
|
|||||||
headers={
|
headers={
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Authorization": f"Bearer {token}",
|
"Authorization": f"Bearer {token}",
|
||||||
"User-Agent": f"roundtable-host-agent/{AGENT_VERSION}",
|
"User-Agent": f"steward-host-agent/{AGENT_VERSION}",
|
||||||
},
|
},
|
||||||
method="POST",
|
method="POST",
|
||||||
)
|
)
|
||||||
@@ -1112,7 +1112,7 @@ def main_loop(conf_path: str) -> int:
|
|||||||
buffer = RingBuffer(maxlen=20)
|
buffer = RingBuffer(maxlen=20)
|
||||||
backoff = 0
|
backoff = 0
|
||||||
|
|
||||||
_log("INFO", f"roundtable-host-agent {AGENT_VERSION} starting "
|
_log("INFO", f"steward-host-agent {AGENT_VERSION} starting "
|
||||||
f"(url={cfg['url']}, interval={cfg['interval_seconds']}s)")
|
f"(url={cfg['url']}, interval={cfg['interval_seconds']}s)")
|
||||||
|
|
||||||
while not _shutdown_requested:
|
while not _shutdown_requested:
|
||||||
@@ -1171,7 +1171,7 @@ def main_loop(conf_path: str) -> int:
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
conf = os.environ.get("ROUNDTABLE_AGENT_CONFIG", "/etc/roundtable-agent.conf")
|
conf = os.environ.get("STEWARD_AGENT_CONFIG", "/etc/steward-agent.conf")
|
||||||
sys.exit(main_loop(conf))
|
sys.exit(main_loop(conf))
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1209,8 +1209,8 @@ git commit -m "feat(host_agent): agent POST, backoff, and main loop"
|
|||||||
import hashlib
|
import hashlib
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
|
|
||||||
from roundtable.models.hosts import Host
|
from steward.models.hosts import Host
|
||||||
from roundtable.core.db import get_session
|
from steward.core.db import get_session
|
||||||
from plugins.host_agent.models import HostAgentRegistration
|
from plugins.host_agent.models import HostAgentRegistration
|
||||||
|
|
||||||
|
|
||||||
@@ -1233,7 +1233,7 @@ async def registered_host(app):
|
|||||||
return {"host": host, "registration": reg, "token": raw_token}
|
return {"host": host, "registration": reg, "token": raw_token}
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Note:** Verify `Host.__init__` accepts `name=` and `address=` kwargs before relying on this. If the real Host model requires more fields (e.g., `probe_type`), pass them here. Read `roundtable/models/hosts.py` first to confirm.
|
> **Note:** Verify `Host.__init__` accepts `name=` and `address=` kwargs before relying on this. If the real Host model requires more fields (e.g., `probe_type`), pass them here. Read `steward/models/hosts.py` first to confirm.
|
||||||
|
|
||||||
- [ ] **Step 2: Write failing ingest test**
|
- [ ] **Step 2: Write failing ingest test**
|
||||||
|
|
||||||
@@ -1244,8 +1244,8 @@ from datetime import datetime, timezone
|
|||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from roundtable.models.metrics import PluginMetric
|
from steward.models.metrics import PluginMetric
|
||||||
from roundtable.core.db import get_session
|
from steward.core.db import get_session
|
||||||
from plugins.host_agent.models import HostAgentRegistration
|
from plugins.host_agent.models import HostAgentRegistration
|
||||||
|
|
||||||
pytestmark = pytest.mark.asyncio
|
pytestmark = pytest.mark.asyncio
|
||||||
@@ -1352,9 +1352,9 @@ from typing import Any
|
|||||||
from quart import Blueprint, jsonify, request
|
from quart import Blueprint, jsonify, request
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from roundtable.core.db import get_session
|
from steward.core.db import get_session
|
||||||
from roundtable.models.hosts import Host
|
from steward.models.hosts import Host
|
||||||
from roundtable.models.metrics import PluginMetric
|
from steward.models.metrics import PluginMetric
|
||||||
from .models import HostAgentRegistration
|
from .models import HostAgentRegistration
|
||||||
|
|
||||||
host_agent_bp = Blueprint("host_agent", __name__, template_folder="templates")
|
host_agent_bp = Blueprint("host_agent", __name__, template_folder="templates")
|
||||||
@@ -1628,7 +1628,7 @@ async def test_install_sh_renders_with_token(client, registered_host):
|
|||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.content_type.startswith("text/plain")
|
assert resp.content_type.startswith("text/plain")
|
||||||
text = (await resp.get_data()).decode()
|
text = (await resp.get_data()).decode()
|
||||||
assert "roundtable-agent" in text
|
assert "steward-agent" in text
|
||||||
assert registered_host["token"] in text
|
assert registered_host["token"] in text
|
||||||
assert "systemctl enable --now" in text
|
assert "systemctl enable --now" in text
|
||||||
assert "NoNewPrivileges=yes" in text
|
assert "NoNewPrivileges=yes" in text
|
||||||
@@ -1758,11 +1758,11 @@ git commit -m "feat(host_agent): install.sh and agent.py serving routes"
|
|||||||
- Create: `plugins/host_agent/templates/settings_list.html`
|
- Create: `plugins/host_agent/templates/settings_list.html`
|
||||||
- Create: `tests/plugins/host_agent/test_settings_routes.py`
|
- Create: `tests/plugins/host_agent/test_settings_routes.py`
|
||||||
|
|
||||||
Auth note: the existing Roundtable admin decorator pattern needs to be used here. Read `roundtable/settings/routes.py` (or similar) to find the decorator name — likely something like `@require_admin` or a session check. Use whatever the rest of the codebase uses. **Do not invent a new auth mechanism.**
|
Auth note: the existing Steward admin decorator pattern needs to be used here. Read `steward/settings/routes.py` (or similar) to find the decorator name — likely something like `@require_admin` or a session check. Use whatever the rest of the codebase uses. **Do not invent a new auth mechanism.**
|
||||||
|
|
||||||
- [ ] **Step 1: Find the admin auth decorator**
|
- [ ] **Step 1: Find the admin auth decorator**
|
||||||
|
|
||||||
Run: `grep -rn "require_admin\|@admin_required\|def admin" roundtable/settings/ roundtable/core/auth.py 2>/dev/null | head -20`
|
Run: `grep -rn "require_admin\|@admin_required\|def admin" steward/settings/ steward/core/auth.py 2>/dev/null | head -20`
|
||||||
Note the decorator name and import path for use in Step 3.
|
Note the decorator name and import path for use in Step 3.
|
||||||
|
|
||||||
- [ ] **Step 2: Write failing test**
|
- [ ] **Step 2: Write failing test**
|
||||||
@@ -1772,8 +1772,8 @@ Note the decorator name and import path for use in Step 3.
|
|||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from roundtable.core.db import get_session
|
from steward.core.db import get_session
|
||||||
from roundtable.models.hosts import Host
|
from steward.models.hosts import Host
|
||||||
from plugins.host_agent.models import HostAgentRegistration
|
from plugins.host_agent.models import HostAgentRegistration
|
||||||
|
|
||||||
pytestmark = pytest.mark.asyncio
|
pytestmark = pytest.mark.asyncio
|
||||||
@@ -1839,7 +1839,7 @@ from quart import redirect, url_for
|
|||||||
|
|
||||||
# TODO: replace _require_admin with the project-wide decorator found in Step 1.
|
# TODO: replace _require_admin with the project-wide decorator found in Step 1.
|
||||||
# Placeholder below mirrors the shape; swap for real admin auth.
|
# Placeholder below mirrors the shape; swap for real admin auth.
|
||||||
from roundtable.core.auth import require_admin # adjust import to actual path
|
from steward.core.auth import require_admin # adjust import to actual path
|
||||||
|
|
||||||
|
|
||||||
def _new_token_pair() -> tuple[str, str]:
|
def _new_token_pair() -> tuple[str, str]:
|
||||||
@@ -1938,7 +1938,7 @@ async def settings_list():
|
|||||||
|
|
||||||
```html
|
```html
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Host Agent — Roundtable{% endblock %}
|
{% block title %}Host Agent — Steward{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1 class="page-title">Host Agent — Registered Hosts</h1>
|
<h1 class="page-title">Host Agent — Registered Hosts</h1>
|
||||||
|
|
||||||
@@ -2003,7 +2003,7 @@ async def settings_list():
|
|||||||
- [ ] **Step 6: Run tests — iterate on auth decorator if needed**
|
- [ ] **Step 6: Run tests — iterate on auth decorator if needed**
|
||||||
|
|
||||||
Run: `pytest tests/plugins/host_agent/test_settings_routes.py -v`
|
Run: `pytest tests/plugins/host_agent/test_settings_routes.py -v`
|
||||||
Expected: PASS. If admin auth fails, the actual import path from Step 1 is wrong — fix the `from roundtable.core.auth import require_admin` line.
|
Expected: PASS. If admin auth fails, the actual import path from Step 1 is wrong — fix the `from steward.core.auth import require_admin` line.
|
||||||
|
|
||||||
- [ ] **Step 7: Commit**
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
@@ -2020,8 +2020,8 @@ git commit -m "feat(host_agent): plugin settings page — add, rotate, delete"
|
|||||||
- Modify: `plugins/host_agent/routes.py` — add `/widget` and `/widget/history` partials.
|
- Modify: `plugins/host_agent/routes.py` — add `/widget` and `/widget/history` partials.
|
||||||
- Create: `plugins/host_agent/templates/widget_table.html`
|
- Create: `plugins/host_agent/templates/widget_table.html`
|
||||||
- Create: `plugins/host_agent/templates/widget_history.html`
|
- Create: `plugins/host_agent/templates/widget_history.html`
|
||||||
- Modify: `roundtable/core/widgets.py`
|
- Modify: `steward/core/widgets.py`
|
||||||
- Modify: `roundtable/alerts/routes.py`
|
- Modify: `steward/alerts/routes.py`
|
||||||
|
|
||||||
- [ ] **Step 1: Add widget partial routes to `plugins/host_agent/routes.py`**
|
- [ ] **Step 1: Add widget partial routes to `plugins/host_agent/routes.py`**
|
||||||
|
|
||||||
@@ -2165,7 +2165,7 @@ async def widget_history():
|
|||||||
</div>
|
</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 4: Register widgets in `roundtable/core/widgets.py`**
|
- [ ] **Step 4: Register widgets in `steward/core/widgets.py`**
|
||||||
|
|
||||||
Add to `WIDGET_REGISTRY` (alphabetically by existing convention, or at the end — check the file first):
|
Add to `WIDGET_REGISTRY` (alphabetically by existing convention, or at the end — check the file first):
|
||||||
|
|
||||||
@@ -2206,7 +2206,7 @@ Add to `WIDGET_REGISTRY` (alphabetically by existing convention, or at the end
|
|||||||
|
|
||||||
- [ ] **Step 5: Register `host_agent` in `METRIC_CATALOG`**
|
- [ ] **Step 5: Register `host_agent` in `METRIC_CATALOG`**
|
||||||
|
|
||||||
Read `roundtable/alerts/routes.py`. Locate the `METRIC_CATALOG` dict. Add:
|
Read `steward/alerts/routes.py`. Locate the `METRIC_CATALOG` dict. Add:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
"host_agent": [
|
"host_agent": [
|
||||||
@@ -2244,7 +2244,7 @@ Expected: PASS.
|
|||||||
- [ ] **Step 7: Commit**
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add plugins/host_agent/routes.py plugins/host_agent/templates/widget_table.html plugins/host_agent/templates/widget_history.html roundtable/core/widgets.py roundtable/alerts/routes.py tests/plugins/host_agent/test_ingest_route.py
|
git add plugins/host_agent/routes.py plugins/host_agent/templates/widget_table.html plugins/host_agent/templates/widget_history.html steward/core/widgets.py steward/alerts/routes.py tests/plugins/host_agent/test_ingest_route.py
|
||||||
git commit -m "feat(host_agent): dashboard widgets and alert metric catalog entry"
|
git commit -m "feat(host_agent): dashboard widgets and alert metric catalog entry"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -2409,8 +2409,8 @@ from datetime import datetime, timedelta, timezone
|
|||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from roundtable.core.db import get_session
|
from steward.core.db import get_session
|
||||||
from roundtable.models.hosts import Host
|
from steward.models.hosts import Host
|
||||||
from plugins.host_agent.models import HostAgentRegistration
|
from plugins.host_agent.models import HostAgentRegistration
|
||||||
from plugins.host_agent.scheduler import find_stale_registrations
|
from plugins.host_agent.scheduler import find_stale_registrations
|
||||||
|
|
||||||
@@ -2459,8 +2459,8 @@ from datetime import datetime, timedelta, timezone
|
|||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from roundtable.core.db import get_session
|
from steward.core.db import get_session
|
||||||
from roundtable.models.hosts import Host
|
from steward.models.hosts import Host
|
||||||
from .models import HostAgentRegistration
|
from .models import HostAgentRegistration
|
||||||
|
|
||||||
|
|
||||||
@@ -2531,8 +2531,8 @@ from unittest.mock import patch
|
|||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from roundtable.core.db import get_session
|
from steward.core.db import get_session
|
||||||
from roundtable.models.metrics import PluginMetric
|
from steward.models.metrics import PluginMetric
|
||||||
from plugins.host_agent import agent as a
|
from plugins.host_agent import agent as a
|
||||||
|
|
||||||
pytestmark = pytest.mark.asyncio
|
pytestmark = pytest.mark.asyncio
|
||||||
@@ -2609,12 +2609,12 @@ Append to the `plugins:` list in `docs/plugins/index.yaml.example`:
|
|||||||
- name: host_agent
|
- name: host_agent
|
||||||
version: "1.0.0"
|
version: "1.0.0"
|
||||||
description: "Remote Linux host resource monitoring via a lightweight Python push agent"
|
description: "Remote Linux host resource monitoring via a lightweight Python push agent"
|
||||||
author: "Roundtable"
|
author: "Steward"
|
||||||
license: "MIT"
|
license: "MIT"
|
||||||
min_app_version: "0.1.0"
|
min_app_version: "0.1.0"
|
||||||
repository_url: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins"
|
repository_url: "https://git.fabledsword.com/bvandeusen/Steward-plugins"
|
||||||
homepage: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins/src/branch/main/host_agent"
|
homepage: "https://git.fabledsword.com/bvandeusen/Steward-plugins/src/branch/main/host_agent"
|
||||||
download_url: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins/releases/download/host_agent-v1.0.0/host_agent.zip"
|
download_url: "https://git.fabledsword.com/bvandeusen/Steward-plugins/releases/download/host_agent-v1.0.0/host_agent.zip"
|
||||||
checksum_sha256: ""
|
checksum_sha256: ""
|
||||||
tags:
|
tags:
|
||||||
- host
|
- host
|
||||||
@@ -2643,7 +2643,7 @@ Use `fable_update_task` to set status=done on task 252 ("Implement host_agent pl
|
|||||||
|
|
||||||
- [ ] **Step 2: Add a Fable note summarizing what shipped**
|
- [ ] **Step 2: Add a Fable note summarizing what shipped**
|
||||||
|
|
||||||
One-paragraph `fable_create_note` attached to Roundtable project (id 6) with:
|
One-paragraph `fable_create_note` attached to Steward project (id 6) with:
|
||||||
- Link to spec: `docs/plugins/host-agent-design.md`
|
- Link to spec: `docs/plugins/host-agent-design.md`
|
||||||
- Link to plan: `docs/plugins/host-agent-plan.md`
|
- Link to plan: `docs/plugins/host-agent-plan.md`
|
||||||
- Note any deviations from the spec discovered during implementation (e.g., auth decorator name, fixture adjustments).
|
- Note any deviations from the spec discovered during implementation (e.g., auth decorator name, fixture adjustments).
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
# roundtable-plugins / index.yaml
|
|
||||||
#
|
|
||||||
# This file is the catalog index for the roundtable plugin repository.
|
|
||||||
# It is fetched by the app's Settings → Plugins → Plugin Catalog UI.
|
|
||||||
#
|
|
||||||
# Roundtable reads this file from:
|
|
||||||
# https://git.fabledsword.com/bvandeusen/Roundtable-plugins/raw/branch/main/index.yaml
|
|
||||||
#
|
|
||||||
# After adding or updating a plugin entry, commit and push — the change is
|
|
||||||
# live immediately for anyone whose app fetches the catalog (cache TTL: 5 min).
|
|
||||||
#
|
|
||||||
# REQUIRED fields for each plugin entry:
|
|
||||||
# name - must match the plugin's directory name exactly
|
|
||||||
# version - semver string
|
|
||||||
# download_url - direct URL to a zip file containing the plugin
|
|
||||||
# checksum_sha256 - SHA-256 hex digest of the zip (leave empty to skip verification)
|
|
||||||
#
|
|
||||||
# OPTIONAL fields:
|
|
||||||
# description, author, license, min_app_version,
|
|
||||||
# repository_url, homepage, tags
|
|
||||||
#
|
|
||||||
# Generating a checksum:
|
|
||||||
# sha256sum traefik.zip
|
|
||||||
# shasum -a 256 traefik.zip (macOS)
|
|
||||||
#
|
|
||||||
# Download URL conventions:
|
|
||||||
# Gitea release assets (recommended):
|
|
||||||
# https://git.fabledsword.com/bvandeusen/Roundtable-plugins/releases/download/traefik-v1.0.0/traefik.zip
|
|
||||||
# Upload the zip as a release attachment in Gitea; paste the URL here.
|
|
||||||
# Source archive tarballs work too but release assets are preferred (smaller, plugin-only).
|
|
||||||
|
|
||||||
version: 1
|
|
||||||
updated: "2026-03-22"
|
|
||||||
|
|
||||||
plugins:
|
|
||||||
|
|
||||||
- name: http
|
|
||||||
version: "1.0.0"
|
|
||||||
description: "Synthetic HTTP endpoint monitoring — status code, response time, content match, TLS expiry"
|
|
||||||
author: "Roundtable"
|
|
||||||
license: "MIT"
|
|
||||||
min_app_version: "0.1.0"
|
|
||||||
repository_url: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins"
|
|
||||||
homepage: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins/src/branch/main/http"
|
|
||||||
download_url: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins/releases/download/http-v1.0.0/http.zip"
|
|
||||||
checksum_sha256: ""
|
|
||||||
tags:
|
|
||||||
- monitoring
|
|
||||||
- http
|
|
||||||
- synthetic
|
|
||||||
- uptime
|
|
||||||
|
|
||||||
- name: docker
|
|
||||||
version: "1.0.0"
|
|
||||||
description: "Docker container status, resource usage, and restart tracking via Docker socket"
|
|
||||||
author: "Roundtable"
|
|
||||||
license: "MIT"
|
|
||||||
min_app_version: "0.1.0"
|
|
||||||
repository_url: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins"
|
|
||||||
homepage: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins/src/branch/main/docker"
|
|
||||||
download_url: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins/releases/download/docker-v1.0.0/docker.zip"
|
|
||||||
checksum_sha256: ""
|
|
||||||
tags:
|
|
||||||
- containers
|
|
||||||
- docker
|
|
||||||
- infrastructure
|
|
||||||
|
|
||||||
- name: traefik
|
|
||||||
version: "1.0.0"
|
|
||||||
description: "Traefik reverse proxy metrics and access log integration"
|
|
||||||
author: "Roundtable"
|
|
||||||
license: "MIT"
|
|
||||||
min_app_version: "0.1.0"
|
|
||||||
repository_url: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins"
|
|
||||||
homepage: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins/src/branch/main/traefik"
|
|
||||||
download_url: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins/releases/download/traefik-v1.0.0/traefik.zip"
|
|
||||||
checksum_sha256: "" # fill in after running: sha256sum traefik.zip
|
|
||||||
tags:
|
|
||||||
- proxy
|
|
||||||
- metrics
|
|
||||||
- access-log
|
|
||||||
|
|
||||||
- name: unifi
|
|
||||||
version: "1.0.0"
|
|
||||||
description: "UniFi Network controller integration — WAN health, devices, clients, DPI"
|
|
||||||
author: "Roundtable"
|
|
||||||
license: "MIT"
|
|
||||||
min_app_version: "0.1.0"
|
|
||||||
repository_url: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins"
|
|
||||||
homepage: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins/src/branch/main/unifi"
|
|
||||||
download_url: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins/releases/download/unifi-v1.0.0/unifi.zip"
|
|
||||||
checksum_sha256: ""
|
|
||||||
tags:
|
|
||||||
- network
|
|
||||||
- unifi
|
|
||||||
- ubiquiti
|
|
||||||
|
|
||||||
- name: host_agent
|
|
||||||
version: "1.0.0"
|
|
||||||
description: "Remote Linux host resource monitoring via a lightweight Python push agent"
|
|
||||||
author: "Roundtable"
|
|
||||||
license: "MIT"
|
|
||||||
min_app_version: "0.1.0"
|
|
||||||
repository_url: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins"
|
|
||||||
homepage: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins/src/branch/main/host_agent"
|
|
||||||
download_url: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins/releases/download/host_agent-v1.0.0/host_agent.zip"
|
|
||||||
checksum_sha256: ""
|
|
||||||
tags:
|
|
||||||
- host
|
|
||||||
- monitoring
|
|
||||||
- cpu
|
|
||||||
- memory
|
|
||||||
- storage
|
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
# Plugin System Overview
|
# Plugin System Overview
|
||||||
|
|
||||||
Plugins extend Roundtable with new data sources, UI pages, scheduled tasks, and dashboard widgets. Only enabled plugins are imported — disabled or unlisted plugins have zero runtime overhead.
|
Plugins extend Steward with new data sources, UI pages, scheduled tasks, and dashboard widgets. Only enabled plugins are imported — disabled or unlisted plugins have zero runtime overhead.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## How Plugins Are Loaded
|
## How Plugins Are Loaded
|
||||||
|
|
||||||
Plugin loading happens in step 9 of `create_app()`, after all core blueprints and tasks are registered. The entrypoint is `load_plugins(app)` in `roundtable/core/plugin_manager.py`.
|
Plugin loading happens in step 9 of `create_app()`, after all core blueprints and tasks are registered. The entrypoint is `load_plugins(app)` in `steward/core/plugin_manager.py`.
|
||||||
|
|
||||||
For each plugin listed as `enabled: true` in the `PLUGINS` config:
|
For each plugin listed as `enabled: true` in the `PLUGINS` config:
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ description: "Does a thing"
|
|||||||
|
|
||||||
# Optional
|
# Optional
|
||||||
author: "Your Name"
|
author: "Your Name"
|
||||||
min_app_version: "0.1.0" # Minimum Roundtable version required
|
min_app_version: "0.1.0" # Minimum Steward version required
|
||||||
|
|
||||||
# Default config — merged with user overrides at runtime
|
# Default config — merged with user overrides at runtime
|
||||||
# Access at runtime via: app.config["PLUGINS"]["myplugin"]["my_setting"]
|
# Access at runtime via: app.config["PLUGINS"]["myplugin"]["my_setting"]
|
||||||
@@ -90,7 +90,7 @@ def setup(app):
|
|||||||
Returns a list of `ScheduledTask` objects. Return `[]` if the plugin has no background tasks. Called after `setup()`, so any app references set in `setup()` are available.
|
Returns a list of `ScheduledTask` objects. Return `[]` if the plugin has no background tasks. Called after `setup()`, so any app references set in `setup()` are available.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from roundtable.core.scheduler import ScheduledTask
|
from steward.core.scheduler import ScheduledTask
|
||||||
|
|
||||||
def get_scheduled_tasks():
|
def get_scheduled_tasks():
|
||||||
app = _app
|
app = _app
|
||||||
@@ -154,7 +154,7 @@ A plugin can contribute a dashboard widget by:
|
|||||||
1. Adding a `GET /widget` route to its blueprint that returns an HTMX HTML fragment
|
1. Adding a `GET /widget` route to its blueprint that returns an HTMX HTML fragment
|
||||||
2. The dashboard template polling that endpoint with HTMX
|
2. The dashboard template polling that endpoint with HTMX
|
||||||
|
|
||||||
The dashboard (`roundtable/templates/dashboard/index.html`) currently includes the Traefik widget conditionally based on `traefik_enabled`. To add a new plugin widget, the dashboard route and template both need to be updated to detect and render the new widget. See the Traefik widget implementation as a reference.
|
The dashboard (`steward/templates/dashboard/index.html`) currently includes the Traefik widget conditionally based on `traefik_enabled`. To add a new plugin widget, the dashboard route and template both need to be updated to detect and render the new widget. See the Traefik widget implementation as a reference.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
# Prometheus widget templates
|
||||||
|
|
||||||
|
The Prometheus dashboard widget draws one scrape target through a **template**: a short YAML file that says which of the target's series to show and how. Steward ships with one, **Fabled Archives**. You can write your own, paste in one someone shared, and share yours as a file.
|
||||||
|
|
||||||
|
A template is data, not code. It names series and lays them out in **blocks**, and Steward draws them. It cannot add a colour, a chart or a formula. Every widget it draws has the same shape:
|
||||||
|
|
||||||
|
1. **A verdict:** "All well.", or the worst thing and how many need a look ("2 need a look. Replication: FullNightly-Restic is running, 11h 53m overdue.").
|
||||||
|
2. **Each block in turn**, its problems first, saying why, then the healthy rest. After six healthy rows a block says "+N more healthy".
|
||||||
|
3. **The target's name and when it was scraped**, linking to its page.
|
||||||
|
|
||||||
|
This page covers where templates live, a walk through a first template, each block with an example, the format reference, and the Fabled Archives template.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Where templates live
|
||||||
|
|
||||||
|
**Prometheus → Widget templates** lists every template: the built-in ones, and your own. Open one to see its YAML beside a **preview**, which draws it over a target you pick from the values Steward has already stored. The preview redraws as you type and lists each problem with its line number.
|
||||||
|
|
||||||
|
| You want to | Do this |
|
||||||
|
|---|---|
|
||||||
|
| Start from scratch | **New template**, then write or paste YAML. |
|
||||||
|
| Use one someone sent you | **New template**, then paste it or **upload** the `.yaml` file. Look at the preview, then **Save template**. |
|
||||||
|
| Change a built-in one | Open it, then **Duplicate**. Built-in templates are read-only and change with Steward, so you edit the copy. |
|
||||||
|
| Share yours | **Export** downloads the YAML as a file. |
|
||||||
|
| Use it on a dashboard | Edit the dashboard, **Add widget → Prometheus**, and pick the target and the template. |
|
||||||
|
|
||||||
|
A template saves only when it has no problems, and its `name` must differ from every other template's. A widget remembers which template it draws with, so renaming a template keeps its widgets. Deleting one leaves its widgets saying the template is gone. Your templates are in Steward's backups, and adding, changing or deleting one is recorded in the audit log.
|
||||||
|
|
||||||
|
Steward never fetches a template from anywhere. Templates arrive only by being pasted or uploaded.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Your first template
|
||||||
|
|
||||||
|
Look at the target's page first (**Prometheus → the target**). It lists every series Steward stored, with its labels and newest value. A template names those series exactly as listed there.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: My service
|
||||||
|
version: 1
|
||||||
|
expects: myservice_
|
||||||
|
blocks:
|
||||||
|
- values:
|
||||||
|
title: Health
|
||||||
|
items:
|
||||||
|
- label: Queue depth
|
||||||
|
metric: myservice_queue_depth
|
||||||
|
unit: count
|
||||||
|
warn_above: 100
|
||||||
|
- label: Disk free
|
||||||
|
metric: myservice_disk_free_bytes
|
||||||
|
of: myservice_disk_size_bytes
|
||||||
|
warn_below: 10%
|
||||||
|
crit_below: 5%
|
||||||
|
- times:
|
||||||
|
items:
|
||||||
|
- label: Last good run
|
||||||
|
metric: myservice_last_success_timestamp_seconds
|
||||||
|
warn_after: 26h
|
||||||
|
crit_after: 2d
|
||||||
|
```
|
||||||
|
|
||||||
|
- `name` is what the widget is called, and what the template list and the widget picker show.
|
||||||
|
- `version` is the template's own version. Raise it when you change a template you have shared, so the people you sent it to can tell copies apart.
|
||||||
|
- `expects` is the start of the metric names it reads. If the target reports none, the widget says so ("is this the template for it?") instead of a column of blanks.
|
||||||
|
|
||||||
|
**Metric names as Steward stores them.** A gauge keeps its name. A counter is stored as a rate per second: `requests_total` becomes `requests_per_sec`. A histogram keeps only `_sum_per_sec` and `_count_per_sec`. See [How series are stored](prometheus.md#how-series-are-stored).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The blocks
|
||||||
|
|
||||||
|
Every block takes an optional `title`. Without one, it gets the default title shown in its heading below.
|
||||||
|
|
||||||
|
### `states`: a state set, one row per thing (default title "States")
|
||||||
|
|
||||||
|
A state set has one series per possible state, with the current one at 1 and the rest at 0. Archives' `fabledarchives_backup_target_state{target, state}` is one. Each thing it describes becomes a row naming its current state.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- states:
|
||||||
|
title: Backup targets
|
||||||
|
metric: fabledarchives_backup_target_state
|
||||||
|
name: target # the label that names each row
|
||||||
|
healthy: [fresh]
|
||||||
|
quiet: [disabled, waiting] # shown greyed, never a problem
|
||||||
|
warning: [late] # anything not listed is a problem
|
||||||
|
columns:
|
||||||
|
- {label: Last backup, metric: fabledarchives_backup_target_last_backup_timestamp_seconds}
|
||||||
|
- {label: Stored, metric: fabledarchives_backup_target_stored_bytes}
|
||||||
|
```
|
||||||
|
|
||||||
|
- A state in `healthy` is fine, in `quiet` is shown greyed, and in `warning` is amber. **A state in none of the lists is red.** So a state the service adds later shows up as a problem rather than slipping by as healthy.
|
||||||
|
- A row reporting no current state is a warning.
|
||||||
|
- `columns` add the row's other series beside its state. Each column series is matched to its row by having the same labels. Failing that, it is matched by the label `name` points at.
|
||||||
|
- **Deadlines:** `warn_past` and `crit_past` each name a `*_timestamp_seconds` metric per row. Once that time has passed, a row in a healthy state reads as a warning, or a problem for `crit_past` ("is running, 11h 53m overdue"). Quiet and failing rows keep their own reading. Use this when a service ranks a state like "running" above lateness, so a run that never ends would otherwise read as healthy.
|
||||||
|
|
||||||
|
### `values`: figures with their units (default title "Figures")
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- values:
|
||||||
|
title: Storage
|
||||||
|
items:
|
||||||
|
- label: Disk free
|
||||||
|
metric: fabledarchives_data_disk_free_bytes
|
||||||
|
of: fabledarchives_data_disk_size_bytes # shown as "16.2 TiB of 41.9 TiB", with a bar
|
||||||
|
warn_below: 10% # a share of `of`
|
||||||
|
crit_below: 5%
|
||||||
|
sparkline: true # the last day as small bars beside it
|
||||||
|
- label: Stored
|
||||||
|
metric: fabledarchives_person_stored_bytes
|
||||||
|
name: person # one row per person
|
||||||
|
where: {kind: files} # only the series with kind=files
|
||||||
|
```
|
||||||
|
|
||||||
|
- `warn_above`, `crit_above`, `warn_below` and `crit_below` take a number, or a share like `10%` when the item has `of`. The red thresholds are checked before the amber ones.
|
||||||
|
- Without `name`, an item must match exactly one series. If more match, the row says how many and asks you to narrow it with `where` or `name`.
|
||||||
|
- A series the target doesn't report reads "—", "not reported", as a warning.
|
||||||
|
- The sparkline's bars are scaled to that series' own peak over the last 24 hours.
|
||||||
|
|
||||||
|
### `times`: moments and how long ago (default title "Times")
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- times:
|
||||||
|
items:
|
||||||
|
- label: Last good run
|
||||||
|
metric: myservice_last_success_timestamp_seconds
|
||||||
|
warn_after: 26h
|
||||||
|
crit_after: 2d
|
||||||
|
```
|
||||||
|
|
||||||
|
The metric is a `*_timestamp_seconds`. The row reads "3h ago" (exact time on hover) and turns amber or red once that long has passed. A timestamp of 0 reads "never", which is a warning when the item has a threshold.
|
||||||
|
|
||||||
|
### `table`: one metric, two labels (default title "Table")
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- table:
|
||||||
|
title: Stored per person
|
||||||
|
metric: fabledarchives_person_stored_bytes
|
||||||
|
rows: person
|
||||||
|
columns: kind
|
||||||
|
```
|
||||||
|
|
||||||
|
Rows and columns are the values of the two labels, in alphabetical order, under one header row. A missing cell reads "—". Tables show figures only; they don't judge them.
|
||||||
|
|
||||||
|
### `info`: labels worth reading (default title "About")
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- info:
|
||||||
|
title: Running
|
||||||
|
metric: fabledarchives_build_info
|
||||||
|
labels: [version, channel, commit] # which labels, in this order; all of them when left out
|
||||||
|
```
|
||||||
|
|
||||||
|
This block is for a `*_info` or `*_build_info` series, where the labels are the point and the value is always 1. It reads "version 2026.10.12.1717 · channel dev · commit 3605d0a".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reference
|
||||||
|
|
||||||
|
**Top level**
|
||||||
|
|
||||||
|
| Key | Needed | What it is |
|
||||||
|
|---|---|---|
|
||||||
|
| `name` | yes | At most 80 characters; unique among your templates and the built-in ones. |
|
||||||
|
| `version` | yes | A whole number from 1. |
|
||||||
|
| `blocks` | yes | One to 20 blocks. |
|
||||||
|
| `description` | | A line shown in the template list. |
|
||||||
|
| `expects` | | The prefix of the metric names it reads, like `fabledarchives_`. |
|
||||||
|
| `format` | | The template format; `1`, the only one so far. |
|
||||||
|
|
||||||
|
**Keys most blocks and items take**
|
||||||
|
|
||||||
|
| Key | What it is |
|
||||||
|
|---|---|
|
||||||
|
| `metric` | A series name as Steward stores it. |
|
||||||
|
| `where` | Labels a series must have, like `{kind: files, state: ok}`. |
|
||||||
|
| `name` | On `states`, `values` and `times`: the label that names each row (one row per value). |
|
||||||
|
| `unit` | On a `values` item, a `states` column or a `table`: overrides the unit read from the name: `bytes`, `seconds` (a span), `percent`, `per_sec`, `count` (thousands separated: 100,462), `timestamp`. |
|
||||||
|
|
||||||
|
Without `unit`, the name decides how a value reads: `_bytes` as bytes, `_seconds` as a span, `_timestamp_seconds` as a moment, `_per_sec` as a rate, `_pct` as a percentage. Anything else is a plain number.
|
||||||
|
|
||||||
|
**Spans** (`warn_after`, `crit_after`) are a number of seconds or a number with `s`, `m`, `h` or `d`: `90`, `15m`, `26h`, `2d`.
|
||||||
|
|
||||||
|
**Limits:** a template is at most 64 KB, with at most 20 blocks and 30 items in a block.
|
||||||
|
|
||||||
|
**What the checker refuses,** each with its line and what to change:
|
||||||
|
- an unknown key;
|
||||||
|
- a key given twice;
|
||||||
|
- anchors and aliases (`&name`, `*name`): write the value out;
|
||||||
|
- a share threshold (`10%`) without `of`;
|
||||||
|
- a state in two of `healthy`, `quiet` and `warning`;
|
||||||
|
- `crit_after` earlier than `warn_after`;
|
||||||
|
- a word YAML reads as true or false (`on`, `off`, `yes`, `no`) used as a state or label value. Quote it: `'off'`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Fabled Archives template, walked through
|
||||||
|
|
||||||
|
The built-in template is a worked example of most of the above. **Duplicate** it to read or change it. It was written against the series Archives actually reports. On 14 Oct 2026 that turned up a nightly replication job that had read "running" for two days without a good copy; the deadlines below exist to catch exactly that.
|
||||||
|
|
||||||
|
| Block | What it shows | Why it is written this way |
|
||||||
|
|---|---|---|
|
||||||
|
| Backup targets (`states`) | Each target Archives receives backups from: fresh, late or missing, with its last backup and what it has stored. | `disabled` and `waiting` are quiet: a target you turned off, or one that hasn't sent its first backup, isn't a problem. `late` is a warning; `missing` (not listed) is a problem. |
|
||||||
|
| Replication (`states`) | Each replication job, its last good copy and its next run. | Archives ranks `running` above lateness, so `warn_past` and `crit_past` read the job's own late and missing times. A run that never finishes turns amber, then red. |
|
||||||
|
| Storage and integrity (`values`) | Disk free of its size (amber under 10%, red under 5%, last day as bars), files checked of files, malware held, failing notification channels. | Held malware and a failing channel each need you to do something, so any count above 0 is a warning. |
|
||||||
|
| Stored per person (`table`) | Each person's files, versions and trash. | Figures to read, not to judge. |
|
||||||
|
| Running (`info`) | The version, channel and commit. | |
|
||||||
|
|
||||||
|
Left out on purpose (the template's header says so too):
|
||||||
|
- `fabledarchives_section_up`: a section Archives couldn't read stops reporting its figures, and the rows that read them then say "not reported".
|
||||||
|
- The background queue, HTTP requests and audit counters: these suit charts and alert rules better than a glance.
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# Prometheus Plugin
|
||||||
|
|
||||||
|
The Prometheus plugin scrapes any service's Prometheus `/metrics` endpoint and stores what you choose to keep in Steward's metrics. Once stored, a series can be charted, read through the MCP `query_metrics` tool, and watched by an alert rule like any other metric.
|
||||||
|
|
||||||
|
It is on by default. Targets are added, tested and edited on its page, **Prometheus** in the sidebar (`/plugins/prometheus/`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adding a target
|
||||||
|
|
||||||
|
| Field | What it is |
|
||||||
|
|---|---|
|
||||||
|
| Name | How the target's series are named in Steward (rule pickers, charts). No `/`, `:` or comma. |
|
||||||
|
| Metrics address | The full URL, e.g. `http://archives:8081/metrics`. |
|
||||||
|
| Bearer token | Sent as `Authorization: Bearer …`. Stored encrypted and never shown again; leave it blank to keep the saved one. |
|
||||||
|
| Scrape every | 10 s to an hour. Default 60 s. |
|
||||||
|
| Check its HTTPS certificate | Off only for a self-signed certificate you trust. |
|
||||||
|
| Keep / Leave out | Name patterns, one a line, `*` for any run of characters. Empty Keep keeps everything; Leave out wins over Keep. |
|
||||||
|
| Series cap | The most series stored from one scrape (default 1000). Past it, the rest are counted and shown on the target's page, not stored. |
|
||||||
|
|
||||||
|
**Test** scrapes the address once with what is typed, filters included, and says how many series would be stored and under which names. Nothing is saved.
|
||||||
|
|
||||||
|
The endpoint hands over everything; Steward chooses what to keep. Start narrow (`yourservice_*`) and widen it if you find you want more: every stored series is a row every scrape.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How series are stored
|
||||||
|
|
||||||
|
| On the endpoint | In Steward |
|
||||||
|
|---|---|
|
||||||
|
| A gauge or an untyped sample | Stored as it is, under its own name. |
|
||||||
|
| A counter (`requests_total`) | Stored as a rate per second, `requests_per_sec`, from the second scrape on. A value lower than the last (the service restarted) is skipped rather than read as a negative rate. |
|
||||||
|
| A histogram or summary | Its `_sum` and `_count` are stored as rates (`x_sum_per_sec`, `x_count_per_sec`). Buckets and quantiles are not stored. |
|
||||||
|
| NaN or ±Inf | Not stored. |
|
||||||
|
|
||||||
|
A series without labels is stored under the target's name. A labelled one is stored under `<target>:<label>=<value>,…`, in the order the endpoint gives the labels, for example `Archives:target=Steward,state=late`.
|
||||||
|
|
||||||
|
Each target also records four metrics about itself, under its own name:
|
||||||
|
|
||||||
|
| Metric | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `is_up` | 1 when the last scrape got metrics, 0 when it didn't. |
|
||||||
|
| `scrape_ms` | How long the scrape took. |
|
||||||
|
| `series_kept` | Series stored from the last scrape. |
|
||||||
|
| `series_capped` | Series the last scrape left out because of the cap. |
|
||||||
|
|
||||||
|
A value's unit is read from Prometheus naming: `_bytes` as bytes, `_seconds` as a span, `_timestamp_seconds` as a moment ("3h ago"), `_per_sec` as a rate.
|
||||||
|
|
||||||
|
A **state set** is one series per possible state, the current one 1 and the rest 0, such as `fabledarchives_backup_target_state{target, state}`. The target page shows it as one row per target (or job), naming the current state; the other states are listed on hover. A metric is read as a state set when its name ends in `_state` and every series has a `state` label reading 0 or 1. The OpenMetrics form, where the label is named after the metric (`foo{foo="a"} 1`), counts too. Each state is still stored as its own series, so an alert rule can watch one state.
|
||||||
|
|
||||||
|
**Only what the endpoint reports now is current.** When a scrape succeeds, any series it no longer has loses its newest value. That happens when the endpoint stopped reporting it, for example an old `build_info` after an upgrade, or when a filter or the cap now leaves it out. Its history stays on the chart until retention removes it. A failed scrape leaves the newest values as they were.
|
||||||
|
|
||||||
|
Renaming a target moves its stored history to the new name. Deleting one removes its newest values; its history ages out with retention (Settings → Retention).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The dashboard widget
|
||||||
|
|
||||||
|
Edit a dashboard, open **Add widget**, and pick **Prometheus** under Integrations. Choose a **target** and a **template**; the widget is named after the template unless you give it a title.
|
||||||
|
|
||||||
|
A template says which of the target's series to show and how: backup states, figures with their units, times, a table, the version running. The widget leads with one line naming the worst problem, then each section with its problems first and the healthy rest after. Its **Details** link opens the target's page.
|
||||||
|
|
||||||
|
Steward ships with one template, **Fabled Archives**. A target whose series don't match its template says so in the widget rather than showing blanks.
|
||||||
|
|
||||||
|
## Widget templates
|
||||||
|
|
||||||
|
**Prometheus → Widget templates** lists every template the widget can draw with: the ones built in, and your own. A template of yours that no longer passes the checker (a newer Steward may read the format differently) is listed first with what to change, and the widgets drawn with it say so too.
|
||||||
|
|
||||||
|
Open one to see its YAML beside a **preview**: the template drawn over a target you choose, exactly as the widget draws it, from the values Steward has stored. Nothing is fetched from the target to draw it.
|
||||||
|
|
||||||
|
- **New template**: write one, or paste in one someone shared. The preview redraws as you type, and lists each problem with its line until there are none.
|
||||||
|
- **Upload a file**: on the same page, read a `.yaml` file into the editor to look over before saving.
|
||||||
|
- **Duplicate**: copy any template, built-in ones included, under a new name, and change the copy. Built-in templates are read-only: they ship with Steward and change with it.
|
||||||
|
- **Export**: download a template's YAML, to keep or to share.
|
||||||
|
|
||||||
|
How to write one, every block, and the format reference: [Prometheus widget templates](prometheus-widgets.md).
|
||||||
|
|
||||||
|
Saving needs a template that passes the checker and a name no other template has. A widget remembers which template it draws with, so renaming a template keeps its widgets; deleting one leaves its widgets saying the template is gone. Templates are kept in Steward's backups, and adding, changing or deleting one is in the audit log.
|
||||||
|
|
||||||
|
## Alerting on a target
|
||||||
|
|
||||||
|
In **Alerts → New rule**, choose **Prometheus targets**, then the series (the resource) and the metric. Two rules worth having for any target:
|
||||||
|
|
||||||
|
- **It stopped answering:** resource `<target>`, metric `is_up`, `< 1`.
|
||||||
|
- **Something was cut off:** resource `<target>`, metric `series_capped`, `> 0`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recipe: Fabled Archives
|
||||||
|
|
||||||
|
Archives serves `/metrics` on its backup listener (port 8081), which is reachable only on your LAN and the swarm's overlay network, behind a scrape token.
|
||||||
|
|
||||||
|
1. In Archives, **Settings → Metrics → Make a token**, named e.g. "Steward". Copy it; it is shown once.
|
||||||
|
2. In Steward, **Prometheus → Add target**:
|
||||||
|
- Name `Archives`
|
||||||
|
- Metrics address `http://<archives host>:8081/metrics` (on a shared overlay network, the service name, e.g. `http://archives_app:8081/metrics`)
|
||||||
|
- Bearer token: the one you copied
|
||||||
|
- Keep: `fabledarchives_*`
|
||||||
|
3. **Test**, then **Add target**.
|
||||||
|
|
||||||
|
Rules worth adding:
|
||||||
|
|
||||||
|
| Watch | Resource | Metric | Condition |
|
||||||
|
|---|---|---|---|
|
||||||
|
| A backup target is late | `Archives:target=<name>,target_id=<id>,state=late` | `fabledarchives_backup_target_state` | `>= 1` |
|
||||||
|
| A backup target is missing | `Archives:target=<name>,target_id=<id>,state=missing` | `fabledarchives_backup_target_state` | `>= 1` |
|
||||||
|
| A replication job is failing | `Archives:job=<name>,job_id=<id>,state=failing` | `fabledarchives_replication_job_state` | `>= 1` |
|
||||||
|
| Archives' data disk is filling | `Archives` | `fabledarchives_data_disk_free_bytes` | `<` your floor |
|
||||||
|
| Archives stopped answering | `Archives` | `is_up` | `< 1` |
|
||||||
|
|
||||||
|
The target's page lists every stored series with its exact resource name, so copy names from there rather than typing them.
|
||||||
+17
-2
@@ -12,7 +12,7 @@ On each scrape:
|
|||||||
3. Writes computed metrics to the `traefik_metrics` history table
|
3. Writes computed metrics to the `traefik_metrics` history table
|
||||||
4. Calls `record_metric()` for each metric, making them available to alert rules
|
4. Calls `record_metric()` for each metric, making them available to alert rules
|
||||||
|
|
||||||
Request rates and error rates are computed as deltas between the current and previous scrape, divided by elapsed time. Latency percentiles (p50, p95, p99) are approximated via linear interpolation over histogram buckets — these are estimates, not exact percentiles.
|
Request rates and error rates are computed as deltas between the current and previous scrape, divided by elapsed time. Latency percentiles (p50, p95, p99) are approximated via linear interpolation over histogram buckets — these are estimates, not exact percentiles. They cover the responses since the previous scrape (each bucket's rise, summed over every status code and method), not every response since Traefik started. A scrape in which a router answered nothing records no latency, shown as "—"; one past the last bucket (5 s by default) reads as that bucket's edge.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -79,7 +79,22 @@ The widget auto-refreshes via HTMX polling every `poll_interval` seconds. Fragme
|
|||||||
|
|
||||||
### Detail Page
|
### Detail Page
|
||||||
|
|
||||||
The full Traefik page at `/plugins/traefik/` shows all routers with sparkline history charts (last 20 data points) for request rate, p95 latency, and 5xx error rate.
|
The full Traefik page at `/plugins/traefik/` reads top to bottom, for the time range picked at the top:
|
||||||
|
|
||||||
|
1. **The verdict.** It names the routers that need a look, says why, and says when the problem started ("api, 3.0% of responses are 5xx, started 2h ago"). When a router has been a problem for the whole range, it says so instead. Otherwise it reads "All N routers healthy".
|
||||||
|
2. **The request chart.** Requests per second across all routers and the share answered 5xx, the same chart as the dashboard widget.
|
||||||
|
3. **A card for each router that needs a look:** why, since when, the 4xx and 5xx status codes it answered in the last 15 minutes, most first ("404 ×1,812, 502 ×40"), and its request rate, bandwidth, p95/p99 latency and 4xx/5xx shares, each with its history. The code is what separates scanners probing for pages (404), a login wall (401/403) and an app that isn't answering (502/503/504). Steward records codes from the version that added them on; until then a card says they aren't recorded yet.
|
||||||
|
Each card then says what its problem usually means and what to do (`plugins/traefik/meaning.py`, #5712):
|
||||||
|
- **Server errors (5xx)** are almost always real. Mostly 500: the app is failing requests. Mostly 502/503/504: Traefik couldn't reach the app, or it timed out.
|
||||||
|
- **Client errors (4xx)** are usually traffic rather than a fault. Mostly 404: scanners and bots. 401/403: a login wall. 429: rate limiting. Mostly 499 is the exception: clients giving up on a slow app.
|
||||||
|
- **Slow responses** compare p95 with p50. When p95 is 4× p50 or more, a few requests are slow (one endpoint or a heavy page). When p50 is close behind, the whole app is slow.
|
||||||
|
- **Next:** a fault links to the swarm service behind the router (its tasks and logs) when Steward can match the name ("codex@swarm" → `codex_app`, "radarr@swarm" → `arr_radarr`), otherwise to Docker → Swarm. Traffic links to Settings → Thresholds.
|
||||||
|
4. **Traefik's own figures:** when its config last reloaded, open connections, memory.
|
||||||
|
5. **The healthy routers,** folded into one table, then the routers Traefik no longer reports, then TLS certificates by soonest expiry, then Traffic Origin when the access log is on.
|
||||||
|
|
||||||
|
A router is graded on its last 15 minutes against Settings → Thresholds. When a problem started is read from the router's history over the range.
|
||||||
|
|
||||||
|
Once a router has 5 days of history, its 4xx share and p95 latency are judged against its own normal for that hour (see [Normals and anomalies](../architecture.md)) instead of the fixed cutoffs. One over the cutoff but within its normal is healthy, and the healthy table says it is usual for that router (an error-pages service that answers 4xx all day, a login wall's steady 401s). One out of its normal is a warning that says what is usual, and a metric anomaly to review; the Traefik page lists the routers' anomalies of the last 30 days. How far out counts is set on Settings → Thresholds → Normals (2 points of 4xx share, 50 ms of p95 by default). A 4xx share over fewer requests in the 15 minutes than Settings → Thresholds allows (30 by default) isn't judged at all. The 5xx share is a failure and always keeps its fixed cutoff. Latency is learned only from #5755's per-scrape figures, so its normal arrives 5 days after that release.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ license: "MIT" # any SPDX identifier, e.g. MIT, Apache-2.0, GPL-3.0
|
|||||||
# Optional: prevents loading on older app versions
|
# Optional: prevents loading on older app versions
|
||||||
min_app_version: "0.1.0"
|
min_app_version: "0.1.0"
|
||||||
|
|
||||||
# Optional: shown in the catalog UI
|
# Optional: where the plugin's source lives
|
||||||
repository_url: "https://github.com/yourname/yourrepo"
|
repository_url: "https://github.com/yourname/yourrepo"
|
||||||
homepage: "https://github.com/yourname/yourrepo/tree/main/myplugin"
|
homepage: "https://github.com/yourname/yourrepo/tree/main/myplugin"
|
||||||
tags:
|
tags:
|
||||||
@@ -46,7 +46,7 @@ config:
|
|||||||
|
|
||||||
## Step 3: Define Models (if needed)
|
## Step 3: Define Models (if needed)
|
||||||
|
|
||||||
If your plugin stores data, define SQLAlchemy models using the shared `Base` from `roundtable.models.base`.
|
If your plugin stores data, define SQLAlchemy models using the shared `Base` from `steward.models.base`.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# plugins/myplugin/models.py
|
# plugins/myplugin/models.py
|
||||||
@@ -55,7 +55,7 @@ import uuid
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from sqlalchemy import String, Float, DateTime
|
from sqlalchemy import String, Float, DateTime
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
from roundtable.models.base import Base
|
from steward.models.base import Base
|
||||||
|
|
||||||
|
|
||||||
class MyPluginMetric(Base):
|
class MyPluginMetric(Base):
|
||||||
@@ -79,7 +79,7 @@ Generate the initial migration:
|
|||||||
# From the project root
|
# From the project root
|
||||||
alembic --config alembic.ini revision \
|
alembic --config alembic.ini revision \
|
||||||
--autogenerate \
|
--autogenerate \
|
||||||
--head=roundtable@head \
|
--head=steward@head \
|
||||||
--branch-label=myplugin \
|
--branch-label=myplugin \
|
||||||
-m "myplugin initial"
|
-m "myplugin initial"
|
||||||
```
|
```
|
||||||
@@ -106,8 +106,8 @@ Keep task logic in a separate file so `__init__.py` stays clean.
|
|||||||
# plugins/myplugin/scheduler.py
|
# plugins/myplugin/scheduler.py
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import logging
|
import logging
|
||||||
from roundtable.core.scheduler import ScheduledTask
|
from steward.core.scheduler import ScheduledTask
|
||||||
from roundtable.core.alerts import record_metric
|
from steward.core.alerts import record_metric
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -174,8 +174,8 @@ async def _fetch_value(url: str) -> float:
|
|||||||
```python
|
```python
|
||||||
# plugins/myplugin/routes.py
|
# plugins/myplugin/routes.py
|
||||||
from quart import Blueprint, current_app, render_template
|
from quart import Blueprint, current_app, render_template
|
||||||
from roundtable.auth.middleware import require_role
|
from steward.auth.middleware import require_role
|
||||||
from roundtable.models.users import UserRole
|
from steward.models.users import UserRole
|
||||||
from .models import MyPluginMetric
|
from .models import MyPluginMetric
|
||||||
|
|
||||||
myplugin_bp = Blueprint("myplugin", __name__, template_folder="templates")
|
myplugin_bp = Blueprint("myplugin", __name__, template_folder="templates")
|
||||||
@@ -215,7 +215,7 @@ Templates live in `plugins/myplugin/templates/myplugin/` (the extra nesting avoi
|
|||||||
```html
|
```html
|
||||||
{# plugins/myplugin/templates/myplugin/index.html #}
|
{# plugins/myplugin/templates/myplugin/index.html #}
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}My Plugin — Roundtable{% endblock %}
|
{% block title %}My Plugin — Steward{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="page-title">My Plugin</div>
|
<div class="page-title">My Plugin</div>
|
||||||
{% for row in rows %}
|
{% for row in rows %}
|
||||||
@@ -283,7 +283,7 @@ On next startup, the plugin will be loaded, its migrations applied, and its blue
|
|||||||
`record_metric()` is how plugins feed data into the alert pipeline. Any metric you write here can be the target of an alert rule created in the UI.
|
`record_metric()` is how plugins feed data into the alert pipeline. Any metric you write here can be the target of an alert rule created in the UI.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from roundtable.core.alerts import record_metric
|
from steward.core.alerts import record_metric
|
||||||
|
|
||||||
# Must be inside an active transaction
|
# Must be inside an active transaction
|
||||||
async with session.begin():
|
async with session.begin():
|
||||||
@@ -302,11 +302,11 @@ async with session.begin():
|
|||||||
|
|
||||||
## Auth in Routes
|
## Auth in Routes
|
||||||
|
|
||||||
Use the `@require_role` decorator from `roundtable.auth.middleware`:
|
Use the `@require_role` decorator from `steward.auth.middleware`:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from roundtable.auth.middleware import require_role
|
from steward.auth.middleware import require_role
|
||||||
from roundtable.models.users import UserRole
|
from steward.models.users import UserRole
|
||||||
|
|
||||||
@myplugin_bp.get("/admin-only")
|
@myplugin_bp.get("/admin-only")
|
||||||
@require_role(UserRole.admin)
|
@require_role(UserRole.admin)
|
||||||
@@ -323,83 +323,82 @@ Role hierarchy: `admin > operator > viewer`. Requiring `viewer` grants access to
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Publishing to the Catalog
|
## Audit Log Entries
|
||||||
|
|
||||||
The official plugin catalog is hosted at `https://git.fabledsword.com/bvandeusen/Roundtable-plugins`. Anyone can submit a plugin by opening a pull request — first-party and third-party plugins are treated identically by the catalog system.
|
A change made through a plugin's pages is audited with `steward.core.audit.log_audit`, naming the thing it changed by `entity_type` and `entity_id`. So the audit page shows that thing's current name, linked to its page, export `get_audit_entities()` from `__init__.py`:
|
||||||
|
|
||||||
### Repo layout
|
```python
|
||||||
|
# plugins/myplugin/__init__.py
|
||||||
```
|
def get_audit_entities() -> dict:
|
||||||
roundtable-plugins/
|
from .models import Target
|
||||||
├── index.yaml ← catalog index — the only file Roundtable fetches
|
return {"myplugin_target": (Target, "/plugins/myplugin/targets/{}")}
|
||||||
├── myplugin/
|
|
||||||
│ ├── plugin.yaml
|
|
||||||
│ ├── __init__.py
|
|
||||||
│ └── ...
|
|
||||||
└── .github/
|
|
||||||
└── workflows/
|
|
||||||
└── publish.yml ← packages each plugin dir into a zip on release
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### index.yaml entry
|
The model needs `id` and `name` columns. An entity deleted since keeps the name the entry recorded in its detail (`detail={"name": ...}`). Add the action's wording to `ACTIONS` in `steward/audit/service.py` so the row reads as a sentence.
|
||||||
|
|
||||||
Each plugin needs a corresponding entry in `index.yaml`. See `docs/plugins/index.yaml.example` for the full schema. The minimum required fields are:
|
---
|
||||||
|
|
||||||
```yaml
|
## Agent Tools (MCP)
|
||||||
- name: myplugin
|
|
||||||
version: "1.0.0"
|
Steward serves an MCP endpoint at `/mcp` that AI clients reach with a personal API token created on the AI access page. A plugin adds tools to it by exporting an optional `register_mcp_tools(register)` from `__init__.py`:
|
||||||
description: "What this plugin does"
|
|
||||||
author: "Your Name"
|
```python
|
||||||
license: "MIT"
|
# plugins/myplugin/__init__.py
|
||||||
min_app_version: "0.1.0"
|
def register_mcp_tools(register) -> None:
|
||||||
repository_url: "https://github.com/yourname/yourrepo"
|
from .mcp_tools import list_targets, rescan_target
|
||||||
homepage: "https://github.com/yourname/yourrepo/tree/main/myplugin"
|
from steward.models.users import UserRole
|
||||||
download_url: "https://github.com/yourname/yourrepo/releases/download/myplugin-v1.0.0/myplugin.zip"
|
register(list_targets, role=UserRole.viewer, read_only=True)
|
||||||
checksum_sha256: "" # fill in after zipping — see below
|
register(rescan_target, role=UserRole.operator, read_only=False)
|
||||||
tags:
|
|
||||||
- monitoring
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Creating a release zip
|
```python
|
||||||
|
# plugins/myplugin/mcp_tools.py
|
||||||
|
from steward.mcp._context import current_caller, mcp_app
|
||||||
|
|
||||||
The zip must contain the plugin files at the top level OR inside a single directory named after the plugin. Both layouts work:
|
|
||||||
|
|
||||||
```
|
async def list_targets(limit: int = 50) -> dict:
|
||||||
# Layout A — flat (preferred)
|
"""What this tool is for, when to use it over its siblings, what each
|
||||||
myplugin.zip
|
argument's empty value means, and what comes back. This docstring is the
|
||||||
├── plugin.yaml
|
tool's description: it is all the agent knows about it."""
|
||||||
├── __init__.py
|
app = mcp_app() # Quart's current_app does not exist here
|
||||||
└── ...
|
async with app.db_sessionmaker() as db:
|
||||||
|
...
|
||||||
# Layout B — single top-level directory (also accepted, GitHub archive default)
|
return {"targets": [...], "total": n, "truncated": n > limit}
|
||||||
myplugin.zip
|
|
||||||
└── myplugin/
|
|
||||||
├── plugin.yaml
|
|
||||||
└── ...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Generate the zip and its checksum:
|
- `role` and `read_only` are required. A tool is listed and callable only for callers whose role reaches `role`, and only while the plugin is enabled.
|
||||||
|
- Tools run outside Quart's request pipeline: use `mcp_app()` and `current_caller()` from `steward.mcp._context`, never `current_app` or `session`.
|
||||||
|
- Refuse by raising `ValueError("what was wrong, and what to do instead")`. The agent reads that text. Any other exception reaches it only as a generic failure.
|
||||||
|
- Tool names are global across core and every plugin; a name already taken fails registration and the plugin contributes no tools (logged).
|
||||||
|
- Hot-reloading the plugin replaces its tools.
|
||||||
|
- Follow the response conventions the core tools use (`steward/mcp/tools/_shape.py`): times via `iso()` plus `age_s()`, lists through `bounded()` so a cut is never silent, series through `summarize()`, and lookups by name or id through `pick()`.
|
||||||
|
|
||||||
```bash
|
### Contributing to get_overview
|
||||||
cd roundtable-plugins
|
|
||||||
zip -r myplugin.zip myplugin/
|
`get_overview` is the agent's first call: what is wrong right now, ranked. A plugin adds its own problems to it by exporting an optional `mcp_overview(db)` that returns findings built with `steward.mcp.tools.overview.finding`:
|
||||||
sha256sum myplugin.zip # paste this into index.yaml checksum_sha256
|
|
||||||
|
```python
|
||||||
|
# plugins/myplugin/__init__.py
|
||||||
|
async def mcp_overview(db) -> list[dict]:
|
||||||
|
from steward.mcp.tools.overview import finding
|
||||||
|
return [
|
||||||
|
finding("warning", "target_unreachable", t.name, f"{t.name} has not answered for {t.misses} scans",
|
||||||
|
since=t.last_ok_at, ids={"target_id": t.id}, next=f'get_target(target="{t.id}")')
|
||||||
|
for t in await _unreachable_targets(db)
|
||||||
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
Upload `myplugin.zip` as a GitHub release asset, then update `index.yaml` with the release download URL and checksum.
|
- `severity` is `"critical"` (something is down or about to be) or `"warning"` (degraded, or heading that way). Report problems only; a healthy plugin returns `[]`.
|
||||||
|
- It is called only while the plugin is enabled, with its own session. If it raises, the overview still returns, and names the plugin under `unavailable`.
|
||||||
|
|
||||||
### How install works
|
---
|
||||||
|
|
||||||
When a user clicks **Install** in Settings → Plugins:
|
## Shipping a Plugin
|
||||||
|
|
||||||
1. The app downloads the zip from `download_url`
|
Steward never downloads plugins. There is no catalog and no install button: the app runs with no outbound network.
|
||||||
2. Verifies the SHA-256 checksum (if provided)
|
|
||||||
3. Extracts the plugin into its `PLUGIN_DIR`
|
|
||||||
4. Runs any pending Alembic migrations for the plugin
|
|
||||||
5. Attempts a **hot-reload** — registers the blueprint and scheduled tasks without restarting
|
|
||||||
6. If the plugin was previously loaded (blueprint already mounted), a **restart** is required to pick up the new code
|
|
||||||
|
|
||||||
Hot-reload works reliably for brand-new plugin installs. Updates to already-active plugins require a restart, which can be triggered from the same settings page.
|
- **First-party plugins** live in this repository under `plugins/` and ship inside the image, so they version with core.
|
||||||
|
- **A third-party plugin** is a folder you put in the external plugin root, `/data/plugins` by default (`STEWARD_PLUGIN_DIR` moves it). Restart Steward and the plugin appears in Settings → Plugins, where you enable it. A bundled plugin of the same name wins over an external one.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -413,6 +412,6 @@ Hot-reload works reliably for brand-new plugin installs. Updates to already-acti
|
|||||||
- [ ] Revision IDs prefixed with plugin name
|
- [ ] Revision IDs prefixed with plugin name
|
||||||
- [ ] `record_metric()` called inside `session.begin()`
|
- [ ] `record_metric()` called inside `session.begin()`
|
||||||
- [ ] Routes use `@require_role` decorator
|
- [ ] Routes use `@require_role` decorator
|
||||||
|
- [ ] MCP tools (if any) registered via `register_mcp_tools(register)` with a role and `read_only`
|
||||||
- [ ] Templates namespaced under `templates/myplugin/`
|
- [ ] Templates namespaced under `templates/myplugin/`
|
||||||
- [ ] Plugin enabled in app settings
|
- [ ] Plugin enabled in app settings
|
||||||
- [ ] `index.yaml` entry added with `download_url` and `checksum_sha256`
|
|
||||||
|
|||||||
+82
-59
@@ -8,15 +8,15 @@ Quick reference for where key functions, models, and entry points live in the co
|
|||||||
|
|
||||||
| What | File | Function / Class |
|
| What | File | Function / Class |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| App factory | `roundtable/app.py` | `create_app()` |
|
| App factory | `steward/app.py` | `create_app()` |
|
||||||
| CLI entry point | `roundtable/cli.py` | `main()` |
|
| CLI entry point | `steward/cli.py` | `main()` |
|
||||||
| Bootstrap config loading | `roundtable/config.py` | `load_bootstrap()` |
|
| Bootstrap config loading | `steward/config.py` | `load_bootstrap()` |
|
||||||
| Secret key resolution | `roundtable/config.py` | `_resolve_secret_key()` |
|
| Secret key resolution | `steward/config.py` | `_resolve_secret_key()` |
|
||||||
| DB engine init | `roundtable/database.py` | `init_db()` |
|
| DB engine init | `steward/database.py` | `init_db()` |
|
||||||
| Core migrations | `roundtable/core/migration_runner.py` | `run_core_migrations()` |
|
| Core migrations | `steward/core/migration_runner.py` | `run_core_migrations()` |
|
||||||
| Plugin migrations | `roundtable/core/migration_runner.py` | `run_plugin_migrations()` |
|
| Plugin migrations | `steward/core/migration_runner.py` | `run_plugin_migrations()` |
|
||||||
| Core task registration | `roundtable/app.py` | `_register_core_tasks()` |
|
| Core task registration | `steward/app.py` | `_register_core_tasks()` |
|
||||||
| Scheduler loop | `roundtable/core/scheduler.py` | `start_scheduler()` |
|
| Scheduler loop | `steward/core/scheduler.py` | `start_scheduler()` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -24,15 +24,15 @@ Quick reference for where key functions, models, and entry points live in the co
|
|||||||
|
|
||||||
| What | File | Function |
|
| What | File | Function |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| All defaults | `roundtable/core/settings.py` | `DEFAULTS` dict |
|
| All defaults | `steward/core/settings.py` | `DEFAULTS` dict |
|
||||||
| Read a setting | `roundtable/core/settings.py` | `get_setting(session, key)` |
|
| Read a setting | `steward/core/settings.py` | `get_setting(session, key)` |
|
||||||
| Write a setting | `roundtable/core/settings.py` | `set_setting(session, key, value)` |
|
| Write a setting | `steward/core/settings.py` | `set_setting(session, key, value)` |
|
||||||
| Read all settings | `roundtable/core/settings.py` | `get_all_settings(session)` |
|
| Read all settings | `steward/core/settings.py` | `get_all_settings(session)` |
|
||||||
| Sync load at startup | `roundtable/core/settings.py` | `load_settings_sync(db_url)` |
|
| Sync load at startup | `steward/core/settings.py` | `load_settings_sync(db_url)` |
|
||||||
| Extract SMTP dict | `roundtable/core/settings.py` | `to_smtp_cfg(settings)` |
|
| Extract SMTP dict | `steward/core/settings.py` | `to_smtp_cfg(settings)` |
|
||||||
| Extract webhook dict | `roundtable/core/settings.py` | `to_webhook_cfg(settings)` |
|
| Extract webhook dict | `steward/core/settings.py` | `to_webhook_cfg(settings)` |
|
||||||
| Extract Ansible dict | `roundtable/core/settings.py` | `to_ansible_cfg(settings)` |
|
| Extract Ansible dict | `steward/core/settings.py` | `to_ansible_cfg(settings)` |
|
||||||
| Extract plugins dict | `roundtable/core/settings.py` | `to_plugins_cfg(settings)` |
|
| Extract plugins dict | `steward/core/settings.py` | `to_plugins_cfg(settings)` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -40,9 +40,9 @@ Quick reference for where key functions, models, and entry points live in the co
|
|||||||
|
|
||||||
| What | File | Function |
|
| What | File | Function |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Plugin loading | `roundtable/core/plugin_manager.py` | `load_plugins(app)` |
|
| Plugin loading | `steward/core/plugin_manager.py` | `load_plugins(app)` |
|
||||||
| ScheduledTask dataclass | `roundtable/core/scheduler.py` | `ScheduledTask` |
|
| ScheduledTask dataclass | `steward/core/scheduler.py` | `ScheduledTask` |
|
||||||
| Task runner | `roundtable/core/scheduler.py` | `start_scheduler(tasks)` |
|
| Task runner | `steward/core/scheduler.py` | `start_scheduler(tasks)` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -50,11 +50,11 @@ Quick reference for where key functions, models, and entry points live in the co
|
|||||||
|
|
||||||
| What | File | Function |
|
| What | File | Function |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Write metric + evaluate alerts | `roundtable/core/alerts.py` | `record_metric(session, source_module, resource_name, metric_name, value)` |
|
| Write metric + evaluate alerts | `steward/core/alerts.py` | `record_metric(session, source_module, resource_name, metric_name, value)` |
|
||||||
| Init alert pipeline | `roundtable/core/alerts.py` | `init_alerts(app)` |
|
| Init alert pipeline | `steward/core/alerts.py` | `init_alerts(app)` |
|
||||||
| Rule evaluation | `roundtable/core/alerts.py` | `_evaluate_rule()` (internal) |
|
| Rule evaluation | `steward/core/alerts.py` | `_evaluate_rule()` (internal) |
|
||||||
| Notification dispatch | `roundtable/core/alerts.py` | `_dispatch_notification()` (internal) |
|
| Notification dispatch | `steward/core/alerts.py` | `_dispatch_notification()` (internal) |
|
||||||
| Email + webhook send | `roundtable/core/notifications.py` | `dispatch_notifications()` |
|
| Email + webhook send | `steward/core/notifications.py` | `dispatch_notifications()` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -62,9 +62,9 @@ Quick reference for where key functions, models, and entry points live in the co
|
|||||||
|
|
||||||
| What | File | Function |
|
| What | File | Function |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Ping a host | `roundtable/monitors/ping.py` | `ping_check(host, session)` |
|
| Ping a host | `steward/monitors/ping.py` | `ping_check(host, session)` |
|
||||||
| DNS check a host | `roundtable/monitors/dns.py` | `dns_check(host, session)` |
|
| DNS check a host | `steward/monitors/dns.py` | `dns_check(host, session)` |
|
||||||
| Data cleanup | `roundtable/core/cleanup.py` | `run_cleanup(app)` |
|
| Data cleanup | `steward/core/cleanup.py` | `run_cleanup(app)` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -72,9 +72,9 @@ Quick reference for where key functions, models, and entry points live in the co
|
|||||||
|
|
||||||
| What | File | Function / Class |
|
| What | File | Function / Class |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Role-based access decorator | `roundtable/auth/middleware.py` | `@require_role(UserRole.X)` |
|
| Role-based access decorator | `steward/auth/middleware.py` | `@require_role(UserRole.X)` |
|
||||||
| Login / session handling | `roundtable/auth/middleware.py` | `login_user()`, `logout_user()` |
|
| Login / session handling | `steward/auth/middleware.py` | `login_user()`, `logout_user()` |
|
||||||
| User count (for first-run) | `roundtable/auth/middleware.py` | `get_user_count(app)` |
|
| User count (for first-run) | `steward/auth/middleware.py` | `get_user_count(app)` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -82,18 +82,19 @@ Quick reference for where key functions, models, and entry points live in the co
|
|||||||
|
|
||||||
| Model | File | Table |
|
| Model | File | Table |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `Host` | `roundtable/models/hosts.py` | `hosts` |
|
| `Host` | `steward/models/hosts.py` | `hosts` |
|
||||||
| `PingResult` | `roundtable/models/monitors.py` | `ping_results` |
|
| `PingResult` | `steward/models/monitors.py` | `ping_results` |
|
||||||
| `DnsResult` | `roundtable/models/monitors.py` | `dns_results` |
|
| `DnsResult` | `steward/models/monitors.py` | `dns_results` |
|
||||||
| `AlertRule` | `roundtable/models/alerts.py` | `alert_rules` |
|
| `AlertRule` | `steward/models/alerts.py` | `alert_rules` |
|
||||||
| `AlertState` | `roundtable/models/alerts.py` | `alert_states` |
|
| `AlertState` | `steward/models/alerts.py` | `alert_states` |
|
||||||
| `AlertEvent` | `roundtable/models/alerts.py` | `alert_events` |
|
| `AlertEvent` | `steward/models/alerts.py` | `alert_events` |
|
||||||
| `PluginMetric` | `roundtable/models/metrics.py` | `plugin_metrics` |
|
| `PluginMetric` | `steward/models/metrics.py` | `plugin_metrics` |
|
||||||
| `AnsibleRun` | `roundtable/models/ansible.py` | `ansible_runs` |
|
| `PluginMetricLatest` | `steward/models/metrics.py` | `plugin_metric_latest` |
|
||||||
| `User` | `roundtable/models/users.py` | `users` |
|
| `AnsibleRun` | `steward/models/ansible.py` | `ansible_runs` |
|
||||||
| `AppSetting` | `roundtable/models/settings.py` | `app_settings` |
|
| `User` | `steward/models/users.py` | `users` |
|
||||||
|
| `AppSetting` | `steward/models/settings.py` | `app_settings` |
|
||||||
| `TraefikMetric` | `plugins/traefik/models.py` | `traefik_metrics` |
|
| `TraefikMetric` | `plugins/traefik/models.py` | `traefik_metrics` |
|
||||||
| SQLAlchemy `Base` | `roundtable/models/base.py` | (shared declarative base) |
|
| SQLAlchemy `Base` | `steward/models/base.py` | (shared declarative base) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -101,16 +102,16 @@ Quick reference for where key functions, models, and entry points live in the co
|
|||||||
|
|
||||||
| URL pattern | Blueprint | File |
|
| URL pattern | Blueprint | File |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `/` (dashboard) | `dashboard_bp` | `roundtable/dashboard/routes.py` |
|
| `/` (dashboard) | `dashboard_bp` | `steward/dashboard/routes.py` |
|
||||||
| `/auth/login`, `/auth/logout` | `auth_bp` | `roundtable/auth/routes.py` |
|
| `/auth/login`, `/auth/logout` | `auth_bp` | `steward/auth/routes.py` |
|
||||||
| `/hosts/` | `hosts_bp` | `roundtable/hosts/routes.py` |
|
| `/hosts/` | `hosts_bp` | `steward/hosts/routes.py` |
|
||||||
| `/ping/`, `/ping/rows`, `/ping/settings` | `ping_bp` | `roundtable/ping/routes.py` |
|
| `/ping/`, `/ping/rows`, `/ping/settings` | `ping_bp` | `steward/ping/routes.py` |
|
||||||
| `/dns/`, `/dns/rows` | `dns_bp` | `roundtable/dns/routes.py` |
|
| `/dns/`, `/dns/rows` | `dns_bp` | `steward/dns/routes.py` |
|
||||||
| `/alerts/` | `alerts_bp` | `roundtable/alerts/routes.py` |
|
| `/alerts/` | `alerts_bp` | `steward/alerts/routes.py` |
|
||||||
| `/ansible/` | `ansible_bp` | `roundtable/ansible/routes.py` |
|
| `/ansible/` | `ansible_bp` | `steward/ansible/routes.py` |
|
||||||
| `/settings/` | `settings_bp` | `roundtable/settings/routes.py` |
|
| `/settings/` | `settings_bp` | `steward/settings/routes.py` |
|
||||||
| `/plugins/traefik/`, `/plugins/traefik/widget` | `traefik_bp` | `plugins/traefik/routes.py` |
|
| `/plugins/traefik/`, `/plugins/traefik/widget` | `traefik_bp` | `plugins/traefik/routes.py` |
|
||||||
| `/health` | (inline) | `roundtable/app.py` |
|
| `/health` | (inline) | `steward/app.py` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -118,12 +119,12 @@ Quick reference for where key functions, models, and entry points live in the co
|
|||||||
|
|
||||||
| Template | Purpose |
|
| Template | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `roundtable/templates/base.html` | Layout, navigation, full CSS design system |
|
| `steward/templates/base.html` | Layout, navigation, full CSS design system |
|
||||||
| `roundtable/templates/dashboard/index.html` | Dashboard with stat strip and widget grid |
|
| `steward/templates/dashboard/index.html` | Dashboard with stat strip and widget grid |
|
||||||
| `roundtable/templates/ping/rows.html` | HTMX fragment: ping pill rows (shared by dashboard and /ping/) |
|
| `steward/templates/ping/rows.html` | HTMX fragment: ping pill rows (shared by dashboard and /ping/) |
|
||||||
| `roundtable/templates/ping/index.html` | Full /ping/ page |
|
| `steward/templates/ping/index.html` | Full /ping/ page |
|
||||||
| `roundtable/templates/dns/rows.html` | HTMX fragment: DNS status rows |
|
| `steward/templates/dns/rows.html` | HTMX fragment: DNS status rows |
|
||||||
| `roundtable/templates/dns/index.html` | Full /dns/ page |
|
| `steward/templates/dns/index.html` | Full /dns/ page |
|
||||||
| `plugins/traefik/templates/traefik/widget.html` | HTMX fragment: Traefik dashboard widget |
|
| `plugins/traefik/templates/traefik/widget.html` | HTMX fragment: Traefik dashboard widget |
|
||||||
| `plugins/traefik/templates/traefik/index.html` | Full Traefik detail page |
|
| `plugins/traefik/templates/traefik/index.html` | Full Traefik detail page |
|
||||||
|
|
||||||
@@ -133,6 +134,28 @@ Quick reference for where key functions, models, and entry points live in the co
|
|||||||
|
|
||||||
| Location | Covers |
|
| Location | Covers |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `roundtable/migrations/versions/` | Core schema (hosts, users, monitors, alerts, app_settings) |
|
| `steward/migrations/versions/` | Core schema (hosts, users, monitors, alerts, app_settings) |
|
||||||
| `plugins/traefik/migrations/versions/` | `traefik_metrics` table |
|
| `plugins/traefik/migrations/versions/` | `traefik_metrics` table |
|
||||||
| `alembic.ini` | Alembic config; `version_locations` lists all migration directories |
|
| `alembic.ini` | Alembic config; `version_locations` lists all migration directories |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MCP server and API tokens
|
||||||
|
|
||||||
|
| What | File | Function / Class |
|
||||||
|
|---|---|---|
|
||||||
|
| Mount `/mcp` (ASGI dispatch, bearer auth, DB gate) | `steward/mcp/server.py` | `mount_mcp(app)` |
|
||||||
|
| Server, role registry, refusals | `steward/mcp/server.py` | `StewardMCPServer`, `register_tool()`, `refusal()` |
|
||||||
|
| Plugin tools and overview findings | `steward/mcp/server.py` | `register_plugin(name, module)` |
|
||||||
|
| Caller and app inside a tool | `steward/mcp/_context.py` | `current_caller()`, `mcp_app()` |
|
||||||
|
| Core tool modules | `steward/mcp/tools/` | `register_all()`; one module per area |
|
||||||
|
| Response conventions | `steward/mcp/tools/_shape.py` | `iso`, `age_s`, `bounded`, `lookback`, `summarize`, `pick` |
|
||||||
|
| Audit a write as "<user> via MCP" | `steward/mcp/tools/_audit.py` | `audit_mcp()` |
|
||||||
|
| "What is wrong now" | `steward/mcp/tools/overview.py` | `get_overview()` |
|
||||||
|
| Playbooks, runs, schedules, alert actions | `steward/mcp/tools/automation.py` | `run_playbook()`, `wait_for_run()`, … |
|
||||||
|
| Monitor validation (forms and tools) | `steward/monitors/service.py` | `monitor_fields()`, `build_config()` |
|
||||||
|
| Alert rule / window validation | `steward/alerts/service.py` | `rule_fields()`, `window_fields()`, `acknowledge()` |
|
||||||
|
| Docker and host-agent tools | `plugins/docker/mcp_tools.py`, `plugins/host_agent/mcp_tools.py` | `register_tools()`, `mcp_overview()` |
|
||||||
|
| Token model | `steward/models/api_tokens.py` | `ApiToken` |
|
||||||
|
| Mint, resolve, revoke tokens | `steward/core/api_tokens.py` | `create_token()`, `resolve_token()`, `revoke_token()` |
|
||||||
|
| Token management page and connect card | `steward/ai_access/routes.py`, `steward/templates/ai_access/` | `ai_access_bp` |
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
# Authoring Steward-friendly Ansible playbooks
|
||||||
|
|
||||||
|
This is the contract between a playbook and Steward's run UI. Follow it and a
|
||||||
|
playbook drops into Steward with a description, fill-in variable fields, correct
|
||||||
|
targeting, and credentials supplied automatically — no per-playbook wiring.
|
||||||
|
|
||||||
|
It applies to **any** playbook in a configured source (bundled, the writable
|
||||||
|
local source, or a git source), including third-party ones.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Describe what it does — `# description:`
|
||||||
|
|
||||||
|
Steward shows a one-line description when a playbook is selected in the run form.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
# description: Reclaim disk on Docker/Swarm nodes by pruning unused images and build cache.
|
||||||
|
- name: Docker system prune
|
||||||
|
hosts: all
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
- Format: a comment line `# description: <text>` anywhere in the file. **First
|
||||||
|
match wins.** Case-insensitive on the `description:` key.
|
||||||
|
- Why a comment and not a key: Ansible rejects unknown *play* keys (you can't add
|
||||||
|
`description:` to a play), so a comment is the portable place. It survives
|
||||||
|
`ansible-playbook` untouched.
|
||||||
|
- Fallback: if there's no `# description:` comment, Steward uses the **first
|
||||||
|
play's `name:`**. So always give your play a meaningful `name:` even without
|
||||||
|
the comment.
|
||||||
|
- Keep it to one readable line. Longer "how to use" notes can go in additional
|
||||||
|
normal comments — Steward only reads the `description:` line.
|
||||||
|
|
||||||
|
## 2. Declare tunables in `vars:` — they become fill-in fields
|
||||||
|
|
||||||
|
Every **scalar** entry in a play's `vars:` block becomes an editable field in the
|
||||||
|
run form, with the default shown as the input's placeholder.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
vars:
|
||||||
|
prune_all_images: false # → checkbox-ish text field, placeholder "default: false"
|
||||||
|
keep_last_days: 7 # → field, placeholder "default: 7"
|
||||||
|
registry_url: "" # → field, placeholder "no default"
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Blank field = use the default.** Steward only sends fields the operator
|
||||||
|
actually fills, so an untouched field falls through to the playbook/inventory
|
||||||
|
default rather than overriding it.
|
||||||
|
- Only scalars (string/int/float/bool) surface as fields. Lists/dicts are
|
||||||
|
skipped — set those in the playbook or via inventory group/host vars.
|
||||||
|
- Values are delivered as **extra-vars** (`-e`), which are the **highest**
|
||||||
|
precedence in Ansible — they override the `vars:` defaults. (This is why the
|
||||||
|
default can be empty and still be safely overridden at run time.)
|
||||||
|
|
||||||
|
### `vars_prompt:` also works
|
||||||
|
|
||||||
|
Steward reads `vars_prompt` too. Use it when you want an explicit prompt or a
|
||||||
|
required value:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
vars_prompt:
|
||||||
|
- name: release_tag
|
||||||
|
prompt: "Which release to deploy?" # shown as the field label
|
||||||
|
# no default → Steward marks the field REQUIRED
|
||||||
|
- name: admin_password
|
||||||
|
prompt: "Admin password"
|
||||||
|
private: true # → masked field, never stored
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Secrets — name them so they're masked and not persisted
|
||||||
|
|
||||||
|
A field is treated as **secret** (rendered masked, and its value is **never
|
||||||
|
written to the DB / run history**) when either:
|
||||||
|
|
||||||
|
- the variable name contains `password`, `passwd`, `secret`, `token`,
|
||||||
|
`api_key` / `apikey`, `private_key`, or `credential` (case-insensitive), **or**
|
||||||
|
- it's a `vars_prompt` entry with `private: true` (Ansible's default for
|
||||||
|
vars_prompt is private).
|
||||||
|
|
||||||
|
So name sensitive variables accordingly (`db_password`, `api_token`,
|
||||||
|
`vault_secret`) and they're handled safely with no extra config. Non-secret
|
||||||
|
run-time vars are persisted (so scheduled runs can reuse them); secret ones are
|
||||||
|
passed to the run only.
|
||||||
|
|
||||||
|
## 4. Target with `hosts: all`
|
||||||
|
|
||||||
|
Steward builds the inventory itself from the **target / group** the operator
|
||||||
|
picks in the run form (or the single host on a host page). Your play should:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
hosts: all # run against whatever Steward scoped to
|
||||||
|
```
|
||||||
|
|
||||||
|
- Don't hardcode hostnames or rely on a checked-in inventory for Steward runs
|
||||||
|
(Steward generates a fresh inventory per run).
|
||||||
|
- Per-host connection vars (`ansible_host`, plus anything you set on the target
|
||||||
|
in **Ansible → Inventory**) arrive as inventory host vars.
|
||||||
|
- The run form's **Limit** / **Tags** map to `--limit` / `--tags`.
|
||||||
|
|
||||||
|
## 5. Privileges & connection — don't put credentials in the playbook
|
||||||
|
|
||||||
|
Steward supplies SSH and become for you:
|
||||||
|
|
||||||
|
- Steady-state runs connect as the managed **`steward`** account using Steward's
|
||||||
|
managed key; that account has **passwordless sudo**. So just use
|
||||||
|
`become: true` where you need root.
|
||||||
|
- First-contact provisioning uses a one-time bootstrap user/password the
|
||||||
|
operator enters (never stored).
|
||||||
|
- Never embed SSH keys, passwords, or `ansible_user`/`ansible_ssh_pass` in the
|
||||||
|
playbook. Connection identity is global (Settings → Ansible) or per-target.
|
||||||
|
|
||||||
|
## 6. Be idempotent
|
||||||
|
|
||||||
|
Steward re-runs playbooks (updates, schedules, retries). Use modules that
|
||||||
|
converge (state-based) rather than ad-hoc `command:`/`shell:` where possible, so
|
||||||
|
re-runs are safe.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Steward reads from a playbook (summary)
|
||||||
|
|
||||||
|
| Source in the playbook | What Steward does with it |
|
||||||
|
|---|---|
|
||||||
|
| `# description: <text>` comment | Description shown on selection (first match) |
|
||||||
|
| first play `name:` | Description fallback |
|
||||||
|
| `# steward:category: <text>` | Grouping badge in the browse list + run form |
|
||||||
|
| `# steward:confirm: true` | Requires a confirmation tick before the run launches |
|
||||||
|
| `vars:` scalar entries | Run-time fill-in fields (placeholder = default) |
|
||||||
|
| `vars_prompt:` entries | Run-time fields (required if no default) |
|
||||||
|
| secret-looking var name / `private: true` | Field masked + value not persisted |
|
||||||
|
| `hosts:` | Expected to be `all`; Steward provides the inventory |
|
||||||
|
|
||||||
|
Everything else (SSH user/key, become password, the inventory, `steward_token`
|
||||||
|
etc. for the agent playbooks) is injected by Steward at run time.
|
||||||
|
|
||||||
|
## Metadata comments
|
||||||
|
|
||||||
|
Steward reads metadata from magic comments (Ansible rejects unknown play keys,
|
||||||
|
so comments are the portable place). Two forms:
|
||||||
|
|
||||||
|
- `# description: <text>` — the description (see §1).
|
||||||
|
- `# steward:<key>: <value>` — the namespaced metadata block. First match per
|
||||||
|
key wins; keys are case-insensitive.
|
||||||
|
|
||||||
|
**Implemented `# steward:` keys:**
|
||||||
|
|
||||||
|
| Key | Example | Effect |
|
||||||
|
|---|---|---|
|
||||||
|
| `category` | `# steward:category: maintenance` | Free-text grouping label. Shown as a badge in the browse list and on the run form. Purely organizational. |
|
||||||
|
| `confirm` | `# steward:confirm: true` | Marks the playbook as significant/destructive. The run form then requires an explicit confirmation tick before it can be launched. Use for data-loss-capable plays (prune with volumes, resets, etc.). Truthy values: `true`/`yes`/`1`/`on`. |
|
||||||
|
| `description` | `# steward:description: ...` | Alias for `# description:` (the unprefixed form takes precedence if both exist). |
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
# description: Reclaim disk on Docker/Swarm nodes by pruning unused data.
|
||||||
|
# steward:category: maintenance
|
||||||
|
# steward:confirm: true
|
||||||
|
- name: Docker system prune
|
||||||
|
hosts: all
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Other `# steward:<key>:` keys are simply ignored today — the namespace is
|
||||||
|
reserved, so it's safe to add ones Steward doesn't yet understand without
|
||||||
|
breaking anything, but only `category`, `confirm`, and `description` do something.
|
||||||
|
|
||||||
|
## Minimal annotated example
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
# description: Restart a systemd service and confirm it came back up.
|
||||||
|
- name: Restart a service
|
||||||
|
hosts: all
|
||||||
|
become: true
|
||||||
|
gather_facts: false
|
||||||
|
vars:
|
||||||
|
service_name: "" # required-ish: operator fills it in the run form
|
||||||
|
tasks:
|
||||||
|
- name: Validate input
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that: service_name | default('') | length > 0
|
||||||
|
fail_msg: "Set service_name."
|
||||||
|
- name: Restart
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: "{{ service_name }}"
|
||||||
|
state: restarted
|
||||||
|
- name: Confirm active
|
||||||
|
ansible.builtin.command: "systemctl is-active {{ service_name }}"
|
||||||
|
changed_when: false
|
||||||
|
```
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
# Roundtable Rebrand Implementation Plan
|
# Steward Rebrand Implementation Plan
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
>
|
>
|
||||||
> **Testing:** Per project feedback, skip all test authoring and test runs during this work. Verify manually by running the app.
|
> **Testing:** Per project feedback, skip all test authoring and test runs during this work. Verify manually by running the app.
|
||||||
|
|
||||||
**Goal:** Rebrand FabledScryer to Roundtable: full visual reskin plus full rename of package, config, containers, and docs.
|
**Goal:** Rebrand FabledScryer to Steward: full visual reskin plus full rename of package, config, containers, and docs.
|
||||||
|
|
||||||
**Architecture:** Staged as six sequential PRs. PR 1 is a pure visual/copy reskin that still ships under the FabledScryer name. PRs 2–4 perform the mechanical rename in order (package → config → container + user-visible strings) with a fallback shim in PR 3 so self-hosters don't break mid-upgrade. PR 5 updates docs. PR 6 removes the shim after one release cycle.
|
**Architecture:** Staged as six sequential PRs. PR 1 is a pure visual/copy reskin that still ships under the FabledScryer name. PRs 2–4 perform the mechanical rename in order (package → config → container + user-visible strings) with a fallback shim in PR 3 so self-hosters don't break mid-upgrade. PR 5 updates docs. PR 6 removes the shim after one release cycle.
|
||||||
|
|
||||||
@@ -25,29 +25,29 @@
|
|||||||
- Any template with an empty-state message — themed line
|
- Any template with an empty-state message — themed line
|
||||||
|
|
||||||
**Renamed (PR 2 — package):**
|
**Renamed (PR 2 — package):**
|
||||||
- `fabledscryer/` → `roundtable/` (directory + every `from fabledscryer` / `import fabledscryer` reference)
|
- `fabledscryer/` → `steward/` (directory + every `from fabledscryer` / `import fabledscryer` reference)
|
||||||
- `pyproject.toml` — name, entry point, hatch packages
|
- `pyproject.toml` — name, entry point, hatch packages
|
||||||
- `alembic.ini` — `script_location`
|
- `alembic.ini` — `script_location`
|
||||||
- `tests/**` — imports (tests not executed, but must not break collection; update imports mechanically)
|
- `tests/**` — imports (tests not executed, but must not break collection; update imports mechanically)
|
||||||
|
|
||||||
**Modified (PR 3 — config & env):**
|
**Modified (PR 3 — config & env):**
|
||||||
- `fabledscryer/config.py` → `roundtable/config.py` (already moved in PR 2) — read both `ROUNDTABLE_*` and `FABLEDSCRYER_*` with deprecation warning
|
- `fabledscryer/config.py` → `steward/config.py` (already moved in PR 2) — read both `STEWARD_*` and `FABLEDSCRYER_*` with deprecation warning
|
||||||
- `.env.example`, `config.example.yaml` — new names
|
- `.env.example`, `config.example.yaml` — new names
|
||||||
- First-boot migration of `~/.config/fabledscryer` → `~/.config/roundtable` (if the app uses it — verify during task)
|
- First-boot migration of `~/.config/fabledscryer` → `~/.config/steward` (if the app uses it — verify during task)
|
||||||
|
|
||||||
**Modified (PR 4 — container + user strings):**
|
**Modified (PR 4 — container + user strings):**
|
||||||
- `Dockerfile` — COPY paths, CMD, image labels
|
- `Dockerfile` — COPY paths, CMD, image labels
|
||||||
- `docker-compose.yml` — service name, image, container_name, volumes, env
|
- `docker-compose.yml` — service name, image, container_name, volumes, env
|
||||||
- `entrypoint.sh` — package path for `nut_setup.py` invocation
|
- `entrypoint.sh` — package path for `nut_setup.py` invocation
|
||||||
- `roundtable/templates/base.html` — wordmark text "Fabled Scryer" → "Roundtable", `<title>`
|
- `steward/templates/base.html` — wordmark text "Fabled Scryer" → "Steward", `<title>`
|
||||||
- `roundtable/templates/auth/login.html` — any residual "Fabled Scryer" text
|
- `steward/templates/auth/login.html` — any residual "Fabled Scryer" text
|
||||||
- `README.md` — first-page references (full doc sweep is PR 5)
|
- `README.md` — first-page references (full doc sweep is PR 5)
|
||||||
|
|
||||||
**Modified (PR 5 — docs):**
|
**Modified (PR 5 — docs):**
|
||||||
- `README.md`, `docs/**/*.md`
|
- `README.md`, `docs/**/*.md`
|
||||||
|
|
||||||
**Modified (PR 6 — cleanup):**
|
**Modified (PR 6 — cleanup):**
|
||||||
- `roundtable/config.py` — remove fallback shim
|
- `steward/config.py` — remove fallback shim
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -197,7 +197,7 @@ git commit -m "style: candlelit glow background replaces star field"
|
|||||||
- [ ] **Step 1: Change the title block**
|
- [ ] **Step 1: Change the title block**
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<title>{% block title %}Roundtable{% endblock %}</title>
|
<title>{% block title %}Steward{% endblock %}</title>
|
||||||
```
|
```
|
||||||
|
|
||||||
Note: the visible wordmark text still reads "Fabled Scryer" — that flips in PR 4. This only changes the browser tab title, which is acceptable to flip early since it's not visible inside the UI.
|
Note: the visible wordmark text still reads "Fabled Scryer" — that flips in PR 4. This only changes the browser tab title, which is acceptable to flip early since it's not visible inside the UI.
|
||||||
@@ -263,7 +263,7 @@ Content for `fabledscryer/templates/errors/404.html`:
|
|||||||
|
|
||||||
```html
|
```html
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Not Found — Roundtable{% endblock %}
|
{% block title %}Not Found — Steward{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div style="text-align:center; padding: 4rem 2rem; font-family: var(--font-serif);">
|
<div style="text-align:center; padding: 4rem 2rem; font-family: var(--font-serif);">
|
||||||
<h1 style="color: var(--gold); font-size: 2.5rem; margin-bottom: 0.5rem;">404</h1>
|
<h1 style="color: var(--gold); font-size: 2.5rem; margin-bottom: 0.5rem;">404</h1>
|
||||||
@@ -278,7 +278,7 @@ Content for `fabledscryer/templates/errors/500.html`:
|
|||||||
|
|
||||||
```html
|
```html
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Error — Roundtable{% endblock %}
|
{% block title %}Error — Steward{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div style="text-align:center; padding: 4rem 2rem; font-family: var(--font-serif);">
|
<div style="text-align:center; padding: 4rem 2rem; font-family: var(--font-serif);">
|
||||||
<h1 style="color: var(--red); font-size: 2.5rem; margin-bottom: 0.5rem;">500</h1>
|
<h1 style="color: var(--red); font-size: 2.5rem; margin-bottom: 0.5rem;">500</h1>
|
||||||
@@ -361,7 +361,7 @@ git add -A
|
|||||||
git commit -m "style: visual polish after manual review"
|
git commit -m "style: visual polish after manual review"
|
||||||
```
|
```
|
||||||
|
|
||||||
**PR 1 done.** Open PR with title `feat: roundtable visual reskin (pewter & gold)`.
|
**PR 1 done.** Open PR with title `feat: steward visual reskin (pewter & gold)`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -370,39 +370,39 @@ git commit -m "style: visual polish after manual review"
|
|||||||
### Task 10: Rename the package directory
|
### Task 10: Rename the package directory
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Rename: `fabledscryer/` → `roundtable/`
|
- Rename: `fabledscryer/` → `steward/`
|
||||||
|
|
||||||
- [ ] **Step 1: Rename the directory**
|
- [ ] **Step 1: Rename the directory**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git mv fabledscryer roundtable
|
git mv fabledscryer steward
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2: Confirm the move**
|
- [ ] **Step 2: Confirm the move**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ls roundtable/ | head
|
ls steward/ | head
|
||||||
git status | head
|
git status | head
|
||||||
```
|
```
|
||||||
|
|
||||||
Expected: see `__init__.py`, `app.py`, `cli.py`, etc. inside `roundtable/`.
|
Expected: see `__init__.py`, `app.py`, `cli.py`, etc. inside `steward/`.
|
||||||
|
|
||||||
- [ ] **Step 3: Commit**
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git commit -m "refactor: rename package directory fabledscryer → roundtable"
|
git commit -m "refactor: rename package directory fabledscryer → steward"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Task 11: Rewrite all `fabledscryer` imports to `roundtable`
|
### Task 11: Rewrite all `fabledscryer` imports to `steward`
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Modify: every file under `roundtable/`, `tests/`, `alembic.ini`, `entrypoint.sh`, `Dockerfile` that imports from the old package
|
- Modify: every file under `steward/`, `tests/`, `alembic.ini`, `entrypoint.sh`, `Dockerfile` that imports from the old package
|
||||||
|
|
||||||
- [ ] **Step 1: Find every remaining `fabledscryer` reference in Python code**
|
- [ ] **Step 1: Find every remaining `fabledscryer` reference in Python code**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
```bash
|
```bash
|
||||||
grep -rn "fabledscryer" roundtable/ tests/ alembic.ini entrypoint.sh Dockerfile pyproject.toml
|
grep -rn "fabledscryer" steward/ tests/ alembic.ini entrypoint.sh Dockerfile pyproject.toml
|
||||||
```
|
```
|
||||||
|
|
||||||
Keep the output visible as a checklist.
|
Keep the output visible as a checklist.
|
||||||
@@ -410,24 +410,24 @@ Keep the output visible as a checklist.
|
|||||||
- [ ] **Step 2: Run a safe codemod across Python files**
|
- [ ] **Step 2: Run a safe codemod across Python files**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
grep -rl "fabledscryer" roundtable/ tests/ | xargs sed -i 's/fabledscryer/roundtable/g'
|
grep -rl "fabledscryer" steward/ tests/ | xargs sed -i 's/fabledscryer/steward/g'
|
||||||
```
|
```
|
||||||
|
|
||||||
This touches only files under `roundtable/` and `tests/`. Do NOT run it across the whole repo yet — `docs/`, `.env.example`, `docker-compose.yml`, `config.example.yaml` and `README.md` are handled in later PRs.
|
This touches only files under `steward/` and `tests/`. Do NOT run it across the whole repo yet — `docs/`, `.env.example`, `docker-compose.yml`, `config.example.yaml` and `README.md` are handled in later PRs.
|
||||||
|
|
||||||
- [ ] **Step 3: Spot-check a few critical files**
|
- [ ] **Step 3: Spot-check a few critical files**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
grep -n "roundtable\|fabledscryer" roundtable/app.py roundtable/cli.py roundtable/config.py roundtable/migrations/env.py
|
grep -n "steward\|fabledscryer" steward/app.py steward/cli.py steward/config.py steward/migrations/env.py
|
||||||
```
|
```
|
||||||
|
|
||||||
Expected: only `roundtable` references remain; no stray `fabledscryer`.
|
Expected: only `steward` references remain; no stray `fabledscryer`.
|
||||||
|
|
||||||
- [ ] **Step 4: Commit**
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add -A
|
git add -A
|
||||||
git commit -m "refactor: update imports fabledscryer → roundtable"
|
git commit -m "refactor: update imports fabledscryer → steward"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Task 12: Update `pyproject.toml`
|
### Task 12: Update `pyproject.toml`
|
||||||
@@ -439,15 +439,15 @@ git commit -m "refactor: update imports fabledscryer → roundtable"
|
|||||||
|
|
||||||
```toml
|
```toml
|
||||||
[project]
|
[project]
|
||||||
name = "roundtable"
|
name = "steward"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
# ... dependencies unchanged ...
|
# ... dependencies unchanged ...
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
roundtable = "roundtable.cli:main"
|
steward = "steward.cli:main"
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
[tool.hatch.build.targets.wheel]
|
||||||
packages = ["roundtable"]
|
packages = ["steward"]
|
||||||
```
|
```
|
||||||
|
|
||||||
Leave `[project.optional-dependencies]`, `[tool.pytest.ini_options]`, and all dependency pins untouched.
|
Leave `[project.optional-dependencies]`, `[tool.pytest.ini_options]`, and all dependency pins untouched.
|
||||||
@@ -456,7 +456,7 @@ Leave `[project.optional-dependencies]`, `[tool.pytest.ini_options]`, and all de
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add pyproject.toml
|
git add pyproject.toml
|
||||||
git commit -m "build: rename package to roundtable in pyproject"
|
git commit -m "build: rename package to steward in pyproject"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Task 13: Update `alembic.ini`
|
### Task 13: Update `alembic.ini`
|
||||||
@@ -468,7 +468,7 @@ git commit -m "build: rename package to roundtable in pyproject"
|
|||||||
|
|
||||||
```ini
|
```ini
|
||||||
[alembic]
|
[alembic]
|
||||||
script_location = roundtable/migrations
|
script_location = steward/migrations
|
||||||
prepend_sys_path = .
|
prepend_sys_path = .
|
||||||
version_path_separator = os
|
version_path_separator = os
|
||||||
```
|
```
|
||||||
@@ -477,7 +477,7 @@ version_path_separator = os
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add alembic.ini
|
git add alembic.ini
|
||||||
git commit -m "build: point alembic at roundtable/migrations"
|
git commit -m "build: point alembic at steward/migrations"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Task 14: Verify the package installs and imports cleanly
|
### Task 14: Verify the package installs and imports cleanly
|
||||||
@@ -487,8 +487,8 @@ git commit -m "build: point alembic at roundtable/migrations"
|
|||||||
```bash
|
```bash
|
||||||
python -m venv /tmp/rt-verify
|
python -m venv /tmp/rt-verify
|
||||||
/tmp/rt-verify/bin/pip install -e .
|
/tmp/rt-verify/bin/pip install -e .
|
||||||
/tmp/rt-verify/bin/python -c "import roundtable; import roundtable.app; import roundtable.cli; print('ok')"
|
/tmp/rt-verify/bin/python -c "import steward; import steward.app; import steward.cli; print('ok')"
|
||||||
/tmp/rt-verify/bin/roundtable --help
|
/tmp/rt-verify/bin/steward --help
|
||||||
```
|
```
|
||||||
|
|
||||||
Expected: `ok` and CLI help output. No ImportError.
|
Expected: `ok` and CLI help output. No ImportError.
|
||||||
@@ -501,20 +501,20 @@ rm -rf /tmp/rt-verify
|
|||||||
|
|
||||||
- [ ] **Step 3: No commit needed (verification only).**
|
- [ ] **Step 3: No commit needed (verification only).**
|
||||||
|
|
||||||
**PR 2 done.** Open PR with title `refactor: rename python package fabledscryer → roundtable`.
|
**PR 2 done.** Open PR with title `refactor: rename python package fabledscryer → steward`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## PR 3 — Config & Environment Variables
|
## PR 3 — Config & Environment Variables
|
||||||
|
|
||||||
### Task 15: Add env-var fallback shim in `roundtable/config.py`
|
### Task 15: Add env-var fallback shim in `steward/config.py`
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Modify: `roundtable/config.py`
|
- Modify: `steward/config.py`
|
||||||
|
|
||||||
- [ ] **Step 1: Replace the env var lookups with a fallback helper**
|
- [ ] **Step 1: Replace the env var lookups with a fallback helper**
|
||||||
|
|
||||||
Open `roundtable/config.py` and replace the existing `load_bootstrap` body so it reads `ROUNDTABLE_*` first and falls back to `FABLEDSCRYER_*` with a deprecation warning.
|
Open `steward/config.py` and replace the existing `load_bootstrap` body so it reads `STEWARD_*` first and falls back to `FABLEDSCRYER_*` with a deprecation warning.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def _env_with_fallback(new_name: str, old_name: str) -> str | None:
|
def _env_with_fallback(new_name: str, old_name: str) -> str | None:
|
||||||
@@ -536,19 +536,19 @@ Then inside `load_bootstrap`, replace the existing env lookups with:
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
database_url = (
|
database_url = (
|
||||||
_env_with_fallback("ROUNDTABLE_DATABASE_URL", "FABLEDSCRYER_DATABASE_URL")
|
_env_with_fallback("STEWARD_DATABASE_URL", "FABLEDSCRYER_DATABASE_URL")
|
||||||
or _env_with_fallback("ROUNDTABLE_DATABASE__URL", "FABLEDSCRYER_DATABASE__URL")
|
or _env_with_fallback("STEWARD_DATABASE__URL", "FABLEDSCRYER_DATABASE__URL")
|
||||||
or raw.get("database", {}).get("url")
|
or raw.get("database", {}).get("url")
|
||||||
)
|
)
|
||||||
if not database_url:
|
if not database_url:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"Database URL is required. Set ROUNDTABLE_DATABASE_URL env var "
|
"Database URL is required. Set STEWARD_DATABASE_URL env var "
|
||||||
"or add 'database.url' to config.yaml."
|
"or add 'database.url' to config.yaml."
|
||||||
)
|
)
|
||||||
|
|
||||||
# ...
|
# ...
|
||||||
plugin_dir = (
|
plugin_dir = (
|
||||||
_env_with_fallback("ROUNDTABLE_PLUGIN_DIR", "FABLEDSCRYER_PLUGIN_DIR")
|
_env_with_fallback("STEWARD_PLUGIN_DIR", "FABLEDSCRYER_PLUGIN_DIR")
|
||||||
or raw.get("plugin_dir", "plugins")
|
or raw.get("plugin_dir", "plugins")
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
@@ -557,15 +557,15 @@ And in `_resolve_secret_key`:
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
from_env = (
|
from_env = (
|
||||||
_env_with_fallback("ROUNDTABLE_SECRET_KEY", "FABLEDSCRYER_SECRET_KEY")
|
_env_with_fallback("STEWARD_SECRET_KEY", "FABLEDSCRYER_SECRET_KEY")
|
||||||
or raw.get("secret_key")
|
or raw.get("secret_key")
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2: Grep for any other `FABLEDSCRYER_` or `FABLEDNETMON_` env var reads across `roundtable/`**
|
- [ ] **Step 2: Grep for any other `FABLEDSCRYER_` or `FABLEDNETMON_` env var reads across `steward/`**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
grep -rn "FABLEDSCRYER_\|FABLEDNETMON_" roundtable/
|
grep -rn "FABLEDSCRYER_\|FABLEDNETMON_" steward/
|
||||||
```
|
```
|
||||||
|
|
||||||
For each hit, apply the same pattern (new name first, old as fallback with warning). If `FABLEDNETMON_` is pure residue with no current consumer, delete the reference.
|
For each hit, apply the same pattern (new name first, old as fallback with warning). If `FABLEDNETMON_` is pure residue with no current consumer, delete the reference.
|
||||||
@@ -573,8 +573,8 @@ For each hit, apply the same pattern (new name first, old as fallback with warni
|
|||||||
- [ ] **Step 3: Commit**
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add roundtable/config.py
|
git add steward/config.py
|
||||||
git commit -m "feat: ROUNDTABLE_* env vars with FABLEDSCRYER_* fallback"
|
git commit -m "feat: STEWARD_* env vars with FABLEDSCRYER_* fallback"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Task 16: Update `.env.example` and `config.example.yaml`
|
### Task 16: Update `.env.example` and `config.example.yaml`
|
||||||
@@ -588,11 +588,11 @@ git commit -m "feat: ROUNDTABLE_* env vars with FABLEDSCRYER_* fallback"
|
|||||||
grep -n "FABLEDSCRYER\|FABLEDNETMON" .env.example
|
grep -n "FABLEDSCRYER\|FABLEDNETMON" .env.example
|
||||||
```
|
```
|
||||||
|
|
||||||
Replace every occurrence with `ROUNDTABLE_*`. Add a short comment at top:
|
Replace every occurrence with `STEWARD_*`. Add a short comment at top:
|
||||||
|
|
||||||
```
|
```
|
||||||
# Roundtable environment configuration.
|
# Steward environment configuration.
|
||||||
# Only ROUNDTABLE_DATABASE_URL is required. Other settings live in the DB.
|
# Only STEWARD_DATABASE_URL is required. Other settings live in the DB.
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2: Rewrite `config.example.yaml`**
|
- [ ] **Step 2: Rewrite `config.example.yaml`**
|
||||||
@@ -600,25 +600,25 @@ Replace every occurrence with `ROUNDTABLE_*`. Add a short comment at top:
|
|||||||
Replace the header and example strings:
|
Replace the header and example strings:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# Roundtable — Bootstrap Configuration Example
|
# Steward — Bootstrap Configuration Example
|
||||||
#
|
#
|
||||||
# The only REQUIRED setting is the database URL.
|
# The only REQUIRED setting is the database URL.
|
||||||
# Set it via env var (recommended for Docker):
|
# Set it via env var (recommended for Docker):
|
||||||
#
|
#
|
||||||
# ROUNDTABLE_DATABASE_URL=postgresql+asyncpg://user:pass@host/db
|
# STEWARD_DATABASE_URL=postgresql+asyncpg://user:pass@host/db
|
||||||
#
|
#
|
||||||
# All other settings are stored in the database and managed via
|
# All other settings are stored in the database and managed via
|
||||||
# the Settings UI at /settings/.
|
# the Settings UI at /settings/.
|
||||||
|
|
||||||
database:
|
database:
|
||||||
url: "postgresql+asyncpg://roundtable:password@localhost/roundtable"
|
url: "postgresql+asyncpg://steward:password@localhost/steward"
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 3: Commit**
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add .env.example config.example.yaml
|
git add .env.example config.example.yaml
|
||||||
git commit -m "docs: ROUNDTABLE_* env var examples"
|
git commit -m "docs: STEWARD_* env var examples"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Task 17: Manual config verification
|
### Task 17: Manual config verification
|
||||||
@@ -626,8 +626,8 @@ git commit -m "docs: ROUNDTABLE_* env var examples"
|
|||||||
- [ ] **Step 1: Run the app with only the new env var**
|
- [ ] **Step 1: Run the app with only the new env var**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ROUNDTABLE_DATABASE_URL=postgresql+asyncpg://fabledscryer:fabledscryer@localhost/fabledscryer \
|
STEWARD_DATABASE_URL=postgresql+asyncpg://fabledscryer:fabledscryer@localhost/fabledscryer \
|
||||||
python -m roundtable.cli --host 127.0.0.1 --port 5001
|
python -m steward.cli --host 127.0.0.1 --port 5001
|
||||||
```
|
```
|
||||||
|
|
||||||
(Use whatever local DB you have; the old db name is fine — data isn't being renamed.)
|
(Use whatever local DB you have; the old db name is fine — data isn't being renamed.)
|
||||||
@@ -638,14 +638,14 @@ Expected: app starts, no deprecation warning.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
FABLEDSCRYER_DATABASE_URL=postgresql+asyncpg://fabledscryer:fabledscryer@localhost/fabledscryer \
|
FABLEDSCRYER_DATABASE_URL=postgresql+asyncpg://fabledscryer:fabledscryer@localhost/fabledscryer \
|
||||||
python -m roundtable.cli --host 127.0.0.1 --port 5001
|
python -m steward.cli --host 127.0.0.1 --port 5001
|
||||||
```
|
```
|
||||||
|
|
||||||
Expected: app starts, deprecation warning logged once.
|
Expected: app starts, deprecation warning logged once.
|
||||||
|
|
||||||
- [ ] **Step 3: Kill processes.** No commit.
|
- [ ] **Step 3: Kill processes.** No commit.
|
||||||
|
|
||||||
**PR 3 done.** Open PR with title `feat: ROUNDTABLE_* env vars (FABLEDSCRYER_* fallback)`.
|
**PR 3 done.** Open PR with title `feat: STEWARD_* env vars (FABLEDSCRYER_* fallback)`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -664,12 +664,12 @@ COPY fabledscryer/ fabledscryer/
|
|||||||
```
|
```
|
||||||
to
|
to
|
||||||
```dockerfile
|
```dockerfile
|
||||||
COPY roundtable/ roundtable/
|
COPY steward/ steward/
|
||||||
```
|
```
|
||||||
|
|
||||||
Change the CMD:
|
Change the CMD:
|
||||||
```dockerfile
|
```dockerfile
|
||||||
CMD ["roundtable", "--host", "0.0.0.0", "--port", "5000"]
|
CMD ["steward", "--host", "0.0.0.0", "--port", "5000"]
|
||||||
```
|
```
|
||||||
|
|
||||||
Leave everything else (apt packages, gosu, app user, EXPOSE 5000) untouched.
|
Leave everything else (apt packages, gosu, app user, EXPOSE 5000) untouched.
|
||||||
@@ -678,7 +678,7 @@ Leave everything else (apt packages, gosu, app user, EXPOSE 5000) untouched.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add Dockerfile
|
git add Dockerfile
|
||||||
git commit -m "build: update Dockerfile for roundtable package"
|
git commit -m "build: update Dockerfile for steward package"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Task 19: Update `entrypoint.sh`
|
### Task 19: Update `entrypoint.sh`
|
||||||
@@ -688,15 +688,15 @@ git commit -m "build: update Dockerfile for roundtable package"
|
|||||||
|
|
||||||
- [ ] **Step 1: Replace the `nut_setup.py` invocation path**
|
- [ ] **Step 1: Replace the `nut_setup.py` invocation path**
|
||||||
|
|
||||||
Change every `/app/fabledscryer/nut_setup.py` to `/app/roundtable/nut_setup.py`.
|
Change every `/app/fabledscryer/nut_setup.py` to `/app/steward/nut_setup.py`.
|
||||||
|
|
||||||
Update any comment that says "FabledScryer container entrypoint." to "Roundtable container entrypoint."
|
Update any comment that says "FabledScryer container entrypoint." to "Steward container entrypoint."
|
||||||
|
|
||||||
- [ ] **Step 2: Commit**
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add entrypoint.sh
|
git add entrypoint.sh
|
||||||
git commit -m "build: entrypoint.sh uses roundtable package path"
|
git commit -m "build: entrypoint.sh uses steward package path"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Task 20: Update `docker-compose.yml`
|
### Task 20: Update `docker-compose.yml`
|
||||||
@@ -708,10 +708,10 @@ git commit -m "build: entrypoint.sh uses roundtable package path"
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
roundtable:
|
steward:
|
||||||
build: .
|
build: .
|
||||||
container_name: roundtable
|
container_name: steward
|
||||||
image: roundtable:latest
|
image: steward:latest
|
||||||
ports:
|
ports:
|
||||||
- "5000:5000"
|
- "5000:5000"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -720,7 +720,7 @@ services:
|
|||||||
- /mnt/Data/traefik/log:/var/log/traefik:ro
|
- /mnt/Data/traefik/log:/var/log/traefik:ro
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
environment:
|
environment:
|
||||||
- ROUNDTABLE_DATABASE_URL=postgresql+asyncpg://roundtable:roundtable@db/roundtable
|
- STEWARD_DATABASE_URL=postgresql+asyncpg://steward:steward@db/steward
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -729,13 +729,13 @@ services:
|
|||||||
db:
|
db:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: roundtable
|
POSTGRES_USER: steward
|
||||||
POSTGRES_PASSWORD: roundtable
|
POSTGRES_PASSWORD: steward
|
||||||
POSTGRES_DB: roundtable
|
POSTGRES_DB: steward
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U roundtable"]
|
test: ["CMD-SHELL", "pg_isready -U steward"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
@@ -752,37 +752,37 @@ volumes:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add docker-compose.yml
|
git add docker-compose.yml
|
||||||
git commit -m "build: docker-compose service → roundtable"
|
git commit -m "build: docker-compose service → steward"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Task 21: Flip user-visible wordmark and title
|
### Task 21: Flip user-visible wordmark and title
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Modify: `roundtable/templates/base.html`
|
- Modify: `steward/templates/base.html`
|
||||||
|
|
||||||
- [ ] **Step 1: Update the `<title>` block (around line 6)**
|
- [ ] **Step 1: Update the `<title>` block (around line 6)**
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<title>{% block title %}Roundtable{% endblock %}</title>
|
<title>{% block title %}Steward{% endblock %}</title>
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2: Update the nav wordmark (around line 214)**
|
- [ ] **Step 2: Update the nav wordmark (around line 214)**
|
||||||
|
|
||||||
Change `Fabled Scryer` inside `nav .brand` to `Roundtable`.
|
Change `Fabled Scryer` inside `nav .brand` to `Steward`.
|
||||||
|
|
||||||
- [ ] **Step 3: Grep for any other user-visible "Fabled Scryer" strings in templates**
|
- [ ] **Step 3: Grep for any other user-visible "Fabled Scryer" strings in templates**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
grep -rn "Fabled Scryer\|FabledScryer" roundtable/templates/
|
grep -rn "Fabled Scryer\|FabledScryer" steward/templates/
|
||||||
```
|
```
|
||||||
|
|
||||||
Replace each with "Roundtable".
|
Replace each with "Steward".
|
||||||
|
|
||||||
- [ ] **Step 4: Commit**
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add roundtable/templates/
|
git add steward/templates/
|
||||||
git commit -m "copy: flip visible wordmark → Roundtable"
|
git commit -m "copy: flip visible wordmark → Steward"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Task 22: Manual container verification
|
### Task 22: Manual container verification
|
||||||
@@ -795,8 +795,8 @@ docker compose up --build
|
|||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2: Verify**
|
- [ ] **Step 2: Verify**
|
||||||
- Container named `roundtable` runs
|
- Container named `steward` runs
|
||||||
- Browser shows "Roundtable" in tab title and nav
|
- Browser shows "Steward" in tab title and nav
|
||||||
- Login, dashboard, and error pages all render with the new palette and wordmark
|
- Login, dashboard, and error pages all render with the new palette and wordmark
|
||||||
- Logs show no import errors and no unhandled deprecation warnings
|
- Logs show no import errors and no unhandled deprecation warnings
|
||||||
|
|
||||||
@@ -806,7 +806,7 @@ docker compose up --build
|
|||||||
docker compose down
|
docker compose down
|
||||||
```
|
```
|
||||||
|
|
||||||
**PR 4 done.** Open PR with title `feat: roundtable container & wordmark flip`.
|
**PR 4 done.** Open PR with title `feat: steward container & wordmark flip`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -828,9 +828,9 @@ grep -rn "fabledscryer\|FabledScryer\|Fabled Scryer" README.md docs/
|
|||||||
```bash
|
```bash
|
||||||
grep -rl "fabledscryer\|FabledScryer\|Fabled Scryer" README.md docs/ \
|
grep -rl "fabledscryer\|FabledScryer\|Fabled Scryer" README.md docs/ \
|
||||||
| xargs sed -i \
|
| xargs sed -i \
|
||||||
-e 's/FabledScryer/Roundtable/g' \
|
-e 's/FabledScryer/Steward/g' \
|
||||||
-e 's/fabledscryer/roundtable/g' \
|
-e 's/fabledscryer/steward/g' \
|
||||||
-e 's/Fabled Scryer/Roundtable/g'
|
-e 's/Fabled Scryer/Steward/g'
|
||||||
```
|
```
|
||||||
|
|
||||||
Then read each changed file top-to-bottom and fix any mangled sentences (e.g. capitalization at the start of sentences, code blocks that now reference a directory that still needs a `./` prefix, etc.). Pay extra attention to:
|
Then read each changed file top-to-bottom and fix any mangled sentences (e.g. capitalization at the start of sentences, code blocks that now reference a directory that still needs a `./` prefix, etc.). Pay extra attention to:
|
||||||
@@ -843,12 +843,12 @@ Then read each changed file top-to-bottom and fix any mangled sentences (e.g. ca
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add README.md docs/
|
git add README.md docs/
|
||||||
git commit -m "docs: roundtable rename sweep"
|
git commit -m "docs: steward rename sweep"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Task 24: Rename the Forgejo repo
|
### Task 24: Rename the Forgejo repo
|
||||||
|
|
||||||
- [ ] **Step 1: Via Forgejo UI or API, rename the repo from `FabledScryer` to `Roundtable`.** The old URL will redirect for one grace period.
|
- [ ] **Step 1: Via Forgejo UI or API, rename the repo from `FabledScryer` to `Steward`.** The old URL will redirect for one grace period.
|
||||||
|
|
||||||
- [ ] **Step 2: Update your local remote URL**
|
- [ ] **Step 2: Update your local remote URL**
|
||||||
|
|
||||||
@@ -862,13 +862,13 @@ git remote -v
|
|||||||
```bash
|
```bash
|
||||||
# from the parent dir
|
# from the parent dir
|
||||||
cd ..
|
cd ..
|
||||||
mv FabledScryer Roundtable
|
mv FabledScryer Steward
|
||||||
cd Roundtable
|
cd Steward
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 4: No commit.**
|
- [ ] **Step 4: No commit.**
|
||||||
|
|
||||||
**PR 5 done.** Open PR with title `docs: roundtable rename sweep`.
|
**PR 5 done.** Open PR with title `docs: steward rename sweep`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -877,11 +877,11 @@ cd Roundtable
|
|||||||
### Task 25: Remove env var fallback shim
|
### Task 25: Remove env var fallback shim
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Modify: `roundtable/config.py`
|
- Modify: `steward/config.py`
|
||||||
|
|
||||||
- [ ] **Step 1: Delete `_env_with_fallback` and inline direct `os.environ.get("ROUNDTABLE_*")` lookups**
|
- [ ] **Step 1: Delete `_env_with_fallback` and inline direct `os.environ.get("STEWARD_*")` lookups**
|
||||||
|
|
||||||
Replace each `_env_with_fallback("ROUNDTABLE_X", "FABLEDSCRYER_X")` call with `os.environ.get("ROUNDTABLE_X")`. Delete the helper.
|
Replace each `_env_with_fallback("STEWARD_X", "FABLEDSCRYER_X")` call with `os.environ.get("STEWARD_X")`. Delete the helper.
|
||||||
|
|
||||||
- [ ] **Step 2: Grep to confirm no `FABLEDSCRYER_` references remain**
|
- [ ] **Step 2: Grep to confirm no `FABLEDSCRYER_` references remain**
|
||||||
|
|
||||||
@@ -894,7 +894,7 @@ Expected: zero hits (or only historical commit messages, which don't show up in
|
|||||||
- [ ] **Step 3: Commit**
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add roundtable/config.py
|
git add steward/config.py
|
||||||
git commit -m "refactor: remove FABLEDSCRYER_* env var fallback shim"
|
git commit -m "refactor: remove FABLEDSCRYER_* env var fallback shim"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -923,5 +923,5 @@ git commit -m "refactor: final rename sweep"
|
|||||||
|
|
||||||
- Spec coverage: all 5 spec sections mapped to PRs 1–5; PR 6 handles the cleanup implied by the shim in PR 3.
|
- Spec coverage: all 5 spec sections mapped to PRs 1–5; PR 6 handles the cleanup implied by the shim in PR 3.
|
||||||
- No placeholders — every template replacement includes concrete code; every grep includes the actual pattern.
|
- No placeholders — every template replacement includes concrete code; every grep includes the actual pattern.
|
||||||
- Type consistency: config helper is `_env_with_fallback` in both Task 15 and Task 25; env var names are `ROUNDTABLE_DATABASE_URL`, `ROUNDTABLE_PLUGIN_DIR`, `ROUNDTABLE_SECRET_KEY` consistently.
|
- Type consistency: config helper is `_env_with_fallback` in both Task 15 and Task 25; env var names are `STEWARD_DATABASE_URL`, `STEWARD_PLUGIN_DIR`, `STEWARD_SECRET_KEY` consistently.
|
||||||
- Tests intentionally skipped per project feedback; verification is manual (browser + container).
|
- Tests intentionally skipped per project feedback; verification is manual (browser + container).
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,21 +1,21 @@
|
|||||||
# Roundtable Rebrand — Design
|
# Steward Rebrand — Design
|
||||||
|
|
||||||
**Date:** 2026-04-13
|
**Date:** 2026-04-13
|
||||||
**Status:** Approved for planning
|
**Status:** Approved for planning
|
||||||
|
|
||||||
## 1. Identity
|
## 1. Identity
|
||||||
|
|
||||||
**Name:** Roundtable (dropping the "Fabled" prefix).
|
**Name:** Steward (dropping the "Fabled" prefix).
|
||||||
|
|
||||||
**Why the change:** The old name `FabledScryer` framed the app as a single seer peering into one crystal ball. The actual product is a gathering point — hosts, metrics, alerts, plugins, and dashboards from across the homelab converge here. "Roundtable" captures that: a place where the realm's tools and information meet.
|
**Why the change:** The old name `FabledScryer` framed the app as a single seer peering into one crystal ball. The actual product is a gathering point — hosts, metrics, alerts, plugins, and dashboards from across the homelab converge here. "Steward" captures that: a place where the realm's tools and information meet.
|
||||||
|
|
||||||
**Name conflict check:**
|
**Name conflict check:**
|
||||||
- *Roundtable Software* — a Progress ABL tooling vendor. Different demographic, no overlap.
|
- *Steward Software* — a Progress ABL tooling vendor. Different demographic, no overlap.
|
||||||
- *Fabled.gg* — a virtual tabletop product. Closest concern, and the reason the "Fabled" prefix is dropped entirely to avoid confusion inside the tabletop/fantasy space.
|
- *Fabled.gg* — a virtual tabletop product. Closest concern, and the reason the "Fabled" prefix is dropped entirely to avoid confusion inside the tabletop/fantasy space.
|
||||||
|
|
||||||
**Umbrella:** `fabledsword.com` remains the family umbrella domain; `git.fabledsword.com` remains the plugin catalog host. Roundtable becomes one product under that umbrella.
|
**Umbrella:** `fabledsword.com` remains the family umbrella domain; `git.fabledsword.com` remains the plugin catalog host. Steward becomes one product under that umbrella.
|
||||||
|
|
||||||
**Tone:** Heraldic / Arthurian register. The roundtable is the gathering place of those who keep watch over the realm.
|
**Tone:** Heraldic / Arthurian register. The steward is the gathering place of those who keep watch over the realm.
|
||||||
|
|
||||||
## 2. Visual System
|
## 2. Visual System
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ Candlelit glow — a soft, warm radial wash behind the main content area. Replac
|
|||||||
|
|
||||||
### Wordmark
|
### Wordmark
|
||||||
|
|
||||||
"Roundtable" in EB Garamond, gold (`#c8a840`), paired with the seal mark to the left in the nav header.
|
"Steward" in EB Garamond, gold (`#c8a840`), paired with the seal mark to the left in the nav header.
|
||||||
|
|
||||||
## 3. Voice & Copy
|
## 3. Voice & Copy
|
||||||
|
|
||||||
@@ -100,21 +100,21 @@ Candlelit glow — a soft, warm radial wash behind the main content area. Replac
|
|||||||
## 4. Rename Mechanics
|
## 4. Rename Mechanics
|
||||||
|
|
||||||
**Code & packaging**
|
**Code & packaging**
|
||||||
- `fabledscryer/` package directory → `roundtable/`
|
- `fabledscryer/` package directory → `steward/`
|
||||||
- All `from fabledscryer...` / `import fabledscryer...` → `roundtable`
|
- All `from fabledscryer...` / `import fabledscryer...` → `steward`
|
||||||
- `pyproject.toml`: project name, CLI entry point (`fabledscryer = "fabledscryer.cli:main"` → `roundtable = "roundtable.cli:main"`), tool sections
|
- `pyproject.toml`: project name, CLI entry point (`fabledscryer = "fabledscryer.cli:main"` → `steward = "steward.cli:main"`), tool sections
|
||||||
- `__init__.py` package metadata
|
- `__init__.py` package metadata
|
||||||
|
|
||||||
**Runtime config**
|
**Runtime config**
|
||||||
- Env vars: `FABLEDSCRYER_*` → `ROUNDTABLE_*`
|
- Env vars: `FABLEDSCRYER_*` → `STEWARD_*`
|
||||||
- Residual `FABLEDNETMON_*` env vars removed
|
- Residual `FABLEDNETMON_*` env vars removed
|
||||||
- Config dir: `~/.config/fabledscryer` → `~/.config/roundtable` (one-shot copy on first boot)
|
- Config dir: `~/.config/fabledscryer` → `~/.config/steward` (one-shot copy on first boot)
|
||||||
- Log file names, systemd unit names if any
|
- Log file names, systemd unit names if any
|
||||||
|
|
||||||
**Container & deploy**
|
**Container & deploy**
|
||||||
- `Dockerfile` labels, workdir
|
- `Dockerfile` labels, workdir
|
||||||
- `docker-compose.yml` service name, image tag, volume names, `container_name`
|
- `docker-compose.yml` service name, image tag, volume names, `container_name`
|
||||||
- Published image: `fabledscryer:latest` → `roundtable:latest`
|
- Published image: `fabledscryer:latest` → `steward:latest`
|
||||||
|
|
||||||
**Database**
|
**Database**
|
||||||
- Alembic `script_location` and any customized version table
|
- Alembic `script_location` and any customized version table
|
||||||
@@ -144,10 +144,10 @@ Staged so `main` stays runnable between PRs.
|
|||||||
New palette tokens, locked logo SVG, EB Garamond font swap, candlelit glow background, themed edge copy (login, empty states, 404/500, dashboard hero "Under Watch, Under Care"). Still named FabledScryer internally. Ships independently, low risk.
|
New palette tokens, locked logo SVG, EB Garamond font swap, candlelit glow background, themed edge copy (login, empty states, 404/500, dashboard hero "Under Watch, Under Care"). Still named FabledScryer internally. Ships independently, low risk.
|
||||||
|
|
||||||
**PR 2 — Python package rename**
|
**PR 2 — Python package rename**
|
||||||
`fabledscryer/` → `roundtable/`, all imports, `pyproject.toml` name + entry point, `__init__.py` metadata. Docker/config untouched. Package installable as `roundtable` locally.
|
`fabledscryer/` → `steward/`, all imports, `pyproject.toml` name + entry point, `__init__.py` metadata. Docker/config untouched. Package installable as `steward` locally.
|
||||||
|
|
||||||
**PR 3 — Config & env vars**
|
**PR 3 — Config & env vars**
|
||||||
`FABLEDSCRYER_*` → `ROUNDTABLE_*` with a short-lived fallback reader (accept both, warn on old) so self-hosters don't break mid-upgrade. Config dir migration on first boot. Drop `FABLEDNETMON_*` residue.
|
`FABLEDSCRYER_*` → `STEWARD_*` with a short-lived fallback reader (accept both, warn on old) so self-hosters don't break mid-upgrade. Config dir migration on first boot. Drop `FABLEDNETMON_*` residue.
|
||||||
|
|
||||||
**PR 4 — Container & deploy**
|
**PR 4 — Container & deploy**
|
||||||
`Dockerfile`, `docker-compose.yml` service/image/volume/container names. Published image tag switch. Template wordmark + `<title>` + meta (the last user-visible string flip).
|
`Dockerfile`, `docker-compose.yml` service/image/volume/container names. Published image tag switch. Template wordmark + `<title>` + meta (the last user-visible string flip).
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Roundtable container entrypoint.
|
# Steward container entrypoint.
|
||||||
#
|
#
|
||||||
# Drops from root to the 'app' user via gosu, then runs the command.
|
# Drops from root to the 'app' user via gosu, then runs the command.
|
||||||
# The container starts as root only so /data permissions can be fixed up
|
# The container starts as root only so /data permissions can be fixed up
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
# plugins/__init__.py
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# plugins/docker/__init__.py
|
||||||
|
from __future__ import annotations
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from quart import Quart
|
||||||
|
|
||||||
|
_app: "Quart | None" = None
|
||||||
|
|
||||||
|
|
||||||
|
def setup(app: "Quart") -> None:
|
||||||
|
global _app
|
||||||
|
_app = app
|
||||||
|
from .models import DockerContainer # noqa: F401 — register with Base
|
||||||
|
|
||||||
|
# Publish the per-host persist hook so the host agent can hand off the
|
||||||
|
# `docker` array from each sample without importing our models (opportunistic
|
||||||
|
# synergy — host_agent gates on has_capability and no-ops if we're disabled).
|
||||||
|
# required_role=viewer: this is a trusted server-side data-plane write driven
|
||||||
|
# by the authenticated agent ingest, not a user-facing privileged action.
|
||||||
|
from steward.core.capabilities import register_capability
|
||||||
|
from steward.models.users import UserRole
|
||||||
|
from .ingest import persist_host_docker
|
||||||
|
from .retention import prune_docker_logs, run_docker_retention
|
||||||
|
register_capability(
|
||||||
|
"docker.persist_host_samples", persist_host_docker,
|
||||||
|
label="Persist host Docker samples",
|
||||||
|
description="Store per-host container state + metrics pushed by the host agent.",
|
||||||
|
required_role=UserRole.viewer,
|
||||||
|
)
|
||||||
|
# Prune lifecycle events, driven by the core cleanup task without it
|
||||||
|
# importing our models. Same trusted server-side data-plane role as above.
|
||||||
|
# Container CPU and memory live in the one metric store and its retention.
|
||||||
|
register_capability(
|
||||||
|
"docker.run_retention", run_docker_retention,
|
||||||
|
label="Run Docker retention",
|
||||||
|
description="Prune Docker lifecycle events past their window.",
|
||||||
|
required_role=UserRole.viewer,
|
||||||
|
)
|
||||||
|
register_capability(
|
||||||
|
"docker.prune_logs", prune_docker_logs,
|
||||||
|
label="Prune Docker logs",
|
||||||
|
description="Keep each container's logs within the age window and byte cap.",
|
||||||
|
required_role=UserRole.viewer,
|
||||||
|
)
|
||||||
|
# Each container's CPU (100% is one core) and memory share learn their
|
||||||
|
# normal (#5742), each with its floor from Settings → Thresholds.
|
||||||
|
from steward.core.norms import floor_setting, learn_normal
|
||||||
|
learn_normal("docker", "cpu_pct", floor_setting(app, "container_cpu_pct"))
|
||||||
|
learn_normal("docker", "mem_pct", floor_setting(app, "container_mem_pct"))
|
||||||
|
# A host's agent carries its containers' figures: they go silent with it (#5721).
|
||||||
|
from steward.core.alerts import agent_fed
|
||||||
|
agent_fed("docker", lambda resource: resource.split("/", 1)[0])
|
||||||
|
|
||||||
|
|
||||||
|
def get_scheduled_tasks() -> list:
|
||||||
|
# Collection is now agent-driven (pushed to the host_agent ingest); the
|
||||||
|
# central socket scrape was removed. No periodic task to register.
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def get_blueprint():
|
||||||
|
from .routes import docker_bp
|
||||||
|
return docker_bp
|
||||||
|
|
||||||
|
|
||||||
|
def register_mcp_tools(register) -> None:
|
||||||
|
from .mcp_tools import register_tools
|
||||||
|
register_tools(register)
|
||||||
|
|
||||||
|
|
||||||
|
async def mcp_overview(db) -> list[dict]:
|
||||||
|
from .mcp_tools import mcp_overview as findings
|
||||||
|
return await findings(db)
|
||||||
|
|
||||||
|
|
||||||
|
def get_nav() -> list[dict]:
|
||||||
|
# Surfaces in the sidebar's Infrastructure group (fleet-wide container view).
|
||||||
|
return [{"label": "Docker", "href": "/plugins/docker/"}]
|
||||||
|
|
||||||
|
|
||||||
|
def get_alert_metrics() -> dict[str, list[str]]:
|
||||||
|
"""What ingest.py records per container (resource "<host>/<container>"):
|
||||||
|
restart_count is cumulative (alert on crash-looping); is_healthy is 1.0 /
|
||||||
|
0.0 from the container's HEALTHCHECK, only for containers that define one.
|
||||||
|
Per host (resource "<host>"), what Docker keeps on its disk (#5304).
|
||||||
|
Swarm-wide (resource "swarm"), from any manager's report: services short
|
||||||
|
of their replicas, nodes down and managers unreachable, drained nodes
|
||||||
|
aside (#5788, swarm_health.py)."""
|
||||||
|
from .disk_view import DISK_METRICS
|
||||||
|
from .swarm_health import SWARM_METRICS
|
||||||
|
return {"docker": ["cpu_pct", "mem_pct", "restart_count", "is_healthy", *DISK_METRICS, *SWARM_METRICS]}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Model-free Docker view helpers.
|
||||||
|
|
||||||
|
Kept separate from routes.py/models.py so tests can import it directly: importing
|
||||||
|
a module that re-imports plugins.docker.models AFTER the app has loaded the docker
|
||||||
|
plugin raises "Table 'docker_containers' is already defined". This module touches
|
||||||
|
no ORM models, so it's safe to import in either environment.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
def dedup_by_container_id(containers: list) -> list:
|
||||||
|
"""Collapse containers double-reported across hosts. Swarm-aware agents on
|
||||||
|
every manager list cluster-wide tasks, so the same container (identical
|
||||||
|
container_id) arrives once per manager and would otherwise be counted N times.
|
||||||
|
Keep the first occurrence per non-empty container_id — callers order
|
||||||
|
running-first, so a running report wins over a stale stopped one. Rows without
|
||||||
|
a container_id (older agents) can't be matched, so they're kept as-is."""
|
||||||
|
seen: set[str] = set()
|
||||||
|
out = []
|
||||||
|
for c in containers:
|
||||||
|
cid = (c.container_id or "").strip()
|
||||||
|
if cid:
|
||||||
|
if cid in seen:
|
||||||
|
continue
|
||||||
|
seen.add(cid)
|
||||||
|
out.append(c)
|
||||||
|
return out
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# plugins/docker/disk_view.py
|
||||||
|
"""What Docker keeps on each host's disk, over time and by image repository
|
||||||
|
(#5304–#5306). Model-free, so it is unit-tested without a database.
|
||||||
|
|
||||||
|
The agent reports `docker system df` as current state; ingest.py records its
|
||||||
|
totals as metrics too (DISK_METRICS, resource = the host's name), so each
|
||||||
|
host's growth has a history and an alert rule can watch it.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# metric name → the df summary field it records; "disk_total_bytes" is their sum.
|
||||||
|
DISK_PARTS = {
|
||||||
|
"disk_images_bytes": "images_size",
|
||||||
|
"disk_containers_bytes": "containers_size",
|
||||||
|
"disk_volumes_bytes": "volumes_size",
|
||||||
|
"disk_build_cache_bytes": "build_cache_size",
|
||||||
|
}
|
||||||
|
DISK_METRICS = [*DISK_PARTS, "disk_total_bytes", "disk_reclaimable_bytes"]
|
||||||
|
|
||||||
|
|
||||||
|
def disk_metrics(disk: dict) -> dict[str, float]:
|
||||||
|
"""The metric samples one df report gives: each part, their total, and
|
||||||
|
what unused images hold."""
|
||||||
|
out = {m: float(int(disk.get(f) or 0)) for m, f in DISK_PARTS.items()}
|
||||||
|
out["disk_total_bytes"] = sum(out.values())
|
||||||
|
out["disk_reclaimable_bytes"] = float(int(disk.get("images_reclaimable") or 0))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def growth(points: list[tuple[datetime, float]]) -> dict | None:
|
||||||
|
"""How much a total changed over the points given (oldest first): the
|
||||||
|
change, the first point's time and value. None with fewer than two."""
|
||||||
|
if len(points) < 2:
|
||||||
|
return None
|
||||||
|
(first_at, first), (_, last) = points[0], points[-1]
|
||||||
|
return {"change": last - first, "since": first_at, "from": first, "to": last,
|
||||||
|
"pct": (last - first) / first * 100 if first else None}
|
||||||
|
|
||||||
|
|
||||||
|
def image_repo(repo_tag: str | None) -> tuple[str | None, str | None]:
|
||||||
|
"""("ghcr.io/x/app", "1.2") for "ghcr.io/x/app:1.2"; a registry's port is
|
||||||
|
part of the repository ("reg:5000/app" has no tag); a digest reference
|
||||||
|
keeps its digest as the tag; (None, None) for an untagged image."""
|
||||||
|
if not repo_tag or repo_tag == "<none>" or repo_tag.startswith("<none>"):
|
||||||
|
return None, None
|
||||||
|
if "@" in repo_tag:
|
||||||
|
repo, _, digest = repo_tag.partition("@")
|
||||||
|
return repo, digest[:19]
|
||||||
|
head, sep, tag = repo_tag.rpartition(":")
|
||||||
|
if not sep or "/" in tag:
|
||||||
|
return repo_tag, None
|
||||||
|
return head, tag
|
||||||
|
|
||||||
|
|
||||||
|
def group_images(images: list[dict]) -> list[dict]:
|
||||||
|
"""Images folded under their repository, the most space first.
|
||||||
|
|
||||||
|
Each image: {repo_tag, image_id, size, shared_size, containers}. A group's
|
||||||
|
`own` counts each version's size less the layers it shares with other
|
||||||
|
images, so a repository is not charged twice for one base layer;
|
||||||
|
`reclaimable` is that, for the versions no container uses. Untagged images
|
||||||
|
form one group of their own (repo None)."""
|
||||||
|
groups: dict[str | None, dict] = {}
|
||||||
|
for im in images:
|
||||||
|
repo, tag = image_repo(im.get("repo_tag"))
|
||||||
|
g = groups.setdefault(repo, {"repo": repo, "versions": [], "own": 0, "size": 0,
|
||||||
|
"reclaimable": 0, "containers": 0})
|
||||||
|
own = max(int(im.get("size") or 0) - int(im.get("shared_size") or 0), 0)
|
||||||
|
used = int(im.get("containers") or 0)
|
||||||
|
g["versions"].append({"tag": tag, "image_id": im.get("image_id"), "size": int(im.get("size") or 0),
|
||||||
|
"shared": int(im.get("shared_size") or 0), "own": own, "containers": used})
|
||||||
|
g["own"] += own
|
||||||
|
g["size"] = max(g["size"], int(im.get("size") or 0))
|
||||||
|
g["containers"] += used
|
||||||
|
if not used:
|
||||||
|
g["reclaimable"] += own
|
||||||
|
for g in groups.values():
|
||||||
|
g["versions"].sort(key=lambda v: (v["containers"] == 0, -v["size"]))
|
||||||
|
g["unused"] = sum(1 for v in g["versions"] if not v["containers"])
|
||||||
|
return sorted(groups.values(), key=lambda g: (g["repo"] is None, -g["own"], g["repo"] or ""))
|
||||||
@@ -0,0 +1,479 @@
|
|||||||
|
# plugins/docker/ingest.py
|
||||||
|
"""Persist host-scoped Docker samples pushed by the host agent.
|
||||||
|
|
||||||
|
Published as the "docker.persist_host_samples" capability (see __init__.setup),
|
||||||
|
so the host_agent plugin can hand off a sample's `docker` array (and, on a swarm
|
||||||
|
manager, its `swarm` object) WITHOUT importing the docker models — the coupling
|
||||||
|
is opportunistic and degrades to a no-op when the docker plugin is disabled.
|
||||||
|
Runs inside the caller's open transaction (the ingest handler's SAVEPOINT);
|
||||||
|
never opens or commits its own.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import delete, select
|
||||||
|
|
||||||
|
|
||||||
|
# Stopped/terminal container states (anything not "running"/"paused"/"restarting").
|
||||||
|
_STOPPED_STATES = {"exited", "dead", "stopped"}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_started_at(value) -> datetime | None:
|
||||||
|
"""Parse the agent's ISO-8601 started_at string back to a datetime."""
|
||||||
|
if not isinstance(value, str) or not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_events(old_state: dict, new_containers: list) -> list:
|
||||||
|
"""Diff a fresh snapshot against stored per-container state → lifecycle events.
|
||||||
|
|
||||||
|
Pure function (no DB) so it's unit-testable. `old_state` maps name → the
|
||||||
|
previously stored {status, health, oom_killed, exit_code, restart_count}; `new_containers`
|
||||||
|
is the newest snapshot's list of container dicts. Returns a list of
|
||||||
|
(container_name, event, detail) tuples:
|
||||||
|
|
||||||
|
start — a container transitions into running (or a genuinely new
|
||||||
|
container appears already running)
|
||||||
|
die — also for a new container first seen already exited
|
||||||
|
non-zero: a task that crashed between two samples (#5049)
|
||||||
|
restart — Docker restarted it (its restart count went up); said
|
||||||
|
instead of a start, since that is what happened
|
||||||
|
stop — running → not-running, or a running container is removed
|
||||||
|
die — same as stop but with a non-zero exit code (abnormal)
|
||||||
|
oom — OOMKilled flips False→True
|
||||||
|
health_change — HEALTHCHECK status string changes
|
||||||
|
|
||||||
|
The CALLER skips this entirely on a host's first-ever snapshot (empty
|
||||||
|
old_state) so the baseline doesn't emit a spurious "start" per existing
|
||||||
|
container — a later-appearing container still gets one because by then
|
||||||
|
old_state is populated and that container's prior entry is simply absent.
|
||||||
|
"""
|
||||||
|
events: list = []
|
||||||
|
new_by_name = {c["name"]: c for c in new_containers if c.get("name")}
|
||||||
|
|
||||||
|
for name, c in new_by_name.items():
|
||||||
|
new_status = (c.get("status") or "").lower()
|
||||||
|
new_running = new_status == "running"
|
||||||
|
new_oom = bool(c.get("oom_killed"))
|
||||||
|
new_health = c.get("health")
|
||||||
|
new_exit = c.get("exit_code")
|
||||||
|
old = old_state.get(name)
|
||||||
|
|
||||||
|
if old is None:
|
||||||
|
# Newly observed container: a start, or, when it is already down
|
||||||
|
# with a non-zero exit, the death that happened between samples.
|
||||||
|
if new_running:
|
||||||
|
events.append((name, "start", c.get("image") or None))
|
||||||
|
elif new_status in _STOPPED_STATES and new_exit not in (None, 0):
|
||||||
|
events.append((name, "die", f"exit {new_exit}"))
|
||||||
|
continue
|
||||||
|
|
||||||
|
old_running = (old.get("status") or "").lower() == "running"
|
||||||
|
new_restarts = c.get("restart_count") or 0
|
||||||
|
old_restarts = old.get("restart_count") or 0
|
||||||
|
if new_restarts > old_restarts:
|
||||||
|
gained = new_restarts - old_restarts
|
||||||
|
events.append((name, "restart", f"restart #{new_restarts}" if gained == 1
|
||||||
|
else f"{gained} restarts, now #{new_restarts}"))
|
||||||
|
if new_running and not old_running and new_restarts <= old_restarts:
|
||||||
|
events.append((name, "start", c.get("image") or None))
|
||||||
|
elif old_running and not new_running:
|
||||||
|
if new_exit not in (None, 0):
|
||||||
|
events.append((name, "die", f"exit {new_exit}"))
|
||||||
|
else:
|
||||||
|
events.append((name, "stop", None))
|
||||||
|
|
||||||
|
if new_oom and not bool(old.get("oom_killed")):
|
||||||
|
events.append((name, "oom", None))
|
||||||
|
|
||||||
|
if new_health != old.get("health") and (new_health or old.get("health")):
|
||||||
|
events.append((name, "health_change",
|
||||||
|
f"{old.get('health') or '?'}→{new_health or '?'}"))
|
||||||
|
|
||||||
|
# A running container that vanished from the listing entirely (removed).
|
||||||
|
for name, old in old_state.items():
|
||||||
|
if name not in new_by_name and (old.get("status") or "").lower() == "running":
|
||||||
|
events.append((name, "stop", "removed"))
|
||||||
|
|
||||||
|
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:
|
||||||
|
"""Upsert this manager's swarm topology; drop rows no longer reported.
|
||||||
|
|
||||||
|
Current-state tables (not time-series): a manager re-reports its full
|
||||||
|
services/nodes set every sample, so we upsert what's present and delete what
|
||||||
|
isn't (scoped to this host so two managers don't clobber each other).
|
||||||
|
"""
|
||||||
|
from datetime import timezone
|
||||||
|
from .models import DockerSwarmNode, DockerSwarmService
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
services = swarm.get("services") or []
|
||||||
|
seen_services: set[str] = set()
|
||||||
|
for s in services:
|
||||||
|
name = s.get("service_name")
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
seen_services.add(name)
|
||||||
|
row = await session.get(DockerSwarmService, (host.id, name))
|
||||||
|
if row is None:
|
||||||
|
row = DockerSwarmService(host_id=host.id, service_name=name)
|
||||||
|
session.add(row)
|
||||||
|
row.mode = s.get("mode") or "replicated"
|
||||||
|
row.desired = int(s.get("desired") or 0)
|
||||||
|
row.running = int(s.get("running") or 0)
|
||||||
|
row.image = s.get("image")
|
||||||
|
row.placement_json = json.dumps(s.get("placement") or [])
|
||||||
|
row.tasks_json = json.dumps(s.get("tasks") or [])
|
||||||
|
row.updated_at = now
|
||||||
|
stale_services = delete(DockerSwarmService).where(
|
||||||
|
DockerSwarmService.host_id == host.id)
|
||||||
|
if seen_services:
|
||||||
|
stale_services = stale_services.where(
|
||||||
|
DockerSwarmService.service_name.notin_(seen_services))
|
||||||
|
await session.execute(stale_services)
|
||||||
|
|
||||||
|
nodes = swarm.get("nodes") or []
|
||||||
|
seen_nodes: set[str] = set()
|
||||||
|
for n in nodes:
|
||||||
|
nid = n.get("node_id")
|
||||||
|
if not nid:
|
||||||
|
continue
|
||||||
|
seen_nodes.add(nid)
|
||||||
|
row = await session.get(DockerSwarmNode, (host.id, nid))
|
||||||
|
if row is None:
|
||||||
|
row = DockerSwarmNode(host_id=host.id, node_id=nid)
|
||||||
|
session.add(row)
|
||||||
|
row.hostname = n.get("hostname") or ""
|
||||||
|
row.role = n.get("role") or "worker"
|
||||||
|
row.availability = n.get("availability") or "active"
|
||||||
|
row.status = n.get("status") or "unknown"
|
||||||
|
row.leader = bool(n.get("leader", False))
|
||||||
|
row.updated_at = now
|
||||||
|
stale_nodes = delete(DockerSwarmNode).where(DockerSwarmNode.host_id == host.id)
|
||||||
|
if seen_nodes:
|
||||||
|
stale_nodes = stale_nodes.where(DockerSwarmNode.node_id.notin_(seen_nodes))
|
||||||
|
await session.execute(stale_nodes)
|
||||||
|
|
||||||
|
|
||||||
|
async def _persist_disk(session, host, disk: dict) -> None:
|
||||||
|
"""Upsert this host's /system/df summary + replace its image storage rows.
|
||||||
|
|
||||||
|
Current-state (not time-series): the agent re-reports the full summary each
|
||||||
|
interval, so we overwrite the single summary row and replace the image set
|
||||||
|
(delete rows no longer present, upsert the rest), scoped to this host.
|
||||||
|
"""
|
||||||
|
from datetime import timezone
|
||||||
|
from .models import DockerDiskUsage, DockerImage
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
summary = await session.get(DockerDiskUsage, host.id)
|
||||||
|
if summary is None:
|
||||||
|
summary = DockerDiskUsage(host_id=host.id)
|
||||||
|
session.add(summary)
|
||||||
|
summary.layers_size = int(disk.get("layers_size") or 0)
|
||||||
|
summary.images_size = int(disk.get("images_size") or 0)
|
||||||
|
summary.images_reclaimable = int(disk.get("images_reclaimable") or 0)
|
||||||
|
summary.containers_size = int(disk.get("containers_size") or 0)
|
||||||
|
summary.volumes_size = int(disk.get("volumes_size") or 0)
|
||||||
|
summary.build_cache_size = int(disk.get("build_cache_size") or 0)
|
||||||
|
summary.images_total = int(disk.get("images_total") or 0)
|
||||||
|
summary.images_active = int(disk.get("images_active") or 0)
|
||||||
|
summary.containers_count = int(disk.get("containers_count") or 0)
|
||||||
|
summary.volumes_count = int(disk.get("volumes_count") or 0)
|
||||||
|
summary.updated_at = now
|
||||||
|
# The totals as metrics too (#5304): a history of each host's growth, and
|
||||||
|
# something an alert rule can watch. Resource: the host's name.
|
||||||
|
from steward.core.alerts import MetricBatch
|
||||||
|
from .disk_view import disk_metrics
|
||||||
|
batch = MetricBatch()
|
||||||
|
for metric, value in disk_metrics(disk).items():
|
||||||
|
batch.add(source_module="docker", resource_name=host.name, metric_name=metric, value=value)
|
||||||
|
await batch.record(session)
|
||||||
|
|
||||||
|
images = disk.get("images") or []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for im in images:
|
||||||
|
iid = im.get("image_id")
|
||||||
|
if not iid or iid in seen:
|
||||||
|
continue
|
||||||
|
seen.add(iid)
|
||||||
|
row = await session.get(DockerImage, (host.id, iid))
|
||||||
|
if row is None:
|
||||||
|
row = DockerImage(host_id=host.id, image_id=iid)
|
||||||
|
session.add(row)
|
||||||
|
row.repo_tag = (im.get("repo_tag") or "<none>")[:512]
|
||||||
|
row.size = int(im.get("size") or 0)
|
||||||
|
row.shared_size = int(im.get("shared_size") or 0)
|
||||||
|
row.containers = int(im.get("containers") or 0)
|
||||||
|
row.updated_at = now
|
||||||
|
stale = delete(DockerImage).where(DockerImage.host_id == host.id)
|
||||||
|
if seen:
|
||||||
|
stale = stale.where(DockerImage.image_id.notin_(seen))
|
||||||
|
await session.execute(stale)
|
||||||
|
|
||||||
|
|
||||||
|
async def _persist_status(session, host, status: dict) -> None:
|
||||||
|
"""Record whether Docker answered on this host, and why not.
|
||||||
|
|
||||||
|
A host reporting "unavailable" with no row yet never had Docker: nothing to
|
||||||
|
record. Once a host has had Docker, losing it is recorded, so its last
|
||||||
|
containers read as stale rather than as running."""
|
||||||
|
from .models import DockerHostStatus
|
||||||
|
|
||||||
|
row = await session.get(DockerHostStatus, host.id)
|
||||||
|
if row is None:
|
||||||
|
if status["status"] == "unavailable":
|
||||||
|
return
|
||||||
|
row = DockerHostStatus(host_id=host.id)
|
||||||
|
session.add(row)
|
||||||
|
if row.status != status["status"]:
|
||||||
|
row.since = status["at"]
|
||||||
|
row.status = status["status"]
|
||||||
|
row.detail = (status.get("detail") or "")[:255] or None
|
||||||
|
row.reported_at = status["at"]
|
||||||
|
|
||||||
|
|
||||||
|
async def _record_swarm_health(session, swarm: dict) -> None:
|
||||||
|
"""The swarm-wide figures alert rules watch (#5788), at most once every
|
||||||
|
RECORD_EVERY whichever manager reported."""
|
||||||
|
from datetime import timezone
|
||||||
|
from steward.core.alerts import MetricBatch
|
||||||
|
from . import swarm_health
|
||||||
|
if not swarm_health.due(datetime.now(timezone.utc)):
|
||||||
|
return
|
||||||
|
batch = MetricBatch()
|
||||||
|
for metric, value in swarm_health.figures(swarm).items():
|
||||||
|
batch.add(source_module="docker", resource_name=swarm_health.RESOURCE, metric_name=metric, value=value)
|
||||||
|
await batch.record(session)
|
||||||
|
|
||||||
|
|
||||||
|
async def _expire_swarm(session, host) -> None:
|
||||||
|
"""Drop the swarm rows a host reported while it was a manager."""
|
||||||
|
from .models import DockerSwarmNode, DockerSwarmService
|
||||||
|
|
||||||
|
await session.execute(delete(DockerSwarmService).where(DockerSwarmService.host_id == host.id))
|
||||||
|
await session.execute(delete(DockerSwarmNode).where(DockerSwarmNode.host_id == host.id))
|
||||||
|
|
||||||
|
|
||||||
|
async def persist_host_docker(session, host, snapshots, swarm=None, disk=None,
|
||||||
|
logs=None, status=None, phases=None) -> None:
|
||||||
|
"""Upsert containers + time-series + lifecycle events + swarm for one host.
|
||||||
|
|
||||||
|
`snapshots` is a list of (recorded_at: datetime, containers: list[dict]) —
|
||||||
|
one entry per ingested sample carrying docker data (usually one; more when
|
||||||
|
the agent flushes a backlog). Every snapshot's running containers record
|
||||||
|
cpu_pct, mem_pct and mem_usage_bytes into the one metric store, at the
|
||||||
|
snapshot's time; the newest snapshot drives current container state and
|
||||||
|
lifecycle-event derivation. `swarm` is the newest
|
||||||
|
sample's swarm object (or None off managers) — persisted when present.
|
||||||
|
`disk` is the newest sample's /system/df summary (or None on Docker-less
|
||||||
|
hosts) — persisted when present. `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. `status` is the newest
|
||||||
|
sample's {"status", "detail", "at", "swarm_manager"} (agent ≥ 1.9.0): an
|
||||||
|
"ok" with an empty snapshot clears the host's containers, and a node that
|
||||||
|
says it is no longer a manager loses the swarm rows it reported as one.
|
||||||
|
|
||||||
|
`phases`, when given, is marked after each part (docker.status, .swarm,
|
||||||
|
.disk, .logs, .metrics, .events, .containers, .alerts, .reap), each
|
||||||
|
flushed before its mark, so a slow ingest names the part (#5181).
|
||||||
|
"""
|
||||||
|
from steward.core.alerts import MetricBatch
|
||||||
|
from .models import DockerContainer, DockerEvent
|
||||||
|
|
||||||
|
async def mark(name: str) -> None:
|
||||||
|
if phases is not None:
|
||||||
|
await session.flush()
|
||||||
|
phases.mark(f"docker.{name}")
|
||||||
|
|
||||||
|
if status is not None:
|
||||||
|
await _persist_status(session, host, status)
|
||||||
|
if status.get("swarm_manager") is False:
|
||||||
|
await _expire_swarm(session, host)
|
||||||
|
await mark("status")
|
||||||
|
if swarm is not None:
|
||||||
|
await _persist_swarm(session, host, swarm)
|
||||||
|
await _record_swarm_health(session, swarm)
|
||||||
|
await mark("swarm")
|
||||||
|
if disk is not None:
|
||||||
|
await _persist_disk(session, host, disk)
|
||||||
|
await mark("disk")
|
||||||
|
if logs:
|
||||||
|
await _persist_logs(session, host, logs)
|
||||||
|
await mark("logs")
|
||||||
|
|
||||||
|
if not snapshots:
|
||||||
|
return
|
||||||
|
ordered = sorted(snapshots, key=lambda s: s[0])
|
||||||
|
latest_at, latest_containers = ordered[-1]
|
||||||
|
|
||||||
|
# Time series for every snapshot (running containers with a CPU read), in
|
||||||
|
# the one metric store (#5752): resource "<host>/<container>", so
|
||||||
|
# containers of the same name on different hosts don't collide. Recorded
|
||||||
|
# with the alert batch below, at each snapshot's own time.
|
||||||
|
batch = MetricBatch()
|
||||||
|
for recorded_at, containers in ordered:
|
||||||
|
for c in containers:
|
||||||
|
if c.get("status") != "running" or c.get("cpu_pct") is None or not c.get("name"):
|
||||||
|
continue
|
||||||
|
resource = f"{host.name}/{c['name']}"
|
||||||
|
for metric in ("cpu_pct", "mem_pct", "mem_usage_bytes"):
|
||||||
|
if c.get(metric) is not None:
|
||||||
|
batch.add(source_module="docker", resource_name=resource,
|
||||||
|
metric_name=metric, value=c[metric], recorded_at=recorded_at)
|
||||||
|
await mark("metrics")
|
||||||
|
|
||||||
|
# Snapshot of stored per-container state BEFORE the upsert overwrites it —
|
||||||
|
# used to diff lifecycle events. Skip event derivation on the host's first
|
||||||
|
# snapshot (empty state) so we don't emit a start per pre-existing container.
|
||||||
|
old_rows = (await session.execute(
|
||||||
|
select(DockerContainer).where(DockerContainer.host_id == host.id)
|
||||||
|
)).scalars().all()
|
||||||
|
old_state = {
|
||||||
|
r.name: {"status": r.status, "health": r.health,
|
||||||
|
"oom_killed": r.oom_killed, "exit_code": r.exit_code,
|
||||||
|
"restart_count": r.restart_count}
|
||||||
|
for r in old_rows
|
||||||
|
}
|
||||||
|
if old_state:
|
||||||
|
# A stop is dated when Docker says it happened (agent ≥ 1.11), not when
|
||||||
|
# the sample that noticed it was taken.
|
||||||
|
finished = {c.get("name"): _parse_started_at(c.get("finished_at"))
|
||||||
|
for c in latest_containers}
|
||||||
|
for name, event, detail in _derive_events(old_state, latest_containers):
|
||||||
|
at = finished.get(name) if event in ("die", "stop") else None
|
||||||
|
session.add(DockerEvent(
|
||||||
|
host_id=host.id, container_name=name,
|
||||||
|
event=event, detail=detail, at=at or latest_at,
|
||||||
|
))
|
||||||
|
await mark("events")
|
||||||
|
|
||||||
|
# Current state, and the restart and health alerts, from the newest snapshot only.
|
||||||
|
for c in latest_containers:
|
||||||
|
name = c.get("name")
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
existing = await session.get(DockerContainer, (host.id, name))
|
||||||
|
if existing is None:
|
||||||
|
existing = DockerContainer(host_id=host.id, name=name)
|
||||||
|
session.add(existing)
|
||||||
|
existing.container_id = c.get("container_id", "") or ""
|
||||||
|
existing.image = c.get("image", "") or ""
|
||||||
|
existing.status = c.get("status", "unknown") or "unknown"
|
||||||
|
existing.cpu_pct = c.get("cpu_pct")
|
||||||
|
existing.mem_usage_bytes = c.get("mem_usage_bytes")
|
||||||
|
existing.mem_limit_bytes = c.get("mem_limit_bytes")
|
||||||
|
existing.mem_pct = c.get("mem_pct")
|
||||||
|
existing.ports_json = json.dumps(c.get("ports") or [])
|
||||||
|
existing.started_at = _parse_started_at(c.get("started_at"))
|
||||||
|
existing.scraped_at = latest_at
|
||||||
|
# Enrichment (agent ≥ 1.4.0; .get keeps older agents working — fields
|
||||||
|
# stay None/0 when absent from the payload).
|
||||||
|
existing.restart_count = c.get("restart_count", 0) or 0
|
||||||
|
existing.health = c.get("health")
|
||||||
|
existing.exit_code = c.get("exit_code")
|
||||||
|
existing.oom_killed = bool(c.get("oom_killed", False))
|
||||||
|
existing.compose_project = c.get("compose_project")
|
||||||
|
existing.service_name = c.get("service_name")
|
||||||
|
existing.task_id = c.get("task_id")
|
||||||
|
existing.node_id = c.get("node_id")
|
||||||
|
existing.net_rx_bytes = c.get("net_rx_bytes")
|
||||||
|
existing.net_tx_bytes = c.get("net_tx_bytes")
|
||||||
|
existing.blk_read_bytes = c.get("blk_read_bytes")
|
||||||
|
existing.blk_write_bytes = c.get("blk_write_bytes")
|
||||||
|
|
||||||
|
resource = f"{host.name}/{name}"
|
||||||
|
# Restart count is alertable regardless of state (crash-looping matters
|
||||||
|
# most while the container is down/restarting).
|
||||||
|
batch.add(
|
||||||
|
source_module="docker",
|
||||||
|
resource_name=resource, metric_name="restart_count",
|
||||||
|
value=float(c.get("restart_count", 0) or 0),
|
||||||
|
)
|
||||||
|
# Health → 1.0/0.0 only for containers that actually define a HEALTHCHECK
|
||||||
|
# (health is None otherwise — recording 0 would false-alarm every plain
|
||||||
|
# container).
|
||||||
|
health = c.get("health")
|
||||||
|
if health in ("healthy", "unhealthy", "starting"):
|
||||||
|
batch.add(
|
||||||
|
source_module="docker",
|
||||||
|
resource_name=resource, metric_name="is_healthy",
|
||||||
|
value=1.0 if health == "healthy" else 0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
await mark("containers")
|
||||||
|
await batch.record(session)
|
||||||
|
await mark("alerts")
|
||||||
|
|
||||||
|
# Reap containers that vanished from the host's latest listing. Without this,
|
||||||
|
# removed one-shot containers (CI job runners, buildkit builders, codex jobs)
|
||||||
|
# accumulate forever as permanent "stopped" rows and inflate every count.
|
||||||
|
# The listing is authoritative (event derivation already treats absence as
|
||||||
|
# removal), so anything not in the newest snapshot no longer exists on the
|
||||||
|
# host. Mirrors the image/swarm/disk persisters. An empty snapshot legitimately
|
||||||
|
# means the host has no containers, so the unfiltered delete is correct.
|
||||||
|
seen_names = {c["name"] for c in latest_containers if c.get("name")}
|
||||||
|
reap = delete(DockerContainer).where(DockerContainer.host_id == host.id)
|
||||||
|
if seen_names:
|
||||||
|
reap = reap.where(DockerContainer.name.notin_(seen_names))
|
||||||
|
await session.execute(reap)
|
||||||
|
await mark("reap")
|
||||||
@@ -0,0 +1,703 @@
|
|||||||
|
"""The docker plugin's MCP tools and its get_overview findings.
|
||||||
|
|
||||||
|
Registered through register_mcp_tools() in __init__.py, so they exist only
|
||||||
|
while the plugin is enabled. Reads go through service.py, the same functions
|
||||||
|
the plugin's pages use.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from steward.ansible.runner import trigger_run
|
||||||
|
from steward.ansible.sources import BUILTIN_SOURCE_NAME
|
||||||
|
from steward.mcp._context import current_caller, mcp_app
|
||||||
|
from steward.mcp.tools._audit import audit_mcp
|
||||||
|
from steward.mcp.tools._shape import (
|
||||||
|
age_s, bounded, clamp_limit, db_session, iso, lookback, now_utc, pick, rnd, summarize,
|
||||||
|
)
|
||||||
|
from steward.mcp.tools.overview import finding
|
||||||
|
from steward.models.ansible_inventory import AnsibleTarget
|
||||||
|
from steward.models.hosts import Host
|
||||||
|
from steward.models.users import UserRole
|
||||||
|
|
||||||
|
from . import service as docker_svc
|
||||||
|
from .service import PROBLEM_SAYS, PROBLEM_SEVERITY, PROBLEMS, container_problems, problem_since
|
||||||
|
from .dedup import dedup_by_container_id
|
||||||
|
from .models import DockerContainer, DockerDiskUsage, DockerSwarmNode, DockerSwarmService
|
||||||
|
from .swarm_view import build_swarm_services, replaced_tasks, task_time
|
||||||
|
|
||||||
|
CRASH_LOOP_DEATHS = docker_svc.CRASH_LOOP_DEATHS
|
||||||
|
LOG_LINE_CHARS = 2000
|
||||||
|
|
||||||
|
|
||||||
|
# ── pure helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def cut_middle(text: str, limit: int = LOG_LINE_CHARS) -> str:
|
||||||
|
"""Keep both ends of an over-long line and say how much of the middle went."""
|
||||||
|
if text is None or len(text) <= limit:
|
||||||
|
return text
|
||||||
|
keep = limit // 2
|
||||||
|
return f"{text[:keep]} [… {len(text) - 2 * keep} characters omitted …] {text[-keep:]}"
|
||||||
|
|
||||||
|
|
||||||
|
def container_row(c, host_names: dict, problems: list[str], now: datetime,
|
||||||
|
replaced: bool = False, since: datetime | None = None) -> dict:
|
||||||
|
running = (c.status or "").lower() == "running"
|
||||||
|
return {
|
||||||
|
"container_name": c.name,
|
||||||
|
"host_id": c.host_id,
|
||||||
|
"host_name": host_names.get(c.host_id),
|
||||||
|
"image": c.image,
|
||||||
|
"status": c.status,
|
||||||
|
"health": c.health,
|
||||||
|
"problems": problems,
|
||||||
|
# When the worst problem began, from the container's retained events.
|
||||||
|
"problem_since": iso(since),
|
||||||
|
"problem_age_s": age_s(since, now),
|
||||||
|
"exit_code": c.exit_code,
|
||||||
|
"oom_killed": bool(c.oom_killed),
|
||||||
|
"restart_count": c.restart_count,
|
||||||
|
"cpu_pct": rnd(c.cpu_pct),
|
||||||
|
"mem_pct": rnd(c.mem_pct),
|
||||||
|
"mem_usage_bytes": c.mem_usage_bytes,
|
||||||
|
"mem_limit_bytes": c.mem_limit_bytes,
|
||||||
|
"started_at": iso(c.started_at),
|
||||||
|
"uptime_s": age_s(c.started_at, now) if running and c.started_at else None,
|
||||||
|
"compose_project": c.compose_project,
|
||||||
|
"swarm_service": c.service_name,
|
||||||
|
# A swarm task Swarm has replaced: history, judged on its service.
|
||||||
|
"swarm_replaced": replaced if c.service_name else None,
|
||||||
|
"reported_at": iso(c.scraped_at),
|
||||||
|
"reported_age_s": age_s(c.scraped_at, now),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _from_iso(text: str | None) -> datetime | None:
|
||||||
|
return datetime.fromisoformat(text.replace("Z", "+00:00")) if text else None
|
||||||
|
|
||||||
|
|
||||||
|
def container_findings(rows: list[dict]) -> list[dict]:
|
||||||
|
out = []
|
||||||
|
for r in rows:
|
||||||
|
if not r["problems"]:
|
||||||
|
continue
|
||||||
|
worst = r["problems"][0]
|
||||||
|
where = f" on {r['host_name']}" if r["host_name"] else ""
|
||||||
|
detail = f" (exit {r['exit_code']})" if r["exit_code"] not in (None, 0) else ""
|
||||||
|
out.append(finding(
|
||||||
|
PROBLEM_SEVERITY[worst], f"container_{worst}", r["container_name"],
|
||||||
|
f"container {r['container_name']}{where} {' and '.join(PROBLEM_SAYS[p] for p in r['problems'])}{detail}",
|
||||||
|
since=_from_iso(r["problem_since"]),
|
||||||
|
ids={"host_id": r["host_id"], "container_name": r["container_name"]},
|
||||||
|
next=f'get_container(container="{r["container_name"]}", host="{r["host_id"]}")'))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def task_error_text(err: dict | None) -> str:
|
||||||
|
"""Swarm's own account of a task's end, as one clause."""
|
||||||
|
if not err:
|
||||||
|
return ""
|
||||||
|
why = err.get("error") or err.get("message") or err.get("state") or "unknown"
|
||||||
|
code = f", exit {err['exit_code']}" if err.get("exit_code") not in (None, 0) else ""
|
||||||
|
where = f" on {err['node']}" if err.get("node") else ""
|
||||||
|
return f"{why}{code}{where}"
|
||||||
|
|
||||||
|
|
||||||
|
# Services that started failing within this long of each other read as one
|
||||||
|
# finding: they are usually one node's event (#5722).
|
||||||
|
TOGETHER = timedelta(minutes=5)
|
||||||
|
|
||||||
|
|
||||||
|
def service_findings(services: list[dict]) -> list[dict]:
|
||||||
|
"""Swarm services failing now: crash-looping, or short of replicas, each
|
||||||
|
naming Swarm's last task error. One back to all its replicas is history,
|
||||||
|
kept on the Docker page, not a finding (#5722). Several short of replicas
|
||||||
|
since within TOGETHER of each other are one finding naming them all."""
|
||||||
|
out, short = [], []
|
||||||
|
for s in services:
|
||||||
|
replicas = f"{s['running']} of {s['desired']} replicas running"
|
||||||
|
last = task_error_text(s.get("last_error"))
|
||||||
|
tail = f"; last task error: {last}" if last else ""
|
||||||
|
ids = {"service_name": s["name"]}
|
||||||
|
since = s.get("trouble_since")
|
||||||
|
if s.get("crash_looping"):
|
||||||
|
out.append(finding(
|
||||||
|
"critical", "swarm_service_crash_looping", s["name"],
|
||||||
|
f"swarm service {s['name']} keeps failing: {s['failures_1h']} tasks failed in the "
|
||||||
|
f"last hour, {replicas}{tail}", since=since, ids=ids,
|
||||||
|
next="list_swarm_services()"))
|
||||||
|
elif s["state"] != "healthy":
|
||||||
|
short.append((s, replicas, tail))
|
||||||
|
for group in _together(short):
|
||||||
|
if len(group) == 1:
|
||||||
|
s, replicas, tail = group[0]
|
||||||
|
out.append(finding(
|
||||||
|
"critical" if s["state"] == "down" else "warning", f"swarm_service_{s['state']}",
|
||||||
|
s["name"], f"swarm service {s['name']} has {replicas}{tail}",
|
||||||
|
since=s.get("trouble_since"), ids={"service_name": s["name"]},
|
||||||
|
next="list_swarm_services()"))
|
||||||
|
continue
|
||||||
|
names = [s["name"] for s, _r, _t in group]
|
||||||
|
down = any(s["state"] == "down" for s, _r, _t in group)
|
||||||
|
out.append(finding(
|
||||||
|
"critical" if down else "warning", "swarm_services_short", f"{len(group)} swarm services",
|
||||||
|
f"{len(group)} swarm services started failing together and are short of replicas: "
|
||||||
|
+ ", ".join(f"{s['name']} ({r})" for s, r, _t in group),
|
||||||
|
since=min(s["trouble_since"] for s, _r, _t in group),
|
||||||
|
ids={"service_names": names}, next="list_swarm_services()"))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _together(short: list[tuple]) -> list[list[tuple]]:
|
||||||
|
"""Short services grouped by when they started failing: each group's
|
||||||
|
starts lie within TOGETHER of the one before. One without a start stands alone."""
|
||||||
|
timed = sorted((x for x in short if x[0].get("trouble_since")), key=lambda x: x[0]["trouble_since"])
|
||||||
|
groups: list[list[tuple]] = []
|
||||||
|
for x in timed:
|
||||||
|
if groups and x[0]["trouble_since"] - groups[-1][-1][0]["trouble_since"] <= TOGETHER:
|
||||||
|
groups[-1].append(x)
|
||||||
|
else:
|
||||||
|
groups.append([x])
|
||||||
|
return groups + [[x] for x in short if not x[0].get("trouble_since")]
|
||||||
|
|
||||||
|
|
||||||
|
# ── loading ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _host_names(db) -> dict[str, str]:
|
||||||
|
return dict((await db.execute(select(Host.id, Host.name))).all())
|
||||||
|
|
||||||
|
|
||||||
|
def _crash_loop_deaths() -> int:
|
||||||
|
return docker_svc.crash_loop_deaths(mcp_app().config)
|
||||||
|
|
||||||
|
|
||||||
|
async def _replaced(db, containers) -> set[tuple]:
|
||||||
|
services = list((await db.execute(select(DockerSwarmService))).scalars())
|
||||||
|
return replaced_tasks(containers, services)
|
||||||
|
|
||||||
|
|
||||||
|
async def _rows(db, now: datetime) -> list[dict]:
|
||||||
|
"""Every container with its problems. A swarm task Swarm replaced has none:
|
||||||
|
its failure, if it was one, belongs to its service (service_findings)."""
|
||||||
|
host_names = await _host_names(db)
|
||||||
|
return [container_row(r["container"], host_names, r["problems"], now,
|
||||||
|
replaced=r["replaced"], since=r["since"])
|
||||||
|
for r in await docker_svc.ranked_containers(db, now, _crash_loop_deaths())]
|
||||||
|
|
||||||
|
|
||||||
|
async def _swarm(db, now: datetime):
|
||||||
|
containers = dedup_by_container_id(await docker_svc.list_containers(db))
|
||||||
|
services = list((await db.execute(select(DockerSwarmService))).scalars())
|
||||||
|
nodes = list((await db.execute(select(DockerSwarmNode))).scalars())
|
||||||
|
host_names = await _host_names(db)
|
||||||
|
node_hostname = {n.node_id: n.hostname for n in nodes if n.hostname}
|
||||||
|
built = build_swarm_services(containers, services, node_hostname, host_names,
|
||||||
|
now=now, crash_loop_deaths=_crash_loop_deaths())
|
||||||
|
return built["services"], nodes, host_names
|
||||||
|
|
||||||
|
|
||||||
|
async def mcp_overview(db) -> list[dict]:
|
||||||
|
"""get_overview findings: failing containers and under-replicated swarm services."""
|
||||||
|
now = now_utc()
|
||||||
|
rows = await _rows(db, now)
|
||||||
|
services, _, _ = await _swarm(db, now)
|
||||||
|
return container_findings(rows) + service_findings(services)
|
||||||
|
|
||||||
|
|
||||||
|
# ── tools ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def list_containers(host: str = "", status: str = "", problem: str = "",
|
||||||
|
compose_project: str = "", limit: int = 100) -> dict:
|
||||||
|
"""Docker containers across every host that runs a Steward agent with Docker.
|
||||||
|
|
||||||
|
host: a host's name or id (list_hosts); empty for all hosts.
|
||||||
|
status: Docker's state — "running", "exited", "restarting", "paused",
|
||||||
|
"created" or "dead"; empty for all.
|
||||||
|
problem: "any" for containers with something wrong, or one of
|
||||||
|
"restarting", "crash_looping" (died repeatedly within the last hour;
|
||||||
|
the count is set under Settings → Thresholds, default 3), "unhealthy"
|
||||||
|
(failing its healthcheck), "oom_killed", "exited_nonzero" (died with an
|
||||||
|
error in the last 24h; exit 137/143 without an OOM kill is a stop, not
|
||||||
|
an error); empty for no filter.
|
||||||
|
compose_project: only containers from this Compose project.
|
||||||
|
limit: default 100, max 500.
|
||||||
|
|
||||||
|
Containers with problems come first. Each row's `problems` lists what is
|
||||||
|
wrong, most severe first, and `problem_since` / `problem_age_s` say when
|
||||||
|
the worst of them began (null when Steward's retained events cannot say).
|
||||||
|
`reported_age_s` is how old the agent's last report is, so a large value
|
||||||
|
means the row may be stale.
|
||||||
|
|
||||||
|
A swarm task's container that Swarm has replaced (a rolling update, or a
|
||||||
|
retry after a failure) has `swarm_replaced: true` and no problems: Swarm
|
||||||
|
restarts a task as a new container, so failures are counted on the
|
||||||
|
service — list_swarm_services shows them.
|
||||||
|
"""
|
||||||
|
if problem and problem != "any" and problem not in PROBLEMS:
|
||||||
|
raise ValueError(f"problem {problem!r} is not 'any' or one of {', '.join(PROBLEMS)}.")
|
||||||
|
limit = clamp_limit(limit, 100, 500)
|
||||||
|
now = now_utc()
|
||||||
|
async with db_session() as db:
|
||||||
|
rows = await _rows(db, now)
|
||||||
|
if host:
|
||||||
|
hosts = list((await db.execute(select(Host))).scalars())
|
||||||
|
h = pick(host, hosts, kind="host", list_tool="list_hosts")
|
||||||
|
rows = [r for r in rows if r["host_id"] == h.id]
|
||||||
|
if status:
|
||||||
|
rows = [r for r in rows if (r["status"] or "").lower() == status.lower()]
|
||||||
|
if problem == "any":
|
||||||
|
rows = [r for r in rows if r["problems"]]
|
||||||
|
elif problem:
|
||||||
|
rows = [r for r in rows if problem in r["problems"]]
|
||||||
|
if compose_project:
|
||||||
|
rows = [r for r in rows if r["compose_project"] == compose_project]
|
||||||
|
rows.sort(key=lambda r: (not r["problems"], (r["status"] or "") != "running",
|
||||||
|
(r["host_name"] or "").lower(), r["container_name"]))
|
||||||
|
return bounded(rows, limit, key="containers",
|
||||||
|
more="Pass a larger `limit` (max 500), or filter by `host`, `status` or `problem`.")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RemovedContainer:
|
||||||
|
"""A container Docker has removed, known only by its retained logs and events."""
|
||||||
|
host_id: str
|
||||||
|
name: str
|
||||||
|
last_seen: datetime
|
||||||
|
|
||||||
|
|
||||||
|
_REMOVED_HINT = ("It may have been removed: Swarm replaces a dead task with a new container under "
|
||||||
|
"a new name. list_containers(status=\"exited\") lists stopped containers, and "
|
||||||
|
"search_container_logs(host=...) searches a whole host.")
|
||||||
|
|
||||||
|
|
||||||
|
async def _pick_container(db, container: str, host: str, *, removed: bool = False):
|
||||||
|
"""The container named `container`: a live one, else (with `removed`) one
|
||||||
|
Docker has removed whose logs or events Steward still holds, returned as a
|
||||||
|
RemovedContainer. Exact names beat partial ones, and live beats removed."""
|
||||||
|
containers = await docker_svc.list_containers(db)
|
||||||
|
host_id = None
|
||||||
|
if host:
|
||||||
|
hosts = list((await db.execute(select(Host))).scalars())
|
||||||
|
host_id = pick(host, hosts, kind="host", list_tool="list_hosts").id
|
||||||
|
containers = [c for c in containers if c.host_id == host_id]
|
||||||
|
want = (container or "").strip()
|
||||||
|
if not want:
|
||||||
|
raise ValueError("pass a container name; list_containers lists them.")
|
||||||
|
live = {(c.host_id, c.name) for c in containers}
|
||||||
|
|
||||||
|
def _one(found, what):
|
||||||
|
if len(found) > 1:
|
||||||
|
raise ValueError(f"{len(found)} hosts have {what} named {want!r}; pass `host` "
|
||||||
|
f"(one of: {', '.join(sorted(f[0] for f in found))}).")
|
||||||
|
return found[0] if found else None
|
||||||
|
|
||||||
|
same = [c for c in containers if c.name == want] or \
|
||||||
|
[c for c in containers if c.name.lower() == want.lower()]
|
||||||
|
if same:
|
||||||
|
return _one([(c.host_id, c) for c in same], "a container")[1]
|
||||||
|
if removed:
|
||||||
|
gone = [r for r in await docker_svc.retained_names(db, want, exact=True, host_id=host_id)
|
||||||
|
if (r[0], r[1]) not in live]
|
||||||
|
if gone:
|
||||||
|
return RemovedContainer(*_one(gone, "a removed container"))
|
||||||
|
# One more chance: a unique container whose name contains it (swarm task
|
||||||
|
# names look like "stack_svc.1.k2j…").
|
||||||
|
partial = [c for c in containers if want.lower() in c.name.lower()]
|
||||||
|
if len(partial) == 1:
|
||||||
|
return partial[0]
|
||||||
|
gone = []
|
||||||
|
if removed and not partial:
|
||||||
|
gone = [r for r in await docker_svc.retained_names(db, want, exact=False, host_id=host_id)
|
||||||
|
if (r[0], r[1]) not in live]
|
||||||
|
if len(gone) == 1:
|
||||||
|
return RemovedContainer(*gone[0])
|
||||||
|
names = sorted(c.name for c in partial)[:10] + [f"{r[1]} (removed)" for r in gone[:10]]
|
||||||
|
hint = f" Containers whose names contain it: {', '.join(names)}." if names else ""
|
||||||
|
services = sorted({c.service_name for c in partial if c.service_name})
|
||||||
|
if services:
|
||||||
|
hint += (f" Swarm services among them: {', '.join(services[:10])}; search_container_logs "
|
||||||
|
"takes a service's name and searches every task it has run.")
|
||||||
|
raise ValueError(f"no container named {want!r}{' on that host' if host else ''}.{hint} "
|
||||||
|
f"{'' if names else _REMOVED_HINT + ' '}list_containers lists them.")
|
||||||
|
|
||||||
|
|
||||||
|
def _removed_note(c: RemovedContainer) -> str:
|
||||||
|
return (f"{c.name} no longer exists: Docker removed it (Swarm replaces a failed task with a new "
|
||||||
|
f"container). This is what Steward retained; it was last seen {iso(c.last_seen)}.")
|
||||||
|
|
||||||
|
|
||||||
|
async def get_container(container: str, host: str = "", range: str = "24h", events: int = 20) -> dict:
|
||||||
|
"""One container in depth: state, resources over time and lifecycle events.
|
||||||
|
|
||||||
|
container: the container's name, as list_containers shows. A unique part of
|
||||||
|
a name also works (swarm task names are long).
|
||||||
|
host: the host's name or id. Needed only when several hosts run a
|
||||||
|
container with this name.
|
||||||
|
range: the window for `cpu_pct_history` and `mem_pct_history` (min, avg,
|
||||||
|
max, p95, last and a ≤60-point series) — "1h", "6h", "24h" (default),
|
||||||
|
"7d" or "30d". `cpu_pct` and `mem_pct` are the latest reported values.
|
||||||
|
events: how many lifecycle events (start, restart, stop, die, oom, health_change)
|
||||||
|
to return, newest first (default 20, max 100).
|
||||||
|
|
||||||
|
A container Docker has since removed (a dead Swarm task, a `docker rm`)
|
||||||
|
still resolves by its exact name, or by a part of it that matches one
|
||||||
|
removed container, while Steward retains its logs or events: the reply
|
||||||
|
has `removed: true`, `last_seen_at`, its history and its events, and no
|
||||||
|
live state. A live container has `removed: false`.
|
||||||
|
"""
|
||||||
|
since, key = lookback(range)
|
||||||
|
events = clamp_limit(events, 20, 100)
|
||||||
|
now = now_utc()
|
||||||
|
async with db_session() as db:
|
||||||
|
c = await _pick_container(db, container, host, removed=True)
|
||||||
|
if isinstance(c, RemovedContainer):
|
||||||
|
return await _removed_container(db, c, since, key, events, now)
|
||||||
|
gone = (c.host_id, c.name) in await _replaced(db, await docker_svc.list_containers(db))
|
||||||
|
failures = (await docker_svc.recent_failures_by_container(
|
||||||
|
db, now - timedelta(hours=24))).get((c.host_id, c.name), [])
|
||||||
|
host_names = await _host_names(db)
|
||||||
|
cpu = await docker_svc.container_points(db, c.host_id, c.name, "cpu_pct", since)
|
||||||
|
mem = await docker_svc.container_points(db, c.host_id, c.name, "mem_pct", since)
|
||||||
|
evs = await docker_svc.container_events(db, c.host_id, c.name, events)
|
||||||
|
sick = await docker_svc.unhealthy_since(db, [(c.host_id, c.name)])
|
||||||
|
hour_ago = now - timedelta(hours=1)
|
||||||
|
recent = sum(1 for e in failures if e.at >= hour_ago)
|
||||||
|
try:
|
||||||
|
ports = json.loads(c.ports_json or "[]")
|
||||||
|
except ValueError:
|
||||||
|
ports = []
|
||||||
|
problems = [] if gone else container_problems(c, recent, bool(failures), _crash_loop_deaths())
|
||||||
|
began = problem_since(problems, failures, hour_ago, sick.get((c.host_id, c.name)))
|
||||||
|
return {
|
||||||
|
**container_row(c, host_names, problems, now, replaced=gone, since=began),
|
||||||
|
"removed": False,
|
||||||
|
"container_id": c.container_id,
|
||||||
|
"ports": ports,
|
||||||
|
"net_rx_bytes": c.net_rx_bytes, "net_tx_bytes": c.net_tx_bytes,
|
||||||
|
"blk_read_bytes": c.blk_read_bytes, "blk_write_bytes": c.blk_write_bytes,
|
||||||
|
"range": key,
|
||||||
|
"cpu_pct_history": summarize(cpu),
|
||||||
|
"mem_pct_history": summarize(mem),
|
||||||
|
"events": _event_rows(evs, now),
|
||||||
|
"next": f'search_container_logs(container="{c.name}", host="{c.host_id}")',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _event_rows(evs, now: datetime) -> list[dict]:
|
||||||
|
return [{"event": e.event, "detail": e.detail, "at": iso(e.at), "age_s": age_s(e.at, now)}
|
||||||
|
for e in evs]
|
||||||
|
|
||||||
|
|
||||||
|
async def _removed_container(db, c: RemovedContainer, since, key: str, events: int,
|
||||||
|
now: datetime) -> dict:
|
||||||
|
"""get_container for a container Docker has removed: what Steward kept."""
|
||||||
|
host_names = await _host_names(db)
|
||||||
|
cpu = await docker_svc.container_points(db, c.host_id, c.name, "cpu_pct", since)
|
||||||
|
mem = await docker_svc.container_points(db, c.host_id, c.name, "mem_pct", since)
|
||||||
|
evs = await docker_svc.container_events(db, c.host_id, c.name, events)
|
||||||
|
return {
|
||||||
|
"container_name": c.name, "host_id": c.host_id, "host_name": host_names.get(c.host_id),
|
||||||
|
"removed": True, "note": _removed_note(c),
|
||||||
|
"last_seen_at": iso(c.last_seen), "last_seen_age_s": age_s(c.last_seen, now),
|
||||||
|
"range": key,
|
||||||
|
"cpu_pct_history": summarize(cpu),
|
||||||
|
"mem_pct_history": summarize(mem),
|
||||||
|
"events": _event_rows(evs, now),
|
||||||
|
"next": f'search_container_logs(container="{c.name}", host="{c.host_id}", range="24h")',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def list_swarm_services(limit: int = 100) -> dict:
|
||||||
|
"""Docker Swarm services with their replicas per node, and the swarm's nodes.
|
||||||
|
|
||||||
|
`state` is "healthy" (all desired replicas running), "degraded" (some) or
|
||||||
|
"down" (none). `replicas` are the service's current tasks: each names its
|
||||||
|
host and container status, and one marked `ghost` runs on a node with no
|
||||||
|
Steward agent, so Steward knows only how many tasks are placed there.
|
||||||
|
Tasks Swarm replaced (rolling updates, retries) are left out and counted
|
||||||
|
in `previous_tasks`.
|
||||||
|
|
||||||
|
From Swarm's own task records (a manager's agent 1.8.0 or later;
|
||||||
|
`tasks_known: false` and nulls otherwise):
|
||||||
|
- `failures_1h` / `failures_24h`: tasks that failed or were rejected.
|
||||||
|
Swarm retries a failed task as a new one, so this, not any container's
|
||||||
|
restart count, is how a crash loop shows. Swarm keeps about 5 old tasks
|
||||||
|
per replica, which bounds the count.
|
||||||
|
- `crash_looping`: failures_1h reached the crash-loop count set under
|
||||||
|
Settings → Thresholds (default 3).
|
||||||
|
- `last_error`: the newest task that failed, was rejected, or carries an
|
||||||
|
error while Swarm retries it — `error` is Swarm's own text (e.g. "No
|
||||||
|
such image", "task: non-zero exit (1)"), with `exit_code`, `node`, `at`.
|
||||||
|
- `trouble_since` / `trouble_age_s`: when the current trouble began — a
|
||||||
|
crash loop's first failure in the hour, a short service's first failed
|
||||||
|
task in its failing slots, a recovered service's first failure in 24h.
|
||||||
|
Swarm keeps few old tasks, so a long outage reads as a lower bound.
|
||||||
|
|
||||||
|
Services with a problem come first. `nodes` lists every swarm node once,
|
||||||
|
with its role (manager/worker), availability and status.
|
||||||
|
limit: default 100, max 500.
|
||||||
|
"""
|
||||||
|
limit = clamp_limit(limit, 100, 500)
|
||||||
|
now = now_utc()
|
||||||
|
async with db_session() as db:
|
||||||
|
services, nodes, host_names = await _swarm(db, now)
|
||||||
|
seen, node_rows = set(), []
|
||||||
|
for n in sorted(nodes, key=lambda n: n.updated_at, reverse=True):
|
||||||
|
if n.node_id in seen:
|
||||||
|
continue
|
||||||
|
seen.add(n.node_id)
|
||||||
|
node_rows.append({"node_id": n.node_id, "hostname": n.hostname, "role": n.role,
|
||||||
|
"leader": n.leader, "availability": n.availability, "status": n.status,
|
||||||
|
"reported_by": host_names.get(n.host_id)})
|
||||||
|
node_rows.sort(key=lambda n: n["hostname"].lower())
|
||||||
|
order = {"down": 0, "degraded": 1, "healthy": 2}
|
||||||
|
|
||||||
|
def _rank(s):
|
||||||
|
return (not s["crash_looping"], order.get(s["state"], 3), not s["failures_24h"], s["name"])
|
||||||
|
|
||||||
|
def _error_row(err):
|
||||||
|
if err is None:
|
||||||
|
return None
|
||||||
|
at = task_time(err)
|
||||||
|
return {"error": err["error"], "message": err["message"], "state": err["state"],
|
||||||
|
"exit_code": err["exit_code"], "node": err["node"],
|
||||||
|
"at": iso(at), "age_s": age_s(at, now) if at else None}
|
||||||
|
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"service_name": s["name"], "mode": s["mode"], "state": s["state"],
|
||||||
|
"running": s["running"], "desired": s["desired"],
|
||||||
|
"crash_looping": s["crash_looping"],
|
||||||
|
"failures_1h": s["failures_1h"], "failures_24h": s["failures_24h"],
|
||||||
|
"last_error": _error_row(s["last_error"]),
|
||||||
|
"trouble_since": iso(s["trouble_since"]),
|
||||||
|
"trouble_age_s": age_s(s["trouble_since"], now),
|
||||||
|
"previous_tasks": s["previous_tasks"], "tasks_known": s["tasks_known"],
|
||||||
|
"replicas": [
|
||||||
|
{"ghost": True, "host": r["host"], "count": r["count"]} if r["ghost"] else
|
||||||
|
{"ghost": False, "host": r["host"], "host_id": r["host_id"], "container_name": r["name"],
|
||||||
|
"status": r["status"], "health": r["health"], "restart_count": r["restart_count"],
|
||||||
|
"cpu_pct": rnd(r["cpu_pct"]), "mem_pct": rnd(r["mem_pct"])}
|
||||||
|
for r in s["replicas"]
|
||||||
|
],
|
||||||
|
}
|
||||||
|
for s in sorted(services, key=_rank)
|
||||||
|
]
|
||||||
|
return {"nodes": node_rows, **bounded(rows, limit, key="services")}
|
||||||
|
|
||||||
|
|
||||||
|
async def search_container_logs(container: str = "", host: str = "", text: str = "",
|
||||||
|
range: str = "1h", stream: str = "", before: str = "",
|
||||||
|
context: int = 0, limit: int = 50) -> dict:
|
||||||
|
"""Search the container log lines Steward has retained, newest first.
|
||||||
|
|
||||||
|
container: one container's name (as list_containers shows), or a Swarm
|
||||||
|
service's name (as list_swarm_services shows) to search every task
|
||||||
|
container the service has run, live and removed, so a search reaches
|
||||||
|
back across deploys; each match then has the `slot` and `task_id` that
|
||||||
|
wrote it. Empty searches every container (narrow with `host`). A
|
||||||
|
container Docker has removed still works while its lines are retained:
|
||||||
|
the reply then has `container_removed: true` and a `note` saying when it
|
||||||
|
was last seen.
|
||||||
|
host: a host's name or id; empty for all hosts.
|
||||||
|
text: case-insensitive substring to match; empty matches every line.
|
||||||
|
range: how far back — "1h" (default), "6h", "24h", "7d" or "30d". Logs are
|
||||||
|
kept per container up to a size and age limit, so older lines may be gone.
|
||||||
|
stream: "stdout" or "stderr"; empty for both.
|
||||||
|
before: an ISO time; returns only lines older than it. Pass a response's
|
||||||
|
`next_before` to page further back.
|
||||||
|
context: lines of surrounding log (0-5) to include with each match, from
|
||||||
|
the same container. Only with `container` set.
|
||||||
|
limit: matches to return, default 50, max 200.
|
||||||
|
|
||||||
|
Lines longer than 2000 characters keep both ends, and the middle is
|
||||||
|
replaced by a marker saying how much was cut.
|
||||||
|
"""
|
||||||
|
since, key = lookback(range)
|
||||||
|
limit = clamp_limit(limit, 50, 200)
|
||||||
|
context = max(0, min(int(context or 0), 5))
|
||||||
|
if context and not container:
|
||||||
|
raise ValueError("context needs `container`: it shows lines around a match within one container's log.")
|
||||||
|
if stream and stream not in ("stdout", "stderr"):
|
||||||
|
raise ValueError(f"stream {stream!r} is not 'stdout' or 'stderr'.")
|
||||||
|
before_dt = None
|
||||||
|
if before:
|
||||||
|
try:
|
||||||
|
before_dt = datetime.fromisoformat(before.replace("Z", "+00:00"))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(f"before {before!r} is not an ISO-8601 time; pass a response's next_before.") from exc
|
||||||
|
async with db_session() as db:
|
||||||
|
host_id = name = removed = service = tasks = None
|
||||||
|
if host:
|
||||||
|
hosts = list((await db.execute(select(Host))).scalars())
|
||||||
|
host_id = pick(host, hosts, kind="host", list_tool="list_hosts").id
|
||||||
|
if container:
|
||||||
|
service, tasks = await _as_service(db, container.strip(), host_id)
|
||||||
|
if service is None:
|
||||||
|
c = await _pick_container(db, container, host, removed=True)
|
||||||
|
host_id, name = c.host_id, c.name
|
||||||
|
removed = c if isinstance(c, RemovedContainer) else None
|
||||||
|
rows = await docker_svc.search_logs(db, since=since, host_id=host_id, name=name,
|
||||||
|
containers=tasks, text=text, stream=stream,
|
||||||
|
before=before_dt, limit=limit + 1)
|
||||||
|
more = len(rows) > limit
|
||||||
|
rows = rows[:limit]
|
||||||
|
host_names = await _host_names(db)
|
||||||
|
matches = []
|
||||||
|
for r in rows:
|
||||||
|
m = {"at": iso(r.ts), "container_name": r.container_name, "host_id": r.host_id,
|
||||||
|
"host_name": host_names.get(r.host_id), "stream": r.stream, "line": cut_middle(r.line)}
|
||||||
|
if service:
|
||||||
|
m["slot"], m["task_id"] = docker_svc.task_of(service, r.container_name)
|
||||||
|
if context:
|
||||||
|
pre, post = await docker_svc.log_context(db, r.host_id, r.container_name, r.ts, context)
|
||||||
|
m["before"] = [cut_middle(x.line) for x in pre]
|
||||||
|
m["after"] = [cut_middle(x.line) for x in post]
|
||||||
|
matches.append(m)
|
||||||
|
out = {"range": key, "matches": matches, "returned": len(matches), "truncated": more}
|
||||||
|
if service:
|
||||||
|
out["service"] = service
|
||||||
|
out["tasks_searched"] = len(tasks)
|
||||||
|
if removed is not None:
|
||||||
|
out["container_removed"] = True
|
||||||
|
out["note"] = _removed_note(removed)
|
||||||
|
if removed.last_seen < since:
|
||||||
|
out["note"] += " That is before this range; pass a longer `range`."
|
||||||
|
if more:
|
||||||
|
# Full precision: `at` is cut to the second for reading, and a cursor
|
||||||
|
# cut the same way would skip lines logged earlier in that second.
|
||||||
|
out["next_before"] = rows[-1].ts.isoformat()
|
||||||
|
out["more"] = "More matches are older; pass next_before as `before`, or narrow with `text`."
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def _as_service(db, want: str, host_id: str | None):
|
||||||
|
"""(service, its task containers) when `want` names a Swarm service rather
|
||||||
|
than a container (#5468): every task container Steward holds anything for,
|
||||||
|
live and removed, kept to `host_id` when given (none there reads as no
|
||||||
|
lines, not as a missing name). (None, None) when it is not a service, or
|
||||||
|
a container carries that exact name, which keeps precedence."""
|
||||||
|
if not want:
|
||||||
|
return None, None
|
||||||
|
exact = (await db.execute(select(DockerContainer.name).where(
|
||||||
|
DockerContainer.name == want).limit(1))).first()
|
||||||
|
if exact is not None:
|
||||||
|
return None, None
|
||||||
|
tasks = await docker_svc.service_containers(db, want)
|
||||||
|
if not tasks:
|
||||||
|
return None, None
|
||||||
|
return want, [(h, n) for h, n in tasks if host_id is None or h == host_id]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_docker_disk_usage(host: str = "") -> dict:
|
||||||
|
"""Docker disk usage per host: images, containers, volumes and build cache, in bytes.
|
||||||
|
|
||||||
|
host: a host's name or id; empty for every host. `images_reclaimable_bytes`
|
||||||
|
is what pruning unused images would free. An admin can prune from
|
||||||
|
Steward's Docker → Disk page.
|
||||||
|
"""
|
||||||
|
async with db_session() as db:
|
||||||
|
summaries = list((await db.execute(select(DockerDiskUsage))).scalars())
|
||||||
|
host_names = await _host_names(db)
|
||||||
|
if host:
|
||||||
|
hosts = list((await db.execute(select(Host))).scalars())
|
||||||
|
hid = pick(host, hosts, kind="host", list_tool="list_hosts").id
|
||||||
|
summaries = [s for s in summaries if s.host_id == hid]
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"host_id": s.host_id, "host_name": host_names.get(s.host_id),
|
||||||
|
"images_total": s.images_total, "images_active": s.images_active,
|
||||||
|
"images_bytes": s.images_size, "images_reclaimable_bytes": s.images_reclaimable,
|
||||||
|
"containers_count": s.containers_count, "containers_bytes": s.containers_size,
|
||||||
|
"volumes_count": s.volumes_count, "volumes_bytes": s.volumes_size,
|
||||||
|
"build_cache_bytes": s.build_cache_size,
|
||||||
|
"reported_at": iso(s.updated_at), "reported_age_s": age_s(s.updated_at),
|
||||||
|
}
|
||||||
|
for s in summaries
|
||||||
|
]
|
||||||
|
rows.sort(key=lambda r: -(r["images_reclaimable_bytes"] or 0))
|
||||||
|
return {"hosts": rows, "total": len(rows)}
|
||||||
|
|
||||||
|
|
||||||
|
PRUNE_BUILTIN_PLAYBOOK = "maintenance/docker_prune.yml"
|
||||||
|
_STOPPED = ("exited", "created", "dead")
|
||||||
|
|
||||||
|
|
||||||
|
def prune_preview(what: str, usage, stopped: int) -> dict:
|
||||||
|
"""What a prune of `what` would remove, from the host's latest disk report."""
|
||||||
|
out: dict = {}
|
||||||
|
if what in ("containers", "system"):
|
||||||
|
out["stopped_containers"] = stopped
|
||||||
|
if what == "images":
|
||||||
|
out["unused_images_bytes"] = usage.images_reclaimable if usage else None
|
||||||
|
if what == "system":
|
||||||
|
out["dangling_images"] = "yes (unused but tagged images stay)"
|
||||||
|
out["build_cache_bytes"] = usage.build_cache_size if usage else None
|
||||||
|
out["unused_networks"] = "yes"
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def prune_docker(host: str, what: str, check: bool = True) -> dict:
|
||||||
|
"""Reclaim Docker disk on a host. Previews unless check=false.
|
||||||
|
|
||||||
|
host: the Steward host's name or id. It needs a linked Ansible target
|
||||||
|
(list_inventory shows the links); the prune runs through Ansible as
|
||||||
|
Steward's built-in docker_prune playbook.
|
||||||
|
what: "containers" removes stopped containers; "images" removes ALL
|
||||||
|
unused images, not only dangling ones; "system" is `docker system
|
||||||
|
prune -f`: stopped containers, dangling images, unused networks and
|
||||||
|
build cache. Volumes are never pruned.
|
||||||
|
check: true (default) starts nothing and returns `would_remove`, sized
|
||||||
|
from the host's latest Docker disk report. Ansible's own check mode
|
||||||
|
skips the prune commands, so a check-mode run would show nothing.
|
||||||
|
Pass false to run the prune.
|
||||||
|
|
||||||
|
Admin only, as on the Docker → Disk page. A real prune returns `run_id`
|
||||||
|
(follow it with wait_for_run) and is recorded in the audit log.
|
||||||
|
"""
|
||||||
|
what = (what or "").strip()
|
||||||
|
if what not in docker_svc.PRUNE_TARGETS:
|
||||||
|
raise ValueError(f"what {what!r} is not one of {', '.join(docker_svc.PRUNE_TARGETS)}.")
|
||||||
|
async with db_session() as db:
|
||||||
|
h = pick(host, (await db.execute(select(Host))).scalars(), kind="host", list_tool="list_hosts")
|
||||||
|
target = (await db.execute(
|
||||||
|
select(AnsibleTarget).where(AnsibleTarget.host_id == h.id))).scalars().first()
|
||||||
|
usage = await db.get(DockerDiskUsage, h.id)
|
||||||
|
stopped = sum(1 for c in await docker_svc.list_containers(db, h.id)
|
||||||
|
if (c.status or "").lower() in _STOPPED)
|
||||||
|
if target is None:
|
||||||
|
raise ValueError(f"host {h.name!r} has no linked Ansible target, so Steward cannot reach it "
|
||||||
|
"to prune. Link one under Ansible → Inventory. Nothing was run.")
|
||||||
|
preview = {
|
||||||
|
"host": h.name,
|
||||||
|
"what": what,
|
||||||
|
"ansible_target": target.name,
|
||||||
|
"would_remove": prune_preview(what, usage, stopped),
|
||||||
|
"disk_report_age_s": age_s(usage.updated_at) if usage else None,
|
||||||
|
}
|
||||||
|
if check:
|
||||||
|
return {**preview, "check": True,
|
||||||
|
"summary": f"Nothing was run. Pass check=false to prune {what} on {h.name}."}
|
||||||
|
caller = current_caller()
|
||||||
|
run, _src, err = await trigger_run(
|
||||||
|
mcp_app(), source_name=BUILTIN_SOURCE_NAME, playbook_path=PRUNE_BUILTIN_PLAYBOOK,
|
||||||
|
inventory_scope=f"steward:target:{target.id}",
|
||||||
|
params={"extra_vars_map": docker_svc.prune_extra_vars(what)},
|
||||||
|
triggered_by=caller.user_id, origin="mcp")
|
||||||
|
if err:
|
||||||
|
raise ValueError(f"the prune could not start: {err}.")
|
||||||
|
await audit_mcp("docker.prune.started", "host", h.name,
|
||||||
|
{"host_id": h.id, "what": what, "run_id": run.id})
|
||||||
|
return {**preview, "check": False, "run_id": run.id,
|
||||||
|
"next": f'wait_for_run(run_id="{run.id}")'}
|
||||||
|
|
||||||
|
|
||||||
|
def register_tools(register) -> None:
|
||||||
|
for fn in (list_containers, get_container, list_swarm_services,
|
||||||
|
search_container_logs, get_docker_disk_usage):
|
||||||
|
register(fn, role=UserRole.viewer, read_only=True)
|
||||||
|
register(prune_docker, role=UserRole.admin, read_only=False)
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# plugins/docker/migrations/env.py
|
||||||
|
"""Alembic env.py for the Docker plugin."""
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from sqlalchemy import pool
|
||||||
|
from sqlalchemy.engine import Connection
|
||||||
|
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||||
|
from alembic import context
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
|
||||||
|
|
||||||
|
from steward.models.base import Base
|
||||||
|
import steward.models # noqa: F401
|
||||||
|
from plugins.docker.models import DockerContainer # noqa: F401
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def _get_url() -> str:
|
||||||
|
import yaml
|
||||||
|
cfg_path = os.environ.get("STEWARD_CONFIG", "config.yaml")
|
||||||
|
try:
|
||||||
|
with open(cfg_path) as f:
|
||||||
|
cfg = yaml.safe_load(f) or {}
|
||||||
|
url = cfg.get("database", {}).get("url", "")
|
||||||
|
except FileNotFoundError:
|
||||||
|
url = ""
|
||||||
|
return os.environ.get("STEWARD_DATABASE__URL", url)
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
context.configure(
|
||||||
|
url=_get_url(), target_metadata=target_metadata,
|
||||||
|
literal_binds=True, dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def do_run_migrations(connection: Connection) -> None:
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_async_migrations() -> None:
|
||||||
|
cfg = config.get_section(config.config_ini_section, {})
|
||||||
|
cfg["sqlalchemy.url"] = _get_url()
|
||||||
|
connectable = async_engine_from_config(cfg, prefix="sqlalchemy.", poolclass=pool.NullPool)
|
||||||
|
async with connectable.connect() as connection:
|
||||||
|
await connection.run_sync(do_run_migrations)
|
||||||
|
await connectable.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
asyncio.run(run_async_migrations())
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Docker plugin initial tables
|
||||||
|
|
||||||
|
Revision ID: docker_001_initial
|
||||||
|
Revises: (none — branch off core via depends_on)
|
||||||
|
Create Date: 2026-03-22
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = "docker_001_initial"
|
||||||
|
down_revision: Union[str, None] = None
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = "docker"
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ("0004_app_settings",)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"docker_containers",
|
||||||
|
sa.Column("name", sa.String(255), primary_key=True),
|
||||||
|
sa.Column("container_id", sa.String(64), nullable=False, server_default=""),
|
||||||
|
sa.Column("image", sa.String(512), nullable=False, server_default=""),
|
||||||
|
sa.Column("status", sa.String(32), nullable=False, server_default="unknown"),
|
||||||
|
sa.Column("cpu_pct", sa.Float, nullable=True),
|
||||||
|
sa.Column("mem_usage_bytes", sa.Integer, nullable=True),
|
||||||
|
sa.Column("mem_limit_bytes", sa.Integer, nullable=True),
|
||||||
|
sa.Column("mem_pct", sa.Float, nullable=True),
|
||||||
|
sa.Column("restart_count", sa.Integer, nullable=False, server_default="0"),
|
||||||
|
sa.Column("ports_json", sa.Text, nullable=False, server_default="[]"),
|
||||||
|
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("scraped_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"docker_metrics",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True),
|
||||||
|
sa.Column("container_name", sa.String(255), nullable=False, index=True),
|
||||||
|
sa.Column("scraped_at", sa.DateTime(timezone=True), nullable=False, index=True),
|
||||||
|
sa.Column("cpu_pct", sa.Float, nullable=False, server_default="0"),
|
||||||
|
sa.Column("mem_pct", sa.Float, nullable=False, server_default="0"),
|
||||||
|
sa.Column("mem_usage_bytes", sa.Integer, nullable=False, server_default="0"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("docker_metrics")
|
||||||
|
op.drop_table("docker_containers")
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""Docker collection goes per-host: add host_id, re-key by (host_id, name)
|
||||||
|
|
||||||
|
Collection moved from the central single-socket scrape to the host agent, so
|
||||||
|
containers are now scoped to the host that reported them. docker_containers is
|
||||||
|
re-keyed (host_id, name) and docker_metrics gains host_id.
|
||||||
|
|
||||||
|
Dev-only posture (rule 122): the old tables only ever held the Steward box's
|
||||||
|
own containers (a single global namespace), which are disposable — so this
|
||||||
|
DROP+recreates rather than backfilling a host_id onto orphan rows.
|
||||||
|
|
||||||
|
Revision ID: docker_002_host_scoped
|
||||||
|
Revises: docker_001_initial
|
||||||
|
Create Date: 2026-06-18
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = "docker_002_host_scoped"
|
||||||
|
down_revision: Union[str, None] = "docker_001_initial"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
# FK targets hosts.id (created in 0002_core_monitors) — make the edge explicit.
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ("0002_core_monitors",)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.drop_table("docker_metrics")
|
||||||
|
op.drop_table("docker_containers")
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"docker_containers",
|
||||||
|
sa.Column("host_id", sa.String(36),
|
||||||
|
sa.ForeignKey("hosts.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
sa.Column("name", sa.String(255), primary_key=True),
|
||||||
|
sa.Column("container_id", sa.String(64), nullable=False, server_default=""),
|
||||||
|
sa.Column("image", sa.String(512), nullable=False, server_default=""),
|
||||||
|
sa.Column("status", sa.String(32), nullable=False, server_default="unknown"),
|
||||||
|
sa.Column("cpu_pct", sa.Float, nullable=True),
|
||||||
|
sa.Column("mem_usage_bytes", sa.Integer, nullable=True),
|
||||||
|
sa.Column("mem_limit_bytes", sa.Integer, nullable=True),
|
||||||
|
sa.Column("mem_pct", sa.Float, nullable=True),
|
||||||
|
sa.Column("restart_count", sa.Integer, nullable=False, server_default="0"),
|
||||||
|
sa.Column("ports_json", sa.Text, nullable=False, server_default="[]"),
|
||||||
|
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("scraped_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"docker_metrics",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True),
|
||||||
|
sa.Column("host_id", sa.String(36),
|
||||||
|
sa.ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("container_name", sa.String(255), nullable=False),
|
||||||
|
sa.Column("scraped_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("cpu_pct", sa.Float, nullable=False, server_default="0"),
|
||||||
|
sa.Column("mem_pct", sa.Float, nullable=False, server_default="0"),
|
||||||
|
sa.Column("mem_usage_bytes", sa.Integer, nullable=False, server_default="0"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_docker_metrics_host_id", "docker_metrics", ["host_id"])
|
||||||
|
op.create_index("ix_docker_metrics_container_name", "docker_metrics",
|
||||||
|
["container_name"])
|
||||||
|
op.create_index("ix_docker_metrics_scraped_at", "docker_metrics", ["scraped_at"])
|
||||||
|
op.create_index("ix_docker_metrics_host_container_time", "docker_metrics",
|
||||||
|
["host_id", "container_name", "scraped_at"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("docker_metrics")
|
||||||
|
op.drop_table("docker_containers")
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"docker_containers",
|
||||||
|
sa.Column("name", sa.String(255), primary_key=True),
|
||||||
|
sa.Column("container_id", sa.String(64), nullable=False, server_default=""),
|
||||||
|
sa.Column("image", sa.String(512), nullable=False, server_default=""),
|
||||||
|
sa.Column("status", sa.String(32), nullable=False, server_default="unknown"),
|
||||||
|
sa.Column("cpu_pct", sa.Float, nullable=True),
|
||||||
|
sa.Column("mem_usage_bytes", sa.Integer, nullable=True),
|
||||||
|
sa.Column("mem_limit_bytes", sa.Integer, nullable=True),
|
||||||
|
sa.Column("mem_pct", sa.Float, nullable=True),
|
||||||
|
sa.Column("restart_count", sa.Integer, nullable=False, server_default="0"),
|
||||||
|
sa.Column("ports_json", sa.Text, nullable=False, server_default="[]"),
|
||||||
|
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("scraped_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"docker_metrics",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True),
|
||||||
|
sa.Column("container_name", sa.String(255), nullable=False, index=True),
|
||||||
|
sa.Column("scraped_at", sa.DateTime(timezone=True), nullable=False, index=True),
|
||||||
|
sa.Column("cpu_pct", sa.Float, nullable=False, server_default="0"),
|
||||||
|
sa.Column("mem_pct", sa.Float, nullable=False, server_default="0"),
|
||||||
|
sa.Column("mem_usage_bytes", sa.Integer, nullable=False, server_default="0"),
|
||||||
|
)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""Docker container enrichment: health, exit/restart, grouping, I/O counters
|
||||||
|
|
||||||
|
Adds the fields the agent (≥1.4.0) now reports per container beyond the basic
|
||||||
|
state: health status, exit code, OOM flag, compose/swarm grouping labels, and
|
||||||
|
cumulative network/block I/O counters. Additive columns — no data loss, so no
|
||||||
|
DROP+recreate needed here.
|
||||||
|
|
||||||
|
Revision ID: docker_003_container_enrichment
|
||||||
|
Revises: docker_002_host_scoped
|
||||||
|
Create Date: 2026-06-19
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = "docker_003_container_enrichment"
|
||||||
|
down_revision: Union[str, None] = "docker_002_host_scoped"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("docker_containers", sa.Column("health", sa.String(16), nullable=True))
|
||||||
|
op.add_column("docker_containers", sa.Column("exit_code", sa.Integer, nullable=True))
|
||||||
|
op.add_column("docker_containers",
|
||||||
|
sa.Column("oom_killed", sa.Boolean, nullable=False, server_default=sa.false()))
|
||||||
|
op.add_column("docker_containers", sa.Column("compose_project", sa.String(255), nullable=True))
|
||||||
|
op.add_column("docker_containers", sa.Column("service_name", sa.String(255), nullable=True))
|
||||||
|
op.add_column("docker_containers", sa.Column("task_id", sa.String(64), nullable=True))
|
||||||
|
op.add_column("docker_containers", sa.Column("node_id", sa.String(64), nullable=True))
|
||||||
|
op.add_column("docker_containers", sa.Column("net_rx_bytes", sa.BigInteger, nullable=True))
|
||||||
|
op.add_column("docker_containers", sa.Column("net_tx_bytes", sa.BigInteger, nullable=True))
|
||||||
|
op.add_column("docker_containers", sa.Column("blk_read_bytes", sa.BigInteger, nullable=True))
|
||||||
|
op.add_column("docker_containers", sa.Column("blk_write_bytes", sa.BigInteger, nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
for col in ("blk_write_bytes", "blk_read_bytes", "net_tx_bytes", "net_rx_bytes",
|
||||||
|
"node_id", "task_id", "service_name", "compose_project",
|
||||||
|
"oom_killed", "exit_code", "health"):
|
||||||
|
op.drop_column("docker_containers", col)
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""Docker lifecycle events + swarm topology tables
|
||||||
|
|
||||||
|
Adds the storage for milestone-77 monitoring depth that doesn't fit on the
|
||||||
|
per-container row:
|
||||||
|
|
||||||
|
* docker_events — lifecycle (start/stop/die/oom/health_change) derived by
|
||||||
|
diffing consecutive host snapshots; host-scoped, indexed for both timeline
|
||||||
|
lookups (host_id, container_name, at) and retention pruning (at).
|
||||||
|
* docker_swarm_services / docker_swarm_nodes — manager-reported Swarm
|
||||||
|
topology (desired-vs-running replicas, node role/availability/status).
|
||||||
|
|
||||||
|
All new tables — purely additive, so no DROP+recreate. `event`/`mode`/`role`
|
||||||
|
etc. are plain strings (no CHECK whitelist), matching how docker_containers
|
||||||
|
models `status`; keeps the value sets open without a migration per addition
|
||||||
|
(so rule 36 doesn't apply here).
|
||||||
|
|
||||||
|
Revision ID: docker_004_events_swarm
|
||||||
|
Revises: docker_003_container_enrichment
|
||||||
|
Create Date: 2026-06-19
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = "docker_004_events_swarm"
|
||||||
|
down_revision: Union[str, None] = "docker_003_container_enrichment"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"docker_events",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True),
|
||||||
|
sa.Column("host_id", sa.String(36),
|
||||||
|
sa.ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("container_name", sa.String(255), nullable=False),
|
||||||
|
sa.Column("event", sa.String(16), nullable=False),
|
||||||
|
sa.Column("detail", sa.String(255), nullable=True),
|
||||||
|
sa.Column("at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("ix_docker_events_host_container_time", "docker_events",
|
||||||
|
["host_id", "container_name", "at"])
|
||||||
|
op.create_index("ix_docker_events_at", "docker_events", ["at"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"docker_swarm_services",
|
||||||
|
sa.Column("host_id", sa.String(36),
|
||||||
|
sa.ForeignKey("hosts.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
sa.Column("service_name", sa.String(255), primary_key=True),
|
||||||
|
sa.Column("mode", sa.String(16), nullable=False, server_default="replicated"),
|
||||||
|
sa.Column("desired", sa.Integer, nullable=False, server_default="0"),
|
||||||
|
sa.Column("running", sa.Integer, nullable=False, server_default="0"),
|
||||||
|
sa.Column("image", sa.String(512), nullable=True),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"docker_swarm_nodes",
|
||||||
|
sa.Column("host_id", sa.String(36),
|
||||||
|
sa.ForeignKey("hosts.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
sa.Column("node_id", sa.String(64), primary_key=True),
|
||||||
|
sa.Column("hostname", sa.String(255), nullable=False, server_default=""),
|
||||||
|
sa.Column("role", sa.String(16), nullable=False, server_default="worker"),
|
||||||
|
sa.Column("availability", sa.String(16), nullable=False, server_default="active"),
|
||||||
|
sa.Column("status", sa.String(16), nullable=False, server_default="unknown"),
|
||||||
|
sa.Column("leader", sa.Boolean, nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("docker_swarm_nodes")
|
||||||
|
op.drop_table("docker_swarm_services")
|
||||||
|
op.drop_index("ix_docker_events_at", table_name="docker_events")
|
||||||
|
op.drop_index("ix_docker_events_host_container_time", table_name="docker_events")
|
||||||
|
op.drop_table("docker_events")
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""Docker swarm service placement column
|
||||||
|
|
||||||
|
Adds docker_swarm_services.placement_json — the task→node placement of a
|
||||||
|
service's running replicas, captured from the agent's swarm payload (a manager
|
||||||
|
sees every task, so this records cross-node placement the local container rows
|
||||||
|
can't). Additive column; no DROP+recreate.
|
||||||
|
|
||||||
|
Revision ID: docker_005_swarm_placement
|
||||||
|
Revises: docker_004_events_swarm
|
||||||
|
Create Date: 2026-06-19
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = "docker_005_swarm_placement"
|
||||||
|
down_revision: Union[str, None] = "docker_004_events_swarm"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("docker_swarm_services",
|
||||||
|
sa.Column("placement_json", sa.Text, nullable=False,
|
||||||
|
server_default="[]"))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("docker_swarm_services", "placement_json")
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Docker hourly metric rollup table
|
||||||
|
|
||||||
|
Adds docker_metrics_hourly — the coarse series that retention rolls raw
|
||||||
|
docker_metrics into before pruning them, so multi-day history stays cheap.
|
||||||
|
One row per (host, container, hour bucket); the unique constraint is the
|
||||||
|
conflict target for the idempotent rollup upsert. Additive create_table.
|
||||||
|
|
||||||
|
Revision ID: docker_006_metric_rollup
|
||||||
|
Revises: docker_005_swarm_placement
|
||||||
|
Create Date: 2026-06-19
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = "docker_006_metric_rollup"
|
||||||
|
down_revision: Union[str, None] = "docker_005_swarm_placement"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"docker_metrics_hourly",
|
||||||
|
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("bucket", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("cpu_pct", sa.Float(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("mem_pct", sa.Float(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("mem_usage_bytes", sa.BigInteger(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("sample_count", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.ForeignKeyConstraint(["host_id"], ["hosts.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("host_id", "container_name", "bucket",
|
||||||
|
name="uq_docker_metrics_hourly_bucket"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_docker_metrics_hourly_bucket",
|
||||||
|
"docker_metrics_hourly", ["bucket"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_docker_metrics_hourly_bucket",
|
||||||
|
table_name="docker_metrics_hourly")
|
||||||
|
op.drop_table("docker_metrics_hourly")
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Docker disk usage + image storage tables
|
||||||
|
|
||||||
|
Adds docker_disk_usage (one per-host /system/df summary row) and docker_images
|
||||||
|
(the heavy-hitter image storage records). Both current-state, host-scoped,
|
||||||
|
re-reported each interval. Additive create_table.
|
||||||
|
|
||||||
|
Revision ID: docker_007_disk_usage
|
||||||
|
Revises: docker_006_metric_rollup
|
||||||
|
Create Date: 2026-06-19
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = "docker_007_disk_usage"
|
||||||
|
down_revision: Union[str, None] = "docker_006_metric_rollup"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"docker_disk_usage",
|
||||||
|
sa.Column("host_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("layers_size", sa.BigInteger(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("images_size", sa.BigInteger(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("images_reclaimable", sa.BigInteger(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("containers_size", sa.BigInteger(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("volumes_size", sa.BigInteger(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("build_cache_size", sa.BigInteger(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("images_total", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("images_active", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("containers_count", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("volumes_count", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["host_id"], ["hosts.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("host_id"),
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"docker_images",
|
||||||
|
sa.Column("host_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("image_id", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("repo_tag", sa.String(length=512), nullable=False, server_default="<none>"),
|
||||||
|
sa.Column("size", sa.BigInteger(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("shared_size", sa.BigInteger(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("containers", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["host_id"], ["hosts.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("host_id", "image_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("docker_images")
|
||||||
|
op.drop_table("docker_disk_usage")
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Widen memory byte columns to BIGINT
|
||||||
|
|
||||||
|
docker_metrics.mem_usage_bytes and docker_containers.mem_usage_bytes /
|
||||||
|
mem_limit_bytes were int4, which overflows for any container using >2.1 GB
|
||||||
|
(asyncpg: "value out of int32 range"), failing the whole ingest batch. Widen to
|
||||||
|
BIGINT — a safe in-place int4→int8 promotion, no data rewrite.
|
||||||
|
|
||||||
|
Revision ID: docker_008_bigint_mem
|
||||||
|
Revises: docker_007_disk_usage
|
||||||
|
Create Date: 2026-06-20
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = "docker_008_bigint_mem"
|
||||||
|
down_revision: Union[str, None] = "docker_007_disk_usage"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.alter_column("docker_metrics", "mem_usage_bytes", type_=sa.BigInteger())
|
||||||
|
op.alter_column("docker_containers", "mem_usage_bytes", type_=sa.BigInteger())
|
||||||
|
op.alter_column("docker_containers", "mem_limit_bytes", type_=sa.BigInteger())
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Narrowing back to int4 will error if any stored value exceeds 2^31 — that's
|
||||||
|
# the very condition this migration fixed, so downgrade is best-effort.
|
||||||
|
op.alter_column("docker_containers", "mem_limit_bytes", type_=sa.Integer())
|
||||||
|
op.alter_column("docker_containers", "mem_usage_bytes", type_=sa.Integer())
|
||||||
|
op.alter_column("docker_metrics", "mem_usage_bytes", type_=sa.Integer())
|
||||||
@@ -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")
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""Docker swarm service tasks column
|
||||||
|
|
||||||
|
Adds docker_swarm_services.tasks_json — each service's newest tasks as a
|
||||||
|
manager's agent (≥ 1.8.0) reports them: slot, node, desired and current state,
|
||||||
|
Swarm's error and message, exit code and time. Lets Steward tell a task Swarm
|
||||||
|
replaced from one that crashed, count a service's failures across task
|
||||||
|
retries, and say why its last task failed. Additive column; no DROP+recreate.
|
||||||
|
|
||||||
|
Revision ID: docker_010_swarm_tasks
|
||||||
|
Revises: docker_009_container_logs
|
||||||
|
Create Date: 2026-10-06
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = "docker_010_swarm_tasks"
|
||||||
|
down_revision: Union[str, None] = "docker_009_container_logs"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("docker_swarm_services",
|
||||||
|
sa.Column("tasks_json", sa.Text, nullable=False,
|
||||||
|
server_default="[]"))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("docker_swarm_services", "tasks_json")
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Docker host status
|
||||||
|
|
||||||
|
Adds docker_host_status: per host, whether Docker answered at the agent's
|
||||||
|
last report (ok / unavailable / error) and why not. Lets Steward show a host
|
||||||
|
whose Docker went away as such, instead of its last containers as running
|
||||||
|
(#5152). Additive create_table.
|
||||||
|
|
||||||
|
Revision ID: docker_011_host_status
|
||||||
|
Revises: docker_010_swarm_tasks
|
||||||
|
Create Date: 2026-10-06
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = "docker_011_host_status"
|
||||||
|
down_revision: Union[str, None] = "docker_010_swarm_tasks"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"docker_host_status",
|
||||||
|
sa.Column("host_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=16), nullable=False),
|
||||||
|
sa.Column("detail", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("since", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("reported_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["host_id"], ["hosts.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("host_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("docker_host_status")
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""Container CPU and memory move to the one metric store
|
||||||
|
|
||||||
|
Drops docker_metrics and docker_metrics_hourly (#5752). Container cpu_pct,
|
||||||
|
mem_pct and mem_usage_bytes are recorded into plugin_metrics as source
|
||||||
|
"docker", resource "<host>/<container>", and roll up with every other metric.
|
||||||
|
CPU and memory % were already recorded there for alerting, so their history
|
||||||
|
stays; memory in bytes starts fresh.
|
||||||
|
|
||||||
|
Revision ID: docker_012_drop_metrics
|
||||||
|
Revises: docker_011_host_status
|
||||||
|
Create Date: 2026-10-14
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = "docker_012_drop_metrics"
|
||||||
|
down_revision: Union[str, None] = "docker_011_host_status"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("DROP TABLE IF EXISTS docker_metrics_hourly")
|
||||||
|
op.execute("DROP TABLE IF EXISTS docker_metrics")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"docker_metrics",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True),
|
||||||
|
sa.Column("host_id", sa.String(36), sa.ForeignKey("hosts.id", ondelete="CASCADE"),
|
||||||
|
nullable=False, index=True),
|
||||||
|
sa.Column("container_name", sa.String(255), nullable=False, index=True),
|
||||||
|
sa.Column("scraped_at", sa.DateTime(timezone=True), nullable=False, index=True),
|
||||||
|
sa.Column("cpu_pct", sa.Float, nullable=False),
|
||||||
|
sa.Column("mem_pct", sa.Float, nullable=False),
|
||||||
|
sa.Column("mem_usage_bytes", sa.BigInteger, nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("ix_docker_metrics_host_container_time", "docker_metrics",
|
||||||
|
["host_id", "container_name", "scraped_at"])
|
||||||
|
op.create_table(
|
||||||
|
"docker_metrics_hourly",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True),
|
||||||
|
sa.Column("host_id", sa.String(36), sa.ForeignKey("hosts.id", ondelete="CASCADE"),
|
||||||
|
nullable=False),
|
||||||
|
sa.Column("container_name", sa.String(255), nullable=False),
|
||||||
|
sa.Column("bucket", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("cpu_pct", sa.Float, nullable=False),
|
||||||
|
sa.Column("mem_pct", sa.Float, nullable=False),
|
||||||
|
sa.Column("mem_usage_bytes", sa.BigInteger, nullable=False),
|
||||||
|
sa.Column("sample_count", sa.Integer, nullable=False),
|
||||||
|
sa.UniqueConstraint("host_id", "container_name", "bucket",
|
||||||
|
name="uq_docker_metrics_hourly_bucket"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_docker_metrics_hourly_bucket", "docker_metrics_hourly", ["bucket"])
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
# plugins/docker/models.py
|
||||||
|
from __future__ import annotations
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy import (
|
||||||
|
BigInteger, Boolean, DateTime, Float, ForeignKey, Index, Integer, String, Text,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from steward.models.base import Base
|
||||||
|
from steward.core.ids import time_ordered_id
|
||||||
|
|
||||||
|
|
||||||
|
class DockerContainer(Base):
|
||||||
|
"""Latest known state per container, scoped to the host that reported it.
|
||||||
|
|
||||||
|
Collection is per-host via the host agent, so container names are only
|
||||||
|
unique within a host — the natural key is (host_id, name). host_id is NOT
|
||||||
|
NULL: every container arrives through a host_agent ingest that resolves a
|
||||||
|
Host first. Deleting a host cascades its containers away.
|
||||||
|
"""
|
||||||
|
__tablename__ = "docker_containers"
|
||||||
|
|
||||||
|
host_id: Mapped[str] = mapped_column(
|
||||||
|
String(36), ForeignKey("hosts.id", ondelete="CASCADE"), primary_key=True
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), primary_key=True)
|
||||||
|
container_id: Mapped[str] = mapped_column(String(64), nullable=False, default="")
|
||||||
|
image: Mapped[str] = mapped_column(String(512), nullable=False, default="")
|
||||||
|
status: Mapped[str] = mapped_column(String(32), nullable=False, default="unknown")
|
||||||
|
# running | stopped | paused | exited | dead
|
||||||
|
cpu_pct: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
# BigInteger: a container's memory usage/limit routinely exceeds 2^31 bytes
|
||||||
|
# (~2.1 GB), which overflows int4 and fails the insert.
|
||||||
|
mem_usage_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||||
|
mem_limit_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||||
|
mem_pct: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
restart_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
ports_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
|
||||||
|
# JSON: [{"host_port": 8080, "container_port": 80, "protocol": "tcp"}]
|
||||||
|
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
scraped_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False,
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Enrichment (agent ≥ 1.4.0) ────────────────────────────────────────────
|
||||||
|
# Health/exit/restart come from `docker inspect` (not the list endpoint);
|
||||||
|
# exit_code is only meaningful for stopped containers.
|
||||||
|
health: Mapped[str | None] = mapped_column(String(16), nullable=True) # healthy|unhealthy|starting
|
||||||
|
exit_code: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
oom_killed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
# Grouping: compose project + swarm placement, read off container labels.
|
||||||
|
compose_project: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
service_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
task_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
node_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
# Cumulative-since-start I/O counters (BigInteger — they exceed 2^31 quickly).
|
||||||
|
net_rx_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||||
|
net_tx_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||||
|
blk_read_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||||
|
blk_write_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class DockerEvent(Base):
|
||||||
|
"""Lifecycle events derived by diffing consecutive host snapshots.
|
||||||
|
|
||||||
|
The agent only reports current state, so Steward synthesises lifecycle by
|
||||||
|
comparing each new snapshot against the stored DockerContainer state for the
|
||||||
|
host: a container that appears → `start`; one that vanishes → `stop`; a
|
||||||
|
transition to a stopped status with a non-zero exit → `die`; an OOM-kill
|
||||||
|
flip → `oom`; a health status change → `health_change`. Scoped to the
|
||||||
|
reporting host (names are only unique within a host). `event` is a plain
|
||||||
|
string (no CHECK whitelist), matching how `status` is modelled on
|
||||||
|
DockerContainer — keeps the value set open without a migration per addition.
|
||||||
|
"""
|
||||||
|
__tablename__ = "docker_events"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(
|
||||||
|
String(36), primary_key=True, default=time_ordered_id
|
||||||
|
)
|
||||||
|
host_id: Mapped[str] = mapped_column(
|
||||||
|
String(36), ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
container_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
event: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||||
|
# start | restart | stop | die | oom | health_change
|
||||||
|
detail: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
# e.g. "exit 137", "healthy→unhealthy", image on start
|
||||||
|
at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False,
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Timeline lookups filter on (host_id, container_name) and sort by time;
|
||||||
|
# retention prunes by `at` alone — index both access paths.
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_docker_events_host_container_time",
|
||||||
|
"host_id", "container_name", "at"),
|
||||||
|
Index("ix_docker_events_at", "at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class 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_containers). 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=time_ordered_id
|
||||||
|
)
|
||||||
|
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):
|
||||||
|
"""A Swarm service as seen by a manager host: desired vs running replicas.
|
||||||
|
|
||||||
|
Manager-scoped — only a host that is a Swarm manager reports these, so a
|
||||||
|
single-manager cluster yields one row set keyed by that host. Service names
|
||||||
|
are unique within a swarm; we still key by (host_id, service_name) so two
|
||||||
|
managers reporting independently don't collide.
|
||||||
|
"""
|
||||||
|
__tablename__ = "docker_swarm_services"
|
||||||
|
|
||||||
|
host_id: Mapped[str] = mapped_column(
|
||||||
|
String(36), ForeignKey("hosts.id", ondelete="CASCADE"), primary_key=True
|
||||||
|
)
|
||||||
|
service_name: Mapped[str] = mapped_column(String(255), primary_key=True)
|
||||||
|
mode: Mapped[str] = mapped_column(String(16), nullable=False, default="replicated")
|
||||||
|
# replicated | global
|
||||||
|
desired: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
running: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
image: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
# task→node placement of running replicas: [{"node_id", "running"}], JSON.
|
||||||
|
# A manager sees every task, so this captures cross-node placement that the
|
||||||
|
# local docker_containers rows (this host only) can't.
|
||||||
|
placement_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
|
||||||
|
# The service's newest tasks, newest first, as the manager's agent shaped
|
||||||
|
# them: [{"task_id", "slot", "node_id", "desired_state", "state", "message",
|
||||||
|
# "error", "exit_code", "at"}], JSON. Swarm's own record of which tasks it
|
||||||
|
# replaced and why each one stopped; "[]" from agents older than 1.8.0.
|
||||||
|
tasks_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False,
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DockerDiskUsage(Base):
|
||||||
|
"""Per-host Docker disk usage summary from `/system/df` (current state).
|
||||||
|
|
||||||
|
One row per host (the agent re-reports the full summary each interval).
|
||||||
|
Reclaimable = bytes held by images no container references. Sizes are bytes
|
||||||
|
(BigInteger — image caches exceed 2^31 easily).
|
||||||
|
"""
|
||||||
|
__tablename__ = "docker_disk_usage"
|
||||||
|
|
||||||
|
host_id: Mapped[str] = mapped_column(
|
||||||
|
String(36), ForeignKey("hosts.id", ondelete="CASCADE"), primary_key=True
|
||||||
|
)
|
||||||
|
layers_size: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||||
|
images_size: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||||
|
images_reclaimable: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||||
|
containers_size: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||||
|
volumes_size: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||||
|
build_cache_size: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||||
|
images_total: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
images_active: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
containers_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
volumes_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False,
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DockerImage(Base):
|
||||||
|
"""Per-host image storage record (the heavy hitters from `/system/df`).
|
||||||
|
|
||||||
|
Keyed (host_id, image_id); `containers` is the reference count (0 ⇒ the
|
||||||
|
image's size is reclaimable). Replaced wholesale per host on each report.
|
||||||
|
"""
|
||||||
|
__tablename__ = "docker_images"
|
||||||
|
|
||||||
|
host_id: Mapped[str] = mapped_column(
|
||||||
|
String(36), ForeignKey("hosts.id", ondelete="CASCADE"), primary_key=True
|
||||||
|
)
|
||||||
|
image_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
|
repo_tag: Mapped[str] = mapped_column(String(512), nullable=False, default="<none>")
|
||||||
|
size: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||||
|
shared_size: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||||
|
containers: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False,
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DockerSwarmNode(Base):
|
||||||
|
"""A Swarm node as seen by a manager host: role / availability / status."""
|
||||||
|
__tablename__ = "docker_swarm_nodes"
|
||||||
|
|
||||||
|
host_id: Mapped[str] = mapped_column(
|
||||||
|
String(36), ForeignKey("hosts.id", ondelete="CASCADE"), primary_key=True
|
||||||
|
)
|
||||||
|
node_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
|
hostname: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||||
|
role: Mapped[str] = mapped_column(String(16), nullable=False, default="worker")
|
||||||
|
# manager | worker
|
||||||
|
availability: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
|
||||||
|
# active | pause | drain
|
||||||
|
status: Mapped[str] = mapped_column(String(16), nullable=False, default="unknown")
|
||||||
|
# ready | down | unknown
|
||||||
|
leader: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False,
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DockerHostStatus(Base):
|
||||||
|
"""Whether Docker answered on a host at its agent's last report (agent ≥ 1.9.0).
|
||||||
|
|
||||||
|
"ok" when it did; "unavailable" when there is no Docker to ask (no socket,
|
||||||
|
nothing listening); "error" when Docker is there and the call failed.
|
||||||
|
`detail` says why for the last two. While a host is not "ok" its container
|
||||||
|
rows are what Docker last said, not what is true now, and read as stale.
|
||||||
|
A host that never had Docker gets no row.
|
||||||
|
"""
|
||||||
|
__tablename__ = "docker_host_status"
|
||||||
|
|
||||||
|
host_id: Mapped[str] = mapped_column(
|
||||||
|
String(36), ForeignKey("hosts.id", ondelete="CASCADE"), primary_key=True
|
||||||
|
)
|
||||||
|
status: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||||
|
detail: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
# When the status last changed, and the agent's newest report.
|
||||||
|
since: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
reported_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# plugins/docker/overview.py
|
||||||
|
"""The Docker page's two tables, as plain rows (#5299, #5301): one row per
|
||||||
|
swarm service and one per standalone container, with the search, stack, host
|
||||||
|
and status filters and the column sort the page offers. Model-free, so it is
|
||||||
|
unit-tested without a database; routes.rows() feeds it.
|
||||||
|
|
||||||
|
A stack is how the operator thinks of a group of services. Swarm names a
|
||||||
|
stack's services "<stack>_<service>", so a service's stack is the part of its
|
||||||
|
name before the first "_"; a standalone container's is its compose project.
|
||||||
|
Stacks filter the list rather than section it, as host groups do on Hosts.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from .swarm_view import service_trouble
|
||||||
|
|
||||||
|
# Status filter values and their labels; NO_STACK picks rows with no stack.
|
||||||
|
DOCKER_STATES = {"problem": "Needs attention", "healthy": "Healthy", "stopped": "Stopped"}
|
||||||
|
NO_STACK = "__none__"
|
||||||
|
# Each sort and whether it starts descending: the busiest first; "up" starts
|
||||||
|
# with the shortest uptime, the thing that most recently restarted.
|
||||||
|
DOCKER_SORTS = {"status": False, "name": False, "stack": False, "cpu": True, "mem": True,
|
||||||
|
"up": False}
|
||||||
|
|
||||||
|
|
||||||
|
def stack_of(service_name: str) -> str | None:
|
||||||
|
"""The stack a swarm service was deployed in: "arr" for "arr_radarr";
|
||||||
|
None for a name with no "_" (a service created on its own)."""
|
||||||
|
stack, sep, rest = service_name.partition("_")
|
||||||
|
return stack if sep and stack and rest else None
|
||||||
|
|
||||||
|
|
||||||
|
def _seconds_up(started_at, now: datetime) -> float | None:
|
||||||
|
return None if started_at is None else max((now - started_at).total_seconds(), 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def service_row(s: dict, now: datetime) -> dict:
|
||||||
|
"""A built swarm service (swarm_view.build_swarm_services) as a table row.
|
||||||
|
CPU and memory add up the running replicas; "up" is the newest replica's
|
||||||
|
start, so a service that just restarted a task reads as just restarted."""
|
||||||
|
reals = [r for r in s["replicas"] if not r["ghost"]]
|
||||||
|
running = [r for r in reals if (r["status"] or "").lower() == "running"]
|
||||||
|
cpu = [r["cpu_pct"] for r in running if r.get("cpu_pct") is not None]
|
||||||
|
mem = [r["mem_bytes"] for r in running if r.get("mem_bytes") is not None]
|
||||||
|
starts = [r["started_at"] for r in running if r.get("started_at")]
|
||||||
|
started = max(starts) if starts else None
|
||||||
|
return {
|
||||||
|
"kind": "service", "name": s["name"], "stack": stack_of(s["name"]),
|
||||||
|
"mode": s["mode"],
|
||||||
|
"image": s.get("image") or next((r["image"] for r in reals if r.get("image")), None),
|
||||||
|
"running": s["running"], "desired": s["desired"], "state": s["state"],
|
||||||
|
"trouble": service_trouble(s),
|
||||||
|
"nodes": sorted({r["host"] for r in reals}, key=str.lower),
|
||||||
|
"ghost_nodes": sorted({r["host"] for r in s["replicas"] if r["ghost"]}, key=str.lower),
|
||||||
|
"cpu_cores": sum(cpu) / 100 if cpu else None,
|
||||||
|
"mem_bytes": sum(mem) if mem else None,
|
||||||
|
"started_at": started, "up_s": _seconds_up(started, now),
|
||||||
|
"unhealthy": any(r.get("health") == "unhealthy" for r in reals),
|
||||||
|
"failures_24h": s.get("failures_24h"), "crash_looping": s.get("crash_looping"),
|
||||||
|
"last_error": s.get("last_error"), "trouble_since": s.get("trouble_since"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def container_trouble(c, *, stale: bool, exit_failed: bool) -> int | None:
|
||||||
|
"""How bad a standalone container is, on service_trouble's scale: 1
|
||||||
|
unhealthy or restarting, 2 died with a failing exit, 3 its report is
|
||||||
|
stale; None when nothing is wrong (a cleanly stopped one included)."""
|
||||||
|
if c.health == "unhealthy" or c.status == "restarting":
|
||||||
|
return 1
|
||||||
|
if c.status != "running" and exit_failed:
|
||||||
|
return 2
|
||||||
|
if stale:
|
||||||
|
return 3
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def container_row(c, *, label: str, host: str, usage: dict, ports: list, stale: bool,
|
||||||
|
exit_failed: bool, now: datetime) -> dict:
|
||||||
|
"""A standalone container as a table row; `usage` is service.usage()."""
|
||||||
|
running = c.status == "running"
|
||||||
|
return {
|
||||||
|
"kind": "container", "name": label, "container_name": c.name, "host": host,
|
||||||
|
"host_id": c.host_id, "stack": c.compose_project or None, "image": c.image,
|
||||||
|
"status": c.status, "health": c.health, "running": running, "ports": ports,
|
||||||
|
"usage": usage, "cpu_cores": usage.get("cores") if running else None,
|
||||||
|
"mem_bytes": usage.get("mem_bytes") if running else None,
|
||||||
|
"started_at": c.started_at if running else None,
|
||||||
|
"up_s": _seconds_up(c.started_at, now) if running else None,
|
||||||
|
"exit_code": c.exit_code, "oom_killed": c.oom_killed, "exit_failed": exit_failed,
|
||||||
|
"restart_count": c.restart_count or 0, "stale": stale, "scraped_at": c.scraped_at,
|
||||||
|
"trouble": container_trouble(c, stale=stale, exit_failed=exit_failed),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _hosts(row: dict) -> list[str]:
|
||||||
|
return row["nodes"] + row["ghost_nodes"] if row["kind"] == "service" else [row["host"]]
|
||||||
|
|
||||||
|
|
||||||
|
def _stopped(row: dict) -> bool:
|
||||||
|
return (row["running"] == 0) if row["kind"] == "service" else not row["running"]
|
||||||
|
|
||||||
|
|
||||||
|
def filter_rows(rows: list[dict], *, q: str = "", stack: str = "", host: str = "",
|
||||||
|
state: str = "") -> list[dict]:
|
||||||
|
"""Rows matching a search (name, container name, image or stack, any
|
||||||
|
case), a stack (or NO_STACK), a host or node, and a Status filter; an
|
||||||
|
empty value doesn't filter."""
|
||||||
|
q = (q or "").strip().lower()
|
||||||
|
out = []
|
||||||
|
for r in rows:
|
||||||
|
if q and not any(q in (v or "").lower() for v in
|
||||||
|
(r["name"], r.get("container_name"), r["image"], r["stack"])):
|
||||||
|
continue
|
||||||
|
if (stack == NO_STACK and r["stack"]) or (stack and stack != NO_STACK and r["stack"] != stack):
|
||||||
|
continue
|
||||||
|
if host and host not in _hosts(r):
|
||||||
|
continue
|
||||||
|
if state == "problem" and r["trouble"] is None:
|
||||||
|
continue
|
||||||
|
if state == "healthy" and (r["trouble"] is not None or _stopped(r)):
|
||||||
|
continue
|
||||||
|
if state == "stopped" and not _stopped(r):
|
||||||
|
continue
|
||||||
|
out.append(r)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def sort_rows(rows: list[dict], sort: str, desc: bool) -> list[dict]:
|
||||||
|
"""Rows in a column's order. Ties keep name order; a row with no value for
|
||||||
|
the column goes last whichever way it runs. "status" is problems first,
|
||||||
|
worst first, then running, then stopped."""
|
||||||
|
by_name = sorted(rows, key=lambda r: r["name"].lower())
|
||||||
|
if sort == "name":
|
||||||
|
return list(reversed(by_name)) if desc else by_name
|
||||||
|
if sort == "status":
|
||||||
|
return sorted(by_name, key=lambda r: (4 if r["trouble"] is None else r["trouble"],
|
||||||
|
_stopped(r)), reverse=desc)
|
||||||
|
value = {"stack": lambda r: (r["stack"] or "").lower() or None,
|
||||||
|
"cpu": lambda r: r["cpu_cores"], "mem": lambda r: r["mem_bytes"],
|
||||||
|
"up": lambda r: r["up_s"]}[sort]
|
||||||
|
have = [r for r in by_name if value(r) is not None]
|
||||||
|
return sorted(have, key=value, reverse=desc) + [r for r in by_name if value(r) is None]
|
||||||
|
|
||||||
|
|
||||||
|
def filter_options(services: list[dict], containers: list[dict]) -> dict:
|
||||||
|
"""The stack and host choices the filter bar offers, from every row."""
|
||||||
|
rows = services + containers
|
||||||
|
return {"stacks": sorted({r["stack"] for r in rows if r["stack"]}, key=str.lower),
|
||||||
|
"hosts": sorted({h for r in rows for h in _hosts(r)}, key=str.lower)}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
name: docker
|
||||||
|
version: "2.0.0"
|
||||||
|
description: "Per-host Docker container status + resource usage, collected by the host agent"
|
||||||
|
author: "Steward"
|
||||||
|
license: "MIT"
|
||||||
|
min_app_version: "0.1.0"
|
||||||
|
repository_url: "https://git.fabledsword.com/bvandeusen/Steward-plugins"
|
||||||
|
homepage: "https://git.fabledsword.com/bvandeusen/Steward-plugins/src/branch/main/docker"
|
||||||
|
tags:
|
||||||
|
- containers
|
||||||
|
- docker
|
||||||
|
- infrastructure
|
||||||
|
|
||||||
|
# No config: collection is agent-driven (the host agent reads each host's local
|
||||||
|
# socket and pushes containers to the host_agent ingest). This plugin is pure
|
||||||
|
# presentation + storage, so there's no socket path or scrape interval to tune.
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# plugins/docker/retention.py
|
||||||
|
"""Bound Docker history growth: prune old lifecycle events and log lines.
|
||||||
|
|
||||||
|
Published as the "docker.run_retention" and "docker.prune_logs" capabilities
|
||||||
|
(see __init__.setup) so the core cleanup task can drive them WITHOUT importing
|
||||||
|
the docker models (same opportunistic-coupling pattern as
|
||||||
|
docker.persist_host_samples). Each runs inside the caller's open transaction;
|
||||||
|
never opens or commits its own.
|
||||||
|
|
||||||
|
Container CPU and memory are not here: they live in the one metric store
|
||||||
|
(plugin_metrics) and its rollup and retention (#5752).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
|
||||||
|
async def run_docker_retention(
|
||||||
|
session,
|
||||||
|
*,
|
||||||
|
events_days: int,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Prune docker_events older than the events window. Returns a counts
|
||||||
|
dict for logging. The container-log ring is prune_docker_logs, run in its
|
||||||
|
own transaction."""
|
||||||
|
from datetime import timezone
|
||||||
|
|
||||||
|
from sqlalchemy import delete
|
||||||
|
|
||||||
|
from .models import DockerEvent
|
||||||
|
|
||||||
|
if now is None:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
res = await session.execute(
|
||||||
|
delete(DockerEvent).where(DockerEvent.at < now - timedelta(days=events_days))
|
||||||
|
)
|
||||||
|
return {"events_pruned": res.rowcount or 0}
|
||||||
|
|
||||||
|
|
||||||
|
async def prune_docker_logs(
|
||||||
|
session,
|
||||||
|
*,
|
||||||
|
retention_days: int,
|
||||||
|
max_bytes_per_container: int,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""The docker_logs ring (m79): drop lines older than the age window, then
|
||||||
|
keep only the newest ~cap bytes per (host, container). Runs inside the
|
||||||
|
caller's open transaction, like run_docker_retention.
|
||||||
|
|
||||||
|
Only containers at or over the cap are ranked (#5449). Ranking every line
|
||||||
|
in the table each hour to find the few containers over it sorted the whole
|
||||||
|
table on slow storage.
|
||||||
|
"""
|
||||||
|
from datetime import timezone
|
||||||
|
|
||||||
|
from sqlalchemy import and_, delete, func, select
|
||||||
|
|
||||||
|
from .models import DockerLog
|
||||||
|
|
||||||
|
if now is None:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
res = await session.execute(
|
||||||
|
delete(DockerLog).where(DockerLog.ts < now - timedelta(days=retention_days))
|
||||||
|
)
|
||||||
|
age_pruned = res.rowcount or 0
|
||||||
|
|
||||||
|
size = func.length(DockerLog.line)
|
||||||
|
over = (
|
||||||
|
select(DockerLog.host_id, DockerLog.container_name)
|
||||||
|
.group_by(DockerLog.host_id, DockerLog.container_name)
|
||||||
|
.having(func.sum(size) >= max_bytes_per_container)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
# Per container, sum line bytes newest-first; delete a row once its
|
||||||
|
# strictly-newer siblings already fill the cap. 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(size).over(
|
||||||
|
partition_by=[DockerLog.host_id, DockerLog.container_name],
|
||||||
|
order_by=[DockerLog.ts.desc(), DockerLog.id.desc()],
|
||||||
|
)
|
||||||
|
ranked = (
|
||||||
|
select(DockerLog.id, (running - size).label("prefix_excl"))
|
||||||
|
.join(over, and_(DockerLog.host_id == over.c.host_id,
|
||||||
|
DockerLog.container_name == over.c.container_name))
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
res = await session.execute(
|
||||||
|
delete(DockerLog).where(DockerLog.id.in_(
|
||||||
|
select(ranked.c.id).where(ranked.c.prefix_excl >= max_bytes_per_container)))
|
||||||
|
)
|
||||||
|
return {"logs_age_pruned": age_pruned, "logs_size_pruned": res.rowcount or 0}
|
||||||
@@ -0,0 +1,980 @@
|
|||||||
|
# plugins/docker/routes.py
|
||||||
|
from __future__ import annotations
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from quart import (
|
||||||
|
Blueprint, current_app, jsonify, redirect, render_template, request,
|
||||||
|
session, url_for,
|
||||||
|
)
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from steward.auth.middleware import require_role
|
||||||
|
from steward.models.users import UserRole
|
||||||
|
from steward.models.hosts import Host
|
||||||
|
from steward.core import anomalies as anom
|
||||||
|
from steward.core.formatting import human_bytes, human_duration, seconds_since
|
||||||
|
from steward.core.time_range import parse_range, DEFAULT_RANGE
|
||||||
|
from . import service as docker_svc
|
||||||
|
from .dedup import dedup_by_container_id
|
||||||
|
from .swarm_view import (
|
||||||
|
build_swarm_services, failing_services, parse_tasks, replaced_tasks, services_problems_first,
|
||||||
|
task_summary, trouble_since,
|
||||||
|
)
|
||||||
|
from .swarm_nodes import node_cards
|
||||||
|
from .overview import (
|
||||||
|
DOCKER_SORTS, DOCKER_STATES, NO_STACK, container_row, filter_rows, service_row, sort_rows,
|
||||||
|
stack_of,
|
||||||
|
)
|
||||||
|
from .models import (
|
||||||
|
DockerContainer, DockerDiskUsage, DockerHostStatus, DockerImage,
|
||||||
|
DockerSwarmNode, DockerSwarmService,
|
||||||
|
)
|
||||||
|
|
||||||
|
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 _uptime(started_at: datetime | None) -> str | None:
|
||||||
|
"""How long a container has run ('3d 4h'), or None if unknown."""
|
||||||
|
secs = seconds_since(started_at)
|
||||||
|
return None if secs is None or secs < 0 else human_duration(secs)
|
||||||
|
|
||||||
|
|
||||||
|
def _filters(args) -> dict:
|
||||||
|
"""The Docker page's search, filters and sort from the query string
|
||||||
|
(#5299): q, stack, host, state, sort, dir; anything unknown is dropped."""
|
||||||
|
sort = args.get("sort") if args.get("sort") in DOCKER_SORTS else "status"
|
||||||
|
desc = {"desc": True, "asc": False}.get(args.get("dir"), DOCKER_SORTS[sort])
|
||||||
|
return {"q": (args.get("q") or "").strip(), "stack": args.get("stack", ""),
|
||||||
|
"host": args.get("host", ""),
|
||||||
|
"state": args.get("state") if args.get("state") in DOCKER_STATES else "",
|
||||||
|
"sort": sort, "dir": "desc" if desc else "asc"}
|
||||||
|
|
||||||
|
|
||||||
|
def _page_url(f: dict, **change) -> str:
|
||||||
|
"""The Docker page with some filters changed; defaults are left out."""
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
want = {**f, **change}
|
||||||
|
if want["dir"] == ("desc" if DOCKER_SORTS[want["sort"]] else "asc"):
|
||||||
|
want["dir"] = ""
|
||||||
|
if want["sort"] == "status":
|
||||||
|
want["sort"] = ""
|
||||||
|
query = urlencode({k: v for k, v in want.items() if v})
|
||||||
|
return "/plugins/docker/" + ("?" + query if query else "")
|
||||||
|
|
||||||
|
|
||||||
|
def _sort_url(f: dict, key: str) -> str:
|
||||||
|
"""A column header's link: its own default direction, or the other way
|
||||||
|
when it is already the sort."""
|
||||||
|
if key == f["sort"]:
|
||||||
|
return _page_url(f, sort=key, dir="asc" if f["dir"] == "desc" else "desc")
|
||||||
|
return _page_url(f, sort=key, dir="desc" if DOCKER_SORTS[key] else "asc")
|
||||||
|
|
||||||
|
|
||||||
|
@docker_bp.get("/")
|
||||||
|
@require_role(UserRole.viewer)
|
||||||
|
async def index():
|
||||||
|
poll_interval = current_app.config.get("MONITORS_POLL_INTERVAL", 60)
|
||||||
|
# Show the Swarm link only when a manager has actually reported topology, so
|
||||||
|
# non-swarm installs aren't offered an always-empty page.
|
||||||
|
async with current_app.db_sessionmaker() as db:
|
||||||
|
has_swarm = (await db.execute(
|
||||||
|
select(DockerSwarmService.host_id).limit(1))).first() is not None
|
||||||
|
has_disk = (await db.execute(
|
||||||
|
select(DockerDiskUsage.host_id).limit(1))).first() is not None
|
||||||
|
choices = await _filter_choices(db)
|
||||||
|
# One line per host under the tables: what Docker keeps on its disk and
|
||||||
|
# how much that grew in a week (#5306); the Disk page has the rest.
|
||||||
|
disk = await docker_svc.disk_hosts(
|
||||||
|
db, datetime.now(timezone.utc) - timedelta(days=7), points=14) if has_disk else []
|
||||||
|
return await render_template(
|
||||||
|
"docker/index.html",
|
||||||
|
poll_interval=poll_interval,
|
||||||
|
has_swarm=has_swarm,
|
||||||
|
has_disk=has_disk, disk=disk,
|
||||||
|
f=_filters(request.args), choices=choices, states=DOCKER_STATES, no_stack=NO_STACK,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _filter_choices(db) -> dict:
|
||||||
|
"""The stacks and hosts the filter bar offers: every swarm service's stack
|
||||||
|
and compose project, and every host or node that runs something."""
|
||||||
|
names = set((await db.execute(select(DockerSwarmService.service_name).distinct())).scalars())
|
||||||
|
names |= {n for n in (await db.execute(
|
||||||
|
select(DockerContainer.service_name).distinct())).scalars() if n}
|
||||||
|
stacks = {stack_of(n) for n in names} | {p for p in (await db.execute(
|
||||||
|
select(DockerContainer.compose_project).distinct())).scalars() if p}
|
||||||
|
host_ids = set((await db.execute(select(DockerContainer.host_id).distinct())).scalars())
|
||||||
|
hosts = {h.name for h in (await docker_svc.host_map(db, host_ids)).values()}
|
||||||
|
hosts |= {n for n in (await db.execute(select(DockerSwarmNode.hostname).distinct())).scalars() if n}
|
||||||
|
return {"stacks": sorted((s for s in stacks if s), key=str.lower),
|
||||||
|
"hosts": sorted(hosts, key=str.lower)}
|
||||||
|
|
||||||
|
|
||||||
|
async def _swarm_context(db, now: datetime) -> dict:
|
||||||
|
"""What the Docker page and a service's page both build from: containers,
|
||||||
|
swarm services and nodes, host names, and the swarm view (each service with
|
||||||
|
its current replicas, Swarm's task records and their failures)."""
|
||||||
|
containers = await docker_svc.list_containers(db)
|
||||||
|
services = list((await db.execute(select(DockerSwarmService))).scalars())
|
||||||
|
nodes = list((await db.execute(select(DockerSwarmNode))).scalars())
|
||||||
|
hosts = await docker_svc.host_map(
|
||||||
|
db, {c.host_id for c in containers} | {n.host_id for n in nodes}
|
||||||
|
| {s.host_id for s in services})
|
||||||
|
node_hostname = {n.node_id: n.hostname for n in nodes if n.hostname}
|
||||||
|
host_name = {h.id: h.name for h in hosts.values()}
|
||||||
|
swarm_view = build_swarm_services(
|
||||||
|
containers, services, node_hostname, host_name, now=now,
|
||||||
|
crash_loop_deaths=docker_svc.crash_loop_deaths(current_app.config))
|
||||||
|
return {"containers": containers, "services": services, "nodes": nodes, "hosts": hosts,
|
||||||
|
"node_hostname": node_hostname, "host_name": host_name, "swarm_view": swarm_view}
|
||||||
|
|
||||||
|
|
||||||
|
@docker_bp.get("/rows")
|
||||||
|
@require_role(UserRole.viewer)
|
||||||
|
async def rows():
|
||||||
|
"""HTMX fragment: a verdict line and the problems that aren't zero, what
|
||||||
|
needs attention, what failed in 24h (each with its logs), then the swarm
|
||||||
|
services and the standalone containers as two tables that share the
|
||||||
|
filter bar's search, filters and sort (#5298–#5301)."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
stale_after = docker_svc.stale_after_seconds(current_app.config)
|
||||||
|
f = _filters(request.args)
|
||||||
|
|
||||||
|
async with current_app.db_sessionmaker() as db:
|
||||||
|
# Hosts whose Docker is not answering: their rows are what it last said.
|
||||||
|
docker_down = {d.host_id: d for d in (await db.execute(
|
||||||
|
select(DockerHostStatus).where(DockerHostStatus.status != "ok"))).scalars()}
|
||||||
|
ranked = await docker_svc.ranked_containers(
|
||||||
|
db, now, docker_svc.crash_loop_deaths(current_app.config))
|
||||||
|
ctx = await _swarm_context(db, now)
|
||||||
|
containers, hosts = ctx["containers"], ctx["hosts"]
|
||||||
|
hosts.update(await docker_svc.host_map(db, set(docker_down) - set(hosts)))
|
||||||
|
host_name = {h.id: h.name for h in hosts.values()}
|
||||||
|
swarm_view = ctx["swarm_view"]
|
||||||
|
capacity = await docker_svc.host_capacity(db, {c.host_id for c in containers})
|
||||||
|
norms = await docker_svc.container_norms(
|
||||||
|
db, {(c.host_id, c.name) for c in containers if not c.service_name}, now)
|
||||||
|
failures = docker_svc.failures_24h(
|
||||||
|
await docker_svc.failure_events(db, now - timedelta(hours=24)),
|
||||||
|
swarm_view["services"], host_name)
|
||||||
|
|
||||||
|
thresholds = current_app.config.get("THRESHOLDS", {})
|
||||||
|
service_rows = [service_row(s, now) for s in swarm_view["services"]]
|
||||||
|
standalone = [c for c in containers if not c.service_name]
|
||||||
|
container_rows = [
|
||||||
|
container_row(
|
||||||
|
c, label=docker_svc.display_name(c), host=host_name.get(c.host_id, c.host_id),
|
||||||
|
usage=docker_svc.usage(c, *capacity.get(c.host_id, (None, None)), thresholds=thresholds,
|
||||||
|
norms=norms.get((c.host_id, c.name))),
|
||||||
|
ports=json.loads(c.ports_json) if c.ports_json else [],
|
||||||
|
stale=_is_stale(c, now, stale_after, docker_down),
|
||||||
|
exit_failed=docker_svc.exit_is_failure(c.exit_code, c.oom_killed), now=now)
|
||||||
|
for c in standalone]
|
||||||
|
desc = f["dir"] == "desc"
|
||||||
|
shown_services = sort_rows(filter_rows(service_rows, q=f["q"], stack=f["stack"], host=f["host"],
|
||||||
|
state=f["state"]), f["sort"], desc)
|
||||||
|
shown_containers = sort_rows(filter_rows(container_rows, q=f["q"], stack=f["stack"],
|
||||||
|
host=f["host"], state=f["state"]), f["sort"], desc)
|
||||||
|
|
||||||
|
failing = failing_services(swarm_view["services"])
|
||||||
|
attention = [dict(r, stale=_is_stale(r["container"], now, stale_after, docker_down),
|
||||||
|
label=docker_svc.task_label(r["container"], ctx["node_hostname"]))
|
||||||
|
for r in docker_svc.needs_attention(ranked)]
|
||||||
|
running = sum(r["running"] for r in container_rows)
|
||||||
|
return await render_template(
|
||||||
|
"docker/rows.html",
|
||||||
|
services=shown_services, containers=shown_containers,
|
||||||
|
services_total=len(service_rows), containers_total=len(container_rows),
|
||||||
|
services_healthy=sum(s["state"] == "healthy" for s in service_rows),
|
||||||
|
failing_services=failing, failures=failures,
|
||||||
|
docker_hosts=len({c.host_id for c in containers}),
|
||||||
|
docker_down=sorted(
|
||||||
|
({"host_id": h, "host_name": hosts[h].name if h in hosts else h, "status": d.status,
|
||||||
|
"detail": d.detail, "since": d.since} for h, d in docker_down.items()),
|
||||||
|
key=lambda d: d["host_name"].lower()),
|
||||||
|
attention=attention, says=docker_svc.PROBLEM_SAYS, severity=docker_svc.PROBLEM_SEVERITY,
|
||||||
|
unhealthy=sum("unhealthy" in r["problems"] for r in attention),
|
||||||
|
restarting=sum(bool({"restarting", "crash_looping"} & set(r["problems"])) for r in attention),
|
||||||
|
stale=sum(r["stale"] for r in container_rows),
|
||||||
|
running=running, stopped=len(container_rows) - running,
|
||||||
|
f=f, sort_url=lambda key: _sort_url(f, key), page_url=lambda **kw: _page_url(f, **kw),
|
||||||
|
filtered=any(f[k] for k in ("q", "stack", "host", "state")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_stale(c, now: datetime, stale_after: int, docker_down=()) -> bool:
|
||||||
|
"""A container whose last report is older than the agent's staleness
|
||||||
|
window, or whose host's Docker is not answering: what the page shows may
|
||||||
|
no longer be true."""
|
||||||
|
return (c.host_id in docker_down or c.scraped_at is None
|
||||||
|
or (now - c.scraped_at).total_seconds() > stale_after)
|
||||||
|
|
||||||
|
|
||||||
|
@docker_bp.get("/widget")
|
||||||
|
@require_role(UserRole.viewer)
|
||||||
|
async def widget():
|
||||||
|
"""HTMX dashboard widget: swarm services and containers, problems first,
|
||||||
|
with how many things failed in the last 24h (linked to the list)."""
|
||||||
|
show_stopped = request.args.get("show_stopped", "no") == "yes"
|
||||||
|
widget_id = request.args.get("wid", "0")
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
async with current_app.db_sessionmaker() as db:
|
||||||
|
all_containers = dedup_by_container_id(await docker_svc.list_containers(db))
|
||||||
|
services = list((await db.execute(select(DockerSwarmService))).scalars())
|
||||||
|
nodes = list((await db.execute(select(DockerSwarmNode))).scalars())
|
||||||
|
events = await docker_svc.failure_events(db, now - timedelta(hours=24))
|
||||||
|
hosts = await docker_svc.host_map(
|
||||||
|
db,
|
||||||
|
{c.host_id for c in all_containers}
|
||||||
|
| {n.host_id for n in nodes} | {s.host_id for s in services},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Swarm tasks collapse into per-service rows (each replica tagged with its
|
||||||
|
# host); non-swarm containers stay grouped by host.
|
||||||
|
node_hostname = {n.node_id: n.hostname for n in nodes if n.hostname}
|
||||||
|
host_name = {h.id: h.name for h in hosts.values()}
|
||||||
|
swarm_services = services_problems_first(build_swarm_services(
|
||||||
|
all_containers, services, node_hostname, host_name,
|
||||||
|
crash_loop_deaths=docker_svc.crash_loop_deaths(current_app.config))["services"])
|
||||||
|
# The dashboard shows what is wrong now: a service back to all its
|
||||||
|
# replicas has its failed tasks on the Docker page, not here (#5722).
|
||||||
|
still = {s["name"] for s in swarm_services if s["state"] != "healthy" or s.get("crash_looping")}
|
||||||
|
failures = [f for f in docker_svc.failures_24h(events, swarm_services, host_name)
|
||||||
|
if f["kind"] != "service" or f["name"] in still]
|
||||||
|
|
||||||
|
standalone = [c for c in all_containers if not c.service_name]
|
||||||
|
running = [c for c in standalone if c.status == "running"]
|
||||||
|
|
||||||
|
def troubled(c) -> bool:
|
||||||
|
return ((c.status or "") == "restarting" or c.health == "unhealthy"
|
||||||
|
or (c.status != "running" and docker_svc.exit_is_failure(c.exit_code, c.oom_killed)))
|
||||||
|
|
||||||
|
# The widget-shape order (UI & Design ruling): what is wrong first, then
|
||||||
|
# the healthy rest one line each, collapsing past HEALTHY_SHOWN.
|
||||||
|
bad_containers = sorted((c for c in standalone if troubled(c)), key=lambda c: c.name)
|
||||||
|
healthy = ([{"kind": "service", "s": s} for s in swarm_services if s["state"] == "healthy"]
|
||||||
|
+ [{"kind": "container", "c": c} for c in sorted(running, key=lambda c: c.name)
|
||||||
|
if not troubled(c)]
|
||||||
|
+ ([{"kind": "stopped", "c": c} for c in sorted(standalone, key=lambda c: c.name)
|
||||||
|
if c.status != "running" and not troubled(c)] if show_stopped else []))
|
||||||
|
|
||||||
|
return await render_template(
|
||||||
|
"docker/widget.html",
|
||||||
|
bad_services=[s for s in swarm_services if s["state"] != "healthy"],
|
||||||
|
bad_containers=bad_containers,
|
||||||
|
healthy=healthy[:HEALTHY_SHOWN], healthy_more=max(0, len(healthy) - HEALTHY_SHOWN),
|
||||||
|
services_total=len(swarm_services),
|
||||||
|
services_healthy=sum(s["state"] == "healthy" for s in swarm_services),
|
||||||
|
running_count=len(running),
|
||||||
|
failures=failures,
|
||||||
|
host_name=host_name,
|
||||||
|
total_count=len(all_containers) + len(swarm_services),
|
||||||
|
widget_id=widget_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Healthy services and containers listed on the dashboard widget before the
|
||||||
|
# rest collapse to "+N healthy"; the Docker page lists them all.
|
||||||
|
HEALTHY_SHOWN = 40
|
||||||
|
FAILURES_SHOWN = 5
|
||||||
|
|
||||||
|
|
||||||
|
CPU_AVERAGE_MINUTES = 5
|
||||||
|
|
||||||
|
|
||||||
|
@docker_bp.get("/widget/resources")
|
||||||
|
@require_role(UserRole.viewer)
|
||||||
|
async def widget_resources():
|
||||||
|
"""HTMX dashboard widget: the containers using the most CPU, averaged over
|
||||||
|
the last few minutes so the order holds still between polls, with their
|
||||||
|
CPU in cores and memory in bytes, each beside bars of the last few hours
|
||||||
|
scaled to the row's own peak (never below a floor, #5214)."""
|
||||||
|
limit = max(1, min(20, _arg_number("limit", 10, int)))
|
||||||
|
hours = max(1, min(24, _arg_number("hours", 6, int)))
|
||||||
|
cpu_floor = max(0.1, _arg_number("cpu_floor", 1.0, float))
|
||||||
|
mem_floor = max(0.1, _arg_number("mem_floor", 1.0, float))
|
||||||
|
widget_id = request.args.get("wid", "0")
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
since = now - timedelta(minutes=CPU_AVERAGE_MINUTES)
|
||||||
|
slots, width = docker_svc.bar_slots(now, hours)
|
||||||
|
|
||||||
|
async with current_app.db_sessionmaker() as db:
|
||||||
|
# Dedup before the limit, else swarm tasks reported by both managers can
|
||||||
|
# fill the list with duplicates of the same container.
|
||||||
|
running = dedup_by_container_id([
|
||||||
|
c for c in await docker_svc.list_containers(db) if c.status == "running"])
|
||||||
|
average = await docker_svc.average_cpu(db, {(c.host_id, c.name) for c in running}, since)
|
||||||
|
|
||||||
|
def cpu_of(c):
|
||||||
|
v = average.get((c.host_id, c.name))
|
||||||
|
return c.cpu_pct if v is None else float(v)
|
||||||
|
|
||||||
|
busiest = sorted(running, key=lambda c: (-(cpu_of(c) or 0), c.name))[:limit]
|
||||||
|
history = await docker_svc.recent_usage(
|
||||||
|
db, {(c.host_id, c.name) for c in busiest}, slots[0], width)
|
||||||
|
hosts = await docker_svc.host_map(db, {c.host_id for c in busiest})
|
||||||
|
capacity = await docker_svc.host_capacity(db, {c.host_id for c in busiest})
|
||||||
|
norms = await docker_svc.container_norms(db, {(c.host_id, c.name) for c in busiest})
|
||||||
|
thresholds = current_app.config.get("THRESHOLDS", {})
|
||||||
|
|
||||||
|
def bars(c, i, scale, floor):
|
||||||
|
seen = history.get((c.host_id, c.name), {})
|
||||||
|
values = [None if seen.get(t) is None or seen[t][i] is None else seen[t][i] * scale
|
||||||
|
for t in slots]
|
||||||
|
return docker_svc.scaled_bars(values, floor)
|
||||||
|
|
||||||
|
rows_data = [{
|
||||||
|
"c": c,
|
||||||
|
"label": docker_svc.display_name(c),
|
||||||
|
"host_name": hosts[c.host_id].name if c.host_id in hosts else c.host_id,
|
||||||
|
"usage": docker_svc.usage(c, *capacity.get(c.host_id, (None, None)), cpu_pct=cpu_of(c),
|
||||||
|
thresholds=thresholds, norms=norms.get((c.host_id, c.name))),
|
||||||
|
"cpu_bars": bars(c, 0, 0.01, cpu_floor), # cpu_pct → cores
|
||||||
|
"mem_bars": bars(c, 1, 1.0, mem_floor * 2**30), # bytes; floor in GiB
|
||||||
|
} for c in busiest]
|
||||||
|
return await render_template(
|
||||||
|
"docker/widget_resources.html",
|
||||||
|
rows=rows_data,
|
||||||
|
minutes=CPU_AVERAGE_MINUTES,
|
||||||
|
hours=hours, cpu_floor=cpu_floor, mem_floor=mem_floor * 2**30,
|
||||||
|
widget_id=widget_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _arg_number(key, default, kind):
|
||||||
|
"""A numeric query argument, or `default` when missing or not a number."""
|
||||||
|
try:
|
||||||
|
return kind(request.args.get(key, default))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
@docker_bp.get("/host/<host_id>")
|
||||||
|
@require_role(UserRole.viewer)
|
||||||
|
async def host_panel(host_id: str):
|
||||||
|
"""Per-host Docker fragment embedded on the core Hosts hub via HTMX:
|
||||||
|
problems first (with exit code and since when), then running, then
|
||||||
|
stopped. Swarm tasks Swarm has already replaced are history and left out.
|
||||||
|
|
||||||
|
Renders nothing when the host reports no containers, so hosts without Docker
|
||||||
|
don't carry an empty card.
|
||||||
|
"""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
async with current_app.db_sessionmaker() as db:
|
||||||
|
containers = await docker_svc.list_containers(db, host_id)
|
||||||
|
if not containers:
|
||||||
|
return ""
|
||||||
|
services = list((await db.execute(select(DockerSwarmService))).scalars())
|
||||||
|
nodes = list((await db.execute(select(DockerSwarmNode))).scalars())
|
||||||
|
failures = await docker_svc.recent_failures_by_container(db, now - timedelta(hours=24))
|
||||||
|
capacity = await docker_svc.host_capacity(db, {host_id})
|
||||||
|
norms = await docker_svc.container_norms(db, {(c.host_id, c.name) for c in containers}, now)
|
||||||
|
|
||||||
|
thresholds = current_app.config.get("THRESHOLDS", {})
|
||||||
|
replaced = replaced_tasks(containers, services)
|
||||||
|
node_hostname = {n.node_id: n.hostname for n in nodes if n.hostname}
|
||||||
|
hour_ago = now - timedelta(hours=1)
|
||||||
|
crash = docker_svc.crash_loop_deaths(current_app.config)
|
||||||
|
rows = []
|
||||||
|
for c in containers:
|
||||||
|
if (c.host_id, c.name) in replaced:
|
||||||
|
continue
|
||||||
|
events = failures.get((c.host_id, c.name), [])
|
||||||
|
recent = sum(1 for e in events if e.at >= hour_ago)
|
||||||
|
problems = docker_svc.container_problems(c, recent, bool(events), crash)
|
||||||
|
rows.append({
|
||||||
|
"c": c, "label": docker_svc.task_label(c, node_hostname), "problems": problems,
|
||||||
|
"since": docker_svc.problem_since(problems, events, hour_ago),
|
||||||
|
"usage": docker_svc.usage(c, *capacity.get(host_id, (None, None)), thresholds=thresholds,
|
||||||
|
norms=norms.get((c.host_id, c.name))),
|
||||||
|
"uptime": _uptime(c.started_at) if c.status == "running" else None,
|
||||||
|
})
|
||||||
|
shown, folded = host_panel_rows(rows)
|
||||||
|
running = sum(1 for r in rows if r["c"].status == "running")
|
||||||
|
return await render_template(
|
||||||
|
"docker/host_panel.html",
|
||||||
|
rows=shown, folded=folded, says=docker_svc.PROBLEM_SAYS, severity=docker_svc.PROBLEM_SEVERITY,
|
||||||
|
running=running, stopped=len(rows) - running,
|
||||||
|
problems=sum(1 for r in rows if r["problems"]),
|
||||||
|
replaced=len(replaced), busiest=HOST_PANEL_BUSIEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Running containers shown before the rest fold away on a host's page (#5276).
|
||||||
|
HOST_PANEL_BUSIEST = 8
|
||||||
|
|
||||||
|
|
||||||
|
def host_panel_rows(rows: list) -> tuple[list, list]:
|
||||||
|
"""(shown, folded) for a host's Docker panel: every container with a
|
||||||
|
problem, worst first; then the busiest running ones by CPU. The other
|
||||||
|
running containers (busiest first) and the stopped ones (by name) fold
|
||||||
|
away, so a host with dozens of containers keeps a short card."""
|
||||||
|
def cpu(r):
|
||||||
|
return getattr(r["c"], "cpu_pct", None) or 0.0
|
||||||
|
problems = sorted((r for r in rows if r["problems"]), key=lambda r: (
|
||||||
|
docker_svc.PROBLEMS.index(r["problems"][0]), r["label"].lower()))
|
||||||
|
running = sorted((r for r in rows if not r["problems"] and r["c"].status == "running"),
|
||||||
|
key=lambda r: (-cpu(r), r["label"].lower()))
|
||||||
|
stopped = sorted((r for r in rows if not r["problems"] and r["c"].status != "running"),
|
||||||
|
key=lambda r: r["label"].lower())
|
||||||
|
return (problems + running[:HOST_PANEL_BUSIEST],
|
||||||
|
running[HOST_PANEL_BUSIEST:] + stopped)
|
||||||
|
|
||||||
|
|
||||||
|
# Glyph + tone per lifecycle event, so the timeline reads at a glance.
|
||||||
|
_EVENT_STYLE = {
|
||||||
|
"start": ("▲", "ok"),
|
||||||
|
"restart": ("↻", "warn"),
|
||||||
|
"stop": ("■", "muted"),
|
||||||
|
"die": ("✕", "crit"),
|
||||||
|
"oom": ("☠", "crit"),
|
||||||
|
"health_change": ("◐", "warn"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@docker_bp.get("/container/<host_id>/<name>")
|
||||||
|
@require_role(UserRole.viewer)
|
||||||
|
async def container_detail(host_id: str, name: str):
|
||||||
|
"""One container (#5310): a header that says whether it is well and what it
|
||||||
|
is, why it stopped only while it has, what it moved since it started, its
|
||||||
|
CPU and memory over time, and its lifecycle."""
|
||||||
|
async with current_app.db_sessionmaker() as db:
|
||||||
|
c = await db.get(DockerContainer, (host_id, name))
|
||||||
|
host = await db.get(Host, host_id)
|
||||||
|
events = await docker_svc.container_events(db, host_id, name) if c is not None else []
|
||||||
|
node_name = None
|
||||||
|
if c is not None and c.node_id:
|
||||||
|
node_name = (await db.execute(
|
||||||
|
select(DockerSwarmNode.hostname)
|
||||||
|
.where(DockerSwarmNode.node_id == c.node_id, DockerSwarmNode.hostname != "")
|
||||||
|
.limit(1)
|
||||||
|
)).scalar()
|
||||||
|
cpus, mem_total = (await docker_svc.host_capacity(db, [host_id])).get(host_id, (None, None)) \
|
||||||
|
if c is not None else (None, None)
|
||||||
|
# Its CPU and memory against their normal, and its anomalies (#5742).
|
||||||
|
norms = (await docker_svc.container_norms(db, {(host_id, name)})).get((host_id, name))
|
||||||
|
resource = docker_svc.container_resource(host.name, name) if host is not None else None
|
||||||
|
anomalies = await anom.views(db, await anom.list_anomalies(
|
||||||
|
db, source="docker", resource=resource,
|
||||||
|
since=datetime.now(timezone.utc) - timedelta(days=30), limit=20),
|
||||||
|
links=False) if resource else []
|
||||||
|
|
||||||
|
if c is None:
|
||||||
|
return await render_template(
|
||||||
|
"docker/container_detail.html",
|
||||||
|
container=None, host_id=host_id, name=name,
|
||||||
|
), 404
|
||||||
|
|
||||||
|
return await render_template(
|
||||||
|
"docker/container_detail.html",
|
||||||
|
container=c,
|
||||||
|
host=host,
|
||||||
|
host_id=host_id,
|
||||||
|
name=name,
|
||||||
|
label=docker_svc.task_label(c, {c.node_id: node_name} if node_name else None),
|
||||||
|
node_name=node_name,
|
||||||
|
usage=docker_svc.usage(c, cpus, mem_total, thresholds=current_app.config.get("THRESHOLDS", {}),
|
||||||
|
norms=norms),
|
||||||
|
anomalies=anomalies, anomaly_back=f"/plugins/docker/container/{host_id}/{name}",
|
||||||
|
anomaly_resource=resource,
|
||||||
|
exit_failed=docker_svc.exit_is_failure(c.exit_code, c.oom_killed),
|
||||||
|
ports=json.loads(c.ports_json) if c.ports_json else [],
|
||||||
|
uptime=_uptime(c.started_at) if c.status == "running" else None,
|
||||||
|
net_rx=human_bytes(c.net_rx_bytes), net_tx=human_bytes(c.net_tx_bytes),
|
||||||
|
blk_read=human_bytes(c.blk_read_bytes), blk_write=human_bytes(c.blk_write_bytes),
|
||||||
|
events=[
|
||||||
|
{"event": e.event, "detail": e.detail, "at": e.at,
|
||||||
|
"glyph": _EVENT_STYLE.get(e.event, ("•", "muted"))[0],
|
||||||
|
"tone": _EVENT_STYLE.get(e.event, ("•", "muted"))[1]}
|
||||||
|
for e in events
|
||||||
|
],
|
||||||
|
died_at=next((e.at for e in events if e.event in ("die", "oom")), None),
|
||||||
|
current_range=request.args.get("range", DEFAULT_RANGE),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@docker_bp.get("/swarm")
|
||||||
|
@require_role(UserRole.viewer)
|
||||||
|
async def swarm():
|
||||||
|
"""The Swarm page, laid out by node (#5303): each node's role, whether it
|
||||||
|
takes work, what its containers use against what the machine has, and the
|
||||||
|
services placed on it; services short of replicas first.
|
||||||
|
|
||||||
|
Swarm services/nodes are cluster-global — every manager's API returns the
|
||||||
|
same list — but each manager reports them independently (rows keyed by
|
||||||
|
host_id). So we group the reporting managers into swarms (managers that share
|
||||||
|
any node_id are the same cluster) and dedup within each: one row per service
|
||||||
|
(by name) and per node (by node_id), keeping the freshest. Without this, two
|
||||||
|
managers in one cluster list every service/node twice.
|
||||||
|
"""
|
||||||
|
async with current_app.db_sessionmaker() as db:
|
||||||
|
services = list((await db.execute(
|
||||||
|
select(DockerSwarmService).order_by(DockerSwarmService.service_name)
|
||||||
|
)).scalars())
|
||||||
|
nodes = list((await db.execute(
|
||||||
|
select(DockerSwarmNode).order_by(
|
||||||
|
DockerSwarmNode.role, DockerSwarmNode.hostname)
|
||||||
|
)).scalars())
|
||||||
|
hosts = await docker_svc.host_map(db, {s.host_id for s in services} | {n.host_id for n in nodes})
|
||||||
|
containers = await docker_svc.list_containers(db)
|
||||||
|
# The Steward host on each node: the one whose agent reports its
|
||||||
|
# containers, else the host named as the node is.
|
||||||
|
node_host = {c.node_id: c.host_id for c in containers if c.node_id}
|
||||||
|
unmatched = {(n.hostname or "").lower() for n in nodes if n.node_id not in node_host} - {""}
|
||||||
|
if unmatched:
|
||||||
|
by_name = {h.name.lower(): h for h in (await db.execute(
|
||||||
|
select(Host).where(func.lower(Host.name).in_(unmatched)))).scalars()}
|
||||||
|
for n in nodes:
|
||||||
|
h = by_name.get((n.hostname or "").lower())
|
||||||
|
if n.node_id not in node_host and h is not None:
|
||||||
|
node_host[n.node_id] = h.id
|
||||||
|
hosts.update(await docker_svc.host_map(db, set(node_host.values()) - set(hosts)))
|
||||||
|
capacity = await docker_svc.host_capacity(db, set(node_host.values()))
|
||||||
|
|
||||||
|
# ── Group reporting managers into swarms by shared node membership ──────────
|
||||||
|
manager_ids = {s.host_id for s in services} | {n.host_id for n in nodes}
|
||||||
|
nodes_by_mgr: dict[str, set[str]] = {}
|
||||||
|
for n in nodes:
|
||||||
|
nodes_by_mgr.setdefault(n.host_id, set()).add(n.node_id)
|
||||||
|
|
||||||
|
parent = {m: m for m in manager_ids}
|
||||||
|
|
||||||
|
def _find(x: str) -> str:
|
||||||
|
while parent[x] != x:
|
||||||
|
parent[x] = parent[parent[x]]
|
||||||
|
x = parent[x]
|
||||||
|
return x
|
||||||
|
|
||||||
|
mgrs = list(manager_ids)
|
||||||
|
for i in range(len(mgrs)):
|
||||||
|
for j in range(i + 1, len(mgrs)):
|
||||||
|
a, b = mgrs[i], mgrs[j]
|
||||||
|
if nodes_by_mgr.get(a) and nodes_by_mgr.get(b) and (nodes_by_mgr[a] & nodes_by_mgr[b]):
|
||||||
|
parent[_find(a)] = _find(b)
|
||||||
|
|
||||||
|
swarm_members: dict[str, set[str]] = {}
|
||||||
|
for m in manager_ids:
|
||||||
|
swarm_members.setdefault(_find(m), set()).add(m)
|
||||||
|
|
||||||
|
# ── Dedup services (by name) + nodes (by node_id) within each swarm ─────────
|
||||||
|
swarms = []
|
||||||
|
for members in swarm_members.values():
|
||||||
|
svc_by_name: dict[str, DockerSwarmService] = {}
|
||||||
|
for s in services:
|
||||||
|
if s.host_id in members and (
|
||||||
|
s.service_name not in svc_by_name
|
||||||
|
or s.updated_at > svc_by_name[s.service_name].updated_at
|
||||||
|
):
|
||||||
|
svc_by_name[s.service_name] = s
|
||||||
|
node_by_id: dict[str, DockerSwarmNode] = {}
|
||||||
|
for n in nodes:
|
||||||
|
if n.host_id in members and (
|
||||||
|
n.node_id not in node_by_id
|
||||||
|
or n.updated_at > node_by_id[n.node_id].updated_at
|
||||||
|
):
|
||||||
|
node_by_id[n.node_id] = n
|
||||||
|
|
||||||
|
node_names = {nid: (nrow.hostname or nid) for nid, nrow in node_by_id.items()}
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
deaths = docker_svc.crash_loop_deaths(current_app.config)
|
||||||
|
svc_dicts = []
|
||||||
|
for s in svc_by_name.values():
|
||||||
|
tasks = parse_tasks(s)
|
||||||
|
summary = task_summary(tasks, now, deaths, node_names)
|
||||||
|
state = ("healthy" if s.running >= s.desired and s.desired > 0
|
||||||
|
else "degraded" if s.running > 0 else "down")
|
||||||
|
placement = []
|
||||||
|
for p in (json.loads(s.placement_json) if s.placement_json else []):
|
||||||
|
nid = p.get("node_id", "")
|
||||||
|
placement.append({
|
||||||
|
"node": node_names.get(nid, nid[:12] or "?"),
|
||||||
|
"running": p.get("running", 0),
|
||||||
|
})
|
||||||
|
svc_dicts.append({
|
||||||
|
"name": s.service_name, "mode": s.mode,
|
||||||
|
"running": s.running, "desired": s.desired, "image": s.image,
|
||||||
|
"healthy": s.running >= s.desired and s.desired > 0,
|
||||||
|
"degraded": 0 < s.running < s.desired,
|
||||||
|
"down": s.running == 0 and s.desired > 0,
|
||||||
|
"state": state,
|
||||||
|
"placement": placement,
|
||||||
|
**summary,
|
||||||
|
"trouble_since": trouble_since(tasks, state, summary["crash_looping"], now),
|
||||||
|
})
|
||||||
|
manager_names = sorted(hosts[m].name if m in hosts else m for m in members)
|
||||||
|
swarms.append({
|
||||||
|
"managers": manager_names,
|
||||||
|
"services_total": len(svc_dicts),
|
||||||
|
"services_healthy": sum(d["state"] == "healthy" for d in svc_dicts),
|
||||||
|
"troubled": failing_services(svc_dicts),
|
||||||
|
"nodes": node_cards(
|
||||||
|
node_by_id.values(), svc_by_name.values(), containers, node_host=node_host,
|
||||||
|
host_names={h.id: h.name for h in hosts.values()}, capacity=capacity,
|
||||||
|
service_state={d["name"]: d["state"] for d in svc_dicts}),
|
||||||
|
})
|
||||||
|
|
||||||
|
swarms.sort(key=lambda g: g["managers"][0].lower() if g["managers"] else "")
|
||||||
|
return await render_template("docker/swarm.html", swarms=swarms)
|
||||||
|
|
||||||
|
|
||||||
|
# How many images a host agent reports, largest first (host_agent agent.py
|
||||||
|
# _disk_images caps its list here); a list this long may be missing smaller ones.
|
||||||
|
IMAGES_REPORTED = 50
|
||||||
|
|
||||||
|
|
||||||
|
@docker_bp.get("/disk")
|
||||||
|
@require_role(UserRole.viewer)
|
||||||
|
async def disk():
|
||||||
|
"""What Docker keeps on each host's disk (#5304, #5305): the hosts side by
|
||||||
|
side with how much each grew over the selected range (the /disk/hosts
|
||||||
|
fragment), then each host's parts, cleanup and images by repository.
|
||||||
|
|
||||||
|
Admins can reclaim space per host via the prune buttons, which fire the
|
||||||
|
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
|
||||||
|
from .disk_view import group_images
|
||||||
|
|
||||||
|
async with current_app.db_sessionmaker() as db:
|
||||||
|
summaries = list((await db.execute(select(DockerDiskUsage))).scalars())
|
||||||
|
images = list((await db.execute(
|
||||||
|
select(DockerImage).order_by(DockerImage.size.desc())
|
||||||
|
)).scalars())
|
||||||
|
# Stopped-container count per host, surfaced alongside reclaimable space.
|
||||||
|
stopped_rows = (await db.execute(
|
||||||
|
select(DockerContainer.host_id)
|
||||||
|
.where(DockerContainer.status != "running")
|
||||||
|
)).scalars()
|
||||||
|
stopped_by_host: dict[str, int] = {}
|
||||||
|
for hid in stopped_rows:
|
||||||
|
stopped_by_host[hid] = stopped_by_host.get(hid, 0) + 1
|
||||||
|
hosts = await docker_svc.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] = {}
|
||||||
|
for im in images:
|
||||||
|
images_by_host.setdefault(im.host_id, []).append({
|
||||||
|
"repo_tag": im.repo_tag, "image_id": im.image_id, "size": im.size,
|
||||||
|
"shared_size": im.shared_size, "containers": im.containers})
|
||||||
|
|
||||||
|
host_groups = [
|
||||||
|
{
|
||||||
|
"host_id": s.host_id,
|
||||||
|
"host_name": hosts[s.host_id].name if s.host_id in hosts else s.host_id,
|
||||||
|
"as_of": s.updated_at,
|
||||||
|
"summary": s,
|
||||||
|
"total": s.images_size + s.containers_size + s.volumes_size + s.build_cache_size,
|
||||||
|
"stopped": stopped_by_host.get(s.host_id, 0),
|
||||||
|
"repos": group_images(images_by_host.get(s.host_id, [])),
|
||||||
|
"images_listed": len(images_by_host.get(s.host_id, [])),
|
||||||
|
# The agent reports the largest IMAGES_REPORTED images (agent._disk_images).
|
||||||
|
"images_capped": len(images_by_host.get(s.host_id, [])) >= IMAGES_REPORTED,
|
||||||
|
"has_target": s.host_id in target_by_host,
|
||||||
|
}
|
||||||
|
for s in summaries
|
||||||
|
]
|
||||||
|
host_groups.sort(key=lambda g: (-g["total"], g["host_name"].lower()))
|
||||||
|
return await render_template(
|
||||||
|
"docker/disk.html", host_groups=host_groups,
|
||||||
|
ansible_available=has_capability("ansible.run_playbook"),
|
||||||
|
current_range=request.args.get("range", "7d"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@docker_bp.get("/disk/hosts")
|
||||||
|
@require_role(UserRole.viewer)
|
||||||
|
async def disk_hosts_fragment():
|
||||||
|
"""HTMX fragment: every Docker host's disk side by side, with how much its
|
||||||
|
total grew over the selected range and a line of it (#5304)."""
|
||||||
|
from steward.core.status import sparkline_svg
|
||||||
|
since, range_key = parse_range(request.args.get("range") or "7d")
|
||||||
|
async with current_app.db_sessionmaker() as db:
|
||||||
|
rows = await docker_svc.disk_hosts(db, since)
|
||||||
|
for r in rows:
|
||||||
|
r["spark"] = sparkline_svg([v for _, v in r["points"]], width=120, height=24,
|
||||||
|
stroke="currentColor")
|
||||||
|
return await render_template("docker/_disk_hosts.html", rows=rows, range_key=range_key)
|
||||||
|
|
||||||
|
|
||||||
|
@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 docker_svc.PRUNE_TARGETS:
|
||||||
|
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 = docker_svc.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")
|
||||||
|
@require_role(UserRole.viewer)
|
||||||
|
async def container_history(host_id: str, name: str):
|
||||||
|
"""HTMX fragment: the container's CPU (cores) and memory over the
|
||||||
|
selected range, with the latest and the peak (#5310)."""
|
||||||
|
since, range_key = parse_range(request.args.get("range"))
|
||||||
|
async with current_app.db_sessionmaker() as db:
|
||||||
|
series = await docker_svc.service_history(db, [(host_id, name)], since)
|
||||||
|
return await _usage_history(series, range_key)
|
||||||
|
|
||||||
|
|
||||||
|
# A history line is drawn against at least this much, so a container idling
|
||||||
|
# near zero draws flat (UI & Design ruling 8, #5310).
|
||||||
|
HISTORY_CPU_FLOOR = 1.0 # cores
|
||||||
|
HISTORY_MEM_FLOOR = 1 << 30 # bytes
|
||||||
|
|
||||||
|
|
||||||
|
async def _usage_history(series: list, range_key: str):
|
||||||
|
"""The CPU and memory history fragment both detail pages load: the latest
|
||||||
|
and the peak over a line that spans its half, scaled with a floor."""
|
||||||
|
from steward.core.status import sparkline_svg
|
||||||
|
cpu = [c / 100 for _, c, _ in series]
|
||||||
|
mem = [m for _, _, m in series]
|
||||||
|
return await render_template(
|
||||||
|
"docker/_usage_history.html", have_data=len(series) >= 2, range_key=range_key,
|
||||||
|
cpu_now=cpu[-1] if cpu else None, cpu_peak=max(cpu) if cpu else None,
|
||||||
|
mem_now=mem[-1] if mem else None, mem_peak=max(mem) if mem else None,
|
||||||
|
cpu_floor=HISTORY_CPU_FLOOR, mem_floor=HISTORY_MEM_FLOOR,
|
||||||
|
spark_cpu=sparkline_svg(cpu, height=48, stroke="currentColor", fill_width=True,
|
||||||
|
floor=HISTORY_CPU_FLOOR),
|
||||||
|
spark_mem=sparkline_svg(mem, height=48, stroke="currentColor", fill_width=True,
|
||||||
|
floor=HISTORY_MEM_FLOOR),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
|
||||||
|
@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 docker_svc.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"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── A swarm service's own page (#5302) ───────────────────────────────────────
|
||||||
|
|
||||||
|
def _task_label(slot: str, task_id: str) -> str:
|
||||||
|
"""How a task reads beside its log lines: its slot and the start of its id."""
|
||||||
|
return f"{slot} · {task_id[:10]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _task_rows(tasks: list[dict], node_hostname: dict) -> list[dict]:
|
||||||
|
"""Swarm's records of a service's newest tasks, newest first, as the page
|
||||||
|
lists them: where each ran, what state it reached, and why it ended."""
|
||||||
|
from .swarm_view import FAILED_TASK_STATES, task_time
|
||||||
|
out = []
|
||||||
|
for t in tasks:
|
||||||
|
nid = t.get("node_id") or ""
|
||||||
|
out.append({
|
||||||
|
"task_id": t.get("task_id") or "", "slot": str(t.get("slot") or ""), "node_id": nid,
|
||||||
|
"node": node_hostname.get(nid) or (nid[:12] if nid else "—"),
|
||||||
|
"state": t.get("state") or "unknown", "desired": t.get("desired_state") or "",
|
||||||
|
"exit_code": t.get("exit_code"), "why": t.get("error") or t.get("message") or "",
|
||||||
|
"at": task_time(t), "failed": t.get("state") in FAILED_TASK_STATES,
|
||||||
|
"current": t.get("state") == "running" and t.get("desired_state") == "running",
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _service_tasks(name: str, tasks: list[dict], replicas: list[dict],
|
||||||
|
node_host: dict, host_name: dict) -> list[dict]:
|
||||||
|
"""One table for a service's tasks (#5310): the running ones first, each
|
||||||
|
with what its container uses, then Swarm's record of the rest, newest
|
||||||
|
first. A node reads as its Steward host's name wherever one is known, so a
|
||||||
|
node is spelled one way down the table. A running replica Swarm's record
|
||||||
|
does not list (an agent before 1.8.0) still gets a row, and so does a node
|
||||||
|
running tasks with no Steward agent on it."""
|
||||||
|
live, unmatched = {}, []
|
||||||
|
for r in (r for r in replicas if not r["ghost"]):
|
||||||
|
slot, tid = docker_svc.task_of(name, r["name"]) or ("", "")
|
||||||
|
tid = r.get("task_id") or tid # the agent's own record of it, else its name's
|
||||||
|
if tid:
|
||||||
|
live[tid] = (slot, r)
|
||||||
|
else:
|
||||||
|
unmatched.append(r)
|
||||||
|
|
||||||
|
def place(node_id, host_id, fallback):
|
||||||
|
hid = host_id or node_host.get(node_id)
|
||||||
|
return hid, (host_name.get(hid) if hid else None) or fallback
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for t in tasks:
|
||||||
|
_, r = live.pop(t["task_id"], (None, None))
|
||||||
|
hid, node = place(t["node_id"], r["host_id"] if r else None, t["node"])
|
||||||
|
rows.append(dict(t, node=node, host_id=hid, container=r["name"] if r else None,
|
||||||
|
usage=r["usage"] if r else None, health=r["health"] if r else None,
|
||||||
|
at=(r["started_at"] or t["at"]) if r and t["current"] else t["at"]))
|
||||||
|
for tid, (slot, r) in [*live.items(), *(("", ("", r)) for r in unmatched)]:
|
||||||
|
rows.append({"task_id": tid, "slot": slot, "node_id": "", "node": r["host"],
|
||||||
|
"host_id": r["host_id"], "state": r["status"], "desired": "",
|
||||||
|
"exit_code": None, "why": "", "at": r["started_at"], "failed": False,
|
||||||
|
"current": (r["status"] or "").lower() == "running", "container": r["name"],
|
||||||
|
"usage": r["usage"], "health": r["health"]})
|
||||||
|
listed = {t["node"] for t in tasks if t["current"]}
|
||||||
|
for g in (r for r in replicas if r["ghost"] and r["host"] not in listed):
|
||||||
|
rows.append({"task_id": "", "slot": "", "node_id": "", "node": g["host"], "host_id": None,
|
||||||
|
"state": "running", "desired": "", "exit_code": None,
|
||||||
|
"why": f"{g['count']} running on a node with no Steward agent: no figures",
|
||||||
|
"at": None, "failed": False, "current": True, "container": None,
|
||||||
|
"usage": None, "health": None})
|
||||||
|
return sorted(rows, key=lambda r: not r["current"]) # stable: newest first within each
|
||||||
|
|
||||||
|
|
||||||
|
@docker_bp.get("/service/<name>")
|
||||||
|
@require_role(UserRole.viewer)
|
||||||
|
async def service_detail(name: str):
|
||||||
|
"""One swarm service: where its replicas run and what they use, its image,
|
||||||
|
Swarm's record of its recent tasks, its CPU and memory over time across
|
||||||
|
every replica, and the logs of every task it has run — newest first, each
|
||||||
|
line marked with its task. `?task=` opens the logs on one task, which is
|
||||||
|
where a failure's "Logs →" lands."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
async with current_app.db_sessionmaker() as db:
|
||||||
|
ctx = await _swarm_context(db, now)
|
||||||
|
task_containers = await docker_svc.service_containers(db, name)
|
||||||
|
s = next((s for s in ctx["swarm_view"]["services"] if s["name"] == name), None)
|
||||||
|
capacity = await docker_svc.host_capacity(
|
||||||
|
db, {r["host_id"] for r in (s["replicas"] if s else []) if not r["ghost"]})
|
||||||
|
norms = await docker_svc.container_norms(
|
||||||
|
db, {(r["host_id"], r["name"]) for r in (s["replicas"] if s else []) if not r["ghost"]}, now)
|
||||||
|
if s is None and not task_containers:
|
||||||
|
return await render_template("docker/service.html", name=name, service=None), 404
|
||||||
|
|
||||||
|
newest = max((v for v in ctx["services"] if v.service_name == name),
|
||||||
|
key=lambda v: v.updated_at, default=None)
|
||||||
|
tasks = _task_rows(parse_tasks(newest) if newest is not None else [], ctx["node_hostname"])
|
||||||
|
by_key = {(c.host_id, c.name): c for c in ctx["containers"]}
|
||||||
|
replicas = []
|
||||||
|
for r in (s["replicas"] if s else []):
|
||||||
|
c = None if r["ghost"] else by_key.get((r["host_id"], r["name"]))
|
||||||
|
replicas.append(dict(r, task_id=getattr(c, "task_id", None) if c is not None else None,
|
||||||
|
usage=docker_svc.usage(c, *capacity.get(r["host_id"], (None, None)),
|
||||||
|
thresholds=current_app.config.get("THRESHOLDS", {}),
|
||||||
|
norms=norms.get((c.host_id, c.name)))
|
||||||
|
if c is not None else None))
|
||||||
|
node_host = {c.node_id: c.host_id for c in ctx["containers"] if c.node_id}
|
||||||
|
table = _service_tasks(name, tasks, replicas, node_host, ctx["host_name"])
|
||||||
|
# Every task Steward holds logs or events for, newest known first.
|
||||||
|
known = {t["task_id"]: _task_label(t["slot"], t["task_id"]) for t in tasks if t["task_id"]}
|
||||||
|
for _, cname in task_containers:
|
||||||
|
slot, tid = docker_svc.task_of(name, cname)
|
||||||
|
known.setdefault(tid, _task_label(slot, tid))
|
||||||
|
focus = request.args.get("task", "")
|
||||||
|
return await render_template(
|
||||||
|
"docker/service.html", name=name, service=s,
|
||||||
|
row=service_row(s, now) if s else None, svc=newest, tasks=table,
|
||||||
|
task_choices=list(known.items()), focus_task=focus if focus in known else "",
|
||||||
|
stack=stack_of(name), current_range=request.args.get("range", DEFAULT_RANGE),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@docker_bp.get("/service/<name>/history")
|
||||||
|
@require_role(UserRole.viewer)
|
||||||
|
async def service_history_fragment(name: str):
|
||||||
|
"""HTMX fragment: the service's CPU (cores) and memory, summed across its
|
||||||
|
replicas, over the selected range, with the latest and the peak."""
|
||||||
|
since, range_key = parse_range(request.args.get("range"))
|
||||||
|
async with current_app.db_sessionmaker() as db:
|
||||||
|
series = await docker_svc.service_history(
|
||||||
|
db, await docker_svc.service_containers(db, name), since)
|
||||||
|
return await _usage_history(series, range_key)
|
||||||
|
|
||||||
|
|
||||||
|
@docker_bp.get("/service/<name>/logs/lines")
|
||||||
|
@require_role(UserRole.viewer)
|
||||||
|
async def service_logs_lines(name: str):
|
||||||
|
"""HTMX fragment: the newest retained lines across every task of the
|
||||||
|
service, stream, text and task filtered, each marked with its task."""
|
||||||
|
stream = request.args.get("stream", "all")
|
||||||
|
query = (request.args.get("q") or "").strip()
|
||||||
|
task = request.args.get("task") or None
|
||||||
|
async with current_app.db_sessionmaker() as db:
|
||||||
|
rows = await docker_svc.query_service_logs(
|
||||||
|
db, await docker_svc.service_containers(db, name), stream, query, _LOG_TAIL_DEFAULT,
|
||||||
|
task=task)
|
||||||
|
lines = []
|
||||||
|
for r in rows:
|
||||||
|
slot, tid = docker_svc.task_of(name, r.container_name) or ("?", r.container_name)
|
||||||
|
lines.append({"ts": r.ts, "stream": r.stream, "line": r.line, "task": _task_label(slot, tid)})
|
||||||
|
return await render_template(
|
||||||
|
"docker/_container_logs_lines.html", lines=lines, name=name, tail=_LOG_TAIL_DEFAULT,
|
||||||
|
filtered=bool(query) or bool(task) or stream in ("stdout", "stderr"), service=True,
|
||||||
|
)
|
||||||
@@ -0,0 +1,750 @@
|
|||||||
|
"""Docker queries shared by the plugin's pages and its MCP tools.
|
||||||
|
|
||||||
|
Each function takes an open session and returns rows or plain data, with no
|
||||||
|
Quart request context, so the routes and the /mcp tools (which run outside
|
||||||
|
Quart's pipeline) call the same code.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from steward.core import metric_series
|
||||||
|
from steward.models.hosts import Host
|
||||||
|
from steward.models.metrics import PluginMetric
|
||||||
|
|
||||||
|
from .dedup import dedup_by_container_id
|
||||||
|
from .models import (
|
||||||
|
DockerContainer, DockerEvent, DockerLog, DockerSwarmService,
|
||||||
|
)
|
||||||
|
from .swarm_view import replaced_tasks
|
||||||
|
|
||||||
|
|
||||||
|
CRASH_LOOP_DEATHS = 3 # default; Settings → Thresholds sets it
|
||||||
|
|
||||||
|
|
||||||
|
def crash_loop_deaths(config) -> int:
|
||||||
|
"""Deaths within an hour that make a container or a swarm service a crash
|
||||||
|
loop, from Settings → Thresholds (app.config["THRESHOLDS"])."""
|
||||||
|
th = (config.get("THRESHOLDS") or {}).get("crash_loop") or {}
|
||||||
|
return int(th.get("warn") or CRASH_LOOP_DEATHS)
|
||||||
|
|
||||||
|
|
||||||
|
# Exit codes a deliberate stop leaves: SIGTERM (143), and SIGKILL (137) when
|
||||||
|
# the process outlives the stop timeout. Without an OOM kill they are someone
|
||||||
|
# stopping the container, not the container failing.
|
||||||
|
STOP_EXITS = (137, 143)
|
||||||
|
|
||||||
|
|
||||||
|
def exit_is_failure(exit_code, oom_killed: bool = False) -> bool:
|
||||||
|
"""Whether a container's exit is a failure: an OOM kill, or a non-zero
|
||||||
|
exit other than a stop's."""
|
||||||
|
return bool(oom_killed) or exit_code not in (None, 0, *STOP_EXITS)
|
||||||
|
|
||||||
|
|
||||||
|
def _event_exit(detail) -> int | None:
|
||||||
|
"""The exit code in a die event's detail ("exit 1"), or None."""
|
||||||
|
text = (detail or "").strip()
|
||||||
|
if text.startswith("exit "):
|
||||||
|
try:
|
||||||
|
return int(text[5:])
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def event_is_failure(e) -> bool:
|
||||||
|
"""Whether a die/oom event is a failure. An oom always is; a die is unless
|
||||||
|
its exit is a stop's (or clean)."""
|
||||||
|
if e.event == "oom":
|
||||||
|
return True
|
||||||
|
return e.event == "die" and exit_is_failure(_event_exit(e.detail))
|
||||||
|
|
||||||
|
|
||||||
|
def failures_24h(events, swarm_services, host_name: dict) -> list[dict]:
|
||||||
|
"""What failed in the last 24h, newest first, for the dashboard count and
|
||||||
|
the Docker page's list of it: one row per plain container that died or was
|
||||||
|
OOM-killed, and one per swarm service with failed tasks.
|
||||||
|
|
||||||
|
`events` are die/oom events of the last 24h. A swarm task's container dies
|
||||||
|
whenever Swarm replaces it, so a service with Swarm's task records counts
|
||||||
|
its failed tasks instead of its containers' deaths; a service without them
|
||||||
|
(an agent older than 1.8.0) falls back to its tasks' deaths.
|
||||||
|
|
||||||
|
Each row: {kind: "container"|"service", name, host_id, host, count, at,
|
||||||
|
detail, task_id}; task_id is the failed swarm task, so its logs can be
|
||||||
|
found (None for a container, or a service without task records).
|
||||||
|
"""
|
||||||
|
by_prefix = {s["name"] + ".": s for s in swarm_services}
|
||||||
|
rows: dict[tuple, dict] = {}
|
||||||
|
# Each manager reports every swarm task, so one task's death can arrive
|
||||||
|
# once per manager: a service counts each dead task container once.
|
||||||
|
task_deaths: dict[tuple, set] = {}
|
||||||
|
for e in sorted(events, key=lambda e: e.at, reverse=True):
|
||||||
|
if not event_is_failure(e):
|
||||||
|
continue
|
||||||
|
svc = next((s for p, s in by_prefix.items() if e.container_name.startswith(p)), None)
|
||||||
|
if svc is not None and svc["tasks_known"]:
|
||||||
|
continue
|
||||||
|
kind, name = ("service", svc["name"]) if svc else ("container", e.container_name)
|
||||||
|
key = (kind, name) if svc else (kind, e.host_id, name)
|
||||||
|
if svc is not None:
|
||||||
|
seen = task_deaths.setdefault(key, set())
|
||||||
|
if e.container_name in seen:
|
||||||
|
continue
|
||||||
|
seen.add(e.container_name)
|
||||||
|
row = rows.get(key)
|
||||||
|
if row is None:
|
||||||
|
rows[key] = {"kind": kind, "name": name, "host_id": e.host_id,
|
||||||
|
"host": host_name.get(e.host_id, e.host_id), "count": 1, "at": e.at,
|
||||||
|
"detail": "OOM-killed" if e.event == "oom" else e.detail,
|
||||||
|
"task_id": (task_of(svc["name"], e.container_name) or (None, None))[1]
|
||||||
|
if svc else None}
|
||||||
|
else:
|
||||||
|
row["count"] += 1
|
||||||
|
from .swarm_view import task_time
|
||||||
|
for s in swarm_services:
|
||||||
|
if s["tasks_known"] and s["failures_24h"]:
|
||||||
|
err = s["last_error"] or {}
|
||||||
|
rows[("service", s["name"])] = {
|
||||||
|
"kind": "service", "name": s["name"], "host_id": None, "host": err.get("node"),
|
||||||
|
"count": s["failures_24h"], "at": task_time(err) if err else None,
|
||||||
|
"detail": err.get("error") or err.get("message") or err.get("state"),
|
||||||
|
"task_id": err.get("task_id")}
|
||||||
|
epoch = datetime.min.replace(tzinfo=timezone.utc)
|
||||||
|
return sorted(rows.values(), key=lambda r: r["at"] or epoch, reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
def task_label(c, node_hostname: dict | None = None) -> str:
|
||||||
|
"""How a container reads to a person. A swarm task is its service and
|
||||||
|
slot: "codex_db · 1" for a replica, "agent · node-3" for a global
|
||||||
|
service's task on a node. Anything else as display_name reads it."""
|
||||||
|
from .swarm_view import _slot_key
|
||||||
|
svc = getattr(c, "service_name", None)
|
||||||
|
if not svc:
|
||||||
|
return display_name(c)
|
||||||
|
slot = _slot_key(c)
|
||||||
|
if slot.isdigit():
|
||||||
|
return f"{svc} · {slot}"
|
||||||
|
host = (node_hostname or {}).get(slot) or (node_hostname or {}).get(getattr(c, "node_id", None))
|
||||||
|
return f"{svc} · {host}" if host else svc
|
||||||
|
|
||||||
|
|
||||||
|
_CI_JOB = re.compile(
|
||||||
|
r"^(?:GITEA|FORGEJO)-ACTIONS-TASK-\d+-WORKFLOW-(?P<workflow>.+?)-JOB-(?P<job>.+?)(?:-[0-9a-f]{16,})?$")
|
||||||
|
# docker buildx's container driver names its node "buildx_buildkit_<builder><n>".
|
||||||
|
_BUILDKIT = re.compile(r"^buildx_buildkit_(?P<builder>.+?)\d*$")
|
||||||
|
|
||||||
|
|
||||||
|
def display_name(c) -> str:
|
||||||
|
"""A container's name as a list can show it in one short line (#5253): a
|
||||||
|
swarm task by its service (the host says where it runs), a Gitea or
|
||||||
|
Forgejo Actions job container as "CI: <workflow> · <job>", a buildx
|
||||||
|
builder as "BuildKit builder · <name>", anything else by its own name."""
|
||||||
|
svc = getattr(c, "service_name", None)
|
||||||
|
if svc:
|
||||||
|
return svc
|
||||||
|
m = _CI_JOB.match(c.name or "")
|
||||||
|
if m:
|
||||||
|
return f"CI: {m['workflow']} · {m['job']}"
|
||||||
|
m = _BUILDKIT.match(c.name or "")
|
||||||
|
if m:
|
||||||
|
return f"BuildKit builder · {m['builder']}"
|
||||||
|
return c.name
|
||||||
|
|
||||||
|
|
||||||
|
def usage(c, cpus: int | None, mem_total: int | None, cpu_pct: float | None = None, *,
|
||||||
|
thresholds: dict | None = None, norms: dict | None = None) -> dict:
|
||||||
|
"""A container's CPU and memory in terms a reader can weigh.
|
||||||
|
|
||||||
|
Docker's CPU% is per core (100% = one core busy), so it is shown as cores
|
||||||
|
and graded as a share of the host's CPUs when the host's core count is
|
||||||
|
known. Docker's memory limit is the host's RAM when none is set, so memory
|
||||||
|
is shown in bytes, against a limit only when one is actually set; mem_pct
|
||||||
|
is then a share of that limit, otherwise of the host's memory. `cpu_pct`
|
||||||
|
replaces the container's last sample (an average, say) when given.
|
||||||
|
|
||||||
|
With `thresholds`, cpu_grade and mem_grade are the grades to show: past
|
||||||
|
the cutoff but within the container's own normal for the hour (`norms`,
|
||||||
|
{metric: Norm}, container_norms) is "ok", and cpu_usual / mem_usual say
|
||||||
|
what is usual (#5742). Without, they are None and the page grades.
|
||||||
|
"""
|
||||||
|
cpu = getattr(c, "cpu_pct", None) if cpu_pct is None else cpu_pct
|
||||||
|
limit = getattr(c, "mem_limit_bytes", None)
|
||||||
|
limited = bool(limit and mem_total and limit < mem_total * 0.95)
|
||||||
|
out = {
|
||||||
|
"cores": None if cpu is None else cpu / 100.0,
|
||||||
|
"cpu_share": None if cpu is None or not cpus else cpu / cpus,
|
||||||
|
"cpus": cpus,
|
||||||
|
"mem_bytes": getattr(c, "mem_usage_bytes", None),
|
||||||
|
"mem_limit": limit if limited else None,
|
||||||
|
"mem_pct": getattr(c, "mem_pct", None),
|
||||||
|
"mem_of": "its limit" if limited else ("the host's memory" if mem_total else None),
|
||||||
|
"cpu_grade": None, "cpu_usual": None, "mem_grade": None, "mem_usual": None,
|
||||||
|
}
|
||||||
|
if thresholds is not None:
|
||||||
|
_grade(out, cpu, thresholds, norms or {})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# Container figures graded against their own normal (#5742): the metric that
|
||||||
|
# learns it and its floor's setting (norms.floor.*).
|
||||||
|
NORMED = {"cpu": ("cpu_pct", "container_cpu_pct"), "mem": ("mem_pct", "container_mem_pct")}
|
||||||
|
|
||||||
|
|
||||||
|
def _grade(u: dict, cpu: float | None, thresholds: dict, norms: dict) -> None:
|
||||||
|
"""Fill usage()'s grades. CPU is graded as a share of the host but learned
|
||||||
|
per core (Docker's own figure), so its normal is read in cores."""
|
||||||
|
from steward.core.anomalies import departs, graded
|
||||||
|
from steward.core.formatting import status_class
|
||||||
|
from steward.core.settings import floor_of
|
||||||
|
grade = status_class(u["cpu_share"], "cpu", thresholds)
|
||||||
|
norm = norms.get("cpu_pct")
|
||||||
|
if grade in ("warn", "crit") and norm is not None and cpu is not None \
|
||||||
|
and not departs(cpu, norm, floor_of(thresholds, "container_cpu_pct")):
|
||||||
|
grade = "ok"
|
||||||
|
u["cpu_usual"] = f"usual {norm.usual_hold / 100:.2f} cores at this hour"
|
||||||
|
u["cpu_grade"] = grade
|
||||||
|
if u["mem_of"]:
|
||||||
|
u["mem_grade"], u["mem_usual"] = graded(
|
||||||
|
u["mem_pct"], "mem", thresholds, norms.get("mem_pct"),
|
||||||
|
floor_of(thresholds, "container_mem_pct"), "%")
|
||||||
|
else:
|
||||||
|
u["mem_grade"] = "dim"
|
||||||
|
|
||||||
|
|
||||||
|
async def container_norms(db, keys, now: datetime | None = None) -> dict:
|
||||||
|
"""{(host_id, container_name): {metric: Norm}}: each container's CPU and
|
||||||
|
memory normals for this hour. One without enough history is absent."""
|
||||||
|
from steward.core.norms import norms_at
|
||||||
|
resources = await container_resources(db, keys)
|
||||||
|
got = await norms_at(db, [("docker", r, metric) for r in resources
|
||||||
|
for metric, _f in NORMED.values()],
|
||||||
|
now or datetime.now(timezone.utc))
|
||||||
|
out: dict = {}
|
||||||
|
for (_s, resource, metric), norm in got.items():
|
||||||
|
out.setdefault(resources[resource], {})[metric] = norm
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def host_capacity(db, host_ids) -> dict[str, tuple[int | None, int | None]]:
|
||||||
|
"""{host_id: (CPU cores, memory bytes)} from the host agent's latest
|
||||||
|
readings: cores counted from its per-core CPU series, memory from
|
||||||
|
available ÷ (1 − used%). (None, None) for a host without an agent."""
|
||||||
|
from sqlalchemy import or_
|
||||||
|
from steward.models.metrics import PluginMetricLatest
|
||||||
|
hosts = await host_map(db, set(host_ids))
|
||||||
|
if not hosts:
|
||||||
|
return {}
|
||||||
|
names = {h.name: hid for hid, h in hosts.items()}
|
||||||
|
rows = (await db.execute(
|
||||||
|
select(PluginMetricLatest.resource_name, PluginMetricLatest.metric_name,
|
||||||
|
PluginMetricLatest.value)
|
||||||
|
.where(PluginMetricLatest.source_module == "host_agent",
|
||||||
|
or_(PluginMetricLatest.resource_name.in_(list(names)),
|
||||||
|
*(PluginMetricLatest.resource_name.like(escape_like(n) + ":core%", escape="\\")
|
||||||
|
for n in names)))
|
||||||
|
)).all()
|
||||||
|
cores: dict[str, int] = {}
|
||||||
|
mem: dict[str, dict] = {}
|
||||||
|
for res, metric, value in rows:
|
||||||
|
if res in names:
|
||||||
|
mem.setdefault(res, {})[metric] = value
|
||||||
|
elif metric == "cpu_pct":
|
||||||
|
host = res.rsplit(":core", 1)[0]
|
||||||
|
cores[host] = cores.get(host, 0) + 1
|
||||||
|
out = {}
|
||||||
|
for name, hid in names.items():
|
||||||
|
m = mem.get(name, {})
|
||||||
|
avail, used = m.get("mem_available_bytes"), m.get("mem_used_pct")
|
||||||
|
total = int(avail / (1 - used / 100)) if avail and used is not None and used < 99.5 else None
|
||||||
|
out[hid] = (cores.get(name), total)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def host_map(db, host_ids: set[str]) -> dict[str, Host]:
|
||||||
|
if not host_ids:
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
h.id: h for h in (await db.execute(
|
||||||
|
select(Host).where(Host.id.in_(host_ids)))).scalars().all()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def escape_like(text: str) -> str:
|
||||||
|
"""Make `text` match literally inside a LIKE/ILIKE pattern (escape char \\)."""
|
||||||
|
return text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||||
|
|
||||||
|
|
||||||
|
async def list_containers(db, host_id: str | None = None) -> list[DockerContainer]:
|
||||||
|
"""Containers, running first then by name; one host's when host_id is given."""
|
||||||
|
stmt = select(DockerContainer)
|
||||||
|
if host_id is not None:
|
||||||
|
stmt = stmt.where(DockerContainer.host_id == host_id)
|
||||||
|
stmt = stmt.order_by((DockerContainer.status == "running").desc(), DockerContainer.name)
|
||||||
|
return list((await db.execute(stmt)).scalars())
|
||||||
|
|
||||||
|
|
||||||
|
async def container_events(db, host_id: str, name: str, limit: int = 50) -> list[DockerEvent]:
|
||||||
|
"""A container's lifecycle events (start/stop/die/oom/health), newest first."""
|
||||||
|
return list((await db.execute(
|
||||||
|
select(DockerEvent)
|
||||||
|
.where(DockerEvent.host_id == host_id)
|
||||||
|
.where(DockerEvent.container_name == name)
|
||||||
|
.order_by(DockerEvent.at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)).scalars())
|
||||||
|
|
||||||
|
|
||||||
|
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"%{escape_like(query)}%", escape="\\"))
|
||||||
|
stmt = stmt.order_by(DockerLog.ts.desc(), DockerLog.id.desc()).limit(limit)
|
||||||
|
return (await db.execute(stmt)).all()
|
||||||
|
|
||||||
|
|
||||||
|
CONTAINER_SERIES = ("cpu_pct", "mem_pct", "mem_usage_bytes")
|
||||||
|
|
||||||
|
|
||||||
|
# A container's series live in the one metric store (#5752): source "docker",
|
||||||
|
# resource "<host name>/<container name>", recorded at ingest.
|
||||||
|
def container_resource(host_name: str, name: str) -> str:
|
||||||
|
return f"{host_name}/{name}"
|
||||||
|
|
||||||
|
|
||||||
|
async def container_resources(db, keys) -> dict[str, tuple[str, str]]:
|
||||||
|
"""{resource: (host_id, container_name)} for (host_id, container_name)
|
||||||
|
keys whose host still exists."""
|
||||||
|
keys = set(keys)
|
||||||
|
hosts = await host_map(db, {h for h, _ in keys})
|
||||||
|
return {container_resource(hosts[h].name, n): (h, n) for h, n in keys if h in hosts}
|
||||||
|
|
||||||
|
|
||||||
|
async def container_points(db, host_id: str, name: str, metric: str, since,
|
||||||
|
target: int = 120) -> list[tuple]:
|
||||||
|
"""(time, average) points of one container metric since `since`, oldest
|
||||||
|
first: raw samples bucket-averaged, and the hourly rollup for the part of
|
||||||
|
the range past the raw window (metric_series.points)."""
|
||||||
|
if metric not in CONTAINER_SERIES:
|
||||||
|
raise ValueError(f"metric must be one of {', '.join(CONTAINER_SERIES)}")
|
||||||
|
host = await db.get(Host, host_id)
|
||||||
|
if host is None:
|
||||||
|
return []
|
||||||
|
return await metric_series.points(db, "docker", container_resource(host.name, name),
|
||||||
|
metric, since, target=target)
|
||||||
|
|
||||||
|
|
||||||
|
async def _samples(db, resources, metrics, since: datetime, width: int | None = None):
|
||||||
|
"""Rows of (resource, time, metric, value) for the given containers since
|
||||||
|
`since`: each sample, or with `width` (seconds) each bucket's average."""
|
||||||
|
at = PluginMetric.recorded_at
|
||||||
|
if width:
|
||||||
|
at = func.date_bin(func.make_interval(0, 0, 0, 0, 0, 0, int(width)),
|
||||||
|
PluginMetric.recorded_at, func.to_timestamp(0))
|
||||||
|
value = func.avg(PluginMetric.value) if width else PluginMetric.value
|
||||||
|
q = (select(PluginMetric.resource_name, at.label("at"), PluginMetric.metric_name, value)
|
||||||
|
.where(PluginMetric.source_module == "docker",
|
||||||
|
PluginMetric.resource_name.in_(list(resources)),
|
||||||
|
PluginMetric.metric_name.in_(list(metrics)),
|
||||||
|
PluginMetric.recorded_at >= since))
|
||||||
|
if width:
|
||||||
|
q = q.group_by(PluginMetric.resource_name, at, PluginMetric.metric_name)
|
||||||
|
return (await db.execute(q.order_by(at))).all()
|
||||||
|
|
||||||
|
|
||||||
|
async def average_cpu(db, keys, since: datetime) -> dict[tuple[str, str], float]:
|
||||||
|
"""{(host_id, container_name): its average cpu_pct since `since`}."""
|
||||||
|
resources = await container_resources(db, keys)
|
||||||
|
if not resources:
|
||||||
|
return {}
|
||||||
|
rows = (await db.execute(
|
||||||
|
select(PluginMetric.resource_name, func.avg(PluginMetric.value))
|
||||||
|
.where(PluginMetric.source_module == "docker",
|
||||||
|
PluginMetric.resource_name.in_(list(resources)),
|
||||||
|
PluginMetric.metric_name == "cpu_pct",
|
||||||
|
PluginMetric.recorded_at >= since)
|
||||||
|
.group_by(PluginMetric.resource_name))).all()
|
||||||
|
return {resources[r]: float(v) for r, v in rows if v is not None}
|
||||||
|
|
||||||
|
|
||||||
|
# Busiest Containers bars (#5214): the recent past in BARS equal slices.
|
||||||
|
BARS = 24
|
||||||
|
|
||||||
|
|
||||||
|
def bar_slots(now: datetime, hours: int, bars: int = BARS) -> tuple[list[datetime], int]:
|
||||||
|
"""The start of each of the last `bars` buckets covering `hours`, oldest
|
||||||
|
first, aligned as date_bin aligns them (to the epoch), and their width in
|
||||||
|
seconds. The newest bucket is the one `now` falls in."""
|
||||||
|
width = max(60, hours * 3600 // bars)
|
||||||
|
last = int(now.timestamp()) // width * width
|
||||||
|
return [datetime.fromtimestamp(last - (bars - 1 - i) * width, timezone.utc)
|
||||||
|
for i in range(bars)], width
|
||||||
|
|
||||||
|
|
||||||
|
async def recent_usage(db, keys, since: datetime, width: int) -> dict:
|
||||||
|
"""{(host_id, container_name): {bucket start: (cpu_pct, mem_usage_bytes)}}
|
||||||
|
averaged per bucket since `since`, for the given containers, in one query."""
|
||||||
|
resources = await container_resources(db, keys)
|
||||||
|
if not resources:
|
||||||
|
return {}
|
||||||
|
out: dict = {}
|
||||||
|
for resource, at, metric, value in await _samples(
|
||||||
|
db, resources, ("cpu_pct", "mem_usage_bytes"), since, width):
|
||||||
|
cpu, mem = out.setdefault(resources[resource], {}).get(at, (None, None))
|
||||||
|
v = None if value is None else float(value)
|
||||||
|
out[resources[resource]][at] = (v, mem) if metric == "cpu_pct" else (cpu, v)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def scaled_bars(values: list, floor: float) -> dict:
|
||||||
|
"""Bar heights in % of a roof: the row's own peak, but never below `floor`,
|
||||||
|
so a container idling at 0.05 cores draws flat and its 0.05 → 0.10 wobble
|
||||||
|
doesn't look like a 1.5 → 3 core spike. None (no samples) stays a gap."""
|
||||||
|
present = [v for v in values if v is not None]
|
||||||
|
peak = max(present) if present else None
|
||||||
|
roof = max(peak or 0.0, floor)
|
||||||
|
return {"bars": [None if v is None else round(100.0 * v / roof, 1) for v in values],
|
||||||
|
"peak": peak, "roof": roof}
|
||||||
|
|
||||||
|
|
||||||
|
async def search_logs(db, *, since, host_id: str | None = None, name: str | None = None,
|
||||||
|
containers: list[tuple[str, str]] | None = None,
|
||||||
|
text: str = "", stream: str = "", before=None, limit: int = 50) -> list:
|
||||||
|
"""Retained log lines matching a filter, newest first (limit + 1 fetched by callers
|
||||||
|
that need to know whether more exist). `containers` keeps the lines of those
|
||||||
|
(host_id, container_name) pairs: a swarm service's tasks (#5468)."""
|
||||||
|
from sqlalchemy import tuple_
|
||||||
|
stmt = (select(DockerLog.id, DockerLog.host_id, DockerLog.container_name,
|
||||||
|
DockerLog.ts, DockerLog.stream, DockerLog.line)
|
||||||
|
.where(DockerLog.ts >= since))
|
||||||
|
if host_id:
|
||||||
|
stmt = stmt.where(DockerLog.host_id == host_id)
|
||||||
|
if name:
|
||||||
|
stmt = stmt.where(DockerLog.container_name == name)
|
||||||
|
if containers is not None:
|
||||||
|
if not containers:
|
||||||
|
return []
|
||||||
|
stmt = stmt.where(tuple_(DockerLog.host_id, DockerLog.container_name).in_(containers))
|
||||||
|
if stream in ("stdout", "stderr"):
|
||||||
|
stmt = stmt.where(DockerLog.stream == stream)
|
||||||
|
if text:
|
||||||
|
stmt = stmt.where(DockerLog.line.ilike(f"%{escape_like(text)}%", escape="\\"))
|
||||||
|
if before is not None:
|
||||||
|
stmt = stmt.where(DockerLog.ts < before)
|
||||||
|
stmt = stmt.order_by(DockerLog.ts.desc(), DockerLog.id.desc()).limit(limit)
|
||||||
|
return list((await db.execute(stmt)).all())
|
||||||
|
|
||||||
|
|
||||||
|
async def log_context(db, host_id: str, name: str, ts, n: int) -> tuple[list, list]:
|
||||||
|
"""Up to n lines either side of `ts` in one container's log, each oldest first."""
|
||||||
|
cols = (DockerLog.id, DockerLog.ts, DockerLog.stream, DockerLog.line)
|
||||||
|
base = select(*cols).where(DockerLog.host_id == host_id, DockerLog.container_name == name)
|
||||||
|
before = (await db.execute(
|
||||||
|
base.where(DockerLog.ts < ts).order_by(DockerLog.ts.desc(), DockerLog.id.desc()).limit(n)
|
||||||
|
)).all()
|
||||||
|
after = (await db.execute(
|
||||||
|
base.where(DockerLog.ts > ts).order_by(DockerLog.ts, DockerLog.id).limit(n)
|
||||||
|
)).all()
|
||||||
|
return list(reversed(before)), list(after)
|
||||||
|
|
||||||
|
|
||||||
|
async def recent_failures_by_container(db, since) -> dict[tuple[str, str], list]:
|
||||||
|
"""{(host_id, container_name): [die/oom events since `since`, newest first]},
|
||||||
|
leaving out a die that was a stop (event_is_failure)."""
|
||||||
|
rows = (await db.execute(
|
||||||
|
select(DockerEvent)
|
||||||
|
.where(DockerEvent.event.in_(("die", "oom")), DockerEvent.at >= since)
|
||||||
|
.order_by(DockerEvent.at.desc())
|
||||||
|
)).scalars()
|
||||||
|
out: dict[tuple[str, str], list] = {}
|
||||||
|
for e in rows:
|
||||||
|
if event_is_failure(e):
|
||||||
|
out.setdefault((e.host_id, e.container_name), []).append(e)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def failure_events(db, since) -> list:
|
||||||
|
"""Die and OOM events since `since`, newest first; failures_24h() decides
|
||||||
|
which of them are failures."""
|
||||||
|
return list((await db.execute(
|
||||||
|
select(DockerEvent)
|
||||||
|
.where(DockerEvent.event.in_(("die", "oom")), DockerEvent.at >= since)
|
||||||
|
.order_by(DockerEvent.at.desc())
|
||||||
|
)).scalars())
|
||||||
|
|
||||||
|
|
||||||
|
async def retained_names(db, want: str, *, exact: bool, host_id: str | None = None,
|
||||||
|
limit: int = 20) -> list[tuple[str, str, datetime]]:
|
||||||
|
"""(host_id, container_name, last seen) of containers Steward still holds
|
||||||
|
logs or events for, newest first — including ones Docker has removed.
|
||||||
|
|
||||||
|
exact: the name itself, from both logs and events. Otherwise names that
|
||||||
|
contain `want` (case-insensitive), from events only: a substring match
|
||||||
|
over every retained log line's name would scan the log table, and a
|
||||||
|
container Steward watched die has a die or stop event.
|
||||||
|
"""
|
||||||
|
seen: dict[tuple[str, str], datetime] = {}
|
||||||
|
sources = ((DockerLog, DockerLog.ts), (DockerEvent, DockerEvent.at)) if exact \
|
||||||
|
else ((DockerEvent, DockerEvent.at),)
|
||||||
|
for model, at in sources:
|
||||||
|
match = (model.container_name == want if exact
|
||||||
|
else model.container_name.ilike(f"%{escape_like(want)}%", escape="\\"))
|
||||||
|
stmt = (select(model.host_id, model.container_name, func.max(at))
|
||||||
|
.where(match).group_by(model.host_id, model.container_name))
|
||||||
|
if host_id:
|
||||||
|
stmt = stmt.where(model.host_id == host_id)
|
||||||
|
for hid, name, last in (await db.execute(stmt)).all():
|
||||||
|
key = (hid, name)
|
||||||
|
if key not in seen or last > seen[key]:
|
||||||
|
seen[key] = last
|
||||||
|
rows = sorted(((h, n, t) for (h, n), t in seen.items()), key=lambda r: r[2], reverse=True)
|
||||||
|
return rows[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
# A swarm task's container is named "<service>.<slot>.<task id>": slot is the
|
||||||
|
# replica number, or the node id for a global service; task ids are 25 chars.
|
||||||
|
_TASK_SUFFIX = re.compile(r"^(?P<slot>[A-Za-z0-9]+)\.(?P<task>[a-z0-9]{25})$")
|
||||||
|
|
||||||
|
|
||||||
|
def task_of(service: str, container_name: str) -> tuple[str, str] | None:
|
||||||
|
"""(slot, task id) when `container_name` is one of `service`'s task
|
||||||
|
containers, else None — so "arr" does not claim "arr_radarr.1.…"."""
|
||||||
|
if not container_name.startswith(service + "."):
|
||||||
|
return None
|
||||||
|
m = _TASK_SUFFIX.match(container_name[len(service) + 1:])
|
||||||
|
return (m["slot"], m["task"]) if m else None
|
||||||
|
|
||||||
|
|
||||||
|
async def service_containers(db, service: str) -> list[tuple[str, str]]:
|
||||||
|
"""(host_id, container_name) of every task container of a swarm service
|
||||||
|
that Steward still holds anything for: the live ones, and the ones Docker
|
||||||
|
removed that left events (each task container has a start event). The
|
||||||
|
page's logs and history read across all of them (#5302)."""
|
||||||
|
pattern = escape_like(service + ".") + "%"
|
||||||
|
found: set[tuple[str, str]] = set()
|
||||||
|
for model, col in ((DockerContainer, DockerContainer.name),
|
||||||
|
(DockerEvent, DockerEvent.container_name)):
|
||||||
|
rows = (await db.execute(
|
||||||
|
select(model.host_id, col).where(col.like(pattern, escape="\\")).distinct())).all()
|
||||||
|
found.update((h, n) for h, n in rows if task_of(service, n))
|
||||||
|
return sorted(found)
|
||||||
|
|
||||||
|
|
||||||
|
async def query_service_logs(db, containers, stream: str, query: str, limit: int,
|
||||||
|
task: str | None = None) -> list:
|
||||||
|
"""The newest retained lines across a service's task containers, newest
|
||||||
|
first, each with its container name so the page can say which task wrote
|
||||||
|
it. `task` keeps one task's lines (a failure's "Logs →")."""
|
||||||
|
from sqlalchemy import tuple_
|
||||||
|
pairs = [(h, n) for h, n in containers if task is None or n.endswith("." + task)]
|
||||||
|
if not pairs:
|
||||||
|
return []
|
||||||
|
stmt = (select(DockerLog.ts, DockerLog.stream, DockerLog.line, DockerLog.container_name)
|
||||||
|
.where(tuple_(DockerLog.host_id, DockerLog.container_name).in_(pairs)))
|
||||||
|
if stream in ("stdout", "stderr"):
|
||||||
|
stmt = stmt.where(DockerLog.stream == stream)
|
||||||
|
if query:
|
||||||
|
stmt = stmt.where(DockerLog.line.ilike(f"%{escape_like(query)}%", escape="\\"))
|
||||||
|
stmt = stmt.order_by(DockerLog.ts.desc(), DockerLog.id.desc()).limit(limit)
|
||||||
|
return (await db.execute(stmt)).all()
|
||||||
|
|
||||||
|
|
||||||
|
def sum_series(samples, since: datetime, until: datetime, points: int = 60) -> list:
|
||||||
|
"""[(bucket start, cpu %, memory bytes)] summed across containers: each
|
||||||
|
container's samples are averaged within a bucket, then the containers are
|
||||||
|
added, so two replicas read as their combined load. `samples` are
|
||||||
|
(container_name, at, cpu_pct, mem_bytes); buckets with no sample are left
|
||||||
|
out rather than read as zero."""
|
||||||
|
width = max((until - since).total_seconds() / points, 60.0)
|
||||||
|
acc: dict[int, dict[str, list]] = {}
|
||||||
|
for name, at, cpu, mem in samples:
|
||||||
|
i = int((at - since).total_seconds() // width)
|
||||||
|
if i < 0:
|
||||||
|
continue
|
||||||
|
per = acc.setdefault(i, {}).setdefault(name, [0.0, 0, 0])
|
||||||
|
per[0] += cpu or 0.0
|
||||||
|
per[1] += mem or 0
|
||||||
|
per[2] += 1
|
||||||
|
out = []
|
||||||
|
for i in sorted(acc):
|
||||||
|
cpu = sum(c / n for c, _, n in acc[i].values())
|
||||||
|
mem = sum(m / n for _, m, n in acc[i].values())
|
||||||
|
out.append((since + timedelta(seconds=i * width), cpu, int(mem)))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def service_history(db, containers, since: datetime) -> list:
|
||||||
|
"""sum_series over a service's task containers' samples since `since`."""
|
||||||
|
resources = await container_resources(db, containers)
|
||||||
|
if not resources:
|
||||||
|
return []
|
||||||
|
joined: dict[tuple[str, datetime], list] = {}
|
||||||
|
for resource, at, metric, value in await _samples(
|
||||||
|
db, resources, ("cpu_pct", "mem_usage_bytes"), since):
|
||||||
|
pair = joined.setdefault((resource, at), [None, None])
|
||||||
|
pair[0 if metric == "cpu_pct" else 1] = value
|
||||||
|
rows = [(resource, at, cpu, mem) for (resource, at), (cpu, mem) in
|
||||||
|
sorted(joined.items(), key=lambda kv: kv[0][1])]
|
||||||
|
return sum_series(rows, since, datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
|
||||||
|
async def unhealthy_since(db, keys) -> dict[tuple[str, str], datetime]:
|
||||||
|
"""{(host_id, container_name): when it last turned unhealthy} for the given
|
||||||
|
containers, from their health_change events; absent when none is retained."""
|
||||||
|
keys = set(keys)
|
||||||
|
if not keys:
|
||||||
|
return {}
|
||||||
|
rows = (await db.execute(
|
||||||
|
select(DockerEvent)
|
||||||
|
.where(DockerEvent.event == "health_change",
|
||||||
|
DockerEvent.detail.like("%→unhealthy"),
|
||||||
|
DockerEvent.container_name.in_({name for _, name in keys}))
|
||||||
|
.order_by(DockerEvent.at.desc())
|
||||||
|
)).scalars()
|
||||||
|
out: dict[tuple[str, str], datetime] = {}
|
||||||
|
for e in rows:
|
||||||
|
key = (e.host_id, e.container_name)
|
||||||
|
if key in keys and key not in out:
|
||||||
|
out[key] = e.at
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
PRUNE_TARGETS = ("containers", "images", "system")
|
||||||
|
|
||||||
|
|
||||||
|
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)."""
|
||||||
|
extra_vars: dict = {"prune_target": prune_target}
|
||||||
|
if prune_target == "images":
|
||||||
|
extra_vars["prune_all_images"] = True
|
||||||
|
return extra_vars
|
||||||
|
|
||||||
|
|
||||||
|
# ── what is wrong with a container ───────────────────────────────────────────
|
||||||
|
# Shared by the Docker page's "Needs attention" and the MCP tools, so both
|
||||||
|
# rank the same containers the same way.
|
||||||
|
|
||||||
|
PROBLEMS = ("restarting", "crash_looping", "unhealthy", "oom_killed", "exited_nonzero")
|
||||||
|
|
||||||
|
def container_problems(c, failures_1h: int, failed_24h: bool,
|
||||||
|
crash_loop_deaths: int = CRASH_LOOP_DEATHS) -> list[str]:
|
||||||
|
"""What is wrong with one container, most severe first; [] when nothing is."""
|
||||||
|
status = (c.status or "").lower()
|
||||||
|
out = []
|
||||||
|
if status == "restarting":
|
||||||
|
out.append("restarting")
|
||||||
|
if failures_1h >= crash_loop_deaths:
|
||||||
|
out.append("crash_looping")
|
||||||
|
if status == "running" and (c.health or "") == "unhealthy":
|
||||||
|
out.append("unhealthy")
|
||||||
|
if c.oom_killed:
|
||||||
|
out.append("oom_killed")
|
||||||
|
if status in ("exited", "dead") and exit_is_failure(c.exit_code) and failed_24h:
|
||||||
|
out.append("exited_nonzero")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def problem_since(problems: list[str], deaths: list, hour_ago: datetime,
|
||||||
|
unhealthy_at: datetime | None = None) -> datetime | None:
|
||||||
|
"""When a container's worst problem began; None when the retained events
|
||||||
|
cannot say. `deaths` are its die/oom events of the last 24h, newest first.
|
||||||
|
|
||||||
|
A restart or crash loop dates from its first death in the last hour, an
|
||||||
|
OOM kill or error exit from the death that ended it, and a failing
|
||||||
|
healthcheck from when its health last turned unhealthy.
|
||||||
|
"""
|
||||||
|
if not problems:
|
||||||
|
return None
|
||||||
|
worst = problems[0]
|
||||||
|
if worst in ("restarting", "crash_looping"):
|
||||||
|
in_hour = [e.at for e in deaths if e.at >= hour_ago]
|
||||||
|
return min(in_hour) if in_hour else (deaths[0].at if deaths else None)
|
||||||
|
if worst == "unhealthy":
|
||||||
|
return unhealthy_at
|
||||||
|
if worst == "oom_killed":
|
||||||
|
ooms = [e.at for e in deaths if e.event == "oom"]
|
||||||
|
return ooms[0] if ooms else (deaths[0].at if deaths else None)
|
||||||
|
return deaths[0].at if deaths else None
|
||||||
|
|
||||||
|
|
||||||
|
PROBLEM_SEVERITY = {"restarting": "critical", "crash_looping": "critical", "unhealthy": "critical",
|
||||||
|
"oom_killed": "warning", "exited_nonzero": "warning"}
|
||||||
|
PROBLEM_SAYS = {"restarting": "is restarting", "crash_looping": "keeps dying",
|
||||||
|
"unhealthy": "fails its healthcheck", "oom_killed": "was OOM-killed",
|
||||||
|
"exited_nonzero": "exited with an error"}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async def ranked_containers(db, now: datetime, crash_loop_deaths: int) -> list[dict]:
|
||||||
|
"""Every container (deduplicated) with what is wrong with it, most severe
|
||||||
|
first: {container, problems, since, replaced}. A swarm task Swarm has
|
||||||
|
replaced has no problems; its failure belongs to its service."""
|
||||||
|
containers = dedup_by_container_id(await list_containers(db))
|
||||||
|
services = list((await db.execute(select(DockerSwarmService))).scalars())
|
||||||
|
replaced = replaced_tasks(containers, services)
|
||||||
|
failures = await recent_failures_by_container(db, now - timedelta(hours=24))
|
||||||
|
hour_ago = now - timedelta(hours=1)
|
||||||
|
sick = await unhealthy_since(
|
||||||
|
db, [(c.host_id, c.name) for c in containers if (c.health or "") == "unhealthy"])
|
||||||
|
out = []
|
||||||
|
for c in containers:
|
||||||
|
key = (c.host_id, c.name)
|
||||||
|
gone = key in replaced
|
||||||
|
events = failures.get(key, [])
|
||||||
|
recent = sum(1 for e in events if e.at >= hour_ago)
|
||||||
|
problems = [] if gone else container_problems(c, recent, bool(events), crash_loop_deaths)
|
||||||
|
out.append({"container": c, "problems": problems, "replaced": gone,
|
||||||
|
"since": problem_since(problems, events, hour_ago, sick.get(key))})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def needs_attention(ranked: list[dict]) -> list[dict]:
|
||||||
|
"""The containers with something wrong, worst problem first, then by host
|
||||||
|
and name."""
|
||||||
|
return sorted((r for r in ranked if r["problems"]),
|
||||||
|
key=lambda r: (PROBLEMS.index(r["problems"][0]), r["container"].host_id, r["container"].name))
|
||||||
|
|
||||||
|
|
||||||
|
def stale_after_seconds(config) -> int:
|
||||||
|
"""How old a container's last report may be before the page calls it
|
||||||
|
stale: the host agent's own staleness window, since the agent reports it."""
|
||||||
|
return int(((config.get("PLUGINS") or {}).get("host_agent") or {}).get("stale_after_seconds", 180))
|
||||||
|
|
||||||
|
|
||||||
|
async def disk_hosts(db, since: datetime, *, points: int = 60) -> list[dict]:
|
||||||
|
"""Each Docker host's disk as `docker system df` last said, most space
|
||||||
|
first, with its recorded total since `since` (#5304): the points, oldest
|
||||||
|
first, and the growth over them (disk_view.growth; None until there are
|
||||||
|
two). Hosts are named by their Steward host."""
|
||||||
|
from steward.core import metric_series
|
||||||
|
from .disk_view import DISK_PARTS, growth
|
||||||
|
from .models import DockerDiskUsage
|
||||||
|
summaries = list((await db.execute(select(DockerDiskUsage))).scalars())
|
||||||
|
hosts = await host_map(db, {s.host_id for s in summaries})
|
||||||
|
out = []
|
||||||
|
for s in summaries:
|
||||||
|
name = hosts[s.host_id].name if s.host_id in hosts else s.host_id
|
||||||
|
parts = {m: int(getattr(s, f) or 0) for m, f in DISK_PARTS.items()}
|
||||||
|
series = await metric_series.points(db, "docker", name, "disk_total_bytes", since, target=points)
|
||||||
|
out.append({"host_id": s.host_id, "host_name": name, "as_of": s.updated_at,
|
||||||
|
"total": sum(parts.values()), "parts": parts,
|
||||||
|
"reclaimable": int(s.images_reclaimable or 0), "points": series,
|
||||||
|
"growth": growth(series)})
|
||||||
|
return sorted(out, key=lambda h: (-h["total"], h["host_name"].lower()))
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""The swarm's health as three figures an alert rule can watch (#5788).
|
||||||
|
|
||||||
|
Every manager's agent reports the whole swarm, so the figures are swarm-wide,
|
||||||
|
on one resource, `swarm`, and are recorded at most once every RECORD_EVERY
|
||||||
|
whichever manager sent them: three managers then read as one series, and one
|
||||||
|
manager going silent doesn't silence it.
|
||||||
|
|
||||||
|
- swarm_services_short: services running fewer tasks than they want. A
|
||||||
|
service scaled to 0 wants none and is never short.
|
||||||
|
- swarm_nodes_down: nodes whose status isn't ready, other than drained ones.
|
||||||
|
A drained node is one taken out of service on purpose (VDNT-Util01 always;
|
||||||
|
any node during its maintenance), so its going away is expected. Its host's
|
||||||
|
own "Agent offline" rule still covers it.
|
||||||
|
- swarm_managers_unreachable: managers the swarm can't reach (agent 1.19.0
|
||||||
|
sends ManagerStatus.Reachability; before that, a manager whose status
|
||||||
|
isn't ready), drained ones aside for the same reason. With three managers
|
||||||
|
one can be lost and the swarm keeps working; two, and it can't change.
|
||||||
|
|
||||||
|
A maintenance job on a swarm node holds these rules' notifications in its
|
||||||
|
window (host_agent maintenance._swarm_window), since draining and rebooting a
|
||||||
|
node is exactly what moves them.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
RESOURCE = "swarm"
|
||||||
|
SWARM_METRICS = ("swarm_services_short", "swarm_nodes_down", "swarm_managers_unreachable")
|
||||||
|
RECORD_EVERY = timedelta(seconds=30)
|
||||||
|
|
||||||
|
_last_recorded: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def figures(swarm: dict) -> dict[str, float]:
|
||||||
|
"""The three figures from one manager's swarm report."""
|
||||||
|
services = swarm.get("services") or []
|
||||||
|
nodes = swarm.get("nodes") or []
|
||||||
|
short = sum(1 for s in services
|
||||||
|
if int(s.get("desired") or 0) > 0 and int(s.get("running") or 0) < int(s.get("desired") or 0))
|
||||||
|
in_service = [n for n in nodes if (n.get("availability") or "active") != "drain"]
|
||||||
|
down = sum(1 for n in in_service if (n.get("status") or "unknown") != "ready")
|
||||||
|
managers = [n for n in in_service if n.get("role") == "manager"]
|
||||||
|
unreachable = sum(1 for n in managers
|
||||||
|
if (n["reachability"] != "reachable" if n.get("reachability")
|
||||||
|
else (n.get("status") or "unknown") != "ready"))
|
||||||
|
return {"swarm_services_short": float(short), "swarm_nodes_down": float(down),
|
||||||
|
"swarm_managers_unreachable": float(unreachable)}
|
||||||
|
|
||||||
|
|
||||||
|
def due(now: datetime) -> bool:
|
||||||
|
"""Whether this report should be recorded: the first in RECORD_EVERY,
|
||||||
|
whichever manager it came from."""
|
||||||
|
global _last_recorded
|
||||||
|
if _last_recorded is not None and now - _last_recorded < RECORD_EVERY:
|
||||||
|
return False
|
||||||
|
_last_recorded = now
|
||||||
|
return True
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# plugins/docker/swarm_nodes.py
|
||||||
|
"""The Swarm page laid out by node (#5303): one card per node with its role,
|
||||||
|
whether it takes work, what its containers use against what the machine has,
|
||||||
|
and the services Swarm placed on it. Model-free, so it is unit-tested without
|
||||||
|
a database; routes.swarm() feeds it.
|
||||||
|
|
||||||
|
A node with a Steward agent reports its own containers, so its services carry
|
||||||
|
CPU and memory. A node without one is known only from the managers' placement:
|
||||||
|
its services are listed by count, with no figures.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .swarm_view import _placement
|
||||||
|
|
||||||
|
|
||||||
|
def node_ok(n) -> bool:
|
||||||
|
"""A node that is up and taking work."""
|
||||||
|
return n.status == "ready" and n.availability == "active"
|
||||||
|
|
||||||
|
|
||||||
|
def node_cards(nodes, services, containers, *, node_host: dict, host_names: dict,
|
||||||
|
capacity: dict, service_state: dict) -> list[dict]:
|
||||||
|
"""One card per node, a node in trouble first, then managers, then by name.
|
||||||
|
|
||||||
|
`nodes` and `services` are one swarm's deduplicated rows; `containers`
|
||||||
|
every container row (only running swarm tasks on these nodes count);
|
||||||
|
`node_host` maps a node id to the Steward host on it, `capacity` a host id
|
||||||
|
to (cores, memory bytes), `service_state` a service name to healthy,
|
||||||
|
degraded or down."""
|
||||||
|
running_on: dict[str, dict[str, dict]] = {}
|
||||||
|
for c in containers:
|
||||||
|
if not (c.node_id and c.service_name and c.status == "running"):
|
||||||
|
continue
|
||||||
|
row = running_on.setdefault(c.node_id, {}).setdefault(
|
||||||
|
c.service_name, {"name": c.service_name, "tasks": 0, "cpu_pct": 0.0, "mem_bytes": 0,
|
||||||
|
"measured": True})
|
||||||
|
row["tasks"] += 1
|
||||||
|
row["cpu_pct"] += c.cpu_pct or 0.0
|
||||||
|
row["mem_bytes"] += c.mem_usage_bytes or 0
|
||||||
|
placed: dict[str, dict[str, int]] = {}
|
||||||
|
for s in services:
|
||||||
|
for p in _placement(s):
|
||||||
|
count = int(p.get("running") or 0)
|
||||||
|
if count > 0 and p.get("node_id"):
|
||||||
|
placed.setdefault(p["node_id"], {})[s.service_name] = count
|
||||||
|
|
||||||
|
cards = []
|
||||||
|
for n in nodes:
|
||||||
|
rows = dict(running_on.get(n.node_id, {}))
|
||||||
|
for name, count in placed.get(n.node_id, {}).items():
|
||||||
|
rows.setdefault(name, {"name": name, "tasks": count, "cpu_pct": None, "mem_bytes": None,
|
||||||
|
"measured": False})
|
||||||
|
for r in rows.values():
|
||||||
|
r["state"] = service_state.get(r["name"], "healthy")
|
||||||
|
r["cores"] = r["cpu_pct"] / 100 if r["cpu_pct"] is not None else None
|
||||||
|
tasks = sorted(rows.values(), key=lambda r: (r["state"] == "healthy", -(r["cores"] or 0.0),
|
||||||
|
r["name"].lower()))
|
||||||
|
host_id = node_host.get(n.node_id)
|
||||||
|
cores, mem = capacity.get(host_id, (None, None)) if host_id else (None, None)
|
||||||
|
measured = [r for r in tasks if r["measured"]]
|
||||||
|
cards.append({
|
||||||
|
"node_id": n.node_id, "hostname": n.hostname or n.node_id[:12], "role": n.role,
|
||||||
|
"leader": n.leader, "status": n.status, "availability": n.availability, "ok": node_ok(n),
|
||||||
|
"host_id": host_id, "host_name": host_names.get(host_id) if host_id else None,
|
||||||
|
"agent": bool(running_on.get(n.node_id)) or host_id is not None,
|
||||||
|
"tasks": tasks, "task_count": sum(r["tasks"] for r in tasks),
|
||||||
|
"cores_used": sum(r["cores"] for r in measured) if measured else None,
|
||||||
|
"mem_used": sum(r["mem_bytes"] for r in measured) if measured else None,
|
||||||
|
"cores": cores, "mem": mem,
|
||||||
|
})
|
||||||
|
return sorted(cards, key=lambda c: (c["ok"], c["role"] != "manager", c["hostname"].lower()))
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
"""Model-free builder for the swarm-aware container view.
|
||||||
|
|
||||||
|
Merges two sources into one service-grouped, cluster-complete picture:
|
||||||
|
• docker_containers — real container rows, collected LOCAL-per-node, so each
|
||||||
|
carries the host (= node) it runs on plus its swarm service/node labels.
|
||||||
|
• DockerSwarmService.placement_json — the managers' cluster-wide task→node
|
||||||
|
counts, which include tasks on nodes that run no Steward agent (and so have
|
||||||
|
no container row of their own).
|
||||||
|
|
||||||
|
For every swarm service we list the real replicas we have detail for, then add
|
||||||
|
"ghost" replicas for the remaining placement count on each node (agent-less
|
||||||
|
nodes). Non-swarm containers pass through grouped by host, unchanged.
|
||||||
|
|
||||||
|
A third source, DockerSwarmService.tasks_json (agent ≥ 1.8.0), is Swarm's own
|
||||||
|
record of each service's newest tasks. It decides which task containers are
|
||||||
|
*superseded* — replaced by Swarm, so neither a replica nor a crash — and gives
|
||||||
|
each service its failures across task retries and the error of its last
|
||||||
|
failed task. Swarm restarts a task by creating a new one (a new container), so
|
||||||
|
failures can only be counted per service, never per container.
|
||||||
|
|
||||||
|
Kept model-free (no ORM imports) so it's unit-testable and safe to import in
|
||||||
|
tests without the plugin-loader "table already defined" gotcha.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
# Task states that mean the task ended in error: `failed` (its container exited
|
||||||
|
# non-zero or never started) and `rejected` (Swarm could not start it at all,
|
||||||
|
# e.g. no such image, a bad mount).
|
||||||
|
FAILED_TASK_STATES = ("failed", "rejected")
|
||||||
|
# Desired states of a task Swarm still means to run. Anything else (shutdown,
|
||||||
|
# remove, complete, orphaned) is a task Swarm has finished with.
|
||||||
|
_CURRENT_DESIRED = ("running", "ready", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _service_state(running: int, desired: int) -> str:
|
||||||
|
if desired > 0 and running >= desired:
|
||||||
|
return "healthy"
|
||||||
|
if running > 0:
|
||||||
|
return "degraded"
|
||||||
|
return "down"
|
||||||
|
|
||||||
|
|
||||||
|
def _placement(service) -> list[dict]:
|
||||||
|
raw = getattr(service, "placement_json", None) or "[]"
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return []
|
||||||
|
return [p for p in data if isinstance(p, dict)]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_tasks(service) -> list[dict]:
|
||||||
|
"""A service row's task records, newest first; [] when its agent sent none."""
|
||||||
|
raw = getattr(service, "tasks_json", None) or "[]"
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return []
|
||||||
|
return [t for t in data if isinstance(t, dict)]
|
||||||
|
|
||||||
|
|
||||||
|
def task_time(task: dict) -> datetime | None:
|
||||||
|
"""A task's status timestamp. Docker writes nanoseconds; Python ≥ 3.11 keeps
|
||||||
|
the first six digits."""
|
||||||
|
raw = task.get("at") or ""
|
||||||
|
try:
|
||||||
|
at = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return at if at.tzinfo else at.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def task_failures(tasks: list[dict], since: datetime) -> int:
|
||||||
|
"""How many tasks ended in error at or after `since`."""
|
||||||
|
n = 0
|
||||||
|
for t in tasks:
|
||||||
|
at = task_time(t)
|
||||||
|
if t.get("state") in FAILED_TASK_STATES and at is not None and at >= since:
|
||||||
|
n += 1
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def last_task_error(tasks: list[dict]) -> dict | None:
|
||||||
|
"""Why the service's newest troubled task is in trouble, from Swarm itself.
|
||||||
|
|
||||||
|
The newest task that failed or was rejected, or that Swarm attached an error
|
||||||
|
to while still trying (a pending task with "no suitable node", say). None
|
||||||
|
when no retained task has a problem.
|
||||||
|
"""
|
||||||
|
for t in tasks: # newest first
|
||||||
|
if t.get("state") in FAILED_TASK_STATES or t.get("error"):
|
||||||
|
return {"state": t.get("state"), "error": t.get("error") or None,
|
||||||
|
"message": t.get("message") or None, "exit_code": t.get("exit_code"),
|
||||||
|
"node_id": t.get("node_id") or None, "at": t.get("at") or None,
|
||||||
|
"task_id": t.get("task_id") or None, "slot": t.get("slot")}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def task_summary(tasks: list[dict], now: datetime, crash_loop_deaths: int,
|
||||||
|
node_hostname: dict) -> dict:
|
||||||
|
"""What a service's task records say: tasks_known, failures_1h,
|
||||||
|
failures_24h (None without records), crash_looping, and last_error with
|
||||||
|
the node's hostname as `node`."""
|
||||||
|
known = bool(tasks)
|
||||||
|
failures_1h = task_failures(tasks, now - timedelta(hours=1)) if known else None
|
||||||
|
last_error = last_task_error(tasks)
|
||||||
|
if last_error is not None:
|
||||||
|
nid = last_error["node_id"]
|
||||||
|
last_error["node"] = node_hostname.get(nid) or (nid[:12] if nid else None)
|
||||||
|
at = task_time(last_error)
|
||||||
|
last_error["age_s"] = max(0, int((now - at).total_seconds())) if at else None
|
||||||
|
return {
|
||||||
|
"tasks_known": known,
|
||||||
|
"failures_1h": failures_1h,
|
||||||
|
"failures_24h": task_failures(tasks, now - timedelta(hours=24)) if known else None,
|
||||||
|
"crash_looping": bool(known and failures_1h >= crash_loop_deaths),
|
||||||
|
"last_error": last_error,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _oldest_failure(tasks: list[dict], since: datetime) -> datetime | None:
|
||||||
|
times = [at for t in tasks if t.get("state") in FAILED_TASK_STATES
|
||||||
|
and (at := task_time(t)) is not None and at >= since]
|
||||||
|
return min(times, default=None)
|
||||||
|
|
||||||
|
|
||||||
|
def trouble_since(tasks: list[dict], state: str, crash_looping: bool,
|
||||||
|
now: datetime) -> datetime | None:
|
||||||
|
"""When a service's current trouble began, as far as its retained tasks
|
||||||
|
tell; None when it has none, or the tasks cannot say.
|
||||||
|
|
||||||
|
A crash loop dates from its first failure in the last hour, and a healthy
|
||||||
|
service that lost tasks from its first failure in the last 24h. A service
|
||||||
|
short of replicas dates from its troubled slots (replicated: the slot;
|
||||||
|
global: the node): in each slot whose newest task is not running, the
|
||||||
|
oldest of its newest troubled tasks (failed, rejected, or carrying an
|
||||||
|
error), walking back until a running task interrupts them. Swarm keeps few
|
||||||
|
old tasks, so a long outage reads as the oldest one retained: a lower
|
||||||
|
bound.
|
||||||
|
"""
|
||||||
|
if crash_looping:
|
||||||
|
return _oldest_failure(tasks, now - timedelta(hours=1))
|
||||||
|
if state == "healthy":
|
||||||
|
return _oldest_failure(tasks, now - timedelta(hours=24))
|
||||||
|
by_slot: dict = {}
|
||||||
|
for t in tasks: # newest first, so each slot's list is too
|
||||||
|
by_slot.setdefault(t.get("slot") or t.get("node_id"), []).append(t)
|
||||||
|
began = []
|
||||||
|
for slot_tasks in by_slot.values():
|
||||||
|
oldest = None
|
||||||
|
for t in slot_tasks:
|
||||||
|
if t.get("state") == "running":
|
||||||
|
break
|
||||||
|
if t.get("state") in FAILED_TASK_STATES or t.get("error"):
|
||||||
|
oldest = task_time(t) or oldest
|
||||||
|
if oldest is not None:
|
||||||
|
began.append(oldest)
|
||||||
|
return min(began, default=None)
|
||||||
|
|
||||||
|
|
||||||
|
def _slot_key(c) -> str:
|
||||||
|
"""A task container's slot within its service: "<service>.<slot>.<task>" for
|
||||||
|
replicated services, "<service>.<node id>.<task>" for global ones."""
|
||||||
|
svc, name = c.service_name, c.name or ""
|
||||||
|
rest = name[len(svc) + 1:] if name.startswith(svc + ".") else name
|
||||||
|
return rest.split(".", 1)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def superseded_containers(swarm_cs, tasks_by_id: dict[str, dict]) -> set[tuple]:
|
||||||
|
"""(host_id, name) of task containers Swarm has replaced.
|
||||||
|
|
||||||
|
With the task on record, its desired state decides: Swarm sets a replaced
|
||||||
|
or failed task's desired state to shutdown. Without it (an older agent, or a
|
||||||
|
task older than the retained history), a container that is not running is
|
||||||
|
superseded when another task in the same slot is running or started later.
|
||||||
|
"""
|
||||||
|
out: set[tuple] = set()
|
||||||
|
by_slot: dict[tuple, list] = {}
|
||||||
|
for c in swarm_cs:
|
||||||
|
by_slot.setdefault((c.service_name, _slot_key(c)), []).append(c)
|
||||||
|
for c in swarm_cs:
|
||||||
|
task = tasks_by_id.get(getattr(c, "task_id", None) or "")
|
||||||
|
if task is not None:
|
||||||
|
if task.get("desired_state") not in _CURRENT_DESIRED:
|
||||||
|
out.add((c.host_id, c.name))
|
||||||
|
continue
|
||||||
|
if (getattr(c, "status", "") or "").lower() == "running":
|
||||||
|
continue
|
||||||
|
mine = getattr(c, "started_at", None)
|
||||||
|
for other in by_slot[(c.service_name, _slot_key(c))]:
|
||||||
|
if other is c:
|
||||||
|
continue
|
||||||
|
theirs = getattr(other, "started_at", None)
|
||||||
|
if ((getattr(other, "status", "") or "").lower() == "running"
|
||||||
|
or (theirs is not None and (mine is None or theirs > mine))):
|
||||||
|
out.add((c.host_id, c.name))
|
||||||
|
break
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _newest_per_name(services) -> dict[str, object]:
|
||||||
|
"""Every manager reports the same cluster-global service set; keep the most
|
||||||
|
recently updated row per name, so a manager on an older agent (no tasks)
|
||||||
|
doesn't shadow one that reports them."""
|
||||||
|
by_name: dict[str, object] = {}
|
||||||
|
for s in services:
|
||||||
|
name = getattr(s, "service_name", None)
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
kept = by_name.get(name)
|
||||||
|
if kept is None:
|
||||||
|
by_name[name] = s
|
||||||
|
continue
|
||||||
|
a, b = getattr(s, "updated_at", None), getattr(kept, "updated_at", None)
|
||||||
|
if a is not None and (b is None or a > b):
|
||||||
|
by_name[name] = s
|
||||||
|
return by_name
|
||||||
|
|
||||||
|
|
||||||
|
def replaced_tasks(containers, services) -> set[tuple]:
|
||||||
|
"""(host_id, name) of the task containers Swarm has replaced, from the
|
||||||
|
container rows and the managers' service rows. What the container views use
|
||||||
|
to leave history out of their problem lists."""
|
||||||
|
swarm_cs = [c for c in containers if getattr(c, "service_name", None)]
|
||||||
|
tasks_by_id = {t["task_id"]: t for s in _newest_per_name(services).values()
|
||||||
|
for t in parse_tasks(s) if t.get("task_id")}
|
||||||
|
return superseded_containers(swarm_cs, tasks_by_id)
|
||||||
|
|
||||||
|
|
||||||
|
def build_swarm_services(containers, services, node_hostname: dict, host_name: dict,
|
||||||
|
*, now: datetime | None = None, crash_loop_deaths: int = 3) -> dict:
|
||||||
|
"""Return {"services": [...], "standalone": [...], "superseded": {(host_id, name)}}.
|
||||||
|
|
||||||
|
services[i] = {name, mode, image, desired, running, state, replicas:[...],
|
||||||
|
previous_tasks, tasks_known, failures_1h, failures_24h,
|
||||||
|
crash_looping, last_error}
|
||||||
|
real replica = {ghost:False, host, host_id, name, status, cpu_pct, mem_pct,
|
||||||
|
mem_bytes, started_at, image, health, restart_count}
|
||||||
|
# current tasks only
|
||||||
|
ghost replica = {ghost:True, host, count} # placement with no local row
|
||||||
|
previous_tasks = task containers Swarm replaced, left out of replicas
|
||||||
|
failures_* = tasks that failed or were rejected in the window (None
|
||||||
|
without task data); crash_looping = failures_1h ≥ the
|
||||||
|
Settings → Thresholds crash-loop count
|
||||||
|
last_error = last_task_error(), plus `node` (the node's hostname)
|
||||||
|
trouble_since = trouble_since(): when the current trouble began, or None
|
||||||
|
standalone[i] = {host_id, host, containers:[...]} # non-swarm, by host
|
||||||
|
superseded = the task containers Swarm replaced, so callers can skip them
|
||||||
|
"""
|
||||||
|
now = now or datetime.now(timezone.utc)
|
||||||
|
containers = list(containers)
|
||||||
|
swarm_cs = [c for c in containers if getattr(c, "service_name", None)]
|
||||||
|
standalone_cs = [c for c in containers if not getattr(c, "service_name", None)]
|
||||||
|
|
||||||
|
svc_by_name = _newest_per_name(services)
|
||||||
|
tasks_by_svc = {name: parse_tasks(s) for name, s in svc_by_name.items()}
|
||||||
|
tasks_by_id = {t["task_id"]: t for ts in tasks_by_svc.values() for t in ts
|
||||||
|
if t.get("task_id")}
|
||||||
|
superseded = superseded_containers(swarm_cs, tasks_by_id)
|
||||||
|
|
||||||
|
# Current task containers grouped by service name; replaced ones are counted.
|
||||||
|
cs_by_service: dict[str, list] = {}
|
||||||
|
previous: dict[str, int] = {}
|
||||||
|
for c in swarm_cs:
|
||||||
|
if (c.host_id, c.name) in superseded:
|
||||||
|
previous[c.service_name] = previous.get(c.service_name, 0) + 1
|
||||||
|
continue
|
||||||
|
cs_by_service.setdefault(c.service_name, []).append(c)
|
||||||
|
|
||||||
|
def _host_of(c):
|
||||||
|
return (host_name.get(getattr(c, "host_id", None))
|
||||||
|
or node_hostname.get(getattr(c, "node_id", None))
|
||||||
|
or getattr(c, "host_id", None) or "?")
|
||||||
|
|
||||||
|
out_services = []
|
||||||
|
for name in sorted(set(svc_by_name) | set(cs_by_service)):
|
||||||
|
svc = svc_by_name.get(name)
|
||||||
|
reals = cs_by_service.get(name, [])
|
||||||
|
|
||||||
|
replicas = []
|
||||||
|
for c in sorted(reals, key=lambda c: (_host_of(c).lower(), c.name)):
|
||||||
|
replicas.append({
|
||||||
|
"ghost": False,
|
||||||
|
"host": _host_of(c),
|
||||||
|
"host_id": getattr(c, "host_id", None),
|
||||||
|
"name": c.name,
|
||||||
|
"status": getattr(c, "status", "unknown"),
|
||||||
|
"cpu_pct": getattr(c, "cpu_pct", None),
|
||||||
|
"mem_pct": getattr(c, "mem_pct", None),
|
||||||
|
"mem_bytes": getattr(c, "mem_usage_bytes", None),
|
||||||
|
"started_at": getattr(c, "started_at", None),
|
||||||
|
"image": getattr(c, "image", None) or None,
|
||||||
|
"health": getattr(c, "health", None),
|
||||||
|
"restart_count": getattr(c, "restart_count", 0) or 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Real running replicas per node, to subtract from placement.
|
||||||
|
real_running_by_node: dict[str, int] = {}
|
||||||
|
for c in reals:
|
||||||
|
if (getattr(c, "status", "") or "").lower() == "running":
|
||||||
|
nid = getattr(c, "node_id", None)
|
||||||
|
if nid:
|
||||||
|
real_running_by_node[nid] = real_running_by_node.get(nid, 0) + 1
|
||||||
|
|
||||||
|
ghosts = []
|
||||||
|
for p in _placement(svc) if svc is not None else []:
|
||||||
|
nid = p.get("node_id", "")
|
||||||
|
placed = int(p.get("running", 0) or 0)
|
||||||
|
ghost = placed - real_running_by_node.get(nid, 0)
|
||||||
|
if ghost > 0:
|
||||||
|
ghosts.append({
|
||||||
|
"ghost": True,
|
||||||
|
"host": node_hostname.get(nid) or (nid[:12] if nid else "?"),
|
||||||
|
"count": ghost,
|
||||||
|
})
|
||||||
|
ghosts.sort(key=lambda g: g["host"].lower())
|
||||||
|
replicas.extend(ghosts)
|
||||||
|
|
||||||
|
running = getattr(svc, "running", None) if svc is not None else None
|
||||||
|
desired = getattr(svc, "desired", None) if svc is not None else None
|
||||||
|
# No service row (manager didn't report it): infer from what we can see.
|
||||||
|
if running is None:
|
||||||
|
running = sum(1 for r in replicas
|
||||||
|
if (not r["ghost"] and (r["status"] or "").lower() == "running"))
|
||||||
|
if desired is None:
|
||||||
|
desired = len(replicas)
|
||||||
|
|
||||||
|
tasks = tasks_by_svc.get(name, [])
|
||||||
|
state = _service_state(int(running or 0), int(desired or 0))
|
||||||
|
summary = task_summary(tasks, now, crash_loop_deaths, node_hostname)
|
||||||
|
out_services.append({
|
||||||
|
"name": name,
|
||||||
|
"mode": getattr(svc, "mode", "replicated") if svc is not None else "replicated",
|
||||||
|
"image": getattr(svc, "image", None) if svc is not None else None,
|
||||||
|
"running": running,
|
||||||
|
"desired": desired,
|
||||||
|
"state": state,
|
||||||
|
"replicas": replicas,
|
||||||
|
"previous_tasks": previous.get(name, 0),
|
||||||
|
**summary,
|
||||||
|
"trouble_since": trouble_since(tasks, state, summary["crash_looping"], now),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Non-swarm containers grouped by host, running-first order preserved.
|
||||||
|
groups: dict[str, dict] = {}
|
||||||
|
for c in standalone_cs:
|
||||||
|
hid = getattr(c, "host_id", None)
|
||||||
|
g = groups.get(hid)
|
||||||
|
if g is None:
|
||||||
|
g = groups[hid] = {"host_id": hid, "host": host_name.get(hid) or hid or "?",
|
||||||
|
"containers": []}
|
||||||
|
g["containers"].append(c)
|
||||||
|
standalone = sorted(groups.values(), key=lambda g: g["host"].lower())
|
||||||
|
|
||||||
|
return {"services": out_services, "standalone": standalone, "superseded": superseded}
|
||||||
|
|
||||||
|
|
||||||
|
def service_trouble(s: dict) -> int | None:
|
||||||
|
"""How bad a built service is, for sorting and for Needs attention: 0 a
|
||||||
|
crash loop, 1 no replica running, 2 short of replicas, 3 healthy but with
|
||||||
|
failed tasks in the last 24h; None when nothing is wrong."""
|
||||||
|
if s.get("crash_looping"):
|
||||||
|
return 0
|
||||||
|
if s.get("state") == "down":
|
||||||
|
return 1
|
||||||
|
if s.get("state") == "degraded":
|
||||||
|
return 2
|
||||||
|
if s.get("failures_24h"):
|
||||||
|
return 3
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def services_problems_first(services: list[dict]) -> list[dict]:
|
||||||
|
"""Services in trouble first, worst first, then the rest by name."""
|
||||||
|
return sorted(services, key=lambda s: (
|
||||||
|
4 if service_trouble(s) is None else service_trouble(s), s["name"].lower()))
|
||||||
|
|
||||||
|
|
||||||
|
def failing_services(services: list[dict]) -> list[dict]:
|
||||||
|
"""The services that belong in Needs attention: a crash loop, or short of
|
||||||
|
their desired replicas. A healthy service whose failed tasks Swarm has
|
||||||
|
already replaced is history, shown on the service, not a problem now."""
|
||||||
|
# A crash loop ranks 0, so test the rank against None, not for truth.
|
||||||
|
return [s for s in services_problems_first(services)
|
||||||
|
if (t := service_trouble(s)) is not None and t <= 2]
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{# 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.
|
||||||
|
A service's lines (#5302) carry the task each came from. #}
|
||||||
|
{% 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>
|
||||||
|
{% if l.task %}<span class="tone-muted" style="flex-shrink:0;white-space:nowrap;" title="Slot and task id">{{ l.task }}</span>{% endif %}
|
||||||
|
<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 {{ "one of its tasks" if service else "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 %}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{# docker/_disk_hosts.html — every Docker host's disk side by side (#5304):
|
||||||
|
what it uses now, how much that grew over the selected range, and a line
|
||||||
|
of it; the parts after. A host is linked to its own section below. #}
|
||||||
|
{% if rows %}
|
||||||
|
<table class="table">
|
||||||
|
<thead><tr>
|
||||||
|
<th>Host</th>
|
||||||
|
<th class="num">Docker uses</th>
|
||||||
|
<th class="num" title="Change in the total over the last {{ range_key }}, from Steward's own record of it">Change ({{ range_key }})</th>
|
||||||
|
<th>Over {{ range_key }}</th>
|
||||||
|
<th class="num">Images</th><th class="num">Volumes</th><th class="num" title="Written by containers on top of their images">Container files</th>
|
||||||
|
<th class="num">Build cache</th><th class="num" title="Held by images no container uses">Reclaimable</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in rows %}
|
||||||
|
{% set g = r.growth %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="#host-{{ r.host_id }}">{{ r.host_name }}</a></td>
|
||||||
|
<td class="num"><strong>{{ r.total|bytes }}</strong></td>
|
||||||
|
<td class="num">
|
||||||
|
{%- if g is none %}<span class="tone-dim" title="Steward records this from now on; growth shows once there are two readings">—</span>
|
||||||
|
{%- else %}<span class="tone-{{ 'warn' if g.pct is not none and g.pct >= 10 else ('ok' if g.change < 0 else 'muted') }}"
|
||||||
|
title="{{ g.from|bytes }} → {{ g.to|bytes }} since {{ g.since.strftime('%Y-%m-%d %H:%M UTC') }}">{{ "+" if g.change > 0 else ("−" if g.change < 0 else "±") }}{{ (g.change|abs)|bytes }}</span>{% endif -%}
|
||||||
|
</td>
|
||||||
|
<td class="tone-info">{{ r.spark|safe }}</td>
|
||||||
|
<td class="num">{{ r.parts.disk_images_bytes|bytes }}</td>
|
||||||
|
<td class="num">{{ r.parts.disk_volumes_bytes|bytes }}</td>
|
||||||
|
<td class="num">{{ r.parts.disk_containers_bytes|bytes }}</td>
|
||||||
|
<td class="num">{{ r.parts.disk_build_cache_bytes|bytes }}</td>
|
||||||
|
<td class="num">{% if r.reclaimable %}<span class="tone-warn">{{ r.reclaimable|bytes }}</span>{% else %}<span class="tone-dim">—</span>{% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p class="tone-muted" style="margin:0;padding:1rem;">No host has reported its Docker disk yet.</p>
|
||||||
|
{% endif %}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{#- One line on a swarm service's task failures, from Swarm's own task records
|
||||||
|
(agent ≥ 1.8.0): a crash loop or the failures of the last 24h, and how the
|
||||||
|
newest troubled task ended, in Swarm's words. Expects `s` with state,
|
||||||
|
crash_looping, failures_1h, failures_24h and last_error; `style` is
|
||||||
|
optional extra CSS for the wrapper. Renders nothing for a healthy service
|
||||||
|
with no recent failures. -#}
|
||||||
|
{%- set e = s.last_error -%}
|
||||||
|
{%- if s.crash_looping or s.failures_24h or (e and s.state != 'healthy') %}
|
||||||
|
<div style="display:flex;flex-wrap:wrap;align-items:baseline;gap:0.2rem 0.6rem;font-size:0.78rem;color:var(--text-muted);{{ style or '' }}">
|
||||||
|
{% if s.crash_looping %}
|
||||||
|
<span style="color:var(--red);font-weight:500;">Crash-looping · {{ s.failures_1h }} tasks failed in the last hour</span>
|
||||||
|
{% elif s.failures_24h %}
|
||||||
|
<span style="color:var(--orange);">{{ s.failures_24h }} task{{ 's' if s.failures_24h != 1 }} failed in the last 24h</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if e %}
|
||||||
|
<span title="How Swarm recorded the end of this service's newest failed task">
|
||||||
|
Last task {{ e.state }}:
|
||||||
|
<span style="color:var(--text);font-family:ui-monospace,monospace;overflow-wrap:anywhere;">{{ e.error or e.message or e.state }}</span>
|
||||||
|
{%- if e.exit_code %} · exit {{ e.exit_code }}{% endif %}
|
||||||
|
{%- if e.node %} · {{ e.node }}{% endif %}
|
||||||
|
{%- if e.age_s is not none %} · {{ e.age_s|duration }} ago{% endif %}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{%- endif %}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{#- A container's CPU and memory as service.usage() describes them (#5209).
|
||||||
|
|
||||||
|
Docker counts CPU per core (100% = one core busy), so CPU reads as cores,
|
||||||
|
toned by its share of the host's CPUs when the host's core count is known.
|
||||||
|
Memory reads in bytes, against a limit only when one is actually set;
|
||||||
|
mem_pct is then a share of that limit, otherwise of the host's memory.
|
||||||
|
Each is toned by usage()'s grade when it has one: within the container's
|
||||||
|
own normal for the hour reads healthy, and says what is usual (#5742). -#}
|
||||||
|
|
||||||
|
{% macro cores(n) -%}
|
||||||
|
{{ "%.2f"|format(n) if n < 1 else "%.1f"|format(n) }} {{ "core" if n == 1 else "cores" }}
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{% macro cpu(u) -%}
|
||||||
|
{%- if u.cores is none -%}<span class="tone-dim">—</span>
|
||||||
|
{%- else -%}
|
||||||
|
<span class="tone-{{ u.cpu_grade or status_class(u.cpu_share, 'cpu') }}"
|
||||||
|
title="Docker counts CPU per core: {{ '%.0f'|format(u.cores * 100) }}% of one core{% if u.cpus %}, {{ '%.1f'|format(u.cpu_share) }}% of this host's {{ u.cpus }} core{{ 's' if u.cpus != 1 }}{% endif %}{% if u.cpu_usual %}; {{ u.cpu_usual }}{% endif %}">{{ cores(u.cores) }}</span>
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{% macro mem(u) -%}
|
||||||
|
{%- if u.mem_bytes is none -%}<span class="tone-dim">—</span>
|
||||||
|
{%- else -%}
|
||||||
|
<span class="tone-{{ u.mem_grade or (status_class(u.mem_pct, 'mem') if u.mem_of else 'dim') }}"
|
||||||
|
title="{% if u.mem_pct is not none and u.mem_of %}{{ '%.1f'|format(u.mem_pct) }}% of {{ u.mem_of }}{% if u.mem_usual %}, {{ u.mem_usual }}{% endif %}{% else %}No memory limit set{% endif %}">{{ u.mem_bytes|bytes }}{% if u.mem_limit %} / {{ u.mem_limit|bytes }}{% endif %}</span>
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endmacro %}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{# docker/_usage_history.html — CPU and memory over the selected range, for a
|
||||||
|
service (summed across its replicas, #5302) or one container (#5310): the
|
||||||
|
latest and the peak, over a line that spans its half. The line is drawn
|
||||||
|
against at least cpu_floor / mem_floor, so idling near zero draws flat. #}
|
||||||
|
{% from "docker/_usage.html" import cores %}
|
||||||
|
{% if have_data %}
|
||||||
|
<div class="history-pair">
|
||||||
|
<div>
|
||||||
|
<div class="section-title">CPU</div>
|
||||||
|
<div style="font-variant-numeric:tabular-nums;">{{ cores(cpu_now) }} <span class="tone-dim">now · peak {{ cores(cpu_peak) }}</span></div>
|
||||||
|
<div class="spark" title="Drawn from zero to its peak, or to {{ cores(cpu_floor)|striptags }} when the peak is lower">{{ spark_cpu|safe }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="section-title">Memory</div>
|
||||||
|
<div style="font-variant-numeric:tabular-nums;">{{ mem_now|bytes }} <span class="tone-dim">now · peak {{ mem_peak|bytes }}</span></div>
|
||||||
|
<div class="spark" title="Drawn from zero to its peak, or to {{ mem_floor|bytes }} when the peak is lower">{{ spark_mem|safe }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="tone-muted" style="margin:0;font-size:0.85rem;">
|
||||||
|
Not enough samples in the last {{ range_key }} yet. History fills in as the agent reports.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
{# docker/container_detail.html — one container (#5310), shaped like a
|
||||||
|
service's page: a header that says whether it is well and what it is; why
|
||||||
|
it stopped, only while it has; what it moved since it started; its CPU and
|
||||||
|
memory over time (the one home for what it uses now), with its metric
|
||||||
|
anomalies above when it has any; its lifecycle.
|
||||||
|
A swarm task is titled by its service and slot, the raw name under it. #}
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% from "_macros.html" import crumbs, status_badge %}
|
||||||
|
{% block title %}{{ label or name }} — Docker — Steward{% endblock %}
|
||||||
|
{% block breadcrumb %}
|
||||||
|
{%- if container is not none and container.service_name -%}
|
||||||
|
{{ crumbs([("Docker", "/plugins/docker/"), (container.service_name, "/plugins/docker/service/" ~ container.service_name), (label, "")]) }}
|
||||||
|
{%- else -%}
|
||||||
|
{{ crumbs([("Docker", "/plugins/docker/"), (label or name, "")]) }}
|
||||||
|
{%- endif -%}
|
||||||
|
{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
{% if container is none %}
|
||||||
|
<div class="card empty-state">
|
||||||
|
<strong>Container not found</strong>
|
||||||
|
<span>No container named <code>{{ name }}</code> is reported for this host now. It may have been
|
||||||
|
removed, or the host agent has not reported recently. <a href="/plugins/docker/">Back to Docker</a></span>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
{% set c = container %}
|
||||||
|
|
||||||
|
{# ── Header: the verdict, then what it is ────────────────────────────────── #}
|
||||||
|
<div class="page-header">
|
||||||
|
<div style="min-width:0;">
|
||||||
|
<h1 class="page-title" style="overflow-wrap:anywhere;">{{ label }}</h1>
|
||||||
|
<p class="page-subtitle">
|
||||||
|
{% if c.status == 'running' %}{{ status_badge('ok', 'Running') }}
|
||||||
|
{% elif c.status == 'restarting' %}{{ status_badge('crit', 'Restarting') }}
|
||||||
|
{% elif c.status == 'paused' %}{{ status_badge('warn', 'Paused') }}
|
||||||
|
{% elif exit_failed %}{{ status_badge('crit', c.status|capitalize) }}
|
||||||
|
{% else %}{{ status_badge('dim', c.status|capitalize) }}{% endif %}
|
||||||
|
{%- if c.health == 'unhealthy' %} {{ status_badge('crit', 'Unhealthy') }}
|
||||||
|
{%- elif c.health == 'starting' %} {{ status_badge('pending', 'Starting') }}
|
||||||
|
{%- elif c.health == 'healthy' %} {{ status_badge('ok', 'Healthy') }}{% endif %}
|
||||||
|
{%- if uptime %} · up {{ uptime }}{% endif %}
|
||||||
|
{%- if host %} · on <a href="/hosts/{{ host.id }}">{{ host.name }}</a>{% endif %}
|
||||||
|
{%- if c.service_name %} · task of <a href="/plugins/docker/service/{{ c.service_name }}">{{ c.service_name }}</a>
|
||||||
|
{%- elif c.compose_project %} · compose <span class="chip">{{ c.compose_project }}</span>{% endif %}
|
||||||
|
{%- if c.image %} · <span class="mono" style="overflow-wrap:anywhere;" title="{{ c.image }}">{{ c.image.split("@")[0] }}</span>{% endif %}
|
||||||
|
</p>
|
||||||
|
<p class="page-subtitle tone-dim" style="margin-top:0.2rem;">
|
||||||
|
{%- if label != c.name %}<span class="mono" style="overflow-wrap:anywhere;">{{ c.name }}</span> · {% endif -%}
|
||||||
|
{%- if c.scraped_at %}reported {{ c.scraped_at|ago }}{% endif -%}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="page-actions"><a class="btn btn-sm btn-ghost" href="/plugins/docker/container/{{ host_id }}/{{ name }}/logs">Logs</a></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Why it stopped, only while it has ───────────────────────────────────── #}
|
||||||
|
{% if c.status != 'running' and exit_failed %}
|
||||||
|
<section class="card problem-card" aria-labelledby="ctr-exit">
|
||||||
|
<h2 id="ctr-exit" class="section-title" style="margin-bottom:0.4rem;">Last exit</h2>
|
||||||
|
<p style="margin:0;">
|
||||||
|
Exited <span class="tone-crit">{{ c.exit_code }}</span>{% if c.oom_killed %}: killed for running out of memory{% endif %}
|
||||||
|
{%- if died_at %} · {{ died_at|ago }}{% endif %}
|
||||||
|
· <a href="/plugins/docker/container/{{ host_id }}/{{ name }}/logs">its logs →</a>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# ── Its CPU or memory out of its normal for the hour, and past anomalies (#5742) #}
|
||||||
|
{% with back=anomaly_back, base_resource=anomaly_resource %}{% include "_anomaly_card.html" %}{% endwith %}
|
||||||
|
|
||||||
|
{# ── What it is set up with, and what it moved since it started ─────────── #}
|
||||||
|
<div class="card">
|
||||||
|
<dl class="kv">
|
||||||
|
{% if not c.service_name %}
|
||||||
|
{#- Swarm never restarts a task's container: it replaces the task, so a
|
||||||
|
task's restart count is always 0; its service's Tasks table is the
|
||||||
|
count that means something. #}
|
||||||
|
<dt>Restarts</dt><dd>{% if c.restart_count %}<span class="tone-warn">{{ c.restart_count }}</span>{% else %}0{% endif %}
|
||||||
|
{%- if c.exit_code is not none and c.status == 'running' and c.restart_count %} <span class="tone-dim">· last exit {{ c.exit_code }}</span>{% endif %}</dd>
|
||||||
|
{% endif %}
|
||||||
|
<dt>Memory limit</dt><dd>{% if usage.mem_limit %}{{ usage.mem_limit|bytes }}{% else %}<span class="tone-dim">none set</span>{% endif %}</dd>
|
||||||
|
{% if ports %}
|
||||||
|
<dt>Ports</dt><dd class="mono">{% for p in ports %}{{ p.host_port }}:{{ p.container_port }}/{{ p.protocol }}{% if not loop.last %}, {% endif %}{% endfor %}</dd>
|
||||||
|
{% endif %}
|
||||||
|
{% if c.node_id %}
|
||||||
|
<dt>Swarm node</dt><dd title="node id {{ c.node_id }}">{{ node_name or c.node_id[:12] }}</dd>
|
||||||
|
{% endif %}
|
||||||
|
<dt>Network</dt><dd>{{ net_rx }} in · {{ net_tx }} out <span class="tone-dim">since it started</span></dd>
|
||||||
|
<dt>Disk</dt><dd>{{ blk_read }} read · {{ blk_write }} written <span class="tone-dim">since it started</span></dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Resource history: the one home for what it uses ────────────────────── #}
|
||||||
|
<section class="card" aria-labelledby="ctr-history">
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;gap:1rem;margin-bottom:0.75rem;flex-wrap:wrap;">
|
||||||
|
<h2 id="ctr-history" class="section-title" style="margin:0;">Resource history</h2>
|
||||||
|
{% include "_time_range.html" %}
|
||||||
|
</div>
|
||||||
|
<div id="container-history"
|
||||||
|
hx-get="/plugins/docker/container/{{ host_id }}/{{ name }}/history"
|
||||||
|
hx-trigger="load, rangeChange from:body"
|
||||||
|
hx-include="#time-range"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<p class="field-hint">Loading…</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{# ── Lifecycle, newest first ─────────────────────────────────────────────── #}
|
||||||
|
<section class="card-flush" aria-labelledby="ctr-lifecycle">
|
||||||
|
<h2 id="ctr-lifecycle" class="table-caption">Lifecycle <span class="tone-muted">newest first</span></h2>
|
||||||
|
{% if events %}
|
||||||
|
<table class="table">
|
||||||
|
<thead><tr><th>Event</th><th>Detail</th><th class="num">When</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for e in events %}
|
||||||
|
<tr>
|
||||||
|
<td><span class="tone-{{ e.tone }}" aria-hidden="true">{{ e.glyph }}</span> {{ e.event|replace("_", " ") }}</td>
|
||||||
|
<td class="tone-muted" style="overflow-wrap:anywhere;">{{ e.detail or "" }}</td>
|
||||||
|
<td class="num">{{ e.at|ago }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p class="tone-muted" style="margin:0;padding:1rem;">No lifecycle events recorded yet. Starts, stops and health changes appear here as the agent reports them.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -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 %}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
{# docker/disk.html — what Docker keeps on each host's disk (#5304, #5305):
|
||||||
|
the hosts side by side with how much each grew, then each host's parts,
|
||||||
|
its cleanup actions, and its images folded under their repository. #}
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% from "_macros.html" import crumbs, page_header, stat_tile, status_badge %}
|
||||||
|
{% block title %}Disk — Docker — Steward{% endblock %}
|
||||||
|
{% block breadcrumb %}{{ crumbs([("Docker", "/plugins/docker/"), ("Disk", "")]) }}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
{% call page_header("Docker disk", "What Docker keeps on each host's disk, from docker system df, and how it has grown.") %}
|
||||||
|
{% include "_time_range.html" %}
|
||||||
|
{% endcall %}
|
||||||
|
|
||||||
|
{% if host_groups %}
|
||||||
|
<section class="card-flush" aria-labelledby="disk-hosts">
|
||||||
|
<h2 id="disk-hosts" class="table-caption">By host <span class="tone-muted">most space first</span></h2>
|
||||||
|
<div hx-get="/plugins/docker/disk/hosts" hx-trigger="load, rangeChange from:body"
|
||||||
|
hx-include="#time-range" hx-swap="innerHTML">
|
||||||
|
<p class="field-hint" style="padding:1rem;margin:0;">Loading…</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% for g in host_groups %}
|
||||||
|
{% set s = g.summary %}
|
||||||
|
<section id="host-{{ g.host_id }}" class="disk-host" aria-labelledby="disk-host-{{ g.host_id }}">
|
||||||
|
<h2 id="disk-host-{{ g.host_id }}" class="disk-host-head">
|
||||||
|
<a href="/hosts/{{ g.host_id }}">{{ g.host_name }}</a>
|
||||||
|
<span class="tone-muted">{{ g.total|bytes }}</span>
|
||||||
|
{% if g.as_of %}<span class="field-hint">as of {{ g.as_of|ago }}</span>{% endif %}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="stat-strip">
|
||||||
|
{{ stat_tile("Reclaimable", s.images_reclaimable|bytes, state="warn" if s.images_reclaimable else "ok", hint="held by images no container uses") }}
|
||||||
|
{{ stat_tile("Images", s.images_size|bytes, hint=s.images_active ~ " of " ~ s.images_total ~ " in use") }}
|
||||||
|
{{ stat_tile("Container files", s.containers_size|bytes, hint="written by " ~ s.containers_count ~ " containers on top of their images") }}
|
||||||
|
{{ stat_tile("Volumes", s.volumes_size|bytes, hint=s.volumes_count ~ " volumes; never pruned here") }}
|
||||||
|
{{ stat_tile("Build cache", s.build_cache_size|bytes, hint="left by docker build; safe to clear") }}
|
||||||
|
{{ stat_tile("Stopped", g.stopped, state="dim" if not g.stopped else none, hint="containers not running") }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Cleanup actions (admin only; audited Ansible prune run) ───────────── #}
|
||||||
|
{% if session.user_role == 'admin' %}
|
||||||
|
<div class="disk-actions">
|
||||||
|
{% if ansible_available and g.has_target %}
|
||||||
|
<form method="post" action="/plugins/docker/disk/{{ g.host_id }}/prune"
|
||||||
|
data-confirm="Remove all STOPPED containers on {{ g.host_name }}? This cannot be undone.">{{ csrf_field() }}
|
||||||
|
<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"
|
||||||
|
data-confirm="Remove ALL unused images on {{ g.host_name }} (docker image prune -a)? Any image not used by a container is deleted.">{{ csrf_field() }}
|
||||||
|
<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"
|
||||||
|
data-confirm="Run a full system prune on {{ g.host_name }}? Removes stopped containers, unused networks, untagged images nothing uses, and the build cache.">{{ csrf_field() }}
|
||||||
|
<input type="hidden" name="target" value="system">
|
||||||
|
<button type="submit" class="btn btn-sm btn-danger">System prune…</button>
|
||||||
|
</form>
|
||||||
|
<span class="field-hint">Each runs an audited Ansible playbook on {{ g.host_name }}; reclaimed space shows on the next agent sample.</span>
|
||||||
|
{% elif not ansible_available %}
|
||||||
|
<span class="field-hint">Cleanup needs the Ansible runner (currently unavailable).</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="field-hint">Link an <a href="/hosts/{{ g.host_id }}">Ansible target</a> to this host to enable prune actions.</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# ── Images by repository, versions folded under it (#5305) ─────────── #}
|
||||||
|
<section class="card-flush" aria-label="Images on {{ g.host_name }}">
|
||||||
|
<h3 class="table-caption">Images by repository
|
||||||
|
<span class="tone-muted">{% if g.images_capped %}the {{ g.images_listed }} largest of {{ s.images_total }} images{% else %}{{ g.images_listed }} images{% endif %}</span></h3>
|
||||||
|
{% if g.repos %}
|
||||||
|
<table class="table image-repos">
|
||||||
|
<thead><tr>
|
||||||
|
<th>Repository</th>
|
||||||
|
<th class="num">Versions</th>
|
||||||
|
<th class="num" title="Each version's size less the layers it shares with other images, added up: what removing them all would free, roughly">Own size</th>
|
||||||
|
<th class="num">Used by</th>
|
||||||
|
<th class="num" title="Own size of the versions no container uses">Reclaimable</th>
|
||||||
|
</tr></thead>
|
||||||
|
{% for r in g.repos %}
|
||||||
|
<tbody class="image-repo">
|
||||||
|
<tr>
|
||||||
|
<td class="cell-name">
|
||||||
|
<details>
|
||||||
|
<summary title="{{ r.repo or 'untagged images' }}">{% if r.repo %}<span class="mono">{{ r.repo }}</span>{% else %}<span class="tone-muted">untagged</span>{% endif %}</summary>
|
||||||
|
<table class="table image-versions">
|
||||||
|
<thead><tr><th>Tag</th><th>ID</th><th class="num">Size</th><th class="num" title="Layers in common with other images: stored once">Shared</th><th class="num">Used by</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for v in r.versions %}
|
||||||
|
<tr>
|
||||||
|
<td class="mono">{{ v.tag or "—" }}</td>
|
||||||
|
<td class="mono tone-muted">{{ (v.image_id or "")|replace("sha256:", "")|truncate(12, true, "") }}</td>
|
||||||
|
<td class="num">{{ v.size|bytes }}</td>
|
||||||
|
<td class="num tone-muted">{{ v.shared|bytes }}</td>
|
||||||
|
<td class="num">{% if v.containers %}{{ v.containers }} container{{ "s" if v.containers != 1 }}{% else %}<span class="tone-warn">nothing</span>{% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</details>
|
||||||
|
</td>
|
||||||
|
<td class="num">{{ r.versions|length }}{% if r.unused %} <span class="tone-dim">({{ r.unused }} unused)</span>{% endif %}</td>
|
||||||
|
<td class="num">{{ r.own|bytes }}</td>
|
||||||
|
<td class="num">{% if r.containers %}{{ r.containers }} container{{ "s" if r.containers != 1 }}{% else %}<span class="tone-warn">nothing</span>{% endif %}</td>
|
||||||
|
<td class="num">{% if r.reclaimable %}<span class="tone-warn">{{ r.reclaimable|bytes }}</span>{% else %}<span class="tone-dim">—</span>{% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p class="tone-muted" style="margin:0;padding:1rem;">No images reported.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="card empty-state">
|
||||||
|
No disk usage reported yet. The host agent reports what Docker keeps on disk,
|
||||||
|
from <code>docker system df</code>, on hosts running Docker.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
{# docker/host_panel.html — per-host Docker fragment embedded on the Hosts hub:
|
||||||
|
problems first with what is wrong and since when, then the busiest running
|
||||||
|
containers; the rest of the running and the stopped fold away so the card
|
||||||
|
stays short (#5276). Tasks Swarm has replaced are left out (counted in the
|
||||||
|
header). #}
|
||||||
|
{% from "_macros.html" import status_badge, status_dot %}
|
||||||
|
{% from "docker/_usage.html" import cpu, mem %}
|
||||||
|
{% macro container_table(rows) %}
|
||||||
|
<table class="table">
|
||||||
|
<colgroup><col><col style="width:7rem"><col style="width:9rem"></colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Container</th>
|
||||||
|
<th title="Docker counts CPU per core: 1 core = one core fully busy">CPU</th>
|
||||||
|
<th title="Used, and the limit when one is set">Memory</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in rows %}
|
||||||
|
{% set c = r.c %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div style="display:flex;align-items:center;gap:0.5rem;min-width:0;">
|
||||||
|
{{ status_dot('crit' if r.problems else ('up' if c.status == 'running' else 'inactive'), c.status|capitalize) }}
|
||||||
|
<div style="min-width:0;">
|
||||||
|
<a href="/plugins/docker/container/{{ c.host_id }}/{{ c.name }}" title="{{ c.name }} · {{ c.image }}" style="font-weight:500;color:inherit;text-decoration:none;overflow-wrap:anywhere;">{{ r.label }}</a>
|
||||||
|
{% if r.problems %}
|
||||||
|
{% set worst = r.problems[0] %}
|
||||||
|
<div style="font-size:0.75rem;margin-top:0.1rem;">
|
||||||
|
{{ status_badge('crit' if severity[worst] == 'critical' else 'warn', worst|replace('_', ' ')|capitalize) }}
|
||||||
|
<span style="color:var(--text-muted);">
|
||||||
|
{%- for p in r.problems %} {{ says[p] }}{{ " and" if not loop.last }}{% endfor %}
|
||||||
|
{%- if c.exit_code %} · exit {{ c.exit_code }}{% if c.oom_killed %} (OOM){% endif %}{% endif %}
|
||||||
|
{%- if r.since %} · since {{ r.since|ago }}{% endif -%}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div style="font-size:0.72rem;color:var(--text-muted);">
|
||||||
|
{{ c.status }}{% if r.uptime %} · up {{ r.uptime }}{% endif %}
|
||||||
|
{%- if c.status != 'running' and c.exit_code %} · exit {{ c.exit_code }}{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style="font-family:ui-monospace,monospace;font-size:0.85rem;white-space:nowrap;">{% if c.status == 'running' %}{{ cpu(r.usage) }}{% else %}<span class="tone-dim">—</span>{% endif %}</td>
|
||||||
|
<td style="font-family:ui-monospace,monospace;font-size:0.85rem;white-space:nowrap;">{% if c.status == 'running' %}{{ mem(r.usage) }}{% else %}<span class="tone-dim">—</span>{% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endmacro %}
|
||||||
|
<div class="card">
|
||||||
|
<div style="display:flex;align-items:baseline;justify-content:space-between;gap:0.6rem;margin-bottom:0.6rem;flex-wrap:wrap;">
|
||||||
|
<h3 class="section-title" style="margin-bottom:0;">Docker</h3>
|
||||||
|
<span style="font-size:0.78rem;color:var(--text-muted);">
|
||||||
|
{% if problems %}<span class="tone-crit">{{ problems }} with problems</span> · {% endif %}
|
||||||
|
{{ running }} running{% if stopped %} · {{ stopped }} stopped{% endif %}
|
||||||
|
{% if replaced %}<span title="Old swarm tasks Swarm has replaced with new ones: history, not containers that need anything"> · {{ replaced }} replaced task{{ "s" if replaced != 1 }} hidden</span>{% endif %}
|
||||||
|
<a href="/plugins/docker/" style="margin-left:0.6rem;color:var(--text-muted);">All →</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{{ container_table(rows) }}
|
||||||
|
{% if folded %}
|
||||||
|
<details style="margin-top:0.5rem;">
|
||||||
|
<summary style="cursor:pointer;font-size:0.82rem;color:var(--text-muted);">
|
||||||
|
{%- set more_running = folded|selectattr("c.status", "equalto", "running")|list|length -%}
|
||||||
|
{%- set more_stopped = folded|length - more_running -%}
|
||||||
|
{%- if more_running %}+{{ more_running }} more running{% endif -%}
|
||||||
|
{%- if more_running and more_stopped %} · {% endif -%}
|
||||||
|
{%- if more_stopped %}{{ more_stopped }} stopped{% endif -%}
|
||||||
|
</summary>
|
||||||
|
{{ container_table(folded) }}
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% from "_macros.html" import page_header %}
|
||||||
|
{# The Docker page. The filter bar sits outside the polled fragment, so a poll
|
||||||
|
never resets what is being typed; the fragment reads the bar on every poll,
|
||||||
|
and a change re-fetches it and puts the filters in the URL (#5299). #}
|
||||||
|
{% block title %}Docker — Steward{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
{% call page_header("Docker", "What needs attention first, then every service and container.") %}
|
||||||
|
{% if has_swarm %}<a class="btn btn-sm btn-ghost" href="/plugins/docker/swarm">Swarm</a>{% endif %}
|
||||||
|
{% if has_disk %}<a class="btn btn-sm btn-ghost" href="/plugins/docker/disk">Disk</a>{% endif %}
|
||||||
|
{% endcall %}
|
||||||
|
|
||||||
|
<form id="docker-filters" class="filter-bar" method="get" action="/plugins/docker/" role="search">
|
||||||
|
<input type="search" id="docker-q" name="q" value="{{ f.q }}" class="input"
|
||||||
|
placeholder="Search name, image or stack" aria-label="Search services and containers" autocomplete="off">
|
||||||
|
{% if choices.stacks %}
|
||||||
|
<select name="stack" aria-label="Stack">
|
||||||
|
<option value="">All stacks</option>
|
||||||
|
{% for s in choices.stacks %}<option value="{{ s }}"{% if f.stack == s %} selected{% endif %}>{{ s }}</option>{% endfor %}
|
||||||
|
<option value="{{ no_stack }}"{% if f.stack == no_stack %} selected{% endif %}>No stack</option>
|
||||||
|
</select>
|
||||||
|
{% endif %}
|
||||||
|
{% if choices.hosts|length > 1 %}
|
||||||
|
<select name="host" aria-label="Host or node">
|
||||||
|
<option value="">All hosts</option>
|
||||||
|
{% for h in choices.hosts %}<option value="{{ h }}"{% if f.host == h %} selected{% endif %}>{{ h }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% endif %}
|
||||||
|
<select name="state" aria-label="Status">
|
||||||
|
<option value="">Any status</option>
|
||||||
|
{% for key, label in states.items() %}<option value="{{ key }}"{% if f.state == key %} selected{% endif %}>{{ label }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% if f.sort != 'status' %}<input type="hidden" name="sort" value="{{ f.sort }}"><input type="hidden" name="dir" value="{{ f.dir }}">{% endif %}
|
||||||
|
<noscript><button type="submit" class="btn btn-sm">Filter</button></noscript>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="docker-rows"
|
||||||
|
hx-get="/plugins/docker/rows"
|
||||||
|
hx-include="#docker-filters"
|
||||||
|
hx-trigger="load, every {{ poll_interval }}s, change from:#docker-filters, input changed delay:250ms from:#docker-q, search from:#docker-q"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<div class="card"><p class="field-hint">Loading…</p></div>
|
||||||
|
</div>
|
||||||
|
{% if disk %}
|
||||||
|
{# What Docker keeps on each host's disk, and its growth over a week (#5306). #}
|
||||||
|
<p class="docker-disk-line">
|
||||||
|
<a href="/plugins/docker/disk" class="section-title">Disk</a>
|
||||||
|
{% for d in disk %}
|
||||||
|
<span class="docker-disk-host"><a href="/plugins/docker/disk#host-{{ d.host_id }}">{{ d.host_name }}</a>
|
||||||
|
{{ d.total|bytes }}
|
||||||
|
{%- if d.growth and d.growth.change %} <span class="tone-{{ 'warn' if d.growth.pct is not none and d.growth.pct >= 10 else 'muted' }}"
|
||||||
|
title="Since {{ d.growth.since.strftime('%Y-%m-%d %H:%M UTC') }}">{{ "+" if d.growth.change > 0 else "−" }}{{ (d.growth.change|abs)|bytes }} in 7d</span>{% endif -%}
|
||||||
|
{%- if d.reclaimable %} <span class="tone-dim">· {{ d.reclaimable|bytes }} reclaimable</span>{% endif %}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var rows = document.getElementById('docker-rows');
|
||||||
|
var form = document.getElementById('docker-filters');
|
||||||
|
// The filters live in the URL too, so a filtered view can be bookmarked and
|
||||||
|
// Back returns to it.
|
||||||
|
function keepInUrl() {
|
||||||
|
var q = new URLSearchParams(new FormData(form));
|
||||||
|
Array.from(q.keys()).forEach(function (k) { if (!q.get(k)) q.delete(k); });
|
||||||
|
var s = q.toString();
|
||||||
|
history.replaceState(null, '', location.pathname + (s ? '?' + s : '') + location.hash);
|
||||||
|
}
|
||||||
|
form.addEventListener('change', keepInUrl);
|
||||||
|
form.addEventListener('input', keepInUrl);
|
||||||
|
// A link to a section (the dashboard's "failed (24h)" → #failures) lands
|
||||||
|
// before the rows exist; scroll to it once, when they first arrive.
|
||||||
|
if (!location.hash || !rows) return;
|
||||||
|
rows.addEventListener('htmx:afterSwap', function once() {
|
||||||
|
rows.removeEventListener('htmx:afterSwap', once);
|
||||||
|
var target = document.getElementById(location.hash.slice(1));
|
||||||
|
if (target) target.scrollIntoView({ block: 'start' });
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
{# docker/rows.html — HTMX fragment for the Docker page (#5298–#5301):
|
||||||
|
a verdict line; what needs
|
||||||
|
attention (service.needs_attention, the ranking the MCP tools use); hosts
|
||||||
|
whose Docker is not answering; what failed in 24h, each with its logs; then
|
||||||
|
the swarm services and the standalone containers as two tables that share
|
||||||
|
the filter bar's search, filters and sort (overview.py). #}
|
||||||
|
{% from "_macros.html" import status_badge, status_dot, sort_th %}
|
||||||
|
{% from "docker/_usage.html" import cpu, mem, cores %}
|
||||||
|
{% macro th(key, label, cls=none, title=none) -%}
|
||||||
|
{{ sort_th(label, sort_url(key), active=f.sort == key, desc=f.dir == 'desc', cls=cls, title=title) }}
|
||||||
|
{%- endmacro %}
|
||||||
|
{% macro up(row) -%}
|
||||||
|
{%- if row.started_at %}<span title="Started {{ row.started_at.strftime('%Y-%m-%d %H:%M UTC') }}">{{ row.started_at|ago|striptags|replace(" ago", "") }}</span>
|
||||||
|
{%- else %}<span class="tone-dim">—</span>{% endif -%}
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{# ── Verdict ──────────────────────────────────────────────────────────────── #}
|
||||||
|
{% set problems = attention|length + failing_services|length %}
|
||||||
|
<p class="page-verdict">
|
||||||
|
{% if problems %}<strong class="tone-crit">{{ problems }} need{{ "s" if problems == 1 }} attention</strong>
|
||||||
|
{% else %}<strong class="tone-ok">Nothing needs attention</strong>{% endif %}
|
||||||
|
{%- if services_total %} · <span class="tone-{{ 'ok' if services_healthy == services_total else 'warn' }}">{{ services_healthy }}/{{ services_total }}</span> services healthy{% endif %}
|
||||||
|
{%- if containers_total %} · {{ running }} container{{ "s" if running != 1 }} running{% if stopped %}, {{ stopped }} stopped{% endif %}{% if services_total %} outside services{% endif %}{% endif %}
|
||||||
|
{%- if docker_hosts %} · {{ docker_hosts }} Docker host{{ "s" if docker_hosts != 1 }}{% endif %}
|
||||||
|
{%- if stale %} · <span class="tone-warn" title="No recent report from their agent: what is shown may no longer be true">{{ stale }} stale</span>{% endif %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{# ── Needs attention ──────────────────────────────────────────────────────── #}
|
||||||
|
{% if attention or failing_services %}
|
||||||
|
<section class="card problem-card" aria-labelledby="docker-problems">
|
||||||
|
<h2 id="docker-problems" class="section-title section-count">Needs attention <span class="tone-crit">{{ problems }}</span>
|
||||||
|
{%- if unhealthy %}<span class="tone-muted"> · {{ unhealthy }} unhealthy</span>{% endif %}
|
||||||
|
{%- if restarting %}<span class="tone-muted"> · {{ restarting }} restarting</span>{% endif %}</h2>
|
||||||
|
<ul class="problem-list">
|
||||||
|
{# A swarm service in trouble: Swarm replaces its failed tasks, so the
|
||||||
|
service, not any one container, is what is failing. #}
|
||||||
|
{% for s in failing_services %}
|
||||||
|
{% set e = s.last_error %}
|
||||||
|
<li>
|
||||||
|
{% if s.crash_looping %}{{ status_badge('crit', 'Crash-looping') }}
|
||||||
|
{% elif s.state == 'down' %}{{ status_badge('crit', 'Down') }}
|
||||||
|
{% else %}{{ status_badge('warn', 'Degraded') }}{% endif %}
|
||||||
|
<a href="/plugins/docker/service/{{ s.name }}">{{ s.name }}</a>
|
||||||
|
<span class="problem-detail">
|
||||||
|
{{- s.running }} of {{ s.desired }} running
|
||||||
|
{%- if s.crash_looping %} · {{ s.failures_1h }} tasks failed in the last hour{% endif %}
|
||||||
|
{%- if e %} · last task {{ e.state }}: {{ e.error or e.message or e.state }}{% if e.exit_code %} (exit {{ e.exit_code }}){% endif %}{% if e.node %} on {{ e.node }}{% endif %}{% endif %}
|
||||||
|
{%- if s.trouble_since %} · since {{ s.trouble_since|ago }}{% endif -%}
|
||||||
|
</span>
|
||||||
|
<a class="field-hint" href="/plugins/docker/service/{{ s.name }}{% if e and e.task_id %}?task={{ e.task_id }}{% endif %}#logs" style="margin-left:0.4rem;">Logs →</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
{% for r in attention %}
|
||||||
|
{% set c = r.container %}
|
||||||
|
{% set worst = r.problems[0] %}
|
||||||
|
<li>
|
||||||
|
{{ status_badge('crit' if severity[worst] == 'critical' else 'warn', worst|replace('_', ' ')|capitalize) }}
|
||||||
|
<a href="/plugins/docker/container/{{ c.host_id }}/{{ c.name }}" title="{{ c.name }}">{{ r.label }}</a>
|
||||||
|
<span class="problem-detail">
|
||||||
|
{%- for p in r.problems %}{{ says[p] }}{{ " and " if not loop.last }}{% endfor %}
|
||||||
|
{%- if c.exit_code and worst in ('exited_nonzero', 'crash_looping', 'restarting') %} · exit {{ c.exit_code }}{% endif %}
|
||||||
|
{%- if r.since %} · since {{ r.since|ago }}{% endif %}
|
||||||
|
{%- if r.stale %} · {{ status_badge('stale') }} last report {{ c.scraped_at|ago if c.scraped_at else "never" }}{% endif -%}
|
||||||
|
</span>
|
||||||
|
<a class="field-hint" href="/plugins/docker/container/{{ c.host_id }}/{{ c.name }}/logs" style="margin-left:0.4rem;">Logs →</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# ── Hosts whose Docker is not answering (agent ≥ 1.9.0) ──────────────────── #}
|
||||||
|
{% if docker_down %}
|
||||||
|
<section class="card problem-card" aria-labelledby="docker-unreachable">
|
||||||
|
<h2 id="docker-unreachable" class="section-title" style="margin-bottom:0.6rem;">Docker not answering</h2>
|
||||||
|
<ul class="problem-list">
|
||||||
|
{% for d in docker_down %}
|
||||||
|
<li>
|
||||||
|
{{ status_badge('crit', 'Error') if d.status == 'error' else status_badge('stale', 'No Docker') }}
|
||||||
|
<a href="/hosts/{{ d.host_id }}">{{ d.host_name }}</a>
|
||||||
|
<span class="problem-detail">{{ d.detail or "no reason given" }} · since {{ d.since|ago }} · its containers below are what Docker last reported</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# ── What failed in the last 24h: what the dashboard's "failed (24h)" counts.
|
||||||
|
A plain container's death, or a swarm service's failed tasks; a container
|
||||||
|
someone stopped (exit 137/143) is not a failure. Each links to the logs of
|
||||||
|
what died: a service's to its failed task's lines (#5300). ───────────── #}
|
||||||
|
{% if failures %}
|
||||||
|
<section id="failures" class="card" aria-labelledby="docker-failures">
|
||||||
|
<h2 id="docker-failures" class="section-title section-count">Failed in the last 24h <span class="tone-warn">{{ failures|length }}</span></h2>
|
||||||
|
<ul class="problem-list">
|
||||||
|
{% for x in failures %}
|
||||||
|
<li>
|
||||||
|
{{ status_badge('warn', 'Service' if x.kind == 'service' else 'Container') }}
|
||||||
|
{% if x.kind == 'service' %}<a href="/plugins/docker/service/{{ x.name }}">{{ x.name }}</a>
|
||||||
|
{% else %}<a href="/plugins/docker/container/{{ x.host_id }}/{{ x.name }}">{{ x.name }}</a>{% endif %}
|
||||||
|
<span class="problem-detail">
|
||||||
|
{{- x.count }} {{ "failure" if x.count == 1 else "failures" }}
|
||||||
|
{%- if x.detail %} · last: {{ x.detail }}{% endif %}
|
||||||
|
{%- if x.host %} · {{ x.host }}{% endif %}
|
||||||
|
{%- if x.at %} · {{ x.at|ago }}{% endif -%}
|
||||||
|
</span>
|
||||||
|
{% if x.kind == 'service' %}
|
||||||
|
<a class="field-hint" href="/plugins/docker/service/{{ x.name }}{% if x.task_id %}?task={{ x.task_id }}{% endif %}#logs" style="margin-left:0.4rem;">Logs{% if x.task_id %} of that task{% endif %} →</a>
|
||||||
|
{% else %}
|
||||||
|
<a class="field-hint" href="/plugins/docker/container/{{ x.host_id }}/{{ x.name }}/logs" style="margin-left:0.4rem;">Logs →</a>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# ── Swarm services: one row each, problems first unless a header picked
|
||||||
|
another order; CPU and memory add up the running replicas (#5299). ───── #}
|
||||||
|
{% if services %}
|
||||||
|
<section class="card-flush" aria-labelledby="docker-services">
|
||||||
|
<h2 id="docker-services" class="table-caption">Swarm services <span class="tone-muted">{{ services|length }}{% if services|length != services_total %} of {{ services_total }}{% endif %}</span>
|
||||||
|
<a href="/plugins/docker/swarm" class="field-hint">By node →</a></h2>
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{{ th("name", "Service") }}
|
||||||
|
{{ th("status", "Status", title="Problems first: a crash loop, then down, then short of replicas, then failed tasks in 24h") }}
|
||||||
|
{{ th("stack", "Stack", title="The part of the service's name before the first _: the stack it was deployed in") }}
|
||||||
|
<th title="The nodes running its replicas">Nodes</th>
|
||||||
|
{{ th("cpu", "CPU", "num", "Across its running replicas; Docker counts CPU per core") }}
|
||||||
|
{{ th("mem", "Memory", "num", "Across its running replicas") }}
|
||||||
|
{{ th("up", "Up", "num", "Since its newest replica started") }}
|
||||||
|
<th class="num" title="Tasks that failed in the last 24h; Swarm replaced them">Failed 24h</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in services %}
|
||||||
|
<tr>
|
||||||
|
<td class="cell-name"><a href="/plugins/docker/service/{{ r.name }}" title="{{ r.name }}{% if r.image %} · {{ r.image }}{% endif %}">{{ r.name }}</a></td>
|
||||||
|
<td>
|
||||||
|
{{ status_dot('up' if r.state == 'healthy' else ('warn' if r.state == 'degraded' else 'down')) }}
|
||||||
|
<span class="tone-{{ 'ok' if r.state == 'healthy' else ('warn' if r.state == 'degraded' else 'crit') }}" style="font-variant-numeric:tabular-nums;">{{ r.running }}/{{ r.desired }}</span>
|
||||||
|
{%- if r.crash_looping %} {{ status_badge('crit', 'Crash-looping') }}{% elif r.unhealthy %} {{ status_badge('crit', 'Unhealthy') }}{% endif %}
|
||||||
|
{%- if r.mode == 'global' %} <span class="tone-dim">global</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{% if r.stack %}<a class="chip" href="{{ page_url(stack=r.stack) }}" title="Show the {{ r.stack }} stack">{{ r.stack }}</a>{% else %}<span class="tone-dim">—</span>{% endif %}</td>
|
||||||
|
<td class="tone-muted">
|
||||||
|
{{- r.nodes|join(", ") or ("" if r.ghost_nodes else "—") -}}
|
||||||
|
{%- if r.ghost_nodes %}{{ ", " if r.nodes }}<span title="Running on a node with no Steward agent: no figures for it">{{ r.ghost_nodes|join(", ") }} (no agent)</span>{% endif -%}
|
||||||
|
</td>
|
||||||
|
<td class="num">{% if r.cpu_cores is not none %}{{ cores(r.cpu_cores) }}{% else %}<span class="tone-dim">—</span>{% endif %}</td>
|
||||||
|
<td class="num">{% if r.mem_bytes is not none %}{{ r.mem_bytes|bytes }}{% else %}<span class="tone-dim">—</span>{% endif %}</td>
|
||||||
|
<td class="num">{{ up(r) }}</td>
|
||||||
|
<td class="num">
|
||||||
|
{%- if r.failures_24h %}<a class="tone-warn" href="/plugins/docker/service/{{ r.name }}#tasks"
|
||||||
|
title="{% if r.last_error %}Last: {{ r.last_error.error or r.last_error.message or r.last_error.state }}{% if r.last_error.exit_code %} (exit {{ r.last_error.exit_code }}){% endif %}{% endif %}">{{ r.failures_24h }}</a>
|
||||||
|
{%- else %}<span class="tone-dim">—</span>{% endif -%}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# ── Containers outside any swarm service, every host in one table (#5301);
|
||||||
|
a compose project is its stack. ─────────────────────────────────────── #}
|
||||||
|
{% if containers %}
|
||||||
|
<section class="card-flush" aria-labelledby="docker-containers">
|
||||||
|
<h2 id="docker-containers" class="table-caption">{{ "Other containers" if services_total else "Containers" }} <span class="tone-muted">{{ containers|length }}{% if containers|length != containers_total %} of {{ containers_total }}{% endif %}</span></h2>
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{{ th("name", "Container") }}
|
||||||
|
{{ th("status", "Status", title="Problems first: unhealthy or restarting, then died with a failing exit, then stale; then running, then stopped") }}
|
||||||
|
<th>Host</th>
|
||||||
|
{{ th("stack", "Stack", title="Its compose project") }}
|
||||||
|
<th>Image</th>
|
||||||
|
{{ th("cpu", "CPU", "num", "Docker counts CPU per core: 1 core = one core fully busy") }}
|
||||||
|
{{ th("mem", "Memory", "num", "Used, and the limit when one is set") }}
|
||||||
|
{{ th("up", "Up", "num", "Since it started") }}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in containers %}
|
||||||
|
<tr>
|
||||||
|
<td class="cell-name">
|
||||||
|
<a href="/plugins/docker/container/{{ r.host_id }}/{{ r.container_name }}" title="{{ r.container_name }}">{{ r.name }}</a>
|
||||||
|
{%- if r.ports %}<div class="cell-sub mono">{% for p in r.ports %}{{ p.host_port }}:{{ p.container_port }}/{{ p.protocol }}{{ " " if not loop.last }}{% endfor %}</div>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ status_dot('stale' if r.stale else ('up' if r.running else ('pending' if r.status == 'paused' else 'down'))) }}
|
||||||
|
{%- if r.health == 'unhealthy' %} {{ status_badge('crit', 'Unhealthy') }}
|
||||||
|
{%- elif r.health == 'starting' %} {{ status_badge('pending', 'Starting') }}
|
||||||
|
{%- else %} <span class="tone-muted">{{ r.status }}</span>{% endif %}
|
||||||
|
{%- if not r.running and r.exit_code is not none and r.exit_code != 0 %}
|
||||||
|
<span class="tone-{{ 'crit' if r.exit_failed else 'dim' }}" title="{{ 'last exit code' if r.exit_failed else 'stopped by a signal; not a failure' }}">exit {{ r.exit_code }}{% if r.oom_killed %} (OOM){% endif %}</span>
|
||||||
|
{%- endif %}
|
||||||
|
{%- if r.restart_count %} <span class="tone-warn" title="Restarted {{ r.restart_count }} times">⟳{{ r.restart_count }}</span>{% endif %}
|
||||||
|
{%- if r.stale %} {{ status_badge('stale', title="Last report " ~ (r.scraped_at.strftime('%Y-%m-%d %H:%M UTC') if r.scraped_at else 'never') ~ "; what is shown may no longer be true") }}{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="tone-muted">{{ r.host }}</td>
|
||||||
|
<td>{% if r.stack %}<a class="chip" href="{{ page_url(stack=r.stack) }}" title="Show the {{ r.stack }} stack">{{ r.stack }}</a>{% else %}<span class="tone-dim">—</span>{% endif %}</td>
|
||||||
|
<td class="cell-image" title="{{ r.image }}">{{ r.image }}</td>
|
||||||
|
<td class="num">{% if r.running %}{{ cpu(r.usage) }}{% else %}<span class="tone-dim">—</span>{% endif %}</td>
|
||||||
|
<td class="num">{% if r.running %}{{ mem(r.usage) }}{% else %}<span class="tone-dim">—</span>{% endif %}</td>
|
||||||
|
<td class="num">{{ up(r) }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if not services and not containers %}
|
||||||
|
<div class="card">
|
||||||
|
{% if services_total or containers_total %}
|
||||||
|
<p class="tone-muted" style="margin:0;">Nothing matches these filters. <a href="/plugins/docker/">Show everything</a></p>
|
||||||
|
{% else %}
|
||||||
|
<p class="tone-muted" style="margin:0;">
|
||||||
|
No containers reported yet. Containers are collected by the Steward host agent —
|
||||||
|
deploy the agent to a host running Docker (Hosts → the host → agent panel) and
|
||||||
|
its containers will appear here.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
{# docker/service.html — one swarm service (#5302, #5310): a header that says
|
||||||
|
whether it is well and what it runs; its last failure only while it is in
|
||||||
|
trouble; its CPU and memory over time; one table of its tasks, the running
|
||||||
|
ones first with what they use; and the logs of every task it has run.
|
||||||
|
A failure's "Logs →" lands on #logs with ?task= picked. #}
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% from "_macros.html" import crumbs, status_badge %}
|
||||||
|
{% from "docker/_usage.html" import cpu, mem %}
|
||||||
|
{% block title %}{{ name }} — Docker — Steward{% endblock %}
|
||||||
|
{% block breadcrumb %}{{ crumbs([("Docker", "/plugins/docker/"), (name, "")]) }}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
{% if service is none %}
|
||||||
|
<div class="card empty-state">
|
||||||
|
<strong>{{ "No longer running" if task_choices else "Service not found" }}</strong>
|
||||||
|
{% if task_choices %}
|
||||||
|
<span>No swarm service named <code>{{ name }}</code> is reported now; it may have been removed.
|
||||||
|
The logs Steward still keeps from its tasks are below.</span>
|
||||||
|
{% else %}
|
||||||
|
<span>No swarm service named <code>{{ name }}</code> is reported, and Steward holds no logs or
|
||||||
|
events for one. It may have been removed. <a href="/plugins/docker/">Back to Docker</a></span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
{# ── Header: the verdict, then what it is (#5310). Each fact has one home:
|
||||||
|
what it uses is in Resource history, its tasks and failures in Tasks. #}
|
||||||
|
{% set troubled = row.crash_looping or row.state != 'healthy' %}
|
||||||
|
{% set tone = 'ok' if row.state == 'healthy' else ('warn' if row.state == 'degraded' else 'crit') %}
|
||||||
|
{% set image = (svc.image if svc and svc.image else row.image) %}
|
||||||
|
{% set e = row.last_error %}
|
||||||
|
<div class="page-header">
|
||||||
|
<div style="min-width:0;">
|
||||||
|
<h1 class="page-title" style="overflow-wrap:anywhere;">{{ name }}</h1>
|
||||||
|
<p class="page-subtitle">
|
||||||
|
{% if row.crash_looping %}{{ status_badge('crit', 'Crash-looping') }}
|
||||||
|
{% elif row.state == 'down' %}{{ status_badge('crit', 'Down') }}
|
||||||
|
{% elif row.state == 'degraded' %}{{ status_badge('warn', 'Degraded') }}
|
||||||
|
{% else %}{{ status_badge('ok', 'Healthy') }}{% endif %}
|
||||||
|
<span class="tone-{{ tone }}">{{ row.running }} of {{ row.desired }} running</span>
|
||||||
|
{%- if troubled and row.trouble_since %} · <span class="tone-{{ tone }}">in trouble since {{ row.trouble_since|ago }}</span>{% endif %}
|
||||||
|
· {{ row.mode }}
|
||||||
|
{%- if stack %} · stack <a class="chip" href="/plugins/docker/?stack={{ stack }}">{{ stack }}</a>{% endif %}
|
||||||
|
{%- if image %} · <span class="mono" style="overflow-wrap:anywhere;" title="{{ image }}">{{ image.split("@")[0] }}</span>{% endif %}
|
||||||
|
</p>
|
||||||
|
{% if (not troubled and e) or svc %}
|
||||||
|
<p class="page-subtitle tone-dim" style="margin-top:0.2rem;">
|
||||||
|
{%- if not troubled and e %}Last failed {% if e.age_s is not none %}{{ e.age_s|duration }} ago{% endif %}
|
||||||
|
{%- if e.exit_code %} (exit {{ e.exit_code }}){% endif %}
|
||||||
|
{%- if e.task_id %} · <a href="?task={{ e.task_id }}#logs">its logs</a>{% endif %} · {% endif -%}
|
||||||
|
{%- if svc %}reported {{ svc.updated_at|ago }} by the manager's agent{% endif -%}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="page-actions"><a class="btn btn-sm btn-ghost" href="#logs">Logs</a></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── What is wrong, only while it is (#5310) ──────────────────────────────── #}
|
||||||
|
{% if troubled and e %}
|
||||||
|
<section class="card problem-card" aria-labelledby="svc-last-error">
|
||||||
|
<h2 id="svc-last-error" class="section-title" style="margin-bottom:0.4rem;">Last failed task</h2>
|
||||||
|
<p style="margin:0;">
|
||||||
|
{{ e.error or e.message or e.state }}{% if e.exit_code %} <span class="tone-crit">(exit {{ e.exit_code }})</span>{% endif %}
|
||||||
|
{%- if e.node %} · on {{ e.node }}{% endif %}{% if e.age_s is not none %} · {{ e.age_s|duration }} ago{% endif %}
|
||||||
|
{%- if e.task_id %} · <a href="?task={{ e.task_id }}#logs">its logs →</a>{% endif %}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# ── Resource history, summed across replicas ────────────────────────────── #}
|
||||||
|
<section class="card" aria-labelledby="svc-history">
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;gap:1rem;margin-bottom:0.75rem;flex-wrap:wrap;">
|
||||||
|
<h2 id="svc-history" class="section-title" style="margin:0;">Resource history <span class="tone-dim">across running replicas</span></h2>
|
||||||
|
{% include "_time_range.html" %}
|
||||||
|
</div>
|
||||||
|
<div hx-get="/plugins/docker/service/{{ name }}/history"
|
||||||
|
hx-trigger="load, rangeChange from:body"
|
||||||
|
hx-include="#time-range"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<p class="field-hint">Loading…</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{# ── Tasks: the running ones with what they use, then Swarm's record ─────── #}
|
||||||
|
<section id="tasks" class="card-flush" aria-labelledby="svc-tasks">
|
||||||
|
<h2 id="svc-tasks" class="table-caption">Tasks
|
||||||
|
<span class="tone-muted">running first, then Swarm's most recent</span>
|
||||||
|
{%- if row.failures_24h %} <span class="tone-warn">· {{ row.failures_24h }} failed in 24h</span>{% endif %}</h2>
|
||||||
|
{% if tasks %}
|
||||||
|
<table class="table">
|
||||||
|
<thead><tr>
|
||||||
|
<th title="The replica slot this task filled">Slot</th><th>Task</th><th>Node</th><th>State</th>
|
||||||
|
<th class="num" title="Docker counts CPU per core">CPU</th><th class="num">Memory</th>
|
||||||
|
<th class="num">Exit</th><th>Why</th><th class="num" title="When it started, for a running task; else when it reached its state">When</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for t in tasks %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ t.slot or "—" }}</td>
|
||||||
|
<td class="mono" title="{{ t.container or t.task_id }}">
|
||||||
|
{%- if t.task_id %}<a href="?task={{ t.task_id }}#logs" title="Its logs">{{ t.task_id[:10] }}</a>
|
||||||
|
{%- if t.container and t.host_id %} <a class="tone-dim" href="/plugins/docker/container/{{ t.host_id }}/{{ t.container }}" title="The container's own page">↗</a>{% endif %}
|
||||||
|
{%- elif t.container and t.host_id %}<a href="/plugins/docker/container/{{ t.host_id }}/{{ t.container }}">{{ t.container|truncate(24, true, "…") }}</a>
|
||||||
|
{%- else %}<span class="tone-dim">—</span>{% endif -%}
|
||||||
|
</td>
|
||||||
|
<td>{% if t.host_id %}<a href="/hosts/{{ t.host_id }}">{{ t.node }}</a>{% else %}{{ t.node }}{% endif %}</td>
|
||||||
|
<td>
|
||||||
|
{%- if t.failed %}{{ status_badge('crit', t.state|capitalize) }}
|
||||||
|
{%- elif t.current %}{{ status_badge('ok', 'Running') }}
|
||||||
|
{%- else %}<span class="tone-muted">{{ t.state }}</span>{% endif %}
|
||||||
|
{%- if t.health == 'unhealthy' %} {{ status_badge('crit', 'Unhealthy') }}{% elif t.health == 'starting' %} {{ status_badge('pending', 'Starting') }}{% endif %}
|
||||||
|
{%- if t.desired and t.desired != t.state and not t.current %} <span class="tone-dim" title="What Swarm wants of it">→ {{ t.desired }}</span>{% endif -%}
|
||||||
|
</td>
|
||||||
|
<td class="num">{% if t.usage %}{{ cpu(t.usage) }}{% else %}<span class="tone-dim">—</span>{% endif %}</td>
|
||||||
|
<td class="num">{% if t.usage %}{{ mem(t.usage) }}{% else %}<span class="tone-dim">—</span>{% endif %}</td>
|
||||||
|
<td class="num">{% if t.exit_code %}<span class="tone-{{ 'crit' if t.failed else 'dim' }}">{{ t.exit_code }}</span>{% else %}<span class="tone-dim">—</span>{% endif %}</td>
|
||||||
|
<td style="overflow-wrap:anywhere;">{{ t.why or "" }}</td>
|
||||||
|
<td class="num">{% if t.at %}{{ t.at|ago }}{% else %}<span class="tone-dim">—</span>{% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p class="tone-muted" style="margin:0;padding:1rem;">No task is running, and Swarm's record of its tasks has not been reported yet (the manager's agent reports it from version 1.8.0).</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# ── Logs across every task, newest first ─────────────────────────────────
|
||||||
|
Shown for a removed service too, while its lines are still kept. #}
|
||||||
|
{% if service is not none or task_choices %}
|
||||||
|
<section id="logs" class="card-flush" aria-labelledby="svc-logs">
|
||||||
|
<h2 id="svc-logs" class="table-caption">Logs <span class="tone-muted">every task, newest first</span></h2>
|
||||||
|
<form id="svc-log-controls" class="filter-bar" onsubmit="return false;" style="padding:0.75rem 1rem 0;">
|
||||||
|
<input type="search" name="q" class="input" placeholder="Filter lines…" autocomplete="off" aria-label="Filter log lines">
|
||||||
|
{% if task_choices|length > 1 %}
|
||||||
|
<select name="task" aria-label="Task">
|
||||||
|
<option value="">All tasks</option>
|
||||||
|
{% for tid, label in task_choices %}<option value="{{ tid }}"{% if tid == focus_task %} selected{% endif %}>{{ label }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% elif focus_task %}<input type="hidden" name="task" value="{{ focus_task }}">{% endif %}
|
||||||
|
<select name="stream" aria-label="Stream">
|
||||||
|
<option value="all">stdout and stderr</option>
|
||||||
|
<option value="stdout">stdout</option>
|
||||||
|
<option value="stderr">stderr</option>
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
<div id="svc-log-lines"
|
||||||
|
hx-get="/plugins/docker/service/{{ name }}/logs/lines"
|
||||||
|
hx-trigger="load, every 5s, change from:#svc-log-controls, keyup changed delay:400ms from:#svc-log-controls"
|
||||||
|
hx-include="#svc-log-controls"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
role="log" aria-live="polite" tabindex="0"
|
||||||
|
style="max-height:60vh;overflow:auto;padding:0.5rem 1rem 0.75rem;font-family:var(--fs-font-mono);font-size:0.8rem;line-height:1.5;">
|
||||||
|
<p class="field-hint">Loading…</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
{# docker/swarm.html — the Swarm laid out by node (#5303). A verdict, the
|
||||||
|
services short of replicas, then one card per node: a node that is down or
|
||||||
|
not taking work first, then managers. Each card says what its containers use
|
||||||
|
against what the machine has, and lists the services placed on it. #}
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% from "_macros.html" import crumbs, status_badge, status_dot %}
|
||||||
|
{% from "docker/_usage.html" import cores as fmt_cores %}
|
||||||
|
{% block title %}Swarm — Docker — Steward{% endblock %}
|
||||||
|
{% block breadcrumb %}{{ crumbs([("Docker", "/plugins/docker/"), ("Swarm", "")]) }}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<h1 class="page-title">Swarm</h1>
|
||||||
|
|
||||||
|
{% if swarms %}
|
||||||
|
{% for g in swarms %}
|
||||||
|
{% set down_nodes = g.nodes|rejectattr("ok")|list %}
|
||||||
|
<section class="swarm-group" aria-label="Swarm reported by {{ g.managers|join(', ') }}">
|
||||||
|
<p class="page-verdict">
|
||||||
|
{% if down_nodes or g.troubled %}<strong class="tone-crit">{{ down_nodes|length + g.troubled|length }} need{{ "s" if (down_nodes|length + g.troubled|length) == 1 }} attention</strong>
|
||||||
|
{% else %}<strong class="tone-ok">Every node is up and taking work</strong>{% endif %}
|
||||||
|
· {{ g.nodes|length }} node{{ "s" if g.nodes|length != 1 }}
|
||||||
|
· <span class="tone-{{ 'ok' if g.services_healthy == g.services_total else 'warn' }}">{{ g.services_healthy }}/{{ g.services_total }}</span> services healthy
|
||||||
|
<span class="tone-dim">· reported by {{ g.managers|join(", ") }}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if down_nodes or g.troubled %}
|
||||||
|
<section class="card problem-card" aria-label="Needs attention">
|
||||||
|
<ul class="problem-list">
|
||||||
|
{% for n in down_nodes %}
|
||||||
|
<li>
|
||||||
|
{{ status_badge('crit', 'Node ' ~ n.status) if n.status != 'ready' else status_badge('warn', n.availability|capitalize) }}
|
||||||
|
<span>{{ n.hostname }}</span>
|
||||||
|
<span class="problem-detail">{{ n.role }} · {{ n.task_count }} task{{ "s" if n.task_count != 1 }} placed on it</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
{% for s in g.troubled %}
|
||||||
|
<li>
|
||||||
|
{% if s.crash_looping %}{{ status_badge('crit', 'Crash-looping') }}{% elif s.state == 'down' %}{{ status_badge('crit', 'Down') }}{% else %}{{ status_badge('warn', 'Degraded') }}{% endif %}
|
||||||
|
<a href="/plugins/docker/service/{{ s.name }}">{{ s.name }}</a>
|
||||||
|
{% set e = s.last_error %}
|
||||||
|
<span class="problem-detail">
|
||||||
|
{{- s.running }} of {{ s.desired }} running
|
||||||
|
{%- if e %} · last task {{ e.state }}: {{ e.error or e.message or e.state }}{% if e.node %} on {{ e.node }}{% endif %}{% endif %}
|
||||||
|
{%- if s.trouble_since %} · since {{ s.trouble_since|ago }}{% endif -%}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="node-grid">
|
||||||
|
{% for n in g.nodes %}
|
||||||
|
<section class="card node-card{% if not n.ok %} problem-card{% endif %}" aria-labelledby="node-{{ n.node_id }}">
|
||||||
|
<h2 id="node-{{ n.node_id }}" class="node-card-head">
|
||||||
|
{{ status_dot('up' if n.status == 'ready' else 'down') }}
|
||||||
|
{% if n.host_id %}<a href="/hosts/{{ n.host_id }}">{{ n.hostname }}</a>{% else %}{{ n.hostname }}{% endif %}
|
||||||
|
<span class="chip">{{ "leader" if n.leader else n.role }}</span>
|
||||||
|
{% if n.availability != 'active' %}{{ status_badge('warn', n.availability|capitalize, title="Swarm places no new tasks here") }}{% endif %}
|
||||||
|
</h2>
|
||||||
|
<p class="node-card-figures">
|
||||||
|
{% if n.cores_used is not none %}
|
||||||
|
Containers use <strong>{{ fmt_cores(n.cores_used) }}</strong>{% if n.cores %} of {{ n.cores }}{% endif %}
|
||||||
|
· <strong>{{ n.mem_used|bytes }}</strong>{% if n.mem %} of {{ n.mem|bytes }}{% endif %}
|
||||||
|
{% elif n.agent %}
|
||||||
|
<span class="tone-muted">No swarm task reports figures here yet.</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="tone-muted" title="Install the Steward agent on this node to see what its tasks use">No Steward agent: what runs here is Swarm's placement, without figures.</span>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
{% if n.tasks %}
|
||||||
|
<table class="table node-tasks">
|
||||||
|
<thead><tr><th>Service</th><th class="num">Tasks</th><th class="num">CPU</th><th class="num">Memory</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for t in n.tasks %}
|
||||||
|
<tr>
|
||||||
|
<td class="cell-name">
|
||||||
|
{%- if t.state != 'healthy' %}{{ status_dot('down' if t.state == 'down' else 'warn') }} {% endif -%}
|
||||||
|
<a href="/plugins/docker/service/{{ t.name }}" title="{{ t.name }}">{{ t.name }}</a>
|
||||||
|
</td>
|
||||||
|
<td class="num">{{ t.tasks }}</td>
|
||||||
|
<td class="num">{% if t.cores is not none %}{{ fmt_cores(t.cores) }}{% else %}<span class="tone-dim">—</span>{% endif %}</td>
|
||||||
|
<td class="num">{% if t.mem_bytes is not none %}{{ t.mem_bytes|bytes }}{% else %}<span class="tone-dim">—</span>{% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p class="tone-muted" style="margin:0;">No tasks placed here.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="card empty-state">
|
||||||
|
No Swarm topology reported. A Steward host agent running on a Swarm
|
||||||
|
<strong>manager</strong> reports services, nodes and task placement here;
|
||||||
|
workers and hosts outside a swarm contribute only their own containers.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
{# docker/widget.html — the dashboard Docker widget (#5252), in the widget-shape
|
||||||
|
order: a verdict line; the services and containers in trouble; what failed
|
||||||
|
in the last 24h, by name; then everything healthy, one line each, packed
|
||||||
|
into as many columns as the widget is wide. Resource use is the Busiest
|
||||||
|
Containers widget's, so it isn't repeated here. #}
|
||||||
|
{% from "_macros.html" import status_dot %}
|
||||||
|
{% if total_count == 0 %}
|
||||||
|
<div class="w-verdict" style="color:var(--text-muted);">No containers reported yet.</div>
|
||||||
|
{% else %}
|
||||||
|
{% set wrong = bad_services|length + bad_containers|length %}
|
||||||
|
<div class="w-verdict {{ 'tone-crit' if wrong else ('tone-warn' if failures else 'tone-ok') }}">
|
||||||
|
{%- if services_total %}{{ services_healthy }}/{{ services_total }} services healthy · {% endif -%}
|
||||||
|
{{ running_count }} other container{{ 's' if running_count != 1 }} running ·
|
||||||
|
{% if failures %}<a href="/plugins/docker/#failures" style="color:inherit;">{{ failures|length }} failed in 24h</a>{% else %}nothing failed in 24h{% endif -%}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if wrong %}
|
||||||
|
<div class="w-section">Needs a look</div>
|
||||||
|
{% for s in bad_services %}
|
||||||
|
<div class="w-item">
|
||||||
|
{{ status_dot('degraded' if s.state == 'degraded' else 'down') }}
|
||||||
|
<span class="w-label" title="{{ s.name }}">{{ s.name }}
|
||||||
|
<span class="w-sub {{ 'tone-crit' if s.state != 'degraded' else 'tone-warn' }}">
|
||||||
|
{%- if s.crash_looping %}crash-looping: {{ s.failures_1h }} failed in the last hour{% else %}{{ s.running }} of {{ s.desired }} running{% endif %}
|
||||||
|
{%- if s.trouble_since %}, since {{ s.trouble_since|ago }}{% endif %}
|
||||||
|
{%- if s.last_error and (s.last_error.error or s.last_error.message) %} · {{ s.last_error.error or s.last_error.message }}{% endif -%}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="w-meta" title="replicas running / wanted">{{ s.running }}/{{ s.desired }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% for c in bad_containers %}
|
||||||
|
<div class="w-item">
|
||||||
|
{{ status_dot('warn' if c.status == 'restarting' else 'down') }}
|
||||||
|
<span class="w-label" title="{{ c.name }}">
|
||||||
|
{%- if session.user_id %}<a href="/plugins/docker/container/{{ c.host_id }}/{{ c.name }}">{{ c.name }}</a>{% else %}{{ c.name }}{% endif %}
|
||||||
|
<span class="w-sub tone-crit">
|
||||||
|
{%- if c.health == 'unhealthy' %}unhealthy{% elif c.status == 'restarting' %}restarting{% else %}exited {{ c.exit_code }}{% if c.oom_killed %}, out of memory{% endif %}{% endif %} · {{ host_name.get(c.host_id, c.host_id) }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if failures %}
|
||||||
|
<div class="w-section">Failed in the last 24h</div>
|
||||||
|
{% for f in failures[:5] %}
|
||||||
|
<div class="w-item">
|
||||||
|
{{ status_dot('failed') }}
|
||||||
|
<span class="w-label" title="{{ f.name }}">{{ f.name }}
|
||||||
|
<span class="w-sub">{% if f.count > 1 %}{{ f.count }} times, last {% endif %}{{ f.at|ago }}{% if f.detail %} · {{ f.detail }}{% endif %} · {{ f.host }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% if failures|length > 5 %}<div class="w-more">+{{ failures|length - 5 }} more · <a href="/plugins/docker/#failures">the full list</a></div>{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if healthy %}
|
||||||
|
<div class="w-section">Healthy</div>
|
||||||
|
<div class="w-grid">
|
||||||
|
{% for h in healthy %}
|
||||||
|
<div class="w-item">
|
||||||
|
{% if h.kind == 'service' %}{% set s = h.s %}
|
||||||
|
{{ status_dot('up') }}
|
||||||
|
<span class="w-label" title="{{ s.name }} — on {{ s.replicas|map(attribute='host')|unique|join(', ') }}">{{ s.name }}
|
||||||
|
<span style="color:var(--text-dim);"> · {{ s.replicas|map(attribute='host')|unique|join(', ') }}</span></span>
|
||||||
|
<span class="w-meta" title="replicas running / wanted">{{ s.running }}/{{ s.desired }}</span>
|
||||||
|
{% else %}{% set c = h.c %}
|
||||||
|
{{ status_dot('up' if h.kind == 'container' else 'unknown') }}
|
||||||
|
<span class="w-label" title="{{ c.name }}">
|
||||||
|
{%- if session.user_id %}<a href="/plugins/docker/container/{{ c.host_id }}/{{ c.name }}">{{ c.name }}</a>{% else %}{{ c.name }}{% endif %}
|
||||||
|
<span style="color:var(--text-dim);"> · {{ host_name.get(c.host_id, c.host_id) }}</span></span>
|
||||||
|
{% if h.kind == 'stopped' %}<span class="w-meta">stopped</span>{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% if healthy_more %}<div class="w-more">+{{ healthy_more }} more healthy · <a href="/plugins/docker/">all of Docker</a></div>{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
{# docker/widget_resources.html — the dashboard Busiest Containers widget
|
||||||
|
(#5253, #5214): a verdict naming the busiest, then the top containers by CPU
|
||||||
|
(averaged over the last few minutes, so the order holds still). Each figure
|
||||||
|
carries its unit, beside bars of the last few hours. A row's bars are scaled
|
||||||
|
to its own peak, but never below a floor (1 core, 1 GiB by default), so a
|
||||||
|
container idling at 0.05 cores reads flat and a spike means real load. #}
|
||||||
|
{% macro cores(v) -%}{{ "%.2f"|format(v) if v < 1 else "%.1f"|format(v) }}{%- endmacro %}
|
||||||
|
{% macro bars(b, peak_text, roof_text) -%}
|
||||||
|
<span class="w-bars w-wide-only" aria-hidden="true"
|
||||||
|
title="{% if b.peak is none %}No samples in the last {{ hours }}h{% else %}Peak {{ peak_text }} in the last {{ hours }}h; bars reach the top at {{ roof_text }}{% endif %}">
|
||||||
|
{%- for h in b.bars %}<i{% if h is none %} class="gap"{% else %} style="height:{{ [h, 4]|max }}%"{% endif %}></i>{% endfor -%}
|
||||||
|
</span>
|
||||||
|
{%- endmacro %}
|
||||||
|
{% if not rows %}
|
||||||
|
<div class="w-verdict" style="color:var(--text-muted);">No running containers.</div>
|
||||||
|
{% else %}
|
||||||
|
{% set top = rows[0] %}
|
||||||
|
<div class="w-verdict">
|
||||||
|
Busiest: <strong>{{ top.label }}</strong> on {{ top.host_name }},
|
||||||
|
{% if top.usage.cores is not none %}{{ cores(top.usage.cores) }}
|
||||||
|
{%- if top.usage.cpus %} of {{ top.usage.cpus }} cores{% else %} cores{% endif %}{% else %}no CPU reading yet{% endif %}.
|
||||||
|
</div>
|
||||||
|
<table class="w-table">
|
||||||
|
<colgroup><col><col class="w-wide-only" style="width:7.5rem"><col class="w-barcol"><col class="w-barcol"></colgroup>
|
||||||
|
<thead><tr><th>Container</th><th class="w-wide-only w-text">Host</th>
|
||||||
|
<th title="Cores, averaged over the last {{ minutes }} minutes; bars: the last {{ hours }}h">CPU<span class="w-wide-only"> · last {{ hours }}h</span></th>
|
||||||
|
<th title="In use now; bars: the last {{ hours }}h">Memory<span class="w-wide-only"> · last {{ hours }}h</span></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in rows %}
|
||||||
|
{% set u = r.usage %}
|
||||||
|
<tr>
|
||||||
|
<td class="w-name" title="{{ r.c.name }}">
|
||||||
|
{%- if session.user_id %}<a href="/plugins/docker/container/{{ r.c.host_id }}/{{ r.c.name }}">{{ r.label }}</a>{% else %}{{ r.label }}{% endif -%}
|
||||||
|
</td>
|
||||||
|
<td class="w-wide-only w-text" title="{{ r.host_name }}">{{ r.host_name }}</td>
|
||||||
|
<td class="tone-{{ (u.cpu_grade or status_class(u.cpu_share, 'cpu')) if u.cpus and u.cores is not none else 'dim' }}"
|
||||||
|
{% if u.cpus and u.cores is not none %}title="{{ '%.1f'|format(u.cpu_share) }}% of {{ r.host_name }}'s {{ u.cpus }} cores{% if u.cpu_usual %}; {{ u.cpu_usual }}{% endif %}"{% endif %}>
|
||||||
|
{{- bars(r.cpu_bars, (cores(r.cpu_bars.peak) ~ " cores") if r.cpu_bars.peak is not none else "", cores(r.cpu_bars.roof) ~ " cores") -}}
|
||||||
|
<span class="w-fig">{% if u.cores is none %}—{% else %}{{ cores(u.cores) }} cores{% endif %}</span>
|
||||||
|
</td>
|
||||||
|
<td class="tone-{{ (u.mem_grade or status_class(u.mem_pct, 'mem')) if u.mem_of and u.mem_bytes is not none else 'dim' }}"
|
||||||
|
{% if u.mem_pct is not none and u.mem_of and u.mem_bytes is not none %}title="{{ '%.1f'|format(u.mem_pct) }}% of {{ u.mem_of }}{% if u.mem_usual %}, {{ u.mem_usual }}{% endif %}"{% endif %}>
|
||||||
|
{{- bars(r.mem_bars, r.mem_bars.peak|bytes if r.mem_bars.peak is not none else "", r.mem_bars.roof|bytes) -}}
|
||||||
|
<span class="w-fig">{% if u.mem_bytes is none %}—{% else %}{{ u.mem_bytes|bytes }}{% endif %}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="w-note w-wide-only">Bars: {{ (hours * 60 // 24) }}-minute slices, oldest left; each row up to its own peak, at least {{ cores(cpu_floor) }} core{{ 's' if cpu_floor != 1 }} and {{ mem_floor|bytes }}.</div>
|
||||||
|
{% endif %}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# plugins/host_agent/__init__.py
|
||||||
|
from __future__ import annotations
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from quart import Quart
|
||||||
|
|
||||||
|
_app: "Quart | None" = None
|
||||||
|
|
||||||
|
|
||||||
|
def setup(app: "Quart") -> None:
|
||||||
|
global _app
|
||||||
|
_app = app
|
||||||
|
# A host's CPU, memory and load per core learn their normal (#5742), each
|
||||||
|
# with its floor from Settings → Thresholds. Parts don't: a host's CPU has
|
||||||
|
# a normal, each of its cores doesn't. Disks stay on their fixed cutoffs:
|
||||||
|
# a nearly full disk is a failure whatever its history (System 182).
|
||||||
|
from steward.core.norms import floor_setting, learn_normal
|
||||||
|
learn_normal("host_agent", "cpu_pct", floor_setting(app, "host_cpu_pct"), parts=False)
|
||||||
|
learn_normal("host_agent", "mem_used_pct", floor_setting(app, "host_mem_pct"), parts=False)
|
||||||
|
learn_normal("host_agent", "load_pct", floor_setting(app, "host_load_pct"), parts=False)
|
||||||
|
# "Agent offline" speaks for everything the agent carries: while it is
|
||||||
|
# firing, the host's other rules going silent isn't emailed (#5721).
|
||||||
|
from steward.core.alerts import agent_fed, agent_heartbeat
|
||||||
|
from steward.hosts.service import agent_stale_after_seconds
|
||||||
|
agent_heartbeat("host_agent", "seconds_since_seen", lambda: agent_stale_after_seconds(app.config))
|
||||||
|
agent_fed("host_agent", lambda resource: resource.split(":", 1)[0])
|
||||||
|
# A reboot the agent announced is silence with a reason, for a while (#5756).
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from steward.core.alerts import agents_away
|
||||||
|
from .downtime import hosts_away, restart_grace_seconds
|
||||||
|
agents_away(lambda session: hosts_away(session, datetime.now(timezone.utc),
|
||||||
|
restart_grace_seconds(app.config)))
|
||||||
|
from steward.core.cleanup import prune_by_age
|
||||||
|
from .models import HostDowntime
|
||||||
|
prune_by_age(HostDowntime.started_at)
|
||||||
|
|
||||||
|
|
||||||
|
def get_audit_entities() -> dict:
|
||||||
|
"""Audit rows about a maintenance plan name it and link to the plans page."""
|
||||||
|
from .models import HostMaintenancePlan
|
||||||
|
return {"maintenance_plan": (HostMaintenancePlan, "/plugins/host_agent/maintenance")}
|
||||||
|
|
||||||
|
|
||||||
|
def get_scheduled_tasks() -> list:
|
||||||
|
from .scheduler import make_maintenance_task, make_sensor_task, make_task
|
||||||
|
return [make_task(_app), make_maintenance_task(_app), make_sensor_task(_app)]
|
||||||
|
|
||||||
|
|
||||||
|
def get_blueprint():
|
||||||
|
from .routes import host_agent_bp
|
||||||
|
return host_agent_bp
|
||||||
|
|
||||||
|
|
||||||
|
def register_mcp_tools(register) -> None:
|
||||||
|
from .mcp_tools import register_tools
|
||||||
|
register_tools(register)
|
||||||
|
|
||||||
|
|
||||||
|
async def mcp_overview(db) -> list[dict]:
|
||||||
|
from .mcp_tools import mcp_overview as findings
|
||||||
|
return await findings(db)
|
||||||
|
|
||||||
|
|
||||||
|
def get_alert_metrics() -> dict[str, list[str]]:
|
||||||
|
"""Host metrics ingest records for alert rules (resource = the host name;
|
||||||
|
disk_used_pct per mount, resource "<host>:<mount>"). seconds_since_seen
|
||||||
|
comes from the scheduler, not the agent: "agent offline" is > 180.
|
||||||
|
clock_off_secs is how far the host's clock is from Steward's, either way
|
||||||
|
(agent ≥ 1.12.0): "clock drifting" is > 30. load_pct is load_1m as a %
|
||||||
|
of the host's cores. inodes_used_pct is per mount (agent ≥ 1.13.0), and
|
||||||
|
disk_used_pct_worst is the fullest mount by space or inodes.
|
||||||
|
mounts_stalled counts the mounts that didn't answer the agent in time
|
||||||
|
(agent ≥ 1.14.0): "an NFS share stopped" is > 0. agent_rss_bytes and
|
||||||
|
agent_cpu_pct are the agent's own memory and CPU (agent ≥ 1.15.0): a
|
||||||
|
healthy agent sits in tens of MB and a few %. updates_pending and
|
||||||
|
updates_security are what the package manager has waiting, and
|
||||||
|
reboot_required is 1 when the host needs a reboot (agent ≥ 1.16.0):
|
||||||
|
"security updates waiting" is updates_security > 0. maintenance_failed
|
||||||
|
comes from the scheduler (#5768): 1 when the host's latest maintenance job
|
||||||
|
failed or timed out, 0 once one succeeds: "maintenance failed" is > 0.
|
||||||
|
temp_c_max is the host's hottest real thermometer, in °C (#5787); sensors
|
||||||
|
with nothing wired to them are left out (#5759, #5783): "running hot" is
|
||||||
|
> 85. temp_c is one sensor, resource "<host>:temp:<label>". net_rx_bps /
|
||||||
|
net_tx_bps and disk_read_bps / disk_write_bps are bytes per second, for
|
||||||
|
the whole host ("<host>") or one interface ("<host>:net:<iface>") or disk
|
||||||
|
("<host>:diskio:<device>").
|
||||||
|
|
||||||
|
Drive health (agent ≥ 1.20.0, #5789), read hourly with smartctl by the
|
||||||
|
root helper and judged in drives.py, per drive on "<host>:drive:<device>":
|
||||||
|
drive_failing is 1 when the drive fails its own self-assessment, raises
|
||||||
|
an NVMe critical warning or runs out of spare blocks; drive_life_used_pct
|
||||||
|
is how much of its rated life it has used; drive_bad_sectors counts its
|
||||||
|
media errors or reallocated / pending / uncorrectable sectors;
|
||||||
|
drive_temp_c is its own temperature. Per host, for covering rules:
|
||||||
|
drives_failing (how many) and drive_life_used_max_pct (the most worn):
|
||||||
|
"drive failing" is drives_failing > 0, "drive wearing out" is
|
||||||
|
drive_life_used_max_pct > 90.
|
||||||
|
|
||||||
|
Recorded but not offered (NOT_ALERTABLE): disk_used_bytes and
|
||||||
|
disk_total_bytes, where disk_used_pct is the figure to watch (and docker
|
||||||
|
uses disk_total_bytes for something else), and mem_cached_bytes /
|
||||||
|
mem_buffers_bytes, parts of what mem_available_bytes already counts."""
|
||||||
|
from .drives import DRIVE_METRICS
|
||||||
|
return {"host_agent": [
|
||||||
|
"cpu_pct", "mem_used_pct", "mem_available_bytes", "swap_used_bytes",
|
||||||
|
"disk_used_pct_worst", "disk_used_pct", "inodes_used_pct", "load_1m", "load_5m", "load_15m",
|
||||||
|
"load_pct",
|
||||||
|
"uptime_secs", "seconds_since_seen", "clock_off_secs", "mounts_stalled",
|
||||||
|
"agent_rss_bytes", "agent_cpu_pct",
|
||||||
|
"updates_pending", "updates_security", "reboot_required", "maintenance_failed",
|
||||||
|
"temp_c_max", "temp_c", "net_rx_bps", "net_tx_bps", "disk_read_bps", "disk_write_bps",
|
||||||
|
*DRIVE_METRICS,
|
||||||
|
]}
|
||||||
|
|
||||||
|
|
||||||
|
# Metrics host_agent records that no rule is offered, each for the reason
|
||||||
|
# get_alert_metrics gives; tests/core/test_metric_catalog.py holds every other
|
||||||
|
# recorded name to the catalog.
|
||||||
|
NOT_ALERTABLE = frozenset({"disk_used_bytes", "disk_total_bytes", "mem_cached_bytes", "mem_buffers_bytes"})
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,309 @@
|
|||||||
|
# plugins/host_agent/cadence.py
|
||||||
|
"""A group's maintenance cadence (#5768): every week, its hosts updated one at
|
||||||
|
a time, each rebooting only when its update needs it (or as the plan says).
|
||||||
|
|
||||||
|
A plan names an inventory group, a weekday and a time in its time zone, and a
|
||||||
|
window. At that time a run starts with the group's hosts in a fixed order. Each
|
||||||
|
host gets a maintenance job (maintenance.py); the next host starts only when
|
||||||
|
the last one is back in service: its agent posting, its swarm node ready and
|
||||||
|
active, every replicated service at its replicas. A job that fails, or a host
|
||||||
|
that doesn't come back into service, stops the run, and the host's
|
||||||
|
maintenance_failed figure goes to 1 for an alert rule to catch. No host is
|
||||||
|
started after the window; the run then stops and says which hosts it didn't
|
||||||
|
reach.
|
||||||
|
|
||||||
|
The decisions are model-free here (plans and runs are anything with the
|
||||||
|
attributes); the database side is below them, and the plugin's minute tick
|
||||||
|
drives it. Everything is on the rows, so a restart of Steward, or the reboot of
|
||||||
|
the host it runs on, picks up where it was.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, time, timedelta, timezone
|
||||||
|
|
||||||
|
from . import maintenance
|
||||||
|
|
||||||
|
WEEKDAYS = ("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday")
|
||||||
|
# How long a host may take to be back in service after its job finished.
|
||||||
|
SETTLE_LIMIT = timedelta(minutes=30)
|
||||||
|
# An agent heard from this recently is posting.
|
||||||
|
POSTING_WITHIN = timedelta(minutes=3)
|
||||||
|
RUN_STATES = ("running", "done", "failed", "stopped")
|
||||||
|
|
||||||
|
|
||||||
|
def _zone(name: str):
|
||||||
|
from steward.core.formatting import zone
|
||||||
|
return zone(name)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_at(at_time: str) -> time:
|
||||||
|
h, m = (int(x) for x in at_time.split(":", 1))
|
||||||
|
if not (0 <= h < 24 and 0 <= m < 60):
|
||||||
|
raise ValueError(at_time)
|
||||||
|
return time(h, m)
|
||||||
|
|
||||||
|
|
||||||
|
def last_slot(plan, now: datetime) -> datetime:
|
||||||
|
"""The most recent scheduled time at or before `now`, in UTC."""
|
||||||
|
tz = _zone(plan.time_zone)
|
||||||
|
local = now.astimezone(tz)
|
||||||
|
day = local.date() - timedelta(days=(local.weekday() - plan.weekday) % 7)
|
||||||
|
slot = datetime.combine(day, parse_at(plan.at_time), tzinfo=tz)
|
||||||
|
if slot > local:
|
||||||
|
slot -= timedelta(days=7)
|
||||||
|
return slot.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def next_slot(plan, now: datetime) -> datetime:
|
||||||
|
return last_slot(plan, now) + timedelta(days=7)
|
||||||
|
|
||||||
|
|
||||||
|
def is_due(plan, now: datetime) -> datetime | None:
|
||||||
|
"""The slot to fire now, or None. A slot fires once (last_slot_at), and
|
||||||
|
only while its window is still open: a Steward that was down over the slot
|
||||||
|
starts late, not a day late."""
|
||||||
|
if not plan.enabled:
|
||||||
|
return None
|
||||||
|
slot = last_slot(plan, now)
|
||||||
|
since = plan.last_slot_at or plan.created_at
|
||||||
|
if since is not None and slot <= since:
|
||||||
|
return None
|
||||||
|
if now - slot >= timedelta(minutes=plan.window_minutes):
|
||||||
|
return None
|
||||||
|
return slot
|
||||||
|
|
||||||
|
|
||||||
|
def back_in_service(*, last_seen_at, node, services, job_finished_at, now: datetime,
|
||||||
|
expect_active: bool = True) -> str | None:
|
||||||
|
"""None when the host is back in service, else why not yet. `node` is the
|
||||||
|
freshest swarm-node row for the job's node (None outside a swarm, or when
|
||||||
|
the job has no node); `services` the freshest row per swarm service. Only
|
||||||
|
rows reported after the job finished count, so a stale "all fine" from
|
||||||
|
before the reboot never passes. `expect_active` is False for a node the
|
||||||
|
job didn't drain (the operator keeps it drained): it need only be ready."""
|
||||||
|
if last_seen_at is None or now - last_seen_at > POSTING_WITHIN:
|
||||||
|
return "its agent isn't posting yet"
|
||||||
|
if node is None:
|
||||||
|
return None
|
||||||
|
if node.updated_at < job_finished_at:
|
||||||
|
return "no swarm report since it came back"
|
||||||
|
if node.status != "ready" or (expect_active and node.availability != "active"):
|
||||||
|
return f"its swarm node is {node.status}/{node.availability}"
|
||||||
|
fresh = [s for s in services if s.updated_at >= job_finished_at]
|
||||||
|
if not fresh:
|
||||||
|
return "no swarm report since it came back"
|
||||||
|
short = sorted(s.service_name for s in fresh
|
||||||
|
if s.mode == "replicated" and s.running < s.desired)
|
||||||
|
if short:
|
||||||
|
return "services below their replicas: " + ", ".join(short[:5])
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def next_move(run, plan, job, back: str | None, now: datetime) -> tuple[str, str | None]:
|
||||||
|
"""What a running run does now: ("start", None) the current host's job;
|
||||||
|
("wait", None); ("next", None) on to the next host; ("settling", why);
|
||||||
|
("fail", why); ("stop", why) the window ended; ("done", None)."""
|
||||||
|
hosts = json.loads(run.host_ids)
|
||||||
|
if run.position >= len(hosts):
|
||||||
|
return "done", None
|
||||||
|
if job is None:
|
||||||
|
if now >= run.started_at + timedelta(minutes=plan.window_minutes):
|
||||||
|
left = len(hosts) - run.position
|
||||||
|
return "stop", f"the window ended with {left} host{'s' if left != 1 else ''} not started"
|
||||||
|
return "start", None
|
||||||
|
if job.state in maintenance.ACTIVE:
|
||||||
|
return "wait", None
|
||||||
|
if job.state != "done":
|
||||||
|
return "fail", job.detail or maintenance.LABELS[job.state]
|
||||||
|
if back is None:
|
||||||
|
return "next", None
|
||||||
|
if run.settle_since is not None and now - run.settle_since > SETTLE_LIMIT:
|
||||||
|
return "fail", f"not back in service within {int(SETTLE_LIMIT.total_seconds() // 60)} min: {back}"
|
||||||
|
return "settling", back
|
||||||
|
|
||||||
|
|
||||||
|
def describe(plan) -> str:
|
||||||
|
""""Sundays at 03:00 (Europe/London), up to 4 h" for the page."""
|
||||||
|
hours = plan.window_minutes / 60
|
||||||
|
span = f"{hours:g} h" if plan.window_minutes % 60 == 0 else f"{plan.window_minutes} min"
|
||||||
|
return f"{WEEKDAYS[plan.weekday]}s at {plan.at_time} ({plan.time_zone}), up to {span}"
|
||||||
|
|
||||||
|
|
||||||
|
# ── applied to the database: the minute tick, Run now, the page ──────────────
|
||||||
|
|
||||||
|
async def group_hosts(session, group_id: str) -> list:
|
||||||
|
"""The group's hosts (through their linked Ansible targets), by name."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
from steward.models.ansible_inventory import AnsibleTarget, ansible_target_groups
|
||||||
|
from steward.models.hosts import Host
|
||||||
|
rows = (await session.execute(
|
||||||
|
select(Host).join(AnsibleTarget, AnsibleTarget.host_id == Host.id)
|
||||||
|
.join(ansible_target_groups, ansible_target_groups.c.target_id == AnsibleTarget.id)
|
||||||
|
.where(ansible_target_groups.c.group_id == group_id))).scalars().all()
|
||||||
|
return sorted({h.id: h for h in rows}.values(), key=lambda h: h.name.lower())
|
||||||
|
|
||||||
|
|
||||||
|
async def start_run(session, plan, now: datetime, manual: bool = False):
|
||||||
|
"""A run of the plan with the group's hosts in order. A host whose agent
|
||||||
|
has no maintenance helper is passed over, and the run says so."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
from .models import HostAgentRegistration, HostMaintenanceRun
|
||||||
|
hosts = await group_hosts(session, plan.group_id)
|
||||||
|
regs = {r.host_id: r for r in (await session.execute(select(HostAgentRegistration).where(
|
||||||
|
HostAgentRegistration.host_id.in_([h.id for h in hosts])))).scalars()} if hosts else {}
|
||||||
|
order, skipped = [], []
|
||||||
|
for h in hosts:
|
||||||
|
reg = regs.get(h.id)
|
||||||
|
if reg is None:
|
||||||
|
skipped.append({"host_id": h.id, "reason": "no agent"})
|
||||||
|
elif not reg.maint_helper:
|
||||||
|
skipped.append({"host_id": h.id, "reason": "no maintenance helper: run Update agent"})
|
||||||
|
else:
|
||||||
|
order.append(h.id)
|
||||||
|
run = HostMaintenanceRun(plan_id=plan.id, state="running", host_ids=json.dumps(order),
|
||||||
|
position=0, skipped=json.dumps(skipped), manual=manual,
|
||||||
|
started_at=now)
|
||||||
|
session.add(run)
|
||||||
|
await session.flush()
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
async def _back(session, host, job, reg, now: datetime) -> str | None:
|
||||||
|
from sqlalchemy import select
|
||||||
|
node = None
|
||||||
|
services: list = []
|
||||||
|
if job.node_id:
|
||||||
|
models = maintenance.docker_models()
|
||||||
|
if models is not None:
|
||||||
|
DockerSwarmNode, DockerSwarmService = models.DockerSwarmNode, models.DockerSwarmService
|
||||||
|
node = (await session.execute(select(DockerSwarmNode).where(
|
||||||
|
DockerSwarmNode.node_id == job.node_id)
|
||||||
|
.order_by(DockerSwarmNode.updated_at.desc()).limit(1))).scalar_one_or_none()
|
||||||
|
freshest: dict = {}
|
||||||
|
for s in (await session.execute(select(DockerSwarmService))).scalars():
|
||||||
|
if s.service_name not in freshest or s.updated_at > freshest[s.service_name].updated_at:
|
||||||
|
freshest[s.service_name] = s
|
||||||
|
services = list(freshest.values())
|
||||||
|
return back_in_service(last_seen_at=reg.last_seen_at if reg else None, node=node,
|
||||||
|
services=services, job_finished_at=job.finished_at or now, now=now,
|
||||||
|
expect_active=bool(job.drain_host_id))
|
||||||
|
|
||||||
|
|
||||||
|
async def advance(session, run, plan, now: datetime) -> str:
|
||||||
|
"""Move one running run on as far as it can go now; returns its state."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
from steward.models.hosts import Host
|
||||||
|
from .models import HostAgentRegistration, HostMaintenanceJob
|
||||||
|
for _ in range(64): # a host per pass; bounded
|
||||||
|
hosts = json.loads(run.host_ids)
|
||||||
|
host_id = hosts[run.position] if run.position < len(hosts) else None
|
||||||
|
job = host = reg = None
|
||||||
|
if host_id is not None:
|
||||||
|
host = await session.get(Host, host_id)
|
||||||
|
reg = (await session.execute(select(HostAgentRegistration).where(
|
||||||
|
HostAgentRegistration.host_id == host_id))).scalar_one_or_none()
|
||||||
|
job = (await session.execute(select(HostMaintenanceJob).where(
|
||||||
|
HostMaintenanceJob.run_id == run.id, HostMaintenanceJob.host_id == host_id)
|
||||||
|
.order_by(HostMaintenanceJob.created_at.desc()).limit(1))).scalar_one_or_none()
|
||||||
|
back = None
|
||||||
|
if job is not None and job.state == "done":
|
||||||
|
back = await _back(session, host, job, reg, now)
|
||||||
|
move, why = next_move(run, plan, job, back, now)
|
||||||
|
if move == "start":
|
||||||
|
if host is None:
|
||||||
|
_skip(run, host_id, "the host is gone")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
await maintenance.create_job(session, host, reg, plan.created_by,
|
||||||
|
plan.reboot_policy, now, run_id=run.id)
|
||||||
|
except maintenance.CannotStart as exc:
|
||||||
|
_skip(run, host_id, str(exc))
|
||||||
|
continue
|
||||||
|
return run.state
|
||||||
|
if move == "next":
|
||||||
|
run.position += 1
|
||||||
|
run.settle_since = None
|
||||||
|
continue
|
||||||
|
if move == "settling":
|
||||||
|
run.settle_since = run.settle_since or now
|
||||||
|
return run.state
|
||||||
|
if move == "fail":
|
||||||
|
if job is not None and job.state == "done":
|
||||||
|
maintenance._finish(job, "failed", now, why) # the host never came back into service
|
||||||
|
name = host.name if host else host_id
|
||||||
|
_end(run, "failed", now, f"{name}: {why}")
|
||||||
|
elif move == "stop":
|
||||||
|
_end(run, "stopped", now, why)
|
||||||
|
elif move == "done":
|
||||||
|
_end(run, "done", now, None)
|
||||||
|
return run.state
|
||||||
|
return run.state
|
||||||
|
|
||||||
|
|
||||||
|
def _skip(run, host_id: str, reason: str) -> None:
|
||||||
|
skipped = json.loads(run.skipped)
|
||||||
|
skipped.append({"host_id": host_id, "reason": reason})
|
||||||
|
run.skipped = json.dumps(skipped)
|
||||||
|
hosts = json.loads(run.host_ids)
|
||||||
|
hosts.pop(run.position)
|
||||||
|
run.host_ids = json.dumps(hosts)
|
||||||
|
|
||||||
|
|
||||||
|
def _end(run, state: str, now: datetime, detail: str | None) -> None:
|
||||||
|
run.state, run.finished_at, run.settle_since = state, now, None
|
||||||
|
if detail:
|
||||||
|
run.detail = detail[:maintenance.DETAIL_MAX]
|
||||||
|
|
||||||
|
|
||||||
|
async def tick(session, now: datetime) -> list[str]:
|
||||||
|
"""Start due plans and move running runs on; returns lines worth logging."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
from .models import HostMaintenancePlan, HostMaintenanceRun
|
||||||
|
said = []
|
||||||
|
running = {r.plan_id: r for r in (await session.execute(select(HostMaintenanceRun).where(
|
||||||
|
HostMaintenanceRun.state == "running"))).scalars()}
|
||||||
|
for plan in (await session.execute(select(HostMaintenancePlan))).scalars():
|
||||||
|
slot = is_due(plan, now)
|
||||||
|
if slot is None:
|
||||||
|
continue
|
||||||
|
plan.last_slot_at = slot
|
||||||
|
if plan.id in running:
|
||||||
|
said.append(f"plan {plan.name!r}: the {slot:%Y-%m-%d %H:%M}Z slot came with a run still going")
|
||||||
|
continue
|
||||||
|
running[plan.id] = await start_run(session, plan, now)
|
||||||
|
said.append(f"plan {plan.name!r}: run started")
|
||||||
|
for run in running.values():
|
||||||
|
plan = await session.get(HostMaintenancePlan, run.plan_id)
|
||||||
|
if plan is None:
|
||||||
|
continue
|
||||||
|
before = run.state
|
||||||
|
after = await advance(session, run, plan, now)
|
||||||
|
if after != before:
|
||||||
|
said.append(f"plan {plan.name!r}: run {after}" + (f" ({run.detail})" if run.detail else ""))
|
||||||
|
return said
|
||||||
|
|
||||||
|
|
||||||
|
async def failed_hosts(session, now: datetime) -> dict[str, float]:
|
||||||
|
"""{host name: 1 or 0} for every host with an agent: whether its latest
|
||||||
|
finished maintenance job failed or timed out, for the maintenance_failed
|
||||||
|
figure an alert rule watches. A host with no job yet reads 0, so a rule
|
||||||
|
on it reports rather than sitting at "no data". A later job that
|
||||||
|
succeeds puts it back to 0."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
from steward.models.hosts import Host
|
||||||
|
from .models import HostAgentRegistration, HostMaintenanceJob
|
||||||
|
agents = (await session.execute(
|
||||||
|
select(Host.name).join(HostAgentRegistration, HostAgentRegistration.host_id == Host.id))).scalars()
|
||||||
|
out = {name: 0.0 for name in agents}
|
||||||
|
rows = (await session.execute(
|
||||||
|
select(Host.name, HostMaintenanceJob.state, HostMaintenanceJob.finished_at)
|
||||||
|
.join(Host, Host.id == HostMaintenanceJob.host_id)
|
||||||
|
.where(HostMaintenanceJob.state.in_(maintenance.FINISHED),
|
||||||
|
HostMaintenanceJob.finished_at >= now - timedelta(days=30)))).all()
|
||||||
|
latest: dict = {}
|
||||||
|
for name, state, finished in rows:
|
||||||
|
if name not in latest or finished > latest[name][1]:
|
||||||
|
latest[name] = (state, finished)
|
||||||
|
out.update({name: 0.0 if state == "done" else 1.0 for name, (state, _) in latest.items()})
|
||||||
|
return out
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
# plugins/host_agent/downtime.py
|
||||||
|
"""What a host's agent says happened to it while it was away (#5756).
|
||||||
|
|
||||||
|
An agent ≥ 1.14.0 tells Steward when it stops (and whether its host is going
|
||||||
|
down with it), when it starts (and when its host booted), and how long it
|
||||||
|
couldn't reach Steward. Each becomes a HostDowntime row, so a reboot reads as
|
||||||
|
a reboot with its length, and "agent offline" is left for silence nobody
|
||||||
|
explained. A post can be retried, so every event is applied idempotently.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from .models import HostDowntime
|
||||||
|
|
||||||
|
# A stop the agent announced: the silence after it is explained, for a while.
|
||||||
|
ANNOUNCED = ("reboot", "agent_stop")
|
||||||
|
|
||||||
|
LABELS = {
|
||||||
|
"reboot": "Rebooted",
|
||||||
|
"agent_stop": "Agent stopped",
|
||||||
|
"unclean": "Restarted without shutting down",
|
||||||
|
"lost_contact": "Lost contact",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def restart_grace_seconds(config) -> int:
|
||||||
|
"""How long an announced stop explains the agent's silence (Settings →
|
||||||
|
Plugins → host_agent, default 10 minutes): past it, agent offline fires."""
|
||||||
|
return int(((config.get("PLUGINS") or {}).get("host_agent") or {})
|
||||||
|
.get("reboot_grace_seconds", 600))
|
||||||
|
|
||||||
|
|
||||||
|
def _at(value) -> datetime | None:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
got = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return got if got.tzinfo else got.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _when(event: dict) -> datetime | None:
|
||||||
|
return _at(event.get("at") or event.get("from"))
|
||||||
|
|
||||||
|
|
||||||
|
async def record_events(session, host_id: str, events, last_seen_at: datetime | None) -> int:
|
||||||
|
"""Apply one post's events to the host's downtime, oldest first; returns
|
||||||
|
how many rows were added or changed. `last_seen_at` is when the agent was
|
||||||
|
last heard before this post: a boot after it that nobody announced is a
|
||||||
|
restart without a shutdown."""
|
||||||
|
if not isinstance(events, list):
|
||||||
|
return 0
|
||||||
|
timed = sorted(((t, e) for e in events if isinstance(e, dict)
|
||||||
|
for t in [_when(e)] if t is not None), key=lambda te: te[0])
|
||||||
|
changed = 0
|
||||||
|
for at, event in timed:
|
||||||
|
kind = event.get("kind")
|
||||||
|
if kind == "stopping":
|
||||||
|
changed += await _stopping(session, host_id, at, event.get("host_shutdown"))
|
||||||
|
elif kind == "started":
|
||||||
|
changed += await _started(session, host_id, at, _at(event.get("boot_at")), last_seen_at)
|
||||||
|
elif kind == "lost_contact":
|
||||||
|
changed += await _lost_contact(session, host_id, at, _at(event.get("to")))
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
async def _stopping(session, host_id: str, at: datetime, host_shutdown) -> int:
|
||||||
|
seen = (await session.execute(select(HostDowntime.id).where(
|
||||||
|
HostDowntime.host_id == host_id, HostDowntime.kind.in_(ANNOUNCED),
|
||||||
|
HostDowntime.started_at == at))).first()
|
||||||
|
if seen:
|
||||||
|
return 0
|
||||||
|
session.add(HostDowntime(host_id=host_id, kind="reboot" if host_shutdown else "agent_stop",
|
||||||
|
started_at=at))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
async def _started(session, host_id: str, at: datetime, boot_at: datetime | None,
|
||||||
|
last_seen_at: datetime | None) -> int:
|
||||||
|
stop = (await session.execute(select(HostDowntime).where(
|
||||||
|
HostDowntime.host_id == host_id, HostDowntime.kind.in_(ANNOUNCED),
|
||||||
|
HostDowntime.ended_at.is_(None), HostDowntime.started_at <= at)
|
||||||
|
.order_by(HostDowntime.started_at.desc()).limit(1))).scalar_one_or_none()
|
||||||
|
if stop is not None:
|
||||||
|
stop.ended_at = at
|
||||||
|
# The boot time settles what the stop was: the host came back from a
|
||||||
|
# boot after it, or it stayed up while only the agent restarted.
|
||||||
|
if boot_at is not None:
|
||||||
|
stop.kind = "reboot" if boot_at > stop.started_at else "agent_stop"
|
||||||
|
return 1
|
||||||
|
if boot_at is None or last_seen_at is None or boot_at <= last_seen_at:
|
||||||
|
return 0
|
||||||
|
seen = (await session.execute(select(HostDowntime.id).where(
|
||||||
|
HostDowntime.host_id == host_id, HostDowntime.kind == "unclean",
|
||||||
|
HostDowntime.ended_at == at))).first()
|
||||||
|
if seen:
|
||||||
|
return 0
|
||||||
|
session.add(HostDowntime(host_id=host_id, kind="unclean", started_at=last_seen_at, ended_at=at))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
async def _lost_contact(session, host_id: str, since: datetime, until: datetime | None) -> int:
|
||||||
|
if until is None or until <= since:
|
||||||
|
return 0
|
||||||
|
row = (await session.execute(select(HostDowntime).where(
|
||||||
|
HostDowntime.host_id == host_id, HostDowntime.kind == "lost_contact",
|
||||||
|
HostDowntime.started_at == since))).scalar_one_or_none()
|
||||||
|
if row is None:
|
||||||
|
session.add(HostDowntime(host_id=host_id, kind="lost_contact", started_at=since, ended_at=until))
|
||||||
|
return 1
|
||||||
|
if row.ended_at is None or until > row.ended_at:
|
||||||
|
row.ended_at = until
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
async def announced_stops(session, now: datetime, grace_seconds: int) -> dict[str, datetime]:
|
||||||
|
"""{host id: when the silence its agent announced stops being explained}
|
||||||
|
for every announced stop still going and inside its grace."""
|
||||||
|
rows = (await session.execute(select(HostDowntime.host_id, HostDowntime.started_at).where(
|
||||||
|
HostDowntime.kind.in_(ANNOUNCED), HostDowntime.ended_at.is_(None),
|
||||||
|
HostDowntime.started_at > now - timedelta(seconds=grace_seconds)))).all()
|
||||||
|
out: dict[str, datetime] = {}
|
||||||
|
for host_id, started in rows:
|
||||||
|
until = started + timedelta(seconds=grace_seconds)
|
||||||
|
out[host_id] = max(out.get(host_id, until), until)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def hosts_away(session, now: datetime, grace_seconds: int) -> set[str]:
|
||||||
|
"""Names of the hosts in announced_stops: their silence has a reason."""
|
||||||
|
from steward.models.hosts import Host
|
||||||
|
ids = list(await announced_stops(session, now, grace_seconds))
|
||||||
|
if not ids:
|
||||||
|
return set()
|
||||||
|
return set((await session.execute(select(Host.name).where(Host.id.in_(ids)))).scalars())
|
||||||
|
|
||||||
|
|
||||||
|
def describe(row: HostDowntime, now: datetime) -> dict:
|
||||||
|
"""One downtime row as the host page shows it."""
|
||||||
|
label = LABELS.get(row.kind, row.kind)
|
||||||
|
if row.kind == "agent_stop" and row.ended_at is not None:
|
||||||
|
label = "Agent restarted"
|
||||||
|
end = row.ended_at or now
|
||||||
|
return {"kind": row.kind, "label": label, "started_at": row.started_at,
|
||||||
|
"ended_at": row.ended_at, "ongoing": row.ended_at is None,
|
||||||
|
"seconds": max(0.0, (end - row.started_at).total_seconds())}
|
||||||
|
|
||||||
|
|
||||||
|
async def recent(session, host_id: str, now: datetime, days: int = 7, limit: int = 6) -> list[dict]:
|
||||||
|
"""The host's downtime in the last `days`, newest first."""
|
||||||
|
rows = (await session.execute(select(HostDowntime).where(
|
||||||
|
HostDowntime.host_id == host_id,
|
||||||
|
HostDowntime.started_at > now - timedelta(days=days))
|
||||||
|
.order_by(HostDowntime.started_at.desc()).limit(limit))).scalars().all()
|
||||||
|
return [describe(r, now) for r in rows]
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"""Each drive's health, judged from what the root helper read with smartctl
|
||||||
|
(#5789). The helper reports figures, not verdicts, so how a drive is judged
|
||||||
|
can change here without an agent update.
|
||||||
|
|
||||||
|
A drive is:
|
||||||
|
- "failing" when its own self-assessment fails (SMART overall-health), an NVMe
|
||||||
|
drive raises a critical warning, or its spare blocks fall under threshold;
|
||||||
|
- "worn" when it has used WORN_PCT of its rated life;
|
||||||
|
- "watch" when it has bad sectors or media errors but otherwise passes: old
|
||||||
|
disks carry a few for years, so a count alone isn't a failure;
|
||||||
|
- "ok", or "unavailable" when it has no SMART to read (a virtual disk, a
|
||||||
|
bridge that hides it), or "unreadable" when smartctl couldn't read it.
|
||||||
|
|
||||||
|
Metrics, per drive on "<host>:drive:<device>": drive_failing (1/0),
|
||||||
|
drive_life_used_pct, drive_bad_sectors, drive_temp_c. Per host, for covering
|
||||||
|
rules: drives_failing (how many) and drive_life_used_max_pct.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
WORN_PCT = 90
|
||||||
|
DRIVE_METRICS = ("drive_failing", "drive_life_used_pct", "drive_bad_sectors", "drive_temp_c",
|
||||||
|
"drives_failing", "drive_life_used_max_pct")
|
||||||
|
# What a drive's own figures count as bad sectors or media errors, whichever
|
||||||
|
# its kind reports: NVMe media errors, ATA reallocated / pending / offline
|
||||||
|
# uncorrectable sectors, the SCSI grown defect list.
|
||||||
|
_BAD = ("media_errors", "reallocated", "pending", "offline_uncorrectable", "grown_defects")
|
||||||
|
_ORDER = {"failing": 0, "unreadable": 1, "worn": 2, "watch": 3, "ok": 4, "unavailable": 5}
|
||||||
|
|
||||||
|
|
||||||
|
def _num(v):
|
||||||
|
return v if isinstance(v, (int, float)) and not isinstance(v, bool) else None
|
||||||
|
|
||||||
|
|
||||||
|
def bad_sectors(d: dict) -> int | None:
|
||||||
|
counts = [_num(d.get(k)) for k in _BAD]
|
||||||
|
counts = [c for c in counts if c is not None]
|
||||||
|
return int(sum(counts)) if counts else None
|
||||||
|
|
||||||
|
|
||||||
|
def failing_reasons(d: dict) -> list[str]:
|
||||||
|
why = []
|
||||||
|
if d.get("passed") is False:
|
||||||
|
why.append("its SMART self-assessment failed")
|
||||||
|
if _num(d.get("critical_warning")):
|
||||||
|
why.append("critical warning %#x" % int(d["critical_warning"]))
|
||||||
|
spare, floor = _num(d.get("spare_pct")), _num(d.get("spare_threshold_pct"))
|
||||||
|
if spare is not None and floor is not None and spare < floor:
|
||||||
|
why.append(f"spare blocks {spare:.0f}% under the {floor:.0f}% threshold")
|
||||||
|
return why
|
||||||
|
|
||||||
|
|
||||||
|
def judge(d: dict) -> dict:
|
||||||
|
"""One drive as the host page shows it: its figures plus `state` and `why`."""
|
||||||
|
out = dict(d)
|
||||||
|
if d.get("error"):
|
||||||
|
out.update(state="unreadable", why=str(d["error"]))
|
||||||
|
elif d.get("available") is False:
|
||||||
|
out.update(state="unavailable", why="no SMART data (a virtual disk, or SMART is off)")
|
||||||
|
elif failing_reasons(d):
|
||||||
|
out.update(state="failing", why="; ".join(failing_reasons(d)))
|
||||||
|
elif (_num(d.get("life_used_pct")) or 0) >= WORN_PCT:
|
||||||
|
out.update(state="worn", why=f"{d['life_used_pct']:.0f}% of its rated life used")
|
||||||
|
elif bad_sectors(d):
|
||||||
|
out.update(state="watch", why=f"{bad_sectors(d)} bad sectors or media errors")
|
||||||
|
else:
|
||||||
|
out.update(state="ok", why="")
|
||||||
|
out["bad_sectors"] = bad_sectors(d)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def drives_of(report) -> list[dict]:
|
||||||
|
"""The report's drives, judged, what needs attention first. A name seen
|
||||||
|
twice gets an ordinal, so two drives never share one series: agent
|
||||||
|
1.20.0 named every disk behind a RAID controller "0" (#5806)."""
|
||||||
|
drives = report.get("drives") if isinstance(report, dict) else None
|
||||||
|
judged, seen = [], {}
|
||||||
|
for d in drives or []:
|
||||||
|
if not isinstance(d, dict) or not d.get("device"):
|
||||||
|
continue
|
||||||
|
d = judge(d)
|
||||||
|
name = str(d["device"])
|
||||||
|
seen[name] = seen.get(name, 0) + 1
|
||||||
|
if seen[name] > 1:
|
||||||
|
d["device"] = f"{name}#{seen[name]}"
|
||||||
|
judged.append(d)
|
||||||
|
return sorted(judged, key=lambda d: (_ORDER[d["state"]], str(d["device"])))
|
||||||
|
|
||||||
|
|
||||||
|
def metric_rows(report, host_name: str) -> list[tuple[str, str, float]]:
|
||||||
|
"""(metric, resource, value) for every figure a drive has, and the host's
|
||||||
|
two roll-ups once any drive was read."""
|
||||||
|
rows: list[tuple[str, str, float]] = []
|
||||||
|
failing = 0
|
||||||
|
life_max: float | None = None
|
||||||
|
read = 0
|
||||||
|
for d in drives_of(report):
|
||||||
|
if d["state"] in ("unreadable", "unavailable"):
|
||||||
|
continue
|
||||||
|
read += 1
|
||||||
|
res = f"{host_name}:drive:{d['device']}"
|
||||||
|
is_failing = d["state"] == "failing"
|
||||||
|
failing += is_failing
|
||||||
|
rows.append(("drive_failing", res, 1.0 if is_failing else 0.0))
|
||||||
|
life = _num(d.get("life_used_pct"))
|
||||||
|
if life is not None:
|
||||||
|
rows.append(("drive_life_used_pct", res, float(life)))
|
||||||
|
life_max = life if life_max is None else max(life_max, life)
|
||||||
|
if d["bad_sectors"] is not None:
|
||||||
|
rows.append(("drive_bad_sectors", res, float(d["bad_sectors"])))
|
||||||
|
if _num(d.get("temp_c")) is not None:
|
||||||
|
rows.append(("drive_temp_c", res, float(d["temp_c"])))
|
||||||
|
if read:
|
||||||
|
rows.append(("drives_failing", host_name, float(failing)))
|
||||||
|
if life_max is not None:
|
||||||
|
rows.append(("drive_life_used_max_pct", host_name, float(life_max)))
|
||||||
|
return rows
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Steward host agent: the packages the agent's helper uses, installed with
|
||||||
|
# whichever package manager the host has (#5789). Served by Steward at
|
||||||
|
# /plugins/host_agent/files/install-deps.sh and run, as root, by install.sh
|
||||||
|
# and the host_agent playbooks: one copy of the list.
|
||||||
|
#
|
||||||
|
# smartmontools gives the helper smartctl, which reads each drive's health.
|
||||||
|
# A package that can't be installed is reported, never fatal: the agent runs
|
||||||
|
# without it and the host page says what is missing.
|
||||||
|
#
|
||||||
|
# Recommended / weak dependencies are skipped: on Debian, smartmontools
|
||||||
|
# recommends a mail server.
|
||||||
|
|
||||||
|
PACKAGES="smartmontools"
|
||||||
|
|
||||||
|
have() { command -v "$1" >/dev/null 2>&1; }
|
||||||
|
|
||||||
|
install_with() {
|
||||||
|
if have apt-get; then
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
# A stale package list can't find the package: refresh once and retry.
|
||||||
|
apt-get install -y --no-install-recommends "$@" \
|
||||||
|
|| { apt-get update && apt-get install -y --no-install-recommends "$@"; }
|
||||||
|
elif have dnf; then
|
||||||
|
dnf -y install --setopt=install_weak_deps=False "$@"
|
||||||
|
elif have yum; then
|
||||||
|
yum -y install "$@"
|
||||||
|
elif have zypper; then
|
||||||
|
zypper --non-interactive install --no-recommends "$@"
|
||||||
|
elif have pacman; then
|
||||||
|
pacman -S --needed --noconfirm "$@"
|
||||||
|
elif have apk; then
|
||||||
|
apk add --no-cache "$@"
|
||||||
|
else
|
||||||
|
echo "no package manager this script knows (apt, dnf, yum, zypper, pacman, apk)"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
missing=""
|
||||||
|
have smartctl || missing="$missing smartmontools"
|
||||||
|
[ -n "$missing" ] || { echo "dependencies present: $PACKAGES"; exit 0; }
|
||||||
|
|
||||||
|
# shellcheck disable=SC2086 # one word per package
|
||||||
|
if install_with $missing; then
|
||||||
|
echo "installed:$missing"
|
||||||
|
else
|
||||||
|
echo "WARNING: could not install$missing; drive health will read \"smartctl not installed\" until it is"
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Steward maintenance helper (#5766): runs as root, started by steward-maint.path
|
||||||
|
when the host agent leaves a request, and does only what ACTIONS lists.
|
||||||
|
|
||||||
|
The agent (an unprivileged user under NoNewPrivileges) can't update or reboot
|
||||||
|
its host itself. It writes `<id>.json` = {"id", "action", ["node"]} into
|
||||||
|
REQUESTS, the one directory it may write. This helper reads each request
|
||||||
|
without following links, refuses anything outside the list, deletes it, does
|
||||||
|
the action, and writes the outcome to RESULTS (root's own directory, which the
|
||||||
|
agent reads back and reports to Steward). A stolen agent token can therefore
|
||||||
|
make its host update, reboot or drain, and nothing else.
|
||||||
|
|
||||||
|
One action only reads: `drives` (#5789) asks smartctl for each disk's health,
|
||||||
|
which needs root, and writes a summary to DRIVES, never a result: it is not a
|
||||||
|
maintenance job.
|
||||||
|
|
||||||
|
Stdlib only; served by Steward at /plugins/host_agent/files/maint.py.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
REQUESTS = "/var/lib/steward-agent/requests"
|
||||||
|
RESULTS = "/var/lib/steward-maint/results"
|
||||||
|
DRIVES = "/var/lib/steward-maint/drives.json"
|
||||||
|
ACTIONS = ("update", "reboot", "drain", "undrain", "drives")
|
||||||
|
REQUEST_MAX_BYTES = 4096
|
||||||
|
RESULTS_KEPT_SECONDS = 7 * 86400
|
||||||
|
UPDATE_DEADLINE_SECONDS = 3000
|
||||||
|
COMMAND_DEADLINE_SECONDS = 600
|
||||||
|
OUTPUT_KEPT_CHARS = 2000
|
||||||
|
_ID = re.compile(r"^[A-Za-z0-9-]{1,64}$")
|
||||||
|
_NODE = re.compile(r"^[a-z0-9]{25}$")
|
||||||
|
|
||||||
|
|
||||||
|
def now_iso() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def read_request(path: str):
|
||||||
|
"""The request at `path` as a dict, or None when it is not a small regular
|
||||||
|
file holding a valid request. Never follows a link the agent planted."""
|
||||||
|
try:
|
||||||
|
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
st = os.fstat(fd)
|
||||||
|
if not (st.st_mode & 0o170000 == 0o100000) or st.st_size > REQUEST_MAX_BYTES:
|
||||||
|
return None
|
||||||
|
raw = os.read(fd, REQUEST_MAX_BYTES + 1)
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
try:
|
||||||
|
req = json.loads(raw.decode("utf-8"))
|
||||||
|
except (UnicodeDecodeError, ValueError):
|
||||||
|
return None
|
||||||
|
return req if valid(req) else None
|
||||||
|
|
||||||
|
|
||||||
|
def valid(req) -> bool:
|
||||||
|
"""A request names a known action, has a plain id, and a node id only in
|
||||||
|
the swarm's own form."""
|
||||||
|
if not isinstance(req, dict) or req.get("action") not in ACTIONS:
|
||||||
|
return False
|
||||||
|
if not isinstance(req.get("id"), str) or not _ID.match(req["id"]):
|
||||||
|
return False
|
||||||
|
node = req.get("node")
|
||||||
|
return node is None or (isinstance(node, str) and bool(_NODE.match(node)))
|
||||||
|
|
||||||
|
|
||||||
|
def run(cmd: list, deadline: int = COMMAND_DEADLINE_SECONDS, env: dict | None = None):
|
||||||
|
"""(ok, output tail) of a command, bounded by `deadline`."""
|
||||||
|
try:
|
||||||
|
done = subprocess.run(cmd, capture_output=True, text=True, timeout=deadline,
|
||||||
|
env=dict(os.environ, LANG="C", LC_ALL="C", **(env or {})))
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return False, f"{cmd[0]} didn't finish in {deadline} s"
|
||||||
|
except OSError as exc:
|
||||||
|
return False, str(exc)
|
||||||
|
out = (done.stdout or "") + (done.stderr or "")
|
||||||
|
return done.returncode == 0, out[-OUTPUT_KEPT_CHARS:]
|
||||||
|
|
||||||
|
|
||||||
|
def reboot_required() -> bool | None:
|
||||||
|
if os.path.exists("/var/run/reboot-required"):
|
||||||
|
return True
|
||||||
|
if shutil.which("apt-get"):
|
||||||
|
return False
|
||||||
|
if shutil.which("needs-restarting"):
|
||||||
|
try:
|
||||||
|
return subprocess.run(["needs-restarting", "-r"], capture_output=True,
|
||||||
|
timeout=60).returncode == 1
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def upgraded_count(output: str) -> int | None:
|
||||||
|
"""Packages an apt or dnf run changed, from its summary line."""
|
||||||
|
m = re.search(r"(\d+) upgraded, (\d+) newly installed", output) # apt
|
||||||
|
if m:
|
||||||
|
return int(m.group(1)) + int(m.group(2))
|
||||||
|
counts = re.findall(r"^\s*(?:Upgrade|Install)\s+(\d+) Packages?", output, re.MULTILINE) # dnf
|
||||||
|
return sum(int(c) for c in counts) if counts else None
|
||||||
|
|
||||||
|
|
||||||
|
def do_update() -> dict:
|
||||||
|
"""Upgrade every package, keeping existing config files and leaving
|
||||||
|
service restarts to a reboot: needrestart only lists, so the Docker daemon
|
||||||
|
isn't restarted under its containers mid-upgrade."""
|
||||||
|
if shutil.which("apt-get"):
|
||||||
|
env = {"DEBIAN_FRONTEND": "noninteractive", "NEEDRESTART_MODE": "l"}
|
||||||
|
ok, out = run(["apt-get", "update"], env=env)
|
||||||
|
if not ok:
|
||||||
|
return {"ok": False, "detail": "apt-get update failed: " + out}
|
||||||
|
ok, out = run(["apt-get", "-y", "-o", "Dpkg::Options::=--force-confdef",
|
||||||
|
"-o", "Dpkg::Options::=--force-confold", "dist-upgrade"],
|
||||||
|
deadline=UPDATE_DEADLINE_SECONDS, env=env)
|
||||||
|
if not ok:
|
||||||
|
return {"ok": False, "detail": "apt-get dist-upgrade failed: " + out}
|
||||||
|
changed = upgraded_count(out)
|
||||||
|
run(["apt-get", "-y", "autoremove"], env=env)
|
||||||
|
elif shutil.which("dnf"):
|
||||||
|
ok, out = run(["dnf", "-y", "upgrade", "--refresh"], deadline=UPDATE_DEADLINE_SECONDS)
|
||||||
|
if not ok:
|
||||||
|
return {"ok": False, "detail": "dnf upgrade failed: " + out}
|
||||||
|
changed = upgraded_count(out)
|
||||||
|
else:
|
||||||
|
return {"ok": False, "detail": "no apt-get or dnf on this host"}
|
||||||
|
return {"ok": True, "packages": changed, "reboot_required": reboot_required(),
|
||||||
|
"detail": out[-500:]}
|
||||||
|
|
||||||
|
|
||||||
|
def own_node() -> str | None:
|
||||||
|
ok, out = run(["docker", "info", "--format", "{{.Swarm.NodeID}}"], deadline=30)
|
||||||
|
node = out.strip() if ok else ""
|
||||||
|
return node if _NODE.match(node) else None
|
||||||
|
|
||||||
|
|
||||||
|
def do_node(availability: str, node: str | None) -> dict:
|
||||||
|
"""Set a swarm node's availability (drain before a reboot, active after).
|
||||||
|
Only a manager can; a worker's helper says so and Steward asks a manager."""
|
||||||
|
if not shutil.which("docker"):
|
||||||
|
return {"ok": False, "detail": "no docker on this host"}
|
||||||
|
node = node or own_node()
|
||||||
|
if node is None:
|
||||||
|
return {"ok": False, "detail": "this host is not in a swarm"}
|
||||||
|
ok, out = run(["docker", "node", "update", "--availability", availability, node], deadline=60)
|
||||||
|
return {"ok": ok, "node": node, "detail": out.strip()[-500:]}
|
||||||
|
|
||||||
|
|
||||||
|
# ── drive health (#5789) ─────────────────────────────────────────────────────
|
||||||
|
# smartctl ≥ 7.0 answers in JSON, the same for SATA, SAS and NVMe drives on any
|
||||||
|
# distribution; an older one is reported, never parsed as text. The helper
|
||||||
|
# finds the drives itself: the request names none.
|
||||||
|
|
||||||
|
SMARTCTL_MIN = (7, 0)
|
||||||
|
SMARTCTL_DEADLINE_SECONDS = 60
|
||||||
|
DRIVES_MAX = 32
|
||||||
|
# smartctl's exit status is a bit mask: 1 the command line, 2 the device
|
||||||
|
# couldn't be opened. Higher bits describe the disk, and its JSON still holds.
|
||||||
|
_SMARTCTL_UNREAD = 0b11
|
||||||
|
# ATA attributes counted as bad sectors.
|
||||||
|
_ATA_BAD = {5: "reallocated", 197: "pending", 198: "offline_uncorrectable"}
|
||||||
|
|
||||||
|
|
||||||
|
def smartctl_version():
|
||||||
|
"""(major, minor) of the installed smartctl, or None when there is none."""
|
||||||
|
if not shutil.which("smartctl"):
|
||||||
|
return None
|
||||||
|
ok, out = run(["smartctl", "--version"], deadline=30)
|
||||||
|
m = re.search(r"smartctl (\d+)\.(\d+)", out)
|
||||||
|
return (int(m.group(1)), int(m.group(2))) if m else (0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def smartctl_json(args: list):
|
||||||
|
"""(exit status, parsed JSON or None) of one smartctl call."""
|
||||||
|
try:
|
||||||
|
done = subprocess.run(["smartctl", "--json", *args], capture_output=True, text=True,
|
||||||
|
timeout=SMARTCTL_DEADLINE_SECONDS,
|
||||||
|
env=dict(os.environ, LANG="C", LC_ALL="C"))
|
||||||
|
except (subprocess.TimeoutExpired, OSError):
|
||||||
|
return None, None
|
||||||
|
try:
|
||||||
|
return done.returncode, json.loads(done.stdout)
|
||||||
|
except ValueError:
|
||||||
|
return done.returncode, None
|
||||||
|
|
||||||
|
|
||||||
|
def _int(v):
|
||||||
|
return v if isinstance(v, int) and not isinstance(v, bool) else None
|
||||||
|
|
||||||
|
|
||||||
|
def drive_name(name: str, kind) -> str:
|
||||||
|
"""A drive's name, unique on its host. Disks behind a RAID controller
|
||||||
|
share one path (/dev/bus/0) and differ only by the slot in their type
|
||||||
|
(megaraid,3; cciss,0), so the slot is part of the name (#5806)."""
|
||||||
|
base = os.path.basename(name)
|
||||||
|
if name.startswith("/dev/bus/"):
|
||||||
|
base = "bus" + base
|
||||||
|
if isinstance(kind, str) and "," in kind:
|
||||||
|
base += "-" + re.sub(r"[^A-Za-z0-9]", "", kind.split("+")[-1])
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def drive_summary(name: str, status, data, kind=None) -> dict:
|
||||||
|
"""The few figures Steward reads from one drive's `smartctl -a` JSON."""
|
||||||
|
out = {"device": drive_name(name, kind)}
|
||||||
|
if not isinstance(data, dict) or status is None or status & _SMARTCTL_UNREAD:
|
||||||
|
messages = ((data.get("smartctl") or {}).get("messages") or []) if isinstance(data, dict) else []
|
||||||
|
text = "; ".join(str(m.get("string", "")) for m in messages if isinstance(m, dict))
|
||||||
|
out["error"] = (text or "smartctl couldn't read it")[:200]
|
||||||
|
return out
|
||||||
|
dev = data.get("device") or {}
|
||||||
|
out["protocol"] = str(dev.get("protocol") or "")[:16]
|
||||||
|
out["model"] = str(data.get("model_name") or data.get("scsi_model_name") or "")[:80]
|
||||||
|
cap = (data.get("user_capacity") or {}).get("bytes")
|
||||||
|
if _int(cap):
|
||||||
|
out["capacity_bytes"] = cap
|
||||||
|
support = data.get("smart_support") or {}
|
||||||
|
if support.get("available") is False or support.get("enabled") is False:
|
||||||
|
out["available"] = False # a virtual disk, or SMART switched off
|
||||||
|
return out
|
||||||
|
passed = (data.get("smart_status") or {}).get("passed")
|
||||||
|
if isinstance(passed, bool):
|
||||||
|
out["passed"] = passed
|
||||||
|
temp = _int((data.get("temperature") or {}).get("current"))
|
||||||
|
if temp is not None:
|
||||||
|
out["temp_c"] = temp
|
||||||
|
hours = _int((data.get("power_on_time") or {}).get("hours"))
|
||||||
|
if hours is not None:
|
||||||
|
out["power_on_hours"] = hours
|
||||||
|
nvme = data.get("nvme_smart_health_information_log") or {}
|
||||||
|
for src, dst in (("critical_warning", "critical_warning"), ("percentage_used", "life_used_pct"),
|
||||||
|
("media_errors", "media_errors"), ("available_spare", "spare_pct"),
|
||||||
|
("available_spare_threshold", "spare_threshold_pct")):
|
||||||
|
if _int(nvme.get(src)) is not None:
|
||||||
|
out[dst] = nvme[src]
|
||||||
|
for attr in (data.get("ata_smart_attributes") or {}).get("table") or []:
|
||||||
|
key = _ATA_BAD.get(attr.get("id")) if isinstance(attr, dict) else None
|
||||||
|
raw = _int((attr.get("raw") or {}).get("value")) if key else None
|
||||||
|
if raw is not None:
|
||||||
|
out[key] = raw
|
||||||
|
used = _int((data.get("endurance_used") or {}).get("current_percent")) # ATA SSDs
|
||||||
|
used = used if used is not None else _int(data.get("scsi_percentage_used_endurance_indicator"))
|
||||||
|
if used is not None and "life_used_pct" not in out:
|
||||||
|
out["life_used_pct"] = used
|
||||||
|
if _int(data.get("scsi_grown_defect_list")) is not None:
|
||||||
|
out["grown_defects"] = data["scsi_grown_defect_list"]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def do_drives() -> dict:
|
||||||
|
"""Each drive's health, as {"checked_at", "smartctl", "drives"} or
|
||||||
|
{"checked_at", "error"} when smartctl is missing or too old."""
|
||||||
|
base = {"checked_at": now_iso()}
|
||||||
|
version = smartctl_version()
|
||||||
|
if version is None:
|
||||||
|
return {**base, "error": "smartctl not installed"}
|
||||||
|
base["smartctl"] = "%d.%d" % version
|
||||||
|
if version < SMARTCTL_MIN:
|
||||||
|
return {**base, "error": "smartctl %d.%d is too old: drive health needs 7.0 or newer" % version}
|
||||||
|
_, scan = smartctl_json(["--scan-open"])
|
||||||
|
devices = (scan or {}).get("devices") if isinstance(scan, dict) else None
|
||||||
|
if not isinstance(devices, list):
|
||||||
|
return {**base, "error": "smartctl couldn't list the drives"}
|
||||||
|
drives = []
|
||||||
|
for d in devices[:DRIVES_MAX]:
|
||||||
|
name, kind = (d.get("name"), d.get("type")) if isinstance(d, dict) else (None, None)
|
||||||
|
if not isinstance(name, str) or not name.startswith("/dev/"):
|
||||||
|
continue
|
||||||
|
args = ["-a", name] if not isinstance(kind, str) else ["-a", "-d", kind, name]
|
||||||
|
status, data = smartctl_json(args)
|
||||||
|
drives.append(drive_summary(name, status, data, kind))
|
||||||
|
return {**base, "drives": drives}
|
||||||
|
|
||||||
|
|
||||||
|
def write_drives(report: dict, path: str | None = None) -> None:
|
||||||
|
"""DRIVES, written whole and readable by the agent."""
|
||||||
|
path = path or DRIVES
|
||||||
|
tmp = path + ".tmp"
|
||||||
|
with open(tmp, "w") as fh:
|
||||||
|
json.dump(report, fh)
|
||||||
|
os.chmod(tmp, 0o644)
|
||||||
|
os.replace(tmp, path)
|
||||||
|
|
||||||
|
|
||||||
|
def write_result(result: dict) -> None:
|
||||||
|
"""RESULTS/<id>.json, written whole (a temp file renamed into place)."""
|
||||||
|
os.makedirs(RESULTS, mode=0o755, exist_ok=True)
|
||||||
|
final = os.path.join(RESULTS, result["id"] + ".json")
|
||||||
|
tmp = final + ".tmp"
|
||||||
|
with open(tmp, "w") as fh:
|
||||||
|
json.dump(result, fh)
|
||||||
|
os.chmod(tmp, 0o644)
|
||||||
|
os.replace(tmp, final)
|
||||||
|
|
||||||
|
|
||||||
|
def already_handled(job_id: str) -> bool:
|
||||||
|
"""A request the agent sent again, for a job already run, is not run twice."""
|
||||||
|
return os.path.exists(os.path.join(RESULTS, job_id + ".json"))
|
||||||
|
|
||||||
|
|
||||||
|
def handle(req: dict) -> dict:
|
||||||
|
base = {"id": req["id"], "action": req["action"], "started_at": now_iso()}
|
||||||
|
if req["action"] == "drives": # a read, not a job: no result
|
||||||
|
write_drives(do_drives())
|
||||||
|
return {**base, "state": "done"}
|
||||||
|
write_result({**base, "state": "running"})
|
||||||
|
action = req["action"]
|
||||||
|
if action == "update":
|
||||||
|
outcome = do_update()
|
||||||
|
elif action in ("drain", "undrain"):
|
||||||
|
outcome = do_node("drain" if action == "drain" else "active", req.get("node"))
|
||||||
|
else: # reboot
|
||||||
|
result = {**base, "state": "rebooting", "ok": True, "finished_at": now_iso()}
|
||||||
|
write_result(result)
|
||||||
|
os.sync()
|
||||||
|
run(["systemctl", "reboot"], deadline=60)
|
||||||
|
return result
|
||||||
|
result = {**base, **outcome, "state": "done" if outcome.get("ok") else "failed",
|
||||||
|
"finished_at": now_iso()}
|
||||||
|
write_result(result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def prune_results(now: float | None = None) -> None:
|
||||||
|
now = time.time() if now is None else now
|
||||||
|
try:
|
||||||
|
names = os.listdir(RESULTS)
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
for name in names:
|
||||||
|
path = os.path.join(RESULTS, name)
|
||||||
|
try:
|
||||||
|
if now - os.lstat(path).st_mtime > RESULTS_KEPT_SECONDS:
|
||||||
|
os.unlink(path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def pending_requests() -> list:
|
||||||
|
"""Request files, oldest first."""
|
||||||
|
try:
|
||||||
|
names = [n for n in os.listdir(REQUESTS) if n.endswith(".json")]
|
||||||
|
except OSError:
|
||||||
|
return []
|
||||||
|
paths = [os.path.join(REQUESTS, n) for n in names]
|
||||||
|
|
||||||
|
def age(p):
|
||||||
|
try:
|
||||||
|
return os.lstat(p).st_mtime
|
||||||
|
except OSError:
|
||||||
|
return 0.0
|
||||||
|
return sorted(paths, key=age)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
prune_results()
|
||||||
|
for path in pending_requests():
|
||||||
|
req = read_request(path)
|
||||||
|
try:
|
||||||
|
os.unlink(path) # whatever it held: never re-read in a loop
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
if req is None:
|
||||||
|
sys.stderr.write(f"[steward-maint] ignored {os.path.basename(path)}: not a valid request\n")
|
||||||
|
continue
|
||||||
|
if already_handled(req["id"]):
|
||||||
|
continue
|
||||||
|
result = handle(req)
|
||||||
|
sys.stderr.write(f"[steward-maint] {req['action']} {req['id']}: {result['state']}\n")
|
||||||
|
if result["state"] == "rebooting":
|
||||||
|
break # the rest waits for the next boot
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Served by Steward at /plugins/host_agent/files/steward-agent.service and
|
||||||
|
# installed by install.sh and the host_agent playbooks: one copy of the unit.
|
||||||
|
[Unit]
|
||||||
|
Description=Steward host agent
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=steward-agent
|
||||||
|
Environment=STEWARD_AGENT_CONFIG=/etc/steward-agent.conf
|
||||||
|
ExecStart=/usr/bin/env python3 /usr/local/lib/steward-agent/agent.py
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
# Stopping waits for the agent's last post, bounded at 10 s.
|
||||||
|
TimeoutStopSec=30
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ReadOnlyPaths=/proc /sys
|
||||||
|
# The one place the agent writes: requests to the maintenance helper (#5766).
|
||||||
|
ReadWritePaths=-/var/lib/steward-agent/requests
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Starts the maintenance helper when the host agent leaves a request (#5766).
|
||||||
|
[Unit]
|
||||||
|
Description=Steward maintenance requests from the host agent
|
||||||
|
|
||||||
|
[Path]
|
||||||
|
PathExistsGlob=/var/lib/steward-agent/requests/*.json
|
||||||
|
Unit=steward-maint.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=paths.target
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Runs as root, and does only what maint.py's fixed list allows: update the
|
||||||
|
# host's packages, reboot it, drain / reactivate its swarm node (#5766), or
|
||||||
|
# read its drives' health with smartctl (#5789).
|
||||||
|
[Unit]
|
||||||
|
Description=Steward maintenance helper
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/bin/env python3 /usr/local/lib/steward-agent/maint.py
|
||||||
|
# A full upgrade on a slow mirror; past this systemd stops it.
|
||||||
|
TimeoutStartSec=3600
|
||||||
@@ -0,0 +1,438 @@
|
|||||||
|
# plugins/host_agent/maintenance.py
|
||||||
|
"""A host's maintenance job, followed to its end through any restart (#5767).
|
||||||
|
|
||||||
|
Steward plans and the host's agent acts: a job is one host's update and, only
|
||||||
|
when the update says one is needed, its reboot, with the host's swarm node
|
||||||
|
drained first and made active again after. Each action is one step the agent
|
||||||
|
hands to its root helper (maint.py, #5766). Everything lives on the job's row,
|
||||||
|
so Steward restarting, or the host Steward runs on rebooting, loses nothing:
|
||||||
|
the next post from the agent carries the step's result, and the `started`
|
||||||
|
event a host sends after booting is what completes a reboot.
|
||||||
|
|
||||||
|
The transitions here are model-free (a job is anything with the attributes),
|
||||||
|
so they're unit-tested without a database; routes.ingest and the scheduler's
|
||||||
|
tick apply them.
|
||||||
|
|
||||||
|
States: queued → updating → [draining →] rebooting → [undraining →] done,
|
||||||
|
or failed / timed_out. A job with no reboot needed goes updating → done.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
ACTIVE = ("queued", "updating", "draining", "rebooting", "undraining")
|
||||||
|
FINISHED = ("done", "failed", "timed_out")
|
||||||
|
# The helper action each handed state asks for.
|
||||||
|
STEP_ACTION = {"updating": "update", "draining": "drain", "rebooting": "reboot",
|
||||||
|
"undraining": "undrain"}
|
||||||
|
# How long a state may last before the job is given up. Queued counts from the
|
||||||
|
# job's creation (the host may be silent); the rest from their hand-off.
|
||||||
|
DEADLINES = {
|
||||||
|
"queued": timedelta(hours=1),
|
||||||
|
"updating": timedelta(minutes=90), # the helper itself stops at 60
|
||||||
|
"draining": timedelta(minutes=10),
|
||||||
|
"rebooting": timedelta(minutes=30),
|
||||||
|
"undraining": timedelta(minutes=10),
|
||||||
|
}
|
||||||
|
# A host's clock and Steward's differ by seconds; a boot this far before the
|
||||||
|
# reboot was handed out is still taken as that reboot.
|
||||||
|
BOOT_SLACK = timedelta(seconds=60)
|
||||||
|
DETAIL_MAX = 500
|
||||||
|
|
||||||
|
|
||||||
|
def step_id(job, state: str) -> str:
|
||||||
|
"""The helper request id for a job's step: unique per job and step, and in
|
||||||
|
the helper's id alphabet (letters, digits, dashes)."""
|
||||||
|
return f"{job.id}-{STEP_ACTION[state]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _enter(job, state: str, now: datetime) -> None:
|
||||||
|
job.state, job.state_at = state, now
|
||||||
|
job.step_id = step_id(job, state) if state in STEP_ACTION else None
|
||||||
|
job.step_seen = False # a new step is handed out until its result arrives
|
||||||
|
if state in FINISHED:
|
||||||
|
job.finished_at = now
|
||||||
|
|
||||||
|
|
||||||
|
def _finish(job, state: str, now: datetime, detail: str | None = None) -> None:
|
||||||
|
if detail:
|
||||||
|
job.detail = detail[:DETAIL_MAX]
|
||||||
|
_enter(job, state, now)
|
||||||
|
|
||||||
|
|
||||||
|
def start(job, now: datetime) -> None:
|
||||||
|
"""The host's agent is about to be handed the job: update first."""
|
||||||
|
if job.state == "queued":
|
||||||
|
_enter(job, "updating", now)
|
||||||
|
|
||||||
|
|
||||||
|
def executor(job) -> str:
|
||||||
|
"""The host whose agent carries out the current step: the job's own host,
|
||||||
|
except a drain or undrain, which a swarm manager does."""
|
||||||
|
if job.state in ("draining", "undraining") and job.drain_host_id:
|
||||||
|
return job.drain_host_id
|
||||||
|
return job.host_id
|
||||||
|
|
||||||
|
|
||||||
|
def handoff(job) -> dict | None:
|
||||||
|
"""What to hand the executing agent now, or None. Handed on every post
|
||||||
|
until a result for the step arrives; the agent and the helper both ignore
|
||||||
|
an id they've already taken, so a repeat is harmless."""
|
||||||
|
if job.state not in STEP_ACTION or job.step_seen:
|
||||||
|
return None
|
||||||
|
out = {"id": job.step_id, "action": STEP_ACTION[job.state]}
|
||||||
|
if job.state in ("draining", "undraining") and job.node_id:
|
||||||
|
out["node"] = job.node_id
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
REBOOT_POLICIES = ("if_required", "always", "never")
|
||||||
|
|
||||||
|
|
||||||
|
def wants_reboot(job) -> bool:
|
||||||
|
"""Reboot only when the update says one is needed (the operator's
|
||||||
|
default), always (a plan may ask), or never."""
|
||||||
|
if job.reboot_policy == "always":
|
||||||
|
return True
|
||||||
|
return bool(job.reboot_needed) and job.reboot_policy == "if_required"
|
||||||
|
|
||||||
|
|
||||||
|
def _after_update(job, now: datetime) -> None:
|
||||||
|
if not wants_reboot(job):
|
||||||
|
_enter(job, "done", now)
|
||||||
|
elif job.node_id and job.drain_host_id:
|
||||||
|
_enter(job, "draining", now)
|
||||||
|
else:
|
||||||
|
_enter(job, "rebooting", now)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_result(job, event: dict, now: datetime) -> bool:
|
||||||
|
"""Apply a `maintenance` event for this job's current step; True when the
|
||||||
|
job changed. A result for a step the job has moved past is ignored, so a
|
||||||
|
repeated or late report does nothing."""
|
||||||
|
if job.state not in STEP_ACTION or event.get("id") != job.step_id:
|
||||||
|
return False
|
||||||
|
state = event.get("state")
|
||||||
|
if state in ("running", "rebooting"):
|
||||||
|
if job.step_seen:
|
||||||
|
return False
|
||||||
|
job.step_seen = True
|
||||||
|
return True
|
||||||
|
if state not in ("done", "failed"):
|
||||||
|
return False
|
||||||
|
if state == "failed" or event.get("ok") is False:
|
||||||
|
what = STEP_ACTION[job.state]
|
||||||
|
detail = f"{what} failed: {event.get('detail') or 'no detail'}"
|
||||||
|
if job.state == "undraining":
|
||||||
|
detail += " (the node is still drained)"
|
||||||
|
_finish(job, "failed", now, detail)
|
||||||
|
return True
|
||||||
|
if job.state == "updating":
|
||||||
|
if isinstance(event.get("packages"), int):
|
||||||
|
job.packages = event["packages"]
|
||||||
|
job.reboot_needed = event.get("reboot_required") is True
|
||||||
|
_after_update(job, now)
|
||||||
|
elif job.state == "draining":
|
||||||
|
_enter(job, "rebooting", now)
|
||||||
|
elif job.state == "undraining":
|
||||||
|
_enter(job, "done", now)
|
||||||
|
else: # rebooting: the boot completes it, not this
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def apply_boot(job, boot_at: datetime | None, now: datetime) -> bool:
|
||||||
|
"""A `started` event from the job's host. A boot after the reboot was
|
||||||
|
handed out is that reboot: the host is back."""
|
||||||
|
if job.state != "rebooting" or boot_at is None or job.state_at is None:
|
||||||
|
return False
|
||||||
|
if boot_at < job.state_at - BOOT_SLACK:
|
||||||
|
return False # the agent restarted; the host didn't
|
||||||
|
job.back_at = now
|
||||||
|
if job.node_id and job.drain_host_id:
|
||||||
|
_enter(job, "undraining", now)
|
||||||
|
else:
|
||||||
|
_enter(job, "done", now)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def check_deadline(job, now: datetime) -> bool:
|
||||||
|
"""Give up on a job whose state has lasted past its deadline."""
|
||||||
|
limit = DEADLINES.get(job.state)
|
||||||
|
since = job.state_at or job.created_at
|
||||||
|
if limit is None or since is None or now - since <= limit:
|
||||||
|
return False
|
||||||
|
waiting = {"queued": "the host's agent didn't check in",
|
||||||
|
"updating": "the update didn't finish",
|
||||||
|
"draining": "the node wasn't drained",
|
||||||
|
"rebooting": "the host didn't come back",
|
||||||
|
"undraining": "the node wasn't made active again"}[job.state]
|
||||||
|
_finish(job, "timed_out", now, f"{waiting} within {int(limit.total_seconds() // 60)} min")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def window_end(job, now: datetime) -> datetime:
|
||||||
|
"""When the job's maintenance window should end: the longest the rest of
|
||||||
|
the job could take, or now once it has finished."""
|
||||||
|
if job.state in FINISHED:
|
||||||
|
return now
|
||||||
|
left = sum((DEADLINES[s] for s in ACTIVE[ACTIVE.index(job.state):]), timedelta())
|
||||||
|
return now + left
|
||||||
|
|
||||||
|
|
||||||
|
def utcnow() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
# ── applied to the database: ingest, the tick, and starting a job ────────────
|
||||||
|
|
||||||
|
async def _window(session, job, host_name: str, now: datetime) -> None:
|
||||||
|
"""Keep the job's maintenance window over the host until the job ends: one
|
||||||
|
scoped to this host's agent metrics, so an alert the job causes waits.
|
||||||
|
A swarm node's job keeps a second over the swarm-wide figures (#5788):
|
||||||
|
draining and rebooting a node takes it down and can leave a service
|
||||||
|
pinned to it short, which is the job, not news."""
|
||||||
|
from steward.models.alerts import MaintenanceWindow
|
||||||
|
if job.window_id:
|
||||||
|
w = await session.get(MaintenanceWindow, job.window_id)
|
||||||
|
if w is not None:
|
||||||
|
w.end_at = max(w.start_at, window_end(job, now))
|
||||||
|
elif job.created_by is None or job.state in FINISHED:
|
||||||
|
return # a window needs an owner; none for a finished job
|
||||||
|
else:
|
||||||
|
w = MaintenanceWindow(name=f"Maintenance: {host_name}"[:128], start_at=now,
|
||||||
|
end_at=window_end(job, now), scope_source="host_agent",
|
||||||
|
scope_resource=host_name, created_by=job.created_by)
|
||||||
|
session.add(w)
|
||||||
|
await session.flush()
|
||||||
|
job.window_id = w.id
|
||||||
|
if job.node_id:
|
||||||
|
await _swarm_window(session, job, host_name, now)
|
||||||
|
|
||||||
|
|
||||||
|
SWARM_SCOPE = ("docker", "swarm") # the swarm-wide figures' source and resource
|
||||||
|
|
||||||
|
|
||||||
|
async def _swarm_window(session, job, host_name: str, now: datetime) -> None:
|
||||||
|
"""The swarm half of a swarm node's job window, found again by its name
|
||||||
|
and the job's start, so it needs no column of its own."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
from steward.models.alerts import MaintenanceWindow
|
||||||
|
name = f"Maintenance: {host_name} (swarm)"[:128]
|
||||||
|
w = (await session.execute(select(MaintenanceWindow).where(
|
||||||
|
MaintenanceWindow.name == name, MaintenanceWindow.scope_source == SWARM_SCOPE[0],
|
||||||
|
MaintenanceWindow.scope_resource == SWARM_SCOPE[1],
|
||||||
|
MaintenanceWindow.start_at >= job.created_at))).scalars().first()
|
||||||
|
if w is not None:
|
||||||
|
w.end_at = max(w.start_at, window_end(job, now))
|
||||||
|
elif job.created_by is not None and job.state not in FINISHED:
|
||||||
|
session.add(MaintenanceWindow(name=name, start_at=now, end_at=window_end(job, now),
|
||||||
|
scope_source=SWARM_SCOPE[0], scope_resource=SWARM_SCOPE[1],
|
||||||
|
created_by=job.created_by))
|
||||||
|
|
||||||
|
|
||||||
|
async def _host_name(session, host_id: str) -> str:
|
||||||
|
from steward.models.hosts import Host
|
||||||
|
host = await session.get(Host, host_id)
|
||||||
|
return host.name if host is not None else host_id
|
||||||
|
|
||||||
|
|
||||||
|
async def apply_events(session, host_id: str, events, now: datetime) -> int:
|
||||||
|
"""Apply one post's `maintenance` results and `started` boots to the jobs
|
||||||
|
they belong to; returns how many jobs changed. A result is matched by its
|
||||||
|
step id, so a drain reported by a manager reaches the job of the node it
|
||||||
|
drained."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
from .downtime import _at
|
||||||
|
from .models import HostMaintenanceJob
|
||||||
|
if not isinstance(events, list):
|
||||||
|
return 0
|
||||||
|
changed = set()
|
||||||
|
for event in events:
|
||||||
|
if not isinstance(event, dict):
|
||||||
|
continue
|
||||||
|
if event.get("kind") == "maintenance" and isinstance(event.get("id"), str):
|
||||||
|
job = (await session.execute(select(HostMaintenanceJob).where(
|
||||||
|
HostMaintenanceJob.step_id == event["id"],
|
||||||
|
HostMaintenanceJob.state.in_(ACTIVE)))).scalar_one_or_none()
|
||||||
|
if job is not None and apply_result(job, event, now):
|
||||||
|
changed.add(job)
|
||||||
|
elif event.get("kind") == "started":
|
||||||
|
boot_at = _at(event.get("boot_at"))
|
||||||
|
for job in (await session.execute(select(HostMaintenanceJob).where(
|
||||||
|
HostMaintenanceJob.host_id == host_id,
|
||||||
|
HostMaintenanceJob.state == "rebooting"))).scalars():
|
||||||
|
if apply_boot(job, boot_at, now):
|
||||||
|
changed.add(job)
|
||||||
|
for job in changed:
|
||||||
|
await _window(session, job, await _host_name(session, job.host_id), now)
|
||||||
|
return len(changed)
|
||||||
|
|
||||||
|
|
||||||
|
async def handoffs(session, host_id: str, host_name: str, now: datetime) -> list[dict]:
|
||||||
|
"""The steps this host's agent should carry out now. A queued job for the
|
||||||
|
host starts here, when its agent is known to be listening."""
|
||||||
|
from sqlalchemy import or_, select
|
||||||
|
from .models import HostMaintenanceJob
|
||||||
|
jobs = (await session.execute(select(HostMaintenanceJob).where(
|
||||||
|
HostMaintenanceJob.state.in_(ACTIVE),
|
||||||
|
or_(HostMaintenanceJob.host_id == host_id,
|
||||||
|
HostMaintenanceJob.drain_host_id == host_id)
|
||||||
|
).order_by(HostMaintenanceJob.created_at))).scalars().all()
|
||||||
|
out = []
|
||||||
|
for job in jobs:
|
||||||
|
if job.state == "queued" and job.host_id == host_id:
|
||||||
|
start(job, now)
|
||||||
|
await _window(session, job, host_name, now)
|
||||||
|
if executor(job) == host_id:
|
||||||
|
step = handoff(job)
|
||||||
|
if step is not None:
|
||||||
|
out.append(step)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def tick(session, now: datetime) -> int:
|
||||||
|
"""Give up on jobs past their deadline; returns how many."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
from .models import HostMaintenanceJob
|
||||||
|
gave_up = 0
|
||||||
|
for job in (await session.execute(select(HostMaintenanceJob).where(
|
||||||
|
HostMaintenanceJob.state.in_(ACTIVE)))).scalars():
|
||||||
|
if check_deadline(job, now):
|
||||||
|
gave_up += 1
|
||||||
|
await _window(session, job, await _host_name(session, job.host_id), now)
|
||||||
|
return gave_up
|
||||||
|
|
||||||
|
|
||||||
|
class CannotStart(Exception):
|
||||||
|
"""Why a host can't take a maintenance job now, in words for the page."""
|
||||||
|
|
||||||
|
|
||||||
|
def pick_drainer(node_hostname_to_id: dict, managers: dict, host_name: str,
|
||||||
|
host_id: str, helpers: set, availability: str = "active") -> tuple:
|
||||||
|
"""(node_id, drain_host_id) for a host in a swarm, (None, None) for one
|
||||||
|
that isn't. `managers` maps a manager's host id to its swarm node id;
|
||||||
|
`helpers` is the hosts whose agent has the maintenance helper. The host
|
||||||
|
drains itself when it is a manager, else another manager does. A node the
|
||||||
|
operator already set to drain or pause (a manager kept free of work) is
|
||||||
|
left as it is: no drain before, and no making it active after."""
|
||||||
|
node_id = node_hostname_to_id.get(host_name.lower())
|
||||||
|
if node_id is None:
|
||||||
|
return None, None
|
||||||
|
if availability != "active":
|
||||||
|
return node_id, None
|
||||||
|
if host_id in managers and host_id in helpers:
|
||||||
|
return node_id, host_id
|
||||||
|
for mgr in sorted(managers):
|
||||||
|
if mgr != host_id and mgr in helpers:
|
||||||
|
return node_id, mgr
|
||||||
|
raise CannotStart("it is a swarm node and no swarm manager has the maintenance "
|
||||||
|
"helper to drain it: run Update agent on a manager")
|
||||||
|
|
||||||
|
|
||||||
|
async def plan_job(session, host, reg, reboot_policy: str = "if_required") -> tuple:
|
||||||
|
"""(node_id, drain_host_id) for a job on `host`, or CannotStart saying why
|
||||||
|
it can't have one now. The panel asks this before offering the button."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
from .models import HostAgentRegistration, HostMaintenanceJob
|
||||||
|
if reg is None or not reg.maint_helper:
|
||||||
|
raise CannotStart("its agent has no maintenance helper: run Update agent first")
|
||||||
|
if reboot_policy not in REBOOT_POLICIES:
|
||||||
|
raise CannotStart(f"unknown reboot policy {reboot_policy!r}")
|
||||||
|
busy = (await session.execute(select(HostMaintenanceJob.id).where(
|
||||||
|
HostMaintenanceJob.host_id == host.id,
|
||||||
|
HostMaintenanceJob.state.in_(ACTIVE)))).first()
|
||||||
|
if busy is not None:
|
||||||
|
raise CannotStart("a maintenance job is already running on it")
|
||||||
|
if reboot_policy == "never":
|
||||||
|
return None, None
|
||||||
|
nodes, managers, availability = await _swarm(session)
|
||||||
|
helpers = set((await session.execute(select(HostAgentRegistration.host_id).where(
|
||||||
|
HostAgentRegistration.maint_helper.is_(True)))).scalars())
|
||||||
|
node_id = nodes.get(host.name.lower())
|
||||||
|
return pick_drainer(nodes, managers, host.name, host.id, helpers,
|
||||||
|
availability.get(node_id, "active"))
|
||||||
|
|
||||||
|
|
||||||
|
async def create_job(session, host, reg, user_id: str | None,
|
||||||
|
reboot_policy: str = "if_required", now: datetime | None = None,
|
||||||
|
run_id: str | None = None):
|
||||||
|
"""Queue a maintenance job for `host`; it starts with the agent's next post."""
|
||||||
|
from .models import HostMaintenanceJob
|
||||||
|
now = now or utcnow()
|
||||||
|
node_id, drain_host_id = await plan_job(session, host, reg, reboot_policy)
|
||||||
|
job = HostMaintenanceJob(host_id=host.id, state="queued", state_at=now,
|
||||||
|
reboot_policy=reboot_policy, node_id=node_id,
|
||||||
|
drain_host_id=drain_host_id, created_by=user_id, created_at=now,
|
||||||
|
run_id=run_id)
|
||||||
|
session.add(job)
|
||||||
|
await session.flush()
|
||||||
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
async def latest_job(session, host_id: str, now: datetime, days: int = 366):
|
||||||
|
"""The host's running job, else its last one (within a year), for the panel."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
from .models import HostMaintenanceJob
|
||||||
|
return (await session.execute(select(HostMaintenanceJob).where(
|
||||||
|
HostMaintenanceJob.host_id == host_id,
|
||||||
|
HostMaintenanceJob.created_at >= now - timedelta(days=days),
|
||||||
|
).order_by(HostMaintenanceJob.state.in_(ACTIVE).desc(),
|
||||||
|
HostMaintenanceJob.created_at.desc()).limit(1))).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
LABELS = {
|
||||||
|
"queued": "Waiting for the agent",
|
||||||
|
"updating": "Updating",
|
||||||
|
"draining": "Draining its swarm node",
|
||||||
|
"rebooting": "Rebooting",
|
||||||
|
"undraining": "Making its swarm node active again",
|
||||||
|
"done": "Done",
|
||||||
|
"failed": "Failed",
|
||||||
|
"timed_out": "Timed out",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def summary(job) -> str:
|
||||||
|
"""One line on what the job did, for the panel."""
|
||||||
|
if job.state not in FINISHED:
|
||||||
|
return LABELS[job.state]
|
||||||
|
parts = []
|
||||||
|
if job.packages is not None:
|
||||||
|
parts.append(f"{job.packages} package{'s' if job.packages != 1 else ''} updated")
|
||||||
|
if job.back_at is not None:
|
||||||
|
parts.append("rebooted")
|
||||||
|
elif job.reboot_needed and job.reboot_policy == "never":
|
||||||
|
parts.append("reboot needed, not allowed by this job")
|
||||||
|
elif job.state == "done" and job.reboot_needed is False:
|
||||||
|
parts.append("no reboot needed")
|
||||||
|
head = LABELS[job.state]
|
||||||
|
return f"{head}: {', '.join(parts)}" if parts else head
|
||||||
|
|
||||||
|
|
||||||
|
def docker_models():
|
||||||
|
"""The loaded Docker plugin's models module, or None when it isn't loaded."""
|
||||||
|
from steward.core.plugin_manager import loaded_plugin_submodule
|
||||||
|
return loaded_plugin_submodule("docker", "models")
|
||||||
|
|
||||||
|
|
||||||
|
async def _swarm(session) -> tuple:
|
||||||
|
"""({node hostname: node id}, {manager host id: its node id},
|
||||||
|
{node id: availability}) from what the Docker plugin last stored; empty
|
||||||
|
when it isn't installed. A manager is a host whose agent reported the
|
||||||
|
swarm's nodes."""
|
||||||
|
models = docker_models()
|
||||||
|
if models is None:
|
||||||
|
return {}, {}, {}
|
||||||
|
DockerSwarmNode = models.DockerSwarmNode
|
||||||
|
from sqlalchemy import select
|
||||||
|
from steward.models.hosts import Host
|
||||||
|
rows = (await session.execute(select(DockerSwarmNode))).scalars().all()
|
||||||
|
nodes = {(n.hostname or "").lower(): n.node_id for n in rows if n.hostname}
|
||||||
|
reporters = {n.host_id for n in rows}
|
||||||
|
names = dict((await session.execute(select(Host.id, Host.name).where(
|
||||||
|
Host.id.in_(reporters)))).all()) if reporters else {}
|
||||||
|
managers = {hid: nodes.get(name.lower()) for hid, name in names.items()}
|
||||||
|
availability = {n.node_id: (n.availability or "active")
|
||||||
|
for n in sorted(rows, key=lambda n: n.updated_at)}
|
||||||
|
return nodes, managers, availability
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""The host_agent plugin's MCP tools and its get_overview findings."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from steward.hosts import service as host_svc
|
||||||
|
from steward.mcp._context import mcp_app
|
||||||
|
from steward.mcp.tools._shape import age_s, db_session, iso, now_utc
|
||||||
|
from steward.mcp.tools.overview import finding
|
||||||
|
from steward.models.hosts import Host
|
||||||
|
from steward.models.users import UserRole
|
||||||
|
|
||||||
|
from .models import HostAgentRegistration
|
||||||
|
|
||||||
|
# A registration this old that has never checked in is a failed install, not one in progress.
|
||||||
|
PENDING_GRACE = timedelta(minutes=30)
|
||||||
|
|
||||||
|
|
||||||
|
def agent_state(reg, now, stale_after_s: int) -> str:
|
||||||
|
"""The agent's state: reporting, stale (stopped checking in) or pending (never has)."""
|
||||||
|
if reg.last_seen_at is None:
|
||||||
|
return "pending"
|
||||||
|
return "reporting" if (now - reg.last_seen_at).total_seconds() <= stale_after_s else "stale"
|
||||||
|
|
||||||
|
|
||||||
|
async def _agents(db):
|
||||||
|
return (await db.execute(
|
||||||
|
select(HostAgentRegistration, Host)
|
||||||
|
.join(Host, Host.id == HostAgentRegistration.host_id)
|
||||||
|
.order_by(Host.name)
|
||||||
|
)).all()
|
||||||
|
|
||||||
|
|
||||||
|
def _updates(reg) -> dict | None:
|
||||||
|
"""A registration's pending updates and reboot, as list_agents says them (#5765)."""
|
||||||
|
if reg.updates_checked_at is None and reg.reboot_required is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"manager": reg.updates_manager, "pending": reg.updates_pending,
|
||||||
|
"security": reg.updates_security, "checked_at": iso(reg.updates_checked_at),
|
||||||
|
"error": reg.updates_error, "reboot_required": reg.reboot_required,
|
||||||
|
"reboot_required_since": iso(reg.reboot_required_since),
|
||||||
|
"reboot_packages": reg.reboot_packages.split(", ") if reg.reboot_packages else [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def list_agents() -> dict:
|
||||||
|
"""Every host agent Steward has registered, with its version and whether it is reporting.
|
||||||
|
|
||||||
|
`state` is "reporting", "stale" (it checked in before but its last
|
||||||
|
check-in is older than the stale window, `stale_after_s`) or "pending"
|
||||||
|
(registered, never checked in, usually an install that is still running
|
||||||
|
or that failed). Each row carries the agent version, kernel, distro, arch
|
||||||
|
and the IP the agent reports. get_host shows an agent's latest metrics.
|
||||||
|
|
||||||
|
`updates` is what the host's package manager has waiting, from the
|
||||||
|
agent's hourly check (agent ≥ 1.16.0): manager, pending, security (null
|
||||||
|
when the manager can't say), checked_at, error, and reboot_required with
|
||||||
|
since when and the packages that asked for it. null before the first check.
|
||||||
|
"""
|
||||||
|
now = now_utc()
|
||||||
|
stale_after = host_svc.agent_stale_after_seconds(mcp_app().config)
|
||||||
|
async with db_session() as db:
|
||||||
|
rows = await _agents(db)
|
||||||
|
agents = [
|
||||||
|
{
|
||||||
|
"host_id": h.id, "host_name": h.name,
|
||||||
|
"state": agent_state(reg, now, stale_after),
|
||||||
|
"last_seen_at": iso(reg.last_seen_at), "last_seen_age_s": age_s(reg.last_seen_at, now),
|
||||||
|
"agent_version": reg.agent_version, "kernel": reg.kernel, "distro": reg.distro,
|
||||||
|
"arch": reg.arch, "reported_ip": reg.host_ip,
|
||||||
|
"registered_at": iso(reg.created_at),
|
||||||
|
"updates": _updates(reg),
|
||||||
|
}
|
||||||
|
for reg, h in rows
|
||||||
|
]
|
||||||
|
counts = {s: sum(1 for a in agents if a["state"] == s) for s in ("reporting", "stale", "pending")}
|
||||||
|
return {"stale_after_s": stale_after, "counts": counts, "agents": agents, "total": len(agents)}
|
||||||
|
|
||||||
|
|
||||||
|
async def mcp_overview(db) -> list[dict]:
|
||||||
|
"""Agents registered more than PENDING_GRACE ago that have never checked in.
|
||||||
|
|
||||||
|
Agents that stopped reporting are already flagged by the core overview
|
||||||
|
from their metrics, so they are not repeated here.
|
||||||
|
"""
|
||||||
|
now = now_utc()
|
||||||
|
return [
|
||||||
|
finding("warning", "agent_never_reported", h.name,
|
||||||
|
"host agent was registered but has never checked in; the install may have failed",
|
||||||
|
since=reg.created_at, ids={"host_id": h.id}, next="list_agents()")
|
||||||
|
for reg, h in await _agents(db)
|
||||||
|
if reg.last_seen_at is None and now - reg.created_at > PENDING_GRACE
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def register_tools(register) -> None:
|
||||||
|
register(list_agents, role=UserRole.viewer, read_only=True)
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""Host-metric read helpers (latest snapshot + bucketed history).
|
||||||
|
|
||||||
|
Kept separate from routes.py so it imports only the core PluginMetric model — not
|
||||||
|
the host_agent ORM models — which lets integration tests import these helpers
|
||||||
|
without tripping the plugin-loader's double-registration ("Table already
|
||||||
|
defined") guard.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from steward.core.time_range import bucket_seconds
|
||||||
|
from steward.models.metrics import PluginMetric, PluginMetricHourly
|
||||||
|
|
||||||
|
SOURCE_MODULE = "host_agent"
|
||||||
|
|
||||||
|
# Host-level metrics charted on the detail page (sub-resources are shown as
|
||||||
|
# current-value lists, not time series, to keep the page readable).
|
||||||
|
HISTORY_METRICS = (
|
||||||
|
"cpu_pct", "mem_used_pct", "disk_used_pct_worst", "load_1m",
|
||||||
|
"net_rx_bps", "net_tx_bps", "disk_read_bps", "disk_write_bps",
|
||||||
|
"temp_c_max", "psi_mem_some_avg10",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _latest_metrics_for_host(session, host_name: str) -> dict[str, dict[str, float]]:
|
||||||
|
"""{resource_name: {metric: value}} — latest sample per (resource, metric) for
|
||||||
|
a host + its sub-resources, from plugin_metric_latest (#5208). A host whose
|
||||||
|
agent has gone quiet keeps its last values until they age out of the raw
|
||||||
|
window; the caller decides staleness from the registration's last_seen_at.
|
||||||
|
"""
|
||||||
|
from steward.core.metric_series import latest_values
|
||||||
|
|
||||||
|
out: dict[str, dict[str, float]] = {}
|
||||||
|
for r in await latest_values(session, SOURCE_MODULE, host_name, include_children=True):
|
||||||
|
out.setdefault(r.resource_name, {})[r.metric_name] = r.value
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def _history_for_host(session, host_name: str, since, *, raw_days: int = 7) -> dict[str, list]:
|
||||||
|
"""{metric: [[epoch_ms, avg_value], …]} host-level series since `since`.
|
||||||
|
|
||||||
|
Retention rolls raw plugin_metrics older than `raw_days` into hourly averages
|
||||||
|
(plugin_metrics_hourly) and deletes the raw rows. So we read the rollup for the
|
||||||
|
part of the range older than that boundary and raw (bucket-averaged in SQL,
|
||||||
|
capped to ≤1h to match the rollup) for the recent part — never shipping raw
|
||||||
|
samples to Python. The two windows don't overlap, so appending hourly-then-raw
|
||||||
|
keeps each metric's series time-ordered. Epoch-ms x feeds a linear chart axis.
|
||||||
|
"""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
raw_cutoff = (now - timedelta(days=raw_days)).replace(minute=0, second=0, microsecond=0)
|
||||||
|
series: dict[str, list] = {m: [] for m in HISTORY_METRICS}
|
||||||
|
|
||||||
|
# ── Older than the raw window: the hourly rollup ──
|
||||||
|
if since < raw_cutoff:
|
||||||
|
hourly = (await session.execute(
|
||||||
|
select(PluginMetricHourly.metric_name, PluginMetricHourly.bucket,
|
||||||
|
PluginMetricHourly.value_avg)
|
||||||
|
.where(
|
||||||
|
PluginMetricHourly.source_module == SOURCE_MODULE,
|
||||||
|
PluginMetricHourly.resource_name == host_name,
|
||||||
|
PluginMetricHourly.metric_name.in_(HISTORY_METRICS),
|
||||||
|
PluginMetricHourly.bucket >= since,
|
||||||
|
PluginMetricHourly.bucket < raw_cutoff,
|
||||||
|
)
|
||||||
|
.order_by(PluginMetricHourly.bucket)
|
||||||
|
)).all()
|
||||||
|
for metric_name, bucket, avg in hourly:
|
||||||
|
series[metric_name].append([int(bucket.timestamp() * 1000), round(float(avg), 2)])
|
||||||
|
|
||||||
|
# ── Recent part: raw, bucket-averaged in SQL ──
|
||||||
|
raw_since = since if since >= raw_cutoff else raw_cutoff
|
||||||
|
width_s = bucket_seconds(raw_since, 120)
|
||||||
|
if since < raw_cutoff:
|
||||||
|
width_s = min(width_s, 3600) # ≤ 1h so it matches the hourly portion
|
||||||
|
rbucket = func.date_bin(
|
||||||
|
func.make_interval(0, 0, 0, 0, 0, 0, width_s),
|
||||||
|
PluginMetric.recorded_at,
|
||||||
|
func.to_timestamp(0), # epoch origin
|
||||||
|
).label("bucket")
|
||||||
|
raw = (await session.execute(
|
||||||
|
select(PluginMetric.metric_name, rbucket, func.avg(PluginMetric.value))
|
||||||
|
.where(
|
||||||
|
PluginMetric.source_module == SOURCE_MODULE,
|
||||||
|
PluginMetric.resource_name == host_name,
|
||||||
|
PluginMetric.metric_name.in_(HISTORY_METRICS),
|
||||||
|
PluginMetric.recorded_at >= raw_since,
|
||||||
|
)
|
||||||
|
.group_by(PluginMetric.metric_name, rbucket)
|
||||||
|
.order_by(rbucket)
|
||||||
|
)).all()
|
||||||
|
for metric_name, b, avg in raw:
|
||||||
|
series[metric_name].append([int(b.timestamp() * 1000), round(float(avg), 2)])
|
||||||
|
return series
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# plugins/host_agent/migrations/env.py
|
||||||
|
"""Alembic env.py for the host_agent plugin."""
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from sqlalchemy import pool
|
||||||
|
from sqlalchemy.engine import Connection
|
||||||
|
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||||
|
from alembic import context
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
|
||||||
|
|
||||||
|
from steward.models.base import Base
|
||||||
|
import steward.models # noqa: F401
|
||||||
|
from plugins.host_agent.models import HostAgentRegistration # noqa: F401
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def _get_url() -> str:
|
||||||
|
import yaml
|
||||||
|
cfg_path = os.environ.get("STEWARD_CONFIG", "config.yaml")
|
||||||
|
try:
|
||||||
|
with open(cfg_path) as f:
|
||||||
|
cfg = yaml.safe_load(f) or {}
|
||||||
|
url = cfg.get("database", {}).get("url", "")
|
||||||
|
except FileNotFoundError:
|
||||||
|
url = ""
|
||||||
|
return os.environ.get("STEWARD_DATABASE__URL", url)
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
context.configure(
|
||||||
|
url=_get_url(), target_metadata=target_metadata,
|
||||||
|
literal_binds=True, dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def do_run_migrations(connection: Connection) -> None:
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_async_migrations() -> None:
|
||||||
|
cfg = config.get_section(config.config_ini_section, {})
|
||||||
|
cfg["sqlalchemy.url"] = _get_url()
|
||||||
|
connectable = async_engine_from_config(cfg, prefix="sqlalchemy.", poolclass=pool.NullPool)
|
||||||
|
async with connectable.connect() as connection:
|
||||||
|
await connection.run_sync(do_run_migrations)
|
||||||
|
await connectable.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
asyncio.run(run_async_migrations())
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# plugins/host_agent/migrations/versions/host_agent_001_initial.py
|
||||||
|
"""host_agent plugin initial tables
|
||||||
|
|
||||||
|
Revision ID: host_agent_001_initial
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = "host_agent_001_initial"
|
||||||
|
down_revision: Union[str, None] = None
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = "host_agent"
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ("0004_app_settings",)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"host_agent_registrations",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True),
|
||||||
|
sa.Column("host_id", sa.String(36),
|
||||||
|
sa.ForeignKey("hosts.id", ondelete="CASCADE"),
|
||||||
|
nullable=False, unique=True),
|
||||||
|
sa.Column("token_hash", sa.String(64), nullable=False, unique=True, index=True),
|
||||||
|
sa.Column("token_created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("agent_version", sa.String(32), nullable=True),
|
||||||
|
sa.Column("kernel", sa.String(128), nullable=True),
|
||||||
|
sa.Column("distro", sa.String(128), nullable=True),
|
||||||
|
sa.Column("arch", sa.String(32), nullable=True),
|
||||||
|
sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("host_agent_registrations")
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# plugins/host_agent/migrations/versions/host_agent_002_host_ip.py
|
||||||
|
"""host_agent: add agent-reported host_ip column
|
||||||
|
|
||||||
|
Revision ID: host_agent_002_host_ip
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = "host_agent_002_host_ip"
|
||||||
|
down_revision: Union[str, None] = "host_agent_001_initial"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"host_agent_registrations",
|
||||||
|
sa.Column("host_ip", sa.String(45), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("host_agent_registrations", "host_ip")
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# plugins/host_agent/migrations/versions/host_agent_003_clock_offset.py
|
||||||
|
"""host_agent: how far each host's clock is from Steward's (milestone 490)
|
||||||
|
|
||||||
|
Revision ID: host_agent_003_clock_offset
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = "host_agent_003_clock_offset"
|
||||||
|
down_revision: Union[str, None] = "host_agent_002_host_ip"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("host_agent_registrations", sa.Column("clock_offset_s", sa.Float(), nullable=True))
|
||||||
|
op.add_column("host_agent_registrations",
|
||||||
|
sa.Column("clock_checked_at", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("host_agent_registrations", "clock_checked_at")
|
||||||
|
op.drop_column("host_agent_registrations", "clock_offset_s")
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# plugins/host_agent/migrations/versions/host_agent_004_downtime.py
|
||||||
|
"""host_agent: each host's downtime and what it was (#5756)
|
||||||
|
|
||||||
|
Revision ID: host_agent_004_downtime
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = "host_agent_004_downtime"
|
||||||
|
down_revision: Union[str, None] = "host_agent_003_clock_offset"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"host_downtime",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True),
|
||||||
|
sa.Column("host_id", sa.String(36), sa.ForeignKey("hosts.id", ondelete="CASCADE"),
|
||||||
|
nullable=False),
|
||||||
|
sa.Column("kind", sa.String(16), nullable=False),
|
||||||
|
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("ended_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("ix_host_downtime_host_id", "host_downtime", ["host_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_host_downtime_host_id", table_name="host_downtime")
|
||||||
|
op.drop_table("host_downtime")
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# plugins/host_agent/migrations/versions/host_agent_005_updates.py
|
||||||
|
"""host_agent: a host's pending updates and whether it needs a reboot (#5765)
|
||||||
|
|
||||||
|
Revision ID: host_agent_005_updates
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = "host_agent_005_updates"
|
||||||
|
down_revision: Union[str, None] = "host_agent_004_downtime"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
_COLUMNS = (
|
||||||
|
("updates_manager", sa.String(8)),
|
||||||
|
("updates_pending", sa.Integer()),
|
||||||
|
("updates_security", sa.Integer()),
|
||||||
|
("updates_checked_at", sa.DateTime(timezone=True)),
|
||||||
|
("updates_error", sa.String(200)),
|
||||||
|
("reboot_required", sa.Boolean()),
|
||||||
|
("reboot_required_since", sa.DateTime(timezone=True)),
|
||||||
|
("reboot_packages", sa.Text()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
for name, kind in _COLUMNS:
|
||||||
|
op.add_column("host_agent_registrations", sa.Column(name, kind, nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
for name, _kind in reversed(_COLUMNS):
|
||||||
|
op.drop_column("host_agent_registrations", name)
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# plugins/host_agent/migrations/versions/host_agent_006_maint_helper.py
|
||||||
|
"""host_agent: whether a host has the maintenance helper (#5766)
|
||||||
|
|
||||||
|
Revision ID: host_agent_006_maint_helper
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = "host_agent_006_maint_helper"
|
||||||
|
down_revision: Union[str, None] = "host_agent_005_updates"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("host_agent_registrations", sa.Column("maint_helper", sa.Boolean(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("host_agent_registrations", "maint_helper")
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""host_agent: a host's maintenance job, followed through its reboot (#5767)
|
||||||
|
|
||||||
|
Revision ID: host_agent_007_maint_jobs
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = "host_agent_007_maint_jobs"
|
||||||
|
down_revision: Union[str, None] = "host_agent_006_maint_helper"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
# maintenance_windows (core 0010) must exist before the window_id key.
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ("0010_maintenance_windows",)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"host_maintenance_jobs",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True),
|
||||||
|
sa.Column("host_id", sa.String(36), sa.ForeignKey("hosts.id", ondelete="CASCADE"),
|
||||||
|
nullable=False),
|
||||||
|
sa.Column("state", sa.String(16), nullable=False),
|
||||||
|
sa.Column("state_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("reboot_policy", sa.String(16), nullable=False),
|
||||||
|
sa.Column("step_id", sa.String(64), nullable=True),
|
||||||
|
sa.Column("step_seen", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("node_id", sa.String(64), nullable=True),
|
||||||
|
sa.Column("drain_host_id", sa.String(36), sa.ForeignKey("hosts.id", ondelete="SET NULL"),
|
||||||
|
nullable=True),
|
||||||
|
sa.Column("packages", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("reboot_needed", sa.Boolean(), nullable=True),
|
||||||
|
sa.Column("back_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("detail", sa.Text(), nullable=True),
|
||||||
|
sa.Column("window_id", sa.String(36),
|
||||||
|
sa.ForeignKey("maintenance_windows.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("created_by", sa.String(36), sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
|
nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index("ix_host_maintenance_jobs_host_id", "host_maintenance_jobs", ["host_id"])
|
||||||
|
op.create_index("ix_host_maintenance_jobs_state", "host_maintenance_jobs", ["state"])
|
||||||
|
op.create_index("ix_host_maintenance_jobs_step_id", "host_maintenance_jobs", ["step_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("host_maintenance_jobs")
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user