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.DockerSmileHarness; 014import ca.cdr.test.app.harness.api.SmileHarness; 015import com.apicatalog.jsonld.StringUtils; 016import org.testcontainers.containers.GenericContainer; 017import org.testcontainers.containers.output.OutputFrame; 018import org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy; 019import org.testcontainers.utility.DockerImageName; 020import org.testcontainers.utility.MountableFile; 021import org.slf4j.Logger; 022import org.slf4j.LoggerFactory; 023 024import java.io.File; 025import java.io.FileInputStream; 026import java.io.IOException; 027import java.nio.file.Files; 028import java.nio.file.Path; 029import java.nio.file.Paths; 030import java.time.Duration; 031import java.util.ArrayList; 032import java.util.Arrays; 033import java.util.HashMap; 034import java.util.List; 035import java.util.Map; 036import java.util.Properties; 037import java.util.stream.Collectors; 038 039/** 040 * SmileCdrContainer is a container abstraction for orchestrating Smile CDR instances 041 * within Docker environments. 042 * <p> 043 * Instances of this container allow for flexible customization and configuration of 044 * Smile CDR images, including properties file specifications, logback configurations, 045 * and support for customer JAR files. It supports smooth integration and automated 046 * provisioning of Smile CDR services for testing and development environments. 047 * <p> 048 * The design facilitates: 049 * - Configuration of Smile CDR version and the corresponding Docker image. 050 * - Mounting of custom resources such as properties files, logback configurations, or JARs. 051 * - Automatic setup of exposed ports based on specified or default configurations. 052 * - Log consumption to aid in debugging and diagnostics during container runtime. 053 * - Ensuring modular support with organized port mapping and harness configuration. 054 * <p> 055 * This class was generated partly with the help of Claude Sonnet 3.7 056 */ 057public class SmileCdrContainer extends GenericContainer<SmileCdrContainer> { 058 059 private static final Logger ourLog = LoggerFactory.getLogger(SmileCdrContainer.class); 060 private static final String DEFAULT_REGISTRY = "docker.smilecdr.com"; 061 private static final String DEFAULT_IMAGE = "smilecdr"; 062 private static final String DEFAULT_TAG = "latest"; 063 private static final DockerImageName DEFAULT_DOCKER_IMAGE_NAME = DockerImageName.parse(DEFAULT_REGISTRY + "/" + DEFAULT_IMAGE + ":" + DEFAULT_TAG); 064 065 066 private final Map<String, Map<String, Integer>> myPortValues = new HashMap<>(); 067 private HarnessContext myHarnessContext; 068 private String myCustomPropertiesFileLocation; 069 private String myCustomLogbackFile; 070 private List<String> myCustomerJarNames; 071 072 public SmileCdrContainer() { 073 this(DEFAULT_DOCKER_IMAGE_NAME); 074 } 075 076 /** 077 * Use this constructor to change the version of Smile CDR, but use Smile's container registry. 078 */ 079 public SmileCdrContainer(String theSmileCdrVersion) { 080 this(new DockerImageName(DEFAULT_REGISTRY + "/" + DEFAULT_IMAGE + ":" + theSmileCdrVersion)); 081 082 } 083 084 /** 085 * Use this constructor to provide a completely custom Docker Image Name to use. Use this if you need to use your own private registry 086 */ 087 public SmileCdrContainer(DockerImageName imageName) { 088 super(imageName); 089 //Wait on successful boot message 090 LogMessageWaitStrategy logMessageWaitStrategy = new LogMessageWaitStrategy(); 091 logMessageWaitStrategy.withRegEx(".*Smile, we're up and running.*").withStartupTimeout(Duration.ofMinutes(10)); 092 waitingFor(logMessageWaitStrategy); 093 094 // Also output logs to stdout to ensure they appear in test output 095 withLogConsumer(SmileCdrContainer::accept); 096 } 097 098 099 @Override 100 public void start() { 101 String logbackXmlFile; 102 103 if (!StringUtils.isBlank(myCustomLogbackFile)) { 104 ourLog.info("Custom properties file provided, using it. [file={}]", myCustomPropertiesFileLocation); 105 logbackXmlFile = myCustomPropertiesFileLocation; 106 MountableFile mountableFile = MountableFile.forClasspathResource(logbackXmlFile); 107 withCopyFileToContainer(mountableFile, "/home/smile/smilecdr/customerlib/all-troubleshooters-enabled-logback.xml"); 108 } else { 109 ourLog.error("No custom logback file provided, using default logback file. [file={}]", "/all-troubleshooters-enabled-logback.xml"); 110 } 111 112 // Copy JAR files from cdr-interceptor-starterproject/target to the container 113 copyCustomerJars(); 114 115 String propertiesFileLocation = resolvePropertiesFile(); 116 exposePortsFromPropertiesFile(propertiesFileLocation); 117 super.start(); 118 } 119 120 private String resolvePropertiesFile() { 121 String propertiesFile; 122 if (StringUtils.isBlank(myCustomPropertiesFileLocation)) { 123 ourLog.info("No custom properties file provided, using default properties file. [file={}]", "/cdr-config-fallback.properties"); 124 propertiesFile = "/cdr-config-fallback.properties"; 125 } else { 126 ourLog.info("Custom properties file provided, using it. [file={}]", myCustomPropertiesFileLocation); 127 propertiesFile = myCustomPropertiesFileLocation; 128 } 129 return propertiesFile; 130 } 131 132 public SmileCdrContainer withBoundProjectClasspath() { 133 String root = Paths.get(".").toAbsolutePath().normalize().toString(); 134 MountableFile mountableFile = MountableFile.forHostPath(root + "/target/classes"); 135 withCopyFileToContainer(mountableFile, "/home/smile/smilecdr/classes"); 136 return this; 137 } 138 139 public SmileCdrContainer withPreseedFiles(String... filesToMount) { 140 for (String fileToMount : filesToMount) { 141 MountableFile mountableFile = MountableFile.forClasspathResource(fileToMount); 142 withCopyFileToContainer(mountableFile, "/home/smile/smilecdr/classes/config_seeding/"); 143 } 144 return this; 145 } 146 147 public SmileCdrContainer withCustomHarnessContext(HarnessContext theHarnessContext) { 148 myHarnessContext = theHarnessContext; 149 return this; 150 } 151 152 public SmileCdrContainer withPropertiesFile(String thePropertiesFileLocation) { 153 myCustomPropertiesFileLocation = thePropertiesFileLocation; 154 return this; 155 } 156 157 public SmileCdrContainer withCustomLogback(String theCustomLogbackLocation) { 158 if (!StringUtils.isBlank(myCustomLogbackFile)) { 159 throw new IllegalStateException("Cannot set a custom logback file when a custom properties file is provided!"); 160 } 161 myCustomLogbackFile = theCustomLogbackLocation; 162 return this; 163 } 164 165 public SmileCdrContainer withAllTroubleshootingLogsEnabled() { 166 myCustomLogbackFile = "/all-troubleshooters-enabled-logback.xml"; 167 return this; 168 } 169 170 /** 171 * Specifies which JAR files from the cdr-interceptor-starterproject's target folder should be copied 172 * into the SmileCdrContainer at /home/smile/smilecdr/customerlib/. 173 * <p> 174 * First looks in the classpath for a jar with a given name. If it does not find one, 175 * it also looks in the target/ directory. If it still doesn't find one, then it fails. 176 * 177 * @param theJarNames A comma-separated list of JAR filenames to copy. If null or empty, all JAR files 178 * in the target directory will be copied. 179 * @return This container instance for method chaining 180 */ 181 public SmileCdrContainer withCustomerJars(String... theJarNames) { 182 if (theJarNames != null && theJarNames.length > 0) { 183 myCustomerJarNames = Arrays.stream(theJarNames) 184 .map(String::trim) 185 .filter(s -> !s.isEmpty()) 186 .collect(Collectors.toList()); 187 188 ourLog.info("Registered {} JAR file(s) to be mounted into customerlib", myCustomerJarNames.size()); 189 } 190 return this; 191 } 192 193 private void exposePortsFromPropertiesFile(String thePropertiesFileLocation) { 194 MountableFile mountableFile = MountableFile.forClasspathResource(thePropertiesFileLocation); 195 withCopyFileToContainer(mountableFile, "/home/smile/smilecdr/classes/cdr-config-Master.properties"); 196 // Read the properties file and extract port values 197 try (FileInputStream fis = new FileInputStream(mountableFile.getResolvedPath())) { 198 Properties properties = new Properties(); 199 properties.load(fis); 200 201 // Extract port values from keys containing ".port" 202 for (String key : properties.stringPropertyNames()) { 203 if (key.contains(".port")) { 204 String value = properties.getProperty(key); 205 try { 206 int portValue = Integer.parseInt(value.trim()); 207 208 // Extract Module ID and Module Type 209 if (key.startsWith("module.")) { 210 String[] parts = key.split("\\."); 211 if (parts.length >= 3) { 212 String moduleId = parts[1]; 213 214 // Get the module type from the properties 215 String moduleTypeKey = "module." + moduleId + ".type"; 216 String moduleType = properties.getProperty(moduleTypeKey); 217 218 if (moduleType != null) { 219 // Add to the map: moduleType -> (moduleId -> port) 220 myPortValues.computeIfAbsent(moduleType, k -> new HashMap<>()) 221 .put(moduleId, portValue); 222 } 223 } 224 } 225 226 // Add the port to exposed ports 227 addExposedPort(portValue); 228 } catch (NumberFormatException e) { 229 // Skip if the port value is not a valid integer 230 } 231 } 232 } 233 } catch (IOException e) { 234 throw new RuntimeException("Failed to read properties file: " + thePropertiesFileLocation, e); 235 } 236 } 237 238 public SmileHarness getHarness() { 239 if (myHarnessContext == null) { 240 myHarnessContext = new HarnessContext("http", this.getHost(), null, "admin", "password"); 241 } 242 return new DockerSmileHarness(myHarnessContext, getPortValuesByModuleType(), getPortMapping()); 243 } 244 245 public Map<Integer, Integer> getPortMapping() { 246 return getExposedPorts().stream().collect(Collectors.toMap(port -> port, this::getMappedPort)); 247 } 248 249 /** 250 * Gets the port values organized by Module Type and Module ID. 251 * 252 * @return A map where the key is the Module Type and the value is a map of Module ID to port 253 */ 254 public Map<String, Map<String, Integer>> getPortValuesByModuleType() { 255 return new HashMap<>(myPortValues); 256 } 257 258 private static void accept(OutputFrame outputFrame) { 259 System.out.print(outputFrame.getUtf8String()); 260 } 261 262 /** 263 * Copies JAR files from the cdr-interceptor-starterproject's target directory to the container. 264 * If myCustomerJarNames is not null, only the specified JAR files will be copied. 265 * Otherwise, all JAR files in the target directory will be copied. 266 * <p> 267 * First attempts to load JAR files from the classpath using MountableFile.forClasspathResource(). 268 * If that fails (because the JAR file is not on the classpath), falls back to using MountableFile.forHostPath(). 269 * If a JAR is not found in either location, throws a RuntimeException. 270 */ 271 private void copyCustomerJars() { 272 // If specific JAR names are provided, try to load them from the classpath first 273 if (myCustomerJarNames != null && !myCustomerJarNames.isEmpty()) { 274 List<String> jarsFoundOnClasspath = new ArrayList<>(); 275 List<String> jarsNotFoundOnClasspath = new ArrayList<>(); 276 277 for (String jarName : myCustomerJarNames) { 278 try { 279 ourLog.info("Attempting to load JAR file from classpath: {}", jarName); 280 MountableFile mountableFile = MountableFile.forClasspathResource(jarName); 281 withCopyFileToContainer(mountableFile, "/home/smile/smilecdr/customerlib/" + jarName); 282 jarsFoundOnClasspath.add(jarName); 283 } catch (IllegalArgumentException e) { 284 ourLog.info("JAR file not found on classpath: {}. Will try local filesystem.", jarName); 285 jarsNotFoundOnClasspath.add(jarName); 286 } 287 } 288 289 // If all JARs were found on the classpath, we're done 290 if (jarsNotFoundOnClasspath.isEmpty()) { 291 ourLog.info("All specified JAR files were loaded from the classpath."); 292 return; 293 } 294 295 // Fall back to loading from the local filesystem for JARs not found on classpath 296 try { 297 // Find the cdr-interceptor-starterproject directory 298 Path projectRoot = Paths.get("."); 299 Path targetDir = projectRoot.resolve("target"); 300 301 if (!Files.exists(targetDir)) { 302 throw new RuntimeException("Could not find target directory. The following JAR files were not found on the classpath: " + jarsNotFoundOnClasspath); 303 } 304 305 // Get all JAR files in the target directory 306 List<File> jarFiles; 307 try (var paths = Files.list(targetDir)) { 308 jarFiles = paths 309 .filter(path -> path.toString().endsWith(".jar")) 310 .map(Path::toFile) 311 .toList(); 312 } 313 314 if (jarFiles.isEmpty()) { 315 throw new IllegalArgumentException("No JAR files found in target directory. The following JAR files were not found on the classpath: " + jarsNotFoundOnClasspath); 316 } 317 318 // Filter JAR files based on jarsNotFoundOnClasspath 319 List<File> filesToCopy = jarFiles.stream() 320 .filter(file -> jarsNotFoundOnClasspath.contains(file.getName())) 321 .toList(); 322 323 // Check if all JARs not found on classpath were found in target directory 324 if (filesToCopy.size() < jarsNotFoundOnClasspath.size()) { 325 List<String> jarsNotFound = new ArrayList<>(jarsNotFoundOnClasspath); 326 jarsNotFound.removeAll(filesToCopy.stream().map(File::getName).toList()); 327 throw new IllegalArgumentException("The following JAR files were not found in either the classpath or the target directory: " + jarsNotFound); 328 } 329 330 for (File jarFile : filesToCopy) { 331 ourLog.info("Copying JAR file from local filesystem to container: {}", jarFile.getName()); 332 MountableFile mountableFile = MountableFile.forHostPath(jarFile.toPath()); 333 withCopyFileToContainer(mountableFile, "/home/smile/smilecdr/customerlib/" + jarFile.getName()); 334 } 335 336 ourLog.info("Copied {} JAR file(s) to container from local filesystem", filesToCopy.size()); 337 } catch (IOException e) { 338 throw new RuntimeException("Error copying JAR files to container from local filesystem", e); 339 } 340 } 341 } 342}