View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one
3    * or more contributor license agreements.  See the NOTICE file
4    * distributed with this work for additional information
5    * regarding copyright ownership.  The ASF licenses this file
6    * to you under the Apache License, Version 2.0 (the
7    * "License"); you may not use this file except in compliance
8    * with the License.  You may obtain a copy of the License at
9    *
10   *   http://www.apache.org/licenses/LICENSE-2.0
11   *
12   * Unless required by applicable law or agreed to in writing,
13   * software distributed under the License is distributed on an
14   * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15   * KIND, either express or implied.  See the License for the
16   * specific language governing permissions and limitations
17   * under the License.
18   */
19  package org.apache.felix.bundleplugin;
20  
21  
22  import java.io.File;
23  import java.io.FileInputStream;
24  import java.io.FileNotFoundException;
25  import java.io.IOError;
26  import java.io.IOException;
27  import java.io.InputStream;
28  import java.io.OutputStream;
29  import java.io.Writer;
30  import java.nio.charset.StandardCharsets;
31  import java.nio.file.Files;
32  import java.nio.file.Path;
33  import java.nio.file.Paths;
34  import java.util.Iterator;
35  import java.util.LinkedHashMap;
36  import java.util.List;
37  import java.util.Map;
38  import java.util.Map.Entry;
39  import java.util.Properties;
40  import java.util.Set;
41  import java.util.jar.Manifest;
42  import java.util.stream.Collectors;
43  import java.util.stream.Stream;
44  import java.util.zip.ZipEntry;
45  import java.util.zip.ZipFile;
46  
47  import aQute.bnd.osgi.Analyzer;
48  import aQute.bnd.osgi.Builder;
49  import aQute.bnd.osgi.Instructions;
50  import aQute.bnd.osgi.Jar;
51  import aQute.bnd.osgi.Resource;
52  import aQute.lib.collections.ExtList;
53  import org.apache.maven.artifact.Artifact;
54  import org.apache.maven.plugin.MojoExecutionException;
55  import org.apache.maven.plugin.MojoFailureException;
56  import org.apache.maven.plugin.logging.Log;
57  import org.apache.maven.plugins.annotations.Component;
58  import org.apache.maven.plugins.annotations.LifecyclePhase;
59  import org.apache.maven.plugins.annotations.Mojo;
60  import org.apache.maven.plugins.annotations.Parameter;
61  import org.apache.maven.plugins.annotations.ResolutionScope;
62  import org.apache.maven.project.MavenProject;
63  import org.codehaus.plexus.util.Scanner;
64  import org.osgi.service.metatype.MetaTypeService;
65  import org.sonatype.plexus.build.incremental.BuildContext;
66  
67  
68  /**
69   * Generate an OSGi manifest for this project
70   */
71  @Mojo( name = "manifest", requiresDependencyResolution = ResolutionScope.TEST,
72         threadSafe = true,
73         defaultPhase = LifecyclePhase.PROCESS_CLASSES)
74  public class ManifestPlugin extends BundlePlugin
75  {
76      /**
77       * When true, generate the manifest by rebuilding the full bundle in memory
78       */
79      @Parameter( property = "rebuildBundle" )
80      protected boolean rebuildBundle;
81  
82      /**
83       * When true, manifest generation on incremental builds is supported in IDEs like Eclipse.
84       * Please note that the underlying BND library does not support incremental build, which means
85       * always the whole manifest and SCR metadata is generated.
86       */
87      @Parameter( property = "supportIncrementalBuild" )
88      private boolean supportIncrementalBuild;
89  
90      @Component
91      private BuildContext buildContext;
92  
93      @Override
94      protected void execute( Map<String, String> instructions, ClassPathItem[] classpath )
95          throws MojoExecutionException
96      {
97  
98          if (supportIncrementalBuild && isUpToDate(project)) {
99              return;
100         }
101         // in incremental build execute manifest generation only when explicitly activated
102         // and when any java file was touched since last build
103         if (buildContext.isIncremental() && !(supportIncrementalBuild && anyJavaSourceFileTouchedSinceLastBuild())) {
104             getLog().debug("Skipping manifest generation because no java source file was added, updated or removed since last build.");
105             return;
106         }
107 
108         Analyzer analyzer;
109         try
110         {
111             analyzer = getAnalyzer(project, instructions, classpath);
112 
113             if (supportIncrementalBuild) {
114                 writeIncrementalInfo(project);
115             }
116         }
117         catch ( FileNotFoundException e )
118         {
119             throw new MojoExecutionException( "Cannot find " + e.getMessage()
120                 + " (manifest goal must be run after compile phase)", e );
121         }
122         catch ( IOException e )
123         {
124             throw new MojoExecutionException( "Error trying to generate Manifest", e );
125         }
126         catch ( MojoFailureException e )
127         {
128             getLog().error( e.getLocalizedMessage() );
129             throw new MojoExecutionException( "Error(s) found in manifest configuration", e );
130         }
131         catch ( Exception e )
132         {
133             getLog().error( "An internal error occurred", e );
134             throw new MojoExecutionException( "Internal error in maven-bundle-plugin", e );
135         }
136 
137         File outputFile = new File( manifestLocation, "MANIFEST.MF" );
138 
139         try
140         {
141             writeManifest( analyzer, outputFile, niceManifest, exportScr, scrLocation, buildContext, getLog() );
142         }
143         catch ( Exception e )
144         {
145             throw new MojoExecutionException( "Error trying to write Manifest to file " + outputFile, e );
146         }
147         finally
148         {
149             try
150             {
151                 analyzer.close();
152             }
153             catch ( IOException e )
154             {
155                 throw new MojoExecutionException( "Error trying to write Manifest to file " + outputFile, e );
156             }
157         }
158     }
159 
160     /**
161      * Checks if any *.java file was added, updated or removed since last build in any source directory.
162      */
163     private boolean anyJavaSourceFileTouchedSinceLastBuild() {
164         @SuppressWarnings("unchecked")
165         List<String> sourceDirectories = project.getCompileSourceRoots();
166         for (String sourceDirectory : sourceDirectories) {
167             File directory = new File(sourceDirectory);
168             Scanner scanner = buildContext.newScanner(directory);
169             Scanner deleteScanner = buildContext.newDeleteScanner(directory);
170             if (containsJavaFile(scanner) || containsJavaFile(deleteScanner)) {
171                 return true;
172             }
173         }
174         return false;
175     }
176     private boolean containsJavaFile(Scanner scanner) {
177         String[] includes = new String[] { "**/*.java" };
178         scanner.setIncludes(includes);
179         scanner.scan();
180         return scanner.getIncludedFiles().length > 0;
181     }
182 
183     public Manifest getManifest( MavenProject project, ClassPathItem[] classpath ) throws IOException, MojoFailureException,
184         MojoExecutionException, Exception
185     {
186         return getManifest( project, new LinkedHashMap<String, String>(), classpath, buildContext );
187     }
188 
189 
190     public Manifest getManifest( MavenProject project, Map<String, String> instructions, ClassPathItem[] classpath,
191             BuildContext buildContext ) throws IOException, MojoFailureException, MojoExecutionException, Exception
192     {
193         Analyzer analyzer = getAnalyzer(project, instructions, classpath);
194 
195         Jar jar = analyzer.getJar();
196         Manifest manifest = jar.getManifest();
197 
198         if (exportScr)
199         {
200             exportScr(analyzer, jar, scrLocation, buildContext, getLog() );
201         }
202 
203         // cleanup...
204         analyzer.close();
205 
206         return manifest;
207     }
208 
209     private static void exportScr(Analyzer analyzer, Jar jar, File scrLocation, BuildContext buildContext, Log log ) throws Exception {
210         log.debug("Export SCR metadata to: " + scrLocation.getPath());
211         scrLocation.mkdirs();
212 
213      // export SCR metadata files from OSGI-INF/
214         Map<String, Resource> scrDir = jar.getDirectories().get("OSGI-INF");
215         if (scrDir != null) {
216             for (Map.Entry<String, Resource> entry : scrDir.entrySet()) {
217                 String path = entry.getKey();
218                 Resource resource = entry.getValue();
219                 writeSCR(resource, new File(scrLocation, path), buildContext,
220                         log);
221             }
222         }
223 
224         // export metatype files from OSGI-INF/metatype
225         Map<String,Resource> metatypeDir = jar.getDirectories().get(MetaTypeService.METATYPE_DOCUMENTS_LOCATION);
226         if (metatypeDir != null) {
227             for (Map.Entry<String, Resource> entry : metatypeDir.entrySet())
228             {
229                 String path = entry.getKey();
230                 Resource resource = entry.getValue();
231                 writeSCR(resource, new File(scrLocation, path), buildContext, log);
232             }
233         }
234 
235     }
236 
237     private static void writeSCR(Resource resource, File destination, BuildContext buildContext, Log log ) throws Exception
238     {
239         log.debug("Write SCR file: " + destination.getPath());
240         destination.getParentFile().mkdirs();
241         OutputStream os = buildContext.newFileOutputStream(destination);
242         try
243         {
244             resource.write(os);
245         }
246         finally
247         {
248             os.close();
249         }
250     }
251 
252     protected Analyzer getAnalyzer( MavenProject project, ClassPathItem[] classpath ) throws IOException, MojoExecutionException,
253         Exception
254     {
255         return getAnalyzer( project, new LinkedHashMap<>(), classpath );
256     }
257 
258 
259     protected Analyzer getAnalyzer( MavenProject project, Map<String, String> instructions, ClassPathItem[] classpath )
260         throws IOException, MojoExecutionException, Exception
261     {
262         if ( rebuildBundle && supportedProjectTypes.contains( project.getArtifact().getType() ) )
263         {
264             return buildOSGiBundle( project, instructions, classpath );
265         }
266 
267         File file = getOutputDirectory();
268         if ( file == null )
269         {
270             file = project.getArtifact().getFile();
271         }
272 
273         if ( !file.exists() )
274         {
275             if ( file.equals( getOutputDirectory() ) )
276             {
277                 file.mkdirs();
278             }
279             else
280             {
281                 throw new FileNotFoundException( file.getPath() );
282             }
283         }
284 
285         Builder analyzer = getOSGiBuilder( project, instructions, classpath );
286 
287         analyzer.setJar( file );
288 
289         // calculateExportsFromContents when we have no explicit instructions defining
290         // the contents of the bundle *and* we are not analyzing the output directory,
291         // otherwise fall-back to addMavenInstructions approach
292 
293         boolean isOutputDirectory = file.equals( getOutputDirectory() );
294 
295         if ( analyzer.getProperty( Analyzer.EXPORT_PACKAGE ) == null
296             && analyzer.getProperty( Analyzer.EXPORT_CONTENTS ) == null
297             && analyzer.getProperty( Analyzer.PRIVATE_PACKAGE ) == null && !isOutputDirectory )
298         {
299             String export = calculateExportsFromContents( analyzer.getJar() );
300             analyzer.setProperty( Analyzer.EXPORT_PACKAGE, export );
301         }
302 
303         addMavenInstructions( project, analyzer );
304 
305         // if we spot Embed-Dependency and the bundle is "target/classes", assume we need to rebuild
306         if ( analyzer.getProperty( DependencyEmbedder.EMBED_DEPENDENCY ) != null && isOutputDirectory )
307         {
308             analyzer.build();
309         }
310         else
311         {
312             analyzer.mergeManifest( analyzer.getJar().getManifest() );
313             analyzer.getJar().setManifest( analyzer.calcManifest() );
314         }
315 
316         mergeMavenManifest( project, analyzer );
317 
318         boolean hasErrors = reportErrors( "Manifest " + project.getArtifact(), analyzer );
319         if ( hasErrors )
320         {
321             String failok = analyzer.getProperty( "-failok" );
322             if ( null == failok || "false".equalsIgnoreCase( failok ) )
323             {
324                 throw new MojoFailureException( "Error(s) found in manifest configuration" );
325             }
326         }
327 
328         Jar jar = analyzer.getJar();
329 
330         if ( unpackBundle )
331         {
332             File outputFile = getOutputDirectory();
333             for ( Entry<String, Resource> entry : jar.getResources().entrySet() )
334             {
335                 File entryFile = new File( outputFile, entry.getKey() );
336                 if ( !entryFile.exists() || entry.getValue().lastModified() == 0 )
337                 {
338                     entryFile.getParentFile().mkdirs();
339                     OutputStream os = buildContext.newFileOutputStream( entryFile );
340                     entry.getValue().write( os );
341                     os.close();
342                 }
343             }
344         }
345 
346         return analyzer;
347     }
348 
349     private void writeIncrementalInfo(MavenProject project) throws MojoExecutionException {
350         try {
351             Path cacheData = getIncrementalDataPath(project);
352             String curdata = getIncrementalData();
353             Files.createDirectories(cacheData.getParent());
354             try (Writer w = Files.newBufferedWriter(cacheData)) {
355                 w.append(curdata);
356             }
357         } catch (IOException e) {
358             throw new MojoExecutionException("Error checking manifest uptodate status", e);
359         }
360     }
361 
362     private boolean isUpToDate(MavenProject project) throws MojoExecutionException {
363         try {
364             Path cacheData = getIncrementalDataPath(project);
365             String prvdata;
366             if (Files.isRegularFile(cacheData)) {
367                 prvdata = new String(Files.readAllBytes(cacheData), StandardCharsets.UTF_8);
368             } else {
369                 prvdata = null;
370             }
371             String curdata = getIncrementalData();
372             if (curdata.equals(prvdata)) {
373                 long lastmod = Files.getLastModifiedTime(cacheData).toMillis();
374                 Set<String> stale = Stream.concat(Stream.of(new File(project.getBuild().getOutputDirectory())),
375                                                             project.getArtifacts().stream().map(Artifact::getFile))
376                         .flatMap(f -> newer(lastmod, f))
377                         .collect(Collectors.toSet());
378                 if (!stale.isEmpty()) {
379                     getLog().info("Stale files detected, re-generating manifest.");
380                     if (getLog().isDebugEnabled()) {
381                         getLog().debug("Stale files: " + stale.stream()
382                                 .collect(Collectors.joining(", ")));
383                     }
384                 } else {
385                     // everything is in order, skip
386                     getLog().info("Skipping manifest generation, everything is up to date.");
387                     return true;
388                 }
389             } else {
390                 if (prvdata == null) {
391                     getLog().info("No previous run data found, generating manifest.");
392                 } else {
393                     getLog().info("Configuration changed, re-generating manifest.");
394                 }
395             }
396         } catch (IOException e) {
397             throw new MojoExecutionException("Error checking manifest uptodate status", e);
398         }
399         return false;
400     }
401 
402     private String getIncrementalData() {
403         return getInstructions().entrySet().stream().map(e -> e.getKey() + "=" + e.getValue())
404                 .collect(Collectors.joining("\n", "", "\n"));
405     }
406 
407     private Path getIncrementalDataPath(MavenProject project) {
408         return Paths.get(project.getBuild().getDirectory(), "maven-bundle-plugin",
409                 "org.apache.felix_maven-bundle-plugin_manifest_xx");
410     }
411 
412     private long lastmod(Path p) {
413         try {
414             return Files.getLastModifiedTime(p).toMillis();
415         } catch (IOException e) {
416             return 0;
417         }
418     }
419 
420     private Stream<String> newer(long lastmod, File file) {
421         try {
422             if (file.isDirectory()) {
423                 return Files.walk(file.toPath())
424                         .filter(Files::isRegularFile)
425                         .filter(p -> lastmod(p) > lastmod)
426                         .map(Path::toString);
427             } else if (file.isFile()) {
428                 if (lastmod(file.toPath()) > lastmod) {
429                     if (file.getName().endsWith(".jar")) {
430                         try (ZipFile zf = new ZipFile(file)) {
431                             return zf.stream()
432                                     .filter(ze -> !ze.isDirectory())
433                                     .filter(ze -> ze.getLastModifiedTime().toMillis() > lastmod)
434                                     .map(ze -> file.toString() + "!" + ze.getName())
435                                     .collect(Collectors.toList())
436                                     .stream();
437                         }
438                     } else {
439                         return Stream.of(file.toString());
440                     }
441                 } else {
442                     return Stream.empty();
443                 }
444             } else {
445                 return Stream.empty();
446             }
447         } catch (IOException e) {
448             throw new IOError(e);
449         }
450     }
451 
452 
453     public static void writeManifest( Analyzer analyzer, File outputFile, boolean niceManifest,
454             boolean exportScr, File scrLocation, BuildContext buildContext, Log log ) throws Exception
455     {
456         Properties properties = analyzer.getProperties();
457         Jar jar = analyzer.getJar();
458         Manifest manifest = jar.getManifest();
459         if ( outputFile.exists() && properties.containsKey( "Merge-Headers" ) )
460         {
461             Manifest analyzerManifest = manifest;
462             manifest = new Manifest();
463             InputStream inputStream = new FileInputStream( outputFile );
464             try
465             {
466                 manifest.read( inputStream );
467             }
468             finally
469             {
470                 inputStream.close();
471             }
472             Instructions instructions = new Instructions( ExtList.from( analyzer.getProperty("Merge-Headers") ) );
473             mergeManifest( instructions, manifest, analyzerManifest );
474         }
475         else
476         {
477             File parentFile = outputFile.getParentFile();
478             parentFile.mkdirs();
479         }
480         writeManifest( manifest, outputFile, niceManifest, buildContext, log );
481 
482         if (exportScr)
483         {
484             exportScr(analyzer, jar, scrLocation, buildContext, log);
485         }
486     }
487 
488 
489     public static void writeManifest( Manifest manifest, File outputFile, boolean niceManifest,
490             BuildContext buildContext, Log log ) throws IOException
491     {
492         log.debug("Write manifest to " + outputFile.getPath());
493         outputFile.getParentFile().mkdirs();
494 
495         OutputStream os = buildContext.newFileOutputStream( outputFile );
496         try
497         {
498             ManifestWriter.outputManifest( manifest, os, niceManifest );
499         }
500         finally
501         {
502             try
503             {
504                 os.close();
505             }
506             catch ( IOException e )
507             {
508                 // nothing we can do here
509             }
510         }
511     }
512 
513 
514     /*
515      * Patched version of bnd's Analyzer.calculateExportsFromContents
516      */
517     public static String calculateExportsFromContents( Jar bundle )
518     {
519         String ddel = "";
520         StringBuffer sb = new StringBuffer();
521         Map<String, Map<String, Resource>> map = bundle.getDirectories();
522         for ( Iterator<Entry<String, Map<String, Resource>>> i = map.entrySet().iterator(); i.hasNext(); )
523         {
524             //----------------------------------------------------
525             // should also ignore directories with no resources
526             //----------------------------------------------------
527             Entry<String, Map<String, Resource>> entry = i.next();
528             if ( entry.getValue() == null || entry.getValue().isEmpty() )
529                 continue;
530             //----------------------------------------------------
531             String directory = entry.getKey();
532             if ( directory.equals( "META-INF" ) || directory.startsWith( "META-INF/" ) )
533                 continue;
534             if ( directory.equals( "OSGI-OPT" ) || directory.startsWith( "OSGI-OPT/" ) )
535                 continue;
536             if ( directory.equals( "/" ) )
537                 continue;
538 
539             if ( directory.endsWith( "/" ) )
540                 directory = directory.substring( 0, directory.length() - 1 );
541 
542             directory = directory.replace( '/', '.' );
543             sb.append( ddel );
544             sb.append( directory );
545             ddel = ",";
546         }
547         return sb.toString();
548     }
549 }