Skip to content

Commit 75bef3f

Browse files
feat(rest): support vended storage credentials (#719)
Support REST vended storage credentials Parse storage credentials from REST load table responses and pass them to FileIO implementations that support credential routing. Also align table FileIO creation with table-level config overrides, route S3 requests by the longest matching credential prefix, and clean up related tests. --------- Co-authored-by: Gang Wu <ustcwg@gmail.com>
1 parent 90a2be7 commit 75bef3f

16 files changed

Lines changed: 680 additions & 134 deletions

src/iceberg/arrow/s3/arrow_s3_file_io.cc

Lines changed: 174 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,13 @@
1818
*/
1919

2020
#include <cstdlib>
21+
#include <memory>
2122
#include <optional>
2223
#include <string>
2324
#include <string_view>
25+
#include <unordered_map>
26+
#include <utility>
27+
#include <vector>
2428

2529
#include <arrow/filesystem/filesystem.h>
2630
#if ICEBERG_S3_ENABLED
@@ -36,9 +40,10 @@
3640

3741
namespace iceberg::arrow {
3842

43+
#if ICEBERG_S3_ENABLED
44+
3945
namespace {
4046

41-
#if ICEBERG_S3_ENABLED
4247
const std::string* FindProperty(
4348
const std::unordered_map<std::string, std::string>& properties,
4449
std::string_view key) {
@@ -74,6 +79,24 @@ Status EnsureS3Initialized() {
7479
return {};
7580
}
7681

82+
// Splits any URI scheme off `endpoint` into `options.scheme`, returning the bare
83+
// host[:port] that Arrow's `endpoint_override` expects.
84+
std::string SplitEndpointScheme(std::string_view endpoint,
85+
::arrow::fs::S3Options& options) {
86+
if (const auto pos = endpoint.find("://"); pos != std::string_view::npos) {
87+
options.scheme = std::string(endpoint.substr(0, pos));
88+
endpoint = endpoint.substr(pos + 3);
89+
}
90+
return std::string(endpoint);
91+
}
92+
93+
bool IsS3FileIOCredentialPrefix(std::string_view prefix) {
94+
return prefix == "s3" || prefix.starts_with("s3://") || prefix.starts_with("s3a://") ||
95+
prefix.starts_with("s3n://");
96+
}
97+
98+
} // namespace
99+
77100
/// \brief Configure S3Options from a properties map.
78101
///
79102
/// \param properties The configuration properties map.
@@ -100,26 +123,21 @@ Result<::arrow::fs::S3Options> ConfigureS3Options(
100123
}
101124

102125
// Configure region
103-
if (const auto* region = FindProperty(properties, S3Properties::kRegion);
126+
if (const auto* region = FindProperty(properties, S3Properties::kClientRegion);
104127
region != nullptr) {
105128
options.region = *region;
106129
}
107130

108131
// Configure endpoint (for MinIO, LocalStack, etc.)
109132
if (const auto* endpoint = FindProperty(properties, S3Properties::kEndpoint);
110133
endpoint != nullptr) {
111-
options.endpoint_override = *endpoint;
112-
} else {
113-
// Fall back to AWS standard environment variables for endpoint override
114-
const char* s3_endpoint_env = std::getenv("AWS_ENDPOINT_URL_S3");
115-
if (s3_endpoint_env != nullptr) {
116-
options.endpoint_override = s3_endpoint_env;
117-
} else {
118-
const char* endpoint_env = std::getenv("AWS_ENDPOINT_URL");
119-
if (endpoint_env != nullptr) {
120-
options.endpoint_override = endpoint_env;
121-
}
122-
}
134+
options.endpoint_override = SplitEndpointScheme(*endpoint, options);
135+
} else if (const char* s3_endpoint_env = std::getenv("AWS_ENDPOINT_URL_S3");
136+
s3_endpoint_env != nullptr) {
137+
options.endpoint_override = SplitEndpointScheme(s3_endpoint_env, options);
138+
} else if (const char* endpoint_env = std::getenv("AWS_ENDPOINT_URL");
139+
endpoint_env != nullptr) {
140+
options.endpoint_override = SplitEndpointScheme(endpoint_env, options);
123141
}
124142

125143
ICEBERG_ASSIGN_OR_RAISE(const auto path_style_access,
@@ -128,11 +146,11 @@ Result<::arrow::fs::S3Options> ConfigureS3Options(
128146
options.force_virtual_addressing = !*path_style_access;
129147
}
130148

131-
// Configure SSL
149+
// Explicit `s3.ssl.enabled` overrides any endpoint-derived scheme.
132150
ICEBERG_ASSIGN_OR_RAISE(const auto ssl_enabled,
133151
ParseOptionalBool(properties, S3Properties::kSslEnabled));
134-
if (ssl_enabled.has_value() && !*ssl_enabled) {
135-
options.scheme = "http";
152+
if (ssl_enabled.has_value()) {
153+
options.scheme = *ssl_enabled ? "https" : "http";
136154
}
137155

138156
// Configure timeouts
@@ -152,33 +170,160 @@ Result<::arrow::fs::S3Options> ConfigureS3Options(
152170

153171
return options;
154172
}
155-
#endif
156173

157-
} // namespace
174+
namespace {
158175

159-
Result<std::unique_ptr<FileIO>> MakeS3FileIO(
176+
Result<std::shared_ptr<::arrow::fs::FileSystem>> BuildArrowS3FileSystem(
160177
const std::unordered_map<std::string, std::string>& properties) {
161-
#if ICEBERG_S3_ENABLED
162178
ICEBERG_RETURN_UNEXPECTED(EnsureS3Initialized());
163-
164-
// Configure S3 options from properties (uses default credentials if empty)
165179
ICEBERG_ASSIGN_OR_RAISE(auto options, ConfigureS3Options(properties));
166180
ICEBERG_ARROW_ASSIGN_OR_RETURN(auto fs, ::arrow::fs::S3FileSystem::Make(options));
181+
return std::shared_ptr<::arrow::fs::FileSystem>(std::move(fs));
182+
}
167183

168-
return std::make_unique<ArrowFileSystemFileIO>(std::move(fs));
169-
#else
170-
return NotSupported("Arrow S3 support is not enabled");
171-
#endif
184+
std::string CanonicalizeS3Scheme(std::string_view location) {
185+
for (std::string_view scheme : {"s3a://", "s3n://", "oss://"}) {
186+
if (location.starts_with(scheme)) {
187+
return std::string("s3://").append(location.substr(scheme.size()));
188+
}
189+
}
190+
return std::string(location);
191+
}
192+
193+
class ArrowS3FileIO final : public FileIO, public SupportsStorageCredentials {
194+
public:
195+
ArrowS3FileIO(std::shared_ptr<::arrow::fs::FileSystem> arrow_fs,
196+
std::unordered_map<std::string, std::string> default_properties)
197+
: default_file_io_(std::move(arrow_fs)),
198+
default_properties_(std::move(default_properties)) {}
199+
200+
Result<std::unique_ptr<InputFile>> NewInputFile(std::string file_location) override;
201+
202+
Result<std::unique_ptr<InputFile>> NewInputFile(std::string file_location,
203+
size_t length) override;
204+
205+
Result<std::unique_ptr<OutputFile>> NewOutputFile(std::string file_location) override;
206+
207+
Status DeleteFile(const std::string& file_location) override;
208+
209+
Status DeleteFiles(const std::vector<std::string>& file_locations) override;
210+
211+
Status SetStorageCredentials(
212+
const std::vector<StorageCredential>& storage_credentials) override;
213+
214+
const std::vector<StorageCredential>& credentials() const override {
215+
return storage_credentials_;
216+
}
217+
218+
SupportsStorageCredentials* AsSupportsStorageCredentials() override { return this; }
219+
220+
private:
221+
ArrowFileSystemFileIO& FileIOForPath(std::string_view location);
222+
223+
ArrowFileSystemFileIO default_file_io_;
224+
std::unordered_map<std::string, std::string> default_properties_;
225+
std::vector<StorageCredential> storage_credentials_;
226+
std::vector<std::pair<std::string, std::unique_ptr<ArrowFileSystemFileIO>>>
227+
file_io_by_prefix_;
228+
};
229+
230+
Status ArrowS3FileIO::SetStorageCredentials(
231+
const std::vector<StorageCredential>& storage_credentials) {
232+
std::vector<std::pair<std::string, std::unique_ptr<ArrowFileSystemFileIO>>>
233+
file_io_by_prefix;
234+
file_io_by_prefix.reserve(storage_credentials.size());
235+
// TODO(gangwu): Refresh vended credentials via credentials.uri before tokens expire.
236+
for (const auto& credential : storage_credentials) {
237+
ICEBERG_RETURN_UNEXPECTED(credential.Validate());
238+
if (!IsS3FileIOCredentialPrefix(credential.prefix)) {
239+
return NotSupported(
240+
"Storage credential prefix '{}' is unsupported by Arrow S3 FileIO",
241+
credential.prefix);
242+
}
243+
auto properties = default_properties_;
244+
for (const auto& [key, value] : credential.config) {
245+
properties[key] = value;
246+
}
247+
ICEBERG_ASSIGN_OR_RAISE(auto fs, BuildArrowS3FileSystem(properties));
248+
file_io_by_prefix.emplace_back(
249+
CanonicalizeS3Scheme(credential.prefix),
250+
std::make_unique<ArrowFileSystemFileIO>(std::move(fs)));
251+
}
252+
file_io_by_prefix_ = std::move(file_io_by_prefix);
253+
storage_credentials_ = storage_credentials;
254+
return {};
255+
}
256+
257+
ArrowFileSystemFileIO& ArrowS3FileIO::FileIOForPath(std::string_view location) {
258+
if (file_io_by_prefix_.empty()) {
259+
return default_file_io_;
260+
}
261+
const std::string canonical = CanonicalizeS3Scheme(location);
262+
ArrowFileSystemFileIO* best = &default_file_io_;
263+
size_t best_len = 0;
264+
for (const auto& [prefix, file_io] : file_io_by_prefix_) {
265+
if (prefix.size() > best_len && canonical.starts_with(prefix)) {
266+
best = file_io.get();
267+
best_len = prefix.size();
268+
}
269+
}
270+
return *best;
271+
}
272+
273+
Result<std::unique_ptr<InputFile>> ArrowS3FileIO::NewInputFile(
274+
std::string file_location) {
275+
return FileIOForPath(file_location).NewInputFile(std::move(file_location));
276+
}
277+
278+
Result<std::unique_ptr<InputFile>> ArrowS3FileIO::NewInputFile(std::string file_location,
279+
size_t length) {
280+
return FileIOForPath(file_location).NewInputFile(std::move(file_location), length);
281+
}
282+
283+
Result<std::unique_ptr<OutputFile>> ArrowS3FileIO::NewOutputFile(
284+
std::string file_location) {
285+
return FileIOForPath(file_location).NewOutputFile(std::move(file_location));
286+
}
287+
288+
Status ArrowS3FileIO::DeleteFile(const std::string& file_location) {
289+
return FileIOForPath(file_location).DeleteFile(file_location);
290+
}
291+
292+
Status ArrowS3FileIO::DeleteFiles(const std::vector<std::string>& file_locations) {
293+
std::unordered_map<ArrowFileSystemFileIO*, std::vector<std::string>> locations_by_io;
294+
for (const auto& file_location : file_locations) {
295+
locations_by_io[&FileIOForPath(file_location)].push_back(file_location);
296+
}
297+
for (auto& [file_io, locations] : locations_by_io) {
298+
ICEBERG_RETURN_UNEXPECTED(file_io->DeleteFiles(locations));
299+
}
300+
return {};
301+
}
302+
303+
} // namespace
304+
305+
Result<std::unique_ptr<FileIO>> MakeS3FileIO(
306+
const std::unordered_map<std::string, std::string>& properties) {
307+
// Uses default credentials if properties are empty.
308+
ICEBERG_ASSIGN_OR_RAISE(auto fs, BuildArrowS3FileSystem(properties));
309+
return std::make_unique<ArrowS3FileIO>(std::move(fs), properties);
172310
}
173311

174312
Status FinalizeS3() {
175-
#if ICEBERG_S3_ENABLED
176313
auto status = ::arrow::fs::FinalizeS3();
177314
ICEBERG_ARROW_RETURN_NOT_OK(status);
178315
return {};
316+
}
317+
179318
#else
319+
320+
Result<std::unique_ptr<FileIO>> MakeS3FileIO(
321+
[[maybe_unused]] const std::unordered_map<std::string, std::string>& properties) {
180322
return NotSupported("Arrow S3 support is not enabled");
181-
#endif
182323
}
183324

325+
Status FinalizeS3() { return NotSupported("Arrow S3 support is not enabled"); }
326+
327+
#endif
328+
184329
} // namespace iceberg::arrow

src/iceberg/arrow/s3/s3_properties.h

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@ struct S3Properties {
3737
static constexpr std::string_view kSecretAccessKey = "s3.secret-access-key";
3838
/// AWS session token (for temporary credentials)
3939
static constexpr std::string_view kSessionToken = "s3.session-token";
40-
/// AWS region
41-
static constexpr std::string_view kRegion = "s3.region";
40+
/// AWS region, standard Iceberg client property.
41+
static constexpr std::string_view kClientRegion = "client.region";
4242
/// Custom endpoint override (for MinIO, LocalStack, etc.)
4343
static constexpr std::string_view kEndpoint = "s3.endpoint";
4444
/// Whether to use path-style access (needed for MinIO)

src/iceberg/catalog/rest/json_serde.cc

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ constexpr std::string_view kSource = "source";
7171
constexpr std::string_view kDestination = "destination";
7272
constexpr std::string_view kMetadata = "metadata";
7373
constexpr std::string_view kConfig = "config";
74+
constexpr std::string_view kStorageCredentials = "storage-credentials";
75+
constexpr std::string_view kPrefix = "prefix";
7476
constexpr std::string_view kIdentifiers = "identifiers";
7577
constexpr std::string_view kOverrides = "overrides";
7678
constexpr std::string_view kDefaults = "defaults";
@@ -133,6 +135,23 @@ constexpr std::string_view kResidualFilter = "residual-filter";
133135
constexpr std::string_view kMapKeys = "keys";
134136
constexpr std::string_view kMapValues = "values";
135137

138+
Result<nlohmann::json> StorageCredentialToJson(const StorageCredential& credential) {
139+
ICEBERG_RETURN_UNEXPECTED(credential.Validate());
140+
nlohmann::json json;
141+
json[kPrefix] = credential.prefix;
142+
json[kConfig] = credential.config;
143+
return json;
144+
}
145+
146+
Result<StorageCredential> StorageCredentialFromJson(const nlohmann::json& json) {
147+
StorageCredential credential;
148+
ICEBERG_ASSIGN_OR_RAISE(credential.prefix, GetJsonValue<std::string>(json, kPrefix));
149+
ICEBERG_ASSIGN_OR_RAISE(credential.config,
150+
GetJsonValue<decltype(credential.config)>(json, kConfig));
151+
ICEBERG_RETURN_UNEXPECTED(credential.Validate());
152+
return credential;
153+
}
154+
136155
template <typename Value>
137156
Result<std::map<int32_t, Value>> KeyValueMapFromJson(const nlohmann::json& json,
138157
std::string_view key) {
@@ -695,6 +714,14 @@ Result<nlohmann::json> ToJson(const LoadTableResult& result) {
695714
SetOptionalStringField(json, kMetadataLocation, result.metadata_location);
696715
ICEBERG_ASSIGN_OR_RAISE(json[kMetadata], ToJson(*result.metadata));
697716
SetContainerField(json, kConfig, result.config);
717+
if (!result.storage_credentials.empty()) {
718+
nlohmann::json creds = nlohmann::json::array();
719+
for (const auto& cred : result.storage_credentials) {
720+
ICEBERG_ASSIGN_OR_RAISE(auto entry, StorageCredentialToJson(cred));
721+
creds.push_back(std::move(entry));
722+
}
723+
json[kStorageCredentials] = std::move(creds);
724+
}
698725
return json;
699726
}
700727

@@ -707,6 +734,15 @@ Result<LoadTableResult> LoadTableResultFromJson(const nlohmann::json& json) {
707734
ICEBERG_ASSIGN_OR_RAISE(result.metadata, TableMetadataFromJson(metadata_json));
708735
ICEBERG_ASSIGN_OR_RAISE(result.config,
709736
GetJsonValueOrDefault<decltype(result.config)>(json, kConfig));
737+
if (auto it = json.find(kStorageCredentials); it != json.end() && !it->is_null()) {
738+
if (!it->is_array()) {
739+
return JsonParseError("Cannot parse storage credentials from non-array");
740+
}
741+
for (const auto& entry : *it) {
742+
ICEBERG_ASSIGN_OR_RAISE(auto cred, StorageCredentialFromJson(entry));
743+
result.storage_credentials.push_back(std::move(cred));
744+
}
745+
}
710746
ICEBERG_RETURN_UNEXPECTED(result.Validate());
711747
return result;
712748
}

0 commit comments

Comments
 (0)