24 Commits
Author SHA1 Message Date
bvandeusenandClaude Opus 5 0da88c7076 fix(plugins): show load-failure reasons on the page the banner links to
CI / lint (push) Successful in 3s
CI / unit (push) Successful in 45s
CI / integration (push) Successful in 2m24s
CI / publish (push) Successful in 1m14s
The admin banner counts every plugin load failure, but settings/plugins.html
only rendered failure reasons inside the plugin card macro -- which iterates
discovered plugins. A plugin that failed BECAUSE it has no directory was
therefore counted in the banner and shown nowhere on the page the banner links
to. That dead end is what the operator hit: "1 plugin failed to load" on the
dashboard, nothing wrong on the details page, and no way to clean it up.

Folds the reason into the "Configured but not installed" rows rather than
adding a competing section, and drives that list from the UNION of stored
plugin.* settings and recorded load failures instead of stored settings alone.
A failure with no stored row -- a plugin enabled by a DEFAULTS entry, which is
exactly what plugin.http was -- would otherwise still be counted and still be
invisible.

Rows carry `removable`: only a plugin with a real stored row can be cleaned up,
so a default-declared one shows the reason and an explanation instead of a
Remove button that would delete nothing while the default reasserts it. Rows
with a reason read as an error (red, "Failed to load"); rows that merely have
leftover config stay a warning. A discovered plugin that failed is excluded --
its own card already shows the reason.

Replaces find_orphaned_plugin_names outright rather than adding a second
overlapping helper; it had one caller.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 08:36:35 -04:00
bvandeusenandClaude Opus 5 8c50ff242c fix(db): ride out a brief database outage mid-request; explain it if it persists
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 40s
CI / integration (push) Successful in 2m21s
CI / publish (push) Successful in 1m2s
The operator's 500 was NOT the startup gap I attributed it to. They found it by
logging in and getting Steward's own error page -- which means create_app had
completed and the app was serving, so the gaierror came from a request handler
acquiring a connection, not from boot. Neither prior fix covers that: the
startup retry never runs, and pool_pre_ping only helps once the database is
back, since its replacement connect fails too while the container is gone.

Adds a before_request gate that acquires a pooled connection with a short
bounded retry (~1.75s over 4 tries) so a database restart is ridden out
invisibly, and renders a distinct 503 "database unavailable" page when the
budget is exhausted. The request budget is deliberately far shorter than the
startup one: nobody watches a container boot, but somebody is watching this
page load, and a page that hangs is worse than one that says what is wrong.

/health and static are exempt -- a liveness probe must stay answerable while
the database is down, or a dependency outage triggers a restart loop.

Credential and missing-database errors still propagate rather than being
reported as "unavailable", which would send the operator chasing the wrong
problem entirely.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 08:23:01 -04:00
bvandeusenandClaude Opus 5 59fece855d fix(plugins): remove the phantom http plugin; surface orphaned plugin settings
CI / lint (push) Successful in 3s
CI / unit (push) Successful in 46s
CI / integration (push) Successful in 2m22s
CI / publish (push) Successful in 1m16s
Operator saw the `http` plugin -- deleted when ping/dns/http were unified into
the Monitor entity -- still reported as enabled, with no way to clear it.

It was never an orphaned row. `plugin.http` was hardcoded in core DEFAULTS, so
deleting the app_settings row did nothing: the default reasserted it on the
next settings load. That is precisely why no cleanup UI could have fixed it.
Rule 22 says the removed subsystem should have taken its setting with it.

Purged the surviving references -- the DEFAULTS key, "http" in
CAPABILITY_PLUGINS, the stale plugin_manager docstring example, and the
capabilities blurb still advertising HTTP/uptime as a bundled capability --
plus a migration dropping any stored plugin.http row. Untouched: `http` as a
MONITOR TYPE (icmp/tcp/dns/http) everywhere it appears, and http_001_initial,
which is kept deliberately so existing DBs resolve the revision graph.

