netadopt 0.1.0__py3-none-any.whl
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.
- netadopt/__init__.py +2 -0
- netadopt/ansible.py +144 -0
- netadopt/ansible_yaml.py +80 -0
- netadopt/ansiblecfg.py +74 -0
- netadopt/cli.py +574 -0
- netadopt/files.py +108 -0
- netadopt/inventory.py +128 -0
- netadopt/inventoryfile.py +97 -0
- netadopt/playbook.py +171 -0
- netadopt/pools.py +116 -0
- netadopt/reconstruct.py +242 -0
- netadopt/repocode.py +112 -0
- netadopt/varfiles.py +137 -0
- netadopt/xr.py +301 -0
- netadopt-0.1.0.dist-info/METADATA +178 -0
- netadopt-0.1.0.dist-info/RECORD +19 -0
- netadopt-0.1.0.dist-info/WHEEL +4 -0
- netadopt-0.1.0.dist-info/entry_points.txt +3 -0
- netadopt-0.1.0.dist-info/licenses/LICENSE +202 -0
netadopt/__init__.py
ADDED
netadopt/ansible.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Finding the Ansible to run.
|
|
2
|
+
|
|
3
|
+
PATH first, the `ansible` extra behind it; `source` says which one answered.
|
|
4
|
+
The answer is always a value -- never an exception, never sys.exit.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
from dataclasses import dataclass, replace
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
# Probed rather than `ansible`: it is the one being run, and on a broken install the
|
|
17
|
+
# two do not always agree.
|
|
18
|
+
PLAYBOOK_EXE = "ansible-playbook"
|
|
19
|
+
|
|
20
|
+
_CORE = re.compile(r"\[core ([^\]]+)\]") # 2.10+ "ansible-playbook [core 2.16.3]"
|
|
21
|
+
_OLD = re.compile(r"^\S+\s+([0-9][^\s]*)") # 2.9 "ansible-playbook 2.9.27"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Ansible:
|
|
26
|
+
"""What `ansible-playbook --version` said, or why it said nothing."""
|
|
27
|
+
|
|
28
|
+
exe: str | None = None
|
|
29
|
+
core: str | None = None
|
|
30
|
+
python: str | None = None
|
|
31
|
+
config_file: str | None = None
|
|
32
|
+
collections: tuple[str, ...] = ()
|
|
33
|
+
problem: str | None = None # set if and only if unusable
|
|
34
|
+
source: str | None = None # "given" | "PATH" | "bundled"
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def usable(self) -> bool:
|
|
38
|
+
return self.problem is None
|
|
39
|
+
|
|
40
|
+
def beside(self, name: str) -> Path | None:
|
|
41
|
+
"""Another executable from this same install, or None if it is not there.
|
|
42
|
+
|
|
43
|
+
Taken from this install's own directory, not from PATH.
|
|
44
|
+
"""
|
|
45
|
+
if self.exe is None:
|
|
46
|
+
return None
|
|
47
|
+
found = Path(self.exe).with_name(name)
|
|
48
|
+
return found if found.exists() else None
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def bundled(self) -> bool:
|
|
52
|
+
"""True when the fallback answered, and not an Ansible already installed."""
|
|
53
|
+
return self.source == "bundled"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def find_ansible(exe: str | None = None) -> Ansible:
|
|
57
|
+
"""Locate an ansible-playbook and read its --version.
|
|
58
|
+
|
|
59
|
+
`exe` overrides PATH -- a name or a path; a venv is selected by pointing here.
|
|
60
|
+
"""
|
|
61
|
+
target = exe or PLAYBOOK_EXE
|
|
62
|
+
found = shutil.which(target)
|
|
63
|
+
if found is None:
|
|
64
|
+
# a typed path and a name looked up on PATH fail for different reasons
|
|
65
|
+
where = "not found" if Path(target).name != target else "not found on PATH"
|
|
66
|
+
return Ansible(problem=f"{target} {where}")
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
done = subprocess.run(
|
|
70
|
+
[found, "--version"],
|
|
71
|
+
check=False,
|
|
72
|
+
capture_output=True,
|
|
73
|
+
text=True,
|
|
74
|
+
timeout=60,
|
|
75
|
+
# closed, so a subprocess cannot stop for a prompt
|
|
76
|
+
stdin=subprocess.DEVNULL,
|
|
77
|
+
)
|
|
78
|
+
except OSError as err: # not executable, wrong architecture, bad interpreter
|
|
79
|
+
return Ansible(exe=found, problem=f"{found} could not be run: {err}")
|
|
80
|
+
except subprocess.TimeoutExpired:
|
|
81
|
+
return Ansible(exe=found, problem=f"{found} --version did not return in 60s")
|
|
82
|
+
|
|
83
|
+
if done.returncode != 0:
|
|
84
|
+
# an install broken by its own dependencies fails here; its stderr, whole
|
|
85
|
+
detail = (done.stderr or done.stdout).strip() or f"exit {done.returncode}"
|
|
86
|
+
return Ansible(exe=found, problem=f"{found} --version failed: {detail}")
|
|
87
|
+
|
|
88
|
+
return _parse(found, done.stdout)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def resolve_ansible(exe: str | None = None) -> Ansible:
|
|
92
|
+
"""PATH first, the bundled ansible-core second."""
|
|
93
|
+
if exe:
|
|
94
|
+
return replace(find_ansible(exe), source="given")
|
|
95
|
+
|
|
96
|
+
found = find_ansible()
|
|
97
|
+
if found.usable:
|
|
98
|
+
return replace(found, source="PATH")
|
|
99
|
+
|
|
100
|
+
# The extra installs ansible-playbook beside this interpreter. Under uvx that
|
|
101
|
+
# directory need not be on PATH, so it is found by location and not by name.
|
|
102
|
+
bundled = Path(sys.executable).parent / PLAYBOOK_EXE
|
|
103
|
+
if not bundled.exists():
|
|
104
|
+
return found # keep the PATH problem: with no fallback it is the one to report
|
|
105
|
+
return replace(find_ansible(str(bundled)), source="bundled")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _parse(exe: str, out: str) -> Ansible:
|
|
109
|
+
lines = out.splitlines()
|
|
110
|
+
head = lines[0] if lines else ""
|
|
111
|
+
match = _CORE.search(head) or _OLD.match(head)
|
|
112
|
+
core = match.group(1) if match else None
|
|
113
|
+
|
|
114
|
+
# The rest is " key = value", and unknown keys are ignored rather than
|
|
115
|
+
# rejected: the set has changed across releases and will change again.
|
|
116
|
+
fields: dict[str, str] = {}
|
|
117
|
+
for line in lines[1:]:
|
|
118
|
+
key, sep, value = line.partition("=")
|
|
119
|
+
if sep:
|
|
120
|
+
fields[key.strip()] = value.strip()
|
|
121
|
+
|
|
122
|
+
python = fields.get("python version", "").split(" ", 1)[0] or None
|
|
123
|
+
config = fields.get("config file") or None
|
|
124
|
+
if config == "None": # Ansible's own word for "no ansible.cfg was found"
|
|
125
|
+
config = None
|
|
126
|
+
# "ansible collection location", with the prefix -- unlike "config file".
|
|
127
|
+
raw = fields.get("ansible collection location", "")
|
|
128
|
+
collections = tuple(p for p in raw.split(":") if p)
|
|
129
|
+
|
|
130
|
+
if core is None:
|
|
131
|
+
return Ansible(
|
|
132
|
+
exe=exe,
|
|
133
|
+
python=python,
|
|
134
|
+
config_file=config,
|
|
135
|
+
collections=collections,
|
|
136
|
+
problem=f"could not read a version from: {head!r}",
|
|
137
|
+
)
|
|
138
|
+
return Ansible(
|
|
139
|
+
exe=exe,
|
|
140
|
+
core=core,
|
|
141
|
+
python=python,
|
|
142
|
+
config_file=config,
|
|
143
|
+
collections=collections,
|
|
144
|
+
)
|
netadopt/ansible_yaml.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Reading and writing YAML the way Ansible does.
|
|
2
|
+
|
|
3
|
+
`!vault` and `!unsafe` are Ansible's, and `yaml.safe_load` raises on both. Here they
|
|
4
|
+
become the shapes `ansible-inventory --list` prints: {"__ansible_vault": ...} and
|
|
5
|
+
{"__ansible_unsafe": ...}. `dump` writes those shapes back as the tags.
|
|
6
|
+
|
|
7
|
+
Any other tag still raises, with its name and the line it is on.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import io
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import yaml
|
|
16
|
+
|
|
17
|
+
VAULT_KEY = "__ansible_vault"
|
|
18
|
+
UNSAFE_KEY = "__ansible_unsafe"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AnsibleLoader(yaml.SafeLoader):
|
|
22
|
+
"""SafeLoader plus the two tags Ansible puts in ordinary vars files."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _vault(loader: AnsibleLoader, node: yaml.Node) -> dict[str, str]:
|
|
26
|
+
# the ciphertext, whole -- carrying it needs no password
|
|
27
|
+
return {VAULT_KEY: loader.construct_scalar(node)}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _unsafe(loader: AnsibleLoader, node: yaml.Node) -> dict[str, str]:
|
|
31
|
+
# !unsafe means "do not template this". Losing the marker would turn a literal
|
|
32
|
+
# {{ ... }} into something Ansible would try to resolve.
|
|
33
|
+
return {UNSAFE_KEY: loader.construct_scalar(node)}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
AnsibleLoader.add_constructor("!vault", _vault)
|
|
37
|
+
AnsibleLoader.add_constructor("!unsafe", _unsafe)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def load(text: str, path: Path | str | None = None) -> object:
|
|
41
|
+
"""Parse one document. Raises yaml.YAMLError, naming `path` in the message."""
|
|
42
|
+
stream = io.StringIO(text)
|
|
43
|
+
if path is not None:
|
|
44
|
+
# PyYAML reports the stream name in its error marks: the filename, not
|
|
45
|
+
# "<unicode string>".
|
|
46
|
+
stream.name = str(path)
|
|
47
|
+
return yaml.load(stream, AnsibleLoader)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class AnsibleDumper(yaml.SafeDumper):
|
|
51
|
+
"""SafeDumper writing the two shapes `load` makes back as Ansible's tags."""
|
|
52
|
+
|
|
53
|
+
def ignore_aliases(self, data: object) -> bool:
|
|
54
|
+
# an object met twice is written twice, never as &anchor and *alias
|
|
55
|
+
return True
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _mapping(dumper: AnsibleDumper, data: dict) -> yaml.Node:
|
|
59
|
+
if len(data) == 1:
|
|
60
|
+
((key, value),) = data.items()
|
|
61
|
+
if key == VAULT_KEY and isinstance(value, str):
|
|
62
|
+
return dumper.represent_scalar("!vault", value, style="|")
|
|
63
|
+
if key == UNSAFE_KEY and isinstance(value, str):
|
|
64
|
+
return dumper.represent_scalar("!unsafe", value)
|
|
65
|
+
return dumper.represent_dict(data)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
AnsibleDumper.add_representer(dict, _mapping)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def dump(data: object) -> str:
|
|
72
|
+
"""One document, keys in their own order, long strings never folded."""
|
|
73
|
+
return yaml.dump(
|
|
74
|
+
data,
|
|
75
|
+
Dumper=AnsibleDumper,
|
|
76
|
+
sort_keys=False,
|
|
77
|
+
allow_unicode=True,
|
|
78
|
+
default_flow_style=False,
|
|
79
|
+
width=float("inf"),
|
|
80
|
+
)
|
netadopt/ansiblecfg.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""ansible.cfg as the repository writes it, for `Fabric.spec.ansibleCfg`.
|
|
2
|
+
|
|
3
|
+
The one in the repository's root, and only that one: it is where Ansible looks when
|
|
4
|
+
run in the repository. `ANSIBLE_CONFIG`, `~/.ansible.cfg` and `/etc/ansible/ansible.cfg`
|
|
5
|
+
belong to whoever runs it.
|
|
6
|
+
|
|
7
|
+
Parsed as Ansible parses it -- stdlib configparser, `;` as an inline comment, keys
|
|
8
|
+
folded to lower case, a duplicate key an error -- so a value here is the value Ansible
|
|
9
|
+
reads. Two settings keep the text as written: no interpolation, so a `%(name)s` is
|
|
10
|
+
left for Ansible to expand; and `[DEFAULT]` stays a section of its own instead of
|
|
11
|
+
being copied into every other one.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import configparser
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
CONFIG_FILE = "ansible.cfg"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class AnsibleCfg:
|
|
25
|
+
path: Path | None = None # None when the repository has no ansible.cfg
|
|
26
|
+
sections: dict[str, dict[str, str]] = field(default_factory=dict)
|
|
27
|
+
problem: str | None = None
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def usable(self) -> bool:
|
|
31
|
+
return self.problem is None
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def inventory(self) -> tuple[str, ...]:
|
|
35
|
+
"""The sources `[defaults] inventory` names, split on commas as Ansible splits them."""
|
|
36
|
+
raw = self.sections.get("defaults", {}).get("inventory", "")
|
|
37
|
+
return tuple(part.strip() for part in raw.split(",") if part.strip())
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def vault_password_file(self) -> str | None:
|
|
41
|
+
"""The path `[defaults] vault_password_file` names, as written."""
|
|
42
|
+
return self.sections.get("defaults", {}).get("vault_password_file") or None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def read_ansible_cfg(repo: Path) -> AnsibleCfg:
|
|
46
|
+
"""`repo/ansible.cfg`, section by section, every value the string it is written as.
|
|
47
|
+
|
|
48
|
+
No file is not a problem: Ansible then runs on its defaults, and `sections` is empty.
|
|
49
|
+
"""
|
|
50
|
+
path = repo / CONFIG_FILE
|
|
51
|
+
if not path.is_file():
|
|
52
|
+
return AnsibleCfg()
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
text = path.read_text(encoding="utf-8")
|
|
56
|
+
except UnicodeDecodeError as err:
|
|
57
|
+
return AnsibleCfg(path=path, problem=f"{path} is not UTF-8: {err}")
|
|
58
|
+
except OSError as err:
|
|
59
|
+
return AnsibleCfg(path=path, problem=f"{path} could not be read: {err}")
|
|
60
|
+
|
|
61
|
+
parser = configparser.ConfigParser(
|
|
62
|
+
inline_comment_prefixes=(";",),
|
|
63
|
+
interpolation=None,
|
|
64
|
+
# A section header is never empty, so no section is the default one.
|
|
65
|
+
default_section="",
|
|
66
|
+
)
|
|
67
|
+
try:
|
|
68
|
+
parser.read_string(text, source=str(path))
|
|
69
|
+
except configparser.Error as err:
|
|
70
|
+
# what Ansible stops on as well: a duplicate key, a line before any section
|
|
71
|
+
return AnsibleCfg(path=path, problem=str(err).replace("\n", " "))
|
|
72
|
+
|
|
73
|
+
sections = {name: dict(parser[name]) for name in parser.sections()}
|
|
74
|
+
return AnsibleCfg(path=path, sections=sections)
|