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 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
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
@@ -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
+ )