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

jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git


The following commit(s) were added to refs/heads/master by this push:
     new 75aff0e9fe Add config profiles and relaxed binding to the config system
75aff0e9fe is described below

commit 75aff0e9fe7677fc715895944b1c4ea6db3abe32
Author: James Bognar <[email protected]>
AuthorDate: Thu Jun 18 13:46:18 2026 -0400

    Add config profiles and relaxed binding to the config system
---
 .../commons/settings/RelaxedPropertySource.java    | 139 +++++++++++++
 .../apache/juneau/commons/settings/Settings.java   |  15 +-
 .../settings/RelaxedPropertySource_Test.java       | 124 +++++++++++
 .../main/java/org/apache/juneau/config/Config.java |  48 ++++-
 .../juneau/config/internal/ProfileMerge.java       |  89 ++++++++
 .../juneau/config/store/ProfileConfigStore.java    | 231 +++++++++++++++++++++
 .../apache/juneau/config/ConfigProfiles_Test.java  | 154 ++++++++++++++
 .../config/store/ProfileConfigStore_Test.java      | 156 ++++++++++++++
 .../SpringEnvironmentPropertySource.java           |  16 +-
 .../SpringEnvironmentPropertySource_Test.java      |  27 +++
 10 files changed, 996 insertions(+), 3 deletions(-)

