morphcloud 0.1.114__tar.gz → 0.1.116__tar.gz
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.
- {morphcloud-0.1.114 → morphcloud-0.1.116}/PKG-INFO +2 -2
- morphcloud-0.1.116/morphcloud/_disk_resize.py +154 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/api.py +131 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/cli.py +1 -1
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/instances.py +41 -2
- {morphcloud-0.1.114 → morphcloud-0.1.116}/pyproject.toml +2 -2
- {morphcloud-0.1.114 → morphcloud-0.1.116}/.github/workflows/publish.yaml +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/.gitignore +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/LICENSE +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/Makefile +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/README.md +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/conf.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/examples/browser_example.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/examples/devbox_example.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/index.rst +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/make.bat +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/__init__.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/_asyncify.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/_bash_interpreter.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/_http_transport.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/_scramble.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/_ssh.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/_synapse.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/_utils.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/cli_helpers.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/cli_runtime.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/config.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/__init__.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/cli.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/client.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/config.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/__init__.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/admin/__init__.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/admin/client.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/admin/raw_client.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/aliases/__init__.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/aliases/client.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/aliases/raw_client.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/client.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/core/__init__.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/core/api_error.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/core/client_wrapper.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/core/datetime_utils.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/core/file.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/core/force_multipart.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/core/http_client.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/core/http_response.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/core/jsonable_encoder.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/core/pydantic_utilities.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/core/query_encoder.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/core/remove_none_from_dict.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/core/request_options.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/core/serialization.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/devboxes_actions/__init__.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/devboxes_actions/client.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/devboxes_actions/raw_client.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/devboxes_core/__init__.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/devboxes_core/client.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/devboxes_core/raw_client.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/devboxes_lifecycle/__init__.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/devboxes_lifecycle/client.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/devboxes_lifecycle/raw_client.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/errors/__init__.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/errors/unprocessable_entity_error.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/templates/__init__.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/templates/client.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/templates/raw_client.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/tmux/__init__.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/tmux/client.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/tmux/raw_client.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/types/__init__.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/types/alias_response.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/types/branch_response.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/types/delete_response.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/types/devbox_detail_list_response.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/types/devbox_list_response.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/types/devbox_refs.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/types/devbox_response.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/types/devbox_spec.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/types/devbox_ssh_credentials_response.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/types/devbox_summary_response.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/types/http_service.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/types/http_service_auth_mode.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/types/http_validation_error.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/types/networking.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/types/secret_value_response.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/types/snapshot_response.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/types/success_response.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/types/template_cache_request.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/types/template_cache_start_response.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/types/template_detail.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/types/template_list_response.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/types/template_share_response.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/types/template_status.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/types/template_step_model.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/types/template_step_status.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/types/template_summary.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/types/timeline_event.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/types/tmux_debug_info_response.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/types/tmux_install_response.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/types/tmux_new_session_response.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/types/tmux_send_keys_response.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/types/tmux_session.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/types/tmux_sessions_list.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/types/tmux_snapshot_response.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/types/tmux_target.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/types/ttl.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/types/upload_response.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/types/validation_error.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/types/validation_error_loc_item.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/types/wake_on.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/uploads/__init__.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/uploads/client.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/uploads/raw_client.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/user_secrets/__init__.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/user_secrets/client.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/gen/user_secrets/raw_client.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/openapi.json +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/template_local_runner.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/template_runner.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox/terminals.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/devbox_commands.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/experimental/__init__.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/experimental/browser.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/images.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/plugin_commands.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/plugins/__init__.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/plugins/cli.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/plugins/installer.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/profiles.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/py.typed +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/simple_index/__init__.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/simple_index/client.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/snapshots.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/users.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/volume_commands.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/volumes/__init__.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/volumes/cli.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/morphcloud/volumes/client.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/scripts/abuild_smoke.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/scripts/increment_version.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/tests/integration/__init__.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/tests/integration/conftest.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/tests/integration/pytest.ini +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/tests/integration/test_as_container.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/tests/integration/test_branching.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/tests/integration/test_command_execution.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/tests/integration/test_file_operations.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/tests/integration/test_from_tag_multiple.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/tests/integration/test_http_service.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/tests/integration/test_managed_ssh_keys.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/tests/integration/test_metadata.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/tests/integration/test_simple.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/tests/integration/test_snapshot_operations.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/tests/integration/test_ssh_key_rotation.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/tests/integration/test_ttl.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/tests/integration/test_user.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/tests/unit/test_cli_devbox.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/tests/unit/test_cli_devbox_terminal.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/tests/unit/test_cli_managed_ssh_keys.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/tests/unit/test_cli_plugins.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/tests/unit/test_cli_secrets.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/tests/unit/test_cli_snapshots.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/tests/unit/test_cli_volumes.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/tests/unit/test_devbox_terminals.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/tests/unit/test_http_transport_sharding.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/tests/unit/test_managed_ssh_keys_api.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/tests/unit/test_plugin_installer.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/tests/unit/test_profiles.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/tests/unit/test_simple_index_client.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/tests/unit/test_snapshot_ttl.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/tests/unit/test_ssh_connect_retry.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/tests/unit/test_template_local_runner.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/tests/unit/test_template_runner.py +0 -0
- {morphcloud-0.1.114 → morphcloud-0.1.116}/tests/unit/test_volumes_client.py +0 -0
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: morphcloud
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.116
|
|
4
4
|
Summary: A Python SDK and CLI tool for creating, managing, and interacting with Morph Cloud VMs.
|
|
5
5
|
License: Apache-2.0
|
|
6
6
|
License-File: LICENSE
|
|
7
|
-
Requires-Python: >=3.
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
8
|
Requires-Dist: click>=8.1.7
|
|
9
9
|
Requires-Dist: httpx>=0.27.0
|
|
10
10
|
Requires-Dist: packaging>=24.2
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Disk-capacity operation values and local waiting policy.
|
|
2
|
+
|
|
3
|
+
Waiting never changes a remote operation: timeouts and cancellation only stop
|
|
4
|
+
local observation. Every error preserves the request key and operation evidence
|
|
5
|
+
available to that invocation so a caller can safely resume observation or replay.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
import math
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from typing import Literal
|
|
13
|
+
from urllib.parse import quote
|
|
14
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
15
|
+
|
|
16
|
+
class DiskResizeOperationError(BaseModel):
|
|
17
|
+
"""Service rejection or failure details, including a measured minimum."""
|
|
18
|
+
model_config = ConfigDict(frozen=True)
|
|
19
|
+
code: str
|
|
20
|
+
message: str
|
|
21
|
+
retryable: bool
|
|
22
|
+
minimum_target_mib: int | None = Field(default=None, ge=0)
|
|
23
|
+
|
|
24
|
+
class DiskResizeOperation(BaseModel):
|
|
25
|
+
"""An observed disk resize; byte observations are null until known.
|
|
26
|
+
|
|
27
|
+
``requested_disk_size`` and ``previous_disk_size`` use MiB. A successful
|
|
28
|
+
operation changes effective capacity; backing storage observations may differ.
|
|
29
|
+
``idempotency_key`` is local recovery context, never inferred by GET calls.
|
|
30
|
+
"""
|
|
31
|
+
model_config = ConfigDict(frozen=True)
|
|
32
|
+
object: Literal['instance_disk_resize'] = 'instance_disk_resize'
|
|
33
|
+
id: str = Field(min_length=1)
|
|
34
|
+
instance_id: str = Field(min_length=1)
|
|
35
|
+
previous_disk_size: int = Field(ge=0)
|
|
36
|
+
requested_disk_size: int = Field(ge=256, le=1048576)
|
|
37
|
+
capacity_mode: str
|
|
38
|
+
status: Literal['pending', 'running', 'succeeded', 'failed', 'recovery_required']
|
|
39
|
+
phase: Literal['accepted', 'geometry_ready', 'budget_applied', 'complete']
|
|
40
|
+
capacity_generation: int = Field(ge=0)
|
|
41
|
+
effective_capacity_bytes: int | None = Field(default=None, ge=0)
|
|
42
|
+
statfs_total_bytes: int | None = Field(default=None, ge=0)
|
|
43
|
+
charged_bytes: int | None = Field(default=None, ge=0)
|
|
44
|
+
reserved_bytes: int | None = Field(default=None, ge=0)
|
|
45
|
+
backing_bytes: int | None = Field(default=None, ge=0)
|
|
46
|
+
guest_device_bytes: int | None = Field(default=None, ge=0)
|
|
47
|
+
ext4_geometry_bytes: int | None = Field(default=None, ge=0)
|
|
48
|
+
error: DiskResizeOperationError | None = None
|
|
49
|
+
retry_after_seconds: float = Field(default=1, ge=0, allow_inf_nan=False)
|
|
50
|
+
created_at: datetime
|
|
51
|
+
updated_at: datetime
|
|
52
|
+
completed_at: datetime | None = None
|
|
53
|
+
idempotency_key: str | None = None
|
|
54
|
+
|
|
55
|
+
class DiskResizeError(Exception):
|
|
56
|
+
"""Base failure with enough evidence to resume or replay safely."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, message: str, *, operation: DiskResizeOperation | None=None, operation_id: str | None=None, idempotency_key: str | None=None) -> None:
|
|
59
|
+
self.operation = operation
|
|
60
|
+
self.operation_id = operation.id if operation else operation_id
|
|
61
|
+
self.idempotency_key = idempotency_key or (operation.idempotency_key if operation else None)
|
|
62
|
+
recovery = []
|
|
63
|
+
if self.operation_id:
|
|
64
|
+
recovery.append(f'operation_id={self.operation_id}')
|
|
65
|
+
if self.idempotency_key:
|
|
66
|
+
recovery.append(f'idempotency_key={self.idempotency_key}')
|
|
67
|
+
if recovery:
|
|
68
|
+
message += ' (' + ', '.join(recovery) + ')'
|
|
69
|
+
super().__init__(message)
|
|
70
|
+
|
|
71
|
+
class DiskResizeFailed(DiskResizeError):
|
|
72
|
+
"""The observed operation failed; inspect its structured error."""
|
|
73
|
+
|
|
74
|
+
class DiskResizeRecoveryRequired(DiskResizeError):
|
|
75
|
+
"""The operation requires recovery; do not submit a replacement."""
|
|
76
|
+
|
|
77
|
+
class DiskResizeTimeout(DiskResizeError, TimeoutError):
|
|
78
|
+
"""Local waiting expired. The remote operation was not cancelled."""
|
|
79
|
+
|
|
80
|
+
class DiskResizeRequestError(DiskResizeError):
|
|
81
|
+
"""A request failed or returned invalid evidence; its cause is preserved."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, message: str, *, cause: Exception, operation: DiskResizeOperation | None=None, operation_id: str | None=None, idempotency_key: str | None=None) -> None:
|
|
84
|
+
self.status_code = getattr(cause, 'status_code', None)
|
|
85
|
+
self.error_code = getattr(cause, 'error_code', None)
|
|
86
|
+
self.response_body = getattr(cause, 'response_body', None)
|
|
87
|
+
if self.status_code is not None:
|
|
88
|
+
message += f'; HTTP {self.status_code}'
|
|
89
|
+
if self.error_code is not None:
|
|
90
|
+
message += f'; {self.error_code}'
|
|
91
|
+
super().__init__(message, operation=operation, operation_id=operation_id, idempotency_key=idempotency_key)
|
|
92
|
+
|
|
93
|
+
class DiskResizeRefreshError(DiskResizeError):
|
|
94
|
+
"""Resize succeeded, but refreshing the local Instance failed.
|
|
95
|
+
|
|
96
|
+
``operation`` remains the successful receipt. Do not repeat the resize to
|
|
97
|
+
refresh an instance; retrieve the instance separately.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
def request_key(disk_size: int, idempotency_key: str | None) -> str:
|
|
101
|
+
if type(disk_size) is not int or not 256 <= disk_size <= 1048576:
|
|
102
|
+
raise ValueError('disk_size must be an integer MiB value from 256 to 1048576')
|
|
103
|
+
if idempotency_key is None:
|
|
104
|
+
return str(uuid.uuid4())
|
|
105
|
+
if not isinstance(idempotency_key, str) or not 1 <= len(idempotency_key) <= 128:
|
|
106
|
+
raise ValueError('idempotency_key must contain 1 to 128 ASCII characters')
|
|
107
|
+
if any((ord(character) < 33 or ord(character) > 126 for character in idempotency_key)):
|
|
108
|
+
raise ValueError('idempotency_key must contain visible ASCII characters')
|
|
109
|
+
return idempotency_key
|
|
110
|
+
|
|
111
|
+
def deadline(timeout: float) -> float:
|
|
112
|
+
if isinstance(timeout, bool) or not isinstance(timeout, (int, float)):
|
|
113
|
+
raise ValueError('timeout must be a finite positive number of seconds')
|
|
114
|
+
if not math.isfinite(timeout) or timeout <= 0:
|
|
115
|
+
raise ValueError('timeout must be a finite positive number of seconds')
|
|
116
|
+
return time.monotonic() + timeout
|
|
117
|
+
|
|
118
|
+
def remaining(end: float, *, operation: DiskResizeOperation | None=None, operation_id: str | None=None, idempotency_key: str | None=None) -> float:
|
|
119
|
+
seconds = end - time.monotonic()
|
|
120
|
+
if seconds <= 0:
|
|
121
|
+
raise DiskResizeTimeout('Disk resize waiting timed out; remote work may still be running', operation=operation, operation_id=operation_id, idempotency_key=idempotency_key)
|
|
122
|
+
return seconds
|
|
123
|
+
|
|
124
|
+
def operation_path(instance_id: str, operation_id: str | None=None) -> str:
|
|
125
|
+
if not isinstance(instance_id, str) or not instance_id:
|
|
126
|
+
raise ValueError('instance_id must be a nonempty string')
|
|
127
|
+
path = f"/instance/{quote(instance_id, safe='')}/disk-resize"
|
|
128
|
+
if operation_id is not None:
|
|
129
|
+
if not isinstance(operation_id, str) or not operation_id:
|
|
130
|
+
raise ValueError('operation_id must be a nonempty string')
|
|
131
|
+
path += f"/{quote(operation_id, safe='')}"
|
|
132
|
+
return path
|
|
133
|
+
|
|
134
|
+
def parse_operation(payload: object, instance_id: str, *, operation_id: str | None=None, idempotency_key: str | None=None, requested_disk_size: int | None=None, previous: DiskResizeOperation | None=None) -> DiskResizeOperation:
|
|
135
|
+
operation = DiskResizeOperation.model_validate(payload)
|
|
136
|
+
if operation.instance_id != instance_id:
|
|
137
|
+
raise ValueError('Disk resize response instance does not match the request')
|
|
138
|
+
if operation_id is not None and operation.id != operation_id:
|
|
139
|
+
raise ValueError('Disk resize response operation does not match the request')
|
|
140
|
+
if requested_disk_size is not None and operation.requested_disk_size != requested_disk_size:
|
|
141
|
+
raise ValueError('Disk resize response target does not match the request')
|
|
142
|
+
if previous is not None and operation.capacity_generation < previous.capacity_generation:
|
|
143
|
+
raise ValueError('Disk resize response capacity generation regressed')
|
|
144
|
+
return operation.model_copy(update={'idempotency_key': idempotency_key})
|
|
145
|
+
|
|
146
|
+
def terminal(operation: DiskResizeOperation) -> bool:
|
|
147
|
+
if operation.status == 'failed':
|
|
148
|
+
raise DiskResizeFailed('Disk resize failed', operation=operation)
|
|
149
|
+
if operation.status == 'recovery_required':
|
|
150
|
+
raise DiskResizeRecoveryRequired('Disk resize requires recovery', operation=operation)
|
|
151
|
+
return operation.status == 'succeeded'
|
|
152
|
+
|
|
153
|
+
def poll_delay(operation: DiskResizeOperation, end: float) -> float:
|
|
154
|
+
return min(max(operation.retry_after_seconds, 0.1), 5, remaining(end, operation=operation))
|
|
@@ -11,6 +11,8 @@ from functools import lru_cache
|
|
|
11
11
|
import httpx
|
|
12
12
|
from pydantic import BaseModel, Field, PrivateAttr, model_validator
|
|
13
13
|
from rich.console import Console
|
|
14
|
+
from morphcloud import _disk_resize
|
|
15
|
+
from morphcloud._disk_resize import DiskResizeError, DiskResizeFailed, DiskResizeOperation, DiskResizeOperationError, DiskResizeRecoveryRequired, DiskResizeRefreshError, DiskResizeRequestError, DiskResizeTimeout
|
|
14
16
|
from morphcloud._http_transport import build_async_http_transport, build_http_transport
|
|
15
17
|
from morphcloud._utils import StrEnum
|
|
16
18
|
from morphcloud.config import resolve_settings
|
|
@@ -1354,6 +1356,111 @@ class InstanceAPI(BaseAPI):
|
|
|
1354
1356
|
response = await self._client._async_http_client.delete(f'/instance/{instance_id}')
|
|
1355
1357
|
response.raise_for_status()
|
|
1356
1358
|
|
|
1359
|
+
def resize_disk(self, instance_id: str, *, disk_size: int, idempotency_key: str | None=None, wait: bool=True, timeout: float=300) -> DiskResizeOperation:
|
|
1360
|
+
"""Set absolute disk capacity in MiB without rebooting an eligible instance.
|
|
1361
|
+
|
|
1362
|
+
Waiting is local: timeout never cancels accepted work. Preserve the
|
|
1363
|
+
returned/exception idempotency key to replay after an uncertain response.
|
|
1364
|
+
Decreases must fit existing usage and required headroom.
|
|
1365
|
+
"""
|
|
1366
|
+
end = _disk_resize.deadline(timeout)
|
|
1367
|
+
key = _disk_resize.request_key(disk_size, idempotency_key)
|
|
1368
|
+
path = _disk_resize.operation_path(instance_id)
|
|
1369
|
+
if not isinstance(wait, bool):
|
|
1370
|
+
raise ValueError('wait must be a bool')
|
|
1371
|
+
try:
|
|
1372
|
+
response = self._client._http_client.post(path, json={'disk_size': disk_size}, headers={'Idempotency-Key': key}, timeout=min(30, _disk_resize.remaining(end, idempotency_key=key)))
|
|
1373
|
+
response.raise_for_status()
|
|
1374
|
+
operation = _disk_resize.parse_operation(response.json(), instance_id, idempotency_key=key, requested_disk_size=disk_size)
|
|
1375
|
+
except httpx.TimeoutException as exc:
|
|
1376
|
+
raise DiskResizeTimeout('Disk resize submission timed out; replay with the same key', idempotency_key=key) from exc
|
|
1377
|
+
except (ApiError, httpx.HTTPError, ValueError) as exc:
|
|
1378
|
+
raise DiskResizeRequestError('Disk resize submission failed; preserve the key for safe replay', cause=exc, idempotency_key=key) from exc
|
|
1379
|
+
if _disk_resize.terminal(operation) or not wait:
|
|
1380
|
+
return operation
|
|
1381
|
+
return self._wait_disk_resize(instance_id, operation.id, end, operation)
|
|
1382
|
+
|
|
1383
|
+
async def aresize_disk(self, instance_id: str, *, disk_size: int, idempotency_key: str | None=None, wait: bool=True, timeout: float=300) -> DiskResizeOperation:
|
|
1384
|
+
"""Async resize_disk; cancelling the caller does not cancel remote work.
|
|
1385
|
+
|
|
1386
|
+
Supply a persistent key before starting if cancellation must be recoverable
|
|
1387
|
+
across processes, including cancellation before an operation ID is known.
|
|
1388
|
+
"""
|
|
1389
|
+
end = _disk_resize.deadline(timeout)
|
|
1390
|
+
key = _disk_resize.request_key(disk_size, idempotency_key)
|
|
1391
|
+
path = _disk_resize.operation_path(instance_id)
|
|
1392
|
+
if not isinstance(wait, bool):
|
|
1393
|
+
raise ValueError('wait must be a bool')
|
|
1394
|
+
try:
|
|
1395
|
+
seconds = _disk_resize.remaining(end, idempotency_key=key)
|
|
1396
|
+
response = await asyncio.wait_for(self._client._async_http_client.post(path, json={'disk_size': disk_size}, headers={'Idempotency-Key': key}, timeout=min(30, seconds)), timeout=seconds)
|
|
1397
|
+
response.raise_for_status()
|
|
1398
|
+
operation = _disk_resize.parse_operation(response.json(), instance_id, idempotency_key=key, requested_disk_size=disk_size)
|
|
1399
|
+
except (httpx.TimeoutException, asyncio.TimeoutError) as exc:
|
|
1400
|
+
raise DiskResizeTimeout('Disk resize submission timed out; replay with the same key', idempotency_key=key) from exc
|
|
1401
|
+
except (ApiError, httpx.HTTPError, ValueError) as exc:
|
|
1402
|
+
raise DiskResizeRequestError('Disk resize submission failed; preserve the key for safe replay', cause=exc, idempotency_key=key) from exc
|
|
1403
|
+
if _disk_resize.terminal(operation) or not wait:
|
|
1404
|
+
return operation
|
|
1405
|
+
return await self._await_disk_resize(instance_id, operation.id, end, operation)
|
|
1406
|
+
|
|
1407
|
+
def get_disk_resize(self, instance_id: str, operation_id: str) -> DiskResizeOperation:
|
|
1408
|
+
"""Inspect the exact operation, including failed or recovery-required state."""
|
|
1409
|
+
return self._get_disk_resize(instance_id, operation_id, _disk_resize.deadline(30))
|
|
1410
|
+
|
|
1411
|
+
async def aget_disk_resize(self, instance_id: str, operation_id: str) -> DiskResizeOperation:
|
|
1412
|
+
"""Async inspection of the exact operation without changing its state."""
|
|
1413
|
+
return await self._aget_disk_resize(instance_id, operation_id, _disk_resize.deadline(30))
|
|
1414
|
+
|
|
1415
|
+
def _get_disk_resize(self, instance_id: str, operation_id: str, end: float, previous: DiskResizeOperation | None=None) -> DiskResizeOperation:
|
|
1416
|
+
path = _disk_resize.operation_path(instance_id, operation_id)
|
|
1417
|
+
key = previous.idempotency_key if previous else None
|
|
1418
|
+
try:
|
|
1419
|
+
response = self._client._http_client.get(path, timeout=min(30, _disk_resize.remaining(end, operation=previous, operation_id=operation_id)))
|
|
1420
|
+
response.raise_for_status()
|
|
1421
|
+
return _disk_resize.parse_operation(response.json(), instance_id, operation_id=operation_id, idempotency_key=key, requested_disk_size=previous.requested_disk_size if previous else None, previous=previous)
|
|
1422
|
+
except httpx.TimeoutException as exc:
|
|
1423
|
+
raise DiskResizeTimeout('Disk resize observation timed out; remote work was not cancelled', operation=previous, operation_id=operation_id) from exc
|
|
1424
|
+
except (ApiError, httpx.HTTPError, ValueError) as exc:
|
|
1425
|
+
raise DiskResizeRequestError('Disk resize observation failed; inspect this operation again', cause=exc, operation=previous, operation_id=operation_id) from exc
|
|
1426
|
+
|
|
1427
|
+
async def _aget_disk_resize(self, instance_id: str, operation_id: str, end: float, previous: DiskResizeOperation | None=None) -> DiskResizeOperation:
|
|
1428
|
+
path = _disk_resize.operation_path(instance_id, operation_id)
|
|
1429
|
+
key = previous.idempotency_key if previous else None
|
|
1430
|
+
try:
|
|
1431
|
+
seconds = _disk_resize.remaining(end, operation=previous, operation_id=operation_id)
|
|
1432
|
+
response = await asyncio.wait_for(self._client._async_http_client.get(path, timeout=min(30, seconds)), timeout=seconds)
|
|
1433
|
+
response.raise_for_status()
|
|
1434
|
+
return _disk_resize.parse_operation(response.json(), instance_id, operation_id=operation_id, idempotency_key=key, requested_disk_size=previous.requested_disk_size if previous else None, previous=previous)
|
|
1435
|
+
except (httpx.TimeoutException, asyncio.TimeoutError) as exc:
|
|
1436
|
+
raise DiskResizeTimeout('Disk resize observation timed out; remote work was not cancelled', operation=previous, operation_id=operation_id) from exc
|
|
1437
|
+
except (ApiError, httpx.HTTPError, ValueError) as exc:
|
|
1438
|
+
raise DiskResizeRequestError('Disk resize observation failed; inspect this operation again', cause=exc, operation=previous, operation_id=operation_id) from exc
|
|
1439
|
+
|
|
1440
|
+
def wait_disk_resize(self, instance_id: str, operation_id: str, *, timeout: float=300) -> DiskResizeOperation:
|
|
1441
|
+
"""Wait for this operation; raise typed terminal errors or a local timeout."""
|
|
1442
|
+
return self._wait_disk_resize(instance_id, operation_id, _disk_resize.deadline(timeout))
|
|
1443
|
+
|
|
1444
|
+
async def await_disk_resize(self, instance_id: str, operation_id: str, *, timeout: float=300) -> DiskResizeOperation:
|
|
1445
|
+
"""Async wait; cancellation stops observation without cancelling remote work."""
|
|
1446
|
+
return await self._await_disk_resize(instance_id, operation_id, _disk_resize.deadline(timeout))
|
|
1447
|
+
|
|
1448
|
+
def _wait_disk_resize(self, instance_id: str, operation_id: str, end: float, operation: DiskResizeOperation | None=None) -> DiskResizeOperation:
|
|
1449
|
+
while True:
|
|
1450
|
+
if operation is not None:
|
|
1451
|
+
if _disk_resize.terminal(operation):
|
|
1452
|
+
return operation
|
|
1453
|
+
time.sleep(_disk_resize.poll_delay(operation, end))
|
|
1454
|
+
operation = self._get_disk_resize(instance_id, operation_id, end, operation)
|
|
1455
|
+
|
|
1456
|
+
async def _await_disk_resize(self, instance_id: str, operation_id: str, end: float, operation: DiskResizeOperation | None=None) -> DiskResizeOperation:
|
|
1457
|
+
while True:
|
|
1458
|
+
if operation is not None:
|
|
1459
|
+
if _disk_resize.terminal(operation):
|
|
1460
|
+
return operation
|
|
1461
|
+
await asyncio.sleep(_disk_resize.poll_delay(operation, end))
|
|
1462
|
+
operation = await self._aget_disk_resize(instance_id, operation_id, end, operation)
|
|
1463
|
+
|
|
1357
1464
|
def reboot(self, instance_id: str, *, vcpus: typing.Optional[int]=None, memory: typing.Optional[int]=None, disk_size: typing.Optional[int]=None, metadata: typing.Optional[typing.Dict[str, str]]=None, ttl_seconds: typing.Optional[int]=None, ttl_action: typing.Union[None, typing.Literal['stop', 'pause']]=None) -> Instance:
|
|
1358
1465
|
"""
|
|
1359
1466
|
Reboot an instance by its ID, optionally applying a new machine configuration.
|
|
@@ -1918,6 +2025,30 @@ class Instance(BaseModel):
|
|
|
1918
2025
|
response = await self._api._client._async_http_client.post(f'/instance/{self.id}/snapshot', params=params, json=body)
|
|
1919
2026
|
return Snapshot.model_validate(response.json())._set_api(self._api._client.snapshots)
|
|
1920
2027
|
|
|
2028
|
+
def resize_disk(self, *, disk_size: int, idempotency_key: str | None=None, wait: bool=True, timeout: float=300) -> DiskResizeOperation:
|
|
2029
|
+
"""Resize disk capacity; refresh local state only after confirmed success.
|
|
2030
|
+
|
|
2031
|
+
A DiskResizeRefreshError retains the successful operation if the later
|
|
2032
|
+
refresh fails. Waiting timeout does not cancel the remote operation.
|
|
2033
|
+
"""
|
|
2034
|
+
operation = self._api.resize_disk(self.id, disk_size=disk_size, idempotency_key=idempotency_key, wait=wait, timeout=timeout)
|
|
2035
|
+
if operation.status == 'succeeded':
|
|
2036
|
+
try:
|
|
2037
|
+
self._refresh()
|
|
2038
|
+
except (ApiError, httpx.HTTPError, ValueError) as exc:
|
|
2039
|
+
raise DiskResizeRefreshError('Disk resize succeeded, but instance refresh failed', operation=operation) from exc
|
|
2040
|
+
return operation
|
|
2041
|
+
|
|
2042
|
+
async def aresize_disk(self, *, disk_size: int, idempotency_key: str | None=None, wait: bool=True, timeout: float=300) -> DiskResizeOperation:
|
|
2043
|
+
"""Async resize with success evidence preserved across refresh failures."""
|
|
2044
|
+
operation = await self._api.aresize_disk(self.id, disk_size=disk_size, idempotency_key=idempotency_key, wait=wait, timeout=timeout)
|
|
2045
|
+
if operation.status == 'succeeded':
|
|
2046
|
+
try:
|
|
2047
|
+
await self._refresh_async()
|
|
2048
|
+
except (ApiError, httpx.HTTPError, ValueError) as exc:
|
|
2049
|
+
raise DiskResizeRefreshError('Disk resize succeeded, but instance refresh failed', operation=operation) from exc
|
|
2050
|
+
return operation
|
|
2051
|
+
|
|
1921
2052
|
def reboot(self, *, vcpus: typing.Optional[int]=None, memory: typing.Optional[int]=None, disk_size: typing.Optional[int]=None, metadata: typing.Optional[typing.Dict[str, str]]=None, ttl_seconds: typing.Optional[int]=None, ttl_action: typing.Union[None, typing.Literal['stop', 'pause']]=None) -> None:
|
|
1922
2053
|
"""
|
|
1923
2054
|
Reboot the instance, optionally applying a new machine configuration.
|