skypilot-nightly 1.0.0.dev2024053101__py3-none-any.whl → 1.0.0.dev2025022801__py3-none-any.whl

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 (299) hide show
  1. sky/__init__.py +64 -32
  2. sky/adaptors/aws.py +23 -6
  3. sky/adaptors/azure.py +432 -15
  4. sky/adaptors/cloudflare.py +5 -5
  5. sky/adaptors/common.py +19 -9
  6. sky/adaptors/do.py +20 -0
  7. sky/adaptors/gcp.py +3 -2
  8. sky/adaptors/kubernetes.py +122 -88
  9. sky/adaptors/nebius.py +100 -0
  10. sky/adaptors/oci.py +39 -1
  11. sky/adaptors/vast.py +29 -0
  12. sky/admin_policy.py +101 -0
  13. sky/authentication.py +117 -98
  14. sky/backends/backend.py +52 -20
  15. sky/backends/backend_utils.py +669 -557
  16. sky/backends/cloud_vm_ray_backend.py +1099 -808
  17. sky/backends/local_docker_backend.py +14 -8
  18. sky/backends/wheel_utils.py +38 -20
  19. sky/benchmark/benchmark_utils.py +22 -23
  20. sky/check.py +76 -27
  21. sky/cli.py +1586 -1139
  22. sky/client/__init__.py +1 -0
  23. sky/client/cli.py +5683 -0
  24. sky/client/common.py +345 -0
  25. sky/client/sdk.py +1765 -0
  26. sky/cloud_stores.py +283 -19
  27. sky/clouds/__init__.py +7 -2
  28. sky/clouds/aws.py +303 -112
  29. sky/clouds/azure.py +185 -179
  30. sky/clouds/cloud.py +115 -37
  31. sky/clouds/cudo.py +29 -22
  32. sky/clouds/do.py +313 -0
  33. sky/clouds/fluidstack.py +44 -54
  34. sky/clouds/gcp.py +206 -65
  35. sky/clouds/ibm.py +26 -21
  36. sky/clouds/kubernetes.py +345 -91
  37. sky/clouds/lambda_cloud.py +40 -29
  38. sky/clouds/nebius.py +297 -0
  39. sky/clouds/oci.py +129 -90
  40. sky/clouds/paperspace.py +22 -18
  41. sky/clouds/runpod.py +53 -34
  42. sky/clouds/scp.py +28 -24
  43. sky/clouds/service_catalog/__init__.py +19 -13
  44. sky/clouds/service_catalog/aws_catalog.py +29 -12
  45. sky/clouds/service_catalog/azure_catalog.py +33 -6
  46. sky/clouds/service_catalog/common.py +95 -75
  47. sky/clouds/service_catalog/constants.py +3 -3
  48. sky/clouds/service_catalog/cudo_catalog.py +13 -3
  49. sky/clouds/service_catalog/data_fetchers/fetch_aws.py +36 -21
  50. sky/clouds/service_catalog/data_fetchers/fetch_azure.py +31 -4
  51. sky/clouds/service_catalog/data_fetchers/fetch_cudo.py +8 -117
  52. sky/clouds/service_catalog/data_fetchers/fetch_fluidstack.py +197 -44
  53. sky/clouds/service_catalog/data_fetchers/fetch_gcp.py +224 -36
  54. sky/clouds/service_catalog/data_fetchers/fetch_lambda_cloud.py +44 -24
  55. sky/clouds/service_catalog/data_fetchers/fetch_vast.py +147 -0
  56. sky/clouds/service_catalog/data_fetchers/fetch_vsphere.py +1 -1
  57. sky/clouds/service_catalog/do_catalog.py +111 -0
  58. sky/clouds/service_catalog/fluidstack_catalog.py +2 -2
  59. sky/clouds/service_catalog/gcp_catalog.py +16 -2
  60. sky/clouds/service_catalog/ibm_catalog.py +2 -2
  61. sky/clouds/service_catalog/kubernetes_catalog.py +192 -70
  62. sky/clouds/service_catalog/lambda_catalog.py +8 -3
  63. sky/clouds/service_catalog/nebius_catalog.py +116 -0
  64. sky/clouds/service_catalog/oci_catalog.py +31 -4
  65. sky/clouds/service_catalog/paperspace_catalog.py +2 -2
  66. sky/clouds/service_catalog/runpod_catalog.py +2 -2
  67. sky/clouds/service_catalog/scp_catalog.py +2 -2
  68. sky/clouds/service_catalog/vast_catalog.py +104 -0
  69. sky/clouds/service_catalog/vsphere_catalog.py +2 -2
  70. sky/clouds/utils/aws_utils.py +65 -0
  71. sky/clouds/utils/azure_utils.py +91 -0
  72. sky/clouds/utils/gcp_utils.py +5 -9
  73. sky/clouds/utils/oci_utils.py +47 -5
  74. sky/clouds/utils/scp_utils.py +4 -3
  75. sky/clouds/vast.py +280 -0
  76. sky/clouds/vsphere.py +22 -18
  77. sky/core.py +361 -107
  78. sky/dag.py +41 -28
  79. sky/data/data_transfer.py +37 -0
  80. sky/data/data_utils.py +211 -32
  81. sky/data/mounting_utils.py +182 -30
  82. sky/data/storage.py +2118 -270
  83. sky/data/storage_utils.py +126 -5
  84. sky/exceptions.py +179 -8
  85. sky/execution.py +158 -85
  86. sky/global_user_state.py +150 -34
  87. sky/jobs/__init__.py +12 -10
  88. sky/jobs/client/__init__.py +0 -0
  89. sky/jobs/client/sdk.py +302 -0
  90. sky/jobs/constants.py +49 -11
  91. sky/jobs/controller.py +161 -99
  92. sky/jobs/dashboard/dashboard.py +171 -25
  93. sky/jobs/dashboard/templates/index.html +572 -60
  94. sky/jobs/recovery_strategy.py +157 -156
  95. sky/jobs/scheduler.py +307 -0
  96. sky/jobs/server/__init__.py +1 -0
  97. sky/jobs/server/core.py +598 -0
  98. sky/jobs/server/dashboard_utils.py +69 -0
  99. sky/jobs/server/server.py +190 -0
  100. sky/jobs/state.py +627 -122
  101. sky/jobs/utils.py +615 -206
  102. sky/models.py +27 -0
  103. sky/optimizer.py +142 -83
  104. sky/provision/__init__.py +20 -5
  105. sky/provision/aws/config.py +124 -42
  106. sky/provision/aws/instance.py +130 -53
  107. sky/provision/azure/__init__.py +7 -0
  108. sky/{skylet/providers → provision}/azure/azure-config-template.json +19 -7
  109. sky/provision/azure/config.py +220 -0
  110. sky/provision/azure/instance.py +1012 -37
  111. sky/provision/common.py +31 -3
  112. sky/provision/constants.py +25 -0
  113. sky/provision/cudo/__init__.py +2 -1
  114. sky/provision/cudo/cudo_utils.py +112 -0
  115. sky/provision/cudo/cudo_wrapper.py +37 -16
  116. sky/provision/cudo/instance.py +28 -12
  117. sky/provision/do/__init__.py +11 -0
  118. sky/provision/do/config.py +14 -0
  119. sky/provision/do/constants.py +10 -0
  120. sky/provision/do/instance.py +287 -0
  121. sky/provision/do/utils.py +301 -0
  122. sky/provision/docker_utils.py +82 -46
  123. sky/provision/fluidstack/fluidstack_utils.py +57 -125
  124. sky/provision/fluidstack/instance.py +15 -43
  125. sky/provision/gcp/config.py +19 -9
  126. sky/provision/gcp/constants.py +7 -1
  127. sky/provision/gcp/instance.py +55 -34
  128. sky/provision/gcp/instance_utils.py +339 -80
  129. sky/provision/gcp/mig_utils.py +210 -0
  130. sky/provision/instance_setup.py +172 -133
  131. sky/provision/kubernetes/__init__.py +1 -0
  132. sky/provision/kubernetes/config.py +104 -90
  133. sky/provision/kubernetes/constants.py +8 -0
  134. sky/provision/kubernetes/instance.py +680 -325
  135. sky/provision/kubernetes/manifests/smarter-device-manager-daemonset.yaml +3 -0
  136. sky/provision/kubernetes/network.py +54 -20
  137. sky/provision/kubernetes/network_utils.py +70 -21
  138. sky/provision/kubernetes/utils.py +1370 -251
  139. sky/provision/lambda_cloud/__init__.py +11 -0
  140. sky/provision/lambda_cloud/config.py +10 -0
  141. sky/provision/lambda_cloud/instance.py +265 -0
  142. sky/{clouds/utils → provision/lambda_cloud}/lambda_utils.py +24 -23
  143. sky/provision/logging.py +1 -1
  144. sky/provision/nebius/__init__.py +11 -0
  145. sky/provision/nebius/config.py +11 -0
  146. sky/provision/nebius/instance.py +285 -0
  147. sky/provision/nebius/utils.py +318 -0
  148. sky/provision/oci/__init__.py +15 -0
  149. sky/provision/oci/config.py +51 -0
  150. sky/provision/oci/instance.py +436 -0
  151. sky/provision/oci/query_utils.py +681 -0
  152. sky/provision/paperspace/constants.py +6 -0
  153. sky/provision/paperspace/instance.py +4 -3
  154. sky/provision/paperspace/utils.py +2 -0
  155. sky/provision/provisioner.py +207 -130
  156. sky/provision/runpod/__init__.py +1 -0
  157. sky/provision/runpod/api/__init__.py +3 -0
  158. sky/provision/runpod/api/commands.py +119 -0
  159. sky/provision/runpod/api/pods.py +142 -0
  160. sky/provision/runpod/instance.py +64 -8
  161. sky/provision/runpod/utils.py +239 -23
  162. sky/provision/vast/__init__.py +10 -0
  163. sky/provision/vast/config.py +11 -0
  164. sky/provision/vast/instance.py +247 -0
  165. sky/provision/vast/utils.py +162 -0
  166. sky/provision/vsphere/common/vim_utils.py +1 -1
  167. sky/provision/vsphere/instance.py +8 -18
  168. sky/provision/vsphere/vsphere_utils.py +1 -1
  169. sky/resources.py +247 -102
  170. sky/serve/__init__.py +9 -9
  171. sky/serve/autoscalers.py +361 -299
  172. sky/serve/client/__init__.py +0 -0
  173. sky/serve/client/sdk.py +366 -0
  174. sky/serve/constants.py +12 -3
  175. sky/serve/controller.py +106 -36
  176. sky/serve/load_balancer.py +63 -12
  177. sky/serve/load_balancing_policies.py +84 -2
  178. sky/serve/replica_managers.py +42 -34
  179. sky/serve/serve_state.py +62 -32
  180. sky/serve/serve_utils.py +271 -160
  181. sky/serve/server/__init__.py +0 -0
  182. sky/serve/{core.py → server/core.py} +271 -90
  183. sky/serve/server/server.py +112 -0
  184. sky/serve/service.py +52 -16
  185. sky/serve/service_spec.py +95 -32
  186. sky/server/__init__.py +1 -0
  187. sky/server/common.py +430 -0
  188. sky/server/constants.py +21 -0
  189. sky/server/html/log.html +174 -0
  190. sky/server/requests/__init__.py +0 -0
  191. sky/server/requests/executor.py +472 -0
  192. sky/server/requests/payloads.py +487 -0
  193. sky/server/requests/queues/__init__.py +0 -0
  194. sky/server/requests/queues/mp_queue.py +76 -0
  195. sky/server/requests/requests.py +567 -0
  196. sky/server/requests/serializers/__init__.py +0 -0
  197. sky/server/requests/serializers/decoders.py +192 -0
  198. sky/server/requests/serializers/encoders.py +166 -0
  199. sky/server/server.py +1106 -0
  200. sky/server/stream_utils.py +141 -0
  201. sky/setup_files/MANIFEST.in +2 -5
  202. sky/setup_files/dependencies.py +159 -0
  203. sky/setup_files/setup.py +14 -125
  204. sky/sky_logging.py +59 -14
  205. sky/skylet/autostop_lib.py +2 -2
  206. sky/skylet/constants.py +183 -50
  207. sky/skylet/events.py +22 -10
  208. sky/skylet/job_lib.py +403 -258
  209. sky/skylet/log_lib.py +111 -71
  210. sky/skylet/log_lib.pyi +6 -0
  211. sky/skylet/providers/command_runner.py +6 -8
  212. sky/skylet/providers/ibm/node_provider.py +2 -2
  213. sky/skylet/providers/scp/config.py +11 -3
  214. sky/skylet/providers/scp/node_provider.py +8 -8
  215. sky/skylet/skylet.py +3 -1
  216. sky/skylet/subprocess_daemon.py +69 -17
  217. sky/skypilot_config.py +119 -57
  218. sky/task.py +205 -64
  219. sky/templates/aws-ray.yml.j2 +37 -7
  220. sky/templates/azure-ray.yml.j2 +27 -82
  221. sky/templates/cudo-ray.yml.j2 +7 -3
  222. sky/templates/do-ray.yml.j2 +98 -0
  223. sky/templates/fluidstack-ray.yml.j2 +7 -4
  224. sky/templates/gcp-ray.yml.j2 +26 -6
  225. sky/templates/ibm-ray.yml.j2 +3 -2
  226. sky/templates/jobs-controller.yaml.j2 +46 -11
  227. sky/templates/kubernetes-ingress.yml.j2 +7 -0
  228. sky/templates/kubernetes-loadbalancer.yml.j2 +7 -0
  229. sky/templates/{kubernetes-port-forward-proxy-command.sh.j2 → kubernetes-port-forward-proxy-command.sh} +51 -7
  230. sky/templates/kubernetes-ray.yml.j2 +292 -25
  231. sky/templates/lambda-ray.yml.j2 +30 -40
  232. sky/templates/nebius-ray.yml.j2 +79 -0
  233. sky/templates/oci-ray.yml.j2 +18 -57
  234. sky/templates/paperspace-ray.yml.j2 +10 -6
  235. sky/templates/runpod-ray.yml.j2 +26 -4
  236. sky/templates/scp-ray.yml.j2 +3 -2
  237. sky/templates/sky-serve-controller.yaml.j2 +12 -1
  238. sky/templates/skypilot-server-kubernetes-proxy.sh +36 -0
  239. sky/templates/vast-ray.yml.j2 +70 -0
  240. sky/templates/vsphere-ray.yml.j2 +8 -3
  241. sky/templates/websocket_proxy.py +64 -0
  242. sky/usage/constants.py +10 -1
  243. sky/usage/usage_lib.py +130 -37
  244. sky/utils/accelerator_registry.py +35 -51
  245. sky/utils/admin_policy_utils.py +147 -0
  246. sky/utils/annotations.py +51 -0
  247. sky/utils/cli_utils/status_utils.py +81 -23
  248. sky/utils/cluster_utils.py +356 -0
  249. sky/utils/command_runner.py +452 -89
  250. sky/utils/command_runner.pyi +77 -3
  251. sky/utils/common.py +54 -0
  252. sky/utils/common_utils.py +319 -108
  253. sky/utils/config_utils.py +204 -0
  254. sky/utils/control_master_utils.py +48 -0
  255. sky/utils/controller_utils.py +548 -266
  256. sky/utils/dag_utils.py +93 -32
  257. sky/utils/db_utils.py +18 -4
  258. sky/utils/env_options.py +29 -7
  259. sky/utils/kubernetes/create_cluster.sh +8 -60
  260. sky/utils/kubernetes/deploy_remote_cluster.sh +243 -0
  261. sky/utils/kubernetes/exec_kubeconfig_converter.py +73 -0
  262. sky/utils/kubernetes/generate_kubeconfig.sh +336 -0
  263. sky/utils/kubernetes/gpu_labeler.py +4 -4
  264. sky/utils/kubernetes/k8s_gpu_labeler_job.yaml +4 -3
  265. sky/utils/kubernetes/kubernetes_deploy_utils.py +228 -0
  266. sky/utils/kubernetes/rsync_helper.sh +24 -0
  267. sky/utils/kubernetes/ssh_jump_lifecycle_manager.py +1 -1
  268. sky/utils/log_utils.py +240 -33
  269. sky/utils/message_utils.py +81 -0
  270. sky/utils/registry.py +127 -0
  271. sky/utils/resources_utils.py +94 -22
  272. sky/utils/rich_utils.py +247 -18
  273. sky/utils/schemas.py +284 -64
  274. sky/{status_lib.py → utils/status_lib.py} +12 -7
  275. sky/utils/subprocess_utils.py +212 -46
  276. sky/utils/timeline.py +12 -7
  277. sky/utils/ux_utils.py +168 -15
  278. skypilot_nightly-1.0.0.dev2025022801.dist-info/METADATA +363 -0
  279. skypilot_nightly-1.0.0.dev2025022801.dist-info/RECORD +352 -0
  280. {skypilot_nightly-1.0.0.dev2024053101.dist-info → skypilot_nightly-1.0.0.dev2025022801.dist-info}/WHEEL +1 -1
  281. sky/clouds/cloud_registry.py +0 -31
  282. sky/jobs/core.py +0 -330
  283. sky/skylet/providers/azure/__init__.py +0 -2
  284. sky/skylet/providers/azure/azure-vm-template.json +0 -301
  285. sky/skylet/providers/azure/config.py +0 -170
  286. sky/skylet/providers/azure/node_provider.py +0 -466
  287. sky/skylet/providers/lambda_cloud/__init__.py +0 -2
  288. sky/skylet/providers/lambda_cloud/node_provider.py +0 -320
  289. sky/skylet/providers/oci/__init__.py +0 -2
  290. sky/skylet/providers/oci/node_provider.py +0 -488
  291. sky/skylet/providers/oci/query_helper.py +0 -383
  292. sky/skylet/providers/oci/utils.py +0 -21
  293. sky/utils/cluster_yaml_utils.py +0 -24
  294. sky/utils/kubernetes/generate_static_kubeconfig.sh +0 -137
  295. skypilot_nightly-1.0.0.dev2024053101.dist-info/METADATA +0 -315
  296. skypilot_nightly-1.0.0.dev2024053101.dist-info/RECORD +0 -275
  297. {skypilot_nightly-1.0.0.dev2024053101.dist-info → skypilot_nightly-1.0.0.dev2025022801.dist-info}/LICENSE +0 -0
  298. {skypilot_nightly-1.0.0.dev2024053101.dist-info → skypilot_nightly-1.0.0.dev2025022801.dist-info}/entry_points.txt +0 -0
  299. {skypilot_nightly-1.0.0.dev2024053101.dist-info → skypilot_nightly-1.0.0.dev2025022801.dist-info}/top_level.txt +0 -0
sky/data/storage.py CHANGED
@@ -1,5 +1,6 @@
1
1
  """Storage and Store Classes for Sky Data."""
2
2
  import enum
3
+ import hashlib
3
4
  import os
4
5
  import re
5
6
  import shlex
@@ -16,19 +17,23 @@ from sky import clouds
16
17
  from sky import exceptions
17
18
  from sky import global_user_state
18
19
  from sky import sky_logging
19
- from sky import status_lib
20
+ from sky import skypilot_config
20
21
  from sky.adaptors import aws
22
+ from sky.adaptors import azure
21
23
  from sky.adaptors import cloudflare
22
24
  from sky.adaptors import gcp
23
25
  from sky.adaptors import ibm
26
+ from sky.adaptors import oci
24
27
  from sky.data import data_transfer
25
28
  from sky.data import data_utils
26
29
  from sky.data import mounting_utils
27
30
  from sky.data import storage_utils
28
31
  from sky.data.data_utils import Rclone
32
+ from sky.skylet import constants
29
33
  from sky.utils import common_utils
30
34
  from sky.utils import rich_utils
31
35
  from sky.utils import schemas
36
+ from sky.utils import status_lib
32
37
  from sky.utils import ux_utils
33
38
 
34
39
  if typing.TYPE_CHECKING:
@@ -49,7 +54,10 @@ SourceType = Union[Path, List[Path]]
49
54
  STORE_ENABLED_CLOUDS: List[str] = [
50
55
  str(clouds.AWS()),
51
56
  str(clouds.GCP()),
52
- str(clouds.IBM()), cloudflare.NAME
57
+ str(clouds.Azure()),
58
+ str(clouds.IBM()),
59
+ str(clouds.OCI()),
60
+ cloudflare.NAME,
53
61
  ]
54
62
 
55
63
  # Maximum number of concurrent rsync upload processes
@@ -67,6 +75,8 @@ _BUCKET_EXTERNALLY_DELETED_DEBUG_MESSAGE = (
67
75
  'Bucket {bucket_name!r} does not exist. '
68
76
  'It may have been deleted externally.')
69
77
 
78
+ _STORAGE_LOG_FILE_NAME = 'storage_sync.log'
79
+
70
80
 
71
81
  def get_cached_enabled_storage_clouds_or_refresh(
72
82
  raise_if_no_cloud_access: bool = False) -> List[str]:
@@ -108,6 +118,7 @@ class StoreType(enum.Enum):
108
118
  AZURE = 'AZURE'
109
119
  R2 = 'R2'
110
120
  IBM = 'IBM'
121
+ OCI = 'OCI'
111
122
 
112
123
  @classmethod
113
124
  def from_cloud(cls, cloud: str) -> 'StoreType':
@@ -120,8 +131,9 @@ class StoreType(enum.Enum):
120
131
  elif cloud.lower() == cloudflare.NAME.lower():
121
132
  return StoreType.R2
122
133
  elif cloud.lower() == str(clouds.Azure()).lower():
123
- with ux_utils.print_exception_no_traceback():
124
- raise ValueError('Azure Blob Storage is not supported yet.')
134
+ return StoreType.AZURE
135
+ elif cloud.lower() == str(clouds.OCI()).lower():
136
+ return StoreType.OCI
125
137
  elif cloud.lower() == str(clouds.Lambda()).lower():
126
138
  with ux_utils.print_exception_no_traceback():
127
139
  raise ValueError('Lambda Cloud does not provide cloud storage.')
@@ -137,10 +149,14 @@ class StoreType(enum.Enum):
137
149
  return StoreType.S3
138
150
  elif isinstance(store, GcsStore):
139
151
  return StoreType.GCS
152
+ elif isinstance(store, AzureBlobStore):
153
+ return StoreType.AZURE
140
154
  elif isinstance(store, R2Store):
141
155
  return StoreType.R2
142
156
  elif isinstance(store, IBMCosStore):
