Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions src/constellation/acme.py
Original file line number Diff line number Diff line change
@@ -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:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this an env var and not a config value? Is the idea that this allows you to test "real" configs with a fake acme buddy set up?

Copy link
Contributor Author

@weshinsley weshinsley Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question - in acme buddy, either the --staging flag or existence of a non-empty ACME_BUDDY_STAGING environment var achieve the same purpose - perhaps in constellation I should accept it as optional in both the config, and the environment...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, I treat the env part of the config as a block, whatever it contains, so ACME_BUDDY_STAGING can be passed in as acme_buddy: env: ACME_BUDDY_STAGING. If the environment variable is present, then that overwrites the config, which is I think the right way round.

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
16 changes: 11 additions & 5 deletions src/constellation/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import yaml

from constellation import vault
from constellation import acme, vault
from constellation.util import ImageReference


Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)

Expand All @@ -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
Expand All @@ -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])
Expand All @@ -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
Expand Down
10 changes: 6 additions & 4 deletions src/constellation/constellation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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
Expand Down Expand Up @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion src/constellation/docker_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
102 changes: 102 additions & 0 deletions tests/test_acme.py
Original file line number Diff line number Diff line change
@@ -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": "[email protected]",
"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 == "[email protected]"
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": "[email protected]",
"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 == "[email protected]"
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="[email protected]",
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] == "[email protected]"
assert acme.args[4] == "--dns-provider"
assert acme.args[5] == "cloudflare"
assert acme.args[-1] == "prefix-proxy"
6 changes: 3 additions & 3 deletions tests/test_constellation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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")

Expand Down
2 changes: 1 addition & 1 deletion tests/test_docker_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down