morphcloud 0.1.109__tar.gz → 0.1.111__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.
Files changed (155) hide show
  1. {morphcloud-0.1.109 → morphcloud-0.1.111}/PKG-INFO +103 -3
  2. {morphcloud-0.1.109 → morphcloud-0.1.111}/README.md +102 -2
  3. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/api.py +109 -6
  4. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/cli.py +133 -12
  5. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/config.py +12 -0
  6. morphcloud-0.1.111/morphcloud/volumes/__init__.py +10 -0
  7. morphcloud-0.1.111/morphcloud/volumes/cli.py +575 -0
  8. morphcloud-0.1.111/morphcloud/volumes/client.py +536 -0
  9. {morphcloud-0.1.109 → morphcloud-0.1.111}/pyproject.toml +1 -1
  10. {morphcloud-0.1.109 → morphcloud-0.1.111}/tests/integration/test_snapshot_operations.py +44 -1
  11. morphcloud-0.1.111/tests/unit/test_cli_snapshots.py +307 -0
  12. morphcloud-0.1.111/tests/unit/test_cli_volumes.py +172 -0
  13. {morphcloud-0.1.109 → morphcloud-0.1.111}/tests/unit/test_http_transport_sharding.py +4 -4
  14. {morphcloud-0.1.109 → morphcloud-0.1.111}/tests/unit/test_profiles.py +4 -0
  15. morphcloud-0.1.111/tests/unit/test_snapshot_ttl.py +258 -0
  16. morphcloud-0.1.111/tests/unit/test_volumes_client.py +209 -0
  17. {morphcloud-0.1.109 → morphcloud-0.1.111}/.github/workflows/publish.yaml +0 -0
  18. {morphcloud-0.1.109 → morphcloud-0.1.111}/.gitignore +0 -0
  19. {morphcloud-0.1.109 → morphcloud-0.1.111}/LICENSE +0 -0
  20. {morphcloud-0.1.109 → morphcloud-0.1.111}/Makefile +0 -0
  21. {morphcloud-0.1.109 → morphcloud-0.1.111}/conf.py +0 -0
  22. {morphcloud-0.1.109 → morphcloud-0.1.111}/examples/browser_example.py +0 -0
  23. {morphcloud-0.1.109 → morphcloud-0.1.111}/examples/devbox_example.py +0 -0
  24. {morphcloud-0.1.109 → morphcloud-0.1.111}/index.rst +0 -0
  25. {morphcloud-0.1.109 → morphcloud-0.1.111}/make.bat +0 -0
  26. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/__init__.py +0 -0
  27. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/_asyncify.py +0 -0
  28. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/_bash_interpreter.py +0 -0
  29. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/_http_transport.py +0 -0
  30. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/_llm.py +0 -0
  31. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/_scramble.py +0 -0
  32. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/_ssh.py +0 -0
  33. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/_utils.py +0 -0
  34. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/cli_helpers.py +0 -0
  35. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/__init__.py +0 -0
  36. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/cli.py +0 -0
  37. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/client.py +0 -0
  38. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/config.py +0 -0
  39. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/__init__.py +0 -0
  40. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/admin/__init__.py +0 -0
  41. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/admin/client.py +0 -0
  42. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/admin/raw_client.py +0 -0
  43. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/aliases/__init__.py +0 -0
  44. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/aliases/client.py +0 -0
  45. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/aliases/raw_client.py +0 -0
  46. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/client.py +0 -0
  47. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/core/__init__.py +0 -0
  48. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/core/api_error.py +0 -0
  49. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/core/client_wrapper.py +0 -0
  50. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/core/datetime_utils.py +0 -0
  51. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/core/file.py +0 -0
  52. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/core/force_multipart.py +0 -0
  53. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/core/http_client.py +0 -0
  54. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/core/http_response.py +0 -0
  55. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/core/jsonable_encoder.py +0 -0
  56. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/core/pydantic_utilities.py +0 -0
  57. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/core/query_encoder.py +0 -0
  58. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/core/remove_none_from_dict.py +0 -0
  59. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/core/request_options.py +0 -0
  60. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/core/serialization.py +0 -0
  61. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/devboxes_actions/__init__.py +0 -0
  62. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/devboxes_actions/client.py +0 -0
  63. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/devboxes_actions/raw_client.py +0 -0
  64. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/devboxes_core/__init__.py +0 -0
  65. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/devboxes_core/client.py +0 -0
  66. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/devboxes_core/raw_client.py +0 -0
  67. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/devboxes_lifecycle/__init__.py +0 -0
  68. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/devboxes_lifecycle/client.py +0 -0
  69. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/devboxes_lifecycle/raw_client.py +0 -0
  70. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/errors/__init__.py +0 -0
  71. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/errors/unprocessable_entity_error.py +0 -0
  72. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/templates/__init__.py +0 -0
  73. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/templates/client.py +0 -0
  74. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/templates/raw_client.py +0 -0
  75. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/tmux/__init__.py +0 -0
  76. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/tmux/client.py +0 -0
  77. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/tmux/raw_client.py +0 -0
  78. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/types/__init__.py +0 -0
  79. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/types/alias_response.py +0 -0
  80. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/types/branch_response.py +0 -0
  81. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/types/delete_response.py +0 -0
  82. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/types/devbox_detail_list_response.py +0 -0
  83. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/types/devbox_list_response.py +0 -0
  84. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/types/devbox_refs.py +0 -0
  85. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/types/devbox_response.py +0 -0
  86. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/types/devbox_spec.py +0 -0
  87. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/types/devbox_ssh_credentials_response.py +0 -0
  88. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/types/devbox_summary_response.py +0 -0
  89. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/types/http_service.py +0 -0
  90. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/types/http_service_auth_mode.py +0 -0
  91. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/types/http_validation_error.py +0 -0
  92. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/types/networking.py +0 -0
  93. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/types/secret_value_response.py +0 -0
  94. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/types/snapshot_response.py +0 -0
  95. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/types/success_response.py +0 -0
  96. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/types/template_cache_request.py +0 -0
  97. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/types/template_cache_start_response.py +0 -0
  98. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/types/template_detail.py +0 -0
  99. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/types/template_list_response.py +0 -0
  100. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/types/template_share_response.py +0 -0
  101. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/types/template_status.py +0 -0
  102. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/types/template_step_model.py +0 -0
  103. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/types/template_step_status.py +0 -0
  104. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/types/template_summary.py +0 -0
  105. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/types/timeline_event.py +0 -0
  106. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/types/tmux_debug_info_response.py +0 -0
  107. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/types/tmux_install_response.py +0 -0
  108. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/types/tmux_new_session_response.py +0 -0
  109. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/types/tmux_send_keys_response.py +0 -0
  110. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/types/tmux_session.py +0 -0
  111. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/types/tmux_sessions_list.py +0 -0
  112. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/types/tmux_snapshot_response.py +0 -0
  113. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/types/tmux_target.py +0 -0
  114. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/types/ttl.py +0 -0
  115. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/types/upload_response.py +0 -0
  116. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/types/validation_error.py +0 -0
  117. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/types/validation_error_loc_item.py +0 -0
  118. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/types/wake_on.py +0 -0
  119. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/uploads/__init__.py +0 -0
  120. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/uploads/client.py +0 -0
  121. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/uploads/raw_client.py +0 -0
  122. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/user_secrets/__init__.py +0 -0
  123. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/user_secrets/client.py +0 -0
  124. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/gen/user_secrets/raw_client.py +0 -0
  125. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/openapi.json +0 -0
  126. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/template_local_runner.py +0 -0
  127. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/template_runner.py +0 -0
  128. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/devbox/terminals.py +0 -0
  129. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/experimental/__init__.py +0 -0
  130. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/experimental/browser.py +0 -0
  131. {morphcloud-0.1.109 → morphcloud-0.1.111}/morphcloud/py.typed +0 -0
  132. {morphcloud-0.1.109 → morphcloud-0.1.111}/requirements.txt +0 -0
  133. {morphcloud-0.1.109 → morphcloud-0.1.111}/scripts/abuild_smoke.py +0 -0
  134. {morphcloud-0.1.109 → morphcloud-0.1.111}/scripts/increment_version.py +0 -0
  135. {morphcloud-0.1.109 → morphcloud-0.1.111}/tests/integration/__init__.py +0 -0
  136. {morphcloud-0.1.109 → morphcloud-0.1.111}/tests/integration/conftest.py +0 -0
  137. {morphcloud-0.1.109 → morphcloud-0.1.111}/tests/integration/pytest.ini +0 -0
  138. {morphcloud-0.1.109 → morphcloud-0.1.111}/tests/integration/test_as_container.py +0 -0
  139. {morphcloud-0.1.109 → morphcloud-0.1.111}/tests/integration/test_branching.py +0 -0
  140. {morphcloud-0.1.109 → morphcloud-0.1.111}/tests/integration/test_command_execution.py +0 -0
  141. {morphcloud-0.1.109 → morphcloud-0.1.111}/tests/integration/test_file_operations.py +0 -0
  142. {morphcloud-0.1.109 → morphcloud-0.1.111}/tests/integration/test_from_tag_multiple.py +0 -0
  143. {morphcloud-0.1.109 → morphcloud-0.1.111}/tests/integration/test_http_service.py +0 -0
  144. {morphcloud-0.1.109 → morphcloud-0.1.111}/tests/integration/test_metadata.py +0 -0
  145. {morphcloud-0.1.109 → morphcloud-0.1.111}/tests/integration/test_simple.py +0 -0
  146. {morphcloud-0.1.109 → morphcloud-0.1.111}/tests/integration/test_ssh_key_rotation.py +0 -0
  147. {morphcloud-0.1.109 → morphcloud-0.1.111}/tests/integration/test_ttl.py +0 -0
  148. {morphcloud-0.1.109 → morphcloud-0.1.111}/tests/integration/test_user.py +0 -0
  149. {morphcloud-0.1.109 → morphcloud-0.1.111}/tests/unit/test_cli_devbox.py +0 -0
  150. {morphcloud-0.1.109 → morphcloud-0.1.111}/tests/unit/test_cli_devbox_terminal.py +0 -0
  151. {morphcloud-0.1.109 → morphcloud-0.1.111}/tests/unit/test_cli_secrets.py +0 -0
  152. {morphcloud-0.1.109 → morphcloud-0.1.111}/tests/unit/test_devbox_terminals.py +0 -0
  153. {morphcloud-0.1.109 → morphcloud-0.1.111}/tests/unit/test_ssh_connect_retry.py +0 -0
  154. {morphcloud-0.1.109 → morphcloud-0.1.111}/tests/unit/test_template_local_runner.py +0 -0
  155. {morphcloud-0.1.109 → morphcloud-0.1.111}/tests/unit/test_template_runner.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: morphcloud