143
157
  return StoreType.IBM
158
+ elif isinstance(store, OciStore):
159
+ return StoreType.OCI
144
160
  else:
145
161
  with ux_utils.print_exception_no_traceback():
146
162
  raise ValueError(f'Unknown store type: {store}')
@@ -150,17 +166,73 @@ class StoreType(enum.Enum):
150
166
  return 's3://'
151
167
  elif self == StoreType.GCS:
152
168
  return 'gs://'
169
+ elif self == StoreType.AZURE:
170
+ return 'https://'
171
+ # R2 storages use 's3://' as a prefix for various aws cli commands
153
172
  elif self == StoreType.R2:
154
173
  return 'r2://'
155
174
  elif self == StoreType.IBM:
156
175
  return 'cos://'
157
- elif self == StoreType.AZURE:
158
- with ux_utils.print_exception_no_traceback():
159
- raise ValueError('Azure Blob Storage is not supported yet.')
176
+ elif self == StoreType.OCI:
177
+ return 'oci://'
160
178
  else:
161
179
  with ux_utils.print_exception_no_traceback():
162
180
  raise ValueError(f'Unknown store type: {self}')
163
181
 
182
+ @classmethod
183
+ def get_endpoint_url(cls, store: 'AbstractStore', path: str) -> str:
184
+ """Generates the endpoint URL for a given store and path.
185
+
186
+ Args:
187
+ store: Store object implementing AbstractStore.
188
+ path: Path within the store.
189
+
190
+ Returns:
191
+ Endpoint URL of the bucket as a string.
192
+ """
193
+ store_type = cls.from_store(store)
194
+ if store_type == StoreType.AZURE:
195
+ assert isinstance(store, AzureBlobStore)
196
+ storage_account_name = store.storage_account_name
197
+ bucket_endpoint_url = data_utils.AZURE_CONTAINER_URL.format(
198
+ storage_account_name=storage_account_name, container_name=path)
199
+ else:
200
+ bucket_endpoint_url = f'{store_type.store_prefix()}{path}'
201
+ return bucket_endpoint_url
202
+
203
+ @classmethod
204
+ def get_fields_from_store_url(
205
+ cls, store_url: str
206
+ ) -> Tuple['StoreType', str, str, Optional[str], Optional[str]]:
207
+ """Returns the store type, bucket name, and sub path from
208
+ a store URL, and the storage account name and region if applicable.
209
+
210
+ Args:
211
+ store_url: str; The store URL.
212
+ """
213
+ # The full path from the user config of IBM COS contains the region,
214
+ # and Azure Blob Storage contains the storage account name, we need to
215
+ # pass these information to the store constructor.
216
+ storage_account_name = None
217
+ region = None
218
+ for store_type in StoreType:
219
+ if store_url.startswith(store_type.store_prefix()):
220
+ if store_type == StoreType.AZURE:
221
+ storage_account_name, bucket_name, sub_path = \
222
+ data_utils.split_az_path(store_url)
223
+ elif store_type == StoreType.IBM:
224
+ bucket_name, sub_path, region = data_utils.split_cos_path(
225
+ store_url)
226
+ elif store_type == StoreType.R2:
227
+ bucket_name, sub_path = data_utils.split_r2_path(store_url)
228
+ elif store_type == StoreType.GCS:
229
+ bucket_name, sub_path = data_utils.split_gcs_path(store_url)
230
+ elif store_type == StoreType.S3:
231
+ bucket_name, sub_path = data_utils.split_s3_path(store_url)
232
+ return store_type, bucket_name, \
233
+ sub_path, storage_account_name, region
234
+ raise ValueError(f'Unknown store URL: {store_url}')
235
+
164
236
 
165
237
  class StorageMode(enum.Enum):
166
238
  MOUNT = 'MOUNT'
@@ -187,25 +259,29 @@ class AbstractStore:
187
259
  name: str,
188
260
  source: Optional[SourceType],
189
261
  region: Optional[str] = None,
190
- is_sky_managed: Optional[bool] = None):
262
+ is_sky_managed: Optional[bool] = None,
263
+ _bucket_sub_path: Optional[str] = None):
191
264
  self.name = name
192
265
  self.source = source
193
266
  self.region = region
194
267
  self.is_sky_managed = is_sky_managed
268
+ self._bucket_sub_path = _bucket_sub_path
195
269
 
196
270
  def __repr__(self):
197
271
  return (f'StoreMetadata('
198
272
  f'\n\tname={self.name},'
199
273
  f'\n\tsource={self.source},'
200
274
  f'\n\tregion={self.region},'
201
- f'\n\tis_sky_managed={self.is_sky_managed})')
275
+ f'\n\tis_sky_managed={self.is_sky_managed},'
276
+ f'\n\t_bucket_sub_path={self._bucket_sub_path})')
202
277
 
203
278
  def __init__(self,
204
279
  name: str,
205
280
  source: Optional[SourceType],
206
281
  region: Optional[str] = None,
207
282
  is_sky_managed: Optional[bool] = None,
208
- sync_on_reconstruction: Optional[bool] = True):
283
+ sync_on_reconstruction: Optional[bool] = True,
284
+ _bucket_sub_path: Optional[str] = None): # pylint: disable=invalid-name
209
285
  """Initialize AbstractStore
210
286
 
211
287
  Args:
@@ -219,7 +295,11 @@ class AbstractStore:
219
295
  there. This is set to false when the Storage object is created not
220
296
  for direct use, e.g. for 'sky storage delete', or the storage is
221
297
  being re-used, e.g., for `sky start` on a stopped cluster.
222
-
298
+ _bucket_sub_path: str; The prefix of the bucket directory to be
299
+ created in the store, e.g. if _bucket_sub_path=my-dir, the files
300
+ will be uploaded to s3://<bucket>/my-dir/.
301
+ This only works if source is a local directory.
302
+ # TODO(zpoint): Add support for non-local source.
223
303
  Raises:
224
304
  StorageBucketCreateError: If bucket creation fails
225
305
  StorageBucketGetError: If fetching existing bucket fails
@@ -230,10 +310,29 @@ class AbstractStore:
230
310
  self.region = region
231
311
  self.is_sky_managed = is_sky_managed
232
312
  self.sync_on_reconstruction = sync_on_reconstruction
313
+
314
+ # To avoid mypy error
315
+ self._bucket_sub_path: Optional[str] = None
316
+ # Trigger the setter to strip any leading/trailing slashes.
317
+ self.bucket_sub_path = _bucket_sub_path
233
318
  # Whether sky is responsible for the lifecycle of the Store.
234
319
  self._validate()
235
320
  self.initialize()
236
321
 
322
+ @property
323
+ def bucket_sub_path(self) -> Optional[str]:
324
+ """Get the bucket_sub_path."""
325
+ return self._bucket_sub_path
326
+
327
+ @bucket_sub_path.setter
328
+ # pylint: disable=invalid-name
329
+ def bucket_sub_path(self, bucket_sub_path: Optional[str]) -> None:
330
+ """Set the bucket_sub_path, stripping any leading/trailing slashes."""
331
+ if bucket_sub_path is not None:
332
+ self._bucket_sub_path = bucket_sub_path.strip('/')
333
+ else:
334
+ self._bucket_sub_path = None
335
+
237
336
  @classmethod
238
337
  def from_metadata(cls, metadata: StoreMetadata, **override_args):
239
338
  """Create a Store from a StoreMetadata object.
@@ -241,19 +340,27 @@ class AbstractStore:
241
340
  Used when reconstructing Storage and Store objects from
242
341
  global_user_state.
243
342
  """
244
- return cls(name=override_args.get('name', metadata.name),
245
- source=override_args.get('source', metadata.source),
246
- region=override_args.get('region', metadata.region),
247
- is_sky_managed=override_args.get('is_sky_managed',
248
- metadata.is_sky_managed),
249
- sync_on_reconstruction=override_args.get(
250
- 'sync_on_reconstruction', True))
343
+ return cls(
344
+ name=override_args.get('name', metadata.name),
345
+ source=override_args.get('source', metadata.source),
346
+ region=override_args.get('region', metadata.region),
347
+ is_sky_managed=override_args.get('is_sky_managed',
348
+ metadata.is_sky_managed),
349
+ sync_on_reconstruction=override_args.get('sync_on_reconstruction',
350
+ True),
351
+ # Backward compatibility
352
+ # TODO: remove the hasattr check after v0.11.0
353
+ _bucket_sub_path=override_args.get(
354
+ '_bucket_sub_path',
355
+ metadata._bucket_sub_path # pylint: disable=protected-access
356
+ ) if hasattr(metadata, '_bucket_sub_path') else None)
251
357
 
252
358
  def get_metadata(self) -> StoreMetadata:
253
359
  return self.StoreMetadata(name=self.name,
254
360
  source=self.source,
255
361
  region=self.region,
256
- is_sky_managed=self.is_sky_managed)
362
+ is_sky_managed=self.is_sky_managed,
363
+ _bucket_sub_path=self._bucket_sub_path)
257
364
 
258
365
  def initialize(self):
259
366
  """Initializes the Store object on the cloud.
@@ -281,7 +388,11 @@ class AbstractStore:
281
388
  raise NotImplementedError
282
389
 
283
390
  def delete(self) -> None:
284
- """Removes the Storage object from the cloud."""
391
+ """Removes the Storage from the cloud."""
392
+ raise NotImplementedError
393
+
394
+ def _delete_sub_path(self) -> None:
395
+ """Removes objects from the sub path in the bucket."""
285
396
  raise NotImplementedError
286
397
 
287
398
  def get_handle(self) -> StorageHandle:
@@ -338,8 +449,9 @@ class AbstractStore:
338
449
  # externally created buckets, users must provide the
339
450
  # bucket's URL as 'source'.
340
451
  if handle is None:
452
+ source_endpoint = StoreType.get_endpoint_url(store=self,
453
+ path=self.name)
341
454
  with ux_utils.print_exception_no_traceback():
342
- store_prefix = StoreType.from_store(self).store_prefix()
343
455
  raise exceptions.StorageSpecError(
344
456
  'Attempted to mount a non-sky managed bucket '
345
457
  f'{self.name!r} without specifying the storage source.'
@@ -350,7 +462,7 @@ class AbstractStore:
350
462
  'specify the bucket URL in the source field '
351
463
  'instead of its name. I.e., replace '
352
464
  f'`name: {self.name}` with '
353
- f'`source: {store_prefix}{self.name}`.')
465
+ f'`source: {source_endpoint}`.')
354
466
 
355
467
 
356
468
  class Storage(object):
@@ -424,13 +536,19 @@ class Storage(object):
424
536
  if storetype in self.sky_stores:
425
537
  del self.sky_stores[storetype]
426
538
 
427
- def __init__(self,
428
- name: Optional[str] = None,
429
- source: Optional[SourceType] = None,
430
- stores: Optional[Dict[StoreType, AbstractStore]] = None,
431
- persistent: Optional[bool] = True,
432
- mode: StorageMode = StorageMode.MOUNT,
433
- sync_on_reconstruction: bool = True) -> None:
539
+ def __init__(
540
+ self,
541
+ name: Optional[str] = None,
542
+ source: Optional[SourceType] = None,
543
+ stores: Optional[List[StoreType]] = None,
544
+ persistent: Optional[bool] = True,
545
+ mode: StorageMode = StorageMode.MOUNT,
546
+ sync_on_reconstruction: bool = True,
547
+ # pylint: disable=invalid-name
548
+ _is_sky_managed: Optional[bool] = None,
549
+ # pylint: disable=invalid-name
550
+ _bucket_sub_path: Optional[str] = None
551
+ ) -> None:
434
552
  """Initializes a Storage object.
435
553
 
436
554
  Three fields are required: the name of the storage, the source
@@ -468,25 +586,60 @@ class Storage(object):
468
586
  there. This is set to false when the Storage object is created not
469
587
  for direct use, e.g. for 'sky storage delete', or the storage is
470
588
  being re-used, e.g., for `sky start` on a stopped cluster.
589
+ _is_sky_managed: Optional[bool]; Indicates if the storage is managed
590
+ by Sky. Without this argument, the controller's behavior differs
591
+ from the local machine. For example, if a bucket does not exist:
592
+ Local Machine (is_sky_managed=True) →
593
+ Controller (is_sky_managed=False).
594
+ With this argument, the controller aligns with the local machine,
595
+ ensuring it retains the is_sky_managed information from the YAML.
596
+ During teardown, if is_sky_managed is True, the controller should
597
+ delete the bucket. Otherwise, it might mistakenly delete only the
598
+ sub-path, assuming is_sky_managed is False.
599
+ _bucket_sub_path: Optional[str]; The subdirectory to use for the
600
+ storage object.
471
601
  """
472
- self.name: str
602
+ self.name = name
473
603
  self.source = source
474
604
  self.persistent = persistent
475
605
  self.mode = mode
476
606
  assert mode in StorageMode
607
+ self.stores: Dict[StoreType, Optional[AbstractStore]] = {}
608
+ if stores is not None:
609
+ for store in stores:
610
+ self.stores[store] = None
477
611
  self.sync_on_reconstruction = sync_on_reconstruction
612
+ self._is_sky_managed = _is_sky_managed
613
+ self._bucket_sub_path = _bucket_sub_path
478
614
 
615
+ self._constructed = False
479
616
  # TODO(romilb, zhwu): This is a workaround to support storage deletion
480
- # for spot. Once sky storage supports forced management for external
481
- # buckets, this can be deprecated.
617
+ # for managed jobs. Once sky storage supports forced management for
618
+ # external buckets, this can be deprecated.
482
619
  self.force_delete = False
483
620
 
484
- # Validate and correct inputs if necessary
485
- self._validate_storage_spec(name)
621
+ def construct(self):
622
+ """Constructs the storage object.
623
+
624
+ The Storage object is lazily initialized, so that when a user
625
+ initializes a Storage object on client side, it does not trigger the
626
+ actual storage creation on the client side.
627
+
628
+ Instead, once the specification of the storage object is uploaded to the
629
+ SkyPilot API server side, the server side should use this construct()
630
+ method to actually create the storage object. The construct() method
631
+ will:
632
+
633
+ 1. Set the stores field if not specified
634
+ 2. Create the bucket or check the existence of the bucket
635
+ 3. Sync the data from the source to the bucket if necessary
636
+ """
637
+ if self._constructed:
638
+ return
639
+ self._constructed = True
486
640
 
487
- # Sky optimizer either adds a storage object instance or selects
488
- # from existing ones
489
- self.stores = {} if stores is None else stores
641
+ # Validate and correct inputs if necessary
642
+ self._validate_storage_spec(self.name)
490
643
 
491
644
  # Logic to rebuild Storage if it is in global user state
492
645
  handle = global_user_state.get_handle_from_storage_name(self.name)
@@ -497,6 +650,28 @@ class Storage(object):
497
650
  f'loading Storage: {self.name}')
498
651
  self._add_store_from_metadata(self.handle.sky_stores)
499
652
 
653
+ # When a storage object is reconstructed from global_user_state,
654
+ # the user may have specified a new store type in the yaml file that
655
+ # was not used with the storage object. We should error out in this
656
+ # case, as we don't support having multiple stores for the same
657
+ # storage object.
658
+ if any(s is None for s in self.stores.values()):
659
+ new_store_type = None
660
+ previous_store_type = None
661
+ for store_type, store in self.stores.items():
662
+ if store is not None:
663
+ previous_store_type = store_type
664
+ else:
665
+ new_store_type = store_type
666
+ with ux_utils.print_exception_no_traceback():
667
+ raise exceptions.StorageBucketCreateError(
668
+ f'Bucket {self.name} was previously created for '
669
+ f'{previous_store_type.value.lower()!r}, but a new '
670
+ f'store type {new_store_type.value.lower()!r} is '
671
+ 'requested. This is not supported yet. Please specify '
672
+ 'the same store type: '
673
+ f'{previous_store_type.value.lower()!r}.')
674
+
500
675
  # TODO(romilb): This logic should likely be in add_store to move
501
676
  # syncing to file_mount stage..
502
677
  if self.sync_on_reconstruction:
@@ -510,15 +685,16 @@ class Storage(object):
510
685
 
511
686
  else:
512
687
  # Storage does not exist in global_user_state, create new stores
513
- sky_managed_stores = {
514
- t: s.get_metadata()
515
- for t, s in self.stores.items()
516
- if s.is_sky_managed
517
- }
688
+ # Sky optimizer either adds a storage object instance or selects
689
+ # from existing ones
690
+ input_stores = self.stores
691
+ self.stores = {}
518
692
  self.handle = self.StorageMetadata(storage_name=self.name,
519
693
  source=self.source,
520
- mode=self.mode,
521
- sky_stores=sky_managed_stores)
694
+ mode=self.mode)
695
+
696
+ for store in input_stores:
697
+ self.add_store(store)
522
698
 
523
699
  if self.source is not None:
524
700
  # If source is a pre-existing bucket, connect to the bucket
@@ -528,10 +704,20 @@ class Storage(object):
528
704
  self.add_store(StoreType.S3)
529
705
  elif self.source.startswith('gs://'):
530
706
  self.add_store(StoreType.GCS)
707
+ elif data_utils.is_az_container_endpoint(self.source):
708
+ self.add_store(StoreType.AZURE)
531
709
  elif self.source.startswith('r2://'):
532
710
  self.add_store(StoreType.R2)
533
711
  elif self.source.startswith('cos://'):
534
712
  self.add_store(StoreType.IBM)
713
+ elif self.source.startswith('oci://'):
714
+ self.add_store(StoreType.OCI)
715
+
716
+ def get_bucket_sub_path_prefix(self, blob_path: str) -> str:
717
+ """Adds the bucket sub path prefix to the blob path."""
718
+ if self._bucket_sub_path is not None:
719
+ return f'{blob_path}/{self._bucket_sub_path}'
720
+ return blob_path
535
721
 
536
722
  @staticmethod
537
723
  def _validate_source(
@@ -612,15 +798,16 @@ class Storage(object):
612
798
  'using a bucket by writing <destination_path>: '
613
799
  f'{source} in the file_mounts section of your YAML')
614
800
  is_local_source = True
615
- elif split_path.scheme in ['s3', 'gs', 'r2', 'cos']:
801
+ elif split_path.scheme in ['s3', 'gs', 'https', 'r2', 'cos', 'oci']:
616
802
  is_local_source = False
617
803
  # Storage mounting does not support mounting specific files from
618
804
  # cloud store - ensure path points to only a directory
619
805
  if mode == StorageMode.MOUNT:
620
- if ((not split_path.scheme == 'cos' and
621
- split_path.path.strip('/') != '') or
622
- (split_path.scheme == 'cos' and
623
- not re.match(r'^/[-\w]+(/\s*)?$', split_path.path))):
806
+ if (split_path.scheme != 'https' and
807
+ ((split_path.scheme != 'cos' and
808
+ split_path.path.strip('/') != '') or
809
+ (split_path.scheme == 'cos' and
810
+ not re.match(r'^/[-\w]+(/\s*)?$', split_path.path)))):
624
811
  # regex allows split_path.path to include /bucket
625
812
  # or /bucket/optional_whitespaces while considering
626
813
  # cos URI's regions (cos://region/bucket_name)
@@ -634,8 +821,8 @@ class Storage(object):
634
821
  else:
635
822
  with ux_utils.print_exception_no_traceback():
636
823
  raise exceptions.StorageSourceError(
637
- f'Supported paths: local, s3://, gs://, '
638
- f'r2://, cos://. Got: {source}')
824
+ f'Supported paths: local, s3://, gs://, https://, '
825
+ f'r2://, cos://, oci://. Got: {source}')
639
826
  return source, is_local_source
640
827
 
641
828
  def _validate_storage_spec(self, name: Optional[str]) -> None:
@@ -650,7 +837,7 @@ class Storage(object):
650
837
  """
651
838
  prefix = name.split('://')[0]
652
839
  prefix = prefix.lower()
653
- if prefix in ['s3', 'gs', 'r2', 'cos']:
840
+ if prefix in ['s3', 'gs', 'https', 'r2', 'cos', 'oci']:
654
841
  with ux_utils.print_exception_no_traceback():
655
842
  raise exceptions.StorageNameError(
656
843
  'Prefix detected: `name` cannot start with '
@@ -701,6 +888,8 @@ class Storage(object):
701
888
  if source.startswith('cos://'):
702
889
  # cos url requires custom parsing
703
890
  name = data_utils.split_cos_path(source)[0]
891
+ elif data_utils.is_az_container_endpoint(source):
892
+ _, name, _ = data_utils.split_az_path(source)
704
893
  else:
705
894
  name = urllib.parse.urlsplit(source).netloc
706
895
  assert name is not None, source
@@ -740,33 +929,59 @@ class Storage(object):
740
929
  store = S3Store.from_metadata(
741
930
  s_metadata,
742
931
  source=self.source,
743
- sync_on_reconstruction=self.sync_on_reconstruction)
932
+ sync_on_reconstruction=self.sync_on_reconstruction,
933
+ _bucket_sub_path=self._bucket_sub_path)
744
934
  elif s_type == StoreType.GCS:
745
935
  store = GcsStore.from_metadata(
746
936
  s_metadata,
747
937
  source=self.source,
748
- sync_on_reconstruction=self.sync_on_reconstruction)
938
+ sync_on_reconstruction=self.sync_on_reconstruction,
939
+ _bucket_sub_path=self._bucket_sub_path)
940
+ elif s_type == StoreType.AZURE:
941
+ assert isinstance(s_metadata,
942
+ AzureBlobStore.AzureBlobStoreMetadata)
943
+ store = AzureBlobStore.from_metadata(
944
+ s_metadata,
945
+ source=self.source,
946
+ sync_on_reconstruction=self.sync_on_reconstruction,
947
+ _bucket_sub_path=self._bucket_sub_path)
749
948
  elif s_type == StoreType.R2:
750
949
  store = R2Store.from_metadata(
751
950
  s_metadata,
752
951
  source=self.source,
753
- sync_on_reconstruction=self.sync_on_reconstruction)
952
+ sync_on_reconstruction=self.sync_on_reconstruction,
953
+ _bucket_sub_path=self._bucket_sub_path)
754
954
  elif s_type == StoreType.IBM:
755
955
  store = IBMCosStore.from_metadata(
756
956
  s_metadata,
757
957
  source=self.source,
758
- sync_on_reconstruction=self.sync_on_reconstruction)
958
+ sync_on_reconstruction=self.sync_on_reconstruction,
959
+ _bucket_sub_path=self._bucket_sub_path)
960
+ elif s_type == StoreType.OCI:
961
+ store = OciStore.from_metadata(
962
+ s_metadata,
963
+ source=self.source,
964
+ sync_on_reconstruction=self.sync_on_reconstruction,
965
+ _bucket_sub_path=self._bucket_sub_path)
759
966
  else:
760
967
  with ux_utils.print_exception_no_traceback():
761
968
  raise ValueError(f'Unknown store type: {s_type}')
762
- # Following error is raised from _get_bucket and caught only when
763
- # an externally removed storage is attempted to be fetched.
764
- except exceptions.StorageExternalDeletionError:
765
- logger.debug(f'Storage object {self.name!r} was attempted to '
766
- 'be reconstructed while the corresponding bucket'
767
- ' was externally deleted.')
969
+ # Following error is caught when an externally removed storage
970
+ # is attempted to be fetched.
971
+ except exceptions.StorageExternalDeletionError as e:
972
+ if isinstance(e, exceptions.NonExistentStorageAccountError):
973
+ assert isinstance(s_metadata,
974
+ AzureBlobStore.AzureBlobStoreMetadata)
975
+ logger.debug(f'Storage object {self.name!r} was attempted '
976
+ 'to be reconstructed while the corresponding '
977
+ 'storage account '
978
+ f'{s_metadata.storage_account_name!r} does '
979
+ 'not exist.')
980
+ else:
981
+ logger.debug(f'Storage object {self.name!r} was attempted '
982
+ 'to be reconstructed while the corresponding '
983
+ 'bucket was externally deleted.')
768
984
  continue
769
-
770
985
  self._add_store(store, is_reconstructed=True)
771
986
 
772
987
  @classmethod
@@ -810,41 +1025,60 @@ class Storage(object):
810
1025
  region: str; Region to place the bucket in. Caller must ensure that
811
1026
  the region is valid for the chosen store_type.
812
1027
  """
