Skip to content

Commit df7bca4

Browse files
authored
Merge pull request #629 from inknos/delete-quadlets
Implemente delete calls for quadlets
2 parents 3e46e2f + 3a2388c commit df7bca4

3 files changed

Lines changed: 294 additions & 7 deletions

File tree

podman/domain/quadlets.py

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@
22

33
import builtins
44
import logging
5-
from typing import Union
5+
from typing import Optional, Union
66

77
import requests
88

99
from podman import api
1010
from podman.domain.manager import Manager, PodmanResource
11-
from podman.errors import NotFound
11+
from podman.errors import NotFound, PodmanError
1212

1313
logger = logging.getLogger("podman.quadlets")
1414

@@ -46,6 +46,20 @@ def application(self) -> str:
4646
def __repr__(self) -> str:
4747
return f"<{self.__class__.__name__}: {self.name}>"
4848

49+
def delete(self, **kwargs) -> builtins.list:
50+
"""Remove this quadlet file. Can force removal of running
51+
quadlets and control systemd reload behavior.
52+
53+
Keyword Args:
54+
force (bool): Remove running quadlet by stopping it first (default False)
55+
ignore (bool): Do not error if the quadlet does not exist (default False)
56+
reload_systemd (bool): Reload systemd after removing quadlets (default True)
57+
58+
Returns:
59+
List of removed quadlet names.
60+
"""
61+
return self.manager.delete(self.name, **kwargs)
62+
4963
def get_contents(self) -> str:
5064
"""Get the contents of this quadlet file.
5165
@@ -167,3 +181,45 @@ def print_contents(self, name: Union[Quadlet, str]) -> None:
167181
response = self.client.get(f"/quadlets/{name}/file")
168182
response.raise_for_status()
169183
print(response.text.strip())
184+
185+
def delete(
186+
self, name: Optional[Union[Quadlet, str]] = None, *_, all: Optional[bool] = None, **kwargs
187+
) -> builtins.list:
188+
"""Remove a quadlet file by name. Can force
189+
removal of running quadlets and control systemd
190+
reload behavior
191+
192+
Args:
193+
name: Identifier for Quadlet to remove
194+
all (bool): Remove all quadlets for the current user (default False)
195+
One between name and all should be provided.
196+
197+
Keyword Args:
198+
force (bool): Remove running quadlet by stopping it first (default False)
199+
ignore (bool): Do not error if the quadlet does not exist (default False)
200+
reload_systemd (bool): Reload systemd after removing quadlets. (default True)
201+
202+
Returns:
203+
List of removed quadlet names.
204+
"""
205+
206+
if name is None and all is None:
207+
raise PodmanError("Quadlet name, or 'all=True' should be provided")
208+
209+
if isinstance(name, Quadlet):
210+
name = name.name
211+
212+
params = {
213+
"force": kwargs.get("force", False),
214+
"ignore": kwargs.get("ignore", False),
215+
"reload-systemd": kwargs.get("reload_systemd", True),
216+
}
217+
218+
if all:
219+
params["all"] = True
220+
response = self.client.delete("/quadlets", params=params)
221+
else:
222+
response = self.client.delete(f"/quadlets/{name}", params=params)
223+
224+
response.raise_for_status()
225+
return response.json()["Removed"]

podman/tests/unit/test_quadlet.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
"""Unit tests for Quadlet domain class."""
22

3-
import pytest
43
import unittest
54
from unittest.mock import patch
65

@@ -18,7 +17,6 @@
1817
}
1918

2019

21-
@pytest.mark.pnext
2220
class QuadletTestCase(unittest.TestCase):
2321
"""Test Quadlet domain class."""
2422

@@ -96,6 +94,40 @@ def test_print_contents(self, mock):
9694
self.assertIsNone(result)
9795
mock_print.assert_called_once_with(expected_content.strip())
9896

97+
@requests_mock.Mocker()
98+
def test_delete(self, mock):
99+
"""Test Quadlet instance delete delegates to manager correctly."""
100+
mock.delete(
101+
tests.LIBPOD_URL + "/quadlets/myapp.container",
102+
json={"Removed": ["myapp.container"]},
103+
status_code=200,
104+
)
105+
quadlet_manager = QuadletsManager(self.client.api)
106+
quadlet = quadlet_manager.prepare_model(attrs=FIRST_QUADLET)
107+
108+
result = quadlet.delete()
109+
self.assertEqual(result, ["myapp.container"])
110+
111+
@requests_mock.Mocker()
112+
def test_delete_with_kwargs(self, mock):
113+
"""Test Quadlet delete passes kwargs (force, ignore, reload_systemd) through."""
114+
adapter = mock.delete(
115+
tests.LIBPOD_URL + "/quadlets/myapp.container",
116+
json={"Removed": ["myapp.container"]},
117+
status_code=200,
118+
)
119+
quadlet_manager = QuadletsManager(self.client.api)
120+
quadlet = quadlet_manager.prepare_model(attrs=FIRST_QUADLET)
121+
122+
result = quadlet.delete(force=True, ignore=True, reload_systemd=False)
123+
self.assertEqual(result, ["myapp.container"])
124+
125+
# Verify all parameters were passed correctly
126+
url_lower = adapter.last_request.url.lower()
127+
self.assertIn("force=true", url_lower)
128+
self.assertIn("ignore=true", url_lower)
129+
self.assertIn("reload-systemd=false", url_lower)
130+
99131

100132
if __name__ == '__main__':
101133
unittest.main()

podman/tests/unit/test_quadletsmanager.py

Lines changed: 202 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
"""Unit tests for QuadletsManager."""
22

3-
import pytest
43
import unittest
54
from unittest.mock import patch
65

76
import requests_mock
87

98
from podman import PodmanClient, tests
109
from podman.domain.quadlets import Quadlet, QuadletsManager
11-
from podman.errors import NotFound
10+
from podman.errors import APIError, NotFound, PodmanError
1211

1312
FIRST_QUADLET = {
1413
"Name": "myapp.container",
@@ -27,7 +26,6 @@
2726
}
2827

2928

30-
@pytest.mark.pnext
3129
class QuadletsManagerTestCase(unittest.TestCase):
3230
"""Test QuadletsManager area of concern.
3331
@@ -177,6 +175,207 @@ def test_print_contents(self, mock):
177175
self.assertIsNone(result)
178176
mock_print.assert_called_once_with(expected_content.strip())
179177

