View Javadoc

1   package com.atlassian.plugin.osgi.factory.transform.stage;
2   
3   import java.io.ByteArrayOutputStream;
4   import java.io.IOException;
5   import java.util.ArrayList;
6   import java.util.Collection;
7   import java.util.Collections;
8   import java.util.HashMap;
9   import java.util.List;
10  import java.util.Map;
11  import java.util.Map.Entry;
12  import java.util.Properties;
13  import java.util.Set;
14  import java.util.StringTokenizer;
15  import java.util.jar.Attributes;
16  import java.util.jar.Manifest;
17  
18  import com.atlassian.plugin.Application;
19  import com.atlassian.plugin.PluginInformation;
20  import com.atlassian.plugin.PluginParseException;
21  import com.atlassian.plugin.osgi.factory.OsgiPlugin;
22  import com.atlassian.plugin.osgi.factory.transform.PluginTransformationException;
23  import com.atlassian.plugin.osgi.factory.transform.TransformContext;
24  import com.atlassian.plugin.osgi.factory.transform.TransformStage;
25  import com.atlassian.plugin.osgi.factory.transform.model.SystemExports;
26  import com.atlassian.plugin.osgi.util.OsgiHeaderUtil;
27  import com.atlassian.plugin.parsers.XmlDescriptorParser;
28  import com.atlassian.plugin.util.PluginUtils;
29  
30  import com.google.common.collect.ImmutableSet;
31  
32  import org.apache.commons.lang.StringUtils;
33  import org.osgi.framework.Constants;
34  import org.osgi.framework.Version;
35  import org.slf4j.Logger;
36  import org.slf4j.LoggerFactory;
37  
38  import aQute.lib.osgi.Analyzer;
39  import aQute.lib.osgi.Builder;
40  import aQute.lib.osgi.Jar;
41  
42  import static com.atlassian.plugin.util.PluginUtils.isAtlassianDevMode;
43  
44  /**
45   * Generates an OSGi manifest if not already defined.  Should be the last stage.
46   *
47   * @since 2.2.0
48   */
49  public class GenerateManifestStage implements TransformStage
50  {
51      private static final Logger log = LoggerFactory.getLogger(GenerateManifestStage.class);
52  
53      private final int SPRING_TIMEOUT = PluginUtils.getDefaultEnablingWaitPeriod();
54      private final String SPRING_CONTEXT_DEFAULT = "*;"+ SPRING_CONTEXT_TIMEOUT + SPRING_TIMEOUT;
55      public static final String SPRING_CONTEXT = "Spring-Context";
56      private static final String SPRING_CONTEXT_TIMEOUT = "timeout:=";
57      private static final String SPRING_CONTEXT_DELIM = ";";
58      private static final String RESOLUTION_DIRECTIVE = "resolution:";
59  
60      public void execute(final TransformContext context) throws PluginTransformationException
61      {
62          final Builder builder = new Builder();
63          try
64          {
65              builder.setJar(context.getPluginFile());
66  
67              // We don't care about the modules, so we pass null
68              final XmlDescriptorParser parser = new XmlDescriptorParser(context.getDescriptorDocument(), ImmutableSet.<Application>of());
69  
70              Manifest mf;
71              final Manifest contextManifest = context.getManifest();
72              if (isOsgiBundle(contextManifest))
73              {
74                  if (context.getExtraImports().isEmpty())
75                  {
76                      boolean modified = false;
77                      mf = builder.getJar().getManifest();
78                      for (final Map.Entry<String,String> entry : getRequiredOsgiHeaders(context, parser.getKey()).entrySet())
79                      {
80                          if (manifestDoesntHaveRequiredOsgiHeader(mf, entry))
81                          {
82                              mf.getMainAttributes().putValue(entry.getKey(), entry.getValue());
83                              modified = true;
84                          }
85                      }
86                      validateOsgiVersionIsValid(mf);
87                      if (modified)
88                      {
89                          writeManifestOverride(context, mf);
90                      }
91                      // skip any manifest manipulation by bnd
92                      return;
93                  }
94                  else
95                  {
96                      // Possibly necessary due to Spring XML creation
97  
98                      assertSpringAvailableIfRequired(context);
99                      mf = builder.getJar().getManifest();
100                     final String imports = addExtraImports(builder.getJar().getManifest().getMainAttributes().getValue(Constants.IMPORT_PACKAGE), context.getExtraImports());
101                     mf.getMainAttributes().putValue(Constants.IMPORT_PACKAGE, imports);
102 
103                     for (final Map.Entry<String,String> entry : getRequiredOsgiHeaders(context, parser.getKey()).entrySet())
104                     {
105                         mf.getMainAttributes().putValue(entry.getKey(), entry.getValue());
106                     }
107                 }
108             }
109             else
110             {
111                 final PluginInformation info = parser.getPluginInformation();
112 
113                 final Properties properties = new Properties();
114 
115                 // Setup defaults
116                 for (final Map.Entry<String,String> entry : getRequiredOsgiHeaders(context, parser.getKey()).entrySet())
117                 {
118                     properties.put(entry.getKey(), entry.getValue());
119                 }
120                 //check for extra xml scan folders
121                 final Set<String> scanFolders = info.getModuleScanFolders();
122                 if (!scanFolders.isEmpty())
123                 {
124                     properties.put(OsgiPlugin.ATLASSIAN_SCAN_FOLDERS, StringUtils.join(scanFolders,","));
125                 }
126 
127                 properties.put(Analyzer.BUNDLE_SYMBOLICNAME, parser.getKey());
128                 properties.put(Analyzer.IMPORT_PACKAGE, "*;resolution:=optional");
129 
130                 // Don't export anything by default
131                 //properties.put(Analyzer.EXPORT_PACKAGE, "*");
132 
133                 properties.put(Analyzer.BUNDLE_VERSION, info.getVersion());
134 
135                 // remove the verbose Include-Resource entry from generated manifest
136                 properties.put(Analyzer.REMOVEHEADERS, Analyzer.INCLUDE_RESOURCE);
137 
138                 header(properties, Analyzer.BUNDLE_DESCRIPTION, info.getDescription());
139                 header(properties, Analyzer.BUNDLE_NAME, parser.getKey());
140                 header(properties, Analyzer.BUNDLE_VENDOR, info.getVendorName());
141                 header(properties, Analyzer.BUNDLE_DOCURL, info.getVendorUrl());
142 
143                 final List<String> bundleClassPaths = new ArrayList<String>();
144 
145                 // the jar root.
146                 bundleClassPaths.add(".");
147 
148                 // inner jars. make the order deterministic here.
149                 final List<String> innerClassPaths = new ArrayList<String>(context.getBundleClassPathJars());
150                 Collections.sort(innerClassPaths);
151                 bundleClassPaths.addAll(innerClassPaths);
152 
153                 // generate bundle classpath.
154                 header(properties, Analyzer.BUNDLE_CLASSPATH, StringUtils.join(bundleClassPaths, ','));
155 
156                 // Process any bundle instructions in atlassian-plugin.xml
157                 properties.putAll(context.getBndInstructions());
158 
159                 // Add extra imports to the imports list
160                 properties.put(Analyzer.IMPORT_PACKAGE, addExtraImports(properties.getProperty(Analyzer.IMPORT_PACKAGE), context.getExtraImports()));
161 
162                 // Add extra exports to the exports list
163                 if (!properties.containsKey(Analyzer.EXPORT_PACKAGE))
164                 {
165                     properties.put(Analyzer.EXPORT_PACKAGE, StringUtils.join(context.getExtraExports(), ','));
166                 }
167 
168                 builder.setProperties(properties);
169                 builder.calcManifest();
170                 builder.getJar().close();
171                 Jar jar = null;
172                 try
173                 {
174                     jar = builder.build();
175                     mf = jar.getManifest();
176                 }
177                 finally
178                 {
179                     if (jar != null)
180                     {
181                         jar.close();
182                     }
183                 }
184 
185                 // We want to preserve any extra headers from the context manifest, that is, the original untransformed manifest.
186                 // However, we don't want to clobber anything which was set by bnd, we want to warn about them being skipped.
187                 final Attributes attributes = mf.getMainAttributes();
188                 for (final Entry<Object, Object> entry : contextManifest.getMainAttributes().entrySet())
189                 {
190                     final Object name = entry.getKey();
191                     if (attributes.containsKey(name))
192                     {
193                         log.debug("Ignoring manifest header {} from {} due to transformer override",
194                                 name, context.getPluginArtifact());
195                     }
196                     else
197                     {
198                         attributes.put(name, entry.getValue());
199                     }
200                 }
201             }
202 
203             enforceHostVersionsForUnknownImports(mf, context.getSystemExports());
204             validateOsgiVersionIsValid(mf);
205 
206             writeManifestOverride(context, mf);
207         }
208         catch (final Exception t)
209         {
210             throw new PluginParseException("Unable to process plugin to generate OSGi manifest", t);
211         } finally
212         {
213             builder.getJar().close();
214             builder.close();
215         }
216 
217     }
218 
219     private Map<String,String> getRequiredOsgiHeaders(final TransformContext context, final String pluginKey)
220     {
221         final Map<String, String> props = new HashMap<String, String>();
222         props.put(OsgiPlugin.ATLASSIAN_PLUGIN_KEY, pluginKey);
223         final String springHeader = getDesiredSpringContextValue(context);
224         if (springHeader != null)
225         {
226             props.put(SPRING_CONTEXT, springHeader);
227         }
228         return props;
229     }
230 
231     private String getDesiredSpringContextValue(final TransformContext context)
232     {
233         // Check for the explicit context value
234         final String header = context.getManifest().getMainAttributes().getValue(SPRING_CONTEXT);
235         if (header != null)
236         {
237             return ensureDefaultTimeout(header);
238         }
239 
240         // Check for the spring files, as the default header value looks here
241         // TODO: This is probly not correct since if there is no META-INF/spring/*.xml, it's still not spring-powered.
242         if (context.getPluginArtifact().doesResourceExist("META-INF/spring/") ||
243             context.shouldRequireSpring() ||
244             context.getDescriptorDocument() != null)
245         {
246             return SPRING_CONTEXT_DEFAULT;
247         }
248         return null;
249     }
250 
251     private String ensureDefaultTimeout(final String header)
252     {
253         final boolean noTimeOutSpecified = StringUtils.isEmpty(System.getProperty(PluginUtils.ATLASSIAN_PLUGINS_ENABLE_WAIT));
254 
255         if (noTimeOutSpecified)
256         {
257             return header;
258         }
259         else
260         {
261             final StringBuilder headerBuf;
262             //Override existing timeout
263             if (header.contains(SPRING_CONTEXT_TIMEOUT))
264             {
265                 final StringTokenizer tokenizer = new StringTokenizer(header, SPRING_CONTEXT_DELIM);
266                 headerBuf = new StringBuilder();
267                 while (tokenizer.hasMoreElements())
268                 {
269                     String directive = (String) tokenizer.nextElement();
270                     if (directive.startsWith(SPRING_CONTEXT_TIMEOUT))
271                     {
272                         directive = SPRING_CONTEXT_TIMEOUT + SPRING_TIMEOUT;
273                     }
274                     headerBuf.append(directive);
275                     if (tokenizer.hasMoreElements())
276                     {
277                         headerBuf.append(SPRING_CONTEXT_DELIM);
278                     }
279                 }
280             }
281             else
282             {
283                 //Append new timeout
284                 headerBuf = new StringBuilder(header);
285                 headerBuf.append(SPRING_CONTEXT_DELIM + SPRING_CONTEXT_TIMEOUT);
286                 headerBuf.append(SPRING_TIMEOUT);
287             }
288             return headerBuf.toString();
289         }
290     }
291 
292     private void validateOsgiVersionIsValid(final Manifest mf)
293     {
294         final String version = mf.getMainAttributes().getValue(Constants.BUNDLE_VERSION);
295         try
296         {
297             if (Version.parseVersion(version) == Version.emptyVersion)
298             {
299                 // we still consider an empty version to be bad
300                 throw new IllegalArgumentException();
301             }
302         }
303         catch (final IllegalArgumentException ex)
304         {
305             throw new IllegalArgumentException("Plugin version '" + version + "' is required and must be able to be " +
306                     "parsed as an OSGi version - MAJOR.MINOR.MICRO.QUALIFIER");
307         }
308     }
309 
310     private void writeManifestOverride(final TransformContext context, final Manifest mf)
311             throws IOException
312     {
313         final ByteArrayOutputStream bout = new ByteArrayOutputStream();
314         mf.write(bout);
315         context.getFileOverrides().put("META-INF/MANIFEST.MF", bout.toByteArray());
316     }
317 
318     /**
319      * Scans for any imports with no version specified and locks them into the specific version exported by the host
320      * container
321      * @param manifest The manifest to read and manipulate
322      * @param exports The list of host exports
323      */
324     private void enforceHostVersionsForUnknownImports(final Manifest manifest, final SystemExports exports)
325     {
326         final String origImports = manifest.getMainAttributes().getValue(Constants.IMPORT_PACKAGE);
327         if (origImports != null)
328         {
329             final StringBuilder imports = new StringBuilder();
330             final Map<String,Map<String,String>> header = OsgiHeaderUtil.parseHeader(origImports);
331             for (final Map.Entry<String,Map<String,String>> pkgImport : header.entrySet())
332             {
333                 String imp = null;
334                 if (pkgImport.getValue().isEmpty())
335                 {
336                     final String export = exports.getFullExport(pkgImport.getKey());
337                     if (!export.equals(imp))
338                     {
339                         imp = export;
340                     }
341 
342                 }
343                 if (imp == null)
344                 {
345                     imp = OsgiHeaderUtil.buildHeader(pkgImport.getKey(), pkgImport.getValue());
346                 }
347                 imports.append(imp);
348                 imports.append(",");
349             }
350             if (imports.length() > 0)
351             {
352                 imports.deleteCharAt(imports.length() - 1);
353             }
354 
355             manifest.getMainAttributes().putValue(Constants.IMPORT_PACKAGE, imports.toString());
356         }
357     }
358 
359     private boolean isOsgiBundle(final Manifest manifest) throws IOException
360     {
361         // OSGi core spec 4.2 section 3.5.2: The Bundle-SymbolicName manifest header is a mandatory header.
362         return manifest.getMainAttributes().getValue(Constants.BUNDLE_SYMBOLICNAME) != null;
363     }
364 
365     private String addExtraImports(final String importsLine, final List<String> extraImports)
366     {
367         final Map<String,Map<String,String>> originalImports = OsgiHeaderUtil.parseHeader(importsLine);
368         for (final String exImport : extraImports)
369         {
370             if (!exImport.startsWith("java."))
371             {
372                 // the extraImportPackage here can be in the form 'package;version=blah'. We only use the package component to check if it's already required.
373                 final String extraImportPackage = StringUtils.split(exImport, ';')[0];
374 
375                 final Map attrs = originalImports.get(extraImportPackage);
376                 // if the package is already required by the import directive supplied by plugin developer, we use the supplied one.
377                 if (attrs != null)
378                 {
379                     final Object resolution = attrs.get(RESOLUTION_DIRECTIVE);
380                     if (Constants.RESOLUTION_OPTIONAL.equals(resolution))
381                     {
382                         attrs.put(RESOLUTION_DIRECTIVE, Constants.RESOLUTION_MANDATORY);
383                     }
384                 }
385                 // otherwise, it is system determined.
386                 else
387                 {
388                     originalImports.put(exImport, Collections.<String, String>emptyMap());
389                 }
390             }
391         }
392 
393         return OsgiHeaderUtil.buildHeader(originalImports);
394     }
395 
396     private boolean manifestDoesntHaveRequiredOsgiHeader(final Manifest mf, final Entry<String, String> entry)
397     {
398         if (mf.getMainAttributes().containsKey(new Attributes.Name(entry.getKey())))
399         {
400             return !entry.getValue().equals(mf.getMainAttributes().getValue(entry.getKey()));
401         }
402         return true;
403     }
404 
405     private static void header(final Properties properties, final String key, final Object value)
406     {
407         if (value == null)
408         {
409             return;
410         }
411 
412         if (value instanceof Collection && ((Collection) value).isEmpty())
413         {
414             return;
415         }
416 
417         properties.put(key, value.toString().replaceAll("[\r\n]", ""));
418     }
419 
420     private void assertSpringAvailableIfRequired(final TransformContext context)
421     {
422         if (isAtlassianDevMode() && context.shouldRequireSpring())
423         {
424             final String header = context.getManifest().getMainAttributes().getValue(SPRING_CONTEXT);
425             if (header == null)
426             {
427                 log.debug("Manifest has no 'Spring-Context:' header. Prefer the header 'Spring-Context: *' in the jar '{}'.",
428                         context.getPluginArtifact());
429             }
430             else if (header.contains(";timeout:="))
431             {
432                 log.warn("Manifest contains a 'Spring-Context:' header with a timeout, namely '{}'. This can cause problems as the "
433                         + "timeout is server specific. Use the header 'Spring-Context: *' in the jar '{}'.",
434                         header, context.getPluginArtifact());
435             }
436             // else there's a Spring-Context with no timeout:=, which is fine.
437         }
438     }
439 
440 }