jac-scale 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. jac_scale/__init__.py +0 -0
  2. jac_scale/abstractions/config/app_config.jac +30 -0
  3. jac_scale/abstractions/config/base_config.jac +26 -0
  4. jac_scale/abstractions/database_provider.jac +51 -0
  5. jac_scale/abstractions/deployment_target.jac +64 -0
  6. jac_scale/abstractions/image_registry.jac +54 -0
  7. jac_scale/abstractions/logger.jac +20 -0
  8. jac_scale/abstractions/models/deployment_result.jac +27 -0
  9. jac_scale/abstractions/models/resource_status.jac +38 -0
  10. jac_scale/config_loader.jac +31 -0
  11. jac_scale/context.jac +14 -0
  12. jac_scale/factories/database_factory.jac +43 -0
  13. jac_scale/factories/deployment_factory.jac +43 -0
  14. jac_scale/factories/registry_factory.jac +32 -0
  15. jac_scale/factories/utility_factory.jac +34 -0
  16. jac_scale/impl/config_loader.impl.jac +131 -0
  17. jac_scale/impl/context.impl.jac +24 -0
  18. jac_scale/impl/memory_hierarchy.main.impl.jac +63 -0
  19. jac_scale/impl/memory_hierarchy.mongo.impl.jac +239 -0
  20. jac_scale/impl/memory_hierarchy.redis.impl.jac +186 -0
  21. jac_scale/impl/serve.impl.jac +1785 -0
  22. jac_scale/jserver/__init__.py +0 -0
  23. jac_scale/jserver/impl/jfast_api.impl.jac +731 -0
  24. jac_scale/jserver/impl/jserver.impl.jac +79 -0
  25. jac_scale/jserver/jfast_api.jac +162 -0
  26. jac_scale/jserver/jserver.jac +101 -0
  27. jac_scale/memory_hierarchy.jac +138 -0
  28. jac_scale/plugin.jac +218 -0
  29. jac_scale/plugin_config.jac +175 -0
  30. jac_scale/providers/database/kubernetes_mongo.jac +137 -0
  31. jac_scale/providers/database/kubernetes_redis.jac +110 -0
  32. jac_scale/providers/registry/dockerhub.jac +64 -0
  33. jac_scale/serve.jac +118 -0
  34. jac_scale/targets/kubernetes/kubernetes_config.jac +215 -0
  35. jac_scale/targets/kubernetes/kubernetes_target.jac +841 -0
  36. jac_scale/targets/kubernetes/utils/kubernetes_utils.impl.jac +519 -0
  37. jac_scale/targets/kubernetes/utils/kubernetes_utils.jac +85 -0
  38. jac_scale/tests/__init__.py +0 -0
  39. jac_scale/tests/conftest.py +29 -0
  40. jac_scale/tests/fixtures/test_api.jac +159 -0
  41. jac_scale/tests/fixtures/todo_app.jac +68 -0
  42. jac_scale/tests/test_abstractions.py +88 -0
  43. jac_scale/tests/test_deploy_k8s.py +265 -0
  44. jac_scale/tests/test_examples.py +484 -0
  45. jac_scale/tests/test_factories.py +149 -0
  46. jac_scale/tests/test_file_upload.py +444 -0
  47. jac_scale/tests/test_k8s_utils.py +156 -0
  48. jac_scale/tests/test_memory_hierarchy.py +247 -0
  49. jac_scale/tests/test_serve.py +1835 -0
  50. jac_scale/tests/test_sso.py +711 -0
  51. jac_scale/utilities/loggers/standard_logger.jac +40 -0
  52. jac_scale/utils.jac +16 -0
  53. jac_scale-0.1.1.dist-info/METADATA +658 -0
  54. jac_scale-0.1.1.dist-info/RECORD +57 -0
  55. jac_scale-0.1.1.dist-info/WHEEL +5 -0
  56. jac_scale-0.1.1.dist-info/entry_points.txt +3 -0
  57. jac_scale-0.1.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,444 @@
