diff --git a/charms/garm-configurator/charmcraft.yaml b/charms/garm-configurator/charmcraft.yaml index 6f40b771..14933460 100644 --- a/charms/garm-configurator/charmcraft.yaml +++ b/charms/garm-configurator/charmcraft.yaml @@ -64,6 +64,12 @@ config: Juju secret containing the GitHub App private key (PEM format). The secret content must have a "value" key with the private key string. +requires: + image: + interface: github_runner_image_v0 + limit: 1 + optional: false + parts: charm: source: . diff --git a/charms/garm-configurator/src/charm.py b/charms/garm-configurator/src/charm.py index c1985fc6..8f7f3c37 100755 --- a/charms/garm-configurator/src/charm.py +++ b/charms/garm-configurator/src/charm.py @@ -8,7 +8,7 @@ import ops -from charm_state import CharmConfigInvalidError, CharmState +from charm_state import IMAGE_RELATION_NAME, CharmConfigInvalidError, CharmState class GarmConfiguratorCharm(ops.CharmBase): @@ -21,29 +21,49 @@ def __init__(self, *args: typing.Any) -> None: args: passthrough to CharmBase. """ super().__init__(*args) - self.framework.observe(self.on.config_changed, self._on_config_changed) - self.framework.observe(self.on.collect_unit_status, self._on_collect_unit_status) + for event in [ + self.on.config_changed, + self.on.secret_changed, + self.on[IMAGE_RELATION_NAME].relation_joined, + self.on[IMAGE_RELATION_NAME].relation_changed, + self.on[IMAGE_RELATION_NAME].relation_broken, + ]: + self.framework.observe(event, self._reconcile) - def _on_config_changed(self, _event: ops.ConfigChangedEvent) -> None: - """Validate configuration on config-changed.""" - try: - CharmState.from_charm(self) - except CharmConfigInvalidError as e: - self.unit.status = ops.BlockedStatus(e.msg) - return + def _reconcile(self, event: ops.EventBase) -> None: + """Reconcile all charm state for every event. - def _on_collect_unit_status(self, event: ops.CollectStatusEvent) -> None: - """Handle collect-unit-status event. + Reads full current state and acts idempotently: forwards OpenStack + credentials to the image builder relation, and reports unit status. Args: - event: The collect status event. + event: The triggering event. """ try: - CharmState.from_charm(self) + state = CharmState.from_charm(self) except CharmConfigInvalidError as e: self.unit.status = ops.BlockedStatus(e.msg) return - event.add_status(ops.ActiveStatus("Ready")) + + relation = self.model.get_relation(IMAGE_RELATION_NAME) + if relation is not None: + relation.data[self.unit].update( + { + "auth_url": state.provider_config.auth_url, + "password": state.provider_config.password, + "project_domain_name": state.provider_config.project_domain_name, + "project_name": state.provider_config.project_name, + "user_domain_name": state.provider_config.user_domain_name, + "username": state.provider_config.username, + } + ) + + if relation is None: + self.unit.status = ops.WaitingStatus("Waiting for image builder relation") + elif state.image_id is None: + self.unit.status = ops.WaitingStatus("Waiting for image UUID from image builder") + else: + self.unit.status = ops.ActiveStatus("Ready") if __name__ == "__main__": diff --git a/charms/garm-configurator/src/charm_state.py b/charms/garm-configurator/src/charm_state.py index 3d3be78a..6fc244dd 100644 --- a/charms/garm-configurator/src/charm_state.py +++ b/charms/garm-configurator/src/charm_state.py @@ -19,6 +19,8 @@ GITHUB_APP_INSTALLATION_ID_CONFIG_NAME = "github-app-installation-id" GITHUB_APP_PRIVATE_KEY_CONFIG_NAME = "github-app-private-key" # nosec +IMAGE_RELATION_NAME = "image" + class CharmConfigInvalidError(Exception): """Raised when charm configuration is invalid. @@ -100,7 +102,7 @@ def from_charm(cls, charm: ops.CharmBase) -> "ProviderConfig": ) try: secret = charm.model.get_secret(id=str(password_secret_id)) - password = secret.get_content()["value"] + password = secret.get_content(refresh=True)["value"] except (ops.SecretNotFoundError, KeyError) as e: raise CharmConfigInvalidError( f"{OPENSTACK_PASSWORD_CONFIG_NAME} secret is invalid or missing 'value' key" @@ -163,7 +165,7 @@ def from_charm(cls, charm: ops.CharmBase) -> "GithubAppConfig": ) try: secret = charm.model.get_secret(id=str(private_key_secret_id)) - private_key = secret.get_content()["value"] + private_key = secret.get_content(refresh=True)["value"] except (ops.SecretNotFoundError, KeyError) as e: raise CharmConfigInvalidError( f"{GITHUB_APP_PRIVATE_KEY_CONFIG_NAME} secret is invalid or missing 'value' key" @@ -182,6 +184,7 @@ class CharmState: Attributes: provider_config: OpenStack provider configuration. github_app_config: GitHub App configuration. + image_id: OpenStack image UUID received from the image builder relation, or None. """ def __init__( @@ -189,15 +192,18 @@ def __init__( *, provider_config: ProviderConfig, github_app_config: GithubAppConfig, + image_id: str | None, ) -> None: """Initialize the charm state. Args: provider_config: The OpenStack provider configuration. github_app_config: The GitHub App configuration. + image_id: The OpenStack image UUID from the image builder relation. """ self.provider_config = provider_config self.github_app_config = github_app_config + self.image_id = image_id @classmethod def from_charm(cls, charm: ops.CharmBase) -> "CharmState": @@ -214,7 +220,28 @@ def from_charm(cls, charm: ops.CharmBase) -> "CharmState": """ provider_config = ProviderConfig.from_charm(charm) github_app_config = GithubAppConfig.from_charm(charm) + image_id = _get_image_id_from_relation(charm) return cls( provider_config=provider_config, github_app_config=github_app_config, + image_id=image_id, ) + + +def _get_image_id_from_relation(charm: ops.CharmBase) -> str | None: + """Return the OpenStack image UUID from the image builder relation, if available. + + Args: + charm: The charm instance. + + Returns: + The image UUID string, or None if the relation is absent or no UUID has been set yet. + """ + relation = charm.model.get_relation(IMAGE_RELATION_NAME) + if relation is None: + return None + for unit in relation.units: + image_id = relation.data[unit].get("id") + if image_id: + return image_id + return None diff --git a/charms/garm-configurator/tests/unit/test_charm.py b/charms/garm-configurator/tests/unit/test_charm.py index f83144f5..d6a886c2 100644 --- a/charms/garm-configurator/tests/unit/test_charm.py +++ b/charms/garm-configurator/tests/unit/test_charm.py @@ -5,7 +5,7 @@ import ops import pytest -from scenario import Context, Secret, State +from scenario import Context, Relation, Secret, State from charm import GarmConfiguratorCharm @@ -34,18 +34,18 @@ def _valid_config(secret: Secret, private_key_secret: Secret) -> dict: } -def test_charm_active_with_valid_config(): +def test_charm_waiting_with_valid_config_no_relation(): """ - arrange: All configs are valid. + arrange: All configs are valid but no image builder relation. act: Run config-changed. - assert: Unit status is Active. + assert: Unit status is Waiting — image builder relation is required. """ ctx = Context(GarmConfiguratorCharm) secret = _make_secret() pk_secret = _make_private_key_secret() state = State(config=_valid_config(secret, pk_secret), secrets=[secret, pk_secret]) out = ctx.run(ctx.on.config_changed(), state) - assert out.unit_status == ops.ActiveStatus("Ready") + assert out.unit_status == ops.WaitingStatus("Waiting for image builder relation") # Represents a missing config value in parameterized tests below @@ -145,11 +145,11 @@ def test_charm_blocked_password_secret_not_found(): ) -def test_charm_active_with_http_auth_url(): +def test_charm_not_blocked_with_http_auth_url(): """ arrange: openstack-auth-url uses http:// (not https://). act: Run config-changed. - assert: Unit status is Active. + assert: Unit status is not Blocked — http:// is a valid scheme. """ ctx = Context(GarmConfiguratorCharm) secret = _make_secret() @@ -158,7 +158,7 @@ def test_charm_active_with_http_auth_url(): config["openstack-auth-url"] = "http://keystone.local:5000/v3" state = State(config=config, secrets=[secret, pk_secret]) out = ctx.run(ctx.on.config_changed(), state) - assert out.unit_status == ops.ActiveStatus("Ready") + assert not isinstance(out.unit_status, ops.BlockedStatus) def test_charm_blocked_github_app_private_key_secret_missing_value_key(): @@ -193,3 +193,168 @@ def test_charm_blocked_github_app_private_key_secret_not_found(): assert out.unit_status == ops.BlockedStatus( "github-app-private-key secret is invalid or missing 'value' key" ) + + +def test_reconcile_writes_openstack_credentials_to_image_relation(): + """ + arrange: Valid config and an image relation with no existing data. + act: relation_joined fires (holistic reconcile). + assert: All six OpenStack credential fields are written to local unit data. + """ + ctx = Context(GarmConfiguratorCharm) + secret = _make_secret() + pk_secret = _make_private_key_secret() + image_relation = Relation(endpoint="image") + state = State( + config=_valid_config(secret, pk_secret), + secrets=[secret, pk_secret], + relations=[image_relation], + ) + out = ctx.run(ctx.on.relation_joined(image_relation), state) + rel_out = out.get_relation(image_relation.id) + assert rel_out.local_unit_data["auth_url"] == "https://keystone.example.com:5000/v3" + assert rel_out.local_unit_data["username"] == "admin" + assert rel_out.local_unit_data["password"] == "s3cr3t" + assert rel_out.local_unit_data["project_name"] == "myproject" + assert rel_out.local_unit_data["user_domain_name"] == "Default" + assert rel_out.local_unit_data["project_domain_name"] == "Default" + + +def test_reconcile_writes_credentials_on_secret_changed(): + """ + arrange: Valid config, existing image relation, OpenStack password secret has a new revision. + act: secret_changed fires for the password secret. + assert: The rotated password (latest revision) is pushed to the relation databag. + """ + ctx = Context(GarmConfiguratorCharm) + secret = Secret(tracked_content={"value": "old-password"}, latest_content={"value": "new-password"}) + pk_secret = _make_private_key_secret() + image_relation = Relation(endpoint="image") + state = State( + config=_valid_config(secret, pk_secret), + secrets=[secret, pk_secret], + relations=[image_relation], + ) + out = ctx.run(ctx.on.secret_changed(secret), state) + rel_out = out.get_relation(image_relation.id) + assert rel_out.local_unit_data["password"] == "new-password" + + +def test_reconcile_writes_credentials_on_config_changed_with_existing_relation(): + """ + arrange: Valid config and an existing image relation. + act: config-changed fires (holistic reconcile). + assert: Credentials are written to the relation even outside relation_joined. + """ + ctx = Context(GarmConfiguratorCharm) + secret = _make_secret() + pk_secret = _make_private_key_secret() + image_relation = Relation(endpoint="image") + state = State( + config=_valid_config(secret, pk_secret), + secrets=[secret, pk_secret], + relations=[image_relation], + ) + out = ctx.run(ctx.on.config_changed(), state) + rel_out = out.get_relation(image_relation.id) + assert rel_out.local_unit_data["auth_url"] == "https://keystone.example.com:5000/v3" + assert rel_out.local_unit_data["project_name"] == "myproject" + + +def test_reconcile_does_not_write_credentials_when_config_invalid(): + """ + arrange: Missing openstack-auth-url and an image relation joined. + act: relation_joined fires. + assert: Local unit data remains empty (no credentials forwarded). + """ + ctx = Context(GarmConfiguratorCharm) + secret = _make_secret() + pk_secret = _make_private_key_secret() + config = _valid_config(secret, pk_secret) + del config["openstack-auth-url"] + image_relation = Relation(endpoint="image") + state = State( + config=config, + secrets=[secret, pk_secret], + relations=[image_relation], + ) + out = ctx.run(ctx.on.relation_joined(image_relation), state) + rel_out = out.get_relation(image_relation.id) + assert "auth_url" not in rel_out.local_unit_data + + +def test_status_waiting_when_image_relation_has_no_uuid(): + """ + arrange: Valid config, image relation joined, but provider has not set an image UUID yet. + act: relation_changed fires (no UUID in remote data). + assert: Unit status is Waiting. + """ + ctx = Context(GarmConfiguratorCharm) + secret = _make_secret() + pk_secret = _make_private_key_secret() + image_relation = Relation(endpoint="image") + state = State( + config=_valid_config(secret, pk_secret), + secrets=[secret, pk_secret], + relations=[image_relation], + ) + out = ctx.run(ctx.on.relation_changed(image_relation), state) + assert out.unit_status == ops.WaitingStatus("Waiting for image UUID from image builder") + + +def test_status_active_when_image_uuid_is_present(): + """ + arrange: Valid config, image relation joined, provider has set a UUID. + act: relation_changed fires with UUID in remote data. + assert: Unit status is Active. + """ + ctx = Context(GarmConfiguratorCharm) + secret = _make_secret() + pk_secret = _make_private_key_secret() + image_relation = Relation(endpoint="image", remote_units_data={0: {"id": "abc-image-uuid"}}) + state = State( + config=_valid_config(secret, pk_secret), + secrets=[secret, pk_secret], + relations=[image_relation], + ) + out = ctx.run(ctx.on.relation_changed(image_relation), state) + assert out.unit_status == ops.ActiveStatus("Ready") + + +def test_status_waiting_when_no_image_relation(): + """ + arrange: Valid config, no image relation. + act: config_changed fires. + assert: Unit status is Waiting — the image builder relation is required. + """ + ctx = Context(GarmConfiguratorCharm) + secret = _make_secret() + pk_secret = _make_private_key_secret() + state = State( + config=_valid_config(secret, pk_secret), + secrets=[secret, pk_secret], + ) + out = ctx.run(ctx.on.config_changed(), state) + assert out.unit_status == ops.WaitingStatus("Waiting for image builder relation") + + +def test_status_waiting_on_relation_broken(): + """ + arrange: Valid config and the image relation being torn down. + act: relation_broken fires. + assert: Unit status is Waiting — ops excludes the breaking relation from model.relations, + so the charm correctly reflects that it has no image builder connected. + """ + ctx = Context(GarmConfiguratorCharm) + secret = _make_secret() + pk_secret = _make_private_key_secret() + image_relation = Relation(endpoint="image") + state = State( + config=_valid_config(secret, pk_secret), + secrets=[secret, pk_secret], + relations=[image_relation], + ) + out = ctx.run(ctx.on.relation_broken(image_relation), state) + assert out.unit_status == ops.WaitingStatus("Waiting for image builder relation") + + diff --git a/charms/tests/integration/conftest.py b/charms/tests/integration/conftest.py index d8215e4a..0703ea14 100644 --- a/charms/tests/integration/conftest.py +++ b/charms/tests/integration/conftest.py @@ -421,3 +421,48 @@ def deploy_garm_app_fixture( logger.info("GARM app '%s' is active", app_name) return app_name + + +@pytest.fixture(scope="module", name="any_charm_image_builder_app") +def deploy_any_charm_image_builder_app_fixture(juju: jubilant.Juju) -> str: + """Deploy any-charm as a fake image builder providing github_runner_image_v0. + + On relation joined, the fake builder immediately writes a synthetic image UUID + to its unit relation data, allowing the configurator to transition to Active. + """ + app_name = "fake-image-builder" + + any_charm_src_overwrite = { + "any_charm.py": textwrap.dedent( + """\ + from any_charm_base import AnyCharmBase + + FAKE_IMAGE_ID = "fake-openstack-image-uuid" + FAKE_IMAGE_TAGS = "x64,noble" + + class AnyCharm(AnyCharmBase): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.framework.observe( + self.on['provide-github-runner-image-v0'].relation_joined, + self._on_image_relation_joined, + ) + + def _on_image_relation_joined(self, event): + event.relation.data[self.unit]["id"] = FAKE_IMAGE_ID + event.relation.data[self.unit]["tags"] = FAKE_IMAGE_TAGS + """ + ), + } + juju.deploy( + "any-charm", + app=app_name, + channel="latest/beta", + config={"src-overwrite": json.dumps(any_charm_src_overwrite)}, + ) + juju.wait( + lambda status: jubilant.all_active(status, app_name), + timeout=10 * 60, + delay=10, + ) + return app_name diff --git a/charms/tests/integration/test_garm_configurator.py b/charms/tests/integration/test_garm_configurator.py index cbcfc20b..c306aa32 100644 --- a/charms/tests/integration/test_garm_configurator.py +++ b/charms/tests/integration/test_garm_configurator.py @@ -3,15 +3,13 @@ """Integration tests for the garm-configurator charm.""" -import logging +import json import jubilant import pytest from tests.conftest import CHARM_FILE_PARAM -logger = logging.getLogger(__name__) - @pytest.fixture(name="garm_configurator_charm_file", scope="module") def garm_configurator_charm_file_fixture(pytestconfig: pytest.Config) -> str: @@ -38,7 +36,8 @@ def deploy_garm_configurator_app_fixture( ) -> str: """Deploy the garm-configurator application with mock config. - Returns the application name once the app reaches Active. + Returns the application name once the app reaches Waiting (config valid, + image builder relation not yet connected). """ app_name = "garm-configurator" juju.deploy(charm=garm_configurator_charm_file, app=app_name) @@ -74,21 +73,81 @@ def deploy_garm_configurator_app_fixture( }, ) juju.wait( - lambda status: jubilant.all_active(status, app_name), + lambda status: jubilant.all_waiting(status, app_name), timeout=5 * 60, delay=10, ) return app_name -def test_garm_configurator_deploys_active( +def test_garm_configurator_waits_without_image_relation( juju: jubilant.Juju, garm_configurator_app: str, ) -> None: """ arrange: garm-configurator charm deployed with valid mock configuration. - act: Check the application status. - assert: Application is in Active state. + act: Check the application status before any image builder is connected. + assert: Application is Waiting — the image builder relation is required. """ status = juju.status() - assert jubilant.all_active(status, garm_configurator_app) + assert jubilant.all_waiting(status, garm_configurator_app) + + +def test_garm_configurator_image_relation( + juju: jubilant.Juju, + garm_configurator_app: str, + any_charm_image_builder_app: str, +) -> None: + """ + arrange: garm-configurator fully configured, fake image builder deployed. + act: Integrate garm-configurator:image with the fake image builder. + assert: + - garm-configurator unit relation data contains all six OpenStack credential fields. + - fake image builder unit relation data contains the synthetic image UUID. + - garm-configurator is Active once relation data has settled. + """ + juju.integrate( + f"{garm_configurator_app}:image", + f"{any_charm_image_builder_app}:provide-github-runner-image-v0", + ) + # The charm starts in WaitingStatus (no relation). ActiveStatus is only + # reached after credentials are written AND the UUID is received back, so + # all_active is a reliable signal that the full handshake completed. + juju.wait( + lambda status: jubilant.all_active(status, garm_configurator_app), + timeout=5 * 60, + delay=10, + ) + + configurator_unit = f"{garm_configurator_app}/0" + builder_unit = f"{any_charm_image_builder_app}/0" + + builder_info = json.loads( + juju.cli("show-unit", builder_unit, "--format=json") + )[builder_unit] + builder_rel = next( + (r for r in builder_info["relation-info"] + if r["endpoint"] == "provide-github-runner-image-v0"), + None, + ) + assert builder_rel is not None, "provide-github-runner-image-v0 relation not found in show-unit" + creds = builder_rel["related-units"][configurator_unit]["data"] + + conf_info = json.loads( + juju.cli("show-unit", configurator_unit, "--format=json") + )[configurator_unit] + conf_rel = next( + (r for r in conf_info["relation-info"] if r["endpoint"] == "image"), + None, + ) + assert conf_rel is not None, "image relation not found in show-unit" + uuid_data = conf_rel["related-units"][builder_unit]["data"] + + assert creds["auth_url"] == "https://keystone.example.com:5000/v3" + assert creds["username"] == "admin" + assert creds["password"] == "fake-password" + assert creds["project_name"] == "myproject" + assert creds["user_domain_name"] == "Default" + assert creds["project_domain_name"] == "Default" + + assert uuid_data["id"] == "fake-openstack-image-uuid"