infrable 0.0.1__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.
- infrable/__init__.py +9 -0
- infrable/cli/__init__.py +0 -0
- infrable/cli/files.py +46 -0
- infrable/cli/main.py +69 -0
- infrable/cli/remote.py +86 -0
- infrable/cli/switch.py +15 -0
- infrable/distro/__init__.py +0 -0
- infrable/distro/linux.py +85 -0
- infrable/distro/ubuntu.py +58 -0
- infrable/errors.py +60 -0
- infrable/files.py +330 -0
- infrable/host.py +31 -0
- infrable/infra.py +106 -0
- infrable/init.py +121 -0
- infrable/meta.py +6 -0
- infrable/paths.py +8 -0
- infrable/readfile.py +22 -0
- infrable/service.py +23 -0
- infrable/switch.py +48 -0
- infrable/template.py +116 -0
- infrable/util.py +12 -0
- infrable-0.0.1.dist-info/METADATA +551 -0
- infrable-0.0.1.dist-info/RECORD +26 -0
- infrable-0.0.1.dist-info/WHEEL +4 -0
- infrable-0.0.1.dist-info/entry_points.txt +2 -0
- infrable-0.0.1.dist-info/licenses/LICENSE +201 -0
infrable/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
__version__ = "0.0.1"
|
|
2
|
+
|
|
3
|
+
from infrable.distro.linux import LinuxLatestHost
|
|
4
|
+
from infrable.distro.ubuntu import UbuntuLatestHost
|
|
5
|
+
from infrable.host import Host
|
|
6
|
+
from infrable.meta import Meta
|
|
7
|
+
from infrable.readfile import readfile
|
|
8
|
+
from infrable.service import Service
|
|
9
|
+
from infrable.switch import Switch
|
infrable/cli/__init__.py
ADDED
|
File without changes
|
infrable/cli/files.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from infrable import files
|
|
6
|
+
|
|
7
|
+
app = typer.Typer(no_args_is_help=True)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@app.command(help=files.affected_hosts.__doc__)
|
|
11
|
+
def affected_hosts(only: list[str] = typer.Option(None)):
|
|
12
|
+
for host in files.affected_hosts(only=only):
|
|
13
|
+
print(host)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@app.command(help=files.deploy.__doc__)
|
|
17
|
+
def deploy(
|
|
18
|
+
path: Path = typer.Argument(None),
|
|
19
|
+
only: list[str] = typer.Option(None),
|
|
20
|
+
yes: bool = False,
|
|
21
|
+
):
|
|
22
|
+
files.deploy(path, only=only, yes=yes)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.command(help=files.recover.__doc__)
|
|
26
|
+
def recover(path: Path = typer.Argument(None)):
|
|
27
|
+
files.recover(path)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@app.command(help=files.gen.__doc__)
|
|
31
|
+
def gen(
|
|
32
|
+
path: Path = typer.Argument(None),
|
|
33
|
+
only: list[str] = typer.Option(None),
|
|
34
|
+
):
|
|
35
|
+
files.gen(path, only=only)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
app.command()(files.backup)
|
|
39
|
+
app.command()(files.pull)
|
|
40
|
+
app.command()(files.diff)
|
|
41
|
+
app.command()(files.push)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@app.command(help=files.revert.__doc__)
|
|
45
|
+
def revert(path: Path = typer.Argument(None)):
|
|
46
|
+
files.revert(path)
|
infrable/cli/main.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from infrable import errors, infra, init
|
|
6
|
+
from infrable.cli import files, remote, switch
|
|
7
|
+
|
|
8
|
+
app = typer.Typer(no_args_is_help=True)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@app.command()
|
|
12
|
+
def hosts(format: str | None = None, repr: bool = False):
|
|
13
|
+
"""List all hosts in the infrastructure."""
|
|
14
|
+
|
|
15
|
+
for name, host in infra.hosts.items():
|
|
16
|
+
if format:
|
|
17
|
+
print(host.format(name, format=format))
|
|
18
|
+
elif repr:
|
|
19
|
+
print(f"{name} = {host.__repr__()}")
|
|
20
|
+
else:
|
|
21
|
+
print(f"{name} = {host}")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@app.command()
|
|
25
|
+
def services(format: str | None = None, repr: bool = False):
|
|
26
|
+
"""List all services in the infrastructure."""
|
|
27
|
+
|
|
28
|
+
for name, service in infra.services.items():
|
|
29
|
+
if format:
|
|
30
|
+
print(service.format(name, format=format))
|
|
31
|
+
elif repr:
|
|
32
|
+
print(f"{name} = {service.__repr__()}")
|
|
33
|
+
else:
|
|
34
|
+
print(f"{name} = {service}")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@app.command()
|
|
38
|
+
def switches(format: str | None = None, repr: bool = False):
|
|
39
|
+
"""List all switches in the infrastructure."""
|
|
40
|
+
|
|
41
|
+
for name, switch in infra.switches.items():
|
|
42
|
+
if format:
|
|
43
|
+
print(switch.format(name, format=format))
|
|
44
|
+
elif repr:
|
|
45
|
+
print(f"{name} = {switch.__repr__()}")
|
|
46
|
+
else:
|
|
47
|
+
print(f"{name} = {switch}")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@app.command(name="init")
|
|
51
|
+
def init_():
|
|
52
|
+
"""Bootstrap a dummy project."""
|
|
53
|
+
init.init()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
app.add_typer(files.app, name="files", help="Manage files.")
|
|
57
|
+
app.add_typer(switch.app, name="switch", help="Manage switches.")
|
|
58
|
+
app.add_typer(remote.app, name="remote", help="Execute remote commands.")
|
|
59
|
+
|
|
60
|
+
for name, ext in infra.typers.items():
|
|
61
|
+
app.add_typer(ext, name=name.replace("_", "-"))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
if __name__ == "__main__":
|
|
65
|
+
try:
|
|
66
|
+
app()
|
|
67
|
+
except errors.Error as e:
|
|
68
|
+
print(e, file=sys.stderr)
|
|
69
|
+
raise typer.Exit(1) from e
|
infrable/cli/remote.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
2
|
+
from typing import Iterable
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
from tqdm import tqdm
|
|
6
|
+
|
|
7
|
+
from infrable import Host, files, infra
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(no_args_is_help=True)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@app.command(name="-")
|
|
13
|
+
def from_stdin(command: str):
|
|
14
|
+
"""Execute a script on the list of hosts passed via stdin."""
|
|
15
|
+
|
|
16
|
+
hosts = []
|
|
17
|
+
for line in typer.get_text_stream("stdin").readlines():
|
|
18
|
+
host = line.split(maxsplit=1)[0].split("@", 1)[-1].strip()
|
|
19
|
+
if not host:
|
|
20
|
+
continue
|
|
21
|
+
for host in infra.filtered_hosts(only=[host]):
|
|
22
|
+
hosts.append(host)
|
|
23
|
+
|
|
24
|
+
_execute(hosts, command=command)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@app.command()
|
|
28
|
+
def infra_hosts(command: str, only: list[str] = typer.Option(None)):
|
|
29
|
+
"""Execute a script on the hosts listed in the infra."""
|
|
30
|
+
|
|
31
|
+
hosts = infra.hosts.values()
|
|
32
|
+
if only:
|
|
33
|
+
hosts = infra.filtered_hosts(only=only)
|
|
34
|
+
|
|
35
|
+
_execute(hosts, command=command)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@app.command()
|
|
39
|
+
def affected_hosts(command: str, only: list[str] = typer.Option(None)):
|
|
40
|
+
"""Execute a script on the affected hosts in the last deployment."""
|
|
41
|
+
|
|
42
|
+
hosts = files.affected_hosts()
|
|
43
|
+
|
|
44
|
+
if only:
|
|
45
|
+
only_hosts = set(h.fqdn for h in infra.filtered_hosts(only=only))
|
|
46
|
+
hosts = (h for h in hosts if h.fqdn in only_hosts)
|
|
47
|
+
|
|
48
|
+
_execute(hosts, command=command)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
for name, group in infra.host_groups.items():
|
|
52
|
+
|
|
53
|
+
def groupmain(command: str, only: list[str] = typer.Option(None)):
|
|
54
|
+
hosts = group
|
|
55
|
+
if only:
|
|
56
|
+
only_hosts = set(h.fqdn for h in infra.filtered_hosts(only=only))
|
|
57
|
+
hosts = (h for h in hosts if h.fqdn in only_hosts)
|
|
58
|
+
|
|
59
|
+
_execute(hosts, command=command)
|
|
60
|
+
|
|
61
|
+
help = f"Execute a script on {name} hosts."
|
|
62
|
+
app.command(name=name, help=help)(groupmain)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
for name, host in infra.hosts.items():
|
|
66
|
+
|
|
67
|
+
def hostmaain(command: str):
|
|
68
|
+
host.remote()(command, _fg=True)
|
|
69
|
+
|
|
70
|
+
help = f"Execute a script on {name}."
|
|
71
|
+
app.command(name=name, help=help)(hostmaain)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _execute(hosts: Iterable[Host], command: str):
|
|
75
|
+
hosts = {h.ip: h for h in hosts}.values()
|
|
76
|
+
futures = []
|
|
77
|
+
with ThreadPoolExecutor(max_workers=4) as pool:
|
|
78
|
+
for h in hosts:
|
|
79
|
+
fut = pool.submit(lambda: (h, h.remote()(command, _err_to_out=True)))
|
|
80
|
+
futures.append(fut)
|
|
81
|
+
print()
|
|
82
|
+
for result in tqdm(as_completed(futures), total=len(futures)):
|
|
83
|
+
host, result = result.result()
|
|
84
|
+
typer.secho(f"╭ {host}", bold=True)
|
|
85
|
+
print(result)
|
|
86
|
+
print()
|
infrable/cli/switch.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from click import Choice
|
|
3
|
+
|
|
4
|
+
from infrable import infra
|
|
5
|
+
|
|
6
|
+
app = typer.Typer(no_args_is_help=True)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
for name, sw in infra.switches.items():
|
|
10
|
+
help = f"Set the value of the {name} switch."
|
|
11
|
+
|
|
12
|
+
def main(value: str = typer.Argument(..., click_type=Choice(list(sw.options)))):
|
|
13
|
+
sw.set(value)
|
|
14
|
+
|
|
15
|
+
app.command(name=name, help=help)(main)
|
|
File without changes
|
infrable/distro/linux.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from shlex import quote as q
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from infrable import errors
|
|
6
|
+
from infrable.host import Host
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(unsafe_hash=True, order=True, eq=True)
|
|
10
|
+
class LinuxLatestHost(Host):
|
|
11
|
+
"""A latest version of generic Linux host."""
|
|
12
|
+
|
|
13
|
+
authorized_keys: str = ""
|
|
14
|
+
users: dict[str, dict[str, str]] = field(default_factory=dict)
|
|
15
|
+
|
|
16
|
+
def setup(self, as_root: bool = False):
|
|
17
|
+
self.ensure_admin(as_root=as_root)
|
|
18
|
+
self.disable_root_login()
|
|
19
|
+
self.ensure_hostname()
|
|
20
|
+
self.ensure_users()
|
|
21
|
+
|
|
22
|
+
def ensure_hostname(self, fqdn: str | None = None):
|
|
23
|
+
fqdn = fqdn or self.fqdn
|
|
24
|
+
if not fqdn:
|
|
25
|
+
raise errors.FQDNMustNotBeEmptyError()
|
|
26
|
+
self.remote().sudo.tee("/etc/hostname", _in=fqdn.split(".", 1)[0], _fg=True)
|
|
27
|
+
|
|
28
|
+
def ensure_admin(
|
|
29
|
+
self,
|
|
30
|
+
username: str | None = None,
|
|
31
|
+
authorized_keys: str | None = None,
|
|
32
|
+
as_root: bool = False,
|
|
33
|
+
):
|
|
34
|
+
username = username or self.admin
|
|
35
|
+
self.ensure_user(username, as_root=as_root)
|
|
36
|
+
self.ensure_authorized_keys(username, keys=authorized_keys, as_root=as_root)
|
|
37
|
+
self.ensure_sudoer(username, as_root=as_root)
|
|
38
|
+
|
|
39
|
+
def ensure_sudoer(
|
|
40
|
+
self,
|
|
41
|
+
username: Optional[str] = None,
|
|
42
|
+
access: str = "ALL=(ALL) NOPASSWD:ALL",
|
|
43
|
+
as_root: bool = False,
|
|
44
|
+
):
|
|
45
|
+
conn = self.remote("root") if as_root else self.remote().sudo
|
|
46
|
+
username = username or self.admin
|
|
47
|
+
if not conn.ls(q(f"/etc/sudoers.d/{username}"), _ok_code=[0, 2]):
|
|
48
|
+
conn.tee(
|
|
49
|
+
q(f"/etc/sudoers.d/{username}"),
|
|
50
|
+
_in=f"{username} {access}",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
conn.chmod("0440", q(f"/etc/sudoers.d/{username}"))
|
|
54
|
+
self.remote(username).sudo.echo("sudo setup successful", _fg=True)
|
|
55
|
+
|
|
56
|
+
def ensure_user(
|
|
57
|
+
self, username: str, shell: str = "/bin/bash", as_root: bool = False
|
|
58
|
+
):
|
|
59
|
+
conn = self.remote("root") if as_root else self.remote().sudo
|
|
60
|
+
if not conn.id(q(username), _ok_code=[0, 1]):
|
|
61
|
+
conn.useradd("-m", "-s", q(shell), q(username), _fg=True)
|
|
62
|
+
|
|
63
|
+
def ensure_users(self, users: dict[str, dict[str, str]] | None = None):
|
|
64
|
+
for username, config in (users or self.users).items():
|
|
65
|
+
self.ensure_user(username, **config, as_root=False)
|
|
66
|
+
|
|
67
|
+
def ensure_authorized_keys(
|
|
68
|
+
self, username: str, keys: str | None, as_root: bool = False
|
|
69
|
+
):
|
|
70
|
+
conn = self.remote("root") if as_root else self.remote().sudo
|
|
71
|
+
sshdir = f"/home/{username}/.ssh"
|
|
72
|
+
dest = f"/{sshdir}/authorized_keys"
|
|
73
|
+
conn.mkdir("-pv", q(sshdir), _fg=True)
|
|
74
|
+
conn.tee(q(dest), _in=keys or self.authorized_keys)
|
|
75
|
+
conn.chown("-R", q(f"{username}:{username}"), sshdir, _fg=True)
|
|
76
|
+
conn.chmod("0600", dest, _fg=True)
|
|
77
|
+
self.remote(login=username).echo(f"{username}: ssh setup successful", _fg=True)
|
|
78
|
+
|
|
79
|
+
def disable_root_login(self):
|
|
80
|
+
sudo_conn = self.remote().sudo
|
|
81
|
+
if sudo_conn.grep(
|
|
82
|
+
q("^PermitRootLogin\\syes"), "/etc/ssh/sshd_config", _ok_code=[0, 1]
|
|
83
|
+
):
|
|
84
|
+
sudo_conn.sed("-i", "/^PermitRootLogin/s/yes/no/", "/etc/ssh/sshd_config")
|
|
85
|
+
sudo_conn.systemctl.reload.ssh()
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from shlex import quote as q
|
|
3
|
+
|
|
4
|
+
from infrable.distro.linux import LinuxLatestHost
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(unsafe_hash=True, order=True, eq=True)
|
|
8
|
+
class UbuntuLatestHost(LinuxLatestHost):
|
|
9
|
+
"""A latest version of Ubuntu."""
|
|
10
|
+
|
|
11
|
+
packages: list[str] = field(default_factory=list)
|
|
12
|
+
|
|
13
|
+
def setup(self, as_root: bool = False):
|
|
14
|
+
super().setup(as_root=as_root)
|
|
15
|
+
self.upgrade_packages()
|
|
16
|
+
self.update_packages()
|
|
17
|
+
self.ensure_apt_packages()
|
|
18
|
+
|
|
19
|
+
def upgrade_packages(self):
|
|
20
|
+
self.remote().sudo(
|
|
21
|
+
"apt-get", "-o", "DPkg::Lock::Timeout=60", "upgrade", "-y", _fg=True
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
def update_packages(self):
|
|
25
|
+
self.remote().sudo(
|
|
26
|
+
"apt-get", "-o", "DPkg::Lock::Timeout=60", "update", "-y", _fg=True
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
def ensure_apt_packages(self, packages: list[str] | None = None):
|
|
30
|
+
packages = packages or self.packages
|
|
31
|
+
if packages:
|
|
32
|
+
self.remote().sudo(
|
|
33
|
+
"apt-get", "-o", "DPkg::Lock::Timeout=60", "update", "-y", _fg=True
|
|
34
|
+
)
|
|
35
|
+
package_batches = [
|
|
36
|
+
self.packages[i : i + 10] for i in range(0, len(packages), 10)
|
|
37
|
+
]
|
|
38
|
+
for batch in package_batches:
|
|
39
|
+
self.remote().sudo(
|
|
40
|
+
"apt-get",
|
|
41
|
+
"-o",
|
|
42
|
+
"DPkg::Lock::Timeout=60",
|
|
43
|
+
"install",
|
|
44
|
+
"-y",
|
|
45
|
+
*[q(pkg) for pkg in batch],
|
|
46
|
+
_fg=True,
|
|
47
|
+
)
|
|
48
|
+
self.remote().sudo(
|
|
49
|
+
"apt-get", "-o", "DPkg::Lock::Timeout=60", "autoremove", "-y", _fg=True
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def disable_root_login(self):
|
|
53
|
+
sudo_conn = self.remote().sudo
|
|
54
|
+
if sudo_conn.grep(
|
|
55
|
+
q("^PermitRootLogin\\syes"), "/etc/ssh/sshd_config", _ok_code=[0, 1]
|
|
56
|
+
):
|
|
57
|
+
sudo_conn.sed("-i", "/^PermitRootLogin/s/yes/no/", "/etc/ssh/sshd_config")
|
|
58
|
+
sudo_conn.systemctl.reload.ssh()
|
infrable/errors.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
class Error(Exception):
|
|
2
|
+
pass
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class FQDNMustNotBeEmptyError(Error, ValueError):
|
|
6
|
+
def __init__(self):
|
|
7
|
+
super().__init__(f"Error: FQDN must not be empty")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MissingMetadataError(Error, ValueError):
|
|
11
|
+
def __init__(self, src):
|
|
12
|
+
super().__init__(f"missing metadata in template: {src}")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MissingDestinationError(Error, ValueError):
|
|
16
|
+
def __init__(self, src):
|
|
17
|
+
super().__init__(f"missing `dest` in template metadata: {src}")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class InvalidDestinationError(Error, ValueError):
|
|
21
|
+
def __init__(self, dest):
|
|
22
|
+
super().__init__(f"invalid destination: {dest}")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class InfraDotPyNotFoundError(Error, ImportError):
|
|
26
|
+
def __init__(self, modulename):
|
|
27
|
+
super().__init__(f"{modulename}.py: file not found")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class HostOrServiceNotFoundError(Error, KeyError):
|
|
31
|
+
def __init__(self, name):
|
|
32
|
+
super().__init__(f"{name}: host or service not found")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SwitchValueError(Error, ValueError):
|
|
36
|
+
def __init__(self, path, value, options):
|
|
37
|
+
options = ", ".join(options)
|
|
38
|
+
super().__init__(
|
|
39
|
+
f"Invalid value {value} for switch {path}. Options are: {options}"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SwitchCaseError(Error, ValueError):
|
|
44
|
+
def __init__(self, path, options, cases):
|
|
45
|
+
_options = ", ".join(options)
|
|
46
|
+
_cases = ", ".join(cases.keys())
|
|
47
|
+
|
|
48
|
+
super().__init__(
|
|
49
|
+
f"{path}: all options are not covered by cases."
|
|
50
|
+
f" Options are: {_options}, Cases are: {_cases}"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class SwitchError(Error, ValueError):
|
|
55
|
+
def __init__(self, path, val, cases):
|
|
56
|
+
_cases = ", ".join(cases.keys())
|
|
57
|
+
super().__init__(
|
|
58
|
+
f"{path}: switch value is set to {val},"
|
|
59
|
+
f" but no case matched. Cases are: {_cases}"
|
|
60
|
+
)
|