001/*- 002 * #%L 003 * Smile CDR - CDR 004 * %% 005 * Copyright (C) 2016 - 2026 Smile CDR, Inc. 006 * %% 007 * All rights reserved. 008 * #L% 009 */ 010package ca.cdr.test.extensions; 011 012import ca.cdr.test.app.harness.api.HarnessContext; 013import ca.cdr.test.app.harness.api.SmileHarness; 014import ca.cdr.test.app.harness.impl.LocalhostSmileHarness; 015import ca.cdr.test.util.PropertiesTestUtil; 016import ca.cdr.test.util.UrlPathUtil; 017import jakarta.annotation.Nonnull; 018import org.apache.commons.lang3.StringUtils; 019import org.slf4j.Logger; 020import org.slf4j.LoggerFactory; 021import org.testcontainers.containers.GenericContainer; 022import org.testcontainers.containers.output.OutputFrame; 023import org.testcontainers.containers.output.WaitingConsumer; 024import org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy; 025import org.testcontainers.utility.DockerImageName; 026import org.testcontainers.utility.MountableFile; 027 028import java.io.File; 029import java.io.FileInputStream; 030import java.io.IOException; 031import java.nio.file.Files; 032import java.nio.file.Path; 033import java.nio.file.Paths; 034import java.time.Duration; 035import java.util.ArrayList; 036import java.util.Arrays; 037import java.util.HashMap; 038import java.util.List; 039import java.util.Map; 040import java.util.Optional; 041import java.util.Properties; 042import java.util.stream.Collectors; 043 044/** 045 * SmileCdrContainer is a container abstraction for orchestrating Smile CDR instances 046 * within Docker environments. 047 * <p> 048 * Instances of this container allow for flexible customization and configuration of 049 * Smile CDR images, including properties file specifications, logback configurations, 050 * and support for customer JAR files. It supports smooth integration and automated 051 * provisioning of Smile CDR services for testing and development environments. 052 * <p> 053 * The design facilitates: 054 * - Configuration of Smile CDR version and the corresponding Docker image. 055 * - Mounting of custom resources such as properties files, logback configurations, or JARs. 056 * - Automatic setup of exposed ports based on specified or default configurations. 057 * - Log consumption to aid in debugging and diagnostics during container runtime. 058 * - Ensuring modular support with organized port mapping and harness configuration. 059 * <p> 060 * This class was generated partly with the help of Claude Sonnet 3.7 061 */ 062public class SmileCdrContainer extends GenericContainer<SmileCdrContainer> { 063 064 private static final Logger ourLog = LoggerFactory.getLogger(SmileCdrContainer.class); 065 private static final String DEFAULT_REGISTRY = "docker.smilecdr.com"; 066 private static final String DEFAULT_IMAGE = "smilecdr"; 067 private static final String DEFAULT_TAG = "latest"; 068 private static final String HOST_NETWORK_MODE = "host"; 069 private static final String CDR_HOME = "/home/smile/smilecdr"; 070 private static final String DEFAULT_ADMIN_JSON_MODULE_ID = "admin_json"; 071 private static final String CLASSPATH_PREFIX = "classpath:"; 072 private static final String FILE_PREFIX = "file:"; 073 private static final DockerImageName DEFAULT_DOCKER_IMAGE_NAME = DockerImageName.parse(DEFAULT_REGISTRY + "/" + DEFAULT_IMAGE + ":" + DEFAULT_TAG); 074 075 private final Map<String, Map<String, Integer>> myPortValues = new HashMap<>(); 076 private String myResolvedPropertiesFileLocation; 077 private HarnessContext myHarnessContext; 078 private LocalhostSmileHarness myHarness; 079 private String myCustomPropertiesFileLocation; 080 private Path myCustomPropertiesFilePath; 081 private String myCustomLogbackFile; 082 private Path myCustomLogbackFilePath; 083 private List<String> myCustomerJarNames; 084 private boolean myDebugEnabled = false; 085 private boolean myDebugSuspendUntilDebuggerAttached = true; 086 private static final int DEBUG_PORT = 5005; 087 088 public SmileCdrContainer() { 089 this(DEFAULT_DOCKER_IMAGE_NAME); 090 } 091 092 /** 093 * Use this constructor to change the version of Smile CDR, but use Smile's container registry. 094 */ 095 public SmileCdrContainer(String theSmileCdrVersion) { 096 this(new DockerImageName(DEFAULT_REGISTRY + "/" + DEFAULT_IMAGE + ":" + theSmileCdrVersion)); 097 098 } 099 100 /** 101 * Use this constructor to provide a completely custom Docker Image Name to use. Use this if you need to use your own private registry 102 */ 103 public SmileCdrContainer(DockerImageName imageName) { 104 super(imageName); 105 // Wait for startup completion (success or failure) 106 LogMessageWaitStrategy logMessageWaitStrategy = new LogMessageWaitStrategy(); 107 logMessageWaitStrategy.withRegEx(".*(Smile, we're up and running|Smile started with errors).*").withStartupTimeout(Duration.ofMinutes(3)); 108 waitingFor(logMessageWaitStrategy); 109 110 // Also output logs to stdout to ensure they appear in test output 111 withLogConsumer(new WaitingConsumer() { 112 @Override 113 public void accept(OutputFrame theFrame) { 114 System.out.printf("Container[%s]: %s", getContainerName(), theFrame.getUtf8String()); 115 } 116 }); 117 } 118 119 120 @Override 121 public void start() { 122 copyCustomLogbackConfiguration(); 123 124 // Copy JAR files from cdr-interceptor-starterproject/target to the container 125 copyCustomerJars(); 126 copyPropertiesFileAndResolveReferences(); 127 128 if (myDebugEnabled) { 129 ourLog.info("Debug mode enabled - container is accessible for debuggers on port {}", DEBUG_PORT); 130 if (!isHostNetwork()) { 131 addFixedExposedPort(DEBUG_PORT, DEBUG_PORT); 132 } 133 String jvmArgs = buildJvmArgs(); 134 ourLog.info("JVMARGS is now set to: {}", jvmArgs); 135 withEnv("JVMARGS", jvmArgs); 136 if (myDebugSuspendUntilDebuggerAttached) { 137 ourLog.warn("=========================================="); 138 ourLog.warn("DEBUG MODE(suspend): Container will wait for debugger"); 139 ourLog.warn("Connect your IDE debugger to: localhost:{}", DEBUG_PORT); 140 ourLog.warn("Container will resume after debugger attaches"); 141 ourLog.warn("=========================================="); 142 } 143 } 144 super.start(); 145 } 146 147 private void copyCustomLogbackConfiguration() { 148 MountableFile mountableFile; 149 if (myCustomLogbackFilePath != null) { 150 ourLog.info("Custom logback file provided, using it. [file={}]", myCustomLogbackFilePath); 151 mountableFile = MountableFile.forHostPath(myCustomLogbackFilePath); 152 } else if (!StringUtils.isBlank(myCustomLogbackFile)) { 153 ourLog.info("Custom logback file provided, using it. [file={}]", myCustomLogbackFile); 154 mountableFile = MountableFile.forClasspathResource(myCustomLogbackFile); 155 } else { 156 ourLog.info("No custom logback file provided, using default logback file. [file={}]", "/all-troubleshooters-enabled-logback.xml"); 157 mountableFile = MountableFile.forClasspathResource("/all-troubleshooters-enabled-logback.xml"); 158 } 159 withCopyFileToContainer(mountableFile, CDR_HOME + "/customerlib/logback-smile-custom.xml"); 160 } 161 162 private void copyPropertiesFileAndResolveReferences() { 163 MountableFile mountableFile = resolvePropertiesFile(); 164 withCopyFileToContainer(mountableFile, CDR_HOME + "/classes/cdr-config-Master.properties"); 165 166 myResolvedPropertiesFileLocation = mountableFile.getResolvedPath(); 167 exposePortsFromPropertiesFile(mountableFile.getResolvedPath()); 168 bootstrapContainerFilesFromProperties(mountableFile.getResolvedPath()); 169 } 170 171 private String buildJvmArgs() { 172 return new StringBuilder() 173 .append("-agentlib:jdwp=transport=dt_socket,server=y,suspend=") 174 .append(myDebugSuspendUntilDebuggerAttached ? "y": "n") 175 .append(",address=*:") 176 .append(DEBUG_PORT) 177 .toString(); 178 } 179 180 private void bootstrapContainerFilesFromProperties(String thePropertiesFileLocation) { 181 try { 182 // Read the properties file 183 Properties properties = new Properties(); 184 try (FileInputStream fis = new FileInputStream(thePropertiesFileLocation)) { 185 properties.load(fis); 186 } 187 188 // Look for classpath and file references in property values 189 for (String key : properties.stringPropertyNames()) { 190 String value = properties.getProperty(key); 191 if (value != null && (value.startsWith(CLASSPATH_PREFIX) || value.startsWith(FILE_PREFIX))) { 192 copyReferencedFileToContainer(value); 193 } 194 } 195 } catch (IOException e) { 196 throw new IllegalArgumentException("Failed to process properties file for bootstrap files: " + thePropertiesFileLocation, e); 197 } 198 } 199 200 private void copyReferencedFileToContainer(String theReference) { 201 String resourcePath; 202 if (theReference.startsWith(CLASSPATH_PREFIX)) { 203 resourcePath = StringUtils.stripStart(theReference.substring(CLASSPATH_PREFIX.length()), "/"); 204 } else if (theReference.startsWith(FILE_PREFIX)) { 205 String filePath = theReference.substring(FILE_PREFIX.length()); 206 if (copyHostFileToContainer(filePath)) { 207 return; 208 } 209 resourcePath = StringUtils.stripStart(filePath, "/"); 210 } else { 211 return; 212 } 213 214 String containerPath = containerPathForReference(theReference); 215 try { 216 MountableFile mountableFile = MountableFile.forClasspathResource(resourcePath); 217 withCopyFileToContainer(mountableFile, containerPath); 218 ourLog.info("Copied classpath resource to container: {} -> {}", resourcePath, containerPath); 219 } catch (IllegalArgumentException e) { 220 // Many references name resources that ship inside the Smile CDR image itself. 221 ourLog.info("Referenced file is not on the test classpath, leaving it to the image: {}", theReference); 222 } 223 } 224 225 /** 226 * Copies a {@code file:} reference from the host, to the path Smile CDR will read it from: 227 * the same absolute path, or a relative path resolved against the Smile CDR home directory. 228 * Relative paths are looked up on the host relative to the working directory. 229 * 230 * @return true if the file was found on the host 231 */ 232 private boolean copyHostFileToContainer(String theFilePath) { 233 Path hostPath = Paths.get(theFilePath); 234 if (!Files.exists(hostPath)) { 235 return false; 236 } 237 String containerPath = fileReferenceContainerPath(theFilePath); 238 withCopyFileToContainer(MountableFile.forHostPath(hostPath), containerPath); 239 ourLog.info("Copied host file to container: {} -> {}", hostPath, containerPath); 240 return true; 241 } 242 243 /** 244 * Where Smile CDR reads a {@code classpath:} or {@code file:} reference from. A {@code file:} 245 * reference lands there whether it was found on the host or, failing that, on the classpath. 246 * 247 * @throws IllegalArgumentException for a value that is neither kind of reference 248 */ 249 static String containerPathForReference(String theReference) { 250 if (theReference.startsWith(CLASSPATH_PREFIX)) { 251 return CDR_HOME + "/classes/" 252 + StringUtils.stripStart(theReference.substring(CLASSPATH_PREFIX.length()), "/"); 253 } 254 if (theReference.startsWith(FILE_PREFIX)) { 255 return fileReferenceContainerPath(theReference.substring(FILE_PREFIX.length())); 256 } 257 throw new IllegalArgumentException("Not a classpath: or file: reference: " + theReference); 258 } 259 260 /** 261 * Where Smile CDR reads a {@code file:} reference from: the same absolute path, or a relative path 262 * resolved against the Smile CDR home directory. 263 */ 264 private static String fileReferenceContainerPath(String theFilePath) { 265 return Paths.get(theFilePath).isAbsolute() ? theFilePath : CDR_HOME + "/" + theFilePath; 266 } 267 268 269 private MountableFile resolvePropertiesFile() { 270 if (myCustomPropertiesFilePath != null) { 271 ourLog.info("Custom properties file provided, using it. [file={}]", myCustomPropertiesFilePath); 272 return MountableFile.forHostPath(myCustomPropertiesFilePath); 273 } 274 if (StringUtils.isBlank(myCustomPropertiesFileLocation)) { 275 ourLog.info("No custom properties file provided, using default properties file. [file={}]", "/cdr-config-fallback.properties"); 276 return MountableFile.forClasspathResource("/cdr-config-fallback.properties"); 277 } 278 ourLog.info("Custom properties file provided, using it. [file={}]", myCustomPropertiesFileLocation); 279 return MountableFile.forClasspathResource(myCustomPropertiesFileLocation); 280 } 281 282 /** 283 * Binds the target/classes and target/test-classes directories into the container's `/classes` directory. 284 * Only mounts directories if they exist and are non-empty. 285 * @return the SmileCdrContainer. 286 */ 287 public SmileCdrContainer withBoundProjectClasspath() { 288 String root = Paths.get(".").toAbsolutePath().normalize().toString(); 289 290 Path classesPath = Paths.get(root, "target", "classes"); 291 if (Files.exists(classesPath) && isDirectoryNonEmpty(classesPath)) { 292 MountableFile mountableFile = MountableFile.forHostPath(classesPath.toString()); 293 withCopyFileToContainer(mountableFile, "/home/smile/smilecdr/classes"); 294 ourLog.info("Mounted target/classes directory to container"); 295 } else { 296 ourLog.info("Skipping target/classes mount - directory does not exist or is empty"); 297 } 298 299 Path testClassesPath = Paths.get(root, "target", "test-classes"); 300 if (Files.exists(testClassesPath) && isDirectoryNonEmpty(testClassesPath)) { 301 MountableFile testMountableFile = MountableFile.forHostPath(testClassesPath.toString()); 302 withCopyFileToContainer(testMountableFile, "/home/smile/smilecdr/classes"); 303 ourLog.info("Mounted target/test-classes directory to container"); 304 } else { 305 ourLog.info("Skipping target/test-classes mount - directory does not exist or is empty"); 306 } 307 308 return this; 309 } 310 311 /** 312 * Checks if a directory is non-empty by attempting to read at least one entry. 313 * @param thePath the directory path to check 314 * @return true if the directory contains at least one file or subdirectory, false otherwise 315 */ 316 private boolean isDirectoryNonEmpty(Path thePath) { 317 try (var stream = Files.list(thePath)) { 318 return stream.findFirst().isPresent(); 319 } catch (IOException e) { 320 ourLog.warn("Error checking if directory is non-empty: {}", thePath, e); 321 return false; 322 } 323 } 324 325 public SmileCdrContainer withPreseedFiles(String... filesToMount) { 326 for (String fileToMount : filesToMount) { 327 MountableFile mountableFile = MountableFile.forClasspathResource(fileToMount); 328 withCopyFileToContainer(mountableFile, "/home/smile/smilecdr/classes/config_seeding/"); 329 } 330 return this; 331 } 332 333 /** 334 * Sets the {@link HarnessContext} the harness is built from. Discards any harness already handed 335 * out, so that a context set after the first {@link #getHarness()} still takes effect rather 336 * than being silently ignored. 337 */ 338 public synchronized @Nonnull SmileCdrContainer withCustomHarnessContext(@Nonnull HarnessContext theHarnessContext) { 339 closeHarness(); 340 myHarnessContext = theHarnessContext; 341 return this; 342 } 343 344 /** 345 * Uses a properties file from the classpath as the container's {@code cdr-config-Master.properties}. 346 */ 347 public SmileCdrContainer withPropertiesFile(String thePropertiesFileLocation) { 348 myCustomPropertiesFileLocation = thePropertiesFileLocation; 349 myCustomPropertiesFilePath = null; 350 return this; 351 } 352 353 /** 354 * Uses a properties file from the host filesystem as the container's 355 * {@code cdr-config-Master.properties}, for configuration that is generated or selected at runtime 356 * rather than packaged on the test classpath. 357 */ 358 public SmileCdrContainer withPropertiesFile(@Nonnull Path thePropertiesFile) { 359 myCustomPropertiesFilePath = thePropertiesFile; 360 myCustomPropertiesFileLocation = null; 361 return this; 362 } 363 364 public SmileCdrContainer withCustomLogback(String theCustomLogbackLocation) { 365 requireNoCustomLogback(); 366 myCustomLogbackFile = theCustomLogbackLocation; 367 return this; 368 } 369 370 /** 371 * Uses a logback configuration from the host filesystem as the container's 372 * {@code customerlib/logback-smile-custom.xml}. 373 */ 374 public SmileCdrContainer withCustomLogback(@Nonnull Path theCustomLogbackFile) { 375 requireNoCustomLogback(); 376 myCustomLogbackFilePath = theCustomLogbackFile; 377 return this; 378 } 379 380 private void requireNoCustomLogback() { 381 if (!StringUtils.isBlank(myCustomLogbackFile) || myCustomLogbackFilePath != null) { 382 throw new IllegalStateException("A custom logback file has already been set"); 383 } 384 } 385 386 public SmileCdrContainer withAllTroubleshootingLogsEnabled() { 387 requireNoCustomLogback(); 388 myCustomLogbackFile = "/all-troubleshooters-enabled-logback.xml"; 389 return this; 390 } 391 392 /** 393 * Specifies which JAR files from the cdr-interceptor-starterproject's target folder should be copied 394 * into the SmileCdrContainer at /home/smile/smilecdr/customerlib/. 395 * <p> 396 * First looks in the classpath for a jar with a given name. If it does not find one, 397 * it also looks in the target/ directory. If it still doesn't find one, then it fails. 398 * 399 * @param theJarNames A comma-separated list of JAR filenames to copy. If null or empty, all JAR files 400 * in the target directory will be copied. 401 * @return This container instance for method chaining 402 */ 403 public SmileCdrContainer withCustomerJars(String... theJarNames) { 404 if (theJarNames != null && theJarNames.length > 0) { 405 myCustomerJarNames = Arrays.stream(theJarNames) 406 .map(String::trim) 407 .filter(s -> !s.isEmpty()) 408 .collect(Collectors.toList()); 409 410 ourLog.info("Registered {} JAR file(s) to be mounted into customerlib", myCustomerJarNames.size()); 411 } 412 return this; 413 } 414 415 /** 416 * Enables remote debugging for the Smile CDR instance running in this container. 417 * When enabled, the container will expose port 5005 (fixed mapping) and wait for a debugger to attach 418 * before completing startup (suspend=y). 419 * 420 * <h3>How to Connect IntelliJ IDEA Debugger:</h3> 421 * <ol> 422 * <li>Run → Edit Configurations → Add New → Remote JVM Debug</li> 423 * <li>Set Host: localhost (or container host)</li> 424 * <li>Set Port: 5005 (always fixed to this port)</li> 425 * <li>Set Debugger mode: Attach to remote JVM</li> 426 * <li>Click Debug - the container will resume startup once debugger connects</li> 427 * </ol> 428 * 429 * <h3>Example Usage:</h3> 430 * <pre> 431 * {@code 432 * @Container 433 * SmileCdrContainer container = new SmileCdrContainer() 434 * .withPropertiesFile("/my-config.properties") 435 * .withDebugEnabled(); 436 * 437 * // Port 5005 is always used - no need to query for mapped port 438 * // Just connect your debugger to localhost:5005 439 * } 440 * </pre> 441 * 442 * <p><strong>IMPORTANT:</strong> The container will pause during startup until a debugger 443 * connects. This is intentional to allow setting breakpoints before code execution begins. 444 * Port 5005 must be available on the host machine.</p> 445 * 446 * @return This container instance for method chaining 447 */ 448 public SmileCdrContainer withDebugEnabled() { 449 return withDebugEnabled(true); 450 } 451 452 /** 453 * Enables remote debugging for the Smile CDR instance running in this container. 454 * When enabled, the container will expose port 5005 (fixed mapping). 455 * 456 * - If `theSuspendUntilDebuggerAttached` is true, the container will pause until a debugger is connected. 457 * - If `theSuspendUntilDebuggerAttached` is false, the container will boot without waiting for the debugger. 458 * 459 * 460 * <h3>How to Connect IntelliJ IDEA Debugger:</h3> 461 * <ol> 462 * <li>Run → Edit Configurations → Add New → Remote JVM Debug</li> 463 * <li>Set Host: localhost (or container host)</li> 464 * <li>Set Port: 5005 (always fixed to this port)</li> 465 * <li>Set Debugger mode: Attach to remote JVM</li> 466 * <li>Click Debug - the container will resume startup once debugger connects</li> 467 * </ol> 468 * 469 * <h3>Example Usage:</h3> 470 * <pre> 471 * {@code 472 * @Container 473 * SmileCdrContainer container = new SmileCdrContainer() 474 * .withPropertiesFile("/my-config.properties") 475 * .withDebugEnabled(false); 476 * 477 * // Port 5005 is always used - no need to query for mapped port 478 * // Just connect your debugger to localhost:5005 479 * } 480 * </pre> 481 * 482 * <p><strong>IMPORTANT:</strong> If you have set `theSuspendUntilDebuggerAttached` to True, 483 * the container will pause during startup until a debugger 484 * connects. This is intentional to allow setting breakpoints before code execution begins. 485 * Port 5005 must be available on the host machine.</p> 486 * 487 * @param theSuspendUntilDebuggerAttached if set to true, blocks the container startup until a debugger connects. 488 * 489 * @return This container instance for method chaining 490 */ 491 public SmileCdrContainer withDebugEnabled(boolean theSuspendUntilDebuggerAttached) { 492 myDebugEnabled = true; 493 myDebugSuspendUntilDebuggerAttached = theSuspendUntilDebuggerAttached; 494 return this; 495 } 496 497 private void exposePortsFromPropertiesFile(String thePropertiesFileLocation) { 498 Map<String, Map<String, Integer>> portsByModuleType = PropertiesTestUtil.loadPortsFromPropertiesFile(thePropertiesFileLocation); 499 500 // On the host network the modules bind host ports directly; Docker maps nothing, so 501 // exposing ports would leave Testcontainers waiting for mappings that never appear. 502 if (!isHostNetwork()) { 503 for (Map<String, Integer> modulePortMap : portsByModuleType.values()) { 504 for (Integer portValue : modulePortMap.values()) { 505 addExposedPort(portValue); 506 } 507 } 508 } 509 510 // Merge the loaded ports into myPortValues 511 for (Map.Entry<String, Map<String, Integer>> entry : portsByModuleType.entrySet()) { 512 String moduleType = entry.getKey(); 513 Map<String, Integer> modulePorts = entry.getValue(); 514 myPortValues.computeIfAbsent(moduleType, k -> new HashMap<>()).putAll(modulePorts); 515 } 516 } 517 518 /** 519 * Returns this container's harness, creating it on first call. 520 * <p> 521 * The harness is cached because it owns an HTTP connection pool and a cookie store: handing 522 * out a fresh one per call would mean each caller got its own session, which any multi-request 523 * login flow depends on sharing. The cached harness is closed and discarded when the container 524 * stops, so a restarted container gets a harness bound to its new port mapping. 525 * <p> 526 * A harness reads the node's modules and their ports once, when it is built. A test that adds, 527 * removes or re-ports a module through the Admin JSON API must therefore call 528 * {@link #refreshHarness()}; calling {@code getHarness()} again returns the same harness, still 529 * holding the configuration from before the change. 530 * <p> 531 * A harness a caller closed itself is replaced rather than handed back: {@link SmileHarness} is 532 * {@link AutoCloseable}, so a try-with-resources around this call would otherwise shut the 533 * connection pool and leave every later call returning a harness whose every request fails. 534 */ 535 public synchronized @Nonnull SmileHarness getHarness() { 536 if (myHarness != null && myHarness.isClosed()) { 537 myHarness = null; 538 } 539 if (myHarness == null) { 540 if (myHarnessContext == null) { 541 // Find the admin JSON port from the discovered ports 542 Optional<Map.Entry<String, Integer>> adminJsonModule = adminJsonModule(myPortValues.get("ADMIN_JSON")); 543 Integer adminJsonPort = null; 544 String adminJsonContextPath = ""; 545 if (adminJsonModule.isPresent()) { 546 Map.Entry<String, Integer> adminJson = adminJsonModule.get(); 547 adminJsonPort = adminJson.getValue(); 548 if (myResolvedPropertiesFileLocation != null) { 549 adminJsonContextPath = UrlPathUtil.normalizeContextPath( 550 PropertiesTestUtil.loadContextPath(myResolvedPropertiesFileLocation, adminJson.getKey())); 551 } 552 } 553 myHarnessContext = new HarnessContext( 554 "http", this.getHost(), adminJsonPort, "admin", "password", adminJsonContextPath); 555 } 556 myHarness = new LocalhostSmileHarness(myHarnessContext, getPortMapping()); 557 } 558 return myHarness; 559 } 560 561 /** 562 * The Admin JSON module the harness is built against when the properties file configures several: 563 * {@code admin_json} if present, otherwise the lowest module ID. A properties file has no order 564 * of its own to fall back on. 565 */ 566 static Optional<Map.Entry<String, Integer>> adminJsonModule(Map<String, Integer> theAdminJsonPorts) { 567 if (theAdminJsonPorts == null || theAdminJsonPorts.isEmpty()) { 568 return Optional.empty(); 569 } 570 if (theAdminJsonPorts.containsKey(DEFAULT_ADMIN_JSON_MODULE_ID)) { 571 return Optional.of(Map.entry(DEFAULT_ADMIN_JSON_MODULE_ID, theAdminJsonPorts.get(DEFAULT_ADMIN_JSON_MODULE_ID))); 572 } 573 return theAdminJsonPorts.entrySet().stream().min(Map.Entry.comparingByKey()).map(e -> Map.entry(e.getKey(), e.getValue())); 574 } 575 576 /** 577 * Discards the cached harness and builds a replacement, which rediscovers the node's modules and 578 * ports. Call this after changing module configuration through the Admin JSON API. 579 * <p> 580 * Any session held by the old harness ends with it, and any reference still pointing at it is 581 * dead — use the returned harness rather than the one you were holding. 582 * 583 * @return the replacement harness, already bound to the node's current configuration 584 */ 585 public synchronized @Nonnull SmileHarness refreshHarness() { 586 closeHarness(); 587 return getHarness(); 588 } 589 590 @Override 591 public void stop() { 592 closeHarness(); 593 super.stop(); 594 } 595 596 private synchronized void closeHarness() { 597 if (myHarness == null) { 598 return; 599 } 600 try { 601 myHarness.close(); 602 } catch (RuntimeException e) { 603 // Never let a teardown failure mask whatever the caller was actually doing. 604 ourLog.warn("Failed to close the harness", e); 605 } finally { 606 myHarness = null; 607 } 608 } 609 610 /** 611 * Maps each port a module listens on inside the container to the port it is reachable on from the 612 * host. On the host network ({@code withNetworkMode("host")}) every port maps to itself. 613 */ 614 public Map<Integer, Integer> getPortMapping() { 615 if (isHostNetwork()) { 616 return myPortValues.values().stream() 617 .flatMap(modulePorts -> modulePorts.values().stream()) 618 .distinct() 619 .collect(Collectors.toMap(port -> port, port -> port)); 620 } 621 return getExposedPorts().stream().collect(Collectors.toMap(port -> port, this::getMappedPort)); 622 } 623 624 private boolean isHostNetwork() { 625 return HOST_NETWORK_MODE.equals(getNetworkMode()); 626 } 627 628 /** 629 * Gets the port values organized by Module Type and Module ID. 630 * 631 * @return A map where the key is the Module Type and the value is a map of Module ID to port 632 */ 633 public Map<String, Map<String, Integer>> getPortValuesByModuleType() { 634 return new HashMap<>(myPortValues); 635 } 636 637 /** 638 * Copies JAR files from the cdr-interceptor-starterproject's target directory to the container. 639 * If myCustomerJarNames is not null, only the specified JAR files will be copied. 640 * Otherwise, all JAR files in the target directory will be copied. 641 * <p> 642 * First attempts to load JAR files from the classpath using MountableFile.forClasspathResource(). 643 * If that fails (because the JAR file is not on the classpath), falls back to using MountableFile.forHostPath(). 644 * If a JAR is not found in either location, throws a RuntimeException. 645 */ 646 private void copyCustomerJars() { 647 // If specific JAR names are provided, try to load them from the classpath first 648 if (myCustomerJarNames != null && !myCustomerJarNames.isEmpty()) { 649 List<String> jarsFoundOnClasspath = new ArrayList<>(); 650 List<String> jarsNotFoundOnClasspath = new ArrayList<>(); 651 652 for (String jarName : myCustomerJarNames) { 653 try { 654 ourLog.info("Attempting to load JAR file from classpath: {}", jarName); 655 MountableFile mountableFile = MountableFile.forClasspathResource(jarName); 656 withCopyFileToContainer(mountableFile, "/home/smile/smilecdr/customerlib/" + jarName); 657 jarsFoundOnClasspath.add(jarName); 658 } catch (IllegalArgumentException e) { 659 ourLog.info("JAR file not found on classpath: {}. Will try local filesystem.", jarName); 660 jarsNotFoundOnClasspath.add(jarName); 661 } 662 } 663 664 // If all JARs were found on the classpath, we're done 665 if (jarsNotFoundOnClasspath.isEmpty()) { 666 ourLog.info("All specified JAR files were loaded from the classpath."); 667 return; 668 } 669 670 // Fall back to loading from the local filesystem for JARs not found on classpath 671 try { 672 // Find the cdr-interceptor-starterproject directory 673 Path projectRoot = Paths.get("."); 674 Path targetDir = projectRoot.resolve("target"); 675 676 if (!Files.exists(targetDir)) { 677 throw new IllegalArgumentException("Could not find target directory. The following JAR files were not found on the classpath: " + jarsNotFoundOnClasspath); 678 } 679 680 // Get all JAR files in the target directory 681 List<File> jarFiles; 682 try (var paths = Files.list(targetDir)) { 683 jarFiles = paths 684 .filter(path -> path.toString().endsWith(".jar")) 685 .map(Path::toFile) 686 .toList(); 687 } 688 689 if (jarFiles.isEmpty()) { 690 throw new IllegalArgumentException("No JAR files found in target directory. The following JAR files were not found on the classpath: " + jarsNotFoundOnClasspath); 691 } 692 693 // Filter JAR files based on jarsNotFoundOnClasspath 694 List<File> filesToCopy = jarFiles.stream() 695 .filter(file -> jarsNotFoundOnClasspath.contains(file.getName())) 696 .toList(); 697 698 // Check if all JARs not found on classpath were found in target directory 699 if (filesToCopy.size() < jarsNotFoundOnClasspath.size()) { 700 List<String> jarsNotFound = new ArrayList<>(jarsNotFoundOnClasspath); 701 jarsNotFound.removeAll(filesToCopy.stream().map(File::getName).toList()); 702 throw new IllegalArgumentException("The following JAR files were not found in either the classpath or the target directory: " + jarsNotFound); 703 } 704 705 for (File jarFile : filesToCopy) { 706 ourLog.info("Copying JAR file from local filesystem to container: {}", jarFile.getName()); 707 MountableFile mountableFile = MountableFile.forHostPath(jarFile.toPath()); 708 withCopyFileToContainer(mountableFile, "/home/smile/smilecdr/customerlib/" + jarFile.getName()); 709 } 710 711 ourLog.info("Copied {} JAR file(s) to container from local filesystem", filesToCopy.size()); 712 } catch (IOException e) { 713 throw new IllegalArgumentException("Error copying JAR files to container from local filesystem", e); 714 } 715 } 716 } 717}