diff --git a/pyproject.toml b/pyproject.toml index 53819580..81de9772 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "uv_build" [project] name = "agentarts-sdk" -version = "0.1.3" +version = "0.1.3dev2026070701" description = "Huawei Cloud AgentArts SDK - Build, deploy and manage AI agents with cloud capabilities" readme = "README.md" license = {file = "LICENSE"} diff --git a/src/agentarts/sdk/memory/async_session.py b/src/agentarts/sdk/memory/async_session.py index 91b76524..9eda9204 100644 --- a/src/agentarts/sdk/memory/async_session.py +++ b/src/agentarts/sdk/memory/async_session.py @@ -92,7 +92,7 @@ async def _ensure_initialized(self) -> None: if self._pending_session_id is None: session_config = SessionCreateRequest(actor_id=self.actor_id) - session_info = await self._async_data_plane.create_memory_session(self.space_id, session_config.to_dict()) + session_info = await self._async_data_plane.create_memory_session(self.space_id, session_config) self.session_id = session_info.id if not self.session_id: msg = f"Failed to create session: {session_info}" @@ -191,7 +191,7 @@ async def get_message(self, message_id: str) -> MessageInfo: """Get a specific message - identical to sync version.""" await self._ensure_initialized() logger.info(f"Getting message: {message_id}") - return await self._async_data_plane.get_message(self.space_id, self.session_id, message_id) + return await self._async_data_plane.get_message(message_id, self.space_id, self.session_id) async def search_memories( self, diff --git a/src/agentarts/sdk/memory/session.py b/src/agentarts/sdk/memory/session.py index d4b08d69..f3b9ed5a 100644 --- a/src/agentarts/sdk/memory/session.py +++ b/src/agentarts/sdk/memory/session.py @@ -129,7 +129,7 @@ def __init__( # If session_id is not provided, call backend API to create a new session if session_id is None: session_config = SessionCreateRequest(actor_id=actor_id) - session_info = self._data_plane.create_memory_session(space_id, session_config.to_dict()) + session_info = self._data_plane.create_memory_session(space_id, session_config) self.session_id = session_info.id if not self.session_id: msg = f"Failed to create session: {session_info}" @@ -333,7 +333,7 @@ def get_message(self, message_id: str) -> MessageInfo: Requires HUAWEICLOUD_SDK_MEMORY_API_KEY environment variable to be set """ logger.info(f"Getting message: {message_id}") - return self._data_plane.get_message(self.space_id, self.session_id, message_id) + return self._data_plane.get_message(message_id, self.space_id, self.session_id) def search_memories( self, diff --git a/src/agentarts/sdk/service/http_client.py b/src/agentarts/sdk/service/http_client.py index 68e1a1b8..87333ad8 100644 --- a/src/agentarts/sdk/service/http_client.py +++ b/src/agentarts/sdk/service/http_client.py @@ -12,6 +12,7 @@ ``iter_lines()`` or ``iter_bytes()`` to consume the body incrementally. """ +import logging from collections.abc import Iterator from dataclasses import dataclass, field from enum import Enum @@ -24,6 +25,8 @@ from agentarts.sdk.utils.signer import SDKSigner from agentarts.sdk.utils.signer_v11 import V11Signer +logger = logging.getLogger(__name__) + _STREAM_CONTENT_TYPES = {"text/event-stream", "application/x-ndjson"} @@ -262,6 +265,14 @@ def _sign_request_v11(self, method: str, full_url: str, **kwargs) -> dict: headers=headers ) + logger.debug( + "V11 request: %s %s params=%s content-type=%s", + method, + full_url, + query_params, + headers.get("Content-Type"), + ) + if "headers" not in kwargs or kwargs["headers"] is None: kwargs["headers"] = {} kwargs["headers"] = headers diff --git a/src/agentarts/sdk/service/runtime_client.py b/src/agentarts/sdk/service/runtime_client.py index 0b7ab3c4..abe0ffb0 100644 --- a/src/agentarts/sdk/service/runtime_client.py +++ b/src/agentarts/sdk/service/runtime_client.py @@ -267,6 +267,7 @@ def create_agent( agent_gateway_id: str | None = None, invoke_config: dict | None = None, observability_config: dict | None = None, + storage_config: dict | None = None, tags_config: list[dict] | None = None, **extra: Any, ) -> dict[str, Any]: @@ -284,6 +285,7 @@ def create_agent( agent_gateway_id: ID of the agent gateway to attach. invoke_config: Invocation-related configuration. observability_config: Observability (tracing, metrics) configuration. + storage_config: Storage (e.g. SFS Turbo) configuration. tags_config: Tags as list of {"key": "K", "value": "V"} dicts. **extra: Additional fields forwarded to the API. @@ -312,6 +314,8 @@ def create_agent( payload["invoke_config"] = invoke_config if observability_config is not None: payload["observability"] = observability_config + if storage_config is not None: + payload["storage_config"] = storage_config if tags_config is not None: payload["tags"] = tags_config @@ -329,6 +333,7 @@ def update_agent( agent_gateway_id: str | None = None, invoke_config: dict | None = None, observability_config: dict | None = None, + storage_config: dict | None = None, tags_config: list[dict] | None = None, **extra: Any, ) -> dict[str, Any]: @@ -345,6 +350,7 @@ def update_agent( agent_gateway_id: ID of the agent gateway to attach. invoke_config: Invocation-related configuration. observability_config: Observability (tracing, metrics) configuration. + storage_config: Storage (e.g. SFS Turbo) configuration. tags_config: Tags as list of {"key": "K", "value": "V"} dicts. **extra: Additional fields forwarded to the API. @@ -371,6 +377,8 @@ def update_agent( payload["invoke_config"] = invoke_config if observability_config is not None: payload["observability"] = observability_config + if storage_config is not None: + payload["storage_config"] = storage_config if tags_config is not None: payload["tags"] = tags_config @@ -389,6 +397,7 @@ def create_or_update_agent( agent_gateway_id: str | None = None, invoke_config: dict | None = None, observability_config: dict | None = None, + storage_config: dict | None = None, tags_config: list[dict] | None = None, **extra: Any, ) -> dict[str, Any]: @@ -410,6 +419,7 @@ def create_or_update_agent( agent_gateway_id: ID of the agent gateway to attach. invoke_config: Invocation-related configuration. observability_config: Observability (tracing, metrics) configuration. + storage_config: Storage (e.g. SFS Turbo) configuration. tags_config: Tags as list of {"key": "K", "value": "V"} dicts. **extra: Additional fields forwarded to the API. @@ -432,6 +442,7 @@ def create_or_update_agent( agent_gateway_id=agent_gateway_id, invoke_config=invoke_config, observability_config=observability_config, + storage_config=storage_config, tags_config=tags_config, **extra, ) @@ -448,6 +459,7 @@ def create_or_update_agent( agent_gateway_id=agent_gateway_id, invoke_config=invoke_config, observability_config=observability_config, + storage_config=storage_config, tags_config=tags_config, **extra, ) @@ -742,7 +754,8 @@ def exec_command( bearer_token: Optional bearer token for authentication. endpoint: Optional endpoint name. user_id: Optional user ID for OAuth2 outbound credentials. - timeout: Request timeout in seconds. + timeout: Command execution timeout in seconds (sent in the request body). + The HTTP client timeout is set to timeout + 60s to allow for overhead. Returns: dict for normal mode, Iterator[str] for chunked mode (ndjson lines). @@ -765,14 +778,14 @@ def exec_command( if endpoint: params["endpoint"] = endpoint - payload = {"command": command} + payload = {"command": command, "timeout": timeout} result = self._data( "POST", path, json=payload, params=params if params else None, headers=headers, - timeout=timeout, + timeout=timeout + 60, ) if not result.success: diff --git a/src/agentarts/sdk/utils/signer_v11.py b/src/agentarts/sdk/utils/signer_v11.py index 8a220abb..f79946de 100644 --- a/src/agentarts/sdk/utils/signer_v11.py +++ b/src/agentarts/sdk/utils/signer_v11.py @@ -4,6 +4,7 @@ import hashlib import hmac +import logging from datetime import datetime, timezone from urllib.parse import quote, unquote @@ -12,6 +13,8 @@ DATE_FORMAT = "%Y%m%dT%H%M%SZ" ALGORITHM = "V11-HMAC-SHA256" +logger = logging.getLogger(__name__) + class V11Signer: """V11-HMAC-SHA256 signer for Huawei Cloud API requests.""" @@ -85,9 +88,13 @@ def _canonical_query_string(self, query_params: dict | None) -> str: if isinstance(value, list): sorted_values = sorted(str(v) for v in value) for v in sorted_values: - arr.append(f"{ke}={self._urlencode(v)}") + # Values keep '/' unencoded: the data-plane gateway decodes + # the wire query and treats '/' as safe when re-canonicalising, + # so the signature must match that form (e.g. upload's `path` + # value like /home/user/test.txt). Keys never contain '/'. + arr.append(f"{ke}={quote(str(v), safe='~/')}") else: - arr.append(f"{ke}={self._urlencode(str(value))}") + arr.append(f"{ke}={quote(str(value), safe='~/')}") return "&".join(arr) def _canonical_headers(self, headers: dict[str, str], signed_headers: list[str]) -> str: @@ -175,6 +182,18 @@ def sign( signature = self._sign_string_to_sign(real_use_secret, string_to_sign) auth_value = self._get_auth_header_value(signed_headers, signature) + logger.debug( + "V11 sign: method=%s path=%s query=%s signed_headers=%s\n" + "canonical_request:\n%s\nstring_to_sign:\n%s\nauthorization=%s", + method, + path, + self._canonical_query_string(query_params), + ";".join(signed_headers), + canonical_request, + string_to_sign, + auth_value, + ) + headers["Authorization"] = auth_value return headers diff --git a/src/agentarts/toolkit/cli/runtime/exec_command.py b/src/agentarts/toolkit/cli/runtime/exec_command.py index 3ded21af..1e9497c3 100644 --- a/src/agentarts/toolkit/cli/runtime/exec_command.py +++ b/src/agentarts/toolkit/cli/runtime/exec_command.py @@ -13,7 +13,7 @@ console = Console() DEFAULT_TIMEOUT = 60 -MAX_TIMEOUT = 300 +MAX_TIMEOUT = 3600 def validate_timeout(timeout: int) -> int: diff --git a/src/agentarts/toolkit/operations/runtime/config.py b/src/agentarts/toolkit/operations/runtime/config.py index 051c753f..e04a1339 100644 --- a/src/agentarts/toolkit/operations/runtime/config.py +++ b/src/agentarts/toolkit/operations/runtime/config.py @@ -11,6 +11,7 @@ AgentArtsConfigList, BaseConfig, SWRConfig, + detect_arch, ) from agentarts.toolkit.utils.swr_org import generate_swr_org_name @@ -230,6 +231,7 @@ def add_agent( agent_config = AgentArtsConfig.from_dict(existing_dict) else: detected_platform = detect_platform() + detected_arch = detect_arch() agent_config = AgentArtsConfig( base=BaseConfig( name=name, @@ -237,6 +239,7 @@ def add_agent( region=region, dependency_file=dependency_file, platform=detected_platform, + arch=detected_arch, ), swr_config=SWRConfig( organization=swr_organization, diff --git a/src/agentarts/toolkit/operations/runtime/deploy.py b/src/agentarts/toolkit/operations/runtime/deploy.py index 68973761..8c3e9182 100644 --- a/src/agentarts/toolkit/operations/runtime/deploy.py +++ b/src/agentarts/toolkit/operations/runtime/deploy.py @@ -74,6 +74,7 @@ def create_agentarts_runtime( network_config = None identity_config = None observability_config = None + storage_config = None env_vars = None tags_config = None execution_agency_name = None @@ -111,6 +112,22 @@ def create_agentarts_runtime( if runtime_cfg.observability: observability_config = runtime_cfg.observability.to_dict() + if runtime_cfg.storage_config: + sc = runtime_cfg.storage_config.to_dict() + # to_dict() returns {"sfs_turbo": [{...}]} (list) or {} (unconfigured). + # Only enforce required fields when the user explicitly opted + # into SFS Turbo by providing sfs_turbo_id. An unset/all-null + # storage_config (e.g. from `agentarts init` / `agentarts config`) + # is treated as "not configured" so deploy is never blocked. + st_list = sc.get("sfs_turbo") or [] + if st_list: + st = st_list[0] + if st.get("sfs_turbo_id"): + if not st.get("mount_path"): + msg = "storage_config.sfs_turbo.mount_path is required when sfs_turbo_id is set" + raise ValueError(msg) + storage_config = sc + if runtime_cfg.environment_variables: env_vars = [{"key": kv.key, "value": kv.value} for kv in runtime_cfg.environment_variables if kv.value] @@ -144,6 +161,7 @@ def create_agentarts_runtime( network_config=network_config, identity_config=identity_config, observability_config=observability_config, + storage_config=storage_config, env_vars=env_vars, tags_config=tags_config, execution_agency_name=execution_agency_name, diff --git a/src/agentarts/toolkit/operations/runtime/exec_command.py b/src/agentarts/toolkit/operations/runtime/exec_command.py index 162d8df9..92abdcd1 100644 --- a/src/agentarts/toolkit/operations/runtime/exec_command.py +++ b/src/agentarts/toolkit/operations/runtime/exec_command.py @@ -10,7 +10,7 @@ from agentarts.toolkit.utils.common import echo_error DEFAULT_TIMEOUT = 60 -MAX_TIMEOUT = 300 +MAX_TIMEOUT = 3600 def exec_runtime_command( @@ -38,7 +38,7 @@ def exec_runtime_command( endpoint: Optional endpoint name skip_ssl_verification: Skip SSL certificate verification user_id: Optional user ID for OAuth2 outbound credentials - timeout: Request timeout in seconds (default: 60, max: 300) + timeout: Request timeout in seconds (default: 60, max: 3600) Returns: dict for normal mode, Iterator[str] for chunked mode diff --git a/src/agentarts/toolkit/operations/runtime/init.py b/src/agentarts/toolkit/operations/runtime/init.py index 8a22f91d..f0226f5b 100644 --- a/src/agentarts/toolkit/operations/runtime/init.py +++ b/src/agentarts/toolkit/operations/runtime/init.py @@ -228,8 +228,16 @@ def create_config_file( artifact_source: url: null # Auto-generated during deploy from swr_config + swr_instance_id: null # UUID format, optional commands: [] + storage_config: + sfs_turbo: + sfs_turbo_id: null # UUID format, required when using SFS Turbo + sfs_path: null + mount_path: null # required when using SFS Turbo + read_only: false + environment_variables:{env_vars_yaml} tags: [] diff --git a/src/agentarts/toolkit/utils/runtime/config.py b/src/agentarts/toolkit/utils/runtime/config.py index 2b3af704..1b5188d7 100644 --- a/src/agentarts/toolkit/utils/runtime/config.py +++ b/src/agentarts/toolkit/utils/runtime/config.py @@ -189,6 +189,65 @@ def to_dict(self) -> dict[str, Any]: return {k: v for k, v in data.items() if v not in ([], {})} +class SfsTurboConfig(BaseModel): + """SFS Turbo storage configuration.""" + + sfs_turbo_id: str | None = Field( + default=None, + pattern=r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + description="SFS Turbo ID (UUID format, required when storage_config is set)", + ) + sfs_path: str | None = Field( + default=None, + description="SFS Turbo shared path inside the file system", + ) + mount_path: str | None = Field( + default=None, + description="Container mount path (required when storage_config is set)", + ) + read_only: bool | None = Field( + default=None, + description="Whether to mount the share read-only", + ) + + model_config = { + "extra": "allow", + } + + def to_dict(self) -> dict[str, Any]: + """Convert configuration to dictionary.""" + data = self.model_dump(mode="json", exclude_none=True) + return {k: v for k, v in data.items() if v not in ([], {})} + + +class StorageConfig(BaseModel): + """Storage configuration for the runtime.""" + + sfs_turbo: SfsTurboConfig | None = Field( + default_factory=SfsTurboConfig, + description="SFS Turbo storage configuration", + ) + + model_config = { + "extra": "allow", + } + + def to_dict(self) -> dict[str, Any]: + """Convert configuration to dictionary. + + The API expects ``sfs_turbo`` as an **array** of config objects + (not a single object). This method wraps the single config object + in a list. Returns ``{}`` when SFS is not configured (no sfs_turbo_id). + """ + st = self.sfs_turbo + if st is None or not st.sfs_turbo_id: + return {} + item = st.to_dict() + if not item: + return {} + return {"sfs_turbo": [item]} + + class NetworkConfig(BaseModel): """Network endpoint configuration.""" @@ -357,6 +416,11 @@ class ArtifactSourceConfig(BaseModel): default=None, description="URL of the artifact source", ) + swr_instance_id: str | None = Field( + default=None, + pattern=r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + description="SWR instance ID (UUID format, optional)", + ) commands: list[str] | None = Field( default_factory=list, description="Commands to run when the artifact is deployed", @@ -490,6 +554,10 @@ class AgentArtsRuntimeConfig(BaseModel): default_factory=ArtifactSourceConfig, description="Artifact source configuration", ) + storage_config: StorageConfig | None = Field( + default_factory=StorageConfig, + description="Storage configuration", + ) environment_variables: list[KeyValuePair] | None = Field( default_factory=list, description="Environment variables configuration", @@ -570,7 +638,7 @@ def order_dict(d: dict[str, Any], key_order: list[str]) -> dict[str, Any]: if "runtime" in ordered_agents[agent_name]: ordered_agents[agent_name]["runtime"] = order_dict( ordered_agents[agent_name]["runtime"], - ["arch", "agent_gateway_id", "agent_id", "execution_agency_name", "invoke_config", "network_config", "identity_configuration", "observability", "artifact_source", "environment_variables", "tags"] + ["arch", "agent_gateway_id", "agent_id", "execution_agency_name", "invoke_config", "network_config", "identity_configuration", "observability", "artifact_source", "storage_config", "environment_variables", "tags"] ) ordered_data["agents"] = ordered_agents @@ -649,7 +717,7 @@ def order_dict(d: dict[str, Any], key_order: list[str]) -> dict[str, Any]: if "runtime" in ordered_agents[agent_name]: ordered_agents[agent_name]["runtime"] = order_dict( ordered_agents[agent_name]["runtime"], - ["arch", "agent_gateway_id", "agent_id", "execution_agency_name", "invoke_config", "network_config", "identity_configuration", "observability", "artifact_source", "environment_variables", "tags"] + ["arch", "agent_gateway_id", "agent_id", "execution_agency_name", "invoke_config", "network_config", "identity_configuration", "observability", "artifact_source", "storage_config", "environment_variables", "tags"] ) ordered_data["agents"] = ordered_agents diff --git a/tests/unit/sdk/memory/test_async_memory_client.py b/tests/unit/sdk/memory/test_async_memory_client.py index 8b557908..5c0f550a 100644 --- a/tests/unit/sdk/memory/test_async_memory_client.py +++ b/tests/unit/sdk/memory/test_async_memory_client.py @@ -12,6 +12,7 @@ MessageBatchResponse, MessageInfo, MessageListResponse, + SessionCreateRequest, SessionInfo, TextMessage, ) @@ -249,6 +250,12 @@ async def test_session_auto_create(self): assert session.session_id == "new-session-123" mock_data_plane.create_memory_session.assert_called_once() + # Regression: session layer must pass a SessionCreateRequest object, not a + # pre-converted dict (the data plane calls .to_dict() internally). + call_args = mock_data_plane.create_memory_session.call_args.args + assert call_args[0] == "space-123" + assert isinstance(call_args[1], SessionCreateRequest) + assert call_args[1].actor_id == "user-1" @pytest.mark.asyncio async def test_session_add_messages(self): @@ -302,6 +309,29 @@ async def test_session_factory_method(self): assert session.space_id == "space-123" assert session.actor_id == "user-1" + @pytest.mark.asyncio + async def test_session_get_message_arg_order(self): + # Regression: get_message must forward args as (message_id, space_id, session_id) + # to match the data plane signature, not (space_id, session_id, message_id). + mock_data_plane = MagicMock() + mock_data_plane.create_memory_session = AsyncMock( + return_value=SessionInfo(id="session-456", space_id="space-123", actor_id="user-1") + ) + mock_data_plane.get_message = AsyncMock(return_value=MessageInfo(id="msg-1", session_id="session-456", seq=1)) + mock_data_plane.close = AsyncMock() + + session = AsyncMemorySession( + space_id="space-123", + actor_id="user-1", + session_id="session-456", + api_key="test-key", + ) + session._async_data_plane = mock_data_plane + + await session.get_message("msg-1") + + mock_data_plane.get_message.assert_called_once_with("msg-1", "space-123", "session-456") + class TestAsyncDataPlaneValidation: """Tests for AsyncDataPlane validation.""" diff --git a/tests/unit/sdk/memory/test_memory.py b/tests/unit/sdk/memory/test_memory.py index a6b5bd6c..aed3012b 100644 --- a/tests/unit/sdk/memory/test_memory.py +++ b/tests/unit/sdk/memory/test_memory.py @@ -212,3 +212,49 @@ def test_close_method_chain(self): mock_control_plane.close.assert_called_once() mock_session_close.assert_called_once() + + +class TestMemorySession: + """Tests for MemorySession (sync).""" + + def test_session_auto_create_passes_request_object(self): + """Regression: __init__ must pass a SessionCreateRequest object to the data + plane, not a pre-converted dict (the data plane calls .to_dict() internally).""" + from agentarts.sdk.memory import MemorySession, SessionCreateRequest, SessionInfo + + with patch( + "agentarts.sdk.memory.session._DataPlane.create_memory_session", + return_value=SessionInfo(id="new-session-123", space_id="space-123", actor_id="user-1"), + ) as mock_create: + session = MemorySession( + space_id="space-123", + actor_id="user-1", + api_key="test-key", + ) + + assert session.session_id == "new-session-123" + mock_create.assert_called_once() + call_args = mock_create.call_args.args + assert call_args[0] == "space-123" + assert isinstance(call_args[1], SessionCreateRequest) + assert call_args[1].actor_id == "user-1" + + def test_session_get_message_arg_order(self): + """Regression: get_message must forward args as (message_id, space_id, + session_id) to match the data plane signature.""" + from agentarts.sdk.memory import MemorySession, MessageInfo + + session = MemorySession( + space_id="space-123", + actor_id="user-1", + session_id="session-456", + api_key="test-key", + ) + + mock_data_plane = MagicMock() + mock_data_plane.get_message.return_value = MessageInfo(id="msg-1", session_id="session-456", seq=1) + session._data_plane = mock_data_plane + + session.get_message("msg-1") + + mock_data_plane.get_message.assert_called_once_with("msg-1", "space-123", "session-456") diff --git a/tests/unit/sdk/utils/test_signer_v11.py b/tests/unit/sdk/utils/test_signer_v11.py new file mode 100644 index 00000000..52ad87c8 --- /dev/null +++ b/tests/unit/sdk/utils/test_signer_v11.py @@ -0,0 +1,41 @@ +"""Unit tests for the V11-HMAC-SHA256 signer.""" + +from agentarts.sdk.utils.signer_v11 import V11Signer + + +class TestCanonicalQueryString: + """Tests for V11Signer._canonical_query_string.""" + + def test_empty_when_no_params(self): + assert V11Signer("ak", "sk", "cn-southwest-2")._canonical_query_string(None) == "" + assert V11Signer("ak", "sk", "cn-southwest-2")._canonical_query_string({}) == "" + + def test_keeps_slash_unencoded(self): + """Regression: query values containing '/' (e.g. upload's `path`) must + keep '/' unencoded to match the data-plane gateway's canonicalisation.""" + signer = V11Signer("ak", "sk", "cn-southwest-2") + result = signer._canonical_query_string({"path": "/home/user/test.txt"}) + assert result == "path=/home/user/test.txt" + + def test_sorts_keys(self): + signer = V11Signer("ak", "sk", "cn-southwest-2") + result = signer._canonical_query_string( + {"path": "/home/user/test.txt", "user_id": 1000, "file_mode": "0644"} + ) + assert result == "file_mode=0644&path=/home/user/test.txt&user_id=1000" + + def test_simple_value_unchanged(self): + """Values without '/' are unaffected by the safe-set change.""" + signer = V11Signer("ak", "sk", "cn-southwest-2") + assert signer._canonical_query_string({"endpoint": "stream"}) == "endpoint=stream" + + def test_list_value_keeps_slash_unencoded(self): + signer = V11Signer("ak", "sk", "cn-southwest-2") + result = signer._canonical_query_string({"k": ["/c", "/a/b"]}) + assert result == "k=/a/b&k=/c" + + def test_space_and_special_encoded(self): + """Non-'/' special chars are still percent-encoded.""" + signer = V11Signer("ak", "sk", "cn-southwest-2") + result = signer._canonical_query_string({"q": "a b&c"}) + assert result == "q=a%20b%26c" diff --git a/tests/unit/toolkit/operations/runtime/test_config.py b/tests/unit/toolkit/operations/runtime/test_config.py index def0f2dc..1fa4a8f2 100644 --- a/tests/unit/toolkit/operations/runtime/test_config.py +++ b/tests/unit/toolkit/operations/runtime/test_config.py @@ -1,9 +1,11 @@ """Unit tests for config.py module""" +from unittest.mock import patch from agentarts.toolkit.operations.runtime.config import ( CONFIG_FILE_NAME, add_agent, + detect_arch, detect_dependency_file, detect_platform, get_agent, @@ -19,10 +21,14 @@ from agentarts.toolkit.utils.runtime.config import ( AgentArtsConfig, AgentArtsConfigList, + ArchType, + ArtifactSourceConfig, AuthConfig, BaseConfig, CustomJWTAuthConfig, InboundIdentityConfig, + SfsTurboConfig, + StorageConfig, ) @@ -35,6 +41,39 @@ def test_returns_valid_platform_format(self): assert result in ("linux/amd64", "linux/arm64") +class TestDetectArch: + """Tests for detect_arch() function.""" + + def test_returns_valid_arch_type(self): + """Returns a valid ArchType value.""" + result = detect_arch() + assert result in (ArchType.X86_64, ArchType.ARM64) + + @patch("platform.machine", return_value="arm64") + def test_detects_arm64_for_arm64_machine(self, mock_machine): + """Returns ARM64 when platform.machine() is arm64.""" + result = detect_arch() + assert result == ArchType.ARM64 + + @patch("platform.machine", return_value="aarch64") + def test_detects_arm64_for_aarch64_machine(self, mock_machine): + """Returns ARM64 when platform.machine() is aarch64.""" + result = detect_arch() + assert result == ArchType.ARM64 + + @patch("platform.machine", return_value="x86_64") + def test_detects_x86_64_for_x86_64_machine(self, mock_machine): + """Returns X86_64 when platform.machine() is x86_64.""" + result = detect_arch() + assert result == ArchType.X86_64 + + @patch("platform.machine", return_value="AMD64") + def test_detects_x86_64_for_uppercase_amd64(self, mock_machine): + """Returns X86_64 for non-arm machine strings (case-insensitive).""" + result = detect_arch() + assert result == ArchType.X86_64 + + class TestDetectDependencyFile: """Tests for detect_dependency_file() function.""" @@ -171,6 +210,19 @@ def test_adds_agent_with_swr_config(self, tmp_path, monkeypatch): assert agent.swr_config.organization == "test-org" assert agent.swr_config.repository == "test-repo" + def test_adds_agent_includes_storage_config_block(self, tmp_path, monkeypatch): + """Config-generated YAML includes the storage_config / sfs_turbo block.""" + monkeypatch.chdir(tmp_path) + + result = add_agent(name="test-agent", entrypoint="agent:app", region="cn-north-4") + assert result is True + + content = (tmp_path / CONFIG_FILE_NAME).read_text() + assert "storage_config:" in content + assert "sfs_turbo:" in content + assert "sfs_turbo_id:" in content + assert "mount_path:" in content + def test_uses_default_swr_repo_as_agent_prefix(self, tmp_path, monkeypatch): """Uses agent_{name} as default SWR repository.""" monkeypatch.chdir(tmp_path) @@ -213,6 +265,28 @@ def test_updates_existing_agent(self, tmp_path, monkeypatch): agent = config.get_agent("test-agent") assert agent.base.entrypoint == "agent:new_app" + @patch("platform.machine", return_value="arm64") + def test_sets_arch_from_detected_environment_arm64(self, mock_machine, tmp_path, monkeypatch): + """Sets arch to arm64 when running on an arm64 machine.""" + monkeypatch.chdir(tmp_path) + + add_agent(name="test-agent", entrypoint="agent:app") + + config = load_config() + agent = config.get_agent("test-agent") + assert agent.base.arch == ArchType.ARM64 + + @patch("platform.machine", return_value="x86_64") + def test_sets_arch_from_detected_environment_x86_64(self, mock_machine, tmp_path, monkeypatch): + """Sets arch to x86_64 when running on an x86_64 machine.""" + monkeypatch.chdir(tmp_path) + + add_agent(name="test-agent", entrypoint="agent:app") + + config = load_config() + agent = config.get_agent("test-agent") + assert agent.base.arch == ArchType.X86_64 + class TestRemoveAgent: """Tests for remove_agent() function.""" @@ -435,3 +509,114 @@ def test_includes_authorizer_configuration_when_not_empty(self): result = config.to_dict() assert "authorizer_configuration" in result + + +class TestStorageConfig: + """Tests for StorageConfig / SfsTurboConfig models.""" + + def test_sfs_turbo_to_dict_excludes_none(self): + """to_dict excludes None fields (read_only only when explicitly set).""" + cfg = SfsTurboConfig( + sfs_turbo_id="12345678-1234-1234-1234-123456789012", + mount_path="/data", + ) + result = cfg.to_dict() + + assert result == { + "sfs_turbo_id": "12345678-1234-1234-1234-123456789012", + "mount_path": "/data", + } + + def test_sfs_turbo_empty_to_dict_is_empty(self): + """An all-default SfsTurboConfig (no sfs_turbo_id) serializes to {}.""" + assert SfsTurboConfig().to_dict() == {} + + def test_storage_config_to_dict_nested(self): + """StorageConfig serializes sfs_turbo as a list (API expects array).""" + cfg = StorageConfig( + sfs_turbo=SfsTurboConfig( + sfs_turbo_id="12345678-1234-1234-1234-123456789012", + sfs_path="/share/sub", + mount_path="/data", + read_only=True, + ) + ) + result = cfg.to_dict() + + assert result == { + "sfs_turbo": [{ + "sfs_turbo_id": "12345678-1234-1234-1234-123456789012", + "sfs_path": "/share/sub", + "mount_path": "/data", + "read_only": True, + }] + } + + def test_storage_config_default_to_dict_is_empty(self): + """A default StorageConfig (no sfs_turbo_id) serializes to {}.""" + assert StorageConfig().to_dict() == {} + + def test_invalid_sfs_turbo_id_rejected(self): + """A non-UUID sfs_turbo_id is rejected by validation.""" + import pytest + from pydantic import ValidationError + + with pytest.raises(ValidationError): + SfsTurboConfig(sfs_turbo_id="not-a-uuid", mount_path="/data") + + def test_runtime_config_has_storage_config_field(self): + """AgentArtsRuntimeConfig exposes a storage_config field with a default + nested SfsTurboConfig (mirrors network_config/vpc_config), so the block + is present in config-generated YAML even when unset.""" + from agentarts.toolkit.utils.runtime.config import AgentArtsRuntimeConfig + + runtime = AgentArtsRuntimeConfig() + assert runtime.storage_config is not None + assert runtime.storage_config.sfs_turbo is not None + assert runtime.storage_config.sfs_turbo.sfs_turbo_id is None + assert runtime.storage_config.sfs_turbo.mount_path is None + assert runtime.storage_config.sfs_turbo.read_only is None + + +class TestArtifactSourceSwrInstanceId: + """Tests for ArtifactSourceConfig.swr_instance_id.""" + + def test_default_excluded_from_to_dict(self): + """An unset swr_instance_id is excluded (not sent to the API).""" + cfg = ArtifactSourceConfig(url="swr/x:latest") + result = cfg.to_dict() + assert "swr_instance_id" not in result + assert result["url"] == "swr/x:latest" + + def test_included_when_set(self): + """A set swr_instance_id flows into the artifact_source payload.""" + uid = "12345678-1234-1234-1234-123456789012" + cfg = ArtifactSourceConfig(url="swr/x:latest", swr_instance_id=uid) + assert cfg.to_dict()["swr_instance_id"] == uid + + def test_invalid_uuid_rejected(self): + """A non-UUID swr_instance_id is rejected by validation.""" + import pytest + from pydantic import ValidationError + + with pytest.raises(ValidationError): + ArtifactSourceConfig(url="swr/x:latest", swr_instance_id="not-a-uuid") + + def test_init_scaffold_and_config_both_include_field(self, tmp_path, monkeypatch): + """Both `init` and `config` generated YAML include swr_instance_id.""" + from agentarts.toolkit.operations.runtime.init import create_config_file + + # init path + d = tmp_path / "init-proj" + d.mkdir() + create_config_file(project_path=d, name="a", template="basic") + init_txt = (d / ".agentarts_config.yaml").read_text() + assert "swr_instance_id:" in init_txt + + # config (add_agent) path + cfg_dir = tmp_path / "cfg-proj" + cfg_dir.mkdir() + monkeypatch.chdir(cfg_dir) + add_agent(name="c", entrypoint="agent:app", region="cn-southwest-2") + cfg_txt = (cfg_dir / CONFIG_FILE_NAME).read_text() + assert "swr_instance_id:" in cfg_txt diff --git a/tests/unit/toolkit/operations/runtime/test_deploy.py b/tests/unit/toolkit/operations/runtime/test_deploy.py index 12b3c39e..6ea8ac87 100644 --- a/tests/unit/toolkit/operations/runtime/test_deploy.py +++ b/tests/unit/toolkit/operations/runtime/test_deploy.py @@ -7,6 +7,12 @@ create_agentarts_runtime, deploy_project, ) +from agentarts.toolkit.utils.runtime.config import ( + AgentArtsConfig, + AgentArtsRuntimeConfig, + SfsTurboConfig, + StorageConfig, +) class TestDeployMode: @@ -88,6 +94,162 @@ def test_uses_custom_description(self, mock_client, tmp_path, monkeypatch): call_args = mock_client_instance.create_or_update_agent.call_args assert call_args.kwargs["description"] == "My custom description" + @patch("agentarts.toolkit.operations.runtime.deploy.RuntimeClient") + def test_storage_config_absent_passes_none(self, mock_client, tmp_path, monkeypatch): + """storage_config kwarg is None when not configured.""" + monkeypatch.chdir(tmp_path) + + mock_client_instance = MagicMock() + mock_client.return_value = mock_client_instance + mock_client_instance.create_or_update_agent.return_value = { + "id": "agent-123", + "latest_version": "v1", + } + + create_agentarts_runtime( + agent_name="test-agent", + swr_image="swr.cn-north-4.myhuaweicloud.com/org/repo:latest", + region="cn-north-4", + ) + + call_args = mock_client_instance.create_or_update_agent.call_args + assert call_args.kwargs["storage_config"] is None + + @patch("agentarts.toolkit.operations.runtime.deploy.RuntimeClient") + def test_storage_config_null_placeholder_does_not_break_deploy(self, mock_client, tmp_path, monkeypatch): + """An all-null placeholder storage_config (as written by `agentarts init`) + is treated as unset so a freshly initialized agent still deploys.""" + monkeypatch.chdir(tmp_path) + + mock_client_instance = MagicMock() + mock_client.return_value = mock_client_instance + mock_client_instance.create_or_update_agent.return_value = { + "id": "agent-123", + "latest_version": "v1", + } + + # Mirrors the null block written by create_config_file in init.py + agent_config = AgentArtsConfig( + runtime=AgentArtsRuntimeConfig( + storage_config=StorageConfig(sfs_turbo=SfsTurboConfig()) + ) + ) + + result = create_agentarts_runtime( + agent_name="test-agent", + swr_image="swr.cn-north-4.myhuaweicloud.com/org/repo:latest", + region="cn-north-4", + agent_config=agent_config, + ) + + assert result is not None + call_args = mock_client_instance.create_or_update_agent.call_args + assert call_args.kwargs["storage_config"] is None + + @patch("agentarts.toolkit.operations.runtime.deploy.RuntimeClient") + def test_storage_config_partial_without_id_not_blocked(self, mock_client, tmp_path, monkeypatch): + """Requirement: deploy must NOT block when the user hasn't configured + storage. A storage_config with only sfs_path/mount_path but no + sfs_turbo_id is treated as unset (no blocking, nothing sent).""" + monkeypatch.chdir(tmp_path) + + mock_client_instance = MagicMock() + mock_client.return_value = mock_client_instance + mock_client_instance.create_or_update_agent.return_value = { + "id": "agent-123", + "latest_version": "v1", + } + + agent_config = AgentArtsConfig( + runtime=AgentArtsRuntimeConfig( + storage_config=StorageConfig( + sfs_turbo=SfsTurboConfig(sfs_path="/share/sub", mount_path="/data") + ) + ) + ) + + result = create_agentarts_runtime( + agent_name="test-agent", + swr_image="swr.cn-north-4.myhuaweicloud.com/org/repo:latest", + region="cn-north-4", + agent_config=agent_config, + ) + + assert result is not None + call_args = mock_client_instance.create_or_update_agent.call_args + assert call_args.kwargs["storage_config"] is None + + @patch("agentarts.toolkit.operations.runtime.deploy.RuntimeClient") + def test_storage_config_forwarded(self, mock_client, tmp_path, monkeypatch): + """A populated storage_config is forwarded to create_or_update_agent.""" + monkeypatch.chdir(tmp_path) + + mock_client_instance = MagicMock() + mock_client.return_value = mock_client_instance + mock_client_instance.create_or_update_agent.return_value = { + "id": "agent-123", + "latest_version": "v1", + } + + agent_config = AgentArtsConfig( + runtime=AgentArtsRuntimeConfig( + storage_config=StorageConfig( + sfs_turbo=SfsTurboConfig( + sfs_turbo_id="12345678-1234-1234-1234-123456789012", + sfs_path="/share/sub", + mount_path="/data", + read_only=True, + ) + ) + ) + ) + + create_agentarts_runtime( + agent_name="test-agent", + swr_image="swr.cn-north-4.myhuaweicloud.com/org/repo:latest", + region="cn-north-4", + agent_config=agent_config, + ) + + call_args = mock_client_instance.create_or_update_agent.call_args + assert call_args.kwargs["storage_config"] == { + "sfs_turbo": [{ + "sfs_turbo_id": "12345678-1234-1234-1234-123456789012", + "sfs_path": "/share/sub", + "mount_path": "/data", + "read_only": True, + }] + } + + @patch("agentarts.toolkit.operations.runtime.deploy.RuntimeClient") + def test_storage_config_requires_mount_path_when_id_set(self, mock_client, tmp_path, monkeypatch): + """When sfs_turbo_id is set, mount_path is required (returns None).""" + monkeypatch.chdir(tmp_path) + + mock_client_instance = MagicMock() + mock_client.return_value = mock_client_instance + + agent_config = AgentArtsConfig( + runtime=AgentArtsRuntimeConfig( + storage_config=StorageConfig( + sfs_turbo=SfsTurboConfig( + sfs_turbo_id="12345678-1234-1234-1234-123456789012", + sfs_path="/share/sub", + ) + ) + ) + ) + + result = create_agentarts_runtime( + agent_name="test-agent", + swr_image="swr.cn-north-4.myhuaweicloud.com/org/repo:latest", + region="cn-north-4", + agent_config=agent_config, + ) + + assert result is None + mock_client_instance.create_or_update_agent.assert_not_called() + class TestDeployProject: """Tests for deploy_project() function.""" diff --git a/tests/unit/toolkit/operations/runtime/test_exec_command.py b/tests/unit/toolkit/operations/runtime/test_exec_command.py index 48afba52..6a0c8bc2 100644 --- a/tests/unit/toolkit/operations/runtime/test_exec_command.py +++ b/tests/unit/toolkit/operations/runtime/test_exec_command.py @@ -158,9 +158,9 @@ def test_exec_command_timeout_negative_raises_error(self): exec_runtime_command(command="ls", timeout=-10) def test_exec_command_timeout_exceeds_max_raises_error(self): - """Test that timeout exceeding max (300) raises ValueError.""" + """Test that timeout exceeding max (3600) raises ValueError.""" with pytest.raises(ValueError, match="Timeout exceeds maximum allowed value"): - exec_runtime_command(command="ls", timeout=500) + exec_runtime_command(command="ls", timeout=4000) def test_exec_command_timeout_valid_passes(self, tmp_path, monkeypatch): """Test that valid timeout passes validation.""" diff --git a/tests/unit/toolkit/operations/runtime/test_init.py b/tests/unit/toolkit/operations/runtime/test_init.py index 79bab37b..45138501 100644 --- a/tests/unit/toolkit/operations/runtime/test_init.py +++ b/tests/unit/toolkit/operations/runtime/test_init.py @@ -158,6 +158,20 @@ def test_config_includes_region(self, tmp_path): content = (tmp_path / ".agentarts_config.yaml").read_text() assert "cn-east-3" in content + def test_config_includes_storage_config_block(self, tmp_path): + """Config includes the storage_config / sfs_turbo block.""" + create_config_file( + project_path=tmp_path, + name="test-agent", + template="basic", + ) + + content = (tmp_path / ".agentarts_config.yaml").read_text() + assert "storage_config:" in content + assert "sfs_turbo:" in content + assert "sfs_turbo_id:" in content + assert "mount_path:" in content + class TestCreateAgentFile: """Tests for create_agent_file() function."""