Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions python/src/phala_cloud/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@
"safe_get_available_os_images",
"update_os_image",
"safe_update_os_image",
"patch_cvm",
"safe_patch_cvm",
"confirm_cvm_patch",
"safe_confirm_cvm_patch",
"get_cvm_state",
"safe_get_cvm_state",
"watch_cvm_state",
Expand Down Expand Up @@ -401,6 +405,22 @@ def safe_update_os_image(client: Any, *args: Any, **kwargs: Any) -> Any:
return client.safe_update_os_image(*args, **kwargs)


def patch_cvm(client: Any, *args: Any, **kwargs: Any) -> Any:
return client.patch_cvm(*args, **kwargs)


def safe_patch_cvm(client: Any, *args: Any, **kwargs: Any) -> Any:
return client.safe_patch_cvm(*args, **kwargs)


def confirm_cvm_patch(client: Any, *args: Any, **kwargs: Any) -> Any:
return client.confirm_cvm_patch(*args, **kwargs)


def safe_confirm_cvm_patch(client: Any, *args: Any, **kwargs: Any) -> Any:
return client.safe_confirm_cvm_patch(*args, **kwargs)


def get_cvm_state(client: Any, *args: Any, **kwargs: Any) -> Any:
return client.get_cvm_state(*args, **kwargs)

Expand Down
152 changes: 152 additions & 0 deletions python/src/phala_cloud/full_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
from .blockchains import deploy_app_auth as _deploy_app_auth
from .client import AsyncPhalaCloud as _AsyncBase
from .client import PhalaCloud as _SyncBase
from .errors import ApiError
from .models.apps import DeviceAllowlistResponse as _DeviceAllowlistResponse
from .models.auth import CurrentUserV20251028, CurrentUserV20260121
from .models.base import CloudModel
Expand Down Expand Up @@ -203,6 +204,31 @@ class WatchCvmStateRequest(CvmIdRequest):
retry_delay: float = Field(default=5.0, ge=0, alias="retryDelay")


class PatchCvmRequest(CvmIdRequest):
docker_compose_file: str | None = None
pre_launch_script: str | None = None
allowed_envs: list[str] | None = None
public_logs: bool | None = None
public_sysinfo: bool | None = None
public_tcbinfo: bool | None = None
encrypted_env: str | None = None
user_config: str | None = None
gpus: dict[str, Any] | None = None
vcpu: int | None = None
memory: int | None = None
disk_size: int | None = None
image: str | None = None
shutdown_timeout: int | None = None
allow_force_stop: bool | None = None


class ConfirmCvmPatchRequest(CvmIdRequest):
compose_hash: str = Field(alias="composeHash")
transaction_hash: str = Field(alias="transactionHash")

model_config = ConfigDict(populate_by_name=True)


class _ExtMixin:
@staticmethod
def _loose_validate(data: Any) -> Any:
Expand Down Expand Up @@ -753,6 +779,69 @@ def safe_update_os_image(
) -> SafeResult[None]:
return self.safe(self.update_os_image, request)

def patch_cvm(self, request: PatchCvmRequest | Mapping[str, Any]) -> dict[str, Any]:
req = PatchCvmRequest.model_validate(request)
cvm_id = req.resolved
body = req.model_dump(
exclude={"id", "uuid", "app_id", "instance_id", "cvm_id", "cvmId"},
exclude_none=True,
)
try:
data = self._loose_validate(self.request("PATCH", f"/cvms/{cvm_id}", json=body))
correlation_id = ""
if isinstance(data, dict):
correlation_id = data.get("correlation_id", "")
elif data is not None:
correlation_id = str(getattr(data, "correlation_id", ""))
return {"requires_on_chain_hash": False, "correlation_id": correlation_id}
except ApiError as exc:
if exc.status_code == 465 and isinstance(exc.detail, dict):
details: dict[str, Any] = {}
for item in exc.detail.get("details") or []:
if isinstance(item, dict) and "field" in item and "value" in item:
details[item["field"]] = item["value"]
return {
"requires_on_chain_hash": True,
"compose_hash": details.get("compose_hash", ""),
"app_id": str(details.get("app_id", "")),
"device_id": details.get("device_id", ""),
"kms_info": details.get("kms_info"),
}
raise

def safe_patch_cvm(
self, request: PatchCvmRequest | Mapping[str, Any]
) -> SafeResult[dict[str, Any]]:
return self.safe(self.patch_cvm, request)

def confirm_cvm_patch(
self, request: ConfirmCvmPatchRequest | Mapping[str, Any]
) -> dict[str, Any]:
req = ConfirmCvmPatchRequest.model_validate(request)
cvm_id = req.resolved
data = self._loose_validate(
self.request(
"PATCH",
f"/cvms/{cvm_id}",
json={},
headers={
"X-Compose-Hash": req.compose_hash,
"X-Transaction-Hash": req.transaction_hash,
},
)
)
correlation_id = ""
if isinstance(data, dict):
correlation_id = data.get("correlation_id", "")
elif data is not None:
correlation_id = str(getattr(data, "correlation_id", ""))
return {"correlation_id": correlation_id}

