This is an automated email from the ASF dual-hosted git repository. jhelou pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/james-project.git
commit f85ea6d91e981cbd1419a5e7bf22e12d498fc8f6 Author: Jean Helou <j...@xn--gml-cma.com> AuthorDate: Mon Mar 17 12:04:18 2025 +0100 [JAMES-4119] Core data migration tool from JPA to Postgres --- mpt/impl/smtp/jpa-pulsar/pom.xml | 7 + pom.xml | 5 + server/apps/migration/core-data-jpa-to-pg/pom.xml | 267 +++++++++++ .../sample-configuration/blob.properties | 66 +++ .../sample-configuration/james-database.properties | 45 ++ .../sample-configuration/logback.xml | 39 ++ .../sample-configuration/postgres.properties | 51 +++ .../org/apache/james/JpaToPgCoreDataMigration.java | 504 +++++++++++++++++++++ .../org/apache/james/MigrationConfiguration.java | 124 +++++ .../src/main/resources/META-INF/persistence.xml | 44 ++ .../src/main/scripts/james-migration | 7 + .../apache/james/JpaToPgCoreDataMigrationTest.java | 354 +++++++++++++++ .../java/org/apache/james/MariaDBExtension.java | 80 ++++ .../modules/blobstore/BlobStoreModulesChooser.java | 2 +- .../modules/data/PostgresQuotaGuiceModule.java | 2 +- .../apache/james/droplists/jpa/JPADropList.java | 17 + .../droplists/jpa/model/JPADropListEntry.java | 2 + .../apache/james/sieve/jpa/JPASieveRepository.java | 32 +- .../james/sieve/jpa/model/JPASieveQuota.java | 6 + .../james/sieve/jpa/model/JPASieveScript.java | 1 + .../org/apache/james/user/jpa/model/JPAUser.java | 15 +- server/pom.xml | 1 + 22 files changed, 1665 insertions(+), 6 deletions(-) diff --git a/mpt/impl/smtp/jpa-pulsar/pom.xml b/mpt/impl/smtp/jpa-pulsar/pom.xml index e2a9dff099..6104541893 100644 --- a/mpt/impl/smtp/jpa-pulsar/pom.xml +++ b/mpt/impl/smtp/jpa-pulsar/pom.xml @@ -31,6 +31,12 @@ <name>Apache James :: MPT :: SMTP :: JPA - Pulsar</name> <dependencies> + <dependency> + <groupId>${james.groupId}</groupId> + <artifactId>apache-james-backends-postgres</artifactId> + <type>test-jar</type> + <scope>test</scope> + </dependency> <dependency> <groupId>${james.groupId}</groupId> <artifactId>apache-james-backends-pulsar</artifactId> @@ -41,6 +47,7 @@ <type>test-jar</type> <scope>test</scope> </dependency> + <dependency> <groupId>${james.groupId}</groupId> <artifactId>apache-james-mpt-smtp-core</artifactId> diff --git a/pom.xml b/pom.xml index 867339af2a..0946424db3 100644 --- a/pom.xml +++ b/pom.xml @@ -3012,6 +3012,11 @@ <artifactId>junit-jupiter</artifactId> <version>${testcontainers.version}</version> </dependency> + <dependency> + <groupId>org.testcontainers</groupId> + <artifactId>mariadb</artifactId> + <version>${testcontainers.version}</version> + </dependency> <dependency> <groupId>org.testcontainers</groupId> <artifactId>postgresql</artifactId> diff --git a/server/apps/migration/core-data-jpa-to-pg/pom.xml b/server/apps/migration/core-data-jpa-to-pg/pom.xml new file mode 100644 index 0000000000..43ba423b75 --- /dev/null +++ b/server/apps/migration/core-data-jpa-to-pg/pom.xml @@ -0,0 +1,267 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + 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. +--> +<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd"> + + <modelVersion>4.0.0</modelVersion> + + <parent> + <groupId>org.apache.james</groupId> + <artifactId>james-server</artifactId> + <version>3.9.0-SNAPSHOT</version> + <relativePath>../../../pom.xml</relativePath> + </parent> + + <artifactId>migration-core-data-jpa-to-pg</artifactId> + <packaging>jar</packaging> + + <name>Apache James :: Server :: Binaries :: core data migration tool from JPA to PG</name> + <description> + A tool to help migrating servers from the JPA implementation + (regardless of backing database) to the new recommended Postgres + implementation. This tool only migrates Core DATA supported by JPA: + - Domains + - Users + - RRT + - Mail Repository urls + - DropLists + - Sieve (quota and scripts) + </description> + + <dependencyManagement> + <dependencies> + <dependency> + <groupId>${james.groupId}</groupId> + <artifactId>james-server-guice</artifactId> + <version>${project.version}</version> + <type>pom</type> + <scope>import</scope> + </dependency> + </dependencies> + </dependencyManagement> + + <dependencies> + <dependency> + <groupId>${james.groupId}</groupId> + <artifactId>apache-james-backends-postgres</artifactId> + <type>test-jar</type> + <scope>test</scope> + </dependency> + <dependency> + <groupId>${james.groupId}</groupId> + <artifactId>james-server-data-jpa</artifactId> + </dependency> + <dependency> + <groupId>${james.groupId}</groupId> + <artifactId>james-server-guice-common</artifactId> + </dependency> + <dependency> + <groupId>${james.groupId}</groupId> + <artifactId>james-server-guice-common</artifactId> + <type>test-jar</type> + <scope>test</scope> + </dependency> + <dependency> + <groupId>${james.groupId}</groupId> + <artifactId>james-server-guice-sieve-postgres</artifactId> + </dependency> + <dependency> + <groupId>${james.groupId}</groupId> + <artifactId>james-server-jpa-common-guice</artifactId> + </dependency> + <dependency> + <groupId>${james.groupId}</groupId> + <artifactId>james-server-postgres-common-guice</artifactId> + </dependency> + <dependency> + <groupId>${james.groupId}</groupId> + <artifactId>james-server-testing</artifactId> + <scope>test</scope> + </dependency> + <dependency> + <groupId>${james.groupId}</groupId> + <artifactId>james-server-util</artifactId> + </dependency> + <dependency> + <groupId>${james.groupId}</groupId> + <artifactId>mailrepository-blob</artifactId> + </dependency> + <dependency> + <groupId>${james.groupId}</groupId> + <artifactId>testing-base</artifactId> + <scope>test</scope> + </dependency> + <dependency> + <groupId>ch.qos.logback</groupId> + <artifactId>logback-classic</artifactId> + </dependency> + <dependency> + <groupId>io.rest-assured</groupId> + <artifactId>rest-assured</artifactId> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.apache.james</groupId> + <artifactId>james-server-guice-memory</artifactId> + </dependency> + <dependency> + <groupId>org.awaitility</groupId> + <artifactId>awaitility</artifactId> + </dependency> + <dependency> + <groupId>org.mariadb.jdbc</groupId> + <artifactId>mariadb-java-client</artifactId> + <version>2.7.2</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.postgresql</groupId> + <artifactId>postgresql</artifactId> + <version>42.6.1</version> + </dependency> + <dependency> + <groupId>org.testcontainers</groupId> + <artifactId>mariadb</artifactId> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.testcontainers</groupId> + <artifactId>postgresql</artifactId> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.testcontainers</groupId> + <artifactId>testcontainers</artifactId> + <scope>test</scope> + </dependency> + </dependencies> + + <build> + <plugins> + <plugin> + <groupId>org.apache.maven.plugins</groupId> + <artifactId>maven-dependency-plugin</artifactId> + <executions> + <execution> + <id>copy-dependencies</id> + <goals> + <goal>copy-dependencies</goal> + </goals> + <phase>package</phase> + <configuration> + <includeScope>compile</includeScope> + <includeScope>runtime</includeScope> + <outputDirectory>${project.build.directory}/${project.artifactId}.lib</outputDirectory> + </configuration> + </execution> + </executions> + </plugin> + <plugin> + <groupId>org.apache.maven.plugins</groupId> + <artifactId>maven-jar-plugin</artifactId> + <executions> + <execution> + <id>default-jar</id> + <goals> + <goal>jar</goal> + </goals> + <configuration> + <finalName>${project.artifactId}</finalName> + <archive> + <manifest> + <addClasspath>true</addClasspath> + <classpathPrefix>${project.artifactId}.lib/</classpathPrefix> + <mainClass>org.apache.james.JpaToPgCoreDataMigration</mainClass> + <useUniqueVersions>false</useUniqueVersions> + </manifest> + </archive> + </configuration> + </execution> + <execution> + <id>test-jar</id> + <goals> + <goal>test-jar</goal> + </goals> + </execution> + </executions> + </plugin> + + <plugin> + <groupId>com.google.cloud.tools</groupId> + <artifactId>jib-maven-plugin</artifactId> + <configuration> + <from> + <image>eclipse-temurin:21-jre-jammy</image> + </from> + <to> + <image>apache/james</image> + <tags> + <tag>migration/core-data-jpa-to-pg</tag> + </tags> + </to> + <container> + <mainClass>org.apache.james.JpaToPgCoreDataMigration</mainClass> + <extraClasspath>/root/extensions-jars/*</extraClasspath> + <appRoot>/root</appRoot> + <jvmFlags> + <jvmFlag>-Dlogback.configurationFile=/root/conf/logback.xml</jvmFlag> + <jvmFlag>-Dworking.directory=/root/</jvmFlag> + <!-- Prevents Logjam (CVE-2015-4000) --> + <jvmFlag>-Djdk.tls.ephemeralDHKeySize=2048</jvmFlag> + <jvmFlag>-Dextra.props=/root/conf/jvm.properties</jvmFlag> + </jvmFlags> + <creationTime>USE_CURRENT_TIMESTAMP</creationTime> + <volumes> + <volume>/logs</volume> + <volume>/root/conf</volume> + </volumes> + </container> + <extraDirectories> + <paths> + <path> + <from>sample-configuration</from> + <into>/root/conf</into> + </path> + <path> + <from>src/main/scripts</from> + <into>/usr/bin</into> + </path> + </paths> + <permissions> + <permission> + <file>/usr/bin/james-migration</file> + <mode>755</mode> + <!-- Read/write/execute for owner, read/execute for group/other --> + </permission> + </permissions> + </extraDirectories> + </configuration> + <executions> + <execution> + <goals> + <goal>buildTar</goal> + </goals> + <phase>package</phase> + </execution> + </executions> + </plugin> + </plugins> + </build> +</project> diff --git a/server/apps/migration/core-data-jpa-to-pg/sample-configuration/blob.properties b/server/apps/migration/core-data-jpa-to-pg/sample-configuration/blob.properties new file mode 100644 index 0000000000..3a01ce1e91 --- /dev/null +++ b/server/apps/migration/core-data-jpa-to-pg/sample-configuration/blob.properties @@ -0,0 +1,66 @@ +# ============================================= BlobStore Implementation ================================== +# Read https://james.apache.org/server/config-blobstore.html for further details + +# Choose your BlobStore implementation +# Mandatory, allowed values are: file, s3, postgres. +implementation=postgres + +# ========================================= Deduplication ======================================== +# If you choose to enable deduplication, the mails with the same content will be stored only once. +# Warning: Once this feature is enabled, there is no turning back as turning it off will lead to the deletion of all +# the mails sharing the same content once one is deleted. +# Mandatory, Allowed values are: true, false +deduplication.enable=true + +# deduplication.family needs to be incremented every time the deduplication.generation.duration is changed +# Positive integer, defaults to 1 +# deduplication.gc.generation.family=1 + +# Duration of generation. +# Deduplication only takes place within a singe generation. +# Only items two generation old can be garbage collected. (This prevent concurrent insertions issues and +# accounts for a clock skew). +# deduplication.family needs to be incremented everytime this parameter is changed. +# Duration. Default unit: days. Defaults to 30 days. +# deduplication.gc.generation.duration=30days + +# ========================================= Encryption ======================================== +# If you choose to enable encryption, the blob content will be encrypted before storing them in the BlobStore. +# Warning: Once this feature is enabled, there is no turning back as turning it off will lead to all content being +# encrypted. This comes at a performance impact but presents you from leaking data if, for instance the third party +# offering you a S3 service is compromised. +# Optional, Allowed values are: true, false, defaults to false +encryption.aes.enable=false + +# Mandatory (if AES encryption is enabled) salt and password. Salt needs to be an hexadecimal encoded string +#encryption.aes.password=xxx +#encryption.aes.salt=73616c7479 +# Optional, defaults to PBKDF2WithHmacSHA512 +#encryption.aes.private.key.algorithm=PBKDF2WithHmacSHA512 + +# ============================================ Blobs Exporting ============================================== +# Read https://james.apache.org/server/config-blob-export.html for further details + +# Choosing blob exporting mechanism, allowed mechanism are: localFile, linshare +# LinShare is a file sharing service, will be explained in the below section +# Optional, default is localFile +blob.export.implementation=localFile + +# ======================================= Local File Blobs Exporting ======================================== +# Optional, directory to store exported blob, directory path follows James file system format +# default is file://var/blobExporting +blob.export.localFile.directory=file://var/blobExporting + +# ======================================= LinShare File Blobs Exporting ======================================== +# LinShare is a sharing service where you can use james, connects to an existing LinShare server and shares files to +# other mail addresses as long as those addresses available in LinShare. For example you can deploy James and LinShare +# sharing the same LDAP repository +# Mandatory if you choose LinShare, url to connect to LinShare service +# blob.export.linshare.url=http://linshare:8080 + +# ======================================= LinShare Configuration BasicAuthentication =================================== +# Authentication is mandatory if you choose LinShare, TechnicalAccount is need to connect to LinShare specific service. +# For Example: It will be formalized to 'Authorization: Basic {Credential of UUID/password}' + +# blob.export.linshare.technical.account.uuid=Technical_Account_UUID +# blob.export.linshare.technical.account.password=password diff --git a/server/apps/migration/core-data-jpa-to-pg/sample-configuration/james-database.properties b/server/apps/migration/core-data-jpa-to-pg/sample-configuration/james-database.properties new file mode 100644 index 0000000000..fbdcb18ca6 --- /dev/null +++ b/server/apps/migration/core-data-jpa-to-pg/sample-configuration/james-database.properties @@ -0,0 +1,45 @@ +# 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. + +# This template file can be used as example for James Server configuration +# DO NOT USE IT AS SUCH AND ADAPT IT TO YOUR NEEDS + +# Read https://james.apache.org/server/config-system.html#james-database.properties for further details + +# Use derby as default +database.driverClassName=org.apache.derby.jdbc.EmbeddedDriver +database.url=jdbc:derby:../var/store/derby;create=true +#database.url=${env:DATABASE_URL} +database.username=app +#database.username=${env:DATABASE_USERNAME} +database.password=app +#database.password=${env:DATABASE_PASSWORD} + +# Use streaming for Blobs +# This is only supported on a limited set of databases atm. You should check if its supported by your DB before enable +# it. +# +# See: +# http://openjpa.apache.org/builds/latest/docs/manual/ref_guide_mapping_jpa.html #7.11. LOB Streaming +# +#openjpa.streaming=false + +# Validate the data source before using it +# datasource.testOnBorrow=true +# datasource.validationQueryTimeoutSec=2 +# This is different per database. See https://stackoverflow.com/questions/10684244/dbcp-validationquery-for-different-databases#10684260 +# datasource.validationQuery=select 1 diff --git a/server/apps/migration/core-data-jpa-to-pg/sample-configuration/logback.xml b/server/apps/migration/core-data-jpa-to-pg/sample-configuration/logback.xml new file mode 100644 index 0000000000..85c261041b --- /dev/null +++ b/server/apps/migration/core-data-jpa-to-pg/sample-configuration/logback.xml @@ -0,0 +1,39 @@ +<?xml version="1.0" encoding="UTF-8"?> +<configuration> + + <contextListener class="ch.qos.logback.classic.jul.LevelChangePropagator"> + <resetJUL>true</resetJUL> + </contextListener> + + <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender"> + <encoder> + <pattern>%d{HH:mm:ss.SSS} %highlight([%-5level]) %logger{15} - %msg%n%rEx</pattern> + <immediateFlush>false</immediateFlush> + </encoder> + </appender> + + <appender name="LOG_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> + <file>/logs/james.log</file> + <rollingPolicy class="ch.qos.logback.core.rolling.FixedWindowRollingPolicy"> + <fileNamePattern>/logs/james.%i.log.tar.gz</fileNamePattern> + <minIndex>1</minIndex> + <maxIndex>3</maxIndex> + </rollingPolicy> + + <triggeringPolicy class="ch.qos.logback.core.rolling.SizeBasedTriggeringPolicy"> + <maxFileSize>100MB</maxFileSize> + </triggeringPolicy> + + <encoder> + <pattern>%d{HH:mm:ss.SSS} [%-5level] %logger{15} - %msg%n%rEx</pattern> + <immediateFlush>false</immediateFlush> + </encoder> + </appender> + <root level="WARN"> + <appender-ref ref="CONSOLE" /> + <appender-ref ref="LOG_FILE" /> + </root> + + <logger name="org.apache.james" level="INFO" /> + +</configuration> diff --git a/server/apps/migration/core-data-jpa-to-pg/sample-configuration/postgres.properties b/server/apps/migration/core-data-jpa-to-pg/sample-configuration/postgres.properties new file mode 100644 index 0000000000..99f4eba218 --- /dev/null +++ b/server/apps/migration/core-data-jpa-to-pg/sample-configuration/postgres.properties @@ -0,0 +1,51 @@ +# String. Optional, default to 'postgres'. Database name. +database.name=james +#database.name=${env:POSTGRESQL_DATABASE_NAME} + +# String. Optional, default to 'public'. Database schema. +database.schema=public +#database.schema=${env:POSTGRESQL_DATABASE_SCHEMA} + +# String. Optional, default to 'localhost'. Database host. +database.host=postgres +#database.host=${env:POSTGRESQL_HOST} + +# Integer. Optional, default to 5432. Database port. +database.port=5432 +#database.port=${env:POSTGRESQL_PORT} + +# String. Required. Database username. +database.username=james +#database.username=${env:POSTGRESQL_USERNAME} + +# String. Required. Database password of the user. +database.password=secret1 +#database.password=${env:POSTGRESQL_PASSWORD} + +# Boolean. Optional, default to false. Whether to enable row level security. +row.level.security.enabled=false + +# String. It is required when row.level.security.enabled is true. Database username with the permission of bypassing RLS. +#database.by-pass-rls.username=bypassrlsjames + +# String. It is required when row.level.security.enabled is true. Database password of by-pass-rls user. +#database.by-pass-rls.password=secret1 + +# Integer. Optional, default to 10. Database connection pool initial size. +pool.initial.size=10 + +# Integer. Optional, default to 15. Database connection pool max size. +pool.max.size=15 + +# Integer. Optional, default to 5. rls-bypass database connection pool initial size. +by-pass-rls.pool.initial.size=5 + +# Integer. Optional, default to 10. rls-bypass database connection pool max size. +by-pass-rls.pool.max.size=10 + +# String. Optional, defaults to allow. SSLMode required to connect to the Postgresql db server. +# Check https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-PROTECTION for a list of supported SSLModes. +ssl.mode=allow + +## Duration. Optional, defaults to 10 second. jOOQ reactive timeout when executing Postgres query. This setting prevent jooq reactive bug from causing hanging issue. +#jooq.reactive.timeout=10second \ No newline at end of file diff --git a/server/apps/migration/core-data-jpa-to-pg/src/main/java/org/apache/james/JpaToPgCoreDataMigration.java b/server/apps/migration/core-data-jpa-to-pg/src/main/java/org/apache/james/JpaToPgCoreDataMigration.java new file mode 100644 index 0000000000..8590affead --- /dev/null +++ b/server/apps/migration/core-data-jpa-to-pg/src/main/java/org/apache/james/JpaToPgCoreDataMigration.java @@ -0,0 +1,504 @@ +/**************************************************************** + * 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.apache.james; + +import static org.apache.james.modules.blobstore.BlobStoreModulesChooser.chooseBlobStoreDAOModule; +import static org.apache.james.modules.blobstore.BlobStoreModulesChooser.chooseEncryptionModule; +import static org.apache.james.modules.blobstore.BlobStoreModulesChooser.chooseStoragePolicyModule; + +import java.util.List; +import java.util.Set; +import java.util.UUID; + +import jakarta.inject.Inject; +import jakarta.persistence.EntityManagerFactory; + +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.core.Username; +import org.apache.james.domainlist.api.DomainListException; +import org.apache.james.domainlist.jpa.JPADomainList; +import org.apache.james.domainlist.lib.DomainListConfiguration; +import org.apache.james.domainlist.postgres.PostgresDomainList; +import org.apache.james.droplists.jpa.JPADropList; +import org.apache.james.droplists.postgres.PostgresDropList; +import org.apache.james.filesystem.api.FileSystem; +import org.apache.james.mailrepository.api.MailKey; +import org.apache.james.mailrepository.api.MailRepository; +import org.apache.james.mailrepository.api.MailRepositoryUrl; +import org.apache.james.mailrepository.api.MailRepositoryUrlStore; +import org.apache.james.mailrepository.jpa.JPAMailRepositoryFactory; +import org.apache.james.mailrepository.jpa.JPAMailRepositoryUrlStore; +import org.apache.james.mailrepository.postgres.PostgresMailRepositoryContentDAO; +import org.apache.james.mailrepository.postgres.PostgresMailRepositoryUrlStore; +import org.apache.james.modules.ClockModule; +import org.apache.james.modules.blobstore.BlobStoreCacheModulesChooser; +import org.apache.james.modules.blobstore.BlobStoreConfiguration; +import org.apache.james.modules.data.JPAEntityManagerModule; +import org.apache.james.modules.data.PostgresCommonModule; +import org.apache.james.modules.data.PostgresDomainListModule; +import org.apache.james.modules.data.PostgresDropListsModule; +import org.apache.james.modules.data.PostgresQuotaGuiceModule; +import org.apache.james.modules.data.PostgresRecipientRewriteTableModule; +import org.apache.james.modules.data.PostgresUsersRepositoryModule; +import org.apache.james.modules.data.SievePostgresRepositoryModules; +import org.apache.james.modules.server.DNSServiceModule; +import org.apache.james.modules.server.DropWizardMetricsModule; +import org.apache.james.rrt.api.RecipientRewriteTableException; +import org.apache.james.rrt.jpa.JPARecipientRewriteTable; +import org.apache.james.rrt.postgres.PostgresRecipientRewriteTable; +import org.apache.james.server.core.configuration.Configuration; +import org.apache.james.server.core.configuration.ConfigurationProvider; +import org.apache.james.server.core.configuration.FileConfigurationProvider; +import org.apache.james.server.core.filesystem.FileSystemImpl; +import org.apache.james.sieve.jpa.JPASieveRepository; +import org.apache.james.sieve.jpa.model.JPASieveScript; +import org.apache.james.sieve.postgres.PostgresSieveRepository; +import org.apache.james.sieverepository.api.ScriptContent; +import org.apache.james.sieverepository.api.ScriptName; +import org.apache.james.user.api.AlreadyExistInUsersRepositoryException; +import org.apache.james.user.api.UsersRepositoryException; +import org.apache.james.user.jpa.JPAUsersDAO; +import org.apache.james.user.jpa.model.JPAUser; +import org.apache.james.user.lib.model.Algorithm; +import org.apache.james.user.lib.model.DefaultUser; +import org.apache.james.user.postgres.PostgresUsersDAO; +import org.apache.james.user.postgres.PostgresUsersRepositoryConfiguration; +import org.apache.james.util.LoggingLevel; +import org.apache.james.utils.InitializationOperation; +import org.apache.james.utils.InitializationOperations; +import org.apache.james.utils.InitilizationOperationBuilder; +import org.apache.james.utils.PropertiesProvider; +import org.apache.mailet.Mail; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.github.fge.lambdas.Throwing; +import com.google.common.collect.ImmutableList; +import com.google.inject.AbstractModule; +import com.google.inject.Guice; +import com.google.inject.Injector; +import com.google.inject.Module; +import com.google.inject.Provides; +import com.google.inject.Scopes; +import com.google.inject.Singleton; +import com.google.inject.multibindings.Multibinder; +import com.google.inject.multibindings.ProvidesIntoSet; +import com.google.inject.util.Modules; + +public class JpaToPgCoreDataMigration { + private static final Logger LOGGER = LoggerFactory.getLogger(JpaToPgCoreDataMigration.class); + + private static final Module JPA_MODULES = Modules.combine( + new JPAEntityManagerModule(), + new UnboundJPAMigrationModule() + ); + private static final Module POSTGRESQL_MODULES = Modules.combine( + new PostgresCommonModule(), + new PostgresDomainListModule(), + new PostgresRecipientRewriteTableModule(), + new PostgresUsersRepositoryModule(), + new org.apache.james.modules.data.PostgresMailRepositoryModule(), + new PostgresDropListsModule(), + new PostgresQuotaGuiceModule(), + new SievePostgresRepositoryModules() + ); + + static final Module MIGRATION_MODULES = Modules.combine( + new DNSServiceModule(), + new DropWizardMetricsModule(), + new ClockModule(), + new CoreEntityValidatorsModule(), + PostgresUsersRepositoryModule.USER_CONFIGURATION_MODULE, + JPA_MODULES, + POSTGRESQL_MODULES + ); + + private final Injector injector; + + public static void main(String[] args) { + MigrationConfiguration configuration = MigrationConfiguration.builder() + .useWorkingDirectoryEnvProperty() + .build(); + LOGGER.info("Loading configuration {}", configuration.toString()); + var blobstoreModule = Modules.combine(chooseBlobStoreModules(configuration)); + var module = Modules.combine( + new MigrationModule(configuration), + MIGRATION_MODULES, + blobstoreModule + ); + + + JpaToPgCoreDataMigration migration = new JpaToPgCoreDataMigration(module); + migration.start(); + } + + static List<Module> chooseBlobStoreModules(MigrationConfiguration configuration) { + ImmutableList.Builder<Module> builder = ImmutableList.<Module>builder() + .addAll(chooseModules(configuration.blobStoreConfiguration())) + .add(new BlobStoreCacheModulesChooser.CacheDisabledModule()); + + + return builder.build(); + } + + public static List<Module> chooseModules(BlobStoreConfiguration choosingConfiguration) { + return ImmutableList.<Module>builder() + .add(chooseEncryptionModule(choosingConfiguration.getCryptoConfig())) + .add(chooseBlobStoreDAOModule(choosingConfiguration.getImplementation())) + .addAll(chooseStoragePolicyModule(choosingConfiguration.storageStrategy())) + .add(binder -> binder.bind(BlobStoreConfiguration.class).toInstance(choosingConfiguration)) + .build(); + } + + static class DomainMigration { + private static final Logger LOGGER = LoggerFactory.getLogger(DomainMigration.class); + + private final JPADomainList jpaDomainList; + private final PostgresDomainList pgDomainList; + + @Inject + DomainMigration( + JPADomainList jpaDomainList, + PostgresDomainList pgDomainList + ) { + this.jpaDomainList = jpaDomainList; + this.pgDomainList = pgDomainList; + } + + void doMigration() { + LOGGER.info("Start domains migration"); + try { + jpaDomainList.getDomains().forEach(domain -> { + try { + pgDomainList.addDomain(domain); + } catch (DomainListException e) { + if (!e.getMessage().contains("already exists.")) { + LOGGER.warn("Failed to migrate domain {}", domain, e); + } + } + } + ); + + } catch (DomainListException e) { + throw new RuntimeException("Unable to migrate domains, aborting processing", e); + } + LOGGER.info("Domains migration completed"); + } + } + + static class UsersMigration { + private static final Logger LOGGER = LoggerFactory.getLogger(UsersMigration.class); + private final Algorithm algorithm; + private final JPAUsersDAO jpaUsersDAO; + private final PostgresUsersDAO postgresUsersDAO; + + + @Inject + UsersMigration( + JPAUsersDAO jpaUsersDAO, + PostgresUsersDAO postgresUsersDAO, + PostgresUsersRepositoryConfiguration postgresUsersRepositoryConfiguration + ) { + this.algorithm = postgresUsersRepositoryConfiguration.getPreferredAlgorithm(); + this.jpaUsersDAO = jpaUsersDAO; + this.postgresUsersDAO = postgresUsersDAO; + } + + void doMigration() { + LOGGER.info("Start users migration"); + try { + jpaUsersDAO.list().forEachRemaining(username -> { + try { + jpaUsersDAO.getUserByName(username).ifPresent(user -> { + var jpaUser = (JPAUser) user; + var pgUser = new DefaultUser( + username, + jpaUser.getPasswordHash(), + jpaUser.getAlgorithm(), + algorithm + ); + try { + postgresUsersDAO.addUser(username, UUID.randomUUID().toString()); + } catch (RuntimeException e) { + if (!(e.getCause() instanceof AlreadyExistInUsersRepositoryException)) { + throw e; + } + // Idempotent behavior, if the user already exists, we + // only update it. + } + Throwing.runnable(() -> + postgresUsersDAO.updateUser(pgUser) + ).sneakyThrow().run(); + + }); + } catch (UsersRepositoryException e) { + LOGGER.warn("Failed to migrate user {}", username, e); + } + } + ); + } catch (UsersRepositoryException e) { + throw new RuntimeException("Failed to retrieve users for migration", e); + } + LOGGER.info("Users migration complete"); + } + } + + static class RRTMigration { + private static final Logger LOGGER = LoggerFactory.getLogger(RRTMigration.class); + + private final JPARecipientRewriteTable jpaRRTRepository; + private final PostgresRecipientRewriteTable pgRRTRepository; + + @Inject + RRTMigration( + JPARecipientRewriteTable jpaRRTRepository, + PostgresRecipientRewriteTable pgRRTRepository + ) { + this.jpaRRTRepository = jpaRRTRepository; + this.pgRRTRepository = pgRRTRepository; + } + + void doMigration() { + LOGGER.info("Start RRT migration"); + try { + jpaRRTRepository.getAllMappings().forEach((mappingSource, mappings) -> + mappings.forEach(mapping -> + pgRRTRepository.addMapping(mappingSource, mapping) + ) + ); + } catch (RecipientRewriteTableException e) { + throw new RuntimeException(e); + } + LOGGER.info("RRT migration complete"); + } + } + + static class MailRepositoryMigration { + private static final Logger LOGGER = LoggerFactory.getLogger(MailRepositoryMigration.class); + + private final JPAMailRepositoryUrlStore jpaMailRepositoryUrlStore; + private final JPAMailRepositoryFactory jpaMailRepositoryFactory; + private final PostgresMailRepositoryUrlStore pgMailRepositoryUrlStore; + private final PostgresMailRepositoryContentDAO postgresMailRepositoryContentDAO; + + @Inject + MailRepositoryMigration( + JPAMailRepositoryUrlStore jpaMailRepositoryUrlStore, + JPAMailRepositoryFactory jpaMailRepositoryFactory, + PostgresMailRepositoryUrlStore pgMailRepositoryUrlStore, + PostgresMailRepositoryContentDAO postgresMailRepositoryContentDAO + ) { + this.jpaMailRepositoryUrlStore = jpaMailRepositoryUrlStore; + this.jpaMailRepositoryFactory = jpaMailRepositoryFactory; + this.pgMailRepositoryUrlStore = pgMailRepositoryUrlStore; + this.postgresMailRepositoryContentDAO = postgresMailRepositoryContentDAO; + } + + void doMigration() { + LOGGER.info("Start mail repository urls migration"); + jpaMailRepositoryUrlStore.listDistinct().forEach( + Throwing.consumer((MailRepositoryUrl url) -> { + pgMailRepositoryUrlStore.add(url); + MailRepository mailRepository = jpaMailRepositoryFactory.create(url); + mailRepository.list().forEachRemaining( + Throwing.consumer((MailKey mailKey) -> { + Mail jpaMail = mailRepository.retrieve(mailKey); + postgresMailRepositoryContentDAO.store(jpaMail, url); + }) + ); + }).sneakyThrow() + ); + LOGGER.info("Mail repository urls migration complete"); + } + } + + static class DropListMigration { + private static final Logger LOGGER = + LoggerFactory.getLogger(MailRepositoryMigration.class); + + private final JPADropList jpaDropList; + private final PostgresDropList pgDropList; + + @Inject + DropListMigration( + JPADropList jpaDropList, + PostgresDropList pgDropList + ) { + this.jpaDropList = jpaDropList; + this.pgDropList = pgDropList; + } + + void doMigration() { + LOGGER.info("Start drop list migration"); + jpaDropList.listAll().flatMap(pgDropList::add).count().block(); + LOGGER.info("Mail drop list migration complete"); + } + } + + static class SieveMigration { + private static final Logger LOGGER = + LoggerFactory.getLogger(MailRepositoryMigration.class); + + private final JPASieveRepository jpaSieveRepository; + private final PostgresSieveRepository pgSieveRepository; + + @Inject + SieveMigration( + JPASieveRepository jpaSieveRepository, + PostgresSieveRepository pgSieveRepository + ) { + this.jpaSieveRepository = jpaSieveRepository; + this.pgSieveRepository = pgSieveRepository; + } + + void doMigration() { + LOGGER.info("Start Sieve quotas migration"); + jpaSieveRepository.listAllSieveQuotas().forEach(quota -> + pgSieveRepository.setQuota(quota.getUsername(), quota.toQuotaSize()) + ); + LOGGER.info("Mail Sieve quotas migration complete"); + LOGGER.info("Start Sieve scripts migration"); + jpaSieveRepository.listAllSieveScripts().forEach( + Throwing.consumer((JPASieveScript sieveScript) -> pgSieveRepository.putScript( + Username.of(sieveScript.getUsername()), + new ScriptName(sieveScript.getScriptName()), + new ScriptContent(sieveScript.getScriptContent()) + )).sneakyThrow() + ); + LOGGER.info("Mail Sieve scripts migration complete"); + } + } + + static class MigrationModule extends AbstractModule { + private final Configuration configuration; + private final FileSystemImpl fileSystem; + + @Inject + public MigrationModule(Configuration configuration) { + this.configuration = configuration; + this.fileSystem = new FileSystemImpl(configuration.directories()); + } + + @Override + protected void configure() { + bind(FileSystem.class).toInstance(fileSystem); + bind(Configuration.class).toInstance(configuration); + + bind(ConfigurationProvider.class).toInstance(new FileConfigurationProvider(fileSystem, configuration)); + bind(DomainMigration.class).in(Scopes.SINGLETON); + bind(UsersMigration.class).in(Scopes.SINGLETON); + bind(RRTMigration.class).in(Scopes.SINGLETON); + bind(MailRepositoryMigration.class).in(Scopes.SINGLETON); + } + + @Provides + @jakarta.inject.Singleton + public Configuration.ConfigurationPath configurationPath() { + return configuration.configurationPath(); + } + + @Provides + @jakarta.inject.Singleton + public PropertiesProvider providePropertiesProvider(FileSystem fileSystem, Configuration.ConfigurationPath configurationPrefix) { + return new PropertiesProvider(fileSystem, configurationPrefix); + } + + @Provides + @Singleton + DomainListConfiguration domainListConfiguration() { + return DomainListConfiguration.DEFAULT; + } + } + + static class UnboundJPAMigrationModule extends AbstractModule { + @Override + protected void configure() { + bind(JPADomainList.class).in(Scopes.SINGLETON); + bind(JPARecipientRewriteTable.class).in(Scopes.SINGLETON); + bind(JPAMailRepositoryUrlStore.class).in(Scopes.SINGLETON); + bind(JPAUsersDAO.class).in(Scopes.SINGLETON); + } + + @ProvidesIntoSet + InitializationOperation configureDomainList(DomainListConfiguration configuration, JPADomainList jpaDomainList) { + return InitilizationOperationBuilder + .forClass(JPADomainList.class) + .init(() -> jpaDomainList.configure(configuration)); + } + + @ProvidesIntoSet + InitializationOperation configureJpaUsers( + EntityManagerFactory entityManagerFactory, + ConfigurationProvider configurationProvider, + JPAUsersDAO usersDAO + ) { + return InitilizationOperationBuilder + .forClass(JPAUsersDAO.class) + .init(() -> { + usersDAO.configure(configurationProvider.getConfiguration("usersrepository", LoggingLevel.INFO)); + usersDAO.setEntityManagerFactory(entityManagerFactory); + usersDAO.init(); + }); + } + } + + static class CoreEntityValidatorsModule extends AbstractModule { + @Override + protected void configure() { + Multibinder.newSetBinder(binder(), UserEntityValidator.class).addBinding().to(DefaultUserEntityValidator.class); + Multibinder.newSetBinder(binder(), UserEntityValidator.class).addBinding().to(RecipientRewriteTableUserEntityValidator.class); + } + + @Provides + @Singleton + UserEntityValidator userEntityValidator(Set<UserEntityValidator> validatorSet) { + return new AggregateUserEntityValidator(validatorSet); + } + } + + static class PostgresMailRepositoryModule extends AbstractModule { + @Override + protected void configure() { + bind(PostgresMailRepositoryUrlStore.class).in(Scopes.SINGLETON); + bind(MailRepositoryUrlStore.class).to(PostgresMailRepositoryUrlStore.class); + Multibinder.newSetBinder(binder(), PostgresModule.class) + .addBinding().toInstance(org.apache.james.mailrepository.postgres.PostgresMailRepositoryModule.MODULE); + } + } + + Injector getInjector() { + return injector; + } + + public JpaToPgCoreDataMigration(Module module) { + this.injector = Guice.createInjector(module); + injector.getInstance(InitializationOperations.class).initModules(); + } + + void start() { + injector.getInstance(DomainMigration.class).doMigration(); + injector.getInstance(UsersMigration.class).doMigration(); + injector.getInstance(RRTMigration.class).doMigration(); + injector.getInstance(MailRepositoryMigration.class).doMigration(); + injector.getInstance(DropListMigration.class).doMigration(); + injector.getInstance(SieveMigration.class).doMigration(); + } +} + diff --git a/server/apps/migration/core-data-jpa-to-pg/src/main/java/org/apache/james/MigrationConfiguration.java b/server/apps/migration/core-data-jpa-to-pg/src/main/java/org/apache/james/MigrationConfiguration.java new file mode 100644 index 0000000000..00f108cdfa --- /dev/null +++ b/server/apps/migration/core-data-jpa-to-pg/src/main/java/org/apache/james/MigrationConfiguration.java @@ -0,0 +1,124 @@ +/**************************************************************** + * 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.apache.james; + +import java.io.File; +import java.util.Optional; + +import org.apache.james.filesystem.api.FileSystem; +import org.apache.james.filesystem.api.JamesDirectoriesProvider; +import org.apache.james.modules.blobstore.BlobStoreConfiguration; +import org.apache.james.server.core.JamesServerResourceLoader; +import org.apache.james.server.core.MissingArgumentException; +import org.apache.james.server.core.configuration.Configuration; +import org.apache.james.server.core.filesystem.FileSystemImpl; +import org.apache.james.utils.PropertiesProvider; + +import com.github.fge.lambdas.Throwing; +import com.google.common.base.MoreObjects; + +public record MigrationConfiguration( + ConfigurationPath configurationPath, + JamesDirectoriesProvider directories, + BlobStoreConfiguration blobStoreConfiguration +) implements Configuration { + private static final BlobStoreConfiguration.BlobStoreImplName DEFAULT_BLOB_STORE = BlobStoreConfiguration.BlobStoreImplName.POSTGRES; + + public static class Builder { + private Optional<String> rootDirectory; + private Optional<ConfigurationPath> configurationPath; + private Optional<BlobStoreConfiguration> blobStoreConfiguration; + + private Builder() { + rootDirectory = Optional.empty(); + configurationPath = Optional.empty(); + } + + public Builder workingDirectory(String path) { + rootDirectory = Optional.of(path); + return this; + } + + public Builder workingDirectory(File file) { + rootDirectory = Optional.of(file.getAbsolutePath()); + return this; + } + + public Builder useWorkingDirectoryEnvProperty() { + rootDirectory = Optional.ofNullable(System.getProperty(WORKING_DIRECTORY)); + if (rootDirectory.isEmpty()) { + throw new MissingArgumentException("Server needs a working.directory env entry"); + } + return this; + } + + public Builder configurationPath(ConfigurationPath path) { + configurationPath = Optional.of(path); + return this; + } + + public Builder configurationFromClasspath() { + configurationPath = Optional.of(new ConfigurationPath(FileSystem.CLASSPATH_PROTOCOL)); + return this; + } + + + public MigrationConfiguration build() { + ConfigurationPath configurationPath = this.configurationPath.orElse(new ConfigurationPath(FileSystem.FILE_PROTOCOL_AND_CONF)); + JamesServerResourceLoader directories = new JamesServerResourceLoader(rootDirectory + .orElseThrow(() -> new MissingArgumentException("Server needs a working.directory env entry"))); + FileSystemImpl fileSystem = new FileSystemImpl(directories); + PropertiesProvider propertiesProvider = new PropertiesProvider(fileSystem, configurationPath); + BlobStoreConfiguration blobStoreConfiguration = this.blobStoreConfiguration.orElseGet( + Throwing.supplier( + () -> BlobStoreConfiguration.parse(propertiesProvider, DEFAULT_BLOB_STORE) + ) + ); + return new MigrationConfiguration(configurationPath, directories, blobStoreConfiguration); + } + + public Builder blobStore(BlobStoreConfiguration blobStoreConfiguration) { + this.blobStoreConfiguration = Optional.of(blobStoreConfiguration); + return this; + } + } + + static MigrationConfiguration.Builder builder() { + return new Builder(); + } + + @Override + public ConfigurationPath configurationPath() { + return configurationPath; + } + + @Override + public JamesDirectoriesProvider directories() { + return directories; + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("configurationPath", configurationPath) + .add("directories", directories) + .toString(); + } +} diff --git a/server/apps/migration/core-data-jpa-to-pg/src/main/resources/META-INF/persistence.xml b/server/apps/migration/core-data-jpa-to-pg/src/main/resources/META-INF/persistence.xml new file mode 100644 index 0000000000..068e05e04b --- /dev/null +++ b/server/apps/migration/core-data-jpa-to-pg/src/main/resources/META-INF/persistence.xml @@ -0,0 +1,44 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + 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. +--> + +<persistence xmlns="http://java.sun.com/xml/ns/persistence" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://java.sun.com/xml/ns/persistence http://java.sun.com/xml/ns/persistence/persistence_2_0.xsd" + version="2.0"> + + <persistence-unit name="Global" transaction-type="RESOURCE_LOCAL"> + <class>org.apache.james.domainlist.jpa.model.JPADomain</class> + <class>org.apache.james.mailrepository.jpa.model.JPAUrl</class> + <class>org.apache.james.mailrepository.jpa.model.JPAMail</class> + <class>org.apache.james.user.jpa.model.JPAUser</class> + <class>org.apache.james.rrt.jpa.model.JPARecipientRewrite</class> + <class>org.apache.james.droplists.jpa.model.JPADropListEntry</class> + <class>org.apache.james.sieve.jpa.model.JPASieveScript</class> + <class>org.apache.james.sieve.jpa.model.JPASieveQuota</class> + <properties> + <property name="openjpa.jdbc.SynchronizeMappings" value="buildSchema(ForeignKeys=true)"/> + <property name="openjpa.jdbc.MappingDefaults" value="ForeignKeyDeleteAction=cascade, JoinForeignKeyDeleteAction=cascade"/> + <property name="openjpa.jdbc.SchemaFactory" value="native(ForeignKeys=true)"/> + <property name="openjpa.jdbc.QuerySQLCache" value="false"/> + </properties> + + </persistence-unit> + +</persistence> diff --git a/server/apps/migration/core-data-jpa-to-pg/src/main/scripts/james-migration b/server/apps/migration/core-data-jpa-to-pg/src/main/scripts/james-migration new file mode 100644 index 0000000000..b4bebfb9cc --- /dev/null +++ b/server/apps/migration/core-data-jpa-to-pg/src/main/scripts/james-migration @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +unset JAVA_TOOL_OPTIONS +java -cp /root/resources:/root/classes:/root/libs/* \ +-Dworking.directory=/root/conf \ +-Dlogback.configurationFile=/root/conf/logback.xml \ +org.apache.james.JpaToPgCoreDataMigration "$@" \ No newline at end of file diff --git a/server/apps/migration/core-data-jpa-to-pg/src/test/java/org/apache/james/JpaToPgCoreDataMigrationTest.java b/server/apps/migration/core-data-jpa-to-pg/src/test/java/org/apache/james/JpaToPgCoreDataMigrationTest.java new file mode 100644 index 0000000000..6993f9481b --- /dev/null +++ b/server/apps/migration/core-data-jpa-to-pg/src/test/java/org/apache/james/JpaToPgCoreDataMigrationTest.java @@ -0,0 +1,354 @@ +/**************************************************************** + * 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.apache.james; + +import static java.util.UUID.randomUUID; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.temporal.ChronoUnit; +import java.util.Date; +import java.util.List; +import java.util.UUID; + +import jakarta.mail.MessagingException; +import jakarta.mail.internet.AddressException; + +import org.apache.commons.io.IOUtils; +import org.apache.commons.lang3.RandomUtils; +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.core.Domain; +import org.apache.james.core.Username; +import org.apache.james.core.builder.MimeMessageBuilder; +import org.apache.james.core.quota.QuotaSizeLimit; +import org.apache.james.domainlist.api.DomainListException; +import org.apache.james.domainlist.jpa.JPADomainList; +import org.apache.james.domainlist.postgres.PostgresDomainList; +import org.apache.james.droplists.api.DropListEntry; +import org.apache.james.droplists.api.OwnerScope; +import org.apache.james.droplists.jpa.JPADropList; +import org.apache.james.droplists.postgres.PostgresDropList; +import org.apache.james.mailrepository.api.MailKey; +import org.apache.james.mailrepository.api.MailRepository; +import org.apache.james.mailrepository.api.MailRepositoryUrl; +import org.apache.james.mailrepository.jpa.JPAMailRepositoryFactory; +import org.apache.james.mailrepository.jpa.JPAMailRepositoryUrlStore; +import org.apache.james.mailrepository.postgres.PostgresMailRepositoryFactory; +import org.apache.james.mailrepository.postgres.PostgresMailRepositoryUrlStore; +import org.apache.james.modules.blobstore.BlobStoreConfiguration; +import org.apache.james.rrt.api.RecipientRewriteTable; +import org.apache.james.rrt.api.RecipientRewriteTableException; +import org.apache.james.rrt.jpa.JPARecipientRewriteTable; +import org.apache.james.rrt.lib.Mapping; +import org.apache.james.rrt.lib.MappingSource; +import org.apache.james.rrt.postgres.PostgresRecipientRewriteTable; +import org.apache.james.server.core.MailImpl; +import org.apache.james.sieve.jpa.JPASieveRepository; +import org.apache.james.sieve.postgres.PostgresSieveRepository; +import org.apache.james.sieverepository.api.ScriptContent; +import org.apache.james.sieverepository.api.ScriptName; +import org.apache.james.sieverepository.api.exception.QuotaExceededException; +import org.apache.james.sieverepository.api.exception.QuotaNotFoundException; +import org.apache.james.sieverepository.api.exception.ScriptNotFoundException; +import org.apache.james.sieverepository.api.exception.StorageException; +import org.apache.james.user.api.UsersRepositoryException; +import org.apache.james.user.jpa.JPAUsersDAO; +import org.apache.james.user.postgres.PostgresUsersDAO; +import org.apache.mailet.Attribute; +import org.apache.mailet.Mail; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.rules.TemporaryFolder; + +import com.github.fge.lambdas.Throwing; +import com.google.inject.Injector; +import com.google.inject.util.Modules; + +class JpaToPgCoreDataMigrationTest { + + @RegisterExtension + static MariaDBExtension mariaDBExtension = new MariaDBExtension(); + + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.empty(); + + @RegisterExtension + static TemporaryFolderRegistrableExtension folderRegistrableExtension = new TemporaryFolderRegistrableExtension(); + + record User(Username username, String password) { + static User create(Domain domain) { + return new User( + Username.of(randomUUID() + "@" + domain.asString()), + randomUUID().toString() + ); + } + } + + record RecipientRewrite(Username username, Mapping mapping) { + static RecipientRewrite create(Domain domain) { + return new RecipientRewrite( + Username.of(randomUUID() + "@" + domain.asString()), + Mapping.forward(randomUUID() + "@email.example") + ); + } + } + + record MailInRepository(MailRepositoryUrl url, Mail mail) { + static MailInRepository create(Domain domain, MailRepositoryUrl url) throws MessagingException { + return new MailInRepository( + url, + MailImpl.builder() + .name(UUID.randomUUID().toString()) + .sender(randomUUID() + "@" + domain.asString()) + .addRecipient(randomUUID() + "@" + domain.asString()) + .addRecipient(randomUUID() + "@" + domain.asString()) + .addAttribute(Attribute.convertToAttribute("attr" + randomUUID(), "value" + randomUUID())) + .mimeMessage(MimeMessageBuilder + .mimeMessageBuilder() + .addHeader(UUID.randomUUID().toString(), UUID.randomUUID().toString()) + .setSubject("test") + .setText("test" + UUID.randomUUID()) + .build()) + .state(Mail.DEFAULT) + .lastUpdated(new Date()) + .build() + ); + } + } + + record SieveEntry(Username username, QuotaSizeLimit quota, ScriptName scriptName, ScriptContent scriptContent) { + static SieveEntry create(Domain domain) { + return new SieveEntry( + Username.of(randomUUID() + "@" + domain.asString()), + QuotaSizeLimit.size(RandomUtils.nextLong()), + new ScriptName(UUID.randomUUID().toString()), + new ScriptContent(UUID.randomUUID().toString()) + ); + } + } + + private static @NotNull Domain someDomain() { + return Domain.of(randomUUID() + ".example"); + } + + private JpaToPgCoreDataMigration dataMigration; + private Injector injector; + + @BeforeEach + void setUp() throws IOException { + dataMigration = createDataMigration(); + injector = dataMigration.getInjector(); + } + + @Test + void migrate_data() throws Exception { + // Given + var domain = givenJpaDomain(); + var user = givenJpaUser(domain); + var recipientRewrite = givenJpaRecipientRewrite(domain); + var mailRepositoryUrl = givenJpaMailRepositoryUrl(); + var mail = givenJpaMailInRepository(domain, mailRepositoryUrl); + var dropListEntry = givenJpaDropList(); + var sieve = givenJpaSieveEntry(domain); + + // When + dataMigration.start(); + + // Then + verifyPgDomain(domain); + verifyPgUser(user); + verifyPgRecipientRewrite(recipientRewrite); + verifyPgRepositoryUrl(mailRepositoryUrl); + verifyPgMailInRepository(mail); + verifyPgDropList(dropListEntry); + verifyPgSieveEntry(sieve); + } + + private Domain givenJpaDomain() throws DomainListException { + var domain = Domain.of(randomUUID() + ".apache.example"); + var jpaDomainList = injector.getInstance(JPADomainList.class); + jpaDomainList.addDomain(domain); + return domain; + } + + private void verifyPgDomain(Domain domain) throws DomainListException { + var domainList = injector.getInstance(PostgresDomainList.class); + assertThat(domainList.containsDomain(domain)).isTrue(); + } + + private User givenJpaUser(Domain domain) throws UsersRepositoryException { + var user = User.create(domain); + var usersDAO = injector.getInstance(JPAUsersDAO.class); + usersDAO.addUser(user.username, user.password); + return user; + } + + private void verifyPgUser(User user) { + var usersDAO = injector.getInstance(PostgresUsersDAO.class); + var pgUser = usersDAO.getUserByName(user.username).orElseThrow(); + assertThat(pgUser.verifyPassword(user.password)).isTrue(); + } + + + private RecipientRewrite givenJpaRecipientRewrite(Domain domain) + throws RecipientRewriteTableException { + var recipientRewrite = RecipientRewrite.create(domain); + var recipientRewriteTable = injector.getInstance(JPARecipientRewriteTable.class); + recipientRewriteTable.addMapping( + MappingSource.fromUser(recipientRewrite.username), + recipientRewrite.mapping + ); + return recipientRewrite; + } + + private void verifyPgRecipientRewrite(RecipientRewrite recipientRewrite) + throws RecipientRewriteTable.ErrorMappingException, RecipientRewriteTableException { + var rewriteTable = injector.getInstance(PostgresRecipientRewriteTable.class); + var resolvedMappings = rewriteTable.getResolvedMappings( + recipientRewrite.username.getLocalPart(), + recipientRewrite.username.getDomainPart().orElseThrow() + ); + assertThat(resolvedMappings).contains(recipientRewrite.mapping); + } + + private MailRepositoryUrl givenJpaMailRepositoryUrl() { + var url = MailRepositoryUrl.from("blob://var/mail/" + randomUUID()); + var urlStore = injector.getInstance(JPAMailRepositoryUrlStore.class); + urlStore.add(url); + return url; + } + + private void verifyPgRepositoryUrl(MailRepositoryUrl url) { + var urlStore = injector.getInstance(PostgresMailRepositoryUrlStore.class); + assertThat(urlStore.contains(url)).isTrue(); + } + + private MailInRepository givenJpaMailInRepository(Domain domain, MailRepositoryUrl url) throws MessagingException { + MailInRepository mailInRepository = MailInRepository.create(domain, url); + var mail = mailInRepository.mail; + var mailRepositoryFactory = injector.getInstance(JPAMailRepositoryFactory.class); + MailRepository mailRepository = mailRepositoryFactory.create(url); + mailRepository.store(mail); + return mailInRepository; + } + + private void checkMailEquality(Mail actual, Mail expected) { + assertSoftly(Throwing.consumer(softly -> { + softly.assertThat(actual.getMessage().getContent()).isEqualTo(expected.getMessage().getContent()); + softly.assertThat(actual.getMessageSize()).isEqualTo(expected.getMessageSize()); + softly.assertThat(actual.getName()).isEqualTo(expected.getName()); + softly.assertThat(actual.getState()).isEqualTo(expected.getState()); + softly.assertThat(actual.attributes()).containsAll(expected.attributes().toList()); + softly.assertThat(actual.getErrorMessage()).isEqualTo(expected.getErrorMessage()); + softly.assertThat(actual.getRemoteHost()).isEqualTo(expected.getRemoteHost()); + softly.assertThat(actual.getRemoteAddr()).isEqualTo(expected.getRemoteAddr()); + // JPA implementation trucates the date to seconds losing precision in the process + softly.assertThat(actual.getLastUpdated().toInstant()).isEqualTo(expected.getLastUpdated().toInstant().truncatedTo(ChronoUnit.SECONDS)); + softly.assertThat(actual.getPerRecipientSpecificHeaders()).isEqualTo(expected.getPerRecipientSpecificHeaders()); + })); + } + + + private void verifyPgMailInRepository(MailInRepository mailInRepository) throws MessagingException { + var mailRepositoryFactory = injector.getInstance(PostgresMailRepositoryFactory.class); + var mailRepository = mailRepositoryFactory.create(mailInRepository.url); + + Mail retrieved = mailRepository.retrieve(MailKey.forMail(mailInRepository.mail)); + assertThat(retrieved).satisfies(actual -> checkMailEquality(actual, mailInRepository.mail)); + } + + private DropListEntry givenJpaDropList() throws AddressException { + Domain aDomain = someDomain(); + Domain anotherDomain = someDomain(); + DropListEntry entry = DropListEntry.builder() + .denyDomain(aDomain) + .denyAddress(User.create(anotherDomain).username.asMailAddress()) + .forAll() + .build(); + var dropList = injector.getInstance(JPADropList.class); + dropList.add(entry).block(); + return entry; + } + + private void verifyPgDropList(DropListEntry entry) { + var dropList = injector.getInstance(PostgresDropList.class); + List<DropListEntry> entries = + dropList.list(OwnerScope.GLOBAL, "").collectList().block(); + assertThat(entries).contains(entry); + } + + private SieveEntry givenJpaSieveEntry(Domain domain) + throws StorageException, QuotaExceededException { + var sieveEntry = SieveEntry.create(domain); + var sieveRepository = injector.getInstance(JPASieveRepository.class); + sieveRepository.setQuota(sieveEntry.username, sieveEntry.quota); + sieveRepository.putScript( + sieveEntry.username, + sieveEntry.scriptName, + sieveEntry.scriptContent + ); + return sieveEntry; + } + + private void verifyPgSieveEntry(SieveEntry sieveEntry) + throws QuotaNotFoundException, ScriptNotFoundException { + var sieveRepository = injector.getInstance(PostgresSieveRepository.class); + var quota = sieveRepository.getQuota(sieveEntry.username); + var scripts = sieveRepository.listScripts(sieveEntry.username); + var script = sieveRepository.getScript(sieveEntry.username, sieveEntry.scriptName); + + assertThat(quota).isEqualTo(sieveEntry.quota); + assertThat(scripts).hasSize(1); + assertThat(script).hasSameContentAs( + IOUtils.toInputStream(sieveEntry.scriptContent.getValue(), StandardCharsets.UTF_8) + ); + } + + private @NotNull JpaToPgCoreDataMigration createDataMigration() throws IOException { + TemporaryFolder tmpDir = folderRegistrableExtension.getTemporaryFolder(); + MigrationConfiguration configuration = MigrationConfiguration.builder() + .workingDirectory(tmpDir.newFolder()) + .configurationFromClasspath() + .blobStore(BlobStoreConfiguration.builder() + .postgres() + .disableCache() + .passthrough() + .noCryptoConfig()) + .build(); + + var blobstoreModule = Modules.combine(JpaToPgCoreDataMigration.chooseBlobStoreModules(configuration)); + var module = Modules.override( + Modules.combine( + JpaToPgCoreDataMigration.MIGRATION_MODULES, + blobstoreModule + ) + ).with( + new JpaToPgCoreDataMigration.MigrationModule(configuration), + mariaDBExtension.getModule(), + postgresExtension.getModule() + ); + return new JpaToPgCoreDataMigration(module); + } + +} \ No newline at end of file diff --git a/server/apps/migration/core-data-jpa-to-pg/src/test/java/org/apache/james/MariaDBExtension.java b/server/apps/migration/core-data-jpa-to-pg/src/test/java/org/apache/james/MariaDBExtension.java new file mode 100644 index 0000000000..7c739f6459 --- /dev/null +++ b/server/apps/migration/core-data-jpa-to-pg/src/test/java/org/apache/james/MariaDBExtension.java @@ -0,0 +1,80 @@ +/**************************************************************** + * 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.apache.james; + +import java.util.UUID; + +import org.apache.james.backends.jpa.JPAConfiguration; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.containers.MariaDBContainer; +import org.testcontainers.containers.output.OutputFrame; + +import com.google.inject.Module; +import com.google.inject.util.Modules; + +public class MariaDBExtension implements GuiceModuleTestExtension { + + private static final Logger logger = LoggerFactory.getLogger(MariaDBExtension.class); + + private static void displayDockerLog(OutputFrame outputFrame) { + logger.info(outputFrame.getUtf8String().trim()); + } + + private final MariaDBContainer container; + private JPAConfiguration jpaConfiguration; + + public MariaDBExtension() { + container = new MariaDBContainer<>("mariadb:10.6") + .withLogConsumer(MariaDBExtension::displayDockerLog) + .withReuse(true); + } + + @Override + public void beforeAll(ExtensionContext extensionContext) throws Exception { + container.start(); + } + + @Override + public void afterAll(ExtensionContext extensionContext) throws Exception { + container.stop(); + } + + @Override + public void beforeEach(ExtensionContext extensionContext) throws Exception { + var dbName = UUID.randomUUID().toString().replace('-', '_'); + String script = String.format("create database %s; grant all on %s.* to %s", dbName, dbName, container.getUsername()); + container.execInContainer("mariadb", "-u", "root", "--password=" + container.getPassword(), "--execute", script); + container.withDatabaseName(dbName); + jpaConfiguration = JPAConfiguration.builder() + .driverName(container.getDriverClassName()) + .driverURL(container.getJdbcUrl()) + .username(container.getUsername()) + .password(container.getPassword()) + .build(); + } + + @Override + public Module getModule() { + return Modules.combine(binder -> binder.bind(JPAConfiguration.class) + .toInstance(jpaConfiguration)); + } +} diff --git a/server/container/guice/distributed/src/main/java/org/apache/james/modules/blobstore/BlobStoreModulesChooser.java b/server/container/guice/distributed/src/main/java/org/apache/james/modules/blobstore/BlobStoreModulesChooser.java index 41f1388a26..a0c467bdb9 100644 --- a/server/container/guice/distributed/src/main/java/org/apache/james/modules/blobstore/BlobStoreModulesChooser.java +++ b/server/container/guice/distributed/src/main/java/org/apache/james/modules/blobstore/BlobStoreModulesChooser.java @@ -180,7 +180,7 @@ public class BlobStoreModulesChooser { return encryptionModule.orElse(new NoEncryptionModule()); } - private static List<Module> chooseStoragePolicyModule(StorageStrategy storageStrategy) { + public static List<Module> chooseStoragePolicyModule(StorageStrategy storageStrategy) { switch (storageStrategy) { case DEDUPLICATION: Module deduplicationBlobModule = binder -> binder.bind(BlobStore.class) diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresQuotaGuiceModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresQuotaGuiceModule.java index 1df8435867..e7f0f1da11 100644 --- a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresQuotaGuiceModule.java +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresQuotaGuiceModule.java @@ -36,7 +36,7 @@ public class PostgresQuotaGuiceModule extends AbstractModule { @Override public void configure() { bind(PostgresQuotaCurrentValueDAO.class).in(Scopes.SINGLETON); -// bind(PostgresQuotaLimitDAO.class).in(Scopes.SINGLETON); + bind(PostgresQuotaLimitDAO.class).in(Scopes.SINGLETON); Multibinder<PostgresModule> postgresDataDefinitions = Multibinder.newSetBinder(binder(), PostgresModule.class); postgresDataDefinitions.addBinding().toInstance(org.apache.james.backends.postgres.quota.PostgresQuotaModule.MODULE); diff --git a/server/data/data-jpa/src/main/java/org/apache/james/droplists/jpa/JPADropList.java b/server/data/data-jpa/src/main/java/org/apache/james/droplists/jpa/JPADropList.java index 88d87cf03c..2ff086dd9b 100644 --- a/server/data/data-jpa/src/main/java/org/apache/james/droplists/jpa/JPADropList.java +++ b/server/data/data-jpa/src/main/java/org/apache/james/droplists/jpa/JPADropList.java @@ -88,6 +88,16 @@ public class JPADropList implements DropList { .doFinally(any -> EntityManagerUtils.safelyClose(entityManager)); } + // This operation is not exposed in the interface on purpose. + // It only makes sense in the context of a migration tool and + // should not be used in normal operations. + public Flux<DropListEntry> listAll() { + EntityManager entityManager = entityManagerFactory.createEntityManager(); + return Flux.fromStream(() -> getAllDropListEntries(entityManager) + .map(JPADropListEntry::toDropListEntry)) + .doFinally(any -> EntityManagerUtils.safelyClose(entityManager)); + } + @Override public Mono<Status> query(OwnerScope ownerScope, String owner, MailAddress sender) { Preconditions.checkArgument(ownerScope != null); @@ -107,6 +117,13 @@ public class JPADropList implements DropList { .getResultStream(); } + @SuppressWarnings("unchecked") + private Stream<JPADropListEntry> getAllDropListEntries(EntityManager entityManager) { + return entityManager + .createNamedQuery("listAllDropListEntries") + .getResultStream(); + } + private DropList.Status queryDropList(EntityManager entityManager, OwnerScope ownerScope, String owner, MailAddress sender) { String specifiedOwner = ownerScope.equals(OwnerScope.GLOBAL) ? "" : owner; return entityManager.createNamedQuery("queryDropListEntry") diff --git a/server/data/data-jpa/src/main/java/org/apache/james/droplists/jpa/model/JPADropListEntry.java b/server/data/data-jpa/src/main/java/org/apache/james/droplists/jpa/model/JPADropListEntry.java index dded720f49..0a4780d084 100644 --- a/server/data/data-jpa/src/main/java/org/apache/james/droplists/jpa/model/JPADropListEntry.java +++ b/server/data/data-jpa/src/main/java/org/apache/james/droplists/jpa/model/JPADropListEntry.java @@ -45,6 +45,8 @@ import com.google.common.base.MoreObjects; @Table(name = "JAMES_DROP_LIST") @NamedQuery(name = "listDropListEntries", query = "SELECT j FROM JamesDropList j WHERE j.ownerScope = :ownerScope AND j.owner = :owner") +@NamedQuery(name = "listAllDropListEntries", + query = "SELECT j FROM JamesDropList j") @NamedQuery(name = "queryDropListEntry", query = "SELECT j FROM JamesDropList j WHERE j.ownerScope = :ownerScope AND j.owner = :owner AND j.deniedEntity IN :deniedEntity") @NamedQuery(name = "removeDropListEntry", diff --git a/server/data/data-jpa/src/main/java/org/apache/james/sieve/jpa/JPASieveRepository.java b/server/data/data-jpa/src/main/java/org/apache/james/sieve/jpa/JPASieveRepository.java index 172e5efc23..b068715cb8 100644 --- a/server/data/data-jpa/src/main/java/org/apache/james/sieve/jpa/JPASieveRepository.java +++ b/server/data/data-jpa/src/main/java/org/apache/james/sieve/jpa/JPASieveRepository.java @@ -26,6 +26,7 @@ import java.util.List; import java.util.Optional; import java.util.function.Consumer; import java.util.function.Function; +import java.util.stream.Stream; import jakarta.inject.Inject; import jakarta.persistence.EntityManager; @@ -66,10 +67,12 @@ public class JPASieveRepository implements SieveRepository { private static final String DEFAULT_SIEVE_QUOTA_USERNAME = "default.quota"; private final TransactionRunner transactionRunner; + private final EntityManagerFactory entityManagerFactory; @Inject public JPASieveRepository(EntityManagerFactory entityManagerFactory) { this.transactionRunner = new TransactionRunner(entityManagerFactory); + this.entityManagerFactory = entityManagerFactory; } @Override @@ -87,9 +90,9 @@ public class JPASieveRepository implements SieveRepository { private QuotaSizeLimit limitToUser(Username username) throws StorageException { return findQuotaForUser(username.asString()) - .or(Throwing.supplier(() -> findQuotaForUser(DEFAULT_SIEVE_QUOTA_USERNAME)).sneakyThrow()) - .map(JPASieveQuota::toQuotaSize) - .orElse(QuotaSizeLimit.unlimited()); + .or(Throwing.supplier(() -> findQuotaForUser(DEFAULT_SIEVE_QUOTA_USERNAME)).sneakyThrow()) + .map(JPASieveQuota::toQuotaSize) + .orElse(QuotaSizeLimit.unlimited()); } private boolean overQuotaAfterModification(long usedSpace, long size, QuotaSizeLimit quota) { @@ -128,6 +131,29 @@ public class JPASieveRepository implements SieveRepository { return Mono.fromCallable(() -> listScripts(username)).flatMapMany(Flux::fromIterable); } + + // This operation is not exposed in the interface on purpose. + // It only makes sense in the context of a migration tool and + // should not be used in normal operations. + @SuppressWarnings("unchecked") + public Stream<JPASieveScript> listAllSieveScripts() { + EntityManager entityManager = entityManagerFactory.createEntityManager(); + return entityManager + .createNamedQuery("listAllSieveScripts") + .getResultStream(); + } + + // This operation is not exposed in the interface on purpose. + // It only makes sense in the context of a migration tool and + // should not be used in normal operations. + @SuppressWarnings("unchecked") + public Stream<JPASieveQuota> listAllSieveQuotas() { + EntityManager entityManager = entityManagerFactory.createEntityManager(); + return entityManager + .createNamedQuery("listAllSieveQuotas") + .getResultStream(); + } + private List<JPASieveScript> findAllSieveScriptsForUser(Username username) throws StorageException { return transactionRunner.runAndRetrieveResult(entityManager -> { List<JPASieveScript> sieveScripts = entityManager.createNamedQuery("findAllByUsername", JPASieveScript.class) diff --git a/server/data/data-jpa/src/main/java/org/apache/james/sieve/jpa/model/JPASieveQuota.java b/server/data/data-jpa/src/main/java/org/apache/james/sieve/jpa/model/JPASieveQuota.java index 3e33d04907..07ebee209b 100644 --- a/server/data/data-jpa/src/main/java/org/apache/james/sieve/jpa/model/JPASieveQuota.java +++ b/server/data/data-jpa/src/main/java/org/apache/james/sieve/jpa/model/JPASieveQuota.java @@ -27,12 +27,14 @@ import jakarta.persistence.Id; import jakarta.persistence.NamedQuery; import jakarta.persistence.Table; +import org.apache.james.core.Username; import org.apache.james.core.quota.QuotaSizeLimit; import com.google.common.base.MoreObjects; @Entity(name = "JamesSieveQuota") @Table(name = "JAMES_SIEVE_QUOTA") +@NamedQuery(name = "listAllSieveQuotas", query = "SELECT sieveQuota FROM JamesSieveQuota sieveQuota") @NamedQuery(name = "findByUsername", query = "SELECT sieveQuota FROM JamesSieveQuota sieveQuota WHERE sieveQuota.username=:username") public class JPASieveQuota { @@ -91,4 +93,8 @@ public class JPASieveQuota { .add("size", size) .toString(); } + + public Username getUsername() { + return Username.of(username); + } } diff --git a/server/data/data-jpa/src/main/java/org/apache/james/sieve/jpa/model/JPASieveScript.java b/server/data/data-jpa/src/main/java/org/apache/james/sieve/jpa/model/JPASieveScript.java index 3df5b9e4c0..394513e016 100644 --- a/server/data/data-jpa/src/main/java/org/apache/james/sieve/jpa/model/JPASieveScript.java +++ b/server/data/data-jpa/src/main/java/org/apache/james/sieve/jpa/model/JPASieveScript.java @@ -39,6 +39,7 @@ import com.google.common.base.Preconditions; @Entity(name = "JamesSieveScript") @Table(name = "JAMES_SIEVE_SCRIPT") +@NamedQuery(name = "listAllSieveScripts", query = "SELECT sieveScript FROM JamesSieveScript sieveScript") @NamedQuery(name = "findAllByUsername", query = "SELECT sieveScript FROM JamesSieveScript sieveScript WHERE sieveScript.username=:username") @NamedQuery(name = "findActiveByUsername", query = "SELECT sieveScript FROM JamesSieveScript sieveScript WHERE sieveScript.username=:username AND sieveScript.isActive=true") @NamedQuery(name = "findSieveScript", query = "SELECT sieveScript FROM JamesSieveScript sieveScript WHERE sieveScript.username=:username AND sieveScript.scriptName=:scriptName") diff --git a/server/data/data-jpa/src/main/java/org/apache/james/user/jpa/model/JPAUser.java b/server/data/data-jpa/src/main/java/org/apache/james/user/jpa/model/JPAUser.java index 9a7f049678..76ae23fc91 100644 --- a/server/data/data-jpa/src/main/java/org/apache/james/user/jpa/model/JPAUser.java +++ b/server/data/data-jpa/src/main/java/org/apache/james/user/jpa/model/JPAUser.java @@ -57,7 +57,7 @@ public class JPAUser implements User { */ @VisibleForTesting static String hashPassword(String password, String nullableSalt, String nullableAlgorithm) { - Algorithm algorithm = Algorithm.of(Optional.ofNullable(nullableAlgorithm).orElse("SHA-512")); + Algorithm algorithm = buildAlgorithm(nullableAlgorithm); if (algorithm.isPBKDF2()) { return algorithm.digest(password, nullableSalt); } @@ -68,6 +68,19 @@ public class JPAUser implements User { return chooseHashFunction(algorithm.getName()).apply(credentials); } + private static Algorithm buildAlgorithm(String nullableAlgorithm) { + return Algorithm.of(Optional.ofNullable(nullableAlgorithm).orElse("SHA-512")); + } + + public String getPasswordHash() { + return password; + } + + public Algorithm getAlgorithm() { + return buildAlgorithm(alg); + } + + interface PasswordHashFunction extends Function<String, String> {} private static PasswordHashFunction chooseHashFunction(String algorithm) { diff --git a/server/pom.xml b/server/pom.xml index 81d7cf120f..76f5442d25 100644 --- a/server/pom.xml +++ b/server/pom.xml @@ -46,6 +46,7 @@ <module>apps/jpa-app</module> <module>apps/jpa-smtp-app</module> <module>apps/memory-app</module> + <module>apps/migration/core-data-jpa-to-pg</module> <module>apps/postgres-app</module> <module>apps/scaling-pulsar-smtp</module> <module>apps/spring-app</module> --------------------------------------------------------------------- To unsubscribe, e-mail: notifications-unsubscr...@james.apache.org For additional commands, e-mail: notifications-h...@james.apache.org