001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.juneau.microservice.tomcat;
018
019import static org.apache.juneau.commons.reflect.ReflectionUtils.*;
020import static org.apache.juneau.commons.utils.FileUtils.*;
021import static org.apache.juneau.commons.utils.ObjectUtils.*;
022import static org.apache.juneau.commons.utils.Shorts.*;
023import static org.apache.juneau.commons.utils.StringUtils.*;
024import static org.apache.juneau.commons.utils.ThrowableUtils.*;
025import static org.apache.juneau.marshall.collections.JsonMap.*;
026
027import java.io.*;
028import java.net.*;
029import java.nio.file.*;
030import java.nio.file.attribute.*;
031import java.time.*;
032import java.util.*;
033import java.util.concurrent.*;
034import java.util.concurrent.atomic.*;
035import java.util.logging.*;
036
037import org.apache.catalina.*;
038import org.apache.catalina.startup.*;
039import org.apache.juneau.commons.inject.*;
040import org.apache.juneau.commons.reflect.*;
041import org.apache.juneau.config.event.*;
042import org.apache.juneau.marshall.cp.*;
043import org.apache.juneau.microservice.*;
044import org.apache.juneau.rest.server.*;
045import org.apache.juneau.rest.server.health.*;
046import org.apache.juneau.rest.server.servlet.*;
047import org.apache.tomcat.util.descriptor.web.*;
048
049import jakarta.servlet.*;
050
051/**
052 * Drives the embedded Tomcat server lifecycle on behalf of the microservice via the {@link MicroserviceListener} fan-out.
053 *
054 * <p>
055 * Contributed by {@link TomcatConfiguration} as a <c>@Bean</c>.  The microservice's {@link Microservice#start()}
056 * call invokes {@link #onStart(Microservice)}, which:
057 * <ol>
058 *    <li>Picks the first available port from the resolved {@link TomcatSettings#getPorts() ports} (falling back to
059 *       <c>Tomcat/port</c> config, then <c>Tomcat-Port</c> manifest, then <c>8000</c>).
060 *    <li>Resolves the {@link Tomcat} instance — preferring an externally-supplied <c>@Bean Tomcat</c>, otherwise
061 *       building one programmatically via the {@link TomcatServerFactory} against a writable base directory.
062 *    <li>Mounts servlets from {@code Tomcat/servlets}, {@code Tomcat/servletMap}, {@code Tomcat/servletAttributes}
063 *       config sections, plus auto-mounts every {@code @Bean Servlet} whose runtime class is annotated with
064 *       {@link Rest @Rest} (at {@link Rest#path()}, or <c>"/"</c> when no path is set).
065 *    <li>Mounts every <c>@Bean</c> {@link ServletFilterRegistration} (and the <c>@Bean</c> <c>AuthFilterChain</c>, at
066 *       <c>/*</c>, unless a registration already wraps it) in {@link ServletFilterRegistration#getOrder() order}.
067 *    <li>Materializes the connector via {@link Tomcat#getConnector()}, starts the server, and publishes the bound
068 *       port via the {@code availablePort} / {@code juneau.serverPort} system properties.
069 * </ol>
070 *
071 * <p>
072 * {@link #onStop(Microservice)} performs the inverse: stops and destroys the server gracefully in a worker thread,
073 * then deletes the auto-created temp base directory (if one was created).
074 *
075 * <p>
076 * External callers reach this component via
077 * {@code Microservice.getInstance().getBeanStore().getBean(TomcatServerComponent.class).orElseThrow()}.
078 *
079 * <h5 class='section'>See Also:</h5><ul>
080 *    <li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/JuneauMicroserviceTomcat">juneau-microservice-tomcat</a>
081 * </ul>
082 *
083 * @since 10.0.0
084 */
085@SuppressWarnings({
086   "java:S115", // Constants use UPPER_SNAKE_CASE convention (e.g., ROOT_CONTEXT_PATH)
087   "java:S3878", // Array wrapper required to dispatch to varargs overload; without it the call recurses infinitely back to this single-String overload.
088   "resource" // The Tomcat server is held by this component and closed in onStop(); its lifecycle spans onStart/onStop, so it cannot be try-with-resources. Eclipse JDT @Owning warning is by design.
089})
090public class TomcatServerComponent implements MicroserviceListener {
091
092   @SuppressWarnings({
093      "java:S2245" // Non-security use: port-selection jitter only; not used for tokens, session IDs, or any security-sensitive purpose.
094   })
095   private static final Random RANDOM = new Random();
096   private static final String ROOT_CONTEXT_PATH = "";
097   private static final String ROOT_DOC_BASE = ".";
098
099   /** Default bounded graceful-shutdown drain timeout applied when none is configured. */
100   static final Duration DEFAULT_STOP_TIMEOUT = Duration.ofSeconds(30);
101
102   private final Messages messages = Messages.of(TomcatServerComponent.class);
103   private final AtomicReference<Tomcat> tomcat = new AtomicReference<>();
104   private final AtomicReference<Microservice> microservice = new AtomicReference<>();
105   private final AtomicReference<File> baseDir = new AtomicReference<>();
106   private final AtomicBoolean ownsBaseDir = new AtomicBoolean(false);
107   private final AtomicReference<Duration> stopTimeout = new AtomicReference<>(DEFAULT_STOP_TIMEOUT);
108   private final AtomicReference<Duration> shutdownSettleDelay = new AtomicReference<>(Duration.ZERO);
109
110   /**
111    * Env-driven sentinel for {@code availablePort}; {@link Optional#empty()} when unset (in which case
112    * {@link #onStart(Microservice)} publishes the bound port back as the {@code availablePort} system property).
113    */
114   @Value("${availablePort}")
115   Optional<String> availablePortEnv = oe();
116
117   /**
118    * Env-driven sentinel for {@code juneau.serverPort}; {@link Optional#empty()} when unset (in which case
119    * {@link #onStart(Microservice)} publishes the bound port back as the {@code juneau.serverPort} system property).
120    */
121   @Value("${juneau.serverPort}")
122   Optional<String> serverPortEnv = oe();
123
124   private static int[] parseIntArray(String csv) {
125      if (ie(csv))
126         return new int[0];
127      var parts = csv.split(",");
128      var out = new int[parts.length];
129      for (var i = 0; i < parts.length; i++)
130         out[i] = Integer.parseInt(parts[i].trim());
131      return out;
132   }
133
134   private static int findOpenPort(int[] ports) {
135      for (var port : ports) {
136         if (port == 0)
137            port = RANDOM.nextInt(32767 - ports[0] + 1) + ports[0];
138         try (var ss = new ServerSocket(port)) {
139            return port;
140         } catch (IOException e) {
141            // Port is in use, try next port in array
142         }
143      }
144      return 0;
145   }
146
147   private static String normalizePathSpec(String rawPath) {
148      var p = rawPath == null ? "" : rawPath;
149      if (p.isEmpty() || eq(p, "/"))
150         return "/*";
151      if (! p.startsWith("/"))
152         p = "/" + p;
153      if (! p.endsWith("/*"))
154         p = trimTrailingSlashes(p) + "/*";
155      return p;
156   }
157
158   private static String normalizeExactPathSpec(String rawPath) {
159      var p = rawPath == null ? "" : trimTrailingSlashes(rawPath);
160      if (p.isEmpty())
161         return "/";
162      if (! p.startsWith("/"))
163         p = "/" + p;
164      return p;
165   }
166
167   /**
168    * Resolves the top-level Tomcat mount path-specs for an auto-discovered {@code @Rest}-annotated servlet.
169    *
170    * <p>
171    * Delegates to {@link RestContext#resolveTopLevelPaths(Class, Object, BeanStore)} so the runtime-override
172    * resolution chain (programmatic &gt; {@code getPaths()} getter &gt; annotation default) is honored at
173    * mount time. The programmatic Builder rung is N/A for Tomcat auto-discovery (the {@code RestContext} does
174    * not exist yet at this point in the lifecycle); users can substitute by overriding {@code getPaths()} on
175    * the resource class, or by leaning on SVL inside {@code @Rest(paths=...)} elements (e.g.
176    * {@code paths={"$C{health.paths}"}}) so each element is resolved through {@code Config} /
177    * {@code $E{...}} / {@code $S{...}} before being comma-split.
178    *
179    * @return The normalized exact-match path-specs (for multi-mount), or the singular {@code @Rest(path)}-style
180    *    {@code "/...&#47;*"} pathspec when no {@code paths} are resolved.
181    */
182   private static String[] restPathsFor(Servlet servlet, BeanStore store) {
183      var cls = servlet.getClass();
184      var r = cls.getAnnotation(Rest.class);
185      if (r == null)
186         return new String[] {"/*"};
187      var resolved = RestContext.resolveTopLevelPaths(cls, servlet, store);
188      if (resolved.length > 0)
189         return Arrays.stream(resolved).map(TomcatServerComponent::normalizeExactPathSpec).toArray(String[]::new);
190      return new String[] {normalizePathSpec(r.path())};
191   }
192
193   /**
194    * Constructor.
195    */
196   @SuppressWarnings({
197      "java:S1186" // Empty constructor; all state is initialized via onStart(Microservice) when wired through the bean store.
198   })
199   public TomcatServerComponent() {}
200
201   @Override /* Overridden from MicroserviceListener */
202   @SuppressWarnings({
203      "java:S3776" // Cognitive complexity acceptable for server creation logic
204   })
205   public void onStart(Microservice ms) {
206      try {
207         microservice.set(ms);
208         var store = ms.getBeanStore();
209         ClassInfo.of(this).inject(this, store);
210         var cf = ms.getConfig();
211         var mf = ms.getManifest();
212         var settings = store.getBean(TomcatSettings.class).orElseGet(() -> TomcatSettings.create().build());
213
214         // Port resolution: settings > Tomcat/port config > Tomcat-Port manifest > 8000
215         var ports = settings.getPorts();
216         if (ports == null)
217            ports = cf.get("Tomcat/port").as(int[].class)
218               .orElseGet(() -> mf.get("Tomcat-Port").map(TomcatServerComponent::parseIntArray).orElseGet(() -> ints(8000)));
219         var availablePort = findOpenPort(ports);
220
221         if (availablePortEnv.isEmpty())
222            // Intentional real-JVM-property publish: the bound port is exposed as a process-global system
223            // property so external config/templates (e.g. the bundled jetty.xml's $S{availablePort}) can read
224            // it.  The $S{...} SVL var resolves via raw System.getProperty (bypassing the Settings override
225            // chain), so a Settings-based write would be invisible to those consumers.  The @Value-injected
226            // availablePortEnv read side is already Settings-abstracted; only this publish must stay raw.
227            System.setProperty("availablePort", String.valueOf(availablePort));
228
229         // Prefer a @Bean-supplied Tomcat, else build one programmatically via the factory.
230         var injectedTomcat = store.getBean(Tomcat.class).orElse(null);
231         if (nn(injectedTomcat)) {
232            tomcat.set(injectedTomcat);
233         } else {
234            var factory = store.getBean(TomcatServerFactory.class).orElseGet(BasicTomcatServerFactory::new);
235            tomcat.set(createServer(factory, resolveBaseDir(settings)));
236         }
237         tomcat.get().setPort(availablePort);
238         tomcat.get().getConnector();
239
240         // Publish back to the store for downstream beans / lookups.
241         store.addBean(Tomcat.class, tomcat.get());
242
243         // Graceful-shutdown wiring (zero-downtime k8s rollouts), kept identical to the Jetty contract:
244         //  - Resolve the bounded drain timeout (TomcatSettings > Tomcat/stopTimeout config > 30s default) so
245         //    onStop() can wait for in-flight requests to complete before the server stops.
246         //  - Remember the settle delay so onStop() can let the LB observe the /readyz 503 before draining.
247         //  - Mark readiness ready so a freshly-(re)started service serves traffic.
248         stopTimeout.set(coalesce(settings.getStopTimeout(), cf.get("Tomcat/stopTimeout").asLong().map(Duration::ofMillis).orElse(DEFAULT_STOP_TIMEOUT)));
249         shutdownSettleDelay.set(coalesce(settings.getShutdownSettleDelay(), cf.get("Tomcat/shutdownSettleDelay").asLong().map(Duration::ofMillis).orElse(Duration.ZERO)));
250
251         // Per-service readiness: reuse an app/test-supplied @Bean ReadinessState if present, else construct
252         // one, and publish it into this microservice's bean store.  shared() is intentionally NOT used here:
253         // it is one JVM-wide singleton, so falling back to it would leak this service's readiness into every
254         // other default-configured microservice in the same JVM.  The health-probe servlet side of this
255         // dual-store publish happens below, in the @Rest servlet auto-discovery loop.
256         var readinessState = store.getBean(ReadinessState.class).orElseGet(ReadinessState::new);
257         store.addBean(ReadinessState.class, readinessState);
258         readinessState.markReady();
259
260         // Track each servlet pathSpec with its declaring source so we can fail loudly on collisions.
261         var mountedPaths = new LinkedHashMap<String,String>();
262
263         for (var s : cf.get("Tomcat/servlets").asStringArray().orElse(new String[0]))
264            mountConfiguredServlet(s, mountedPaths);
265
266         cf.get("Tomcat/servletMap").asMap().orElse(EMPTY_MAP).forEach((k, v) -> mountMappedServlet(k, v, mountedPaths));
267
268         cf.get("Tomcat/servletAttributes").asMap().orElse(EMPTY_MAP).forEach(this::addServletAttribute);
269
270         // Mount the @Bean ServletFilterRegistration filters, plus the @Bean AuthFilterChain at /* unless a
271         // registration already wraps it, outermost first.  Filter matching does not depend on whether the
272         // servlets or the filters were added first, so mounting after the config-sourced servlets is fine.
273         for (var registration : ServletFilterRegistration.collect(store))
274            addFilter(registration);
275
276         // Auto-discover @Rest servlets contributed via @Configuration/@Bean methods.
277         // Path source precedence: @Rest(path=...) on the resource class, else "/".
278         for (var e : store.getBeansOfType(Servlet.class).entrySet()) {
279            var servlet = e.getValue();
280            var cls = servlet.getClass();
281            if (cls.getAnnotation(Rest.class) == null)
282               continue;
283            if (servlet instanceof HealthServlet hs)
284               // Dual-store publish (READY-394): this probe's own RestContext bean store is not linked to
285               // ms.getBeanStore(), so the lifecycle-owned instance must be handed to it explicitly before
286               // it initializes; HealthServlet.initReadinessState() registers it into its own bean store.
287               hs.publishReadinessState(readinessState);
288            if (servlet instanceof ReadinessStateAware rsa)
289               // Lets servlets holding long-lived streams (e.g. MCP subscriptions/listen) end them when shutdown
290               // begins, so the graceful drain does not wait out the full stopTimeout for them.
291               rsa.acceptReadinessState(readinessState);
292            var pathSpecs = restPathsFor(servlet, store);
293            var source = "@Bean " + cls.getName() + (ine(e.getKey()) ? "[" + e.getKey() + "]" : "");
294            for (var pathSpec : pathSpecs)
295               checkPathCollision(pathSpec, source, mountedPaths);
296            addServlet(servlet, pathSpecs);
297         }
298
299         if (serverPortEnv.isEmpty())
300            // Intentional real-JVM-property publish: juneau.serverPort is a documented cross-component/external
301            // publication of the bound port, consumed via raw System.getProperty (and $S{...} SVL).  A
302            // Settings-based write would not be visible to those raw readers, so this is kept.  The
303            // @Value-injected serverPortEnv read side is already Settings-abstracted.
304            System.setProperty("juneau.serverPort", String.valueOf(availablePort));
305
306         tomcat.get().start();
307         ms.out(messages, "ServerStarted", getPort());
308      } catch (Exception e) {
309         throw toRex(e);
310      }
311   }
312
313   @Override /* Overridden from MicroserviceListener */
314   public void onStop(Microservice ms) {
315      final Logger logger = ms.getLogger();
316      // Flip readiness out of service BEFORE the connector stops so /readyz returns 503 and the load balancer /
317      // Kubernetes stops routing new traffic while in-flight requests drain.  /livez stays healthy so the pod is
318      // not killed mid-drain.  The settle delay (default 0) gives the LB a window to observe the 503.
319      ReadinessState.resolve(ms.getBeanStore()).markOutOfService();
320      ms.out(messages, "DrainingRequests");
321      sleepQuietly(shutdownSettleDelay.get());
322      var t = new Thread("TomcatServerComponentStop") {
323         @Override /* Overridden from Thread */
324         public void run() {
325            try {
326               var t2 = tomcat.get();
327               if (t2 == null)
328                  return;
329               drainConnector(t2, stopTimeout.get(), logger);
330               ms.out(messages, "StoppingServer");
331               t2.stop();
332               t2.destroy();
333               ms.out(messages, "ServerStopped");
334            } catch (Exception e) {
335               logger.log(Level.WARNING, localizedMessage(e), e);
336            } finally {
337               cleanupBaseDir();
338            }
339         }
340      };
341      t.start();
342      try {
343         t.join();
344      } catch (InterruptedException e) {
345         Thread.currentThread().interrupt();
346      }
347   }
348
349   /**
350    * Pauses the connector (stops accepting new connections) and waits up to {@code timeout} for in-flight requests
351    * to drain before the caller stops the server.  Best-effort: never throws out of the shutdown sequence.
352    */
353   private static void drainConnector(Tomcat t2, Duration timeout, Logger logger) {
354      try {
355         var connector = t2.getConnector();
356         if (connector == null)
357            return;
358         connector.pause();
359         var executor = connector.getProtocolHandler() == null ? null : connector.getProtocolHandler().getExecutor();
360         if (executor instanceof ThreadPoolExecutor executor2) {
361            var deadline = System.nanoTime() + Math.max(0L, timeout.toNanos());
362            while (executor2.getActiveCount() > 0 && System.nanoTime() < deadline) {
363               if (sleepInterruptibly(50L))
364                  return;
365            }
366         }
367      } catch (Exception e) {
368         logger.log(Level.WARNING, localizedMessage(e), e);
369      }
370   }
371
372   private static void sleepQuietly(Duration d) {
373      if (d == null || d.isZero() || d.isNegative())
374         return;
375      try {
376         Thread.sleep(d.toMillis());
377      } catch (@SuppressWarnings("unused") InterruptedException e) {
378         Thread.currentThread().interrupt();
379      }
380   }
381
382   private static boolean sleepInterruptibly(long millis) {
383      try {
384         Thread.sleep(millis);
385         return false;
386      } catch (@SuppressWarnings("unused") InterruptedException e) {
387         Thread.currentThread().interrupt();
388         return true;
389      }
390   }
391
392   @Override /* Overridden from MicroserviceListener */
393   public void onConfigChange(Microservice ms, ConfigEvents events) {
394      // No-op: Intentional empty implementation - this component is purely lifecycle-driven.
395   }
396
397   /**
398    * Adds an arbitrary servlet to this Tomcat server.
399    *
400    * @param servlet The servlet instance.
401    * @param pathSpec The context path of the servlet.
402    * @return This object.
403    */
404   public TomcatServerComponent addServlet(Servlet servlet, String pathSpec) {
405      return addServlet(servlet, new String[]{normalizePathSpec(pathSpec)});
406   }
407
408   /**
409    * Adds an arbitrary servlet to this Tomcat server at one or more context paths.
410    *
411    * <p>
412    * The servlet is registered async-capable (Tomcat's default is off), so that
413    * {@link jakarta.servlet.http.HttpServletRequest#startAsync()} and asynchronous REST operations work, matching Jetty.
414    *
415    * @param servlet The servlet instance.
416    * @param pathSpecs The context paths of the servlet.
417    * @return This object.
418    */
419   @SuppressWarnings({
420      "deprecation" // Context.addServletMappingDecoded is deprecated in Tomcat 11 in favor of Wrapper.addMapping, which does not populate Context.findServletMappings() during embed startup.
421   })
422   public TomcatServerComponent addServlet(Servlet servlet, String...pathSpecs) {
423      var context = getContext();
424      var servletName = cn(servlet) + "#" + UUID.randomUUID();
425      var wrapper = Tomcat.addServlet(context, servletName, servlet);
426      wrapper.setAsyncSupported(true);
427      for (var pathSpec : pathSpecs)
428         context.addServletMappingDecoded(pathSpec, servletName);
429      return this;
430   }
431
432   /**
433    * Adds a servlet filter to this Tomcat server at the specified URL pattern.
434    *
435    * @param filter The filter instance.
436    * @param urlPattern The URL pattern the filter applies to (e.g. {@code "/*"}, {@code "/api/*"}).
437    * @return This object.
438    */
439   public TomcatServerComponent addFilter(jakarta.servlet.Filter filter, String urlPattern) {
440      return addFilter(filter, new String[]{urlPattern});
441   }
442
443   /**
444    * Adds a servlet filter to this Tomcat server at one or more URL patterns.
445    *
446    * @param filter The filter instance.
447    * @param urlPatterns The URL patterns the filter applies to.
448    * @return This object.
449    */
450   public TomcatServerComponent addFilter(jakarta.servlet.Filter filter, String...urlPatterns) {
451      var context = getContext();
452      var filterName = cn(filter) + "#" + UUID.randomUUID();
453      var fd = new FilterDef();
454      fd.setFilter(filter);
455      fd.setFilterName(filterName);
456      context.addFilterDef(fd);
457      for (var pattern : urlPatterns) {
458         var fm = new FilterMap();
459         fm.setFilterName(filterName);
460         fm.addURLPattern(pattern);
461         fm.setDispatcher(DispatcherType.REQUEST.name());
462         context.addFilterMap(fm);
463      }
464      return this;
465   }
466
467   /**
468    * Adds a servlet filter registration to this Tomcat server.
469    *
470    * <p>
471    * Maps the filter on every URL pattern and dispatcher type of the registration with one filter map, and sets exactly
472    * the requested dispatcher types.  Mapping order is call order, so call this outermost first.
473    *
474    * <p>
475    * Applications contribute a {@link ServletFilterRegistration} <c>@Bean</c> instead; {@link #onStart(Microservice)}
476    * applies the beans through this method in {@link ServletFilterRegistration#getOrder() order}.  Calling it directly
477    * is only possible once the server object exists (during startup) and before the server runs.
478    *
479    * @param registration The registration.  Cannot be <jk>null</jk>.
480    * @return This object.
481    * @throws IllegalArgumentException If the registration is <jk>null</jk>.
482    * @throws IllegalStateException If the server has already started.
483    */
484   public TomcatServerComponent addFilter(ServletFilterRegistration registration) {
485      if (registration == null)
486         throw iaex("registration must not be null.");
487      var context = getContext();
488      var state = context.getState();
489      if (state != LifecycleState.NEW && state != LifecycleState.INITIALIZED)
490         throw isex("Filters must be added before the server starts.");
491      var filter = registration.getFilter();
492      var filterName = registration.getName().orElseGet(() -> cn(filter) + "#" + UUID.randomUUID());
493      var fd = new FilterDef();
494      fd.setFilter(filter);
495      fd.setFilterName(filterName);
496      fd.setAsyncSupported(String.valueOf(registration.isAsyncSupported()));
497      context.addFilterDef(fd);
498      var fm = new FilterMap();
499      fm.setFilterName(filterName);
500      for (var pattern : registration.getUrlPatterns())
501         fm.addURLPattern(pattern);
502      for (var type : registration.getDispatcherTypes())
503         fm.setDispatcher(type.name());
504      context.addFilterMap(fm);
505      return this;
506   }
507
508   /**
509    * Adds a servlet filter to this Tomcat server on the given dispatcher types and URL patterns.
510    *
511    * <p>
512    * Shorthand for {@link #addFilter(ServletFilterRegistration)} with the default order and async support, and the same
513    * validation.  The overloads without dispatcher types keep their {@code REQUEST}-only behavior.
514    *
515    * @param filter The filter instance.  Cannot be <jk>null</jk>.
516    * @param dispatcherTypes The dispatcher types.  Cannot be empty.
517    * @param urlPatterns The URL patterns.  At least one is required; see {@link org.apache.juneau.microservice.ServletFilterRegistration.Builder#urlPatterns(String...) ServletFilterRegistration.Builder#urlPatterns(String...)}.
518    * @return This object.
519    * @throws IllegalArgumentException If an argument is invalid.
520    * @throws IllegalStateException If the server has already started.
521    */
522   public TomcatServerComponent addFilter(jakarta.servlet.Filter filter, Set<DispatcherType> dispatcherTypes, String...urlPatterns) {
523      if (dispatcherTypes == null)
524         throw iaex("dispatcherTypes must not be null.");
525      return addFilter(ServletFilterRegistration.create(filter).urlPatterns(urlPatterns).dispatcherTypes(dispatcherTypes.toArray(DispatcherType[]::new)).build());
526   }
527
528   /**
529    * Adds an attribute to the root servlet context of the Tomcat server.
530    *
531    * @param name The attribute name.
532    * @param value The attribute value.
533    * @return This object.
534    */
535   public TomcatServerComponent addServletAttribute(String name, Object value) {
536      getContext().getServletContext().setAttribute(name, value);
537      return this;
538   }
539
540   /**
541    * Returns the context path that this server is using.
542    *
543    * @return The context path.
544    */
545   public String getContextPath() {
546      return getContext().getPath();
547   }
548
549   /**
550    * Returns the root {@link Context} of the Tomcat server, creating it if necessary.
551    *
552    * @return The root context.
553    */
554   public Context getContext() {
555      var context = (Context)getServer().getHost().findChild(ROOT_CONTEXT_PATH);
556      if (context != null)
557         return context;
558      return getServer().addContext(ROOT_CONTEXT_PATH, ROOT_DOC_BASE);
559   }
560
561   /**
562    * Returns the hostname of this microservice.
563    *
564    * @return The hostname.
565    */
566   public String getHostName() {
567      String hostname = "localhost";
568      try {
569         hostname = InetAddress.getLocalHost().getHostName();
570      } catch (UnknownHostException e) {
571         // Cannot determine hostname, use default "localhost"
572      }
573      return hostname;
574   }
575
576   /**
577    * Returns the port that this server is listening on.
578    *
579    * @return The port.
580    */
581   public int getPort() {
582      return getServer().getConnector().getLocalPort();
583   }
584
585   /**
586    * Returns whether this server is using <js>"http"</js> or <js>"https"</js>.
587    *
588    * @return The protocol.
589    */
590   public String getProtocol() {
591      return getServer().getConnector().getSecure() ? "https" : "http";
592   }
593
594   /**
595    * Returns the underlying Tomcat server.
596    *
597    * @return The underlying Tomcat server.
598    * @throws NullPointerException If the server has not been created yet.
599    */
600   public Tomcat getServer() {
601      return Objects.requireNonNull(tomcat.get(), "Tomcat not found.  Microservice has not been started yet.");
602   }
603
604   /**
605    * Returns the URI where this server is listening.
606    *
607    * @return The URI.
608    */
609   public URI getURI() {
610      var cp = getContextPath();
611      try {
612         return new URI(getProtocol(), null, getHostName(), getPort(), ie(cp) ? null : cp, null, null);
613      } catch (URISyntaxException e) {
614         throw toRex(e);
615      }
616   }
617
618   private static Tomcat createServer(TomcatServerFactory factory, File dir) {
619      try {
620         return factory.create(dir);
621      } catch (Exception e) {
622         throw toRex(e);
623      }
624   }
625
626   private File resolveBaseDir(TomcatSettings settings) {
627      var configured = settings.getBaseDir();
628      if (ine(configured)) {
629         ownsBaseDir.set(false);
630         var f = new File(configured);
631         baseDir.set(f);
632         return f;
633      }
634      try {
635         var f = createTempBaseDir().toFile();
636         ownsBaseDir.set(true);
637         baseDir.set(f);
638         return f;
639      } catch (IOException e) {
640         throw toRex(e);
641      }
642   }
643
644   @SuppressWarnings({
645      "java:S5443" // Temp dir created via Files.createTempDirectory with owner-only (rwx------) POSIX perms; non-POSIX fallback relies on OS-default owner-restricted temp dirs.
646   })
647   private static Path createTempBaseDir() throws IOException {
648      try {
649         // Explicitly restrict to owner-only access (rwx------) on POSIX file systems.
650         Set<PosixFilePermission> ownerOnly = EnumSet.of(
651            PosixFilePermission.OWNER_READ,
652            PosixFilePermission.OWNER_WRITE,
653            PosixFilePermission.OWNER_EXECUTE);
654         return Files.createTempDirectory("juneau-tomcat",
655            PosixFilePermissions.asFileAttribute(ownerOnly));
656      } catch (UnsupportedOperationException e) {
657         // Non-POSIX filesystem (e.g. Windows) — fall back to default temp-dir creation.
658         return Files.createTempDirectory("juneau-tomcat");
659      }
660   }
661
662   private void cleanupBaseDir() {
663      if (ownsBaseDir.get()) {
664         var f = baseDir.getAndSet(null);
665         if (nn(f))
666            deleteFile(f);
667      }
668   }
669
670   private void mountConfiguredServlet(String className, Map<String,String> mountedPaths) {
671      try {
672         var c = info(Class.forName(className));
673         if (c.isAssignableTo(RestServlet.class)) {
674            var rs = (RestServlet)c.newInstance();
675            mountWithCollisionCheck(rs, rs.getPath(), "Tomcat/servlets[" + className + "]", mountedPaths);
676         } else {
677            throw rex("Invalid servlet specified in Tomcat/servlets.  Must be a subclass of RestServlet: %s", className);
678         }
679      } catch (ClassNotFoundException e) {
680         throw toRex(e);
681      }
682   }
683
684   private void mountMappedServlet(String pathKey, Object className, Map<String,String> mountedPaths) {
685      try {
686         var c = info(Class.forName(className.toString()));
687         if (c.isAssignableTo(Servlet.class)) {
688            var rs = (Servlet)c.newInstance();
689            mountWithCollisionCheck(rs, pathKey, "Tomcat/servletMap[" + pathKey + "]", mountedPaths);
690         } else {
691            throw rex("Invalid servlet specified in Tomcat/servletMap.  Must be a subclass of Servlet: %s", cn(className));
692         }
693      } catch (ClassNotFoundException e) {
694         throw toRex(e);
695      }
696   }
697
698   private void mountWithCollisionCheck(Servlet servlet, String rawPath, String source, Map<String,String> mountedPaths) {
699      var pathSpec = normalizePathSpec(rawPath);
700      checkPathCollision(pathSpec, source, mountedPaths);
701      addServlet(servlet, pathSpec);
702   }
703
704   private void checkPathCollision(String pathSpec, String source, Map<String,String> mountedPaths) {
705      var prior = mountedPaths.get(pathSpec);
706      if (nn(prior))
707         throw rex("Servlet mount path collision: '%s' is already mounted by %s; refused by %s.", pathSpec, prior, source);
708      mountedPaths.put(pathSpec, source);
709   }
710}