| /* | |
| * 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. | |
| */ | |
| #include "paimon/fs/oss/oss_file_system.h" | |
| #include <chrono> | |
| #include <cstdint> | |
| #include <future> | |
| #include <map> | |
| #include <memory> | |
| #include <sstream> | |
| #include <string> | |
| #include <system_error> | |
| #include <utility> | |
| #include <vector> | |
| #include "alibabacloud/oss2/ClientConfiguration.h" | |
| #include "alibabacloud/oss2/OSSClient.h" | |
| #include "alibabacloud/oss2/credentials/CredentialsProvider.h" | |
| #include "alibabacloud/oss2/io/ByteWriter.h" | |
| #include "alibabacloud/oss2/transport/HttpTransport.h" | |
| #include "gtest/gtest.h" | |
| #include "paimon/fs/oss/oss_file_system_factory.h" | |
| #include "paimon/testing/utils/testharness.h" | |
| namespace paimon::oss { | |
| namespace { | |
| namespace oss2 = alibabacloud::oss2; | |
| class MockHttpTransport : public oss2::HttpTransport { | |
| public: | |
| oss2::ResponseResult send(std::unique_ptr<oss2::RequestMessage>& request, | |
| const oss2::RequestOptions& options) override { | |
| requests_.emplace_back(std::make_unique<oss2::RequestMessage>(*request)); | |
| if (responses_.empty()) { | |
| return oss2::TransportError{std::make_error_code(std::errc::no_message_available), "", | |
| ""}; | |
| } | |
| std::unique_ptr<oss2::ResponseMessage> response = std::move(responses_.front()); | |
| responses_.erase(responses_.begin()); | |
| if (response->statusCode / 100 == 2 && options.sinkFactory.has_value() && | |
| response->body != nullptr) { | |
| int64_t content_length = -1; | |
| auto content_length_header = response->headers.find("Content-Length"); | |
| if (content_length_header != response->headers.end()) { | |
| content_length = std::stoll(content_length_header->second); | |
| } | |
| std::shared_ptr<oss2::ByteWriter> sink = | |
| options.sinkFactory.value()(content_length, response->headers); | |
| std::ostringstream body; | |
| body << response->body->rdbuf(); | |
| const std::string data = body.str(); | |
| sink->write(reinterpret_cast<const uint8_t*>(data.data()), data.size()); | |
| response->body.reset(); | |
| } | |
| return response; | |
| } | |
| std::string getName() const override { | |
| return "MockHttpTransport"; | |
| } | |
| void AddResponse(int status_code, oss2::HeaderCollection headers, std::string body = "") { | |
| std::shared_ptr<std::iostream> response_body; | |
| if (!body.empty()) { | |
| response_body = std::make_shared<std::stringstream>(std::move(body)); | |
| } | |
| responses_.emplace_back(std::make_unique<oss2::ResponseMessage>(oss2::ResponseMessage{ | |
| status_code, "", std::move(headers), std::move(response_body), nullptr})); | |
| } | |
| std::vector<std::unique_ptr<oss2::ResponseMessage>> responses_; | |
| std::vector<std::unique_ptr<oss2::RequestMessage>> requests_; | |
| }; | |
| std::unique_ptr<OssFileSystem> CreateFileSystem( | |
| const std::shared_ptr<MockHttpTransport>& transport) { | |
| oss2::ClientConfiguration config = oss2::ClientConfiguration::loadDefault(); | |
| config.region = "cn-hangzhou"; | |
| config.credentialsProvider = | |
| std::make_shared<oss2::StaticCredentialsProvider>("access-key", "secret-key"); | |
| config.httpTransport = transport; | |
| return std::make_unique<OssFileSystem>("bucket", std::make_shared<oss2::OSSClient>(config), | |
| CreateDefaultExecutor()); | |
| } | |
| } // namespace | |
| TEST(OssFileSystemFactoryTest, TestOptionValidation) { | |
| OssFileSystemFactory factory; | |
| std::map<std::string, std::string> options; | |
| ASSERT_NOK(factory.Create("s3://bucket/key", options)); | |
| ASSERT_NOK(factory.Create("oss://bucket/key", options)); | |
| options[kOssAccessKeyIdOption] = "access-key"; | |
| options[kOssAccessKeySecretOption] = "secret-key"; | |
| options[kOssEndpointOption] = "oss-cn-hangzhou.aliyuncs.com"; | |
| options[kOssUsePathStyleOption] = "treu"; | |
| ASSERT_NOK(factory.Create("oss://bucket/key", options)); | |
| options[kOssUsePathStyleOption] = "false"; | |
| options[kOssSignatureVersionOption] = "v2"; | |
| ASSERT_NOK(factory.Create("oss://bucket/key", options)); | |
| options[kOssSignatureVersionOption] = "v4"; | |
| options[kOssExecutorThreadCountOption] = "0"; | |
| ASSERT_NOK(factory.Create("oss://bucket/key", options)); | |
| options[kOssExecutorThreadCountOption] = "4"; | |
| ASSERT_OK(factory.Create("oss://bucket/key", options)); | |
| options[kOssEndpointOption] = ""; | |
| options[kOssRegionOption] = "cn-hangzhou"; | |
| ASSERT_OK(factory.Create("oss://bucket/key", options)); | |
| } | |
| TEST(OssFileSystemFactoryTest, TestBucketOptionsOverrideGlobalOptions) { | |
| OssFileSystemFactory factory; | |
| std::map<std::string, std::string> options = { | |
| {kOssAccessKeyIdOption, ""}, | |
| {kOssAccessKeySecretOption, ""}, | |
| {kOssEndpointOption, ""}, | |
| {"fs.oss.bucket.bucket.accessKeyId", "access-key"}, | |
| {"fs.oss.bucket.bucket.accessKeySecret", "secret-key"}, | |
| {"fs.oss.bucket.bucket.endpoint", "oss-cn-hangzhou.aliyuncs.com"}, | |
| }; | |
| ASSERT_OK(factory.Create("oss://bucket/key", options)); | |
| } | |
| TEST(OssFileSystemFactoryTest, TestBucketOptionErrorReportsBucketKey) { | |
| OssFileSystemFactory factory; | |
| std::map<std::string, std::string> options = { | |
| {kOssAccessKeyIdOption, "access-key"}, | |
| {kOssAccessKeySecretOption, "secret-key"}, | |
| {kOssEndpointOption, "oss-cn-hangzhou.aliyuncs.com"}, | |
| {"fs.oss.bucket.bucket.accessKeyId", ""}, | |
| }; | |
| ASSERT_NOK_WITH_MSG(factory.Create("oss://bucket/key", options), | |
| "fs.oss.bucket.bucket.accessKeyId"); | |
| } | |
| TEST(OssFileSystemFactoryTest, TestEndpointRegionValidation) { | |
| OssFileSystemFactory factory; | |
| std::map<std::string, std::string> options = { | |
| {kOssAccessKeyIdOption, "access-key"}, | |
| {kOssAccessKeySecretOption, "secret-key"}, | |
| }; | |
| options[kOssEndpointOption] = "oss-cn-hangzhou.aliyuncs.com"; | |
| ASSERT_OK(factory.Create("oss://bucket/key", options)); | |
| options[kOssEndpointOption] = "oss-cn-hangzhou-internal.aliyuncs.com"; | |
| ASSERT_OK(factory.Create("oss://bucket/key", options)); | |
| options[kOssEndpointOption] = "oss-ap-southeast-1.aliyuncs.com:443"; | |
| ASSERT_OK(factory.Create("oss://bucket/key", options)); | |
| options[kOssEndpointOption] = "cn-hangzhou.oss.aliyuncs.com"; | |
| ASSERT_OK(factory.Create("oss://bucket/key", options)); | |
| options[kOssEndpointOption] = "oss-accelerate.aliyuncs.com"; | |
| ASSERT_NOK_WITH_MSG(factory.Create("oss://bucket/key", options), "OSS region must be"); | |
| options[kOssEndpointOption] = "oss-accelerate-overseas.aliyuncs.com"; | |
| ASSERT_NOK_WITH_MSG(factory.Create("oss://bucket/key", options), "OSS region must be"); | |
| options[kOssEndpointOption] = "oss.example.com"; | |
| ASSERT_NOK_WITH_MSG(factory.Create("oss://bucket/key", options), "OSS region must be"); | |
| options[kOssRegionOption] = "cn-hangzhou"; | |
| ASSERT_OK(factory.Create("oss://bucket/key", options)); | |
| options.erase(kOssRegionOption); | |
| options[kOssSignatureVersionOption] = "v1"; | |
| ASSERT_OK(factory.Create("oss://bucket/key", options)); | |
| } | |
| TEST(OssFileSystemTest, TestHeadObjectParsesMetadata) { | |
| std::shared_ptr<MockHttpTransport> transport = std::make_shared<MockHttpTransport>(); | |
| transport->AddResponse(200, {{"Content-Length", "3"}, | |
| {"Last-Modified", "not-a-timestamp"}, | |
| {"x-oss-request-id", "request-id"}}); | |
| std::unique_ptr<OssFileSystem> file_system = CreateFileSystem(transport); | |
| ASSERT_OK_AND_ASSIGN(FileStatus status, file_system->GetFileStatus("oss://bucket/key")); | |
| ASSERT_EQ(3, status.GetLen()); | |
| ASSERT_EQ(FileStatus::kUnknownModificationTime, status.GetModificationTime()); | |
| ASSERT_EQ(1U, transport->requests_.size()); | |
| ASSERT_EQ("HEAD", transport->requests_[0]->method); | |
| } | |
| TEST(OssFileSystemTest, TestHeadObjectNotFound) { | |
| std::shared_ptr<MockHttpTransport> transport = std::make_shared<MockHttpTransport>(); | |
| transport->AddResponse(404, {{"x-oss-request-id", "request-id"}}, | |
| "<Error><Code>NoSuchKey</Code><Message>missing</Message></Error>"); | |
| transport->AddResponse(200, {}, | |
| "<ListBucketResult><IsTruncated>false</IsTruncated></ListBucketResult>"); | |
| std::unique_ptr<OssFileSystem> file_system = CreateFileSystem(transport); | |
| Result<FileStatus> status = file_system->GetFileStatus("oss://bucket/missing"); | |
| ASSERT_TRUE(status.status().IsNotExist()) << status.status().ToString(); | |
| ASSERT_NOK_WITH_MSG(status, "does not exist"); | |
| } | |
| TEST(OssFileSystemTest, TestHeadObjectErrorMapping) { | |
| std::shared_ptr<MockHttpTransport> transport = std::make_shared<MockHttpTransport>(); | |
| transport->AddResponse(403, {{"x-oss-request-id", "request-id"}}, | |
| "<Error><Code>AccessDenied</Code><Message>denied</Message></Error>"); | |
| std::unique_ptr<OssFileSystem> file_system = CreateFileSystem(transport); | |
| Result<FileStatus> status = file_system->GetFileStatus("oss://bucket/key"); | |
| ASSERT_TRUE(status.status().IsIOError()) << status.status().ToString(); | |
| ASSERT_NOK_WITH_MSG(status, "code=AccessDenied, status=403"); | |
| } | |
| TEST(OssFileSystemTest, TestListObjects) { | |
| std::shared_ptr<MockHttpTransport> transport = std::make_shared<MockHttpTransport>(); | |
| transport->AddResponse(404, {{"x-oss-request-id", "request-id"}}, | |
| "<Error><Code>NoSuchKey</Code><Message>missing</Message></Error>"); | |
| transport->AddResponse(200, {{"x-oss-request-id", "request-id"}}, R"( | |
| <ListBucketResult> | |
| <IsTruncated>false</IsTruncated> | |
| <Contents> | |
| <Key>prefix/file</Key> | |
| <Size>3</Size> | |
| <LastModified>2024-01-01T00:00:00.000Z</LastModified> | |
| </Contents> | |
| <CommonPrefixes><Prefix>prefix/sub/</Prefix></CommonPrefixes> | |
| </ListBucketResult>)"); | |
| std::unique_ptr<OssFileSystem> file_system = CreateFileSystem(transport); | |
| std::vector<FileStatus> statuses; | |
| ASSERT_OK(file_system->ListFileStatus("oss://bucket/prefix", &statuses)); | |
| ASSERT_EQ(2U, statuses.size()); | |
| ASSERT_EQ(1704067200000, statuses[0].GetModificationTime()); | |
| ASSERT_EQ(2U, transport->requests_.size()); | |
| ASSERT_EQ("HEAD", transport->requests_[0]->method); | |
| ASSERT_EQ("GET", transport->requests_[1]->method); | |
| ASSERT_NE(std::string::npos, transport->requests_[1]->uri.find("list-type=2")); | |
| } | |
| TEST(OssFileSystemTest, TestListObjectsErrorMapping) { | |
| std::shared_ptr<MockHttpTransport> transport = std::make_shared<MockHttpTransport>(); | |
| transport->AddResponse(403, {{"x-oss-request-id", "request-id"}}, | |
| "<Error><Code>AccessDenied</Code><Message>denied</Message></Error>"); | |
| std::unique_ptr<OssFileSystem> file_system = CreateFileSystem(transport); | |
| std::vector<BasicFileStatus> statuses; | |
| Status status = file_system->ListDir("oss://bucket/prefix/", &statuses); | |
| ASSERT_TRUE(status.IsIOError()) << status.ToString(); | |
| ASSERT_NOK_WITH_MSG(status, "code=AccessDenied, status=403"); | |
| } | |
| TEST(OssFileSystemTest, TestGetObjectRangeAndShortRead) { | |
| std::shared_ptr<MockHttpTransport> transport = std::make_shared<MockHttpTransport>(); | |
| transport->AddResponse(206, {{"Content-Length", "3"}}, "abc"); | |
| std::unique_ptr<OssFileSystem> file_system = CreateFileSystem(transport); | |
| FileStatus file_status("oss://bucket/key", 3); | |
| ASSERT_OK_AND_ASSIGN(std::unique_ptr<InputStream> stream, file_system->Open(file_status)); | |
| char buffer[3]; | |
| ASSERT_OK_AND_ASSIGN(int64_t bytes_read, stream->Read(buffer, 3, 0)); | |
| ASSERT_EQ(3, bytes_read); | |
| ASSERT_EQ("abc", std::string(buffer, sizeof(buffer))); | |
| ASSERT_EQ(1U, transport->requests_.size()); | |
| ASSERT_EQ("bytes=0-2", transport->requests_[0]->headers.at("range")); | |
| std::shared_ptr<MockHttpTransport> short_transport = std::make_shared<MockHttpTransport>(); | |
| short_transport->AddResponse(206, {{"Content-Length", "2"}}, "ab"); | |
| std::unique_ptr<OssFileSystem> short_file_system = CreateFileSystem(short_transport); | |
| ASSERT_OK_AND_ASSIGN(std::unique_ptr<InputStream> short_stream, | |
| short_file_system->Open(file_status)); | |
| ASSERT_NOK_WITH_MSG(short_stream->Read(buffer, 3, 0), "expected 3"); | |
| } | |
| TEST(OssFileSystemTest, TestGetObjectRangeErrorMapping) { | |
| std::shared_ptr<MockHttpTransport> transport = std::make_shared<MockHttpTransport>(); | |
| transport->AddResponse(403, {{"x-oss-request-id", "request-id"}}, | |
| "<Error><Code>AccessDenied</Code><Message>denied</Message></Error>"); | |
| std::unique_ptr<OssFileSystem> file_system = CreateFileSystem(transport); | |
| FileStatus file_status("oss://bucket/key", 3); | |
| ASSERT_OK_AND_ASSIGN(std::unique_ptr<InputStream> stream, file_system->Open(file_status)); | |
| char buffer[3]; | |
| Result<int64_t> result = stream->Read(buffer, 3, 0); | |
| ASSERT_TRUE(result.status().IsIOError()) << result.status().ToString(); | |
| ASSERT_NOK_WITH_MSG(result, "code=AccessDenied, status=403"); | |
| } | |
| TEST(OssFileSystemTest, TestGetObjectRangeAsync) { | |
| std::shared_ptr<MockHttpTransport> transport = std::make_shared<MockHttpTransport>(); | |
| transport->AddResponse(206, {{"Content-Length", "3"}}, "abc"); | |
| std::unique_ptr<OssFileSystem> file_system = CreateFileSystem(transport); | |
| FileStatus file_status("oss://bucket/key", 3); | |
| ASSERT_OK_AND_ASSIGN(std::unique_ptr<InputStream> stream, file_system->Open(file_status)); | |
| char buffer[3]; | |
| std::promise<Status> promise; | |
| std::future<Status> future = promise.get_future(); | |
| stream->ReadAsync(buffer, 3, 0, | |
| [&promise](Status status) { promise.set_value(std::move(status)); }); | |
| ASSERT_EQ(std::future_status::ready, future.wait_for(std::chrono::seconds(5))); | |
| ASSERT_OK(future.get()); | |
| ASSERT_EQ("abc", std::string(buffer, sizeof(buffer))); | |
| ASSERT_EQ(1U, transport->requests_.size()); | |
| ASSERT_EQ("bytes=0-2", transport->requests_[0]->headers.at("range")); | |
| } | |
| } // namespace paimon::oss |