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.baseline;
20  
21  import java.io.File;
22  import java.io.IOException;
23  import java.text.SimpleDateFormat;
24  import java.util.Arrays;
25  import java.util.Date;
26  import java.util.Iterator;
27  import java.util.List;
28  import java.util.Map;
29  import java.util.Set;
30  
31  import org.apache.maven.artifact.Artifact;
32  import org.apache.maven.artifact.factory.ArtifactFactory;
33  import org.apache.maven.artifact.metadata.ArtifactMetadataRetrievalException;
34  import org.apache.maven.artifact.metadata.ArtifactMetadataSource;
35  import org.apache.maven.artifact.repository.ArtifactRepository;
36  import org.apache.maven.artifact.resolver.ArtifactNotFoundException;
37  import org.apache.maven.artifact.resolver.ArtifactResolutionException;
38  import org.apache.maven.artifact.resolver.ArtifactResolver;
39  import org.apache.maven.artifact.versioning.ArtifactVersion;
40  import org.apache.maven.artifact.versioning.InvalidVersionSpecificationException;
41  import org.apache.maven.artifact.versioning.OverConstrainedVersionException;
42  import org.apache.maven.artifact.versioning.VersionRange;
43  import org.apache.maven.plugin.AbstractMojo;
44  import org.apache.maven.plugin.MojoExecutionException;
45  import org.apache.maven.plugin.MojoFailureException;
46  import org.apache.maven.project.MavenProject;
47  import org.codehaus.plexus.util.StringUtils;
48  
49  import aQute.bnd.differ.Baseline;
50  import aQute.bnd.differ.Baseline.Info;
51  import aQute.bnd.differ.DiffPluginImpl;
52  import aQute.bnd.osgi.Instructions;
53  import aQute.bnd.osgi.Jar;
54  import aQute.bnd.osgi.Processor;
55  import aQute.bnd.service.diff.Delta;
56  import aQute.bnd.service.diff.Diff;
57  import aQute.bnd.version.Version;
58  import aQute.service.reporter.Reporter;
59  
60  /**
61   * Abstract BND Baseline check between two bundles.
62   */
63  abstract class AbstractBaselinePlugin
64      extends AbstractMojo
65  {
66  
67      /**
68       * Flag to easily skip execution.
69       *
70       * @parameter expression="${baseline.skip}" default-value="false"
71       */
72      protected boolean skip;
73  
74      /**
75       * Whether to fail on errors.
76       *
77       * @parameter expression="${baseline.failOnError}" default-value="true"
78       */
79      protected boolean failOnError;
80  
81      /**
82       * Whether to fail on warnings.
83       *
84       * @parameter expression="${baseline.failOnWarning}" default-value="false"
85       */
86      protected boolean failOnWarning;
87  
88      /**
89       * @parameter expression="${project}"
90       * @required
91       * @readonly
92       */
93      protected MavenProject project;
94  
95      /**
96       * @parameter expression="${project.build.directory}"
97       * @required
98       * @readonly
99       */
100     private File buildDirectory;
101 
102     /**
103      * @parameter expression="${project.build.finalName}"
104      * @required
105      * @readonly
106      */
107     private String finalName;
108 
109     /**
110      * @component
111      */
112     protected ArtifactResolver resolver;
113 
114     /**
115      * @component
116      */
117     protected ArtifactFactory factory;
118 
119     /**
120      * @parameter default-value="${localRepository}"
121      * @required
122      * @readonly
123      */
124     protected ArtifactRepository localRepository;
125 
126     /**
127      * @component
128      */
129     private ArtifactMetadataSource metadataSource;
130 
131     /**
132      * Version to compare the current code against.
133      *
134      * @parameter expression="${comparisonVersion}" default-value="(,${project.version})"
135      * @required
136      * @readonly
137      */
138     protected String comparisonVersion;
139 
140     /**
141      * A list of packages filter, if empty the whole bundle will be traversed. Values are specified in OSGi package
142      * instructions notation, e.g. <code>!org.apache.felix.bundleplugin</code>.
143      *
144      * @parameter
145      */
146     private String[] filters;
147 
148     /**
149      * Project types which this plugin supports.
150      *
151      * @parameter
152      */
153     protected List supportedProjectTypes = Arrays.asList( new String[] { "jar", "bundle" } );
154 
155     public final void execute()
156         throws MojoExecutionException, MojoFailureException
157     {
158         if ( skip )
159         {
160             getLog().info( "Skipping Baseline execution" );
161             return;
162         }
163 
164         if ( !supportedProjectTypes.contains( project.getArtifact().getType() ) )
165         {
166             getLog().info("Skipping Baseline (project type " + project.getArtifact().getType() + " not supported)");
167             return;
168         }
169 
170         // get the bundles that have to be compared
171 
172         final Jar currentBundle = getCurrentBundle();
173         if ( currentBundle == null )
174         {
175             getLog().info( "Not generating Baseline report as there is no bundle generated by the project" );
176             return;
177         }
178 
179         final Jar previousBundle = getPreviousBundle();
180         if ( previousBundle == null )
181         {
182             getLog().info( "Not generating Baseline report as there is no previous version of the library to compare against" );
183             return;
184         }
185 
186         // preparing the filters
187 
188         final Instructions packageFilters;
189         if ( filters == null || filters.length == 0 )
190         {
191             packageFilters = new Instructions();
192         }
193         else
194         {
195             packageFilters = new Instructions( Arrays.asList( filters ) );
196         }
197 
198         // go!
199 
200         init();
201 
202         String generationDate = new SimpleDateFormat( "yyyy-MM-dd'T'HH:mm'Z'" ).format( new Date() );
203         Reporter reporter = new Processor();
204 
205         try
206         {
207             Set<Info> infoSet = new Baseline( reporter, new DiffPluginImpl() )
208                                 .baseline( currentBundle, previousBundle, packageFilters );
209 
210             startBaseline( generationDate, project.getArtifactId(), project.getVersion(), comparisonVersion );
211 
212             final Info[] infos = infoSet.toArray( new Info[infoSet.size()] );
213             Arrays.sort( infos, new InfoComparator() );
214 
215             for ( Info info : infos )
216             {
217                 DiffMessage diffMessage = null;
218                 Version newerVersion = info.newerVersion;
219                 Version suggestedVersion = info.suggestedVersion;
220 
221                 if ( suggestedVersion != null )
222                 {
223                     if ( newerVersion.compareTo( suggestedVersion ) > 0 )
224                     {
225                         diffMessage = new DiffMessage( "Excessive version increase", DiffMessage.Type.warning );
226                         reporter.warning( "%s: %s; detected %s, suggested %s",
227                                           info.packageName, diffMessage, info.newerVersion, info.suggestedVersion );
228                     }
229                     else if ( newerVersion.compareTo( suggestedVersion ) < 0 )
230                     {
231                         diffMessage = new DiffMessage( "Version increase required", DiffMessage.Type.error );
232                         reporter.error( "%s: %s; detected %s, suggested %s",
233                                         info.packageName, diffMessage, info.newerVersion, info.suggestedVersion );
234                     }
235                 }
236 
237                 Diff packageDiff = info.packageDiff;
238 
239                 Delta delta = packageDiff.getDelta();
240 
241                 switch ( delta )
242                 {
243                     case UNCHANGED:
244                         if ( ( suggestedVersion.getMajor() != newerVersion.getMajor() )
245                             || ( suggestedVersion.getMicro() != newerVersion.getMicro() )
246                             || ( suggestedVersion.getMinor() != newerVersion.getMinor() ) )
247                         {
248                             diffMessage = new DiffMessage( "Version has been increased but analysis detected no changes", DiffMessage.Type.warning );
249                             reporter.warning( "%s: %s; detected %s, suggested %s",
250                                               info.packageName, diffMessage, info.newerVersion, info.suggestedVersion );
251                         }
252                         break;
253 
254                     case REMOVED:
255                         diffMessage = new DiffMessage( "Package removed", DiffMessage.Type.info );
256                         reporter.trace( "%s: %s ", info.packageName, diffMessage );
257                         break;
258 
259                     case CHANGED:
260                     case MICRO:
261                     case MINOR:
262                     case MAJOR:
263                     case ADDED:
264                     default:
265                         // ok
266                         break;
267                 }
268 
269                 boolean mismatch = info.mismatch;
270                 String packageName = info.packageName;
271                 String shortDelta = getShortDelta( info.packageDiff.getDelta() );
272                 String deltaString = StringUtils.lowerCase( String.valueOf( info.packageDiff.getDelta() ) );
273                 String newerVersionString = String.valueOf( info.newerVersion );
274                 String olderVersionString = String.valueOf( info.olderVersion );
275                 String suggestedVersionString = String.valueOf( ( info.suggestedVersion == null ) ? "-" : info.suggestedVersion );
276                 Map<String,String> attributes = info.attributes;
277 
278                 startPackage( mismatch,
279                               packageName,
280                               shortDelta,
281                               deltaString,
282                               newerVersionString,
283                               olderVersionString,
284                               suggestedVersionString,
285                               diffMessage,
286                               attributes );
287 
288                 if ( Delta.REMOVED != delta )
289                 {
290                     doPackageDiff( packageDiff );
291                 }
292 
293                 endPackage();
294             }
295 
296             endBaseline();
297         }
298         catch ( Exception e )
299         {
300             throw new MojoExecutionException( "Impossible to calculate the baseline", e );
301         }
302         finally
303         {
304             closeJars( currentBundle, previousBundle );
305         }
306 
307         // check if it has to fail if some error has been detected
308 
309         boolean fail = false;
310 
311         if ( !reporter.isOk() )
312         {
313             for ( String errorMessage : reporter.getErrors() )
314             {
315                 getLog().error( errorMessage );
316             }
317 
318             if ( failOnError )
319             {
320                 fail = true;
321             }
322         }
323 
324         // check if it has to fail if some warning has been detected
325 
326         if ( !reporter.getWarnings().isEmpty() )
327         {
328             for ( String warningMessage : reporter.getWarnings() )
329             {
330                 getLog().warn( warningMessage );
331             }
332 
333             if ( failOnWarning )
334             {
335                 fail = true;
336             }
337         }
338 
339         getLog().info( String.format( "Baseline analisys complete, %s error(s), %s warning(s)",
340                                       reporter.getErrors().size(),
341                                       reporter.getWarnings().size() ) );
342 
343         if ( fail )
344         {
345             throw new MojoFailureException( "Baseline failed, see generated report" );
346         }
347     }
348 
349     private void doPackageDiff( Diff diff )
350     {
351         int depth = 1;
352 
353         for ( Diff curDiff : diff.getChildren() )
354         {
355             if ( Delta.UNCHANGED != curDiff.getDelta() )
356             {
357                 doDiff( curDiff, depth );
358             }
359         }
360     }
361 
362     private void doDiff( Diff diff, int depth )
363     {
364         String type = StringUtils.lowerCase( String.valueOf( diff.getType() ) );
365         String shortDelta = getShortDelta( diff.getDelta() );
366         String delta = StringUtils.lowerCase( String.valueOf( diff.getDelta() ) );
367         String name = diff.getName();
368 
369         startDiff( depth, type, name, delta, shortDelta );
370 
371         for ( Diff curDiff : diff.getChildren() )
372         {
373             if ( Delta.UNCHANGED != curDiff.getDelta() )
374             {
375                 doDiff( curDiff, depth + 1 );
376             }
377         }
378 
379         endDiff( depth );
380     }
381 
382     // extensions APIs
383 
384     protected abstract void init();
385 
386     protected abstract void startBaseline( String generationDate, String bundleName, String currentVersion, String previousVersion );
387 
388     protected abstract void startPackage( boolean mismatch,
389                                           String name,
390                                           String shortDelta,
391                                           String delta,
392                                           String newerVersion,
393                                           String olderVersion,
394                                           String suggestedVersion,
395                                           DiffMessage diffMessage,
396                                           Map<String,String> attributes );
397 
398     protected abstract void startDiff( int depth,
399                                        String type,
400                                        String name,
401                                        String delta,
402                                        String shortDelta );
403 
404     protected abstract void endDiff( int depth );
405 
406     protected abstract void endPackage();
407 
408     protected abstract void endBaseline();
409 
410     // internals
411 
412     private Jar getCurrentBundle()
413         throws MojoExecutionException
414     {
415         /*
416          * Resolving the aQute.bnd.osgi.Jar via the produced artifact rather than what is produced in the target/classes
417          * directory would make the Mojo working also in projects where the bundle-plugin is used just to generate the
418          * manifest file and the final jar is assembled via the jar-plugin
419          */
420         File currentBundle = new File( buildDirectory, getBundleName() );
421         if ( !currentBundle.exists() )
422         {
423             getLog().debug( "Produced bundle not found: " + currentBundle );
424             return null;
425         }
426 
427         return openJar( currentBundle );
428     }
429 
430     private Jar getPreviousBundle()
431         throws MojoFailureException, MojoExecutionException
432     {
433         // Find the previous version JAR and resolve it, and it's dependencies
434         final VersionRange range;
435         try
436         {
437             range = VersionRange.createFromVersionSpec( comparisonVersion );
438         }
439         catch ( InvalidVersionSpecificationException e )
440         {
441             throw new MojoFailureException( "Invalid comparison version: " + e.getMessage() );
442         }
443 
444         final Artifact previousArtifact;
445         try
446         {
447             previousArtifact =
448                 factory.createDependencyArtifact( project.getGroupId(),
449                                                   project.getArtifactId(),
450                                                   range,
451                                                   project.getPackaging(),
452                                                   null,
453                                                   Artifact.SCOPE_COMPILE );
454 
455             if ( !previousArtifact.getVersionRange().isSelectedVersionKnown( previousArtifact ) )
456             {
457                 getLog().debug( "Searching for versions in range: " + previousArtifact.getVersionRange() );
458                 @SuppressWarnings( "unchecked" )
459                 // type is konwn
460                 List<ArtifactVersion> availableVersions =
461                     metadataSource.retrieveAvailableVersions( previousArtifact, localRepository,
462                                                               project.getRemoteArtifactRepositories() );
463                 filterSnapshots( availableVersions );
464                 ArtifactVersion version = range.matchVersion( availableVersions );
465                 if ( version != null )
466                 {
467                     previousArtifact.selectVersion( version.toString() );
468                 }
469             }
470         }
471         catch ( OverConstrainedVersionException ocve )
472         {
473             throw new MojoFailureException( "Invalid comparison version: " + ocve.getMessage() );
474         }
475         catch ( ArtifactMetadataRetrievalException amre )
476         {
477             throw new MojoExecutionException( "Error determining previous version: " + amre.getMessage(), amre );
478         }
479 
480         if ( previousArtifact.getVersion() == null )
481         {
482             getLog().info( "Unable to find a previous version of the project in the repository" );
483             return null;
484         }
485 
486         try
487         {
488             resolver.resolve( previousArtifact, project.getRemoteArtifactRepositories(), localRepository );
489         }
490         catch ( ArtifactResolutionException are )
491         {
492             throw new MojoExecutionException( "Artifact " + previousArtifact + " cannot be resolved", are );
493         }
494         catch ( ArtifactNotFoundException anfe )
495         {
496             throw new MojoExecutionException( "Artifact " + previousArtifact
497                 + " does not exist on local/remote repositories", anfe );
498         }
499 
500         return openJar( previousArtifact.getFile() );
501     }
502 
503     private void filterSnapshots( List<ArtifactVersion> versions )
504     {
505         for ( Iterator<ArtifactVersion> versionIterator = versions.iterator(); versionIterator.hasNext(); )
506         {
507             ArtifactVersion version = versionIterator.next();
508             if ( "SNAPSHOT".equals( version.getQualifier() ) )
509             {
510                 versionIterator.remove();
511             }
512         }
513     }
514 
515     private static Jar openJar( File file )
516         throws MojoExecutionException
517     {
518         try
519         {
520             return new Jar( file );
521         }
522         catch ( IOException e )
523         {
524             throw new MojoExecutionException( "An error occurred while opening JAR directory: " + file, e );
525         }
526     }
527 
528     private static void closeJars( Jar...jars )
529     {
530         for ( Jar jar : jars )
531         {
532             jar.close();
533         }
534     }
535 
536     private String getBundleName()
537     {
538         String extension;
539         try
540         {
541             extension = project.getArtifact().getArtifactHandler().getExtension();
542         }
543         catch ( Throwable e )
544         {
545             extension = project.getArtifact().getType();
546         }
547 
548         if ( StringUtils.isEmpty( extension ) || "bundle".equals( extension ) || "pom".equals( extension ) )
549         {
550             extension = "jar"; // just in case maven gets confused
551         }
552 
553         String classifier = project.getArtifact().getClassifier();
554         if ( null != classifier && classifier.trim().length() > 0 )
555         {
556             return finalName + '-' + classifier + '.' + extension;
557         }
558 
559         return finalName + '.' + extension;
560     }
561 
562     private static String getShortDelta( Delta delta )
563     {
564         switch ( delta )
565         {
566             case ADDED:
567                 return "+";
568 
569             case CHANGED:
570                 return "~";
571 
572             case MAJOR:
573                 return ">";
574 
575             case MICRO:
576                 return "0xB5";
577 
578             case MINOR:
579                 return "<";
580 
581             case REMOVED:
582                 return "-";
583 
584             case UNCHANGED:
585                 return " ";
586 
587             default:
588                 String deltaString = delta.toString();
589                 return String.valueOf( deltaString.charAt( 0 ) );
590         }
591     }
592 
593 }