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}