178+
@requests_mock.Mocker()
179+
def test_delete_by_name(self, mock):
180+
"""Test delete single quadlet by name string."""
181+
mock.delete(
182+
tests.LIBPOD_URL + "/quadlets/myapp.container",
183+
json={"Removed": ["myapp.container"]},
184+
status_code=200,
185+
)
186+
187+
result = self.client.quadlets.delete("myapp.container")
188+
self.assertEqual(result, ["myapp.container"])
189+
190+
@requests_mock.Mocker()
191+
def test_delete_by_quadlet_object(self, mock):
192+
"""Test delete using Quadlet object."""
193+
mock.delete(
194+
tests.LIBPOD_URL + "/quadlets/myapp.container",
195+
json={"Removed": ["myapp.container"]},
196+
status_code=200,
197+
)
198+
199+
quadlet = Quadlet(attrs=FIRST_QUADLET)
200+
result = self.client.quadlets.delete(quadlet)
201+
self.assertEqual(result, ["myapp.container"])
202+
203+
@requests_mock.Mocker()
204+
def test_delete_all(self, mock):
205+
"""Test delete all quadlets with all=True."""
206+
mock.delete(
207+
tests.LIBPOD_URL + "/quadlets",
208+
json={"Removed": ["myapp.container", "mydb.container"]},
209+
status_code=200,
210+
)
211+
212+
result = self.client.quadlets.delete(all=True)
213+
self.assertEqual(result, ["myapp.container", "mydb.container"])
214+
215+
@requests_mock.Mocker()
216+
def test_delete_with_force(self, mock):
217+
"""Test delete with force=True parameter."""
218+
adapter = mock.delete(
219+
tests.LIBPOD_URL + "/quadlets/myapp.container",
220+
json={"Removed": ["myapp.container"]},
221+
status_code=200,
222+
)
223+
224+
result = self.client.quadlets.delete("myapp.container", force=True)
225+
self.assertEqual(result, ["myapp.container"])
226+
227+
# Verify force parameter was passed correctly
228+
self.assertIn("force=true", adapter.last_request.url.lower())
229+
230+
@requests_mock.Mocker()
231+
def test_delete_with_ignore(self, mock):
232+
"""Test delete with ignore=True parameter."""
233+
adapter = mock.delete(
234+
tests.LIBPOD_URL + "/quadlets/myapp.container",
235+
json={"Removed": []},
236+
status_code=200,
237+
)
238+
239+
result = self.client.quadlets.delete("myapp.container", ignore=True)
240+
self.assertEqual(result, [])
241+
242+
# Verify ignore parameter was passed correctly
243+
self.assertIn("ignore=true", adapter.last_request.url.lower())
244+
245+
@requests_mock.Mocker()
246+
def test_delete_without_reload(self, mock):
247+
"""Test delete with reload_systemd=False."""
248+
adapter = mock.delete(
249+
tests.LIBPOD_URL + "/quadlets/myapp.container",
250+
json={"Removed": ["myapp.container"]},
251+
status_code=200,
252+
)
253+
254+
result = self.client.quadlets.delete("myapp.container", reload_systemd=False)
255+
self.assertEqual(result, ["myapp.container"])
256+
257+
# Verify reload-systemd parameter was passed correctly (note the hyphen)
258+
self.assertIn("reload-systemd=false", adapter.last_request.url.lower())
259+
260+
def test_delete_no_name_or_all(self):
261+
"""Test delete raises PodmanError when neither name nor all provided."""
262+
with self.assertRaises(PodmanError) as context:
263+
self.client.quadlets.delete()
264+
265+
self.assertIn("Quadlet name, or 'all=True' should be provided", str(context.exception))
266+
267+
@requests_mock.Mocker()
268+
def test_delete_response_format(self, mock):
269+
"""Test delete correctly parses 'Removed' field from API response."""
270+
mock.delete(
271+
tests.LIBPOD_URL + "/quadlets/myapp.container",
272+
json={"Removed": ["myapp.container", "related.container"]},
273+
status_code=200,
274+
)
275+
276+
result = self.client.quadlets.delete("myapp.container")
277+
self.assertIsInstance(result, list)
278+
self.assertEqual(len(result), 2)
279+
self.assertIn("myapp.container", result)
280+
self.assertIn("related.container", result)
281+
282+
@requests_mock.Mocker()
283+
def test_delete_nonexistent_quadlet_error(self, mock):
284+
"""Test delete raises NotFound for non-existent quadlet without ignore."""
285+
mock.delete(
286+
tests.LIBPOD_URL + "/quadlets/nonexistent.container",
287+
json={"error": "no such quadlet: nonexistent.container"},
288+
status_code=404,
289+
)
290+
291+
with self.assertRaises(NotFound) as context:
292+
self.client.quadlets.delete("nonexistent.container")
293+
294+
self.assertIn("nonexistent.container", str(context.exception))
295+
296+
@requests_mock.Mocker()
297+
def test_delete_nonexistent_with_ignore_succeeds(self, mock):
298+
"""Test delete with ignore=True succeeds for non-existent quadlet."""
299+
mock.delete(
300+
tests.LIBPOD_URL + "/quadlets/nonexistent.container",
301+
json={"Removed": ["nonexistent.container"], "Errors": {}},
302+
status_code=200,
303+
)
304+
305+
result = self.client.quadlets.delete("nonexistent.container", ignore=True)
306+
self.assertEqual(result, ["nonexistent.container"])
307+
308+
@requests_mock.Mocker()
309+
def test_delete_running_quadlet_error(self, mock):
310+
"""Test delete raises error when quadlet is running without force."""
311+
mock.delete(
312+
tests.LIBPOD_URL + "/quadlets/running.container",
313+
json={
314+
"cause": (
315+
"quadlet running.container is running and force is not set, refusing to remove"
316+
),
317+
"message": "container is running and force is not set, refusing to remove",
318+
"response": 400,
319+
},
320+
status_code=400,
321+
)
322+
323+
with self.assertRaises(APIError) as context:
324+
self.client.quadlets.delete("running.container")
325+
326+
error_message = str(context.exception)
327+
self.assertTrue(
328+
"running" in error_message.lower() or "force" in error_message.lower(),
329+
f"Expected error about running quadlet or force, got: {error_message}",
330+
)
331+
332+
@requests_mock.Mocker()
333+
def test_delete_running_quadlet_with_force_succeeds(self, mock):
334+
"""Test delete with force=True succeeds for running quadlet."""
335+
mock.delete(
336+
tests.LIBPOD_URL + "/quadlets/running.container",
337+
json={"Removed": ["running.container"], "Errors": {}},
338+
status_code=200,
339+
)
340+
341+
result = self.client.quadlets.delete("running.container", force=True)
342+
self.assertEqual(result, ["running.container"])
343+
344+
@requests_mock.Mocker()
345+
def test_delete_internal_server_error(self, mock):
346+
"""Test delete raises error on internal server error."""
347+
mock.delete(
348+
tests.LIBPOD_URL + "/quadlets/myapp.container",
349+
json={"cause": "systemd connection failed", "message": "Internal error"},
350+
status_code=500,
351+
)
352+
353+
with self.assertRaises(APIError) as context:
354+
self.client.quadlets.delete("myapp.container")
355+
356+
# Verify it's a 500 error
357+
self.assertEqual(context.exception.status_code, 500)
358+
self.assertTrue(context.exception.is_server_error())
359+
360+
@requests_mock.Mocker()
361+
def test_delete_all_with_partial_errors(self, mock):
362+
"""Test delete all with some quadlets failing doesn't raise exception."""
363+
mock.delete(
364+
tests.LIBPOD_URL + "/quadlets",
365+
json={
366+
"Removed": ["success1.container", "success2.container"],
367+
"Errors": {"failed.container": "could not locate quadlet failed.container"},
368+
},
369+
status_code=200,
370+
)
371+
372+
# Should return successfully removed quadlets
373+
result = self.client.quadlets.delete(all=True)
374+
self.assertIsInstance(result, list)
375+
self.assertEqual(len(result), 2)
376+
self.assertIn("success1.container", result)
377+
self.assertIn("success2.container", result)
378+
180379

181380
if __name__ == '__main__':
182381
unittest.main()

0 commit comments

Comments
 (0)