Rework SingleFeatureTest 25/97725/39
authorRuslan Kashapov <ruslan.kashapov@pantheon.tech>
Thu, 11 Jan 2024 16:21:37 +0000 (18:21 +0200)
committerRobert Varga <nite@hq.sk>
Sun, 28 Jan 2024 19:46:04 +0000 (19:46 +0000)
SingleFeatureTest needs protection from concurrent exection by default.
We just cannot assume the spawned container does not allocate global
resources, like listening on all IP addresses' specific port.

Features that are known to be safe can define sft.concurrent to enable
concurrent execution, otherwise each executing Maven reactor will ensure
testing executes in at most a single reactor.

JIRA: ODLPARENT-262
Change-Id: Ib26b4ac55f4bc689a9f459f6e296df65a75ef645
Signed-off-by: Robert Varga <robert.varga@pantheon.tech>
Signed-off-by: Ruslan Kashapov <ruslan.kashapov@pantheon.tech>
15 files changed:
features-test-plugin-it/pom.xml [new file with mode: 0644]
features-test-plugin-it/src/it/simple-bundle/pom.xml [new file with mode: 0644]
features-test-plugin-it/src/it/test-dependency/pom.xml [new file with mode: 0644]
features-test-plugin-it/src/it/test-feature-parent/pom.xml [new file with mode: 0644]
features-test-plugin/pom.xml [new file with mode: 0644]
features-test-plugin/src/main/java/org/opendaylight/odlparent/features/test/plugin/DependencyResolver.java [new file with mode: 0644]
features-test-plugin/src/main/java/org/opendaylight/odlparent/features/test/plugin/DependencyUtils.java [new file with mode: 0644]
features-test-plugin/src/main/java/org/opendaylight/odlparent/features/test/plugin/FeatureDependency.java [new file with mode: 0644]
features-test-plugin/src/main/java/org/opendaylight/odlparent/features/test/plugin/PaxExamExecution.java [new file with mode: 0644]
features-test-plugin/src/main/java/org/opendaylight/odlparent/features/test/plugin/PaxOptionUtils.java [new file with mode: 0644]
features-test-plugin/src/main/java/org/opendaylight/odlparent/features/test/plugin/TestFeaturesMojo.java [new file with mode: 0644]
features-test-plugin/src/main/java/org/opendaylight/odlparent/features/test/plugin/TestProbe.java [new file with mode: 0644]
features-test-plugin/src/main/resources/versions [new file with mode: 0644]
odlparent-artifacts/pom.xml
pom.xml