3
- Version: 0.1.109
3
+ Version: 0.1.111
4
4
  Summary: A Python SDK and CLI tool for creating, managing, and interacting with Morph Cloud VMs.
5
5
  Author-email: Morph Labs <jobs@morph.so>
6
6
  License: Apache-2.0
@@ -133,7 +133,7 @@ morphcloud image list [--json]
133
133
  morphcloud snapshot list [--json] [--metadata KEY=VALUE]
134
134
 
135
135
  # Create a new snapshot
136
- morphcloud snapshot create --image-id <id> --vcpus <n> --memory <mb> --disk-size <mb> [--digest <hash>] [--metadata KEY=VALUE]
136
+ morphcloud snapshot create --image-id <id> --vcpus <n> --memory <mb> --disk-size <mb> [--digest <hash>] [--ttl-seconds <seconds>] [--metadata KEY=VALUE]
137
137
 
138
138
  # Get detailed snapshot information
139
139
  morphcloud snapshot get <snapshot-id>
@@ -143,6 +143,9 @@ morphcloud snapshot delete <snapshot-id>
143
143
 
144
144
  # Set metadata on a snapshot
145
145
  morphcloud snapshot set-metadata <snapshot-id> KEY1=VALUE1 [KEY2=VALUE2...]
146
+
147
+ # Set or clear snapshot retention
148
+ morphcloud snapshot set-ttl <snapshot-id> --ttl-seconds <seconds|-1>
146
149
  ```
147
150
 
148
151
  ### Instances
@@ -170,7 +173,7 @@ morphcloud instance stop <instance-id>
170
173
  morphcloud instance get <instance-id>
171
174
 
172
175
  # Create snapshot from instance
173
- morphcloud instance snapshot <instance-id> [--digest <hash>] [--json]
176
+ morphcloud instance snapshot <instance-id> [--digest <hash>] [--ttl-seconds <seconds>] [--json]
174
177
 
175
178
  # Create multiple instances from an instance (branching)
176
179
  morphcloud instance branch <instance-id> [--count <n>] [--json]
@@ -213,6 +216,61 @@ morphcloud instance copy inst_123:/remote/file.log ./local_dir/
213
216
  morphcloud instance copy -r ./local_dir inst_123:/remote/dir
214
217
  ```
