Skip to content

Commit 9cbb26f

Browse files
authored
feat(agent): use typed_config in TF Agent Host Charm (#927)
* feat(agent): add typed config to agent host charm * tests: update unit tests with typed config * remove unecessary try except * add additional unit test for invalid base64 * add reviewer suggestions
1 parent b1f5956 commit 9cbb26f

File tree

7 files changed

+127
-62
lines changed

7 files changed

+127
-62
lines changed

agent/charms/testflinger-agent-host-charm/requirements.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@ Jinja2==3.1.6
77
maturin==1.8.6
88
charmlibs-passwd==1.0.0
99
charmlibs-apt==1.0.0
10-
requests>=2.32.4
10+
requests>=2.32.4
11+
pydantic>=2.12,<3

agent/charms/testflinger-agent-host-charm/src/charm.py

Lines changed: 18 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from charmlibs import apt, passwd
1919
from charms.grafana_agent.v0.cos_agent import COSAgentProvider
2020
from common import copy_ssh_keys, run_with_logged_errors, update_charm_scripts
21+
from config import TestflingerAgentConfig
2122
from defaults import (
2223
AGENT_CONFIGS_PATH,
2324
LOCAL_TESTFLINGER_PATH,
@@ -36,6 +37,9 @@ class TestflingerAgentHostCharm(ops.charm.CharmBase):
3637

3738
def __init__(self, *args):
3839
super().__init__(*args)
40+
self.typed_config = self.load_config(
41+
TestflingerAgentConfig, errors="blocked"
42+
)
3943
self.framework.observe(self.on.install, self.on_install)
4044
self.framework.observe(self.on.start, self.on_start)
4145
self.framework.observe(self.on.config_changed, self.on_config_changed)
@@ -106,10 +110,10 @@ def update_config_files(self):
106110
Clone the config files from the repo and swap it in for whatever is
107111
in AGENT_CONFIGS_PATH.
108112
"""
109-
config_repo = self.config.get("config-repo")
110-
config_dir = self.config.get("config-dir")
111-
config_branch = self.config.get("config-branch")
112-
if not config_repo or not config_dir:
113+
if (
114+
not self.typed_config.config_repo
115+
or not self.typed_config.config_dir
116+
):
113117
logger.error("config-repo and config-dir must be set")
114118
raise ValueError("config-repo and config-dir must be set")
115119
tmp_repo_path = Path("/srv/tmp-agent-configs")
@@ -118,8 +122,8 @@ def update_config_files(self):
118122
shutil.rmtree(tmp_repo_path, ignore_errors=True)
119123
try:
120124
Repo.clone_from(
121-
url=config_repo,
122-
branch=config_branch,
125+
url=self.typed_config.config_repo,
126+
branch=self.typed_config.config_branch,
123127
to_path=tmp_repo_path,
124128
depth=1,
125129
)
@@ -140,7 +144,7 @@ def write_supervisor_service_files(self):
140144
contains a directory for each agent that needs to run from this host.
141145
The agent directory name will be used as the service name.
142146
"""
143-
config_dirs = Path(AGENT_CONFIGS_PATH) / self.config.get("config-dir")
147+
config_dirs = Path(AGENT_CONFIGS_PATH) / self.typed_config.config_dir
144148

145149
if not config_dirs.is_dir():
146150
logger.error("config-dir must point to a directory")
@@ -219,7 +223,7 @@ def setup_docker(self):
219223
def update_tf_cmd_scripts(self):
220224
"""Update tf-cmd-scripts."""
221225
self.unit.status = ops.MaintenanceStatus("Installing tf-cmd-scripts")
222-
update_charm_scripts(self.config)
226+
update_charm_scripts(self.typed_config)
223227

224228
def on_upgrade_charm(self, _):
225229
"""Upgrade hook."""
@@ -237,7 +241,8 @@ def on_start(self, _):
237241

238242
def _on_secret_changed(self, event: ops.SecretChangedEvent):
239243
"""Handle secret changed event."""
240-
if event.secret.id == self.config.get("credentials-secret"):
244+
secret = self.typed_config.credentials_secret
245+
if secret and event.secret.id == secret.id:
241246
logger.info("Credentials secret changed, re-authenticating")
242247
self._update_refresh_token()
243248

@@ -260,18 +265,16 @@ def _block(self, message: str) -> bool:
260265
def _valid_secret(self) -> dict | None:
261266
"""Check the validity of the credentials-secret.
262267
263-
This evaluates if the secret URI is known and and if so that the secret
268+
This evaluates if the secret is known and if so that the secret
264269
contains the necessary fields.
265270
266271
If secret is valid, return the secret content, otherwise return None.
267272
"""
268-
secret_uri = self.config.get("credentials-secret")
269-
if not secret_uri:
273+
if not (secret := self.typed_config.credentials_secret):
270274
logger.error("credentials-secret config not set")
271275
return
272276

273277
try:
274-
secret = self.model.get_secret(id=secret_uri)
275278
content = secret.get_content(refresh=True)
276279
if "client-id" not in content or "secret-key" not in content:
277280
logger.error("Secret missing required fields")
@@ -302,13 +305,9 @@ def _authenticate_with_server(self) -> bool:
302305
if not (content := self._valid_secret()):
303306
return self._block("Invalid credentials secret")
304307

305-
server = self.config.get("testflinger-server")
306-
if not server or not server.startswith(("http://", "https://")):
307-
return self._block("Testflinger server config not set or invalid")
308-
309308
logger.info("Authenticating with Testflinger server")
310309
if not testflinger_client.authenticate(
311-
server=server,
310+
server=self.typed_config.testflinger_server,
312311
client_id=content["client-id"],
313312
secret_key=content["secret-key"],
314313
):
@@ -325,7 +324,7 @@ def on_config_changed(self, _):
325324
except ValueError:
326325
self._block("config-repo and config-dir must be set")
327326
return
328-
copy_ssh_keys(self.config)
327+
copy_ssh_keys(self.typed_config)
329328
self.update_tf_cmd_scripts()
330329
self.write_supervisor_service_files()
331330
supervisord.supervisor_update()

agent/charms/testflinger-agent-host-charm/src/common.py

Lines changed: 15 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44
import logging
55
import os
66
import subprocess
7-
from base64 import b64decode
87
from pathlib import Path
98

9+
from config import TestflingerAgentConfig
1010
from defaults import (
1111
AGENT_CONFIGS_PATH,
1212
VIRTUAL_ENV_PATH,
@@ -34,24 +34,16 @@ def run_with_logged_errors(cmd: list) -> int:
3434
return proc.returncode
3535

3636

37-
def copy_ssh_keys(config: dict) -> None:
38-
try:
39-
ssh_config = config.get("ssh-config", "")
40-
config_file = Path(SSH_CONFIG)
41-
write_file(config_file, ssh_config, chmod=0o640)
37+
def copy_ssh_keys(config: TestflingerAgentConfig) -> None:
38+
"""Copy SSH keys and config from charm config to ~/.ssh/.
4239
43-
priv_key = config.get("ssh-private-key", "")
44-
priv_key_file = Path(SSH_PRIVATE_KEY)
45-
write_file(priv_key_file, b64decode(priv_key).decode(), chmod=0o600)
40+
:param config: The charm configuration containing the SSH keys and config.
41+
"""
42+
write_file(Path(SSH_CONFIG), config.ssh_config, chmod=0o640)
4643

47-
pub_key = config.get("ssh-public-key", "")
48-
pub_key_file = Path(SSH_PUBLIC_KEY)
49-
write_file(pub_key_file, b64decode(pub_key).decode())
50-
except (TypeError, UnicodeDecodeError):
51-
logger.error(
52-
"Failed to decode ssh keys - ensure they are base64 encoded"
53-
)
54-
raise
44+
write_file(Path(SSH_PRIVATE_KEY), config.ssh_private_key, chmod=0o600)
45+
46+
write_file(Path(SSH_PUBLIC_KEY), config.ssh_public_key)
5547

5648

5749
def write_file(location: Path, contents: str, chmod: int = 0o644) -> None:
@@ -69,15 +61,19 @@ def write_file(location: Path, contents: str, chmod: int = 0o644) -> None:
6961
os.chmod(location, chmod)
7062

7163

72-
def update_charm_scripts(config: dict) -> None:
64+
def update_charm_scripts(config: TestflingerAgentConfig) -> None:
65+
"""Update charm scripts with rendered templates for tf scripts.
66+
67+
:param config: The charm configuration containing config directory.
68+
"""
7369
tf_cmd_dir = Path("src/tf-cmd-scripts/")
7470
usr_local_bin = Path("/usr/local/bin")
7571

7672
for tf_cmd_file in tf_cmd_dir.iterdir():
7773
template = Template(tf_cmd_file.read_text())
7874
rendered = template.render(
7975
agent_configs_path=AGENT_CONFIGS_PATH,
80-
config_dir=config.get("config-dir"),
76+
config_dir=config.config_dir,
8177
virtual_env_path=VIRTUAL_ENV_PATH,
8278
)
8379
agent_file = usr_local_bin / tf_cmd_file.name
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Copyright 2026 Canonical Ltd.
2+
# See LICENSE file for licensing details.
3+
"""Charm configuration."""
4+
5+
import ops
6+
import pydantic
7+
8+
SSH_CONFIG_FILE = """\
9+
StrictHostKeyChecking no
10+
UserKnownHostsFile /dev/null
11+
LogLevel QUIET
12+
ConnectTimeout 30
13+
"""
14+
15+
16+
class TestflingerAgentConfig(pydantic.BaseModel):
17+
"""Testflinger Agent Host Charm configuration."""
18+
19+
model_config = pydantic.ConfigDict(arbitrary_types_allowed=True)
20+
21+
config_repo: str = ""
22+
config_branch: str = "main"
23+
config_dir: str = ""
24+
ssh_private_key: pydantic.Base64Str = ""
25+
ssh_public_key: pydantic.Base64Str = ""
26+
ssh_config: str = SSH_CONFIG_FILE
27+
testflinger_server: str = "https://testflinger.canonical.com"
28+
credentials_secret: ops.Secret | None = None
29+
30+
@pydantic.field_validator("testflinger_server")
31+
@classmethod
32+
def validate_server(cls, value):
33+
if not value.startswith(("http://", "https://")):
34+
raise ValueError(
35+
"testflinger_server must include protocol (http:// or https://)"
36+
)
37+
return value

agent/charms/testflinger-agent-host-charm/tests/unit/test_charm.py

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -49,21 +49,6 @@ def test_update_tf_cmd_scripts_on_config_changed(
4949
mock_update_scripts.assert_called_once()
5050

5151

52-
def test_blocked_on_invalid_server(ctx, state_in, secret):
53-
"""Test blocked status when server is not valid."""
54-
state = state_in(
55-
config={
56-
"testflinger-server": "localhost:5000",
57-
"credentials-secret": secret.id,
58-
},
59-
secrets=[secret],
60-
)
61-
state_out = ctx.run(ctx.on.start(), state=state)
62-
assert state_out.unit_status == testing.BlockedStatus(
63-
"Testflinger server config not set or invalid"
64-
)
65-
66-
6752
def test_blocked_on_no_secret(ctx, state_in, caplog):
6853
"""Test blocked status when credentials-secret config is not set."""
6954
state_out = ctx.run(ctx.on.start(), state=state_in())

agent/charms/testflinger-agent-host-charm/tests/unit/test_common.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from unittest.mock import MagicMock, patch
77

88
import common
9+
from config import TestflingerAgentConfig
910

1011
SSH_PUBLIC_KEY = "/home/ubuntu/.ssh/id_rsa.pub"
1112
SSH_PRIVATE_KEY = "/home/ubuntu/.ssh/id_rsa"
@@ -16,13 +17,11 @@
1617
@patch("common.write_file")
1718
def test_copy_ssh_keys(mock_write_file, mock_chmod, mock_chown):
1819
"""Test the copy_ssh_keys function."""
19-
config = {
20-
"ssh-config": "ssh_config_content",
21-
"ssh-private-key": base64.b64encode(
22-
b"ssh_private_key_content"
23-
).decode(),
24-
"ssh-public-key": base64.b64encode(b"ssh_public_key_content").decode(),
25-
}
20+
config = TestflingerAgentConfig(
21+
ssh_config="ssh_config_content",
22+
ssh_private_key=base64.b64encode(b"ssh_private_key_content").decode(),
23+
ssh_public_key=base64.b64encode(b"ssh_public_key_content").decode(),
24+
)
2625

2726
common.copy_ssh_keys(config)
2827

@@ -51,7 +50,7 @@ def test_update_charm_scripts(
5150
mock_script.read_text.return_value = "config_dir={{ config_dir }}"
5251
mock_iterdir.return_value = [mock_script]
5352

54-
config = {"config-dir": "my-agent-configs"}
53+
config = TestflingerAgentConfig(config_dir="my-agent-configs")
5554

5655
common.update_charm_scripts(config)
5756

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Copyright 2026 Canonical
2+
# See LICENSE file for licensing details.
3+
"""Unit tests for config.py."""
4+
5+
import base64
6+
7+
import pytest
8+
from config import TestflingerAgentConfig
9+
10+
11+
def test_base64_str_fields():
12+
"""Test that Base64Str fields decode correctly."""
13+
private_key = base64.b64encode(b"ssh_private_key_content").decode()
14+
public_key = base64.b64encode(b"ssh_public_key_content").decode()
15+
config = TestflingerAgentConfig(
16+
ssh_private_key=private_key,
17+
ssh_public_key=public_key,
18+
)
19+
assert config.ssh_private_key == "ssh_private_key_content"
20+
assert config.ssh_public_key == "ssh_public_key_content"
21+
22+
23+
def test_invalid_base64_str_fields():
24+
"""Test that invalid Base64Str fields raise errors."""
25+
with pytest.raises(ValueError):
26+
TestflingerAgentConfig(ssh_private_key="undecodable_string")
27+
28+
with pytest.raises(ValueError):
29+
TestflingerAgentConfig(ssh_public_key="undecodable_string")
30+
31+
32+
def test_valid_testflinger_server():
33+
"""Test that valid server URLs are accepted."""
34+
config = TestflingerAgentConfig(
35+
testflinger_server="https://testflinger.local"
36+
)
37+
assert config.testflinger_server == "https://testflinger.local"
38+
39+
config = TestflingerAgentConfig(
40+
testflinger_server="http://testflinger.local"
41+
)
42+
assert config.testflinger_server == "http://testflinger.local"
43+
44+
45+
def test_invalid_testflinger_server():
46+
"""Test that invalid server URLs are rejected."""
47+
with pytest.raises(ValueError):
48+
TestflingerAgentConfig(testflinger_server="localhost:5000")

0 commit comments

Comments
 (0)