diff --git a/features-test-plugin-it/pom.xml b/features-test-plugin-it/pom.xml
new file mode 100644 (file)
index 0000000..e7b8610
--- /dev/null
@@ -0,0 +1,70 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ Copyright © 2023 PANTHEON.tech, s.r.o. and others.  All rights reserved.
+
+ This program and the accompanying materials are made available under the
+ terms of the Eclipse Public License v1.0 which accompanies this distribution,
+ and is available at http://www.eclipse.org/legal/epl-v10.html
+ -->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>org.opendaylight.odlparent</groupId>
+        <artifactId>odlparent</artifactId>
+        <version>13.0.11-SNAPSHOT</version>
+        <relativePath>../../odlparent</relativePath>
+    </parent>
+
+    <artifactId>features-test-plugin-it</artifactId>
+    <packaging>pom</packaging>
+    <name>ODL :: odlparent :: ${project.artifactId}</name>
+
+    <properties>
+        <jacoco.skip>true</jacoco.skip>
+        <spotbugs.skip>true</spotbugs.skip>
+        <maven.javadoc.skip>true</maven.javadoc.skip>
+        <jacoco.skip>true</jacoco.skip>
+        <maven.install.skip>true</maven.install.skip>
+        <maven.deploy.skip>true</maven.deploy.skip>
+    </properties>
+
+    <dependencies>
+        <!-- set dependencies to ensure current module is processed after listed below -->
+        <dependency>
+            <groupId>org.opendaylight.odlparent</groupId>
+            <artifactId>features-test-plugin</artifactId>
+            <version>${project.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>org.opendaylight.odlparent</groupId>
+            <artifactId>opendaylight-karaf-empty</artifactId>
+            <version>${project.version}</version>
+            <type>tar.gz</type>
+        </dependency>
+    </dependencies>
+
+    <build>
+         <plugins>
+             <plugin>
+                <artifactId>maven-invoker-plugin</artifactId>
+                <configuration>
+                    <projectsDirectory>src/it</projectsDirectory>
+                    <cloneProjectsTo>${project.build.directory}/it</cloneProjectsTo>
+                    <pomExcludes>
+                        <pomExclude>test-feature-parent/pom.xml</pomExclude>
+                    </pomExcludes>
+                    <debug>true</debug>
+                </configuration>
+                <executions>
+                    <execution>
+                        <id>integration-test</id>
+                        <goals>
+                            <goal>integration-test</goal>
+                            <goal>verify</goal>
+                        </goals>
+                    </execution>
+                </executions>
+            </plugin>
+        </plugins>
+    </build>
+</project>
\ No newline at end of file
diff --git a/features-test-plugin-it/src/it/simple-bundle/pom.xml b/features-test-plugin-it/src/it/simple-bundle/pom.xml
new file mode 100644 (file)
index 0000000..3ffb519
--- /dev/null
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ Copyright (c) 2023 PANTHEON.tech s.r.o. and others. All rights reserved.
+
+ This program and the accompanying materials are made available under the
+ terms of the Eclipse Public License v1.0 which accompanies this distribution,
+ and is available at http://www.eclipse.org/legal/epl-v10.html
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>features-test-it</groupId>
+        <artifactId>features-test-it-parent</artifactId>
+        <version>@project.version@</version>
+        <relativePath>../test-feature-parent</relativePath>
+    </parent>
+
+    <artifactId>simple-bundle</artifactId>
+    <packaging>feature</packaging>
+
+    <dependencies>
+        <dependency>
+            <groupId>com.google.guava</groupId>
+            <artifactId>guava</artifactId>
+        </dependency>
+    </dependencies>
+
+</project>
\ No newline at end of file
diff --git a/features-test-plugin-it/src/it/test-dependency/pom.xml b/features-test-plugin-it/src/it/test-dependency/pom.xml
new file mode 100644 (file)
index 0000000..78f8abc
--- /dev/null
@@ -0,0 +1,35 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ Copyright (c) 2023 PANTHEON.tech s.r.o. and others. All rights reserved.
+
+ This program and the accompanying materials are made available under the
+ terms of the Eclipse Public License v1.0 which accompanies this distribution,
+ and is available at http://www.eclipse.org/legal/epl-v10.html
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>features-test-it</groupId>
+        <artifactId>features-test-it-parent</artifactId>
+        <version>@project.version@</version>
+        <relativePath>../test-feature-parent</relativePath>
+    </parent>
+
+    <artifactId>test-dependency</artifactId>
+    <packaging>feature</packaging>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.apache.karaf.features</groupId>
+            <artifactId>static</artifactId>
+            <version>${karaf.version}</version>
+            <classifier>features</classifier>
+            <type>xml</type>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+
+</project>
\ No newline at end of file
diff --git a/features-test-plugin-it/src/it/test-feature-parent/pom.xml b/features-test-plugin-it/src/it/test-feature-parent/pom.xml
new file mode 100644 (file)
index 0000000..9186a19
--- /dev/null
@@ -0,0 +1,66 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ Copyright (c) 2023 PANTHEON.tech s.r.o. and others. All rights reserved.
+
+ This program and the accompanying materials are made available under the
+ terms of the Eclipse Public License v1.0 which accompanies this distribution,
+ and is available at http://www.eclipse.org/legal/epl-v10.html
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>org.opendaylight.odlparent</groupId>
+        <artifactId>odlparent</artifactId>
+        <version>@project.version@</version>
+        <relativePath>../../../../odlparent</relativePath>
+    </parent>
+
+    <groupId>features-test-it</groupId>
+    <artifactId>features-test-it-parent</artifactId>
+    <packaging>pom</packaging>
+
+    <properties>
+        <odlparent.dependency.skip>true</odlparent.dependency.skip>
+        <jacoco.skip>true</jacoco.skip>
+        <spotbugs.skip>true</spotbugs.skip>
+        <maven.javadoc.skip>true</maven.javadoc.skip>
+        <maven.deploy.skip>true</maven.deploy.skip>
+    </properties>
+
+    <build>
+        <plugins>
+            <!-- build features.xml -->
+            <plugin>
+                <groupId>org.apache.karaf.tooling</groupId>
+                <artifactId>karaf-maven-plugin</artifactId>
+                <extensions>true</extensions>
+                <configuration>
+                    <enableGeneration>true</enableGeneration>
+                </configuration>
+            </plugin>
+            <!-- execute SFT -->
+            <plugin>
+                <groupId>org.opendaylight.odlparent</groupId>
+                <artifactId>features-test-plugin</artifactId>
+                <extensions>true</extensions>
+                <version>@project.version@</version>
+                <configuration>
+                    <keepUnpack>false</keepUnpack>
+                    <concurrent>true</concurrent>
+                </configuration>
+                <executions>
+                    <execution>
+                        <id>integration-test</id>
+                        <phase>test</phase>
+                        <goals>
+                            <goal>test</goal>
+                        </goals>
+                    </execution>
+                </executions>
+            </plugin>
+        </plugins>
+    </build>
+</project>
\ No newline at end of file
diff --git a/features-test-plugin/pom.xml b/features-test-plugin/pom.xml
new file mode 100644 (file)
index 0000000..aebf063
--- /dev/null
@@ -0,0 +1,206 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ Copyright © 2023 PANTHEON.tech, s.r.o. and others.  All rights reserved.
+
+ This program and the accompanying materials are made available under the
+ terms of the Eclipse Public License v1.0 which accompanies this distribution,
+ and is available at http://www.eclipse.org/legal/epl-v10.html
+ -->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>org.opendaylight.odlparent</groupId>
+        <artifactId>odlparent</artifactId>
+        <version>13.0.11-SNAPSHOT</version>
+        <relativePath>../odlparent</relativePath>
+    </parent>
+
+    <artifactId>features-test-plugin</artifactId>
+    <packaging>maven-plugin</packaging>
+    <name>ODL :: odlparent :: ${project.artifactId}</name>
+
+    <prerequisites>
+        <maven>3.8.3</maven>
+    </prerequisites>
+
+    <properties>
+        <!-- Do not delete: pax.exam.version property is used in copy resources -->
+        <!-- NB 4.13.3 is last known ok version allowing concurrent karaf containers -->
+        <!-- pax exam issue https://github.com/ops4j/org.ops4j.pax.exam2/issues/1020 -->
+        <pax.exam.version>4.13.3</pax.exam.version>
+        <pax.url.version>2.6.2</pax.url.version>
+    </properties>
+
+    <dependencies>
+        <!-- Maven plugin API -->
+        <dependency>
+            <groupId>org.apache.maven</groupId>
+            <artifactId>maven-artifact</artifactId>
+            <version>3.8.3</version>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.maven</groupId>
+            <artifactId>maven-core</artifactId>
+            <version>3.8.3</version>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.maven</groupId>
+            <artifactId>maven-model</artifactId>
+            <version>3.8.3</version>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.maven</groupId>
+            <artifactId>maven-plugin-api</artifactId>
+            <version>3.8.3</version>
+            <scope>provided</scope>
+        </dependency>
+        <!-- dependencies to annotations -->
+        <dependency>
+            <groupId>org.apache.maven.plugin-tools</groupId>
+            <artifactId>maven-plugin-annotations</artifactId>
+            <version>3.8.2</version>
+            <scope>provided</scope>
+        </dependency>
+
+        <!-- dependency resolution -->
+        <dependency>
+            <groupId>org.apache.maven.wagon</groupId>
+            <artifactId>wagon-http</artifactId>
+            <version>3.2.0</version>
+            <scope>compile</scope>
+            <exclusions>
+                <exclusion>
+                    <!-- because it conflicts with org.slf4j:jcl-over-slf4j -->
+                    <groupId>commons-logging</groupId>
+                    <artifactId>commons-logging</artifactId>
+                </exclusion>
+            </exclusions>
+        </dependency>
+        <dependency>
+            <groupId>org.eclipse.aether</groupId>
+            <artifactId>aether-api</artifactId>
+            <version>1.1.0</version>
+        </dependency>
+
+        <!-- Pax artifacts, includes scope redefined (test to compile/runtime) dependencies
+            in order to be included (preloaded to local repo) on plugin execution -->
+        <dependency>
+            <groupId>org.ops4j.pax.exam</groupId>
+            <artifactId>pax-exam</artifactId>
+            <version>${pax.exam.version}</version>
+            <scope>compile</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.ops4j.pax.exam</groupId>
+            <artifactId>pax-exam-link-mvn</artifactId>
+            <version>${pax.exam.version}</version>
+            <scope>compile</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.ops4j.pax.url</groupId>
+            <artifactId>pax-url-aether</artifactId>
+            <version>${pax.url.version}</version>
+            <scope>runtime</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.ops4j.pax.url</groupId>
+            <artifactId>pax-url-link</artifactId>
+            <version>${pax.url.version}</version>
+            <scope>runtime</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.ops4j.pax.exam</groupId>
+            <artifactId>pax-exam-container-karaf</artifactId>
+            <version>${pax.exam.version}</version>
+            <scope>compile</scope>
+        </dependency>
+
+        <!-- required features -->
+        <dependency>
+            <groupId>org.ops4j.pax.exam</groupId>
+            <artifactId>pax-exam-features</artifactId>
+            <version>${pax.exam.version}</version>
+            <type>xml</type>
+            <scope>runtime</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.karaf.features</groupId>
+            <artifactId>standard</artifactId>
+            <version>${karaf.version}</version>
+            <classifier>features</classifier>
+            <type>xml</type>
+            <scope>runtime</scope>
+        </dependency>
+
+        <!-- OSGi -->
+        <dependency>
+            <groupId>org.osgi</groupId>
+            <artifactId>org.osgi.framework</artifactId>
+            <scope>compile</scope>
+        </dependency>
+
+        <!-- test probe dependencies for karaf environment -->
+        <dependency>
+            <groupId>org.apache.karaf.features</groupId>
+            <artifactId>org.apache.karaf.features.core</artifactId>
+            <scope>compile</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.karaf.bundle</groupId>
+            <artifactId>org.apache.karaf.bundle.core</artifactId>
+            <scope>compile</scope>
+        </dependency>
+
+        <!-- junit 4 @Test annotation is required for junit probe invoker -->
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <scope>compile</scope>
+        </dependency>
+
+        <dependency>
+            <groupId>com.github.spotbugs</groupId>
+            <artifactId>spotbugs-annotations</artifactId>
+            <optional>true</optional>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <resources>
+            <resource>
+                <directory>src/main/resources</directory>
+                <filtering>true</filtering>
+            </resource>
+        </resources>
+
+        <plugins>
+            <plugin>
+                <artifactId>maven-checkstyle-plugin</artifactId>
+                <executions>
+                    <execution>
+                        <id>check-license</id>
+                        <configuration>
+                            <excludes>
+                                <!-- Skip Apache Licensed files -->
+                                org/opendaylight/odlparent/features/test/plugin/TestFeaturesMojo.java
+                            </excludes>
+                        </configuration>
+                    </execution>
+                </executions>
+            </plugin>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-plugin-plugin</artifactId>
+                <executions>
+                    <execution>
+                        <id>default-descriptor</id>
+                        <phase>process-classes</phase>
+                    </execution>
+                </executions>
+            </plugin>
+        </plugins>
+    </build>
+</project>
\ No newline at end of file
diff --git a/features-test-plugin/src/main/java/org/opendaylight/odlparent/features/test/plugin/DependencyResolver.java b/features-test-plugin/src/main/java/org/opendaylight/odlparent/features/test/plugin/DependencyResolver.java
new file mode 100644 (file)
index 0000000..c9fe128
--- /dev/null
@@ -0,0 +1,183 @@
+/*
+ * Copyright (c) 2024 PANTHEON.tech s.r.o. and others. All rights reserved.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v1.0 which accompanies this distribution,
+ * and is available at http://www.eclipse.org/legal/epl-v10.html
+ */
+package org.opendaylight.odlparent.features.test.plugin;
+
+import static org.opendaylight.odlparent.features.test.plugin.DependencyUtils.FEATURES;
+import static org.opendaylight.odlparent.features.test.plugin.DependencyUtils.KARAF_VERSION;
+import static org.opendaylight.odlparent.features.test.plugin.DependencyUtils.PAX_EXAM_VERSION;
+import static org.opendaylight.odlparent.features.test.plugin.DependencyUtils.RELEASE_VERSION;
+import static org.opendaylight.odlparent.features.test.plugin.DependencyUtils.TEST;
+import static org.opendaylight.odlparent.features.test.plugin.DependencyUtils.XML;
+import static org.opendaylight.odlparent.features.test.plugin.DependencyUtils.extractDependencies;
+import static org.opendaylight.odlparent.features.test.plugin.DependencyUtils.identifierOf;
+import static org.opendaylight.odlparent.features.test.plugin.DependencyUtils.isFeature;
+import static org.opendaylight.odlparent.features.test.plugin.DependencyUtils.toAetherArtifact;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.net.MalformedURLException;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+import org.apache.karaf.features.Feature;
+import org.apache.karaf.features.internal.model.Features;
+import org.apache.karaf.features.internal.model.JaxbUtil;
+import org.apache.maven.plugin.MojoExecutionException;
+import org.eclipse.aether.RepositorySystem;
+import org.eclipse.aether.RepositorySystemSession;
+import org.eclipse.aether.artifact.Artifact;
+import org.eclipse.aether.artifact.DefaultArtifact;
+import org.eclipse.aether.repository.RemoteRepository;
+import org.eclipse.aether.resolution.ArtifactRequest;
+import org.eclipse.aether.resolution.ArtifactResolutionException;
+import org.eclipse.aether.resolution.ArtifactResult;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Maven dependencies resolver for feature artifacts. The main goal of dependencies resolution
+ * is moving artifacts to local repositories so these artifacts became available for karaf deployer
+ * when test feature is being installed.
+ */
+final class DependencyResolver {
+    private static final Logger LOG = LoggerFactory.getLogger(DependencyResolver.class);
+
+    private final Map<String, Artifact> resolvedArtifacts = new HashMap<>();
+    private final Map<String, Set<String>> resolvedFeatures = new HashMap<>();
+    private final RepositorySystemSession repoSession;
+    private final List<RemoteRepository> repositories;
+    private final RepositorySystem repoSystem;
+
+    DependencyResolver(final RepositorySystem repoSystem, final RepositorySystemSession repoSession,
+            final List<RemoteRepository> repositories) {
+        this.repoSession = repoSession;
+        this.repositories = repositories;
+        this.repoSystem = repoSystem;
+    }
+
+    /**
+     * Iterates over artifacts, detects features, resolves dependencies on other artifacts.
+     *
+     * @param artifacts list of maven artifacts to check
+     * @return collection of feature descriptors for features from test scope, expected to be pre-installed
+     * @throws MojoExecutionException  if any dependency resolution fails
+     */
+    Set<FeatureDependency> resolveFeatures(final Collection<org.apache.maven.artifact.Artifact> artifacts)
+            throws MojoExecutionException {
+        final var featureDependencies = new LinkedList<FeatureDependency>();
+        for (var mvnArtifact : artifacts) {
+            final var artifact = toAetherArtifact(mvnArtifact);
+            if (isFeature(artifact)) {
+                final var resolved = resolve(artifact);
+                final var featureNames = resolveFeatureFile(resolved.getFile());
+                if (TEST.equals(mvnArtifact.getScope())) {
+                    featureDependencies.add(new FeatureDependency(resolved, featureNames));
+                }
+            }
+        }
+        return Set.copyOf(featureDependencies);
+    }
+
+    /**
+     * Resolves dependencies on features required for proper plugin functionality.
+     *
+     * @return collection of feature descriptors for the features expected to be pre-installed
+     * @throws MojoExecutionException if any dependency resolution fails
+     */
+    Set<FeatureDependency> resolvePluginFeatures() throws MojoExecutionException {
+
+        // pax-exam features, installed by karaf container, no need explicit installation
+        final var paxExamFeaturesResolved = resolve(
+            new DefaultArtifact("org.ops4j.pax.exam", "pax-exam-features", null, XML, PAX_EXAM_VERSION));
+        resolveFeatureFile(paxExamFeaturesResolved.getFile());
+
+        // karaf scr feature
+        final var karafFeatureResolved = resolve(
+            new DefaultArtifact("org.apache.karaf.features", "standard", FEATURES, XML, KARAF_VERSION));
+        resolveFeatureFile(karafFeatureResolved.getFile());
+
+        return Set.of(new FeatureDependency(karafFeatureResolved, Set.of("scr")));
+    }
+
+    String resolveKarafDistroUrl(final String groupId, final String artifactId, final String type)
+            throws MojoExecutionException {
+        final var resolved = resolve(new DefaultArtifact(groupId, artifactId, null, type, RELEASE_VERSION));
+        if (!resolved.getFile().exists()) {
+            throw new MojoExecutionException("No file for karaf distribution resolved " + resolved);
+        }
+        try {
+            return resolved.getFile().toURI().toURL().toString();
+        } catch (MalformedURLException e) {
+            throw new MojoExecutionException("Could not get karaf distribution URL", e);
+        }
+    }
+
+    /**
+     * Extracts features dependencies from feature file and resolves dependencies on maven artifacts including
+     * other features.
+     *
+     * @param featureFile the xml file describing features
+     * @return Collection of feature names extracted from the file
+     * @throws MojoExecutionException if file cannot be parsed, or any dependency resolved
+     */
+    Set<String> resolveFeatureFile(final File featureFile) throws MojoExecutionException {
+        final var identifier = featureFile.getAbsolutePath();
+        LOG.debug("Resolving dependencies for feature file: {}", identifier);
+        final var cached = resolvedFeatures.get(identifier);
+        if (cached != null) {
+            return cached;
+        }
+        if (!featureFile.exists()) {
+            LOG.debug("Feature file {} does not exist. Dependency resolution omitted.", identifier);
+            return Set.of();
+        }
+        final Features features;
+        try (var inputStream = new FileInputStream(featureFile)) {
+            features = JaxbUtil.unmarshal(featureFile.toURI().toString(), inputStream, false);
+        } catch (IOException e) {
+            throw new MojoExecutionException("Could not read feature file " + featureFile, e);
+        }
+        for (var unresolved : extractDependencies(features)) {
+            final var resolved = resolve(unresolved);
+            if (isFeature(resolved)) {
+                resolveFeatureFile(resolved.getFile());
+            }
+        }
+        final var featureNames = features.getFeature().stream().map(Feature::getName).collect(Collectors.toSet());
+        resolvedFeatures.put(identifier, featureNames);
+        return featureNames;
+    }
+
+    private Artifact resolve(final Artifact unresolved) throws MojoExecutionException {
+        final var identifier = identifierOf(unresolved);
+        final var cached = resolvedArtifacts.get(identifier);
+        if (cached != null) {
+            return cached;
+        }
+        final var request = new ArtifactRequest().setRepositories(repositories).setArtifact(unresolved);
+        final ArtifactResult resolutionResult;
+        try {
+            resolutionResult = repoSystem.resolveArtifact(repoSession, request);
+        } catch (ArtifactResolutionException e) {
+            throw new MojoExecutionException("Could not resolve artifact " + identifier, e);
+        }
+        final var resolved = resolutionResult.getArtifact();
+        LOG.debug("Dependency resolved for {}", identifier);
+        final var file = resolved.getFile();
+        if (file == null || !file.exists()) {
+            LOG.warn("Dependency artifact {} is resolved but has no attached file.", identifier);
+        }
+        resolvedArtifacts.put(identifier, resolved);
+        return resolved;
+    }
+}
\ No newline at end of file
diff --git a/features-test-plugin/src/main/java/org/opendaylight/odlparent/features/test/plugin/DependencyUtils.java b/features-test-plugin/src/main/java/org/opendaylight/odlparent/features/test/plugin/DependencyUtils.java
new file mode 100644 (file)
index 0000000..f60e6f9
--- /dev/null
@@ -0,0 +1,133 @@
+/*
+ * Copyright (c) 2024 PANTHEON.tech s.r.o. and others. All rights reserved.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v1.0 which accompanies this distribution,
+ * and is available at http://www.eclipse.org/legal/epl-v10.html
+ */
+package org.opendaylight.odlparent.features.test.plugin;
+
+import java.io.IOException;
+import java.net.MalformedURLException;
+import java.util.Collection;
+import java.util.LinkedHashSet;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Properties;
+import org.apache.karaf.features.BundleInfo;
+import org.apache.karaf.features.internal.model.Bundle;
+import org.apache.karaf.features.internal.model.ConfigFile;
+import org.apache.karaf.features.internal.model.Features;
+import org.apache.karaf.util.maven.Parser;
+import org.eclipse.aether.artifact.Artifact;
+import org.eclipse.aether.artifact.DefaultArtifact;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Utility artifact responsible for maven dependencies extraction and conversion.
+ */
+final class DependencyUtils {
+    private static final Logger LOG = LoggerFactory.getLogger(DependencyUtils.class);
+    private static final String MVN_PREFIX = "mvn:";
+    private static final int MVN_CUT_INDEX = MVN_PREFIX.length();
+    private static final String WRAP_PREFIX = "wrap:mvn:";
+    private static final int WRAP_CUT_INDEX = WRAP_PREFIX.length();
+
+    static final String XML = "xml";
+    static final String TEST = "test";
+    static final String FEATURES = "features";
+
+    static final String RELEASE_VERSION;
+    static final String KARAF_VERSION;
+    static final String PAX_EXAM_VERSION;
+
+    static {
+        try (var in = DependencyUtils.class.getClassLoader().getResourceAsStream("versions")) {
+            if (in == null) {
+                throw new ExceptionInInitializerError("Cannot read from 'versions' resource");
+            }
+            final var props = new Properties();
+            props.load(in);
+            RELEASE_VERSION = nonnullValue(props, "release.version");
+            KARAF_VERSION = nonnullValue(props, "karaf.version");
+            PAX_EXAM_VERSION = nonnullValue(props, "pax.exam.version");
+        } catch (IOException | IllegalStateException e) {
+            throw new ExceptionInInitializerError(e);
+        }
+    }
+
+    private DependencyUtils() {
+        // utility class
+    }
+
+    static Collection<Artifact> extractDependencies(final Features features) {
+        final var urls = new LinkedHashSet<String>();
+        urls.addAll(features.getRepository());
+        for (var feature : features.getFeature()) {
+            if (feature.getBundle() != null) {
+                urls.addAll(feature.getBundle().stream().map(Bundle::getLocation).toList());
+            }
+            if (feature.getConditional() != null) {
+                urls.addAll(feature.getConditional().stream()
+                    .filter(conditional -> conditional.getBundles() != null)
+                    .flatMap(conditional -> conditional.getBundles().stream())
+                    .map(BundleInfo::getLocation).toList());
+            }
+            if (feature.getConfigfile() != null) {
+                urls.addAll(feature.getConfigfile().stream().map(ConfigFile::getLocation).toList());
+            }
+        }
+        final var artifacts = new LinkedList<Artifact>();
+        urls.forEach(url -> collectArtifact(url, artifacts));
+        return artifacts;
+    }
+
+    private static void collectArtifact(final String url, final Collection<Artifact> collection) {
+        final var filteredUrl = url == null ? null : filterMvnUrl(url);
+        if (filteredUrl != null) {
+            final Parser parser;
+            try {
+                parser = new Parser(filteredUrl);
+            } catch (MalformedURLException e) {
+                LOG.warn("Error parsing url {} -> dependency omitted", url, e);
+                return;
+            }
+            collection.add(new DefaultArtifact(parser.getGroup(), parser.getArtifact(),
+                parser.getClassifier(), parser.getType(), parser.getVersion()));
+        }
+    }
+
+    private static String filterMvnUrl(final String url) {
+        if (url.startsWith(MVN_PREFIX)) {
+            return url.substring(MVN_CUT_INDEX);
+        }
+        if (url.startsWith(WRAP_PREFIX)) {
+            final var endIndex = url.indexOf('$');
+            return endIndex > WRAP_CUT_INDEX ? url.substring(WRAP_CUT_INDEX, endIndex) : url.substring(WRAP_CUT_INDEX);
+        }
+        return null;
+    }
+
+    static Artifact toAetherArtifact(final org.apache.maven.artifact.Artifact mavenArtifact) {
+        return new DefaultArtifact(mavenArtifact.getGroupId(), mavenArtifact.getArtifactId(),
+            mavenArtifact.getClassifier(), mavenArtifact.getType(), mavenArtifact.getVersion());
+    }
+
+    static String identifierOf(final Artifact artifact) {
+        return String.join(":", List.of(artifact.getGroupId(), artifact.getArtifactId(),
+            artifact.getVersion(), artifact.getClassifier(), artifact.getExtension()));
+    }
+
+    static boolean isFeature(final Artifact artifact) {
+        return FEATURES.equals(artifact.getClassifier()) && XML.equals(artifact.getExtension());
+    }
+
+    private static String nonnullValue(final Properties props, final String key) {
+        final var value = props.getProperty(key);
+        if (value == null) {
+            throw new IllegalStateException("no property value found for key " + key);
+        }
+        return value;
+    }
+}
\ No newline at end of file
diff --git a/features-test-plugin/src/main/java/org/opendaylight/odlparent/features/test/plugin/FeatureDependency.java b/features-test-plugin/src/main/java/org/opendaylight/odlparent/features/test/plugin/FeatureDependency.java
new file mode 100644 (file)
index 0000000..f8e7afa
--- /dev/null
@@ -0,0 +1,20 @@
+/*
+ * Copyright (c) 2024 PANTHEON.tech s.r.o. and others. All rights reserved.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v1.0 which accompanies this distribution,
+ * and is available at http://www.eclipse.org/legal/epl-v10.html
+ */
+package org.opendaylight.odlparent.features.test.plugin;
+
+import java.util.Set;
+import org.eclipse.aether.artifact.Artifact;
+
+/**
+ * Feature descriptor in maven dependency context.
+ *
+ * @param artifact aether object representing maven artifact
+ * @param featureNames all feature names extracted from xml file
+ */
+public record FeatureDependency(Artifact artifact, Set<String> featureNames) {
+}
\ No newline at end of file
diff --git a/features-test-plugin/src/main/java/org/opendaylight/odlparent/features/test/plugin/PaxExamExecution.java b/features-test-plugin/src/main/java/org/opendaylight/odlparent/features/test/plugin/PaxExamExecution.java
new file mode 100644 (file)
index 0000000..2776fe2
--- /dev/null
@@ -0,0 +1,70 @@
+/*
+ * Copyright (c) 2024 PANTHEON.tech, s.r.o. and others.  All rights reserved.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v1.0 which accompanies this distribution,
+ * and is available at http://www.eclipse.org/legal/epl-v10.html
+ */
+package org.opendaylight.odlparent.features.test.plugin;
+
+import static java.util.Objects.requireNonNull;
+
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.PrintStream;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.apache.maven.plugin.MojoExecutionException;
+import org.ops4j.pax.exam.ExamSystem;
+import org.ops4j.pax.exam.TestContainer;
+
+final class PaxExamExecution {
+    private final TestContainer[] containers;
+    private final ExamSystem examSystem;
+    private final String localRepository;
+
+    PaxExamExecution(final String localRepository, final ExamSystem examSystem, final TestContainer ... containers) {
+        this.localRepository = requireNonNull(localRepository);
+        this.containers = containers;
+        this.examSystem = examSystem;
+    }
+
+    @SuppressWarnings({"IllegalCatch", "RegexpSinglelineJava"})
+    @SuppressFBWarnings("DM_DEFAULT_ENCODING")
+    void execute() throws MojoExecutionException {
+
+        // Use the same repository for Pax Exam as is used for Maven
+        System.setProperty("org.ops4j.pax.url.mvn.localRepository", localRepository);
+
+        for (var container : containers) {
+            // disable karaf stdout output to maven log
+            final var stdout = System.out;
+            System.setOut(new PrintStream(OutputStream.nullOutputStream()));
+
+            final var containerStarted = new AtomicBoolean(false);
+            try {
+                container.start();
+                containerStarted.set(true);
+
+                // build probe
+                final var probeBuilder = examSystem.createProbe();
+                final var address = probeBuilder.addTest(TestProbe.class, "testFeature");
+                probeBuilder.addTest(TestProbe.CheckResult.class);
+
+                // install probe bundle
+                container.install(probeBuilder.build().getStream());
+                // execute probe testMethod
+                container.call(address);
+
+            } catch (RuntimeException | IOException e) {
+                throw new MojoExecutionException(e);
+            } finally {
+                if (containerStarted.get()) {
+                    container.stop();
+                }
+                // restore stdout
+                System.setOut(stdout);
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/features-test-plugin/src/main/java/org/opendaylight/odlparent/features/test/plugin/PaxOptionUtils.java b/features-test-plugin/src/main/java/org/opendaylight/odlparent/features/test/plugin/PaxOptionUtils.java
new file mode 100644 (file)
index 0000000..3b37a8f
--- /dev/null
@@ -0,0 +1,169 @@
+/*
+ * Copyright (c) 2023 PANTHEON.tech s.r.o. and others. All rights reserved.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v1.0 which accompanies this distribution,
+ * and is available at http://www.eclipse.org/legal/epl-v10.html
+ */
+package org.opendaylight.odlparent.features.test.plugin;
+
+import static org.opendaylight.odlparent.features.test.plugin.DependencyUtils.KARAF_VERSION;
+import static org.ops4j.pax.exam.CoreOptions.bootDelegationPackages;
+import static org.ops4j.pax.exam.CoreOptions.maven;
+import static org.ops4j.pax.exam.CoreOptions.propagateSystemProperties;
+import static org.ops4j.pax.exam.CoreOptions.systemPackages;
+import static org.ops4j.pax.exam.CoreOptions.when;
+import static org.ops4j.pax.exam.karaf.options.KarafDistributionOption.configureConsole;
+import static org.ops4j.pax.exam.karaf.options.KarafDistributionOption.editConfigurationFilePut;
+import static org.ops4j.pax.exam.karaf.options.KarafDistributionOption.features;
+import static org.ops4j.pax.exam.karaf.options.KarafDistributionOption.karafDistributionConfiguration;
+import static org.ops4j.pax.exam.karaf.options.KarafDistributionOption.keepRuntimeFolder;
+import static org.ops4j.pax.exam.karaf.options.KarafDistributionOption.logLevel;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.Collection;
+import org.apache.maven.plugin.MojoExecutionException;
+import org.eclipse.aether.artifact.Artifact;
+import org.ops4j.pax.exam.Option;
+import org.ops4j.pax.exam.karaf.options.LogLevelOption;
+import org.ops4j.pax.exam.options.MavenArtifactUrlReference;
+import org.ops4j.pax.exam.options.extra.VMOption;
+
+final class PaxOptionUtils {
+
+    private PaxOptionUtils() {
+        // utility class
+    }
+
+    public static Option[] vmOptions(final String maxHeap, final String heapDumpPath) {
+        return new Option[]{
+            new VMOption("-Xmx" + maxHeap),
+            new VMOption("-XX:+HeapDumpOnOutOfMemoryError"),
+            new VMOption("-XX:HeapDumpPath=" + heapDumpPath),
+            // inspired by org.apache.commons.lang.SystemUtils
+            when("Linux".equals(System.getProperty("os.name"))).useOptions(
+                // This prevents low entropy issues on Linux to affect Java random numbers
+                // which can block crypto such as the SSH server in netconf
+                // see https://jira.opendaylight.org/browse/ODLPARENT-49
+                new VMOption("-Djava.security.egd=file:/dev/./urandom")
+            ),
+            new VMOption("--add-reads=java.xml=java.logging"),
+            new VMOption("--add-exports=java.base/org.apache.karaf.specs.locator=java.xml,ALL-UNNAMED"),
+            new VMOption("--patch-module"),
+            new VMOption("java.base=lib/endorsed/org.apache.karaf.specs.locator-" + KARAF_VERSION + ".jar"),
+            new VMOption("--patch-module"),
+            new VMOption("java.xml=lib/endorsed/org.apache.karaf.specs.java.xml-" + KARAF_VERSION + ".jar"),
+            new VMOption("--add-opens"),
+            new VMOption("java.base/java.security=ALL-UNNAMED"),
+            new VMOption("--add-opens"),
+            new VMOption("java.base/java.net=ALL-UNNAMED"),
+            new VMOption("--add-opens"),
+            new VMOption("java.base/java.lang=ALL-UNNAMED"),
+            new VMOption("--add-opens"),
+            new VMOption("java.base/java.util=ALL-UNNAMED"),
+            new VMOption("--add-opens"),
+            new VMOption("java.naming/javax.naming.spi=ALL-UNNAMED"),
+            new VMOption("--add-opens"),
+            new VMOption("java.rmi/sun.rmi.transport.tcp=ALL-UNNAMED"),
+            new VMOption("--add-exports=java.base/sun.net.www.protocol.file=ALL-UNNAMED"),
+            new VMOption("--add-exports=java.base/sun.net.www.protocol.ftp=ALL-UNNAMED"),
+            new VMOption("--add-exports=java.base/sun.net.www.protocol.http=ALL-UNNAMED"),
+            new VMOption("--add-exports=java.base/sun.net.www.protocol.https=ALL-UNNAMED"),
+            new VMOption("--add-exports=java.base/sun.net.www.protocol.jar=ALL-UNNAMED"),
+            new VMOption("--add-exports=java.base/sun.net.www.content.text=ALL-UNNAMED"),
+            new VMOption("--add-exports=jdk.naming.rmi/com.sun.jndi.url.rmi=ALL-UNNAMED"),
+            new VMOption("--add-exports=java.rmi/sun.rmi.registry=ALL-UNNAMED"),
+            new VMOption("-classpath"),
+            new VMOption("lib/jdk9plus/*" + File.pathSeparator + "lib/boot/*"
+                + File.pathSeparator + "lib/endorsed/*")
+        };
+    }
+
+    static Option[] profileOptions(final boolean profile) throws MojoExecutionException {
+        if (profile) {
+            try {
+                final var jfrFile = Files.createTempFile("SingleFeatureTest-Karaf-JavaFlightRecorder", ".jfr");
+                return new Option[]{
+                    new VMOption("-XX:StartFlightRecording=disk=true,settings=profile,dumponexit=true,filename="
+                        + jfrFile.toAbsolutePath()),
+                    bootDelegationPackages("jdk.jfr", "jdk.jfr.consumer", "jdk.jfr.event", "jdk.jfr.event.handlers",
+                        "jdk.jfr.internal.*"),
+                };
+            } catch (IOException e) {
+                throw new MojoExecutionException("Failed to create JFR file", e);
+            }
+        }
+        return new Option[0];
+    }
+
+    static Option[] miscOptions() {
+        return new Option[]{
+            // Needed for Agrona/aeron.io
+            systemPackages("com.sun.media.sound", "sun.net", "sun.nio.ch")
+        };
+    }
+
+    static Option[] karafDistroOptions(final String url, final boolean keepUnpack, final String buildDir) {
+        return new Option[]{
+            karafDistributionConfiguration().frameworkUrl(url)
+                .name("OpenDaylight")
+                .unpackDirectory(new File(buildDir, "pax"))
+                .useDeployFolder(false),
+            when(keepUnpack).useOptions(keepRuntimeFolder()),
+//            configureSecurity().disableKarafMBeanServerBuilder(),
+            configureConsole().ignoreLocalConsole().ignoreRemoteShell(),
+        };
+    }
+
+    static Option[] karafConfigOptions(final String buildDir, final String localRepository)
+            throws MojoExecutionException {
+        final var karafLogPath = Path.of(buildDir, "SFT", "karaf.log");
+        try {
+            Files.createDirectories(karafLogPath.getParent());
+        } catch (IOException e) {
+            throw new MojoExecutionException("Failed to create log directory", e);
+        }
+        return new Option[]{
+            logLevel(LogLevelOption.LogLevel.INFO),
+            // Make sure karaf's default repository is consulted before anything else
+            editConfigurationFilePut("etc/org.ops4j.pax.url.mvn.cfg", "org.ops4j.pax.url.mvn.defaultRepositories",
+                "file:${karaf.home}/${karaf.default.repository}@id=system.repository"),
+            // remote repository, exclude snapshots
+            editConfigurationFilePut("etc/org.ops4j.pax.url.mvn.cfg", "org.ops4j.pax.url.mvn.repositories",
+                "https://repo1.maven.org/maven2@id=central"),
+            // local repository
+            editConfigurationFilePut("etc/org.ops4j.pax.url.mvn.cfg", "org.ops4j.pax.url.mvn.localRepository",
+                localRepository),
+            // redirect karaf log output
+            editConfigurationFilePut("etc/org.ops4j.pax.logging.cfg", "log4j2.appender.rolling.fileName",
+                karafLogPath.toString()),
+            editConfigurationFilePut("etc/org.ops4j.pax.logging.cfg", "log4j2.appender.rolling.filePattern",
+                karafLogPath + ".%i")
+        };
+    }
+
+    static Option[] dependencyFeaturesOptions(final Collection<FeatureDependency> featureDependencies) {
+        return featureDependencies == null ? new Option[0] :
+            featureDependencies.stream()
+                .map(fd -> features(urlReferenceOf(fd.artifact()), fd.featureNames().toArray(String[]::new)))
+                .toArray(Option[]::new);
+    }
+
+    static Option[] probePropertiesOptions() {
+        return new Option[]{propagateSystemProperties(TestProbe.ALL_PROPERTY_KEYS)};
+    }
+
+    private static MavenArtifactUrlReference urlReferenceOf(final Artifact artifact) {
+        return maven().groupId(artifact.getGroupId()).artifactId(artifact.getArtifactId())
+            .type(artifact.getExtension()).classifier(artifact.getClassifier())
+            .version(artifact.getVersion());
+    }
+
+    static Option[] concat(final Option[]... options) {
+        return Arrays.stream(options).flatMap(Arrays::stream).toArray(Option[]::new);
+    }
+}
\ No newline at end of file
diff --git a/features-test-plugin/src/main/java/org/opendaylight/odlparent/features/test/plugin/TestFeaturesMojo.java b/features-test-plugin/src/main/java/org/opendaylight/odlparent/features/test/plugin/TestFeaturesMojo.java
new file mode 100644 (file)
index 0000000..c4015c4
--- /dev/null
@@ -0,0 +1,209 @@
+/*
+ * Copyright (c) 2024 PANTHEON.tech s.r.o. and others. All rights reserved.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v1.0 which accompanies this distribution,
+ * and is available at http://www.eclipse.org/legal/epl-v10.html
+ */
+package org.opendaylight.odlparent.features.test.plugin;
+
+import static org.opendaylight.odlparent.features.test.plugin.PaxOptionUtils.concat;
+import static org.opendaylight.odlparent.features.test.plugin.PaxOptionUtils.dependencyFeaturesOptions;
+import static org.opendaylight.odlparent.features.test.plugin.PaxOptionUtils.karafConfigOptions;
+import static org.opendaylight.odlparent.features.test.plugin.PaxOptionUtils.karafDistroOptions;
+import static org.opendaylight.odlparent.features.test.plugin.PaxOptionUtils.miscOptions;
+import static org.opendaylight.odlparent.features.test.plugin.PaxOptionUtils.probePropertiesOptions;
+import static org.opendaylight.odlparent.features.test.plugin.PaxOptionUtils.profileOptions;
+import static org.opendaylight.odlparent.features.test.plugin.PaxOptionUtils.vmOptions;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+import org.apache.maven.execution.MavenSession;
+import org.apache.maven.plugin.AbstractMojo;
+import org.apache.maven.plugin.MojoExecutionException;
+import org.apache.maven.plugin.descriptor.PluginDescriptor;
+import org.apache.maven.plugins.annotations.Component;
+import org.apache.maven.plugins.annotations.LifecyclePhase;
+import org.apache.maven.plugins.annotations.Mojo;
+import org.apache.maven.plugins.annotations.Parameter;
+import org.apache.maven.plugins.annotations.ResolutionScope;
+import org.apache.maven.project.MavenProject;
+import org.eclipse.aether.RepositorySystem;
+import org.eclipse.aether.RepositorySystemSession;
+import org.eclipse.aether.repository.RemoteRepository;
+import org.ops4j.pax.exam.ExamSystem;
+import org.ops4j.pax.exam.karaf.container.internal.KarafTestContainerFactory;
+import org.ops4j.pax.exam.spi.PaxExamRuntime;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Feature test plugin mojo.
+ */
+@Mojo(name = "test", defaultPhase = LifecyclePhase.INTEGRATION_TEST,
+    requiresDependencyResolution = ResolutionScope.TEST,
+    requiresDependencyCollection = ResolutionScope.TEST, threadSafe = true)
+public final class TestFeaturesMojo extends AbstractMojo {
+
+    private static final Logger LOG = LoggerFactory.getLogger(TestFeaturesMojo.class);
+    private static final String[] FEATURE_FILENAMES = {"feature.xml", "features.xml"};
+    private static final PluginDescriptor STATIC_DESCRIPTOR = staticPluginDescriptor();
+
+    @Parameter(defaultValue = "${project}", readonly = true, required = true)
+    private MavenProject project;
+    @Parameter(defaultValue = "${session}", readonly = true, required = true)
+    private MavenSession session;
+    @Parameter(defaultValue = "${settings.localRepository}")
+    private String localRepository;
+    @Parameter(defaultValue = "${repositorySystemSession}", readonly = true, required = true)
+    private RepositorySystemSession repoSession;
+    @Parameter(defaultValue = "${project.remoteProjectRepositories}", readonly = true, required = true)
+    private List<RemoteRepository> repositories;
+    @Component
+    private RepositorySystem repoSystem;
+
+    @Parameter(property = "sft.skip", defaultValue = "false")
+    private boolean skip;
+    @Deprecated(since = "13.0.10", forRemoval = true)
+    @Parameter(property = "skip.karaf.featureTest", defaultValue = "false")
+    private boolean legacySkip;
+    @Parameter(property = "sft.concurrent", defaultValue = "false")
+    private boolean concurrent;
+
+    // bundle state check probe settings
+    @Parameter(property = "sft.diag.skip", defaultValue = "false")
+    private boolean bundleStateCheckSkip;
+    @Parameter(property = "sft.diag.timeout", defaultValue = "120")
+    private int bundleStateCheckTimeout;
+
+    // vm and profile options for karaf container
+    @Parameter(property = "sft.heap.max", defaultValue = "2g")
+    private String maxHeap;
+    @Parameter(property = "sft.heap.dump.path", defaultValue = "/dev/null")
+    private String heapDumpPath;
+    @Parameter(property = "karaf.featureTest.profile", defaultValue = "false")
+    private boolean profile;
+
+    // Backing distribution details
+    @Parameter(property = "karaf.distro.groupId", defaultValue = "org.opendaylight.odlparent")
+    private String distGroupId;
+    @Parameter(property = "karaf.distro.artifactId", defaultValue = "opendaylight-karaf-empty")
+    private String distArtifactId;
+    @Parameter(property = "karaf.distro.type", defaultValue = "tar.gz")
+    private String distType;
+    @Parameter(property = "karaf.keep.unpack", defaultValue = "false")
+    private boolean keepUnpack;
+
+    @Override
+    public void execute() throws MojoExecutionException {
+        if (skip) {
+            LOG.debug("Skipping execution");
+            return;
+        }
+        if (legacySkip) {
+            LOG.warn("Skipping execution due to legacy karaf.featureTest.skip, please migrate to sft.skip");
+            return;
+        }
+        if (!"feature".equals(project.getPackaging())) {
+            LOG.info("Project packaging is not 'feature', skipping execution");
+            return;
+        }
+
+        LOG.info("Starting SFT for {}:{}", project.getGroupId(), project.getArtifactId());
+
+        final var buildDir = project.getBuild().getDirectory();
+        final var featureFile = getFeatureFile(new File(buildDir + File.separator + "feature"));
+
+        // resolve dependencies (ensure all are in local repository)
+        final var resolver = new DependencyResolver(repoSystem, repoSession, repositories);
+        resolver.resolveFeatureFile(featureFile);
+
+        // using file:* url instead of mvn:* to avoid MalformedUrlException (unknown protocol 'mvn');
+        // no reason to involve external maven resolver (pax-exam-aether-url) to fetch distro artifact via maven,
+        // while we can use local repository file directly (URL has built-in handler for 'file' protocol)
+        final var karafDistroUrl = resolver.resolveKarafDistroUrl(distGroupId, distArtifactId, distType);
+        LOG.debug("Distro URL resolved: {}", karafDistroUrl);
+
+        // dependency features (incl test scope) to be pre-installed
+        final var pluginDependencyFeatures = resolver.resolvePluginFeatures();
+        final var projectDependencyFeatures = resolver.resolveFeatures(project.getArtifacts());
+        LOG.info("Project dependency features detected: {}", projectDependencyFeatures);
+
+        // pax exam options
+        final var options = concat(
+            vmOptions(maxHeap, heapDumpPath),
+            profileOptions(profile),
+            karafDistroOptions(karafDistroUrl, keepUnpack, buildDir),
+            karafConfigOptions(buildDir, localRepository),
+            dependencyFeaturesOptions(pluginDependencyFeatures),
+            dependencyFeaturesOptions(projectDependencyFeatures),
+            probePropertiesOptions(),
+            miscOptions()
+        );
+
+        // probe parameters
+        System.setProperty(TestProbe.FEATURE_FILE_URI_PROP, featureFile.toURI().toString());
+        System.setProperty(TestProbe.BUNDLE_CHECK_SKIP, String.valueOf(bundleStateCheckSkip));
+        System.setProperty(TestProbe.BUNDLE_CHECK_TIMEOUT_SECONDS, String.valueOf(bundleStateCheckTimeout));
+
+        final ExamSystem system;
+        try {
+            system = PaxExamRuntime.createTestSystem(options);
+        } catch (IOException e) {
+            throw new MojoExecutionException("Cannot create pax-exam system", e);
+        }
+
+        final var execution = new PaxExamExecution(localRepository, system,
+            new KarafTestContainerFactory().create(system));
+        if (concurrent) {
+            execution.execute();
+            return;
+        }
+
+        // We create a plugin context in the top-level project of the build. There we store a single object which acts
+        // as the global lock protecting execution.
+        final Map<String, Object> topContext;
+        synchronized (session) {
+            // This is as careful as we can be. We guard against concurrent executions on the same top-level project.
+            topContext = session.getPluginContext(STATIC_DESCRIPTOR, session.getTopLevelProject());
+        }
+
+        final var lock = (Lock) topContext.computeIfAbsent("lock", key -> new ReentrantLock());
+        LOG.debug("Using lock {}", lock);
+        try {
+            lock.lockInterruptibly();
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new MojoExecutionException("Interrupted while acquiring lock", e);
+        }
+
+        LOG.debug("Acquired lock {}", lock);
+        try {
+            execution.execute();
+        } finally {
+            lock.unlock();
+            LOG.debug("Released lock {}", lock);
+        }
+    }
+
+    private static PluginDescriptor staticPluginDescriptor() {
+        final var desc = new PluginDescriptor();
+        desc.setGroupId("org.opendaylight.odlparent");
+        desc.setArtifactId("features-test-plugin");
+        return desc;
+    }
+
+    static File getFeatureFile(final File dir) throws MojoExecutionException {
+        for (var filename : FEATURE_FILENAMES) {
+            final File file = new File(dir, filename);
+            if (file.exists()) {
+                return file;
+            }
+        }
+        throw new MojoExecutionException("No feature XML file found in " + dir);
+    }
+}
\ No newline at end of file
diff --git a/features-test-plugin/src/main/java/org/opendaylight/odlparent/features/test/plugin/TestProbe.java b/features-test-plugin/src/main/java/org/opendaylight/odlparent/features/test/plugin/TestProbe.java
new file mode 100644 (file)
index 0000000..a4c666e
--- /dev/null
@@ -0,0 +1,249 @@
+/*
+ * Copyright (c) 2024 PANTHEON.tech s.r.o. and others. All rights reserved.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v1.0 which accompanies this distribution,
+ * and is available at http://www.eclipse.org/legal/epl-v10.html
+ */
+package org.opendaylight.odlparent.features.test.plugin;
+
+import static org.apache.karaf.bundle.core.BundleState.Installed;
+import static org.apache.karaf.bundle.core.BundleState.Waiting;
+
+import com.google.common.base.Functions;
+import java.io.File;
+import java.net.URI;
+import java.util.Arrays;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.stream.Collectors;
+import javax.inject.Inject;
+import org.apache.karaf.bundle.core.BundleService;
+import org.apache.karaf.bundle.core.BundleState;
+import org.apache.karaf.features.FeaturesService;
+import org.junit.Test;
+import org.osgi.framework.Bundle;
+import org.osgi.framework.BundleContext;
+import org.osgi.framework.BundleListener;
+import org.osgi.framework.InvalidSyntaxException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The feature test probe artifact.
+ *
+ * <p>
+ * The class is being packaged and deployed to karaf environment on {@link PaxExamExecution#execute()} invocation.
+ * All dependencies which are absent on target environment expected to be packaged using same
+ * {@link org.ops4j.pax.exam.ProbeBuilder}. Input parameters are passed through system properties. in order to be
+ * delivered properly all affected properties require explicit declaration using associated Pax options -- see
+ * {@link PaxOptionUtils#probePropertiesOptions()}.
+ *
+ * <p>
+ * Pax Exam module references:
+ * <ul>
+ *     <li>Probe bundle deployment handling is served by pax-exam-extender-service</li>
+ *     <li>Service instances lookup and injection into probe instance is served by pax-exam-inject</li>
+ *     <li>Test method invocation is served by pax-exam-invoker-junit, uses JUnitCore v.4, which requires @Test
+ *     annotation for method to be eligible for invocation</li>
+ * </ul>
+ */
+public final class TestProbe {
+
+    static final String FEATURE_FILE_URI_PROP = "feature.test.file.uri";
+    static final String BUNDLE_CHECK_SKIP = "feature.test.bundle.check.skip";
+    static final String BUNDLE_CHECK_TIMEOUT_SECONDS = "feature.test.bundle.check.timeout.seconds";
+    static final String[] ALL_PROPERTY_KEYS =
+        {FEATURE_FILE_URI_PROP, BUNDLE_CHECK_SKIP, BUNDLE_CHECK_TIMEOUT_SECONDS};
+
+    private static final Logger LOG = LoggerFactory.getLogger(TestProbe.class);
+    private static final Map<String, BundleState> ELIGIBLE_STATES = Map.of(
+        "slf4j.log4j12", Installed,
+        "org.apache.karaf.scr.management", Waiting);
+
+    private final Map<Long, CheckResult> bundleCheckResults = new ConcurrentHashMap<>();
+    private final AtomicReference<CompletableFuture<CheckResult>> checkFutureRef = new AtomicReference<>();
+
+    @Inject
+    private BundleContext bundleContext;
+
+    @Inject
+    private FeaturesService featuresService;
+
+    @Inject
+    private BundleService bundleService;
+
+    /**
+     * Performs the project feature installation on karaf environment with subsequent state check of deployed bundles.
+     *
+     * @throws Exception on probe failure
+     */
+    @Test
+    @SuppressWarnings("IllegalCatch")
+    public void testFeature() throws Exception {
+        validateServices();
+        try {
+            installFeatures();
+            checkBundleStates();
+        } catch (Exception e) {
+            LOG.error("Exception executing feature test", e);
+            throw e;
+        }
+    }
+
+    private void validateServices() {
+        if (bundleContext == null) {
+            throw new IllegalStateException("bundleContext is not initialized");
+        }
+        // replace the probe's initial context which expires too fast
+        bundleContext = bundleContext.getBundle(0).getBundleContext();
+
+        if (featuresService == null) {
+            throw new IllegalStateException("featureService is not initialized");
+        }
+        if (bundleService == null) {
+            throw new IllegalStateException("bundleService is not initialized");
+        }
+    }
+
+    private void installFeatures() throws Exception {
+        final var featureUri = URI.create(System.getProperty(FEATURE_FILE_URI_PROP));
+        if (!new File(featureUri).exists()) {
+            throw new IllegalStateException("Feature file with URI " + featureUri + " does not exist");
+        }
+
+        // install repository the feature definition can be read from
+        featuresService.addRepository(featureUri);
+        LOG.info("Feature repository with URI: {} initialized", featureUri);
+
+        // install features
+        for (var feature : featuresService.getRepository(featureUri).getFeatures()) {
+            final var name = feature.getName();
+            final var version = feature.getVersion();
+            LOG.info("Installing feature: {}, {}", name, version);
+            featuresService.installFeature(name, version, EnumSet.of(FeaturesService.Option.Verbose));
+            LOG.info("Feature is installed: {}, isInstalled()={}, getState()={}",
+                name, featuresService.isInstalled(feature), featuresService.getState(feature.getId()));
+        }
+    }
+
+    private void checkBundleStates() throws InterruptedException, ExecutionException {
+        if ("true".equals(System.getProperty(BUNDLE_CHECK_SKIP))) {
+            return;
+        }
+        final int timeout = Integer.parseInt(System.getProperty(BUNDLE_CHECK_TIMEOUT_SECONDS, "600"));
+        LOG.info("Checking bundle states. Timeout is {} seconds.", timeout);
+
+        // start event based states collection
+        final BundleListener bundleListener = event -> {
+            captureBundleState(event.getBundle());
+            updateCheckResults();
+        };
+        bundleContext.addBundleListener(bundleListener);
+        // init all bundles state data
+        Arrays.stream(bundleContext.getBundles()).forEach(this::captureBundleState);
+        // enable stats analysis
+        checkFutureRef.set(new CompletableFuture<>());
+        // perform stats analysis
+        updateCheckResults();
+
+        final CheckResult result;
+        try {
+            result = checkFutureRef.get().get(timeout, TimeUnit.SECONDS);
+        } catch (TimeoutException e) {
+            logNokBundleDetails();
+            throw new IllegalStateException("Bundles states check was not completed in " + timeout + "seconds", e);
+        } finally {
+            bundleContext.removeBundleListener(bundleListener);
+        }
+        LOG.info("Bundle state check completed with result {}", result);
+        if (result != CheckResult.SUCCESS) {
+            logNokBundleDetails();
+            throw new IllegalStateException("Bundle states check failed");
+        }
+    }
+
+    private void captureBundleState(final Bundle bundle) {
+        if (bundle != null) {
+            final var info = bundleService.getInfo(bundle);
+            final var checkResult = checkResultOf(info.getSymbolicName(), info.getState());
+            LOG.info("Bundle state updated: {} -> {} ({})", info.getSymbolicName(), info.getState(), checkResult);
+            bundleCheckResults.put(bundle.getBundleId(), checkResult);
+        }
+    }
+
+    private void updateCheckResults() {
+        if (checkFutureRef.get() == null || checkFutureRef.get().isDone()) {
+            // don't check stats if results are not expected or already delivered
+            return;
+        }
+        final var resultStats = bundleCheckResults.entrySet().stream()
+            .collect(Collectors.groupingBy(Map.Entry::getValue, Collectors.counting()));
+        LOG.info("Bundle states check results: total={}, byResult={}", bundleCheckResults.size(), resultStats);
+
+        if (resultStats.getOrDefault(CheckResult.FAILURE, 0L) > 0) {
+            checkFutureRef.get().complete(CheckResult.FAILURE);
+        } else if (resultStats.getOrDefault(CheckResult.STOPPING, 0L) > 0) {
+            checkFutureRef.get().complete(CheckResult.STOPPING);
+        } else if (resultStats.getOrDefault(CheckResult.IN_PROGRESS, 0L) == 0) {
+            checkFutureRef.get().complete(CheckResult.SUCCESS);
+        }
+    }
+
+    private void logNokBundleDetails() {
+        final var nokBundles = bundleCheckResults.entrySet().stream()
+            .filter(entry -> CheckResult.SUCCESS != entry.getValue())
+            .map(Map.Entry::getKey).collect(Collectors.toSet());
+        // log NOK Bundles
+        for (var bundle : bundleContext.getBundles()) {
+            if (nokBundles.contains(bundle.getBundleId())) {
+                final var info = bundleService.getInfo(bundle);
+                LOG.warn("NOK Bundle {} -> State: {}", info.getSymbolicName(), info.getState());
+            }
+        }
+        // log services of NOK bundles
+        try {
+            for (var serviceRef : bundleContext.getAllServiceReferences(null, null)) {
+                if (serviceRef.getBundle() != null && nokBundles.contains(serviceRef.getBundle().getBundleId())) {
+                    final var bundle = serviceRef.getBundle();
+                    final var usingBundles = serviceRef.getUsingBundles() == null ? List.of() :
+                        Arrays.stream(serviceRef.getUsingBundles()).map(Bundle::getSymbolicName).toList();
+                    final var propKeys = serviceRef.getPropertyKeys();
+                    final var serviceProps = Arrays.stream(propKeys)
+                        .collect(Collectors.toMap(Functions.identity(), serviceRef::getProperty));
+                    LOG.warn("NOK Service {} -> of bundle: {}, using: {}, props: {}",
+                        serviceRef.getClass().getName(), bundle.getSymbolicName(), usingBundles, serviceProps);
+                }
+            }
+        } catch (InvalidSyntaxException e) {
+            LOG.warn("Error retrieving services", e);
+        }
+    }
+
+    static CheckResult checkResultOf(final String bundleName, final BundleState state) {
+        if (bundleName != null && state == ELIGIBLE_STATES.get(bundleName)) {
+            return CheckResult.SUCCESS;
+        }
+        if (state == BundleState.Stopping) {
+            return CheckResult.STOPPING;
+        }
+        if (state == BundleState.Failure) {
+            return CheckResult.FAILURE;
+        }
+        if (state == BundleState.Resolved || state == BundleState.Active) {
+            return CheckResult.SUCCESS;
+        }
+        return CheckResult.IN_PROGRESS;
+    }
+
+    enum CheckResult {
+        SUCCESS, FAILURE, STOPPING, IN_PROGRESS;
+    }
+}
\ No newline at end of file
diff --git a/features-test-plugin/src/main/resources/versions b/features-test-plugin/src/main/resources/versions
new file mode 100644 (file)
index 0000000..aeb371f
--- /dev/null
@@ -0,0 +1,3 @@
+release.version=${project.version}
+karaf.version=${karaf.version}
+pax.exam.version=${pax.exam.version}
index 58c86b117047084153a3886b5432226771fabba3..e3afa1292ffb418bdc8d180fd094e7edde8aa192 100644 (file)
                 <version>${project.version}</version>
                 <type>zip</type>
             </dependency>
+            <dependency>
+                <groupId>org.opendaylight.odlparent</groupId>
+                <artifactId>opendaylight-karaf-empty</artifactId>
+                <version>${project.version}</version>
+                <type>tar.gz</type>
+            </dependency>
             <dependency>
                 <groupId>org.opendaylight.odlparent</groupId>
                 <artifactId>bundles-test-lib</artifactId>
diff --git a/pom.xml b/pom.xml
index b29993554eab4e9aeabd7516c7365c5b4219e2c0..4bf7f581852ec386115099d62a1d5b27e866fe3e 100644 (file)
--- a/pom.xml
+++ b/pom.xml
@@ -41,6 +41,8 @@
         <module>bundles-test-lib</module>
         <module>bundles4-test</module>
         <module>features-test</module>
+        <module>features-test-plugin</module>
+        <module>features-test-plugin-it</module>
 
         <!-- Karaf integration -->
         <module>karaf</module>