215
218
 
219
+ ### Volumes
220
+
221
+ Morph Volumes exposes an S3-compatible object store. Bucket lifecycle and object operations use your normal `MORPH_API_KEY`.
222
+
223
+ ```python
224
+ import os
225
+
226
+ from morphcloud import MorphCloudClient
227
+
228
+ client = MorphCloudClient(
229
+ api_key=os.environ["MORPH_API_KEY"],
230
+ )
231
+
232
+ client.volumes.create_bucket("my-volume-bucket")
233
+ client.volumes.put_object(
234
+ "my-volume-bucket",
235
+ "builds/artifact.txt",
236
+ b"hello from morph volumes\n",
237
+ content_type="text/plain",
238
+ )
239
+ payload = client.volumes.get_object("my-volume-bucket", "builds/artifact.txt")
240
+ client.volumes.delete_object("my-volume-bucket", "builds/artifact.txt")
241
+ client.volumes.delete_bucket("my-volume-bucket")
242
+ ```
243
+
244
+ ```bash
245
+ # List buckets
246
+ morphcloud volumes ls
247
+
248
+ # Create or remove a bucket
249
+ morphcloud volumes mb my-volume-bucket
250
+ morphcloud volumes rb my-volume-bucket
251
+
252
+ # Browse a bucket or prefix
253
+ morphcloud volumes ls s3://my-volume-bucket/
254
+ morphcloud volumes tree s3://my-volume-bucket/project/
255
+
256
+ # Upload, inspect, and download objects
257
+ morphcloud volumes cp ./artifact.txt s3://my-volume-bucket/builds/artifact.txt
258
+ morphcloud volumes cp -r ./dataset s3://my-volume-bucket/datasets/
259
+ morphcloud volumes cat s3://my-volume-bucket/builds/artifact.txt
260
+ morphcloud volumes cp s3://my-volume-bucket/builds/artifact.txt ./downloads/
261
+
262
+ # Remove objects under a prefix
263
+ morphcloud volumes rm -r s3://my-volume-bucket/datasets/
264
+ ```
265
+
266
+ If you use profiles with non-default endpoints, the volumes gateway can be configured explicitly:
267
+
268
+ ```bash
269
+ morphcloud profile set prod \
270
+ --api-host cloud.morph.so \
271
+ --volumes-base-url https://volumes.svc.cloud.morph.so
272
+ ```
273
+
216
274
  ### Interactive Tools
