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.
- sky/__init__.py +64 -32
- sky/adaptors/aws.py +23 -6
- sky/adaptors/azure.py +432 -15
- sky/adaptors/cloudflare.py +5 -5
- sky/adaptors/common.py +19 -9
- sky/adaptors/do.py +20 -0
- sky/adaptors/gcp.py +3 -2
- sky/adaptors/kubernetes.py +122 -88
- sky/adaptors/nebius.py +100 -0
- sky/adaptors/oci.py +39 -1
- sky/adaptors/vast.py +29 -0
- sky/admin_policy.py +101 -0
- sky/authentication.py +117 -98
- sky/backends/backend.py +52 -20
- sky/backends/backend_utils.py +669 -557
- sky/backends/cloud_vm_ray_backend.py +1099 -808
- sky/backends/local_docker_backend.py +14 -8
- sky/backends/wheel_utils.py +38 -20
- sky/benchmark/benchmark_utils.py +22 -23
- sky/check.py +76 -27
- sky/cli.py +1586 -1139
- sky/client/__init__.py +1 -0
- sky/client/cli.py +5683 -0
- sky/client/common.py +345 -0
- sky/client/sdk.py +1765 -0
- sky/cloud_stores.py +283 -19
- sky/clouds/__init__.py +7 -2
- sky/clouds/aws.py +303 -112
- sky/clouds/azure.py +185 -179
- sky/clouds/cloud.py +115 -37
- sky/clouds/cudo.py +29 -22
- sky/clouds/do.py +313 -0
- sky/clouds/fluidstack.py +44 -54
- sky/clouds/gcp.py +206 -65
- sky/clouds/ibm.py +26 -21
- sky/clouds/kubernetes.py +345 -91
- sky/clouds/lambda_cloud.py +40 -29
- sky/clouds/nebius.py +297 -0
- sky/clouds/oci.py +129 -90
- sky/clouds/paperspace.py +22 -18
- sky/clouds/runpod.py +53 -34
- sky/clouds/scp.py +28 -24
- sky/clouds/service_catalog/__init__.py +19 -13
- sky/clouds/service_catalog/aws_catalog.py +29 -12
- sky/clouds/service_catalog/azure_catalog.py +33 -6
- sky/clouds/service_catalog/common.py +95 -75
- sky/clouds/service_catalog/constants.py +3 -3
- sky/clouds/service_catalog/cudo_catalog.py +13 -3
- sky/clouds/service_catalog/data_fetchers/fetch_aws.py +36 -21
- sky/clouds/service_catalog/data_fetchers/fetch_azure.py +31 -4
- sky/clouds/service_catalog/data_fetchers/fetch_cudo.py +8 -117
- sky/clouds/service_catalog/data_fetchers/fetch_fluidstack.py +197 -44
- sky/clouds/service_catalog/data_fetchers/fetch_gcp.py +224 -36
- sky/clouds/service_catalog/data_fetchers/fetch_lambda_cloud.py +44 -24
- sky/clouds/service_catalog/data_fetchers/fetch_vast.py +147 -0
- sky/clouds/service_catalog/data_fetchers/fetch_vsphere.py +1 -1
- sky/clouds/service_catalog/do_catalog.py +111 -0
- sky/clouds/service_catalog/fluidstack_catalog.py +2 -2
- sky/clouds/service_catalog/gcp_catalog.py +16 -2
- sky/clouds/service_catalog/ibm_catalog.py +2 -2
- sky/clouds/service_catalog/kubernetes_catalog.py +192 -70
- sky/clouds/service_catalog/lambda_catalog.py +8 -3
- sky/clouds/service_catalog/nebius_catalog.py +116 -0
- sky/clouds/service_catalog/oci_catalog.py +31 -4
- sky/clouds/service_catalog/paperspace_catalog.py +2 -2
- sky/clouds/service_catalog/runpod_catalog.py +2 -2
- sky/clouds/service_catalog/scp_catalog.py +2 -2
- sky/clouds/service_catalog/vast_catalog.py +104 -0
- sky/clouds/service_catalog/vsphere_catalog.py +2 -2
- sky/clouds/utils/aws_utils.py +65 -0
- sky/clouds/utils/azure_utils.py +91 -0
- sky/clouds/utils/gcp_utils.py +5 -9
- sky/clouds/utils/oci_utils.py +47 -5
- sky/clouds/utils/scp_utils.py +4 -3
- sky/clouds/vast.py +280 -0
- sky/clouds/vsphere.py +22 -18
- sky/core.py +361 -107
- sky/dag.py +41 -28
- sky/data/data_transfer.py +37 -0
- sky/data/data_utils.py +211 -32
- sky/data/mounting_utils.py +182 -30
- sky/data/storage.py +2118 -270
- sky/data/storage_utils.py +126 -5
- sky/exceptions.py +179 -8
- sky/execution.py +158 -85
- sky/global_user_state.py +150 -34
- sky/jobs/__init__.py +12 -10
- sky/jobs/client/__init__.py +0 -0
- sky/jobs/client/sdk.py +302 -0
- sky/jobs/constants.py +49 -11
- sky/jobs/controller.py +161 -99
- sky/jobs/dashboard/dashboard.py +171 -25
- sky/jobs/dashboard/templates/index.html +572 -60
- sky/jobs/recovery_strategy.py +157 -156
- sky/jobs/scheduler.py +307 -0
- sky/jobs/server/__init__.py +1 -0
- sky/jobs/server/core.py +598 -0
- sky/jobs/server/dashboard_utils.py +69 -0
- sky/jobs/server/server.py +190 -0
- sky/jobs/state.py +627 -122
- sky/jobs/utils.py +615 -206
- sky/models.py +27 -0
- sky/optimizer.py +142 -83
- sky/provision/__init__.py +20 -5
- sky/provision/aws/config.py +124 -42
- sky/provision/aws/instance.py +130 -53
- sky/provision/azure/__init__.py +7 -0
- sky/{skylet/providers → provision}/azure/azure-config-template.json +19 -7
- sky/provision/azure/config.py +220 -0
- sky/provision/azure/instance.py +1012 -37
- sky/provision/common.py +31 -3
- sky/provision/constants.py +25 -0
- sky/provision/cudo/__init__.py +2 -1
- sky/provision/cudo/cudo_utils.py +112 -0
- sky/provision/cudo/cudo_wrapper.py +37 -16
- sky/provision/cudo/instance.py +28 -12
- sky/provision/do/__init__.py +11 -0
- sky/provision/do/config.py +14 -0
- sky/provision/do/constants.py +10 -0
- sky/provision/do/instance.py +287 -0
- sky/provision/do/utils.py +301 -0
- sky/provision/docker_utils.py +82 -46
- sky/provision/fluidstack/fluidstack_utils.py +57 -125
- sky/provision/fluidstack/instance.py +15 -43
- sky/provision/gcp/config.py +19 -9
- sky/provision/gcp/constants.py +7 -1
- sky/provision/gcp/instance.py +55 -34
- sky/provision/gcp/instance_utils.py +339 -80
- sky/provision/gcp/mig_utils.py +210 -0
- sky/provision/instance_setup.py +172 -133
- sky/provision/kubernetes/__init__.py +1 -0
- sky/provision/kubernetes/config.py +104 -90
- sky/provision/kubernetes/constants.py +8 -0
- sky/provision/kubernetes/instance.py +680 -325
- sky/provision/kubernetes/manifests/smarter-device-manager-daemonset.yaml +3 -0
- sky/provision/kubernetes/network.py +54 -20
- sky/provision/kubernetes/network_utils.py +70 -21
- sky/provision/kubernetes/utils.py +1370 -251
- sky/provision/lambda_cloud/__init__.py +11 -0
- sky/provision/lambda_cloud/config.py +10 -0
- sky/provision/lambda_cloud/instance.py +265 -0
- sky/{clouds/utils → provision/lambda_cloud}/lambda_utils.py +24 -23
- sky/provision/logging.py +1 -1
- sky/provision/nebius/__init__.py +11 -0
- sky/provision/nebius/config.py +11 -0
- sky/provision/nebius/instance.py +285 -0
- sky/provision/nebius/utils.py +318 -0
- sky/provision/oci/__init__.py +15 -0
- sky/provision/oci/config.py +51 -0
- sky/provision/oci/instance.py +436 -0
- sky/provision/oci/query_utils.py +681 -0
- sky/provision/paperspace/constants.py +6 -0
- sky/provision/paperspace/instance.py +4 -3
- sky/provision/paperspace/utils.py +2 -0
- sky/provision/provisioner.py +207 -130
- sky/provision/runpod/__init__.py +1 -0
- sky/provision/runpod/api/__init__.py +3 -0
- sky/provision/runpod/api/commands.py +119 -0
- sky/provision/runpod/api/pods.py +142 -0
- sky/provision/runpod/instance.py +64 -8
- sky/provision/runpod/utils.py +239 -23
- sky/provision/vast/__init__.py +10 -0
- sky/provision/vast/config.py +11 -0
- sky/provision/vast/instance.py +247 -0
- sky/provision/vast/utils.py +162 -0
- sky/provision/vsphere/common/vim_utils.py +1 -1
- sky/provision/vsphere/instance.py +8 -18
- sky/provision/vsphere/vsphere_utils.py +1 -1
- sky/resources.py +247 -102
- sky/serve/__init__.py +9 -9
- sky/serve/autoscalers.py +361 -299
- sky/serve/client/__init__.py +0 -0
- sky/serve/client/sdk.py +366 -0
- sky/serve/constants.py +12 -3
- sky/serve/controller.py +106 -36
- sky/serve/load_balancer.py +63 -12
- sky/serve/load_balancing_policies.py +84 -2
- sky/serve/replica_managers.py +42 -34
- sky/serve/serve_state.py +62 -32
- sky/serve/serve_utils.py +271 -160
- sky/serve/server/__init__.py +0 -0
- sky/serve/{core.py → server/core.py} +271 -90
- sky/serve/server/server.py +112 -0
- sky/serve/service.py +52 -16
- sky/serve/service_spec.py +95 -32
- sky/server/__init__.py +1 -0
- sky/server/common.py +430 -0
- sky/server/constants.py +21 -0
- sky/server/html/log.html +174 -0
- sky/server/requests/__init__.py +0 -0
- sky/server/requests/executor.py +472 -0
- sky/server/requests/payloads.py +487 -0
- sky/server/requests/queues/__init__.py +0 -0
- sky/server/requests/queues/mp_queue.py +76 -0
- sky/server/requests/requests.py +567 -0
- sky/server/requests/serializers/__init__.py +0 -0
- sky/server/requests/serializers/decoders.py +192 -0
- sky/server/requests/serializers/encoders.py +166 -0
- sky/server/server.py +1106 -0
- sky/server/stream_utils.py +141 -0
- sky/setup_files/MANIFEST.in +2 -5
- sky/setup_files/dependencies.py +159 -0
- sky/setup_files/setup.py +14 -125
- sky/sky_logging.py +59 -14
- sky/skylet/autostop_lib.py +2 -2
- sky/skylet/constants.py +183 -50
- sky/skylet/events.py +22 -10
- sky/skylet/job_lib.py +403 -258
- sky/skylet/log_lib.py +111 -71
- sky/skylet/log_lib.pyi +6 -0
- sky/skylet/providers/command_runner.py +6 -8
- sky/skylet/providers/ibm/node_provider.py +2 -2
- sky/skylet/providers/scp/config.py +11 -3
- sky/skylet/providers/scp/node_provider.py +8 -8
- sky/skylet/skylet.py +3 -1
- sky/skylet/subprocess_daemon.py +69 -17
- sky/skypilot_config.py +119 -57
- sky/task.py +205 -64
- sky/templates/aws-ray.yml.j2 +37 -7
- sky/templates/azure-ray.yml.j2 +27 -82
- sky/templates/cudo-ray.yml.j2 +7 -3
- sky/templates/do-ray.yml.j2 +98 -0
- sky/templates/fluidstack-ray.yml.j2 +7 -4
- sky/templates/gcp-ray.yml.j2 +26 -6
- sky/templates/ibm-ray.yml.j2 +3 -2
- sky/templates/jobs-controller.yaml.j2 +46 -11
- sky/templates/kubernetes-ingress.yml.j2 +7 -0
- sky/templates/kubernetes-loadbalancer.yml.j2 +7 -0
- sky/templates/{kubernetes-port-forward-proxy-command.sh.j2 → kubernetes-port-forward-proxy-command.sh} +51 -7
- sky/templates/kubernetes-ray.yml.j2 +292 -25
- sky/templates/lambda-ray.yml.j2 +30 -40
- sky/templates/nebius-ray.yml.j2 +79 -0
- sky/templates/oci-ray.yml.j2 +18 -57
- sky/templates/paperspace-ray.yml.j2 +10 -6
- sky/templates/runpod-ray.yml.j2 +26 -4
- sky/templates/scp-ray.yml.j2 +3 -2
- sky/templates/sky-serve-controller.yaml.j2 +12 -1
- sky/templates/skypilot-server-kubernetes-proxy.sh +36 -0
- sky/templates/vast-ray.yml.j2 +70 -0
- sky/templates/vsphere-ray.yml.j2 +8 -3
- sky/templates/websocket_proxy.py +64 -0
- sky/usage/constants.py +10 -1
- sky/usage/usage_lib.py +130 -37
- sky/utils/accelerator_registry.py +35 -51
- sky/utils/admin_policy_utils.py +147 -0
- sky/utils/annotations.py +51 -0
- sky/utils/cli_utils/status_utils.py +81 -23
- sky/utils/cluster_utils.py +356 -0
- sky/utils/command_runner.py +452 -89
- sky/utils/command_runner.pyi +77 -3
- sky/utils/common.py +54 -0
- sky/utils/common_utils.py +319 -108
- sky/utils/config_utils.py +204 -0
- sky/utils/control_master_utils.py +48 -0
- sky/utils/controller_utils.py +548 -266
- sky/utils/dag_utils.py +93 -32
- sky/utils/db_utils.py +18 -4
- sky/utils/env_options.py +29 -7
- sky/utils/kubernetes/create_cluster.sh +8 -60
- sky/utils/kubernetes/deploy_remote_cluster.sh +243 -0
- sky/utils/kubernetes/exec_kubeconfig_converter.py +73 -0
- sky/utils/kubernetes/generate_kubeconfig.sh +336 -0
- sky/utils/kubernetes/gpu_labeler.py +4 -4
- sky/utils/kubernetes/k8s_gpu_labeler_job.yaml +4 -3
- sky/utils/kubernetes/kubernetes_deploy_utils.py +228 -0
- sky/utils/kubernetes/rsync_helper.sh +24 -0
- sky/utils/kubernetes/ssh_jump_lifecycle_manager.py +1 -1
- sky/utils/log_utils.py +240 -33
- sky/utils/message_utils.py +81 -0
- sky/utils/registry.py +127 -0
- sky/utils/resources_utils.py +94 -22
- sky/utils/rich_utils.py +247 -18
- sky/utils/schemas.py +284 -64
- sky/{status_lib.py → utils/status_lib.py} +12 -7
- sky/utils/subprocess_utils.py +212 -46
- sky/utils/timeline.py +12 -7
- sky/utils/ux_utils.py +168 -15
- skypilot_nightly-1.0.0.dev2025022801.dist-info/METADATA +363 -0
- skypilot_nightly-1.0.0.dev2025022801.dist-info/RECORD +352 -0
- {skypilot_nightly-1.0.0.dev2024053101.dist-info → skypilot_nightly-1.0.0.dev2025022801.dist-info}/WHEEL +1 -1
- sky/clouds/cloud_registry.py +0 -31
- sky/jobs/core.py +0 -330
- sky/skylet/providers/azure/__init__.py +0 -2
- sky/skylet/providers/azure/azure-vm-template.json +0 -301
- sky/skylet/providers/azure/config.py +0 -170
- sky/skylet/providers/azure/node_provider.py +0 -466
- sky/skylet/providers/lambda_cloud/__init__.py +0 -2
- sky/skylet/providers/lambda_cloud/node_provider.py +0 -320
- sky/skylet/providers/oci/__init__.py +0 -2
- sky/skylet/providers/oci/node_provider.py +0 -488
- sky/skylet/providers/oci/query_helper.py +0 -383
- sky/skylet/providers/oci/utils.py +0 -21
- sky/utils/cluster_yaml_utils.py +0 -24
- sky/utils/kubernetes/generate_static_kubeconfig.sh +0 -137
- skypilot_nightly-1.0.0.dev2024053101.dist-info/METADATA +0 -315
- skypilot_nightly-1.0.0.dev2024053101.dist-info/RECORD +0 -275
- {skypilot_nightly-1.0.0.dev2024053101.dist-info → skypilot_nightly-1.0.0.dev2025022801.dist-info}/LICENSE +0 -0
- {skypilot_nightly-1.0.0.dev2024053101.dist-info → skypilot_nightly-1.0.0.dev2025022801.dist-info}/entry_points.txt +0 -0
- {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
|
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.
|
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
|
-
|
124
|
-
|
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.
|
158
|
-
|
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(
|
245
|
-
|
246
|
-
|
247
|
-
|
248
|
-
|
249
|
-
|
250
|
-
|
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
|
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: {
|
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__(
|
428
|
-
|
429
|
-
|
430
|
-
|
431
|
-
|
432
|
-
|
433
|
-
|
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
|
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
|
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
|
-
|
485
|
-
|
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
|
-
#
|
488
|
-
|
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
|
-
|
514
|
-
|
515
|
-
|
516
|
-
|
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
|
-
|
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 (
|
621
|
-
|
622
|
-
|
623
|
-
|
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
|
763
|
-
#
|
764
|
-
except exceptions.StorageExternalDeletionError:
|
765
|
-
|
766
|
-
|
767
|
-
|
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
|
817
|
-
|
818
|
-
|
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
|
-
|
847
|
-
|
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 =
|
906
|
-
|
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
|
-
|
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
|
-
|
990
|
-
|
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,
|
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
|
-
|
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] =
|
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.
|
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.
|
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'
|
1254
|
-
|
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=
|
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
|
-
|
1372
|
-
|
1373
|
-
|
1374
|
-
|
1375
|
-
|
1376
|
-
|
1377
|
-
|
1378
|
-
|
1379
|
-
|
1380
|
-
|
1381
|
-
|
1382
|
-
|
1383
|
-
|
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
|
-
|
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
|
-
|
1409
|
-
|
1410
|
-
|
1411
|
-
|
1412
|
-
|
1413
|
-
|
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
|
1452
|
-
|
1453
|
-
|
1454
|
-
|
1455
|
-
|
1456
|
-
|
1457
|
-
|
1458
|
-
|
1459
|
-
|
1460
|
-
|
1461
|
-
|
1462
|
-
|
1463
|
-
|
1464
|
-
)
|
1465
|
-
|
1466
|
-
|
1467
|
-
|
1468
|
-
|
1469
|
-
|
1470
|
-
|
1471
|
-
|
1472
|
-
|
1473
|
-
|
1474
|
-
|
1475
|
-
|
1476
|
-
|
1477
|
-
|
1478
|
-
)
|
1479
|
-
|
1480
|
-
|
1481
|
-
|
1482
|
-
|
1483
|
-
|
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.
|
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'
|
1649
|
-
|
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.
|
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'
|
1705
|
-
|
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
|
1828
|
-
f'with 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(
|
1832
|
-
|
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
|
-
|
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 = (
|
1861
|
-
|
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
|
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
|
1875
|
-
"""
|
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
|
-
|
2338
|
+
storage_account_name: str = '',
|
2339
|
+
region: Optional[str] = 'eastus',
|
1885
2340
|
is_sky_managed: Optional[bool] = None,
|
1886
|
-
sync_on_reconstruction:
|
1887
|
-
|
1888
|
-
self.
|
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
|
-
'
|
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 =
|
2437
|
+
self.name = self.validate_name(self.name)
|
2438
|
+
|
1921
2439
|
# Check if the storage is enabled
|
1922
|
-
if not _is_storage_cloud_enabled(
|
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
|
1926
|
-
'
|
1927
|
-
'
|
1928
|
-
'
|
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
|
2495
|
+
"""Initializes the AZ Container object on the cloud.
|
1933
2496
|
|
1934
|
-
Initialization involves fetching
|
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
|
-
|
1939
|
-
|
1940
|
-
|
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.
|
1943
|
-
self.
|
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
|
1952
|
-
|
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
|
-
|
1955
|
-
|
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
|
-
|
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.
|
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'
|
2056
|
-
|
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(
|
2168
|
-
|
2169
|
-
|
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
|
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
|
-
|
2225
|
-
|
2226
|
-
|
2227
|
-
|
2228
|
-
|
2229
|
-
|
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}
|
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 = (
|
2472
|
-
|
2473
|
-
|
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'
|
2484
|
-
|
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
|
2620
|
-
f'
|
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
|
2635
|
-
|
2636
|
-
|
2637
|
-
|
2638
|
-
|
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
|
-
|
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
|