This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Core functionality is implemented and tested: settings, role/playbook discovery, the
inventory builder, JobManager/ansible-runner integration, and the full FastAPI app (routes +
templates). Not yet done: the Dockerfile/Docker Hub publishing described under "Distribution".
Update this file as the remaining pieces land and decisions evolve — do not let it drift out of
sync with the actual repo.
A small web UI for running Ansible roles against a single target host on demand:
- It scans a configured directory (default
/opt/ansible/roles) for Ansible roles — e.g.<roles_path>/docker-host/tasks/main.ymlis discovered as a role nameddocker-host. - The user checks one or more roles in the UI, either individually or by clicking a playbook
— a named preset (e.g. "LAMP") that checks a predefined set of roles (e.g.
apache,mysql,php) in one click. Playbook clicks only add checks; the user can still uncheck individual roles or combine/stack multiple playbooks and manual picks before applying. A role can also declare its own fillable variables (name, type, optional default, whether it's required) via a standardmeta/argument_specs.yml— checking that role surfaces them in the Deploy column's Variables area, any declared default autofilled (see "Role variables (argument_specs)" below). - The user fills in target connection details: IP address, port, username, password. There is no separate Linux/Windows picker — the OS is implied by which port preset is chosen (see "Backend & UI" below) — and username/password fields are pre-filled from configured per-preset defaults but remain editable before submitting. A status dot shows a quick reachability check whenever the IP address or Port field loses focus, or a new preset is picked.
- Clicking "Apply" launches an Ansible run (via
ansible-runner) that applies the selected roles to that single target host, authenticating over SSH (Linux, viasshpass), WinRM, or PSRP (the latter two both Windows), depending on the chosen preset. - The user watches a live log of the run in the browser.
Runs are tracked as jobs; multiple runs can execute concurrently, and run history (metadata + logs) is persisted so past runs can be reviewed later.
- FastAPI is the web framework. Pages and fragments are server-rendered with Jinja2
templates; HTMX drives most interactivity declaratively (refreshing the role/playbook
lists, the Cancel button) without a separate frontend build/JS framework. Two interactions —
submitting the apply form and opening a run from History — go through plain
fetch()instead of htmx'shx-post/hx-get, because both need to relocate the server's response into a dynamically-created run tab (see "Live logs" below) rather than swap it into a fixed target; letting htmx swap into a throwaway element first, only to relocate its content afterwards, would mean htmx briefly initializes that throwaway element for real (in particular opening a live SSE connection) before it's discarded. Every URL the browser is ever asked to hit --base.html's stylesheet/script tags, everyhx-get/hx-post, and every JS-builtfetch()/EventSource()URL inindex.html-- is written relative, with no leading slash (static/style.css,runs,`runs/${runId}/stream`, etc.), never root-absolute (/static/style.css). This is what lets the app work when reverse-proxied at an arbitrary, request-invisible subpath -- e.g. Coder OSS's path-based workspace-app proxying (https://coder.example.com/@user/workspace.agent/apps/ansiblaster/) -- which strips its own prefix before forwarding to the backend and sends noX-Forwarded-Prefix-style header either, so the backend has no way to learn its external prefix from the request it receives; only the browser's own address bar knows it. Root-absolute URLs resolve against the domain root and so silently skip straight past that prefix (fetching a different app's, or Coder's own,/static/style.css); relative URLs resolve against the current document's own address -- whatever the browser is actually looking at, prefix included -- which is why this only works correctly when that address ends in a trailing slash:https://.../apps/ansiblaster(no trailing slash) resolvesstatic/style.cssone directory too high (.../apps/static/style.css-- a different app's, or nothing at all), while.../apps/ansiblaster/resolves it correctly. Path-based proxies commonly don't add that trailing slash on their own, and -- same root cause as the paragraph above -- the backend can't detect or fix a missing one itself, since every request it receives looks identical either way once the proxy's prefix is stripped. Sobase.htmlself-heals this client-side: the very first thing in<head>, before the stylesheet/htmx<script src>tags, is a small blocking inline<script>that redirects (window.location.replace(), so it doesn't leave a broken history entry) towindow.location.pathname + "/"wheneverpathnamedoesn't already end in/-- a no-op at the plain, non-proxied root (GET /). It has to be the first thing (before any relative<link>/<script src>), since a browser's preload scanner can speculatively dispatch those fetches (against the still-wrong base) while the parser is still working through the rest of the document, even though the redirect script itself executes and callsreplace()synchronously before the parser ever reaches them -- so a request or two against the wrong base can still appear (and 404) in the network log for an instant before the corrected page takes over, which is harmless (the corrected page loads right behind it) but expected if you see it.base_urlvalues baked server-side intopartials/file_browser.html(fromroutes/roles.py/routes/playbooks.py) follow the same relative-URL rule (f"roles/{name}", notf"/roles/{name}"). Since the backend itself is never asked to serve anything at a prefixed path (Coder strips it before proxying), none of this needs any server-sideroot_pathhandling -- no uvicorn--root-path, noX-Forwarded-*middleware -- the fix is entirely in how URLs are written and, for the trailing slash specifically, a client-side redirect, not anything resolved or configured server-side. Keep any new URL added to a template or toindex.html's JS on this same relative convention. - The role checklist is built by scanning the configured roles directory at request time (or on
a refresh action) — a directory is treated as a role if it looks like a standard Ansible role
(contains
tasks/main.yml, etc.), not by any hardcoded list. - UI design: a fixed-viewport, three-column IDE layout (Playbooks / Roles / Deploy) below a
thin title bar, styled after VS Code with the Dracula color palette (
style.css's:rootcustom properties) — always dark, no light-mode variant. Each column manages its own internal scroll (overflow-y: autowithmin-height: 0on the flex chain) rather than the page itself scrolling. Playbooks and Roles each have a client-side fuzzy filter at the top (VS Code command-palette style: query characters must appear in order, not contiguously — seefuzzyMatch()inindex.html) that filters the already-rendered list with no server round trip per keystroke, and re-applies itself after a list is refreshed. Below the Deploy column's header, a small, fixed-height (not flex-grow) read-only reflection of whichever role checkboxes are currently checked (syncSelectedRolesSummary(), delegated offchangeevents so it survives a role-list refresh) — unchecking happens back in the Roles column, not in this summary, though each entry also gets its own eyeball button (same as Roles/Playbooks, see "Viewer tab" below) so a selected role's files can be checked without leaving the Deploy column. A drag handle (#deploy-resizer,setupDeployResizer()inindex.html) sits right below it — the same technique as the bottom log panel's own#panel-resizer(see "Live logs" below), just measured from the summary's top edge instead of the viewport's bottom, since this one grows downward from a fixed top rather than upward from a fixed bottom. Below that is the target form (.deploy-target, nowflex: 1 1 auto— the one that actually grows to fill whatever the small fixed-height summary above doesn't use), which is a single scrolling area (overflow-y: autoon.deploy-targetitself, nothing scrolling independently inside it) listing one<fieldset class="deploy-vars-role">after another: first a static "Host" fieldset (IP/port, then username/password), then one more such fieldset per checked role that declares variables viameta/argument_specs.yml(see "Role variables (argument_specs)" below) — the Variables area (.deploy-vars/#deploy-vars-summary), whichsyncRoleVariables()rebuilds wholesale on every selection change and appends directly after the Host fieldset, both using the same fieldset+legend styling so the whole list reads as one consistent set of sections rather than a visually distinct "fixed fields" part and "Variables" part.syncRoleVariables()is called from the same delegatedchangelistener plus every other placesyncSelectedRolesSummary()is called —applyPlaybook(), the page's initial load, andloadRunIntoDeploy(). Unlike the role checkboxes (which live in a different column and need theform="apply-form"attribute trick), the Variables area's inputs are built directly inside#apply-form's own DOM subtree, soFormData(applyForm)picks them up with no extra wiring. There is no visible Linux/Windows picker in the target form: a small "SSH"/"WinRM"/ "WinRM (Secure)"/"PSRP"/"PSRP (Secure)"<select>is merged with the port number input into one bordered control (.port-field-groupinstyle.css— the select and input themselves are borderless/transparent so only the group's shared border shows, making them read as one field rather than two) — picking a preset writes the OS+connection it implies into a hiddentarget_osinput and fills the port input from the selected<option>'s own value, not a per-OS lookup table (applyPortPreset()inindex.html): WinRM and WinRM (Secure) share an OS/connection (so a per-OS port lookup can't tell them apart) but need different actual ports (5985 vs 5986), same for the two PSRP options. The port stays freely editable afterward (e.g. a non-standard SSH port). That hiddentarget_osfield is the only thing actually submitted for OS, soPOST /runsand everything downstream of it (inventory building,become, etc.) are unchanged by any of this. WinRM and PSRP are two different Ansible connection plugins/Python libraries (pywinrm/pypsrp) that both talk to the same Windows WinRM listener (secure or not), so they get their ownTargetOSmembers (WINDOWS/WINDOWS_PSRP) but share default ports and are otherwise treated identically everywhere exceptinventory.py(see "Ansible execution" below). A dedicated Status row ("Status: ⟳ ⬤ ") sits at the bottom of the column, outside.deploy-target's scrollable area, with the Apply button on the same row, pushed to its right edge (margin-left: autoon.apply-buttonitself rather than a separate wrapper) — the Status row shows a quick reachability check (checkTargetPort()/resetPortStatusDot()inindex.html, callingGET /target/check-port— seeportcheck.py) run whenever the IP address field or the Port field loses focus, or a port preset is picked (applyPortPreset()callscheckTargetPort()directly rather than just resetting the dot, since the port/protocol just changed out from under whatever was last checked): the text is whatever banner the target volunteers unprompted (e.g.SSH-2.0-OpenSSH_10.2, the same thingnc host portwould show), since a protocol like SSH sends that the instant a connection opens. WinRM/PSRP targets just show "open, no banner" — both are HTTP/SOAP underneath, and HTTP is request-driven, so nothing arrives until the client sends a request first, which this check deliberately never does (keeping the same passive connect-and-listen behavior for every target rather than branching per protocol is what makes it "service-agnostic"). That call is plainfetch()rather than htmx so the request's query string can't end up including the password field's value the way htmx's default form-scraping would. A small refresh (.icon-button, the same one used elsewhere) sits to the left of the dot to re-run the check on demand, calling the samecheckTargetPort(). - Below the three columns, a full-width collapsible panel has three tabs: Log (one sub-tab
per concurrent run, opened by submitting the form or clicking a run in History), History
(
GET /runs, restyled but otherwise unchanged), and Viewer (a read-only file browser, see below). Role checkboxes live in their own column, outside<form id="apply-form">(which now wraps only the Deploy column, since that's where the Apply button lives) — they're associated to that form via the HTML5form="apply-form"attribute rather than DOM nesting, which is what bothFormData(form)and native form submission need to pick them up. Playbook buttons carry no form control at all (see "Playbooks (role presets)" below) — they only ever drive role checkboxes via client-side JS, so there's nothing of theirs for a form to submit. - Viewer tab: every role and playbook row in the Playbooks/Roles columns, and every role
in the Deploy column's selected-roles summary, has an eyeball button (
.icon-button .eyeball-button, an inline SVG rather than an emoji character — a colored emoji glyph would clash with the otherwise monochrome Dracula icon set, and rendering is font/platform-dependent in a way astroke="currentColor"SVG isn't; the SVG markup is shared three ways: a Jinja partial,partials/eye_icon.html, for the two server-rendered lists, and a JS constant,EYE_ICON_SVGinindex.html-- built by{% include %}ing that same partial into a template literal -- for the selected-roles summary, since that list is built client-side and can't{% include %}a partial into its own markup at runtime) right after its name. It's invisible (visibility: hidden, notdisplay: none, so it doesn't shift the row's layout when it appears) until its row is hovered or it has keyboard focus — clicking it switches the bottom panel to the Viewer tab and loads that item's files into a two-pane read-only browser (.viewer-file-list+.viewer-file-content), viaopenViewer(kind, name)inindex.htmlcallingGET /{roles|playbooks}/{name}/files(partials/file_browser.html) and, per file clicked,GET /{roles|playbooks}/{name}/file?path=...(partials/file_content.html) — both plainhx-gets on the file browser's own buttons, no extra JS needed for that part.browse.pybacks both: a role's "files" are every regular file under its directory (rglob("*"), relative paths); a playbook's is always exactly one entry, its own filename, so the same two-step "browse, then click a file" UI works for both without special-casing either. Once the file list loads,selectDefaultViewerFile()auto-loads a file into.viewer-file-contentwithout waiting for a click: a role'stasks/main.ymlif it has one (matched by each file button'sdata-relpath), otherwise whichever file is the only one (always true for a playbook) — it issues its ownhtmx.ajax()GET for that file rather than calling.click()on the matching button, since that button was just swapped in by the files-list request's ownhtmx.ajax()and isn't necessarily done being wired up for click handling by the time its promise resolves (confirmed via Playwright: a synthetic.click()right there was silently a no-op, while issuing the equivalent request directly was not). Every lookup re-val 10000 idates the role/playbook name against the same rulesroles.py/playbooks.pyuse for discovery, and every resolved path (the name itself, and any requested file path within a role) is checked to still be inside its expected base directory once resolved — defense against a../-laden name or path, or a symlink, trying to read something outsideroles_path/playbooks_path. Neither the eyeball button nor the row it sits in could just be<button>-in-<button>(roles' would also need to sit inside the checkbox's<label>, which would otherwise toggle the checkbox too) — seerole_list.html/playbook_list.html's comments; playbook rows use the same "outer<div>, two sibling<button>s" shape as the run tabs described below.
- A playbook is a YAML file — written like a normal Ansible playbook (a list of plays, each
with a
roles:list) — that lives in a configured directory (ansible.playbooks_path, parallel toroles_path) and names a preset group of roles, e.g. alamp.ymlplaybook listingapache,mysql,php. - Playbook parsing only needs to extract role names, not build a runnable playbook: it reads
each play's
roles:list (supporting plain string entries and- role: <name>dict entries), aggregating across every play in the file if there's more than one. There's no need for a full Ansible playbook parser since vars/tags/conditionals on those role entries are irrelevant here. - Selecting a playbook is a pure client-side action, not a server round trip: when the playbook list is rendered, each playbook's roles are baked into the HTML (e.g. as a data attribute on its button), and a small inline script checks those specific role checkboxes on click. Because it only ever adds checks, this naturally unions with whatever roles are already checked (individually or from another playbook) with no extra request needed.
- Playbooks are a convenience layer only — they do not change what gets submitted or how a run
executes, and they are not tracked once a run is created.
POST /runsonly ever receivesroles[](see Data model & routes); there's noplaybooks[]field and no record anywhere of which playbook(s), if any, contributed to a given run's role selection — a run's history is just the roles that were actually applied, the same regardless of whether they came from a playbook, manual picks, or both.
- A role can declare fillable variables via
meta/argument_specs.yml(or.yaml) — Ansible's own standard for declaring a role's inputs (ansible-core 2.11+, normally consumed byansible-doc/ansible-lint), underargument_specs.main.options. Each option can carrytype,default,required, anddescription. This was chosen over readingdefaults/main.ymldirectly because that file has no way to express "this variable exists but has no default and must be filled in" — everything in it already has a value. role_vars.py'sdiscover_role_variables(roles_path, roles)parses this per role, returning{role: {var_name: {"type", "default", "required", "description"}}}. A role with no argument_specs file, unreadable/malformed YAML, or the wrong shape simply has no entry in the result (never an empty{}) — mirrorsroles.py/playbooks.py's "missing/bad data on disk = no data, never raise" philosophy, since this reads config files, not user input. Exposing variables in the Deploy column is opt-in per role: a role with no argument_specs file just shows nothing in the Variables area.- Both
GET /roles(routes/roles.py) andGET /(routes/pages.py) calldiscover_role_variables()alongsidediscover_roles()and pass the result torole_list.html, which bakes each role's variable spec into adata-varsattribute on its checkbox ({{ (role_variables.get(role) or {}) | tojson }}) — the same "bake it into HTML, zero extra round trips" precedent playbook buttons already use for their roles list. See "Backend & UI" above for how the client renders this into the Variables area. - Field naming is
vars[<role>][<var_name>], parsed server-side inroutes/runs.py'screate_run()via a small bracket-notation regex (_parse_role_variables()), then validated/ type-coerced against the selected roles' own argument_specs (_coerce_role_variables(),_coerce_value()) —boolfromtrue/1/yes/onandfalse/0/no/off(case-insensitive),int/floatvia a direct cast,list/dictviayaml.safe_load()plus anisinstancecheck (YAML is a JSON superset, so["a", "b"]or{"key": "val"}typed into a plain text box parses correctly), anything else (str, unrecognized types) passed through as-is. A blank + required variable is a 400 ("<role>: '<var_name>' is required."); a blank + optional variable is omitted entirely (so the role's own argument_specs default — applied by Ansible itself at role invocation — wins, rather than being overridden by an explicit empty string); a bad cast (e.g. non-numeric text for anint) is also a 400. This is deliberately stricter than the "never raise" discovery philosophy above — validating what a user just typed in before launching a privileged job against a real host is a different concern than reading config off disk, closer in spirit to the existingtarget_portint-cast 400. A strayvars[<role-not-selected>][...]field is silently ignored (onlyroles, and each of their own specs, are iterated) — same lenient-toward-unknown-fields precedent as a strayplaybooks[]field. - A role entry in the generated playbook's
roles:list becomes{"role": name, "vars": {...}}only when that role actually has variables supplied (jobs.py's_build_playbook()) — kept as a plain string otherwise, so the common no-vars case stays exactly as minimal as before. A mixed list is expected and fine:playbooks.py's own_role_name_from_entry()already reads this exact dict shape (currently discardingvars:) when parsing a playbook file'sroles:list, confirming it's the right Ansible-native shape rather than an invention here. - Submitted variables are not persisted anywhere —
create_run()passes the parsed/coercedvariablesdict straight intojob_manager.start_job(..., variables=variables), which uses it only to build that one job's ephemeral playbook (jobs.py's_build_playbook()); theRunrow itself carries novariablescolumn. Same treatment as the target password (see therunstable note below), and for the same reason: a variable named e.g.mysql_root_passwordtyped into the Variables area should never end up sitting in run history in the clear. "Load into Deploy" (see "Live logs" below) therefore has nothing to restore — it resets the Variables area to each re-checked role's declared default (or blank), the same as freshly checking that role, rather than reproducing whatever was actually submitted last time. - A variable's default (as opposed to a submitted run's actual value, never persisted per the
point above) can itself be overridden persistently via the Settings popup — see "Settings
popup" below. Each variable field in the Deploy column carries its own "Save as Default"
icon button next to it for this. Overridden defaults are baked into the same
data-varsattribute this section describes, merged in server-side beforerole_list.htmlever renders, so the client-side code in "Backend & UI" above needs no awareness that an override happened at all -- to it, an overridden default just looks like a role's own declared one.
- Ansible is invoked through the
ansible-runnerPython library, not by shelling out toansible-playbookdirectly. This gives programmatic access to run status and per-event output, which the log streaming and job-tracking layers depend on. - Each job builds a dynamic, single-host inventory in memory (or in the run's
private_data_dir) from the submitted OS/IP/port/username/password — there is no static inventory file to maintain. - Authentication is OS/connection-dependent, chosen per run by the target's
TargetOSvalue (linux/windows/windows_psrp), and always password-based (no SSH keys or WinRM/PSRP certs):- Linux targets use the standard
sshconnection plugin together withsshpass, which feeds the password non-interactively.sshpassmust be present wherever a run actually executes (dev environment and container image both need it installed as a system package, not a Python dependency). - Windows targets use either the
winrmconnection plugin (via thepywinrmPython package) or thepsrpconnection plugin (via thepypsrpPython package) — both declared dependencies, unlikesshpass, and both talk HTTP/SOAP to the same WinRM listener on the target, just via different client libraries/Ansible connection plugins. Either way the password is accepted directly — no extra system package required for this path. inventory.pyis responsible for branching on the target's OS/connection and setting the rightansible_connection/ansible_port/auth variables for the host it generates.- All three branches deliberately skip trust verification that would otherwise block a
genuinely new target: Linux sets
StrictHostKeyChecking=no/UserKnownHostsFile=/dev/null(no known_hosts entry required), and both Windows connection types ignore the WinRM/PSRP cert validation and use thentlmauth/transport (works over plain HTTP without extra server-side trust config). This matches the app's ad hoc "type an IP and go" workflow, at the cost of not verifying a target's identity on first contact.
- Linux targets use the standard
- The generated playbook runs with
become: trueon Linux targets only (installing things likedocker-host/apacheneeds root). Since the app only ever collects one password,ansible_become_passwordis deliberately set to the same value as the login password (harmless no-op iftarget_useris already root) rather than prompting for a second one. Windows targets (either connection type) skipbecomeentirely — they're expected to connect as an already-admin account, and Ansible's sudo-based become doesn't apply to WinRM/PSRP anyway.
- Multiple runs can be in flight simultaneously, each against its own target. Every run is a distinct job with its own id, status, and log stream — jobs are not serialized behind a single global lock.
- Each job gets its own
ansible-runnerprivate_data_dir, keeping inventories, run artifacts, and logs isolated per job.
- Log lines reach the browser via Server-Sent Events (SSE), one stream per job
(e.g.
GET /runs/{job_id}/stream), fed fromansible-runner's event/status callbacks as the run progresses. Each stdout chunk becomes an unnamed ("message") SSE event; when the job finishes, the stream emits one final nameddoneevent with no payload and closes. - The client side is a plain
EventSourcemanaged per-run in vanilla JS (connectRunStream()/closeRunStream()inindex.html), not htmx's SSE extension. That extension was tried first and dropped: itshx-trigger="sse:done"binding needs to locate its own element's just-createdEventSourceat trigger-setup time, which reliably failed (htmx:noSSESourceError, the "done" listener silently never binding) when the element carryinghx-ext="sse"/sse-connectis inserted viahtmx.process()— a manual DOM insertion, which opening a run tab requires — rather than through htmx's own swap pipeline. The practical symptom was a finished run's tab staying stuck showing "pending" forever, even though the log lines themselves streamed in fine (sse-swap="message"did work reliably; only the completion signal didn't). Hand-rolling both halves in JS avoids the mismatch and keeps one code path instead of mixing two mechanisms for one conceptual stream. messageevents append to that run's<pre class="run-log">via plaintextContent +=; thedoneevent closes theEventSourceand re-fetch()es/runs/{job_id}, feeding the result back intoopenRunTab()(see below) to refresh the tab in place with the final status/return code/log — the same "re-fetch rather than parse a status out of the event" approach as before, just driven by JS instead of anhx-trigger.- Run tabs: one per concurrent run, inside the bottom panel's Log tab (see "Backend & UI"
above).
openRunTab(html)parses therun_detail.htmlfragment the server has always returned (fromPOST /runsorGET /runs/{job_id}), creates or reuses that run's tab button + content pane, callshtmx.process()on it (so the Cancel button'shx-poststill works) andconnectRunStream()if the run is still active (run_detail.html'sdata-active="true"). Each tab is a<div class="run-tab">(not a<button>— it wraps two buttons,.run-tab-labeland.run-tab-close, and a button can't nest inside a button) with a single delegated click listener on#run-tabs, sinceopenRunTab()replaces a tab'sinnerHTMLwholesale on every status/log refresh, which would silently drop per-button listeners bound directly to the old nodes. Tabs also carrydata-active(mirrored fromrun_detail.html) so the close handler below can tell whether to prompt without re-reading the DOM. - Closing a run tab is only exposed on the tab strip's own
.run-tab-closebutton —run_detail.htmlused to carry a second close button in its own header, but that read as two redundant close controls for the same tab, so it was replaced (see "Load into Deploy" below).requestCloseRunTab(runId)is what the tab strip's close button calls. If the tab's run is still active, it shows awindow.confirm()("stop it and close the tab?") before doing anything — declining leaves the tab open and running untouched; acceptingPOSTs/runs/{job_id}/cancelfirst. Either way (or immediately, for an already-finished run)closeRunTab()then closes that run'sEventSourcebefore removing the DOM nodes, so an abandoned tab doesn't leak a connection or leave an orphaned job running with nothing left to show its log. - "Load into Deploy", in
run_detail.html's header (where the redundant close button used to live), re-populates the Deploy column's target form and role checkboxes from that run vialoadRunIntoDeploy(runId), reading the target fields back offrun_detail.html's owndata-target-os/data-target-port/data-target-user/data-rolesattributes. It replaces the current role selection outright (unlike a playbook click, which only ever adds checks) so it exactly mirrors that run's roles, and reflects the run's OS/connection in the port preset dropdown too, matched bydata-osand port together first (falling back to adata-os-only match, then to no selection) since WinRM/PSRP's plain and "(Secure)" presets now share adata-os. Its Variables area is not restored from the run — variables are never persisted (see "Role variables (argument_specs)" above) —syncRoleVariables({})resets each re-checked role's fields to its declared default (or blank) instead, same as freshly checking that role. The run's actual password was never persisted either (see therunstable's password note below), so there's nothing to restore it to -- but a password the user already typed into the form before clicking this is worth more than the configured default, so it's preserved rather than clobbered:loadRunIntoDeploy()only letsapplyOsDefaults()'s default password through when the Password field was empty beforehand, restoring whatever was there otherwise. - The History tab refreshes itself (
refreshHistory(), afetch("/runs")that replaces#run-list'sinnerHTML) rather than requiring the panel's manual "Refresh history" button — called right after a run is submitted (so it appears aspending/runningimmediately), again when its SSE stream'sdoneevent fires, and again after a cancel-via-close, sincecloseRunTab()tears down that run'sEventSourceitself and so its owndonelistener never gets the chance to fire. - The panel between the 3-column workspace and the bottom panel is drag-resizable: a thin
#panel-resizerdiv (role="separator") sits between them; amousedownon it starts trackingmousemoveand sets#bottom-panel-body's inlineheightdirectly from the cursor's distance to the viewport bottom (clamped to a sane min/max). Since.workspaceabove it isflex: 1, it grows or shrinks to fill whatever space the drag leaves automatically — no separate logic needed for the top half. Disabled (via CSS:has()+pointer-events: none) while.bottom-panelis.collapsed, since there's nothing to resize then.
- SQLite via SQLAlchemy stores run metadata: job id, target host, selected roles, status, timestamps, and result. This is the queryable "run history" the UI lists.
- Full run logs are not duplicated into the database — they live as files under each job's
ansible-runnerartifacts directory, and are looked up by job id when a log needs to be replayed or re-streamed.
- App settings (most importantly the roles directory path, default
/opt/ansible/roles) live in a YAML config file, with environment variable overrides for container-friendly deployment (12-factor style). There is no settings UI/database — settings are static per process start. - Implemented via
pydantic-settings:settings.py'sSettingsmodel merges the YAML file andANSIBLASTER_*env vars (__-nested), with the YAML source resolved dynamically perANSIBLASTER_CONFIG/./config.yamlrather than fixed at class definition time. Application code should callget_settings()(a cached singleton) rather than constructingSettings()directly;load_settings()is the uncached version tests use.
Distinct from the static, config-file-driven "Settings" above (same name, different thing --
this is the gear-icon popup in the app's own UI, not config.yaml): a small set of runtime,
DB-backed overrides, editable from a modal opened via a cog button in the title bar (top
right, next to "AnsiBlaster" -- base.html). The modal has three tabs, Role Variables,
Host Variables, and Environment (.settings-tab/.settings-tab-content, switched purely
client-side by switchSettingsTab() -- same technique as the bottom panel's Log/History/Viewer
tabs):
- Role Variables tab -- overrides for what a role variable (declared via a role's
meta/argument_specs.yml-- see "Role variables (argument_specs)" above) autofills to when its role is checked. Overrides are keyed globally by variable name, not per role -- a savedmysql_portoverride applies to every role that happens to declare a variable namedmysql_port, matching the tab's own "sorted alphabetically by variable name" / "+ to add a variable by name" design, which has no notion of "for this role only". Existing overrides are listed one per line (each just a name + its value, styled like a single-line "row" -- no per-row save button) under a single "Save Role Variables" button that saves every edited row's current value at once (POST /settings/role-variables/bulk, fields namedvalue[<name>], same bracket-notation conventionroutes/runs.py's ownvars[<role>][<var_name>]fields already use); adding a brand-new override by name is its own separate small "+" action instead, since there's no existing value to bulk-save alongside yet (POST /settings/role-variables, also what a Deploy-column "Save as Default" click hits -- see below). Removing one is still a per-row action (DELETE /settings/role-variables/{name}). The tab itself is a fixed-top/scrolling-middle/fixed-bottom flex column (see style.css's[data-settings-content="role-variables"]rules): the heading/hint/filter stay put at the top, the "Save Role Variables" button and the "add a new one" fields stay put at the bottom, and only the list of existing overrides in between scrolls -- a long list shouldn't push those always-relevant controls below the fold, nor force the whole tab to scroll along with it. The scrolling list is wrapped in its own<form id="settings-var-bulk-form">(so it can collect every row'svalue[<name>]field on submit) but the actual submit button lives outside that scrolling markup, in the fixed-bottom area, associated back to the form via the HTML5form="settings-var-bulk-form"attribute -- the same techniquerole_list.html's checkboxes already use for#apply-form, which likewise lives in a different part of the DOM from its own submit button. A fuzzy-filter input (#settings-var-filter, samefuzzyMatch()/applyFuzzyFilter()client-side filtering the Playbooks/Roles columns already use -- see "Backend & UI" above) sits in the fixed-top area, matching each row via adata-filter-name="<name>"attribute on.settings-var-row. - Host Variables tab -- username/password overrides per port preset (
ssh/winrm/psrp-- the same threeconfig.yamlalready has underdefaults.*, see "Configuration file" below), each field its own full-width line (the same row shape the Role Variables tab's list uses, not the Deploy column's side-by-side username/password pairing). Leaving a field blank means "no override, fall back toconfig.yaml's value" (shown as that field's placeholder) rather than "override it with an explicit blank" -- submitting a blank field clears any existing override for that exact preset+field rather than saving an empty string over a real configured default. Saved all at once via a single "Save Host Settings" button (POST /settings/host). - Environment tab -- read-only, unlike the two tabs above: lists every environment variable
actually set in the running process that configures AnsiBlaster itself, so an admin can
confirm what's really in effect without shelling into the container. Scope is every
ANSIBLASTER_*variable (this app's own env-var-override prefix, see "Configuration file" below) plusPUID/PGID(consumed bydocker-entrypoint.shbefore the app itself starts, see "Distribution" below) -- unrelated environment noise (PATH,HOME, etc.) is excluded, and only variables actually set are listed, not the full set of keys the app recognizes (an unset one just doesn't appear -- there's no "unset" row). A value whose variable name contains "password", "secret", or "token" (case-insensitive) is masked with a fixed placeholder rather than shown -- and masked server-side, insettings.py'srelevant_environment_variables()itself, not just styled differently client-side, so the real value never reaches the modal's HTML at all regardless of who might view-source it. Backed entirely byos.environat render time, not theSettingDB table the other two tabs use, so this tab has noroutes/settings.pyendpoint of its own --_render_modal_body()just callsrelevant_environment_variables()alongside its existing DB lookups and threads the result into the samepartials/settings_modal_body.htmlrender every other route here already returns, same as everything else in this fragment.
Each variable field in the Deploy column's Variables area also carries its own "Save as
Default" icon button (saveVariableAsDefault() in index.html), saving that field's current
value as the new global default for its variable name with no need to open the popup at all.
Both override kinds are backed by a single generic key/value Setting table (key: str
PK, value: JSON -- see models.py) rather than a bespoke table per override kind,
specifically to sidestep this project's lack of a schema-migration framework (see the runs
table's no-migration-framework note below): a new kind of setting only ever needs a new
key-naming convention, never a new column or table. settings_store.py owns the key
conventions ("role_variable:<name>" / "host:<preset>:<field>") and the get/set/delete
helpers; nothing outside that module should query the Setting table directly. It also has the
merge helpers that layer these overrides on top of their non-DB source of truth --
apply_role_variable_overrides() (over discover_role_variables()'s output) and
apply_host_overrides() (over settings.defaults) -- a DB override always wins over the
matching config.yaml/argument_specs value when both exist. Values are parsed with
parse_override_value(), which is yaml.safe_load()-based (a JSON superset, so typed text like
42/true/["a", "b"] round-trips to its natural Python type, same technique
routes/runs.py's own _coerce_value() already uses for list/dict variable submissions) --
except a blank submission, kept as an explicit empty string rather than
yaml.safe_load("") -> None, since a blank default is itself a meaningful, deliberate choice
here.
routes/pages.py (GET /) and routes/roles.py (GET /roles) both call
apply_role_variable_overrides() right after their existing discover_role_variables() call,
and routes/pages.py also swaps what it passes to the template as defaults for
apply_host_overrides(settings.defaults, ...)'s merged result -- everywhere else in the
codebase (index.html's defaults.ssh.username etc., OS_DEFAULTS in its inline script) reads
that same defaults context variable unchanged, with no awareness an override might be
involved. But that merge only happens when a role checkbox is actually (re)rendered -- its
data-vars attribute is a snapshot baked in at the last GET /roles/GET /, not a live value
-- so every action that can create or change an override (the modal's "Save Role
Variables"/"+"/delete, and the Deploy column's own "Save as Default") also calls
refreshRoleList() (index.html) afterwards: it re-fetches GET /roles in place via
htmx.ajax(), then re-checks whatever roles were checked and re-applies
syncRoleVariables() over whatever was already typed, so an override takes effect the moment
you next check that role (even by unchecking and rechecking the same role right away) without
needing a manual Roles-panel refresh or a full page reload first. For the modal specifically,
this is wired generically rather than once per action: a htmx:afterRequest listener on
#settings-modal-body calls refreshRoleList() after any non-GET request that fragment makes.
routes/settings.py is the popup's own backend (GET /settings renders the modal's body
fragment, partials/settings_modal_body.html; POST /settings/role-variables and
DELETE /settings/role-variables/{name} add/edit or remove one override; POST /settings/role-variables/bulk saves every existing override at once; POST /settings/host
saves the whole host-defaults form at once) -- every one of its routes returns that same
fragment afresh (mirroring routes/runs.py's re-fetch-and-re-render pattern for
run_detail.html), so the modal reflects whatever was just saved/removed without a page reload;
base.html's cog button issues a plain hx-get="settings" into #settings-modal-body each
time it's opened (nothing about the modal is server-rendered on initial page load) and calls
openSettingsModal() (unhides the overlay, and resets the active tab to Role Variables). The
cog and "Save as Default" icons are inline SVGs (partials/settings_icon.html,
partials/save_icon.html), following the same stroke="currentColor" monochrome-icon
precedent the eyeball button already established (see "Backend & UI" above) rather than a
Unicode emoji glyph, which renders in color and inconsistently across platforms.
Because #settings-modal-body's content is swapped wholesale by htmx on every mutating action,
both its tab state (activeSettingsTab) and the Role Variables tab's filter query
(settingsVarFilterQuery) have to survive that swap -- switchSettingsTab() and a
value-restore-then-applyFuzzyFilter() call are both re-applied via a htmx:afterSwap
listener, rather than relying on whatever settings_modal_body.html renders as the default tab
(or empty filter) each time. settingsVarFilterQuery itself is kept current via its own
input listener on #settings-modal-body (delegated, since the filter input is rebuilt by
every swap too) -- separate from index.html's own delegated .fuzzy-filter input listener,
which drives the actual live filtering and needs no changes to also cover this dynamically
-loaded input. openSettingsModal() resets both activeSettingsTab and
settingsVarFilterQuery on every fresh open. All of this tab/filter-state code (and the
htmx:afterRequest → refreshRoleList()/toast wiring below) lives in base.html's own
trailing <script> block, not index.html's -- deliberately, and not just for the "modal
chrome belongs with the modal markup" reasoning that already put the click-outside/Escape
handling there. index.html's script runs while parsing <main>, which comes before
base.html's modal <div>s in the document -- a document.getElementById ("settings-modal-body") call from index.html's script would find nothing yet and throw
(Cannot read properties of null), silently breaking every inline onclick on the page after
it, since a script-level exception aborts the rest of that <script> block. refreshRoleList()
and applyFuzzyFilter() themselves stay in index.html (they only touch elements already
present inside <main>, and base.html's later script calling an earlier-defined global
function is the safe direction).
A brief, auto-dismissing confirmation (showToast(message, type = "success"), base.html) for
an action that would otherwise give no visible feedback -- appended to a fixed-position
#toast-container (bottom right), fades in, then fades out and removes itself after ~3s.
type is "success" (default, --green-accented) or "error" (--red-accented), toggled via
a toast-success/toast-error class on the toast element. Defined in base.html (co-located
with the container it manipulates) but called from both base.html's own settings-modal
listener and index.html's event handlers -- safe regardless of which script defines it, since
every call happens from inside an event handler, by which point the whole page (both scripts)
has already finished executing at least once, unlike the top-level document.getElementById
pitfall described above.
Wired into every action a user would expect to notice completing, but currently gives no other visible sign of:
- Playbooks/Roles panel refresh buttons and the History tab's "Refresh history" button
-- each has an
hx-on::after-request="if (event.detail.successful) showToast('...')"attribute right on the button itself (htmx 1.9's namespacedhx-on::<event>shorthand), rather than a JS listener, since each is a simple, self-contained one-off. This deliberately does not toast forrefreshRoleList()'s own programmaticGET /roles(see "Settings popup" above) -- that call targets#role-listdirectly viahtmx.ajax(), whosehtmx:afterRequestevent is dispatched on#role-listand bubbles up through its own ancestors, never reaching the sibling refresh button's own directly-attached listener. Piggy -backing a redundant "Roles refreshed" toast onto e.g. a "Save as Default" click (which triggers that same background refresh as a side effect) would be noise, not signal -- the meaningful toast there is "saved as default", not "roles refreshed". - The Settings modal's own mutating actions (add/bulk-save/delete a role-variable override,
save host settings) -- handled generically by the same
#settings-modal-bodyhtmx:afterRequestlistener that already callsrefreshRoleList()(see "Settings popup" above), extended to also callshowToast():settingsSuccessMessage(path)picks the message by matchingevt.detail.requestConfig.pathagainst each of the foursettings/role-variables*/settings/hostroutes, andsettingsErrorMessage(xhr)unwraps a failed request's body for the error toast -- FastAPI renders a raisedHTTPExceptionas JSON ({"detail": "..."}), not plain text, so this isJSON.parse()-based rather than showing the raw response body verbatim, falling back to a generic message if the body isn't that shape (e.g. a 500 with an HTML error page rather than a raisedHTTPException). - The Deploy column's "Save as Default" (
saveVariableAsDefault()inindex.html) -- not htmx-driven (see "Role variables (argument_specs)" above), so it callsshowToast()directly in its existingfetch()success/catchbranches, alongside the icon's own success/error color flash (kept as extra, more spatially-precise feedback right at the field itself; the toast is the more visible, load-bearing one).
| Column | Type | Notes |
|---|---|---|
id |
String (UUID), PK |
Also used as the ansible-runner ident / private_data_dir name |
target_os |
String/enum, not null |
linux, windows, or windows_psrp — determines the connection method used. Never shown to the user as-is: inventory.connection_label(target_os, target_port) (a connection_label Jinja filter, registered in deps.py) maps it back to whatever port preset produces it -- "SSH"/"WinRM"/"WinRM (Secure)"/"PSRP"/"PSRP (Secure)" -- for display in run history/detail, since that's what the user actually picked (see "Backend & UI" above) |
target_host |
String, not null |
IP address entered by the user |
target_port |
Integer, not null |
Default 22 for linux, 5985 for windows/windows_psrp (chosen by the form/route, not the DB) |
target_user |
String, not null |
|
roles |
JSON (list[str]), not null |
Snapshot of selected role names at submit time (regardless of whether they came from a playbook, manual picks, or both — playbooks themselves aren't tracked, see "Playbooks (role presets)" above) |
status |
String/enum, not null, default pending |
pending → running → successful | failed | canceled | error |
return_code |
Integer, nullable |
ansible-runner's rc once finished |
created_at |
DateTime, not null |
|
started_at |
DateTime, nullable |
|
finished_at |
DateTime, nullable |
|
artifact_dir |
String, nullable |
Path to this job's ansible-runner artifacts, for log lookup |
The target's password (SSH or WinRM, whichever the OS calls for) is never persisted — it is deliberately not a column on this table, only ever held in memory for the life of the request/job (used to build the inventory, then discarded). Re-running a past job means re-entering the password, even though the default password shown in the form comes from config (see "Configuration file" below). Submitted role variables (see "Role variables (argument_specs)" above) get the same treatment and for the same reason — they're used only to build that one job's ephemeral playbook, never written to a column here.
Roles are recorded as a JSON snapshot rather than a normalized association table — roles are
filesystem-defined, not DB entities, so a run's roles column is just an immutable record of
what was selected at submit time, not a live reference. There's no equivalent column for
playbooks: they're a client-side-only convenience for checking roles (see "Playbooks (role
presets)" above), not something a run's history needs to remember.
There's no migration framework in this project (db.py's init_db() is a plain
Base.metadata.create_all(), which only creates missing tables, not missing columns on an
already-existing one) — a future column added to a table won't be picked up by an existing
SQLite file without recreating it. A general, pre-existing limitation of this project's
schema-management approach, avoided so far by keeping table schemas stable rather than solved —
the settings table below is deliberately designed around this same limitation rather than
risking it again (see "Settings popup" above).
Backs the Settings popup (see "Settings popup" above) — a single generic key/value table, not one column/table per override kind.
| Column | Type | Notes |
|---|---|---|
key |
String, PK |
"role_variable:<name>" or "host:<preset>:<field>" — see settings_store.py |
value |
JSON, not null |
Any JSON-serializable value; a role-variable override's type depends on what was saved (an int/bool/list/etc., not always a str), a host override is always a plain string |
| Method & path | Purpose |
|---|---|
GET / |
Main page: role checklist, apply form, recent run history |
GET /roles |
HTMX fragment — rescans the roles directory, re-renders the checklist |
GET /roles/{name}/files |
HTMX fragment — Viewer tab file list for one role (partials/file_browser.html) |
GET /roles/{name}/file |
HTMX fragment — one file's read-only content (path query param, partials/file_content.html) |
GET /playbooks |
HTMX fragment — rescans the playbooks directory, re-renders the playbook button list (each button's roles baked in as a data attribute for the client-side check script) |
GET /playbooks/{name}/files |
HTMX fragment — Viewer tab file list for one playbook (always one entry, its own file) |
GET /playbooks/{name}/file |
HTMX fragment — that file's read-only content (path query param) |
GET /runs |
HTMX fragment/page — run history list |
POST /runs |
Create a run (roles[], target_os, target_host, target_port, target_user, target_password, vars[<role>][<var_name>] for any selected role's declared variables); inserts a pending row, launches the ansible-runner job async, returns the new run's detail panel |
GET /runs/{job_id} |
Run detail fragment/page — status, target, roles, timestamps, log panel container |
GET /runs/{job_id}/stream |
SSE endpoint — live log lines + status transitions for the job; closes when the run ends |
GET /runs/{job_id}/log |
Full plain-text log — used for replaying a finished run, or backfilling before SSE attaches |
POST /runs/{job_id}/cancel |
Cancel an in-progress run (ansible-runner stop) → status becomes canceled |
GET /target/check-port |
JSON {"open": bool, "banner": str | null} — quick, service-agnostic TCP reachability + banner check for the Deploy column's Status row (host/port query params) |
GET /settings |
HTMX fragment — the Settings popup's body (partials/settings_modal_body.html), listing current role-variable-default and host-default overrides, plus the read-only Environment tab's currently-set ANSIBLASTER_*/PUID/PGID env vars |
POST /settings/role-variables |
Add or update one role-variable-default override (name, value); returns the refreshed popup body |
POST /settings/role-variables/bulk |
Save every existing role-variable override at once (value[<name>] per row, from the Role Variables tab's single "Save Role Variables" button); returns the refreshed popup body |
DELETE /settings/role-variables/{name} |
Remove one role-variable-default override; returns the refreshed popup body |
POST /settings/host |
Save the whole host-defaults form at once ({preset}_username/{preset}_password for ssh/winrm/psrp; a blank field clears that override); returns the refreshed popup body |
Settings live in a YAML file, discovered via ANSIBLASTER_CONFIG (an explicit path) falling
back to ./config.yaml in the current working directory if unset. The file is optional — every
key has a built-in default, so a missing file (e.g. a fresh container with no volume mounted) is
not an error.
# config.yaml — all keys shown with their defaults
# dir: /opt/ansiblaster # unset by default -- base dir for ansible.artifacts_path/database.path
# below, when both should simply move somewhere other than
# /opt/ansiblaster instead of overriding them individually (see below)
server:
host: "0.0.0.0"
port: 8000
ansible:
# path: /opt/ansible # unset by default -- base dir for roles_path/playbooks_path below,
# when both live side by side instead of overriding them individually
roles_path: /opt/ansible/roles # directory scanned for roles
playbooks_path: /opt/ansible/playbooks # directory scanned for playbook YAML files (role presets)
artifacts_path: /opt/ansiblaster/artifacts # base dir for ansible-runner's per-job private_data_dir
database:
path: /opt/ansiblaster/ansiblaster.db # SQLite file location
logging:
level: INFO # governs the app's own logger + uvicorn's startup/error logs -- see below
defaults:
ssh:
username: ""
password: ""
winrm:
username: ""
password: ""
psrp:
username: ""
password: ""defaults.ssh / defaults.winrm / defaults.psrp pre-fill the target form's username/password
fields depending on which port preset the user picks (see "Backend & UI" above), purely as a
convenience — they are never used directly without passing through the form, and the actual
value submitted (default or edited) is the one held in memory for that run (see the
password-persistence note above). Each is independent, with no fallback between them: WinRM and
PSRP are both "Windows" but not necessarily the same account, so leaving psrp unset (say)
just leaves that preset's fields blank rather than borrowing winrm's values.
The Settings popup's Host Defaults (see "Settings popup" above) can override any of these username/password values per preset at runtime, without editing this file or restarting the process — a DB-saved override always wins over this file's value for that exact preset+field when both exist; a field left unset in the popup just falls back to whatever's configured here.
dir (top-level, unset by default) is a single base directory for AnsiBlaster's own persistent
storage — ansible.artifacts_path and database.path — as an alternative to overriding those
two individually when both should simply move somewhere other than the hardcoded
/opt/ansiblaster default, e.g. a non-root uv run checkout on a host where /opt isn't
writable: ANSIBLASTER_DIR=/home/user/.config/ansiblaster puts the database at
~/.config/ansiblaster/ansiblaster.db and job artifacts under
~/.config/ansiblaster/artifacts/. It only fills in whichever of ansible.artifacts_path/
database.path are still at their built-in default — either one explicitly set (env var or this
file) always wins over dir for that one path, so e.g. dir plus a separately-overridden
database.path relocates only artifacts_path. settings.py's Settings._apply_dir_override()
implements this by comparing against the hardcoded default path strings rather than tracking
"was this explicitly set" through the settings-source merge — the one edge case that approach
misses is a database.path/artifacts_path override that happens to equal the literal default
path, which would still be treated as unset. Whichever directories are actually in effect
(dir-derived or not) are created automatically, before first use, if missing — db.py's
make_engine() creates database.path's parent directory when the engine is built at startup,
and jobs.py's JobManager creates artifacts_path itself when constructed (also at startup),
in addition to its own per-job subdirectory under it — so nothing here needs the directory to
already exist ahead of time (a bind-mounted VOLUME in the Docker image, or a manually created
directory on a bare checkout).
ansible.path (unset by default) is the same idea one level down, for ansible.roles_path/
ansible.playbooks_path instead of database.path/ansible.artifacts_path: a base directory
holding both as sibling subdirectories (roles/, playbooks/), for when roles and playbooks
simply live side by side somewhere other than the hardcoded /opt/ansible default — e.g. a
single checked-out Ansible content repo laid out as <path>/roles and <path>/playbooks. Set
via ANSIBLASTER_ANSIBLE__PATH=/srv/ansible or this section's own path: key. Same precedence
and same comparison-against-the-hardcoded-default implementation as dir above
(AnsibleSettings._apply_path_override(), right next to _apply_dir_override() in
settings.py) — an explicitly-set roles_path/playbooks_path still wins over path for that
one setting. Unlike dir, nothing here creates path's directories: roles_path/
playbooks_path are expected to already hold real Ansible content (read-only bind mounts in the
Docker image), not app-owned storage the app should create on first use — a missing directory is
handled the same as always, by discovery simply finding nothing there (see "Backend & UI"
above), never by creating an empty one.
logging.level governs the app's own logger ("ansiblaster", and every ansiblaster.* module
logger under it via logging.getLogger(__name__)) plus uvicorn's "uvicorn"/"uvicorn.error"
loggers (startup/shutdown/errors) — applied in __main__.py's main() (the uv run ansiblaster/Docker entrypoint — not the uv run uvicorn ... --reload dev-server invocation,
which bypasses __main__.py and so needs its own --log-level flag if you want this to apply
there too). logging_config.py's build_log_config() builds the actual log_config dict this
gets turned into; see its docstring for why this is a full custom log_config, not uvicorn's
own log_level kwarg (the latter would unconditionally override the access-log split below).
Deliberately not governed by logging.level: uvicorn's own "uvicorn.access" logger (one
line per HTTP request) is hard-pinned to require DEBUG specifically, regardless of what
logging.level is set to — the kind of thing that happens constantly under normal operation,
as opposed to the app's own occasional-but-meaningful log lines (e.g. routes/roles.py/
routes/playbooks.py logging a role/playbook reload's outcome — "Loaded 12 role(s) from
/opt/ansible/roles." or "Roles directory /opt/ansible/roles does not exist -- showing no
roles." — at INFO, right where a rescan is explicitly triggered, i.e. GET /roles/
GET /playbooks, not the main page load). This split is what lets INFO be the default
without also drowning it in a line for every single request: defaults to INFO rather than
WARNING, since the reload-outcome lines above are exactly the kind of thing worth seeing
without reaching for DEBUG first. Set ANSIBLASTER_LOGGING__LEVEL=DEBUG to get uvicorn's
per-request access log back too when actually debugging; any level Python's logging module
recognizes works (CRITICAL/ERROR/WARNING/INFO/DEBUG, case-insensitive).
Every key is overridable via an environment variable using the ANSIBLASTER_ prefix with __ as
the nesting delimiter, e.g.:
ANSIBLASTER_SERVER__PORT=9000ANSIBLASTER_ANSIBLE__PATH=/srv/ansible(impliesroles_path/playbooks_pathof/srv/ansible/roles//srv/ansible/playbooks, unless either is itself also set below)ANSIBLASTER_ANSIBLE__ROLES_PATH=/srv/ansible/rolesANSIBLASTER_ANSIBLE__PLAYBOOKS_PATH=/srv/ansible/playbooksANSIBLASTER_DATABASE__PATH=/data/ansiblaster.dbANSIBLASTER_LOGGING__LEVEL=INFOANSIBLASTER_DEFAULTS__SSH__PASSWORD=.../ANSIBLASTER_DEFAULTS__WINRM__PASSWORD=.../ANSIBLASTER_DEFAULTS__PSRP__PASSWORD=...
dir is the one exception to the __-nesting convention above — it's a top-level field, not
nested under a sub-model, so it's set via the plain ANSIBLASTER_DIR env var (no __), e.g.
ANSIBLASTER_DIR=/home/user/.config/ansiblaster.
src/ansiblaster/
├── __init__.py
├── __main__.py # entrypoint: uv run ansiblaster → starts uvicorn, via
│ # logging_config.build_log_config(settings.logging.level)
├── logging_config.py # build_log_config(): the log_config dict passed to uvicorn.run() --
│ # settings.logging.level governs the app's own logger + uvicorn's
│ # startup/error logs, but "uvicorn.access" (one line per request) is
│ # pinned to require DEBUG regardless -- see its own docstring for why
│ # this has to be a full log_config, not uvicorn's own log_level kwarg
├── app.py # FastAPI app factory: creates app, mounts static/, includes routers,
│ # wires startup/shutdown (settings load, DB init, JobManager) onto
│ # app.state
├── deps.py # Shared FastAPI dependency providers (get_app_settings,
│ # get_session_factory, get_job_manager, all reading app.state) plus
│ # the Jinja2Templates instance. Kept separate from app.py so route
│ # modules can import it without an app.py <-> routes circular import
├── settings.py # YAML config + env var overrides → Settings object. Also
│ # relevant_environment_variables(): currently-set ANSIBLASTER_*/PUID/
│ # PGID env vars for the Settings popup's read-only Environment tab
├── db.py # SQLAlchemy engine/session factory, declarative Base, init_db()
├── models.py # Run ORM model + RunStatus enum, plus the generic Setting(key, value)
│ # model backing the Settings popup (see settings_store.py)
├── roles.py # Role discovery: scans settings.roles_path, returns valid role names
├── role_vars.py # discover_role_variables(roles_path, roles): parses each role's
│ # meta/argument_specs.yml into {role: {var_name: {type, default,
│ # required, description}}} -- backs the Deploy column's Variables area
├── settings_store.py # Settings popup: get/set/delete helpers for the Setting table's two key
│ # namespaces (role-variable-default overrides, host credential
│ # overrides), plus apply_role_variable_overrides()/apply_host_overrides()
│ # to merge them over discover_role_variables()/settings.defaults
├── playbooks.py # Playbook discovery: scans settings.playbooks_path, parses each file's
│ # roles: list(s) into {playbook_name: [role_names]}
├── inventory.py # Builds the ephemeral single-host inventory for ansible-runner
│ # from a run's target fields
├── jobs.py # JobManager: creates the Run DB row, launches ansible-runner's
│ # run_async, keeps the in-process registry (job_id → runner handle,
│ # asyncio.Queue for SSE, cancel event), event_handler callback that
│ # persists status/log to DB and pushes lines to the job's queue.
│ # Also stdout_log_path(run), mirroring ansible-runner's own
│ # private_data_dir/artifacts/<ident>/stdout convention
├── portcheck.py # check_port(host, port): service-agnostic async TCP connect + a
│ # best-effort read of whatever banner the target volunteers unprompted
│ # (works for SSH, naturally yields none for WinRM) backing the Deploy
│ # column's Status row. Times the connect out with asyncio.wait(), not
│ # asyncio.wait_for() -- see the docstring for why
├── browse.py # Viewer tab: list_role_files/read_role_file, list_playbook_files/
│ # read_playbook_file -- re-validates the role/playbook name and checks
│ # every resolved path stays inside its expected base directory
├── routes/
│ ├── __init__.py # aggregates routers for app.py to include
│ ├── pages.py # GET / -- also threads discover_role_variables() through to index.html
│ │ # alongside discover_roles(), for the Variables area's data-vars
│ ├── roles.py # GET /roles fragment (distinct module from top-level roles.py --
│ │ # that one discovers roles, this one serves the HTTP fragment), plus
│ │ # the Viewer tab's GET /roles/{name}/files and .../file. Also threads
│ │ # discover_role_variables() through, same as pages.py above
│ ├── playbooks.py # GET /playbooks fragment (distinct from top-level playbooks.py, same
│ │ # naming pattern as roles.py above), plus the Viewer tab's
│ │ # GET /playbooks/{name}/files and .../file
│ ├── runs.py # POST /runs, GET /runs, GET /runs/{job_id}, GET /runs/{job_id}/stream,
│ │ # GET /runs/{job_id}/log, POST /runs/{job_id}/cancel. POST /runs also
│ │ # parses/validates vars[<role>][<var_name>] fields (see "Role variables
│ │ # (argument_specs)") -- _parse_role_variables(), _coerce_role_variables()
│ ├── target.py # GET /target/check-port -- thin JSON wrapper around portcheck.py
│ └── settings.py # GET /settings, POST /settings/role-variables,
│ # POST /settings/role-variables/bulk,
│ # DELETE /settings/role-variables/{name}, POST /settings/host -- see
│ # "Settings popup". Every route returns the same
│ # partials/settings_modal_body.html fragment, freshly re-rendered, also
│ # threading in settings.py's relevant_environment_variables() for the
│ # Environment tab (no route of its own -- it's read-only, not DB-backed)
├── templates/
│ ├── base.html # Title bar (incl. the Settings popup's cog button) + the modal
│ │ # overlay shell itself, plus the modal's own tab-switching/
│ │ # refreshRoleList()-wiring script (see "Settings popup" for why that
│ │ # script lives here and not in index.html) -- the only template other
│ │ # than index.html's own content block
│ ├── index.html # 3-column workspace + bottom panel; owns nearly all client-side JS
│ │ # (fuzzy filter, playbook->checkbox, run tabs, EventSource management)
│ └── partials/
│ ├── role_list.html
│ ├── playbook_list.html # playbook buttons, each with its roles baked in as a data
│ │ # attribute for the client-side check script
│ ├── file_browser.html # Viewer tab: one role's/playbook's file list
│ ├── file_content.html # Viewer tab: one file's read-only content
│ ├── eye_icon.html # shared inline SVG for every eyeball view button
│ ├── save_icon.html # shared inline SVG for the Deploy column's "Save as Default"
│ │ # buttons (the Settings popup itself has no per-row save
│ │ # buttons -- see "Settings popup")
│ ├── settings_icon.html # the title bar's cog button SVG
│ ├── settings_modal_body.html # the Settings popup's content -- three tabs (Role
│ │ # Variables, Host Variables, read-only Environment),
│ │ # rendered by GET /settings and every settings/* route's
│ │ # response (see "Settings popup")
│ ├── run_list.html
│ ├── run_row.html # single history-list item; opened via a delegated fetch(),
│ │ # not hx-get (see "Backend & UI")
│ └── run_detail.html # one run's status + log; relocated into a run tab client-side
└── static/
├── htmx.min.js # vendored, not CDN — the app must work with no outbound internet access
└── style.css # Dracula palette + the 3-column/bottom-panel IDE layout
tests/mirrors this layout alongsidesrc/(not inside the package):test_roles.py,test_role_vars.py,test_playbooks.py,test_inventory.py,test_jobs.py,test_portcheck.py,test_browse.py,test_settings.py,test_settings_store.py,test_logging_config.py,test_main.py,test_routes_pages.py,test_routes_roles.py,test_routes_playbooks.py,test_routes_runs.py,test_routes_target.py,test_routes_settings.py, plus a sharedconftest.py(theclientfixture — aTestClientwired to per-test tmp_path roles/playbooks/artifacts/DB paths — andmake_role/make_playbookhelpers;make_roletakes an optionalargument_specs=dict to write ameta/argument_specs.ymlfor it).- Tests mock
ansible-runnerexecution (monkeypatch.setattr("ansiblaster.jobs.ansible_runner.run_async", ...)) rather than running real playbooks/SSH — no live target host is required to run the test suite. - The full suite must pass unattended in GitHub Actions, not just locally — nothing may depend on
interactive input, a locally-cached resource, or network behavior that only happens to be fast on a
given machine.
test_portcheck.py/test_routes_target.pycome closest to violating this: they do exercise real sockets (a loopback listener the test itself starts, an unused local port, a non-routable documentation-range IP, an unresolvable.invalidhostname), but only ever against127.0.0.1or addresses guaranteed to fail — none of it depends on a real remote host answering. That's still worth watching ifportcheck.pychanges:check_port()usesasyncio.wait()rather thanasyncio.wait_for()specifically soconnect_timeoutis enforced even when hostname resolution (a blockinggetaddrinfo()callasyncioruns in a worker thread) is slow to fail —wait_for()waits for a cancelled task to actually unwind before raising, which a still-running thread-pool call can't be made to do, so on a run where DNS resolution for a bad hostname is unusually slow,wait_for()would silently block past its own timeout instead of enforcing it. That exact pattern (a CI job hanging indefinitely on "Run tests" with no failure and no useful log output, since nothing ever raises) is worth recognizing on sight if it recurs elsewhere: anywait_for()/shield()around a coroutine that bottoms out in a thread-pool call (DNS resolution, blocking file I/O viarun_in_executor, etc.) has the same gap.
Each run is launched via ansible-runner's own run_async, which handles thread isolation and
per-job private_data_dirs internally — the app does not manage its own worker/thread pool on
top of it. jobs.py keeps a simple in-process registry mapping job_id → runner handle, an
asyncio.Queue feeding that job's SSE stream, and a cancel event, so concurrent jobs stay
independent without extra process-management code.
- Dependency management:
uv, viapyproject.toml+uv.lock.- Install dependencies:
uv sync - Run the app:
uv run ansiblaster(registered via[project.scripts]), oruv run uvicorn ansiblaster.app:app --reloadduring development for auto-reload
- Install dependencies:
- Lint & format:
ruffhandles both linting and formatting (no separate black/isort/flake8).pyproject.toml's[tool.ruff.lint.flake8-bugbear] extend-immutable-callsallowlists FastAPI'sDepends(...)-as-a-default-argument idiom, which bugbear's B008 would otherwise flag.- Check:
uv run ruff check . - Format:
uv run ruff format .
- Check:
- Tests:
pytest(withpytest-asyncio,asyncio_mode = "auto"soasync def test_...functions just work) andhttpx(for FastAPI'sTestClient).pytest-timeoutsets a global 60s-per-test watchdog ([tool.pytest.ini_options] timeoutinpyproject.toml) purely as a CI safety net: the full suite normally runs in a few seconds, so this isn't a tight budget, it's there so a hang fails loudly with a full multi-thread traceback (ansible-runnerand its callbacks run on their own background threads, not just the asyncio event loop -- see "Job execution model") instead of silently consuming the whole GitHub Actions job the way a since-fixedcheck_port()bug once did (20+ minutes stuck on "Run tests" with no output at all -- seeportcheck.py's docstring). If this timeout ever actually fires in CI, the resulting traceback is the way to find out what's really stuck, rather than guessing blind.- Full suite:
uv run pytest - Single test:
uv run pytest tests/path/to/test_file.py::test_name
- Full suite:
Dockerfile(multi-stage): abuilderstage usesuv(viaCOPY --from=ghcr.io/astral-sh/uv:latest) to resolvepyproject.toml/uv.lockinto a self-contained.venv(dependencies synced in their own layer, before thesrc/copy, so editing application code doesn't bust that slower layer); theruntimestage is a freshpython:3.11-slimwith only/app/.venvcopied in — nouv, no build tools. Runtime system packages:sshpass+openssh-client(Ansible'ssshconnection plugin needs both to authenticate to Linux targets with a password) andgosu(privilege drop, see below).ansible-core/ansible-runner/pywinrm/pypsrpare just Python deps already inpyproject.toml, installed into the venv like everything else.- Container user: non-root by default (
ansiblaster, baseline uid/gid 1000), but the container starts as root anddocker-entrypoint.shremaps that user toPUID/PGIDenv vars (default1000/1000, a no-op at those values) before dropping privileges viagosu— the common self-hosted-tool convention (popularized by linuxserver.io images). This lets a bind-mounted host directory just work by settingPUID/PGIDto match its owner, instead of requiring the host directory's ownership to match some fixed in-image uid. The entrypoint onlychowns/opt/ansiblaster(the app's own artifacts/db storage) — deliberately not/opt/ansible/rolesor/opt/ansible/playbooks, since those are expected to be a read-only bind mount of the user's existing Ansible content andchowning a bind mount also changes ownership on the host filesystem. - Image paths matches
settings.py's defaults exactly (/opt/ansible/roles,/opt/ansible/playbooks,/opt/ansiblaster/artifacts,/opt/ansiblaster/ansiblaster.db's parent dir), declared asVOLUMEs, so the image works with zero config beyond bind-mounting your own roles at/opt/ansible/roles— e.g.docker run -p 8000:8000 -v /path/to/roles:/opt/ansible/roles -v ansiblaster-data:/opt/ansiblaster ansiblaster. docker-compose.ymlwraps that same setup (HOST_PORT/PUID/PGID/HOST_ROLES_DIR/HOST_PLAYBOOKS_DIRenv vars, documented in.env.example) and defaultsHOST_ROLES_DIR/HOST_PLAYBOOKS_DIRto the bundledexample/directory — a minimal Ubuntu/Debian LAMP stack (apache/mysql/phproles + alamp.ymlplaybook, allansible.builtin-only, no extra collections needed) sodocker compose uphas something real to show on first run instead of an empty checklist. Seeexample/README.md.- The repo must also be directly runnable from a clone (via
uv sync+uv run) without Docker — both distribution paths are first-class, not just the container. .github/workflows/docker-publish.ymlbuilds and publishes to Docker Hub (bamhm182/ansiblaster) automatically: atestjob (uv sync,ruff check,pytest) gates adockerjob that builds the image (linux/amd64only) viadocker/build-push-action. Push tomainpublisheslatest; pushing avX.Y.Zgit tag additionally publishes matchingX.Y.Z/X.Y/Xtags (viadocker/metadata-action). Pull requests run the same build for validation but never push (no Docker Hub login happens on PR events, since forked PRs don't have secrets access anyway). Requires two repo secrets set in GitHub —DOCKERHUB_USERNAMEandDOCKERHUB_TOKEN(a Docker Hub access token, not the account password) — which aren't and shouldn't be committed anywhere in this repo.- Still not verified by a real
docker build: no session so far has had a Docker daemon available (client only). One real bug already slipped through this gap: the builder stage's seconduv syncoriginally lacked--no-editable, so it installed the project in editable mode — a.pthfile in site-packages pointing back at the builder stage's/app/src— which works inside that stage but breaks the instant the runtime stage copies only.venv, since the.pththen points nowhere (ModuleNotFoundError: No module named 'ansiblaster'at container start). Found via a realdocker compose upfailure report, and the fix was verified by faithfully reproducing the two-stage layering withuvdirectly (separate directories per stage, physically removing the builder'ssrc/before testing the "runtime".venvin isolation) rather than guessed at — but that is still not the same as an actualdocker build/docker runpass, which is owed before trusting this in production.