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
1 change: 0 additions & 1 deletion app/src/github_runner_image_builder/logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,4 @@ def configure(log_level: str | int) -> None:
logging.basicConfig(
level=log_level_normalized,
handlers=(log_handler,),
encoding="utf-8",
)
9 changes: 9 additions & 0 deletions charmcraft.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,15 @@ config:
The password section of the clouds.yaml contents, used to authenticate the OpenStack \
client (e.g. myverysecurepassword). See https://docs.openstack.org/python-openstackclient/\
queens/configuration/index.html for more information.
DEPRECATED: Use openstack-password-secret instead for better security.
openstack-password-secret:
type: secret
description: |
The password section of the clouds.yaml contents, used to authenticate the OpenStack
client. A Juju user secret ID should be passed in the format of secret:<secret-id>.
The secret must contain a 'password' key with the OpenStack password as its value.
Example: juju add-secret openstack-password password=<value>.
This option takes precedence over openstack-password if both are set.
openstack-project-domain-name:
type: string
default: ""
Expand Down
5 changes: 5 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@

<!-- vale Canonical.007-Headings-sentence-case = NO -->

## [#219 Use Juju secrets](https://github.com/canonical/github-runner-image-builder-operator/pull/219) (2026-04-17)
* Add new `openstack-password-secret` configuration option to securely store OpenStack passwords using Juju secrets.
* Deprecated `openstack-password` configuration option (still supported for backward compatibility).
* Users can migrate to the new secret-based approach by setting `openstack-password-secret` instead of `openstack-password`.

## [#206 Add resolute image support]
* Add resolute image support.

