Home > Apache Geronimo v1.1 > Documentation > Apache Geronimo v1.1 - User's Guide > Sample applications > Advanced plugin sample |
Code Mostly Present, Text Not Yet Finished
Here's a sample plugin setup demonstrating several advanced features. It is based on the Quartz scheduler. The plugin is separated into 3 components:
Note that once the Quartz plugin is deployed, any J2EE application can get a JNDI reference to the Quartz scheduler, making it easy for applications to work with.
Before you try this process, you should be familiar with basic GBeans. There's a much more straightforward Quartz article available at http://www-128.ibm.com/developerworks/opensource/library/os-ag-thirdparty/ if you need some background (though that covers Geronimo 1.0 syntax for the XML files, which is slightly different).
For more on Quartz, see
The goal of the basic Quartz integration is to:
The next sections will talk about deploying and managing Quartz jobs, and managing the Quartz scheduler and jobs through the Geronimo console.
As described here, this does not expose the full power of Quartz. This package does not let you configure database persistence, or deploy jobs on schedules other than Cron schedules, etc. This may or may not be sufficient for your needs, but it's certainly enough for this example.
The steps described here are:
Here's a sample Scheduler GBean:
/** * Copyright 2006 The Apache Software Foundation * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.gplugins.quartz; import java.util.Properties; import java.util.Iterator; import java.util.Map; import java.util.HashMap; import java.util.Collections; import java.util.Set; import java.io.InputStream; import org.apache.geronimo.gbean.GBeanLifecycle; import org.apache.geronimo.gbean.GBeanInfo; import org.apache.geronimo.gbean.GBeanInfoBuilder; import org.apache.geronimo.gbean.AbstractNameQuery; import org.apache.geronimo.gbean.AbstractName; import org.apache.geronimo.system.threads.ThreadPool; import org.apache.geronimo.kernel.Kernel; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.quartz.Scheduler; import org.quartz.SchedulerException; import org.quartz.JobDetail; import org.quartz.Trigger; import org.quartz.impl.StdSchedulerFactory; /** * A GBean that starts and stops the Quartz scheduler. * * @version $Rev: 355877 $ $Date: 2005-12-10 21:48:27 -0500 (Sat, 10 Dec 2005) $ */ public class QuartzSchedulerGBean implements GBeanLifecycle, QuartzScheduler { private final static Log log = LogFactory.getLog(QuartzSchedulerGBean.class); final static String GROUP_NAME="Geronimo Quartz"; private final Kernel kernel; private String threadPoolKey; private Scheduler scheduler; public QuartzSchedulerGBean(ThreadPool pool, Kernel kernel) { this.kernel = kernel; if(pool != null) { threadPoolKey = QuartzThreadPool.addPool(pool); } } public void scheduleJob(JobDetail detail, Trigger trigger) throws SchedulerException { detail.setGroup(GROUP_NAME); scheduler.scheduleJob(detail, trigger); } public void deleteJob(String jobName) throws SchedulerException { scheduler.deleteJob(jobName, GROUP_NAME); } public Map getScheduledJobs() throws SchedulerException { String[] names = scheduler.getJobNames(GROUP_NAME); Map map = new HashMap(); for (int i = 0; i < names.length; i++) { String name = names[i]; JobDetail detail = scheduler.getJobDetail(name, GROUP_NAME); Trigger[] triggers = scheduler.getTriggersOfJob(name, GROUP_NAME); if(triggers.length == 1) { map.put(detail, triggers[0]); } else { map.put(detail, null); } } return map; } public void rescheduleJob(String jobName, Trigger trigger) throws SchedulerException { Trigger[] triggers = scheduler.getTriggersOfJob(jobName, GROUP_NAME); for (int i = 0; i < triggers.length; i++) { Trigger old = triggers[i]; scheduler.unscheduleJob(old.getName(), old.getGroup()); } trigger.setJobName(jobName); trigger.setJobGroup(GROUP_NAME); scheduler.scheduleJob(trigger); } public void executeImmediately(String jobName) throws SchedulerException { scheduler.triggerJob(jobName, GROUP_NAME); } public void pauseJob(String jobName) throws SchedulerException { scheduler.pauseJob(jobName, GROUP_NAME); } public void resumeJob(String jobName) throws SchedulerException { scheduler.resumeJob(jobName, GROUP_NAME); } public QuartzJob getJob(String jobName) { Set results = kernel.listGBeans(new AbstractNameQuery(null, Collections.singletonMap("name", jobName), QuartzJob.class.getName())); if(results.size() == 0) { return null; } return (QuartzJob) kernel.getProxyManager().createProxy((AbstractName) results.iterator().next(), QuartzJob.class); } public void doStart() throws Exception { StdSchedulerFactory factory = new StdSchedulerFactory(); if(threadPoolKey != null) { Properties props = new Properties(); InputStream in = Scheduler.class.getResourceAsStream("quartz.properties"); props.load(in); in.close(); for (Iterator it = props.keySet().iterator(); it.hasNext();) { String key = (String) it.next(); if(key.startsWith("org.quartz.threadPool.")) { log.info("Ignoring thread property '"+key+"'"); it.remove(); } } props.put("org.quartz.threadPool.class", "org.gplugins.quartz.QuartzThreadPool"); props.put("org.quartz.threadPool.poolID", threadPoolKey); factory.initialize(props); } scheduler = factory.getScheduler(); scheduler.start(); } public void doStop() throws Exception { try { scheduler.shutdown(); } catch (SchedulerException e) { log.error("Quartz scheduler shutdown error", e); } } public void doFail() { } public static final GBeanInfo GBEAN_INFO; static { GBeanInfoBuilder infoFactory = GBeanInfoBuilder.createStatic("Quartz Scheduler", QuartzSchedulerGBean.class); infoFactory.addAttribute("kernel", Kernel.class, false, false); infoFactory.addReference("ThreadPool", ThreadPool.class, "GBean"); infoFactory.addInterface(QuartzScheduler.class); infoFactory.setConstructor(new String[]{"ThreadPool", "kernel"}); GBEAN_INFO = infoFactory.getBeanInfo(); } public static GBeanInfo getGBeanInfo() { return GBEAN_INFO; } }
Here are some things to note:
The management interface for the Scheduler GBean looks like this. This interface will be used by callers to interact with the GBean (and it's what an application will get if the app maps the QuartzScheduler into its JNDI space). While an interface isn't required, it's definitely recommended.
/** * Copyright 2006 The Apache Software Foundation * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.gplugins.quartz; import java.util.Map; import org.quartz.JobDetail; import org.quartz.Trigger; import org.quartz.SchedulerException; /** * Management interface for the Quartz scheduler * * @version $Rev: 355877 $ $Date: 2005-12-10 21:48:27 -0500 (Sat, 10 Dec 2005) $ */ public interface QuartzScheduler { /** * Schedules a Quartz job. * * Note that any jobs scheduled through this mechanism must have the group * name "Geronimo Quartz". The scheduler will overwrite it to be that if * necessary. */ void scheduleJob(JobDetail detail, Trigger trigger) throws SchedulerException; /** * Deletes a scheduled job. Assumes the group "Geronimo Quartz". */ void deleteJob(String jobName) throws SchedulerException; /** * Returns a Map with keys of type JobDetail and values of type Trigger * for all jobs scheduled with group "Geronimo Quartz". */ Map getScheduledJobs() throws SchedulerException; /** * Sets a new Trigger for an existing job (which must be in the group * "Geronimo Quartz"). */ void rescheduleJob(String jobName, Trigger trigger) throws SchedulerException; /** * Pauses an existing job (which must be in the group "Geronimo Quartz". */ void pauseJob(String jobName) throws SchedulerException; /** * Resumes an existing job (which must be in the group "Geronimo Quartz". */ void resumeJob(String jobName) throws SchedulerException; /** * Triggers a job (in the "Geronimo Quartz" group) to start right away. */ void executeImmediately(String jobName) throws SchedulerException; /** * Gets the job GBean for a job in the "Geronimo Quartz" group. */ QuartzJob getJob(String jobName); }
/** * Copyright 2006 The Apache Software Foundation * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.gplugins.quartz; import org.quartz.SchedulerException; /** * Management interface for a Quartz schedule job * * @version $Rev: 355877 $ $Date: 2005-12-10 21:48:27 -0500 (Sat, 10 Dec 2005) $ */ public interface QuartzJob { String getCronExpression(); void setCronExpression(String cronExpression) throws SchedulerException; String getJobClass(); void setJobClass(String jobClass); String getName(); void setName(String name); void pause() throws SchedulerException; void resume() throws SchedulerException; void execute() throws SchedulerException; }
/** * Copyright 2006 The Apache Software Foundation * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.gplugins.quartz; import java.text.ParseException; import org.apache.geronimo.gbean.GBeanLifecycle; import org.apache.geronimo.gbean.GBeanInfo; import org.apache.geronimo.gbean.GBeanInfoBuilder; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.quartz.JobDetail; import org.quartz.CronTrigger; import org.quartz.SchedulerException; /** * Represents a Quartz job. * * @version $Rev: 355877 $ $Date: 2005-12-10 21:48:27 -0500 (Sat, 10 Dec 2005) $ */ public class QuartzJobGBean implements GBeanLifecycle, QuartzJob { private final static Log log = LogFactory.getLog(QuartzJobGBean.class); private final QuartzScheduler scheduler; private final ClassLoader loader; private String name; private String jobClass; private String cronExpression; boolean running; public QuartzJobGBean(QuartzScheduler scheduler, String jobClass, String name, ClassLoader loader) { this.jobClass = jobClass; this.name = name; this.scheduler = scheduler; this.loader = loader; } public String getCronExpression() { return cronExpression; } public void setCronExpression(String cronExpression) throws SchedulerException { this.cronExpression = cronExpression; if(running) { try { scheduler.rescheduleJob(name, createTrigger()); } catch (ParseException e) { throw new SchedulerException("Unable to schedule; invalid cron expression '"+cronExpression+"'", e); } } } public String getJobClass() { return jobClass; } public void setJobClass(String jobClass) { this.jobClass = jobClass; } public String getName() { return name; } public void setName(String name) { this.name = name; } public void pause() throws SchedulerException { if(running) { scheduler.pauseJob(name); } } public void resume() throws SchedulerException { if(running) { scheduler.resumeJob(name); } } public void execute() throws SchedulerException { if(running) { scheduler.executeImmediately(name); } } public void doStart() throws Exception { log.info("Scheduling job '"+name+"'"); Class cls = loader.loadClass(jobClass); JobDetail jd = new JobDetail(name, QuartzSchedulerGBean.GROUP_NAME, cls); CronTrigger cronTrigger = createTrigger(); scheduler.scheduleJob(jd, cronTrigger); running = true; } public void doStop() throws Exception { running = false; scheduler.deleteJob(name); } public void doFail() { running = false; } private CronTrigger createTrigger() throws ParseException { CronTrigger cronTrigger = new CronTrigger(name+" Trigger", QuartzSchedulerGBean.GROUP_NAME); cronTrigger.setCronExpression(cronExpression); return cronTrigger; } public static final GBeanInfo GBEAN_INFO; static { GBeanInfoBuilder infoFactory = GBeanInfoBuilder.createStatic("Quartz Job", QuartzJobGBean.class); infoFactory.addAttribute("classLoader", ClassLoader.class, false); infoFactory.addReference("QuartzScheduler", QuartzScheduler.class, "GBean"); infoFactory.addInterface(QuartzJob.class, new String[]{"cronExpression", "jobClass","name"}, new String[]{"cronExpression", "jobClass","name"}); infoFactory.setConstructor(new String[]{"QuartzScheduler", "jobClass", "name", "classLoader"}); GBEAN_INFO = infoFactory.getBeanInfo(); } public static GBeanInfo getGBeanInfo() { return GBEAN_INFO; } }
/** * Copyright 2006 The Apache Software Foundation * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.gplugins.quartz; import java.util.Map; import java.util.HashMap; import org.apache.geronimo.system.threads.ThreadPool; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.quartz.SchedulerConfigException; /** * A wrapper around a Geronimo thread pool for usage by Quartz. * * @version $Rev: 355877 $ $Date: 2005-12-10 21:48:27 -0500 (Sat, 10 Dec 2005) $ */ public class QuartzThreadPool implements org.quartz.spi.ThreadPool { private final static Log log = LogFactory.getLog(QuartzThreadPool.class); static Map geronimoPools = new HashMap(); static String addPool(ThreadPool pool) { synchronized(QuartzThreadPool.class) { int count = geronimoPools.size(); String key = Integer.toString(count); geronimoPools.put(key, pool); return key; } } private ThreadPool geronimoPool; private String poolID; public int getPoolSize() { return geronimoPool.getPoolSize(); } public void initialize() throws SchedulerConfigException { log.info("Geronimo Quartz thread pool starting with Pool ID "+poolID); geronimoPool = (ThreadPool) geronimoPools.get(poolID); if(geronimoPool == null) { throw new SchedulerConfigException("No Geronimo thread pool available ("+poolID+")!"); } } public boolean runInThread(Runnable runnable) { try { geronimoPool.execute("Quartz Scheduled Job", runnable); } catch (InterruptedException e) { log.error("Unable to complete schedule job", e); return false; } return true; } public void shutdown(boolean b) { geronimoPools.remove(poolID); } public String getPoolID() { return poolID; } public void setPoolID(String poolID) { this.poolID = poolID; } }
If you compile the previous classes and put them in a JAR, you can create the following deployment plan and either keep it outside the JAR or save it to META-INF/geronimo-service.xml in the JAR. For example, the JAR might look like this:
META-INF/ META-INF/MANIFEST.MF META-INF/geronimo-service.xml org/ org/gplugins/ org/gplugins/quartz/ org/gplugins/quartz/QuartzJob.class org/gplugins/quartz/QuartzJobGBean.class org/gplugins/quartz/QuartzScheduler.class org/gplugins/quartz/QuartzSchedulerGBean.class org/gplugins/quartz/QuartzThreadPool.class
The deployment plan is:
<?xml version="1.0" encoding="UTF-8"?> <module xmlns="http://geronimo.apache.org/xml/ns/deployment-1.1"> <environment> <moduleId> <groupId>gplugins</groupId> <artifactId>quartz</artifactId> <version>0.1</version> <type>car</type> </moduleId> <dependencies> <dependency> <groupId>geronimo</groupId> <artifactId>rmi-naming</artifactId> <type>car</type> </dependency> <dependency> <groupId>opensymphony</groupId> <artifactId>quartz</artifactId> <type>jar</type> </dependency> </dependencies> </environment> <gbean name="QuartzScheduler" class="org.gplugins.quartz.QuartzSchedulerGBean"> <reference name="ThreadPool"> <name>DefaultThreadPool</name> </reference> </gbean> </module>
Note that in order to deploy this, you must have Quartz in your Geronimo repository (e.g. repository/opensymphony/quartz/1.5.2/quartz-1.5.2.jar). If you install the Quartz integration as a plugin, this will be installed for you automatically.
TODO: need to write this
<?xml version="1.0" encoding="UTF-8"?> <!-- Copyright 2004 The Apache Software Foundation Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. --> <xs:schema targetNamespace="http://geronimo.apache.org/xml/ns/plugins/quartz-0.1" xmlns:job="http://geronimo.apache.org/xml/ns/plugins/quartz-0.1" xmlns:sys="http://geronimo.apache.org/xml/ns/deployment-1.1" xmlns:xs="http://www.w3.org/2001/XMLSchema" elementFormDefault="qualified" attributeFormDefault="unqualified" > <xs:annotation> <xs:documentation> Schema for a Quartz scheduled job. </xs:documentation> </xs:annotation> <xs:import namespace="http://geronimo.apache.org/xml/ns/deployment-1.1" schemaLocation="geronimo-module-1.1.xsd"/> <!-- Top-level elements --> <xs:element name="jobs" type="job:jobsType"> <xs:annotation> <xs:documentation> A list of jobs to schedule with Quartz </xs:documentation> </xs:annotation> </xs:element> <xs:complexType name="jobsType"> <xs:sequence> <xs:element ref="sys:environment"/> <xs:element name="job" type="job:jobType" minOccurs="1" maxOccurs="unbounded"> <xs:annotation> <xs:documentation> A job to schedule with Quartz </xs:documentation> </xs:annotation> </xs:element> </xs:sequence> </xs:complexType> <xs:complexType name="jobType"> <xs:sequence> <xs:element name="job-name" type="xs:string"> <xs:annotation> <xs:documentation> A unique name used to identify the Quartz job </xs:documentation> </xs:annotation> </xs:element> <xs:element name="job-class" type="xs:string"> <xs:annotation> <xs:documentation> The fully-qualified class name of the Quartz job </xs:documentation> </xs:annotation> </xs:element> <xs:choice> <xs:element name="cron-expression" type="xs:string"> <xs:annotation> <xs:documentation> A CRON-formatted expression for when the job should run </xs:documentation> </xs:annotation> </xs:element> </xs:choice> </xs:sequence> </xs:complexType> </xs:schema>
Sample:
<?xml version="1.0" encoding="UTF-8"?> <jobs xmlns="http://geronimo.apache.org/xml/ns/plugins/quartz-0.1"> <environment xmlns="http://geronimo.apache.org/xml/ns/deployment-1.1"> <moduleId> <artifactId>TestJob</artifactId> </moduleId> </environment> <job> <job-name>Test 1</job-name> <job-class>org.gplugins.quartz.jobs.TestJob</job-class> <cron-expression>0/15 * * * * ?</cron-expression> </job> </jobs>
We use Maven 2 and XMLBeans to code-generate classes corresponding to this Schema, using a Maven POM like this. (Note the extra dependency because the schema above imports the Geronimo schema, whose classes are in the geronimo-service-builder module.)
<project> <modelVersion>4.0.0</modelVersion> <groupId>gplugins</groupId> <artifactId>quartz-deployer</artifactId> <version>0.1</version> <name>Geronimo Quartz Deployer</name> <build> <plugins> <plugin> <groupId>org.codehaus.mojo</groupId> <artifactId>xmlbeans-maven-plugin</artifactId> <version>2.0</version> <executions> <execution> <goals> <goal>xmlbeans</goal> </goals> </execution> </executions> <configuration> <download>true</download> <sourceSchemas>geronimo-quartz-0.1.xsd</sourceSchemas> <schemaDirectory>src/schema</schemaDirectory> <xmlConfigs> <xmlConfig implementation="java.io.File">src/schema/xmlconfig.xml</xmlConfig> </xmlConfigs> </configuration> </plugin> </plugins> <sourceDirectory>src/java</sourceDirectory> <testSourceDirectory>src/test</testSourceDirectory> <resources> <resource> <directory>src/resources</directory> <filtering>true</filtering> </resource> </resources> <testResources> <testResource> <directory>src/test-resources</directory> </testResource> </testResources> </build> <dependencies> <dependency> <groupId>gplugins</groupId> <artifactId>quartz</artifactId> <version>0.1</version> </dependency> <dependency> <groupId>xmlbeans</groupId> <artifactId>xbean</artifactId> <version>2.0.0</version> </dependency> <dependency> <groupId>stax</groupId> <artifactId>stax-api</artifactId> <version>1.0</version> </dependency> <dependency> <groupId>org.apache.geronimo.modules</groupId> <artifactId>geronimo-kernel</artifactId> <version>1.1-SNAPSHOT</version> </dependency> <dependency> <groupId>org.apache.geronimo.modules</groupId> <artifactId>geronimo-deployment</artifactId> <version>1.1-SNAPSHOT</version> </dependency> <dependency> <groupId>org.apache.geronimo.modules</groupId> <artifactId>geronimo-service-builder</artifactId> <version>1.1-SNAPSHOT</version> </dependency> <dependency> <groupId>org.apache.geronimo.modules</groupId> <artifactId>geronimo-common</artifactId> <version>1.1-SNAPSHOT</version> </dependency> <dependency> <groupId>opensymphony</groupId> <artifactId>quartz</artifactId> <version>1.5.2</version> <scope>test</scope> </dependency> <dependency> <groupId>junit</groupId> <artifactId>junit</artifactId> <version>3.8.1</version> <scope>test</scope> </dependency> </dependencies> </project>
The deployer has a couple main responsibilites:
Generally, this can be reduced to "input XML, output either null or Module ID plus GBeans".
Here's the deployer:
/** * Copyright 2006 The Apache Software Foundation * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.gplugins.quartz.deployment; import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; import java.net.URI; import java.net.URL; import java.util.ArrayList; import java.util.Collection; import java.util.jar.JarFile; import javax.xml.namespace.QName; import org.apache.geronimo.common.DeploymentException; import org.apache.geronimo.deployment.ConfigurationBuilder; import org.apache.geronimo.deployment.DeploymentContext; import org.apache.geronimo.deployment.ModuleIDBuilder; import org.apache.geronimo.deployment.service.EnvironmentBuilder; import org.apache.geronimo.deployment.util.DeploymentUtil; import org.apache.geronimo.deployment.xbeans.ArtifactType; import org.apache.geronimo.deployment.xbeans.EnvironmentType; import org.apache.geronimo.deployment.xmlbeans.XmlBeansUtil; import org.apache.geronimo.gbean.AbstractName; import org.apache.geronimo.gbean.AbstractNameQuery; import org.apache.geronimo.gbean.GBeanData; import org.apache.geronimo.gbean.GBeanInfo; import org.apache.geronimo.gbean.GBeanInfoBuilder; import org.apache.geronimo.kernel.GBeanAlreadyExistsException; import org.apache.geronimo.kernel.Kernel; import org.apache.geronimo.kernel.Naming; import org.apache.geronimo.kernel.config.ConfigurationAlreadyExistsException; import org.apache.geronimo.kernel.config.ConfigurationManager; import org.apache.geronimo.kernel.config.ConfigurationModuleType; import org.apache.geronimo.kernel.config.ConfigurationStore; import org.apache.geronimo.kernel.config.ConfigurationUtil; import org.apache.geronimo.kernel.config.SimpleConfigurationManager; import org.apache.geronimo.kernel.repository.Artifact; import org.apache.geronimo.kernel.repository.ArtifactResolver; import org.apache.geronimo.kernel.repository.Environment; import org.apache.geronimo.kernel.repository.Repository; import org.apache.xmlbeans.XmlCursor; import org.apache.xmlbeans.XmlException; import org.apache.xmlbeans.XmlObject; import org.gplugins.quartz.QuartzJobGBean; import org.gplugins.quartz.deployment.xbeans.JobType; import org.gplugins.quartz.deployment.xbeans.JobsDocument; import org.gplugins.quartz.deployment.xbeans.JobsType; /** * Deploys Quartz jobs at runtime * * @version $Rev: 355877 $ $Date: 2005-12-10 21:48:27 -0500 (Sat, 10 Dec 2005) $ */ public class QuartzJobDeployer implements ConfigurationBuilder { private static final QName JOBS_QNAME = JobsDocument.type.getDocumentElementName(); private final Environment defaultEnvironment; private final Collection repositories; private final Naming naming; private final ConfigurationManager configurationManager; private final AbstractNameQuery schedulerName; public QuartzJobDeployer(Environment defaultEnvironment, Collection repositories, AbstractNameQuery schedulerName, Kernel kernel) { this.defaultEnvironment = defaultEnvironment; this.repositories = repositories; this.schedulerName = schedulerName; naming = kernel.getNaming(); configurationManager = ConfigurationUtil.getConfigurationManager(kernel); } public Object getDeploymentPlan(File planFile, JarFile jarFile, ModuleIDBuilder idBuilder) throws DeploymentException { if (planFile == null && jarFile == null) { return null; } try { XmlObject xmlObject; if (planFile != null) { xmlObject = XmlBeansUtil.parse(planFile.toURL()); } else { URL path = DeploymentUtil.createJarURL(jarFile, "META-INF/geronimo-quartz.xml"); try { xmlObject = XmlBeansUtil.parse(path); } catch (FileNotFoundException e) { // It has a JAR but no plan, and nothing at META-INF/geronimo-quartz.xml, // therefore it's not a quartz job deployment return null; } } if (xmlObject == null) { return null; } XmlCursor cursor = xmlObject.newCursor(); try { cursor.toFirstChild(); if (!JOBS_QNAME.equals(cursor.getName())) { return null; } } finally { cursor.dispose(); } JobsDocument moduleDoc; if (xmlObject instanceof JobsDocument) { moduleDoc = (JobsDocument) xmlObject; } else { moduleDoc = (JobsDocument) xmlObject.changeType(JobsDocument.type); } Collection errors = new ArrayList(); if (!moduleDoc.validate(XmlBeansUtil.createXmlOptions(errors))) { throw new DeploymentException("Invalid deployment descriptor: " + errors + "\nDescriptor: " + moduleDoc.toString()); } // If there's no artifact ID and we won't be able to figure one out later, use the plan file name. Bit of a hack. if (jarFile == null && (moduleDoc.getJobs().getEnvironment() == null || moduleDoc.getJobs().getEnvironment().getModuleId() == null || moduleDoc.getJobs().getEnvironment().getModuleId().getArtifactId() == null)) { if (moduleDoc.getJobs().getEnvironment() == null) { moduleDoc.getJobs().addNewEnvironment(); } if (moduleDoc.getJobs().getEnvironment().getModuleId() == null) { moduleDoc.getJobs().getEnvironment().addNewModuleId(); } String name = planFile.getName(); int pos = name.lastIndexOf('.'); if (pos > -1) { name = name.substring(0, pos); } moduleDoc.getJobs().getEnvironment().getModuleId().setArtifactId(name); } return moduleDoc.getJobs(); } catch (XmlException e) { throw new DeploymentException("Could not parse xml in plan", e); } catch (IOException e) { throw new DeploymentException("no plan at " + planFile, e); } } public Artifact getConfigurationID(Object plan, JarFile module, ModuleIDBuilder idBuilder) throws IOException, DeploymentException { JobsType configType = (JobsType) plan; EnvironmentType environmentType = configType.getEnvironment(); Environment environment = EnvironmentBuilder.buildEnvironment(environmentType, defaultEnvironment); idBuilder.resolve(environment, module == null ? "" : new File(module.getName()).getName(), "car"); if(!environment.getConfigId().isResolved()) { throw new IllegalStateException("Module ID is not fully populated ("+environment.getConfigId()+")"); } return environment.getConfigId(); } public DeploymentContext buildConfiguration(boolean inPlaceDeployment, Artifact configId, Object plan, JarFile jar, Collection configurationStores, ArtifactResolver artifactResolver, ConfigurationStore targetConfigurationStore) throws IOException, DeploymentException { JobsType configType = (JobsType) plan; return buildConfiguration(inPlaceDeployment, configId, configType, jar, configurationStores, artifactResolver, targetConfigurationStore); } public DeploymentContext buildConfiguration(boolean inPlaceDeployment, Artifact configId, JobsType moduleType, JarFile jar, Collection configurationStores, ArtifactResolver artifactResolver, ConfigurationStore targetConfigurationStore) throws DeploymentException, IOException { ArtifactType type = moduleType.getEnvironment().isSetModuleId() ? moduleType.getEnvironment().getModuleId() : moduleType.getEnvironment().addNewModuleId(); type.setArtifactId(configId.getArtifactId()); type.setGroupId(configId.getGroupId()); type.setType(configId.getType()); type.setVersion(configId.getVersion().toString()); Environment environment = EnvironmentBuilder.buildEnvironment(moduleType.getEnvironment(), defaultEnvironment); if(!environment.getConfigId().isResolved()) { throw new IllegalStateException("Module ID should be fully resolved by now (not "+environment.getConfigId()+")"); } File outfile; try { outfile = targetConfigurationStore.createNewConfigurationDir(configId); } catch (ConfigurationAlreadyExistsException e) { throw new DeploymentException(e); } ConfigurationManager configurationManager = this.configurationManager; if (configurationManager == null) { configurationManager = new SimpleConfigurationManager(configurationStores, artifactResolver, repositories); } DeploymentContext context = new DeploymentContext(outfile, inPlaceDeployment && null != jar ? DeploymentUtil.toFile(jar) : null, environment, ConfigurationModuleType.SERVICE, naming, configurationManager, repositories ); if(jar != null) { File file = new File(jar.getName()); context.addIncludeAsPackedJar(URI.create(file.getName()), jar); } try { ClassLoader cl = context.getClassLoader(); AbstractName moduleName = naming.createRootName(configId, configId.toString(), "ServiceModule"); addJobs(moduleType.getJobArray(), cl, moduleName, context); return context; } catch (RuntimeException t) { context.close(); throw t; } catch (Error e) { context.close(); throw e; } } private void addJobs(JobType[] jobs, ClassLoader cl, AbstractName moduleName, DeploymentContext context) throws DeploymentException { for (int i = 0; i < jobs.length; i++) { JobType job = jobs[i]; GBeanInfo gBeanInfo = GBeanInfo.getGBeanInfo(QuartzJobGBean.class.getName(), cl); String namePart = job.getJobName(); AbstractName abstractName = context.getNaming().createChildName(moduleName, namePart, "GBean"); GBeanData data = new GBeanData(abstractName, gBeanInfo); data.setReferencePattern("QuartzScheduler", schedulerName); data.setAttribute("name", job.getJobName()); data.setAttribute("jobClass", job.getJobClass()); data.setAttribute("cronExpression", job.getCronExpression()); try { context.addGBean(data); } catch (GBeanAlreadyExistsException e) { throw new DeploymentException("Cannot add Quartz job '"+job.getJobName()+"' for module "+moduleName+"; a job with that name already exists."); } } } public static final GBeanInfo GBEAN_INFO; static { GBeanInfoBuilder infoFactory = GBeanInfoBuilder.createStatic("Quartz Deployer", QuartzJobDeployer.class, "ConfigBuilder"); infoFactory.addInterface(ConfigurationBuilder.class); infoFactory.addAttribute("defaultEnvironment", Environment.class, true); infoFactory.addAttribute("schedulerName", AbstractNameQuery.class, true, true); infoFactory.addAttribute("kernel", Kernel.class, false, false); infoFactory.addReference("Repository", Repository.class, "Repository"); infoFactory.setConstructor(new String[]{"defaultEnvironment", "Repository", "schedulerName", "kernel"}); GBEAN_INFO = infoFactory.getBeanInfo(); } public static GBeanInfo getGBeanInfo() { return GBEAN_INFO; } }
One thing to notice is how the Quartz Job Deployment Plan is connected to the Quartz Job Deployer. It is based on the schema namespace (http://geronimo.apache.org/xml/ns/plugins/quartz-0.1), and the fact that the plan was in the right place for us to find to begin with. For all the deployers we've done so far, we use XMLBeans to create JavaBeans connected to the schema. Then we use XMLBeans to read in the deployment plan, and check whether it's the type this deployer expects. Here's an excerpt from the deployer above:
XmlObject xmlObject; if (planFile != null) { xmlObject = XmlBeansUtil.parse(planFile.toURL()); } else { URL path = DeploymentUtil.createJarURL(jarFile, "META-INF/geronimo-quartz.xml"); try { xmlObject = XmlBeansUtil.parse(path); } catch (FileNotFoundException e) { // It has a JAR but no plan, and nothing at META-INF/geronimo-quartz.xml, // therefore it's not a quartz job deployment return null; } } if (xmlObject == null) { return null; }
This part establishes that we can load a plan at all. If not, it either means no plan was provided, or the plan is in the module at a different location (e.g. WEB-INF/geronimo-web.xml, meaning it's definitely not a Quartz job). Either way, this deployer can't handle the archive so we return null.
If we get past that, it means that we found a plan. So we go on to check the type:
XmlCursor cursor = xmlObject.newCursor(); try { cursor.toFirstChild(); if (!SERVICE_QNAME.equals(cursor.getName())) { return null; } } finally { cursor.dispose(); }
The constant JOBS_QNAME is a reference to the schema namespace of the first element in the file. If it's the one we're looking for, great. Otherwise, even though we found a plan, it was not the right type of plan (e.g. someone passed a web plan on the command line), so this deployer can't handle it.
If we get past those two checks (plan present and plan has correct namespace) then we assume that it really was meant for this deployer to handle, and for other kinds of errors (syntax error in plan, etc.) we throw a deployment exception. Some of the deployers have additional logic to silently upgrade old-format plans to current-format plans, but this one does not.
This can be packaged into a JAR with the deployer code like this:
META-INF/ META-INF/MANIFEST.MF META-INF/geronimo-service.xml org/ org/gplugins/ org/gplugins/quartz/ org/gplugins/quartz/deployment/ org/gplugins/quartz/deployment/QuartzJobDeployer.class (a bunch of XMLBeans and Maven stuff)
The plan is:
<?xml version="1.0" encoding="UTF-8"?> <module xmlns="http://geronimo.apache.org/xml/ns/deployment-1.1"> <environment> <moduleId> <groupId>gplugins</groupId> <artifactId>quartz-builder</artifactId> <version>0.1</version> <type>car</type> </moduleId> <dependencies> <dependency> <groupId>gplugins</groupId> <artifactId>quartz</artifactId> <version>0.1</version> <type>car</type> </dependency> <dependency> <groupId>geronimo</groupId> <artifactId>geronimo-gbean-deployer</artifactId> <type>car</type> </dependency> </dependencies> </environment> <gbean name="QuartzBuilder" class="org.gplugins.quartz.deployment.QuartzJobDeployer"> <reference name="Repository"/> <attribute name="schedulerName">?name=QuartzScheduler</attribute> <xml-attribute name="defaultEnvironment"> <environment> <dependencies> <dependency> <groupId>geronimo</groupId> <artifactId>rmi-naming</artifactId> <type>car</type> </dependency> <dependency> <groupId>gplugins</groupId> <artifactId>quartz</artifactId> <type>car</type> </dependency> </dependencies> </environment> </xml-attribute> </gbean> </module>
TODO: need to write this
Note that this portlet can access the QuartzScheduler in JNDI at java:comp/env/Scheduler by adding the following reference to geronimo-web.xml:
<gbean-ref> <ref-name>Scheduler</ref-name> <ref-type>org.gplugins.quartz.QuartzScheduler</ref-type> <pattern> <name>QuartzScheduler</name> </pattern> </gbean-ref>
That assumes that the Quartz package is listed as a dependency higher up in geronimo-web.xml:
<dependency> <groupId>gplugins</groupId> <artifactId>quartz</artifactId> <version>0.1</version> <type>car</type> </dependency>
The secret sauce here is a GBean that rewrites the console config files. After that, you just have to hit http://localhost:8080/console/portal/welcome?hotDeploy=true (note that last bit) to force the console to reread its configuration files. I'm sure there's a better way, but hey.
This GBean can be reused to install any portlets into the console (though it's configured to add one new page with any/all the portlets on that single page).
/** * Copyright 2006 The Apache Software Foundation * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.gplugins.console.util; import java.io.BufferedReader; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.FileReader; import java.io.FileWriter; import java.io.IOException; import java.io.PrintWriter; import java.net.URL; import java.util.ArrayList; import java.util.List; import java.util.Set; import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.ParserConfigurationException; import javax.xml.transform.OutputKeys; import javax.xml.transform.Transformer; import javax.xml.transform.TransformerException; import javax.xml.transform.TransformerFactory; import javax.xml.transform.dom.DOMSource; import javax.xml.transform.stream.StreamResult; import org.apache.geronimo.gbean.GBeanInfo; import org.apache.geronimo.gbean.GBeanInfoBuilder; import org.apache.geronimo.gbean.GBeanLifecycle; import org.apache.geronimo.gbean.AbstractName; import org.apache.geronimo.kernel.Kernel; import org.apache.geronimo.kernel.config.ConfigurationManager; import org.apache.geronimo.kernel.config.ConfigurationStore; import org.apache.geronimo.kernel.config.ConfigurationUtil; import org.apache.geronimo.kernel.repository.Artifact; import org.apache.geronimo.kernel.repository.Version; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.w3c.dom.NodeList; import org.w3c.dom.Node; import org.xml.sax.SAXException; /** * This is a GBean that sleazily adds a portlet to the console. * * @version $Rev: 355877 $ $Date: 2005-12-10 21:48:27 -0500 (Sat, 10 Dec 2005) $ */ public class AddToConsoleGBean implements GBeanLifecycle { private final String title; private final String webApp; private final List portlets; private final Kernel kernel; private final AbstractName abstractName; private boolean installed = false; private Element registryApp; public AddToConsoleGBean(String webApp, String title, List portlets, Kernel kernel, AbstractName abstractName) { this.title = title; this.webApp = webApp; this.portlets = portlets; this.kernel = kernel; this.abstractName = abstractName; } public boolean isInstalled() { return installed; } public void setInstalled(boolean installed) { this.installed = installed; } public void doStart() throws Exception { if(installed) { return; } ConfigurationManager mgr = ConfigurationUtil.getConfigurationManager(kernel); // Identify the Console app Artifact[] consoles = mgr.getInstalled(new Artifact("geronimo", "webconsole-jetty", (Version) null, "car")); if (consoles.length == 0) { consoles = mgr.getInstalled(new Artifact("geronimo", "webconsole-tomcat", (Version) null, "car")); if (consoles.length == 0) { throw new IllegalStateException("Admin console does not seem to be installed!"); } } Artifact console = consoles[consoles.length - 1]; // Identify the disk location where the console config files are stored ConfigurationStore store = mgr.getStoreForConfiguration(console); Set set = store.resolve(console, null, ""); URL url = (URL) set.iterator().next(); File file = new File(url.getPath()); File[] children = file.listFiles(); File framework = null; for (int i = 0; i < children.length; i++) { File test = children[i]; if (test.getName().indexOf("framework") > -1) { framework = test; break; } } if (framework == null) { throw new IllegalStateException("Cannot locate framework web app within console EAR at " + file.getAbsolutePath()); } File dataDir = new File(new File(framework, "WEB-INF"), "data"); if (!dataDir.exists() || !dataDir.isDirectory() || !dataDir.canRead()) { throw new IllegalStateException("Unable to read console data dir at " + dataDir.getAbsolutePath()); } // Update the console portal config files File registryFile = new File(dataDir, "portletentityregistry.xml"); File contextFile = new File(dataDir, "portletcontexts.txt"); boolean changed = addContext(contextFile, "/"+webApp); if(changed) { Integer newPortletStartID = processRegistry(registryFile); File pageFile = new File(dataDir, "pageregistry.xml"); processPortlets(pageFile, registryApp, newPortletStartID.intValue()); FileOutputStream out = new FileOutputStream(new File(registryFile.getParentFile(), registryFile.getName()+".new")); TransformerFactory xfactory = TransformerFactory.newInstance(); Transformer xform = xfactory.newTransformer(); xform.setOutputProperty(OutputKeys.INDENT, "yes"); xform.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "4"); xform.transform(new DOMSource(registryApp.getOwnerDocument()), new StreamResult(out)); out.flush(); out.close(); // Save off the old files, and replace them with the new ones registryFile.renameTo(new File(dataDir, registryFile.getName()+".old")); contextFile.renameTo(new File(dataDir, contextFile.getName()+".old")); pageFile.renameTo(new File(dataDir, pageFile.getName()+".old")); new File(dataDir, registryFile.getName()+".new").renameTo(registryFile); new File(dataDir, contextFile.getName()+".new").renameTo(contextFile); new File(dataDir, pageFile.getName()+".new").renameTo(pageFile); } kernel.setAttribute(abstractName, "installed", Boolean.TRUE); } private void processPortlets(File pageFile, Element registryApp, int newPortletStartID) throws ParserConfigurationException, IOException, SAXException, TransformerException { DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); DocumentBuilder builder = factory.newDocumentBuilder(); FileInputStream in = new FileInputStream(pageFile); Document doc = builder.parse(in); in.close(); Element root = doc.getDocumentElement(); NodeList cats = root.getChildNodes(); for (int i = 0; i < cats.getLength(); i++) { Node node = cats.item(i); if(node.getNodeType() != Node.ELEMENT_NODE) { continue; } Element category = (Element) node; String title = category.getAttribute("name"); if(title.equals("plugins")) { NodeList pages = category.getChildNodes(); for (int j = 0; j < pages.getLength(); j++) { Node pageNode = pages.item(j); if(pageNode.getNodeType() != Node.ELEMENT_NODE) { continue; } Element page = (Element) pageNode; if(!page.getNodeName().equals("fragment")) { continue; } title = page.getAttribute("name"); portlets.remove(title); } if(portlets.size() > 0) { Element page = doc.createElement("fragment"); page.setAttribute("name", this.title); page.setAttribute("type", "page"); category.appendChild(page); Element nav = doc.createElement("navigation"); page.appendChild(nav); Element navTitle = doc.createElement("title"); nav.appendChild(navTitle); navTitle.appendChild(doc.createTextNode(this.title)); Element desc = doc.createElement("description"); nav.appendChild(desc); desc.appendChild(doc.createTextNode("ico_list_16x16.gif Configure this plugin")); for (int j = 0; j < portlets.size(); j++) { String name = (String) portlets.get(j); int id = newPortletStartID+j; // Write into registry Element regPortlet = registryApp.getOwnerDocument().createElement("portlet"); registryApp.appendChild(regPortlet); regPortlet.setAttribute("id", Integer.toString(id)); Element def = regPortlet.getOwnerDocument().createElement("definition-id"); regPortlet.appendChild(def); def.appendChild(def.getOwnerDocument().createTextNode(webApp+"."+name)); // Write into page Element row = doc.createElement("fragment"); page.appendChild(row); row.setAttribute("name", "row"+j); row.setAttribute("type", "row"); Element col = doc.createElement("fragment"); row.appendChild(col); col.setAttribute("name", "col1"); col.setAttribute("type", "column"); Element port = doc.createElement("fragment"); col.appendChild(port); port.setAttribute("name", "p1"); port.setAttribute("type", "portlet"); Element prop = doc.createElement("property"); port.appendChild(prop); prop.setAttribute("name", "portlet"); prop.setAttribute("value", "9."+id); } } } } FileOutputStream out = new FileOutputStream(new File(pageFile.getParentFile(), pageFile.getName()+".new")); TransformerFactory xfactory = TransformerFactory.newInstance(); Transformer xform = xfactory.newTransformer(); xform.setOutputProperty(OutputKeys.INDENT, "yes"); xform.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "4"); xform.transform(new DOMSource(doc), new StreamResult(out)); out.flush(); out.close(); } private boolean addContext(File contextFile, String context) { try { List list = new ArrayList(); BufferedReader in = new BufferedReader(new FileReader(contextFile)); String line; while((line = in.readLine()) != null) { line = line.trim(); if(line.equals(context)) { in.close(); return false; } else if(!line.equals("")) { list.add(line); } } in.close(); PrintWriter out = new PrintWriter(new FileWriter(new File(contextFile.getParentFile(), contextFile.getName()+".new"))); for (int i = 0; i < list.size(); i++) { line = (String) list.get(i); out.println(line); } out.println(context); out.close(); return true; } catch (IOException e) { throw (IllegalStateException)new IllegalStateException("Unable to process portlet contexts").initCause(e); } } private Integer processRegistry(File file) { try { DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); DocumentBuilder builder = factory.newDocumentBuilder(); FileInputStream in = new FileInputStream(file); Document doc = builder.parse(in); in.close(); Element root = doc.getDocumentElement(); NodeList apps = root.getElementsByTagName("application"); for (int i = 0; i < apps.getLength(); i++) { Element app = (Element) apps.item(i); if (app.getAttribute("id").equals("9")) { registryApp = app; NodeList portlets = app.getElementsByTagName("portlet"); int max = 0; for (int j = 0; j < portlets.getLength(); j++) { Element portlet = (Element) portlets.item(j); String id = portlet.getAttribute("id"); int value = Integer.parseInt(id); if (value > max) { max = value; } } return new Integer(max + 1); } } Element app = doc.createElement("application"); app.setAttribute("id", "9"); root.appendChild(app); Element def = doc.createElement("definition-id"); app.appendChild(def); def.appendChild(doc.createTextNode(webApp)); registryApp = app; return new Integer(1); } catch (Exception e) { throw (IllegalStateException)new IllegalStateException("Unable to process portlet registry").initCause(e); } } public void doStop() throws Exception { } public void doFail() { } public static final GBeanInfo GBEAN_INFO; static { GBeanInfoBuilder infoFactory = GBeanInfoBuilder.createStatic("Console Portlet Installer", AddToConsoleGBean.class, "GBean"); infoFactory.addAttribute("installed", boolean.class, true, true); infoFactory.addAttribute("title", String.class, true, true); infoFactory.addAttribute("webApp", String.class, true, true); infoFactory.addAttribute("portlets", List.class, true, true); infoFactory.addAttribute("kernel", Kernel.class, false, false); infoFactory.addAttribute("abstractName", AbstractName.class, false, false); infoFactory.setConstructor(new String[]{"webApp", "title", "portlets", "kernel", "abstractName"}); GBEAN_INFO = infoFactory.getBeanInfo(); } public static GBeanInfo getGBeanInfo() { return GBEAN_INFO; } }
The deployment plan block that configures this GBean is added to geronimo-web.xml for the web app, and looks like this:
<gbean name="PortletInstaller" class="org.gplugins.console.util.AddToConsoleGBean"> <attribute name="title">Quartz</attribute> <attribute name="webApp">quartz-console</attribute> <attribute name="portlets">Quartz</attribute> </gbean>
The "title" is the name of the entry for the new page in the console, the "webApp" is the context root of the web application containing the portlets, and the "portlets" is a list of portlets by the name they declare in portlet.xml. Note that if there were multiple portlets, the portlets property would use a comma-separated list (but there's still only one page/title and web app).
This ends up going into a WAR like this:
META-INF/MANIFEST.MF WEB-INF/ WEB-INF/web.xml WEB-INF/portlet.xml WEB-INF/geronimo-web.xml WEB-INF/classes/ WEB-INF/classes/org/ WEB-INF/classes/org/gplugins/ WEB-INF/classes/org/gplugins/console/ WEB-INF/classes/org/gplugins/console/quartz/ WEB-INF/classes/org/gplugins/console/quartz/QuartzPortlet.class WEB-INF/classes/org/gplugins/console/util/ WEB-INF/classes/org/gplugins/console/util/AddToConsoleGBean.class WEB-INF/lib/ WEB-INF/lib/jstl-1.1.1.jar WEB-INF/lib/standard-1.1.1.jar WEB-INF/tld/ WEB-INF/tld/portlet.tld (plus JSPs, images, etc.)
TODO: need to write this