From b128bdd08e9c8dcdf81f8d96691b1abdd2e0999a Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Fri, 13 Oct 2023 16:34:11 +0100 Subject: [PATCH 01/87] Basic keys working --- .gitignore | 5 +++ example/simple.json | 25 +++++++++++ pyproject.toml | 13 +++++- src/privateer2/cli.py | 33 ++++++++++++++ src/privateer2/config.py | 48 ++++++++++++++++++++ src/privateer2/keys.py | 95 ++++++++++++++++++++++++++++++++++++++++ src/privateer2/util.py | 69 +++++++++++++++++++++++++++++ src/privateer2/vault.py | 30 +++++++++++++ tests/test_config.py | 30 +++++++++++++ tests/test_keys.py | 65 +++++++++++++++++++++++++++ 10 files changed, 412 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 example/simple.json create mode 100644 src/privateer2/cli.py create mode 100644 src/privateer2/config.py create mode 100644 src/privateer2/keys.py create mode 100644 src/privateer2/util.py create mode 100644 src/privateer2/vault.py create mode 100644 tests/test_config.py create mode 100644 tests/test_keys.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c6625cd --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +*.pyc +__pycache__ +dist/ +.coverage +coverage.xml diff --git a/example/simple.json b/example/simple.json new file mode 100644 index 0000000..b351a1e --- /dev/null +++ b/example/simple.json @@ -0,0 +1,25 @@ +{ + "servers": [ + { + "name": "alice", + "hostname": "alice.example.com", + "port": 22 + } + ], + "clients": [ + { + "name": "bob", + "backup": ["data"], + "restore": ["data"] + } + ], + "volumes": [ + { + "name": "data" + } + ], + "vault": { + "url": "http://localhost:8200", + "prefix": "/secret/privateer" + } +} diff --git a/pyproject.toml b/pyproject.toml index bfcc9b5..cc91a9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,20 +24,31 @@ classifiers = [ "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", ] -dependencies = [] +dependencies = [ + "cryptography", + "docker", + "docopt", + "hvac", + "pydantic" +] [project.urls] Documentation = "https://github.com/unknown/privateer2#readme" Issues = "https://github.com/unknown/privateer2/issues" Source = "https://github.com/unknown/privateer2" +[project.scripts] +privateer2 = "privateer2.cli:main" + [tool.hatch.version] path = "src/privateer2/__about__.py" [tool.hatch.envs.default] +python = "python3" dependencies = [ "coverage[toml]>=6.5", "pytest", + "vault-dev" ] [tool.hatch.envs.default.scripts] test = "pytest {args:tests}" diff --git a/src/privateer2/cli.py b/src/privateer2/cli.py new file mode 100644 index 0000000..5cad2c7 --- /dev/null +++ b/src/privateer2/cli.py @@ -0,0 +1,33 @@ +"""Usage: + privateer2 --version + privateer2 [-f=PATH] keygen ... + privateer2 [-f=PATH] configure + privateer2 backup [--server=NAME --include=NAME... --exclude=NAME...] + +Options: + -f=PATH The path to the privateer configuration [default: privateer.json]. + --server The name of the server to back up to, if more than one configured + --include Volumes to include in the backup + --exclude Volumes to exclude from the backup +""" + +import docopt + +from privateer2.keys import configure, keygen +from privateer2.backup import backup + +def main(argv=None): + opts = docopt.docopt(__doc__, argv) + if opts["--version"]: + return about.__version__ + path_config = opts["-f"] + cfg = read_config(path_config) + if opts["keygen"]: + keygen(cfg, opts["..."]) + elif opts["configure"]: + configure(cfg, opts[""]) + elif opts["backup"]: + server = opts["--server"] + include = opts["--include"] + exclude = opts["--exclude"] + backup(cfg, sever, include, exclude) diff --git a/src/privateer2/config.py b/src/privateer2/config.py new file mode 100644 index 0000000..5c13658 --- /dev/null +++ b/src/privateer2/config.py @@ -0,0 +1,48 @@ +import json +from typing import List +from pydantic import BaseModel + +from privateer2.vault import vault_client + +def read_config(path): + with open(path) as f: + return Config(**json.loads(f.read().strip())) + + +class Server(BaseModel): + name: str + hostname: str + port: int + key_volume: str = "privateer_keys" + + +class Client(BaseModel): + name: str + backup: List[str] + restore: List[str] + key_volume: str = "privateer_keys" + + +class Volume(BaseModel): + name: str + + +class Vault(BaseModel): + url: str + prefix: str + + def client(self): + return vault_client(self.url) + + +class Config(BaseModel): + servers: List[Server] + clients: List[Client] + volumes: List[Volume] + vault: Vault + + def list_servers(self): + return [x.name for x in self.servers] + + def list_clients(self): + return [x.name for x in self.clients] diff --git a/src/privateer2/keys.py b/src/privateer2/keys.py new file mode 100644 index 0000000..131bb55 --- /dev/null +++ b/src/privateer2/keys.py @@ -0,0 +1,95 @@ +import docker +import hvac +import re +import os +from cryptography.hazmat.primitives import serialization as crypto_serialization +from cryptography.hazmat.primitives.asymmetric import rsa + +from privateer2.util import string_to_container + + +def keygen(cfg, name): + vault = cfg.vault.client() + data = _create_keypair() + path = f"{cfg.vault.prefix}/{name}" + # TODO: The docs are here: + # https://hvac.readthedocs.io/en/stable/usage/secrets_engines/kv_v1.html + # They do not indicate if this will error if the write fails though. + print(f"Writing key for {name}") + _r = vault.secrets.kv.v1.create_or_update_secret(path, secret=data) + + +def configure(cfg, name): + cl = docker.from_env() + data = _keys_data(cfg, name) + + image = "alpine" + volume_name = _key_volume_name(cfg, name) + v = cl.volumes.create(volume_name) + mounts = [docker.types.Mount("/keys", volume_name, type="volume")] + container = cl.containers.create(image, mounts=mounts) + try: + string_to_container(data["private"], container, "/keys/id_rsa", + uid=0, gid=0, mode=0o600) + string_to_container(data["public"], container, "/keys/id_rsa.pub", + uid=0, gid=0, mode=0o644) + if data["authorized_keys"]: + string_to_container(data["authorized_keys"], container, + "/keys/authorized_keys", + uid=0, gid=0, mode=0o644) + if data["known_hosts"]: + string_to_container(data["known_hosts"], container, + "/keys/known_hosts", + uid=0, gid=0, mode=0o644) + string_to_container(f"{name}\n", container, "/keys/name", + uid=0, gid=0, mode=0o644) + finally: + container.remove() + + +def _get_pubkeys(vault, prefix, nms): + return { + nm: vault.secrets.kv.v1.read_secret(f"{prefix}/{nm}")["data"]["public"] + for nm in nms + } + + +def _create_keypair(): + key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048 + ) + + private = key.private_bytes( + crypto_serialization.Encoding.PEM, + crypto_serialization.PrivateFormat.PKCS8, + crypto_serialization.NoEncryption() + ).decode("UTF-8") + + public = key.public_key().public_bytes( + crypto_serialization.Encoding.OpenSSH, + crypto_serialization.PublicFormat.OpenSSH + ).decode("UTF-8") + + return {"public": public, "private": private} + + +def _keys_data(cfg, name): + vault = cfg.vault.client() + response = vault.secrets.kv.v1.read_secret(f"{cfg.vault.prefix}/{name}") + ret = {"name": name, **response["data"], "authorized_keys": None, "known_hosts": None} + if name in cfg.list_servers(): + keys = _get_pubkeys(vault, cfg.vault.prefix, cfg.list_clients()) + ret["authorized_keys"] = "".join([f"{v}\n" for v in keys.values()]) + if name in cfg.list_clients(): + keys = _get_pubkeys(vault, cfg.vault.prefix, cfg.list_servers()) + ret["known_hosts"] = "".join([f"{k} {v}\n" for k, v in keys.items()]) + return ret + + +def _key_volume_name(cfg, name): + for el in cfg.servers + cfg.clients: + if el.name == name: + return el.key_volume + msg = "Invalid configuration, can't determine volume name" + raise Exception(msg) diff --git a/src/privateer2/util.py b/src/privateer2/util.py new file mode 100644 index 0000000..c4d26fd --- /dev/null +++ b/src/privateer2/util.py @@ -0,0 +1,69 @@ +from contextlib import contextmanager + +import docker +import tarfile +import tempfile +import os.path +import os + + +def string_to_container(text, container, path, **kwargs): + with simple_tar_string(text, os.path.basename(path), **kwargs) as tar: + container.put_archive(os.path.dirname(path), tar) + + +def set_permissions(mode=None, uid=None, gid=None): + def ret(tarinfo): + if mode is not None: + tarinfo.mode = mode + if uid is not None: + tarinfo.uid = uid + if gid is not None: + tarinfo.gid = gid + return tarinfo + return ret + + +def simple_tar_string(text, name, **kwargs): + if isinstance(text, str): + text = bytes(text, "utf-8") + try: + fd, tmp = tempfile.mkstemp(text=True) + with os.fdopen(fd, "wb") as f: + f.write(text) + return simple_tar(tmp, name, **kwargs) + finally: + os.remove(tmp) + + +def simple_tar(path, name, **kwargs): + f = tempfile.NamedTemporaryFile() + t = tarfile.open(mode="w", fileobj=f) + abs_path = os.path.abspath(path) + t.add(abs_path, arcname=name, recursive=False, + filter=set_permissions(**kwargs)) + t.close() + f.seek(0) + return f + + +@contextmanager +def transient_envvar(**kwargs): + prev = { + k: os.environ[k] if k in os.environ else None + for k in kwargs.keys() + } + try: + _setdictvals(kwargs, os.environ) + yield + finally: + _setdictvals(prev, os.environ) + + +def _setdictvals(new, container): + for k, v in new.items(): + if v is None: + del container[k] + else: + container[k] = v + return container diff --git a/src/privateer2/vault.py b/src/privateer2/vault.py new file mode 100644 index 0000000..4dd5bcb --- /dev/null +++ b/src/privateer2/vault.py @@ -0,0 +1,30 @@ +import os +import re + +import hvac + + +def vault_client(addr, token=None): + re_gh = re.compile("^ghp_[A-Za-z0-9]{36}$") + re_vault = re.compile("^(hv)?s\\.{80}$") + token = _get_vault_token(token) + if re_gh.match(token): + print("logging into vault using github") + client = hvac.Client(addr) + client.github.login(token) + else: + client = hvac.Client(addr, token=token) + return client + + +def _get_vault_token(token): + if token is not None: + re_envvar = re.compile("^\\$[A-Z0-9_-]+$") + if re_envvar.match(token): + token = os.environ[token[1:]] + return token + check = ["VAULT_TOKEN", "VAULT_AUTH_GITHUB_TOKEN"] + for token_type in check: + if token_type in os.environ: + return os.environ[token_type] + return input("Enter token for vault: ").strip() diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..04bdbdc --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,30 @@ +from vault_dev import Server as vault_test_server + +from privateer2.config import read_config +from privateer2.util import transient_envvar + + +def test_can_read_config(): + cfg = read_config("example/simple.json") + assert len(cfg.servers) == 1 + assert cfg.servers[0].name == "alice" + assert cfg.servers[0].hostname == "alice.example.com" + assert cfg.servers[0].port == 22 + assert len(cfg.clients) == 1 + assert cfg.clients[0].name == "bob" + assert cfg.clients[0].backup == ["data"] + assert cfg.clients[0].restore == ["data"] + assert len(cfg.volumes) == 1 + assert cfg.volumes[0].name == "data" + assert cfg.vault.url == "http://localhost:8200" + assert cfg.vault.prefix == "/secret/privateer" + assert cfg.list_servers() == ["alice"] + assert cfg.list_clients() == ["bob"] + + +def test_can_create_vault_client(): + cfg = read_config("example/simple.json") + with vault_test_server(export_token=True) as server: + cfg.vault.url = server.url() + client = cfg.vault.client() + assert client.is_authenticated() diff --git a/tests/test_keys.py b/tests/test_keys.py new file mode 100644 index 0000000..a8f536d --- /dev/null +++ b/tests/test_keys.py @@ -0,0 +1,65 @@ +import docker +import random +import string + +from vault_dev import Server as vault_test_server + +from privateer2.config import read_config +from privateer2.keys import configure, keygen +from privateer2.util import transient_envvar + + +def rand_str(n=8): + return ''.join(random.choices(string.ascii_lowercase + string.digits, k=n)) + + +def test_can_create_keys(): + with vault_test_server(export_token=True) as server: + cfg = read_config("example/simple.json") + cfg.vault.url = server.url() + keygen(cfg, "alice") + client = cfg.vault.client() + response = client.secrets.kv.v1.read_secret("/secret/privateer/alice") + pair = response["data"] + assert set(pair.keys()) == {"private", "public"} + assert pair["public"].startswith("ssh-rsa") + assert "PRIVATE KEY" in pair["private"] + + +def test_can_unpack_keys_for_server(): + with vault_test_server(export_token=True) as server: + cfg = read_config("example/simple.json") + cfg.vault.url = server.url() + vol = f"privateer_keys_{rand_str()}" + cfg.servers[0].key_volume = vol + keygen(cfg, "alice") + keygen(cfg, "bob") + configure(cfg, "alice") + client = docker.from_env() + mounts = [docker.types.Mount("/keys", vol, type="volume")] + res = client.containers.run("alpine", mounts=mounts, + command=["ls", "/keys"], remove=True) + assert set(res.decode("UTF-8").strip().split("\n")) == { + "authorized_keys", "id_rsa", "id_rsa.pub", "name" + } + client.volumes.get(vol).remove() + + + +def test_can_unpack_keys_for_client(): + with vault_test_server(export_token=True) as server: + cfg = read_config("example/simple.json") + cfg.vault.url = server.url() + vol = f"privateer_keys_{rand_str()}" + cfg.clients[0].key_volume = vol + keygen(cfg, "alice") + keygen(cfg, "bob") + configure(cfg, "bob") + client = docker.from_env() + mounts = [docker.types.Mount("/keys", vol, type="volume")] + res = client.containers.run("alpine", mounts=mounts, + command=["ls", "/keys"], remove=True) + assert set(res.decode("UTF-8").strip().split("\n")) == { + "known_hosts", "id_rsa", "id_rsa.pub", "name" + } + client.volumes.get(vol).remove() From d919036f0027a837a332f91cee4b4db3e9e5eada Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Fri, 13 Oct 2023 16:39:12 +0100 Subject: [PATCH 02/87] Fix lint --- pyproject.toml | 4 ++++ src/privateer2/cli.py | 7 +++++-- src/privateer2/config.py | 2 ++ src/privateer2/keys.py | 45 +++++++++++++--------------------------- src/privateer2/util.py | 17 ++++++--------- src/privateer2/vault.py | 2 +- tests/test_config.py | 5 ++--- tests/test_keys.py | 28 +++++++++---------------- 8 files changed, 44 insertions(+), 66 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index cc91a9b..329b3fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -140,6 +140,10 @@ ignore = [ "S105", "S106", "S107", # Ignore complexity "C901", "PLR0911", "PLR0912", "PLR0913", "PLR0915", + # Let us use random + "S311", + # Let us use print + "T201" ] unfixable = [ # Don't touch unused imports diff --git a/src/privateer2/cli.py b/src/privateer2/cli.py index 5cad2c7..09a3266 100644 --- a/src/privateer2/cli.py +++ b/src/privateer2/cli.py @@ -13,8 +13,11 @@ import docopt -from privateer2.keys import configure, keygen +import privateer2.__about__ as about from privateer2.backup import backup +from privateer2.config import read_config +from privateer2.keys import configure, keygen + def main(argv=None): opts = docopt.docopt(__doc__, argv) @@ -30,4 +33,4 @@ def main(argv=None): server = opts["--server"] include = opts["--include"] exclude = opts["--exclude"] - backup(cfg, sever, include, exclude) + backup(cfg, server, include, exclude) diff --git a/src/privateer2/config.py b/src/privateer2/config.py index 5c13658..e5b605d 100644 --- a/src/privateer2/config.py +++ b/src/privateer2/config.py @@ -1,9 +1,11 @@ import json from typing import List + from pydantic import BaseModel from privateer2.vault import vault_client + def read_config(path): with open(path) as f: return Config(**json.loads(f.read().strip())) diff --git a/src/privateer2/keys.py b/src/privateer2/keys.py index 131bb55..903b004 100644 --- a/src/privateer2/keys.py +++ b/src/privateer2/keys.py @@ -1,7 +1,4 @@ import docker -import hvac -import re -import os from cryptography.hazmat.primitives import serialization as crypto_serialization from cryptography.hazmat.primitives.asymmetric import rsa @@ -25,51 +22,37 @@ def configure(cfg, name): image = "alpine" volume_name = _key_volume_name(cfg, name) - v = cl.volumes.create(volume_name) + cl.volumes.create(volume_name) mounts = [docker.types.Mount("/keys", volume_name, type="volume")] container = cl.containers.create(image, mounts=mounts) try: - string_to_container(data["private"], container, "/keys/id_rsa", - uid=0, gid=0, mode=0o600) - string_to_container(data["public"], container, "/keys/id_rsa.pub", - uid=0, gid=0, mode=0o644) + string_to_container(data["private"], container, "/keys/id_rsa", uid=0, gid=0, mode=0o600) + string_to_container(data["public"], container, "/keys/id_rsa.pub", uid=0, gid=0, mode=0o644) if data["authorized_keys"]: - string_to_container(data["authorized_keys"], container, - "/keys/authorized_keys", - uid=0, gid=0, mode=0o644) + string_to_container(data["authorized_keys"], container, "/keys/authorized_keys", uid=0, gid=0, mode=0o644) if data["known_hosts"]: - string_to_container(data["known_hosts"], container, - "/keys/known_hosts", - uid=0, gid=0, mode=0o644) - string_to_container(f"{name}\n", container, "/keys/name", - uid=0, gid=0, mode=0o644) + string_to_container(data["known_hosts"], container, "/keys/known_hosts", uid=0, gid=0, mode=0o644) + string_to_container(f"{name}\n", container, "/keys/name", uid=0, gid=0, mode=0o644) finally: container.remove() def _get_pubkeys(vault, prefix, nms): - return { - nm: vault.secrets.kv.v1.read_secret(f"{prefix}/{nm}")["data"]["public"] - for nm in nms - } + return {nm: vault.secrets.kv.v1.read_secret(f"{prefix}/{nm}")["data"]["public"] for nm in nms} def _create_keypair(): - key = rsa.generate_private_key( - public_exponent=65537, - key_size=2048 - ) + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) private = key.private_bytes( - crypto_serialization.Encoding.PEM, - crypto_serialization.PrivateFormat.PKCS8, - crypto_serialization.NoEncryption() + crypto_serialization.Encoding.PEM, crypto_serialization.PrivateFormat.PKCS8, crypto_serialization.NoEncryption() ).decode("UTF-8") - public = key.public_key().public_bytes( - crypto_serialization.Encoding.OpenSSH, - crypto_serialization.PublicFormat.OpenSSH - ).decode("UTF-8") + public = ( + key.public_key() + .public_bytes(crypto_serialization.Encoding.OpenSSH, crypto_serialization.PublicFormat.OpenSSH) + .decode("UTF-8") + ) return {"public": public, "private": private} diff --git a/src/privateer2/util.py b/src/privateer2/util.py index c4d26fd..0e7b742 100644 --- a/src/privateer2/util.py +++ b/src/privateer2/util.py @@ -1,10 +1,8 @@ -from contextlib import contextmanager - -import docker +import os +import os.path import tarfile import tempfile -import os.path -import os +from contextlib import contextmanager def string_to_container(text, container, path, **kwargs): @@ -21,6 +19,7 @@ def ret(tarinfo): if gid is not None: tarinfo.gid = gid return tarinfo + return ret @@ -40,8 +39,7 @@ def simple_tar(path, name, **kwargs): f = tempfile.NamedTemporaryFile() t = tarfile.open(mode="w", fileobj=f) abs_path = os.path.abspath(path) - t.add(abs_path, arcname=name, recursive=False, - filter=set_permissions(**kwargs)) + t.add(abs_path, arcname=name, recursive=False, filter=set_permissions(**kwargs)) t.close() f.seek(0) return f @@ -49,10 +47,7 @@ def simple_tar(path, name, **kwargs): @contextmanager def transient_envvar(**kwargs): - prev = { - k: os.environ[k] if k in os.environ else None - for k in kwargs.keys() - } + prev = {k: os.environ[k] if k in os.environ else None for k in kwargs.keys()} try: _setdictvals(kwargs, os.environ) yield diff --git a/src/privateer2/vault.py b/src/privateer2/vault.py index 4dd5bcb..d2d7a4f 100644 --- a/src/privateer2/vault.py +++ b/src/privateer2/vault.py @@ -6,7 +6,7 @@ def vault_client(addr, token=None): re_gh = re.compile("^ghp_[A-Za-z0-9]{36}$") - re_vault = re.compile("^(hv)?s\\.{80}$") + re.compile("^(hv)?s\\.{80}$") token = _get_vault_token(token) if re_gh.match(token): print("logging into vault using github") diff --git a/tests/test_config.py b/tests/test_config.py index 04bdbdc..42b6502 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,7 +1,6 @@ -from vault_dev import Server as vault_test_server +import vault_dev from privateer2.config import read_config -from privateer2.util import transient_envvar def test_can_read_config(): @@ -24,7 +23,7 @@ def test_can_read_config(): def test_can_create_vault_client(): cfg = read_config("example/simple.json") - with vault_test_server(export_token=True) as server: + with vault_dev.Server(export_token=True) as server: cfg.vault.url = server.url() client = cfg.vault.client() assert client.is_authenticated() diff --git a/tests/test_keys.py b/tests/test_keys.py index a8f536d..c730873 100644 --- a/tests/test_keys.py +++ b/tests/test_keys.py @@ -1,20 +1,19 @@ -import docker import random import string -from vault_dev import Server as vault_test_server +import docker +import vault_dev from privateer2.config import read_config from privateer2.keys import configure, keygen -from privateer2.util import transient_envvar def rand_str(n=8): - return ''.join(random.choices(string.ascii_lowercase + string.digits, k=n)) + return "".join(random.choices(string.ascii_lowercase + string.digits, k=n)) def test_can_create_keys(): - with vault_test_server(export_token=True) as server: + with vault_dev.Server(export_token=True) as server: cfg = read_config("example/simple.json") cfg.vault.url = server.url() keygen(cfg, "alice") @@ -27,7 +26,7 @@ def test_can_create_keys(): def test_can_unpack_keys_for_server(): - with vault_test_server(export_token=True) as server: + with vault_dev.Server(export_token=True) as server: cfg = read_config("example/simple.json") cfg.vault.url = server.url() vol = f"privateer_keys_{rand_str()}" @@ -37,17 +36,13 @@ def test_can_unpack_keys_for_server(): configure(cfg, "alice") client = docker.from_env() mounts = [docker.types.Mount("/keys", vol, type="volume")] - res = client.containers.run("alpine", mounts=mounts, - command=["ls", "/keys"], remove=True) - assert set(res.decode("UTF-8").strip().split("\n")) == { - "authorized_keys", "id_rsa", "id_rsa.pub", "name" - } + res = client.containers.run("alpine", mounts=mounts, command=["ls", "/keys"], remove=True) + assert set(res.decode("UTF-8").strip().split("\n")) == {"authorized_keys", "id_rsa", "id_rsa.pub", "name"} client.volumes.get(vol).remove() - def test_can_unpack_keys_for_client(): - with vault_test_server(export_token=True) as server: + with vault_dev.Server(export_token=True) as server: cfg = read_config("example/simple.json") cfg.vault.url = server.url() vol = f"privateer_keys_{rand_str()}" @@ -57,9 +52,6 @@ def test_can_unpack_keys_for_client(): configure(cfg, "bob") client = docker.from_env() mounts = [docker.types.Mount("/keys", vol, type="volume")] - res = client.containers.run("alpine", mounts=mounts, - command=["ls", "/keys"], remove=True) - assert set(res.decode("UTF-8").strip().split("\n")) == { - "known_hosts", "id_rsa", "id_rsa.pub", "name" - } + res = client.containers.run("alpine", mounts=mounts, command=["ls", "/keys"], remove=True) + assert set(res.decode("UTF-8").strip().split("\n")) == {"known_hosts", "id_rsa", "id_rsa.pub", "name"} client.volumes.get(vol).remove() From 322ed5ed0438a46151148ee08196469a9efaf570 Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Fri, 13 Oct 2023 17:02:13 +0100 Subject: [PATCH 03/87] Correct format for known_hosts --- example/simple.json | 2 +- src/privateer2/keys.py | 5 ++++- tests/test_keys.py | 38 ++++++++++++++++++++++++++++++++++---- 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/example/simple.json b/example/simple.json index b351a1e..57ae807 100644 --- a/example/simple.json +++ b/example/simple.json @@ -3,7 +3,7 @@ { "name": "alice", "hostname": "alice.example.com", - "port": 22 + "port": 10022 } ], "clients": [ diff --git a/src/privateer2/keys.py b/src/privateer2/keys.py index 903b004..01ff22f 100644 --- a/src/privateer2/keys.py +++ b/src/privateer2/keys.py @@ -66,7 +66,10 @@ def _keys_data(cfg, name): ret["authorized_keys"] = "".join([f"{v}\n" for v in keys.values()]) if name in cfg.list_clients(): keys = _get_pubkeys(vault, cfg.vault.prefix, cfg.list_servers()) - ret["known_hosts"] = "".join([f"{k} {v}\n" for k, v in keys.items()]) + known_hosts = [] + for s in cfg.servers: + known_hosts.append(f"[{s.hostname}]:{s.port} {keys[s.name]}\n") + ret["known_hosts"] = "".join(known_hosts) return ret diff --git a/tests/test_keys.py b/tests/test_keys.py index c730873..33c5782 100644 --- a/tests/test_keys.py +++ b/tests/test_keys.py @@ -5,7 +5,7 @@ import vault_dev from privateer2.config import read_config -from privateer2.keys import configure, keygen +from privateer2.keys import _keys_data, configure, keygen def rand_str(n=8): @@ -25,6 +25,31 @@ def test_can_create_keys(): assert "PRIVATE KEY" in pair["private"] +def test_can_generate_server_keys_data(): + with vault_dev.Server(export_token=True) as server: + cfg = read_config("example/simple.json") + cfg.vault.url = server.url() + keygen(cfg, "alice") + keygen(cfg, "bob") + dat = _keys_data(cfg, "alice") + assert dat["name"] is "alice" + assert dat["known_hosts"] is None + assert dat["authorized_keys"].startswith("ssh-rsa") + + +def test_can_generate_client_keys_data(): + with vault_dev.Server(export_token=True) as server: + cfg = read_config("example/simple.json") + cfg.vault.url = server.url() + keygen(cfg, "alice") + keygen(cfg, "bob") + dat = _keys_data(cfg, "bob") + assert dat["name"] is "bob" + assert dat["authorized_keys"] is None + assert dat["known_hosts"].startswith( + "[alice.example.com]:10022 ssh-rsa") + + def test_can_unpack_keys_for_server(): with vault_dev.Server(export_token=True) as server: cfg = read_config("example/simple.json") @@ -36,8 +61,11 @@ def test_can_unpack_keys_for_server(): configure(cfg, "alice") client = docker.from_env() mounts = [docker.types.Mount("/keys", vol, type="volume")] - res = client.containers.run("alpine", mounts=mounts, command=["ls", "/keys"], remove=True) - assert set(res.decode("UTF-8").strip().split("\n")) == {"authorized_keys", "id_rsa", "id_rsa.pub", "name"} + res = client.containers.run("alpine", mounts=mounts, + command=["ls", "/keys"], remove=True) + assert set(res.decode("UTF-8").strip().split("\n")) == { + "authorized_keys", "id_rsa", "id_rsa.pub", "name" + } client.volumes.get(vol).remove() @@ -53,5 +81,7 @@ def test_can_unpack_keys_for_client(): client = docker.from_env() mounts = [docker.types.Mount("/keys", vol, type="volume")] res = client.containers.run("alpine", mounts=mounts, command=["ls", "/keys"], remove=True) - assert set(res.decode("UTF-8").strip().split("\n")) == {"known_hosts", "id_rsa", "id_rsa.pub", "name"} + assert set(res.decode("UTF-8").strip().split("\n")) == { + "known_hosts", "id_rsa", "id_rsa.pub", "name" + } client.volumes.get(vol).remove() From e840e5459eaa6f58d25f12529e13a9ca80fbf91a Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Fri, 13 Oct 2023 17:03:30 +0100 Subject: [PATCH 04/87] Relint --- pyproject.toml | 4 +-- src/privateer2/keys.py | 58 ++++++++++++++++++++++++++++++++++-------- src/privateer2/util.py | 11 ++++++-- tests/test_keys.py | 28 +++++++++++++------- 4 files changed, 78 insertions(+), 23 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 329b3fa..aa44c53 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -98,12 +98,12 @@ all = [ [tool.black] target-version = ["py37"] -line-length = 120 +line-length = 80 skip-string-normalization = true [tool.ruff] target-version = "py37" -line-length = 120 +line-length = 80 select = [ "A", "ARG", diff --git a/src/privateer2/keys.py b/src/privateer2/keys.py index 01ff22f..094e7b8 100644 --- a/src/privateer2/keys.py +++ b/src/privateer2/keys.py @@ -1,7 +1,7 @@ -import docker from cryptography.hazmat.primitives import serialization as crypto_serialization from cryptography.hazmat.primitives.asymmetric import rsa +import docker from privateer2.util import string_to_container @@ -26,31 +26,64 @@ def configure(cfg, name): mounts = [docker.types.Mount("/keys", volume_name, type="volume")] container = cl.containers.create(image, mounts=mounts) try: - string_to_container(data["private"], container, "/keys/id_rsa", uid=0, gid=0, mode=0o600) - string_to_container(data["public"], container, "/keys/id_rsa.pub", uid=0, gid=0, mode=0o644) + string_to_container( + data["private"], container, "/keys/id_rsa", uid=0, gid=0, mode=0o600 + ) + string_to_container( + data["public"], + container, + "/keys/id_rsa.pub", + uid=0, + gid=0, + mode=0o644, + ) if data["authorized_keys"]: - string_to_container(data["authorized_keys"], container, "/keys/authorized_keys", uid=0, gid=0, mode=0o644) + string_to_container( + data["authorized_keys"], + container, + "/keys/authorized_keys", + uid=0, + gid=0, + mode=0o644, + ) if data["known_hosts"]: - string_to_container(data["known_hosts"], container, "/keys/known_hosts", uid=0, gid=0, mode=0o644) - string_to_container(f"{name}\n", container, "/keys/name", uid=0, gid=0, mode=0o644) + string_to_container( + data["known_hosts"], + container, + "/keys/known_hosts", + uid=0, + gid=0, + mode=0o644, + ) + string_to_container( + f"{name}\n", container, "/keys/name", uid=0, gid=0, mode=0o644 + ) finally: container.remove() def _get_pubkeys(vault, prefix, nms): - return {nm: vault.secrets.kv.v1.read_secret(f"{prefix}/{nm}")["data"]["public"] for nm in nms} + return { + nm: vault.secrets.kv.v1.read_secret(f"{prefix}/{nm}")["data"]["public"] + for nm in nms + } def _create_keypair(): key = rsa.generate_private_key(public_exponent=65537, key_size=2048) private = key.private_bytes( - crypto_serialization.Encoding.PEM, crypto_serialization.PrivateFormat.PKCS8, crypto_serialization.NoEncryption() + crypto_serialization.Encoding.PEM, + crypto_serialization.PrivateFormat.PKCS8, + crypto_serialization.NoEncryption(), ).decode("UTF-8") public = ( key.public_key() - .public_bytes(crypto_serialization.Encoding.OpenSSH, crypto_serialization.PublicFormat.OpenSSH) + .public_bytes( + crypto_serialization.Encoding.OpenSSH, + crypto_serialization.PublicFormat.OpenSSH, + ) .decode("UTF-8") ) @@ -60,7 +93,12 @@ def _create_keypair(): def _keys_data(cfg, name): vault = cfg.vault.client() response = vault.secrets.kv.v1.read_secret(f"{cfg.vault.prefix}/{name}") - ret = {"name": name, **response["data"], "authorized_keys": None, "known_hosts": None} + ret = { + "name": name, + **response["data"], + "authorized_keys": None, + "known_hosts": None, + } if name in cfg.list_servers(): keys = _get_pubkeys(vault, cfg.vault.prefix, cfg.list_clients()) ret["authorized_keys"] = "".join([f"{v}\n" for v in keys.values()]) diff --git a/src/privateer2/util.py b/src/privateer2/util.py index 0e7b742..c70c371 100644 --- a/src/privateer2/util.py +++ b/src/privateer2/util.py @@ -39,7 +39,12 @@ def simple_tar(path, name, **kwargs): f = tempfile.NamedTemporaryFile() t = tarfile.open(mode="w", fileobj=f) abs_path = os.path.abspath(path) - t.add(abs_path, arcname=name, recursive=False, filter=set_permissions(**kwargs)) + t.add( + abs_path, + arcname=name, + recursive=False, + filter=set_permissions(**kwargs), + ) t.close() f.seek(0) return f @@ -47,7 +52,9 @@ def simple_tar(path, name, **kwargs): @contextmanager def transient_envvar(**kwargs): - prev = {k: os.environ[k] if k in os.environ else None for k in kwargs.keys()} + prev = { + k: os.environ[k] if k in os.environ else None for k in kwargs.keys() + } try: _setdictvals(kwargs, os.environ) yield diff --git a/tests/test_keys.py b/tests/test_keys.py index 33c5782..eb9dc78 100644 --- a/tests/test_keys.py +++ b/tests/test_keys.py @@ -1,9 +1,9 @@ import random import string -import docker import vault_dev +import docker from privateer2.config import read_config from privateer2.keys import _keys_data, configure, keygen @@ -32,7 +32,7 @@ def test_can_generate_server_keys_data(): keygen(cfg, "alice") keygen(cfg, "bob") dat = _keys_data(cfg, "alice") - assert dat["name"] is "alice" + assert dat["name"] == "alice" assert dat["known_hosts"] is None assert dat["authorized_keys"].startswith("ssh-rsa") @@ -44,10 +44,11 @@ def test_can_generate_client_keys_data(): keygen(cfg, "alice") keygen(cfg, "bob") dat = _keys_data(cfg, "bob") - assert dat["name"] is "bob" + assert dat["name"] == "bob" assert dat["authorized_keys"] is None assert dat["known_hosts"].startswith( - "[alice.example.com]:10022 ssh-rsa") + "[alice.example.com]:10022 ssh-rsa" + ) def test_can_unpack_keys_for_server(): @@ -61,10 +62,14 @@ def test_can_unpack_keys_for_server(): configure(cfg, "alice") client = docker.from_env() mounts = [docker.types.Mount("/keys", vol, type="volume")] - res = client.containers.run("alpine", mounts=mounts, - command=["ls", "/keys"], remove=True) + res = client.containers.run( + "alpine", mounts=mounts, command=["ls", "/keys"], remove=True + ) assert set(res.decode("UTF-8").strip().split("\n")) == { - "authorized_keys", "id_rsa", "id_rsa.pub", "name" + "authorized_keys", + "id_rsa", + "id_rsa.pub", + "name", } client.volumes.get(vol).remove() @@ -80,8 +85,13 @@ def test_can_unpack_keys_for_client(): configure(cfg, "bob") client = docker.from_env() mounts = [docker.types.Mount("/keys", vol, type="volume")] - res = client.containers.run("alpine", mounts=mounts, command=["ls", "/keys"], remove=True) + res = client.containers.run( + "alpine", mounts=mounts, command=["ls", "/keys"], remove=True + ) assert set(res.decode("UTF-8").strip().split("\n")) == { - "known_hosts", "id_rsa", "id_rsa.pub", "name" + "known_hosts", + "id_rsa", + "id_rsa.pub", + "name", } client.volumes.get(vol).remove() From 94df3e0c1c1f8fa447ceb29830a6985860b70b51 Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Fri, 13 Oct 2023 17:57:25 +0100 Subject: [PATCH 05/87] Check a configuration --- src/privateer2/config.py | 3 ++ src/privateer2/keys.py | 74 +++++++++++++++++++--------------------- src/privateer2/util.py | 55 +++++++++++++++++++++++++++++ tests/test_config.py | 2 +- tests/test_keys.py | 11 +++++- 5 files changed, 105 insertions(+), 40 deletions(-) diff --git a/src/privateer2/config.py b/src/privateer2/config.py index e5b605d..ba1ebce 100644 --- a/src/privateer2/config.py +++ b/src/privateer2/config.py @@ -16,6 +16,8 @@ class Server(BaseModel): hostname: str port: int key_volume: str = "privateer_keys" + data_volume: str = "privateer_data" + container: str = "privateer_server" class Client(BaseModel): @@ -42,6 +44,7 @@ class Config(BaseModel): clients: List[Client] volumes: List[Volume] vault: Vault + tag: str = "docker" def list_servers(self): return [x.name for x in self.servers] diff --git a/src/privateer2/keys.py b/src/privateer2/keys.py index 094e7b8..51ebd71 100644 --- a/src/privateer2/keys.py +++ b/src/privateer2/keys.py @@ -2,7 +2,7 @@ from cryptography.hazmat.primitives.asymmetric import rsa import docker -from privateer2.util import string_to_container +from privateer2.util import string_from_volume, string_to_volume def keygen(cfg, name): @@ -20,46 +20,40 @@ def configure(cfg, name): cl = docker.from_env() data = _keys_data(cfg, name) - image = "alpine" - volume_name = _key_volume_name(cfg, name) - cl.volumes.create(volume_name) - mounts = [docker.types.Mount("/keys", volume_name, type="volume")] - container = cl.containers.create(image, mounts=mounts) - try: - string_to_container( - data["private"], container, "/keys/id_rsa", uid=0, gid=0, mode=0o600 - ) - string_to_container( - data["public"], - container, - "/keys/id_rsa.pub", + vol = _key_volume_name(cfg, name) + cl.volumes.create(vol) + string_to_volume( + data["public"], vol, "id_rsa.pub", uid=0, gid=0, mode=0o644 + ) + string_to_volume(data["private"], vol, "id_rsa", uid=0, gid=0, mode=0o600) + if data["authorized_keys"]: + string_to_volume( + data["authorized_keys"], + vol, + "authorized_keys", uid=0, gid=0, - mode=0o644, + mode=0o600, ) - if data["authorized_keys"]: - string_to_container( - data["authorized_keys"], - container, - "/keys/authorized_keys", - uid=0, - gid=0, - mode=0o644, - ) - if data["known_hosts"]: - string_to_container( - data["known_hosts"], - container, - "/keys/known_hosts", - uid=0, - gid=0, - mode=0o644, - ) - string_to_container( - f"{name}\n", container, "/keys/name", uid=0, gid=0, mode=0o644 + if data["known_hosts"]: + string_to_volume( + data["known_hosts"], vol, "known_hosts", uid=0, gid=0, mode=0o600 ) - finally: - container.remove() + string_to_volume(name, vol, "name", uid=0, gid=0) + + +def check(cfg, name): + machine = _machine_config(cfg, name) + try: + docker.from_env().volumes.get(machine.key_volume) + except docker.errors.VolumeNotFound: + msg = f"'{name}' looks unconfigured" + raise Exception(msg) from None + found = string_from_volume(machine.key_volume, "name") + if found != name: + msg = f"Configuration is for '{found}', not '{name}'" + raise Exception(msg) + return machine def _get_pubkeys(vault, prefix, nms): @@ -112,8 +106,12 @@ def _keys_data(cfg, name): def _key_volume_name(cfg, name): + return _machine_config(cfg, name).key_volume + + +def _machine_config(cfg, name): for el in cfg.servers + cfg.clients: if el.name == name: - return el.key_volume + return el msg = "Invalid configuration, can't determine volume name" raise Exception(msg) diff --git a/src/privateer2/util.py b/src/privateer2/util.py index c70c371..3be1a36 100644 --- a/src/privateer2/util.py +++ b/src/privateer2/util.py @@ -3,6 +3,33 @@ import tarfile import tempfile from contextlib import contextmanager +from pathlib import Path + +import docker + + +def string_to_volume(text, volume, path, **kwargs): + _ensure_image("alpine") + dest = Path("/dest") + mounts = [docker.types.Mount(str(dest), volume, type="volume")] + cl = docker.from_env() + container = cl.containers.create("alpine", mounts=mounts) + try: + string_to_container(text, container, dest / path, **kwargs) + finally: + container.remove() + + +def string_from_volume(volume, path): + _ensure_image("alpine") + src = Path("/src") + mounts = [docker.types.Mount(str(src), volume, type="volume")] + cl = docker.from_env() + container = cl.containers.create("alpine", mounts=mounts) + try: + return string_from_container(container, src / path) + finally: + container.remove() def string_to_container(text, container, path, **kwargs): @@ -69,3 +96,31 @@ def _setdictvals(new, container): else: container[k] = v return container + + +def string_from_container(container, path): + return bytes_from_container(container, path).decode("utf-8") + + +def bytes_from_container(container, path): + stream, status = container.get_archive(path) + try: + fd, tmp = tempfile.mkstemp(text=False) + with os.fdopen(fd, "wb") as f: + for d in stream: + f.write(d) + with open(tmp, "rb") as f: + t = tarfile.open(mode="r", fileobj=f) + p = t.extractfile(os.path.basename(path)) + return p.read() + finally: + os.remove(tmp) + + +def _ensure_image(name): + cl = docker.from_env() + try: + cl.images.get(name) + except docker.errors.ImageNotFound: + print(f"Pulling {name}") + cl.images.pull(name) diff --git a/tests/test_config.py b/tests/test_config.py index 42b6502..7efe48d 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -8,7 +8,7 @@ def test_can_read_config(): assert len(cfg.servers) == 1 assert cfg.servers[0].name == "alice" assert cfg.servers[0].hostname == "alice.example.com" - assert cfg.servers[0].port == 22 + assert cfg.servers[0].port == 10022 assert len(cfg.clients) == 1 assert cfg.clients[0].name == "bob" assert cfg.clients[0].backup == ["data"] diff --git a/tests/test_keys.py b/tests/test_keys.py index eb9dc78..6cf3811 100644 --- a/tests/test_keys.py +++ b/tests/test_keys.py @@ -1,11 +1,13 @@ import random import string +import pytest import vault_dev import docker from privateer2.config import read_config -from privateer2.keys import _keys_data, configure, keygen +from privateer2.keys import _keys_data, check, configure, keygen +from privateer2.util import string_from_volume def rand_str(n=8): @@ -71,6 +73,7 @@ def test_can_unpack_keys_for_server(): "id_rsa.pub", "name", } + assert string_from_volume(vol, "name") == "alice" client.volumes.get(vol).remove() @@ -94,4 +97,10 @@ def test_can_unpack_keys_for_client(): "id_rsa.pub", "name", } + assert string_from_volume(vol, "name") == "bob" + assert check(cfg, "bob").key_volume == vol + msg = "Configuration is for 'bob', not 'alice'" + cfg.servers[0].key_volume = vol + with pytest.raises(Exception, match=msg): + check(cfg, "alice") client.volumes.get(vol).remove() From 674e38c97902b483a80e95fb5c8c768cde7beb62 Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Mon, 16 Oct 2023 09:43:59 +0100 Subject: [PATCH 06/87] Tidy up to work locally --- example/local.json | 28 ++++++++++++++++++++++++++++ src/privateer2/cli.py | 18 ++++++++---------- src/privateer2/keys.py | 12 ++++++++---- src/privateer2/util.py | 12 +++++++++--- tests/test_keys.py | 9 +-------- 5 files changed, 54 insertions(+), 25 deletions(-) create mode 100644 example/local.json diff --git a/example/local.json b/example/local.json new file mode 100644 index 0000000..e29cbac --- /dev/null +++ b/example/local.json @@ -0,0 +1,28 @@ +{ + "servers": [ + { + "name": "alice", + "hostname": "alice.example.com", + "port": 10022, + "key_volume": "privateer_keys_alice", + "data_volume": "privateer_data_alice" + } + ], + "clients": [ + { + "name": "bob", + "backup": ["data"], + "restore": ["data"], + "key_volume": "privateer_keys_bob" + } + ], + "volumes": [ + { + "name": "data" + } + ], + "vault": { + "url": "http://localhost:8200", + "prefix": "/secret/privateer" + } +} diff --git a/src/privateer2/cli.py b/src/privateer2/cli.py index 09a3266..301d51f 100644 --- a/src/privateer2/cli.py +++ b/src/privateer2/cli.py @@ -1,8 +1,9 @@ """Usage: privateer2 --version - privateer2 [-f=PATH] keygen ... + privateer2 [-f=PATH] keygen privateer2 [-f=PATH] configure - privateer2 backup [--server=NAME --include=NAME... --exclude=NAME...] + privateer2 [-f=PATH] check + privateer2 [-f=PATH] server [--dry-run] Options: -f=PATH The path to the privateer configuration [default: privateer.json]. @@ -14,9 +15,9 @@ import docopt import privateer2.__about__ as about -from privateer2.backup import backup + from privateer2.config import read_config -from privateer2.keys import configure, keygen +from privateer2.keys import check, configure, keygen def main(argv=None): @@ -26,11 +27,8 @@ def main(argv=None): path_config = opts["-f"] cfg = read_config(path_config) if opts["keygen"]: - keygen(cfg, opts["..."]) + keygen(cfg, opts[""]) elif opts["configure"]: configure(cfg, opts[""]) - elif opts["backup"]: - server = opts["--server"] - include = opts["--include"] - exclude = opts["--exclude"] - backup(cfg, server, include, exclude) + elif opts["check"]: + check(cfg, opts[""]) diff --git a/src/privateer2/keys.py b/src/privateer2/keys.py index 51ebd71..9a7f27b 100644 --- a/src/privateer2/keys.py +++ b/src/privateer2/keys.py @@ -12,21 +12,22 @@ def keygen(cfg, name): # TODO: The docs are here: # https://hvac.readthedocs.io/en/stable/usage/secrets_engines/kv_v1.html # They do not indicate if this will error if the write fails though. - print(f"Writing key for {name}") + print(f"Writing keypair for {name}") _r = vault.secrets.kv.v1.create_or_update_secret(path, secret=data) def configure(cfg, name): cl = docker.from_env() data = _keys_data(cfg, name) - vol = _key_volume_name(cfg, name) cl.volumes.create(vol) + print(f"Copying keypair for '{name}' to volume '{vol}'") string_to_volume( data["public"], vol, "id_rsa.pub", uid=0, gid=0, mode=0o644 ) string_to_volume(data["private"], vol, "id_rsa", uid=0, gid=0, mode=0o600) if data["authorized_keys"]: + print("Authorising public keys") string_to_volume( data["authorized_keys"], vol, @@ -36,6 +37,7 @@ def configure(cfg, name): mode=0o600, ) if data["known_hosts"]: + print("Recognising servers") string_to_volume( data["known_hosts"], vol, "known_hosts", uid=0, gid=0, mode=0o600 ) @@ -44,15 +46,17 @@ def configure(cfg, name): def check(cfg, name): machine = _machine_config(cfg, name) + vol = machine.key_volume try: - docker.from_env().volumes.get(machine.key_volume) + docker.from_env().volumes.get(vol) except docker.errors.VolumeNotFound: msg = f"'{name}' looks unconfigured" raise Exception(msg) from None - found = string_from_volume(machine.key_volume, "name") + found = string_from_volume(vol, "name") if found != name: msg = f"Configuration is for '{found}', not '{name}'" raise Exception(msg) + print(f"Volume '{vol}' looks configured as '{name}'") return machine diff --git a/src/privateer2/util.py b/src/privateer2/util.py index 3be1a36..544ff79 100644 --- a/src/privateer2/util.py +++ b/src/privateer2/util.py @@ -1,3 +1,5 @@ +import random +import string import os import os.path import tarfile @@ -9,7 +11,7 @@ def string_to_volume(text, volume, path, **kwargs): - _ensure_image("alpine") + ensure_image("alpine") dest = Path("/dest") mounts = [docker.types.Mount(str(dest), volume, type="volume")] cl = docker.from_env() @@ -21,7 +23,7 @@ def string_to_volume(text, volume, path, **kwargs): def string_from_volume(volume, path): - _ensure_image("alpine") + ensure_image("alpine") src = Path("/src") mounts = [docker.types.Mount(str(src), volume, type="volume")] cl = docker.from_env() @@ -117,10 +119,14 @@ def bytes_from_container(container, path): os.remove(tmp) -def _ensure_image(name): +def ensure_image(name): cl = docker.from_env() try: cl.images.get(name) except docker.errors.ImageNotFound: print(f"Pulling {name}") cl.images.pull(name) + + +def rand_str(n=8): + return "".join(random.choices(string.ascii_lowercase + string.digits, k=n)) diff --git a/tests/test_keys.py b/tests/test_keys.py index 6cf3811..23b433c 100644 --- a/tests/test_keys.py +++ b/tests/test_keys.py @@ -1,17 +1,10 @@ -import random -import string - import pytest import vault_dev import docker from privateer2.config import read_config from privateer2.keys import _keys_data, check, configure, keygen -from privateer2.util import string_from_volume - - -def rand_str(n=8): - return "".join(random.choices(string.ascii_lowercase + string.digits, k=n)) +from privateer2.util import rand_str, string_from_volume def test_can_create_keys(): From c0d41447d61665a8e08f3e320d0bfda6294de93e Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Mon, 16 Oct 2023 10:13:34 +0100 Subject: [PATCH 07/87] Add simple server commands --- src/privateer2/cli.py | 6 +++++- src/privateer2/server.py | 39 +++++++++++++++++++++++++++++++++++++++ src/privateer2/util.py | 9 +++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 src/privateer2/server.py diff --git a/src/privateer2/cli.py b/src/privateer2/cli.py index 301d51f..bc85909 100644 --- a/src/privateer2/cli.py +++ b/src/privateer2/cli.py @@ -3,7 +3,7 @@ privateer2 [-f=PATH] keygen privateer2 [-f=PATH] configure privateer2 [-f=PATH] check - privateer2 [-f=PATH] server [--dry-run] + privateer2 [-f=PATH] serve [--dry-run] Options: -f=PATH The path to the privateer configuration [default: privateer.json]. @@ -18,6 +18,7 @@ from privateer2.config import read_config from privateer2.keys import check, configure, keygen +from privateer2.server import serve def main(argv=None): @@ -26,9 +27,12 @@ def main(argv=None): return about.__version__ path_config = opts["-f"] cfg = read_config(path_config) + dry_run = opts["--dry-run"] if opts["keygen"]: keygen(cfg, opts[""]) elif opts["configure"]: configure(cfg, opts[""]) elif opts["check"]: check(cfg, opts[""]) + elif opts["serve"]: + serve(cfg, opts[""], dry_run=dry_run) diff --git a/src/privateer2/server.py b/src/privateer2/server.py new file mode 100644 index 0000000..02d8241 --- /dev/null +++ b/src/privateer2/server.py @@ -0,0 +1,39 @@ +import docker + +from privateer2.keys import check +from privateer2.util import container_exists, ensure_image + + +def serve(cfg, name, *, dry_run=False): + machine = check(cfg, name) + image = f"mrcide/privateer-server:{cfg.tag}" + ensure_image(image) + if dry_run: + cmd = ["docker", "run", "--rm", "-d", + "--name", machine.container, + "-v", f"{machine.key_volume}:/run/privateer:ro", + "-v", f"{machine.data_volume}:/privateer", + "-p", f"{machine.port}:22", + image] + print("Command to manually launch server") + print() + print(f" {' '.join(cmd)}") + print() + print("(remove the '-d' flag to run in blocking mode)") + return + + if container_exists(machine.container): + msg = f"Container '{machine.container}' for '{name}' already running" + raise Exception(msg) + + mounts = [ + docker.types.Mount("/run/privateer", machine.key_volume, + type="volume", read_only=True), + docker.types.Mount("/privateer", machine.data_volume, type="volume"), + ] + ports = {"22/tcp": machine.port} # or ("0.0.0.0", machine.port) + client = docker.from_env() + print("Starting server") + client.containers.run(image, auto_remove=True, detach=True, + name=machine.container, mounts=mounts, ports=ports) + print(f"Server {name} now running on port {machine.port}") diff --git a/src/privateer2/util.py b/src/privateer2/util.py index 544ff79..afa2c71 100644 --- a/src/privateer2/util.py +++ b/src/privateer2/util.py @@ -128,5 +128,14 @@ def ensure_image(name): cl.images.pull(name) +def container_exists(name): + cl = docker.from_env() + try: + cl.containers.get(name) + return True + except docker.errors.NotFound: + return False + + def rand_str(n=8): return "".join(random.choices(string.ascii_lowercase + string.digits, k=n)) From d151bea43a35dda4c0b1288004c059f7538f31c7 Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Mon, 16 Oct 2023 10:26:09 +0100 Subject: [PATCH 08/87] Smalll pull utility --- src/privateer2/cli.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/privateer2/cli.py b/src/privateer2/cli.py index bc85909..b3f7324 100644 --- a/src/privateer2/cli.py +++ b/src/privateer2/cli.py @@ -3,6 +3,7 @@ privateer2 [-f=PATH] keygen privateer2 [-f=PATH] configure privateer2 [-f=PATH] check + privateer2 [-f=PATH] pull privateer2 [-f=PATH] serve [--dry-run] Options: @@ -12,6 +13,7 @@ --exclude Volumes to exclude from the backup """ +import docker import docopt import privateer2.__about__ as about @@ -36,3 +38,10 @@ def main(argv=None): check(cfg, opts[""]) elif opts["serve"]: serve(cfg, opts[""], dry_run=dry_run) + elif opts["pull"]: + img = [f"mrcide/privateer-client:{cfg.tag}", + f"mrcide/privateer-server:{cfg.tag}"] + cl = docker.from_env() + for nm in img: + print(f"pulling '{nm}'") + cl.images.pull(nm) From 3aa477fb8fd65e8b60e0c1a81c0c981aa36ff547 Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Mon, 16 Oct 2023 15:39:36 +0100 Subject: [PATCH 09/87] Add ssh configuration --- src/privateer2/keys.py | 16 ++++++++++++++-- src/privateer2/server.py | 2 +- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/privateer2/keys.py b/src/privateer2/keys.py index 9a7f27b..5bbc054 100644 --- a/src/privateer2/keys.py +++ b/src/privateer2/keys.py @@ -41,10 +41,15 @@ def configure(cfg, name): string_to_volume( data["known_hosts"], vol, "known_hosts", uid=0, gid=0, mode=0o600 ) + if data["config"]: + print("Adding ssh config") + string_to_volume( + data["config"], vol, "config", uid=0, gid=0, mode=0o600 + ) string_to_volume(name, vol, "name", uid=0, gid=0) -def check(cfg, name): +def check(cfg, name, *, quiet=False): machine = _machine_config(cfg, name) vol = machine.key_volume try: @@ -56,7 +61,8 @@ def check(cfg, name): if found != name: msg = f"Configuration is for '{found}', not '{name}'" raise Exception(msg) - print(f"Volume '{vol}' looks configured as '{name}'") + if not quiet: + print(f"Volume '{vol}' looks configured as '{name}'") return machine @@ -103,9 +109,15 @@ def _keys_data(cfg, name): if name in cfg.list_clients(): keys = _get_pubkeys(vault, cfg.vault.prefix, cfg.list_servers()) known_hosts = [] + config = [] for s in cfg.servers: known_hosts.append(f"[{s.hostname}]:{s.port} {keys[s.name]}\n") + config.append(f"Host {s.name}\n") + config.append( " User root\n") + config.append(f" Port {s.port}\n") + config.append(f" HostName {s.hostname}\n") ret["known_hosts"] = "".join(known_hosts) + ret["config"] = "".join(config) return ret diff --git a/src/privateer2/server.py b/src/privateer2/server.py index 02d8241..ea3e4b9 100644 --- a/src/privateer2/server.py +++ b/src/privateer2/server.py @@ -15,7 +15,7 @@ def serve(cfg, name, *, dry_run=False): "-v", f"{machine.data_volume}:/privateer", "-p", f"{machine.port}:22", image] - print("Command to manually launch server") + print("Command to manually launch server:") print() print(f" {' '.join(cmd)}") print() From 0ee83037b47a106d832a1d3a9981217d5c10eca4 Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Mon, 16 Oct 2023 15:39:57 +0100 Subject: [PATCH 10/87] Allow basic backup --- src/privateer2/backup.py | 65 ++++++++++++++++++++++++++++++++++++++++ src/privateer2/cli.py | 4 +++ src/privateer2/util.py | 7 +++++ 3 files changed, 76 insertions(+) create mode 100644 src/privateer2/backup.py diff --git a/src/privateer2/backup.py b/src/privateer2/backup.py new file mode 100644 index 0000000..5bf3740 --- /dev/null +++ b/src/privateer2/backup.py @@ -0,0 +1,65 @@ +import docker + +from privateer2.keys import check +from privateer2.util import ensure_image, log_tail + + +def backup(cfg, name, *, dry_run=False): + machine = check(cfg, name, quiet=True) + if len(cfg.servers) != 1: + msg = "More than one server configured, some care needed" + raise Exception(msg) + server = cfg.servers[0].name + for volume in machine.backup: + backup_volume(cfg, name, volume, server, dry_run=dry_run) + + +def backup_volume(cfg, name, volume, server, *, dry_run=False): + machine = check(cfg, name, quiet=True) + image = f"mrcide/privateer-client:{cfg.tag}" + ensure_image(image) + container = "privateer_client" + src_mount = f"/privateer/{volume}" + command = ["rsync", "-av", "--delete", src_mount, + f"{server}:/privateer/{name}"] + if dry_run: + cmd = ["docker", "run", "--rm", + "-v", f"{machine.key_volume}:/run/privateer:ro", + "-v", f"{volume}:{src_mount}:ro", + image] + command + print("Command to manually run backup") + print() + print(f" {' '.join(cmd)}") + print() + print(f"This will copy the volume '{volume}' from '{name}' " + + f"to the server '{server}'") + print() + print("Note that this uses hostname/port information for the server") + print("contained within /run/privateer/config, along with our identity") + print("in /run/config/id_rsa") + else: + print(f"Backing up '{volume}' to '{server}'") + mounts = [ + docker.types.Mount("/run/privateer", machine.key_volume, + type="volume", read_only=True), + docker.types.Mount(src_mount, volume, + type="volume", read_only=True) + ] + client = docker.from_env() + container = client.containers.run(image, command=command, detach=True, + mounts=mounts) + print("Backup command started. To stream progress, run:") + print(f" docker logs -f {container.name}") + result = container.wait() + if result["StatusCode"] == 0: + print("Backup completed successfully! Container logs:") + log_tail(container, 10) + container.remove() + # TODO: also copy over some metadata at this point, via + # ssh; probably best to write tiny utility in the client + # container that will do this for us. + else: + print("An error occured! Container logs:") + log_tail(container, 20) + msg = f"backup failed; see {container.name} logs for details" + raise Exception(msg) diff --git a/src/privateer2/cli.py b/src/privateer2/cli.py index b3f7324..4b6b1d9 100644 --- a/src/privateer2/cli.py +++ b/src/privateer2/cli.py @@ -5,6 +5,7 @@ privateer2 [-f=PATH] check privateer2 [-f=PATH] pull privateer2 [-f=PATH] serve [--dry-run] + privateer2 [-f=PATH] backup [--dry-run] Options: -f=PATH The path to the privateer configuration [default: privateer.json]. @@ -18,6 +19,7 @@ import privateer2.__about__ as about +from privateer2.backup import backup from privateer2.config import read_config from privateer2.keys import check, configure, keygen from privateer2.server import serve @@ -38,6 +40,8 @@ def main(argv=None): check(cfg, opts[""]) elif opts["serve"]: serve(cfg, opts[""], dry_run=dry_run) + elif opts["backup"]: + backup(cfg, opts[""], dry_run=dry_run) elif opts["pull"]: img = [f"mrcide/privateer-client:{cfg.tag}", f"mrcide/privateer-server:{cfg.tag}"] diff --git a/src/privateer2/util.py b/src/privateer2/util.py index afa2c71..bab76d3 100644 --- a/src/privateer2/util.py +++ b/src/privateer2/util.py @@ -139,3 +139,10 @@ def container_exists(name): def rand_str(n=8): return "".join(random.choices(string.ascii_lowercase + string.digits, k=n)) + + +def log_tail(container, n): + logs = container.logs().decode("utf-8").strip().split("\n") + if len(logs) > n: + print(f"(ommitting {len(logs) - n} lines of logs)") + print("\n".join(logs[-n:])) From efb6ff99913091f7d72f4f2f494c179e7d6a423a Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Mon, 16 Oct 2023 16:05:10 +0100 Subject: [PATCH 11/87] Basic support for restore --- src/privateer2/cli.py | 4 +++ src/privateer2/restore.py | 66 +++++++++++++++++++++++++++++++++++++++ src/privateer2/util.py | 9 ++++++ 3 files changed, 79 insertions(+) create mode 100644 src/privateer2/restore.py diff --git a/src/privateer2/cli.py b/src/privateer2/cli.py index 4b6b1d9..59b37cf 100644 --- a/src/privateer2/cli.py +++ b/src/privateer2/cli.py @@ -6,6 +6,7 @@ privateer2 [-f=PATH] pull privateer2 [-f=PATH] serve [--dry-run] privateer2 [-f=PATH] backup [--dry-run] + privateer2 [-f=PATH] restore [--dry-run] Options: -f=PATH The path to the privateer configuration [default: privateer.json]. @@ -22,6 +23,7 @@ from privateer2.backup import backup from privateer2.config import read_config from privateer2.keys import check, configure, keygen +from privateer2.restore import restore from privateer2.server import serve @@ -42,6 +44,8 @@ def main(argv=None): serve(cfg, opts[""], dry_run=dry_run) elif opts["backup"]: backup(cfg, opts[""], dry_run=dry_run) + elif opts["restore"]: + restore(cfg, opts[""], dry_run=dry_run) elif opts["pull"]: img = [f"mrcide/privateer-client:{cfg.tag}", f"mrcide/privateer-server:{cfg.tag}"] diff --git a/src/privateer2/restore.py b/src/privateer2/restore.py new file mode 100644 index 0000000..cf743fa --- /dev/null +++ b/src/privateer2/restore.py @@ -0,0 +1,66 @@ +import docker + +from privateer2.keys import check +from privateer2.util import ensure_image, log_tail, volume_exists + + +def restore(cfg, name, *, dry_run=False): + machine = check(cfg, name, quiet=True) + if len(cfg.servers) != 1: + msg = "More than one server configured, some care needed" + raise Exception(msg) + server = cfg.servers[0].name + for volume in machine.restore: + restore_volume(cfg, name, volume, server, dry_run=dry_run) + + +def restore_volume(cfg, name, volume, server, *, dry_run=False): + machine = check(cfg, name, quiet=True) + image = f"mrcide/privateer-client:{cfg.tag}" + ensure_image(image) + container = "privateer_client" + dest_mount = f"/privateer/{volume}" + command = ["rsync", "-av", "--delete", + f"{server}:/privateer/{name}/{volume}/", f"{dest_mount}/"] + if dry_run: + cmd = ["docker", "run", "--rm", + "-v", f"{machine.key_volume}:/run/privateer:ro", + "-v", f"{volume}:{dest_mount}", + image] + command + print("Command to manually run restore") + print() + print(f" {' '.join(cmd)}") + print() + print(f"This will data from the server '{server}' into into our") + print(f"local volume '{volume}'") + print() + print("Note that this uses hostname/port information for the server") + print("contained within /run/privateer/config, along with our identity") + print("in /run/config/id_rsa") + else: + print(f"Restoring '{volume}' from '{server}'") + if volume_exists(volume): + print("This command will overwrite the contents of this volume!") + else: + docker.from_env().volumes.create(volume) + mounts = [ + docker.types.Mount("/run/privateer", machine.key_volume, + type="volume", read_only=True), + docker.types.Mount(dest_mount, volume, + type="volume", read_only=False) + ] + client = docker.from_env() + container = client.containers.run(image, command=command, detach=True, + mounts=mounts) + print("Restore command started. To stream progress, run:") + print(f" docker logs -f {container.name}") + result = container.wait() + if result["StatusCode"] == 0: + print("Restore completed successfully! Container logs:") + log_tail(container, 10) + container.remove() + else: + print("An error occured! Container logs:") + log_tail(container, 20) + msg = f"restore failed; see {container.name} logs for details" + raise Exception(msg) diff --git a/src/privateer2/util.py b/src/privateer2/util.py index bab76d3..a382e16 100644 --- a/src/privateer2/util.py +++ b/src/privateer2/util.py @@ -137,6 +137,15 @@ def container_exists(name): return False +def volume_exists(name): + cl = docker.from_env() + try: + cl.volumes.get(name) + return True + except docker.errors.NotFound: + return False + + def rand_str(n=8): return "".join(random.choices(string.ascii_lowercase + string.digits, k=n)) From 57f0b3164b9f9d3e4716548ae83f25f05d6e7567 Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Mon, 16 Oct 2023 16:52:15 +0100 Subject: [PATCH 12/87] Tidy up --- src/privateer2/cli.py | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/src/privateer2/cli.py b/src/privateer2/cli.py index 59b37cf..686b9a7 100644 --- a/src/privateer2/cli.py +++ b/src/privateer2/cli.py @@ -1,18 +1,21 @@ """Usage: privateer2 --version + privateer2 [-f=PATH] pull privateer2 [-f=PATH] keygen privateer2 [-f=PATH] configure privateer2 [-f=PATH] check - privateer2 [-f=PATH] pull privateer2 [-f=PATH] serve [--dry-run] - privateer2 [-f=PATH] backup [--dry-run] - privateer2 [-f=PATH] restore [--dry-run] + privateer2 [-f=PATH] backup [--dry-run] + privateer2 [-f=PATH] restore [--dry-run] --from= Options: -f=PATH The path to the privateer configuration [default: privateer.json]. --server The name of the server to back up to, if more than one configured - --include Volumes to include in the backup - --exclude Volumes to exclude from the backup + +Commentary: + In all the above '' refers to the name of the client or server + being acted on; the machine we are generating keys for, configuring, + checking, serving, backing up from or restoring to. """ import docker @@ -27,6 +30,15 @@ from privateer2.server import serve +def pull(cfg): + img = [f"mrcide/privateer-client:{cfg.tag}", + f"mrcide/privateer-server:{cfg.tag}"] + cl = docker.from_env() + for nm in img: + print(f"pulling '{nm}'") + cl.images.pull(nm) + + def main(argv=None): opts = docopt.docopt(__doc__, argv) if opts["--version"]: @@ -47,9 +59,4 @@ def main(argv=None): elif opts["restore"]: restore(cfg, opts[""], dry_run=dry_run) elif opts["pull"]: - img = [f"mrcide/privateer-client:{cfg.tag}", - f"mrcide/privateer-server:{cfg.tag}"] - cl = docker.from_env() - for nm in img: - print(f"pulling '{nm}'") - cl.images.pull(nm) + pull(cfg) From dbe078dec10964604fd2440a4d349c2c6778f86e Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Mon, 16 Oct 2023 17:11:26 +0100 Subject: [PATCH 13/87] Better handling of input commands --- src/privateer2/backup.py | 32 +++++++++++--------------------- src/privateer2/cli.py | 6 +++--- src/privateer2/restore.py | 33 +++++++++++---------------------- src/privateer2/server.py | 20 ++++++++++++-------- src/privateer2/util.py | 27 +++++++++++++++++++++++++++ 5 files changed, 64 insertions(+), 54 deletions(-) diff --git a/src/privateer2/backup.py b/src/privateer2/backup.py index 5bf3740..7442b5b 100644 --- a/src/privateer2/backup.py +++ b/src/privateer2/backup.py @@ -1,32 +1,28 @@ import docker from privateer2.keys import check -from privateer2.util import ensure_image, log_tail +from privateer2.util import ensure_image, log_tail, match_value, mounts_str -def backup(cfg, name, *, dry_run=False): +def backup(cfg, name, volume, *, server=None, dry_run=False): machine = check(cfg, name, quiet=True) - if len(cfg.servers) != 1: - msg = "More than one server configured, some care needed" - raise Exception(msg) - server = cfg.servers[0].name - for volume in machine.backup: - backup_volume(cfg, name, volume, server, dry_run=dry_run) - - -def backup_volume(cfg, name, volume, server, *, dry_run=False): + server = match_value(server, cfg.list_servers(), "server") + volume = match_value(volume, machine.backup, "volume") machine = check(cfg, name, quiet=True) image = f"mrcide/privateer-client:{cfg.tag}" ensure_image(image) container = "privateer_client" src_mount = f"/privateer/{volume}" + mounts = [ + docker.types.Mount("/run/privateer", machine.key_volume, + type="volume", read_only=True), + docker.types.Mount(src_mount, volume, + type="volume", read_only=True) + ] command = ["rsync", "-av", "--delete", src_mount, f"{server}:/privateer/{name}"] if dry_run: - cmd = ["docker", "run", "--rm", - "-v", f"{machine.key_volume}:/run/privateer:ro", - "-v", f"{volume}:{src_mount}:ro", - image] + command + cmd = ["docker", "run", "--rm", *mounts_str(mounts), image] + command print("Command to manually run backup") print() print(f" {' '.join(cmd)}") @@ -39,12 +35,6 @@ def backup_volume(cfg, name, volume, server, *, dry_run=False): print("in /run/config/id_rsa") else: print(f"Backing up '{volume}' to '{server}'") - mounts = [ - docker.types.Mount("/run/privateer", machine.key_volume, - type="volume", read_only=True), - docker.types.Mount(src_mount, volume, - type="volume", read_only=True) - ] client = docker.from_env() container = client.containers.run(image, command=command, detach=True, mounts=mounts) diff --git a/src/privateer2/cli.py b/src/privateer2/cli.py index 686b9a7..514add8 100644 --- a/src/privateer2/cli.py +++ b/src/privateer2/cli.py @@ -6,7 +6,7 @@ privateer2 [-f=PATH] check privateer2 [-f=PATH] serve [--dry-run] privateer2 [-f=PATH] backup [--dry-run] - privateer2 [-f=PATH] restore [--dry-run] --from= + privateer2 [-f=PATH] restore [--dry-run] --from=NAME Options: -f=PATH The path to the privateer configuration [default: privateer.json]. @@ -55,8 +55,8 @@ def main(argv=None): elif opts["serve"]: serve(cfg, opts[""], dry_run=dry_run) elif opts["backup"]: - backup(cfg, opts[""], dry_run=dry_run) + backup(cfg, opts[""], opts[""], dry_run=dry_run) elif opts["restore"]: - restore(cfg, opts[""], dry_run=dry_run) + restore(cfg, opts[""], opts[""], dry_run=dry_run) elif opts["pull"]: pull(cfg) diff --git a/src/privateer2/restore.py b/src/privateer2/restore.py index cf743fa..dc4aece 100644 --- a/src/privateer2/restore.py +++ b/src/privateer2/restore.py @@ -1,32 +1,27 @@ import docker from privateer2.keys import check -from privateer2.util import ensure_image, log_tail, volume_exists +from privateer2.util import ensure_image, log_tail, mounts_str, volume_exists -def restore(cfg, name, *, dry_run=False): - machine = check(cfg, name, quiet=True) - if len(cfg.servers) != 1: - msg = "More than one server configured, some care needed" - raise Exception(msg) - server = cfg.servers[0].name - for volume in machine.restore: - restore_volume(cfg, name, volume, server, dry_run=dry_run) - - -def restore_volume(cfg, name, volume, server, *, dry_run=False): +def restore(cfg, name, volume, *, dry_run=False): machine = check(cfg, name, quiet=True) + server = match_value(server, cfg.list_servers(), "server") + volume = match_value(volume, machine.backup, "volume") image = f"mrcide/privateer-client:{cfg.tag}" ensure_image(image) container = "privateer_client" dest_mount = f"/privateer/{volume}" + mounts = [ + docker.types.Mount("/run/privateer", machine.key_volume, + type="volume", read_only=True), + docker.types.Mount(dest_mount, volume, + type="volume", read_only=False) + ] command = ["rsync", "-av", "--delete", f"{server}:/privateer/{name}/{volume}/", f"{dest_mount}/"] if dry_run: - cmd = ["docker", "run", "--rm", - "-v", f"{machine.key_volume}:/run/privateer:ro", - "-v", f"{volume}:{dest_mount}", - image] + command + cmd = ["docker", "run", "--rm", mounts_str(mounts), image] + command print("Command to manually run restore") print() print(f" {' '.join(cmd)}") @@ -43,12 +38,6 @@ def restore_volume(cfg, name, volume, server, *, dry_run=False): print("This command will overwrite the contents of this volume!") else: docker.from_env().volumes.create(volume) - mounts = [ - docker.types.Mount("/run/privateer", machine.key_volume, - type="volume", read_only=True), - docker.types.Mount(dest_mount, volume, - type="volume", read_only=False) - ] client = docker.from_env() container = client.containers.run(image, command=command, detach=True, mounts=mounts) diff --git a/src/privateer2/server.py b/src/privateer2/server.py index ea3e4b9..d9080c0 100644 --- a/src/privateer2/server.py +++ b/src/privateer2/server.py @@ -1,18 +1,27 @@ import docker from privateer2.keys import check -from privateer2.util import container_exists, ensure_image +from privateer2.util import container_exists, ensure_image, mounts_str def serve(cfg, name, *, dry_run=False): machine = check(cfg, name) image = f"mrcide/privateer-server:{cfg.tag}" ensure_image(image) + mounts = [ + docker.types.Mount("/run/privateer", machine.key_volume, + type="volume", read_only=True), + docker.types.Mount("/privateer", machine.data_volume, type="volume"), + ] + for v in cfg.volumes: + if v.local: + mounts.append(docker.types.Mount("/privateer/local/{v.name}", + v.name, type="volume", + read_only=True)) if dry_run: cmd = ["docker", "run", "--rm", "-d", "--name", machine.container, - "-v", f"{machine.key_volume}:/run/privateer:ro", - "-v", f"{machine.data_volume}:/privateer", + *mounts_str(mounts), "-p", f"{machine.port}:22", image] print("Command to manually launch server:") @@ -26,11 +35,6 @@ def serve(cfg, name, *, dry_run=False): msg = f"Container '{machine.container}' for '{name}' already running" raise Exception(msg) - mounts = [ - docker.types.Mount("/run/privateer", machine.key_volume, - type="volume", read_only=True), - docker.types.Mount("/privateer", machine.data_volume, type="volume"), - ] ports = {"22/tcp": machine.port} # or ("0.0.0.0", machine.port) client = docker.from_env() print("Starting server") diff --git a/src/privateer2/util.py b/src/privateer2/util.py index a382e16..b5c2ce6 100644 --- a/src/privateer2/util.py +++ b/src/privateer2/util.py @@ -155,3 +155,30 @@ def log_tail(container, n): if len(logs) > n: print(f"(ommitting {len(logs) - n} lines of logs)") print("\n".join(logs[-n:])) + + +def mounts_str(mounts): + ret = [] + for m in mounts: + ret += mount_str(m) + return ret + + +def mount_str(mount): + ret = f"{mount['Source']}:{mount['Target']}" + if mount["ReadOnly"]: + ret += ":ro" + return ["-v", ret] + + +def match_value(given, valid, name): + if given is None: + if len(valid) == 1: + return valid[0] + msg = f"Please provide a value for {name}" + raise Exception(msg) + if given not in valid: + valid_str = ", ".join([f"'{x}'" for x in valid]) + msg = f"Invalid {name} '{given}': valid options: {valid_str}" + raise Exception(msg) + return given From 86fd3c1d60cfddf902dad0cd3698c80a1ca594d3 Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Mon, 16 Oct 2023 18:25:30 +0100 Subject: [PATCH 14/87] Support data export --- src/privateer2/cli.py | 15 ++++++-- src/privateer2/restore.py | 80 +++++++++++++++++++++++++++++++++++++-- src/privateer2/server.py | 4 +- src/privateer2/util.py | 20 ++++++++++ 4 files changed, 110 insertions(+), 9 deletions(-) diff --git a/src/privateer2/cli.py b/src/privateer2/cli.py index 514add8..aa7e7f0 100644 --- a/src/privateer2/cli.py +++ b/src/privateer2/cli.py @@ -6,11 +6,12 @@ privateer2 [-f=PATH] check privateer2 [-f=PATH] serve [--dry-run] privateer2 [-f=PATH] backup [--dry-run] - privateer2 [-f=PATH] restore [--dry-run] --from=NAME + privateer2 [-f=PATH] restore [--dry-run] [--server=NAME] [--source=NAME] + privateer2 [-f=PATH] export [--dry-run] [--to=PATH] [--source=NAME] Options: -f=PATH The path to the privateer configuration [default: privateer.json]. - --server The name of the server to back up to, if more than one configured + --dry-run Do nothing, but print docker commands Commentary: In all the above '' refers to the name of the client or server @@ -26,7 +27,7 @@ from privateer2.backup import backup from privateer2.config import read_config from privateer2.keys import check, configure, keygen -from privateer2.restore import restore +from privateer2.restore import export, restore from privateer2.server import serve @@ -57,6 +58,12 @@ def main(argv=None): elif opts["backup"]: backup(cfg, opts[""], opts[""], dry_run=dry_run) elif opts["restore"]: - restore(cfg, opts[""], opts[""], dry_run=dry_run) + restore(cfg, opts[""], opts[""], + server=opts["--server"], source=opts["--source"], + dry_run=dry_run) + elif opts["export"]: + export(cfg, opts[""], opts[""], + to=opts["--to"], source=opts["--source"], + dry_run=dry_run) elif opts["pull"]: pull(cfg) diff --git a/src/privateer2/restore.py b/src/privateer2/restore.py index dc4aece..f2cb538 100644 --- a/src/privateer2/restore.py +++ b/src/privateer2/restore.py @@ -1,13 +1,15 @@ import docker +import os from privateer2.keys import check -from privateer2.util import ensure_image, log_tail, mounts_str, volume_exists +from privateer2.util import ensure_image, log_tail, match_value, mounts_str, volume_exists, isotimestamp, take_ownership -def restore(cfg, name, volume, *, dry_run=False): +def restore(cfg, name, volume, *, server=None, source=None, dry_run=False): machine = check(cfg, name, quiet=True) server = match_value(server, cfg.list_servers(), "server") volume = match_value(volume, machine.backup, "volume") + source = find_source(cfg, volume, source) image = f"mrcide/privateer-client:{cfg.tag}" ensure_image(image) container = "privateer_client" @@ -21,13 +23,13 @@ def restore(cfg, name, volume, *, dry_run=False): command = ["rsync", "-av", "--delete", f"{server}:/privateer/{name}/{volume}/", f"{dest_mount}/"] if dry_run: - cmd = ["docker", "run", "--rm", mounts_str(mounts), image] + command + cmd = ["docker", "run", "--rm", *mounts_str(mounts), image] + command print("Command to manually run restore") print() print(f" {' '.join(cmd)}") print() print(f"This will data from the server '{server}' into into our") - print(f"local volume '{volume}'") + print(f"local volume '{volume}'; data originally from '{source}'") print() print("Note that this uses hostname/port information for the server") print("contained within /run/privateer/config, along with our identity") @@ -53,3 +55,73 @@ def restore(cfg, name, volume, *, dry_run=False): log_tail(container, 20) msg = f"restore failed; see {container.name} logs for details" raise Exception(msg) + + +def export(cfg, name, volume, *, to=None, source=None, dry_run=False): + machine = check(cfg, name, quiet=True) + # TODO: check here that volume is either local, or that it is a + # backup target for anything. + source = find_source(cfg, volume, source) + image = f"mrcide/privateer-client:{cfg.tag}" + ensure_image(image) + if to is None: + export_path = os.getcwd() + else: + export_path = os.path.abspath(to) + mounts = [ + docker.types.Mount("/export", export_path, type="bind"), + docker.types.Mount("/privateer", machine.data_volume, type="volume", + read_only=True) + ] + tarfile = f"{source}-{volume}-{isotimestamp()}.tar" + working_dir = f"/privateer/{source}/{volume}" + command = ["tar", "-cpvf", f"/export/{tarfile}", "."] + if dry_run: + cmd = ["docker", "run", "--rm", *mounts_str(mounts), "-w", working_dir, image] + command + print("Command to manually run export") + print() + print(f" {' '.join(cmd)}") + print() + print("(pay attention to the final '.' in the above command!)") + print() + print(f"This will data from the server '{name}' onto the host") + print(f"machine at '{export_path}' as '{tarfile}'.") + print(f"Data originally from '{source}'") + print() + print("Note that this file will have root ownership after creation") + print(f"You can fix that with 'sudo chown $(whoami) {tarfile}'") + print("or") + print() + cmd_own = take_ownership(tarfile, export_path, command_only=True) + print(f" {' '.join(cmd_own)}") + else: + client = docker.from_env() + container = client.containers.run(image, command=command, detach=True, + mounts=mounts, + working_dir=working_dir) + print("Export command started. To stream progress, run:") + print(f" docker logs -f {container.name}") + result = container.wait() + if result["StatusCode"] == 0: + print("Export completed successfully! Container logs:") + log_tail(container, 10) + container.remove() + os.geteuid() + print("Taking ownership of file") + take_ownership(tarfile, export_path) + else: + print("An error occured! Container logs:") + log_tail(container, 20) + msg = f"export failed; see {container.name} logs for details" + raise Exception(msg) + + +def find_source(cfg, volume, source): + for v in cfg.volumes: + if v.name == volume and v.local: + if source is not None: + msg = f"{volume} is a local source, so 'source' must be empty" + raise Exception(msg) + return "local" + pos = [cl.name for cl in cfg.clients if volume in cl.backup] + return match_value(source, pos, "source") diff --git a/src/privateer2/server.py b/src/privateer2/server.py index d9080c0..c411e33 100644 --- a/src/privateer2/server.py +++ b/src/privateer2/server.py @@ -1,4 +1,5 @@ import docker +import os from privateer2.keys import check from privateer2.util import container_exists, ensure_image, mounts_str @@ -8,10 +9,11 @@ def serve(cfg, name, *, dry_run=False): machine = check(cfg, name) image = f"mrcide/privateer-server:{cfg.tag}" ensure_image(image) + mounts = [ docker.types.Mount("/run/privateer", machine.key_volume, type="volume", read_only=True), - docker.types.Mount("/privateer", machine.data_volume, type="volume"), + docker.types.Mount("/privateer", machine.data_volume, type="volume") ] for v in cfg.volumes: if v.local: diff --git a/src/privateer2/util.py b/src/privateer2/util.py index b5c2ce6..54e1b5e 100644 --- a/src/privateer2/util.py +++ b/src/privateer2/util.py @@ -1,3 +1,4 @@ +import datetime import random import string import os @@ -182,3 +183,22 @@ def match_value(given, valid, name): msg = f"Invalid {name} '{given}': valid options: {valid_str}" raise Exception(msg) return given + + +def isotimestamp(): + return datetime.datetime.now().strftime("%Y%m%d-%H%M%S") + + +def take_ownership(filename, directory, *, command_only=False): + uid = os.geteuid() + gid = os.getegid() + cl = docker.from_env() + ensure_image("alpine") + mounts = [docker.types.Mount("/src", directory, type="bind")] + command = ["chown", f"{uid}.{gid}", filename] + if command_only: + return ["docker", "run", *mounts_str(mounts), "-w", "/src", + "alpine"] + command + else: + cl.containers.run("alpine", mounts=mounts, working_dir="/src", + command=command) From 3f9cf455d1dc6c335dd1dd4edca7c5db99b29a53 Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Mon, 16 Oct 2023 18:40:20 +0100 Subject: [PATCH 15/87] Big tidyup, simpler now --- src/privateer2/backup.py | 25 +++-------- src/privateer2/cli.py | 10 ++--- src/privateer2/config.py | 12 +++++ src/privateer2/restore.py | 95 ++------------------------------------- src/privateer2/tar.py | 49 ++++++++++++++++++++ src/privateer2/util.py | 21 +++++++++ 6 files changed, 95 insertions(+), 117 deletions(-) create mode 100644 src/privateer2/tar.py diff --git a/src/privateer2/backup.py b/src/privateer2/backup.py index 7442b5b..a55e418 100644 --- a/src/privateer2/backup.py +++ b/src/privateer2/backup.py @@ -1,7 +1,7 @@ import docker from privateer2.keys import check -from privateer2.util import ensure_image, log_tail, match_value, mounts_str +from privateer2.util import ensure_image, log_tail, match_value, mounts_str, run_docker_command def backup(cfg, name, volume, *, server=None, dry_run=False): @@ -10,7 +10,6 @@ def backup(cfg, name, volume, *, server=None, dry_run=False): volume = match_value(volume, machine.backup, "volume") machine = check(cfg, name, quiet=True) image = f"mrcide/privateer-client:{cfg.tag}" - ensure_image(image) container = "privateer_client" src_mount = f"/privateer/{volume}" mounts = [ @@ -35,21 +34,7 @@ def backup(cfg, name, volume, *, server=None, dry_run=False): print("in /run/config/id_rsa") else: print(f"Backing up '{volume}' to '{server}'") - client = docker.from_env() - container = client.containers.run(image, command=command, detach=True, - mounts=mounts) - print("Backup command started. To stream progress, run:") - print(f" docker logs -f {container.name}") - result = container.wait() - if result["StatusCode"] == 0: - print("Backup completed successfully! Container logs:") - log_tail(container, 10) - container.remove() - # TODO: also copy over some metadata at this point, via - # ssh; probably best to write tiny utility in the client - # container that will do this for us. - else: - print("An error occured! Container logs:") - log_tail(container, 20) - msg = f"backup failed; see {container.name} logs for details" - raise Exception(msg) + run_docker_command("Backup", image, command=command, mounts=mounts) + # TODO: also copy over some metadata at this point, via + # ssh; probably best to write tiny utility in the client + # container that will do this for us. diff --git a/src/privateer2/cli.py b/src/privateer2/cli.py index aa7e7f0..4cf9fc5 100644 --- a/src/privateer2/cli.py +++ b/src/privateer2/cli.py @@ -27,9 +27,9 @@ from privateer2.backup import backup from privateer2.config import read_config from privateer2.keys import check, configure, keygen -from privateer2.restore import export, restore +from privateer2.restore import restore from privateer2.server import serve - +from privateer2.tar import export_tar def pull(cfg): img = [f"mrcide/privateer-client:{cfg.tag}", @@ -62,8 +62,8 @@ def main(argv=None): server=opts["--server"], source=opts["--source"], dry_run=dry_run) elif opts["export"]: - export(cfg, opts[""], opts[""], - to=opts["--to"], source=opts["--source"], - dry_run=dry_run) + export_tar(cfg, opts[""], opts[""], + to=opts["--to"], source=opts["--source"], + dry_run=dry_run) elif opts["pull"]: pull(cfg) diff --git a/src/privateer2/config.py b/src/privateer2/config.py index ba1ebce..d2cf94c 100644 --- a/src/privateer2/config.py +++ b/src/privateer2/config.py @@ -3,6 +3,7 @@ from pydantic import BaseModel +from privateer2.util import match_value from privateer2.vault import vault_client @@ -51,3 +52,14 @@ def list_servers(self): def list_clients(self): return [x.name for x in self.clients] + + +def find_source(cfg, volume, source): + for v in cfg.volumes: + if v.name == volume and v.local: + if source is not None: + msg = f"{volume} is a local source, so 'source' must be empty" + raise Exception(msg) + return "local" + pos = [cl.name for cl in cfg.clients if volume in cl.backup] + return match_value(source, pos, "source") diff --git a/src/privateer2/restore.py b/src/privateer2/restore.py index f2cb538..a8a5ebc 100644 --- a/src/privateer2/restore.py +++ b/src/privateer2/restore.py @@ -1,8 +1,8 @@ import docker -import os +from privateer2.config import find_source from privateer2.keys import check -from privateer2.util import ensure_image, log_tail, match_value, mounts_str, volume_exists, isotimestamp, take_ownership +from privateer2.util import ensure_image, match_value, mounts_str, run_docker_command, volume_exists def restore(cfg, name, volume, *, server=None, source=None, dry_run=False): @@ -11,7 +11,6 @@ def restore(cfg, name, volume, *, server=None, source=None, dry_run=False): volume = match_value(volume, machine.backup, "volume") source = find_source(cfg, volume, source) image = f"mrcide/privateer-client:{cfg.tag}" - ensure_image(image) container = "privateer_client" dest_mount = f"/privateer/{volume}" mounts = [ @@ -36,92 +35,4 @@ def restore(cfg, name, volume, *, server=None, source=None, dry_run=False): print("in /run/config/id_rsa") else: print(f"Restoring '{volume}' from '{server}'") - if volume_exists(volume): - print("This command will overwrite the contents of this volume!") - else: - docker.from_env().volumes.create(volume) - client = docker.from_env() - container = client.containers.run(image, command=command, detach=True, - mounts=mounts) - print("Restore command started. To stream progress, run:") - print(f" docker logs -f {container.name}") - result = container.wait() - if result["StatusCode"] == 0: - print("Restore completed successfully! Container logs:") - log_tail(container, 10) - container.remove() - else: - print("An error occured! Container logs:") - log_tail(container, 20) - msg = f"restore failed; see {container.name} logs for details" - raise Exception(msg) - - -def export(cfg, name, volume, *, to=None, source=None, dry_run=False): - machine = check(cfg, name, quiet=True) - # TODO: check here that volume is either local, or that it is a - # backup target for anything. - source = find_source(cfg, volume, source) - image = f"mrcide/privateer-client:{cfg.tag}" - ensure_image(image) - if to is None: - export_path = os.getcwd() - else: - export_path = os.path.abspath(to) - mounts = [ - docker.types.Mount("/export", export_path, type="bind"), - docker.types.Mount("/privateer", machine.data_volume, type="volume", - read_only=True) - ] - tarfile = f"{source}-{volume}-{isotimestamp()}.tar" - working_dir = f"/privateer/{source}/{volume}" - command = ["tar", "-cpvf", f"/export/{tarfile}", "."] - if dry_run: - cmd = ["docker", "run", "--rm", *mounts_str(mounts), "-w", working_dir, image] + command - print("Command to manually run export") - print() - print(f" {' '.join(cmd)}") - print() - print("(pay attention to the final '.' in the above command!)") - print() - print(f"This will data from the server '{name}' onto the host") - print(f"machine at '{export_path}' as '{tarfile}'.") - print(f"Data originally from '{source}'") - print() - print("Note that this file will have root ownership after creation") - print(f"You can fix that with 'sudo chown $(whoami) {tarfile}'") - print("or") - print() - cmd_own = take_ownership(tarfile, export_path, command_only=True) - print(f" {' '.join(cmd_own)}") - else: - client = docker.from_env() - container = client.containers.run(image, command=command, detach=True, - mounts=mounts, - working_dir=working_dir) - print("Export command started. To stream progress, run:") - print(f" docker logs -f {container.name}") - result = container.wait() - if result["StatusCode"] == 0: - print("Export completed successfully! Container logs:") - log_tail(container, 10) - container.remove() - os.geteuid() - print("Taking ownership of file") - take_ownership(tarfile, export_path) - else: - print("An error occured! Container logs:") - log_tail(container, 20) - msg = f"export failed; see {container.name} logs for details" - raise Exception(msg) - - -def find_source(cfg, volume, source): - for v in cfg.volumes: - if v.name == volume and v.local: - if source is not None: - msg = f"{volume} is a local source, so 'source' must be empty" - raise Exception(msg) - return "local" - pos = [cl.name for cl in cfg.clients if volume in cl.backup] - return match_value(source, pos, "source") + run_docker_command("Restore", image, command=command, mounts=mounts) diff --git a/src/privateer2/tar.py b/src/privateer2/tar.py new file mode 100644 index 0000000..4d0cc02 --- /dev/null +++ b/src/privateer2/tar.py @@ -0,0 +1,49 @@ +import docker +import os + +from privateer2.config import find_source +from privateer2.keys import check +from privateer2.util import isotimestamp, mounts_str, run_docker_command, take_ownership + +def export_tar(cfg, name, volume, *, to=None, source=None, dry_run=False): + machine = check(cfg, name, quiet=True) + # TODO: check here that volume is either local, or that it is a + # backup target for anything. + source = find_source(cfg, volume, source) + image = f"mrcide/privateer-client:{cfg.tag}" + if to is None: + export_path = os.getcwd() + else: + export_path = os.path.abspath(to) + mounts = [ + docker.types.Mount("/export", export_path, type="bind"), + docker.types.Mount("/privateer", machine.data_volume, type="volume", + read_only=True) + ] + tarfile = f"{source}-{volume}-{isotimestamp()}.tar" + working_dir = f"/privateer/{source}/{volume}" + command = ["tar", "-cpvf", f"/export/{tarfile}", "."] + if dry_run: + cmd = ["docker", "run", "--rm", *mounts_str(mounts), "-w", working_dir, image] + command + print("Command to manually run export") + print() + print(f" {' '.join(cmd)}") + print() + print("(pay attention to the final '.' in the above command!)") + print() + print(f"This will data from the server '{name}' onto the host") + print(f"machine at '{export_path}' as '{tarfile}'.") + print(f"Data originally from '{source}'") + print() + print("Note that this file will have root ownership after creation") + print(f"You can fix that with 'sudo chown $(whoami) {tarfile}'") + print("or") + print() + cmd_own = take_ownership(tarfile, export_path, command_only=True) + print(f" {' '.join(cmd_own)}") + else: + run_docker_command("Export", image, command=command, mounts=mounts, + working_dir=working_dir) + print("Taking ownership of file") + take_ownership(tarfile, export_path) + print(f"Tar file ready at '{export_path}/{tarfile}'") diff --git a/src/privateer2/util.py b/src/privateer2/util.py index 54e1b5e..611d2fb 100644 --- a/src/privateer2/util.py +++ b/src/privateer2/util.py @@ -202,3 +202,24 @@ def take_ownership(filename, directory, *, command_only=False): else: cl.containers.run("alpine", mounts=mounts, working_dir="/src", command=command) + + +def run_docker_command(name, image, **kwargs): + ensure_image(image) + client = docker.from_env() + container = client.containers.run(image, **kwargs, detach=True) + print(f"{name} command started. To stream progress, run:") + print(f" docker logs -f {container.name}") + result = container.wait() + if result["StatusCode"] == 0: + print(f"{name} completed successfully! Container logs:") + log_tail(container, 10) + container.remove() + # TODO: also copy over some metadata at this point, via + # ssh; probably best to write tiny utility in the client + # container that will do this for us. + else: + print("An error occured! Container logs:") + log_tail(container, 20) + msg = f"{name} failed; see {container.name} logs for details" + raise Exception(msg) From d93b3cd9cc0cb9cefeb3cc3cf85456100164411a Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Mon, 16 Oct 2023 18:55:52 +0100 Subject: [PATCH 16/87] Add manual import path --- src/privateer2/cli.py | 16 ++++++++++++++-- src/privateer2/tar.py | 29 ++++++++++++++++++++++++++++- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/src/privateer2/cli.py b/src/privateer2/cli.py index 4cf9fc5..f622ab6 100644 --- a/src/privateer2/cli.py +++ b/src/privateer2/cli.py @@ -8,6 +8,7 @@ privateer2 [-f=PATH] backup [--dry-run] privateer2 [-f=PATH] restore [--dry-run] [--server=NAME] [--source=NAME] privateer2 [-f=PATH] export [--dry-run] [--to=PATH] [--source=NAME] + privateer2 import [--dry-run] Options: -f=PATH The path to the privateer configuration [default: privateer.json]. @@ -17,6 +18,10 @@ In all the above '' refers to the name of the client or server being acted on; the machine we are generating keys for, configuring, checking, serving, backing up from or restoring to. + + Note that the 'import' subcommand is quite different and does not + interact with the configuration. If 'volume' exists already, it will + fail, so this is fairly safe. """ import docker @@ -29,7 +34,7 @@ from privateer2.keys import check, configure, keygen from privateer2.restore import restore from privateer2.server import serve -from privateer2.tar import export_tar +from privateer2.tar import export_tar, import_tar def pull(cfg): img = [f"mrcide/privateer-client:{cfg.tag}", @@ -44,9 +49,13 @@ def main(argv=None): opts = docopt.docopt(__doc__, argv) if opts["--version"]: return about.__version__ + + dry_run = opts["--dry-run"] + if opts["import"]: + return import_tar(opts[""], opts[""], dry_run=dry_run) + path_config = opts["-f"] cfg = read_config(path_config) - dry_run = opts["--dry-run"] if opts["keygen"]: keygen(cfg, opts[""]) elif opts["configure"]: @@ -65,5 +74,8 @@ def main(argv=None): export_tar(cfg, opts[""], opts[""], to=opts["--to"], source=opts["--source"], dry_run=dry_run) + elif opts["import"]: + import_tar(opts[""], opts[""], + opts[""], dry_run=dry_run) elif opts["pull"]: pull(cfg) diff --git a/src/privateer2/tar.py b/src/privateer2/tar.py index 4d0cc02..0809f93 100644 --- a/src/privateer2/tar.py +++ b/src/privateer2/tar.py @@ -3,7 +3,7 @@ from privateer2.config import find_source from privateer2.keys import check -from privateer2.util import isotimestamp, mounts_str, run_docker_command, take_ownership +from privateer2.util import isotimestamp, mounts_str, run_docker_command, take_ownership, volume_exists def export_tar(cfg, name, volume, *, to=None, source=None, dry_run=False): machine = check(cfg, name, quiet=True) @@ -47,3 +47,30 @@ def export_tar(cfg, name, volume, *, to=None, source=None, dry_run=False): print("Taking ownership of file") take_ownership(tarfile, export_path) print(f"Tar file ready at '{export_path}/{tarfile}'") + + +def import_tar(volume, tarfile, *, dry_run=False): + if volume_exists(volume): + msg = f"Volume '{volume}' already exists, please delete first" + raise Exception(msg) + if not os.path.exists(tarfile): + msg = f"Input file '{tarfile}' does not exist" + + image = f"alpine" + tarfile = os.path.abspath(tarfile) + mounts = [ + docker.types.Mount("/src.tar", tarfile, type="bind", read_only=True), + docker.types.Mount("/privateer", volume, type="volume") + ] + working_dir = f"/privateer" + command = ["tar", "-xvf", "/src.tar"] + if dry_run: + cmd = ["docker", "run", "--rm", *mounts_str(mounts), "-w", working_dir, image] + command + print("Command to manually run import") + print() + print(f" docker volume create {volume}") + print(f" {' '.join(cmd)}") + else: + docker.from_env().volumes.create(volume) + run_docker_command("Import", image, command=command, mounts=mounts, + working_dir=working_dir) From 409931e8711716d071fefe923b712f620d7eb36e Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Mon, 16 Oct 2023 19:01:02 +0100 Subject: [PATCH 17/87] Reformat --- src/privateer2/backup.py | 28 ++++++++++------- src/privateer2/cli.py | 53 +++++++++++++++++++------------- src/privateer2/config.py | 3 ++ src/privateer2/keys.py | 4 +-- src/privateer2/restore.py | 22 ++++++++------ src/privateer2/server.py | 48 +++++++++++++++++++---------- src/privateer2/tar.py | 63 +++++++++++++++++++++++++++++++-------- src/privateer2/util.py | 23 +++++++++----- tests/test_keys.py | 2 +- 9 files changed, 168 insertions(+), 78 deletions(-) diff --git a/src/privateer2/backup.py b/src/privateer2/backup.py index a55e418..6af6ac7 100644 --- a/src/privateer2/backup.py +++ b/src/privateer2/backup.py @@ -1,7 +1,7 @@ import docker from privateer2.keys import check -from privateer2.util import ensure_image, log_tail, match_value, mounts_str, run_docker_command +from privateer2.util import match_value, mounts_str, run_docker_command def backup(cfg, name, volume, *, server=None, dry_run=False): @@ -10,24 +10,30 @@ def backup(cfg, name, volume, *, server=None, dry_run=False): volume = match_value(volume, machine.backup, "volume") machine = check(cfg, name, quiet=True) image = f"mrcide/privateer-client:{cfg.tag}" - container = "privateer_client" src_mount = f"/privateer/{volume}" mounts = [ - docker.types.Mount("/run/privateer", machine.key_volume, - type="volume", read_only=True), - docker.types.Mount(src_mount, volume, - type="volume", read_only=True) + docker.types.Mount( + "/run/privateer", machine.key_volume, type="volume", read_only=True + ), + docker.types.Mount(src_mount, volume, type="volume", read_only=True), + ] + command = [ + "rsync", + "-av", + "--delete", + src_mount, + f"{server}:/privateer/{name}", ] - command = ["rsync", "-av", "--delete", src_mount, - f"{server}:/privateer/{name}"] if dry_run: - cmd = ["docker", "run", "--rm", *mounts_str(mounts), image] + command + cmd = ["docker", "run", "--rm", *mounts_str(mounts), image, *command] print("Command to manually run backup") print() print(f" {' '.join(cmd)}") print() - print(f"This will copy the volume '{volume}' from '{name}' " + - f"to the server '{server}'") + print( + f"This will copy the volume '{volume}' from '{name}' " + f"to the server '{server}'" + ) print() print("Note that this uses hostname/port information for the server") print("contained within /run/privateer/config, along with our identity") diff --git a/src/privateer2/cli.py b/src/privateer2/cli.py index f622ab6..4385e0e 100644 --- a/src/privateer2/cli.py +++ b/src/privateer2/cli.py @@ -1,14 +1,14 @@ """Usage: privateer2 --version - privateer2 [-f=PATH] pull - privateer2 [-f=PATH] keygen - privateer2 [-f=PATH] configure - privateer2 [-f=PATH] check - privateer2 [-f=PATH] serve [--dry-run] - privateer2 [-f=PATH] backup [--dry-run] - privateer2 [-f=PATH] restore [--dry-run] [--server=NAME] [--source=NAME] - privateer2 [-f=PATH] export [--dry-run] [--to=PATH] [--source=NAME] - privateer2 import [--dry-run] + privateer2 [options] pull + privateer2 [options] keygen + privateer2 [options] configure + privateer2 [options] check + privateer2 [options] serve + privateer2 [options] backup + privateer2 [options] restore [--server=NAME] [--source=NAME] + privateer2 [options] export [--to=PATH] [--source=NAME] + privateer2 [--dry-run] import Options: -f=PATH The path to the privateer configuration [default: privateer.json]. @@ -28,7 +28,6 @@ import docopt import privateer2.__about__ as about - from privateer2.backup import backup from privateer2.config import read_config from privateer2.keys import check, configure, keygen @@ -36,9 +35,12 @@ from privateer2.server import serve from privateer2.tar import export_tar, import_tar + def pull(cfg): - img = [f"mrcide/privateer-client:{cfg.tag}", - f"mrcide/privateer-server:{cfg.tag}"] + img = [ + f"mrcide/privateer-client:{cfg.tag}", + f"mrcide/privateer-server:{cfg.tag}", + ] cl = docker.from_env() for nm in img: print(f"pulling '{nm}'") @@ -67,15 +69,26 @@ def main(argv=None): elif opts["backup"]: backup(cfg, opts[""], opts[""], dry_run=dry_run) elif opts["restore"]: - restore(cfg, opts[""], opts[""], - server=opts["--server"], source=opts["--source"], - dry_run=dry_run) + restore( + cfg, + opts[""], + opts[""], + server=opts["--server"], + source=opts["--source"], + dry_run=dry_run, + ) elif opts["export"]: - export_tar(cfg, opts[""], opts[""], - to=opts["--to"], source=opts["--source"], - dry_run=dry_run) + export_tar( + cfg, + opts[""], + opts[""], + to=opts["--to"], + source=opts["--source"], + dry_run=dry_run, + ) elif opts["import"]: - import_tar(opts[""], opts[""], - opts[""], dry_run=dry_run) + import_tar( + opts[""], opts[""], opts[""], dry_run=dry_run + ) elif opts["pull"]: pull(cfg) diff --git a/src/privateer2/config.py b/src/privateer2/config.py index d2cf94c..28696ac 100644 --- a/src/privateer2/config.py +++ b/src/privateer2/config.py @@ -12,6 +12,8 @@ def read_config(path): return Config(**json.loads(f.read().strip())) +# TODO: forbid name of 'local' for either server of client, if that is +# the name that we stick with. class Server(BaseModel): name: str hostname: str @@ -30,6 +32,7 @@ class Client(BaseModel): class Volume(BaseModel): name: str + local: bool = False class Vault(BaseModel): diff --git a/src/privateer2/keys.py b/src/privateer2/keys.py index 5bbc054..de02775 100644 --- a/src/privateer2/keys.py +++ b/src/privateer2/keys.py @@ -1,7 +1,7 @@ +import docker from cryptography.hazmat.primitives import serialization as crypto_serialization from cryptography.hazmat.primitives.asymmetric import rsa -import docker from privateer2.util import string_from_volume, string_to_volume @@ -113,7 +113,7 @@ def _keys_data(cfg, name): for s in cfg.servers: known_hosts.append(f"[{s.hostname}]:{s.port} {keys[s.name]}\n") config.append(f"Host {s.name}\n") - config.append( " User root\n") + config.append(" User root\n") config.append(f" Port {s.port}\n") config.append(f" HostName {s.hostname}\n") ret["known_hosts"] = "".join(known_hosts) diff --git a/src/privateer2/restore.py b/src/privateer2/restore.py index a8a5ebc..e8cfb0d 100644 --- a/src/privateer2/restore.py +++ b/src/privateer2/restore.py @@ -2,7 +2,7 @@ from privateer2.config import find_source from privateer2.keys import check -from privateer2.util import ensure_image, match_value, mounts_str, run_docker_command, volume_exists +from privateer2.util import match_value, mounts_str, run_docker_command def restore(cfg, name, volume, *, server=None, source=None, dry_run=False): @@ -11,18 +11,22 @@ def restore(cfg, name, volume, *, server=None, source=None, dry_run=False): volume = match_value(volume, machine.backup, "volume") source = find_source(cfg, volume, source) image = f"mrcide/privateer-client:{cfg.tag}" - container = "privateer_client" dest_mount = f"/privateer/{volume}" mounts = [ - docker.types.Mount("/run/privateer", machine.key_volume, - type="volume", read_only=True), - docker.types.Mount(dest_mount, volume, - type="volume", read_only=False) + docker.types.Mount( + "/run/privateer", machine.key_volume, type="volume", read_only=True + ), + docker.types.Mount(dest_mount, volume, type="volume", read_only=False), + ] + command = [ + "rsync", + "-av", + "--delete", + f"{server}:/privateer/{name}/{volume}/", + f"{dest_mount}/", ] - command = ["rsync", "-av", "--delete", - f"{server}:/privateer/{name}/{volume}/", f"{dest_mount}/"] if dry_run: - cmd = ["docker", "run", "--rm", *mounts_str(mounts), image] + command + cmd = ["docker", "run", "--rm", *mounts_str(mounts), image, *command] print("Command to manually run restore") print() print(f" {' '.join(cmd)}") diff --git a/src/privateer2/server.py b/src/privateer2/server.py index c411e33..df82529 100644 --- a/src/privateer2/server.py +++ b/src/privateer2/server.py @@ -1,5 +1,4 @@ import docker -import os from privateer2.keys import check from privateer2.util import container_exists, ensure_image, mounts_str @@ -11,21 +10,34 @@ def serve(cfg, name, *, dry_run=False): ensure_image(image) mounts = [ - docker.types.Mount("/run/privateer", machine.key_volume, - type="volume", read_only=True), - docker.types.Mount("/privateer", machine.data_volume, type="volume") + docker.types.Mount( + "/run/privateer", machine.key_volume, type="volume", read_only=True + ), + docker.types.Mount("/privateer", machine.data_volume, type="volume"), ] for v in cfg.volumes: if v.local: - mounts.append(docker.types.Mount("/privateer/local/{v.name}", - v.name, type="volume", - read_only=True)) + mounts.append( + docker.types.Mount( + "/privateer/local/{v.name}", + v.name, + type="volume", + read_only=True, + ) + ) if dry_run: - cmd = ["docker", "run", "--rm", "-d", - "--name", machine.container, - *mounts_str(mounts), - "-p", f"{machine.port}:22", - image] + cmd = [ + "docker", + "run", + "--rm", + "-d", + "--name", + machine.container, + *mounts_str(mounts), + "-p", + f"{machine.port}:22", + image, + ] print("Command to manually launch server:") print() print(f" {' '.join(cmd)}") @@ -37,9 +49,15 @@ def serve(cfg, name, *, dry_run=False): msg = f"Container '{machine.container}' for '{name}' already running" raise Exception(msg) - ports = {"22/tcp": machine.port} # or ("0.0.0.0", machine.port) + ports = {"22/tcp": machine.port} # or ("0.0.0.0", machine.port) client = docker.from_env() print("Starting server") - client.containers.run(image, auto_remove=True, detach=True, - name=machine.container, mounts=mounts, ports=ports) + client.containers.run( + image, + auto_remove=True, + detach=True, + name=machine.container, + mounts=mounts, + ports=ports, + ) print(f"Server {name} now running on port {machine.port}") diff --git a/src/privateer2/tar.py b/src/privateer2/tar.py index 0809f93..c6c6d9d 100644 --- a/src/privateer2/tar.py +++ b/src/privateer2/tar.py @@ -1,9 +1,17 @@ -import docker import os +import docker + from privateer2.config import find_source from privateer2.keys import check -from privateer2.util import isotimestamp, mounts_str, run_docker_command, take_ownership, volume_exists +from privateer2.util import ( + isotimestamp, + mounts_str, + run_docker_command, + take_ownership, + volume_exists, +) + def export_tar(cfg, name, volume, *, to=None, source=None, dry_run=False): machine = check(cfg, name, quiet=True) @@ -17,14 +25,24 @@ def export_tar(cfg, name, volume, *, to=None, source=None, dry_run=False): export_path = os.path.abspath(to) mounts = [ docker.types.Mount("/export", export_path, type="bind"), - docker.types.Mount("/privateer", machine.data_volume, type="volume", - read_only=True) + docker.types.Mount( + "/privateer", machine.data_volume, type="volume", read_only=True + ), ] tarfile = f"{source}-{volume}-{isotimestamp()}.tar" working_dir = f"/privateer/{source}/{volume}" command = ["tar", "-cpvf", f"/export/{tarfile}", "."] if dry_run: - cmd = ["docker", "run", "--rm", *mounts_str(mounts), "-w", working_dir, image] + command + cmd = [ + "docker", + "run", + "--rm", + *mounts_str(mounts), + "-w", + working_dir, + image, + *command, + ] print("Command to manually run export") print() print(f" {' '.join(cmd)}") @@ -42,8 +60,13 @@ def export_tar(cfg, name, volume, *, to=None, source=None, dry_run=False): cmd_own = take_ownership(tarfile, export_path, command_only=True) print(f" {' '.join(cmd_own)}") else: - run_docker_command("Export", image, command=command, mounts=mounts, - working_dir=working_dir) + run_docker_command( + "Export", + image, + command=command, + mounts=mounts, + working_dir=working_dir, + ) print("Taking ownership of file") take_ownership(tarfile, export_path) print(f"Tar file ready at '{export_path}/{tarfile}'") @@ -56,21 +79,35 @@ def import_tar(volume, tarfile, *, dry_run=False): if not os.path.exists(tarfile): msg = f"Input file '{tarfile}' does not exist" - image = f"alpine" + image = "alpine" tarfile = os.path.abspath(tarfile) mounts = [ docker.types.Mount("/src.tar", tarfile, type="bind", read_only=True), - docker.types.Mount("/privateer", volume, type="volume") + docker.types.Mount("/privateer", volume, type="volume"), ] - working_dir = f"/privateer" + working_dir = "/privateer" command = ["tar", "-xvf", "/src.tar"] if dry_run: - cmd = ["docker", "run", "--rm", *mounts_str(mounts), "-w", working_dir, image] + command + cmd = [ + "docker", + "run", + "--rm", + *mounts_str(mounts), + "-w", + working_dir, + image, + *command, + ] print("Command to manually run import") print() print(f" docker volume create {volume}") print(f" {' '.join(cmd)}") else: docker.from_env().volumes.create(volume) - run_docker_command("Import", image, command=command, mounts=mounts, - working_dir=working_dir) + run_docker_command( + "Import", + image, + command=command, + mounts=mounts, + working_dir=working_dir, + ) diff --git a/src/privateer2/util.py b/src/privateer2/util.py index 611d2fb..bd6beac 100644 --- a/src/privateer2/util.py +++ b/src/privateer2/util.py @@ -1,8 +1,8 @@ import datetime -import random -import string import os import os.path +import random +import string import tarfile import tempfile from contextlib import contextmanager @@ -186,7 +186,8 @@ def match_value(given, valid, name): def isotimestamp(): - return datetime.datetime.now().strftime("%Y%m%d-%H%M%S") + now = datetime.datetime.now(tz=datetime.timezone.utc) + return now.strftime("%Y%m%d-%H%M%S") def take_ownership(filename, directory, *, command_only=False): @@ -197,11 +198,19 @@ def take_ownership(filename, directory, *, command_only=False): mounts = [docker.types.Mount("/src", directory, type="bind")] command = ["chown", f"{uid}.{gid}", filename] if command_only: - return ["docker", "run", *mounts_str(mounts), "-w", "/src", - "alpine"] + command + return [ + "docker", + "run", + *mounts_str(mounts), + "-w", + "/src", + "alpine", + *command, + ] else: - cl.containers.run("alpine", mounts=mounts, working_dir="/src", - command=command) + cl.containers.run( + "alpine", mounts=mounts, working_dir="/src", command=command + ) def run_docker_command(name, image, **kwargs): diff --git a/tests/test_keys.py b/tests/test_keys.py index 23b433c..622f504 100644 --- a/tests/test_keys.py +++ b/tests/test_keys.py @@ -1,7 +1,7 @@ +import docker import pytest import vault_dev -import docker from privateer2.config import read_config from privateer2.keys import _keys_data, check, configure, keygen from privateer2.util import rand_str, string_from_volume From 4f6f43a0c2eeabf081c74559087cb9bd26a4e78b Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Mon, 16 Oct 2023 19:01:26 +0100 Subject: [PATCH 18/87] Ignore more files --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index c6625cd..6110248 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ __pycache__ dist/ .coverage coverage.xml +*.tar From b265da9d27d0315b8eaf42219a4a10e3883f233c Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Fri, 13 Oct 2023 16:50:19 +0100 Subject: [PATCH 19/87] Add docker images --- docker/Dockerfile.client | 10 ++++++++++ docker/Dockerfile.server | 19 +++++++++++++++++++ docker/build | 30 ++++++++++++++++++++++++++++++ docker/common | 24 ++++++++++++++++++++++++ docker/ssh_config | 5 +++++ docker/sshd_config | 28 ++++++++++++++++++++++++++++ 6 files changed, 116 insertions(+) create mode 100644 docker/Dockerfile.client create mode 100644 docker/Dockerfile.server create mode 100755 docker/build create mode 100644 docker/common create mode 100644 docker/ssh_config create mode 100644 docker/sshd_config diff --git a/docker/Dockerfile.client b/docker/Dockerfile.client new file mode 100644 index 0000000..168e65a --- /dev/null +++ b/docker/Dockerfile.client @@ -0,0 +1,10 @@ +FROM ubuntu + +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + openssh-client \ + rsync && \ + mkdir -p /root/.ssh + +COPY ssh_config /etc/ssh/ssh_config +VOLUME /run/privateer diff --git a/docker/Dockerfile.server b/docker/Dockerfile.server new file mode 100644 index 0000000..f043e80 --- /dev/null +++ b/docker/Dockerfile.server @@ -0,0 +1,19 @@ +FROM ubuntu + +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + openssh-client \ + openssh-server \ + rsync && \ + mkdir -p /var/run/sshd && \ + mkdir -p /root/.ssh + +RUN apt-get update && \ + apt-get install -y --no-install-recommends python3-hvac + +COPY sshd_config /etc/ssh/sshd_config + +VOLUME /run/privateer +EXPOSE 22 + +ENTRYPOINT ["/usr/sbin/sshd", "-D", "-E", "/dev/stderr"] diff --git a/docker/build b/docker/build new file mode 100755 index 0000000..9a10393 --- /dev/null +++ b/docker/build @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -exu + +HERE=$(dirname $0) +. $HERE/common + +docker build --pull \ + --tag $TAG_SERVER_SHA \ + -f Dockerfile.server \ + $HERE + +docker build --pull \ + --tag $TAG_CLIENT_SHA \ + -f Dockerfile.client \ + $HERE + +docker push $TAG_SERVER_SHA +docker push $TAG_CLIENT_SHA + +docker tag $TAG_SERVER_SHA $TAG_SERVER_BRANCH +docker push $TAG_SERVER_BRANCH +docker tag $TAG_CLIENT_SHA $TAG_CLIENT_BRANCH +docker push $TAG_CLIENT_BRANCH + +if [ $GIT_BRANCH == "main" ]; then + docker tag $TAG_SERVER_SHA $TAG_SERVER_LATEST + docker push $TAG_SERVER_LATEST + docker tag $TAG_CLIENT_SHA $TAG_CLIENT_LATEST + docker push $TAG_CLIENT_LATEST +fi diff --git a/docker/common b/docker/common new file mode 100644 index 0000000..4a622e3 --- /dev/null +++ b/docker/common @@ -0,0 +1,24 @@ +# -*-sh-*- +DOCKER_ROOT=$(realpath $HERE/..) +PACKAGE_ORG=mrcide +CLIENT_NAME=privateer-client +SERVER_NAME=privateer-server + +# Buildkite doesn't check out a full history from the remote (just the +# single commit) so you end up with a detached head and git rev-parse +# doesn't work +if [ false && "$BUILDKITE" = "true" ]; then + GIT_SHA=${BUILDKITE_COMMIT:0:7} + GIT_BRANCH=$BUILDKITE_BRANCH +else + GIT_SHA=$(git -C "$DOCKER_ROOT" rev-parse --short=7 HEAD) + GIT_BRANCH=$(git -C "$DOCKER_ROOT" symbolic-ref --short HEAD) +fi + +TAG_CLIENT_SHA="${PACKAGE_ORG}/${CLIENT_NAME}:${GIT_SHA}" +TAG_CLIENT_BRANCH="${PACKAGE_ORG}/${CLIENT_NAME}:${GIT_BRANCH}" +TAG_CLIENT_LATEST="${PACKAGE_ORG}/${CLIENT_NAME}:latest" + +TAG_SERVER_SHA="${PACKAGE_ORG}/${SERVER_NAME}:${GIT_SHA}" +TAG_SERVER_BRANCH="${PACKAGE_ORG}/${SERVER_NAME}:${GIT_BRANCH}" +TAG_SERVER_LATEST="${PACKAGE_ORG}/${SERVER_NAME}:latest" diff --git a/docker/ssh_config b/docker/ssh_config new file mode 100644 index 0000000..2b2df39 --- /dev/null +++ b/docker/ssh_config @@ -0,0 +1,5 @@ +PasswordAuthentication no +IdentityFile /run/privateer/id_rsa +SendEnv LANG LC_* +HashKnownHosts no +UserKnownHostsFile /run/privateer/known_hosts diff --git a/docker/sshd_config b/docker/sshd_config new file mode 100644 index 0000000..29c0730 --- /dev/null +++ b/docker/sshd_config @@ -0,0 +1,28 @@ +Include /etc/ssh/sshd_config.d/*.conf + +PermitRootLogin prohibit-password +#StrictModes yes +#MaxAuthTries 1 +#MaxSessions 10 + +PubkeyAuthentication yes + +AuthorizedKeysFile /run/privateer/authorized_keys +HostKey /run/privateer/id_rsa + +PasswordAuthentication no +ChallengeResponseAuthentication no +UsePAM no + +#AllowAgentForwarding yes +#AllowTcpForwarding yes +#GatewayPorts no +X11Forwarding no +# PermitTTY no +PrintMotd no + +# Allow client to pass locale environment variables +AcceptEnv LANG LC_* + +# override default of no subsystems +# Subsystem sftp /usr/lib/openssh/sftp-server From a09540568e10cdf92bd49cd6de4a7dfc4a7f396f Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Mon, 16 Oct 2023 10:27:43 +0100 Subject: [PATCH 20/87] Add volume marker --- docker/Dockerfile.server | 1 + 1 file changed, 1 insertion(+) diff --git a/docker/Dockerfile.server b/docker/Dockerfile.server index f043e80..2eeb670 100644 --- a/docker/Dockerfile.server +++ b/docker/Dockerfile.server @@ -14,6 +14,7 @@ RUN apt-get update && \ COPY sshd_config /etc/ssh/sshd_config VOLUME /run/privateer +VOLUME /privateer EXPOSE 22 ENTRYPOINT ["/usr/sbin/sshd", "-D", "-E", "/dev/stderr"] From 3d12d0d4fc48073fb7bebc6435d2fca3396ac2e4 Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Mon, 16 Oct 2023 19:02:47 +0100 Subject: [PATCH 21/87] Update ssh config --- docker/ssh_config | 1 + 1 file changed, 1 insertion(+) diff --git a/docker/ssh_config b/docker/ssh_config index 2b2df39..e68d479 100644 --- a/docker/ssh_config +++ b/docker/ssh_config @@ -3,3 +3,4 @@ IdentityFile /run/privateer/id_rsa SendEnv LANG LC_* HashKnownHosts no UserKnownHostsFile /run/privateer/known_hosts +Include /run/privateer/config From 28b2ccc3daaec2da773383f3a7a58b5f62e7cd2d Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Mon, 16 Oct 2023 19:23:47 +0100 Subject: [PATCH 22/87] Basic config validation --- src/privateer2/backup.py | 1 - src/privateer2/cli.py | 2 +- src/privateer2/config.py | 40 +++++++++++++++++++++++++++++++++++++-- src/privateer2/keys.py | 2 +- src/privateer2/restore.py | 1 - src/privateer2/server.py | 1 - src/privateer2/tar.py | 1 - tests/test_keys.py | 2 +- 8 files changed, 41 insertions(+), 9 deletions(-) diff --git a/src/privateer2/backup.py b/src/privateer2/backup.py index 6af6ac7..ee11b68 100644 --- a/src/privateer2/backup.py +++ b/src/privateer2/backup.py @@ -1,5 +1,4 @@ import docker - from privateer2.keys import check from privateer2.util import match_value, mounts_str, run_docker_command diff --git a/src/privateer2/cli.py b/src/privateer2/cli.py index 4385e0e..c29452b 100644 --- a/src/privateer2/cli.py +++ b/src/privateer2/cli.py @@ -24,9 +24,9 @@ fail, so this is fairly safe. """ -import docker import docopt +import docker import privateer2.__about__ as about from privateer2.backup import backup from privateer2.config import read_config diff --git a/src/privateer2/config.py b/src/privateer2/config.py index 28696ac..f3f94d7 100644 --- a/src/privateer2/config.py +++ b/src/privateer2/config.py @@ -12,8 +12,6 @@ def read_config(path): return Config(**json.loads(f.read().strip())) -# TODO: forbid name of 'local' for either server of client, if that is -# the name that we stick with. class Server(BaseModel): name: str hostname: str @@ -50,6 +48,9 @@ class Config(BaseModel): vault: Vault tag: str = "docker" + def model_post_init(self, __context): + check_config(self) + def list_servers(self): return [x.name for x in self.servers] @@ -66,3 +67,38 @@ def find_source(cfg, volume, source): return "local" pos = [cl.name for cl in cfg.clients if volume in cl.backup] return match_value(source, pos, "source") + + +def check_config(cfg): + servers = cfg.list_servers() + clients = cfg.list_clients() + _check_not_duplicated(servers, "servers") + _check_not_duplicated(clients, "clients") + err = set(cfg.list_servers()).intersection(set(cfg.list_clients())) + if err: + err_str = ", ".join(f"'{nm}'" for nm in err) + msg = f"Invalid machine listed as both a client and a server: {err_str}" + raise Exception(msg) + if "local" in cfg.list_servers() or "local" in cfg.list_clients(): + msg = "Machines cannot be called 'local'" + raise Exception(msg) + vols_local = [x.name for x in cfg.volumes if x.local] + vols_all = [x.name for x in cfg.volumes] + for cl in cfg.clients: + for v in cl.restore: + if v not in vols_all: + msg = f"Client '{cl.name}' restores from unknown volume '{v}'" + raise Exception(msg) + for v in cl.backup: + if v not in vols_all: + msg = f"Client '{cl.name}' backs up unknown volume '{v}'" + raise Exception(msg) + if v in vols_local: + msg = f"Client '{cl.name}' backs up local volume '{v}'" + raise Exception(msg) + + +def _check_not_duplicated(els, name): + if len(els) > len(set(els)): + msg = f"Duplicated elements in {name}" + raise Exception(msg) diff --git a/src/privateer2/keys.py b/src/privateer2/keys.py index de02775..01542e8 100644 --- a/src/privateer2/keys.py +++ b/src/privateer2/keys.py @@ -1,7 +1,7 @@ -import docker from cryptography.hazmat.primitives import serialization as crypto_serialization from cryptography.hazmat.primitives.asymmetric import rsa +import docker from privateer2.util import string_from_volume, string_to_volume diff --git a/src/privateer2/restore.py b/src/privateer2/restore.py index e8cfb0d..ee3a79e 100644 --- a/src/privateer2/restore.py +++ b/src/privateer2/restore.py @@ -1,5 +1,4 @@ import docker - from privateer2.config import find_source from privateer2.keys import check from privateer2.util import match_value, mounts_str, run_docker_command diff --git a/src/privateer2/server.py b/src/privateer2/server.py index df82529..c081f4c 100644 --- a/src/privateer2/server.py +++ b/src/privateer2/server.py @@ -1,5 +1,4 @@ import docker - from privateer2.keys import check from privateer2.util import container_exists, ensure_image, mounts_str diff --git a/src/privateer2/tar.py b/src/privateer2/tar.py index c6c6d9d..76023fb 100644 --- a/src/privateer2/tar.py +++ b/src/privateer2/tar.py @@ -1,7 +1,6 @@ import os import docker - from privateer2.config import find_source from privateer2.keys import check from privateer2.util import ( diff --git a/tests/test_keys.py b/tests/test_keys.py index 622f504..23b433c 100644 --- a/tests/test_keys.py +++ b/tests/test_keys.py @@ -1,7 +1,7 @@ -import docker import pytest import vault_dev +import docker from privateer2.config import read_config from privateer2.keys import _keys_data, check, configure, keygen from privateer2.util import rand_str, string_from_volume From bde6225c8e676bc4136472d322bfbbc5e2faef52 Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Mon, 16 Oct 2023 19:33:43 +0100 Subject: [PATCH 23/87] Fix tests --- pyproject.toml | 2 +- src/privateer2/keys.py | 1 + tests/test_keys.py | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index aa44c53..1cc9a4d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,7 +55,7 @@ test = "pytest {args:tests}" test-cov = "coverage run -m pytest {args:tests}" cov-report = [ "- coverage combine", - "coverage report", + "coverage report --show-missing", ] cov = [ "test-cov", diff --git a/src/privateer2/keys.py b/src/privateer2/keys.py index 01542e8..c1298c9 100644 --- a/src/privateer2/keys.py +++ b/src/privateer2/keys.py @@ -102,6 +102,7 @@ def _keys_data(cfg, name): **response["data"], "authorized_keys": None, "known_hosts": None, + "config": None, } if name in cfg.list_servers(): keys = _get_pubkeys(vault, cfg.vault.prefix, cfg.list_clients()) diff --git a/tests/test_keys.py b/tests/test_keys.py index 23b433c..bdccf7f 100644 --- a/tests/test_keys.py +++ b/tests/test_keys.py @@ -89,6 +89,7 @@ def test_can_unpack_keys_for_client(): "id_rsa", "id_rsa.pub", "name", + "config", } assert string_from_volume(vol, "name") == "bob" assert check(cfg, "bob").key_volume == vol From 777ddecb2f7057c32fc7e005b186fc87017f2fb3 Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Mon, 16 Oct 2023 19:35:43 +0100 Subject: [PATCH 24/87] Expand config examples --- example/local.json | 6 +++++- example/montagu.json | 47 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 example/montagu.json diff --git a/example/local.json b/example/local.json index e29cbac..4f2b57b 100644 --- a/example/local.json +++ b/example/local.json @@ -12,13 +12,17 @@ { "name": "bob", "backup": ["data"], - "restore": ["data"], + "restore": ["data", "other"], "key_volume": "privateer_keys_bob" } ], "volumes": [ { "name": "data" + }, + { + "name": "other", + "local": true } ], "vault": { diff --git a/example/montagu.json b/example/montagu.json new file mode 100644 index 0000000..874d266 --- /dev/null +++ b/example/montagu.json @@ -0,0 +1,47 @@ +{ + "servers": [ + { + "name": "annex", + "hostname": "annex.montagu.dide.ic.ac.uk", + "port": 10022 + }, + { + "name": "annex2", + "hostname": "annex2.montagu.dide.ic.ac.uk", + "port": 10022 + } + ], + "clients": [ + { + "name": "production", + "backup": ["montagu_orderly_volume"], + "restore": ["montagu_orderly_volume", "barman_recover"] + }, + { + "name": "production2", + "backup": ["montagu_orderly_volume"], + "restore": ["montagu_orderly_volume", "barman_recover"] + }, + { + "name": "science", + "restore": ["montagu_orderly_volume", "barman_recover"] + }, + { + "name": "uat", + "restore": ["montagu_orderly_volume", "barman_recover"] + } + ], + "volumes": [ + { + "name": "montagu_orderly_volume" + }, + { + "name": "barman_recover", + "local": true + } + ], + "vault": { + "url": "http://vault.dide.ic.ac.uk:8200", + "prefix": "/secret/vimc/privateer" + } +} From e25da6dc0f54bbbeb524ab2011ed3cac174ab2ff Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Mon, 16 Oct 2023 19:35:53 +0100 Subject: [PATCH 25/87] Add dev notes --- development.md | 63 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 development.md diff --git a/development.md b/development.md new file mode 100644 index 0000000..ec1c330 --- /dev/null +++ b/development.md @@ -0,0 +1,63 @@ +# Development notes + +Because this uses docker, vault and requires work with hostnames, this is going to be hard to test properly without a lot of mocking. We'll update this as our strategy improves. + +## Vault server for testing + +We use [`vault-dev`](https://github.com/vimc/vault-dev) to bring up vault in testing mode. You can also do this manually (e.g., to match the configuration in [`example/simple.json`](example/simple.json) by running + +``` +vault server -dev -dev-kv-v1 +``` + +If you need to interact with this on the command line, use: + +``` +export VAULT_ADDR='http://127.0.0.1:8200' +``` + +You may need to export your root token + +``` +export VAULT_TOKEN=hvs.cPdO7xlwqNugg8xTF7KrxJyj +``` + +within the hatch environment + + +``` +privateer2 -f example/simple.json keygen alice +privateer2 -f example/simple.json keygen bob +privateer2 -f example/simple.json configure alice +``` + +## Worked example + +``` +privateer2 -f example/local.json keygen alice +privateer2 -f example/local.json keygen bob +privateer2 -f example/local.json configure alice +privateer2 -f example/local.json configure bob +privateer2 -f example/local.json serve alice --dry-run +``` + +Create some random data + +``` +docker volume create data +docker run -it --rm -v data:/data ubuntu bash -c "base64 /dev/urandom | head -c 10000000 > /data/file.txt" +``` + +``` +docker run -it --rm -v privateer_keys_bob:/run/privateer:ro -v data:/privateer/data:ro -w /privateer mrcide/privateer-client:docker bash + +rsync -av -e 'ssh -p 10022 -i /run/privateer/id_rsa' --delete data/ root@wpia-dide136:/privateer/data/bob/ +``` + +privateer2 -f example/local.json backup bob --dry-run +privateer2 -f example/local.json backup bob + +rsync -av --delete data/ root@wpia-dide136:/privateer/data/bob/ + + +rsync -av --delete alice:/privateer/bob/data /privateer From 3357c88c158d8e605057fbbebac5a0959d497762 Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Tue, 17 Oct 2023 08:11:19 +0100 Subject: [PATCH 26/87] Drop extra spaces in cli usage --- src/privateer2/cli.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/privateer2/cli.py b/src/privateer2/cli.py index c29452b..e489079 100644 --- a/src/privateer2/cli.py +++ b/src/privateer2/cli.py @@ -4,10 +4,10 @@ privateer2 [options] keygen privateer2 [options] configure privateer2 [options] check - privateer2 [options] serve - privateer2 [options] backup - privateer2 [options] restore [--server=NAME] [--source=NAME] - privateer2 [options] export [--to=PATH] [--source=NAME] + privateer2 [options] serve + privateer2 [options] backup + privateer2 [options] restore [--server=NAME] [--source=NAME] + privateer2 [options] export [--to=PATH] [--source=NAME] privateer2 [--dry-run] import Options: From 156cce6b6a858f31d20824eb4a25cc1c5c526b11 Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Tue, 17 Oct 2023 08:29:47 +0100 Subject: [PATCH 27/87] Generate all keys at once --- src/privateer2/cli.py | 9 ++++++--- src/privateer2/keys.py | 23 ++++++++++++++++------- tests/test_keys.py | 14 +++++--------- 3 files changed, 27 insertions(+), 19 deletions(-) diff --git a/src/privateer2/cli.py b/src/privateer2/cli.py index e489079..43bc690 100644 --- a/src/privateer2/cli.py +++ b/src/privateer2/cli.py @@ -1,7 +1,7 @@ """Usage: privateer2 --version privateer2 [options] pull - privateer2 [options] keygen + privateer2 [options] keygen ( | --all) privateer2 [options] configure privateer2 [options] check privateer2 [options] serve @@ -30,7 +30,7 @@ import privateer2.__about__ as about from privateer2.backup import backup from privateer2.config import read_config -from privateer2.keys import check, configure, keygen +from privateer2.keys import check, configure, keygen, keygen_all from privateer2.restore import restore from privateer2.server import serve from privateer2.tar import export_tar, import_tar @@ -59,7 +59,10 @@ def main(argv=None): path_config = opts["-f"] cfg = read_config(path_config) if opts["keygen"]: - keygen(cfg, opts[""]) + if opts["--all"]: + keygen_all(cfg) + else: + keygen(cfg, opts[""]) elif opts["configure"]: configure(cfg, opts[""]) elif opts["check"]: diff --git a/src/privateer2/keys.py b/src/privateer2/keys.py index c1298c9..0efd8e2 100644 --- a/src/privateer2/keys.py +++ b/src/privateer2/keys.py @@ -6,14 +6,23 @@ def keygen(cfg, name): + _keygen(cfg, name, cfg.vault.client()) + + +def keygen_all(cfg): vault = cfg.vault.client() - data = _create_keypair() - path = f"{cfg.vault.prefix}/{name}" - # TODO: The docs are here: - # https://hvac.readthedocs.io/en/stable/usage/secrets_engines/kv_v1.html - # They do not indicate if this will error if the write fails though. - print(f"Writing keypair for {name}") - _r = vault.secrets.kv.v1.create_or_update_secret(path, secret=data) + for name in cfg.list_servers() + cfg.list_clients(): + _keygen(cfg, name, vault) + + +def _keygen(cfg, name, vault): + data = _create_keypair() + path = f"{cfg.vault.prefix}/{name}" + # TODO: The docs are here: + # https://hvac.readthedocs.io/en/stable/usage/secrets_engines/kv_v1.html + # They do not indicate if this will error if the write fails though. + print(f"Writing keypair for {name}") + _r = vault.secrets.kv.v1.create_or_update_secret(path, secret=data) def configure(cfg, name): diff --git a/tests/test_keys.py b/tests/test_keys.py index bdccf7f..e191819 100644 --- a/tests/test_keys.py +++ b/tests/test_keys.py @@ -3,7 +3,7 @@ import docker from privateer2.config import read_config -from privateer2.keys import _keys_data, check, configure, keygen +from privateer2.keys import _keys_data, check, configure, keygen, keygen_all from privateer2.util import rand_str, string_from_volume @@ -24,8 +24,7 @@ def test_can_generate_server_keys_data(): with vault_dev.Server(export_token=True) as server: cfg = read_config("example/simple.json") cfg.vault.url = server.url() - keygen(cfg, "alice") - keygen(cfg, "bob") + keygen_all(cfg) dat = _keys_data(cfg, "alice") assert dat["name"] == "alice" assert dat["known_hosts"] is None @@ -36,8 +35,7 @@ def test_can_generate_client_keys_data(): with vault_dev.Server(export_token=True) as server: cfg = read_config("example/simple.json") cfg.vault.url = server.url() - keygen(cfg, "alice") - keygen(cfg, "bob") + keygen_all(cfg) dat = _keys_data(cfg, "bob") assert dat["name"] == "bob" assert dat["authorized_keys"] is None @@ -52,8 +50,7 @@ def test_can_unpack_keys_for_server(): cfg.vault.url = server.url() vol = f"privateer_keys_{rand_str()}" cfg.servers[0].key_volume = vol - keygen(cfg, "alice") - keygen(cfg, "bob") + keygen_all(cfg) configure(cfg, "alice") client = docker.from_env() mounts = [docker.types.Mount("/keys", vol, type="volume")] @@ -76,8 +73,7 @@ def test_can_unpack_keys_for_client(): cfg.vault.url = server.url() vol = f"privateer_keys_{rand_str()}" cfg.clients[0].key_volume = vol - keygen(cfg, "alice") - keygen(cfg, "bob") + keygen_all(cfg) configure(cfg, "bob") client = docker.from_env() mounts = [docker.types.Mount("/keys", vol, type="volume")] From 21d533c33937fb85f90648051d7deb544d89157f Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Tue, 17 Oct 2023 09:09:18 +0100 Subject: [PATCH 28/87] Use correct vault --- example/montagu.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example/montagu.json b/example/montagu.json index 874d266..9af7402 100644 --- a/example/montagu.json +++ b/example/montagu.json @@ -41,7 +41,7 @@ } ], "vault": { - "url": "http://vault.dide.ic.ac.uk:8200", + "url": "https://vault.dide.ic.ac.uk:8200", "prefix": "/secret/vimc/privateer" } } From c910290f9e0ba8f4792b79b2c4f66dc1a6fc5277 Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Tue, 17 Oct 2023 09:09:28 +0100 Subject: [PATCH 29/87] Compatibility tweaks --- pyproject.toml | 2 +- src/privateer2/__about__.py | 2 +- src/privateer2/config.py | 4 ++-- src/privateer2/vault.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1cc9a4d..5b7349e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ classifiers = [ "Programming Language :: Python :: Implementation :: PyPy", ] dependencies = [ - "cryptography", + "cryptography>=3.1", "docker", "docopt", "hvac", diff --git a/src/privateer2/__about__.py b/src/privateer2/__about__.py index 8813381..df46152 100644 --- a/src/privateer2/__about__.py +++ b/src/privateer2/__about__.py @@ -1,4 +1,4 @@ # SPDX-FileCopyrightText: 2023-present Rich FitzJohn # # SPDX-License-Identifier: MIT -__version__ = "0.0.1" +__version__ = "0.0.2" diff --git a/src/privateer2/config.py b/src/privateer2/config.py index f3f94d7..f673874 100644 --- a/src/privateer2/config.py +++ b/src/privateer2/config.py @@ -23,8 +23,8 @@ class Server(BaseModel): class Client(BaseModel): name: str - backup: List[str] - restore: List[str] + backup: List[str] = [] + restore: List[str] = [] key_volume: str = "privateer_keys" diff --git a/src/privateer2/vault.py b/src/privateer2/vault.py index d2d7a4f..acd8181 100644 --- a/src/privateer2/vault.py +++ b/src/privateer2/vault.py @@ -11,7 +11,7 @@ def vault_client(addr, token=None): if re_gh.match(token): print("logging into vault using github") client = hvac.Client(addr) - client.github.login(token) + client.auth.github.login(token) else: client = hvac.Client(addr, token=token) return client From bd3a78ec25cadca5229a0f820e437df2ebe41d04 Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Tue, 17 Oct 2023 10:25:37 +0100 Subject: [PATCH 30/87] Start moving to having an identity file --- README.md | 45 ++++++++++++++++-- src/privateer2/cli.py | 108 +++++++++++++++++++++++++++--------------- 2 files changed, 111 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 27c934d..d1f382d 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,49 @@ ----- -**Table of Contents** +## The idea -- [Installation](#installation) -- [License](#license) +We need a way of syncronising some docker volumes from a machine to some backup server, incrementally, using `rsync`. We previously used [`offen/docker-volume-backup`](https://github.com/offen/docker-volume-backup) to backup volumes in their entirity to another machine as a tar file but the space and time requirements made this hard to use in practice. + +### The setup + +We assume some number of **server** machines -- these will recieve data, and some number of **client** machines -- these will send data to the server(s). A client can back any number of volumes to any number of servers, and a server can recieve and serve any unmber of volumes to any number of clients. + +A typical topolgy for us would be that we would have a "production" machine which is backing up to one or more servers, and then some additional set of "staging" machines that recieve data from the servers, but which in practice never send any data. + +Because we are going to use ssh for transport, we assume existance of [HashiCorp Vault](https://www.vaultproject.io/) to store secrets. + +### Configuration + +The system is configured via a single `json` document, `privateer.json` which contains information about all the moving parts: servers, clients, volumes and the vault configuration. See [`example/`](example/) for some examples. + +We imagine that your configuration will exist in some repo, and that that repo will be checked out on all involved machines. Please add `.privateer_identity` to your `.gitignore` for this repo. + +### Setup + +After writing a configuration, on any machine run + +``` +privateer2 keygen --all +``` + +which will generate ssh keypairs for all machines and put them in the vault. +Then, on each machine run + + +``` +privateer2 configure +``` + +replacing `` with the name of the machine within either the `servers` or `clients` section of your configuration. This sets up a special docker volume that will persist ssh keys and configurations so that communication between clients and servers is straightforward and secure. + +You can run + +``` +privateer2 status +``` + +which prints information about the current setup. ## Installation diff --git a/src/privateer2/cli.py b/src/privateer2/cli.py index 43bc690..8bcc055 100644 --- a/src/privateer2/cli.py +++ b/src/privateer2/cli.py @@ -3,27 +3,30 @@ privateer2 [options] pull privateer2 [options] keygen ( | --all) privateer2 [options] configure - privateer2 [options] check - privateer2 [options] serve - privateer2 [options] backup - privateer2 [options] restore [--server=NAME] [--source=NAME] - privateer2 [options] export [--to=PATH] [--source=NAME] + privateer2 [options] status + privateer2 [options] check + privateer2 [options] serve + privateer2 [options] backup + privateer2 [options] restore [--server=NAME] [--source=NAME] + privateer2 [options] export [--to=PATH] [--source=NAME] privateer2 [--dry-run] import Options: - -f=PATH The path to the privateer configuration [default: privateer.json]. - --dry-run Do nothing, but print docker commands + --path=PATH The path to the configuration [default: privateer.json]. + --as=NAME The machine to run the command as + --dry-run Do nothing, but print docker commands Commentary: - In all the above '' refers to the name of the client or server - being acted on; the machine we are generating keys for, configuring, - checking, serving, backing up from or restoring to. + In all the above '--as' (or ) refers to the name of the client + or server being acted on; the machine we are generating keys for, + configuring, checking, serving, backing up from or restoring to. Note that the 'import' subcommand is quite different and does not interact with the configuration. If 'volume' exists already, it will fail, so this is fairly safe. """ +import os import docopt import docker @@ -47,51 +50,78 @@ def pull(cfg): cl.images.pull(nm) +def _dont_use(name, opts, cmd): + if opts[name]: + msg = f"Don't use '{name}' with '{cmd}'" + raise Exception(msg) + + +def _find_identity(name, root_config): + if name: + return name + path_as = os.path.join(root_config, ".privateer_identity") + if not os.path.exists(path_as): + msg = ( + "Can't determine identity; did you forget to configure?" + "Alternatively, pass '--as=NAME' to this command" + ) + raise Exception(msg) + with open(path_as) as f: + return path_as.read().strip() + + def main(argv=None): opts = docopt.docopt(__doc__, argv) if opts["--version"]: return about.__version__ dry_run = opts["--dry-run"] + name = opts["--as"] if opts["import"]: + _dont_use("--as", opts, "import") + _dont_use("--path", opts, "import") return import_tar(opts[""], opts[""], dry_run=dry_run) - path_config = opts["-f"] + path_config = opts["--path"] + root_config = os.path.dirname(path_config) if path_config else os.getcwd() cfg = read_config(path_config) if opts["keygen"]: + _dont_use("--as", opts, "keygen") if opts["--all"]: keygen_all(cfg) else: keygen(cfg, opts[""]) elif opts["configure"]: + _dont_use("--as", opts, "configure") configure(cfg, opts[""]) - elif opts["check"]: - check(cfg, opts[""]) - elif opts["serve"]: - serve(cfg, opts[""], dry_run=dry_run) - elif opts["backup"]: - backup(cfg, opts[""], opts[""], dry_run=dry_run) - elif opts["restore"]: - restore( - cfg, - opts[""], - opts[""], - server=opts["--server"], - source=opts["--source"], - dry_run=dry_run, - ) - elif opts["export"]: - export_tar( - cfg, - opts[""], - opts[""], - to=opts["--to"], - source=opts["--source"], - dry_run=dry_run, - ) - elif opts["import"]: - import_tar( - opts[""], opts[""], opts[""], dry_run=dry_run - ) + with open(os.path.join(root_config, ".privateer_identity"), "w") as f: + f.write(f"{name}\n") elif opts["pull"]: + _dont_use("--as", opts, "configure") pull(cfg) + else: + name = _find_identity(opts["--as"], root_config) + if opts["check"]: + check(cfg, name) + elif opts["serve"]: + serve(cfg, name, dry_run=dry_run) + elif opts["backup"]: + backup(cfg, name, opts[""], dry_run=dry_run) + elif opts["restore"]: + restore( + cfg, + name, + opts[""], + server=opts["--server"], + source=opts["--source"], + dry_run=dry_run, + ) + elif opts["export"]: + export_tar( + cfg, + name, + opts[""], + to=opts["--to"], + source=opts["--source"], + dry_run=dry_run, + ) From 835b277a649867a5c0f6d29eec9a9d953f5958c5 Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Tue, 17 Oct 2023 10:34:07 +0100 Subject: [PATCH 31/87] Test for unconfigured --- src/privateer2/keys.py | 2 +- tests/test_keys.py | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/privateer2/keys.py b/src/privateer2/keys.py index 0efd8e2..79f16b9 100644 --- a/src/privateer2/keys.py +++ b/src/privateer2/keys.py @@ -63,7 +63,7 @@ def check(cfg, name, *, quiet=False): vol = machine.key_volume try: docker.from_env().volumes.get(vol) - except docker.errors.VolumeNotFound: + except docker.errors.NotFound: msg = f"'{name}' looks unconfigured" raise Exception(msg) from None found = string_from_volume(vol, "name") diff --git a/tests/test_keys.py b/tests/test_keys.py index e191819..b3812e9 100644 --- a/tests/test_keys.py +++ b/tests/test_keys.py @@ -94,3 +94,13 @@ def test_can_unpack_keys_for_client(): with pytest.raises(Exception, match=msg): check(cfg, "alice") client.volumes.get(vol).remove() + + +def test_error_on_check_if_unconfigured(): + with vault_dev.Server(export_token=True) as server: + cfg = read_config("example/simple.json") + cfg.vault.url = server.url() + vol = f"privateer_keys_{rand_str()}" + cfg.servers[0].key_volume = vol + with pytest.raises(Exception, match="'alice' looks unconfigured"): + check(cfg, "alice") From 3fc461e59ffc8efef132e57b3062e03551ae91c5 Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Tue, 17 Oct 2023 10:41:25 +0100 Subject: [PATCH 32/87] Better error with invalid name given --- src/privateer2/keys.py | 10 ++++------ tests/test_keys.py | 9 +++++++++ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/privateer2/keys.py b/src/privateer2/keys.py index 79f16b9..b553435 100644 --- a/src/privateer2/keys.py +++ b/src/privateer2/keys.py @@ -28,7 +28,7 @@ def _keygen(cfg, name, vault): def configure(cfg, name): cl = docker.from_env() data = _keys_data(cfg, name) - vol = _key_volume_name(cfg, name) + vol = _machine_config(cfg, name).key_volume cl.volumes.create(vol) print(f"Copying keypair for '{name}' to volume '{vol}'") string_to_volume( @@ -131,13 +131,11 @@ def _keys_data(cfg, name): return ret -def _key_volume_name(cfg, name): - return _machine_config(cfg, name).key_volume - - def _machine_config(cfg, name): for el in cfg.servers + cfg.clients: if el.name == name: return el - msg = "Invalid configuration, can't determine volume name" + valid = cfg.list_servers() + cfg.list_clients() + valid_str = ", ".join(f"'{x}'" for x in valid) + msg = f"Invalid configuration '{name}', must be one of {valid_str}" raise Exception(msg) diff --git a/tests/test_keys.py b/tests/test_keys.py index b3812e9..aacc300 100644 --- a/tests/test_keys.py +++ b/tests/test_keys.py @@ -104,3 +104,12 @@ def test_error_on_check_if_unconfigured(): cfg.servers[0].key_volume = vol with pytest.raises(Exception, match="'alice' looks unconfigured"): check(cfg, "alice") + + +def test_error_on_check_if_unknown_machine(): + with vault_dev.Server(export_token=True) as server: + cfg = read_config("example/simple.json") + cfg.vault.url = server.url() + msg = "Invalid configuration 'eve', must be one of 'alice', 'bob'" + with pytest.raises(Exception, match=msg): + check(cfg, "eve") From 6d4cbfff09f755e37d4342d07bc9f06e740f01cd Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Tue, 17 Oct 2023 12:04:22 +0100 Subject: [PATCH 33/87] Expand testing --- src/privateer2/config.py | 8 +-- src/privateer2/util.py | 3 +- src/privateer2/vault.py | 9 ++-- tests/test_config.py | 111 ++++++++++++++++++++++++++++++++++++++- tests/test_keys.py | 16 ++++++ 5 files changed, 139 insertions(+), 8 deletions(-) diff --git a/src/privateer2/config.py b/src/privateer2/config.py index f673874..8190c8d 100644 --- a/src/privateer2/config.py +++ b/src/privateer2/config.py @@ -49,7 +49,7 @@ class Config(BaseModel): tag: str = "docker" def model_post_init(self, __context): - check_config(self) + _check_config(self) def list_servers(self): return [x.name for x in self.servers] @@ -58,18 +58,20 @@ def list_clients(self): return [x.name for x in self.clients] +# this could be put elsewhere; we find the plausible sources (original +# clients) that backed up a source to any server. def find_source(cfg, volume, source): for v in cfg.volumes: if v.name == volume and v.local: if source is not None: - msg = f"{volume} is a local source, so 'source' must be empty" + msg = f"'{volume}' is a local source, so 'source' must be empty" raise Exception(msg) return "local" pos = [cl.name for cl in cfg.clients if volume in cl.backup] return match_value(source, pos, "source") -def check_config(cfg): +def _check_config(cfg): servers = cfg.list_servers() clients = cfg.list_clients() _check_not_duplicated(servers, "servers") diff --git a/src/privateer2/util.py b/src/privateer2/util.py index bd6beac..76ebbd9 100644 --- a/src/privateer2/util.py +++ b/src/privateer2/util.py @@ -95,7 +95,8 @@ def transient_envvar(**kwargs): def _setdictvals(new, container): for k, v in new.items(): if v is None: - del container[k] + if k in container: + del container[k] else: container[k] = v return container diff --git a/src/privateer2/vault.py b/src/privateer2/vault.py index acd8181..9313fc1 100644 --- a/src/privateer2/vault.py +++ b/src/privateer2/vault.py @@ -5,10 +5,8 @@ def vault_client(addr, token=None): - re_gh = re.compile("^ghp_[A-Za-z0-9]{36}$") - re.compile("^(hv)?s\\.{80}$") token = _get_vault_token(token) - if re_gh.match(token): + if _is_github_token(token): print("logging into vault using github") client = hvac.Client(addr) client.auth.github.login(token) @@ -28,3 +26,8 @@ def _get_vault_token(token): if token_type in os.environ: return os.environ[token_type] return input("Enter token for vault: ").strip() + + +def _is_github_token(token): + re_gh = re.compile("^ghp_[A-Za-z0-9]{36}$") + return re_gh.match(token) diff --git a/tests/test_config.py b/tests/test_config.py index 7efe48d..063c93f 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,6 +1,7 @@ +import pytest import vault_dev -from privateer2.config import read_config +from privateer2.config import find_source, read_config, _check_config def test_can_read_config(): @@ -27,3 +28,111 @@ def test_can_create_vault_client(): cfg.vault.url = server.url() client = cfg.vault.client() assert client.is_authenticated() + + +# These are annoying to setup, the rest just run the validation manually: +def test_validation_is_run_on_load(tmp_path): + path = tmp_path / "privateer.json" + with path.open("w") as f: + f.write("""{ + "servers": [ + { + "name": "alice", + "hostname": "alice.example.com", + "port": 10022 + } + ], + "clients": [ + { + "name": "alice", + "backup": ["data"], + "restore": ["data", "other"] + } + ], + "volumes": [ + { + "name": "data" + } + ], + "vault": { + "url": "http://localhost:8200", + "prefix": "/secret/privateer" + } +}""") + msg = "Invalid machine listed as both a client and a server: 'alice'" + with pytest.raises(Exception, match=msg): + read_config(path) + + +def test_machines_cannot_be_duplicated(): + cfg = read_config("example/simple.json") + cfg.clients = cfg.clients + cfg.clients + with pytest.raises(Exception, match="Duplicated elements in clients"): + _check_config(cfg) + cfg.servers = cfg.servers + cfg.servers + with pytest.raises(Exception, match="Duplicated elements in servers"): + _check_config(cfg) + + +def test_machines_cannot_be_client_and_server(): + cfg = read_config("example/simple.json") + tmp = cfg.clients[0].model_copy() + tmp.name = "alice" + cfg.clients.append(tmp) + msg = "Invalid machine listed as both a client and a server: 'alice'" + with pytest.raises(Exception, match=msg): + _check_config(cfg) + + +def test_machines_cannot_be_called_local(): + cfg = read_config("example/simple.json") + cfg.clients[0].name = "local" + with pytest.raises(Exception, match="Machines cannot be called 'local'"): + _check_config(cfg) + + +def test_restore_volumes_are_known(): + cfg = read_config("example/simple.json") + cfg.clients[0].restore.append("other") + msg = "Client 'bob' restores from unknown volume 'other'" + with pytest.raises(Exception, match=msg): + _check_config(cfg) + + +def test_backup_volumes_are_known(): + cfg = read_config("example/simple.json") + cfg.clients[0].backup.append("other") + msg = "Client 'bob' backs up unknown volume 'other'" + with pytest.raises(Exception, match=msg): + _check_config(cfg) + + +def test_local_volumes_cannot_be_backed_up(): + cfg = read_config("example/simple.json") + cfg.volumes[0].local = True + msg = "Client 'bob' backs up local volume 'data'" + with pytest.raises(Exception, match=msg): + _check_config(cfg) + + +def test_can_find_appropriate_source(): + cfg = read_config("example/simple.json") + tmp = cfg.clients[0].model_copy() + tmp.name = "carol" + cfg.clients.append(tmp) + assert find_source(cfg, "data", "bob") == "bob" + assert find_source(cfg, "data", "carol") == "carol" + msg = "Invalid source 'alice': valid options: 'bob', 'carol'" + with pytest.raises(Exception, match=msg): + find_source(cfg, "data", "alice") + + +def test_can_find_appropriate_source_if_local(): + cfg = read_config("example/simple.json") + cfg.volumes[0].local = True + find_source(cfg, "data", None) + msg = "'data' is a local source, so 'source' must be empty" + with pytest.raises(Exception, match=msg): + find_source(cfg, "data", "bob") + with pytest.raises(Exception, match=msg): + find_source(cfg, "data", "local") diff --git a/tests/test_keys.py b/tests/test_keys.py index aacc300..bd7966d 100644 --- a/tests/test_keys.py +++ b/tests/test_keys.py @@ -96,6 +96,22 @@ def test_can_unpack_keys_for_client(): client.volumes.get(vol).remove() +def test_can_check_quietly(capsys): + with vault_dev.Server(export_token=True) as server: + cfg = read_config("example/simple.json") + cfg.vault.url = server.url() + vol = f"privateer_keys_{rand_str()}" + cfg.servers[0].key_volume = vol + keygen_all(cfg) + configure(cfg, "alice") + capsys.readouterr() # flush capture so far + assert check(cfg, "alice", quiet=True).key_volume == vol + assert capsys.readouterr().out == "" + assert check(cfg, "alice", quiet=False).key_volume == vol + out_loud = capsys.readouterr() + assert out_loud.out == f"Volume '{vol}' looks configured as 'alice'\n" + + def test_error_on_check_if_unconfigured(): with vault_dev.Server(export_token=True) as server: cfg = read_config("example/simple.json") From 9889dded7328d46c01d036f185c2edbf89b188fb Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Tue, 17 Oct 2023 14:27:41 +0100 Subject: [PATCH 34/87] Expand testing --- src/privateer2/cli.py | 105 ++++++++++++++------ src/privateer2/tar.py | 4 +- src/privateer2/util.py | 12 +++ tests/test_cli.py | 217 +++++++++++++++++++++++++++++++++++++++++ tests/test_vault.py | 24 +++++ 5 files changed, 331 insertions(+), 31 deletions(-) create mode 100644 tests/test_cli.py create mode 100644 tests/test_vault.py diff --git a/src/privateer2/cli.py b/src/privateer2/cli.py index 8bcc055..052141e 100644 --- a/src/privateer2/cli.py +++ b/src/privateer2/cli.py @@ -8,11 +8,11 @@ privateer2 [options] serve privateer2 [options] backup privateer2 [options] restore [--server=NAME] [--source=NAME] - privateer2 [options] export [--to=PATH] [--source=NAME] - privateer2 [--dry-run] import + privateer2 [options] export [--to-dir=PATH] [--source=NAME] + privateer2 [options] import Options: - --path=PATH The path to the configuration [default: privateer.json]. + --path=PATH The path to the configuration (rather than privateer.json) --as=NAME The machine to run the command as --dry-run Do nothing, but print docker commands @@ -22,8 +22,9 @@ configuring, checking, serving, backing up from or restoring to. Note that the 'import' subcommand is quite different and does not - interact with the configuration. If 'volume' exists already, it will - fail, so this is fairly safe. + interact with the configuration; it will reject options '--as' and + '--path'. If 'volume' exists already, it will fail, so this is + fairly safe. """ import os @@ -67,61 +68,107 @@ def _find_identity(name, root_config): ) raise Exception(msg) with open(path_as) as f: - return path_as.read().strip() + return f.read().strip() -def main(argv=None): +def _do_configure(cfg, name, root): + configure(cfg, name) + with open(os.path.join(root, ".privateer_identity"), "w") as f: + f.write(f"{name}\n") + + +def _show_version(): + print(f"privateer {about.__version__}") + + +class Call: + def __init__(self, target, **kwargs): + self.target = target + self.kwargs = kwargs + + def run(self): + return self.target(**self.kwargs) + + def __eq__(self, other): + return self.target == other.target and self.kwargs == other.kwargs + + +def _parse_argv(argv): opts = docopt.docopt(__doc__, argv) + return _parse_opts(opts) + + +def _parse_opts(opts): if opts["--version"]: - return about.__version__ + return Call(_show_version) dry_run = opts["--dry-run"] name = opts["--as"] if opts["import"]: _dont_use("--as", opts, "import") _dont_use("--path", opts, "import") - return import_tar(opts[""], opts[""], dry_run=dry_run) + return Call(import_tar, volume=opts[""], + tarfile=opts[""], + dry_run=dry_run) - path_config = opts["--path"] - root_config = os.path.dirname(path_config) if path_config else os.getcwd() + path_config = opts["--path"] or "privateer.json" + root_config = os.path.dirname(path_config) cfg = read_config(path_config) if opts["keygen"]: _dont_use("--as", opts, "keygen") if opts["--all"]: - keygen_all(cfg) + return Call(keygen_all, cfg=cfg) else: - keygen(cfg, opts[""]) + return Call(keygen, cfg=cfg, name=opts[""]) elif opts["configure"]: _dont_use("--as", opts, "configure") - configure(cfg, opts[""]) - with open(os.path.join(root_config, ".privateer_identity"), "w") as f: - f.write(f"{name}\n") + return Call( + _do_configure, + cfg=cfg, + name=opts[""], + root=root_config, + ) elif opts["pull"]: _dont_use("--as", opts, "configure") - pull(cfg) + return Call(pull, cfg=cfg) else: name = _find_identity(opts["--as"], root_config) if opts["check"]: - check(cfg, name) + return Call(check, cfg=cfg, name=name) elif opts["serve"]: - serve(cfg, name, dry_run=dry_run) + return Call(serve, cfg=cfg, name=name, dry_run=dry_run) elif opts["backup"]: - backup(cfg, name, opts[""], dry_run=dry_run) + return Call( + backup, + cfg=cfg, + name=name, + volume=opts[""], + dry_run=dry_run, + ) elif opts["restore"]: - restore( - cfg, - name, - opts[""], + return Call( + restore, + cfg=cfg, + name=name, + volume=opts[""], server=opts["--server"], source=opts["--source"], dry_run=dry_run, ) elif opts["export"]: - export_tar( - cfg, - name, - opts[""], - to=opts["--to"], + return Call( + export_tar, + cfg=cfg, + name=name, + volume=opts[""], + to_dir=opts["--to-dir"], source=opts["--source"], dry_run=dry_run, ) + else: + msg = "Invalid cli call -- privateer bug" + raise Exception(msg) + + +def main(argv=None): + _parse_argv(argv).run() diff --git a/src/privateer2/tar.py b/src/privateer2/tar.py index 76023fb..6887613 100644 --- a/src/privateer2/tar.py +++ b/src/privateer2/tar.py @@ -12,7 +12,7 @@ ) -def export_tar(cfg, name, volume, *, to=None, source=None, dry_run=False): +def export_tar(cfg, name, volume, *, to_dir=None, source=None, dry_run=False): machine = check(cfg, name, quiet=True) # TODO: check here that volume is either local, or that it is a # backup target for anything. @@ -21,7 +21,7 @@ def export_tar(cfg, name, volume, *, to=None, source=None, dry_run=False): if to is None: export_path = os.getcwd() else: - export_path = os.path.abspath(to) + export_path = os.path.abspath(to_dir) mounts = [ docker.types.Mount("/export", export_path, type="bind"), docker.types.Mount( diff --git a/src/privateer2/util.py b/src/privateer2/util.py index 76ebbd9..d2b7c81 100644 --- a/src/privateer2/util.py +++ b/src/privateer2/util.py @@ -233,3 +233,15 @@ def run_docker_command(name, image, **kwargs): log_tail(container, 20) msg = f"{name} failed; see {container.name} logs for details" raise Exception(msg) + + +@contextmanager +def transient_working_directory(path): + origin = os.getcwd() + try: + if path is not None: + os.chdir(path) + yield + finally: + if path is not None: + os.chdir(origin) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..3e8febd --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,217 @@ +from unittest.mock import MagicMock + +import pytest +import shutil + +import privateer2.cli +from privateer2.cli import Call, _parse_argv, _parse_opts, _do_configure, _find_identity, _show_version +from privateer2.config import read_config +from privateer2.util import transient_working_directory + + +def test_can_create_and_run_call(): + def f(a, b=1): + return [a, b] + call = Call(f, a=1, b=2) + assert call.run() == [1, 2] + + +def test_can_parse_version(): + res = _parse_argv(["--version"]) + assert res.target == privateer2.cli._show_version + assert res.kwargs == {} + + +def test_can_parse_import(): + res = _parse_argv(["import", "--dry-run", "f", "v"]) + assert res.target == privateer2.cli.import_tar + assert res.kwargs == {"volume": "v", "tarfile": "f", "dry_run": True} + assert not _parse_argv(["import", "f", "v"]).kwargs["dry_run"] + with pytest.raises(Exception, match="Don't use '--path' with 'import'"): + _parse_argv(["--path=privateer.json", "import", "f", "v"]) + with pytest.raises(Exception, match="Don't use '--as' with 'import'"): + _parse_argv(["--as=alice", "import", "f", "v"]) + + +def test_can_parse_keygen_all(): + res = _parse_argv(["keygen", "--path=example/simple.json", "--all"]) + assert res.target == privateer2.cli.keygen_all + assert res.kwargs == {"cfg": read_config("example/simple.json")} + + +def test_can_parse_keygen_one(): + res = _parse_argv(["keygen", "--path=example/simple.json", "alice"]) + assert res.target == privateer2.cli.keygen + assert res.kwargs == { + "cfg": read_config("example/simple.json"), + "name": "alice" + } + + +def test_can_parse_configure(): + res = _parse_argv(["configure", "--path=example/simple.json", "alice"]) + assert res.target == privateer2.cli._do_configure + assert res.kwargs == { + "cfg": read_config("example/simple.json"), + "name": "alice", + "root": "example" + } + + +def test_can_parse_configure_without_explicit_path(tmp_path): + shutil.copy("example/simple.json", tmp_path / "privateer.json") + with transient_working_directory(tmp_path): + res = _parse_argv(["configure", "alice"]) + assert res.target == privateer2.cli._do_configure + assert res.kwargs == { + "cfg": read_config("example/simple.json"), + "name": "alice", + "root": "" + } + + +def test_can_parse_pull(): + res = _parse_argv(["pull", "--path=example/simple.json"]) + assert res.target == privateer2.cli.pull + assert res.kwargs == {"cfg": read_config("example/simple.json")} + + +def test_can_parse_check(tmp_path): + shutil.copy("example/simple.json", tmp_path / "privateer.json") + with open(tmp_path / ".privateer_identity", "w") as f: + f.write("alice\n") + with transient_working_directory(tmp_path): + res = _parse_argv(["check"]) + assert res.target == privateer2.cli.check + assert res.kwargs == { + "cfg": read_config("example/simple.json"), + "name": "alice" + } + path = str(tmp_path / "privateer.json") + res2 = _parse_argv(["check", "--path", path]) + assert _parse_argv(["check", "--path", path]) == res + assert _parse_argv(["check", "--path", path, "--as", "alice"]) == res + res.kwargs["name"] = "bob" + assert _parse_argv(["check", "--path", path, "--as", "bob"]) == res + + +def test_can_parse_serve(tmp_path): + shutil.copy("example/simple.json", tmp_path / "privateer.json") + with open(tmp_path / ".privateer_identity", "w") as f: + f.write("alice\n") + with transient_working_directory(tmp_path): + res = _parse_argv(["serve"]) + assert res.target == privateer2.cli.serve + assert res.kwargs == { + "cfg": read_config("example/simple.json"), + "name": "alice", + "dry_run": False + } + + +def test_can_parse_backup(tmp_path): + shutil.copy("example/simple.json", tmp_path / "privateer.json") + with open(tmp_path / ".privateer_identity", "w") as f: + f.write("alice\n") + with transient_working_directory(tmp_path): + res = _parse_argv(["backup", "v"]) + assert res.target == privateer2.cli.backup + assert res.kwargs == { + "cfg": read_config("example/simple.json"), + "name": "alice", + "volume": "v", + "dry_run": False + } + + +def test_can_parse_restore(tmp_path): + shutil.copy("example/simple.json", tmp_path / "privateer.json") + with open(tmp_path / ".privateer_identity", "w") as f: + f.write("alice\n") + with transient_working_directory(tmp_path): + res = _parse_argv(["restore", "v"]) + assert res.target == privateer2.cli.restore + assert res.kwargs == { + "cfg": read_config("example/simple.json"), + "name": "alice", + "volume": "v", + "server": None, + "source": None, + "dry_run": False + } + + +def test_can_parse_complex_restore(tmp_path): + shutil.copy("example/simple.json", tmp_path / "privateer.json") + with open(tmp_path / ".privateer_identity", "w") as f: + f.write("alice\n") + with transient_working_directory(tmp_path): + res = _parse_argv(["restore", "v", "--server=alice", "--source=bob"]) + assert res.target == privateer2.cli.restore + assert res.kwargs == { + "cfg": read_config("example/simple.json"), + "name": "alice", + "volume": "v", + "server": "alice", + "source": "bob", + "dry_run": False + } + + +def test_can_parse_export(tmp_path): + shutil.copy("example/simple.json", tmp_path / "privateer.json") + with open(tmp_path / ".privateer_identity", "w") as f: + f.write("alice\n") + with transient_working_directory(tmp_path): + res = _parse_argv(["export", "v"]) + assert res.target == privateer2.cli.export_tar + assert res.kwargs == { + "cfg": read_config("example/simple.json"), + "name": "alice", + "volume": "v", + "to_dir": None, + "source": None, + "dry_run": False + } + + +def test_error_if_unknown_identity(tmp_path): + shutil.copy("example/simple.json", tmp_path / "privateer.json") + msg = "Can't determine identity; did you forget to configure" + with pytest.raises(Exception, match=msg): + _find_identity(None, tmp_path) + assert _find_identity("alice", tmp_path) == "alice" + with open(tmp_path / ".privateer_identity", "w") as f: + f.write("alice\n") + assert _find_identity(None, tmp_path) == "alice" + assert _find_identity("bob", tmp_path) == "bob" + + + +def test_configuration_writes_identity(tmp_path, monkeypatch): + shutil.copy("example/simple.json", tmp_path / "privateer.json") + cfg = read_config("example/simple.json") + mock_configure = MagicMock() + monkeypatch.setattr(privateer2.cli, "configure", mock_configure) + _do_configure(cfg, "alice", str(tmp_path)) + assert tmp_path.joinpath(".privateer_identity").exists() + assert _find_identity(None, str(tmp_path)) == "alice" + mock_configure.assert_called_once_with(cfg, "alice") + + +def test_can_print_version(capsys): + _show_version() + out = capsys.readouterr() + assert out.out == f"privateer {privateer2.cli.about.__version__}\n" + + +def test_options_parsing_else_clause(tmp_path): + class empty: # noqa + def __getitem__(self, name): + return None + shutil.copy("example/simple.json", tmp_path / "privateer.json") + with open(tmp_path / ".privateer_identity", "w") as f: + f.write("alice\n") + with pytest.raises(Exception, match="Invalid cli call -- privateer bug"): + with transient_working_directory(tmp_path): + _parse_opts(empty()) diff --git a/tests/test_vault.py b/tests/test_vault.py new file mode 100644 index 0000000..de7488f --- /dev/null +++ b/tests/test_vault.py @@ -0,0 +1,24 @@ +from privateer2.util import transient_envvar +from privateer2.vault import _get_vault_token + + +def test_pass_back_given_token(): + assert _get_vault_token("a") == "a" + + +def test_lookup_token_from_env_if_given(): + with transient_envvar(MY_TOKEN="token"): + assert _get_vault_token("$MY_TOKEN") == "token" + + +def test_fallback_on_known_good_tokens(): + with transient_envvar(VAULT_TOKEN="vt", VAULT_AUTH_GITHUB_TOKEN="gt"): + assert _get_vault_token(None) == "vt" + with transient_envvar(VAULT_TOKEN=None, VAULT_AUTH_GITHUB_TOKEN="gt"): + assert _get_vault_token(None) == "gt" + + +def test_prompt_if_no_tokens_found(monkeypatch): + monkeypatch.setattr('builtins.input', lambda _: "foo") + with transient_envvar(VAULT_TOKEN=None, VAULT_AUTH_GITHUB_TOKEN=None): + _get_vault_token(None) == "foo" From 6ce7f8696758bc58c14eb0afcabcdeb92ef45fc6 Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Tue, 17 Oct 2023 14:31:16 +0100 Subject: [PATCH 35/87] Fix lint --- src/privateer2/cli.py | 10 +++++++--- src/privateer2/keys.py | 14 +++++++------- src/privateer2/tar.py | 2 +- tests/test_cli.py | 34 +++++++++++++++++++++------------- tests/test_config.py | 10 ++++++---- tests/test_keys.py | 2 +- tests/test_vault.py | 4 ++-- 7 files changed, 45 insertions(+), 31 deletions(-) diff --git a/src/privateer2/cli.py b/src/privateer2/cli.py index 052141e..0968e94 100644 --- a/src/privateer2/cli.py +++ b/src/privateer2/cli.py @@ -28,6 +28,7 @@ """ import os + import docopt import docker @@ -107,9 +108,12 @@ def _parse_opts(opts): if opts["import"]: _dont_use("--as", opts, "import") _dont_use("--path", opts, "import") - return Call(import_tar, volume=opts[""], - tarfile=opts[""], - dry_run=dry_run) + return Call( + import_tar, + volume=opts[""], + tarfile=opts[""], + dry_run=dry_run, + ) path_config = opts["--path"] or "privateer.json" root_config = os.path.dirname(path_config) diff --git a/src/privateer2/keys.py b/src/privateer2/keys.py index b553435..cbb3272 100644 --- a/src/privateer2/keys.py +++ b/src/privateer2/keys.py @@ -16,13 +16,13 @@ def keygen_all(cfg): def _keygen(cfg, name, vault): - data = _create_keypair() - path = f"{cfg.vault.prefix}/{name}" - # TODO: The docs are here: - # https://hvac.readthedocs.io/en/stable/usage/secrets_engines/kv_v1.html - # They do not indicate if this will error if the write fails though. - print(f"Writing keypair for {name}") - _r = vault.secrets.kv.v1.create_or_update_secret(path, secret=data) + data = _create_keypair() + path = f"{cfg.vault.prefix}/{name}" + # TODO: The docs are here: + # https://hvac.readthedocs.io/en/stable/usage/secrets_engines/kv_v1.html + # They do not indicate if this will error if the write fails though. + print(f"Writing keypair for {name}") + _r = vault.secrets.kv.v1.create_or_update_secret(path, secret=data) def configure(cfg, name): diff --git a/src/privateer2/tar.py b/src/privateer2/tar.py index 6887613..f966bf3 100644 --- a/src/privateer2/tar.py +++ b/src/privateer2/tar.py @@ -18,7 +18,7 @@ def export_tar(cfg, name, volume, *, to_dir=None, source=None, dry_run=False): # backup target for anything. source = find_source(cfg, volume, source) image = f"mrcide/privateer-client:{cfg.tag}" - if to is None: + if to_dir is None: export_path = os.getcwd() else: export_path = os.path.abspath(to_dir) diff --git a/tests/test_cli.py b/tests/test_cli.py index 3e8febd..a4e061d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,10 +1,17 @@ +import shutil from unittest.mock import MagicMock import pytest -import shutil import privateer2.cli -from privateer2.cli import Call, _parse_argv, _parse_opts, _do_configure, _find_identity, _show_version +from privateer2.cli import ( + Call, + _do_configure, + _find_identity, + _parse_argv, + _parse_opts, + _show_version, +) from privateer2.config import read_config from privateer2.util import transient_working_directory @@ -12,6 +19,7 @@ def test_can_create_and_run_call(): def f(a, b=1): return [a, b] + call = Call(f, a=1, b=2) assert call.run() == [1, 2] @@ -44,7 +52,7 @@ def test_can_parse_keygen_one(): assert res.target == privateer2.cli.keygen assert res.kwargs == { "cfg": read_config("example/simple.json"), - "name": "alice" + "name": "alice", } @@ -54,7 +62,7 @@ def test_can_parse_configure(): assert res.kwargs == { "cfg": read_config("example/simple.json"), "name": "alice", - "root": "example" + "root": "example", } @@ -66,7 +74,7 @@ def test_can_parse_configure_without_explicit_path(tmp_path): assert res.kwargs == { "cfg": read_config("example/simple.json"), "name": "alice", - "root": "" + "root": "", } @@ -85,10 +93,10 @@ def test_can_parse_check(tmp_path): assert res.target == privateer2.cli.check assert res.kwargs == { "cfg": read_config("example/simple.json"), - "name": "alice" + "name": "alice", } path = str(tmp_path / "privateer.json") - res2 = _parse_argv(["check", "--path", path]) + _parse_argv(["check", "--path", path]) assert _parse_argv(["check", "--path", path]) == res assert _parse_argv(["check", "--path", path, "--as", "alice"]) == res res.kwargs["name"] = "bob" @@ -105,7 +113,7 @@ def test_can_parse_serve(tmp_path): assert res.kwargs == { "cfg": read_config("example/simple.json"), "name": "alice", - "dry_run": False + "dry_run": False, } @@ -120,7 +128,7 @@ def test_can_parse_backup(tmp_path): "cfg": read_config("example/simple.json"), "name": "alice", "volume": "v", - "dry_run": False + "dry_run": False, } @@ -137,7 +145,7 @@ def test_can_parse_restore(tmp_path): "volume": "v", "server": None, "source": None, - "dry_run": False + "dry_run": False, } @@ -154,7 +162,7 @@ def test_can_parse_complex_restore(tmp_path): "volume": "v", "server": "alice", "source": "bob", - "dry_run": False + "dry_run": False, } @@ -171,7 +179,7 @@ def test_can_parse_export(tmp_path): "volume": "v", "to_dir": None, "source": None, - "dry_run": False + "dry_run": False, } @@ -187,7 +195,6 @@ def test_error_if_unknown_identity(tmp_path): assert _find_identity("bob", tmp_path) == "bob" - def test_configuration_writes_identity(tmp_path, monkeypatch): shutil.copy("example/simple.json", tmp_path / "privateer.json") cfg = read_config("example/simple.json") @@ -209,6 +216,7 @@ def test_options_parsing_else_clause(tmp_path): class empty: # noqa def __getitem__(self, name): return None + shutil.copy("example/simple.json", tmp_path / "privateer.json") with open(tmp_path / ".privateer_identity", "w") as f: f.write("alice\n") diff --git a/tests/test_config.py b/tests/test_config.py index 063c93f..37d1404 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,7 +1,7 @@ import pytest import vault_dev -from privateer2.config import find_source, read_config, _check_config +from privateer2.config import _check_config, find_source, read_config def test_can_read_config(): @@ -34,7 +34,8 @@ def test_can_create_vault_client(): def test_validation_is_run_on_load(tmp_path): path = tmp_path / "privateer.json" with path.open("w") as f: - f.write("""{ + f.write( + """{ "servers": [ { "name": "alice", @@ -58,7 +59,8 @@ def test_validation_is_run_on_load(tmp_path): "url": "http://localhost:8200", "prefix": "/secret/privateer" } -}""") +}""" + ) msg = "Invalid machine listed as both a client and a server: 'alice'" with pytest.raises(Exception, match=msg): read_config(path) @@ -76,7 +78,7 @@ def test_machines_cannot_be_duplicated(): def test_machines_cannot_be_client_and_server(): cfg = read_config("example/simple.json") - tmp = cfg.clients[0].model_copy() + tmp = cfg.clients[0].model_copy() tmp.name = "alice" cfg.clients.append(tmp) msg = "Invalid machine listed as both a client and a server: 'alice'" diff --git a/tests/test_keys.py b/tests/test_keys.py index bd7966d..8774555 100644 --- a/tests/test_keys.py +++ b/tests/test_keys.py @@ -104,7 +104,7 @@ def test_can_check_quietly(capsys): cfg.servers[0].key_volume = vol keygen_all(cfg) configure(cfg, "alice") - capsys.readouterr() # flush capture so far + capsys.readouterr() # flush capture so far assert check(cfg, "alice", quiet=True).key_volume == vol assert capsys.readouterr().out == "" assert check(cfg, "alice", quiet=False).key_volume == vol diff --git a/tests/test_vault.py b/tests/test_vault.py index de7488f..af23f49 100644 --- a/tests/test_vault.py +++ b/tests/test_vault.py @@ -19,6 +19,6 @@ def test_fallback_on_known_good_tokens(): def test_prompt_if_no_tokens_found(monkeypatch): - monkeypatch.setattr('builtins.input', lambda _: "foo") + monkeypatch.setattr("builtins.input", lambda _: "foo") with transient_envvar(VAULT_TOKEN=None, VAULT_AUTH_GITHUB_TOKEN=None): - _get_vault_token(None) == "foo" + assert _get_vault_token(None) == "foo" From 357a70be6aab63f6a2e6d245277ce6e162074e74 Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Tue, 17 Oct 2023 16:04:32 +0100 Subject: [PATCH 36/87] Basic tests for server --- src/privateer2/server.py | 4 +- tests/test_server.py | 118 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 2 deletions(-) create mode 100644 tests/test_server.py diff --git a/src/privateer2/server.py b/src/privateer2/server.py index c081f4c..c8f36a3 100644 --- a/src/privateer2/server.py +++ b/src/privateer2/server.py @@ -4,7 +4,7 @@ def serve(cfg, name, *, dry_run=False): - machine = check(cfg, name) + machine = check(cfg, name, quiet=True) image = f"mrcide/privateer-server:{cfg.tag}" ensure_image(image) @@ -18,7 +18,7 @@ def serve(cfg, name, *, dry_run=False): if v.local: mounts.append( docker.types.Mount( - "/privateer/local/{v.name}", + f"/privateer/local/{v.name}", v.name, type="volume", read_only=True, diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..f2227e0 --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,118 @@ +from unittest.mock import MagicMock, call + +import pytest +import vault_dev + +import docker +import privateer2.server +from privateer2.config import read_config +from privateer2.keys import configure, keygen_all +from privateer2.server import serve +from privateer2.util import rand_str + + +def test_can_print_instructions_to_start_server(capsys): + with vault_dev.Server(export_token=True) as server: + cfg = read_config("example/simple.json") + cfg.vault.url = server.url() + vol = f"privateer_keys_{rand_str()}" + cfg.servers[0].key_volume = vol + keygen_all(cfg) + configure(cfg, "alice") + capsys.readouterr() # flush previous output + serve(cfg, "alice", dry_run=True) + out = capsys.readouterr() + lines = out.out.strip().split("\n") + assert "Command to manually launch server:" in lines + cmd = ( + " docker run --rm -d --name privateer_server " + f"-v {vol}:/run/privateer:ro -v privateer_data:/privateer " + "-p 10022:22 mrcide/privateer-server:docker" + ) + assert cmd in lines + docker.from_env().volumes.get(vol).remove() + + +def test_can_start_server(monkeypatch): + mock_docker = MagicMock() + monkeypatch.setattr(privateer2.server, "docker", mock_docker) + with vault_dev.Server(export_token=True) as server: + cfg = read_config("example/simple.json") + cfg.vault.url = server.url() + vol = f"privateer_keys_{rand_str()}" + cfg.servers[0].key_volume = vol + keygen_all(cfg) + configure(cfg, "alice") + serve(cfg, "alice") + assert mock_docker.from_env.called + client = mock_docker.from_env.return_value + assert client.containers.run.call_count == 1 + mount = mock_docker.types.Mount + assert client.containers.run.call_args == call( + f"mrcide/privateer-server:{cfg.tag}", + auto_remove=True, + detach=True, + name="privateer_server", + mounts=[mount.return_value, mount.return_value], + ports={"22/tcp": 10022}, + ) + assert mount.call_count == 2 + assert mount.call_args_list[0] == call( + "/run/privateer", vol, type="volume", read_only=True + ) + assert mount.call_args_list[1] == call( + "/privateer", "privateer_data", type="volume" + ) + + +def test_can_start_server_with_local_volume(monkeypatch): + mock_docker = MagicMock() + monkeypatch.setattr(privateer2.server, "docker", mock_docker) + with vault_dev.Server(export_token=True) as server: + cfg = read_config("example/local.json") + cfg.vault.url = server.url() + vol = f"privateer_keys_{rand_str()}" + cfg.servers[0].key_volume = vol + keygen_all(cfg) + configure(cfg, "alice") + serve(cfg, "alice") + assert mock_docker.from_env.called + client = mock_docker.from_env.return_value + assert client.containers.run.call_count == 1 + mount = mock_docker.types.Mount + assert mount.call_count == 3 + assert mount.call_args_list[0] == call( + "/run/privateer", vol, type="volume", read_only=True + ) + assert mount.call_args_list[1] == call( + "/privateer", "privateer_data_alice", type="volume" + ) + assert mount.call_args_list[2] == call( + "/privateer/local/other", "other", type="volume", read_only=True + ) + assert client.containers.run.call_args == call( + f"mrcide/privateer-server:{cfg.tag}", + auto_remove=True, + detach=True, + name="privateer_server", + mounts=[mount.return_value, mount.return_value, mount.return_value], + ports={"22/tcp": 10022}, + ) + + +def test_throws_if_container_already_exists(monkeypatch): + mock_ce = MagicMock() # container exists? + mock_ce.return_value = True + monkeypatch.setattr(privateer2.server, "container_exists", mock_ce) + with vault_dev.Server(export_token=True) as server: + cfg = read_config("example/local.json") + cfg.vault.url = server.url() + vol = f"privateer_keys_{rand_str()}" + cfg.servers[0].key_volume = vol + keygen_all(cfg) + configure(cfg, "alice") + msg = "Container 'privateer_server' for 'alice' already running" + with pytest.raises(Exception, match=msg): + serve(cfg, "alice") + assert mock_ce.call_count == 1 + mock_ce.assert_called_with("privateer_server") From 54b341cae4444f51016d25c8b64b8318fe601546 Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Tue, 17 Oct 2023 16:19:08 +0100 Subject: [PATCH 37/87] Bunch more mocks --- tests/test_cli.py | 28 +++++++++++++++++++++++++++- tests/test_vault.py | 17 ++++++++++++++++- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index a4e061d..fd624f5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,11 +1,13 @@ import shutil -from unittest.mock import MagicMock +from unittest.mock import MagicMock, call import pytest import privateer2.cli from privateer2.cli import ( Call, + main, + pull, _do_configure, _find_identity, _parse_argv, @@ -223,3 +225,27 @@ def __getitem__(self, name): with pytest.raises(Exception, match="Invalid cli call -- privateer bug"): with transient_working_directory(tmp_path): _parse_opts(empty()) + + +def test_call_main(monkeypatch): + mock_call = MagicMock() + monkeypatch.setattr(privateer2.cli, "_parse_argv", mock_call) + main(["--version"]) + assert mock_call.call_count == 1 + assert mock_call.call_args == call(["--version"]) + assert mock_call.return_value.run.call_count == 1 + assert mock_call.return_value.run.call_args == call() + + +def test_run_pull(monkeypatch): + cfg = read_config("example/simple.json") + image_client = f"mrcide/privateer-client:{cfg.tag}" + image_server = f"mrcide/privateer-server:{cfg.tag}" + mock_docker = MagicMock() + monkeypatch.setattr(privateer2.cli, "docker", mock_docker) + pull(cfg) + assert mock_docker.from_env.call_count == 1 + client = mock_docker.from_env.return_value + assert client.images.pull.call_count == 2 + assert client.images.pull.call_args_list[0] == call(image_client) + assert client.images.pull.call_args_list[1] == call(image_server) diff --git a/tests/test_vault.py b/tests/test_vault.py index af23f49..5c33fd4 100644 --- a/tests/test_vault.py +++ b/tests/test_vault.py @@ -1,5 +1,8 @@ +from unittest.mock import MagicMock, call + +import privateer2.vault from privateer2.util import transient_envvar -from privateer2.vault import _get_vault_token +from privateer2.vault import _get_vault_token, vault_client def test_pass_back_given_token(): @@ -22,3 +25,15 @@ def test_prompt_if_no_tokens_found(monkeypatch): monkeypatch.setattr("builtins.input", lambda _: "foo") with transient_envvar(VAULT_TOKEN=None, VAULT_AUTH_GITHUB_TOKEN=None): assert _get_vault_token(None) == "foo" + + +def test_can_use_github_auth(monkeypatch): + token = f"ghp_{'x' * 36}" + mock_client = MagicMock() + monkeypatch.setattr(privateer2.vault.hvac, "Client", mock_client) + client = vault_client("https://vault.example.com:8200", token) + assert mock_client.call_count == 1 + assert mock_client.call_args == call("https://vault.example.com:8200") + assert client == mock_client.return_value + assert client.auth.github.login.call_count == 1 + assert client.auth.github.login.call_args == call(token) From 8e89f5b2e95e910a66ceef6054c53b1bb6069c85 Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Tue, 17 Oct 2023 16:27:46 +0100 Subject: [PATCH 38/87] A little utility testing --- src/privateer2/util.py | 44 ++++++++++++++++++---------------------- tests/test_cli.py | 4 ++-- tests/test_util.py | 46 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 26 deletions(-) create mode 100644 tests/test_util.py diff --git a/src/privateer2/util.py b/src/privateer2/util.py index d2b7c81..a678bf4 100644 --- a/src/privateer2/util.py +++ b/src/privateer2/util.py @@ -40,6 +40,25 @@ def string_to_container(text, container, path, **kwargs): container.put_archive(os.path.dirname(path), tar) +def string_from_container(container, path): + return bytes_from_container(container, path).decode("utf-8") + + +def bytes_from_container(container, path): + stream, status = container.get_archive(path) + try: + fd, tmp = tempfile.mkstemp(text=False) + with os.fdopen(fd, "wb") as f: + for d in stream: + f.write(d) + with open(tmp, "rb") as f: + t = tarfile.open(mode="r", fileobj=f) + p = t.extractfile(os.path.basename(path)) + return p.read() + finally: + os.remove(tmp) + + def set_permissions(mode=None, uid=None, gid=None): def ret(tarinfo): if mode is not None: @@ -54,8 +73,7 @@ def ret(tarinfo): def simple_tar_string(text, name, **kwargs): - if isinstance(text, str): - text = bytes(text, "utf-8") + text = bytes(text, "utf-8") try: fd, tmp = tempfile.mkstemp(text=True) with os.fdopen(fd, "wb") as f: @@ -102,25 +120,6 @@ def _setdictvals(new, container): return container -def string_from_container(container, path): - return bytes_from_container(container, path).decode("utf-8") - - -def bytes_from_container(container, path): - stream, status = container.get_archive(path) - try: - fd, tmp = tempfile.mkstemp(text=False) - with os.fdopen(fd, "wb") as f: - for d in stream: - f.write(d) - with open(tmp, "rb") as f: - t = tarfile.open(mode="r", fileobj=f) - p = t.extractfile(os.path.basename(path)) - return p.read() - finally: - os.remove(tmp) - - def ensure_image(name): cl = docker.from_env() try: @@ -225,9 +224,6 @@ def run_docker_command(name, image, **kwargs): print(f"{name} completed successfully! Container logs:") log_tail(container, 10) container.remove() - # TODO: also copy over some metadata at this point, via - # ssh; probably best to write tiny utility in the client - # container that will do this for us. else: print("An error occured! Container logs:") log_tail(container, 20) diff --git a/tests/test_cli.py b/tests/test_cli.py index fd624f5..416a368 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -6,13 +6,13 @@ import privateer2.cli from privateer2.cli import ( Call, - main, - pull, _do_configure, _find_identity, _parse_argv, _parse_opts, _show_version, + main, + pull, ) from privateer2.config import read_config from privateer2.util import transient_working_directory diff --git a/tests/test_util.py b/tests/test_util.py new file mode 100644 index 0000000..c762c05 --- /dev/null +++ b/tests/test_util.py @@ -0,0 +1,46 @@ +import os +import re +import tarfile + +import pytest + +import privateer2.util + + +def test_create_simple_tar_from_string(): + p = privateer2.util.simple_tar_string("hello", "path") + t = tarfile.open(fileobj=p) + els = t.getmembers() + assert len(els) == 1 + assert els[0].name == "path" + assert els[0].uid == os.geteuid() + assert els[0].gid == os.getegid() + + +def test_create_simple_tar_with_permissions(): + p = privateer2.util.simple_tar_string( + "hello", "path", uid=0, gid=0, mode=0o600 + ) + t = tarfile.open(fileobj=p) + els = t.getmembers() + assert len(els) == 1 + assert els[0].name == "path" + assert els[0].uid == 0 + assert els[0].gid == 0 + assert els[0].mode == 0o600 + + +def test_can_match_values(): + match_value = privateer2.util.match_value + assert match_value(None, "x", "nm") == "x" + assert match_value("x", "x", "nm") == "x" + assert match_value("x", ["x", "y"], "nm") == "x" + with pytest.raises(Exception, match="Please provide a value for nm"): + match_value(None, ["x", "y"], "nm") + msg = "Invalid nm 'z': valid options: 'x', 'y'" + with pytest.raises(Exception, match=msg): + match_value("z", ["x", "y"], "nm") + + +def test_can_format_timestamp(): + assert re.match("^[0-9]{8}-[0-9]{6}", privateer2.util.isotimestamp()) From 729daeea6c68082aab061865a7bc750e83626e1c Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Tue, 17 Oct 2023 17:27:52 +0100 Subject: [PATCH 39/87] Expand server control --- src/privateer2/cli.py | 13 +++++-- src/privateer2/server.py | 28 +++++++++++++- src/privateer2/util.py | 10 +++-- tests/test_cli.py | 32 ++++++++++++++-- tests/test_server.py | 81 +++++++++++++++++++++++++++++++++++++--- 5 files changed, 146 insertions(+), 18 deletions(-) diff --git a/src/privateer2/cli.py b/src/privateer2/cli.py index 0968e94..145aceb 100644 --- a/src/privateer2/cli.py +++ b/src/privateer2/cli.py @@ -5,7 +5,7 @@ privateer2 [options] configure privateer2 [options] status privateer2 [options] check - privateer2 [options] serve + privateer2 [options] server (start | stop | status) privateer2 [options] backup privateer2 [options] restore [--server=NAME] [--source=NAME] privateer2 [options] export [--to-dir=PATH] [--source=NAME] @@ -37,7 +37,7 @@ from privateer2.config import read_config from privateer2.keys import check, configure, keygen, keygen_all from privateer2.restore import restore -from privateer2.server import serve +from privateer2.server import server_start, server_status, server_stop from privateer2.tar import export_tar, import_tar @@ -139,8 +139,13 @@ def _parse_opts(opts): name = _find_identity(opts["--as"], root_config) if opts["check"]: return Call(check, cfg=cfg, name=name) - elif opts["serve"]: - return Call(serve, cfg=cfg, name=name, dry_run=dry_run) + elif opts["server"]: + if opts["start"]: + return Call(server_start, cfg=cfg, name=name, dry_run=dry_run) + elif opts["stop"]: + return Call(server_stop, cfg=cfg, name=name) + else: + return Call(server_status, cfg=cfg, name=name) elif opts["backup"]: return Call( backup, diff --git a/src/privateer2/server.py b/src/privateer2/server.py index c8f36a3..9d0febd 100644 --- a/src/privateer2/server.py +++ b/src/privateer2/server.py @@ -1,9 +1,14 @@ import docker from privateer2.keys import check -from privateer2.util import container_exists, ensure_image, mounts_str +from privateer2.util import ( + container_exists, + container_if_exists, + ensure_image, + mounts_str, +) -def serve(cfg, name, *, dry_run=False): +def server_start(cfg, name, *, dry_run=False): machine = check(cfg, name, quiet=True) image = f"mrcide/privateer-server:{cfg.tag}" ensure_image(image) @@ -60,3 +65,22 @@ def serve(cfg, name, *, dry_run=False): ports=ports, ) print(f"Server {name} now running on port {machine.port}") + + +def server_stop(cfg, name): + machine = check(cfg, name, quiet=True) + container = container_if_exists(machine.container) + if container: + if container.status == "running": + container.stop() + else: + print("Container '{machine.container}' for '{name}' does not exist") + + +def server_status(cfg, name): + machine = check(cfg, name, quiet=False) + container = container_if_exists(machine.container) + if container: + print(container.status) + else: + print("not running") diff --git a/src/privateer2/util.py b/src/privateer2/util.py index a678bf4..57e1991 100644 --- a/src/privateer2/util.py +++ b/src/privateer2/util.py @@ -130,12 +130,14 @@ def ensure_image(name): def container_exists(name): - cl = docker.from_env() + return bool(container_if_exists(name)) + + +def container_if_exists(name): try: - cl.containers.get(name) - return True + return docker.from_env().containers.get(name) except docker.errors.NotFound: - return False + return None def volume_exists(name): diff --git a/tests/test_cli.py b/tests/test_cli.py index 416a368..c67eee3 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -105,13 +105,13 @@ def test_can_parse_check(tmp_path): assert _parse_argv(["check", "--path", path, "--as", "bob"]) == res -def test_can_parse_serve(tmp_path): +def test_can_parse_server_start(tmp_path): shutil.copy("example/simple.json", tmp_path / "privateer.json") with open(tmp_path / ".privateer_identity", "w") as f: f.write("alice\n") with transient_working_directory(tmp_path): - res = _parse_argv(["serve"]) - assert res.target == privateer2.cli.serve + res = _parse_argv(["server", "start"]) + assert res.target == privateer2.cli.server_start assert res.kwargs == { "cfg": read_config("example/simple.json"), "name": "alice", @@ -119,6 +119,32 @@ def test_can_parse_serve(tmp_path): } +def test_can_parse_server_status(tmp_path): + shutil.copy("example/simple.json", tmp_path / "privateer.json") + with open(tmp_path / ".privateer_identity", "w") as f: + f.write("alice\n") + with transient_working_directory(tmp_path): + res = _parse_argv(["server", "status"]) + assert res.target == privateer2.cli.server_status + assert res.kwargs == { + "cfg": read_config("example/simple.json"), + "name": "alice", + } + + +def test_can_parse_server_stop(tmp_path): + shutil.copy("example/simple.json", tmp_path / "privateer.json") + with open(tmp_path / ".privateer_identity", "w") as f: + f.write("alice\n") + with transient_working_directory(tmp_path): + res = _parse_argv(["server", "stop"]) + assert res.target == privateer2.cli.server_stop + assert res.kwargs == { + "cfg": read_config("example/simple.json"), + "name": "alice", + } + + def test_can_parse_backup(tmp_path): shutil.copy("example/simple.json", tmp_path / "privateer.json") with open(tmp_path / ".privateer_identity", "w") as f: diff --git a/tests/test_server.py b/tests/test_server.py index f2227e0..473a679 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -7,7 +7,7 @@ import privateer2.server from privateer2.config import read_config from privateer2.keys import configure, keygen_all -from privateer2.server import serve +from privateer2.server import server_start, server_status, server_stop from privateer2.util import rand_str @@ -20,7 +20,7 @@ def test_can_print_instructions_to_start_server(capsys): keygen_all(cfg) configure(cfg, "alice") capsys.readouterr() # flush previous output - serve(cfg, "alice", dry_run=True) + server_start(cfg, "alice", dry_run=True) out = capsys.readouterr() lines = out.out.strip().split("\n") assert "Command to manually launch server:" in lines @@ -43,7 +43,7 @@ def test_can_start_server(monkeypatch): cfg.servers[0].key_volume = vol keygen_all(cfg) configure(cfg, "alice") - serve(cfg, "alice") + server_start(cfg, "alice") assert mock_docker.from_env.called client = mock_docker.from_env.return_value assert client.containers.run.call_count == 1 @@ -75,7 +75,7 @@ def test_can_start_server_with_local_volume(monkeypatch): cfg.servers[0].key_volume = vol keygen_all(cfg) configure(cfg, "alice") - serve(cfg, "alice") + server_start(cfg, "alice") assert mock_docker.from_env.called client = mock_docker.from_env.return_value assert client.containers.run.call_count == 1 @@ -113,6 +113,77 @@ def test_throws_if_container_already_exists(monkeypatch): configure(cfg, "alice") msg = "Container 'privateer_server' for 'alice' already running" with pytest.raises(Exception, match=msg): - serve(cfg, "alice") + server_start(cfg, "alice") assert mock_ce.call_count == 1 mock_ce.assert_called_with("privateer_server") + + +def test_can_stop_server(monkeypatch): + mock_container = MagicMock() + mock_container.status = "running" + mock_container_if_exists = MagicMock(return_value=mock_container) + monkeypatch.setattr( + privateer2.server, + "container_if_exists", + mock_container_if_exists, + ) + with vault_dev.Server(export_token=True) as server: + cfg = read_config("example/local.json") + cfg.vault.url = server.url() + vol = f"privateer_keys_{rand_str()}" + cfg.servers[0].key_volume = vol + keygen_all(cfg) + configure(cfg, "alice") + + server_stop(cfg, "alice") + assert mock_container_if_exists.call_count == 1 + assert mock_container_if_exists.call_args == call("privateer_server") + assert mock_container.stop.call_count == 1 + assert mock_container.stop.call_args == call() + + mock_container.status = "exited" + server_stop(cfg, "alice") + assert mock_container_if_exists.call_count == 2 + assert mock_container.stop.call_count == 1 + + mock_container_if_exists.return_value = None + server_stop(cfg, "alice") + assert mock_container_if_exists.call_count == 3 + assert mock_container.stop.call_count == 1 + + +def test_can_get_server_status(monkeypatch, capsys): + mock_container = MagicMock() + mock_container.status = "running" + mock_container_if_exists = MagicMock(return_value=mock_container) + monkeypatch.setattr( + privateer2.server, + "container_if_exists", + mock_container_if_exists, + ) + with vault_dev.Server(export_token=True) as server: + cfg = read_config("example/local.json") + cfg.vault.url = server.url() + vol = f"privateer_keys_{rand_str()}" + cfg.servers[0].key_volume = vol + keygen_all(cfg) + configure(cfg, "alice") + + capsys.readouterr() # flush previous output + + prefix = f"Volume '{vol}' looks configured as 'alice'" + + server_status(cfg, "alice") + assert mock_container_if_exists.call_count == 1 + assert mock_container_if_exists.call_args == call("privateer_server") + assert capsys.readouterr().out == f"{prefix}\nrunning\n" + + mock_container.status = "exited" + server_status(cfg, "alice") + assert mock_container_if_exists.call_count == 2 + assert capsys.readouterr().out == f"{prefix}\nexited\n" + + mock_container_if_exists.return_value = None + server_status(cfg, "alice") + assert mock_container_if_exists.call_count == 3 + assert capsys.readouterr().out == f"{prefix}\nnot running\n" From ca229c376b8702d329ef4804ecc11d6bf67fb992 Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Tue, 17 Oct 2023 17:50:53 +0100 Subject: [PATCH 40/87] Tidy up docs --- .gitignore | 2 ++ development.md | 61 ++++++++++++++++++++++++++------------- src/privateer2/backup.py | 2 +- src/privateer2/restore.py | 2 +- 4 files changed, 45 insertions(+), 22 deletions(-) diff --git a/.gitignore b/.gitignore index 6110248..4954067 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ dist/ .coverage coverage.xml *.tar +.privateer_identity +tmp/ diff --git a/development.md b/development.md index ec1c330..38a247c 100644 --- a/development.md +++ b/development.md @@ -14,50 +14,71 @@ If you need to interact with this on the command line, use: ``` export VAULT_ADDR='http://127.0.0.1:8200' +export VAULT_TOKEN=$(cat ~/.vault-token) ``` -You may need to export your root token +within the hatch environment ``` -export VAULT_TOKEN=hvs.cPdO7xlwqNugg8xTF7KrxJyj +privateer2 --path example/simple.json keygen --all ``` -within the hatch environment +## Worked example +We need to swap in the globally-findable address for alice (`alice.example.com`) for the value of the machine this is tested on: ``` -privateer2 -f example/simple.json keygen alice -privateer2 -f example/simple.json keygen bob -privateer2 -f example/simple.json configure alice +mkdir -p tmp +sed "s/alice.example.com/$(hostname)/" example/local.json > tmp/privateer.json ``` -## Worked example +Set up the key volumes (and remove the file that would ordinarily be created) + +``` +privateer2 --path tmp/privateer.json configure alice +privateer2 --path tmp/privateer.json configure bob +rm -f tmp/.privateer_identity +``` + +Start the server, as a background process ``` -privateer2 -f example/local.json keygen alice -privateer2 -f example/local.json keygen bob -privateer2 -f example/local.json configure alice -privateer2 -f example/local.json configure bob -privateer2 -f example/local.json serve alice --dry-run +privateer2 --path tmp/privateer.json --as=alice server start ``` -Create some random data +Create some random data within the `data` volume (this is the one that we want to send from `bob` to `alice`) ``` docker volume create data -docker run -it --rm -v data:/data ubuntu bash -c "base64 /dev/urandom | head -c 10000000 > /data/file.txt" +docker run -it --rm -v data:/data ubuntu bash -c "base64 /dev/urandom | head -c 100000 > /data/file1.txt" +``` + +We can now backup from `bob` to `alice` as: + +``` +privateer2 --path tmp/privateer.json --as=bob backup data ``` +or see what commands you would need in order to try this yourself: + ``` -docker run -it --rm -v privateer_keys_bob:/run/privateer:ro -v data:/privateer/data:ro -w /privateer mrcide/privateer-client:docker bash +privateer2 --path tmp/privateer.json --as=bob backup data --dry-run +``` + +Delete the volume -rsync -av -e 'ssh -p 10022 -i /run/privateer/id_rsa' --delete data/ root@wpia-dide136:/privateer/data/bob/ +``` +docker volume rm data ``` -privateer2 -f example/local.json backup bob --dry-run -privateer2 -f example/local.json backup bob +We can now restore it: -rsync -av --delete data/ root@wpia-dide136:/privateer/data/bob/ +``` +privateer2 --path tmp/privateer.json --as=bob restore data +``` +or see the commands to do this outselves: -rsync -av --delete alice:/privateer/bob/data /privateer +``` +privateer2 --path tmp/privateer.json --as=bob restore data --dry-run +``` diff --git a/src/privateer2/backup.py b/src/privateer2/backup.py index ee11b68..f833322 100644 --- a/src/privateer2/backup.py +++ b/src/privateer2/backup.py @@ -36,7 +36,7 @@ def backup(cfg, name, volume, *, server=None, dry_run=False): print() print("Note that this uses hostname/port information for the server") print("contained within /run/privateer/config, along with our identity") - print("in /run/config/id_rsa") + print("in /run/privateer/id_rsa") else: print(f"Backing up '{volume}' to '{server}'") run_docker_command("Backup", image, command=command, mounts=mounts) diff --git a/src/privateer2/restore.py b/src/privateer2/restore.py index ee3a79e..b47d07d 100644 --- a/src/privateer2/restore.py +++ b/src/privateer2/restore.py @@ -35,7 +35,7 @@ def restore(cfg, name, volume, *, server=None, source=None, dry_run=False): print() print("Note that this uses hostname/port information for the server") print("contained within /run/privateer/config, along with our identity") - print("in /run/config/id_rsa") + print("in /run/privateer/id_rsa") else: print(f"Restoring '{volume}' from '{server}'") run_docker_command("Restore", image, command=command, mounts=mounts) From 9bf458daf172793ae17552bcfe874fecd87d78f0 Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Tue, 17 Oct 2023 17:56:13 +0100 Subject: [PATCH 41/87] Basic dry run tests for backup/restore --- src/privateer2/backup.py | 2 +- src/privateer2/restore.py | 2 +- tests/test_backup.py | 34 ++++++++++++++++++++++++++++++++++ tests/test_restore.py | 32 ++++++++++++++++++++++++++++++++ 4 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 tests/test_backup.py create mode 100644 tests/test_restore.py diff --git a/src/privateer2/backup.py b/src/privateer2/backup.py index f833322..8defe87 100644 --- a/src/privateer2/backup.py +++ b/src/privateer2/backup.py @@ -25,7 +25,7 @@ def backup(cfg, name, volume, *, server=None, dry_run=False): ] if dry_run: cmd = ["docker", "run", "--rm", *mounts_str(mounts), image, *command] - print("Command to manually run backup") + print("Command to manually run backup:") print() print(f" {' '.join(cmd)}") print() diff --git a/src/privateer2/restore.py b/src/privateer2/restore.py index b47d07d..78c0c74 100644 --- a/src/privateer2/restore.py +++ b/src/privateer2/restore.py @@ -26,7 +26,7 @@ def restore(cfg, name, volume, *, server=None, source=None, dry_run=False): ] if dry_run: cmd = ["docker", "run", "--rm", *mounts_str(mounts), image, *command] - print("Command to manually run restore") + print("Command to manually run restore:") print() print(f" {' '.join(cmd)}") print() diff --git a/tests/test_backup.py b/tests/test_backup.py new file mode 100644 index 0000000..cd94c67 --- /dev/null +++ b/tests/test_backup.py @@ -0,0 +1,34 @@ +from unittest.mock import MagicMock, call + +import pytest +import vault_dev + +import docker +import privateer2.server +from privateer2.backup import backup +from privateer2.config import read_config +from privateer2.keys import configure, keygen_all +from privateer2.util import rand_str + + +def test_can_print_instructions_to_run_backup(capsys): + with vault_dev.Server(export_token=True) as server: + cfg = read_config("example/simple.json") + cfg.vault.url = server.url() + vol = f"privateer_keys_{rand_str()}" + cfg.clients[0].key_volume = vol + keygen_all(cfg) + configure(cfg, "bob") + capsys.readouterr() # flush previous output + backup(cfg, "bob", "data", dry_run=True) + out = capsys.readouterr() + lines = out.out.strip().split("\n") + assert "Command to manually run backup:" in lines + cmd = ( + " docker run --rm " + f"-v {vol}:/run/privateer:ro -v data:/privateer/data:ro " + "mrcide/privateer-client:docker " + "rsync -av --delete /privateer/data alice:/privateer/bob" + ) + assert cmd in lines + docker.from_env().volumes.get(vol).remove() diff --git a/tests/test_restore.py b/tests/test_restore.py new file mode 100644 index 0000000..4eaafb2 --- /dev/null +++ b/tests/test_restore.py @@ -0,0 +1,32 @@ +import pytest +import vault_dev + +import docker +import privateer2.server +from privateer2.config import read_config +from privateer2.keys import configure, keygen_all +from privateer2.restore import restore +from privateer2.util import rand_str + + +def test_can_print_instructions_to_run_restore(capsys): + with vault_dev.Server(export_token=True) as server: + cfg = read_config("example/simple.json") + cfg.vault.url = server.url() + vol = f"privateer_keys_{rand_str()}" + cfg.clients[0].key_volume = vol + keygen_all(cfg) + configure(cfg, "bob") + capsys.readouterr() # flush previous output + restore(cfg, "bob", "data", dry_run=True) + out = capsys.readouterr() + lines = out.out.strip().split("\n") + assert "Command to manually run restore:" in lines + cmd = ( + " docker run --rm " + f"-v {vol}:/run/privateer:ro -v data:/privateer/data " + "mrcide/privateer-client:docker " + "rsync -av --delete alice:/privateer/bob/data/ /privateer/data/" + ) + assert cmd in lines + docker.from_env().volumes.get(vol).remove() From faa1cf9ad82692e4f0103a1d3ee4367092f59c8a Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Tue, 17 Oct 2023 18:04:36 +0100 Subject: [PATCH 42/87] Interaction tests of running backup/restore --- src/privateer2/backup.py | 2 +- tests/test_backup.py | 35 ++++++++++++++++++++++++++++++++++- tests/test_restore.py | 39 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 72 insertions(+), 4 deletions(-) diff --git a/src/privateer2/backup.py b/src/privateer2/backup.py index 8defe87..aa60dff 100644 --- a/src/privateer2/backup.py +++ b/src/privateer2/backup.py @@ -38,7 +38,7 @@ def backup(cfg, name, volume, *, server=None, dry_run=False): print("contained within /run/privateer/config, along with our identity") print("in /run/privateer/id_rsa") else: - print(f"Backing up '{volume}' to '{server}'") + print(f"Backing up '{volume}' from '{name}' to '{server}'") run_docker_command("Backup", image, command=command, mounts=mounts) # TODO: also copy over some metadata at this point, via # ssh; probably best to write tiny utility in the client diff --git a/tests/test_backup.py b/tests/test_backup.py index cd94c67..a1b8329 100644 --- a/tests/test_backup.py +++ b/tests/test_backup.py @@ -1,6 +1,5 @@ from unittest.mock import MagicMock, call -import pytest import vault_dev import docker @@ -32,3 +31,37 @@ def test_can_print_instructions_to_run_backup(capsys): ) assert cmd in lines docker.from_env().volumes.get(vol).remove() + + +def test_can_run_backup(monkeypatch): + mock_run = MagicMock() + monkeypatch.setattr(privateer2.backup, "run_docker_command", mock_run) + with vault_dev.Server(export_token=True) as server: + cfg = read_config("example/simple.json") + cfg.vault.url = server.url() + vol = f"privateer_keys_{rand_str()}" + cfg.clients[0].key_volume = vol + keygen_all(cfg) + configure(cfg, "bob") + backup(cfg, "bob", "data") + + image = f"mrcide/privateer-client:{cfg.tag}" + command = [ + "rsync", + "-av", + "--delete", + "/privateer/data", + "alice:/privateer/bob", + ] + mounts = [ + docker.types.Mount( + "/run/privateer", vol, type="volume", read_only=True + ), + docker.types.Mount( + "/privateer/data", "data", type="volume", read_only=True + ), + ] + assert mock_run.call_count == 1 + assert mock_run.call_args == call( + "Backup", image, command=command, mounts=mounts + ) diff --git a/tests/test_restore.py b/tests/test_restore.py index 4eaafb2..4c25af0 100644 --- a/tests/test_restore.py +++ b/tests/test_restore.py @@ -1,8 +1,9 @@ -import pytest +from unittest.mock import MagicMock, call + import vault_dev import docker -import privateer2.server +import privateer2.restore from privateer2.config import read_config from privateer2.keys import configure, keygen_all from privateer2.restore import restore @@ -30,3 +31,37 @@ def test_can_print_instructions_to_run_restore(capsys): ) assert cmd in lines docker.from_env().volumes.get(vol).remove() + + +def test_can_run_restore(monkeypatch): + mock_run = MagicMock() + monkeypatch.setattr(privateer2.restore, "run_docker_command", mock_run) + with vault_dev.Server(export_token=True) as server: + cfg = read_config("example/simple.json") + cfg.vault.url = server.url() + vol = f"privateer_keys_{rand_str()}" + cfg.clients[0].key_volume = vol + keygen_all(cfg) + configure(cfg, "bob") + restore(cfg, "bob", "data") + + image = f"mrcide/privateer-client:{cfg.tag}" + command = [ + "rsync", + "-av", + "--delete", + "alice:/privateer/bob/data/", + "/privateer/data/", + ] + mounts = [ + docker.types.Mount( + "/run/privateer", vol, type="volume", read_only=True + ), + docker.types.Mount( + "/privateer/data", "data", type="volume", read_only=False + ), + ] + assert mock_run.call_count == 1 + assert mock_run.call_args == call( + "Restore", image, command=command, mounts=mounts + ) From ce7b33244a09cc0ce995af329f85857a7ce69caf Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Tue, 17 Oct 2023 18:16:54 +0100 Subject: [PATCH 43/87] Tidy up docs --- .gitignore | 1 + README.md | 25 +++++++++++++++++++------ src/privateer2/cli.py | 1 - 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index 4954067..4214466 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ coverage.xml *.tar .privateer_identity tmp/ +.coverage.* diff --git a/README.md b/README.md index d1f382d..b44e8f9 100644 --- a/README.md +++ b/README.md @@ -31,23 +31,36 @@ After writing a configuration, on any machine run privateer2 keygen --all ``` -which will generate ssh keypairs for all machines and put them in the vault. -Then, on each machine run +which will generate ssh keypairs for all machines and put them in the vault. This only needs to be done once, but you might need to run it again if +* you add more machines to your system +* you want to rotate keys + +Once keys are written to the vault, on each machine run ``` privateer2 configure ``` -replacing `` with the name of the machine within either the `servers` or `clients` section of your configuration. This sets up a special docker volume that will persist ssh keys and configurations so that communication between clients and servers is straightforward and secure. +replacing `` with the name of the machine within either the `servers` or `clients` section of your configuration. This sets up a special docker volume that will persist ssh keys and configurations so that communication between clients and servers is straightforward and secure. It also leaves a file `.privateer_identity` at the same location as the configuration file, which is used as the default identity for subsequent commands. Typically this is what you want. + +### Manual backup + +``` +privateer backup +``` + +Add `--dry-run` to see the commands to run it yourself + +### Restore -You can run +Restoration is always manual ``` -privateer2 status +privateer2 restore [--server=NAME] [--source=NAME] ``` -which prints information about the current setup. +where `--server` controls the server you are pulling from (if you have more than one configured) and `--source` controls the original machine that backed the data up (if more than one machine is pushing backups). ## Installation diff --git a/src/privateer2/cli.py b/src/privateer2/cli.py index 145aceb..26f64a2 100644 --- a/src/privateer2/cli.py +++ b/src/privateer2/cli.py @@ -3,7 +3,6 @@ privateer2 [options] pull privateer2 [options] keygen ( | --all) privateer2 [options] configure - privateer2 [options] status privateer2 [options] check privateer2 [options] server (start | stop | status) privateer2 [options] backup From c293b9466f0cfdcb036be8a650dfe17718d5172d Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Tue, 17 Oct 2023 18:44:02 +0100 Subject: [PATCH 44/87] Expand testing --- src/privateer2/util.py | 25 +++++++------- tests/test_util.py | 75 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 13 deletions(-) diff --git a/src/privateer2/util.py b/src/privateer2/util.py index 57e1991..c83f197 100644 --- a/src/privateer2/util.py +++ b/src/privateer2/util.py @@ -156,8 +156,9 @@ def rand_str(n=8): def log_tail(container, n): logs = container.logs().decode("utf-8").strip().split("\n") if len(logs) > n: - print(f"(ommitting {len(logs) - n} lines of logs)") - print("\n".join(logs[-n:])) + return [f"(ommitting {len(logs) - n} lines of logs)"] + logs[-n:] + else: + return logs def mounts_str(mounts): @@ -192,7 +193,7 @@ def isotimestamp(): return now.strftime("%Y%m%d-%H%M%S") -def take_ownership(filename, directory, *, command_only=False): +def take_ownership(filename, directory, *, command_only=False): # tar uid = os.geteuid() gid = os.getegid() cl = docker.from_env() @@ -215,21 +216,21 @@ def take_ownership(filename, directory, *, command_only=False): ) -def run_docker_command(name, image, **kwargs): +def run_docker_command(display, image, **kwargs): ensure_image(image) client = docker.from_env() container = client.containers.run(image, **kwargs, detach=True) - print(f"{name} command started. To stream progress, run:") + print(f"{display} command started. To stream progress, run:") print(f" docker logs -f {container.name}") result = container.wait() if result["StatusCode"] == 0: - print(f"{name} completed successfully! Container logs:") - log_tail(container, 10) + print(f"{display} completed successfully! Container logs:") + print("\n".join(log_tail(container, 10))) container.remove() else: print("An error occured! Container logs:") - log_tail(container, 20) - msg = f"{name} failed; see {container.name} logs for details" + print("\n".join(log_tail(container, 20))) + msg = f"{display} failed; see {container.name} logs for details" raise Exception(msg) @@ -237,9 +238,7 @@ def run_docker_command(name, image, **kwargs): def transient_working_directory(path): origin = os.getcwd() try: - if path is not None: - os.chdir(path) + os.chdir(path) yield finally: - if path is not None: - os.chdir(origin) + os.chdir(origin) diff --git a/tests/test_util.py b/tests/test_util.py index c762c05..de0099d 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -4,6 +4,7 @@ import pytest +import docker import privateer2.util @@ -44,3 +45,77 @@ def test_can_match_values(): def test_can_format_timestamp(): assert re.match("^[0-9]{8}-[0-9]{6}", privateer2.util.isotimestamp()) + + +def test_can_pull_image_if_required(): + def image_exists(name): + cl = docker.from_env() + try: + cl.images.get(name) + return True + except docker.errors.NotFound: + return False + + cl = docker.from_env() + if image_exists("alpine"): + cl.images.get("alpine").remove() + assert not image_exists("alpine") + privateer2.util.ensure_image("alpine") + assert image_exists("alpine") + + +def test_can_tail_logs_from_container(): + privateer2.util.ensure_image("alpine") + name = f"tmp_{privateer2.util.rand_str()}" + command = ["seq", "1", "10"] + cl = docker.from_env() + cl.containers.run("alpine", name=name, command=command) + assert privateer2.util.log_tail(cl.containers.get(name), 5) == [ + "(ommitting 5 lines of logs)", + "6", + "7", + "8", + "9", + "10", + ] + assert privateer2.util.log_tail(cl.containers.get(name), 100) == [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10", + ] + + +def test_can_run_long_command(capsys): + name = f"tmp_{privateer2.util.rand_str()}" + command = ["seq", "1", "3"] + privateer2.util.run_docker_command( + "Test", "alpine", name=name, command=command + ) + out = capsys.readouterr().out + lines = out.strip().split("\n") + assert lines[0] == "Test command started. To stream progress, run:" + assert lines[1] == f" docker logs -f {name}" + assert lines[2] == "Test completed successfully! Container logs:" + assert lines[3:] == ["1", "2", "3"] + + +def test_can_run_failing_command(capsys): + name = f"tmp_{privateer2.util.rand_str()}" + command = ["false"] + msg = f"Test failed; see {name} logs for details" + with pytest.raises(Exception, match=msg): + privateer2.util.run_docker_command( + "Test", "alpine", name=name, command=command + ) + out = capsys.readouterr().out + lines = out.strip().split("\n") + assert lines[0] == "Test command started. To stream progress, run:" + assert lines[1] == f" docker logs -f {name}" + assert lines[2] == "An error occured! Container logs:" From 1c11d193b3d4d10da35457635b33c595b3635e63 Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Wed, 18 Oct 2023 08:34:42 +0100 Subject: [PATCH 45/87] More utility tests --- tests/test_util.py | 53 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 47 insertions(+), 6 deletions(-) diff --git a/tests/test_util.py b/tests/test_util.py index de0099d..d0172ba 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -53,15 +53,15 @@ def image_exists(name): try: cl.images.get(name) return True - except docker.errors.NotFound: + except docker.errors.ImageNotFound: return False cl = docker.from_env() - if image_exists("alpine"): - cl.images.get("alpine").remove() - assert not image_exists("alpine") - privateer2.util.ensure_image("alpine") - assert image_exists("alpine") + if image_exists("hello-world:latest"): + cl.images.get("hello-world:latest").remove() # pragma: no cover + assert not image_exists("hello-world:latest") + privateer2.util.ensure_image("hello-world:latest") + assert image_exists("hello-world:latest") def test_can_tail_logs_from_container(): @@ -119,3 +119,44 @@ def test_can_run_failing_command(capsys): assert lines[0] == "Test command started. To stream progress, run:" assert lines[1] == f" docker logs -f {name}" assert lines[2] == "An error occured! Container logs:" + + +def test_can_detect_if_volume_exists(): + name = f"tmp_{privateer2.util.rand_str()}" + cl = docker.from_env() + cl.volumes.create(name) + assert privateer2.util.volume_exists(name) + cl.volumes.get(name).remove() + assert not privateer2.util.volume_exists(name) + + +def test_can_take_ownership_of_a_file(tmp_path): + cl = docker.from_env() + mounts = [docker.types.Mount("/src", str(tmp_path), type="bind")] + command = ["touch", "/src/newfile"] + cl.containers.run("ubuntu", mounts=mounts, command=command) + path = tmp_path / "newfile" + info = os.stat(path) + assert info.st_uid == 0 + assert info.st_gid == 0 + uid = os.geteuid() + gid = os.getegid() + cmd = privateer2.util.take_ownership( + "newfile", str(tmp_path), command_only=True + ) + expected = [ + "docker", + "run", + *privateer2.util.mounts_str(mounts), + "-w", + "/src", + "alpine", + "chown", + f"{uid}.{gid}", + "newfile", + ] + assert cmd == expected + privateer2.util.take_ownership("newfile", str(tmp_path)) + info = os.stat(path) + assert info.st_uid == uid + assert info.st_gid == gid From e536299d17d010a9e29ff1360b0be2fedaa21ae6 Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Wed, 18 Oct 2023 08:52:40 +0100 Subject: [PATCH 46/87] Share approach from containers to volumes --- src/privateer2/util.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/privateer2/util.py b/src/privateer2/util.py index c83f197..646cd19 100644 --- a/src/privateer2/util.py +++ b/src/privateer2/util.py @@ -141,12 +141,14 @@ def container_if_exists(name): def volume_exists(name): - cl = docker.from_env() + return bool(volume_if_exists(name)) + + +def volume_if_exists(name): try: - cl.volumes.get(name) - return True + return docker.from_env().volumes.get(name) except docker.errors.NotFound: - return False + return None def rand_str(n=8): From da9b62505acfcb9720f6cec52b2b917dc4029b7b Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Wed, 18 Oct 2023 08:53:24 +0100 Subject: [PATCH 47/87] New docker fixture --- tests/conftest.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 tests/conftest.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..ad7ed18 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,30 @@ +import pytest + +from privateer2.util import ( + container_if_exists, + match_value, + rand_str, + volume_if_exists, +) + + +@pytest.fixture +def managed_docker(): + created = {"container": [], "volume": []} + + def _new(what, *, prefix="privateer_test"): + match_value(what, ["container", "volume"], "what") + name = f"{prefix}_{rand_str()}" + created[what].append(name) + return name + + yield _new + + for name in created["container"]: + container = container_if_exists(name) + if container: + container.remove() + for name in created["volume"]: + volume = volume_if_exists(name) + if volume: + volume.remove() From be20b851721407c082459a3d9daf846e3d531a1d Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Wed, 18 Oct 2023 09:18:38 +0100 Subject: [PATCH 48/87] Isolate tests with fixtures --- src/privateer2/util.py | 6 ++- tests/test_backup.py | 10 ++-- tests/test_keys.py | 32 +++++++------ tests/test_restore.py | 10 ++-- tests/test_server.py | 102 ++++++++++++++++++++++++++--------------- tests/test_util.py | 21 +++++---- 6 files changed, 107 insertions(+), 74 deletions(-) diff --git a/src/privateer2/util.py b/src/privateer2/util.py index 646cd19..51e4d7a 100644 --- a/src/privateer2/util.py +++ b/src/privateer2/util.py @@ -214,7 +214,11 @@ def take_ownership(filename, directory, *, command_only=False): # tar ] else: cl.containers.run( - "alpine", mounts=mounts, working_dir="/src", command=command + "alpine", + mounts=mounts, + working_dir="/src", + command=command, + remove=True, ) diff --git a/tests/test_backup.py b/tests/test_backup.py index a1b8329..f2d2510 100644 --- a/tests/test_backup.py +++ b/tests/test_backup.py @@ -7,14 +7,13 @@ from privateer2.backup import backup from privateer2.config import read_config from privateer2.keys import configure, keygen_all -from privateer2.util import rand_str -def test_can_print_instructions_to_run_backup(capsys): +def test_can_print_instructions_to_run_backup(capsys, managed_docker): with vault_dev.Server(export_token=True) as server: cfg = read_config("example/simple.json") cfg.vault.url = server.url() - vol = f"privateer_keys_{rand_str()}" + vol = managed_docker("volume") cfg.clients[0].key_volume = vol keygen_all(cfg) configure(cfg, "bob") @@ -30,16 +29,15 @@ def test_can_print_instructions_to_run_backup(capsys): "rsync -av --delete /privateer/data alice:/privateer/bob" ) assert cmd in lines - docker.from_env().volumes.get(vol).remove() -def test_can_run_backup(monkeypatch): +def test_can_run_backup(monkeypatch, managed_docker): mock_run = MagicMock() monkeypatch.setattr(privateer2.backup, "run_docker_command", mock_run) with vault_dev.Server(export_token=True) as server: cfg = read_config("example/simple.json") cfg.vault.url = server.url() - vol = f"privateer_keys_{rand_str()}" + vol = managed_docker("volume") cfg.clients[0].key_volume = vol keygen_all(cfg) configure(cfg, "bob") diff --git a/tests/test_keys.py b/tests/test_keys.py index 8774555..1ec9963 100644 --- a/tests/test_keys.py +++ b/tests/test_keys.py @@ -4,7 +4,7 @@ import docker from privateer2.config import read_config from privateer2.keys import _keys_data, check, configure, keygen, keygen_all -from privateer2.util import rand_str, string_from_volume +from privateer2.util import string_from_volume def test_can_create_keys(): @@ -44,18 +44,22 @@ def test_can_generate_client_keys_data(): ) -def test_can_unpack_keys_for_server(): +def test_can_unpack_keys_for_server(managed_docker): with vault_dev.Server(export_token=True) as server: cfg = read_config("example/simple.json") cfg.vault.url = server.url() - vol = f"privateer_keys_{rand_str()}" + vol = managed_docker("volume") cfg.servers[0].key_volume = vol keygen_all(cfg) configure(cfg, "alice") client = docker.from_env() mounts = [docker.types.Mount("/keys", vol, type="volume")] + name = managed_docker("container") res = client.containers.run( - "alpine", mounts=mounts, command=["ls", "/keys"], remove=True + "alpine", + mounts=mounts, + command=["ls", "/keys"], + name=name, ) assert set(res.decode("UTF-8").strip().split("\n")) == { "authorized_keys", @@ -64,21 +68,24 @@ def test_can_unpack_keys_for_server(): "name", } assert string_from_volume(vol, "name") == "alice" - client.volumes.get(vol).remove() -def test_can_unpack_keys_for_client(): +def test_can_unpack_keys_for_client(managed_docker): with vault_dev.Server(export_token=True) as server: cfg = read_config("example/simple.json") cfg.vault.url = server.url() - vol = f"privateer_keys_{rand_str()}" + vol = managed_docker("volume") cfg.clients[0].key_volume = vol keygen_all(cfg) configure(cfg, "bob") client = docker.from_env() mounts = [docker.types.Mount("/keys", vol, type="volume")] + name = managed_docker("container") res = client.containers.run( - "alpine", mounts=mounts, command=["ls", "/keys"], remove=True + "alpine", + mounts=mounts, + command=["ls", "/keys"], + name=name, ) assert set(res.decode("UTF-8").strip().split("\n")) == { "known_hosts", @@ -93,14 +100,13 @@ def test_can_unpack_keys_for_client(): cfg.servers[0].key_volume = vol with pytest.raises(Exception, match=msg): check(cfg, "alice") - client.volumes.get(vol).remove() -def test_can_check_quietly(capsys): +def test_can_check_quietly(capsys, managed_docker): with vault_dev.Server(export_token=True) as server: cfg = read_config("example/simple.json") cfg.vault.url = server.url() - vol = f"privateer_keys_{rand_str()}" + vol = managed_docker("volume") cfg.servers[0].key_volume = vol keygen_all(cfg) configure(cfg, "alice") @@ -112,11 +118,11 @@ def test_can_check_quietly(capsys): assert out_loud.out == f"Volume '{vol}' looks configured as 'alice'\n" -def test_error_on_check_if_unconfigured(): +def test_error_on_check_if_unconfigured(managed_docker): with vault_dev.Server(export_token=True) as server: cfg = read_config("example/simple.json") cfg.vault.url = server.url() - vol = f"privateer_keys_{rand_str()}" + vol = managed_docker("volume") cfg.servers[0].key_volume = vol with pytest.raises(Exception, match="'alice' looks unconfigured"): check(cfg, "alice") diff --git a/tests/test_restore.py b/tests/test_restore.py index 4c25af0..5056f9c 100644 --- a/tests/test_restore.py +++ b/tests/test_restore.py @@ -7,14 +7,13 @@ from privateer2.config import read_config from privateer2.keys import configure, keygen_all from privateer2.restore import restore -from privateer2.util import rand_str -def test_can_print_instructions_to_run_restore(capsys): +def test_can_print_instructions_to_run_restore(capsys, managed_docker): with vault_dev.Server(export_token=True) as server: cfg = read_config("example/simple.json") cfg.vault.url = server.url() - vol = f"privateer_keys_{rand_str()}" + vol = managed_docker("volume") cfg.clients[0].key_volume = vol keygen_all(cfg) configure(cfg, "bob") @@ -30,16 +29,15 @@ def test_can_print_instructions_to_run_restore(capsys): "rsync -av --delete alice:/privateer/bob/data/ /privateer/data/" ) assert cmd in lines - docker.from_env().volumes.get(vol).remove() -def test_can_run_restore(monkeypatch): +def test_can_run_restore(monkeypatch, managed_docker): mock_run = MagicMock() monkeypatch.setattr(privateer2.restore, "run_docker_command", mock_run) with vault_dev.Server(export_token=True) as server: cfg = read_config("example/simple.json") cfg.vault.url = server.url() - vol = f"privateer_keys_{rand_str()}" + vol = managed_docker("volume") cfg.clients[0].key_volume = vol keygen_all(cfg) configure(cfg, "bob") diff --git a/tests/test_server.py b/tests/test_server.py index 473a679..92d86c7 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -3,20 +3,22 @@ import pytest import vault_dev -import docker import privateer2.server from privateer2.config import read_config from privateer2.keys import configure, keygen_all from privateer2.server import server_start, server_status, server_stop -from privateer2.util import rand_str -def test_can_print_instructions_to_start_server(capsys): +def test_can_print_instructions_to_start_server(capsys, managed_docker): with vault_dev.Server(export_token=True) as server: cfg = read_config("example/simple.json") cfg.vault.url = server.url() - vol = f"privateer_keys_{rand_str()}" - cfg.servers[0].key_volume = vol + vol_keys = managed_docker("volume") + vol_data = managed_docker("volume") + name = managed_docker("container") + cfg.servers[0].key_volume = vol_keys + cfg.servers[0].data_volume = vol_data + cfg.servers[0].container = name keygen_all(cfg) configure(cfg, "alice") capsys.readouterr() # flush previous output @@ -25,22 +27,25 @@ def test_can_print_instructions_to_start_server(capsys): lines = out.out.strip().split("\n") assert "Command to manually launch server:" in lines cmd = ( - " docker run --rm -d --name privateer_server " - f"-v {vol}:/run/privateer:ro -v privateer_data:/privateer " + f" docker run --rm -d --name {name} " + f"-v {vol_keys}:/run/privateer:ro -v {vol_data}:/privateer " "-p 10022:22 mrcide/privateer-server:docker" ) assert cmd in lines - docker.from_env().volumes.get(vol).remove() -def test_can_start_server(monkeypatch): +def test_can_start_server(monkeypatch, managed_docker): mock_docker = MagicMock() monkeypatch.setattr(privateer2.server, "docker", mock_docker) with vault_dev.Server(export_token=True) as server: cfg = read_config("example/simple.json") cfg.vault.url = server.url() - vol = f"privateer_keys_{rand_str()}" - cfg.servers[0].key_volume = vol + vol_keys = managed_docker("volume") + vol_data = managed_docker("volume") + name = managed_docker("container") + cfg.servers[0].key_volume = vol_keys + cfg.servers[0].data_volume = vol_data + cfg.servers[0].container = name keygen_all(cfg) configure(cfg, "alice") server_start(cfg, "alice") @@ -52,27 +57,33 @@ def test_can_start_server(monkeypatch): f"mrcide/privateer-server:{cfg.tag}", auto_remove=True, detach=True, - name="privateer_server", + name=name, mounts=[mount.return_value, mount.return_value], ports={"22/tcp": 10022}, ) assert mount.call_count == 2 assert mount.call_args_list[0] == call( - "/run/privateer", vol, type="volume", read_only=True + "/run/privateer", vol_keys, type="volume", read_only=True ) assert mount.call_args_list[1] == call( - "/privateer", "privateer_data", type="volume" + "/privateer", vol_data, type="volume" ) -def test_can_start_server_with_local_volume(monkeypatch): +def test_can_start_server_with_local_volume(monkeypatch, managed_docker): mock_docker = MagicMock() monkeypatch.setattr(privateer2.server, "docker", mock_docker) with vault_dev.Server(export_token=True) as server: cfg = read_config("example/local.json") cfg.vault.url = server.url() - vol = f"privateer_keys_{rand_str()}" - cfg.servers[0].key_volume = vol + vol_keys = managed_docker("volume") + vol_data = managed_docker("volume") + vol_other = managed_docker("volume") + name = managed_docker("container") + cfg.servers[0].key_volume = vol_keys + cfg.servers[0].data_volume = vol_data + cfg.servers[0].container = name + cfg.volumes[1].name = vol_other keygen_all(cfg) configure(cfg, "alice") server_start(cfg, "alice") @@ -82,43 +93,50 @@ def test_can_start_server_with_local_volume(monkeypatch): mount = mock_docker.types.Mount assert mount.call_count == 3 assert mount.call_args_list[0] == call( - "/run/privateer", vol, type="volume", read_only=True + "/run/privateer", vol_keys, type="volume", read_only=True ) assert mount.call_args_list[1] == call( - "/privateer", "privateer_data_alice", type="volume" + "/privateer", vol_data, type="volume" ) assert mount.call_args_list[2] == call( - "/privateer/local/other", "other", type="volume", read_only=True + f"/privateer/local/{vol_other}", + vol_other, + type="volume", + read_only=True, ) assert client.containers.run.call_args == call( f"mrcide/privateer-server:{cfg.tag}", auto_remove=True, detach=True, - name="privateer_server", + name=name, mounts=[mount.return_value, mount.return_value, mount.return_value], ports={"22/tcp": 10022}, ) -def test_throws_if_container_already_exists(monkeypatch): +def test_throws_if_container_already_exists(monkeypatch, managed_docker): mock_ce = MagicMock() # container exists? mock_ce.return_value = True monkeypatch.setattr(privateer2.server, "container_exists", mock_ce) with vault_dev.Server(export_token=True) as server: - cfg = read_config("example/local.json") + cfg = read_config("example/simple.json") cfg.vault.url = server.url() - vol = f"privateer_keys_{rand_str()}" - cfg.servers[0].key_volume = vol + vol_keys = managed_docker("volume") + vol_data = managed_docker("volume") + name = managed_docker("container") + cfg.servers[0].key_volume = vol_keys + cfg.servers[0].data_volume = vol_data + cfg.servers[0].container = name keygen_all(cfg) configure(cfg, "alice") - msg = "Container 'privateer_server' for 'alice' already running" + msg = f"Container '{name}' for 'alice' already running" with pytest.raises(Exception, match=msg): server_start(cfg, "alice") assert mock_ce.call_count == 1 - mock_ce.assert_called_with("privateer_server") + mock_ce.assert_called_with(name) -def test_can_stop_server(monkeypatch): +def test_can_stop_server(monkeypatch, managed_docker): mock_container = MagicMock() mock_container.status = "running" mock_container_if_exists = MagicMock(return_value=mock_container) @@ -128,16 +146,20 @@ def test_can_stop_server(monkeypatch): mock_container_if_exists, ) with vault_dev.Server(export_token=True) as server: - cfg = read_config("example/local.json") + cfg = read_config("example/simple.json") cfg.vault.url = server.url() - vol = f"privateer_keys_{rand_str()}" - cfg.servers[0].key_volume = vol + vol_keys = managed_docker("volume") + vol_data = managed_docker("volume") + name = managed_docker("container") + cfg.servers[0].key_volume = vol_keys + cfg.servers[0].data_volume = vol_data + cfg.servers[0].container = name keygen_all(cfg) configure(cfg, "alice") server_stop(cfg, "alice") assert mock_container_if_exists.call_count == 1 - assert mock_container_if_exists.call_args == call("privateer_server") + assert mock_container_if_exists.call_args == call(name) assert mock_container.stop.call_count == 1 assert mock_container.stop.call_args == call() @@ -152,7 +174,7 @@ def test_can_stop_server(monkeypatch): assert mock_container.stop.call_count == 1 -def test_can_get_server_status(monkeypatch, capsys): +def test_can_get_server_status(monkeypatch, capsys, managed_docker): mock_container = MagicMock() mock_container.status = "running" mock_container_if_exists = MagicMock(return_value=mock_container) @@ -162,20 +184,24 @@ def test_can_get_server_status(monkeypatch, capsys): mock_container_if_exists, ) with vault_dev.Server(export_token=True) as server: - cfg = read_config("example/local.json") + cfg = read_config("example/simple.json") cfg.vault.url = server.url() - vol = f"privateer_keys_{rand_str()}" - cfg.servers[0].key_volume = vol + vol_keys = managed_docker("volume") + vol_data = managed_docker("volume") + name = managed_docker("container") + cfg.servers[0].key_volume = vol_keys + cfg.servers[0].data_volume = vol_data + cfg.servers[0].container = name keygen_all(cfg) configure(cfg, "alice") capsys.readouterr() # flush previous output - prefix = f"Volume '{vol}' looks configured as 'alice'" + prefix = f"Volume '{vol_keys}' looks configured as 'alice'" server_status(cfg, "alice") assert mock_container_if_exists.call_count == 1 - assert mock_container_if_exists.call_args == call("privateer_server") + assert mock_container_if_exists.call_args == call(name) assert capsys.readouterr().out == f"{prefix}\nrunning\n" mock_container.status = "exited" diff --git a/tests/test_util.py b/tests/test_util.py index d0172ba..b9f1caf 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -64,9 +64,9 @@ def image_exists(name): assert image_exists("hello-world:latest") -def test_can_tail_logs_from_container(): +def test_can_tail_logs_from_container(managed_docker): privateer2.util.ensure_image("alpine") - name = f"tmp_{privateer2.util.rand_str()}" + name = managed_docker("container") command = ["seq", "1", "10"] cl = docker.from_env() cl.containers.run("alpine", name=name, command=command) @@ -92,8 +92,8 @@ def test_can_tail_logs_from_container(): ] -def test_can_run_long_command(capsys): - name = f"tmp_{privateer2.util.rand_str()}" +def test_can_run_long_command(capsys, managed_docker): + name = managed_docker("container") command = ["seq", "1", "3"] privateer2.util.run_docker_command( "Test", "alpine", name=name, command=command @@ -106,8 +106,8 @@ def test_can_run_long_command(capsys): assert lines[3:] == ["1", "2", "3"] -def test_can_run_failing_command(capsys): - name = f"tmp_{privateer2.util.rand_str()}" +def test_can_run_failing_command(capsys, managed_docker): + name = managed_docker("container") command = ["false"] msg = f"Test failed; see {name} logs for details" with pytest.raises(Exception, match=msg): @@ -121,8 +121,8 @@ def test_can_run_failing_command(capsys): assert lines[2] == "An error occured! Container logs:" -def test_can_detect_if_volume_exists(): - name = f"tmp_{privateer2.util.rand_str()}" +def test_can_detect_if_volume_exists(managed_docker): + name = managed_docker("volume") cl = docker.from_env() cl.volumes.create(name) assert privateer2.util.volume_exists(name) @@ -130,11 +130,12 @@ def test_can_detect_if_volume_exists(): assert not privateer2.util.volume_exists(name) -def test_can_take_ownership_of_a_file(tmp_path): +def test_can_take_ownership_of_a_file(tmp_path, managed_docker): cl = docker.from_env() mounts = [docker.types.Mount("/src", str(tmp_path), type="bind")] command = ["touch", "/src/newfile"] - cl.containers.run("ubuntu", mounts=mounts, command=command) + name = managed_docker("container") + cl.containers.run("ubuntu", name=name, mounts=mounts, command=command) path = tmp_path / "newfile" info = os.stat(path) assert info.st_uid == 0 From 99aedd5539dab25f8ba7e68dfd56ec1711b5f333 Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Wed, 18 Oct 2023 10:51:40 +0100 Subject: [PATCH 49/87] Add connection test --- src/privateer2/cli.py | 5 +++-- src/privateer2/keys.py | 33 ++++++++++++++++++++++++++++++++- src/privateer2/server.py | 11 +++++++++++ tests/test_cli.py | 6 ++++++ tests/test_keys.py | 35 +++++++++++++++++++++++++++++++++++ 5 files changed, 87 insertions(+), 3 deletions(-) diff --git a/src/privateer2/cli.py b/src/privateer2/cli.py index 26f64a2..3151d3d 100644 --- a/src/privateer2/cli.py +++ b/src/privateer2/cli.py @@ -3,7 +3,7 @@ privateer2 [options] pull privateer2 [options] keygen ( | --all) privateer2 [options] configure - privateer2 [options] check + privateer2 [options] check [--connection] privateer2 [options] server (start | stop | status) privateer2 [options] backup privateer2 [options] restore [--server=NAME] [--source=NAME] @@ -137,7 +137,8 @@ def _parse_opts(opts): else: name = _find_identity(opts["--as"], root_config) if opts["check"]: - return Call(check, cfg=cfg, name=name) + connection = opts["--connection"] + return Call(check, cfg=cfg, name=name, connection=connection) elif opts["server"]: if opts["start"]: return Call(server_start, cfg=cfg, name=name, dry_run=dry_run) diff --git a/src/privateer2/keys.py b/src/privateer2/keys.py index cbb3272..0b23c32 100644 --- a/src/privateer2/keys.py +++ b/src/privateer2/keys.py @@ -58,7 +58,7 @@ def configure(cfg, name): string_to_volume(name, vol, "name", uid=0, gid=0) -def check(cfg, name, *, quiet=False): +def check(cfg, name, *, connection=False, quiet=False): machine = _machine_config(cfg, name) vol = machine.key_volume try: @@ -72,6 +72,8 @@ def check(cfg, name, *, quiet=False): raise Exception(msg) if not quiet: print(f"Volume '{vol}' looks configured as '{name}'") + if connection and name in cfg.list_clients(): + _check_connections(cfg, machine) return machine @@ -139,3 +141,32 @@ def _machine_config(cfg, name): valid_str = ", ".join(f"'{x}'" for x in valid) msg = f"Invalid configuration '{name}', must be one of {valid_str}" raise Exception(msg) + + +def _check_connections(cfg, machine): + image = f"mrcide/privateer-client:{cfg.tag}" + mounts = [ + docker.types.Mount( + "/run/privateer", machine.key_volume, type="volume", read_only=True + ) + ] + cl = docker.from_env() + result = {} + for server in cfg.servers: + print( + f"checking connection to '{server.name}' ({server.hostname})...", + end="", + flush=True, + ) + try: + command = ["ssh", server.name, "cat", "/run/privateer/name"] + cl.containers.run( + image, mounts=mounts, command=command, remove=True + ) + result[server.name] = True + print("OK") + except docker.errors.ContainerError as e: + result[server.name] = False + print("ERROR") + print(e.stderr.decode("utf-8").strip()) + return result diff --git a/src/privateer2/server.py b/src/privateer2/server.py index 9d0febd..be7f9a7 100644 --- a/src/privateer2/server.py +++ b/src/privateer2/server.py @@ -1,3 +1,5 @@ +from contextlib import contextmanager + import docker from privateer2.keys import check from privateer2.util import ( @@ -84,3 +86,12 @@ def server_status(cfg, name): print(container.status) else: print("not running") + + +@contextmanager +def transient_server(cfg, name): + server_start(cfg, name) + try: + yield + finally: + server_stop(cfg, name) diff --git a/tests/test_cli.py b/tests/test_cli.py index c67eee3..4a8ad29 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -96,6 +96,7 @@ def test_can_parse_check(tmp_path): assert res.kwargs == { "cfg": read_config("example/simple.json"), "name": "alice", + "connection": False, } path = str(tmp_path / "privateer.json") _parse_argv(["check", "--path", path]) @@ -103,6 +104,11 @@ def test_can_parse_check(tmp_path): assert _parse_argv(["check", "--path", path, "--as", "alice"]) == res res.kwargs["name"] = "bob" assert _parse_argv(["check", "--path", path, "--as", "bob"]) == res + res.kwargs["connection"] = True + assert ( + _parse_argv(["check", "--path", path, "--as", "bob", "--connection"]) + == res + ) def test_can_parse_server_start(tmp_path): diff --git a/tests/test_keys.py b/tests/test_keys.py index 1ec9963..cd9752b 100644 --- a/tests/test_keys.py +++ b/tests/test_keys.py @@ -1,9 +1,12 @@ +import platform + import pytest import vault_dev import docker from privateer2.config import read_config from privateer2.keys import _keys_data, check, configure, keygen, keygen_all +from privateer2.server import transient_server from privateer2.util import string_from_volume @@ -135,3 +138,35 @@ def test_error_on_check_if_unknown_machine(): msg = "Invalid configuration 'eve', must be one of 'alice', 'bob'" with pytest.raises(Exception, match=msg): check(cfg, "eve") + + +def test_can_test_connection(capsys, managed_docker): + with vault_dev.Server(export_token=True) as server: + cfg = read_config("example/simple.json") + cfg.vault.url = server.url() + vol_alice = managed_docker("volume") + cfg.servers[0].container = managed_docker("container") + cfg.servers[0].hostname = platform.node() + cfg.servers[0].data_volume = managed_docker("volume") + cfg.servers[0].key_volume = vol_alice + cfg.clients[0].key_volume = managed_docker("volume") + keygen_all(cfg) + configure(cfg, "alice") + configure(cfg, "bob") + capsys.readouterr() # flush capture so far + prefix = f"checking connection to 'alice' ({platform.node()})..." + + with transient_server(cfg, "alice"): + check(cfg, "bob", connection=True) + out_success = capsys.readouterr().out + assert f"{prefix}OK\n" in out_success + + check(cfg, "bob", connection=True) + out_fail = capsys.readouterr().out + assert f"{prefix}ERROR\n" in out_fail + + check(cfg, "alice", connection=True) + out_server = capsys.readouterr().out + # Never reports on connections, as alice is a server + expected = f"Volume '{vol_alice}' looks configured as 'alice'\n" + assert out_server == expected From 9594d3e9fb99cd0914ccbeda2f7207c7bd17f529 Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Wed, 18 Oct 2023 11:11:08 +0100 Subject: [PATCH 50/87] Expand development docs --- development.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/development.md b/development.md index 38a247c..8364fe9 100644 --- a/development.md +++ b/development.md @@ -46,6 +46,12 @@ Start the server, as a background process privateer2 --path tmp/privateer.json --as=alice server start ``` +Once `alice` is running, we can test this connection from `bob`: + +``` +privateer2 --path tmp/privateer.json --as=bob check --connection +``` + Create some random data within the `data` volume (this is the one that we want to send from `bob` to `alice`) ``` @@ -82,3 +88,7 @@ or see the commands to do this outselves: ``` privateer2 --path tmp/privateer.json --as=bob restore data --dry-run ``` + +## Writing tests + +We use a lot of global resources, so it's easy to leave behind volumes and containers (often exited) after running tests. At best this is lazy and messy, but at worst it creates hard-to-diagnose dependencies between tests. Try and create names for auto-cleaned volumes and containers using the `managed_docker` fixture (see [`tests/conftest.py`](tests/conftest.py) for details). From 3290070bc05be0ecb144972a513ac8207ce129bf Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Wed, 18 Oct 2023 11:11:16 +0100 Subject: [PATCH 51/87] Expand main docs --- README.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/README.md b/README.md index b44e8f9..02047b6 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,31 @@ privateer2 restore [--server=NAME] [--source=NAME] where `--server` controls the server you are pulling from (if you have more than one configured) and `--source` controls the original machine that backed the data up (if more than one machine is pushing backups). +## What's the problem anyway? + +[Docker volumes](https://docs.docker.com/storage/volumes/) are useful for abstracting away some persistant storage for an application. They're much nicer to use than bind mounts because they don't pollute the host sytem with immovable files (docker containers often running as root or with a uid different to the user running docker). The docker [docs describe some approaches to backup and restore](https://docs.docker.com/storage/volumes/#back-up-restore-or-migrate-data-volumes) but in practice this ignores many practical issues, especially when the volumes are large or off-site backup is important. + +We want to be able to syncronise a volume to another volume on a different machine; our setup looks like this: + +``` +bob alice ++-------------------+ +-----------------------+ +| | | | +| application | | | +| | | | | +| volume1 | | volume2 | +| | | ssh/ | | | +| privateer-client--=----------=---> privateer-server | +| | | rsync | | | +| keys | | keys | +| | | | ++-------------------+ +-----------------------+ +``` + +so in this case `bob` runs a privateer client which sends data over ssh+rsync to a server running on `alice`, eventually meaning that the data in `volume1` on `bob` is replicated to `volume2` on `alice`. This process uses a set of ssh keys that each client and server will hold in a `keys` volume. This means that they do not interact with any ssh systems on the host. Note that if `alice` is also running sshd, this backup process will use a *second* ssh connection. + +In addition, we will support point-in-time backups on `alice`, creating `tar` files of the volume onto disk that can be easily restored onto any host. + ## Installation ```console From 1eea639b2931614c2fb69c0a99337d9e20aa3ba2 Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Wed, 18 Oct 2023 11:18:58 +0100 Subject: [PATCH 52/87] Add extra cli test --- tests/test_cli.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_cli.py b/tests/test_cli.py index 4a8ad29..8c97e7b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -58,6 +58,11 @@ def test_can_parse_keygen_one(): } +def test_can_prevent_use_of_as_with_keygen(): + with pytest.raises(Exception, match="Don't use '--as' with 'keygen'"): + _parse_argv(["keygen", "--path=example/local.json", "--as", "x", "y"]) + + def test_can_parse_configure(): res = _parse_argv(["configure", "--path=example/simple.json", "alice"]) assert res.target == privateer2.cli._do_configure From eba7ca69c1e10ab32131a948ce1433664b650b0d Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Wed, 18 Oct 2023 11:16:16 +0100 Subject: [PATCH 53/87] Drop archive creation for now --- src/privateer2/cli.py | 28 ----------- src/privateer2/tar.py | 112 ------------------------------------------ tests/test_cli.py | 28 ----------- 3 files changed, 168 deletions(-) delete mode 100644 src/privateer2/tar.py diff --git a/src/privateer2/cli.py b/src/privateer2/cli.py index 3151d3d..b38d68c 100644 --- a/src/privateer2/cli.py +++ b/src/privateer2/cli.py @@ -7,8 +7,6 @@ privateer2 [options] server (start | stop | status) privateer2 [options] backup privateer2 [options] restore [--server=NAME] [--source=NAME] - privateer2 [options] export [--to-dir=PATH] [--source=NAME] - privateer2 [options] import Options: --path=PATH The path to the configuration (rather than privateer.json) @@ -19,11 +17,6 @@ In all the above '--as' (or ) refers to the name of the client or server being acted on; the machine we are generating keys for, configuring, checking, serving, backing up from or restoring to. - - Note that the 'import' subcommand is quite different and does not - interact with the configuration; it will reject options '--as' and - '--path'. If 'volume' exists already, it will fail, so this is - fairly safe. """ import os @@ -37,7 +30,6 @@ from privateer2.keys import check, configure, keygen, keygen_all from privateer2.restore import restore from privateer2.server import server_start, server_status, server_stop -from privateer2.tar import export_tar, import_tar def pull(cfg): @@ -104,16 +96,6 @@ def _parse_opts(opts): dry_run = opts["--dry-run"] name = opts["--as"] - if opts["import"]: - _dont_use("--as", opts, "import") - _dont_use("--path", opts, "import") - return Call( - import_tar, - volume=opts[""], - tarfile=opts[""], - dry_run=dry_run, - ) - path_config = opts["--path"] or "privateer.json" root_config = os.path.dirname(path_config) cfg = read_config(path_config) @@ -164,16 +146,6 @@ def _parse_opts(opts): source=opts["--source"], dry_run=dry_run, ) - elif opts["export"]: - return Call( - export_tar, - cfg=cfg, - name=name, - volume=opts[""], - to_dir=opts["--to-dir"], - source=opts["--source"], - dry_run=dry_run, - ) else: msg = "Invalid cli call -- privateer bug" raise Exception(msg) diff --git a/src/privateer2/tar.py b/src/privateer2/tar.py deleted file mode 100644 index f966bf3..0000000 --- a/src/privateer2/tar.py +++ /dev/null @@ -1,112 +0,0 @@ -import os - -import docker -from privateer2.config import find_source -from privateer2.keys import check -from privateer2.util import ( - isotimestamp, - mounts_str, - run_docker_command, - take_ownership, - volume_exists, -) - - -def export_tar(cfg, name, volume, *, to_dir=None, source=None, dry_run=False): - machine = check(cfg, name, quiet=True) - # TODO: check here that volume is either local, or that it is a - # backup target for anything. - source = find_source(cfg, volume, source) - image = f"mrcide/privateer-client:{cfg.tag}" - if to_dir is None: - export_path = os.getcwd() - else: - export_path = os.path.abspath(to_dir) - mounts = [ - docker.types.Mount("/export", export_path, type="bind"), - docker.types.Mount( - "/privateer", machine.data_volume, type="volume", read_only=True - ), - ] - tarfile = f"{source}-{volume}-{isotimestamp()}.tar" - working_dir = f"/privateer/{source}/{volume}" - command = ["tar", "-cpvf", f"/export/{tarfile}", "."] - if dry_run: - cmd = [ - "docker", - "run", - "--rm", - *mounts_str(mounts), - "-w", - working_dir, - image, - *command, - ] - print("Command to manually run export") - print() - print(f" {' '.join(cmd)}") - print() - print("(pay attention to the final '.' in the above command!)") - print() - print(f"This will data from the server '{name}' onto the host") - print(f"machine at '{export_path}' as '{tarfile}'.") - print(f"Data originally from '{source}'") - print() - print("Note that this file will have root ownership after creation") - print(f"You can fix that with 'sudo chown $(whoami) {tarfile}'") - print("or") - print() - cmd_own = take_ownership(tarfile, export_path, command_only=True) - print(f" {' '.join(cmd_own)}") - else: - run_docker_command( - "Export", - image, - command=command, - mounts=mounts, - working_dir=working_dir, - ) - print("Taking ownership of file") - take_ownership(tarfile, export_path) - print(f"Tar file ready at '{export_path}/{tarfile}'") - - -def import_tar(volume, tarfile, *, dry_run=False): - if volume_exists(volume): - msg = f"Volume '{volume}' already exists, please delete first" - raise Exception(msg) - if not os.path.exists(tarfile): - msg = f"Input file '{tarfile}' does not exist" - - image = "alpine" - tarfile = os.path.abspath(tarfile) - mounts = [ - docker.types.Mount("/src.tar", tarfile, type="bind", read_only=True), - docker.types.Mount("/privateer", volume, type="volume"), - ] - working_dir = "/privateer" - command = ["tar", "-xvf", "/src.tar"] - if dry_run: - cmd = [ - "docker", - "run", - "--rm", - *mounts_str(mounts), - "-w", - working_dir, - image, - *command, - ] - print("Command to manually run import") - print() - print(f" docker volume create {volume}") - print(f" {' '.join(cmd)}") - else: - docker.from_env().volumes.create(volume) - run_docker_command( - "Import", - image, - command=command, - mounts=mounts, - working_dir=working_dir, - ) diff --git a/tests/test_cli.py b/tests/test_cli.py index 8c97e7b..2257ac3 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -32,17 +32,6 @@ def test_can_parse_version(): assert res.kwargs == {} -def test_can_parse_import(): - res = _parse_argv(["import", "--dry-run", "f", "v"]) - assert res.target == privateer2.cli.import_tar - assert res.kwargs == {"volume": "v", "tarfile": "f", "dry_run": True} - assert not _parse_argv(["import", "f", "v"]).kwargs["dry_run"] - with pytest.raises(Exception, match="Don't use '--path' with 'import'"): - _parse_argv(["--path=privateer.json", "import", "f", "v"]) - with pytest.raises(Exception, match="Don't use '--as' with 'import'"): - _parse_argv(["--as=alice", "import", "f", "v"]) - - def test_can_parse_keygen_all(): res = _parse_argv(["keygen", "--path=example/simple.json", "--all"]) assert res.target == privateer2.cli.keygen_all @@ -205,23 +194,6 @@ def test_can_parse_complex_restore(tmp_path): } -def test_can_parse_export(tmp_path): - shutil.copy("example/simple.json", tmp_path / "privateer.json") - with open(tmp_path / ".privateer_identity", "w") as f: - f.write("alice\n") - with transient_working_directory(tmp_path): - res = _parse_argv(["export", "v"]) - assert res.target == privateer2.cli.export_tar - assert res.kwargs == { - "cfg": read_config("example/simple.json"), - "name": "alice", - "volume": "v", - "to_dir": None, - "source": None, - "dry_run": False, - } - - def test_error_if_unknown_identity(tmp_path): shutil.copy("example/simple.json", tmp_path / "privateer.json") msg = "Can't determine identity; did you forget to configure" From 173a17c64976eb2b732b099c2f132921d13e6ebe Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Wed, 18 Oct 2023 11:23:09 +0100 Subject: [PATCH 54/87] Shut down server --- development.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/development.md b/development.md index 8364fe9..bc50252 100644 --- a/development.md +++ b/development.md @@ -89,6 +89,12 @@ or see the commands to do this outselves: privateer2 --path tmp/privateer.json --as=bob restore data --dry-run ``` +Tear down the server with + +``` +privateer2 --path tmp/privateer.json --as=alice server stop +``` + ## Writing tests We use a lot of global resources, so it's easy to leave behind volumes and containers (often exited) after running tests. At best this is lazy and messy, but at worst it creates hard-to-diagnose dependencies between tests. Try and create names for auto-cleaned volumes and containers using the `managed_docker` fixture (see [`tests/conftest.py`](tests/conftest.py) for details). From dbb4ff9b1c39db432c49fbf50f76f396a0555d92 Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Wed, 18 Oct 2023 11:29:56 +0100 Subject: [PATCH 55/87] Try installing vault from git --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 5b7349e..f9b7522 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ python = "python3" dependencies = [ "coverage[toml]>=6.5", "pytest", - "vault-dev" + "git+https://github.com/vimc/vimc-dev.git@mrc-4644" ] [tool.hatch.envs.default.scripts] test = "pytest {args:tests}" From d5f624602ba607cbc5f7952b9d0f629e25f96011 Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Wed, 18 Oct 2023 11:46:55 +0100 Subject: [PATCH 56/87] Correct package-from-github syntax --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f9b7522..484ada3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ python = "python3" dependencies = [ "coverage[toml]>=6.5", "pytest", - "git+https://github.com/vimc/vimc-dev.git@mrc-4644" + "vault-dev@git+https://github.com/vimc/vault-dev#egg=mrc-4644" ] [tool.hatch.envs.default.scripts] test = "pytest {args:tests}" From dafd0b507b0cbf1711909affa0d51f1ba613173b Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Wed, 18 Oct 2023 11:51:56 +0100 Subject: [PATCH 57/87] Another attempt at versions --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 484ada3..98c947e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ python = "python3" dependencies = [ "coverage[toml]>=6.5", "pytest", - "vault-dev@git+https://github.com/vimc/vault-dev#egg=mrc-4644" + "vault-dev@git+https://github.com/vimc/vault-dev@mrc-4644" ] [tool.hatch.envs.default.scripts] test = "pytest {args:tests}" From 1b0082790a755135173f450310c6bf8867381b7b Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Wed, 18 Oct 2023 12:24:37 +0100 Subject: [PATCH 58/87] Install vault if required --- pyproject.toml | 2 +- tests/conftest.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 98c947e..2f7fa5b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ python = "python3" dependencies = [ "coverage[toml]>=6.5", "pytest", - "vault-dev@git+https://github.com/vimc/vault-dev@mrc-4644" + "vault-dev>=0.1.1" ] [tool.hatch.envs.default.scripts] test = "pytest {args:tests}" diff --git a/tests/conftest.py b/tests/conftest.py index ad7ed18..f5ccec8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,5 @@ import pytest +import vault_dev from privateer2.util import ( container_if_exists, @@ -8,6 +9,9 @@ ) +vault_dev.ensure_installed() + + @pytest.fixture def managed_docker(): created = {"container": [], "volume": []} From 10e2397d89edd973a47576447c059fd74a1a7576 Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Wed, 18 Oct 2023 12:27:52 +0100 Subject: [PATCH 59/87] Add tmate --- .github/workflows/test.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1e2f173..7f96205 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,6 +32,8 @@ jobs: run: | python -m pip install --upgrade pip pip install hatch + - name: Setup tmate session + uses: mxschmitt/action-tmate@v3 - name: Test run: | hatch run cov-ci From 58ea3714945e12d2d6f9c042e7b07256e21ca3f6 Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Wed, 18 Oct 2023 12:48:06 +0100 Subject: [PATCH 60/87] Skip test for now --- .github/workflows/test.yml | 3 +-- tests/test_keys.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7f96205..cb480da 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,6 +17,7 @@ jobs: run: runs-on: ubuntu-latest + timeout-minutes: 5 strategy: fail-fast: false matrix: @@ -32,8 +33,6 @@ jobs: run: | python -m pip install --upgrade pip pip install hatch - - name: Setup tmate session - uses: mxschmitt/action-tmate@v3 - name: Test run: | hatch run cov-ci diff --git a/tests/test_keys.py b/tests/test_keys.py index cd9752b..da022c7 100644 --- a/tests/test_keys.py +++ b/tests/test_keys.py @@ -139,7 +139,7 @@ def test_error_on_check_if_unknown_machine(): with pytest.raises(Exception, match=msg): check(cfg, "eve") - +@pytest.skipif("GITHUB_ACTIONS" in os.environ, reason="firewall issues?") def test_can_test_connection(capsys, managed_docker): with vault_dev.Server(export_token=True) as server: cfg = read_config("example/simple.json") From 89e16beefa643bf7304829d601d9722e571f8fe7 Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Wed, 18 Oct 2023 12:51:08 +0100 Subject: [PATCH 61/87] Correct skip syntax --- tests/test_keys.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_keys.py b/tests/test_keys.py index da022c7..6ff7392 100644 --- a/tests/test_keys.py +++ b/tests/test_keys.py @@ -1,3 +1,4 @@ +import os import platform import pytest @@ -139,7 +140,7 @@ def test_error_on_check_if_unknown_machine(): with pytest.raises(Exception, match=msg): check(cfg, "eve") -@pytest.skipif("GITHUB_ACTIONS" in os.environ, reason="firewall issues?") +@pytest.mark.skipif("GITHUB_ACTIONS" in os.environ, reason="firewall issues?") def test_can_test_connection(capsys, managed_docker): with vault_dev.Server(export_token=True) as server: cfg = read_config("example/simple.json") From 59f5f39c2011b310a4d19f10ced659816fcfcfb8 Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Wed, 18 Oct 2023 13:19:47 +0100 Subject: [PATCH 62/87] Fix lint --- tests/conftest.py | 1 - tests/test_keys.py | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index f5ccec8..7e741df 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,7 +8,6 @@ volume_if_exists, ) - vault_dev.ensure_installed() diff --git a/tests/test_keys.py b/tests/test_keys.py index 6ff7392..7c9a4c1 100644 --- a/tests/test_keys.py +++ b/tests/test_keys.py @@ -140,6 +140,7 @@ def test_error_on_check_if_unknown_machine(): with pytest.raises(Exception, match=msg): check(cfg, "eve") + @pytest.mark.skipif("GITHUB_ACTIONS" in os.environ, reason="firewall issues?") def test_can_test_connection(capsys, managed_docker): with vault_dev.Server(export_token=True) as server: From f16b601c311357db4edbadc1854c105992e63d32 Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Wed, 18 Oct 2023 13:50:22 +0100 Subject: [PATCH 63/87] Replace integration test with unit test --- src/privateer2/server.py | 9 ---- tests/test_keys.py | 108 ++++++++++++++++++++++++++++++--------- 2 files changed, 85 insertions(+), 32 deletions(-) diff --git a/src/privateer2/server.py b/src/privateer2/server.py index be7f9a7..513e730 100644 --- a/src/privateer2/server.py +++ b/src/privateer2/server.py @@ -86,12 +86,3 @@ def server_status(cfg, name): print(container.status) else: print("not running") - - -@contextmanager -def transient_server(cfg, name): - server_start(cfg, name) - try: - yield - finally: - server_stop(cfg, name) diff --git a/tests/test_keys.py b/tests/test_keys.py index 7c9a4c1..b303f64 100644 --- a/tests/test_keys.py +++ b/tests/test_keys.py @@ -1,3 +1,4 @@ +from unittest.mock import MagicMock, call import os import platform @@ -5,8 +6,9 @@ import vault_dev import docker +import privateer2.keys from privateer2.config import read_config -from privateer2.keys import _keys_data, check, configure, keygen, keygen_all +from privateer2.keys import _check_connections, _keys_data, check, configure, keygen, keygen_all from privateer2.server import transient_server from privateer2.util import string_from_volume @@ -141,34 +143,94 @@ def test_error_on_check_if_unknown_machine(): check(cfg, "eve") -@pytest.mark.skipif("GITHUB_ACTIONS" in os.environ, reason="firewall issues?") -def test_can_test_connection(capsys, managed_docker): +def test_can_check_connections(capsys, monkeypatch, managed_docker): + mock_docker = MagicMock() + monkeypatch.setattr(privateer2.keys, "docker", mock_docker) with vault_dev.Server(export_token=True) as server: cfg = read_config("example/simple.json") cfg.vault.url = server.url() - vol_alice = managed_docker("volume") - cfg.servers[0].container = managed_docker("container") - cfg.servers[0].hostname = platform.node() - cfg.servers[0].data_volume = managed_docker("volume") - cfg.servers[0].key_volume = vol_alice - cfg.clients[0].key_volume = managed_docker("volume") + vol_keys_bob = managed_docker("volume") + cfg.servers[0].key_volume = managed_docker("volume") + cfg.clients[0].key_volume = vol_keys_bob keygen_all(cfg) - configure(cfg, "alice") configure(cfg, "bob") - capsys.readouterr() # flush capture so far - prefix = f"checking connection to 'alice' ({platform.node()})..." + capsys.readouterr() # flush previous output + _check_connections(cfg, cfg.clients[0]) + + out = capsys.readouterr().out + assert out == "checking connection to 'alice' (alice.example.com)...OK\n" + assert mock_docker.from_env.called + client = mock_docker.from_env.return_value + mount = mock_docker.types.Mount + assert mount.call_count == 1 + assert mount.call_args_list[0] == call( + "/run/privateer", vol_keys_bob, type="volume", read_only=True + ) + assert client.containers.run.call_count == 1 + assert client.containers.run.call_args == call( + f"mrcide/privateer-client:{cfg.tag}", + mounts=[mount.return_value], + command=["ssh", "alice", "cat", "/run/privateer/name"], + remove=True, + ) - with transient_server(cfg, "alice"): - check(cfg, "bob", connection=True) - out_success = capsys.readouterr().out - assert f"{prefix}OK\n" in out_success - check(cfg, "bob", connection=True) - out_fail = capsys.readouterr().out - assert f"{prefix}ERROR\n" in out_fail +def test_can_report_connection_failure(capsys, monkeypatch, managed_docker): + mock_docker = MagicMock() + mock_docker.errors = docker.errors + err = docker.errors.ContainerError("nm", 1, "ssh", "img", b"the reason") + monkeypatch.setattr(privateer2.keys, "docker", mock_docker) + client = mock_docker.from_env.return_value + client.containers.run.side_effect = err + with vault_dev.Server(export_token=True) as server: + cfg = read_config("example/simple.json") + cfg.vault.url = server.url() + vol_keys_bob = managed_docker("volume") + cfg.servers[0].key_volume = managed_docker("volume") + cfg.clients[0].key_volume = vol_keys_bob + keygen_all(cfg) + configure(cfg, "bob") + capsys.readouterr() # flush previous output + _check_connections(cfg, cfg.clients[0]) + out = capsys.readouterr().out + assert out == ( + "checking connection to 'alice' (alice.example.com)...ERROR\n" + "the reason\n" + ) + assert mock_docker.from_env.called + client = mock_docker.from_env.return_value + mount = mock_docker.types.Mount + assert mount.call_count == 1 + assert mount.call_args_list[0] == call( + "/run/privateer", vol_keys_bob, type="volume", read_only=True + ) + assert client.containers.run.call_count == 1 + assert client.containers.run.call_args == call( + f"mrcide/privateer-client:{cfg.tag}", + mounts=[mount.return_value], + command=["ssh", "alice", "cat", "/run/privateer/name"], + remove=True, + ) + +def test_only_test_connection_for_clients(monkeypatch, managed_docker): + mock_check = MagicMock() + monkeypatch.setattr(privateer2.keys, "_check_connections", mock_check) + with vault_dev.Server(export_token=True) as server: + cfg = read_config("example/simple.json") + cfg.vault.url = server.url() + cfg.servers[0].key_volume = managed_docker("volume") + cfg.servers[0].data_volume = managed_docker("volume") + cfg.clients[0].key_volume = managed_docker("volume") + keygen_all(cfg) + configure(cfg, "alice") + configure(cfg, "bob") + check(cfg, "alice") + assert mock_check.call_count == 0 + check(cfg, "bob") + assert mock_check.call_count == 0 check(cfg, "alice", connection=True) - out_server = capsys.readouterr().out - # Never reports on connections, as alice is a server - expected = f"Volume '{vol_alice}' looks configured as 'alice'\n" - assert out_server == expected + assert mock_check.call_count == 0 + check(cfg, "bob", connection=True) + assert mock_check.call_count == 1 + assert mock_check.call_args == call(cfg, cfg.clients[0]) From 10c61a8a7a190ef53f837ee2eaf6777df9bc1d45 Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Wed, 18 Oct 2023 13:51:19 +0100 Subject: [PATCH 64/87] Fix lint --- src/privateer2/server.py | 2 -- tests/test_keys.py | 17 ++++++++++++----- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/privateer2/server.py b/src/privateer2/server.py index 513e730..9d0febd 100644 --- a/src/privateer2/server.py +++ b/src/privateer2/server.py @@ -1,5 +1,3 @@ -from contextlib import contextmanager - import docker from privateer2.keys import check from privateer2.util import ( diff --git a/tests/test_keys.py b/tests/test_keys.py index b303f64..6119bbd 100644 --- a/tests/test_keys.py +++ b/tests/test_keys.py @@ -1,6 +1,4 @@ from unittest.mock import MagicMock, call -import os -import platform import pytest import vault_dev @@ -8,8 +6,14 @@ import docker import privateer2.keys from privateer2.config import read_config -from privateer2.keys import _check_connections, _keys_data, check, configure, keygen, keygen_all -from privateer2.server import transient_server +from privateer2.keys import ( + _check_connections, + _keys_data, + check, + configure, + keygen, + keygen_all, +) from privateer2.util import string_from_volume @@ -158,7 +162,9 @@ def test_can_check_connections(capsys, monkeypatch, managed_docker): _check_connections(cfg, cfg.clients[0]) out = capsys.readouterr().out - assert out == "checking connection to 'alice' (alice.example.com)...OK\n" + assert ( + out == "checking connection to 'alice' (alice.example.com)...OK\n" + ) assert mock_docker.from_env.called client = mock_docker.from_env.return_value mount = mock_docker.types.Mount @@ -213,6 +219,7 @@ def test_can_report_connection_failure(capsys, monkeypatch, managed_docker): remove=True, ) + def test_only_test_connection_for_clients(monkeypatch, managed_docker): mock_check = MagicMock() monkeypatch.setattr(privateer2.keys, "_check_connections", mock_check) From a8ddeb02804588274697029546cc066cbbab3f62 Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Wed, 18 Oct 2023 13:56:19 +0100 Subject: [PATCH 65/87] Change mount point --- docker/Dockerfile.server | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile.server b/docker/Dockerfile.server index 2eeb670..341c5bf 100644 --- a/docker/Dockerfile.server +++ b/docker/Dockerfile.server @@ -14,7 +14,7 @@ RUN apt-get update && \ COPY sshd_config /etc/ssh/sshd_config VOLUME /run/privateer -VOLUME /privateer +VOLUME /privateer/volumes EXPOSE 22 ENTRYPOINT ["/usr/sbin/sshd", "-D", "-E", "/dev/stderr"] From 9f0a6b75c57584f524c502bde1e50e6cea33e47a Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Wed, 18 Oct 2023 14:06:30 +0100 Subject: [PATCH 66/87] Use different structure for storage --- src/privateer2/backup.py | 2 +- src/privateer2/restore.py | 2 +- src/privateer2/server.py | 4 +++- tests/test_backup.py | 4 ++-- tests/test_restore.py | 5 +++-- tests/test_server.py | 6 +++--- 6 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/privateer2/backup.py b/src/privateer2/backup.py index aa60dff..39bde4b 100644 --- a/src/privateer2/backup.py +++ b/src/privateer2/backup.py @@ -21,7 +21,7 @@ def backup(cfg, name, volume, *, server=None, dry_run=False): "-av", "--delete", src_mount, - f"{server}:/privateer/{name}", + f"{server}:/privateer/volumes/{name}", ] if dry_run: cmd = ["docker", "run", "--rm", *mounts_str(mounts), image, *command] diff --git a/src/privateer2/restore.py b/src/privateer2/restore.py index 78c0c74..218e819 100644 --- a/src/privateer2/restore.py +++ b/src/privateer2/restore.py @@ -21,7 +21,7 @@ def restore(cfg, name, volume, *, server=None, source=None, dry_run=False): "rsync", "-av", "--delete", - f"{server}:/privateer/{name}/{volume}/", + f"{server}:/privateer/volumes/{name}/{volume}/", f"{dest_mount}/", ] if dry_run: diff --git a/src/privateer2/server.py b/src/privateer2/server.py index 9d0febd..1991cf6 100644 --- a/src/privateer2/server.py +++ b/src/privateer2/server.py @@ -17,7 +17,9 @@ def server_start(cfg, name, *, dry_run=False): docker.types.Mount( "/run/privateer", machine.key_volume, type="volume", read_only=True ), - docker.types.Mount("/privateer", machine.data_volume, type="volume"), + docker.types.Mount( + "/privateer/volumes", machine.data_volume, type="volume" + ), ] for v in cfg.volumes: if v.local: diff --git a/tests/test_backup.py b/tests/test_backup.py index f2d2510..9c98eeb 100644 --- a/tests/test_backup.py +++ b/tests/test_backup.py @@ -26,7 +26,7 @@ def test_can_print_instructions_to_run_backup(capsys, managed_docker): " docker run --rm " f"-v {vol}:/run/privateer:ro -v data:/privateer/data:ro " "mrcide/privateer-client:docker " - "rsync -av --delete /privateer/data alice:/privateer/bob" + "rsync -av --delete /privateer/data alice:/privateer/volumes/bob" ) assert cmd in lines @@ -49,7 +49,7 @@ def test_can_run_backup(monkeypatch, managed_docker): "-av", "--delete", "/privateer/data", - "alice:/privateer/bob", + "alice:/privateer/volumes/bob", ] mounts = [ docker.types.Mount( diff --git a/tests/test_restore.py b/tests/test_restore.py index 5056f9c..08a5bc6 100644 --- a/tests/test_restore.py +++ b/tests/test_restore.py @@ -26,7 +26,8 @@ def test_can_print_instructions_to_run_restore(capsys, managed_docker): " docker run --rm " f"-v {vol}:/run/privateer:ro -v data:/privateer/data " "mrcide/privateer-client:docker " - "rsync -av --delete alice:/privateer/bob/data/ /privateer/data/" + "rsync -av --delete alice:/privateer/volumes/bob/data/ " + "/privateer/data/" ) assert cmd in lines @@ -48,7 +49,7 @@ def test_can_run_restore(monkeypatch, managed_docker): "rsync", "-av", "--delete", - "alice:/privateer/bob/data/", + "alice:/privateer/volumes/bob/data/", "/privateer/data/", ] mounts = [ diff --git a/tests/test_server.py b/tests/test_server.py index 92d86c7..0eca8e8 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -28,7 +28,7 @@ def test_can_print_instructions_to_start_server(capsys, managed_docker): assert "Command to manually launch server:" in lines cmd = ( f" docker run --rm -d --name {name} " - f"-v {vol_keys}:/run/privateer:ro -v {vol_data}:/privateer " + f"-v {vol_keys}:/run/privateer:ro -v {vol_data}:/privateer/volumes " "-p 10022:22 mrcide/privateer-server:docker" ) assert cmd in lines @@ -66,7 +66,7 @@ def test_can_start_server(monkeypatch, managed_docker): "/run/privateer", vol_keys, type="volume", read_only=True ) assert mount.call_args_list[1] == call( - "/privateer", vol_data, type="volume" + "/privateer/volumes", vol_data, type="volume" ) @@ -96,7 +96,7 @@ def test_can_start_server_with_local_volume(monkeypatch, managed_docker): "/run/privateer", vol_keys, type="volume", read_only=True ) assert mount.call_args_list[1] == call( - "/privateer", vol_data, type="volume" + "/privateer/volumes", vol_data, type="volume" ) assert mount.call_args_list[2] == call( f"/privateer/local/{vol_other}", From 5145c130e395a273cf2fc590076af2adb42370e2 Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Wed, 18 Oct 2023 14:10:00 +0100 Subject: [PATCH 67/87] Move keys too --- docker/Dockerfile.client | 2 +- docker/Dockerfile.server | 2 +- docker/ssh_config | 6 +++--- docker/sshd_config | 4 ++-- src/privateer2/backup.py | 6 +++--- src/privateer2/keys.py | 4 ++-- src/privateer2/restore.py | 6 +++--- src/privateer2/server.py | 2 +- tests/test_backup.py | 4 ++-- tests/test_keys.py | 8 ++++---- tests/test_restore.py | 4 ++-- tests/test_server.py | 6 +++--- 12 files changed, 27 insertions(+), 27 deletions(-) diff --git a/docker/Dockerfile.client b/docker/Dockerfile.client index 168e65a..67de0c4 100644 --- a/docker/Dockerfile.client +++ b/docker/Dockerfile.client @@ -7,4 +7,4 @@ RUN apt-get update && \ mkdir -p /root/.ssh COPY ssh_config /etc/ssh/ssh_config -VOLUME /run/privateer +VOLUME /privateer/keys diff --git a/docker/Dockerfile.server b/docker/Dockerfile.server index 341c5bf..03bf8ec 100644 --- a/docker/Dockerfile.server +++ b/docker/Dockerfile.server @@ -13,7 +13,7 @@ RUN apt-get update && \ COPY sshd_config /etc/ssh/sshd_config -VOLUME /run/privateer +VOLUME /privateer/keys VOLUME /privateer/volumes EXPOSE 22 diff --git a/docker/ssh_config b/docker/ssh_config index e68d479..bab51c4 100644 --- a/docker/ssh_config +++ b/docker/ssh_config @@ -1,6 +1,6 @@ PasswordAuthentication no -IdentityFile /run/privateer/id_rsa +IdentityFile /privateer/keys/id_rsa SendEnv LANG LC_* HashKnownHosts no -UserKnownHostsFile /run/privateer/known_hosts -Include /run/privateer/config +UserKnownHostsFile /privateer/keys/known_hosts +Include /privateer/keys/config diff --git a/docker/sshd_config b/docker/sshd_config index 29c0730..4c70abf 100644 --- a/docker/sshd_config +++ b/docker/sshd_config @@ -7,8 +7,8 @@ PermitRootLogin prohibit-password PubkeyAuthentication yes -AuthorizedKeysFile /run/privateer/authorized_keys -HostKey /run/privateer/id_rsa +AuthorizedKeysFile /privateer/keys/authorized_keys +HostKey /privateer/keys/id_rsa PasswordAuthentication no ChallengeResponseAuthentication no diff --git a/src/privateer2/backup.py b/src/privateer2/backup.py index 39bde4b..7aeb026 100644 --- a/src/privateer2/backup.py +++ b/src/privateer2/backup.py @@ -12,7 +12,7 @@ def backup(cfg, name, volume, *, server=None, dry_run=False): src_mount = f"/privateer/{volume}" mounts = [ docker.types.Mount( - "/run/privateer", machine.key_volume, type="volume", read_only=True + "/privateer/keys", machine.key_volume, type="volume", read_only=True ), docker.types.Mount(src_mount, volume, type="volume", read_only=True), ] @@ -35,8 +35,8 @@ def backup(cfg, name, volume, *, server=None, dry_run=False): ) print() print("Note that this uses hostname/port information for the server") - print("contained within /run/privateer/config, along with our identity") - print("in /run/privateer/id_rsa") + print("contained within (config), along with our identity (id_rsa)") + print("in the directory /privateer/keys") else: print(f"Backing up '{volume}' from '{name}' to '{server}'") run_docker_command("Backup", image, command=command, mounts=mounts) diff --git a/src/privateer2/keys.py b/src/privateer2/keys.py index 0b23c32..6370603 100644 --- a/src/privateer2/keys.py +++ b/src/privateer2/keys.py @@ -147,7 +147,7 @@ def _check_connections(cfg, machine): image = f"mrcide/privateer-client:{cfg.tag}" mounts = [ docker.types.Mount( - "/run/privateer", machine.key_volume, type="volume", read_only=True + "/privateer/keys", machine.key_volume, type="volume", read_only=True ) ] cl = docker.from_env() @@ -159,7 +159,7 @@ def _check_connections(cfg, machine): flush=True, ) try: - command = ["ssh", server.name, "cat", "/run/privateer/name"] + command = ["ssh", server.name, "cat", "/privateer/keys/name"] cl.containers.run( image, mounts=mounts, command=command, remove=True ) diff --git a/src/privateer2/restore.py b/src/privateer2/restore.py index 218e819..99667a5 100644 --- a/src/privateer2/restore.py +++ b/src/privateer2/restore.py @@ -13,7 +13,7 @@ def restore(cfg, name, volume, *, server=None, source=None, dry_run=False): dest_mount = f"/privateer/{volume}" mounts = [ docker.types.Mount( - "/run/privateer", machine.key_volume, type="volume", read_only=True + "/privateer/keys", machine.key_volume, type="volume", read_only=True ), docker.types.Mount(dest_mount, volume, type="volume", read_only=False), ] @@ -34,8 +34,8 @@ def restore(cfg, name, volume, *, server=None, source=None, dry_run=False): print(f"local volume '{volume}'; data originally from '{source}'") print() print("Note that this uses hostname/port information for the server") - print("contained within /run/privateer/config, along with our identity") - print("in /run/privateer/id_rsa") + print("contained within (config), along with our identity (id_rsa)") + print("in the directory /privateer/keys") else: print(f"Restoring '{volume}' from '{server}'") run_docker_command("Restore", image, command=command, mounts=mounts) diff --git a/src/privateer2/server.py b/src/privateer2/server.py index 1991cf6..61a4dc8 100644 --- a/src/privateer2/server.py +++ b/src/privateer2/server.py @@ -15,7 +15,7 @@ def server_start(cfg, name, *, dry_run=False): mounts = [ docker.types.Mount( - "/run/privateer", machine.key_volume, type="volume", read_only=True + "/privateer/keys", machine.key_volume, type="volume", read_only=True ), docker.types.Mount( "/privateer/volumes", machine.data_volume, type="volume" diff --git a/tests/test_backup.py b/tests/test_backup.py index 9c98eeb..db53b98 100644 --- a/tests/test_backup.py +++ b/tests/test_backup.py @@ -24,7 +24,7 @@ def test_can_print_instructions_to_run_backup(capsys, managed_docker): assert "Command to manually run backup:" in lines cmd = ( " docker run --rm " - f"-v {vol}:/run/privateer:ro -v data:/privateer/data:ro " + f"-v {vol}:/privateer/keys:ro -v data:/privateer/data:ro " "mrcide/privateer-client:docker " "rsync -av --delete /privateer/data alice:/privateer/volumes/bob" ) @@ -53,7 +53,7 @@ def test_can_run_backup(monkeypatch, managed_docker): ] mounts = [ docker.types.Mount( - "/run/privateer", vol, type="volume", read_only=True + "/privateer/keys", vol, type="volume", read_only=True ), docker.types.Mount( "/privateer/data", "data", type="volume", read_only=True diff --git a/tests/test_keys.py b/tests/test_keys.py index 6119bbd..c53149f 100644 --- a/tests/test_keys.py +++ b/tests/test_keys.py @@ -170,13 +170,13 @@ def test_can_check_connections(capsys, monkeypatch, managed_docker): mount = mock_docker.types.Mount assert mount.call_count == 1 assert mount.call_args_list[0] == call( - "/run/privateer", vol_keys_bob, type="volume", read_only=True + "/privateer/keys", vol_keys_bob, type="volume", read_only=True ) assert client.containers.run.call_count == 1 assert client.containers.run.call_args == call( f"mrcide/privateer-client:{cfg.tag}", mounts=[mount.return_value], - command=["ssh", "alice", "cat", "/run/privateer/name"], + command=["ssh", "alice", "cat", "/privateer/keys/name"], remove=True, ) @@ -209,13 +209,13 @@ def test_can_report_connection_failure(capsys, monkeypatch, managed_docker): mount = mock_docker.types.Mount assert mount.call_count == 1 assert mount.call_args_list[0] == call( - "/run/privateer", vol_keys_bob, type="volume", read_only=True + "/privateer/keys", vol_keys_bob, type="volume", read_only=True ) assert client.containers.run.call_count == 1 assert client.containers.run.call_args == call( f"mrcide/privateer-client:{cfg.tag}", mounts=[mount.return_value], - command=["ssh", "alice", "cat", "/run/privateer/name"], + command=["ssh", "alice", "cat", "/privateer/keys/name"], remove=True, ) diff --git a/tests/test_restore.py b/tests/test_restore.py index 08a5bc6..2024db8 100644 --- a/tests/test_restore.py +++ b/tests/test_restore.py @@ -24,7 +24,7 @@ def test_can_print_instructions_to_run_restore(capsys, managed_docker): assert "Command to manually run restore:" in lines cmd = ( " docker run --rm " - f"-v {vol}:/run/privateer:ro -v data:/privateer/data " + f"-v {vol}:/privateer/keys:ro -v data:/privateer/data " "mrcide/privateer-client:docker " "rsync -av --delete alice:/privateer/volumes/bob/data/ " "/privateer/data/" @@ -54,7 +54,7 @@ def test_can_run_restore(monkeypatch, managed_docker): ] mounts = [ docker.types.Mount( - "/run/privateer", vol, type="volume", read_only=True + "/privateer/keys", vol, type="volume", read_only=True ), docker.types.Mount( "/privateer/data", "data", type="volume", read_only=False diff --git a/tests/test_server.py b/tests/test_server.py index 0eca8e8..4e0e9af 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -28,7 +28,7 @@ def test_can_print_instructions_to_start_server(capsys, managed_docker): assert "Command to manually launch server:" in lines cmd = ( f" docker run --rm -d --name {name} " - f"-v {vol_keys}:/run/privateer:ro -v {vol_data}:/privateer/volumes " + f"-v {vol_keys}:/privateer/keys:ro -v {vol_data}:/privateer/volumes " "-p 10022:22 mrcide/privateer-server:docker" ) assert cmd in lines @@ -63,7 +63,7 @@ def test_can_start_server(monkeypatch, managed_docker): ) assert mount.call_count == 2 assert mount.call_args_list[0] == call( - "/run/privateer", vol_keys, type="volume", read_only=True + "/privateer/keys", vol_keys, type="volume", read_only=True ) assert mount.call_args_list[1] == call( "/privateer/volumes", vol_data, type="volume" @@ -93,7 +93,7 @@ def test_can_start_server_with_local_volume(monkeypatch, managed_docker): mount = mock_docker.types.Mount assert mount.call_count == 3 assert mount.call_args_list[0] == call( - "/run/privateer", vol_keys, type="volume", read_only=True + "/privateer/keys", vol_keys, type="volume", read_only=True ) assert mount.call_args_list[1] == call( "/privateer/volumes", vol_data, type="volume" From ab1eab61911795100977888e8715c462a4a86b28 Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Wed, 18 Oct 2023 14:13:26 +0100 Subject: [PATCH 68/87] Add buildkite configuration --- buildkite/pipeline.yml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 buildkite/pipeline.yml diff --git a/buildkite/pipeline.yml b/buildkite/pipeline.yml new file mode 100644 index 0000000..e066a34 --- /dev/null +++ b/buildkite/pipeline.yml @@ -0,0 +1,3 @@ +steps: + - label: ":whale: Build and push" + command: docker/build From 4ce714c18d0309d51016f45c66a2636f9e6eae83 Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Wed, 18 Oct 2023 14:14:08 +0100 Subject: [PATCH 69/87] Fix lint --- tests/test_server.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_server.py b/tests/test_server.py index 4e0e9af..f684e7f 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -28,7 +28,8 @@ def test_can_print_instructions_to_start_server(capsys, managed_docker): assert "Command to manually launch server:" in lines cmd = ( f" docker run --rm -d --name {name} " - f"-v {vol_keys}:/privateer/keys:ro -v {vol_data}:/privateer/volumes " + f"-v {vol_keys}:/privateer/keys:ro " + f"-v {vol_data}:/privateer/volumes " "-p 10022:22 mrcide/privateer-server:docker" ) assert cmd in lines From 22890cf1d45c4147e0fb08dc1e06be95791dea0c Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Wed, 18 Oct 2023 14:14:23 +0100 Subject: [PATCH 70/87] Fix docker build --- docker/build | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker/build b/docker/build index 9a10393..399e264 100755 --- a/docker/build +++ b/docker/build @@ -6,12 +6,12 @@ HERE=$(dirname $0) docker build --pull \ --tag $TAG_SERVER_SHA \ - -f Dockerfile.server \ + -f $HERE/Dockerfile.server \ $HERE docker build --pull \ --tag $TAG_CLIENT_SHA \ - -f Dockerfile.client \ + -f $HERE/Dockerfile.client \ $HERE docker push $TAG_SERVER_SHA From 71898cd3d303613abcb21961d5fb91a30ceeccff Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Wed, 18 Oct 2023 14:16:54 +0100 Subject: [PATCH 71/87] Relax naming requirement --- src/privateer2/config.py | 3 --- tests/test_config.py | 7 ------- 2 files changed, 10 deletions(-) diff --git a/src/privateer2/config.py b/src/privateer2/config.py index 8190c8d..40a4dba 100644 --- a/src/privateer2/config.py +++ b/src/privateer2/config.py @@ -81,9 +81,6 @@ def _check_config(cfg): err_str = ", ".join(f"'{nm}'" for nm in err) msg = f"Invalid machine listed as both a client and a server: {err_str}" raise Exception(msg) - if "local" in cfg.list_servers() or "local" in cfg.list_clients(): - msg = "Machines cannot be called 'local'" - raise Exception(msg) vols_local = [x.name for x in cfg.volumes if x.local] vols_all = [x.name for x in cfg.volumes] for cl in cfg.clients: diff --git a/tests/test_config.py b/tests/test_config.py index 37d1404..56affe8 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -86,13 +86,6 @@ def test_machines_cannot_be_client_and_server(): _check_config(cfg) -def test_machines_cannot_be_called_local(): - cfg = read_config("example/simple.json") - cfg.clients[0].name = "local" - with pytest.raises(Exception, match="Machines cannot be called 'local'"): - _check_config(cfg) - - def test_restore_volumes_are_known(): cfg = read_config("example/simple.json") cfg.clients[0].restore.append("other") From fba196304f233f523aaf3ea0c0acf8a9c1888083 Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Wed, 18 Oct 2023 14:28:41 +0100 Subject: [PATCH 72/87] Use correct branch for images --- src/privateer2/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/privateer2/config.py b/src/privateer2/config.py index 40a4dba..0ce2626 100644 --- a/src/privateer2/config.py +++ b/src/privateer2/config.py @@ -46,7 +46,7 @@ class Config(BaseModel): clients: List[Client] volumes: List[Volume] vault: Vault - tag: str = "docker" + tag: str = "prototype" def model_post_init(self, __context): _check_config(self) From f71a507cb61836a663871ee2819cbc406bd54044 Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Wed, 18 Oct 2023 15:47:18 +0100 Subject: [PATCH 73/87] Pull images in action --- .github/workflows/test.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cb480da..ee11a04 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -33,6 +33,10 @@ jobs: run: | python -m pip install --upgrade pip pip install hatch + - name: Pull images + run: | + docker pull mrcide/privateer-server:${GITHUB_REF_NAME} + docker pull mrcide/privateer-client:${GITHUB_REF_NAME} - name: Test run: | hatch run cov-ci From 63b3d6537cdb0343883b837e8e212787df661be8 Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Wed, 18 Oct 2023 15:47:27 +0100 Subject: [PATCH 74/87] Better docs --- development.md | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/development.md b/development.md index bc50252..76a7cc4 100644 --- a/development.md +++ b/development.md @@ -17,11 +17,7 @@ export VAULT_ADDR='http://127.0.0.1:8200' export VAULT_TOKEN=$(cat ~/.vault-token) ``` -within the hatch environment - -``` -privateer2 --path example/simple.json keygen --all -``` +within the hatch environment before running any commands. ## Worked example @@ -32,12 +28,23 @@ mkdir -p tmp sed "s/alice.example.com/$(hostname)/" example/local.json > tmp/privateer.json ``` -Set up the key volumes (and remove the file that would ordinarily be created) +Create a set of keys + +``` +privateer2 --path tmp/privateer.json keygen --all +``` + +You could also do this individually like + +``` +privateer2 --path tmp/privateer.json keygen alice +``` + +Set up the key volumes ``` privateer2 --path tmp/privateer.json configure alice privateer2 --path tmp/privateer.json configure bob -rm -f tmp/.privateer_identity ``` Start the server, as a background process @@ -52,6 +59,14 @@ Once `alice` is running, we can test this connection from `bob`: privateer2 --path tmp/privateer.json --as=bob check --connection ``` +This command would be simpler to run if in the `tmp` directory, which would be the usual situation in a multi-machine setup + +``` +privateer2 check --connection +``` + +For all other commands below, you can drop the `--path` and `--as` arguments if you change directory. + Create some random data within the `data` volume (this is the one that we want to send from `bob` to `alice`) ``` From 16fbbdfb6346609449edf646fc6552367d948d7b Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Wed, 18 Oct 2023 15:48:06 +0100 Subject: [PATCH 75/87] Remove dep from docker --- docker/Dockerfile.server | 3 --- 1 file changed, 3 deletions(-) diff --git a/docker/Dockerfile.server b/docker/Dockerfile.server index 03bf8ec..7bf3f48 100644 --- a/docker/Dockerfile.server +++ b/docker/Dockerfile.server @@ -8,9 +8,6 @@ RUN apt-get update && \ mkdir -p /var/run/sshd && \ mkdir -p /root/.ssh -RUN apt-get update && \ - apt-get install -y --no-install-recommends python3-hvac - COPY sshd_config /etc/ssh/sshd_config VOLUME /privateer/keys From c8e28e60851503ee9fea5996ecf2660ed1e43dbf Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Wed, 18 Oct 2023 15:49:29 +0100 Subject: [PATCH 76/87] Remove redundant call --- src/privateer2/backup.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/privateer2/backup.py b/src/privateer2/backup.py index 7aeb026..372e66d 100644 --- a/src/privateer2/backup.py +++ b/src/privateer2/backup.py @@ -7,7 +7,6 @@ def backup(cfg, name, volume, *, server=None, dry_run=False): machine = check(cfg, name, quiet=True) server = match_value(server, cfg.list_servers(), "server") volume = match_value(volume, machine.backup, "volume") - machine = check(cfg, name, quiet=True) image = f"mrcide/privateer-client:{cfg.tag}" src_mount = f"/privateer/{volume}" mounts = [ From b44e61bdcb9cfa421760227ea048771e1a78f4d1 Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Wed, 18 Oct 2023 15:51:56 +0100 Subject: [PATCH 77/87] Add missing arg to backup --- src/privateer2/cli.py | 3 ++- tests/test_cli.py | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/privateer2/cli.py b/src/privateer2/cli.py index b38d68c..bd99fe9 100644 --- a/src/privateer2/cli.py +++ b/src/privateer2/cli.py @@ -5,7 +5,7 @@ privateer2 [options] configure privateer2 [options] check [--connection] privateer2 [options] server (start | stop | status) - privateer2 [options] backup + privateer2 [options] backup [--server=NAME] privateer2 [options] restore [--server=NAME] [--source=NAME] Options: @@ -134,6 +134,7 @@ def _parse_opts(opts): cfg=cfg, name=name, volume=opts[""], + server=opts["--server"], dry_run=dry_run, ) elif opts["restore"]: diff --git a/tests/test_cli.py b/tests/test_cli.py index 2257ac3..becb3ea 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -156,6 +156,23 @@ def test_can_parse_backup(tmp_path): "cfg": read_config("example/simple.json"), "name": "alice", "volume": "v", + "server": None, + "dry_run": False, + } + + +def test_can_parse_backup_with_server(tmp_path): + shutil.copy("example/simple.json", tmp_path / "privateer.json") + with open(tmp_path / ".privateer_identity", "w") as f: + f.write("alice\n") + with transient_working_directory(tmp_path): + res = _parse_argv(["backup", "v", "--server", "alice"]) + assert res.target == privateer2.cli.backup + assert res.kwargs == { + "cfg": read_config("example/simple.json"), + "name": "alice", + "volume": "v", + "server": "alice", "dry_run": False, } From 1f15bbd19eedfa68037de80beeed980011e7781d Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Wed, 18 Oct 2023 16:02:53 +0100 Subject: [PATCH 78/87] Don't store things double-secret --- src/privateer2/config.py | 2 ++ tests/test_config.py | 14 +++++++++++++- tests/test_keys.py | 2 +- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/privateer2/config.py b/src/privateer2/config.py index 0ce2626..985ef72 100644 --- a/src/privateer2/config.py +++ b/src/privateer2/config.py @@ -95,6 +95,8 @@ def _check_config(cfg): if v in vols_local: msg = f"Client '{cl.name}' backs up local volume '{v}'" raise Exception(msg) + if cfg.vault.prefix.startswith("/secret"): + cfg.vault.prefix = cfg.vault.prefix[7:] def _check_not_duplicated(els, name): diff --git a/tests/test_config.py b/tests/test_config.py index 56affe8..fcf881c 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -17,7 +17,7 @@ def test_can_read_config(): assert len(cfg.volumes) == 1 assert cfg.volumes[0].name == "data" assert cfg.vault.url == "http://localhost:8200" - assert cfg.vault.prefix == "/secret/privateer" + assert cfg.vault.prefix == "/privateer" assert cfg.list_servers() == ["alice"] assert cfg.list_clients() == ["bob"] @@ -131,3 +131,15 @@ def test_can_find_appropriate_source_if_local(): find_source(cfg, "data", "bob") with pytest.raises(Exception, match=msg): find_source(cfg, "data", "local") + + +def test_can_strip_leading_secret_from_path(): + cfg = read_config("example/simple.json") + + cfg.vault.prefix = "/secret/my/path" + _check_config(cfg) + assert cfg.vault.prefix == "/my/path" + + cfg.vault.prefix = "/my/path" + _check_config(cfg) + assert cfg.vault.prefix == "/my/path" diff --git a/tests/test_keys.py b/tests/test_keys.py index c53149f..57e1040 100644 --- a/tests/test_keys.py +++ b/tests/test_keys.py @@ -23,7 +23,7 @@ def test_can_create_keys(): cfg.vault.url = server.url() keygen(cfg, "alice") client = cfg.vault.client() - response = client.secrets.kv.v1.read_secret("/secret/privateer/alice") + response = client.secrets.kv.v1.read_secret("/privateer/alice") pair = response["data"] assert set(pair.keys()) == {"private", "public"} assert pair["public"].startswith("ssh-rsa") From c7478c13b80ac2af3544f431417183962e0b1d8e Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Wed, 18 Oct 2023 16:03:11 +0100 Subject: [PATCH 79/87] Don't hardcode branch in tests --- tests/test_backup.py | 2 +- tests/test_restore.py | 2 +- tests/test_server.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_backup.py b/tests/test_backup.py index db53b98..0fdcd31 100644 --- a/tests/test_backup.py +++ b/tests/test_backup.py @@ -25,7 +25,7 @@ def test_can_print_instructions_to_run_backup(capsys, managed_docker): cmd = ( " docker run --rm " f"-v {vol}:/privateer/keys:ro -v data:/privateer/data:ro " - "mrcide/privateer-client:docker " + f"mrcide/privateer-client:{cfg.tag} " "rsync -av --delete /privateer/data alice:/privateer/volumes/bob" ) assert cmd in lines diff --git a/tests/test_restore.py b/tests/test_restore.py index 2024db8..c99c098 100644 --- a/tests/test_restore.py +++ b/tests/test_restore.py @@ -25,7 +25,7 @@ def test_can_print_instructions_to_run_restore(capsys, managed_docker): cmd = ( " docker run --rm " f"-v {vol}:/privateer/keys:ro -v data:/privateer/data " - "mrcide/privateer-client:docker " + f"mrcide/privateer-client:{cfg.tag} " "rsync -av --delete alice:/privateer/volumes/bob/data/ " "/privateer/data/" ) diff --git a/tests/test_server.py b/tests/test_server.py index f684e7f..fbebef5 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -30,7 +30,7 @@ def test_can_print_instructions_to_start_server(capsys, managed_docker): f" docker run --rm -d --name {name} " f"-v {vol_keys}:/privateer/keys:ro " f"-v {vol_data}:/privateer/volumes " - "-p 10022:22 mrcide/privateer-server:docker" + f"-p 10022:22 mrcide/privateer-server:{cfg.tag}" ) assert cmd in lines From 569b308e1a9948347e83973f2151f7944994a2d9 Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Wed, 18 Oct 2023 16:05:21 +0100 Subject: [PATCH 80/87] Use literal branch name --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ee11a04..fb9b74e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,8 +35,8 @@ jobs: pip install hatch - name: Pull images run: | - docker pull mrcide/privateer-server:${GITHUB_REF_NAME} - docker pull mrcide/privateer-client:${GITHUB_REF_NAME} + docker pull mrcide/privateer-server:prototype + docker pull mrcide/privateer-client:prototype - name: Test run: | hatch run cov-ci From 43e3648c096d0be56e8ee4a392e857a5c937157b Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Wed, 18 Oct 2023 16:20:35 +0100 Subject: [PATCH 81/87] Allow ommitting the path --- development.md | 22 +++++++++++----------- src/privateer2/cli.py | 15 +++++++++++++-- tests/test_cli.py | 18 ++++++++++++++++++ 3 files changed, 42 insertions(+), 13 deletions(-) diff --git a/development.md b/development.md index 76a7cc4..ce8de80 100644 --- a/development.md +++ b/development.md @@ -31,32 +31,32 @@ sed "s/alice.example.com/$(hostname)/" example/local.json > tmp/privateer.json Create a set of keys ``` -privateer2 --path tmp/privateer.json keygen --all +privateer2 --path tmp keygen --all ``` You could also do this individually like ``` -privateer2 --path tmp/privateer.json keygen alice +privateer2 --path tmp keygen alice ``` Set up the key volumes ``` -privateer2 --path tmp/privateer.json configure alice -privateer2 --path tmp/privateer.json configure bob +privateer2 --path tmp configure alice +privateer2 --path tmp configure bob ``` Start the server, as a background process ``` -privateer2 --path tmp/privateer.json --as=alice server start +privateer2 --path tmp --as=alice server start ``` Once `alice` is running, we can test this connection from `bob`: ``` -privateer2 --path tmp/privateer.json --as=bob check --connection +privateer2 --path tmp --as=bob check --connection ``` This command would be simpler to run if in the `tmp` directory, which would be the usual situation in a multi-machine setup @@ -77,13 +77,13 @@ docker run -it --rm -v data:/data ubuntu bash -c "base64 /dev/urandom | head -c We can now backup from `bob` to `alice` as: ``` -privateer2 --path tmp/privateer.json --as=bob backup data +privateer2 --path tmp --as=bob backup data ``` or see what commands you would need in order to try this yourself: ``` -privateer2 --path tmp/privateer.json --as=bob backup data --dry-run +privateer2 --path tmp --as=bob backup data --dry-run ``` Delete the volume @@ -95,19 +95,19 @@ docker volume rm data We can now restore it: ``` -privateer2 --path tmp/privateer.json --as=bob restore data +privateer2 --path tmp --as=bob restore data ``` or see the commands to do this outselves: ``` -privateer2 --path tmp/privateer.json --as=bob restore data --dry-run +privateer2 --path tmp --as=bob restore data --dry-run ``` Tear down the server with ``` -privateer2 --path tmp/privateer.json --as=alice server stop +privateer2 --path tmp --as=alice server stop ``` ## Writing tests diff --git a/src/privateer2/cli.py b/src/privateer2/cli.py index bd99fe9..4d5002a 100644 --- a/src/privateer2/cli.py +++ b/src/privateer2/cli.py @@ -9,7 +9,7 @@ privateer2 [options] restore [--server=NAME] [--source=NAME] Options: - --path=PATH The path to the configuration (rather than privateer.json) + --path=PATH The path to the configuration, or directory with privateer.json --as=NAME The machine to run the command as --dry-run Do nothing, but print docker commands @@ -90,13 +90,24 @@ def _parse_argv(argv): return _parse_opts(opts) +def _path_config(path): + if not path: + path = "privateer.json" + elif os.path.isdir(path): + path = os.path.join(path, "privateer.json") + if not os.path.exists(path): + msg = f"Did not find privateer configuration at '{path}'" + raise Exception(msg) + return path + + def _parse_opts(opts): if opts["--version"]: return Call(_show_version) dry_run = opts["--dry-run"] name = opts["--as"] - path_config = opts["--path"] or "privateer.json" + path_config = _path_config(opts["--path"]) root_config = os.path.dirname(path_config) cfg = read_config(path_config) if opts["keygen"]: diff --git a/tests/test_cli.py b/tests/test_cli.py index becb3ea..9959066 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -10,6 +10,7 @@ _find_identity, _parse_argv, _parse_opts, + _path_config, _show_version, main, pull, @@ -275,3 +276,20 @@ def test_run_pull(monkeypatch): assert client.images.pull.call_count == 2 assert client.images.pull.call_args_list[0] == call(image_client) assert client.images.pull.call_args_list[1] == call(image_server) + + +def test_clean_path(tmp_path): + with pytest.raises(Exception, match="Did not find privateer configuration"): + with transient_working_directory(str(tmp_path)): + _path_config(None) + with pytest.raises(Exception, match="Did not find privateer configuration"): + _path_config(tmp_path) + with pytest.raises(Exception, match="Did not find privateer configuration"): + _path_config(tmp_path / "foo.json") + with pytest.raises(Exception, match="Did not find privateer configuration"): + _path_config("foo.json") + shutil.copy("example/simple.json", tmp_path / "privateer.json") + assert _path_config(str(tmp_path)) == str(tmp_path / "privateer.json") + assert _path_config("example/simple.json") == "example/simple.json" + with transient_working_directory(str(tmp_path)): + assert _path_config(None) == "privateer.json" From 4e1480963a786e3bc95805e02551661d3286f801 Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Thu, 19 Oct 2023 08:20:10 +0100 Subject: [PATCH 82/87] Apply suggestions from code review Co-authored-by: M-Kusumgar <98405247+M-Kusumgar@users.noreply.github.com> --- README.md | 21 ++++++++++++++------- development.md | 4 ++-- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 02047b6..79d5847 100644 --- a/README.md +++ b/README.md @@ -7,15 +7,15 @@ ## The idea -We need a way of syncronising some docker volumes from a machine to some backup server, incrementally, using `rsync`. We previously used [`offen/docker-volume-backup`](https://github.com/offen/docker-volume-backup) to backup volumes in their entirity to another machine as a tar file but the space and time requirements made this hard to use in practice. +We need a way of synchronising some docker volumes from a machine to some backup server, incrementally, using `rsync`. We previously used [`offen/docker-volume-backup`](https://github.com/offen/docker-volume-backup) to backup volumes in their entirety to another machine as a tar file but the space and time requirements made this hard to use in practice. ### The setup -We assume some number of **server** machines -- these will recieve data, and some number of **client** machines -- these will send data to the server(s). A client can back any number of volumes to any number of servers, and a server can recieve and serve any unmber of volumes to any number of clients. +We assume some number of **server** machines -- these will receive data, and some number of **client** machines -- these will send data to the server(s). A client can back any number of volumes to any number of servers, and a server can receive and serve any number of volumes to any number of clients. -A typical topolgy for us would be that we would have a "production" machine which is backing up to one or more servers, and then some additional set of "staging" machines that recieve data from the servers, but which in practice never send any data. +A typical framework for us would be that we would have a "production" machine which is backing up to one or more servers, and then some additional set of "staging" machines that receive data from the servers, which in practice never send any data. -Because we are going to use ssh for transport, we assume existance of [HashiCorp Vault](https://www.vaultproject.io/) to store secrets. +Because we are going to use ssh for transport, we assume existence of [HashiCorp Vault](https://www.vaultproject.io/) to store secrets. ### Configuration @@ -60,13 +60,20 @@ Restoration is always manual privateer2 restore [--server=NAME] [--source=NAME] ``` -where `--server` controls the server you are pulling from (if you have more than one configured) and `--source` controls the original machine that backed the data up (if more than one machine is pushing backups). +where `--server` controls the server you are pulling from (useful if you have more than one configured) and `--source` controls the original machine that backed the data up (if more than one machine is pushing backups). + +For example, if you are on a "staging" machine, connecting to the "backup" server and want to pull the "user_data" volume that was backed up from "production" machine called you would type + +``` +privateer2 restore user_data --server=backup --source=production +``` + ## What's the problem anyway? -[Docker volumes](https://docs.docker.com/storage/volumes/) are useful for abstracting away some persistant storage for an application. They're much nicer to use than bind mounts because they don't pollute the host sytem with immovable files (docker containers often running as root or with a uid different to the user running docker). The docker [docs describe some approaches to backup and restore](https://docs.docker.com/storage/volumes/#back-up-restore-or-migrate-data-volumes) but in practice this ignores many practical issues, especially when the volumes are large or off-site backup is important. +[Docker volumes](https://docs.docker.com/storage/volumes/) are useful for abstracting away some persistent storage for an application. They're much nicer to use than bind mounts because they don't pollute the host system with immovable files (docker containers often running as root or with a uid different to the user running docker). The docker [docs](https://docs.docker.com/storage/volumes/#back-up-restore-or-migrate-data-volumes) describe some approaches to backup and restore but in practice this ignores many practical issues, especially when the volumes are large or off-site backup is important. -We want to be able to syncronise a volume to another volume on a different machine; our setup looks like this: +We want to be able to synchronise a volume to another volume on a different machine; our setup looks like this: ``` bob alice diff --git a/development.md b/development.md index ce8de80..bad07aa 100644 --- a/development.md +++ b/development.md @@ -98,7 +98,7 @@ We can now restore it: privateer2 --path tmp --as=bob restore data ``` -or see the commands to do this outselves: +or see the commands to do this ourselves: ``` privateer2 --path tmp --as=bob restore data --dry-run @@ -112,4 +112,4 @@ privateer2 --path tmp --as=alice server stop ## Writing tests -We use a lot of global resources, so it's easy to leave behind volumes and containers (often exited) after running tests. At best this is lazy and messy, but at worst it creates hard-to-diagnose dependencies between tests. Try and create names for auto-cleaned volumes and containers using the `managed_docker` fixture (see [`tests/conftest.py`](tests/conftest.py) for details). +We use a lot of global resources, so it's easy to leave behind volumes and containers (often exited) after running tests. At best this is lazy and messy, but at worst it creates hard-to-diagnose dependencies between tests. Try and create names for auto-cleaned volumes and containers using the `managed_docker` fixture (see [`tests/conftest.py`](tests/conftest.py) for details). From 6bc9e1c43b39c8215681088dea1b2798914c60df Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Thu, 19 Oct 2023 08:15:13 +0100 Subject: [PATCH 83/87] Fix fstring, with test --- src/privateer2/server.py | 2 +- tests/test_server.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/privateer2/server.py b/src/privateer2/server.py index 61a4dc8..ed21942 100644 --- a/src/privateer2/server.py +++ b/src/privateer2/server.py @@ -76,7 +76,7 @@ def server_stop(cfg, name): if container.status == "running": container.stop() else: - print("Container '{machine.container}' for '{name}' does not exist") + print(f"Container '{machine.container}' for '{name}' does not exist") def server_status(cfg, name): diff --git a/tests/test_server.py b/tests/test_server.py index fbebef5..04ac922 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -137,7 +137,7 @@ def test_throws_if_container_already_exists(monkeypatch, managed_docker): mock_ce.assert_called_with(name) -def test_can_stop_server(monkeypatch, managed_docker): +def test_can_stop_server(monkeypatch, managed_docker, capsys): mock_container = MagicMock() mock_container.status = "running" mock_container_if_exists = MagicMock(return_value=mock_container) @@ -157,22 +157,26 @@ def test_can_stop_server(monkeypatch, managed_docker): cfg.servers[0].container = name keygen_all(cfg) configure(cfg, "alice") + capsys.readouterr() # flush previous output server_stop(cfg, "alice") assert mock_container_if_exists.call_count == 1 assert mock_container_if_exists.call_args == call(name) assert mock_container.stop.call_count == 1 assert mock_container.stop.call_args == call() + assert capsys.readouterr().out == "" mock_container.status = "exited" server_stop(cfg, "alice") assert mock_container_if_exists.call_count == 2 assert mock_container.stop.call_count == 1 + assert capsys.readouterr().out == "" mock_container_if_exists.return_value = None server_stop(cfg, "alice") assert mock_container_if_exists.call_count == 3 assert mock_container.stop.call_count == 1 + assert capsys.readouterr().out == f"Container '{name}' for 'alice' does not exist\n" def test_can_get_server_status(monkeypatch, capsys, managed_docker): From 6765c34bf9a79cb76bd3818c8a351be5ff6880f3 Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Thu, 19 Oct 2023 08:17:18 +0100 Subject: [PATCH 84/87] Use more descriptive name for utility --- src/privateer2/backup.py | 6 ++++-- src/privateer2/restore.py | 6 ++++-- src/privateer2/util.py | 2 +- tests/test_backup.py | 4 +++- tests/test_restore.py | 4 +++- tests/test_server.py | 5 ++++- tests/test_util.py | 4 ++-- 7 files changed, 21 insertions(+), 10 deletions(-) diff --git a/src/privateer2/backup.py b/src/privateer2/backup.py index 372e66d..c8b2ee4 100644 --- a/src/privateer2/backup.py +++ b/src/privateer2/backup.py @@ -1,6 +1,6 @@ import docker from privateer2.keys import check -from privateer2.util import match_value, mounts_str, run_docker_command +from privateer2.util import match_value, mounts_str, run_container_with_command def backup(cfg, name, volume, *, server=None, dry_run=False): @@ -38,7 +38,9 @@ def backup(cfg, name, volume, *, server=None, dry_run=False): print("in the directory /privateer/keys") else: print(f"Backing up '{volume}' from '{name}' to '{server}'") - run_docker_command("Backup", image, command=command, mounts=mounts) + run_container_with_command( + "Backup", image, command=command, mounts=mounts + ) # TODO: also copy over some metadata at this point, via # ssh; probably best to write tiny utility in the client # container that will do this for us. diff --git a/src/privateer2/restore.py b/src/privateer2/restore.py index 99667a5..20656b0 100644 --- a/src/privateer2/restore.py +++ b/src/privateer2/restore.py @@ -1,7 +1,7 @@ import docker from privateer2.config import find_source from privateer2.keys import check -from privateer2.util import match_value, mounts_str, run_docker_command +from privateer2.util import match_value, mounts_str, run_container_with_command def restore(cfg, name, volume, *, server=None, source=None, dry_run=False): @@ -38,4 +38,6 @@ def restore(cfg, name, volume, *, server=None, source=None, dry_run=False): print("in the directory /privateer/keys") else: print(f"Restoring '{volume}' from '{server}'") - run_docker_command("Restore", image, command=command, mounts=mounts) + run_container_with_command( + "Restore", image, command=command, mounts=mounts + ) diff --git a/src/privateer2/util.py b/src/privateer2/util.py index 51e4d7a..9dbd5b8 100644 --- a/src/privateer2/util.py +++ b/src/privateer2/util.py @@ -222,7 +222,7 @@ def take_ownership(filename, directory, *, command_only=False): # tar ) -def run_docker_command(display, image, **kwargs): +def run_container_with_command(display, image, **kwargs): ensure_image(image) client = docker.from_env() container = client.containers.run(image, **kwargs, detach=True) diff --git a/tests/test_backup.py b/tests/test_backup.py index 0fdcd31..a999a60 100644 --- a/tests/test_backup.py +++ b/tests/test_backup.py @@ -33,7 +33,9 @@ def test_can_print_instructions_to_run_backup(capsys, managed_docker): def test_can_run_backup(monkeypatch, managed_docker): mock_run = MagicMock() - monkeypatch.setattr(privateer2.backup, "run_docker_command", mock_run) + monkeypatch.setattr( + privateer2.backup, "run_container_with_command", mock_run + ) with vault_dev.Server(export_token=True) as server: cfg = read_config("example/simple.json") cfg.vault.url = server.url() diff --git a/tests/test_restore.py b/tests/test_restore.py index c99c098..9a09ad6 100644 --- a/tests/test_restore.py +++ b/tests/test_restore.py @@ -34,7 +34,9 @@ def test_can_print_instructions_to_run_restore(capsys, managed_docker): def test_can_run_restore(monkeypatch, managed_docker): mock_run = MagicMock() - monkeypatch.setattr(privateer2.restore, "run_docker_command", mock_run) + monkeypatch.setattr( + privateer2.restore, "run_container_with_command", mock_run + ) with vault_dev.Server(export_token=True) as server: cfg = read_config("example/simple.json") cfg.vault.url = server.url() diff --git a/tests/test_server.py b/tests/test_server.py index 04ac922..e4abe26 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -176,7 +176,10 @@ def test_can_stop_server(monkeypatch, managed_docker, capsys): server_stop(cfg, "alice") assert mock_container_if_exists.call_count == 3 assert mock_container.stop.call_count == 1 - assert capsys.readouterr().out == f"Container '{name}' for 'alice' does not exist\n" + assert ( + capsys.readouterr().out + == f"Container '{name}' for 'alice' does not exist\n" + ) def test_can_get_server_status(monkeypatch, capsys, managed_docker): diff --git a/tests/test_util.py b/tests/test_util.py index b9f1caf..a743b23 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -95,7 +95,7 @@ def test_can_tail_logs_from_container(managed_docker): def test_can_run_long_command(capsys, managed_docker): name = managed_docker("container") command = ["seq", "1", "3"] - privateer2.util.run_docker_command( + privateer2.util.run_container_with_command( "Test", "alpine", name=name, command=command ) out = capsys.readouterr().out @@ -111,7 +111,7 @@ def test_can_run_failing_command(capsys, managed_docker): command = ["false"] msg = f"Test failed; see {name} logs for details" with pytest.raises(Exception, match=msg): - privateer2.util.run_docker_command( + privateer2.util.run_container_with_command( "Test", "alpine", name=name, command=command ) out = capsys.readouterr().out From 859dae242f06145721bbb703d29cbfe9e69e8501 Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Thu, 19 Oct 2023 08:25:33 +0100 Subject: [PATCH 85/87] Apply suggestions from code review Co-authored-by: M-Kusumgar <98405247+M-Kusumgar@users.noreply.github.com> --- development.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/development.md b/development.md index bad07aa..1bbd75a 100644 --- a/development.md +++ b/development.md @@ -47,7 +47,7 @@ privateer2 --path tmp configure alice privateer2 --path tmp configure bob ``` -Start the server, as a background process +Start the server, as a background process (note that if these were on different machine the `privateer2 configure ` step would generate the `.privateer_identity` automatically so the `--as` argument is not needed) ``` privateer2 --path tmp --as=alice server start @@ -59,7 +59,7 @@ Once `alice` is running, we can test this connection from `bob`: privateer2 --path tmp --as=bob check --connection ``` -This command would be simpler to run if in the `tmp` directory, which would be the usual situation in a multi-machine setup +This command would be simpler to run if we are in the `tmp` directory, which would be the usual situation in a multi-machine setup ``` privateer2 check --connection From d5e2233508fc5eead39f6afd5b3a1e67e134d23a Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Thu, 19 Oct 2023 11:08:00 +0100 Subject: [PATCH 86/87] Update README.md Co-authored-by: Rob <39248272+r-ash@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 79d5847..9bcd4a0 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ replacing `` with the name of the machine within either the `servers` or ` ### Manual backup ``` -privateer backup +privateer2 backup [--server=NAME] ``` Add `--dry-run` to see the commands to run it yourself From c876866c456b3644fea81594c5773562629bb49a Mon Sep 17 00:00:00 2001 From: Rich FitzJohn Date: Thu, 19 Oct 2023 08:11:34 +0100 Subject: [PATCH 87/87] Refactor to use a common service approach --- src/privateer2/backup.py | 22 +++-- src/privateer2/config.py | 9 ++ src/privateer2/keys.py | 14 +-- src/privateer2/server.py | 61 ++----------- src/privateer2/service.py | 79 +++++++++++++++++ src/privateer2/util.py | 18 +++- tests/test_server.py | 181 +++++++++++--------------------------- tests/test_service.py | 148 +++++++++++++++++++++++++++++++ tests/test_util.py | 26 ++++++ 9 files changed, 355 insertions(+), 203 deletions(-) create mode 100644 src/privateer2/service.py create mode 100644 tests/test_service.py diff --git a/src/privateer2/backup.py b/src/privateer2/backup.py index c8b2ee4..577c9a5 100644 --- a/src/privateer2/backup.py +++ b/src/privateer2/backup.py @@ -3,25 +3,29 @@ from privateer2.util import match_value, mounts_str, run_container_with_command +def backup_command(name, volume, server): + return [ + "rsync", + "-av", + "--delete", + f"/privateer/{volume}", + f"{server}:/privateer/volumes/{name}", + ] + + def backup(cfg, name, volume, *, server=None, dry_run=False): machine = check(cfg, name, quiet=True) server = match_value(server, cfg.list_servers(), "server") volume = match_value(volume, machine.backup, "volume") image = f"mrcide/privateer-client:{cfg.tag}" - src_mount = f"/privateer/{volume}" + src = f"/privateer/{volume}" mounts = [ docker.types.Mount( "/privateer/keys", machine.key_volume, type="volume", read_only=True ), - docker.types.Mount(src_mount, volume, type="volume", read_only=True), - ] - command = [ - "rsync", - "-av", - "--delete", - src_mount, - f"{server}:/privateer/volumes/{name}", + docker.types.Mount(src, volume, type="volume", read_only=True), ] + command = backup_command(name, volume, server) if dry_run: cmd = ["docker", "run", "--rm", *mounts_str(mounts), image, *command] print("Command to manually run backup:") diff --git a/src/privateer2/config.py b/src/privateer2/config.py index 985ef72..f7cf7c4 100644 --- a/src/privateer2/config.py +++ b/src/privateer2/config.py @@ -57,6 +57,15 @@ def list_servers(self): def list_clients(self): return [x.name for x in self.clients] + def machine_config(self, name): + for el in self.servers + self.clients: + if el.name == name: + return el + valid = self.list_servers() + self.list_clients() + valid_str = ", ".join(f"'{x}'" for x in valid) + msg = f"Invalid configuration '{name}', must be one of {valid_str}" + raise Exception(msg) + # this could be put elsewhere; we find the plausible sources (original # clients) that backed up a source to any server. diff --git a/src/privateer2/keys.py b/src/privateer2/keys.py index 6370603..cf8b987 100644 --- a/src/privateer2/keys.py +++ b/src/privateer2/keys.py @@ -28,7 +28,7 @@ def _keygen(cfg, name, vault): def configure(cfg, name): cl = docker.from_env() data = _keys_data(cfg, name) - vol = _machine_config(cfg, name).key_volume + vol = cfg.machine_config(name).key_volume cl.volumes.create(vol) print(f"Copying keypair for '{name}' to volume '{vol}'") string_to_volume( @@ -59,7 +59,7 @@ def configure(cfg, name): def check(cfg, name, *, connection=False, quiet=False): - machine = _machine_config(cfg, name) + machine = cfg.machine_config(name) vol = machine.key_volume try: docker.from_env().volumes.get(vol) @@ -133,16 +133,6 @@ def _keys_data(cfg, name): return ret -def _machine_config(cfg, name): - for el in cfg.servers + cfg.clients: - if el.name == name: - return el - valid = cfg.list_servers() + cfg.list_clients() - valid_str = ", ".join(f"'{x}'" for x in valid) - msg = f"Invalid configuration '{name}', must be one of {valid_str}" - raise Exception(msg) - - def _check_connections(cfg, machine): image = f"mrcide/privateer-client:{cfg.tag}" mounts = [ diff --git a/src/privateer2/server.py b/src/privateer2/server.py index ed21942..dcd2d3c 100644 --- a/src/privateer2/server.py +++ b/src/privateer2/server.py @@ -1,17 +1,10 @@ import docker from privateer2.keys import check -from privateer2.util import ( - container_exists, - container_if_exists, - ensure_image, - mounts_str, -) +from privateer2.service import service_start, service_status, service_stop def server_start(cfg, name, *, dry_run=False): machine = check(cfg, name, quiet=True) - image = f"mrcide/privateer-server:{cfg.tag}" - ensure_image(image) mounts = [ docker.types.Mount( @@ -31,58 +24,22 @@ def server_start(cfg, name, *, dry_run=False): read_only=True, ) ) - if dry_run: - cmd = [ - "docker", - "run", - "--rm", - "-d", - "--name", - machine.container, - *mounts_str(mounts), - "-p", - f"{machine.port}:22", - image, - ] - print("Command to manually launch server:") - print() - print(f" {' '.join(cmd)}") - print() - print("(remove the '-d' flag to run in blocking mode)") - return - - if container_exists(machine.container): - msg = f"Container '{machine.container}' for '{name}' already running" - raise Exception(msg) - - ports = {"22/tcp": machine.port} # or ("0.0.0.0", machine.port) - client = docker.from_env() - print("Starting server") - client.containers.run( - image, - auto_remove=True, - detach=True, - name=machine.container, + service_start( + name, + machine.container, + image=f"mrcide/privateer-server:{cfg.tag}", mounts=mounts, - ports=ports, + ports={"22/tcp": machine.port}, + dry_run=dry_run, ) print(f"Server {name} now running on port {machine.port}") def server_stop(cfg, name): machine = check(cfg, name, quiet=True) - container = container_if_exists(machine.container) - if container: - if container.status == "running": - container.stop() - else: - print(f"Container '{machine.container}' for '{name}' does not exist") + service_stop(name, machine.container) def server_status(cfg, name): machine = check(cfg, name, quiet=False) - container = container_if_exists(machine.container) - if container: - print(container.status) - else: - print("not running") + service_status(machine.container) diff --git a/src/privateer2/service.py b/src/privateer2/service.py new file mode 100644 index 0000000..ffd53ce --- /dev/null +++ b/src/privateer2/service.py @@ -0,0 +1,79 @@ +import docker +from privateer2.util import ( + container_exists, + container_if_exists, + ensure_image, + mounts_str, + ports_str, +) + + +def service_command(image, name, *, mounts=None, ports=None, command=None): + return [ + "docker", + "run", + "--rm", + "-d", + "--name", + name, + *mounts_str(mounts), + *ports_str(ports), + image, + *(command or []), + ] + + +def service_start( + name, + container_name, + image, + *, + dry_run=False, + mounts=None, + ports=None, + command=None, +): + if dry_run: + cmd = service_command( + image, container_name, mounts=mounts, ports=ports, command=command + ) + print("Command to manually launch server:") + print() + print(f" {' '.join(cmd)}") + print() + print("(remove the '-d' flag to run in blocking mode)") + return + + if container_exists(container_name): + msg = f"Container '{container_name}' for '{name}' already running" + raise Exception(msg) + + ensure_image(image) + print("Starting server '{name}' as container '{container_name}'") + client = docker.from_env() + client.containers.run( + image, + auto_remove=True, + detach=True, + name=container_name, + mounts=mounts, + ports=ports, + command=command, + ) + + +def service_stop(name, container_name): + container = container_if_exists(container_name) + if container: + if container.status == "running": + container.stop() + else: + print(f"Container '{container_name}' for '{name}' does not exist") + + +def service_status(container_name): + container = container_if_exists(container_name) + if container: + print(container.status) + else: + print("not running") diff --git a/src/privateer2/util.py b/src/privateer2/util.py index 9dbd5b8..d5effc9 100644 --- a/src/privateer2/util.py +++ b/src/privateer2/util.py @@ -2,6 +2,7 @@ import os import os.path import random +import re import string import tarfile import tempfile @@ -12,6 +13,8 @@ def string_to_volume(text, volume, path, **kwargs): + if isinstance(text, list): + text = "".join(x + "\n" for x in text) ensure_image("alpine") dest = Path("/dest") mounts = [docker.types.Mount(str(dest), volume, type="volume")] @@ -165,8 +168,9 @@ def log_tail(container, n): def mounts_str(mounts): ret = [] - for m in mounts: - ret += mount_str(m) + if mounts: + for m in mounts: + ret += mount_str(m) return ret @@ -177,6 +181,16 @@ def mount_str(mount): return ["-v", ret] +# This could be improved, there are more formats possible here. +def ports_str(ports): + ret = [] + if ports: + for k, v in ports.items(): + ret.append("-p") + ret.append(f"{v}:{re.sub('/.+', '', k)}") + return ret + + def match_value(given, valid, name): if given is None: if len(valid) == 1: diff --git a/tests/test_server.py b/tests/test_server.py index e4abe26..26ab7ec 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1,6 +1,5 @@ from unittest.mock import MagicMock, call -import pytest import vault_dev import privateer2.server @@ -37,7 +36,9 @@ def test_can_print_instructions_to_start_server(capsys, managed_docker): def test_can_start_server(monkeypatch, managed_docker): mock_docker = MagicMock() + mock_start = MagicMock() monkeypatch.setattr(privateer2.server, "docker", mock_docker) + monkeypatch.setattr(privateer2.server, "service_start", mock_start) with vault_dev.Server(export_token=True) as server: cfg = read_config("example/simple.json") cfg.vault.url = server.url() @@ -50,18 +51,7 @@ def test_can_start_server(monkeypatch, managed_docker): keygen_all(cfg) configure(cfg, "alice") server_start(cfg, "alice") - assert mock_docker.from_env.called - client = mock_docker.from_env.return_value - assert client.containers.run.call_count == 1 mount = mock_docker.types.Mount - assert client.containers.run.call_args == call( - f"mrcide/privateer-server:{cfg.tag}", - auto_remove=True, - detach=True, - name=name, - mounts=[mount.return_value, mount.return_value], - ports={"22/tcp": 10022}, - ) assert mount.call_count == 2 assert mount.call_args_list[0] == call( "/privateer/keys", vol_keys, type="volume", read_only=True @@ -69,11 +59,25 @@ def test_can_start_server(monkeypatch, managed_docker): assert mount.call_args_list[1] == call( "/privateer/volumes", vol_data, type="volume" ) + assert mock_start.call_count == 1 + image = f"mrcide/privateer-server:{cfg.tag}" + mounts = [mount.return_value] * 2 + ports = {"22/tcp": 10022} + assert mock_start.call_args == call( + "alice", + name, + image=image, + mounts=mounts, + ports=ports, + dry_run=False, + ) def test_can_start_server_with_local_volume(monkeypatch, managed_docker): mock_docker = MagicMock() + mock_start = MagicMock() monkeypatch.setattr(privateer2.server, "docker", mock_docker) + monkeypatch.setattr(privateer2.server, "service_start", mock_start) with vault_dev.Server(export_token=True) as server: cfg = read_config("example/local.json") cfg.vault.url = server.url() @@ -88,9 +92,6 @@ def test_can_start_server_with_local_volume(monkeypatch, managed_docker): keygen_all(cfg) configure(cfg, "alice") server_start(cfg, "alice") - assert mock_docker.from_env.called - client = mock_docker.from_env.return_value - assert client.containers.run.call_count == 1 mount = mock_docker.types.Mount assert mount.call_count == 3 assert mount.call_args_list[0] == call( @@ -105,119 +106,43 @@ def test_can_start_server_with_local_volume(monkeypatch, managed_docker): type="volume", read_only=True, ) - assert client.containers.run.call_args == call( - f"mrcide/privateer-server:{cfg.tag}", - auto_remove=True, - detach=True, - name=name, - mounts=[mount.return_value, mount.return_value, mount.return_value], - ports={"22/tcp": 10022}, - ) - - -def test_throws_if_container_already_exists(monkeypatch, managed_docker): - mock_ce = MagicMock() # container exists? - mock_ce.return_value = True - monkeypatch.setattr(privateer2.server, "container_exists", mock_ce) - with vault_dev.Server(export_token=True) as server: - cfg = read_config("example/simple.json") - cfg.vault.url = server.url() - vol_keys = managed_docker("volume") - vol_data = managed_docker("volume") - name = managed_docker("container") - cfg.servers[0].key_volume = vol_keys - cfg.servers[0].data_volume = vol_data - cfg.servers[0].container = name - keygen_all(cfg) - configure(cfg, "alice") - msg = f"Container '{name}' for 'alice' already running" - with pytest.raises(Exception, match=msg): - server_start(cfg, "alice") - assert mock_ce.call_count == 1 - mock_ce.assert_called_with(name) - - -def test_can_stop_server(monkeypatch, managed_docker, capsys): - mock_container = MagicMock() - mock_container.status = "running" - mock_container_if_exists = MagicMock(return_value=mock_container) - monkeypatch.setattr( - privateer2.server, - "container_if_exists", - mock_container_if_exists, - ) - with vault_dev.Server(export_token=True) as server: - cfg = read_config("example/simple.json") - cfg.vault.url = server.url() - vol_keys = managed_docker("volume") - vol_data = managed_docker("volume") - name = managed_docker("container") - cfg.servers[0].key_volume = vol_keys - cfg.servers[0].data_volume = vol_data - cfg.servers[0].container = name - keygen_all(cfg) - configure(cfg, "alice") - capsys.readouterr() # flush previous output - - server_stop(cfg, "alice") - assert mock_container_if_exists.call_count == 1 - assert mock_container_if_exists.call_args == call(name) - assert mock_container.stop.call_count == 1 - assert mock_container.stop.call_args == call() - assert capsys.readouterr().out == "" - - mock_container.status = "exited" - server_stop(cfg, "alice") - assert mock_container_if_exists.call_count == 2 - assert mock_container.stop.call_count == 1 - assert capsys.readouterr().out == "" - - mock_container_if_exists.return_value = None - server_stop(cfg, "alice") - assert mock_container_if_exists.call_count == 3 - assert mock_container.stop.call_count == 1 - assert ( - capsys.readouterr().out - == f"Container '{name}' for 'alice' does not exist\n" + assert mock_start.call_count == 1 + image = f"mrcide/privateer-server:{cfg.tag}" + mounts = [mount.return_value] * 3 + ports = {"22/tcp": 10022} + assert mock_start.call_args == call( + "alice", + name, + image=image, + mounts=mounts, + ports=ports, + dry_run=False, ) -def test_can_get_server_status(monkeypatch, capsys, managed_docker): - mock_container = MagicMock() - mock_container.status = "running" - mock_container_if_exists = MagicMock(return_value=mock_container) - monkeypatch.setattr( - privateer2.server, - "container_if_exists", - mock_container_if_exists, - ) - with vault_dev.Server(export_token=True) as server: - cfg = read_config("example/simple.json") - cfg.vault.url = server.url() - vol_keys = managed_docker("volume") - vol_data = managed_docker("volume") - name = managed_docker("container") - cfg.servers[0].key_volume = vol_keys - cfg.servers[0].data_volume = vol_data - cfg.servers[0].container = name - keygen_all(cfg) - configure(cfg, "alice") - - capsys.readouterr() # flush previous output - - prefix = f"Volume '{vol_keys}' looks configured as 'alice'" - - server_status(cfg, "alice") - assert mock_container_if_exists.call_count == 1 - assert mock_container_if_exists.call_args == call(name) - assert capsys.readouterr().out == f"{prefix}\nrunning\n" - - mock_container.status = "exited" - server_status(cfg, "alice") - assert mock_container_if_exists.call_count == 2 - assert capsys.readouterr().out == f"{prefix}\nexited\n" - - mock_container_if_exists.return_value = None - server_status(cfg, "alice") - assert mock_container_if_exists.call_count == 3 - assert capsys.readouterr().out == f"{prefix}\nnot running\n" +def test_can_stop_server(monkeypatch): + mock_check = MagicMock() + mock_stop = MagicMock() + cfg = MagicMock() + monkeypatch.setattr(privateer2.server, "check", mock_check) + monkeypatch.setattr(privateer2.server, "service_stop", mock_stop) + server_stop(cfg, "alice") + assert mock_check.call_count == 1 + assert mock_check.call_args == call(cfg, "alice", quiet=True) + container = mock_check.return_value.container + assert mock_stop.call_count == 1 + assert mock_stop.call_args == call("alice", container) + + +def test_can_get_server_status(monkeypatch): + mock_check = MagicMock() + mock_status = MagicMock() + cfg = MagicMock() + monkeypatch.setattr(privateer2.server, "check", mock_check) + monkeypatch.setattr(privateer2.server, "service_status", mock_status) + server_status(cfg, "alice") + assert mock_check.call_count == 1 + assert mock_check.call_args == call(cfg, "alice", quiet=False) + container = mock_check.return_value.container + assert mock_status.call_count == 1 + assert mock_status.call_args == call(container) diff --git a/tests/test_service.py b/tests/test_service.py new file mode 100644 index 0000000..81e3ac9 --- /dev/null +++ b/tests/test_service.py @@ -0,0 +1,148 @@ +from unittest.mock import MagicMock, Mock, call + +import pytest + +import docker +import privateer2.service +from privateer2.service import ( + service_command, + service_start, + service_status, + service_stop, +) + + +def test_can_create_command(): + base = ["docker", "run", "--rm", "-d", "--name", "nm"] + assert service_command("img", "nm") == [*base, "img"] + assert service_command("img", "nm", command=["a", "b"]) == [ + *base, + "img", + "a", + "b", + ] + mounts = [docker.types.Mount("/dest", "vol", type="volume", read_only=True)] + assert service_command("img", "nm", mounts=mounts) == [ + *base, + "-v", + "vol:/dest:ro", + "img", + ] + assert service_command("img", "nm", ports={"22/tcp": 10022}) == [ + *base, + "-p", + "10022:22", + "img", + ] + + +def test_can_launch_container(monkeypatch): + mock_docker = MagicMock() + client = mock_docker.from_env.return_value + mock_exists = MagicMock() + mock_exists.return_value = False + mock_ensure_image = MagicMock() + mounts = Mock() + ports = Mock() + command = Mock() + monkeypatch.setattr(privateer2.service, "docker", mock_docker) + monkeypatch.setattr(privateer2.service, "container_exists", mock_exists) + monkeypatch.setattr(privateer2.service, "ensure_image", mock_ensure_image) + service_start( + "alice", "nm", "img", mounts=mounts, ports=ports, command=command + ) + assert mock_exists.call_count == 1 + assert mock_exists.call_args == call("nm") + assert mock_ensure_image.call_count == 1 + assert mock_ensure_image.call_args == call("img") + assert mock_docker.from_env.call_count == 1 + assert client.containers.run.call_count == 1 + assert client.containers.run.call_args == call( + "img", + auto_remove=True, + detach=True, + name="nm", + mounts=mounts, + ports=ports, + command=command, + ) + + +def test_throws_if_container_already_exists(monkeypatch): + mock_exists = MagicMock() + mock_exists.return_value = True + monkeypatch.setattr(privateer2.service, "container_exists", mock_exists) + msg = "Container 'nm' for 'alice' already running" + with pytest.raises(Exception, match=msg): + service_start("alice", "nm", "img") + assert mock_exists.call_count == 1 + mock_exists.assert_called_with("nm") + + +def test_returns_cmd_even_if_container_already_exists(capsys, monkeypatch): + mock_exists = MagicMock() + mock_exists.return_value = True + monkeypatch.setattr(privateer2.service, "container_exists", mock_exists) + service_start("alice", "nm", "img", dry_run=True) + expected = service_command("img", "nm") + out = capsys.readouterr().out + assert " ".join(expected) in out + assert mock_exists.call_count == 0 + + +def test_can_stop_service(monkeypatch, capsys): + mock_container = MagicMock() + mock_container.status = "running" + mock_container_if_exists = MagicMock(return_value=mock_container) + monkeypatch.setattr( + privateer2.service, + "container_if_exists", + mock_container_if_exists, + ) + + service_stop("alice", "nm") + assert mock_container_if_exists.call_count == 1 + assert mock_container_if_exists.call_args == call("nm") + assert mock_container.stop.call_count == 1 + assert mock_container.stop.call_args == call() + assert capsys.readouterr().out == "" + + mock_container.status = "exited" + service_stop("alice", "nm") + assert mock_container_if_exists.call_count == 2 + assert mock_container.stop.call_count == 1 + assert capsys.readouterr().out == "" + + mock_container_if_exists.return_value = None + service_stop("alice", "nm") + assert mock_container_if_exists.call_count == 3 + assert mock_container.stop.call_count == 1 + assert ( + capsys.readouterr().out == "Container 'nm' for 'alice' does not exist\n" + ) + + +def test_can_get_service_status(monkeypatch, capsys): + mock_container = MagicMock() + mock_container.status = "running" + mock_container_if_exists = MagicMock(return_value=mock_container) + monkeypatch.setattr( + privateer2.service, + "container_if_exists", + mock_container_if_exists, + ) + + service_status("nm") + assert mock_container_if_exists.call_count == 1 + assert mock_container_if_exists.call_args == call("nm") + assert capsys.readouterr().out == "running\n" + + mock_container.status = "exited" + service_status("nm") + assert mock_container_if_exists.call_count == 2 + assert capsys.readouterr().out == "exited\n" + + mock_container_if_exists.return_value = None + service_status("nm") + assert mock_container_if_exists.call_count == 3 + assert capsys.readouterr().out == "not running\n" diff --git a/tests/test_util.py b/tests/test_util.py index a743b23..c8e2320 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -161,3 +161,29 @@ def test_can_take_ownership_of_a_file(tmp_path, managed_docker): info = os.stat(path) assert info.st_uid == uid assert info.st_gid == gid + + +def test_can_format_ports(): + ports_str = privateer2.util.ports_str + assert ports_str(None) == [] + assert ports_str({"22/tcp": 10022}) == ["-p", "10022:22"] + assert ports_str({"22": 10022}) == ["-p", "10022:22"] + + +def test_can_test_if_container_exists(managed_docker): + name = managed_docker("container") + assert not privateer2.util.container_exists(name) + assert privateer2.util.container_if_exists(name) is None + privateer2.util.ensure_image("alpine") + cl = docker.from_env() + container = cl.containers.create("alpine", name=name) + assert privateer2.util.container_exists(name) + assert privateer2.util.container_if_exists(name) == container + + +def test_can_copy_string_into_volume(managed_docker): + vol = managed_docker("volume") + privateer2.util.string_to_volume("hello", vol, "test") + assert privateer2.util.string_from_volume(vol, "test") == "hello" + privateer2.util.string_to_volume(["hello", "world"], vol, "test") + assert privateer2.util.string_from_volume(vol, "test") == "hello\nworld\n"