From ae2fd9712d5849edd83b65d073ada80cb780edcc Mon Sep 17 00:00:00 2001 From: DROWNING2003 Date: Wed, 9 Sep 2026 18:51:43 +0800 Subject: [PATCH 1/2] feat(sandbox): expose resource APIs Expose sandbox resource management and support inline Kodo credentials. Add template disk size configuration and regression coverage. --- qiniu/services/sandbox/client.py | 63 +++++++++++++-- qiniu/services/sandbox/resources.py | 12 ++- qiniu/services/sandbox/sandbox.py | 12 +++ .../test_services/test_sandbox/test_client.py | 76 +++++++++++++++++++ 4 files changed, 157 insertions(+), 6 deletions(-) diff --git a/qiniu/services/sandbox/client.py b/qiniu/services/sandbox/client.py index af140f43..1a25821a 100644 --- a/qiniu/services/sandbox/client.py +++ b/qiniu/services/sandbox/client.py @@ -65,15 +65,27 @@ def _normalize_injection(injection): def _normalize_resources(resources): if resources is None: return None - return [_to_dict(resource) for resource in resources] + normalized = [_to_dict(resource) for resource in resources] + for resource in normalized: + if not isinstance(resource, dict) or resource.get('type') != 'kodo': + continue + if (resource.get('access_key') is None) != ( + resource.get('secret_key') is None): + raise ValueError( + 'access_key and secret_key must be provided together') + if (resource.get('access_key') is not None and + (not resource.get('access_key') or + not resource.get('secret_key'))): + raise ValueError( + 'access_key and secret_key must not be empty') + return normalized def _has_kodo_resource(resources): for resource in resources or []: - if isinstance(resource, KodoResource): - return True data = _to_dict(resource) - if isinstance(data, dict) and data.get('type') == 'kodo': + if (isinstance(data, dict) and data.get('type') == 'kodo' and + (not data.get('access_key') or not data.get('secret_key'))): return True return False @@ -179,6 +191,17 @@ def _normalize_list_options(opts): return opts +def _normalize_template_create_options(opts): + body = dict(opts or {}) + disk_size_mb = _single_alias_value( + body, 'disk_size_mb', 'diskSizeMB') + body.pop('disk_size_mb', None) + body.pop('diskSizeMB', None) + if disk_size_mb is not None: + body['diskSizeMB'] = disk_size_mb + return body + + def _sandbox_api_key_from_env(): return ( os.getenv('QINIU_SANDBOX_API_KEY') or @@ -475,6 +498,32 @@ def get_sandbox_injections(self, sandbox_id): getSandboxInjections = get_sandbox_injections + def get_sandbox_resources(self, sandbox_id): + _require_sandbox_id(sandbox_id) + return self._request( + 'GET', + '/sandboxes/{0}/resources'.format(encode_path(sandbox_id)), + ) + + getSandboxResources = get_sandbox_resources + + def update_git_repository_resource_token( + self, sandbox_id, resource_id, authorization_token): + _require_sandbox_id(sandbox_id) + if not resource_id: + raise SandboxError('resource_id is required') + if not authorization_token: + raise SandboxError('authorization_token is required') + return self._request( + 'PATCH', + '/sandboxes/{0}/resources/{1}'.format( + encode_path(sandbox_id), encode_path(resource_id)), + body={'authorization_token': authorization_token}, + empty=True, + ) + + updateGitRepositoryResourceToken = update_git_repository_resource_token + def update_sandbox_injections(self, sandbox_id, injections): _require_sandbox_id(sandbox_id) if injections is None: @@ -583,7 +632,11 @@ def get_sandbox_logs(self, sandbox_id, **opts): getLogs = get_sandbox_logs def create_template(self, **opts): - return self._request('POST', '/v3/templates', body=opts) + return self._request( + 'POST', + '/v3/templates', + body=_normalize_template_create_options(opts), + ) createTemplate = create_template createTemplateV3 = create_template diff --git a/qiniu/services/sandbox/resources.py b/qiniu/services/sandbox/resources.py index 05456ff4..dab30978 100644 --- a/qiniu/services/sandbox/resources.py +++ b/qiniu/services/sandbox/resources.py @@ -21,11 +21,14 @@ def to_dict(self): class KodoResource(object): - def __init__(self, bucket, mount_path, prefix=None, read_only=None): + def __init__(self, bucket, mount_path, prefix=None, read_only=None, + access_key=None, secret_key=None): self.bucket = bucket self.mount_path = mount_path self.prefix = prefix self.read_only = read_only + self.access_key = access_key + self.secret_key = secret_key def to_dict(self): data = { @@ -37,4 +40,11 @@ def to_dict(self): data['prefix'] = self.prefix if self.read_only is not None: data['read_only'] = self.read_only + if (self.access_key is None) != (self.secret_key is None): + raise ValueError('access_key and secret_key must be provided together') + if self.access_key is not None: + if not self.access_key or not self.secret_key: + raise ValueError('access_key and secret_key must not be empty') + data['access_key'] = self.access_key + data['secret_key'] = self.secret_key return data diff --git a/qiniu/services/sandbox/sandbox.py b/qiniu/services/sandbox/sandbox.py index 6ade2857..bc02a520 100644 --- a/qiniu/services/sandbox/sandbox.py +++ b/qiniu/services/sandbox/sandbox.py @@ -258,6 +258,18 @@ def get_injections(self): getInjections = get_injections + def get_resources(self): + return self.client.get_sandbox_resources(self.sandbox_id) + + getResources = get_resources + + def update_git_repository_resource_token( + self, resource_id, authorization_token): + return self.client.update_git_repository_resource_token( + self.sandbox_id, resource_id, authorization_token) + + updateGitRepositoryResourceToken = update_git_repository_resource_token + def update_injections(self, injections): return self.client.update_sandbox_injections( self.sandbox_id, injections) diff --git a/tests/cases/test_services/test_sandbox/test_client.py b/tests/cases/test_services/test_sandbox/test_client.py index 03d9519f..9a670543 100644 --- a/tests/cases/test_services/test_sandbox/test_client.py +++ b/tests/cases/test_services/test_sandbox/test_client.py @@ -123,6 +123,52 @@ def test_client_uses_default_endpoint_and_api_key_headers(): } +def test_create_template_maps_disk_size_mb_to_api_field(): + session = RecordingSession([DummyResponse(202, { + 'templateID': 'tpl123', + 'buildID': 'build123', + })]) + client = SandboxClient(api_key='api-key', session=session) + + client.create_template(name='disk-size-test', disk_size_mb=15360) + + req = session.requests[0] + assert req.method == 'POST' + assert req.url == DEFAULT_ENDPOINT + '/v3/templates' + assert body_of(req) == { + 'name': 'disk-size-test', + 'diskSizeMB': 15360, + } + + +def test_sandbox_resource_apis_list_and_update_git_token(): + session = RecordingSession([ + DummyResponse(200, {'resources': [{ + 'type': 'github_repository', + 'resource_id': 'res123', + 'url': 'https://github.com/qiniu/python-sdk.git', + 'mount_path': '/workspace/repo', + }]}), + DummyResponse(204), + ]) + client = SandboxClient(api_key='api-key', session=session) + + resources = client.get_sandbox_resources('sbx/123') + client.update_git_repository_resource_token( + 'sbx/123', 'res/123', 'new-token') + + assert resources['resources'][0]['resource_id'] == 'res123' + assert session.requests[0].method == 'GET' + assert session.requests[0].url == ( + DEFAULT_ENDPOINT + '/sandboxes/sbx%2F123/resources') + assert session.requests[1].method == 'PATCH' + assert session.requests[1].url == ( + DEFAULT_ENDPOINT + '/sandboxes/sbx%2F123/resources/res%2F123') + assert body_of(session.requests[1]) == { + 'authorization_token': 'new-token', + } + + @pytest.mark.parametrize('status_code', [408, 500]) def test_create_sandbox_retries_retryable_status_and_reuses_idempotency_key( monkeypatch, status_code): @@ -331,6 +377,36 @@ def test_create_with_kodo_resource_uses_qiniu_signature(): }] +def test_create_with_inline_kodo_credentials_uses_api_key_auth(): + session = RecordingSession( + [DummyResponse(201, {'sandboxID': 'sbx123', 'templateID': 'base'})]) + client = SandboxClient(api_key='api-key', session=session) + + client.create_sandbox(resources=[KodoResource( + bucket='bucket', + mount_path='/mnt/bucket', + access_key='resource-ak', + secret_key='resource-sk', + )]) + + req = session.requests[0] + assert req.headers['Authorization'] == 'Bearer api-key' + assert body_of(req)['resources'][0]['access_key'] == 'resource-ak' + assert body_of(req)['resources'][0]['secret_key'] == 'resource-sk' + + +def test_create_rejects_partial_inline_kodo_credentials(): + client = SandboxClient(api_key='api-key', session=RecordingSession()) + + with pytest.raises(ValueError, match='access_key and secret_key'): + client.create_sandbox(resources=[{ + 'type': 'kodo', + 'bucket': 'bucket', + 'mount_path': '/mnt/bucket', + 'access_key': 'resource-ak', + }]) + + def test_create_with_saved_injection_rule_requires_qiniu_credentials( monkeypatch): monkeypatch.delenv('QINIU_SANDBOX_ACCESS_KEY', raising=False) From af3e658792fb19485b27e3a37a349a8760c053fb Mon Sep 17 00:00:00 2001 From: DROWNING2003 Date: Thu, 10 Sep 2026 10:54:14 +0800 Subject: [PATCH 2/2] fix(sandbox): remove unused resource import Keep the sandbox client compliant with the repository lint gate across the supported Python versions. --- qiniu/services/sandbox/client.py | 1 - 1 file changed, 1 deletion(-) diff --git a/qiniu/services/sandbox/client.py b/qiniu/services/sandbox/client.py index 1a25821a..e8a80055 100644 --- a/qiniu/services/sandbox/client.py +++ b/qiniu/services/sandbox/client.py @@ -17,7 +17,6 @@ from .constants import DEFAULT_TEMPLATE from .errors import SandboxError, TemplateBuildError -from .resources import KodoResource from .util import ( encode_path, json_dumps,