1028
+ assert self._constructed, self
1029
+ assert self.name is not None, self
1030
+
813
1031
  if isinstance(store_type, str):
814
1032
  store_type = StoreType(store_type)
815
1033
 
816
- if store_type in self.stores:
817
- logger.info(f'Storage type {store_type} already exists.')
818
- return self.stores[store_type]
1034
+ if self.stores.get(store_type) is not None:
1035
+ if store_type == StoreType.AZURE:
1036
+ azure_store_obj = self.stores[store_type]
1037
+ assert isinstance(azure_store_obj, AzureBlobStore)
1038
+ storage_account_name = azure_store_obj.storage_account_name
1039
+ logger.info(f'Storage type {store_type} already exists under '
1040
+ f'storage account {storage_account_name!r}.')
1041
+ else:
1042
+ logger.info(f'Storage type {store_type} already exists.')
1043
+ store = self.stores[store_type]
1044
+ assert store is not None, self
1045
+ return store
819
1046
 
820
1047
  store_cls: Type[AbstractStore]
821
1048
  if store_type == StoreType.S3:
822
1049
  store_cls = S3Store
823
1050
  elif store_type == StoreType.GCS:
824
1051
  store_cls = GcsStore
1052
+ elif store_type == StoreType.AZURE:
1053
+ store_cls = AzureBlobStore
825
1054
  elif store_type == StoreType.R2:
826
1055
  store_cls = R2Store
827
1056
  elif store_type == StoreType.IBM:
828
1057
  store_cls = IBMCosStore
1058
+ elif store_type == StoreType.OCI:
1059
+ store_cls = OciStore
829
1060
  else:
830
1061
  with ux_utils.print_exception_no_traceback():
831
1062
  raise exceptions.StorageSpecError(
832
1063
  f'{store_type} not supported as a Store.')
833
-
834
- # Initialize store object and get/create bucket
835
1064
  try:
836
1065
  store = store_cls(
837
1066
  name=self.name,
838
1067
  source=self.source,
839
1068
  region=region,
840
- sync_on_reconstruction=self.sync_on_reconstruction)
1069
+ sync_on_reconstruction=self.sync_on_reconstruction,
1070
+ is_sky_managed=self._is_sky_managed,
1071
+ _bucket_sub_path=self._bucket_sub_path)
841
1072
  except exceptions.StorageBucketCreateError:
842
1073
  # Creation failed, so this must be sky managed store. Add failure
843
1074
  # to state.
844
1075
  logger.error(f'Could not create {store_type} store '
845
1076
  f'with name {self.name}.')
846
- global_user_state.set_storage_status(self.name,
847
- StorageStatus.INIT_FAILED)
1077
+ try:
1078
+ global_user_state.set_storage_status(self.name,
1079
+ StorageStatus.INIT_FAILED)
1080
+ except ValueError as e:
1081
+ logger.error(f'Error setting storage status: {e}')
848
1082
  raise
849
1083
  except exceptions.StorageBucketGetError:
850
1084
  # Bucket get failed, so this is not sky managed. Do not update state
@@ -876,6 +1110,7 @@ class Storage(object):
876
1110
  if store.is_sky_managed:
877
1111
  self.handle.add_store(store)
878
1112
  if not is_reconstructed:
1113
+ assert self.name is not None, self
879
1114
  global_user_state.add_or_update_storage(self.name, self.handle,
880
1115
  StorageStatus.INIT)
881
1116
 
@@ -891,9 +1126,12 @@ class Storage(object):
891
1126
  """
892
1127
  if not self.stores:
893
1128
  logger.info('No backing stores found. Deleting storage.')
1129
+ assert self.name is not None
894
1130
  global_user_state.remove_storage(self.name)
895
- if store_type:
1131
+ if store_type is not None:
1132
+ assert self.name is not None
896
1133
  store = self.stores[store_type]
1134
+ assert store is not None, self
897
1135
  is_sky_managed = store.is_sky_managed
898
1136
  # We delete a store from the cloud if it's sky managed. Else just
899
1137
  # remove handle and return
@@ -902,8 +1140,12 @@ class Storage(object):
902
1140
  store.delete()
903
1141
  # Check remaining stores - if none is sky managed, remove
904
1142
  # the storage from global_user_state.
905
- delete = all(
906
- s.is_sky_managed is False for s in self.stores.values())
1143
+ delete = True
1144
+ for store in self.stores.values():
1145
+ assert store is not None, self
1146
+ if store.is_sky_managed:
1147
+ delete = False
1148
+ break
907
1149
  if delete:
908
1150
  global_user_state.remove_storage(self.name)
909
1151
  else:
@@ -914,6 +1156,7 @@ class Storage(object):
914
1156
  del self.stores[store_type]
915
1157
  else:
916
1158
  for _, store in self.stores.items():
1159
+ assert store is not None, self
917
1160
  if store.is_sky_managed:
918
1161
  self.handle.remove_store(store)
919
1162
  store.delete()
@@ -921,15 +1164,19 @@ class Storage(object):
921
1164
  store.delete()
922
1165
  self.stores = {}
923
1166
  # Remove storage from global_user_state if present
924
- global_user_state.remove_storage(self.name)
1167
+ if self.name is not None:
1168
+ global_user_state.remove_storage(self.name)
925
1169
 
926
1170
  def sync_all_stores(self):
927
1171
  """Syncs the source and destinations of all stores in the Storage"""
928
1172
  for _, store in self.stores.items():
1173
+ assert store is not None, self
929
1174
  self._sync_store(store)
930
1175
 
931
1176
  def _sync_store(self, store: AbstractStore):
932
1177
  """Runs the upload routine for the store and handles failures"""
1178
+ assert self._constructed, self
1179
+ assert self.name is not None, self
933
1180
 
934
1181
  def warn_for_git_dir(source: str):
935
1182
  if os.path.isdir(os.path.join(source, '.git')):
@@ -960,12 +1207,15 @@ class Storage(object):
960
1207
  def from_yaml_config(cls, config: Dict[str, Any]) -> 'Storage':
961
1208
  common_utils.validate_schema(config, schemas.get_storage_schema(),
962
1209
  'Invalid storage YAML: ')
963
-
964
1210
  name = config.pop('name', None)
965
1211
  source = config.pop('source', None)
966
1212
  store = config.pop('store', None)
967
1213
  mode_str = config.pop('mode', None)
968
1214
  force_delete = config.pop('_force_delete', None)
1215
+ # pylint: disable=invalid-name
1216
+ _is_sky_managed = config.pop('_is_sky_managed', None)
1217
+ # pylint: disable=invalid-name
1218
+ _bucket_sub_path = config.pop('_bucket_sub_path', None)
969
1219
  if force_delete is None:
970
1220
  force_delete = False
971
1221
 
@@ -982,18 +1232,23 @@ class Storage(object):
982
1232
  assert not config, f'Invalid storage args: {config.keys()}'
983
1233
 
984
1234
  # Validation of the config object happens on instantiation.
1235
+ if store is not None:
1236
+ stores = [StoreType(store.upper())]
1237
+ else:
1238
+ stores = None
985
1239
  storage_obj = cls(name=name,
986
1240
  source=source,
987
1241
  persistent=persistent,
988
- mode=mode)
989
- if store is not None:
990
- storage_obj.add_store(StoreType(store.upper()))
1242
+ mode=mode,
1243
+ stores=stores,
1244
+ _is_sky_managed=_is_sky_managed,
1245
+ _bucket_sub_path=_bucket_sub_path)
991
1246
 
992
1247
  # Add force deletion flag
993
1248
  storage_obj.force_delete = force_delete
994
1249
  return storage_obj
995
1250
 
996
- def to_yaml_config(self) -> Dict[str, str]:
1251
+ def to_yaml_config(self) -> Dict[str, Any]:
997
1252
  config = {}
998
1253
 
999
1254
  def add_if_not_none(key: str, value: Optional[Any]):
@@ -1009,13 +1264,20 @@ class Storage(object):
1009
1264
  add_if_not_none('source', self.source)
1010
1265
 
1011
1266
  stores = None
1012
- if len(self.stores) > 0:
1267
+ is_sky_managed = self._is_sky_managed
1268
+ if self.stores:
1013
1269
  stores = ','.join([store.value for store in self.stores])
1270
+ store = list(self.stores.values())[0]
1271
+ if store is not None:
1272
+ is_sky_managed = store.is_sky_managed
1014
1273
  add_if_not_none('store', stores)
1274
+ add_if_not_none('_is_sky_managed', is_sky_managed)
1015
1275
  add_if_not_none('persistent', self.persistent)
1016
1276
  add_if_not_none('mode', self.mode.value)
1017
1277
  if self.force_delete:
1018
1278
  config['_force_delete'] = True
1279
+ if self._bucket_sub_path is not None:
1280
+ config['_bucket_sub_path'] = self._bucket_sub_path
1019
1281
  return config
1020
1282
 
1021
1283
 
@@ -1024,18 +1286,34 @@ class S3Store(AbstractStore):
1024
1286
  for S3 buckets.
1025
1287
  """
1026
1288
 
1289
+ _DEFAULT_REGION = 'us-east-1'
1027
1290
  _ACCESS_DENIED_MESSAGE = 'Access Denied'
1291
+ _CUSTOM_ENDPOINT_REGIONS = [
1292
+ 'ap-east-1', 'me-south-1', 'af-south-1', 'eu-south-1', 'eu-south-2',
1293
+ 'ap-south-2', 'ap-southeast-3', 'ap-southeast-4', 'me-central-1',
1294
+ 'il-central-1'
1295
+ ]
1028
1296
 
1029
1297
  def __init__(self,
1030
1298
  name: str,
1031
1299
  source: str,
1032
- region: Optional[str] = 'us-east-2',
1300
+ region: Optional[str] = _DEFAULT_REGION,
1033
1301
  is_sky_managed: Optional[bool] = None,
1034
- sync_on_reconstruction: bool = True):
1302
+ sync_on_reconstruction: bool = True,
1303
+ _bucket_sub_path: Optional[str] = None):
1035
1304
  self.client: 'boto3.client.Client'
1036
1305
  self.bucket: 'StorageHandle'
1306
+ # TODO(romilb): This is purely a stopgap fix for
1307
+ # https://github.com/skypilot-org/skypilot/issues/3405
1308
+ # We should eventually make all opt-in regions also work for S3 by
1309
+ # passing the right endpoint flags.
1310
+ if region in self._CUSTOM_ENDPOINT_REGIONS:
1311
+ logger.warning('AWS opt-in regions are not supported for S3. '
1312
+ f'Falling back to default region '
1313
+ f'{self._DEFAULT_REGION} for bucket {name!r}.')
1314
+ region = self._DEFAULT_REGION
1037
1315
  super().__init__(name, source, region, is_sky_managed,
1038
- sync_on_reconstruction)
1316
+ sync_on_reconstruction, _bucket_sub_path)
1039
1317
 
1040
1318
  def _validate(self):
1041
1319
  if self.source is not None and isinstance(self.source, str):
@@ -1050,6 +1328,16 @@ class S3Store(AbstractStore):
1050
1328
  assert data_utils.verify_gcs_bucket(self.name), (
1051
1329
  f'Source specified as {self.source}, a GCS bucket. ',
1052
1330
  'GCS Bucket should exist.')
1331
+ elif data_utils.is_az_container_endpoint(self.source):
1332
+ storage_account_name, container_name, _ = (
1333
+ data_utils.split_az_path(self.source))
1334
+ assert self.name == container_name, (
1335
+ 'Azure bucket is specified as path, the name should be '
1336
+ 'the same as Azure bucket.')
1337
+ assert data_utils.verify_az_bucket(
1338
+ storage_account_name, self.name), (
1339
+ f'Source specified as {self.source}, an Azure bucket. '
1340
+ 'Azure bucket should exist.')
1053
1341
  elif self.source.startswith('r2://'):
1054
1342
  assert self.name == data_utils.split_r2_path(self.source)[0], (
1055
1343
  'R2 Bucket is specified as path, the name should be '
@@ -1064,6 +1352,9 @@ class S3Store(AbstractStore):
1064
1352
  assert data_utils.verify_ibm_cos_bucket(self.name), (
1065
1353
  f'Source specified as {self.source}, a COS bucket. ',
1066
1354
  'COS Bucket should exist.')
1355
+ elif self.source.startswith('oci://'):
1356
+ raise NotImplementedError(
1357
+ 'Moving data from OCI to S3 is currently not supported.')
1067
1358
  # Validate name
1068
1359
  self.name = self.validate_name(self.name)
1069
1360
 
@@ -1074,11 +1365,11 @@ class S3Store(AbstractStore):
1074
1365
  'Storage \'store: s3\' specified, but ' \
1075
1366
  'AWS access is disabled. To fix, enable '\
1076
1367
  'AWS by running `sky check`. More info: '\
1077
- 'https://skypilot.readthedocs.io/en/latest/getting-started/installation.html.' # pylint: disable=line-too-long
1368
+ 'https://docs.skypilot.co/en/latest/getting-started/installation.html.' # pylint: disable=line-too-long
1078
1369
  )
1079
1370
 
1080
1371
  @classmethod
1081
- def validate_name(cls, name) -> str:
1372
+ def validate_name(cls, name: str) -> str:
1082
1373
  """Validates the name of the S3 store.
1083
1374
 
1084
1375
  Source for rules: https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucketnamingrules.html # pylint: disable=line-too-long
@@ -1175,6 +1466,8 @@ class S3Store(AbstractStore):
1175
1466
  self._transfer_to_s3()
1176
1467
  elif self.source.startswith('r2://'):
1177
1468
  self._transfer_to_s3()
1469
+ elif self.source.startswith('oci://'):
1470
+ self._transfer_to_s3()
1178
1471
  else:
1179
1472
  self.batch_aws_rsync([self.source])
1180
1473
  except exceptions.StorageUploadError:
@@ -1184,6 +1477,9 @@ class S3Store(AbstractStore):
1184
1477
  f'Upload failed for store {self.name}') from e
1185
1478
 
1186
1479
  def delete(self) -> None:
1480
+ if self._bucket_sub_path is not None and not self.is_sky_managed:
1481
+ return self._delete_sub_path()
1482
+
1187
1483
  deleted_by_skypilot = self._delete_s3_bucket(self.name)
1188
1484
  if deleted_by_skypilot:
1189
1485
  msg_str = f'Deleted S3 bucket {self.name}.'
@@ -1193,6 +1489,19 @@ class S3Store(AbstractStore):
1193
1489
  logger.info(f'{colorama.Fore.GREEN}{msg_str}'
1194
1490
  f'{colorama.Style.RESET_ALL}')
1195
1491
 
1492
+ def _delete_sub_path(self) -> None:
1493
+ assert self._bucket_sub_path is not None, 'bucket_sub_path is not set'
1494
+ deleted_by_skypilot = self._delete_s3_bucket_sub_path(
1495
+ self.name, self._bucket_sub_path)
1496
+ if deleted_by_skypilot:
1497
+ msg_str = f'Removed objects from S3 bucket ' \
1498
+ f'{self.name}/{self._bucket_sub_path}.'
1499
+ else:
1500
+ msg_str = f'Failed to remove objects from S3 bucket ' \
1501
+ f'{self.name}/{self._bucket_sub_path}.'
1502
+ logger.info(f'{colorama.Fore.GREEN}{msg_str}'
1503
+ f'{colorama.Style.RESET_ALL}')
1504
+
1196
1505
  def get_handle(self) -> StorageHandle:
1197
1506
  return aws.resource('s3').Bucket(self.name)
1198
1507
 
@@ -1216,6 +1525,8 @@ class S3Store(AbstractStore):
1216
1525
  set to True, the directory is created in the bucket root and
1217
1526
  contents are uploaded to it.
1218
1527
  """
1528
+ sub_path = (f'/{self._bucket_sub_path}'
1529
+ if self._bucket_sub_path else '')
1219
1530
 
1220
1531
  def get_file_sync_command(base_dir_path, file_names):
