Skip to content
Open
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
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,7 @@ build/

# Editor/IDE
.idea/
.vscode/
.vscode/

# Git worktrees
.worktrees/
5 changes: 5 additions & 0 deletions charms/garm/charmcraft.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,8 @@ requires:
limit: 1
garm-configurator:
interface: garm_configurator_v0

actions:
get-credentials:
description: Show the GARM admin credentials.

15 changes: 15 additions & 0 deletions charms/garm/src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,13 +292,28 @@ def __init__(self, *args: typing.Any) -> None:
self.on[GARM_CONFIGURATOR_RELATION_NAME].relation_broken,
self._reconcile,
)
self.framework.observe(
self.on.get_credentials_action, self._on_get_credentials_action
)
self.framework.observe(self.on.update_status, self._reconcile)

@block_if_invalid_data
def _reconcile(self, _: ops.EventBase) -> None:
"""Reconcile charm state."""
self.restart()

def _on_get_credentials_action(self, event: ops.ActionEvent) -> None:
"""Return the GARM admin credentials to the operator.

Args:
event: The action event.
"""
credentials = self._get_admin_credentials()
if credentials is None:
event.fail("GARM admin credentials are not yet available")
return
event.set_results(credentials)

@property
def _workload_config(self) -> WorkloadConfig:
"""Pin GARM to a fixed port and disable the default metrics scrape job.
Expand Down
38 changes: 38 additions & 0 deletions charms/garm/tests/unit/test_charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,44 @@ def test_maybe_first_run_skips_on_missing_credential_key():
mock_client_cls.assert_not_called()


def test_get_credentials_action_returns_credentials_when_available():
"""
arrange: Admin credentials secret exists and contains valid content.
act: Call _on_get_credentials_action().
"""

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This docstring is missing its assert: section — it only has arrange:/act:, while the test body performs two assertions. The sibling test added in the same commit (test_get_credentials_action_fails_when_credentials_unavailable) includes all three sections, as does every other test in this file.

Suggestion:

Suggested change
"""
assert: event.set_results is called once with the credentials dict and
event.fail is not called.
"""

charm = MagicMock()
event = MagicMock()
credentials = {
"username": "admin",
"password": "s3cr3t",
"email": "admin@garm.local",
"full-name": "GARM Admin",
}
charm._get_admin_credentials.return_value = credentials

GarmCharm._on_get_credentials_action(charm, event)

event.set_results.assert_called_once_with(credentials)
event.fail.assert_not_called()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we add assertion that the credentials returned is as expected

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's what the event.set_results.assert_called_once_with(credentials) is doing. Since we're mocking the event and not using scenario we don't have a way to actually get the output ( the hook methods always return None )

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i see, is there a reason why we cannot use scenario? ideally it's better to test the state, not interaction

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No particular reason aside from that I like to keep tests simple if it's only a single method. And using scenario we'd still only call the hook method so it's a bit more overhead. But I can switch to scenario if needed, wdyt?



def test_get_credentials_action_fails_when_credentials_unavailable():
"""
arrange: Admin credentials secret does not exist yet.
act: Call _on_get_credentials_action().
assert: event.fail is called with a message containing "not yet available" and
event.set_results is not called.
"""
charm = MagicMock()
event = MagicMock()
charm._get_admin_credentials.return_value = None

GarmCharm._on_get_credentials_action(charm, event)

event.fail.assert_called_once()
fail_message = event.fail.call_args[0][0]
assert "not yet available" in fail_message
event.set_results.assert_not_called()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are zero blank lines between the end of this test and the next top-level def test_reconcile_scalesets_skips_when_no_admin_credentials(): — PEP 8 / ruff E302 expects two. This won't fail CI (this charm's tox -e lint only checks src, not tests/), but it will get reformatted the next time someone runs tox -e fmt, producing unrelated churn in a future PR.

Suggested change
event.set_results.assert_not_called()
event.set_results.assert_not_called()

def test_reconcile_scalesets_skips_when_no_admin_credentials():
"""
arrange: Admin credentials secret is unavailable.
Expand Down
28 changes: 28 additions & 0 deletions charms/tests/integration/test_garm.py
Original file line number Diff line number Diff line change
Expand Up @@ -770,3 +770,31 @@ def _wait_for_scaleset(
)
return scaleset


def test_garm_get_credentials_action(
juju: jubilant.Juju,
garm_app: str,
):
"""
arrange: The GARM charm is deployed and active (leader has initialized secrets).
act: Run the get-credentials action on the first unit.
assert: The action succeeds and returns all four credential fields (username,
password, email, full-name), with username "admin" and email "admin@garm.local".
"""
unit = f"{garm_app}/0"
logger.info("Running get-credentials action on unit %s", unit)

# juju.run() raises TaskError if the action fails, so a clean return means success.
task = juju.run(unit, "get-credentials")

for key in ("username", "password", "email", "full-name"):
assert key in task.results, (
f"Expected '{key}' in action results, got: {list(task.results)}"
)
assert task.results["username"] == "admin", (
f"Expected username 'admin', got: {task.results['username']!r}"
)
assert task.results["email"] == "admin@garm.local", (
f"Expected email 'admin@garm.local', got: {task.results['email']!r}"
)
assert task.results["password"], "Expected non-empty password in action results"
1 change: 1 addition & 0 deletions docs/how-to/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ The following guides cover key processes and common tasks for managing and using

Contribute <contribute>
Enable log forwarding <enable-log-forwarding>
Retrieve GARM admin credentials <retrieve-garm-credentials>
39 changes: 39 additions & 0 deletions docs/how-to/retrieve-garm-credentials.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# How to retrieve GARM admin credentials
Comment thread
Thanhphan1147 marked this conversation as resolved.

The `get-credentials` action retrieves the GARM admin credentials generated by the charm during start-up. Use it when you need to authenticate via the GARM API or when registering new profiles using `garm-cli`.
Comment thread
Thanhphan1147 marked this conversation as resolved.

## Prerequisites

The GARM charm must be fully initialized. The unit status must be `active` before running this action. If the charm has not completed its first-run setup, the credentials will not yet be available and the action will fail.

## Run the action

```bash
juju run garm/0 get-credentials
```

## Action output

The action returns four fields:

| Field | Description |
|-------|-------------|
| `username` | The GARM admin username. |
| `password` | The GARM admin password. |
| `email` | The email address associated with the admin account. |
| `full-name` | The full name associated with the admin account. |

Example output:

```{terminal}
:output-only:

Running operation 1 with 1 task
- task 2 on unit-garm-0

Waiting for task 2...
email: admin@garm.local
full-name: GARM Admin
password: <generated-password>
username: admin
```