For the general case, cleanup splits by provenance rather than being uniformly
automatic or uniformly manual:

  Bundled plugins ship in the image and version atomically with core, so they
  cannot be transiently missing -- a plugin.* default with no bundled directory
  is unambiguously a bug. Guarded by a unit test that fails CI, which is the
  only part safe to automate.

  External plugins live in operator-mounted /data/plugins, where absence is
  ambiguous: unmounted volume, failed install, mid-upgrade. Auto-deleting their
  config would silently destroy unrecoverable credentials on a transient
  condition, so Settings now lists them under "Configured but not installed"
  with an explicit, confirmed, audit-logged Remove. The section hides entirely
  when empty, and the remove route refuses if the plugin is actually installed.

Orphan detection reads STORED rows, never the DEFAULTS-merged view -- offering
to remove a default-only key would be a lie -- and returns names only, since
plugin config can hold credentials and listing orphans never needs their values.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 23:41:06 -04:00
bvandeusenandClaude Opus 5 6c9b89390a fix(db): survive a database restart and a not-yet-ready database
CI / lint (push) Successful in 4s
CI / unit (push) Successful in 50s
CI / integration (push) Successful in 2m21s
CI / publish (push) Successful in 1m2s
Two connection-recovery gaps, both surfaced by a Postgres restart that left
the app throwing tracebacks while the DB itself was healthy.

