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