001/*-
002 * #%L
003 * Smile CDR - CDR
004 * %%
005 * Copyright (C) 2016 - 2026 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.api.SmileHarness;
014import ca.cdr.test.app.harness.impl.LocalhostSmileHarness;
015import ca.cdr.test.util.PropertiesTestUtil;
016import ca.cdr.test.util.UrlPathUtil;
017import jakarta.annotation.Nonnull;
018import org.apache.commons.lang3.StringUtils;
019import org.slf4j.Logger;
020import org.slf4j.LoggerFactory;
021import org.testcontainers.containers.GenericContainer;
022import org.testcontainers.containers.output.OutputFrame;
023import org.testcontainers.containers.output.WaitingConsumer;
024import org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy;
025import org.testcontainers.utility.DockerImageName;
026import org.testcontainers.utility.MountableFile;
027
028import java.io.File;
029import java.io.FileInputStream;
030import java.io.IOException;
031import java.nio.file.Files;
032import java.nio.file.Path;
033import java.nio.file.Paths;
034import java.time.Duration;
035import java.util.ArrayList;
036import java.util.Arrays;
037import java.util.HashMap;
038import java.util.List;
039import java.util.Map;
040import java.util.Optional;
041import java.util.Properties;
042import java.util.stream.Collectors;
043
044/**
045 * SmileCdrContainer is a container abstraction for orchestrating Smile CDR instances
046 * within Docker environments.
047 * <p>
048 * Instances of this container allow for flexible customization and configuration of
049 * Smile CDR images, including properties file specifications, logback configurations,
050 * and support for customer JAR files. It supports smooth integration and automated
051 * provisioning of Smile CDR services for testing and development environments.
052 * <p>
053 * The design facilitates:
054 * - Configuration of Smile CDR version and the corresponding Docker image.
055 * - Mounting of custom resources such as properties files, logback configurations, or JARs.
056 * - Automatic setup of exposed ports based on specified or default configurations.
057 * - Log consumption to aid in debugging and diagnostics during container runtime.
058 * - Ensuring modular support with organized port mapping and harness configuration.
059 * <p>
060 * This class was generated partly with the help of Claude Sonnet 3.7
061 */
062public class SmileCdrContainer extends GenericContainer<SmileCdrContainer> {
063
064        private static final Logger ourLog = LoggerFactory.getLogger(SmileCdrContainer.class);
065        private static final String DEFAULT_REGISTRY = "docker.smilecdr.com";
066        private static final String DEFAULT_IMAGE = "smilecdr";
067        private static final String DEFAULT_TAG = "latest";
068        private static final String HOST_NETWORK_MODE = "host";
069        private static final String CDR_HOME = "/home/smile/smilecdr";
070        private static final String DEFAULT_ADMIN_JSON_MODULE_ID = "admin_json";
071        private static final String CLASSPATH_PREFIX = "classpath:";
072        private static final String FILE_PREFIX = "file:";
073        private static final DockerImageName DEFAULT_DOCKER_IMAGE_NAME = DockerImageName.parse(DEFAULT_REGISTRY + "/" + DEFAULT_IMAGE + ":" + DEFAULT_TAG);
074
075        private final Map<String, Map<String, Integer>> myPortValues = new HashMap<>();
076        private String myResolvedPropertiesFileLocation;
077        private HarnessContext myHarnessContext;
078        private LocalhostSmileHarness myHarness;
079        private String myCustomPropertiesFileLocation;
080        private Path myCustomPropertiesFilePath;
081        private String myCustomLogbackFile;
082        private Path myCustomLogbackFilePath;
083        private List<String> myCustomerJarNames;
084        private boolean myDebugEnabled = false;
085        private boolean myDebugSuspendUntilDebuggerAttached = true;
086        private static final int DEBUG_PORT = 5005;
087
088        public SmileCdrContainer() {
089                this(DEFAULT_DOCKER_IMAGE_NAME);
090        }
091
092        /**
093         * Use this constructor to change the version of Smile CDR, but use Smile's container registry.
094         */
095        public SmileCdrContainer(String theSmileCdrVersion) {
096                this(new DockerImageName(DEFAULT_REGISTRY + "/" + DEFAULT_IMAGE + ":" + theSmileCdrVersion));
097
098        }
099
100        /**
101         * Use this constructor to provide a completely custom Docker Image Name to use. Use this if you need to use your own private registry
102         */
103        public SmileCdrContainer(DockerImageName imageName) {
104                super(imageName);
105                // Wait for startup completion (success or failure)
106                LogMessageWaitStrategy logMessageWaitStrategy = new LogMessageWaitStrategy();
107                logMessageWaitStrategy.withRegEx(".*(Smile, we're up and running|Smile started with errors).*").withStartupTimeout(Duration.ofMinutes(3));
108                waitingFor(logMessageWaitStrategy);
109
110                // Also output logs to stdout to ensure they appear in test output
111                withLogConsumer(new WaitingConsumer() {
112                        @Override
113                        public void accept(OutputFrame theFrame) {
114                                System.out.printf("Container[%s]: %s", getContainerName(), theFrame.getUtf8String());
115                        }
116                });
117        }
118
119
120        @Override
121        public void start() {
122                copyCustomLogbackConfiguration();
123
124                // Copy JAR files from cdr-interceptor-starterproject/target to the container
125                copyCustomerJars();
126                copyPropertiesFileAndResolveReferences();
127
128                if (myDebugEnabled) {
129                        ourLog.info("Debug mode enabled - container is accessible for debuggers on port {}", DEBUG_PORT);
130                        if (!isHostNetwork()) {
131                                addFixedExposedPort(DEBUG_PORT, DEBUG_PORT);
132                        }
133                        String jvmArgs = buildJvmArgs();
134                        ourLog.info("JVMARGS is now set to: {}", jvmArgs);
135                        withEnv("JVMARGS", jvmArgs);
136                        if (myDebugSuspendUntilDebuggerAttached) {
137                                ourLog.warn("==========================================");
138                                ourLog.warn("DEBUG MODE(suspend): Container will wait for debugger");
139                                ourLog.warn("Connect your IDE debugger to: localhost:{}", DEBUG_PORT);
140                                ourLog.warn("Container will resume after debugger attaches");
141                                ourLog.warn("==========================================");
142                        }
143                }
144                super.start();
145        }
146
147        private void copyCustomLogbackConfiguration() {
148                MountableFile mountableFile;
149                if (myCustomLogbackFilePath != null) {
150                        ourLog.info("Custom logback file provided, using it. [file={}]", myCustomLogbackFilePath);
151                        mountableFile = MountableFile.forHostPath(myCustomLogbackFilePath);
152                } else if (!StringUtils.isBlank(myCustomLogbackFile)) {
153                        ourLog.info("Custom logback file provided, using it. [file={}]", myCustomLogbackFile);
154                        mountableFile = MountableFile.forClasspathResource(myCustomLogbackFile);
155                } else {
156                        ourLog.info("No custom logback file provided, using default logback file. [file={}]", "/all-troubleshooters-enabled-logback.xml");
157                        mountableFile = MountableFile.forClasspathResource("/all-troubleshooters-enabled-logback.xml");
158                }
159                withCopyFileToContainer(mountableFile, CDR_HOME + "/customerlib/logback-smile-custom.xml");
160        }
161
162        private void copyPropertiesFileAndResolveReferences() {
163                MountableFile mountableFile = resolvePropertiesFile();
164                withCopyFileToContainer(mountableFile, CDR_HOME + "/classes/cdr-config-Master.properties");
165
166                myResolvedPropertiesFileLocation = mountableFile.getResolvedPath();
167                exposePortsFromPropertiesFile(mountableFile.getResolvedPath());
168                bootstrapContainerFilesFromProperties(mountableFile.getResolvedPath());
169        }
170
171        private String buildJvmArgs() {
172                return new StringBuilder()
173                        .append("-agentlib:jdwp=transport=dt_socket,server=y,suspend=")
174                        .append(myDebugSuspendUntilDebuggerAttached ? "y": "n")
175                        .append(",address=*:")
176                        .append(DEBUG_PORT)
177                        .toString();
178        }
179
180        private void bootstrapContainerFilesFromProperties(String thePropertiesFileLocation) {
181                try {
182                        // Read the properties file
183                        Properties properties = new Properties();
184                        try (FileInputStream fis = new FileInputStream(thePropertiesFileLocation)) {
185                                properties.load(fis);
186                        }
187
188                        // Look for classpath and file references in property values
189                        for (String key : properties.stringPropertyNames()) {
190                                String value = properties.getProperty(key);
191                                if (value != null && (value.startsWith(CLASSPATH_PREFIX) || value.startsWith(FILE_PREFIX))) {
192                                        copyReferencedFileToContainer(value);
193                                }
194                        }
195                } catch (IOException e) {
196                        throw new IllegalArgumentException("Failed to process properties file for bootstrap files: " + thePropertiesFileLocation, e);
197                }
198        }
199
200        private void copyReferencedFileToContainer(String theReference) {
201                String resourcePath;
202                if (theReference.startsWith(CLASSPATH_PREFIX)) {
203                        resourcePath = StringUtils.stripStart(theReference.substring(CLASSPATH_PREFIX.length()), "/");
204                } else if (theReference.startsWith(FILE_PREFIX)) {
205                        String filePath = theReference.substring(FILE_PREFIX.length());
206                        if (copyHostFileToContainer(filePath)) {
207                                return;
208                        }
209                        resourcePath = StringUtils.stripStart(filePath, "/");
210                } else {
211                        return;
212                }
213
214                String containerPath = containerPathForReference(theReference);
215                try {
216                        MountableFile mountableFile = MountableFile.forClasspathResource(resourcePath);
217                        withCopyFileToContainer(mountableFile, containerPath);
218                        ourLog.info("Copied classpath resource to container: {} -> {}", resourcePath, containerPath);
219                } catch (IllegalArgumentException e) {
220                        // Many references name resources that ship inside the Smile CDR image itself.
221                        ourLog.info("Referenced file is not on the test classpath, leaving it to the image: {}", theReference);
222                }
223        }
224
225        /**
226         * Copies a {@code file:} reference from the host, to the path Smile CDR will read it from:
227         * the same absolute path, or a relative path resolved against the Smile CDR home directory.
228         * Relative paths are looked up on the host relative to the working directory.
229         *
230         * @return true if the file was found on the host
231         */
232        private boolean copyHostFileToContainer(String theFilePath) {
233                Path hostPath = Paths.get(theFilePath);
234                if (!Files.exists(hostPath)) {
235                        return false;
236                }
237                String containerPath = fileReferenceContainerPath(theFilePath);
238                withCopyFileToContainer(MountableFile.forHostPath(hostPath), containerPath);
239                ourLog.info("Copied host file to container: {} -> {}", hostPath, containerPath);
240                return true;
241        }
242
243        /**
244         * Where Smile CDR reads a {@code classpath:} or {@code file:} reference from. A {@code file:}
245         * reference lands there whether it was found on the host or, failing that, on the classpath.
246         *
247         * @throws IllegalArgumentException for a value that is neither kind of reference
248         */
249        static String containerPathForReference(String theReference) {
250                if (theReference.startsWith(CLASSPATH_PREFIX)) {
251                        return CDR_HOME + "/classes/"
252                                + StringUtils.stripStart(theReference.substring(CLASSPATH_PREFIX.length()), "/");
253                }
254                if (theReference.startsWith(FILE_PREFIX)) {
255                        return fileReferenceContainerPath(theReference.substring(FILE_PREFIX.length()));
256                }
257                throw new IllegalArgumentException("Not a classpath: or file: reference: " + theReference);
258        }
259
260        /**
261         * Where Smile CDR reads a {@code file:} reference from: the same absolute path, or a relative path
262         * resolved against the Smile CDR home directory.
263         */
264        private static String fileReferenceContainerPath(String theFilePath) {
265                return Paths.get(theFilePath).isAbsolute() ? theFilePath : CDR_HOME + "/" + theFilePath;
266        }
267
268
269        private MountableFile resolvePropertiesFile() {
270                if (myCustomPropertiesFilePath != null) {
271                        ourLog.info("Custom properties file provided, using it. [file={}]", myCustomPropertiesFilePath);
272                        return MountableFile.forHostPath(myCustomPropertiesFilePath);
273                }
274                if (StringUtils.isBlank(myCustomPropertiesFileLocation)) {
275                        ourLog.info("No custom properties file provided, using default properties file. [file={}]", "/cdr-config-fallback.properties");
276                        return MountableFile.forClasspathResource("/cdr-config-fallback.properties");
277                }
278                ourLog.info("Custom properties file provided, using it. [file={}]", myCustomPropertiesFileLocation);
279                return MountableFile.forClasspathResource(myCustomPropertiesFileLocation);
280        }
281
282        /**
283         * Binds the target/classes and target/test-classes directories into the container's `/classes` directory.
284         * Only mounts directories if they exist and are non-empty.
285         * @return the SmileCdrContainer.
286         */
287        public SmileCdrContainer withBoundProjectClasspath() {
288                String root = Paths.get(".").toAbsolutePath().normalize().toString();
289
290                Path classesPath = Paths.get(root, "target", "classes");
291                if (Files.exists(classesPath) && isDirectoryNonEmpty(classesPath)) {
292                        MountableFile mountableFile = MountableFile.forHostPath(classesPath.toString());
293                        withCopyFileToContainer(mountableFile, "/home/smile/smilecdr/classes");
294                        ourLog.info("Mounted target/classes directory to container");
295                } else {
296                        ourLog.info("Skipping target/classes mount - directory does not exist or is empty");
297                }
298
299                Path testClassesPath = Paths.get(root, "target", "test-classes");
300                if (Files.exists(testClassesPath) && isDirectoryNonEmpty(testClassesPath)) {
301                        MountableFile testMountableFile = MountableFile.forHostPath(testClassesPath.toString());
302                        withCopyFileToContainer(testMountableFile, "/home/smile/smilecdr/classes");
303                        ourLog.info("Mounted target/test-classes directory to container");
304                } else {
305                        ourLog.info("Skipping target/test-classes mount - directory does not exist or is empty");
306                }
307
308                return this;
309        }
310
311        /**
312         * Checks if a directory is non-empty by attempting to read at least one entry.
313         * @param thePath the directory path to check
314         * @return true if the directory contains at least one file or subdirectory, false otherwise
315         */
316        private boolean isDirectoryNonEmpty(Path thePath) {
317                try (var stream = Files.list(thePath)) {
318                        return stream.findFirst().isPresent();
319                } catch (IOException e) {
320                        ourLog.warn("Error checking if directory is non-empty: {}", thePath, e);
321                        return false;
322                }
323        }
324
325        public SmileCdrContainer withPreseedFiles(String... filesToMount) {
326                for (String fileToMount : filesToMount) {
327                        MountableFile mountableFile = MountableFile.forClasspathResource(fileToMount);
328                        withCopyFileToContainer(mountableFile, "/home/smile/smilecdr/classes/config_seeding/");
329                }
330                return this;
331        }
332
333        /**
334         * Sets the {@link HarnessContext} the harness is built from. Discards any harness already handed
335         * out, so that a context set after the first {@link #getHarness()} still takes effect rather
336         * than being silently ignored.
337         */
338        public synchronized @Nonnull SmileCdrContainer withCustomHarnessContext(@Nonnull HarnessContext theHarnessContext) {
339                closeHarness();
340                myHarnessContext = theHarnessContext;
341                return this;
342        }
343
344        /**
345         * Uses a properties file from the classpath as the container's {@code cdr-config-Master.properties}.
346         */
347        public SmileCdrContainer withPropertiesFile(String thePropertiesFileLocation) {
348                myCustomPropertiesFileLocation = thePropertiesFileLocation;
349                myCustomPropertiesFilePath = null;
350                return this;
351        }
352
353        /**
354         * Uses a properties file from the host filesystem as the container's
355         * {@code cdr-config-Master.properties}, for configuration that is generated or selected at runtime
356         * rather than packaged on the test classpath.
357         */
358        public SmileCdrContainer withPropertiesFile(@Nonnull Path thePropertiesFile) {
359                myCustomPropertiesFilePath = thePropertiesFile;
360                myCustomPropertiesFileLocation = null;
361                return this;
362        }
363
364        public SmileCdrContainer withCustomLogback(String theCustomLogbackLocation) {
365                requireNoCustomLogback();
366                myCustomLogbackFile = theCustomLogbackLocation;
367                return this;
368        }
369
370        /**
371         * Uses a logback configuration from the host filesystem as the container's
372         * {@code customerlib/logback-smile-custom.xml}.
373         */
374        public SmileCdrContainer withCustomLogback(@Nonnull Path theCustomLogbackFile) {
375                requireNoCustomLogback();
376                myCustomLogbackFilePath = theCustomLogbackFile;
377                return this;
378        }
379
380        private void requireNoCustomLogback() {
381                if (!StringUtils.isBlank(myCustomLogbackFile) || myCustomLogbackFilePath != null) {
382                        throw new IllegalStateException("A custom logback file has already been set");
383                }
384        }
385
386        public SmileCdrContainer withAllTroubleshootingLogsEnabled() {
387                requireNoCustomLogback();
388                myCustomLogbackFile = "/all-troubleshooters-enabled-logback.xml";
389                return this;
390        }
391
392        /**
393         * Specifies which JAR files from the cdr-interceptor-starterproject's target folder should be copied
394         * into the SmileCdrContainer at /home/smile/smilecdr/customerlib/.
395         * <p>
396         * First looks in the classpath for a jar with a given name. If it does not find one,
397         * it also looks in the target/ directory. If it still doesn't find one, then it fails.
398         *
399         * @param theJarNames A comma-separated list of JAR filenames to copy. If null or empty, all JAR files
400         *                    in the target directory will be copied.
401         * @return This container instance for method chaining
402         */
403        public SmileCdrContainer withCustomerJars(String... theJarNames) {
404                if (theJarNames != null && theJarNames.length > 0) {
405                        myCustomerJarNames = Arrays.stream(theJarNames)
406                                .map(String::trim)
407                                .filter(s -> !s.isEmpty())
408                                .collect(Collectors.toList());
409
410                        ourLog.info("Registered {} JAR file(s) to be mounted into customerlib", myCustomerJarNames.size());
411                }
412                return this;
413        }
414
415        /**
416         * Enables remote debugging for the Smile CDR instance running in this container.
417         * When enabled, the container will expose port 5005 (fixed mapping) and wait for a debugger to attach
418         * before completing startup (suspend=y).
419         *
420         * <h3>How to Connect IntelliJ IDEA Debugger:</h3>
421         * <ol>
422         *   <li>Run → Edit Configurations → Add New → Remote JVM Debug</li>
423         *   <li>Set Host: localhost (or container host)</li>
424         *   <li>Set Port: 5005 (always fixed to this port)</li>
425         *   <li>Set Debugger mode: Attach to remote JVM</li>
426         *   <li>Click Debug - the container will resume startup once debugger connects</li>
427         * </ol>
428         *
429         * <h3>Example Usage:</h3>
430         * <pre>
431         * {@code
432         * @Container
433         * SmileCdrContainer container = new SmileCdrContainer()
434         *     .withPropertiesFile("/my-config.properties")
435         *     .withDebugEnabled();
436         *
437         * // Port 5005 is always used - no need to query for mapped port
438         * // Just connect your debugger to localhost:5005
439         * }
440         * </pre>
441         *
442         * <p><strong>IMPORTANT:</strong> The container will pause during startup until a debugger
443         * connects. This is intentional to allow setting breakpoints before code execution begins.
444         * Port 5005 must be available on the host machine.</p>
445         *
446         * @return This container instance for method chaining
447         */
448        public SmileCdrContainer withDebugEnabled() {
449                return withDebugEnabled(true);
450        }
451
452        /**
453         * Enables remote debugging for the Smile CDR instance running in this container.
454         * When enabled, the container will expose port 5005 (fixed mapping).
455         *
456         * - If `theSuspendUntilDebuggerAttached` is true, the container will pause until a debugger is connected.
457         * - If `theSuspendUntilDebuggerAttached` is false, the container will boot without waiting for the debugger.
458         *
459         *
460         * <h3>How to Connect IntelliJ IDEA Debugger:</h3>
461         * <ol>
462         *   <li>Run → Edit Configurations → Add New → Remote JVM Debug</li>
463         *   <li>Set Host: localhost (or container host)</li>
464         *   <li>Set Port: 5005 (always fixed to this port)</li>
465         *   <li>Set Debugger mode: Attach to remote JVM</li>
466         *   <li>Click Debug - the container will resume startup once debugger connects</li>
467         * </ol>
468         *
469         * <h3>Example Usage:</h3>
470         * <pre>
471         * {@code
472         * @Container
473         * SmileCdrContainer container = new SmileCdrContainer()
474         *     .withPropertiesFile("/my-config.properties")
475         *     .withDebugEnabled(false);
476         *
477         * // Port 5005 is always used - no need to query for mapped port
478         * // Just connect your debugger to localhost:5005
479         * }
480         * </pre>
481         *
482         * <p><strong>IMPORTANT:</strong> If you have set `theSuspendUntilDebuggerAttached` to True,
483         * the container will pause during startup until a debugger
484         * connects. This is intentional to allow setting breakpoints before code execution begins.
485         * Port 5005 must be available on the host machine.</p>
486         *
487         * @param theSuspendUntilDebuggerAttached if set to true, blocks the container startup until a debugger connects.
488         *
489         * @return This container instance for method chaining
490         */
491        public SmileCdrContainer withDebugEnabled(boolean theSuspendUntilDebuggerAttached) {
492                myDebugEnabled = true;
493                myDebugSuspendUntilDebuggerAttached = theSuspendUntilDebuggerAttached;
494                return this;
495        }
496
497        private void exposePortsFromPropertiesFile(String thePropertiesFileLocation) {
498                Map<String, Map<String, Integer>> portsByModuleType = PropertiesTestUtil.loadPortsFromPropertiesFile(thePropertiesFileLocation);
499
500                // On the host network the modules bind host ports directly; Docker maps nothing, so
501                // exposing ports would leave Testcontainers waiting for mappings that never appear.
502                if (!isHostNetwork()) {
503                        for (Map<String, Integer> modulePortMap : portsByModuleType.values()) {
504                                for (Integer portValue : modulePortMap.values()) {
505                                        addExposedPort(portValue);
506                                }
507                        }
508                }
509
510                // Merge the loaded ports into myPortValues
511                for (Map.Entry<String, Map<String, Integer>> entry : portsByModuleType.entrySet()) {
512                        String moduleType = entry.getKey();
513                        Map<String, Integer> modulePorts = entry.getValue();
514                        myPortValues.computeIfAbsent(moduleType, k -> new HashMap<>()).putAll(modulePorts);
515                }
516        }
517
518        /**
519         * Returns this container's harness, creating it on first call.
520         * <p>
521         * The harness is cached because it owns an HTTP connection pool and a cookie store: handing
522         * out a fresh one per call would mean each caller got its own session, which any multi-request
523         * login flow depends on sharing. The cached harness is closed and discarded when the container
524         * stops, so a restarted container gets a harness bound to its new port mapping.
525         * <p>
526         * A harness reads the node's modules and their ports once, when it is built. A test that adds,
527         * removes or re-ports a module through the Admin JSON API must therefore call
528         * {@link #refreshHarness()}; calling {@code getHarness()} again returns the same harness, still
529         * holding the configuration from before the change.
530         * <p>
531         * A harness a caller closed itself is replaced rather than handed back: {@link SmileHarness} is
532         * {@link AutoCloseable}, so a try-with-resources around this call would otherwise shut the
533         * connection pool and leave every later call returning a harness whose every request fails.
534         */
535        public synchronized @Nonnull SmileHarness getHarness() {
536                if (myHarness != null && myHarness.isClosed()) {
537                        myHarness = null;
538                }
539                if (myHarness == null) {
540                        if (myHarnessContext == null) {
541                                // Find the admin JSON port from the discovered ports
542                                Optional<Map.Entry<String, Integer>> adminJsonModule = adminJsonModule(myPortValues.get("ADMIN_JSON"));
543                                Integer adminJsonPort = null;
544                                String adminJsonContextPath = "";
545                                if (adminJsonModule.isPresent()) {
546                                        Map.Entry<String, Integer> adminJson = adminJsonModule.get();
547                                        adminJsonPort = adminJson.getValue();
548                                        if (myResolvedPropertiesFileLocation != null) {
549                                                adminJsonContextPath = UrlPathUtil.normalizeContextPath(
550                                                        PropertiesTestUtil.loadContextPath(myResolvedPropertiesFileLocation, adminJson.getKey()));
551                                        }
552                                }
553                                myHarnessContext = new HarnessContext(
554                                        "http", this.getHost(), adminJsonPort, "admin", "password", adminJsonContextPath);
555                        }
556                        myHarness = new LocalhostSmileHarness(myHarnessContext, getPortMapping());
557                }
558                return myHarness;
559        }
560
561        /**
562         * The Admin JSON module the harness is built against when the properties file configures several:
563         * {@code admin_json} if present, otherwise the lowest module ID. A properties file has no order
564         * of its own to fall back on.
565         */
566        static Optional<Map.Entry<String, Integer>> adminJsonModule(Map<String, Integer> theAdminJsonPorts) {
567                if (theAdminJsonPorts == null || theAdminJsonPorts.isEmpty()) {
568                        return Optional.empty();
569                }
570                if (theAdminJsonPorts.containsKey(DEFAULT_ADMIN_JSON_MODULE_ID)) {
571                        return Optional.of(Map.entry(DEFAULT_ADMIN_JSON_MODULE_ID, theAdminJsonPorts.get(DEFAULT_ADMIN_JSON_MODULE_ID)));
572                }
573                return theAdminJsonPorts.entrySet().stream().min(Map.Entry.comparingByKey()).map(e -> Map.entry(e.getKey(), e.getValue()));
574        }
575
576        /**
577         * Discards the cached harness and builds a replacement, which rediscovers the node's modules and
578         * ports. Call this after changing module configuration through the Admin JSON API.
579         * <p>
580         * Any session held by the old harness ends with it, and any reference still pointing at it is
581         * dead — use the returned harness rather than the one you were holding.
582         *
583         * @return the replacement harness, already bound to the node's current configuration
584         */
585        public synchronized @Nonnull SmileHarness refreshHarness() {
586                closeHarness();
587                return getHarness();
588        }
589
590        @Override
591        public void stop() {
592                closeHarness();
593                super.stop();
594        }
595
596        private synchronized void closeHarness() {
597                if (myHarness == null) {
598                        return;
599                }
600                try {
601                        myHarness.close();
602                } catch (RuntimeException e) {
603                        // Never let a teardown failure mask whatever the caller was actually doing.
604                        ourLog.warn("Failed to close the harness", e);
605                } finally {
606                        myHarness = null;
607                }
608        }
609
610        /**
611         * Maps each port a module listens on inside the container to the port it is reachable on from the
612         * host. On the host network ({@code withNetworkMode("host")}) every port maps to itself.
613         */
614        public Map<Integer, Integer> getPortMapping() {
615                if (isHostNetwork()) {
616                        return myPortValues.values().stream()
617                                .flatMap(modulePorts -> modulePorts.values().stream())
618                                .distinct()
619                                .collect(Collectors.toMap(port -> port, port -> port));
620                }
621                return getExposedPorts().stream().collect(Collectors.toMap(port -> port, this::getMappedPort));
622        }
623
624        private boolean isHostNetwork() {
625                return HOST_NETWORK_MODE.equals(getNetworkMode());
626        }
627
628        /**
629         * Gets the port values organized by Module Type and Module ID.
630         *
631         * @return A map where the key is the Module Type and the value is a map of Module ID to port
632         */
633        public Map<String, Map<String, Integer>> getPortValuesByModuleType() {
634                return new HashMap<>(myPortValues);
635        }
636
637        /**
638         * Copies JAR files from the cdr-interceptor-starterproject's target directory to the container.
639         * If myCustomerJarNames is not null, only the specified JAR files will be copied.
640         * Otherwise, all JAR files in the target directory will be copied.
641         * <p>
642         * First attempts to load JAR files from the classpath using MountableFile.forClasspathResource().
643         * If that fails (because the JAR file is not on the classpath), falls back to using MountableFile.forHostPath().
644         * If a JAR is not found in either location, throws a RuntimeException.
645         */
646        private void copyCustomerJars() {
647                // If specific JAR names are provided, try to load them from the classpath first
648                if (myCustomerJarNames != null && !myCustomerJarNames.isEmpty()) {
649                        List<String> jarsFoundOnClasspath = new ArrayList<>();
650                        List<String> jarsNotFoundOnClasspath = new ArrayList<>();
651
652                        for (String jarName : myCustomerJarNames) {
653                                try {
654                                        ourLog.info("Attempting to load JAR file from classpath: {}", jarName);
655                                        MountableFile mountableFile = MountableFile.forClasspathResource(jarName);
656                                        withCopyFileToContainer(mountableFile, "/home/smile/smilecdr/customerlib/" + jarName);
657                                        jarsFoundOnClasspath.add(jarName);
658                                } catch (IllegalArgumentException e) {
659                                        ourLog.info("JAR file not found on classpath: {}. Will try local filesystem.", jarName);
660                                        jarsNotFoundOnClasspath.add(jarName);
661                                }
662                        }
663
664                        // If all JARs were found on the classpath, we're done
665                        if (jarsNotFoundOnClasspath.isEmpty()) {
666                                ourLog.info("All specified JAR files were loaded from the classpath.");
667                                return;
668                        }
669
670                        // Fall back to loading from the local filesystem for JARs not found on classpath
671                        try {
672                                // Find the cdr-interceptor-starterproject directory
673                                Path projectRoot = Paths.get(".");
674                                Path targetDir = projectRoot.resolve("target");
675
676                                if (!Files.exists(targetDir)) {
677                                        throw new IllegalArgumentException("Could not find target directory. The following JAR files were not found on the classpath: " + jarsNotFoundOnClasspath);
678                                }
679
680                                // Get all JAR files in the target directory
681                                List<File> jarFiles;
682                                try (var paths = Files.list(targetDir)) {
683                                        jarFiles = paths
684                                                .filter(path -> path.toString().endsWith(".jar"))
685                                                .map(Path::toFile)
686                                                .toList();
687                                }
688
689                                if (jarFiles.isEmpty()) {
690                                        throw new IllegalArgumentException("No JAR files found in target directory. The following JAR files were not found on the classpath: " + jarsNotFoundOnClasspath);
691                                }
692
693                                // Filter JAR files based on jarsNotFoundOnClasspath
694                                List<File> filesToCopy = jarFiles.stream()
695                                        .filter(file -> jarsNotFoundOnClasspath.contains(file.getName()))
696                                        .toList();
697
698                                // Check if all JARs not found on classpath were found in target directory
699                                if (filesToCopy.size() < jarsNotFoundOnClasspath.size()) {
700                                        List<String> jarsNotFound = new ArrayList<>(jarsNotFoundOnClasspath);
701                                        jarsNotFound.removeAll(filesToCopy.stream().map(File::getName).toList());
702                                        throw new IllegalArgumentException("The following JAR files were not found in either the classpath or the target directory: " + jarsNotFound);
703                                }
704
705                                for (File jarFile : filesToCopy) {
706                                        ourLog.info("Copying JAR file from local filesystem to container: {}", jarFile.getName());
707                                        MountableFile mountableFile = MountableFile.forHostPath(jarFile.toPath());
708                                        withCopyFileToContainer(mountableFile, "/home/smile/smilecdr/customerlib/" + jarFile.getName());
709                                }
710
711                                ourLog.info("Copied {} JAR file(s) to container from local filesystem", filesToCopy.size());
712                        } catch (IOException e) {
713                                throw new IllegalArgumentException("Error copying JAR files to container from local filesystem", e);
714                        }
715                }
716        }
717}