From 4718d2dd1679a8ffd92ae7c17955aae984ce6592 Mon Sep 17 00:00:00 2001 From: Tim Ward Date: Tue, 29 Apr 2025 15:08:55 +0100 Subject: [PATCH 1/2] Add a FeatureBundle manifest replacement extension This commit adds a utility extension which can be used to replace the manifest of a Feature Bundle being installed. This can be used, for example, to dynamically add a manifest to an artifact which isn't an OSGi bundle, or to correct some metadata. Signed-off-by: Tim Ward --- .licenserc.yaml | 1 + extensions/hash.checker/pom.xml | 2 +- extensions/manifest.replacer/.gitignore | 1 + extensions/manifest.replacer/pom.xml | 88 ++++++++ .../replacer/BundleManifestReplacer.java | 200 ++++++++++++++++++ .../replacer/BundleManifestReplacerTests.java | 158 ++++++++++++++ .../bundles/test-bundle/META-INF/MANIFEST.MF | 4 + .../bundles/test-bundle2/META-INF/MANIFEST.MF | 4 + .../test/resources/features/no-manifests.json | 21 ++ .../features/not-json-extension.json | 21 ++ .../resources/features/with-manifests.json | 24 +++ extensions/pom.xml | 1 + 12 files changed, 524 insertions(+), 1 deletion(-) create mode 100644 extensions/manifest.replacer/.gitignore create mode 100644 extensions/manifest.replacer/pom.xml create mode 100644 extensions/manifest.replacer/src/main/java/org/eclipse/osgi/technology/featurelauncher/extensions/manifest/replacer/BundleManifestReplacer.java create mode 100644 extensions/manifest.replacer/src/test/java/org/eclipse/osgi/technology/featurelauncher/extensions/manifest/replacer/BundleManifestReplacerTests.java create mode 100644 extensions/manifest.replacer/src/test/resources/bundles/test-bundle/META-INF/MANIFEST.MF create mode 100644 extensions/manifest.replacer/src/test/resources/bundles/test-bundle2/META-INF/MANIFEST.MF create mode 100644 extensions/manifest.replacer/src/test/resources/features/no-manifests.json create mode 100644 extensions/manifest.replacer/src/test/resources/features/not-json-extension.json create mode 100644 extensions/manifest.replacer/src/test/resources/features/with-manifests.json diff --git a/.licenserc.yaml b/.licenserc.yaml index 7047a9e..05e0773 100644 --- a/.licenserc.yaml +++ b/.licenserc.yaml @@ -52,5 +52,6 @@ header: - '**/resources/META-INF/services/**' - '**/bnd.bnd' - 'extensions/hash.checker/src/test/resources/**' + - 'extensions/manifest.replacer/src/test/resources/bundles/*/META-INF/MANIFEST.MF' comment: always diff --git a/extensions/hash.checker/pom.xml b/extensions/hash.checker/pom.xml index 2887b2f..69f1511 100644 --- a/extensions/hash.checker/pom.xml +++ b/extensions/hash.checker/pom.xml @@ -24,7 +24,7 @@ hash.checker jar - OSGi Feature Launcher Maven Artifact Repository - no framework dependencie + OSGi Feature Launcher Extensions - Bundle installation hash checker https://github.com/eclipse-osgi-technology/feature-launcher diff --git a/extensions/manifest.replacer/.gitignore b/extensions/manifest.replacer/.gitignore new file mode 100644 index 0000000..b83d222 --- /dev/null +++ b/extensions/manifest.replacer/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/extensions/manifest.replacer/pom.xml b/extensions/manifest.replacer/pom.xml new file mode 100644 index 0000000..cdcc56a --- /dev/null +++ b/extensions/manifest.replacer/pom.xml @@ -0,0 +1,88 @@ + + + + 4.0.0 + + + org.eclipse.osgi-technology.featurelauncher.extensions + extensions + 1.0.0-SNAPSHOT + + + manifest.replacer + jar + + OSGi Feature Launcher Extensions - Bundle Manifest Replacement + https://github.com/eclipse-osgi-technology/feature-launcher + + + true + + + + + org.osgi + org.osgi.service.featurelauncher + + + org.osgi + org.osgi.framework + + + + + jakarta.json + jakarta.json-api + + + org.eclipse.osgi-technology.featurelauncher.repository + spi + ${project.version} + + + org.slf4j + slf4j-api + + + org.glassfish + jakarta.json + + + org.apache.felix + org.apache.felix.feature + test + + + org.apache.felix + org.apache.felix.cm.json + test + + + org.eclipse.osgi-technology.featurelauncher + common + ${project.version} + test + + + + + + + biz.aQute.bnd + bnd-maven-plugin + + + + diff --git a/extensions/manifest.replacer/src/main/java/org/eclipse/osgi/technology/featurelauncher/extensions/manifest/replacer/BundleManifestReplacer.java b/extensions/manifest.replacer/src/main/java/org/eclipse/osgi/technology/featurelauncher/extensions/manifest/replacer/BundleManifestReplacer.java new file mode 100644 index 0000000..b3d0900 --- /dev/null +++ b/extensions/manifest.replacer/src/main/java/org/eclipse/osgi/technology/featurelauncher/extensions/manifest/replacer/BundleManifestReplacer.java @@ -0,0 +1,200 @@ +/** + * Copyright (c) 2025 Kentyou and others. + * All rights reserved. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Kentyou - initial implementation + */ +package org.eclipse.osgi.technology.featurelauncher.extensions.manifest.replacer; + +import java.io.BufferedOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.StringReader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.jar.Attributes; +import java.util.jar.JarEntry; +import java.util.jar.JarInputStream; +import java.util.jar.JarOutputStream; +import java.util.jar.Manifest; +import java.util.stream.Collectors; + +import org.eclipse.osgi.technology.featurelauncher.repository.spi.FileSystemRepository; +import org.osgi.service.feature.Feature; +import org.osgi.service.feature.FeatureBundle; +import org.osgi.service.feature.FeatureExtension; +import org.osgi.service.feature.FeatureExtension.Type; +import org.osgi.service.feature.ID; +import org.osgi.service.featurelauncher.decorator.AbandonOperationException; +import org.osgi.service.featurelauncher.decorator.DecoratorBuilderFactory; +import org.osgi.service.featurelauncher.decorator.FeatureExtensionHandler; +import org.osgi.service.featurelauncher.repository.ArtifactRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import jakarta.json.Json; +import jakarta.json.JsonObject; +import jakarta.json.JsonReader; + +public class BundleManifestReplacer implements FeatureExtensionHandler { + + /** + * The recommended extension name for this extension handler + */ + public static final String MANIFEST_REPLACER_EXTENSION_NAME = "eclipse.osgi.technology.manifest.replacer"; + + public static final String WORKING_DIRECTORY = "working_directory"; + + private static final Logger LOG = LoggerFactory.getLogger(BundleManifestReplacer.class); + + @Override + public Feature handle(Feature feature, FeatureExtension extension, + List repositories, + FeatureExtensionHandlerBuilder decoratedFeatureBuilder, DecoratorBuilderFactory factory) + throws AbandonOperationException { + if(!MANIFEST_REPLACER_EXTENSION_NAME.equals(extension.getName())) { + LOG.warn("The recommended extension name for using the manifest replacer is {}, but it is being called for extensions named {}"); + } + + if(extension.getType() != Type.JSON) { + LOG.error("The manifest replacer requires JSON configuration not {}", extension.getType()); + throw new AbandonOperationException("The configuration of the manifest replacer feature extension must be JSON."); + } + try { + JsonObject config; + String json = extension.getJSON(); + if(json == null || json.isBlank()) { + config = Json.createObjectBuilder().build(); + } else { + try (JsonReader reader = Json.createReader(new StringReader(json))) { + config = reader.readObject(); + }; + } + + Path baseFolder = Optional.ofNullable(config.getString(WORKING_DIRECTORY, null)) + .map(Paths::get) + .orElse(Files.createTempDirectory(MANIFEST_REPLACER_EXTENSION_NAME)) + .resolve(feature.getID().toString()); + + Map manifestReplacedBundles = new HashMap<>(); + + for (FeatureBundle fb : feature.getBundles()) { + ID fbId = fb.getID(); + Map manifest = fb.getMetadata().entrySet().stream() + .filter(e -> e.getKey().startsWith(MANIFEST_REPLACER_EXTENSION_NAME)) + .collect(Collectors.toMap( + e -> e.getKey().substring(MANIFEST_REPLACER_EXTENSION_NAME.length() + 1), + e -> e.getValue().toString())); + + if(manifest.isEmpty()) { + continue; + } + + Manifest m = new Manifest(); + Attributes a = m.getMainAttributes(); + a.put(Attributes.Name.MANIFEST_VERSION, "1.0"); + manifest.forEach((k,v) -> a.putValue(k,v)); + + Path outputPath = baseFolder + .resolve(fbId.getGroupId()) + .resolve(fbId.getArtifactId()) + .resolve(fbId.toString()); + Files.createDirectories(outputPath.getParent()); + manifestReplacedBundles.put(fbId, outputPath); + + try(JarInputStream is = new JarInputStream(repositories.stream() + .map(r -> r.getArtifact(fbId)) + .filter(Objects::nonNull) + .findFirst() + .orElseThrow(() -> new AbandonOperationException("Unable to locate feature bundle " + + fbId + " in a repository"))); + JarOutputStream os = new JarOutputStream( + new BufferedOutputStream(Files.newOutputStream(outputPath)), m)) { + + JarEntry je; + while((je = is.getNextJarEntry()) != null) { + if("META-INF/MANIFEST.MF".equals(je.getName())) { + continue; + } + os.putNextEntry(new JarEntry(je.getRealName())); + is.transferTo(os); + } + } catch (IOException ioe) { + throw new AbandonOperationException("Failed to generate jar with updated manifest for " + + fbId, ioe); + } + } + + if(!manifestReplacedBundles.isEmpty()) { + ArtifactRepository virtual = new ManifestReplacingArtifactRepository(feature.getID(), manifestReplacedBundles); + repositories.add(0, virtual); + } + } catch(IOException | RuntimeException e) { + throw new AbandonOperationException("Unable to process extension " + extension.getName() + + " for feature " + feature.getID()); + } + + return feature; + } + + private static class ManifestReplacingArtifactRepository implements ArtifactRepository, FileSystemRepository { + + private final ID featureId; + private final Map manifestReplacedBundles; + + public ManifestReplacingArtifactRepository(ID featureId, Map manifestReplacedBundles) { + this.featureId = featureId; + this.manifestReplacedBundles = Map.copyOf(manifestReplacedBundles); + } + + @Override + public InputStream getArtifactData(ID id) { + Path path = manifestReplacedBundles.get(id); + + if(path != null) { + try { + return Files.newInputStream(path); + } catch (IOException e) { + throw new RuntimeException("Failed to open manifest replaced file for feature bundle " + id); + } + } else { + return null; + } + } + + @Override + public String getName() { + return "Virtual repository for manifest replaced bundles in feature " + featureId; + } + + @Override + public Path getArtifactPath(ID id) { + return manifestReplacedBundles.get(id); + } + + @Override + public Path getLocalRepositoryPath() { + // We don't expose a root path + return null; + } + + @Override + public InputStream getArtifact(ID id) { + return getArtifactData(id); + } + + } +} diff --git a/extensions/manifest.replacer/src/test/java/org/eclipse/osgi/technology/featurelauncher/extensions/manifest/replacer/BundleManifestReplacerTests.java b/extensions/manifest.replacer/src/test/java/org/eclipse/osgi/technology/featurelauncher/extensions/manifest/replacer/BundleManifestReplacerTests.java new file mode 100644 index 0000000..bb5c01a --- /dev/null +++ b/extensions/manifest.replacer/src/test/java/org/eclipse/osgi/technology/featurelauncher/extensions/manifest/replacer/BundleManifestReplacerTests.java @@ -0,0 +1,158 @@ +/** + * Copyright (c) 2025 Kentyou and others. + * All rights reserved. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Kentyou - initial implementation + */ +package org.eclipse.osgi.technology.featurelauncher.extensions.manifest.replacer; + +import static org.eclipse.osgi.technology.featurelauncher.extensions.manifest.replacer.BundleManifestReplacer.MANIFEST_REPLACER_EXTENSION_NAME; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrowsExactly; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Properties; +import java.util.jar.Attributes; +import java.util.jar.JarEntry; +import java.util.jar.JarInputStream; +import java.util.jar.JarOutputStream; +import java.util.jar.Manifest; + +import org.apache.felix.feature.impl.FeatureServiceImpl; +import org.eclipse.osgi.technology.featurelauncher.common.decorator.impl.DecoratorBuilderFactoryImpl; +import org.eclipse.osgi.technology.featurelauncher.common.decorator.impl.FeatureExtensionHandlerBuilderImpl; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.osgi.service.feature.Feature; +import org.osgi.service.feature.FeatureBundle; +import org.osgi.service.feature.FeatureService; +import org.osgi.service.featurelauncher.decorator.AbandonOperationException; +import org.osgi.service.featurelauncher.decorator.FeatureExtensionHandler; +import org.osgi.service.featurelauncher.repository.ArtifactRepository; + +class BundleManifestReplacerTests { + + static final Path BUNDLES = Paths.get("src/test/resources/bundles"); + static final Path FEATURES = Paths.get("src/test/resources/features"); + static final Path OUTPUT = Paths.get("target/test-repo/"); + + static Properties aHashes; + static Properties eHashes; + + static ArtifactRepository ar; + + @BeforeAll + static void makeJars() throws IOException { + + Files.createDirectories(OUTPUT); + + Files.newDirectoryStream(BUNDLES) + .forEach(p -> { + if(Files.isDirectory(p)) { + try (InputStream is = Files.newInputStream(p.resolve("META-INF/MANIFEST.MF")); + JarOutputStream jos = new JarOutputStream( + Files.newOutputStream(OUTPUT.resolve(p.getFileName())))) { + jos.putNextEntry(new JarEntry("META-INF/MANIFEST.MF")); + is.transferTo(jos); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + }); + ar = id -> { + try { + return Files.newInputStream(OUTPUT.resolve(id.getArtifactId())); + } catch (IOException e) { + e.printStackTrace(); + return null; + } + }; + } + + FeatureService featureService = new FeatureServiceImpl(); + + @Test + void noManifests() throws Exception { + doChecks("no-manifests.json", "test", "1.2.3", "test2", "4.5.6", false); + } + + @Test + void withManifests() throws Exception { + doChecks("with-manifests.json", "test-update", "2.3.4", "test2", "4.5.6", true); + } + + @Test + void notJson() throws Exception { + checkValidateFails("not-json-extension.json"); + } + + + void doChecks(String file, String symbolicName1, String version1, String symbolicName2, String version2, + boolean listChange) throws IOException, AbandonOperationException { + try (BufferedReader br = Files.newBufferedReader(FEATURES.resolve(file))) { + Feature feature = featureService.readFeature(br); + + FeatureExtensionHandler bundleManifestReplacer = new BundleManifestReplacer(); + + List list = new ArrayList(List.of(ar)); + + assertSame(feature, bundleManifestReplacer.handle(feature, + feature.getExtensions().get(MANIFEST_REPLACER_EXTENSION_NAME), + list, new FeatureExtensionHandlerBuilderImpl(featureService, feature), + new DecoratorBuilderFactoryImpl(featureService))); + + if(listChange) { + assertEquals(2, list.size()); + assertSame(ar, list.get(1)); + } else { + assertEquals(List.of(ar), list); + } + + checkFeatureBundle(symbolicName1, version1, list, feature.getBundles().get(0)); + checkFeatureBundle(symbolicName2, version2, list, feature.getBundles().get(1)); + }; + } + + + void checkFeatureBundle(String symbolicName1, String version1, List list, FeatureBundle fb) + throws IOException { + try (JarInputStream jis = new JarInputStream( + list.stream().map(a -> a.getArtifact(fb.getID())).filter(Objects::nonNull).findFirst().get())) { + Manifest manifest = jis.getManifest(); + Attributes attributes = manifest.getMainAttributes(); + assertEquals(symbolicName1, attributes.getValue("Bundle-SymbolicName")); + assertEquals(version1, attributes.getValue("Bundle-Version")); + } + } + + void checkValidateFails(String file) throws IOException, AbandonOperationException { + try (BufferedReader br = Files.newBufferedReader(FEATURES.resolve(file))) { + Feature feature = featureService.readFeature(br); + + FeatureExtensionHandler bundleManifestReplacer = new BundleManifestReplacer(); + + List list = new ArrayList(List.of(ar)); + + assertThrowsExactly(AbandonOperationException.class, () -> bundleManifestReplacer.handle(feature, + feature.getExtensions().get(MANIFEST_REPLACER_EXTENSION_NAME), + list, new FeatureExtensionHandlerBuilderImpl(featureService, feature), + new DecoratorBuilderFactoryImpl(featureService))); + }; + } +} diff --git a/extensions/manifest.replacer/src/test/resources/bundles/test-bundle/META-INF/MANIFEST.MF b/extensions/manifest.replacer/src/test/resources/bundles/test-bundle/META-INF/MANIFEST.MF new file mode 100644 index 0000000..0c4f2f0 --- /dev/null +++ b/extensions/manifest.replacer/src/test/resources/bundles/test-bundle/META-INF/MANIFEST.MF @@ -0,0 +1,4 @@ +Manifest-Version: 1.0 +Bundle-ManifestVersion: 2 +Bundle-SymbolicName: test +Bundle-Version: 1.2.3 diff --git a/extensions/manifest.replacer/src/test/resources/bundles/test-bundle2/META-INF/MANIFEST.MF b/extensions/manifest.replacer/src/test/resources/bundles/test-bundle2/META-INF/MANIFEST.MF new file mode 100644 index 0000000..8daedb8 --- /dev/null +++ b/extensions/manifest.replacer/src/test/resources/bundles/test-bundle2/META-INF/MANIFEST.MF @@ -0,0 +1,4 @@ +Manifest-Version: 1.0 +Bundle-ManifestVersion: 2 +Bundle-SymbolicName: test2 +Bundle-Version: 4.5.6 diff --git a/extensions/manifest.replacer/src/test/resources/features/no-manifests.json b/extensions/manifest.replacer/src/test/resources/features/no-manifests.json new file mode 100644 index 0000000..089482d --- /dev/null +++ b/extensions/manifest.replacer/src/test/resources/features/no-manifests.json @@ -0,0 +1,21 @@ +{ + "id": "org.eclipse.osgi.technology.featurelauncher.extensions.manifest.replacer.test:no-manifests:1.0", + "name": "No manifests", + "description": "No manifests Feature with empty extension config", + "complete": true, + "bundles": [ + { + "id": "org.eclipse.osgi.technology.featurelauncher.extensions.manifest.replacer.test:test-bundle:0.0.1" + }, + { + "id": "org.eclipse.osgi.technology.featurelauncher.extensions.manifest.replacer.test:test-bundle2:0.0.1" + } + ], + "extensions": { + "eclipse.osgi.technology.manifest.replacer": { + "kind": "mandatory", + "type": "json", + "json": {} + } + } +} diff --git a/extensions/manifest.replacer/src/test/resources/features/not-json-extension.json b/extensions/manifest.replacer/src/test/resources/features/not-json-extension.json new file mode 100644 index 0000000..484c009 --- /dev/null +++ b/extensions/manifest.replacer/src/test/resources/features/not-json-extension.json @@ -0,0 +1,21 @@ +{ + "id": "org.eclipse.osgi.technology.featurelauncher.extensions.manifest.replacer.test:not-json:1.0", + "name": "No hashes", + "description": "No manifests Feature with invalid extension config", + "complete": true, + "bundles": [ + { + "id": "org.eclipse.osgi.technology.featurelauncher.extensions.manifest.replacer.test:test-bundle:0.0.1" + }, + { + "id": "org.eclipse.osgi.technology.featurelauncher.extensions.manifest.replacer.test:test-bundle2:0.0.1" + } + ], + "extensions": { + "eclipse.osgi.technology.manifest.replacer": { + "kind": "mandatory", + "type": "text", + "text": ["Foobar"] + } + } +} diff --git a/extensions/manifest.replacer/src/test/resources/features/with-manifests.json b/extensions/manifest.replacer/src/test/resources/features/with-manifests.json new file mode 100644 index 0000000..f3876d6 --- /dev/null +++ b/extensions/manifest.replacer/src/test/resources/features/with-manifests.json @@ -0,0 +1,24 @@ +{ + "id": "org.eclipse.osgi.technology.featurelauncher.extensions.manifest.replacer.test:with-manifests:1.0", + "name": "With manifests", + "description": "With Manifests Feature with empty extension config", + "complete": true, + "bundles": [ + { + "id": "org.eclipse.osgi.technology.featurelauncher.extensions.manifest.replacer.test:test-bundle:0.0.1", + "eclipse.osgi.technology.manifest.replacer.Bundle-ManifestVersion": "2", + "eclipse.osgi.technology.manifest.replacer.Bundle-SymbolicName": "test-update", + "eclipse.osgi.technology.manifest.replacer.Bundle-Version": "2.3.4" + }, + { + "id": "org.eclipse.osgi.technology.featurelauncher.extensions.manifest.replacer.test:test-bundle2:0.0.1" + } + ], + "extensions": { + "eclipse.osgi.technology.manifest.replacer": { + "kind": "mandatory", + "type": "json", + "json": {} + } + } +} diff --git a/extensions/pom.xml b/extensions/pom.xml index 4849566..4d7f1fe 100644 --- a/extensions/pom.xml +++ b/extensions/pom.xml @@ -30,6 +30,7 @@ hash.checker + manifest.replacer From d26ba26bcb4c76091d03299ce7f11117ff946818 Mon Sep 17 00:00:00 2001 From: Tim Ward Date: Tue, 10 Jun 2025 09:30:01 +0200 Subject: [PATCH 2/2] Fix review comments Use a more meaningful name in the test Feature JSON file --- .../src/test/resources/features/not-json-extension.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/manifest.replacer/src/test/resources/features/not-json-extension.json b/extensions/manifest.replacer/src/test/resources/features/not-json-extension.json index 484c009..2c93261 100644 --- a/extensions/manifest.replacer/src/test/resources/features/not-json-extension.json +++ b/extensions/manifest.replacer/src/test/resources/features/not-json-extension.json @@ -1,6 +1,6 @@ { "id": "org.eclipse.osgi.technology.featurelauncher.extensions.manifest.replacer.test:not-json:1.0", - "name": "No hashes", + "name": "Wrong extension type", "description": "No manifests Feature with invalid extension config", "complete": true, "bundles": [