1221
1532
  includes = ' '.join([
@@ -1225,13 +1536,12 @@ class S3Store(AbstractStore):
1225
1536
  base_dir_path = shlex.quote(base_dir_path)
1226
1537
  sync_command = ('aws s3 sync --no-follow-symlinks --exclude="*" '
1227
1538
  f'{includes} {base_dir_path} '
1228
- f's3://{self.name}')
1539
+ f's3://{self.name}{sub_path}')
1229
1540
  return sync_command
1230
1541
 
1231
1542
  def get_dir_sync_command(src_dir_path, dest_dir_name):
1232
1543
  # we exclude .git directory from the sync
1233
- excluded_list = storage_utils.get_excluded_files_from_gitignore(
1234
- src_dir_path)
1544
+ excluded_list = storage_utils.get_excluded_files(src_dir_path)
1235
1545
  excluded_list.append('.git/*')
1236
1546
  excludes = ' '.join([
1237
1547
  f'--exclude {shlex.quote(file_name)}'
@@ -1240,7 +1550,7 @@ class S3Store(AbstractStore):
1240
1550
  src_dir_path = shlex.quote(src_dir_path)
1241
1551
  sync_command = (f'aws s3 sync --no-follow-symlinks {excludes} '
1242
1552
  f'{src_dir_path} '
1243
- f's3://{self.name}/{dest_dir_name}')
1553
+ f's3://{self.name}{sub_path}/{dest_dir_name}')
1244
1554
  return sync_command
1245
1555
 
1246
1556
  # Generate message for upload
@@ -1249,17 +1559,24 @@ class S3Store(AbstractStore):
1249
1559
  else:
1250
1560
  source_message = source_path_list[0]
1251
1561
 
1562
+ log_path = sky_logging.generate_tmp_logging_file_path(
1563
+ _STORAGE_LOG_FILE_NAME)
1564
+ sync_path = f'{source_message} -> s3://{self.name}{sub_path}/'
1252
1565
  with rich_utils.safe_status(
1253
- f'[bold cyan]Syncing '
1254
- f'[green]{source_message}[/] to [green]s3://{self.name}/[/]'):
1566
+ ux_utils.spinner_message(f'Syncing {sync_path}',
1567
+ log_path=log_path)):
1255
1568
  data_utils.parallel_upload(
1256
1569
  source_path_list,
1257
1570
  get_file_sync_command,
1258
1571
  get_dir_sync_command,
1572
+ log_path,
1259
1573
  self.name,
1260
1574
  self._ACCESS_DENIED_MESSAGE,
1261
1575
  create_dirs=create_dirs,
1262
1576
  max_concurrent_uploads=_MAX_CONCURRENT_UPLOADS)
1577
+ logger.info(
1578
+ ux_utils.finishing_message(f'Storage synced: {sync_path}',
1579
+ log_path))
1263
1580
 
1264
1581
  def _transfer_to_s3(self) -> None:
1265
1582
  assert isinstance(self.source, str), self.source
@@ -1351,13 +1668,14 @@ class S3Store(AbstractStore):
1351
1668
  """
1352
1669
  install_cmd = mounting_utils.get_s3_mount_install_cmd()
1353
1670
  mount_cmd = mounting_utils.get_s3_mount_cmd(self.bucket.name,
1354
- mount_path)
1671
+ mount_path,
1672
+ self._bucket_sub_path)
1355
1673
  return mounting_utils.get_mounting_command(mount_path, install_cmd,
1356
1674
  mount_cmd)
1357
1675
 
1358
1676
  def _create_s3_bucket(self,
1359
1677
  bucket_name: str,
1360
- region='us-east-2') -> StorageHandle:
1678
+ region=_DEFAULT_REGION) -> StorageHandle:
1361
1679
  """Creates S3 bucket with specific name in specific region
1362
1680
 
1363
1681
  Args:
@@ -1368,26 +1686,60 @@ class S3Store(AbstractStore):
1368
1686
  """
1369
1687
  s3_client = self.client
1370
1688
  try:
1371
- if region is None:
1372
- s3_client.create_bucket(Bucket=bucket_name)
1373
- else:
1374
- if region == 'us-east-1':
1375
- # If default us-east-1 region is used, the
1376
- # LocationConstraint must not be specified.
1377
- # https://stackoverflow.com/a/51912090
1378
- s3_client.create_bucket(Bucket=bucket_name)
1379
- else:
1380
- location = {'LocationConstraint': region}
1381
- s3_client.create_bucket(Bucket=bucket_name,
1382
- CreateBucketConfiguration=location)
1383
- logger.info(f'Created S3 bucket {bucket_name} in {region}')
1689
+ create_bucket_config: Dict[str, Any] = {'Bucket': bucket_name}
1690
+ # If default us-east-1 region of create_bucket API is used,
1691
+ # the LocationConstraint must not be specified.
1692
+ # Reference: https://stackoverflow.com/a/51912090
1693
+ if region is not None and region != 'us-east-1':
1694
+ create_bucket_config['CreateBucketConfiguration'] = {
1695
+ 'LocationConstraint': region
1696
+ }
1697
+ s3_client.create_bucket(**create_bucket_config)
1698
+ logger.info(
1699
+ f' {colorama.Style.DIM}Created S3 bucket {bucket_name!r} in '
1700
+ f'{region or "us-east-1"}{colorama.Style.RESET_ALL}')
1701
+
1702
+ # Add AWS tags configured in config.yaml to the bucket.
1703
+ # This is useful for cost tracking and external cleanup.
1704
+ bucket_tags = skypilot_config.get_nested(('aws', 'labels'), {})
1705
+ if bucket_tags:
1706
+ s3_client.put_bucket_tagging(
1707
+ Bucket=bucket_name,
1708
+ Tagging={
1709
+ 'TagSet': [{
1710
+ 'Key': k,
1711
+ 'Value': v
1712
+ } for k, v in bucket_tags.items()]
1713
+ })
1714
+
1384
1715
  except aws.botocore_exceptions().ClientError as e:
1385
1716
  with ux_utils.print_exception_no_traceback():
1386
1717
  raise exceptions.StorageBucketCreateError(
1387
- f'Attempted to create a bucket '
1388
- f'{self.name} but failed.') from e
1718
+ f'Attempted to create a bucket {self.name} but failed.'
1719
+ ) from e
1389
1720
  return aws.resource('s3').Bucket(bucket_name)
1390
1721
 
1722
+ def _execute_s3_remove_command(self, command: str, bucket_name: str,
1723
+ hint_operating: str,
1724
+ hint_failed: str) -> bool:
1725
+ try:
1726
+ with rich_utils.safe_status(
1727
+ ux_utils.spinner_message(hint_operating)):
1728
+ subprocess.check_output(command.split(' '),
1729
+ stderr=subprocess.STDOUT)
1730
+ except subprocess.CalledProcessError as e:
1731
+ if 'NoSuchBucket' in e.output.decode('utf-8'):
1732
+ logger.debug(
1733
+ _BUCKET_EXTERNALLY_DELETED_DEBUG_MESSAGE.format(
1734
+ bucket_name=bucket_name))
1735
+ return False
1736
+ else:
1737
+ with ux_utils.print_exception_no_traceback():
1738
+ raise exceptions.StorageBucketDeleteError(
1739
+ f'{hint_failed}'
1740
+ f'Detailed error: {e.output}')
1741
+ return True
1742
+
1391
1743
  def _delete_s3_bucket(self, bucket_name: str) -> bool:
1392
1744
  """Deletes S3 bucket, including all objects in bucket
1393
1745
 
@@ -1396,6 +1748,9 @@ class S3Store(AbstractStore):
1396
1748
 
1397
1749
  Returns:
1398
1750
  bool; True if bucket was deleted, False if it was deleted externally.
1751
+
1752
+ Raises:
1753
+ StorageBucketDeleteError: If deleting the bucket fails.
1399
1754
  """
1400
1755
  # Deleting objects is very slow programatically
1401
1756
  # (i.e. bucket.objects.all().delete() is slow).
@@ -1405,28 +1760,28 @@ class S3Store(AbstractStore):
1405
1760
  # The fastest way to delete is to run `aws s3 rb --force`,
1406
1761
  # which removes the bucket by force.
1407
1762
  remove_command = f'aws s3 rb s3://{bucket_name} --force'
1408
- try:
1409
- with rich_utils.safe_status(
1410
- f'[bold cyan]Deleting S3 bucket {bucket_name}[/]'):
1411
- subprocess.check_output(remove_command.split(' '),
1412
- stderr=subprocess.STDOUT)
1413
- except subprocess.CalledProcessError as e:
1414
- if 'NoSuchBucket' in e.output.decode('utf-8'):
1415
- logger.debug(
1416
- _BUCKET_EXTERNALLY_DELETED_DEBUG_MESSAGE.format(
1417
- bucket_name=bucket_name))
1418
- return False
1419
- else:
1420
- logger.error(e.output)
1421
- with ux_utils.print_exception_no_traceback():
1422
- raise exceptions.StorageBucketDeleteError(
1423
- f'Failed to delete S3 bucket {bucket_name}.')
1763
+ success = self._execute_s3_remove_command(
1764
+ remove_command, bucket_name,
1765
+ f'Deleting S3 bucket [green]{bucket_name}[/]',
1766
+ f'Failed to delete S3 bucket {bucket_name}.')
1767
+ if not success:
1768
+ return False
1424
1769
 
1425
1770
  # Wait until bucket deletion propagates on AWS servers
1426
1771
  while data_utils.verify_s3_bucket(bucket_name):
1427
1772
  time.sleep(0.1)
1428
1773
  return True
1429
1774
 
1775
+ def _delete_s3_bucket_sub_path(self, bucket_name: str,
1776
+ sub_path: str) -> bool:
1777
+ """Deletes the sub path from the bucket."""
1778
+ remove_command = f'aws s3 rm s3://{bucket_name}/{sub_path}/ --recursive'
1779
+ return self._execute_s3_remove_command(
1780
+ remove_command, bucket_name, f'Removing objects from S3 bucket '
1781
+ f'[green]{bucket_name}/{sub_path}[/]',
1782
+ f'Failed to remove objects from S3 bucket {bucket_name}/{sub_path}.'
1783
+ )
1784
+
1430
1785
 
1431
1786
  class GcsStore(AbstractStore):
1432
1787
  """GcsStore inherits from Storage Object and represents the backend
@@ -1440,47 +1795,53 @@ class GcsStore(AbstractStore):
1440
1795
  source: str,
1441
1796
  region: Optional[str] = 'us-central1',
1442
1797
  is_sky_managed: Optional[bool] = None,
1443
- sync_on_reconstruction: Optional[bool] = True):
1798
+ sync_on_reconstruction: Optional[bool] = True,
1799
+ _bucket_sub_path: Optional[str] = None):
1444
1800
  self.client: 'storage.Client'
1445
1801
  self.bucket: StorageHandle
1446
1802
  super().__init__(name, source, region, is_sky_managed,
1447
- sync_on_reconstruction)
1803
+ sync_on_reconstruction, _bucket_sub_path)
1448
1804
 
1449
1805
  def _validate(self):
1450
- if self.source is not None:
1451
- if isinstance(self.source, str):
1452
- if self.source.startswith('s3://'):
1453
- assert self.name == data_utils.split_s3_path(
1454
- self.source
1455
- )[0], (
1456
- 'S3 Bucket is specified as path, the name should be the'
1457
- ' same as S3 bucket.')
1458
- assert data_utils.verify_s3_bucket(self.name), (
1459
- f'Source specified as {self.source}, an S3 bucket. ',
1460
- 'S3 Bucket should exist.')
1461
- elif self.source.startswith('gs://'):
1462
- assert self.name == data_utils.split_gcs_path(
1463
- self.source
1464
- )[0], (
1465
- 'GCS Bucket is specified as path, the name should be '
1466
- 'the same as GCS bucket.')
1467
- elif self.source.startswith('r2://'):
1468
- assert self.name == data_utils.split_r2_path(
1469
- self.source
1470
- )[0], ('R2 Bucket is specified as path, the name should be '
1471
- 'the same as R2 bucket.')
1472
- assert data_utils.verify_r2_bucket(self.name), (
1473
- f'Source specified as {self.source}, a R2 bucket. ',
1474
- 'R2 Bucket should exist.')
1475
- elif self.source.startswith('cos://'):
1476
- assert self.name == data_utils.split_cos_path(
1477
- self.source
1478
- )[0], (
1479
- 'COS Bucket is specified as path, the name should be '
1480
- 'the same as COS bucket.')
1481
- assert data_utils.verify_ibm_cos_bucket(self.name), (
1482
- f'Source specified as {self.source}, a COS bucket. ',
1483
- 'COS Bucket should exist.')
1806
+ if self.source is not None and isinstance(self.source, str):
1807
+ if self.source.startswith('s3://'):
1808
+ assert self.name == data_utils.split_s3_path(self.source)[0], (
1809
+ 'S3 Bucket is specified as path, the name should be the'
1810
+ ' same as S3 bucket.')
1811
+ assert data_utils.verify_s3_bucket(self.name), (
1812
+ f'Source specified as {self.source}, an S3 bucket. ',
1813
+ 'S3 Bucket should exist.')
1814
+ elif self.source.startswith('gs://'):
1815
+ assert self.name == data_utils.split_gcs_path(self.source)[0], (
1816
+ 'GCS Bucket is specified as path, the name should be '
1817
+ 'the same as GCS bucket.')
1818
+ elif data_utils.is_az_container_endpoint(self.source):
1819
+ storage_account_name, container_name, _ = (
1820
+ data_utils.split_az_path(self.source))
1821
+ assert self.name == container_name, (
1822
+ 'Azure bucket is specified as path, the name should be '
1823
+ 'the same as Azure bucket.')
1824
+ assert data_utils.verify_az_bucket(
1825
+ storage_account_name, self.name), (
1826
+ f'Source specified as {self.source}, an Azure bucket. '
1827
+ 'Azure bucket should exist.')
1828
+ elif self.source.startswith('r2://'):
1829
+ assert self.name == data_utils.split_r2_path(self.source)[0], (
1830
+ 'R2 Bucket is specified as path, the name should be '
1831
+ 'the same as R2 bucket.')
1832
+ assert data_utils.verify_r2_bucket(self.name), (
1833
+ f'Source specified as {self.source}, a R2 bucket. ',
1834
+ 'R2 Bucket should exist.')
1835
+ elif self.source.startswith('cos://'):
1836
+ assert self.name == data_utils.split_cos_path(self.source)[0], (
1837
+ 'COS Bucket is specified as path, the name should be '
1838
+ 'the same as COS bucket.')
1839
+ assert data_utils.verify_ibm_cos_bucket(self.name), (
1840
+ f'Source specified as {self.source}, a COS bucket. ',
1841
+ 'COS Bucket should exist.')
1842
+ elif self.source.startswith('oci://'):
1843
+ raise NotImplementedError(
1844
+ 'Moving data from OCI to GCS is currently not supported.')
1484
1845
  # Validate name
1485
1846
  self.name = self.validate_name(self.name)
1486
1847
  # Check if the storage is enabled
@@ -1490,10 +1851,10 @@ class GcsStore(AbstractStore):
1490
1851
  'Storage \'store: gcs\' specified, but '
1491
1852
  'GCP access is disabled. To fix, enable '
1492
1853
  'GCP by running `sky check`. '
1493
- 'More info: https://skypilot.readthedocs.io/en/latest/getting-started/installation.html.') # pylint: disable=line-too-long
1854
+ 'More info: https://docs.skypilot.co/en/latest/getting-started/installation.html.') # pylint: disable=line-too-long
1494
1855
 
1495
1856
  @classmethod
1496
- def validate_name(cls, name) -> str:
1857
+ def validate_name(cls, name: str) -> str:
1497
1858
  """Validates the name of the GCS store.
1498
1859
 
1499
1860
  Source for rules: https://cloud.google.com/storage/docs/buckets#naming
@@ -1589,6 +1950,8 @@ class GcsStore(AbstractStore):
1589
1950
  self._transfer_to_gcs()
1590
1951
  elif self.source.startswith('r2://'):
1591
1952
  self._transfer_to_gcs()
1953
+ elif self.source.startswith('oci://'):
1954
+ self._transfer_to_gcs()
1592
1955
  else:
1593
1956
  # If a single directory is specified in source, upload
1594
1957
  # contents to root of bucket by suffixing /*.
@@ -1600,6 +1963,9 @@ class GcsStore(AbstractStore):
1600
1963
  f'Upload failed for store {self.name}') from e
1601
1964
 
1602
1965
  def delete(self) -> None:
1966
+ if self._bucket_sub_path is not None and not self.is_sky_managed:
1967
+ return self._delete_sub_path()
1968
+
1603
1969
  deleted_by_skypilot = self._delete_gcs_bucket(self.name)
1604
1970
  if deleted_by_skypilot:
1605
1971
  msg_str = f'Deleted GCS bucket {self.name}.'
@@ -1609,6 +1975,19 @@ class GcsStore(AbstractStore):
1609
1975
  logger.info(f'{colorama.Fore.GREEN}{msg_str}'
1610
1976
  f'{colorama.Style.RESET_ALL}')
1611
1977
 
1978
+ def _delete_sub_path(self) -> None:
1979
+ assert self._bucket_sub_path is not None, 'bucket_sub_path is not set'
1980
+ deleted_by_skypilot = self._delete_gcs_bucket(self.name,
1981
+ self._bucket_sub_path)
1982
+ if deleted_by_skypilot:
1983
+ msg_str = f'Deleted objects in GCS bucket ' \
1984
+ f'{self.name}/{self._bucket_sub_path}.'
1985
+ else:
1986
+ msg_str = f'GCS bucket {self.name} may have ' \
1987
+ 'been deleted externally.'
1988
+ logger.info(f'{colorama.Fore.GREEN}{msg_str}'
1989
+ f'{colorama.Style.RESET_ALL}')
1990
+
1612
1991
  def get_handle(self) -> StorageHandle:
1613
1992
  return self.client.get_bucket(self.name)
1614
1993
 
@@ -1641,15 +2020,23 @@ class GcsStore(AbstractStore):
1641
2020
  copy_list = '\n'.join(
1642
2021
  os.path.abspath(os.path.expanduser(p)) for p in source_path_list)
1643
2022
  gsutil_alias, alias_gen = data_utils.get_gsutil_command()
2023
+ sub_path = (f'/{self._bucket_sub_path}'
2024
+ if self._bucket_sub_path else '')
1644
2025
  sync_command = (f'{alias_gen}; echo "{copy_list}" | {gsutil_alias} '
1645
- f'cp -e -n -r -I gs://{self.name}')
1646
-
2026
+ f'cp -e -n -r -I gs://{self.name}{sub_path}')
2027
+ log_path = sky_logging.generate_tmp_logging_file_path(
2028
+ _STORAGE_LOG_FILE_NAME)
2029
+ sync_path = f'{source_message} -> gs://{self.name}{sub_path}/'
1647
2030
  with rich_utils.safe_status(
1648
- f'[bold cyan]Syncing '
1649
- f'[green]{source_message}[/] to [green]gs://{self.name}/[/]'):
2031
+ ux_utils.spinner_message(f'Syncing {sync_path}',
2032
+ log_path=log_path)):
1650
2033
  data_utils.run_upload_cli(sync_command,
1651
2034
  self._ACCESS_DENIED_MESSAGE,
1652
- bucket_name=self.name)
2035
+ bucket_name=self.name,
2036
+ log_path=log_path)
2037
+ logger.info(
2038
+ ux_utils.finishing_message(f'Storage synced: {sync_path}',
2039
+ log_path))
1653
2040
 
1654
2041
  def batch_gsutil_rsync(self,
1655
2042
  source_path_list: List[Path],
@@ -1671,6 +2058,8 @@ class GcsStore(AbstractStore):
1671
2058
  set to True, the directory is created in the bucket root and
1672
2059
  contents are uploaded to it.
1673
2060
  """
2061
+ sub_path = (f'/{self._bucket_sub_path}'
2062
+ if self._bucket_sub_path else '')
1674
2063
 
1675
2064
  def get_file_sync_command(base_dir_path, file_names):
1676
2065
  sync_format = '|'.join(file_names)
@@ -1678,12 +2067,11 @@ class GcsStore(AbstractStore):
1678
2067
  base_dir_path = shlex.quote(base_dir_path)
1679
2068
  sync_command = (f'{alias_gen}; {gsutil_alias} '
1680
2069
  f'rsync -e -x \'^(?!{sync_format}$).*\' '
1681
- f'{base_dir_path} gs://{self.name}')
2070
+ f'{base_dir_path} gs://{self.name}{sub_path}')
1682
2071
  return sync_command
1683
2072
 
1684
2073
  def get_dir_sync_command(src_dir_path, dest_dir_name):
1685
- excluded_list = storage_utils.get_excluded_files_from_gitignore(
1686
- src_dir_path)
2074
+ excluded_list = storage_utils.get_excluded_files(src_dir_path)
1687
2075
  # we exclude .git directory from the sync
1688
2076
  excluded_list.append(r'^\.git/.*$')
1689
2077
  excludes = '|'.join(excluded_list)
@@ -1691,7 +2079,7 @@ class GcsStore(AbstractStore):
1691
2079
  src_dir_path = shlex.quote(src_dir_path)
1692
2080
  sync_command = (f'{alias_gen}; {gsutil_alias} '
1693
2081
  f'rsync -e -r -x \'({excludes})\' {src_dir_path} '
1694
- f'gs://{self.name}/{dest_dir_name}')
2082
+ f'gs://{self.name}{sub_path}/{dest_dir_name}')
1695
2083
  return sync_command
1696
2084
 
1697
2085
  # Generate message for upload
@@ -1700,17 +2088,24 @@ class GcsStore(AbstractStore):
1700
2088
  else:
1701
2089
  source_message = source_path_list[0]
1702
2090
 
2091
+ log_path = sky_logging.generate_tmp_logging_file_path(
2092
+ _STORAGE_LOG_FILE_NAME)
2093
+ sync_path = f'{source_message} -> gs://{self.name}{sub_path}/'
1703
2094
  with rich_utils.safe_status(
1704
- f'[bold cyan]Syncing '
1705
- f'[green]{source_message}[/] to [green]gs://{self.name}/[/]'):
2095
+ ux_utils.spinner_message(f'Syncing {sync_path}',
2096
+ log_path=log_path)):
1706
2097
  data_utils.parallel_upload(
1707
2098
  source_path_list,
1708
2099
  get_file_sync_command,
1709
2100
  get_dir_sync_command,
2101
+ log_path,
1710
2102
  self.name,
1711
2103
  self._ACCESS_DENIED_MESSAGE,
1712
2104
  create_dirs=create_dirs,
1713
2105
  max_concurrent_uploads=_MAX_CONCURRENT_UPLOADS)
2106
+ logger.info(
2107
+ ux_utils.finishing_message(f'Storage synced: {sync_path}',
2108
+ log_path))
1714
2109
 
1715
2110
  def _transfer_to_gcs(self) -> None:
1716
2111
  if isinstance(self.source, str) and self.source.startswith('s3://'):
@@ -1789,7 +2184,8 @@ class GcsStore(AbstractStore):
1789
2184
  """
1790
2185
  install_cmd = mounting_utils.get_gcs_mount_install_cmd()
1791
2186
  mount_cmd = mounting_utils.get_gcs_mount_cmd(self.bucket.name,
1792
- mount_path)
2187
+ mount_path,
2188
+ self._bucket_sub_path)
1793
2189
  version_check_cmd = (
1794
2190
  f'gcsfuse --version | grep -q {mounting_utils.GCSFUSE_VERSION}')
1795
2191
  return mounting_utils.get_mounting_command(mount_path, install_cmd,
@@ -1824,22 +2220,43 @@ class GcsStore(AbstractStore):
1824
2220
  f'Attempted to create a bucket {self.name} but failed.'
1825
2221
  ) from e
1826
2222
  logger.info(
1827
- f'Created GCS bucket {new_bucket.name} in {new_bucket.location} '
1828
- f'with storage class {new_bucket.storage_class}')
2223
+ f' {colorama.Style.DIM}Created GCS bucket {new_bucket.name!r} in '
2224
+ f'{new_bucket.location} with storage class '
2225
+ f'{new_bucket.storage_class}{colorama.Style.RESET_ALL}')
1829
2226
  return new_bucket
1830
2227
 
1831
- def _delete_gcs_bucket(self, bucket_name: str) -> bool:
1832
- """Deletes GCS bucket, including all objects in bucket
2228
+ def _delete_gcs_bucket(
2229
+ self,
2230
+ bucket_name: str,
2231
+ # pylint: disable=invalid-name
2232
+ _bucket_sub_path: Optional[str] = None
2233
+ ) -> bool:
2234
+ """Deletes objects in GCS bucket
1833
2235
 
1834
2236
  Args:
1835
2237
  bucket_name: str; Name of bucket
2238
+ _bucket_sub_path: str; Sub path in the bucket, if provided only
2239
+ objects in the sub path will be deleted, else the whole bucket will
2240
+ be deleted
1836
2241
 
1837
2242
  Returns:
1838
2243
  bool; True if bucket was deleted, False if it was deleted externally.
1839
- """
1840
2244
 