1
+ """Test file upload functionality in jac-scale serve."""
2
+
3
+ import contextlib
4
+ import gc
5
+ import glob
6
+ import io
7
+ import socket
8
+ import subprocess
9
+ import time
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import requests
14
+
15
+
16
+ def get_free_port() -> int:
17
+ """Get a free port by binding to port 0 and releasing it."""
18
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
19
+ s.bind(("", 0))
20
+ s.listen(1)
21
+ port = s.getsockname()[1]
22
+ return port
23
+
24
+
25
+ class TestFileUpload:
26
+ """Test file upload functionality in jac-scale."""
27
+
28
+ fixtures_dir: Path
29
+ test_file: Path
30
+ port: int
31
+ base_url: str
32
+ server_process: subprocess.Popen[str] | None = None
33
+
34
+ @classmethod
35
+ def setup_class(cls) -> None:
36
+ """Set up test class - runs once for all tests."""
37
+ cls.fixtures_dir = Path(__file__).parent / "fixtures"
38
+ cls.test_file = cls.fixtures_dir / "test_api.jac"
39
+
40
+ if not cls.test_file.exists():
41
+ raise FileNotFoundError(f"Test fixture not found: {cls.test_file}")
42
+
43
+ cls.port = get_free_port()
44
+ cls.base_url = f"http://localhost:{cls.port}"
45
+
46
+ cls._cleanup_db_files()
47
+ cls.server_process = None
48
+ cls._start_server()
49
+
50
+ @classmethod
51
+ def teardown_class(cls) -> None:
52
+ """Tear down test class - runs once after all tests."""
53
+ if cls.server_process:
54
+ cls.server_process.terminate()
55
+ try:
56
+ cls.server_process.wait(timeout=5)
57
+ except subprocess.TimeoutExpired:
58
+ cls.server_process.kill()
59
+ cls.server_process.wait()
60
+
61
+ time.sleep(0.5)
62
+ gc.collect()
63
+ cls._cleanup_db_files()
64
+
65
+ @classmethod
66
+ def _start_server(cls) -> None:
67
+ """Start the jac-scale server in a subprocess."""
68
+ import sys
69
+
70
+ jac_executable = Path(sys.executable).parent / "jac"
71
+
72
+ # Build the command to start the server
73
+ # Use just the filename and set cwd to fixtures directory
74
+ # This is required for proper bytecode caching and module resolution
75
+ cmd = [
76
+ str(jac_executable),
77
+ "start",
78
+ cls.test_file.name,
79
+ "--port",
80
+ str(cls.port),
81
+ ]
82
+
83
+ # Start the server process with cwd set to fixtures directory
84
+ cls.server_process = subprocess.Popen(
85
+ cmd,
86
+ stdout=subprocess.PIPE,
87
+ stderr=subprocess.PIPE,
88
+ text=True,
89
+ cwd=str(cls.fixtures_dir),
90
+ )
91
+
92
+ max_attempts = 50
93
+ server_ready = False
94
+
95
+ for _ in range(max_attempts):
96
+ if cls.server_process.poll() is not None:
97
+ stdout, stderr = cls.server_process.communicate()
98
+ raise RuntimeError(
99
+ f"Server process terminated unexpectedly.\n"
100
+ f"STDOUT: {stdout}\nSTDERR: {stderr}"
101
+ )
102
+
103
+ try:
104
+ response = requests.get(f"{cls.base_url}/docs", timeout=2)
105
+ if response.status_code in (200, 404):
106
+ print(f"Server started successfully on port {cls.port}")
107
+ server_ready = True
108
+ break
109
+ except (requests.ConnectionError, requests.Timeout):
110
+ time.sleep(2)
111
+
112
+ if not server_ready:
113
+ cls.server_process.terminate()
114
+ try:
115
+ stdout, stderr = cls.server_process.communicate(timeout=2)
116
+ except subprocess.TimeoutExpired:
117
+ cls.server_process.kill()
118
+ stdout, stderr = cls.server_process.communicate()
119
+
120
+ raise RuntimeError(
121
+ f"Server failed to start after {max_attempts} attempts.\n"
122
+ f"STDOUT: {stdout}\nSTDERR: {stderr}"
123
+ )
124
+
125
+ @classmethod
126
+ def _cleanup_db_files(cls) -> None:
127
+ """Delete SQLite database files and legacy shelf files."""
128
+ import shutil
129
+
130
+ for pattern in [
131
+ "*.db",
132
+ "*.db-wal",
133
+ "*.db-shm",
134
+ "anchor_store.db.dat",
135
+ "anchor_store.db.bak",
136
+ "anchor_store.db.dir",
137
+ ]:
138
+ for db_file in glob.glob(pattern):
139
+ with contextlib.suppress(Exception):
140
+ Path(db_file).unlink()
141
+
142
+ for pattern in ["*.db", "*.db-wal", "*.db-shm"]:
143
+ for db_file in glob.glob(str(cls.fixtures_dir / pattern)):
144
+ with contextlib.suppress(Exception):
145
+ Path(db_file).unlink()
146
+
147
+ client_build_dir = cls.fixtures_dir / ".jac"
148
+ if client_build_dir.exists():
149
+ with contextlib.suppress(Exception):
150
+ shutil.rmtree(client_build_dir)
151
+
152
+ @staticmethod
153
+ def _extract_transport_response_data(
154
+ json_response: dict[str, Any] | list[Any],
155
+ ) -> dict[str, Any] | list[Any]:
156
+ """Extract data from TransportResponse envelope format."""
157
+ if isinstance(json_response, list) and len(json_response) == 2:
158
+ body: dict[str, Any] = json_response[1]
159
+ json_response = body
160
+
161
+ if (
162
+ isinstance(json_response, dict)
163
+ and "ok" in json_response
164
+ and "data" in json_response
165
+ ):
166
+ if json_response.get("ok") and json_response.get("data") is not None:
167
+ return json_response["data"]
168
+ elif not json_response.get("ok") and json_response.get("error"):
169
+ error_info = json_response["error"]
170
+ result: dict[str, Any] = {
171
+ "error": error_info.get("message", "Unknown error")
172
+ }
173
+ if "code" in error_info:
174
+ result["error_code"] = error_info["code"]
175
+ if "details" in error_info:
176
+ result["error_details"] = error_info["details"]
177
+ return result
178
+
179
+ return json_response
180
+
181
+ def _get_auth_token(self, username: str = "filetest_user") -> str:
182
+ """Register a user and get auth token."""
183
+ response = requests.post(
184
+ f"{self.base_url}/user/register",
185
+ json={"username": username, "password": "testpass123"},
186
+ timeout=5,
187
+ )
188
+ data = self._extract_transport_response_data(response.json())
189
+ if isinstance(data, dict) and "token" in data:
190
+ return data["token"]
191
+
192
+ # User might already exist, try login
193
+ response = requests.post(
194
+ f"{self.base_url}/user/login",
195
+ json={"username": username, "password": "testpass123"},
196
+ timeout=5,
197
+ )
198
+ data = self._extract_transport_response_data(response.json())
199
+ assert isinstance(data, dict)
200
+ return data["token"]
201
+
202
+ def _create_test_file(
203
+ self, filename: str = "test.txt", content: bytes = b"Hello, World!"
204
+ ) -> tuple[str, io.BytesIO]:
205
+ """Create a test file-like object for upload."""
206
+ file_obj = io.BytesIO(content)
207
+ return filename, file_obj
208
+
209
+ # ========================================================================
210
+ # Single File Upload Tests
211
+ # ========================================================================
212
+
213
+ def test_single_file_upload_basic(self) -> None:
214
+ """Test basic single file upload."""
215
+ token = self._get_auth_token("single_file_user")
216
+
217
+ filename, file_obj = self._create_test_file("document.txt", b"Test content")
218
+
219
+ response = requests.post(
220
+ f"{self.base_url}/walker/UploadSingleFile",
221
+ headers={"Authorization": f"Bearer {token}"},
222
+ files={"myfile": (filename, file_obj, "text/plain")},
223
+ data={"description": "Test document"},
224
+ timeout=10,
225
+ )
226
+
227
+ assert response.status_code == 200
228
+ result = self._extract_transport_response_data(response.json())
229
+ assert isinstance(result, dict)
230
+
231
+ # Check walker reports are in the result
232
+ if "reports" in result:
233
+ reports = result["reports"]
234
+ assert len(reports) > 0
235
+ file_info = reports[0]
236
+ else:
237
+ file_info = result
238
+
239
+ assert file_info["filename"] == "document.txt"
240
+ assert file_info["content_type"] == "text/plain"
241
+ assert file_info["description"] == "Test document"
242
+
243
+ def test_single_file_upload_pdf(self) -> None:
244
+ """Test uploading a PDF file."""
245
+ token = self._get_auth_token("pdf_upload_user")
246
+
247
+ # Create a minimal PDF-like content
248
+ pdf_content = b"%PDF-1.4\n1 0 obj\n<< /Type /Catalog >>\nendobj\n%%EOF"
249
+ filename, file_obj = self._create_test_file("report.pdf", pdf_content)
250
+
251
+ response = requests.post(
252
+ f"{self.base_url}/walker/UploadSingleFile",
253
+ headers={"Authorization": f"Bearer {token}"},
254
+ files={"myfile": (filename, file_obj, "application/pdf")},
255
+ data={"description": "PDF Report"},
256
+ timeout=10,
257
+ )
258
+
259
+ assert response.status_code == 200
260
+ result = self._extract_transport_response_data(response.json())
261
+ assert isinstance(result, dict)
262
+
263
+ file_info = result["reports"][0] if "reports" in result else result
264
+
265
+ assert file_info["filename"] == "report.pdf"
266
+ assert file_info["content_type"] == "application/pdf"
267
+
268
+ def test_single_file_upload_with_empty_description(self) -> None:
269
+ """Test file upload with default empty description."""
270
+ token = self._get_auth_token("empty_desc_user")
271
+
272
+ filename, file_obj = self._create_test_file("image.png", b"\x89PNG\r\n\x1a\n")
273
+
274
+ response = requests.post(
275
+ f"{self.base_url}/walker/UploadSingleFile",
276
+ headers={"Authorization": f"Bearer {token}"},
277
+ files={"myfile": (filename, file_obj, "image/png")},
278
+ timeout=10,
279
+ )
280
+
281
+ assert response.status_code == 200
282
+ result = self._extract_transport_response_data(response.json())
283
+ assert isinstance(result, dict)
284
+
285
+ file_info = result["reports"][0] if "reports" in result else result
286
+
287
+ assert file_info["filename"] == "image.png"
288
+ # Default description should be empty string
289
+ assert file_info["description"] == ""
290
+
291
+ # ========================================================================
292
+ # Authentication Tests
293
+ # ========================================================================
294
+
295
+ def test_file_upload_requires_auth(self) -> None:
296
+ """Test that private file upload walker requires authentication."""
297
+ filename, file_obj = self._create_test_file("secret.txt", b"Secret content")
298
+
299
+ # Try to upload without auth token
300
+ response = requests.post(
301
+ f"{self.base_url}/walker/SecureFileUpload",
302
+ files={"document": (filename, file_obj, "text/plain")},
303
+ data={"notes": "Confidential"},
304
+ timeout=10,
305
+ )
306
+
307
+ # Should fail with 401 Unauthorized
308
+ assert response.status_code in (401, 200) # 200 if error is in response body
309
+ if response.status_code == 200:
310
+ result = self._extract_transport_response_data(response.json())
311
+ assert isinstance(result, dict)
312
+ assert "error" in result
313
+
314
+ def test_file_upload_with_valid_auth(self) -> None:
315
+ """Test file upload with valid authentication."""
316
+ token = self._get_auth_token("secure_upload_user")
317
+
318
+ filename, file_obj = self._create_test_file("confidential.doc", b"Secret data")
319
+
320
+ response = requests.post(
321
+ f"{self.base_url}/walker/SecureFileUpload",
322
+ headers={"Authorization": f"Bearer {token}"},
323
+ files={"document": (filename, file_obj, "application/msword")},
324
+ data={"notes": "Important document"},
325
+ timeout=10,
326
+ )
327
+
328
+ assert response.status_code == 200
329
+ result = self._extract_transport_response_data(response.json())
330
+ assert isinstance(result, dict)
331
+
332
+ file_info = result["reports"][0] if "reports" in result else result
333
+
334
+ assert file_info["filename"] == "confidential.doc"
335
+ assert file_info["notes"] == "Important document"
336
+ assert file_info["authenticated"] is True
337
+
338
+ def test_public_file_upload_no_auth_required(self) -> None:
339
+ """Test that public file upload walker works without authentication."""
340
+ filename, file_obj = self._create_test_file("public.txt", b"Public content")
341
+
342
+ response = requests.post(
343
+ f"{self.base_url}/walker/PublicFileUpload",
344
+ files={"attachment": (filename, file_obj, "text/plain")},
345
+ timeout=10,
346
+ )
347
+
348
+ assert response.status_code == 200
349
+ result = self._extract_transport_response_data(response.json())
350
+ assert isinstance(result, dict)
351
+
352
+ file_info = result["reports"][0] if "reports" in result else result
353
+
354
+ assert file_info["filename"] == "public.txt"
355
+ assert file_info["content_type"] == "text/plain"
356
+
357
+ # ========================================================================
358
+ # File Type Tests
359
+ # ========================================================================
360
+
361
+ def test_upload_various_file_types(self) -> None:
362
+ """Test uploading various file types."""
363
+ token = self._get_auth_token("various_types_user")
364
+
365
+ file_types = [
366
+ ("document.json", b'{"key": "value"}', "application/json"),
367
+ ("image.jpg", b"\xff\xd8\xff\xe0", "image/jpeg"),
368
+ ("script.js", b"console.log('hello');", "application/javascript"),
369
+ ("data.csv", b"col1,col2\nval1,val2", "text/csv"),
370
+ ]
371
+
372
+ for filename, content, content_type in file_types:
373
+ file_obj = io.BytesIO(content)
374
+
375
+ response = requests.post(
376
+ f"{self.base_url}/walker/UploadSingleFile",
377
+ headers={"Authorization": f"Bearer {token}"},
378
+ files={"myfile": (filename, file_obj, content_type)},
379
+ data={"description": f"Testing {content_type}"},
380
+ timeout=10,
381
+ )
382
+
383
+ assert response.status_code == 200, f"Failed for {filename}"
384
+ result = self._extract_transport_response_data(response.json())
385
+ assert isinstance(result, dict)
386
+
387
+ file_info = result["reports"][0] if "reports" in result else result
388
+
389
+ assert file_info["filename"] == filename
390
+ assert file_info["content_type"] == content_type
391
+
392
+ # ========================================================================
393
+ # Error Handling Tests
394
+ # ========================================================================
395
+
396
+ def test_upload_missing_required_file(self) -> None:
397
+ """Test upload endpoint when required file is missing."""
398
+ token = self._get_auth_token("missing_file_user")
399
+
400
+ # Send request without the required file
401
+ response = requests.post(
402
+ f"{self.base_url}/walker/UploadSingleFile",
403
+ headers={"Authorization": f"Bearer {token}"},
404
+ data={"description": "No file attached"},
405
+ timeout=10,
406
+ )
407
+
408
+ # Should return validation error (422) or error in response
409
+ assert response.status_code in (422, 400, 200)
410
+ result = response.json()
411
+
412
+ if response.status_code == 422:
413
+ # FastAPI validation error
414
+ assert "detail" in result
415
+ elif response.status_code == 200:
416
+ # Error in response body
417
+ extracted = self._extract_transport_response_data(result)
418
+ assert isinstance(extracted, dict)
419
+ assert "error" in extracted
420
+
421
+ def test_upload_large_file(self) -> None:
422
+ """Test uploading a larger file (1MB)."""
423
+ token = self._get_auth_token("large_file_user")
424
+
425
+ # Create a 1MB file
426
+ large_content = b"X" * (1024 * 1024) # 1MB
427
+ filename, file_obj = self._create_test_file("large_file.bin", large_content)
428
+
429
+ response = requests.post(
430
+ f"{self.base_url}/walker/UploadSingleFile",
431
+ headers={"Authorization": f"Bearer {token}"},
432
+ files={"myfile": (filename, file_obj, "application/octet-stream")},
433
+ data={"description": "Large file test"},
434
+ timeout=30, # Longer timeout for large file
435
+ )
436
+
437
+ assert response.status_code == 200
438
+ result = self._extract_transport_response_data(response.json())
439
+ assert isinstance(result, dict)
440
+
441
+ file_info = result["reports"][0] if "reports" in result else result
442
+
443
+ assert file_info["filename"] == "large_file.bin"
444
+ assert file_info["size"] == 1024 * 1024
@@ -0,0 +1,156 @@
1
+ import tarfile
2
+ from pathlib import Path
3
+ from types import SimpleNamespace
4
+ from unittest.mock import MagicMock
5
+
6
+ import pytest
7
+ from kubernetes.client.exceptions import ApiException
8
+ from pytest import MonkeyPatch
9
+
10
+ from jac_scale.targets.kubernetes.utils import kubernetes_utils as utils
11
+ from jac_scale.targets.kubernetes.utils.kubernetes_utils import (
12
+ create_tarball,
13
+ ensure_pvc_exists,
14
+ load_env_variables,
15
+ parse_cpu_quantity,
16
+ parse_memory_quantity,
17
+ validate_resource_limits,
18
+ )
19
+
20
+
21
+ @pytest.mark.parametrize(
22
+ "raw,expected",
23
+ [
24
+ ("500m", 0.5),
25
+ ("2", 2.0),
26
+ (" 250 ", 250.0),
27
+ ],
28
+ )
29
+ def test_parse_cpu_quantity_valid(raw: str, expected: float) -> None:
30
+ assert parse_cpu_quantity(raw) == pytest.approx(expected)
31
+
32
+
33
+ @pytest.mark.parametrize("raw", ["", "m", " m "])
34
+ def test_parse_cpu_quantity_invalid(raw: str) -> None:
35
+ with pytest.raises(ValueError):
36
+ parse_cpu_quantity(raw)
37
+
38
+
39
+ @pytest.mark.parametrize(
40
+ "raw,expected",
41
+ [
42
+ ("256Mi", float(256 * 1024**2)),
43
+ ("1Gi", float(1024**3)),
44
+ ("2", 2.0),
45
+ ],
46
+ )
47
+ def test_parse_memory_quantity_valid(raw: str, expected: float) -> None:
48
+ assert parse_memory_quantity(raw) == pytest.approx(expected)
49
+
50
+
51
+ @pytest.mark.parametrize("raw", ["", "Mi", " Gi "])
52
+ def test_parse_memory_quantity_invalid(raw: str) -> None:
53
+ with pytest.raises(ValueError):
54
+ parse_memory_quantity(raw)
55
+
56
+
57
+ def test_validate_resource_limits_accepts_valid_pairs() -> None:
58
+ validate_resource_limits("250m", "500m", "256Mi", "512Mi")
59
+
60
+
61
+ def test_validate_resource_limits_rejects_lower_limits() -> None:
62
+ with pytest.raises(ValueError):
63
+ validate_resource_limits("500m", "250m", None, None)
64
+
65
+
66
+ def test_validate_resource_limits_rejects_invalid_quantity() -> None:
67
+ with pytest.raises(ValueError):
68
+ validate_resource_limits("abc", "1", None, None)
69
+
70
+
71
+ def test_load_env_variables_reads_env_file(tmp_path: Path) -> None:
72
+ env_dir = tmp_path / "app"
73
+ env_dir.mkdir()
74
+ env_file = env_dir / ".env"
75
+ env_file.write_text("VAR1=1\nVAR2=two\n")
76
+
77
+ env_vars = load_env_variables(str(env_dir))
78
+
79
+ assert {"name": "VAR1", "value": "1"} in env_vars
80
+ assert {"name": "VAR2", "value": "two"} in env_vars
81
+
82
+
83
+ def test_ensure_pvc_exists_skips_when_present() -> None:
84
+ core_v1 = MagicMock()
85
+ core_v1.read_namespaced_persistent_volume_claim.return_value = object()
86
+
87
+ ensure_pvc_exists(core_v1, "test-ns", "test-pvc", "5Gi")
88
+
89
+ core_v1.create_namespaced_persistent_volume_claim.assert_not_called()
90
+
91
+
92
+ def test_ensure_pvc_exists_creates_when_missing() -> None:
93
+ core_v1 = MagicMock()
94
+ core_v1.read_namespaced_persistent_volume_claim.side_effect = ApiException(
95
+ status=404
96
+ )
97
+
98
+ ensure_pvc_exists(
99
+ core_v1,
100
+ namespace="test-ns",
101
+ pvc_name="test-pvc",
102
+ storage_size="10Gi",
103
+ storage_class="fast",
104
+ )
105
+
106
+ call_args = core_v1.create_namespaced_persistent_volume_claim.call_args
107
+ assert call_args is not None
108
+ args, kwargs = call_args
109
+ assert kwargs == {}
110
+ assert args[0] == "test-ns"
111
+ body = args[1]
112
+ assert body["metadata"]["name"] == "test-pvc"
113
+ assert body["spec"]["accessModes"] == ["ReadWriteOnce"]
114
+ assert body["spec"]["resources"]["requests"]["storage"] == "10Gi"
115
+ assert body["spec"]["storageClassName"] == "fast"
116
+
117
+
118
+ def test_cluster_type_detects_aws_by_provider(monkeypatch: MonkeyPatch) -> None:
119
+ class Node:
120
+ def __init__(self, provider_id: str) -> None:
121
+ self.spec = SimpleNamespace(provider_id=provider_id)
122
+ self.metadata = SimpleNamespace(labels={})
123
+
124
+ class Response:
125
+ def __init__(self) -> None:
126
+ self.items = [Node("aws://12345")] # type: ignore[arg-type]
127
+
128
+ class FakeApi:
129
+ def list_node(self) -> Response:
130
+ return Response()
131
+
132
+ monkeypatch.setattr(utils.client, "CoreV1Api", lambda: FakeApi())
133
+
134
+ assert utils.cluster_type() == "aws"
135
+
136
+
137
+ def test_create_tarball_captures_files(tmp_path: Path) -> None:
138
+ source_dir = tmp_path / "src"
139
+ source_dir.mkdir()
140
+ file_path = source_dir / "hello.txt"
141
+ file_path.write_text("hello")
142
+ tar_path = tmp_path / "archive.tar.gz"
143
+
144
+ create_tarball(str(source_dir), str(tar_path))
145
+
146
+ assert tar_path.exists()
147
+ with tarfile.open(tar_path, "r:gz") as tar:
148
+ member_names = tar.getnames()
149
+ assert "./hello.txt" in member_names
150
+
151
+
152
+ def test_create_tarball_missing_source(tmp_path: Path) -> None:
153
+ tar_path = tmp_path / "archive.tar.gz"
154
+
155
+ with pytest.raises(FileNotFoundError):
156
+ create_tarball(str(tmp_path / "missing"), str(tar_path))