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 5ef12bbbb1 TODO-281: resolve PARITY cluster (INI round-trip, openapi3 
drift, Tomcat launcher)
5ef12bbbb1 is described below

commit 5ef12bbbb13e10e9b1d2515449c8938b880347a9
Author: James Bognar <[email protected]>
AuthorDate: Tue Jul 21 20:22:49 2026 -0400

    TODO-281: resolve PARITY cluster (INI round-trip, openapi3 drift, Tomcat 
launcher)
    
    PARITY-01: add IniParser.Builder.kvSeparator(char) threaded through 
context/session
    (+ @IniConfig wiring) so a custom key/value separator round-trips; legacy 
=/: behavior
    preserved when left at the default. PARITY-02: add getSiteName/setSiteName 
(x-siteName)
    and addProperty(String,SchemaInfo) to openapi-v3 for parity with 
swagger-v2, and document
    the required(List)/requiredProperties(Set) divergence as intentional (no 
rename).
    PARITY-03: build TomcatMicroservice zero-config launcher + 
HealthProbeConfiguration
    mirroring the Jetty facade. Adds round-trip and smoke tests; new public API
    (IniParser.kvSeparator, TomcatMicroservice) noted for the 10.0.0 release 
notes.
    
    Co-authored-by: Cursor <[email protected]>
---
 .../java/org/apache/juneau/bean/openapi3/Info.java |  32 +++++
 .../apache/juneau/bean/openapi3/SchemaInfo.java    |  26 ++++
 .../org/apache/juneau/bean/openapi3/Info_Test.java |  42 +++---
 .../juneau/bean/openapi3/SchemaInfo_Test.java      |  13 +-
 .../org/apache/juneau/bean/swagger/SchemaInfo.java |   8 ++
 .../juneau/marshall/ini/IniConfigAnnotation.java   |   3 +-
 .../org/apache/juneau/marshall/ini/IniParser.java  |  37 ++++++
 .../juneau/marshall/ini/IniParserSession.java      |  24 +++-
 .../juneau/marshall/ini/IniRoundTrip_Test.java     |  37 ++++++
 .../tomcat/HealthProbeConfiguration.java           |  53 ++++++++
 .../microservice/tomcat/TomcatMicroservice.java    | 147 +++++++++++++++++++++
 .../tomcat/TomcatMicroservice_Test.java            |  98 ++++++++++++++
 12 files changed, 490 insertions(+), 30 deletions(-)

diff --git 
a/juneau-bean/juneau-bean-openapi-v3/src/main/java/org/apache/juneau/bean/openapi3/Info.java
 