217
275
 
218
276
  ```bash
@@ -396,6 +454,48 @@ instance.stop()
396
454
  print("done\n")
397
455
  ```
398
456
 
457
+ ### Snapshot TTL
458
+
459
+ `ttl_seconds` must be greater than zero when you set it. Use `None` in Python or `--ttl-seconds -1` in the CLI to remove an existing snapshot TTL.
460
+
461
+ ```python
462
+ from morphcloud.api import MorphCloudClient
463
+
464
+ client = MorphCloudClient()
465
+
466
+ snapshot = client.snapshots.create(
467
+ image_id="morphvm-minimal",
468
+ vcpus=1,
469
+ memory=512,
470
+ disk_size=1024,
471
+ ttl_seconds=3600, # auto-delete after 1 hour of inactivity
472
+ )
473
+
474
+ print(snapshot.ttl.ttl_seconds)
475
+ print(snapshot.ttl.ttl_expire_at)
476
+
477
+ # Update an existing snapshot TTL
478
+ snapshot.set_ttl(7200)
479
+
480
+ # Clear the snapshot TTL entirely
481
+ snapshot.set_ttl(None)
482
+ ```
483
+
484
+ CLI equivalents:
485
+
486
+ ```bash
487
+ morphcloud snapshot create \
488
+ --image-id morphvm-minimal \
489
+ --vcpus 1 \
490
+ --memory 512 \
491
+ --disk-size 1024 \
492
+ --ttl-seconds 3600
493
+
494
+ morphcloud snapshot set-ttl snapshot_123 --ttl-seconds 7200
495
+ morphcloud snapshot set-ttl snapshot_123 --ttl-seconds -1
496
+ morphcloud instance snapshot instance_123 --ttl-seconds 3600
497
+ ```
498
+
399
499
  ### Working with SSH
400
500
 
401
501
  ```python
@@ -102,7 +102,7 @@ morphcloud image list [--json]
102
102
  morphcloud snapshot list [--json] [--metadata KEY=VALUE]
103
103
 
104
104
  # Create a new snapshot
105
- morphcloud snapshot create --image-id <id> --vcpus <n> --memory <mb> --disk-size <mb> [--digest <hash>] [--metadata KEY=VALUE]
105
+ morphcloud snapshot create --image-id <id> --vcpus <n> --memory <mb> --disk-size <mb> [--digest <hash>] [--ttl-seconds <seconds>] [--metadata KEY=VALUE]
106
106
 
107
107
  # Get detailed snapshot information
108
108
  morphcloud snapshot get <snapshot-id>
@@ -112,6 +112,9 @@ morphcloud snapshot delete <snapshot-id>
112
112
 
113
113
  # Set metadata on a snapshot
114
114
  morphcloud snapshot set-metadata <snapshot-id> KEY1=VALUE1 [KEY2=VALUE2...]
115
+
116
+ # Set or clear snapshot retention
117
+ morphcloud snapshot set-ttl <snapshot-id> --ttl-seconds <seconds|-1>
115
118
  ```
