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}