31 Commits

Author SHA1 Message Date
bvandeusen 524fd1f509 feat(ansible): default source/playbook dropdowns to the first item
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 46s
CI / integration (push) Successful in 2m18s
CI / publish (push) Successful in 1m0s
The schedule and host run forms opened their Source/Playbook <select>s on a
"— choose … —" placeholder, forcing an extra click. Default them to the first
item and cascade the dependent fields so the initial state is real:

- Remove the placeholder options (schedules source+playbook, host source+
  playbook, and the shared _playbook_options fragment).
- schedules(): pass sel_source (first source, or the edited schedule's) and
  that source's pre-rendered playbooks so the playbook dropdown starts populated
  (create mode too, no longer the all-sources union).
- Cascade on load/change: the source <select> fires playbook-options then
  (hx-on::after-settle) re-triggers the playbook <select>, which loads that
  playbook's variable fields. Host form drives the whole chain from a load
  trigger; schedule create mode loads vars via the playbook <select>'s own load
  trigger, while edit mode keeps its server-rendered prefill untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 23:35:01 -04:00
bvandeusen efc11c1837 feat(ansible): structured playbook-variable fields on the schedule form
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 45s
CI / integration (push) Successful in 2m20s
CI / publish (push) Successful in 1m14s
The Ansible Schedules form only offered a free-form extra-vars textarea,
unlike the browse/host run forms which auto-populate a field per declared
playbook variable (vars:/vars_prompt:) plus the playbook description. Wire
the schedule form to the same shared `_playbook_vars.html` partial:

- Playbook <select> now loads /ansible/playbook-vars?schedule=1 on change
  into a #sch-playbook-vars container (mirrors the host detail form).
- Edit mode pre-renders the saved playbook's variables server-side with
  their stored values; the extra-vars textarea keeps only leftover
  (non-declared) vars.
- Partial gains optional `values` (prefill) and `schedule` flags. In the
  schedule context secret vars render disabled ("secrets can't be
  scheduled") since they're dropped downstream, and the destructive-confirm
  gate is shown but not required.

Backend already parsed var__* fields for schedules, so this is the
front-end/partial parity fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 23:03:54 -04:00
bvandeusen 7b80552a7d feat(docker): per-host collection via the host agent; drop central scrape
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 2m16s
CI / unit (push) Successful in 4m28s
CI / publish (push) Successful in 1m4s
Docker collection moves off the central single-socket scrape onto the host
agent, giving Docker a real per-host dimension. The Steward host now reports
its own containers like any other host, and same-named containers on different
hosts no longer collide.

- agent: stdlib UDS Docker client (AF_UNIX HTTP/1.1, Connection: close,
  chunked-aware), collect_docker() ports the cpu%/mem math; sample["docker"]
  added best-effort (silent-skip on absent/unreadable socket). AGENT_VERSION
  1.2.0 → 1.3.0; optional docker_socket config key.
- ingest: host_agent ingest hands per-host container snapshots to the docker
  plugin via a new "docker.persist_host_samples" capability (no hard import,
  no-op when docker disabled), inside a SAVEPOINT so a docker failure never
  sinks the host metrics. Resource names are host-scoped ("<host>/<name>").
- schema: docker_containers re-keyed (host_id, name); docker_metrics gains
  host_id; docker_002 migration DROP+recreates (dev-only, rule 122).
- ui: Docker page + widgets grouped by host with host links; new per-host
  Docker panel embedded on the Hosts hub (gated on docker enabled via a new
  enabled_plugins template context). Replaces the SQLite-only strftime
  bucketing with DB-agnostic Python bucketing.
- provisioning: install/provision playbooks add steward-agent to the docker
  group (best-effort) so the agent can read the socket.
- removed central scrape: docker scheduler.py + scraper.py deleted; plugin.yaml
  socket_path/scrape_interval_seconds/include_stopped dropped (plugin 2.0.0).
- tests: agent docker collector units (math, chunked decode, silent-skip,
  sample shape, config) + integration (host-scoped schema + persistence).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
2026-06-18 17:54:36 -04:00
bvandeusen 2ea8c2f9af feat(ansible): bundled system_update maintenance playbook
CI / lint (push) Successful in 3s
CI / unit (push) Successful in 8s
CI / integration (push) Successful in 2m16s
CI / publish (push) Successful in 55s
New maintenance/system_update.yml: cross-distro OS package upgrade (apt +
dnf/yum) with two run-form flags:
  - restart_services: restart every service that needs it after the upgrade
    (Debian via needrestart -r a, installed if missing; RHEL via
    needs-restarting -s → systemctl try-restart).
  - reboot_if_required: reboot the host only when a reboot is actually pending
    (Debian /var/run/reboot-required; RHEL needs-restarting -r). Never reboots
    otherwise.

Tagged steward:category=maintenance and steward:confirm=true (significant /
reboot-capable), so it shows in the run UI with the confirm gate and both flags
as fill-in fields. No code changes — auto-discovered from the builtin source.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 14:15:33 -04:00
bvandeusen 2594ca517d fix(ansible): clear error when the managed SSH key can't be decrypted
CI / lint (push) Successful in 3s
CI / unit (push) Successful in 6s
CI / integration (push) Successful in 2m17s
CI / publish (push) Successful in 54s
When ansible.ssh_private_key can't be decrypted (app secret key changed),
decrypt_secret returns the ciphertext unchanged; the executor was writing that
enc:v1: blob as the SSH key file → cryptic "Load key ...: error in libcrypto"
→ Permission denied. Now:
- build_credentials skips a still-encrypted key value (never writes ciphertext
  as a key file).
- start_run broadcasts a plain-language run error: "The managed SSH key could
  not be decrypted (the app secret key changed). Regenerate it in Settings →
  Ansible and re-provision the host(s)."

The run still fails (no usable key), but the reason is now obvious instead of a
libcrypto error. Operator remedy: regenerate the managed key + re-provision.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 12:52:24 -04:00
bvandeusen e0253fba48 fix(ansible): surface run failure reason; widget audit cleanup
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 7s
CI / integration (push) Successful in 2m17s
CI / publish (push) Successful in 1m1s
Run UX: when start_run throws before/around launch (e.g. ENOSPC creating the
temp dir — the box is out of disk), the run was marked "failed" with empty
output. Now the exception is broadcast + written to the run output/results so
the run view shows e.g. "[run error] OSError: [Errno 28] No space left on
device" instead of a blank failure.

Widget audit follow-ups (no broken links were found; these are consistency):
- host_resource_history widget now charts root (/) disk, consistent with the
  host panel (was the opaque "disk worst").
- host_resources widget: tooltip on the health dot explaining it warns on the
  worst mount while the number shows root.
- status_overview widget detail_url /status → /status/ (avoid redirect).
- Normalize ad-hoc widget empty-states to the shared .empty style (wording,
  which distinguishes "configured" vs "data yet", preserved).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 12:46:08 -04:00
bvandeusen 42f7840c26 feat(ansible): steward:category + steward:confirm playbook metadata
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 8s
CI / integration (push) Successful in 2m14s
CI / publish (push) Successful in 59s
Extend the playbook metadata convention with a namespaced `# steward:<key>:`
comment block:

- steward:category — free-text grouping label, shown as a badge in the browse
  list and on the run form.
- steward:confirm — true/yes/1/on marks a playbook destructive; the run form
  then requires a confirmation tick (required checkbox in the shared vars
  fragment) before it can launch.

sources.discover_playbook_meta() parses description + category + confirm (first
match per key; `# description:` still primary, `# steward:description:` alias).
discover_playbook_description() now delegates to it. The browse list reads
per-playbook meta to show category badges + descriptions; the run-form and
playbook-vars fragments render the badge + confirm gate.

Bundled playbooks tagged: docker_prune → category maintenance + confirm true;
provision/install/update → category host-agent.

Docs: docs/reference/playbook-authoring.md updated (keys now implemented) and a
quick reference added next to the code at steward/ansible/PLAYBOOK_CONVENTIONS.md.
Tests added for category/confirm/alias parsing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 11:35:35 -04:00
bvandeusen e5f6a11f94 feat(ansible): playbooks self-describe via "# description:" comment
CI / lint (push) Successful in 3s
CI / unit (push) Successful in 8s
CI / integration (push) Successful in 2m15s
CI / publish (push) Successful in 52s
Playbooks can ship a human description Steward reads and shows when one is
selected. Convention: a `# description: <text>` magic comment (Ansible rejects
unknown play keys, so a comment is the portable place — works for third-party
playbooks too); falls back to the first play's name:. sources
.discover_playbook_description().

Surfaced at the top of the shared _playbook_vars.html partial, which loads on
playbook selection in the host run form, schedules form, and browse run form.
All four bundled playbooks (provision/install/update/docker_prune) now carry a
description line. Unit tests added.

Scribe #900.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 11:25:25 -04:00
bvandeusen 7e6e63521b fix(host_agent): provision/deploy vars shadowed by play-var precedence
CI / lint (push) Successful in 3s
CI / unit (push) Successful in 8s
CI / integration (push) Successful in 2m15s
CI / publish (push) Successful in 54s
The assertion failed ("Pass steward_url/token/pubkey") because those were
injected as inventory HOST vars, but the playbooks declared them in the play
`vars:` block — and play vars OUTRANK inventory host vars, so the empty
defaults won and the injected values never reached the play.

- Pass globals (steward_url, steward_pubkey, steward_user, agent_interval) as
  extra-vars via the JSON -e @file (highest precedence, space-safe). Keep only
  the per-host steward_token as an inventory host var.
- provision.yml / install.yml: drop steward_token from `vars:` so the host var
  isn't shadowed; assertions use `| default('')` for the un-defaulted token.

This was the next layer under the inventory-format fix — first the inventory
wouldn't parse, now the injected vars actually apply.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 10:11:30 -04:00
bvandeusen ad726e65f3 feat(ansible): dropdown playbook selection + auto-populated variable fields
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 8s
CI / integration (push) Successful in 2m17s
CI / publish (push) Successful in 43s
Stop making operators type playbook paths and guess extra-var names.

- Reusable infra: shared ansible/_playbook_vars.html (the discovered-variable
  fields) + ansible/_playbook_options.html; two HTMX endpoints —
  /ansible/playbook-options (a source's playbooks, optional ?selected for edit)
  and /ansible/playbook-vars (a playbook's vars:/vars_prompt: as fill-in
  fields). browse _run_form.html refactored to include the shared partial.
- Host "Run a playbook against this host": source dropdown → playbook dropdown
  → variable fields, all chained via HTMX. Handler reuses _parse_run_params so
  var__/secret__ fields flow through extra_vars_map + the unpersisted
  secret_vars channel.
- Schedules: playbook free-text+datalist → source-dependent dropdown; fixed the
  extra-vars edit pre-fill to read extra_vars_map (stale list key after the
  earlier JSON-extra-vars change).

Scribe #895.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 10:02:15 -04:00
bvandeusen bb90411f00 fix(host_agent): gate "deployed" on agent check-in; fail no-op runs
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 2m18s
CI / publish (push) Successful in 58s
A failed provision looked successful in two ways:

1. The host panel showed the agent as deployed (metrics + Update/Rotate/Remove)
   because provision/deploy mint the registration row BEFORE the playbook runs
   and the panel keyed "installed" on that row. Now gated on the agent actually
   checking in (reg.last_seen_at). Three states: reporting (metrics + lifecycle),
   pending (token minted but no check-in → "no metrics yet, deploy may be
   running/failed" banner + retry + Clear pending registration), and none
   (install path).

2. The run reported success though nothing ran — ansible-playbook exits 0 on
   "no hosts matched"/empty inventory. The executor now treats an empty PLAY
   RECAP (returncode 0 but no hosts executed) as failed, with a clear failure
   note. Non-zero exits and recap failed/unreachable were already caught.

Scribe issue #887.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 09:50:11 -04:00
bvandeusen 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
bvandeusen 6a8146b544 fix(ansible): replace steward key on reprovision + distinct agent update path
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 7s
CI / integration (push) Successful in 2m19s
CI / publish (push) Successful in 54s
Provisioning review corrections + the matching frontend, plus breadcrumb
header integration.

- provision.yml: authorize the managed pubkey with a regexp match on the
  ' steward-managed' comment so rotating the key REPLACES the host's steward
  key in place instead of stacking a second authorized entry. Hand-added keys
  (other comments) are untouched.
- update.yml (new): refresh agent.py + restart only. Does NOT rotate the token
  or rewrite /etc/steward-agent.conf — the host keeps its identity. Asserts the
  agent is already installed and fails clearly otherwise.
- host_agent /update route: runs update.yml as the managed steward user (no
  token minting). Token rotation stays a deliberate action.
- settings/ansible/generate-key honors a safe relative `next` redirect, so an
  inline trigger elsewhere returns to its page.
- Host Agents settings: reworked into a clear lifecycle — an intro card that
  explains it runs Ansible to deploy the agent (+ inline "generate managed key"
  warning/trigger when none exists), then three labelled cards: 1 Provision,
  2 Install/enroll, 3 Update. Each explains what it does.
- base.html: breadcrumb now renders as a kicker line directly above the page
  title (moved below alerts, tightened margin) so nested and top-level views
  share one consistent header.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 19:22:12 -04:00
bvandeusen a996cc6908 feat(ansible): per-variable fields in the playbook run form
CI / lint (push) Successful in 9s
CI / unit (push) Successful in 14s
CI / integration (push) Successful in 2m19s
CI / publish (push) Successful in 55s
When you click Run, Steward now parses the playbook and renders a field
for each declared variable instead of a blank extra-vars textarea. The
form loads on demand via HTMX (/ansible/run-form/<source>/<playbook>).

- sources.discover_playbook_variables: parse vars: defaults + vars_prompt:
  (vars_prompt wins on name collision; non-scalar vars skipped; role/include
  vars not traversed). Flags secret-looking names + vars_prompt private.
- Run-time values flow through a JSON extra-vars file (-e @file), which is
  space/quote-safe — fixes a latent shlex-split bug in the old -e key=value
  textarea path. executor.build_extra_vars_file (pure) + start_run merge.
- Secret-flagged fields are masked AND routed through an unpersisted
  secret_vars channel (runner.trigger_run → start_run), so passwords entered
  at run time never land in the DB / run history.
- Defaults shown as placeholders (not prefilled): an untouched field falls
  through to the inventory/play default instead of overriding it.
- routes: run_form HTMX endpoint; _parse_run_params now returns
  (params, secret_vars, err) and reads var__/secret__ fields. Schedules drop
  secret vars (can't prompt unattended).
- templates: ansible/_run_form.html fragment; browse.html rewired to HTMX,
  static JS run-form removed. Advanced section keeps limit/tags/check + a
  free-form extra-vars escape hatch.
- tests: test_playbook_variables.py (discovery + extra-vars file).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:54:01 -04:00
bvandeusen 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
bvandeusen 88857be24e feat(ansible): runner robustness — cancel, concurrency, structured results, retention
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 7s
CI / integration (push) Successful in 2m18s
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
bvandeusen 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
bvandeusen f3e919892d feat(plugins): plugin capability registry + host_agent→Ansible deploy synergy
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 7s
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
bvandeusen 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
bvandeusen 4a0a3ee46e feat(ansible): scheduled recurring playbook runs
CI / lint (push) Successful in 3s
CI / unit (push) Successful in 7s
CI / integration (push) Successful in 2m20s
CI / publish (push) Successful in 49s
Adds cron-like recurring runs (the engine the maintenance-automation work
needs). New AnsibleSchedule model + migration 0018; a core ScheduledTask
(ansible_scheduled_runs, 60s) fires due schedules, each creating a
system-triggered AnsibleRun (triggered_by=None). Centralises the
resolve-inventory → create-run → launch flow in ansible/runner.trigger_run,
shared by the manual route (refactored to use it) and the scheduler.

Schedules UI under /ansible/schedules: create/edit/pause/delete/run-now,
with interval presets, scope targeting (all / group / target), extra-vars /
limit / tags / dry-run, and last-run status (resolved via last_run_id) +
next-run. Unit test for the due-check.

Task #549 (milestone #37).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 11:04:02 -04:00
bvandeusen 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 935cbc105c feat(ansible): inventory groups CRUD routes + templates + blueprint registration 2026-06-05 18:50:09 -04:00
bvandeusen 2c991eabd2 feat(ansible): scope picker in run form + create_run() DB inventory generation 2026-06-05 18:49:02 -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
bvandeusen 38f61b71c1 feat(ansible): run a playbook against a single Steward host (task 547)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 8s
CI / integration (push) Successful in 2m19s
From a host's edit page, an operator can run a playbook against just that host
via an ephemeral one-host inventory — no need for the host to exist in a source
inventory.

- ansible/sources.py: pure host_inventory_content(host) -> '<name> ansible_host=<addr>'
- executor.start_run: optional inventory_content written to the temp dir and used
  as -i (overrides inventory_path); cleaned up with the creds dir
- hosts/routes.py: POST /hosts/<id>/run-playbook (operator) — validates source +
  playbook, builds the ephemeral inventory, starts a user-triggered run, redirects
  to the live run detail; edit page gets the ansible source list
- hosts/form.html: 'Run Ansible playbook against this host' panel (shown when
  editing an existing host and sources exist) — source + playbook + extra-vars/
  tags/dry-run (no --limit; single host)
- tests: unit host_inventory_content; integration runs a hosts:all/connection:local
  playbook against the ephemeral inventory and asserts inventory_hostname

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 19:26:58 -04:00
bvandeusen 0bf007173b feat(ansible): credentials — SSH key, become, vault, host-key checking (task 548)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 7s
CI / integration (push) Successful in 2m24s
Global Ansible credentials, applied to every run (manual + alert-triggered):
- core/settings.py: ansible.ssh_private_key / become_password / vault_password
  (plaintext at rest, masked in UI — encryption tracked in #580) + host_key_checking
  (default off); surfaced via to_ansible_cfg into app.config[ANSIBLE]
- executor.py: pure build_credentials() materializes creds into a 0600 temp dir
  (--private-key, --vault-password-file, become via -e @vars-file so the password
  never hits argv) cleaned up in finally; pure ansible_env() sets
  ANSIBLE_HOST_KEY_CHECKING. build_ansible_command stays param-only
- settings/routes.py + ansible.html: admin-only Credentials section, masked-update
  (blank keeps current, explicit Clear checkbox), reload app config on save
- tests: unit (build_credentials per cred + none; ansible_env toggle); integration
  vault round-trip (ansible-vault encrypt a vars file, run via executor with the
  vault password, assert it decrypted)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 18:41:58 -04:00
bvandeusen 8b62eb2ca3 feat(ansible): parameterized runs — extra-vars, limit, tags, dry-run (task 546)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 7s
CI / integration (push) Successful in 2m16s
Run form + executor now support optional params, passed as argv (never
shell-interpolated):
- extra_vars: one key=value per line -> repeated -e
- limit -> --limit; tags -> --tags
- dry-run checkbox -> --check --diff

- executor.py: pure build_ansible_command(playbook, inventory, params); start_run
  gains an optional params arg (backward-compatible)
- models/ansible.py + migration 0013: nullable params JSON column
- routes.py: create_run parses + validates (extra-var lines need '='), stores
  params on the run, passes to the executor
- browse.html run form: Extra vars / Limit / Tags / Dry-run fields
- run_detail.html: shows the params used
- tests/test_ansible_command.py: unit coverage of the builder; integration test
  runs a real playbook with extra-var + limit + check and asserts substitution

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 13:31:16 -04:00
bvandeusen fb579bcf97 feat(ansible): ship ansible in image + allow system-triggered runs (task 545)
CI / lint (push) Successful in 4s
CI / unit (push) Successful in 9s
CI / integration (push) Failing after 2m22s
Foundation for the Ansible automation milestone (#37) — makes the existing
manual playbook runner actually executable and the schema automation-ready.

- pyproject: [ansible] extra (full ansible package, batteries-included, pinned)
- Dockerfile: pip install .[ansible]; add openssh-client for remote runs
- models/ansible.py + migration 0012: AnsibleRun.triggered_by now nullable so
  automated (alert/schedule) runs need no human actor
- ansible/routes.py + run_detail.html: show 'Triggered by' (username or 'system')
- CI: integration lane installs .[dev,ansible]; new tests/integration/
  test_ansible_foundation.py runs a real connection:local playbook end-to-end,
  asserts success+output, and round-trips a NULL-triggered run

No extra-vars/limit/credentials/scheduling here — those are their own #37 tasks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 11:31:43 -04:00
bvandeusen af60ca446d fix(plugins): complete steward rename across bundled plugins; clear lint debt
CI / lint (push) Successful in 3s
CI / unit (push) Successful in 7s
CI / integration (push) Successful in 2m8s
The fabledscryer->steward rename had only ever reached host_agent. The other
five bundled plugins (http, snmp, traefik, unifi, docker) still imported
`from fabledscryer.*` (package no longer exists) and read FABLEDSCRYER_* env
vars — so every one of them was broken at import since the original rebrand.
CI stayed green only because none are enabled by default and migrations don't
import plugin modules. Now that they version in-tree, complete the rename:
- fabledscryer.* -> steward.* imports across all five plugins
- FABLEDSCRYER_* -> STEWARD_* in plugin migration env.py files
- author/repository/homepage + user-facing 'Fabled Scryer' strings -> Steward
- snmp/scheduler.py: also drop dead `now`/datetime; record_metric from steward

Adds tests/test_no_legacy_names.py — fails if 'scryer'/'roundtable' ever
reappear in shipped code (the drift bit twice; this stops a third time).

Also clears pre-existing ruff lint debt (unused imports, semicolon statements,
mid-file import) surfaced by the new lint lane.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 11:22:20 -04:00
bvandeusen 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