diff --git a/plugins/host_agent/templates/settings_list.html b/plugins/host_agent/templates/settings_list.html index 27602f2..bb858ff 100644 --- a/plugins/host_agent/templates/settings_list.html +++ b/plugins/host_agent/templates/settings_list.html @@ -74,7 +74,7 @@

Agent lifecycle via Ansible

These actions run bundled Ansible playbooks to deploy and maintain the - Steward monitoring agent on your inventory hosts. + Steward monitoring agent on your inventory hosts. Steward generates each host's API token automatically and connects over SSH as the managed steward account — you'll be dropped into the live Ansible run to watch it.

@@ -95,7 +95,7 @@ {% elif not (deploy_targets or deploy_groups) %}

- No Ansible inventory targets yet. Add some under Ansible → Inventory. + No Ansible inventory targets yet. Add some under Ansible → Inventory.

{% endif %} diff --git a/steward/ansible/inventory_routes.py b/steward/ansible/inventory_routes.py index ea1d746..4b4bb8d 100644 --- a/steward/ansible/inventory_routes.py +++ b/steward/ansible/inventory_routes.py @@ -11,6 +11,8 @@ from steward.ansible import guards from steward.auth.middleware import require_role from steward.core.audit import audit_request as _audit from steward.models.ansible_inventory import AnsibleGroup, AnsibleTarget +from steward.models.ansible_schedule import AnsibleSchedule +from steward.models.hosts import Host from steward.models.users import UserRole inventory_bp = Blueprint("ansible_inventory", __name__, url_prefix="/ansible/inventory") @@ -38,38 +40,84 @@ def _vars_refusal(new: dict, old: dict | None = None) -> str | None: UserRole(session.get("user_role", "viewer"))) +def _add(kind: str, error: str, values: dict) -> dict: + """An Add dialog to reopen on the Inventory page: which, why, what was typed.""" + return {"kind": kind, "error": error, "values": values} + + +async def _name_taken(model, name: str) -> bool: + async with current_app.db_sessionmaker() as db: + return (await db.execute(select(model.id).where(model.name == name))).first() is not None + + +# --------------------------------------------------------------------------- +# The Inventory tab (#5333): targets and groups on one page +# --------------------------------------------------------------------------- + +async def _scope_users(db) -> dict[str, list[AnsibleSchedule]]: + """{inventory scope: [schedule, …]}: which schedules run against a target + or group, so its page can say what deleting it would leave pointing at + nothing.""" + out: dict[str, list[AnsibleSchedule]] = {} + for sch in (await db.execute(select(AnsibleSchedule).order_by(AnsibleSchedule.name))).scalars(): + out.setdefault(sch.inventory_scope, []).append(sch) + return out + + +@inventory_bp.get("/") +@require_role(UserRole.viewer) +async def inventory(add: dict | None = None, status: int = 200): + """Every target with its address, the Steward host it is linked to and + its groups; then every group with its members. The row opens the target + or group, where it is edited and deleted. `add` reopens an Add dialog + with the error and what was typed (a failed create), or ?add=target|group + opens one empty.""" + async with current_app.db_sessionmaker() as db: + targets = (await db.execute( + select(AnsibleTarget).options(selectinload(AnsibleTarget.groups)) + .order_by(AnsibleTarget.name))).scalars().all() + groups = (await db.execute( + select(AnsibleGroup).options(selectinload(AnsibleGroup.targets)) + .order_by(AnsibleGroup.name))).scalars().all() + host_ids = [t.host_id for t in targets if t.host_id] + hosts = {h.id: h for h in (await db.execute( + select(Host).where(Host.id.in_(host_ids)))).scalars()} if host_ids else {} + users = await _scope_users(db) + if add is None and request.args.get("add") in ("target", "group"): + add = {"kind": request.args["add"], "error": None, "values": {}} + verdict = { + "targets": len(targets), "groups": len(groups), + "unlinked": sum(1 for t in targets if t.host_id not in hosts), + "ungrouped": sum(1 for t in targets if not t.groups), + } + body = await render_template( + "ansible/inventory/index.html", targets=targets, groups=groups, hosts=hosts, + users=users, verdict=verdict, add=add) + return body, status + + # --------------------------------------------------------------------------- # Groups # --------------------------------------------------------------------------- -@inventory_bp.get("/groups") -@require_role(UserRole.viewer) -async def groups_list(): - async with current_app.db_sessionmaker() as db: - result = await db.execute( - select(AnsibleGroup) - .options(selectinload(AnsibleGroup.targets)) - .order_by(AnsibleGroup.name) - ) - groups = result.scalars().all() - return await render_template("ansible/inventory/groups.html", groups=groups) - - @inventory_bp.post("/groups") @require_role(UserRole.operator) async def groups_create(): form = await request.form name = form.get("name", "").strip() + values = {"name": name} if not name: - return "name is required", 400 + return await inventory(_add("group", "A group needs a name.", values), 400) try: ansible_vars = _parse_ansible_vars(form.get("ansible_vars", "")) except ValueError as exc: - return str(exc), 400 + return await inventory(_add("group", str(exc), values), 400) err = _vars_refusal(ansible_vars) if err: - return err, 400 + return await inventory(_add("group", err, values), 400) + if await _name_taken(AnsibleGroup, name): + return await inventory(_add("group", f"There is already a group named {name}.", values), 400) group = AnsibleGroup(id=str(uuid.uuid4()), name=name, ansible_vars=ansible_vars) async with current_app.db_sessionmaker() as db: @@ -77,7 +125,7 @@ async def groups_create(): db.add(group) await _audit("ansible.group.created", "ansible_group", group.id, {"name": name, "vars": sorted(ansible_vars)}) - return redirect(url_for("ansible_inventory.groups_list")) + return redirect(url_for("ansible_inventory.group_detail", group_id=group.id)) @inventory_bp.get("/groups/") @@ -95,11 +143,13 @@ async def group_detail(group_id: str): all_targets = ( await db.execute(select(AnsibleTarget).order_by(AnsibleTarget.name)) ).scalars().all() + used = (await _scope_users(db)).get(f"steward:group:{group.id}", []) member_ids = {t.id for t in group.targets} ansible_vars_yaml = yaml.dump(group.ansible_vars) if group.ansible_vars else "" return await render_template( "ansible/inventory/group_detail.html", group=group, + used=used, all_targets=all_targets, member_ids=member_ids, ansible_vars_yaml=ansible_vars_yaml, @@ -157,46 +207,36 @@ async def group_delete(group_id: str): ) group = result.scalar_one_or_none() if group is None: - return redirect(url_for("ansible_inventory.groups_list")) + return redirect(url_for("ansible_inventory.inventory")) name = group.name await db.delete(group) await _audit("ansible.group.deleted", "ansible_group", group_id, {"name": name}) - return redirect(url_for("ansible_inventory.groups_list")) + return redirect(url_for("ansible_inventory.inventory")) # --------------------------------------------------------------------------- # Targets # --------------------------------------------------------------------------- -@inventory_bp.get("/targets") -@require_role(UserRole.viewer) -async def targets_list(): - async with current_app.db_sessionmaker() as db: - result = await db.execute( - select(AnsibleTarget) - .options(selectinload(AnsibleTarget.groups)) - .order_by(AnsibleTarget.name) - ) - targets = result.scalars().all() - return await render_template("ansible/inventory/targets.html", targets=targets) - - @inventory_bp.post("/targets") @require_role(UserRole.operator) async def targets_create(): form = await request.form name = form.get("name", "").strip() address = form.get("address", "").strip() + values = {"name": name, "address": address} if not name or not address: - return "name and address are required", 400 + return await inventory(_add("target", "A target needs a name and an address.", values), 400) try: ansible_vars = _parse_ansible_vars(form.get("ansible_vars", "")) except ValueError as exc: - return str(exc), 400 + return await inventory(_add("target", str(exc), values), 400) err = _vars_refusal(ansible_vars) if err: - return err, 400 + return await inventory(_add("target", err, values), 400) + if await _name_taken(AnsibleTarget, name): + return await inventory(_add("target", f"There is already a target named {name}.", values), 400) target = AnsibleTarget( id=str(uuid.uuid4()), name=name, address=address, ansible_vars=ansible_vars @@ -206,7 +246,7 @@ async def targets_create(): db.add(target) await _audit("ansible.target.created", "ansible_target", target.id, {"name": name, "address": address, "vars": sorted(ansible_vars)}) - return redirect(url_for("ansible_inventory.targets_list")) + return redirect(url_for("ansible_inventory.target_detail", target_id=target.id)) @inventory_bp.get("/targets/") @@ -224,11 +264,15 @@ async def target_detail(target_id: str): all_groups = ( await db.execute(select(AnsibleGroup).order_by(AnsibleGroup.name)) ).scalars().all() + host = await db.get(Host, target.host_id) if target.host_id else None + used = (await _scope_users(db)).get(f"steward:target:{target.id}", []) member_group_ids = {g.id for g in target.groups} ansible_vars_yaml = yaml.dump(target.ansible_vars) if target.ansible_vars else "" return await render_template( "ansible/inventory/target_detail.html", target=target, + host=host, + used=used, all_groups=all_groups, member_group_ids=member_group_ids, ansible_vars_yaml=ansible_vars_yaml, @@ -287,8 +331,8 @@ async def target_delete(target_id: str): ) target = result.scalar_one_or_none() if target is None: - return redirect(url_for("ansible_inventory.targets_list")) + return redirect(url_for("ansible_inventory.inventory")) name = target.name await db.delete(target) await _audit("ansible.target.deleted", "ansible_target", target_id, {"name": name}) - return redirect(url_for("ansible_inventory.targets_list")) + return redirect(url_for("ansible_inventory.inventory")) diff --git a/steward/static/steward.css b/steward/static/steward.css index fc44125..c93e4db 100644 --- a/steward/static/steward.css +++ b/steward/static/steward.css @@ -216,6 +216,8 @@ strong, b, th { font-weight: 500; } .table tr.row-link { position: relative; cursor: pointer; } .table tr.row-link .row-target::after { content: ""; position: absolute; inset: 0; } .table tr.row-link .td-actions { position: relative; z-index: 1; cursor: auto; } +/* A second link inside a row that opens as a whole: it sits above the row's target. */ +.table tr.row-link .row-inner { position: relative; z-index: 1; } .table td.nowrap { white-space: nowrap; } .cell-note { font-size: 0.78rem; color: var(--text-muted); margin-top: 0.2rem; } /* An alert episode's own transitions, folded under it (#5316). */ @@ -434,6 +436,8 @@ a.stat-tile:hover { border-color: var(--border-mid); background: var(--bg-elevat .form-section > .section-title { margin-bottom: 0.9rem; } .form-pair { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(14rem, 100%), 1fr)); gap: 0 1rem; } .form-pair[hidden] { display: none; } +/* A column of checkboxes: a target's groups, a group's members. */ +.check-list { display: grid; gap: 0.4rem; } .ansible-fields { padding: 0.9rem 1rem 0.25rem; margin-bottom: 0.75rem; border: 1px solid var(--border); border-radius: var(--fs-radius-md); background: var(--bg-elevated); } .form-actions { display: flex; gap: 0.75rem; margin: 0.5rem 0 1.5rem; } .danger-zone { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; diff --git a/steward/templates/ansible/_tabs.html b/steward/templates/ansible/_tabs.html index 601b15c..f89ba33 100644 --- a/steward/templates/ansible/_tabs.html +++ b/steward/templates/ansible/_tabs.html @@ -4,5 +4,5 @@ {% macro ansible_tabs(active) -%} {{ section_tabs([("runs", "Runs", "/ansible/"), ("playbooks", "Playbooks", "/ansible/playbooks"), ("schedules", "Schedules", "/ansible/schedules"), - ("inventory", "Inventory", "/ansible/inventory/targets")], active, "Ansible") }} + ("inventory", "Inventory", "/ansible/inventory/")], active, "Ansible") }} {%- endmacro %} diff --git a/steward/templates/ansible/inventory/group_detail.html b/steward/templates/ansible/inventory/group_detail.html index c5a644e..a5fb922 100644 --- a/steward/templates/ansible/inventory/group_detail.html +++ b/steward/templates/ansible/inventory/group_detail.html @@ -1,53 +1,66 @@ +{# ansible/inventory/group_detail.html — one group (#5333): its members and + the schedules aimed at it, its form (operators), and at the foot Delete + behind the confirm dialog. #} {% extends "base.html" %} +{% from "_macros.html" import crumbs, page_header %} {% from "ansible/_tabs.html" import ansible_tabs %} -{% from "_macros.html" import crumbs %} -{% block title %}Group: {{ group.name }} — Steward{% endblock %} -{% block breadcrumb %}{{ crumbs([("Ansible", "/ansible/"), ("Inventory", "/ansible/inventory/targets"), ("Groups", "/ansible/inventory/groups"), (group.name, "")]) }}{% endblock %} +{% block title %}{{ group.name }} — Inventory — Steward{% endblock %} +{% block breadcrumb %}{{ crumbs([("Ansible", "/ansible/"), ("Inventory", "/ansible/inventory/"), (group.name, "")]) }}{% endblock %} {% block content %} +{% set can_edit = session.user_role in ('operator', 'admin') %} {{ ansible_tabs("inventory") }} -
-

