- /ansible/playbooks lists every playbook across sources: what it says it does, its category and destructive flag, how it last ran; the whole row opens the playbook. A source with none is one quiet line. - /ansible/playbooks/<source>/<path>: the verdict (what it does, flagged destructive, last ran), the run form in place (dry run beside the button, options folded), the schedules that use it, its recent runs, the playbook itself folded. ?from=<run> fills the form from that run: Run again on a run's page. - /ansible/browse, its view page and the /run-form fragment are gone, not redirected (rule 122); every link points at the new pages. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+95
-80
@@ -161,66 +161,113 @@ def run_view(run: AnsibleRun, labels: dict[str, str], now: datetime) -> dict:
|
||||
"recap": run_recap(run.results, check)}
|
||||
|
||||
|
||||
@ansible_bp.get("/browse")
|
||||
@require_role(UserRole.viewer)
|
||||
async def browse():
|
||||
from steward.models.ansible_inventory import AnsibleTarget, AnsibleGroup
|
||||
def _source(name: str) -> dict | None:
|
||||
return next((s for s in _get_sources() if s["name"] == name), None)
|
||||
|
||||
all_sources = _get_sources()
|
||||
source_data = []
|
||||
for source in all_sources:
|
||||
playbooks = []
|
||||
for path in src_module.discover_playbooks(source["path"]):
|
||||
|
||||
async def last_runs(db, keys) -> dict[tuple[str, str], AnsibleRun]:
|
||||
"""{(source, playbook): its newest run} for the given playbooks."""
|
||||
keys = list(keys)
|
||||
if not keys:
|
||||
return {}
|
||||
newest = (select(AnsibleRun.source_name, AnsibleRun.playbook_path,
|
||||
func.max(AnsibleRun.started_at).label("at"))
|
||||
.group_by(AnsibleRun.source_name, AnsibleRun.playbook_path).subquery())
|
||||
rows = (await db.execute(select(AnsibleRun).join(newest, (
|
||||
(AnsibleRun.source_name == newest.c.source_name)
|
||||
& (AnsibleRun.playbook_path == newest.c.playbook_path)
|
||||
& (AnsibleRun.started_at == newest.c.at))))).scalars().all()
|
||||
wanted = set(keys)
|
||||
return {(r.source_name, r.playbook_path): r for r in rows
|
||||
if (r.source_name, r.playbook_path) in wanted}
|
||||
|
||||
|
||||
@ansible_bp.get("/playbooks")
|
||||
@require_role(UserRole.viewer)
|
||||
async def playbooks():
|
||||
"""The Playbooks tab (#5331): every playbook across sources in one list,
|
||||
each with what it says it does and how it last ran; a source with none
|
||||
is one quiet line under it."""
|
||||
rows, empty = [], []
|
||||
sources = _get_sources()
|
||||
for source in sources:
|
||||
paths = src_module.discover_playbooks(source["path"])
|
||||
if not paths:
|
||||
empty.append({**source, "editable": src_module.is_editable_source(source)})
|
||||
for path in paths:
|
||||
contents = src_module.read_playbook(source["path"], path)
|
||||
meta = (src_module.discover_playbook_meta(contents) if contents is not None
|
||||
else {"description": "", "category": ""})
|
||||
playbooks.append({
|
||||
"path": path,
|
||||
"category": meta["category"],
|
||||
"description": meta["description"],
|
||||
})
|
||||
inventories = src_module.discover_inventories(source["path"])
|
||||
source_data.append({
|
||||
"source": source,
|
||||
"playbooks": playbooks,
|
||||
"inventories": inventories,
|
||||
"editable": src_module.is_editable_source(source),
|
||||
})
|
||||
|
||||
else {"description": "", "category": "", "confirm": False})
|
||||
rows.append({"source": source["name"], "path": path, **meta})
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
targets = (await db.execute(
|
||||
select(AnsibleTarget).order_by(AnsibleTarget.name)
|
||||
)).scalars().all()
|
||||
groups = (await db.execute(
|
||||
select(AnsibleGroup).order_by(AnsibleGroup.name)
|
||||
)).scalars().all()
|
||||
|
||||
last = await last_runs(db, [(r["source"], r["path"]) for r in rows])
|
||||
for r in rows:
|
||||
r["last"] = last.get((r["source"], r["path"]))
|
||||
return await render_template(
|
||||
"ansible/browse.html",
|
||||
source_data=source_data,
|
||||
targets=targets,
|
||||
groups=groups,
|
||||
)
|
||||
"ansible/playbooks.html", rows=rows, empty_sources=empty, sources=sources,
|
||||
can_add=bool(_editable_sources()) and session.get("user_role") == "admin")
|
||||
|
||||
|
||||
@ansible_bp.get("/browse/<source_name>/<path:playbook_path>")
|
||||
@ansible_bp.get("/playbooks/<source_name>/<path:playbook_path>")
|
||||
@require_role(UserRole.viewer)
|
||||
async def view_playbook(source_name: str, playbook_path: str):
|
||||
all_sources = _get_sources()
|
||||
source = next((s for s in all_sources if s["name"] == source_name), None)
|
||||
async def playbook_page(source_name: str, playbook_path: str):
|
||||
"""One playbook's home (#5331): what it does, the form that runs it, the
|
||||
schedules that use it and its recent runs, then the playbook itself.
|
||||
?from=<run id> fills the form from that run ("Run again")."""
|
||||
from steward.models.ansible_inventory import AnsibleGroup, AnsibleTarget
|
||||
|
||||
source = _source(source_name)
|
||||
if source is None:
|
||||
return "Source not found", 404
|
||||
contents = src_module.read_playbook(source["path"], playbook_path)
|
||||
if contents is None:
|
||||
return "Playbook not found", 404
|
||||
meta = src_module.discover_playbook_meta(contents)
|
||||
variables = src_module.discover_playbook_variables(contents)
|
||||
now = datetime.now(timezone.utc)
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
runs = (await db.execute(select(AnsibleRun).where(
|
||||
AnsibleRun.source_name == source_name, AnsibleRun.playbook_path == playbook_path)
|
||||
.order_by(AnsibleRun.started_at.desc()).limit(RECENT_RUNS))).scalars().all()
|
||||
scheds = (await db.execute(select(AnsibleSchedule).where(
|
||||
AnsibleSchedule.source_name == source_name, AnsibleSchedule.playbook_path == playbook_path)
|
||||
.order_by(AnsibleSchedule.name))).scalars().all()
|
||||
labels = await scope_labels(db, [r.inventory_scope for r in runs] + [s.inventory_scope for s in scheds])
|
||||
origins = await run_origins(db, runs)
|
||||
targets = (await db.execute(select(AnsibleTarget).order_by(AnsibleTarget.name))).scalars().all()
|
||||
groups = (await db.execute(select(AnsibleGroup).order_by(AnsibleGroup.name))).scalars().all()
|
||||
again = None
|
||||
if request.args.get("from"):
|
||||
again = (await db.execute(select(AnsibleRun).where(
|
||||
AnsibleRun.id == request.args["from"], AnsibleRun.source_name == source_name,
|
||||
AnsibleRun.playbook_path == playbook_path))).scalar_one_or_none()
|
||||
return await render_template(
|
||||
"ansible/browse.html",
|
||||
source_data=[],
|
||||
view_source=source_name,
|
||||
view_path=playbook_path,
|
||||
view_contents=contents,
|
||||
view_editable=src_module.is_editable_source(source),
|
||||
)
|
||||
"ansible/playbook.html", source=source, source_name=source_name, playbook_path=playbook_path,
|
||||
contents=contents, description=meta["description"], category=meta["category"],
|
||||
confirm=meta["confirm"], variables=variables, editable=src_module.is_editable_source(source),
|
||||
runs=[{**run_view(r, labels, now), "origin": origins[r.id]} for r in runs],
|
||||
schedules=[{"s": s, "scope_label": labels.get(s.inventory_scope, s.inventory_scope),
|
||||
"next": next_scheduled([s], now)} for s in scheds],
|
||||
targets=targets, groups=groups, inventories=src_module.discover_inventories(source["path"]),
|
||||
prefill=run_prefill(again, {v["name"] for v in variables}))
|
||||
|
||||
|
||||
RECENT_RUNS = 10
|
||||
|
||||
|
||||
def run_prefill(run, declared: set[str]) -> dict:
|
||||
"""The run form's starting values: blank, or those of an earlier run
|
||||
("Run again"). Declared variables fill their own fields; any other extra
|
||||
var goes back into the free-form box. Secret values were never stored."""
|
||||
if run is None:
|
||||
return {"scope": "", "values": {}, "extra": "", "limit": "", "tags": "", "check": False}
|
||||
params = run.params or {}
|
||||
extra = params.get("extra_vars_map") or {}
|
||||
return {"scope": run.inventory_scope,
|
||||
"values": {k: v for k, v in extra.items() if k in declared},
|
||||
"extra": "\n".join(f"{k}={v}" for k, v in extra.items() if k not in declared),
|
||||
"limit": params.get("limit") or "", "tags": params.get("tags") or "",
|
||||
"check": bool(params.get("check"))}
|
||||
|
||||
|
||||
def _caller_role() -> UserRole:
|
||||
@@ -331,38 +378,6 @@ async def playbook_vars():
|
||||
confirm=meta["confirm"], schedule=schedule)
|
||||
|
||||
|
||||
@ansible_bp.get("/run-form/<source_name>/<path:playbook_path>")
|
||||
@require_role(UserRole.operator)
|
||||
async def run_form(source_name: str, playbook_path: str):
|
||||
"""HTMX fragment: the run form for one playbook, with a field per declared
|
||||
variable (vars/vars_prompt) discovered from the playbook itself."""
|
||||
from steward.models.ansible_inventory import AnsibleTarget, AnsibleGroup
|
||||
|
||||
source = next((s for s in _get_sources() if s["name"] == source_name), None)
|
||||
if source is None:
|
||||
return "Source not found", 404
|
||||
contents = src_module.read_playbook(source["path"], playbook_path)
|
||||
if contents is None:
|
||||
return "Playbook not found", 404
|
||||
variables = src_module.discover_playbook_variables(contents)
|
||||
meta = src_module.discover_playbook_meta(contents)
|
||||
inventories = src_module.discover_inventories(source["path"])
|
||||
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
targets = (await db.execute(
|
||||
select(AnsibleTarget).order_by(AnsibleTarget.name))).scalars().all()
|
||||
groups = (await db.execute(
|
||||
select(AnsibleGroup).order_by(AnsibleGroup.name))).scalars().all()
|
||||
|
||||
return await render_template(
|
||||
"ansible/_run_form.html",
|
||||
source_name=source_name, playbook_path=playbook_path,
|
||||
variables=variables, description=meta["description"], category=meta["category"],
|
||||
confirm=meta["confirm"], targets=targets, groups=groups,
|
||||
inventories=inventories,
|
||||
)
|
||||
|
||||
|
||||
@ansible_bp.post("/runs")
|
||||
@require_role(UserRole.operator)
|
||||
async def create_run():
|
||||
@@ -803,7 +818,7 @@ async def save_playbook():
|
||||
"ansible_playbook", f"{source_name}:{playbook_path}",
|
||||
{"source": source_name, "playbook": playbook_path, "bytes": len(content)})
|
||||
return redirect(url_for(
|
||||
"ansible.view_playbook", source_name=source_name, playbook_path=playbook_path))
|
||||
"ansible.playbook_page", source_name=source_name, playbook_path=playbook_path))
|
||||
|
||||
|
||||
@ansible_bp.post("/playbooks/delete")
|
||||
@@ -819,7 +834,7 @@ async def delete_playbook():
|
||||
return err, 400
|
||||
await _audit("ansible.playbook.deleted", "ansible_playbook", f"{source['name']}:{playbook_path}",
|
||||
{"source": source["name"], "playbook": playbook_path})
|
||||
return redirect(url_for("ansible.browse"))
|
||||
return redirect(url_for("ansible.playbooks"))
|
||||
|
||||
|
||||
_NEW_PLAYBOOK_TEMPLATE = """\
|
||||
|
||||
@@ -375,6 +375,17 @@ a.stat-tile:hover { border-color: var(--border-mid); background: var(--bg-elevat
|
||||
color: var(--text-muted); max-height: 600px; overflow-y: auto; white-space: pre-wrap; }
|
||||
.run-failures { padding: 0.75rem; border-radius: var(--fs-radius-sm); color: var(--red); max-height: 240px; }
|
||||
.run-log-status { margin: 0; padding: 0.5rem 1rem; }
|
||||
/* A playbook's run form (ansible/_run_form.html): options folded away, the
|
||||
dry-run choice beside the button. */
|
||||
.run-more { margin: 0 0 0.75rem; }
|
||||
.run-more > summary { cursor: pointer; font-size: 0.85rem; color: var(--text-muted); margin-bottom: 0.6rem; }
|
||||
.field-check { display: inline-flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; font-weight: normal; color: var(--text-muted); }
|
||||
.form-actions .field-check { margin: 0; }
|
||||
.playbook-cell { max-width: 30rem; }
|
||||
/* The playbook itself, folded under its page. */
|
||||
.playbook-source > summary { cursor: pointer; margin: 0; }
|
||||
.playbook-source[open] > summary { margin-bottom: 0.75rem; }
|
||||
.card-flush-note { margin: 0; padding: 0.75rem 1rem; }
|
||||
/* Monospaced data: ids, limits, tags, paths. */
|
||||
.mono { font-family: var(--fs-font-mono); font-size: 0.85em; }
|
||||
/* The host page's health header (_host_vitals.html). */
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{# ansible/_macros.html — small phrasings the Ansible pages share. #}
|
||||
|
||||
{# A schedule's interval in words: "daily", "hourly", "every 6h". #}
|
||||
{% macro interval_words(secs) -%}
|
||||
{%- set named = {300: "every 5 min", 3600: "hourly", 21600: "every 6h", 43200: "every 12h", 86400: "daily", 604800: "weekly"} -%}
|
||||
{{ named.get(secs) or ("every " ~ (secs|duration)) }}
|
||||
{%- endmacro %}
|
||||
|
||||
{# A run's or host's recap parts (ansible.recap) as one line. #}
|
||||
{% macro recap_words(parts) -%}
|
||||
{% for text, tone in parts %}{% if not loop.first %} · {% endif %}{% if tone %}<span class="tone-{{ tone }}">{{ text }}</span>{% else %}{{ text }}{% endif %}{% endfor %}
|
||||
{%- endmacro %}
|
||||
@@ -1,67 +1,62 @@
|
||||
{# Run form for a single playbook — loaded via HTMX from /ansible/run-form/… #}
|
||||
<div style="background:var(--bg-elevated);border-radius:6px;padding:1rem;border:1px solid var(--border-mid);">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.75rem;">
|
||||
<h4 class="section-title" style="margin-bottom:0;">Run <span style="font-family:ui-monospace,monospace;font-weight:normal;">{{ playbook_path }}</span></h4>
|
||||
<a href="#" onclick="this.closest('div[id^=run-form-]').innerHTML='';return false;"
|
||||
class="btn btn-ghost btn-sm">Cancel</a>
|
||||
</div>
|
||||
<form hx-post="/ansible/runs" hx-target="next .run-result" hx-swap="innerHTML">
|
||||
{# ansible/_run_form.html — the form that runs one playbook, on its page
|
||||
(#5331): where to run it, its declared variables, more options folded
|
||||
away, and the dry-run choice beside the button. `prefill` holds an earlier
|
||||
run's choices ("Run again"); the run starts in place, under the form. #}
|
||||
<form id="run-form" class="run-form" hx-post="/ansible/runs" hx-target="#run-result" hx-swap="innerHTML">
|
||||
<input type="hidden" name="source_name" value="{{ source_name }}">
|
||||
<input type="hidden" name="playbook_path" value="{{ playbook_path }}">
|
||||
|
||||
<div class="form-group">
|
||||
<label>Run against</label>
|
||||
<select name="inventory_scope">
|
||||
<label for="run-scope">Run against</label>
|
||||
<select id="run-scope" name="inventory_scope">
|
||||
{% if targets %}
|
||||
<option value="steward:all">All targets ({{ targets | length }})</option>
|
||||
<option value="steward:all"{% if prefill.scope == 'steward:all' %} selected{% endif %}>All targets ({{ targets | length }})</option>
|
||||
{% if groups %}
|
||||
<optgroup label="Group">
|
||||
{% for grp in groups %}<option value="steward:group:{{ grp.id }}">Group: {{ grp.name }}</option>{% endfor %}
|
||||
{% for grp in groups %}{% set v = "steward:group:" ~ grp.id %}<option value="{{ v }}"{% if prefill.scope == v %} selected{% endif %}>Group: {{ grp.name }}</option>{% endfor %}
|
||||
</optgroup>
|
||||
{% endif %}
|
||||
<optgroup label="Single target">
|
||||
{% for tgt in targets %}<option value="steward:target:{{ tgt.id }}">{{ tgt.name }}</option>{% endfor %}
|
||||
{% for tgt in targets %}{% set v = "steward:target:" ~ tgt.id %}<option value="{{ v }}"{% if prefill.scope == v %} selected{% endif %}>{{ tgt.name }}</option>{% endfor %}
|
||||
</optgroup>
|
||||
{% endif %}
|
||||
{% if inventories %}
|
||||
<optgroup label="Repo inventory file">
|
||||
{% for inv in inventories %}<option value="repo:{{ source_name }}:{{ inv }}">{{ source_name }}/{{ inv }}</option>{% endfor %}
|
||||
<optgroup label="Inventory file in {{ source_name }}">
|
||||
{% for inv in inventories %}{% set v = "repo:" ~ source_name ~ ":" ~ inv %}<option value="{{ v }}"{% if prefill.scope == v %} selected{% endif %}>{{ source_name }}/{{ inv }}</option>{% endfor %}
|
||||
</optgroup>
|
||||
{% endif %}
|
||||
{% if not targets and not inventories %}
|
||||
<option value="" disabled>No targets or inventory files — add targets at /ansible/inventory/targets</option>
|
||||
<option value="" disabled selected>Nothing to run against yet: add a target under Inventory</option>
|
||||
{% endif %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{% include "ansible/_playbook_vars.html" %}
|
||||
{% with description="", category="", values=prefill["values"] %}{% include "ansible/_playbook_vars.html" %}{% endwith %}
|
||||
|
||||
<details style="margin:0.25rem 0 0.75rem;">
|
||||
<summary style="cursor:pointer;font-size:0.82rem;color:var(--text-muted);">Advanced</summary>
|
||||
<div class="form-group" style="margin-top:0.6rem;">
|
||||
<label>Extra vars <span style="color:var(--text-muted);font-weight:normal;">(one key=value per line)</span></label>
|
||||
<textarea name="extra_vars" rows="2" placeholder="custom_var=value"
|
||||
style="width:100%;font-family:ui-monospace,monospace;font-size:0.85rem;"></textarea>
|
||||
</div>
|
||||
<details class="run-more"{% if prefill.extra or prefill.limit or prefill.tags %} open{% endif %}>
|
||||
<summary>More options</summary>
|
||||
<div class="form-group">
|
||||
<label>Limit to host(s) <span style="color:var(--text-muted);font-weight:normal;">(--limit)</span></label>
|
||||
<input type="text" name="limit" placeholder="web01,db*">
|
||||
<label for="run-extra">Extra vars <span class="field-hint">one key=value per line</span></label>
|
||||
<textarea id="run-extra" name="extra_vars" rows="2" class="mono" placeholder="custom_var=value">{{ prefill.extra }}</textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Tags <span style="color:var(--text-muted);font-weight:normal;">(--tags)</span></label>
|
||||
<input type="text" name="tags" placeholder="deploy,config">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="display:flex;align-items:center;gap:0.5rem;font-weight:normal;">
|
||||
<input type="checkbox" name="check">
|
||||
Dry-run (--check --diff) — preview changes without applying
|
||||
</label>
|
||||
<div class="form-pair">
|
||||
<div class="form-group">
|
||||
<label for="run-limit">Only these hosts <span class="field-hint">--limit</span></label>
|
||||
<input id="run-limit" type="text" name="limit" value="{{ prefill.limit }}" placeholder="web01,db*">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="run-tags">Only these tags <span class="field-hint">--tags</span></label>
|
||||
<input id="run-tags" type="text" name="tags" value="{{ prefill.tags }}" placeholder="deploy,config">
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div style="display:flex;gap:0.75rem;align-items:center;">
|
||||
<button type="submit" class="btn">Execute</button>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn">Run</button>
|
||||
<label class="field-check">
|
||||
<input type="checkbox" name="check"{% if prefill.check %} checked{% endif %}>
|
||||
Dry run: show what would change, change nothing
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
<div class="run-result" style="margin-top:1rem;"></div>
|
||||
</div>
|
||||
</form>
|
||||
<div id="run-result"></div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{# ansible/_runs_page.html — one page of runs, then the sentinel that loads the
|
||||
next page as the reader nears it (core.paging, pref 172). The whole row
|
||||
opens the run (.row-link); its title is the one real link. Under the status,
|
||||
what the run did across its hosts (ansible.recap.run_recap). #}
|
||||
what the run did across its hosts (ansible.recap.run_recap). `compact` (a
|
||||
playbook's recent runs) ends without the list's end marker. #}
|
||||
{% from "_macros.html" import status_badge, scroll_more, scroll_end %}
|
||||
{% for row in runs %}
|
||||
{% set run = row.run %}
|
||||
@@ -23,6 +24,6 @@
|
||||
{% endfor %}
|
||||
{% if more_url %}
|
||||
{{ scroll_more(more_url, cols=5) }}
|
||||
{% else %}
|
||||
{% elif not compact %}
|
||||
{{ scroll_end(cols=5) }}
|
||||
{% endif %}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
{% if not runs %}
|
||||
<div class="card">
|
||||
{%- if filtered %}{{ empty_state("No runs match these filters.", "Show every run", "/ansible/") }}
|
||||
{%- else %}{{ empty_state("No runs yet.", "Run a playbook", "/ansible/browse") }}{% endif -%}
|
||||
{%- else %}{{ empty_state("No runs yet.", "Run a playbook", "/ansible/playbooks") }}{% endif -%}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card-flush">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
on every Ansible page; `active` names the page or the one it sits under. #}
|
||||
{% from "_macros.html" import section_tabs %}
|
||||
{% macro ansible_tabs(active) -%}
|
||||
{{ section_tabs([("runs", "Runs", "/ansible/"), ("playbooks", "Playbooks", "/ansible/browse"),
|
||||
{{ section_tabs([("runs", "Runs", "/ansible/"), ("playbooks", "Playbooks", "/ansible/playbooks"),
|
||||
("schedules", "Schedules", "/ansible/schedules"),
|
||||
("inventory", "Inventory", "/ansible/inventory/targets")], active, "Ansible") }}
|
||||
{%- endmacro %}
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "ansible/_tabs.html" import ansible_tabs %}
|
||||
{% from "_macros.html" import crumbs %}
|
||||
{% block title %}Browse Playbooks — Steward{% endblock %}
|
||||
{% block breadcrumb %}
|
||||
{%- if view_contents is defined -%}
|
||||
{{ crumbs([("Ansible", "/ansible/"), ("Browse", "/ansible/browse"), (view_path, "")]) }}
|
||||
{%- else -%}
|
||||
{{ crumbs([("Ansible", "/ansible/"), ("Browse", "")]) }}
|
||||
{%- endif -%}
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
{{ ansible_tabs("playbooks") }}
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;">
|
||||
<h1 class="page-title" style="margin-bottom:0;">Browse Playbooks</h1>
|
||||
<div style="display:flex;gap:0.5rem;">
|
||||
{% if session.user_role == 'admin' %}
|
||||
<a href="/ansible/playbooks/new" class="btn btn-sm">New playbook</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if view_contents is defined %}
|
||||
<div class="card">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem;">
|
||||
<h3 class="section-title">{{ view_path }}</h3>
|
||||
<div style="display:flex;gap:0.5rem;">
|
||||
{% if view_editable and session.user_role == 'admin' %}
|
||||
<a href="/ansible/playbooks/edit/{{ view_source }}/{{ view_path }}" class="btn btn-sm">Edit</a>
|
||||
<form method="post" action="/ansible/playbooks/delete" style="display:inline;"
|
||||
data-confirm="Delete {{ view_path }}?">{{ csrf_field() }}
|
||||
<input type="hidden" name="source_name" value="{{ view_source }}">
|
||||
<input type="hidden" name="playbook_path" value="{{ view_path }}">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<pre style="background:var(--bg);padding:1rem;border-radius:4px;overflow-x:auto;font-size:0.8rem;color:var(--text-muted);max-height:600px;overflow-y:auto;">{{ view_contents }}</pre>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not source_data %}
|
||||
{% if view_contents is not defined %}
|
||||
<div class="card">
|
||||
<p style="color:var(--text-muted);">No Ansible sources configured. <a href="/settings/ansible/">Add sources in Settings → Ansible</a>.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% for sd in source_data %}
|
||||
<div class="card" style="margin-bottom:1.5rem;">
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:1rem;">
|
||||
<h3 class="section-title">{{ sd.source.name }}</h3>
|
||||
<span style="color:var(--text-muted);font-size:0.8rem;background:var(--bg-elevated);padding:0.2rem 0.5rem;border-radius:3px;">{{ sd.source.type }}</span>
|
||||
<span style="color:var(--text-dim);font-size:0.8rem;">{{ sd.source.path }}</span>
|
||||
</div>
|
||||
|
||||
{% if not sd.playbooks %}
|
||||
<p style="color:var(--text-dim);font-size:0.9rem;">No playbooks found at this path.</p>
|
||||
{% else %}
|
||||
<table class="table" style="margin-bottom:1rem;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Playbook</th>
|
||||
<th style="width:120px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for pb in sd.playbooks %}
|
||||
<tr>
|
||||
<td>
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap;">
|
||||
{% if pb.category %}<span style="font-size:0.68rem;text-transform:uppercase;letter-spacing:0.04em;background:var(--bg-elevated);color:var(--text-dim);border-radius:3px;padding:0.05em 0.45em;">{{ pb.category }}</span>{% endif %}
|
||||
<span style="font-family:ui-monospace,monospace;font-size:0.88rem;">{{ pb.path }}</span>
|
||||
</div>
|
||||
{% if pb.description %}<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.15rem;">{{ pb.description }}</div>{% endif %}
|
||||
</td>
|
||||
<td class="td-actions">
|
||||
<a href="/ansible/browse/{{ sd.source.name }}/{{ pb.path }}" class="btn btn-sm btn-ghost" style="margin-right:0.5rem;">View</a>
|
||||
{% if sd.editable and session.user_role == 'admin' %}
|
||||
<a href="/ansible/playbooks/edit/{{ sd.source.name }}/{{ pb.path }}" class="btn btn-sm btn-ghost" style="margin-right:0.5rem;">Edit</a>
|
||||
{% endif %}
|
||||
<a hx-get="/ansible/run-form/{{ sd.source.name }}/{{ pb.path }}"
|
||||
hx-target="#run-form-{{ sd.source.name }}" hx-swap="innerHTML"
|
||||
style="cursor:pointer;" class="btn btn-sm btn-ghost">Run</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{# Run form (loaded on demand via HTMX from /ansible/run-form/<source>/<pb>) #}
|
||||
<div id="run-form-{{ sd.source.name }}"></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -8,7 +8,7 @@
|
||||
{% block content %}
|
||||
{{ ansible_tabs("runs") }}
|
||||
{% call page_header("Ansible Runs") %}
|
||||
<a href="/ansible/browse" class="btn">Run a playbook</a>
|
||||
<a href="/ansible/playbooks" class="btn">Run a playbook</a>
|
||||
{% endcall %}
|
||||
|
||||
{% set v = verdict %}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
{# ansible/playbook.html — one playbook's page (#5331): what it says it does
|
||||
and how it last ran, the form that runs it (operators), the schedules that
|
||||
use it, its recent runs, then the playbook itself, folded. #}
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import crumbs, page_header, status_badge %}
|
||||
{% from "ansible/_tabs.html" import ansible_tabs %}
|
||||
{% from "ansible/_macros.html" import interval_words %}
|
||||
{% block title %}{{ playbook_path }} — Steward{% endblock %}
|
||||
{% block breadcrumb %}{{ crumbs([("Ansible", "/ansible/"), ("Playbooks", "/ansible/playbooks"), (playbook_path, "")]) }}{% endblock %}
|
||||
{% block content %}
|
||||
{% set can_run = session.user_role in ('operator', 'admin') %}
|
||||
{{ ansible_tabs("playbooks") }}
|
||||
{% call page_header(playbook_path) %}
|
||||
{% if editable and session.user_role == 'admin' %}
|
||||
<a href="/ansible/playbooks/edit/{{ source_name }}/{{ playbook_path }}" class="btn btn-sm btn-ghost">Edit</a>
|
||||
<form method="post" action="/ansible/playbooks/delete"
|
||||
data-confirm="Delete {{ playbook_path }}? The file is removed from {{ source_name }}; its past runs stay in the list.">{{ csrf_field() }}
|
||||
<input type="hidden" name="source_name" value="{{ source_name }}">
|
||||
<input type="hidden" name="playbook_path" value="{{ playbook_path }}">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endcall %}
|
||||
|
||||
<p class="page-verdict">
|
||||
{%- if category %}<span class="chip">{{ category }}</span> {% endif -%}
|
||||
{{ description or "It says nothing about what it does." }}
|
||||
{%- if confirm %} · <span class="tone-warn">flagged destructive by its author: a real run asks you to confirm</span>{% endif %}
|
||||
· from <span class="mono">{{ source_name }}</span>
|
||||
{%- if runs %} · last ran {{ runs[0].run.started_at|ago }} {{ status_badge(runs[0].run.status) }}{% else %} · never run{% endif -%}
|
||||
</p>
|
||||
|
||||
{% if can_run %}
|
||||
<section class="card" aria-labelledby="pb-run">
|
||||
<h2 id="pb-run" class="section-title">Run it</h2>
|
||||
{% include "ansible/_run_form.html" %}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="card-flush" aria-labelledby="pb-schedules">
|
||||
<h2 id="pb-schedules" class="table-caption">Schedules
|
||||
{%- if schedules %} <span class="tone-muted">{{ schedules|length }}</span>{% endif %}
|
||||
{%- if can_run %} <a class="field-hint" href="/ansible/schedules">Schedule it →</a>{% endif %}</h2>
|
||||
{% if schedules %}
|
||||
<table class="table">
|
||||
<tbody>
|
||||
{% for row in schedules %}{% set s = row.s %}
|
||||
<tr class="row-link{% if not s.enabled %} row-off{% endif %}">
|
||||
<td class="cell-name"><a class="row-target" href="/ansible/schedules?edit={{ s.id }}#form">{{ s.name }}</a></td>
|
||||
<td>on {{ row.scope_label }} · {{ interval_words(s.interval_seconds) }}</td>
|
||||
<td class="nowrap">{% if not s.enabled %}<span class="tone-dim">paused</span>
|
||||
{%- elif row.next and row.next.at %}next {{ row.next.at|ago }}{% else %}due now{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="field-hint card-flush-note">No schedule runs it.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="card-flush" aria-labelledby="pb-runs">
|
||||
<h2 id="pb-runs" class="table-caption">Recent runs
|
||||
{%- if runs %} <a class="field-hint" href="/ansible/?q={{ playbook_path|urlencode }}">All its runs →</a>{% endif %}</h2>
|
||||
{% if runs %}
|
||||
<table class="table">
|
||||
<thead><tr><th>Run</th><th>Result</th><th>Started by</th><th>Started</th><th class="num">Took</th></tr></thead>
|
||||
<tbody>
|
||||
{% with compact=true, more_url=none %}{% include "ansible/_runs_page.html" %}{% endwith %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="field-hint card-flush-note">It has not been run yet.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<details class="card playbook-source">
|
||||
<summary class="section-title">The playbook</summary>
|
||||
<pre class="run-log">{{ contents }}</pre>
|
||||
</details>
|
||||
{% endblock %}
|
||||
@@ -2,7 +2,7 @@
|
||||
{% from "ansible/_tabs.html" import ansible_tabs %}
|
||||
{% from "_macros.html" import crumbs %}
|
||||
{% block title %}{{ "Edit" if editing else "New" }} Playbook — Steward{% endblock %}
|
||||
{% block breadcrumb %}{{ crumbs([("Ansible", "/ansible/"), ("Browse", "/ansible/browse"), (("Edit " ~ playbook_path) if editing else "New playbook", "")]) }}{% endblock %}
|
||||
{% block breadcrumb %}{{ crumbs([("Ansible", "/ansible/"), ("Playbooks", "/ansible/playbooks"), (("Edit " ~ playbook_path) if editing else "New playbook", "")]) }}{% endblock %}
|
||||
{% block content %}
|
||||
{{ ansible_tabs("playbooks") }}
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.25rem;gap:1rem;flex-wrap:wrap;">
|
||||
@@ -50,7 +50,7 @@
|
||||
</div>
|
||||
<div style="display:flex;gap:0.5rem;">
|
||||
<button type="submit" class="btn">Save</button>
|
||||
<a href="/ansible/browse" class="btn btn-ghost">Cancel</a>
|
||||
<a href="{{ ('/ansible/playbooks/' ~ source_name ~ '/' ~ playbook_path) if editing else '/ansible/playbooks' }}" class="btn btn-ghost">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
{# ansible/playbooks.html — the Playbooks tab (#5331): every playbook across
|
||||
its sources in one list, what each says it does and how it last ran; the
|
||||
whole row opens the playbook's page, where it is run. A source with no
|
||||
playbooks is one quiet line under the list. #}
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import crumbs, page_header, status_badge, empty_state %}
|
||||
{% from "ansible/_tabs.html" import ansible_tabs %}
|
||||
{% block title %}Playbooks — Steward{% endblock %}
|
||||
{% block breadcrumb %}{{ crumbs([("Ansible", "/ansible/"), ("Playbooks", "")]) }}{% endblock %}
|
||||
{% block content %}
|
||||
{{ ansible_tabs("playbooks") }}
|
||||
{% call page_header("Playbooks") %}
|
||||
{% if can_add %}<a href="/ansible/playbooks/new" class="btn">New playbook</a>{% endif %}
|
||||
{% endcall %}
|
||||
|
||||
{% if not sources %}
|
||||
<div class="card">{{ empty_state("No playbook sources are set up.", "Add one in Settings", "/settings/ansible/") }}</div>
|
||||
{% else %}
|
||||
{% set n_src = rows|map(attribute="source")|unique|list|length %}
|
||||
{% set n_bad = rows|selectattr("confirm")|list|length %}
|
||||
<p class="page-verdict">
|
||||
{{- rows|length }} playbook{{ 's' if rows|length != 1 }}{% if n_src %} from {{ n_src }} source{{ 's' if n_src != 1 }}{% endif %}
|
||||
{%- if n_bad %} · <span class="tone-warn">{{ n_bad }} flagged destructive by {{ 'their authors' if n_bad > 1 else 'its author' }}</span>{% endif -%}
|
||||
</p>
|
||||
|
||||
{% if rows %}
|
||||
<div class="card-flush">
|
||||
<table class="table">
|
||||
<thead><tr><th>Playbook</th><th>Source</th><th>Last run</th></tr></thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
<tr class="row-link">
|
||||
<td class="cell-name playbook-cell">
|
||||
<a class="row-target mono" href="/ansible/playbooks/{{ r.source }}/{{ r.path }}" title="{{ r.path }}">{{ r.path }}</a>
|
||||
{% if r.description %}<div class="cell-sub" title="{{ r.description }}">{{ r.description }}</div>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="tone-muted">{{ r.source }}</span>
|
||||
{%- if r.category %} <span class="chip">{{ r.category }}</span>{% endif %}
|
||||
{%- if r.confirm %} {{ status_badge('warn', 'destructive', 'Its author flagged it as making significant or destructive changes: a real run asks for confirmation') }}{% endif %}
|
||||
</td>
|
||||
<td class="nowrap">
|
||||
{%- if r.last %}{{ status_badge(r.last.status) }} <span class="tone-muted">{{ r.last.started_at|ago }}</span>
|
||||
{%- else %}<span class="tone-dim">never run</span>{% endif -%}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% for s in empty_sources %}
|
||||
<p class="field-hint">{{ s.name }} (<span class="mono">{{ s.path }}</span>) has no playbooks yet.
|
||||
{%- if can_add and s.editable %} <a href="/ansible/playbooks/new">Write one</a>{% endif %}</p>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -17,6 +17,9 @@
|
||||
<button type="submit" class="btn btn-sm btn-danger">Cancel run</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if session.user_role in ("operator", "admin") %}
|
||||
<a href="/ansible/playbooks/{{ run.source_name }}/{{ run.playbook_path }}?from={{ run.id }}#run-form" class="btn btn-sm btn-ghost">Run again</a>
|
||||
{% endif %}
|
||||
<a href="/ansible/runs/{{ run.id }}/log" class="btn btn-sm btn-ghost">Download log</a>
|
||||
{% endcall %}
|
||||
|
||||
@@ -31,7 +34,7 @@
|
||||
|
||||
<div class="card">
|
||||
<dl class="kv">
|
||||
<dt>Playbook</dt><dd><a href="/ansible/browse/{{ run.source_name }}/{{ run.playbook_path }}">{{ run.playbook_path }}</a> <span class="tone-dim">from {{ run.source_name }}</span></dd>
|
||||
<dt>Playbook</dt><dd><a href="/ansible/playbooks/{{ run.source_name }}/{{ run.playbook_path }}">{{ run.playbook_path }}</a> <span class="tone-dim">from {{ run.source_name }}</span></dd>
|
||||
<dt>On</dt><dd>{{ row.scope_label }}</dd>
|
||||
{% if run.params %}
|
||||
{% if run.params.limit %}<dt>Limit</dt><dd class="mono">{{ run.params.limit }}</dd>{% endif %}
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:0.4rem;flex-shrink:0;align-items:center;">
|
||||
<a href="/ansible/browse" class="btn btn-ghost btn-sm" style="font-size:0.78rem;">Browse</a>
|
||||
<a href="/ansible/playbooks" class="btn btn-ghost btn-sm" style="font-size:0.78rem;">Playbooks</a>
|
||||
|
||||
{% if src.type == 'git' %}
|
||||
<span id="ansible-sync-status-{{ idx }}"></span>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""The Playbooks tab and a playbook's own page (#5331): one list across
|
||||
sources whose row opens the playbook; the page carries the form that runs
|
||||
it (filled from an earlier run for "Run again"), the schedules that use it
|
||||
and its recent runs."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
from quart import render_template, session
|
||||
|
||||
from steward.ansible.routes import run_prefill, run_view
|
||||
from steward.models.ansible import AnsibleRunStatus
|
||||
from tests.test_style_tokens import hex_colours
|
||||
|
||||
|
||||
def _run(rid, status, mins_ago, params=None):
|
||||
now = datetime.now(timezone.utc)
|
||||
return SimpleNamespace(id=rid, playbook_path="maintenance/prune.yml", source_name="steward-builtin",
|
||||
inventory_scope="steward:all", status=status,
|
||||
started_at=now - timedelta(minutes=mins_ago),
|
||||
finished_at=now - timedelta(minutes=mins_ago - 1), params=params,
|
||||
results={"hosts": {"a": {"ok": 2, "changed": 1}}}, output="")
|
||||
|
||||
|
||||
def test_playbook_urls_reach_their_own_routes(app):
|
||||
"""The playbook page's path converter must not swallow the editor's URLs."""
|
||||
urls = app.url_map.bind("localhost")
|
||||
assert urls.match("/ansible/playbooks/steward-builtin/maintenance/prune.yml")[0] == "ansible.playbook_page"
|
||||
assert urls.match("/ansible/playbooks/edit/steward-local/a/b.yml")[0] == "ansible.edit_playbook"
|
||||
assert urls.match("/ansible/playbooks/new")[0] == "ansible.new_playbook"
|
||||
assert urls.match("/ansible/playbooks")[0] == "ansible.playbooks"
|
||||
import pytest
|
||||
from werkzeug.exceptions import NotFound
|
||||
with pytest.raises(NotFound):
|
||||
urls.match("/ansible/browse") # moved, not redirected (rule 122)
|
||||
|
||||
|
||||
async def test_the_playbooks_list_is_one_table_across_sources(app):
|
||||
last = _run("r1", AnsibleRunStatus.success, 60)
|
||||
rows = [{"source": "steward-builtin", "path": "maintenance/prune.yml", "description": "Reclaim disk",
|
||||
"category": "maintenance", "confirm": True, "last": last},
|
||||
{"source": "steward-builtin", "path": "host_agent/update.yml", "description": "Update the agent",
|
||||
"category": "", "confirm": False, "last": None}]
|
||||
empty = [{"name": "steward-local", "path": "/data/ansible/playbooks", "editable": True}]
|
||||
async with app.test_request_context("/ansible/playbooks"):
|
||||
session["user_role"] = "admin"
|
||||
page = await render_template("ansible/playbooks.html", rows=rows, empty_sources=empty,
|
||||
sources=[{"name": "steward-builtin"}, {"name": "steward-local"}],
|
||||
can_add=True)
|
||||
main = page[page.index("<main"):]
|
||||
assert main.count('<tr class="row-link">') == 2
|
||||
assert 'class="row-target mono" href="/ansible/playbooks/steward-builtin/maintenance/prune.yml"' in main
|
||||
assert "2 playbooks from 1 source" in main and "1 flagged destructive by its author" in main
|
||||
assert ">destructive</span>" in main and '<span class="chip">maintenance</span>' in main
|
||||
assert '<span class="badge badge-ok">Succeeded</span>' in main and "never run" in main
|
||||
assert "steward-local" in main and "has no playbooks yet." in main and 'href="/ansible/playbooks/new"' in main
|
||||
assert '<a href="/ansible/playbooks" aria-current="page">Playbooks</a>' in main
|
||||
assert "/run-form/" not in main # the form lives on the playbook's page
|
||||
assert not hex_colours(main), hex_colours(main)
|
||||
|
||||
|
||||
def _page_ctx(role_runs=True, prefill=None, schedules=None):
|
||||
now = datetime.now(timezone.utc)
|
||||
run = _run("r9", AnsibleRunStatus.failed, 30, params={"check": True})
|
||||
return {
|
||||
"source": {"name": "steward-builtin"}, "source_name": "steward-builtin",
|
||||
"playbook_path": "maintenance/prune.yml", "contents": "- hosts: all\n",
|
||||
"description": "Reclaim disk", "category": "maintenance", "confirm": True,
|
||||
"variables": [{"name": "prune_volumes", "prompt": "", "default": "false", "required": False, "secret": False}],
|
||||
"editable": False,
|
||||
"runs": [{**run_view(run, {"steward:all": "all inventory targets"}, now),
|
||||
"origin": {"text": "alice", "url": None, "kind": "user"}}] if role_runs else [],
|
||||
"schedules": schedules or [], "targets": [SimpleNamespace(id="t1", name="web-1")],
|
||||
"groups": [SimpleNamespace(id="g1", name="Docker Swarm")], "inventories": [],
|
||||
"prefill": prefill or run_prefill(None, set()),
|
||||
}
|
||||
|
||||
|
||||
async def test_a_playbook_page_runs_it_and_shows_its_history(app):
|
||||
now = datetime.now(timezone.utc)
|
||||
sched = SimpleNamespace(id="s1", name="Nightly prune", enabled=True, interval_seconds=86400,
|
||||
last_run_at=now - timedelta(hours=22), inventory_scope="steward:group:g1")
|
||||
async with app.test_request_context("/ansible/playbooks/steward-builtin/maintenance/prune.yml"):
|
||||
session["user_role"] = "operator"
|
||||
page = await render_template("ansible/playbook.html", **_page_ctx(schedules=[
|
||||
{"s": sched, "scope_label": "group Docker Swarm",
|
||||
"next": {"name": "Nightly prune", "at": now + timedelta(hours=2)}}]))
|
||||
main = page[page.index("<main"):]
|
||||
assert 'href="/ansible/playbooks"' in page # crumbs back to the list
|
||||
verdict = main[main.index('<p class="page-verdict">'):]
|
||||
verdict = verdict[:verdict.index("</p>")]
|
||||
assert "Reclaim disk" in verdict and "flagged destructive" in verdict and "last ran" in verdict
|
||||
assert 'id="run-form"' in main and 'hx-post="/ansible/runs"' in main
|
||||
assert 'name="confirmed"' in main # the destructive flag's gate
|
||||
assert 'name="var__prune_volumes"' in main and 'name="check"' in main
|
||||
assert "Nightly prune" in main and "on group Docker Swarm · daily" in main
|
||||
assert 'href="/ansible/schedules?edit=s1#form"' in main
|
||||
assert 'href="/ansible/runs/r9"' in main and "End of the list." not in main
|
||||
assert 'href="/ansible/?q=maintenance/prune.yml"' in main
|
||||
assert "/ansible/playbooks/edit/" not in main # a built-in playbook is read-only
|
||||
assert not hex_colours(main), hex_colours(main)
|
||||
|
||||
|
||||
async def test_a_viewer_reads_the_playbook_but_gets_no_form(app):
|
||||
async with app.test_request_context("/ansible/playbooks/steward-builtin/maintenance/prune.yml"):
|
||||
session["user_role"] = "viewer"
|
||||
page = await render_template("ansible/playbook.html", **_page_ctx(role_runs=False))
|
||||
assert 'id="run-form"' not in page and "never run" in page and "No schedule runs it." in page
|
||||
|
||||
|
||||
def test_run_again_fills_the_form_from_the_earlier_run():
|
||||
run = SimpleNamespace(inventory_scope="steward:group:g1", params={
|
||||
"extra_vars_map": {"prune_volumes": "true", "other": "x"}, "limit": "web*", "tags": "a", "check": True})
|
||||
assert run_prefill(run, {"prune_volumes"}) == {
|
||||
"scope": "steward:group:g1", "values": {"prune_volumes": "true"}, "extra": "other=x",
|
||||
"limit": "web*", "tags": "a", "check": True}
|
||||
assert run_prefill(None, set())["scope"] == ""
|
||||
|
||||
|
||||
async def test_the_form_comes_back_as_the_earlier_run_left_it(app):
|
||||
prefill = run_prefill(SimpleNamespace(inventory_scope="steward:group:g1", params={
|
||||
"extra_vars_map": {"prune_volumes": "true", "other": "x"}, "limit": "web*", "check": True}), {"prune_volumes"})
|
||||
async with app.test_request_context("/ansible/playbooks/x"):
|
||||
session["user_role"] = "operator"
|
||||
page = await render_template("ansible/playbook.html", **_page_ctx(prefill=prefill))
|
||||
assert '<option value="steward:group:g1" selected>Group: Docker Swarm</option>' in page
|
||||
assert 'name="var__prune_volumes"' in page and 'value="true"' in page
|
||||
assert ">other=x</textarea>" in page and 'value="web*"' in page
|
||||
assert '<details class="run-more" open>' in page and 'name="check" checked' in page
|
||||
@@ -92,7 +92,7 @@ async def test_ansible_pages_share_one_strip_of_tabs(app):
|
||||
page = await render_template("ansible/index.html", **_ctx([]))
|
||||
nav = page[page.index('<nav class="section-tabs" aria-label="Ansible">'):]
|
||||
nav = nav[:nav.index("</nav>")]
|
||||
for href in ("/ansible/", "/ansible/browse", "/ansible/schedules", "/ansible/inventory/targets"):
|
||||
for href in ("/ansible/", "/ansible/playbooks", "/ansible/schedules", "/ansible/inventory/targets"):
|
||||
assert f'href="{href}"' in nav, href
|
||||
assert nav.count('aria-current="page"') == 1 and '<a href="/ansible/" aria-current="page">Runs</a>' in nav
|
||||
assert "No runs yet." in page and "No runs in the last 30 days" in page
|
||||
@@ -180,7 +180,8 @@ async def test_run_detail_titles_the_run_and_keeps_the_trail(app):
|
||||
# Each host's result in words, the failed host first.
|
||||
assert page.index(">zulu<") < page.index(">alpha<")
|
||||
assert '<span class="tone-crit">1 failed</span> · 1 ok' in page and ">3 ok<" in page
|
||||
assert 'href="/ansible/browse/builtin/update.yml"' in page # the playbook it ran
|
||||
assert 'href="/ansible/playbooks/builtin/update.yml"' in page # the playbook it ran
|
||||
assert 'href="/ansible/playbooks/builtin/update.yml?from=dddd4444#run-form">Run again</a>' in page
|
||||
main = page[page.index("<main"):]
|
||||
assert 'style="' not in main[:main.index('id="run-output"')] # no hand-set colours
|
||||
assert not hex_colours(main), hex_colours(main)
|
||||
|
||||
@@ -60,7 +60,12 @@ def test_the_runs_tab_counts_and_filters(app):
|
||||
out = {"page": await get(f"/ansible/?q=it{tag}"),
|
||||
"failed": await get(f"/ansible/?q=it{tag}&status=failed", headers={"HX-Request": "true"}),
|
||||
"sched": await get(f"/ansible/?q=it{tag}&origin=schedule", headers={"HX-Request": "true"}),
|
||||
"person": await get(f"/ansible/?q=it{tag}&origin=manual", headers={"HX-Request": "true"})}
|
||||
"person": await get(f"/ansible/?q=it{tag}&origin=manual", headers={"HX-Request": "true"}),
|
||||
# The Playbooks tab and a built-in playbook's page (#5331): their
|
||||
# newest-run and schedule queries against Postgres.
|
||||
"playbooks": await get("/ansible/playbooks"),
|
||||
"playbook": await get("/ansible/playbooks/steward-builtin/maintenance/docker_prune.yml"),
|
||||
"missing": await get("/ansible/playbooks/steward-builtin/nope.yml")}
|
||||
async with app.db_sessionmaker() as s:
|
||||
async with s.begin():
|
||||
from sqlalchemy import delete
|
||||
@@ -83,3 +88,9 @@ def test_the_runs_tab_counts_and_filters(app):
|
||||
assert f"it{tag}/prune.yml" in sched and f"it{tag}/update.yml" not in sched
|
||||
person = out["person"][1]
|
||||
assert f"it{tag}/update.yml" in person and f"it{tag}/prune.yml" not in person
|
||||
|
||||
status, playbooks = out["playbooks"]
|
||||
assert status == 200 and 'href="/ansible/playbooks/steward-builtin/maintenance/docker_prune.yml"' in playbooks
|
||||
status, playbook = out["playbook"]
|
||||
assert status == 200 and "The playbook" in playbook and 'id="run-form"' not in playbook # a viewer
|
||||
assert out["missing"][0] == 404
|
||||
|
||||
Reference in New Issue
Block a user