Gradle Equinox Plugin – Part 5: How to install bundles in custom container

updated_logo_for_gradle

In the previous part, I described, how to create a container with available console. But at the current state, the user cannot choose what to install in the framework. To solve this issue, must be provided a mechanism for configuration. Two options are possible:

  • Implement plugin configuration properties, e.g. convention mapping
  • Externalize the tasks from the plugin’s apply method as separate task class. I prefer to use this option. It allows to define custom tasks with that type, not using only the default one.

Let’s start, but first I want to write a little more about how Gradle framework works.
What is the buildscript? – The build script is a set of actions that will be executed in some predefined order and perform certain operations. These actions or group of actions are called Task, which is a part of parent entity called Project. The smallest unit which Gradle can execute is a Task.
Gradle is a language for dependency based programming. This means that can be defined tasks and dependencies between tasks. These tasks are executed in the order of their dependencies and each task is executed only once. Gradle builds a complete dependency graph (DAG) before any task execution.
Gradle build lifecycle is based on three phases:

    1. Initialization – Gradle determines which projects are going to take part in the build and creates a Project instance for each of these projects.
    2. Configuration – Gradle configurates the project objects. Executes the build scripts of all projects which are part of the build, but tasks will not be executed, only the DAG (Directed Analityc Graph) would be created for all tasks.
      Since Gradle 1.4 is supported configuration on demand allowing to configure only the relevant projects – Configuration On Demand
    3. Execution – Gradle executes all tasks (actions) as per the order given in the command line. If any dependencies exist between tasks, those relationships will be honored first before the command like ordering.

I externalized the logic from the plugin class as separate task. The code is available at github if you are looking the whole file.

To define a Gradle task, the DefaultTask class from the Gradle API must be extended:

class CreateEquinoxContainer extends DefaultTask{

As next step, must be specified a task action which will be executed. To do that, a method must be annotated with @TaskAction:

@TaskAction
def build() {

}

As I mentioned above, a mechanism for task configuration is needed. The Gradle API provides annotations (@Input, @Optional) which specify the required properties for the configuration of the task.

