diff --git a/src/constellation/acme.py b/src/constellation/acme.py new file mode 100644 index 0000000..b16d32c --- /dev/null +++ b/src/constellation/acme.py @@ -0,0 +1,66 @@ +import os + +import constellation +from constellation.config import ( + config_dict, + config_integer, + config_list, + config_string, +) + + +class AcmeBuddyConfig: + def __init__(self, data, path: list[str]): + name = config_string(data, [*path, "image", "name"]) + repo = config_string(data, [*path, "image", "repo"]) + tag = config_string(data, [*path, "image", "tag"]) + self.ref = constellation.ImageReference(repo, name, tag) + self.port = config_integer(data, [*path, "port"]) + self.dns_provider = config_string(data, [*path, "dns_provider"]) + self.env = config_dict(data, [*path, "env"]) + if "ACME_BUDDY_STAGING" in os.environ: + self.env["ACME_BUDDY_STAGING"] = os.environ["ACME_BUDDY_STAGING"] + self.email = config_string(data, [*path, "email"]) + if "additional_domains" in config_dict(data, path): + self.additional_domains = config_list( + data, [*path, "additional_domains"] + ) + + +def acme_buddy_container( + cfg: AcmeBuddyConfig, name: str, proxy: str, volume: str, hostname: str +) -> constellation.ConstellationContainer: + acme_mounts = [ + constellation.ConstellationVolumeMount(volume, "/tls"), + constellation.ConstellationBindMount( + "/var/run/docker.sock", + "/var/run/docker.sock", + ), + ] + + domain_names = ",".join((hostname, *cfg.additional_domains)) + + acme = constellation.ConstellationContainer( + name, + cfg.ref, + ports=[cfg.port], + mounts=acme_mounts, + environment=cfg.env, + args=[ + "--domain", + domain_names, + "--email", + cfg.email, + "--dns-provider", + cfg.dns_provider, + "--certificate-path", + "/tls/certificate.pem", + "--key-path", + "/tls/key.pem", + "--account-path", + "/tls/account.json", + "--reload-container", + proxy, + ], + ) + return acme diff --git a/src/constellation/config.py b/src/constellation/config.py index 0b8f920..77415ca 100644 --- a/src/constellation/config.py +++ b/src/constellation/config.py @@ -4,7 +4,7 @@ import yaml -from constellation import vault +from constellation import acme, vault from constellation.util import ImageReference @@ -32,7 +32,7 @@ def config_build(path, data, extra=None, options=None): # Utility function for centralising control over pulling information # out of the configuration. def config_value(data, path, data_type, is_optional, default=None): - if type(path) is str: + if isinstance(path, str): path = [path] for i, p in enumerate(path): try: @@ -67,6 +67,12 @@ def config_vault(data, path): return vault.VaultConfig(url, auth_method, auth_args) +def config_acme(data, path): + if isinstance(path, str): + path = [path] + return acme.AcmeBuddyConfig(data, path) + + def config_string(data, path, is_optional=False, default=None): return config_value(data, path, "string", is_optional, default) @@ -91,7 +97,7 @@ def config_dict_strict(data, path, keys, is_optional=False, default=None): msg = "Expected keys {} for {}".format(", ".join(keys), ":".join(path)) raise ValueError(msg) for k, v in d.items(): - if type(v) is not str: + if not isinstance(v, str): msg = "Expected a string for {}".format(":".join([*path, k])) raise ValueError(msg) return d @@ -112,7 +118,7 @@ def config_enum(data, path, values, is_optional=False, default=None): def config_image_reference(dat, path, name="name"): - if type(path) is str: + if isinstance(path, str): path = [path] repo = config_string(dat, [*path, "repo"]) name = config_string(dat, [*path, name]) @@ -130,7 +136,7 @@ def combine(base, extra): """Combine exactly two dictionaries recursively, modifying the first argument in place with the contets of the second""" for k, v in extra.items(): - if k in base and type(base[k]) is dict and v is not None: + if k in base and isinstance(base[k], dict) and v is not None: combine(base[k], v) else: base[k] = v diff --git a/src/constellation/constellation.py b/src/constellation/constellation.py index 974df42..9a80fc8 100644 --- a/src/constellation/constellation.py +++ b/src/constellation/constellation.py @@ -18,16 +18,17 @@ def __init__( volumes, data=None, vault_config=None, + acme_buddy=None, ): self.data = data - assert type(name) is str + assert isinstance(name, str) self.name = name - assert type(prefix) is str + assert isinstance(prefix, str) self.prefix = prefix - assert type(network) is str + assert isinstance(network, str) self.network = ConstellationNetwork(network) self.volumes = ConstellationVolumeCollection(volumes) @@ -36,6 +37,7 @@ def __init__( self.containers = ConstellationContainerCollection(containers) self.vault_config = vault_config + self.acme_buddy = acme_buddy def status(self): nw_name = self.network.name @@ -216,7 +218,7 @@ def remove(self, prefix): container.remove() -# This could be achievd by inheriting from ConstellationContainer but +# This could be achieved by inheriting from ConstellationContainer but # this seems more like a has-a than an is-a relationship. class ConstellationService: def __init__(self, name, image, scale, **kwargs): diff --git a/src/constellation/docker_util.py b/src/constellation/docker_util.py index 10a0f19..d0aa325 100644 --- a/src/constellation/docker_util.py +++ b/src/constellation/docker_util.py @@ -196,7 +196,7 @@ def string_from_container(container, path): def bytes_from_container(container, path): - stream, status = container.get_archive(path) + stream, _status = container.get_archive(path) try: fd, tmp = tempfile.mkstemp(text=False) with os.fdopen(fd, "wb") as f: diff --git a/tests/test_acme.py b/tests/test_acme.py new file mode 100644 index 0000000..41ca19e --- /dev/null +++ b/tests/test_acme.py @@ -0,0 +1,102 @@ +import types + +from constellation.acme import acme_buddy_container +from constellation.config import config_acme + + +def test_acme_buddy_config_hdb(): + data = { + "acme_buddy": { + "email": "reside@imperial.ac.uk", + "image": { + "repo": "ghcr.io/reside-ic", + "name": "acme-buddy", + "tag": "main", + }, + "port": 2112, + "dns_provider": "hdb", + "env": { + "HDB_ACME_USERNAME": "testuser", + "HDB_ACME_PASSWORD": "testpw", + }, + }, + } + + cfg = config_acme(data, "acme_buddy") + assert cfg.email == "reside@imperial.ac.uk" + assert cfg.dns_provider == "hdb" + assert cfg.ref.repo == "ghcr.io/reside-ic" + assert cfg.ref.name == "acme-buddy" + assert cfg.ref.tag == "main" + assert cfg.port == 2112 + assert cfg.env["HDB_ACME_USERNAME"] == "testuser" + assert cfg.env["HDB_ACME_PASSWORD"] == "testpw" + + +def test_acme_buddy_config_cloudflare(monkeypatch): + monkeypatch.setenv("ACME_BUDDY_STAGING", "0") + data = { + "acme_buddy": { + "additional_domains": ["anotherhost.com"], + "email": "reside@imperial.ac.uk", + "image": { + "repo": "ghcr.io/reside-ic", + "name": "acme-buddy", + "tag": "main", + }, + "port": 2112, + "dns_provider": "cloudflare", + "env": { + "CLOUDFLARE_DNS_API_TOKEN": "abcdefgh12345678", + }, + }, + } + cfg = config_acme(data, "acme_buddy") + assert cfg.email == "reside@imperial.ac.uk" + assert cfg.dns_provider == "cloudflare" + assert cfg.ref.repo == "ghcr.io/reside-ic" + assert cfg.ref.name == "acme-buddy" + assert cfg.ref.tag == "main" + assert cfg.port == 2112 + assert cfg.additional_domains == ["anotherhost.com"] + assert cfg.env["CLOUDFLARE_DNS_API_TOKEN"] == "abcdefgh12345678" + assert cfg.env["ACME_BUDDY_STAGING"] == "0" + + +def test_acme_buddy_container(): + cfg = types.SimpleNamespace() + cfg.containers = {"acme-buddy": "acme-buddy"} + cfg.container_prefix = "prefix" + cfg.hostname = "example.com" + cfg.acme_buddy = types.SimpleNamespace( + dns_provider="cloudflare", + env={ + "CLOUDFLARE_DNS_API_TOKEN": "abcdefgh12345678", + "ACME_BUDDY_STAGING": "0", + }, + ref="ghcr.io/reside-ic/acme-buddy:main", + port=2112, + email="reside@imperial.ac.uk", + additional_domains=["www.example.com"], + ) + + proxy = types.SimpleNamespace() + proxy.name_external = lambda prefix: f"{prefix}-proxy" + tls_volume = "tls-volume" + acme = acme_buddy_container( + cfg.acme_buddy, + cfg.containers["acme-buddy"], + proxy.name_external(cfg.container_prefix), + tls_volume, + cfg.hostname, + ) + + assert acme.environment["ACME_BUDDY_STAGING"] == "0" + assert acme.environment["CLOUDFLARE_DNS_API_TOKEN"] == "abcdefgh12345678" + assert acme.args[0] == "--domain" + assert acme.args[1] == "example.com,www.example.com" + assert acme.args[2] == "--email" + assert acme.args[3] == "reside@imperial.ac.uk" + assert acme.args[4] == "--dns-provider" + assert acme.args[5] == "cloudflare" + assert acme.args[-1] == "prefix-proxy" diff --git a/tests/test_constellation.py b/tests/test_constellation.py index 5ce2ec1..f8ddd56 100644 --- a/tests/test_constellation.py +++ b/tests/test_constellation.py @@ -100,7 +100,7 @@ def test_empty_volume_collection(): def test_volume_mount_with_relative_paths(): with pytest.raises( - ValueError, match="Path 'target_path' must be an absolute path." + ValueError, match=r"Path 'target_path' must be an absolute path." ): ConstellationVolumeMount("role1", "target_path") @@ -142,11 +142,11 @@ def test_volume_mount_with_args(): def test_bind_mount_with_relative_paths(): with pytest.raises( - ValueError, match="Path 'target_path' must be an absolute path." + ValueError, match=r"Path 'target_path' must be an absolute path." ): ConstellationBindMount("/source_path", "target_path") with pytest.raises( - ValueError, match="Path 'source_path' must be an absolute path." + ValueError, match=r"Path 'source_path' must be an absolute path." ): ConstellationBindMount("source_path", "/target_path") diff --git a/tests/test_docker_util.py b/tests/test_docker_util.py index 39fe39f..5a8f1a3 100644 --- a/tests/test_docker_util.py +++ b/tests/test_docker_util.py @@ -48,7 +48,7 @@ def test_exec_safely_throws_on_failure(): container = cl.containers.run( "alpine", ["sleep", "10"], detach=True, auto_remove=True ) - with pytest.raises(Exception, match=""): + with pytest.raises(Exception, match=None): exec_safely(container, "missing_command") container.kill()