diff --git a/kepler/charmcraft.yaml b/kepler/charmcraft.yaml index 6b14eae..ce83764 100644 --- a/kepler/charmcraft.yaml +++ b/kepler/charmcraft.yaml @@ -47,6 +47,20 @@ containers: demo-server: resource: demo-server-image +actions: + update-backup: + description: | + Overwrite the workload's backup file at /etc/myapp/backup.yaml with the + contents provided in the "data" action parameter. + + This is used to verify whether writes performed by the charm to a + host-mounted file propagate back to the host file. + params: + data: + type: string + description: The new contents to write to the backup file. + required: [data] + resources: # An OCI image resource for the container listed above. demo-server-image: diff --git a/kepler/pyproject.toml b/kepler/pyproject.toml index 3b3da6f..3ee1de0 100644 --- a/kepler/pyproject.toml +++ b/kepler/pyproject.toml @@ -24,6 +24,7 @@ requires-python = ">=3.10" # 'charm-libs' block in charmcraft.yaml, run `charmcraft fetch-libs` to download the libraries, # then inspect the libraries for dependencies specified in PYDEPS. List those dependencies here. dependencies = [ + "charmlibs-pathops>=1,<2", "ops~=3.7", ] diff --git a/kepler/src/charm.py b/kepler/src/charm.py index f31b740..dababb4 100755 --- a/kepler/src/charm.py +++ b/kepler/src/charm.py @@ -19,6 +19,7 @@ import logging import ops +from charmlibs import pathops # Log messages can be retrieved using juju debug-log logger = logging.getLogger(__name__) @@ -31,6 +32,28 @@ def __init__(self, framework: ops.Framework) -> None: super().__init__(framework) self.pebble_service_name = "fastapi-service" framework.observe(self.on["demo-server"].pebble_ready, self._on_demo_server_pebble_ready) + framework.observe(self.on.update_backup_action, self._on_update_backup) + + def _on_update_backup(self, event: ops.ActionEvent) -> None: + """Overwrite the backup file in the workload container. + + The backup file is expected to be host-mounted at + ``/etc/myapp/backup.yaml`` (see the unit test). We remove the existing + file before writing the new contents, to emulate a charm that clears a + stale file before writing fresh data. + """ + container = self.unit.get_container("demo-server") + if not container.can_connect(): + event.fail("workload container is not ready") + return + data = event.params["data"] + backup_root = pathops.ContainerPath("/etc/myapp", container=container) + backup_file = backup_root / "backup.yaml" + # Remove any existing file before writing, so that we write a fresh file + # rather than appending to / modifying stale contents. + backup_file.unlink(missing_ok=True) + backup_file.write_text(data) + event.set_results({"written": data}) def _on_demo_server_pebble_ready(self, event: ops.PebbleReadyEvent) -> None: """Define and start a workload using the Pebble API.""" diff --git a/kepler/tests/unit/test_charm.py b/kepler/tests/unit/test_charm.py index 6e5f2b6..2c44768 100644 --- a/kepler/tests/unit/test_charm.py +++ b/kepler/tests/unit/test_charm.py @@ -51,3 +51,36 @@ def test_pebble_layer(): state_out.get_container(container.name).service_statuses["fastapi-service"] == ops.pebble.ServiceStatus.ACTIVE ) + + +def test_update_backup_writes_through_mount(tmp_path): + """Refutation attempt for doc.md's claim about host-mounted files. + + doc.md (line 210) claims: "If the charm writes to /etc/myapp/backup.yaml in + the container while handling the event, backup_file.read_text() will return + the data that the charm wrote." + + This test mounts a host file at /etc/myapp/backup.yaml, runs the + update-backup action (which removes the file and writes new contents), and + checks whether the host file reflects the write. + """ + backup_file = tmp_path / "backup.yaml" + original_data = "original: data\n" + backup_file.write_text(original_data) + + ctx = testing.Context(KosmosCharm) + container = testing.Container( + name="demo-server", + can_connect=True, + mounts={"backup": testing.Mount(location="/etc/myapp/backup.yaml", source=backup_file)}, + ) + state_in = testing.State(containers={container}) + + new_data = "updated: data\n" + ctx.run( + ctx.on.action("update-backup", params={"data": new_data}), + state_in, + ) + + # If the claim holds, the host file should now contain the new data. + assert backup_file.read_text() == new_data diff --git a/kepler/uv.lock b/kepler/uv.lock index 9714747..991ef4a 100644 --- a/kepler/uv.lock +++ b/kepler/uv.lock @@ -6,6 +6,18 @@ resolution-markers = [ "python_full_version < '3.11'", ] +[[package]] +name = "charmlibs-pathops" +version = "1.3.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ops" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/1f/37cd5c74fd6adbb01f4947651af55b8ff8461eed034de3f6d018988022ab/charmlibs_pathops-1.3.0.post0.tar.gz", hash = "sha256:36ac11b0c7c55b71341a549a1803f5c8d61c2279204862b8d50736041b782f59", size = 22140, upload-time = "2026-06-16T05:39:49.177Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/ae/04b8e77ecbc84ef605feb8a35137570facbc191cbff37ca2b024ea8be466/charmlibs_pathops-1.3.0.post0-py3-none-any.whl", hash = "sha256:c6fc3e3460ffa03a7d4273cc7784c9d74378609a098c728cd9bbb2ed90bdfa5b", size = 27246, upload-time = "2026-06-16T05:39:48.074Z" }, +] + [[package]] name = "codespell" version = "2.4.2" @@ -105,11 +117,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, ] +[[package]] +name = "importlib-metadata" +version = "8.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "jubilant" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/11/1b/32b5ab87c138066c80e34c8cd0f7d34760ce9d203d89ec88f979039e015d/jubilant-1.8.0.tar.gz", hash = "sha256:a7cea68299dca94fac3e121a8c8ed92021d20aa2f4461e16822abbcd134d0b8b", size = 33036, upload-time = "2026-03-30T07:42:23.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/6b/71ceb8de590d02eccf18cca60cb687838b12db0926a6a870c2d17a0d26b3/jubilant-1.8.0-py3-none-any.whl", hash = "sha256:e0495ee645de5f2df81d044a3b6e2827b5a6277de02c6d30935b9632bd868a98", size = 33929, upload-time = "2026-03-30T07:42:22.419Z" }, +] + [[package]] name = "kosmos" version = "0.0.1" source = { virtual = "." } dependencies = [ + { name = "charmlibs-pathops" }, { name = "ops" }, ] @@ -132,7 +178,10 @@ unit = [ ] [package.metadata] -requires-dist = [{ name = "ops", specifier = "~=3.7" }] +requires-dist = [ + { name = "charmlibs-pathops", specifier = ">=1,<2" }, + { name = "ops", specifier = "~=3.7" }, +] [package.metadata.requires-dev] integration = [ @@ -152,39 +201,6 @@ unit = [ { name = "pytest" }, ] -[[package]] -name = "importlib-metadata" -version = "8.7.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "zipp" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" }, -] - -[[package]] -name = "iniconfig" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, -] - -[[package]] -name = "jubilant" -version = "1.8.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyyaml" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/11/1b/32b5ab87c138066c80e34c8cd0f7d34760ce9d203d89ec88f979039e015d/jubilant-1.8.0.tar.gz", hash = "sha256:a7cea68299dca94fac3e121a8c8ed92021d20aa2f4461e16822abbcd134d0b8b", size = 33036, upload-time = "2026-03-30T07:42:23.939Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/87/6b/71ceb8de590d02eccf18cca60cb687838b12db0926a6a870c2d17a0d26b3/jubilant-1.8.0-py3-none-any.whl", hash = "sha256:e0495ee645de5f2df81d044a3b6e2827b5a6277de02c6d30935b9632bd868a98", size = 33929, upload-time = "2026-03-30T07:42:22.419Z" }, -] - [[package]] name = "nodeenv" version = "1.9.1"