Group: {{ group.name }}

-
+{{ page_header(group.name) }} -
{{ csrf_field() }} -
-

Settings

-
- - -
-
- - -
+

+ {%- set members = group.targets|sort(attribute='name') %} + {%- if members %}{{ members|length }} member{{ 's' if members|length != 1 }}: + {% for t in members %}{{ t.name }}{{ ", " if not loop.last }}{% endfor %} + {%- else %}no members{% endif %} + {%- if used %} · scheduled by {% for s in used %}{{ s.name }}{{ ", " if not loop.last }}{% endfor %}{% endif %} +

+ +{% if can_edit %} +{{ csrf_field() }} +
+

Group

+
+ +
- -
-

Members

- {% if not all_targets %} -

No targets exist yet. - Add targets first.

- {% else %} -
- {% for tgt in all_targets %} - - {% endfor %} -
- {% endif %} +
+ + + YAML, applied to every member. A target's own variables override these.
+
- +
+

Members

+ {% if not all_targets %} +

No targets yet. Add a target first.

+ {% else %} +
+ {% for tgt in all_targets %} + + {% endfor %} +
+ {% endif %} +
+ + + +
+

Delete this group

+

{% if used %}{{ used|length }} schedule{{ 's' if used|length != 1 }} run{{ 's' if used|length == 1 }} on it and would find nothing to run on.{% else %}Its targets stay; only the grouping goes.{% endif %}

