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}