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