diff --git a/fe/fe-filesystem/fe-filesystem-oss/pom.xml b/fe/fe-filesystem/fe-filesystem-oss/pom.xml index a6b96473c02df5..4469b6c182f2e0 100644 --- a/fe/fe-filesystem/fe-filesystem-oss/pom.xml +++ b/fe/fe-filesystem/fe-filesystem-oss/pom.xml @@ -58,6 +58,18 @@ under the License. aliyun-sdk-oss ${aliyun-sdk-oss.version} + + + com.aliyun + aliyun-java-sdk-core + 4.7.6 + + + + com.aliyun + aliyun-java-sdk-sts + 3.1.3 + org.junit.jupiter junit-jupiter-api diff --git a/fe/fe-filesystem/fe-filesystem-oss/src/main/java/org/apache/doris/filesystem/oss/AliyunCredentialsBridge.java b/fe/fe-filesystem/fe-filesystem-oss/src/main/java/org/apache/doris/filesystem/oss/AliyunCredentialsBridge.java new file mode 100644 index 00000000000000..84794aacc26bf2 --- /dev/null +++ b/fe/fe-filesystem/fe-filesystem-oss/src/main/java/org/apache/doris/filesystem/oss/AliyunCredentialsBridge.java @@ -0,0 +1,62 @@ +// 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. + +package org.apache.doris.filesystem.oss; + +import com.aliyun.oss.common.auth.Credentials; +import com.aliyun.oss.common.auth.CredentialsProvider; +import com.aliyun.oss.common.auth.DefaultCredentials; +import com.aliyuncs.auth.AlibabaCloudCredentials; +import com.aliyuncs.auth.AlibabaCloudCredentialsProvider; +import com.aliyuncs.auth.BasicSessionCredentials; + +/** + * Bridges {@link AlibabaCloudCredentialsProvider} (aliyuncs SDK) to + * {@link CredentialsProvider} (OSS SDK) so SDK-managed providers such as + * {@code OIDCCredentialsProvider} and {@code STSAssumeRoleSessionCredentialsProvider} + * can be passed directly to {@code OSSClientBuilder.build()}. + * + *