b/juneau-bean/juneau-bean-openapi-v3/src/main/java/org/apache/juneau/bean/openapi3/Info.java
index ceeddac2c3..6ff14c4bf6 100644
--- 
a/juneau-bean/juneau-bean-openapi-v3/src/main/java/org/apache/juneau/bean/openapi3/Info.java
+++ 
b/juneau-bean/juneau-bean-openapi-v3/src/main/java/org/apache/juneau/bean/openapi3/Info.java
@@ -110,10 +110,12 @@ public class Info extends OpenApiElement {
        private static final String PROP_contact = "contact";
        private static final String PROP_description = "description";
        private static final String PROP_license = "license";
+       private static final String PROP_siteName = "siteName";
        private static final String PROP_termsOfService = "termsOfService";
        private static final String PROP_title = "title";
        private static final String PROP_version = "version";
 
+       private String siteName;
        private String title;
        private String description;
        private String termsOfService;
@@ -136,6 +138,7 @@ public class Info extends OpenApiElement {
 
                this.title = copyFrom.title;
                this.description = copyFrom.description;
+               this.siteName = copyFrom.siteName;
                this.termsOfService = copyFrom.termsOfService;
                this.version = copyFrom.version;
                this.contact = copyOf(copyFrom.contact);
@@ -160,6 +163,7 @@ public class Info extends OpenApiElement {
                        case PROP_termsOfService -> toType(getTermsOfService(), 
type);
                        case PROP_contact -> toType(getContact(), type);
                        case PROP_license -> toType(getLicense(), type);
+                       case PROP_siteName -> toType(getSiteName(), type);
                        case PROP_version -> toType(getVersion(), type);
                        default -> super.get(property, type);
                };
@@ -195,6 +199,16 @@ public class Info extends OpenApiElement {
         */
        public License getLicense() { return license; }
 
+       /**
+        * Bean property getter:  <property>siteName</property>.
+        *
+        * <p>
+        * The site name of the application.
+        *
+        * @return The property value, or <jk>null</jk> if it is not set.
+        */
+       public String getSiteName() { return siteName; }
+
        /**
         * Bean property getter:  <property>termsOfService</property>.
         *
@@ -232,6 +246,7 @@ public class Info extends OpenApiElement {
                        .addIf(nn(contact), PROP_contact)
                        .addIf(nn(description), PROP_description)
                        .addIf(nn(license), PROP_license)
+                       .addIf(nn(siteName), PROP_siteName)
                        .addIf(nn(termsOfService), PROP_termsOfService)
                        .addIf(nn(title), PROP_title)
                        .addIf(nn(version), PROP_version)
@@ -247,6 +262,7 @@ public class Info extends OpenApiElement {
                        case PROP_contact -> setContact(toType(value, 
Contact.class));
                        case PROP_description -> setDescription(s(value));
                        case PROP_license -> setLicense(toType(value, 
License.class));
+                       case PROP_siteName -> setSiteName(s(value));
                        case PROP_termsOfService -> setTermsOfService(s(value));
                        case PROP_title -> setTitle(s(value));
                        case PROP_version -> setVersion(s(value));
@@ -305,6 +321,22 @@ public class Info extends OpenApiElement {
                return this;
        }
 
+       /**
+        * Bean property setter:  <property>siteName</property>.
+        *
+        * <p>
+        * The site name of the application.
+        *
+        * @param value
+        *      The new value for this property.
+        *      <br>Can be <jk>null</jk> to unset the property.
+        * @return This object
+        */
+       public Info setSiteName(String value) {
+               siteName = value;
+               return this;
+       }
+
        /**
         * Bean property setter:  <property>termsOfService</property>.
         *
diff --git 
a/juneau-bean/juneau-bean-openapi-v3/src/main/java/org/apache/juneau/bean/openapi3/SchemaInfo.java
 
b/juneau-bean/juneau-bean-openapi-v3/src/main/java/org/apache/juneau/bean/openapi3/SchemaInfo.java
index a617a7738a..88fd7e6860 100644
--- 
a/juneau-bean/juneau-bean-openapi-v3/src/main/java/org/apache/juneau/bean/openapi3/SchemaInfo.java
+++ 
b/juneau-bean/juneau-bean-openapi-v3/src/main/java/org/apache/juneau/bean/openapi3/SchemaInfo.java
@@ -104,7 +104,9 @@ import org.apache.juneau.commons.collections.*;
 public class SchemaInfo extends OpenApiElement {
 
        // Argument name constants for assertArgNotNull
+       private static final String ARG_key = "key";
        private static final String ARG_property = "property";
+       private static final String ARG_value = "value";
 
        // Property name constants
        private static final String PROP_additionalProperties = 
"additionalProperties";
@@ -356,6 +358,22 @@ public class SchemaInfo extends OpenApiElement {
                return this;
        }
 
+       /**
+        * Bean property appender:  <property>properties</property>.
+        *
+        * @param key The property key.  Must not be <jk>null</jk>.
+        * @param value The property value.  Must not be <jk>null</jk>.
+        * @return This object.
+        */
+       public SchemaInfo addProperty(String key, SchemaInfo value) {
+               assertArgNotNull(ARG_key, key);
+               assertArgNotNull(ARG_value, value);
+               if (properties == null)
+                       properties = map();
+               properties.put(key, value);
+               return this;
+       }
+
        /**
         * Same as {@link #addRequired(String...)}.
         *
@@ -661,6 +679,14 @@ public class SchemaInfo extends OpenApiElement {
         * <p>
         * The list of required properties.
         *
+        * <p>
+        * <b>Note:</b> Named/typed <c>required</c> (<c>List&lt;String&gt;</c>) 
here per the OpenAPI 3.x JSON Schema
+        * subset, vs. <c>requiredProperties</c> (<c>Set&lt;String&gt;</c>) in 
swagger-v2's sibling
+        * {@code org.apache.juneau.bean.swagger.SchemaInfo}. This is an 
intentional, maintainer-confirmed divergence
+        * (PARITY-02): each module tracks its own spec version's canonical 
field name/type; swagger-v2 additionally
+        * carries a separate boolean {@code getRequired()}/{@code 
setRequired(Boolean)} pair that this module has no
+        * equivalent for. No rename/retype planned.
+        *
         * @return The property value, or <jk>null</jk> if it is not set.
         */
        public List<String> getRequired() { return nie(required); }
diff --git 
a/juneau-bean/juneau-bean-openapi-v3/src/test/java/org/apache/juneau/bean/openapi3/Info_Test.java
 
b/juneau-bean/juneau-bean-openapi-v3/src/test/java/org/apache/juneau/bean/openapi3/Info_Test.java
index 2103336205..719f77565b 100644
--- 
a/juneau-bean/juneau-bean-openapi-v3/src/test/java/org/apache/juneau/bean/openapi3/Info_Test.java
+++ 
b/juneau-bean/juneau-bean-openapi-v3/src/test/java/org/apache/juneau/bean/openapi3/Info_Test.java
@@ -39,14 +39,15 @@ class Info_Test extends TestBase {
                                        
.setContact(contact().setEmail("a1").setName("a2").setUrl(URI.create("a3")))
                                        .setDescription("b")
                                        
.setLicense(license().setName("c1").setUrl(URI.create("c2")))
-                                       .setTermsOfService("d")
-                                       .setTitle("e")
-                                       .setVersion("f")
+                                       .setSiteName("d")
+                                       .setTermsOfService("e")
+                                       .setTitle("f")
+                                       .setVersion("g")
                        )
-                       
.props("contact{email,name,url},description,license{name,url},termsOfService,title,version")
-                       .vals("{a1,a2,a3},b,{c1,c2},d,e,f")
-                       
.json("{contact:{email:'a1',name:'a2',url:'a3'},description:'b',license:{name:'c1',url:'c2'},termsOfService:'d',title:'e',version:'f'}")
-                       
.string("{'contact':{'email':'a1','name':'a2','url':'a3'},'description':'b','license':{'name':'c1','url':'c2'},'termsOfService':'d','title':'e','version':'f'}".replace('\'','"'))
+                       
.props("contact{email,name,url},description,license{name,url},siteName,termsOfService,title,version")
+                       .vals("{a1,a2,a3},b,{c1,c2},d,e,f,g")
+                       
.json("{contact:{email:'a1',name:'a2',url:'a3'},description:'b',license:{name:'c1',url:'c2'},siteName:'d',termsOfService:'e',title:'f',version:'g'}")
+                       
.string("{'contact':{'email':'a1','name':'a2','url':'a3'},'description':'b','license':{'name':'c1','url':'c2'},'siteName':'d','termsOfService':'e','title':'f','version':'g'}".replace('\'','"'))
                ;
 
                @Test void a01_gettersAndSetters() {
@@ -74,7 +75,7 @@ class Info_Test extends TestBase {
                }
 
                @Test void a07_keySet() {
-                       assertList(TESTER.bean().keySet(), "contact", 
"description", "license", "termsOfService", "title", "version");
+                       assertList(TESTER.bean().keySet(), "contact", 
"description", "license", "siteName", "termsOfService", "title", "version");
                }
 
                @Test void a08_nullParameters() {
@@ -157,16 +158,17 @@ class Info_Test extends TestBase {
                                        .set("contact", contact().setName("a"))
                                        .set("description", "b")
                                        .set("license", license().setName("c"))
-                                       .set("termsOfService", "d")
-                                       .set("title", "e")
-                                       .set("version", "f")
+                                       .set("siteName", "d")
+                                       .set("termsOfService", "e")
+                                       .set("title", "f")
+                                       .set("version", "g")
                                        .set("x1", "x1a")
                                        .set("x2", null)
                        )
-                       
.props("contact{name},description,license{name},termsOfService,title,version,x1,x2")
-                       .vals("{a},b,{c},d,e,f,x1a,<null>")
-                       
.json("{contact:{name:'a'},description:'b',license:{name:'c'},termsOfService:'d',title:'e',version:'f',x1:'x1a'}")
-                       
.string("{'contact':{'name':'a'},'description':'b','license':{'name':'c'},'termsOfService':'d','title':'e','version':'f','x1':'x1a'}".replace('\'',
 '"'))
+                       
.props("contact{name},description,license{name},siteName,termsOfService,title,version,x1,x2")
+                       .vals("{a},b,{c},d,e,f,g,x1a,<null>")
+                       
.json("{contact:{name:'a'},description:'b',license:{name:'c'},siteName:'d',termsOfService:'e',title:'f',version:'g',x1:'x1a'}")
+                       
.string("{'contact':{'name':'a'},'description':'b','license':{'name':'c'},'siteName':'d','termsOfService':'e','title':'f','version':'g','x1':'x1a'}".replace('\'',
 '"'))
                ;
 
                @Test void c01_gettersAndSetters() {
@@ -194,22 +196,22 @@ class Info_Test extends TestBase {
                }
 
                @Test void c07_keySet() {
-                       assertList(TESTER.bean().keySet(), "contact", 
"description", "license", "termsOfService", "title", "version", "x1", "x2");
+                       assertList(TESTER.bean().keySet(), "contact", 
"description", "license", "siteName", "termsOfService", "title", "version", 
"x1", "x2");
                }
 
                @Test void c08_get() {
                        assertMapped(
                                TESTER.bean(), (obj,prop) -> obj.get(prop, 
Object.class),
-                               
"contact{name},description,license{name},termsOfService,title,version,x1,x2",
-                               "{a},b,{c},d,e,f,x1a,<null>"
+                               
"contact{name},description,license{name},siteName,termsOfService,title,version,x1,x2",
+                               "{a},b,{c},d,e,f,g,x1a,<null>"
                        );
                }
 
                @Test void c09_getTypes() {
                        assertMapped(
                                TESTER.bean(), (obj,prop) -> cns(obj.get(prop, 
Object.class)),
-                               
"contact,description,license,termsOfService,title,version,x1,x2",
-                               
"Contact,String,License,String,String,String,String,<null>"
+                               
"contact,description,license,siteName,termsOfService,title,version,x1,x2",
+                               
"Contact,String,License,String,String,String,String,String,<null>"
                        );
                }
 
diff --git 
a/juneau-bean/juneau-bean-openapi-v3/src/test/java/org/apache/juneau/bean/openapi3/SchemaInfo_Test.java
 
b/juneau-bean/juneau-bean-openapi-v3/src/test/java/org/apache/juneau/bean/openapi3/SchemaInfo_Test.java
index 4e500d6211..1ddc77bd1d 100644
--- 
a/juneau-bean/juneau-bean-openapi-v3/src/test/java/org/apache/juneau/bean/openapi3/SchemaInfo_Test.java
+++ 
b/juneau-bean/juneau-bean-openapi-v3/src/test/java/org/apache/juneau/bean/openapi3/SchemaInfo_Test.java
@@ -121,12 +121,19 @@ class SchemaInfo_Test extends TestBase {
                                        .addRequired("b1", "b2")
                                        .addAllOf(schemaInfo("c1"), 
schemaInfo("c2"))
                                        .addAnyOf(schemaInfo("d1"), 
schemaInfo("d2"))
-                                       .addOneOf(schemaInfo("e1"), 
schemaInfo("e2")),
-                               
"enum,required,allOf{#{type}},anyOf{#{type}},oneOf{#{type}}",
-                               
"[a1,a2],[b1,b2],{[{c1},{c2}]},{[{d1},{d2}]},{[{e1},{e2}]}"
+                                       .addOneOf(schemaInfo("e1"), 
schemaInfo("e2"))
+                                       .addProperty("f1", schemaInfo("f2")),
+                               
"enum,required,allOf{#{type}},anyOf{#{type}},oneOf{#{type}},properties{f1{type}}",
+                               
"[a1,a2],[b1,b2],{[{c1},{c2}]},{[{d1},{d2}]},{[{e1},{e2}]},{{f2}}"
                        );
                }
 
+               @Test void a09b_addProperty_nullChecks() {
+                       var x = bean();
+                       assertThrows(IllegalArgumentException.class, () -> 
x.addProperty(null, schemaInfo("a")));
+                       assertThrows(IllegalArgumentException.class, () -> 
x.addProperty("a", null));
+               }
+
                @Test void a10_asMap() {
                        assertBean(
                                bean()
diff --git 
a/juneau-bean/juneau-bean-swagger-v2/src/main/java/org/apache/juneau/bean/swagger/SchemaInfo.java
 
b/juneau-bean/juneau-bean-swagger-v2/src/main/java/org/apache/juneau/bean/swagger/SchemaInfo.java
index 3f8db061e4..37607e6b16 100644
--- 
a/juneau-bean/juneau-bean-swagger-v2/src/main/java/org/apache/juneau/bean/swagger/SchemaInfo.java
+++ 
b/juneau-bean/juneau-bean-swagger-v2/src/main/java/org/apache/juneau/bean/swagger/SchemaInfo.java
@@ -580,6 +580,14 @@ public class SchemaInfo extends SwaggerElement {
         * <p>
         * The list of required properties.
         *
+        * <p>
+        * <b>Note:</b> Named/typed <c>requiredProperties</c> 
(<c>Set&lt;String&gt;</c>) here per the JSON Schema Draft
+        * this module targets, vs. <c>required</c> (<c>List&lt;String&gt;</c>) 
in openapi3's sibling
+        * {@code org.apache.juneau.bean.openapi3.SchemaInfo}. This is an 
intentional, maintainer-confirmed divergence
+        * (PARITY-02): each module tracks its own spec version's canonical 
field name/type; the separate boolean
+        * {@link #getRequired()}/{@link #setRequired(Boolean)} pair on this 
class (unrelated to this property) has no
+        * openapi3-side equivalent. No rename/retype planned.
+        *
         * @return The property value, or <jk>null</jk> if it is not set.
         */
        public Set<String> getRequiredProperties() { return 
nie(requiredProperties); }
diff --git 
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/ini/IniConfigAnnotation.java
 
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/ini/IniConfigAnnotation.java
index 0da48c7c67..5153faa3b3 100644
--- 
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/ini/IniConfigAnnotation.java
+++ 
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/ini/IniConfigAnnotation.java
@@ -44,7 +44,8 @@ public class IniConfigAnnotation {
 
                @Override
                public void apply(AnnotationInfo<IniConfig> ai, 
IniParser.Builder b) {
-                       // No-op: Parser accepts both = and :; no 
format-specific settings needed.
+                       IniConfig a = ai.inner();
+                       string(a.kvSeparator()).filter(s -> 
!s.isEmpty()).ifPresent(s -> b.kvSeparator(s.charAt(0)));
                }
        }
 
diff --git 
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/ini/IniParser.java
 
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/ini/IniParser.java
index dcffe56ffb..5c637e4e1f 100644
--- 
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/ini/IniParser.java
+++ 
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/ini/IniParser.java
@@ -97,6 +97,7 @@ public class IniParser extends ReaderParser implements 
IniMetaProvider, RecordRe
        private final java.util.concurrent.ConcurrentHashMap<ClassMeta<?>, 
IniClassMeta> iniClassMetas = new java.util.concurrent.ConcurrentHashMap<>();
        private final java.util.concurrent.ConcurrentHashMap<BeanPropertyMeta, 
IniBeanPropertyMeta> iniBeanPropertyMetas = new 
java.util.concurrent.ConcurrentHashMap<>();
 
+       private static final String PROP_kvSeparator = "kvSeparator";
        private static final String ARG_copyFrom = "copyFrom";
 
        /**
@@ -106,16 +107,37 @@ public class IniParser extends ReaderParser implements 
IniMetaProvider, RecordRe
 
                private static final Cache<HashKey,IniParser> CACHE = 
Cache.of(HashKey.class, IniParser.class).build();
 
+               private char kvSeparator = '=';
+
                protected Builder() {
                        consumes("text/ini,text/x-ini");
                }
 
                protected Builder(Builder copyFrom) {
                        super(assertArgNotNull(ARG_copyFrom, copyFrom));
+                       kvSeparator = copyFrom.kvSeparator;
                }
 
                protected Builder(IniParser copyFrom) {
                        super(assertArgNotNull(ARG_copyFrom, copyFrom));
+                       kvSeparator = copyFrom.kvSeparator;
+               }
+
+               /**
+                * Key-value separator character.
+                *
+                * <p>
+                * By default, the parser recognizes both <c>=</c> and <c>:</c> 
as key-value separators (matching
+                * {@link IniSerializer.Builder#kvSeparator(char)}'s default of 
<c>=</c>). Set this to match a
+                * serializer configured with a non-default {@code kvSeparator} 
so the resulting INI can be
+                * round-tripped.
+                *
+                * @param value The key-value separator character to recognize 
(in addition to the default <c>=</c>/<c>:</c> pair).
+                * @return This object.
+                */
+               public Builder kvSeparator(char value) {
+                       kvSeparator = value;
+                       return this;
                }
 
                @Override
@@ -127,6 +149,11 @@ public class IniParser extends ReaderParser implements 
IniMetaProvider, RecordRe
                public Builder copy() {
                        return new Builder(this);
                }
+
+               @Override /* Overridden from Context.Builder<?> */
+               public HashKey hashKey() {
+                       return HashKey.of(super.hashKey(), kvSeparator);
+               }
        }
 
        /** Default parser instance. */
@@ -141,6 +168,9 @@ public class IniParser extends ReaderParser implements 
IniMetaProvider, RecordRe
                return new Builder();
        }
 
+       /** Key-value separator. */
+       protected final char kvSeparator;
+
        /**
         * Constructor.
         *
@@ -148,6 +178,7 @@ public class IniParser extends ReaderParser implements 
IniMetaProvider, RecordRe
         */
        public IniParser(Builder builder) {
                super(builder);
+               kvSeparator = builder.kvSeparator;
        }
 
        @Override
@@ -172,6 +203,12 @@ public class IniParser extends ReaderParser implements 
IniMetaProvider, RecordRe
                return new Builder(this);
        }
 
+       @Override
+       protected FluentMap<String,Object> properties() {
+               return super.properties()
+                       .a(PROP_kvSeparator, String.valueOf(kvSeparator));
+       }
+
        /**
         * Convenience delegator that opens a {@link RecordReader} over the 
input using
         * <b>default session arguments</b> (mirrors {@link #read(Object, 
Class)}).
diff --git 
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/ini/IniParserSession.java
 
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/ini/IniParserSession.java
index 490e803bcb..d208bd81c7 100644
--- 
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/ini/IniParserSession.java
+++ 
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/ini/IniParserSession.java
@@ -59,8 +59,11 @@ public class IniParserSession extends ReaderParserSession 
implements RecordReada
         */
        public static class Builder extends 
ReaderParserSession.Builder<Builder> {
 
+               private final IniParser ctx;
+
                protected Builder(IniParser ctx) {
                        super(assertArgNotNull(ARG_ctx, ctx));
+                       this.ctx = ctx;
                }
 
                @Override
@@ -80,8 +83,11 @@ public class IniParserSession extends ReaderParserSession 
implements RecordReada
                return new Builder(assertArgNotNull(ARG_ctx, ctx));
        }
 
+       private final IniParser ctx;
+
        protected IniParserSession(Builder builder) {
                super(builder);
+               ctx = builder.ctx;
        }
 
        @Override /* RecordReadable */
@@ -151,12 +157,17 @@ public class IniParserSession extends ReaderParserSession 
implements RecordReada
        }
 
        /**
-        * Splits an INI {@code key=value} / {@code key:value} line into its 
key and raw-value parts.
+        * Splits an INI {@code key=value} / {@code key:value} (or 
configured-separator) line into its key and
+        * raw-value parts.
         *
         * <p>
         * Behavior mirrors the former {@code ^([^=#\s][^=]*)\s*[=:]\s*(.*)$} 
regex but without its super-linear
-        * backtracking: an {@code '='} delimiter (if present) binds to the 
first {@code '='}; otherwise the last
-        * {@code ':'} is used. Surrounding whitespace is left in place 
(callers trim).
+        * backtracking: when this session's {@link 
IniParser.Builder#kvSeparator(char) kvSeparator} is left at its
+        * default (<c>=</c>), an {@code '='} delimiter (if present) binds to 
the first {@code '='}; otherwise the
+        * last {@code ':'} is used (unchanged legacy behavior). When a 
non-default {@code kvSeparator} has been
+        * configured (e.g. to round-trip an {@link IniSerializer} that was 
configured with a matching
+        * {@code kvSeparator}), only that character is recognized as the 
delimiter. Surrounding whitespace is left
+        * in place (callers trim).
         *
         * @param line The (already-trimmed) candidate line.
         * @return A two-element array of {raw-key, raw-value}, or 
<jk>null</jk> if the line is not a key/value pair.
@@ -164,14 +175,15 @@ public class IniParserSession extends ReaderParserSession 
implements RecordReada
        @SuppressWarnings({
                "java:S1168" // null is a distinct "not a key/value pair" 
sentinel; the caller guards on != null before indexing, so an empty array would 
change parse semantics.
        })
-       private static String[] splitKeyValue(String line) {
+       private String[] splitKeyValue(String line) {
                if (line.isEmpty())
                        return null;
                var first = line.charAt(0);
                if (first == '=' || first == '#' || 
Character.isWhitespace(first))
                        return null;
-               var idx = line.indexOf('=');
-               if (idx < 0)
+               var sep = ctx.kvSeparator;
+               var idx = line.indexOf(sep);
+               if (idx < 0 && sep == '=')
                        idx = line.lastIndexOf(':');
                if (idx < 1)
                        return null;
diff --git 
a/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/ini/IniRoundTrip_Test.java
 
b/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/ini/IniRoundTrip_Test.java
index 29654c4acb..a28d08fe90 100644
--- 
a/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/ini/IniRoundTrip_Test.java
+++ 
b/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/ini/IniRoundTrip_Test.java
@@ -183,4 +183,41 @@ class IniRoundTrip_Test extends TestBase {
                var b = (Map<String, Object>) IniParser.DEFAULT.read(ini, 
Map.class, String.class, Object.class);
                assertBean(b, "name,emoji", "José,Hello \uD83D\uDE00");
        }
+
+       
//====================================================================================================
+       // b - Custom kvSeparator round-trip (PARITY-01)
+       
//====================================================================================================
+
+       @Test
+       void b01_customKvSeparatorRoundTrip_map() throws Exception {
+               var a = new LinkedHashMap<String, Object>();
+               a.put("name", "Alice");
+               a.put("age", 30);
+               var s = IniSerializer.create().kvSeparator('|').build();
+               var ini = s.write(a);
+               var p = IniParser.create().kvSeparator('|').build();
+               var b = (Map<String, Object>) p.read(ini, Map.class, 
String.class, Object.class);
+               assertBean(b, "name,age", "Alice,30");
+       }
+
+       @Test
+       void b02_customKvSeparatorRoundTrip_bean() throws Exception {
+               var a = new ComplexPerson("Alice", new Address("123 Main", 
"Boston"), list("a", "b", "c"));
+               var s = IniSerializer.create().kvSeparator('|').build();
+               var ini = s.write(a);
+               var p = IniParser.create().kvSeparator('|').build();
+               var b = p.read(ini, ComplexPerson.class);
+               assertBean(b, "name,address{street,city},tags", "Alice,{123 
Main,Boston},[a,b,c]");
+       }
+
+       @Test
+       void b03_defaultKvSeparatorBehaviorUnchanged() throws Exception {
+               var a = new LinkedHashMap<String, Object>();
+               a.put("name", "Alice");
+               a.put("age", 30);
+               var ini = IniSerializer.DEFAULT.write(a);
+               var p = IniParser.create().build();
+               var b = (Map<String, Object>) p.read(ini, Map.class, 
String.class, Object.class);
+               assertBean(b, "name,age", "Alice,30");
+       }
 }
diff --git 
a/juneau-microservice/juneau-microservice-tomcat/src/main/java/org/apache/juneau/microservice/tomcat/HealthProbeConfiguration.java
 
b/juneau-microservice/juneau-microservice-tomcat/src/main/java/org/apache/juneau/microservice/tomcat/HealthProbeConfiguration.java
new file mode 100644
index 0000000000..c2fc0d089d
--- /dev/null
+++ 
b/juneau-microservice/juneau-microservice-tomcat/src/main/java/org/apache/juneau/microservice/tomcat/HealthProbeConfiguration.java
@@ -0,0 +1,53 @@
+/*
+ * 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.microservice.tomcat;
+
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.rest.server.health.*;
+
+import jakarta.servlet.*;
+
+/**
+ * Opt-in probe configuration that auto-mounts {@link HealthServlet}.
+ *
+ * @since 10.0.0
+ */
+@Configuration
+public class HealthProbeConfiguration {
+
+       /**
+        * Default probe settings bean.
+        *
+        * @return Default settings.
+        */
+       @Bean
+       @ConditionalOnMissingBean(HealthProbeSettings.class)
+       public HealthProbeSettings healthProbeSettings() {
+               return HealthProbeSettings.create().build();
+       }
+
+       /**
+        * Probe servlet bean discovered by {@link TomcatServerComponent}.
+        *
+        * @return Probe servlet.
+        */
+       @Bean(name="healthProbeServlet")
+       @ConditionalOnMissingBean(name="healthProbeServlet")
+       public Servlet healthProbeServlet() {
+               return new HealthServlet();
+       }
+}
diff --git 
a/juneau-microservice/juneau-microservice-tomcat/src/main/java/org/apache/juneau/microservice/tomcat/TomcatMicroservice.java
 
b/juneau-microservice/juneau-microservice-tomcat/src/main/java/org/apache/juneau/microservice/tomcat/TomcatMicroservice.java
new file mode 100644
index 0000000000..d3ae5d811e
--- /dev/null
+++ 
b/juneau-microservice/juneau-microservice-tomcat/src/main/java/org/apache/juneau/microservice/tomcat/TomcatMicroservice.java
@@ -0,0 +1,147 @@
+/*
+ * 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.microservice.tomcat;
+
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.microservice.*;
+
+import jakarta.servlet.*;
+
+/**
+ * Zero-config convenience launcher for an embedded-Tomcat-backed Juneau 
microservice.
+ *
+ * <p>
+ * Wraps the {@link Microservice} + {@link TomcatConfiguration} bootstrap in a 
single static call so
+ * a consumer can stand up a Tomcat microservice with one Maven dependency and 
one line of code:
+ *
+ * <p class='bjava'>
+ *     <jc>// Boot Tomcat + Juneau on the default port (8000) with the 
interactive console enabled.</jc>
+ *     <jk>public static void</jk> main(String[] args) <jk>throws</jk> 
Exception {
+ *             TomcatMicroservice.<jsm>run</jsm>(args, <jk>new</jk> 
RootResources());
+ *     }
+ * </p>
+ *
+ * <p>
+ * Bundled defaults (all overridable):
+ * <ul>
+ *     <li>Port <c>8000</c> (surfaced as <c>Tomcat/port</c> in a 
consumer-supplied <c>juneau.cfg</c>).
+ *     <li>Interactive console enabled (use {@link #run(String[], Servlet, 
boolean)} with
+ *             {@code startConsole=false} to suppress).
+ *     <li>An auto-created, auto-deleted temp Catalina base directory &mdash; 
a consumer-supplied
+ *             {@code @Bean TomcatSettings} with {@link 
TomcatSettings.Builder#baseDir(String)} wins via the
+ *             existing {@link TomcatServerComponent} resolution chain.
+ * </ul>
+ *
+ * <p>
+ * The supplied root servlet is registered as a {@code @Bean Servlet} in an 
external bean store and
+ * auto-mounted by {@link TomcatServerComponent TomcatServerComponent}
+ * at the path declared by its {@link org.apache.juneau.rest.server.Rest 
@Rest} annotation.  Consumers
+ * who want full control over the bean store / configuration classes / 
listener wiring should call
+ * {@link Microservice#create()} directly &mdash; this facade is a thin 
convenience over that builder.
+ *
+ * @since 10.0.0
+ */
+@SuppressWarnings({
+       "resource" // beanStore lifetime is managed by the returned 
Microservice; not an independent resource.
+})
+public final class TomcatMicroservice {
+
+       private TomcatMicroservice() {}
+
+       /**
+        * Boots a Tomcat microservice with the supplied root servlet and the 
bundled defaults,
+        * with the interactive console enabled.
+        *
+        * <p>
+        * Equivalent to calling {@link #run(String[], Servlet, boolean) 
run(args, rootServlet, true)}.
+        *
+        * @param args         The {@code main(String[])} arguments.  Forwarded 
to {@link org.apache.juneau.microservice.Microservice.Builder#args(String...)}.
+        * @param rootServlet  The root REST servlet (typically annotated with
+        *                     {@link org.apache.juneau.rest.server.Rest 
@Rest}).
+        * @return The started {@link Microservice} instance.  Callers 
typically chain {@link Microservice#join()}.
+        * @throws Exception Error occurred during bootstrap.
+        */
+       @SuppressWarnings({
+               "java:S112" // throws Exception intentional - mirrors 
Microservice.start() lifecycle contract.
+       })
+       public static Microservice run(String[] args, Servlet rootServlet) 
throws Exception {
+               return run(args, rootServlet, true);
+       }
+
+       /**
+        * Boots a Tomcat microservice with the supplied root servlet and the 
bundled defaults.
+        *
+        * <p>
+        * Constructs the {@link Microservice}, registers the supplied servlet 
as a {@code @Bean Servlet} so
+        * {@link TomcatServerComponent TomcatServerComponent} auto-mounts
+        * it, applies {@link TomcatConfiguration} so embedded Tomcat itself is 
wired, and starts the lifecycle.  The
+        * returned microservice has not yet been {@link Microservice#join() 
joined}; callers wanting the
+        * standard "start and block forever" loop should chain {@code .join()}.
+        *
+        * @param args          The {@code main(String[])} arguments.  
Forwarded to {@link 
org.apache.juneau.microservice.Microservice.Builder#args(String...)}.
+        * @param rootServlet   The root REST servlet (typically annotated with
+        *                      {@link org.apache.juneau.rest.server.Rest 
@Rest}).
+        * @param startConsole  If <jk>true</jk>, also starts the interactive 
console thread via
+        *                      {@link Microservice#startConsole()}.
+        * @return The started {@link Microservice} instance.
+        * @throws Exception Error occurred during bootstrap.
+        */
+       @SuppressWarnings({
+               "java:S112" // throws Exception intentional - mirrors 
Microservice.start() lifecycle contract.
+       })
+       public static Microservice run(String[] args, Servlet rootServlet, 
boolean startConsole) throws Exception {
+               var beanStore = new BasicBeanStore();
+               beanStore.addBean(Servlet.class, rootServlet);
+               return run(args, beanStore, startConsole, 
TomcatConfiguration.class);
+       }
+
+       /**
+        * Power-user form: boots a Tomcat microservice with a caller-supplied 
bean store and configuration
+        * class list.
+        *
+        * <p>
+        * Equivalent to writing the {@link Microservice} builder chain by 
hand, but with the boilerplate
+        * collapsed.  Use this when the simpler {@link #run(String[], 
Servlet)} overloads are too
+        * restrictive &mdash; e.g. multiple {@code @Bean Servlet} 
contributions, custom
+        * {@code MicroserviceListener}s, or a parent bean store.
+        *
+        * @param args            The {@code main(String[])} arguments.  
Forwarded to {@link 
org.apache.juneau.microservice.Microservice.Builder#args(String...)}.
+        * @param beanStore       The bean store to use for dependency 
injection.  Pre-populated bean entries
+        *                        ({@code @Bean Servlet}, {@code 
MicroserviceListener}, etc.) are picked up by
+        *                        {@link TomcatConfiguration} and the 
microservice runtime.
+        * @param startConsole    If <jk>true</jk>, also starts the interactive 
console thread.
+        * @param configurations  The {@code @Configuration}-annotated classes 
to register.  Always includes
+        *                        {@link TomcatConfiguration}; pass an empty 
list to use only that.
+        * @return The started {@link Microservice} instance.
+        * @throws Exception Error occurred during bootstrap.
+        */
+       @SuppressWarnings({
+               "java:S112" // throws Exception intentional - mirrors 
Microservice.start() lifecycle contract.
+       })
+       public static Microservice run(String[] args, WritableBeanStore 
beanStore, boolean startConsole, Class<?>... configurations) throws Exception {
+               var ms = Microservice
+                       .create()
+                       .args(args)
+                       .beanStore(beanStore)
+                       .configurations(configurations)
+                       .build()
+                       .start();
+               if (startConsole)
+                       ms.startConsole();
+               return ms;
+       }
+}
diff --git 
a/juneau-microservice/juneau-microservice-tomcat/src/test/java/org/apache/juneau/microservice/tomcat/TomcatMicroservice_Test.java
 
b/juneau-microservice/juneau-microservice-tomcat/src/test/java/org/apache/juneau/microservice/tomcat/TomcatMicroservice_Test.java
new file mode 100644
index 0000000000..ed0536894d
--- /dev/null
+++ 
b/juneau-microservice/juneau-microservice-tomcat/src/test/java/org/apache/juneau/microservice/tomcat/TomcatMicroservice_Test.java
@@ -0,0 +1,98 @@
+/*
+ * 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.microservice.tomcat;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.*;
+import java.net.*;
+import java.nio.charset.*;
+import java.util.stream.*;
+
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.microservice.*;
+import org.apache.juneau.rest.server.*;
+import org.apache.juneau.rest.server.servlet.*;
+import org.junit.jupiter.api.*;
+
+import jakarta.servlet.*;
+
+/**
+ * Boot-and-shutdown smoke test for {@link TomcatMicroservice}.
+ *
+ * <p>
+ * Verifies that a bean-store-supplied ephemeral {@link TomcatSettings} port 
plus a single
+ * {@code @Bean Servlet} are sufficient to stand up a Tomcat-backed Juneau 
microservice and serve a 200
+ * from a {@link Rest @Rest}-annotated resource.  Asserts both the facade 
entry-point and a clean shutdown
+ * via {@link Microservice#stop()}.
+ *
+ * <p>
+ * Unlike the Jetty facade (which resolves its bind port from a bundled {@code 
jetty.xml} via a
+ * pre-published {@code availablePort} system property), the embedded-Tomcat 
path binds programmatically,
+ * so the ephemeral port ({@code TomcatSettings.ports(0)}) is supplied 
directly via the power-user
+ * {@link TomcatMicroservice#run(String[], WritableBeanStore, boolean, 
Class[])} overload and read back
+ * from {@link TomcatServerComponent#getPort()} after start.
+ *
+ * @since 10.0.0
+ */
[email protected]
+class TomcatMicroservice_Test {
+
+       @Rest(paths="/*")
+       public static class Root extends BasicRestServlet {
+               private static final long serialVersionUID = 1L;
+               @RestGet(path="/hello")
+               public String hello() {
+                       return "OK";
+               }
+       }
+
+       @Test
+       @SuppressWarnings({
+               "resource" // ms is a server-lifetime resource stopped in the 
finally block; try-with-resources is not applicable.
+       })
+       void runStartsServerServesRequestAndStops() throws Exception {
+               var beanStore = new BasicBeanStore();
+               beanStore.addBean(Servlet.class, new Root());
+               beanStore.addBean(TomcatSettings.class, 
TomcatSettings.create().ports(0).build());
+               var ms = TomcatMicroservice.run(new String[0], beanStore, 
false, TomcatConfiguration.class);
+               try {
+                       var tsc = 
ms.getBeanStore().getBean(TomcatServerComponent.class).orElseThrow();
+                       var localPort = tsc.getPort();
+                       assertTrue(localPort > 0, "Tomcat should bind to an 
ephemeral port");
+
+                       var url = URI.create("http://localhost:"; + localPort + 
"/hello").toURL();
+                       var conn = (HttpURLConnection)url.openConnection();
+                       conn.setRequestMethod("GET");
+                       conn.setRequestProperty("Accept", "text/plain");
+                       conn.setConnectTimeout(5000);
+                       conn.setReadTimeout(5000);
+                       try {
+                               assertEquals(200, conn.getResponseCode());
+                               try (var r = new BufferedReader(new 
InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
+                                       var body = 
r.lines().collect(Collectors.joining());
+                                       assertTrue(body.contains("OK"), 
"Expected body to contain 'OK' but got: " + body);
+                               }
+                       } finally {
+                               conn.disconnect();
+                       }
+               } finally {
+                       ms.stop();
+                       ms.stopConsole();
+               }
+       }
+}


Reply via email to