116
119
 
117
120
  ### Instances
@@ -139,7 +142,7 @@ morphcloud instance stop <instance-id>
139
142
  morphcloud instance get <instance-id>
140
143
 
141
144
  # Create snapshot from instance
142
- morphcloud instance snapshot <instance-id> [--digest <hash>] [--json]
145
+ morphcloud instance snapshot <instance-id> [--digest <hash>] [--ttl-seconds <seconds>] [--json]
143
146
 
144
147
  # Create multiple instances from an instance (branching)
145
148
  morphcloud instance branch <instance-id> [--count <n>] [--json]
@@ -182,6 +185,61 @@ morphcloud instance copy inst_123:/remote/file.log ./local_dir/
182
185
  morphcloud instance copy -r ./local_dir inst_123:/remote/dir
183
186
  ```
184
187
 
188
+ ### Volumes
189
+
190
+ Morph Volumes exposes an S3-compatible object store. Bucket lifecycle and object operations use your normal `MORPH_API_KEY`.
191
+
192
+ ```python
193
+ import os
194
+
195
+ from morphcloud import MorphCloudClient
196
+
197
+ client = MorphCloudClient(
198
+ api_key=os.environ["MORPH_API_KEY"],
199
+ )
200
+
201
+ client.volumes.create_bucket("my-volume-bucket")
202
+ client.volumes.put_object(
203
+ "my-volume-bucket",
204
+ "builds/artifact.txt",
205
+ b"hello from morph volumes\n",
206
+ content_type="text/plain",
207
+ )
208
+ payload = client.volumes.get_object("my-volume-bucket", "builds/artifact.txt")
209
+ client.volumes.delete_object("my-volume-bucket", "builds/artifact.txt")
210
+ client.volumes.delete_bucket("my-volume-bucket")
211
+ ```
212
+
213
+ ```bash
214
+ # List buckets
215
+ morphcloud volumes ls
216
+
217
+ # Create or remove a bucket
218
+ morphcloud volumes mb my-volume-bucket
219
+ morphcloud volumes rb my-volume-bucket
220
+
221
+ # Browse a bucket or prefix
222
+ morphcloud volumes ls s3://my-volume-bucket/
223
+ morphcloud volumes tree s3://my-volume-bucket/project/
224
+
225
+ # Upload, inspect, and download objects
226
+ morphcloud volumes cp ./artifact.txt s3://my-volume-bucket/builds/artifact.txt
227
+ morphcloud volumes cp -r ./dataset s3://my-volume-bucket/datasets/
228
+ morphcloud volumes cat s3://my-volume-bucket/builds/artifact.txt
229
+ morphcloud volumes cp s3://my-volume-bucket/builds/artifact.txt ./downloads/
230
+
231
+ # Remove objects under a prefix
232
+ morphcloud volumes rm -r s3://my-volume-bucket/datasets/
233
+ ```
234
+
235
+ If you use profiles with non-default endpoints, the volumes gateway can be configured explicitly:
236
+
237
+ ```bash
238
+ morphcloud profile set prod \
239
+ --api-host cloud.morph.so \
240
+ --volumes-base-url https://volumes.svc.cloud.morph.so
241
+ ```
242
+
185
243
  ### Interactive Tools
186
244
 
187
245
  ```bash
@@ -365,6 +423,48 @@ instance.stop()
365
423
  print("done\n")
