Repository: apache/sling-org-apache-sling-jcr-packageinit
Pull request #11: SLING-12773 - JCR Packageinit should always install SNAPSHOT packages
Author: login: rombert
Created: 2025-05-02T20:44:04Z | Merged: 2025-05-09T08:28:35Z

Adjust the ExecutionPlanRepoInitializer to only store and check hashes for execution plans that do not reference SNAPSHOT packages.


Diff:
diff --git a/src/main/java/org/apache/sling/jcr/packageinit/impl/ExecutionPlanRepoInitializer.java b/src/main/java/org/apache/sling/jcr/packageinit/impl/ExecutionPlanRepoInitializer.java
index a864a31..5278570 100644
--- a/src/main/java/org/apache/sling/jcr/packageinit/impl/ExecutionPlanRepoInitializer.java
+++ b/src/main/java/org/apache/sling/jcr/packageinit/impl/ExecutionPlanRepoInitializer.java
@@ -20,19 +20,15 @@
 import java.io.BufferedWriter;
 import java.io.ByteArrayInputStream;
 import java.io.File;
-import java.io.FileNotFoundException;
 import java.io.FileReader;
 import java.io.FileWriter;
-import java.io.IOException;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Path;
 import java.nio.file.Paths;
-import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
-import java.util.stream.Collectors;
 
 import javax.jcr.Session;
 
@@ -60,7 +56,6 @@
 public class ExecutionPlanRepoInitializer implements SlingRepositoryInitializer {
 
     private static final String EXECUTEDPLANS_FILE = "executedplans.file";
-    private List<String> executionPlans = new ArrayList<>();
     
     private File statusFile;
 
@@ -74,6 +69,9 @@ public class ExecutionPlanRepoInitializer implements SlingRepositoryInitializer
 
         @AttributeDefinition
         String[] executionplans() default {};
+        
+        @AttributeDefinition
+        boolean reinstallSnapshots() default false;
     }
 
     /**
@@ -81,43 +79,12 @@ public class ExecutionPlanRepoInitializer implements SlingRepositoryInitializer
      */
     private final Logger logger = LoggerFactory.getLogger(getClass());
     private BundleContext context;
+    private Config config;
 
     @Activate
