diff --git a/lib/charms/operator_libs_linux/v2/snap.py b/lib/charms/operator_libs_linux/v2/snap.py index 9ccd8ae..d84b7d8 100644 --- a/lib/charms/operator_libs_linux/v2/snap.py +++ b/lib/charms/operator_libs_linux/v2/snap.py @@ -343,7 +343,7 @@ def set(self, config: Dict[str, Any], *, typed: bool = False) -> None: """ if not typed: config = {k: str(v) for k, v in config.items()} - self._snap_client.put_snap_config(self._name, config) + self._snap_client._put_snap_conf(self._name, config) def unset(self, key) -> str: """Unset a snap configuration value. @@ -774,12 +774,15 @@ def _request( return self._wait(response["change"]) return response["result"] - def _wait(self, change_id: int) -> JSONType: + def _wait(self, change_id: str, timeout=300) -> JSONType: """Wait for an async change to complete. The poll time is 100 milliseconds, the same as in snap clients. """ + deadline = time.time() + timeout while True: + if time.time() > deadline: + raise TimeoutError(f"timeout waiting for snap change {change_id}") response = self._request("GET", f"changes/{change_id}") status = response["status"] if status == "Done": @@ -840,7 +843,7 @@ def get_installed_snap_apps(self, name: str) -> List: """Query the snap server for apps belonging to a named, currently installed snap.""" return self._request("GET", "apps", {"names": name, "select": "service"}) - def put_snap_config(self, name: str, conf: Dict[str, Any]): + def _put_snap_conf(self, name: str, conf: Dict[str, Any]): """Set the configuration details for an installed snap.""" return self._request("PUT", f"snaps/{name}/conf", body=conf) diff --git a/tests/unit/test_snap.py b/tests/unit/test_snap.py index 02a3c26..0c02def 100644 --- a/tests/unit/test_snap.py +++ b/tests/unit/test_snap.py @@ -4,7 +4,10 @@ # pyright: reportPrivateUsage=false import datetime +import io import json +import time +import typing import unittest from subprocess import CalledProcessError from typing import Any, Dict, Iterable, Optional @@ -697,6 +700,141 @@ def test_request_raw_bad_response_raises_snapapierror(self): finally: shutdown() + def test_wait_changes(self): + change_finished = False + + def _request_raw( + method: str, + path: str, + query: Dict = None, + headers: Dict = None, + data: bytes = None, + ) -> typing.IO[bytes]: + nonlocal change_finished + if method == "PUT" and path == "snaps/test/conf": + return io.BytesIO( + json.dumps( + { + "type": "async", + "status-code": 202, + "status": "Accepted", + "result": None, + "change": "97", + } + ).encode("utf-8") + ) + if method == "GET" and path == "changes/97" and not change_finished: + change_finished = True + return io.BytesIO( + json.dumps( + { + "type": "sync", + "status-code": 200, + "status": "OK", + "result": { + "id": "97", + "kind": "configure-snap", + "summary": 'Change configuration of "test" snap', + "status": "Doing", + "tasks": [ + { + "id": "1029", + "kind": "run-hook", + "summary": 'Run configure hook of "test" snap', + "status": "Doing", + "progress": {"label": "", "done": 1, "total": 1}, + "spawn-time": "2024-11-28T20:02:47.498399651+00:00", + "data": {"affected-snaps": ["test"]}, + } + ], + "ready": False, + "spawn-time": "2024-11-28T20:02:47.49842583+00:00", + }, + } + ).encode("utf-8") + ) + if method == "GET" and path == "changes/97" and change_finished: + return io.BytesIO( + json.dumps( + { + "type": "sync", + "status-code": 200, + "status": "OK", + "result": { + "id": "98", + "kind": "configure-snap", + "summary": 'Change configuration of "test" snap', + "status": "Done", + "tasks": [ + { + "id": "1030", + "kind": "run-hook", + "summary": 'Run configure hook of "test" snap', + "status": "Done", + "progress": {"label": "", "done": 1, "total": 1}, + "spawn-time": "2024-11-28T20:06:41.415929854+00:00", + "ready-time": "2024-11-28T20:06:41.797437537+00:00", + "data": {"affected-snaps": ["test"]}, + } + ], + "ready": True, + "spawn-time": "2024-11-28T20:06:41.415955681+00:00", + "ready-time": "2024-11-28T20:06:41.797440022+00:00", + }, + } + ).encode("utf-8") + ) + raise RuntimeError("unknown request") + + client = snap.SnapClient() + with patch.object(client, "_request_raw", _request_raw), patch.object(time, "sleep"): + client._put_snap_conf("test", {"foo": "bar"}) + + def test_wait_failed(self): + def _request_raw( + method: str, + path: str, + query: Dict = None, + headers: Dict = None, + data: bytes = None, + ) -> typing.IO[bytes]: + if method == "PUT" and path == "snaps/test/conf": + return io.BytesIO( + json.dumps( + { + "type": "async", + "status-code": 202, + "status": "Accepted", + "result": None, + "change": "97", + } + ).encode("utf-8") + ) + if method == "GET" and path == "changes/97": + return io.BytesIO( + json.dumps( + { + "type": "sync", + "status-code": 200, + "status": "OK", + "result": { + "id": "97", + "kind": "configure-snap", + "summary": 'Change configuration of "test" snap', + "status": "Error", + "ready": False, + "spawn-time": "2024-11-28T20:02:47.49842583+00:00", + }, + } + ).encode("utf-8") + ) + raise RuntimeError("unknown request") + + client = snap.SnapClient() + with patch.object(client, "_request_raw", _request_raw), patch.object(time, "sleep"): + with self.assertRaises(snap.SnapError): + client._put_snap_conf("test", {"foo": "bar"}) + class TestSnapBareMethods(unittest.TestCase): @patch("builtins.open", new_callable=mock_open, read_data="curl\n") @@ -902,23 +1040,23 @@ def fake_snap(command: str, optargs: Optional[Iterable[str]] = None) -> str: with self.assertRaises(TypeError): foo.get(None) # pyright: ignore[reportArgumentType] - @patch("charms.operator_libs_linux.v2.snap.SnapClient.put_snap_config") - def test_snap_set_typed(self, put_snap_config): + @patch("charms.operator_libs_linux.v2.snap.SnapClient._put_snap_conf") + def test_snap_set_typed(self, put_snap_conf): foo = snap.Snap("foo", snap.SnapState.Present, "stable", "1", "classic") config = {"n": 42, "s": "string", "d": {"nested": True}} foo.set(config, typed=True) - put_snap_config.assert_called_with("foo", {"n": 42, "s": "string", "d": {"nested": True}}) + put_snap_conf.assert_called_with("foo", {"n": 42, "s": "string", "d": {"nested": True}}) - @patch("charms.operator_libs_linux.v2.snap.SnapClient.put_snap_config") - def test_snap_set_untyped(self, put_snap_config): + @patch("charms.operator_libs_linux.v2.snap.SnapClient._put_snap_conf") + def test_snap_set_untyped(self, put_snap_conf): foo = snap.Snap("foo", snap.SnapState.Present, "stable", "1", "classic") config = {"n": 42, "s": "string", "d": {"nested": True}} foo.set(config, typed=False) - put_snap_config.assert_called_with( + put_snap_conf.assert_called_with( "foo", {"n": "42", "s": "string", "d": "{'nested': True}"} )