Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
14953bf
Add support for integration test with juju secrets
yhaliaw Apr 17, 2026
c8dbc15
Remove incorrect argument for juju.deploy
yhaliaw Apr 17, 2026
97345c0
Merge remote-tracking branch 'origin/main' into feat/juju-secret-config
cbartz Apr 17, 2026
725e81c
fix(test): remove invalid log kwarg from juju.deploy
cbartz Apr 17, 2026
1715b66
docs: document juju secret config options in changelog
cbartz Apr 17, 2026
ef2e801
test(integration): skip test_charm_upgrade until latest/edge has secr…
cbartz Apr 17, 2026
e70587b
fix(charm_state): mention token-secret-id in missing-auth error
cbartz Apr 17, 2026
cdf2060
Apply suggestions from code review
cbartz Apr 17, 2026
028b739
docs(test): explain why plaintext fallback is not used in upgrade tes…
cbartz Apr 17, 2026
77067cb
fix(charm_state): tailor openstack clouds yaml parse error to source
cbartz Apr 17, 2026
8de25ca
refactor(charm): collapse config-changed flush detection into a loop
cbartz Apr 17, 2026
2bd7e7f
refactor(test): thread openstack clouds yaml as helper parameter
cbartz Apr 17, 2026
0b97597
fix(charm_state): silence bandit on openstack secret-id constant
cbartz Apr 17, 2026
dbd8a6a
fix(charm): track plaintext clouds yaml + sharpen secret error paths
cbartz Apr 17, 2026
d959a99
refactor(charm): don't track plaintext openstack-clouds-yaml in _stored
cbartz Apr 17, 2026
262747a
fix(charm_state): don't log decrypted clouds.yaml on parse error
cbartz Apr 17, 2026
5e80b6a
Enable the upgrade charm test
yhaliaw Apr 20, 2026
5075455
Revert "Enable the upgrade charm test"
yhaliaw Apr 20, 2026
a517f96
Remove extra code
yhaliaw Apr 20, 2026
b6ea872
test: skip fork path-change integration test
cbartz Apr 20, 2026
b3ca376
Add back from None for avoid lint
yhaliaw Apr 21, 2026
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
25 changes: 20 additions & 5 deletions charmcraft.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,16 @@ config:
type: string
default: ""
description: >-
The clouds.yaml yaml necessary for OpenStack integration.
The format for the clouds.yaml is described in the docs:
(Compatibility fallback) The clouds.yaml yaml necessary for OpenStack integration.
Prefer setting openstack-clouds-yaml-secret-id.
The format for clouds.yaml is described in the docs:
(https://docs.openstack.org/python-openstackclient/pike/configuration/index.html#clouds-yaml).
openstack-clouds-yaml-secret-id:
type: string
default: ""
description: >-
Juju secret ID containing OpenStack clouds.yaml content under the `clouds-yaml` field.
When set, this takes precedence over `openstack-clouds-yaml`.
openstack-flavor:
type: string
default: ""
Expand Down Expand Up @@ -137,10 +144,18 @@ config:
type: string
default: ""
description: >-
The GitHub Personal Access Token for registering the self-hosted runners. The token requires
'repo' scope for repository runners and 'repo' + 'admin:org' scope for organization runners.
For fine grained token scopes, see
(Compatibility fallback) The GitHub Personal Access Token for registering self-hosted
runners. Prefer setting token-secret-id.
The token requires 'repo' scope for repository runners and 'repo' + 'admin:org' scope for
organization runners.
For fine-grained token scopes, see
https://charmhub.io/github-runner/docs/how-to-change-token.
token-secret-id:
type: string
default: ""
description: >-
Juju secret ID containing the GitHub token under the `github-token` field.
When set, this takes precedence over `token`.
github-app-client-id:
type: string
description: >-
Expand Down
4 changes: 4 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

This changelog documents user-relevant changes to the GitHub runner charm.

## 2026-04-17

- Added `token-secret-id` and `openstack-clouds-yaml-secret-id` configuration options, allowing the GitHub token and OpenStack `clouds.yaml` to be supplied through Juju user secrets. When set, these take precedence over the plaintext `token` and `openstack-clouds-yaml` options, which remain supported as a compatibility fallback.

## 2026-04-13

- Fixed Juju secrets not picking up new revisions. The charm now uses `refresh=True` when reading secret contents, ensuring it always retrieves the latest revision instead of a cached one.
Expand Down
66 changes: 30 additions & 36 deletions src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,11 @@
GITHUB_APP_PRIVATE_KEY_SECRET_ID_CONFIG_NAME,
IMAGE_INTEGRATION_NAME,
LABELS_CONFIG_NAME,
OPENSTACK_CLOUDS_YAML_SECRET_ID_CONFIG_NAME,
PATH_CONFIG_NAME,
PLANNER_INTEGRATION_NAME,
TOKEN_CONFIG_NAME,
TOKEN_SECRET_ID_CONFIG_NAME,
CharmConfigInvalidError,
CharmState,
OpenstackImage,
Expand Down Expand Up @@ -84,6 +86,25 @@
LEGACY_RECONCILE_SERVICE = "ghro.reconcile-runners.service"
LEGACY_MANAGER_SINGLETON_SERVICE = "github-runner-manager.service"

# Config keys whose change triggers a runner flush, mapped to the attribute
# on `_stored` that tracks the last-seen value. The plaintext
# `openstack-clouds-yaml` option is intentionally omitted: tracking it here would
# persist the credentials to `.unit-state.db` on disk. Operators who want
# automatic flush on credential rotation should use
# `openstack-clouds-yaml-secret-id`, which is flushed via this mapping and via
# `on_secret_changed` when the secret content rotates.
_FLUSH_ON_CHANGE_CONFIG_TO_STORED: tuple[tuple[str, str], ...] = (
(TOKEN_CONFIG_NAME, "token"),
(TOKEN_SECRET_ID_CONFIG_NAME, "token_secret_id"),
(GITHUB_APP_CLIENT_ID_CONFIG_NAME, "github_app_client_id"),
(GITHUB_APP_INSTALLATION_ID_CONFIG_NAME, "github_app_installation_id"),
(GITHUB_APP_PRIVATE_KEY_SECRET_ID_CONFIG_NAME, "github_app_private_key_secret_id"),
(PATH_CONFIG_NAME, "path"),
(LABELS_CONFIG_NAME, "labels"),
(OPENSTACK_CLOUDS_YAML_SECRET_ID_CONFIG_NAME, "openstack_clouds_yaml_secret_id"),
Comment thread
cbartz marked this conversation as resolved.
(ALLOW_EXTERNAL_CONTRIBUTOR_CONFIG_NAME, "allow_external_contributor"),
)


logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -205,6 +226,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None:
self._stored.set_default(
path=self.config[PATH_CONFIG_NAME], # for detecting changes
token=self.config[TOKEN_CONFIG_NAME], # for detecting changes
token_secret_id=self.config.get(TOKEN_SECRET_ID_CONFIG_NAME), # for detecting changes
github_app_client_id=self.config.get(
GITHUB_APP_CLIENT_ID_CONFIG_NAME
), # for detecting changes
Expand All @@ -214,6 +236,9 @@ def __init__(self, *args: Any, **kwargs: Any) -> None:
github_app_private_key_secret_id=self.config.get(
GITHUB_APP_PRIVATE_KEY_SECRET_ID_CONFIG_NAME
), # for detecting changes
openstack_clouds_yaml_secret_id=self.config.get(
OPENSTACK_CLOUDS_YAML_SECRET_ID_CONFIG_NAME
), # for detecting changes
labels=self.config[LABELS_CONFIG_NAME], # for detecting changes
allow_external_contributor=self.config[
ALLOW_EXTERNAL_CONTRIBUTOR_CONFIG_NAME
Expand Down Expand Up @@ -350,42 +375,11 @@ def _on_config_changed(self, _: ConfigChangedEvent) -> None:
state = self._setup_state()

flush_runners = False
if self.config[TOKEN_CONFIG_NAME] != self._stored.token:
self._stored.token = self.config[TOKEN_CONFIG_NAME]
flush_runners = True
if self.config.get(GITHUB_APP_CLIENT_ID_CONFIG_NAME) != self._stored.github_app_client_id:
self._stored.github_app_client_id = self.config.get(GITHUB_APP_CLIENT_ID_CONFIG_NAME)
flush_runners = True
if (
self.config.get(GITHUB_APP_INSTALLATION_ID_CONFIG_NAME)
!= self._stored.github_app_installation_id
):
self._stored.github_app_installation_id = self.config.get(
GITHUB_APP_INSTALLATION_ID_CONFIG_NAME
)
flush_runners = True
if (
self.config.get(GITHUB_APP_PRIVATE_KEY_SECRET_ID_CONFIG_NAME)
!= self._stored.github_app_private_key_secret_id
):
self._stored.github_app_private_key_secret_id = self.config.get(
GITHUB_APP_PRIVATE_KEY_SECRET_ID_CONFIG_NAME
)
flush_runners = True
if self.config[PATH_CONFIG_NAME] != self._stored.path:
self._stored.path = self.config[PATH_CONFIG_NAME]
flush_runners = True
if self.config[LABELS_CONFIG_NAME] != self._stored.labels:
self._stored.labels = self.config[LABELS_CONFIG_NAME]
flush_runners = True
if (
self.config[ALLOW_EXTERNAL_CONTRIBUTOR_CONFIG_NAME]
!= self._stored.allow_external_contributor
):
self._stored.allow_external_contributor = self.config[
ALLOW_EXTERNAL_CONTRIBUTOR_CONFIG_NAME
]
flush_runners = True
for config_key, stored_attr in _FLUSH_ON_CHANGE_CONFIG_TO_STORED:
new_value = self.config.get(config_key)
if new_value != getattr(self._stored, stored_attr):
setattr(self._stored, stored_attr, new_value)
flush_runners = True

self._reconcile(state)

Expand Down
67 changes: 61 additions & 6 deletions src/charm_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
MAX_TOTAL_VIRTUAL_MACHINES_CONFIG_NAME = "max-total-virtual-machines"
MANAGER_SSH_PROXY_COMMAND_CONFIG_NAME = "manager-ssh-proxy-command"
OPENSTACK_CLOUDS_YAML_CONFIG_NAME = "openstack-clouds-yaml"
OPENSTACK_CLOUDS_YAML_SECRET_ID_CONFIG_NAME = "openstack-clouds-yaml-secret-id" # nosec
OPENSTACK_NETWORK_CONFIG_NAME = "openstack-network"
OPENSTACK_FLAVOR_CONFIG_NAME = "openstack-flavor"
PATH_CONFIG_NAME = "path"
Expand All @@ -60,6 +61,7 @@
TEST_MODE_CONFIG_NAME = "test-mode"
# bandit thinks this is a hardcoded password.
TOKEN_CONFIG_NAME = "token" # nosec
TOKEN_SECRET_ID_CONFIG_NAME = "token-secret-id" # nosec
USE_APROXY_CONFIG_NAME = "experimental-use-aproxy"
APROXY_EXCLUDE_ADDRESSES_CONFIG_NAME = "aproxy-exclude-addresses"
APROXY_REDIRECT_PORTS_CONFIG_NAME = "aproxy-redirect-ports"
Expand Down Expand Up @@ -152,6 +154,7 @@ class GithubConfig:
path: GitHubPath

@classmethod
# pylint: disable=too-many-locals
def from_charm(cls, charm: CharmBase) -> "GithubConfig": # noqa: C901
"""Get github related charm configuration values from charm.

Expand All @@ -168,6 +171,7 @@ def from_charm(cls, charm: CharmBase) -> "GithubConfig": # noqa: C901

path_str = cast(str, charm.config.get(PATH_CONFIG_NAME, ""))
token = cast(str, charm.config.get(TOKEN_CONFIG_NAME)) or None
token_secret_id = cast(str, charm.config.get(TOKEN_SECRET_ID_CONFIG_NAME)) or None
app_client_id = cast(str, charm.config.get(GITHUB_APP_CLIENT_ID_CONFIG_NAME)) or None
installation_id = (
cast(int, charm.config.get(GITHUB_APP_INSTALLATION_ID_CONFIG_NAME)) or None
Expand All @@ -187,13 +191,27 @@ def from_charm(cls, charm: CharmBase) -> "GithubConfig": # noqa: C901
app_fields = (app_client_id, installation_id, private_key_secret_id)
app_fields_set = sum(field is not None for field in app_fields)

if token_secret_id:
try:
token_secret = charm.model.get_secret(id=token_secret_id)
token = token_secret.get_content(refresh=True).get("github-token")
except SecretNotFoundError as exc:
raise CharmConfigInvalidError(
Comment thread
cbartz marked this conversation as resolved.
f"GitHub token secret {token_secret_id} not found"
) from exc
if not token:
raise CharmConfigInvalidError(
f"GitHub token secret {token_secret_id} is missing github-token"
)

if token and app_fields_set:
raise CharmConfigInvalidError(
"Configure either token or GitHub App authentication, not both"
Comment thread
cbartz marked this conversation as resolved.
)
if not token and not app_fields_set:
raise CharmConfigInvalidError(
f"Missing {TOKEN_CONFIG_NAME} or GitHub App authentication configuration"
f"Missing {TOKEN_CONFIG_NAME}, {TOKEN_SECRET_ID_CONFIG_NAME} "
"or GitHub App authentication configuration"
)
if not token and app_fields_set != 3:
raise CharmConfigInvalidError(
Expand Down Expand Up @@ -385,11 +403,37 @@ def _parse_openstack_clouds_config(cls, charm: CharmBase) -> OpenStackCloudsYAML
Returns:
The openstack clouds yaml.
"""
openstack_clouds_yaml_secret_id = (
cast(str, charm.config.get(OPENSTACK_CLOUDS_YAML_SECRET_ID_CONFIG_NAME)) or None
)
openstack_clouds_yaml_str: str | None = cast(
str, charm.config.get(OPENSTACK_CLOUDS_YAML_CONFIG_NAME)
)
source = OPENSTACK_CLOUDS_YAML_CONFIG_NAME

if openstack_clouds_yaml_secret_id:
source = OPENSTACK_CLOUDS_YAML_SECRET_ID_CONFIG_NAME
try:
cloud_secret = charm.model.get_secret(id=openstack_clouds_yaml_secret_id)
openstack_clouds_yaml_str = cloud_secret.get_content(refresh=True).get(
"clouds-yaml"
)
except SecretNotFoundError as exc:
Comment thread
cbartz marked this conversation as resolved.
raise CharmConfigInvalidError(
f"OpenStack clouds.yaml secret {openstack_clouds_yaml_secret_id} not found"
) from exc
if not openstack_clouds_yaml_str:
raise CharmConfigInvalidError(
"OpenStack clouds.yaml secret "
f"{openstack_clouds_yaml_secret_id} is missing clouds-yaml"
)

if not openstack_clouds_yaml_str:
raise CharmConfigInvalidError("No openstack_clouds_yaml")
raise CharmConfigInvalidError(
"Missing OpenStack clouds configuration; set either "
f"{OPENSTACK_CLOUDS_YAML_CONFIG_NAME} or "
f"{OPENSTACK_CLOUDS_YAML_SECRET_ID_CONFIG_NAME}."
)

try:
openstack_clouds_yaml: OpenStackCloudsYAML = yaml.safe_load(
Expand All @@ -398,10 +442,21 @@ def _parse_openstack_clouds_config(cls, charm: CharmBase) -> OpenStackCloudsYAML
# use Pydantic to validate TypedDict.
create_model_from_typeddict(OpenStackCloudsYAML)(**openstack_clouds_yaml)
except (yaml.YAMLError, TypeError) as exc:
Comment thread
cbartz marked this conversation as resolved.
logger.error(f"Invalid {OPENSTACK_CLOUDS_YAML_CONFIG_NAME} config: %s.", exc)
raise CharmConfigInvalidError(
f"Invalid {OPENSTACK_CLOUDS_YAML_CONFIG_NAME} config. Invalid yaml."
) from exc
if source == OPENSTACK_CLOUDS_YAML_SECRET_ID_CONFIG_NAME:
# Don't log `exc` or chain with `from exc`: yaml.YAMLError and
# pydantic TypeError messages can embed a snippet of the
# offending input (here, the decrypted clouds.yaml), and the
# chained cause would be printed by `logger.exception` upstream.
logger.error(
"Invalid OpenStack clouds.yaml content in secret %s.",
openstack_clouds_yaml_secret_id,
)
raise CharmConfigInvalidError(
"Invalid OpenStack clouds.yaml content in secret "
f"{openstack_clouds_yaml_secret_id}. Invalid yaml."
) from None
logger.error("Invalid %s config: %s.", source, exc)
raise CharmConfigInvalidError(f"Invalid {source} config. Invalid yaml.") from exc

return openstack_clouds_yaml

Expand Down
16 changes: 5 additions & 11 deletions tests/integration/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@
BASE_VIRTUAL_MACHINES_CONFIG_NAME,
DOCKERHUB_MIRROR_CONFIG_NAME,
LABELS_CONFIG_NAME,
OPENSTACK_CLOUDS_YAML_CONFIG_NAME,
OPENSTACK_FLAVOR_CONFIG_NAME,
OPENSTACK_NETWORK_CONFIG_NAME,
PATH_CONFIG_NAME,
Expand Down Expand Up @@ -561,7 +560,6 @@ def image_builder_fixture(
"virt-type": "virtual-machine",
"cores": "2",
},
log=False,
)

yield image_builder_app_name
Expand All @@ -581,8 +579,7 @@ def image_builder_fixture(
series = dep_ctx.series

any_charm_src_overwrite = {
"any_charm.py": textwrap.dedent(
f"""\
"any_charm.py": textwrap.dedent(f"""\
from any_charm_base import AnyCharmBase

class AnyCharm(AnyCharmBase):
Expand All @@ -595,8 +592,7 @@ def _image_relation_changed(self, event):
# Provide mock image relation data
event.relation.data[self.unit]['id'] = '{openstack_config.test_image_id}'
event.relation.data[self.unit]['tags'] = '{series}, amd64'
"""
),
"""),
}
logging.info(
"Deploying fake image builder via any-charm for image ID %s",
Expand Down Expand Up @@ -638,13 +634,13 @@ def app_openstack_runner_fixture(
no_proxy=openstack_config.no_proxy,
),
reconcile_interval=DEFAULT_RECONCILE_INTERVAL,
openstack_clouds_yaml=openstack_config.clouds_yaml_contents,
constraints={
"root-disk": "51200M",
"mem": "2048M",
"virt-type": "virtual-machine",
},
config={
OPENSTACK_CLOUDS_YAML_CONFIG_NAME: openstack_config.clouds_yaml_contents,
OPENSTACK_NETWORK_CONFIG_NAME: openstack_config.network_name,
OPENSTACK_FLAVOR_CONFIG_NAME: openstack_config.flavor_name,
USE_APROXY_CONFIG_NAME: bool(openstack_config.http_proxy),
Expand Down Expand Up @@ -922,8 +918,7 @@ def mock_planner_app(juju: jubilant.Juju, planner_token_secret: str) -> Iterator
planner_name = "planner"

any_charm_src_overwrite = {
"any_charm.py": textwrap.dedent(
f"""\
"any_charm.py": textwrap.dedent(f"""\
from any_charm_base import AnyCharmBase

class AnyCharm(AnyCharmBase):
Expand All @@ -937,8 +932,7 @@ def __init__(self, *args, **kwargs):
def _on_planner_relation_changed(self, event):
event.relation.data[self.app]["endpoint"] = "http://mock:8080"
event.relation.data[self.app]["token"] = "{planner_token_secret}"
"""
),
"""),
}

juju.deploy(
Expand Down
Loading
Loading