diff --git 
a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/settings/RelaxedPropertySource.java
 
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/settings/RelaxedPropertySource.java
new file mode 100644
index 0000000000..e7100904ee
--- /dev/null
+++ 
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/settings/RelaxedPropertySource.java
@@ -0,0 +1,139 @@
+/*
+ * 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.juneau.commons.settings;
+
+import java.util.*;
+
+/**
+ * A {@link PropertySource} decorator that adds <b>relaxed binding</b> over a 
delegate source.
+ *
+ * <p>
+ * When a property lookup misses against the delegate, this source retries 
with a small set of
+ * <i>canonical name variants</i> derived from the requested name, so a single 
logical key can be supplied in any of
+ * the common environment conventions and still resolve.  This is the 
mechanism that lets a Kubernetes-style
+ * environment variable such as {@code MY_SECTION_MY_KEY} satisfy a lookup for 
{@code MySection/myKey}, or
+ * {@code MY_PROP} satisfy {@code my.prop} / {@code myProp}.
+ *
+ * <h5 class='section'>Candidate order</h5>
+ * <p>
+ * The requested name is always tried <b>verbatim first</b>, so exact-match 
behavior is preserved unchanged (an
+ * already-canonical key resolves with no transformation, and a source that 
defines the literal name wins).  Only on a
+ * miss are the relaxed variants tried, in this order:
+ * <ol>
+ *     <li>verbatim (the requested name, unchanged);
+ *     <li><b>upper underscore</b> &mdash; every run of non-alphanumeric 
characters (and every lower&rarr;upper camel-case
+ *             boundary) becomes a single {@code _}, then the whole thing is 
upper-cased.  This is the dominant env-var form:
+ *             {@code my.prop}/{@code my-prop}/{@code myProp}/{@code 
MySection/myKey} &rarr; {@code MY_PROP} /
+ *             {@code MY_SECTION_MY_KEY};
+ *     <li><b>lower dotted</b> &mdash; the same separator collapse but joined 
with {@code .} and lower-cased
+ *             ({@code MY_PROP} &rarr; {@code my.prop}), so a dotted-property 
lookup can be satisfied by an env-style key and
+ *             vice-versa.
+ * </ol>
+ *
+ * <p>
+ * Because the boundary between "section" and "key" in a flat relaxed name 
(e.g. the underscores in
+ * {@code MY_SECTION_MY_KEY}) is inherently ambiguous, this decorator 
deliberately works in the
+ * <b>lookup-key &rarr; candidate-name</b> direction only (the well-defined 
direction): the caller asks for a known
+ * logical key and the decorator generates the environment-convention 
spellings to probe.  It never tries to reverse a
+ * flat env name back into a {@code section/key} pair.
+ *
+ * <h5 class='section'>Usage</h5>
+ * <p class='bjava'>
+ *     <jc>// Wrap the system-env source so env vars bind relaxedly.</jc>
+ *     <jv>settings</jv> = Settings.<jsm>create</jsm>().addSource(<jk>new</jk> 
RelaxedPropertySource(Settings.<jsf>SYSTEM_ENV_SOURCE</jsf>)).build();
+ * </p>
+ *
+ * @since 10.0.0
+ */
+public class RelaxedPropertySource implements PropertySource {
+
+       private final PropertySource delegate;
+
+       /**
+        * Constructor.
+        *
+        * @param delegate The wrapped source that the generated candidate 
names are probed against.  Must not be <jk>null</jk>.
+        */
+       public RelaxedPropertySource(PropertySource delegate) {
+               this.delegate = Objects.requireNonNull(delegate, "delegate");
+       }
+
+       @Override
+       public PropertyLookupResult get(String name) {
+               if (name == null)
+                       return PropertyLookupResult.missing();
+               for (var candidate : candidates(name)) {
+                       var r = delegate.get(candidate);
+                       if (r.isPresent())
+                               return r;
+               }
+               return PropertyLookupResult.missing();
+       }
+
+       /**
+        * Generates the ordered, de-duplicated list of candidate names for a 
requested name (verbatim first).
+        *
+        * @param name The requested property name.
+        * @return The candidate names to probe, in priority order.
+        */
+       static List<String> candidates(String name) {
+               var out = new ArrayList<String>(3);
+               out.add(name);
+               var tokens = tokenize(name);
+               if (! tokens.isEmpty()) {
+                       addIfNew(out, String.join("_", 
tokens).toUpperCase(Locale.ROOT));
+                       addIfNew(out, String.join(".", 
tokens).toLowerCase(Locale.ROOT));
+               }
+               return out;
+       }
+
+       /**
+        * Splits a name into lowercase-normalized word tokens, breaking on any 
non-alphanumeric run and on each
+        * lower&rarr;upper camel-case boundary.  E.g. {@code 
"MySection/myKey"} &rarr; {@code [my, section, my, key]}.
+        */
+       private static List<String> tokenize(String name) {
+               var tokens = new ArrayList<String>();
+               var sb = new StringBuilder();
+               char prev = 0;
+               for (var i = 0; i < name.length(); i++) {
+                       var c = name.charAt(i);
+                       if (! Character.isLetterOrDigit(c)) {
+                               // Separator (., -, /, _, space, …): flush the 
current token.
+                               if (sb.length() > 0) {
+                                       tokens.add(sb.toString());
+                                       sb.setLength(0);
+                               }
+                       } else {
+                               // camelCase boundary: lower/digit followed by 
upper starts a new token.
+                               if (sb.length() > 0 && Character.isUpperCase(c) 
&& (Character.isLowerCase(prev) || Character.isDigit(prev))) {
+                                       tokens.add(sb.toString());
+                                       sb.setLength(0);
+                               }
+                               sb.append(Character.toLowerCase(c));
+                       }
+                       prev = c;
+               }
+               if (sb.length() > 0)
+                       tokens.add(sb.toString());
+               return tokens;
+       }
+
+       private static void addIfNew(List<String> out, String candidate) {
+               if (! out.contains(candidate))
+                       out.add(candidate);
+       }
+}
diff --git 
a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/settings/Settings.java
 
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/settings/Settings.java
index 5e4e0a2673..5bf3e7d04c 100644
--- 
a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/settings/Settings.java
+++ 
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/settings/Settings.java
@@ -159,6 +159,19 @@ public class Settings {
         */
        public static final PropertySource SYSTEM_ENV_SOURCE = 
FunctionalPropertySource.of(System::getenv);
 
+       /**
+        * {@link #SYSTEM_ENV_SOURCE} wrapped with {@link RelaxedPropertySource 
relaxed binding}, so a single logical key
+        * resolves from any of the common environment-variable spellings (e.g. 
{@code my.prop} / {@code myProp} /
+        * {@code MySection/myKey} all satisfied by {@code MY_PROP} / {@code 
MY_SECTION_MY_KEY}).
+        *
+        * <p>
+        * This is the env source used by the default {@link #get() singleton}. 
 It is verbatim-first, so exact-match
+        * behavior is unchanged &mdash; a relaxed variant is only probed when 
the exact name misses.
+        *
+        * @since 10.0.0
+        */
+       public static final PropertySource RELAXED_SYSTEM_ENV_SOURCE = new 
RelaxedPropertySource(SYSTEM_ENV_SOURCE);
+
        private static final String DISABLE_GLOBAL_PROP = 
"juneau.settings.disableGlobal";
        private static final String MSG_globalDisabled = "Global settings not 
enabled";
        private static final String MSG_localDisabled = "Local settings not 
enabled";
@@ -334,7 +347,7 @@ public class Settings {
 
        private static final Settings INSTANCE = new Builder()
                
.globalStore(initProperty(DISABLE_GLOBAL_PROP).map(Boolean::valueOf).orElse(false)
 ? () -> null : MapStore::new)
-               .setSources(SYSTEM_ENV_SOURCE, SYSTEM_PROPERTY_SOURCE)
+               .setSources(RELAXED_SYSTEM_ENV_SOURCE, SYSTEM_PROPERTY_SOURCE)
                .useServiceLoader()
                .build();
 
diff --git 
a/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/settings/RelaxedPropertySource_Test.java
 
b/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/settings/RelaxedPropertySource_Test.java
new file mode 100644
index 0000000000..4379a33414
--- /dev/null
+++ 
b/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/settings/RelaxedPropertySource_Test.java
@@ -0,0 +1,124 @@
+/*
+ * 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.juneau.commons.settings;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+
+import org.apache.juneau.commons.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Tests for {@link RelaxedPropertySource} — relaxed-binding candidate 
generation and decorator lookup.
+ */
+class RelaxedPropertySource_Test extends TestBase {
+
+       /** Simple in-memory delegate keyed by exact name. */
+       private static PropertySource map(String... kv) {
+               var m = new LinkedHashMap<String,String>();
+               for (var i = 0; i < kv.length; i += 2)
+                       m.put(kv[i], kv[i + 1]);
+               return name -> m.containsKey(name) ? 
PropertyLookupResult.present(Optional.ofNullable(m.get(name))) : 
PropertyLookupResult.missing();
+       }
+
+       private static String resolve(PropertySource s, String name) {
+               var r = s.get(name);
+               return r.isPresent() ? r.value().orElse(null) : null;
+       }
+
+       // 
=================================================================================
+       // A. Candidate generation
+       // 
=================================================================================
+
+       @Test void a01_candidates_edges() {
+               // A name with no alphanumerics tokenizes to nothing -> only 
the verbatim candidate.
+               assertEquals(List.of("_"), 
RelaxedPropertySource.candidates("_"));
+               assertEquals(List.of(""), RelaxedPropertySource.candidates(""));
+               assertEquals(List.of("/"), 
RelaxedPropertySource.candidates("/"));
+               // digit->upper camel boundary: "a1B" -> tokens [a1, b].
+               assertEquals(List.of("a1B", "A1_B", "a1.b"), 
RelaxedPropertySource.candidates("a1B"));
+       }
+
+       @Test void a02_candidates_basic() {
+               // "my.prop": verbatim, then UPPER_UNDERSCORE (=MY_PROP); the 
lower-dotted form equals verbatim so it dedups out.
+               assertEquals(List.of("my.prop", "MY_PROP"), 
RelaxedPropertySource.candidates("my.prop"));
+               assertEquals(List.of("myProp", "MY_PROP", "my.prop"), 
RelaxedPropertySource.candidates("myProp"));
+               // "MY_PROP": verbatim, then lower-dotted (=my.prop); the 
upper-underscore form equals verbatim so it dedups out.
+               assertEquals(List.of("MY_PROP", "my.prop"), 
RelaxedPropertySource.candidates("MY_PROP"));
+               assertEquals(List.of("MySection/myKey", "MY_SECTION_MY_KEY", 
"my.section.my.key"), RelaxedPropertySource.candidates("MySection/myKey"));
+               assertEquals(List.of("my-prop", "MY_PROP", "my.prop"), 
RelaxedPropertySource.candidates("my-prop"));
+       }
+
+       // 
=================================================================================
+       // B. Verbatim-first / exact-match preserved
+       // 
=================================================================================
+
+       @Test void b01_verbatimWins() {
+               // Both the exact key and a relaxed variant exist; the exact 
(verbatim) one must win.
+               var s = new RelaxedPropertySource(map("my.prop", "exact", 
"MY_PROP", "relaxed"));
+               assertEquals("exact", resolve(s, "my.prop"));
+       }
+
+       @Test void b02_alreadyCanonicalUnchanged() {
+               var s = new RelaxedPropertySource(map("MY_PROP", "v"));
+               assertEquals("v", resolve(s, "MY_PROP"));
+       }
+
+       // 
=================================================================================
+       // C. Relaxed matches
+       // 
=================================================================================
+
+       @Test void c01_dottedResolvesEnvStyle() {
+               var s = new RelaxedPropertySource(map("MY_PROP", "v"));
+               assertEquals("v", resolve(s, "my.prop"));
+       }
+
+       @Test void c02_camelResolvesEnvStyle() {
+               var s = new RelaxedPropertySource(map("MY_PROP", "v"));
+               assertEquals("v", resolve(s, "myProp"));
+       }
+
+       @Test void c03_sectionKeyResolvesEnvStyle() {
+               var s = new RelaxedPropertySource(map("MY_SECTION_MY_KEY", 
"v"));
+               assertEquals("v", resolve(s, "MySection/myKey"));
+       }
+
+       @Test void c04_envStyleResolvesDotted() {
+               var s = new RelaxedPropertySource(map("my.prop", "v"));
+               assertEquals("v", resolve(s, "MY_PROP"));
+       }
+
+       @Test void c05_missReturnsMissing() {
+               var s = new RelaxedPropertySource(map("OTHER", "v"));
+               assertNull(resolve(s, "my.prop"));
+               assertFalse(s.get("my.prop").isPresent());
+       }
+
+       @Test void c06_nullNameMissing() {
+               var s = new RelaxedPropertySource(map("X", "v"));
+               assertFalse(s.get(null).isPresent());
+       }
+
+       @Test void c07_presentNullValuePreserved() {
+               // A key present with a null value resolves as present-empty, 
not missing.
+               var s = new RelaxedPropertySource(name -> 
"MY_PROP".equals(name) ? PropertyLookupResult.present(Optional.empty()) : 
PropertyLookupResult.missing());
+               var r = s.get("my.prop");
+               assertTrue(r.isPresent());
+               assertTrue(r.value().isEmpty());
+       }
+}
diff --git 
a/juneau-core/juneau-config/src/main/java/org/apache/juneau/config/Config.java 
b/juneau-core/juneau-config/src/main/java/org/apache/juneau/config/Config.java
index 9148a0fc95..8d70a6dee8 100644
--- 
a/juneau-core/juneau-config/src/main/java/org/apache/juneau/config/Config.java
+++ 
b/juneau-core/juneau-config/src/main/java/org/apache/juneau/config/Config.java
@@ -97,6 +97,7 @@ public class Config extends Context implements 
ConfigEventListener {
                private ConfigFormat format;
                private VarResolver varResolver;
                private WriterSerializer serializer;
+               private List<String> profiles;
 
                /**
                 * Constructor, default settings.
@@ -114,6 +115,22 @@ public class Config extends Context implements 
ConfigEventListener {
                        store = FileStore.DEFAULT;
                        format = null;
                        varResolver = VarResolver.DEFAULT;
+                       profiles = defaultProfiles();
+               }
+
+               /**
+                * Resolves the default active profiles from {@code 
juneau.profiles.active} (system property / env var / config),
+                * comma-split and trimmed.  Returns an empty list when unset.
+                */
+               private static List<String> defaultProfiles() {
+                       var s = env("juneau.profiles.active", "");
+                       if (s == null || s.isBlank())
+                               return list();
+                       var out = new ArrayList<String>();
+                       for (var p : s.split(","))
+                               if (! p.isBlank())
+                                       out.add(p.trim());
+                       return out;
                }
 
                /**
@@ -134,6 +151,7 @@ public class Config extends Context implements 
ConfigEventListener {
                        serializer = copyFrom.serializer;
                        store = copyFrom.store;
                        varResolver = copyFrom.varResolver;
+                       profiles = copyFrom.profiles == null ? list() : new 
ArrayList<>(copyFrom.profiles);
                }
 
                /**
@@ -292,6 +310,29 @@ public class Config extends Context implements 
ConfigEventListener {
                        return this;
                }
 
+               /**
+                * Active configuration profiles.
+                *
+                * <p>
+                * Each active profile {@code P} activates a {@code <name>-P} 
overlay (e.g. base {@code my.cfg} + profile
+                * {@code stage} &rarr; {@code my-stage.cfg}) layered over the 
base config: profile entries win over base, and
+                * for multiple active profiles the <b>last</b> one wins.  YAML 
bases use {@code <name>-P.yml} overlays the same
+                * way.  Overlays are resolved through the configured {@link 
#store(ConfigStore) store}, so live-reload fires when
+                * either the base or an active profile file changes.
+                *
+                * <p>
+                * Defaults to the comma-separated value of {@code 
juneau.profiles.active} (system property, environment variable
+                * &mdash; with relaxed binding &mdash; or config key).  Pass 
no arguments / an empty array to disable profiles.
+                *
+                * @param value The active profile names, in activation order.  
May be empty.
+                * @return This object.
+                * @since 10.0.0
+                */
+               public Builder profiles(String...value) {
+                       profiles = value == null ? list() : list(value);
+                       return this;
+               }
+
                /**
                 * Configuration format.
                 *
@@ -600,7 +641,12 @@ public class Config extends Context implements 
ConfigEventListener {
                parser = builder.parser;
                readOnly = builder.readOnly;
                serializer = builder.serializer;
-               store = builder.store;
+               // When profiles are active, wrap the configured store so reads 
of the base name return the base config
+               // with each active profile's <name>-<profile> overlay merged 
on top (profile-wins, last-active-wins).
+               var profiles2 = builder.profiles == null ? 
Collections.<String>emptyList() : builder.profiles;
+               store = profiles2.isEmpty()
+                       ? builder.store
+                       : 
ProfileConfigStore.create().delegate(builder.store).baseName(name).profiles(profiles2).format(format).build();
                varResolver = builder.varResolver;
                configMap = store.getMap(name, format);
                configMap.register(this);
diff --git 
a/juneau-core/juneau-config/src/main/java/org/apache/juneau/config/internal/ProfileMerge.java
 
b/juneau-core/juneau-config/src/main/java/org/apache/juneau/config/internal/ProfileMerge.java
new file mode 100644
index 0000000000..9a7c6dd53b
--- /dev/null
+++ 
b/juneau-core/juneau-config/src/main/java/org/apache/juneau/config/internal/ProfileMerge.java
@@ -0,0 +1,89 @@
+/*
+ * 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.juneau.config.internal;
+
+import java.io.*;
+import java.util.*;
+
+import org.apache.juneau.config.format.*;
+import org.apache.juneau.config.store.*;
+
+/**
+ * Merges a base configuration with one or more profile overlays into a single 
internal-INI string.
+ *
+ * <p>
+ * This is the engine behind config <b>profiles</b> ({@code 
<name>-<profile>.cfg}/{@code .yml} overlays activated via
+ * {@code juneau.profiles.active}).  The base config is the foundation; each 
active profile's entries are layered on
+ * top in activation order so that:
+ * <ul>
+ *     <li><b>profile wins over base</b> for any section/key the profile 
redefines, and
+ *     <li><b>last-active-profile wins</b> when multiple active profiles 
redefine the same section/key.
+ * </ul>
+ *
+ * <p>
+ * Each input (base + profiles) is parsed through its own {@link ConfigFormat} 
(so a YAML profile is converted to the
+ * internal INI form like any other config), then the parsed maps are overlaid 
entry-by-entry and re-emitted as a
+ * single internal-INI string via {@link ConfigMap#asIniString()}.  Feeding 
that merged string back through the normal
+ * {@link ConfigMap} parse keeps format handling, variable resolution, and 
change-listener wiring identical to a
+ * single-file config &mdash; profiles are transparent to everything 
downstream of the store.
+ *
+ * @since 10.0.0
+ */
+public final class ProfileMerge {
+
+       private ProfileMerge() {}
+
+       /**
+        * Merges base + profile contents into one internal-INI string 
(profile-wins, last-active-profile-wins).
+        *
+        * @param store The store the throwaway parse maps are associated with 
(used only for parse context; never written).
+        * @param baseName The base config name (for parse diagnostics).
+        * @param baseContents The raw base config contents (format-native).
+        * @param profiles Ordered profile overlays (activation order); each is 
a raw format-native contents string.  May be empty.
+        * @param format The config format used to parse every input.
+        * @return The merged contents as an internal-INI string.
+        * @throws IOException If any input fails to parse.
+        */
+       public static String merge(ConfigStore store, String baseName, String 
baseContents, List<String> profiles, ConfigFormat format) throws IOException {
+               var f = format == null ? IniConfigFormat.INSTANCE : format;
+               // Parse the base into a throwaway map; this becomes the 
accumulator we overlay onto.
+               var merged = new ConfigMap(store, baseName, baseContents == 
null ? "" : baseContents, f);
+               for (var p : profiles) {
+                       if (p == null || p.isBlank())
+                               continue;
+                       var overlay = new ConfigMap(store, baseName, p, f);
+                       applyOverlay(merged, overlay);
+               }
+               return merged.asIniString();
+       }
+
+       /**
+        * Overlays every section/key entry from {@code overlay} onto {@code 
base} (overlay wins).
+        */
+       private static void applyOverlay(ConfigMap base, ConfigMap overlay) {
+               for (var section : overlay.getSections()) {
+                       // Ensure the section exists on the base (preserving 
the overlay's section pre-lines when it is new).
+                       if (! base.hasSection(section))
+                               base.setSection(section, 
overlay.getPreLines(section));
+                       for (var key : overlay.getKeys(section)) {
+                               var e = overlay.getEntry(section, key);
+                               if (e != null)  // HTT: getKeys() only returns 
keys with entries, so getEntry is never null here.
+                                       base.setEntry(section, key, 
e.getValue(), e.getModifiers(), e.getComment(), e.getPreLines());
+                       }
+               }
+       }
+}
diff --git 
a/juneau-core/juneau-config/src/main/java/org/apache/juneau/config/store/ProfileConfigStore.java
 
b/juneau-core/juneau-config/src/main/java/org/apache/juneau/config/store/ProfileConfigStore.java
new file mode 100644
index 0000000000..2502439e2e
--- /dev/null
+++ 
b/juneau-core/juneau-config/src/main/java/org/apache/juneau/config/store/ProfileConfigStore.java
@@ -0,0 +1,231 @@
+/*
+ * 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.juneau.config.store;
+
+import static org.apache.juneau.commons.utils.AssertionUtils.*;
+import static org.apache.juneau.commons.utils.Utils.*;
+
+import java.io.*;
+import java.util.*;
+
+import org.apache.juneau.config.format.*;
+import org.apache.juneau.config.internal.*;
+import org.apache.juneau.marshall.*;
+
+/**
+ * A {@link ConfigStore} decorator that layers <b>profile overlays</b> over a 
delegate store's base config.
+ *
+ * <p>
+ * Given a set of active profiles (e.g. {@code ["stage"]} or {@code 
["base","cloud"]}) and a base config name
+ * {@code N}, a {@code read(N)} returns the base contents from the delegate 
with each active profile's
+ * {@code N-<profile>} overlay merged on top &mdash; profile wins over base, 
last-active-profile wins over earlier
+ * ones (see {@link ProfileMerge}).  Reads for any name <i>other</i> than the 
configured base name pass straight
+ * through to the delegate.
+ *
+ * <p>
+ * <b>Live reload:</b> the store registers a listener on the delegate for the 
base name <i>and</i> each profile name,
+ * so a change to the base file or any active profile file re-runs the merge 
and re-fires the change to this store's
+ * own listeners (the {@link ConfigMap} sees a fresh merged contents and 
reloads).
+ *
+ * <p>
+ * The profile file name is derived from the base name by inserting {@code 
-<profile>} before the extension
+ * (e.g. base {@code my.cfg} + profile {@code stage} &rarr; {@code 
my-stage.cfg}); a base with no extension simply
+ * appends {@code -<profile>}.  Writes, existence checks, and name resolution 
delegate to the wrapped store.
+ *
+ * @since 10.0.0
+ */
+public class ProfileConfigStore extends ConfigStore {
+
+       /**
+        * Builder class.
+        */
+       public static class Builder extends ConfigStore.Builder<Builder> {
+
+               ConfigStore delegate;
+               String baseName;
+               List<String> profiles = new ArrayList<>();
+               ConfigFormat format = IniConfigFormat.INSTANCE;
+
+               /** Constructor, default settings. */
+               protected Builder() {}
+
+               /**
+                * Copy constructor.
+                *
+                * @param copyFrom The builder to copy from.  Cannot be 
<jk>null</jk>.
+                */
+               protected Builder(Builder copyFrom) {
+                       super(assertArgNotNull("copyFrom", copyFrom));
+                       this.delegate = copyFrom.delegate;
+                       this.baseName = copyFrom.baseName;
+                       this.profiles = new ArrayList<>(copyFrom.profiles);
+                       this.format = copyFrom.format;
+               }
+
+               /**
+                * Copy constructor.
+                *
+                * @param copyFrom The bean to copy from.  Cannot be 
<jk>null</jk>.
+                */
+               protected Builder(ProfileConfigStore copyFrom) {
+                       super(assertArgNotNull("copyFrom", copyFrom));
+                       type(copyFrom.getClass());
+                       this.delegate = copyFrom.delegate;
+                       this.baseName = copyFrom.baseName;
+                       this.profiles = new ArrayList<>(copyFrom.profiles);
+                       this.format = copyFrom.format;
+               }
+
+               /**
+                * Sets the wrapped delegate store that supplies the base + 
profile file contents.
+                *
+                * @param value The delegate store.
+                * @return This object.
+                */
+               public Builder delegate(ConfigStore value) { delegate = value; 
return this; }
+
+               /**
+                * Sets the base config name whose {@code -<profile>} overlays 
are merged.
+                *
+                * @param value The base config name.
+                * @return This object.
+                */
+               public Builder baseName(String value) { baseName = value; 
return this; }
+
+               /**
+                * Sets the active profiles, in activation order 
(last-active-profile wins).
+                *
+                * @param value The active profile names.
+                * @return This object.
+                */
+               public Builder profiles(List<String> value) { profiles = value 
== null ? new ArrayList<>() : new ArrayList<>(value); return this; }
+
+               /**
+                * Sets the config format used to parse the base + profile 
files.
+                *
+                * @param value The format.
+                * @return This object.
+                */
+               public Builder format(ConfigFormat value) { format = value == 
null ? IniConfigFormat.INSTANCE : value; return this; }
+
+               @Override /* Overridden from Context.Builder<?> */
+               public ProfileConfigStore build() {
+                       return build(ProfileConfigStore.class);
+               }
+
+               @Override /* Overridden from Context.Builder<?> */
+               public Builder copy() {
+                       return new Builder(this);
+               }
+       }
+
+       /**
+        * Creates a new builder for this object.
+        *
+        * @return A new builder.
+        */
+       public static Builder create() {
+               return new Builder();
+       }
+
+       private final ConfigStore delegate;
+       private final String baseName;
+       private final List<String> profiles;
+       private final ConfigFormat format;
+       private final ConfigStoreListener reloadListener;
+
+       /**
+        * Constructor.
+        *
+        * @param builder The builder for this object.
+        */
+       public ProfileConfigStore(Builder builder) {
+               super(builder);
+               this.delegate = assertArgNotNull("delegate", builder.delegate);
+               this.baseName = assertArgNotNull("baseName", builder.baseName);
+               this.profiles = List.copyOf(builder.profiles);
+               this.format = builder.format;
+
+               // Re-merge + re-notify whenever the base or any active profile 
file changes underneath us.
+               this.reloadListener = contents -> {
+                       try {
+                               update(baseName, mergedContents());
+                       } catch (IOException e) {  // HTT: re-merge IO failure 
only on a real FileStore IO error mid-reload; not reproducible with in-memory 
stores.
+                               throw new ConfigException(e, "Failed to 
re-merge profile overlays for ''{0}''", baseName);
+                       }
+               };
+               delegate.register(baseName, reloadListener);
+               for (var p : profiles)
+                       delegate.register(profileName(p), reloadListener);
+       }
+
+       @Override /* Overridden from Context */
+       public Builder copy() {
+               return new Builder(this);
+       }
+
+       @Override /* Overridden from Closeable */
+       public void close() throws IOException {
+               delegate.unregister(baseName, reloadListener);
+               for (var p : profiles)
+                       delegate.unregister(profileName(p), reloadListener);
+               delegate.close();
+       }
+
+       @Override /* Overridden from ConfigStore */
+       public boolean exists(String name) {
+               return delegate.exists(name);
+       }
+
+       @Override /* Overridden from ConfigStore */
+       public String read(String name) throws IOException {
+               if (! eq(name, baseName))
+                       return delegate.read(name);
+               return mergedContents();
+       }
+
+       @Override /* Overridden from ConfigStore */
+       public String write(String name, String expectedContents, String 
newContents) throws IOException {
+               return delegate.write(name, expectedContents, newContents);
+       }
+
+       /**
+        * Reads the base + each active profile from the delegate and merges 
them (profile-wins, last-active-wins).
+        */
+       private String mergedContents() throws IOException {
+               var base = delegate.read(baseName);
+               if (profiles.isEmpty())
+                       return base;
+               var overlays = new ArrayList<String>(profiles.size());
+               for (var p : profiles)
+                       overlays.add(delegate.read(profileName(p)));
+               return ProfileMerge.merge(delegate, baseName, base, overlays, 
format);
+       }
+
+       /**
+        * Derives the profile file name from the base name by inserting {@code 
-<profile>} before the extension.
+        *
+        * @param profile The profile name.
+        * @return The profile config name.
+        */
+       String profileName(String profile) {
+               var dot = baseName.lastIndexOf('.');
+               if (dot < 0)
+                       return baseName + "-" + profile;
+               return baseName.substring(0, dot) + "-" + profile + 
baseName.substring(dot);
+       }
+}
diff --git 
a/juneau-core/juneau-config/src/test/java/org/apache/juneau/config/ConfigProfiles_Test.java
 
b/juneau-core/juneau-config/src/test/java/org/apache/juneau/config/ConfigProfiles_Test.java
new file mode 100644
index 0000000000..aea0a2a70a
--- /dev/null
+++ 
b/juneau-core/juneau-config/src/test/java/org/apache/juneau/config/ConfigProfiles_Test.java
@@ -0,0 +1,154 @@
+/*
+ * 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.juneau.config;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.concurrent.*;
+import java.util.concurrent.atomic.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.config.event.*;
+import org.apache.juneau.config.store.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Tests for config <b>profiles</b> &mdash; {@code <name>-<profile>} overlays 
activated via
+ * {@code Config.Builder.profiles(...)} (the standalone path; the Spring 
piggyback is covered in the springboot module).
+ */
+class ConfigProfiles_Test extends TestBase {
+
+       @SuppressWarnings({
+               "resource" // MemoryStore/Config are test fixtures; lifecycle 
managed by the test, not a real leak.
+       })
+       private static MemoryStore store(String... namesAndContents) {
+               var s = MemoryStore.create().build();
+               for (var i = 0; i < namesAndContents.length; i += 2)
+                       s.update(namesAndContents[i], namesAndContents[i + 1]);
+               return s;
+       }
+
+       @SuppressWarnings({
+               "resource" // see above.
+       })
+       private static Config config(MemoryStore s, String name, String... 
profiles) {
+               try {
+                       return 
Config.create().store(s).name(name).profiles(profiles).build();
+               } catch (Exception e) {
+                       throw new RuntimeException(e);
+               }
+       }
+
+       // 
=================================================================================
+       // A. Basic overlay — profile wins over base; base-only keys survive.
+       // 
=================================================================================
+
+       @Test void a01_profileOverridesBase_baseOnlySurvives() {
+               var s = store(
+                       "App.cfg", "[S]\na = base-a\nb = base-b\n",
+                       "App-stage.cfg", "[S]\na = stage-a\n");
+               var c = config(s, "App.cfg", "stage");
+               assertEquals("stage-a", c.get("S/a").orElse(null));  // profile 
wins
+               assertEquals("base-b", c.get("S/b").orElse(null));   // 
base-only survives
+       }
+
+       @Test void a02_noProfilesIsBaseOnly() {
+               var s = store("App.cfg", "[S]\na = base-a\n", "App-stage.cfg", 
"[S]\na = stage-a\n");
+               var c = config(s, "App.cfg");
+               assertEquals("base-a", c.get("S/a").orElse(null));
+       }
+
+       @Test void a03_newSectionFromProfile() {
+               var s = store(
+                       "App.cfg", "[S]\na = base-a\n",
+                       "App-stage.cfg", "[T]\nx = stage-x\n");
+               var c = config(s, "App.cfg", "stage");
+               assertEquals("base-a", c.get("S/a").orElse(null));
+               assertEquals("stage-x", c.get("T/x").orElse(null));  // section 
only in the profile
+       }
+
+       // 
=================================================================================
+       // B. Multiple active profiles — last-active-profile wins.
+       // 
=================================================================================
+
+       @Test void b01_lastActiveProfileWins() {
+               var s = store(
+                       "App.cfg", "[S]\na = base\n",
+                       "App-one.cfg", "[S]\na = one\n",
+                       "App-two.cfg", "[S]\na = two\n");
+               var c = config(s, "App.cfg", "one", "two");
+               assertEquals("two", c.get("S/a").orElse(null));  // two 
activated last
+       }
+
+       @Test void b02_earlierProfileStillContributesUnsharedKeys() {
+               var s = store(
+                       "App.cfg", "[S]\na = base\n",
+                       "App-one.cfg", "[S]\nb = one-b\n",
+                       "App-two.cfg", "[S]\na = two-a\n");
+               var c = config(s, "App.cfg", "one", "two");
+               assertEquals("two-a", c.get("S/a").orElse(null));   // two wins 
for a
+               assertEquals("one-b", c.get("S/b").orElse(null));   // one's 
unique key survives
+       }
+
+       // 
=================================================================================
+       // C. Missing profile file is a no-op overlay (base unchanged).
+       // 
=================================================================================
+
+       @Test void c01_missingProfileFileNoOp() {
+               var s = store("App.cfg", "[S]\na = base-a\n");  // no 
App-stage.cfg seeded
+               var c = config(s, "App.cfg", "stage");
+               assertEquals("base-a", c.get("S/a").orElse(null));
+       }
+
+       // 
=================================================================================
+       // D. Live reload — a change to a profile overlay file re-fires 
config-change listeners.
+       // 
=================================================================================
+
+       @Test void d01_profileFileChangeTriggersReload() throws Exception {
+               var s = store(
+                       "App.cfg", "[S]\na = base-a\n",
+                       "App-stage.cfg", "[S]\na = stage-a\n");
+               var c = config(s, "App.cfg", "stage");
+               assertEquals("stage-a", c.get("S/a").orElse(null));
+
+               var latch = new CountDownLatch(1);
+               var seen = new AtomicReference<String>();
+               c.addListener((ConfigEventListener) events -> { 
seen.set("fired"); latch.countDown(); });
+
+               // Change the PROFILE file underneath the store.
+               s.update("App-stage.cfg", "[S]\na = stage-a2\n");
+
+               assertTrue(latch.await(5, TimeUnit.SECONDS), "Expected a 
config-change event from the profile-file update");
+               assertEquals("stage-a2", c.get("S/a").orElse(null));  // 
re-merged value visible
+       }
+
+       @Test void d02_baseFileChangeTriggersReload() throws Exception {
+               var s = store(
+                       "App.cfg", "[S]\na = base-a\nb = base-b\n",
+                       "App-stage.cfg", "[S]\na = stage-a\n");
+               var c = config(s, "App.cfg", "stage");
+
+               var latch = new CountDownLatch(1);
+               c.addListener((ConfigEventListener) events -> 
latch.countDown());
+
+               s.update("App.cfg", "[S]\na = base-a\nb = base-b2\n");
+
+               assertTrue(latch.await(5, TimeUnit.SECONDS), "Expected a 
config-change event from the base-file update");
+               assertEquals("stage-a", c.get("S/a").orElse(null));   // 
profile still wins after base reload
+               assertEquals("base-b2", c.get("S/b").orElse(null));   // base 
change visible
+       }
+}
diff --git 
a/juneau-core/juneau-config/src/test/java/org/apache/juneau/config/store/ProfileConfigStore_Test.java
 
b/juneau-core/juneau-config/src/test/java/org/apache/juneau/config/store/ProfileConfigStore_Test.java
new file mode 100644
index 0000000000..93fdeea442
--- /dev/null
+++ 
b/juneau-core/juneau-config/src/test/java/org/apache/juneau/config/store/ProfileConfigStore_Test.java
@@ -0,0 +1,156 @@
+/*
+ * 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.juneau.config.store;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.config.format.*;
+import org.apache.juneau.config.internal.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Direct tests for {@link ProfileConfigStore} (the profile-overlay store 
decorator) and {@link ProfileMerge}
+ * (the overlay engine), exercising read pass-through, delegation, name 
derivation, lifecycle, and the merge edges.
+ */
+@SuppressWarnings({
+       "resource" // MemoryStore/ProfileConfigStore are test fixtures; 
lifecycle managed by the test, not a real leak.
+})
+class ProfileConfigStore_Test extends TestBase {
+
+       private static MemoryStore store(String... kv) {
+               var s = MemoryStore.create().build();
+               for (var i = 0; i < kv.length; i += 2)
+                       s.update(kv[i], kv[i + 1]);
+               return s;
+       }
+
+       private static ProfileConfigStore wrap(MemoryStore delegate, String 
baseName, String... profiles) {
+               return 
ProfileConfigStore.create().delegate(delegate).baseName(baseName).profiles(Arrays.asList(profiles)).format(IniConfigFormat.INSTANCE).build();
+       }
+
+       // 
=================================================================================
+       // A. read() — base name merges; other names pass through.
+       // 
=================================================================================
+
+       @Test void a01_readBaseNameMerges() throws Exception {
+               var d = store("App.cfg", "[S]\na = base\n", "App-stage.cfg", 
"[S]\na = stage\n");
+               var p = wrap(d, "App.cfg", "stage");
+               assertTrue(p.read("App.cfg").contains("stage"));
+       }
+
+       @Test void a02_readOtherNamePassesThrough() throws Exception {
+               var d = store("App.cfg", "[S]\na = base\n", "Other.cfg", 
"[S]\nx = y\n");
+               var p = wrap(d, "App.cfg", "stage");
+               assertTrue(p.read("Other.cfg").contains("x = y"));
+       }
+
+       @Test void a03_noProfilesReturnsBaseVerbatim() throws Exception {
+               var d = store("App.cfg", "[S]\na = base\n");
+               var p = wrap(d, "App.cfg");  // no profiles
+               assertTrue(p.read("App.cfg").contains("base"));
+       }
+
+       // 
=================================================================================
+       // B. Delegation — exists / write delegate to the wrapped store.
+       // 
=================================================================================
+
+       @Test void b01_existsDelegates() {
+               var d = store("App.cfg", "x");
+               var p = wrap(d, "App.cfg", "stage");
+               assertTrue(p.exists("App.cfg"));
+               assertFalse(p.exists("Nope.cfg"));
+       }
+
+       @Test void b02_writeDelegates() throws Exception {
+               var d = store("App.cfg", "[S]\na = base\n");
+               var p = wrap(d, "App.cfg", "stage");
+               p.write("Other.cfg", null, "[S]\nx = written\n");
+               assertTrue(d.read("Other.cfg").contains("written"));
+       }
+
+       // 
=================================================================================
+       // C. profileName() — inserts -<profile> before the extension; appends 
when none.
+       // 
=================================================================================
+
+       @Test void c01_profileNameWithExtension() {
+               var p = wrap(store("App.cfg", ""), "App.cfg", "stage");
+               assertEquals("App-stage.cfg", p.profileName("stage"));
+       }
+
+       @Test void c02_profileNameNoExtension() {
+               var p = wrap(store("App", ""), "App", "stage");
+               assertEquals("App-stage", p.profileName("stage"));
+       }
+
+       // 
=================================================================================
+       // D. Lifecycle — copy() round-trips; close() unregisters + closes 
delegate.
+       // 
=================================================================================
+
+       @Test void d01_copyRoundTrips() {
+               var p = wrap(store("App.cfg", ""), "App.cfg", "stage");
+               var p2 = p.copy().build();  // store.copy() -> 
Builder(ProfileConfigStore)
+               assertEquals("App-stage.cfg", p2.profileName("stage"));
+       }
+
+       @Test void d01b_builderCopyRoundTrips() {
+               var d = store("App.cfg", "");
+               var b = 
ProfileConfigStore.create().delegate(d).baseName("App.cfg").profiles(List.of("stage")).format(IniConfigFormat.INSTANCE);
+               var p = b.copy().build();  // Builder.copy() -> Builder(Builder)
+               assertEquals("App-stage.cfg", p.profileName("stage"));
+       }
+
+       @Test void d02_closeIsClean() {
+               var d = store("App.cfg", "[S]\na = base\n", "App-stage.cfg", 
"[S]\na = stage\n");
+               var p = wrap(d, "App.cfg", "stage");
+               assertDoesNotThrow(p::close);  // unregisters listeners + 
closes delegate
+       }
+
+       // 
=================================================================================
+       // E. ProfileMerge edges — null format, null base, blank/empty profiles.
+       // 
=================================================================================
+
+       @Test void e01_mergeNullFormatDefaultsIni() throws Exception {
+               var d = store();
+               var merged = ProfileMerge.merge(d, "App.cfg", "[S]\na = 
base\n", List.of("[S]\na = over\n"), null);
+               assertTrue(merged.contains("over"));
+       }
+
+       @Test void e02_mergeNullBaseContents() throws Exception {
+               var d = store();
+               var merged = ProfileMerge.merge(d, "App.cfg", null, 
List.of("[S]\na = over\n"), IniConfigFormat.INSTANCE);
+               assertTrue(merged.contains("over"));
+       }
+
+       @Test void e03_mergeBlankAndNullProfilesSkipped() throws Exception {
+               var d = store();
+               var profiles = new ArrayList<String>();
+               profiles.add("");      // blank — skipped
+               profiles.add(null);    // null — skipped
+               profiles.add("[S]\na = over\n");
+               var merged = ProfileMerge.merge(d, "App.cfg", "[S]\na = 
base\n", profiles, IniConfigFormat.INSTANCE);
+               assertTrue(merged.contains("over"));
+       }
+
+       @Test void e04_mergeNoProfilesReturnsBase() throws Exception {
+               var d = store();
+               var merged = ProfileMerge.merge(d, "App.cfg", "[S]\na = 
base\n", List.of(), IniConfigFormat.INSTANCE);
+               assertTrue(merged.contains("base"));
+       }
+}
diff --git 
a/juneau-rest/juneau-rest-server-springboot/src/main/java/org/apache/juneau/rest/server/springboot/SpringEnvironmentPropertySource.java
 
b/juneau-rest/juneau-rest-server-springboot/src/main/java/org/apache/juneau/rest/server/springboot/SpringEnvironmentPropertySource.java
index 851bc719c0..472a602921 100644
--- 
a/juneau-rest/juneau-rest-server-springboot/src/main/java/org/apache/juneau/rest/server/springboot/SpringEnvironmentPropertySource.java
+++ 
b/juneau-rest/juneau-rest-server-springboot/src/main/java/org/apache/juneau/rest/server/springboot/SpringEnvironmentPropertySource.java
@@ -115,11 +115,25 @@ public class SpringEnvironmentPropertySource implements 
org.apache.juneau.common
                                }
                        }
                }
-               if (e == null || ! e.containsProperty(name))
+               if (e == null)
+                       return 
org.apache.juneau.commons.settings.PropertyLookupResult.missing();
+               // Profile piggyback: a Juneau config-profile activation lookup 
resolves to Spring's active profiles, so a
+               // Spring-Boot deployment has a single source of truth for 
which profiles are active.  Only answered when the
+               // caller has NOT explicitly set juneau.profiles.active as a 
property (that exact key wins if present).
+               if (PROFILES_ACTIVE_KEY.equals(name) && ! 
e.containsProperty(name)) {
+                       var active = e.getActiveProfiles();
+                       if (active != null && active.length > 0)  // HTT: 
getActiveProfiles() never returns null per the Environment contract; the guard 
is defensive.
+                               return 
org.apache.juneau.commons.settings.PropertyLookupResult.present(opt(String.join(",",
 active)));
+                       return 
org.apache.juneau.commons.settings.PropertyLookupResult.missing();
+               }
+               if (! e.containsProperty(name))
                        return 
org.apache.juneau.commons.settings.PropertyLookupResult.missing();
                // Spring's getProperty() returns null only for unresolved 
placeholders, which
                // containsProperty() already filtered out.  Wrap defensively 
anyway.
                var v = e.getProperty(name);
                return 
org.apache.juneau.commons.settings.PropertyLookupResult.present(opt(v));
        }
+
+       /** The Juneau config-profile activation key that piggybacks on 
Spring's active profiles. */
+       private static final String PROFILES_ACTIVE_KEY = 
"juneau.profiles.active";
 }
diff --git 
a/juneau-rest/juneau-rest-server-springboot/src/test/java/org/apache/juneau/rest/server/springboot/SpringEnvironmentPropertySource_Test.java
 
b/juneau-rest/juneau-rest-server-springboot/src/test/java/org/apache/juneau/rest/server/springboot/SpringEnvironmentPropertySource_Test.java
index 68b6937a8a..340921b1dc 100644
--- 
a/juneau-rest/juneau-rest-server-springboot/src/test/java/org/apache/juneau/rest/server/springboot/SpringEnvironmentPropertySource_Test.java
+++ 
b/juneau-rest/juneau-rest-server-springboot/src/test/java/org/apache/juneau/rest/server/springboot/SpringEnvironmentPropertySource_Test.java
@@ -237,4 +237,31 @@ class SpringEnvironmentPropertySource_Test extends 
TestBase {
                        store.clear();
                }
        }
+
+       // 
=================================================================================
+       // D. Config-profile piggyback — juneau.profiles.active resolves to 
Spring's active profiles.
+       // 
=================================================================================
+
+       @Test void d01_profilesActiveResolvesToSpringActiveProfiles() {
+               var env = new MockEnvironment();
+               env.setActiveProfiles("stage", "cloud");
+               var src = new SpringEnvironmentPropertySource(env);
+               var r = src.get("juneau.profiles.active");
+               assertTrue(r.isPresent());
+               assertEquals("stage,cloud", r.value().orElse(null));
+       }
+
+       @Test void d02_noActiveProfilesMissing() {
+               var env = new MockEnvironment();  // no active profiles
+               var src = new SpringEnvironmentPropertySource(env);
+               assertFalse(src.get("juneau.profiles.active").isPresent());
+       }
+
+       @Test void d03_explicitPropertyWinsOverActiveProfiles() {
+               // An explicit juneau.profiles.active property takes precedence 
over getActiveProfiles().
+               var env = new 
MockEnvironment().withProperty("juneau.profiles.active", "explicit");
+               env.setActiveProfiles("stage");
+               var src = new SpringEnvironmentPropertySource(env);
+               assertEquals("explicit", 
src.get("juneau.profiles.active").value().orElse(null));
+       }
 }

Reply via email to