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 073 public SmileCdrContainer() { 074 this(DEFAULT_DOCKER_IMAGE_NAME); 075 } 076 077 /** 078 * Use this constructor to change the version of Smile CDR, but use Smile's container registry. 079 */ 080 public SmileCdrContainer(String theSmileCdrVersion) { 081 this(new DockerImageName(DEFAULT_REGISTRY + "/" + DEFAULT_IMAGE + ":" + theSmileCdrVersion)); 082 083 } 084 085 /** 086 * Use this constructor to provide a completely custom Docker Image Name to use. Use this if you need to use your own private registry 087 */ 088 public SmileCdrContainer(DockerImageName imageName) { 089 super(imageName); 090 //Wait on successful boot message 091 LogMessageWaitStrategy logMessageWaitStrategy = new LogMessageWaitStrategy(); 092 logMessageWaitStrategy.withRegEx(".*Smile, we're up and running.*").withStartupTimeout(Duration.ofMinutes(10)); 093 waitingFor(logMessageWaitStrategy); 094 095 // Also output logs to stdout to ensure they appear in test output 096 withLogConsumer(SmileCdrContainer::accept); 097 } 098 099 100 @Override 101 public void start() { 102 String logbackXmlFile; 103 104 if (!StringUtils.isBlank(myCustomLogbackFile)) { 105 ourLog.info("Custom properties file provided, using it. [file={}]", myCustomPropertiesFileLocation); 106 logbackXmlFile = myCustomPropertiesFileLocation; 107 MountableFile mountableFile = MountableFile.forClasspathResource(logbackXmlFile); 108 withCopyFileToContainer(mountableFile, "/home/smile/smilecdr/customerlib/all-troubleshooters-enabled-logback.xml"); 109 } else { 110 ourLog.error("No custom logback file provided, using default logback file. [file={}]", "/all-troubleshooters-enabled-logback.xml"); 111 } 112 113 // Copy JAR files from cdr-interceptor-starterproject/target to the container 114 copyCustomerJars(); 115 116 String propertiesFileLocation = resolvePropertiesFile(); 117 exposePortsFromPropertiesFile(propertiesFileLocation); 118 bootstrapContainerFilesFromProperties(propertiesFileLocation); 119 super.start(); 120 } 121 122 private void bootstrapContainerFilesFromProperties(String thePropertiesFileLocation) { 123 try { 124 MountableFile mountableFile = MountableFile.forClasspathResource(thePropertiesFileLocation); 125 126 // Read the properties file 127 Properties properties = new Properties(); 128 try (FileInputStream fis = new FileInputStream(mountableFile.getResolvedPath())) { 129 properties.load(fis); 130 } 131 132 // Look for classpath and file references in property values 133 for (String key : properties.stringPropertyNames()) { 134 String value = properties.getProperty(key); 135 if (value != null && (value.startsWith("classpath:") || value.startsWith("file:"))) { 136 copyReferencedFileToContainer(value); 137 } 138 } 139 } catch (IOException e) { 140 throw new RuntimeException("Failed to process properties file for bootstrap files: " + thePropertiesFileLocation, e); 141 } 142 } 143 144 private void copyReferencedFileToContainer(String theReference) { 145 try { 146 String resourcePath; 147 148 if (theReference.startsWith("classpath:")) { 149 // Remove "classpath:" prefix and handle leading slash 150 resourcePath = theReference.substring("classpath:".length()); 151 if (resourcePath.startsWith("/")) { 152 resourcePath = resourcePath.substring(1); 153 } 154 } else if (theReference.startsWith("file:")) { 155 // Remove "file:" prefix 156 resourcePath = theReference.substring("file:".length()); 157 } else { 158 return; // Not a reference we handle 159 } 160 161 // Try to find the file on the classpath first 162 try { 163 MountableFile mountableFile = MountableFile.forClasspathResource(resourcePath); 164 String containerPath = "/home/smile/smilecdr/classes/" + resourcePath; 165 withCopyFileToContainer(mountableFile, containerPath); 166 ourLog.info("Copied classpath resource to container: {} -> {}", resourcePath, containerPath); 167 } catch (IllegalArgumentException e) { 168 ourLog.warn("Referenced file not found on classpath: {}", resourcePath); 169 } 170 } catch (Exception e) { 171 ourLog.error("Failed to copy referenced file to container: {}", theReference, e); 172 } 173 } 174 175 176 private String resolvePropertiesFile() { 177 String propertiesFile; 178 if (StringUtils.isBlank(myCustomPropertiesFileLocation)) { 179 ourLog.info("No custom properties file provided, using default properties file. [file={}]", "/cdr-config-fallback.properties"); 180 propertiesFile = "/cdr-config-fallback.properties"; 181 } else { 182 ourLog.info("Custom properties file provided, using it. [file={}]", myCustomPropertiesFileLocation); 183 propertiesFile = myCustomPropertiesFileLocation; 184 } 185 return propertiesFile; 186 } 187 188 public SmileCdrContainer withBoundProjectClasspath() { 189 String root = Paths.get(".").toAbsolutePath().normalize().toString(); 190 MountableFile mountableFile = MountableFile.forHostPath(root + "/target/classes"); 191 withCopyFileToContainer(mountableFile, "/home/smile/smilecdr/classes"); 192 return this; 193 } 194 195 public SmileCdrContainer withPreseedFiles(String... filesToMount) { 196 for (String fileToMount : filesToMount) { 197 MountableFile mountableFile = MountableFile.forClasspathResource(fileToMount); 198 withCopyFileToContainer(mountableFile, "/home/smile/smilecdr/classes/config_seeding/"); 199 } 200 return this; 201 } 202 203 public SmileCdrContainer withCustomHarnessContext(HarnessContext theHarnessContext) { 204 myHarnessContext = theHarnessContext; 205 return this; 206 } 207 208 public SmileCdrContainer withPropertiesFile(String thePropertiesFileLocation) { 209 myCustomPropertiesFileLocation = thePropertiesFileLocation; 210 return this; 211 } 212 213 public SmileCdrContainer withCustomLogback(String theCustomLogbackLocation) { 214 if (!StringUtils.isBlank(myCustomLogbackFile)) { 215 throw new IllegalStateException("Cannot set a custom logback file when a custom properties file is provided!"); 216 } 217 myCustomLogbackFile = theCustomLogbackLocation; 218 return this; 219 } 220 221 public SmileCdrContainer withAllTroubleshootingLogsEnabled() { 222 myCustomLogbackFile = "/all-troubleshooters-enabled-logback.xml"; 223 return this; 224 } 225 226 /** 227 * Specifies which JAR files from the cdr-interceptor-starterproject's target folder should be copied 228 * into the SmileCdrContainer at /home/smile/smilecdr/customerlib/. 229 * <p> 230 * First looks in the classpath for a jar with a given name. If it does not find one, 231 * it also looks in the target/ directory. If it still doesn't find one, then it fails. 232 * 233 * @param theJarNames A comma-separated list of JAR filenames to copy. If null or empty, all JAR files 234 * in the target directory will be copied. 235 * @return This container instance for method chaining 236 */ 237 public SmileCdrContainer withCustomerJars(String... theJarNames) { 238 if (theJarNames != null && theJarNames.length > 0) { 239 myCustomerJarNames = Arrays.stream(theJarNames) 240 .map(String::trim) 241 .filter(s -> !s.isEmpty()) 242 .collect(Collectors.toList()); 243 244 ourLog.info("Registered {} JAR file(s) to be mounted into customerlib", myCustomerJarNames.size()); 245 } 246 return this; 247 } 248 249 private void exposePortsFromPropertiesFile(String thePropertiesFileLocation) { 250 MountableFile mountableFile = MountableFile.forClasspathResource(thePropertiesFileLocation); 251 withCopyFileToContainer(mountableFile, "/home/smile/smilecdr/classes/cdr-config-Master.properties"); 252 253 Map<String, Map<String, Integer>> portsByModuleType = PropertiesTestUtil.loadPortsFromPropertiesFile(mountableFile.getResolvedPath()); 254 255 // Add all ports to myPortValues and expose them 256 for (Map<String, Integer> modulePortMap : portsByModuleType.values()) { 257 for (Integer portValue : modulePortMap.values()) { 258 addExposedPort(portValue); 259 } 260 } 261 262 // Merge the loaded ports into myPortValues 263 for (Map.Entry<String, Map<String, Integer>> entry : portsByModuleType.entrySet()) { 264 String moduleType = entry.getKey(); 265 Map<String, Integer> modulePorts = entry.getValue(); 266 myPortValues.computeIfAbsent(moduleType, k -> new HashMap<>()).putAll(modulePorts); 267 } 268 } 269 270 public SmileHarness getHarness() { 271 if (myHarnessContext == null) { 272 // Find the admin JSON port from the discovered ports 273 Map<String, Integer> adminJsonPorts = myPortValues.get("ADMIN_JSON"); 274 Integer adminJsonPort = null; 275 if (adminJsonPorts != null && !adminJsonPorts.isEmpty()) { 276 adminJsonPort = adminJsonPorts.values().iterator().next(); 277 } 278 myHarnessContext = new HarnessContext("http", this.getHost(), adminJsonPort, "admin", "password"); 279 } 280 return new LocalhostSmileHarness(myHarnessContext, getPortMapping()); 281 } 282 283 public Map<Integer, Integer> getPortMapping() { 284 return getExposedPorts().stream().collect(Collectors.toMap(port -> port, this::getMappedPort)); 285 } 286 287 /** 288 * Gets the port values organized by Module Type and Module ID. 289 * 290 * @return A map where the key is the Module Type and the value is a map of Module ID to port 291 */ 292 public Map<String, Map<String, Integer>> getPortValuesByModuleType() { 293 return new HashMap<>(myPortValues); 294 } 295 296 private static void accept(OutputFrame outputFrame) { 297 System.out.print(outputFrame.getUtf8String()); 298 } 299 300 /** 301 * Copies JAR files from the cdr-interceptor-starterproject's target directory to the container. 302 * If myCustomerJarNames is not null, only the specified JAR files will be copied. 303 * Otherwise, all JAR files in the target directory will be copied. 304 * <p> 305 * First attempts to load JAR files from the classpath using MountableFile.forClasspathResource(). 306 * If that fails (because the JAR file is not on the classpath), falls back to using MountableFile.forHostPath(). 307 * If a JAR is not found in either location, throws a RuntimeException. 308 */ 309 private void copyCustomerJars() { 310 // If specific JAR names are provided, try to load them from the classpath first 311 if (myCustomerJarNames != null && !myCustomerJarNames.isEmpty()) { 312 List<String> jarsFoundOnClasspath = new ArrayList<>(); 313 List<String> jarsNotFoundOnClasspath = new ArrayList<>(); 314 315 for (String jarName : myCustomerJarNames) { 316 try { 317 ourLog.info("Attempting to load JAR file from classpath: {}", jarName); 318 MountableFile mountableFile = MountableFile.forClasspathResource(jarName); 319 withCopyFileToContainer(mountableFile, "/home/smile/smilecdr/customerlib/" + jarName); 320 jarsFoundOnClasspath.add(jarName); 321 } catch (IllegalArgumentException e) { 322 ourLog.info("JAR file not found on classpath: {}. Will try local filesystem.", jarName); 323 jarsNotFoundOnClasspath.add(jarName); 324 } 325 } 326 327 // If all JARs were found on the classpath, we're done 328 if (jarsNotFoundOnClasspath.isEmpty()) { 329 ourLog.info("All specified JAR files were loaded from the classpath."); 330 return; 331 } 332 333 // Fall back to loading from the local filesystem for JARs not found on classpath 334 try { 335 // Find the cdr-interceptor-starterproject directory 336 Path projectRoot = Paths.get("."); 337 Path targetDir = projectRoot.resolve("target"); 338 339 if (!Files.exists(targetDir)) { 340 throw new RuntimeException("Could not find target directory. The following JAR files were not found on the classpath: " + jarsNotFoundOnClasspath); 341 } 342 343 // Get all JAR files in the target directory 344 List<File> jarFiles; 345 try (var paths = Files.list(targetDir)) { 346 jarFiles = paths 347 .filter(path -> path.toString().endsWith(".jar")) 348 .map(Path::toFile) 349 .toList(); 350 } 351 352 if (jarFiles.isEmpty()) { 353 throw new IllegalArgumentException("No JAR files found in target directory. The following JAR files were not found on the classpath: " + jarsNotFoundOnClasspath); 354 } 355 356 // Filter JAR files based on jarsNotFoundOnClasspath 357 List<File> filesToCopy = jarFiles.stream() 358 .filter(file -> jarsNotFoundOnClasspath.contains(file.getName())) 359 .toList(); 360 361 // Check if all JARs not found on classpath were found in target directory 362 if (filesToCopy.size() < jarsNotFoundOnClasspath.size()) { 363 List<String> jarsNotFound = new ArrayList<>(jarsNotFoundOnClasspath); 364 jarsNotFound.removeAll(filesToCopy.stream().map(File::getName).toList()); 365 throw new IllegalArgumentException("The following JAR files were not found in either the classpath or the target directory: " + jarsNotFound); 366 } 367 368 for (File jarFile : filesToCopy) { 369 ourLog.info("Copying JAR file from local filesystem to container: {}", jarFile.getName()); 370 MountableFile mountableFile = MountableFile.forHostPath(jarFile.toPath()); 371 withCopyFileToContainer(mountableFile, "/home/smile/smilecdr/customerlib/" + jarFile.getName()); 372 } 373 374 ourLog.info("Copied {} JAR file(s) to container from local filesystem", filesToCopy.size()); 375 } catch (IOException e) { 376 throw new RuntimeException("Error copying JAR files to container from local filesystem", e); 377 } 378 } 379 } 380}