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}