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