pool_pre_ping + pool_recycle on the app engine [#2626]: when the database
restarts, every connection already in the pool is dead at the socket level.
SQLAlchemy only discovered that by failing a real query, so the first
operation after a restart errored out on whatever triggered it. pre_ping
checks liveness on checkout and swaps the dead connection transparently;
pool_recycle caps connection age so a socket stranded by a NAT/conntrack
timeout or a Docker network rebuild is retired on a timer instead.

wait_for_database() gate at startup [#2627]: create_app touches the DB
synchronously (migrations, secret re-encryption, settings load) and assumed
it was both resolvable and accepting connections on the first try. Neither
holds after a host reboot (Docker DNS not yet serving `db` -> gaierror -2)
or an unclean shutdown (Postgres still replaying WAL -> "not yet accepting
connections"). Both are transient, so retry with capped backoff behind one
gate ahead of the first DB touch. Credential and missing-database errors
are classified by SQLSTATE and still fail immediately -- waiting cannot fix
those. Budget is bootstrap-configurable (STEWARD_DB_CONNECT_TIMEOUT /
database.connect_timeout, default 60s) since it governs reaching the DB and
so cannot live in the DB-backed settings.

Tests drive the retry loop off a fake clock, so backoff and timeout
behaviour are deterministic rather than wall-clock dependent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 23:21:58 -04:00
bvandeusenandClaude Opus 4.8 e8ac99174a feat(ansible): parameterize docker_prune with prune_target (containers/images/system)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 44s
CI / integration (push) Successful in 2m36s
CI / publish (push) Successful in 1m11s
M78 step 1. The bundled maintenance/docker_prune.yml was a single
`docker system prune -f`; the upcoming per-host prune buttons need a
granular split. Add a `prune_target` extra-var:
- containers → docker container prune -f
- images     → docker image prune -f (+ -a when prune_all_images)
- system     → docker system prune -f (+ -a / --volumes) — the DEFAULT,
               so existing manual/scheduled callers (which set no var)
               keep today's behavior.

Reuses the existing bundled playbook rather than adding parallel files;
keeps the description/category/confirm meta so it still self-describes as
a confirm-gated maintenance run. Adds unit coverage: the playbook stays
valid YAML, its meta still discovers, and all three targets are guarded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CAGR73DUowdVFVvYzLXC5C
2026-07-19 16:09:39 -04:00
bvandeusenandClaude Opus 4.8 f40063a74d feat(reliability): fd-leak rails — self-watchdog, poll-overlap guard, fd tests
CI / lint (push) Successful in 3s
CI / unit (push) Successful in 46s
CI / integration (push) Successful in 2m19s
CI / publish (push) Successful in 1m3s
Guardrails so the fd-leak class of bug (Errno 24 lockup; recent SNMP + UniFi
fixes) surfaces early or can't compound, instead of silently killing the app.

- Self-fd watchdog (steward/core/self_monitor.py): records open_fds and
  open_fds_pct (% of soft RLIMIT_NOFILE) as "steward"/"process" metrics each
  minute through the normal alert pipeline, so the operator can alert on them
  via the existing alert-rules UI. Built-in WARNING floor at 80% gives a
  zero-config early signal. Stdlib-only (/proc + resource); degrades to a no-op
  off Linux. Registered as a core ScheduledTask in app.py.

- Poll-overlap guard (steward/core/scheduler.py): extract a pure _DueTracker
  that skips a tick while a task's prior run is still in flight, so a hung poll
  can't stack overlapping runs (which amplify per-poll resource/fd use). A
  skipped task isn't penalised — it retries the next tick after it completes.

- fd-stability tests (tests/core/): _DueTracker overlap policy, the watchdog
  metric/warning/degradation paths, and a real-fd canary that hammers tcp_check
  and asserts /proc/self/fd doesn't grow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:36:53 -04:00
bvandeusenandClaude Opus 4.8 8af297670e feat(metrics): roll plugin_metrics up to hourly to bound storage
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 47s
CI / integration (push) Successful in 2m18s
CI / publish (push) Successful in 1m6s
plugin_metrics grows by (sources × resources × ~30s cadence); keeping 90d of raw
is a large table. Add a raw→hourly rollup (mirroring the Docker plugin) so only a
short raw window is kept at full resolution, with hourly averages archived longer.

- PluginMetricHourly model + core migration 0024 (plugin_metrics_hourly: avg/max/
  count per source/resource/metric/hour, unique bucket constraint + lookup index).
- steward/core/metrics_retention.rollup_plugin_metrics: date_trunc('hour') agg of
  raw older than the hour-aligned raw window, idempotent pg upsert into hourly,
  delete the rolled raw, prune hourly beyond the rollup window.
- cleanup.py: plugin_metrics is no longer blanket-deleted at data.retention_days;
  _run_metrics_retention drives the rollup with windows read live from settings.
- Settings: metrics.retention.raw_days (7) + rollup_days (90), tunable on the
  Thresholds & Retention page (new "Host metrics retention" card).
- Chart read: _history_for_host merges the hourly rollup (older part of the range)
  with raw date_bin (recent part, capped ≤1h), so 30d charts keep working —
  recent at full resolution, older at hourly. Route passes raw_days from settings.
- Tests: unit (cutoff helpers) + integration (rollup aggregates/prunes; history
  merges hourly + raw) against Postgres.

Speed was already handled by the indexes + SQL aggregation; this is the storage
lever (raw window ~10x smaller).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
2026-06-20 21:08:30 -04:00
bvandeusenandClaude Opus 4.8 c8b6719b37 feat(ui): plugin get_nav() hook → sidebar Infrastructure links (slice 2)
CI / unit (push) Successful in 46s
CI / integration (push) Successful in 2m20s
CI / lint (push) Successful in 2s
CI / publish (push) Successful in 1m16s
Plugin UIs had no nav home (only dashboard widgets / typed URLs). Add an
optional get_nav() plugin export and surface it in the sidebar.

- plugin_manager: _PLUGIN_NAV registry + get_plugin_nav() getter + a tolerant
  _collect_plugin_nav() (missing hook = fine; raising/malformed = logged &
  skipped; idempotent per plugin so hot-reload re-runs cleanly). Collected in
  both the startup load path and the hot-reload path.
- app.py: inject plugin_nav into the template context, filtered to enabled
  plugins so a hot-disabled plugin's link can't linger before restart.
- base.html: render plugin links under the Infrastructure group, with the same
  request.path active-state treatment as core links.
- docker/snmp/unifi/traefik: each exports get_nav() → its /plugins/<name>/ view.
- Tests: collector behavior (collect, missing hook, reload-replace, malformed
  item, raising hook, sorted output).

With docker enabled, "Docker" now appears under Infrastructure → its fleet view.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
2026-06-19 18:00:12 -04:00
bvandeusenandClaude Opus 4.8 0940dc6972 feat(crypto): fail loudly on unpersistable secret key; flag undecryptable secrets
CI / lint (push) Successful in 3s
CI / unit (push) Successful in 47s
CI / integration (push) Successful in 2m21s
CI / publish (push) Successful in 1m2s
Follow-up to the incident where an unwritable /data made Steward silently mint a
fresh ephemeral secret key on every boot, orphaning all encrypted secrets — only
discovered when an Ansible run failed. Make both failure modes loud and visible.

- config._resolve_secret_key: if a new key must be generated but can't be
  persisted (no STEWARD_SECRET_KEY, unwritable /data), raise RuntimeError and
  refuse to start, with an actionable fix (set STEWARD_SECRET_KEY, or make /data
  writable by uid 1000). Also raises a clear error if an existing key file can't
  be read. First-run on a writable volume still generates + persists normally.
- core.settings: detect secrets stored as ciphertext that won't decrypt with the
  current key (scan_undecryptable_secrets at startup; _is_undecryptable helper).
  Cached in memory; set_setting discards a key on a fresh write so the banner
  clears without a restart.
- app.py: run the scan after migrate_plaintext_secrets, log a warning, and inject
  undecryptable_secrets into the template context.
- base.html: admin banner naming which secrets to re-enter, each linked to its
  settings tab.
- compose.deploy.yml: document the uid-1000 /data write requirement and the
  STEWARD_SECRET_KEY option for multi-node Swarm.
- Tests: secret-key resolver (reuse existing file, generate when writable, raise
  when unpersistable) and the undecryptable-secret detection helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
2026-06-19 13:52:27 -04:00
bvandeusenandClaude Opus 4.8 35f658b573 feat(monitors): unify ping/dns/http into one Monitor entity + custom targets
CI / unit (push) Successful in 8s
CI / integration (push) Successful in 2m17s
CI / lint (push) Successful in 2s
CI / publish (push) Successful in 1m10s
Collapse the three former check types into a single core `Monitor` entity
with one management surface (/monitors), one result table (monitor_results),
and a single scheduled task. Every type can now watch a free-standing custom
destination (optional host_id) — not just a registered Host.

- models: Monitor + MonitorResult replace PingResult/DnsResult; Host loses its
  ping/dns facet columns (now Monitor rows linked by host_id).
- checks: monitors/{ping,dns,http}.py pure probes + runner.run_monitor
  dispatcher; one monitor_check scheduler with a per-monitor due-filter.
- status: single monitor_status_source replaces the three sources.
- UI: /monitors blueprint (type-aware add/edit/list/widget); host hub shows a
  host's linked monitors + "add monitor for this host"; nav + widget registry
  + alert metric catalog rewired. http plugin folded into core and removed.
- migration 0022 merges the http branch, data-migrates host facets +
  http_monitors + all three result histories, drops the old tables/columns.

Resolves the per-host ping/dns auto-attach issue (#275): monitors are now
explicit, never auto-added to every host.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
2026-06-18 08:56:13 -04:00
bvandeusenandClaude Opus 4.8 a92d1995d5 fix(ansible): write DB inventory as static YAML, not dynamic --list JSON
CI / lint (push) Successful in 3s
CI / unit (push) Successful in 8s
CI / integration (push) Successful in 2m20s
CI / publish (push) Successful in 1m6s
generate_inventory() emits Ansible's dynamic --list shape (all.hosts is a
LIST, vars under _meta) — valid only as an executable inventory script's
stdout. We were writing it to a static file and passing -i, so Ansible's yaml
plugin rejected it ("Invalid 'hosts' entry for 'all' group, requires a
dictionary, found ...list...") and fell back to implicit localhost → "no hosts
matched". Affected every steward:* scope run; surfaced on the first real
provision.

- New inventory_to_yaml(inv): convert the --list dict → a valid static YAML
  inventory (all.hosts dict keyed by host, groups under all.children, group
  vars preserved, injected per-host vars like steward_token retained).
- Wire it into runner.trigger_run, host_agent deploy + provision.
- executor writes the file as inventory.yml so the yaml plugin's extension
  check reliably claims it.
- generate_inventory unchanged (still the --list dict); conversion happens at
  write time. Unit tests added.

Scribe issue #885.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 09:28:31 -04:00
bvandeusenandClaude Opus 4.8 0318f6423f feat(ansible): host provisioning via steward managed SSH identity
CI / lint (push) Successful in 3s
CI / unit (push) Successful in 8s
CI / integration (push) Successful in 2m18s
CI / publish (push) Successful in 1m3s
Turn the agent-install playbook into a full provisioning + maintenance
path. Solves the bootstrap chicken-and-egg: first contact uses an
operator-supplied password (one run, never stored), which creates a
dedicated `steward` login account with NOPASSWD sudo + Steward's managed
public key. Every run thereafter connects as `steward` with the managed
key — fully unattended (scheduled prune, agent updates).

- core/crypto: generate_ssh_keypair() — ed25519, OpenSSH formats.
- settings: ansible.ssh_public_key (non-secret, displayed) + ansible.ssh_user
  (default steward); to_ansible_cfg extended.
- settings UI + route: "Generate managed key" (private encrypted, public
  shown to copy) + SSH-user field.
- executor: build_bootstrap() writes a 0600 vars file (-e @file) for the
  per-run user/password — never argv, never DB, never logged; drops the
  managed key when a bootstrap password is given; --user floor from the
  global ssh_user when no override.
- runner.trigger_run: pass-through `connection` kwarg, deliberately NOT
  persisted on AnsibleRun.params (password stays out of the DB).
- bundled/host_agent/provision.yml: create steward user + authorized_keys
  + /etc/sudoers.d/steward (visudo-validated) + agent install.
- host_agent: /provision route + "Provision a fresh host" card (bootstrap
  user/password; injects pubkey + user + token as space-safe JSON hostvars).
- Dockerfile: add sshpass (Ansible shells out to it for password SSH).
- tests: keypair generation + build_bootstrap (secret stays off argv).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:17:38 -04:00
bvandeusenandClaude Opus 4.8 6e91bdc82b feat(security): encrypt sensitive settings at rest (Fernet)
CI / unit (push) Successful in 7s
CI / integration (push) Successful in 2m17s
CI / lint (push) Successful in 2s
CI / publish (push) Successful in 45s
Secrets (smtp.password, oidc.client_secret, ldap.bind_password, ansible
ssh_private_key/become_password/vault_password) were stored plaintext in
app_settings. Add transparent encryption-at-rest:

- steward/core/crypto.py: Fernet keyed off the app secret (/data/secret.key),
  enc:v1: prefix marks ciphertext; passthrough for plaintext/empty/no-key,
  never reveals plaintext on a wrong key.
- settings.py: SECRET_KEYS registry; set_setting encrypts on write; all read
  paths (get_setting / get_all_settings / load_settings_sync) decrypt
  transparently; migrate_plaintext_secrets() converts legacy rows in place.
- app.py startup: init_crypto(SECRET_KEY) + one-time legacy-secret migration
  before settings load.
- Add cryptography dependency.

UI masking is unchanged (it checks decrypted truthiness). Key-loss caveat
documented: secrets are unrecoverable if the app secret key is lost. Unit
tests cover round-trip, empty/plaintext passthrough, and wrong-key safety.

Task #580.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:22:43 -04:00
bvandeusenandClaude Opus 4.8 88857be24e feat(ansible): runner robustness — cancel, concurrency, structured results, retention
CI / unit (push) Successful in 7s
CI / integration (push) Successful in 2m18s
CI / lint (push) Successful in 2s
CI / publish (push) Successful in 55s
Closes #550 (all four):

- Cancellation: track live subprocesses; POST /ansible/runs/<id>/cancel
  (operator) SIGTERMs then SIGKILLs after a grace; new 'cancelled' status
  (+ migration 0019, ALTER TYPE in autocommit). Queued runs cancel cleanly
  before launch. Cancel button on run detail.
- Concurrency: global semaphore (ansible.max_concurrent_runs, default 3,
  Settings→Ansible) caps simultaneous runs; excess show 'queued' (new status)
  until a slot frees. Semaphore bound lazily per running loop.
- Structured results: parse PLAY RECAP into per-host ok/changed/unreachable/
  failed/skipped + capture failed-task lines, stored in new results JSON
  column (migration 0020); rendered as a host-summary table on run detail.
  Keeps live streaming (no json-callback swap).
- Retention: full output written to a persistent log artifact
  (/data/ansible/runs/<id>.log, env-overridable) beyond the 1 MB DB cap and
  across restarts; in-memory replay buffer bounded + GC'd after completion;
  Download-log route. Boot reconciliation now also sweeps stale 'queued'.

Unit tests for recap parsing + cancel flagging. Status colors updated across
run list / detail / schedules.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:51:04 -04:00
bvandeusenandClaude Opus 4.8 c10eae1c74 feat(ansible): in-app playbook authoring/editor
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 7s
CI / integration (push) Successful in 2m17s
CI / publish (push) Successful in 1m6s
Adds create/edit/delete of playbooks from the UI (admin only), so a homelab
user without a git workflow can author automation in-app. A new always-present
writable local source "steward-local" (/data/ansible/playbooks, env-overridable,
created on first save) is editable alongside operator local-dir sources; the
bundled and git sources stay read-only (git is GitOps, clobbered on pull).

sources.py: write_playbook / delete_playbook (traversal-guarded, .yml/.yaml
only) + validate_playbook_yaml (YAML + play-list check) + is_editable_source.
routes.py: /playbooks/new, /edit, /save, /delete (admin). Browse gains a
"New playbook" button and per-playbook + view-page Edit/Delete for editable
sources. Plain textarea editor with save-time YAML validation. Unit tests for
write/delete/guard/validate.

Task #579 — completes milestone #37 (Ansible automation).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 11:39:47 -04:00
bvandeusenandClaude Opus 4.8 f3e919892d feat(plugins): plugin capability registry + host_agent→Ansible deploy synergy
CI / unit (push) Successful in 7s
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 2m18s
CI / publish (push) Successful in 1m10s
Implements #253's framework: a small core capability registry
(steward/core/capabilities.py) where a module/plugin publishes a named,
role-gated action and a consumer discovers it via has_capability() and runs it
via invoke_capability() — no hard import, graceful degradation, permission
propagation (actor role checked against the capability's required_role).

Core publishes "ansible.run_playbook" (operator) wrapping ansible.runner.
trigger_run (extended to accept a caller-built inventory). First consumer: the
host_agent plugin gains "Deploy via Ansible" on its settings page — pick an
inventory target/group and it installs/updates the agent via the bundled
host_agent/install.yml, minting a fresh token per host and injecting it as an
inventory hostvar (turning per-host curl|sh into one run). Exposed role
ordering as middleware.role_meets. Unit tests for the registry + role checks.

Task #253 (milestone #37).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 11:28:29 -04:00
bvandeusenandClaude Opus 4.8 656bda2e3d feat(ansible): bundled first-party playbooks + built-in source
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 7s
CI / integration (push) Successful in 2m16s
CI / publish (push) Successful in 55s
Ships a read-only "steward-builtin" source (steward/ansible/bundled/) that
always appears alongside operator-configured sources, with two playbooks:
- maintenance/docker_prune.yml — docker system prune for swarm/standalone
  nodes (safe by default; prune_all_images / prune_volumes extra-vars to
  widen). Schedule it against a swarm-node group for recurring cleanup (#869).
- host_agent/install.yml — installs/updates the host agent (mirrors
  install.sh: user, agent.py, config, hardened systemd unit), parameterised
  with steward_url + steward_token extra-vars.

get_sources() now prepends the builtin source. Tests updated to find the
configured git source by name; added coverage that the bundled playbooks are
discoverable.

Tasks #869 + agent-install (milestone #37).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 11:07:34 -04:00
bvandeusenandClaude Opus 4.8 3b6e005ed8 feat(status): unified Status page + widget across ping/DNS/HTTP monitors
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 7s
CI / integration (push) Successful in 2m15s
CI / publish (push) Successful in 56s
Adds a Kuma-style "is everything up?" surface that aggregates heterogeneous
monitor types via a status-source registry (steward/core/status.py): each
type registers an async source(db) -> [StatusEntry]. Core registers ping/DNS;
the http plugin registers its own from setup() so core never imports plugin
tables. Per entry: current up/down, last-30 heartbeat bar, uptime %
(24h/7d/30d), latest latency + response sparkline, and TLS expiry countdown
(HTTP). New /status page (live htmx refresh) + a status_overview dashboard
widget + nav link. Pure-function unit tests for registry + sparkline.

First deliverable of milestone #68 (task #866).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:18:29 -04:00
bvandeusenandClaude Opus 4.8 b49496b57b feat(plugins): default-enable generic bundled plugins on fresh install
CI / unit (push) Successful in 7s
CI / integration (push) Failing after 2m16s
CI / publish (push) Has been skipped
CI / lint (push) Successful in 2s
A fresh install enabled zero plugins — settings.py DEFAULTS had no plugin.*
keys, so to_plugins_cfg returned {} and every plugin had to be flipped on
by hand. Seed docker, host_agent, http, snmp as default-on (generic, non-
vendor-specific); traefik and unifi stay opt-in. Stored choices override
the default, so disabling persists.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 21:24:21 -04:00
bvandeusenandClaude Sonnet 4.6 80613d6310 chore(lint): remove unused imports flagged by ruff
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 18:55:21 -04:00
bvandeusen d906232eb2 feat(ansible): GIT_ASKPASS per-source HTTP token auth + settings UI 2026-06-05 18:48:00 -04:00
bvandeusen ea47169049 feat(ansible): generate_inventory() + fetch_scope_targets() + unit tests 2026-06-05 18:46:22 -04:00
bvandeusenandClaude Opus 4.7 88ab5b917e chore: rename project Roundtable → Steward
Renames the Python package directory, CLI command, env var prefix,
docker-compose service/container/image, Postgres role/db, and all
visible branding. Marketing form is "Fabled Steward".

Clean break from the previous rebrand: drops the fabledscryer→roundtable
import shim in __init__.py and the FABLEDSCRYER_* env var fallback in
config.py and migrations/env.py. Env vars are now STEWARD_* only.

Heads-up for existing deployments:
- Postgres user/db renamed fabledscryer → steward in docker-compose.yml.
  Existing volumes need the role/db renamed inside Postgres, or override
  POSTGRES_USER/POSTGRES_DB to keep the old names.
- Host-agent systemd unit is now steward-agent.service. Existing agents
  keep running under the old name; reinstall to switch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 16:20:14 -04:00
bvandeusen 3a3d094a2a test(settings): failing tests for public_base_url helper 2026-04-15 20:29:28 -04:00