deniskuzZ commented on code in PR #6474: URL: https://github.com/apache/hive/pull/6474#discussion_r3560549527
########## iceberg/iceberg-handler/src/main/java/org/apache/iceberg/mr/hive/IcebergVendedCredentialUtil.java: ########## @@ -0,0 +1,460 @@ +/* + * 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.iceberg.mr.hive; + +import java.net.URI; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import org.apache.commons.lang3.StringUtils; +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.hive.ql.plan.TableDesc; +import org.apache.hadoop.mapred.JobConf; +import org.apache.iceberg.Table; +import org.apache.iceberg.exceptions.NoSuchTableException; +import org.apache.iceberg.hive.IcebergCatalogProperties; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.StorageCredential; +import org.apache.iceberg.io.SupportsStorageCredentials; +import org.apache.iceberg.mr.InputFormatConfig; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableSet; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.util.SerializationUtil; +import org.springframework.util.CollectionUtils; + +/** + * Propagates vended storage credentials from an Iceberg {@link Table}'s {@link FileIO} to Hive job + * configuration so Tez/LLAP executors can access object storage without static catalog keys. + */ +public final class IcebergVendedCredentialUtil { + + public static final String ACCESS_KEY_ID = "s3.access-key-id"; + public static final String SECRET_ACCESS_KEY = "s3.secret-access-key"; + public static final String SESSION_TOKEN = "s3.session-token"; + public static final String ENDPOINT = "s3.endpoint"; + public static final String PATH_STYLE_ACCESS = "s3.path-style-access"; + + private static final ImmutableSet<String> SECRET_ICEBERG_KEYS = ImmutableSet.of( + ACCESS_KEY_ID, SECRET_ACCESS_KEY, SESSION_TOKEN); + + private IcebergVendedCredentialUtil() { + } + + /** + * Copies vended credentials from the table FileIO into Hive job configuration. + * + * <p>Follows the HIVE-20651 split used by {@code JdbcStorageHandler}: sensitive values (including + * the serialized {@link StorageCredential} list) go to {@code jobSecrets}; non-secret config such + * as endpoint and path-style access go to {@code jobProperties}. + * + * @param table loaded Iceberg table + * @param catalogName Hive catalog name ({@link InputFormatConfig#CATALOG_NAME}) + * @param jobProperties Tez/MR non-secret job properties; may be {@code null} + * @param jobSecrets sensitive keys and serialized credentials; may be {@code null} + * @param conf session conf used to preserve host-side endpoint overrides + */ + public static void propagateToJob(Table table, String catalogName, Map<String, String> jobProperties, + Map<String, String> jobSecrets, Configuration conf) { + + List<StorageCredential> credentials = + withConfigurationOverrides(catalogName, extractCredentials(table), conf); + + if (credentials.isEmpty()) { + return; + } + + if (jobSecrets != null) { + jobSecrets.put( + InputFormatConfig.VENDED_STORAGE_CREDENTIALS, + SerializationUtil.serializeToBase64(Lists.newArrayList(credentials))); + } + + for (StorageCredential credential : credentials) { + addCredentialEntries(catalogName, credential, jobProperties, jobSecrets, conf); + } + } + + /** + * Writes each key in one vended {@link StorageCredential} into job configuration. + * + * <p>Derives the S3 bucket from {@link StorageCredential#prefix()} and delegates to + * {@link #addCredentialEntry} for every entry in {@link StorageCredential#config()}, which maps + * Iceberg keys to catalog-level and per-bucket S3A job properties or secrets. + */ + private static void addCredentialEntries(String catalogName, StorageCredential credential, + Map<String, String> jobProperties, Map<String, String> jobSecrets, Configuration conf) { + + String bucket = bucketFromPrefix(credential.prefix()); + for (Map.Entry<String, String> entry : credential.config().entrySet()) { + addCredentialEntry( + catalogName, bucket, entry.getKey(), entry.getValue(), jobProperties, jobSecrets, conf); + } + } + + /** + * Routes one Iceberg credential config entry into job properties or secrets. + * + * <p>Skips blank values, applies session catalog overrides via {@link #resolveCredentialValue}, + * then sends non-secret keys (endpoint, path-style access, etc.) to {@code jobProperties} and + * secret keys (access key, secret key, session token) to {@code jobSecrets}. Either map may be + * {@code null} when {@link #propagateToJob} is called for only properties or only secrets. + */ + private static void addCredentialEntry(String catalogName, String bucket, String icebergKey, String value, + Map<String, String> jobProperties, Map<String, String> jobSecrets, Configuration conf) { + + if (StringUtils.isBlank(value)) { + return; + } + String resolvedValue = resolveCredentialValue(catalogName, icebergKey, value, conf); + + if (jobProperties != null && !isSecretKey(icebergKey)) { + addNonSecretCredentialEntry(catalogName, bucket, icebergKey, resolvedValue, jobProperties); + } + + if (jobSecrets != null && isSecretKey(icebergKey)) { + addSecretCredentialEntry(bucket, icebergKey, resolvedValue, jobSecrets); + } + } + + /** + * Adds one non-secret vended value to {@code jobProperties} for Iceberg and Hadoop S3A. + * + * <p>When {@code catalogName} is set, writes {@code iceberg.catalog.<catalog>.<key>}. + * When {@code bucket} is set, also writes the matching {@code fs.s3a.bucket.<bucket>.*} + * key if {@link #toS3aBucketProperty} maps the Iceberg key. + */ + private static void addNonSecretCredentialEntry(String catalogName, String bucket, String icebergKey, String value, + Map<String, String> jobProperties) { + + if (catalogName != null) { + String catalogConfigKey = + IcebergCatalogProperties.catalogPropertyConfigKey(catalogName, icebergKey); + jobProperties.putIfAbsent(catalogConfigKey, value); + } + + if (bucket != null) { + String s3aKey = toS3aBucketProperty(bucket, icebergKey); + if (s3aKey != null) { + jobProperties.putIfAbsent(s3aKey, value); + } + } + } + + /** Writes Hadoop S3A per-bucket keys only; Iceberg secrets are carried in the serialized blob. */ + private static void addSecretCredentialEntry(String bucket, String icebergKey, String value, + Map<String, String> jobSecrets) { + if (bucket != null) { + String s3aSecretKey = toS3aBucketProperty(bucket, icebergKey); + if (s3aSecretKey != null) { + jobSecrets.put(s3aSecretKey, value); + } + } + } + + /** + * Applies vended credentials to the table FileIO, merging session/catalog conf overrides (e.g. S3 endpoint). + * Used on executors after deserialization and on HS2 commit when the table is taken from query state. + */ + public static void applyFromJobConf(Table table, Configuration conf) { + if (table == null || conf == null) { + return; + } + + String catalogName = conf.get(InputFormatConfig.CATALOG_NAME); + if (shouldSkipApplyFromJobConf(catalogName, conf)) { + return; + } + + FileIO io = table.io(); + if (!(io instanceof SupportsStorageCredentials credentialIo)) { + return; + } + + List<StorageCredential> credentials = resolveCredentialsForApply(table, credentialIo, conf); + if (credentials != null && !credentials.isEmpty()) { + credentialIo.setCredentials(withConfigurationOverrides(catalogName, credentials, conf)); + } + } + + /** + * Returns true when the job carries no serialized vended credentials and the catalog is not + * configured for credential vending (no {@code vended-credentials} REST delegation header). + * Otherwise apply may restore credentials from the job conf or from the table FileIO. + */ + private static boolean shouldSkipApplyFromJobConf(String catalogName, Configuration conf) { + return StringUtils.isBlank(conf.get(InputFormatConfig.VENDED_STORAGE_CREDENTIALS)) && + !IcebergCatalogProperties.requestsVendedCredentials(catalogName, conf); + } + + /** + * Chooses which vended credentials {@link #applyFromJobConf} should install on the FileIO. + * + * <p>Uses the first non-empty source: credentials already on the FileIO (typical on HS2 after + * table load), else the base64 list from {@link InputFormatConfig#VENDED_STORAGE_CREDENTIALS} on + * the task {@code conf} (propagated at plan time), else {@link #extractCredentials(Table)} from + * the table (including FileIO property fallbacks). + */ + private static List<StorageCredential> resolveCredentialsForApply( + Table table, SupportsStorageCredentials credentialIo, Configuration conf) { + + List<StorageCredential> credentials = credentialIo.credentials(); + if (CollectionUtils.isEmpty(credentials)) { + String serialized = conf.get(InputFormatConfig.VENDED_STORAGE_CREDENTIALS); + if (StringUtils.isNotBlank(serialized)) { + @SuppressWarnings("unchecked") + List<StorageCredential> deserialized = + SerializationUtil.deserializeFromBase64(serialized); + credentials = deserialized; + } + } + + if (credentials == null || credentials.isEmpty()) { + credentials = extractCredentials(table); + } + return credentials; + } + + /** + * Returns true when the table catalog is configured for REST vended storage credentials. + */ + static boolean requestsVendedCredentials(Properties properties, Configuration configuration) { + if (properties == null) { + return false; + } + return IcebergCatalogProperties.requestsVendedCredentials( + properties.getProperty(InputFormatConfig.CATALOG_NAME), configuration); + } + + /** + * Loads a table and, if needed, bypasses the query-level cache so REST vended credentials are present on the FileIO. + * When vended credentials are not requested for the catalog, returns the cached table without an extra load. + */ + static Table getTableWithVendedCredentials(Properties properties, Configuration configuration) { + Table table = IcebergTableUtil.getTable(configuration, properties); + if (requestsVendedCredentials(properties, configuration) && extractCredentials(table).isEmpty()) { + table = IcebergTableUtil.getTable(configuration, properties, true); + } + return table; + } + + /** + * Reloads vended credentials at job launch when compile-time propagation missed them. + * Non-secret config is written to {@code jobConf}; secrets are merged into {@code TableDesc#getJobSecrets()} + * for {@link org.apache.hadoop.hive.ql.plan.PlanUtils#configureJobConf} (HIVE-20651). + */ + static void refreshVendedCredentialsIfMissing(TableDesc tableDesc, JobConf jobConf, Configuration configuration) { + if (tableDesc == null || tableDesc.getProperties() == null || + hasSerializedCredentials(tableDesc.getJobSecrets()) || + StringUtils.isNotBlank(jobConf.get(InputFormatConfig.VENDED_STORAGE_CREDENTIALS))) { + return; + } + + Properties props = tableDesc.getProperties(); + if (!requestsVendedCredentials(props, configuration)) { + return; + } + + String catalogName = props.getProperty(InputFormatConfig.CATALOG_NAME); + if (catalogName == null) { + return; + } + + try { + Table table = getTableWithVendedCredentials(props, configuration); + Map<String, String> jobProps = new LinkedHashMap<>(); + Map<String, String> secrets = new LinkedHashMap<>(); + propagateToJob(table, catalogName, jobProps, secrets, configuration); + jobProps.forEach(jobConf::set); + mergeJobSecrets(tableDesc, secrets); + } catch (NoSuchTableException ex) { + // Table may not exist yet for CTAS; credentials will not be available. + } + } + + /** Copies {@code jobSecrets} onto {@code jobConf} so the Tez driver can use S3A before tasks start. */ + static void applyJobSecretsToJobConf(TableDesc tableDesc, JobConf jobConf) { + Map<String, String> secrets = tableDesc.getJobSecrets(); + if (secrets != null) { + secrets.forEach(jobConf::set); + } + } + + private static boolean hasSerializedCredentials(Map<String, String> jobSecrets) { + return jobSecrets != null && + StringUtils.isNotBlank(jobSecrets.get(InputFormatConfig.VENDED_STORAGE_CREDENTIALS)); + } + + private static void mergeJobSecrets(TableDesc tableDesc, Map<String, String> secrets) { + if (secrets.isEmpty()) { + return; + } + Map<String, String> existing = tableDesc.getJobSecrets(); + if (existing == null) { + tableDesc.setJobSecrets(new LinkedHashMap<>(secrets)); + } else { + secrets.forEach(existing::putIfAbsent); + } + } + + private static boolean isSecretKey(String icebergKey) { + return SECRET_ICEBERG_KEYS.contains(icebergKey); + } + + static List<StorageCredential> extractCredentials(Table table) { + if (table == null) { + return List.of(); + } + FileIO io = table.io(); + if (io instanceof SupportsStorageCredentials credentialIo) { + List<StorageCredential> credentials = credentialIo.credentials(); + if (credentials != null && !credentials.isEmpty()) { + return credentials; + } + } + return credentialsFromFileIoProperties(table, io); + } + + private static List<StorageCredential> credentialsFromFileIoProperties(Table table, FileIO io) { + Map<String, String> props = io.properties(); + if (props == null || StringUtils.isBlank(props.get(ACCESS_KEY_ID)) || + StringUtils.isBlank(props.get(SECRET_ACCESS_KEY))) { + return List.of(); + } + Map<String, String> config = new LinkedHashMap<>(); + putIfPresent(config, props, ACCESS_KEY_ID); + putIfPresent(config, props, SECRET_ACCESS_KEY); + putIfPresent(config, props, SESSION_TOKEN); + putIfPresent(config, props, ENDPOINT); + putIfPresent(config, props, PATH_STYLE_ACCESS); + putIfPresent(config, props, "client.region"); Review Comment: AwsClientProperties.CLIENT_REGION -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected] --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
