Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions biz.aQute.bndlib.tests/test/test/ProjectTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.jar.Manifest;
import java.util.regex.Pattern;

Expand All @@ -39,6 +42,7 @@
import aQute.bnd.osgi.Processor;
import aQute.bnd.osgi.Resource;
import aQute.bnd.osgi.eclipse.EclipseClasspath;
import aQute.bnd.service.JarLifecycleListener;
import aQute.bnd.service.RepositoryPlugin;
import aQute.bnd.service.Strategy;
import aQute.bnd.test.jupiter.InjectTemporaryDirectory;
Expand Down Expand Up @@ -546,6 +550,72 @@ private void stale(Project project, boolean b) throws Exception {
file.setLastModified(project.lastModified() + 10000);
}

/**
* Verify that JarLifecycleListener plugins are called at the right times.
*/
@Test
public void testJarLifecycleListenerIntegration() throws Exception {
Workspace ws = getWorkspace(IO.getFile("testresources/ws"));
Project project = ws.getProject("p-stale");
assertNotNull(project);

// Register a mock listener to verify it's called
AtomicBoolean beforeCalled = new AtomicBoolean();
AtomicBoolean afterCalled = new AtomicBoolean();

JarLifecycleListener mockListener = new JarLifecycleListener() {
@Override
public void beforeWrite(Project p, Jar jar, File outputFile, Map<String, Object> context) {
beforeCalled.set(true);
}

@Override
public void afterWrite(Project p, File outputFile, Jar jar, Map<String, Object> context) {
afterCalled.set(true);
}
};

ws.addBasicPlugin(mockListener);

// Build
File[] built = project.build();
assertNotNull(built);
assertThat(built.length).isGreaterThan(0);

// Verify listeners were called
assertThat(beforeCalled).isTrue();
assertThat(afterCalled).isTrue();
}

/**
* Verify that the context map is passed correctly and allows data exchange.
*/
Comment thread
chrisrueger marked this conversation as resolved.
@Test
public void testJarArtifactWriteContextDataExchange() throws Exception {
Workspace ws = getWorkspace(IO.getFile("testresources/ws"));
Project project = ws.getProject("p-stale");

AtomicReference<String> afterData = new AtomicReference<>();

JarLifecycleListener testListener = new JarLifecycleListener() {
@Override
public void beforeWrite(Project p, Jar jar, File outputFile, Map<String, Object> context) {
context.put("testKey", "testValue");
}

@Override
public void afterWrite(Project p, File outputFile, Jar jar, Map<String, Object> context) {
afterData.set((String) context.get("testKey"));
}
};

ws.addBasicPlugin(testListener);
project.build();

assertThat(afterData.get()).isEqualTo("testValue");
}