2245
+ Raises:
2246
+ StorageBucketDeleteError: If deleting the bucket fails.
2247
+ PermissionError: If the bucket is external and the user is not
2248
+ allowed to delete it.
2249
+ """
2250
+ if _bucket_sub_path is not None:
2251
+ command_suffix = f'/{_bucket_sub_path}'
2252
+ hint_text = 'objects in '
2253
+ else:
2254
+ command_suffix = ''
2255
+ hint_text = ''
1841
2256
  with rich_utils.safe_status(
1842
- f'[bold cyan]Deleting GCS bucket {bucket_name}[/]'):
2257
+ ux_utils.spinner_message(
2258
+ f'Deleting {hint_text}GCS bucket '
2259
+ f'[green]{bucket_name}{command_suffix}[/]')):
1843
2260
  try:
1844
2261
  self.client.get_bucket(bucket_name)
1845
2262
  except gcp.forbidden_exception() as e:
@@ -1857,37 +2274,126 @@ class GcsStore(AbstractStore):
1857
2274
  return False
1858
2275
  try:
1859
2276
  gsutil_alias, alias_gen = data_utils.get_gsutil_command()
1860
- remove_obj_command = (f'{alias_gen};{gsutil_alias} '
1861
- f'rm -r gs://{bucket_name}')
2277
+ remove_obj_command = (
2278
+ f'{alias_gen};{gsutil_alias} '
2279
+ f'rm -r gs://{bucket_name}{command_suffix}')
1862
2280
  subprocess.check_output(remove_obj_command,
1863
2281
  stderr=subprocess.STDOUT,
1864
2282
  shell=True,
1865
2283
  executable='/bin/bash')
1866
2284
  return True
1867
2285
  except subprocess.CalledProcessError as e:
1868
- logger.error(e.output)
1869
2286
  with ux_utils.print_exception_no_traceback():
1870
2287
  raise exceptions.StorageBucketDeleteError(
1871
- f'Failed to delete GCS bucket {bucket_name}.')
2288
+ f'Failed to delete {hint_text}GCS bucket '
2289
+ f'{bucket_name}{command_suffix}.'
2290
+ f'Detailed error: {e.output}')
1872
2291
 
1873
2292
 
1874
- class R2Store(AbstractStore):
1875
- """R2Store inherits from S3Store Object and represents the backend
1876
- for R2 buckets.
1877
- """
2293
+ class AzureBlobStore(AbstractStore):
2294
+ """Represents the backend for Azure Blob Storage Container."""
1878
2295
 
1879
2296
  _ACCESS_DENIED_MESSAGE = 'Access Denied'
2297
+ DEFAULT_RESOURCE_GROUP_NAME = 'sky{user_hash}'
2298
+ # Unlike resource group names, which only need to be unique within the
2299
+ # subscription, storage account names must be globally unique across all of
2300
+ # Azure users. Hence, the storage account name includes the subscription
2301
+ # hash as well to ensure its uniqueness.
2302
+ DEFAULT_STORAGE_ACCOUNT_NAME = (
2303
+ 'sky{region_hash}{user_hash}{subscription_hash}')
2304
+ _SUBSCRIPTION_HASH_LENGTH = 4
2305
+ _REGION_HASH_LENGTH = 4
2306
+
2307
+ class AzureBlobStoreMetadata(AbstractStore.StoreMetadata):
2308
+ """A pickle-able representation of Azure Blob Store.
2309
+
2310
+ Allows store objects to be written to and reconstructed from
2311
+ global_user_state.
2312
+ """
2313
+
2314
+ def __init__(self,
2315
+ *,
2316
+ name: str,
2317
+ storage_account_name: str,
2318
+ source: Optional[SourceType],
2319
+ region: Optional[str] = None,
2320
+ is_sky_managed: Optional[bool] = None):
2321
+ self.storage_account_name = storage_account_name
2322
+ super().__init__(name=name,
2323
+ source=source,
2324
+ region=region,
2325
+ is_sky_managed=is_sky_managed)
2326
+
2327
+ def __repr__(self):
2328
+ return (f'AzureBlobStoreMetadata('
2329
+ f'\n\tname={self.name},'
2330
+ f'\n\tstorage_account_name={self.storage_account_name},'
2331
+ f'\n\tsource={self.source},'
2332
+ f'\n\tregion={self.region},'
2333
+ f'\n\tis_sky_managed={self.is_sky_managed})')
1880
2334
 
1881
2335
  def __init__(self,
1882
2336
  name: str,
1883
2337
  source: str,
1884
- region: Optional[str] = 'auto',
2338
+ storage_account_name: str = '',
2339
+ region: Optional[str] = 'eastus',
1885
2340
  is_sky_managed: Optional[bool] = None,
1886
- sync_on_reconstruction: Optional[bool] = True):
1887
- self.client: 'boto3.client.Client'
1888
- self.bucket: 'StorageHandle'
2341
+ sync_on_reconstruction: bool = True,
2342
+ _bucket_sub_path: Optional[str] = None):
2343
+ self.storage_client: 'storage.Client'
2344
+ self.resource_client: 'storage.Client'
2345
+ self.container_name: str
2346
+ # storage_account_name is not None when initializing only
2347
+ # when it is being reconstructed from the handle(metadata).
2348
+ self.storage_account_name = storage_account_name
2349
+ self.storage_account_key: Optional[str] = None
2350
+ self.resource_group_name: Optional[str] = None
2351
+ if region is None:
2352
+ region = 'eastus'
1889
2353
  super().__init__(name, source, region, is_sky_managed,
1890
- sync_on_reconstruction)
2354
+ sync_on_reconstruction, _bucket_sub_path)
2355
+
2356
+ @classmethod
2357
+ def from_metadata(cls, metadata: AbstractStore.StoreMetadata,
2358
+ **override_args) -> 'AzureBlobStore':
2359
+ """Creates AzureBlobStore from a AzureBlobStoreMetadata object.
2360
+
2361
+ Used when reconstructing Storage and Store objects from
2362
+ global_user_state.
2363
+
2364
+ Args:
2365
+ metadata: Metadata object containing AzureBlobStore information.
2366
+
2367
+ Returns:
2368
+ An instance of AzureBlobStore.
2369
+ """
2370
+ assert isinstance(metadata, AzureBlobStore.AzureBlobStoreMetadata)
2371
+ # TODO: this needs to be kept in sync with the abstract
2372
+ # AbstractStore.from_metadata.
2373
+ return cls(
2374
+ name=override_args.get('name', metadata.name),
2375
+ storage_account_name=override_args.get(
2376
+ 'storage_account', metadata.storage_account_name),
2377
+ source=override_args.get('source', metadata.source),
2378
+ region=override_args.get('region', metadata.region),
2379
+ is_sky_managed=override_args.get('is_sky_managed',
2380
+ metadata.is_sky_managed),
2381
+ sync_on_reconstruction=override_args.get('sync_on_reconstruction',
2382
+ True),
2383
+ # Backward compatibility
2384
+ # TODO: remove the hasattr check after v0.11.0
2385
+ _bucket_sub_path=override_args.get(
2386
+ '_bucket_sub_path',
2387
+ metadata._bucket_sub_path # pylint: disable=protected-access
2388
+ ) if hasattr(metadata, '_bucket_sub_path') else None)
2389
+
2390
+ def get_metadata(self) -> AzureBlobStoreMetadata:
2391
+ return self.AzureBlobStoreMetadata(
2392
+ name=self.name,
2393
+ storage_account_name=self.storage_account_name,
2394
+ source=self.source,
2395
+ region=self.region,
2396
+ is_sky_managed=self.is_sky_managed)
1891
2397
 
1892
2398
  def _validate(self):
1893
2399
  if self.source is not None and isinstance(self.source, str):
@@ -1905,42 +2411,108 @@ class R2Store(AbstractStore):
1905
2411
  assert data_utils.verify_gcs_bucket(self.name), (
1906
2412
  f'Source specified as {self.source}, a GCS bucket. ',
1907
2413
  'GCS Bucket should exist.')
2414
+ elif data_utils.is_az_container_endpoint(self.source):
2415
+ _, container_name, _ = data_utils.split_az_path(self.source)
2416
+ assert self.name == container_name, (
2417
+ 'Azure bucket is specified as path, the name should be '
2418
+ 'the same as Azure bucket.')
1908
2419
  elif self.source.startswith('r2://'):
1909
2420
  assert self.name == data_utils.split_r2_path(self.source)[0], (
1910
2421
  'R2 Bucket is specified as path, the name should be '
1911
2422
  'the same as R2 bucket.')
2423
+ assert data_utils.verify_r2_bucket(self.name), (
2424
+ f'Source specified as {self.source}, a R2 bucket. ',
2425
+ 'R2 Bucket should exist.')
1912
2426
  elif self.source.startswith('cos://'):
1913
2427
  assert self.name == data_utils.split_cos_path(self.source)[0], (
1914
- 'IBM COS Bucket is specified as path, the name should be '
2428
+ 'COS Bucket is specified as path, the name should be '
1915
2429
  'the same as COS bucket.')
1916
2430
  assert data_utils.verify_ibm_cos_bucket(self.name), (
1917
2431
  f'Source specified as {self.source}, a COS bucket. ',
1918
2432
  'COS Bucket should exist.')
2433
+ elif self.source.startswith('oci://'):
2434
+ raise NotImplementedError(
2435
+ 'Moving data from OCI to AZureBlob is not supported.')
1919
2436
  # Validate name
1920
- self.name = S3Store.validate_name(self.name)
2437
+ self.name = self.validate_name(self.name)
2438
+
1921
2439
  # Check if the storage is enabled
1922
- if not _is_storage_cloud_enabled(cloudflare.NAME):
2440
+ if not _is_storage_cloud_enabled(str(clouds.Azure())):
1923
2441
  with ux_utils.print_exception_no_traceback():
1924
2442
  raise exceptions.ResourcesUnavailableError(
1925
- 'Storage \'store: r2\' specified, but ' \
1926
- 'Cloudflare R2 access is disabled. To fix, '\
1927
- 'enable Cloudflare R2 by running `sky check`. '\
1928
- 'More info: https://skypilot.readthedocs.io/en/latest/getting-started/installation.html.' # pylint: disable=line-too-long
1929
- )
2443
+ 'Storage "store: azure" specified, but '
2444
+ 'Azure access is disabled. To fix, enable '
2445
+ 'Azure by running `sky check`. More info: '
2446
+ 'https://docs.skypilot.co/en/latest/getting-started/installation.html.' # pylint: disable=line-too-long
2447
+ )
2448
+
2449
+ @classmethod
2450
+ def validate_name(cls, name: str) -> str:
2451
+ """Validates the name of the AZ Container.
2452
+
2453
+ Source for rules: https://learn.microsoft.com/en-us/rest/api/storageservices/Naming-and-Referencing-Containers--Blobs--and-Metadata#container-names # pylint: disable=line-too-long
2454
+
2455
+ Args:
2456
+ name: Name of the container
2457
+
2458
+ Returns:
2459
+ Name of the container
2460
+
2461
+ Raises:
2462
+ StorageNameError: if the given container name does not follow the
2463
+ naming convention
2464
+ """
2465
+
2466
+ def _raise_no_traceback_name_error(err_str):
2467
+ with ux_utils.print_exception_no_traceback():
2468
+ raise exceptions.StorageNameError(err_str)
2469
+
2470
+ if name is not None and isinstance(name, str):
2471
+ if not 3 <= len(name) <= 63:
2472
+ _raise_no_traceback_name_error(
2473
+ f'Invalid store name: name {name} must be between 3 (min) '
2474
+ 'and 63 (max) characters long.')
2475
+
2476
+ # Check for valid characters and start/end with a letter or number
2477
+ pattern = r'^[a-z0-9][-a-z0-9]*[a-z0-9]$'
2478
+ if not re.match(pattern, name):
2479
+ _raise_no_traceback_name_error(
2480
+ f'Invalid store name: name {name} can consist only of '
2481
+ 'lowercase letters, numbers, and hyphens (-). '
2482
+ 'It must begin and end with a letter or number.')
2483
+
2484
+ # Check for two adjacent hyphens
2485
+ if '--' in name:
2486
+ _raise_no_traceback_name_error(
2487
+ f'Invalid store name: name {name} must not contain '
2488
+ 'two adjacent hyphens.')
2489
+
2490
+ else:
2491
+ _raise_no_traceback_name_error('Store name must be specified.')
2492
+ return name
1930
2493
 
1931
2494
  def initialize(self):
1932
- """Initializes the R2 store object on the cloud.
2495
+ """Initializes the AZ Container object on the cloud.
1933
2496
 
1934
- Initialization involves fetching bucket if exists, or creating it if
1935
- it does not.
2497
+ Initialization involves fetching container if exists, or creating it if
2498
+ it does not. Also, it checks for the existence of the storage account
2499
+ if provided by the user and the resource group is inferred from it.
2500
+ If not provided, both are created with a default naming conventions.
1936
2501
 
1937
2502
  Raises:
1938
- StorageBucketCreateError: If bucket creation fails
1939
- StorageBucketGetError: If fetching existing bucket fails
1940
- StorageInitError: If general initialization fails.
2503
+ StorageBucketCreateError: If container creation fails or storage
2504
+ account attempted to be created already exists.
2505
+ StorageBucketGetError: If fetching existing container fails.
2506
+ StorageInitError: If general initialization fails.
2507
+ NonExistentStorageAccountError: When storage account provided
2508
+ either through config.yaml or local db does not exist under
2509
+ user's subscription ID.
1941
2510
  """
1942
- self.client = data_utils.create_r2_client(self.region)
1943
- self.bucket, is_new_bucket = self._get_bucket()
2511
+ self.storage_client = data_utils.create_az_client('storage')
2512
+ self.resource_client = data_utils.create_az_client('resource')
2513
+ self._update_storage_account_name_and_resource()
2514
+
2515
+ self.container_name, is_new_bucket = self._get_bucket()
1944
2516
  if self.is_sky_managed is None:
1945
2517
  # If is_sky_managed is not specified, then this is a new storage
1946
2518
  # object (i.e., did not exist in global_user_state) and we should
@@ -1948,14 +2520,723 @@ class R2Store(AbstractStore):
1948
2520
  # If is_sky_managed is specified, then we take no action.
1949
2521
  self.is_sky_managed = is_new_bucket
1950
2522
 
1951
- def upload(self):
1952
- """Uploads source to store bucket.
2523
+ def _update_storage_account_name_and_resource(self):
2524
+ self.storage_account_name, self.resource_group_name = (
2525
+ self._get_storage_account_and_resource_group())
2526
+
2527
+ # resource_group_name is set to None when using non-sky-managed
2528
+ # public container or private container without authorization.
2529
+ if self.resource_group_name is not None:
2530
+ self.storage_account_key = data_utils.get_az_storage_account_key(
2531
+ self.storage_account_name, self.resource_group_name,
2532
+ self.storage_client, self.resource_client)
2533
+
2534
+ def update_storage_attributes(self, **kwargs: Dict[str, Any]):
2535
+ assert 'storage_account_name' in kwargs, (
2536
+ 'only storage_account_name supported')
2537
+ assert isinstance(kwargs['storage_account_name'],
2538
+ str), ('storage_account_name must be a string')
2539
+ self.storage_account_name = kwargs['storage_account_name']
2540
+ self._update_storage_account_name_and_resource()
1953
2541
 
1954
- Upload must be called by the Storage handler - it is not called on
1955
- Store initialization.
2542
+ @staticmethod
2543
+ def get_default_storage_account_name(region: Optional[str]) -> str:
2544
+ """Generates a unique default storage account name.
2545
+
2546
+ The subscription ID is included to avoid conflicts when user switches
2547
+ subscriptions. The length of region_hash, user_hash, and
2548
+ subscription_hash are adjusted to ensure the storage account name
2549
+ adheres to the 24-character limit, as some region names can be very
2550
+ long. Using a 4-character hash for the region helps keep the name
2551
+ concise and prevents potential conflicts.
2552
+ Reference: https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/resource-name-rules#microsoftstorage # pylint: disable=line-too-long
2553
+
2554
+ Args:
2555
+ region: Name of the region to create the storage account/container.
2556
+
2557
+ Returns:
2558
+ Name of the default storage account.
2559
+ """
2560
+ assert region is not None
2561
+ subscription_id = azure.get_subscription_id()
2562
+ subscription_hash_obj = hashlib.md5(subscription_id.encode('utf-8'))
2563
+ subscription_hash = subscription_hash_obj.hexdigest(
2564
+ )[:AzureBlobStore._SUBSCRIPTION_HASH_LENGTH]
2565
+ region_hash_obj = hashlib.md5(region.encode('utf-8'))
2566
+ region_hash = region_hash_obj.hexdigest()[:AzureBlobStore.
2567
+ _REGION_HASH_LENGTH]
2568
+
2569
+ storage_account_name = (
2570
+ AzureBlobStore.DEFAULT_STORAGE_ACCOUNT_NAME.format(
2571
+ region_hash=region_hash,
2572
+ user_hash=common_utils.get_user_hash(),
2573
+ subscription_hash=subscription_hash))
2574
+
2575
+ return storage_account_name
2576
+
2577
+ def _get_storage_account_and_resource_group(
2578
+ self) -> Tuple[str, Optional[str]]:
2579
+ """Get storage account and resource group to be used for AzureBlobStore
2580
+
2581
+ Storage account name and resource group name of the container to be
2582
+ used for AzureBlobStore object is obtained from this function. These
2583
+ are determined by either through the metadata, source, config.yaml, or
2584
+ default name:
2585
+
2586
+ 1) If self.storage_account_name already has a set value, this means we
2587
+ are reconstructing the storage object using metadata from the local
2588
+ state.db to reuse sky managed storage.
2589
+
2590
+ 2) Users provide externally created non-sky managed storage endpoint
2591
+ as a source from task yaml. Then, storage account is read from it and
2592
+ the resource group is inferred from it.
2593
+
2594
+ 3) Users provide the storage account, which they want to create the
2595
+ sky managed storage, through config.yaml. Then, resource group is
2596
+ inferred from it.
2597
+
2598
+ 4) If none of the above are true, default naming conventions are used
2599
+ to create the resource group and storage account for the users.
2600
+
2601
+ Returns:
2602
+ str: The storage account name.
2603
+ Optional[str]: The resource group name, or None if not found.
1956
2604
 
1957
2605
  Raises:
1958
- StorageUploadError: if upload fails.
2606
+ StorageBucketCreateError: If storage account attempted to be
2607
+ created already exists.
2608
+ NonExistentStorageAccountError: When storage account provided
2609
+ either through config.yaml or local db does not exist under
2610
+ user's subscription ID.
2611
+ """
2612
+ # self.storage_account_name already has a value only when it is being
2613
+ # reconstructed with metadata from local db.
2614
+ if self.storage_account_name:
2615
+ resource_group_name = azure.get_az_resource_group(
2616
+ self.storage_account_name)
2617
+ if resource_group_name is None:
2618
+ # If the storage account does not exist, the containers under
2619
+ # the account does not exist as well.
2620
+ with ux_utils.print_exception_no_traceback():
2621
+ raise exceptions.NonExistentStorageAccountError(
2622
+ f'The storage account {self.storage_account_name!r} '
2623
+ 'read from local db does not exist under your '
2624
+ 'subscription ID. The account may have been externally'
2625
+ ' deleted.')
2626
+ storage_account_name = self.storage_account_name
2627
+ # Using externally created container
2628
+ elif (isinstance(self.source, str) and
2629
+ data_utils.is_az_container_endpoint(self.source)):
2630
+ storage_account_name, container_name, _ = data_utils.split_az_path(
2631
+ self.source)
2632
+ assert self.name == container_name
2633
+ resource_group_name = azure.get_az_resource_group(
2634
+ storage_account_name)
2635
+ # Creates new resource group and storage account or use the
2636
+ # storage_account provided by the user through config.yaml
2637
+ else:
2638
+ config_storage_account = skypilot_config.get_nested(
2639
+ ('azure', 'storage_account'), None)
2640
+ if config_storage_account is not None:
2641
+ # using user provided storage account from config.yaml
2642
+ storage_account_name = config_storage_account
2643
+ resource_group_name = azure.get_az_resource_group(
2644
+ storage_account_name)
2645
+ # when the provided storage account does not exist under user's
2646
+ # subscription id.
2647
+ if resource_group_name is None:
2648
+ with ux_utils.print_exception_no_traceback():
2649
+ raise exceptions.NonExistentStorageAccountError(
2650
+ 'The storage account '
2651
+ f'{storage_account_name!r} specified in '
2652
+ 'config.yaml does not exist under the user\'s '
2653
+ 'subscription ID. Provide a storage account '
2654
+ 'through config.yaml only when creating a '
2655
+ 'container under an already existing storage '
2656
+ 'account within your subscription ID.')
2657
+ else:
2658
+ # If storage account name is not provided from config, then
2659
+ # use default resource group and storage account names.
2660
+ storage_account_name = self.get_default_storage_account_name(
2661
+ self.region)
2662
+ resource_group_name = (self.DEFAULT_RESOURCE_GROUP_NAME.format(
2663
+ user_hash=common_utils.get_user_hash()))
2664
+ try:
2665
+ # obtains detailed information about resource group under
2666
+ # the user's subscription. Used to check if the name
2667
+ # already exists
2668
+ self.resource_client.resource_groups.get(
2669
+ resource_group_name)
2670
+ except azure.exceptions().ResourceNotFoundError:
2671
+ with rich_utils.safe_status(
2672
+ ux_utils.spinner_message(
2673
+ f'Setting up resource group: '
2674
+ f'{resource_group_name}')):
2675
+ self.resource_client.resource_groups.create_or_update(
2676
+ resource_group_name, {'location': self.region})
2677
+ logger.info(' Created Azure resource group '
2678
+ f'{resource_group_name!r}.')
2679
+ # check if the storage account name already exists under the
2680
+ # given resource group name.
2681
+ try:
2682
+ self.storage_client.storage_accounts.get_properties(
2683
+ resource_group_name, storage_account_name)
2684
+ except azure.exceptions().ResourceNotFoundError:
2685
+ with rich_utils.safe_status(
2686
+ ux_utils.spinner_message(
2687
+ f'Setting up storage account: '
2688
+ f'{storage_account_name}')):
2689
+ self._create_storage_account(resource_group_name,
2690
+ storage_account_name)
2691
+ # wait until new resource creation propagates to Azure.
2692
+ time.sleep(1)
2693
+ logger.info(' Created Azure storage account '
2694
+ f'{storage_account_name!r}.')
2695
+
2696
+ return storage_account_name, resource_group_name
2697
+
2698
+ def _create_storage_account(self, resource_group_name: str,
2699
+ storage_account_name: str) -> None:
2700
+ """Creates new storage account and assign Storage Blob Data Owner role.
2701
+
2702
+ Args:
2703
+ resource_group_name: Name of the resource group which the storage
2704
+ account will be created under.
2705
+ storage_account_name: Name of the storage account to be created.
2706
+
2707
+ Raises:
2708
+ StorageBucketCreateError: If storage account attempted to be
2709
+ created already exists or fails to assign role to the create
2710
+ storage account.
2711
+ """
2712
+ try:
2713
+ creation_response = (
2714
+ self.storage_client.storage_accounts.begin_create(
2715
+ resource_group_name, storage_account_name, {
2716
+ 'sku': {
2717
+ 'name': 'Standard_GRS'
2718
+ },
2719
+ 'kind': 'StorageV2',
2720
+ 'location': self.region,
2721
+ 'encryption': {
2722
+ 'services': {
2723
+ 'blob': {
2724
+ 'key_type': 'Account',
2725
+ 'enabled': True
2726
+ }
2727
+ },
2728
+ 'key_source': 'Microsoft.Storage'
2729
+ },
2730
+ }).result())
2731
+ except azure.exceptions().ResourceExistsError as error:
2732
+ with ux_utils.print_exception_no_traceback():
2733
+ raise exceptions.StorageBucketCreateError(
2734
+ 'Failed to create storage account '
2735
+ f'{storage_account_name!r}. You may be '
2736
+ 'attempting to create a storage account '
2737
+ 'already being in use. Details: '
2738
+ f'{common_utils.format_exception(error, use_bracket=True)}')
2739
+
2740
+ # It may take some time for the created storage account to propagate
2741
+ # to Azure, we reattempt to assign the role for several times until
2742
+ # storage account creation fully propagates.
2743
+ role_assignment_start = time.time()
2744
+ retry = 0
2745
+
2746
+ while (time.time() - role_assignment_start <
2747
+ constants.WAIT_FOR_STORAGE_ACCOUNT_CREATION):
2748
+ try:
2749
+ azure.assign_storage_account_iam_role(
2750
+ storage_account_name=storage_account_name,
2751
+ storage_account_id=creation_response.id)
2752
+ return
2753
+ except AttributeError as e:
2754
+ if 'signed_session' in str(e):
2755
+ if retry % 5 == 0:
2756
+ logger.info(
2757
+ 'Retrying role assignment due to propagation '
2758
+ 'delay of the newly created storage account. '
2759
+ f'Retry count: {retry}.')
2760
+ time.sleep(1)
2761
+ retry += 1
2762
+ continue
2763
+ with ux_utils.print_exception_no_traceback():
2764
+ role_assignment_failure_error_msg = (
2765
+ constants.ROLE_ASSIGNMENT_FAILURE_ERROR_MSG.format(
2766
+ storage_account_name=storage_account_name))
2767
+ raise exceptions.StorageBucketCreateError(
2768
+ f'{role_assignment_failure_error_msg}'
2769
+ 'Details: '
2770
+ f'{common_utils.format_exception(e, use_bracket=True)}')
2771
+
2772
+ def upload(self):
2773
+ """Uploads source to store bucket.
2774
+
2775
+ Upload must be called by the Storage handler - it is not called on
2776
+ Store initialization.
2777
+
2778
+ Raises:
2779
+ StorageUploadError: if upload fails.
2780
+ """
2781
+ try:
2782
+ if isinstance(self.source, list):
2783
+ self.batch_az_blob_sync(self.source, create_dirs=True)
2784
+ elif self.source is not None:
2785
+ error_message = (
2786
+ 'Moving data directly from {cloud} to Azure is currently '
2787
+ 'not supported. Please specify a local source for the '
2788
+ 'storage object.')
2789
+ if data_utils.is_az_container_endpoint(self.source):
2790
+ pass
2791
+ elif self.source.startswith('s3://'):
2792
+ raise NotImplementedError(error_message.format('S3'))
2793
+ elif self.source.startswith('gs://'):
2794
+ raise NotImplementedError(error_message.format('GCS'))
2795
+ elif self.source.startswith('r2://'):
2796
+ raise NotImplementedError(error_message.format('R2'))
2797
+ elif self.source.startswith('cos://'):
2798
+ raise NotImplementedError(error_message.format('IBM COS'))
2799
+ elif self.source.startswith('oci://'):
2800
+ raise NotImplementedError(error_message.format('OCI'))
2801
+ else:
2802
+ self.batch_az_blob_sync([self.source])
2803
+ except exceptions.StorageUploadError:
2804
+ raise
2805
+ except Exception as e:
2806
+ raise exceptions.StorageUploadError(
2807
+ f'Upload failed for store {self.name}') from e
2808
+
2809
+ def delete(self) -> None:
2810
+ """Deletes the storage."""
2811
+ if self._bucket_sub_path is not None and not self.is_sky_managed:
2812
+ return self._delete_sub_path()
2813
+
2814
+ deleted_by_skypilot = self._delete_az_bucket(self.name)
2815
+ if deleted_by_skypilot:
2816
+ msg_str = (f'Deleted AZ Container {self.name!r} under storage '
2817
+ f'account {self.storage_account_name!r}.')
2818
+ else:
2819
+ msg_str = (f'AZ Container {self.name} may have '
2820
+ 'been deleted externally. Removing from local state.')
2821
+ logger.info(f'{colorama.Fore.GREEN}{msg_str}'
2822
+ f'{colorama.Style.RESET_ALL}')
2823
+
2824
+ def _delete_sub_path(self) -> None:
2825
+ assert self._bucket_sub_path is not None, 'bucket_sub_path is not set'
2826
+ try:
2827
+ container_url = data_utils.AZURE_CONTAINER_URL.format(
2828
+ storage_account_name=self.storage_account_name,
2829
+ container_name=self.name)
2830
+ container_client = data_utils.create_az_client(
2831
+ client_type='container',
2832
+ container_url=container_url,
2833
+ storage_account_name=self.storage_account_name,
2834
+ resource_group_name=self.resource_group_name)
2835
+ # List and delete blobs in the specified directory
2836
+ blobs = container_client.list_blobs(
2837
+ name_starts_with=self._bucket_sub_path + '/')
2838
+ for blob in blobs:
2839
+ container_client.delete_blob(blob.name)
2840
+ logger.info(
2841
+ f'Deleted objects from sub path {self._bucket_sub_path} '
2842
+ f'in container {self.name}.')
2843
+ except Exception as e: # pylint: disable=broad-except
2844
+ logger.error(
2845
+ f'Failed to delete objects from sub path '
2846
+ f'{self._bucket_sub_path} in container {self.name}. '
2847
+ f'Details: {common_utils.format_exception(e, use_bracket=True)}'
2848
+ )
2849
+
2850
+ def get_handle(self) -> StorageHandle:
2851
+ """Returns the Storage Handle object."""
2852
+ return self.storage_client.blob_containers.get(
2853
+ self.resource_group_name, self.storage_account_name, self.name)
2854
+
2855
+ def batch_az_blob_sync(self,
2856
+ source_path_list: List[Path],
2857
+ create_dirs: bool = False) -> None:
2858
+ """Invokes az storage blob sync to batch upload a list of local paths.
2859
+
2860
+ Args:
2861
+ source_path_list: List of paths to local files or directories
2862
+ create_dirs: If the local_path is a directory and this is set to
2863
+ False, the contents of the directory are directly uploaded to
2864
+ root of the bucket. If the local_path is a directory and this is
2865
+ set to True, the directory is created in the bucket root and
2866
+ contents are uploaded to it.
2867
+ """
2868
+ container_path = (f'{self.container_name}/{self._bucket_sub_path}'
2869
+ if self._bucket_sub_path else self.container_name)
2870
+
2871
+ def get_file_sync_command(base_dir_path, file_names) -> str:
2872
+ # shlex.quote is not used for file_names as 'az storage blob sync'
2873
+ # already handles file names with empty spaces when used with
2874
+ # '--include-pattern' option.
2875
+ includes_list = ';'.join(file_names)
2876
+ includes = f'--include-pattern "{includes_list}"'
2877
+ base_dir_path = shlex.quote(base_dir_path)
2878
+ sync_command = (f'az storage blob sync '
2879
+ f'--account-name {self.storage_account_name} '
2880
+ f'--account-key {self.storage_account_key} '
2881
+ f'{includes} '
2882
+ '--delete-destination false '
2883
+ f'--source {base_dir_path} '
2884
+ f'--container {container_path}')
2885
+ return sync_command
2886
+
2887
+ def get_dir_sync_command(src_dir_path, dest_dir_name) -> str:
2888
+ # we exclude .git directory from the sync
2889
+ excluded_list = storage_utils.get_excluded_files(src_dir_path)
2890
+ excluded_list.append('.git/')
2891
+ excludes_list = ';'.join(
2892
+ [file_name.rstrip('*') for file_name in excluded_list])
2893
+ excludes = f'--exclude-path "{excludes_list}"'
2894
+ src_dir_path = shlex.quote(src_dir_path)
2895
+ if dest_dir_name:
2896
+ dest_dir_name = f'/{dest_dir_name}'
2897
+ else:
2898
+ dest_dir_name = ''
2899
+ sync_command = (f'az storage blob sync '
2900
+ f'--account-name {self.storage_account_name} '
2901
+ f'--account-key {self.storage_account_key} '
2902
+ f'{excludes} '
2903
+ '--delete-destination false '
2904
+ f'--source {src_dir_path} '
2905
+ f'--container {container_path}{dest_dir_name}')
2906
+ return sync_command
2907
+
2908
+ # Generate message for upload
2909
+ assert source_path_list
2910
+ if len(source_path_list) > 1:
2911
+ source_message = f'{len(source_path_list)} paths'
2912
+ else:
2913
+ source_message = source_path_list[0]
2914
+ container_endpoint = data_utils.AZURE_CONTAINER_URL.format(
2915
+ storage_account_name=self.storage_account_name,
2916
+ container_name=container_path)
2917
+ log_path = sky_logging.generate_tmp_logging_file_path(
2918
+ _STORAGE_LOG_FILE_NAME)
2919
+ sync_path = f'{source_message} -> {container_endpoint}/'
2920
+ with rich_utils.safe_status(
2921
+ ux_utils.spinner_message(f'Syncing {sync_path}',
2922
+ log_path=log_path)):
2923
+ data_utils.parallel_upload(
2924
+ source_path_list,
2925
+ get_file_sync_command,
2926
+ get_dir_sync_command,
2927
+ log_path,
2928
+ self.name,
2929
+ self._ACCESS_DENIED_MESSAGE,
2930
+ create_dirs=create_dirs,
2931
+ max_concurrent_uploads=_MAX_CONCURRENT_UPLOADS)
2932
+ logger.info(
2933
+ ux_utils.finishing_message(f'Storage synced: {sync_path}',
2934
+ log_path))
2935
+
2936
+ def _get_bucket(self) -> Tuple[str, bool]:
2937
+ """Obtains the AZ Container.
2938
+
2939
+ Buckets for Azure Blob Storage are referred as Containers.
2940
+ If the container exists, this method will return the container.
2941
+ If the container does not exist, there are three cases:
2942
+ 1) Raise an error if the container source starts with https://
2943
+ 2) Return None if container has been externally deleted and
2944
+ sync_on_reconstruction is False
2945
+ 3) Create and return a new container otherwise
2946
+
2947
+ Returns:
2948
+ str: name of the bucket(container)
2949
+ bool: represents either or not the bucket is managed by skypilot
2950
+
2951
+ Raises:
2952
+ StorageBucketCreateError: If creating the container fails
2953
+ StorageBucketGetError: If fetching a container fails
2954
+ StorageExternalDeletionError: If externally deleted container is
2955
+ attempted to be fetched while reconstructing the Storage for
2956
+ 'sky storage delete' or 'sky start'
2957
+ """
2958
+ try:
2959
+ container_url = data_utils.AZURE_CONTAINER_URL.format(
2960
+ storage_account_name=self.storage_account_name,
2961
+ container_name=self.name)
2962
+ try:
2963
+ container_client = data_utils.create_az_client(
2964
+ client_type='container',
2965
+ container_url=container_url,
2966
+ storage_account_name=self.storage_account_name,
2967
+ resource_group_name=self.resource_group_name)
2968
+ except azure.exceptions().ClientAuthenticationError as e:
2969
+ if 'ERROR: AADSTS50020' in str(e):
2970
+ # Caught when failing to obtain container client due to
2971
+ # lack of permission to passed given private container.
2972
+ if self.resource_group_name is None:
2973
+ with ux_utils.print_exception_no_traceback():
2974
+ raise exceptions.StorageBucketGetError(
2975
+ _BUCKET_FAIL_TO_CONNECT_MESSAGE.format(
2976
+ name=self.name))
2977
+ raise
2978
+ if container_client.exists():
2979
+ is_private = (True if
2980
+ container_client.get_container_properties().get(
2981
+ 'public_access', None) is None else False)
2982
+ # when user attempts to use private container without
2983
+ # access rights
2984
+ if self.resource_group_name is None and is_private:
2985
+ with ux_utils.print_exception_no_traceback():
2986
+ raise exceptions.StorageBucketGetError(
2987
+ _BUCKET_FAIL_TO_CONNECT_MESSAGE.format(
2988
+ name=self.name))
2989
+ self._validate_existing_bucket()
2990
+ return container_client.container_name, False
2991
+ # when the container name does not exist under the provided
2992
+ # storage account name and credentials, and user has the rights to
2993
+ # access the storage account.
2994
+ else:
2995
+ # when this if statement is not True, we let it to proceed
2996
+ # farther and create the container.
2997
+ if (isinstance(self.source, str) and
2998
+ self.source.startswith('https://')):
2999
+ with ux_utils.print_exception_no_traceback():
3000
+ raise exceptions.StorageBucketGetError(
3001
+ 'Attempted to use a non-existent container as a '
3002
+ f'source: {self.source}. Please check if the '
3003
+ 'container name is correct.')
3004
+ except azure.exceptions().ServiceRequestError as e:
3005
+ # raised when storage account name to be used does not exist.
3006
+ error_message = e.message
3007
+ if 'Name or service not known' in error_message:
3008
+ with ux_utils.print_exception_no_traceback():
3009
+ raise exceptions.StorageBucketGetError(
3010
+ 'Attempted to fetch the container from non-existent '
3011
+ 'storage account '
3012
+ f'name: {self.storage_account_name}. Please check '
3013
+ 'if the name is correct.')
3014
+ else:
3015
+ with ux_utils.print_exception_no_traceback():
3016
+ raise exceptions.StorageBucketGetError(
3017
+ 'Failed to fetch the container from storage account '
3018
+ f'{self.storage_account_name!r}.'
3019
+ 'Details: '
3020
+ f'{common_utils.format_exception(e, use_bracket=True)}')
3021
+
3022
+ # If the container cannot be found in both private and public settings,
3023
+ # the container is to be created by Sky. However, creation is skipped
3024
+ # if Store object is being reconstructed for deletion or re-mount with
3025
+ # sky start, and error is raised instead.
3026
+ if self.sync_on_reconstruction:
3027
+ container = self._create_az_bucket(self.name)
3028
+ return container.name, True
3029
+
3030
+ # Raised when Storage object is reconstructed for sky storage
3031
+ # delete or to re-mount Storages with sky start but the storage
3032
+ # is already removed externally.
3033
+ with ux_utils.print_exception_no_traceback():
3034
+ raise exceptions.StorageExternalDeletionError(
3035
+ f'Attempted to fetch a non-existent container: {self.name}')
3036
+
3037
+ def mount_command(self, mount_path: str) -> str:
3038
+ """Returns the command to mount the container to the mount_path.
3039
+
3040
+ Uses blobfuse2 to mount the container.
3041
+
3042
+ Args:
3043
+ mount_path: Path to mount the container to
3044
+
3045
+ Returns:
3046
+ str: a heredoc used to setup the AZ Container mount
3047
+ """
3048
+ install_cmd = mounting_utils.get_az_mount_install_cmd()
3049
+ mount_cmd = mounting_utils.get_az_mount_cmd(self.container_name,
3050
+ self.storage_account_name,
3051
+ mount_path,
3052
+ self.storage_account_key,
3053
+ self._bucket_sub_path)
3054
+ return mounting_utils.get_mounting_command(mount_path, install_cmd,
3055
+ mount_cmd)
3056
+
3057
+ def _create_az_bucket(self, container_name: str) -> StorageHandle:
3058
+ """Creates AZ Container.
3059
+
3060
+ Args:
3061
+ container_name: Name of bucket(container)
3062
+
3063
+ Returns:
3064
+ StorageHandle: Handle to interact with the container
3065
+
3066
+ Raises:
3067
+ StorageBucketCreateError: If container creation fails.
3068
+ """
3069
+ try:
3070
+ # Container is created under the region which the storage account
3071
+ # belongs to.
3072
+ container = self.storage_client.blob_containers.create(
3073
+ self.resource_group_name,
3074
+ self.storage_account_name,
3075
+ container_name,
3076
+ blob_container={})
3077
+ logger.info(f' {colorama.Style.DIM}Created AZ Container '
3078
+ f'{container_name!r} in {self.region!r} under storage '
3079
+ f'account {self.storage_account_name!r}.'
3080
+ f'{colorama.Style.RESET_ALL}')
3081
+ except azure.exceptions().ResourceExistsError as e:
3082
+ if 'container is being deleted' in e.error.message:
3083
+ with ux_utils.print_exception_no_traceback():
3084
+ raise exceptions.StorageBucketCreateError(
3085
+ f'The container {self.name!r} is currently being '
3086
+ 'deleted. Please wait for the deletion to complete'
3087
+ 'before attempting to create a container with the '
3088
+ 'same name. This may take a few minutes.')
3089
+ else:
3090
+ with ux_utils.print_exception_no_traceback():
3091
+ raise exceptions.StorageBucketCreateError(
3092
+ f'Failed to create the container {self.name!r}. '
3093
+ 'Details: '
3094
+ f'{common_utils.format_exception(e, use_bracket=True)}')
3095
+ return container
3096
+
3097
+ def _delete_az_bucket(self, container_name: str) -> bool:
3098
+ """Deletes AZ Container, including all objects in Container.
3099
+
3100
+ Args:
3101
+ container_name: Name of bucket(container).
3102
+
3103
+ Returns:
3104
+ bool: True if container was deleted, False if it's deleted
3105
+ externally.
3106
+
3107
+ Raises:
3108
+ StorageBucketDeleteError: If deletion fails for reasons other than
3109
+ the container not existing.
3110
+ """
3111
+ try:
3112
+ with rich_utils.safe_status(
3113
+ ux_utils.spinner_message(
3114
+ f'Deleting Azure container {container_name}')):
3115
+ # Check for the existance of the container before deletion.
3116
+ self.storage_client.blob_containers.get(
3117
+ self.resource_group_name,
3118
+ self.storage_account_name,
3119
+ container_name,
3120
+ )
3121
+ self.storage_client.blob_containers.delete(
3122
+ self.resource_group_name,
3123
+ self.storage_account_name,
3124
+ container_name,
3125
+ )
3126
+ except azure.exceptions().ResourceNotFoundError as e:
3127
+ if 'Code: ContainerNotFound' in str(e):
3128
+ logger.debug(
3129
+ _BUCKET_EXTERNALLY_DELETED_DEBUG_MESSAGE.format(
3130
+ bucket_name=container_name))
3131
+ return False
3132
+ else:
3133
+ with ux_utils.print_exception_no_traceback():
3134
+ raise exceptions.StorageBucketDeleteError(
3135
+ f'Failed to delete Azure container {container_name}. '
3136
+ f'Detailed error: {e}')
3137
+ return True
3138
+
3139
+
3140
+ class R2Store(AbstractStore):
3141
+ """R2Store inherits from S3Store Object and represents the backend
3142
+ for R2 buckets.
3143
+ """
3144
+
3145
+ _ACCESS_DENIED_MESSAGE = 'Access Denied'
3146
+
3147
+ def __init__(self,
3148
+ name: str,
3149
+ source: str,
3150
+ region: Optional[str] = 'auto',
3151
+ is_sky_managed: Optional[bool] = None,
3152
+ sync_on_reconstruction: Optional[bool] = True,
3153
+ _bucket_sub_path: Optional[str] = None):
3154
+ self.client: 'boto3.client.Client'
3155
+ self.bucket: 'StorageHandle'
3156
+ super().__init__(name, source, region, is_sky_managed,
3157
+ sync_on_reconstruction, _bucket_sub_path)
3158
+
3159
+ def _validate(self):
3160
+ if self.source is not None and isinstance(self.source, str):
3161
+ if self.source.startswith('s3://'):
3162
+ assert self.name == data_utils.split_s3_path(self.source)[0], (
3163
+ 'S3 Bucket is specified as path, the name should be the'
3164
+ ' same as S3 bucket.')
3165
+ assert data_utils.verify_s3_bucket(self.name), (
3166
+ f'Source specified as {self.source}, a S3 bucket. ',
3167
+ 'S3 Bucket should exist.')
3168
+ elif self.source.startswith('gs://'):
3169
+ assert self.name == data_utils.split_gcs_path(self.source)[0], (
3170
+ 'GCS Bucket is specified as path, the name should be '
3171
+ 'the same as GCS bucket.')
3172
+ assert data_utils.verify_gcs_bucket(self.name), (
3173
+ f'Source specified as {self.source}, a GCS bucket. ',
3174
+ 'GCS Bucket should exist.')
3175
+ elif data_utils.is_az_container_endpoint(self.source):
3176
+ storage_account_name, container_name, _ = (
3177
+ data_utils.split_az_path(self.source))
3178
+ assert self.name == container_name, (
3179
+ 'Azure bucket is specified as path, the name should be '
3180
+ 'the same as Azure bucket.')
3181
+ assert data_utils.verify_az_bucket(
3182
+ storage_account_name, self.name), (
3183
+ f'Source specified as {self.source}, an Azure bucket. '
3184
+ 'Azure bucket should exist.')
3185
+ elif self.source.startswith('r2://'):
3186
+ assert self.name == data_utils.split_r2_path(self.source)[0], (
3187
+ 'R2 Bucket is specified as path, the name should be '
3188
+ 'the same as R2 bucket.')
3189
+ elif self.source.startswith('cos://'):
3190
+ assert self.name == data_utils.split_cos_path(self.source)[0], (
3191
+ 'IBM COS Bucket is specified as path, the name should be '
3192
+ 'the same as COS bucket.')
3193
+ assert data_utils.verify_ibm_cos_bucket(self.name), (
3194
+ f'Source specified as {self.source}, a COS bucket. ',
3195
+ 'COS Bucket should exist.')
3196
+ elif self.source.startswith('oci://'):
3197
+ raise NotImplementedError(
3198
+ 'Moving data from OCI to R2 is currently not supported.')
3199
+
3200
+ # Validate name
3201
+ self.name = S3Store.validate_name(self.name)
3202
+ # Check if the storage is enabled
3203
+ if not _is_storage_cloud_enabled(cloudflare.NAME):
3204
+ with ux_utils.print_exception_no_traceback():
3205
+ raise exceptions.ResourcesUnavailableError(
3206
+ 'Storage \'store: r2\' specified, but ' \
3207
+ 'Cloudflare R2 access is disabled. To fix, '\
3208
+ 'enable Cloudflare R2 by running `sky check`. '\
3209
+ 'More info: https://docs.skypilot.co/en/latest/getting-started/installation.html.' # pylint: disable=line-too-long
3210
+ )
3211
+
3212
+ def initialize(self):
3213
+ """Initializes the R2 store object on the cloud.
3214
+
3215
+ Initialization involves fetching bucket if exists, or creating it if
3216
+ it does not.
3217
+
3218
+ Raises:
3219
+ StorageBucketCreateError: If bucket creation fails
3220
+ StorageBucketGetError: If fetching existing bucket fails
3221
+ StorageInitError: If general initialization fails.
3222
+ """
3223
+ self.client = data_utils.create_r2_client(self.region)
3224
+ self.bucket, is_new_bucket = self._get_bucket()
3225
+ if self.is_sky_managed is None:
3226
+ # If is_sky_managed is not specified, then this is a new storage
3227
+ # object (i.e., did not exist in global_user_state) and we should
3228
+ # set the is_sky_managed property.
3229
+ # If is_sky_managed is specified, then we take no action.
3230
+ self.is_sky_managed = is_new_bucket
3231
+
3232
+ def upload(self):
3233
+ """Uploads source to store bucket.
3234
+
3235
+ Upload must be called by the Storage handler - it is not called on
3236
+ Store initialization.
3237
+
3238
+ Raises:
3239
+ StorageUploadError: if upload fails.
1959
3240
  """
1960
3241
  try:
1961
3242
  if isinstance(self.source, list):
@@ -1967,6 +3248,8 @@ class R2Store(AbstractStore):
1967
3248
  self._transfer_to_r2()
1968
3249
  elif self.source.startswith('r2://'):
1969
3250
  pass
3251
+ elif self.source.startswith('oci://'):
3252
+ self._transfer_to_r2()
1970
3253
  else:
1971
3254
  self.batch_aws_rsync([self.source])
1972
3255
  except exceptions.StorageUploadError:
@@ -1976,6 +3259,9 @@ class R2Store(AbstractStore):
1976
3259
  f'Upload failed for store {self.name}') from e
1977
3260
 
1978
3261
  def delete(self) -> None:
3262
+ if self._bucket_sub_path is not None and not self.is_sky_managed:
3263
+ return self._delete_sub_path()
3264
+
1979
3265
  deleted_by_skypilot = self._delete_r2_bucket(self.name)
1980
3266
  if deleted_by_skypilot:
1981
3267
  msg_str = f'Deleted R2 bucket {self.name}.'
@@ -1985,6 +3271,19 @@ class R2Store(AbstractStore):
1985
3271
  logger.info(f'{colorama.Fore.GREEN}{msg_str}'
1986
3272
  f'{colorama.Style.RESET_ALL}')
1987
3273
 
3274
+ def _delete_sub_path(self) -> None:
3275
+ assert self._bucket_sub_path is not None, 'bucket_sub_path is not set'
3276
+ deleted_by_skypilot = self._delete_r2_bucket_sub_path(
3277
+ self.name, self._bucket_sub_path)
3278
+ if deleted_by_skypilot:
3279
+ msg_str = f'Removed objects from R2 bucket ' \
3280
+ f'{self.name}/{self._bucket_sub_path}.'
3281
+ else:
3282
+ msg_str = f'Failed to remove objects from R2 bucket ' \
3283
+ f'{self.name}/{self._bucket_sub_path}.'
3284
+ logger.info(f'{colorama.Fore.GREEN}{msg_str}'
3285
+ f'{colorama.Style.RESET_ALL}')
3286
+
1988
3287
  def get_handle(self) -> StorageHandle:
1989
3288
  return cloudflare.resource('s3').Bucket(self.name)
1990
3289
 
@@ -2008,6 +3307,8 @@ class R2Store(AbstractStore):
2008
3307
  set to True, the directory is created in the bucket root and
2009
3308
  contents are uploaded to it.
2010
3309
  """