Expand Down
38 changes: 37 additions & 1 deletion src/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@
OPENSTACK_AUTH_URL_CONFIG_NAME = "openstack-auth-url"
# Bandit thinks this is a hardcoded password
OPENSTACK_PASSWORD_CONFIG_NAME = "openstack-password" # nosec: hardcoded_password_string
# Bandit thinks this is a hardcoded password
OPENSTACK_PASSWORD_SECRET_CONFIG_NAME = (
"openstack-password-secret" # nosec: hardcoded_password_string
)
OPENSTACK_PROJECT_DOMAIN_CONFIG_NAME = "openstack-project-domain-name"
OPENSTACK_PROJECT_CONFIG_NAME = "openstack-project-name"
OPENSTACK_USER_DOMAIN_CONFIG_NAME = "openstack-user-domain-name"
Expand Down Expand Up @@ -628,14 +632,46 @@ def _parse_openstack_clouds_config(charm: ops.CharmBase) -> OpenstackCloudsConfi
The openstack clouds yaml.
"""
auth_url = typing.cast(str, charm.config.get(OPENSTACK_AUTH_URL_CONFIG_NAME))
password_secret_id = typing.cast(str, charm.config.get(OPENSTACK_PASSWORD_SECRET_CONFIG_NAME))
password = typing.cast(str, charm.config.get(OPENSTACK_PASSWORD_CONFIG_NAME))
project_domain = typing.cast(str, charm.config.get(OPENSTACK_PROJECT_DOMAIN_CONFIG_NAME))
project = typing.cast(str, charm.config.get(OPENSTACK_PROJECT_CONFIG_NAME))
user_domain = typing.cast(str, charm.config.get(OPENSTACK_USER_DOMAIN_CONFIG_NAME))
user = typing.cast(str, charm.config.get(OPENSTACK_USER_CONFIG_NAME))
if not all((auth_url, password, project_domain, project, user_domain, user)):

# Check if we have the required configs, password can come from either source
if not all((auth_url, project_domain, project, user_domain, user)):
raise InvalidCloudConfigError("Please supply all OpenStack configurations.")

# Prefer the secret-based password if provided
if password_secret_id:
if not password_secret_id.startswith("secret:"):
raise InvalidCloudConfigError(
f"Invalid value '{password_secret_id}' for openstack-password-secret. "
"Expected a Juju secret ID in the format 'secret:<secret-id>'."
)
try:
secret = charm.model.get_secret(id=password_secret_id)
except ops.SecretNotFoundError as exc:
raise InvalidCloudConfigError(
f"OpenStack password secret not found: {password_secret_id}."
) from exc
except ops.ModelError as exc:
raise InvalidCloudConfigError(
"Charm does not have access to the OpenStack password secret. "
"Please grant the charm read access to the secret."
) from exc
secret_content = secret.get_content(refresh=True)
password = secret_content.get("password", "")
if not password:
raise InvalidCloudConfigError(
f"Secret {password_secret_id} does not contain a 'password' key."
)
elif not password:
raise InvalidCloudConfigError(
"Please supply OpenStack password via openstack-password or openstack-password-secret."
)
Comment thread
yanksyoon marked this conversation as resolved.

clouds_config = OpenstackCloudsConfig(
clouds={
CLOUD_NAME: _CloudsConfig(
Expand Down
14 changes: 12 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

"""Fixtures for github runner charm."""

import os

from pytest import Parser


Expand All @@ -29,52 +31,60 @@ def pytest_addoption(parser: Parser):
"--openstack-network-name-amd64",
action="store",
help="The Openstack network to create testing instances under.",
default=os.getenv("OPENSTACK_NETWORK_NAME_AMD64"),
)
parser.addoption(
"--openstack-flavor-name-amd64",
action="store",
help="The Openstack flavor to create testing instances with.",
default=os.getenv("OPENSTACK_FLAVOR_NAME_AMD64"),
)
parser.addoption(
"--openstack-auth-url-amd64",
action="store",
help="The URL to Openstack authentication service, i.e. keystone.",
default=os.getenv("OPENSTACK_AUTH_URL_AMD64"),
)
parser.addoption(
"--openstack-project-domain-name-amd64",
action="store",
help="The Openstack project domain name to use.",
default=os.getenv("OPENSTACK_PROJECT_DOMAIN_NAME_AMD64"),
)
parser.addoption(
"--openstack-project-name-amd64",
action="store",
help="The Openstack project name to use.",
default=os.getenv("OPENSTACK_PROJECT_NAME_AMD64"),
)
parser.addoption(
"--openstack-user-domain-name-amd64",
action="store",
help="The Openstack user domain name to use.",
default=os.getenv("OPENSTACK_USER_DOMAIN_NAME_AMD64"),
)
parser.addoption(
"--openstack-username-amd64",
action="store",
help="The Openstack user to authenticate as.",
default=os.getenv("OPENSTACK_USERNAME_AMD64"),
)
parser.addoption(
"--openstack-region-name-amd64",
action="store",
help="The Openstack region to authenticate to.",
default=os.getenv("OPENSTACK_REGION_NAME_AMD64"),
)
# Shared private endpoint options
parser.addoption(
"--proxy",
action="store",
help="The HTTP proxy URL to apply on the Openstack runners.",
default=None,
default=os.getenv("PROXY"),
)
parser.addoption(
"--no-proxy",
action="store",
help="The no proxy URL(s) to apply on the Openstack runners.",
default=None,
default=os.getenv("NO_PROXY"),
)
94 changes: 79 additions & 15 deletions tests/integration/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
EXTERNAL_BUILD_NETWORK_CONFIG_NAME,
OPENSTACK_AUTH_URL_CONFIG_NAME,
OPENSTACK_PASSWORD_CONFIG_NAME,
OPENSTACK_PASSWORD_SECRET_CONFIG_NAME,
OPENSTACK_PROJECT_CONFIG_NAME,
OPENSTACK_PROJECT_DOMAIN_CONFIG_NAME,
OPENSTACK_USER_CONFIG_NAME,
Expand Down Expand Up @@ -332,12 +333,27 @@ async def script_secret_fixture(test_configs) -> _Secret:
return _Secret(id=secret_id, name=secret_name)


@pytest.fixture(scope="module", name="app_config")
def app_config_fixture(
@pytest_asyncio.fixture(scope="module", name="openstack_password_secret")
async def openstack_password_secret_fixture(
test_configs: TestConfigs,
private_endpoint_configs: PrivateEndpointConfigs,
) -> _Secret:
"""The OpenStack password Juju secret."""
secret_name = f"openstack-password-{uuid4().hex}"
secret_id = await test_configs.model.add_secret(
name=secret_name,
data_args=[f"password={private_endpoint_configs['password']}"],
) # note secret_id already contains "secret:" prefix
return _Secret(id=secret_id, name=secret_name)


@pytest_asyncio.fixture(scope="module", name="app_config")
async def app_config_fixture(
private_endpoint_configs: PrivateEndpointConfigs,
image_configs: ImageConfigs,
openstack_metadata: OpenstackMeta,
arch: state.Arch,
openstack_password_secret: _Secret,
) -> dict:
"""The image builder application config."""
return {
Expand All @@ -346,7 +362,7 @@ def app_config_fixture(
BUILD_INTERVAL_CONFIG_NAME: 12,
REVISION_HISTORY_LIMIT_CONFIG_NAME: 5,
OPENSTACK_AUTH_URL_CONFIG_NAME: private_endpoint_configs["auth_url"],
OPENSTACK_PASSWORD_CONFIG_NAME: private_endpoint_configs["password"],
OPENSTACK_PASSWORD_SECRET_CONFIG_NAME: openstack_password_secret.id,
Comment thread
yanksyoon marked this conversation as resolved.
OPENSTACK_PROJECT_CONFIG_NAME: private_endpoint_configs["project_name"],
OPENSTACK_PROJECT_DOMAIN_CONFIG_NAME: private_endpoint_configs["project_domain_name"],
OPENSTACK_USER_CONFIG_NAME: private_endpoint_configs["username"],
Expand All @@ -372,6 +388,7 @@ async def app_fixture(
base_machine_constraint: str,
test_configs: TestConfigs,
script_secret: _Secret,
openstack_password_secret: _Secret,
) -> AsyncGenerator[Application, None]:
"""The deployed application fixture."""
logger.info("Deploying image builder: %s", test_configs.dispatch_time)
Expand All @@ -381,6 +398,7 @@ async def app_fixture(
constraints=base_machine_constraint,
config=app_config,
)
await app.model.grant_secret(openstack_password_secret.name, app.name)
await app.model.grant_secret(script_secret.name, app.name)
await app.set_config(
{
Expand All @@ -398,38 +416,84 @@ async def app_fixture(
await test_configs.model.remove_application(app_name=app.name)


@pytest_asyncio.fixture(scope="module", name="app_on_charmhub")
async def app_on_charmhub_fixture(
test_configs: TestConfigs,
app_config: dict,
base_machine_constraint: str,
ops_test,
) -> AsyncGenerator[Application, None]:
"""Fixture for deploying the charm from charmhub."""
# Normally we would use latest/stable, but upgrading
# from stable is currently broken, and therefore we are using edge. Change this in the future.
async def _prepare_charmhub_app_config(
ops_test, app_config: dict, openstack_password: str
) -> tuple[str, dict, set[str]]:
"""Prepare the application config for charmhub deployment.

