diff --git a/.github/workflows/build-seeded-databases.yml b/.github/workflows/build-seeded-databases.yml new file mode 100644 index 000000000000..b66d10bafdae --- /dev/null +++ b/.github/workflows/build-seeded-databases.yml @@ -0,0 +1,230 @@ +name: Build Seeded Database Images + +on: + schedule: + - cron: "0 2 * * 0" + workflow_dispatch: + inputs: + preset: + description: "Preset name to build (empty = curated default list, all = every preset)" + required: false + type: string + database: + description: "Database type to build (all = default matrix)" + required: false + default: all + type: choice + options: + - all + - postgres + - mysql + - mariadb + - mssql + +env: + _AZ_REGISTRY: bitwardenprod.azurecr.io + _DEFAULT_PRESETS: >- + ["qa.dunder-mifflin-enterprise-full", + "scale.md-balanced-sterling-cooper", + "scale.lg-balanced-wayne-enterprises", + "scale.lg-highperm-tyrell-corp", + "scale.xl-broad-initech"] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-${{ inputs.preset }}-${{ inputs.database }} + cancel-in-progress: true + +jobs: + setup: + name: Determine build matrix + runs-on: ubuntu-24.04 + outputs: + presets: ${{ steps.matrix.outputs.presets }} + databases: ${{ steps.matrix.outputs.databases }} + steps: + - name: Check out repo + if: inputs.preset == 'all' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up .NET + if: inputs.preset == 'all' + uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 + with: + cache: true + cache-dependency-path: "**/*.csproj" + + - name: Build SeederUtility + if: inputs.preset == 'all' + run: dotnet build util/SeederUtility/SeederUtility.csproj + + - name: Determine matrix + id: matrix + env: + INPUT_PRESET: ${{ inputs.preset }} + INPUT_DATABASE: ${{ inputs.database }} + run: | + if [[ "${INPUT_PRESET}" == "all" ]]; then + presets=$(dotnet run --project util/SeederUtility --no-build -- preset --list --output json \ + | jq -c '[.organization[], .individual[]]') + elif [[ -n "${INPUT_PRESET}" ]]; then + presets="[\"${INPUT_PRESET}\"]" + else + presets="${_DEFAULT_PRESETS}" + fi + + if [[ -n "${INPUT_DATABASE}" && "${INPUT_DATABASE}" != "all" ]]; then + databases="[\"${INPUT_DATABASE}\"]" + else + databases='["postgres","mysql","mariadb","mssql"]' + fi + + echo "presets=${presets}" >> "$GITHUB_OUTPUT" + echo "databases=${databases}" >> "$GITHUB_OUTPUT" + echo "Preset matrix: ${presets}" + echo "Database matrix: ${databases}" + + build: + name: ${{ matrix.database }} / ${{ matrix.preset }} + needs: setup + runs-on: ubuntu-24.04 + permissions: + contents: read + id-token: write + strategy: + matrix: + preset: ${{ fromJson(needs.setup.outputs.presets) }} + database: ${{ fromJson(needs.setup.outputs.databases) }} + fail-fast: false + + steps: + - name: Check out repo + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up .NET + uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 + with: + cache: true + cache-dependency-path: "**/*.csproj" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + + - name: Set up QEMU + uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0 + + - name: Restore .NET local tools + run: dotnet tool restore + + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main + with: + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} + + - name: Retrieve data protection key + id: retrieve-secret + uses: bitwarden/gh-actions/get-keyvault-secrets@main + with: + keyvault: gh-org-bitwarden + secrets: "DP-KEY-XML" + + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + + - name: Build seeded image + env: + PUSH: "false" + REGISTRY: ${{ env._AZ_REGISTRY }} + GIT_SHA: ${{ github.sha }} + MATRIX_PRESET: ${{ matrix.preset }} + MATRIX_DATABASE: ${{ matrix.database }} + DP_KEY_XML: ${{ steps.retrieve-secret.outputs.DP-KEY-XML }} + run: | + GIT_SHA="${GIT_SHA:0:7}" \ + bash util/SeederUtility/scripts/build-seeded-image.sh \ + "${MATRIX_PRESET}" \ + "${MATRIX_DATABASE}" + + - name: Save image as tarball + env: + MATRIX_PRESET: ${{ matrix.preset }} + MATRIX_DATABASE: ${{ matrix.database }} + run: | + TAG="${MATRIX_PRESET//./-}" + DB="${MATRIX_DATABASE}" + SHA_SHORT="${GITHUB_SHA:0:7}" + docker save \ + "${_AZ_REGISTRY}/shot/seeded-${DB}:${TAG}-latest" \ + "${_AZ_REGISTRY}/shot/seeded-${DB}:${TAG}-${SHA_SHORT}" \ + -o "seeded-${DB}-${TAG}-${SHA_SHORT}.tar" + + - name: Upload image artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: seeded-${{ matrix.database }}-${{ matrix.preset }} + path: seeded-*.tar + retention-days: 7 + if-no-files-found: error + + - name: Stage attachment blobs + run: | + mkdir -p bundle-out + for f in util/SeederUtility/docker/bundles/seeded-core-*.tar.gz; do + tar -xzf "${f}" -C bundle-out + done + + - name: Upload attachment blobs + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: seeded-attachments-${{ matrix.database }}-${{ matrix.preset }} + path: bundle-out/core/attachments/** + retention-days: 7 + if-no-files-found: ignore + + summary: + name: Image summary + needs: [setup, build] + if: always() + runs-on: ubuntu-24.04 + permissions: + contents: read + actions: read + steps: + - name: Render image table + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PRESETS: ${{ needs.setup.outputs.presets }} + DATABASES: ${{ needs.setup.outputs.databases }} + GIT_SHA: ${{ github.sha }} + run: | + SHA_SHORT="${GIT_SHA:0:7}" + results=$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/jobs" \ + --paginate --jq '.jobs[] | [.name, .conclusion] | @tsv') + + { + echo "### Seeded images for \`${SHA_SHORT}\`" + echo "" + echo "| Preset | Database | Image |" + echo "| --- | --- | --- |" + while read -r db; do + while read -r preset; do + tag="${preset//./-}" + conclusion=$(printf '%s\n' "${results}" \ + | awk -F'\t' -v name="${db} / ${preset}" '$1 == name { print $2; exit }') + if [ "${conclusion}" != "success" ]; then + continue + fi + echo "| ${preset} | ${db} | \`${_AZ_REGISTRY}/shot/seeded-${db}:${tag}-${SHA_SHORT}\` |" + done < <(echo "${PRESETS}" | jq -r '.[]') + done < <(echo "${DATABASES}" | jq -r '.[]') + echo "" + echo "Images are built, not pushed. Download them from this run's artifacts." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/util/Seeder/Seeds/docs/presets.md b/util/Seeder/Seeds/docs/presets.md index a0610dfc943e..e5edecee87d2 100644 --- a/util/Seeder/Seeds/docs/presets.md +++ b/util/Seeder/Seeds/docs/presets.md @@ -2,6 +2,8 @@ Complete catalog of all seeder presets, organized by purpose. Use `--mangle` to avoid collisions with existing data. +Some of these presets make up the default build list in `.github/workflows/build-seeded-databases.yml` under the`_DEFAULT_PRESETS` variable. Removing one requires updating that list with the same change. + ## Cipher generation knobs These options apply to any preset that uses generated (count-based) ciphers — QA, Scale, and Individual alike. Add them to the `"ciphers"` or `"personalCiphers"` block in the preset JSON. Schema reference: `Seeds/schemas/preset.schema.json`. diff --git a/util/SeederUtility/.gitignore b/util/SeederUtility/.gitignore new file mode 100644 index 000000000000..20a75880f5a2 --- /dev/null +++ b/util/SeederUtility/.gitignore @@ -0,0 +1,9 @@ +docker/dp-keys/ +docker/licenses/ +docker/bundles/ +docker/seed.sql +docker/**/seed.sql +docker/**/seed.bak +docker/**/*.mdf +docker/**/*.ldf +docker/*/build/ diff --git a/util/SeederUtility/Configuration/ServiceCollectionExtension.cs b/util/SeederUtility/Configuration/ServiceCollectionExtension.cs index 870ddb4928fb..7d33d53fcf6d 100644 --- a/util/SeederUtility/Configuration/ServiceCollectionExtension.cs +++ b/util/SeederUtility/Configuration/ServiceCollectionExtension.cs @@ -3,6 +3,7 @@ using Bit.Core.Entities; using Bit.Core.Services; using Bit.Core.Settings; +using Bit.Core.Utilities; using Bit.Seeder.Pipeline; using Bit.Seeder.Services; using Bit.SharedWeb.Utilities; @@ -34,7 +35,11 @@ public static void ConfigureServices(ServiceCollection services, bool enableMang services.AddSingleton, PasswordHasher>(); services.TryAddSingleton(); - services.AddDataProtection().SetApplicationName("Bitwarden"); + var dpBuilder = services.AddDataProtection().SetApplicationName("Bitwarden"); + if (CoreHelpers.SettingHasValue(globalSettings.DataProtection.Directory)) + { + dpBuilder.PersistKeysToFileSystem(new DirectoryInfo(globalSettings.DataProtection.Directory)); + } services.AddAttachmentStorageService(globalSettings); diff --git a/util/SeederUtility/docker/mariadb/Dockerfile b/util/SeederUtility/docker/mariadb/Dockerfile new file mode 100644 index 000000000000..126456ba6334 --- /dev/null +++ b/util/SeederUtility/docker/mariadb/Dockerfile @@ -0,0 +1,16 @@ +FROM mariadb:12 + +ARG PRESET_NAME=unknown +ARG PRESET_CATEGORY=unknown +ARG GIT_SHA=unknown +ARG BUILD_DATE=unknown + +LABEL bitwarden.seeder.preset="${PRESET_NAME}" +LABEL bitwarden.seeder.category="${PRESET_CATEGORY}" +LABEL org.opencontainers.image.revision="${GIT_SHA}" +LABEL org.opencontainers.image.created="${BUILD_DATE}" + +ENV MARIADB_DATABASE=vault_dev +ENV MARIADB_ROOT_PASSWORD=Password1! + +COPY seed.sql /docker-entrypoint-initdb.d/seed.sql diff --git a/util/SeederUtility/docker/mssql/Dockerfile b/util/SeederUtility/docker/mssql/Dockerfile new file mode 100644 index 000000000000..94178cab43aa --- /dev/null +++ b/util/SeederUtility/docker/mssql/Dockerfile @@ -0,0 +1,29 @@ +FROM mcr.microsoft.com/mssql/server:2025-CU5-ubuntu-24.04 + +ARG PRESET_NAME=unknown +ARG PRESET_CATEGORY=unknown +ARG GIT_SHA=unknown +ARG BUILD_DATE=unknown + +LABEL bitwarden.seeder.preset="${PRESET_NAME}" +LABEL bitwarden.seeder.category="${PRESET_CATEGORY}" +LABEL org.opencontainers.image.revision="${GIT_SHA}" +LABEL org.opencontainers.image.created="${BUILD_DATE}" + +ENV ACCEPT_EULA=Y +ENV MSSQL_PID=Developer + +USER root +RUN mkdir -p /seed +COPY vault_dev.mdf /seed/vault_dev.mdf +COPY vault_dev_log.ldf /seed/vault_dev_log.ldf +COPY docker-entrypoint.sh /docker-entrypoint.sh +RUN chmod +x /docker-entrypoint.sh + +# Healthy only once the seed is attached, so dependents can wait on it rather than +# racing the attach and migrating an empty database into place +HEALTHCHECK --interval=10s --timeout=15s --retries=90 --start-period=30s \ + CMD /opt/mssql-tools18/bin/sqlcmd -S localhost -U SA -P "${SA_PASSWORD}" -C \ + -d vault -b -Q "SET NOCOUNT ON; IF NOT EXISTS (SELECT 1 FROM [User]) RAISERROR('unseeded', 16, 1)" > /dev/null 2>&1 + +ENTRYPOINT ["/docker-entrypoint.sh"] diff --git a/util/SeederUtility/docker/mssql/docker-entrypoint.sh b/util/SeederUtility/docker/mssql/docker-entrypoint.sh new file mode 100644 index 000000000000..f4d198a73bb1 --- /dev/null +++ b/util/SeederUtility/docker/mssql/docker-entrypoint.sh @@ -0,0 +1,100 @@ +#!/bin/bash +# Starts SQL Server, waits for it to be ready, then attaches the seeded database files. +set -e + +/opt/mssql/bin/sqlservr & +SQLSERVR_PID=$! + +# PID 1 discards SIGTERM by default, so forward it and let sqlservr shut down cleanly +term_handler() { + kill -TERM "${SQLSERVR_PID}" 2>/dev/null || true + wait "${SQLSERVR_PID}" || true + exit 143 +} +trap term_handler TERM INT + +sqlcmd() { + /opt/mssql-tools18/bin/sqlcmd -S localhost -U SA -P "${SA_PASSWORD}" -C "$@" +} + +# Polls the given command every 2s, failing the container if it never succeeds +wait_for() { + local what="$1" attempts="$2" + shift 2 + echo "Waiting for ${what}..." + for _ in $(seq 1 "${attempts}"); do + if "$@"; then + echo "${what}: ready." + return 0 + fi + sleep 2 + done + echo "ERROR: timed out waiting for ${what}" + return 1 +} + +accepts_connections() { + sqlcmd -Q "SELECT 1" > /dev/null 2>&1 +} + +system_databases_online() { + local offline + offline=$(sqlcmd -h -1 \ + -Q "SET NOCOUNT ON; SELECT COUNT(*) FROM sys.databases WHERE database_id <= 4 AND state_desc <> 'ONLINE'" \ + 2>/dev/null | tr -d '[:space:]') + [ "${offline}" = "0" ] +} + +# Creating and dropping a database proves the data directory is writable +data_directory_writable() { + sqlcmd -Q "CREATE DATABASE [__attach_ready]; DROP DATABASE [__attach_ready]" > /dev/null 2>&1 +} + +wait_for "SQL Server connections" 60 accepts_connections +wait_for "system databases online" 60 system_databases_online +wait_for "writable data directory" 30 data_directory_writable + +DATA_PATH=$(sqlcmd -h -1 \ + -Q "SET NOCOUNT ON; SELECT CAST(SERVERPROPERTY('InstanceDefaultDataPath') AS NVARCHAR(512))" \ + 2>/dev/null | tr -d '\r\n ') +echo "MSSQL default data path: ${DATA_PATH}" + +database_exists() { + local count + count=$(sqlcmd -h -1 \ + -Q "SET NOCOUNT ON; SELECT COUNT(*) FROM sys.databases WHERE name = 'vault'" \ + 2>/dev/null | tr -d '[:space:]') + [ "${count}" = "1" ] +} + +# Counts rows, since a zero-row SELECT is not a sqlcmd error +database_seeded() { + local count + count=$(sqlcmd -b -h -1 -d vault \ + -Q "SET NOCOUNT ON; SELECT COUNT(*) FROM [User]" \ + 2>/dev/null | tr -d '[:space:]') + [ -n "${count}" ] && [ "${count}" -gt 0 ] 2>/dev/null +} + +# The data directory is usually a mounted volume, so vault survives a restart +if database_exists; then + if ! database_seeded; then + echo "ERROR: a 'vault' database exists but holds no seeded data." + echo "Something created it before this image could attach the seed. Start the database" + echo "and wait for it to report healthy before starting anything that migrates." + exit 1 + fi + echo "Database 'vault' is already attached. Leaving it as is." +else + echo "Copying database files to data directory..." + cp /seed/vault_dev.mdf "${DATA_PATH}vault.mdf" + cp /seed/vault_dev_log.ldf "${DATA_PATH}vault_log.ldf" + + # -b exits non-zero on a T-SQL error so a failed attach does not log success + echo "Attaching seeded database..." + sqlcmd -b -Q "CREATE DATABASE [vault] ON (FILENAME = '${DATA_PATH}vault.mdf'), (FILENAME = '${DATA_PATH}vault_log.ldf') FOR ATTACH" + + echo "Attach complete." +fi + +wait "${SQLSERVR_PID}" diff --git a/util/SeederUtility/docker/mysql/Dockerfile b/util/SeederUtility/docker/mysql/Dockerfile new file mode 100644 index 000000000000..7949784a770c --- /dev/null +++ b/util/SeederUtility/docker/mysql/Dockerfile @@ -0,0 +1,16 @@ +FROM mysql:8.0 + +ARG PRESET_NAME=unknown +ARG PRESET_CATEGORY=unknown +ARG GIT_SHA=unknown +ARG BUILD_DATE=unknown + +LABEL bitwarden.seeder.preset="${PRESET_NAME}" +LABEL bitwarden.seeder.category="${PRESET_CATEGORY}" +LABEL org.opencontainers.image.revision="${GIT_SHA}" +LABEL org.opencontainers.image.created="${BUILD_DATE}" + +ENV MYSQL_DATABASE=vault_dev +ENV MYSQL_ROOT_PASSWORD=Password1! + +COPY seed.sql /docker-entrypoint-initdb.d/seed.sql diff --git a/util/SeederUtility/docker/postgres/Dockerfile b/util/SeederUtility/docker/postgres/Dockerfile new file mode 100644 index 000000000000..d83cc0fb067c --- /dev/null +++ b/util/SeederUtility/docker/postgres/Dockerfile @@ -0,0 +1,16 @@ +FROM postgres:14 + +ARG PRESET_NAME=unknown +ARG PRESET_CATEGORY=unknown +ARG GIT_SHA=unknown +ARG BUILD_DATE=unknown + +LABEL bitwarden.seeder.preset="${PRESET_NAME}" +LABEL bitwarden.seeder.category="${PRESET_CATEGORY}" +LABEL org.opencontainers.image.revision="${GIT_SHA}" +LABEL org.opencontainers.image.created="${BUILD_DATE}" + +ENV POSTGRES_DB=vault_dev +ENV POSTGRES_PASSWORD=Password1! + +COPY seed.sql /docker-entrypoint-initdb.d/seed.sql diff --git a/util/SeederUtility/docker/sqlite/Dockerfile b/util/SeederUtility/docker/sqlite/Dockerfile new file mode 100644 index 000000000000..6971e31f3fe9 --- /dev/null +++ b/util/SeederUtility/docker/sqlite/Dockerfile @@ -0,0 +1,16 @@ +FROM busybox:stable + +ARG PRESET_NAME=unknown +ARG PRESET_CATEGORY=unknown +ARG GIT_SHA=unknown +ARG BUILD_DATE=unknown + +LABEL bitwarden.seeder.preset="${PRESET_NAME}" +LABEL bitwarden.seeder.category="${PRESET_CATEGORY}" +LABEL org.opencontainers.image.revision="${GIT_SHA}" +LABEL org.opencontainers.image.created="${BUILD_DATE}" + +# The SQLite database file — mounted by the application at runtime +COPY seed.db /seed.db + +CMD ["sh", "-c", "cp /seed.db /data/vault_dev.db && echo 'SQLite seed copied'"] diff --git a/util/SeederUtility/scripts/README.md b/util/SeederUtility/scripts/README.md new file mode 100644 index 000000000000..6e96457ccefd --- /dev/null +++ b/util/SeederUtility/scripts/README.md @@ -0,0 +1,285 @@ +# Seeded Database Build Pipeline + +Builds pre-seeded database Docker images from seeder presets, so a deployment can start from seeded data without running the seeder itself. + +## Quick Start + +```bash +# Build a single preset for postgres (default) +./build-seeded-image.sh qa.dunder-mifflin-enterprise-full + +# Build for a specific database type +./build-seeded-image.sh qa.dunder-mifflin-enterprise-full mssql + +# Build and push to ACR +PUSH=true ./build-seeded-image.sh qa.dunder-mifflin-enterprise-full postgres + +# List all available presets +dotnet run --project .. -- preset --list --output json +``` + +## Supported Database Types + +| Type | Base Image | Seed Method | +|------|-----------|-------------| +| `postgres` | `postgres:14` | `pg_dump` → init SQL script | +| `mysql` | `mysql:8.0` | `mysqldump` → init SQL script | +| `mariadb` | `mariadb:12` | `mysqldump` → init SQL script | +| `mssql` | `mcr.microsoft.com/mssql/server:2025-CU5-ubuntu-24.04` | MDF/LDF file copy → `CREATE DATABASE ... FOR ATTACH` | +| `sqlite` | `busybox:stable` | Direct `.db` file copy | + +`mysql` and `mariadb` share the same migrations project and seeded data; they differ only in the engine the dump is produced from and restored into. + +`sqlite` is local only for now. The workflow's database matrix covers the other four, so no CI run produces a sqlite image. + +### MSSQL Notes + +- MSSQL uses file attach (`CREATE DATABASE ... FOR ATTACH`) instead of `.bak` restore. The `.bak` restore approach fails on Kubernetes PVCs due to `ValidateTargetForCreation` errors — a known issue with MSSQL on certain storage backends. +- The entrypoint waits for all system databases to be ONLINE and verifies the data directory is writable (by creating and dropping a test database) before attempting the attach. +- The database is restored as `vault` (matching the self-host chart's connection string), not `vault_dev` (the seeder's default name). + +## Image Tags + +Each build produces two tags: + +- **Latest**: `seeded-{db}:{preset-name}-latest` — e.g. `seeded-postgres:qa-dunder-mifflin-enterprise-full-latest`. Moves with every build. +- **Versioned**: `seeded-{db}:{preset-name}-{git-sha}` — e.g. `seeded-postgres:qa-dunder-mifflin-enterprise-full-abc1234`. Immutable, so a deployment can pin a known build. + +Either tag works with any copy of the data protection key, because CI pins one key for every build. + +Local builds tag for `bitwardenprod.azurecr.io/shot/` and push there only when you pass `PUSH=true`. The GitHub Actions workflow sets `PUSH: "false"` and has no registry login, so nothing it builds reaches the registry. Take those images from the run's artifacts instead. + +## Getting an image from a CI build + +Each matrix job uploads the image as an artifact named after the database and preset. Artifacts are deleted 7 days after the run. + +```bash +RUN=31203415095 +PRESET=qa.dunder-mifflin-enterprise-full + +gh run download "$RUN" --name "seeded-postgres-$PRESET" +docker load -i seeded-postgres-*.tar +``` + +### Getting the data protection key + +CI does not publish the key, so fetch it from Key Vault yourself. Every build shares the same key, so you only do this once per environment. + +`DP-KEY-XML` exists to encrypt seeded fixtures and nothing else. Do not reuse it in an environment that holds real vault data. + +```bash +mkdir -p ~/bitwarden-seed/core/aspnet-dataprotection +az keyvault secret show --vault-name gh-org-bitwarden --name DP-KEY-XML --query value -o tsv \ + > ~/bitwarden-seed/core/aspnet-dataprotection/key-9aa06f19-9afe-414b-8791-189be3b5650f.xml +``` + +Attachment blobs go to a separate artifact, `seeded-attachments-{db}-{preset}`, holding `{cipherId}/{attachmentId}` at its root. Presets without attachments upload nothing, so the artifact is absent. + +```bash +gh run download "$RUN" --name "seeded-attachments-postgres-$PRESET" \ + -D ~/bitwarden-seed/core/attachments +``` + +Start the database: + +```bash +docker run -d -p 5432:5432 \ + bitwardenprod.azurecr.io/shot/seeded-postgres:qa-dunder-mifflin-enterprise-full-latest +``` + +The seed runs on first boot for postgres, mysql, and mariadb, so the server accepts connections before the data is loaded. Poll for a seeded table rather than trusting `pg_isready`: + +```bash +until docker exec psql -U postgres -d vault_dev \ + -tAc 'select 1 from "Organization" limit 1' >/dev/null 2>&1; do sleep 2; done +``` + +## Traceability + +Traceability lives entirely in the Docker image labels (`docker inspect`): + +``` +bitwarden.seeder.preset=qa.dunder-mifflin-enterprise-full +bitwarden.seeder.category=qa +org.opencontainers.image.revision=abc1234 +org.opencontainers.image.created=2026-04-16T00:00:00Z +``` + +The category is derived from the preset name prefix, which matches the fixture folder under `Seeds/fixtures/presets/`. + +## Core Bundle + +The application reads two of the seeder's outputs, not the database, so the database image cannot carry them. Data protection keys come first: the seeder encrypts `MasterPassword`, `Key`, and `PrivateKey` with ASP.NET Data Protection, and logins fail without the same key. Attachment blobs are the other, since the database holds only attachment metadata. + +In a deployment both live under `/etc/bitwarden/core`, so each build writes them to one tarball next to the image, which CI does not publish: + +``` +docker/bundles/seeded-core-{db}-{preset}-{git-sha}.tar.gz +└── core/ + ├── aspnet-dataprotection/key-….xml + └── attachments/{cipherId}/{attachmentId} +``` + +CI pulls the key from the `gh-org-bitwarden` Azure Key Vault as `DP-KEY-XML` and passes it in as `DP_KEY_XML`, so every build and every database in a build share one key and their bundles are interchangeable. A local build without `DP_KEY_XML` falls back to `docker/dp-keys/`. With neither, the build fails rather than letting Data Protection mint a throwaway key, which would produce an image whose encrypted fields open only with that one build's bundle. + +### Consuming the bundle + +A local build leaves the tarball in `docker/bundles/`. Its `core/` layout matches classic self-host, where the app reads `/etc/bitwarden/core`, so unpack it over that volume: + +```bash +tar -xzf seeded-core-*.tar.gz -C /etc/bitwarden +``` + +Coming from a CI build there is no tarball to unpack — assemble the same `core/` layout by hand, as described in [Getting the data protection key](#getting-the-data-protection-key). + +BW Lite reads different paths, so the same command puts the key somewhere lite never reads and login fails. See [Running BW Lite against a seeded image](#running-bw-lite-against-a-seeded-image) for the layout it expects. + +For local development on local disk, unpack anywhere and point the app at it: + +``` +globalSettings__attachment__baseDirectory=/core/attachments +globalSettings__dataProtection__directory=/core/aspnet-dataprotection +``` + +For local development on azurite, the attachment paths in the tarball match Azure blob names exactly, so import the tree as-is: + +```bash +az storage blob upload-batch \ + --connection-string "UseDevelopmentStorage=true" \ + -d attachments -s core/attachments +``` + +Leave `attachment.connectionString` set (azurite wins over `baseDirectory`) and point `dataProtection.directory` at the unpacked keys. + +> The bundled key is the **filesystem** form. Deployments using `PersistKeysToAzureBlobStorage` expect a single aggregated `keys.xml` in an `aspnet-dataprotection` container instead, so the key needs converting for that path. + +## Running BW Lite against a seeded image + +Load the image and fetch the key first, as described in [Getting an image from a CI build](#getting-an-image-from-a-ci-build). + +Lite reads `/etc/bitwarden/data-protection`, `/etc/bitwarden/attachments`, and `/etc/bitwarden/licenses`, which do not match the bundle's `core/` layout. Stage a directory in the shape lite expects: + +```bash +mkdir -p ~/bwlite-etc/{data-protection,attachments,licenses/organization,licenses/user} +cp ~/bitwarden-seed/core/aspnet-dataprotection/*.xml ~/bwlite-etc/data-protection/ +``` + +For attachment presets, also copy `core/attachments/` into `~/bwlite-etc/attachments/`. + +Start the database on a named network: + +```bash +docker network create bwlite +docker run -d --name bwlite-db --network bwlite -p 5433:5432 bitwardenprod.azurecr.io/shot/seeded-postgres:scale-lg-balanced-wayne-enterprises-latest +``` + +Start lite against it: + +```bash +docker run -d --name bwlite --network bwlite -p 8080:8080 -v "$HOME/bwlite-etc:/etc/bitwarden" -e BW_DOMAIN=localhost:8080 -e BW_DB_PROVIDER=postgresql -e BW_DB_SERVER=bwlite-db -e BW_DB_PORT=5432 -e BW_DB_DATABASE=vault_dev -e BW_DB_USERNAME=postgres -e BW_DB_PASSWORD='Password1!' -e BW_INSTALLATION_ID=e6b8a9c4-0d3f-4a71-9c2e-5f7a1b3d8e02 -e BW_INSTALLATION_KEY=seederlocaltest ghcr.io/bitwarden/lite:beta +``` + +Confirm all six services start: + +```bash +docker logs bwlite 2>&1 | grep -E "entered RUNNING state|FATAL state" +``` + +Open `http://localhost:8080` and log in as the preset's owner. Seeded accounts use the password `asdfasdfasdf` unless the preset overrides it. + +Notes: + +- Pass `BW_INSTALLATION_ID`, not `globalSettings__installation__id`. The entrypoint overwrites the latter with an empty string, and every service then dies on a Guid parse error. The symptom is a supervisord loop of `terminated by SIGABRT` and `entered FATAL state`, with only nginx surviving. +- A successful login confirms the data protection key is correct. The seeder encrypts `MasterPassword`, `Key`, and `PrivateKey`, so nothing authenticates without it. +- Admins see only the collections assigned to them, because presets leave `AllowAdminAccessToAllCollectionItems` off. The owner of a large org sees a small slice of it. +- Seeded organizations have no license file, so `ValidateOrganizationsAsync` disables them within twelve hours on self-host. Short sessions are unaffected. + +## Running self-host against a seeded image + +Load the image and fetch the key as described in [Getting an image from a CI build](#getting-an-image-from-a-ci-build). Self-host reads `/etc/bitwarden/core`, which matches the `core/` layout staged there, so it copies straight into `bwdata`. + +Get an installation id from https://bitwarden.com/host. The Setup container validates it against the Bitwarden API, so an invented one fails. + +```bash +./bitwarden.sh install ~/bwdata +``` + +Swap the database in `bwdata/docker/docker-compose.override.yml`, which `run.sh` merges automatically: + +```yaml +services: + mssql: + image: bitwardenprod.azurecr.io/shot/seeded-mssql:qa-dunder-mifflin-enterprise-full-latest +``` + +To pull that image, run `az acr login -n bitwardenprod` first. The registry refuses anonymous pulls, and `run.sh` runs `docker compose pull` on every start. + +An image loaded from a CI artifact also needs `pull_policy: never`, because the tag names a registry it was never pushed to and the pull fails without it. + +Gate `admin` on the database. It migrates at startup, and on a fresh volume it will create an empty `vault` before the seed finishes attaching, leaving a schema with no data. The image reports healthy only once the seed is attached: + +```yaml +services: + mssql: + image: bitwardenprod.azurecr.io/shot/seeded-mssql:qa-dunder-mifflin-enterprise-full-abc1234 + pull_policy: never + + admin: + depends_on: + mssql: + condition: service_healthy +``` + +Copy the key into place, then start: + +```bash +mkdir -p ~/bwdata/core/aspnet-dataprotection +cp ~/bitwarden-seed/core/aspnet-dataprotection/*.xml ~/bwdata/core/aspnet-dataprotection/ +./bitwarden.sh start ~/bwdata +``` + +For attachment presets, also copy `core/attachments/` into `~/bwdata/core/attachments/`. + +Log in at the URL the installer prints, using the preset's owner account. + +### Match the app version to the image + +An image older than the deployment is fine. Admin migrates the seeded database forward on startup, keeping the data. + +The other direction breaks. `bitwarden.sh` pins a released core version, so an image built from `main` can be missing procedures that release still calls, and no migration can restore a dropped one. Login fails with `Could not find stored procedure`. Build from the release branch the deployment runs, or pin the app images to a tag built from the same commit. + +Rule out the cheaper cause first. SQL Server has no arm64 build, so on Apple Silicon it runs emulated and can hit an assertion failure that leaves it reporting existing procedures as missing. Run `docker restart bitwarden-mssql` and try again before chasing a version mismatch. + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `PUSH` | `false` | Set to `true` to push images to ACR | +| `REGISTRY` | `bitwardenprod.azurecr.io` | ACR registry | +| `GIT_SHA` | Current HEAD | Git SHA for versioned tag | +| `DP_KEY_XML` | (empty) | Data protection key XML content. CI supplies this from Key Vault; locally it falls back to `docker/dp-keys/` | +| `KEEP_BUILD_DIR` | (unset) | Set to `1` to preserve the per-preset build directory | + +## GitHub Actions + +The workflow at `.github/workflows/build-seeded-databases.yml` supports: + +- **Manual dispatch**: Build a single preset + database type. Leave `preset` empty for the curated default list, or set it to `all` to build every preset. Leave `database` as `all` to build the full database matrix. +- **Cron**: Every Sunday at 2am UTC, rebuilds the curated default preset list (`_DEFAULT_PRESETS`) × all database types + +The workflow uses a matrix strategy (`preset × database`) with `fail-fast: false`. + +## Using seeded images with the self-host Helm chart + +Point the chart's database image at a seeded tag in [bitwarden/charts](https://github.com/bitwarden/charts): + +```yaml +# values.yaml +self-host: + database: + image: + name: bitwardenprod.azurecr.io/shot/seeded-mssql + tag: qa-dunder-mifflin-enterprise-full-latest +``` + +**Note**: The chart also needs the [data protection key](#getting-the-data-protection-key) at `/etc/bitwarden/core/aspnet-dataprotection`, plus `core/attachments` for attachment presets. Login fails against seeded data without the key. diff --git a/util/SeederUtility/scripts/build-seeded-image.sh b/util/SeederUtility/scripts/build-seeded-image.sh new file mode 100755 index 000000000000..618c95d213e2 --- /dev/null +++ b/util/SeederUtility/scripts/build-seeded-image.sh @@ -0,0 +1,407 @@ +#!/usr/bin/env bash +# Builds a seeded database Docker image for a given preset and database type, plus a +# "core bundle" tarball (data protection key + attachment blobs) under docker/bundles/ +# that the consuming environment unpacks at /etc/bitwarden/core. See README.md. +# +# Usage: +# ./build-seeded-image.sh [db-type] +# +# db-type: postgres (default), mysql, mariadb, mssql, sqlite +# +# Environment variables: +# PUSH=true Push images to ACR after build +# REGISTRY ACR registry (default: bitwardenprod.azurecr.io) +# GIT_SHA Override git SHA (default: current HEAD short SHA) +# DP_KEY_XML Data protection key XML content +# KEEP_BUILD_DIR=1 Preserve the per-preset build directory after completion +# +# Parallel invocations: +# The script is safe to run concurrently for different +# pairs. Per-invocation isolation comes from: +# - a unique container name (seeder-build--) +# - dynamic host-port binding (the DB port is mapped to an ephemeral host +# port, discovered via `docker inspect`) +# - a per-preset Docker build context under docker//build// +# Callers should `dotnet build` the migrations projects and the SeederUtility +# once before fanning out in parallel — concurrent `dotnet run` invocations +# from the same project directory will race on bin/obj outputs. +# +# Examples: +# ./build-seeded-image.sh qa.dunder-mifflin-enterprise-full +# ./build-seeded-image.sh qa.dunder-mifflin-enterprise-full mysql +# PUSH=true ./build-seeded-image.sh scale.md-balanced-sterling-cooper mssql +# +# # Loop over every preset from `preset --list --output json` and build for postgres: +# dotnet run --project .. -- preset --list --output json \ +# | jq -r '.organization[], .individual[]' \ +# | while read -r preset; do ./build-seeded-image.sh "$preset"; done + +set -euo pipefail + +PRESET_NAME="${1:?Usage: $0 [db-type]}" +DB_TYPE="${2:-${DB_TYPE:-postgres}}" +REGISTRY="${REGISTRY:-bitwardenprod.azurecr.io}" +GIT_SHA="${GIT_SHA:-$(git rev-parse --short HEAD 2>/dev/null || echo 'unknown')}" +BUILD_DATE="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" +PUSH="${PUSH:-false}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SEEDER_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +REPO_ROOT="$(cd "${SEEDER_DIR}/../.." && pwd)" +DOCKER_DIR="${SEEDER_DIR}/docker/${DB_TYPE}" + +# --- Validate DB type --- +case "${DB_TYPE}" in + postgres|mysql|mariadb|mssql|sqlite) ;; + *) + echo "ERROR: Unknown database type '${DB_TYPE}'. Supported: postgres, mysql, mariadb, mssql, sqlite" + exit 1 + ;; +esac + +# Sanitize preset name for Docker tag + container name: replace dots with dashes +TAG="${PRESET_NAME//./-}" +IMAGE_REPO="${REGISTRY}/shot/seeded-${DB_TYPE}" +IMAGE_VERSIONED="${IMAGE_REPO}:${TAG}-${GIT_SHA}" +IMAGE_LATEST="${IMAGE_REPO}:${TAG}-latest" + +CONTAINER_NAME="seeder-build-${DB_TYPE}-${TAG}" +WORK_DIR="${DOCKER_DIR}/build/${TAG}" + +# --- Cleanup on any exit (partial failures shouldn't leave containers behind) --- +cleanup() { + local status=$? + docker rm -f "${CONTAINER_NAME}" >/dev/null 2>&1 || true + if [[ "${KEEP_BUILD_DIR:-0}" != "1" ]]; then + # BUNDLE_STAGE holds key material + rm -rf "${WORK_DIR}" "${DOCKER_DIR}/build/${TAG}-bundle" + fi + return "${status}" +} +trap cleanup EXIT + +# Preset names are ., matching their fixture folder under Seeds/fixtures/presets/ +PRESET_CATEGORY="${PRESET_NAME%%.*}" + +echo "==> Building seeded ${DB_TYPE} image for preset: ${PRESET_NAME}" +echo " Versioned: ${IMAGE_VERSIONED}" +echo " Latest: ${IMAGE_LATEST}" +echo " Git SHA: ${GIT_SHA}" +echo " Category: ${PRESET_CATEGORY}" +echo " Container: ${CONTAINER_NAME}" +echo " Build dir: ${WORK_DIR}" + +# --- Prepare per-preset build context --- +rm -rf "${WORK_DIR}" +mkdir -p "${WORK_DIR}" +cp "${DOCKER_DIR}/Dockerfile" "${WORK_DIR}/Dockerfile" +if [[ "${DB_TYPE}" == "mssql" ]]; then + cp "${DOCKER_DIR}/docker-entrypoint.sh" "${WORK_DIR}/docker-entrypoint.sh" +fi + +# ============================================================ +# Docker build and push (shared for all DB types) +# ============================================================ +_docker_build_and_push() { + echo "==> Building Docker image" + docker buildx build \ + --platform linux/amd64 \ + --build-arg "PRESET_NAME=${PRESET_NAME}" \ + --build-arg "PRESET_CATEGORY=${PRESET_CATEGORY}" \ + --build-arg "GIT_SHA=${GIT_SHA}" \ + --build-arg "BUILD_DATE=${BUILD_DATE}" \ + -t "${IMAGE_VERSIONED}" \ + -t "${IMAGE_LATEST}" \ + "${WORK_DIR}" \ + --load + + echo "==> Built: ${IMAGE_VERSIONED}" + echo "==> Built: ${IMAGE_LATEST}" + + if [[ "${PUSH}" == "true" ]]; then + # Caller is responsible for registry auth (e.g. `az acr login` in CI or + # locally) before invoking with PUSH=true. + echo "==> Pushing images" + docker push "${IMAGE_VERSIONED}" + docker push "${IMAGE_LATEST}" + echo "==> Pushed: ${IMAGE_VERSIONED}" + echo "==> Pushed: ${IMAGE_LATEST}" + + # free up disk after push + docker rmi "${IMAGE_VERSIONED}" "${IMAGE_LATEST}" >/dev/null 2>&1 || true + fi +} + +# --- DB-type configuration --- +# INTERNAL_PORT: the port the database listens on inside the container. +# HOST_PORT is discovered post-start via `docker inspect`. +case "${DB_TYPE}" in + postgres) + INTERNAL_PORT=5432 + DB_NAME="vault_dev" + DB_USER="postgres" + DB_PASS="Password1!" + MIGRATIONS_DIR="${REPO_ROOT}/util/PostgresMigrations" + ;; + mysql) + INTERNAL_PORT=3306 + DB_NAME="vault_dev" + DB_USER="root" + DB_PASS="Password1!" + MIGRATIONS_DIR="${REPO_ROOT}/util/MySqlMigrations" + ;; + mariadb) + INTERNAL_PORT=3306 + DB_NAME="vault_dev" + DB_USER="root" + DB_PASS="Password1!" + MIGRATIONS_DIR="${REPO_ROOT}/util/MySqlMigrations" + ;; + mssql) + INTERNAL_PORT=1433 + DB_NAME="vault_dev" + DB_USER="SA" + # MSSQL requires a complex password (uppercase, number, symbol) + DB_PASS="Password1!Strong" + MIGRATIONS_DIR="${REPO_ROOT}/util/MsSqlMigratorUtility" + ;; + sqlite) + DB_NAME="vault_dev" + SQLITE_FILE="${WORK_DIR}/seed.db" + MIGRATIONS_DIR="${REPO_ROOT}/util/SqliteMigrations" + ;; +esac + +# --- Core bundle --- +# Data protection keys and attachment blobs, tarred for the consumer to unpack at +# /etc/bitwarden/core. Staged outside WORK_DIR, which is the Docker build context. +BUNDLE_STAGE="${DOCKER_DIR}/build/${TAG}-bundle" +CORE_DIR="${BUNDLE_STAGE}/core" +DP_KEYS_DIR="${CORE_DIR}/aspnet-dataprotection" +ATTACHMENTS_DIR="${CORE_DIR}/attachments" +BUNDLE_DIR="${SEEDER_DIR}/docker/bundles" +BUNDLE_FILE="${BUNDLE_DIR}/seeded-core-${DB_TYPE}-${TAG}-${GIT_SHA}.tar.gz" +mkdir -p "${DP_KEYS_DIR}" "${ATTACHMENTS_DIR}" "${BUNDLE_DIR}" + +DP_KEY_FILENAME="key-9aa06f19-9afe-414b-8791-189be3b5650f.xml" +DP_KEY_SRC="${SEEDER_DIR}/docker/dp-keys/${DP_KEY_FILENAME}" + +if [[ -n "${DP_KEY_XML:-}" ]]; then + echo "==> Using data protection key from DP_KEY_XML" + echo "${DP_KEY_XML}" > "${DP_KEYS_DIR}/${DP_KEY_FILENAME}" +elif [[ -f "${DP_KEY_SRC}" ]]; then + echo "==> Using data protection key from ${DP_KEY_SRC}" + cp "${DP_KEY_SRC}" "${DP_KEYS_DIR}/" +else + echo "ERROR: No data protection key. Set DP_KEY_XML or place a key at ${DP_KEY_SRC}." + exit 1 +fi + +# Self-hosted mode uses the licensing certificates embedded in Core and a no-op event +# repository. Installation ID is required when self-hosted. A blank attachment +# connection string selects local disk over Azure. +SEED_ENV=( + "globalSettings__selfHosted=true" + "globalSettings__installation__id=e6b8a9c4-0d3f-4a71-9c2e-5f7a1b3d8e02" + "globalSettings__dataProtection__directory=${DP_KEYS_DIR}" + "globalSettings__attachment__connectionString=" + "globalSettings__attachment__baseDirectory=${ATTACHMENTS_DIR}" +) + +_write_core_bundle() { + tar -czf "${BUNDLE_FILE}" -C "${BUNDLE_STAGE}" core + echo "==> Core bundle: ${BUNDLE_FILE}" + echo " Unpack with: tar -xzf $(basename "${BUNDLE_FILE}") -C /etc/bitwarden" +} + +# ============================================================ +# SQLite — no container needed, seeder writes directly to file +# ============================================================ +if [[ "${DB_TYPE}" == "sqlite" ]]; then + echo "==> Running SQLite migrations" + cd "${MIGRATIONS_DIR}" + dotnet ef database update \ + --connection "Data Source=${SQLITE_FILE}" + + echo "==> Seeding SQLite database with preset: ${PRESET_NAME}" + cd "${SEEDER_DIR}" + env "${SEED_ENV[@]}" \ + globalSettings__databaseProvider=sqlite \ + globalSettings__sqlite__connectionString="Data Source=${SQLITE_FILE}" \ + dotnet run --project . -- preset --name "${PRESET_NAME}" + + _write_core_bundle + _docker_build_and_push + echo "==> Done: ${PRESET_NAME} (${DB_TYPE}) → ${TAG}" + exit 0 +fi + +# ============================================================ +# Container-based databases +# ============================================================ + +# --- Start container with a dynamic host port so multiple invocations don't clash --- +echo "==> Starting ${DB_TYPE} container: ${CONTAINER_NAME}" +docker rm -f "${CONTAINER_NAME}" 2>/dev/null || true + +case "${DB_TYPE}" in + postgres) + docker run -d \ + --name "${CONTAINER_NAME}" \ + -e "POSTGRES_DB=${DB_NAME}" \ + -e "POSTGRES_USER=${DB_USER}" \ + -e "POSTGRES_PASSWORD=${DB_PASS}" \ + -p "0:${INTERNAL_PORT}" \ + postgres:14 >/dev/null + ;; + mysql) + docker run -d \ + --name "${CONTAINER_NAME}" \ + -e "MYSQL_DATABASE=${DB_NAME}" \ + -e "MYSQL_ROOT_PASSWORD=${DB_PASS}" \ + -p "0:${INTERNAL_PORT}" \ + mysql:8.0 \ + --default-authentication-plugin=mysql_native_password >/dev/null + ;; + mariadb) + docker run -d \ + --name "${CONTAINER_NAME}" \ + -e "MARIADB_DATABASE=${DB_NAME}" \ + -e "MARIADB_ROOT_PASSWORD=${DB_PASS}" \ + -p "0:${INTERNAL_PORT}" \ + mariadb:12 >/dev/null + ;; + mssql) + docker run -d \ + --name "${CONTAINER_NAME}" \ + -e "ACCEPT_EULA=Y" \ + -e "MSSQL_PID=Developer" \ + -e "SA_PASSWORD=${DB_PASS}" \ + -p "0:${INTERNAL_PORT}" \ + --platform linux/amd64 \ + mcr.microsoft.com/mssql/server:2025-CU5-ubuntu-24.04 >/dev/null + ;; +esac + +# Poll for the published host port. `with` yields an empty string while unbound. +for _ in $(seq 1 30); do + HOST_PORT=$(docker inspect \ + --format="{{with index .NetworkSettings.Ports \"${INTERNAL_PORT}/tcp\"}}{{(index . 0).HostPort}}{{end}}" \ + "${CONTAINER_NAME}") + [[ -n "${HOST_PORT}" ]] && break + sleep 1 +done + +if [[ -z "${HOST_PORT}" ]]; then + echo "ERROR: ${DB_TYPE} container never published port ${INTERNAL_PORT}" + docker logs --tail 50 "${CONTAINER_NAME}" || true + exit 1 +fi +echo "==> ${DB_TYPE} host port: ${HOST_PORT}" + +# --- Wait for readiness (bounded so a stuck container fails fast) --- +READY_TIMEOUT_SECS=300 +wait_until_ready() { + local deadline=$(( $(date +%s) + READY_TIMEOUT_SECS )) + while ! "$@" &>/dev/null; do + if (( $(date +%s) >= deadline )); then + echo "ERROR: ${DB_TYPE} did not become ready within ${READY_TIMEOUT_SECS}s" + docker logs --tail 50 "${CONTAINER_NAME}" || true + return 1 + fi + sleep 2 + done +} + +echo "==> Waiting for ${DB_TYPE} to be ready (timeout ${READY_TIMEOUT_SECS}s)..." +case "${DB_TYPE}" in + postgres) + wait_until_ready docker exec "${CONTAINER_NAME}" \ + pg_isready -U "${DB_USER}" -d "${DB_NAME}" + ;; + mysql|mariadb) + wait_until_ready docker exec "${CONTAINER_NAME}" \ + sh -c 'mysqladmin ping -u root -p"'"${DB_PASS}"'" --silent 2>/dev/null || mariadb-admin ping -u root -p"'"${DB_PASS}"'" --silent 2>/dev/null' + ;; + mssql) + wait_until_ready docker exec "${CONTAINER_NAME}" \ + /opt/mssql-tools18/bin/sqlcmd \ + -S localhost -U SA -P "${DB_PASS}" -C \ + -Q "SELECT 1" + ;; +esac +echo "==> ${DB_TYPE} ready" + +# --- Run migrations --- +echo "==> Running database migrations" +case "${DB_TYPE}" in + postgres) + cd "${MIGRATIONS_DIR}" + dotnet ef database update \ + -- --globalSettings:postgreSql:connectionString="Host=localhost;Port=${HOST_PORT};Database=${DB_NAME};Username=${DB_USER};Password=${DB_PASS}" + ;; + mysql|mariadb) + cd "${MIGRATIONS_DIR}" + dotnet ef database update \ + -- --globalSettings:databaseProvider=mysql \ + --globalSettings:mySql:connectionString="Server=localhost;Port=${HOST_PORT};Database=${DB_NAME};Uid=${DB_USER};Pwd=${DB_PASS};" + ;; + mssql) + cd "${MIGRATIONS_DIR}" + dotnet run -- \ + "Server=localhost,${HOST_PORT};Database=${DB_NAME};User Id=${DB_USER};Password=${DB_PASS};TrustServerCertificate=true;" + ;; +esac + +# --- Seed --- +echo "==> Seeding database with preset: ${PRESET_NAME}" +cd "${SEEDER_DIR}" +case "${DB_TYPE}" in + postgres) + DB_PROVIDER="postgreSql" + DB_CONNECTION="globalSettings__postgreSql__connectionString=Host=localhost;Port=${HOST_PORT};Database=${DB_NAME};Username=${DB_USER};Password=${DB_PASS}" + ;; + mysql|mariadb) + DB_PROVIDER="mySQL" + DB_CONNECTION="globalSettings__mySql__connectionString=Server=localhost;Port=${HOST_PORT};Database=${DB_NAME};Uid=${DB_USER};Pwd=${DB_PASS};" + ;; + mssql) + DB_PROVIDER="sqlServer" + DB_CONNECTION="globalSettings__sqlServer__connectionString=Server=localhost,${HOST_PORT};Database=${DB_NAME};User Id=${DB_USER};Password=${DB_PASS};TrustServerCertificate=true;" + ;; +esac + +env "${SEED_ENV[@]}" \ + "globalSettings__databaseProvider=${DB_PROVIDER}" \ + "${DB_CONNECTION}" \ + dotnet run --project . -- preset --name "${PRESET_NAME}" + +# --- Dump database --- +case "${DB_TYPE}" in + postgres) + docker exec "${CONTAINER_NAME}" \ + pg_dump --no-owner --no-acl -U "${DB_USER}" -d "${DB_NAME}" > "${WORK_DIR}/seed.sql" + ;; + + mysql|mariadb) + docker exec "${CONTAINER_NAME}" \ + sh -c 'mysqldump -u root -p"'"${DB_PASS}"'" --no-tablespaces "'"${DB_NAME}"'" 2>/dev/null || mariadb-dump -u root -p"'"${DB_PASS}"'" --no-tablespaces "'"${DB_NAME}"'" 2>/dev/null' > "${WORK_DIR}/seed.sql" + ;; + + mssql) + # Copy MDF/LDF files directly — avoids RESTORE issues on Kubernetes PVCs + docker exec "${CONTAINER_NAME}" \ + /opt/mssql-tools18/bin/sqlcmd \ + -S localhost -U SA -P "${DB_PASS}" -C -b \ + -Q "ALTER DATABASE [${DB_NAME}] SET OFFLINE WITH ROLLBACK IMMEDIATE" + docker cp "${CONTAINER_NAME}:/var/opt/mssql/data/${DB_NAME}.mdf" "${WORK_DIR}/${DB_NAME}.mdf" + docker cp "${CONTAINER_NAME}:/var/opt/mssql/data/${DB_NAME}_log.ldf" "${WORK_DIR}/${DB_NAME}_log.ldf" + ;; +esac + +echo "==> Stopping ${DB_TYPE} container" +docker rm -f "${CONTAINER_NAME}" >/dev/null + +_write_core_bundle +_docker_build_and_push +echo "==> Done: ${PRESET_NAME} (${DB_TYPE}) → ${TAG}"