/**
* Check multiple repos
*
Expand Down
32 changes: 32 additions & 0 deletions biz.aQute.bndlib/src/aQute/bnd/build/Project.java
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@
import aQute.bnd.service.CommandPlugin;
import aQute.bnd.service.DependencyContributor;
import aQute.bnd.service.Deploy;
import aQute.bnd.service.JarLifecycleListener;
import aQute.bnd.service.RepositoryPlugin;
import aQute.bnd.service.RepositoryPlugin.PutOptions;
import aQute.bnd.service.RepositoryPlugin.PutResult;
Expand Down Expand Up @@ -2103,9 +2104,16 @@ public File saveBuild(Jar jar) throws Exception {
private File saveBuildWithoutClose(Jar jar) throws Exception {
File outputFile = getOutputFile(jar.getName(), jar.getVersion());

// Notify BEFORE listeners
Map<String, Object> context = new HashMap<String, Object>();
beforeJarWrite(jar, outputFile, context);

reportNewer(outputFile.lastModified(), jar);
File logicalFile = write(jar::write, outputFile);

// Notify AFTER listeners
afterJarWrite(this, outputFile, jar, context);

logger.debug("{} ({}) {}", jar.getName(), outputFile.getName(), jar.getResources()
.size());
//
Expand Down Expand Up @@ -2135,6 +2143,30 @@ private File saveBuildWithoutClose(Jar jar) throws Exception {
return logicalFile;
}

private void beforeJarWrite(Jar jar, File outputFile, Map<String, Object> context) throws Exception {
List<JarLifecycleListener> listeners = getPlugins(JarLifecycleListener.class);

for (JarLifecycleListener listener : listeners) {
try {
listener.beforeWrite(this, jar, outputFile, context);
} catch (Exception e) {
logger.debug("Exception in JarLifecycleListener.beforeWrite", e);
}
Comment thread
chrisrueger marked this conversation as resolved.
}
}

private void afterJarWrite(Project project, File outputFile, Jar jar, Map<String, Object> context)
throws Exception {
List<JarLifecycleListener> listeners = getPlugins(JarLifecycleListener.class);
for (JarLifecycleListener listener : listeners) {
try {
listener.afterWrite(project, outputFile, jar, context);
} catch (Exception e) {
logger.debug("Exception in JarArtifactLifecycleListener.afterJarWrite", e);
}
}
}

private File write(ConsumerWithException<File> jar, File outputFile)
throws IOException, InterruptedException, Exception {
File logicalFile = outputFile;
Expand Down
181 changes: 181 additions & 0 deletions biz.aQute.bndlib/src/aQute/bnd/service/JarLifecycleListener.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
package aQute.bnd.service;

import java.io.File;
import java.util.Map;

import aQute.bnd.build.Project;
import aQute.bnd.exceptions.ConsumerWithException;
import aQute.bnd.osgi.Jar;

/**
* A plugin interface for hooking into the JAR artifact lifecycle during project
* builds.
* <p>
* Implementations can observe and participate in key phases of JAR artifact
* handling: before the JAR is written to disk and after it has been written.
* This enables plugins to compute metadata, validate artifacts, sign JARs, or
* perform other side-effects without modifying the core build logic.
* <p>
* Listeners are called in order of registration. If one listener throws an
* exception, the exception is logged but does not prevent other listeners from
* running.
* <p>
* <h2>Thread Safety</h2>
* <p>
* Implementations must be thread-safe. Each JAR write receives a fresh context
* map, so listeners can use the context to pass data between the before and
* after phases without storing mutable state.
* <p>
* <h2>Typical Use Cases</h2>
* <ul>
* <li><b>Artifact metadata</b> – Compute and store digests, checksums, or
* signatures as sidecar files (e.g., `.digest`, `.asc`, `.md5`).</li>
* <li><b>Rebuild optimization</b> – Detect whether the JAR content or API has
* changed to avoid unnecessary cascade rebuilds in incremental build
* scenarios.</li>
* <li><b>JAR signing</b> – Sign the JAR post-write using cryptographic
* keys.</li>
* <li><b>Build artifacts indexing</b> – Log or index built artifacts in a
* repository or build database.</li>
* <li><b>Binary validation</b> – Post-write validation or security
* scanning.</li>
* <li><b>Bytecode enhancement</b> – Transform JAR contents before write (e.g.,
* AspectJ weaving, code coverage instrumentation).</li>
* </ul>
* <p>
* <h2>Usage Example</h2>
* <p>
* To compute and store a digest before writing, then validate and restore a
* timestamp after writing:
* <p>
*
* <pre>
* public class DigestListener implements JarLifecycleListener {
*
* &#64;Override
* public void beforeWrite(Project project, Jar jar, File outputFile, Map&lt;String, Object&gt; context)
* throws Exception {
* // Compute digest before the JAR is written to disk
* byte[] digest = jar.getTimelessDigest();
* String digestHex = Hex.toHexString(digest);
* context.put("digestHex", digestHex);
* }
*
* &#64;Override
* public void afterWrite(Project project, File outputFile, Jar jar, Map&lt;String, Object&gt; context)
* throws Exception {
Comment thread
chrisrueger marked this conversation as resolved.
* // Retrieve the computed digest
* String digestHex = (String) context.get("digestHex");
*
* // Store it in a sidecar file
* File digestFile = new File(outputFile.getParentFile(), outputFile.getName() + ".digest");
* IO.store(digestHex, digestFile);
* }
* }
* </pre>
* <p>
* <h2>Context Map</h2>
* <p>
* The {@code context} parameter is a mutable map that persists across the
* before and after phases for a single JAR write. Listeners can use this to
* exchange data:
* <ul>
* <li>The {@code beforeJarWrite} phase can compute and store values in the
* context.</li>
* <li>The {@code afterJarWrite} phase can retrieve those values to perform
* post-write actions.</li>
* <li>Multiple listeners can coordinate through shared context keys (with
* appropriate synchronization if needed).</li>
* </ul>
* <p>
* Each JAR write receives a fresh context map; there is no data leakage between
* different JAR writes.
* <p>
* <h2>Order of Execution</h2>
* <ol>
* <li>All {@code beforeJarWrite} methods are invoked in registration
* order.</li>
* <li>The JAR is written to disk (via
* {@link Project#write(ConsumerWithException, File)}).</li>
* <li>All {@code afterJarWrite} methods are invoked in registration order.</li>
* </ol>
* <p>
* <h2>Error Handling</h2>
* <p>
* If a listener throws an exception:
* <ul>
* <li>The exception is caught and logged.</li>
* <li>Other listeners continue to run.</li>
* <li>The JAR write itself is not affected (exceptions do not roll back the
* write).</li>
* </ul>
* <p>
* Implementations should avoid throwing exceptions unless they represent truly
* exceptional conditions. Use logging for informational messages.
* <p>
*
* @see Project#saveBuild(Jar)
*/
public interface JarLifecycleListener {

/**
* Invoked before the JAR is written to the output file.
* <p>
* This phase is suitable for:
* <ul>
* <li>Computing metadata (digests, checksums, API signatures) that should
* be based on the in-memory JAR state.</li>
* <li>Preparing data structures for the post-write phase.</li>
* <li>Validating the JAR before it is persisted.</li>
* </ul>
* <p>
* The listener can store computed values in the {@code context} map for use
* in the {@link #afterWrite afterJarWrite} phase.
* <p>
*
* @param project the project being built
* @param jar the in-memory JAR object, before being written to disk
* @param outputFile the file where the JAR will be written
* @param context a mutable map for sharing data between before and after
* phases; persists across both phases for a single JAR write
* @throws Exception if an error occurs; the exception is logged but does
* not prevent other listeners or the JAR write from proceeding
*/
default void beforeWrite(Project project, Jar jar, File outputFile, Map<String, Object> context)
throws Exception {}

/**
* Invoked after the JAR has been successfully written to the output file.
* <p>
* This phase is suitable for:
* <ul>
* <li>Storing metadata (digests, signatures) in sidecar files.</li>
* <li>Modifying file properties (timestamps, permissions).</li>
* <li>Triggering post-write notifications or side-effects.</li>
* <li>Validating the written file.</li>
* </ul>
* <p>
* The listener can retrieve data computed in the {@link #beforeWrite
* beforeJarWrite} phase from the {@code context} map.
* <p>
* <b>Note:</b> The JAR object may be in a different state after writing
* (e.g., resources may have been closed). Listeners should not rely on the
* JAR being fully functional; use the {@code outputFile} and the
* {@code context} map instead.
* <p>
*
* @param project the project that was built
* @param outputFile the file to which the JAR was written; the file is
* guaranteed to exist on the filesystem
* @param jar the JAR object that was written (state may have changed)
* @param context the same mutable map passed to {@link #beforeWrite
* beforeJarWrite}, allowing access to data computed in that
* phase
* @throws Exception if an error occurs; the exception is logged but does
* not prevent other listeners from running
*/
default void afterWrite(Project project, File outputFile, Jar jar, Map<String, Object> context)
throws Exception {}


}
2 changes: 1 addition & 1 deletion biz.aQute.bndlib/src/aQute/bnd/service/package-info.java
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
@Version("4.10.0")
@Version("4.11.0")
package aQute.bnd.service;

