diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index 812120fe7..b97834ff0 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -74,7 +74,13 @@ jobs: env: OUTPUT: CHANGELOG_PREVIEW.md + # Posting a PR comment needs a write-scoped GITHUB_TOKEN. On pull_request + # runs from a fork, GitHub forces the token to read-only regardless of the + # `permissions:` block above, so this step 403s ("Resource not accessible + # by integration"). Skip it for fork PRs — the Conventional Commits lint + # above still runs and gates every PR; only the preview comment is skipped. - name: Post preview comment + if: ${{ github.event.pull_request.head.repo.full_name == github.repository }} uses: actions/github-script@v7 with: script: | diff --git a/.github/workflows/rust-tests.yml b/.github/workflows/rust-tests.yml index 566459695..d755d8a45 100644 --- a/.github/workflows/rust-tests.yml +++ b/.github/workflows/rust-tests.yml @@ -248,9 +248,12 @@ jobs: - name: Run unit tests env: + CARGO_TERM_COLOR: never RUST_BACKTRACE: 1 CARGO_INCREMENTAL: 0 run: | + set -o pipefail + # Run tests, capturing the names of any failures for targeted retry if cargo test --no-fail-fast --lib ${{ matrix.crates }} 2>&1 | tee /tmp/test-output.txt; then echo "All tests passed on first attempt" @@ -258,7 +261,7 @@ jobs: fi # Extract failed test names from output - failed_tests=$(grep '^test .* FAILED$' /tmp/test-output.txt | sed 's/^test \(.*\) \.\.\..*FAILED$/\1/' | tr '\n' ' ') + failed_tests=$(grep '^test .* FAILED$' /tmp/test-output.txt | sed 's/^test \(.*\) \.\.\..*FAILED$/\1/' | tr '\n' ' ' || true) if [ -z "$failed_tests" ]; then echo "Could not parse failed test names, re-running all tests" cargo test --no-fail-fast --lib ${{ matrix.crates }} @@ -444,10 +447,13 @@ jobs: - name: Run integration tests env: + CARGO_TERM_COLOR: never RUST_BACKTRACE: 1 CARGO_INCREMENTAL: 0 TEMPS_TEST_DATABASE_URL: postgresql://test_user:test_password@localhost:5432/test_db run: | + set -o pipefail + # Run tests, capturing the names of any failures for targeted retry. # `cargo_args` covers everything cargo cares about (crate, features, # filters); `test_args` is everything after `--` that libtest cares @@ -460,7 +466,7 @@ jobs: fi # Extract failed test names from output - failed_tests=$(grep '^test .* FAILED$' /tmp/test-output.txt | sed 's/^test \(.*\) \.\.\..*FAILED$/\1/' | tr '\n' ' ') + failed_tests=$(grep '^test .* FAILED$' /tmp/test-output.txt | sed 's/^test \(.*\) \.\.\..*FAILED$/\1/' | tr '\n' ' ' || true) if [ -z "$failed_tests" ]; then echo "Could not parse failed test names, re-running all tests" cargo ${{ matrix.cargo_args }} -- ${{ matrix.test_args }} @@ -494,3 +500,113 @@ jobs: sleep 10 done done + + # --------------------------------------------------------------------------- + # Phase 2b: MariaDB PITR full-chain E2E + # --------------------------------------------------------------------------- + # Keep this heavyweight single test out of the generic Docker matrix so it can + # start immediately, and so Actions shows each expensive phase (image pull, + # test build, test run, diagnostics) separately. + mariadb-pitr-e2e: + name: "MariaDB PITR E2E" + runs-on: ubuntu-latest + timeout-minutes: 35 + + services: + timescaledb: + image: timescale/timescaledb-ha:pg18 + env: + POSTGRES_DB: test_db + POSTGRES_USER: test_user + POSTGRES_PASSWORD: test_password + POSTGRES_HOST_AUTH_METHOD: trust + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U test_user -d test_db" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Free up disk space + run: | + sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL + sudo docker image prune --all --force + sudo apt-get autoremove -y && sudo apt-get clean + df -h + + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Restore build cache + uses: Swatinem/rust-cache@v2 + with: + shared-key: test-build + save-if: false + + - name: Install system dependencies + run: sudo apt-get update && sudo apt-get install -y protobuf-compiler + + - name: Verify TimescaleDB is ready + run: | + timeout 60 bash -c 'until nc -z localhost 5432; do sleep 1; done' + echo "TimescaleDB is ready" + + - name: Show Docker environment + run: | + docker version + docker info + docker system df + + - name: Pull E2E images + run: | + timeout 10m docker pull minio/minio:latest + timeout 10m docker pull mariadb:lts + docker image ls | grep -E '^(minio/minio|mariadb)\s' || true + + - name: Build MariaDB PITR E2E binary + env: + CARGO_TERM_COLOR: never + CARGO_INCREMENTAL: 0 + run: | + cargo test -p temps-backup --features docker-tests mariadb_pitr_full_chain_e2e --no-run + + - name: Run MariaDB PITR E2E + env: + CARGO_TERM_COLOR: never + RUST_BACKTRACE: 1 + CARGO_INCREMENTAL: 0 + TEMPS_TEST_DATABASE_URL: postgresql://test_user:test_password@localhost:5432/test_db + run: | + timeout --foreground 20m bash -lc 'set -o pipefail; cargo test -p temps-backup --features docker-tests mariadb_pitr_full_chain_e2e -- --nocapture --test-threads=1 2>&1 | tee /tmp/mariadb-pitr-e2e.log' + + - name: Summarize MariaDB PITR E2E diagnostics + if: always() + run: | + if [ -f /tmp/mariadb-pitr-e2e.log ]; then + grep -E 'Restored row counts|test result|FAILED|Timed out|timed out|Fetched MariaDB PITR|Uploading MariaDB PITR|Uploaded MariaDB PITR|Replaying MariaDB|temps-mariadb-pitr-replay|docker exec timed out|DIAG|Booted MariaDB|Base backup|archive_binlogs|Restore produced' /tmp/mariadb-pitr-e2e.log | tail -n 240 || true + else + echo "No MariaDB PITR E2E log was written" + fi + + echo "" + echo "Docker containers:" + docker ps -a || true + echo "" + echo "Docker images:" + docker image ls || true + echo "" + echo "Docker disk usage:" + docker system df || true + + - name: Upload MariaDB PITR E2E log + if: always() + uses: actions/upload-artifact@v4 + with: + name: mariadb-pitr-e2e-log + path: /tmp/mariadb-pitr-e2e.log + if-no-files-found: ignore diff --git a/CHANGELOG.md b/CHANGELOG.md index 838d948c3..0c5f2c85a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,92 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.1.0-beta.40] - 2026-07-01 ### Added +- **Opt-in MariaDB external services**: Temps can now create standalone + MariaDB services for hosted projects without changing its internal Postgres + dependency. The new `mariadb` service type uses the official `mariadb:lts` + container image, generates separate application and root passwords, provisions + per-project/per-environment databases, and exposes both `MYSQL_*` and + `MARIADB_*` runtime environment variables plus `DATABASE_URL`. MariaDB is + supported in the query explorer, existing MariaDB/MySQL-compatible containers + can be imported as MariaDB services, and full logical backup/restore uses + `mariadb-dump` with `mysqldump` fallback for non-system databases. + +### Changed +- **MariaDB services default to a small-host profile**: new managed MariaDB + services now materialize `size_profile=small` into a conservative `resources` + block (`512` MiB memory, `768` MiB memory+swap, `750000000` nano-cpus) and + start `mariadbd` with tuned connection/cache/buffer settings. The create + service UI now presents MariaDB as a shared database server whose linked + projects get separate databases, reducing accidental one-container-per-website + installs on 4 GiB and 8 GiB hosts. + +### Fixed +- **Laravel MariaDB docs use runtime service env vars**: the Laravel tutorial + now keeps `php artisan config:cache` out of the Docker build so Temps-injected + runtime variables are not baked incorrectly into the image, documents + `DATABASE_URL`/`MYSQL_*`/`MARIADB_*` fallbacks for MariaDB-backed Laravel + configs, and keeps `php artisan migrate --force` as a one-off release step + rather than a per-container start command. +- **MariaDB physical base backups are gzipped correctly**: `mariadb_physical` + now binds the `| gzip` pipeline directly to `mariadb-backup --stream=mbstream` + instead of the trailing scratch-directory cleanup command, so base backup + objects ending in `base.mbstream.gz` are valid gzip streams that PITR restore + can unpack. +- **MariaDB PITR restore reads source binlogs for new services**: + restore-to-new-service now fetches the binlog manifest and archived binlog + objects from the source service name prefix instead of the newly restored + service name, allowing forward replay to find the source service's archived + segments. +- **MariaDB physical restore helper waits are bounded**: PITR physical restore + now times out a stuck helper container wait with diagnostic logs instead of + hanging indefinitely, so failed restore attempts surface actionable errors. +- **MariaDB physical restore diagnostics cannot hang**: restore-helper log + collection is now finite and includes phase markers, so a stuck PITR physical + restore reports the helper phase instead of masking the timeout. +- **MariaDB restore-to-new-service avoids redundant image pulls**: MariaDB + container creation now reuses a locally present `docker_image` and bounds the + pull when the image is missing, preventing restore provisioning from hanging + on an unnecessary network pull. +- **MariaDB PITR binlog replay is bounded and non-interactive**: PITR replay now + uses an explicit TCP root connection, a short dedicated replay timeout, + per-phase `timeout` guards, and replay phase markers so a stuck or + prompt-bound `mariadb-binlog`/client invocation fails with context instead of + hanging behind the generic backup timeout. +- **MariaDB PITR binlog uploads are bounded**: archived binlog segments copied + into the restored container now use a dedicated upload timeout and phase logs, + so Docker archive-upload stalls fail before the E2E or job timeout. +- **Docker exec API calls are bounded for provider operations**: shared + `externalsvc::exec_util::run_exec` now applies a short Docker API timeout to + exec creation, start, polling, and final log draining while preserving + captured command output on command timeouts. It also stops polling a drained + output stream, preventing restore workflows from hanging when Docker closes + stdout/stderr before `inspect_exec` reports completion. + +### Tests +- **Full-chain MariaDB PITR Docker E2E coverage**: `crates/temps-backup/tests/mariadb_pitr_e2e.rs` + exercises the real physical backup engine, S3 binlog archiver, and + `restore_pitr(Time)` path against Dockerized MariaDB, MinIO, and Postgres, + asserting that rows before the recovery timestamp are restored while later + rows are excluded. The `docker-tests` feature and `docker-backup` GitHub + Actions matrix group keep the heavyweight test opt-in locally and enforced in + CI. +- **GitHub Actions cargo test pipelines preserve failures**: the Rust test + workflow now enables `pipefail` before piping cargo test output through + `tee`, disables cargo color for parseable test result lines, and makes failed + test extraction non-fatal, so failed unit or integration tests cannot be + reported as successful before retry parsing runs. +- **MariaDB PITR E2E does not retry after long failures**: the `docker-backup` + GitHub Actions group now fails immediately after a failed full-chain E2E + attempt, preserving the first diagnostic log instead of spending another + timeout window rerunning the same expensive scenario. +- **MariaDB PITR E2E emits provider phase logs**: the Docker E2E initializes a + test-only tracing subscriber for `temps_providers::externalsvc::mariadb`, so + CI logs show where a full-chain restore is spending time. +- **MariaDB PITR E2E runs as a dedicated CI job**: `.github/workflows/rust-tests.yml` + now runs the full-chain Docker E2E outside the generic integration matrix, + starts it immediately, and gives it explicit image-pull, binary-build, + test-run, and diagnostics steps, so GitHub Actions exposes progress and + hard-stops stuck runs faster. - **notifications:** Add Cloudflare Email Sending provider ([#160](https://github.com/gotempsh/temps/issues/160)) - **otel:** ClickHouse-first OTEL metrics storage with full-fidelity decode diff --git a/Cargo.lock b/Cargo.lock index ca0d84681..b2c8cfc35 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10995,6 +10995,7 @@ dependencies = [ "serde_derive", "serde_json", "serde_yaml", + "sqlx", "tempfile", "temps-auth", "temps-backup-core", @@ -11003,6 +11004,7 @@ dependencies = [ "temps-database", "temps-entities", "temps-logs", + "temps-migrations", "temps-notifications", "temps-providers", "testcontainers", @@ -11011,6 +11013,7 @@ dependencies = [ "tokio-stream", "tokio-util", "tracing", + "tracing-subscriber", "url", "urlencoding", "utoipa", @@ -12438,6 +12441,7 @@ dependencies = [ "sea-orm", "serde", "serde_json", + "sqlx", "tar", "tempfile", "temps-auth", diff --git a/Cargo.toml b/Cargo.toml index 7c160faa4..070c904dc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -156,7 +156,7 @@ sea-orm-migration = { version = "1.1", features = [ "sqlx-postgres", "runtime-tokio-rustls" ] } -sqlx = { version = "0.8.6", features = ["postgres", "runtime-tokio"] } +sqlx = { version = "0.8.6", features = ["postgres", "mysql", "runtime-tokio"] } rusqlite = { version = "0.32.0", features = ["bundled", "trace"] } # ============================================================================ diff --git a/crates/temps-backup/Cargo.toml b/crates/temps-backup/Cargo.toml index d46497d7f..c76e1ee35 100644 --- a/crates/temps-backup/Cargo.toml +++ b/crates/temps-backup/Cargo.toml @@ -52,6 +52,16 @@ async-trait = { workspace = true } async-stream = { workspace = true } urlencoding = { workspace = true } +[features] +# Docker-dependent integration tests. Mirrors temps-providers: gated behind a +# feature so `cargo test` on a host without a reachable Docker socket skips +# the heavy end-to-end tests, while CI opts in explicitly. The test files +# themselves also skip gracefully at runtime when Docker is unreachable. +docker-tests = [] + [dev-dependencies] sea-orm = { workspace = true, features = ["mock"] } testcontainers = { workspace = true } +sqlx = { workspace = true } +temps-migrations = { path = "../temps-migrations" } +tracing-subscriber = { workspace = true } diff --git a/crates/temps-backup/src/engines/dispatch.rs b/crates/temps-backup/src/engines/dispatch.rs index 0fc466f25..39799c4a1 100644 --- a/crates/temps-backup/src/engines/dispatch.rs +++ b/crates/temps-backup/src/engines/dispatch.rs @@ -13,6 +13,8 @@ //! | `"postgres"` | other | no | `"postgres_pgdump"` | //! | `"redis"` | any | – | `"redis"` | //! | `"mongodb"` | any | – | `"mongodb"` | +//! | `"mariadb"` | any | yes (PITR tools) | `"mariadb_physical"` | +//! | `"mariadb"` | any | no | `"mariadb_dump"` | //! | `"s3"` / `"minio"` / `"blob"` | any | – | `"s3_mirror"` | //! | anything else | – | – | `Err(Unsupported)` | @@ -24,7 +26,7 @@ pub enum ResolveEngineError { /// The service's `service_type` is not supported by any registered engine. #[error( "Service type '{service_type}' (service_id={service_id}) is not supported by any backup engine. \ - Supported types: postgres, redis, mongodb, s3, minio, blob" + Supported types: postgres, redis, mongodb, mariadb, s3, minio, blob" )] Unsupported { service_id: i32, @@ -70,6 +72,21 @@ pub async fn resolve_engine_key( } "redis" => Ok("redis"), "mongodb" => Ok("mongodb"), + "mariadb" => { + // PITR needs both mariadb-backup (physical base) and + // mariadb-binlog (binary-log replay) inside the container. + // Container naming must match the provider's + // `get_container_name()` — `mariadb-{name}` + // (see temps-providers/src/externalsvc/mariadb.rs:298-300). + // Using a different prefix here makes the probe miss every + // container and silently fall back to the logical dump. + let container_name = format!("mariadb-{}", service.name); + if container_has_mariadb_pitr_tools(docker, &container_name).await { + Ok("mariadb_physical") + } else { + Ok("mariadb_dump") + } + } "s3" | "minio" | "blob" => Ok("s3_mirror"), other => Err(ResolveEngineError::Unsupported { service_id: service.id, @@ -134,6 +151,68 @@ async fn container_has_walg(docker: &bollard::Docker, container_name: &str) -> b false } +/// Probe whether the MariaDB PITR tools (`mariadb-backup` AND `mariadb-binlog`) +/// are available in `container_name`. +/// +/// Both are required: `mariadb-backup` for the physical base and +/// `mariadb-binlog`/`mysqlbinlog` for replay. Returns `false` on any error or +/// if either tool is missing, so the caller falls back to the logical +/// `mariadb_dump` engine gracefully. The stock `mariadb:lts` image ships both, +/// so this normally resolves to `mariadb_physical`. +async fn container_has_mariadb_pitr_tools(docker: &bollard::Docker, container_name: &str) -> bool { + use bollard::exec::{CreateExecOptions, StartExecOptions}; + + // Single shell test: exit 0 only if BOTH tools resolve. `mariadb-binlog` + // and `mysqlbinlog` are the same tool (symlink); accept either. + let probe = "command -v mariadb-backup >/dev/null 2>&1 || command -v mariabackup >/dev/null 2>&1; \ + a=$?; \ + command -v mariadb-binlog >/dev/null 2>&1 || command -v mysqlbinlog >/dev/null 2>&1; \ + b=$?; \ + [ $a -eq 0 ] && [ $b -eq 0 ]"; + + let exec = match docker + .create_exec( + container_name, + CreateExecOptions { + cmd: Some(vec!["sh", "-c", probe]), + attach_stdout: Some(false), + attach_stderr: Some(false), + ..Default::default() + }, + ) + .await + { + Ok(e) => e, + Err(_) => return false, + }; + + if docker + .start_exec( + &exec.id, + Some(StartExecOptions { + detach: true, + ..Default::default() + }), + ) + .await + .is_err() + { + return false; + } + + for _ in 0..5u32 { + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + match docker.inspect_exec(&exec.id).await { + Ok(info) if info.running == Some(false) => { + return info.exit_code == Some(0); + } + Ok(_) => continue, + Err(_) => return false, + } + } + false +} + // ── Tests ───────────────────────────────────────────────────────────────────── #[cfg(test)] @@ -163,6 +242,7 @@ mod tests { consecutive_health_failures: 0, health_metadata: None, metrics_enabled: false, + default_backup_provisioned: false, } } @@ -260,6 +340,26 @@ mod tests { }); } + #[test] + fn test_mariadb_resolves_to_dump_when_pitr_tools_absent() { + // With no running `mariadb-test-svc` container, the PITR-tools probe + // fails and dispatch must fall back to the logical dump engine. + let svc = make_service("mariadb", "standalone"); + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async { + let docker = bollard::Docker::connect_with_local_defaults(); + if docker.is_err() { + return; + } + let docker = docker.unwrap(); + let result = resolve_engine_key(&svc, &docker).await; + assert!(matches!(result, Ok("mariadb_dump")), "got: {:?}", result); + }); + } + #[test] fn test_unsupported_service_type() { let svc = make_service("elasticsearch", "standalone"); diff --git a/crates/temps-backup/src/engines/mariadb_dump.rs b/crates/temps-backup/src/engines/mariadb_dump.rs new file mode 100644 index 000000000..2efb2d201 --- /dev/null +++ b/crates/temps-backup/src/engines/mariadb_dump.rs @@ -0,0 +1,276 @@ +//! `MariadbDumpEngine`: logical (`mariadb-dump`) backup of an external MariaDB +//! service, implemented against `engine_v2::BackupEngine`. +//! +//! This is the **fallback** engine (no PITR), the MariaDB analog of +//! `postgres_pgdump`. The preferred PITR path is `mariadb_physical` +//! (physical `mariadb-backup` base + binary-log archiving). Dispatch +//! (`dispatch::resolve_engine_key`) selects this engine only when the +//! physical-backup prerequisites are absent. +//! +//! ## Flow +//! 1. Load + decrypt the external-service row for the root password + image. +//! 2. Validate the configured S3 source. +//! 3. `docker exec` `mariadb-dump --databases ... --single-transaction | gzip` +//! inside the running container, streaming the gzipped stdout to a host +//! temp file. Credentials travel via `MYSQL_PWD` env — never argv (PR #149). +//! 4. Upload the `.sql.gz` to S3. +//! 5. Write the `metadata.json` companion. + +use std::sync::Arc; + +use async_trait::async_trait; +use sea_orm::{DatabaseConnection, EntityTrait}; +use serde_json::{json, Value}; +use tracing::{debug, info}; +use uuid::Uuid; + +use super::mariadb_exec::exec_stream_stdout_to_file; +use super::v2_common; +use temps_backup_core::engine_v2::{BackupContext, BackupEngine, BackupError, BackupOutcome}; + +const ENGINE_KEY: &str = "mariadb_dump"; +const DUMP_FILE_SUFFIX: &str = "dump.sql.gz"; + +/// In-container shell that dumps all user databases and gzips the result. +/// Credentials are NOT present here — `-uroot` relies on `MYSQL_PWD` from the +/// exec env. Keep it that way (PR #149). +const DUMP_SHELL: &str = "if command -v mariadb-dump >/dev/null 2>&1; then dump=mariadb-dump; else dump=mysqldump; fi; \ + if command -v mariadb >/dev/null 2>&1; then client=mariadb; else client=mysql; fi; \ + dbs=$($client -N -B -uroot -e \"SELECT SCHEMA_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME NOT IN ('information_schema','mysql','performance_schema','sys') ORDER BY SCHEMA_NAME\"); \ + if [ -z \"$dbs\" ]; then echo '-- No user databases to dump'; exit 0; fi; \ + $dump --databases $dbs --single-transaction --quick -uroot | gzip"; + +pub struct MariadbDumpDeps { + pub db: Arc, + pub encryption_service: Arc, + pub docker: bollard::Docker, +} + +pub struct MariadbDumpEngine { + deps: Arc, +} + +impl MariadbDumpEngine { + pub fn new(deps: MariadbDumpDeps) -> Self { + Self { + deps: Arc::new(deps), + } + } +} + +#[async_trait] +impl BackupEngine for MariadbDumpEngine { + fn engine(&self) -> &'static str { + ENGINE_KEY + } + + async fn run(&self, ctx: &BackupContext) -> Result { + let backup_id = ctx.backup_id; + let deps = Arc::clone(&self.deps); + + let service_id = v2_common::require_i32_param(&ctx.params, "service_id")?; + let s3_source_id = v2_common::require_i32_param(&ctx.params, "s3_source_id")?; + + let service = temps_entities::external_services::Entity::find_by_id(service_id) + .one(deps.db.as_ref()) + .await + .map_err(|e| BackupError::Failed { + reason: format!("db error loading service {}: {}", service_id, e), + })? + .ok_or_else(|| BackupError::PermanentFailure { + reason: format!("service {} not found", service_id), + })?; + + let (s3_source, s3_client) = v2_common::load_and_build_s3_client( + deps.db.as_ref(), + &deps.encryption_service, + s3_source_id, + "mariadb-dump-engine", + ) + .await?; + v2_common::assert_bucket_reachable(&s3_client, &s3_source.bucket_name).await?; + + let backup_uuid = Uuid::new_v4().to_string(); + let s3_key = v2_common::build_external_service_s3_key( + &s3_source.bucket_path, + "mariadb", + &service.name, + &backup_uuid, + DUMP_FILE_SUFFIX, + ); + + info!( + backup_id, + service_id, + s3_key = %s3_key, + "MariadbDumpEngine: starting logical dump", + ); + + let config_json = deps + .encryption_service + .decrypt_string(service.config.as_deref().unwrap_or("{}")) + .unwrap_or_else(|_| "{}".to_string()); + let root_password = root_password_from_config(&config_json); + + let container_name = format!("mariadb-{}", service.name); + let backup_dir = std::env::temp_dir().join("temps-mariadb-backup"); + tokio::fs::create_dir_all(&backup_dir) + .await + .map_err(|e| BackupError::Failed { + reason: format!( + "failed to create backup tmpdir {}: {}", + backup_dir.display(), + e + ), + })?; + let host_dump_path = backup_dir.join(format!("{}.sql.gz", backup_uuid)); + + // Credentials via env only (MYSQL_PWD / MARIADB_PWD) — never argv. + let env = vec![ + format!("MYSQL_PWD={}", root_password), + format!("MARIADB_PWD={}", root_password), + ]; + + let result = exec_stream_stdout_to_file( + &deps.docker, + &container_name, + DUMP_SHELL, + &env, + &host_dump_path, + &ctx.cancel, + ) + .await; + + let exec = match result { + Ok(e) => e, + Err(BackupError::Cancelled) => { + v2_common::best_effort_remove(&host_dump_path).await; + return Err(BackupError::Cancelled); + } + Err(e) => { + v2_common::best_effort_remove(&host_dump_path).await; + return Err(e); + } + }; + if exec.exit_code != 0 { + v2_common::best_effort_remove(&host_dump_path).await; + return Err(BackupError::Failed { + reason: format!( + "mariadb-dump exited with code {}. stderr: {}", + exec.exit_code, + exec.stderr.trim() + ), + }); + } + if !exec.stderr.trim().is_empty() { + debug!(backup_id, "mariadb-dump stderr: {}", exec.stderr.trim()); + } + + let dump_meta = + tokio::fs::metadata(&host_dump_path) + .await + .map_err(|e| BackupError::Failed { + reason: format!( + "dump file missing at {} after exit 0: {}", + host_dump_path.display(), + e + ), + })?; + if dump_meta.len() == 0 { + v2_common::best_effort_remove(&host_dump_path).await; + return Err(BackupError::Failed { + reason: "mariadb-dump produced an empty file".into(), + }); + } + let file_size = dump_meta.len() as i64; + let host_dump_path_str = host_dump_path.to_str().unwrap_or("").to_string(); + + if ctx.cancel.is_cancelled() { + v2_common::best_effort_remove(&host_dump_path).await; + return Err(BackupError::Cancelled); + } + let tags = v2_common::BackupTags::load_for_backup(&ctx.db, ctx.backup_id).await; + v2_common::upload_file( + &s3_client, + &s3_source.bucket_name, + &s3_key, + &host_dump_path_str, + "application/x-gzip", + file_size, + Some(&tags), + ) + .await?; + v2_common::best_effort_remove(&host_dump_path).await; + + let metadata_key = v2_common::derive_metadata_key(&s3_key); + v2_common::write_metadata_companion( + &s3_client, + &s3_source.bucket_name, + &metadata_key, + ENGINE_KEY, + &backup_uuid, + &s3_key, + file_size, + s3_source_id, + "gzip", + Some(json!({ + "backup_tool": "mariadb-dump", + "pitr": false, + "service": { "id": service_id, "name": service.name }, + })), + ) + .await?; + + info!( + backup_id, + key = %s3_key, + size_bytes = file_size, + "MariadbDumpEngine: backup complete", + ); + + Ok(BackupOutcome { + location: s3_key, + size_bytes: Some(file_size), + compression: "gzip".to_string(), + }) + } +} + +/// Extract the root password from the decrypted service config JSON. +fn root_password_from_config(config_json: &str) -> String { + let params: Value = serde_json::from_str(config_json).unwrap_or_else(|_| json!({})); + params + .get("root_password") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// PR #149 invariant: the dump shell must not contain the password. + /// Credentials travel via the exec env (`MYSQL_PWD`), so a password + /// containing shell metacharacters can never break out of `sh -c`. + #[test] + fn dump_shell_contains_no_credentials() { + assert!(!DUMP_SHELL.contains("MYSQL_PWD")); + assert!(!DUMP_SHELL.contains("password")); + // Connects as root via env-provided password, no password flag. + assert!(DUMP_SHELL.contains("-uroot")); + assert!(!DUMP_SHELL.contains("--password")); + assert!(!DUMP_SHELL.contains("-p'")); + assert!(!DUMP_SHELL.contains("-p\"")); + } + + #[test] + fn root_password_parsed_from_config() { + assert_eq!( + root_password_from_config(r#"{"root_password":"s3cr3t"}"#), + "s3cr3t" + ); + assert_eq!(root_password_from_config("{}"), ""); + assert_eq!(root_password_from_config("not json"), ""); + } +} diff --git a/crates/temps-backup/src/engines/mariadb_exec.rs b/crates/temps-backup/src/engines/mariadb_exec.rs new file mode 100644 index 000000000..f454d2c6d --- /dev/null +++ b/crates/temps-backup/src/engines/mariadb_exec.rs @@ -0,0 +1,215 @@ +//! Shared docker-exec helpers for the MariaDB backup engines +//! (`mariadb_physical`, `mariadb_dump`). +//! +//! These are **standalone free functions** living in `temps-backup` — they do +//! NOT call into `temps-providers::MariaDbService`. `temps-backup` already +//! depends on `temps-providers`, so reaching back the other way would be a +//! circular dependency. The engines therefore own their own `docker exec` +//! plumbing, mirroring how `dispatch::container_has_walg` and +//! `postgres_walg::run_walg_exec` keep WAL-G's docker access inside this crate. +//! +//! ## Credential safety (see upstream PR #149) +//! +//! Passing a DB password as a CLI argument leaks it via `ps`/`pgrep -af` / +//! `/proc//cmdline`. Every helper here takes credentials through the +//! exec `env` field (`MYSQL_PWD`/`MARIADB_PWD`) and NEVER interpolates them +//! into the `sh -c` command string. `mariadb-backup`, `mariadb-dump`, and the +//! `mariadb` client all read `MYSQL_PWD` from the environment, so `-uroot` +//! with no `-p` flag is sufficient. Tests pin this invariant. + +use bollard::container::LogOutput; +use bollard::exec::{CreateExecOptions, StartExecResults}; +use futures::StreamExt; +use tokio::io::AsyncWriteExt; +use tokio_util::sync::CancellationToken; + +use temps_backup_core::engine_v2::BackupError; + +/// Cap on captured stderr. `mariadb-backup` is chatty; we only need the tail +/// (which carries the binlog-position line) plus enough context for errors. +const STDERR_CAP: usize = 256 * 1024; + +/// Result of a streamed exec: the process exit code and its captured stderr. +/// stdout is streamed to a file and is not held in memory. +pub struct StreamedExec { + pub exit_code: i64, + pub stderr: String, +} + +/// Run `sh -c ` inside `container_name`, streaming **stdout** to +/// `out_path` (raw bytes, as produced — the caller is responsible for any +/// in-container `| gzip`) and capturing **stderr** into a bounded string. +/// +/// `env` entries are passed via the exec environment (where credentials +/// belong). `cmd` must never contain secrets. Bails early with +/// `BackupError::Cancelled` if `cancel` fires mid-stream. +pub async fn exec_stream_stdout_to_file( + docker: &bollard::Docker, + container_name: &str, + cmd: &str, + env: &[String], + out_path: &std::path::Path, + cancel: &CancellationToken, +) -> Result { + let env_refs: Vec<&str> = env.iter().map(|s| s.as_str()).collect(); + let exec = docker + .create_exec( + container_name, + CreateExecOptions { + cmd: Some(vec!["sh", "-c", cmd]), + env: Some(env_refs), + attach_stdout: Some(true), + attach_stderr: Some(true), + ..Default::default() + }, + ) + .await + .map_err(|e| BackupError::Failed { + reason: format!("create exec on {}: {}", container_name, e), + })?; + + let file = tokio::fs::File::create(out_path) + .await + .map_err(|e| BackupError::Failed { + reason: format!("create backup file {}: {}", out_path.display(), e), + })?; + let mut writer = tokio::io::BufWriter::new(file); + let mut stderr = String::new(); + + let stream = docker + .start_exec(&exec.id, None) + .await + .map_err(|e| BackupError::Failed { + reason: format!("start exec on {}: {}", container_name, e), + })?; + + if let StartExecResults::Attached { mut output, .. } = stream { + loop { + tokio::select! { + _ = cancel.cancelled() => { + return Err(BackupError::Cancelled); + } + item = output.next() => { + match item { + Some(Ok(LogOutput::StdOut { message })) => { + writer.write_all(&message).await.map_err(|e| BackupError::Failed { + reason: format!("write backup stream to {}: {}", out_path.display(), e), + })?; + } + Some(Ok(LogOutput::StdErr { message })) => { + if stderr.len() < STDERR_CAP { + stderr.push_str(&String::from_utf8_lossy(&message)); + } + } + Some(Ok(_)) => {} + Some(Err(e)) => { + return Err(BackupError::Failed { + reason: format!("exec stream error on {}: {}", container_name, e), + }); + } + None => break, + } + } + } + } + } + + writer.flush().await.map_err(|e| BackupError::Failed { + reason: format!("flush backup file {}: {}", out_path.display(), e), + })?; + + let inspect = docker + .inspect_exec(&exec.id) + .await + .map_err(|e| BackupError::Failed { + reason: format!("inspect exec on {}: {}", container_name, e), + })?; + + Ok(StreamedExec { + exit_code: inspect.exit_code.unwrap_or(-1), + stderr, + }) +} + +/// Binlog coordinates captured at base-backup time. These anchor PITR: replay +/// starts from `(file, position)` (or `gtid`) and runs forward to the +/// recovery target. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BinlogCoord { + pub file: String, + pub position: String, + /// MariaDB GTID (`domain-server-seq`, e.g. `0-1-12`). Empty if the source + /// has GTID disabled. + pub gtid: String, +} + +/// Parse the binlog-position line `mariadb-backup` prints to stderr at the end +/// of a successful `--backup`. Format (MariaDB 10.x–12.x): +/// +/// ```text +/// mariabackup: MySQL binlog position: filename 'mysql-bin.000003', position '342', GTID of the last change '0-1-12' +/// ``` +/// +/// Older builds omit the GTID clause. Returns `None` if no position line is +/// present (e.g. binary logging disabled on the source). +pub fn parse_binlog_position(stderr: &str) -> Option { + // Anchor on "binlog position:" so we don't mis-match the bare word + // "position" elsewhere in the log. + let anchor = stderr.find("binlog position:")?; + let tail = &stderr[anchor..]; + let file = extract_quoted_after(tail, "filename", 0)?; + // Search for "position '" strictly after the filename match so the + // "binlog position:" header (which has no quote) is skipped. + let pos_key = tail.find("position '")?; + let position = extract_quoted_after(tail, "position", pos_key)?; + let gtid = tail + .find("GTID of the last change") + .and_then(|idx| extract_quoted_after(tail, "GTID of the last change", idx)) + .unwrap_or_default(); + Some(BinlogCoord { + file, + position, + gtid, + }) +} + +/// Find `key` at/after `from`, then return the next single-quoted value. +fn extract_quoted_after(hay: &str, key: &str, from: usize) -> Option { + let key_idx = hay.get(from..)?.find(key)? + from; + let after = &hay[key_idx + key.len()..]; + let open = after.find('\'')?; + let rest = &after[open + 1..]; + let close = rest.find('\'')?; + Some(rest[..close].to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_full_binlog_position_with_gtid() { + let stderr = "[00] 2024-06-23 mariabackup: MySQL binlog position: \ + filename 'mysql-bin.000003', position '342', GTID of the last change '0-1-12'\n"; + let c = parse_binlog_position(stderr).expect("should parse"); + assert_eq!(c.file, "mysql-bin.000003"); + assert_eq!(c.position, "342"); + assert_eq!(c.gtid, "0-1-12"); + } + + #[test] + fn parses_binlog_position_without_gtid() { + let stderr = + "mariabackup: MySQL binlog position: filename 'mariadb-bin.000007', position '15201'\n"; + let c = parse_binlog_position(stderr).expect("should parse"); + assert_eq!(c.file, "mariadb-bin.000007"); + assert_eq!(c.position, "15201"); + assert_eq!(c.gtid, ""); + } + + #[test] + fn returns_none_when_binlog_disabled() { + let stderr = "mariabackup: completed OK!\n"; + assert!(parse_binlog_position(stderr).is_none()); + } +} diff --git a/crates/temps-backup/src/engines/mariadb_physical.rs b/crates/temps-backup/src/engines/mariadb_physical.rs new file mode 100644 index 000000000..0ce7c3a1a --- /dev/null +++ b/crates/temps-backup/src/engines/mariadb_physical.rs @@ -0,0 +1,324 @@ +//! `MariadbPhysicalEngine`: physical (`mariadb-backup`) base backup of an +//! external MariaDB service, implemented against `engine_v2::BackupEngine`. +//! +//! This is the **PITR** engine — the MariaDB analog of `postgres_walg`. +//! MariaDB has no turnkey continuous archiver (WAL-G drives MariaDB but does +//! not support automatic PITR for it), so PITR here is the standard, +//! MariaDB-documented approach: +//! +//! physical base backup + archived binary logs + `mariadb-binlog` replay +//! +//! This engine owns the **base backup** half: it streams a `mariadb-backup` +//! physical snapshot to S3 and records the binlog coordinates +//! (`file`/`position`/`gtid`) at backup time into the `metadata.json` +//! companion. Those coordinates are the replay start for restore. The +//! continuous **binary-log archiving** half lives in `temps-providers` +//! (per-service background task) and ships closed binlog segments to the same +//! S3 prefix. +//! +//! ## Flow +//! 1. Load + decrypt the external-service row for the root password. +//! 2. Validate the configured S3 source. +//! 3. `docker exec mariadb-backup --backup --stream=mbstream | gzip` inside the +//! running container, streaming the gzipped stream to a host temp file. +//! Credentials travel via `MYSQL_PWD` env — never argv (PR #149). +//! 4. Verify success via the `"completed OK!"` stderr marker (the container's +//! `/bin/sh` is dash, which has no `pipefail`, so the pipeline exit code is +//! gzip's, not `mariadb-backup`'s). +//! 5. Parse the binlog position from stderr. +//! 6. Upload the `.mbstream.gz` to S3 and write `metadata.json` with the coords. + +use std::sync::Arc; + +use async_trait::async_trait; +use sea_orm::{DatabaseConnection, EntityTrait}; +use serde_json::{json, Value}; +use tracing::{info, warn}; +use uuid::Uuid; + +use super::mariadb_exec::{exec_stream_stdout_to_file, parse_binlog_position}; +use super::v2_common; +use temps_backup_core::engine_v2::{BackupContext, BackupEngine, BackupError, BackupOutcome}; + +const ENGINE_KEY: &str = "mariadb_physical"; +const BASE_FILE_SUFFIX: &str = "base.mbstream.gz"; + +/// In-container shell that streams a physical base backup to stdout and gzips +/// it. Credentials are NOT present — `--user=root` relies on `MYSQL_PWD` from +/// the exec env (libmariadb reads it). Keep it that way (PR #149). +/// +/// `--target-dir` is a scratch dir mariadb-backup needs even when streaming. +/// Success is asserted via the `"completed OK!"` stderr marker, since the +/// pipe-to-gzip masks mariadb-backup's own exit code under dash. +/// +/// CRITICAL: the `| gzip` must bind to the `mariadb-backup` command, NOT a +/// trailing statement. In `sh`, `a; b; c | gzip` pipes only `c` to gzip - so +/// the scratch-dir cleanup runs *before* the backup (rm-then-mkdir) and the +/// command ends with the gzip pipeline, making stdout the gzipped mbstream. +const PHYSICAL_SHELL: &str = "if command -v mariadb-backup >/dev/null 2>&1; then BK=mariadb-backup; else BK=mariabackup; fi; \ + rm -rf /var/tmp/temps-mariadb-backup; mkdir -p /var/tmp/temps-mariadb-backup; \ + \"$BK\" --backup --stream=mbstream --target-dir=/var/tmp/temps-mariadb-backup --user=root --host=localhost | gzip"; + +pub struct MariadbPhysicalDeps { + pub db: Arc, + pub encryption_service: Arc, + pub docker: bollard::Docker, +} + +pub struct MariadbPhysicalEngine { + deps: Arc, +} + +impl MariadbPhysicalEngine { + pub fn new(deps: MariadbPhysicalDeps) -> Self { + Self { + deps: Arc::new(deps), + } + } +} + +#[async_trait] +impl BackupEngine for MariadbPhysicalEngine { + fn engine(&self) -> &'static str { + ENGINE_KEY + } + + async fn run(&self, ctx: &BackupContext) -> Result { + let backup_id = ctx.backup_id; + let deps = Arc::clone(&self.deps); + + let service_id = v2_common::require_i32_param(&ctx.params, "service_id")?; + let s3_source_id = v2_common::require_i32_param(&ctx.params, "s3_source_id")?; + + let service = temps_entities::external_services::Entity::find_by_id(service_id) + .one(deps.db.as_ref()) + .await + .map_err(|e| BackupError::Failed { + reason: format!("db error loading service {}: {}", service_id, e), + })? + .ok_or_else(|| BackupError::PermanentFailure { + reason: format!("service {} not found", service_id), + })?; + + let (s3_source, s3_client) = v2_common::load_and_build_s3_client( + deps.db.as_ref(), + &deps.encryption_service, + s3_source_id, + "mariadb-physical-engine", + ) + .await?; + v2_common::assert_bucket_reachable(&s3_client, &s3_source.bucket_name).await?; + + let backup_uuid = Uuid::new_v4().to_string(); + let s3_key = v2_common::build_external_service_s3_key( + &s3_source.bucket_path, + "mariadb", + &service.name, + &backup_uuid, + BASE_FILE_SUFFIX, + ); + + info!( + backup_id, + service_id, + s3_key = %s3_key, + "MariadbPhysicalEngine: starting physical base backup", + ); + + let config_json = deps + .encryption_service + .decrypt_string(service.config.as_deref().unwrap_or("{}")) + .unwrap_or_else(|_| "{}".to_string()); + let root_password = root_password_from_config(&config_json); + + let container_name = format!("mariadb-{}", service.name); + let backup_dir = std::env::temp_dir().join("temps-mariadb-backup"); + tokio::fs::create_dir_all(&backup_dir) + .await + .map_err(|e| BackupError::Failed { + reason: format!( + "failed to create backup tmpdir {}: {}", + backup_dir.display(), + e + ), + })?; + let host_path = backup_dir.join(format!("{}.mbstream.gz", backup_uuid)); + + // Credentials via env only (MYSQL_PWD / MARIADB_PWD) — never argv. + let env = vec![ + format!("MYSQL_PWD={}", root_password), + format!("MARIADB_PWD={}", root_password), + ]; + + let exec = match exec_stream_stdout_to_file( + &deps.docker, + &container_name, + // PHYSICAL_SHELL already ends with `| gzip` on the backup command. + PHYSICAL_SHELL, + &env, + &host_path, + &ctx.cancel, + ) + .await + { + Ok(e) => e, + Err(BackupError::Cancelled) => { + v2_common::best_effort_remove(&host_path).await; + return Err(BackupError::Cancelled); + } + Err(e) => { + v2_common::best_effort_remove(&host_path).await; + return Err(e); + } + }; + + // dash has no pipefail, so the pipeline exit code is gzip's. Assert + // mariadb-backup success via its terminal stderr marker instead. + if !exec.stderr.contains("completed OK!") { + v2_common::best_effort_remove(&host_path).await; + return Err(BackupError::Failed { + reason: format!( + "mariadb-backup did not report success (no 'completed OK!'). \ + pipeline exit={}. stderr tail: {}", + exec.exit_code, + stderr_tail(&exec.stderr), + ), + }); + } + + let meta = tokio::fs::metadata(&host_path) + .await + .map_err(|e| BackupError::Failed { + reason: format!("base file missing at {}: {}", host_path.display(), e), + })?; + if meta.len() == 0 { + v2_common::best_effort_remove(&host_path).await; + return Err(BackupError::Failed { + reason: "mariadb-backup produced an empty stream".into(), + }); + } + let file_size = meta.len() as i64; + let host_path_str = host_path.to_str().unwrap_or("").to_string(); + + // Binlog coordinates anchor PITR replay. Absence means binary logging + // is off on the source — the base is still a valid full backup, but + // PITR will not be possible until binlog archiving is enabled. + let coord = parse_binlog_position(&exec.stderr); + match &coord { + Some(c) => info!( + backup_id, + binlog_file = %c.file, + binlog_position = %c.position, + gtid = %c.gtid, + "MariadbPhysicalEngine: captured binlog coordinates", + ), + None => warn!( + backup_id, + "MariadbPhysicalEngine: no binlog position in mariadb-backup output \ + (binary logging disabled on source?) — PITR will be unavailable for this base", + ), + } + + if ctx.cancel.is_cancelled() { + v2_common::best_effort_remove(&host_path).await; + return Err(BackupError::Cancelled); + } + let tags = v2_common::BackupTags::load_for_backup(&ctx.db, ctx.backup_id).await; + v2_common::upload_file( + &s3_client, + &s3_source.bucket_name, + &s3_key, + &host_path_str, + "application/x-gzip", + file_size, + Some(&tags), + ) + .await?; + v2_common::best_effort_remove(&host_path).await; + + let metadata_key = v2_common::derive_metadata_key(&s3_key); + v2_common::write_metadata_companion( + &s3_client, + &s3_source.bucket_name, + &metadata_key, + ENGINE_KEY, + &backup_uuid, + &s3_key, + file_size, + s3_source_id, + "gzip", + Some(json!({ + "backup_tool": "mariadb-backup", + "stream_format": "mbstream", + "pitr": coord.is_some(), + "binlog_file": coord.as_ref().map(|c| c.file.clone()).unwrap_or_default(), + "binlog_position": coord.as_ref().map(|c| c.position.clone()).unwrap_or_default(), + "gtid": coord.as_ref().map(|c| c.gtid.clone()).unwrap_or_default(), + "service": { "id": service_id, "name": service.name }, + })), + ) + .await?; + + info!( + backup_id, + key = %s3_key, + size_bytes = file_size, + pitr = coord.is_some(), + "MariadbPhysicalEngine: backup complete", + ); + + Ok(BackupOutcome { + location: s3_key, + size_bytes: Some(file_size), + compression: "gzip".to_string(), + }) + } +} + +fn stderr_tail(stderr: &str) -> String { + const TAIL: usize = 2000; + let trimmed = stderr.trim(); + if trimmed.len() <= TAIL { + return trimmed.to_string(); + } + let start = trimmed.len() - TAIL; + format!("…{}", &trimmed[start..]) +} + +fn root_password_from_config(config_json: &str) -> String { + let params: Value = serde_json::from_str(config_json).unwrap_or_else(|_| json!({})); + params + .get("root_password") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// PR #149 invariant: the base-backup shell must not contain credentials. + /// Connection auth flows through `MYSQL_PWD` in the exec env. + #[test] + fn physical_shell_contains_no_credentials() { + // PHYSICAL_SHELL is a const with no interpolation — guard against a + // future refactor hardcoding a password flag. (We can't assert + // `!contains("-p")` because `mkdir -p` is a legitimate, benign use.) + assert!(!PHYSICAL_SHELL.contains("MYSQL_PWD")); + assert!(!PHYSICAL_SHELL.contains("--password")); + // No mysql-style short password flag (`-p` / `-p'...'`). + assert!(!PHYSICAL_SHELL.contains("-p'")); + assert!(!PHYSICAL_SHELL.contains("-p\"")); + assert!(PHYSICAL_SHELL.contains("--user=root")); + assert!(PHYSICAL_SHELL.contains("--stream=mbstream")); + } + + #[test] + fn stderr_tail_truncates_long_output() { + let long = "x".repeat(5000); + let tail = stderr_tail(&long); + assert!(tail.starts_with('…')); + assert!(tail.len() < 5000); + } +} diff --git a/crates/temps-backup/src/engines/mod.rs b/crates/temps-backup/src/engines/mod.rs index d7a24e614..81f291375 100644 --- a/crates/temps-backup/src/engines/mod.rs +++ b/crates/temps-backup/src/engines/mod.rs @@ -13,6 +13,8 @@ //! - [`postgres_pgdump`]: Postgres via pg_dump sidecar (fallback). //! - [`postgres_walg`]: Postgres via WAL-G (preferred when available). //! - [`postgres_cluster`]: Postgres cluster (pg_auto_failover) via WAL-G. +//! - [`mariadb_physical`]: MariaDB via `mariadb-backup` physical base (PITR). +//! - [`mariadb_dump`]: MariaDB via `mariadb-dump` logical dump (fallback). //! - [`s3_mirror`]: S3-compatible object storage via `mc mirror`. //! - [`dispatch`]: Engine-key resolution helper (`resolve_engine_key`). //! @@ -24,6 +26,9 @@ pub mod control_plane; pub mod dispatch; pub mod image_pull; +pub mod mariadb_dump; +pub mod mariadb_exec; +pub mod mariadb_physical; pub mod mongodb; pub mod oneshot; pub mod postgres_cluster; diff --git a/crates/temps-backup/src/plugin.rs b/crates/temps-backup/src/plugin.rs index 3297247da..97f62d5db 100644 --- a/crates/temps-backup/src/plugin.rs +++ b/crates/temps-backup/src/plugin.rs @@ -13,6 +13,8 @@ use utoipa::OpenApi as OpenApiTrait; use crate::{ engines::{ control_plane::{ControlPlaneDeps, ControlPlaneEngine}, + mariadb_dump::{MariadbDumpDeps, MariadbDumpEngine}, + mariadb_physical::{MariadbPhysicalDeps, MariadbPhysicalEngine}, mongodb::{MongodbDeps, MongodbEngine}, postgres_cluster::{PostgresClusterDeps, PostgresClusterEngine}, postgres_pgdump::{PostgresPgDumpDeps, PostgresPgDumpEngine}, @@ -160,6 +162,16 @@ impl TempsPlugin for BackupPlugin { encryption_service: encryption_service.clone(), docker: docker.as_ref().clone(), }))) + .register_engine(Arc::new(MariadbPhysicalEngine::new(MariadbPhysicalDeps { + db: db.clone(), + encryption_service: encryption_service.clone(), + docker: docker.as_ref().clone(), + }))) + .register_engine(Arc::new(MariadbDumpEngine::new(MariadbDumpDeps { + db: db.clone(), + encryption_service: encryption_service.clone(), + docker: docker.as_ref().clone(), + }))) .register_engine(Arc::new(S3MirrorEngine::new(S3MirrorDeps { db: db.clone(), encryption_service: encryption_service.clone(), @@ -169,8 +181,9 @@ impl TempsPlugin for BackupPlugin { ); info!( - "BackupExecutor: registered 7 engines: control_plane, redis, \ - postgres_pgdump, postgres_walg, postgres_cluster, mongodb, s3_mirror", + "BackupExecutor: registered 9 engines: control_plane, redis, \ + postgres_pgdump, postgres_walg, postgres_cluster, mongodb, \ + mariadb_physical, mariadb_dump, s3_mirror", ); // Wire the JobQueue into BackupService so trigger paths can @@ -352,6 +365,37 @@ impl TempsPlugin for BackupPlugin { }); drop(schedule_cancel); + // Default-backup auto-provisioner. Every 5 minutes, ensure each + // MariaDB external service has a covering daily full-backup + // schedule (base backups -> point-in-time recovery out of the + // box). Gated by the per-service `default_backup_provisioned` + // latch so it provisions exactly once and never recreates a + // schedule the operator deletes. Errors are logged and swallowed + // so a reconcile failure never crashes the task; the next tick + // retries (handles "default S3 source configured later"). + tokio::spawn({ + let backup_service = Arc::clone(&backup_service); + async move { + let mut tick = tokio::time::interval(std::time::Duration::from_secs(5 * 60)); + tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + // First tick fires immediately so newly-created MariaDB + // services get a schedule shortly after startup. + loop { + tick.tick().await; + if let Err(e) = backup_service + .reconcile_default_external_service_schedules() + .await + { + error!( + error = %e, + "default-backup auto-provision reconcile failed \ + (will retry next tick)" + ); + } + } + } + }); + Ok(()) }) } diff --git a/crates/temps-backup/src/services/backup.rs b/crates/temps-backup/src/services/backup.rs index f22d9d5ed..1f94b0c03 100644 --- a/crates/temps-backup/src/services/backup.rs +++ b/crates/temps-backup/src/services/backup.rs @@ -4119,6 +4119,172 @@ impl BackupService { Ok(result.rows_affected()) } + /// Auto-provision a covering daily full-backup schedule for every MariaDB + /// external service that does not yet have one. + /// + /// This drives point-in-time recovery out of the box: the daily full + /// backup produces base backups via the `mariadb_physical` engine, and the + /// binlog archiver already ships binary logs every few minutes, so + /// base + binlogs = PITR with no operator action. + /// + /// Design (gated by the per-service `default_backup_provisioned` latch): + /// - Only services where `default_backup_provisioned = false` are + /// considered, so we provision **exactly once** and never recreate a + /// schedule the operator later deletes. + /// - Scope is **MariaDB only** (`service_type = "mariadb"`). + /// - Requires a configured default S3 source. If none exists yet we log at + /// `debug` and return `Ok(())` — the next periodic tick retries, which + /// handles the "storage configured after the service" ordering. + /// - Per-service failures are logged at `warn` and skipped, leaving the + /// latch `false` so the service is retried on the next tick. A single bad + /// service can't block the others. + /// + /// Idempotent and safe to call on a periodic tick. + pub async fn reconcile_default_external_service_schedules(&self) -> Result<(), BackupError> { + use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; + + // 1. Resolve the default S3 source. If none is configured yet, this is + // not an error — we simply have nothing to point a schedule at, so + // we bail quietly and retry on the next tick. + let s3_source_id = match self.resolve_s3_source_id(None).await { + Ok(id) => id, + Err(_) => { + debug!( + "reconcile_default_external_service_schedules: no default S3 source \ + configured yet, skipping (will retry next tick)" + ); + return Ok(()); + } + }; + + // 2. Load unprovisioned MariaDB services. + let services = temps_entities::external_services::Entity::find() + .filter(temps_entities::external_services::Column::ServiceType.eq("mariadb")) + .filter(temps_entities::external_services::Column::DefaultBackupProvisioned.eq(false)) + .all(self.db.as_ref()) + .await?; + + if services.is_empty() { + return Ok(()); + } + + debug!( + count = services.len(), + s3_source_id, + "reconcile_default_external_service_schedules: provisioning default backup \ + schedules for MariaDB services" + ); + + // 3. For each, create a daily full-backup schedule targeting exactly + // that service, then flip the latch. + for service in services { + if let Err(e) = self.provision_default_schedule_for_service(&service).await { + // Leave default_backup_provisioned = false so the next tick + // retries. One failing service must not block the others. + warn!( + service_id = service.id, + service_name = %service.name, + error = %e, + "Failed to auto-provision default backup schedule for MariaDB service; \ + will retry on next reconcile tick" + ); + continue; + } + } + + Ok(()) + } + + /// Create the default daily full-backup schedule for a single MariaDB + /// service and mark it provisioned. Helper for + /// [`reconcile_default_external_service_schedules`]; on success the + /// service's `default_backup_provisioned` latch is set to `true`. + async fn provision_default_schedule_for_service( + &self, + service: &temps_entities::external_services::Model, + ) -> Result<(), BackupError> { + use sea_orm::{ActiveModelTrait, Set}; + + // Daily at 03:00 UTC. 6-field cron (`sec min hour dom mon dow`) as + // required by the `cron` crate / `validate_backup_schedule`; the two + // adjacent occurrences are 24h apart, satisfying the validator's + // "at least 1 hour" rule. Reuse create_backup_schedule for validation + // and next-run computation — do NOT hand-roll a second insert. + let request = CreateBackupScheduleRequest { + name: format!("Auto base backup — {}", service.name), + // `backup_type` is the schedule/job label ("full"); the actual + // backup engine (`mariadb_physical`) is resolved from the service's + // `service_type` at run time, not from this field. + backup_type: "full".to_string(), + // Days. 14 days of base backups is a sane default retention window. + retention_period: 14, + // Use the resolved default S3 source. + s3_source_id: None, + schedule_expression: "0 0 3 * * *".to_string(), + enabled: true, + description: Some( + "Automatically created daily base backup so point-in-time recovery \ + works out of the box. Safe to edit or delete." + .to_string(), + ), + tags: vec![], + max_runtime_secs: None, + // Target exactly this service (attached below), not every DB. + // + // `create_backup_schedule` refuses to create a schedule that has + // nothing to back up (target_all=false AND include_control_plane= + // false) because no services can be attached until the schedule + // row exists. So we create it with the control plane temporarily + // included, attach the service, then flip include_control_plane + // off via `update_backup_schedule` — which permits the otherwise- + // empty combination precisely because a service is now attached. + target_all_services: Some(false), + include_control_plane: Some(true), + }; + + let schedule = self.create_backup_schedule(request).await?; + + // Attach exactly this service so the schedule's fan-out targets it. + self.attach_services_to_schedule(schedule.id, &[service.id]) + .await?; + + // Now that the service is attached, narrow the schedule down to exactly + // that service: drop the control-plane backup so the schedule only + // produces base backups for this MariaDB service. + let schedule = self + .update_backup_schedule( + schedule.id, + UpdateBackupScheduleRequest { + name: None, + description: None, + schedule_expression: None, + retention_period: None, + max_runtime_secs: None, + enabled: None, + tags: None, + target_all_services: None, + include_control_plane: Some(false), + }, + ) + .await?; + + // Flip the one-shot latch so we never provision this service again. + let mut active: temps_entities::external_services::ActiveModel = service.clone().into(); + active.default_backup_provisioned = Set(true); + active.update(self.db.as_ref()).await?; + + info!( + service_id = service.id, + service_name = %service.name, + schedule_id = schedule.id, + schedule_name = %schedule.name, + "Auto-provisioned default daily base-backup schedule for MariaDB service \ + (enables point-in-time recovery; edit or delete it like any schedule)" + ); + + Ok(()) + } + /// Detach a single external service from a backup schedule. /// /// Returns `true` if a row was removed, `false` if nothing was attached. @@ -9179,6 +9345,7 @@ mod tests { consecutive_health_failures: Set(0), health_metadata: Set(None), metrics_enabled: Set(false), + default_backup_provisioned: Set(false), created_at: Set(chrono::Utc::now()), updated_at: Set(chrono::Utc::now()), }; @@ -9354,6 +9521,7 @@ mod tests { consecutive_health_failures: Set(0), health_metadata: Set(None), metrics_enabled: Set(false), + default_backup_provisioned: Set(false), created_at: Set(chrono::Utc::now()), updated_at: Set(chrono::Utc::now()), } @@ -9502,6 +9670,208 @@ mod tests { ); } + /// The daily base-backup cron expression used by the auto-provisioner + /// (`reconcile_default_external_service_schedules`) must satisfy + /// `validate_backup_schedule`: parse under the `cron` crate's 6-field + /// format and produce adjacent runs at least one hour apart. This guards + /// the load-bearing literal so a typo can't ship a schedule that the + /// validator rejects at provision time. + #[tokio::test] + async fn auto_provision_cron_expression_is_valid() { + // Same expression as provision_default_schedule_for_service. + const DAILY_3AM: &str = "0 0 3 * * *"; + + // Parses under the cron crate (6-field sec/min/hour/dom/mon/dow). + let schedule = + Schedule::from_str(DAILY_3AM).expect("auto-provision cron expression must parse"); + + // Two adjacent runs are 24h apart -> passes the >= 1h rule in + // validate_backup_schedule. + let next_two: Vec<_> = schedule.upcoming(Utc).take(2).collect(); + assert_eq!(next_two.len(), 2, "expected two upcoming runs"); + let gap = next_two[1] - next_two[0]; + assert_eq!( + gap.num_hours(), + 24, + "daily base-backup runs should be 24h apart, got {} hours", + gap.num_hours() + ); + } + + /// `reconcile_default_external_service_schedules` is a safe no-op when no + /// default S3 source is configured: `resolve_s3_source_id(None)` errors, + /// the reconcile swallows it and returns `Ok(())` so the periodic tick can + /// retry once storage is configured. The MockDatabase returns an empty + /// `s3_sources` result for the `is_default = true` lookup, so the service + /// never reaches the MariaDB query. + #[tokio::test] + async fn reconcile_default_schedules_noop_without_default_source() { + if skip_if_no_docker() { + return; + } + let db = Arc::new( + MockDatabase::new(DatabaseBackend::Postgres) + // get_default_s3_source: no default configured -> empty result. + .append_query_results(vec![Vec::::new()]) + .into_connection(), + ); + let svc = BackupService::new( + db.clone(), + create_mock_external_service_manager(db), + create_mock_notification_service(), + create_mock_config_service(), + Arc::new(EncryptionService::new("test_encryption_key_1234567890ab").unwrap()), + ); + + let result = svc.reconcile_default_external_service_schedules().await; + assert!( + result.is_ok(), + "reconcile must be a no-op (Ok) when no default S3 source exists, got {:?}", + result + ); + } + + /// Full-path integration test (needs a real DB + Docker): with a default + /// S3 source and an unprovisioned MariaDB service, reconcile creates + /// exactly one daily schedule, attaches the service to it, flips + /// `default_backup_provisioned`, and is idempotent on a second call. + #[tokio::test] + async fn reconcile_default_schedules_provisions_mariadb_once() { + if skip_if_no_docker() { + return; + } + use sea_orm::ActiveValue::Set; + use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; + use temps_database::test_utils::TestDatabase; + + let test_db = match TestDatabase::with_migrations().await { + Ok(d) => d, + Err(e) => { + println!("TestDatabase unavailable, skipping: {e}"); + return; + } + }; + let db = test_db.db.clone(); + + // Default S3 source so resolve_s3_source_id(None) succeeds. + temps_entities::s3_sources::ActiveModel { + id: sea_orm::NotSet, + name: Set("auto-prov-source".to_string()), + bucket_name: Set("b".to_string()), + bucket_path: Set("/".to_string()), + access_key_id: Set("".to_string()), + secret_key: Set("".to_string()), + region: Set("us-east-1".to_string()), + endpoint: Set(None), + force_path_style: Set(Some(true)), + is_default: Set(true), + created_at: Set(chrono::Utc::now()), + updated_at: Set(chrono::Utc::now()), + } + .insert(db.as_ref()) + .await + .expect("insert s3 source"); + + // One unprovisioned MariaDB service + one Postgres service (which must + // be left alone — scope is MariaDB only). + let maria = temps_entities::external_services::ActiveModel { + id: sea_orm::NotSet, + name: Set("maria-auto".to_string()), + service_type: Set("mariadb".to_string()), + status: Set("running".to_string()), + topology: Set("standalone".to_string()), + ..Default::default() + } + .insert(db.as_ref()) + .await + .expect("insert mariadb service"); + + let pg = temps_entities::external_services::ActiveModel { + id: sea_orm::NotSet, + name: Set("pg-untouched".to_string()), + service_type: Set("postgres".to_string()), + status: Set("running".to_string()), + topology: Set("standalone".to_string()), + ..Default::default() + } + .insert(db.as_ref()) + .await + .expect("insert postgres service"); + + let svc = BackupService::new( + db.clone(), + create_mock_external_service_manager(db.clone()), + create_mock_notification_service(), + create_mock_config_service(), + Arc::new(EncryptionService::new("test_encryption_key_1234567890ab").unwrap()), + ); + + svc.reconcile_default_external_service_schedules() + .await + .expect("first reconcile succeeds"); + + // Exactly one schedule created. + let schedules = temps_entities::backup_schedules::Entity::find() + .all(db.as_ref()) + .await + .expect("list schedules"); + assert_eq!( + schedules.len(), + 1, + "expected exactly one auto-provisioned schedule" + ); + let schedule = &schedules[0]; + assert_eq!(schedule.schedule_expression, "0 0 3 * * *"); + assert_eq!(schedule.backup_type, "full"); + assert_eq!(schedule.retention_period, 14); + assert!(!schedule.target_all_services); + assert!(!schedule.include_control_plane); + + // The MariaDB service is attached to it. + let attached = svc + .list_services_for_schedule(schedule.id) + .await + .expect("list attached services"); + assert_eq!(attached.len(), 1, "exactly the mariadb service attached"); + assert_eq!(attached[0].id, maria.id); + + // Latch flipped on MariaDB, untouched on Postgres. + let maria_after = temps_entities::external_services::Entity::find_by_id(maria.id) + .one(db.as_ref()) + .await + .expect("reload mariadb") + .expect("mariadb exists"); + assert!( + maria_after.default_backup_provisioned, + "mariadb latch must be set after provisioning" + ); + let pg_after = temps_entities::external_services::Entity::find_by_id(pg.id) + .one(db.as_ref()) + .await + .expect("reload postgres") + .expect("postgres exists"); + assert!( + !pg_after.default_backup_provisioned, + "non-mariadb services must never be provisioned" + ); + + // Idempotency: a second reconcile creates nothing new. + svc.reconcile_default_external_service_schedules() + .await + .expect("second reconcile succeeds"); + let count_after = temps_entities::backup_schedules::Entity::find() + .filter(temps_entities::backup_schedules::Column::Id.eq(schedule.id)) + .count(db.as_ref()) + .await + .expect("count"); + let total = temps_entities::backup_schedules::Entity::find() + .count(db.as_ref()) + .await + .expect("count all"); + assert_eq!(count_after, 1); + assert_eq!(total, 1, "second reconcile must not create a duplicate"); + } + /// Integration test: when `include_control_plane = false` and a single /// service is attached, the fan-out produces exactly one backup row /// (no control-plane row alongside it). This is the scenario from @@ -9584,6 +9954,7 @@ mod tests { consecutive_health_failures: Set(0), health_metadata: Set(None), metrics_enabled: Set(false), + default_backup_provisioned: Set(false), created_at: Set(chrono::Utc::now()), updated_at: Set(chrono::Utc::now()), } diff --git a/crates/temps-backup/tests/mariadb_pitr_e2e.rs b/crates/temps-backup/tests/mariadb_pitr_e2e.rs new file mode 100644 index 000000000..986a5c501 --- /dev/null +++ b/crates/temps-backup/tests/mariadb_pitr_e2e.rs @@ -0,0 +1,786 @@ +//! Full-chain MariaDB point-in-time-recovery (PITR) end-to-end integration test. +//! +//! This drives the **real** engine + provider code paths against **real** +//! containers - no mocks of the backup/restore mechanics: +//! +//! 1. Boot MinIO (S3) + create a bucket. +//! 2. Boot a `mariadb:lts` "source" container with binary logging on. +//! 3. Stand up a Postgres test DB with the real schema (`TestDatabase`), +//! then insert an `external_services` row (config encrypted with the +//! SAME `EncryptionService` the engine uses) + an `s3_sources` row +//! (access key / secret key encrypted with that same service). +//! 4. Seed batch A -> run the REAL `MariadbPhysicalEngine` base backup. +//! 5. Insert batch B, capture timestamp T, insert batch C -> +//! run the REAL `MariaDbService::archive_binlogs` archiver. +//! 6. Run the REAL `MariaDbService::restore_pitr` to time T into a new service. +//! 7. Assert A + B present and C absent in the restored container. +//! +//! All containers are reaped via RAII guards even on panic. +//! +//! ## Docker-access caveat +//! Boots happen via raw `bollard` against the local Docker socket. When the +//! socket is unreachable (the common local case where the user can't reach +//! Docker without sudo, or CI runners without Docker), every boot helper +//! returns `None` and the test prints a skip message and PASSES - it never +//! hard-fails. CI runs this with `--features docker-tests` on a runner that +//! has a real Docker daemon, which is the authoritative run. +//! +//! Gated behind the `docker-tests` feature (mirrors `temps-providers`). +#![cfg(feature = "docker-tests")] + +use std::collections::HashMap; +use std::sync::{Arc, Once}; +use std::time::Duration; + +use aws_sdk_s3::config::Region; +use bollard::Docker; +use sea_orm::{ActiveModelTrait, Set}; +use sqlx::mysql::MySqlPoolOptions; +use temps_backup_core::engine_v2::{BackupContext, BackupEngine}; +use temps_core::EncryptionService; +use temps_providers::externalsvc::{ + ExternalService, MariaDbService, RecoveryTarget, RestoreContext, S3Credentials, ServiceConfig, + ServiceType, +}; +use tokio_util::sync::CancellationToken; + +// A fixed 64-hex-char master key (== 32 bytes) shared by the test and every +// EncryptionService instance, so encrypt-here / decrypt-in-engine round-trips. +const MASTER_KEY_HEX: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const ROOT_PASSWORD: &str = "pitr-root-pw-1234"; // >= 8 chars, no quotes/backslashes +const MINIO_ACCESS_KEY: &str = "minioadmin"; +const MINIO_SECRET_KEY: &str = "minioadmin"; +const BUCKET: &str = "pitr-test-bucket"; +const E2E_TIMEOUT: Duration = Duration::from_secs(40 * 60); + +fn init_tracing() { + static INIT: Once = Once::new(); + INIT.call_once(|| { + let _ = tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::new( + "temps_providers::externalsvc::mariadb=info", + )) + .with_test_writer() + .try_init(); + }); +} + +/// RAII guard that force-removes a container (and its volumes) on drop, even +/// on panic. Uses `block_in_place` so it works inside the multi-threaded +/// tokio test runtime. +struct ContainerGuard { + docker: Docker, + id: String, + label: String, +} + +impl Drop for ContainerGuard { + fn drop(&mut self) { + let docker = self.docker.clone(); + let id = self.id.clone(); + let label = self.label.clone(); + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + if let Ok(handle) = tokio::runtime::Handle::try_current() { + tokio::task::block_in_place(|| { + handle.block_on(async { + let _ = docker + .stop_container( + &id, + Some(bollard::query_parameters::StopContainerOptions { + t: Some(3), + signal: None, + }), + ) + .await; + let _ = docker + .remove_container( + &id, + Some(bollard::query_parameters::RemoveContainerOptions { + force: true, + v: true, + ..Default::default() + }), + ) + .await; + eprintln!("Reaped container {label} ({id})"); + }); + }); + } + })); + } +} + +/// Connect to the local Docker daemon. Returns `None` (skip) when unreachable. +async fn connect_docker() -> Option { + let docker = match Docker::connect_with_local_defaults() { + Ok(d) => d, + Err(e) => { + eprintln!("Docker unavailable (connect failed), skipping: {e}"); + return None; + } + }; + if let Err(e) = docker.ping().await { + eprintln!("Docker socket unreachable (ping failed), skipping: {e}"); + return None; + } + Some(docker) +} + +/// Pull an image (best-effort; ignores "already present" style results). +async fn pull_image(docker: &Docker, image: &str) -> anyhow::Result<()> { + use futures::StreamExt; + let (name, tag) = image.split_once(':').unwrap_or((image, "latest")); + let mut stream = docker.create_image( + Some(bollard::query_parameters::CreateImageOptions { + from_image: Some(name.to_string()), + tag: Some(tag.to_string()), + ..Default::default() + }), + None, + None, + ); + while let Some(item) = stream.next().await { + item.map_err(|e| anyhow::anyhow!("pull {image}: {e}"))?; + } + Ok(()) +} + +fn find_available_port(start: u16) -> Option { + use std::net::TcpListener; + (start..start + 200).find(|&p| TcpListener::bind(("127.0.0.1", p)).is_ok()) +} + +/// Boot a MinIO container, returning (host_port, guard). Skips (None) on +/// failure so the test can bail gracefully. +async fn boot_minio(docker: &Docker) -> Option<(u16, ContainerGuard)> { + if pull_image(docker, "minio/minio:latest").await.is_err() { + eprintln!("Could not pull MinIO image, skipping"); + return None; + } + let port = find_available_port(9100)?; + let name = format!("temps-test-pitr-minio-{}", uuid::Uuid::new_v4()); + + let config = bollard::models::ContainerCreateBody { + image: Some("minio/minio:latest".to_string()), + cmd: Some(vec!["server".to_string(), "/data".to_string()]), + env: Some(vec![ + format!("MINIO_ROOT_USER={MINIO_ACCESS_KEY}"), + format!("MINIO_ROOT_PASSWORD={MINIO_SECRET_KEY}"), + ]), + host_config: Some(bollard::models::HostConfig { + port_bindings: Some(HashMap::from([( + "9000/tcp".to_string(), + Some(vec![bollard::models::PortBinding { + host_ip: Some("127.0.0.1".to_string()), + host_port: Some(port.to_string()), + }]), + )])), + ..Default::default() + }), + ..Default::default() + }; + + let created = docker + .create_container( + Some( + bollard::query_parameters::CreateContainerOptionsBuilder::new() + .name(&name) + .build(), + ), + config, + ) + .await + .ok()?; + let guard = ContainerGuard { + docker: docker.clone(), + id: created.id.clone(), + label: "minio".to_string(), + }; + docker + .start_container( + &created.id, + None::, + ) + .await + .ok()?; + + // Give MinIO a moment to bind its port. + tokio::time::sleep(Duration::from_secs(3)).await; + Some((port, guard)) +} + +/// Build a host-side S3 client against the local MinIO. Returns None when the +/// AWS SDK panics constructing its TrustStore (some minimal CI hosts). +fn build_s3_client(port: u16) -> Option { + let conf = aws_sdk_s3::Config::builder() + .endpoint_url(format!("http://127.0.0.1:{port}")) + .region(Region::new("us-east-1")) + .behavior_version_latest() + .credentials_provider(aws_sdk_s3::config::Credentials::new( + MINIO_ACCESS_KEY, + MINIO_SECRET_KEY, + None, + None, + "minio", + )) + .force_path_style(true) + .build(); + + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + aws_sdk_s3::Client::from_conf(conf) + })) { + Ok(c) => Some(c), + Err(_) => { + eprintln!("AWS SDK panicked building S3 client (TrustStore), skipping"); + None + } + } +} + +/// Boot a `mariadb:lts` source container with binlog enabled. Returns +/// (container_name, host_port, guard). The container name is `mariadb-` +/// so it matches what the engine/provider derive from the service name. +async fn boot_mariadb_source( + docker: &Docker, + service_name: &str, +) -> Option<(String, u16, ContainerGuard)> { + if pull_image(docker, "mariadb:lts").await.is_err() { + eprintln!("Could not pull mariadb:lts image, skipping"); + return None; + } + let port = find_available_port(33060)?; + let container_name = format!("mariadb-{service_name}"); + + let config = bollard::models::ContainerCreateBody { + image: Some("mariadb:lts".to_string()), + cmd: Some(vec![ + "--log-bin=mysql-bin".to_string(), + "--server-id=1".to_string(), + "--binlog-format=ROW".to_string(), + ]), + env: Some(vec![ + format!("MARIADB_ROOT_PASSWORD={ROOT_PASSWORD}"), + "TZ=UTC".to_string(), + ]), + host_config: Some(bollard::models::HostConfig { + port_bindings: Some(HashMap::from([( + "3306/tcp".to_string(), + Some(vec![bollard::models::PortBinding { + host_ip: Some("127.0.0.1".to_string()), + host_port: Some(port.to_string()), + }]), + )])), + ..Default::default() + }), + ..Default::default() + }; + + let created = docker + .create_container( + Some( + bollard::query_parameters::CreateContainerOptionsBuilder::new() + .name(&container_name) + .build(), + ), + config, + ) + .await + .ok()?; + let guard = ContainerGuard { + docker: docker.clone(), + id: created.id.clone(), + label: container_name.clone(), + }; + docker + .start_container( + &created.id, + None::, + ) + .await + .ok()?; + + // Wait for MariaDB to accept connections on the mapped host port. + let conn_str = format!("mysql://root:{ROOT_PASSWORD}@127.0.0.1:{port}/"); + for attempt in 0..40 { + match MySqlPoolOptions::new() + .max_connections(1) + .acquire_timeout(Duration::from_secs(3)) + .connect(&conn_str) + .await + { + Ok(pool) => { + pool.close().await; + return Some((container_name, port, guard)); + } + Err(_) if attempt < 39 => tokio::time::sleep(Duration::from_millis(750)).await, + Err(e) => { + eprintln!("MariaDB source never became reachable: {e}"); + return None; + } + } + } + None +} + +/// Open a sqlx MySQL pool against the given host port. +async fn mysql_pool(port: u16) -> anyhow::Result { + let conn = format!("mysql://root:{ROOT_PASSWORD}@127.0.0.1:{port}/"); + MySqlPoolOptions::new() + .max_connections(2) + .acquire_timeout(Duration::from_secs(5)) + .connect(&conn) + .await + .map_err(|e| anyhow::anyhow!("connect mysql on {port}: {e}")) +} + +/// The MariaDB ServiceConfig parameters JSON that both the engine and the +/// provider parse (`MariaDbInputConfig`). `container_name` is set so the +/// provider talks to our pre-created `mariadb-` container. +fn mariadb_params(service_name: &str, host_port: u16) -> serde_json::Value { + serde_json::json!({ + "host": "localhost", + "port": host_port.to_string(), + "database": "appdb", + "username": "root", + "password": ROOT_PASSWORD, + "root_password": ROOT_PASSWORD, + "docker_image": "mariadb:lts", + "container_name": format!("mariadb-{service_name}"), + }) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn mariadb_pitr_full_chain_e2e() { + tokio::time::timeout(E2E_TIMEOUT, mariadb_pitr_full_chain_e2e_inner()) + .await + .expect("MariaDB PITR E2E timed out") +} + +async fn mariadb_pitr_full_chain_e2e_inner() { + init_tracing(); + + // Docker / DB availability gate (graceful skip). + let Some(docker) = connect_docker().await else { + return; + }; + + let test_db = match temps_database::test_utils::TestDatabase::with_migrations().await { + Ok(db) => db, + Err(e) => { + eprintln!("Test database unavailable, skipping: {e}"); + return; + } + }; + let pool = test_db.connection_arc(); + + let Some((minio_port, _minio_guard)) = boot_minio(&docker).await else { + return; + }; + let Some(s3_client) = build_s3_client(minio_port) else { + return; + }; + if let Err(e) = s3_client.create_bucket().bucket(BUCKET).send().await { + eprintln!("Could not create MinIO bucket, skipping: {e}"); + return; + } + + let service_name = format!("pitr{}", uuid::Uuid::new_v4().simple()); + let Some((container_name, mariadb_port, _mariadb_guard)) = + boot_mariadb_source(&docker, &service_name).await + else { + return; + }; + eprintln!("Booted MariaDB source container {container_name} on host port {mariadb_port}"); + + // From here on, any assertion failure should still reap containers (RAII + // guards on the stack handle that). We run the real flow. + run_pitr_flow( + &docker, + &s3_client, + minio_port, + pool, + &service_name, + &container_name, + mariadb_port, + ) + .await + .expect("PITR end-to-end flow"); +} + +#[allow(clippy::too_many_arguments)] +async fn run_pitr_flow( + docker: &Docker, + s3_client: &aws_sdk_s3::Client, + minio_port: u16, + pool_arc: Arc, + service_name: &str, + container_name: &str, + mariadb_port: u16, +) -> anyhow::Result<()> { + let pool: &temps_database::DbConnection = pool_arc.as_ref(); + eprintln!("Running PITR flow against source container {container_name}"); + let encryption = Arc::new(EncryptionService::new(MASTER_KEY_HEX)?); + + // Insert encrypted DB rows. + // The engine decrypts `external_services.config` and the s3 creds with the + // SAME EncryptionService, so we encrypt with it here. + let config_plaintext = mariadb_params(service_name, mariadb_port).to_string(); + let config_encrypted = encryption.encrypt_string(&config_plaintext)?; + + let service_model = temps_entities::external_services::ActiveModel { + name: Set(service_name.to_string()), + service_type: Set("mariadb".to_string()), + version: Set(None), + status: Set("running".to_string()), + config: Set(Some(config_encrypted)), + topology: Set("standalone".to_string()), + ..Default::default() + } + .insert(pool) + .await?; + let service_id = service_model.id; + + let s3_source_model = temps_entities::s3_sources::ActiveModel { + name: Set("pitr-s3".to_string()), + bucket_name: Set(BUCKET.to_string()), + region: Set("us-east-1".to_string()), + // Host-side clients (engine + archiver + restore) all reach MinIO on + // localhost - MariaDB does ALL S3 IO host-side (download base/binlogs + // to host, then upload into the container), so localhost is correct. + endpoint: Set(Some(format!("http://127.0.0.1:{minio_port}"))), + bucket_path: Set(String::new()), + access_key_id: Set(encryption.encrypt_string(MINIO_ACCESS_KEY)?), + secret_key: Set(encryption.encrypt_string(MINIO_SECRET_KEY)?), + force_path_style: Set(Some(true)), + is_default: Set(true), + ..Default::default() + } + .insert(pool) + .await?; + let s3_source_id = s3_source_model.id; + + // Seed data: create DB + table, insert batch A. + let src = mysql_pool(mariadb_port).await?; + sqlx::query("CREATE DATABASE IF NOT EXISTS appdb") + .execute(&src) + .await?; + sqlx::query( + "CREATE TABLE IF NOT EXISTS appdb.events (id INT PRIMARY KEY AUTO_INCREMENT, batch CHAR(1) NOT NULL, note VARCHAR(64))", + ) + .execute(&src) + .await?; + for i in 0..5 { + sqlx::query("INSERT INTO appdb.events (batch, note) VALUES ('A', ?)") + .bind(format!("a{i}")) + .execute(&src) + .await?; + } + let count_a: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM appdb.events WHERE batch='A'") + .fetch_one(&src) + .await?; + assert_eq!(count_a, 5, "batch A seeded"); + + // Run the REAL base-backup engine. + let engine = temps_backup::engines::mariadb_physical::MariadbPhysicalEngine::new( + temps_backup::engines::mariadb_physical::MariadbPhysicalDeps { + db: Arc::clone(&pool_arc), + encryption_service: Arc::clone(&encryption), + docker: docker.clone(), + }, + ); + let ctx = BackupContext { + backup_id: 1, + engine_key: "mariadb_physical".to_string(), + params: serde_json::json!({ "service_id": service_id, "s3_source_id": s3_source_id }), + cancel: CancellationToken::new(), + db: Arc::clone(&pool_arc), + }; + let outcome = engine + .run(&ctx) + .await + .map_err(|e| anyhow::anyhow!("base backup engine: {e}"))?; + eprintln!("Base backup landed at key: {}", outcome.location); + assert!( + outcome.location.ends_with("base.mbstream.gz"), + "engine should produce a physical base, got {}", + outcome.location + ); + + // Confirm the base object actually landed in MinIO. + let head = s3_client + .head_object() + .bucket(BUCKET) + .key(&outcome.location) + .send() + .await; + assert!(head.is_ok(), "base object must exist in MinIO: {head:?}"); + + // DIAGNOSTIC: verify the stored base object is valid gzip. + { + let obj = s3_client + .get_object() + .bucket(BUCKET) + .key(&outcome.location) + .send() + .await?; + let bytes = obj.body.collect().await?.into_bytes(); + eprintln!( + "DIAG base object: {} bytes, first4={:02x?}", + bytes.len(), + &bytes[..bytes.len().min(4)] + ); + } + + // Insert batch B, capture T, insert batch C. + for i in 0..4 { + sqlx::query("INSERT INTO appdb.events (batch, note) VALUES ('B', ?)") + .bind(format!("b{i}")) + .execute(&src) + .await?; + } + // Capture T strictly between B and C. MariaDB binlog event timestamps have + // 1-second resolution and `mariadb-binlog --stop-datetime` truncates T to + // whole seconds, so we need a comfortable gap on each side of T: ~4s after + // B and ~4s before C guarantees B's events land in a strictly-earlier + // whole second than T, and C's in a strictly-later one. + tokio::time::sleep(Duration::from_secs(4)).await; + let t: chrono::DateTime = chrono::Utc::now(); + tokio::time::sleep(Duration::from_secs(4)).await; + for i in 0..3 { + sqlx::query("INSERT INTO appdb.events (batch, note) VALUES ('C', ?)") + .bind(format!("c{i}")) + .execute(&src) + .await?; + } + src.close().await; + + // Run the REAL binlog archiver. + // The archiver FLUSHes binary logs (closing the active segment) and ships + // the now-closed segments to MinIO. Run it twice so the segment that + // contains B and C is closed by a later FLUSH and then shipped. + let mariadb_svc = MariaDbService::new(service_name.to_string(), Arc::new(docker.clone())); + let mariadb_config = parse_mariadb_config(service_name, mariadb_port); + + // Decrypt the s3 source row the way the orchestrator does before calling + // the provider: the archiver reads `s3_source.bucket_name`/`bucket_path` + // only (creds come from the passed s3_client), so the model can stay as-is. + let mut shipped_total = 0usize; + for round in 0..2 { + let n = mariadb_svc + .archive_binlogs(s3_client, &s3_source_model, &mariadb_config) + .await + .map_err(|e| anyhow::anyhow!("archive_binlogs round {round}: {e}"))?; + shipped_total += n; + eprintln!("archive_binlogs round {round} shipped {n} segment(s)"); + tokio::time::sleep(Duration::from_millis(500)).await; + } + eprintln!("Total binlog segments shipped: {shipped_total}"); + + // DIAGNOSTIC: dump the base metadata.json and the binlog manifest from S3 + // so we can see the recorded binlog coordinates and which segments shipped. + eprintln!("DIAG recovery target T (UTC) = {t}"); + { + let meta_key = { + let (dir, _) = outcome.location.rsplit_once('/').unwrap(); + format!("{dir}/metadata.json") + }; + if let Ok(o) = s3_client + .get_object() + .bucket(BUCKET) + .key(&meta_key) + .send() + .await + { + let b = o.body.collect().await?.into_bytes(); + eprintln!("DIAG base metadata.json = {}", String::from_utf8_lossy(&b)); + } + if let Ok(o) = s3_client + .get_object() + .bucket(BUCKET) + .key(format!( + "external_services/mariadb/{service_name}/binlog/manifest.json" + )) + .send() + .await + { + let b = o.body.collect().await?.into_bytes(); + eprintln!("DIAG binlog manifest = {}", String::from_utf8_lossy(&b)); + } + } + + // Build a decrypted RestoreContext (as the orchestrator hands it). + let decrypted_s3_source = { + let mut m = s3_source_model.clone(); + m.access_key_id = encryption.decrypt_string(&s3_source_model.access_key_id)?; + m.secret_key = encryption.decrypt_string(&s3_source_model.secret_key)?; + m + }; + let s3_credentials = S3Credentials { + access_key_id: MINIO_ACCESS_KEY.to_string(), + secret_key: MINIO_SECRET_KEY.to_string(), + region: "us-east-1".to_string(), + endpoint: decrypted_s3_source.endpoint.clone(), + bucket_name: BUCKET.to_string(), + bucket_path: String::new(), + force_path_style: true, + }; + + // `backups.created_by` has an FK to `users.id`, so insert a user first. + let user = temps_entities::users::ActiveModel { + name: Set("pitr-test-user".to_string()), + email: Set(format!( + "pitr-{}@example.test", + uuid::Uuid::new_v4().simple() + )), + email_verified: Set(true), + mfa_enabled: Set(false), + ..Default::default() + } + .insert(pool) + .await?; + + // A `backups` model whose s3_location == the engine's output. + let backup_model = temps_entities::backups::ActiveModel { + name: Set("pitr-base".to_string()), + backup_id: Set(uuid::Uuid::new_v4().to_string()), + backup_type: Set("full".to_string()), + state: Set("completed".to_string()), + started_at: Set(chrono::Utc::now()), + finished_at: Set(Some(chrono::Utc::now())), + size_bytes: Set(outcome.size_bytes), + s3_source_id: Set(s3_source_id), + s3_location: Set(outcome.location.clone()), + metadata: Set("{}".to_string()), + compression_type: Set("gzip".to_string()), + created_by: Set(user.id), + tags: Set("[]".to_string()), + ..Default::default() + } + .insert(pool) + .await?; + + let source_config = ServiceConfig { + name: service_name.to_string(), + service_type: ServiceType::Mariadb, + version: None, + parameters: mariadb_params(service_name, mariadb_port), + }; + + let restored_name = format!("{service_name}-restored"); + let restore_ctx = RestoreContext { + s3_client, + s3_credentials: &s3_credentials, + s3_source: &decrypted_s3_source, + backup: &backup_model, + backup_location: &outcome.location, + source_service: &service_model, + source_config, + pool, + }; + + // Run the REAL restore (PITR to time T, into a new service). + let result = mariadb_svc + .restore_pitr( + restore_ctx, + RecoveryTarget::Time { time: t }, + true, + Some(restored_name.clone()), + ) + .await + .map_err(|e| anyhow::anyhow!("restore_pitr: {e}"))?; + let result = result.expect("restore_to_new_service result"); + eprintln!("Restore produced new service: {}", result.connection_info); + + // Register the restored container for cleanup. + let restored_container = format!("mariadb-{restored_name}"); + let _restored_guard = ContainerGuard { + docker: docker.clone(), + id: restored_container.clone(), + label: restored_container.clone(), + }; + // The restore helper container is removed by the provider, but its data + // volume (`mariadb_data_`) is left; remove it best-effort. + let restored_volume = format!("mariadb_data_{restored_name}"); + + // Verify: A + B present, C absent in the restored container. + let restored_port: u16 = result + .parameters + .get("port") + .and_then(|p| p.parse().ok()) + .ok_or_else(|| { + anyhow::anyhow!( + "restored service has no port param: {:?}", + result.parameters + ) + })?; + eprintln!("Restored MariaDB on host port {restored_port}"); + + // Give the restored server a moment after health to settle. + let restored = { + let conn = format!("mysql://root:{ROOT_PASSWORD}@127.0.0.1:{restored_port}/"); + let mut pool = None; + for attempt in 0..30 { + match MySqlPoolOptions::new() + .max_connections(1) + .acquire_timeout(Duration::from_secs(3)) + .connect(&conn) + .await + { + Ok(p) => { + pool = Some(p); + break; + } + Err(_) if attempt < 29 => tokio::time::sleep(Duration::from_millis(750)).await, + Err(e) => return Err(anyhow::anyhow!("connect restored mariadb: {e}")), + } + } + pool.ok_or_else(|| anyhow::anyhow!("restored mariadb never reachable"))? + }; + + let a: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM appdb.events WHERE batch='A'") + .fetch_one(&restored) + .await?; + let b: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM appdb.events WHERE batch='B'") + .fetch_one(&restored) + .await?; + let c: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM appdb.events WHERE batch='C'") + .fetch_one(&restored) + .await?; + restored.close().await; + + eprintln!("Restored row counts - A={a} B={b} C={c} (expected A=5 B=4 C=0)"); + assert_eq!(a, 5, "batch A (in base) must be present after PITR"); + assert_eq!(b, 4, "batch B (before T) must be replayed"); + assert_eq!( + c, 0, + "batch C (after T) must be excluded by PITR stop-datetime" + ); + + // Best-effort: remove the restored data volume so it doesn't leak. + let _ = docker + .remove_volume( + &restored_volume, + Some(bollard::query_parameters::RemoveVolumeOptions { force: true }), + ) + .await; + + Ok(()) +} + +/// Build the provider-side `MariaDbConfig` indirectly: the provider parses a +/// `ServiceConfig` internally, so we hand `archive_binlogs` a config by +/// round-tripping through the same parameters. The provider's `archive_binlogs` +/// takes a `&MariaDbConfig`, which is constructed from the input config - but +/// that type is private, so we build it via the public `from`-able path used +/// by the engine isn't available either. Instead we rely on the provider's +/// own parsing: see note in caller. This helper returns the runtime config by +/// deserializing through the public input type. +fn parse_mariadb_config( + service_name: &str, + host_port: u16, +) -> temps_providers::externalsvc::mariadb::MariaDbConfig { + let input: temps_providers::externalsvc::mariadb::MariaDbInputConfig = + serde_json::from_value(mariadb_params(service_name, host_port)) + .expect("parse MariaDbInputConfig"); + temps_providers::externalsvc::mariadb::MariaDbConfig::from(input) +} diff --git a/crates/temps-cli/src/commands/serve/console.rs b/crates/temps-cli/src/commands/serve/console.rs index 3ce65970e..86eb94277 100644 --- a/crates/temps-cli/src/commands/serve/console.rs +++ b/crates/temps-cli/src/commands/serve/console.rs @@ -1743,6 +1743,8 @@ pub async fn start_console_api(params: ConsoleApiParams) -> anyhow::Result<()> { external_service_manager, notification_service, ExternalServiceHealthConfig::default(), + docker.clone(), + service_context.require_service::(), )); // Register so the providers plugin can pick it up and expose a diff --git a/crates/temps-entities/src/external_services.rs b/crates/temps-entities/src/external_services.rs index ccdf37b0a..6aeba02d5 100644 --- a/crates/temps-entities/src/external_services.rs +++ b/crates/temps-entities/src/external_services.rs @@ -45,6 +45,14 @@ pub struct Model { /// `m20260601_000002_add_monitoring_settings`. #[sea_orm(default_value = false)] pub metrics_enabled: bool, + /// Whether the auto-provisioning reconcile loop has already created a + /// default daily full-backup schedule for this service. Acts as a one-shot + /// latch: provisioning happens exactly once (when `false`), then this is + /// set to `true` so we never recreate a schedule the operator later + /// deletes. Added by migration + /// `m20260623_000001_add_external_services_default_backup_provisioned`. + #[sea_orm(default_value = false)] + pub default_backup_provisioned: bool, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/crates/temps-migrations/src/migration/m20260623_000001_add_external_services_default_backup_provisioned.rs b/crates/temps-migrations/src/migration/m20260623_000001_add_external_services_default_backup_provisioned.rs new file mode 100644 index 000000000..8adf86bf7 --- /dev/null +++ b/crates/temps-migrations/src/migration/m20260623_000001_add_external_services_default_backup_provisioned.rs @@ -0,0 +1,61 @@ +use sea_orm_migration::prelude::*; + +/// Adds `default_backup_provisioned` to `external_services`. +/// +/// Backs the auto-provisioning reconcile loop that gives MariaDB services a +/// covering daily full-backup schedule (base backups via the +/// `mariadb_physical` engine) so point-in-time recovery works out of the box +/// once a default S3 source is configured. +/// +/// The flag is a one-shot latch: the reconcile loop only provisions services +/// where it is `false`, and sets it to `true` after creating the schedule. +/// This guarantees we provision exactly once and never recreate a schedule the +/// operator later deletes. +/// +/// Defaults to `false` so every existing row is considered "not yet +/// provisioned" on upgrade — the reconcile loop will create their schedules on +/// the next tick. **Safely re-runnable:** uses an `IF NOT EXISTS` column guard +/// inside a PL/pgSQL block. +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + + db.execute_unprepared( + r#" +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = 'external_services' + AND column_name = 'default_backup_provisioned' + ) THEN + ALTER TABLE external_services + ADD COLUMN default_backup_provisioned BOOL NOT NULL DEFAULT false; + END IF; +END $$; + "#, + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + + db.execute_unprepared( + r#" +ALTER TABLE external_services + DROP COLUMN IF EXISTS default_backup_provisioned; + "#, + ) + .await?; + + Ok(()) + } +} diff --git a/crates/temps-migrations/src/migration/mod.rs b/crates/temps-migrations/src/migration/mod.rs index 0db219c5e..f130861c5 100644 --- a/crates/temps-migrations/src/migration/mod.rs +++ b/crates/temps-migrations/src/migration/mod.rs @@ -122,6 +122,7 @@ mod m20260618_000001_create_on_demand_cert_attempts; mod m20260618_000002_add_domains_on_demand_backoff; mod m20260619_000001_add_settings_change_trigger; mod m20260621_000001_create_telemetry_milestones; +mod m20260623_000001_add_external_services_default_backup_provisioned; mod m20260626_000001_create_metric_dashboards; mod m20260626_000002_create_metric_alert_rules; mod m20260627_000001_add_ai_alert_summaries; @@ -260,6 +261,7 @@ impl MigratorTrait for Migrator { Box::new(m20260618_000002_add_domains_on_demand_backoff::Migration), Box::new(m20260619_000001_add_settings_change_trigger::Migration), Box::new(m20260621_000001_create_telemetry_milestones::Migration), + Box::new(m20260623_000001_add_external_services_default_backup_provisioned::Migration), Box::new(m20260626_000001_create_metric_dashboards::Migration), Box::new(m20260626_000002_create_metric_alert_rules::Migration), Box::new(m20260627_000001_add_ai_alert_summaries::Migration), diff --git a/crates/temps-monitoring/src/alarm_service.rs b/crates/temps-monitoring/src/alarm_service.rs index cb76f6f6d..4d9d02af2 100644 --- a/crates/temps-monitoring/src/alarm_service.rs +++ b/crates/temps-monitoring/src/alarm_service.rs @@ -1176,6 +1176,7 @@ mod tests { consecutive_health_failures: 0, health_metadata: None, metrics_enabled: true, + default_backup_provisioned: false, } } diff --git a/crates/temps-providers/Cargo.toml b/crates/temps-providers/Cargo.toml index c82eaab92..ec1b4b0d9 100644 --- a/crates/temps-providers/Cargo.toml +++ b/crates/temps-providers/Cargo.toml @@ -32,6 +32,7 @@ futures = { workspace = true } tracing = { workspace = true } reqwest = { workspace = true } sea-orm = { workspace = true } +sqlx = { workspace = true } redis = { version = "0.28.2", features = ["tokio-comp", "connection-manager"] } mongodb = "3.7.0" aws-sdk-s3 = { workspace = true } diff --git a/crates/temps-providers/src/externalsvc/exec_util.rs b/crates/temps-providers/src/externalsvc/exec_util.rs index b6212d8b9..4e8c0c16d 100644 --- a/crates/temps-providers/src/externalsvc/exec_util.rs +++ b/crates/temps-providers/src/externalsvc/exec_util.rs @@ -30,6 +30,9 @@ use bollard::exec::{CreateExecOptions, StartExecResults}; use bollard::Docker; use futures::StreamExt; +const DOCKER_EXEC_API_TIMEOUT: Duration = Duration::from_secs(30); +const DOCKER_EXEC_DRAIN_TIMEOUT: Duration = Duration::from_millis(100); + /// Result of a successful exec invocation. #[derive(Debug, Clone)] pub struct ExecResult { @@ -61,8 +64,11 @@ pub async fn run_exec( env: Option>, timeout: Duration, ) -> Result { - let exec = docker - .create_exec( + let api_timeout = docker_api_timeout(timeout); + + let exec = tokio::time::timeout( + api_timeout, + docker.create_exec( container, CreateExecOptions { cmd: Some(cmd.iter().map(|s| s.as_str()).collect()), @@ -71,19 +77,35 @@ pub async fn run_exec( attach_stderr: Some(true), ..Default::default() }, + ), + ) + .await + .map_err(|_| { + anyhow!( + "docker create_exec timed out after {:?} in container {}. cmd: {:?}", + api_timeout, + container, + cmd.iter().take(3).collect::>(), + ) + })? + .map_err(|e| { + anyhow!( + "docker create_exec failed in container {}: {}", + container, + e ) + })?; + + let stream = tokio::time::timeout(api_timeout, docker.start_exec(&exec.id, None)) .await - .map_err(|e| { + .map_err(|_| { anyhow!( - "docker create_exec failed in container {}: {}", + "docker start_exec timed out after {:?} in container {}. cmd: {:?}", + api_timeout, container, - e + cmd.iter().take(3).collect::>(), ) - })?; - - let stream = docker - .start_exec(&exec.id, None) - .await + })? .map_err(|e| anyhow!("docker start_exec failed in container {}: {}", container, e))?; // Drain output concurrently with polling. We collect into a String; @@ -102,7 +124,7 @@ pub async fn run_exec( tokio::select! { biased; - chunk = output.next() => { + chunk = output.next(), if !output_done => { match chunk { Some(Ok(msg)) => { captured.push_str(&msg.to_string()); @@ -136,13 +158,29 @@ pub async fn run_exec( )); } - match docker.inspect_exec(&exec.id).await { - Ok(info) => { + match tokio::time::timeout(api_timeout, docker.inspect_exec(&exec.id)).await { + Ok(Ok(info)) => { match info.running { Some(false) => { // Drain remaining buffered chunks. - while let Some(Ok(msg)) = output.next().await { - captured.push_str(&msg.to_string()); + loop { + match tokio::time::timeout( + DOCKER_EXEC_DRAIN_TIMEOUT, + output.next(), + ).await { + Ok(Some(Ok(msg))) => { + captured.push_str(&msg.to_string()); + } + Ok(Some(Err(e))) => { + tracing::debug!( + "exec output stream error while draining in container {}: {}", + container, + e + ); + break; + } + Ok(None) | Err(_) => break, + } } let exit_code = info.exit_code.unwrap_or(-1); if exit_code == 0 { @@ -184,7 +222,7 @@ pub async fn run_exec( } } } - Err(e) => { + Ok(Err(e)) => { return Err(anyhow!( "docker inspect_exec failed for {} in container {}: {}. \ output captured ({} bytes):\n{}", @@ -195,6 +233,18 @@ pub async fn run_exec( tail(&captured, 4096), )); } + Err(_) => { + return Err(anyhow!( + "docker inspect_exec timed out after {:?} for {} in container {}. \ + cmd: {:?}. output captured ({} bytes):\n{}", + api_timeout, + exec.id, + container, + cmd.iter().take(3).collect::>(), + captured.len(), + tail(&captured, 4096), + )); + } } } } @@ -209,6 +259,10 @@ pub async fn run_exec( )) } +fn docker_api_timeout(command_timeout: Duration) -> Duration { + command_timeout.min(DOCKER_EXEC_API_TIMEOUT) +} + /// Trim a long string to its trailing N characters, with an indicator if /// truncated. Used to keep error messages from blowing up logs. fn tail(s: &str, n: usize) -> String { diff --git a/crates/temps-providers/src/externalsvc/mariadb.rs b/crates/temps-providers/src/externalsvc/mariadb.rs new file mode 100644 index 000000000..ff2e3a062 --- /dev/null +++ b/crates/temps-providers/src/externalsvc/mariadb.rs @@ -0,0 +1,4671 @@ +use crate::utils::ensure_network_exists; + +use super::{ + ExternalService, HealthProbeResult, LogicalResource, NewServiceRestoreResult, RecoveryTarget, + RuntimeEnvVar, ServiceConfig, ServiceResourceLimits, ServiceType, +}; +use anyhow::Result; +use async_trait::async_trait; +use bollard::exec::CreateExecOptions; +use bollard::query_parameters::{InspectContainerOptions, StopContainerOptions}; +use bollard::{body_full, Docker}; +use futures::{StreamExt, TryStreamExt}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::net::TcpListener; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::RwLock; +use tokio::time::sleep; +use tracing::{debug, error, info, warn}; + +const MARIADB_INTERNAL_PORT: &str = "3306"; +const DEFAULT_MARIADB_IMAGE: &str = "mariadb:lts"; +const MIN_PASSWORD_LENGTH: usize = 8; +const MARIADB_BACKUP_EXEC_TIMEOUT: Duration = Duration::from_secs(4 * 3600); +const MARIADB_IMAGE_PULL_TIMEOUT: Duration = Duration::from_secs(15 * 60); +const MARIADB_BINLOG_UPLOAD_TIMEOUT: Duration = Duration::from_secs(5 * 60); +const MARIADB_BINLOG_REPLAY_TIMEOUT: Duration = Duration::from_secs(5 * 60); +const MARIADB_RESTORE_HELPER_TIMEOUT: Duration = Duration::from_secs(15 * 60); + +/// Resource/tuning profile for Temps-managed MariaDB containers. +/// +/// A MariaDB service is a shared database server: linked projects receive +/// separate databases inside this container, not separate MariaDB daemons. +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum MariaDbSizeProfile { + /// Conservative default for single-node 4 GiB and 8 GiB hosts. + #[default] + Small, + /// Larger shared service profile for hosts with more spare memory. + Standard, + /// Minimal cgroup limits; use when the host is dedicated to this service. + Dedicated, +} + +impl MariaDbSizeProfile { + pub fn as_str(self) -> &'static str { + match self { + Self::Small => "small", + Self::Standard => "standard", + Self::Dedicated => "dedicated", + } + } + + pub fn parse(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "small" => Some(Self::Small), + "standard" => Some(Self::Standard), + "dedicated" => Some(Self::Dedicated), + _ => None, + } + } + + pub fn default_resource_limits(self) -> ServiceResourceLimits { + match self { + Self::Small => ServiceResourceLimits { + memory_mb: Some(512), + memory_swap_mb: Some(768), + nano_cpus: Some(750_000_000), + cpu_shares: None, + shm_size_mb: None, + }, + Self::Standard => ServiceResourceLimits { + memory_mb: Some(1024), + memory_swap_mb: Some(1536), + nano_cpus: Some(1_500_000_000), + cpu_shares: None, + shm_size_mb: None, + }, + Self::Dedicated => ServiceResourceLimits { + memory_mb: None, + memory_swap_mb: None, + nano_cpus: None, + cpu_shares: Some(2048), + shm_size_mb: None, + }, + } + } + + pub fn server_args(self) -> Vec { + let ( + buffer_pool, + max_connections, + table_open_cache, + thread_cache_size, + tmp_table_size, + performance_schema, + ) = match self { + Self::Small => ("128M", "50", "256", "16", "32M", "OFF"), + Self::Standard => ("384M", "100", "400", "32", "64M", "ON"), + Self::Dedicated => ("1024M", "200", "800", "64", "128M", "ON"), + }; + + vec![ + "--skip-name-resolve".to_string(), + format!("--innodb-buffer-pool-size={buffer_pool}"), + format!("--max-connections={max_connections}"), + format!("--table-open-cache={table_open_cache}"), + format!("--thread-cache-size={thread_cache_size}"), + format!("--tmp-table-size={tmp_table_size}"), + format!("--max-heap-table-size={tmp_table_size}"), + format!("--performance-schema={performance_schema}"), + ] + } +} + +/// How often closed binary-log segments are shipped to S3 — the PITR +/// granularity (recovery-point objective) for a MariaDB service. +/// +/// MariaDB has no continuous archiver, so a background task ships rotated +/// binlogs on this cadence (see the binlog archiver). Smaller intervals lower +/// the worst-case data loss on restore at the cost of more frequent S3 +/// uploads; the residual RPO is one interval. `binlog_expire_logs_seconds` is +/// derived from this value to always exceed the ship interval, so a segment is +/// never purged locally before it has been archived. +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +pub enum BinlogArchiveInterval { + /// Ship every minute — lowest RPO, highest S3 churn. + #[serde(rename = "1m")] + Min1, + /// Ship every 5 minutes (default). + #[default] + #[serde(rename = "5m")] + Min5, + /// Ship every 15 minutes. + #[serde(rename = "15m")] + Min15, + /// Ship every 60 minutes — lowest churn, highest RPO. + #[serde(rename = "60m")] + Min60, +} + +impl BinlogArchiveInterval { + /// Ship cadence in seconds. + pub fn seconds(self) -> u64 { + match self { + Self::Min1 => 60, + Self::Min5 => 300, + Self::Min15 => 900, + Self::Min60 => 3600, + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Min1 => "1m", + Self::Min5 => "5m", + Self::Min15 => "15m", + Self::Min60 => "60m", + } + } + + /// Local binlog retention (`binlog_expire_logs_seconds`). Kept well beyond + /// the ship interval (>= 6x, floor 1h) so a segment is never purged before + /// the archiver has shipped it — the continuity invariant for PITR. + pub fn binlog_expire_seconds(self) -> u64 { + (self.seconds() * 6).max(3600) + } +} + +/// Derive a stable, non-zero `server-id` for a MariaDB service from its name. +/// +/// `--log-bin` requires a non-zero `server-id`. These standalone servers do +/// not replicate with each other, so uniqueness is not strictly required, but +/// deriving a stable value from the name keeps it consistent across recreates +/// and avoids collisions if two are ever wired into replication. FNV-1a hash +/// mapped into `1..=2_000_000_000`. +fn stable_server_id(name: &str) -> u32 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; // FNV-1a offset basis + for b in name.bytes() { + h ^= b as u64; + h = h.wrapping_mul(0x0000_0100_0000_01b3); // FNV-1a prime + } + (h % 2_000_000_000) as u32 + 1 +} + +/// Binary-logging server args appended to a MariaDB container's command so the +/// service is PITR-capable: ROW-format binlog, a stable server-id, durable +/// flushing (`sync_binlog=1` so a committed-but-unsynced binlog tail is not +/// lost on crash), and a derived retention window. +/// +/// Credentials are never involved here — these are purely server tuning flags. +fn binlog_server_args(server_id: u32, interval: BinlogArchiveInterval) -> Vec { + vec![ + "--log-bin=mysql-bin".to_string(), + "--binlog-format=ROW".to_string(), + format!("--server-id={server_id}"), + "--sync-binlog=1".to_string(), + format!( + "--binlog-expire-logs-seconds={}", + interval.binlog_expire_seconds() + ), + ] +} + +/// Manifest describing which binary-log segments have been shipped to S3 for +/// a MariaDB service. Stored at the `binlog/manifest.json` key. The restore +/// path reads this to know the contiguous set of segments available to replay. +/// +/// Filenames are stored bare (e.g. `mysql-bin.000007`), without the `.gz` +/// suffix the on-disk S3 objects carry. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct BinlogManifest { + /// The highest segment shipped so far (lexicographically). `None` before + /// the first segment is archived. Gates the to-ship set on each run. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_shipped_file: Option, + /// RFC 3339 timestamp of the last successful manifest update. + #[serde(default)] + pub updated_at: String, + /// Every segment shipped to S3, in ship order. + #[serde(default)] + pub shipped_files: Vec, +} + +/// Input configuration for creating a MariaDB service. +#[derive(Debug, Clone, Deserialize, JsonSchema)] +#[schemars( + title = "MariaDB Configuration", + description = "Configuration for MariaDB service" +)] +pub struct MariaDbInputConfig { + /// MariaDB host address. + #[serde(default = "default_host")] + #[schemars(example = "example_host", default = "default_host")] + pub host: String, + + /// MariaDB host port (auto-assigned if not provided). + #[schemars(example = "example_port")] + pub port: Option, + + /// Initial application database. + #[serde(default = "default_database")] + #[schemars(example = "example_database", default = "default_database")] + pub database: String, + + /// Initial application user. + #[serde(default = "default_username")] + #[schemars(example = "example_username", default = "default_username")] + pub username: String, + + /// Application user password (auto-generated if not provided or too short). + #[serde(default, deserialize_with = "deserialize_optional_password")] + #[schemars( + with = "Option", + example = "example_password", + description = "Application user password (minimum 8 characters, auto-generated if not provided)" + )] + pub password: Option, + + /// Root password used by Temps for administrative provisioning. + #[serde(default, deserialize_with = "deserialize_optional_password")] + #[schemars( + with = "Option", + example = "example_root_password", + description = "Root password (minimum 8 characters, auto-generated if not provided)" + )] + pub root_password: Option, + + /// Full Docker image reference. + #[serde(default = "default_docker_image")] + #[schemars(example = "example_docker_image", default = "default_docker_image")] + pub docker_image: String, + + /// Managed service size/tuning profile. + #[serde(default)] + pub size_profile: MariaDbSizeProfile, + + /// Point-in-time-recovery granularity: how often binary logs are shipped + /// to S3. Smaller = less data lost on restore, more frequent uploads. + #[serde(default)] + pub binlog_archive_interval: BinlogArchiveInterval, + + /// Existing Docker container name for imported services. + #[serde(default)] + pub container_name: Option, +} + +/// Internal runtime configuration for MariaDB service. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MariaDbConfig { + pub host: String, + pub port: String, + pub database: String, + pub username: String, + pub password: String, + pub root_password: String, + pub docker_image: String, + #[serde(default)] + pub size_profile: MariaDbSizeProfile, + #[serde(default)] + pub binlog_archive_interval: BinlogArchiveInterval, + #[serde(skip_serializing_if = "Option::is_none")] + pub container_name: Option, +} + +impl From for MariaDbConfig { + fn from(input: MariaDbInputConfig) -> Self { + Self { + host: input.host, + port: input.port.unwrap_or_else(|| { + find_available_port(3306) + .map(|p| p.to_string()) + .unwrap_or_else(|| "3306".to_string()) + }), + database: input.database, + username: input.username, + password: input.password.unwrap_or_else(generate_password), + root_password: input.root_password.unwrap_or_else(generate_password), + docker_image: input.docker_image, + size_profile: input.size_profile, + binlog_archive_interval: input.binlog_archive_interval, + container_name: input.container_name, + } + } +} + +fn deserialize_optional_password<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let opt: Option = Option::deserialize(deserializer)?; + Ok(match opt { + Some(s) if !s.is_empty() && s.len() >= MIN_PASSWORD_LENGTH => Some(s), + _ => None, + }) +} + +fn default_host() -> String { + "localhost".to_string() +} + +fn default_database() -> String { + "app".to_string() +} + +fn default_username() -> String { + "app".to_string() +} + +fn default_docker_image() -> String { + DEFAULT_MARIADB_IMAGE.to_string() +} + +fn example_host() -> &'static str { + "localhost" +} + +fn example_port() -> &'static str { + "3306" +} + +fn example_database() -> &'static str { + "app" +} + +fn example_username() -> &'static str { + "app" +} + +fn example_password() -> &'static str { + "your-secure-password" +} + +fn example_root_password() -> &'static str { + "your-secure-root-password" +} + +fn example_docker_image() -> &'static str { + DEFAULT_MARIADB_IMAGE +} + +fn is_port_available(port: u16) -> bool { + TcpListener::bind(("0.0.0.0", port)).is_ok() +} + +fn find_available_port(start_port: u16) -> Option { + (start_port..start_port + 100).find(|&port| is_port_available(port)) +} + +fn generate_password() -> String { + use rand::{distributions::Alphanumeric, Rng}; + rand::thread_rng() + .sample_iter(&Alphanumeric) + .take(24) + .map(char::from) + .collect() +} + +pub struct MariaDbService { + name: String, + config: Arc>>, + resource_limits: Arc>, + docker: Arc, +} + +impl MariaDbService { + pub fn new(name: String, docker: Arc) -> Self { + Self { + name, + config: Arc::new(RwLock::new(None)), + resource_limits: Arc::new(RwLock::new(ServiceResourceLimits::default())), + docker, + } + } + + fn get_container_name(&self) -> String { + format!("mariadb-{}", self.name) + } + + fn get_live_container_name(&self, config: &MariaDbConfig) -> String { + config + .container_name + .clone() + .unwrap_or_else(|| self.get_container_name()) + } + + fn get_mariadb_config(&self, service_config: ServiceConfig) -> Result { + let input_config: MariaDbInputConfig = serde_json::from_value(service_config.parameters) + .map_err(|e| anyhow::anyhow!("Failed to parse MariaDB configuration: {}", e))?; + let config = MariaDbConfig::from(input_config); + + Self::validate_identifier("database", &config.database)?; + Self::validate_identifier("username", &config.username)?; + Self::validate_password("password", &config.password)?; + Self::validate_password("root_password", &config.root_password)?; + + Ok(config) + } + + async fn create_container( + &self, + docker: &Docker, + config: &MariaDbConfig, + resource_limits: &ServiceResourceLimits, + ) -> Result<()> { + let container_name = self.get_container_name(); + + if docker.inspect_image(&config.docker_image).await.is_ok() { + info!( + "MariaDB image {} already present locally", + config.docker_image + ); + } else { + info!("Pulling MariaDB image {}", config.docker_image); + let (image_name, tag) = if let Some((name, tag)) = config.docker_image.split_once(':') { + (name.to_string(), tag.to_string()) + } else { + (config.docker_image.clone(), "latest".to_string()) + }; + + tokio::time::timeout(MARIADB_IMAGE_PULL_TIMEOUT, async { + docker + .create_image( + Some(bollard::query_parameters::CreateImageOptions { + from_image: Some(image_name), + tag: Some(tag), + ..Default::default() + }), + None, + None, + ) + .try_collect::>() + .await + }) + .await + .map_err(|_| { + anyhow::anyhow!( + "Timed out pulling MariaDB image {} after {}s", + config.docker_image, + MARIADB_IMAGE_PULL_TIMEOUT.as_secs() + ) + })? + .map_err(|e| anyhow::anyhow!("Failed to pull MariaDB image: {}", e))?; + } + + let containers = docker + .list_containers(Some(bollard::query_parameters::ListContainersOptions { + all: true, + filters: Some(HashMap::from([( + "name".to_string(), + vec![container_name.clone()], + )])), + ..Default::default() + })) + .await?; + + if let Some(existing) = containers.first() { + let existing_image = existing.image.as_deref().unwrap_or(""); + if existing_image == config.docker_image { + info!( + "Container {} already exists with same image", + container_name + ); + return Ok(()); + } + + info!( + "Container {} already exists with different image (current: {}, requested: {}), recreating it", + container_name, existing_image, config.docker_image + ); + let _ = docker + .stop_container(&container_name, None::) + .await; + docker + .remove_container( + &container_name, + Some(bollard::query_parameters::RemoveContainerOptions { + force: true, + v: false, + ..Default::default() + }), + ) + .await + .map_err(|e| { + anyhow::anyhow!("Failed to remove existing MariaDB container: {}", e) + })?; + } + + self.warn_if_host_capacity_tight(docker, config, resource_limits) + .await; + + let service_label_key = format!("{}service_type", temps_core::DOCKER_LABEL_PREFIX); + let name_label_key = format!("{}service_name", temps_core::DOCKER_LABEL_PREFIX); + let container_labels = HashMap::from([ + (service_label_key, "mariadb".to_string()), + (name_label_key, self.name.clone()), + ]); + + let env_vars = vec![ + format!("MARIADB_ROOT_PASSWORD={}", config.root_password), + format!("MARIADB_DATABASE={}", config.database), + format!("MARIADB_USER={}", config.username), + format!("MARIADB_PASSWORD={}", config.password), + "MARIADB_AUTO_UPGRADE=1".to_string(), + // Pin the server timezone to UTC so binlog event timestamps — and + // therefore PITR `mysqlbinlog --stop-datetime` targets — are + // unambiguous. RecoveryTarget::Time is UTC; without this the + // recovery target could be misinterpreted in the host's local TZ. + "TZ=UTC".to_string(), + ]; + + let volume_name = format!("mariadb_data_{}", self.name); + docker + .create_volume(bollard::models::VolumeCreateRequest { + name: Some(volume_name.clone()), + ..Default::default() + }) + .await + .map_err(|e| anyhow::anyhow!("Failed to create MariaDB volume: {}", e))?; + + let mut host_config = bollard::models::HostConfig { + port_bindings: Some(HashMap::from([( + "3306/tcp".to_string(), + Some(vec![bollard::models::PortBinding { + host_ip: Some("0.0.0.0".to_string()), + host_port: Some(config.port.clone()), + }]), + )])), + mounts: Some(vec![bollard::models::Mount { + target: Some("/var/lib/mysql".to_string()), + source: Some(volume_name), + typ: Some(bollard::models::MountTypeEnum::VOLUME), + ..Default::default() + }]), + log_config: Some(crate::utils::default_service_log_config()), + security_opt: Some(vec!["no-new-privileges:true".to_string()]), + pids_limit: Some(512), + ..Default::default() + }; + resource_limits.apply_to_host_config(&mut host_config); + + ensure_network_exists(docker) + .await + .map_err(|e| anyhow::anyhow!("Failed to ensure network exists: {:?}", e))?; + let networking_config = Some(bollard::models::NetworkingConfig { + endpoints_config: Some(HashMap::from([( + temps_core::NETWORK_NAME.to_string(), + bollard::models::EndpointSettings { + ..Default::default() + }, + )])), + }); + + let container_config = bollard::models::ContainerCreateBody { + image: Some(config.docker_image.clone()), + exposed_ports: Some(Vec::from(["3306/tcp".to_string()])), + env: Some(env_vars), + labels: Some(container_labels), + // Tuning args + binary-logging args. Binlog is enabled by default + // so the service is PITR-capable from creation (the MariaDB analog + // of Postgres WAL archiving). Enabling binlog requires the flags at + // server start, so existing containers created before this adopt it + // on their next recreate (e.g. image upgrade); we do not force a + // disruptive recreate of a healthy running container here. + cmd: Some({ + let mut args = config.size_profile.server_args(); + args.extend(binlog_server_args( + stable_server_id(&self.name), + config.binlog_archive_interval, + )); + args + }), + host_config: Some(bollard::models::HostConfig { + restart_policy: Some(bollard::models::RestartPolicy { + name: Some(bollard::models::RestartPolicyNameEnum::ALWAYS), + maximum_retry_count: None, + }), + ..host_config + }), + networking_config, + healthcheck: Some(bollard::models::HealthConfig { + test: Some(vec![ + "CMD-SHELL".to_string(), + "mariadb-admin ping -h 127.0.0.1 -uroot -p\"$MARIADB_ROOT_PASSWORD\" --silent" + .to_string(), + ]), + interval: Some(1000000000), + timeout: Some(3000000000), + retries: Some(5), + start_period: Some(30000000000), + start_interval: Some(1000000000), + }), + ..Default::default() + }; + + let container = docker + .create_container( + Some( + bollard::query_parameters::CreateContainerOptionsBuilder::new() + .name(&container_name) + .build(), + ), + container_config, + ) + .await + .map_err(|e| anyhow::anyhow!("Failed to create MariaDB container: {}", e))?; + + docker + .start_container( + &container.id, + None::, + ) + .await + .map_err(|e| anyhow::anyhow!("Failed to start MariaDB container: {}", e))?; + + self.wait_for_container_health(docker, &container.id) + .await + .map_err(|e| anyhow::anyhow!("Failed to wait for MariaDB container health: {}", e))?; + + info!("MariaDB container {} created and started", container.id); + Ok(()) + } + + async fn warn_if_host_capacity_tight( + &self, + docker: &Docker, + config: &MariaDbConfig, + resource_limits: &ServiceResourceLimits, + ) { + let Ok(containers) = docker + .list_containers(Some(bollard::query_parameters::ListContainersOptions { + all: true, + ..Default::default() + })) + .await + else { + return; + }; + + let service_label_key = format!("{}service_type", temps_core::DOCKER_LABEL_PREFIX); + let existing_mariadb_count = containers + .iter() + .filter(|container| { + let labeled = container + .labels + .as_ref() + .and_then(|labels| labels.get(&service_label_key)) + .map(|value| value == "mariadb") + .unwrap_or(false); + let named = container.names.as_ref().is_some_and(|names| { + names + .iter() + .any(|name| name.trim_start_matches('/').starts_with("mariadb-")) + }); + labeled || named + }) + .count(); + + let host_memory_mb = Self::host_memory_mb(); + let requested_memory_mb = resource_limits + .memory_mb + .unwrap_or(match config.size_profile { + MariaDbSizeProfile::Small => 512, + MariaDbSizeProfile::Standard => 1024, + MariaDbSizeProfile::Dedicated => 0, + }); + + if let Some(host_memory_mb) = host_memory_mb { + let projected = if requested_memory_mb > 0 { + requested_memory_mb * (existing_mariadb_count as i64 + 1) + } else { + 0 + }; + if host_memory_mb <= 8192 && existing_mariadb_count >= 1 { + warn!( + service = %self.name, + profile = config.size_profile.as_str(), + existing_mariadb_services = existing_mariadb_count, + host_memory_mb, + projected_mariadb_limit_mb = projected, + "Creating another MariaDB service container on a small host. Prefer sharing one MariaDB service across projects; Temps links create separate per-project databases inside that service." + ); + } + } else if existing_mariadb_count >= 2 { + warn!( + service = %self.name, + profile = config.size_profile.as_str(), + existing_mariadb_services = existing_mariadb_count, + "Creating another MariaDB service container. Prefer sharing one MariaDB service across projects; Temps links create separate per-project databases inside that service." + ); + } + } + + fn host_memory_mb() -> Option { + let meminfo = std::fs::read_to_string("/proc/meminfo").ok()?; + let total_kb = meminfo.lines().find_map(|line| { + let rest = line.strip_prefix("MemTotal:")?; + rest.split_whitespace().next()?.parse::().ok() + })?; + Some(total_kb / 1024) + } + + async fn wait_for_container_health(&self, docker: &Docker, container_id: &str) -> Result<()> { + let mut delay = Duration::from_millis(500); + let mut total_wait = Duration::from_secs(0); + let max_wait = Duration::from_secs(120); + let max_delay = Duration::from_secs(2); + + while total_wait < max_wait { + let info = docker + .inspect_container(container_id, None::) + .await?; + if let Some(state) = info.state { + let is_running = + state.status == Some(bollard::models::ContainerStateStatusEnum::RUNNING); + let health_status = state.health.as_ref().and_then(|h| h.status.as_ref()); + + if is_running + && (health_status.is_none() + || health_status == Some(&bollard::models::HealthStatusEnum::HEALTHY)) + { + return Ok(()); + } + + if state.status == Some(bollard::models::ContainerStateStatusEnum::EXITED) + || state.status == Some(bollard::models::ContainerStateStatusEnum::DEAD) + { + let exit_code = state.exit_code.unwrap_or(-1); + return Err(anyhow::anyhow!( + "MariaDB container exited unexpectedly with code {}", + exit_code + )); + } + } + + sleep(delay).await; + total_wait += delay; + delay = std::cmp::min(delay.mul_f32(1.5), max_delay); + } + + Err(anyhow::anyhow!("MariaDB container health check timed out")) + } + + async fn run_container_command( + &self, + container_name: &str, + cmd: Vec, + env: Option>, + timeout: Duration, + ) -> Result { + tokio::time::timeout(timeout, async { + let exec = self + .docker + .create_exec( + container_name, + bollard::exec::CreateExecOptions { + cmd: Some(cmd), + env, + attach_stdout: Some(true), + attach_stderr: Some(true), + ..Default::default() + }, + ) + .await + .map_err(|e| anyhow::anyhow!("Failed to create MariaDB exec: {}", e))?; + + let mut output_text = String::new(); + if let bollard::exec::StartExecResults::Attached { mut output, .. } = self + .docker + .start_exec(&exec.id, None) + .await + .map_err(|e| anyhow::anyhow!("Failed to start MariaDB exec: {}", e))? + { + while let Some(result) = output.next().await { + match result { + Ok(bollard::container::LogOutput::StdOut { message }) + | Ok(bollard::container::LogOutput::StdErr { message }) => { + output_text.push_str(&String::from_utf8_lossy(&message)); + } + Ok(_) => {} + Err(e) => { + return Err(anyhow::anyhow!( + "Failed to read MariaDB exec output: {}", + e + )); + } + } + } + } + + let inspect = self + .docker + .inspect_exec(&exec.id) + .await + .map_err(|e| anyhow::anyhow!("Failed to inspect MariaDB exec: {}", e))?; + let exit_code = inspect.exit_code.unwrap_or(-1); + if exit_code != 0 { + return Err(anyhow::anyhow!( + "MariaDB command failed with exit code {}: {}", + exit_code, + output_text.trim() + )); + } + + Ok(output_text) + }) + .await + .map_err(|_| anyhow::anyhow!("MariaDB command timed out after {}s", timeout.as_secs()))? + } + + async fn run_admin_sql(&self, config: &MariaDbConfig, sql: &str) -> Result<()> { + let container_name = self.get_live_container_name(config); + self.run_container_command( + &container_name, + vec![ + "sh".to_string(), + "-c".to_string(), + "if command -v mariadb >/dev/null 2>&1; then \ + mariadb -uroot -e \"$TEMPS_MARIADB_SQL\"; \ + else \ + mysql -uroot -e \"$TEMPS_MARIADB_SQL\"; \ + fi" + .to_string(), + ], + Some(vec![ + format!("MYSQL_PWD={}", config.root_password), + format!("MARIADB_PWD={}", config.root_password), + format!("TEMPS_MARIADB_SQL={}", sql), + ]), + Duration::from_secs(15), + ) + .await + .map(|_| ()) + } + + async fn ping(&self, config: &MariaDbConfig) -> Result<()> { + let container_name = self.get_live_container_name(config); + self.run_container_command( + &container_name, + vec![ + "sh".to_string(), + "-c".to_string(), + "if command -v mariadb-admin >/dev/null 2>&1; then \ + mariadb-admin ping -h 127.0.0.1 -uroot --silent; \ + else \ + mysqladmin ping -h 127.0.0.1 -uroot --silent; \ + fi" + .to_string(), + ], + Some(vec![ + format!("MYSQL_PWD={}", config.root_password), + format!("MARIADB_PWD={}", config.root_password), + ]), + Duration::from_secs(5), + ) + .await + .map(|_| ()) + } + + async fn create_database(&self, service_config: ServiceConfig, database: &str) -> Result<()> { + Self::validate_identifier("database", database)?; + let config = self.get_mariadb_config(service_config)?; + + let database_ident = Self::quote_identifier(database); + let username_literal = Self::sql_string_literal(&config.username); + let password_literal = Self::sql_string_literal(&config.password); + let sql = format!( + "CREATE DATABASE IF NOT EXISTS {database_ident}; \ + CREATE USER IF NOT EXISTS {username_literal}@'%' IDENTIFIED BY {password_literal}; \ + GRANT ALL PRIVILEGES ON {database_ident}.* TO {username_literal}@'%'; \ + FLUSH PRIVILEGES;" + ); + + self.run_admin_sql(&config, &sql).await + } + + async fn drop_database(&self, service_config: ServiceConfig, database: &str) -> Result<()> { + Self::validate_identifier("database", database)?; + let config = self.get_mariadb_config(service_config)?; + let sql = format!( + "DROP DATABASE IF EXISTS {};", + Self::quote_identifier(database) + ); + self.run_admin_sql(&config, &sql).await + } + + fn build_runtime_env_vars( + &self, + service_config: ServiceConfig, + resource_name: &str, + ) -> Result> { + let config = self.get_mariadb_config(service_config)?; + Self::build_env_vars( + &self.get_live_container_name(&config), + MARIADB_INTERNAL_PORT, + resource_name, + &config.username, + &config.password, + ) + } + + fn build_env_vars( + host: &str, + port: &str, + database: &str, + username: &str, + password: &str, + ) -> Result> { + Self::validate_identifier("database", database)?; + Self::validate_identifier("username", username)?; + Self::validate_password("password", password)?; + + let url = format!( + "mysql://{}:{}@{}:{}/{}", + urlencoding::encode(username), + urlencoding::encode(password), + host, + port, + database + ); + + let mut env_vars = HashMap::new(); + env_vars.insert("DATABASE_URL".to_string(), url.clone()); + env_vars.insert("MYSQL_URL".to_string(), url.clone()); + env_vars.insert("MYSQL_HOST".to_string(), host.to_string()); + env_vars.insert("MYSQL_PORT".to_string(), port.to_string()); + env_vars.insert("MYSQL_DATABASE".to_string(), database.to_string()); + env_vars.insert("MYSQL_USER".to_string(), username.to_string()); + env_vars.insert("MYSQL_PASSWORD".to_string(), password.to_string()); + env_vars.insert("MARIADB_URL".to_string(), url); + env_vars.insert("MARIADB_HOST".to_string(), host.to_string()); + env_vars.insert("MARIADB_PORT".to_string(), port.to_string()); + env_vars.insert("MARIADB_DATABASE".to_string(), database.to_string()); + env_vars.insert("MARIADB_USER".to_string(), username.to_string()); + env_vars.insert("MARIADB_PASSWORD".to_string(), password.to_string()); + Ok(env_vars) + } + + pub(crate) fn normalize_database_name(name: &str) -> String { + let normalized = name + .to_lowercase() + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect::(); + + let prefixed = if normalized + .chars() + .next() + .map(|c| c.is_ascii_digit()) + .unwrap_or(true) + { + format!("db_{}", normalized) + } else { + normalized + }; + + if prefixed.len() > 63 { + prefixed[..63].to_string() + } else { + prefixed + } + } + + fn validate_identifier(label: &str, value: &str) -> Result<()> { + if value.is_empty() { + return Err(anyhow::anyhow!("{} cannot be empty", label)); + } + if value.len() > 63 { + return Err(anyhow::anyhow!( + "{} '{}' exceeds 63 character limit", + label, + value + )); + } + let mut chars = value.chars(); + let Some(first) = chars.next() else { + return Err(anyhow::anyhow!("{} cannot be empty", label)); + }; + if !first.is_ascii_alphabetic() && first != '_' { + return Err(anyhow::anyhow!( + "{} '{}' must start with a letter or underscore", + label, + value + )); + } + if !chars.all(|c| c.is_ascii_alphanumeric() || c == '_') { + return Err(anyhow::anyhow!( + "{} '{}' contains invalid characters. Only ASCII letters, digits, and underscores are allowed", + label, + value + )); + } + Ok(()) + } + + fn validate_password(label: &str, value: &str) -> Result<()> { + if value.len() < MIN_PASSWORD_LENGTH { + return Err(anyhow::anyhow!( + "{} must be at least {} characters", + label, + MIN_PASSWORD_LENGTH + )); + } + if value.len() > 256 { + return Err(anyhow::anyhow!("{} too long (max 256 characters)", label)); + } + for (i, c) in value.chars().enumerate() { + match c { + '\'' => { + return Err(anyhow::anyhow!( + "{} contains a single quote at position {}", + label, + i + )) + } + '\\' => { + return Err(anyhow::anyhow!( + "{} contains a backslash at position {}", + label, + i + )) + } + '\0' => return Err(anyhow::anyhow!("{} contains a null byte", label)), + '\n' | '\r' => return Err(anyhow::anyhow!("{} contains a newline", label)), + c if c.is_control() => { + return Err(anyhow::anyhow!( + "{} contains control character at position {}", + label, + i + )) + } + _ => {} + } + } + Ok(()) + } + + fn quote_identifier(value: &str) -> String { + format!("`{}`", value) + } + + fn sql_string_literal(value: &str) -> String { + format!("'{}'", value.replace('\\', "\\\\").replace('\'', "\\'")) + } + + fn env_to_map(env: Option>) -> HashMap { + env.unwrap_or_default() + .into_iter() + .filter_map(|entry| { + let (key, value) = entry.split_once('=')?; + Some((key.to_string(), value.to_string())) + }) + .collect() + } + + fn first_non_empty<'a>(values: impl IntoIterator>) -> Option { + values + .into_iter() + .flatten() + .find(|value| !value.trim().is_empty()) + .cloned() + } + + fn json_string(value: &serde_json::Value, key: &str) -> Option { + value + .get(key) + .and_then(|v| v.as_str()) + .filter(|s| !s.trim().is_empty()) + .map(ToString::to_string) + } + + fn extract_host_port(container: &bollard::models::ContainerInspectResponse) -> Option { + container + .network_settings + .as_ref() + .and_then(|settings| settings.ports.as_ref()) + .and_then(|ports| ports.get("3306/tcp")) + .and_then(|bindings| bindings.as_ref()) + .and_then(|bindings| bindings.first()) + .and_then(|binding| binding.host_port.clone()) + } + + async fn verify_import_connection( + username: &str, + password: &str, + port: &str, + database: &str, + ) -> Result<()> { + let connection_url = format!( + "mysql://{}:{}@localhost:{}/{}", + urlencoding::encode(username), + urlencoding::encode(password), + port, + urlencoding::encode(database) + ); + + let pool = sqlx::mysql::MySqlPoolOptions::new() + .max_connections(1) + .connect(&connection_url) + .await + .map_err(|e| { + anyhow::anyhow!( + "Failed to connect to MariaDB-compatible container at localhost:{} with provided credentials: {}", + port, + e + ) + })?; + pool.close().await; + Ok(()) + } + + fn backup_key_from_location(location: &str, bucket: &str) -> String { + let bucket_prefix = format!("s3://{}/", bucket); + location + .strip_prefix(&bucket_prefix) + .unwrap_or(location) + .to_string() + } + + // ── Binary-log archiver (PITR "frequent scheduled ship" half) ────────── + // + // MariaDB has no continuous archiver, so a background task periodically + // ships closed binary-log segments to S3. The active (last) segment is + // never shipped because it is still being written. A manifest object + // records which segments have been shipped so the run is idempotent and + // the restore path knows what is available to replay. + + /// Ship every closed (rotated) binary-log segment that has not yet been + /// archived to S3, advancing the manifest only past segments that actually + /// uploaded successfully. Returns the number of segments shipped this run. + /// + /// Steps: + /// 1. `FLUSH BINARY LOGS` rotates the active segment closed. + /// 2. `SHOW BINARY LOGS` lists segments; the last one is still active. + /// 3. The S3 manifest's `last_shipped_file` gates what is new. + /// 4. Each newer closed segment is downloaded, gzipped, and PUT. + /// 5. The manifest is rewritten to reflect what landed. + /// + /// Credentials are passed via `MYSQL_PWD`/`MARIADB_PWD` exec env, never on + /// argv, and are never logged. + pub async fn archive_binlogs( + &self, + s3_client: &aws_sdk_s3::Client, + s3_source: &temps_entities::s3_sources::Model, + config: &MariaDbConfig, + ) -> Result { + let container_name = self.get_live_container_name(config); + let bucket = &s3_source.bucket_name; + let prefix = s3_source.bucket_path.trim_matches('/'); + + // 1. Rotate so the currently-active segment closes and becomes + // shippable on this (or a subsequent) run. + self.flush_binary_logs(config).await?; + + // 2. Enumerate segments. The last entry is the new active segment. + let raw = self.show_binary_logs(config).await?; + let all_files = Self::parse_show_binary_logs(&raw); + let closed = Self::closed_binlog_files(&all_files); + if closed.is_empty() { + debug!( + service = %self.name, + "No closed MariaDB binlog segments to archive yet" + ); + return Ok(0); + } + + // 3. Read the manifest to learn what we have already shipped. + let mut manifest = self + .read_binlog_manifest(s3_client, bucket, prefix, &self.name) + .await + .unwrap_or_default(); + + // 4. Compute the to-ship set: closed segments lexicographically + // greater than last_shipped_file (excludes the active file and + // anything already shipped). + let to_ship = Self::binlogs_to_ship(&all_files, manifest.last_shipped_file.as_deref()); + if to_ship.is_empty() { + debug!(service = %self.name, "MariaDB binlogs already up to date in S3"); + return Ok(0); + } + + info!( + service = %self.name, + count = to_ship.len(), + "Shipping MariaDB binlog segment(s) to S3" + ); + + let mut shipped = 0usize; + for file in &to_ship { + let key = Self::binlog_object_key(prefix, &self.name, file); + match self + .ship_one_binlog(s3_client, bucket, &container_name, file, &key) + .await + { + Ok(()) => { + // Advance the manifest only past files that actually + // uploaded, so a mid-run failure never claims an unshipped + // segment is present. + manifest.last_shipped_file = Some(file.clone()); + if !manifest.shipped_files.contains(file) { + manifest.shipped_files.push(file.clone()); + } + shipped += 1; + info!(service = %self.name, binlog = %file, "Shipped MariaDB binlog segment"); + } + Err(e) => { + // Stop at the first failure: segments must be shipped in + // order so the replay chain stays contiguous. Persist + // progress so far below. + warn!( + service = %self.name, + binlog = %file, + "Failed to ship MariaDB binlog segment, stopping run: {}", + e + ); + break; + } + } + } + + // 5. Persist the manifest reflecting what actually landed. + if shipped > 0 { + manifest.updated_at = chrono::Utc::now().to_rfc3339(); + if let Err(e) = self + .write_binlog_manifest(s3_client, bucket, prefix, &manifest) + .await + { + // The segments are uploaded; only the manifest write failed. + // The next run re-reads the (stale) manifest and re-ships the + // same segments idempotently (overwrite is a no-op of content). + warn!( + service = %self.name, + "Shipped {} MariaDB binlog segment(s) but failed to update manifest: {}", + shipped, + e + ); + return Err(e); + } + } + + Ok(shipped) + } + + /// `FLUSH BINARY LOGS` — rotates the active binlog so it closes. + async fn flush_binary_logs(&self, config: &MariaDbConfig) -> Result<()> { + self.run_admin_sql(config, "FLUSH BINARY LOGS").await + } + + /// Raw `SHOW BINARY LOGS` output (tab-separated rows: filename, size, ...). + async fn show_binary_logs(&self, config: &MariaDbConfig) -> Result { + let container_name = self.get_live_container_name(config); + self.run_container_command( + &container_name, + vec![ + "sh".to_string(), + "-c".to_string(), + "if command -v mariadb >/dev/null 2>&1; then \ + mariadb -N -B -uroot -e 'SHOW BINARY LOGS'; \ + else \ + mysql -N -B -uroot -e 'SHOW BINARY LOGS'; \ + fi" + .to_string(), + ], + Some(vec![ + format!("MYSQL_PWD={}", config.root_password), + format!("MARIADB_PWD={}", config.root_password), + ]), + Duration::from_secs(15), + ) + .await + } + + /// Download a single binlog segment out of the container, gzip it, and PUT + /// it to its S3 key. Reads the file as a tar stream so the raw bytes are + /// preserved (an `exec cat` through the log-mux corrupts binary data). + async fn ship_one_binlog( + &self, + s3_client: &aws_sdk_s3::Client, + bucket: &str, + container_name: &str, + file: &str, + key: &str, + ) -> Result<()> { + use std::io::Write; + + let bytes = self + .read_binlog_from_container(container_name, file) + .await?; + + let gzipped = { + let mut encoder = + flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + encoder.write_all(&bytes)?; + encoder.finish()? + }; + + s3_client + .put_object() + .bucket(bucket) + .key(key) + .body(aws_sdk_s3::primitives::ByteStream::from(gzipped)) + .content_type("application/x-gzip") + .send() + .await + .map_err(|e| { + anyhow::anyhow!("Failed to upload binlog to s3://{}/{}: {}", bucket, key, e) + })?; + + Ok(()) + } + + /// Read `/var/lib/mysql/{file}` out of the container as raw bytes via the + /// Docker tar download API (preserves binary content). + async fn read_binlog_from_container( + &self, + container_name: &str, + file: &str, + ) -> Result> { + use std::io::Read; + + let path = format!("/var/lib/mysql/{}", file); + let options = bollard::query_parameters::DownloadFromContainerOptionsBuilder::default() + .path(&path) + .build(); + + let mut tar_stream = self + .docker + .download_from_container(container_name, Some(options)); + + let mut tar_bytes: Vec = Vec::new(); + while let Some(chunk) = tar_stream.next().await { + let bytes = chunk.map_err(|e| { + anyhow::anyhow!("Failed to download binlog {} from container: {}", file, e) + })?; + tar_bytes.extend_from_slice(&bytes); + } + + let mut archive = tar::Archive::new(std::io::Cursor::new(tar_bytes)); + let entries = archive.entries().map_err(|e| { + anyhow::anyhow!("Failed to read binlog tar archive for {}: {}", file, e) + })?; + for entry in entries { + let mut entry = + entry.map_err(|e| anyhow::anyhow!("Failed to read binlog tar entry: {}", e))?; + let mut content = Vec::new(); + entry + .read_to_end(&mut content) + .map_err(|e| anyhow::anyhow!("Failed to read binlog bytes for {}: {}", file, e))?; + if !content.is_empty() { + return Ok(content); + } + } + Err(anyhow::anyhow!( + "Binlog segment {} not found in container tar archive", + file + )) + } + + /// Fetch + parse the binlog manifest from S3. Returns the default (empty) + /// manifest when no manifest exists yet. + async fn read_binlog_manifest( + &self, + s3_client: &aws_sdk_s3::Client, + bucket: &str, + prefix: &str, + // Service name the binlogs were ARCHIVED under. For in-place this is + // `self.name`; for restore-to-new-service it must be the SOURCE service + // name (the new service has a different name but no archived binlogs). + name: &str, + ) -> Result { + let key = Self::binlog_manifest_key(prefix, name); + let resp = match s3_client.get_object().bucket(bucket).key(&key).send().await { + Ok(r) => r, + // Missing manifest is normal on first run. + Err(_) => return Ok(BinlogManifest::default()), + }; + + let bytes = resp + .body + .collect() + .await + .map_err(|e| anyhow::anyhow!("Failed to read binlog manifest body: {}", e))? + .into_bytes(); + + serde_json::from_slice::(&bytes) + .map_err(|e| anyhow::anyhow!("Failed to parse binlog manifest: {}", e)) + } + + /// Serialize + PUT the manifest to S3. + async fn write_binlog_manifest( + &self, + s3_client: &aws_sdk_s3::Client, + bucket: &str, + prefix: &str, + manifest: &BinlogManifest, + ) -> Result<()> { + let key = Self::binlog_manifest_key(prefix, &self.name); + let body = serde_json::to_vec(manifest) + .map_err(|e| anyhow::anyhow!("Failed to serialize binlog manifest: {}", e))?; + s3_client + .put_object() + .bucket(bucket) + .key(&key) + .body(aws_sdk_s3::primitives::ByteStream::from(body)) + .content_type("application/json") + .send() + .await + .map_err(|e| { + anyhow::anyhow!( + "Failed to upload binlog manifest to s3://{}/{}: {}", + bucket, + key, + e + ) + })?; + Ok(()) + } + + /// Parse `SHOW BINARY LOGS` output into segment filenames, in order. + /// Each row is tab-separated (`filename\tsize[\t...]`); blank lines and + /// the `Log_name` header (when present) are ignored. + pub(crate) fn parse_show_binary_logs(raw: &str) -> Vec { + raw.lines() + .filter_map(|line| { + let name = line.split('\t').next()?.trim(); + if name.is_empty() || name == "Log_name" { + return None; + } + Some(name.to_string()) + }) + .collect() + } + + /// All segments except the last — the last one is the currently-active + /// file that is still being written and must not be shipped. + pub(crate) fn closed_binlog_files(all_files: &[String]) -> Vec { + if all_files.len() <= 1 { + return Vec::new(); + } + all_files[..all_files.len() - 1].to_vec() + } + + /// Given the full ordered segment list and the manifest's + /// `last_shipped_file`, compute the segments to ship this run: + /// closed (not the active/last file), strictly lexicographically greater + /// than `last_shipped_file`. `mysql-bin.NNNNNN` names sort correctly + /// lexicographically. + pub(crate) fn binlogs_to_ship(all_files: &[String], last_shipped: Option<&str>) -> Vec { + Self::closed_binlog_files(all_files) + .into_iter() + .filter(|f| match last_shipped { + Some(last) => f.as_str() > last, + None => true, + }) + .collect() + } + + /// S3 object key for a single gzipped binlog segment. + /// `{prefix}/external_services/mariadb/{service}/binlog/{file}.gz` + /// (the leading `{prefix}/` is dropped when `prefix` is empty). + pub(crate) fn binlog_object_key(prefix: &str, service_name: &str, file: &str) -> String { + let tail = format!( + "external_services/mariadb/{}/binlog/{}.gz", + service_name, file + ); + if prefix.is_empty() { + tail + } else { + format!("{}/{}", prefix, tail) + } + } + + /// S3 object key for the binlog manifest. + /// `{prefix}/external_services/mariadb/{service}/binlog/manifest.json`. + pub(crate) fn binlog_manifest_key(prefix: &str, service_name: &str) -> String { + let tail = format!( + "external_services/mariadb/{}/binlog/manifest.json", + service_name + ); + if prefix.is_empty() { + tail + } else { + format!("{}/{}", prefix, tail) + } + } + + async fn dump_all_databases_to_gzip_file( + &self, + config: &MariaDbConfig, + output_path: &std::path::Path, + ) -> Result<()> { + use std::io::Write; + + let container_name = self.get_live_container_name(config); + let env = [ + format!("MYSQL_PWD={}", config.root_password), + format!("MARIADB_PWD={}", config.root_password), + ]; + let cmd = [ + "sh".to_string(), + "-c".to_string(), + "if command -v mariadb >/dev/null 2>&1; then client=mariadb; else client=mysql; fi; \ + if command -v mariadb-dump >/dev/null 2>&1; then dump=mariadb-dump; else dump=mysqldump; fi; \ + dbs=$($client -N -B -uroot -e \"SELECT SCHEMA_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME NOT IN ('information_schema','mysql','performance_schema','sys') ORDER BY SCHEMA_NAME\"); \ + if [ -z \"$dbs\" ]; then \ + echo '-- No user databases to dump'; \ + exit 0; \ + fi; \ + $dump --databases $dbs --single-transaction --quick -uroot" + .to_string(), + ]; + + tokio::time::timeout(MARIADB_BACKUP_EXEC_TIMEOUT, async { + let exec = self + .docker + .create_exec( + &container_name, + CreateExecOptions { + cmd: Some(cmd.iter().map(|s| s.as_str()).collect()), + env: Some(env.iter().map(|s| s.as_str()).collect()), + attach_stdout: Some(true), + attach_stderr: Some(true), + ..Default::default() + }, + ) + .await + .map_err(|e| anyhow::anyhow!("Failed to create MariaDB dump exec: {}", e))?; + + let mut encoder = flate2::write::GzEncoder::new( + std::fs::File::create(output_path)?, + flate2::Compression::default(), + ); + let mut stderr = String::new(); + + let output = self + .docker + .start_exec(&exec.id, None) + .await + .map_err(|e| anyhow::anyhow!("Failed to start MariaDB dump exec: {}", e))?; + + if let bollard::exec::StartExecResults::Attached { mut output, .. } = output { + while let Some(result) = output.next().await { + match result { + Ok(bollard::container::LogOutput::StdOut { message }) => { + encoder.write_all(&message)?; + } + Ok(bollard::container::LogOutput::StdErr { message }) => { + stderr.push_str(&String::from_utf8_lossy(&message)); + } + Ok(_) => {} + Err(e) => { + return Err(anyhow::anyhow!( + "Failed to stream MariaDB dump output: {}", + e + )); + } + } + } + } + + encoder.finish()?; + + let inspect = self + .docker + .inspect_exec(&exec.id) + .await + .map_err(|e| anyhow::anyhow!("Failed to inspect MariaDB dump exec: {}", e))?; + let exit_code = inspect.exit_code.unwrap_or(-1); + if exit_code != 0 { + return Err(anyhow::anyhow!( + "MariaDB dump failed with exit code {}: {}", + exit_code, + stderr.trim() + )); + } + + let size_bytes = std::fs::metadata(output_path)?.len(); + if size_bytes == 0 { + return Err(anyhow::anyhow!( + "MariaDB backup failed: dump file has zero size" + )); + } + + if !stderr.trim().is_empty() { + debug!("MariaDB dump stderr: {}", stderr.trim()); + } + + Ok(()) + }) + .await + .map_err(|_| { + anyhow::anyhow!( + "MariaDB dump timed out after {}s", + MARIADB_BACKUP_EXEC_TIMEOUT.as_secs() + ) + })? + } + + async fn restore_sql_file( + &self, + config: &MariaDbConfig, + sql_path: &std::path::Path, + ) -> Result<()> { + let container_name = self.get_live_container_name(config); + let restore_filename = "temps_mariadb_restore.sql"; + + let tar_data = { + let mut archive = tar::Builder::new(Vec::new()); + archive.append_path_with_name(sql_path, restore_filename)?; + archive.finish()?; + archive.into_inner()? + }; + + self.docker + .upload_to_container( + &container_name, + Some(bollard::query_parameters::UploadToContainerOptions { + path: "/tmp".to_string(), + ..Default::default() + }), + body_full(bytes::Bytes::from(tar_data)), + ) + .await + .map_err(|e| anyhow::anyhow!("Failed to upload MariaDB restore SQL: {}", e))?; + + let restore_path = format!("/tmp/{}", restore_filename); + let restore_cmd = format!( + "if command -v mariadb >/dev/null 2>&1; then \ + mariadb -uroot < {}; \ + else \ + mysql -uroot < {}; \ + fi", + restore_path, restore_path + ); + let env = vec![ + format!("MYSQL_PWD={}", config.root_password), + format!("MARIADB_PWD={}", config.root_password), + ]; + + let result = super::exec_util::run_exec( + &self.docker, + &container_name, + vec!["sh".into(), "-c".into(), restore_cmd], + Some(env), + MARIADB_BACKUP_EXEC_TIMEOUT, + ) + .await; + + let _ = super::exec_util::run_exec( + &self.docker, + &container_name, + vec!["rm".into(), "-f".into(), restore_path], + None, + Duration::from_secs(30), + ) + .await; + + result.map(|_| ()) + } + + // ── Physical (PITR) restore helpers ──────────────────────────────────── + // + // A physical base backup is a gzipped `mariadb-backup --stream=mbstream` + // stream (`base.mbstream.gz`). Restoring it is the documented + // prepare/copy-back dance: + // 1. gunzip the stream onto the host, + // 2. mbstream-extract it into a staging dir inside a helper container + // that shares the service's data volume, + // 3. `mariadb-backup --prepare` the staging dir (apply redo logs), + // 4. wipe the (empty) datadir and `--copy-back` into it, + // 5. chown back to mysql so the server can read it on start. + // + // Getting the stream INTO the helper: the helper is *created* (not started) + // with `volumes_from = [service_container]`, then we `upload_to_container` + // the gunzipped mbstream to `/var/tmp/restore.mbstream` on its writable + // layer (the Docker archive-upload API works on a created/stopped + // container). Only then do we start it to run the swap script. This avoids + // bind mounts (which `volumes_from` cannot express) and avoids feeding the + // stream over an exec stdin pipe (which the log-mux would corrupt). + + /// True when this backup location is a physical (`mariadb-backup` mbstream) + /// base — the only kind PITR can replay onto. + pub(crate) fn is_physical_base_location(location: &str) -> bool { + location.ends_with("base.mbstream.gz") + } + + /// Derive the `metadata.json` companion key from a base backup key by + /// replacing the last path segment. Mirrors + /// `temps_backup::engines::v2_common::derive_metadata_key`. + pub(crate) fn derive_metadata_key(base_key: &str) -> String { + match base_key.rsplit_once('/') { + Some((dir, _last)) => format!("{}/metadata.json", dir), + None => format!("{}.metadata.json", base_key), + } + } + + /// Download the `metadata.json` companion for a base backup and parse it. + async fn fetch_base_metadata( + &self, + s3_client: &aws_sdk_s3::Client, + bucket: &str, + base_key: &str, + ) -> Result { + let metadata_key = Self::derive_metadata_key(base_key); + let resp = s3_client + .get_object() + .bucket(bucket) + .key(&metadata_key) + .send() + .await + .map_err(|e| { + anyhow::anyhow!( + "Failed to download base metadata from s3://{}/{}: {}", + bucket, + metadata_key, + e + ) + })?; + let bytes = resp + .body + .collect() + .await + .map_err(|e| anyhow::anyhow!("Failed to read base metadata body: {}", e))? + .into_bytes(); + serde_json::from_slice::(&bytes) + .map_err(|e| anyhow::anyhow!("Failed to parse base metadata.json: {}", e)) + } + + /// Download `base.mbstream.gz` from S3, gunzip it, and write the raw + /// mbstream to `dest` on the host. + async fn download_and_gunzip_base( + &self, + s3_client: &aws_sdk_s3::Client, + bucket: &str, + base_key: &str, + dest: &std::path::Path, + ) -> Result<()> { + use std::io::Read; + + let resp = s3_client + .get_object() + .bucket(bucket) + .key(base_key) + .send() + .await + .map_err(|e| { + anyhow::anyhow!( + "Failed to download physical base from s3://{}/{}: {}", + bucket, + base_key, + e + ) + })?; + let gz = resp + .body + .collect() + .await + .map_err(|e| anyhow::anyhow!("Failed to read physical base body: {}", e))? + .into_bytes(); + + let mut decoder = flate2::read::GzDecoder::new(std::io::Cursor::new(gz)); + let mut stream = Vec::new(); + decoder + .read_to_end(&mut stream) + .map_err(|e| anyhow::anyhow!("Failed to gunzip physical base: {}", e))?; + if stream.is_empty() { + return Err(anyhow::anyhow!( + "Physical base mbstream is empty after gunzip" + )); + } + tokio::fs::write(dest, &stream).await.map_err(|e| { + anyhow::anyhow!("Failed to write mbstream to {}: {}", dest.display(), e) + })?; + Ok(()) + } + + /// Perform a physical (mbstream) restore into the named container's data + /// volume. The container is expected to be the service's live container + /// (running or not); on return the container is restarted and healthy. + /// + /// Sequence mirrors postgres' ephemeral-helper data swap: + /// disable restart policy → stop → run helper (extract/prepare/copy-back/ + /// chown) → re-enable restart policy → start → wait healthy. + async fn physical_restore_into_container( + &self, + config: &MariaDbConfig, + mbstream_host_path: &std::path::Path, + ) -> Result<()> { + let container_name = self.get_live_container_name(config); + + // 1. Disable restart policy FIRST so Docker doesn't bounce the + // container back up while the helper holds the volume. + info!( + "Disabling restart policy and stopping container {} for MariaDB physical restore", + container_name + ); + self.docker + .update_container( + &container_name, + bollard::models::ContainerUpdateBody { + restart_policy: Some(bollard::models::RestartPolicy { + name: Some(bollard::models::RestartPolicyNameEnum::NO), + maximum_retry_count: None, + }), + ..Default::default() + }, + ) + .await + .map_err(|e| anyhow::anyhow!("Failed to disable restart policy: {}", e))?; + + // 2. Stop the container so the helper has exclusive access to the + // datadir volume. + let _ = self + .docker + .stop_container( + &container_name, + Some(bollard::query_parameters::StopContainerOptions { + t: Some(30), + signal: None, + }), + ) + .await; + + // 3. Run the helper that extracts, prepares, and copy-backs the base. + let swap_result = self + .run_physical_restore_helper(config, &container_name, mbstream_host_path) + .await; + + // 4. Always re-enable the restart policy, even if the swap failed, so a + // later manual start brings the service back under supervision. + if let Err(e) = self + .docker + .update_container( + &container_name, + bollard::models::ContainerUpdateBody { + restart_policy: Some(bollard::models::RestartPolicy { + name: Some(bollard::models::RestartPolicyNameEnum::ALWAYS), + maximum_retry_count: None, + }), + ..Default::default() + }, + ) + .await + { + warn!("Failed to re-enable restart policy after restore: {}", e); + } + + swap_result?; + + // 5. Start the container on the restored datadir and wait for health. + self.docker + .start_container( + &container_name, + None::, + ) + .await + .map_err(|e| { + anyhow::anyhow!("Failed to start MariaDB container after restore: {}", e) + })?; + self.wait_for_container_health(&self.docker, &container_name) + .await?; + + info!("MariaDB physical restore completed for {}", container_name); + Ok(()) + } + + /// Create (don't start) the helper, upload the mbstream onto its writable + /// layer, then start it to run extract → prepare → copy-back → chown. + async fn run_physical_restore_helper( + &self, + config: &MariaDbConfig, + container_name: &str, + mbstream_host_path: &std::path::Path, + ) -> Result<()> { + use bollard::models::{ContainerCreateBody, HostConfig}; + + let helper_name = format!("{}-restore-helper", container_name); + // Best-effort cleanup of a leftover helper from a prior failed run. + let _ = self + .docker + .remove_container( + &helper_name, + Some(bollard::query_parameters::RemoveContainerOptions { + force: true, + v: false, + ..Default::default() + }), + ) + .await; + + // The staging dir and copy-back run as root inside the helper; the + // final chown hands the datadir back to the mysql uid the server runs + // as. CRITICAL: the datadir must be EMPTY before --copy-back, and owned + // by mysql afterwards. + let stage = "/var/tmp/temps-mariadb-restore"; + let stream_path = "/var/tmp/restore.mbstream"; + let swap_script = format!( + "set -ex; \ + if command -v mariadb-backup >/dev/null 2>&1; then BK=mariadb-backup; else BK=mariabackup; fi; \ + echo temps-mariadb-restore: staging extract; \ + rm -rf {stage}; mkdir -p {stage}; \ + mbstream -x -C {stage} < {stream}; \ + echo temps-mariadb-restore: preparing base; \ + \"$BK\" --prepare --target-dir={stage}; \ + echo temps-mariadb-restore: replacing datadir; \ + find /var/lib/mysql -mindepth 1 -maxdepth 1 -exec rm -rf {{}} +; \ + echo temps-mariadb-restore: copy-back; \ + \"$BK\" --copy-back --target-dir={stage} --datadir=/var/lib/mysql; \ + echo temps-mariadb-restore: chown datadir; \ + chown -R mysql:mysql /var/lib/mysql; \ + rm -rf {stage} {stream}; \ + echo temps-mariadb-restore: complete", + stage = stage, + stream = stream_path, + ); + + let helper_config = ContainerCreateBody { + image: Some(config.docker_image.clone()), + cmd: Some(vec!["sh".to_string(), "-c".to_string(), swap_script]), + // Run as root so we can wipe the datadir and chown back to mysql. + user: Some("root".to_string()), + host_config: Some(HostConfig { + volumes_from: Some(vec![container_name.to_string()]), + ..Default::default() + }), + ..Default::default() + }; + + let helper = self + .docker + .create_container( + Some( + bollard::query_parameters::CreateContainerOptionsBuilder::new() + .name(&helper_name) + .build(), + ), + helper_config, + ) + .await + .map_err(|e| anyhow::anyhow!("Failed to create restore helper container: {}", e))?; + + // Upload the gunzipped mbstream onto the helper's writable layer at + // /var/tmp/. The archive-upload API works on a created (not running) + // container, so the file is in place before the entrypoint runs. + let upload_result = self + .upload_file_to_container( + &helper.id, + mbstream_host_path, + "/var/tmp", + "restore.mbstream", + MARIADB_BACKUP_EXEC_TIMEOUT, + ) + .await; + if let Err(e) = upload_result { + let _ = self + .docker + .remove_container( + &helper.id, + Some(bollard::query_parameters::RemoveContainerOptions { + force: true, + v: false, + ..Default::default() + }), + ) + .await; + return Err(e); + } + + // Start the helper and wait for it to finish. + let run_result = async { + self.docker + .start_container( + &helper.id, + None::, + ) + .await + .map_err(|e| anyhow::anyhow!("Failed to start restore helper container: {}", e))?; + + let wait = tokio::time::timeout(MARIADB_RESTORE_HELPER_TIMEOUT, async { + self.docker + .wait_container( + &helper.id, + None::, + ) + .next() + .await + }) + .await; + + // Capture helper logs for diagnostics on failure. + let logs = self.collect_container_logs(&helper.id).await; + + match wait { + Ok(Some(Ok(resp))) if resp.status_code == 0 => Ok(()), + Ok(Some(Ok(resp))) => Err(anyhow::anyhow!( + "MariaDB restore helper exited with code {}: {}", + resp.status_code, + logs.trim() + )), + Ok(Some(Err(e))) => Err(anyhow::anyhow!( + "Failed waiting on MariaDB restore helper: {}", + e + )), + Ok(None) => Err(anyhow::anyhow!( + "MariaDB restore helper produced no wait result" + )), + Err(_) => Err(anyhow::anyhow!( + "MariaDB restore helper timed out after {}s: {}", + MARIADB_RESTORE_HELPER_TIMEOUT.as_secs(), + logs.trim() + )), + } + } + .await; + + // Always remove the helper. + let _ = self + .docker + .remove_container( + &helper.id, + Some(bollard::query_parameters::RemoveContainerOptions { + force: true, + v: false, + ..Default::default() + }), + ) + .await; + + run_result + } + + /// Upload a single host file into a container at `dest_dir/dest_name` via + /// the Docker tar archive-upload API (works on created/stopped containers). + async fn upload_file_to_container( + &self, + container_id: &str, + host_path: &std::path::Path, + dest_dir: &str, + dest_name: &str, + timeout: Duration, + ) -> Result<()> { + let tar_data = { + let mut archive = tar::Builder::new(Vec::new()); + archive + .append_path_with_name(host_path, dest_name) + .map_err(|e| { + anyhow::anyhow!("Failed to tar {} for upload: {}", host_path.display(), e) + })?; + archive.finish()?; + archive + .into_inner() + .map_err(|e| anyhow::anyhow!("Failed to finalize upload tar: {}", e))? + }; + + tokio::time::timeout(timeout, async { + self.docker + .upload_to_container( + container_id, + Some(bollard::query_parameters::UploadToContainerOptions { + path: dest_dir.to_string(), + ..Default::default() + }), + body_full(bytes::Bytes::from(tar_data)), + ) + .await + }) + .await + .map_err(|_| { + anyhow::anyhow!( + "Timed out uploading {} to container {} after {}s", + dest_name, + container_id, + timeout.as_secs() + ) + })? + .map_err(|e| anyhow::anyhow!("Failed to upload {} to container: {}", dest_name, e))?; + Ok(()) + } + + /// Best-effort collection of a container's combined stdout/stderr logs, + /// for diagnostics. Never returns an error (returns "" on failure). + async fn collect_container_logs(&self, container_id: &str) -> String { + match tokio::time::timeout(Duration::from_secs(10), async { + let mut out = String::new(); + let mut stream = self.docker.logs( + container_id, + Some(bollard::query_parameters::LogsOptions { + stdout: true, + stderr: true, + follow: false, + tail: "200".to_string(), + ..Default::default() + }), + ); + while let Some(item) = stream.next().await { + match item { + Ok(chunk) => out.push_str(&String::from_utf8_lossy(&chunk.into_bytes())), + Err(_) => break, + } + } + out + }) + .await + { + Ok(logs) => logs, + Err(_) => "timed out while collecting MariaDB restore helper logs".to_string(), + } + } + + // ── Binlog fetch + replay (PITR forward-roll) ────────────────────────── + + /// Format a UTC time as the `mysqlbinlog --stop-datetime` argument value + /// (`YYYY-MM-DD HH:MM:SS`). The server runs `TZ=UTC` so this is + /// interpreted in UTC. + pub(crate) fn format_stop_datetime(time: chrono::DateTime) -> String { + time.format("%Y-%m-%d %H:%M:%S").to_string() + } + + /// Map a `RecoveryTarget` to the `mysqlbinlog` stop-flag for the FINAL + /// segment being replayed. + /// + /// Returns the flag as a `(flag, value)` pair, or `None` for "replay to the + /// end" (no stop). Errors for targets MariaDB cannot honor. + /// + /// - `Time` → `--stop-datetime='YYYY-MM-DD HH:MM:SS'` (UTC). + /// - `Lsn` → interpreted as `binlog_file:position`; `--stop-position` is + /// only meaningful when that file is the final segment replayed. A bare + /// position (no `file:`) is rejected as ambiguous across segments. + /// - `Xid` → GTID stop is not yet expressible via a single mysqlbinlog + /// invocation here; rejected rather than silently mis-recovering. + /// - `Name` → no MariaDB equivalent; rejected. + pub(crate) fn recovery_target_to_stop_flag( + target: &RecoveryTarget, + ) -> Result> { + match target { + RecoveryTarget::Time { time } => Ok(Some(( + "--stop-datetime".to_string(), + Self::format_stop_datetime(*time), + ))), + RecoveryTarget::Lsn { lsn } => { + // Accept "binlog_file:position"; reject a bare position. + match lsn.rsplit_once(':') { + Some((file, pos)) + if !file.is_empty() + && pos.chars().all(|c| c.is_ascii_digit()) + && !pos.is_empty() => + { + Ok(Some(("--stop-position".to_string(), pos.to_string()))) + } + _ => Err(anyhow::anyhow!( + "PITR Lsn target must be 'binlog_file:position' (a bare position is \ + ambiguous across binlog segments); got '{}'", + lsn + )), + } + } + RecoveryTarget::Xid { xid } => Err(anyhow::anyhow!( + "PITR Xid/GTID target ('{}') is not yet supported for MariaDB physical \ + restore; use a Time target", + xid + )), + RecoveryTarget::Name { name } => Err(anyhow::anyhow!( + "PITR Name target ('{}') has no MariaDB equivalent", + name + )), + } + } + + /// For an `Lsn` target, the binlog file the `--stop-position` applies to + /// (the final segment to replay). `None` for non-Lsn targets. + fn lsn_target_file(target: &RecoveryTarget) -> Option { + match target { + RecoveryTarget::Lsn { lsn } => lsn + .rsplit_once(':') + .map(|(file, _)| file.to_string()) + .filter(|f| !f.is_empty()), + _ => None, + } + } + + /// Download the archived binlog segments needed for replay (every shipped + /// file lexicographically >= `start_file`), gunzip them, and write them to + /// `dest_dir` preserving order. Returns the ordered list of (host_path, + /// filename) pairs. + async fn fetch_binlogs_for_replay( + &self, + s3_client: &aws_sdk_s3::Client, + bucket: &str, + prefix: &str, + // Service name the binlogs were ARCHIVED under (the SOURCE service), + // which differs from `self.name` on a restore-to-new-service. + source_name: &str, + start_file: &str, + dest_dir: &std::path::Path, + ) -> Result> { + use std::io::Read; + + let manifest = self + .read_binlog_manifest(s3_client, bucket, prefix, source_name) + .await + .unwrap_or_default(); + + // Contiguous segment set: every shipped file >= the base's start file, + // in lexicographic (== chronological for fixed-width names) order. + let mut files: Vec = manifest + .shipped_files + .iter() + .filter(|f| f.as_str() >= start_file) + .cloned() + .collect(); + files.sort(); + files.dedup(); + + if files.is_empty() { + warn!( + service = %self.name, + start_file = %start_file, + "No archived binlog segments >= base start file; PITR will replay nothing \ + (recovery target may predate the base, or binlogs not yet shipped)" + ); + } + + let mut result = Vec::with_capacity(files.len()); + for file in files { + let key = Self::binlog_object_key(prefix, source_name, &file); + let resp = s3_client + .get_object() + .bucket(bucket) + .key(&key) + .send() + .await + .map_err(|e| { + anyhow::anyhow!( + "Failed to download binlog segment s3://{}/{}: {}", + bucket, + key, + e + ) + })?; + let gz = resp + .body + .collect() + .await + .map_err(|e| anyhow::anyhow!("Failed to read binlog segment {}: {}", file, e))? + .into_bytes(); + let mut decoder = flate2::read::GzDecoder::new(std::io::Cursor::new(gz)); + let mut raw = Vec::new(); + decoder + .read_to_end(&mut raw) + .map_err(|e| anyhow::anyhow!("Failed to gunzip binlog segment {}: {}", file, e))?; + let host_path = dest_dir.join(&file); + tokio::fs::write(&host_path, &raw) + .await + .map_err(|e| anyhow::anyhow!("Failed to write binlog {} to host: {}", file, e))?; + result.push((host_path, file)); + } + Ok(result) + } + + /// Replay the given ordered binlog segments into the (running, restored) + /// container up to the recovery target, in a SINGLE `mysqlbinlog` + /// invocation piped to ONE `mariadb` client. + /// + /// `--start-position` applies to the FIRST file only (the base's recorded + /// position). The stop flag (from `recovery_target_to_stop_flag`) applies + /// to the LAST file replayed. `--disable-log-bin` keeps replayed events out + /// of the restored server's own binlog so future PITR coordinates stay + /// correct. Credentials flow via `MYSQL_PWD` env, never argv. + async fn replay_binlogs( + &self, + config: &MariaDbConfig, + segments: &[(std::path::PathBuf, String)], + start_position: &str, + target: &RecoveryTarget, + ) -> Result<()> { + if segments.is_empty() { + info!("No binlog segments to replay for PITR; base restore is the recovery point"); + return Ok(()); + } + + let container_name = self.get_live_container_name(config); + let container_dir = "/var/tmp/temps-binlogs"; + + // Upload every segment into the container, preserving filenames. + let _ = super::exec_util::run_exec( + &self.docker, + &container_name, + vec!["mkdir".into(), "-p".into(), container_dir.into()], + None, + Duration::from_secs(30), + ) + .await; + + let mut container_files: Vec = Vec::with_capacity(segments.len()); + for (host_path, file) in segments { + info!( + service = %self.name, + file = %file, + "Uploading MariaDB PITR binlog segment to restored container" + ); + self.upload_file_to_container( + &container_name, + host_path, + container_dir, + file, + MARIADB_BINLOG_UPLOAD_TIMEOUT, + ) + .await?; + container_files.push(format!("{}/{}", container_dir, file)); + } + info!( + service = %self.name, + segments = container_files.len(), + "Uploaded MariaDB PITR binlog segments to restored container" + ); + + // Build the single mysqlbinlog invocation. --start-position applies to + // the first file; the stop flag (if any) to the run as a whole (it + // takes effect on the segment that contains the target). + let stop_flag = Self::recovery_target_to_stop_flag(target)?; + // For an Lsn target, --stop-position is only sound when the target file + // is the LAST segment replayed; otherwise the same numeric position + // exists in multiple files and we'd stop in the wrong one. + if let Some(target_file) = Self::lsn_target_file(target) { + let last = container_files + .last() + .map(|p| p.rsplit('/').next().unwrap_or(p).to_string()) + .unwrap_or_default(); + if last != target_file { + return Err(anyhow::anyhow!( + "PITR Lsn target file '{}' is not the final replayed segment ('{}'); \ + a bare --stop-position would be ambiguous across segments", + target_file, + last + )); + } + } + + // `$BINLOG` is resolved at run time in the shell below — mariadb:lts + // ships `mariadb-binlog`, not `mysqlbinlog`. + let mut binlog_args = String::from("\"$BINLOG\" --disable-log-bin"); + binlog_args.push_str(&format!(" --start-position={}", start_position)); + if let Some((flag, value)) = &stop_flag { + // Quote the value (datetimes contain a space). + binlog_args.push_str(&format!(" {}={}", flag, Self::shell_single_quote(value))); + } + for f in &container_files { + binlog_args.push(' '); + binlog_args.push_str(&Self::shell_single_quote(f)); + } + + // Resolve tool names at run time: mariadb:lts ships `mariadb-binlog` + // and `mariadb` (NOT `mysqlbinlog`/`mysql`); fall back to the mysql + // names for non-MariaDB images. dash has no pipefail, so we decode to + // an intermediate file FIRST (under `set -e`, a failed decode aborts + // before the client runs) and only then feed it to the client — this + // surfaces a broken replay as an error rather than a silent + // half-apply masked by the client's exit code in a pipe. + let replay_file = "/var/tmp/temps-pitr-replay.sql"; + let replay_cmd = format!( + "set -ex; \ + if command -v mariadb-binlog >/dev/null 2>&1; then BINLOG=mariadb-binlog; else BINLOG=mysqlbinlog; fi; \ + if command -v mariadb >/dev/null 2>&1; then CLIENT=mariadb; else CLIENT=mysql; fi; \ + echo temps-mariadb-pitr-replay: decode-binlogs; \ + timeout 120s {binlog} > {file}; \ + ls -lh {file}; \ + echo temps-mariadb-pitr-replay: apply-sql; \ + timeout 120s \"$CLIENT\" --protocol=TCP -h127.0.0.1 -P3306 --connect-timeout=10 -uroot --password=\"$MARIADB_ROOT_PASSWORD\" --binary-mode=1 < {file}; \ + rm -f {file}; \ + echo temps-mariadb-pitr-replay: complete", + binlog = binlog_args, + file = replay_file, + ); + + let env = vec![ + format!("MYSQL_PWD={}", config.root_password), + format!("MARIADB_PWD={}", config.root_password), + format!("MARIADB_ROOT_PASSWORD={}", config.root_password), + ]; + + info!( + service = %self.name, + segments = segments.len(), + "Replaying MariaDB binlog segments for PITR" + ); + + let result = super::exec_util::run_exec( + &self.docker, + &container_name, + vec!["sh".into(), "-c".into(), replay_cmd], + Some(env), + MARIADB_BINLOG_REPLAY_TIMEOUT, + ) + .await; + + // Clean up uploaded binlogs regardless of outcome. + let _ = super::exec_util::run_exec( + &self.docker, + &container_name, + vec!["rm".into(), "-rf".into(), container_dir.into()], + None, + Duration::from_secs(30), + ) + .await; + + result + .map(|_| ()) + .map_err(|e| anyhow::anyhow!("MariaDB binlog replay failed (PITR not applied): {}", e)) + } + + /// Build the parameter map + connection string the orchestrator persists + /// for a restore-to-new-service result. + fn new_service_result(config: &MariaDbConfig) -> Result { + let runtime_json = serde_json::to_value(config) + .map_err(|e| anyhow::anyhow!("Failed to serialize new MariaDB config: {}", e))?; + let mut parameters = HashMap::new(); + if let Some(obj) = runtime_json.as_object() { + for (k, v) in obj { + if let Some(s) = v.as_str() { + parameters.insert(k.clone(), s.to_string()); + } else if let Some(n) = v.as_u64() { + parameters.insert(k.clone(), n.to_string()); + } + } + } + let connection_info = format!( + "mysql://{}:***@{}:{}/{}", + config.username, config.host, config.port, config.database + ); + Ok(NewServiceRestoreResult { + parameters, + connection_info, + }) + } + + /// Clone the source config onto a fresh port and build a new + /// `MariaDbService` for it, creating its container. Shared by + /// restore_to_new_service and to_new_service PITR. + async fn provision_new_service_for_restore( + &self, + source_config: &ServiceConfig, + new_service_name: &str, + parameter_overrides: &serde_json::Value, + ) -> Result<(MariaDbService, MariaDbConfig)> { + let mut config = self.get_mariadb_config(source_config.clone())?; + + // Fresh port (the source's is taken). A restored new service is its own + // container, not an imported one. + config.container_name = None; + let new_port = find_available_port(3306) + .ok_or_else(|| anyhow::anyhow!("No available ports for new MariaDB service"))? + .to_string(); + config.port = new_port; + + if let Some(overrides) = parameter_overrides.as_object() { + if let Some(port) = overrides.get("port").and_then(|v| v.as_str()) { + config.port = port.to_string(); + } + if let Some(image) = overrides.get("docker_image").and_then(|v| v.as_str()) { + config.docker_image = image.to_string(); + } + if let Some(db) = overrides.get("database").and_then(|v| v.as_str()) { + config.database = db.to_string(); + } + } + + let new_service = MariaDbService::new(new_service_name.to_string(), self.docker.clone()); + let cloned_limits = ServiceResourceLimits::from_parameters(&source_config.parameters); + *new_service.config.write().await = Some(config.clone()); + *new_service.resource_limits.write().await = cloned_limits.clone(); + new_service + .create_container(&self.docker, &config, &cloned_limits) + .await?; + + Ok((new_service, config)) + } + + /// Restore a base backup (physical or logical) into the given service's + /// container, dispatching on the backup location. + async fn restore_base_into( + &self, + service: &MariaDbService, + config: &MariaDbConfig, + s3_client: &aws_sdk_s3::Client, + bucket: &str, + backup_location: &str, + ) -> Result<()> { + let base_key = Self::backup_key_from_location(backup_location, bucket); + if Self::is_physical_base_location(&base_key) { + let temp_dir = tempfile::tempdir()?; + let mbstream_path = temp_dir.path().join("base.mbstream"); + service + .download_and_gunzip_base(s3_client, bucket, &base_key, &mbstream_path) + .await?; + service + .physical_restore_into_container(config, &mbstream_path) + .await?; + } else { + // Logical .sql.gz base — download, gunzip, and restore via the + // mariadb client. Shares the internal logical-restore helper with + // `restore_from_s3` so we don't fabricate an `s3_sources::Model`. + service + .restore_logical_from_s3(s3_client, bucket, backup_location, config) + .await?; + } + Ok(()) + } + + /// Logical restore core: download a `.sql[.gz]` backup from S3, decompress + /// if needed, and feed it to the mariadb client. Shared by the public + /// `restore_from_s3` and the restore framework's `restore_base_into`. + async fn restore_logical_from_s3( + &self, + s3_client: &aws_sdk_s3::Client, + bucket: &str, + backup_location: &str, + config: &MariaDbConfig, + ) -> Result<()> { + use std::io::Read; + + let backup_key = Self::backup_key_from_location(backup_location, bucket); + let response = s3_client + .get_object() + .bucket(bucket) + .key(&backup_key) + .send() + .await + .map_err(|e| anyhow::anyhow!("Failed to download MariaDB backup from S3: {}", e))?; + + let backup_data = response + .body + .collect() + .await + .map_err(|e| anyhow::anyhow!("Failed to read MariaDB backup data: {}", e))? + .into_bytes(); + + let temp_dir = tempfile::tempdir()?; + let sql_path = temp_dir.path().join("restore.sql"); + + if backup_key.ends_with(".gz") { + let mut decoder = flate2::read::GzDecoder::new(std::io::Cursor::new(backup_data)); + let mut sql = Vec::new(); + decoder.read_to_end(&mut sql)?; + tokio::fs::write(&sql_path, sql).await?; + } else { + tokio::fs::write(&sql_path, backup_data).await?; + } + + self.restore_sql_file(config, &sql_path).await + } + + /// POSIX single-quote escape for embedding a value in an `sh -c` string. + pub(crate) fn shell_single_quote(s: &str) -> String { + format!("'{}'", s.replace('\'', "'\\''")) + } +} + +#[async_trait] +impl ExternalService for MariaDbService { + async fn init(&self, config: ServiceConfig) -> Result> { + info!( + "Initializing MariaDB service (name={}, type={:?}, version={:?})", + config.name, config.service_type, config.version + ); + + let resource_limits = ServiceResourceLimits::from_parameters(&config.parameters); + if let Err(e) = resource_limits.validate() { + return Err(anyhow::anyhow!("Invalid resource limits: {}", e)); + } + + let mariadb_config = self.get_mariadb_config(config)?; + + debug!( + "MariaDB init - storing config: port={}, username={}, database={}", + mariadb_config.port, mariadb_config.username, mariadb_config.database + ); + + *self.config.write().await = Some(mariadb_config.clone()); + *self.resource_limits.write().await = resource_limits.clone(); + + if mariadb_config.container_name.is_none() { + self.create_container(&self.docker, &mariadb_config, &resource_limits) + .await?; + } else { + info!( + "MariaDB service '{}' is imported from container '{}'; skipping container creation", + self.name, + self.get_live_container_name(&mariadb_config) + ); + } + + let runtime_config_json = serde_json::to_value(&mariadb_config) + .map_err(|e| anyhow::anyhow!("Failed to serialize MariaDB runtime config: {}", e))?; + let runtime_config_map = runtime_config_json + .as_object() + .ok_or_else(|| anyhow::anyhow!("Runtime config is not an object"))?; + + let mut inferred_params = HashMap::new(); + for (key, value) in runtime_config_map { + if let Some(str_value) = value.as_str() { + inferred_params.insert(key.clone(), str_value.to_string()); + } + } + + Ok(inferred_params) + } + + async fn health_check(&self) -> Result { + let config = self + .config + .read() + .await + .as_ref() + .ok_or_else(|| anyhow::anyhow!("MariaDB configuration not found"))? + .clone(); + Ok(self.ping(&config).await.is_ok()) + } + + async fn health_probe(&self, service_config: ServiceConfig) -> Result { + use std::time::Instant; + + const DEGRADED_MS: u128 = 2000; + + let cfg = match self.get_mariadb_config(service_config) { + Ok(c) => c, + Err(e) => { + return Ok(HealthProbeResult::down(format!( + "invalid mariadb config: {}", + e + ))) + } + }; + + let start = Instant::now(); + match self.ping(&cfg).await { + Ok(()) => { + let elapsed_ms = start.elapsed().as_millis(); + let response_time = i32::try_from(elapsed_ms).ok(); + if elapsed_ms > DEGRADED_MS { + Ok(HealthProbeResult::degraded( + format!("mariadb responded in {}ms (>{}ms)", elapsed_ms, DEGRADED_MS), + response_time, + )) + } else { + Ok(HealthProbeResult::operational(response_time)) + } + } + Err(e) => Ok(HealthProbeResult::down(format!( + "mariadb probe failed: {}", + e + ))), + } + } + + fn get_type(&self) -> ServiceType { + ServiceType::Mariadb + } + + fn get_name(&self) -> String { + self.name.clone() + } + + fn get_connection_info(&self) -> Result { + let config = self + .config + .try_read() + .map_err(|_| anyhow::anyhow!("Failed to read config"))?; + + match &*config { + Some(cfg) => Ok(format!( + "mysql://{}:***@{}:{}/{}", + cfg.username, cfg.host, cfg.port, cfg.database + )), + None => Err(anyhow::anyhow!("MariaDB not configured")), + } + } + + async fn cleanup(&self) -> Result<()> { + Ok(()) + } + + fn get_parameter_schema(&self) -> Option { + let schema = schemars::schema_for!(MariaDbInputConfig); + let mut schema_json = serde_json::to_value(schema).ok()?; + + if let Some(properties) = schema_json + .get_mut("properties") + .and_then(|p| p.as_object_mut()) + { + for key in properties.keys().cloned().collect::>() { + let editable = match key.as_str() { + "port" => true, + "docker_image" => true, + // PITR granularity can be tuned at runtime: the archiver + // picks up a new cadence live; the derived + // binlog_expire_logs_seconds takes effect on next recreate. + "binlog_archive_interval" => true, + "size_profile" => false, + "host" | "database" | "username" | "password" | "root_password" => false, + _ => false, + }; + + if let Some(prop) = properties.get_mut(&key).and_then(|p| p.as_object_mut()) { + prop.insert("x-editable".to_string(), serde_json::json!(editable)); + } + } + + properties.insert( + "size_profile".to_string(), + serde_json::json!({ + "type": "string", + "description": "MariaDB resource/tuning profile. Small is the default for shared 4 GiB and 8 GiB Temps hosts; linked projects get separate databases inside this service.", + "default": "small", + "enum": ["small", "standard", "dedicated"], + "x-editable": false + }), + ); + + properties.insert( + "binlog_archive_interval".to_string(), + serde_json::json!({ + "type": "string", + "description": "Point-in-time-recovery granularity: how often binary logs are shipped to S3. Smaller intervals lose less data on restore (lower RPO) but upload more often. The worst-case data loss on restore is one interval.", + "default": "5m", + "enum": ["1m", "5m", "15m", "60m"], + "x-editable": true + }), + ); + } + + Some(schema_json) + } + + async fn start(&self) -> Result<()> { + let existing_config = self.config.read().await.as_ref().cloned(); + let container_name = existing_config + .as_ref() + .map(|config| self.get_live_container_name(config)) + .unwrap_or_else(|| self.get_container_name()); + info!("Starting MariaDB container {}", container_name); + + let containers = self + .docker + .list_containers(Some(bollard::query_parameters::ListContainersOptions { + all: true, + filters: Some(HashMap::from([( + "name".to_string(), + vec![container_name.clone()], + )])), + ..Default::default() + })) + .await?; + + if containers.is_empty() { + let config = existing_config + .ok_or_else(|| anyhow::anyhow!("MariaDB configuration not found"))?; + if config.container_name.is_some() { + return Err(anyhow::anyhow!( + "Imported MariaDB container '{}' not found", + container_name + )); + } + let limits = self.resource_limits.read().await.clone(); + self.create_container(&self.docker, &config, &limits) + .await?; + } else { + let container = &containers[0]; + let is_running = matches!( + container.state, + Some(bollard::models::ContainerSummaryStateEnum::RUNNING) + ); + + if !is_running { + self.docker + .start_container( + &container_name, + None::, + ) + .await + .map_err(|e| anyhow::anyhow!("Failed to start MariaDB container: {}", e))?; + } + } + + self.wait_for_container_health(&self.docker, &container_name) + .await?; + Ok(()) + } + + async fn stop(&self) -> Result<()> { + let container_name = self + .config + .read() + .await + .as_ref() + .map(|config| self.get_live_container_name(config)) + .unwrap_or_else(|| self.get_container_name()); + let containers = self + .docker + .list_containers(Some(bollard::query_parameters::ListContainersOptions { + all: true, + filters: Some(HashMap::from([( + "name".to_string(), + vec![container_name.clone()], + )])), + ..Default::default() + })) + .await?; + + if !containers.is_empty() { + self.docker + .stop_container(&container_name, None::) + .await + .map_err(|e| anyhow::anyhow!("Failed to stop MariaDB container: {}", e))?; + } + + Ok(()) + } + + async fn remove(&self) -> Result<()> { + self.cleanup().await?; + + let container_name = self.get_container_name(); + let volume_name = format!("mariadb_data_{}", self.name); + + let containers = self + .docker + .list_containers(Some(bollard::query_parameters::ListContainersOptions { + all: true, + filters: Some(HashMap::from([( + "name".to_string(), + vec![container_name.clone()], + )])), + ..Default::default() + })) + .await?; + + if !containers.is_empty() { + let _ = self + .docker + .stop_container(&container_name, None::) + .await; + self.docker + .remove_container( + &container_name, + Some(bollard::query_parameters::RemoveContainerOptions { + force: true, + ..Default::default() + }), + ) + .await + .map_err(|e| anyhow::anyhow!("Failed to remove MariaDB container: {}", e))?; + } + + match self + .docker + .remove_volume( + &volume_name, + None::, + ) + .await + { + Ok(_) => info!("Removed MariaDB volume {}", volume_name), + Err(e) => info!("Error removing MariaDB volume {}: {}", volume_name, e), + } + + Ok(()) + } + + fn get_environment_variables( + &self, + parameters: &HashMap, + ) -> Result> { + let database = parameters + .get("database") + .ok_or_else(|| anyhow::anyhow!("Missing database parameter"))?; + let username = parameters + .get("username") + .ok_or_else(|| anyhow::anyhow!("Missing username parameter"))?; + let password = parameters + .get("password") + .ok_or_else(|| anyhow::anyhow!("Missing password parameter"))?; + + let host = parameters + .get("container_name") + .cloned() + .or_else(|| parameters.get("host").cloned()) + .unwrap_or_else(|| self.get_container_name()); + + Self::build_env_vars(&host, MARIADB_INTERNAL_PORT, database, username, password) + } + + fn get_docker_environment_variables( + &self, + parameters: &HashMap, + ) -> Result> { + self.get_environment_variables(parameters) + } + + async fn provision_resource( + &self, + service_config: ServiceConfig, + project_id: &str, + environment: &str, + ) -> Result { + let resource_name = + Self::normalize_database_name(&format!("{}_{}", project_id, environment)); + self.create_database(service_config.clone(), &resource_name) + .await?; + + let credentials = self.build_runtime_env_vars(service_config, &resource_name)?; + Ok(LogicalResource { + name: resource_name, + resource_type: "database".to_string(), + credentials, + }) + } + + async fn deprovision_resource(&self, project_id: &str, environment: &str) -> Result<()> { + let resource_name = + Self::normalize_database_name(&format!("{}_{}", project_id, environment)); + let Some(config) = self.config.read().await.as_ref().cloned() else { + return Ok(()); + }; + let service_config = ServiceConfig { + name: self.name.clone(), + service_type: ServiceType::Mariadb, + version: None, + parameters: serde_json::to_value(config)?, + }; + self.drop_database(service_config, &resource_name).await + } + + fn get_runtime_env_definitions(&self) -> Vec { + vec![ + RuntimeEnvVar { + name: "DATABASE_URL".to_string(), + description: "Full MariaDB-compatible connection URL".to_string(), + example: "mysql://app:pass@mariadb-service:3306/project_production".to_string(), + sensitive: true, + }, + RuntimeEnvVar { + name: "MYSQL_DATABASE".to_string(), + description: "Database name specific to this project/environment".to_string(), + example: "project_production".to_string(), + sensitive: false, + }, + RuntimeEnvVar { + name: "MYSQL_USER".to_string(), + description: "MariaDB application user".to_string(), + example: "app".to_string(), + sensitive: false, + }, + RuntimeEnvVar { + name: "MYSQL_PASSWORD".to_string(), + description: "MariaDB application user password".to_string(), + example: "secure-password".to_string(), + sensitive: true, + }, + ] + } + + async fn get_runtime_env_vars( + &self, + service_config: ServiceConfig, + project_id: &str, + environment: &str, + ) -> Result> { + let resource_name = + Self::normalize_database_name(&format!("{}_{}", project_id, environment)); + self.create_database(service_config.clone(), &resource_name) + .await?; + self.build_runtime_env_vars(service_config, &resource_name) + } + + async fn preview_runtime_env_vars( + &self, + service_config: ServiceConfig, + project_id: &str, + environment: &str, + ) -> Result> { + let resource_name = + Self::normalize_database_name(&format!("{}_{}", project_id, environment)); + self.build_runtime_env_vars(service_config, &resource_name) + } + + fn get_local_address(&self, service_config: ServiceConfig) -> Result { + let config = self.get_mariadb_config(service_config)?; + Ok(format!("localhost:{}", config.port)) + } + + fn get_effective_address(&self, service_config: ServiceConfig) -> Result<(String, String)> { + let config = self.get_mariadb_config(service_config)?; + + if temps_core::DeploymentMode::is_docker() { + Ok(( + self.get_live_container_name(&config), + MARIADB_INTERNAL_PORT.to_string(), + )) + } else { + Ok(("localhost".to_string(), config.port)) + } + } + + fn get_docker_container_name(&self) -> String { + self.get_container_name() + } + + fn get_docker_internal_port(&self) -> String { + MARIADB_INTERNAL_PORT.to_string() + } + + async fn backup_to_s3( + &self, + s3_client: &aws_sdk_s3::Client, + _s3_credentials: &super::S3Credentials, + backup: temps_entities::backups::Model, + s3_source: &temps_entities::s3_sources::Model, + subpath: &str, + _subpath_root: &str, + pool: &temps_database::DbConnection, + external_service: &temps_entities::external_services::Model, + service_config: ServiceConfig, + ) -> Result { + use chrono::Utc; + use sea_orm::*; + + info!("Starting MariaDB backup to S3 via mariadb-dump"); + + let config = self.get_mariadb_config(service_config)?; + let backup_record = temps_entities::external_service_backups::Entity::insert( + temps_entities::external_service_backups::ActiveModel { + service_id: Set(external_service.id), + backup_id: Set(backup.id), + backup_type: Set("full".to_string()), + state: Set("running".to_string()), + started_at: Set(Utc::now()), + s3_location: Set(String::new()), + metadata: Set(serde_json::json!({ + "service_type": "mariadb", + "service_name": self.name, + "backup_tool": "mariadb-dump", + })), + compression_type: Set("gzip".to_string()), + created_by: Set(0), + ..Default::default() + }, + ) + .exec_with_returning(pool) + .await?; + + let temp_dir = tempfile::tempdir()?; + let dump_path = temp_dir + .path() + .join(format!("mariadb_backup_{}.sql.gz", uuid::Uuid::new_v4())); + + let result = async { + self.dump_all_databases_to_gzip_file(&config, &dump_path) + .await?; + + let size_bytes = tokio::fs::metadata(&dump_path).await?.len() as i64; + let timestamp = Utc::now().format("%Y%m%d_%H%M%S"); + let backup_key = format!( + "{}/mariadb_backup_{}.sql.gz", + subpath.trim_matches('/'), + timestamp + ); + + let body = aws_sdk_s3::primitives::ByteStream::from_path(&dump_path).await?; + s3_client + .put_object() + .bucket(&s3_source.bucket_name) + .key(&backup_key) + .body(body) + .content_type("application/x-gzip") + .send() + .await + .map_err(|e| { + anyhow::anyhow!( + "Failed to upload backup to s3://{}/{}: {}", + s3_source.bucket_name, + backup_key, + e + ) + })?; + + Ok::<(String, i64), anyhow::Error>((backup_key, size_bytes)) + } + .await; + + match result { + Ok((backup_key, size_bytes)) => { + let mut update: temps_entities::external_service_backups::ActiveModel = + backup_record.clone().into(); + update.state = Set("completed".to_string()); + update.finished_at = Set(Some(Utc::now())); + update.s3_location = Set(backup_key.clone()); + update.size_bytes = Set(Some(size_bytes)); + update.update(pool).await?; + + info!( + "MariaDB backup completed successfully: {} ({} bytes)", + backup_key, size_bytes + ); + Ok(super::BackupOutcome::new(backup_key, Some(size_bytes))) + } + Err(e) => { + let error_msg = format!("MariaDB backup failed: {}", e); + error!("{}", error_msg); + let mut update: temps_entities::external_service_backups::ActiveModel = + backup_record.into(); + update.state = Set("failed".to_string()); + update.error_message = Set(Some(error_msg.clone())); + update.finished_at = Set(Some(Utc::now())); + if let Err(update_err) = update.update(pool).await { + error!( + "Failed to mark MariaDB backup row as failed: {}", + update_err + ); + } + Err(e) + } + } + } + + async fn restore_from_s3( + &self, + s3_client: &aws_sdk_s3::Client, + _s3_credentials: &super::S3Credentials, + backup_location: &str, + s3_source: &temps_entities::s3_sources::Model, + service_config: ServiceConfig, + ) -> Result<()> { + info!("Starting MariaDB restore from S3: {}", backup_location); + + let config = self.get_mariadb_config(service_config)?; + let bucket = &s3_source.bucket_name; + let backup_key = Self::backup_key_from_location(backup_location, bucket); + + // Detect a physical (`mariadb-backup` mbstream) base vs the legacy + // logical `.sql.gz` dump and dispatch accordingly. We treat a + // `base.mbstream.gz` location as physical; everything else stays on the + // existing logical path. (We don't fetch metadata.json here to keep the + // common logical path a single round-trip; PITR fetches it for its + // guard.) + if Self::is_physical_base_location(&backup_key) { + info!("Detected physical MariaDB base backup; performing in-place physical restore"); + let temp_dir = tempfile::tempdir()?; + let mbstream_path = temp_dir.path().join("base.mbstream"); + self.download_and_gunzip_base(s3_client, bucket, &backup_key, &mbstream_path) + .await?; + self.physical_restore_into_container(&config, &mbstream_path) + .await?; + } else { + self.restore_logical_from_s3(s3_client, bucket, backup_location, &config) + .await?; + } + + info!("MariaDB restore completed successfully"); + Ok(()) + } + + /// MariaDB supports in-place restore, restore-to-new-service, and PITR. + /// PITR requires a physical (`mariadb-backup`) base plus archived binlogs; + /// logical-only backups are rejected at execute time by `restore_pitr`. + /// + /// We don't populate `earliest_pitr_time` / `latest_pitr_time` — deriving + /// them would require reading every base's metadata and the binlog manifest + /// per S3 source. The UI shows an unconstrained datetime picker and the + /// server validates on execute. + async fn restore_capabilities( + &self, + _service_config: ServiceConfig, + ) -> Result { + Ok(super::RestoreCapabilities { + restore_in_place: true, + restore_to_new_service: true, + pitr: true, + earliest_pitr_time: None, + latest_pitr_time: None, + }) + } + + /// Provision a new MariaDB service from an existing backup (physical or + /// logical). Clones the source config onto a fresh port, creates the + /// container, and restores the base into it. + async fn restore_to_new_service( + &self, + ctx: super::RestoreContext<'_>, + new_service_name: String, + parameter_overrides: serde_json::Value, + ) -> Result { + info!( + "Provisioning new MariaDB service '{}' from backup at {}", + new_service_name, ctx.backup_location + ); + + let (new_service, config) = self + .provision_new_service_for_restore( + &ctx.source_config, + &new_service_name, + ¶meter_overrides, + ) + .await?; + + self.restore_base_into( + &new_service, + &config, + ctx.s3_client, + &ctx.s3_source.bucket_name, + ctx.backup_location, + ) + .await?; + + Self::new_service_result(&config) + } + + /// Point-in-time recovery: restore a physical base, then replay archived + /// binlogs up to the recovery target. + async fn restore_pitr( + &self, + ctx: super::RestoreContext<'_>, + target: super::RecoveryTarget, + to_new_service: bool, + new_service_name: Option, + ) -> Result> { + let bucket = &ctx.s3_source.bucket_name; + let base_key = Self::backup_key_from_location(ctx.backup_location, bucket); + + // ── Guard: PITR requires a physical base with binlog coordinates ───── + // Mirrors postgres' WAL-G guard. Logical (`mariadb_dump`) backups carry + // no binlog start position and cannot anchor a replay. + if !Self::is_physical_base_location(&base_key) { + return Err(anyhow::anyhow!( + "PITR requires a physical (mariadb-backup) base backup; '{}' is a \ + logical dump and cannot be used for point-in-time recovery", + ctx.backup_location + )); + } + let metadata = self + .fetch_base_metadata(ctx.s3_client, bucket, &base_key) + .await?; + let engine = metadata + .get("engine") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let pitr_enabled = metadata + .get("pitr") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let binlog_file = metadata + .get("binlog_file") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let binlog_position = metadata + .get("binlog_position") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + if engine != "mariadb_physical" || !pitr_enabled || binlog_file.is_empty() { + return Err(anyhow::anyhow!( + "PITR requires a physical (mariadb-backup) base with binlog coordinates; \ + base metadata has engine='{}', pitr={}, binlog_file='{}' — not usable for \ + point-in-time recovery", + engine, + pitr_enabled, + binlog_file + )); + } + let start_position = if binlog_position.is_empty() { + "4".to_string() // binlog header size; replay the whole first segment + } else { + binlog_position + }; + + info!( + "Running MariaDB PITR to target {:?} (to_new_service={}) from base {} (binlog {}:{})", + target, to_new_service, ctx.backup_location, binlog_file, start_position + ); + + // Validate the recovery target maps to something we can honor BEFORE + // we destroy any data — fail fast on Name/Xid/bad-Lsn targets. + let _ = Self::recovery_target_to_stop_flag(&target)?; + + // ── Restore the base (new service or in place) ────────────────────── + let (target_service, target_config, new_result) = if to_new_service { + let new_name = new_service_name.ok_or_else(|| { + anyhow::anyhow!("new_service_name is required when to_new_service=true") + })?; + info!( + source_service = %ctx.source_config.name, + target_service = %new_name, + "Provisioning MariaDB service for PITR restore" + ); + let (new_service, config) = self + .provision_new_service_for_restore( + &ctx.source_config, + &new_name, + &serde_json::Value::Null, + ) + .await?; + let result = Self::new_service_result(&config)?; + info!( + target_service = %new_name, + target_port = %config.port, + "Provisioned MariaDB service for PITR restore" + ); + (new_service, config, Some(result)) + } else { + let config = self.get_mariadb_config(ctx.source_config.clone())?; + // Restore in place onto self; clone self's docker handle. + let svc = MariaDbService::new(self.name.clone(), self.docker.clone()); + *svc.config.write().await = Some(config.clone()); + (svc, config, None) + }; + + // Physical base restore into the target container. + let temp_dir = tempfile::tempdir()?; + let mbstream_path = temp_dir.path().join("base.mbstream"); + info!( + target_service = %target_service.name, + base_key = %base_key, + "Downloading MariaDB PITR physical base" + ); + target_service + .download_and_gunzip_base(ctx.s3_client, bucket, &base_key, &mbstream_path) + .await?; + info!( + target_service = %target_service.name, + "Restoring MariaDB PITR physical base" + ); + target_service + .physical_restore_into_container(&target_config, &mbstream_path) + .await?; + info!( + target_service = %target_service.name, + "Restored MariaDB PITR physical base" + ); + + // ── Forward-roll: fetch + replay archived binlogs to the target ───── + let prefix = ctx.s3_source.bucket_path.trim_matches('/'); + let binlog_temp = tempfile::tempdir()?; + // Binlogs were archived under the SOURCE service name; the target may + // be a freshly-named new service, so fetch from the source's prefix. + let segments = target_service + .fetch_binlogs_for_replay( + ctx.s3_client, + bucket, + prefix, + &ctx.source_config.name, + &binlog_file, + binlog_temp.path(), + ) + .await?; + info!( + target_service = %target_service.name, + segments = segments.len(), + "Fetched MariaDB PITR binlog segments" + ); + target_service + .replay_binlogs(&target_config, &segments, &start_position, &target) + .await?; + + info!("MariaDB PITR completed successfully"); + Ok(new_result) + } + + async fn import_from_container( + &self, + container_id: String, + service_name: String, + credentials: HashMap, + additional_config: serde_json::Value, + ) -> Result { + let container = self + .docker + .inspect_container( + &container_id, + None::, + ) + .await + .map_err(|e| { + anyhow::anyhow!("Failed to inspect container '{}': {}", container_id, e) + })?; + + let container_config = container.config.as_ref().ok_or_else(|| { + anyhow::anyhow!("Could not inspect config for container '{}'", container_id) + })?; + let image = container_config.image.clone().ok_or_else(|| { + anyhow::anyhow!("Could not determine image for container '{}'", container_id) + })?; + if !crate::mariadb_query::is_mariadb_compatible_image(&image) { + return Err(anyhow::anyhow!( + "Container '{}' image '{}' is not MariaDB/MySQL-compatible", + container_id, + image + )); + } + let imported_container_name = container + .name + .as_deref() + .unwrap_or(&container_id) + .trim_start_matches('/') + .to_string(); + + let env = Self::env_to_map(container_config.env.clone()); + let database_override = Self::json_string(&additional_config, "database"); + let port_override = Self::json_string(&additional_config, "port"); + + let root_password = Self::first_non_empty([ + credentials.get("root_password"), + credentials.get("password").filter(|_| { + credentials + .get("username") + .map(|u| u.eq_ignore_ascii_case("root")) + .unwrap_or(false) + }), + env.get("MARIADB_ROOT_PASSWORD"), + env.get("MYSQL_ROOT_PASSWORD"), + ]) + .ok_or_else(|| { + anyhow::anyhow!( + "root_password is required for MariaDB import unless the container exposes MARIADB_ROOT_PASSWORD or MYSQL_ROOT_PASSWORD" + ) + })?; + + let database = Self::first_non_empty([ + credentials.get("database"), + database_override.as_ref(), + env.get("MARIADB_DATABASE"), + env.get("MYSQL_DATABASE"), + ]) + .unwrap_or_else(|| "mysql".to_string()); + + let username = Self::first_non_empty([ + credentials.get("username"), + env.get("MARIADB_USER"), + env.get("MYSQL_USER"), + ]) + .unwrap_or_else(|| "root".to_string()); + + let password = Self::first_non_empty([ + credentials.get("password"), + env.get("MARIADB_PASSWORD"), + env.get("MYSQL_PASSWORD"), + ]) + .unwrap_or_else(|| { + if username.eq_ignore_ascii_case("root") { + root_password.clone() + } else { + String::new() + } + }); + + if password.is_empty() { + return Err(anyhow::anyhow!( + "password is required for MariaDB import when username is not root" + )); + } + + Self::validate_identifier("database", &database)?; + Self::validate_identifier("username", &username)?; + Self::validate_password("password", &password)?; + Self::validate_password("root_password", &root_password)?; + + let port = port_override + .or_else(|| Self::extract_host_port(&container)) + .unwrap_or_else(|| MARIADB_INTERNAL_PORT.to_string()); + + Self::verify_import_connection(&username, &password, &port, &database).await?; + info!("Successfully verified MariaDB-compatible connection for import"); + + let network_ready = { + match ensure_network_exists(&self.docker).await { + Ok(()) => true, + Err(e) => { + warn!( + "Failed to ensure Temps Docker network before MariaDB import attach: {:?}", + e + ); + false + } + } + }; + + if network_ready { + let network_name = temps_core::NETWORK_NAME.as_str(); + let request = bollard::models::NetworkConnectRequest { + container: container_id.clone(), + ..Default::default() + }; + match self.docker.connect_network(network_name, request).await { + Ok(()) => info!( + "Attached imported MariaDB-compatible container '{}' to {}", + imported_container_name, network_name + ), + Err(bollard::errors::Error::DockerResponseServerError { + status_code: 403, .. + }) => debug!( + "Imported MariaDB-compatible container '{}' is already attached to {}", + imported_container_name, network_name + ), + Err(e) => warn!( + "Failed to attach imported MariaDB-compatible container '{}' to {}: {}", + imported_container_name, network_name, e + ), + } + } + + let version = image + .rfind(':') + .map(|tag_pos| image[tag_pos + 1..].to_string()) + .unwrap_or_else(|| "latest".to_string()); + + Ok(ServiceConfig { + name: service_name, + service_type: ServiceType::Mariadb, + version: Some(version), + parameters: serde_json::json!({ + "host": "localhost", + "port": port, + "database": database, + "username": username, + "password": password, + "root_password": root_password, + "docker_image": image, + "size_profile": "dedicated", + "container_name": imported_container_name, + }), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::externalsvc::DEPLOYMENT_MODE_MUTEX as ENV_MUTEX; + + #[test] + fn normalizes_database_names() { + assert_eq!( + MariaDbService::normalize_database_name("Project-123 Production"), + "project_123_production" + ); + assert_eq!( + MariaDbService::normalize_database_name("123-prod"), + "db_123_prod" + ); + } + + #[test] + fn rejects_unsafe_identifiers() { + assert!(MariaDbService::validate_identifier("database", "valid_name").is_ok()); + assert!(MariaDbService::validate_identifier("database", "bad-name").is_err()); + assert!(MariaDbService::validate_identifier("database", "1bad").is_err()); + assert!(MariaDbService::validate_identifier("database", "bad`name").is_err()); + } + + #[test] + fn builds_mysql_and_mariadb_env_aliases() { + let env = MariaDbService::build_env_vars( + "mariadb-app", + "3306", + "project_prod", + "app", + "secretpass", + ) + .expect("env vars should build"); + + assert_eq!(env.get("MYSQL_DATABASE"), Some(&"project_prod".to_string())); + assert_eq!( + env.get("MARIADB_DATABASE"), + Some(&"project_prod".to_string()) + ); + assert_eq!( + env.get("DATABASE_URL"), + Some(&"mysql://app:secretpass@mariadb-app:3306/project_prod".to_string()) + ); + } + + #[test] + fn small_profile_sets_conservative_resources_and_server_args() { + let resources = MariaDbSizeProfile::Small.default_resource_limits(); + assert_eq!(resources.memory_mb, Some(512)); + assert_eq!(resources.memory_swap_mb, Some(768)); + assert_eq!(resources.nano_cpus, Some(750_000_000)); + + let args = MariaDbSizeProfile::Small.server_args(); + assert!(args.contains(&"--innodb-buffer-pool-size=128M".to_string())); + assert!(args.contains(&"--max-connections=50".to_string())); + assert!(args.contains(&"--performance-schema=OFF".to_string())); + } + + #[test] + fn parses_size_profile_from_config() { + let config = MariaDbConfig::from(MariaDbInputConfig { + host: "localhost".to_string(), + port: Some("3306".to_string()), + database: "app".to_string(), + username: "app".to_string(), + password: Some("secretpass".to_string()), + root_password: Some("rootpass1".to_string()), + docker_image: DEFAULT_MARIADB_IMAGE.to_string(), + size_profile: MariaDbSizeProfile::Standard, + binlog_archive_interval: BinlogArchiveInterval::Min15, + container_name: None, + }); + + assert_eq!(config.size_profile, MariaDbSizeProfile::Standard); + assert_eq!(config.binlog_archive_interval, BinlogArchiveInterval::Min15); + } + + #[test] + fn binlog_archive_interval_defaults_to_5m() { + assert_eq!( + BinlogArchiveInterval::default(), + BinlogArchiveInterval::Min5 + ); + assert_eq!(BinlogArchiveInterval::default().as_str(), "5m"); + } + + #[test] + fn binlog_interval_seconds_and_expire() { + // Retention must always exceed the ship interval (>= 6x, floor 1h) so + // a segment is never purged before it is archived. + for iv in [ + BinlogArchiveInterval::Min1, + BinlogArchiveInterval::Min5, + BinlogArchiveInterval::Min15, + BinlogArchiveInterval::Min60, + ] { + assert!( + iv.binlog_expire_seconds() >= iv.seconds() * 6, + "{}: expire must be >= 6x interval", + iv.as_str() + ); + assert!( + iv.binlog_expire_seconds() >= 3600, + "{}: expire floor is 1h", + iv.as_str() + ); + } + assert_eq!(BinlogArchiveInterval::Min60.seconds(), 3600); + assert_eq!(BinlogArchiveInterval::Min60.binlog_expire_seconds(), 21600); + } + + #[test] + fn binlog_interval_serde_round_trips_wire_format() { + let cfg: MariaDbInputConfig = + serde_json::from_value(serde_json::json!({ "binlog_archive_interval": "1m" })) + .expect("parse"); + assert_eq!(cfg.binlog_archive_interval, BinlogArchiveInterval::Min1); + } + + #[test] + fn binlog_server_args_has_expected_flags_and_no_credentials() { + let args = binlog_server_args(42, BinlogArchiveInterval::Min5); + assert!(args.contains(&"--log-bin=mysql-bin".to_string())); + assert!(args.contains(&"--binlog-format=ROW".to_string())); + assert!(args.contains(&"--server-id=42".to_string())); + assert!(args.contains(&"--sync-binlog=1".to_string())); + assert!(args.contains(&"--binlog-expire-logs-seconds=3600".to_string())); + // Server tuning flags only — never a password. + assert!(!args + .iter() + .any(|a| a.contains("password") || a.contains("PWD"))); + } + + #[test] + fn stable_server_id_is_deterministic_and_nonzero() { + let a = stable_server_id("orders-db"); + let b = stable_server_id("orders-db"); + let c = stable_server_id("analytics-db"); + assert_eq!(a, b, "must be stable across calls"); + assert_ne!(a, 0, "server-id must be non-zero for --log-bin"); + assert_ne!(a, c, "distinct names should generally differ"); + } + + #[test] + fn parameter_schema_exposes_editable_binlog_interval() { + let service = MariaDbService::new( + "schema-test".to_string(), + Arc::new(Docker::connect_with_http_defaults().expect("docker client")), + ); + let schema = service + .get_parameter_schema() + .expect("schema should be available"); + let prop = schema + .get("properties") + .and_then(|p| p.get("binlog_archive_interval")) + .expect("binlog_archive_interval should be present"); + assert_eq!(prop.get("default").and_then(|v| v.as_str()), Some("5m")); + assert_eq!(prop.get("x-editable").and_then(|v| v.as_bool()), Some(true)); + } + + #[test] + fn parse_show_binary_logs_extracts_filenames_in_order() { + // Typical `mariadb -N -B` output: tab-separated, no header. + let raw = "mysql-bin.000001\t1234\nmysql-bin.000002\t5678\nmysql-bin.000003\t90\n"; + assert_eq!( + MariaDbService::parse_show_binary_logs(raw), + vec![ + "mysql-bin.000001".to_string(), + "mysql-bin.000002".to_string(), + "mysql-bin.000003".to_string(), + ] + ); + } + + #[test] + fn parse_show_binary_logs_ignores_header_and_blank_lines() { + // Some clients (non -N) emit a header row and trailing blank lines. + let raw = "Log_name\tFile_size\nmysql-bin.000007\t100\n\n"; + assert_eq!( + MariaDbService::parse_show_binary_logs(raw), + vec!["mysql-bin.000007".to_string()] + ); + } + + #[test] + fn closed_binlog_files_excludes_active_last_segment() { + let all = vec![ + "mysql-bin.000001".to_string(), + "mysql-bin.000002".to_string(), + "mysql-bin.000003".to_string(), + ]; + // The last segment is active and must not be shippable. + assert_eq!( + MariaDbService::closed_binlog_files(&all), + vec![ + "mysql-bin.000001".to_string(), + "mysql-bin.000002".to_string() + ] + ); + + // A single segment is the active one — nothing closed yet. + assert!(MariaDbService::closed_binlog_files(&["mysql-bin.000001".to_string()]).is_empty()); + assert!(MariaDbService::closed_binlog_files(&[]).is_empty()); + } + + #[test] + fn binlogs_to_ship_excludes_active_and_already_shipped() { + let all = vec![ + "mysql-bin.000001".to_string(), + "mysql-bin.000002".to_string(), + "mysql-bin.000003".to_string(), + "mysql-bin.000004".to_string(), // active — never shipped + ]; + + // Nothing shipped yet: ship all closed segments (1..=3), not the active 4. + assert_eq!( + MariaDbService::binlogs_to_ship(&all, None), + vec![ + "mysql-bin.000001".to_string(), + "mysql-bin.000002".to_string(), + "mysql-bin.000003".to_string(), + ] + ); + + // last_shipped=000002: only 000003 is new (000004 is active). + assert_eq!( + MariaDbService::binlogs_to_ship(&all, Some("mysql-bin.000002")), + vec!["mysql-bin.000003".to_string()] + ); + + // last_shipped=000003: everything closed is already shipped. + assert!(MariaDbService::binlogs_to_ship(&all, Some("mysql-bin.000003")).is_empty()); + } + + #[test] + fn binlogs_to_ship_lexicographic_ordering_holds_across_rollover() { + // mysql-bin.NNNNNN names sort lexicographically the same as numerically + // within the fixed-width range. + let all = vec![ + "mysql-bin.000009".to_string(), + "mysql-bin.000010".to_string(), + "mysql-bin.000011".to_string(), // active + ]; + assert_eq!( + MariaDbService::binlogs_to_ship(&all, Some("mysql-bin.000009")), + vec!["mysql-bin.000010".to_string()] + ); + } + + #[test] + fn binlog_object_key_handles_empty_and_nonempty_prefix() { + // Non-empty bucket_path prefix. + assert_eq!( + MariaDbService::binlog_object_key("backups/prod", "orders-db", "mysql-bin.000007"), + "backups/prod/external_services/mariadb/orders-db/binlog/mysql-bin.000007.gz" + ); + // Empty prefix drops the leading segment. + assert_eq!( + MariaDbService::binlog_object_key("", "orders-db", "mysql-bin.000007"), + "external_services/mariadb/orders-db/binlog/mysql-bin.000007.gz" + ); + } + + #[test] + fn binlog_manifest_key_handles_empty_and_nonempty_prefix() { + assert_eq!( + MariaDbService::binlog_manifest_key("backups/prod", "orders-db"), + "backups/prod/external_services/mariadb/orders-db/binlog/manifest.json" + ); + assert_eq!( + MariaDbService::binlog_manifest_key("", "orders-db"), + "external_services/mariadb/orders-db/binlog/manifest.json" + ); + } + + #[test] + fn binlog_manifest_round_trips_json_shape() { + let manifest = BinlogManifest { + last_shipped_file: Some("mysql-bin.000007".to_string()), + updated_at: "2026-06-23T00:00:00+00:00".to_string(), + shipped_files: vec![ + "mysql-bin.000003".to_string(), + "mysql-bin.000004".to_string(), + ], + }; + let json = serde_json::to_value(&manifest).expect("serialize"); + assert_eq!(json["last_shipped_file"], "mysql-bin.000007"); + assert_eq!(json["shipped_files"][0], "mysql-bin.000003"); + let parsed: BinlogManifest = serde_json::from_value(json).expect("deserialize"); + assert_eq!(parsed, manifest); + } + + #[test] + fn binlog_manifest_default_is_empty() { + let m = BinlogManifest::default(); + assert!(m.last_shipped_file.is_none()); + assert!(m.shipped_files.is_empty()); + } + + // ── Restore / PITR unit tests (no Docker) ────────────────────────────── + + fn mariadb_service_for_tests() -> MariaDbService { + MariaDbService::new( + "pitr-test".to_string(), + Arc::new(Docker::connect_with_http_defaults().expect("docker client")), + ) + } + + #[tokio::test] + async fn restore_capabilities_reports_pitr_and_both_modes() { + let service = mariadb_service_for_tests(); + let cfg = ServiceConfig { + name: "pitr-test".to_string(), + service_type: ServiceType::Mariadb, + version: None, + parameters: serde_json::json!({ + "host": "localhost", + "port": "3306", + "database": "app", + "username": "app", + "password": "secretpass", + "root_password": "rootpass1", + "docker_image": DEFAULT_MARIADB_IMAGE, + }), + }; + let caps = service + .restore_capabilities(cfg) + .await + .expect("capabilities"); + assert!(caps.pitr, "MariaDB should advertise PITR support"); + assert!(caps.restore_in_place); + assert!(caps.restore_to_new_service); + } + + #[test] + fn detects_physical_vs_logical_backup_from_location() { + assert!(MariaDbService::is_physical_base_location( + "backups/prod/external_services/mariadb/orders/2026/06/23/abc/base.mbstream.gz" + )); + assert!(!MariaDbService::is_physical_base_location( + "backups/prod/mariadb_backup_20260623_010101.sql.gz" + )); + assert!(!MariaDbService::is_physical_base_location("dump.sql.gz")); + } + + #[test] + fn derives_metadata_key_from_base_key() { + assert_eq!( + MariaDbService::derive_metadata_key( + "backups/prod/external_services/mariadb/orders/2026/06/23/abc/base.mbstream.gz" + ), + "backups/prod/external_services/mariadb/orders/2026/06/23/abc/metadata.json" + ); + // No slash → companion-suffix fallback. + assert_eq!( + MariaDbService::derive_metadata_key("base.mbstream.gz"), + "base.mbstream.gz.metadata.json" + ); + } + + #[test] + fn recovery_target_time_maps_to_stop_datetime() { + use chrono::TimeZone; + let time = chrono::Utc + .with_ymd_and_hms(2026, 6, 23, 14, 30, 15) + .single() + .expect("valid time"); + let flag = MariaDbService::recovery_target_to_stop_flag(&RecoveryTarget::Time { time }) + .expect("time target maps") + .expect("has stop flag"); + assert_eq!(flag.0, "--stop-datetime"); + assert_eq!(flag.1, "2026-06-23 14:30:15"); + assert_eq!( + MariaDbService::format_stop_datetime(time), + "2026-06-23 14:30:15" + ); + } + + #[test] + fn recovery_target_lsn_requires_file_and_position() { + // file:position → --stop-position + let flag = MariaDbService::recovery_target_to_stop_flag(&RecoveryTarget::Lsn { + lsn: "mysql-bin.000007:1234".to_string(), + }) + .expect("valid lsn") + .expect("has flag"); + assert_eq!(flag.0, "--stop-position"); + assert_eq!(flag.1, "1234"); + + // bare position → rejected (ambiguous across segments) + assert!( + MariaDbService::recovery_target_to_stop_flag(&RecoveryTarget::Lsn { + lsn: "1234".to_string(), + }) + .is_err() + ); + } + + #[test] + fn recovery_target_xid_and_name_are_rejected() { + assert!( + MariaDbService::recovery_target_to_stop_flag(&RecoveryTarget::Xid { + xid: "0-1-100".to_string(), + }) + .is_err() + ); + assert!( + MariaDbService::recovery_target_to_stop_flag(&RecoveryTarget::Name { + name: "my-restore-point".to_string(), + }) + .is_err() + ); + } + + #[test] + fn pitr_guard_rejects_logical_only_backup() { + // A logical dump location is rejected by the location-based guard + // before any network call: it is not a physical base. + let location = "s3://my-bucket/backups/mariadb_backup_20260623.sql.gz"; + let key = MariaDbService::backup_key_from_location(location, "my-bucket"); + assert!(!MariaDbService::is_physical_base_location(&key)); + + // Exercise the guard message wording directly: it must mention PITR and + // physical so operators (and greps) can find it. + let guard_msg = format!( + "PITR requires a physical (mariadb-backup) base backup; '{}' is a logical dump", + location + ); + assert!(guard_msg.contains("PITR")); + assert!(guard_msg.contains("physical")); + + // And confirm the engine-mismatch guard (used when metadata says + // mariadb_dump) produces a PITR+physical message too. + let metadata = serde_json::json!({ + "engine": "mariadb_dump", + "pitr": false, + }); + let engine = metadata + .get("engine") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let pitr = metadata + .get("pitr") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + assert_eq!(engine, "mariadb_dump"); + assert!(!pitr); + let mismatch_msg = format!( + "PITR requires a physical (mariadb-backup) base with binlog coordinates; \ + base metadata has engine='{}', pitr={}", + engine, pitr + ); + assert!(mismatch_msg.contains("PITR")); + assert!(mismatch_msg.contains("physical")); + } + + #[test] + fn shell_single_quote_escapes_embedded_quotes() { + assert_eq!(MariaDbService::shell_single_quote("plain"), "'plain'"); + assert_eq!(MariaDbService::shell_single_quote("a'b"), "'a'\\''b'"); + // A datetime value (contains a space) stays a single shell token. + assert_eq!( + MariaDbService::shell_single_quote("2026-06-23 14:30:15"), + "'2026-06-23 14:30:15'" + ); + } + + #[test] + fn parameter_schema_exposes_create_time_size_profile() { + let service = MariaDbService::new( + "schema-test".to_string(), + Arc::new(Docker::connect_with_http_defaults().expect("docker client")), + ); + let schema = service + .get_parameter_schema() + .expect("schema should be available"); + let size_profile = schema + .get("properties") + .and_then(|p| p.get("size_profile")) + .expect("size_profile should be present"); + + assert_eq!( + size_profile.get("default").and_then(|v| v.as_str()), + Some("small") + ); + assert_eq!( + size_profile.get("x-editable").and_then(|v| v.as_bool()), + Some(false) + ); + } + + // ── Identifier / database-name validation (parity with Postgres) ──────── + // + // MariaDB's `validate_identifier` is the gate for database/username before + // any SQL is emitted. Unlike Postgres it accepts uppercase (MariaDB + // identifiers are case-sensitive on some platforms and validate is a + // safety gate, not a normalizer); `normalize_database_name` lowercases. + + #[test] + fn test_validate_database_name_valid_names() { + assert!(MariaDbService::validate_identifier("database", "mydb").is_ok()); + assert!(MariaDbService::validate_identifier("database", "project_1_production").is_ok()); + assert!(MariaDbService::validate_identifier("database", "db_test_env").is_ok()); + assert!(MariaDbService::validate_identifier("database", "a").is_ok()); + assert!(MariaDbService::validate_identifier("database", "_private").is_ok()); + } + + #[test] + fn test_validate_database_name_rejects_empty() { + assert!(MariaDbService::validate_identifier("database", "").is_err()); + } + + #[test] + fn test_validate_database_name_rejects_sql_injection_single_quote() { + // Classic SQL injection: ' OR 1=1 -- + assert!( + MariaDbService::validate_identifier("database", "test'; DROP TABLE users--").is_err() + ); + } + + #[test] + fn test_validate_database_name_rejects_sql_injection_semicolon() { + assert!( + MariaDbService::validate_identifier("database", "mydb; DROP DATABASE production") + .is_err() + ); + } + + #[test] + fn test_validate_database_name_rejects_spaces() { + assert!(MariaDbService::validate_identifier("database", "my database").is_err()); + } + + #[test] + fn test_validate_database_name_rejects_special_chars() { + assert!(MariaDbService::validate_identifier("database", "db-name").is_err()); + assert!(MariaDbService::validate_identifier("database", "db.name").is_err()); + assert!(MariaDbService::validate_identifier("database", "db/name").is_err()); + assert!(MariaDbService::validate_identifier("database", "db\\name").is_err()); + assert!(MariaDbService::validate_identifier("database", "db`name").is_err()); + } + + #[test] + fn test_validate_database_name_accepts_uppercase() { + // MariaDB's validator accepts uppercase letters (it is a safety gate, + // not a normalizer). This diverges from Postgres, which rejects + // uppercase; we assert MariaDB's actual behavior. + assert!(MariaDbService::validate_identifier("database", "MyDatabase").is_ok()); + } + + #[test] + fn test_validate_database_name_rejects_leading_digit() { + assert!(MariaDbService::validate_identifier("database", "1database").is_err()); + assert!(MariaDbService::validate_identifier("database", "123").is_err()); + } + + #[test] + fn test_validate_database_name_rejects_too_long() { + let long_name = "a".repeat(64); + assert!(MariaDbService::validate_identifier("database", &long_name).is_err()); + } + + #[test] + fn test_validate_database_name_accepts_max_length() { + let max_name = "a".repeat(63); + assert!(MariaDbService::validate_identifier("database", &max_name).is_ok()); + } + + #[test] + fn test_normalize_then_validate_is_always_safe() { + // Any input passed through normalize_database_name must pass validation. + let dangerous_inputs = vec![ + "'; DROP TABLE users--", + "test; DELETE FROM sessions", + "../../etc/passwd", + "admin\x00hidden", + "Robert'); DROP TABLE Students;--", + "name WITH spaces AND STUFF", + "UPPERCASE_NAME", + "123_starts_with_number", + "db`name", + ]; + + for input in dangerous_inputs { + let normalized = MariaDbService::normalize_database_name(input); + assert!( + MariaDbService::validate_identifier("database", &normalized).is_ok(), + "normalize_database_name('{}') produced '{}' which failed validation", + input, + normalized + ); + } + } + + // ── Config / schema parity ────────────────────────────────────────────── + + #[test] + fn test_mariadb_input_config_default_values() { + let input = MariaDbInputConfig { + host: default_host(), + port: None, + database: default_database(), + username: default_username(), + password: None, + root_password: None, + docker_image: default_docker_image(), + size_profile: MariaDbSizeProfile::default(), + binlog_archive_interval: BinlogArchiveInterval::default(), + container_name: None, + }; + + let config: MariaDbConfig = input.into(); + + assert_eq!(config.host, "localhost"); + assert_eq!(config.database, "app"); + assert_eq!(config.username, "app"); + assert_eq!(config.docker_image, DEFAULT_MARIADB_IMAGE); + assert_eq!(config.size_profile, MariaDbSizeProfile::Small); + assert_eq!(config.binlog_archive_interval, BinlogArchiveInterval::Min5); + // Auto-generated credentials: 24 alphanumeric chars, distinct. + assert_eq!(config.password.len(), 24); + assert_eq!(config.root_password.len(), 24); + assert!(config.password.chars().all(|c| c.is_ascii_alphanumeric())); + assert!(config + .root_password + .chars() + .all(|c| c.is_ascii_alphanumeric())); + assert_ne!( + config.password, config.root_password, + "app and root passwords should be independently generated" + ); + // Generated passwords satisfy the password validator. + assert!(MariaDbService::validate_password("password", &config.password).is_ok()); + assert!(MariaDbService::validate_password("root_password", &config.root_password).is_ok()); + } + + #[test] + fn test_mariadb_input_config_custom_docker_image() { + let input = MariaDbInputConfig { + host: "localhost".to_string(), + port: Some("3306".to_string()), + database: "mydb".to_string(), + username: "myuser".to_string(), + password: Some("mypassword".to_string()), + root_password: Some("myrootpassword".to_string()), + docker_image: "mariadb:11.4".to_string(), + size_profile: MariaDbSizeProfile::default(), + binlog_archive_interval: BinlogArchiveInterval::default(), + container_name: None, + }; + + let config: MariaDbConfig = input.into(); + assert_eq!(config.docker_image, "mariadb:11.4"); + assert_eq!(config.port, "3306"); + // A supplied >= 8 char password is kept (not regenerated). + assert_eq!(config.password, "mypassword"); + assert_eq!(config.root_password, "myrootpassword"); + } + + #[test] + fn test_short_password_is_regenerated() { + // The optional-password deserializer drops too-short values, so a + // sub-8-char password is replaced with an auto-generated one. + let input: MariaDbInputConfig = serde_json::from_value(serde_json::json!({ + "host": "localhost", + "database": "app", + "username": "app", + "password": "short", + "docker_image": DEFAULT_MARIADB_IMAGE, + })) + .expect("parse input config"); + assert!( + input.password.is_none(), + "too-short password must be dropped to None by the deserializer" + ); + let config: MariaDbConfig = input.into(); + assert_eq!(config.password.len(), 24, "dropped password is regenerated"); + } + + #[test] + fn test_parameter_schema_editable_fields() { + let service = MariaDbService::new( + "test-editable".to_string(), + Arc::new(Docker::connect_with_http_defaults().expect("docker client")), + ); + + let schema = service + .get_parameter_schema() + .expect("schema should be generated"); + let properties = schema + .get("properties") + .and_then(|v| v.as_object()) + .expect("properties should be an object"); + + // Connection identity is fixed after creation; only port and image + // (and binlog cadence, covered by its own test) are editable. + let editable_status = vec![ + ("host", false), + ("port", true), + ("database", false), + ("username", false), + ("password", false), + ("root_password", false), + ("docker_image", true), + ]; + + for (field_name, should_be_editable) in editable_status { + let field = properties + .get(field_name) + .and_then(|v| v.as_object()) + .unwrap_or_else(|| panic!("{} field should exist", field_name)); + let is_editable = field + .get("x-editable") + .and_then(|v| v.as_bool()) + .unwrap_or_else(|| panic!("{} should have x-editable property", field_name)); + assert_eq!( + is_editable, should_be_editable, + "Field {} editable status should be {}", + field_name, should_be_editable + ); + } + } + + #[test] + fn test_default_docker_image_constant() { + // The default image tag the service provisions with. + assert_eq!(default_docker_image(), "mariadb:lts"); + assert_eq!(DEFAULT_MARIADB_IMAGE, "mariadb:lts"); + } + + // ── Address / env-var routing (parity with Postgres) ──────────────────── + + #[test] + fn test_get_effective_address_baremetal_mode() { + let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + // Clear Docker mode to ensure baremetal mode. + unsafe { std::env::remove_var("DEPLOYMENT_MODE") }; + + let service = MariaDbService::new( + "test-effective-addr".to_string(), + Arc::new(Docker::connect_with_http_defaults().expect("docker client")), + ); + let config = ServiceConfig { + name: "test-mariadb".to_string(), + service_type: ServiceType::Mariadb, + version: None, + parameters: serde_json::json!({ + "host": "localhost", + "port": "3307", + "database": "app", + "username": "app", + "password": "secretpass", + "root_password": "rootpass1", + "docker_image": DEFAULT_MARIADB_IMAGE, + }), + }; + + let (host, port) = service.get_effective_address(config).unwrap(); + // Baremetal: localhost with the exposed host port. + assert_eq!(host, "localhost"); + assert_eq!(port, "3307"); + } + + #[test] + fn test_get_effective_address_docker_mode() { + let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + unsafe { std::env::set_var("DEPLOYMENT_MODE", "docker") }; + + let service = MariaDbService::new( + "test-effective-addr-docker".to_string(), + Arc::new(Docker::connect_with_http_defaults().expect("docker client")), + ); + let config = ServiceConfig { + name: "test-mariadb".to_string(), + service_type: ServiceType::Mariadb, + version: None, + parameters: serde_json::json!({ + "host": "localhost", + "port": "3307", + "database": "app", + "username": "app", + "password": "secretpass", + "root_password": "rootpass1", + "docker_image": DEFAULT_MARIADB_IMAGE, + }), + }; + + let (host, port) = service.get_effective_address(config).unwrap(); + // Docker: container name with the internal port, not the host port. + assert_eq!(host, "mariadb-test-effective-addr-docker"); + assert_eq!(port, MARIADB_INTERNAL_PORT); + + unsafe { std::env::remove_var("DEPLOYMENT_MODE") }; + } + + #[test] + fn test_get_effective_address_docker_mode_uses_imported_container_name() { + let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + unsafe { std::env::set_var("DEPLOYMENT_MODE", "docker") }; + + let service = MariaDbService::new( + "imported-svc".to_string(), + Arc::new(Docker::connect_with_http_defaults().expect("docker client")), + ); + let config = ServiceConfig { + name: "imported-svc".to_string(), + service_type: ServiceType::Mariadb, + version: None, + parameters: serde_json::json!({ + "host": "localhost", + "port": "3307", + "database": "app", + "username": "app", + "password": "secretpass", + "root_password": "rootpass1", + "docker_image": DEFAULT_MARIADB_IMAGE, + "container_name": "legacy-mariadb", + }), + }; + + let (host, port) = service.get_effective_address(config).unwrap(); + // The imported container name wins over the derived mariadb-{name}. + assert_eq!(host, "legacy-mariadb"); + assert_eq!(port, MARIADB_INTERNAL_PORT); + + unsafe { std::env::remove_var("DEPLOYMENT_MODE") }; + } + + #[test] + fn test_get_environment_variables_always_uses_container_name() { + // get_environment_variables always targets the container name and the + // internal port (3306) for container-to-container traffic, regardless + // of the exposed host port. + let service = MariaDbService::new( + "test-env-vars".to_string(), + Arc::new(Docker::connect_with_http_defaults().expect("docker client")), + ); + + let mut params = HashMap::new(); + params.insert("port".to_string(), "3399".to_string()); // host port, ignored + params.insert("database".to_string(), "project_prod".to_string()); + params.insert("username".to_string(), "app".to_string()); + params.insert("password".to_string(), "secretpass".to_string()); + + let env = service.get_environment_variables(¶ms).unwrap(); + + assert_eq!(env.get("MYSQL_HOST").unwrap(), "mariadb-test-env-vars"); + assert_eq!(env.get("MARIADB_HOST").unwrap(), "mariadb-test-env-vars"); + assert_eq!(env.get("MYSQL_PORT").unwrap(), MARIADB_INTERNAL_PORT); + assert_eq!(env.get("MARIADB_PORT").unwrap(), MARIADB_INTERNAL_PORT); + assert_eq!(env.get("MYSQL_DATABASE").unwrap(), "project_prod"); + assert_eq!( + env.get("DATABASE_URL").unwrap(), + "mysql://app:secretpass@mariadb-test-env-vars:3306/project_prod" + ); + // Internal port is used; the host port is never embedded. + assert!(!env.get("DATABASE_URL").unwrap().contains("3399")); + } + + #[test] + fn test_get_environment_variables_prefers_explicit_container_name() { + // An imported service surfaces its real container_name as the host. + let service = MariaDbService::new( + "svc".to_string(), + Arc::new(Docker::connect_with_http_defaults().expect("docker client")), + ); + + let mut params = HashMap::new(); + params.insert("container_name".to_string(), "legacy-mariadb".to_string()); + params.insert("host".to_string(), "localhost".to_string()); + params.insert("database".to_string(), "app".to_string()); + params.insert("username".to_string(), "app".to_string()); + params.insert("password".to_string(), "secretpass".to_string()); + + let env = service.get_environment_variables(¶ms).unwrap(); + assert_eq!(env.get("MARIADB_HOST").unwrap(), "legacy-mariadb"); + assert!(env + .get("MARIADB_URL") + .unwrap() + .contains("legacy-mariadb:3306")); + } + + #[test] + fn test_get_docker_environment_variables_match_environment_variables() { + let service = MariaDbService::new( + "test-docker-env".to_string(), + Arc::new(Docker::connect_with_http_defaults().expect("docker client")), + ); + + let mut params = HashMap::new(); + params.insert("port".to_string(), "3399".to_string()); + params.insert("database".to_string(), "app".to_string()); + params.insert("username".to_string(), "app".to_string()); + params.insert("password".to_string(), "secretpass".to_string()); + + let env = service.get_docker_environment_variables(¶ms).unwrap(); + assert_eq!(env.get("MYSQL_HOST").unwrap(), "mariadb-test-docker-env"); + assert_eq!(env.get("MYSQL_PORT").unwrap(), MARIADB_INTERNAL_PORT); + } + + #[test] + fn test_get_environment_variables_missing_params_error() { + let service = MariaDbService::new( + "missing".to_string(), + Arc::new(Docker::connect_with_http_defaults().expect("docker client")), + ); + // No database/username/password → error. + let params = HashMap::new(); + assert!(service.get_environment_variables(¶ms).is_err()); + } + + // ── Import (parity with Postgres) ─────────────────────────────────────── + + #[test] + fn test_import_connection_url_format() { + // Mirrors verify_import_connection's URL construction (mysql:// scheme). + let username = "app"; + let password = "mysecretpassword"; + let port = "3306"; + let database = "importeddb"; + let connection_url = format!( + "mysql://{}:{}@localhost:{}/{}", + urlencoding::encode(username), + urlencoding::encode(password), + port, + urlencoding::encode(database) + ); + assert!(connection_url.contains("mysql://")); + assert!(connection_url.contains("app")); + assert!(connection_url.contains("mysecretpassword")); + assert!(connection_url.contains("localhost")); + assert!(connection_url.contains("3306")); + assert!(connection_url.contains("importeddb")); + } + + #[test] + fn test_import_version_extraction_with_tag() { + // Import derives `version` from the image tag (image.rfind(':')). + let test_cases = vec![ + ("mariadb:lts", "lts"), + ("mariadb:11.4", "11.4"), + ("mysql:8.0", "8.0"), + ("docker.io/library/mariadb:10.11", "10.11"), + ]; + for (image, expected) in test_cases { + let version = image + .rfind(':') + .map(|p| image[p + 1..].to_string()) + .unwrap_or_else(|| "latest".to_string()); + assert_eq!(version, expected, "failed for image {}", image); + } + } + + #[test] + fn test_import_version_extraction_without_tag() { + let image = "mariadb"; + let version = image + .rfind(':') + .map(|p| image[p + 1..].to_string()) + .unwrap_or_else(|| "latest".to_string()); + assert_eq!(version, "latest"); + } + + #[test] + fn test_import_root_password_resolution_from_credentials_and_env() { + // root_password resolution prefers explicit credentials, then a + // username==root password, then MARIADB/MYSQL env. Exercises the + // first_non_empty precedence the import path relies on. + let root_pw = "explicit-root-pw".to_string(); + let env_pw = "env-root-pw".to_string(); + let resolved = MariaDbService::first_non_empty([Some(&root_pw), Some(&env_pw)]); + assert_eq!(resolved.as_deref(), Some("explicit-root-pw")); + + // Blank/whitespace entries are skipped. + let blank = " ".to_string(); + let resolved = MariaDbService::first_non_empty([Some(&blank), Some(&env_pw)]); + assert_eq!(resolved.as_deref(), Some("env-root-pw")); + + // Nothing usable → None (the import path then errors). + let resolved = MariaDbService::first_non_empty([None, Some(&blank)]); + assert!(resolved.is_none()); + } + + #[test] + fn test_import_env_to_map_parses_container_env() { + // import reads root/db/user/password from the container's env list. + let env = MariaDbService::env_to_map(Some(vec![ + "MARIADB_ROOT_PASSWORD=rootsecret".to_string(), + "MARIADB_DATABASE=appdb".to_string(), + "MARIADB_USER=appuser".to_string(), + "PATH=/usr/bin".to_string(), + "MALFORMED_NO_EQUALS".to_string(), + ])); + assert_eq!( + env.get("MARIADB_ROOT_PASSWORD").map(String::as_str), + Some("rootsecret") + ); + assert_eq!( + env.get("MARIADB_DATABASE").map(String::as_str), + Some("appdb") + ); + assert_eq!(env.get("MARIADB_USER").map(String::as_str), Some("appuser")); + // A line without '=' is dropped, not panicked on. + assert!(!env.contains_key("MALFORMED_NO_EQUALS")); + } + + #[test] + fn test_import_json_string_extracts_override() { + // database/port overrides come from additional_config via json_string. + let cfg = serde_json::json!({ + "database": "overridedb", + "port": "3399", + "blank": " ", + }); + assert_eq!( + MariaDbService::json_string(&cfg, "database").as_deref(), + Some("overridedb") + ); + assert_eq!( + MariaDbService::json_string(&cfg, "port").as_deref(), + Some("3399") + ); + // Blank-only values are treated as absent. + assert!(MariaDbService::json_string(&cfg, "blank").is_none()); + assert!(MariaDbService::json_string(&cfg, "missing").is_none()); + } + + // ── Upgrade decision ──────────────────────────────────────────────────── + // + // MariaDB does NOT override the ExternalService::upgrade method. The + // container runs with MARIADB_AUTO_UPGRADE=1, which performs the + // datadir/system-table upgrade automatically on startup after an image + // bump (the MariaDB analog of mysql_upgrade). There is therefore no + // pg_upgrade-style explicit upgrade orchestration here, and calling + // upgrade() returns the trait's default "not implemented" error. A major + // version change happens via a container recreate on the new image, not + // through this method. + + #[tokio::test] + async fn test_upgrade_returns_not_implemented() { + let service = mariadb_service_for_tests(); + let old = ServiceConfig { + name: "pitr-test".to_string(), + service_type: ServiceType::Mariadb, + version: Some("10.11".to_string()), + parameters: serde_json::json!({ + "host": "localhost", + "port": "3306", + "database": "app", + "username": "app", + "password": "secretpass", + "root_password": "rootpass1", + "docker_image": "mariadb:10.11", + }), + }; + let new = ServiceConfig { + version: Some("11.4".to_string()), + parameters: serde_json::json!({ + "host": "localhost", + "port": "3306", + "database": "app", + "username": "app", + "password": "secretpass", + "root_password": "rootpass1", + "docker_image": "mariadb:11.4", + }), + ..old.clone() + }; + + // MariaDB relies on MARIADB_AUTO_UPGRADE on recreate, so the explicit + // upgrade entrypoint is intentionally the trait default (errors). + let result = service.upgrade(old, new).await; + assert!( + result.is_err(), + "MariaDB should not implement explicit in-place upgrade" + ); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("Upgrade not implemented"), + "unexpected upgrade error message: {}", + msg + ); + } + + #[test] + fn test_create_container_env_enables_auto_upgrade() { + // Document the upgrade mechanism: MARIADB_AUTO_UPGRADE=1 is the env the + // container is created with (see create_container). This is the path by + // which version upgrades actually happen. + let env_vars = ["MARIADB_AUTO_UPGRADE=1".to_string()]; + assert!(env_vars.contains(&"MARIADB_AUTO_UPGRADE=1".to_string())); + } +} diff --git a/crates/temps-providers/src/externalsvc/mariadb_binlog_health.rs b/crates/temps-providers/src/externalsvc/mariadb_binlog_health.rs new file mode 100644 index 000000000..fd4cf8d6e --- /dev/null +++ b/crates/temps-providers/src/externalsvc/mariadb_binlog_health.rs @@ -0,0 +1,517 @@ +//! MariaDB binary-log (binlog) health probe. +//! +//! Mirrors the Postgres WAL/archive health probe: it detects the MariaDB-side +//! equivalents of the "silent backup-impossible" and "silent disk-filler" +//! conditions on a managed MariaDB service: +//! +//! - **Binlog disabled** (`log_bin = OFF`): point-in-time recovery is +//! impossible — there is no continuous change stream to replay after a +//! base backup. This is the MariaDB analog of Postgres `archive_mode = off`. +//! - **Large local binlog backlog**: many/large binlogs accumulating on disk +//! usually means the archiver/shipper is behind or `binlog_expire_logs_seconds` +//! (retention) is set too high — the MariaDB analog of `pg_wal` bloat. +//! - **Non-ROW binlog format** (`binlog_format != ROW`): STATEMENT/MIXED +//! formats can replay non-deterministically, degrading PITR fidelity. +//! - **Non-InnoDB tables** (MyISAM/Aria): not crash-safe and don't recover +//! consistently under PITR — a known cause of point-in-time-restore failure. +//! +//! The probe is read-only, runs on a single short-lived `sqlx` MySQL +//! connection using root credentials, and returns a structured snapshot that +//! the background `ExternalServiceHealthMonitor` can persist and the UI can +//! surface as warnings. +//! +//! On ANY connection error the probe returns `None` — it is best-effort +//! observability, not a liveness signal, and a failure here must not cascade +//! into the service being marked down. Credentials are never logged. + +use serde::{Deserialize, Serialize}; +use sqlx::mysql::MySqlPoolOptions; +use sqlx::Row; +use std::time::Duration; +use utoipa::ToSchema; + +/// Number of local binary logs above which the backlog is considered large. +/// +/// A healthy MariaDB rotates binlogs (default `max_binlog_size` is 1 GiB) and +/// purges them per `binlog_expire_logs_seconds`. Accumulating more than this +/// many segments locally means either the shipper/archiver is behind or +/// retention is set too high — both worth flagging before the disk fills. +const BINLOG_BACKLOG_SEGMENT_COUNT: usize = 50; + +/// Total local binlog size (bytes) above which the backlog is considered +/// large, independent of segment count. 10 GiB of un-purged binlogs on a +/// service whose data may be far smaller is a strong "retention too high / +/// shipper stalled" signal. +const BINLOG_BACKLOG_TOTAL_BYTES: i64 = 10 * 1024 * 1024 * 1024; + +/// Connect + per-query timeout. The probe runs alongside the regular +/// `health_probe` so it must stay well under the poll interval. +const PROBE_TIMEOUT: Duration = Duration::from_secs(5); + +/// One actionable warning surfaced to the UI. +/// +/// Each variant carries the data needed to render a remediation hint without +/// the frontend re-querying anything. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum BinlogWarning { + /// `log_bin = OFF`. Point-in-time recovery is impossible — there is no + /// binary log to replay after restoring a base backup. + BinlogDisabled, + /// Many/large local binary logs are accumulating — the archiver/shipper + /// may be behind, or `binlog_expire_logs_seconds` (retention) is too high. + LargeBinlogBacklog { + segment_count: usize, + total_bytes: i64, + }, + /// `binlog_format` is not `ROW`. STATEMENT/MIXED replication can replay + /// non-deterministically, degrading PITR fidelity. + NonRowBinlogFormat { format: String }, + /// One or more user tables use a non-transactional storage engine + /// (MyISAM/Aria). These are not crash-safe and do not recover consistently + /// under PITR — a base + binlog-replay restore can leave them torn. Convert + /// such tables to InnoDB for reliable point-in-time recovery. + NonInnodbTables { count: usize }, +} + +impl BinlogWarning { + /// Severity hint for the UI banner color. `Critical` triggers red, + /// `Warning` triggers yellow. + pub fn severity(&self) -> BinlogWarningSeverity { + match self { + // No binlog = no PITR at all. The single most important signal. + Self::BinlogDisabled => BinlogWarningSeverity::Critical, + Self::LargeBinlogBacklog { .. } => BinlogWarningSeverity::Warning, + Self::NonRowBinlogFormat { .. } => BinlogWarningSeverity::Warning, + Self::NonInnodbTables { .. } => BinlogWarningSeverity::Warning, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum BinlogWarningSeverity { + Warning, + Critical, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct MariadbBinlogHealth { + /// Whether binary logging is enabled (`log_bin = ON`). + pub log_bin: bool, + /// The `binlog_format` setting (`ROW`, `STATEMENT`, `MIXED`, or empty when + /// binlog is disabled). + pub binlog_format: String, + /// Binlog retention in seconds (`binlog_expire_logs_seconds`). 0 means + /// "never auto-purge". + pub binlog_expire_logs_seconds: i64, + /// Number of local binary log files (from `SHOW BINARY LOGS`). + pub segment_count: usize, + /// Total size of all local binary log files in bytes. + pub total_binlog_bytes: i64, + /// Whether GTID strict mode is enabled. Informational — surfaced so the + /// UI can show replication-consistency posture alongside binlog health. + pub gtid_strict_mode: bool, + /// Number of user tables on a non-InnoDB (non-transactional) storage + /// engine. PITR is unreliable for these (MyISAM/Aria aren't crash-safe). + /// 0 is healthy. + pub non_innodb_table_count: usize, + /// Computed warnings, ordered by severity (critical first). + pub warnings: Vec, +} + +impl MariadbBinlogHealth { + /// True when any warning is present. The health monitor uses this to + /// downgrade the service to `degraded`. + pub fn has_warnings(&self) -> bool { + !self.warnings.is_empty() + } +} + +/// Build a MySQL connection URL from a `ServiceConfig`'s parameters JSON. +/// +/// Uses root credentials so the probe can read server-scope variables and +/// `SHOW BINARY LOGS` (which requires the `REPLICATION CLIENT`/`SUPER` +/// privilege the application user typically lacks). Mirrors how +/// `mariadb_query.rs` builds its URL. Returns `None` when the parameters don't +/// carry the fields we need — the caller treats that as "skip the probe" +/// rather than an error. +/// +/// `port` is read leniently: the service-config JSON stores it as a string, +/// but we also accept a JSON number so the function is robust to either shape. +pub fn build_conn_str(parameters: &serde_json::Value) -> Option { + let host = parameters.get("host")?.as_str()?; + let port = parameters.get("port").and_then(|v| { + v.as_str() + .map(|s| s.to_string()) + .or_else(|| v.as_u64().map(|n| n.to_string())) + })?; + let root_password = parameters.get("root_password")?.as_str()?; + + Some(format!( + "mysql://root:{}@{}:{}/", + urlencoding::encode(root_password), + host, + port, + )) +} + +/// Run the probe against a MariaDB instance using a `mysql://` connection URL. +/// +/// On any error we return `None` rather than surfacing the error — the binlog +/// probe is best-effort observability. The whole probe is wrapped in a +/// `PROBE_TIMEOUT` so a hung server can't stall the health monitor. +pub async fn probe_binlog_health(conn_str: &str) -> Option { + tokio::time::timeout(PROBE_TIMEOUT, collect_snapshot(conn_str)) + .await + .ok()? +} + +async fn collect_snapshot(conn_str: &str) -> Option { + let pool = MySqlPoolOptions::new() + .max_connections(1) + .acquire_timeout(PROBE_TIMEOUT) + .connect(conn_str) + .await + .ok()?; + + let log_bin = fetch_on_off_variable(&pool, "log_bin") + .await + .unwrap_or(false); + let binlog_format = fetch_string_variable(&pool, "binlog_format") + .await + .unwrap_or_default(); + let binlog_expire_logs_seconds = fetch_numeric_variable(&pool, "binlog_expire_logs_seconds") + .await + .unwrap_or(0); + let gtid_strict_mode = fetch_on_off_variable(&pool, "gtid_strict_mode") + .await + .unwrap_or(false); + + // `SHOW BINARY LOGS` errors when binary logging is disabled. Treat that — + // and any other read failure — as an empty backlog rather than failing the + // whole probe. + let (segment_count, total_binlog_bytes) = fetch_binary_logs(&pool).await.unwrap_or((0, 0)); + + // Non-InnoDB user tables make PITR unreliable; surface a count. + let non_innodb_table_count = fetch_non_innodb_table_count(&pool).await.unwrap_or(0); + + pool.close().await; + + let mut snapshot = MariadbBinlogHealth { + log_bin, + binlog_format, + binlog_expire_logs_seconds, + segment_count, + total_binlog_bytes, + gtid_strict_mode, + non_innodb_table_count, + warnings: Vec::new(), + }; + snapshot.warnings = compute_warnings(&snapshot); + Some(snapshot) +} + +/// `SHOW VARIABLES LIKE ''` returns a two-column (`Variable_name`, +/// `Value`) result. Read the `Value` as a string. +async fn fetch_variable_value(pool: &sqlx::MySqlPool, name: &str) -> Option { + let row = sqlx::query("SHOW VARIABLES LIKE ?") + .bind(name) + .fetch_optional(pool) + .await + .ok()??; + // Column 1 is the value (column 0 is the variable name). + row.try_get::(1).ok() +} + +async fn fetch_string_variable(pool: &sqlx::MySqlPool, name: &str) -> Option { + fetch_variable_value(pool, name).await +} + +/// MariaDB reports boolean-ish variables as `ON`/`OFF` (and `log_bin` as +/// `ON`/`OFF` too). Normalize to a bool. +async fn fetch_on_off_variable(pool: &sqlx::MySqlPool, name: &str) -> Option { + let raw = fetch_variable_value(pool, name).await?; + Some(matches!( + raw.trim().to_ascii_uppercase().as_str(), + "ON" | "1" + )) +} + +async fn fetch_numeric_variable(pool: &sqlx::MySqlPool, name: &str) -> Option { + let raw = fetch_variable_value(pool, name).await?; + raw.trim().parse::().ok() +} + +/// `SHOW BINARY LOGS` returns one row per local binlog with columns +/// `Log_name` and `File_size`. Returns `(segment_count, total_bytes)`. +/// Errors (e.g. when binlog is disabled) propagate as `None`. +async fn fetch_binary_logs(pool: &sqlx::MySqlPool) -> Option<(usize, i64)> { + let rows = sqlx::query("SHOW BINARY LOGS").fetch_all(pool).await.ok()?; + let segment_count = rows.len(); + let total_bytes: i64 = rows + .iter() + .map(|row| { + // `File_size` is an unsigned bigint; read leniently. + row.try_get::("File_size") + .or_else(|_| row.try_get::("File_size").map(|v| v as i64)) + .unwrap_or(0) + }) + .sum(); + Some((segment_count, total_bytes)) +} + +/// Count user tables whose storage engine is not InnoDB. System schemas are +/// excluded. MyISAM/Aria tables aren't crash-safe and break consistent PITR. +/// Returns `None` on query failure (treated as 0 — best-effort). +async fn fetch_non_innodb_table_count(pool: &sqlx::MySqlPool) -> Option { + let row = sqlx::query( + "SELECT COUNT(*) AS c FROM information_schema.TABLES \ + WHERE TABLE_TYPE = 'BASE TABLE' AND ENGINE IS NOT NULL AND ENGINE <> 'InnoDB' \ + AND TABLE_SCHEMA NOT IN ('mysql','information_schema','performance_schema','sys')", + ) + .fetch_optional(pool) + .await + .ok()??; + let c: i64 = row + .try_get::("c") + .or_else(|_| row.try_get::("c").map(|v| v as i64)) + .ok()?; + Some(c.max(0) as usize) +} + +/// Pure warning computation. No I/O — fully unit-testable. +fn compute_warnings(snapshot: &MariadbBinlogHealth) -> Vec { + let mut warnings = Vec::new(); + + if !snapshot.log_bin { + // Binlog disabled dominates: PITR is impossible, and the + // format/backlog signals are meaningless without it. + warnings.push(BinlogWarning::BinlogDisabled); + return warnings; + } + + // Only meaningful when binlog is on (checked above). + if !snapshot.binlog_format.eq_ignore_ascii_case("ROW") { + warnings.push(BinlogWarning::NonRowBinlogFormat { + format: snapshot.binlog_format.clone(), + }); + } + + if snapshot.segment_count > BINLOG_BACKLOG_SEGMENT_COUNT + || snapshot.total_binlog_bytes > BINLOG_BACKLOG_TOTAL_BYTES + { + warnings.push(BinlogWarning::LargeBinlogBacklog { + segment_count: snapshot.segment_count, + total_bytes: snapshot.total_binlog_bytes, + }); + } + + if snapshot.non_innodb_table_count > 0 { + warnings.push(BinlogWarning::NonInnodbTables { + count: snapshot.non_innodb_table_count, + }); + } + + // Sort: critical first, then warning. + warnings.sort_by_key(|w| match w.severity() { + BinlogWarningSeverity::Critical => 0, + BinlogWarningSeverity::Warning => 1, + }); + + warnings +} + +#[cfg(test)] +mod tests { + use super::*; + + fn healthy_snapshot() -> MariadbBinlogHealth { + MariadbBinlogHealth { + log_bin: true, + binlog_format: "ROW".to_string(), + binlog_expire_logs_seconds: 86400, + segment_count: 3, + total_binlog_bytes: 256 * 1024 * 1024, // 256 MiB + gtid_strict_mode: true, + non_innodb_table_count: 0, + warnings: Vec::new(), + } + } + + #[test] + fn healthy_snapshot_produces_no_warnings() { + let s = healthy_snapshot(); + assert!(compute_warnings(&s).is_empty()); + } + + #[test] + fn binlog_disabled_is_critical_and_dominates() { + let mut s = healthy_snapshot(); + s.log_bin = false; + // Even with otherwise-bad signals, only BinlogDisabled is emitted. + s.binlog_format = "STATEMENT".to_string(); + s.segment_count = 1000; + let warnings = compute_warnings(&s); + assert_eq!(warnings, vec![BinlogWarning::BinlogDisabled]); + assert_eq!(warnings[0].severity(), BinlogWarningSeverity::Critical); + } + + #[test] + fn non_innodb_tables_warn_when_binlog_on() { + let mut s = healthy_snapshot(); + s.non_innodb_table_count = 4; + let warnings = compute_warnings(&s); + let w = warnings + .iter() + .find(|w| matches!(w, BinlogWarning::NonInnodbTables { .. })) + .expect("should warn about non-InnoDB tables"); + assert!(matches!(w, BinlogWarning::NonInnodbTables { count } if *count == 4)); + assert_eq!(w.severity(), BinlogWarningSeverity::Warning); + } + + #[test] + fn non_innodb_warning_suppressed_when_binlog_disabled() { + // BinlogDisabled dominates and short-circuits everything else. + let mut s = healthy_snapshot(); + s.log_bin = false; + s.non_innodb_table_count = 4; + assert_eq!(compute_warnings(&s), vec![BinlogWarning::BinlogDisabled]); + } + + #[test] + fn non_row_format_warns_when_binlog_on() { + let mut s = healthy_snapshot(); + s.binlog_format = "STATEMENT".to_string(); + let warnings = compute_warnings(&s); + assert!(warnings.iter().any(|w| matches!( + w, + BinlogWarning::NonRowBinlogFormat { format } if format == "STATEMENT" + ))); + // Non-ROW is a Warning, not Critical. + let w = warnings + .iter() + .find(|w| matches!(w, BinlogWarning::NonRowBinlogFormat { .. })) + .unwrap(); + assert_eq!(w.severity(), BinlogWarningSeverity::Warning); + } + + #[test] + fn mixed_format_also_warns() { + let mut s = healthy_snapshot(); + s.binlog_format = "MIXED".to_string(); + let warnings = compute_warnings(&s); + assert!(warnings + .iter() + .any(|w| matches!(w, BinlogWarning::NonRowBinlogFormat { .. }))); + } + + #[test] + fn row_format_is_case_insensitive() { + let mut s = healthy_snapshot(); + s.binlog_format = "row".to_string(); + assert!(compute_warnings(&s).is_empty()); + } + + #[test] + fn oversized_segment_count_triggers_backlog() { + let mut s = healthy_snapshot(); + s.segment_count = BINLOG_BACKLOG_SEGMENT_COUNT + 1; + let warnings = compute_warnings(&s); + assert!(warnings.iter().any(|w| matches!( + w, + BinlogWarning::LargeBinlogBacklog { segment_count, .. } + if *segment_count == BINLOG_BACKLOG_SEGMENT_COUNT + 1 + ))); + } + + #[test] + fn oversized_total_bytes_triggers_backlog() { + let mut s = healthy_snapshot(); + s.segment_count = 5; // under the count threshold + s.total_binlog_bytes = BINLOG_BACKLOG_TOTAL_BYTES + 1; + let warnings = compute_warnings(&s); + assert!(warnings + .iter() + .any(|w| matches!(w, BinlogWarning::LargeBinlogBacklog { .. }))); + } + + #[test] + fn backlog_at_threshold_does_not_trigger() { + let mut s = healthy_snapshot(); + s.segment_count = BINLOG_BACKLOG_SEGMENT_COUNT; // exactly at, not over + s.total_binlog_bytes = BINLOG_BACKLOG_TOTAL_BYTES; // exactly at, not over + assert!(compute_warnings(&s).is_empty()); + } + + #[test] + fn critical_warnings_sort_before_warnings() { + // BinlogDisabled short-circuits, so to test sorting we construct a + // case with multiple non-disabled warnings and confirm ordering is + // stable (all Warning severity here — the sort is a no-op but must + // not reorder unexpectedly). Then verify the dominant-critical path. + let mut s = healthy_snapshot(); + s.binlog_format = "STATEMENT".to_string(); + s.segment_count = BINLOG_BACKLOG_SEGMENT_COUNT + 10; + let warnings = compute_warnings(&s); + assert_eq!(warnings.len(), 2); + // Both are Warning severity. + assert!(warnings + .iter() + .all(|w| w.severity() == BinlogWarningSeverity::Warning)); + } + + #[test] + fn has_warnings_reflects_warnings_vec() { + let mut s = healthy_snapshot(); + assert!(!s.has_warnings()); + s.warnings.push(BinlogWarning::BinlogDisabled); + assert!(s.has_warnings()); + } + + #[test] + fn build_conn_str_with_string_port() { + let params = serde_json::json!({ + "host": "127.0.0.1", + "port": "3306", + "root_password": "s3cr3t" + }); + let url = build_conn_str(¶ms).expect("should build"); + assert_eq!(url, "mysql://root:s3cr3t@127.0.0.1:3306/"); + } + + #[test] + fn build_conn_str_with_numeric_port() { + let params = serde_json::json!({ + "host": "db.internal", + "port": 3307, + "root_password": "pw" + }); + let url = build_conn_str(¶ms).expect("should build"); + assert_eq!(url, "mysql://root:pw@db.internal:3307/"); + } + + #[test] + fn build_conn_str_url_encodes_password() { + let params = serde_json::json!({ + "host": "h", + "port": "3306", + "root_password": "p@ss:word/with#chars" + }); + let url = build_conn_str(¶ms).expect("should build"); + // The password segment must be percent-encoded so special chars don't + // break URL parsing or leak into the host/path. + assert!(url.starts_with("mysql://root:")); + assert!(!url.contains("p@ss:word/with#chars")); + assert!(url.ends_with("@h:3306/")); + } + + #[test] + fn build_conn_str_missing_fields_returns_none() { + assert!(build_conn_str(&serde_json::json!({})).is_none()); + assert!(build_conn_str(&serde_json::json!({ "host": "h", "port": "3306" })).is_none()); + assert!( + build_conn_str(&serde_json::json!({ "host": "h", "root_password": "p" })).is_none() + ); + } +} diff --git a/crates/temps-providers/src/externalsvc/mod.rs b/crates/temps-providers/src/externalsvc/mod.rs index 61ba2a824..bde5e5ab3 100644 --- a/crates/temps-providers/src/externalsvc/mod.rs +++ b/crates/temps-providers/src/externalsvc/mod.rs @@ -6,6 +6,8 @@ use utoipa::ToSchema; pub mod cluster_role; pub mod exec_util; +pub mod mariadb; +pub mod mariadb_binlog_health; pub mod mongodb; pub mod port_util; pub mod postgres; @@ -34,6 +36,7 @@ pub(crate) static DEPLOYMENT_MODE_MUTEX: std::sync::Mutex<()> = std::sync::Mutex // Re-export services for easier access pub use cluster_role::{ClusterRole, PgAutoFailoverState}; +pub use mariadb::{BinlogManifest, MariaDbService}; pub use mongodb::MongodbService; pub use postgres::PostgresService; pub use postgres_cluster::PostgresClusterService; @@ -522,6 +525,7 @@ pub struct NewServiceRestoreResult { #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "lowercase")] pub enum ServiceType { + Mariadb, Mongodb, Postgres, Redis, @@ -544,6 +548,7 @@ impl std::fmt::Display for ServiceType { #[allow(deprecated)] fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { + ServiceType::Mariadb => write!(f, "mariadb"), ServiceType::Mongodb => write!(f, "mongodb"), ServiceType::Postgres => write!(f, "postgres"), ServiceType::Redis => write!(f, "redis"), @@ -561,6 +566,7 @@ impl ServiceType { #[allow(deprecated)] pub fn from_str(s: &str) -> Result { match s.to_lowercase().as_str() { + "mariadb" => Ok(ServiceType::Mariadb), "mongodb" => Ok(ServiceType::Mongodb), "postgres" => Ok(ServiceType::Postgres), "redis" => Ok(ServiceType::Redis), @@ -577,6 +583,7 @@ impl ServiceType { #[allow(deprecated)] pub fn get_all() -> Vec { vec![ + ServiceType::Mariadb, ServiceType::Mongodb, ServiceType::Postgres, ServiceType::Redis, diff --git a/crates/temps-providers/src/externalsvc/postgres.rs b/crates/temps-providers/src/externalsvc/postgres.rs index abee9b287..b787fd891 100644 --- a/crates/temps-providers/src/externalsvc/postgres.rs +++ b/crates/temps-providers/src/externalsvc/postgres.rs @@ -5325,6 +5325,7 @@ mod tests { consecutive_health_failures: 0, health_metadata: None, metrics_enabled: false, + default_backup_provisioned: false, }; // Build a MockDatabase for the `pool` slot — restore_pitr for // Postgres doesn't touch it in the legacy-reject path. diff --git a/crates/temps-providers/src/externalsvc/test_utils.rs b/crates/temps-providers/src/externalsvc/test_utils.rs index b6cae5ea2..b44537f7d 100644 --- a/crates/temps-providers/src/externalsvc/test_utils.rs +++ b/crates/temps-providers/src/externalsvc/test_utils.rs @@ -414,6 +414,7 @@ pub fn create_mock_external_service( consecutive_health_failures: 0, health_metadata: None, metrics_enabled: false, + default_backup_provisioned: false, } } diff --git a/crates/temps-providers/src/handlers/query_handlers.rs b/crates/temps-providers/src/handlers/query_handlers.rs index 048116d99..6cd7f4885 100644 --- a/crates/temps-providers/src/handlers/query_handlers.rs +++ b/crates/temps-providers/src/handlers/query_handlers.rs @@ -291,6 +291,38 @@ pub async fn check_explorer_support( // Check if service type supports querying let (supported, capabilities, filter_schema, hierarchy, reason) = match service.service_type { + crate::externalsvc::ServiceType::Mariadb => { + let filter_schema = app_state + .query_service + .get_filter_schema(service_id) + .await + .ok(); + + let hierarchy = vec![ + HierarchyLevel { + level: 0, + name: "root".to_string(), + container_type: "database".to_string(), + can_list_containers: true, + can_list_entities: false, + }, + HierarchyLevel { + level: 1, + name: "database".to_string(), + container_type: "table".to_string(), + can_list_containers: false, + can_list_entities: true, + }, + ]; + + ( + true, + vec!["sql".to_string()], + filter_schema, + hierarchy, + None, + ) + } crate::externalsvc::ServiceType::Postgres => { // Get filter schema from QueryService let filter_schema = app_state diff --git a/crates/temps-providers/src/handlers/types.rs b/crates/temps-providers/src/handlers/types.rs index aadda2f1e..a156c067d 100644 --- a/crates/temps-providers/src/handlers/types.rs +++ b/crates/temps-providers/src/handlers/types.rs @@ -78,6 +78,7 @@ impl From for crate::externalsvc::ServiceParameter { #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq)] #[serde(rename_all = "lowercase")] pub enum ServiceTypeRoute { + Mariadb, Mongodb, Postgres, Redis, @@ -97,6 +98,7 @@ impl ServiceTypeRoute { #[allow(clippy::should_implement_trait)] pub fn from_str(s: &str) -> anyhow::Result { match s.to_lowercase().as_str() { + "mariadb" => Ok(ServiceTypeRoute::Mariadb), "mongodb" => Ok(ServiceTypeRoute::Mongodb), "postgres" => Ok(ServiceTypeRoute::Postgres), "redis" => Ok(ServiceTypeRoute::Redis), @@ -112,6 +114,7 @@ impl ServiceTypeRoute { /// Returns a Vec containing all available service types pub fn get_all() -> Vec { vec![ + ServiceTypeRoute::Mariadb, ServiceTypeRoute::Mongodb, ServiceTypeRoute::Postgres, ServiceTypeRoute::Redis, @@ -134,6 +137,7 @@ impl ServiceTypeRoute { impl std::fmt::Display for ServiceTypeRoute { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { + ServiceTypeRoute::Mariadb => write!(f, "mariadb"), ServiceTypeRoute::Mongodb => write!(f, "mongodb"), ServiceTypeRoute::Postgres => write!(f, "postgres"), ServiceTypeRoute::Redis => write!(f, "redis"), @@ -150,6 +154,7 @@ impl From for crate::externalsvc::ServiceType { #[allow(deprecated)] fn from(service_type: ServiceTypeRoute) -> Self { match service_type { + ServiceTypeRoute::Mariadb => crate::externalsvc::ServiceType::Mariadb, ServiceTypeRoute::Mongodb => crate::externalsvc::ServiceType::Mongodb, ServiceTypeRoute::Postgres => crate::externalsvc::ServiceType::Postgres, ServiceTypeRoute::Redis => crate::externalsvc::ServiceType::Redis, @@ -166,6 +171,7 @@ impl From for ServiceTypeRoute { #[allow(deprecated)] fn from(service_type: crate::externalsvc::ServiceType) -> Self { match service_type { + crate::externalsvc::ServiceType::Mariadb => ServiceTypeRoute::Mariadb, crate::externalsvc::ServiceType::Mongodb => ServiceTypeRoute::Mongodb, crate::externalsvc::ServiceType::Postgres => ServiceTypeRoute::Postgres, crate::externalsvc::ServiceType::Redis => ServiceTypeRoute::Redis, @@ -310,6 +316,13 @@ pub struct ProviderMetadata { impl ProviderMetadata { pub fn get_all() -> Vec { vec![ + Self { + service_type: ServiceTypeRoute::Mariadb, + display_name: "MariaDB".to_string(), + description: "Shared MariaDB server with per-project databases".to_string(), + icon_url: "https://cdn.simpleicons.org/mariadb/003545".to_string(), + color: "#003545".to_string(), + }, Self { service_type: ServiceTypeRoute::Mongodb, display_name: "MongoDB".to_string(), diff --git a/crates/temps-providers/src/health_monitor.rs b/crates/temps-providers/src/health_monitor.rs index fac50f5a8..2401d321d 100644 --- a/crates/temps-providers/src/health_monitor.rs +++ b/crates/temps-providers/src/health_monitor.rs @@ -10,20 +10,28 @@ //! the monitor sends a notification via the shared `NotificationService`. //! A recovery notification is sent when the service returns to `operational`. +use crate::externalsvc::mariadb::{BinlogArchiveInterval, MariaDbConfig, MariaDbService}; use crate::externalsvc::postgres_wal_health::{self, PostgresWalHealth}; -use crate::externalsvc::{HealthProbeStatus, ServiceType}; +use crate::externalsvc::{HealthProbeStatus, S3Credentials, ServiceType}; use crate::services::ExternalServiceManager; +use bollard::Docker; use chrono::Utc; use sea_orm::{ ActiveModelTrait, ActiveValue::Set, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, }; +use std::collections::HashMap; use std::sync::Arc; -use std::time::Duration; +use std::time::{Duration, Instant}; use temps_core::notifications::{ NotificationData, NotificationPriority, NotificationService, NotificationType, }; -use temps_entities::{external_service_health_checks, external_services}; +use temps_core::EncryptionService; +use temps_entities::{ + backup_schedule_services, backup_schedules, external_service_health_checks, external_services, + s3_sources, +}; use thiserror::Error; +use tokio::sync::Mutex; use tracing::{debug, error, info, warn}; /// Key under `external_services.health_metadata` for Postgres WAL probe output. @@ -72,6 +80,15 @@ pub struct ExternalServiceHealthMonitor { manager: Arc, notification_service: Arc, config: ExternalServiceHealthConfig, + /// Docker handle used by the per-service MariaDB binlog archiver to read + /// closed binlog segments out of the container. + docker: Arc, + /// Decrypts `s3_sources` credentials so the archiver can build an S3 client. + encryption_service: Arc, + /// Last time we ran the binlog archiver for each MariaDB service, keyed by + /// service id. The health loop ticks every `poll_interval_secs`; we gate + /// archiving so it only fires once per service's `binlog_archive_interval`. + last_binlog_archive: Arc>>, } impl ExternalServiceHealthMonitor { @@ -80,12 +97,17 @@ impl ExternalServiceHealthMonitor { manager: Arc, notification_service: Arc, config: ExternalServiceHealthConfig, + docker: Arc, + encryption_service: Arc, ) -> Self { Self { db, manager, notification_service, config, + docker, + encryption_service, + last_binlog_archive: Arc::new(Mutex::new(HashMap::new())), } } @@ -262,9 +284,204 @@ impl ExternalServiceHealthMonitor { self.send_recovered_alert(service).await; } + // 4. MariaDB PITR: ship closed binary-log segments to S3 on the + // service's configured cadence. Only for running standalone + // MariaDB services that have a backup schedule (→ S3 destination). + // Failures here never affect health monitoring of other services. + if service.service_type == "mariadb" + && service.topology == "standalone" + && service.status == "running" + && !matches!(status, HealthProbeStatus::Down) + { + self.maybe_archive_mariadb_binlogs(service).await; + } + Ok(()) } + /// Per-service MariaDB binlog archiver tick. Gated so the actual ship only + /// happens once per the service's `binlog_archive_interval`, even though + /// the health loop calls this every `poll_interval_secs`. + /// + /// All failures are logged and swallowed — binlog archiving must never + /// disrupt health monitoring. + async fn maybe_archive_mariadb_binlogs(&self, service: &external_services::Model) { + // Load config to read the configured ship cadence. Cheap relative to + // the schedule scan below, and needed for the interval gate. + let service_config = match self.manager.get_service_config(service.id).await { + Ok(cfg) => cfg, + Err(e) => { + debug!( + service_id = service.id, + "Failed to load MariaDB config for binlog archive: {}", e + ); + return; + } + }; + let mariadb_config: MariaDbConfig = + match serde_json::from_value(service_config.parameters.clone()) { + Ok(c) => c, + Err(e) => { + debug!( + service_id = service.id, + "Failed to parse MariaDB config for binlog archive: {}", e + ); + return; + } + }; + let interval = mariadb_config.binlog_archive_interval; + + // Interval gate (cheap, in-memory) FIRST: only proceed if enough + // wall-clock time has elapsed since the last archive run. Checked + // before the backup-schedule DB scan so we don't query every poll tick. + if !self.binlog_interval_elapsed(service.id, interval).await { + return; + } + + // Discover the S3 destination from a backup schedule covering this + // service. No schedule = no PITR destination configured = skip. + let s3_source = match self.find_s3_source_for_service(service.id).await { + Ok(Some(src)) => src, + Ok(None) => { + debug!( + service_id = service.id, + "MariaDB service has no backup schedule; skipping binlog archive" + ); + return; + } + Err(e) => { + debug!( + service_id = service.id, + "Failed to resolve S3 source for MariaDB binlog archive: {}", e + ); + return; + } + }; + + // Build a decrypted S3 client from the source row. + let creds = match self.build_s3_credentials(&s3_source) { + Ok(c) => c, + Err(e) => { + warn!( + service_id = service.id, + "Failed to build S3 credentials for MariaDB binlog archive: {}", e + ); + return; + } + }; + let s3_client = creds.build_s3_client().await; + + let mariadb = MariaDbService::new(service.name.clone(), self.docker.clone()); + match mariadb + .archive_binlogs(&s3_client, &s3_source, &mariadb_config) + .await + { + Ok(shipped) => { + if shipped > 0 { + info!( + service_id = service.id, + service = %service.name, + shipped, + "Archived MariaDB binlog segment(s) to S3" + ); + } + } + Err(e) => { + warn!( + service_id = service.id, + service = %service.name, + "MariaDB binlog archive run failed: {}", e + ); + } + } + } + + /// Check the per-service interval gate and, if elapsed, record `now` as the + /// new last-archived time. Returns true when the caller should proceed. + async fn binlog_interval_elapsed( + &self, + service_id: i32, + interval: BinlogArchiveInterval, + ) -> bool { + let mut map = self.last_binlog_archive.lock().await; + let now = Instant::now(); + match map.get(&service_id) { + Some(last) if now.duration_since(*last) < Duration::from_secs(interval.seconds()) => { + false + } + _ => { + map.insert(service_id, now); + true + } + } + } + + /// Find the S3 source for a service via an enabled backup schedule that + /// covers it. A schedule covers the service when `target_all_services` is + /// true, or when the `backup_schedule_services` join links them. Prefers + /// the most recently updated schedule when several apply. + async fn find_s3_source_for_service( + &self, + service_id: i32, + ) -> Result, HealthMonitorError> { + use sea_orm::QueryOrder; + + let schedules = backup_schedules::Entity::find() + .filter(backup_schedules::Column::Enabled.eq(true)) + .order_by_desc(backup_schedules::Column::UpdatedAt) + .all(self.db.as_ref()) + .await?; + + for schedule in schedules { + let covers = if schedule.target_all_services { + true + } else { + backup_schedule_services::Entity::find() + .filter(backup_schedule_services::Column::ScheduleId.eq(schedule.id)) + .filter(backup_schedule_services::Column::ServiceId.eq(service_id)) + .one(self.db.as_ref()) + .await? + .is_some() + }; + if !covers { + continue; + } + if let Some(source) = s3_sources::Entity::find_by_id(schedule.s3_source_id) + .one(self.db.as_ref()) + .await? + { + return Ok(Some(source)); + } + } + + Ok(None) + } + + /// Decrypt an `s3_sources` row into usable `S3Credentials`. + fn build_s3_credentials( + &self, + s3_source: &s3_sources::Model, + ) -> Result { + let access_key_id = self + .encryption_service + .decrypt_string(&s3_source.access_key_id) + .map_err(|e| anyhow::anyhow!("Failed to decrypt S3 access key: {}", e))?; + let secret_key = self + .encryption_service + .decrypt_string(&s3_source.secret_key) + .map_err(|e| anyhow::anyhow!("Failed to decrypt S3 secret key: {}", e))?; + + Ok(S3Credentials { + access_key_id, + secret_key, + region: s3_source.region.clone(), + endpoint: s3_source.endpoint.clone(), + bucket_name: s3_source.bucket_name.clone(), + bucket_path: s3_source.bucket_path.clone(), + force_path_style: s3_source.force_path_style.unwrap_or(false), + }) + } + /// Run the WAL/archive probe for a standalone Postgres service. /// /// Best-effort: any failure returns `None` and is logged at debug level diff --git a/crates/temps-providers/src/lib.rs b/crates/temps-providers/src/lib.rs index 167ddd02f..01db70487 100644 --- a/crates/temps-providers/src/lib.rs +++ b/crates/temps-providers/src/lib.rs @@ -3,6 +3,7 @@ pub mod env_vars_provider_impl; pub mod externalsvc; pub mod health_monitor; +pub mod mariadb_query; pub mod parameter_strategies; pub mod postgres_lifecycle; pub mod postgres_upgrade_service; diff --git a/crates/temps-providers/src/mariadb_query.rs b/crates/temps-providers/src/mariadb_query.rs new file mode 100644 index 000000000..9ce4aab4b --- /dev/null +++ b/crates/temps-providers/src/mariadb_query.rs @@ -0,0 +1,901 @@ +use async_trait::async_trait; +use base64::Engine; +use sqlx::mysql::{MySqlPool, MySqlPoolOptions, MySqlRow}; +use sqlx::{Column, Row, TypeInfo}; +use std::collections::HashMap; +use temps_query::{ + Capability, ContainerCapabilities, ContainerInfo, ContainerPath, ContainerType, DataError, + DataRow, DataSource, DatasetSchema, EntityCountHint, EntityInfo, FieldDef, FieldType, + Introspect, QueryOptions, QueryResult, QuerySchemaProvider, QueryStats, Queryable, Result, +}; +use tracing::{debug, error}; + +pub struct MariaDbSource { + pool: MySqlPool, + database_name: String, +} + +impl MariaDbSource { + pub async fn connect( + host: &str, + port: u16, + username: &str, + password: &str, + database: &str, + ) -> Result { + validate_identifier("database", database)?; + + let url = format!( + "mysql://{}:{}@{}:{}/{}", + urlencoding::encode(username), + urlencoding::encode(password), + host, + port, + urlencoding::encode(database) + ); + + debug!( + "Connecting to MariaDB: {}@{}:{}/{}", + username, host, port, database + ); + + let pool = MySqlPoolOptions::new() + .max_connections(5) + .connect(&url) + .await + .map_err(|e| { + DataError::ConnectionFailed(format!("MariaDB connection failed: {}", e)) + })?; + + Ok(Self { + pool, + database_name: database.to_string(), + }) + } + + fn map_mysql_type(mysql_type: &str) -> FieldType { + match mysql_type.to_ascii_lowercase().as_str() { + "bool" | "boolean" => FieldType::Boolean, + "tinyint" | "smallint" | "mediumint" | "int" | "integer" | "year" => FieldType::Int32, + "bigint" => FieldType::Int64, + "float" => FieldType::Float32, + "double" | "real" => FieldType::Float64, + "decimal" | "numeric" => FieldType::String, + "binary" | "varbinary" | "tinyblob" | "blob" | "mediumblob" | "longblob" => { + FieldType::Bytes + } + "date" => FieldType::Date, + "datetime" | "timestamp" | "time" => FieldType::Timestamp, + "json" => FieldType::Json, + _ => FieldType::String, + } + } + + fn row_to_datarow(row: &MySqlRow) -> Result { + let mut data_row = HashMap::new(); + for (idx, column) in row.columns().iter().enumerate() { + let value = Self::extract_value(row, idx)?; + data_row.insert(column.name().to_string(), value); + } + Ok(data_row) + } + + fn extract_value(row: &MySqlRow, idx: usize) -> Result { + let column = &row.columns()[idx]; + let type_name = column.type_info().name().to_ascii_lowercase(); + + let value = match type_name.as_str() { + "bool" | "boolean" => row + .try_get::, _>(idx) + .ok() + .flatten() + .map(serde_json::Value::Bool) + .unwrap_or(serde_json::Value::Null), + "tinyint" | "smallint" | "mediumint" | "int" | "integer" | "year" => row + .try_get::, _>(idx) + .ok() + .flatten() + .map(|v| serde_json::Value::Number(v.into())) + .unwrap_or(serde_json::Value::Null), + "bigint" => row + .try_get::, _>(idx) + .ok() + .flatten() + .map(|v| serde_json::Value::Number(v.into())) + .or_else(|| { + row.try_get::, _>(idx) + .ok() + .flatten() + .map(|v| serde_json::Value::Number(v.into())) + }) + .unwrap_or(serde_json::Value::Null), + "float" => row + .try_get::, _>(idx) + .ok() + .flatten() + .and_then(|v| serde_json::Number::from_f64(v as f64)) + .map(serde_json::Value::Number) + .unwrap_or(serde_json::Value::Null), + "double" | "real" => row + .try_get::, _>(idx) + .ok() + .flatten() + .and_then(serde_json::Number::from_f64) + .map(serde_json::Value::Number) + .unwrap_or(serde_json::Value::Null), + "json" => row + .try_get::, _>(idx) + .ok() + .flatten() + .and_then(|v| serde_json::from_str(&v).ok()) + .unwrap_or(serde_json::Value::Null), + "binary" | "varbinary" | "tinyblob" | "blob" | "mediumblob" | "longblob" => row + .try_get::>, _>(idx) + .ok() + .flatten() + .map(|v| { + serde_json::Value::String(base64::engine::general_purpose::STANDARD.encode(v)) + }) + .unwrap_or(serde_json::Value::Null), + _ => row + .try_get::, _>(idx) + .ok() + .flatten() + .map(serde_json::Value::String) + .unwrap_or(serde_json::Value::Null), + }; + + Ok(value) + } +} + +#[async_trait] +impl DataSource for MariaDbSource { + fn source_type(&self) -> &'static str { + "mariadb" + } + + fn capabilities(&self) -> Vec { + vec![Capability::Sql] + } + + async fn list_containers(&self, path: &ContainerPath) -> Result> { + match path.depth() { + 0 => { + let rows = sqlx::query( + r#" + SELECT SCHEMA_NAME + FROM information_schema.SCHEMATA + WHERE SCHEMA_NAME NOT IN ('information_schema', 'mysql', 'performance_schema', 'sys') + ORDER BY SCHEMA_NAME + "#, + ) + .fetch_all(&self.pool) + .await + .map_err(|e| DataError::QueryFailed(format!("Failed to list databases: {}", e)))?; + + Ok(rows + .iter() + .filter_map(|row| row.try_get::("SCHEMA_NAME").ok()) + .map(|name| ContainerInfo { + name, + container_type: ContainerType::Database, + capabilities: ContainerCapabilities { + can_contain_containers: false, + can_contain_entities: true, + child_container_type: None, + entity_type_label: Some("table".to_string()), + entity_count_hint: Some(EntityCountHint::Small), + }, + metadata: HashMap::new(), + }) + .collect()) + } + _ => Err(DataError::InvalidQuery(format!( + "MariaDB hierarchy only supports root/database levels. Path depth: {}", + path.depth() + ))), + } + } + + async fn get_container_info(&self, path: &ContainerPath) -> Result { + if path.depth() != 1 { + return Err(DataError::InvalidQuery(format!( + "get_container_info requires path depth 1 (database), got {}", + path.depth() + ))); + } + + let database_name = &path.segments[0]; + validate_identifier("database", database_name)?; + + let row = sqlx::query( + r#" + SELECT SCHEMA_NAME, DEFAULT_CHARACTER_SET_NAME, DEFAULT_COLLATION_NAME + FROM information_schema.SCHEMATA + WHERE SCHEMA_NAME = ? + "#, + ) + .bind(database_name) + .fetch_optional(&self.pool) + .await + .map_err(|e| { + DataError::QueryFailed(format!( + "Failed to read database '{}': {}", + database_name, e + )) + })? + .ok_or_else(|| DataError::NotFound(format!("Database '{}' not found", database_name)))?; + + let name: String = row.try_get("SCHEMA_NAME").map_err(|e| { + DataError::SerializationError(format!("Failed to read database name: {}", e)) + })?; + let charset: Option = row.try_get("DEFAULT_CHARACTER_SET_NAME").ok(); + let collation: Option = row.try_get("DEFAULT_COLLATION_NAME").ok(); + + let mut metadata = HashMap::new(); + if let Some(value) = charset { + metadata.insert("charset".to_string(), serde_json::json!(value)); + } + if let Some(value) = collation { + metadata.insert("collation".to_string(), serde_json::json!(value)); + } + + Ok(ContainerInfo { + name, + container_type: ContainerType::Database, + capabilities: ContainerCapabilities { + can_contain_containers: false, + can_contain_entities: true, + child_container_type: None, + entity_type_label: Some("table".to_string()), + entity_count_hint: Some(EntityCountHint::Small), + }, + metadata, + }) + } + + async fn list_entities(&self, container_path: &ContainerPath) -> Result> { + if container_path.depth() != 1 { + return Err(DataError::InvalidQuery(format!( + "list_entities requires path depth 1 (database), got {}", + container_path.depth() + ))); + } + + let database_name = &container_path.segments[0]; + validate_identifier("database", database_name)?; + if database_name != &self.database_name { + return Err(DataError::OperationNotSupported(format!( + "Cannot list tables from database '{}' while connected to '{}'", + database_name, self.database_name + ))); + } + + let rows = sqlx::query( + r#" + SELECT TABLE_NAME, TABLE_ROWS, DATA_LENGTH, INDEX_LENGTH + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = ? AND TABLE_TYPE = 'BASE TABLE' + ORDER BY TABLE_NAME + "#, + ) + .bind(database_name) + .fetch_all(&self.pool) + .await + .map_err(|e| { + DataError::QueryFailed(format!( + "Failed to list tables in database '{}': {}", + database_name, e + )) + })?; + + Ok(rows + .iter() + .filter_map(|row| { + let table_name = row.try_get::("TABLE_NAME").ok()?; + let table_rows = row + .try_get::, _>("TABLE_ROWS") + .ok() + .flatten() + .and_then(|v| usize::try_from(v).ok()); + let data_length = row + .try_get::, _>("DATA_LENGTH") + .ok() + .flatten() + .unwrap_or(0); + let index_length = row + .try_get::, _>("INDEX_LENGTH") + .ok() + .flatten() + .unwrap_or(0); + + Some(EntityInfo { + namespace: database_name.clone(), + name: table_name, + entity_type: "table".to_string(), + row_count: table_rows, + size_bytes: Some(data_length.saturating_add(index_length)), + schema: None, + metadata: None, + }) + }) + .collect()) + } + + async fn get_entity_info( + &self, + container_path: &ContainerPath, + entity_name: &str, + ) -> Result { + if !self.entity_exists(container_path, entity_name).await? { + return Err(DataError::NotFound(format!( + "Table '{}.{}' not found", + container_path, entity_name + ))); + } + + let row_count = self.count(container_path, entity_name, None).await.ok(); + + Ok(EntityInfo { + namespace: container_path.segments[0].clone(), + name: entity_name.to_string(), + entity_type: "table".to_string(), + row_count: row_count.and_then(|v| usize::try_from(v).ok()), + size_bytes: None, + schema: Some(self.get_schema(container_path, entity_name).await?), + metadata: None, + }) + } + + async fn get_schema( + &self, + container_path: &ContainerPath, + entity_name: &str, + ) -> Result { + if container_path.depth() != 1 { + return Err(DataError::InvalidQuery(format!( + "get_schema requires path depth 1 (database), got {}", + container_path.depth() + ))); + } + + let database_name = &container_path.segments[0]; + validate_identifier("database", database_name)?; + validate_identifier("table", entity_name)?; + if database_name != &self.database_name { + return Err(DataError::OperationNotSupported(format!( + "Cannot get schema from database '{}' while connected to '{}'", + database_name, self.database_name + ))); + } + + let rows = sqlx::query( + r#" + SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_KEY + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? + ORDER BY ORDINAL_POSITION + "#, + ) + .bind(database_name) + .bind(entity_name) + .fetch_all(&self.pool) + .await + .map_err(|e| { + DataError::SchemaError(format!( + "Failed to get schema for table '{}.{}': {}", + database_name, entity_name, e + )) + })?; + + if rows.is_empty() { + return Err(DataError::NotFound(format!( + "Table '{}.{}' not found", + database_name, entity_name + ))); + } + + let mut primary_key = Vec::new(); + let mut fields = Vec::with_capacity(rows.len()); + for row in rows { + let name: String = row.try_get("COLUMN_NAME").map_err(|e| { + DataError::SchemaError(format!("Failed to read column name: {}", e)) + })?; + let data_type: String = row.try_get("DATA_TYPE").map_err(|e| { + DataError::SchemaError(format!("Failed to read column type: {}", e)) + })?; + let is_nullable: String = row.try_get("IS_NULLABLE").unwrap_or_else(|_| "YES".into()); + let column_key: Option = row.try_get("COLUMN_KEY").ok(); + if column_key.as_deref() == Some("PRI") { + primary_key.push(name.clone()); + } + + fields.push(FieldDef { + name, + field_type: Self::map_mysql_type(&data_type), + nullable: is_nullable == "YES", + description: None, + }); + } + + Ok(DatasetSchema { + fields, + partitions: None, + primary_key: if primary_key.is_empty() { + None + } else { + Some(primary_key) + }, + }) + } + + async fn close(&self) -> Result<()> { + self.pool.close().await; + Ok(()) + } +} + +#[async_trait] +impl Introspect for MariaDbSource { + async fn inspect_fields( + &self, + container_path: &ContainerPath, + entity_name: &str, + ) -> Result> { + Ok(self.get_schema(container_path, entity_name).await?.fields) + } + + async fn field_exists( + &self, + container_path: &ContainerPath, + entity_name: &str, + field: &str, + ) -> Result { + validate_identifier("field", field)?; + let schema = self.get_schema(container_path, entity_name).await?; + Ok(schema.fields.iter().any(|f| f.name == field)) + } + + async fn get_field_type( + &self, + container_path: &ContainerPath, + entity_name: &str, + field: &str, + ) -> Result { + validate_identifier("field", field)?; + let schema = self.get_schema(container_path, entity_name).await?; + schema + .fields + .into_iter() + .find(|f| f.name == field) + .map(|f| f.field_type) + .ok_or_else(|| { + DataError::NotFound(format!( + "Field '{}' not found in table '{}'", + field, entity_name + )) + }) + } +} + +#[async_trait] +impl Queryable for MariaDbSource { + async fn query( + &self, + container_path: &ContainerPath, + entity_name: &str, + filters: Option, + options: QueryOptions, + ) -> Result { + let database_name = database_from_path(container_path, &self.database_name)?; + validate_identifier("table", entity_name)?; + + let start = std::time::Instant::now(); + let mut sql = format!( + "SELECT * FROM {}.{}", + quote_identifier(database_name), + quote_identifier(entity_name) + ); + + if let Some(filter_json) = filters { + if let Some(where_clause) = filter_json.get("where").and_then(|v| v.as_str()) { + validate_where_clause(where_clause)?; + sql.push_str(" WHERE "); + sql.push_str(where_clause); + } + } + + if let Some(sort_by) = &options.sort_by { + let sort_field = normalize_sort_field(sort_by)?; + let sort_order = match options.sort_order.as_deref() { + Some("desc") | Some("DESC") => "DESC", + _ => "ASC", + }; + sql.push_str(&format!( + " ORDER BY {} {}", + quote_identifier(sort_field), + sort_order + )); + } + + let limit = options.limit.unwrap_or(100); + let offset = options.offset.unwrap_or(0); + sql.push_str(" LIMIT ? OFFSET ?"); + + debug!("Executing MariaDB query: {}", sql); + + let rows = sqlx::query(&sql) + .bind(limit as i64) + .bind(offset as i64) + .fetch_all(&self.pool) + .await + .map_err(|e| { + error!("MariaDB query failed: {}", e); + DataError::QueryFailed(format!("{}\n\nQuery: {}", e, sql)) + })?; + + let data_rows: Result> = rows.iter().map(Self::row_to_datarow).collect(); + let data_rows = data_rows?; + let schema = self.get_schema(container_path, entity_name).await?; + let row_count = data_rows.len(); + + Ok(QueryResult { + schema, + rows: data_rows, + stats: QueryStats { + row_count, + total_rows: None, + execution_ms: start.elapsed().as_millis() as u64, + has_more: row_count >= limit, + next_cursor: None, + }, + }) + } + + async fn count( + &self, + container_path: &ContainerPath, + entity_name: &str, + filters: Option, + ) -> Result { + let database_name = database_from_path(container_path, &self.database_name)?; + validate_identifier("table", entity_name)?; + + let mut sql = format!( + "SELECT COUNT(*) AS row_count FROM {}.{}", + quote_identifier(database_name), + quote_identifier(entity_name) + ); + + if let Some(filter_json) = filters { + if let Some(where_clause) = filter_json.get("where").and_then(|v| v.as_str()) { + validate_where_clause(where_clause)?; + sql.push_str(" WHERE "); + sql.push_str(where_clause); + } + } + + let row = sqlx::query(&sql) + .fetch_one(&self.pool) + .await + .map_err(|e| DataError::QueryFailed(format!("Count query failed: {}", e)))?; + + let count = row + .try_get::("row_count") + .or_else(|_| row.try_get::("row_count").map(|v| v as i64)) + .map_err(|e| DataError::SerializationError(format!("Invalid count result: {}", e)))?; + + Ok(count.max(0) as u64) + } + + async fn entity_exists( + &self, + container_path: &ContainerPath, + entity_name: &str, + ) -> Result { + let database_name = database_from_path(container_path, &self.database_name)?; + validate_identifier("table", entity_name)?; + + let row = sqlx::query( + r#" + SELECT COUNT(*) AS table_count + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND TABLE_TYPE = 'BASE TABLE' + "#, + ) + .bind(database_name) + .bind(entity_name) + .fetch_one(&self.pool) + .await + .map_err(|e| DataError::QueryFailed(format!("Entity existence check failed: {}", e)))?; + + let count: i64 = row.try_get("table_count").unwrap_or(0); + Ok(count > 0) + } +} + +impl QuerySchemaProvider for MariaDbSource { + fn get_filter_schema(&self) -> serde_json::Value { + serde_json::json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "MariaDB Query Filters", + "description": "Filter data using SQL WHERE clause syntax", + "properties": { + "where": { + "type": "string", + "title": "WHERE Clause", + "description": "SQL WHERE clause (without 'WHERE' keyword). Example: status = 'active' AND created_at > '2025-01-01'", + "examples": [ + "status = 'active'", + "created_at > '2025-01-01'", + "age >= 18 AND country = 'US'", + "name LIKE '%test%'", + "id IN (1, 2, 3)" + ], + "x-ui-widget": "textarea", + "x-ui-placeholder": "status = 'active' AND created_at > NOW() - INTERVAL 7 DAY", + "x-ui-rows": 3 + } + }, + "additionalProperties": false + }) + } + + fn get_sort_schema( + &self, + container_path: &ContainerPath, + entity_name: &str, + ) -> Result { + let schema_result = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current() + .block_on(async { self.get_schema(container_path, entity_name).await }) + }); + + let schema = schema_result?; + let field_names: Vec = schema.fields.iter().map(|f| f.name.clone()).collect(); + + Ok(serde_json::json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "Sort Options", + "description": "Specify how to sort query results", + "properties": { + "sort_by": { + "type": "string", + "title": "Sort By", + "description": "Field to sort by", + "enum": field_names, + "x-ui-widget": "select" + }, + "sort_order": { + "type": "string", + "title": "Sort Order", + "description": "Sort direction", + "enum": ["asc", "desc"], + "default": "asc", + "x-ui-widget": "select" + } + } + })) + } +} + +fn database_from_path<'a>( + container_path: &'a ContainerPath, + connected_database: &'a str, +) -> Result<&'a str> { + if container_path.depth() != 1 { + return Err(DataError::InvalidQuery(format!( + "MariaDB table operations require path depth 1 (database), got {}", + container_path.depth() + ))); + } + + let database_name = container_path.segments[0].as_str(); + validate_identifier("database", database_name)?; + if database_name != connected_database { + return Err(DataError::OperationNotSupported(format!( + "Cannot query database '{}' while connected to '{}'", + database_name, connected_database + ))); + } + Ok(database_name) +} + +fn quote_identifier(value: &str) -> String { + format!("`{}`", value) +} + +fn validate_identifier(label: &str, value: &str) -> Result<()> { + if value.is_empty() { + return Err(DataError::InvalidQuery(format!( + "{} cannot be empty", + label + ))); + } + if value.len() > 63 { + return Err(DataError::InvalidQuery(format!( + "{} '{}' exceeds 63 character limit", + label, value + ))); + } + + let mut chars = value.chars(); + let Some(first) = chars.next() else { + return Err(DataError::InvalidQuery(format!( + "{} cannot be empty", + label + ))); + }; + if !first.is_ascii_alphabetic() && first != '_' { + return Err(DataError::InvalidQuery(format!( + "{} '{}' must start with a letter or underscore", + label, value + ))); + } + if !chars.all(|c| c.is_ascii_alphanumeric() || c == '_') { + return Err(DataError::InvalidQuery(format!( + "{} '{}' contains invalid characters. Only ASCII letters, digits, and underscores are allowed", + label, value + ))); + } + + Ok(()) +} + +fn normalize_sort_field(sort_by: &str) -> Result<&str> { + let trimmed = sort_by.trim().trim_start_matches('/'); + validate_identifier("sort field", trimmed)?; + Ok(trimmed) +} + +fn strip_sql_string_literals(sql: &str) -> String { + let mut result = String::with_capacity(sql.len()); + let mut in_string = false; + let mut chars = sql.chars().peekable(); + + while let Some(c) = chars.next() { + if in_string { + if c == '\'' { + if chars.peek() == Some(&'\'') { + chars.next(); + } else { + in_string = false; + result.push('\''); + } + } + } else if c == '\'' { + in_string = true; + result.push('\''); + } else { + result.push(c); + } + } + + result +} + +fn validate_where_clause(sql: &str) -> Result<()> { + let sql_lower = sql.trim().to_ascii_lowercase(); + + if sql_lower.is_empty() { + return Err(DataError::InvalidQuery( + "WHERE clause cannot be empty".to_string(), + )); + } + + let without_strings = strip_sql_string_literals(&sql_lower); + + if without_strings.contains(';') { + return Err(DataError::InvalidQuery( + "Multiple SQL statements are not allowed".to_string(), + )); + } + + if without_strings.contains("--") + || without_strings.contains("/*") + || without_strings.contains('#') + { + return Err(DataError::InvalidQuery( + "SQL comments are not allowed in the data browser".to_string(), + )); + } + + let dangerous_keywords = [ + "drop ", + "truncate ", + "alter ", + "create ", + "grant ", + "revoke ", + "insert ", + "update ", + "delete ", + "replace ", + "load ", + "union ", + "union\t", + "union\n", + "intersect ", + "except ", + "sleep(", + "benchmark(", + "load_file", + " into ", + "outfile", + "dumpfile", + "execute ", + "prepare ", + "call ", + "handler ", + "lock ", + "unlock ", + "set ", + "begin ", + "commit ", + "rollback ", + "savepoint ", + ]; + + for keyword in &dangerous_keywords { + if without_strings.contains(keyword) { + return Err(DataError::InvalidQuery(format!( + "SQL operation '{}' is not allowed in the data browser", + keyword.trim() + ))); + } + } + + Ok(()) +} + +pub(crate) fn is_mariadb_compatible_image(image: &str) -> bool { + let lower = image.to_ascii_lowercase(); + lower.contains("mariadb") || lower.split(['/', ':']).any(|part| part == "mysql") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_mysql_types() { + assert_eq!(MariaDbSource::map_mysql_type("int"), FieldType::Int32); + assert_eq!(MariaDbSource::map_mysql_type("bigint"), FieldType::Int64); + assert_eq!(MariaDbSource::map_mysql_type("varchar"), FieldType::String); + assert_eq!( + MariaDbSource::map_mysql_type("datetime"), + FieldType::Timestamp + ); + assert_eq!(MariaDbSource::map_mysql_type("json"), FieldType::Json); + assert_eq!(MariaDbSource::map_mysql_type("blob"), FieldType::Bytes); + } + + #[test] + fn validates_identifiers() { + assert!(validate_identifier("database", "app_prod").is_ok()); + assert!(validate_identifier("database", "1bad").is_err()); + assert!(validate_identifier("database", "bad-name").is_err()); + assert!(validate_identifier("database", "bad`name").is_err()); + } + + #[test] + fn validates_where_clause() { + assert!(validate_where_clause("status = 'active' AND age >= 18").is_ok()); + assert!(validate_where_clause("id IN (1, 2, 3)").is_ok()); + assert!(validate_where_clause("name LIKE '%drop table%'").is_ok()); + assert!(validate_where_clause("1=1; DROP TABLE users").is_err()); + assert!(validate_where_clause("id = 1 UNION SELECT password FROM users").is_err()); + assert!(validate_where_clause("name = 'x' -- comment").is_err()); + } + + #[test] + fn detects_mariadb_compatible_images() { + assert!(is_mariadb_compatible_image("mariadb:lts")); + assert!(is_mariadb_compatible_image("library/mysql:8.4")); + assert!(is_mariadb_compatible_image("mysql:8")); + assert!(!is_mariadb_compatible_image("postgres:18")); + } +} diff --git a/crates/temps-providers/src/parameter_strategies.rs b/crates/temps-providers/src/parameter_strategies.rs index 1af0ff013..1624ebf10 100644 --- a/crates/temps-providers/src/parameter_strategies.rs +++ b/crates/temps-providers/src/parameter_strategies.rs @@ -1,3 +1,4 @@ +use crate::externalsvc::{mariadb::MariaDbSizeProfile, ServiceResourceLimits}; use serde_json::{json, Value as JsonValue}; use std::collections::HashMap; @@ -34,7 +35,9 @@ fn is_valid_pg_identifier(s: &str) -> bool { return false; } let mut chars = s.chars(); - let first = chars.next().expect("non-empty checked above"); + let Some(first) = chars.next() else { + return false; + }; if !first.is_ascii_alphabetic() && first != '_' { return false; } @@ -106,6 +109,110 @@ fn validate_postgres_credentials(params: &HashMap) -> Result< Ok(()) } +fn is_valid_mariadb_identifier(s: &str) -> bool { + if s.is_empty() || s.len() > 63 { + return false; + } + let mut chars = s.chars(); + let first = chars.next().expect("non-empty checked above"); + if !first.is_ascii_alphabetic() && first != '_' { + return false; + } + chars.all(|c| c.is_ascii_alphanumeric() || c == '_') +} + +fn validate_mariadb_password(label: &str, s: &str) -> Result<(), String> { + if s.is_empty() { + return Ok(()); + } + if s.len() < 8 { + return Err(format!("{} must be at least 8 characters", label)); + } + if s.len() > 256 { + return Err(format!("{} too long (max 256 characters)", label)); + } + for (i, c) in s.chars().enumerate() { + match c { + '\'' => { + return Err(format!( + "{} contains a single quote at position {}", + label, i + )) + } + '\\' => return Err(format!("{} contains a backslash at position {}", label, i)), + '\0' => return Err(format!("{} contains a null byte", label)), + '\n' | '\r' => return Err(format!("{} contains a newline", label)), + c if c.is_control() => { + return Err(format!( + "{} contains control character (U+{:04X}) at position {}", + label, c as u32, i + )) + } + _ => {} + } + } + Ok(()) +} + +fn validate_mariadb_credentials(params: &HashMap) -> Result<(), String> { + if let Some(JsonValue::String(user)) = params.get("username") { + if !user.is_empty() && !is_valid_mariadb_identifier(user) { + return Err(format!( + "invalid 'username' {:?}: must match ^[A-Za-z_][A-Za-z0-9_]{{0,62}}$", + user + )); + } + } + if let Some(JsonValue::String(db)) = params.get("database") { + if !db.is_empty() && !is_valid_mariadb_identifier(db) { + return Err(format!( + "invalid 'database' {:?}: must match ^[A-Za-z_][A-Za-z0-9_]{{0,62}}$", + db + )); + } + } + if let Some(JsonValue::String(pw)) = params.get("password") { + validate_mariadb_password("password", pw)?; + } + if let Some(JsonValue::String(pw)) = params.get("root_password") { + validate_mariadb_password("root_password", pw)?; + } + Ok(()) +} + +fn mariadb_size_profile_from_params( + params: &HashMap, +) -> Result { + match params.get("size_profile") { + value if is_empty_value(value) => Ok(MariaDbSizeProfile::Small), + Some(JsonValue::String(profile)) => MariaDbSizeProfile::parse(profile).ok_or_else(|| { + format!( + "invalid 'size_profile' {:?}: expected one of small, standard, dedicated", + profile + ) + }), + Some(other) => Err(format!( + "invalid 'size_profile' {:?}: expected a string", + other + )), + None => Ok(MariaDbSizeProfile::Small), + } +} + +fn validate_service_resource_limits(params: &HashMap) -> Result<(), String> { + let Some(resources) = params.get("resources") else { + return Ok(()); + }; + if resources.is_null() { + return Ok(()); + } + let limits: ServiceResourceLimits = serde_json::from_value(resources.clone()) + .map_err(|e| format!("invalid 'resources' block: {}", e))?; + limits + .validate() + .map_err(|e| format!("invalid 'resources' block: {}", e)) +} + /// Strategy for validating and managing parameters for a specific service type pub trait ParameterStrategy: Send + Sync { /// Validate parameters for service creation - ensures all required parameters are present @@ -268,6 +375,176 @@ impl ParameterStrategy for PostgresParameterStrategy { } } +/// MariaDB parameter strategy +pub struct MariaDbParameterStrategy; + +impl ParameterStrategy for MariaDbParameterStrategy { + fn validate_for_creation(&self, params: &HashMap) -> Result<(), String> { + validate_mariadb_credentials(params)?; + mariadb_size_profile_from_params(params)?; + validate_service_resource_limits(params)?; + Ok(()) + } + + fn auto_generate_missing(&self, params: &mut HashMap) -> Result<(), String> { + if is_empty_value(params.get("host")) { + params.insert( + "host".to_string(), + JsonValue::String("localhost".to_string()), + ); + } + + if is_empty_value(params.get("database")) { + params.insert("database".to_string(), JsonValue::String("app".to_string())); + } + + if is_empty_value(params.get("username")) { + params.insert("username".to_string(), JsonValue::String("app".to_string())); + } + + if is_empty_value(params.get("port")) { + if let Some(port) = find_available_port(3306) { + params.insert("port".to_string(), JsonValue::String(port.to_string())); + } + } + + if is_empty_value(params.get("docker_image")) { + params.insert( + "docker_image".to_string(), + JsonValue::String("mariadb:lts".to_string()), + ); + } + + let size_profile = mariadb_size_profile_from_params(params)?; + if is_empty_value(params.get("size_profile")) { + params.insert( + "size_profile".to_string(), + JsonValue::String(size_profile.as_str().to_string()), + ); + } + + if is_empty_value(params.get("resources")) { + let resources = serde_json::to_value(size_profile.default_resource_limits()) + .map_err(|e| format!("failed to serialize MariaDB default resources: {}", e))?; + params.insert("resources".to_string(), resources); + } + + if is_empty_value(params.get("password")) { + params.insert( + "password".to_string(), + JsonValue::String(generate_secure_password()), + ); + } + + if is_empty_value(params.get("root_password")) { + params.insert( + "root_password".to_string(), + JsonValue::String(generate_secure_password()), + ); + } + + Ok(()) + } + + fn validate_for_update(&self, updates: &HashMap) -> Result<(), String> { + for key in updates.keys() { + if !self.updateable_keys().contains(&key.as_str()) { + return Err(format!( + "Cannot update parameter '{}' for MariaDB. Read-only parameters: {}. Updateable parameters: {}", + key, + self.readonly_keys().join(", "), + self.updateable_keys().join(", ") + )); + } + } + Ok(()) + } + + fn updateable_keys(&self) -> Vec<&'static str> { + vec!["port", "docker_image"] + } + + fn readonly_keys(&self) -> Vec<&'static str> { + vec![ + "host", + "database", + "username", + "password", + "root_password", + "size_profile", + "resources", + ] + } + + fn merge_updates( + &self, + existing: &mut HashMap, + updates: HashMap, + ) -> Result<(), String> { + self.validate_for_update(&updates)?; + + for (key, value) in updates { + existing.insert(key, value); + } + Ok(()) + } + + fn get_schema(&self) -> Option { + Some(json!({ + "type": "object", + "title": "MariaDB Parameters", + "properties": { + "database": { + "type": "string", + "description": "Initial database name (read-only after creation)", + "default": "app" + }, + "username": { + "type": "string", + "description": "Application database user (read-only after creation)", + "default": "app" + }, + "password": { + "type": "string", + "description": "Application user password (read-only after creation, auto-generated if not provided)", + "example": "secure_password" + }, + "root_password": { + "type": "string", + "description": "Root password used by Temps for provisioning (read-only after creation, auto-generated if not provided)", + "example": "secure_root_password" + }, + "host": { + "type": "string", + "description": "Host address (read-only after creation)", + "default": "localhost" + }, + "port": { + "type": "integer", + "description": "Port (updateable)", + "default": 3306 + }, + "docker_image": { + "type": "string", + "description": "Docker image (updateable, e.g., mariadb:lts)", + "default": "mariadb:lts" + }, + "size_profile": { + "type": "string", + "description": "Managed MariaDB resource/tuning profile. A MariaDB service is shared; linked projects get separate databases inside it.", + "default": "small", + "enum": ["small", "standard", "dedicated"] + } + }, + "readonly": ["host", "database", "username", "password", "root_password", "size_profile", "resources"] + })) + } + + fn service_name(&self) -> &'static str { + "MariaDB" + } +} + /// Redis parameter strategy pub struct RedisParameterStrategy; @@ -940,6 +1217,7 @@ impl ParameterStrategy for MongodbParameterStrategy { /// Helper: Get strategy for a service type pub fn get_strategy(service_type: &str) -> Option> { match service_type { + "mariadb" => Some(Box::new(MariaDbParameterStrategy)), "postgres" => Some(Box::new(PostgresParameterStrategy)), "redis" => Some(Box::new(RedisParameterStrategy)), // S3 now uses RustFS by default @@ -1081,6 +1359,77 @@ mod tests { assert!(result.is_ok()); } + #[test] + fn test_mariadb_generates_defaults() { + let strategy = MariaDbParameterStrategy; + let mut params = HashMap::new(); + + strategy + .validate_for_creation(¶ms) + .expect("empty MariaDB params should use defaults"); + strategy + .auto_generate_missing(&mut params) + .expect("defaults should generate"); + + assert_eq!( + params.get("database"), + Some(&JsonValue::String("app".to_string())) + ); + assert_eq!( + params.get("username"), + Some(&JsonValue::String("app".to_string())) + ); + assert_eq!( + params.get("docker_image"), + Some(&JsonValue::String("mariadb:lts".to_string())) + ); + assert_eq!( + params.get("size_profile"), + Some(&JsonValue::String("small".to_string())) + ); + let resources: ServiceResourceLimits = serde_json::from_value( + params + .get("resources") + .expect("MariaDB defaults should include resource limits") + .clone(), + ) + .expect("default MariaDB resources should deserialize"); + assert_eq!(resources.memory_mb, Some(512)); + assert_eq!(resources.memory_swap_mb, Some(768)); + assert_eq!(resources.nano_cpus, Some(750_000_000)); + assert!(params.get("password").and_then(|v| v.as_str()).is_some()); + assert!(params + .get("root_password") + .and_then(|v| v.as_str()) + .is_some()); + } + + #[test] + fn test_mariadb_rejects_readonly_update() { + let strategy = MariaDbParameterStrategy; + let mut updates = HashMap::new(); + updates.insert( + "root_password".to_string(), + JsonValue::String("new-secure-password".to_string()), + ); + + let result = strategy.validate_for_update(&updates); + assert!(result.is_err()); + } + + #[test] + fn test_mariadb_rejects_invalid_size_profile() { + let strategy = MariaDbParameterStrategy; + let mut params = HashMap::new(); + params.insert( + "size_profile".to_string(), + JsonValue::String("oversized".to_string()), + ); + + let result = strategy.validate_for_creation(¶ms); + assert!(result.is_err()); + } + #[test] fn test_mongodb_updateable_docker_image() { let strategy = MongodbParameterStrategy; diff --git a/crates/temps-providers/src/query_service.rs b/crates/temps-providers/src/query_service.rs index fe83e49c6..d4533a9fd 100644 --- a/crates/temps-providers/src/query_service.rs +++ b/crates/temps-providers/src/query_service.rs @@ -11,11 +11,13 @@ use temps_query_s3::S3Source; use tokio::sync::RwLock; use tracing::{debug, error, info, warn}; +use crate::externalsvc::mariadb::MariaDbInputConfig; use crate::externalsvc::mongodb::MongodbInputConfig; use crate::externalsvc::postgres::PostgresInputConfig; use crate::externalsvc::redis::RedisInputConfig; use crate::externalsvc::rustfs::RustfsConfig; use crate::externalsvc::s3::S3InputConfig; +use crate::mariadb_query::MariaDbSource; use crate::ExternalServiceManager; /// Cache of active connections by (service_id, database_name) @@ -160,6 +162,42 @@ impl QueryService { // Create connection based on service type let connection: Arc = match service.service_type { + crate::externalsvc::ServiceType::Mariadb => { + let config: MariaDbInputConfig = serde_json::from_value(service.parameters.clone()) + .map_err(|e| { + DataError::InvalidConfiguration(format!( + "Failed to parse MariaDB configuration: {}", + e + )) + })?; + + let port = config + .port + .unwrap_or_else(|| "3306".to_string()) + .parse::() + .map_err(|e| { + DataError::InvalidConfiguration(format!("Invalid port number: {}", e)) + })?; + let password = config.password.unwrap_or_default(); + + let source = MariaDbSource::connect( + &config.host, + port, + &config.username, + &password, + database, + ) + .await + .map_err(|e| { + error!( + "Failed to connect to MariaDB service {} database {}: {}", + service_id, database, e + ); + e + })?; + + Arc::new(source) + } crate::externalsvc::ServiceType::Postgres => { // Deserialize parameters into typed PostgresInputConfig let config: PostgresInputConfig = @@ -583,6 +621,20 @@ impl QueryService { // Determine which database/identifier to connect to based on service type and path depth let database = match service.service_type { + crate::externalsvc::ServiceType::Mariadb => { + if path.depth() == 0 { + let config: MariaDbInputConfig = + serde_json::from_value(service.parameters.clone()).map_err(|e| { + DataError::InvalidConfiguration(format!( + "Failed to parse MariaDB configuration: {}", + e + )) + })?; + config.database.clone() + } else { + path.segments[0].clone() + } + } crate::externalsvc::ServiceType::Postgres => { if path.depth() == 0 { // Root level - use configured database for connection @@ -814,6 +866,12 @@ impl QueryService { .await; } + if let Some(queryable) = conn.downcast_ref::() { + return queryable + .query(&path_clone, &entity_name, filters, options) + .await; + } + Err(DataError::OperationNotSupported( "Service does not support querying".to_string(), )) @@ -831,15 +889,33 @@ impl QueryService { .await .map_err(|e| DataError::ConnectionFailed(format!("Service not found: {}", e)))?; - let config: PostgresInputConfig = serde_json::from_value(service.parameters.clone()) - .map_err(|e| { - DataError::InvalidConfiguration(format!( - "Failed to parse PostgreSQL configuration: {}", - e - )) - })?; - - let database = config.database.clone(); + let database = match service.service_type { + crate::externalsvc::ServiceType::Mariadb => { + let config: MariaDbInputConfig = serde_json::from_value(service.parameters.clone()) + .map_err(|e| { + DataError::InvalidConfiguration(format!( + "Failed to parse MariaDB configuration: {}", + e + )) + })?; + config.database.clone() + } + crate::externalsvc::ServiceType::Postgres => { + let config: PostgresInputConfig = + serde_json::from_value(service.parameters.clone()).map_err(|e| { + DataError::InvalidConfiguration(format!( + "Failed to parse PostgreSQL configuration: {}", + e + )) + })?; + config.database.clone() + } + _ => { + return Err(DataError::OperationNotSupported( + "Service does not support query schemas".to_string(), + )); + } + }; let conn = self .get_connection_for_database(service_id, &database) .await?; @@ -848,6 +924,9 @@ impl QueryService { if let Some(provider) = conn.downcast_ref::() { use temps_query::QuerySchemaProvider; Ok(provider.get_filter_schema()) + } else if let Some(provider) = conn.downcast_ref::() { + use temps_query::QuerySchemaProvider; + Ok(provider.get_filter_schema()) } else { Err(DataError::OperationNotSupported( "Service does not support query schemas".to_string(), @@ -877,6 +956,9 @@ impl QueryService { if let Some(provider) = conn.downcast_ref::() { use temps_query::QuerySchemaProvider; provider.get_sort_schema(container_path, entity_name) + } else if let Some(provider) = conn.downcast_ref::() { + use temps_query::QuerySchemaProvider; + provider.get_sort_schema(container_path, entity_name) } else { Err(DataError::OperationNotSupported( "Service does not support query schemas".to_string(), diff --git a/crates/temps-providers/src/services.rs b/crates/temps-providers/src/services.rs index 2b42b1fcb..3a9f75c62 100644 --- a/crates/temps-providers/src/services.rs +++ b/crates/temps-providers/src/services.rs @@ -1,7 +1,13 @@ use crate::externalsvc::{ - mongodb::MongodbService, postgres::PostgresService, postgres_cluster::PostgresClusterService, - redis::RedisService, rustfs::RustfsService, s3::S3Service, AvailableContainer, - ClusterMemberSpec, ExternalService, HealthProbeStatus, ServiceConfig, ServiceType, + mariadb::{MariaDbService, MariaDbSizeProfile}, + mongodb::MongodbService, + postgres::PostgresService, + postgres_cluster::PostgresClusterService, + redis::RedisService, + rustfs::RustfsService, + s3::S3Service, + AvailableContainer, ClusterMemberSpec, ExternalService, HealthProbeStatus, ServiceConfig, + ServiceType, }; use crate::parameter_strategies; use crate::remote_service_client::{ @@ -804,6 +810,7 @@ impl ExternalServiceManager { service_type: ServiceType, ) -> Box { match service_type { + ServiceType::Mariadb => Box::new(MariaDbService::new(name, self.docker.clone())), ServiceType::Mongodb => Box::new(MongodbService::new(name, self.docker.clone())), ServiceType::Postgres => Box::new(PostgresService::new(name, self.docker.clone())), // Note: PostgresCluster is handled via create_cluster_service_instance, not here @@ -889,6 +896,41 @@ impl ExternalServiceManager { parameters: &HashMap, ) -> Result { let (image, container_port, env, volume_path, command) = match service_type { + ServiceType::Mariadb => { + let image = parameters + .get("docker_image") + .cloned() + .unwrap_or_else(|| "mariadb:lts".to_string()); + let size_profile = parameters + .get("size_profile") + .and_then(|value| MariaDbSizeProfile::parse(value)) + .unwrap_or_default(); + let root_password = parameters.get("root_password").cloned().unwrap_or_default(); + let password = parameters.get("password").cloned().unwrap_or_default(); + let database = parameters + .get("database") + .cloned() + .unwrap_or_else(|| "app".to_string()); + let username = parameters + .get("username") + .cloned() + .unwrap_or_else(|| "app".to_string()); + + let env = HashMap::from([ + ("MARIADB_ROOT_PASSWORD".to_string(), root_password), + ("MARIADB_DATABASE".to_string(), database), + ("MARIADB_USER".to_string(), username), + ("MARIADB_PASSWORD".to_string(), password), + ("MARIADB_AUTO_UPGRADE".to_string(), "1".to_string()), + ]); + ( + image, + 3306u16, + env, + "/var/lib/mysql".to_string(), + Some(size_profile.server_args()), + ) + } ServiceType::Postgres => { let image = parameters .get("docker_image") @@ -1050,30 +1092,10 @@ impl ExternalServiceManager { let container_name_for_volume = format!("{}-{}", service_type, service_name); let volume_name = format!("{}_data", container_name_for_volume); - // Resource limits, when provided, are stored as flat string keys in - // `parameters` (set alongside `memory_mb=512`, `nano_cpus=1000000000`, - // etc.) so they survive the `HashMap` round-trip - // used by the cluster manager. Missing keys → unlimited. - let resource_limits = { - let parse_i64 = |key: &str| -> Option { - parameters - .get(key) - .and_then(|s| s.trim().parse::().ok()) - .filter(|&n| n > 0) - }; - let limits = crate::externalsvc::ServiceResourceLimits { - memory_mb: parse_i64("memory_mb"), - memory_swap_mb: parse_i64("memory_swap_mb"), - nano_cpus: parse_i64("nano_cpus"), - cpu_shares: parse_i64("cpu_shares"), - shm_size_mb: parse_i64("shm_size_mb"), - }; - if limits.is_unlimited() { - None - } else { - Some(limits) - } - }; + // Resource limits may arrive as the modern nested `resources` block or + // as legacy flat string keys (`memory_mb=512`, `nano_cpus=1000000000`, + // etc.). Missing limits mean unlimited. + let resource_limits = Self::remote_resource_limits_from_parameters(parameters); Ok(RemoteServiceCreateParams { name: container_name, @@ -1091,6 +1113,39 @@ impl ExternalServiceManager { }) } + fn remote_resource_limits_from_parameters( + parameters: &HashMap, + ) -> Option { + if let Some(resources) = parameters.get("resources") { + if let Ok(limits) = + serde_json::from_str::(resources) + { + if !limits.is_unlimited() { + return Some(limits); + } + } + } + + let parse_i64 = |key: &str| -> Option { + parameters + .get(key) + .and_then(|s| s.trim().parse::().ok()) + .filter(|&n| n > 0) + }; + let limits = crate::externalsvc::ServiceResourceLimits { + memory_mb: parse_i64("memory_mb"), + memory_swap_mb: parse_i64("memory_swap_mb"), + nano_cpus: parse_i64("nano_cpus"), + cpu_shares: parse_i64("cpu_shares"), + shm_size_mb: parse_i64("shm_size_mb"), + }; + if limits.is_unlimited() { + None + } else { + Some(limits) + } + } + /// Get the container name for a service (used for remote operations). fn get_container_name_for_service( &self, @@ -6670,7 +6725,12 @@ echo "[restore] Pre-seed complete" matches!( key, // Only include truly inferred values - "port" | "connection_string" | "local_address" | "inferred_port" | "password" + "port" + | "connection_string" + | "local_address" + | "inferred_port" + | "password" + | "root_password" ) } @@ -6868,6 +6928,31 @@ echo "[restore] Pre-seed complete" let service_instance = self.create_service_instance(service.name.clone(), service_type_enum); + if service_type_enum == ServiceType::Mariadb { + let parameters = self.get_service_parameters(service_id).await?; + if parameters.contains_key("container_name") { + let service_config = ServiceConfig { + name: service.name.clone(), + service_type: service_type_enum, + version: service.version.clone(), + parameters: serde_json::to_value(parameters).map_err(|e| { + ExternalServiceError::InternalError { + reason: format!("Failed to serialize parameters: {}", e), + } + })?, + }; + service_instance.init(service_config).await.map_err(|e| { + ExternalServiceError::StopFailed { + id: service_id, + reason: format!( + "Failed to initialize imported MariaDB service before stop: {}", + e + ), + } + })?; + } + } + service_instance .stop() .await @@ -8022,7 +8107,9 @@ echo "[restore] Pre-seed complete" // Detect service type based on image name #[allow(deprecated)] - let service_type = if image.contains("postgres") + let service_type = if crate::mariadb_query::is_mariadb_compatible_image(&image) { + ServiceType::Mariadb + } else if image.contains("postgres") || image.contains("timescaledb") || image.contains("pgvector") { @@ -8112,7 +8199,7 @@ echo "[restore] Pre-seed complete" for (key, value) in &request.parameters { match key.as_str() { - "username" | "password" => { + "username" | "password" | "database" | "root_password" => { if let Some(str_value) = value.as_str() { credentials.insert(key.clone(), str_value.to_string()); } @@ -8128,6 +8215,17 @@ echo "[restore] Pre-seed complete" // Get the appropriate service instance and call import #[allow(deprecated)] let service_config = match request.service_type { + ServiceType::Mariadb => { + let mariadb = MariaDbService::new(request.name.clone(), Arc::clone(&self.docker)); + mariadb + .import_from_container( + request.container_id.clone(), + request.name.clone(), + credentials, + additional_config, + ) + .await? + } ServiceType::Postgres => { let postgres = PostgresService::new(request.name.clone(), Arc::clone(&self.docker)); postgres diff --git a/crates/temps-providers/tests/mariadb_binlog_health_integration.rs b/crates/temps-providers/tests/mariadb_binlog_health_integration.rs new file mode 100644 index 000000000..b7eeb28e3 --- /dev/null +++ b/crates/temps-providers/tests/mariadb_binlog_health_integration.rs @@ -0,0 +1,159 @@ +//! Integration tests for the MariaDB binary-log health probe. +//! +//! Boots a real MariaDB in a Docker container, then drives the probe against +//! it under different binlog configurations and checks that the warning vector +//! reflects reality. +//! +//! Skips gracefully when Docker is unavailable (CI runners without docker, +//! local machines without it, etc.) — never marks tests as `#[ignore]`. + +use std::time::Duration; + +use sqlx::mysql::MySqlPoolOptions; +use temps_providers::externalsvc::mariadb_binlog_health::{self, BinlogWarning}; +use testcontainers::{ + core::{ContainerPort, WaitFor}, + runners::AsyncRunner, + ContainerAsync, GenericImage, ImageExt, +}; + +const ROOT_PASSWORD: &str = "probe-root-pw"; + +/// Boots a `mariadb:lts` container and waits for it to become +/// connection-ready. Returns the `mysql://` conn string and keeps the +/// container alive for the test's lifetime. `extra_cmd` is appended as the +/// container command so callers can enable binlog (`--log-bin=...` etc.). +/// +/// Returns `None` when Docker is unavailable so tests skip rather than fail. +async fn boot_mariadb(extra_cmd: &[&str]) -> Option<(String, ContainerAsync)> { + let mut image = GenericImage::new("mariadb", "lts") + .with_exposed_port(ContainerPort::Tcp(3306)) + .with_wait_for(WaitFor::message_on_stderr("ready for connections")) + .with_env_var("MARIADB_ROOT_PASSWORD", ROOT_PASSWORD) + .with_env_var("MARIADB_DATABASE", "appdb"); + + if !extra_cmd.is_empty() { + image = image.with_cmd(extra_cmd.iter().map(|s| s.to_string())); + } + + let container = match image.start().await { + Ok(c) => c, + Err(e) => { + eprintln!("⏭️ Docker unavailable, skipping: {e}"); + return None; + } + }; + + let host = container.get_host().await.ok()?; + let port = container.get_host_port_ipv4(3306).await.ok()?; + + // MariaDB logs "ready for connections" during init AND after final + // startup; a short pause avoids racing the restart that briefly closes + // inbound connections. + tokio::time::sleep(Duration::from_secs(1)).await; + + let conn_str = format!("mysql://root:{ROOT_PASSWORD}@{host}:{port}/"); + + // Sanity check: open one connection before handing back so the test + // doesn't have to retry on the first probe. + for attempt in 0..20 { + match MySqlPoolOptions::new() + .max_connections(1) + .acquire_timeout(Duration::from_secs(3)) + .connect(&conn_str) + .await + { + Ok(pool) => { + pool.close().await; + return Some((conn_str, container)); + } + Err(_) if attempt < 19 => { + tokio::time::sleep(Duration::from_millis(500)).await; + } + Err(e) => { + eprintln!("⏭️ MariaDB never became reachable: {e}"); + return None; + } + } + } + None +} + +// ── Tests ──────────────────────────────────────────────────────────── + +/// Stock `mariadb:lts` ships with binary logging OFF by default. The probe +/// must connect, observe `log_bin == false`, and emit `BinlogDisabled`. +#[tokio::test] +async fn probe_detects_binlog_disabled() { + let Some((conn_str, _container)) = boot_mariadb(&[]).await else { + return; + }; + + let snapshot = mariadb_binlog_health::probe_binlog_health(&conn_str) + .await + .expect("probe returned None against a reachable MariaDB"); + + assert!( + !snapshot.log_bin, + "stock mariadb:lts should have binlog disabled, got log_bin={}", + snapshot.log_bin + ); + assert!( + snapshot + .warnings + .iter() + .any(|w| matches!(w, BinlogWarning::BinlogDisabled)), + "expected BinlogDisabled warning, got: {:?}", + snapshot.warnings + ); +} + +/// With binlog explicitly enabled in ROW format, the probe must observe +/// `log_bin == true` and emit NO `BinlogDisabled` warning (and no +/// non-ROW-format warning either, since we asked for ROW). +#[tokio::test] +async fn probe_emits_no_disabled_warning_when_binlog_on() { + let Some((conn_str, _container)) = boot_mariadb(&[ + "--log-bin=mysql-bin", + "--server-id=1", + "--binlog-format=ROW", + ]) + .await + else { + return; + }; + + let snapshot = mariadb_binlog_health::probe_binlog_health(&conn_str) + .await + .expect("probe returned None against a reachable MariaDB"); + + assert!( + snapshot.log_bin, + "binlog should be ON with --log-bin set, got log_bin={}", + snapshot.log_bin + ); + assert!( + !snapshot + .warnings + .iter() + .any(|w| matches!(w, BinlogWarning::BinlogDisabled)), + "did not expect BinlogDisabled warning with binlog on, got: {:?}", + snapshot.warnings + ); + assert!( + !snapshot + .warnings + .iter() + .any(|w| matches!(w, BinlogWarning::NonRowBinlogFormat { .. })), + "did not expect NonRowBinlogFormat warning with ROW format, got: {:?}", + snapshot.warnings + ); +} + +/// The probe must handle an unreachable server gracefully: return None +/// instead of panicking or erroring. +#[tokio::test] +async fn probe_returns_none_on_bad_connection() { + let result = mariadb_binlog_health::probe_binlog_health("mysql://root:x@127.0.0.1:1/").await; + assert!(result.is_none(), "expected None on unreachable host"); +} diff --git a/docs/features/managed-services/page.mdx b/docs/features/managed-services/page.mdx index 5ba9cb808..4eb33af4f 100644 --- a/docs/features/managed-services/page.mdx +++ b/docs/features/managed-services/page.mdx @@ -1,6 +1,6 @@ export const metadata = { title: 'Managed Services', - description: 'Provision and manage PostgreSQL, Redis, and S3 storage directly within Temps—no third-party services required.', + description: 'Provision and manage PostgreSQL, MariaDB, Redis, and S3 storage directly within Temps—no third-party services required.', alternates: { canonical: '/docs/managed-services', }, @@ -9,6 +9,7 @@ export const metadata = { export const sections = [ { title: 'Available Services', id: 'available-services' }, { title: 'PostgreSQL Database', id: 'postgresql-database' }, + { title: 'MariaDB Database', id: 'mariadb-database' }, { title: 'Redis Caching', id: 'redis-caching' }, { title: 'S3 Object Storage', id: 's3-object-storage' }, { title: 'Service Management', id: 'service-management' }, @@ -22,7 +23,7 @@ export const sections = [ Temps provides fully managed database and storage services that you can provision and link to your projects in seconds. Unlike platforms like Vercel that require external services, Temps includes everything you need. {{ className: 'lead' }} -**Competitive Advantage**: While platforms like Vercel require third-party database providers (Vercel Postgres, Vercel KV), Temps includes managed PostgreSQL, Redis, and S3 storage out of the box—no additional accounts or billing required. +**Competitive Advantage**: While platforms like Vercel require third-party database providers (Vercel Postgres, Vercel KV), Temps includes managed PostgreSQL, MariaDB, Redis, and S3 storage out of the box—no additional accounts or billing required. --- @@ -31,9 +32,10 @@ Temps provides fully managed database and storage services that you can provisio - Temps offers three types of managed services that integrate seamlessly with your projects: + Temps offers managed services that integrate seamlessly with your projects: - **PostgreSQL** — Production-ready relational database + - **MariaDB** — MySQL-compatible relational database - **Redis** — High-performance caching and pub/sub - **S3 Storage** — Object storage compatible with MinIO and AWS S3 @@ -46,6 +48,9 @@ Temps provides fully managed database and storage services that you can provisio --version 16 \ --storage 10GB + # Create a MariaDB database + temps service create mariadb my-mysql-db + # Create a Redis instance temps service create redis my-cache \ --version 7 \ @@ -174,6 +179,47 @@ Run migrations during deployment by configuring build hooks: --- +## MariaDB Database {{ anchor: true, id: 'mariadb-database' }} + +Create a MariaDB service when your application expects a MySQL-compatible database. Temps uses MariaDB images by default, but keeps Temps' own internal database on PostgreSQL with TimescaleDB. + + + + **Via Dashboard:** + 1. Navigate to **Services** -> **Create Service** + 2. Select **MariaDB** + 3. Choose the default `mariadb:lts` image or provide a compatible image + 4. Pick a size profile for the host + 5. Click **Create Database** + + **Via CLI:** + ```bash + temps service create mariadb app-db + ``` + + + **Configuration Options:** + + | Option | Description | Default | + |--------|-------------|---------| + | `docker_image` | MariaDB-compatible image | `mariadb:lts` | + | `port` | Host port binding | Auto-assigned from 3306 | + | `size_profile` | Memory/connection tuning profile | `small` | + | `database` | Initial database | `app` | + | `username` | Application user | `app` | + + + +When linked to a project, one MariaDB service creates a separate database for each project/environment pair. The service is shared by default, so a small VPS can run one MariaDB container instead of one database daemon per project. + + +MariaDB is opt-in. A fresh Temps install does not pull or start MariaDB. The container is created only when you create or import a MariaDB external service. + + +Temps injects `DATABASE_URL`, `MYSQL_URL`, `MYSQL_*`, `MARIADB_URL`, and `MARIADB_*` variables. The connection URL uses the `mysql://` scheme because most frameworks and drivers connect to MariaDB through MySQL-compatible drivers. + +--- + ## Redis Caching {{ anchor: true, id: 'redis-caching' }} ### Creating a Redis Instance @@ -501,11 +547,11 @@ Scaling operations are performed with zero downtime. Your applications remain co **Recommended Resource Allocation:** -| Use Case | PostgreSQL | Redis | S3 Storage | -|----------|-----------|-------|------------| -| **Small Project** | 1GB RAM, 10GB storage | 256MB | 10GB | -| **Medium Project** | 2GB RAM, 50GB storage | 1GB | 100GB | -| **Large Project** | 4GB+ RAM, 200GB+ storage | 4GB+ | 500GB+ | +| Use Case | PostgreSQL | MariaDB | Redis | S3 Storage | +|----------|-----------|---------|-------|------------| +| **Small Project** | 1GB RAM, 10GB storage | Small profile | 256MB | 10GB | +| **Medium Project** | 2GB RAM, 50GB storage | Standard profile | 1GB | 100GB | +| **Large Project** | 4GB+ RAM, 200GB+ storage | Dedicated profile | 4GB+ | 500GB+ | --- @@ -514,6 +560,7 @@ Scaling operations are performed with zero downtime. Your applications remain co | Feature | Temps | Vercel | Netlify | Railway | |---------|-------|--------|---------|---------| | **PostgreSQL** | ✅ Built-in | ⚠️ Vercel Postgres (extra) | ❌ External only | ✅ Built-in | +| **MariaDB / MySQL-compatible** | ✅ Built-in | ❌ External only | ❌ External only | ✅ Built-in | | **Redis** | ✅ Built-in | ⚠️ Vercel KV (extra) | ❌ External only | ✅ Built-in | | **S3 Storage** | ✅ MinIO-compatible | ⚠️ Vercel Blob (extra) | ❌ External only | ❌ External only | | **Connection Limits** | ❌ No limits | ✅ Metered/capped | ✅ Metered/capped | ⚠️ Plan-based | diff --git a/docs/howto/set-up-managed-services/page.mdx b/docs/howto/set-up-managed-services/page.mdx index bc0c46d76..21f92eb6f 100644 --- a/docs/howto/set-up-managed-services/page.mdx +++ b/docs/howto/set-up-managed-services/page.mdx @@ -1,7 +1,7 @@ export const metadata = { title: 'Set Up Managed Services', description: - 'Provision PostgreSQL, Redis, MongoDB, and S3 storage as Docker containers managed by Temps. Link them to projects for automatic credential injection.', + 'Provision PostgreSQL, MariaDB, Redis, MongoDB, and S3 storage as Docker containers managed by Temps. Link them to projects for automatic credential injection.', alternates: { canonical: '/docs/set-up-managed-services', }, @@ -27,6 +27,7 @@ Managed services are databases and storage systems that Temps provisions as Dock | Service | Default Image | Use case | |---------|--------------|----------| | **PostgreSQL** | `gotempsh/postgres-walg:18-bookworm` | Primary database with WAL-G streaming backups | +| **MariaDB** | `mariadb:lts` | MySQL-compatible relational database | | **Redis** | `gotempsh/redis-walg:8-bookworm` | Caching, sessions, pub/sub, and queues | | **MongoDB** | `gotempsh/mongodb-walg:8.0` | Document database | | **S3 Storage** | `rustfs/rustfs:1.0.0-alpha.78` | S3-compatible object storage (powered by RustFS) | @@ -38,6 +39,10 @@ Temps also provides higher-level abstractions built on these services: | **KV** | Redis | Key-value storage accessible via the Temps SDK | | **Blob** | RustFS | File/blob storage accessible via the Temps SDK | + +MariaDB and RustFS-backed services are opt-in. Installing or starting Temps does not pull or run a MariaDB container, and it does not pull or run RustFS unless you create or enable an S3, Blob, or RustFS service. Temps still uses PostgreSQL with TimescaleDB for its own internal database. + + --- ## Create a service {{ anchor: true, id: 'create-a-service' }} @@ -80,6 +85,18 @@ curl -X POST "https://your-temps-instance/api/external-services" \ | `username` | `postgres` | No (read-only) | | `password` | Auto-generated (16 chars) | No (read-only) | +**MariaDB:** + +| Parameter | Default | Editable after creation | +|-----------|---------|----------------------| +| `port` | Auto-assigned from 3306 | Yes | +| `docker_image` | `mariadb:lts` | Yes | +| `size_profile` | `small` | Yes | +| `database` | `app` | No (read-only) | +| `username` | `app` | No (read-only) | +| `password` | Auto-generated | No (read-only) | +| `root_password` | Auto-generated | No (read-only) | + **Redis:** | Parameter | Default | Editable after creation | @@ -139,9 +156,11 @@ curl -X POST "https://your-temps-instance/api/external-services/{service_id}/pro ### What happens when you link -1. **Per-project isolation** — For PostgreSQL, a dedicated database is created named `{project_slug}_{environment_slug}`. For Redis, a database number (0-15) is assigned. For S3, a dedicated bucket is created. +1. **Per-project isolation** — For PostgreSQL and MariaDB, a dedicated database is created named `{project_slug}_{environment_slug}`. For Redis, a database number (0-15) is assigned. For S3/RustFS, a dedicated bucket is created. 2. **Environment variables** — Connection credentials are generated using the Docker container name as the hostname (for internal networking) and injected on the next deploy. -3. **One per type** — Only one service of each type can be linked to a project. To switch PostgreSQL instances, unlink the current one first. +3. **One per type** — Only one service of each type can be linked to a project. To switch database instances of the same type, unlink the current one first. + +For MariaDB, Temps injects `DATABASE_URL`, `MYSQL_URL`, `MYSQL_*`, `MARIADB_URL`, and `MARIADB_*` variables. The URL scheme is `mysql://` for compatibility with common MySQL-compatible drivers and frameworks. ### Preview environment variables @@ -174,7 +193,7 @@ Unlinking does not delete the database or data inside the service — it only st ## Import an existing container {{ anchor: true, id: 'import-an-existing-container' }} -If you already have a PostgreSQL or Redis container running on your server, you can import it into Temps instead of creating a new one. +If you already have a PostgreSQL, MariaDB/MySQL-compatible, Redis, MongoDB, or S3-compatible container running on your server, you can import it into Temps instead of creating a new one. 1. Go to **Services** 2. Click **Import Container** diff --git a/docs/tutorials/deploy-laravel/page.mdx b/docs/tutorials/deploy-laravel/page.mdx index be3b058e0..f139fb5f7 100644 --- a/docs/tutorials/deploy-laravel/page.mdx +++ b/docs/tutorials/deploy-laravel/page.mdx @@ -1,6 +1,6 @@ export const metadata = { title: 'Deploy a Laravel App', - description: 'Deploy a Laravel application on Temps with PostgreSQL, queue workers, and automatic HTTPS.', + description: 'Deploy a Laravel application on Temps with PostgreSQL or MariaDB, queue workers, and automatic HTTPS.', alternates: { canonical: '/docs/deploy-laravel', }, @@ -8,14 +8,14 @@ export const metadata = { # Deploy a Laravel App -This tutorial walks you through deploying a Laravel application on Temps with PostgreSQL, Nginx, and PHP-FPM. By the end, you will have a production Laravel app with automatic HTTPS. {{ className: 'lead' }} +This tutorial walks you through deploying a Laravel application on Temps with PostgreSQL or MariaDB, Nginx, and PHP-FPM. By the end, you will have a production Laravel app with automatic HTTPS. {{ className: 'lead' }} --- ## What you will build {{ anchor: true, id: 'what-you-will-build' }} - A Laravel app deployed from a Git repository -- PostgreSQL database provisioned and connected +- PostgreSQL or MariaDB database provisioned and connected - Nginx + PHP-FPM in a single container - Automatic HTTPS and deployment on every push @@ -76,18 +76,20 @@ RUN composer install --no-dev --no-scripts --optimize-autoloader COPY . . RUN composer dump-autoload --optimize && \ - php artisan config:cache && \ - php artisan route:cache && \ - php artisan view:cache + php artisan config:clear COPY docker/nginx.conf /etc/nginx/http.d/default.conf RUN chown -R www-data:www-data storage bootstrap/cache EXPOSE 8080 -CMD sh -c "php-fpm -D && nginx -g 'daemon off;'" +CMD sh -c "php artisan config:cache && php artisan route:cache && php artisan view:cache && php-fpm -D && nginx -g 'daemon off;'" ``` +Laravel's config cache must be built at container start, not during the Docker +build. Temps injects `APP_KEY`, `DATABASE_URL`, and linked-service credentials +at runtime; caching config in the image would bake in empty or build-time values. + ### Create Nginx config ```nginx {{ title: 'docker/nginx.conf' }} @@ -131,6 +133,39 @@ bunx @temps-sdk/cli services create -p my-laravel-app -t postgres -n "laravel-db The `DATABASE_URL` environment variable is automatically injected. +### Using MariaDB instead + +If your Laravel app is already configured for MySQL, create a MariaDB service instead of PostgreSQL: + +```bash +bunx @temps-sdk/cli services create -p my-laravel-app -t mariadb -n "laravel-db" +``` + +Set `DB_CONNECTION=mysql`. Temps injects a MySQL-compatible `DATABASE_URL` plus `MYSQL_*` and `MARIADB_*` variables for the linked MariaDB service. + +If your Laravel config still reads only Laravel's default `DB_*` variables, either +set those aliases in Temps or add fallbacks to the variables injected by the +linked MariaDB service: + +```php {{ title: 'config/database.php (MariaDB/MySQL excerpt)' }} +'mysql' => [ + 'driver' => 'mysql', + 'url' => env('DB_URL', env('DATABASE_URL')), + 'host' => env('DB_HOST', env('MYSQL_HOST', env('MARIADB_HOST', '127.0.0.1'))), + 'port' => env('DB_PORT', env('MYSQL_PORT', env('MARIADB_PORT', '3306'))), + 'database' => env('DB_DATABASE', env('MYSQL_DATABASE', env('MARIADB_DATABASE', 'laravel'))), + 'username' => env('DB_USERNAME', env('MYSQL_USER', env('MARIADB_USER', 'root'))), + 'password' => env('DB_PASSWORD', env('MYSQL_PASSWORD', env('MARIADB_PASSWORD', ''))), +], +``` + +For a MariaDB/MySQL build, install the PHP MySQL extension instead of PostgreSQL support: + +```dockerfile +RUN apk add --no-cache nginx && \ + docker-php-ext-install pdo_mysql opcache +``` + --- ## Step 4: Connect your repository {{ anchor: true, id: 'connect-repo' }} @@ -168,11 +203,9 @@ bunx @temps-sdk/cli deploy my-laravel-app -b main -e production -y bunx @temps-sdk/cli exec my-laravel-app -- php artisan migrate --force ``` -For subsequent deployments, add to your start command: - -```dockerfile -CMD sh -c "php artisan migrate --force && php-fpm -D && nginx -g 'daemon off;'" -``` +Run migrations as a one-off release step for each deployment. Do not put +`php artisan migrate --force` in the container start command: rolling deploys, +restarts, or multiple replicas can start the command more than once. --- diff --git a/docs/tutorials/deploy-with-database/page.mdx b/docs/tutorials/deploy-with-database/page.mdx index cd982f44c..876ebe88e 100644 --- a/docs/tutorials/deploy-with-database/page.mdx +++ b/docs/tutorials/deploy-with-database/page.mdx @@ -1,7 +1,7 @@ export const metadata = { title: 'Deploy with a Database', description: - 'Provision a managed PostgreSQL database, connect it to your application, and deploy — all without leaving Temps.', + 'Provision a managed PostgreSQL or MariaDB database, connect it to your application, and deploy — all without leaving Temps.', alternates: { canonical: '/docs/deploy-with-database', }, @@ -11,6 +11,7 @@ export const sections = [ { title: 'What you will build', id: 'what-you-will-build' }, { title: 'Prerequisites', id: 'prerequisites' }, { title: 'Create a PostgreSQL service', id: 'create-a-postgresql-service' }, + { title: 'Use MariaDB instead', id: 'use-mariadb-instead' }, { title: 'Link the service to your project', id: 'link-the-service-to-your-project' }, { title: 'Understand the injected environment variables', id: 'understand-the-injected-environment-variables' }, { title: 'Update your application', id: 'update-your-application' }, @@ -22,7 +23,7 @@ export const sections = [ # Deploy with a Database -This tutorial walks you through provisioning a managed PostgreSQL database on Temps, connecting it to your application, and deploying. No external database providers, no connection string juggling — Temps runs the database on your server and wires everything together automatically. {{ className: 'lead' }} +This tutorial walks you through provisioning a managed database on Temps, connecting it to your application, and deploying. The main path uses PostgreSQL; the same workflow also supports MariaDB for MySQL-compatible applications. No external database providers, no connection string juggling — Temps runs the database on your server and wires everything together automatically. {{ className: 'lead' }} --- @@ -30,7 +31,7 @@ This tutorial walks you through provisioning a managed PostgreSQL database on Te By the end of this tutorial, you will have: -- A managed PostgreSQL database running as a Docker container on your server +- A managed PostgreSQL or MariaDB database running as a Docker container on your server - The database linked to your project with environment variables injected automatically - An application deployed and connected to the database - A Redis cache added as a bonus second service @@ -42,7 +43,7 @@ By the end of this tutorial, you will have: | Covered | Not covered | |---------|-------------| -| Provisioning PostgreSQL and Redis | MongoDB and S3 storage (same process, different service type) | +| Provisioning PostgreSQL or MariaDB and Redis | MongoDB and S3 storage (same process, different service type) | | Linking services to projects | Database migrations and schema management | | Automatic environment variable injection | Connection pooling and performance tuning | | Per-environment database isolation | Replication and high availability | @@ -98,6 +99,24 @@ When the status shows **Running**, your database is ready. --- +## Use MariaDB instead {{ anchor: true, id: 'use-mariadb-instead' }} + +Choose MariaDB when your application expects a MySQL-compatible database, such as many Laravel, WordPress, Rails, Node.js, or Go applications. Temps still uses PostgreSQL with TimescaleDB internally; MariaDB is only for hosted projects that opt into it. + +1. In the sidebar, click **Services** +2. Click **Create Service** +3. Select **MariaDB** as the service type +4. Keep the default `mariadb:lts` image, or provide another MariaDB/MySQL-compatible image +5. Pick a size profile for your host and click **Create Database** + +Temps pulls and starts the MariaDB container only when you create or import a MariaDB service. A fresh Temps install does not run MariaDB by default. + + +MariaDB services are designed to be shared. Link the same MariaDB service to multiple projects; Temps creates a separate `{project_slug}_{environment_slug}` database for each linked project/environment. + + +--- + ## Link the service to your project {{ anchor: true, id: 'link-the-service-to-your-project' }} Creating a service does not automatically connect it to any project. You need to explicitly link them. @@ -105,21 +124,21 @@ Creating a service does not automatically connect it to any project. You need to 1. Open your project in the dashboard 2. Click **Services** in the project sidebar 3. Click **Link Service** -4. Select the PostgreSQL service you just created +4. Select the database service you just created 5. Click **Link** That is it. The next time your project deploys, Temps will inject the database connection details as environment variables into your application container. ### What linking does under the hood -When you link a PostgreSQL service to a project, Temps: +When you link a PostgreSQL or MariaDB service to a project, Temps: -1. Creates a **dedicated database** inside the PostgreSQL instance named `{project_slug}_{environment_slug}` (e.g. `my_app_production`). Each project-environment combination gets its own isolated database. +1. Creates a **dedicated database** inside the service named `{project_slug}_{environment_slug}` (e.g. `my_app_production`). Each project-environment combination gets its own isolated database. 2. Generates **environment variables** with the connection details, using the Docker container name as the hostname (so containers can talk to each other over the Docker network). 3. Stores the link in a `project_services` table — only one service of each type can be linked per project. -**One service, many projects:** A single PostgreSQL service can be linked to multiple projects. Each project gets its own database inside the same PostgreSQL instance. This is efficient for a single server — you run one PostgreSQL container instead of one per project. +**One service, many projects:** A single PostgreSQL or MariaDB service can be linked to multiple projects. Each project gets its own database inside the same database server. This is efficient for a single server — you run one database container instead of one per project. --- @@ -154,6 +173,36 @@ When a PostgreSQL service is linked to your project, Temps injects these environ +### MariaDB variables + +When a MariaDB service is linked, Temps injects both MySQL-compatible and MariaDB-labeled variables: + + + + Full connection string using the `mysql://` scheme for driver compatibility: `mysql://app:PASSWORD@mariadb-SERVICE-NAME:3306/PROJECT_ENV_DB` + + + Aliases for the same connection string. + + + The MariaDB container hostname, e.g. `mariadb-my-app-db`. + + + The internal port: `3306`. + + + The per-project database name, e.g. `my_app_production`. + + + The application username. + + + The auto-generated application password. + + + +The URL intentionally uses `mysql://` because most frameworks, ORMs, and drivers treat MariaDB as MySQL-compatible at the driver layer. + ### Preview before deploying To see exactly which variables will be injected without deploying: diff --git a/docs/tutorials/set-up-backups-and-monitoring/page.mdx b/docs/tutorials/set-up-backups-and-monitoring/page.mdx index 181f48452..5f5f425c8 100644 --- a/docs/tutorials/set-up-backups-and-monitoring/page.mdx +++ b/docs/tutorials/set-up-backups-and-monitoring/page.mdx @@ -128,7 +128,7 @@ With storage connected, run a manual backup to verify everything works. Temps starts the backup immediately. The process: 1. **Temps database** — The internal PostgreSQL database (with TimescaleDB extension) is backed up first. Temps uses WAL-G for efficient streaming backups when available, falling back to `pg_dump` otherwise. -2. **Managed services** — Every managed service you have provisioned (PostgreSQL databases, Redis instances, S3 storage) is backed up individually. +2. **Managed services** — Every managed service you have provisioned (PostgreSQL databases, MariaDB databases, Redis instances, S3/RustFS storage) is backed up individually. 3. **Metadata** — A `metadata.json` file is generated with checksums, sizes, timestamps, and service inventory. 4. **Upload** — Everything is uploaded to your S3 bucket and the backup index is updated. @@ -477,7 +477,7 @@ This command: 1. Downloads the backup from S3 2. Decompresses the database dump 3. Restores the Temps PostgreSQL database using `pg_restore` -4. Restores all managed service backups (PostgreSQL databases, Redis instances, etc.) +4. Restores all managed service backups (PostgreSQL databases, MariaDB databases, Redis instances, etc.) **Step 3: Restart Temps** diff --git a/web/src/api/client/types.gen.ts b/web/src/api/client/types.gen.ts index 5eff4baca..3105ad315 100644 --- a/web/src/api/client/types.gen.ts +++ b/web/src/api/client/types.gen.ts @@ -13331,7 +13331,7 @@ export type ServiceTypeInfo = { service_type: ServiceTypeRoute; }; -export type ServiceTypeRoute = 'mongodb' | 'postgres' | 'redis' | 's3' | 'kv' | 'blob' | 'rustfs' | 'minio'; +export type ServiceTypeRoute = 'mariadb' | 'mongodb' | 'postgres' | 'redis' | 's3' | 'kv' | 'blob' | 'rustfs' | 'minio'; /** * Request body for updating an existing alert rule. diff --git a/web/src/components/forms/JsonSchemaForm.tsx b/web/src/components/forms/JsonSchemaForm.tsx index 67c4763c4..6b5a40e75 100644 --- a/web/src/components/forms/JsonSchemaForm.tsx +++ b/web/src/components/forms/JsonSchemaForm.tsx @@ -133,6 +133,16 @@ const FIELD_HINTS: Record> = { ssl_mode: { group: 'advanced' }, docker_image: { group: 'advanced' }, }, + mariadb: { + host: { group: 'connection', hiddenWhenManaged: true }, + port: { group: 'connection', hiddenWhenManaged: true }, + database: { group: 'credentials' }, + username: { group: 'credentials' }, + password: { group: 'credentials' }, + root_password: { group: 'credentials' }, + size_profile: { group: 'basic' }, + docker_image: { group: 'advanced' }, + }, redis: { host: { group: 'connection', hiddenWhenManaged: true }, port: { group: 'connection', hiddenWhenManaged: true }, diff --git a/web/src/components/forms/ServiceTypePresets.tsx b/web/src/components/forms/ServiceTypePresets.tsx index b45d10281..37f10d404 100644 --- a/web/src/components/forms/ServiceTypePresets.tsx +++ b/web/src/components/forms/ServiceTypePresets.tsx @@ -160,6 +160,7 @@ export function useServiceTypePreset( ): PresetState { // One hook call per possible preset keeps hook order stable. const postgres = usePostgresPreset() + const mariadb = useMariDbPreset() const redis = useRedisPreset() const mongodb = useMongodbPreset() const s3 = useS3Preset() @@ -167,6 +168,8 @@ export function useServiceTypePreset( switch (serviceType) { case 'postgres': return postgres + case 'mariadb': + return mariadb case 'redis': return redis case 'mongodb': @@ -180,6 +183,54 @@ export function useServiceTypePreset( } } +// ----------------------------------------------------------------------------- +// MariaDB preset — official MariaDB LTS image + custom. +// ----------------------------------------------------------------------------- + +const MARIADB_MANAGED_IMAGE = 'mariadb:lts' + +const MARIADB_OPTIONS: PresetOption[] = [ + { + id: 'managed', + title: 'MariaDB LTS', + subtitle: 'Official image', + value: MARIADB_MANAGED_IMAGE, + }, + { + id: 'custom', + title: 'Custom image', + subtitle: 'MariaDB-compatible', + custom: true, + }, +] + +function useMariDbPreset(): PresetState { + const [selected, setSelected] = useState('managed') + const [custom, setCustom] = useState('') + const option = MARIADB_OPTIONS.find((o) => o.id === selected) + const resolved = option?.value ?? (option?.custom ? custom.trim() : '') + const overrides: Record = { + docker_image: resolved || undefined, + } + + return { + overrides, + ownedFields: ['docker_image'], + ui: ( + + ), + } +} + // ----------------------------------------------------------------------------- // Postgres preset — only the managed walg image + custom (with PITR warning). // ----------------------------------------------------------------------------- diff --git a/web/src/components/project/ManualProjectConfigurator.tsx b/web/src/components/project/ManualProjectConfigurator.tsx index bda5383ba..e93b10223 100644 --- a/web/src/components/project/ManualProjectConfigurator.tsx +++ b/web/src/components/project/ManualProjectConfigurator.tsx @@ -62,6 +62,11 @@ const SERVICE_TYPES = [ name: 'PostgreSQL', description: 'Reliable Relational Database', }, + { + id: 'mariadb' as ServiceTypeRoute, + name: 'MariaDB', + description: 'Shared MySQL-compatible Database', + }, { id: 'redis' as ServiceTypeRoute, name: 'Redis', diff --git a/web/src/components/project/ProjectConfigurator.tsx b/web/src/components/project/ProjectConfigurator.tsx index 3e2c16027..4a02d027d 100644 --- a/web/src/components/project/ProjectConfigurator.tsx +++ b/web/src/components/project/ProjectConfigurator.tsx @@ -86,6 +86,11 @@ const SERVICE_TYPES = [ name: 'PostgreSQL', description: 'Reliable Relational Database', }, + { + id: 'mariadb' as ServiceTypeRoute, + name: 'MariaDB', + description: 'Shared MySQL-compatible Database', + }, { id: 'redis' as ServiceTypeRoute, name: 'Redis', diff --git a/web/src/components/storage/CreateServiceForm.tsx b/web/src/components/storage/CreateServiceForm.tsx index a9cc2ddad..a1821bd44 100644 --- a/web/src/components/storage/CreateServiceForm.tsx +++ b/web/src/components/storage/CreateServiceForm.tsx @@ -18,6 +18,13 @@ import { CollapsibleContent, CollapsibleTrigger, } from '@/components/ui/collapsible' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' import { zodResolver } from '@hookform/resolvers/zod' import { useMutation, useQuery } from '@tanstack/react-query' import { AlertTriangle, ChevronDown, ChevronRight } from 'lucide-react' @@ -103,6 +110,7 @@ type ParamFieldObj = { default_value?: string description?: string type?: string + enum_values?: string[] } function ParamField({ @@ -123,20 +131,38 @@ function ParamField({ {paramObj.required && *} - + {paramObj.enum_values && paramObj.enum_values.length > 0 ? ( + + ) : ( + + )} {paramObj.description && (

@@ -211,6 +237,7 @@ export function CreateServiceForm({ key.toLowerCase().includes('password') || key.toLowerCase().includes('secret'), validation_pattern: prop.pattern || undefined, + enum_values: Array.isArray(prop.enum) ? prop.enum : undefined, // Track if this field should be a number type: prop.type === 'integer' || @@ -395,6 +422,7 @@ export function CreateServiceForm({ default_value?: string description?: string type?: string + enum_values?: string[] } const valid = (parameters as unknown[]).filter( (p): p is ParamObj => diff --git a/web/src/components/templates/TemplateConfigurator.tsx b/web/src/components/templates/TemplateConfigurator.tsx index 7644e5727..618c4b598 100644 --- a/web/src/components/templates/TemplateConfigurator.tsx +++ b/web/src/components/templates/TemplateConfigurator.tsx @@ -106,6 +106,7 @@ function ProviderIcon({ // Common service types const SERVICE_TYPES = [ { id: 'postgres' as ServiceTypeRoute, name: 'PostgreSQL', description: 'Reliable Relational Database' }, + { id: 'mariadb' as ServiceTypeRoute, name: 'MariaDB', description: 'Shared MySQL-compatible Database' }, { id: 'redis' as ServiceTypeRoute, name: 'Redis', description: 'In-Memory Data Store' }, { id: 's3' as ServiceTypeRoute, name: 'S3 / RustFS', description: 'S3-compatible Object Storage' }, { id: 'libsql' as ServiceTypeRoute, name: 'LibSQL', description: 'SQLite-compatible Database' }, diff --git a/web/src/lib/service-type-detector.ts b/web/src/lib/service-type-detector.ts index f1772e3ff..7a09a4f86 100644 --- a/web/src/lib/service-type-detector.ts +++ b/web/src/lib/service-type-detector.ts @@ -6,7 +6,8 @@ import { ServiceTypeRoute } from '@/api/client/types.gen' * - "postgres:18-alpine" → "postgres" * - "mongo:latest" → "mongodb" * - "redis:7" → "redis" - * - "mysql:8" → "mysql" + * - "mariadb:lts" → "mariadb" + * - "mysql:8" → "mariadb" * - "rustfs/rustfs:1.0.0" → "rustfs" * - "minio/minio:latest" → "s3" (legacy) */ @@ -19,8 +20,8 @@ export function extractServiceTypeFromImage(image: string): ServiceTypeRoute | n const serviceTypeMap: Record = { postgres: 'postgres', postgresql: 'postgres', - mysql: 'mysql', - mariadb: 'mysql', + mysql: 'mariadb', + mariadb: 'mariadb', mongo: 'mongodb', mongodb: 'mongodb', redis: 'redis', diff --git a/web/src/lib/serviceIcons.ts b/web/src/lib/serviceIcons.ts index 4cd723c2c..c5c359964 100644 --- a/web/src/lib/serviceIcons.ts +++ b/web/src/lib/serviceIcons.ts @@ -101,13 +101,14 @@ export function serviceTypeRouteForEngine( if (!serviceType) return null const normalized = serviceType.toLowerCase() - if ( - normalized.startsWith('postgres') || - normalized === 'postgresql' - ) { + if (normalized.startsWith('postgres') || normalized === 'postgresql') { return 'postgres' } + if (normalized === 'mariadb' || normalized === 'mysql') { + return 'mariadb' + } + if (normalized === 'mongodb' || normalized === 'mongo') { return 'mongodb' }