The aliyuncs providers handle credential caching and refresh internally; + * this class only bridges the credential types. + */ +class AliyunCredentialsBridge implements CredentialsProvider { + + private final AlibabaCloudCredentialsProvider delegate; + + AliyunCredentialsBridge(AlibabaCloudCredentialsProvider delegate) { + this.delegate = delegate; + } + + @Override + public void setCredentials(Credentials credentials) { + } + + @Override + public Credentials getCredentials() { + try { + AlibabaCloudCredentials c = delegate.getCredentials(); + // Temporary credentials (STS/RRSA/ECS) return BasicSessionCredentials with a token + if (c instanceof BasicSessionCredentials) { + String token = ((BasicSessionCredentials) c).getSessionToken(); + return new DefaultCredentials(c.getAccessKeyId(), c.getAccessKeySecret(), token); + } + return new DefaultCredentials(c.getAccessKeyId(), c.getAccessKeySecret()); + } catch (Exception e) { + throw new IllegalStateException("Failed to obtain credentials: " + e.getMessage(), e); + } + } +} diff --git a/fe/fe-filesystem/fe-filesystem-oss/src/main/java/org/apache/doris/filesystem/oss/OssCredentialsProviderType.java b/fe/fe-filesystem/fe-filesystem-oss/src/main/java/org/apache/doris/filesystem/oss/OssCredentialsProviderType.java new file mode 100644 index 00000000000000..39e173683adae1 --- /dev/null +++ b/fe/fe-filesystem/fe-filesystem-oss/src/main/java/org/apache/doris/filesystem/oss/OssCredentialsProviderType.java @@ -0,0 +1,92 @@ +// 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. + +package org.apache.doris.filesystem.oss; + +import org.apache.commons.lang3.StringUtils; + +/** + * Credential provider mode for OSS filesystem access. + * + *

Mirrors {@code S3CredentialsProviderType} for consistency across providers. + */ +public enum OssCredentialsProviderType { + + /** + * Try in order: INSTANCE_PROFILE → OIDC (if env configured) → ENV → static ak/sk → anonymous. + */ + DEFAULT("DEFAULT"), + + /** + * ECS instance metadata (100.100.100.200). Role name from {@code oss.ecs_ram_role_name} + * or auto-discovered from the metadata base URL when not set. + */ + INSTANCE_PROFILE("INSTANCE_PROFILE"), + + /** + * RRSA / Kubernetes pod identity via STS AssumeRoleWithOIDC. + * Role ARN and token file from {@code oss.oidc_provider_arn}/{@code oss.oidc_token_file} + * or from env vars {@code ALIBABA_CLOUD_ROLE_ARN} / {@code ALIBABA_CLOUD_OIDC_TOKEN_FILE} + * / {@code ALIBABA_CLOUD_OIDC_PROVIDER_ARN}. + */ + OIDC("OIDC"), + + /** + * Environment variables: {@code OSS_ACCESS_KEY_ID} and {@code OSS_ACCESS_KEY_SECRET}. + */ + ENV("ENV"), + + /** No credentials — public buckets only. */ + ANONYMOUS("ANONYMOUS"); + + private final String mode; + + OssCredentialsProviderType(String mode) { + this.mode = mode; + } + + public String getMode() { + return mode; + } + + public static OssCredentialsProviderType fromString(String value) { + if (StringUtils.isBlank(value)) { + return DEFAULT; + } + String normalized = value.trim().toUpperCase().replace('-', '_'); + switch (normalized) { + case "INSTANCE_PROFILE": + case "ECS": + case "ECS_RAM_ROLE": + return INSTANCE_PROFILE; + case "OIDC": + case "RRSA": + case "WEB_IDENTITY": + return OIDC; + case "ENV": + case "ENVIRONMENT": + return ENV; + case "ANONYMOUS": + return ANONYMOUS; + case "DEFAULT": + return DEFAULT; + default: + throw new IllegalArgumentException( + "Unsupported oss.credentials_provider: " + value); + } + } +} diff --git a/fe/fe-filesystem/fe-filesystem-oss/src/main/java/org/apache/doris/filesystem/oss/OssFileSystemProperties.java b/fe/fe-filesystem/fe-filesystem-oss/src/main/java/org/apache/doris/filesystem/oss/OssFileSystemProperties.java index 663b5596c242cd..1b664876c2e84b 100644 --- a/fe/fe-filesystem/fe-filesystem-oss/src/main/java/org/apache/doris/filesystem/oss/OssFileSystemProperties.java +++ b/fe/fe-filesystem/fe-filesystem-oss/src/main/java/org/apache/doris/filesystem/oss/OssFileSystemProperties.java @@ -62,6 +62,9 @@ public final class OssFileSystemProperties public static final String FORCE_PARSING_BY_STANDARD_URI = "oss.force_parsing_by_standard_uri"; + public static final String CREDENTIALS_PROVIDER = "oss.credentials_provider"; + + public static final String DEFAULT_CREDENTIALS_PROVIDER = "DEFAULT"; public static final String DEFAULT_MAX_CONNECTIONS = "100"; public static final String DEFAULT_REQUEST_TIMEOUT_MS = "10000"; public static final String DEFAULT_CONNECTION_TIMEOUT_MS = "10000"; @@ -149,11 +152,36 @@ public final class OssFileSystemProperties description = "The OSS role ARN for AssumeRole access.") private String roleArn = ""; + @ConnectorProperty(names = {"oss.role_session_name"}, + required = false, + description = "Session name used for STS AssumeRole; default 'doris-session'.") + private String roleSessionName = "doris-session"; + @ConnectorProperty(names = {"AWS_EXTERNAL_ID"}, required = false, description = "The external ID for AssumeRole trust policy.") private String externalId = ""; + @ConnectorProperty(names = {"oss.ecs_ram_role_name"}, + required = false, + description = "ECS-attached RAM role name; mutually exclusive with oss.role_arn.") + private String ecsRamRoleName = ""; + + @ConnectorProperty(names = {"oss.oidc_provider_arn"}, + required = false, + description = "OIDC provider ARN for RRSA (Kubernetes pod identity).") + private String oidcProviderArn = ""; + + @ConnectorProperty(names = {"oss.oidc_token_file"}, + required = false, + description = "Path to OIDC token file mounted by Kubernetes.") + private String oidcTokenFile = ""; + + @ConnectorProperty(names = {CREDENTIALS_PROVIDER, "AWS_CREDENTIALS_PROVIDER_TYPE"}, + required = false, + description = "Credential provider mode: DEFAULT, INSTANCE_PROFILE, OIDC, ENV, ANONYMOUS.") + private String credentialsProvider = DEFAULT_CREDENTIALS_PROVIDER; + @ConnectorProperty(names = {"AWS_ROOT_PATH"}, required = false, description = "The root path prefix inside the bucket.") @@ -178,6 +206,14 @@ public static OssFileSystemProperties of(Map properties) { @Override public void validate() { new ParamRules() + .mutuallyExclusive(roleArn, ecsRamRoleName, + "OSS_ROLE_ARN and oss.ecs_ram_role_name are mutually exclusive.") + .mutuallyExclusive(ecsRamRoleName, oidcProviderArn, + "oss.ecs_ram_role_name and oss.oidc_provider_arn are mutually exclusive.") + .requireTogether(new String[] {oidcProviderArn, oidcTokenFile}, + "oss.oidc_provider_arn and oss.oidc_token_file must be set together.") + .check(this::hasUnsupportedCredentialsProviderType, + "Unsupported oss.credentials_provider: " + credentialsProvider) .requireTogether(new String[] {accessKey, secretKey}, "Both the access key and the secret key must be set.") .check(() -> StringUtils.isBlank(region), @@ -313,6 +349,35 @@ public String getRoleArn() { return roleArn; } + public String getRoleSessionName() { + return roleSessionName; + } + + public String getEcsRamRoleName() { + return ecsRamRoleName; + } + + public String getOidcProviderArn() { + return oidcProviderArn; + } + + public String getOidcTokenFile() { + return oidcTokenFile; + } + + public OssCredentialsProviderType getCredentialsProviderType() { + return OssCredentialsProviderType.fromString(credentialsProvider); + } + + private boolean hasUnsupportedCredentialsProviderType() { + try { + getCredentialsProviderType(); + return false; + } catch (IllegalArgumentException e) { + return true; + } + } + @Override public String getExternalId() { return externalId; diff --git a/fe/fe-filesystem/fe-filesystem-oss/src/main/java/org/apache/doris/filesystem/oss/OssObjStorage.java b/fe/fe-filesystem/fe-filesystem-oss/src/main/java/org/apache/doris/filesystem/oss/OssObjStorage.java index cfb1da6853270c..8f370bb2859531 100644 --- a/fe/fe-filesystem/fe-filesystem-oss/src/main/java/org/apache/doris/filesystem/oss/OssObjStorage.java +++ b/fe/fe-filesystem/fe-filesystem-oss/src/main/java/org/apache/doris/filesystem/oss/OssObjStorage.java @@ -176,9 +176,53 @@ private static boolean isVirtualHostCompatible(String bucket) { protected OSS buildOssClient(boolean pathStyle) throws IOException { String endpoint = properties.getEndpoint(); + OssCredentialsProviderType type = properties.getCredentialsProviderType(); + switch (type) { + case INSTANCE_PROFILE: + return new OSSClientBuilder().build(endpoint, + new com.aliyun.oss.common.auth.EcsRamRoleCredentialsProvider( + resolveEcsRoleName()), + clientConfiguration(pathStyle)); + case OIDC: + return new OSSClientBuilder().build(endpoint, + new AliyunCredentialsBridge(buildOidcProvider()), + clientConfiguration(pathStyle)); + case ENV: + return new OSSClientBuilder().build(endpoint, + new com.aliyun.oss.common.auth.EnvironmentVariableCredentialsProvider(), + clientConfiguration(pathStyle)); + case ANONYMOUS: + return new OSSClientBuilder().build(endpoint, anonymousCredentialsProvider(), + anonymousClientConfiguration(pathStyle)); + default: + break; // DEFAULT — fall through to property-driven chain below + } + // DEFAULT: property-driven chain + if (hasText(properties.getEcsRamRoleName())) { + return new OSSClientBuilder().build(endpoint, + new com.aliyun.oss.common.auth.EcsRamRoleCredentialsProvider( + properties.getEcsRamRoleName()), + clientConfiguration(pathStyle)); + } + if (hasText(properties.getOidcProviderArn())) { + return new OSSClientBuilder().build(endpoint, + new AliyunCredentialsBridge(buildOidcProvider()), + clientConfiguration(pathStyle)); + } + if (hasText(properties.getRoleArn())) { + return new OSSClientBuilder().build(endpoint, + new AliyunCredentialsBridge(buildAssumeRoleProvider()), + clientConfiguration(pathStyle)); + } + // existing ak/sk / anonymous logic — unchanged String accessKey = properties.getAccessKey(); String secretKey = properties.getSecretKey(); if (!hasText(accessKey)) { + if (hasText(System.getenv("OSS_ACCESS_KEY_ID"))) { + return new OSSClientBuilder().build(endpoint, + new com.aliyun.oss.common.auth.EnvironmentVariableCredentialsProvider(), + clientConfiguration(pathStyle)); + } return new OSSClientBuilder().build(endpoint, anonymousCredentialsProvider(), anonymousClientConfiguration(pathStyle)); } @@ -190,6 +234,57 @@ protected OSS buildOssClient(boolean pathStyle) throws IOException { return new OSSClientBuilder().build(endpoint, accessKey, secretKey, config); } + // protected so tests can override + protected String discoverEcsRoleName() throws IOException { + java.net.URL url = new java.net.URL( + "http://100.100.100.200/latest/meta-data/ram/security-credentials/"); + java.net.HttpURLConnection conn = (java.net.HttpURLConnection) url.openConnection(); + conn.setConnectTimeout(3000); + conn.setReadTimeout(3000); + try (java.io.InputStream in = conn.getInputStream()) { + return new String(in.readAllBytes()).trim().split("\n")[0].trim(); + } finally { + conn.disconnect(); + } + } + + private String resolveEcsRoleName() throws IOException { + String name = properties.getEcsRamRoleName(); + return hasText(name) ? name : discoverEcsRoleName(); + } + + private com.aliyuncs.auth.AlibabaCloudCredentialsProvider buildOidcProvider() { + String roleArn = hasText(properties.getRoleArn()) + ? properties.getRoleArn() : System.getenv("ALIBABA_CLOUD_ROLE_ARN"); + String providerArn = hasText(properties.getOidcProviderArn()) + ? properties.getOidcProviderArn() + : System.getenv("ALIBABA_CLOUD_OIDC_PROVIDER_ARN"); + String tokenFile = hasText(properties.getOidcTokenFile()) + ? properties.getOidcTokenFile() + : System.getenv("ALIBABA_CLOUD_OIDC_TOKEN_FILE"); + return com.aliyuncs.auth.OIDCCredentialsProvider.builder() + .roleArn(roleArn) + .oidcProviderArn(providerArn) + .oidcTokenFilePath(tokenFile) + .roleSessionName(properties.getRoleSessionName()) + .stsRegionId(properties.getRegion()) + .build(); + } + + private com.aliyuncs.auth.AlibabaCloudCredentialsProvider buildAssumeRoleProvider() { + com.aliyuncs.auth.STSAssumeRoleSessionCredentialsProvider.Builder builder = + com.aliyuncs.auth.STSAssumeRoleSessionCredentialsProvider.builder() + .accessKeyId(properties.getAccessKey()) + .accessKeySecret(properties.getSecretKey()) + .roleArn(properties.getRoleArn()) + .roleSessionName(properties.getRoleSessionName()) + .stsRegionId(properties.getRegion()); + if (hasText(properties.getExternalId())) { + builder.externalId(properties.getExternalId()); + } + return builder.build(); + } + @Override public RemoteObjects listObjects(String remotePath, String continuationToken) throws IOException { return listObjectsWithOptions(remotePath, ObjectListOptions.builder() diff --git a/fe/fe-filesystem/fe-filesystem-oss/src/test/java/org/apache/doris/filesystem/oss/OssCredentialProviderTest.java b/fe/fe-filesystem/fe-filesystem-oss/src/test/java/org/apache/doris/filesystem/oss/OssCredentialProviderTest.java new file mode 100644 index 00000000000000..edbe9245d72428 --- /dev/null +++ b/fe/fe-filesystem/fe-filesystem-oss/src/test/java/org/apache/doris/filesystem/oss/OssCredentialProviderTest.java @@ -0,0 +1,118 @@ +// 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. + +package org.apache.doris.filesystem.oss; + +import com.aliyun.oss.common.auth.Credentials; +import com.aliyuncs.auth.AlibabaCloudCredentials; +import com.aliyuncs.auth.BasicSessionCredentials; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + * Tests for credential bridging and provider selection in OssObjStorage. + * No real OSS credentials required — all external calls are overridden. + */ +class OssCredentialProviderTest { + + // ── AliyunCredentialsBridge ─────────────────────────────────────────────── + + @Test + void bridge_wrapsSessionCredentialsWithToken() { + BasicSessionCredentials alibabaCreds = + new BasicSessionCredentials("session-ak", "session-sk", "session-token"); + + AliyunCredentialsBridge bridge = new AliyunCredentialsBridge(() -> alibabaCreds); + + Credentials ossCreds = bridge.getCredentials(); + Assertions.assertEquals("session-ak", ossCreds.getAccessKeyId()); + Assertions.assertEquals("session-sk", ossCreds.getSecretAccessKey()); + Assertions.assertEquals("session-token", ossCreds.getSecurityToken()); + } + + @Test + void bridge_wrapsBasicCredentialsWithoutToken() { + AlibabaCloudCredentials alibabaCreds = new com.aliyuncs.auth.BasicCredentials("ak", "sk"); + + AliyunCredentialsBridge bridge = new AliyunCredentialsBridge(() -> alibabaCreds); + + Credentials ossCreds = bridge.getCredentials(); + Assertions.assertEquals("ak", ossCreds.getAccessKeyId()); + Assertions.assertEquals("sk", ossCreds.getSecretAccessKey()); + Assertions.assertTrue(ossCreds.getSecurityToken() == null + || ossCreds.getSecurityToken().isEmpty()); + } + + @Test + void bridge_wrapsProviderException() { + AliyunCredentialsBridge bridge = new AliyunCredentialsBridge(() -> { + throw new com.aliyuncs.exceptions.ClientException("network error"); + }); + + Assertions.assertThrows(IllegalStateException.class, bridge::getCredentials); + } + + // ── OssObjStorage — INSTANCE_PROFILE mode auto-discovery ───────────────── + + @Test + void objStorage_instanceProfileMode_callsDiscoverEcsRoleName() { + OssFileSystemProperties props = OssFileSystemProperties.of(java.util.Map.of( + "oss.endpoint", "https://oss-cn-hangzhou.aliyuncs.com", + "oss.credentials_provider", "instance_profile")); + + String[] captured = {null}; + OssObjStorage storage = new OssObjStorage(props) { + @Override + protected String discoverEcsRoleName() { + captured[0] = "discovered"; + return "AutoRole"; + } + }; + + try { + storage.getClient(); + } catch (Exception ignored) { + // expected — no real endpoint in tests + } + Assertions.assertEquals("discovered", captured[0], + "discoverEcsRoleName() should be called when no role name is set"); + } + + @Test + void objStorage_instanceProfileMode_usesConfiguredRoleNameWithoutDiscovery() { + OssFileSystemProperties props = OssFileSystemProperties.of(java.util.Map.of( + "oss.endpoint", "https://oss-cn-hangzhou.aliyuncs.com", + "oss.ecs_ram_role_name", "ExplicitRole", + "oss.credentials_provider", "instance_profile")); + + String[] captured = {null}; + OssObjStorage storage = new OssObjStorage(props) { + @Override + protected String discoverEcsRoleName() { + captured[0] = "should-not-be-called"; + return "AutoRole"; + } + }; + + try { + storage.getClient(); + } catch (Exception ignored) { + // expected + } + Assertions.assertNull(captured[0], "discoverEcsRoleName() should NOT be called when role name is set"); + } +} diff --git a/fe/fe-filesystem/fe-filesystem-oss/src/test/java/org/apache/doris/filesystem/oss/OssFileSystemPropertiesTest.java b/fe/fe-filesystem/fe-filesystem-oss/src/test/java/org/apache/doris/filesystem/oss/OssFileSystemPropertiesTest.java index a4d7a11c6ce962..005bac4e5835f4 100644 --- a/fe/fe-filesystem/fe-filesystem-oss/src/test/java/org/apache/doris/filesystem/oss/OssFileSystemPropertiesTest.java +++ b/fe/fe-filesystem/fe-filesystem-oss/src/test/java/org/apache/doris/filesystem/oss/OssFileSystemPropertiesTest.java @@ -180,4 +180,129 @@ void objStorageDoesNotExposeLegacyToS3PropsTranslator() { Assertions.assertNotEquals("toS3Props", method.getName()); } } + + @Test + void bind_roleArnAndRoleSessionName() { + OssFileSystemProperties p = OssFileSystemProperties.of(Map.of( + "oss.endpoint", "https://oss-cn-hangzhou.aliyuncs.com", + "oss.access_key", "ak", + "oss.secret_key", "sk", + "oss.role_arn", "acs:ram::123:role/r", + "oss.role_session_name", "my-session", + "oss.external_id", "ext-id")); + + Assertions.assertEquals("acs:ram::123:role/r", p.getRoleArn()); + Assertions.assertEquals("my-session", p.getRoleSessionName()); + Assertions.assertEquals("ext-id", p.getExternalId()); + } + + @Test + void bind_roleSessionName_defaultsToDorisSession() { + OssFileSystemProperties p = OssFileSystemProperties.of(Map.of( + "oss.endpoint", "https://oss-cn-hangzhou.aliyuncs.com", + "oss.access_key", "ak", + "oss.secret_key", "sk", + "oss.role_arn", "acs:ram::123:role/r")); + + Assertions.assertEquals("doris-session", p.getRoleSessionName()); + } + + @Test + void bind_ecsRamRoleName() { + OssFileSystemProperties p = OssFileSystemProperties.of(Map.of( + "oss.endpoint", "https://oss-cn-hangzhou.aliyuncs.com", + "oss.ecs_ram_role_name", "my-ecs-role")); + + Assertions.assertEquals("my-ecs-role", p.getEcsRamRoleName()); + Assertions.assertEquals("", p.getAccessKey()); + } + + @Test + void validate_roleArnAndEcsRamRoleAreMutuallyExclusive() { + Assertions.assertThrows(IllegalArgumentException.class, () -> + OssFileSystemProperties.of(Map.of( + "oss.endpoint", "https://oss-cn-hangzhou.aliyuncs.com", + "oss.access_key", "ak", + "oss.secret_key", "sk", + "oss.role_arn", "acs:ram::123:role/r", + "oss.ecs_ram_role_name", "my-role"))); + } + + @Test + void validate_legacyRoleArnAliasesResolved() { + OssFileSystemProperties p = OssFileSystemProperties.of(Map.of( + "oss.endpoint", "https://oss-cn-hangzhou.aliyuncs.com", + "oss.access_key", "ak", + "oss.secret_key", "sk", + "OSS_ROLE_ARN", "acs:ram::123:role/legacy")); + + Assertions.assertEquals("acs:ram::123:role/legacy", p.getRoleArn()); + } + + @Test + void bind_oidcRrsaProperties() { + OssFileSystemProperties p = OssFileSystemProperties.of(Map.of( + "oss.endpoint", "https://oss-cn-hangzhou.aliyuncs.com", + "OSS_ROLE_ARN", "acs:ram::123:role/r", + "oss.oidc_provider_arn", "acs:ram::123:oidc-provider/p", + "oss.oidc_token_file", "/var/run/secrets/token", + "oss.role_session_name", "doris-k8s")); + + Assertions.assertEquals("acs:ram::123:role/r", p.getRoleArn()); + Assertions.assertEquals("acs:ram::123:oidc-provider/p", p.getOidcProviderArn()); + Assertions.assertEquals("/var/run/secrets/token", p.getOidcTokenFile()); + Assertions.assertEquals("doris-k8s", p.getRoleSessionName()); + } + + @Test + void validate_oidcProviderArnRequiresTokenFileTogether() { + Assertions.assertThrows(IllegalArgumentException.class, () -> + OssFileSystemProperties.of(Map.of( + "oss.endpoint", "https://oss-cn-hangzhou.aliyuncs.com", + "oss.oidc_provider_arn", "acs:ram::123:oidc-provider/p" + // oss.oidc_token_file missing — must be set together + ))); + } + + @Test + void validate_ecsRamRoleAndOidcAreMutuallyExclusive() { + Assertions.assertThrows(IllegalArgumentException.class, () -> + OssFileSystemProperties.of(Map.of( + "oss.endpoint", "https://oss-cn-hangzhou.aliyuncs.com", + "oss.ecs_ram_role_name", "my-role", + "oss.oidc_provider_arn", "acs:ram::123:oidc-provider/p", + "oss.oidc_token_file", "/var/run/secrets/token"))); + } + + @Test + void bind_credentialsProviderType_instanceProfile() { + OssFileSystemProperties p = OssFileSystemProperties.of(Map.of( + "oss.endpoint", "https://oss-cn-hangzhou.aliyuncs.com", + "oss.credentials_provider", "instance_profile")); + + Assertions.assertEquals(OssCredentialsProviderType.INSTANCE_PROFILE, + p.getCredentialsProviderType()); + } + + @Test + void bind_credentialsProviderType_aliases() { + Assertions.assertEquals(OssCredentialsProviderType.INSTANCE_PROFILE, + OssCredentialsProviderType.fromString("ecs")); + Assertions.assertEquals(OssCredentialsProviderType.OIDC, + OssCredentialsProviderType.fromString("rrsa")); + Assertions.assertEquals(OssCredentialsProviderType.OIDC, + OssCredentialsProviderType.fromString("WEB_IDENTITY")); + Assertions.assertEquals(OssCredentialsProviderType.DEFAULT, + OssCredentialsProviderType.fromString("")); + Assertions.assertEquals(OssCredentialsProviderType.DEFAULT, + OssCredentialsProviderType.fromString(null)); + } + + @Test + void validate_unsupportedCredentialsProvider() { + Assertions.assertThrows(IllegalArgumentException.class, () -> + OssFileSystemProperties.of(Map.of( + "oss.endpoint", "https://oss-cn-hangzhou.aliyuncs.com", + "oss.credentials_provider", "INVALID_TYPE"))); + } }