Args:
ops_test: The pytest operator test instance.
app_config: The base application configuration.
openstack_password: The plaintext OpenStack password, used as a fallback when the
charmhub revision does not yet expose openstack-password-secret.

Returns:
A tuple of (channel, prepared_config, config_options).

"""
charmhub_channel = "edge"
ret_code, stdout, stderr = await ops_test.juju(
"info", "--format", "json", "--channel", charmhub_channel, "github-runner-image-builder"
)
assert ret_code == 0, f"Failed to get charm info: {stderr}"
charmhub_info = json.loads(stdout.strip())
charmhub_config_options = charmhub_info["charm"]["config"]["Options"].keys()
charmhub_config_options = set(charmhub_info["charm"]["config"]["Options"].keys())

charmhub_app_config = {k: v for k, v in app_config.items() if k in charmhub_config_options}
# We might need to test using the legacy config options.
legacy_config_prefix = "experimental-external-"
for opt in (EXTERNAL_BUILD_FLAVOR_CONFIG_NAME, EXTERNAL_BUILD_NETWORK_CONFIG_NAME):
if (legacy_opt := f"{legacy_config_prefix}{opt}") in charmhub_config_options:
charmhub_app_config[legacy_opt] = app_config[opt]

# If the charmhub revision doesn't expose openstack-password-secret yet, fall back to the
# legacy openstack-password option so the charm has credentials during initial deployment.
if (
OPENSTACK_PASSWORD_SECRET_CONFIG_NAME not in charmhub_config_options
and OPENSTACK_PASSWORD_CONFIG_NAME in charmhub_config_options
):
charmhub_app_config[OPENSTACK_PASSWORD_CONFIG_NAME] = openstack_password

return charmhub_channel, charmhub_app_config, charmhub_config_options


@pytest_asyncio.fixture(scope="module", name="app_on_charmhub")
async def app_on_charmhub_fixture( # pylint: disable=too-many-arguments,too-many-positional-arguments
test_configs: TestConfigs,
app_config: dict,
base_machine_constraint: str,
ops_test,
openstack_password_secret: _Secret,
private_endpoint_configs: PrivateEndpointConfigs,
) -> AsyncGenerator[Application, None]:
"""Fixture for deploying the charm from charmhub."""
# Normally we would use latest/stable, but upgrading
# from stable is currently broken, and therefore we are using edge. Change this in the future.
charmhub_channel, charmhub_app_config, charmhub_config_options = (
await _prepare_charmhub_app_config(
ops_test, app_config, private_endpoint_configs["password"]
)
)

# Deploy without the secret-backed config so the charm doesn't try to read the secret
# before the grant is in place.
deploy_config = {
k: v for k, v in charmhub_app_config.items() if k != OPENSTACK_PASSWORD_SECRET_CONFIG_NAME
}
app: Application = await test_configs.model.deploy(
"github-runner-image-builder",
application_name=f"image-builder-operator-{test_configs.test_id}",
constraints=base_machine_constraint,
config=charmhub_app_config,
config=deploy_config,
channel=charmhub_channel,
)

if OPENSTACK_PASSWORD_SECRET_CONFIG_NAME in charmhub_config_options:
# Grant access first, then set the config to trigger a config-changed hook
# after the charm already has read permissions for the secret.
await app.model.grant_secret(openstack_password_secret.name, app.name)
await app.set_config({OPENSTACK_PASSWORD_SECRET_CONFIG_NAME: openstack_password_secret.id})

Comment thread
yanksyoon marked this conversation as resolved.
Comment thread
yanksyoon marked this conversation as resolved.
await test_configs.model.wait_for_idle(apps=[app.name], idle_period=30, timeout=60 * 30)

yield app
Expand Down
15 changes: 14 additions & 1 deletion tests/unit/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
EXTERNAL_BUILD_NETWORK_CONFIG_NAME,
OPENSTACK_AUTH_URL_CONFIG_NAME,
OPENSTACK_PASSWORD_CONFIG_NAME,
OPENSTACK_PASSWORD_SECRET_CONFIG_NAME,
OPENSTACK_PROJECT_CONFIG_NAME,
OPENSTACK_PROJECT_DOMAIN_CONFIG_NAME,
OPENSTACK_USER_CONFIG_NAME,
Expand Down Expand Up @@ -76,6 +77,7 @@ class Meta: # pylint: disable=too-few-public-methods
"""Configuration for factory.""" # noqa: DCO060

model = MagicMock
exclude = ["_setup_mock_openstack_secret"]

app = MockAppFactory()
unit = MockUnitFactory()
Expand All @@ -86,7 +88,8 @@ class Meta: # pylint: disable=too-few-public-methods
EXTERNAL_BUILD_FLAVOR_CONFIG_NAME: "test-flavor",
EXTERNAL_BUILD_NETWORK_CONFIG_NAME: "test-network",
OPENSTACK_AUTH_URL_CONFIG_NAME: "http://testing-auth/keystone",
OPENSTACK_PASSWORD_CONFIG_NAME: "test-password",
OPENSTACK_PASSWORD_CONFIG_NAME: "",
OPENSTACK_PASSWORD_SECRET_CONFIG_NAME: "secret:test-secret-id",
OPENSTACK_PROJECT_DOMAIN_CONFIG_NAME: "test-project-domain",
OPENSTACK_PROJECT_CONFIG_NAME: "test-project-name",
OPENSTACK_USER_DOMAIN_CONFIG_NAME: "test-user-domain",
Expand All @@ -98,6 +101,16 @@ class Meta: # pylint: disable=too-few-public-methods
}
)

@factory.post_generation
def _setup_mock_openstack_secret( # noqa: DCO020
obj: MagicMock, create: bool, extracted: typing.Any, **kwargs: typing.Any
) -> None:
mock_secret = MagicMock()
mock_secret.get_content.return_value = {
"password": "test-password" # nosec: hardcoded_password_string
}
obj.model.get_secret.return_value = mock_secret


class CloudAuthFactory(factory.DictFactory):
"""Mock cloud auth dict object factory.""" # noqa: DCO060
Expand Down
Loading
Loading