blob: 1dd0b40dc564fa5ef71cea29f3f88d34cdd71a79 [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""OSS-specific operations, reusing the existing Arrow/Jindo FileIO."""
import re
from urllib.parse import urlparse
from pypaimon.common.options.config import OssOptions
from pypaimon.filesystem.pyarrow_file_io import PyArrowFileIO
class OssFileIO(PyArrowFileIO):
"""Override atomic metadata creation; inherit all ordinary file operations."""
# Intentionally no lock: stable bucket configuration makes duplicate first
# queries acceptable. Without a lock, no additional __getstate__/__setstate__
# hooks are needed; the parent's pickle support already handles this None/bool
# cache, preserving its value under the same stability assumption.
_atomic_write_supported = None
def try_to_write_atomic(self, path: str, content: str) -> bool:
uri = urlparse(path)
if uri.scheme:
if uri.scheme != 'oss' or self._extract_oss_bucket(path) != self._oss_bucket:
raise ValueError("Atomic write must target the configured OSS bucket")
key = self._extract_oss_object_key(path)
path = 'oss://{}/{}'.format(self._oss_bucket, key)
else:
key = path
if not self._use_jindo and not self._oss_bucket_in_endpoint:
bucket, _, key = path.partition('/')
if bucket != self._oss_bucket:
raise ValueError("Atomic write must target the configured OSS bucket")
if not key or key == '.' or key.endswith('/'):
return False
try:
import oss2
except ImportError as error:
raise ImportError(
"OSS atomic writes require oss2. Install pypaimon[oss] or pypaimon[jindo]."
) from error
session = oss2.Session()
try:
bucket = self._create_oss_bucket(session)
headers = self._sse_headers()
headers['x-oss-forbid-overwrite'] = 'true'
if not self._supports_atomic_write(bucket):
return super().try_to_write_atomic(path, content)
try:
bucket.put_object(key, content.encode('utf-8'), headers=headers)
return True
except oss2.exceptions.ServerError as error:
if error.code == 'FileAlreadyExists':
return False
raise
except oss2.exceptions.OssError as error:
raise OSError("Failed to atomically write oss://{}/{}".format(self._oss_bucket, key)) from error
finally:
session.session.close()
def create_blob_presigned_url(self, table_root, descriptor, validity) -> str:
try:
import oss2
except ImportError as error:
raise ImportError(
"OSS BLOB presigning requires oss2. Install pypaimon[oss] "
"or pypaimon[jindo]."
) from error
from pypaimon.filesystem.oss_blob_presigner import create_presigned_url
session = oss2.Session()
try:
bucket = self._create_oss_bucket(
session, self._extract_oss_bucket(descriptor.uri))
return create_presigned_url(
bucket,
table_root,
descriptor,
validity,
sse_headers=self._sse_headers(),
has_security_token=bool(
self.properties.get(OssOptions.OSS_SECURITY_TOKEN)),
)
finally:
session.session.close()
def _supports_atomic_write(self, bucket):
"""Cache the publication mode for this instance; failed queries remain retryable."""
import oss2
# Intentionally lock-free: duplicate first queries are acceptable with stable bucket configuration.
if self._atomic_write_supported is None:
try:
versioning = bucket.get_bucket_versioning().status
except oss2.exceptions.ServerError as error:
if error.status != 403 or error.code != 'AccessDenied':
raise
versioning = 'unknown (GetBucketVersioning denied)'
self._atomic_write_supported = versioning is None
if not self._atomic_write_supported:
self.logger.warning(
"Using legacy temporary-file-and-rename writes for OSS bucket %s "
"(versioning: %s). Concurrent commits are not protected against overwrites.",
self._oss_bucket, versioning)
return self._atomic_write_supported
@staticmethod
def _extract_oss_object_key(location) -> str:
"""Return an OSS object key for standard and credential URI forms."""
uri = urlparse(location)
if uri.scheme and uri.scheme.lower() != 'oss':
raise ValueError("Not an OSS URI: {}".format(location))
key = re.sub(r'/+', '/', uri.path).lstrip('/')
netloc = uri.netloc or ''
if ((getattr(uri, 'username', None)
or getattr(uri, 'password', None))
or '@' in netloc):
# Legacy URI: oss://AK:SK@endpoint/bucket/object-key
key = key.partition('/')[2]
return key
def _create_oss_bucket(self, session, bucket_name=None):
"""Build the metadata client with one V4 credential path for both AK and STS."""
import oss2
from oss2.credentials import StaticCredentialsProvider
access_key = self.properties.get(OssOptions.OSS_ACCESS_KEY_ID)
secret_key = self.properties.get(OssOptions.OSS_ACCESS_KEY_SECRET)
token = self.properties.get(OssOptions.OSS_SECURITY_TOKEN)
endpoint = self.properties.get(OssOptions.OSS_ENDPOINT)
if not access_key or not secret_key or not endpoint:
raise ValueError(
"OSS atomic writes require fs.oss.accessKeyId, fs.oss.accessKeySecret "
"and fs.oss.endpoint; pass fs.oss.securityToken for STS credentials.")
if '://' not in endpoint:
endpoint = 'https://' + endpoint
region = (self.properties.get(OssOptions.OSS_REGION) or '').strip()
if not region:
match = re.fullmatch(
r'oss-(?!accelerate(?:[.-]))([a-z0-9-]+?)(?:-internal)?\.aliyuncs\.com',
urlparse(endpoint).hostname or '')
region = match.group(1) if match else None
if not region:
raise ValueError("Set fs.oss.region for OSS V4 signing when the endpoint is not regional")
provider = StaticCredentialsProvider(access_key, secret_key, token)
auth = oss2.ProviderAuthV4(provider)
return oss2.Bucket(
auth,
endpoint,
bucket_name or self._oss_bucket,
session=session,
region=region,
)
def _sse_headers(self):
"""Match Java OSSFileIO's SSE resolution, including the native option fallback."""
settings = (OssOptions.OSS_SSE_METHOD, OssOptions.OSS_SSE_KMS_KEY_ID,
OssOptions.OSS_SSE_DATA_ENCRYPTION)
values = [self.properties.get(setting) for setting in settings]
for setting, value in zip(settings, values):
if value is not None and not value.strip():
raise ValueError("'{}' is set but blank".format(setting.key()))
method, key_id, data_encryption = [value.strip() if value is not None else None for value in values]
if method is None and key_id is None and data_encryption is None:
# Java forwards a nonempty native value as-is; the OSS service validates it.
algorithm = self.properties.get(OssOptions.OSS_SSE_ALGORITHM)
return {'x-oss-server-side-encryption': algorithm} if algorithm else {}
method = (method or 'KMS').upper()
if method not in ('AES256', 'KMS', 'SM4'):
raise ValueError("'{}' must be one of AES256/KMS/SM4".format(OssOptions.OSS_SSE_METHOD.key()))
headers = {'x-oss-server-side-encryption': method}
if key_id is not None:
if any(character.isspace() for character in key_id):
raise ValueError("'{}' must not contain whitespace".format(OssOptions.OSS_SSE_KMS_KEY_ID.key()))
if method != 'KMS':
raise ValueError("'{}' requires KMS".format(OssOptions.OSS_SSE_KMS_KEY_ID.key()))
headers['x-oss-server-side-encryption-key-id'] = key_id
if data_encryption is not None:
if method != 'KMS':
raise ValueError("'{}' requires KMS".format(OssOptions.OSS_SSE_DATA_ENCRYPTION.key()))
if data_encryption.upper() != 'SM4':
raise ValueError("'{}' only supports SM4".format(OssOptions.OSS_SSE_DATA_ENCRYPTION.key()))
headers['x-oss-server-side-data-encryption'] = 'SM4'
return headers