Skip to content

Commit 15026b7

Browse files
feat: add Pod Snapshot extension for Python SDK (#338)
* Add constants * PodSanpshotSandboxClient Class * Fix minor nits * Address nit * Update comment * Use label-selector to check if pod snapshot agent is running * Updated snapshot client check * Update variable names * address comments
1 parent 4d2530a commit 15026b7

7 files changed

Lines changed: 494 additions & 0 deletions

File tree

clients/python/agentic-sandbox-client/k8s_agent_sandbox/constants.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,7 @@
2626
SANDBOX_PLURAL_NAME = "sandboxes"
2727

2828
POD_NAME_ANNOTATION = "agents.x-k8s.io/pod-name"
29+
30+
PODSNAPSHOT_API_GROUP = "podsnapshot.gke.io"
31+
PODSNAPSHOT_API_VERSION = "v1alpha1"
32+
PODSNAPSHOT_API_KIND = "PodSnapshot"
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Copyright 2026 The Kubernetes Authors.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from .podsnapshot_client import PodSnapshotSandboxClient
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# Agentic Sandbox Pod Snapshot Extension
2+
3+
This directory contains the Python client extension for interacting with the Agentic Sandbox to manage Pod Snapshots. This extension allows you to trigger snapshots of a running sandbox and restore a new sandbox from the recently created snapshot.
4+
5+
## `podsnapshot_client.py`
6+
7+
This file defines the `PodSnapshotSandboxClient` class, which extend the base `SandboxClient` to provide snapshot capabilities.
8+
9+
### `PodSnapshotSandboxClient`
10+
11+
A specialized Sandbox client for interacting with the gke pod snapshot controller.
12+
13+
### Key Features:
14+
15+
* **`PodSnapshotSandboxClient(template_name: str, ...)`**:
16+
* Initializes the client with optional server port.
17+
18+
* **`__exit__(self)`**:
19+
* Cleans up the `SandboxClaim` resources.
20+
21+
## `test_podsnapshot_extension.py`
22+
23+
This file, located in the parent directory (`clients/python/agentic-sandbox-client/`), contains an integration test script for the `PodSnapshotSandboxClient` extension. It verifies the snapshot and restore functionality.
24+
25+
### Test Phases:
26+
27+
1. **Phase 1: Starting Counter Sandbox**:
28+
* Starts a sandbox with a counter application.
29+
30+
### Prerequisites
31+
32+
1. **Python Virtual Environment**:
33+
```bash
34+
python3 -m venv .venv
35+
source .venv/bin/activate
36+
```
37+
38+
2. **Install Dependencies**:
39+
```bash
40+
pip install kubernetes
41+
pip install -e clients/python/agentic-sandbox-client/
42+
```
43+
44+
3. **Pod Snapshot Controller**: The Pod Snapshot controller must be installed in a **GKE standard cluster** running with **gVisor**.
45+
* For detailed setup instructions, refer to the [GKE Pod Snapshots public documentation](https://docs.cloud.google.com/kubernetes-engine/docs/how-to/pod-snapshots).
46+
* Ensure a GCS bucket is configured to store the pod snapshot states and that the necessary IAM permissions are applied.
47+
48+
4. **CRDs**: `PodSnapshotStorageConfig`, `PodSnapshotPolicy` CRDs must be applied. `PodSnapshotPolicy` should specify the selector match labels.
49+
50+
5. **Sandbox Template**: A `SandboxTemplate` (e.g., `python-counter-template`) with runtime gVisor, appropriate KSA and label that matches that selector label in `PodSnapshotPolicy` must be available in the cluster.
51+
52+
### Running Tests:
53+
54+
To run the integration test, execute the script with the appropriate arguments:
55+
56+
```bash
57+
python3 clients/python/agentic-sandbox-client/test_podsnapshot_extension.py \
58+
--template-name python-counter-template \
59+
--namespace sandbox-test
60+
```
61+
62+
Adjust the `--namespace`, `--template-name` as needed for your environment.
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# Copyright 2026 The Kubernetes Authors.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import logging
16+
from kubernetes import client
17+
from kubernetes.client import ApiException
18+
from ..sandbox_client import SandboxClient
19+
from ..constants import (
20+
PODSNAPSHOT_API_GROUP,
21+
PODSNAPSHOT_API_VERSION,
22+
PODSNAPSHOT_API_KIND,
23+
)
24+
25+
logger = logging.getLogger(__name__)
26+
27+
28+
class PodSnapshotSandboxClient(SandboxClient):
29+
"""
30+
A specialized Sandbox client for interacting with the GKE Pod Snapshot Controller.
31+
32+
TODO: This class enables users to take a snapshot of their sandbox and restore from the taken snapshot.
33+
"""
34+
35+
def __init__(
36+
self,
37+
template_name: str,
38+
**kwargs,
39+
):
40+
super().__init__(template_name, **kwargs)
41+
42+
self.snapshot_crd_installed = False
43+
self.core_v1_api = client.CoreV1Api()
44+
45+
def __enter__(self) -> "PodSnapshotSandboxClient":
46+
try:
47+
self.snapshot_crd_installed = self._check_snapshot_crd_installed()
48+
if not self.snapshot_crd_installed:
49+
raise RuntimeError(
50+
"Pod Snapshot Controller is not ready. "
51+
"Ensure the PodSnapshot CRD is installed."
52+
)
53+
super().__enter__()
54+
return self
55+
except Exception as e:
56+
self.__exit__(None, None, None)
57+
raise RuntimeError(
58+
f"Failed to initialize PodSnapshotSandboxClient. Ensure that you are connected to a GKE cluster "
59+
f"with the Pod Snapshot Controller enabled. Error details: {e}"
60+
) from e
61+
62+
def _check_snapshot_crd_installed(self) -> bool:
63+
"""
64+
Checks if the PodSnapshot CRD is installed in the cluster.
65+
"""
66+
67+
if self.snapshot_crd_installed:
68+
return True
69+
70+
try:
71+
# Check if the API resource exists using CustomObjectsApi
72+
resource_list = self.custom_objects_api.get_api_resources(
73+
group=PODSNAPSHOT_API_GROUP,
74+
version=PODSNAPSHOT_API_VERSION,
75+
)
76+
77+
if not resource_list or not resource_list.resources:
78+
return False
79+
80+
for resource in resource_list.resources:
81+
if resource.kind == PODSNAPSHOT_API_KIND:
82+
return True
83+
return False
84+
except ApiException as e:
85+
# If discovery fails with 403/404, we assume not ready/accessible
86+
if e.status == 403 or e.status == 404:
87+
return False
88+
raise
89+
90+
def __exit__(self, exc_type, exc_val, exc_tb):
91+
"""
92+
Automatically cleans up the Sandbox.
93+
"""
94+
super().__exit__(exc_type, exc_val, exc_tb)
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
# Copyright 2026 The Kubernetes Authors.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import unittest
16+
import os
17+
import logging
18+
from unittest.mock import MagicMock, patch
19+
from k8s_agent_sandbox.gke_extensions.podsnapshot_client import (
20+
PodSnapshotSandboxClient,
21+
)
22+
from k8s_agent_sandbox.constants import (
23+
PODSNAPSHOT_API_KIND,
24+
PODSNAPSHOT_API_GROUP,
25+
PODSNAPSHOT_API_VERSION,
26+
)
27+
28+
from kubernetes.client import ApiException
29+
30+
from kubernetes import config
31+
32+
logger = logging.getLogger(__name__)
33+
34+
35+
class TestPodSnapshotSandboxClient(unittest.TestCase):
36+
37+
@patch("kubernetes.config")
38+
def setUp(self, mock_config):
39+
logger.info("Setting up TestPodSnapshotSandboxClient...")
40+
# Mock kubernetes config loading
41+
mock_config.load_incluster_config.side_effect = config.ConfigException(
42+
"Not in cluster"
43+
)
44+
mock_config.load_kube_config.return_value = None
45+
46+
# Create client without patching super, as it's tested separately
47+
with patch.object(
48+
PodSnapshotSandboxClient, "_check_snapshot_crd_installed", return_value=True
49+
):
50+
self.client = PodSnapshotSandboxClient("test-template")
51+
52+
# Mock the kubernetes APIs on the client instance
53+
self.client.custom_objects_api = MagicMock()
54+
self.client.core_v1_api = MagicMock()
55+
56+
logger.info("Finished setting up TestPodSnapshotSandboxClient.")
57+
58+
def test_init(self):
59+
"""Test initialization of PodSnapshotSandboxClient."""
60+
logger.info("Starting test_init...")
61+
with patch(
62+
"k8s_agent_sandbox.sandbox_client.SandboxClient.__init__", return_value=None
63+
) as mock_super:
64+
with patch.object(
65+
PodSnapshotSandboxClient,
66+
"_check_snapshot_crd_installed",
67+
return_value=True,
68+
):
69+
client = PodSnapshotSandboxClient("test-template")
70+
mock_super.assert_called_once_with("test-template")
71+
self.assertFalse(client.snapshot_crd_installed)
72+
logger.info("Finished test_init.")
73+
74+
def test_check_snapshot_crd_installed_success(self):
75+
"""Test _check_snapshot_crd_installed success scenarios (Check CRD Existence)."""
76+
logger.info("TEST: CRD Existence Success")
77+
mock_resource_list = MagicMock()
78+
mock_resource = MagicMock()
79+
mock_resource.kind = PODSNAPSHOT_API_KIND
80+
mock_resource_list.resources = [mock_resource]
81+
self.client.custom_objects_api.get_api_resources.return_value = (
82+
mock_resource_list
83+
)
84+
85+
self.client.snapshot_crd_installed = False
86+
self.assertTrue(self.client._check_snapshot_crd_installed())
87+
self.client.custom_objects_api.get_api_resources.assert_called_with(
88+
group=PODSNAPSHOT_API_GROUP, version=PODSNAPSHOT_API_VERSION
89+
)
90+
91+
def test_check_snapshot_crd_installed_failures(self):
92+
"""Test _check_snapshot_crd_installed failure scenarios."""
93+
94+
# 1. No CRDs found
95+
self.client.custom_objects_api.get_api_resources.return_value = None
96+
self.client.snapshot_crd_installed = False
97+
self.assertFalse(self.client._check_snapshot_crd_installed())
98+
99+
# 2. CRD Kind mismatch
100+
mock_resource_list = MagicMock()
101+
mock_resource = MagicMock()
102+
mock_resource.kind = "SomeOtherKind"
103+
mock_resource_list.resources = [mock_resource]
104+
self.client.custom_objects_api.get_api_resources.return_value = (
105+
mock_resource_list
106+
)
107+
self.client.snapshot_crd_installed = False
108+
self.assertFalse(self.client._check_snapshot_crd_installed())
109+
110+
# 3. 404 on CRD check
111+
self.client.custom_objects_api.get_api_resources.side_effect = ApiException(
112+
status=404
113+
)
114+
self.client.snapshot_crd_installed = False
115+
self.assertFalse(self.client._check_snapshot_crd_installed())
116+
117+
# 4. 403 on CRD check
118+
self.client.custom_objects_api.get_api_resources.side_effect = ApiException(
119+
status=403
120+
)
121+
self.client.snapshot_crd_installed = False
122+
self.assertFalse(self.client._check_snapshot_crd_installed())
123+
124+
def test_check_snapshot_crd_installed_exceptions(self):
125+
"""Test API exceptions during snapshot readiness checks."""
126+
127+
# 1. 500 on CRD Check
128+
self.client.custom_objects_api.get_api_resources.side_effect = ApiException(
129+
status=500
130+
)
131+
self.client.snapshot_crd_installed = False
132+
with self.assertRaises(ApiException):
133+
self.client._check_snapshot_crd_installed()
134+
135+
def test_enter_exit(self):
136+
"""Test context manager __enter__ implementation."""
137+
# Success path
138+
self.client.snapshot_crd_installed = False
139+
with patch.object(
140+
self.client, "_check_snapshot_crd_installed", return_value=True
141+
) as mock_ready:
142+
with patch(
143+
"k8s_agent_sandbox.sandbox_client.SandboxClient.__enter__"
144+
) as mock_super_enter:
145+
result = self.client.__enter__()
146+
self.assertEqual(result, self.client)
147+
mock_ready.assert_called_once()
148+
mock_super_enter.assert_called_once()
149+
self.assertTrue(self.client.snapshot_crd_installed)
150+
151+
# Failure path: Controller not ready (return False)
152+
self.client.snapshot_crd_installed = False
153+
with patch.object(
154+
self.client, "_check_snapshot_crd_installed", return_value=False
155+
) as mock_ready:
156+
with patch.object(self.client, "__exit__") as mock_exit:
157+
with self.assertRaises(RuntimeError) as context:
158+
self.client.__enter__()
159+
self.assertIn(
160+
"Pod Snapshot Controller is not ready",
161+
str(context.exception),
162+
)
163+
mock_exit.assert_called_once_with(None, None, None)
164+
165+
# Failure path: Exception during check
166+
self.client.snapshot_crd_installed = False
167+
with patch.object(
168+
self.client,
169+
"_check_snapshot_crd_installed",
170+
side_effect=ValueError("Test error"),
171+
) as mock_ready:
172+
with patch.object(self.client, "__exit__") as mock_exit:
173+
with self.assertRaises(RuntimeError) as context:
174+
self.client.__enter__()
175+
self.assertIn(
176+
"Failed to initialize PodSnapshotSandboxClient",
177+
str(context.exception),
178+
)
179+
mock_exit.assert_called_once_with(None, None, None)
180+
181+
# Test Exit
182+
with patch(
183+
"k8s_agent_sandbox.sandbox_client.SandboxClient.__exit__"
184+
) as mock_super_exit:
185+
exc_val = ValueError("test")
186+
self.client.__exit__(ValueError, exc_val, None)
187+
mock_super_exit.assert_called_once_with(ValueError, exc_val, None)
188+
189+
def test_check_snapshot_crd_installed_already_ready(self):
190+
"""Test early return if snapshot controller is already ready."""
191+
self.client.snapshot_crd_installed = True
192+
result = self.client._check_snapshot_crd_installed()
193+
self.assertTrue(result)
194+
self.client.custom_objects_api.get_api_resources.assert_not_called()
195+
196+
197+
if __name__ == "__main__":
198+
unittest.main()

0 commit comments

Comments
 (0)