From f047c6baafec7fb7b142e3ad8962384a9aff1095 Mon Sep 17 00:00:00 2001 From: Derek Hulley Date: Mon, 29 Jan 2007 14:43:37 +0000 Subject: [PATCH] Merged DEV\EXTENSIONS to HEAD svn merge svn://svn.alfresco.com:3691/alfresco/BRANCHES/DEV/EXTENSIONS@4868 svn://svn.alfresco.com:3691/alfresco/BRANCHES/DEV/EXTENSIONS@4869 . svn merge svn://svn.alfresco.com:3691/alfresco/BRANCHES/DEV/EXTENSIONS@4904 svn://svn.alfresco.com:3691/alfresco/BRANCHES/DEV/EXTENSIONS@4938 . Module management support Modularization of Records Management git-svn-id: https://svn.alfresco.com/repos/alfresco-enterprise/alfresco/HEAD/root@4956 c4b6b30b-aa2e-2d43-bbcb-ca4b014f7261 --- config/alfresco/application-context.xml | 11 +- config/alfresco/bootstrap-context.xml | 8 + config/alfresco/core-services-context.xml | 1 + .../messages/module-messages.properties | 12 + config/alfresco/module-context.xml | 27 + .../module/test/module-context.xml.sample | 24 + .../module/test/module.properties.sample | 5 + config/alfresco/public-services-context.xml | 28 + .../repo/admin/patch/AbstractPatch.java | 2 +- .../repo/admin/registry/RegistryKey.java | 108 ++++ .../repo/admin/registry/RegistryService.java | 8 +- .../admin/registry/RegistryServiceImpl.java | 67 +- .../registry/RegistryServiceImplTest.java | 25 +- .../alfresco/repo/domain/PropertyValue.java | 19 + .../repo/importer/ImporterBootstrap.java | 23 +- .../repo/module/AbstractModuleComponent.java | 348 ++++++++++ .../alfresco/repo/module/ComponentsTest.java | 142 +++++ .../repo/module/ImporterModuleComponent.java | 108 ++++ .../alfresco/repo/module/ModuleComponent.java | 93 +++ .../repo/module/ModuleComponentHelper.java | 342 ++++++++++ .../module/ModuleComponentHelperTest.java | 259 ++++++++ .../repo/module/ModuleDetailsImpl.java | 141 +++++ .../repo/module/ModuleManagementTool.java | 430 ------------- .../repo/module/ModuleManagementToolTest.java | 108 ---- .../repo/module/ModuleServiceImpl.java | 192 ++++++ .../alfresco/repo/module/ModuleStarter.java | 54 ++ .../repo/module/tool/InstalledFiles.java | 217 +++++++ .../repo/module/tool/ModuleDetailsHelper.java | 129 ++++ .../module/tool/ModuleManagementTool.java | 598 ++++++++++++++++++ .../ModuleManagementToolException.java | 2 +- .../module/tool/ModuleManagementToolTest.java | 235 +++++++ .../default-file-mapping.properties | 0 .../service/cmr/module/ModuleDetails.java | 68 +- .../service/cmr/module/ModuleService.java | 40 +- .../datatype/DefaultTypeConverter.java | 22 + .../datatype/DefaultTypeConverterTest.java | 5 + .../alfresco/util/BaseAlfrescoTestCase.java | 25 +- .../module/module-component-test-beans.xml | 36 ++ .../module-importer-test-categories.xml | 26 + source/test-resources/module/test.amp | Bin 58458 -> 58572 bytes source/test-resources/module/test.war | Bin 8498 -> 8603 bytes source/test-resources/module/test_v2.amp | Bin 0 -> 59743 bytes 42 files changed, 3324 insertions(+), 664 deletions(-) create mode 100644 config/alfresco/messages/module-messages.properties create mode 100644 config/alfresco/module-context.xml create mode 100644 config/alfresco/module/test/module-context.xml.sample create mode 100644 config/alfresco/module/test/module.properties.sample create mode 100644 source/java/org/alfresco/repo/admin/registry/RegistryKey.java create mode 100644 source/java/org/alfresco/repo/module/AbstractModuleComponent.java create mode 100644 source/java/org/alfresco/repo/module/ComponentsTest.java create mode 100644 source/java/org/alfresco/repo/module/ImporterModuleComponent.java create mode 100644 source/java/org/alfresco/repo/module/ModuleComponent.java create mode 100644 source/java/org/alfresco/repo/module/ModuleComponentHelper.java create mode 100644 source/java/org/alfresco/repo/module/ModuleComponentHelperTest.java create mode 100644 source/java/org/alfresco/repo/module/ModuleDetailsImpl.java delete mode 100644 source/java/org/alfresco/repo/module/ModuleManagementTool.java delete mode 100644 source/java/org/alfresco/repo/module/ModuleManagementToolTest.java create mode 100644 source/java/org/alfresco/repo/module/ModuleServiceImpl.java create mode 100644 source/java/org/alfresco/repo/module/ModuleStarter.java create mode 100644 source/java/org/alfresco/repo/module/tool/InstalledFiles.java create mode 100644 source/java/org/alfresco/repo/module/tool/ModuleDetailsHelper.java create mode 100644 source/java/org/alfresco/repo/module/tool/ModuleManagementTool.java rename source/java/org/alfresco/repo/module/{ => tool}/ModuleManagementToolException.java (93%) create mode 100644 source/java/org/alfresco/repo/module/tool/ModuleManagementToolTest.java rename source/java/org/alfresco/repo/module/{ => tool}/default-file-mapping.properties (100%) create mode 100644 source/test-resources/module/module-component-test-beans.xml create mode 100644 source/test-resources/module/module-importer-test-categories.xml create mode 100644 source/test-resources/module/test_v2.amp diff --git a/config/alfresco/application-context.xml b/config/alfresco/application-context.xml index 5ca124e1c2..0b7a896325 100644 --- a/config/alfresco/application-context.xml +++ b/config/alfresco/application-context.xml @@ -30,6 +30,13 @@ + + + - - - diff --git a/config/alfresco/bootstrap-context.xml b/config/alfresco/bootstrap-context.xml index fd3189c903..1d9f58fa79 100644 --- a/config/alfresco/bootstrap-context.xml +++ b/config/alfresco/bootstrap-context.xml @@ -270,6 +270,14 @@ + + + + + + + + diff --git a/config/alfresco/core-services-context.xml b/config/alfresco/core-services-context.xml index 767a32a0c5..3fccc42714 100644 --- a/config/alfresco/core-services-context.xml +++ b/config/alfresco/core-services-context.xml @@ -213,6 +213,7 @@ alfresco.messages.system-messages + alfresco.messages.module-messages alfresco.messages.dictionary-messages alfresco.messages.version-service alfresco.messages.permissions-service diff --git a/config/alfresco/messages/module-messages.properties b/config/alfresco/messages/module-messages.properties new file mode 100644 index 0000000000..d98dc8cab6 --- /dev/null +++ b/config/alfresco/messages/module-messages.properties @@ -0,0 +1,12 @@ +# Module messages + +module.msg.found_modules=Found {0} module(s). +module.msg.starting= Starting module ''{0}'' version {1}. +module.msg.installing= Installing module ''{0}'' version {1}. +module.msg.upgrading= Upgrading module ''{0}'' version {1} (was {2}). + +module.err.downgrading_not_supported=\nDowngrading of modules is not supported.\nModule ''{0}'' version {1} is currently installed and must be uninstalled before version {2} can be installed. +module.err.already_executed=The module component has already been executed: {0}.{1} +module.err.execution_failed=A module component ''{0}'' failed to execute: {1} +module.err.component_already_registered=A component named ''{0}'' has already been registered for module ''{1}''. +module.err.unable_to_open_module_properties=The module properties file ''{0}'' could not be read. \ No newline at end of file diff --git a/config/alfresco/module-context.xml b/config/alfresco/module-context.xml new file mode 100644 index 0000000000..db670a0ebb --- /dev/null +++ b/config/alfresco/module-context.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/config/alfresco/module/test/module-context.xml.sample b/config/alfresco/module/test/module-context.xml.sample new file mode 100644 index 0000000000..586382e10e --- /dev/null +++ b/config/alfresco/module/test/module-context.xml.sample @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/config/alfresco/module/test/module.properties.sample b/config/alfresco/module/test/module.properties.sample new file mode 100644 index 0000000000..6f07422d49 --- /dev/null +++ b/config/alfresco/module/test/module.properties.sample @@ -0,0 +1,5 @@ +# A test module +module.id=test.xyz +module.title=Test XYZ +module.description=A module for unit testing +module.version=3.0 \ No newline at end of file diff --git a/config/alfresco/public-services-context.xml b/config/alfresco/public-services-context.xml index 2b15e9e115..a830294e78 100644 --- a/config/alfresco/public-services-context.xml +++ b/config/alfresco/public-services-context.xml @@ -1320,4 +1320,32 @@ + + + + + org.alfresco.service.cmr.module.ModuleService + + + + + + + + + + + + + + + + + + ${server.transaction.mode.readOnly} + ${server.transaction.mode.default} + + + + diff --git a/source/java/org/alfresco/repo/admin/patch/AbstractPatch.java b/source/java/org/alfresco/repo/admin/patch/AbstractPatch.java index b921a3e0f7..4c74cdd1ff 100644 --- a/source/java/org/alfresco/repo/admin/patch/AbstractPatch.java +++ b/source/java/org/alfresco/repo/admin/patch/AbstractPatch.java @@ -43,7 +43,7 @@ import org.apache.commons.logging.LogFactory; public abstract class AbstractPatch implements Patch { /** - * I18N message when properties nto set. + * I18N message when properties not set. *
    *
  • {0} = property name
  • *
  • {1} = patch instance
  • diff --git a/source/java/org/alfresco/repo/admin/registry/RegistryKey.java b/source/java/org/alfresco/repo/admin/registry/RegistryKey.java new file mode 100644 index 0000000000..81531c4a87 --- /dev/null +++ b/source/java/org/alfresco/repo/admin/registry/RegistryKey.java @@ -0,0 +1,108 @@ +/* + * Copyright (C) 2007 Alfresco, Inc. + * + * Licensed under the Mozilla Public License version 1.1 + * with a permitted attribution clause. You may obtain a + * copy of the License at + * + * http://www.alfresco.org/legal/license.txt + * + * 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.alfresco.repo.admin.registry; + +import java.io.Serializable; + +/** + * Key for looking up registry metadata. + * + * @author Derek Hulley + */ +public class RegistryKey implements Serializable +{ + private static final long serialVersionUID = 1137822242292626854L; + + static final String REGISTRY_1_0_URI = "http://www.alfresco.org/system/registry/1.0"; + + private String namespaceUri; + private String[] path; + private String property; + + /** + * Build a registry key from a given array of elements. + */ + private static String buildPathString(String... elements) + { + if (elements.length == 0) + { + return "/"; + } + StringBuilder sb = new StringBuilder(); + for (String element : elements) + { + if (element == null || element.length() == 0) + { + throw new IllegalArgumentException("Key elements may not be empty or null"); + } + sb.append("/").append(element); + } + return sb.toString(); + } + + /** + * For path /a/b/c and property 'x', put in
    "a", "b", "c", "x"
    + * The property can also be null as in
    "a", "b", "c", null
    + * + * @param namespaceUri the key namespace to use. If left null then the + * {@link #REGISTRY_1_0_URI default} will be used. + * @param key the path elements followed by the property name. + */ + public RegistryKey(String namespaceUri, String... key) + { + if (namespaceUri == null) + { + namespaceUri = REGISTRY_1_0_URI; + } + this.namespaceUri = namespaceUri; + // The last value is the property + int length = key.length; + if (length == 0) + { + throw new IllegalArgumentException("No value supplied for the RegistryKey property"); + } + this.property = key[length - 1]; + this.path = new String[length - 1]; + System.arraycopy(key, 0, path, 0, length - 1); + } + + @Override + public String toString() + { + StringBuilder sb = new StringBuilder(128); + sb.append("RegistryKey") + .append("[ ").append(RegistryKey.buildPathString(path)).append("/").append(property) + .append(" ]"); + return sb.toString(); + } + + public String getNamespaceUri() + { + return namespaceUri; + } + + public String[] getPath() + { + return path; + } + + public String getProperty() + { + return property; + } + +} diff --git a/source/java/org/alfresco/repo/admin/registry/RegistryService.java b/source/java/org/alfresco/repo/admin/registry/RegistryService.java index 338d0a02c3..9000a28b14 100644 --- a/source/java/org/alfresco/repo/admin/registry/RegistryService.java +++ b/source/java/org/alfresco/repo/admin/registry/RegistryService.java @@ -29,16 +29,16 @@ public interface RegistryService /** * Assign a value to the registry key, which must be of the form /a/b/c. * - * @param key the registry key path delimited with '/'. + * @param key the registry key. * @param value any value that can be stored in the repository. */ - void addValue(String key, Serializable value); + void addValue(RegistryKey key, Serializable value); /** - * @param key the registry key path delimited with '/'. + * @param key the registry key. * @return Returns the value stored in the key. * * @see #addValue(String, Serializable) */ - Serializable getValue(String key); + Serializable getValue(RegistryKey key); } diff --git a/source/java/org/alfresco/repo/admin/registry/RegistryServiceImpl.java b/source/java/org/alfresco/repo/admin/registry/RegistryServiceImpl.java index a73cd97acc..1f77d22160 100644 --- a/source/java/org/alfresco/repo/admin/registry/RegistryServiceImpl.java +++ b/source/java/org/alfresco/repo/admin/registry/RegistryServiceImpl.java @@ -18,7 +18,6 @@ package org.alfresco.repo.admin.registry; import java.io.Serializable; import java.util.List; -import java.util.StringTokenizer; import org.alfresco.error.AlfrescoRuntimeException; import org.alfresco.model.ContentModel; @@ -33,6 +32,7 @@ import org.alfresco.service.namespace.NamespaceService; import org.alfresco.service.namespace.QName; import org.alfresco.util.Pair; import org.alfresco.util.PropertyCheck; +import org.alfresco.util.PropertyMap; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; @@ -154,65 +154,44 @@ public class RegistryServiceImpl implements RegistryService return registryRootNodeRef; } - /** - * @return Returns a pair representing the node path and the property name - */ - private Pair splitKey(String key) - { - int index = key.lastIndexOf('/'); - Pair result = null; - if (index < 0) // It is just a property - { - result = new Pair("/", key); - } - else - { - String propertyName = key.substring(index + 1, key.length()); - if (propertyName.length() == 0) - { - throw new IllegalArgumentException("The registry key is invalid: " + key); - } - result = new Pair(key.substring(0, index), propertyName); - } - // done - return result; - } - /** * @return Returns the node and property name represented by the key or null * if it doesn't exist and was not allowed to be created */ - private Pair getPath(String key, boolean create) + private Pair getPath(RegistryKey key, boolean create) { // Get the root NodeRef currentNodeRef = getRegistryRootNodeRef(); - // Split the key - Pair keyPair = splitKey(key); - // Parse the key - StringTokenizer tokenizer = new StringTokenizer(keyPair.getFirst(), "/"); + // Get the key and property + String namespaceUri = key.getNamespaceUri(); + String[] pathElements = key.getPath(); + String property = key.getProperty(); // Find the node and property to put the value - while (tokenizer.hasMoreTokens()) + for (String pathElement : pathElements) { - String token = tokenizer.nextToken(); - String name = QName.createValidLocalName(token); - QName qname = QName.createQName(NamespaceService.SYSTEM_MODEL_1_0_URI, name); + QName assocQName = QName.createQName( + namespaceUri, + QName.createValidLocalName(pathElement)); // Find the node List childAssocRefs = nodeService.getChildAssocs( currentNodeRef, ContentModel.ASSOC_CHILDREN, - qname); + assocQName); int size = childAssocRefs.size(); if (size == 0) // Found nothing with that path { if (create) // Must create the path { - // Create the node + // Create the node (with a name) + PropertyMap properties = new PropertyMap(); + properties.put(ContentModel.PROP_NAME, pathElement); currentNodeRef = nodeService.createNode( currentNodeRef, ContentModel.ASSOC_CHILDREN, - qname, - ContentModel.TYPE_CONTAINER).getChildRef(); + assocQName, + ContentModel.TYPE_CONTAINER, + properties).getChildRef(); } else { @@ -244,15 +223,15 @@ public class RegistryServiceImpl implements RegistryService } // Create the result QName propertyQName = QName.createQName( - NamespaceService.SYSTEM_MODEL_1_0_URI, - QName.createValidLocalName(keyPair.getSecond())); + namespaceUri, + QName.createValidLocalName(property)); Pair resultPair = new Pair(currentNodeRef, propertyQName); // done if (logger.isDebugEnabled()) { logger.debug("Converted registry key: \n" + - " key pair: " + keyPair + "\n" + - " result: " + resultPair); + " Key: " + key + "\n" + + " Result: " + resultPair); } if (resultPair.getFirst() == null) { @@ -267,7 +246,7 @@ public class RegistryServiceImpl implements RegistryService /** * @inheritDoc */ - public void addValue(String key, Serializable value) + public void addValue(RegistryKey key, Serializable value) { // Get the path, with creation support Pair keyPair = getPath(key, true); @@ -282,7 +261,7 @@ public class RegistryServiceImpl implements RegistryService } } - public Serializable getValue(String key) + public Serializable getValue(RegistryKey key) { // Get the path, without creating Pair keyPair = getPath(key, false); diff --git a/source/java/org/alfresco/repo/admin/registry/RegistryServiceImplTest.java b/source/java/org/alfresco/repo/admin/registry/RegistryServiceImplTest.java index 42a454f882..7d97d0f622 100644 --- a/source/java/org/alfresco/repo/admin/registry/RegistryServiceImplTest.java +++ b/source/java/org/alfresco/repo/admin/registry/RegistryServiceImplTest.java @@ -40,7 +40,7 @@ public class RegistryServiceImplTest extends TestCase authenticationComponent = (AuthenticationComponent) ctx.getBean("AuthenticationComponent"); registryService = (RegistryService) ctx.getBean("RegistryService"); - // Run as admin + // Run as system user authenticationComponent.setSystemUserAsCurrentUser(); } @@ -65,13 +65,16 @@ public class RegistryServiceImplTest extends TestCase private static final Long VALUE_ONE = 1L; private static final Long VALUE_TWO = 2L; private static final Long VALUE_THREE = 3L; - private static final String KEY_A_B_C_1 = "/a/b/c/1"; - private static final String KEY_A_B_C_2 = "/a/b/c/2"; - private static final String KEY_A_B_C_3 = "/a/b/c/3"; - private static final String KEY_A_B_C_D_1 = "/a/b/c/d/1"; - private static final String KEY_A_B_C_D_2 = "/a/b/c/d/2"; - private static final String KEY_A_B_C_D_3 = "/a/b/c/d/3"; - private static final String KEY_SPECIAL = "/me & you/ whatever"; + private static final RegistryKey KEY_A_B_C_0 = new RegistryKey(null, "a", "b", "c", "0"); + private static final RegistryKey KEY_A_B_C_1 = new RegistryKey(null, "a", "b", "c", "1"); + private static final RegistryKey KEY_A_B_C_2 = new RegistryKey(null, "a", "b", "c", "2"); + private static final RegistryKey KEY_A_B_C_3 = new RegistryKey(null, "a", "b", "c", "3"); + private static final RegistryKey KEY_A_B_C_D_0 = new RegistryKey(null, "a", "b", "c", "d", "0"); + private static final RegistryKey KEY_A_B_C_D_1 = new RegistryKey(null, "a", "b", "c", "d", "1"); + private static final RegistryKey KEY_A_B_C_D_2 = new RegistryKey(null, "a", "b", "c", "d", "2"); + private static final RegistryKey KEY_A_B_C_D_3 = new RegistryKey(null, "a", "b", "c", "d", "3"); + private static final RegistryKey KEY_X_Y_Z_0 = new RegistryKey(null, "x", "y", "z", "0"); + private static final RegistryKey KEY_SPECIAL = new RegistryKey(null, "me & you", "whatever"); /** * General writing and reading back. */ @@ -91,9 +94,9 @@ public class RegistryServiceImplTest extends TestCase assertEquals("Incorrect value from service registry", VALUE_TWO, registryService.getValue(KEY_A_B_C_D_2)); assertEquals("Incorrect value from service registry", VALUE_THREE, registryService.getValue(KEY_A_B_C_D_3)); - assertNull("Missing key should return null value", registryService.getValue("/a/b/c/0")); - assertNull("Missing key should return null value", registryService.getValue("/a/b/c/d/0")); - assertNull("Missing key should return null value", registryService.getValue("/x/y/z/0")); + assertNull("Missing key should return null value", registryService.getValue(KEY_A_B_C_0)); + assertNull("Missing key should return null value", registryService.getValue(KEY_A_B_C_D_0)); + assertNull("Missing key should return null value", registryService.getValue(KEY_X_Y_Z_0)); } public void testSpecialCharacters() diff --git a/source/java/org/alfresco/repo/domain/PropertyValue.java b/source/java/org/alfresco/repo/domain/PropertyValue.java index 341dcc7724..4e3f517eb8 100644 --- a/source/java/org/alfresco/repo/domain/PropertyValue.java +++ b/source/java/org/alfresco/repo/domain/PropertyValue.java @@ -35,6 +35,7 @@ import org.alfresco.service.cmr.repository.Path; import org.alfresco.service.cmr.repository.datatype.DefaultTypeConverter; import org.alfresco.service.namespace.QName; import org.alfresco.util.EqualsHelper; +import org.alfresco.util.VersionNumber; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; @@ -256,6 +257,20 @@ public class PropertyValue implements Cloneable, Serializable { return DefaultTypeConverter.INSTANCE.convert(Locale.class, value); } + }, + VERSION_NUMBER + { + @Override + protected ValueType getPersistedType(Serializable value) + { + return ValueType.STRING; + } + + @Override + Serializable convert(Serializable value) + { + return DefaultTypeConverter.INSTANCE.convert(VersionNumber.class, value); + } }; /** @@ -362,6 +377,10 @@ public class PropertyValue implements Cloneable, Serializable { return ValueType.LOCALE; } + else if (value instanceof VersionNumber) + { + return ValueType.VERSION_NUMBER; + } else { // type is not recognised as belonging to any particular slot diff --git a/source/java/org/alfresco/repo/importer/ImporterBootstrap.java b/source/java/org/alfresco/repo/importer/ImporterBootstrap.java index 9b4e1b6eb3..9313b97307 100644 --- a/source/java/org/alfresco/repo/importer/ImporterBootstrap.java +++ b/source/java/org/alfresco/repo/importer/ImporterBootstrap.java @@ -30,10 +30,11 @@ import java.util.List; import java.util.Locale; import java.util.Properties; import java.util.ResourceBundle; -import java.util.StringTokenizer; import javax.transaction.UserTransaction; +import net.sf.acegisecurity.Authentication; + import org.alfresco.error.AlfrescoRuntimeException; import org.alfresco.i18n.I18NUtil; import org.alfresco.repo.security.authentication.AuthenticationComponent; @@ -249,21 +250,8 @@ public class ImporterBootstrap extends AbstractLifecycleBean public void setLocale(String locale) { // construct locale - StringTokenizer t = new StringTokenizer(locale, "_"); - int tokens = t.countTokens(); - if (tokens == 1) - { - this.locale = new Locale(locale); - } - else if (tokens == 2) - { - this.locale = new Locale(t.nextToken(), t.nextToken()); - } - else if (tokens == 3) - { - this.locale = new Locale(t.nextToken(), t.nextToken(), t.nextToken()); - } - + this.locale = I18NUtil.parseLocale(locale); + // store original strLocale = locale; } @@ -333,6 +321,7 @@ public class ImporterBootstrap extends AbstractLifecycleBean } UserTransaction userTransaction = transactionService.getUserTransaction(); + Authentication authentication = authenticationComponent.getCurrentAuthentication(); authenticationComponent.setSystemUserAsCurrentUser(); try @@ -448,7 +437,7 @@ public class ImporterBootstrap extends AbstractLifecycleBean } finally { - try {authenticationComponent.clearCurrentSecurityContext(); } catch (Throwable ex) {} + try {authenticationComponent.setCurrentAuthentication(authentication); } catch (Throwable ex) {} } } diff --git a/source/java/org/alfresco/repo/module/AbstractModuleComponent.java b/source/java/org/alfresco/repo/module/AbstractModuleComponent.java new file mode 100644 index 0000000000..d7f930e87d --- /dev/null +++ b/source/java/org/alfresco/repo/module/AbstractModuleComponent.java @@ -0,0 +1,348 @@ +/* + * Copyright (C) 2007 Alfresco, Inc. + * + * Licensed under the Mozilla Public License version 1.1 + * with a permitted attribution clause. You may obtain a + * copy of the License at + * + * http://www.alfresco.org/legal/license.txt + * + * 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.alfresco.repo.module; + +import java.util.ArrayList; +import java.util.List; + +import org.alfresco.error.AlfrescoRuntimeException; +import org.alfresco.i18n.I18NUtil; +import org.alfresco.repo.security.authentication.AuthenticationComponent; +import org.alfresco.service.ServiceRegistry; +import org.alfresco.service.cmr.module.ModuleService; +import org.alfresco.util.EqualsHelper; +import org.alfresco.util.PropertyCheck; +import org.alfresco.util.VersionNumber; +import org.springframework.beans.factory.BeanNameAware; + +/** + * Implementation of a {@link org.alfresco.repo.module.ModuleComponent} to provide + * the basic necessities. + * + * @see #executeInternal() + * + * @author Roy Wetherall + * @author Derek Hulley + * @since 2.0 + */ +public abstract class AbstractModuleComponent implements ModuleComponent, BeanNameAware +{ + private static final String ERR_ALREADY_EXECUTED = "module.err.already_executed"; + private static final String ERR_EXECUTION_FAILED = "module.err.execution_failed"; + + // Supporting components + protected ServiceRegistry serviceRegistry; + protected AuthenticationComponent authenticationComponent; + protected ModuleService moduleService; + + private String moduleId; + private String name; + private String description; + private VersionNumber sinceVersion; + private VersionNumber appliesFromVersion; + private VersionNumber appliesToVersion; + private List dependsOn; + /** Defaults to true */ + private boolean executeOnceOnly; + private boolean executed; + + public AbstractModuleComponent() + { + sinceVersion = VersionNumber.VERSION_ZERO; + appliesFromVersion = VersionNumber.VERSION_ZERO; + appliesToVersion = VersionNumber.VERSION_BIG; + dependsOn = new ArrayList(0); + executeOnceOnly = true; + executed = false; + } + + /** + * Checks for the presence of all generally-required properties. + */ + protected void checkProperties() + { + PropertyCheck.mandatory(this, "serviceRegistry", serviceRegistry); + PropertyCheck.mandatory(this, "authenticationComponent", authenticationComponent); + PropertyCheck.mandatory(this, "moduleId", moduleId); + PropertyCheck.mandatory(this, "name", name); + PropertyCheck.mandatory(this, "sinceVersion", sinceVersion); + PropertyCheck.mandatory(this, "appliesFromVersion", appliesFromVersion); + PropertyCheck.mandatory(this, "appliesToVersion", appliesToVersion); + } + + /** + * @see #getModuleId() + * @see #getName() + */ + @Override + public String toString() + { + StringBuilder sb = new StringBuilder(128); + sb.append("ModuleComponent") + .append("[ module=").append(moduleId) + .append(", name=").append(name) + .append(", since=").append(sinceVersion) + .append(", appliesFrom=").append(appliesFromVersion) + .append(", appliesTo=").append(appliesToVersion) + .append(", onceOnly=").append(executeOnceOnly) + .append("]"); + return sb.toString(); + } + + @Override + public boolean equals(Object obj) + { + if (this == obj) + { + return true; + } + if (false == obj instanceof ModuleComponent) + { + return false; + } + ModuleComponent that = (ModuleComponent) obj; + return (EqualsHelper.nullSafeEquals(this.moduleId, that.getModuleId()) + && EqualsHelper.nullSafeEquals(this.name, that.getName())); + } + + @Override + public int hashCode() + { + return moduleId.hashCode() + 17 * name.hashCode(); + } + + public void setAuthenticationComponent(AuthenticationComponent authenticationComponent) + { + this.authenticationComponent = authenticationComponent; + } + + /** + * Set the module service to register with. If not set, the component will not be + * automatically started. + * + * @param moduleService the service to register against. This is optional. + */ + public void setModuleService(ModuleService moduleService) + { + this.moduleService = moduleService; + } + + public void setServiceRegistry(ServiceRegistry serviceRegistry) + { + this.serviceRegistry = serviceRegistry; + } + + /** + * @inheritDoc + */ + public String getModuleId() + { + return moduleId; + } + + /** + * @param moduleId the globally unique module name. + */ + public void setModuleId(String moduleId) + { + this.moduleId = moduleId; + } + + /** + * @inheritDoc + */ + public String getName() + { + return name; + } + + /** + * Set the component name, which must be unique within the context of the + * module. If the is not set, then the bean name will be used. + * + * @param name the name of the component within the module. + * + * @see #setBeanName(String) + */ + public void setName(String name) + { + this.name = name; + } + + /** + * Convenience method that will set the name of the component to + * match the bean name, unless the {@link #setName(String) name} has + * been explicitly set. + */ + public void setBeanName(String name) + { + setName(name); + } + + /** + * @inheritDoc + */ + public String getDescription() + { + return description; + } + + /** + * Set the component's description. This will automatically be I18N'ized, so it may just + * be a resource bundle key. + * + * @param description a description of the component. + */ + public void setDescription(String description) + { + this.description = description; + } + + /** + * @inheritDoc + */ + public VersionNumber getSinceVersionNumber() + { + return sinceVersion; + } + + /** + * Set the version number for which this component was added. + */ + public void setSinceVersion(String version) + { + this.sinceVersion = new VersionNumber(version); + } + + /** + * @inheritDoc + */ + public VersionNumber getAppliesFromVersionNumber() + { + return appliesFromVersion; + } + + /** + * Set the minimum module version number to which this component applies. + * Default 0.0. + */ + public void setAppliesFromVersion(String version) + { + this.appliesFromVersion = new VersionNumber(version); + } + + /** + * @inheritDoc + */ + public VersionNumber getAppliesToVersionNumber() + { + return appliesToVersion; + } + + /** + * Set the minimum module version number to which this component applies. + * Default 999.0. + */ + public void setAppliesToVersion(String version) + { + this.appliesToVersion = new VersionNumber(version); + } + + /** + * @inheritDoc + */ + public List getDependsOn() + { + return dependsOn; + } + + /** + * @param dependsOn a list of modules that must be executed before this one + */ + public void setDependsOn(List dependsOn) + { + this.dependsOn = dependsOn; + } + + /** + * @inheritDoc + * + * @return Returns true always. Override as required. + */ + public boolean isExecuteOnceOnly() + { + return executeOnceOnly; + } + + /** + * @param executeOnceOnly true to force execution of this component with + * each startup or false if it must only be executed once. + */ + public void setExecuteOnceOnly(boolean executeOnceOnly) + { + this.executeOnceOnly = executeOnceOnly; + } + + public void init() + { + // Ensure that the description gets I18N'ized + description = I18NUtil.getMessage(description); + // Register the component with the service + if (moduleService != null) // Allows optional registration of the component + { + moduleService.registerComponent(this); + } + } + + /** + * The method that performs the actual work. For the most part, derived classes will + * only have to override this method to be fully functional. + * + * @throws Throwable any problems, just throw them + */ + protected abstract void executeInternal() throws Throwable; + + /** + * @inheritDoc + * + * @see #executeInternal() the abstract method to be implemented by subclasses + */ + public final synchronized void execute() + { + // ensure that this has not been executed already + if (executed) + { + throw AlfrescoRuntimeException.create(ERR_ALREADY_EXECUTED, moduleId, name); + } + // Ensure properties have been set + checkProperties(); + // Execute + try + { + executeInternal(); + } + catch (Throwable e) + { + throw AlfrescoRuntimeException.create(e, ERR_EXECUTION_FAILED, name, e.getMessage()); + } + finally + { + // There are no second chances + executed = true; + } + } +} diff --git a/source/java/org/alfresco/repo/module/ComponentsTest.java b/source/java/org/alfresco/repo/module/ComponentsTest.java new file mode 100644 index 0000000000..98a2f382c2 --- /dev/null +++ b/source/java/org/alfresco/repo/module/ComponentsTest.java @@ -0,0 +1,142 @@ +/* + * Copyright (C) 2007 Alfresco, Inc. + * + * Licensed under the Mozilla Public License version 1.1 + * with a permitted attribution clause. You may obtain a + * copy of the License at + * + * http://www.alfresco.org/legal/license.txt + * + * 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.alfresco.repo.module; + +import java.util.Collection; + +import javax.transaction.UserTransaction; + +import junit.framework.TestCase; + +import org.alfresco.model.ContentModel; +import org.alfresco.repo.security.authentication.AuthenticationComponent; +import org.alfresco.service.ServiceRegistry; +import org.alfresco.service.cmr.repository.ChildAssociationRef; +import org.alfresco.service.cmr.repository.NodeRef; +import org.alfresco.service.cmr.repository.NodeService; +import org.alfresco.service.cmr.repository.StoreRef; +import org.alfresco.service.cmr.search.CategoryService; +import org.alfresco.service.transaction.TransactionService; +import org.springframework.context.ApplicationContext; +import org.springframework.context.support.ClassPathXmlApplicationContext; + +/** + * Tests various module components. + * + * @see org.alfresco.repo.module.ImporterModuleComponent + * @see org.alfresco.repo.module.ModuleComponent + * + * @author Derek Hulley + */ +public class ComponentsTest extends TestCase +{ + private static ApplicationContext ctx = new ClassPathXmlApplicationContext("module/module-component-test-beans.xml"); + + private ServiceRegistry serviceRegistry; + private AuthenticationComponent authenticationComponent; + private TransactionService transactionService; + private NodeService nodeService; + private UserTransaction txn; + + @Override + protected void setUp() throws Exception + { + serviceRegistry = (ServiceRegistry) ctx.getBean(ServiceRegistry.SERVICE_REGISTRY); + authenticationComponent = (AuthenticationComponent) ctx.getBean("AuthenticationComponent"); + transactionService = serviceRegistry.getTransactionService(); + nodeService = serviceRegistry.getNodeService(); + + // Run as system user + authenticationComponent.setSystemUserAsCurrentUser(); + + // Start a transaction + txn = transactionService.getUserTransaction(); + } + + @Override + protected void tearDown() throws Exception + { + // Clear authentication + try + { + authenticationComponent.clearCurrentSecurityContext(); + } + catch (Throwable e) + { + e.printStackTrace(); + } + // Rollback the transaction + try + { +// txn.rollback(); + txn.commit(); + } + catch (Throwable e) + { + // Ignore + } + } + + /** Ensure that the test starts and stops properly */ + public void testSetup() throws Exception + { + } + + private NodeRef getLoadedCategoryRoot() + { + StoreRef storeRef = new StoreRef(StoreRef.PROTOCOL_WORKSPACE, "SpacesStore"); + + CategoryService categoryService = serviceRegistry.getCategoryService(); + // Check if the categories exist + Collection assocRefs = categoryService.getRootCategories( + storeRef, + ContentModel.ASPECT_GEN_CLASSIFIABLE); + // Find it + for (ChildAssociationRef assocRef : assocRefs) + { + NodeRef nodeRef = assocRef.getChildRef(); + if (nodeRef.getId().equals("test:xyz-root")) + { + // Found it + return nodeRef; + } + } + return null; + } + + public void testImporterModuleComponent() throws Exception + { + // Delete any pre-existing data + NodeRef nodeRef = getLoadedCategoryRoot(); + if (nodeRef != null) + { + CategoryService categoryService = serviceRegistry.getCategoryService(); + categoryService.deleteCategory(nodeRef); + } + // Double check to make sure it is gone + nodeRef = getLoadedCategoryRoot(); + assertNull("Category not deleted", nodeRef); + + ImporterModuleComponent component = (ImporterModuleComponent) ctx.getBean("module.test.importerComponent"); + // Execute it + component.execute(); + + // Now make sure the data exists + nodeRef = getLoadedCategoryRoot(); + assertNotNull("Loaded category root not found", nodeRef); + } +} diff --git a/source/java/org/alfresco/repo/module/ImporterModuleComponent.java b/source/java/org/alfresco/repo/module/ImporterModuleComponent.java new file mode 100644 index 0000000000..ed30973ba3 --- /dev/null +++ b/source/java/org/alfresco/repo/module/ImporterModuleComponent.java @@ -0,0 +1,108 @@ +/* + * Copyright (C) 2007 Alfresco, Inc. + * + * Licensed under the Mozilla Public License version 1.1 + * with a permitted attribution clause. You may obtain a + * copy of the License at + * + * http://www.alfresco.org/legal/license.txt + * + * 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.alfresco.repo.module; + +import java.util.ArrayList; +import java.util.List; +import java.util.Properties; + +import org.alfresco.repo.importer.ImporterBootstrap; +import org.alfresco.util.PropertyCheck; + + +/** + * Generic module component that can be wired up to import data into the system. + * + * @author Derek Hulley + * @since 2.0 + */ +public class ImporterModuleComponent extends AbstractModuleComponent +{ + private ImporterBootstrap importer; + private Properties bootstrapView; + private List bootstrapViews; + + /** + * Set the helper that has details of the store to load the data into. + * Alfresco has a set of predefined importers for all the common stores in use. + * + * @param importer the bootstrap bean that performs the store bootstrap. + */ + public void setImporter(ImporterBootstrap importer) + { + this.importer = importer; + } + + /** + * Set a list of bootstrap views to import.
    + * This is an alternative to {@link #setBootstrapViews(List)}. + * + * @param bootstrapView the bootstrap data location + * + * @see ImporterBootstrap#setBootstrapViews(List) + */ + public void setBootstrapView(Properties bootstrapView) + { + this.bootstrapView = bootstrapView; + } + + /** + * Set a list of bootstrap views to import.
    + * This is an alternative to {@link #setBootstrapView(Properties)}. + * + * @param bootstrapViews the bootstrap data locations + * + * @see ImporterBootstrap#setBootstrapViews(List) + */ + public void setBootstrapViews(List bootstrapViews) + { + this.bootstrapViews = bootstrapViews; + } + + @Override + protected void checkProperties() + { + PropertyCheck.mandatory(this, "importerBootstrap", importer); + if (bootstrapView == null && bootstrapViews == null) + { + PropertyCheck.mandatory(this, null, "bootstrapViews or bootstrapView"); + } + // fulfil contract of override + super.checkProperties(); + } + + @Override + protected void executeInternal() throws Throwable + { + // Construct the bootstrap views + List views = new ArrayList(1); + if (bootstrapViews != null) + { + views.addAll(bootstrapViews); + } + if (bootstrapView != null) + { + views.add(bootstrapView); + } + // modify the bootstrapper + importer.setBootstrapViews(views); + importer.setUseExistingStore(true); // allow import into existing store + + importer.bootstrap(); + // Done + } +} diff --git a/source/java/org/alfresco/repo/module/ModuleComponent.java b/source/java/org/alfresco/repo/module/ModuleComponent.java new file mode 100644 index 0000000000..a8b5a2b7fc --- /dev/null +++ b/source/java/org/alfresco/repo/module/ModuleComponent.java @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2007 Alfresco, Inc. + * + * Licensed under the Mozilla Public License version 1.1 + * with a permitted attribution clause. You may obtain a + * copy of the License at + * + * http://www.alfresco.org/legal/license.txt + * + * 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.alfresco.repo.module; + +import java.util.List; + +import org.alfresco.util.VersionNumber; + +/** + * Interface for classes that control the startup and shutdown behaviour of modules. + *

    + * Note that the execution order of these components is on the basis of dependencies + * only. The version numbering determines only whether a component will be executed + * and doesn't imply any ordering. + *

    + * Equals and Hashcode method must be implemented. + * + * @author Derek Hulley + * @since 2.0 + */ +public interface ModuleComponent +{ + /** + * @return Returns the globally unique module ID. + */ + String getModuleId(); + + /** + * @return Returns the name of the component in the context of the module ID. It does not + * have to be globally unique. + */ + String getName(); + + /** + * + * @return Returns a description of the component. + */ + String getDescription(); + + /** + * @return Returns the version number of the module for which this component was introduced. + */ + VersionNumber getSinceVersionNumber(); + + /** + * @return Returns the smallest version number of the module to which this component applies. + */ + VersionNumber getAppliesFromVersionNumber(); + + /** + * @return Returns the largest version number of the module to which this component applies. + */ + VersionNumber getAppliesToVersionNumber(); + + /** + * A list of module components that must be executed prior to this instance. + * This is the only way to guarantee ordered execution. The dependencies may include + * components from other modules, guaranteeing an early failure if a module is missing. + * + * @return Returns a list of components that must be executed prior to this component. + */ + List getDependsOn(); + + /** + * @return Returns true if the component is to be successfully executed exactly once, + * or false if the component must be executed with each startup. + */ + boolean isExecuteOnceOnly(); + + /** + * Perform the actual component's work. Execution will be done within the context of a + * system account with an enclosing transaction. Long-running processes should be spawned + * from the calling thread, if required. + *

    + * All failures should just be thrown out as runtime exceptions and will be dealt with by + * the associated module infrastructure. + */ + void execute(); +} diff --git a/source/java/org/alfresco/repo/module/ModuleComponentHelper.java b/source/java/org/alfresco/repo/module/ModuleComponentHelper.java new file mode 100644 index 0000000000..c26d9067c6 --- /dev/null +++ b/source/java/org/alfresco/repo/module/ModuleComponentHelper.java @@ -0,0 +1,342 @@ +/* + * Copyright (C) 2007 Alfresco, Inc. + * + * Licensed under the Mozilla Public License version 1.1 + * with a permitted attribution clause. You may obtain a + * copy of the License at + * + * http://www.alfresco.org/legal/license.txt + * + * 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.alfresco.repo.module; + +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import net.sf.acegisecurity.Authentication; + +import org.alfresco.error.AlfrescoRuntimeException; +import org.alfresco.i18n.I18NUtil; +import org.alfresco.repo.admin.registry.RegistryKey; +import org.alfresco.repo.admin.registry.RegistryService; +import org.alfresco.repo.security.authentication.AuthenticationComponent; +import org.alfresco.repo.transaction.TransactionUtil; +import org.alfresco.repo.transaction.TransactionUtil.TransactionWork; +import org.alfresco.service.ServiceRegistry; +import org.alfresco.service.cmr.module.ModuleDetails; +import org.alfresco.service.cmr.module.ModuleService; +import org.alfresco.service.transaction.TransactionService; +import org.alfresco.util.PropertyCheck; +import org.alfresco.util.VersionNumber; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +/** + * Helper class to split up some of the code for managing module components. This class handles + * the execution of the module components. + * + * @author Derek Hulley + */ +public class ModuleComponentHelper +{ + public static final String URI_MODULES_1_0 = "http://www.alfresco.org/system/modules/1.0"; + private static final String REGISTRY_PATH_MODULES = "modules"; + private static final String REGISTRY_PROPERTY_INSTALLED_VERSION = "installedVersion"; + private static final String REGISTRY_PROPERTY_CURRENT_VERSION = "currentVersion"; + private static final String REGISTRY_PATH_COMPONENTS = "components"; + private static final String REGISTRY_PROPERTY_EXECUTION_DATE = "executionDate"; + + private static final String MSG_FOUND_MODULES = "module.msg.found_modules"; + private static final String MSG_STARTING = "module.msg.starting"; + private static final String MSG_INSTALLING = "module.msg.installing"; + private static final String MSG_UPGRADING = "module.msg.upgrading"; + private static final String ERR_NO_DOWNGRADE = "module.err.downgrading_not_supported"; + private static final String ERR_COMPONENT_ALREADY_REGISTERED = "module.err.component_already_registered"; + + private static Log logger = LogFactory.getLog(ModuleComponentHelper.class); + private static Log loggerService = LogFactory.getLog(ModuleServiceImpl.class); + + private ServiceRegistry serviceRegistry; + private AuthenticationComponent authenticationComponent; + private RegistryService registryService; + private ModuleService moduleService; + private Map> componentsByNameByModule; + + /** Default constructor */ + public ModuleComponentHelper() + { + componentsByNameByModule = new HashMap>(7); + } + + /** + * @param serviceRegistry provides access to the service APIs + */ + public void setServiceRegistry(ServiceRegistry serviceRegistry) + { + this.serviceRegistry = serviceRegistry; + } + + /** + * @param authenticationComponent allows execution as system user. + */ + public void setAuthenticationComponent(AuthenticationComponent authenticationComponent) + { + this.authenticationComponent = authenticationComponent; + } + + /** + * @param registryService the service used to persist component execution details. + */ + public void setRegistryService(RegistryService registryService) + { + this.registryService = registryService; + } + + /** + * @param moduleService the service from which to get the available modules. + */ + public void setModuleService(ModuleService moduleService) + { + this.moduleService = moduleService; + } + + /** + * Add a managed module component to the registry of components. These will be controlled + * by the {@link #startModules()} method. + * + * @param component a module component to be executed + */ + public synchronized void registerComponent(ModuleComponent component) + { + String moduleId = component.getModuleId(); + String name = component.getName(); + // Get the map of components for the module + Map componentsByName = componentsByNameByModule.get(moduleId); + if (componentsByName == null) + { + componentsByName = new HashMap(11); + componentsByNameByModule.put(moduleId, componentsByName); + } + // Check if the component has already been registered + if (componentsByName.containsKey(name)) + { + throw AlfrescoRuntimeException.create(ERR_COMPONENT_ALREADY_REGISTERED, name, moduleId); + } + // Add it + componentsByName.put(name, component); + // Done + if (logger.isDebugEnabled()) + { + logger.debug("Registered component: " + component); + } + } + + /** + * @return Returns the map of components keyed by name. The map could be empty but + * will never be null. + */ + private synchronized Map getComponents(String moduleId) + { + Map componentsByName = componentsByNameByModule.get(moduleId); + if (componentsByName != null) + { + // Done + return componentsByName; + } + else + { + // Done + return Collections.emptyMap(); + } + } + + /** + * @inheritDoc + */ + public synchronized void startModules() + { + // Check properties + PropertyCheck.mandatory(this, "serviceRegistry", serviceRegistry); + PropertyCheck.mandatory(this, "authenticationComponent", authenticationComponent); + PropertyCheck.mandatory(this, "registryService", registryService); + PropertyCheck.mandatory(this, "moduleService", moduleService); + /* + * Ensure transactionality and the correct authentication + */ + // Get the current authentication + Authentication authentication = authenticationComponent.getCurrentAuthentication(); + try + { + TransactionService transactionService = serviceRegistry.getTransactionService(); + authenticationComponent.setSystemUserAsCurrentUser(); + // Get all the modules + List modules = moduleService.getAllModules(); + loggerService.info(I18NUtil.getMessage(MSG_FOUND_MODULES, modules.size())); + // Process each module in turn. Ordering is not important. + final Set executedComponents = new HashSet(10); + for (final ModuleDetails module : modules) + { + TransactionWork startModuleWork = new TransactionWork() + { + public Object doWork() throws Exception + { + startModule(module, executedComponents); + return null; + } + }; + TransactionUtil.executeInNonPropagatingUserTransaction(transactionService, startModuleWork); + } + // Done + if (logger.isDebugEnabled()) + { + logger.debug("Executed " + executedComponents.size() + " components"); + } + } + finally + { + // Restore the original authentication + authenticationComponent.setCurrentAuthentication(authentication); + } + } + + /** + * Does the actual work without fussing about transactions and authentication. + */ + private void startModule(ModuleDetails module, Set executedComponents) + { + String moduleId = module.getId(); + VersionNumber moduleVersion = module.getVersionNumber(); + // Get the module details from the registry + RegistryKey moduleKeyInstalledVersion = new RegistryKey( + ModuleComponentHelper.URI_MODULES_1_0, + REGISTRY_PATH_MODULES, moduleId, REGISTRY_PROPERTY_INSTALLED_VERSION); + RegistryKey moduleKeyCurrentVersion = new RegistryKey( + ModuleComponentHelper.URI_MODULES_1_0, + REGISTRY_PATH_MODULES, moduleId, REGISTRY_PROPERTY_CURRENT_VERSION); + VersionNumber versionCurrent = (VersionNumber) registryService.getValue(moduleKeyCurrentVersion); + String msg = null; + if (versionCurrent == null) // There is no current version + { + msg = I18NUtil.getMessage(MSG_INSTALLING, moduleId, moduleVersion); + // Record the install version + registryService.addValue(moduleKeyInstalledVersion, moduleVersion); + } + else // It is an upgrade or is the same + { + if (versionCurrent.compareTo(moduleVersion) == 0) // The current version is the same + { + msg = I18NUtil.getMessage(MSG_STARTING, moduleId, moduleVersion); + } + else if (versionCurrent.compareTo(moduleVersion) > 0) // Downgrading not supported + { + throw AlfrescoRuntimeException.create(ERR_NO_DOWNGRADE, moduleId, versionCurrent, moduleVersion); + } + else // This is an upgrade + { + msg = I18NUtil.getMessage(MSG_UPGRADING, moduleId, moduleVersion, versionCurrent); + } + } + loggerService.info(msg); + // Record the current version + registryService.addValue(moduleKeyCurrentVersion, moduleVersion); + + Map componentsByName = getComponents(moduleId); + for (ModuleComponent component : componentsByName.values()) + { + executeComponent(module, component, executedComponents); + } + + // Done + if (logger.isDebugEnabled()) + { + logger.debug("Started module: " + module); + } + } + + /** + * Execute the component, respecting dependencies. + */ + private void executeComponent(ModuleDetails module, ModuleComponent component, Set executedComponents) + { + // Ignore if it has been executed in this run already + if (executedComponents.contains(component)) + { + // Already done + if (logger.isDebugEnabled()) + { + logger.debug("Skipping component already executed in this run: \n" + + " Component: " + component); + } + return; + } + + // Check the version applicability + VersionNumber moduleVersion = module.getVersionNumber(); + VersionNumber minVersion = component.getAppliesFromVersionNumber(); + VersionNumber maxVersion = component.getAppliesToVersionNumber(); + if (moduleVersion.compareTo(minVersion) < 0 || moduleVersion.compareTo(maxVersion) > 0) + { + // It is out of the allowable range for execution so we just ignore it + if (logger.isDebugEnabled()) + { + logger.debug("Skipping component that doesn't apply to the current version: \n" + + " Component: " + component + "\n" + + " Module: " + module + "\n" + + " Version: " + moduleVersion + "\n" + + " Applies From : " + minVersion + "\n" + + " Applies To : " + maxVersion); + } + return; + } + + // Construct the registry key to store the execution date + String moduleId = component.getModuleId(); + String name = component.getName(); + RegistryKey executionDateKey = new RegistryKey( + ModuleComponentHelper.URI_MODULES_1_0, + REGISTRY_PATH_MODULES, moduleId, REGISTRY_PATH_COMPONENTS, name, REGISTRY_PROPERTY_EXECUTION_DATE); + + // Check if the component has been executed + Date executionDate = (Date) registryService.getValue(executionDateKey); + if (executionDate != null && component.isExecuteOnceOnly()) + { + // It has been executed and is scheduled for a single execution - leave it + if (logger.isDebugEnabled()) + { + logger.debug("Skipping already-executed module component: \n" + + " Component: " + component + "\n" + + " Execution Time: " + executionDate); + } + return; + } + // It may have been executed, but not in this run and it is allowed to be repeated + // Check for dependencies + List dependencies = component.getDependsOn(); + for (ModuleComponent dependency : dependencies) + { + executeComponent(module, dependency, executedComponents); + } + // Execute the component itself + component.execute(); + // Keep track of it in the registry and in this run + executedComponents.add(component); + registryService.addValue(executionDateKey, new Date()); + // Done + if (logger.isDebugEnabled()) + { + logger.debug("Executed module component: \n" + + " Component: " + component); + } + } +} diff --git a/source/java/org/alfresco/repo/module/ModuleComponentHelperTest.java b/source/java/org/alfresco/repo/module/ModuleComponentHelperTest.java new file mode 100644 index 0000000000..00de91f456 --- /dev/null +++ b/source/java/org/alfresco/repo/module/ModuleComponentHelperTest.java @@ -0,0 +1,259 @@ +/* + * Copyright (C) 2007 Alfresco, Inc. + * + * Licensed under the Mozilla Public License version 1.1 + * with a permitted attribution clause. You may obtain a + * copy of the License at + * + * http://www.alfresco.org/legal/license.txt + * + * 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.alfresco.repo.module; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.alfresco.repo.admin.registry.RegistryService; +import org.alfresco.service.cmr.module.ModuleDetails; +import org.alfresco.service.cmr.module.ModuleService; +import org.alfresco.util.BaseAlfrescoTestCase; +import org.alfresco.util.VersionNumber; + +/** + * @see org.alfresco.repo.module.ModuleComponentHelper + *

    + * This test creates a bunch of dummy components and then simulates + * startups with different module current versions. + *

    + * There are 3 modules. There are 3 components. + * + * @author Derek Hulley + */ +public class ModuleComponentHelperTest extends BaseAlfrescoTestCase +{ + private final String CURRENT_TIME = "" + System.currentTimeMillis() + "-" + System.nanoTime(); + private final String[] MODULE_IDS = + { + "M0 @ " + CURRENT_TIME, + "M1 @ " + CURRENT_TIME, + "M2 @ " + CURRENT_TIME + }; + private final String[] COMPONENT_NAMES = + { + "C0 @ " + CURRENT_TIME, + "C1 @ " + CURRENT_TIME, + "C2 @ " + CURRENT_TIME + }; + private final VersionNumber[] VERSIONS = + { + new VersionNumber("0"), + new VersionNumber("1"), + new VersionNumber("2"), + new VersionNumber("3") + }; + private static final Map EXECUTION_COUNT_BY_VERSION; + static + { + EXECUTION_COUNT_BY_VERSION = new HashMap(13); + EXECUTION_COUNT_BY_VERSION.put(new VersionNumber("0.0"), 3); + EXECUTION_COUNT_BY_VERSION.put(new VersionNumber("0.5"), 3); + EXECUTION_COUNT_BY_VERSION.put(new VersionNumber("1.0"), 6); + EXECUTION_COUNT_BY_VERSION.put(new VersionNumber("1.5"), 3); + EXECUTION_COUNT_BY_VERSION.put(new VersionNumber("2.0"), 6); + EXECUTION_COUNT_BY_VERSION.put(new VersionNumber("2.5"), 3); + EXECUTION_COUNT_BY_VERSION.put(new VersionNumber("3.0"), 3); + EXECUTION_COUNT_BY_VERSION.put(new VersionNumber("3.5"), 0); + }; + + private RegistryService registryService; + private DummyModuleService moduleService; + private ModuleComponentHelper helper; + + private DummyModuleComponent[][] components; + + public void setUp() throws Exception + { + super.setUp(); + + registryService = (RegistryService) ctx.getBean("RegistryService"); + + moduleService = new DummyModuleService(); + helper = new ModuleComponentHelper(); + helper.setAuthenticationComponent(super.authenticationComponent); + helper.setModuleService(moduleService); + helper.setRegistryService(registryService); + helper.setServiceRegistry(serviceRegistry); + + // Register the components + components = new DummyModuleComponent[3][3]; // i,j + for (int i = 0; i < 3; i++) // i = module number + { + for (int j = 0; j < 3; j++) // j = component number + { + DummyModuleComponent component = new DummyModuleComponent( + MODULE_IDS[i], + COMPONENT_NAMES[j], + VERSIONS[j], + VERSIONS[j+1]); + component.setServiceRegistry(serviceRegistry); + component.setAuthenticationComponent(authenticationComponent); + component.setModuleService(moduleService); + // Don't initialize the component as that will do the registration. We do it manually. + helper.registerComponent(component); + // Add to array + components[i][j] = component; + } + } + // M1-C1 depends on M0-C1 + components[1][1].setDependsOn(Collections.singletonList(components[0][1])); + } + + public void testSetup() throws Exception + { + // See that it all starts OK + } + + private void startComponents(VersionNumber moduleVersion) + { + int expectedCount = (Integer) EXECUTION_COUNT_BY_VERSION.get(moduleVersion); + // Set the current version number for all modules + moduleService.setCurrentVersion(moduleVersion); + // Start them + helper.startModules(); + // Check + assertEquals("Incorrent number of executions (version " + moduleVersion + ")", expectedCount, executed); + } + + public void testStartComponentsV00() + { + VersionNumber moduleVersion = new VersionNumber("0.0"); + startComponents(moduleVersion); + } + + public void testStartComponentsV05() + { + VersionNumber moduleVersion = new VersionNumber("0.5"); + startComponents(moduleVersion); + } + + public void testStartComponentsV10() + { + VersionNumber moduleVersion = new VersionNumber("1.0"); + startComponents(moduleVersion); + } + + public void testStartComponentsV15() + { + VersionNumber moduleVersion = new VersionNumber("1.5"); + startComponents(moduleVersion); + } + + public void testStartComponentsV30() + { + VersionNumber moduleVersion = new VersionNumber("3.0"); + startComponents(moduleVersion); + } + + public void testStartComponentsV35() + { + VersionNumber moduleVersion = new VersionNumber("3.5"); + startComponents(moduleVersion); + } + + /** + * Helper bean to simulate module presences under controlled conditions. + */ + private class DummyModuleService implements ModuleService + { + private VersionNumber currentVersion; + /** Set the current version of all the modules */ + public void setCurrentVersion(VersionNumber currentVersion) + { + this.currentVersion = currentVersion; + } + + public void registerComponent(ModuleComponent component) + { + throw new UnsupportedOperationException(); + } + + public List getAllModules() + { + // Reset the execution count + executed = 0; + // Create some module details + List details = new ArrayList(3); + for (int i = 0; i < 3; i++) + { + ModuleDetails moduleDetails = new ModuleDetailsImpl( + MODULE_IDS[i], + currentVersion, + "Module-" + i, + "Description-" + i); + details.add(moduleDetails); + } + // Done + return details; + } + + public ModuleDetails getModule(String moduleId) + { + throw new UnsupportedOperationException(); + } + + public void startModules() + { + throw new UnsupportedOperationException(); + } + } + + /** Keep track of the execution count */ + static int executed = 0; + + /** + * A dummy + * @author Derek Hulley + */ + private class DummyModuleComponent extends AbstractModuleComponent + { + private DummyModuleComponent(String moduleId, String name, VersionNumber from, VersionNumber to) + { + super.setServiceRegistry(serviceRegistry); + super.setAuthenticationComponent(authenticationComponent); + super.setModuleService(moduleService); + + super.setModuleId(moduleId); + super.setName(name); + super.setAppliesFromVersion(from.toString()); + super.setAppliesToVersion(to.toString()); + super.setSinceVersion("10.1.2"); + + super.setDescription("A dummy module component"); + } + + @Override + protected void executeInternal() throws Throwable + { + // Record execution + executed++; + } + } + + /** No-operation tester class */ + public static class NoopModuleComponent extends AbstractModuleComponent + { + @Override + protected void executeInternal() throws Throwable + { + } + } +} diff --git a/source/java/org/alfresco/repo/module/ModuleDetailsImpl.java b/source/java/org/alfresco/repo/module/ModuleDetailsImpl.java new file mode 100644 index 0000000000..de59b6b3d5 --- /dev/null +++ b/source/java/org/alfresco/repo/module/ModuleDetailsImpl.java @@ -0,0 +1,141 @@ +/* + * Copyright (C) 2005 Alfresco, Inc. + * + * Licensed under the Mozilla Public License version 1.1 + * with a permitted attribution clause. You may obtain a + * copy of the License at + * + * http://www.alfresco.org/legal/license.txt + * + * 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.alfresco.repo.module; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Properties; + +import org.alfresco.repo.module.tool.ModuleManagementToolException; +import org.alfresco.service.cmr.module.ModuleDetails; +import org.alfresco.util.VersionNumber; + +/** + * Module details implementation. + * + * Loads details from the serialized properties file provided. + * + * @author Roy Wetherall + */ +public class ModuleDetailsImpl implements ModuleDetails +{ + /** Property names */ + protected static final String PROP_ID = "module.id"; + protected static final String PROP_TITLE = "module.title"; + protected static final String PROP_DESCRIPTION = "module.description"; + protected static final String PROP_VERSION = "module.version"; + protected static final String PROP_INSTALL_DATE = "module.installDate"; + + /** Properties object */ + protected Properties properties; + + /** + * Constructor + * + * @param is input stream, which will be closed + */ + public ModuleDetailsImpl(InputStream is) + { + try + { + this.properties = new Properties(); + this.properties.load(is); + } + catch (IOException exception) + { + throw new ModuleManagementToolException("Unable to load module details from property file.", exception); + } + finally + { + try { is.close(); } catch (IOException e) { e.printStackTrace(); } + } + } + + /** + * Constructor + * + * @param id module id + * @param versionNumber version number + * @param title title + * @param description description + */ + public ModuleDetailsImpl(String id, VersionNumber versionNumber, String title, String description) + { + this.properties = new Properties(); + this.properties.setProperty(PROP_ID, id); + this.properties.setProperty(PROP_VERSION, versionNumber.toString()); + this.properties.setProperty(PROP_TITLE, title); + this.properties.setProperty(PROP_DESCRIPTION, description); + } + + /** + * @see org.alfresco.service.cmr.module.ModuleDetails#exists() + */ + public boolean exists() + { + return (this.properties != null); + } + + /** + * @see org.alfresco.service.cmr.module.ModuleDetails#getId() + */ + public String getId() + { + return this.properties.getProperty(PROP_ID); + } + + /** + * @see org.alfresco.service.cmr.module.ModuleDetails#getVersionNumber() + */ + public VersionNumber getVersionNumber() + { + return new VersionNumber(this.properties.getProperty(PROP_VERSION)); + } + + /** + * @see org.alfresco.service.cmr.module.ModuleDetails#getTitle() + */ + public String getTitle() + { + return this.properties.getProperty(PROP_TITLE); + } + + /** + * @see org.alfresco.service.cmr.module.ModuleDetails#getDescription() + */ + public String getDescription() + { + return this.properties.getProperty(PROP_DESCRIPTION); + } + + /** + * @see org.alfresco.service.cmr.module.ModuleDetails#getInstalledDate() + */ + public String getInstalledDate() + { + return this.properties.getProperty(PROP_INSTALL_DATE); + } + + /** + * @see java.lang.Object#toString() + */ + @Override + public String toString() + { + return getId(); + } +} diff --git a/source/java/org/alfresco/repo/module/ModuleManagementTool.java b/source/java/org/alfresco/repo/module/ModuleManagementTool.java deleted file mode 100644 index c1d8134330..0000000000 --- a/source/java/org/alfresco/repo/module/ModuleManagementTool.java +++ /dev/null @@ -1,430 +0,0 @@ -/* - * Copyright (C) 2005 Alfresco, Inc. - * - * Licensed under the Mozilla Public License version 1.1 - * with a permitted attribution clause. You may obtain a - * copy of the License at - * - * http://www.alfresco.org/legal/license.txt - * - * 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.alfresco.repo.module; - -import java.io.BufferedReader; -import java.io.FileNotFoundException; -import java.io.FileReader; -import java.io.IOException; -import java.io.InputStream; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Properties; - -import org.alfresco.error.AlfrescoRuntimeException; -import org.alfresco.util.VersionNumber; -import org.apache.log4j.Logger; - -import de.schlichtherle.io.DefaultRaesZipDetector; -import de.schlichtherle.io.File; -import de.schlichtherle.io.FileInputStream; -import de.schlichtherle.io.FileOutputStream; -import de.schlichtherle.io.ZipControllerException; -import de.schlichtherle.io.ZipDetector; -import de.schlichtherle.io.ZipWarningException; - -/** - * @author Roy Wetherall - */ -public class ModuleManagementTool -{ - public static Logger logger = Logger.getLogger("org.alfresco.repo.extension.ModuleManagementTool"); - - private static final String DEFAULT_FILE_MAPPING_PROPERTIES = "org/alfresco/repo/module/default-file-mapping.properties"; - private static final String MODULE_DIR = "/WEB-INF/classes/alfresco/module"; - - private static final String DELIMITER = ":"; - - private static final String PROP_ID = "module.id"; - private static final String PROP_TITLE = "module.title"; - private static final String PROP_DESCRIPTION = "module.description"; - private static final String PROP_VERSION = "module.version"; - - private static final String MOD_ADD_FILE = "add"; - private static final String MOD_UPDATE_FILE = "update"; - private static final String MOD_MK_DIR = "mkdir"; - - private static final String OP_INSTALL = "install"; - - private ZipDetector defaultDetector; - - private Properties fileMappingProperties; - - private boolean verbose = false; - - public ModuleManagementTool() - { - // Create the default zip detector - this.defaultDetector = new DefaultRaesZipDetector("amp|war"); - - // Load the default file mapping properties - this.fileMappingProperties = new Properties(); - InputStream is = this.getClass().getClassLoader().getResourceAsStream(DEFAULT_FILE_MAPPING_PROPERTIES); - try - { - this.fileMappingProperties.load(is); - } - catch (IOException exception) - { - throw new ModuleManagementToolException("Unable to load default extension file mapping properties.", exception); - } - } - - public boolean isVerbose() - { - return verbose; - } - - public void setVerbose(boolean verbose) - { - this.verbose = verbose; - } - - public void installModule(String ampFileLocation, String warFileLocation) - { - try - { - // Load the extension properties - File installingPropertiesFile = new File(ampFileLocation + "/module.properties", this.defaultDetector); - if (installingPropertiesFile.exists() == false) - { - throw new ModuleManagementToolException("Extension properties are not present in the AMP. Check that a valid module.properties file is present."); - } - Properties installingProperties = new Properties(); - installingProperties.load(new FileInputStream(installingPropertiesFile)); - - // Get the intalling extension version - String installingVersionString = installingProperties.getProperty(PROP_VERSION); - if (installingVersionString == null || installingVersionString.length() == 0) - { - throw new ModuleManagementToolException("The version number has not been specified in the module properties found in the AMP."); - } - VersionNumber installingVersion = new VersionNumber(installingVersionString); - - // Get the installed directory - File installDir = getInstalledDir(warFileLocation); - - // Look for a previously installed version of this extension - File installedExtensionPropertiesFile = new File(installDir.getPath() + "/" + getModuleDetailsFileName(installingProperties.getProperty(PROP_ID)), this.defaultDetector); - if (installedExtensionPropertiesFile.exists() == true) - { - Properties installedExtensionProperties = new Properties(); - InputStream is = new FileInputStream(installedExtensionPropertiesFile); - installedExtensionProperties.load(is); - - // Get the installed version - VersionNumber installedVersion = new VersionNumber(installedExtensionProperties.getProperty(PROP_VERSION)); - int compareValue = installedVersion.compareTo(installingVersion); - if (compareValue == -1) - { - // Trying to update the extension, old files need to cleaned before we proceed - cleanWAR(warFileLocation, installedExtensionProperties); - } - else if (compareValue == 0) - { - // Trying to install the same extension version again - verboseMessage("WARNING: This version of this module is already installed in the WAR"); - throw new ModuleManagementToolException("This version of this module is alreay installed. Use the 'force' parameter if you want to overwrite the current installation."); - } - else if (compareValue == 1) - { - // Trying to install an earlier version of the extension - verboseMessage("WARNING: A later version of this module is already installed in the WAR"); - throw new ModuleManagementToolException("An earlier version of this module is already installed. You must first unistall the current version before installing this version of the module."); - } - - } - - // TODO check for any additional file mapping propeties supplied in the AEP file - - // Copy the files from the AEP file into the WAR file - Map modifications = new HashMap(50); - for (Map.Entry entry : this.fileMappingProperties.entrySet()) - { - modifications.putAll(copyToWar(ampFileLocation, warFileLocation, (String)entry.getKey(), (String)entry.getValue())); - } - - // Copy the properties file into the war - if (installedExtensionPropertiesFile.exists() == false) - { - installedExtensionPropertiesFile.createNewFile(); - } - InputStream is = new FileInputStream(installingPropertiesFile); - try - { - installedExtensionPropertiesFile.catFrom(is); - } - finally - { - is.close(); - } - - // Create and add the modifications file to the war - writeModificationToFile(installDir.getPath() + "/" + getModuleModificationFileName(installingProperties.getProperty(PROP_ID)), modifications); - - // Update the zip file's - File.update(); - } - catch (ZipWarningException ignore) - { - // Only instances of the class ZipWarningException exist in the chain of - // exceptions. We choose to ignore this. - } - catch (ZipControllerException exception) - { - // At least one exception occured which is not just a ZipWarningException. - // This is a severe situation that needs to be handled. - throw new ModuleManagementToolException("A Zip error was encountered during deployment of the AEP into the WAR", exception); - } - catch (IOException exception) - { - throw new ModuleManagementToolException("An IO error was encountered during deployment of the AEP into the WAR", exception); - } - } - - private void cleanWAR(String warFileLocation, Properties installedExtensionProperties) - { - // Get the currently installed modifications - Map modifications = readModificationsFromFile(warFileLocation + "/" + getModuleModificationFileName(installedExtensionProperties.getProperty(PROP_ID))); - - for (Map.Entry modification : modifications.entrySet()) - { - String modType = modification.getValue(); - if (MOD_ADD_FILE.equals(modType) == true) - { - // Remove file - } - else if (MOD_UPDATE_FILE.equals(modType) == true) - { - // Remove file - // Replace with back-up - } - else if (MOD_MK_DIR.equals(modType) == true) - { - // Add to list of dir's to remove at the end - } - } - } - - private Map copyToWar(String aepFileLocation, String warFileLocation, String sourceDir, String destinationDir) - throws IOException - { - Map result = new HashMap(10); - - String sourceLocation = aepFileLocation + sourceDir; - File aepConfig = new File(sourceLocation, this.defaultDetector); - - for (java.io.File sourceChild : aepConfig.listFiles()) - { - String destinationFileLocation = warFileLocation + destinationDir + "/" + sourceChild.getName(); - File destinationChild = new File(destinationFileLocation, this.defaultDetector); - if (sourceChild.isFile() == true) - { - boolean createFile = false; - if (destinationChild.exists() == false) - { - destinationChild.createNewFile(); - createFile = true; - } - FileInputStream fis = new FileInputStream(sourceChild); - try - { - destinationChild.catFrom(fis); - } - finally - { - fis.close(); - } - - if (createFile == true) - { - result.put(destinationDir + "/" + sourceChild.getName(), MOD_ADD_FILE); - this.verboseMessage("File added: " + destinationDir + "/" + sourceChild.getName()); - } - else - { - result.put(destinationDir + "/" + sourceChild.getName(), MOD_UPDATE_FILE); - this.verboseMessage("File updated:" + destinationDir + "/" + sourceChild.getName()); - } - } - else - { - boolean mkdir = false; - if (destinationChild.exists() == false) - { - destinationChild.mkdir(); - mkdir = true; - } - - Map subResult = copyToWar(aepFileLocation, warFileLocation, sourceDir + "/" + sourceChild.getName(), - destinationDir + "/" + sourceChild.getName()); - result.putAll(subResult); - - if (mkdir == true) - { - result.put(destinationDir + "/" + sourceChild.getName(), MOD_MK_DIR); - this.verboseMessage("Directory added: " + destinationDir + "/" + sourceChild.getName()); - } - } - } - - return result; - } - - private File getInstalledDir(String warFileLocation) - { - // Check for the installed directory in the WAR file - File installedDir = new File(warFileLocation + MODULE_DIR, this.defaultDetector); - if (installedDir.exists() == false) - { - installedDir.mkdir(); - } - return installedDir; - } - - public void disableModule(String moduleId, String warLocation) - { - System.out.println("Currently unsupported ..."); - } - - public void enableModule(String moduleId, String warLocation) - { - System.out.println("Currently unsupported ..."); - } - - public void uninstallModule(String moduleId, String warLocation) - { - System.out.println("Currently unsupported ..."); - } - - public void listModules(String warLocation) - { - System.out.println("Currently unsupported ..."); - } - - private void verboseMessage(String message) - { - if (this.verbose == true) - { - System.out.println(message); - } - } - - private void writeModificationToFile(String fileLocation, Map modifications) - throws IOException - { - File file = new File(fileLocation, this.defaultDetector); - if (file.exists() == false) - { - file.createNewFile(); - } - FileOutputStream os = new FileOutputStream(file); - try - { - for (Map.Entry mod : modifications.entrySet()) - { - String output = mod.getValue() + DELIMITER + mod.getKey() + "\n"; - os.write(output.getBytes()); - } - } - finally - { - os.close(); - } - } - - private Map readModificationsFromFile(String fileLocation) - { - Map modifications = new HashMap(50); - - File file = new File(fileLocation, this.defaultDetector); - try - { - BufferedReader reader = new BufferedReader(new FileReader(file)); - try - { - String line = reader.readLine(); - while (line != null) - { - line = reader.readLine(); - String[] modification = line.split(DELIMITER); - modifications.put(modification[1], modification[0]); - } - } - finally - { - reader.close(); - } - } - catch(FileNotFoundException exception) - { - throw new ModuleManagementToolException("The module file install file '" + fileLocation + "' does not exist"); - } - catch(IOException exception) - { - throw new ModuleManagementToolException("Error whilst reading file '" + fileLocation); - } - - return modifications; - } - - private String getModuleDetailsFileName(String moduleId) - { - return "module-" + moduleId + ".install"; - } - - private String getModuleModificationFileName(String moduleId) - { - return "module-" + moduleId + "-modifications.install"; - } - - /** - * @param args - */ - public static void main(String[] args) - { - if (args.length >= 1) - { - ModuleManagementTool manager = new ModuleManagementTool(); - - String operation = args[0]; - if (operation.equals(OP_INSTALL) == true && args.length >= 3) - { - String aepFileLocation = args[1]; - String warFileLocation = args[2]; - - manager.installModule(aepFileLocation, warFileLocation); - } - else - { - outputUsage(); - } - } - else - { - outputUsage(); - } - } - - private static void outputUsage() - { - System.out.println("output useage ..."); - } - -} diff --git a/source/java/org/alfresco/repo/module/ModuleManagementToolTest.java b/source/java/org/alfresco/repo/module/ModuleManagementToolTest.java deleted file mode 100644 index f942bfc6f5..0000000000 --- a/source/java/org/alfresco/repo/module/ModuleManagementToolTest.java +++ /dev/null @@ -1,108 +0,0 @@ -/* - * Copyright (C) 2005 Alfresco, Inc. - * - * Licensed under the Mozilla Public License version 1.1 - * with a permitted attribution clause. You may obtain a - * copy of the License at - * - * http://www.alfresco.org/legal/license.txt - * - * 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.alfresco.repo.module; - -import java.io.File; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.util.ArrayList; -import java.util.List; - -import junit.framework.TestCase; - -import org.springframework.util.FileCopyUtils; - -import de.schlichtherle.io.DefaultRaesZipDetector; -import de.schlichtherle.io.FileOutputStream; -import de.schlichtherle.io.ZipDetector; - -/** - * @author Roy Wetherall - */ -public class ModuleManagementToolTest extends TestCase -{ - private ModuleManagementTool manager = new ModuleManagementTool(); - - ZipDetector defaultDetector = new DefaultRaesZipDetector("amp|war"); - - public void testBasicInstall() - throws Exception - { - manager.setVerbose(true); - - String warLocation = getFileLocation(".war", "module/test.war"); - String ampLocation = getFileLocation(".amp", "module/test.amp"); - - System.out.println(warLocation); - - // Initial install of module - this.manager.installModule(ampLocation, warLocation); - - // Check that the war has been modified correctly - List files = new ArrayList(10); - files.add("/WEB-INF/classes/alfresco/module/module-test.install"); - files.add("/WEB-INF/classes/alfresco/module/module-test-modifications.install"); - files.add("/WEB-INF/lib/test.jar"); - files.add("/WEB-INF/classes/alfresco/module/test/module-context.xml"); - files.add("/WEB-INF/classes/alfresco/module/test"); - files.add("/WEB-INF/licenses/license.txt"); - files.add("/scripts/test.js"); - files.add("/images/test.jpg"); - files.add("/jsp/test.jsp"); - files.add("/css/test.css"); - checkForFileExistance(warLocation, files); - - // Try and install same version - try - { - this.manager.installModule(ampLocation, warLocation); - fail("The module is already installed so an exception should have been raised since we are not forcing an overwite"); - } - catch(ModuleManagementToolException exception) - { - // Pass - } - - // Install a later version - // TODO - - // Try and install and earlier version - // TODO - - - } - - private String getFileLocation(String extension, String location) - throws IOException - { - File file = File.createTempFile("moduleManagementToolTest-", extension); - InputStream is = this.getClass().getClassLoader().getResourceAsStream(location); - OutputStream os = new FileOutputStream(file); - FileCopyUtils.copy(is, os); - return file.getPath(); - } - - private void checkForFileExistance(String warLocation, List files) - { - for (String file : files) - { - File file0 = new de.schlichtherle.io.File(warLocation + file, this.defaultDetector); - assertTrue("The file/dir " + file + " does not exist in the WAR.", file0.exists()); - } - } -} diff --git a/source/java/org/alfresco/repo/module/ModuleServiceImpl.java b/source/java/org/alfresco/repo/module/ModuleServiceImpl.java new file mode 100644 index 0000000000..c41b32cfcb --- /dev/null +++ b/source/java/org/alfresco/repo/module/ModuleServiceImpl.java @@ -0,0 +1,192 @@ +/* + * Copyright (C) 2007 Alfresco, Inc. + * + * Licensed under the Mozilla Public License version 1.1 + * with a permitted attribution clause. You may obtain a + * copy of the License at + * + * http://www.alfresco.org/legal/license.txt + * + * 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.alfresco.repo.module; + +import java.io.BufferedInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.alfresco.error.AlfrescoRuntimeException; +import org.alfresco.repo.admin.registry.RegistryService; +import org.alfresco.repo.security.authentication.AuthenticationComponent; +import org.alfresco.service.ServiceRegistry; +import org.alfresco.service.cmr.module.ModuleDetails; +import org.alfresco.service.cmr.module.ModuleService; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; + +/** + * This component controls the execution of + * {@link org.alfresco.repo.module.runtime.ModuleComponent module startup components}. + *

    + * All required startup executions are performed in a single transaction, so this + * component guarantees that the module initialization is consistent. Module components are + * executed in dependency order only. The version numbering is not to be used + * for ordering purposes. + *

    + * Afterwards, execution details are persisted in the + * {@link org.alfresco.repo.admin.registry.RegistryService service registry} to be used when the + * server starts up again. + * + * @author Roy Wetherall + * @author Derek Hulley + * @since 2.0 + */ +public class ModuleServiceImpl implements ModuleService +{ + /** Error messages **/ + private static final String ERR_UNABLE_TO_OPEN_MODULE_PROPETIES = "module.err.unable_to_open_module_properties"; + + /** The classpath search path for module properties */ + private static final String MODULE_CONFIG_SEARCH_ALL = "classpath*:alfresco/module/*/module.properties"; + + private static Log logger = LogFactory.getLog(ModuleServiceImpl.class); + + private ServiceRegistry serviceRegistry; + private AuthenticationComponent authenticationComponent; + private ModuleComponentHelper moduleComponentHelper; + /** A cache of module details by module ID */ + private Map moduleDetailsById; + + /** Default constructor */ + public ModuleServiceImpl() + { + moduleComponentHelper = new ModuleComponentHelper(); + moduleComponentHelper.setModuleService(this); + } + + public void setServiceRegistry(ServiceRegistry serviceRegistry) + { + this.serviceRegistry = serviceRegistry; + this.moduleComponentHelper.setServiceRegistry(this.serviceRegistry); + } + + /** + * @param authenticationComponent allows execution as system user. + */ + public void setAuthenticationComponent(AuthenticationComponent authenticationComponent) + { + this.authenticationComponent = authenticationComponent; + this.moduleComponentHelper.setAuthenticationComponent(this.authenticationComponent); + } + + /** + * @param registryService the service used to persist component execution details. + */ + public void setRegistryService(RegistryService registryService) + { + this.moduleComponentHelper.setRegistryService(registryService); + } + + /** + * @see ModuleComponentHelper#registerComponent(ModuleComponent) + */ + public void registerComponent(ModuleComponent component) + { + this.moduleComponentHelper.registerComponent(component); + } + + /** + * @inheritDoc + * + * @see ModuleComponentHelper#startModules() + */ + public void startModules() + { + moduleComponentHelper.startModules(); + } + + /** + * @inheritDoc + */ + public ModuleDetails getModule(String moduleId) + { + cacheModuleDetails(); + // Get the details of the specific module + ModuleDetails details = moduleDetailsById.get(moduleId); + // Done + return details; + } + + /** + * @inheritDoc + */ + public List getAllModules() + { + cacheModuleDetails(); + Collection moduleDetails = moduleDetailsById.values(); + // Make a copy to avoid modification of cached data by clients (and to satisfy API) + List result = new ArrayList(moduleDetails); + // Done + return result; + } + + /** + * Ensure that the {@link #moduleDetailsById module details} are populated. + *

    + * TODO: We will have to avoid caching or add context listening if we support reloading + * of beans one day. + */ + private synchronized void cacheModuleDetails() + { + if (moduleDetailsById != null) + { + // There is nothing to do + return; + } + try + { + moduleDetailsById = new HashMap(13); + + PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(); + Resource[] resources = resolver.getResources(MODULE_CONFIG_SEARCH_ALL); + + // Read each resource + for (Resource resource : resources) + { + try + { + InputStream is = new BufferedInputStream(resource.getInputStream()); + ModuleDetails details = new ModuleDetailsImpl(is); + moduleDetailsById.put(details.getId(), details); + } + catch (Throwable e) + { + throw AlfrescoRuntimeException.create(e, ERR_UNABLE_TO_OPEN_MODULE_PROPETIES, resource); + } + } + } + catch (IOException e) + { + throw new AlfrescoRuntimeException("Failed to retrieve module information", e); + } + // Done + if (logger.isDebugEnabled()) + { + logger.debug( + "Found " + moduleDetailsById.size() + " modules: \n" + + " Modules: " + moduleDetailsById); + } + } +} diff --git a/source/java/org/alfresco/repo/module/ModuleStarter.java b/source/java/org/alfresco/repo/module/ModuleStarter.java new file mode 100644 index 0000000000..78ef38e1c1 --- /dev/null +++ b/source/java/org/alfresco/repo/module/ModuleStarter.java @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2007 Alfresco, Inc. + * + * Licensed under the Mozilla Public License version 1.1 + * with a permitted attribution clause. You may obtain a + * copy of the License at + * + * http://www.alfresco.org/legal/license.txt + * + * 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.alfresco.repo.module; + +import org.alfresco.service.cmr.module.ModuleService; +import org.alfresco.util.AbstractLifecycleBean; +import org.alfresco.util.PropertyCheck; +import org.springframework.context.ApplicationEvent; + +/** + * This component is responsible for ensuring that patches are applied + * at the appropriate time. + * + * @author Derek Hulley + */ +public class ModuleStarter extends AbstractLifecycleBean +{ + private ModuleService moduleService; + + /** + * @param moduleService the service that will do the actual work. + */ + public void setModuleService(ModuleService moduleService) + { + this.moduleService = moduleService; + } + + @Override + protected void onBootstrap(ApplicationEvent event) + { + PropertyCheck.mandatory(this, "moduleService", moduleService); + moduleService.startModules(); + } + + @Override + protected void onShutdown(ApplicationEvent event) + { + // NOOP + } +} diff --git a/source/java/org/alfresco/repo/module/tool/InstalledFiles.java b/source/java/org/alfresco/repo/module/tool/InstalledFiles.java new file mode 100644 index 0000000000..43c52210c2 --- /dev/null +++ b/source/java/org/alfresco/repo/module/tool/InstalledFiles.java @@ -0,0 +1,217 @@ +package org.alfresco.repo.module.tool; + +import java.io.BufferedReader; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStreamReader; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import de.schlichtherle.io.File; +import de.schlichtherle.io.FileInputStream; +import de.schlichtherle.io.FileOutputStream; + +/** + * Details of the files installed during a module installation into a WAR + * + * @author Roy Wetherall + */ +public class InstalledFiles +{ + /** Modification types */ + private static final String MOD_ADD_FILE = "add"; + private static final String MOD_UPDATE_FILE = "update"; + private static final String MOD_MK_DIR = "mkdir"; + + /** Delimieter used in the file */ + private static final String DELIMITER = "|"; + + /** War location **/ + private String warLocation; + + /** Module id **/ + private String moduleId; + + /** Lists containing the modifications made */ + private List adds = new ArrayList(); + private Map updates = new HashMap(); + private List mkdirs = new ArrayList(); + + /** + * Constructor + * + * @param warLocation the war location + * @param moduleId the module id + */ + public InstalledFiles(String warLocation, String moduleId) + { + this.warLocation = warLocation; + this.moduleId = moduleId; + } + + /** + * Loads the exisiting information about the installed files from the WAR + */ + public void load() + { + File file = new File(getFileLocation(), ModuleManagementTool.defaultDetector); + if (file.exists() == true) + { + try + { + BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream(file))); + try + { + String line = reader.readLine(); + while (line != null) + { + String[] modification = line.split("\\" + DELIMITER); + String mod = modification[0]; + String location = modification[1]; + if (mod.equals(MOD_ADD_FILE) == true) + { + this.adds.add(location); + } + else if (mod.equals(MOD_MK_DIR) == true) + { + this.mkdirs.add(location); + } + else if (mod.equals(MOD_UPDATE_FILE) == true) + { + this.updates.put(location, modification[2]); + } + line = reader.readLine(); + } + } + finally + { + reader.close(); + } + } + catch(FileNotFoundException exception) + { + throw new ModuleManagementToolException("The module file install file '" + getFileLocation() + "' does not exist", exception); + } + catch(IOException exception) + { + throw new ModuleManagementToolException("Error whilst reading file '" + getFileLocation(), exception); + } + } + } + + /** + * Saves the current modification details into the WAR + */ + public void save() + { + try + { + File file = new File(getFileLocation(), ModuleManagementTool.defaultDetector); + if (file.exists() == false) + { + file.createNewFile(); + } + FileOutputStream os = new FileOutputStream(file); + try + { + for (String add : this.adds) + { + String output = MOD_ADD_FILE + DELIMITER + add + "\n"; + os.write(output.getBytes()); + } + for (Map.Entry update : this.updates.entrySet()) + { + String output = MOD_UPDATE_FILE + DELIMITER + update.getKey() + DELIMITER + update.getValue() + "\n"; + os.write(output.getBytes()); + } + for (String mkdir : this.mkdirs) + { + String output = MOD_MK_DIR + DELIMITER + mkdir + "\n"; + os.write(output.getBytes()); + } + } + finally + { + os.close(); + } + } + catch(IOException exception) + { + throw new ModuleManagementToolException("Error whilst saving modifications file.", exception); + } + } + + /** + * Returns the location of the modifications file based on the module id + * + * @return the file location + */ + private String getFileLocation() + { + return this.warLocation + ModuleManagementTool.MODULE_DIR + "/" + this.moduleId + "/modifications.install"; + } + + /** + * Get all the added files + * + * @return list of files added to war + */ + public List getAdds() + { + return adds; + } + + /** + * Get all the updated files, key is the file that has been updated and the value is the + * location of the backup made before modification took place. + * + * @return map of file locaiton and backup + */ + public Map getUpdates() + { + return updates; + } + + /** + * Gets a list of the dirs added during install + * + * @return list of directories added + */ + public List getMkdirs() + { + return mkdirs; + } + + /** + * Add a file addition + * + * @param location the file added + */ + public void addAdd(String location) + { + this.adds.add(location); + } + + /** + * Add a file update + * + * @param location the file updated + * @param backup the backup location + */ + public void addUpdate(String location, String backup) + { + this.updates.put(location, backup); + } + + /** + * Add a directory + * + * @param location the directory location + */ + public void addMkdir(String location) + { + this.mkdirs.add(location); + } +} diff --git a/source/java/org/alfresco/repo/module/tool/ModuleDetailsHelper.java b/source/java/org/alfresco/repo/module/tool/ModuleDetailsHelper.java new file mode 100644 index 0000000000..352e94e92a --- /dev/null +++ b/source/java/org/alfresco/repo/module/tool/ModuleDetailsHelper.java @@ -0,0 +1,129 @@ +/* + * Copyright (C) 2005 Alfresco, Inc. + * + * Licensed under the Mozilla Public License version 1.1 + * with a permitted attribution clause. You may obtain a + * copy of the License at + * + * http://www.alfresco.org/legal/license.txt + * + * 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.alfresco.repo.module.tool; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Date; + +import org.alfresco.repo.module.ModuleDetailsImpl; + +import de.schlichtherle.io.File; +import de.schlichtherle.io.FileInputStream; +import de.schlichtherle.io.FileOutputStream; + +/** + * Module details helper used by the module mangement tool + * + * @author Roy Wetherall + */ +public class ModuleDetailsHelper extends ModuleDetailsImpl +{ + /** + * Constructor + * + * @param is input stream + */ + public ModuleDetailsHelper(InputStream is) + { + super(is); + } + + /** + * Creates a module details helper object based on a file location. + * + * @param location file location + * @return module details helper object + */ + public static ModuleDetailsHelper create(String location) + { + ModuleDetailsHelper result = null; + try + { + File file = new File(location, ModuleManagementTool.defaultDetector); + if (file.exists() == true) + { + result = new ModuleDetailsHelper(new FileInputStream(file)); + } + } + catch (IOException exception) + { + throw new ModuleManagementToolException("Unable to load module details from property file.", exception); + } + return result; + } + + /** + * Creates a module details helper object based on a war location and the module id + * + * @param warLocation the war location + * @param moduleId the module id + * @return the module details helper + */ + public static ModuleDetailsHelper create(String warLocation, String moduleId) + { + return ModuleDetailsHelper.create(ModuleDetailsHelper.getFileLocation(warLocation, moduleId)); + } + + /** + * Gets the file location + * + * @param warLocation the war location + * @param moduleId the module id + * @return the file location + */ + private static String getFileLocation(String warLocation, String moduleId) + { + return warLocation + ModuleManagementTool.MODULE_DIR + "/" + moduleId + "/" + "module.properties"; + } + + /** + * Saves the module detailsin to the war in the correct location based on the module id + * + * @param warLocation the war location + * @param moduleId the module id + */ + public void save(String warLocation, String moduleId) + { + try + { + File file = new File(getFileLocation(warLocation, moduleId), ModuleManagementTool.defaultDetector); + if (file.exists() == false) + { + file.createNewFile(); + } + + OutputStream os = new FileOutputStream(file); + try + { + Date now = new Date(); + this.properties.setProperty(PROP_INSTALL_DATE, now.toString()); + this.properties.store(os, null); + } + finally + { + os.close(); + } + } + catch (IOException exception) + { + throw new ModuleManagementToolException("Unable to save module details into WAR file.", exception); + } + } + +} diff --git a/source/java/org/alfresco/repo/module/tool/ModuleManagementTool.java b/source/java/org/alfresco/repo/module/tool/ModuleManagementTool.java new file mode 100644 index 0000000000..5a001310f2 --- /dev/null +++ b/source/java/org/alfresco/repo/module/tool/ModuleManagementTool.java @@ -0,0 +1,598 @@ +/* + * Copyright (C) 2005 Alfresco, Inc. + * + * Licensed under the Mozilla Public License version 1.1 + * with a permitted attribution clause. You may obtain a + * copy of the License at + * + * http://www.alfresco.org/legal/license.txt + * + * 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.alfresco.repo.module.tool; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.util.Map; +import java.util.Properties; + +import org.alfresco.util.GUID; +import org.apache.log4j.Logger; +import org.springframework.util.FileCopyUtils; + +import de.schlichtherle.io.DefaultRaesZipDetector; +import de.schlichtherle.io.File; +import de.schlichtherle.io.FileInputStream; +import de.schlichtherle.io.ZipControllerException; +import de.schlichtherle.io.ZipDetector; +import de.schlichtherle.io.ZipWarningException; + +/** + * Module management tool. + * + * Manages the modules installed in a war file. Allows modules to be installed, updated, enabled, disabled and + * uninstalled. Information about the module installed is also available. + * + * @author Roy Wetherall + */ +public class ModuleManagementTool +{ + /** Logger */ + public static Logger logger = Logger.getLogger("org.alfresco.repo.extension.ModuleManagementTool"); + + /** Location of the default mapping properties file */ + private static final String DEFAULT_FILE_MAPPING_PROPERTIES = "org/alfresco/repo/module/tool/default-file-mapping.properties"; + + /** Standard directories found in the alfresco war */ + public static final String MODULE_DIR = "/WEB-INF/classes/alfresco/module"; + public static final String BACKUP_DIR = MODULE_DIR + "/backup"; + + /** Operations and options supperted via the command line interface to this class */ + private static final String OP_INSTALL = "install"; + private static final String OP_LIST = "list"; + private static final String OPTION_VERBOSE = "-verbose"; + private static final String OPTION_FORCE = "-force"; + private static final String OPTION_PREVIEW = "-preview"; + private static final String OPTION_NOBACKUP = "-nobackup"; + + /** Default zip detector */ + public static ZipDetector defaultDetector = new DefaultRaesZipDetector("amp|war"); + + /** File mapping properties */ + private Properties fileMappingProperties; + + /** Indicates the current verbose setting */ + private boolean verbose = false; + + /** + * Constructor + */ + public ModuleManagementTool() + { + // Load the default file mapping properties + this.fileMappingProperties = new Properties(); + InputStream is = this.getClass().getClassLoader().getResourceAsStream(DEFAULT_FILE_MAPPING_PROPERTIES); + try + { + this.fileMappingProperties.load(is); + } + catch (IOException exception) + { + throw new ModuleManagementToolException("Unable to load default extension file mapping properties.", exception); + } + } + + /** + * Indicates whether the management tool is currently in verbose reporting mode. + * + * @return true if verbose, false otherwise + */ + public boolean isVerbose() + { + return verbose; + } + + /** + * Sets the verbose setting for the mangement tool + * + * @param verbose true if verbose, false otherwise + */ + public void setVerbose(boolean verbose) + { + this.verbose = verbose; + } + + /** + * Installs a given AMP file into a given WAR file. + * + * @see ModuleManagementTool.installModule(String, String, boolean, boolean, boolean) + * + * @param ampFileLocation the location of the AMP file to be installed + * @param warFileLocation the location of the WAR file into which the AMP file is to be installed + */ + public void installModule(String ampFileLocation, String warFileLocation) + { + installModule(ampFileLocation, warFileLocation, false, false, true); + } + + /** + * Installs a given AMP file into a given WAR file. + * + * @param ampFileLocation the location of the AMP file to be installed + * @param warFileLocation the location of the WAR file into which the AMP file is to be installed. + * @param preview indicates whether this should be a preview install. This means that the process of + * installation will be followed and reported, but the WAR file will not be modified. + * @param forceInstall indicates whether the installed files will be replaces reguarless of the currently installed + * version of the AMP. Generally used during development of the AMP. + * @param backupWAR indicates whether we should backup the war we are modifying or not + */ + public void installModule(String ampFileLocation, String warFileLocation, boolean preview, boolean forceInstall, boolean backupWAR) + { + try + { + if (preview == false) + { + // Make sure the module and backup directory exisits in the WAR file + File moduleDir = new File(warFileLocation + MODULE_DIR, defaultDetector); + if (moduleDir.exists() == false) + { + moduleDir.mkdir(); + } + File backUpDir = new File(warFileLocation + BACKUP_DIR, defaultDetector); + if (backUpDir.exists() == false) + { + backUpDir.mkdir(); + } + + // Make a backup of the war we are oging to modify + if (backupWAR == true) + { + java.io.File warFile = new java.io.File(warFileLocation); + if (warFile.exists() == false) + { + throw new ModuleManagementToolException("The war file '" + warFileLocation + "' does not exist."); + } + String backupLocation = warFileLocation + "-" + System.currentTimeMillis() + ".bak"; + java.io.File backup = new java.io.File(backupLocation); + FileCopyUtils.copy(warFile, backup); + + outputMessage("WAR has been backed up to '" + backupLocation + "'"); + } + } + + // Get the details of the installing module + ModuleDetailsHelper installingModuleDetails = ModuleDetailsHelper.create(ampFileLocation + "/module.properties"); + if (installingModuleDetails.exists() == false) + { + throw new ModuleManagementToolException("No module.properties file has been found in the installing .amp file '" + ampFileLocation + "'"); + } + + // Get the detail of the installed module + ModuleDetailsHelper installedModuleDetails = ModuleDetailsHelper.create(warFileLocation, installingModuleDetails.getId()); + if (installedModuleDetails != null) + { + int compareValue = installedModuleDetails.getVersionNumber().compareTo(installingModuleDetails.getVersionNumber()); + if (forceInstall == true || compareValue == -1) + { + if (forceInstall == true) + { + // Warn of forced install + outputMessage("WARNING: The installation of this module is being forced. All files will be removed and replaced reguarless of exiting versions present."); + } + + // Trying to update the extension, old files need to cleaned before we proceed + outputMessage("Clearing out files relating to version '" + installedModuleDetails.getVersionNumber().toString() + "' of module '" + installedModuleDetails.getId() + "'"); + cleanWAR(warFileLocation, installedModuleDetails.getId(), preview); + } + else if (compareValue == 0) + { + // Trying to install the same extension version again + outputMessage("WARNING: This version of this module is already installed in the WAR"); + throw new ModuleManagementToolException("This version of this module is alreay installed. Use the 'force' parameter if you want to overwrite the current installation."); + } + else if (compareValue == 1) + { + // Trying to install an earlier version of the extension + outputMessage("WARNING: A later version of this module is already installed in the WAR"); + throw new ModuleManagementToolException("An earlier version of this module is already installed. You must first unistall the current version before installing this version of the module."); + } + + } + + // TODO check for any additional file mapping propeties supplied in the AEP file + + // Copy the files from the AEP file into the WAR file + outputMessage("Adding files relating to version '" + installingModuleDetails.getVersionNumber().toString() + "' of module '" + installingModuleDetails.getId() + "'"); + InstalledFiles installedFiles = new InstalledFiles(warFileLocation, installingModuleDetails.getId()); + for (Map.Entry entry : this.fileMappingProperties.entrySet()) + { + // Run throught the files one by one figuring out what we are going to do during the copy + copyToWar(ampFileLocation, warFileLocation, (String)entry.getKey(), (String)entry.getValue(), installedFiles, preview); + + if (preview == false) + { + // Get a reference to the source folder (if it isn't present dont do anything + File source = new File(ampFileLocation + "/" + entry.getKey(), defaultDetector); + if (source != null && source.list() != null) + { + // Get a reference to the destination folder + File destination = new File(warFileLocation + "/" + entry.getValue(), defaultDetector); + if (destination == null) + { + throw new ModuleManagementToolException("The destination folder '" + entry.getValue() + "' as specified in mapping properties does not exist in the war"); + } + // Do the bulk copy since this is quicker than copying file's one by one + destination.copyAllFrom(source); + } + } + } + + if (preview == false) + { + // Save the installed file list + installedFiles.save(); + + // Update the installed module details + installingModuleDetails.save(warFileLocation, installingModuleDetails.getId()); + + // Update the zip file's + File.update(); + } + } + catch (ZipWarningException ignore) + { + // Only instances of the class ZipWarningException exist in the chain of + // exceptions. We choose to ignore this. + } + catch (ZipControllerException exception) + { + // At least one exception occured which is not just a ZipWarningException. + // This is a severe situation that needs to be handled. + throw new ModuleManagementToolException("A Zip error was encountered during deployment of the AEP into the WAR", exception); + } + catch (IOException exception) + { + throw new ModuleManagementToolException("An IO error was encountered during deployment of the AEP into the WAR", exception); + } + } + + /** + * Cleans the WAR file of all files relating to the currently installed version of the the AMP. + * + * @param warFileLocatio the war file location + * @param moduleId the module id + * @param preview indicates whether this is a preview installation + */ + private void cleanWAR(String warFileLocation, String moduleId, boolean preview) + { + InstalledFiles installedFiles = new InstalledFiles(warFileLocation, moduleId); + installedFiles.load(); + + for (String add : installedFiles.getAdds()) + { + // Remove file + removeFile(warFileLocation, add, preview); + } + for (String mkdir : installedFiles.getMkdirs()) + { + // Remove folder + removeFile(warFileLocation, mkdir, preview); + } + for (Map.Entry update : installedFiles.getUpdates().entrySet()) + { + if (preview == false) + { + // Recover updated file and delete backups + File modified = new File(warFileLocation + update.getKey(), defaultDetector); + File backup = new File(warFileLocation + update.getValue(), defaultDetector); + modified.copyFrom(backup); + backup.delete(); + } + + outputMessage("Recovering file '" + update.getKey() + "' from backup '" + update.getValue() + "'", true); + } + } + + /** + * Removes a file from the given location in the war file. + * + * @param warLocation the war file location + * @param filePath the path to the file that is to be deleted + * @param preview indicates whether this is a preview install + */ + private void removeFile(String warLocation, String filePath, boolean preview) + { + File removeFile = new File(warLocation + filePath, defaultDetector); + if (removeFile.exists() == true) + { + outputMessage("Removing file '" + filePath + "' from war", true); + if (preview == false) + { + removeFile.delete(); + } + } + else + { + outputMessage("The file '" + filePath + "' was expected for removal but was not present in the war", true); + } + } + + /** + * Copies a file from the AMP location to the correct location in the WAR, interating on directories where appropraite. + * + * @param ampFileLocation the AMP file location + * @param warFileLocation the WAR file location + * @param sourceDir the directory in the AMP to copy from + * @param destinationDir the directory in the WAR to copy to + * @param installedFiles a list of the currently installed files + * @param preview indicates whether this is a preview install or not + * @throws IOException throws any IOExpceptions thar are raised + */ + private void copyToWar(String ampFileLocation, String warFileLocation, String sourceDir, String destinationDir, InstalledFiles installedFiles, boolean preview) + throws IOException + { + String sourceLocation = ampFileLocation + sourceDir; + File ampConfig = new File(sourceLocation, defaultDetector); + + java.io.File[] files = ampConfig.listFiles(); + if (files != null) + { + for (java.io.File sourceChild : files) + { + String destinationFileLocation = warFileLocation + destinationDir + "/" + sourceChild.getName(); + File destinationChild = new File(destinationFileLocation, defaultDetector); + if (sourceChild.isFile() == true) + { + String backupLocation = null; + boolean createFile = false; + if (destinationChild.exists() == false) + { + createFile = true; + } + else + { + // Backup file about to be updated + backupLocation = BACKUP_DIR + "/" + GUID.generate() + ".bin"; + if (preview == false) + { + File backupFile = new File(warFileLocation + backupLocation, defaultDetector); + backupFile.copyFrom(destinationChild); + } + } + + if (createFile == true) + { + installedFiles.addAdd(destinationDir + "/" + sourceChild.getName()); + this.outputMessage("File '" + destinationDir + "/" + sourceChild.getName() + "' added to war from amp", true); + } + else + { + installedFiles.addUpdate(destinationDir + "/" + sourceChild.getName(), backupLocation); + this.outputMessage("WARNING: The file '" + destinationDir + "/" + sourceChild.getName() + "' is being updated by this module and has been backed-up to '" + backupLocation + "'", true); + } + } + else + { + boolean mkdir = false; + if (destinationChild.exists() == false) + { + mkdir = true; + } + + copyToWar(ampFileLocation, warFileLocation, sourceDir + "/" + sourceChild.getName(), + destinationDir + "/" + sourceChild.getName(), installedFiles, preview); + if (mkdir == true) + { + installedFiles.addMkdir(destinationDir + "/" + sourceChild.getName()); + this.outputMessage("Directory '" + destinationDir + "/" + sourceChild.getName() + "' added to war", true); + } + } + } + } + } + + /** + * @throws UnsupportedOperationException + */ + public void disableModule(String moduleId, String warLocation) + { + throw new UnsupportedOperationException("Disable module is not currently supported"); + } + + /** + * @throws UnsupportedOperationException + */ + public void enableModule(String moduleId, String warLocation) + { + throw new UnsupportedOperationException("Enable module is not currently supported"); + } + + /** + * @throws UnsupportedOperationException + */ + public void uninstallModule(String moduleId, String warLocation) + { + throw new UnsupportedOperationException("Uninstall module is not currently supported"); + } + + /** + * Lists all the currently installed modules in the WAR + * + * @param warLocation the war location + */ + public void listModules(String warLocation) + { + ModuleDetailsHelper moduleDetails = null; + boolean previous = this.verbose; + this.verbose = true; + try + { + File moduleDir = new File(warLocation + MODULE_DIR, defaultDetector); + if (moduleDir.exists() == false) + { + outputMessage("No modules are installed in this WAR file"); + } + + java.io.File[] dirs = moduleDir.listFiles(); + if (dirs != null && dirs.length != 0) + { + for (java.io.File dir : dirs) + { + if (dir.isDirectory() == true) + { + File moduleProperties = new File(dir.getPath() + "/module.properties", defaultDetector); + if (moduleProperties.exists() == true) + { + try + { + moduleDetails = new ModuleDetailsHelper(new FileInputStream(moduleProperties)); + } + catch (FileNotFoundException exception) + { + throw new ModuleManagementToolException("Unable to open module properties file '" + moduleProperties.getPath() + "'"); + } + + outputMessage("Module '" + moduleDetails.getId() + "' installed in '" + warLocation + "'"); + outputMessage("Title: " + moduleDetails.getTitle(), true); + outputMessage("Version: " + moduleDetails.getVersionNumber(), true); + outputMessage("Install Date: " + moduleDetails.getInstalledDate(), true); + outputMessage("Desription: " + moduleDetails.getDescription(), true); + } + } + } + } + else + { + outputMessage("No modules are installed in this WAR file"); + } + } + finally + { + this.verbose = previous; + } + } + + /** + * Outputs a message the console (in verbose mode) and the logger. + * + * @param message the message to output + */ + private void outputMessage(String message) + { + outputMessage(message, false); + } + + /** + * Outputs a message the console (in verbose mode) and the logger. + * + * @param message the message to output + * @prarm indent indicates that the message should be formated with an indent + */ + private void outputMessage(String message, boolean indent) + { + if (indent == true) + { + message = " - " + message; + } + if (this.verbose == true) + { + System.out.println(message); + } + if (logger.isDebugEnabled() == true) + { + logger.debug(message); + } + } + + /** + * Main + * + * @param args command line interface arguments + */ + public static void main(String[] args) + { + if (args.length >= 1) + { + ModuleManagementTool manager = new ModuleManagementTool(); + + String operation = args[0]; + if (operation.equals(OP_INSTALL) == true && args.length >= 3) + { + String aepFileLocation = args[1]; + String warFileLocation = args[2]; + boolean forceInstall = false; + boolean previewInstall = false; + boolean backup = true; + + if (args.length > 3) + { + for (int i = 3; i < args.length; i++) + { + String option = args[i]; + if (OPTION_VERBOSE.equals(option) == true) + { + manager.setVerbose(true); + } + else if (OPTION_FORCE.equals(option) == true) + { + forceInstall = true; + } + else if (OPTION_PREVIEW.equals(option) == true) + { + previewInstall = true; + } + else if (OPTION_NOBACKUP.equals(option) == true) + { + backup = false; + } + } + } + + // Install the module + manager.installModule(aepFileLocation, warFileLocation, previewInstall, forceInstall, backup); + } + else if (OP_LIST.equals(operation) == true && args.length == 2) + { + // List the installed modules + String warFileLocation = args[1]; + manager.listModules(warFileLocation); + } + else + { + outputUsage(); + } + } + else + { + outputUsage(); + } + } + + /** + * Outputs the module management tool usage + */ + private static void outputUsage() + { + System.out.println("Module managment tool available commands:"); + System.out.println("-----------------------------------------------------------\n"); + System.out.println("install: Installs a AMP file into an Alfresco WAR file, updates if an older version is already installed."); + System.out.println("usage: install AMPFile WARFile options"); + System.out.println("valid options: "); + System.out.println(" -verbose : enable verbose output"); + System.out.println(" -force : forces installation of AMP regardless of currently installed module version"); + System.out.println(" -preview : previews installation of AMP without modifying WAR file"); + System.out.println(" -nobackup : indicates that no backup should be made of the WAR\n"); + System.out.println("-----------------------------------------------------------\n"); + System.out.println("list: Lists all the modules currently installed in an Alfresco WAR file."); + System.out.println("usage: list WARFile\n"); + System.out.println("-----------------------------------------------------------\n"); + } + + +} diff --git a/source/java/org/alfresco/repo/module/ModuleManagementToolException.java b/source/java/org/alfresco/repo/module/tool/ModuleManagementToolException.java similarity index 93% rename from source/java/org/alfresco/repo/module/ModuleManagementToolException.java rename to source/java/org/alfresco/repo/module/tool/ModuleManagementToolException.java index 59edd6b7cb..47453a1020 100644 --- a/source/java/org/alfresco/repo/module/ModuleManagementToolException.java +++ b/source/java/org/alfresco/repo/module/tool/ModuleManagementToolException.java @@ -14,7 +14,7 @@ * language governing permissions and limitations under the * License. */ -package org.alfresco.repo.module; +package org.alfresco.repo.module.tool; import org.alfresco.error.AlfrescoRuntimeException; diff --git a/source/java/org/alfresco/repo/module/tool/ModuleManagementToolTest.java b/source/java/org/alfresco/repo/module/tool/ModuleManagementToolTest.java new file mode 100644 index 0000000000..0d904d1ac6 --- /dev/null +++ b/source/java/org/alfresco/repo/module/tool/ModuleManagementToolTest.java @@ -0,0 +1,235 @@ +/* + * Copyright (C) 2005 Alfresco, Inc. + * + * Licensed under the Mozilla Public License version 1.1 + * with a permitted attribution clause. You may obtain a + * copy of the License at + * + * http://www.alfresco.org/legal/license.txt + * + * 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.alfresco.repo.module.tool; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import junit.framework.TestCase; + +import org.springframework.util.FileCopyUtils; + +import de.schlichtherle.io.DefaultRaesZipDetector; +import de.schlichtherle.io.FileInputStream; +import de.schlichtherle.io.FileOutputStream; +import de.schlichtherle.io.ZipDetector; + +/** + * Module management tool unit test + * + * @author Roy Wetherall + */ +public class ModuleManagementToolTest extends TestCase +{ + private ModuleManagementTool manager = new ModuleManagementTool(); + + ZipDetector defaultDetector = new DefaultRaesZipDetector("amp|war"); + + public void testBasicInstall() + throws Exception + { + manager.setVerbose(true); + + String warLocation = getFileLocation(".war", "module/test.war"); + String ampLocation = getFileLocation(".amp", "module/test.amp"); + String ampV2Location = getFileLocation(".amp", "module/test_v2.amp"); + + System.out.println(warLocation); + + // Initial install of module + this.manager.installModule(ampLocation, warLocation); + + // Check that the war has been modified correctly + List files = new ArrayList(10); + files.add("/WEB-INF/classes/alfresco/module/test/module.properties"); + files.add("/WEB-INF/classes/alfresco/module/test/modifications.install"); + files.add("/WEB-INF/lib/test.jar"); + files.add("/WEB-INF/classes/alfresco/module/test/module-context.xml"); + files.add("/WEB-INF/classes/alfresco/module/test"); + files.add("/WEB-INF/licenses/license.txt"); + files.add("/scripts/test.js"); + files.add("/images/test.jpg"); + files.add("/jsp/test.jsp"); + files.add("/css/test.css"); + checkForFileExistance(warLocation, files); + + // Check the intstalled files + InstalledFiles installed0 = new InstalledFiles(warLocation, "test"); + installed0.load(); + assertNotNull(installed0); + assertEquals(7, installed0.getAdds().size()); + assertEquals(1, installed0.getMkdirs().size()); + assertEquals(1, installed0.getUpdates().size()); + String backup = null; + String orig = null; + for (Map.Entry update : installed0.getUpdates().entrySet()) + { + checkContentsOfFile(warLocation + update.getKey(), "VERSIONONE"); + checkContentsOfFile(warLocation + update.getValue(), "ORIGIONAL"); + backup = update.getValue(); + orig = update.getKey(); + } + + // Try and install same version + try + { + this.manager.installModule(ampLocation, warLocation); + fail("The module is already installed so an exception should have been raised since we are not forcing an overwite"); + } + catch(ModuleManagementToolException exception) + { + // Pass + } + + // Install a later version + this.manager.installModule(ampV2Location, warLocation); + + // Check that the war has been modified correctly + List files2 = new ArrayList(12); + files.add("/WEB-INF/classes/alfresco/module/test/module.properties"); + files.add("/WEB-INF/classes/alfresco/module/test/modifications.install"); + files2.add("/WEB-INF/lib/test.jar"); + files2.add("/WEB-INF/classes/alfresco/module/test/module-context.xml"); + files2.add("/WEB-INF/classes/alfresco/module/test"); + files2.add("/WEB-INF/licenses/license.txt"); + files2.add("/scripts/test2.js"); + files2.add("/scripts/test3.js"); + files2.add("/images/test.jpg"); + files2.add("/css/test.css"); + files2.add("/WEB-INF/classes/alfresco/module/test/version2"); + files2.add("/WEB-INF/classes/alfresco/module/test/version2/version2-context.xml"); + checkForFileExistance(warLocation, files2); + + List files3 = new ArrayList(2); + files3.add("/scripts/test.js"); + files3.add("/jsp/test.jsp"); + files3.add(backup); + checkForFileNonExistance(warLocation, files3); + + // Check the intstalled files + InstalledFiles installed1 = new InstalledFiles(warLocation, "test"); + installed1.load(); + assertNotNull(installed1); + assertEquals(8, installed1.getAdds().size()); + assertEquals(1, installed1.getMkdirs().size()); + assertEquals(0, installed1.getUpdates().size()); + + // Ensure the file has been reverted as it isnt updated in the v2.0 + checkContentsOfFile(warLocation + orig, "ORIGIONAL"); + + // Try and install and earlier version + try + { + this.manager.installModule(ampLocation, warLocation); + fail("An earlier version of this module is already installed so an exception should have been raised since we are not forcing an overwite"); + } + catch(ModuleManagementToolException exception) + { + // Pass + } + } + + public void testPreviewInstall() + throws Exception + { + manager.setVerbose(true); + + String warLocation = getFileLocation(".war", "module/test.war"); + String ampLocation = getFileLocation(".amp", "module/test.amp"); + + System.out.println(warLocation); + + // Initial install of module + this.manager.installModule(ampLocation, warLocation, true, false, true); + + // TODO need to prove that the war file has not been updated in any way + } + + public void testForcedInstall() + throws Exception + { + manager.setVerbose(true); + + String warLocation = getFileLocation(".war", "module/test.war"); + String ampLocation = getFileLocation(".amp", "module/test.amp"); + + System.out.println(warLocation); + + // Initial install of module + this.manager.installModule(ampLocation, warLocation, false, false, false); + this.manager.installModule(ampLocation, warLocation, false, true, false); + } + + public void testList() + throws Exception + { + String warLocation = getFileLocation(".war", "module/test.war"); + String ampLocation = getFileLocation(".amp", "module/test.amp"); + + this.manager.listModules(warLocation); + + this.manager.installModule(ampLocation, warLocation); + + this.manager.listModules(warLocation); + } + + private String getFileLocation(String extension, String location) + throws IOException + { + File file = File.createTempFile("moduleManagementToolTest-", extension); + InputStream is = this.getClass().getClassLoader().getResourceAsStream(location); + OutputStream os = new FileOutputStream(file); + FileCopyUtils.copy(is, os); + return file.getPath(); + } + + private void checkForFileExistance(String warLocation, List files) + { + for (String file : files) + { + File file0 = new de.schlichtherle.io.File(warLocation + file, this.defaultDetector); + assertTrue("The file/dir " + file + " does not exist in the WAR.", file0.exists()); + } + } + + private void checkForFileNonExistance(String warLocation, List files) + { + for (String file : files) + { + File file0 = new de.schlichtherle.io.File(warLocation + file, this.defaultDetector); + assertFalse("The file/dir " + file + " does exist in the WAR.", file0.exists()); + } + } + + private void checkContentsOfFile(String location, String expectedContents) + throws IOException + { + File file = new de.schlichtherle.io.File(location, this.defaultDetector); + assertTrue(file.exists()); + BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream(file))); + String line = reader.readLine(); + assertNotNull(line); + assertEquals(expectedContents, line.trim()); + } +} diff --git a/source/java/org/alfresco/repo/module/default-file-mapping.properties b/source/java/org/alfresco/repo/module/tool/default-file-mapping.properties similarity index 100% rename from source/java/org/alfresco/repo/module/default-file-mapping.properties rename to source/java/org/alfresco/repo/module/tool/default-file-mapping.properties diff --git a/source/java/org/alfresco/service/cmr/module/ModuleDetails.java b/source/java/org/alfresco/service/cmr/module/ModuleDetails.java index 363c55c8b0..6afb0609e3 100644 --- a/source/java/org/alfresco/service/cmr/module/ModuleDetails.java +++ b/source/java/org/alfresco/service/cmr/module/ModuleDetails.java @@ -19,42 +19,52 @@ package org.alfresco.service.cmr.module; import org.alfresco.util.VersionNumber; /** - * Module details, contains the details of an installed alfresco - * module. + * Module details, contains the details of an installed alfresco module. * * @author Roy Wetherall + * @since 2.0 */ -public class ModuleDetails +public interface ModuleDetails { - private String id; - private VersionNumber version; - private String title; - private String description; + /** + * Indicates whether the details exists or not + * + * @return true if it exists, false otherwise + */ + boolean exists(); - public ModuleDetails(String id, VersionNumber version, String title, String description) - { - this.id = id; - this.version = version; - this.title = title; - } + /** + * Get the id of the module + * + * @return module id + */ + String getId(); - public String getId() - { - return id; - } + /** + * Get the version number of the module + * + * @return module version number + */ + VersionNumber getVersionNumber(); - public VersionNumber getVersion() - { - return version; - } + /** + * Get the title of the module + * + * @return module title + */ + String getTitle(); - public String getTitle() - { - return title; - } + /** + * Get the description of the module + * + * @return module description + */ + String getDescription(); - public String getDescription() - { - return description; - } + /** + * Get the modules install date + * + * @return module install date + */ + String getInstalledDate(); } diff --git a/source/java/org/alfresco/service/cmr/module/ModuleService.java b/source/java/org/alfresco/service/cmr/module/ModuleService.java index c7d3b177dc..153aa8f1c5 100644 --- a/source/java/org/alfresco/service/cmr/module/ModuleService.java +++ b/source/java/org/alfresco/service/cmr/module/ModuleService.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2005 Alfresco, Inc. + * Copyright (C) 2007 Alfresco, Inc. * * Licensed under the Mozilla Public License version 1.1 * with a permitted attribution clause. You may obtain a @@ -18,15 +18,45 @@ package org.alfresco.service.cmr.module; import java.util.List; +import org.alfresco.repo.module.ModuleComponent; + /** - * Module service. Provides information about the currently installed alfresco - * modules. + * A service to control and provide information about the currently-installed modules. * * @author Roy Wetherall + * @author Derek Hulley + * @since 2.0 */ public interface ModuleService { - public ModuleDetails getModule(String moduleId); + /** + * Gets the module details for a given module id. If the module does not exist or is not installed + * then null is returned. + * + * @param moduleId a module id + * @return the module details + */ + ModuleDetails getModule(String moduleId); - public List getAllModules(); + /** + * Gets a list of all the modules currently installed. + * + * @return module details of the currently installed modules. + */ + List getAllModules(); + + /** + * Register a component of a module for execution. + * + * @param component the module component. + */ + void registerComponent(ModuleComponent component); + + /** + * Start all the modules. For transaction purposes, each module should be + * regarded as a self-contained unit and started in its own transaction. + * Where inter-module dependencies exist, these will be pulled into the + * transaction. + */ + void startModules(); } diff --git a/source/java/org/alfresco/service/cmr/repository/datatype/DefaultTypeConverter.java b/source/java/org/alfresco/service/cmr/repository/datatype/DefaultTypeConverter.java index 09e97e7466..664870247d 100644 --- a/source/java/org/alfresco/service/cmr/repository/datatype/DefaultTypeConverter.java +++ b/source/java/org/alfresco/service/cmr/repository/datatype/DefaultTypeConverter.java @@ -42,6 +42,7 @@ import org.alfresco.service.cmr.repository.NodeRef; import org.alfresco.service.cmr.repository.Path; import org.alfresco.service.namespace.QName; import org.alfresco.util.ISO8601DateFormat; +import org.alfresco.util.VersionNumber; /** * Support for generic conversion between types. @@ -278,6 +279,14 @@ public class DefaultTypeConverter } }); + INSTANCE.addConverter(String.class, VersionNumber.class, new TypeConverter.Converter() + { + public VersionNumber convert(String source) + { + return new VersionNumber(source); + } + }); + // // From Locale @@ -296,7 +305,20 @@ public class DefaultTypeConverter } }); + + // + // From VersionNumber + // + INSTANCE.addConverter(VersionNumber.class, String.class, new TypeConverter.Converter() + { + public String convert(VersionNumber source) + { + return source.toString(); + } + }); + + // // From MLText // diff --git a/source/java/org/alfresco/service/cmr/repository/datatype/DefaultTypeConverterTest.java b/source/java/org/alfresco/service/cmr/repository/datatype/DefaultTypeConverterTest.java index 4b3dbad33f..39a06c1bab 100644 --- a/source/java/org/alfresco/service/cmr/repository/datatype/DefaultTypeConverterTest.java +++ b/source/java/org/alfresco/service/cmr/repository/datatype/DefaultTypeConverterTest.java @@ -28,6 +28,7 @@ import junit.framework.TestCase; import org.alfresco.service.cmr.repository.MLText; import org.alfresco.util.ISO8601DateFormat; +import org.alfresco.util.VersionNumber; public class DefaultTypeConverterTest extends TestCase { @@ -98,6 +99,8 @@ public class DefaultTypeConverterTest extends TestCase assertEquals("woof", DefaultTypeConverter.INSTANCE.convert(String.class, mlText)); // Locale assertEquals("fr_FR_", DefaultTypeConverter.INSTANCE.convert(String.class, Locale.FRANCE)); + // VersionNumber + assertEquals("1.2.3", DefaultTypeConverter.INSTANCE.convert(String.class, new VersionNumber("1.2.3"))); } public void testFromString() @@ -131,6 +134,8 @@ public class DefaultTypeConverterTest extends TestCase assertEquals(Locale.FRANCE, DefaultTypeConverter.INSTANCE.convert(Locale.class, "fr_FR")); assertEquals(Locale.FRANCE, DefaultTypeConverter.INSTANCE.convert(Locale.class, "fr_FR_")); + + assertEquals(new VersionNumber("1.2.3"), DefaultTypeConverter.INSTANCE.convert(VersionNumber.class, "1.2.3")); } public void testPrimativeAccessors() diff --git a/source/java/org/alfresco/util/BaseAlfrescoTestCase.java b/source/java/org/alfresco/util/BaseAlfrescoTestCase.java index 250e673de8..d8a41562af 100644 --- a/source/java/org/alfresco/util/BaseAlfrescoTestCase.java +++ b/source/java/org/alfresco/util/BaseAlfrescoTestCase.java @@ -20,14 +20,12 @@ package org.alfresco.util; import junit.framework.TestCase; import org.alfresco.repo.security.authentication.AuthenticationComponent; -import org.alfresco.repo.security.authentication.AuthenticationException; import org.alfresco.service.ServiceRegistry; import org.alfresco.service.cmr.action.ActionService; import org.alfresco.service.cmr.repository.ContentService; import org.alfresco.service.cmr.repository.NodeRef; import org.alfresco.service.cmr.repository.NodeService; import org.alfresco.service.cmr.repository.StoreRef; -import org.alfresco.service.cmr.security.AuthenticationService; import org.alfresco.service.transaction.TransactionService; import org.springframework.context.ApplicationContext; @@ -52,8 +50,8 @@ public abstract class BaseAlfrescoTestCase extends TestCase /** The content service */ protected ContentService contentService; - /** The authentication service */ - protected AuthenticationService authenticationService; + /** The authentication component */ + protected AuthenticationComponent authenticationComponent; /** The store reference */ protected StoreRef storeRef; @@ -73,14 +71,13 @@ public abstract class BaseAlfrescoTestCase extends TestCase // get the service register this.serviceRegistry = (ServiceRegistry) ctx.getBean(ServiceRegistry.SERVICE_REGISTRY); //Get a reference to the node service - this.nodeService = (NodeService)ctx.getBean("NodeService"); - this.contentService = (ContentService)ctx.getBean("ContentService"); - this.authenticationService = (AuthenticationService)ctx.getBean("authenticationService"); + this.nodeService = serviceRegistry.getNodeService(); + this.contentService = serviceRegistry.getContentService(); + this.authenticationComponent = (AuthenticationComponent)ctx.getBean("authenticationComponent"); this.actionService = (ActionService)ctx.getBean("actionService"); - this.transactionService = (TransactionService)ctx.getBean("transactionComponent"); + this.transactionService = serviceRegistry.getTransactionService(); // Authenticate as the system user - this must be done before we create the store - AuthenticationComponent authenticationComponent = (AuthenticationComponent)ctx.getBean("authenticationComponent"); authenticationComponent.setSystemUserAsCurrentUser(); // Create the store and get the root node @@ -94,7 +91,15 @@ public abstract class BaseAlfrescoTestCase extends TestCase @Override protected void tearDown() throws Exception { - authenticationService.clearCurrentSecurityContext(); + try + { + authenticationComponent.clearCurrentSecurityContext(); + } + catch (Throwable e) + { + e.printStackTrace(); + // Don't let this mask any previous exceptions + } super.tearDown(); } diff --git a/source/test-resources/module/module-component-test-beans.xml b/source/test-resources/module/module-component-test-beans.xml new file mode 100644 index 0000000000..3e21801530 --- /dev/null +++ b/source/test-resources/module/module-component-test-beans.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + /cm:categoryRoot/cm:generalclassifiable + module/module-importer-test-categories.xml + + + + + diff --git a/source/test-resources/module/module-importer-test-categories.xml b/source/test-resources/module/module-importer-test-categories.xml new file mode 100644 index 0000000000..49f289fbcf --- /dev/null +++ b/source/test-resources/module/module-importer-test-categories.xml @@ -0,0 +1,26 @@ + + + + module.importer.test.categories + test:xyz-root + + + X + test:x + + + Y + test:y + + + Z + test:z + + + + + + \ No newline at end of file diff --git a/source/test-resources/module/test.amp b/source/test-resources/module/test.amp index d9915a26910caaa9816d59598305f6afe719ea7c..5865987b4538e32ef510c056c91276d0cfcab78b 100644 GIT binary patch delta 144 zcmcb0g89rz<_%l#PkwiUL+4wOk=gG`z8EeB1_lu31LE@3B>k-70{z@dJs=h48Wim5 z@8|F5x_R^c$t(ekOd?>F3=G)x0<|+RGAIDa&G*qw3h-uS1Idd3;aL_2hL=DU3=9B? CT_v&r delta 35 mcmX?elKIvN<_%l#Z+?A$B8vzs8v_ss1K~s#28PY|K|BBmX$t@V diff --git a/source/test-resources/module/test.war b/source/test-resources/module/test.war index 5549ecc3a57362abd06c2d59ec442b4dfc79b2b4..efb5ef271888383f840826b5ae970b81f69d74b9 100644 GIT binary patch delta 145 zcmdnwG~0PYwv0YA3l{?joXa#ayWL*y$q8hGFi143xIjO*QV&S^2YI@C`ujQhOr9?z zGx?Sr-{#3O-7G$gOd?=~*tG*qU|?iW0Fs$9$c6=Yv$BEYM1k-%3j@O}pezFe0Gmr4 A-T(jq delta 41 scmbR3yvb=pw#?*9GE$Sd<@q)rk?Cd;VP#_g0udnG%)-EsDFfmG00h4Y6#xJL diff --git a/source/test-resources/module/test_v2.amp b/source/test-resources/module/test_v2.amp new file mode 100644 index 0000000000000000000000000000000000000000..ea9a5fcbb90e203b433180eb5a02ed3d0637dbbe GIT binary patch literal 59743 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 H`|f`L@Zm?Q literal 0 HcmV?d00001