001/* 002 * (C) Copyright 2006-2018 Nuxeo (http://nuxeo.com/) and others. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 * 016 * Contributors: 017 * Nuxeo - initial API and implementation 018 */ 019package org.nuxeo.runtime.test; 020 021import static java.util.stream.Collectors.joining; 022 023import java.io.File; 024import java.io.IOException; 025import java.net.MalformedURLException; 026import java.net.URI; 027import java.net.URISyntaxException; 028import java.net.URL; 029import java.nio.file.Files; 030import java.util.ArrayList; 031import java.util.Arrays; 032import java.util.HashMap; 033import java.util.HashSet; 034import java.util.List; 035import java.util.Map; 036import java.util.Objects; 037import java.util.Properties; 038import java.util.Set; 039import java.util.jar.Attributes; 040import java.util.jar.Manifest; 041import java.util.stream.Collectors; 042import java.util.stream.Stream; 043 044import org.apache.commons.io.FileUtils; 045import org.apache.logging.log4j.LogManager; 046import org.apache.logging.log4j.Logger; 047import org.nuxeo.common.Environment; 048import org.nuxeo.osgi.BundleFile; 049import org.nuxeo.osgi.BundleImpl; 050import org.nuxeo.osgi.DirectoryBundleFile; 051import org.nuxeo.osgi.JarBundleFile; 052import org.nuxeo.osgi.OSGiAdapter; 053import org.nuxeo.osgi.SystemBundle; 054import org.nuxeo.osgi.SystemBundleFile; 055import org.nuxeo.osgi.application.StandaloneBundleLoader; 056import org.nuxeo.runtime.RuntimeServiceException; 057import org.nuxeo.runtime.api.Framework; 058import org.nuxeo.runtime.model.Extension; 059import org.nuxeo.runtime.model.RegistrationInfo; 060import org.nuxeo.runtime.model.RuntimeContext; 061import org.nuxeo.runtime.model.StreamRef; 062import org.nuxeo.runtime.model.URLStreamRef; 063import org.nuxeo.runtime.model.impl.DefaultRuntimeContext; 064import org.nuxeo.runtime.osgi.OSGiRuntimeContext; 065import org.nuxeo.runtime.osgi.OSGiRuntimeService; 066import org.nuxeo.runtime.test.runner.RuntimeHarness; 067import org.nuxeo.runtime.test.runner.TargetExtensions; 068import org.nuxeo.runtime.transaction.TransactionHelper; 069import org.osgi.framework.Bundle; 070import org.osgi.framework.FrameworkEvent; 071 072import io.github.lukehutch.fastclasspathscanner.FastClasspathScanner; 073 074/** 075 * Default RuntimeHarness implementation. 076 * 077 * @since 10.2 078 */ 079public class RuntimeHarnessImpl implements RuntimeHarness { 080 081 protected static final Logger log = LogManager.getLogger(RuntimeHarnessImpl.class); 082 083 protected static URL[] introspectClasspath() { 084 return new FastClasspathScanner().getUniqueClasspathElements().stream().map(file -> { 085 try { 086 return file.toURI().toURL(); 087 } catch (MalformedURLException cause) { 088 throw new RuntimeServiceException("Could not get URL from " + file, cause); 089 } 090 }).toArray(URL[]::new); 091 } 092 093 protected StandaloneBundleLoader bundleLoader; 094 095 protected Map<String, BundleFile> bundles; 096 097 protected boolean frameworkStarted; 098 099 protected OSGiAdapter osgi; 100 101 protected Set<URI> readUris; 102 103 protected OSGiRuntimeService runtime; 104 105 protected Bundle runtimeBundle; 106 107 protected TargetResourceLocator targetResourceLocator; 108 109 protected URL[] urls; // classpath urls, used for bundles lookup 110 111 protected List<WorkingDirectoryConfigurator> wdConfigs; 112 113 protected File workingDir; 114 115 protected RuntimeHarnessImpl() { 116 wdConfigs = new ArrayList<>(); 117 } 118 119 public RuntimeHarnessImpl(Class<?> clazz) { 120 this(); 121 targetResourceLocator = new TargetResourceLocator(clazz); 122 } 123 124 @Override 125 public void addWorkingDirectoryConfigurator(WorkingDirectoryConfigurator config) { 126 wdConfigs.add(config); 127 } 128 129 @Override 130 public void deployBundle(String name) throws Exception { 131 // install only if not yet installed 132 Bundle bundle = bundleLoader.getOSGi().getRegistry().getBundle(name); 133 if (bundle == null) { 134 BundleFile bundleFile = lookupBundle(name); 135 bundleLoader.loadBundle(bundleFile); 136 bundleLoader.installBundle(bundleFile); 137 bundle = bundleLoader.getOSGi().getRegistry().getBundle(name); 138 } else { 139 log.info("A bundle with name {} has been found. Deploy is ignored.", name); 140 } 141 if (runtime.getContext(bundle) == null) { 142 runtime.createContext(bundle); 143 } 144 } 145 146 @Override 147 public void deployContrib(String name, String contrib) throws Exception { 148 RuntimeContext context = runtime.getContext(name); 149 if (context == null) { 150 context = runtime.getContext(); 151 BundleFile file = lookupBundle(name); 152 URL location = file.getEntry(contrib); 153 if (location == null) { 154 throw new AssertionError("Cannot locate " + contrib + " in " + name); 155 } 156 context.deploy(location); 157 } else { 158 context.deploy(contrib); 159 } 160 } 161 162 @Override 163 @Deprecated 164 public void deployFolder(File folder, ClassLoader loader) throws Exception { 165 DirectoryBundleFile bf = new DirectoryBundleFile(folder); 166 BundleImpl bundle = new BundleImpl(osgi, bf, loader); 167 osgi.install(bundle); 168 } 169 170 @Override 171 public RuntimeContext deployPartial(String name, Set<TargetExtensions> targetExtensions) throws Exception { 172 // Do not install bundle; we only need the Object to list his components 173 Bundle bundle = new BundleImpl(osgi, lookupBundle(name), getClass().getClassLoader()); 174 RuntimeContext ctx = new OSGiRuntimeContext(runtime, bundle); 175 listBundleComponents(bundle).map(URLStreamRef::new).forEach(component -> { 176 try { 177 deployPartialComponent(ctx, targetExtensions, component); 178 } catch (IOException e) { 179 log.error("PartialBundle: {} failed to load: {}", name, component, e); 180 } 181 }); 182 return ctx; 183 } 184 185 @Override 186 @Deprecated 187 public RuntimeContext deployTestContrib(String bundle, String contrib) throws Exception { 188 URL url = targetResourceLocator.getTargetTestResource(contrib); 189 return deployTestContrib(bundle, url); 190 } 191 192 @Override 193 @Deprecated 194 public RuntimeContext deployTestContrib(String bundle, URL contrib) throws Exception { 195 Bundle b = bundleLoader.getOSGi().getRegistry().getBundle(bundle); 196 if (b == null) { 197 b = osgi.getSystemBundle(); 198 } 199 OSGiRuntimeContext ctx = new OSGiRuntimeContext(runtime, b); 200 ctx.deploy(contrib); 201 return ctx; 202 } 203 204 @Override 205 public void fireFrameworkStarted() { 206 if (frameworkStarted) { 207 throw new IllegalStateException("fireFrameworkStarted must not be called more than once"); 208 } 209 frameworkStarted = true; 210 boolean txStarted = !TransactionHelper.isTransactionActiveOrMarkedRollback() 211 && TransactionHelper.startTransaction(); 212 boolean txFinished = false; 213 try { 214 osgi.fireFrameworkEvent(new FrameworkEvent(FrameworkEvent.STARTED, runtimeBundle, null)); 215 txFinished = true; 216 } finally { 217 if (!txFinished) { 218 TransactionHelper.setTransactionRollbackOnly(); 219 } 220 if (txStarted) { 221 TransactionHelper.commitOrRollbackTransaction(); 222 } 223 } 224 } 225 226 @Override 227 @Deprecated 228 public List<String> getClassLoaderFiles() throws URISyntaxException { 229 List<String> files = new ArrayList<>(urls.length); 230 for (URL url : urls) { 231 files.add(url.toURI().getPath()); 232 } 233 return files; 234 } 235 236 @Override 237 public RuntimeContext getContext() { 238 return runtime.getContext(); 239 } 240 241 @Override 242 public OSGiAdapter getOSGiAdapter() { 243 return osgi; 244 } 245 246 @Override 247 @Deprecated 248 public Properties getProperties() { 249 return runtime.getProperties(); 250 } 251 252 @Override 253 public File getWorkingDir() { 254 return workingDir; 255 } 256 257 @Override 258 public boolean isRestart() { 259 return false; 260 } 261 262 @Override 263 public boolean isStarted() { 264 return runtime != null; 265 } 266 267 @Override 268 public void restart() throws Exception { 269 // do nothing 270 } 271 272 @Override 273 public void start() throws Exception { 274 System.setProperty("org.nuxeo.runtime.testing", "true"); 275 wipeEmptyTestSystemProperties(); 276 wipeRuntime(); 277 initUrls(); 278 if (urls == null) { 279 throw new UnsupportedOperationException("no bundles available"); 280 } 281 initOsgiRuntime(); 282 } 283 284 @Override 285 public void stop() throws Exception { 286 wipeRuntime(); 287 if (workingDir != null) { 288 if (workingDir.exists() && !FileUtils.deleteQuietly(workingDir)) { 289 log.warn("Cannot delete {}", workingDir); 290 } 291 workingDir = null; 292 } 293 readUris = null; 294 bundles = null; 295 } 296 297 @Override 298 public void undeployContrib(String name, String contrib) { 299 RuntimeContext context = runtime.getContext(name); 300 if (context == null) { 301 context = runtime.getContext(); 302 } 303 context.undeploy(contrib); 304 } 305 306 /** 307 * Read a component from his StreamRef and create a new component (suffixed with `-partial`, and the base component 308 * name aliased) with only matching contributions of the extensionPoints parameter. 309 * 310 * @param ctx RuntimeContext in which the new component will be deployed 311 * @param extensionPoints Set of white listed TargetExtensions 312 * @param component Reference to the original component 313 * @throws IOException Signals that an I/O exception has occurred. 314 */ 315 protected void deployPartialComponent(RuntimeContext ctx, Set<TargetExtensions> extensionPoints, 316 StreamRef component) throws IOException { 317 RegistrationInfo ri = ((DefaultRuntimeContext) ctx).createRegistrationInfo(component); 318 String name = ri.getName().getName() + "-partial"; 319 320 // Flatten Target Extension Points 321 Set<String> targets = extensionPoints.stream() 322 .map(TargetExtensions::getTargetExtensions) 323 .flatMap(Set::stream) 324 .collect(Collectors.toSet()); 325 326 String ext = Arrays.stream(ri.getExtensions()) 327 .filter(e -> targets.contains(TargetExtensions.newTargetExtension( 328 e.getTargetComponent().getName(), e.getExtensionPoint()))) 329 .map(Extension::toXML) 330 .collect(joining()); 331 332 ctx.deploy(new InlineRef(name, String.format("<component name=\"%s\">%s</component>", name, ext))); 333 } 334 335 /** 336 * Inits the osgi runtime. 337 * 338 * @throws Exception the exception 339 */ 340 protected void initOsgiRuntime() throws Exception { 341 try { 342 Environment.setDefault(null); 343 if (System.getProperties().remove("nuxeo.home") != null) { 344 log.warn("Removed System property nuxeo.home."); 345 } 346 workingDir = File.createTempFile("nxruntime-" + Thread.currentThread().getName() + "-", null, 347 new File("target")); 348 Files.delete(workingDir.toPath()); 349 } catch (IOException e) { 350 log.error("Could not init working directory", e); 351 throw e; 352 } 353 osgi = new OSGiAdapter(workingDir); 354 BundleFile bf = new SystemBundleFile(workingDir); 355 bundleLoader = new StandaloneBundleLoader(osgi, RuntimeHarnessImpl.class.getClassLoader()); 356 SystemBundle systemBundle = new SystemBundle(osgi, bf, bundleLoader.getSharedClassLoader().getLoader()); 357 osgi.setSystemBundle(systemBundle); 358 Thread.currentThread().setContextClassLoader(bundleLoader.getSharedClassLoader().getLoader()); 359 360 for (WorkingDirectoryConfigurator cfg : wdConfigs) { 361 cfg.configure(this, workingDir); 362 } 363 364 bundleLoader.setScanForNestedJARs(false); // for now 365 bundleLoader.setExtractNestedJARs(false); 366 367 BundleFile bundleFile = lookupBundle("org.nuxeo.runtime"); 368 runtimeBundle = new RootRuntimeBundle(osgi, bundleFile, bundleLoader.getClass().getClassLoader(), true); 369 runtimeBundle.start(); 370 371 runtime = (OSGiRuntimeService) Framework.getRuntime(); 372 373 } 374 375 /** 376 * Inits the urls. 377 */ 378 protected void initUrls() { 379 urls = introspectClasspath(); 380 log.debug("URLs on the classpath:\n{}", () -> Stream.of(urls).map(URL::toString).collect(joining("\n"))); 381 readUris = new HashSet<>(); 382 bundles = new HashMap<>(); 383 } 384 385 /** 386 * Listing component's urls of a bundle. Inspired from org.nuxeo.runtime.osgi.OSGiRuntimeService#loadComponents but 387 * without deploying anything. 388 * 389 * @param bundle Bundle to be read 390 * @return the stream 391 */ 392 protected Stream<URL> listBundleComponents(Bundle bundle) { 393 String list = OSGiRuntimeService.getComponentsList(bundle); 394 String name = bundle.getSymbolicName(); 395 log.debug("PartialBundle: {} components: {}", name, list); 396 if (list == null) { 397 return Stream.empty(); 398 } else { 399 return Arrays.stream(list.split("[, \t\n\r\f]")).map(bundle::getEntry).filter(Objects::nonNull); 400 } 401 } 402 403 /** 404 * Lookup bundle. 405 * 406 * @param bundleName the bundle name 407 * @return the bundle file 408 * @throws Exception the exception 409 */ 410 protected BundleFile lookupBundle(String bundleName) throws Exception { 411 BundleFile bundleFile = bundles.get(bundleName); 412 if (bundleFile != null) { 413 return bundleFile; 414 } 415 for (URL url : urls) { 416 URI uri = url.toURI(); 417 if (readUris.contains(uri)) { 418 continue; 419 } 420 File file = new File(uri); 421 readUris.add(uri); 422 try { 423 if (file.isDirectory()) { 424 bundleFile = new DirectoryBundleFile(file); 425 } else { 426 bundleFile = new JarBundleFile(file); 427 } 428 } catch (IOException e) { 429 // no manifest => not a bundle 430 continue; 431 } 432 String symbolicName = readSymbolicName(bundleFile); 433 if (symbolicName != null) { 434 log.debug("Bundle '{}' has URL {}", symbolicName, url); 435 bundles.put(symbolicName, bundleFile); 436 } 437 if (bundleName.equals(symbolicName)) { 438 return bundleFile; 439 } 440 } 441 throw new RuntimeServiceException(String.format("No bundle with symbolic name '%s';", bundleName)); 442 } 443 444 /** 445 * Read symbolic name. 446 * 447 * @param bf the bf 448 * @return the string 449 */ 450 protected String readSymbolicName(BundleFile bf) { 451 Manifest manifest = bf.getManifest(); 452 if (manifest == null) { 453 return null; 454 } 455 Attributes attrs = manifest.getMainAttributes(); 456 String name = attrs.getValue("Bundle-SymbolicName"); 457 if (name == null) { 458 return null; 459 } 460 String[] sp = name.split(";", 2); 461 return sp[0]; 462 } 463 464 /** 465 * Makes sure there is no previous runtime hanging around. 466 * <p> 467 * This happens for instance if a previous test had errors in its <code>setUp()</code>, because 468 * <code>tearDown()</code> has not been called. 469 */ 470 protected void wipeRuntime() { 471 // Make sure there is no active runtime (this might happen if an 472 // exception is raised during a previous setUp -> tearDown is not called afterwards). 473 runtime = null; 474 frameworkStarted = false; 475 if (Framework.getRuntime() != null) { 476 try { 477 Framework.shutdown(); 478 } catch (InterruptedException cause) { 479 Thread.currentThread().interrupt(); 480 throw new RuntimeServiceException("Interrupted during shutdown", cause); 481 } 482 } 483 } 484 485 /** 486 * Removes Nuxeo test system properties that are empty. 487 * <p> 488 * This is needed when using maven surefire > 2.17 because since SUREFIRE-649 surefire propagates empty system 489 * properties. 490 */ 491 protected void wipeEmptyTestSystemProperties() { 492 List<String> emptyProps = System.getProperties() 493 .entrySet() 494 .stream() 495 .filter(this::isAnEmptyTestProperty) 496 .map(entry -> entry.getKey().toString()) 497 .collect(Collectors.toList()); 498 emptyProps.forEach(System::clearProperty); 499 if (log.isDebugEnabled()) { 500 emptyProps.forEach(property -> log.debug("Removed empty test system property: {}", property)); 501 } 502 } 503 504 protected boolean isAnEmptyTestProperty(Map.Entry<Object, Object> entry) { 505 if (!entry.getKey().toString().startsWith("nuxeo.test.")) { 506 return false; 507 } 508 return entry.getValue() == null || entry.getValue().toString().isEmpty(); 509 } 510 511}