366
424
  ```
367
425
 
426
+ ### Snapshot TTL
427
+
428
+ `ttl_seconds` must be greater than zero when you set it. Use `None` in Python or `--ttl-seconds -1` in the CLI to remove an existing snapshot TTL.
429
+
430
+ ```python
431
+ from morphcloud.api import MorphCloudClient
432
+
433
+ client = MorphCloudClient()
434
+
435
+ snapshot = client.snapshots.create(
436
+ image_id="morphvm-minimal",
437
+ vcpus=1,
438
+ memory=512,
439
+ disk_size=1024,
440
+ ttl_seconds=3600, # auto-delete after 1 hour of inactivity
441
+ )
442
+
443
+ print(snapshot.ttl.ttl_seconds)
444
+ print(snapshot.ttl.ttl_expire_at)
445
+
446
+ # Update an existing snapshot TTL
447
+ snapshot.set_ttl(7200)
448
+
449
+ # Clear the snapshot TTL entirely
450
+ snapshot.set_ttl(None)
451
+ ```
452
+
453
+ CLI equivalents:
454
+
455
+ ```bash
456
+ morphcloud snapshot create \
457
+ --image-id morphvm-minimal \
458
+ --vcpus 1 \
459
+ --memory 512 \
460
+ --disk-size 1024 \
461
+ --ttl-seconds 3600
462
+
463
+ morphcloud snapshot set-ttl snapshot_123 --ttl-seconds 7200
464
+ morphcloud snapshot set-ttl snapshot_123 --ttl-seconds -1
465
+ morphcloud instance snapshot instance_123 --ttl-seconds 3600
466
+ ```
467
+
368
468
  ### Working with SSH
369
469
 
370
470
  ```python
@@ -24,6 +24,7 @@ from morphcloud.config import resolve_settings
24
24
 
25
25
  if typing.TYPE_CHECKING:
26
26
  from morphcloud.devbox.client import AsyncDevboxClient, DevboxClient
27
+ from morphcloud.volumes.client import VolumesClient
27
28
 
28
29
  # Global console instance
29
30
  console = Console()
@@ -109,6 +110,29 @@ class TTL(BaseModel):
109
110
  )
110
111
 
111
112
 
113
+ class SnapshotTTL(BaseModel):
114
+ """Represents the retention policy for a snapshot."""
115
+
116
+ ttl_seconds: typing.Optional[int] = Field(
117
+ None, description="Time in seconds until the snapshot expires."
118
+ )
119
+ ttl_expire_at: typing.Optional[int] = Field(
120
+ None, description="Unix timestamp when the snapshot is set to expire."
121
+ )
122
+
123
+
124
+ def _validate_snapshot_ttl_seconds(
125
+ ttl_seconds: typing.Optional[int], *, allow_none: bool
126
+ ) -> None:
127
+ """Validate snapshot TTL values before sending them to the API."""
128
+ if ttl_seconds is None:
129
+ if allow_none:
130
+ return
131
+ raise ValueError("ttl_seconds must be greater than zero")
132
+ if ttl_seconds <= 0:
133
+ raise ValueError("ttl_seconds must be greater than zero")
134
+
135
+
112
136
  class WakeOn(BaseModel):
113
137
  """Represents the wake-on-event configuration for an instance."""
114
138
 
@@ -139,6 +163,8 @@ class MorphCloudClient:
139
163
  profile: typing.Optional[str] = None,
140
164
  api_host: typing.Optional[str] = None,
141
165
  service_base_url: typing.Optional[str] = None,
166
+ volumes_base_url: typing.Optional[str] = None,
167
+ volumes_service_api_key: typing.Optional[str] = None,
142
168
  admin_base_url: typing.Optional[str] = None,
143
169
  db_base_url: typing.Optional[str] = None,
144
170
  ssh_hostname: typing.Optional[str] = None,
@@ -152,6 +178,7 @@ class MorphCloudClient:
152
178
  "base_url": base_url,
153
179
  "api_host": api_host,
154
180
  "service_base_url": service_base_url,
181
+ "volumes_base_url": volumes_base_url,
155
182
  "devbox_base_url": devbox_base_url,
156
183
  "admin_base_url": admin_base_url,
157
184
  "db_base_url": db_base_url,
@@ -166,12 +193,17 @@ class MorphCloudClient:
166
193
  self.ssh_hostname = settings.ssh_hostname
167
194
  self.ssh_port = settings.ssh_port
168
195
  self.service_base_url = settings.service_base_url
196
+ self.volumes_base_url = settings.volumes_base_url
197
+ self.volumes_service_api_key = volumes_service_api_key or os.environ.get(
198
+ "MORPH_VOLUMES_SERVICE_API_KEY"
199
+ )
169
200
  self.devbox_base_url = settings.devbox_base_url
170
201
  self.admin_base_url = settings.admin_base_url
171
202
  self.db_base_url = settings.db_base_url
172
203
  self.profile = settings.profile
173
204
  self._devbox_client = None
174
205
  self._devbox_async_client = None
206
+ self._volumes_client = None
175
207
  if not self.api_key:
