This is an automated email from the ASF dual-hosted git repository.

adutra pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/polaris.git


The following commit(s) were added to refs/heads/main by this push:
     new 4c1433d9 Ability to read root credentials from file when bootstrapping 
(#940)
4c1433d9 is described below

commit 4c1433d9ce10737d17ab9b78eee6c9a4cfcda614
Author: Alexandre Dutra <[email protected]>
AuthorDate: Tue Feb 11 13:49:14 2025 +0100

    Ability to read root credentials from file when bootstrapping (#940)
---
 .../EclipseLinkPolarisMetaStoreManagerFactory.java |   6 +-
 .../LocalPolarisMetaStoreManagerFactory.java       |  18 +--
 .../core/persistence/MetaStoreManagerFactory.java  |   3 +-
 .../persistence/PolarisCredentialsBootstrap.java   | 115 ----------------
 .../persistence/PrincipalSecretsGenerator.java     |  37 ++++-
 .../persistence/bootstrap/RootCredentials.java     |  50 +++++++
 .../persistence/bootstrap/RootCredentialsSet.java  | 153 +++++++++++++++++++++
 .../PolarisCredentialsBootstrapTest.java           | 113 ---------------
 .../persistence/PrincipalSecretsGeneratorTest.java |   3 +-
 .../bootstrap/RootCredentialsSetTest.java          | 137 ++++++++++++++++++
 .../persistence/bootstrap/credentials-invalid.json |   3 +
 .../persistence/bootstrap/credentials-invalid.yaml |  20 +++
 .../core/persistence/bootstrap/credentials.json    |  11 ++
 .../core/persistence/bootstrap/credentials.yaml    |  27 ++++
 .../org/apache/polaris/admintool/BaseCommand.java  |   1 +
 .../apache/polaris/admintool/BootstrapCommand.java | 116 +++++++++++-----
 .../polaris/admintool/BootstrapCommandTest.java    |  92 ++++++++++++-
 .../apache/polaris/admintool/PurgeCommandTest.java |  26 +++-
 .../org/apache/polaris/admintool/credentials.json  |  10 ++
 .../org/apache/polaris/admintool/credentials.yaml  |  25 ++++
 .../quarkus/catalog/BasePolarisCatalogTest.java    |   4 +-
 .../test/PolarisIntegrationTestFixture.java        |   4 +-
 .../InMemoryPolarisMetaStoreManagerFactory.java    |  15 +-
 23 files changed, 685 insertions(+), 304 deletions(-)

diff --git 
a/extension/persistence/eclipselink/src/main/java/org/apache/polaris/extension/persistence/impl/eclipselink/EclipseLinkPolarisMetaStoreManagerFactory.java
 
b/extension/persistence/eclipselink/src/main/java/org/apache/polaris/extension/persistence/impl/eclipselink/EclipseLinkPolarisMetaStoreManagerFactory.java
index 5e27dddc..a68fa783 100644
--- 
a/extension/persistence/eclipselink/src/main/java/org/apache/polaris/extension/persistence/impl/eclipselink/EclipseLinkPolarisMetaStoreManagerFactory.java
+++ 
b/extension/persistence/eclipselink/src/main/java/org/apache/polaris/extension/persistence/impl/eclipselink/EclipseLinkPolarisMetaStoreManagerFactory.java
@@ -29,9 +29,9 @@ import org.apache.polaris.core.PolarisConfigurationStore;
 import org.apache.polaris.core.PolarisDiagnostics;
 import org.apache.polaris.core.context.RealmContext;
 import org.apache.polaris.core.persistence.LocalPolarisMetaStoreManagerFactory;
-import org.apache.polaris.core.persistence.PolarisCredentialsBootstrap;
 import org.apache.polaris.core.persistence.PolarisMetaStoreManager;
 import org.apache.polaris.core.persistence.PolarisMetaStoreSession;
+import org.apache.polaris.core.persistence.bootstrap.RootCredentialsSet;
 import org.apache.polaris.core.storage.PolarisStorageIntegrationProvider;
 
 /**
@@ -72,7 +72,7 @@ public class EclipseLinkPolarisMetaStoreManagerFactory
   protected PolarisMetaStoreSession createMetaStoreSession(
       @Nonnull PolarisEclipseLinkStore store,
       @Nonnull RealmContext realmContext,
-      @Nullable PolarisCredentialsBootstrap credentialsBootstrap,
+      @Nullable RootCredentialsSet rootCredentialsSet,
       @Nonnull PolarisDiagnostics diagnostics) {
     return new PolarisEclipseLinkMetaStoreSessionImpl(
         store,
@@ -80,7 +80,7 @@ public class EclipseLinkPolarisMetaStoreManagerFactory
         realmContext,
         configurationFile(),
         persistenceUnitName(),
-        secretsGenerator(realmContext, credentialsBootstrap),
+        secretsGenerator(realmContext, rootCredentialsSet),
         diagnostics);
   }
 
diff --git 
a/polaris-core/src/main/java/org/apache/polaris/core/persistence/LocalPolarisMetaStoreManagerFactory.java
 
b/polaris-core/src/main/java/org/apache/polaris/core/persistence/LocalPolarisMetaStoreManagerFactory.java
index 59d326dd..fe809949 100644
--- 
a/polaris-core/src/main/java/org/apache/polaris/core/persistence/LocalPolarisMetaStoreManagerFactory.java
+++ 
b/polaris-core/src/main/java/org/apache/polaris/core/persistence/LocalPolarisMetaStoreManagerFactory.java
@@ -34,6 +34,7 @@ import org.apache.polaris.core.entity.PolarisEntityConstants;
 import org.apache.polaris.core.entity.PolarisEntitySubType;
 import org.apache.polaris.core.entity.PolarisEntityType;
 import org.apache.polaris.core.entity.PolarisPrincipalSecrets;
+import org.apache.polaris.core.persistence.bootstrap.RootCredentialsSet;
 import org.apache.polaris.core.persistence.cache.EntityCache;
 import org.apache.polaris.core.storage.cache.StorageCredentialCache;
 import org.slf4j.Logger;
@@ -71,26 +72,25 @@ public abstract class 
LocalPolarisMetaStoreManagerFactory<StoreType>
   protected abstract PolarisMetaStoreSession createMetaStoreSession(
       @Nonnull StoreType store,
       @Nonnull RealmContext realmContext,
-      @Nullable PolarisCredentialsBootstrap credentialsBootstrap,
+      @Nullable RootCredentialsSet rootCredentialsSet,
       @Nonnull PolarisDiagnostics diagnostics);
 
   protected PrincipalSecretsGenerator secretsGenerator(
-      RealmContext realmContext, @Nullable PolarisCredentialsBootstrap 
credentialsBootstrap) {
-    if (credentialsBootstrap != null) {
+      RealmContext realmContext, @Nullable RootCredentialsSet 
rootCredentialsSet) {
+    if (rootCredentialsSet != null) {
       return PrincipalSecretsGenerator.bootstrap(
-          realmContext.getRealmIdentifier(), credentialsBootstrap);
+          realmContext.getRealmIdentifier(), rootCredentialsSet);
     } else {
       return PrincipalSecretsGenerator.RANDOM_SECRETS;
     }
   }
 
   private void initializeForRealm(
-      RealmContext realmContext, PolarisCredentialsBootstrap 
credentialsBootstrap) {
+      RealmContext realmContext, RootCredentialsSet rootCredentialsSet) {
     final StoreType backingStore = createBackingStore(diagnostics);
     sessionSupplierMap.put(
         realmContext.getRealmIdentifier(),
-        () ->
-            createMetaStoreSession(backingStore, realmContext, 
credentialsBootstrap, diagnostics));
+        () -> createMetaStoreSession(backingStore, realmContext, 
rootCredentialsSet, diagnostics));
 
     PolarisMetaStoreManager metaStoreManager =
         new PolarisMetaStoreManagerImpl(realmContext, diagnostics, 
configurationStore, clock);
@@ -99,13 +99,13 @@ public abstract class 
LocalPolarisMetaStoreManagerFactory<StoreType>
 
   @Override
   public synchronized Map<String, PrincipalSecretsResult> bootstrapRealms(
-      List<String> realms, PolarisCredentialsBootstrap credentialsBootstrap) {
+      List<String> realms, RootCredentialsSet rootCredentialsSet) {
     Map<String, PrincipalSecretsResult> results = new HashMap<>();
 
     for (String realm : realms) {
       RealmContext realmContext = () -> realm;
       if (!metaStoreManagerMap.containsKey(realmContext.getRealmIdentifier())) 
{
-        initializeForRealm(realmContext, credentialsBootstrap);
+        initializeForRealm(realmContext, rootCredentialsSet);
         PrincipalSecretsResult secretsResult =
             bootstrapServiceAndCreatePolarisPrincipalForRealm(
                 realmContext, 
metaStoreManagerMap.get(realmContext.getRealmIdentifier()));
diff --git 
a/polaris-core/src/main/java/org/apache/polaris/core/persistence/MetaStoreManagerFactory.java
 
b/polaris-core/src/main/java/org/apache/polaris/core/persistence/MetaStoreManagerFactory.java
index 4e37f034..13557157 100644
--- 
a/polaris-core/src/main/java/org/apache/polaris/core/persistence/MetaStoreManagerFactory.java
+++ 
b/polaris-core/src/main/java/org/apache/polaris/core/persistence/MetaStoreManagerFactory.java
@@ -23,6 +23,7 @@ import java.util.Map;
 import java.util.function.Supplier;
 import 
org.apache.polaris.core.auth.PolarisSecretsManager.PrincipalSecretsResult;
 import org.apache.polaris.core.context.RealmContext;
+import org.apache.polaris.core.persistence.bootstrap.RootCredentialsSet;
 import org.apache.polaris.core.persistence.cache.EntityCache;
 import org.apache.polaris.core.storage.cache.StorageCredentialCache;
 
@@ -38,7 +39,7 @@ public interface MetaStoreManagerFactory {
   EntityCache getOrCreateEntityCache(RealmContext realmContext);
 
   Map<String, PrincipalSecretsResult> bootstrapRealms(
-      List<String> realms, PolarisCredentialsBootstrap credentialsBootstrap);
+      List<String> realms, RootCredentialsSet rootCredentialsSet);
 
   /** Purge all metadata for the realms provided */
   void purgeRealms(List<String> realms);
diff --git 
a/polaris-core/src/main/java/org/apache/polaris/core/persistence/PolarisCredentialsBootstrap.java
 
b/polaris-core/src/main/java/org/apache/polaris/core/persistence/PolarisCredentialsBootstrap.java
deleted file mode 100644
index f12f58db..00000000
--- 
a/polaris-core/src/main/java/org/apache/polaris/core/persistence/PolarisCredentialsBootstrap.java
+++ /dev/null
@@ -1,115 +0,0 @@
-/*
- * 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.polaris.core.persistence;
-
-import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Splitter;
-import jakarta.annotation.Nullable;
-import java.util.AbstractMap.SimpleEntry;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Optional;
-import org.apache.polaris.core.entity.PolarisEntityConstants;
-import org.apache.polaris.core.entity.PolarisPrincipalSecrets;
-
-/**
- * A utility to parse and provide credentials for Polaris realms and 
principals during a bootstrap
- * phase.
- */
-public class PolarisCredentialsBootstrap {
-
-  public static final PolarisCredentialsBootstrap EMPTY =
-      new PolarisCredentialsBootstrap(new HashMap<>());
-
-  /**
-   * Parse credentials from the system property {@code 
polaris.bootstrap.credentials} or the
-   * environment variable {@code POLARIS_BOOTSTRAP_CREDENTIALS}, whichever is 
set.
-   *
-   * <p>See {@link #fromString(String)} for the expected format.
-   */
-  public static PolarisCredentialsBootstrap fromEnvironment() {
-    return fromString(
-        System.getProperty(
-            "polaris.bootstrap.credentials", 
System.getenv().get("POLARIS_BOOTSTRAP_CREDENTIALS")));
-  }
-
-  /**
-   * Parse a string of credentials in the format:
-   *
-   * <pre>
-   * realm1,client1,secret1;realm2,client2,secret2;...
-   * </pre>
-   */
-  public static PolarisCredentialsBootstrap fromString(@Nullable String 
credentialsString) {
-    return credentialsString != null && !credentialsString.isBlank()
-        ? 
fromList(Splitter.on(';').trimResults().splitToList(credentialsString))
-        : EMPTY;
-  }
-
-  /**
-   * Parse a list of credentials; each element should be in the format: {@code
-   * realm,clientId,clientSecret}.
-   */
-  public static PolarisCredentialsBootstrap fromList(List<String> 
credentialsList) {
-    Map<String, Map.Entry<String, String>> credentials = new HashMap<>();
-    for (String triplet : credentialsList) {
-      if (!triplet.isBlank()) {
-        List<String> parts = 
Splitter.on(',').trimResults().splitToList(triplet);
-        if (parts.size() != 3) {
-          throw new IllegalArgumentException("Invalid credentials format: " + 
triplet);
-        }
-        String realmName = parts.get(0);
-        String clientId = parts.get(1);
-        String clientSecret = parts.get(2);
-
-        if (credentials.containsKey(realmName)) {
-          throw new IllegalArgumentException("Duplicate realm: " + realmName);
-        }
-        credentials.put(realmName, new SimpleEntry<>(clientId, clientSecret));
-      }
-    }
-    return credentials.isEmpty() ? EMPTY : new 
PolarisCredentialsBootstrap(credentials);
-  }
-
-  @VisibleForTesting final Map<String, Map.Entry<String, String>> credentials;
-
-  private PolarisCredentialsBootstrap(Map<String, Map.Entry<String, String>> 
credentials) {
-    this.credentials = credentials;
-  }
-
-  /**
-   * Get the secrets for the specified principal in the specified realm, if 
available among the
-   * credentials that were supplied for bootstrap. Only credentials for the 
root principal are
-   * supported.
-   */
-  public Optional<PolarisPrincipalSecrets> getSecrets(
-      String realmName, long principalId, String principalName) {
-    if (principalName.equals(PolarisEntityConstants.getRootPrincipalName())) {
-      return Optional.ofNullable(credentials.get(realmName))
-          .map(
-              credentials -> {
-                String clientId = credentials.getKey();
-                String secret = credentials.getValue();
-                return new PolarisPrincipalSecrets(principalId, clientId, 
secret, secret);
-              });
-    }
-    return Optional.empty();
-  }
-}
diff --git 
a/polaris-core/src/main/java/org/apache/polaris/core/persistence/PrincipalSecretsGenerator.java
 
b/polaris-core/src/main/java/org/apache/polaris/core/persistence/PrincipalSecretsGenerator.java
index 270d8569..1befc8b1 100644
--- 
a/polaris-core/src/main/java/org/apache/polaris/core/persistence/PrincipalSecretsGenerator.java
+++ 
b/polaris-core/src/main/java/org/apache/polaris/core/persistence/PrincipalSecretsGenerator.java
@@ -21,7 +21,9 @@ package org.apache.polaris.core.persistence;
 import jakarta.annotation.Nonnull;
 import jakarta.annotation.Nullable;
 import java.util.Optional;
+import org.apache.polaris.core.entity.PolarisEntityConstants;
 import org.apache.polaris.core.entity.PolarisPrincipalSecrets;
+import org.apache.polaris.core.persistence.bootstrap.RootCredentialsSet;
 
 /**
  * An interface for generating principal secrets. It enables detaching the 
secret generation logic
@@ -31,8 +33,8 @@ import org.apache.polaris.core.entity.PolarisPrincipalSecrets;
  * ID and secret overrides via system properties or environment variables, 
which can be useful for
  * bootstrapping new realms.
  *
- * <p>See {@link PolarisCredentialsBootstrap} for more information on the 
expected environment
- * variable name, and the format of the bootstrap credentials.
+ * <p>See {@link RootCredentialsSet} for more information on the expected 
environment variable name,
+ * and the format of the bootstrap credentials.
  */
 @FunctionalInterface
 public interface PrincipalSecretsGenerator {
@@ -56,14 +58,37 @@ public interface PrincipalSecretsGenerator {
   PolarisPrincipalSecrets produceSecrets(@Nonnull String principalName, long 
principalId);
 
   static PrincipalSecretsGenerator bootstrap(String realmName) {
-    return bootstrap(realmName, PolarisCredentialsBootstrap.fromEnvironment());
+    return bootstrap(realmName, RootCredentialsSet.fromEnvironment());
   }
 
   static PrincipalSecretsGenerator bootstrap(
-      String realmName, @Nullable PolarisCredentialsBootstrap 
credentialsSupplier) {
+      String realmName, @Nullable RootCredentialsSet rootCredentialsSet) {
     return (principalName, principalId) ->
-        Optional.ofNullable(credentialsSupplier)
-            .flatMap(credentials -> credentials.getSecrets(realmName, 
principalId, principalName))
+        Optional.ofNullable(rootCredentialsSet)
+            .flatMap(
+                credentialsSet -> getSecrets(realmName, principalName, 
principalId, credentialsSet))
             .orElseGet(() -> RANDOM_SECRETS.produceSecrets(principalName, 
principalId));
   }
+
+  /**
+   * Get the secrets for the specified principal in the specified realm, if 
available among the
+   * credentials that were supplied for bootstrap. Only credentials for the 
root principal are
+   * supported.
+   */
+  private static Optional<PolarisPrincipalSecrets> getSecrets(
+      String realmName,
+      String principalName,
+      long principalId,
+      RootCredentialsSet rootCredentialsSet) {
+    if (principalName.equals(PolarisEntityConstants.getRootPrincipalName())) {
+      return 
Optional.ofNullable(rootCredentialsSet.credentials().get(realmName))
+          .map(
+              credentials -> {
+                String clientId = credentials.clientId();
+                String secret = credentials.clientSecret();
+                return new PolarisPrincipalSecrets(principalId, clientId, 
secret, secret);
+              });
+    }
+    return Optional.empty();
+  }
 }
diff --git 
a/polaris-core/src/main/java/org/apache/polaris/core/persistence/bootstrap/RootCredentials.java
 
b/polaris-core/src/main/java/org/apache/polaris/core/persistence/bootstrap/RootCredentials.java
new file mode 100644
index 00000000..26f14357
--- /dev/null
+++ 
b/polaris-core/src/main/java/org/apache/polaris/core/persistence/bootstrap/RootCredentials.java
@@ -0,0 +1,50 @@
+/*
+ * 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.polaris.core.persistence.bootstrap;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import org.apache.polaris.immutables.PolarisImmutable;
+import org.immutables.value.Value;
+
+@PolarisImmutable
+@JsonSerialize(as = ImmutableRootCredentials.class)
+@JsonDeserialize(as = ImmutableRootCredentials.class)
+public interface RootCredentials {
+
+  @Value.Parameter(order = 1)
+  @JsonProperty("client-id")
+  String clientId();
+
+  @Value.Parameter(order = 2)
+  @Value.Redacted
+  @JsonProperty("client-secret")
+  String clientSecret();
+
+  @Value.Check
+  default void check() {
+    if (clientId().isEmpty()) {
+      throw new IllegalArgumentException("clientId cannot be empty");
+    }
+    if (clientSecret().isEmpty()) {
+      throw new IllegalArgumentException("clientSecret cannot be empty");
+    }
+  }
+}
diff --git 
a/polaris-core/src/main/java/org/apache/polaris/core/persistence/bootstrap/RootCredentialsSet.java
 
b/polaris-core/src/main/java/org/apache/polaris/core/persistence/bootstrap/RootCredentialsSet.java
new file mode 100644
index 00000000..0300612c
--- /dev/null
+++ 
b/polaris-core/src/main/java/org/apache/polaris/core/persistence/bootstrap/RootCredentialsSet.java
@@ -0,0 +1,153 @@
+/*
+ * 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.polaris.core.persistence.bootstrap;
+
+import static 
com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES;
+
+import com.fasterxml.jackson.annotation.JsonAnyGetter;
+import com.fasterxml.jackson.annotation.JsonAnySetter;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
+import com.google.common.base.Splitter;
+import jakarta.annotation.Nullable;
+import java.net.URL;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.polaris.immutables.PolarisImmutable;
+import org.immutables.value.Value;
+
+/**
+ * A utility to parse and provide credentials for Polaris realms and 
principals during a bootstrap
+ * phase.
+ */
+@PolarisImmutable
+@JsonSerialize(as = ImmutableRootCredentialsSet.class)
+@JsonDeserialize(as = ImmutableRootCredentialsSet.class)
[email protected](jdkOnly = true)
+public interface RootCredentialsSet {
+
+  RootCredentialsSet EMPTY = ImmutableRootCredentialsSet.builder().build();
+
+  String SYSTEM_PROPERTY = "polaris.bootstrap.credentials";
+  String ENVIRONMENT_VARIABLE = "POLARIS_BOOTSTRAP_CREDENTIALS";
+
+  /**
+   * Parse credentials from the system property {@value #SYSTEM_PROPERTY} or 
the environment
+   * variable {@value #ENVIRONMENT_VARIABLE}, whichever is set.
+   *
+   * <p>See {@link #fromString(String)} for the expected format.
+   */
+  static RootCredentialsSet fromEnvironment() {
+    return fromString(
+        System.getProperty(SYSTEM_PROPERTY, 
System.getenv().get(ENVIRONMENT_VARIABLE)));
+  }
+
+  /**
+   * Parse a string of credentials in the format:
+   *
+   * <pre>
+   * realm1,client1,secret1;realm2,client2,secret2;...
+   * </pre>
+   */
+  static RootCredentialsSet fromString(@Nullable String credentialsString) {
+    return credentialsString != null && !credentialsString.isBlank()
+        ? 
fromList(Splitter.on(';').trimResults().splitToList(credentialsString))
+        : EMPTY;
+  }
+
+  /**
+   * Parse a list of credentials; each element should be in the format: {@code
+   * realm,clientId,clientSecret}.
+   */
+  static RootCredentialsSet fromList(List<String> credentialsList) {
+    Map<String, RootCredentials> credentials = new HashMap<>();
+    for (String triplet : credentialsList) {
+      if (!triplet.isBlank()) {
+        List<String> parts = 
Splitter.on(',').trimResults().splitToList(triplet);
+        if (parts.size() != 3) {
+          throw new IllegalArgumentException("Invalid credentials format: " + 
triplet);
+        }
+        String realm = parts.get(0);
+        RootCredentials creds = ImmutableRootCredentials.of(parts.get(1), 
parts.get(2));
+        if (credentials.containsKey(realm)) {
+          throw new IllegalArgumentException("Duplicate realm: " + realm);
+        }
+        credentials.put(realm, creds);
+      }
+    }
+    return credentials.isEmpty() ? EMPTY : 
ImmutableRootCredentialsSet.of(credentials);
+  }
+
+  /**
+   * Parse credentials set from any URL containing a valid YAML or JSON 
credentials file.
+   *
+   * <p>The expected YAML format is:
+   *
+   * <pre>
+   * realm1:
+   *   client-id: client1
+   *   client-secret: secret1
+   * realm2:
+   *   client-id: client2
+   *   client-secret: secret2
+   * # etc.
+   * </pre>
+   *
+   * Multiple YAMl documents are also supported; all documents will be merged 
into a single set of
+   * credentials.
+   *
+   * <p>The expected JSON format is:
+   *
+   * <pre>
+   * {
+   *   "realm1": {
+   *     "client-id": "client1",
+   *     "client-secret": "secret1"
+   *   },
+   *   "realm2": {
+   *     "client-id": "client2",
+   *     "client-secret": "secret2"
+   *   }
+   * }
+   * </pre>
+   */
+  static RootCredentialsSet fromUrl(URL url) {
+    YAMLFactory factory = new YAMLFactory();
+    ObjectMapper mapper = new 
ObjectMapper(factory).configure(FAIL_ON_UNKNOWN_PROPERTIES, false);
+    try (var parser = factory.createParser(url)) {
+      var values = mapper.readValues(parser, RootCredentialsSet.class);
+      var builder = ImmutableRootCredentialsSet.builder();
+      while (values.hasNext()) {
+        builder.putAllCredentials(values.next().credentials());
+      }
+      return builder.build();
+    } catch (Exception e) {
+      throw new IllegalArgumentException("Failed to read credentials file: " + 
url, e);
+    }
+  }
+
+  /** Get all the credentials contained in this set, keyed by realm 
identifier. */
+  @JsonAnyGetter
+  @JsonAnySetter
+  @Value.Parameter(order = 0)
+  Map<String, RootCredentials> credentials();
+}
diff --git 
a/polaris-core/src/test/java/org/apache/polaris/core/persistence/PolarisCredentialsBootstrapTest.java
 
b/polaris-core/src/test/java/org/apache/polaris/core/persistence/PolarisCredentialsBootstrapTest.java
deleted file mode 100644
index bd4ecec6..00000000
--- 
a/polaris-core/src/test/java/org/apache/polaris/core/persistence/PolarisCredentialsBootstrapTest.java
+++ /dev/null
@@ -1,113 +0,0 @@
-/*
- * 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.polaris.core.persistence;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
-
-import java.util.List;
-import org.junit.jupiter.api.Test;
-
-class PolarisCredentialsBootstrapTest {
-
-  @Test
-  void nullString() {
-    PolarisCredentialsBootstrap credentials = 
PolarisCredentialsBootstrap.fromString(null);
-    assertThat(credentials.credentials).isEmpty();
-  }
-
-  @Test
-  void emptyString() {
-    PolarisCredentialsBootstrap credentials = 
PolarisCredentialsBootstrap.fromString("");
-    assertThat(credentials.credentials).isEmpty();
-  }
-
-  @Test
-  void blankString() {
-    PolarisCredentialsBootstrap credentials = 
PolarisCredentialsBootstrap.fromString("  ");
-    assertThat(credentials.credentials).isEmpty();
-  }
-
-  @Test
-  void invalidString() {
-    assertThatThrownBy(() -> PolarisCredentialsBootstrap.fromString("test"))
-        .hasMessage("Invalid credentials format: test");
-  }
-
-  @Test
-  void duplicateRealm() {
-    assertThatThrownBy(
-            () ->
-                PolarisCredentialsBootstrap.fromString(
-                    "realm1,client1a,secret1a;realm1,client1b,secret1b"))
-        .hasMessage("Duplicate realm: realm1");
-  }
-
-  @Test
-  void getSecretsValidString() {
-    PolarisCredentialsBootstrap credentials =
-        PolarisCredentialsBootstrap.fromString(
-            " ; realm1 , client1 , secret1 ; realm2 , client2 , secret2 ; ");
-    assertCredentials(credentials);
-  }
-
-  @Test
-  void getSecretsValidList() {
-    PolarisCredentialsBootstrap credentials =
-        PolarisCredentialsBootstrap.fromList(
-            List.of("realm1,client1,secret1", "realm2,client2,secret2"));
-    assertCredentials(credentials);
-  }
-
-  @Test
-  void getSecretsValidSystemProperty() {
-    PolarisCredentialsBootstrap credentials = 
PolarisCredentialsBootstrap.fromEnvironment();
-    assertThat(credentials.credentials).isEmpty();
-    try {
-      System.setProperty(
-          "polaris.bootstrap.credentials", 
"realm1,client1,secret1;realm2,client2,secret2");
-      credentials = PolarisCredentialsBootstrap.fromEnvironment();
-      assertCredentials(credentials);
-    } finally {
-      System.clearProperty("polaris.bootstrap.credentials");
-    }
-  }
-
-  private void assertCredentials(PolarisCredentialsBootstrap credentials) {
-    assertThat(credentials.getSecrets("realm3", 123, "root")).isEmpty();
-    assertThat(credentials.getSecrets("nonexistent", 123, "root")).isEmpty();
-    assertThat(credentials.getSecrets("realm1", 123, "non-root")).isEmpty();
-    assertThat(credentials.getSecrets("realm1", 123, "root"))
-        .hasValueSatisfying(
-            secrets -> {
-              assertThat(secrets.getPrincipalId()).isEqualTo(123);
-              assertThat(secrets.getPrincipalClientId()).isEqualTo("client1");
-              assertThat(secrets.getMainSecret()).isEqualTo("secret1");
-              assertThat(secrets.getSecondarySecret()).isEqualTo("secret1");
-            });
-    assertThat(credentials.getSecrets("realm2", 123, "root"))
-        .hasValueSatisfying(
-            secrets -> {
-              assertThat(secrets.getPrincipalId()).isEqualTo(123);
-              assertThat(secrets.getPrincipalClientId()).isEqualTo("client2");
-              assertThat(secrets.getMainSecret()).isEqualTo("secret2");
-              assertThat(secrets.getSecondarySecret()).isEqualTo("secret2");
-            });
-  }
-}
diff --git 
a/polaris-core/src/test/java/org/apache/polaris/core/persistence/PrincipalSecretsGeneratorTest.java
 
b/polaris-core/src/test/java/org/apache/polaris/core/persistence/PrincipalSecretsGeneratorTest.java
index d220d7ef..6ffb2a41 100644
--- 
a/polaris-core/src/test/java/org/apache/polaris/core/persistence/PrincipalSecretsGeneratorTest.java
+++ 
b/polaris-core/src/test/java/org/apache/polaris/core/persistence/PrincipalSecretsGeneratorTest.java
@@ -22,6 +22,7 @@ import static 
org.apache.polaris.core.persistence.PrincipalSecretsGenerator.boot
 import static org.assertj.core.api.Assertions.assertThat;
 
 import org.apache.polaris.core.entity.PolarisPrincipalSecrets;
+import org.apache.polaris.core.persistence.bootstrap.RootCredentialsSet;
 import org.junit.jupiter.api.Test;
 
 class PrincipalSecretsGeneratorTest {
@@ -39,7 +40,7 @@ class PrincipalSecretsGeneratorTest {
   @Test
   void testSecretOverride() {
     PrincipalSecretsGenerator gen =
-        bootstrap("test-Realm", 
PolarisCredentialsBootstrap.fromString("test-Realm,client1,sec2"));
+        bootstrap("test-Realm", 
RootCredentialsSet.fromString("test-Realm,client1,sec2"));
     PolarisPrincipalSecrets s = gen.produceSecrets("root", 123);
     assertThat(s).isNotNull();
     assertThat(s.getPrincipalId()).isEqualTo(123);
diff --git 
a/polaris-core/src/test/java/org/apache/polaris/core/persistence/bootstrap/RootCredentialsSetTest.java
 
b/polaris-core/src/test/java/org/apache/polaris/core/persistence/bootstrap/RootCredentialsSetTest.java
new file mode 100644
index 00000000..c6c5ecd7
--- /dev/null
+++ 
b/polaris-core/src/test/java/org/apache/polaris/core/persistence/bootstrap/RootCredentialsSetTest.java
@@ -0,0 +1,137 @@
+/*
+ * 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.polaris.core.persistence.bootstrap;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.net.URL;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+class RootCredentialsSetTest {
+
+  @Test
+  void nullString() {
+    RootCredentialsSet credentials = RootCredentialsSet.fromString(null);
+    assertThat(credentials.credentials()).isEmpty();
+  }
+
+  @Test
+  void emptyString() {
+    RootCredentialsSet credentials = RootCredentialsSet.fromString("");
+    assertThat(credentials.credentials()).isEmpty();
+  }
+
+  @Test
+  void blankString() {
+    RootCredentialsSet credentials = RootCredentialsSet.fromString("  ");
+    assertThat(credentials.credentials()).isEmpty();
+  }
+
+  @Test
+  void invalidString() {
+    assertThatThrownBy(() -> RootCredentialsSet.fromString("test"))
+        .hasMessage("Invalid credentials format: test");
+  }
+
+  @Test
+  void duplicateRealm() {
+    assertThatThrownBy(
+            () ->
+                
RootCredentialsSet.fromString("realm1,client1a,secret1a;realm1,client1b,secret1b"))
+        .hasMessage("Duplicate realm: realm1");
+  }
+
+  @Test
+  void getSecretsValidString() {
+    RootCredentialsSet credentials =
+        RootCredentialsSet.fromString(
+            " ; realm1 , client1 , secret1 ; realm2 , client2 , secret2 ; ");
+    assertCredentials(credentials);
+  }
+
+  @Test
+  void getSecretsValidList() {
+    RootCredentialsSet credentials =
+        RootCredentialsSet.fromList(List.of("realm1,client1,secret1", 
"realm2,client2,secret2"));
+    assertCredentials(credentials);
+  }
+
+  @Test
+  void getSecretsValidSystemProperty() {
+    RootCredentialsSet credentials = RootCredentialsSet.fromEnvironment();
+    assertThat(credentials.credentials()).isEmpty();
+    try {
+      System.setProperty(
+          RootCredentialsSet.SYSTEM_PROPERTY, 
"realm1,client1,secret1;realm2,client2,secret2");
+      credentials = RootCredentialsSet.fromEnvironment();
+      assertCredentials(credentials);
+    } finally {
+      System.clearProperty(RootCredentialsSet.SYSTEM_PROPERTY);
+    }
+  }
+
+  @Test
+  void getSecretsValidJson() {
+    URL resource = getClass().getResource("credentials.json");
+    RootCredentialsSet set = RootCredentialsSet.fromUrl(resource);
+    assertCredentials(set);
+  }
+
+  @Test
+  void getSecretsValidYaml() {
+    URL resource = getClass().getResource("credentials.yaml");
+    RootCredentialsSet set = RootCredentialsSet.fromUrl(resource);
+    assertCredentials(set);
+  }
+
+  @Test
+  void getSecretsInvalidJson() {
+    URL resource = getClass().getResource("credentials-invalid.json");
+    assertThatThrownBy(() -> RootCredentialsSet.fromUrl(resource))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessageContaining("Failed to read credentials file")
+        .rootCause()
+        .isInstanceOf(IllegalStateException.class)
+        .hasMessageContaining(
+            "Cannot build RootCredentials, some of required attributes are not 
set [clientId, clientSecret]");
+  }
+
+  @Test
+  void getSecretsInvalidYaml() {
+    URL resource = getClass().getResource("credentials-invalid.yaml");
+    assertThatThrownBy(() -> RootCredentialsSet.fromUrl(resource))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessageContaining("Failed to read credentials file")
+        .rootCause()
+        .isInstanceOf(IllegalStateException.class)
+        .hasMessageContaining(
+            "Cannot build RootCredentials, some of required attributes are not 
set [clientId, clientSecret]");
+  }
+
+  private static void assertCredentials(RootCredentialsSet set) {
+    assertThat(set.credentials()).hasSize(2);
+    assertThat(set.credentials().keySet()).containsExactlyInAnyOrder("realm1", 
"realm2");
+    assertThat(set.credentials().get("realm1"))
+        .isEqualTo(ImmutableRootCredentials.of("client1", "secret1"));
+    assertThat(set.credentials().get("realm2"))
+        .isEqualTo(ImmutableRootCredentials.of("client2", "secret2"));
+  }
+}
diff --git 
a/polaris-core/src/test/resources/org/apache/polaris/core/persistence/bootstrap/credentials-invalid.json
 
b/polaris-core/src/test/resources/org/apache/polaris/core/persistence/bootstrap/credentials-invalid.json
new file mode 100644
index 00000000..5bab6c70
--- /dev/null
+++ 
b/polaris-core/src/test/resources/org/apache/polaris/core/persistence/bootstrap/credentials-invalid.json
@@ -0,0 +1,3 @@
+{
+  "realm1": {}
+}
\ No newline at end of file
diff --git 
a/polaris-core/src/test/resources/org/apache/polaris/core/persistence/bootstrap/credentials-invalid.yaml
 
b/polaris-core/src/test/resources/org/apache/polaris/core/persistence/bootstrap/credentials-invalid.yaml
new file mode 100644
index 00000000..0e943b66
--- /dev/null
+++ 
b/polaris-core/src/test/resources/org/apache/polaris/core/persistence/bootstrap/credentials-invalid.yaml
@@ -0,0 +1,20 @@
+#
+# 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.
+#
+
+realm1: {}
diff --git 
a/polaris-core/src/test/resources/org/apache/polaris/core/persistence/bootstrap/credentials.json
 
b/polaris-core/src/test/resources/org/apache/polaris/core/persistence/bootstrap/credentials.json
new file mode 100644
index 00000000..46646140
--- /dev/null
+++ 
b/polaris-core/src/test/resources/org/apache/polaris/core/persistence/bootstrap/credentials.json
@@ -0,0 +1,11 @@
+{
+  "realm1": {
+    "client-id": "client1",
+    "client-secret": "secret1"
+  },
+  "realm2": {
+    "client-id": "client2",
+    "client-secret": "secret2",
+    "extra-field": "extra-value"
+  }
+}
\ No newline at end of file
diff --git 
a/polaris-core/src/test/resources/org/apache/polaris/core/persistence/bootstrap/credentials.yaml
 
b/polaris-core/src/test/resources/org/apache/polaris/core/persistence/bootstrap/credentials.yaml
new file mode 100644
index 00000000..7978fc5d
--- /dev/null
+++ 
b/polaris-core/src/test/resources/org/apache/polaris/core/persistence/bootstrap/credentials.yaml
@@ -0,0 +1,27 @@
+#
+# 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.
+#
+
+realm1:
+  client-id: client1
+  client-secret: secret1
+---
+realm2:
+  client-id: client2
+  client-secret: secret2
+  extra-field: extra-value
diff --git 
a/quarkus/admin/src/main/java/org/apache/polaris/admintool/BaseCommand.java 
b/quarkus/admin/src/main/java/org/apache/polaris/admintool/BaseCommand.java
index 173c990e..c56fe971 100644
--- a/quarkus/admin/src/main/java/org/apache/polaris/admintool/BaseCommand.java
+++ b/quarkus/admin/src/main/java/org/apache/polaris/admintool/BaseCommand.java
@@ -26,6 +26,7 @@ import picocli.CommandLine.Spec;
 
 public abstract class BaseCommand implements Callable<Integer> {
 
+  public static final int EXIT_CODE_USAGE = 2;
   public static final int EXIT_CODE_BOOTSTRAP_ERROR = 3;
   public static final int EXIT_CODE_PURGE_ERROR = 4;
 
diff --git 
a/quarkus/admin/src/main/java/org/apache/polaris/admintool/BootstrapCommand.java
 
b/quarkus/admin/src/main/java/org/apache/polaris/admintool/BootstrapCommand.java
index e1b61e5a..e77ee9b5 100644
--- 
a/quarkus/admin/src/main/java/org/apache/polaris/admintool/BootstrapCommand.java
+++ 
b/quarkus/admin/src/main/java/org/apache/polaris/admintool/BootstrapCommand.java
@@ -18,10 +18,11 @@
  */
 package org.apache.polaris.admintool;
 
+import java.nio.file.Path;
 import java.util.List;
 import java.util.Map;
 import 
org.apache.polaris.core.auth.PolarisSecretsManager.PrincipalSecretsResult;
-import org.apache.polaris.core.persistence.PolarisCredentialsBootstrap;
+import org.apache.polaris.core.persistence.bootstrap.RootCredentialsSet;
 import picocli.CommandLine;
 
 @CommandLine.Command(
@@ -30,49 +31,92 @@ import picocli.CommandLine;
     description = "Bootstraps realms and root principal credentials.")
 public class BootstrapCommand extends BaseCommand {
 
-  @CommandLine.Option(
-      names = {"-r", "--realm"},
-      paramLabel = "<realm>",
-      required = true,
-      description = "The name of a realm to bootstrap.")
-  List<String> realms;
+  @CommandLine.ArgGroup(multiplicity = "1")
+  InputOptions inputOptions;
 
-  @CommandLine.Option(
-      names = {"-c", "--credential"},
-      paramLabel = "<realm,clientId,clientSecret>",
-      description =
-          "Root principal credentials to bootstrap. Must be of the form 
'realm,clientId,clientSecret'.")
-  List<String> credentials;
+  static class InputOptions {
+
+    @CommandLine.ArgGroup(multiplicity = "1", exclusive = false)
+    StandardInputOptions stdinOptions;
+
+    @CommandLine.ArgGroup(multiplicity = "1")
+    FileInputOptions fileOptions;
+
+    static class StandardInputOptions {
+
+      @CommandLine.Option(
+          names = {"-r", "--realm"},
+          paramLabel = "<realm>",
+          required = true,
+          description = "The name of a realm to bootstrap.")
+      List<String> realms;
+
+      @CommandLine.Option(
+          names = {"-c", "--credential"},
+          paramLabel = "<realm,clientId,clientSecret>",
+          description =
+              "Root principal credentials to bootstrap. Must be of the form 
'realm,clientId,clientSecret'.")
+      List<String> credentials;
+    }
+
+    static class FileInputOptions {
+      @CommandLine.Option(
+          names = {"-f", "--credentials-file"},
+          paramLabel = "<file>",
+          description = "A file containing root principal credentials to 
bootstrap.")
+      Path file;
+    }
+  }
 
   @Override
   public Integer call() {
-    PolarisCredentialsBootstrap credentialsBootstrap =
-        credentials == null || credentials.isEmpty()
-            ? PolarisCredentialsBootstrap.EMPTY
-            : PolarisCredentialsBootstrap.fromList(credentials);
+    try {
+      RootCredentialsSet rootCredentialsSet;
+      List<String> realms; // TODO Iterable
+
+      if (inputOptions.fileOptions != null) {
+        rootCredentialsSet =
+            
RootCredentialsSet.fromUrl(inputOptions.fileOptions.file.toUri().toURL());
+        realms = rootCredentialsSet.credentials().keySet().stream().toList();
+      } else {
+        realms = inputOptions.stdinOptions.realms;
+        rootCredentialsSet =
+            inputOptions.stdinOptions.credentials == null
+                    || inputOptions.stdinOptions.credentials.isEmpty()
+                ? RootCredentialsSet.EMPTY
+                : 
RootCredentialsSet.fromList(inputOptions.stdinOptions.credentials);
+      }
 
-    // Execute the bootstrap
-    Map<String, PrincipalSecretsResult> results =
-        metaStoreManagerFactory.bootstrapRealms(realms, credentialsBootstrap);
+      // Execute the bootstrap
+      Map<String, PrincipalSecretsResult> results =
+          metaStoreManagerFactory.bootstrapRealms(realms, rootCredentialsSet);
 
-    // Log any errors:
-    boolean success = true;
-    for (Map.Entry<String, PrincipalSecretsResult> result : 
results.entrySet()) {
-      if (!result.getValue().isSuccess()) {
-        String realm = result.getKey();
-        spec.commandLine()
-            .getErr()
-            .printf(
-                "Bootstrapping '%s' failed: %s%n",
-                realm, result.getValue().getReturnStatus().toString());
-        success = false;
+      // Log any errors:
+      boolean success = true;
+      for (Map.Entry<String, PrincipalSecretsResult> result : 
results.entrySet()) {
+        if (result.getValue().isSuccess()) {
+          String realm = result.getKey();
+          spec.commandLine().getOut().printf("Realm '%s' successfully 
bootstrapped.%n", realm);
+        } else {
+          String realm = result.getKey();
+          spec.commandLine()
+              .getErr()
+              .printf(
+                  "Bootstrapping '%s' failed: %s%n",
+                  realm, result.getValue().getReturnStatus().toString());
+          success = false;
+        }
       }
-    }
 
-    if (success) {
-      spec.commandLine().getOut().println("Bootstrap completed successfully.");
-      return 0;
-    } else {
+      if (success) {
+        spec.commandLine().getOut().println("Bootstrap completed 
successfully.");
+        return 0;
+      } else {
+        spec.commandLine().getErr().println("Bootstrap encountered errors 
during operation.");
+        return EXIT_CODE_BOOTSTRAP_ERROR;
+      }
+    } catch (Exception e) {
+      e.printStackTrace(spec.commandLine().getErr());
       spec.commandLine().getErr().println("Bootstrap encountered errors during 
operation.");
       return EXIT_CODE_BOOTSTRAP_ERROR;
     }
diff --git 
a/quarkus/admin/src/test/java/org/apache/polaris/admintool/BootstrapCommandTest.java
 
b/quarkus/admin/src/test/java/org/apache/polaris/admintool/BootstrapCommandTest.java
index a3a3f5d3..b00bef2d 100644
--- 
a/quarkus/admin/src/test/java/org/apache/polaris/admintool/BootstrapCommandTest.java
+++ 
b/quarkus/admin/src/test/java/org/apache/polaris/admintool/BootstrapCommandTest.java
@@ -18,6 +18,8 @@
  */
 package org.apache.polaris.admintool;
 
+import static 
org.apache.polaris.admintool.BaseCommand.EXIT_CODE_BOOTSTRAP_ERROR;
+import static org.apache.polaris.admintool.BaseCommand.EXIT_CODE_USAGE;
 import static 
org.apache.polaris.admintool.PostgresTestResourceLifecycleManager.INIT_SCRIPT;
 import static org.assertj.core.api.Assertions.assertThat;
 
@@ -26,8 +28,17 @@ import io.quarkus.test.common.TestResourceScope;
 import io.quarkus.test.common.WithTestResource;
 import io.quarkus.test.junit.main.Launch;
 import io.quarkus.test.junit.main.LaunchResult;
+import io.quarkus.test.junit.main.QuarkusMainLauncher;
 import io.quarkus.test.junit.main.QuarkusMainTest;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Objects;
+import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
 
 @QuarkusMainTest
 @WithTestResource(
@@ -36,6 +47,15 @@ import org.junit.jupiter.api.Test;
     initArgs = @ResourceArg(name = INIT_SCRIPT, value = 
"org/apache/polaris/admintool/init.sql"))
 class BootstrapCommandTest {
 
+  private static Path json;
+  private static Path yaml;
+
+  @BeforeAll
+  static void prepareFiles(@TempDir Path temp) throws IOException {
+    json = copyResource(temp, "credentials.json");
+    yaml = copyResource(temp, "credentials.yaml");
+  }
+
   @Test
   @Launch(
       value = {
@@ -49,7 +69,75 @@ class BootstrapCommandTest {
         "-c",
         "realm2,root,s3cr3t"
       })
-  public void testBootstrap(LaunchResult result) {
-    assertThat(result.getOutput()).contains("Bootstrap completed 
successfully.");
+  public void testBootstrapFromCommandLineArguments(LaunchResult result) {
+    assertThat(result.getOutput())
+        .contains("Realm 'realm1' successfully bootstrapped.")
+        .contains("Realm 'realm2' successfully bootstrapped.")
+        .contains("Bootstrap completed successfully.");
+  }
+
+  @Test
+  @Launch(
+      value = {
+        "bootstrap",
+        "-r",
+        "realm1",
+        "-c",
+        "invalid syntax",
+      },
+      exitCode = EXIT_CODE_BOOTSTRAP_ERROR)
+  public void testBootstrapInvalidCredentials(LaunchResult result) {
+    assertThat(result.getErrorOutput())
+        .contains("Invalid credentials format: invalid syntax")
+        .contains("Bootstrap encountered errors during operation.");
+  }
+
+  @Test
+  @Launch(
+      value = {"bootstrap", "-r", "realm1", "-f", "/irrelevant"},
+      exitCode = EXIT_CODE_USAGE)
+  public void testBootstrapInvalidArguments(LaunchResult result) {
+    assertThat(result.getErrorOutput())
+        .contains(
+            "Error: (-r=<realm> [-r=<realm>]... 
[-c=<realm,clientId,clientSecret>]...) "
+                + "and -f=<file> are mutually exclusive (specify only one)");
+  }
+
+  @Test
+  public void testBootstrapFromValidJsonFile(QuarkusMainLauncher launcher) {
+    LaunchResult result = launcher.launch("bootstrap", "-f", json.toString());
+    assertThat(result.exitCode()).isEqualTo(0);
+    assertThat(result.getOutput())
+        .contains("Realm 'realm1' successfully bootstrapped.")
+        .contains("Realm 'realm2' successfully bootstrapped.")
+        .contains("Bootstrap completed successfully.");
+  }
+
+  @Test
+  public void testBootstrapFromValidYamlFile(QuarkusMainLauncher launcher) {
+    LaunchResult result = launcher.launch("bootstrap", "-f", yaml.toString());
+    assertThat(result.exitCode()).isEqualTo(0);
+    assertThat(result.getOutput())
+        .contains("Realm 'realm1' successfully bootstrapped.")
+        .contains("Realm 'realm2' successfully bootstrapped.")
+        .contains("Bootstrap completed successfully.");
+  }
+
+  @Test
+  public void testBootstrapFromInvalidFile(QuarkusMainLauncher launcher) {
+    LaunchResult result = launcher.launch("bootstrap", "-f", 
"/non/existing/file");
+    assertThat(result.exitCode()).isEqualTo(EXIT_CODE_BOOTSTRAP_ERROR);
+    assertThat(result.getErrorOutput())
+        .contains("Failed to read credentials file: file:/non/existing/file")
+        .contains("Bootstrap encountered errors during operation.");
+  }
+
+  private static Path copyResource(Path temp, String resource) throws 
IOException {
+    URL source = 
Objects.requireNonNull(BootstrapCommandTest.class.getResource(resource));
+    Path dest = temp.resolve(resource);
+    try (InputStream in = source.openStream()) {
+      Files.copy(in, dest);
+    }
+    return dest;
   }
 }
diff --git 
a/quarkus/admin/src/test/java/org/apache/polaris/admintool/PurgeCommandTest.java
 
b/quarkus/admin/src/test/java/org/apache/polaris/admintool/PurgeCommandTest.java
index c0fc4b4c..ba60d9e2 100644
--- 
a/quarkus/admin/src/test/java/org/apache/polaris/admintool/PurgeCommandTest.java
+++ 
b/quarkus/admin/src/test/java/org/apache/polaris/admintool/PurgeCommandTest.java
@@ -22,22 +22,40 @@ import static org.assertj.core.api.Assertions.assertThat;
 
 import io.quarkus.runtime.StartupEvent;
 import io.quarkus.test.common.WithTestResource;
+import io.quarkus.test.junit.QuarkusTestProfile;
+import io.quarkus.test.junit.TestProfile;
 import io.quarkus.test.junit.main.Launch;
 import io.quarkus.test.junit.main.LaunchResult;
 import io.quarkus.test.junit.main.QuarkusMainTest;
 import jakarta.enterprise.event.Observes;
 import java.util.List;
+import java.util.Map;
 import org.apache.polaris.core.persistence.MetaStoreManagerFactory;
-import org.apache.polaris.core.persistence.PolarisCredentialsBootstrap;
+import org.apache.polaris.core.persistence.bootstrap.RootCredentialsSet;
+import org.eclipse.microprofile.config.inject.ConfigProperty;
 import org.junit.jupiter.api.Test;
 
 @QuarkusMainTest
 @WithTestResource(PostgresTestResourceLifecycleManager.class)
+@TestProfile(PurgeCommandTest.Profile.class)
 class PurgeCommandTest {
 
-  void preBootstrap(@Observes StartupEvent event, MetaStoreManagerFactory 
metaStoreManagerFactory) {
-    metaStoreManagerFactory.bootstrapRealms(
-        List.of("realm1", "realm2"), PolarisCredentialsBootstrap.EMPTY);
+  public static class Profile implements QuarkusTestProfile {
+
+    @Override
+    public Map<String, String> getConfigOverrides() {
+      return Map.of("pre-bootstrap", "true");
+    }
+  }
+
+  void preBootstrap(
+      @Observes StartupEvent event,
+      @ConfigProperty(name = "pre-bootstrap", defaultValue = "false") boolean 
preBootstrap,
+      MetaStoreManagerFactory metaStoreManagerFactory) {
+    if (preBootstrap) {
+      metaStoreManagerFactory.bootstrapRealms(
+          List.of("realm1", "realm2"), RootCredentialsSet.EMPTY);
+    }
   }
 
   @Test
diff --git 
a/quarkus/admin/src/test/resources/org/apache/polaris/admintool/credentials.json
 
b/quarkus/admin/src/test/resources/org/apache/polaris/admintool/credentials.json
new file mode 100644
index 00000000..de858205
--- /dev/null
+++ 
b/quarkus/admin/src/test/resources/org/apache/polaris/admintool/credentials.json
@@ -0,0 +1,10 @@
+{
+  "realm1" : {
+    "client-id" : "client1",
+    "client-secret" : "secret1"
+  },
+  "realm2" : {
+    "client-id": "client2",
+    "client-secret": "secret2"
+  }
+}
\ No newline at end of file
diff --git 
a/quarkus/admin/src/test/resources/org/apache/polaris/admintool/credentials.yaml
 
b/quarkus/admin/src/test/resources/org/apache/polaris/admintool/credentials.yaml
new file mode 100644
index 00000000..34268d7c
--- /dev/null
+++ 
b/quarkus/admin/src/test/resources/org/apache/polaris/admintool/credentials.yaml
@@ -0,0 +1,25 @@
+#
+# 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.
+#
+
+realm1:
+  client-id: client1
+  client-secret: secret1
+realm2:
+  client-id: client2
+  client-secret: secret2
\ No newline at end of file
diff --git 
a/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/catalog/BasePolarisCatalogTest.java
 
b/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/catalog/BasePolarisCatalogTest.java
index 373df251..97741176 100644
--- 
a/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/catalog/BasePolarisCatalogTest.java
+++ 
b/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/catalog/BasePolarisCatalogTest.java
@@ -82,10 +82,10 @@ import org.apache.polaris.core.entity.PolarisEntityType;
 import org.apache.polaris.core.entity.PrincipalEntity;
 import org.apache.polaris.core.entity.TaskEntity;
 import org.apache.polaris.core.persistence.MetaStoreManagerFactory;
-import org.apache.polaris.core.persistence.PolarisCredentialsBootstrap;
 import org.apache.polaris.core.persistence.PolarisEntityManager;
 import org.apache.polaris.core.persistence.PolarisMetaStoreManager;
 import org.apache.polaris.core.persistence.PolarisMetaStoreSession;
+import org.apache.polaris.core.persistence.bootstrap.RootCredentialsSet;
 import org.apache.polaris.core.persistence.cache.EntityCache;
 import org.apache.polaris.core.storage.PolarisCredentialProperty;
 import org.apache.polaris.core.storage.PolarisStorageIntegration;
@@ -335,7 +335,7 @@ public class BasePolarisCatalogTest extends 
CatalogTests<BasePolarisCatalog> {
 
       @Override
       public Map<String, PrincipalSecretsResult> bootstrapRealms(
-          List<String> realms, PolarisCredentialsBootstrap 
credentialsBootstrap) {
+          List<String> realms, RootCredentialsSet rootCredentialsSet) {
         throw new NotImplementedException("Bootstrapping realms is not 
supported");
       }
 
diff --git 
a/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/test/PolarisIntegrationTestFixture.java
 
b/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/test/PolarisIntegrationTestFixture.java
index b7e38dd4..45a8ff67 100644
--- 
a/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/test/PolarisIntegrationTestFixture.java
+++ 
b/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/test/PolarisIntegrationTestFixture.java
@@ -40,9 +40,9 @@ import org.apache.polaris.core.entity.PolarisEntityConstants;
 import org.apache.polaris.core.entity.PolarisEntitySubType;
 import org.apache.polaris.core.entity.PolarisEntityType;
 import org.apache.polaris.core.entity.PolarisPrincipalSecrets;
-import org.apache.polaris.core.persistence.PolarisCredentialsBootstrap;
 import org.apache.polaris.core.persistence.PolarisMetaStoreManager;
 import org.apache.polaris.core.persistence.PolarisMetaStoreSession;
+import org.apache.polaris.core.persistence.bootstrap.RootCredentialsSet;
 import 
org.apache.polaris.service.persistence.InMemoryPolarisMetaStoreManagerFactory;
 import org.apache.polaris.service.quarkus.auth.TokenUtils;
 import org.junit.jupiter.api.TestInfo;
@@ -97,7 +97,7 @@ public class PolarisIntegrationTestFixture {
   private PolarisPrincipalSecrets fetchAdminSecrets() {
     if (!(helper.metaStoreManagerFactory instanceof 
InMemoryPolarisMetaStoreManagerFactory)) {
       helper.metaStoreManagerFactory.bootstrapRealms(
-          List.of(realm), PolarisCredentialsBootstrap.fromEnvironment());
+          List.of(realm), RootCredentialsSet.fromEnvironment());
     }
 
     RealmContext realmContext = () -> realm;
diff --git 
a/service/common/src/main/java/org/apache/polaris/service/persistence/InMemoryPolarisMetaStoreManagerFactory.java
 
b/service/common/src/main/java/org/apache/polaris/service/persistence/InMemoryPolarisMetaStoreManagerFactory.java
index c9bd21f1..7088fe7b 100644
--- 
a/service/common/src/main/java/org/apache/polaris/service/persistence/InMemoryPolarisMetaStoreManagerFactory.java
+++ 
b/service/common/src/main/java/org/apache/polaris/service/persistence/InMemoryPolarisMetaStoreManagerFactory.java
@@ -34,11 +34,11 @@ import org.apache.polaris.core.PolarisDiagnostics;
 import 
org.apache.polaris.core.auth.PolarisSecretsManager.PrincipalSecretsResult;
 import org.apache.polaris.core.context.RealmContext;
 import org.apache.polaris.core.persistence.LocalPolarisMetaStoreManagerFactory;
-import org.apache.polaris.core.persistence.PolarisCredentialsBootstrap;
 import org.apache.polaris.core.persistence.PolarisMetaStoreManager;
 import org.apache.polaris.core.persistence.PolarisMetaStoreSession;
 import org.apache.polaris.core.persistence.PolarisTreeMapMetaStoreSessionImpl;
 import org.apache.polaris.core.persistence.PolarisTreeMapStore;
+import org.apache.polaris.core.persistence.bootstrap.RootCredentialsSet;
 import org.apache.polaris.core.storage.PolarisStorageIntegrationProvider;
 import org.apache.polaris.service.context.RealmContextConfiguration;
 
@@ -77,13 +77,10 @@ public class InMemoryPolarisMetaStoreManagerFactory
   protected PolarisMetaStoreSession createMetaStoreSession(
       @Nonnull PolarisTreeMapStore store,
       @Nonnull RealmContext realmContext,
-      @Nullable PolarisCredentialsBootstrap credentialsBootstrap,
+      @Nullable RootCredentialsSet rootCredentialsSet,
       @Nonnull PolarisDiagnostics diagnostics) {
     return new PolarisTreeMapMetaStoreSessionImpl(
-        store,
-        storageIntegration,
-        secretsGenerator(realmContext, credentialsBootstrap),
-        diagnostics);
+        store, storageIntegration, secretsGenerator(realmContext, 
rootCredentialsSet), diagnostics);
   }
 
   @Override
@@ -107,10 +104,8 @@ public class InMemoryPolarisMetaStoreManagerFactory
   }
 
   private void bootstrapRealmsAndPrintCredentials(List<String> realms) {
-    PolarisCredentialsBootstrap credentialsBootstrap =
-        PolarisCredentialsBootstrap.fromEnvironment();
-    Map<String, PrincipalSecretsResult> results =
-        this.bootstrapRealms(realms, credentialsBootstrap);
+    RootCredentialsSet rootCredentialsSet = 
RootCredentialsSet.fromEnvironment();
+    Map<String, PrincipalSecretsResult> results = this.bootstrapRealms(realms, 
rootCredentialsSet);
     bootstrappedRealms.addAll(realms);
 
     for (String realmId : realms) {

Reply via email to