001/*
002 *  Copyright 2016 Anyware Services
003 *
004 *  Licensed under the Apache License, Version 2.0 (the "License");
005 *  you may not use this file except in compliance with the License.
006 *  You may obtain a copy of the License at
007 *
008 *      http://www.apache.org/licenses/LICENSE-2.0
009 *
010 *  Unless required by applicable law or agreed to in writing, software
011 *  distributed under the License is distributed on an "AS IS" BASIS,
012 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 *  See the License for the specific language governing permissions and
014 *  limitations under the License.
015 */
016package org.ametys.core.datasource;
017
018import java.io.File;
019import java.io.IOException;
020import java.sql.Connection;
021import java.sql.SQLException;
022import java.time.Duration;
023import java.util.HashMap;
024import java.util.LinkedHashMap;
025import java.util.Map;
026
027import javax.sql.DataSource;
028
029import org.apache.avalon.framework.configuration.Configuration;
030import org.apache.avalon.framework.configuration.ConfigurationException;
031import org.apache.avalon.framework.configuration.DefaultConfigurationBuilder;
032import org.apache.avalon.framework.service.ServiceException;
033import org.apache.avalon.framework.service.ServiceManager;
034import org.apache.commons.dbcp2.ConnectionFactory;
035import org.apache.commons.dbcp2.DriverManagerConnectionFactory;
036import org.apache.commons.dbcp2.PoolableConnection;
037import org.apache.commons.dbcp2.PoolableConnectionFactory;
038import org.apache.commons.dbcp2.PoolingDataSource;
039import org.apache.commons.pool2.ObjectPool;
040import org.apache.commons.pool2.impl.GenericObjectPool;
041import org.xml.sax.SAXException;
042
043import org.ametys.core.datasource.dbtype.SQLDatabaseType;
044import org.ametys.core.datasource.dbtype.SQLDatabaseTypeExtensionPoint;
045import org.ametys.plugins.core.impl.checker.SQLConnectionChecker;
046import org.ametys.plugins.core.impl.datasource.DerbySQLDatabaseType;
047import org.ametys.plugins.core.impl.datasource.HsqlDbSQLDatabaseType;
048import org.ametys.runtime.i18n.I18nizableText;
049import org.ametys.runtime.model.checker.ItemCheckerTestFailureException;
050import org.ametys.runtime.servlet.RuntimeConfig;
051import org.ametys.runtime.util.AmetysHomeHelper;
052
053/**
054 * This component handles SQL data sources. 
055 * It is associated with the configuration file $AMETYS_HOME/config/datasources-sql.xml 
056 */
057public class SQLDataSourceManager extends AbstractDataSourceManager
058{
059    /** Avalon Role */
060    public static final String ROLE = SQLDataSourceManager.class.getName();
061    
062    /** Name of parameter for database type */
063    public static final String PARAM_DATABASE_TYPE = "dbtype";
064    /** Name of parameter for database url */
065    public static final String PARAM_DATABASE_URL = "url";
066    /** Name of parameter for user's login */
067    public static final String PARAM_DATABASE_USER = "user";
068    /** Name of parameter for user's password */
069    public static final String PARAM_DATABASE_PASSWORD = "password";
070    
071    /** The id of the internal DataSource */
072    public static final String SQL_DATASOURCE_PREFIX = "SQL-";
073    
074    /** The id of the internal DataSource */
075    public static final String AMETYS_INTERNAL_DATASOURCE_ID = "SQL-ametys-internal";
076    private static final I18nizableText __AMETYS_INTERNAL_DATASOURCE_NAME = new I18nizableText("plugin.core", "PLUGINS_CORE_INTERNAL_DATASOURCE_LABEL");
077    private static final I18nizableText __AMETYS_INTERNAL_DATASOURCE_DESCRIPTION = new I18nizableText("plugin.core", "PLUGINS_CORE_INTERNAL_DATASOURCE_DESCRIPTION");
078    
079    private static String __filename;
080
081    private Map<String, DataSource> _sqlDataSources;
082    private Map<String, ObjectPool> _pools;
083    
084    private DataSourceDefinition _internalDataSource;
085
086    private SQLDatabaseTypeExtensionPoint _sqlDatabaseTypeEP;
087
088    private ServiceManager _manager;
089    
090    @Override
091    public void service(ServiceManager serviceManager) throws ServiceException
092    {
093        _manager = serviceManager;
094        super.service(serviceManager);
095    }
096    
097    /**
098     * Read the read source definitions 
099     * @param file The configuration file
100     * @return the data source definitions
101     */
102    public static Map<String, DataSourceDefinition> readDataSourceDefinition (File file)
103    {
104        Map<String, DataSourceDefinition> definitions = new HashMap<>();
105        
106        try
107        {
108            if (file.exists())
109            {
110                Configuration configuration = new DefaultConfigurationBuilder().buildFromFile(file);
111                for (Configuration dsConfig : configuration.getChildren("datasource"))
112                {
113                    String id = dsConfig.getAttribute("id");
114                    
115                    I18nizableText name = I18nizableText.parseI18nizableText(dsConfig.getChild("name"), "plugin.core");
116                    I18nizableText description = I18nizableText.parseI18nizableText(dsConfig.getChild("description"), "plugin.core", "");
117                    
118                    boolean isPrivate = dsConfig.getAttributeAsBoolean("private", false);
119                    boolean isDefault = dsConfig.getAttributeAsBoolean("default", false);
120                    
121                    Map<String, Object> parameters = new HashMap<>();
122                    
123                    Configuration[] paramsConfig = dsConfig.getChild("parameters").getChildren();
124                    for (Configuration paramConfig : paramsConfig)
125                    {
126                        String value = paramConfig.getValue("");
127                        parameters.put(paramConfig.getName(), value);
128                    }
129                    
130                    DataSourceDefinition dataSource = new DataSourceDefinition(id, name, description, parameters, isPrivate, isDefault);
131                    definitions.put(id, dataSource);
132                }
133            }
134            
135            return definitions;
136        }
137        catch (IOException | ConfigurationException | SAXException e)
138        {
139            throw new RuntimeException("Unable to parse datasource configuration file.", e);
140        }
141    }
142    
143    @Override
144    protected Map<String, DataSourceDefinition> doReadConfiguration(File file)
145    {
146        return readDataSourceDefinition(file);
147    }
148    
149    private SQLDatabaseTypeExtensionPoint getSQLDatabaseTypeEP()
150    {
151        if (_sqlDatabaseTypeEP == null)
152        {
153            try
154            {
155                _sqlDatabaseTypeEP = (SQLDatabaseTypeExtensionPoint) _manager.lookup(SQLDatabaseTypeExtensionPoint.ROLE);
156            }
157            catch (ServiceException e)
158            {
159                throw new RuntimeException(e);
160            }
161        }
162        return _sqlDatabaseTypeEP;
163    }
164    
165    /**
166     * Set the config filename. Only use for tests.
167     * @param filename Name with path of the config file
168     */
169    public static void setFilename(String filename)
170    {
171        __filename = filename;
172    }
173    
174    /**
175     * Get the configuration file for SQL data sources
176     * @return the configuration file
177     */
178    public static File getStaticFileConfiguration ()
179    {
180        if (__filename != null)
181        {
182            return new File(__filename);
183        }
184        
185        return new File(AmetysHomeHelper.getAmetysHomeConfig(), "datasources-sql.xml");
186    }
187    
188    @Override
189    public File getFileConfiguration()
190    {
191        return getStaticFileConfiguration();
192    }
193    
194    /**
195     * Initializes connections.
196     * @param preserveInternalDB also initialize internal db connection, or not.
197     * @throws Exception if an error occurs
198     */
199
200    public void initialize(boolean preserveInternalDB) throws Exception
201    {
202        if (preserveInternalDB && _internalDataSource == null)
203        {
204            throw new IllegalStateException("Internal dataSource should not be null to be preserved.");
205        }
206        
207        String internalId = null;
208        DataSource internalDataSource = null;
209        ObjectPool internalPool = null;
210        
211        if (preserveInternalDB)
212        {
213            // keep internal DB pool
214            internalId = _internalDataSource.getId();
215            internalDataSource = _sqlDataSources.get(internalId);
216            internalPool = _pools.get(internalId);
217        }
218        
219        _sqlDataSources = new HashMap<>();
220        _pools = new HashMap<>();
221        
222        if (preserveInternalDB)
223        {
224            _sqlDataSources.put(internalId, internalDataSource);
225            _pools.put(internalId, internalPool);
226        }
227        else
228        {
229            _internalDataSource = getInternalDataSourceDefinition();
230        }
231
232        super.initialize();
233        
234        if (!preserveInternalDB)
235        {
236            // Add the internal and not editable DB
237            createDataSource(_internalDataSource);
238        }
239    }
240    
241    @Override
242    public void initialize() throws Exception
243    {
244        initialize(false);
245    }
246    
247    @Override
248    public DataSourceDefinition getDataSourceDefinition(String id)
249    {
250        readConfiguration();
251        
252        if (AMETYS_INTERNAL_DATASOURCE_ID.equals(id))
253        {
254            return _internalDataSource;
255        }
256        
257        return super.getDataSourceDefinition(id);
258    }
259
260    @Override
261    public Map<String, DataSourceDefinition> getDataSourceDefinitions(boolean includePrivate, boolean includeInternal, boolean includeDefault)
262    {
263        readConfiguration();
264        
265        Map<String, DataSourceDefinition> datasources = new LinkedHashMap<>();
266        
267        if (includePrivate && includeInternal)
268        {
269            // Include the internal db
270            datasources.put(AMETYS_INTERNAL_DATASOURCE_ID, _internalDataSource);
271        }
272        
273        datasources.putAll(super.getDataSourceDefinitions(includePrivate, includeInternal, includeDefault));
274        return datasources;
275    }
276    
277    @Override
278    protected String getDataSourcePrefixId()
279    {
280        return SQL_DATASOURCE_PREFIX;
281    }
282    
283    /**
284     * Get the datasource definition for internal database
285     * @return The datasource definition
286     */
287    public static DataSourceDefinition getInternalDataSourceDefinition ()
288    {
289        Map<String, Object> parameters = new HashMap<>();
290        parameters.put (PARAM_DATABASE_TYPE, ConnectionHelper.DATABASE_DERBY);
291        
292        File dbFile = new File (RuntimeConfig.getInstance().getAmetysHome(), "data" + File.separator + "internal-db");
293        parameters.put (PARAM_DATABASE_URL, "jdbc:derby:" + dbFile.getAbsolutePath() + ";create=true");
294        parameters.put (PARAM_DATABASE_USER, ""); 
295        parameters.put (PARAM_DATABASE_PASSWORD, "");
296        
297        return new DataSourceDefinition(AMETYS_INTERNAL_DATASOURCE_ID, __AMETYS_INTERNAL_DATASOURCE_NAME, __AMETYS_INTERNAL_DATASOURCE_DESCRIPTION, parameters, true, false);
298    }
299    
300    /**
301     * Get a connection object the internal data source
302     * @return the connection object to Ametys' internal SQL data source
303     */
304    public Connection getInternalSQLDataSourceConnection()
305    {
306        DataSource dataSource;
307        Connection connection = null;
308        try
309        {
310            dataSource =  getSQLDataSource(AMETYS_INTERNAL_DATASOURCE_ID);
311            connection = dataSource.getConnection();
312        }
313        catch (SQLException e)
314        {
315            throw new RuntimeException("Unable to get the connection to the internal ametys SQL data source", e);
316        }
317        
318        return connection;
319    }
320    
321    /**
322     * Get the existing SQL data sources
323     * @return the SQL data sources
324     */
325    public Map<String, DataSource> getSQLDataSources ()
326    {
327        return _sqlDataSources;
328    }
329    
330    /**
331     * Get the SQL data source by its identifier
332     * @param id The id of data source
333     * @return the SQL data source
334     */
335    public DataSource getSQLDataSource (String id)
336    {
337        DataSource dataSource = _sqlDataSources.get(id);
338        if (getDefaultDataSourceId().equals(id))
339        {
340            String actualDataSourceId = getDefaultDataSourceDefinition().getId(); 
341            dataSource = _sqlDataSources.get(actualDataSourceId);
342        }
343        
344        if (dataSource == null)
345        {
346            throw new UnknownDataSourceException("The data source of id '" + id + "' was not found.");
347        }
348        
349        return dataSource;
350    }
351    
352    
353    @Override
354    public DataSourceDefinition setDefaultDataSource(String id)
355    {
356        readConfiguration();
357        
358        // If the default data source was the internal data source, it is no longer the case
359        DataSourceDefinition oldDefaultDataSource = getDefaultDataSourceDefinition();
360        if (oldDefaultDataSource != null && oldDefaultDataSource.getId().equals(AMETYS_INTERNAL_DATASOURCE_ID))
361        {
362            _internalDataSource.setDefault(false);
363        }    
364        
365        try
366        {
367            return super.setDefaultDataSource(id);
368        }
369        catch (RuntimeException e)
370        {
371            if (id.equals(AMETYS_INTERNAL_DATASOURCE_ID))
372            {
373                _internalDataSource.setDefault(true);
374                return _internalDataSource;
375            }
376            else 
377            {
378                throw new RuntimeException(e);
379            }
380        }
381    }
382    
383    @Override
384    public DataSourceDefinition getDefaultDataSourceDefinition()
385    {
386        if (_internalDataSource.isDefault())
387        {
388            return _internalDataSource;
389        }
390        else
391        {
392            return super.getDefaultDataSourceDefinition();
393        }
394    }
395    
396    @Override
397    protected void internalSetDefaultDataSource()
398    {
399        _internalDataSource.setDefault(true);
400    }
401    
402    @Override
403    public void checkParameters(Map<String, Object> parameters) throws ItemCheckerTestFailureException
404    {
405        SQLConnectionChecker.check((String) parameters.get(PARAM_DATABASE_URL), (String) parameters.get(PARAM_DATABASE_USER), (String) parameters.get(PARAM_DATABASE_PASSWORD), _manager);
406    }
407    
408    @Override
409    protected void editDataSource(DataSourceDefinition dataSource)
410    {
411        deleteDataSource(dataSource);
412        createDataSource(dataSource);
413    }
414    
415    @Override
416    protected void createDataSource(DataSourceDefinition dataSourceDef)
417    {
418        Map<String, Object> parameters = dataSourceDef.getParameters();
419        
420        String url = (String) parameters.get(PARAM_DATABASE_URL);
421        String user = (String) parameters.get(PARAM_DATABASE_USER);
422        String password = (String) parameters.get(PARAM_DATABASE_PASSWORD);
423        
424        String dbtype = (String) parameters.get(PARAM_DATABASE_TYPE);
425        if (!getSQLDatabaseTypeEP().hasExtension(dbtype))
426        {
427            throw new IllegalArgumentException("Database of type '" + dbtype + "' is not supported");
428        }
429        SQLDatabaseType sqlDbType = getSQLDatabaseTypeEP().getExtension(dbtype);
430        
431        ConnectionFactory connectionFactory = new DriverManagerConnectionFactory(url, user, password);
432        PoolableConnectionFactory poolableConnectionFactory = new PoolableConnectionFactory(connectionFactory, null);
433        
434        GenericObjectPool<PoolableConnection> connectionPool = new GenericObjectPool<>(poolableConnectionFactory);
435        connectionPool.setMaxTotal(-1);
436        connectionPool.setMaxIdle(10);
437        connectionPool.setMinIdle(2);
438        connectionPool.setTestOnBorrow(true);
439        connectionPool.setTestOnReturn(false);
440        connectionPool.setTestWhileIdle(true);
441        connectionPool.setTimeBetweenEvictionRuns(Duration.ofMillis(1000 * 60 * 30));
442        
443        poolableConnectionFactory.setPool(connectionPool);
444        poolableConnectionFactory.setValidationQuery(sqlDbType.getValidationQuery());
445        poolableConnectionFactory.setDefaultAutoCommit(true);
446        poolableConnectionFactory.setDefaultReadOnly(false);
447                 
448        PoolingDataSource<PoolableConnection> dataSource = new PoolingDataSource<>(connectionPool);
449        
450        String id = dataSourceDef.getId();
451        
452        // make sure the previous connection has been disposed
453        if (_pools.containsKey(id))
454        {
455            _disposePool(id);
456        }
457        
458        // Store the connection pools and the data sources
459        _pools.put(id, connectionPool);
460        _sqlDataSources.put(id, dataSource);
461    }
462    
463    @Override
464    protected void deleteDataSource(DataSourceDefinition dataSource)
465    {
466        _sqlDataSources.remove(dataSource.getId());
467        _disposePool(dataSource.getId());
468    }
469    
470    /**
471     * Disposes connections.
472     * @param preserveInternalDB also remove internal db connection, or not.
473     */
474    public void dispose(boolean preserveInternalDB)
475    {
476        if (preserveInternalDB && _internalDataSource == null)
477        {
478            throw new IllegalStateException("Internal dataSource should not be null to be preserved.");
479        }
480        
481        String internalId = null;
482        ObjectPool internalPool = null;
483        
484        if (preserveInternalDB)
485        {
486            // keep internal DB pool
487            internalId = _internalDataSource.getId();
488            internalPool = _pools.get(internalId);
489        }
490        
491        for (String id : _pools.keySet())
492        {
493            if (!id.equals(_internalDataSource.getId()) || !preserveInternalDB)
494            {
495                // Not calling _disposePool(id); to avoid
496                // ConcurrentModificationException RUNTIME-1633
497                _closePool(id);
498            }
499        }
500        
501        _pools.clear();
502        _pools.put(internalId, internalPool);
503    }
504    
505    @Override
506    public void dispose()
507    {
508        dispose(false);
509    }
510    
511    /**
512     * Dispose of a connection pool
513     * @param id the id of the connection pool to dispose of
514     */
515    private void _disposePool(String id)
516    {
517        _closePool(id);
518        _pools.remove(id);
519    }
520    
521    private void _closePool(String id)
522    {
523        try
524        {
525            _pools.get(id).close();
526        }
527        catch (Exception e)
528        {
529            getLogger().warn("Unable to close the connection pool '{}'", id, e);
530        }
531        
532        try
533        {
534            // Close in-process databases
535            DataSourceDefinition definition = getDataSourceDefinition(id);
536            Map<String, Object> parameters = definition.getParameters();
537            String dbtype = (String) parameters.get(PARAM_DATABASE_TYPE);
538            
539            if (ConnectionHelper.DATABASE_DERBY.equals(dbtype))
540            {
541                DerbySQLDatabaseType.shutDown(definition);
542            }
543            else if (ConnectionHelper.DATABASE_HSQLDB.equals(dbtype))
544            {
545                HsqlDbSQLDatabaseType.shutDown(definition);
546            }
547        }
548        catch (Exception e)
549        {
550            getLogger().warn("Unable to shutdown an in-process database '{}'", id, e);
551        }
552    }
553}