def safe_confirm_cvm_patch(
self, request: ConfirmCvmPatchRequest | Mapping[str, Any]
) -> SafeResult[dict[str, Any]]:
return self.safe(self.confirm_cvm_patch, request)

def get_cvm_state(self, request: CvmIdRequest | Mapping[str, Any]) -> Any:
cvm_id = CvmIdRequest.model_validate(request).resolved
return self._loose_validate(self.get(f"/cvms/{cvm_id}/state"))
Expand Down Expand Up @@ -1461,6 +1550,69 @@ async def safe_update_os_image(
) -> SafeResult[None]:
return await self.safe(self.update_os_image, request)

async def patch_cvm(self, request: PatchCvmRequest | Mapping[str, Any]) -> dict[str, Any]:
req = PatchCvmRequest.model_validate(request)
cvm_id = req.resolved
body = req.model_dump(
exclude={"id", "uuid", "app_id", "instance_id", "cvm_id", "cvmId"},
exclude_none=True,
)
try:
data = self._loose_validate(await self.request("PATCH", f"/cvms/{cvm_id}", json=body))
correlation_id = ""
if isinstance(data, dict):
correlation_id = data.get("correlation_id", "")
elif data is not None:
correlation_id = str(getattr(data, "correlation_id", ""))
return {"requires_on_chain_hash": False, "correlation_id": correlation_id}
except ApiError as exc:
if exc.status_code == 465 and isinstance(exc.detail, dict):
details: dict[str, Any] = {}
for item in exc.detail.get("details") or []:
if isinstance(item, dict) and "field" in item and "value" in item:
details[item["field"]] = item["value"]
return {
"requires_on_chain_hash": True,
"compose_hash": details.get("compose_hash", ""),
"app_id": str(details.get("app_id", "")),
"device_id": details.get("device_id", ""),
"kms_info": details.get("kms_info"),
}
raise

async def safe_patch_cvm(
self, request: PatchCvmRequest | Mapping[str, Any]
) -> SafeResult[dict[str, Any]]:
return await self.safe(self.patch_cvm, request)

async def confirm_cvm_patch(
self, request: ConfirmCvmPatchRequest | Mapping[str, Any]
) -> dict[str, Any]:
req = ConfirmCvmPatchRequest.model_validate(request)
cvm_id = req.resolved
data = self._loose_validate(
await self.request(
"PATCH",
f"/cvms/{cvm_id}",
json={},
headers={
"X-Compose-Hash": req.compose_hash,
"X-Transaction-Hash": req.transaction_hash,
},
)
)
correlation_id = ""
if isinstance(data, dict):
correlation_id = data.get("correlation_id", "")
elif data is not None:
correlation_id = str(getattr(data, "correlation_id", ""))
return {"correlation_id": correlation_id}

async def safe_confirm_cvm_patch(
self, request: ConfirmCvmPatchRequest | Mapping[str, Any]
) -> SafeResult[dict[str, Any]]:
return await self.safe(self.confirm_cvm_patch, request)

async def get_cvm_state(self, request: CvmIdRequest | Mapping[str, Any]) -> Any:
cvm_id = CvmIdRequest.model_validate(request).resolved
return self._loose_validate(await self.get(f"/cvms/{cvm_id}/state"))
Expand Down
32 changes: 32 additions & 0 deletions python/tests/test_action_matrix_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,30 @@ def _mock_handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(204)
if method == "PATCH" and any(path.endswith(s) for s in ["/resources", "/os-image"]):
return httpx.Response(202)
# patch_cvm / confirm_cvm_patch: PATCH /cvms/{cvm_id} (no sub-path)
if (
method == "PATCH"
and "/cvms/" in path
and not any(
path.endswith(s)
for s in [
"/envs",
"/docker-compose",
"/pre-launch-script",
"/visibility",
"/instance-id",
"/resources",
"/os-image",
"/compose_file",
"/compose",
"/name",
"/listed",
"/scheduled-delete",
]
)
and path != "/api/v1/cvms/instance-ids"
):
return _json_response({"correlation_id": "corr-123"}, status=202)
if method == "PATCH" and path.endswith("/visibility"):
return _json_response({"status": "running"})
if method == "PATCH" and path.endswith("/instance-id"):
Expand Down Expand Up @@ -362,6 +386,10 @@ def test_sync_action_matrix_and_safe() -> None:
lambda: c.refresh_cvm_instance_id({"id": "c1"}),
lambda: c.refresh_cvm_instance_ids({}),
lambda: c.replicate_cvm({"id": "c1"}),
lambda: c.patch_cvm({"id": "c1", "vcpu": 2}),
lambda: c.confirm_cvm_patch(
{"id": "c1", "compose_hash": "h", "transaction_hash": "tx"}
),
lambda: c.get_app_list(),
lambda: c.get_app_info({"appId": "a"}),
lambda: c.get_app_cvms({"appId": "a"}),
Expand Down Expand Up @@ -438,6 +466,10 @@ def test_safe_matrix_sync_all_actions() -> None:
"safe_refresh_cvm_instance_id": ({"id": "c1"},),
"safe_refresh_cvm_instance_ids": ({},),
"safe_replicate_cvm": ({"id": "c1"},),
"safe_patch_cvm": ({"id": "c1", "vcpu": 2},),
"safe_confirm_cvm_patch": (
{"id": "c1", "compose_hash": "h", "transaction_hash": "tx"},
),
"safe_get_app_list": (),
"safe_get_app_info": ({"appId": "a"},),
"safe_get_app_cvms": ({"appId": "a"},),
Expand Down
119 changes: 119 additions & 0 deletions python/tests/test_e2e_all_interfaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -733,3 +733,122 @@ async def test_e2e_async_all_interfaces() -> None:
if cvm_id:
await _cleanup_async(client, cvm_id)
await client.aclose()


