已合并
fix(sdk): clear cached sandbox state after successful deletion #191
ytluo创建于 9月2日
fix(sdk): clear cached sandbox state after successful deletion #191
已合并
共 3 个文件变更+95-0
| @@ -165,6 +165,8 @@ sandbox.delete(sandbox_id=None) -> bool | |||
| 165 | 165 | ||
| 166 | **返回:** 成功返回 `True`,失败抛出 `RuntimeError` | 166 | **返回:** 成功返回 `True`,失败抛出 `RuntimeError` |
| 167 | 167 | ||
| 168 | +成功删除当前实例对应的沙箱后,SDK 清空该对象缓存的身份、元数据和运行时信息,并关闭 Agent 客户端。删除失败或删除其他 ID 的沙箱时,不修改当前对象。 | ||
| 169 | + | ||
| 168 | **静态方法:** | 170 | **静态方法:** |
| 169 | ```text | 171 | ```text |
| 170 | Sandbox.delete_sandbox(sandbox_id) -> bool | 172 | Sandbox.delete_sandbox(sandbox_id) -> bool |
| @@ -287,6 +289,8 @@ sandbox.get_info() -> SandboxInfo | |||
| 287 | 289 | ||
| 288 | 获取当前实例保存的沙箱 ID、IP、源 Template Name 和实际 Template ID。 | 290 | 获取当前实例保存的沙箱 ID、IP、源 Template Name 和实际 Template ID。 |
| 289 | 291 | ||
| 292 | +该方法只读取本地缓存,不请求 daemon。当前对象成功调用 `delete()` 后,返回 `SandboxInfo(sandbox_id="", ip="", template_name=None, template_id=None)`。通过其他对象、CLI 或进程删除沙箱,不会自动清空此对象的缓存。 | ||
| 293 | + | ||
| 290 | **示例:** | 294 | **示例:** |
| 291 | ```python | 295 | ```python |
| 292 | info = sbx.get_info() | 296 | info = sbx.get_info() |
| @@ -833,11 +833,45 @@ class Sandbox: | |||
| 833 | 833 | ||
| 834 | try: | 834 | try: |
| 835 | self._delete_control_plane_requests(path) | 835 | self._delete_control_plane_requests(path) |
| 836 | + if target_id == self.sandbox_id: | ||
| 837 | + self._clear_sandbox_state() | ||
| 836 | return True | 838 | return True |
| 837 | 839 | ||
| 838 | except requests.exceptions.RequestException as e: | 840 | except requests.exceptions.RequestException as e: |
| 839 | raise RuntimeError(_request_exception_message(e)) | 841 | raise RuntimeError(_request_exception_message(e)) |
| 840 | 842 | ||
| 843 | + def _clear_sandbox_state(self) -> None: | ||
| 844 | + client = self._client | ||
| 845 | + self._client = None | ||
| 846 | + self.control_plane_only = False | ||
| 847 | + self.sandbox_id = "" | ||
| 848 | + self.ip = None | ||
| 849 | + self.agent_token = None | ||
| 850 | + self.template_name = None | ||
| 851 | + self.template_id = None | ||
| 852 | + self.vcpu_num = None | ||
| 853 | + self.vcpu_max = None | ||
| 854 | + self.ram_mb = None | ||
| 855 | + self.vmm_name = None | ||
| 856 | + self.image_name = None | ||
| 857 | + self.snapshot_id = None | ||
| 858 | + self.started_at = None | ||
| 859 | + self.end_at = None | ||
| 860 | + self.disk_size_mb = None | ||
| 861 | + self.conch_init_version = None | ||
| 862 | + self.alias = None | ||
| 863 | + self.volume_mounts = [] | ||
| 864 | + self.env = None | ||
| 865 | + self.network = None | ||
| 866 | + self.metadata = {} | ||
| 867 | + self.lifecycle = {} | ||
| 868 | + if client is not None: | ||
| 869 | + try: | ||
| 870 | + client.close() | ||
| 871 | + except Exception: | ||
| 872 | + # Local connection cleanup must not undo a successful delete. | ||
| 873 | + pass | ||
| 874 | + | ||
| 841 | @classmethod | 875 | @classmethod |
| 842 | def service_health(cls) -> bool: | 876 | def service_health(cls) -> bool: |
| 843 | try: | 877 | try: |
| @@ -1,3 +1,5 @@ | |||
| 1 | +from unittest.mock import Mock | ||
| 2 | + | ||
| 1 | import pytest | 3 | import pytest |
| 2 | import requests | 4 | import requests |
| 3 | 5 | ||
| @@ -81,6 +83,61 @@ def test_control_plane_transport_failures(monkeypatch): | |||
| 81 | Sandbox.list() | 83 | Sandbox.list() |
| 82 | 84 | ||
| 83 | 85 | ||
| 86 | +@pytest.mark.parametrize("close_error", [None, RuntimeError("close failed")]) | ||
| 87 | +def test_delete_clears_local_sandbox_state(monkeypatch, close_error): | ||
| 88 | + monkeypatch.setattr(sandbox_module.requests_unixsocket, "Session", FakeSession) | ||
| 89 | + sandbox = Sandbox.get("sandbox-1") | ||
| 90 | + client = Mock() | ||
| 91 | + client.close.side_effect = close_error | ||
| 92 | + sandbox.client = client | ||
| 93 | + cached_fields = ( | ||
| 94 | + "ip", "agent_token", "template_name", "template_id", "vcpu_num", | ||
| 95 | + "vcpu_max", "ram_mb", "vmm_name", "image_name", "snapshot_id", | ||
| 96 | + "started_at", "end_at", "disk_size_mb", "conch_init_version", "alias", | ||
| 97 | + ) | ||
| 98 | + for field in cached_fields: | ||
| 99 | + setattr(sandbox, field, "cached") | ||
| 100 | + sandbox.volume_mounts = [{"path": "/data"}] | ||
| 101 | + sandbox.env = {"KEY": "value"} | ||
| 102 | + sandbox.lifecycle = {"state": "running"} | ||
| 103 | + | ||
| 104 | + assert sandbox.delete() is True | ||
| 105 | + | ||
| 106 | + assert sandbox.get_info() == sandbox_module.SandboxInfo("", "", None, None) | ||
| 107 | + assert all(getattr(sandbox, field) is None for field in cached_fields) | ||
| 108 | + assert sandbox.metadata == {} | ||
| 109 | + assert sandbox.lifecycle == {} | ||
| 110 | + assert sandbox.volume_mounts == [] | ||
| 111 | + assert sandbox.env is None | ||
| 112 | + assert sandbox.network is None | ||
| 113 | + assert sandbox._client is None | ||
| 114 | + client.close.assert_called_once_with() | ||
| 115 | + with pytest.raises(RuntimeError, match="not initialized"): | ||
| 116 | + sandbox.health_check() | ||
| 117 | + | ||
| 118 | + | ||
| 119 | +@pytest.mark.parametrize("delete_fails", [False, True]) | ||
| 120 | +def test_delete_preserves_state_on_failure_or_other_target(monkeypatch, delete_fails): | ||
| 121 | + monkeypatch.setattr(sandbox_module.requests_unixsocket, "Session", FakeSession) | ||
| 122 | + sandbox = Sandbox.get("sandbox-1") | ||
| 123 | + client = Mock() | ||
| 124 | + sandbox.client = client | ||
| 125 | + before = vars(sandbox).copy() | ||
| 126 | + delete = Mock(side_effect=requests.HTTPError("delete failed") if delete_fails else None) | ||
| 127 | + monkeypatch.setattr(sandbox._session, "delete", delete) | ||
| 128 | + | ||
| 129 | + if delete_fails: | ||
| 130 | + with pytest.raises(RuntimeError, match="delete failed"): | ||
| 131 | + sandbox.delete() | ||
| 132 | + else: | ||
| 133 | + delete.return_value = FakeResponse(204) | ||
| 134 | + assert sandbox.delete("sandbox-2") is True | ||
| 135 | + assert delete.call_args.args[0].endswith("/sandboxes/sandbox-2") | ||
| 136 | + | ||
| 137 | + assert vars(sandbox) == before | ||
| 138 | + client.close.assert_not_called() | ||
| 139 | + | ||
| 140 | + | ||
| 84 | def test_control_plane_structured_error_uses_code_and_message(): | 141 | def test_control_plane_structured_error_uses_code_and_message(): |
| 85 | response = requests.Response() | 142 | response = requests.Response() |
| 86 | response.status_code = 400 | 143 | response.status_code = 400 |