/* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package org.forgerock.openidm.config.installer; import java.io.BufferedReader; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStreamReader; import java.io.OutputStreamWriter; import java.io.Writer; import java.net.URI; import java.util.AbstractMap; import java.util.AbstractSet; import java.util.Collections; import java.util.Dictionary; import java.util.Enumeration; import java.util.HashMap; import java.util.Hashtable; import java.util.Iterator; import java.util.Map; import java.util.Properties; import java.util.Set; import org.apache.felix.fileinstall.ArtifactInstaller; import org.apache.felix.fileinstall.internal.DirectoryWatcher; import org.forgerock.openidm.config.crypto.ConfigCrypto; import org.forgerock.openidm.config.enhanced.JSONEnhancedConfig; import org.forgerock.openidm.config.persistence.ConfigBootstrapHelper; import org.forgerock.openidm.core.IdentityServer; import org.forgerock.openidm.metadata.WaitForMetaData; import org.osgi.framework.BundleContext; import org.osgi.framework.Constants; import org.osgi.framework.ServiceReference; import org.osgi.service.cm.Configuration; import org.osgi.service.cm.ConfigurationAdmin; import org.osgi.service.cm.ConfigurationEvent; import org.osgi.service.cm.ConfigurationException; import org.osgi.service.cm.ConfigurationListener; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * ArtifactInstaller for JSON configurations. Based on ConfigInstaller. * TODO: This service lifecycle should be bound to the ConfigurationAdmin service lifecycle. */ public class JSONConfigInstaller implements ArtifactInstaller, ConfigurationListener { final static Logger logger = LoggerFactory.getLogger(JSONConfigInstaller.class); // The key in the OSGi configuration dictionary holding the complete JSON configuration string public final static String JSON_CONFIG_PROPERTY = JSONEnhancedConfig.JSON_CONFIG_PROPERTY; public final static String SERVICE_FACTORY_PID_ALIAS = "config.factory-pid"; private static String fileEncoding = IdentityServer.getInstance() .getProperty("openidm.config.file.encoding", "UTF-8"); private final Map<String, String> pidToFile = Collections.synchronizedMap(new HashMap<String, String>()); private BundleContext context; private ConfigurationAdmin configAdmin; private ConfigCrypto configCrypto; private DelayedConfigHandler delayedConfigHandler = new DelayedConfigHandler(); public void start(BundleContext ctx) { configCrypto = ConfigCrypto.getInstance(ctx, delayedConfigHandler); this.context = ctx; this.configAdmin = lookupConfigAdmin(context); if (this.configAdmin != null) { logger.debug("Starting JSON configuration listener"); } else { logger.debug("ConfigAdmin is not yet available for JSON configuration listener, " + " will not handle JSON configuration files until it is available."); } } public void stop(BundleContext ctx) { this.context = null; this.configAdmin = null; logger.debug("Stopped JSON configuration listener"); } public boolean canHandle(File artifact) { if (this.configAdmin == null) { // See if the configuration admin service is available now this.configAdmin = lookupConfigAdmin(this.context); if (this.configAdmin != null) { logger.info("Detected ConfigAdmin service, starting JSON configuration listener"); } } if (this.configAdmin != null) { logger.debug("Checking if can handle artifact: {}", artifact); return artifact.getName().endsWith(ConfigBootstrapHelper.JSON_CONFIG_FILE_EXT); } else { return false; } } public void install(File artifact) throws Exception { logger.debug("Artifact install {}", artifact); setConfig(artifact); } public void update(File artifact) throws Exception { logger.debug("Artifact update {}", artifact); setConfig(artifact); } public void uninstall(File artifact) throws Exception { logger.debug("Artifact uninstall {}", artifact); deleteConfig(artifact); } public void configurationEvent(ConfigurationEvent configurationEvent) { logger.debug("ConfigurationEvent {}, pid: {}, factoryPid: {}, type: {}", new Object[] {configurationEvent, configurationEvent.getPid(), configurationEvent.getFactoryPid(), configurationEvent.getType()}); // Check if writing back configurations has been disabled. Object obj = this.context.getProperty( DirectoryWatcher.DISABLE_CONFIG_SAVE ); if (obj instanceof String) { obj = Boolean.valueOf((String) obj); } if (Boolean.FALSE.equals(obj)) { return; } String factoryPid = configurationEvent.getFactoryPid(); if ("org.forgerock.openidm.router".equalsIgnoreCase(factoryPid)) { logger.warn("Factory router config is detected. OpenIDM prevents further processing of this config!"); return; } String pid = configurationEvent.getPid(); if (configurationEvent.getType() == ConfigurationEvent.CM_UPDATED) { try { Configuration config = getConfigurationAdmin().getConfiguration(pid, factoryPid); Dictionary dict = config.getProperties(); if (dict == null) { dict = new Properties(); } String fileName = (String) dict.get( DirectoryWatcher.FILENAME ); String confDir = ConfigBootstrapHelper.getConfigFileInstallDir(); // Externalize OpenIDM configurations into the file "view" if (fileName == null && pid.startsWith(ConfigBootstrapHelper.DEFAULT_SERVICE_RDN_PREFIX)) { String unqualified = pid.substring(ConfigBootstrapHelper.DEFAULT_SERVICE_RDN_PREFIX.length()); if (factoryPid != null) { String unqualifiedFactoryPid = factoryPid; if (factoryPid.startsWith(ConfigBootstrapHelper.DEFAULT_SERVICE_RDN_PREFIX)) { unqualifiedFactoryPid = factoryPid.substring(ConfigBootstrapHelper.DEFAULT_SERVICE_RDN_PREFIX.length()); } String alias = (String) dict.get(SERVICE_FACTORY_PID_ALIAS); if (alias == null) { logger.warn("Could not write out factory configuration file, as no friendly alias is set in the configuration." + " factory pid: {} assigned pid {}", factoryPid, pid); return; } fileName = toConfigKey(new File(confDir, unqualifiedFactoryPid + "-" + alias + ConfigBootstrapHelper.JSON_CONFIG_FILE_EXT)); } else { fileName = toConfigKey(new File(confDir, unqualified + ConfigBootstrapHelper.JSON_CONFIG_FILE_EXT)); } logger.debug("Store config view filename in configuration {}", fileName); dict.put(DirectoryWatcher.FILENAME, fileName); config.update(dict); } File file = fileName != null ? fromConfigKey(fileName) : null; // IF file exists, update it, if does not exist create it if ( file != null) { if (fileName.endsWith(ConfigBootstrapHelper.JSON_CONFIG_FILE_EXT)) { synchronized(this) { // With rapid changes prevent conflicting writes to a file boolean isUpToDate = false; if (file.exists()) { Hashtable existingCfg = loadConfigFile(file); isUpToDate = isConfigSame(dict, existingCfg); } if (isUpToDate) { logger.debug("Config file is up-to-date: {}", fileName); } else { logger.info("Updating configuration file: {}", fileName); // Note: currently only stores JSON config property, not other properties in Dictionary. String jsonConfig = ""; Object entry = dict.get(JSON_CONFIG_PROPERTY); if (entry != null) { jsonConfig = entry.toString(); } Writer writer = new OutputStreamWriter(new FileOutputStream(file), fileEncoding); try { writer.write(jsonConfig); } finally { writer.close(); } logger.debug("Completed update of configuration file {}", fileName); } } } } } catch (Exception e) { logger.info("Unable to save configuration", e); } } else if (configurationEvent.getType() == ConfigurationEvent.CM_DELETED) { String fileName = pidToFile.get(pid); if (fileName != null) { File fileToDel = fromConfigKey(fileName); synchronized(this) { logger.trace("Try to delete {} exists: {}", fileToDel, fileToDel.exists()); boolean deleted = fileToDel.delete(); if (deleted) { logger.debug("Deleted configuration file from view {}", fileName); } else { logger.info("No configuration deleted from view corresponding to {} {}", pid, fileName); } pidToFile.remove(pid); } } } } public static ConfigurationAdmin lookupConfigAdmin(BundleContext context) { ConfigurationAdmin confAdmin = null; if (context != null) { ServiceReference configurationAdminReference = context.getServiceReference(ConfigurationAdmin.class.getName()); if (configurationAdminReference != null) { confAdmin = (org.osgi.service.cm.ConfigurationAdmin) context.getService(configurationAdminReference); } } return confAdmin; } public ConfigurationAdmin getConfigurationAdmin() { // TOOD: better guarding against this service not (yet) being there. if (configAdmin == null) { this.configAdmin = lookupConfigAdmin(context); if (this.configAdmin != null) { logger.info("ConfigAdmin service detected by JSON configuration listener"); } else { logger.warn("JSON Configuration listener could not find ConfigAdmin service"); } } return configAdmin; } /** * Load the specified configuration file as hashtable. * * May also be called by other clients that want direct access to a given configuration file * without going through the configuration admin service. * * @param f * @return * @throws java.io.IOException */ @SuppressWarnings("unchecked") public static Hashtable loadConfigFile(final File f) throws IOException { logger.debug("Loading configuration from {}", f); final Hashtable ht = new Hashtable(); if (f.getName().endsWith(ConfigBootstrapHelper.JSON_CONFIG_FILE_EXT)) { StringBuilder fileBuf = new StringBuilder(1024); BufferedReader reader = new BufferedReader(new InputStreamReader( new FileInputStream(f), fileEncoding)); try { char[] buf = new char[1024]; int numRead = 0; while((numRead = reader.read(buf)) != -1){ fileBuf.append(buf, 0, numRead); } } finally { reader.close(); } ht.put(JSON_CONFIG_PROPERTY, fileBuf.toString()); } return ht; } /** * Set the configuration based on the config file. * * @param f * Configuration file * @return * @throws Exception */ @SuppressWarnings("unchecked") synchronized boolean setConfig(final File f) throws Exception { boolean updated = false; try { Dictionary ht = loadConfigFile(f); String pid[] = parsePid(f.getName()); updated = setConfig(ht, pid, f); } catch (Exception ex) { logger.warn("Loading configuration file {} failed ", f, ex); } return updated; } synchronized boolean setConfig(Dictionary ht, final String[] pid, final File f) throws Exception { boolean updated = false; Configuration config = getConfiguration(toConfigKey(f), pid[0], pid[1], true); Dictionary props = config.getProperties(); if (!isConfigSame(ht, props)) { try { ht = configCrypto.encrypt(pid[0], pid[1], ht); ht.put(DirectoryWatcher.FILENAME, toConfigKey(f)); if (pid != null && pid[1] != null) { ht.put(SERVICE_FACTORY_PID_ALIAS, pid[1]); } if (config.getBundleLocation() != null) { config.setBundleLocation(null); } if (pid[1] == null) { logger.info("Loaded changed configuration for {} from {}", pid[0], f.getName()); } else { logger.info("Loaded changed configuration for {} {} from {}", new Object[] {pid[0], pid[1], f.getName()}); } config.update(ht); } catch (WaitForMetaData ex) { logger.debug("Wait for meta data for config {}-{}", pid[0], pid[1]); DelayedConfig delayed = new DelayedConfig(); delayed.pidOrFactory = pid[0]; delayed.factoryAlias = pid[1]; delayed.file = f; delayed.oldConfig = props; delayed.newConfig = ht; delayed.parsedConfig = configCrypto.parse(ht, pid[0] + "-" + pid[1]); delayed.configInstaller = this; delayed.configCrypto = configCrypto; delayedConfigHandler.addConfig(delayed); } updated = true; } else { logger.debug("File contents of configuration for {} from {} has not changed.", pid[1], f); updated = false; } return updated; } /** * Remove the configuration. * * @param f * File where the configuration in whas defined. * @return * @throws Exception */ boolean deleteConfig(File f) throws Exception { String pid[] = parsePid(f.getName()); Configuration config = getConfiguration(toConfigKey(f), pid[0], pid[1], false); config.delete(); return true; } String toConfigKey(File f) { return f.getAbsoluteFile().toURI().toString(); } File fromConfigKey(String key) { return new File(URI.create(key)); } String[] parsePid(String path) { String pid = path.substring(0, path.lastIndexOf('.')); int n = pid.indexOf('-'); if (n > 0) { String factoryPid = pid.substring(n + 1); pid = pid.substring(0, n); pid = ConfigBootstrapHelper.qualifyPid(pid); logger.info("Configuring service PID {} factory PID {}", pid, factoryPid); return new String[] { pid, factoryPid }; } else { pid = ConfigBootstrapHelper.qualifyPid(pid); return new String[] { pid, null }; } } /** * Whether the JSON configuration is the same (Including formatting) * Ignores meta-data such as whether factory pid has been assigned yet * * @param newCfg * @param oldCfg * @return true if the JSON config is the same */ boolean isConfigSame(Dictionary newCfg, Dictionary oldCfg) { if (newCfg == null || oldCfg == null) { return oldCfg == newCfg; } Dictionary newCompare = new Hashtable(new DictionaryAsMap(newCfg)); newCompare.remove( DirectoryWatcher.FILENAME ); newCompare.remove( Constants.SERVICE_PID ); newCompare.remove( ConfigurationAdmin.SERVICE_FACTORYPID ); newCompare.remove( SERVICE_FACTORY_PID_ALIAS ); Dictionary oldCompare = new Hashtable(new DictionaryAsMap(oldCfg)); oldCompare.remove( DirectoryWatcher.FILENAME ); oldCompare.remove( Constants.SERVICE_PID ); oldCompare.remove( ConfigurationAdmin.SERVICE_FACTORYPID ); oldCompare.remove( SERVICE_FACTORY_PID_ALIAS ); return newCompare.equals(oldCompare); } Configuration getConfiguration(String fileName, String pid, String factoryPid, boolean addIfNew) throws Exception { Configuration oldConfiguration = findExistingConfiguration(fileName, pid, factoryPid); if (oldConfiguration != null) { logger.debug("Updating configuration from {}", fileName); return oldConfiguration; } else { Configuration newConfiguration; if (factoryPid != null) { if ("org.forgerock.openidm.router".equalsIgnoreCase(pid)) { throw new ConfigurationException(factoryPid, "router config can not be factory config"); } newConfiguration = getConfigurationAdmin().createFactoryConfiguration(pid, null); } else { newConfiguration = getConfigurationAdmin().getConfiguration(pid, null); } if (addIfNew) { pidToFile.put(newConfiguration.getPid(), fileName); } return newConfiguration; } } Configuration findExistingConfiguration(String fileName, String pid, String factoryPid) throws Exception { String filter = null; if (null == factoryPid) { filter = "(" + Constants.SERVICE_PID + "=" + pid + ")"; } else { filter = "(&(" + ConfigurationAdmin.SERVICE_FACTORYPID + "=" + pid + ")(config.factory-pid=" + factoryPid + "))"; } Configuration[] configurations = getConfigurationAdmin().listConfigurations(filter); if (configurations != null && configurations.length > 0) { pidToFile.put(configurations[0].getPid(), fileName); return configurations[0]; } else { return null; } } } /** * A wrapper around a dictionary access it as a Map */ class DictionaryAsMap<U, V> extends AbstractMap<U, V> { private Dictionary<U, V> dict; public DictionaryAsMap(Dictionary<U, V> dict) { this.dict = dict; } @Override public Set<Entry<U, V>> entrySet() { return new AbstractSet<Entry<U, V>>() { @Override public Iterator<Entry<U, V>> iterator() { final Enumeration<U> e = dict.keys(); return new Iterator<Entry<U, V>>() { private U key; public boolean hasNext() { return e.hasMoreElements(); } public Entry<U, V> next() { key = e.nextElement(); return new KeyEntry(key); } public void remove() { if (key == null) { throw new IllegalStateException(); } dict.remove(key); } }; } @Override public int size() { return dict.size(); } }; } @Override public V put(U key, V value) { return dict.put(key, value); } class KeyEntry implements Map.Entry<U,V> { private final U key; KeyEntry(U key) { this.key = key; } public U getKey() { return key; } public V getValue() { return dict.get(key); } public V setValue(V value) { return DictionaryAsMap.this.put(key, value); } } }