From 86d88a14cd4f1e4ccac9eb561cc9b0fd92cbfda3 Mon Sep 17 00:00:00 2001 From: Wes Hinsley Date: Thu, 23 Oct 2025 17:06:27 +0100 Subject: [PATCH 01/15] Use acme buddy --- config/acme/montagu.yml | 20 +++-- config/complete/montagu.yml | 11 --- src/montagu_deploy/certbot.py | 90 --------------------- src/montagu_deploy/cli.py | 20 +---- src/montagu_deploy/config.py | 22 +++-- src/montagu_deploy/montagu_constellation.py | 77 ++++++++++++------ tests/test_cli.py | 12 +-- tests/test_config.py | 11 +-- 8 files changed, 82 insertions(+), 181 deletions(-) delete mode 100644 src/montagu_deploy/certbot.py diff --git a/config/acme/montagu.yml b/config/acme/montagu.yml index c6cc386..f6f4abc 100644 --- a/config/acme/montagu.yml +++ b/config/acme/montagu.yml @@ -24,9 +24,7 @@ volumes: templates: template_volume guidance: guidance_volume mq: mq - acme-challenge: acme-challenge - certificates: certificates - certbot: certbot + montagu-tls: montagu-tls api: name: montagu-api @@ -76,10 +74,18 @@ proxy: repo: nginx name: nginx-prometheus-exporter tag: 1.3.0 - acme: - email: admin@montagu.org - additional_domains: - - montagu-dev.org + +acme_buddy: + email: admin@montagu.org + additional_domains: + - montagu-dev.org + repo: ghcr.io/reside-ic + name: acme-buddy + tag: main + hdb_username: VAULT:secret/certbot-hdb/credentials:username + hdb_password: VAULT:secret/certbot-hdb/credentials:password + port: 2112 + contrib: name: montagu-contrib-portal tag: master diff --git a/config/complete/montagu.yml b/config/complete/montagu.yml index ab02359..4c07c56 100644 --- a/config/complete/montagu.yml +++ b/config/complete/montagu.yml @@ -83,17 +83,6 @@ proxy: repo: nginx name: nginx-prometheus-exporter tag: 1.3.0 - ## This section describes how to get the certificate in. We - ## support two sources: - ## - ## 1. self signed certificates - just leave this section blank - ## - ## 2. certificates from strings - include the strings directly in - ## the keys here, or more likely use a VAULT:: - ## string to extract them from the vault. - ssl: - key: "k3y" - certificate: "cert" contrib: name: montagu-contrib-portal tag: master diff --git a/src/montagu_deploy/certbot.py b/src/montagu_deploy/certbot.py deleted file mode 100644 index 199b282..0000000 --- a/src/montagu_deploy/certbot.py +++ /dev/null @@ -1,90 +0,0 @@ -# https://github.com/certbot/certbot/blob/v3.0.1/acme/examples/http01_example.py - -import os.path -import sys -import tarfile -from tempfile import TemporaryFile - -import docker -from constellation import docker_util - -# The Docker API uses Go's FileMode values. These are different from the -# standard values, as found in eg. stat.S_IFLNK. -# https://pkg.go.dev/io/fs#FileMode -DOCKER_MODE_TYPE = 0x8F280000 -DOCKER_MODE_SYMLINK = 0x8000000 - - -def read_file(container, path, *, follow_links=False): - stream, status = container.get_archive(path) - if follow_links and (status["mode"] & DOCKER_MODE_TYPE) == DOCKER_MODE_SYMLINK: - return read_file(container, status["linkTarget"], follow_links=False) - else: - with TemporaryFile() as f: - for d in stream: - f.write(d) - f.seek(0) - - with tarfile.open(fileobj=f) as tar: - return tar.extractfile(os.path.basename(path)).read() - - -def obtain_certificate(cfg, extra_args): - docker_util.ensure_volume(cfg.volumes["certbot"]) - docker_util.ensure_volume(cfg.volumes["acme-challenge"]) - - environment = {} - command = [ - "certonly", - "--non-interactive", - "--agree-tos", - "--webroot", - "--webroot-path=/var/www", - f"--email={cfg.acme_email}", - f"--domain={cfg.hostname}", - ] - - for d in cfg.acme_additional_domains: - command.append(f"--domain={d}") - - if cfg.acme_server: - command.append(f"--server={cfg.acme_server}"), - if cfg.acme_no_verify_ssl: - command.append("--no-verify-ssl") - environment["PYTHONWARNINGS"] = "ignore:Unverified HTTPS request" - - command.extend(extra_args) - - image = "certbot/certbot" - container = docker.from_env().containers.run( - image, - command=command, - detach=True, - volumes={ - cfg.volumes["acme-challenge"]: { - "bind": "/var/www/.well-known/acme-challenge", - "mode": "rw", - }, - cfg.volumes["certbot"]: { - "bind": "/etc/letsencrypt", - "mode": "rw", - }, - }, - network=cfg.network, - environment=environment, - ) - - try: - exit_status = container.wait()["StatusCode"] - - sys.stderr.write(container.logs().decode("utf-8")) - if exit_status != 0: - raise docker.errors.ContainerError(container, exit_status, command, image, None) - - cert = read_file(container, f"/etc/letsencrypt/live/{cfg.hostname}/fullchain.pem", follow_links=True) - key = read_file(container, f"/etc/letsencrypt/live/{cfg.hostname}/privkey.pem", follow_links=True) - - return (cert, key) - - finally: - container.remove() diff --git a/src/montagu_deploy/cli.py b/src/montagu_deploy/cli.py index 7495971..6046aa5 100644 --- a/src/montagu_deploy/cli.py +++ b/src/montagu_deploy/cli.py @@ -5,7 +5,6 @@ montagu status [--name=PATH] montagu stop [--name=PATH] [--volumes] [--network] [--kill] [--force] [--extra=PATH] [--option=OPTION]... - montagu renew-certificate [--name=PATH] [--option=OPTION]... [--] [ARGS...] Options: --name=PATH Override the configured name, use with care! @@ -26,9 +25,8 @@ import yaml import montagu_deploy.__about__ as about -from montagu_deploy.certbot import obtain_certificate from montagu_deploy.config import MontaguConfig -from montagu_deploy.montagu_constellation import montagu_constellation, proxy_update_certificate +from montagu_deploy.montagu_constellation import montagu_constellation def main(argv=None): @@ -49,8 +47,6 @@ def main(argv=None): montagu_status(obj) elif args.action == "stop": montagu_stop(obj, args, cfg) - elif args.action == "renew-certificate": - montagu_renew_certificate(obj, cfg, args.extra_args) def parse_args(argv=None): @@ -90,18 +86,6 @@ def montagu_status(obj): obj.status() -def montagu_renew_certificate(obj, cfg, extra_args): - if cfg.ssl_mode != "acme": - msg = "Proxy is not configured to use automatic certificates" - raise Exception(msg) - - print("Renewing certificates") - (cert, key) = obtain_certificate(cfg, extra_args) - - container = obj.containers.get("proxy", cfg.container_prefix) - proxy_update_certificate(container, cert, key, reload=True) - - def montagu_stop(obj, args, cfg): if args.volumes: verify_data_loss(cfg) @@ -164,8 +148,6 @@ def __init__(self, args): self.action = "status" elif args["stop"]: self.action = "stop" - elif args["renew-certificate"]: - self.action = "renew-certificate" elif args["configure"]: self.action = "configure" diff --git a/src/montagu_deploy/config.py b/src/montagu_deploy/config.py index fd31916..ba8068d 100644 --- a/src/montagu_deploy/config.py +++ b/src/montagu_deploy/config.py @@ -56,8 +56,8 @@ def __init__(self, path, extra=None, options=None): self.proxy_port_metrics = config.config_integer(dat, ["proxy", "port_metrics"], is_optional=True, default=9000) self.proxy_metrics_ref = self.build_ref(dat["proxy"], "metrics") - if "ssl" in dat["proxy"] and "acme" in dat["proxy"]: - msg = "Cannot specify both ssl and acme options in proxy options." + if "ssl" in dat["proxy"] and "acme_buddy" in dat: + msg = "Cannot specify both ssl in proxy and acme_buddy." raise Exception(msg) if "ssl" in dat["proxy"]: self.ssl_mode = "static" @@ -65,12 +65,12 @@ def __init__(self, path, extra=None, options=None): self.ssl_key = config.config_string(dat, ["proxy", "ssl", "key"]) elif "acme" in dat["proxy"]: self.ssl_mode = "acme" - self.acme_email = config.config_string(dat, ["proxy", "acme", "email"]) - self.acme_server = config.config_string(dat, ["proxy", "acme", "server"], is_optional=True) - self.acme_no_verify_ssl = config.config_boolean(dat, ["proxy", "acme", "no_verify_ssl"], is_optional=True) - self.acme_additional_domains = config.config_list( - dat, ["proxy", "acme", "additional_domains"], is_optional=True, default=[] - ) + self.acme_buddy_ref = self.build_ref(dat, "acme_buddy") + self.acme_buddy_port = config.config_integer(dat, ["acme_buddy", "port"]) + self.acme_buddy_hdb_username = config.config_string(dat, ["acme_buddy", "hdb_username"]) + self.acme_buddy_hdb_password = config.config_string(dat, ["acme_buddy", "hdb_password"]) + self.acme_buddy_email = config.config_string(dat, ["acme_buddy", "email"]) + self.acme_additional_domains = config.config_list(dat, ["acme_buddy", "additional_domains"]) else: self.ssl_mode = "self-signed" @@ -106,6 +106,9 @@ def __init__(self, path, extra=None, options=None): if self.fake_smtp_ref: self.containers["fake_smtp"] = "fake-smtp" + if self.ssl_mode == "acme": + self.containers["acme-buddy"] = "acme-buddy" + self.images = { "db": self.db_ref, "api": self.api_ref, @@ -123,6 +126,9 @@ def __init__(self, path, extra=None, options=None): if self.fake_smtp_ref: self.images["fake_smtp"] = self.fake_smtp_ref + if self.ssl_mode == "acme": + self.images["acme-buddy"] = self.acme_buddy_ref + def build_ref(self, dat, section): name = config.config_string(dat, [section, "name"]) tag = config.config_string(dat, [section, "tag"]) diff --git a/src/montagu_deploy/montagu_constellation.py b/src/montagu_deploy/montagu_constellation.py index 127fb12..db889a2 100644 --- a/src/montagu_deploy/montagu_constellation.py +++ b/src/montagu_deploy/montagu_constellation.py @@ -1,3 +1,4 @@ +import os from os.path import join import constellation @@ -10,18 +11,23 @@ def montagu_constellation(cfg): + proxy = proxy_container(cfg) containers = [ api_container(cfg), db_container(cfg), admin_container(cfg), contrib_container(cfg), - proxy_container(cfg), + proxy, proxy_metrics_container(cfg), mq_container(cfg), flower_container(cfg), task_queue_container(cfg), ] + if cfg.ssl_mode == "acme": + acme_buddy = acme_buddy_container(cfg, proxy) + containers.append(acme_buddy) + if cfg.fake_smtp_ref: fake_smtp = fake_smtp_container(cfg) containers.append(fake_smtp) @@ -92,6 +98,49 @@ def fake_smtp_container(cfg): return constellation.ConstellationContainer(name, cfg.fake_smtp_ref, ports=[1025, 1080]) +def acme_buddy_container(cfg, proxy): + name = cfg.containers["acme-buddy"] + acme_buddy_staging = int(os.environ.get("ACME_BUDDY_STAGING", "0")) + acme_env = { + "ACME_BUDDY_STAGING": acme_buddy_staging, + "HDB_ACME_USERNAME": cfg.acme_buddy_hdb_username, + "HDB_ACME_PASSWORD": cfg.acme_buddy_hdb_password, + } + acme_mounts = [ + constellation.ConstellationVolumeMount("montagu-tls", "/tls"), + constellation.ConstellationBindMount("/var/run/docker.sock", "/var/run/docker.sock"), + ] + + domain_names = cfg.hostname + for i in range(len(cfg.acme_additional_domains)): + domain_names += f",{cfg.acme_additional_domains[i]}" + + acme = constellation.ConstellationContainer( + name, + cfg.acme_buddy_ref, + ports=[cfg.acme_buddy_port], + mounts=acme_mounts, + environment=acme_env, + args=[ + "--domain", + domain_names, + "--email", + cfg.acme_buddy_email, + "--dns-provider", + "hdb", + "--certificate-path", + "/tls/certificate.pem", + "--key-path", + "/tls/key.pem", + "--account-path", + "/tls/account.json", + "--reload-container", + proxy.name_external(cfg.container_prefix), + ], + ) + return acme + + def db_container(cfg): name = cfg.containers["db"] mounts = [constellation.ConstellationVolumeMount("db", "/pgdata")] @@ -215,10 +264,7 @@ def proxy_container(cfg): if cfg.ssl_mode == "acme": mounts.extend( [ - constellation.ConstellationVolumeMount( - "acme-challenge", "/var/www/.well-known/acme-challenge", read_only=True - ), - constellation.ConstellationVolumeMount("certificates", "/etc/montagu/proxy"), + constellation.ConstellationVolumeMount("montagu-tls", "/etc/montagu/proxy"), ] ) @@ -227,31 +273,10 @@ def proxy_container(cfg): cfg.proxy_ref, ports=proxy_ports, args=[str(cfg.proxy_port_https), cfg.hostname], - preconfigure=proxy_preconfigure, mounts=mounts, ) -def proxy_update_certificate(container, cert, key, *, reload): - print("[proxy] Copying ssl certificate and key into proxy") - ssl_path = "/etc/montagu/proxy" - docker_util.string_into_container(cert, container, join(ssl_path, "certificate.pem")) - docker_util.string_into_container(key, container, join(ssl_path, "ssl_key.pem")) - - if reload: - print("[proxy] Reloading nginx") - docker_util.exec_safely(container, "nginx -s reload") - - -def proxy_preconfigure(container, cfg): - # In self-signed mode, the container generates its own certificate on its - # own. Similarly, in ACME mode, the container generates its own certificate - # and after starting we request a new one. - if cfg.ssl_mode == "static": - print("[proxy] Configuring reverse proxy") - proxy_update_certificate(container, cfg.ssl_certificate, cfg.ssl_key, reload=False) - - def proxy_metrics_container(cfg): proxy_name = cfg.containers["proxy"] return constellation.ConstellationContainer( diff --git a/tests/test_cli.py b/tests/test_cli.py index 9a14df4..0bac3a4 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -60,7 +60,7 @@ def test_parse_args(): def test_version(capsys): cli.main(["--version"]) - out, err = capsys.readouterr() + out, _err = capsys.readouterr() assert re.match(r"\d+\.\d+\.\d+", out) @@ -104,16 +104,6 @@ def test_args_passed_to_configure(): assert f.mock_calls[0] == mock.call("config/basic") -def test_can_parse_extra_certbot_args(): - res = cli.parse_args(["renew-certificate", "--name=config/basic", "--", "--force-renewal"]) - assert res[0] == "config/basic" - assert res[1] is None - assert res[2] == [] - args = res[3] - assert args.action == "renew-certificate" - assert args.extra_args == ["--force-renewal"] - - def test_verify_data_loss_called(): f = io.StringIO() with redirect_stdout(f): diff --git a/tests/test_config.py b/tests/test_config.py index 2a515c7..4262c88 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -76,17 +76,10 @@ def test_config_email(): assert cfg.email_flow_url == "fakeurl" -def test_config_ssl(): - cfg = MontaguConfig("config/complete") - assert cfg.ssl_mode == "static" - assert cfg.ssl_certificate == "cert" - assert cfg.ssl_key == "k3y" - - def test_config_acme(): cfg = MontaguConfig("config/acme") assert cfg.ssl_mode == "acme" - assert cfg.acme_email == "admin@montagu.org" + assert cfg.acme_buddy_email == "admin@montagu.org" assert cfg.acme_server is None @@ -109,5 +102,5 @@ def test_config_streaming_replication(): def test_config_validates_db_user_permissions(): options = {"db": {"users": {"api": {"permissions": "bad", "password": "pw"}}}} - with pytest.raises(Exception, match="Invalid database permissions for 'api'."): + with pytest.raises(Exception, match=r"Invalid database permissions for 'api'."): MontaguConfig("config/basic", options=options) From 427c628ba60cfc87774b7bbd559d2bd0e66f310d Mon Sep 17 00:00:00 2001 From: Wes Hinsley Date: Thu, 23 Oct 2025 20:28:26 +0100 Subject: [PATCH 02/15] Fix tests --- src/montagu_deploy/cli.py | 1 - src/montagu_deploy/config.py | 2 +- tests/test_config.py | 1 - tests/test_constellation.py | 17 ----------------- 4 files changed, 1 insertion(+), 20 deletions(-) diff --git a/src/montagu_deploy/cli.py b/src/montagu_deploy/cli.py index 6046aa5..4c6b4a5 100644 --- a/src/montagu_deploy/cli.py +++ b/src/montagu_deploy/cli.py @@ -156,7 +156,6 @@ def __init__(self, args): self.volumes = args["--volumes"] self.network = args["--network"] self.version = args["--version"] - self.extra_args = args["ARGS"] IDENTITY_FILE = Path(".montagu_identity") diff --git a/src/montagu_deploy/config.py b/src/montagu_deploy/config.py index ba8068d..6cd7c60 100644 --- a/src/montagu_deploy/config.py +++ b/src/montagu_deploy/config.py @@ -63,7 +63,7 @@ def __init__(self, path, extra=None, options=None): self.ssl_mode = "static" self.ssl_certificate = config.config_string(dat, ["proxy", "ssl", "certificate"]) self.ssl_key = config.config_string(dat, ["proxy", "ssl", "key"]) - elif "acme" in dat["proxy"]: + elif "acme_buddy" in dat: self.ssl_mode = "acme" self.acme_buddy_ref = self.build_ref(dat, "acme_buddy") self.acme_buddy_port = config.config_integer(dat, ["acme_buddy", "port"]) diff --git a/tests/test_config.py b/tests/test_config.py index 4262c88..5d63560 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -80,7 +80,6 @@ def test_config_acme(): cfg = MontaguConfig("config/acme") assert cfg.ssl_mode == "acme" assert cfg.acme_buddy_email == "admin@montagu.org" - assert cfg.acme_server is None def test_config_generates_root_db_password(): diff --git a/tests/test_constellation.py b/tests/test_constellation.py index 857c1aa..49e6c7e 100644 --- a/tests/test_constellation.py +++ b/tests/test_constellation.py @@ -124,23 +124,6 @@ def test_db_configured(): obj.stop(kill=True, remove_volumes=True) -def test_proxy_configured_ssl(): - cfg = MontaguConfig("config/complete") - obj = montagu_constellation(cfg) - - try: - obj.start() - - api = get_container(cfg, "proxy") - cert = docker_util.string_from_container(api, "/etc/montagu/proxy/certificate.pem") - key = docker_util.string_from_container(api, "/etc/montagu/proxy/ssl_key.pem") - assert cert == "cert" - assert key == "k3y" - - finally: - obj.stop(kill=True, remove_volumes=True) - - def test_metrics(): cfg = MontaguConfig("config/basic") obj = montagu_constellation(cfg) From 402a7954f58e1cae6f10dab975e64486b020594d Mon Sep 17 00:00:00 2001 From: Wes Hinsley Date: Thu, 23 Oct 2025 20:51:04 +0100 Subject: [PATCH 03/15] Remove cert test --- tests/test_integration.py | 72 --------------------------------------- 1 file changed, 72 deletions(-) diff --git a/tests/test_integration.py b/tests/test_integration.py index 3eff69e..185885e 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -156,75 +156,3 @@ def add_task_queue_user(cfg, packit): admin.add_user(cfg, "task.queue", "task.queue", "montagu-task@imperial.ac.uk", "password") admin.add_role_to_user(cfg, "task.queue", "user") - - -def test_acme_certificate(): - path = "config/acme" - network = "montagu-network" - - try: - options = [ - "--option=proxy.acme.server=https://pebble/dir", - "--option=proxy.acme.no_verify_ssl=true", - ] - - cli.main(["start", "--name", path, *options]) - - # wait for nginx to be ready - http_get("https://localhost") - - # We need Pebble to be able to resolve the proxy at the names used in the certificate. - # We set this up by adding a custom /etc/hosts in the pebble container pointing to the - # right IP address. - container = docker.from_env().containers.get("montagu-proxy") - ip = container.attrs["NetworkSettings"]["Networks"][network]["IPAddress"] - hostnames = { - "montagu.org": ip, - "montagu-dev.org": ip, - } - - with run_pebble(hostname="pebble", network=network, extra_hosts=hostnames): - # Initially the server starts with a self-signed certificate. - # This allows it to start even before we get our first cert. - cert_pem = ssl.get_server_certificate(("localhost", 443)) - cert = x509.load_pem_x509_certificate(cert_pem.encode("ascii")) - assert cert.issuer == cert.subject - self_signed_fingerprint = cert.fingerprint(hashes.SHA256()) - - # Renew the certificate using ACME. Confirm that it worked by - # looking at the issuer's CN. It can take some time for nginx to - # reload, so loop until the certificate has changed. - cli.main(["renew-certificate", "--name", path, *options]) - - for _ in range(5): - cert_pem = ssl.get_server_certificate(("localhost", 443)) - cert = x509.load_pem_x509_certificate(cert_pem.encode("ascii")) - time.sleep(0.5) - if cert.fingerprint(hashes.SHA256()) != self_signed_fingerprint: - break - else: - pytest.fail("Certificate was not reloaded") - - acme_fingerprint = cert.fingerprint(hashes.SHA256()) - assert "CN=Pebble Intermediate CA" in cert.issuer.rfc4514_string() - san = cert.extensions.get_extension_for_oid(ExtensionOID.SUBJECT_ALTERNATIVE_NAME) - assert set(san.value.get_values_for_type(x509.DNSName)) == { - "montagu.org", - "montagu-dev.org", - } - - # When restarting the server, the certificate we got from ACME is - # carried over and is immediately available, no need to issue it - # again. - cli.main(["stop", "--name", path, "--kill"]) - cli.main(["start", "--name", path, *options]) - - cert_pem = ssl.get_server_certificate(("localhost", 443)) - cert = x509.load_pem_x509_certificate(cert_pem.encode("ascii")) - assert "CN=Pebble Intermediate CA" in cert.issuer.rfc4514_string() - assert cert.fingerprint(hashes.SHA256()) == acme_fingerprint - - finally: - with mock.patch("src.montagu_deploy.cli.prompt_yes_no") as prompt: - prompt.return_value = True - cli.main(["stop", "--name", path, "--kill", "--volumes", "--network"]) From 17c58b73bebc3e3f06d2a0b446ceb53b1f4cbd52 Mon Sep 17 00:00:00 2001 From: Wes Hinsley Date: Thu, 23 Oct 2025 21:44:24 +0100 Subject: [PATCH 04/15] Clean up imports --- tests/test_integration.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/tests/test_integration.py b/tests/test_integration.py index 185885e..d668ad9 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -1,18 +1,12 @@ import os -import ssl -import time from unittest import mock import celery import docker -import pytest import requests import tenacity import vault_dev from constellation import docker_util -from cryptography import x509 -from cryptography.hazmat.primitives import hashes -from cryptography.x509.oid import ExtensionOID from packit_deploy.config import PackitConfig from packit_deploy.packit_constellation import PackitConstellation from YTClient.YTClient import YTClient @@ -21,7 +15,7 @@ from src.montagu_deploy import cli from src.montagu_deploy.config import MontaguConfig from tests import admin -from tests.utils import http_get, run_pebble +from tests.utils import http_get def test_start_stop_status(): From 74ec088a58878f33464ae9c55fd231500dc8f9a1 Mon Sep 17 00:00:00 2001 From: Wes Hinsley Date: Fri, 24 Oct 2025 15:02:39 +0100 Subject: [PATCH 05/15] More test cleanup --- src/montagu_deploy/config.py | 9 +-------- tests/test_constellation.py | 13 +++++++++++++ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/montagu_deploy/config.py b/src/montagu_deploy/config.py index 6cd7c60..47b447e 100644 --- a/src/montagu_deploy/config.py +++ b/src/montagu_deploy/config.py @@ -56,14 +56,7 @@ def __init__(self, path, extra=None, options=None): self.proxy_port_metrics = config.config_integer(dat, ["proxy", "port_metrics"], is_optional=True, default=9000) self.proxy_metrics_ref = self.build_ref(dat["proxy"], "metrics") - if "ssl" in dat["proxy"] and "acme_buddy" in dat: - msg = "Cannot specify both ssl in proxy and acme_buddy." - raise Exception(msg) - if "ssl" in dat["proxy"]: - self.ssl_mode = "static" - self.ssl_certificate = config.config_string(dat, ["proxy", "ssl", "certificate"]) - self.ssl_key = config.config_string(dat, ["proxy", "ssl", "key"]) - elif "acme_buddy" in dat: + if "acme_buddy" in dat: self.ssl_mode = "acme" self.acme_buddy_ref = self.build_ref(dat, "acme_buddy") self.acme_buddy_port = config.config_integer(dat, ["acme_buddy", "port"]) diff --git a/tests/test_constellation.py b/tests/test_constellation.py index 49e6c7e..20c613b 100644 --- a/tests/test_constellation.py +++ b/tests/test_constellation.py @@ -139,3 +139,16 @@ def test_metrics(): def get_container(cfg, name): cl = docker.client.from_env() return cl.containers.get(f"{cfg.container_prefix}-{cfg.containers[name]}") + + +def test_acme_buddy(): + cfg = MontaguConfig("config/acme") + obj = montagu_constellation(cfg) + assert cfg.ssl_mode == "acme" + assert "acme-buddy" in cfg.containers + assert "acme-buddy" in cfg.images + try: + obj.start() + acme = get_container(cfg, "acme-buddy") + finally: + obj.stop(kill=True) From 47d2f4def8adb80e609e7d3a6da1e90e64ad473e Mon Sep 17 00:00:00 2001 From: Wes Hinsley Date: Fri, 24 Oct 2025 15:10:53 +0100 Subject: [PATCH 06/15] Bit more acme test --- tests/test_constellation.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_constellation.py b/tests/test_constellation.py index 20c613b..b36ddfb 100644 --- a/tests/test_constellation.py +++ b/tests/test_constellation.py @@ -150,5 +150,10 @@ def test_acme_buddy(): try: obj.start() acme = get_container(cfg, "acme-buddy") + env = acme.attrs["Config"]["Env"] + env_dict = dict(e.split("=", 1) for e in env) + assert "HDB_ACME_USERNAME" in env_dict + assert "HDB_ACME_PASSWORD" in env_dict + finally: obj.stop(kill=True) From e69fd3da2db3e9cf773d2edd096b0b672bd7dbcd Mon Sep 17 00:00:00 2001 From: Wes Hinsley Date: Fri, 24 Oct 2025 15:35:46 +0100 Subject: [PATCH 07/15] Vault for acme test --- config/acme/montagu.yml | 17 ++++++++++++++++- tests/test_integration.py | 19 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/config/acme/montagu.yml b/config/acme/montagu.yml index f6f4abc..0ee676f 100644 --- a/config/acme/montagu.yml +++ b/config/acme/montagu.yml @@ -1,3 +1,19 @@ +vault: + ## Address of the vault server. This should be a string if it is + ## present. + addr: ~ + auth: + ## Authentication type - must be either "token" or the name of a + ## supported authentication method. These seem to be poorly + ## documented in the hvac, but include "github" for github + ## authentication. + ## + ## On a vault client object, see auth.implemented_class_names for + ## a list, which is currently + ## + ## azure, github, gcp, kubernetes, ldap, mfa, okta + method: token + ## Prefix for container names; we'll use {container_prefix}-(container_name) container_prefix: montagu @@ -74,7 +90,6 @@ proxy: repo: nginx name: nginx-prometheus-exporter tag: 1.3.0 - acme_buddy: email: admin@montagu.org additional_domains: diff --git a/tests/test_integration.py b/tests/test_integration.py index d668ad9..877bf9e 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -150,3 +150,22 @@ def add_task_queue_user(cfg, packit): admin.add_user(cfg, "task.queue", "task.queue", "montagu-task@imperial.ac.uk", "password") admin.add_role_to_user(cfg, "task.queue", "user") + +def test_acme(): + path = "config/acme" + try: + with vault_dev.Server() as s: + url = f"http://localhost:{s.port}" + options = {"vault": {"addr": url, "auth": {"args": {"token": s.token}}}} + s.client().write("secret/certbot-hdb/credentials", username="hdb-us3r", password="hdb-p@assword") + + cli.main(["start", "--name", path]) + client = docker.client.from_env() + container = client.containers.get("montagu-acme-buddy") + env = container.attrs["Config"]["Env"] + env_dict = dict(e.split("=", 1) for e in env) + assert "hdb-us3r" in env_dict["HDB_ACME_USERNAME"] + assert "hdb-p@assword" in env_dict["HDB_ACME_PASSWORD"] + + finally: + cli.main(["stop", "--name", path, "--kill", "--volumes", "--network"]) \ No newline at end of file From 75de574b16588f804e075ce44110575586ad5f8c Mon Sep 17 00:00:00 2001 From: Wes Hinsley Date: Fri, 24 Oct 2025 15:46:50 +0100 Subject: [PATCH 08/15] Fix --- tests/test_integration.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/test_integration.py b/tests/test_integration.py index 877bf9e..a59d221 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -158,8 +158,15 @@ def test_acme(): url = f"http://localhost:{s.port}" options = {"vault": {"addr": url, "auth": {"args": {"token": s.token}}}} s.client().write("secret/certbot-hdb/credentials", username="hdb-us3r", password="hdb-p@assword") - - cli.main(["start", "--name", path]) + cli.main( + [ + "start", + "--name", + path, + f"--option=vault.addr={vault_addr}", + f"--option=vault.auth.args.token={s.token}", + ] + ) client = docker.client.from_env() container = client.containers.get("montagu-acme-buddy") env = container.attrs["Config"]["Env"] @@ -168,4 +175,4 @@ def test_acme(): assert "hdb-p@assword" in env_dict["HDB_ACME_PASSWORD"] finally: - cli.main(["stop", "--name", path, "--kill", "--volumes", "--network"]) \ No newline at end of file + cli.main(["stop", "--name", path, "--kill", "--volumes", "--network"]) From 66e81d9f2201f0cb239cb6fdf6e310cd2ae7fde0 Mon Sep 17 00:00:00 2001 From: Wes Hinsley Date: Fri, 24 Oct 2025 15:52:18 +0100 Subject: [PATCH 09/15] Fix'n'lint --- tests/test_integration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_integration.py b/tests/test_integration.py index a59d221..eb3e4be 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -156,8 +156,8 @@ def test_acme(): try: with vault_dev.Server() as s: url = f"http://localhost:{s.port}" - options = {"vault": {"addr": url, "auth": {"args": {"token": s.token}}}} s.client().write("secret/certbot-hdb/credentials", username="hdb-us3r", password="hdb-p@assword") + vault_addr = f"http://localhost:{s.port}" cli.main( [ "start", From c6b4d3f886cb35a6dab2ffb97a3c01b8a87e4279 Mon Sep 17 00:00:00 2001 From: Wes Hinsley Date: Fri, 24 Oct 2025 16:11:12 +0100 Subject: [PATCH 10/15] One more lint --- tests/test_integration.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_integration.py b/tests/test_integration.py index eb3e4be..1f26e08 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -155,7 +155,6 @@ def test_acme(): path = "config/acme" try: with vault_dev.Server() as s: - url = f"http://localhost:{s.port}" s.client().write("secret/certbot-hdb/credentials", username="hdb-us3r", password="hdb-p@assword") vault_addr = f"http://localhost:{s.port}" cli.main( From 9157ba0a4f194e92061524f2a40d01a506ff2372 Mon Sep 17 00:00:00 2001 From: Wes Hinsley Date: Fri, 24 Oct 2025 16:13:59 +0100 Subject: [PATCH 11/15] Again --- tests/test_integration.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_integration.py b/tests/test_integration.py index 1f26e08..e0907cf 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -151,6 +151,7 @@ def add_task_queue_user(cfg, packit): admin.add_user(cfg, "task.queue", "task.queue", "montagu-task@imperial.ac.uk", "password") admin.add_role_to_user(cfg, "task.queue", "user") + def test_acme(): path = "config/acme" try: From fe093edea75325c4669982ce7abc5e3be4f5dfb1 Mon Sep 17 00:00:00 2001 From: Wes Hinsley Date: Fri, 24 Oct 2025 16:45:02 +0100 Subject: [PATCH 12/15] Copy more vault test --- tests/test_integration.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/tests/test_integration.py b/tests/test_integration.py index e0907cf..5b5a7ec 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -153,11 +153,16 @@ def add_task_queue_user(cfg, packit): def test_acme(): + packit_config_path = "tests" path = "config/acme" + packit_config = PackitConfig(packit_config_path) try: - with vault_dev.Server() as s: - s.client().write("secret/certbot-hdb/credentials", username="hdb-us3r", password="hdb-p@assword") + with vault_dev.Server(export_token=True) as s: + cl = s.client() + cl.write("secret/certbot-hdb/credentials", username="hdb-us3r", password="hdb-p@assword") vault_addr = f"http://localhost:{s.port}" + packit = PackitConstellation(packit_config) + packit.start(pull_images=True) cli.main( [ "start", @@ -175,4 +180,7 @@ def test_acme(): assert "hdb-p@assword" in env_dict["HDB_ACME_PASSWORD"] finally: - cli.main(["stop", "--name", path, "--kill", "--volumes", "--network"]) + with mock.patch("src.montagu_deploy.cli.prompt_yes_no") as prompt: + prompt.return_value = True + PackitConstellation(packit_config).stop(kill=True) + cli.main(["stop", "--name", path, "--kill", "--volumes", "--network"]) From c75f524547286b9354da041f22d16e20db913460 Mon Sep 17 00:00:00 2001 From: Wes Hinsley Date: Sat, 25 Oct 2025 21:53:44 +0100 Subject: [PATCH 13/15] Redo acme/vault test --- config/acme/montagu.yml | 2 +- src/montagu_deploy/__about__.py | 2 +- src/montagu_deploy/montagu_constellation.py | 13 +++++++----- tests/test_constellation.py | 18 ----------------- tests/test_integration.py | 22 +++++++++++++-------- 5 files changed, 24 insertions(+), 33 deletions(-) diff --git a/config/acme/montagu.yml b/config/acme/montagu.yml index 0ee676f..2651d83 100644 --- a/config/acme/montagu.yml +++ b/config/acme/montagu.yml @@ -30,7 +30,7 @@ repo: vimc network: montagu-network # Domain where this instance of Montagu will be deployed. E.g. science.montagu.dide.ic.uk -hostname: montagu.org +hostname: localhost ## Names of the docker volumes to use volumes: diff --git a/src/montagu_deploy/__about__.py b/src/montagu_deploy/__about__.py index b101341..0a73d74 100644 --- a/src/montagu_deploy/__about__.py +++ b/src/montagu_deploy/__about__.py @@ -1,4 +1,4 @@ # SPDX-FileCopyrightText: 2023-present Alex # # SPDX-License-Identifier: MIT -__version__ = "0.0.9" +__version__ = "0.1.0" diff --git a/src/montagu_deploy/montagu_constellation.py b/src/montagu_deploy/montagu_constellation.py index db889a2..fe42f9a 100644 --- a/src/montagu_deploy/montagu_constellation.py +++ b/src/montagu_deploy/montagu_constellation.py @@ -4,13 +4,16 @@ import constellation import docker import yaml -from constellation import docker_util +from constellation import docker_util, vault from psycopg2 import connect from montagu_deploy import database def montagu_constellation(cfg): + if cfg.vault and cfg.vault.url: + vault.resolve_secrets(cfg, cfg.vault.client()) + proxy = proxy_container(cfg) containers = [ api_container(cfg), @@ -24,14 +27,14 @@ def montagu_constellation(cfg): task_queue_container(cfg), ] - if cfg.ssl_mode == "acme": - acme_buddy = acme_buddy_container(cfg, proxy) - containers.append(acme_buddy) - if cfg.fake_smtp_ref: fake_smtp = fake_smtp_container(cfg) containers.append(fake_smtp) + if cfg.ssl_mode == "acme": + acme_buddy = acme_buddy_container(cfg, proxy) + containers.append(acme_buddy) + return constellation.Constellation( "montagu", cfg.container_prefix, containers, cfg.network, cfg.volumes, data=cfg, vault_config=cfg.vault ) diff --git a/tests/test_constellation.py b/tests/test_constellation.py index b36ddfb..49e6c7e 100644 --- a/tests/test_constellation.py +++ b/tests/test_constellation.py @@ -139,21 +139,3 @@ def test_metrics(): def get_container(cfg, name): cl = docker.client.from_env() return cl.containers.get(f"{cfg.container_prefix}-{cfg.containers[name]}") - - -def test_acme_buddy(): - cfg = MontaguConfig("config/acme") - obj = montagu_constellation(cfg) - assert cfg.ssl_mode == "acme" - assert "acme-buddy" in cfg.containers - assert "acme-buddy" in cfg.images - try: - obj.start() - acme = get_container(cfg, "acme-buddy") - env = acme.attrs["Config"]["Env"] - env_dict = dict(e.split("=", 1) for e in env) - assert "HDB_ACME_USERNAME" in env_dict - assert "HDB_ACME_PASSWORD" in env_dict - - finally: - obj.stop(kill=True) diff --git a/tests/test_integration.py b/tests/test_integration.py index 5b5a7ec..5bed97f 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -153,23 +153,19 @@ def add_task_queue_user(cfg, packit): def test_acme(): - packit_config_path = "tests" - path = "config/acme" - packit_config = PackitConfig(packit_config_path) try: with vault_dev.Server(export_token=True) as s: + path = "config/acme" cl = s.client() cl.write("secret/certbot-hdb/credentials", username="hdb-us3r", password="hdb-p@assword") vault_addr = f"http://localhost:{s.port}" - packit = PackitConstellation(packit_config) - packit.start(pull_images=True) cli.main( [ "start", "--name", path, - f"--option=vault.addr={vault_addr}", f"--option=vault.auth.args.token={s.token}", + f"--option=vault.addr={vault_addr}", ] ) client = docker.client.from_env() @@ -182,5 +178,15 @@ def test_acme(): finally: with mock.patch("src.montagu_deploy.cli.prompt_yes_no") as prompt: prompt.return_value = True - PackitConstellation(packit_config).stop(kill=True) - cli.main(["stop", "--name", path, "--kill", "--volumes", "--network"]) + cli.main( + [ + "stop", + "--name", + path, + f"--option=vault.auth.args.token={s.token}", + f"--option=vault.addr={vault_addr}", + "--kill", + "--volumes", + "--network", + ] + ) From cc6a1c4d52ff7fedb01bdf7ea1a821ef0deec7b5 Mon Sep 17 00:00:00 2001 From: Wes Hinsley Date: Sat, 25 Oct 2025 22:17:19 +0100 Subject: [PATCH 14/15] Fix vault terminated early --- tests/test_integration.py | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/tests/test_integration.py b/tests/test_integration.py index 5bed97f..9fe23ec 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -153,8 +153,8 @@ def add_task_queue_user(cfg, packit): def test_acme(): - try: - with vault_dev.Server(export_token=True) as s: + with vault_dev.Server(export_token=True) as s: + try: path = "config/acme" cl = s.client() cl.write("secret/certbot-hdb/credentials", username="hdb-us3r", password="hdb-p@assword") @@ -175,18 +175,18 @@ def test_acme(): assert "hdb-us3r" in env_dict["HDB_ACME_USERNAME"] assert "hdb-p@assword" in env_dict["HDB_ACME_PASSWORD"] - finally: - with mock.patch("src.montagu_deploy.cli.prompt_yes_no") as prompt: - prompt.return_value = True - cli.main( - [ - "stop", - "--name", - path, - f"--option=vault.auth.args.token={s.token}", - f"--option=vault.addr={vault_addr}", - "--kill", - "--volumes", - "--network", - ] - ) + finally: + with mock.patch("src.montagu_deploy.cli.prompt_yes_no") as prompt: + prompt.return_value = True + cli.main( + [ + "stop", + "--name", + path, + f"--option=vault.auth.args.token={s.token}", + f"--option=vault.addr={vault_addr}", + "--kill", + "--volumes", + "--network", + ] + ) From 978d9586dbb92e53eac5c7dfb368fd0bc034c962 Mon Sep 17 00:00:00 2001 From: Wes Hinsley Date: Mon, 3 Nov 2025 10:53:19 +0000 Subject: [PATCH 15/15] Update src/montagu_deploy/montagu_constellation.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Paul LiƩtar --- src/montagu_deploy/montagu_constellation.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/montagu_deploy/montagu_constellation.py b/src/montagu_deploy/montagu_constellation.py index fe42f9a..75614c8 100644 --- a/src/montagu_deploy/montagu_constellation.py +++ b/src/montagu_deploy/montagu_constellation.py @@ -114,9 +114,7 @@ def acme_buddy_container(cfg, proxy): constellation.ConstellationBindMount("/var/run/docker.sock", "/var/run/docker.sock"), ] - domain_names = cfg.hostname - for i in range(len(cfg.acme_additional_domains)): - domain_names += f",{cfg.acme_additional_domains[i]}" + domain_names = ",".join([cfg.hostname] + cfg.acme_additional_domains) acme = constellation.ConstellationContainer( name,