3310
+ sub_path = (f'/{self._bucket_sub_path}'
3311
+ if self._bucket_sub_path else '')
2011
3312
 
2012
3313
  def get_file_sync_command(base_dir_path, file_names):
2013
3314
  includes = ' '.join([
@@ -2020,15 +3321,14 @@ class R2Store(AbstractStore):
2020
3321
  f'{cloudflare.R2_CREDENTIALS_PATH} '
2021
3322
  'aws s3 sync --no-follow-symlinks --exclude="*" '
2022
3323
  f'{includes} {base_dir_path} '
2023
- f's3://{self.name} '
3324
+ f's3://{self.name}{sub_path} '
2024
3325
  f'--endpoint {endpoint_url} '
2025
3326
  f'--profile={cloudflare.R2_PROFILE_NAME}')
2026
3327
  return sync_command
2027
3328
 
2028
3329
  def get_dir_sync_command(src_dir_path, dest_dir_name):
2029
3330
  # we exclude .git directory from the sync
2030
- excluded_list = storage_utils.get_excluded_files_from_gitignore(
2031
- src_dir_path)
3331
+ excluded_list = storage_utils.get_excluded_files(src_dir_path)
2032
3332
  excluded_list.append('.git/*')
2033
3333
  excludes = ' '.join([
2034
3334
  f'--exclude {shlex.quote(file_name)}'
@@ -2040,7 +3340,7 @@ class R2Store(AbstractStore):
2040
3340
  f'{cloudflare.R2_CREDENTIALS_PATH} '
2041
3341
  f'aws s3 sync --no-follow-symlinks {excludes} '
2042
3342
  f'{src_dir_path} '
2043
- f's3://{self.name}/{dest_dir_name} '
3343
+ f's3://{self.name}{sub_path}/{dest_dir_name} '
2044
3344
  f'--endpoint {endpoint_url} '
2045
3345
  f'--profile={cloudflare.R2_PROFILE_NAME}')
2046
3346
  return sync_command
@@ -2051,17 +3351,24 @@ class R2Store(AbstractStore):
2051
3351
  else:
2052
3352
  source_message = source_path_list[0]
2053
3353
 
3354
+ log_path = sky_logging.generate_tmp_logging_file_path(
3355
+ _STORAGE_LOG_FILE_NAME)
3356
+ sync_path = f'{source_message} -> r2://{self.name}{sub_path}/'
2054
3357
  with rich_utils.safe_status(
2055
- f'[bold cyan]Syncing '
2056
- f'[green]{source_message}[/] to [green]r2://{self.name}/[/]'):
3358
+ ux_utils.spinner_message(f'Syncing {sync_path}',
3359
+ log_path=log_path)):
2057
3360
  data_utils.parallel_upload(
2058
3361
  source_path_list,
2059
3362
  get_file_sync_command,
2060
3363
  get_dir_sync_command,
3364
+ log_path,
2061
3365
  self.name,
2062
3366
  self._ACCESS_DENIED_MESSAGE,
2063
3367
  create_dirs=create_dirs,
2064
3368
  max_concurrent_uploads=_MAX_CONCURRENT_UPLOADS)
3369
+ logger.info(
3370
+ ux_utils.finishing_message(f'Storage synced: {sync_path}',
3371
+ log_path))
2065
3372
 
2066
3373
  def _transfer_to_r2(self) -> None:
2067
3374
  assert isinstance(self.source, str), self.source
@@ -2164,11 +3471,9 @@ class R2Store(AbstractStore):
2164
3471
  endpoint_url = cloudflare.create_endpoint()
2165
3472
  r2_credential_path = cloudflare.R2_CREDENTIALS_PATH
2166
3473
  r2_profile_name = cloudflare.R2_PROFILE_NAME
2167
- mount_cmd = mounting_utils.get_r2_mount_cmd(r2_credential_path,
2168
- r2_profile_name,
2169
- endpoint_url,
2170
- self.bucket.name,
2171
- mount_path)
3474
+ mount_cmd = mounting_utils.get_r2_mount_cmd(
3475
+ r2_credential_path, r2_profile_name, endpoint_url, self.bucket.name,
3476
+ mount_path, self._bucket_sub_path)
2172
3477
  return mounting_utils.get_mounting_command(mount_path, install_cmd,
2173
3478
  mount_cmd)
2174
3479
 
@@ -2191,7 +3496,9 @@ class R2Store(AbstractStore):
2191
3496
  location = {'LocationConstraint': region}
2192
3497
  r2_client.create_bucket(Bucket=bucket_name,
2193
3498
  CreateBucketConfiguration=location)
2194
- logger.info(f'Created R2 bucket {bucket_name} in {region}')
3499
+ logger.info(f' {colorama.Style.DIM}Created R2 bucket '
3500
+ f'{bucket_name!r} in {region}'
3501
+ f'{colorama.Style.RESET_ALL}')
2195
3502
  except aws.botocore_exceptions().ClientError as e:
2196
3503
  with ux_utils.print_exception_no_traceback():
2197
3504
  raise exceptions.StorageBucketCreateError(
@@ -2199,6 +3506,43 @@ class R2Store(AbstractStore):
2199
3506
  f'{self.name} but failed.') from e
2200
3507
  return cloudflare.resource('s3').Bucket(bucket_name)
2201
3508
 
3509
+ def _execute_r2_remove_command(self, command: str, bucket_name: str,
3510
+ hint_operating: str,
3511
+ hint_failed: str) -> bool:
3512
+ try:
3513
+ with rich_utils.safe_status(
3514
+ ux_utils.spinner_message(hint_operating)):
3515
+ subprocess.check_output(command.split(' '),
3516
+ stderr=subprocess.STDOUT,
3517
+ shell=True)
3518
+ except subprocess.CalledProcessError as e:
3519
+ if 'NoSuchBucket' in e.output.decode('utf-8'):
3520
+ logger.debug(
3521
+ _BUCKET_EXTERNALLY_DELETED_DEBUG_MESSAGE.format(
3522
+ bucket_name=bucket_name))
3523
+ return False
3524
+ else:
3525
+ with ux_utils.print_exception_no_traceback():
3526
+ raise exceptions.StorageBucketDeleteError(
3527
+ f'{hint_failed}'
3528
+ f'Detailed error: {e.output}')
3529
+ return True
3530
+
3531
+ def _delete_r2_bucket_sub_path(self, bucket_name: str,
3532
+ sub_path: str) -> bool:
3533
+ """Deletes the sub path from the bucket."""
3534
+ endpoint_url = cloudflare.create_endpoint()
3535
+ remove_command = (
3536
+ f'AWS_SHARED_CREDENTIALS_FILE={cloudflare.R2_CREDENTIALS_PATH} '
3537
+ f'aws s3 rm s3://{bucket_name}/{sub_path}/ --recursive '
3538
+ f'--endpoint {endpoint_url} '
3539
+ f'--profile={cloudflare.R2_PROFILE_NAME}')
3540
+ return self._execute_r2_remove_command(
3541
+ remove_command, bucket_name,
3542
+ f'Removing objects from R2 bucket {bucket_name}/{sub_path}',
3543
+ f'Failed to remove objects from R2 bucket {bucket_name}/{sub_path}.'
3544
+ )
3545
+
2202
3546
  def _delete_r2_bucket(self, bucket_name: str) -> bool:
2203
3547
  """Deletes R2 bucket, including all objects in bucket
2204
3548
 
@@ -2207,6 +3551,9 @@ class R2Store(AbstractStore):
2207
3551
 
2208
3552
  Returns:
2209
3553
  bool; True if bucket was deleted, False if it was deleted externally.
3554
+
3555
+ Raises:
3556
+ StorageBucketDeleteError: If deleting the bucket fails.
2210
3557
  """
2211
3558
  # Deleting objects is very slow programatically
2212
3559
  # (i.e. bucket.objects.all().delete() is slow).
@@ -2214,30 +3561,19 @@ class R2Store(AbstractStore):
2214
3561
  # are slow, since AWS puts deletion markers.
2215
3562
  # https://stackoverflow.com/questions/49239351/why-is-it-so-much-slower-to-delete-objects-in-aws-s3-than-it-is-to-create-them
2216
3563
  # The fastest way to delete is to run `aws s3 rb --force`,
2217
- # which removes the bucket by force.
2218
- endpoint_url = cloudflare.create_endpoint()
2219
- remove_command = (
2220
- f'AWS_SHARED_CREDENTIALS_FILE={cloudflare.R2_CREDENTIALS_PATH} '
2221
- f'aws s3 rb s3://{bucket_name} --force '
2222
- f'--endpoint {endpoint_url} '
2223
- f'--profile={cloudflare.R2_PROFILE_NAME}')
2224
- try:
2225
- with rich_utils.safe_status(
2226
- f'[bold cyan]Deleting R2 bucket {bucket_name}[/]'):
2227
- subprocess.check_output(remove_command,
2228
- stderr=subprocess.STDOUT,
2229
- shell=True)
2230
- except subprocess.CalledProcessError as e:
2231
- if 'NoSuchBucket' in e.output.decode('utf-8'):
2232
- logger.debug(
2233
- _BUCKET_EXTERNALLY_DELETED_DEBUG_MESSAGE.format(
2234
- bucket_name=bucket_name))
2235
- return False
2236
- else:
2237
- logger.error(e.output)
2238
- with ux_utils.print_exception_no_traceback():
2239
- raise exceptions.StorageBucketDeleteError(
2240
- f'Failed to delete R2 bucket {bucket_name}.')
3564
+ # which removes the bucket by force.
3565
+ endpoint_url = cloudflare.create_endpoint()
3566
+ remove_command = (
3567
+ f'AWS_SHARED_CREDENTIALS_FILE={cloudflare.R2_CREDENTIALS_PATH} '
3568
+ f'aws s3 rb s3://{bucket_name} --force '
3569
+ f'--endpoint {endpoint_url} '
3570
+ f'--profile={cloudflare.R2_PROFILE_NAME}')
3571
+
3572
+ success = self._execute_r2_remove_command(
3573
+ remove_command, bucket_name, f'Deleting R2 bucket {bucket_name}',
3574
+ f'Failed to delete R2 bucket {bucket_name}.')
3575
+ if not success:
3576
+ return False
2241
3577
 
2242
3578
  # Wait until bucket deletion propagates on AWS servers
2243
3579
  while data_utils.verify_r2_bucket(bucket_name):
@@ -2256,11 +3592,12 @@ class IBMCosStore(AbstractStore):
2256
3592
  source: str,
2257
3593
  region: Optional[str] = 'us-east',
2258
3594
  is_sky_managed: Optional[bool] = None,
2259
- sync_on_reconstruction: bool = True):
3595
+ sync_on_reconstruction: bool = True,
3596
+ _bucket_sub_path: Optional[str] = None):
2260
3597
  self.client: 'storage.Client'
2261
3598
  self.bucket: 'StorageHandle'
2262
3599
  super().__init__(name, source, region, is_sky_managed,
2263
- sync_on_reconstruction)
3600
+ sync_on_reconstruction, _bucket_sub_path)
2264
3601
  self.bucket_rclone_profile = \
2265
3602
  Rclone.generate_rclone_bucket_profile_name(
2266
3603
  self.name, Rclone.RcloneClouds.IBM)
@@ -2281,6 +3618,16 @@ class IBMCosStore(AbstractStore):
2281
3618
  assert data_utils.verify_gcs_bucket(self.name), (
2282
3619
  f'Source specified as {self.source}, a GCS bucket. ',
2283
3620
  'GCS Bucket should exist.')
3621
+ elif data_utils.is_az_container_endpoint(self.source):
3622
+ storage_account_name, container_name, _ = (
3623
+ data_utils.split_az_path(self.source))
3624
+ assert self.name == container_name, (
3625
+ 'Azure bucket is specified as path, the name should be '
3626
+ 'the same as Azure bucket.')
3627
+ assert data_utils.verify_az_bucket(
3628
+ storage_account_name, self.name), (
3629
+ f'Source specified as {self.source}, an Azure bucket. '
3630
+ 'Azure bucket should exist.')
2284
3631
  elif self.source.startswith('r2://'):
2285
3632
  assert self.name == data_utils.split_r2_path(self.source)[0], (
2286
3633
  'R2 Bucket is specified as path, the name should be '
@@ -2296,7 +3643,7 @@ class IBMCosStore(AbstractStore):
2296
3643
  self.name = IBMCosStore.validate_name(self.name)
2297
3644
 
2298
3645
  @classmethod
2299
- def validate_name(cls, name) -> str:
3646
+ def validate_name(cls, name: str) -> str:
2300
3647
  """Validates the name of a COS bucket.
2301
3648
 
2302
3649
  Rules source: https://ibm.github.io/ibm-cos-sdk-java/com/ibm/cloud/objectstorage/services/s3/model/Bucket.html # pylint: disable=line-too-long
@@ -2395,10 +3742,22 @@ class IBMCosStore(AbstractStore):
2395
3742
  f'Upload failed for store {self.name}') from e
2396
3743
 
2397
3744
  def delete(self) -> None:
3745
+ if self._bucket_sub_path is not None and not self.is_sky_managed:
3746
+ return self._delete_sub_path()
3747
+
2398
3748
  self._delete_cos_bucket()
2399
3749
  logger.info(f'{colorama.Fore.GREEN}Deleted COS bucket {self.name}.'
2400
3750
  f'{colorama.Style.RESET_ALL}')
2401
3751
 
3752
+ def _delete_sub_path(self) -> None:
3753
+ assert self._bucket_sub_path is not None, 'bucket_sub_path is not set'
3754
+ bucket = self.s3_resource.Bucket(self.name)
3755
+ try:
3756
+ self._delete_cos_bucket_objects(bucket, self._bucket_sub_path + '/')
3757
+ except ibm.ibm_botocore.exceptions.ClientError as e:
3758
+ if e.__class__.__name__ == 'NoSuchBucket':
3759
+ logger.debug('bucket already removed')
3760
+
2402
3761
  def get_handle(self) -> StorageHandle:
2403
3762
  return self.s3_resource.Bucket(self.name)
2404
3763
 
@@ -2418,6 +3777,8 @@ class IBMCosStore(AbstractStore):
2418
3777
  set to True, the directory is created in the bucket root and
2419
3778
  contents are uploaded to it.
2420
3779
  """
3780
+ sub_path = (f'/{self._bucket_sub_path}'
3781
+ if self._bucket_sub_path else '')
2421
3782
 
2422
3783
  def get_dir_sync_command(src_dir_path, dest_dir_name) -> str:
2423
3784
  """returns an rclone command that copies a complete folder
@@ -2442,7 +3803,8 @@ class IBMCosStore(AbstractStore):
2442
3803
  sync_command = (
2443
3804
  'rclone copy --exclude ".git/*" '
2444
3805
  f'{src_dir_path} '
2445
- f'{self.bucket_rclone_profile}:{self.name}/{dest_dir_name}')
3806
+ f'{self.bucket_rclone_profile}:{self.name}{sub_path}'
3807
+ f'/{dest_dir_name}')
2446
3808
  return sync_command
2447
3809
 
2448
3810
  def get_file_sync_command(base_dir_path, file_names) -> str:
@@ -2468,9 +3830,10 @@ class IBMCosStore(AbstractStore):
2468
3830
  for file_name in file_names
2469
3831
  ])
2470
3832
  base_dir_path = shlex.quote(base_dir_path)
2471
- sync_command = ('rclone copy '
2472
- f'{includes} {base_dir_path} '
2473
- f'{self.bucket_rclone_profile}:{self.name}')
3833
+ sync_command = (
3834
+ 'rclone copy '
3835
+ f'{includes} {base_dir_path} '
3836
+ f'{self.bucket_rclone_profile}:{self.name}{sub_path}')
2474
3837
  return sync_command
2475
3838
 
2476
3839
  # Generate message for upload
@@ -2479,18 +3842,25 @@ class IBMCosStore(AbstractStore):
2479
3842
  else:
2480
3843
  source_message = source_path_list[0]
2481
3844
 
3845
+ log_path = sky_logging.generate_tmp_logging_file_path(
3846
+ _STORAGE_LOG_FILE_NAME)
3847
+ sync_path = (
3848
+ f'{source_message} -> cos://{self.region}/{self.name}{sub_path}/')
2482
3849
  with rich_utils.safe_status(
2483
- f'[bold cyan]Syncing '
2484
- f'[green]{source_message}[/] to '
2485
- f'[green]cos://{self.region}/{self.name}/[/]'):
3850
+ ux_utils.spinner_message(f'Syncing {sync_path}',
3851
+ log_path=log_path)):
2486
3852
  data_utils.parallel_upload(
2487
3853
  source_path_list,
2488
3854
  get_file_sync_command,
2489
3855
  get_dir_sync_command,
3856
+ log_path,
2490
3857
  self.name,
2491
3858
  self._ACCESS_DENIED_MESSAGE,
2492
3859
  create_dirs=create_dirs,
2493
3860
  max_concurrent_uploads=_MAX_CONCURRENT_UPLOADS)
3861
+ logger.info(
3862
+ ux_utils.finishing_message(f'Storage synced: {sync_path}',
3863
+ log_path))
2494
3864
 
2495
3865
  def _get_bucket(self) -> Tuple[StorageHandle, bool]:
2496
3866
  """returns IBM COS bucket object if exists, otherwise creates it.
@@ -2549,6 +3919,7 @@ class IBMCosStore(AbstractStore):
2549
3919
  Rclone.RcloneClouds.IBM,
2550
3920
  self.region, # type: ignore
2551
3921
  )
3922
+
2552
3923
  if not bucket_region and self.sync_on_reconstruction:
2553
3924
  # bucket doesn't exist
2554
3925
  return self._create_cos_bucket(self.name, self.region), True
@@ -2595,7 +3966,8 @@ class IBMCosStore(AbstractStore):
2595
3966
  Rclone.RCLONE_CONFIG_PATH,
2596
3967
  self.bucket_rclone_profile,
2597
3968
  self.bucket.name,
2598
- mount_path)
3969
+ mount_path,
3970
+ self._bucket_sub_path)
2599
3971
  return mounting_utils.get_mounting_command(mount_path, install_cmd,
2600
3972
  mount_cmd)
2601
3973
 
@@ -2616,8 +3988,10 @@ class IBMCosStore(AbstractStore):
2616
3988
  CreateBucketConfiguration={
2617
3989
  'LocationConstraint': f'{region}-smart'
2618
3990
  })
2619
- logger.info(f'Created IBM COS bucket {bucket_name} in {region} '
2620
- f'with storage class smart tier')
3991
+ logger.info(f' {colorama.Style.DIM}Created IBM COS bucket '
3992
+ f'{bucket_name!r} in {region} '
3993
+ 'with storage class smart tier'
3994
+ f'{colorama.Style.RESET_ALL}')
2621
3995
  self.bucket = self.s3_resource.Bucket(bucket_name)
2622
3996
 
2623
3997
  except ibm.ibm_botocore.exceptions.ClientError as e: # type: ignore[union-attr] # pylint: disable=line-too-long
@@ -2631,18 +4005,492 @@ class IBMCosStore(AbstractStore):
2631
4005
 
2632
4006
  return self.bucket
2633
4007
 
2634
- def _delete_cos_bucket(self):
2635
- bucket = self.s3_resource.Bucket(self.name)
2636
- try:
2637
- bucket_versioning = self.s3_resource.BucketVersioning(self.name)
2638
- if bucket_versioning.status == 'Enabled':
4008
+ def _delete_cos_bucket_objects(self,
4009
+ bucket: Any,
4010
+ prefix: Optional[str] = None) -> None:
4011
+ bucket_versioning = self.s3_resource.BucketVersioning(bucket.name)
4012
+ if bucket_versioning.status == 'Enabled':
4013
+ if prefix is not None:
4014
+ res = list(
4015
+ bucket.object_versions.filter(Prefix=prefix).delete())
4016
+ else:
2639
4017
  res = list(bucket.object_versions.delete())
