001/*- 002 * #%L 003 * Smile CDR - CDR 004 * %% 005 * Copyright (C) 2016 - 2025 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.impl.LocalhostSmileHarness; 014import ca.cdr.test.app.harness.api.SmileHarness; 015import ca.cdr.test.util.PropertiesTestUtil; 016import com.apicatalog.jsonld.StringUtils; 017import org.testcontainers.containers.GenericContainer; 018import org.testcontainers.containers.output.OutputFrame; 019import org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy; 020import org.testcontainers.utility.DockerImageName; 021import org.testcontainers.utility.MountableFile; 022import org.slf4j.Logger; 023import org.slf4j.LoggerFactory; 024 025import java.io.File; 026import java.io.FileInputStream; 027import java.io.IOException; 028import java.nio.file.Files; 029import java.nio.file.Path; 030import java.nio.file.Paths; 031import java.time.Duration; 032import java.util.ArrayList; 033import java.util.Arrays; 034import java.util.HashMap; 035import java.util.List; 036import java.util.Map; 037import java.util.Properties; 038import java.util.stream.Collectors; 039 040/** 041 * SmileCdrContainer is a container abstraction for orchestrating Smile CDR instances 042 * within Docker environments. 043 * <p> 044 * Instances of this container allow for flexible customization and configuration of 045 * Smile CDR images, including properties file specifications, logback configurations, 046 * and support for customer JAR files. It supports smooth integration and automated 047 * provisioning of Smile CDR services for testing and development environments. 048 * <p> 049 * The design facilitates: 050 * - Configuration of Smile CDR version and the corresponding Docker image. 051 * - Mounting of custom resources such as properties files, logback configurations, or JARs. 052 * - Automatic setup of exposed ports based on specified or default configurations. 053 * - Log consumption to aid in debugging and diagnostics during container runtime. 054 * - Ensuring modular support with organized port mapping and harness configuration. 055 * <p> 056 * This class was generated partly with the help of Claude Sonnet 3.7 057 */ 058public class SmileCdrContainer extends GenericContainer<SmileCdrContainer> { 059 060 private static final Logger ourLog = LoggerFactory.getLogger(SmileCdrContainer.class); 061 private static final String DEFAULT_REGISTRY = "docker.smilecdr.com"; 062 private static final String DEFAULT_IMAGE = "smilecdr"; 063 private static final String DEFAULT_TAG = "latest"; 064 private static final DockerImageName DEFAULT_DOCKER_IMAGE_NAME = DockerImageName.parse(DEFAULT_REGISTRY + "/" + DEFAULT_IMAGE + ":" + DEFAULT_TAG); 065 066 067 private final Map<String, Map<String, Integer>> myPortValues = new HashMap<>(); 068 private HarnessContext myHarnessContext; 069 private String myCustomPropertiesFileLocation; 070 private String myCustomLogbackFile; 071 private List<String> myCustomerJarNames; 072 private boolean myDebugEnabled = false; 073 private boolean myDebugSuspendUntilDebuggerAttached = true; 074 private static final int DEBUG_PORT = 5005; 075 076 public SmileCdrContainer() { 077 this(DEFAULT_DOCKER_IMAGE_NAME); 078 } 079 080 /** 081 * Use this constructor to change the version of Smile CDR, but use Smile's container registry. 082 */ 083 public SmileCdrContainer(String theSmileCdrVersion) { 084 this(new DockerImageName(DEFAULT_REGISTRY + "/" + DEFAULT_IMAGE + ":" + theSmileCdrVersion)); 085 086 } 087 088 /** 089 * Use this constructor to provide a completely custom Docker Image Name to use. Use this if you need to use your own private registry 090 */ 091 public SmileCdrContainer(DockerImageName imageName) { 092 super(imageName); 093 //Wait on successful boot message 094 LogMessageWaitStrategy logMessageWaitStrategy = new LogMessageWaitStrategy(); 095 logMessageWaitStrategy.withRegEx(".*Smile, we're up and running.*").withStartupTimeout(Duration.ofMinutes(10)); 096 waitingFor(logMessageWaitStrategy); 097 098 // Also output logs to stdout to ensure they appear in test output 099 withLogConsumer(SmileCdrContainer::accept); 100 } 101 102 103 @Override 104 public void start() { 105 String logbackXmlFile; 106 107 if (!StringUtils.isBlank(myCustomLogbackFile)) { 108 ourLog.info("Custom properties file provided, using it. [file={}]", myCustomPropertiesFileLocation); 109 logbackXmlFile = myCustomPropertiesFileLocation; 110 MountableFile mountableFile = MountableFile.forClasspathResource(logbackXmlFile); 111 withCopyFileToContainer(mountableFile, "/home/smile/smilecdr/customerlib/all-troubleshooters-enabled-logback.xml"); 112 } else { 113 ourLog.error("No custom logback file provided, using default logback file. [file={}]", "/all-troubleshooters-enabled-logback.xml"); 114 } 115 116 // Copy JAR files from cdr-interceptor-starterproject/target to the container 117 copyCustomerJars(); 118 119 String propertiesFileLocation = resolvePropertiesFile(); 120 exposePortsFromPropertiesFile(propertiesFileLocation); 121 bootstrapContainerFilesFromProperties(propertiesFileLocation); 122 123 if (myDebugEnabled) { 124 ourLog.info("Debug mode enabled - container is accessible for debuggers on port {}", DEBUG_PORT); 125 addFixedExposedPort(DEBUG_PORT, DEBUG_PORT); 126 String jvmArgs = buildJvmArgs(); 127 ourLog.info("JVMARGS is now set to: {}", jvmArgs); 128 withEnv("JVMARGS", jvmArgs); 129 if (myDebugSuspendUntilDebuggerAttached) { 130 ourLog.warn("=========================================="); 131 ourLog.warn("DEBUG MODE(suspend): Container will wait for debugger"); 132 ourLog.warn("Connect your IDE debugger to: localhost:{}", DEBUG_PORT); 133 ourLog.warn("Container will resume after debugger attaches"); 134 ourLog.warn("=========================================="); 135 } 136 } 137 super.start(); 138 } 139 140 private String buildJvmArgs() { 141 return new StringBuilder() 142 .append("-agentlib:jdwp=transport=dt_socket,server=y,suspend=") 143 .append(myDebugSuspendUntilDebuggerAttached ? "y": "n") 144 .append(",address=*:") 145 .append(DEBUG_PORT) 146 .toString(); 147 } 148 149 private void bootstrapContainerFilesFromProperties(String thePropertiesFileLocation) { 150 try { 151 MountableFile mountableFile = MountableFile.forClasspathResource(thePropertiesFileLocation); 152 153 // Read the properties file 154 Properties properties = new Properties(); 155 try (FileInputStream fis = new FileInputStream(mountableFile.getResolvedPath())) { 156 properties.load(fis); 157 } 158 159 // Look for classpath and file references in property values 160 for (String key : properties.stringPropertyNames()) { 161 String value = properties.getProperty(key); 162 if (value != null && (value.startsWith("classpath:") || value.startsWith("file:"))) { 163 copyReferencedFileToContainer(value); 164 } 165 } 166 } catch (IOException e) { 167 throw new RuntimeException("Failed to process properties file for bootstrap files: " + thePropertiesFileLocation, e); 168 } 169 } 170 171 private void copyReferencedFileToContainer(String theReference) { 172 try { 173 String resourcePath; 174 175 if (theReference.startsWith("classpath:")) { 176 // Remove "classpath:" prefix and handle leading slash 177 resourcePath = theReference.substring("classpath:".length()); 178 if (resourcePath.startsWith("/")) { 179 resourcePath = resourcePath.substring(1); 180 } 181 } else if (theReference.startsWith("file:")) { 182 // Remove "file:" prefix 183 resourcePath = theReference.substring("file:".length()); 184 } else { 185 return; // Not a reference we handle 186 } 187 188 // Try to find the file on the classpath first 189 try { 190 MountableFile mountableFile = MountableFile.forClasspathResource(resourcePath); 191 String containerPath = "/home/smile/smilecdr/classes/" + resourcePath; 192 withCopyFileToContainer(mountableFile, containerPath); 193 ourLog.info("Copied classpath resource to container: {} -> {}", resourcePath, containerPath); 194 } catch (IllegalArgumentException e) { 195 ourLog.warn("Referenced file not found on classpath: {}", resourcePath); 196 } 197 } catch (Exception e) { 198 ourLog.error("Failed to copy referenced file to container: {}", theReference, e); 199 } 200 } 201 202 203 private String resolvePropertiesFile() { 204 String propertiesFile; 205 if (StringUtils.isBlank(myCustomPropertiesFileLocation)) { 206 ourLog.info("No custom properties file provided, using default properties file. [file={}]", "/cdr-config-fallback.properties"); 207 propertiesFile = "/cdr-config-fallback.properties"; 208 } else { 209 ourLog.info("Custom properties file provided, using it. [file={}]", myCustomPropertiesFileLocation); 210 propertiesFile = myCustomPropertiesFileLocation; 211 } 212 return propertiesFile; 213 } 214 215 /** 216 * Binds the target/classes and target/test-classes directories into the container's `/classes` directory. 217 * Only mounts directories if they exist and are non-empty. 218 * @return the SmileCdrContainer. 219 */ 220 public SmileCdrContainer withBoundProjectClasspath() { 221 String root = Paths.get(".").toAbsolutePath().normalize().toString(); 222 223 Path classesPath = Paths.get(root, "target", "classes"); 224 if (Files.exists(classesPath) && isDirectoryNonEmpty(classesPath)) { 225 MountableFile mountableFile = MountableFile.forHostPath(classesPath.toString()); 226 withCopyFileToContainer(mountableFile, "/home/smile/smilecdr/classes"); 227 ourLog.info("Mounted target/classes directory to container"); 228 } else { 229 ourLog.info("Skipping target/classes mount - directory does not exist or is empty"); 230 } 231 232 Path testClassesPath = Paths.get(root, "target", "test-classes"); 233 if (Files.exists(testClassesPath) && isDirectoryNonEmpty(testClassesPath)) { 234 MountableFile testMountableFile = MountableFile.forHostPath(testClassesPath.toString()); 235 withCopyFileToContainer(testMountableFile, "/home/smile/smilecdr/classes"); 236 ourLog.info("Mounted target/test-classes directory to container"); 237 } else { 238 ourLog.info("Skipping target/test-classes mount - directory does not exist or is empty"); 239 } 240 241 return this; 242 } 243 244 /** 245 * Checks if a directory is non-empty by attempting to read at least one entry. 246 * @param thePath the directory path to check 247 * @return true if the directory contains at least one file or subdirectory, false otherwise 248 */ 249 private boolean isDirectoryNonEmpty(Path thePath) { 250 try (var stream = Files.list(thePath)) { 251 return stream.findFirst().isPresent(); 252 } catch (IOException e) { 253 ourLog.warn("Error checking if directory is non-empty: {}", thePath, e); 254 return false; 255 } 256 } 257 258 public SmileCdrContainer withPreseedFiles(String... filesToMount) { 259 for (String fileToMount : filesToMount) { 260 MountableFile mountableFile = MountableFile.forClasspathResource(fileToMount); 261 withCopyFileToContainer(mountableFile, "/home/smile/smilecdr/classes/config_seeding/"); 262 } 263 return this; 264 } 265 266 public SmileCdrContainer withCustomHarnessContext(HarnessContext theHarnessContext) { 267 myHarnessContext = theHarnessContext; 268 return this; 269 } 270 271 public SmileCdrContainer withPropertiesFile(String thePropertiesFileLocation) { 272 myCustomPropertiesFileLocation = thePropertiesFileLocation; 273 return this; 274 } 275 276 public SmileCdrContainer withCustomLogback(String theCustomLogbackLocation) { 277 if (!StringUtils.isBlank(myCustomLogbackFile)) { 278 throw new IllegalStateException("Cannot set a custom logback file when a custom properties file is provided!"); 279 } 280 myCustomLogbackFile = theCustomLogbackLocation; 281 return this; 282 } 283 284 public SmileCdrContainer withAllTroubleshootingLogsEnabled() { 285 myCustomLogbackFile = "/all-troubleshooters-enabled-logback.xml"; 286 return this; 287 } 288 289 /** 290 * Specifies which JAR files from the cdr-interceptor-starterproject's target folder should be copied 291 * into the SmileCdrContainer at /home/smile/smilecdr/customerlib/. 292 * <p> 293 * First looks in the classpath for a jar with a given name. If it does not find one, 294 * it also looks in the target/ directory. If it still doesn't find one, then it fails. 295 * 296 * @param theJarNames A comma-separated list of JAR filenames to copy. If null or empty, all JAR files 297 * in the target directory will be copied. 298 * @return This container instance for method chaining 299 */ 300 public SmileCdrContainer withCustomerJars(String... theJarNames) { 301 if (theJarNames != null && theJarNames.length > 0) { 302 myCustomerJarNames = Arrays.stream(theJarNames) 303 .map(String::trim) 304 .filter(s -> !s.isEmpty()) 305 .collect(Collectors.toList()); 306 307 ourLog.info("Registered {} JAR file(s) to be mounted into customerlib", myCustomerJarNames.size()); 308 } 309 return this; 310 } 311 312 /** 313 * Enables remote debugging for the Smile CDR instance running in this container. 314 * When enabled, the container will expose port 5005 (fixed mapping) and wait for a debugger to attach 315 * before completing startup (suspend=y). 316 * 317 * <h3>How to Connect IntelliJ IDEA Debugger:</h3> 318 * <ol> 319 * <li>Run ? Edit Configurations ? Add New ? Remote JVM Debug</li> 320 * <li>Set Host: localhost (or container host)</li> 321 * <li>Set Port: 5005 (always fixed to this port)</li> 322 * <li>Set Debugger mode: Attach to remote JVM</li> 323 * <li>Click Debug - the container will resume startup once debugger connects</li> 324 * </ol> 325 * 326 * <h3>Example Usage:</h3> 327 * <pre> 328 * {@code 329 * @Container 330 * SmileCdrContainer container = new SmileCdrContainer() 331 * .withPropertiesFile("/my-config.properties") 332 * .withDebugEnabled(); 333 * 334 * // Port 5005 is always used - no need to query for mapped port 335 * // Just connect your debugger to localhost:5005 336 * } 337 * </pre> 338 * 339 * <p><strong>IMPORTANT:</strong> The container will pause during startup until a debugger 340 * connects. This is intentional to allow setting breakpoints before code execution begins. 341 * Port 5005 must be available on the host machine.</p> 342 * 343 * @return This container instance for method chaining 344 */ 345 public SmileCdrContainer withDebugEnabled() { 346 return withDebugEnabled(true); 347 } 348 349 /** 350 * Enables remote debugging for the Smile CDR instance running in this container. 351 * When enabled, the container will expose port 5005 (fixed mapping). 352 * 353 * - If `theSuspendUntilDebuggerAttached` is true, the container will pause until a debugger is connected. 354 * - If `theSuspendUntilDebuggerAttached` is false, the container will boot without waiting for the debugger. 355 * 356 * 357 * <h3>How to Connect IntelliJ IDEA Debugger:</h3> 358 * <ol> 359 * <li>Run ? Edit Configurations ? Add New ? Remote JVM Debug</li> 360 * <li>Set Host: localhost (or container host)</li> 361 * <li>Set Port: 5005 (always fixed to this port)</li> 362 * <li>Set Debugger mode: Attach to remote JVM</li> 363 * <li>Click Debug - the container will resume startup once debugger connects</li> 364 * </ol> 365 * 366 * <h3>Example Usage:</h3> 367 * <pre> 368 * {@code 369 * @Container 370 * SmileCdrContainer container = new SmileCdrContainer() 371 * .withPropertiesFile("/my-config.properties") 372 * .withDebugEnabled(false); 373 * 374 * // Port 5005 is always used - no need to query for mapped port 375 * // Just connect your debugger to localhost:5005 376 * } 377 * </pre> 378 * 379 * <p><strong>IMPORTANT:</strong> If you have set `theSuspendUntilDebuggerAttached` to True, 380 * the container will pause during startup until a debugger 381 * connects. This is intentional to allow setting breakpoints before code execution begins. 382 * Port 5005 must be available on the host machine.</p> 383 * 384 * @param theSuspendUntilDebuggerAttached if set to true, blocks the container startup until a debugger connects. 385 * 386 * @return This container instance for method chaining 387 */ 388 public SmileCdrContainer withDebugEnabled(boolean theSuspendUntilDebuggerAttached) { 389 myDebugEnabled = true; 390 myDebugSuspendUntilDebuggerAttached = theSuspendUntilDebuggerAttached; 391 return this; 392 } 393 394 private void exposePortsFromPropertiesFile(String thePropertiesFileLocation) { 395 MountableFile mountableFile = MountableFile.forClasspathResource(thePropertiesFileLocation); 396 withCopyFileToContainer(mountableFile, "/home/smile/smilecdr/classes/cdr-config-Master.properties"); 397 398 Map<String, Map<String, Integer>> portsByModuleType = PropertiesTestUtil.loadPortsFromPropertiesFile(mountableFile.getResolvedPath()); 399 400 // Add all ports to myPortValues and expose them 401 for (Map<String, Integer> modulePortMap : portsByModuleType.values()) { 402 for (Integer portValue : modulePortMap.values()) { 403 addExposedPort(portValue); 404 } 405 } 406 407 // Merge the loaded ports into myPortValues 408 for (Map.Entry<String, Map<String, Integer>> entry : portsByModuleType.entrySet()) { 409 String moduleType = entry.getKey(); 410 Map<String, Integer> modulePorts = entry.getValue(); 411 myPortValues.computeIfAbsent(moduleType, k -> new HashMap<>()).putAll(modulePorts); 412 } 413 } 414 415 public SmileHarness getHarness() { 416 if (myHarnessContext == null) { 417 // Find the admin JSON port from the discovered ports 418 Map<String, Integer> adminJsonPorts = myPortValues.get("ADMIN_JSON"); 419 Integer adminJsonPort = null; 420 if (adminJsonPorts != null && !adminJsonPorts.isEmpty()) { 421 adminJsonPort = adminJsonPorts.values().iterator().next(); 422 } 423 myHarnessContext = new HarnessContext("http", this.getHost(), adminJsonPort, "admin", "password"); 424 } 425 return new LocalhostSmileHarness(myHarnessContext, getPortMapping()); 426 } 427 428 public Map<Integer, Integer> getPortMapping() { 429 return getExposedPorts().stream().collect(Collectors.toMap(port -> port, this::getMappedPort)); 430 } 431 432 /** 433 * Gets the port values organized by Module Type and Module ID. 434 * 435 * @return A map where the key is the Module Type and the value is a map of Module ID to port 436 */ 437 public Map<String, Map<String, Integer>> getPortValuesByModuleType() { 438 return new HashMap<>(myPortValues); 439 } 440 441 private static void accept(OutputFrame outputFrame) { 442 System.out.print(outputFrame.getUtf8String()); 443 } 444 445 /** 446 * Copies JAR files from the cdr-interceptor-starterproject's target directory to the container. 447 * If myCustomerJarNames is not null, only the specified JAR files will be copied. 448 * Otherwise, all JAR files in the target directory will be copied. 449 * <p> 450 * First attempts to load JAR files from the classpath using MountableFile.forClasspathResource(). 451 * If that fails (because the JAR file is not on the classpath), falls back to using MountableFile.forHostPath(). 452 * If a JAR is not found in either location, throws a RuntimeException. 453 */ 454 private void copyCustomerJars() { 455 // If specific JAR names are provided, try to load them from the classpath first 456 if (myCustomerJarNames != null && !myCustomerJarNames.isEmpty()) { 457 List<String> jarsFoundOnClasspath = new ArrayList<>(); 458 List<String> jarsNotFoundOnClasspath = new ArrayList<>(); 459 460 for (String jarName : myCustomerJarNames) { 461 try { 462 ourLog.info("Attempting to load JAR file from classpath: {}", jarName); 463 MountableFile mountableFile = MountableFile.forClasspathResource(jarName); 464 withCopyFileToContainer(mountableFile, "/home/smile/smilecdr/customerlib/" + jarName); 465 jarsFoundOnClasspath.add(jarName); 466 } catch (IllegalArgumentException e) { 467 ourLog.info("JAR file not found on classpath: {}. Will try local filesystem.", jarName); 468 jarsNotFoundOnClasspath.add(jarName); 469 } 470 } 471 472 // If all JARs were found on the classpath, we're done 473 if (jarsNotFoundOnClasspath.isEmpty()) { 474 ourLog.info("All specified JAR files were loaded from the classpath."); 475 return; 476 } 477 478 // Fall back to loading from the local filesystem for JARs not found on classpath 479 try { 480 // Find the cdr-interceptor-starterproject directory 481 Path projectRoot = Paths.get("."); 482 Path targetDir = projectRoot.resolve("target"); 483 484 if (!Files.exists(targetDir)) { 485 throw new RuntimeException("Could not find target directory. The following JAR files were not found on the classpath: " + jarsNotFoundOnClasspath); 486 } 487 488 // Get all JAR files in the target directory 489 List<File> jarFiles; 490 try (var paths = Files.list(targetDir)) { 491 jarFiles = paths 492 .filter(path -> path.toString().endsWith(".jar")) 493 .map(Path::toFile) 494 .toList(); 495 } 496 497 if (jarFiles.isEmpty()) { 498 throw new IllegalArgumentException("No JAR files found in target directory. The following JAR files were not found on the classpath: " + jarsNotFoundOnClasspath); 499 } 500 501 // Filter JAR files based on jarsNotFoundOnClasspath 502 List<File> filesToCopy = jarFiles.stream() 503 .filter(file -> jarsNotFoundOnClasspath.contains(file.getName())) 504 .toList(); 505 506 // Check if all JARs not found on classpath were found in target directory 507 if (filesToCopy.size() < jarsNotFoundOnClasspath.size()) { 508 List<String> jarsNotFound = new ArrayList<>(jarsNotFoundOnClasspath); 509 jarsNotFound.removeAll(filesToCopy.stream().map(File::getName).toList()); 510 throw new IllegalArgumentException("The following JAR files were not found in either the classpath or the target directory: " + jarsNotFound); 511 } 512 513 for (File jarFile : filesToCopy) { 514 ourLog.info("Copying JAR file from local filesystem to container: {}", jarFile.getName()); 515 MountableFile mountableFile = MountableFile.forHostPath(jarFile.toPath()); 516 withCopyFileToContainer(mountableFile, "/home/smile/smilecdr/customerlib/" + jarFile.getName()); 517 } 518 519 ourLog.info("Copied {} JAR file(s) to container from local filesystem", filesToCopy.size()); 520 } catch (IOException e) { 521 throw new RuntimeException("Error copying JAR files to container from local filesystem", e); 522 } 523 } 524 } 525}