Two source-and-replica pairs run in containers: one replicating with GTIDs, one by binary log position. mysql_replica_tests covers test 17 (tests 9, 10 and 12 with GTIDs) and mysql_replica_position_tests covers test 18 (lag from Seconds_Behind_Source, and a replica whose account lacks REPLICATION CLIENT getting no reads) and test 19 (a parallel replica without replica_preserve_commit_order left out at startup). The lag reader now takes Seconds_Behind_Source whatever numeric type the server returns, accepts the older column name, and says in the log why it gave up measuring.
781 lines
30 KiB
Rust
781 lines
30 KiB
Rust
/*
|
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
|
*
|
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
|
*/
|
|
|
|
use std::time::{Duration, Instant};
|
|
|
|
use testcontainers::{
|
|
ContainerAsync, GenericBuildableImage, GenericImage, ImageExt, ReuseDirective,
|
|
core::{CmdWaitFor, ExecCommand, Host, IntoContainerPort, WaitFor},
|
|
runners::{AsyncBuilder, AsyncRunner},
|
|
};
|
|
use tokio::{net::TcpStream, sync::OnceCell};
|
|
|
|
const ACME_NETWORK: &str = "stalwart-test-acme";
|
|
|
|
const READY_TIMEOUT: Duration = Duration::from_secs(180);
|
|
|
|
static FOUNDATIONDB: OnceCell<ContainerAsync<GenericImage>> = OnceCell::const_new();
|
|
static POSTGRES: OnceCell<ContainerAsync<GenericImage>> = OnceCell::const_new();
|
|
static MYSQL: OnceCell<ContainerAsync<GenericImage>> = OnceCell::const_new();
|
|
static MARIADB: OnceCell<ContainerAsync<GenericImage>> = OnceCell::const_new();
|
|
static REDIS: OnceCell<ContainerAsync<GenericImage>> = OnceCell::const_new();
|
|
static NATS: OnceCell<ContainerAsync<GenericImage>> = OnceCell::const_new();
|
|
static MINIO: OnceCell<ContainerAsync<GenericImage>> = OnceCell::const_new();
|
|
static OPENSEARCH: OnceCell<ContainerAsync<GenericImage>> = OnceCell::const_new();
|
|
static MEILISEARCH: OnceCell<ContainerAsync<GenericImage>> = OnceCell::const_new();
|
|
static KEYCLOAK: OnceCell<ContainerAsync<GenericImage>> = OnceCell::const_new();
|
|
static OPENLDAP: OnceCell<ContainerAsync<GenericImage>> = OnceCell::const_new();
|
|
static CHALLTESTSRV: OnceCell<ContainerAsync<GenericImage>> = OnceCell::const_new();
|
|
static PEBBLE: OnceCell<ContainerAsync<GenericImage>> = OnceCell::const_new();
|
|
static POWERDNS: OnceCell<ContainerAsync<GenericImage>> = OnceCell::const_new();
|
|
static SCIM_TESTER: OnceCell<ContainerAsync<GenericImage>> = OnceCell::const_new();
|
|
// inbuxa: scale-out storage (ST-5 to ST-15): a primary and a streaming replica
|
|
static PG_PRIMARY: OnceCell<ContainerAsync<GenericImage>> = OnceCell::const_new();
|
|
static PG_REPLICA: OnceCell<ContainerAsync<GenericImage>> = OnceCell::const_new();
|
|
const PG_REPLICATION_NETWORK: &str = "inbuxa-test-pg-replication";
|
|
pub const PG_PRIMARY_PORT: u16 = 5442;
|
|
pub const PG_REPLICA_PORT: u16 = 5443;
|
|
pub const PG_REPLICA_CONTAINER: &str = "inbuxa-test-pg-replica";
|
|
pub const PG_PRIMARY_CONTAINER: &str = "inbuxa-test-pg-primary";
|
|
static MYSQL_GTID: OnceCell<(ContainerAsync<GenericImage>, ContainerAsync<GenericImage>)> =
|
|
OnceCell::const_new();
|
|
static MYSQL_POS: OnceCell<(ContainerAsync<GenericImage>, ContainerAsync<GenericImage>)> =
|
|
OnceCell::const_new();
|
|
const MYSQL_REPLICATION_NETWORK: &str = "inbuxa-test-mysql-replication";
|
|
/// The MySQL pair that replicates with GTIDs, and the one that replicates
|
|
/// by binary log position.
|
|
pub const MYSQL_GTID_PORTS: (u16, u16) = (3317, 3318);
|
|
pub const MYSQL_POS_PORTS: (u16, u16) = (3327, 3328);
|
|
|
|
const OPENLDAP_LDAPI_URL: &str = "ldapi://%2Fvar%2Frun%2Fslapd%2Fldapi/";
|
|
|
|
const OPENLDAP_ALLOW_UNAUTHENTICATED_BIND: &str = r#"set -e
|
|
ldapmodify -Y EXTERNAL -Q -H "$LDAPI" <<'EOF'
|
|
dn: cn=config
|
|
changetype: modify
|
|
replace: olcAllows
|
|
olcAllows: bind_anon_dn
|
|
EOF
|
|
test "$(ldapwhoami -x -H "$LDAPI" -D uid=john.doe,ou=users,dc=stalwart,dc=test -w '')" = anonymous
|
|
"#;
|
|
|
|
const POWERDNS_ZONE_INIT: &str = r#"set -e
|
|
for i in $(seq 1 60); do
|
|
pdnsutil list-all-zones >/dev/null 2>&1 && break
|
|
sleep 1
|
|
done
|
|
if pdnsutil list-zone stalwart.test >/dev/null 2>&1; then
|
|
exit 0
|
|
fi
|
|
pdnsutil create-zone stalwart.test ns1.stalwart.test
|
|
pdnsutil set-kind stalwart.test native
|
|
pdnsutil replace-rrset stalwart.test '' SOA 'ns1.stalwart.test. admin.stalwart.test. 2024010101 3600 900 604800 86400'
|
|
pdnsutil add-record stalwart.test 'ns1' A '127.0.0.1'
|
|
pdnsutil add-record stalwart.test '' A '127.0.0.1'
|
|
pdnsutil add-record stalwart.test '' MX '10 mail.stalwart.test.'
|
|
pdnsutil add-record stalwart.test 'mail' A '127.0.0.1'
|
|
pdnsutil import-tsig-key stalwart-update-key hmac-sha256 'c3RhbHdhcnQtdGVzdC10c2lnLXNlY3JldC1rZXkxMjM0NTY3ODkw'
|
|
pdnsutil activate-tsig-key stalwart.test stalwart-update-key primary
|
|
pdnsutil set-meta stalwart.test TSIG-ALLOW-DNSUPDATE stalwart-update-key
|
|
pdnsutil set-meta stalwart.test ALLOW-DNSUPDATE-FROM '0.0.0.0/0'
|
|
"#;
|
|
|
|
pub async fn ensure_foundationdb() {
|
|
let container = FOUNDATIONDB
|
|
.get_or_init(|| async {
|
|
GenericImage::new("foundationdb/foundationdb", "7.4.6")
|
|
.with_env_var("FDB_NETWORKING_MODE", "container")
|
|
.with_mapped_port(4500, 4500.tcp())
|
|
.with_startup_timeout(READY_TIMEOUT)
|
|
.with_container_name("stalwart-test-foundationdb")
|
|
.with_reuse(ReuseDirective::Always)
|
|
.start()
|
|
.await
|
|
.expect("Failed to start FoundationDB container")
|
|
})
|
|
.await;
|
|
|
|
let start = Instant::now();
|
|
loop {
|
|
if fdbcli(container, "status minimal")
|
|
.await
|
|
.contains("The database is available")
|
|
{
|
|
return;
|
|
}
|
|
let created = fdbcli(container, "configure new single memory").await;
|
|
if created.contains("Database created") || created.contains("Already exists") {
|
|
continue;
|
|
}
|
|
if start.elapsed() > READY_TIMEOUT {
|
|
panic!("Timed out configuring FoundationDB: {created}");
|
|
}
|
|
tokio::time::sleep(Duration::from_secs(2)).await;
|
|
}
|
|
}
|
|
|
|
async fn fdbcli(container: &ContainerAsync<GenericImage>, command: &str) -> String {
|
|
let mut result = container
|
|
.exec(
|
|
ExecCommand::new(["fdbcli", "--exec", command, "--timeout", "5"])
|
|
.with_cmd_ready_condition(CmdWaitFor::exit()),
|
|
)
|
|
.await
|
|
.expect("Failed to exec fdbcli");
|
|
let stdout = result.stdout_to_vec().await.unwrap_or_default();
|
|
let stderr = result.stderr_to_vec().await.unwrap_or_default();
|
|
format!(
|
|
"{}{}",
|
|
String::from_utf8_lossy(&stdout),
|
|
String::from_utf8_lossy(&stderr)
|
|
)
|
|
}
|
|
|
|
pub async fn ensure_postgres() {
|
|
POSTGRES
|
|
.get_or_init(|| async {
|
|
GenericImage::new("postgres", "16-alpine")
|
|
.with_wait_for(WaitFor::message_on_stderr(
|
|
"database system is ready to accept connections",
|
|
))
|
|
.with_wait_for(WaitFor::message_on_stderr(
|
|
"database system is ready to accept connections",
|
|
))
|
|
.with_env_var("POSTGRES_USER", "stalwart")
|
|
.with_env_var("POSTGRES_PASSWORD", "stalwart")
|
|
.with_env_var("POSTGRES_DB", "stalwart")
|
|
.with_mapped_port(5432, 5432.tcp())
|
|
.with_startup_timeout(READY_TIMEOUT)
|
|
.with_container_name("stalwart-test-postgres")
|
|
.with_reuse(ReuseDirective::Always)
|
|
.start()
|
|
.await
|
|
.expect("Failed to start PostgreSQL container")
|
|
})
|
|
.await;
|
|
wait_for_tcp(5432).await;
|
|
}
|
|
|
|
pub async fn ensure_mysql() {
|
|
MYSQL
|
|
.get_or_init(|| async {
|
|
GenericImage::new("mysql", "8.0")
|
|
.with_wait_for(WaitFor::message_on_stderr("port: 3306 MySQL"))
|
|
.with_env_var("MYSQL_ROOT_PASSWORD", "password")
|
|
.with_env_var("MYSQL_DATABASE", "stalwart")
|
|
.with_cmd(["--default-authentication-plugin=mysql_native_password"])
|
|
.with_mapped_port(3307, 3306.tcp())
|
|
.with_startup_timeout(READY_TIMEOUT)
|
|
.with_container_name("stalwart-test-mysql")
|
|
.with_reuse(ReuseDirective::Always)
|
|
.start()
|
|
.await
|
|
.expect("Failed to start MySQL container")
|
|
})
|
|
.await;
|
|
wait_for_tcp(3307).await;
|
|
}
|
|
|
|
pub async fn ensure_mariadb() {
|
|
MARIADB
|
|
.get_or_init(|| async {
|
|
GenericImage::new("mariadb", "11.4")
|
|
.with_wait_for(WaitFor::message_on_stderr("port: 3306 mariadb.org"))
|
|
.with_env_var("MARIADB_ROOT_PASSWORD", "password")
|
|
.with_env_var("MARIADB_DATABASE", "stalwart")
|
|
.with_mapped_port(3308, 3306.tcp())
|
|
.with_startup_timeout(READY_TIMEOUT)
|
|
.with_container_name("stalwart-test-mariadb")
|
|
.with_reuse(ReuseDirective::Always)
|
|
.start()
|
|
.await
|
|
.expect("Failed to start MariaDB container")
|
|
})
|
|
.await;
|
|
wait_for_tcp(3308).await;
|
|
}
|
|
|
|
pub async fn ensure_redis() {
|
|
REDIS
|
|
.get_or_init(|| async {
|
|
GenericImage::new("redis", "7-alpine")
|
|
.with_wait_for(WaitFor::message_on_stdout("Ready to accept connections"))
|
|
.with_cmd(["redis-server", "--save", "", "--appendonly", "no"])
|
|
.with_mapped_port(6379, 6379.tcp())
|
|
.with_startup_timeout(READY_TIMEOUT)
|
|
.with_container_name("stalwart-test-redis")
|
|
.with_reuse(ReuseDirective::Always)
|
|
.start()
|
|
.await
|
|
.expect("Failed to start Redis container")
|
|
})
|
|
.await;
|
|
wait_for_tcp(6379).await;
|
|
}
|
|
|
|
pub async fn ensure_nats() {
|
|
NATS.get_or_init(|| async {
|
|
GenericImage::new("nats", "latest")
|
|
.with_wait_for(WaitFor::message_on_stderr("Server is ready"))
|
|
.with_cmd(["--addr", "0.0.0.0", "--port", "4222", "--http_port", "8222"])
|
|
.with_mapped_port(4222, 4222.tcp())
|
|
.with_mapped_port(8222, 8222.tcp())
|
|
.with_startup_timeout(READY_TIMEOUT)
|
|
.with_container_name("stalwart-test-nats")
|
|
.with_reuse(ReuseDirective::Always)
|
|
.start()
|
|
.await
|
|
.expect("Failed to start NATS container")
|
|
})
|
|
.await;
|
|
wait_for_tcp(4222).await;
|
|
}
|
|
|
|
pub async fn ensure_minio() {
|
|
MINIO
|
|
.get_or_init(|| async {
|
|
GenericImage::new("minio/minio", "latest")
|
|
.with_env_var("MINIO_ROOT_USER", "minioadmin")
|
|
.with_env_var("MINIO_ROOT_PASSWORD", "minioadmin")
|
|
.with_cmd(["server", "/data", "--console-address", ":9001"])
|
|
.with_mapped_port(9000, 9000.tcp())
|
|
.with_mapped_port(9001, 9001.tcp())
|
|
.with_startup_timeout(READY_TIMEOUT)
|
|
.with_container_name("stalwart-test-minio")
|
|
.with_reuse(ReuseDirective::Always)
|
|
.start()
|
|
.await
|
|
.expect("Failed to start MinIO container")
|
|
})
|
|
.await;
|
|
wait_for_http("http://localhost:9000/minio/health/live").await;
|
|
create_minio_bucket().await;
|
|
}
|
|
|
|
pub async fn ensure_opensearch() {
|
|
OPENSEARCH
|
|
.get_or_init(|| async {
|
|
GenericImage::new("opensearchproject/opensearch", "2")
|
|
.with_env_var("discovery.type", "single-node")
|
|
.with_env_var("DISABLE_SECURITY_PLUGIN", "true")
|
|
.with_env_var("OPENSEARCH_JAVA_OPTS", "-Xms1g -Xmx1g")
|
|
.with_env_var("DISABLE_INSTALL_DEMO_CONFIG", "true")
|
|
.with_mapped_port(9200, 9200.tcp())
|
|
.with_startup_timeout(READY_TIMEOUT)
|
|
.with_container_name("stalwart-test-opensearch")
|
|
.with_reuse(ReuseDirective::Always)
|
|
.start()
|
|
.await
|
|
.expect("Failed to start OpenSearch container")
|
|
})
|
|
.await;
|
|
wait_for_http("http://localhost:9200").await;
|
|
}
|
|
|
|
pub async fn ensure_meilisearch() {
|
|
MEILISEARCH
|
|
.get_or_init(|| async {
|
|
GenericImage::new("getmeili/meilisearch", "latest")
|
|
.with_env_var("MEILI_ENV", "development")
|
|
.with_env_var("MEILI_NO_ANALYTICS", "true")
|
|
.with_env_var("MEILI_MASTER_KEY", "stalwart-master-key")
|
|
.with_mapped_port(7700, 7700.tcp())
|
|
.with_startup_timeout(READY_TIMEOUT)
|
|
.with_container_name("stalwart-test-meilisearch")
|
|
.with_reuse(ReuseDirective::Always)
|
|
.start()
|
|
.await
|
|
.expect("Failed to start Meilisearch container")
|
|
})
|
|
.await;
|
|
wait_for_http("http://localhost:7700/health").await;
|
|
}
|
|
|
|
pub async fn ensure_keycloak() {
|
|
KEYCLOAK
|
|
.get_or_init(|| async {
|
|
GenericImage::new("quay.io/keycloak/keycloak", "latest")
|
|
.with_env_var("KC_BOOTSTRAP_ADMIN_USERNAME", "admin")
|
|
.with_env_var("KC_BOOTSTRAP_ADMIN_PASSWORD", "admin")
|
|
.with_env_var("KC_HTTP_PORT", "9080")
|
|
.with_env_var("KC_HEALTH_ENABLED", "true")
|
|
.with_cmd(["start-dev", "--import-realm"])
|
|
.with_copy_to(
|
|
"/opt/keycloak/data/import/stalwart-realm.json",
|
|
include_bytes!("../../docker/keycloak/stalwart-realm.json").to_vec(),
|
|
)
|
|
.with_mapped_port(9080, 9080.tcp())
|
|
.with_startup_timeout(READY_TIMEOUT)
|
|
.with_container_name("stalwart-test-keycloak")
|
|
.with_reuse(ReuseDirective::Always)
|
|
.start()
|
|
.await
|
|
.expect("Failed to start Keycloak container")
|
|
})
|
|
.await;
|
|
wait_for_http("http://localhost:9080/realms/stalwart/.well-known/openid-configuration").await;
|
|
}
|
|
|
|
pub async fn ensure_scim_tester() -> &'static ContainerAsync<GenericImage> {
|
|
SCIM_TESTER
|
|
.get_or_init(|| async {
|
|
let image = GenericBuildableImage::new("stalwart-test-scim-tester", "local")
|
|
.with_dockerfile_string(include_str!("../../docker/scim/Dockerfile"))
|
|
.build_image()
|
|
.await
|
|
.expect("Failed to build the SCIM tester image");
|
|
image
|
|
.with_host("host.docker.internal", Host::HostGateway)
|
|
// inbuxa: on the host's network, so a host firewall that
|
|
// drops the Docker bridge doesn't block the test server
|
|
.with_network("host")
|
|
.with_startup_timeout(READY_TIMEOUT)
|
|
.with_container_name("stalwart-test-scim-tester")
|
|
.with_reuse(ReuseDirective::Always)
|
|
.start()
|
|
.await
|
|
.expect("Failed to start the SCIM tester container")
|
|
})
|
|
.await
|
|
}
|
|
|
|
pub async fn scim_tester_exec(args: &[&str]) -> (String, String) {
|
|
let mut result = ensure_scim_tester()
|
|
.await
|
|
.exec(ExecCommand::new(args.iter().copied()).with_cmd_ready_condition(CmdWaitFor::exit()))
|
|
.await
|
|
.expect("Failed to exec the SCIM driver");
|
|
let stdout = result.stdout_to_vec().await.unwrap_or_default();
|
|
let stderr = result.stderr_to_vec().await.unwrap_or_default();
|
|
|
|
(
|
|
String::from_utf8_lossy(&stdout).into_owned(),
|
|
String::from_utf8_lossy(&stderr).into_owned(),
|
|
)
|
|
}
|
|
|
|
pub async fn ensure_acme() {
|
|
ensure_challtestsrv().await;
|
|
ensure_pebble().await;
|
|
}
|
|
|
|
async fn ensure_challtestsrv() {
|
|
CHALLTESTSRV
|
|
.get_or_init(|| async {
|
|
let image = GenericBuildableImage::new("stalwart-test-challtestsrv", "local")
|
|
.with_dockerfile_string(include_str!("../../docker/pebble/Dockerfile.challtestsrv"))
|
|
.build_image()
|
|
.await
|
|
.expect("Failed to build challtestsrv image");
|
|
image
|
|
.with_network(ACME_NETWORK)
|
|
.with_host("host.docker.internal", Host::HostGateway)
|
|
.with_mapped_port(8055, 8055.tcp())
|
|
.with_startup_timeout(READY_TIMEOUT)
|
|
.with_container_name("stalwart-test-challtestsrv")
|
|
.with_reuse(ReuseDirective::Always)
|
|
.start()
|
|
.await
|
|
.expect("Failed to start challtestsrv container")
|
|
})
|
|
.await;
|
|
wait_for_tcp(8055).await;
|
|
}
|
|
|
|
async fn ensure_pebble() {
|
|
PEBBLE
|
|
.get_or_init(|| async {
|
|
GenericImage::new("ghcr.io/letsencrypt/pebble", "latest")
|
|
.with_env_var("PEBBLE_VA_NOSLEEP", "1")
|
|
.with_env_var("PEBBLE_WFE_NONCEREJECT", "0")
|
|
.with_env_var("PEBBLE_ALTERNATE_ROOTS", "2")
|
|
.with_cmd([
|
|
"-config",
|
|
"/test/config/pebble-config.json",
|
|
"-dnsserver",
|
|
"stalwart-test-challtestsrv:8053",
|
|
])
|
|
.with_copy_to(
|
|
"/test/config/pebble-config.json",
|
|
include_bytes!("../../docker/pebble/pebble-config.json").to_vec(),
|
|
)
|
|
.with_network(ACME_NETWORK)
|
|
.with_host("host.docker.internal", Host::HostGateway)
|
|
.with_mapped_port(14000, 14000.tcp())
|
|
.with_mapped_port(15000, 15000.tcp())
|
|
.with_startup_timeout(READY_TIMEOUT)
|
|
.with_container_name("stalwart-test-pebble")
|
|
.with_reuse(ReuseDirective::Always)
|
|
.start()
|
|
.await
|
|
.expect("Failed to start Pebble container")
|
|
})
|
|
.await;
|
|
wait_for_tcp(14000).await;
|
|
}
|
|
|
|
pub async fn ensure_powerdns() {
|
|
let container = POWERDNS
|
|
.get_or_init(|| async {
|
|
GenericImage::new("powerdns/pdns-auth-49", "latest")
|
|
.with_wait_for(WaitFor::message_on_stderr("Creating backend connection"))
|
|
.with_env_var("PDNS_AUTH_API_KEY", "stalwart-api-key")
|
|
.with_copy_to(
|
|
"/etc/powerdns/pdns.d/stalwart.conf",
|
|
include_bytes!("../../docker/powerdns/pdns.conf").to_vec(),
|
|
)
|
|
.with_mapped_port(5300, 53.tcp())
|
|
.with_mapped_port(5300, 53.udp())
|
|
.with_startup_timeout(READY_TIMEOUT)
|
|
.with_container_name("stalwart-test-powerdns")
|
|
.with_reuse(ReuseDirective::Always)
|
|
.start()
|
|
.await
|
|
.expect("Failed to start PowerDNS container")
|
|
})
|
|
.await;
|
|
|
|
let mut result = container
|
|
.exec(
|
|
ExecCommand::new(["bash", "-c", POWERDNS_ZONE_INIT])
|
|
.with_cmd_ready_condition(CmdWaitFor::exit()),
|
|
)
|
|
.await
|
|
.expect("Failed to exec PowerDNS zone init");
|
|
if result.exit_code().await.ok().flatten() != Some(0) {
|
|
let stdout =
|
|
String::from_utf8_lossy(&result.stdout_to_vec().await.unwrap_or_default()).into_owned();
|
|
let stderr =
|
|
String::from_utf8_lossy(&result.stderr_to_vec().await.unwrap_or_default()).into_owned();
|
|
panic!("PowerDNS zone init failed:\n{stdout}\n{stderr}");
|
|
}
|
|
wait_for_tcp(5300).await;
|
|
}
|
|
|
|
pub async fn ensure_openldap() {
|
|
const BOOTSTRAP_DIR: &str = "/container/service/slapd/assets/config/bootstrap/ldif/custom";
|
|
let container = OPENLDAP
|
|
.get_or_init(|| async {
|
|
GenericImage::new("osixia/openldap", "1.5.0")
|
|
.with_wait_for(WaitFor::message_on_stderr("slapd starting"))
|
|
.with_env_var("LDAP_ORGANISATION", "Stalwart Test")
|
|
.with_env_var("LDAP_DOMAIN", "stalwart.test")
|
|
.with_env_var("LDAP_BASE_DN", "dc=stalwart,dc=test")
|
|
.with_env_var("LDAP_ADMIN_PASSWORD", "admin")
|
|
.with_env_var("LDAP_TLS", "false")
|
|
.with_copy_to(
|
|
format!("{BOOTSTRAP_DIR}/50-users.ldif"),
|
|
include_bytes!("../../docker/ldap/50-users.ldif").to_vec(),
|
|
)
|
|
.with_copy_to(
|
|
format!("{BOOTSTRAP_DIR}/60-groups.ldif"),
|
|
include_bytes!("../../docker/ldap/60-groups.ldif").to_vec(),
|
|
)
|
|
.with_mapped_port(389, 389.tcp())
|
|
.with_startup_timeout(READY_TIMEOUT)
|
|
.with_container_name("stalwart-test-openldap")
|
|
.with_reuse(ReuseDirective::Always)
|
|
.start()
|
|
.await
|
|
.expect("Failed to start OpenLDAP container")
|
|
})
|
|
.await;
|
|
wait_for_tcp(389).await;
|
|
|
|
let setup = format!("LDAPI={OPENLDAP_LDAPI_URL}\n{OPENLDAP_ALLOW_UNAUTHENTICATED_BIND}");
|
|
let mut result = container
|
|
.exec(
|
|
ExecCommand::new(["bash", "-c", setup.as_str()])
|
|
.with_cmd_ready_condition(CmdWaitFor::exit()),
|
|
)
|
|
.await
|
|
.expect("Failed to exec OpenLDAP unauthenticated bind setup");
|
|
if result.exit_code().await.ok().flatten() != Some(0) {
|
|
let stdout =
|
|
String::from_utf8_lossy(&result.stdout_to_vec().await.unwrap_or_default()).into_owned();
|
|
let stderr =
|
|
String::from_utf8_lossy(&result.stderr_to_vec().await.unwrap_or_default()).into_owned();
|
|
panic!("OpenLDAP unauthenticated bind setup failed:\n{stdout}\n{stderr}");
|
|
}
|
|
}
|
|
|
|
async fn create_minio_bucket() {
|
|
use s3::{Bucket, BucketConfiguration, Region, creds::Credentials};
|
|
|
|
let region = Region::Custom {
|
|
region: "eu-central-1".to_string(),
|
|
endpoint: "http://localhost:9000".to_string(),
|
|
};
|
|
let credentials = Credentials::new(Some("minioadmin"), Some("minioadmin"), None, None, None)
|
|
.expect("Failed to build MinIO credentials");
|
|
|
|
match Bucket::create_with_path_style(
|
|
"stalwart",
|
|
region,
|
|
credentials,
|
|
BucketConfiguration::default(),
|
|
)
|
|
.await
|
|
{
|
|
Ok(response) if response.success() => {}
|
|
Ok(_) => {}
|
|
Err(s3::error::S3Error::HttpFailWithBody(409, _)) => {}
|
|
Err(err) => panic!("Failed to create MinIO bucket: {err:?}"),
|
|
}
|
|
}
|
|
|
|
async fn wait_for_tcp(port: u16) {
|
|
let start = Instant::now();
|
|
loop {
|
|
if TcpStream::connect(("127.0.0.1", port)).await.is_ok() {
|
|
return;
|
|
}
|
|
if start.elapsed() > READY_TIMEOUT {
|
|
panic!("Timed out waiting for TCP port {port}");
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(250)).await;
|
|
}
|
|
}
|
|
|
|
async fn wait_for_http(url: &str) {
|
|
let client = reqwest::Client::builder()
|
|
.danger_accept_invalid_certs(true)
|
|
.build()
|
|
.expect("Failed to build HTTP client");
|
|
let start = Instant::now();
|
|
loop {
|
|
if let Ok(response) = client.get(url).send().await
|
|
&& response.status().is_success()
|
|
{
|
|
return;
|
|
}
|
|
if start.elapsed() > READY_TIMEOUT {
|
|
panic!("Timed out waiting for {url}");
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
|
}
|
|
}
|
|
|
|
/// inbuxa: a PostgreSQL primary on port 5442 with a hot-standby streaming
|
|
/// replica on 5443, for the read-replica tests (ST-5 to ST-15).
|
|
pub async fn ensure_postgres_replicated() {
|
|
PG_PRIMARY
|
|
.get_or_init(|| async {
|
|
GenericImage::new("postgres", "16-alpine")
|
|
.with_wait_for(WaitFor::message_on_stderr(
|
|
"database system is ready to accept connections",
|
|
))
|
|
.with_env_var("POSTGRES_USER", "stalwart")
|
|
.with_env_var("POSTGRES_PASSWORD", "stalwart")
|
|
.with_env_var("POSTGRES_DB", "stalwart")
|
|
.with_copy_to(
|
|
"/docker-entrypoint-initdb.d/replication.sh",
|
|
b"#!/bin/sh\necho 'host replication all all scram-sha-256' >> \"$PGDATA/pg_hba.conf\"\n"
|
|
.to_vec(),
|
|
)
|
|
.with_cmd([
|
|
"postgres",
|
|
"-c",
|
|
"wal_level=replica",
|
|
"-c",
|
|
"max_wal_senders=10",
|
|
"-c",
|
|
"hot_standby=on",
|
|
])
|
|
.with_network(PG_REPLICATION_NETWORK)
|
|
.with_mapped_port(PG_PRIMARY_PORT, 5432.tcp())
|
|
.with_startup_timeout(READY_TIMEOUT)
|
|
.with_container_name(PG_PRIMARY_CONTAINER)
|
|
.with_reuse(ReuseDirective::Always)
|
|
.start()
|
|
.await
|
|
.expect("Failed to start the PostgreSQL primary")
|
|
})
|
|
.await;
|
|
wait_for_tcp(PG_PRIMARY_PORT).await;
|
|
PG_REPLICA
|
|
.get_or_init(|| async {
|
|
GenericImage::new("postgres", "16-alpine")
|
|
.with_wait_for(WaitFor::message_on_stderr(
|
|
"database system is ready to accept read-only connections",
|
|
))
|
|
.with_env_var("PGPASSWORD", "stalwart")
|
|
.with_cmd([
|
|
"sh",
|
|
"-c",
|
|
concat!(
|
|
"if [ ! -s /var/lib/postgresql/replica/PG_VERSION ]; then ",
|
|
"until pg_basebackup -h inbuxa-test-pg-primary -U stalwart ",
|
|
"-D /var/lib/postgresql/replica -R -X stream; do sleep 1; done; fi; ",
|
|
"chown -R postgres:postgres /var/lib/postgresql/replica; ",
|
|
"chmod 700 /var/lib/postgresql/replica; ",
|
|
"exec su-exec postgres postgres -D /var/lib/postgresql/replica ",
|
|
"-c hot_standby=on"
|
|
),
|
|
])
|
|
.with_network(PG_REPLICATION_NETWORK)
|
|
.with_mapped_port(PG_REPLICA_PORT, 5432.tcp())
|
|
.with_startup_timeout(READY_TIMEOUT)
|
|
.with_container_name(PG_REPLICA_CONTAINER)
|
|
.with_reuse(ReuseDirective::Always)
|
|
.start()
|
|
.await
|
|
.expect("Failed to start the PostgreSQL replica")
|
|
})
|
|
.await;
|
|
wait_for_tcp(PG_REPLICA_PORT).await;
|
|
}
|
|
|
|
/// inbuxa: runs SQL on a test container, through `psql`.
|
|
pub fn psql(container: &str, sql: &str) -> String {
|
|
let output = std::process::Command::new("docker")
|
|
.args([
|
|
"exec", container, "psql", "-U", "stalwart", "-d", "stalwart", "-tAc", sql,
|
|
])
|
|
.output()
|
|
.expect("docker exec");
|
|
String::from_utf8_lossy(&output.stdout).trim().to_string()
|
|
}
|
|
|
|
/// inbuxa: stops or starts a test container.
|
|
pub fn docker(action: &str, container: &str) {
|
|
let status = std::process::Command::new("docker")
|
|
.args([action, container])
|
|
.status()
|
|
.expect("docker");
|
|
assert!(status.success(), "docker {action} {container}");
|
|
}
|
|
|
|
fn mysql_container(
|
|
name: &str,
|
|
port: u16,
|
|
gtid: bool,
|
|
server_id: &str,
|
|
) -> testcontainers::ContainerRequest<GenericImage> {
|
|
let mut cmd = vec![
|
|
format!("--server-id={server_id}"),
|
|
"--log-bin=binlog".to_string(),
|
|
"--binlog-format=ROW".to_string(),
|
|
"--default-authentication-plugin=mysql_native_password".to_string(),
|
|
];
|
|
if gtid {
|
|
cmd.push("--gtid-mode=ON".to_string());
|
|
cmd.push("--enforce-gtid-consistency=ON".to_string());
|
|
}
|
|
let _ = name;
|
|
GenericImage::new("mysql", "8.0")
|
|
.with_wait_for(WaitFor::message_on_stderr("port: 3306 MySQL"))
|
|
.with_env_var("MYSQL_ROOT_PASSWORD", "password")
|
|
.with_env_var("MYSQL_DATABASE", "stalwart")
|
|
.with_cmd(cmd)
|
|
.with_network(MYSQL_REPLICATION_NETWORK)
|
|
.with_mapped_port(port, 3306.tcp())
|
|
.with_startup_timeout(READY_TIMEOUT)
|
|
.with_container_name(name)
|
|
.with_reuse(ReuseDirective::Always)
|
|
}
|
|
|
|
/// inbuxa: runs SQL on a MySQL test container, through the `mysql` client.
|
|
pub fn mysql_query(container: &str, sql: &str) -> String {
|
|
let output = std::process::Command::new("docker")
|
|
.args([
|
|
"exec",
|
|
container,
|
|
"mysql",
|
|
"-uroot",
|
|
"-ppassword",
|
|
"-N",
|
|
"-B",
|
|
"-e",
|
|
sql,
|
|
])
|
|
.output()
|
|
.expect("docker exec");
|
|
if !output.status.success() {
|
|
panic!(
|
|
"{sql}: {}",
|
|
String::from_utf8_lossy(&output.stderr).trim().to_string()
|
|
);
|
|
}
|
|
String::from_utf8_lossy(&output.stdout).trim().to_string()
|
|
}
|
|
|
|
/// inbuxa: a MySQL source and replica, replicating with GTIDs or by binary
|
|
/// log position (ST-10, ST-15).
|
|
pub async fn ensure_mysql_replicated(gtid: bool) {
|
|
let (cell, ports, names) = if gtid {
|
|
(
|
|
&MYSQL_GTID,
|
|
MYSQL_GTID_PORTS,
|
|
(
|
|
"inbuxa-test-mysql-gtid-primary",
|
|
"inbuxa-test-mysql-gtid-replica",
|
|
),
|
|
)
|
|
} else {
|
|
(
|
|
&MYSQL_POS,
|
|
MYSQL_POS_PORTS,
|
|
(
|
|
"inbuxa-test-mysql-pos-primary",
|
|
"inbuxa-test-mysql-pos-replica",
|
|
),
|
|
)
|
|
};
|
|
cell.get_or_init(|| async {
|
|
let primary = mysql_container(names.0, ports.0, gtid, "1")
|
|
.start()
|
|
.await
|
|
.expect("Failed to start the MySQL source");
|
|
let replica = mysql_container(names.1, ports.1, gtid, "2")
|
|
.start()
|
|
.await
|
|
.expect("Failed to start the MySQL replica");
|
|
wait_for_tcp(ports.0).await;
|
|
wait_for_tcp(ports.1).await;
|
|
|
|
// A replication account, and one without REPLICATION CLIENT (test 18)
|
|
mysql_query(
|
|
names.0,
|
|
concat!(
|
|
"CREATE USER IF NOT EXISTS 'repl'@'%' IDENTIFIED WITH mysql_native_password BY 'repl';",
|
|
"GRANT REPLICATION SLAVE ON *.* TO 'repl'@'%';",
|
|
"CREATE USER IF NOT EXISTS 'plain'@'%' IDENTIFIED WITH mysql_native_password BY 'plain';",
|
|
"GRANT ALL ON stalwart.* TO 'plain'@'%'; FLUSH PRIVILEGES;"
|
|
),
|
|
);
|
|
if mysql_query(names.1, "SELECT SERVICE_STATE FROM performance_schema.replication_applier_status")
|
|
.is_empty()
|
|
{
|
|
let source = if gtid {
|
|
format!(
|
|
"CHANGE REPLICATION SOURCE TO SOURCE_HOST='{}', SOURCE_PORT=3306, \
|
|
SOURCE_USER='repl', SOURCE_PASSWORD='repl', SOURCE_AUTO_POSITION=1",
|
|
names.0
|
|
)
|
|
} else {
|
|
let status = mysql_query(names.0, "SHOW MASTER STATUS");
|
|
let mut fields = status.split('\t');
|
|
let file = fields.next().unwrap_or_default().to_string();
|
|
let position = fields.next().unwrap_or("4").to_string();
|
|
format!(
|
|
"CHANGE REPLICATION SOURCE TO SOURCE_HOST='{}', SOURCE_PORT=3306, \
|
|
SOURCE_USER='repl', SOURCE_PASSWORD='repl', SOURCE_LOG_FILE='{file}', \
|
|
SOURCE_LOG_POS={position}",
|
|
names.0
|
|
)
|
|
};
|
|
mysql_query(names.1, &source);
|
|
mysql_query(names.1, "START REPLICA");
|
|
mysql_query(names.1, "SET GLOBAL super_read_only=ON");
|
|
}
|
|
(primary, replica)
|
|
})
|
|
.await;
|
|
wait_for_tcp(ports.0).await;
|
|
wait_for_tcp(ports.1).await;
|
|
}
|