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 > {@code getPaths()} getter > 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 "/.../*"} 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}