From f066da73fd4626ca3cd936d2e31ba8f900340613 Mon Sep 17 00:00:00 2001 From: jansdhillon Date: Wed, 22 Apr 2026 13:06:24 -0600 Subject: [PATCH 1/8] feat: manage GPG credentials via Juju secrets Adds gpg_secret_id config option that accepts a Juju secret URI containing 'passphrase' and 'private-key' fields. When set, the charm writes /etc/landscape/gpg-passphrase and imports the private key into /etc/landscape/gpg on install and config-changed, and reacts to secret-changed to reconfigure and restart services when the secret is rotated. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- config.yaml | 12 +++ src/charm.py | 78 ++++++++++++++++++ src/config.py | 1 + tests/unit/test_charm.py | 171 ++++++++++++++++++++++++++++++++++++++- 4 files changed, 261 insertions(+), 1 deletion(-) diff --git a/config.yaml b/config.yaml index b55155d1..0dcbda7f 100644 --- a/config.yaml +++ b/config.yaml @@ -187,6 +187,18 @@ options: description: | A cookie encryption key used for API authentication flows. The value should be 32 url-safe, base64 encoded bytes. If not set, one will be generated securely. + gpg_secret_id: + type: string + default: + description: | + Juju secret URI (e.g. secret:xxxx) containing the GPG credentials used by + Landscape Server to sign repository metadata. The secret must contain two fields: + - passphrase: the GPG key passphrase + - private-key: the ASCII-armored GPG private key + The secret must be granted to the landscape-server application before setting + this option. When set, the charm writes /etc/landscape/gpg-passphrase and + imports the private key into /etc/landscape/gpg on install and whenever the + secret is rotated. min_install: type: boolean default: False diff --git a/src/charm.py b/src/charm.py index 4c0e7867..88c5c9a5 100755 --- a/src/charm.py +++ b/src/charm.py @@ -57,6 +57,7 @@ MaintenanceStatus, ModelError, Relation, + SecretNotFoundError, WaitingStatus, ) from pydantic import ValidationError @@ -98,6 +99,9 @@ HASH_ID_DATABASES = "/opt/canonical/landscape/hash-id-databases-ignore-maintenance" UPDATE_WSL_DISTRIBUTIONS_SCRIPT = "/opt/canonical/landscape/update-wsl-distributions" +GPG_HOME_DIR = "/etc/landscape/gpg" +GPG_PASSPHRASE_FILE = "/etc/landscape/gpg-passphrase" + LANDSCAPE_SERVER = "landscape-server" LANDSCAPE_PACKAGES = ( LANDSCAPE_SERVER, @@ -272,6 +276,9 @@ def __init__(self, *args): self.on.get_service_conf_action, self._on_get_service_conf_action ) + # Secrets + self.framework.observe(self.on.secret_changed, self._on_secret_changed) + # State self._stored.set_default( ready={ @@ -529,6 +536,8 @@ def _on_config_changed(self, _) -> None: self._write_cookie_encryption_key(cookie_encryption_key) self._stored.cookie_encryption_key = cookie_encryption_key + self._configure_gpg() + self._update_ready_status(restart_services=True) self._provide_all_haproxy_route_requirements() @@ -593,6 +602,73 @@ def _write_cookie_encryption_key(self, cookie_encryption_key): logger.info("Writing cookie encryption key") update_service_conf({"api": {"cookie-encryption-key": cookie_encryption_key}}) + def _configure_gpg(self) -> bool: + """Write GPG credentials from the configured Juju secret. + + Writes the passphrase to GPG_PASSPHRASE_FILE and imports the private + key into GPG_HOME_DIR. Returns True when GPG was configured + successfully, False when no secret is configured. Sets a BlockedStatus + and returns False on error. + """ + secret_id = self.charm_config.gpg_secret_id + if not secret_id: + return False + + try: + secret = self.model.get_secret(id=secret_id) + content = secret.get_content(refresh=True) + except SecretNotFoundError: + logger.error("GPG secret '%s' not found or not accessible", secret_id) + self.unit.status = BlockedStatus("GPG secret not found or not accessible") + return False + + passphrase = content.get("passphrase") + private_key = content.get("private-key") + + if not passphrase or not private_key: + logger.error( + "GPG secret is missing required fields: " + "'passphrase' and/or 'private-key'" + ) + self.unit.status = BlockedStatus("GPG secret missing required fields") + return False + + landscape_uid = user_exists("landscape").pw_uid + + os.makedirs(GPG_HOME_DIR, mode=0o700, exist_ok=True) + os.chown(GPG_HOME_DIR, landscape_uid, self.root_gid) + + try: + subprocess.run( + ["gpg", "--homedir", GPG_HOME_DIR, "--batch", "--import"], + input=private_key, + check=True, + text=True, + capture_output=True, + ) + except subprocess.CalledProcessError as e: + logger.error("Failed to import GPG private key: %s", e.stderr) + self.unit.status = BlockedStatus("Failed to import GPG key") + return False + + os.makedirs(os.path.dirname(GPG_PASSPHRASE_FILE), exist_ok=True) + with open(GPG_PASSPHRASE_FILE, "w") as fp: + fp.write(passphrase) + os.chmod(GPG_PASSPHRASE_FILE, 0o640) + os.chown(GPG_PASSPHRASE_FILE, landscape_uid, self.root_gid) + + logger.info("GPG credentials configured successfully") + return True + + def _on_secret_changed(self, event) -> None: + """Re-configure GPG credentials when the GPG secret is rotated.""" + secret_id = self.charm_config.gpg_secret_id + if not secret_id or event.secret.id != secret_id: + return + + if self._configure_gpg(): + self._update_ready_status(restart_services=True) + def _on_upgrade_charm(self, _: UpgradeCharmEvent) -> None: self._provide_all_haproxy_route_requirements() @@ -655,6 +731,8 @@ def _on_install(self, event: InstallEvent) -> None: license_file, user_exists("landscape").pw_uid, self.root_gid ) + self._configure_gpg() + self.unit.status = ActiveStatus("Unit is ready") # Indicate that this install is a charm install. diff --git a/src/config.py b/src/config.py index 595292de..91d3d111 100644 --- a/src/config.py +++ b/src/config.py @@ -64,6 +64,7 @@ def landscape_ppas(self) -> list[str]: additional_service_config: str | None = None secret_token: str | None = None cookie_encryption_key: str | None = None + gpg_secret_id: str | None = None min_install: bool prometheus_scrape_interval: str autoregistration: bool diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index a156eafc..41f8e783 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -8,10 +8,11 @@ import json import os from pwd import struct_passwd +import subprocess from subprocess import CalledProcessError from tempfile import TemporaryDirectory import unittest -from unittest.mock import ANY, call, DEFAULT, Mock, patch, PropertyMock +from unittest.mock import ANY, call, DEFAULT, Mock, mock_open, patch, PropertyMock from charms.operator_libs_linux.v0 import apt from charms.operator_libs_linux.v0.apt import PackageError, PackageNotFoundError @@ -23,6 +24,7 @@ MaintenanceStatus, PeerRelation, Relation, + Secret, State, StoredState, TCPPort, @@ -31,6 +33,7 @@ from charm import ( DEFAULT_SERVICES, get_modified_env_vars, + GPG_HOME_DIR, HASH_ID_DATABASES, LANDSCAPE_PACKAGES, LANDSCAPE_UBUNTU_INSTALLER_ATTACH, @@ -2160,3 +2163,169 @@ def test_action_get_service_conf(monkeypatch): assert ctx.action_results is not None assert "config" in ctx.action_results assert json.loads(ctx.action_results["config"]) == conf + + +class TestGPGConfiguration: + """Tests for GPG credential management via Juju secrets.""" + + GPG_SECRET_ID = "secret:test-gpg-secret-id" + PASSPHRASE = "my-test-passphrase" + PRIVATE_KEY = ( + "-----BEGIN PGP PRIVATE KEY BLOCK-----\ntest" + "\n-----END PGP PRIVATE KEY BLOCK-----" + ) + + def _make_gpg_secret(self, secret_id=None): + return Secret( + id=secret_id or self.GPG_SECRET_ID, + tracked_content={ + "passphrase": self.PASSPHRASE, + "private-key": self.PRIVATE_KEY, + }, + ) + + def test_no_secret_configured_skips_gpg(self, replicas_network_state): + """When gpg_secret_id is not set, _configure_gpg does nothing.""" + ctx = Context(LandscapeServerCharm) + state_in = State(**replicas_network_state) + + with patch("charm.os.makedirs") as mock_makedirs: + state_out = ctx.run(ctx.on.config_changed(), state_in) + + mock_makedirs.assert_not_called() + assert not isinstance(state_out.unit_status, BlockedStatus) + + def test_secret_not_found_sets_blocked(self, replicas_network_state): + """When the secret does not exist, the unit enters BlockedStatus.""" + ctx = Context(LandscapeServerCharm) + state_in = State( + **replicas_network_state, + config={"gpg_secret_id": self.GPG_SECRET_ID}, + ) + + state_out = ctx.run(ctx.on.config_changed(), state_in) + + assert isinstance(state_out.unit_status, BlockedStatus) + assert "not found" in state_out.unit_status.message + + def test_secret_missing_fields_sets_blocked(self, replicas_network_state): + """When the secret exists but lacks required fields, BlockedStatus is set.""" + ctx = Context(LandscapeServerCharm) + incomplete_secret = Secret( + id=self.GPG_SECRET_ID, + tracked_content={"passphrase": self.PASSPHRASE}, + ) + state_in = State( + **replicas_network_state, + config={"gpg_secret_id": self.GPG_SECRET_ID}, + secrets=[incomplete_secret], + ) + + state_out = ctx.run(ctx.on.config_changed(), state_in) + + assert isinstance(state_out.unit_status, BlockedStatus) + assert "missing required fields" in state_out.unit_status.message + + def test_gpg_import_failure_sets_blocked(self, replicas_network_state): + """When gpg --import fails, BlockedStatus is set.""" + ctx = Context(LandscapeServerCharm) + secret = self._make_gpg_secret() + state_in = State( + **replicas_network_state, + config={"gpg_secret_id": self.GPG_SECRET_ID}, + secrets=[secret], + ) + + with ( + patch("charm.os.makedirs"), + patch("charm.os.chown"), + patch("charm.user_exists") as mock_user, + patch("charm.subprocess.run") as mock_run, + ): + mock_user.return_value.pw_uid = 1000 + mock_run.side_effect = subprocess.CalledProcessError( + 1, "gpg", stderr="bad key" + ) + state_out = ctx.run(ctx.on.config_changed(), state_in) + + assert isinstance(state_out.unit_status, BlockedStatus) + assert "Failed to import GPG key" in state_out.unit_status.message + + def test_gpg_configured_successfully(self, replicas_network_state): + """When credentials are valid, passphrase file is written and key imported.""" + ctx = Context(LandscapeServerCharm) + secret = self._make_gpg_secret() + state_in = State( + **replicas_network_state, + config={"gpg_secret_id": self.GPG_SECRET_ID}, + secrets=[secret], + ) + + with ( + patch("charm.os.makedirs"), + patch("charm.os.chown"), + patch("charm.os.chmod"), + patch("charm.user_exists") as mock_user, + patch("charm.subprocess.run") as mock_run, + patch("builtins.open", mock_open()), + ): + mock_user.return_value.pw_uid = 1000 + mock_run.return_value = subprocess.CompletedProcess([], 0) + + ctx.run(ctx.on.config_changed(), state_in) + + mock_run.assert_called_once_with( + ["gpg", "--homedir", GPG_HOME_DIR, "--batch", "--import"], + input=self.PRIVATE_KEY, + check=True, + text=True, + capture_output=True, + ) + + def test_secret_changed_ignores_different_secret(self, replicas_network_state): + """secret-changed for an unrelated secret ID is ignored.""" + ctx = Context(LandscapeServerCharm) + other_secret = Secret( + id="secret:other-secret-id", + tracked_content={"key": "value"}, + ) + state_in = State( + **replicas_network_state, + config={"gpg_secret_id": self.GPG_SECRET_ID}, + secrets=[other_secret], + ) + + with patch("charm.os.makedirs") as mock_makedirs: + ctx.run(ctx.on.secret_changed(other_secret), state_in) + + mock_makedirs.assert_not_called() + + def test_secret_changed_reconfigures_gpg(self, replicas_network_state): + """secret-changed for the GPG secret triggers reconfiguration.""" + ctx = Context(LandscapeServerCharm) + secret = self._make_gpg_secret() + state_in = State( + **replicas_network_state, + config={"gpg_secret_id": self.GPG_SECRET_ID}, + secrets=[secret], + ) + + with ( + patch("charm.os.makedirs"), + patch("charm.os.chown"), + patch("charm.os.chmod"), + patch("charm.user_exists") as mock_user, + patch("charm.subprocess.run") as mock_run, + patch("builtins.open", mock_open()), + ): + mock_user.return_value.pw_uid = 1000 + mock_run.return_value = subprocess.CompletedProcess([], 0) + ctx.run(ctx.on.secret_changed(secret), state_in) + + mock_run.assert_called_once_with( + ["gpg", "--homedir", GPG_HOME_DIR, "--batch", "--import"], + input=self.PRIVATE_KEY, + check=True, + text=True, + capture_output=True, + ) From 49b753595b228fd8660290459e51dddaf46ec2af Mon Sep 17 00:00:00 2001 From: jansdhillon Date: Wed, 22 Apr 2026 13:49:32 -0600 Subject: [PATCH 2/8] fix: address PR review comments on GPG secrets management - Use landscape gid (not root) for GPG_HOME_DIR and passphrase file ownership - Rename secret fields to gpg-passphrase and gpg-private-key to match landscape-saas setup-gpg.sh convention - Enforce GPG_HOME_DIR permissions with explicit chmod after makedirs to guard against umask interference - Write passphrase file with restricted permissions from the start by setting umask to 0o177 before open(), eliminating the race window where the file could be world-readable - Pass --passphrase-file to gpg --import for encrypted private keys - Guard _on_install ActiveStatus against overriding a BlockedStatus set by _configure_gpg() - Update tests to assert passphrase file path, permissions, and ownership; fix secret field names throughout Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- config.yaml | 4 +- src/charm.py | 43 ++++++++++++++------- tests/unit/test_charm.py | 81 ++++++++++++++++++++++++++++------------ 3 files changed, 89 insertions(+), 39 deletions(-) diff --git a/config.yaml b/config.yaml index 0dcbda7f..d3b916a6 100644 --- a/config.yaml +++ b/config.yaml @@ -193,8 +193,8 @@ options: description: | Juju secret URI (e.g. secret:xxxx) containing the GPG credentials used by Landscape Server to sign repository metadata. The secret must contain two fields: - - passphrase: the GPG key passphrase - - private-key: the ASCII-armored GPG private key + - gpg-passphrase: the GPG key passphrase + - gpg-private-key: the ASCII-armored GPG private key The secret must be granted to the landscape-server application before setting this option. When set, the charm writes /etc/landscape/gpg-passphrase and imports the private key into /etc/landscape/gpg on install and whenever the diff --git a/src/charm.py b/src/charm.py index 88c5c9a5..dc3d4cff 100755 --- a/src/charm.py +++ b/src/charm.py @@ -622,25 +622,47 @@ def _configure_gpg(self) -> bool: self.unit.status = BlockedStatus("GPG secret not found or not accessible") return False - passphrase = content.get("passphrase") - private_key = content.get("private-key") + passphrase = content.get("gpg-passphrase") + private_key = content.get("gpg-private-key") if not passphrase or not private_key: logger.error( "GPG secret is missing required fields: " - "'passphrase' and/or 'private-key'" + "'gpg-passphrase' and/or 'gpg-private-key'" ) self.unit.status = BlockedStatus("GPG secret missing required fields") return False - landscape_uid = user_exists("landscape").pw_uid + landscape_user = user_exists("landscape") + landscape_uid = landscape_user.pw_uid + landscape_gid = landscape_user.pw_gid os.makedirs(GPG_HOME_DIR, mode=0o700, exist_ok=True) - os.chown(GPG_HOME_DIR, landscape_uid, self.root_gid) + # Enforce 0700 explicitly — makedirs is subject to umask + os.chmod(GPG_HOME_DIR, 0o700) + os.chown(GPG_HOME_DIR, landscape_uid, landscape_gid) + + # Set umask to 0o177 so open() creates the file with 0o600 immediately, + # avoiding a window where it could be world-readable + old_umask = os.umask(0o177) + try: + with open(GPG_PASSPHRASE_FILE, "w") as fp: + fp.write(passphrase) + finally: + os.umask(old_umask) + os.chown(GPG_PASSPHRASE_FILE, landscape_uid, landscape_gid) try: subprocess.run( - ["gpg", "--homedir", GPG_HOME_DIR, "--batch", "--import"], + [ + "gpg", + "--homedir", + GPG_HOME_DIR, + "--batch", + "--passphrase-file", + GPG_PASSPHRASE_FILE, + "--import", + ], input=private_key, check=True, text=True, @@ -651,12 +673,6 @@ def _configure_gpg(self) -> bool: self.unit.status = BlockedStatus("Failed to import GPG key") return False - os.makedirs(os.path.dirname(GPG_PASSPHRASE_FILE), exist_ok=True) - with open(GPG_PASSPHRASE_FILE, "w") as fp: - fp.write(passphrase) - os.chmod(GPG_PASSPHRASE_FILE, 0o640) - os.chown(GPG_PASSPHRASE_FILE, landscape_uid, self.root_gid) - logger.info("GPG credentials configured successfully") return True @@ -733,7 +749,8 @@ def _on_install(self, event: InstallEvent) -> None: self._configure_gpg() - self.unit.status = ActiveStatus("Unit is ready") + if not isinstance(self.unit.status, BlockedStatus): + self.unit.status = ActiveStatus("Unit is ready") # Indicate that this install is a charm install. prepend_default_settings({"DEPLOYED_FROM": "charm"}) diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index 41f8e783..e5086099 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -34,6 +34,7 @@ DEFAULT_SERVICES, get_modified_env_vars, GPG_HOME_DIR, + GPG_PASSPHRASE_FILE, HASH_ID_DATABASES, LANDSCAPE_PACKAGES, LANDSCAPE_UBUNTU_INSTALLER_ATTACH, @@ -2179,8 +2180,8 @@ def _make_gpg_secret(self, secret_id=None): return Secret( id=secret_id or self.GPG_SECRET_ID, tracked_content={ - "passphrase": self.PASSPHRASE, - "private-key": self.PRIVATE_KEY, + "gpg-passphrase": self.PASSPHRASE, + "gpg-private-key": self.PRIVATE_KEY, }, ) @@ -2213,7 +2214,7 @@ def test_secret_missing_fields_sets_blocked(self, replicas_network_state): ctx = Context(LandscapeServerCharm) incomplete_secret = Secret( id=self.GPG_SECRET_ID, - tracked_content={"passphrase": self.PASSPHRASE}, + tracked_content={"gpg-passphrase": self.PASSPHRASE}, ) state_in = State( **replicas_network_state, @@ -2235,14 +2236,19 @@ def test_gpg_import_failure_sets_blocked(self, replicas_network_state): config={"gpg_secret_id": self.GPG_SECRET_ID}, secrets=[secret], ) + mock_landscape = Mock() + mock_landscape.pw_uid = 1000 + mock_landscape.pw_gid = 1001 with ( patch("charm.os.makedirs"), + patch("charm.os.chmod"), patch("charm.os.chown"), - patch("charm.user_exists") as mock_user, + patch("charm.os.umask", return_value=0o022), + patch("charm.user_exists", return_value=mock_landscape), patch("charm.subprocess.run") as mock_run, + patch("builtins.open", mock_open()), ): - mock_user.return_value.pw_uid = 1000 mock_run.side_effect = subprocess.CalledProcessError( 1, "gpg", stderr="bad key" ) @@ -2252,7 +2258,7 @@ def test_gpg_import_failure_sets_blocked(self, replicas_network_state): assert "Failed to import GPG key" in state_out.unit_status.message def test_gpg_configured_successfully(self, replicas_network_state): - """When credentials are valid, passphrase file is written and key imported.""" + """When credentials are valid, files are written with correct permissions.""" ctx = Context(LandscapeServerCharm) secret = self._make_gpg_secret() state_in = State( @@ -2260,27 +2266,43 @@ def test_gpg_configured_successfully(self, replicas_network_state): config={"gpg_secret_id": self.GPG_SECRET_ID}, secrets=[secret], ) + mock_landscape = Mock() + mock_landscape.pw_uid = 1000 + mock_landscape.pw_gid = 1001 with ( - patch("charm.os.makedirs"), - patch("charm.os.chown"), - patch("charm.os.chmod"), - patch("charm.user_exists") as mock_user, + patch("charm.os.makedirs") as mock_makedirs, + patch("charm.os.chmod") as mock_chmod, + patch("charm.os.chown") as mock_chown, + patch("charm.os.umask", return_value=0o022) as mock_umask, + patch("charm.user_exists", return_value=mock_landscape), patch("charm.subprocess.run") as mock_run, - patch("builtins.open", mock_open()), + patch("builtins.open", mock_open()) as mock_file, ): - mock_user.return_value.pw_uid = 1000 mock_run.return_value = subprocess.CompletedProcess([], 0) - ctx.run(ctx.on.config_changed(), state_in) - mock_run.assert_called_once_with( - ["gpg", "--homedir", GPG_HOME_DIR, "--batch", "--import"], - input=self.PRIVATE_KEY, - check=True, - text=True, - capture_output=True, - ) + mock_makedirs.assert_called_once_with(GPG_HOME_DIR, mode=0o700, exist_ok=True) + mock_chmod.assert_any_call(GPG_HOME_DIR, 0o700) + mock_chown.assert_any_call(GPG_HOME_DIR, 1000, 1001) + mock_umask.assert_any_call(0o177) + mock_file.assert_any_call(GPG_PASSPHRASE_FILE, "w") + mock_chown.assert_any_call(GPG_PASSPHRASE_FILE, 1000, 1001) + mock_run.assert_called_once_with( + [ + "gpg", + "--homedir", + GPG_HOME_DIR, + "--batch", + "--passphrase-file", + GPG_PASSPHRASE_FILE, + "--import", + ], + input=self.PRIVATE_KEY, + check=True, + text=True, + capture_output=True, + ) def test_secret_changed_ignores_different_secret(self, replicas_network_state): """secret-changed for an unrelated secret ID is ignored.""" @@ -2309,21 +2331,32 @@ def test_secret_changed_reconfigures_gpg(self, replicas_network_state): config={"gpg_secret_id": self.GPG_SECRET_ID}, secrets=[secret], ) + mock_landscape = Mock() + mock_landscape.pw_uid = 1000 + mock_landscape.pw_gid = 1001 with ( patch("charm.os.makedirs"), - patch("charm.os.chown"), patch("charm.os.chmod"), - patch("charm.user_exists") as mock_user, + patch("charm.os.chown"), + patch("charm.os.umask", return_value=0o022), + patch("charm.user_exists", return_value=mock_landscape), patch("charm.subprocess.run") as mock_run, patch("builtins.open", mock_open()), ): - mock_user.return_value.pw_uid = 1000 mock_run.return_value = subprocess.CompletedProcess([], 0) ctx.run(ctx.on.secret_changed(secret), state_in) mock_run.assert_called_once_with( - ["gpg", "--homedir", GPG_HOME_DIR, "--batch", "--import"], + [ + "gpg", + "--homedir", + GPG_HOME_DIR, + "--batch", + "--passphrase-file", + GPG_PASSPHRASE_FILE, + "--import", + ], input=self.PRIVATE_KEY, check=True, text=True, From e289b77c07f1750e4ea196a6c23df132764a034f Mon Sep 17 00:00:00 2001 From: jansdhillon Date: Wed, 22 Apr 2026 14:14:06 -0600 Subject: [PATCH 3/8] fix: clean up logging levels and status handling in _configure_gpg - Downgrade secret-not-found and missing-fields log calls from error to warning since these are operator configuration issues, not program errors - Set MaintenanceStatus at the top of _configure_gpg() and WaitingStatus on success, following the same pattern as the other _configure_* methods (_configure_oidc, _configure_openid) - Move _configure_gpg() call in _on_install to after ActiveStatus is set, so it can naturally override with BlockedStatus on failure; _update_ready_status() then preserves that blocked state Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/charm.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/charm.py b/src/charm.py index dc3d4cff..05927dce 100755 --- a/src/charm.py +++ b/src/charm.py @@ -614,11 +614,13 @@ def _configure_gpg(self) -> bool: if not secret_id: return False + self.unit.status = MaintenanceStatus("Configuring GPG credentials") + try: secret = self.model.get_secret(id=secret_id) content = secret.get_content(refresh=True) except SecretNotFoundError: - logger.error("GPG secret '%s' not found or not accessible", secret_id) + logger.warning("GPG secret '%s' not found or not accessible", secret_id) self.unit.status = BlockedStatus("GPG secret not found or not accessible") return False @@ -626,7 +628,7 @@ def _configure_gpg(self) -> bool: private_key = content.get("gpg-private-key") if not passphrase or not private_key: - logger.error( + logger.warning( "GPG secret is missing required fields: " "'gpg-passphrase' and/or 'gpg-private-key'" ) @@ -674,6 +676,7 @@ def _configure_gpg(self) -> bool: return False logger.info("GPG credentials configured successfully") + self.unit.status = WaitingStatus("Waiting on relations") return True def _on_secret_changed(self, event) -> None: @@ -747,14 +750,13 @@ def _on_install(self, event: InstallEvent) -> None: license_file, user_exists("landscape").pw_uid, self.root_gid ) - self._configure_gpg() - - if not isinstance(self.unit.status, BlockedStatus): - self.unit.status = ActiveStatus("Unit is ready") + self.unit.status = ActiveStatus("Unit is ready") # Indicate that this install is a charm install. prepend_default_settings({"DEPLOYED_FROM": "charm"}) + self._configure_gpg() + self._update_ready_status() def _update_status(self, event: UpdateStatusEvent) -> None: From a698ba094a7bf57517c6ec2a8e88ab5d00230c3d Mon Sep 17 00:00:00 2001 From: jansdhillon Date: Wed, 22 Apr 2026 14:17:09 -0600 Subject: [PATCH 4/8] fix: add debug log when gpg_secret_id not configured Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/charm.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/charm.py b/src/charm.py index 05927dce..1b64eabd 100755 --- a/src/charm.py +++ b/src/charm.py @@ -612,6 +612,7 @@ def _configure_gpg(self) -> bool: """ secret_id = self.charm_config.gpg_secret_id if not secret_id: + logger.debug("gpg_secret_id not configured, skipping GPG setup") return False self.unit.status = MaintenanceStatus("Configuring GPG credentials") From 28014ee83de581ea726fd941602f49221aa2d28a Mon Sep 17 00:00:00 2001 From: jansdhillon Date: Wed, 22 Apr 2026 14:21:55 -0600 Subject: [PATCH 5/8] fix: clean up _on_install status logic to follow _configure_* pattern Remove explicit ActiveStatus before _configure_gpg(); instead let _update_ready_status() determine final status, matching the pattern used in _on_config_changed and other _configure_* functions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/charm.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/charm.py b/src/charm.py index 1b64eabd..ab170469 100755 --- a/src/charm.py +++ b/src/charm.py @@ -750,8 +750,7 @@ def _on_install(self, event: InstallEvent) -> None: write_license_file( license_file, user_exists("landscape").pw_uid, self.root_gid ) - - self.unit.status = ActiveStatus("Unit is ready") + self.unit.status = WaitingStatus("Waiting on relations") # Indicate that this install is a charm install. prepend_default_settings({"DEPLOYED_FROM": "charm"}) From 1365d77546c6767ae28402f4bdcb62333644e5ec Mon Sep 17 00:00:00 2001 From: jansdhillon Date: Wed, 22 Apr 2026 14:52:28 -0600 Subject: [PATCH 6/8] fix: use /etc/landscape-server for GPG paths GPG home and passphrase paths should match the Landscape Server config: /etc/landscape-server/gpg and /etc/landscape-server/gpg-passphrase.txt Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/charm.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/charm.py b/src/charm.py index ab170469..8696f83e 100755 --- a/src/charm.py +++ b/src/charm.py @@ -99,8 +99,8 @@ HASH_ID_DATABASES = "/opt/canonical/landscape/hash-id-databases-ignore-maintenance" UPDATE_WSL_DISTRIBUTIONS_SCRIPT = "/opt/canonical/landscape/update-wsl-distributions" -GPG_HOME_DIR = "/etc/landscape/gpg" -GPG_PASSPHRASE_FILE = "/etc/landscape/gpg-passphrase" +GPG_HOME_DIR = "/etc/landscape-server/gpg" +GPG_PASSPHRASE_FILE = "/etc/landscape-server/gpg-passphrase.txt" LANDSCAPE_SERVER = "landscape-server" LANDSCAPE_PACKAGES = ( From d7907d0964997a69de33aede3e8285958702553d Mon Sep 17 00:00:00 2001 From: jansdhillon Date: Wed, 22 Apr 2026 15:25:50 -0600 Subject: [PATCH 7/8] fix: catch ModelError when GPG secret is not granted ops raises ModelError (permission denied) when get_secret is called for a secret that exists but hasn't been granted to the app. Catch both SecretNotFoundError and ModelError for consistent BlockedStatus. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/charm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/charm.py b/src/charm.py index 8696f83e..f8c63f26 100755 --- a/src/charm.py +++ b/src/charm.py @@ -620,7 +620,7 @@ def _configure_gpg(self) -> bool: try: secret = self.model.get_secret(id=secret_id) content = secret.get_content(refresh=True) - except SecretNotFoundError: + except (SecretNotFoundError, ModelError): logger.warning("GPG secret '%s' not found or not accessible", secret_id) self.unit.status = BlockedStatus("GPG secret not found or not accessible") return False From 7ffcdfef248e22b766bf77162a48825ad12c50e6 Mon Sep 17 00:00:00 2001 From: jansdhillon Date: Wed, 22 Apr 2026 16:41:46 -0600 Subject: [PATCH 8/8] lint --- tests/unit/test_charm.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index 510d93d2..c2fe3f4b 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -2465,4 +2465,3 @@ def test_smtp_relation_broken_tolerates_missing_files(self): """_on_smtp_relation_broken does not raise if sasl files don't exist.""" with patch("charm.POSTFIX_SASL_PASSWD", "/nonexistent/sasl_passwd"): self.harness.charm._on_smtp_relation_broken(Mock()) -