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.
- runspec_playbook-0.1.0/.gitignore +61 -0
- runspec_playbook-0.1.0/CHANGELOG.md +28 -0
- runspec_playbook-0.1.0/PKG-INFO +11 -0
- runspec_playbook-0.1.0/README.md +106 -0
- runspec_playbook-0.1.0/pyproject.toml +37 -0
- runspec_playbook-0.1.0/runspec_playbook/__init__.py +24 -0
- runspec_playbook-0.1.0/runspec_playbook/playbooks/deploy.yml +56 -0
- runspec_playbook-0.1.0/runspec_playbook/run_playbook.py +178 -0
- runspec_playbook-0.1.0/runspec_playbook/runspec.toml +75 -0
- runspec_playbook-0.1.0/tests/__init__.py +0 -0
- runspec_playbook-0.1.0/tests/test_run_playbook.py +120 -0
|
@@ -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"]
|