import org.osgi.annotation.versioning.Version;
Original file line number Diff line number Diff line change
Expand Up @@ -307,9 +307,10 @@ protected IProject[] build(int kind, Map<String, String> args, IProgressMonitor
});
if (model.isCnf()) {
Job job = Job.create("refresh workspace", (m) -> {
model.getWorkspace()
.refresh(); // this is for bnd plugins built in
// cnf
Workspace wsttmp = model.getWorkspace();
wsttmp.refresh(); // this is for bnd plugins built
// in cnf
Central.applyRebuildTriggerPolicy(wsttmp);
});
job.schedule(10);
}
Expand Down
1 change: 1 addition & 0 deletions bndtools.builder/src/org/bndtools/builder/CnfWatcher.java
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ private void processEvent(IResourceChangeEvent event) {
public IStatus runInWorkspace(IProgressMonitor arg0) throws CoreException {
try {
workspace.refresh();
Central.applyRebuildTriggerPolicy(workspace);
BndtoolsBuilder.dirty.addAll(allProjects);
MarkerSupport ms = new MarkerSupport(cnfProject);
ms.deleteMarkers("*");
Expand Down
1 change: 1 addition & 0 deletions bndtools.core/bnd.bnd
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ Export-Package: org.osgi.service.metatype.annotations
org.eclipse.jdt.core.compiler.batch

-testpath: \
biz.aQute.bnd.test;version=project,\
slf4j.api,\
slf4j.simple,\
${junit},\
Expand Down
Loading
Loading