comphead commented on code in PR #7026:
URL: https://github.com/apache/datafusion-comet/pull/7026#discussion_r4332013918
##########
spark/src/main/scala/org/apache/comet/objectstore/NativeConfig.scala:
##########
@@ -406,16 +406,32 @@ object NativeConfig {
* container and account of one URI and forwarded under global key names, so
a scan whose ABFS
* files span more than one container or account, or that mixes ABFS and
other schemes, is
* declined, and one that stays in a single container forwards the
resolution of its first file.
+ *
+ * An S3 scan is also declined when the native S3 client would resolve a
different configuration
+ * than Hadoop's `S3AFileSystem` for any of its buckets (see
[[S3ConfigGate]]), since the
+ * forwarded options would then make native read under a different effective
identity or
+ * endpoint than Spark would, silently. A caller whose own claim gate
already ran
+ * [[S3ConfigGate.declineReason]] over a superset of `uris` passes
`verifyS3Config = false`
+ * rather than pay for the check twice.
*/
def extractScanObjectStoreOptions(
hadoopConf: Configuration,
- uris: Iterable[URI]): Either[String, Map[String, String]] = {
+ uris: Iterable[URI],
+ verifyS3Config: Boolean = true): Either[String, Map[String, String]] = {
val iter = uris.iterator
if (!iter.hasNext) {
return Right(Map.empty)
}
val first = iter.next()
val firstIsAbfs = isAbfs(first)
+ // One representative URI per S3 bucket: the gate decides per bucket, so
the files of a large
+ // scan need not each be re-examined by every gate.
+ val s3Uris = scala.collection.mutable.LinkedHashMap.empty[String, URI]
+ def collectS3(uri: URI): Unit =
+ if (verifyS3Config) {
+ S3ConfigGate.s3Bucket(uri).foreach(bucket =>
s3Uris.getOrElseUpdate(bucket, uri))
Review Comment:
This collects `s3` and `s3a` URIs even when native reads them through
libhdfs. For a scheme listed in `fs.comet.libhdfs.schemes`, the store is built
with `create_hdfs_object_store` and only `fs.comet.*` options are kept, so none
of the `fs.s3a.*` settings this gate compares are read. Routing `s3a` through
libhdfs is how a deployment gets Hadoop's own `S3AFileSystem` for the settings
this gate declines (a proxy, SSE-C, an unrecognized provider class), and such
scans would now fall back to Spark. `extractObjectStoreOptions` further down
already treats libhdfs URIs separately through `objectStoreKey(...).isLibhdfs`.
Could this skip URIs whose scheme is in `resolveLibhdfsSchemes(hadoopConf)`,
with a `NativeConfigSuite` case using `fs.comet.libhdfs.schemes=s3a` and
`fs.s3a.proxy.host`? Not run.
##########
docs/source/user-guide/latest/datasources.md:
##########
@@ -424,7 +424,28 @@ Comet's S3 support has the following limitations:
2. **Custom credential providers**: Custom credential provider classes named
in `fs.s3a.aws.credentials.provider` are not supported; only the standard
providers listed in the table above are. To route credential requests through
your own Java code, implement Comet's `CometS3CredentialProvider` SPI; see [S3
Credential Providers](s3-credential-providers.md). Broader Hadoop S3A
integration is tracked in
[#1829](https://github.com/apache/datafusion-comet/issues/1829).
-3. **Settings that Hadoop's S3A client resolves differently**: The native
scans resolve storage settings with their own client rather than with Hadoop's.
Where the two disagree, a native read can use a different identity or route
than Spark and fail where Spark succeeds. Examples are JCEKS-backed secrets and
the precedence of per-bucket settings, which the native Parquet scan forwards
without checking for a difference
([#5662](https://github.com/apache/datafusion-comet/issues/5662)).
+3. **S3 settings that Hadoop and Comet resolve differently**: Comet does not
run Hadoop's `S3AFileSystem`, so some
+ settings take effect differently in the native client than they do for
Spark's reader. To keep a scan from
+ silently reading under a different identity or endpoint than Spark would,
Comet declines the scan, and Spark
Review Comment:
This reads as covering every native S3 read, but the gate only runs through
`extractScanObjectStoreOptions`, which covers the native Parquet, CSV and Delta
scans. The native Iceberg scan, native writes and opted-in
`fs.comet.s3Compliant.schemes` aliases still forward `fs.s3a.*` through
`extractObjectStoreOptions` without it. The old text kept that visible through
the #5662 link this edit removes, and #5662 asks for the check on every native
read. Could this item name the scans it covers and keep a link or a follow-up
issue for the rest?
##########
spark/src/main/scala/org/apache/comet/objectstore/S3ConfigGate.scala:
##########
@@ -0,0 +1,1250 @@
+/*
+ * 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.comet.objectstore
+
+import java.io.IOException
+import java.net.{URI, URISyntaxException}
+import java.util.Locale
+
+import scala.collection.mutable.{Map => MutableMap}
+import scala.jdk.CollectionConverters._
+
+import org.apache.hadoop.conf.Configuration
+
+/**
+ * Decides whether Comet's native S3 client would resolve the same S3
configuration as Hadoop's
+ * `S3AFileSystem` for the buckets a scan touches. The native client builds
its configuration from
+ * the options [[NativeConfig.extractObjectStoreOptions]] forwards, and
resolves them differently
+ * from Hadoop in several ways (`lookupPassword` bucket precedence, JCEKS
credential provider
+ * aliases, the clear text fallback, assumed role session policies, provider
class semantics,
+ * scheme-less endpoints). A scan that went native over such a table would
read under a different
+ * effective identity or endpoint than Spark would, silently. Each gate here
returns a decline
+ * reason for the scan to fall back to Spark, or `None` when native and Hadoop
agree.
+ *
+ * Shared by every native scan that forwards `fs.s3a.*` options (see
+ * [[NativeConfig.extractScanObjectStoreOptions]]) and by the Delta contrib's
own claim gates.
+ * Correctness rule: when in doubt, decline.
+ */
+object S3ConfigGate {
+
+ /**
+ * First reason the native S3 client would not resolve the same
configuration as Hadoop for any
+ * bucket among `uris`, or `None` when it would, or when no URI is an
`s3`/`s3a` one. The gates
+ * are ordered cheapest and most specific first. They share one
propagated-conf cache so each
+ * bucket's full `Configuration` deep copy is made at most once.
+ */
+ def declineReason(hadoopConf: Configuration, uris: Seq[URI]): Option[String]
= {
+ if (!uris.exists(uri => s3Bucket(uri).isDefined)) {
+ return None
+ }
+ val propagatedConfCache = MutableMap.empty[String, Configuration]
+ unsupportedEncryptionAlgorithmReason(hadoopConf, uris, propagatedConfCache)
+ .orElse(proxyGateReason(hadoopConf, uris, propagatedConfCache))
+ .orElse(assumedRolePolicyGateReason(hadoopConf, uris,
propagatedConfCache))
+ .orElse(hadoopOnlyEndpointGateReason(hadoopConf, uris,
propagatedConfCache))
+ .orElse(s3ConfigDivergenceReason(hadoopConf, uris, propagatedConfCache))
+ .orElse(providerClassGateReason(hadoopConf, uris))
+ }
+
+ /**
+ * String-literal Hadoop conf keys consulted below. `hadoop-aws` is NOT on
this module's runtime
+ * classpath, so `org.apache.hadoop.fs.s3a.Constants` must never be
referenced here (would raise
+ * `NoClassDefFoundError` for sessions with no S3 dependency).
+ */
+ private val HadoopCredentialProviderPathKey =
"hadoop.security.credential.provider.path"
+ private val S3aCredentialProviderPathKey =
"fs.s3a.security.credential.provider.path"
+
+ /**
+ *
`CommonConfigurationKeysPublic.HADOOP_SECURITY_CREDENTIAL_CLEAR_TEXT_FALLBACK`,
default
+ * `true`, verified via `javap` against `hadoop-common` 3.3.4's
+ * `Configuration#getPasswordFromConfig`: `getPassword` only falls back to
reading a plaintext
+ * conf value once `getBoolean(<this key>, true)` holds -- with the flag
off, a plaintext value
+ * is invisible to every `getPassword`-based resolver, even when no
credential provider is
+ * configured at all.
+ */
+ private val ClearTextFallbackKey =
"hadoop.security.credential.clear-text-fallback"
+
+ private def s3aBucketProviderPathKey(bucket: String): String =
+ s"fs.s3a.bucket.$bucket.security.credential.provider.path"
+
+ /**
+ * The LONG form of [[s3aBucketProviderPathKey]]: `S3AUtils#lookupPassword`
resolves per-bucket
+ * overrides through both a long key (`fs.s3a.bucket.B.<full base key>`) and
a short key; both
+ * must be covered here too.
+ */
+ private def s3aBucketLongProviderPathKey(bucket: String): String =
+ s"fs.s3a.bucket.$bucket.fs.s3a.security.credential.provider.path"
+
+ private def nonEmptyConf(hadoopConf: Configuration, key: String): Boolean =
+ Option(hadoopConf.get(key)).exists(_.nonEmpty)
+
+ /**
+ * The lowercase-scheme-checked S3/S3A bucket name from `uri`'s authority,
or `None` when
+ * `uri`'s scheme is not `s3`/`s3a`. Parses the raw authority manually
rather than
+ * `URI#getHost`, avoiding the RFC 3986 `reg-name` pitfall (`getHost`
returns `null` for the
+ * whole authority).
+ */
+ private[comet] def s3Bucket(uri: URI): Option[String] = {
+ val scheme = Option(uri.getScheme).map(_.toLowerCase(Locale.ROOT))
+ if (scheme.contains("s3") || scheme.contains("s3a")) {
+ val authority = Option(uri.getAuthority).getOrElse("")
+ val at = authority.lastIndexOf('@')
+ val hostAndPort = if (at >= 0) authority.substring(at + 1) else authority
+ val colon = hostAndPort.lastIndexOf(':')
+ val host = if (colon >= 0) hostAndPort.substring(0, colon) else
hostAndPort
+ if (host.isEmpty) None else Some(host)
+ } else {
+ None
+ }
+ }
+
+ private def plainValue(hadoopConf: Configuration, key: String):
Option[String] =
+ Option(hadoopConf.get(key)).filter(_.nonEmpty)
+
+ /**
+ * How Hadoop's OWN consumer reads one of the keys compared by
[[s3ConfigDivergenceReason]],
+ * which decides how [[s3KeyDivergenceReason]] computes the Hadoop-effective
side of its
+ * equality check. Exactly two consumer families exist among
[[AllS3ConfigKeys]] in `hadoop-aws`
+ * 3.3.4, each verified via `javap`/CFR against the real call sites (cited
per key on
+ * [[S3ConfigKeyConsumers]]). The tier must mirror the key's ACTUAL
consumer: resolving a
+ * [[PropagatedOptionConsumer]] key through the wider `lookupPassword`
cascade is NOT fail-safe
+ * for a value-EQUALITY comparator -- a long-form alias value Hadoop itself
never reads can
+ * EQUAL native's resolution while Hadoop's true propagate-then-plain-get
value differs, turning
+ * a real divergence into a wrongly-claimed scan (the endpoint
`${...}`-redirect shape pinned in
+ * `S3ConfigGateSuite`).
+ */
+ private[comet] sealed trait S3ConfigConsumer
+
+ /**
+ * Read via `S3AUtils#lookupPassword(bucket, conf, baseKey)`, verified via
`javap` against
+ * `hadoop-aws` 3.3.4: builds `longBucketKey = "fs.s3a.bucket." + bucket +
"." + baseKey` (the
+ * FULL, already-`fs.s3a`-prefixed base key appended after the bucket
segment) and reads it via
+ * `Configuration#getPassword` BEFORE the short-bucket key, keeping the long
value whenever
+ * `getPassword` returns non-empty and only falling through to
short-then-global otherwise.
+ * `getPassword` is Hadoop-credential-provider-aware and skips plaintext
conf entirely when
+ * [[ClearTextFallbackKey]] is false. Modeled by
[[hadoopLookupPasswordEffective]].
+ */
+ private[comet] case object LookupPasswordConsumer extends S3ConfigConsumer
+
+ /**
+ * Read via `S3AUtils#propagateBucketOptions` followed by a plain
`Configuration#get`-family
+ * call (`getTrimmed`/`getBoolean`/`getClasses`) against the propagated
view: the short bucket
+ * form wins only by having overwritten the global key during propagation,
the long bucket form
+ * folds into an unread `fs.s3a.fs.s3a.*` key, and neither a credential
provider nor
+ * [[ClearTextFallbackKey]] is ever consulted. Modeled as a plain
`Configuration#get` on the
+ * [[propagateBucketOptions]] result, which also expands `${...}` references
under that
+ * propagated view exactly like the real consumer.
+ */
+ private[comet] case object PropagatedOptionConsumer extends S3ConfigConsumer
+
+ /**
+ * Every `fs.s3a.*` base key that governs whether a claimed native scan
actually behaves like
+ * Hadoop's own reader would, paired with the consumer family Hadoop
resolves it through -- ONE
+ * list, with each key's resolution tier declared beside it, so a key can
never sit in the
+ * comparator without a deliberate classification (adding one without
picking a tier does not
+ * compile). The entries are every per-bucket `fs.s3a.*` base key native's
S3 client's
+ * `get_config` (s3.rs) resolves, verified directly against its call sites:
+ * `extract_s3_config_options` (endpoint.region, path.style.access, endpoint,
+ * requester.pays.enabled), `lookup_provider_class` (the Comet-specific
+ * credential-provider-class activation key), and
+ * `build_credential_provider`/`build_aws_credential_provider_metadata`/
+ * `build_assume_role_credential_provider_metadata`
(aws.credentials.provider,
+ * assumed.role.credentials.provider, assumed.role.arn,
assumed.role.session.name).
+ *
+ * Tier assignments, each verified via `javap`/CFR against `hadoop-aws`
3.3.4:
+ * - access.key/secret.key/session.token: `S3AUtils#getAWSAccessKeys` and
+ * `MarshalledCredentialBinding#fromFileSystem` (reached from
+ * `TemporaryAWSCredentialsProvider`) resolve all three via
`S3AUtils#lookupPassword` --
+ * [[LookupPasswordConsumer]].
+ * - aws.credentials.provider and assumed.role.credentials.provider:
+ * `S3AUtils#buildAWSProviderList` -> `loadAWSProviderClasses` -> plain
+ * `Configuration#getClasses` -- [[PropagatedOptionConsumer]].
+ * - assumed.role.arn/session.name: `AssumedRoleCredentialProvider`'s
constructor reads both
+ * via plain `Configuration#getTrimmed` -- [[PropagatedOptionConsumer]].
+ * - endpoint (`S3AFileSystem`: `getTrimmed`), endpoint.region
(`DefaultS3ClientFactory`:
+ * `getTrimmed`), path.style.access (`S3AFileSystem`: `getBoolean`) --
+ * [[PropagatedOptionConsumer]].
+ * - requester.pays.enabled: not read anywhere in `hadoop-aws` 3.3.4 (the
constant does not
+ * even exist in its `Constants` class); later releases read it via
plain `getBoolean`
+ * against the propagated conf, so the plain tier is both the faithful
forward model and
+ * inert on 3.3.4 -- [[PropagatedOptionConsumer]].
+ * - comet.credential.provider.class: Comet's own activation key, plain
conf read on both
+ * sides, never a Hadoop key at all -- [[PropagatedOptionConsumer]].
+ *
+ * SYNC NOTE: the key list must stay a superset of native's
`NATIVE_S3A_CONFIG_PROPERTIES`
+ * constant (`native/core/src/parquet/objectstore/s3.rs`, property suffixes
without the
+ * `fs.s3a.` prefix) -- `S3ConfigGateSuite`'s discovery-harness test asserts
this mechanically
+ * against [[AllS3ConfigKeys]]. A Rust test in `s3.rs` also parses this
`Seq(` block as text, so
+ * each entry must stay a single `"fs.s3a.<key>" -> <Tier>` literal pair.
Literal strings, not
+ * the [[AwsCredentialsProviderKey]] / [[AssumedRoleCredentialsProviderKey]]
vals declared
+ * below, purely to avoid a forward reference inside this `object` body;
kept textually
+ * identical to those two constants.
+ */
+ private[comet] val S3ConfigKeyConsumers: Seq[(String, S3ConfigConsumer)] =
Seq(
+ "fs.s3a.access.key" -> LookupPasswordConsumer,
+ "fs.s3a.secret.key" -> LookupPasswordConsumer,
+ "fs.s3a.session.token" -> LookupPasswordConsumer,
+ "fs.s3a.aws.credentials.provider" -> PropagatedOptionConsumer,
+ "fs.s3a.assumed.role.arn" -> PropagatedOptionConsumer,
+ "fs.s3a.assumed.role.session.name" -> PropagatedOptionConsumer,
+ "fs.s3a.assumed.role.credentials.provider" -> PropagatedOptionConsumer,
+ "fs.s3a.endpoint" -> PropagatedOptionConsumer,
+ "fs.s3a.endpoint.region" -> PropagatedOptionConsumer,
+ "fs.s3a.path.style.access" -> PropagatedOptionConsumer,
+ "fs.s3a.requester.pays.enabled" -> PropagatedOptionConsumer,
+ "fs.s3a.comet.credential.provider.class" -> PropagatedOptionConsumer)
+
+ /** The compared keys alone, in [[S3ConfigKeyConsumers]] order
(discovery-harness surface). */
+ private[comet] val AllS3ConfigKeys: Seq[String] =
S3ConfigKeyConsumers.map(_._1)
+
+ /**
+ * The short-bucket-then-global value resolved for `baseKey` under `bucket`
from `hadoopConf`,
+ * skipping an empty value at either alias exactly like [[plainValue]]. NOT
used by
+ * [[s3ConfigDivergenceReason]]/[[s3KeyDivergenceReason]] any more -- every
key checked there
+ * resolves per its declared [[S3ConfigKeyConsumers]] tier (see
[[s3KeyDivergenceReason]]),
+ * neither of which matches this function's read. This function's one
remaining caller is
+ * [[shortThenGlobalOrReason]], which reads provider-CLASS strings (from the
ORIGINAL,
+ * unpropagated conf) for name-support validation in [[providerClassReason]]/
+ * [[assumedRoleProviderClassReason]] -- by the time those run,
[[s3ConfigDivergenceReason]] has
+ * already proven Hadoop's and native's effective values agree for the same
key, so whichever of
+ * the two (equal) values this narrower read returns does not affect
correctness there. NEVER
+ * used to compute native's own effective value -- see
[[nativeShortThenGlobal]] for that.
+ */
+ private def shortThenGlobal(
+ hadoopConf: Configuration,
+ bucket: String,
+ baseKey: String): Option[String] = {
+ val shortKey = s"fs.s3a.bucket.$bucket." + baseKey.stripPrefix("fs.s3a.")
+ plainValue(hadoopConf, shortKey).orElse(plainValue(hadoopConf, baseKey))
+ }
+
+ /**
+ * The short-bucket-then-global value native's `get_config` (s3.rs) resolves
for `baseKey` under
+ * `bucket` from the ORIGINAL, unpropagated `hadoopConf` -- `NativeConfig
+ * .extractObjectStoreOptions` forwards `Configuration#get`'s substituted
value for every
+ * `fs.s3a.*` entry with no bucket-option propagation step of its own, so
the original conf is
+ * the right input here. Unlike [[shortThenGlobal]]/[[plainValue]], this
mirrors `get_config`
+ * faithfully: PRESENCE of the short-bucket key alone -- never its emptiness
-- decides whether
+ * native falls back to the global key (`get_config` is a plain
`HashMap::get`, which returns
+ * `Some` for a key explicitly set to `""`), so an explicitly empty or
whitespace-only
+ * short-bucket value resolves to `Some("")` here and never falls through to
global -- the
+ * OPPOSITE of Hadoop's own `getPassword`/`lookupPassword` semantics (see
+ * [[hadoopLookupPasswordEffective]]), which treat empty as absent and keep
trying the next
+ * alias. The ONLY function used to compute native's effective value in
+ * [[s3KeyDivergenceReason]].
+ *
+ * Deliberately does NOT apply `get_config_trimmed`'s `.trim()` here:
[[s3KeyDivergenceReason]]
+ * trims both this value and Hadoop's effective value together,
symmetrically, at the point they
+ * are compared, rather than one-sidedly here -- trimming only the native
side would flag a
+ * spurious divergence for a value neither side's whitespace actually
changes the behavior of
+ * once each side's own downstream parsing normalizes it (e.g. Hadoop's own
multi-line
+ * `fs.s3a.aws.credentials.provider` default, which both Hadoop and native
additionally trim per
+ * comma-separated entry after splitting), while a one-sided trim would make
an
+ * otherwise-identical default value look diverged for every bucket, never
claiming natively at
+ * all.
+ */
+ private def nativeShortThenGlobal(
+ hadoopConf: Configuration,
+ bucket: String,
+ baseKey: String): Option[String] = {
+ val shortKey = s"fs.s3a.bucket.$bucket." + baseKey.stripPrefix("fs.s3a.")
+ Option(hadoopConf.get(shortKey)).orElse(Option(hadoopConf.get(baseKey)))
+ }
+
+ /**
+ * Faithful in-memory replica of `S3AUtils#propagateBucketOptions`
(`hadoop-aws`), which
+ * `S3AFileSystem#initialize` calls FIRST, before any option or credential
is read:
+ * `Configuration conf = propagateBucketOptions(originalConf, bucket); ...;
setConf(conf);` --
+ * every subsequent `conf.get`/`getPassword` call in that filesystem
instance, including
+ * `${...}` variable substitution, resolves against this propagated view,
not the original conf.
+ * `hadoop-aws` is not on this module's runtime classpath (see the
string-literal-keys note
+ * above), so `S3AUtils#propagateBucketOptions` cannot be called directly;
this reproduces its
+ * logic verbatim using only `hadoop-common`'s `Configuration`:
+ * {{{
+ * public static Configuration propagateBucketOptions(Configuration source,
String bucket) {
+ * final String bucketPrefix = FS_S3A_BUCKET_PREFIX + bucket + '.';
+ * final Configuration dest = new Configuration(source);
+ * for (Map.Entry<String, String> entry : source) {
+ * final String key = entry.getKey();
+ * final String value = entry.getValue(); // the (unexpanded) value
+ * if (!key.startsWith(bucketPrefix) || bucketPrefix.equals(key))
continue;
+ * final String stripped = key.substring(bucketPrefix.length());
+ * if (stripped.startsWith("bucket.") || "impl".equals(stripped)) {
+ * // ignored
+ * } else {
+ * final String generic = FS_S3A_PREFIX + stripped;
+ * dest.set(generic, value, ...); // overwrites any existing global
value
+ * }
+ * }
+ * return dest;
+ * }
+ * }}}
+ * Note the LONG bucket form (`fs.s3a.bucket.B.fs.s3a.<key>`) folds to an
unread
+ * `fs.s3a.fs.s3a.<key>` key here too, exactly like the real method --
`stripped` already starts
+ * with `fs.s3a.` in that case, so prepending `fs.s3a.` again produces a key
nothing ever reads.
+ */
+ private def propagateBucketOptions(hadoopConf: Configuration, bucket:
String): Configuration = {
+ val bucketPrefix = s"fs.s3a.bucket.$bucket."
+ val dest = new Configuration(hadoopConf)
+ hadoopConf.iterator().asScala.foreach { entry =>
+ val key = entry.getKey
+ if (key.startsWith(bucketPrefix) && key != bucketPrefix) {
+ val stripped = key.substring(bucketPrefix.length)
+ if (!stripped.startsWith("bucket.") && stripped != "impl") {
+ dest.set(s"fs.s3a.$stripped", entry.getValue)
+ }
+ }
+ }
+ dest
+ }
+
+ /**
+ * Canonical and deprecated Hadoop S3A encryption-algorithm config keys,
verified via `javap`
+ * against `hadoop-aws` 3.3.4's `org.apache.hadoop.fs.s3a.Constants`:
`S3_ENCRYPTION_ALGORITHM =
+ * "fs.s3a.encryption.algorithm"` (canonical) and
`SERVER_SIDE_ENCRYPTION_ALGORITHM =
+ * "fs.s3a.server-side-encryption-algorithm"` (DEPRECATED -- note the hyphen
before "algorithm",
+ * unlike the corresponding `*.key` constants below, which both use a `.key`
suffix).
+ * `hadoop-aws` is NOT on this module's runtime classpath, so these stay
string literals, same
+ * rationale as [[HadoopCredentialProviderPathKey]].
+ */
+ private val S3EncryptionAlgorithmKey = "fs.s3a.encryption.algorithm"
+ private val DeprecatedS3EncryptionAlgorithmKey =
"fs.s3a.server-side-encryption-algorithm"
+
+ /**
+ * The exact strings `S3AEncryptionMethods#getMethod` accepts, verified via
`javap`/CFR against
+ * `hadoop-aws` 3.3.4's `S3AEncryptionMethods` enum: `NONE("")`,
`SSE_S3("AES256", serverSide =
+ * true, requiresSecret = false)`, `SSE_KMS("SSE-KMS", serverSide = true,
requiresSecret =
+ * false)`, `SSE_C("SSE-C", serverSide = true, requiresSecret = true)`,
`CSE_KMS("CSE-KMS",
+ * serverSide = false, requiresSecret = true)`, `CSE_CUSTOM("CSE-CUSTOM",
serverSide = false,
+ * requiresSecret = true)`. `getMethod` parses case-insensitively
+ * (`values().find(_.getMethod.equalsIgnoreCase(algorithm))`), matched below
the same way.
+ *
+ * ALLOWLIST, not a blocklist (replaces the former SSE-C-only blocklist):
only the algorithms S3
+ * decrypts transparently on GET/HEAD given read permission alone, with NO
extra request header
+ * and NO client-side step, are safe for a native scan that forwards none of
Hadoop's
+ * `fs.s3a.encryption.*`/`fs.s3a.server-side-encryption*` options --
+ * - `AES256` (SSE_S3, `serverSide = true`): plain server-side encryption,
transparent on GET.
+ * - `SSE-KMS` (SSE_KMS, `serverSide = true`): server-side, KMS-managed
key, transparent on
+ * GET given KMS decrypt permission (no header).
+ * - `DSSE-KMS`: NOT present in this enum on `hadoop-aws` 3.3.4 (confirmed
by the six values
+ * listed above) -- `S3AEncryptionMethods.getMethod("DSSE-KMS")` throws
+ * `IOException("Unknown encryption algorithm DSSE-KMS")` on this
version, so
+ * `S3AUtils#buildEncryptionSecrets` (and therefore Hadoop's own reader)
already fails
+ * before ever reading such a table under 3.3.4, meaning this string can
never actually be
+ * the resolved value on the declared target version -- admitting it
here is inert there.
+ * Included anyway, forward-compatible, for a newer `hadoop-aws` on the
runtime classpath (a
+ * later Hadoop release; this module has no compile-time `hadoop-aws`
dependency, see the
+ * string-literal-keys note above) where DSSE-KMS is a real, dual-layer,
server-side
+ * algorithm decrypted transparently on GET the same way SSE-KMS is.
Every other value
+ * declines: `SSE-C` (SSE_C is `serverSide = true` in Hadoop's own enum,
but `requiresSecret
+ * \= true` -- S3 rejects a GET/HEAD for an SSE-C object outright (400
Bad Request) unless
+ * the customer key is resent as a request header on every call, so a
native scan that never
+ * learns the key cannot succeed at all, where Hadoop's own reader --
whose request factory
+ * attaches the key -- would), `CSE-KMS`/`CSE-CUSTOM` (`serverSide =
false`: client-side
+ * encryption decrypts object bytes locally in the SDK layer, which the
native Parquet
+ * reader has no equivalent of -- it would read raw ciphertext), and any
future/unknown
+ * value (a value `S3AEncryptionMethods.getMethod` itself would reject
is certainly not one
+ * of the three confirmed-transparent algorithms above; declining is the
only safe default
+ * for anything this gate cannot positively confirm).
+ */
+ private val AllowedEncryptionAlgorithms: Set[String] = Set("AES256",
"SSE-KMS", "DSSE-KMS")
+
+ /**
+ * `bucket`'s effective encryption-algorithm key and value under
`hadoopConf`, or `None` when
+ * neither the canonical nor deprecated key is set anywhere consulted.
Mirrors
+ * `S3AUtils#buildEncryptionSecrets`'s real resolution order, verified via
`javap`/CFR
+ * decompilation of `hadoop-aws` 3.3.4's `S3AUtils.class`:
+ * {{{
+ * String algorithm = lookupBucketSecret(bucket, conf,
"fs.s3a.encryption.algorithm");
+ * if (algorithm == null)
+ * algorithm = lookupBucketSecret(bucket, conf,
"fs.s3a.server-side-encryption-algorithm");
+ * if (algorithm == null)
+ * algorithm = lookupPassword(null, conf, "fs.s3a.encryption.algorithm");
+ * if (algorithm == null)
+ * algorithm = lookupPassword(null, conf,
"fs.s3a.server-side-encryption-algorithm");
+ * }}}
+ * i.e. bucket-tier (canonical, then deprecated), THEN global-tier
(canonical, then deprecated)
+ * -- the two tiers are never interleaved key-by-key, so this must stay two
explicit bucket-tier
+ * lookups followed by two explicit global-tier lookups, not a single
+ * [[hadoopLookupPasswordEffective]] call per key (which would let an unset
canonical bucket key
+ * fall through straight to the canonical GLOBAL value ahead of a SET
deprecated bucket key, the
+ * wrong answer).
+ *
+ * The three-argument `lookupPassword` defaults to `""`, not `null`, so the
last call in the
+ * block above never runs: Hadoop reads the deprecated global key only
through `Configuration`'s
+ * deprecation alias of the canonical one. This function still reads it as a
fourth lookup,
+ * which can only add a decline.
+ *
+ * THE FIX for the SSE-C long-bucket-alias gap is entirely inside the bucket
tier:
+ * `lookupBucketSecret` itself is long-then-short, decompiled from
`hadoop-aws` 3.3.4's
+ * `S3AUtils.class`:
+ * {{{
+ * // longBucketKey = fs.s3a.bucket.B.fs.s3a.<key>
+ * String longBucketKey = String.format(BUCKET_PATTERN, bucket, baseKey);
+ * String initialVal = getPassword(conf, longBucketKey, null, null);
+ * // shortBucketKey = fs.s3a.bucket.B.<key>
+ * String shortBucketKey = String.format(BUCKET_PATTERN, bucket, subkey);
+ * // keeps initialVal (the LONG value) if non-empty
+ * return getPassword(conf, shortBucketKey, initialVal, null);
+ * }}}
+ * i.e. the SAME long-bucket-key construction and long-wins-if-nonempty
semantics as
+ * `S3AUtils#lookupPassword` (see
[[LookupPasswordConsumer]]/[[hadoopLookupPasswordEffective]]).
+ * `buildEncryptionSecrets` runs on the [[propagateBucketOptions]] view, so
+ * [[unsupportedEncryptionAlgorithmReason]] calls this on that view (and on
the raw conf, which
+ * can only add a decline). An earlier version of this function modeled the
bucket tier as
+ * SHORT-only, documented as "the LONG bucket form is genuinely never
consulted for this key" --
+ * that documentation was wrong (this decompilation supersedes it): a bucket
configured only via
+ * `fs.s3a.bucket.B.fs.s3a.encryption.algorithm=SSE-C` bypassed the SSE-C
gate entirely, because
+ * Hadoop's own reader DOES read that long form (and picks SSE-C), while
this function reported
+ * `None` (nothing set) and the allowlist check below never even ran.
+ *
+ * The canonical-vs-deprecated distinction below is frequently moot in
practice: `hadoop-aws`'s
+ * `S3AFileSystem.addDeprecatedKeys()` statically registers
`fs.s3a.server-side-encryption-*` as
+ * `Configuration`-level deprecated aliases of `fs.s3a.encryption.*`
(verified via `javap`), a
+ * registration that lives in a static field on Hadoop's `Configuration`
class -- process-wide
+ * once `S3AFileSystem`'s class has loaded anywhere in the JVM, which a real
scan has always
+ * already done by the time this gate runs, since reading the S3 table at
all requires loading
+ * that class. Once active, `Configuration#get` resolves either literal key
to the identical
+ * value transparently, making the two-key cascade below redundant (but
harmless) for that case;
+ * it remains the operative path only when nothing else in the process has
loaded
+ * `S3AFileSystem` yet.
+ *
+ * ALSO walks the Hadoop-credential-provider (JCEKS) path via
[[resolveViaCredentialAliases]]
+ * for each of the four lookups below, matching
`lookupBucketSecret`/`lookupPassword`'s real
+ * per-alias `getPassword` calls (quoted above) exactly: both are
`getPassword`, not plain
+ * `Configuration#get`, so a bucket storing the algorithm name ONLY in a
JCEKS keystore is
+ * exactly as real a Hadoop deployment shape for this key as it is for the
credential keys
+ * [[hadoopLookupPasswordEffective]] already covers -- there is nothing
algorithm-specific that
+ * makes JCEKS storage implausible here, so an earlier version of this
function skipping it
+ * (documented at the time as "the algorithm NAME is not
credential-sensitive data, so storing
+ * it in a keystore is not a realistic Hadoop deployment pattern") was an
unjustified, narrower
+ * read than Hadoop's own resolver actually performs, under-declining a
bucket whose algorithm
+ * is keystore-only. [[resolveViaCredentialAliases]]'s Arm B/C split still
means this is zero
+ * extra I/O for the common case: keystore I/O only happens when a Hadoop
credential-provider
+ * path is actually configured for the bucket, contained in that function's
own try/catch.
+ * `bucketTier`/`globalTier` return `Left` (propagated straight through by
[[orElseTier]]) when
+ * [[resolveViaCredentialAliases]] cannot safely verify a tier at all (an
S3A-scoped provider
+ * path, or a corrupt/unreadable global keystore) -- correctly
short-circuiting the whole
+ * cascade with a decline rather than silently falling through to a later
tier that might look
+ * unset only because the true value was unverifiable.
+ */
+ private def effectiveEncryptionAlgorithm(
+ hadoopConf: Configuration,
+ bucket: String): Either[String, Option[(String, String)]] = {
+ def bucketTier(baseKey: String): Either[String, Option[(String, String)]]
= {
+ val longKey = s"fs.s3a.bucket.$bucket.$baseKey"
+ val shortKey = s"fs.s3a.bucket.$bucket." + baseKey.stripPrefix("fs.s3a.")
+ resolveViaCredentialAliases(hadoopConf, bucket, Seq(longKey, shortKey))
+ .map(_.map(baseKey -> _))
+ }
+ def globalTier(baseKey: String): Either[String, Option[(String, String)]] =
+ resolveViaCredentialAliases(hadoopConf, bucket, Seq(baseKey))
+ .map(_.map(baseKey -> _))
+
+ // Short-circuits on Left (unverifiable tier) or Right(Some(_))
(resolved); only Right(None)
+ // (tier definitively unset) falls through to `next`, mirroring
buildEncryptionSecrets's
+ // sequential `if (algorithm == null) algorithm = ...` cascade exactly.
+ def orElseTier(
+ current: Either[String, Option[(String, String)]],
+ next: => Either[String, Option[(String, String)]])
+ : Either[String, Option[(String, String)]] =
+ current match {
+ case Left(reason) => Left(reason)
+ case Right(Some(value)) => Right(Some(value))
+ case Right(None) => next
+ }
+
+ orElseTier(
+ bucketTier(S3EncryptionAlgorithmKey),
+ orElseTier(
+ bucketTier(DeprecatedS3EncryptionAlgorithmKey),
+ orElseTier(
+ globalTier(S3EncryptionAlgorithmKey),
+ // Hadoop reaches this key only through the deprecation alias (see
above); reading
+ // it directly can only over-decline.
+ globalTier(DeprecatedS3EncryptionAlgorithmKey))))
+ }
+
+ /**
+ * The only algorithm values a decline reason may print. Any other resolved
value is withheld,
+ * since a `${...}` reference can expand the algorithm key to a customer key
or token.
+ */
+ private val PrintableEncryptionAlgorithms: Seq[String] =
+ Seq("AES256", "SSE-S3", "SSE-KMS", "DSSE-KMS", "SSE-C", "CSE-KMS",
"CSE-CUSTOM")
+
+ private def unsupportedEncryptionAlgorithmDeclineReason(
+ bucket: String,
+ algorithmKey: String,
+ algorithm: String): String = {
+ val setting = PrintableEncryptionAlgorithms
+ .find(_.equalsIgnoreCase(algorithm))
+ .map(name => s"$algorithmKey=$name")
+ .getOrElse(s"$algorithmKey set to a value that is not a recognised S3
encryption algorithm")
+ s"Native scan does not support $setting for $bucket " +
+ "(the native S3 client only supports unencrypted objects and S3's
transparent " +
+ "server-side algorithms -- AES256/SSE-S3, SSE-KMS, and DSSE-KMS decrypt
on GET/HEAD given " +
+ "read permission alone, with no extra request header; SSE-C additionally
requires the " +
+ "customer-provided key resent as a header on every GET/HEAD request,
which the native S3 " +
+ "client's extract_s3_config_options never forwards, and
CSE-KMS/CSE-CUSTOM decrypt object " +
+ "bytes client-side, a layer the native Parquet reader does not have --
any of these would " +
+ "fail outright or silently read ciphertext where Hadoop's own reader
succeeds)"
+ }
+
+ /**
+ * First reason any bucket among `uris` is configured for an encryption
algorithm the native S3
+ * client cannot safely read, or `None` when claimable. Allowlist-based (see
+ * [[AllowedEncryptionAlgorithms]]): only `AES256`/`SSE-KMS`/`DSSE-KMS` (and
unset/empty) pass;
+ * every other resolved value -- `SSE-C`, `CSE-KMS`, `CSE-CUSTOM`, or any
unrecognized future
+ * algorithm string -- declines. Deliberately NOT a blocklist keyed on
`SSE-C` alone: an
+ * allowlist is safe by construction against a Hadoop release adding a new
encryption method
+ * this gate has never heard of, where a blocklist would silently admit it.
Never interpolates a
+ * resolved key value, only key names, the bucket, and the (non-secret)
algorithm name. Declines
+ * on a `Left` from [[effectiveEncryptionAlgorithm]] too (an unverifiable
credential-provider
+ * arm, e.g. an S3A-scoped provider path or a corrupt/unreadable global
keystore) -- the
+ * algorithm cannot be ruled safe when it cannot be read at all.
+ */
+ private[comet] def unsupportedEncryptionAlgorithmReason(
+ hadoopConf: Configuration,
+ uris: Seq[URI],
+ propagatedConfCache: MutableMap[String, Configuration] =
MutableMap.empty)
+ : Option[String] = {
+ val buckets = uris.flatMap(s3Bucket).distinct
+ buckets.foldLeft(Option.empty[String]) { (declined, bucket) =>
+ if (declined.isDefined) {
+ declined
+ } else {
+ try {
+ // Hadoop resolves the algorithm on the propagated view, where a
bucket override can
+ // change what a `${...}` reference expands to. The raw view is
checked as well, so a
+ // disagreement between the two can only decline.
+ val propagatedConf =
+ propagatedConfCache.getOrElseUpdate(
+ bucket,
+ propagateBucketOptions(hadoopConf, bucket))
+ encryptionAlgorithmReason(propagatedConf, bucket)
+ .orElse(encryptionAlgorithmReason(hadoopConf, bucket))
+ } catch {
+ case e @ (_: IOException | _: RuntimeException) =>
+ Some(unverifiableValueReason(bucket, S3EncryptionAlgorithmKey, e))
+ }
+ }
+ }
+ }
+
+ private def encryptionAlgorithmReason(conf: Configuration, bucket: String):
Option[String] =
+ effectiveEncryptionAlgorithm(conf, bucket) match {
+ case Left(reason) => Some(reason)
+ case Right(None) => None
+ case Right(Some((key, value))) =>
+ if (!AllowedEncryptionAlgorithms.exists(_.equalsIgnoreCase(value))) {
+ Some(unsupportedEncryptionAlgorithmDeclineReason(bucket, key, value))
+ } else {
+ None
+ }
+ }
+
+ /**
+ * Canonical Hadoop S3A HTTP-proxy host config key, verified via CFR
decompilation of
+ * `hadoop-aws` 3.3.4's `S3AUtils.class` (`initProxySupport`):
+ * {{{
+ * String proxyHost = conf.getTrimmed("fs.s3a.proxy.host", "");
+ * int proxyPort = conf.getInt("fs.s3a.proxy.port", -1);
+ * if (!proxyHost.isEmpty()) {
+ * ...
+ * String proxyUsername =
+ * S3AUtils.lookupPassword(bucket, conf, "fs.s3a.proxy.username",
null, null);
+ * String proxyPassword =
+ * S3AUtils.lookupPassword(bucket, conf, "fs.s3a.proxy.password",
null, null);
+ * ...
+ * }
+ * }}}
+ * `fs.s3a.proxy.host`/`fs.s3a.proxy.port` resolve via a PLAIN,
non-bucket-scoped, non-JCEKS
+ * `Configuration#getTrimmed`/`getInt` call -- NOT `lookupPassword` --
against whatever conf
+ * `S3AFileSystem#initialize` already ran through `propagateBucketOptions`
before
+ * `createAwsConf`/`initProxySupport` ever runs; only the SIBLING
`fs.s3a.proxy.username`/
+ * `fs.s3a.proxy.password` keys go through `lookupPassword` (bucket
long/short/global,
+ * JCEKS-aware). So the host is bucket-aware only via
`propagateBucketOptions`'s short-bucket-
+ * form folding, never the long-bucket form, and never a credential-provider
read -- the SAME
+ * shape as `endpoint`/`path.style.access` ([[PropagatedOptionConsumer]], see
+ * [[S3ConfigKeyConsumers]]'s doc), not the credential family. `hadoop-aws`
3.4.x (the Spark 4.x
+ * profiles' version) moves this code to
`AWSClientConfig#createProxyConfiguration`/
+ * `#createAsyncProxyConfiguration` but keeps the exact same reads, verified
via `javap` against
+ * 3.4.2: `conf.getTrimmed("fs.s3a.proxy.host", "")` for the host,
`S3AUtils.lookupPassword` for
+ * username/password only.
+ *
+ * [[proxyGateReason]] therefore resolves the host EXACTLY like its real
consumer -- plain
+ * `Configuration#getTrimmed` on the [[propagateBucketOptions]] result, no
provider arms, no
+ * [[ClearTextFallbackKey]] handling -- rather than through the wider
`lookupPassword` cascade
+ * an earlier version reused here. The wider cascade was wrong in BOTH
directions for this key:
+ * with only the GLOBAL Hadoop provider path set and
[[ClearTextFallbackKey]] false,
+ * `getPassword` hides a plaintext host that `getTrimmed` serves to Hadoop
anyway (a missed
+ * decline, the exact bypass this gate exists to close -- native has NO
HTTP-proxy support of
+ * any kind, no `fs.s3a.proxy.*` key is read anywhere in `s3.rs`); and an
S3A-scoped provider
+ * path or a lone long-form bucket alias declined a bucket whose real
consumer can never see a
+ * host from either source (pure over-refusal -- no keystore can supply the
host to a plain
+ * `getTrimmed`, and the long form folds into the unread
`fs.s3a.fs.s3a.proxy.host`).
+ */
+ private val S3ProxyHostKey = "fs.s3a.proxy.host"
+
+ private def unsupportedProxyReason(bucket: String, key: String): String =
+ s"Native scan does not support $key configured for $bucket (the native S3
client has " +
+ "no HTTP proxy support at all -- no fs.s3a.proxy.* key is read anywhere
in its object " +
+ "store layer -- so a claimed scan would connect to S3 directly instead
of routing through " +
+ "the configured proxy, either bypassing an egress/network-segmentation
policy or simply " +
+ "failing to reach the endpoint)"
+
+ /**
+ * First reason any bucket among `uris` has an HTTP proxy configured via
[[S3ProxyHostKey]], or
+ * `None` when claimable. Reads the host EXACTLY like its real consumer (see
+ * [[S3ProxyHostKey]]'s doc): plain `Configuration#getTrimmed` on the
[[propagateBucketOptions]]
+ * result, so this gate is always zero-I/O -- no credential provider is ever
consulted for the
+ * host, because none ever supplies it to Hadoop either. Ordered alongside
+ * [[unsupportedEncryptionAlgorithmReason]] among the conf-only gates, ahead
of
+ * [[s3ConfigDivergenceReason]]. The try/catch guards `Configuration#get`'s
+ * `IllegalStateException` on a `${...}` substitution cycle, same as
[[s3KeyDivergenceReason]].
+ * Never interpolates a resolved value: the proxy HOST is not secret, but
naming it here would
+ * be a strange place to first surface it, and proxy CREDENTIALS
+ * (`fs.s3a.proxy.username`/`fs.s3a.proxy.password`, not read by this gate
at all -- the whole
+ * point of gating on the host is that a non-empty host declines before any
proxy credential
+ * would ever need to be forwarded) must never appear in a decline reason
regardless.
+ */
+ private[comet] def proxyGateReason(
+ hadoopConf: Configuration,
+ uris: Seq[URI],
+ propagatedConfCache: MutableMap[String, Configuration] =
MutableMap.empty)
+ : Option[String] = {
+ val buckets = uris.flatMap(s3Bucket).distinct
+ buckets.foldLeft(Option.empty[String]) { (declined, bucket) =>
+ if (declined.isDefined) {
+ declined
+ } else {
+ try {
+ val propagatedConf =
+ propagatedConfCache.getOrElseUpdate(
+ bucket,
+ propagateBucketOptions(hadoopConf, bucket))
+ if (propagatedConf.getTrimmed(S3ProxyHostKey, "").nonEmpty) {
+ Some(unsupportedProxyReason(bucket, S3ProxyHostKey))
+ } else {
+ None
+ }
+ } catch {
+ case e @ (_: IOException | _: RuntimeException) =>
+ Some(unverifiableValueReason(bucket, S3ProxyHostKey, e))
+ }
+ }
+ }
+ }
+
+ /**
+ * Canonical Hadoop S3A assumed-role session-policy key.
`AssumedRoleCredentialProvider`'s
+ * constructor reads it via a plain `Configuration#getTrimmed` against the
+ * [[propagateBucketOptions]]-propagated conf (verified via `javap` against
`hadoop-aws` 3.3.4
+ * and 3.4.1: `conf.getTrimmed("fs.s3a.assumed.role.policy", "")` -- the
same consumer shape as
+ * `assumed.role.arn`/`session.name`, see [[S3ConfigKeyConsumers]]) and,
when non-empty,
+ * attaches it as the session policy of its STS AssumeRole request.
+ */
+ private val S3AssumedRolePolicyKey = "fs.s3a.assumed.role.policy"
+
+ private def assumedRolePolicyReason(bucket: String, key: String): String =
+ s"Native scan does not support $key configured for $bucket (Hadoop sends
the " +
+ "configured session policy in its STS AssumeRole request, but the native
S3 client's " +
+ "assumed-role provider never reads or forwards this key, so a claimed
scan would " +
+ "assume the role WITHOUT the configured session restriction -- silently
widening the " +
+ "effective permissions instead of failing)"
+
+ /**
+ * First reason any bucket among `uris` configures an assumed-role session
policy via
+ * [[S3AssumedRolePolicyKey]], or `None` when claimable. Hadoop's
+ * `AssumedRoleCredentialProvider` includes the policy in its AssumeRole
request; native's
+ * assumed-role provider does not, so a configured policy must decline until
native supports it.
+ * Resolved exactly like the key's real consumer (plain `getTrimmed` on the
propagated conf,
+ * mirroring [[proxyGateReason]]); declined whenever set, whether or not the
current provider
+ * chain names the assumed-role provider -- a policy that is dead config
today can become live
+ * through a provider-chain change native never re-validates. Never
interpolates the policy
+ * document itself, only the key and bucket.
+ */
+ private[comet] def assumedRolePolicyGateReason(
+ hadoopConf: Configuration,
+ uris: Seq[URI],
+ propagatedConfCache: MutableMap[String, Configuration] =
MutableMap.empty)
+ : Option[String] = {
+ val buckets = uris.flatMap(s3Bucket).distinct
+ buckets.foldLeft(Option.empty[String]) { (declined, bucket) =>
+ if (declined.isDefined || cometProviderClassConfigured(hadoopConf,
bucket)) {
+ declined
+ } else {
+ try {
+ val propagatedConf =
+ propagatedConfCache.getOrElseUpdate(
+ bucket,
+ propagateBucketOptions(hadoopConf, bucket))
+ if (propagatedConf.getTrimmed(S3AssumedRolePolicyKey, "").nonEmpty) {
+ Some(assumedRolePolicyReason(bucket, S3AssumedRolePolicyKey))
+ } else {
+ None
+ }
+ } catch {
+ case e @ (_: IOException | _: RuntimeException) =>
+ Some(unverifiableValueReason(bucket, S3AssumedRolePolicyKey, e))
+ }
+ }
+ }
+ }
+
+ /** Hadoop's S3A SSL switch; `S3AFileSystem` reads it via
`Configuration#getBoolean`. */
+ private val S3SslEnabledKey = "fs.s3a.connection.ssl.enabled"
+ private val S3EndpointKey = "fs.s3a.endpoint"
+
+ /**
+ * Hadoop's assumed-role STS endpoint keys; `AssumedRoleCredentialProvider`
sends AssumeRole to
+ * the configured endpoint, while native builds its provider with SDK
defaults.
+ */
+ private val S3StsEndpointKeys =
+ Seq("fs.s3a.assumed.role.sts.endpoint",
"fs.s3a.assumed.role.sts.endpoint.region")
+
+ private def insecureEndpointReason(bucket: String): String =
+ s"Native scan does not support a scheme-less $S3EndpointKey with " +
+ s"$S3SslEnabledKey=false for $bucket (Hadoop addresses that endpoint
over http://, " +
+ "while the native S3 client always assumes https://, so a claimed scan
would fail at " +
+ "execution where Spark reads fine)"
+
+ private def stsEndpointReason(bucket: String, key: String): String =
+ s"Native scan does not support $key configured for $bucket (Hadoop sends
its " +
+ "AssumeRole request to the configured STS endpoint, while the native S3
client's " +
+ "assumed-role provider uses the SDK defaults, so the two sides would
authenticate " +
+ "against different endpoints)"
+
+ /**
+ * First reason any bucket among `uris` configures an endpoint native would
address differently
+ * from Hadoop: a scheme-less `fs.s3a.endpoint` with SSL disabled, or an
assumed-role STS
+ * endpoint. Resolved like the keys' real consumers (plain reads on the
propagated conf,
+ * mirroring [[proxyGateReason]]); the STS keys decline whenever set, since
dead config can
+ * become live through a provider-chain change native never re-validates.
+ */
+ private[comet] def hadoopOnlyEndpointGateReason(
+ hadoopConf: Configuration,
+ uris: Seq[URI],
+ propagatedConfCache: MutableMap[String, Configuration] =
MutableMap.empty)
+ : Option[String] = {
+ val buckets = uris.flatMap(s3Bucket).distinct
+ buckets.foldLeft(Option.empty[String]) { (declined, bucket) =>
+ if (declined.isDefined) {
+ declined
+ } else {
+ try {
+ val propagatedConf =
+ propagatedConfCache.getOrElseUpdate(
+ bucket,
+ propagateBucketOptions(hadoopConf, bucket))
+ val endpoint = propagatedConf.getTrimmed(S3EndpointKey, "")
+ val schemeless = endpoint.nonEmpty && !endpoint.contains("://")
+ if (schemeless && !propagatedConf.getBoolean(S3SslEnabledKey, true))
{
+ Some(insecureEndpointReason(bucket))
+ } else {
+ S3StsEndpointKeys
+ .find(key => propagatedConf.getTrimmed(key, "").nonEmpty)
Review Comment:
The SPI exemption added to `assumedRolePolicyGateReason` and
`providerClassGateReason` (through `cometProviderClassConfigured`) seems to
apply equally to the STS endpoint keys, but this check still declines them.
When `fs.s3a.comet.credential.provider.class` resolves for a bucket,
`create_store` in `s3.rs` takes the `CometS3CredentialBridge` branch and never
calls `build_credential_provider`, the native assumed-role provider that
ignores `fs.s3a.assumed.role.sts.endpoint`.
`HadoopS3ACredentialProviderAdapter` builds Hadoop's own provider chain over
the forwarded `fs.s3a.*` keys, so AssumeRole should go to the configured
endpoint. With this change a regular Parquet scan using that setup plus an STS
endpoint key falls back to Spark, where on main it runs natively. Could the STS
check skip buckets where `cometProviderClassConfigured` is true, keeping the
scheme-less endpoint check, which applies to the store itself? A test next to
`assumedRolePolicyGateReason admits a session policy
when the Comet SPI is set` would pin it. I read this from the sources and did
not run it.
--
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]