  • @Input – annotates a property or getter method indicating that the property specifies some input value for the task. The task will be considered out-of-date when the property is changed. I will not describe the up-to-date check in this article, for more information – Gradle Up-to-date checks (AKA Incremental build) doc. Just want to mention, if you need to use the check for a file content not for the path, the @InputFile or @InputDirectory annotations must be used.
  • @Optional – mark the property as optional.

For the project, I defined two @Input properties:

/**
* The name of the container which will be created.
* The property is introduced in order to create multiple different instances.
*/
@Input
def containerName
/**
* The Equinox kernel dependency.
* It will be passed to the Gradle DependencyHandler with the kernel configuration.
*/
@Input
@Optional
def equinoxNotation = [group:'org.eclipse', name: 'osgi', version: '3.10.0-v20140606-1445', configuration: 'runtime']

The container name is needed to support creation of multiple framework containers. It will be used in the name of the Gradle configurations and tasks during their creation which will ensure that they are unique. This property is mandatory.
The equinoxNotation property is introduced to provide possibility to specify an Equinox container with different group, name, version or configuration.

The last and most important thing is the user to be able to specify a list with the bundles that should be installed. To achieve that, I created a method called install which expects a start level and Configuration obeject. When the method is invoked it will put the provided arguments in a map. The map has an integer key representing the start level of the bundles and a list of configurations as value represing the bundles which should be installed. During the task action execution all artifacts from the configuration will be obtained and installed in the framework with the coresponding start level:

/**
 * Adds bundles for installation in the Equinox framework with specific start level.
 * @param startLevel The start level of the configuration dependencies
 * @param configuration A configuration with already defined dependencies. The task will extract the bundles
 * and install them in the created framework.
 */
void install(int startLevel, Configuration configuration) {
	if(bundlesInstallationMap[startLevel] == null) {
		bundlesInstallationMap[startLevel] = [configuration]
	} else {
		bundlesInstallationMap[startLevel] << configuration
	}
}

Also one more install method is available. It requires only Configuration parameter. For this configuration will be used a default start level:

/**
* Adds bundles for installation in the Equinox framework with default start level - 4
* @param configuration A configuration with already defined dependencies. Tha task will extract the bundles
* and install them in the created framework.
*/
void install(Configuration configuration) {
install(4, configuration)
}

Let’s see the actual logic which creates the container. As in the previous part will be needed a configuration for the kernel to get the Equinox jar and a configuration for the default bundles (boot bundles) which will be installed, e.g. a console:

// Creation of the kernel configuration
def kernelConfigurationName = "${containerName}Kernel" // Ensures that the name of the configuration will be unique for enevy equinox container
logger.debug "Creating configuration: $kernelConfigurationName for the the Equinox Kernel"
kernelConfiguration = project.configurations.create kernelConfigurationName // Creates the configuration for the project

// Setting the kernel as dependency to the kernel configuration
logger.debug "Adding dependency notation: $equinoxNotation to configuration: $kernelConfiguration.name"
project.dependencies.add kernelConfiguration.name, equinoxNotation // Adding the Equinox as dependency of the kernel configuration

// Creation of the boot configuration
def bootConfigurationName = "${containerName}Boot" // Ensures that the name of the configuration will be unique for enevy equinox container
logger.debug "Creating configuration: $bootConfigurationName for the the Equinox boot bundles"
Configuration bootConfiguration = project.configurations.create bootConfigurationName, { // Creates the configuration for the project
	transitive = false // Only the first level dependencies
}

// Setting the boot bundles as dependencies to the boot configuration
project.dependencies.add bootConfiguration.name, [group:'org.apache.felix', name: 'org.apache.felix.gogo.shell', version: '0.12.0']
project.dependencies.add bootConfiguration.name, [group:'org.apache.felix', name: 'org.apache.felix.gogo.runtime', version: '0.12.0']
project.dependencies.add bootConfiguration.name, [group:'org.apache.felix', name: 'org.apache.felix.gogo.command', version: '0.12.0']

install(1, bootConfiguration) // Adding the boot configuration for installation with start level 1

Now let’s implement the task action which will be executed during the execution phase:

/**
 * The task action which creates the config.ini file.
 */
@TaskAction
def build() {
	if(equinoxBuildDir.exists()) {
		// If the build directory for the container already exists,
		// the task assume that another container with the same name is available
		throw new GradleException("Container with name $containerName already exists.")
	}

	logger.info "Creating container: $containerName with directory: $equinoxBuildDir"

	Path equinoxKernelFile = kernelConfiguration.incoming.files.singleFile.toPath()
	// Definition of the configuration properties
	// TODO Add a mechanism allowing override of the default configuration properties
	Properties configurationProperties = new Properties()
	configurationProperties['osgi.install.area'] = equinoxBuildDir.toPath().toAbsolutePath().toUri().toString()
	configurationProperties['osgi.framework'] = "file:${equinoxBuildDir.toPath().relativize(equinoxKernelFile)}".toString()
	configurationProperties['osgi.noShutdown'] = 'true'
	configurationProperties['eclipse.consoleLog'] = 'true'
	configurationProperties['eclipse.ignoreApp'] = 'true'
	configurationProperties['osgi.bundles'] = generateInstallationBundlesList()

	logger.debug 'Generated config.ini file:'
	logger.debug configurationProperties.toMapString()

	// Storing the config.ini file
	def equinoxConfigurationDir = new File(equinoxBuildDir, 'configuration')
	equinoxConfigurationDir.mkdirs()
	def equinoxConfigurationFile = new File(equinoxConfigurationDir, 'config.ini')
	equinoxConfigurationFile.withOutputStream {
		logger.debug "Storing the config.ini file to $equinoxConfigurationFile.path"
		configurationProperties.store it, null/*No comments*/
	}
}

The code is similar to this from the previous part, but the new thing the call of the generateInstallationBundlesList() method which creates the list with the bundles for installation:

/**
 * Generates a list with bundles which will be installed in the Equinox framework.
 * @return A comma-separated list with the bundles for installation with format:
 * reference:file:<file-path>@<start-lervel>[:<auto-start-flag>]
 */
protected String generateInstallationBundlesList() {
	def result = bundlesInstallationMap.collect {int startLevel, List configurations ->
		configurations.findResults { configuration ->
			configuration.findResults { file ->
				file.withInputStream { stream ->
					JarInputStream jarInputStream = null
					try {
						jarInputStream = new JarInputStream(stream)
						java.util.jar.Manifest mf = jarInputStream.manifest
						if(mf != null) {
							mf.mainAttributes.containsKey(new java.util.jar.Attributes.Name('Fragment-Host')) ?
									"reference:file:$file.path@$startLevel" :
									"reference:file:$file.path@$startLevel:start"
						} else {
							logger.warn "File with null manifest found: $file"
							"reference:file:$file.path@$startLevel"
						}
					} finally {
						if(jarInputStream != null) {
							jarInputStream.close()
						}
						if(stream != null) {
							stream.close()
						}
					}
				}
			}.join(',')
		}.join(',')
	}.join(',')
	logger.info "Generated installation property value: $result"
	result
}

As I mentioned above, there is a map called bundlesInstallationMap which contains all configurations for installation. But the configuration’s artifacts must be scanned to obtain the file paths and to check whether the artifact is a fragment.
To iterate over the map, I using the Groovy goodnesses. First from the map must be created a list with the bundles and their start levels. The collect method transforms the map to list with the returned elements from the closure. The value of the map is a list itself, so another loop is needed to process the configurations list. To iterate over the configuration, I’m using the findResults method which returns a list from the iteration with the returned values from its closure. The list of the configurations has Configuration objects which are also collections, so one more loop will be needed to iterate over the files from the Configuration object. The last loop returns strings in the format which Equinox can read, it is described in the previous part.
The collect and findResults methods return collections and to transform them to comma-separated lists, the join method is used.
I’ll try to illustrate it:
At the begging map looks like:

[startLevel:List]->[startLevel:List<List>] or simplified [startLevel:[[fileConf1,fileConf1,...],[fileConf2,fileConf2,...]]]

The last loop transforms it to

[startLevel:[["reference:file:$fileConf1.path@$startLevel","reference:file:$fileConf1.path@$startLevel",...],["reference:file:$fileConf2.path@$startLevel","reference:file:$fileConf2.path@$startLevel",...]]]

and after the join

[startLevel:["reference:file:$fileConf1.path@$startLevel,reference:file:$fileConf1.path@$startLevel,...","reference:file:$fileConf2.path@$startLevel,reference:file:$fileConf2.path@$startLevel,..."]]

The second loop with join transforms the result from the previous one to:

[startLevel:"reference:file:$fileConf1.path@$startLevel,reference:file:$fileConf1.path@$startLevel,...,reference:file:$fileConf2.path@$startLevel,reference:file:$fileConf2.path@$startLevel,..."]

The collect of the map return the following list:

["reference:file:$fileConf1.path@$startLevel,reference:file:$fileConf1.path@$startLevel,...,reference:file:$fileConf2.path@$startLevel,reference:file:$fileConf2.path@$startLevel,..."]

And the join transforms it to string:

"reference:file:$fileConf1.path@$startLevel,reference:file:$fileConf1.path@$startLevel,...,reference:file:$fileConf2.path@$startLevel,reference:file:$fileConf2.path@$startLevel,..."

Now the config.ini file available and a startup script is needed. For the startup I’m using the same approach as in the previous post, getting it from the resources and replacing the placeholders:

Task startupScriptTask = project.tasks.create "create${containerName.capitalize()}StartupScript", Copy.class, { // Using the Gradle Copy task
	boolean isWindows = System.properties['os.name'].toLowerCase().contains('windows') // Checking the OS familly
	def startupFile = "equinox.${isWindows ? 'bat' : 'sh'}" // Generating the file name based on the OS
	logger.debug "Getting Equinox startup file: $startupFile"

	from new File(CreateEquinoxContainer.classLoader.getResource("bin/$startupFile").file) // Getting the file from the resources
	into new File(equinoxBuildDir, 'bin') // Places the file in the bin directory

	doFirst { 
		// Attaching an action before the execution of the Copy task action
		// Configuration.incoming resolves the configuration and this should be done in the execution phase, not in the configuration phase
		
		// Specifying the filters which will replace the placeholders
		// from the included startup scripts

		filter {
			// Setting the Equinox kernel jar file path
			it.replace('{{OSGI_FILE}}', kernelConfiguration.incoming.files.singleFile.path)
		}

		filter {
			// Setting the configuration directory
			it.replace('{{CONFIG_FILE}}', "$equinoxBuildDir/configuration")
		}
	}
}

Now final touches like adding group and description to the tasks:

Task configure(Closure configureClosure) {
	...
	group 'Equinox Generation'
	description 'Creates an Equinox container instance'
Task startupScriptTask = project.tasks.create "create${containerName.capitalize()}StartupScript", Copy.class, { // Using the Gradle Copy task
	group 'Equinox Generation'
	description 'Generates a startup script for Equinox container instance'

The result from the group and the description will be show, when tasks is being executed:

>gradlew tasks

I’ll change the test to execute tasks, just to show the result:

when:
def result = GradleRunner.create()
		.withProjectDir(testBuildDir)
		.withArguments('tasks', '-i', '-s')
		.withPluginClasspath(pluginClasspath)
		.build()

gradle-tasks-group

To set dependency to the startup task:

Task configure(Closure configureClosure) {
...
	// The startup script generation will be execution after the
	// creation of the configuration
	finalizedBy startupScriptTask
}

After the config.ini generation task will be executed the startup script creation task.

Still when the user apply the plugin, it must define a custom CreateEquinoxContainer task, but I want to provide a default one. This can be done in the apply method on the plugin:

@Override
void apply(Project project) {
	...

	// Defines the default task for creation of Equinox container
	project.tasks.create 'createEquinoxContainer', CreateEquinoxContainer.class, {
		containerName = 'equinox'
	}
}

Now let’s execute the test which creates the container. Just the task name for execution must be changed to createEquinoxContainer:

def result = GradleRunner.create()
		.withProjectDir(testBuildDir)
		.withArguments('createEquinoxContainer', '-i', '-s')
		.withPluginClasspath(pluginClasspath)
		.build()

The result must be the same as this from the previous part. But if I want another conatiner, I can define my own CreateEquinoxContainer task. Let’s write another test which creates two Equinox containers:

    def "create-custom-equinox-containers-test"() {
        given:
        def testBuildDir = new File(testsBuildDir, name.getMethodName())
        testBuildDir.deleteDir()
        testBuildDir.mkdirs()
        buildFile = new File(testBuildDir, 'build.gradle')
        buildFile.createNewFile()

        buildFile << """
plugins {
    id 'name.mazgalov.equinox'
}

project.repositories {
    mavenCentral()
}

configurations {
    ds {
        transitive = false
    }
    jline
}

dependencies {
    ds group: 'org.apache.felix', name: 'org.apache.felix.scr', version: '2.0.6'
    ds group: 'org.apache.felix', name: 'org.apache.felix.configadmin', version: '1.8.10'
    jline group: 'org.apache.servicemix.bundles', name: 'org.apache.servicemix.bundles.jline', version: '0.9.94_1'
}

task 'createFirstContainer', type: name.mazgalov.equinox.task.CreateEquinoxContainer, {
    containerName = 'firstEquinox'
    install 2, configurations.ds
}

task 'createSecondContainer', type: name.mazgalov.equinox.task.CreateEquinoxContainer, {
    containerName = 'secondEquinox'
    install configurations.jline
}
"""

        when:
        println pluginClasspath
        def result = GradleRunner.create()
                .withProjectDir(testBuildDir)
                .withArguments('createFirstContainer', 'createSecondContainer', '-s')
                .withPluginClasspath(pluginClasspath)
                .build()

        then:
        printTestOutput result
    }

The result on the file system after the tests execution is:
equinox-multiple-containers

When I start the first container and list the installed bundles, these from the ds configuration are available:
equinox-ds-console

It is similar with the second container. The jline bundle is available:
equinox-jline-console

As you can see, in the console is printed:

initial@reference:file:../../../../junit/junit/3.8.1/99129f16442844f6a4a11ae22fbbee40b14d774f/junit-3.8.1.jar (0.0.0)

It comes as transitive dependency to jline. In the test build script, I set only the ds configuration as transitive = false.

As next step, I’m planing some code improvements and publication of the first version of the plugin to plugins.gradle.org.

I hope you can find something interesting in the articles. Enjoy 🙂

elephant-th

One thought on “Gradle Equinox Plugin – Part 5: How to install bundles in custom container

Leave a Reply

Your email address will not be published. Required fields are marked *