Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
9587b36
test(hookcmds): add follow-up 3 (secret grant/revoke) and 4 (endpoint…
tonyandrewmeyer Jun 2, 2026
56dddee
test(hookcmds): drop stale uv.lock from test-hookcmds charm
tonyandrewmeyer Jun 10, 2026
3500a65
test(hookcmds): xfail three Juju 4.0/k8s failures
tonyandrewmeyer Jun 10, 2026
cc34d9b
test(hookcmds): widen xfail in test_secret_grant_revoke to cover the …
tonyandrewmeyer Jun 10, 2026
67f62e8
test(hookcmds): use the strict xfail helper in the follow-up tests too
tonyandrewmeyer Jun 11, 2026
9ee0489
Apply suggestion from @tonyandrewmeyer
tonyandrewmeyer Jun 11, 2026
efe9466
Apply suggestion from @tonyandrewmeyer
tonyandrewmeyer Jun 11, 2026
b07fd7c
fix(hookcmds): declare filename for the test-file resource
tonyandrewmeyer Jun 11, 2026
60b9c2a
test(hookcmds): fix the matrix-expansion regressions surfaced by fork-CI
tonyandrewmeyer Jun 11, 2026
5999260
test(hookcmds): finish wiring up the matrix-expansion fixes
tonyandrewmeyer Jun 11, 2026
40a1d6d
test(hookcmds): drop strict-fail-on-success from the Juju 4 xfail helper
tonyandrewmeyer Jun 11, 2026
8b9212b
test(hookcmds): refine the credential-get / juju-reboot guards and so…
tonyandrewmeyer Jun 11, 2026
0720fe3
test(hookcmds): assert relation_model_get equals JUJU_MODEL_UUID for …
tonyandrewmeyer Jun 11, 2026
ff01007
test(hookcmds): also skip juju-reboot on localhost/LXD machine cells
tonyandrewmeyer Jun 11, 2026
af625b6
test(hookcmds): correctly skip juju-reboot — action hooks are explici…
tonyandrewmeyer Jun 11, 2026
54d5010
test(hookcmds): real juju-reboot harness driven by config-changed
tonyandrewmeyer Jun 11, 2026
c852e25
test(hookcmds): fix lint and verify the reboot actually happened via …
tonyandrewmeyer Jun 11, 2026
961d9d1
test(hookcmds): drop the blanket * gitignore; explicit build-artefact…
tonyandrewmeyer Jun 11, 2026
e64797b
Apply suggestions from code review
tonyandrewmeyer Jun 12, 2026
a9ae994
Update test/charms/test_hookcmds/src/charm.py
tonyandrewmeyer Jun 12, 2026
1cfc0b6
test(hookcmds): rename _is_caas to _is_k8s; use btime instead of uptime
tonyandrewmeyer Jun 12, 2026
bb35285
test(hookcmds): derive boot-time from now - uptime so it works in LXD
tonyandrewmeyer Jun 12, 2026
d5b4512
test(hookcmds): xfail juju-reboot on Juju 4 (juju/juju#22639)
tonyandrewmeyer Jun 12, 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
3 changes: 3 additions & 0 deletions test/charms/test_hookcmds/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
uv.lock
*.tar.gz
Comment thread
tonyandrewmeyer marked this conversation as resolved.
*.charm
52 changes: 43 additions & 9 deletions test/charms/test_hookcmds/charmcraft.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,20 +28,41 @@ config:
type: int
default: 42
description: An integer config option for testing config-get.
reboot-trigger:
type: string
default: ''
description: |
When set to 'reboot-please', the config-changed handler
writes a marker file and calls hookcmds.juju_reboot(). The
marker prevents the next config-changed (which fires
automatically after the post-reboot hook re-run) from
re-triggering, so there's no infinite reboot loop. Used
by the integration test for hookcmds.juju_reboot.

peers:
peer:
interface: test-hookcmds-peer

requires:
anycharm:
interface: db

storage:
data:
type: filesystem
minimum-size: 1M
description: Filesystem storage for testing storage hook commands.
multiple:
range: 1-2
description: |
Filesystem storage for testing storage hook commands. Declared
with range 1-2 so test_storage_add can attach a second instance;
without `multiple:` Juju treats it as singular and rejects the
second attach.

resources:
test-file:
type: file
filename: test-file.txt
description: Small file used by integration tests of resource_get.

actions:
Expand Down Expand Up @@ -156,10 +177,13 @@ actions:
test-credential-get:
description: |
Call hookcmds.credential_get and return the cloud type and name.
test-juju-reboot:
test-reboot-marker:
description: |
Queue a reboot via hookcmds.juju_reboot(now=False). The unit
reboots after the action context exits.
Return whether the marker file written by the reboot-trigger
config-changed handler exists. The integration test runs this
after setting `reboot-trigger=reboot-please` and waiting for
the unit to recover, to confirm the config-changed →
juju_reboot path actually ran.
test-relation-model-get:
description: |
Call hookcmds.relation_model_get for the peer relation and return
Expand All @@ -170,17 +194,27 @@ actions:
the cached file path.
test-secret-grant:
description: |
Create a secret and grant it to the peer relation via
hookcmds.secret_grant. Returns the secret ID.
Add a secret owned by this unit and grant it to the related any-charm
application via the anycharm relation. Returns the secret ID and the
relation ID used for the grant.
test-secret-revoke:
description: |
Revoke access to a secret from the peer relation via
hookcmds.secret_revoke. Does NOT remove the secret.
Revoke the grant on a previously granted secret (from test-secret-grant)
and remove the secret entirely.
params:
secret-id:
type: string
description: The secret URI to revoke.
description: The ID of the secret whose grant should be revoked.
test-storage-add:
description: |
Queue one additional 'data' storage instance via
hookcmds.storage_add and return the current count before adding.
test-ports-endpoint-scoped:
description: |
Open a TCP port scoped to the peer endpoint, verify it appears with
the correct endpoint via opened_ports(endpoints=True), then close it.
params:
port:
type: integer
description: Port number to open/close, scoped to the peer endpoint.
default: 7766
113 changes: 98 additions & 15 deletions test/charms/test_hookcmds/src/charm.py
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,18 @@
from __future__ import annotations

import json
import os
import pathlib
import time
from typing import Any

import ops
import ops.hookcmds as hookcmds

_REBOOT_MARKER = (
pathlib.Path(os.environ.get('JUJU_CHARM_DIR', '.')) / '.test-hookcmds.reboot-triggered'
)


class TestHookcmdsCharm(ops.CharmBase):
def __init__(self, framework: ops.Framework):
Expand All @@ -53,14 +60,19 @@ def __init__(self, framework: ops.Framework):
self.on['trigger-relation-error'].action, self._on_trigger_relation_error
)
framework.observe(self.on['test-credential-get'].action, self._on_test_credential_get)
framework.observe(self.on['test-juju-reboot'].action, self._on_test_juju_reboot)
framework.observe(self.on.config_changed, self._on_config_changed)
framework.observe(self.on['test-reboot-marker'].action, self._on_test_reboot_marker)
framework.observe(
self.on['test-relation-model-get'].action, self._on_test_relation_model_get
)
framework.observe(self.on['test-resource-get'].action, self._on_test_resource_get)
framework.observe(self.on['test-secret-grant'].action, self._on_test_secret_grant)
framework.observe(self.on['test-secret-revoke'].action, self._on_test_secret_revoke)
framework.observe(self.on['test-storage-add'].action, self._on_test_storage_add)
framework.observe(
self.on['test-ports-endpoint-scoped'].action,
self._on_test_ports_endpoint_scoped,
)

# Lifecycle

Expand Down Expand Up @@ -343,23 +355,49 @@ def _on_test_credential_get(self, event: ops.ActionEvent):
cloud = hookcmds.credential_get()
event.set_results({'cloud-type': cloud.type, 'cloud-name': cloud.name})

# Reboot
# Reboot — driven by config-changed because juju forbids juju-reboot

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

juju forbids juju-reboot

That's interesting. Could you point me to the place where I can read more about this Juju behavior?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

There's our docs and the juju docs, probably not much else.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Ah great. Thank you !

# from an action context.

def _on_test_juju_reboot(self, event: ops.ActionEvent):
"""Queue a machine reboot via juju_reboot(now=False)."""
def _on_config_changed(self, event: ops.ConfigChangedEvent):
if self.config.get('reboot-trigger') != 'reboot-please':
return
if _REBOOT_MARKER.exists():
# Already triggered this deployment; don't reboot again.
return
_REBOOT_MARKER.write_text('triggered')
hookcmds.juju_reboot(now=False)

def _on_test_reboot_marker(self, event: ops.ActionEvent):
# Compute the boot epoch as `now - uptime`. /proc/stat's `btime` is
Comment thread
tromai marked this conversation as resolved.
# the host kernel boot time and doesn't change in LXD containers, so
# we can't use it directly; /proc/uptime, however, is virtualised
# per-container by lxcfs and resets on juju-reboot. Comparing the
# derived boot epoch before/after proves the reboot took effect (the
# marker alone only proves config-changed ran the path).
with open('/proc/uptime') as f:
uptime_seconds = float(f.read().split()[0])
boot_time = int(time.time() - uptime_seconds)
event.set_results({
'marker-exists': str(_REBOOT_MARKER.exists()).lower(),
'boot-time': str(boot_time),
})

# Relation model

def _on_test_relation_model_get(self, event: ops.ActionEvent):
"""Return the model UUID for the peer relation."""
"""Return the model UUID for the peer relation, plus JUJU_MODEL_UUID."""
ids = hookcmds.relation_ids('peer')
if not ids:
event.fail('No peer relation IDs - deploy 2+ units')
return
rel_id = int(ids[0].split(':')[-1])
model = hookcmds.relation_model_get(id=rel_id, endpoint='peer')
event.set_results({'uuid': model.uuid})
event.set_results({
'uuid': model.uuid,
# Emit the env-side model UUID alongside so the test can verify
# the two match without doing its own juju show-model dance.
'env-model-uuid': os.environ.get('JUJU_MODEL_UUID', ''),
})

# Resource

Expand All @@ -368,28 +406,42 @@ def _on_test_resource_get(self, event: ops.ActionEvent):
path = hookcmds.resource_get('test-file')
event.set_results({'path': str(path)})

# Secret grant / revoke
# Cross-app secret grant / revoke

def _on_test_secret_grant(self, event: ops.ActionEvent):
"""Create a secret and grant it to the peer relation."""
ids = hookcmds.relation_ids('peer')
"""Add a secret and grant it to the related any-charm app."""
ids = hookcmds.relation_ids('anycharm')
if not ids:
event.fail('No peer relation - deploy 2+ units')
event.fail('No anycharm relation IDs - is any-charm deployed and integrated?')
return
rel_id = int(ids[0].split(':')[-1])
secret_id = hookcmds.secret_add({'key': 'grant-test-value'}, label='grant-test')

secret_id = hookcmds.secret_add(
{'token': 'grant-test-token'},
label='hookcmds-grant-test',
description='Created for grant/revoke integration test',
)
hookcmds.secret_grant(secret_id, rel_id)
event.set_results({'secret-id': secret_id})

event.set_results({
'secret-id': secret_id,
'relation-id': str(rel_id),
'granted': 'true',
})

def _on_test_secret_revoke(self, event: ops.ActionEvent):
"""Revoke access to a secret from the peer relation."""
"""Revoke the grant on a secret, then remove it entirely."""
secret_id = event.params['secret-id']
ids = hookcmds.relation_ids('peer')
ids = hookcmds.relation_ids('anycharm')
if not ids:
event.fail('No peer relation - deploy 2+ units')
event.fail('No anycharm relation IDs - is any-charm deployed and integrated?')
return
rel_id = int(ids[0].split(':')[-1])

hookcmds.secret_revoke(secret_id, relation_id=rel_id)
hookcmds.secret_remove(secret_id)

event.set_results({'revoked': 'true'})

# Storage add

Expand All @@ -399,6 +451,37 @@ def _on_test_storage_add(self, event: ops.ActionEvent):
hookcmds.storage_add({'data': 1})
event.set_results({'count-before-add': str(len(current))})

# Endpoint-scoped ports

def _on_test_ports_endpoint_scoped(self, event: ops.ActionEvent):
"""Open a port scoped to the peer endpoint, verify, then close it."""
port = int(event.params.get('port', 7766))
endpoint = 'peer'

hookcmds.open_port('tcp', port, endpoints=endpoint)

all_ports = hookcmds.opened_ports(endpoints=True)
our_port = next(
(p for p in all_ports if p.port == port and p.protocol == 'tcp'),
None,
)

port_found = our_port is not None
ep_list = (our_port.endpoints or []) if our_port else []
endpoint_matches = port_found and endpoint in ep_list

hookcmds.close_port('tcp', port, endpoints=endpoint)

final = hookcmds.opened_ports(endpoints=True)
still_open = any(p.port == port and p.protocol == 'tcp' for p in final)

event.set_results({
'port-found-with-endpoint': str(port_found).lower(),
'endpoints-list': ','.join(ep_list),
'endpoint-matches': str(endpoint_matches).lower(),
'closed-after': str(not still_open).lower(),
})


if __name__ == '__main__':
ops.main(TestHookcmdsCharm)
Loading
Loading