4018
+ else:
4019
+ if prefix is not None:
4020
+ res = list(bucket.objects.filter(Prefix=prefix).delete())
2640
4021
  else:
2641
4022
  res = list(bucket.objects.delete())
2642
- logger.debug(f'Deleted bucket\'s content:\n{res}')
4023
+ logger.debug(f'Deleted bucket\'s content:\n{res}, prefix: {prefix}')
4024
+
4025
+ def _delete_cos_bucket(self) -> None:
4026
+ bucket = self.s3_resource.Bucket(self.name)
4027
+ try:
4028
+ self._delete_cos_bucket_objects(bucket)
2643
4029
  bucket.delete()
2644
4030
  bucket.wait_until_not_exists()
2645
4031
  except ibm.ibm_botocore.exceptions.ClientError as e:
2646
4032
  if e.__class__.__name__ == 'NoSuchBucket':
2647
4033
  logger.debug('bucket already removed')
2648
4034
  Rclone.delete_rclone_bucket_profile(self.name, Rclone.RcloneClouds.IBM)
4035
+
4036
+
4037
+ class OciStore(AbstractStore):
4038
+ """OciStore inherits from Storage Object and represents the backend
4039
+ for OCI buckets.
4040
+ """
4041
+
4042
+ _ACCESS_DENIED_MESSAGE = 'AccessDeniedException'
4043
+
4044
+ def __init__(self,
4045
+ name: str,
4046
+ source: Optional[SourceType],
4047
+ region: Optional[str] = None,
4048
+ is_sky_managed: Optional[bool] = None,
4049
+ sync_on_reconstruction: Optional[bool] = True,
4050
+ _bucket_sub_path: Optional[str] = None):
4051
+ self.client: Any
4052
+ self.bucket: StorageHandle
4053
+ self.oci_config_file: str
4054
+ self.config_profile: str
4055
+ self.compartment: str
4056
+ self.namespace: str
4057
+
4058
+ # Region is from the specified name in <bucket>@<region> format.
4059
+ # Another case is name can also be set by the source, for example:
4060
+ # /datasets-storage:
4061
+ # source: oci://RAGData@us-sanjose-1
4062
+ # The name in above mount will be set to RAGData@us-sanjose-1
4063
+ region_in_name = None
4064
+ if name is not None and '@' in name:
4065
+ self._validate_bucket_expr(name)
4066
+ name, region_in_name = name.split('@')
4067
+
4068
+ # Region is from the specified source in oci://<bucket>@<region> format
4069
+ region_in_source = None
4070
+ if isinstance(source,
4071
+ str) and source.startswith('oci://') and '@' in source:
4072
+ self._validate_bucket_expr(source)
4073
+ source, region_in_source = source.split('@')
4074
+
4075
+ if region_in_name is not None and region_in_source is not None:
4076
+ # This should never happen because name and source will never be
4077
+ # the remote bucket at the same time.
4078
+ assert region_in_name == region_in_source, (
4079
+ f'Mismatch region specified. Region in name {region_in_name}, '
4080
+ f'but region in source is {region_in_source}')
4081
+
4082
+ if region_in_name is not None:
4083
+ region = region_in_name
4084
+ elif region_in_source is not None:
4085
+ region = region_in_source
4086
+
4087
+ # Default region set to what specified in oci config.
4088
+ if region is None:
4089
+ region = oci.get_oci_config()['region']
4090
+
4091
+ # So far from now on, the name and source are canonical, means there
4092
+ # is no region (@<region> suffix) associated with them anymore.
4093
+
4094
+ super().__init__(name, source, region, is_sky_managed,
4095
+ sync_on_reconstruction, _bucket_sub_path)
4096
+ # TODO(zpoint): add _bucket_sub_path to the sync/mount/delete commands
4097
+
4098
+ def _validate_bucket_expr(self, bucket_expr: str):
4099
+ pattern = r'^(\w+://)?[A-Za-z0-9-._]+(@\w{2}-\w+-\d{1})$'
4100
+ if not re.match(pattern, bucket_expr):
4101
+ raise ValueError(
4102
+ 'The format for the bucket portion is <bucket>@<region> '
4103
+ 'when specify a region with a bucket.')
4104
+
4105
+ def _validate(self):
4106
+ if self.source is not None and isinstance(self.source, str):
4107
+ if self.source.startswith('oci://'):
4108
+ assert self.name == data_utils.split_oci_path(self.source)[0], (
4109
+ 'OCI Bucket is specified as path, the name should be '
4110
+ 'the same as OCI bucket.')
4111
+ elif not re.search(r'^\w+://', self.source):
4112
+ # Treat it as local path.
4113
+ pass
4114
+ else:
4115
+ raise NotImplementedError(
4116
+ f'Moving data from {self.source} to OCI is not supported.')
4117
+
4118
+ # Validate name
4119
+ self.name = self.validate_name(self.name)
4120
+ # Check if the storage is enabled
4121
+ if not _is_storage_cloud_enabled(str(clouds.OCI())):
4122
+ with ux_utils.print_exception_no_traceback():
4123
+ raise exceptions.ResourcesUnavailableError(
4124
+ 'Storage \'store: oci\' specified, but ' \
4125
+ 'OCI access is disabled. To fix, enable '\
4126
+ 'OCI by running `sky check`. '\
4127
+ 'More info: https://skypilot.readthedocs.io/en/latest/getting-started/installation.html.' # pylint: disable=line-too-long
4128
+ )
4129
+
4130
+ @classmethod
4131
+ def validate_name(cls, name) -> str:
4132
+ """Validates the name of the OCI store.
4133
+
4134
+ Source for rules: https://docs.oracle.com/en-us/iaas/Content/Object/Tasks/managingbuckets.htm#Managing_Buckets # pylint: disable=line-too-long
4135
+ """
4136
+
4137
+ def _raise_no_traceback_name_error(err_str):
4138
+ with ux_utils.print_exception_no_traceback():
4139
+ raise exceptions.StorageNameError(err_str)
4140
+
4141
+ if name is not None and isinstance(name, str):
4142
+ # Check for overall length
4143
+ if not 1 <= len(name) <= 256:
4144
+ _raise_no_traceback_name_error(
4145
+ f'Invalid store name: name {name} must contain 1-256 '
4146
+ 'characters.')
4147
+
4148
+ # Check for valid characters and start/end with a number or letter
4149
+ pattern = r'^[A-Za-z0-9-._]+$'
4150
+ if not re.match(pattern, name):
4151
+ _raise_no_traceback_name_error(
4152
+ f'Invalid store name: name {name} can only contain '
4153
+ 'upper or lower case letters, numeric characters, hyphens '
4154
+ '(-), underscores (_), and dots (.). Spaces are not '
4155
+ 'allowed. Names must start and end with a number or '
4156
+ 'letter.')
4157
+ else:
4158
+ _raise_no_traceback_name_error('Store name must be specified.')
4159
+ return name
4160
+
4161
+ def initialize(self):
4162
+ """Initializes the OCI store object on the cloud.
4163
+
4164
+ Initialization involves fetching bucket if exists, or creating it if
4165
+ it does not.
4166
+
4167
+ Raises:
4168
+ StorageBucketCreateError: If bucket creation fails
4169
+ StorageBucketGetError: If fetching existing bucket fails
4170
+ StorageInitError: If general initialization fails.
4171
+ """
4172
+ # pylint: disable=import-outside-toplevel
4173
+ from sky.clouds.utils import oci_utils
4174
+ from sky.provision.oci.query_utils import query_helper
4175
+
4176
+ self.oci_config_file = oci.get_config_file()
4177
+ self.config_profile = oci_utils.oci_config.get_profile()
4178
+
4179
+ ## pylint: disable=line-too-long
4180
+ # What's compartment? See thttps://docs.oracle.com/en/cloud/foundation/cloud_architecture/governance/compartments.html
4181
+ self.compartment = query_helper.find_compartment(self.region)
4182
+ self.client = oci.get_object_storage_client(region=self.region,
4183
+ profile=self.config_profile)
4184
+ self.namespace = self.client.get_namespace(
4185
+ compartment_id=oci.get_oci_config()['tenancy']).data
4186
+
4187
+ self.bucket, is_new_bucket = self._get_bucket()
4188
+ if self.is_sky_managed is None:
4189
+ # If is_sky_managed is not specified, then this is a new storage
4190
+ # object (i.e., did not exist in global_user_state) and we should
4191
+ # set the is_sky_managed property.
4192
+ # If is_sky_managed is specified, then we take no action.
4193
+ self.is_sky_managed = is_new_bucket
4194
+
4195
+ def upload(self):
4196
+ """Uploads source to store bucket.
4197
+
4198
+ Upload must be called by the Storage handler - it is not called on
4199
+ Store initialization.
4200
+
4201
+ Raises:
4202
+ StorageUploadError: if upload fails.
4203
+ """
4204
+ try:
4205
+ if isinstance(self.source, list):
4206
+ self.batch_oci_rsync(self.source, create_dirs=True)
4207
+ elif self.source is not None:
4208
+ if self.source.startswith('oci://'):
4209
+ pass
4210
+ else:
4211
+ self.batch_oci_rsync([self.source])
4212
+ except exceptions.StorageUploadError:
4213
+ raise
4214
+ except Exception as e:
4215
+ raise exceptions.StorageUploadError(
4216
+ f'Upload failed for store {self.name}') from e
4217
+
4218
+ def delete(self) -> None:
4219
+ deleted_by_skypilot = self._delete_oci_bucket(self.name)
4220
+ if deleted_by_skypilot:
4221
+ msg_str = f'Deleted OCI bucket {self.name}.'
4222
+ else:
4223
+ msg_str = (f'OCI bucket {self.name} may have been deleted '
4224
+ f'externally. Removing from local state.')
4225
+ logger.info(f'{colorama.Fore.GREEN}{msg_str}'
4226
+ f'{colorama.Style.RESET_ALL}')
4227
+
4228
+ def get_handle(self) -> StorageHandle:
4229
+ return self.client.get_bucket(namespace_name=self.namespace,
4230
+ bucket_name=self.name).data
4231
+
4232
+ def batch_oci_rsync(self,
4233
+ source_path_list: List[Path],
4234
+ create_dirs: bool = False) -> None:
4235
+ """Invokes oci sync to batch upload a list of local paths to Bucket
4236
+
4237
+ Use OCI bulk operation to batch process the file upload
4238
+
4239
+ Args:
4240
+ source_path_list: List of paths to local files or directories
4241
+ create_dirs: If the local_path is a directory and this is set to
4242
+ False, the contents of the directory are directly uploaded to
4243
+ root of the bucket. If the local_path is a directory and this is
4244
+ set to True, the directory is created in the bucket root and
4245
+ contents are uploaded to it.
4246
+ """
4247
+ sub_path = (f'{self._bucket_sub_path}/'
4248
+ if self._bucket_sub_path else '')
4249
+
4250
+ @oci.with_oci_env
4251
+ def get_file_sync_command(base_dir_path, file_names):
4252
+ includes = ' '.join(
4253
+ [f'--include "{file_name}"' for file_name in file_names])
4254
+ prefix_arg = ''
4255
+ if sub_path:
4256
+ prefix_arg = f'--object-prefix "{sub_path.strip("/")}"'
4257
+ sync_command = (
4258
+ 'oci os object bulk-upload --no-follow-symlinks --overwrite '
4259
+ f'--bucket-name {self.name} --namespace-name {self.namespace} '
4260
+ f'--region {self.region} --src-dir "{base_dir_path}" '
4261
+ f'{prefix_arg} '
4262
+ f'{includes}')
4263
+
4264
+ return sync_command
4265
+
4266
+ @oci.with_oci_env
4267
+ def get_dir_sync_command(src_dir_path, dest_dir_name):
4268
+ if dest_dir_name and not str(dest_dir_name).endswith('/'):
4269
+ dest_dir_name = f'{dest_dir_name}/'
4270
+
4271
+ excluded_list = storage_utils.get_excluded_files(src_dir_path)
4272
+ excluded_list.append('.git/*')
4273
+ excludes = ' '.join([
4274
+ f'--exclude {shlex.quote(file_name)}'
4275
+ for file_name in excluded_list
4276
+ ])
4277
+
4278
+ # we exclude .git directory from the sync
4279
+ sync_command = (
4280
+ 'oci os object bulk-upload --no-follow-symlinks --overwrite '
4281
+ f'--bucket-name {self.name} --namespace-name {self.namespace} '
4282
+ f'--region {self.region} '
4283
+ f'--object-prefix "{sub_path}{dest_dir_name}" '
4284
+ f'--src-dir "{src_dir_path}" {excludes}')
4285
+
4286
+ return sync_command
4287
+
4288
+ # Generate message for upload
4289
+ if len(source_path_list) > 1:
4290
+ source_message = f'{len(source_path_list)} paths'
4291
+ else:
4292
+ source_message = source_path_list[0]
4293
+
4294
+ log_path = sky_logging.generate_tmp_logging_file_path(
4295
+ _STORAGE_LOG_FILE_NAME)
4296
+ sync_path = f'{source_message} -> oci://{self.name}/{sub_path}'
4297
+ with rich_utils.safe_status(
4298
+ ux_utils.spinner_message(f'Syncing {sync_path}',
4299
+ log_path=log_path)):
4300
+ data_utils.parallel_upload(
4301
+ source_path_list=source_path_list,
4302
+ filesync_command_generator=get_file_sync_command,
4303
+ dirsync_command_generator=get_dir_sync_command,
4304
+ log_path=log_path,
4305
+ bucket_name=self.name,
4306
+ access_denied_message=self._ACCESS_DENIED_MESSAGE,
4307
+ create_dirs=create_dirs,
4308
+ max_concurrent_uploads=1)
4309
+
4310
+ logger.info(
4311
+ ux_utils.finishing_message(f'Storage synced: {sync_path}',
4312
+ log_path))
4313
+
4314
+ def _get_bucket(self) -> Tuple[StorageHandle, bool]:
4315
+ """Obtains the OCI bucket.
4316
+ If the bucket exists, this method will connect to the bucket.
4317
+
4318
+ If the bucket does not exist, there are three cases:
4319
+ 1) Raise an error if the bucket source starts with oci://
4320
+ 2) Return None if bucket has been externally deleted and
4321
+ sync_on_reconstruction is False
4322
+ 3) Create and return a new bucket otherwise
4323
+
4324
+ Return tuple (Bucket, Boolean): The first item is the bucket
4325
+ json payload from the OCI API call, the second item indicates
4326
+ if this is a new created bucket(True) or an existing bucket(False).
4327
+
4328
+ Raises:
4329
+ StorageBucketCreateError: If creating the bucket fails
4330
+ StorageBucketGetError: If fetching a bucket fails
4331
+ """
4332
+ try:
4333
+ get_bucket_response = self.client.get_bucket(
4334
+ namespace_name=self.namespace, bucket_name=self.name)
4335
+ bucket = get_bucket_response.data
4336
+ return bucket, False
4337
+ except oci.service_exception() as e:
4338
+ if e.status == 404: # Not Found
4339
+ if isinstance(self.source,
4340
+ str) and self.source.startswith('oci://'):
4341
+ with ux_utils.print_exception_no_traceback():
4342
+ raise exceptions.StorageBucketGetError(
4343
+ 'Attempted to connect to a non-existent bucket: '
4344
+ f'{self.source}') from e
4345
+ else:
4346
+ # If bucket cannot be found (i.e., does not exist), it is
4347
+ # to be created by Sky. However, creation is skipped if
4348
+ # Store object is being reconstructed for deletion.
4349
+ if self.sync_on_reconstruction:
4350
+ bucket = self._create_oci_bucket(self.name)
4351
+ return bucket, True
4352
+ else:
4353
+ return None, False
4354
+ elif e.status == 401: # Unauthorized
4355
+ # AccessDenied error for buckets that are private and not
4356
+ # owned by user.
4357
+ command = (
4358
+ f'oci os object list --namespace-name {self.namespace} '
4359
+ f'--bucket-name {self.name}')
4360
+ with ux_utils.print_exception_no_traceback():
4361
+ raise exceptions.StorageBucketGetError(
4362
+ _BUCKET_FAIL_TO_CONNECT_MESSAGE.format(name=self.name) +
4363
+ f' To debug, consider running `{command}`.') from e
4364
+ else:
4365
+ # Unknown / unexpected error happened. This might happen when
4366
+ # Object storage service itself functions not normal (e.g.
4367
+ # maintainance event causes internal server error or request
4368
+ # timeout, etc).
4369
+ with ux_utils.print_exception_no_traceback():
4370
+ raise exceptions.StorageBucketGetError(
4371
+ f'Failed to connect to OCI bucket {self.name}') from e
4372
+
4373
+ def mount_command(self, mount_path: str) -> str:
4374
+ """Returns the command to mount the bucket to the mount_path.
4375
+
4376
+ Uses Rclone to mount the bucket.
4377
+
4378
+ Args:
4379
+ mount_path: str; Path to mount the bucket to.
4380
+ """
4381
+ install_cmd = mounting_utils.get_rclone_install_cmd()
4382
+ mount_cmd = mounting_utils.get_oci_mount_cmd(
4383
+ mount_path=mount_path,
4384
+ store_name=self.name,
4385
+ region=str(self.region),
4386
+ namespace=self.namespace,
4387
+ compartment=self.bucket.compartment_id,
4388
+ config_file=self.oci_config_file,
4389
+ config_profile=self.config_profile)
4390
+ version_check_cmd = mounting_utils.get_rclone_version_check_cmd()
4391
+
4392
+ return mounting_utils.get_mounting_command(mount_path, install_cmd,
4393
+ mount_cmd, version_check_cmd)
4394
+
4395
+ def _download_file(self, remote_path: str, local_path: str) -> None:
4396
+ """Downloads file from remote to local on OCI bucket
4397
+
4398
+ Args:
4399
+ remote_path: str; Remote path on OCI bucket
4400
+ local_path: str; Local path on user's device
4401
+ """
4402
+ if remote_path.startswith(f'/{self.name}'):
4403
+ # If the remote path is /bucket_name, we need to
4404
+ # remove the leading /
4405
+ remote_path = remote_path.lstrip('/')
4406
+
4407
+ filename = os.path.basename(remote_path)
4408
+ if not local_path.endswith(filename):
4409
+ local_path = os.path.join(local_path, filename)
4410
+
4411
+ @oci.with_oci_env
4412
+ def get_file_download_command(remote_path, local_path):
4413
+ download_command = (f'oci os object get --bucket-name {self.name} '
4414
+ f'--namespace-name {self.namespace} '
4415
+ f'--region {self.region} --name {remote_path} '
4416
+ f'--file {local_path}')
4417
+
4418
+ return download_command
4419
+
4420
+ download_command = get_file_download_command(remote_path, local_path)
4421
+
4422
+ try:
4423
+ with rich_utils.safe_status(
4424
+ f'[bold cyan]Downloading: {remote_path} -> {local_path}[/]'
4425
+ ):
4426
+ subprocess.check_output(download_command,
4427
+ stderr=subprocess.STDOUT,
4428
+ shell=True)
4429
+ except subprocess.CalledProcessError as e:
4430
+ logger.error(f'Download failed: {remote_path} -> {local_path}.\n'
4431
+ f'Detail errors: {e.output}')
4432
+ with ux_utils.print_exception_no_traceback():
4433
+ raise exceptions.StorageBucketDeleteError(
4434
+ f'Failed download file {self.name}:{remote_path}.') from e
4435
+
4436
+ def _create_oci_bucket(self, bucket_name: str) -> StorageHandle:
4437
+ """Creates OCI bucket with specific name in specific region
4438
+
4439
+ Args:
4440
+ bucket_name: str; Name of bucket
4441
+ region: str; Region name, e.g. us-central1, us-west1
4442
+ """
4443
+ logger.debug(f'_create_oci_bucket: {bucket_name}')
4444
+ try:
4445
+ create_bucket_response = self.client.create_bucket(
4446
+ namespace_name=self.namespace,
4447
+ create_bucket_details=oci.oci.object_storage.models.
4448
+ CreateBucketDetails(
4449
+ name=bucket_name,
4450
+ compartment_id=self.compartment,
4451
+ ))
4452
+ bucket = create_bucket_response.data
4453
+ return bucket
4454
+ except oci.service_exception() as e:
4455
+ with ux_utils.print_exception_no_traceback():
4456
+ raise exceptions.StorageBucketCreateError(
4457
+ f'Failed to create OCI bucket: {self.name}') from e
4458
+
4459
+ def _delete_oci_bucket(self, bucket_name: str) -> bool:
4460
+ """Deletes OCI bucket, including all objects in bucket
4461
+
4462
+ Args:
4463
+ bucket_name: str; Name of bucket
4464
+
4465
+ Returns:
4466
+ bool; True if bucket was deleted, False if it was deleted externally.
4467
+ """
4468
+ logger.debug(f'_delete_oci_bucket: {bucket_name}')
4469
+
4470
+ @oci.with_oci_env
4471
+ def get_bucket_delete_command(bucket_name):
4472
+ remove_command = (f'oci os bucket delete --bucket-name '
4473
+ f'--region {self.region} '
4474
+ f'{bucket_name} --empty --force')
4475
+
4476
+ return remove_command
4477
+
4478
+ remove_command = get_bucket_delete_command(bucket_name)
4479
+
4480
+ try:
4481
+ with rich_utils.safe_status(
4482
+ f'[bold cyan]Deleting OCI bucket {bucket_name}[/]'):
4483
+ subprocess.check_output(remove_command.split(' '),
4484
+ stderr=subprocess.STDOUT)
4485
+ except subprocess.CalledProcessError as e:
4486
+ if 'BucketNotFound' in e.output.decode('utf-8'):
4487
+ logger.debug(
4488
+ _BUCKET_EXTERNALLY_DELETED_DEBUG_MESSAGE.format(
4489
+ bucket_name=bucket_name))
4490
+ return False
4491
+ else:
4492
+ logger.error(e.output)
4493
+ with ux_utils.print_exception_no_traceback():
4494
+ raise exceptions.StorageBucketDeleteError(
4495
+ f'Failed to delete OCI bucket {bucket_name}.')
4496
+ return True