# ---------------------------------------------------------------------------
# patch_cvm E2E — deploy → patch (various combos) → verify → cleanup
# ---------------------------------------------------------------------------


@pytest.mark.e2e
def test_e2e_patch_cvm() -> None:
base_url = _must_env("PHALA_CLOUD_E2E_BASE_URL", "https://cloud-api.phala.com/api/v1")
api_key = _must_env("PHALA_CLOUD_E2E_API_KEY")
client = create_client(api_key=api_key, base_url=base_url)
cvm_id: str | None = None

print(f"\n=== E2E patch_cvm ({base_url}) ===", flush=True)

try:
# Deploy
cvm_id, app_id, encrypt_pubkey = _deploy(client)
req = {"id": cvm_id}

# 1. Visibility-only patch
_assert_idle(client, cvm_id, "patch: visibility")
print(" patch: visibility ...", flush=True)
result = client.patch_cvm(
{
"id": cvm_id,
"public_logs": True,
"public_sysinfo": True,
}
)
assert not result["requires_on_chain_hash"]
assert result["correlation_id"]
print(f" [ok] correlation_id={result['correlation_id']}", flush=True)
_wait_idle(client, cvm_id)

# Verify visibility was applied
info = client.get_cvm_info(req)
assert getattr(info, "public_logs", None) is True
assert getattr(info, "public_sysinfo", None) is True
print(" [verified] visibility applied", flush=True)

# 2. Docker compose patch
_assert_idle(client, cvm_id, "patch: docker_compose")
print(" patch: docker_compose ...", flush=True)
result = client.patch_cvm(
{
"id": cvm_id,
"docker_compose_file": TEST_COMPOSE,
}
)
assert not result["requires_on_chain_hash"]
assert result["correlation_id"]
print(f" [ok] correlation_id={result['correlation_id']}", flush=True)
_wait_idle(client, cvm_id)

# 3. Pre-launch script patch
_assert_idle(client, cvm_id, "patch: pre_launch_script")
print(" patch: pre_launch_script ...", flush=True)
result = client.patch_cvm(
{
"id": cvm_id,
"pre_launch_script": "#!/bin/sh\necho patched",
}
)
assert not result["requires_on_chain_hash"]
assert result["correlation_id"]
print(f" [ok] correlation_id={result['correlation_id']}", flush=True)
_wait_idle(client, cvm_id)

# 4. Encrypted env patch
if encrypt_pubkey:
_assert_idle(client, cvm_id, "patch: encrypted_env")
print(" patch: encrypted_env ...", flush=True)
encrypted = asyncio.run(_encrypt_envs(encrypt_pubkey))
result = client.patch_cvm(
{
"id": cvm_id,
"encrypted_env": encrypted,
}
)
assert not result["requires_on_chain_hash"]
assert result["correlation_id"]
print(f" [ok] correlation_id={result['correlation_id']}", flush=True)
_wait_idle(client, cvm_id)

# 5. Multi-field patch (visibility + compose together)
_assert_idle(client, cvm_id, "patch: multi-field")
print(" patch: multi-field (visibility + compose) ...", flush=True)
result = client.patch_cvm(
{
"id": cvm_id,
"public_logs": False,
"docker_compose_file": TEST_COMPOSE,
}
)
assert not result["requires_on_chain_hash"]
assert result["correlation_id"]
print(f" [ok] correlation_id={result['correlation_id']}", flush=True)
_wait_idle(client, cvm_id)

# Verify multi-field was applied
info = client.get_cvm_info(req)
assert getattr(info, "public_logs", None) is False
print(" [verified] multi-field applied", flush=True)

# 6. safe_patch_cvm
_assert_idle(client, cvm_id, "safe_patch_cvm")
print(" safe_patch_cvm ...", flush=True)
r = client.safe_patch_cvm({"id": cvm_id, "public_sysinfo": False})
assert r.ok, r.error
print(" [ok]", flush=True)
_wait_idle(client, cvm_id)

print("=== patch_cvm test done ===", flush=True)

finally:
if cvm_id:
_cleanup(client, cvm_id)
Loading