176
208
  raise ValueError(
177
209
  "API key must be provided or set in MORPH_API_KEY environment variable"
@@ -295,6 +327,18 @@ class MorphCloudClient:
295
327
  """Access the API for the current authenticated user."""
296
328
  return UserAPI(self)
297
329
 
330
+ @property
331
+ def volumes(self) -> "VolumesClient":
332
+ if self._volumes_client is None:
333
+ from morphcloud.volumes.client import VolumesClient
334
+
335
+ self._volumes_client = VolumesClient(self)
336
+ return self._volumes_client
337
+
338
+ @volumes.setter
339
+ def volumes(self, value: "VolumesClient") -> None:
340
+ self._volumes_client = value
341
+
298
342
 
299
343
  class BaseAPI:
300
344
  def __init__(self, client: MorphCloudClient):
@@ -645,6 +689,7 @@ class SnapshotAPI:
645
689
  disk_size: typing.Optional[int] = None,
646
690
  digest: typing.Optional[str] = None,
647
691
  metadata: typing.Optional[typing.Dict[str, str]] = None,
692
+ ttl_seconds: typing.Optional[int] = None,
648
693
  ) -> Snapshot:
649
694
  """Create a new snapshot from a base image and a machine configuration.
650
695
 
@@ -654,7 +699,11 @@ class SnapshotAPI:
654
699
  memory: The amount of memory (in MB) for the snapshot.
655
700
  disk_size: The size of the snapshot (in MB).
656
701
  digest: Optional digest for the snapshot. If provided, it will be used to identify the snapshot. If a snapshot with the same digest already exists, it will be returned instead of creating a new one.
657
- metadata: Optional metadata to attach to the snapshot."""
702
+ metadata: Optional metadata to attach to the snapshot.
703
+ ttl_seconds: Optional retention period in seconds before the snapshot is automatically deleted.
704
+ """
705
+ if ttl_seconds is not None:
706
+ _validate_snapshot_ttl_seconds(ttl_seconds, allow_none=False)
658
707
  body = {}
659
708
  if image_id is not None:
660
709
  body["image_id"] = image_id
@@ -668,6 +717,8 @@ class SnapshotAPI:
668
717
  body["digest"] = digest
669
718
  if metadata is not None:
670
719
  body["metadata"] = metadata
720
+ if ttl_seconds is not None:
721
+ body["ttl_seconds"] = ttl_seconds
671
722
  response = self._client._http_client.post("/snapshot", json=body)
672
723
  snap: Snapshot = Snapshot.model_validate(response.json())._set_api(self)
673
724
  snap.wait_until_ready()
@@ -681,6 +732,7 @@ class SnapshotAPI:
681
732
  disk_size: typing.Optional[int] = None,
682
733
  digest: typing.Optional[str] = None,
683
734
  metadata: typing.Optional[typing.Dict[str, str]] = None,
735
+ ttl_seconds: typing.Optional[int] = None,
684
736
  ) -> Snapshot:
685
737
  """Create a new snapshot from a base image and a machine configuration.
686
738
 
@@ -690,7 +742,11 @@ class SnapshotAPI:
690
742
  memory: The amount of memory (in MB) for the snapshot.
691
743
  disk_size: The size of the snapshot (in MB).
692
744
  digest: Optional digest for the snapshot. If provided, it will be used to identify the snapshot. If a snapshot with the same digest already exists, it will be returned instead of creating a new one.
693
- metadata: Optional metadata to attach to the snapshot."""
745
+ metadata: Optional metadata to attach to the snapshot.
746
+ ttl_seconds: Optional retention period in seconds before the snapshot is automatically deleted.
747
+ """
748
+ if ttl_seconds is not None:
749
+ _validate_snapshot_ttl_seconds(ttl_seconds, allow_none=False)
694
750
  body = {}
695
751
  if image_id is not None:
696
752
  body["image_id"] = image_id
@@ -704,6 +760,8 @@ class SnapshotAPI:
704
760
  body["digest"] = digest
705
761
  if metadata is not None:
706
762
  body["metadata"] = metadata
763
+ if ttl_seconds is not None:
764
+ body["ttl_seconds"] = ttl_seconds
707
765
  response = await self._client._async_http_client.post("/snapshot", json=body)
708
766
  snap: Snapshot = Snapshot.model_validate(response.json())._set_api(self)
709
767
  await snap.await_until_ready()
@@ -735,6 +793,7 @@ class Snapshot(BaseModel):
735
793
  metadata: typing.Dict[str, str] = Field(
736
794
  default_factory=dict, description="User provided metadata"
737
795
  )