+
{{ csrf_field() }} + +
+
+{% endif %} {% endblock %} diff --git a/steward/templates/ansible/inventory/groups.html b/steward/templates/ansible/inventory/groups.html deleted file mode 100644 index 7a36488..0000000 --- a/steward/templates/ansible/inventory/groups.html +++ /dev/null @@ -1,54 +0,0 @@ -{% extends "base.html" %} -{% from "ansible/_tabs.html" import ansible_tabs %} -{% from "_macros.html" import crumbs %} -{% block title %}Inventory Groups — Steward{% endblock %} -{% block breadcrumb %}{{ crumbs([("Ansible", "/ansible/"), ("Inventory", "/ansible/inventory/targets"), ("Groups", "")]) }}{% endblock %} -{% block content %} -{{ ansible_tabs("inventory") }} -
-

Inventory Groups

-
- Targets -
-
- -
-

New Group

-
{{ csrf_field() }} -
- - -
- -
-
- -{% if not groups %} -
-

No groups yet. Create one above.

-
-{% else %} -
- - - - - - {% for grp in groups %} - - - - - - {% endfor %} - -
NameMembers
{{ grp.name }}{{ grp.targets | length }} - Edit -
{{ csrf_field() }} - -
-
-
-{% endif %} -{% endblock %} diff --git a/steward/templates/ansible/inventory/index.html b/steward/templates/ansible/inventory/index.html new file mode 100644 index 0000000..53e711d --- /dev/null +++ b/steward/templates/ansible/inventory/index.html @@ -0,0 +1,125 @@ +{# ansible/inventory/index.html — the Inventory tab (#5333): a verdict line, + every target (address, the Steward host it is linked to, its groups), then + every group (its members, the schedules that run on it). The whole row opens + the target or group, where it is edited and deleted. Add target and Add + group open small dialogs; a failed add reopens its dialog with the error. #} +{% extends "base.html" %} +{% from "_macros.html" import crumbs, page_header, empty_state %} +{% from "ansible/_tabs.html" import ansible_tabs %} +{% block title %}Ansible Inventory — Steward{% endblock %} +{% block breadcrumb %}{{ crumbs([("Ansible", "/ansible/"), ("Inventory", "")]) }}{% endblock %} +{% block content %} +{% set can_edit = session.user_role in ('operator', 'admin') %} +{{ ansible_tabs("inventory") }} +{% call page_header("Inventory") %} + {% if can_edit %} + + + {% endif %} +{% endcall %} + +{% if not targets and not groups %} +
{{ empty_state("No targets yet. A target is a machine Ansible can reach: a name, an address, and the groups it is in.") }}
+{% else %} +

+ {{ verdict.targets }} target{{ 's' if verdict.targets != 1 }} in {{ verdict.groups }} group{{ 's' if verdict.groups != 1 }} + {%- if verdict.ungrouped %} · {{ verdict.ungrouped }} in no group{% endif %} + {%- if verdict.unlinked %} · {{ verdict.unlinked }} not linked to a Steward host{% endif %} +

+ +
+

Targets

+ {% if targets %} + + + + {% for t in targets %} + {% set h = hosts.get(t.host_id) %} + + + + + + {% endfor %} + +
TargetSteward hostGroups
+ {% else %} +

No targets yet.

+ {% endif %} +
+ +
+

Groups

+ {% if groups %} + + + + {% for g in groups %} + {% set used = users.get('steward:group:' ~ g.id, []) %} + + + + + + {% endfor %} + +
GroupMembersSchedules
+ {% else %} +

No groups yet. A group is what a run or a schedule targets, and what the Hosts list filters by.

+ {% endif %} +
+{% endif %} + +{% if can_edit %} +{% set a = add or {} %} +{% set v = a.get('values') or {} %} + + + {% if a.kind == 'target' and a.error %}{% endif %} +
{{ csrf_field() }} +
+ + + What plays call it: hosts: web-01. +
+
+ + + An IP or DNS name. Groups, variables and the Steward host are set on its page next. +
+ +
+
+ + + {% if a.kind == 'group' and a.error %}{% endif %} +
{{ csrf_field() }} +
+ + + Members and variables are set on its page next. +
+ +
+
+{% if a.kind in ('target', 'group') %}{% endif %} +{% endif %} +{% endblock %} diff --git a/steward/templates/ansible/inventory/target_detail.html b/steward/templates/ansible/inventory/target_detail.html index 8abe130..4add064 100644 --- a/steward/templates/ansible/inventory/target_detail.html +++ b/steward/templates/ansible/inventory/target_detail.html @@ -1,56 +1,75 @@ +{# ansible/inventory/target_detail.html — one target (#5333): what it is (its + address, the Steward host it is linked to, its groups, the schedules aimed + at it), its form (operators), and at the foot Delete behind the confirm + dialog. #} {% extends "base.html" %} +{% from "_macros.html" import crumbs, page_header %} {% from "ansible/_tabs.html" import ansible_tabs %} -{% from "_macros.html" import crumbs %} -{% block title %}Target: {{ target.name }} — Steward{% endblock %} -{% block breadcrumb %}{{ crumbs([("Ansible", "/ansible/"), ("Inventory", "/ansible/inventory/targets"), ("Targets", "/ansible/inventory/targets"), (target.name, "")]) }}{% endblock %} +{% block title %}{{ target.name }} — Inventory — Steward{% endblock %} +{% block breadcrumb %}{{ crumbs([("Ansible", "/ansible/"), ("Inventory", "/ansible/inventory/"), (target.name, "")]) }}{% endblock %} {% block content %} +{% set can_edit = session.user_role in ('operator', 'admin') %} {{ ansible_tabs("inventory") }} -
-

Target: {{ target.name }}

-
+{{ page_header(target.name) }} -
{{ csrf_field() }} -
-

Connection

-
- - -
-
- - -
-
- - -
+

+ {{ target.address }} + · {% if host %}Steward host {{ host.name }}{% else %}not linked to a Steward host{% endif %} + · {% if target.groups %}in {% for g in target.groups|sort(attribute='name') %}{{ g.name }}{{ ", " if not loop.last }}{% endfor %}{% else %}in no group{% endif %} + {%- if used %} · scheduled by {% for s in used %}{{ s.name }}{{ ", " if not loop.last }}{% endfor %}{% endif %} +

+{% if not host %}

A target is linked from its host's page, in the Ansible section.

{% endif %} + +{% if can_edit %} +{{ csrf_field() }} +
+

Connection

+
+
+ + + What plays call it: hosts: {{ target.name }}. +
+
+ + + Becomes ansible_host. +
- -
-

Groups

- {% if not all_groups %} -

No groups exist yet. - Create a group first.

- {% else %} -
- {% for grp in all_groups %} - - {% endfor %} -
- {% endif %} +
+ + + YAML. These override its groups' variables for this target only.
+
- +
+

Groups

+ {% if not all_groups %} +

No groups yet. Add a group first.

+ {% else %} +
+ {% for grp in all_groups %} + + {% endfor %} +
+ {% endif %} +
+ + + +
+

Delete this target

+

{% if used %}{{ used|length }} schedule{{ 's' if used|length != 1 }} run{{ 's' if used|length == 1 }} on it alone and would find nothing to run on.{% else %}It leaves its groups; its past runs stay in the Runs list.{% endif %}

+
{{ csrf_field() }} + +
+
+{% endif %} {% endblock %} diff --git a/steward/templates/ansible/inventory/targets.html b/steward/templates/ansible/inventory/targets.html deleted file mode 100644 index f1bffa8..0000000 --- a/steward/templates/ansible/inventory/targets.html +++ /dev/null @@ -1,63 +0,0 @@ -{% extends "base.html" %} -{% from "ansible/_tabs.html" import ansible_tabs %} -{% from "_macros.html" import crumbs %} -{% block title %}Inventory Targets — Steward{% endblock %} -{% block breadcrumb %}{{ crumbs([("Ansible", "/ansible/"), ("Inventory", "/ansible/inventory/targets"), ("Targets", "")]) }}{% endblock %} -{% block content %} -{{ ansible_tabs("inventory") }} -
-

Inventory Targets

-
- Groups -
-
- -
-

New Target

-
{{ csrf_field() }} -
- - -
-
- - -
- -
-
- -{% if not targets %} -
-

No targets yet. Add one above.

-
-{% else %} -
- - - - - - {% for tgt in targets %} - - - - - - - {% endfor %} - -
NameAddressGroups
{{ tgt.name }}{{ tgt.address }} - {% for grp in tgt.groups %} - {{ grp.name }} - {% endfor %} - - Edit -
{{ csrf_field() }} - -
-
-
-{% endif %} -{% endblock %} diff --git a/tests/core/test_ansible_inventory_page.py b/tests/core/test_ansible_inventory_page.py new file mode 100644 index 0000000..25ed180 --- /dev/null +++ b/tests/core/test_ansible_inventory_page.py @@ -0,0 +1,122 @@ +"""Inventory on one page (#5333): every target with its address, its linked +Steward host and its groups, then every group with its members and the +schedules aimed at it. The row opens the target or group; Add is a dialog; +Delete is on the target's or group's page, behind the confirm dialog.""" +from types import SimpleNamespace as NS + +import pytest +from quart import render_template, session +from werkzeug.exceptions import MethodNotAllowed + +from tests.test_style_tokens import hex_colours + + +def _world(): + swarm = NS(id="g1", name="Docker Swarm", targets=[]) + empty = NS(id="g2", name="Web", targets=[]) + d2 = NS(id="t1", name="VDNT-Docker02", address="10.0.0.12", host_id="h1", groups=[swarm]) + util = NS(id="t2", name="VDNT-Util01", address="10.0.0.20", host_id=None, groups=[]) + swarm.targets = [d2] + return d2, util, swarm, empty + + +def _ctx(add=None): + d2, util, swarm, empty = _world() + return {"targets": [d2, util], "groups": [swarm, empty], + "hosts": {"h1": NS(id="h1", name="docker02")}, + "users": {"steward:group:g1": [NS(id="s1", name="Nightly prune")]}, + "verdict": {"targets": 2, "groups": 2, "unlinked": 1, "ungrouped": 1}, "add": add} + + +def test_inventory_is_one_page(app): + urls = app.url_map.bind("localhost") + assert urls.match("/ansible/inventory/")[0] == "ansible_inventory.inventory" + assert urls.match("/ansible/inventory/targets/t1")[0] == "ansible_inventory.target_detail" + assert urls.match("/ansible/inventory/groups/g1")[0] == "ansible_inventory.group_detail" + # The two list pages are gone (rule 122): their URLs only take the Add form's POST. + for gone in ("/ansible/inventory/targets", "/ansible/inventory/groups"): + with pytest.raises(MethodNotAllowed): + urls.match(gone, method="GET") + + +async def test_the_inventory_lists_targets_then_groups(app): + async with app.test_request_context("/ansible/inventory/"): + session["user_role"] = "operator" + page = await render_template("ansible/inventory/index.html", **_ctx()) + main = page[page.index("'):] + verdict = verdict[:verdict.index("

")] + assert "2 targets in 2 groups" in verdict and '1 in no group' in verdict + assert "1 not linked to a Steward host" in verdict + # Each row opens its target or group; the host and schedule links sit above it. + assert main.count('') == 4 + assert 'VDNT-Docker02' in main + assert 'Docker Swarm' in main + assert 'docker02' in main + assert 'Nightly prune' in main + assert main.index("VDNT-Docker02") < main.index('id="inv-groups"') # targets first + # No red × in the rows: deleting is on the target's or group's page. + assert "/delete" not in main and "×" not in main + assert 'id="add-target"' in main and 'id="add-group"' in main and "showModal()" in main + assert 'Inventory' in main + assert not hex_colours(main), hex_colours(main) + + +async def test_a_failed_add_reopens_its_dialog_with_what_was_typed(app): + add = {"kind": "target", "error": "There is already a target named web-1.", + "values": {"name": "web-1", "address": "10.0.0.9"}} + async with app.test_request_context("/ansible/inventory/targets", method="POST"): + session["user_role"] = "operator" + page = await render_template("ansible/inventory/index.html", **_ctx(add)) + assert '' in page + assert 'value="10.0.0.9"' in page + assert "document.getElementById('add-target').showModal();" in page + + +async def test_a_viewer_reads_the_inventory_but_cannot_add(app): + async with app.test_request_context("/ansible/inventory/"): + session["user_role"] = "viewer" + page = await render_template("ansible/inventory/index.html", **_ctx()) + assert "VDNT-Docker02" in page and 'id="add-target"' not in page and "Add target" not in page + + +async def test_a_target_page_says_what_it_is_and_deletes_behind_the_dialog(app): + d2, util, swarm, empty = _world() + async with app.test_request_context("/ansible/inventory/targets/t1"): + session["user_role"] = "operator" + page = await render_template( + "ansible/inventory/target_detail.html", target=d2, host=NS(id="h1", name="docker02"), + used=[], all_groups=[swarm, empty], member_group_ids={"g1"}, ansible_vars_yaml="") + main = page[page.index("docker02' in main + assert 'in Docker Swarm' in main + assert 'href="/ansible/inventory/"' in page # the crumb back up + form = main[main.index('action="/ansible/inventory/targets/t1/delete"'):] + assert form.index('data-confirm="Delete target VDNT-Docker02?') < form.index(">Delete target") + assert 'value="g1" checked' in main and 'value="g2" >' in main + assert not hex_colours(main), hex_colours(main) + + +async def test_a_group_page_names_its_members_and_what_runs_on_it(app): + d2, util, swarm, empty = _world() + async with app.test_request_context("/ansible/inventory/groups/g1"): + session["user_role"] = "operator" + page = await render_template( + "ansible/inventory/group_detail.html", group=swarm, used=[NS(id="s1", name="Nightly prune")], + all_targets=[d2, util], member_ids={"t1"}, ansible_vars_yaml="") + main = page[page.index("VDNT-Docker02' in main + assert 'scheduled by Nightly prune' in main + # Deleting a group a schedule runs on says so before it goes. + assert 'data-confirm="Delete group Docker Swarm? 1 schedule aimed at it would find nothing to run on."' in main + assert not hex_colours(main), hex_colours(main) + + +async def test_a_viewer_reads_a_target_but_gets_no_form(app): + d2, util, swarm, empty = _world() + async with app.test_request_context("/ansible/inventory/targets/t1"): + session["user_role"] = "viewer" + page = await render_template( + "ansible/inventory/target_detail.html", target=d2, host=None, used=[], + all_groups=[swarm], member_group_ids={"g1"}, ansible_vars_yaml="") + assert "not linked to a Steward host" in page and "
'):] nav = nav[:nav.index("")] - for href in ("/ansible/", "/ansible/playbooks", "/ansible/schedules", "/ansible/inventory/targets"): + for href in ("/ansible/", "/ansible/playbooks", "/ansible/schedules", "/ansible/inventory/"): assert f'href="{href}"' in nav, href assert nav.count('aria-current="page"') == 1 and 'Runs' in nav assert "No runs yet." in page and "No runs in the last 30 days" in page diff --git a/tests/integration/test_ansible_runs_list.py b/tests/integration/test_ansible_runs_list.py index 3804423..c9a7f4d 100644 --- a/tests/integration/test_ansible_runs_list.py +++ b/tests/integration/test_ansible_runs_list.py @@ -1,4 +1,5 @@ -"""Integration: the Ansible Runs tab (#5329) against real Postgres. Its +"""Integration: the Ansible Runs tab (#5329) and the Inventory page (#5333) +against real Postgres. The Runs tab's verdict counts the last 30 days; the filter bar narrows by playbook, status and origin; an HTMX request from the bar gets the table alone, and the next page as the reader scrolls keeps the filters. Requires STEWARD_DATABASE_URL.""" @@ -94,3 +95,62 @@ def test_the_runs_tab_counts_and_filters(app): 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 + + +def test_the_inventory_page_and_its_add_dialogs(app): + """#5333: one Inventory page with each target's linked host and groups; a + failed add comes back to the page with its dialog open; a good add opens + the new target's page; Delete goes back to the Inventory page.""" + from sqlalchemy import select + from steward.models.ansible_inventory import AnsibleGroup, AnsibleTarget + from steward.models.hosts import Host + from steward.models.users import User, UserRole + + async def _go(): + tag = uuid.uuid4().hex[:8] + uid, hid, gid, tid = (str(uuid.uuid4()) for _ in range(4)) + async with app.db_sessionmaker() as s: + async with s.begin(): + s.add(User(id=uid, username=f"inv-{tag}", email=f"inv-{tag}@test", + password_hash="x", role=UserRole.operator, is_active=True)) + s.add(Host(id=hid, name=f"host-{tag}", address="10.9.8.1")) + async with s.begin(): + grp = AnsibleGroup(id=gid, name=f"grp-{tag}", ansible_vars={}) + s.add(grp) + s.add(AnsibleTarget(id=tid, name=f"tgt-{tag}", address="10.9.8.1", host_id=hid, + ansible_vars={}, groups=[grp])) + client = app.test_client() + async with client.session_transaction() as sess: + sess.update(user_id=uid, user_role="operator", username=f"inv-{tag}", + _csrf="test-token", login_at=time.time()) + csrf = {"X-CSRF-Token": "test-token"} + page = await client.get("/ansible/inventory/") + dup = await client.post("/ansible/inventory/targets", headers=csrf, + form={"name": f"tgt-{tag}", "address": "10.9.8.2"}) + new = await client.post("/ansible/inventory/targets", headers=csrf, + form={"name": f"new-{tag}", "address": "10.9.8.3"}) + async with app.db_sessionmaker() as s: + new_id = (await s.execute(select(AnsibleTarget.id).where( + AnsibleTarget.name == f"new-{tag}"))).scalar_one() + gone = await client.post(f"/ansible/inventory/targets/{new_id}/delete", headers=csrf) + out = {"page": (page.status_code, await page.get_data(as_text=True)), + "dup": (dup.status_code, await dup.get_data(as_text=True)), + "new": (new.status_code, new.headers.get("Location")), "new_id": new_id, + "gone": (gone.status_code, gone.headers.get("Location"))} + async with app.db_sessionmaker() as s: + async with s.begin(): + for model, rid in ((AnsibleTarget, tid), (AnsibleGroup, gid), (Host, hid), (User, uid)): + await s.delete(await s.get(model, rid)) + return tag, hid, tid, out + + tag, hid, tid, out = asyncio.run(_go()) + status, page = out["page"] + assert status == 200 + assert f'tgt-{tag}' in page + assert f'host-{tag}' in page + assert f'grp-{tag}' in page + status, dup = out["dup"] + assert status == 400 and f"There is already a target named tgt-{tag}." in dup + assert "document.getElementById('add-target').showModal();" in dup and 'value="10.9.8.2"' in dup + assert out["new"] == (302, f"/ansible/inventory/targets/{out['new_id']}") + assert out["gone"] == (302, "/ansible/inventory/")