chesnokoff commented on code in PR #13540:
URL: https://github.com/apache/ignite/pull/13540#discussion_r4003825525


##########
modules/compatibility/src/test/java/org/apache/ignite/compatibility/testframework/testcontainers/IgniteContainer.java:
##########
@@ -383,77 +398,194 @@ private String execControl(String... cmd) {
         return result.getStdout();
     }
 
-    /** @return Jar with {@link #TEST_CLASSES}, built once and reused for all 
containers. */
-    private static File testClassesJar() throws IOException {
-        File jar = testClassesJar;
+    /**
+     * Builds the {@code control.sh} command line to be executed inside the 
container.
+     *
+     * @param cmd Control utility arguments (e.g. {@code --set-state ACTIVE 
--yes}).
+     * @return Full command whose first element is the absolute path to {@code 
control.sh}, followed by {@code cmd}.
+     */
+    protected String[] command(String... cmd) {
+        String[] fullCmd = new String[cmd.length + 1];
+
+        fullCmd[0] = rootDir + "bin/control.sh";
+
+        System.arraycopy(cmd, 0, fullCmd, 1, cmd.length);
+
+        return fullCmd;
+    }

Review Comment:
   Do we need both extension points? Overriding command() seems enough to 
customize the command while keeping execution and error handling in the base 
class. What use case requires execControl() to be protected?



##########
modules/compatibility/src/test/java/org/apache/ignite/compatibility/testframework/testcontainers/IgniteClusterContainer.java:
##########
@@ -30,24 +31,66 @@ public class IgniteClusterContainer implements Startable {
     private final List<IgniteContainer> containers;
 
     /** Network. */
-    private final Network net = Network.newNetwork();
+    protected final Network net = Network.newNetwork();
+
+    /** Image name. */
+    protected final String imageName;
+
+    /** Consistent ID's. */
+    protected final List<String> consistentIds;
+
+    /** Whether the cluster has been started, guarding against a second {@link 
#start()}. */
+    private boolean started;
 
     /**
      * @param imageName Image name.
      * @param consistentIds Consistent ID's.
      */
-    public IgniteClusterContainer(String imageName, List<String> 
consistentIds) throws Exception {
+    public IgniteClusterContainer(String imageName, List<String> 
consistentIds) {
+        this.imageName = imageName;
+        this.consistentIds = consistentIds;
+
         containers = new ArrayList<>(consistentIds.size());
+    }
+
+    /**
+     * Factory hook for the node container. Overrides only receive {@code 
idx}; the image name, network and
+     * consistent IDs are instance fields (see {@link #imageName}, {@link 
#net}, {@link #consistentIds}).
+     *
+     * @param idx Node index.
+     * @return The node container.
+     */
+    protected IgniteContainer container(int idx) throws Exception {
+        return new IgniteContainer(imageName, net, "node" + (1 + idx), 
consistentIds.get(idx), idx);

Review Comment:
   Why do we do 1+idx here?



##########
modules/compatibility/src/test/java/org/apache/ignite/compatibility/testframework/testcontainers/IgniteClusterContainer.java:
##########
@@ -30,24 +31,66 @@ public class IgniteClusterContainer implements Startable {
     private final List<IgniteContainer> containers;
 
     /** Network. */
-    private final Network net = Network.newNetwork();
+    protected final Network net = Network.newNetwork();
+
+    /** Image name. */
+    protected final String imageName;
+
+    /** Consistent ID's. */
+    protected final List<String> consistentIds;
+
+    /** Whether the cluster has been started, guarding against a second {@link 
#start()}. */
+    private boolean started;
 
     /**
      * @param imageName Image name.
      * @param consistentIds Consistent ID's.
      */
-    public IgniteClusterContainer(String imageName, List<String> 
consistentIds) throws Exception {
+    public IgniteClusterContainer(String imageName, List<String> 
consistentIds) {
+        this.imageName = imageName;
+        this.consistentIds = consistentIds;
+
         containers = new ArrayList<>(consistentIds.size());
+    }
+
+    /**
+     * Factory hook for the node container. Overrides only receive {@code 
idx}; the image name, network and
+     * consistent IDs are instance fields (see {@link #imageName}, {@link 
#net}, {@link #consistentIds}).
+     *
+     * @param idx Node index.
+     * @return The node container.
+     */
+    protected IgniteContainer container(int idx) throws Exception {
+        return new IgniteContainer(imageName, net, "node" + (1 + idx), 
consistentIds.get(idx), idx);
+    }
 
+    /** Builds the node containers. */
+    protected void initContainers() throws Exception {
         for (int i = 0; i < consistentIds.size(); i++)
-            containers.add(new IgniteContainer(imageName, net, "node" + (1 + 
i), consistentIds.get(i), i));
+            containers.add(container(i));
     }
 
     /** {@inheritDoc} */
     @Override public void start() {
+        // Idempotent: either the cluster already started successfully, or 
container creation succeeded
+        // but startup (deepStart/activateCluster) failed on a previous 
attempt — in both cases the
+        // containers list is already populated and must not be built a second 
time (duplicate hostnames,
+        // consistent IDs and fixed host ports would make the baseline 
unreachable).
+        if (started || !containers.isEmpty())
+            return;

Review Comment:
   Question: I agree that we should not call initContainers() twice. But should 
we just return when `containers` is not empty and `started` is still false?
   
   I'm not sure what is better here: silently skip it or throw an error



##########
modules/compatibility/src/test/java/org/apache/ignite/compatibility/testframework/testcontainers/IgniteContainer.java:
##########
@@ -383,77 +398,194 @@ private String execControl(String... cmd) {
         return result.getStdout();
     }
 
-    /** @return Jar with {@link #TEST_CLASSES}, built once and reused for all 
containers. */
-    private static File testClassesJar() throws IOException {
-        File jar = testClassesJar;
+    /**
+     * Builds the {@code control.sh} command line to be executed inside the 
container.
+     *
+     * @param cmd Control utility arguments (e.g. {@code --set-state ACTIVE 
--yes}).
+     * @return Full command whose first element is the absolute path to {@code 
control.sh}, followed by {@code cmd}.
+     */
+    protected String[] command(String... cmd) {
+        String[] fullCmd = new String[cmd.length + 1];
+
+        fullCmd[0] = rootDir + "bin/control.sh";
+
+        System.arraycopy(cmd, 0, fullCmd, 1, cmd.length);
+
+        return fullCmd;
+    }
+
+    /** @return Classpath resource of the common (shared) node config copied 
into the container. */
+    protected String commonConfigResource() {
+        return "docker/common-test-config.xml";
+    }
+
+    /** @return Classpath resource of the source (pre-upgrade) node config 
copied into the container. */
+    protected String sourceConfigResource() {
+        return "docker/test-config.xml";
+    }
+
+    /** @return Classpath resource of the node config used on the target 
(upgraded) side during in-place Docker upgrade. */
+    protected String targetConfigResource() {
+        return "docker/target-test-config.xml";
+    }
+
+    /** @return Ignite root directory inside the container, with a trailing 
slash. */
+    protected String rootDirPath() {
+        return "/opt/ignite/apache-ignite/";
+    }
+
+    /** @return Custom classes (with their nested classes) used by the node in 
containers. */
+    protected List<String> testClasses() {
+        return List.of(
+            ContainerAddressResolver.class.getName(),
+            TestCompatibilityPluginProvider.class.getName(),
+            DisabledRollingUpgradeProcessor.class.getName(),
+            DisabledValidationProcessor.class.getName()
+        );
+    }
+
+    /**
+     * @return Jar with the {@link #testClasses() test classes}, built per 
distinct class list and reused.
+     *      The cache is keyed on the effective {@link #testClasses()} result 
so subclasses overriding it get
+     *      their own jar instead of silently reusing the one built for the 
base class.
+     */
+    protected File testClassesJar() throws IOException {
+        // List.copyOf makes an immutable, value-comparable key.
+        List<String> classes = List.copyOf(testClasses());
+
+        File jar = TEST_CLASSES_JARS.get(classes);
 
         if (jar != null)
             return jar;
 
         synchronized (IgniteContainer.class) {
-            if (testClassesJar != null)
-                return testClassesJar;
+            jar = TEST_CLASSES_JARS.get(classes);
+
+            if (jar != null)
+                return jar;
 
             jar = File.createTempFile("test-classes", ".jar");
             jar.deleteOnExit();
 
             try (JarOutputStream out = new JarOutputStream(new 
FileOutputStream(jar))) {
-                for (String cls : TEST_CLASSES) {
+                for (String cls : classes) {
                     String clsPath = cls.replace('.', '/') + ".class";
 
-                    URL url = 
IgniteContainer.class.getClassLoader().getResource(clsPath);
-
-                    if (url == null)
-                        throw new IOException("Class not found on classpath: " 
+ clsPath);
+                    // Include the class and its nested classes (e.g. the 
provider's anonymous $1).
+                    // Each entry carries the URL already resolved by 
classResources(), avoiding a second
+                    // getResource() for the same top-level class (which would 
otherwise be hit twice).
+                    for (ClassResource res : classResources(clsPath)) {
+                        out.putNextEntry(new JarEntry(res.name));
 
-                    File dir;
+                        try (InputStream in = res.url.openStream()) {
+                            in.transferTo(out);
+                        }
 
-                    try {
-                        dir = new File(url.toURI()).getParentFile();
-                    }
-                    catch (URISyntaxException e) {
-                        throw new IOException(e);
+                        out.closeEntry();
                     }
+                }
+            }
 
-                    String pkg = clsPath.substring(0, clsPath.lastIndexOf('/') 
+ 1);
-                    String simple = cls.substring(cls.lastIndexOf('.') + 1);
+            TEST_CLASSES_JARS.put(classes, jar);
 
-                    // Include the class and its nested classes (e.g. the 
provider's anonymous $1).
-                    File[] clsFiles = dir.listFiles((d, name) ->
-                        name.equals(simple + ".class") || 
name.startsWith(simple + '$'));
+            return jar;
+        }
+    }
 
-                    if (clsFiles == null)
-                        throw new IOException("Cannot list class directory: " 
+ dir);
+    /**
+     * Resolves the fully qualified resources (the top-level class plus its 
nested classes, e.g. {@code Outer$1})
+     * for a class located either on the file system or inside a jar on the 
classpath. Each returned element pairs
+     * the resource name with its already-resolved URL, so callers do not need 
to call {@code getResource()} again.
+     *
+     * @param clsPath Resource path of the top-level class (package separator 
replaced with '/', ending in {@code .class}).
+     * @return Pairs of resource name and resolved URL for the class and its 
nested classes.
+     */
+    private static Collection<ClassResource> classResources(String clsPath) 
throws IOException {
+        String pkg = clsPath.substring(0, clsPath.lastIndexOf('/') + 1);
+        String simple = clsPath.substring(clsPath.lastIndexOf('/') + 1, 
clsPath.length() - ".class".length());
+        String nestedPrefix = pkg + simple + "$";
 
-                    for (File f : clsFiles) {
-                        out.putNextEntry(new JarEntry(pkg + f.getName()));
+        ClassLoader cl = IgniteContainer.class.getClassLoader();
 
-                        Files.copy(f.toPath(), out);
+        URL url = cl.getResource(clsPath);
 
-                        out.closeEntry();
+        if (url == null)
+            throw new IOException("Class not found on classpath: " + clsPath);
+
+        List<ClassResource> res = new ArrayList<>();
+
+        try {
+            if ("file".equals(url.getProtocol())) {
+                File dir = new File(url.toURI()).getParentFile();
+
+                File[] clsFiles = dir.listFiles((d, name) ->
+                    name.startsWith(simple + '$') && name.endsWith(".class"));
+
+                if (clsFiles == null)
+                    throw new IOException("Cannot list class directory: " + 
dir);
+
+                res.add(new ClassResource(clsPath, url));
+
+                for (File f : clsFiles)
+                    res.add(new ClassResource(pkg + f.getName(), 
cl.getResource(pkg + f.getName())));
+            }
+            else if ("jar".equals(url.getProtocol())) {
+                JarURLConnection conn = (JarURLConnection)url.openConnection();
+
+                try (JarFile jar = conn.getJarFile()) {
+                    Enumeration<JarEntry> entries = jar.entries();
+
+                    while (entries.hasMoreElements()) {
+                        String name = entries.nextElement().getName();
+
+                        if (name.equals(clsPath) || 
(name.startsWith(nestedPrefix) && name.endsWith(".class")))
+                            res.add(new ClassResource(name, 
cl.getResource(name)));
                     }
                 }
             }
+            else
+                throw new IOException("Unsupported class resource protocol: " 
+ url.getProtocol());
+        }
+        catch (URISyntaxException e) {
+            throw new IOException(e);
+        }
 
-            return testClassesJar = jar;
+        return res;
+    }
+
+    /** A class resource: its name on the classpath paired with the 
already-resolved URL. */
+    private static final class ClassResource {
+        /** Resource name on the classpath. */
+        final String name;
+
+        /** Resolved URL of the resource. */
+        final URL url;
+
+        /** @param name Resource name on the classpath. */
+        ClassResource(String name, URL url) {
+            this.name = name;
+            this.url = url;
         }
     }

Review Comment:
   Probably we could use `record` to reduce LOC, but it's up to you



##########
modules/compatibility/src/test/java/org/apache/ignite/compatibility/testframework/testcontainers/IgniteContainer.java:
##########
@@ -383,77 +398,194 @@ private String execControl(String... cmd) {
         return result.getStdout();
     }
 
-    /** @return Jar with {@link #TEST_CLASSES}, built once and reused for all 
containers. */
-    private static File testClassesJar() throws IOException {
-        File jar = testClassesJar;
+    /**
+     * Builds the {@code control.sh} command line to be executed inside the 
container.
+     *
+     * @param cmd Control utility arguments (e.g. {@code --set-state ACTIVE 
--yes}).
+     * @return Full command whose first element is the absolute path to {@code 
control.sh}, followed by {@code cmd}.
+     */
+    protected String[] command(String... cmd) {
+        String[] fullCmd = new String[cmd.length + 1];
+
+        fullCmd[0] = rootDir + "bin/control.sh";
+
+        System.arraycopy(cmd, 0, fullCmd, 1, cmd.length);
+
+        return fullCmd;
+    }
+
+    /** @return Classpath resource of the common (shared) node config copied 
into the container. */
+    protected String commonConfigResource() {
+        return "docker/common-test-config.xml";
+    }
+
+    /** @return Classpath resource of the source (pre-upgrade) node config 
copied into the container. */
+    protected String sourceConfigResource() {
+        return "docker/test-config.xml";
+    }
+
+    /** @return Classpath resource of the node config used on the target 
(upgraded) side during in-place Docker upgrade. */
+    protected String targetConfigResource() {
+        return "docker/target-test-config.xml";
+    }
+
+    /** @return Ignite root directory inside the container, with a trailing 
slash. */
+    protected String rootDirPath() {
+        return "/opt/ignite/apache-ignite/";
+    }
+
+    /** @return Custom classes (with their nested classes) used by the node in 
containers. */
+    protected List<String> testClasses() {
+        return List.of(
+            ContainerAddressResolver.class.getName(),
+            TestCompatibilityPluginProvider.class.getName(),
+            DisabledRollingUpgradeProcessor.class.getName(),
+            DisabledValidationProcessor.class.getName()
+        );
+    }
+
+    /**
+     * @return Jar with the {@link #testClasses() test classes}, built per 
distinct class list and reused.
+     *      The cache is keyed on the effective {@link #testClasses()} result 
so subclasses overriding it get
+     *      their own jar instead of silently reusing the one built for the 
base class.
+     */
+    protected File testClassesJar() throws IOException {
+        // List.copyOf makes an immutable, value-comparable key.
+        List<String> classes = List.copyOf(testClasses());

Review Comment:
   I'm not sure we need the comment. Looks like it just explains semantics of 
`List.copyOf`



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to