-    private void activate(BundleContext context, Config config) throws FileNotFoundException, IOException {
-        List<String> epCandidates = Arrays.asList(config.executionplans());
-        if (!epCandidates.isEmpty()) {
-            if (StringUtils.isEmpty(config.statusfilepath())) {
-                // if no path is configured lookup default file in bundledata
-                statusFile = context.getDataFile(EXECUTEDPLANS_FILE);
-            } else {
-                Path statusFilePath = Paths.get(config.statusfilepath());
-                if (statusFilePath.isAbsolute()) {
-                    // only absolute references are considered for lookup of
-                    // external statusfile
-                    statusFile = statusFilePath.toFile();
-                } else {
-                    throw new IllegalStateException("Only absolute paths supported");
-                }
-            }
-            if (statusFile.exists()) {
-                // in case statusFile already exists read all hashes
-                Set<Integer> executedHashes = new HashSet<>();
-                try (BufferedReader br = new BufferedReader(new FileReader(statusFile))) {
-                    for (String line; (line = br.readLine()) != null;) {
-                        executedHashes.add(Integer.parseInt(line));
-                    }
-                }
-                this.executionPlans.addAll(filterCandidates(epCandidates, executedHashes));
-            } else {
-               this.executionPlans.addAll(epCandidates);
-            }
-        }
+    private void activate(BundleContext context, Config config) {
         this.context = context;
-    }
-
-    private static List<String> filterCandidates(List<String> epCandidates, Set<Integer> executedHashes) {
-        // only add those candidates which have not been processed yet
-       return epCandidates.stream().filter(candidate -> !isCandidateProcessed(candidate, executedHashes)).collect(Collectors.toList());
+        this.config = config;
     }
 
     private static boolean isCandidateProcessed(String candidate, Set<Integer> executedHashes) {
@@ -126,7 +93,32 @@ private static boolean isCandidateProcessed(String candidate, Set<Integer> execu
 
     @Override
     public void processRepository(SlingRepository slingRepository) throws Exception {
-        if (!executionPlans.isEmpty()) {
+        
+        List<String> epCandidates = Arrays.asList(config.executionplans());
+        Set<Integer> executedHashes = new HashSet<>();
+        if (StringUtils.isEmpty(config.statusfilepath())) {
+            // if no path is configured lookup default file in bundledata
+            statusFile = context.getDataFile(EXECUTEDPLANS_FILE);
+        } else {
+            Path statusFilePath = Paths.get(config.statusfilepath());
+            if (statusFilePath.isAbsolute()) {
+                // only absolute references are considered for lookup of
+                // external statusfile
+                statusFile = statusFilePath.toFile();
+            } else {
+                throw new IllegalStateException("Only absolute paths supported");
+            }
+        }
+        if (statusFile.exists()) {
+            // in case statusFile already exists read all hashes
+            try (BufferedReader br = new BufferedReader(new FileReader(statusFile))) {
+                for (String line; (line = br.readLine()) != null;) {
+                    executedHashes.add(Integer.parseInt(line));
+                }
+            }
+        }
+        
+        if (!epCandidates.isEmpty()) {
             ServiceTracker<PackageRegistry, ?> st = new ServiceTracker<>(context, PackageRegistry.class, null);
             try {
                 st.open();
@@ -138,9 +130,15 @@ public void processRepository(SlingRepository slingRepository) throws Exception
                 @SuppressWarnings("deprecation")
                 Session session = slingRepository.loginAdministrative(null);
                 try (BufferedWriter writer = new BufferedWriter(new FileWriter(statusFile))) {
-                    for (String plan : executionPlans) {
+                    for (String plan : epCandidates) {
                         builder.load(new ByteArrayInputStream(plan.getBytes(StandardCharsets.UTF_8)));
                         builder.with(session);
+                        boolean planHasSnapshot = builder.preview().stream().anyMatch( p -> p.getVersionString().endsWith("-SNAPSHOT"));
+                        // by default check if either there are no SNAPSHOT versions ( e.g. all releases ) or if the config was set to not reinstall snapshots
+                        boolean checkIfCandidateWasProcessed = !planHasSnapshot || !config.reinstallSnapshots();
+                        if ( checkIfCandidateWasProcessed && isCandidateProcessed(plan, executedHashes)) {
+                            continue;
+                        }
                         ExecutionPlan xplan = builder.execute();
                         if (xplan.getTasks().size() > 0) {
                             if (xplan.hasErrors()) {
diff --git a/src/test/java/org/apache/sling/jcr/packageinit/ExecutionPlanRepoInitializerTest.java b/src/test/java/org/apache/sling/jcr/packageinit/ExecutionPlanRepoInitializerTest.java
index 45da686..c7a600c 100644
--- a/src/test/java/org/apache/sling/jcr/packageinit/ExecutionPlanRepoInitializerTest.java
+++ b/src/test/java/org/apache/sling/jcr/packageinit/ExecutionPlanRepoInitializerTest.java
@@ -30,7 +30,10 @@
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.StringWriter;
+import java.nio.file.Files;
 import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
 import java.util.Dictionary;
 import java.util.Hashtable;
 import java.util.Iterator;
@@ -44,7 +47,6 @@
 import javax.jcr.Session;
 
 import org.apache.commons.io.IOUtils;
-import org.apache.commons.lang.ArrayUtils;
 import org.apache.jackrabbit.vault.packaging.PackageException;
 import org.apache.jackrabbit.vault.packaging.PackageId;
 import org.apache.jackrabbit.vault.packaging.registry.ExecutionPlan;
@@ -52,7 +54,6 @@
 import org.apache.jackrabbit.vault.packaging.registry.PackageRegistry;
 import org.apache.jackrabbit.vault.packaging.registry.PackageTask;
 import org.apache.jackrabbit.vault.packaging.registry.PackageTask.State;
-import org.apache.jackrabbit.vault.packaging.registry.impl.FSPackageRegistry;
 import org.apache.sling.jcr.api.SlingRepository;
 import org.apache.sling.jcr.packageinit.impl.ExecutionPlanRepoInitializer;
 import org.apache.sling.testing.mock.osgi.MockOsgi;
@@ -65,7 +66,6 @@
 import org.junit.runner.RunWith;
 import org.mockito.ArgumentCaptor;
 import org.mockito.Mock;
-import org.mockito.Spy;
 import org.mockito.junit.MockitoJUnitRunner;
 import org.slf4j.LoggerFactory;
 
@@ -85,8 +85,8 @@ public class ExecutionPlanRepoInitializerTest {
     static String EXECUTIONPLAN_2 =
             "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
                     "<executionPlan version=\"1.0\">\n" +
-                    "    <task cmd=\"extract\" packageId=\"my_packages:test_b:1.0\"/>\n" +
-                    "</executionPlan>\n";
+                    "    <task cmd=\"extract\" packageId=\"my_packages:test_b:1.0-SNAPSHOT\"/>\n" +
+                    "</executionPlan>\n";    
     
     static String[] EXECUTIONSPLANS = {EXECUTIONPLAN_1, EXECUTIONPLAN_2};
     
@@ -133,7 +133,7 @@ public void setup() throws IOException, PackageException, LoginException, Reposi
         listAppender = new ListAppender<>();
         listAppender.start();
         logger.addAppender(listAppender);
-        foundExceptions = new ArrayList<Exception>();
+        foundExceptions = new ArrayList<>();
         when(slingRepo.loginAdministrative(null)).thenReturn(adminSession);
     }
 
@@ -145,7 +145,7 @@ public void teardown() {
 
     @Test
     public void skipWithoutExecution() throws Exception {
-        ExecutionPlanRepoInitializer initializer = registerRepoInitializer(new String[]{});
+        ExecutionPlanRepoInitializer initializer = registerRepoInitializer(new String[]{}, false);
 
         initializer.processRepository(slingRepo);
         List<ILoggingEvent> logsList = listAppender.list;
@@ -156,7 +156,7 @@ public void skipWithoutExecution() throws Exception {
 
     @Test
     public void waitForRegistryAndInstall() throws Exception {
-        ExecutionPlanRepoInitializer initializer = registerRepoInitializer(EXECUTIONSPLANS);
+        ExecutionPlanRepoInitializer initializer = registerRepoInitializer(EXECUTIONSPLANS, false);
 
         CountDownLatch cdl = new CountDownLatch(1);
         processRepository(initializer, cdl, foundExceptions);
@@ -178,7 +178,7 @@ public void waitForRegistryAndInstall() throws Exception {
 
     @Test
     public void doubleExecute() throws Exception {
-        ExecutionPlanRepoInitializer initializer = registerRepoInitializer(EXECUTIONSPLANS);
+        ExecutionPlanRepoInitializer initializer = registerRepoInitializer(EXECUTIONSPLANS, false);
 
         CountDownLatch cdl = new CountDownLatch(1);
         processRepository(initializer, cdl, foundExceptions);
@@ -189,25 +189,26 @@ public void doubleExecute() throws Exception {
         context.bundleContext().registerService(PackageRegistry.class.getName(), registry, null);
         cdl.await(500, TimeUnit.MILLISECONDS);
         verify(builder, times(2)).load(captor.capture());
-        
+        verify(builder, times(2)).execute();
+
         // use different builder to reset captor
         when(registry.createExecutionPlan()).thenReturn(builder2);
         
         MockOsgi.deactivate(initializer, context.bundleContext());
         // 2nd time register in inverted order
-        initializer = registerRepoInitializer(new String[] { EXECUTIONPLAN_2, EXECUTIONPLAN_1 });
+        initializer = registerRepoInitializer(new String[] { EXECUTIONPLAN_2, EXECUTIONPLAN_1 }, false);
         cdl = new CountDownLatch(1);
         processRepository(initializer, cdl, foundExceptions);
         
         cdl.await(500, TimeUnit.MILLISECONDS);
-        verify(builder2, never()).load(captor.capture());
-
+        verify(builder2, times(2)).load(captor.capture());
+        verify(builder2, never()).execute();
     }
     
 
     @Test
     public void failOnError() throws Exception {
-        ExecutionPlanRepoInitializer initializer = registerRepoInitializer(EXECUTIONSPLANS);
+        ExecutionPlanRepoInitializer initializer = registerRepoInitializer(EXECUTIONSPLANS, false);
 
         CountDownLatch cdl = new CountDownLatch(1);
 
@@ -238,13 +239,54 @@ public void failOnError() throws Exception {
         assertEquals("PackageRegistry found - starting execution of execution plan", logsList.get(1)
             .getMessage());
     }
+    
+    @Test
+    public void skipPlanWithExistingHash() throws Exception {
+        
+        testSkipExecutionPlanWithSnapshots(false);
+    }
+
+    @Test
+    public void reinstallSnapshotIfRequestedEvenWithExistingHash() throws Exception {
+        
+        testSkipExecutionPlanWithSnapshots(true);
+    }
+    
+    private void testSkipExecutionPlanWithSnapshots(boolean reinstallSnapshots) throws Exception {
+        
+        // simulate previous installation by writing hashcodes to the status file
+        Files.write(statusFile.toPath(), Arrays.asList(EXECUTIONPLAN_1.hashCode() + "", EXECUTIONPLAN_2.hashCode() + ""));
+        
+        // configure builder to return a snapshot for the second execution plan
+        when(builder.preview())
+            .thenReturn(Collections.singleton(PackageId.fromString("my_packages:test_a:1.0")))
+            .thenReturn(Collections.singleton(PackageId.fromString("my_packages:test_b:1.0-SNAPSHOT")));
+        
+        context.bundleContext().registerService(PackageRegistry.class.getName(), registry, null);
+        
+        
+        ExecutionPlanRepoInitializer initializer = registerRepoInitializer(EXECUTIONSPLANS, reinstallSnapshots);
+        initializer.processRepository(slingRepo);
+        
+        ArgumentCaptor<InputStream> captor = ArgumentCaptor.forClass(InputStream.class);
+        
+        verify(builder, times(2)).load(captor.capture());
 
+        if (reinstallSnapshots) {
+            // only SNAPSHOT package is reinstalled
+            verify(builder, times(1)).execute();
+        } else {
+            // no packages are reinstalled
+            verify(builder, never()).execute();
+        }
+    }
 
-    private ExecutionPlanRepoInitializer registerRepoInitializer(String[] executionPlans) {
+    private ExecutionPlanRepoInitializer registerRepoInitializer(String[] executionPlans, boolean reinstallSnapshots) {
         ExecutionPlanRepoInitializer initializer = new ExecutionPlanRepoInitializer();
-        Dictionary<String, Object> props = new Hashtable<String, Object>();
+        Dictionary<String, Object> props = new Hashtable<>();
         props.put("executionplans", executionPlans);
         props.put("statusfilepath", statusFile.getAbsolutePath());
+        props.put("reinstallSnapshots", reinstallSnapshots);
         context.registerInjectActivateService(initializer, props);
         return initializer;
     }


Reviews:
user: login: jsedding
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-05T15:16:09Z
user: login: rombert
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T15:22:51Z
user: login: jsedding
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-05T15:27:44Z

Review comments:
diff_hunk: @@ -157,9 +156,12 @@ public void processRepository(SlingRepository slingRepository) throws Exception
                             logger.info("No tasks found in execution plan - no additional packages installed.");
                         }
                         
-                        // save hashes to file for crosscheck on subsequent startup to avoid double processing
-                        writer.write(String.valueOf(plan.hashCode()));
-                        writer.newLine();
+                        if ( !hasSnapshot ) {
+                            // save hashes to file for crosscheck on subsequent startup to avoid double processing
+                            // but always process SNAPSHOT packages
+                            writer.write(String.valueOf(plan.hashCode()));
+                            writer.newLine();
+                        }
path: src/main/java/org/apache/sling/jcr/packageinit/impl/ExecutionPlanRepoInitializer.java
user: login: jsedding
body: Does this mean that _all_ packages are reinstalled if a snapshot package is part of the execution plan?
created_at: 2025-05-05T15:16:09Z
author_association: NONE
diff_hunk: @@ -157,9 +156,12 @@ public void processRepository(SlingRepository slingRepository) throws Exception
                             logger.info("No tasks found in execution plan - no additional packages installed.");
                         }
                         
-                        // save hashes to file for crosscheck on subsequent startup to avoid double processing
-                        writer.write(String.valueOf(plan.hashCode()));
-                        writer.newLine();
+                        if ( !hasSnapshot ) {
+                            // save hashes to file for crosscheck on subsequent startup to avoid double processing
+                            // but always process SNAPSHOT packages
+                            writer.write(String.valueOf(plan.hashCode()));
+                            writer.newLine();
+                        }
path: src/main/java/org/apache/sling/jcr/packageinit/impl/ExecutionPlanRepoInitializer.java
user: login: rombert
body: Yes. The alternative would be to generate separate execution plans but that is too invasive. Would it make sense to log a WARN here if we have mixed SNAPSHOT and release packages?
created_at: 2025-05-05T15:22:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -157,9 +156,12 @@ public void processRepository(SlingRepository slingRepository) throws Exception
                             logger.info("No tasks found in execution plan - no additional packages installed.");
                         }
                         
-                        // save hashes to file for crosscheck on subsequent startup to avoid double processing
-                        writer.write(String.valueOf(plan.hashCode()));
-                        writer.newLine();
+                        if ( !hasSnapshot ) {
+                            // save hashes to file for crosscheck on subsequent startup to avoid double processing
+                            // but always process SNAPSHOT packages
+                            writer.write(String.valueOf(plan.hashCode()));
+                            writer.newLine();
+                        }
path: src/main/java/org/apache/sling/jcr/packageinit/impl/ExecutionPlanRepoInitializer.java
user: login: jsedding
body: I don't know. I am not sufficiently familiar with how execution plans work. I am also not opposed to having a solution where all packages are reinstalled in this case. It _should_ not matter, because installing packages without snapshot qualifier should be a no-op. Of course, this is where hard to diagnose problems can be born. 🙂 
created_at: 2025-05-05T15:27:44Z
author_association: NONE

PR comments:
user: login: rombert
created_at: 2025-05-02T20:45:10Z
body: This is a very rough draft that works for my specific use case and I'm pushing it early for feedback, not sure if I'm going in the right direction. @DominikSuess - you're also knowledgeable in this area but can't add you as a reviewer; your input would be valuable.
author_association: CONTRIBUTOR
user: login: rombert
created_at: 2025-05-02T21:32:15Z
body: The test failures are expected because I did not adjust the test expectations

```
[ERROR] Failures: 
[ERROR]   ExecutionPlanRepoInitializerTest.doubleExecute:203 
builder2.load(<Capturing argument>);
Never wanted here:
-> at org.apache.sling.jcr.packageinit.ExecutionPlanRepoInitializerTest.doubleExecute(ExecutionPlanRepoInitializerTest.java:203)
But invoked here:
-> at org.apache.sling.jcr.packageinit.impl.ExecutionPlanRepoInitializer.processRepository(ExecutionPlanRepoInitializer.java:137)
-> at org.apache.sling.jcr.packageinit.impl.ExecutionPlanRepoInitializer.processRepository(ExecutionPlanRepoInitializer.java:137)
```
author_association: CONTRIBUTOR
user: login: jsedding
created_at: 2025-05-05T15:16:31Z
body: Looks reasonable so far.
author_association: NONE
user: login: sonarqubecloud[bot]
created_at: 2025-05-09T07:32:21Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=apache_sling-org-apache-sling-jcr-packageinit&pullRequest=11) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [1 New issue](https://sonarcloud.io/project/issues?id=apache_sling-org-apache-sling-jcr-packageinit&pullRequest=11&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=apache_sling-org-apache-sling-jcr-packageinit&pullRequest=11&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=apache_sling-org-apache-sling-jcr-packageinit&pullRequest=11&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [100.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=apache_sling-org-apache-sling-jcr-packageinit&pullRequest=11&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=apache_sling-org-apache-sling-jcr-packageinit&pullRequest=11&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=apache_sling-org-apache-sling-jcr-packageinit&pullRequest=11)


author_association: NONE