runspec-playbook 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,61 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.pyo
5
+ *.pyd
6
+ .Python
7
+ *.egg
8
+ *.egg-info/
9
+ dist/
10
+ build/
11
+ .eggs/
12
+ .venv/
13
+ venv/
14
+ env/
15
+ .env
16
+ pip-wheel-metadata/
17
+ .pytest_cache/
18
+ .mypy_cache/
19
+ .ruff_cache/
20
+ htmlcov/
21
+ .coverage
22
+ coverage.xml
23
+ *.cover
24
+
25
+ # Node
26
+ node_modules/
27
+ dist/
28
+ *.js.map
29
+ .npm
30
+
31
+ # Go
32
+ *.exe
33
+ *.test
34
+ *.out
35
+ vendor/
36
+
37
+ # IDE
38
+ .idea/
39
+ .vscode/
40
+ *.iml
41
+ *.iws
42
+ *.ipr
43
+ .DS_Store
44
+ Thumbs.db
45
+
46
+ # Docs
47
+ site/
48
+
49
+ # Misc
50
+ *.log
51
+ *.tmp
52
+
53
+ # External reference repos (cloned locally, not committed)
54
+ chainlit-docs/
55
+ .chainlit/
56
+
57
+ # Claude Code local config (machine-specific)
58
+ .claude/launch.json
59
+
60
+ # Stray committed test venv (removed from tracking)
61
+ .venv-test/
@@ -0,0 +1,28 @@
1
+ # runspec-playbook Changelog
2
+
3
+ ## [0.1.0] — 2026-06-30
4
+
5
+ Initial release.
6
+
7
+ One runnable, **`run-playbook`** (confirm), that deploys a Python package by
8
+ running a **bundled Ansible playbook locally** on the target host:
9
+
10
+ - The runspec-console reaches the host over its own SSH connection (the host
11
+ from `runspec_hosts.toml`) and invokes `run-playbook` there. The playbook runs
12
+ against `localhost` (`ansible_connection=local`), so **Ansible uses no SSH of
13
+ its own** — the `ansible-pull` pattern, driven by the console instead of cron.
14
+ - Arguments: positional `package` (supports `name[extras]`) and `version`
15
+ (composed into `name[extras]==version`), a `playbook` choice over the bundled
16
+ playbooks, `--check` (Ansible check-mode dry run), repeatable `--extra-var
17
+ key=value`, `--tags`/`--skip-tags`, and `-v/--verbosity`.
18
+ - Secrets (Nexus credentials, etc.) are passed as **environment variables**
19
+ injected by the console from the OS keychain — never packaged, never on the
20
+ command line, never in the audit log. The playbook reads them with
21
+ `lookup('env', 'NAME')`.
22
+ - Driven by `ansible-runner` (the programmatic runner AWX/AAP use): returns
23
+ structured JSON (`status`, `rc`, `changed`, per-result `stats`) into the
24
+ console audit record.
25
+
26
+ Built to be copied into a private corporate package: depend on it (or vendor
27
+ the wrapper), drop your own per-host playbooks under `runspec_playbook/playbooks/`,
28
+ and publish to your internal index. See `docs/design/console-ansible-deploy.md`.
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: runspec-playbook
3
+ Version: 0.1.0
4
+ Summary: Run bundled Ansible playbooks locally on a host the runspec-console is already connected to — no Ansible SSH, one host list (runspec_hosts.toml)
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: ansible-core>=2.16
7
+ Requires-Dist: ansible-runner>=2.3
8
+ Requires-Dist: runspec>=0.38.0
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest>=8.0; extra == 'dev'
11
+ Requires-Dist: ruff==0.15.20; extra == 'dev'
@@ -0,0 +1,106 @@
1
+ # runspec-playbook
2
+
3
+ Run **bundled Ansible playbooks locally** on a host the runspec-console is
4
+ already connected to — no second SSH stack, one host list.
5
+
6
+ ```
7
+ runspec-console ──SSH──▶ host ──▶ run-playbook ──▶ ansible-runner (connection: local) ──▶ your playbook
8
+ (runspec_hosts.toml) (acts on localhost — no SSH)
9
+ ```
10
+
11
+ This is the `ansible-pull` pattern, driven by the console instead of cron. The
12
+ console reaches each host over its own SSH connection and runs `run-playbook`
13
+ there; Ansible then executes the playbook against `localhost`, so it opens no
14
+ SSH of its own. Everything the console already owns per host keeps applying —
15
+ connection, credentials, denylist, audit — while Ansible supplies the playbook
16
+ richness (idempotent modules, handlers, templates, `become:`, `--check`).
17
+
18
+ > **When this fits:** your playbooks are **per-host** (provisioning/config of
19
+ > the box they run on). Local execution means each host only knows about
20
+ > itself, so anything that coordinates *across* hosts — `delegate_to`,
21
+ > `run_once`, reading another host's `hostvars`, `serial:` rolling deploys —
22
+ > needs a real Ansible control node and is out of scope here.
23
+
24
+ **Deployment is the worked example, not the limit.** The mechanism runs *any*
25
+ per-host playbook — patching, config enforcement, an audit/report pass, a
26
+ restart — and the shipped `run-playbook` runnable is just the deploy-shaped one
27
+ (package + version). Add a `patch.yml` / `audit.yml` under `playbooks/` and a
28
+ matching runnable (or a generic playbook-name + extra-vars runnable) the same
29
+ way; see "Making it your own" below.
30
+
31
+ ## The runnable
32
+
33
+ | Arg | | Description |
34
+ |---|---|---|
35
+ | `package` | positional 1 | Package to deploy; supports extras, e.g. `myapp` or `myapp[postgres,redis]`. |
36
+ | `version` | positional 2 | Exact version; composed into `<package>==<version>`. |
37
+ | `--playbook` | choice | Which bundled playbook to run (default `deploy`). |
38
+ | `--check` / `-C` | flag | Dry run — Ansible check mode (report, don't apply). |
39
+ | `--extra-var k=v` | repeatable | Extra Ansible variables, merged after the `deploy_*` vars. |
40
+ | `--tags` / `--skip-tags` | str | Comma-separated Ansible tag filters. |
41
+ | `--verbosity` / `-v` | int 0–4 | Ansible verbosity. |
42
+
43
+ ```bash
44
+ run-playbook myapp 1.4.2
45
+ run-playbook 'myapp[postgres]' 1.4.2 --check
46
+ run-playbook myapp 1.4.2 --extra-var venv_path=/opt/myapp/venv -v
47
+ ```
48
+
49
+ The runnable always passes these variables to the playbook: `deploy_package`,
50
+ `deploy_version`, `deploy_package_spec`. It returns JSON
51
+ (`status`, `rc`, `changed`, `stats`) — and follows the Ansible `changed`
52
+ convention so it reads cleanly in the console history/analytics.
53
+
54
+ ## Secrets
55
+
56
+ **Do not package a `.env` of secrets.** A wheel/sdist on your index is readable
57
+ by anyone with read access, package data is not encrypted, and it would land on
58
+ every host's disk in `site-packages`. Bake only **non-secret** config (an index
59
+ host, a default venv path) into a playbook variable.
60
+
61
+ Instead use the console's existing **Credentials → env var** channel. Secrets
62
+ live in the OS keychain (Windows Credential Manager / macOS Keychain / Secret
63
+ Service); only metadata is stored on disk. At run time the console injects them
64
+ as plain environment variables derived from the credential's label + kind, and
65
+ this runnable forwards the environment to the playbook. Read them in tasks with
66
+ `lookup('env', 'NAME')`.
67
+
68
+ Name the credential so the convention yields the names your playbook expects:
69
+
70
+ | Credential label | Kind | Env vars injected |
71
+ |---|---|---|
72
+ | `Nexus` | token | `NEXUS_TOKEN`, `NEXUS_PAT` |
73
+ | `Nexus` | userpass | `NEXUS_USERNAME`, `NEXUS_USER`, `NEXUS_PASSWORD` |
74
+ | `Deploy Key` | token | `DEPLOY_KEY_TOKEN`, `DEPLOY_KEY_PAT` |
75
+
76
+ Select one or more credentials in the run dialog (the **Forms** tab) — these are
77
+ the GitLab CI/CD variables you have today, now sourced from the keychain instead
78
+ of a CI settings page. For an unattended run, a **schedule** with
79
+ `action = "runnable"` runs `run-playbook` with no operator present (scheduling
80
+ *is* the authorization); in-scope credentials auto-apply on the agent path.
81
+
82
+ > A file-based secret is only acceptable if it is **Ansible Vault** (encrypted)
83
+ > — and the vault password itself should still arrive via an injected env var
84
+ > (e.g. `ANSIBLE_VAULT_PASSWORD_FILE` pointing at a tiny script that echoes it),
85
+ > not be packaged.
86
+
87
+ ## Making it your own
88
+
89
+ This package is a reference. To adopt it:
90
+
91
+ 1. Replace `runspec_playbook/playbooks/deploy.yml` with your existing per-host
92
+ playbooks (keep `hosts: all` — the inventory is `localhost`). Vendor any
93
+ required collections under a `collections/` dir for an index-only / air-gapped
94
+ install rather than fetching from Galaxy at run time.
95
+ 2. Rename the package and publish it to your internal index from your existing
96
+ GitLab CI — CI keeps building and publishing; the console just deploys.
97
+ 3. Install it (and `ansible-core`) into the same venv as runspec on each host
98
+ (e.g. via the console's `install-into-venv` runnable), so `run-playbook`
99
+ surfaces in the console alongside your other runnables.
100
+
101
+ See `docs/design/console-ansible-deploy.md` for the full design and rationale.
102
+
103
+ ## Requirements
104
+
105
+ `runspec`, `ansible-core`, and `ansible-runner` on the host. Zero of these need
106
+ to be reachable at run time once installed.
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "runspec-playbook"
7
+ version = "0.1.0"
8
+ requires-python = ">=3.10"
9
+ description = "Run bundled Ansible playbooks locally on a host the runspec-console is already connected to — no Ansible SSH, one host list (runspec_hosts.toml)"
10
+ dependencies = [
11
+ "runspec>=0.38.0",
12
+ "ansible-core>=2.16",
13
+ "ansible-runner>=2.3",
14
+ ]
15
+
16
+ [project.scripts]
17
+ run-playbook = "runspec_playbook.run_playbook:main_run_playbook"
18
+
19
+ [project.optional-dependencies]
20
+ dev = [
21
+ "ruff==0.15.20",
22
+ "pytest>=8.0",
23
+ ]
24
+
25
+ # Ship the runnable interface and the bundled playbooks as package data.
26
+ [tool.hatch.build.targets.wheel]
27
+ packages = ["runspec_playbook"]
28
+
29
+ [tool.pytest.ini_options]
30
+ testpaths = ["tests"]
31
+
32
+ [tool.ruff]
33
+ line-length = 200
34
+ target-version = "py310"
35
+
36
+ [tool.ruff.lint]
37
+ select = ["E", "F", "I", "UP", "B", "SIM"]
@@ -0,0 +1,24 @@
1
+ """runspec-playbook — run bundled Ansible playbooks locally, driven by the console.
2
+
3
+ Public helpers are re-exported so a private corporate wrapper can import the
4
+ pure logic (and bake in its own index/venv conventions) without going through
5
+ the CLI:
6
+
7
+ from runspec_playbook import compose_package_spec, run_ansible
8
+ """
9
+
10
+ from runspec_playbook.run_playbook import (
11
+ compose_package_spec,
12
+ main_run_playbook,
13
+ parse_extra_vars,
14
+ run_ansible,
15
+ summarize_stats,
16
+ )
17
+
18
+ __all__ = [
19
+ "compose_package_spec",
20
+ "main_run_playbook",
21
+ "parse_extra_vars",
22
+ "run_ansible",
23
+ "summarize_stats",
24
+ ]
@@ -0,0 +1,56 @@
1
+ ---
2
+ # Example deploy playbook for runspec-playbook.
3
+ #
4
+ # Runs LOCALLY on the target host: the inventory pins ansible_connection=local,
5
+ # so Ansible uses NO SSH of its own. The runspec-console already reached this
6
+ # host (over its own SSH, from runspec_hosts.toml) and invoked the run-playbook
7
+ # runnable here; this playbook then acts on localhost.
8
+ #
9
+ # Variables provided automatically by the run-playbook runnable:
10
+ # deploy_package e.g. "myapp" or "myapp[postgres]"
11
+ # deploy_version e.g. "1.4.2"
12
+ # deploy_package_spec e.g. "myapp[postgres]==1.4.2"
13
+ # Override anything else with --extra-var key=value.
14
+ #
15
+ # Secrets (Nexus credentials, etc.) arrive as ENVIRONMENT VARIABLES injected by
16
+ # the runspec-console from the OS keychain at run time — never packaged, never
17
+ # on the command line, never in the audit log. Read them with
18
+ # lookup('env', 'NAME'). Name the console credential so the label→env-var
19
+ # convention yields the names this playbook expects (a token credential
20
+ # labelled "Nexus" → NEXUS_TOKEN/NEXUS_PAT; a userpass "Nexus" → NEXUS_USERNAME).
21
+ #
22
+ # Replace the body below with your existing per-host playbook — the only
23
+ # requirement for local execution is `hosts: all` (the inventory is localhost).
24
+ - name: Deploy {{ deploy_package_spec }} locally
25
+ hosts: all
26
+ gather_facts: true
27
+ vars:
28
+ # Strip any [extras] suffix to derive a plain application name.
29
+ app_name: "{{ deploy_package | regex_replace('\\[.*$', '') }}"
30
+ venv_path: "/opt/{{ app_name }}/venv"
31
+ tasks:
32
+ - name: Ensure the venv parent directory exists
33
+ become: true
34
+ ansible.builtin.file:
35
+ path: "{{ venv_path | dirname }}"
36
+ state: directory
37
+ mode: "0755"
38
+
39
+ - name: Install {{ deploy_package_spec }} into {{ venv_path }} (idempotent)
40
+ become: true
41
+ ansible.builtin.pip:
42
+ name: "{{ deploy_package_spec }}"
43
+ state: present
44
+ virtualenv: "{{ venv_path }}"
45
+ virtualenv_command: python3 -m venv
46
+ environment:
47
+ # Point pip at Nexus using the injected credential env vars. If no
48
+ # credential is present, fall back to an already-exported PIP_INDEX_URL,
49
+ # or omit to use the system default index.
50
+ PIP_INDEX_URL: >-
51
+ {{
52
+ ('https://' ~ lookup('env', 'NEXUS_USERNAME') ~ ':' ~ lookup('env', 'NEXUS_TOKEN')
53
+ ~ '@nexus.example.com/repository/pypi/simple')
54
+ if lookup('env', 'NEXUS_TOKEN')
55
+ else (lookup('env', 'PIP_INDEX_URL') | default(omit, true))
56
+ }}
@@ -0,0 +1,178 @@
1
+ """run-playbook — deploy a package by running a bundled Ansible playbook locally.
2
+
3
+ The runspec-console reaches the target host over its own SSH connection (the
4
+ host from ``runspec_hosts.toml``) and runs this runnable there. The playbook is
5
+ then executed against ``localhost`` with ``ansible_connection=local``, so
6
+ Ansible itself opens no SSH connection. Per-host concerns the console already
7
+ owns — connection, credentials (injected as env vars), denylist, audit — keep
8
+ applying; Ansible supplies the playbook richness (idempotent modules, handlers,
9
+ templates, ``become:``, ``--check``).
10
+
11
+ The pure helpers (``compose_package_spec``/``parse_extra_vars``/
12
+ ``summarize_stats``) are factored out so they unit-test without Ansible, and the
13
+ single Ansible call goes through ``run_ansible`` so tests can substitute it.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ import sys
21
+ import tempfile
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ import runspec as rs
26
+
27
+
28
+ def compose_package_spec(package: str, version: str) -> str:
29
+ """Combine a package (optionally with extras) and a pinned version.
30
+
31
+ ``"myapp"`` + ``"1.4.2"`` → ``"myapp==1.4.2"``; extras are preserved:
32
+ ``"myapp[postgres]"`` + ``"1.4.2"`` → ``"myapp[postgres]==1.4.2"``.
33
+ """
34
+ package = package.strip()
35
+ version = version.strip()
36
+ if not package:
37
+ raise ValueError("package is required")
38
+ if not version:
39
+ return package
40
+ return f"{package}=={version}"
41
+
42
+
43
+ def parse_extra_vars(items: list[str]) -> dict[str, str]:
44
+ """Parse ``--extra-var key=value`` items into a dict (split on the first =)."""
45
+ out: dict[str, str] = {}
46
+ for item in items:
47
+ if "=" not in item:
48
+ raise ValueError(f"--extra-var must be key=value, got: {item!r}")
49
+ key, value = item.split("=", 1)
50
+ out[key.strip()] = value
51
+ return out
52
+
53
+
54
+ def summarize_stats(stats: dict[str, Any]) -> dict[str, int]:
55
+ """Reduce ansible-runner's per-host stats to friendly fleet-wide totals."""
56
+
57
+ def total(key: str) -> int:
58
+ section = stats.get(key) or {}
59
+ return sum(section.values()) if isinstance(section, dict) else 0
60
+
61
+ return {
62
+ "ok": total("ok"),
63
+ "changed": total("changed"),
64
+ "failed": total("failures"),
65
+ "unreachable": total("dark"),
66
+ "skipped": total("skipped"),
67
+ }
68
+
69
+
70
+ def run_ansible(
71
+ playbook_name: str,
72
+ extravars: dict[str, Any],
73
+ *,
74
+ check: bool,
75
+ verbosity: int,
76
+ tags: str | None,
77
+ skip_tags: str | None,
78
+ envvars: dict[str, str],
79
+ ) -> dict[str, Any]:
80
+ """Run a bundled playbook against localhost via ansible-runner.
81
+
82
+ The bundled ``playbooks/`` tree is copied into a throwaway private data dir
83
+ so artefacts never litter the host. ``envvars`` is forwarded verbatim so the
84
+ console-injected secrets reach the playbook (read there with
85
+ ``lookup('env', 'NAME')``); this function never logs them.
86
+ """
87
+ import shutil
88
+ from importlib.resources import as_file, files
89
+
90
+ import ansible_runner
91
+
92
+ source = files("runspec_playbook") / "playbooks"
93
+ with as_file(source) as playbooks_dir, tempfile.TemporaryDirectory(prefix="runspec-playbook-") as data_dir:
94
+ project = Path(data_dir) / "project"
95
+ shutil.copytree(playbooks_dir, project)
96
+ if not (project / f"{playbook_name}.yml").is_file():
97
+ raise FileNotFoundError(f"bundled playbook not found: {playbook_name}.yml")
98
+
99
+ kwargs: dict[str, Any] = {
100
+ "private_data_dir": data_dir,
101
+ "playbook": f"{playbook_name}.yml",
102
+ # One inline host pinned to the local connection — no SSH, no remote inventory.
103
+ "inventory": {"all": {"hosts": {"localhost": {"ansible_connection": "local"}}}},
104
+ "extravars": extravars,
105
+ "envvars": envvars,
106
+ "verbosity": verbosity,
107
+ "quiet": True,
108
+ }
109
+ if tags:
110
+ kwargs["tags"] = tags
111
+ if skip_tags:
112
+ kwargs["skip_tags"] = skip_tags
113
+ if check:
114
+ kwargs["cmdline"] = "--check"
115
+
116
+ runner = ansible_runner.run(**kwargs)
117
+ return {"status": runner.status, "rc": runner.rc, "stats": runner.stats or {}}
118
+
119
+
120
+ def main_run_playbook() -> None:
121
+ spec = rs.parse("run-playbook")
122
+ package = str(spec.package.value)
123
+ version = str(spec.version.value)
124
+ playbook_name = str(spec.playbook.value)
125
+ check = bool(spec.check.value)
126
+ verbosity = int(spec.verbosity.value)
127
+ tags = spec.tags.value
128
+ skip_tags = spec.skip_tags.value
129
+ extra_items = spec.extra_var.value or []
130
+
131
+ try:
132
+ package_spec = compose_package_spec(package, version)
133
+ extravars: dict[str, Any] = {
134
+ "deploy_package": package,
135
+ "deploy_version": version,
136
+ "deploy_package_spec": package_spec,
137
+ **parse_extra_vars(extra_items),
138
+ }
139
+ except ValueError as exc:
140
+ print(json.dumps({"error": str(exc)}))
141
+ sys.exit(2)
142
+
143
+ try:
144
+ result = run_ansible(
145
+ playbook_name,
146
+ extravars,
147
+ check=check,
148
+ verbosity=verbosity,
149
+ tags=tags,
150
+ skip_tags=skip_tags,
151
+ # The console already curated this environment (it added the selected
152
+ # credentials' env vars); forward it so the playbook can read them.
153
+ envvars=dict(os.environ),
154
+ )
155
+ except ModuleNotFoundError as exc:
156
+ print(json.dumps({"error": f"ansible-runner is not installed ({exc}). Install ansible-core and ansible-runner on this host."}))
157
+ sys.exit(1)
158
+ except FileNotFoundError as exc:
159
+ print(json.dumps({"error": str(exc)}))
160
+ sys.exit(1)
161
+
162
+ summary = summarize_stats(result.get("stats") or {})
163
+ status = result.get("status")
164
+ rc = int(result.get("rc") or 0)
165
+ print(
166
+ json.dumps(
167
+ {
168
+ "package": package_spec,
169
+ "playbook": playbook_name,
170
+ "check": check,
171
+ "status": status,
172
+ "rc": rc,
173
+ "changed": summary["changed"] > 0,
174
+ "stats": summary,
175
+ }
176
+ )
177
+ )
178
+ sys.exit(0 if status == "successful" else (rc or 1))
@@ -0,0 +1,75 @@
1
+ [config]
2
+ autonomy-default = "confirm"
3
+
4
+ [config.logging]
5
+ # Per-invocation audit files — one {runnable}.{utc-ts}.{run_id}.log per deploy.
6
+ # Multi-writer safe for shared venvs (view/compact/prune with `runspec logs`),
7
+ # and the data the runspec-console history/analytics tabs read.
8
+ store = "per-run"
9
+
10
+ # ── Deploy a package via a bundled Ansible playbook ──────────────────────────────
11
+ #
12
+ # This runnable runs ENTIRELY ON THE LOCAL HOST. The runspec-console reaches the
13
+ # host over its own SSH connection (the host listed in runspec_hosts.toml) and
14
+ # invokes this runnable there; the playbook is then executed against localhost
15
+ # (inventory pins ansible_connection=local), so Ansible uses NO SSH of its own.
16
+ # This is the `ansible-pull` pattern driven by the console instead of cron.
17
+ #
18
+ # Secrets (e.g. Nexus credentials) are passed as ENVIRONMENT VARIABLES injected
19
+ # by the console from the OS keychain — never packaged, never on the command
20
+ # line, never in the audit log. See README.md → "Secrets".
21
+
22
+ [run-playbook]
23
+ description = "Deploy a Python package by running a bundled Ansible playbook locally on this host. Installs <package>[extras]==<version> from the configured index and runs the playbook's steps. Uses no Ansible SSH — the console already reached this host; the playbook acts on localhost (connection: local)."
24
+ autonomy = "confirm"
25
+ output = "json"
26
+ examples = [
27
+ "run-playbook myapp 1.4.2",
28
+ "run-playbook 'myapp[postgres]' 1.4.2 --check",
29
+ { cmd = "run-playbook myapp 1.4.2 --extra-var venv_path=/opt/myapp/venv -v", description = "Override the target venv and show task output" },
30
+ ]
31
+
32
+ [run-playbook.args.package]
33
+ type = "str"
34
+ position = 1
35
+ description = "Package to deploy. Supports extras, e.g. 'myapp' or 'myapp[postgres,redis]'."
36
+
37
+ [run-playbook.args.version]
38
+ type = "str"
39
+ position = 2
40
+ description = "Exact version to install (e.g. '1.4.2'). Pinned — composed into '<package>==<version>'."
41
+
42
+ [run-playbook.args.playbook]
43
+ type = "choice"
44
+ options = ["deploy"]
45
+ default = "deploy"
46
+ description = "Which bundled playbook to run (one of the playbooks shipped inside this package)."
47
+
48
+ [run-playbook.args.check]
49
+ type = "flag"
50
+ short = "-C"
51
+ default = false
52
+ description = "Dry run: execute the playbook in Ansible check mode — report what would change without applying it."
53
+
54
+ [run-playbook.args.extra-var]
55
+ type = "str"
56
+ multiple = true
57
+ required = false
58
+ description = "Extra Ansible variable as key=value (repeatable, e.g. --extra-var venv_path=/opt/app/venv). Merged into extra-vars after the deploy_* variables."
59
+
60
+ [run-playbook.args.tags]
61
+ type = "str"
62
+ required = false
63
+ description = "Comma-separated Ansible --tags: run only tasks with these tags."
64
+
65
+ [run-playbook.args.skip-tags]
66
+ type = "str"
67
+ required = false
68
+ description = "Comma-separated Ansible --skip-tags: skip tasks with these tags."
69
+
70
+ [run-playbook.args.verbosity]
71
+ type = "int"
72
+ short = "-v"
73
+ default = 0
74
+ range = [0, 4]
75
+ description = "Ansible verbosity, 0–4 (like -v … -vvvv)."
File without changes
@@ -0,0 +1,120 @@
1
+ """Tests for run-playbook.
2
+
3
+ The pure helpers are tested directly. The end-to-end path is exercised
4
+ in-process with a stubbed ``run_ansible`` (so no Ansible install is needed):
5
+ ``rs.parse`` still reads the bundled runspec.toml because the finder walks up
6
+ from the calling module's directory.
7
+ """
8
+
9
+ import json
10
+
11
+ import pytest
12
+
13
+ from runspec_playbook import run_playbook
14
+
15
+ # ── pure helpers ──────────────────────────────────────────────────────────────
16
+
17
+
18
+ def test_compose_package_spec_plain() -> None:
19
+ assert run_playbook.compose_package_spec("myapp", "1.4.2") == "myapp==1.4.2"
20
+
21
+
22
+ def test_compose_package_spec_preserves_extras() -> None:
23
+ assert run_playbook.compose_package_spec("myapp[postgres,redis]", "1.4.2") == "myapp[postgres,redis]==1.4.2"
24
+
25
+
26
+ def test_compose_package_spec_requires_package() -> None:
27
+ with pytest.raises(ValueError):
28
+ run_playbook.compose_package_spec("", "1.0.0")
29
+
30
+
31
+ def test_parse_extra_vars_splits_on_first_equals() -> None:
32
+ assert run_playbook.parse_extra_vars(["venv_path=/opt/x/venv", "url=https://h/p?a=b"]) == {
33
+ "venv_path": "/opt/x/venv",
34
+ "url": "https://h/p?a=b",
35
+ }
36
+
37
+
38
+ def test_parse_extra_vars_rejects_bare_token() -> None:
39
+ with pytest.raises(ValueError):
40
+ run_playbook.parse_extra_vars(["novalue"])
41
+
42
+
43
+ def test_summarize_stats_totals_across_hosts() -> None:
44
+ stats = {
45
+ "ok": {"localhost": 3},
46
+ "changed": {"localhost": 2},
47
+ "failures": {"localhost": 1},
48
+ "dark": {},
49
+ }
50
+ assert run_playbook.summarize_stats(stats) == {
51
+ "ok": 3,
52
+ "changed": 2,
53
+ "failed": 1,
54
+ "unreachable": 0,
55
+ "skipped": 0,
56
+ }
57
+
58
+
59
+ # ── end-to-end (stubbed Ansible) ────────────────────────────────────────────────
60
+
61
+
62
+ def _stub_runner(monkeypatch, captured, *, status="successful", rc=0, stats=None):
63
+ def fake_run_ansible(playbook_name, extravars, *, check, verbosity, tags, skip_tags, envvars):
64
+ captured.update(
65
+ playbook_name=playbook_name,
66
+ extravars=extravars,
67
+ check=check,
68
+ verbosity=verbosity,
69
+ tags=tags,
70
+ skip_tags=skip_tags,
71
+ )
72
+ return {"status": status, "rc": rc, "stats": stats if stats is not None else {"changed": {"localhost": 1}, "ok": {"localhost": 2}}}
73
+
74
+ monkeypatch.setattr(run_playbook, "run_ansible", fake_run_ansible)
75
+
76
+
77
+ def test_main_builds_spec_and_reports_change(monkeypatch, capsys) -> None:
78
+ captured: dict = {}
79
+ _stub_runner(monkeypatch, captured)
80
+ monkeypatch.setattr("sys.argv", ["run-playbook", "myapp[pg]", "1.2.3", "--check", "--extra-var", "venv_path=/opt/x/venv"])
81
+
82
+ with pytest.raises(SystemExit) as exc:
83
+ run_playbook.main_run_playbook()
84
+ assert exc.value.code == 0
85
+
86
+ out = json.loads(capsys.readouterr().out)
87
+ assert out["package"] == "myapp[pg]==1.2.3"
88
+ assert out["changed"] is True
89
+ assert out["check"] is True
90
+ assert out["status"] == "successful"
91
+ assert out["stats"]["changed"] == 1
92
+
93
+ assert captured["extravars"]["deploy_package_spec"] == "myapp[pg]==1.2.3"
94
+ assert captured["extravars"]["deploy_package"] == "myapp[pg]"
95
+ assert captured["extravars"]["venv_path"] == "/opt/x/venv"
96
+ assert captured["check"] is True
97
+
98
+
99
+ def test_main_propagates_failure_exit_code(monkeypatch, capsys) -> None:
100
+ captured: dict = {}
101
+ _stub_runner(monkeypatch, captured, status="failed", rc=2, stats={"failures": {"localhost": 1}})
102
+ monkeypatch.setattr("sys.argv", ["run-playbook", "myapp", "9.9.9"])
103
+
104
+ with pytest.raises(SystemExit) as exc:
105
+ run_playbook.main_run_playbook()
106
+ assert exc.value.code == 2
107
+
108
+ out = json.loads(capsys.readouterr().out)
109
+ assert out["status"] == "failed"
110
+ assert out["changed"] is False
111
+ assert out["stats"]["failed"] == 1
112
+
113
+
114
+ def test_main_reports_bad_extra_var(monkeypatch, capsys) -> None:
115
+ monkeypatch.setattr("sys.argv", ["run-playbook", "myapp", "1.0.0", "--extra-var", "oops"])
116
+ with pytest.raises(SystemExit) as exc:
117
+ run_playbook.main_run_playbook()
118
+ assert exc.value.code == 2
119
+ out = json.loads(capsys.readouterr().out)
120
+ assert "key=value" in out["error"]