|
| 1 | +/******************************************************************************* |
| 2 | + * Copyright (c) 2025 Christoph Läubrich and others. |
| 3 | + * This program and the accompanying materials |
| 4 | + * are made available under the terms of the Eclipse Public License 2.0 |
| 5 | + * which accompanies this distribution, and is available at |
| 6 | + * https://www.eclipse.org/legal/epl-2.0/ |
| 7 | + * |
| 8 | + * SPDX-License-Identifier: EPL-2.0 |
| 9 | + * |
| 10 | + * Contributors: |
| 11 | + * Christoph Läubrich - initial API and implementation |
| 12 | + ******************************************************************************/ |
| 13 | +package org.eclipse.tycho.wrap; |
| 14 | + |
| 15 | +import java.io.File; |
| 16 | +import java.io.IOException; |
| 17 | +import java.util.Comparator; |
| 18 | +import java.util.LinkedHashMap; |
| 19 | +import java.util.List; |
| 20 | +import java.util.Map; |
| 21 | +import java.util.Map.Entry; |
| 22 | +import java.util.Optional; |
| 23 | +import java.util.Set; |
| 24 | +import java.util.TreeMap; |
| 25 | +import java.util.function.Predicate; |
| 26 | +import java.util.jar.JarFile; |
| 27 | +import java.util.jar.Manifest; |
| 28 | + |
| 29 | +import org.apache.felix.resolver.Util; |
| 30 | +import org.apache.maven.artifact.Artifact; |
| 31 | +import org.apache.maven.plugin.AbstractMojo; |
| 32 | +import org.apache.maven.plugin.MojoExecutionException; |
| 33 | +import org.apache.maven.plugin.MojoFailureException; |
| 34 | +import org.apache.maven.plugin.logging.Log; |
| 35 | +import org.apache.maven.plugins.annotations.Component; |
| 36 | +import org.apache.maven.plugins.annotations.LifecyclePhase; |
| 37 | +import org.apache.maven.plugins.annotations.Mojo; |
| 38 | +import org.apache.maven.plugins.annotations.Parameter; |
| 39 | +import org.apache.maven.plugins.annotations.ResolutionScope; |
| 40 | +import org.apache.maven.project.MavenProject; |
| 41 | +import org.eclipse.osgi.container.ModuleContainer; |
| 42 | +import org.osgi.framework.Version; |
| 43 | +import org.osgi.framework.namespace.ExecutionEnvironmentNamespace; |
| 44 | +import org.osgi.framework.namespace.PackageNamespace; |
| 45 | +import org.osgi.resource.Capability; |
| 46 | +import org.osgi.resource.Requirement; |
| 47 | +import org.osgi.resource.Resource; |
| 48 | + |
| 49 | +import aQute.bnd.build.model.EE; |
| 50 | +import aQute.bnd.osgi.Analyzer; |
| 51 | +import aQute.bnd.osgi.Constants; |
| 52 | +import aQute.bnd.osgi.Jar; |
| 53 | +import aQute.bnd.osgi.resource.CapReqBuilder; |
| 54 | +import aQute.bnd.osgi.resource.ResourceBuilder; |
| 55 | +import aQute.bnd.osgi.resource.ResourceUtils; |
| 56 | + |
| 57 | +/** |
| 58 | + * This mojos takes the project artifact and verify it can be resolved inside |
| 59 | + * OSGiusing the projects dependency artifacts. |
| 60 | + */ |
| 61 | +@Mojo(name = "verify", requiresProject = true, threadSafe = true, defaultPhase = LifecyclePhase.VERIFY, requiresDependencyCollection = ResolutionScope.COMPILE_PLUS_RUNTIME) |
| 62 | +public class VerifyMojo extends AbstractMojo { |
| 63 | + |
| 64 | + private static final String CHECK = " ✓ "; |
| 65 | + private static final String FAIL = " ☹ "; |
| 66 | + private static final String WARN = " ⚠ "; |
| 67 | + |
| 68 | + @Component |
| 69 | + private MavenProject project; |
| 70 | + |
| 71 | + @Parameter(defaultValue = "jar") |
| 72 | + private Set<String> packaging; |
| 73 | + |
| 74 | + @Parameter |
| 75 | + private Set<String> ignored; |
| 76 | + |
| 77 | + @Override |
| 78 | + public void execute() throws MojoExecutionException, MojoFailureException { |
| 79 | + Log log = getLog(); |
| 80 | + if (!packaging.contains(project.getPackaging())) { |
| 81 | + log.info("Skipped because package type does not match: " + project.getPackaging()); |
| 82 | + return; |
| 83 | + } |
| 84 | + Map<Artifact, Resource> map = analyzeArtifacts(); |
| 85 | + List<Capability> jvmCapabilities = getJVMResource().getCapabilities(null); |
| 86 | + Map<String, String> missingRequirements = new TreeMap<>(); |
| 87 | + int ignoredProblems = 0; |
| 88 | + try { |
| 89 | + Resource resource = createProjectResource(); |
| 90 | + List<Requirement> requirements = resource.getRequirements(PackageNamespace.PACKAGE_NAMESPACE); |
| 91 | + if (requirements.isEmpty()) { |
| 92 | + log.info("It has requirements specified!"); |
| 93 | + } else { |
| 94 | + log.info("It has " + requirements.size() + " requirements:"); |
| 95 | + for (Requirement requirement : requirements) { |
| 96 | + String req = ModuleContainer.toString(requirement); |
| 97 | + Predicate<Capability> matcher = ResourceUtils.matcher(requirement); |
| 98 | + if (jvmCapabilities.stream().anyMatch(matcher)) { |
| 99 | + log.info(CHECK + req + " (provided by the JVM)"); |
| 100 | + } else { |
| 101 | + Optional<Entry<Artifact, Resource>> artifactMatch = map.entrySet().stream() |
| 102 | + .filter(entry -> entry.getValue().getCapabilities(null).stream().anyMatch(matcher)) |
| 103 | + .sorted(new Comparator<Entry<Artifact, Resource>>() { |
| 104 | + |
| 105 | + @Override |
| 106 | + public int compare(Entry<Artifact, Resource> o1, Entry<Artifact, Resource> o2) { |
| 107 | + boolean wr1 = o1.getValue() instanceof WrappedResource; |
| 108 | + boolean wr2 = o2.getValue() instanceof WrappedResource; |
| 109 | + if (wr1 == wr2) { |
| 110 | + return 0; |
| 111 | + } |
| 112 | + if (wr1) { |
| 113 | + return -1; |
| 114 | + } |
| 115 | + if (wr2) { |
| 116 | + return 1; |
| 117 | + } |
| 118 | + return 0; |
| 119 | + } |
| 120 | + }).findFirst(); |
| 121 | + if (artifactMatch.isEmpty()) { |
| 122 | + log.info(FAIL + req + " not found in the artifacts of the current project!"); |
| 123 | + if (!Util.isOptional(requirement)) { |
| 124 | + if (isIgnored(req)) { |
| 125 | + ignoredProblems++; |
| 126 | + } else { |
| 127 | + missingRequirements.put(req, |
| 128 | + """ |
| 129 | + Seems not provided anywhere in the project artifacts! |
| 130 | +
|
| 131 | + You can exclude the import if this is satisfied otherwise or make it optional if it is provided by some other ways. |
| 132 | + """); |
| 133 | + } |
| 134 | + } |
| 135 | + } else { |
| 136 | + Entry<Artifact, Resource> entry = artifactMatch.get(); |
| 137 | + if (entry.getValue() instanceof WrappedResource) { |
| 138 | + log.info(WARN + req + " (can be provided by " + entry.getKey().getId() |
| 139 | + + " but artifact is not an OSGi bundle!)"); |
| 140 | + if (!Util.isOptional(requirement)) { |
| 141 | + if (isIgnored(req)) { |
| 142 | + ignoredProblems++; |
| 143 | + } else { |
| 144 | + missingRequirements.put(req, |
| 145 | + """ |
| 146 | + Not provided by an OSGi bundle! |
| 147 | +
|
| 148 | + This does not mean it can not work but is harder to use. You can check if there is an alternative dependency that supplies OSGi metadata already or suggest doing so to the maintainer. |
| 149 | + You might also choose to ignore this issue and either let your consumers find a way to provide the missing requirement or ask them to help out with this issue. |
| 150 | + """); |
| 151 | + } |
| 152 | + } |
| 153 | + } else { |
| 154 | + log.info(CHECK + req + " (provided by " + entry.getKey().getId() + ")"); |
| 155 | + } |
| 156 | + } |
| 157 | + } |
| 158 | + } |
| 159 | + } |
| 160 | + List<Capability> capabilities = resource.getCapabilities(PackageNamespace.PACKAGE_NAMESPACE); |
| 161 | + if (capabilities.isEmpty()) { |
| 162 | + log.info("It has no capabilities specified!"); |
| 163 | + } else { |
| 164 | + log.info("It provides " + capabilities.size() + " capabilities:"); |
| 165 | + for (Capability capability : capabilities) { |
| 166 | + log.info(" - " + ModuleContainer.toString(capability)); |
| 167 | + } |
| 168 | + } |
| 169 | + if (ignoredProblems > 0) { |
| 170 | + log.info(WARN + ignoredProblems + " problems are currently ignored!"); |
| 171 | + } |
| 172 | + } catch (IOException e) { |
| 173 | + throw new MojoExecutionException(e); |
| 174 | + } |
| 175 | + if (missingRequirements.isEmpty()) { |
| 176 | + return; |
| 177 | + } |
| 178 | + log.error("Problems where detected that will hinder your artifact from being used in an OSGi environment:"); |
| 179 | + for (Entry<String, String> entry : missingRequirements.entrySet()) { |
| 180 | + log.error("\t" + entry.getKey() + " --> " + entry.getValue()); |
| 181 | + } |
| 182 | + log.info( |
| 183 | + "To ignore the problem temporary you can add the error to the <ignored> list in the <configuration> section of the plugin." |
| 184 | + + System.lineSeparator()); |
| 185 | + log.info( |
| 186 | + "If you find the provided instructions insufficient please report an issue at https://github.com/eclipse-tycho/tycho/issues so we can enhance them!"); |
| 187 | + StringBuilder sb = new StringBuilder(); |
| 188 | + sb.append(missingRequirements.size()); |
| 189 | + sb.append( |
| 190 | + " requirements can possibly not satisfied in an OSGi environment, see the logfile for more details on specific items!"); |
| 191 | + |
| 192 | + throw new MojoFailureException(sb.toString()); |
| 193 | + } |
| 194 | + |
| 195 | + private boolean isIgnored(String req) { |
| 196 | + if (ignored != null) { |
| 197 | + return ignored.contains(req); |
| 198 | + } |
| 199 | + return false; |
| 200 | + } |
| 201 | + |
| 202 | + private Resource createProjectResource() throws IOException, MojoFailureException { |
| 203 | + try (JarFile jar = new JarFile(project.getArtifact().getFile())) { |
| 204 | + Manifest manifest = jar.getManifest(); |
| 205 | + if (manifest == null) { |
| 206 | + throw new MojoFailureException("Project artifact does not contain a manifest!"); |
| 207 | + } |
| 208 | + String bsn = manifest.getMainAttributes().getValue(Constants.BUNDLE_SYMBOLICNAME); |
| 209 | + if (bsn == null) { |
| 210 | + throw new MojoFailureException("Bundle-SymbolicName is missing in the manifest!"); |
| 211 | + } |
| 212 | + ResourceBuilder builder = new ResourceBuilder(); |
| 213 | + String version = manifest.getMainAttributes().getValue(Constants.BUNDLE_VERSION); |
| 214 | + getLog().info("The Bundle-SymbolicName is: " + bsn); |
| 215 | + checkBSN(bsn); |
| 216 | + getLog().info("The Bundle-Version is: " + version == null ? "0" : version); |
| 217 | + checkVersion(version); |
| 218 | + builder.addManifest(manifest); |
| 219 | + Resource resource = builder.build(); |
| 220 | + return resource; |
| 221 | + } |
| 222 | + } |
| 223 | + |
| 224 | + private void checkVersion(String version) throws MojoFailureException { |
| 225 | + if (version == null) { |
| 226 | + getLog().warn( |
| 227 | + "The 'Bundle-Version' header is missing, consider adding a version to your bundle ro prevent it getting the default version." |
| 228 | + + System.lineSeparator() |
| 229 | + + "See https://docs.osgi.org/specification/osgi.core/8.0.0/framework.module.html#d0e2103 for details."); |
| 230 | + return; |
| 231 | + } |
| 232 | + try { |
| 233 | + Version.parseVersion(version); |
| 234 | + } catch (IllegalArgumentException e) { |
| 235 | + throw new MojoFailureException("The 'Bundle-Version' value '" + version + "' is not valid!" |
| 236 | + + System.lineSeparator() |
| 237 | + + "See https://docs.osgi.org/specification/osgi.core/8.0.0/framework.module.html#d0e2103 for details!", |
| 238 | + e); |
| 239 | + } |
| 240 | + } |
| 241 | + |
| 242 | + private void checkBSN(String bsn) { |
| 243 | + if (!bsn.contains(".")) { |
| 244 | + getLog().warn( |
| 245 | + "The OSGi specification recommends to use a reverse domain name for the 'Bundle-SymbolicName' but the current value do not seem to match!" |
| 246 | + + System.lineSeparator() |
| 247 | + + "See https://docs.osgi.org/specification/osgi.core/8.0.0/framework.module.html#d0e2086 for details."); |
| 248 | + } |
| 249 | + } |
| 250 | + |
| 251 | + private Map<Artifact, Resource> analyzeArtifacts() { |
| 252 | + Map<Artifact, Resource> map = new LinkedHashMap<>(); |
| 253 | + Set<Artifact> artifacts = project.getArtifacts(); |
| 254 | + for (Artifact artifact : artifacts) { |
| 255 | + File file = artifact.getFile(); |
| 256 | + if (file != null && artifact.getArtifactHandler().isAddedToClasspath()) { |
| 257 | + ResourceBuilder builder = new ResourceBuilder(); |
| 258 | + try { |
| 259 | + if (builder.addFile(file)) { |
| 260 | + map.put(artifact, builder.build()); |
| 261 | + } else { |
| 262 | + try (Analyzer analyzer = new Analyzer(new Jar(file))) { |
| 263 | + analyzer.setExportPackage("*"); |
| 264 | + ResourceBuilder rb = new ResourceBuilder(); |
| 265 | + rb.addManifest(analyzer.calcManifest()); |
| 266 | + map.put(artifact, new WrappedResource(rb.build(), artifact)); |
| 267 | + } |
| 268 | + } |
| 269 | + } catch (Exception e) { |
| 270 | + // we can not use that for the verification process |
| 271 | + } |
| 272 | + } |
| 273 | + } |
| 274 | + return map; |
| 275 | + } |
| 276 | + |
| 277 | + private Resource getJVMResource() { |
| 278 | + ResourceBuilder builder = new ResourceBuilder(); |
| 279 | + builder.addEE(EE.getEEFromReleaseVersion(0)); |
| 280 | + CapReqBuilder ee = new CapReqBuilder(ExecutionEnvironmentNamespace.EXECUTION_ENVIRONMENT_NAMESPACE); |
| 281 | + ee.addAttribute(ExecutionEnvironmentNamespace.EXECUTION_ENVIRONMENT_NAMESPACE, "JavaSE"); |
| 282 | + ee.addAttribute(ExecutionEnvironmentNamespace.CAPABILITY_VERSION_ATTRIBUTE, Runtime.version().feature()); |
| 283 | + builder.addCapability(ee); |
| 284 | + ModuleLayer.boot().modules().stream().map(Module::getDescriptor).flatMap(desc -> desc.isAutomatic() |
| 285 | + ? desc.packages().stream() |
| 286 | + : desc.exports().stream().filter(Predicate.not(java.lang.module.ModuleDescriptor.Exports::isQualified)) |
| 287 | + .map(java.lang.module.ModuleDescriptor.Exports::source)) |
| 288 | + .forEach(pkg -> builder.addExportPackage(pkg, null)); |
| 289 | + return builder.build(); |
| 290 | + } |
| 291 | + |
| 292 | +} |
0 commit comments