796
+ ttl: SnapshotTTL = Field(default_factory=SnapshotTTL)
738
797
 
739
798
  _api: SnapshotAPI = PrivateAttr()
740
799
 
@@ -771,6 +830,28 @@ class Snapshot(BaseModel):
771
830
  response.raise_for_status()
772
831
  await self._refresh_async()
773
832
 
833
+ def set_ttl(self, ttl_seconds: typing.Optional[int]) -> None:
834
+ """Set or clear the retention policy for this snapshot.
835
+
836
+ Parameters:
837
+ ttl_seconds: New TTL in seconds. Pass None to remove the snapshot TTL.
838
+ """
839
+ _validate_snapshot_ttl_seconds(ttl_seconds, allow_none=True)
840
+ response = self._api._client._http_client.post(
841
+ f"/snapshot/{self.id}/ttl", json={"ttl_seconds": ttl_seconds}
842
+ )
843
+ response.raise_for_status()
844
+ self._refresh()
845
+
846
+ async def aset_ttl(self, ttl_seconds: typing.Optional[int]) -> None:
847
+ """Async version of set_ttl()."""
848
+ _validate_snapshot_ttl_seconds(ttl_seconds, allow_none=True)
849
+ response = await self._api._client._async_http_client.post(
850
+ f"/snapshot/{self.id}/ttl", json={"ttl_seconds": ttl_seconds}
851
+ )
852
+ response.raise_for_status()
853
+ await self._refresh_async()
854
+
774
855
  def _refresh(self) -> None:
775
856
  refreshed = self._api.get(self.id)
776
857
  updated = type(self).model_validate(refreshed.model_dump())
@@ -2769,13 +2850,27 @@ class Instance(BaseModel):
2769
2850
  self,
2770
2851
  digest: typing.Optional[str] = None,
2771
2852
  metadata: typing.Optional[typing.Dict[str, str]] = None,
2853
+ ttl_seconds: typing.Optional[int] = None,
2772
2854
  ) -> Snapshot:
2773
- """Save the instance as a snapshot."""
2855
+ """Save the instance as a snapshot.
2856
+
2857
+ Parameters:
2858
+ digest: Optional digest to associate with the new snapshot.
2859
+ metadata: Optional metadata to attach to the new snapshot.
2860
+ ttl_seconds: Optional retention period in seconds before the snapshot is automatically deleted.
2861
+ """
2862
+ if ttl_seconds is not None:
2863
+ _validate_snapshot_ttl_seconds(ttl_seconds, allow_none=False)
2774
2864
  params = {}
2775
2865
  if digest is not None:
2776
2866
  params["digest"] = digest
2867
+ body: typing.Dict[str, typing.Any] = {}
2868
+ if metadata is not None:
2869
+ body["metadata"] = metadata
2870
+ if ttl_seconds is not None:
2871
+ body["ttl_seconds"] = ttl_seconds
2777
2872
  response = self._api._client._http_client.post(
2778
- f"/instance/{self.id}/snapshot", params=params, json=dict(metadata=metadata)
2873
+ f"/instance/{self.id}/snapshot", params=params, json=body
2779
2874
  )
2780
2875
  return Snapshot.model_validate(response.json())._set_api(
2781
2876
  self._api._client.snapshots,
@@ -2785,13 +2880,21 @@ class Instance(BaseModel):
2785
2880
  self,
2786
2881
  digest: typing.Optional[str] = None,
2787
2882
  metadata: typing.Optional[typing.Dict[str, str]] = None,
2883
+ ttl_seconds: typing.Optional[int] = None,
2788
2884
  ) -> Snapshot:
2789
- """Save the instance as a snapshot."""
2885
+ """Async version of snapshot()."""
2886
+ if ttl_seconds is not None:
2887
+ _validate_snapshot_ttl_seconds(ttl_seconds, allow_none=False)
2790
2888
  params = {}
2791
2889
  if digest is not None:
2792
2890
  params = {"digest": digest}
2891
+ body: typing.Dict[str, typing.Any] = {}
2892
+ if metadata is not None:
2893
+ body["metadata"] = metadata
2894
+ if ttl_seconds is not None:
2895
+ body["ttl_seconds"] = ttl_seconds
2793
2896
  response = await self._api._client._async_http_client.post(
2794
- f"/instance/{self.id}/snapshot", params=params, json=dict(metadata=metadata)
2897
+ f"/instance/{self.id}/snapshot", params=params, json=body
2795
2898
  )
2796
2899
  return Snapshot.model_validate(response.json())._set_api(
2797
2900
  self._api._client.snapshots