diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 30d4290..d507810 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,8 +16,6 @@ concurrency: cancel-in-progress: true jobs: - # Keep the Windows build separate so we still validate the .NET Framework target - # and the packaging prerequisites that only exist on Windows runners. build: name: Build (${{ matrix.os }}) runs-on: ${{ matrix.os }} @@ -57,20 +55,14 @@ jobs: run: dotnet restore ./src/NEventStore.Persistence.Sql.Core.sln --verbosity m - name: Run GitVersion and patch assembly info - id: gitversion shell: pwsh - working-directory: ${{ github.workspace }} run: | - $gitVersion = dotnet tool run dotnet-gitversion /targetpath "${{ github.workspace }}" /output json /updateAssemblyInfo | ConvertFrom-Json + dotnet tool run dotnet-gitversion /targetpath "${{ github.workspace }}" /output json /updateAssemblyInfo | Out-Null dotnet tool run dotnet-gitversion /targetpath "${{ github.workspace }}/dependencies/NEventStore" /updateAssemblyInfo | Out-Null - "semver=$($gitVersion.SemVer)" >> $env:GITHUB_OUTPUT - name: Build solution run: dotnet build ./src/NEventStore.Persistence.Sql.Core.sln -c Release --no-restore /p:ContinuousIntegrationBuild=True - # Tests run on Linux with database services (PostgreSQL, MySQL, SQLite). - # Each database provider runs independently to isolate failures and validate - # database-specific persistence logic. test-sql-databases: needs: build name: Test (Linux, ${{ matrix.tfm }}) @@ -78,77 +70,7 @@ jobs: strategy: fail-fast: false matrix: - # Run each modern TFM independently so failures are isolated and easy to read. - tfm: - - net8.0 - - net9.0 - - net10.0 - - services: - sqlexpress: - image: mcr.microsoft.com/mssql/server:2022-RTM-GDR1-ubuntu-20.04 - env: - SA_PASSWORD: Password12! - ACCEPT_EULA: Y - MSSQL_PID: Express - options: >- - --health-cmd "/opt/mssql-tools/bin/sqlcmd -S localhost -U SA -P 'Password12!' -Q 'SELECT 1'" - --health-interval 20s - --health-timeout 10s - --health-retries 10 - --health-start-period 30s - ports: - - 1433:1433 - - postgresql: - image: postgres:15-alpine - env: - POSTGRES_DB: NEventStore - POSTGRES_USER: postgres - POSTGRES_PASSWORD: Password12! - options: >- - --health-cmd pg_isready - --health-interval 20s - --health-timeout 10s - --health-retries 10 - --health-start-period 30s - ports: - - 5432:5432 - - mysql: - image: mysql:8 - env: - MYSQL_DATABASE: NEventStore - MYSQL_ROOT_PASSWORD: Password12! - options: >- - --health-cmd "mysqladmin ping -h localhost" - --health-interval 20s - --health-timeout 10s - --health-retries 10 - --health-start-period 30s - ports: - - 3306:3306 - - oracle: - image: gvenzl/oracle-xe - env: - ORACLE_ALLOW_REMOTE: true - ORACLE_PASSWORD: Password12! - options: >- - --health-cmd "sqlplus -L -S / as sysdba @/dev/null" - --health-interval 25s - --health-timeout 10s - --health-retries 10 - --health-start-period 30s - ports: - - 1521:1521 - - env: - NEventStore.MsSql: Server=localhost;Database=NEventStore;User Id=SA;Password=Password12!;TrustServerCertificate=True; - NEventStore.PostgreSql: Server=localhost;Database=NEventStore;Uid=postgres;Pwd=Password12!;Enlist=false; - NEventStore.MySql: Server=localhost;Database=NEventStore;Uid=root;Pwd=Password12!;AutoEnlist=false; - NEventStore.Sqlite: Data Source=:memory:;Cache=Shared; - NEventStore.Oracle: Data Source=localhost:1521/XE;User Id=system;Password=Password12!;Persist Security Info=True; + tfm: [net8.0, net9.0, net10.0] steps: - name: Checkout @@ -174,46 +96,23 @@ jobs: restore-keys: | nuget-${{ runner.os }}- - - name: Create NEventStore database once SQL Server is healthy + - name: Start CI database environment shell: bash - run: | - set -euo pipefail - - # Find the SQL Server service container by ancestor image (more reliable in GH Actions) - container_id="$(/usr/bin/docker ps --filter "ancestor=mcr.microsoft.com/mssql/server:2022-RTM-GDR1-ubuntu-20.04" --format "{{.ID}}" | head -n 1)" - if [ -z "$container_id" ]; then - echo "SQL Server service container not found." - /usr/bin/docker ps --format "{{.ID}} {{.Image}} {{.Names}}" || true - exit 1 - fi - - # Wait until container reports healthy - for i in $(seq 1 60); do - health_status="$(/usr/bin/docker inspect --format='{{.State.Health.Status}}' "$container_id" 2>/dev/null || echo starting)" - if [ "$health_status" = "healthy" ]; then - echo "SQL Server container healthy" - break - fi - echo "Waiting for SQL Server to be healthy: $health_status ($i/60)" - sleep 2 - done - - if [ "$health_status" != "healthy" ]; then - echo "SQL Server container is not healthy. Logs follow (last 200 lines):" - /usr/bin/docker logs "$container_id" --tail 200 || true - exit 1 - fi - - # Create database using sqlcmd inside the container - /usr/bin/docker exec "$container_id" /opt/mssql-tools/bin/sqlcmd -S localhost -U SA -P "Password12!" -Q "IF DB_ID(N'NEventStore') IS NULL CREATE DATABASE [NEventStore];" + run: bash ./docker/start-environment.sh ci - name: Run tests for ${{ matrix.tfm }} run: dotnet test ./src/NEventStore.Persistence.Sql.Core.sln -c Release -f ${{ matrix.tfm }} --logger "trx;LogFileName=test-results-${{ matrix.tfm }}.trx" - name: Upload test results + if: always() uses: actions/upload-artifact@v7 with: name: test-results-${{ matrix.tfm }} path: "**/test-results-${{ matrix.tfm }}.trx" if-no-files-found: ignore retention-days: 14 + + - name: Stop CI database environment + if: always() + shell: bash + run: bash ./docker/stop-environment.sh ci --remove-data diff --git a/.gitignore b/.gitignore index b48498b..994403f 100644 --- a/.gitignore +++ b/.gitignore @@ -113,4 +113,5 @@ UpgradeLog*.XML # Custom artifacts/ -.tokensave/ \ No newline at end of file +.tokensave/ +.env diff --git a/.vscode/tasks.json b/.vscode/tasks.json index bc28cf8..1c889a7 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -4,7 +4,7 @@ { "label": "Docker Up", "options": { - "cwd": "${workspaceFolder}/docker/" + "cwd": "${workspaceFolder}" }, "type": "shell", "command": "powershell", @@ -12,8 +12,8 @@ "-ExecutionPolicy", "Bypass", "-File", - "${workspaceFolder}/docker/DockerComposeUp.ps1", - "linux" + "${workspaceFolder}/docker/start-environment.ps1", + "debug" ], "presentation": { "echo": true, @@ -26,7 +26,7 @@ { "label": "Docker Down", "options": { - "cwd": "${workspaceFolder}/docker/" + "cwd": "${workspaceFolder}" }, "type": "shell", "command": "powershell", @@ -34,8 +34,8 @@ "-ExecutionPolicy", "Bypass", "-File", - "${workspaceFolder}/docker/DockerComposeDown.ps1", - "linux" + "${workspaceFolder}/docker/stop-environment.ps1", + "debug" ], "presentation": { "echo": true, @@ -56,4 +56,4 @@ "label": "dotnet: build" } ] -} \ No newline at end of file +} diff --git a/README.md b/README.md index 99a00d9..990c829 100644 --- a/README.md +++ b/README.md @@ -54,20 +54,32 @@ git submodule update To build the project locally on a Windows Machine: -- Optional: update `.\src\.nuget\NEventStore.Persistence.Sql.nuspec` file if needed (before creating relase packages). +- Optional: update `.\src\.nuget\NEventStore.Persistence.Sql.nuspec` file if needed (before creating release packages). - Open a Powershell console in Administrative mode and run the build script `build.ps1` in the root of the repository. ## How to Run Unit Tests (locally) -- Install Database engines or use Docker to run them in a container (you can use the scripts in `./docker` folder). -- Define the following environment variables: +Start the complete Linux-container database environment with one of the scripts under `docker/`: - ``` - NEventStore.MsSql="Server=localhost,50001;Database=NEventStore;User Id=sa;Password=Password1;TrustServerCertificate=True;" - NEventStore.MySql="Server=localhost;Port=50003;Database=NEventStore;Uid=sa;Pwd=Password1;AutoEnlist=false;" - NEventStore.PostgreSql="Server=localhost;Port=50004;Database=NEventStore;Uid=sa;Pwd=Password1;Enlist=false;" - NEventStore.Oracle="Data Source=localhost:50005/XE;User Id=system;Password=Password1;Persist Security Info=True;" - ``` +```powershell +./docker/start-environment.ps1 debug +``` + +```bash +./docker/start-environment.sh debug +``` + +The scripts generate a repository-root `.env` file containing the database connection strings. The test projects load that file automatically unless the corresponding process environment variables are already defined. + +Dynamic environment names may be used for parallel worktrees and agents: + +```bash +./docker/start-environment.sh issue-142 +dotnet test ./src/NEventStore.Persistence.Sql.Core.sln +./docker/stop-environment.sh issue-142 +``` + +See [`docker/Readme.md`](docker/Readme.md) for fixed ports, data removal, and environment isolation details. ## How to contribute @@ -81,19 +93,19 @@ This repository uses GitFlow to develop, if you are not familiar with GitFlow yo ### Installing and configuring Git Flow -Probably the most straightforward way to install GitFlow on your machine is installing [Git Command Line](https://git-for-windows.github.io/), then install the [Visual Studio Plugin for Git-Flow](https://visualstudiogallery.msdn.microsoft.com/27f6d087-9b6f-46b0-b236-d72907b54683). This plugin is accessible from the **Team Explorer** menu and allows you to install GitFlow extension directly from Visual Studio with a simple click. The installer installs standard GitFlow extension both for command line and for Visual Studio Plugin. +Probably the most straightforward way to install GitFlow on your machine is installing [Git Command Line](https://git-for-windows.github.io/), then install the [Visual Studio Plugin for Git-Flow](https://visualstudiogallery.msdn.microsoft.com/27f6d087-9b6f-46b0-b236-d72907b54683). This plugin is accessible from the **Team Explorer** menu and allows you to install GitFlow extension directly from Visual Studio. The installer installs standard GitFlow extension both for command line and for Visual Studio Plugin. Once installed you can use GitFlow right from Visual Studio or from Command line, which one you prefer. ### Build machine and GitVersion -Build machine uses [GitVersion](https://github.com/GitTools/GitVersion) to manage automatic versioning of assemblies and Nuget Packages. You need to be aware that there are a rule that does not allow you to directly commit on master, or the build will fail. +Build machine uses GitVersion to manage automatic versioning of assemblies and Nuget Packages. You need to be aware that there are a rule that does not allow you to directly commit on master, or the build will fail. -A commit on master can be done only following the [Git-Flow](http://nvie.com/posts/a-successful-git-branching-model/) model, as a result of a new release coming from develop, or with an hotfix. +A commit on master can be done only following the Git-Flow model, as a result of a new release coming from develop, or with an hotfix. ### Quick Info for NEventstore projects -Just clone the repository and from command line checkout develop branch with +Just clone the repository and from command line checkout develop branch with ``` git checkout develop @@ -109,17 +121,8 @@ You can leave all values as default. Now your repository is GitFlow enabled. ### Note on Nuget version on Nuspec -Remember to update `.\src\.nuget\NEventStore.Persistence.Sql.nuspec` file if needed (before creating relase packages). +Remember to update `.\src\.nuget\NEventStore.Persistence.Sql.nuspec` file if needed (before creating release packages). The .nuspec file is needed because the new `dotnet pack` command has problems dealing with ProjectReferences, submodules get the wrong version number. -While we are on develop branch, (suppose we just bumped major number so the driver version number is 6.0.0-unstablexxxx), we need to declare that this persistence driver depends from a version greater than the latest published. If the latest version of NEventStore 5.x.x wave iw 5.4.0 we need to declare this package dependency as - -(5.4, 7) - -This means, that we need a NEventStore greater than the latest published, but lesser than the next main version. This allows version 6.0.0-unstable of NEventStore to satisfy the dependency. We remember that prerelease package are considered minor than the stable package. Es. - -5.4.0 -5.4.1 -6.0.0-unstable00001 -6.0.0 +While we are on develop branch, suppose we just bumped major number so the driver version number is 6.0.0-unstablexxxx. We need to declare that this persistence driver depends from a version greater than the latest published. If the latest version of NEventStore 5.x.x wave is 5.4.0 we need to declare that dependency as `(5.4, 7)`. diff --git a/docker/DockerComposeDown.ps1 b/docker/DockerComposeDown.ps1 deleted file mode 100644 index 37c8f3f..0000000 --- a/docker/DockerComposeDown.ps1 +++ /dev/null @@ -1,24 +0,0 @@ -$platform = $args[0] -if (!$platform) { - Write-Host "Missing Platform: windows | linux" - exit(1) -} - -if ($platform -eq "linux") { - # there's a bug on stopping/terminating LCOW - # https://github.com/moby/moby/issues/37919 - # workaround is to kill the containers manually - - #Write-Host "LCOW stopping bug, hack: killing the containers (https://github.com/moby/moby/issues/37919)." - #docker kill -s 9 nesci-sqlexpress-1 - #docker kill -s 9 nesci-mongo-1 - #docker kill -s 9 nesci-mysql-1 - #docker kill -s 9 nesci-postgres-1 -} -# -v "removes all the volumes, there's no need to do it manually" -docker-compose -f docker-compose.ci.$platform.db.yml -p nesci down -v - -# remove unneeded volumes, so we start clear every time -#Write-Host "Removing volumes:" -#docker volume rm nesci-h8-sqldata-${platform}_ci -#docker volume rm nesci-h8_mongodata-${platform}_ci \ No newline at end of file diff --git a/docker/DockerComposeUp.ps1 b/docker/DockerComposeUp.ps1 deleted file mode 100644 index d10ddc3..0000000 --- a/docker/DockerComposeUp.ps1 +++ /dev/null @@ -1,21 +0,0 @@ -$platform = $args[0] -if (!$platform) { - Write-Host "Missing Platform: windows | linux" - exit(1) -} - -Write-Host "Run the CI environment" - -docker-compose -f docker-compose.ci.$platform.db.yml -p nesci up --detach - -Write-Host "Creating Databases" - -Start-Sleep -Seconds 20 - -if ($platform -eq "linux") { -docker exec nesci-sqlexpress-1 /opt/mssql-tools/bin/sqlcmd -l 60 -S localhost -U sa -P Password1 -Q "CREATE DATABASE NEventStore" -} - -if ($platform -eq "windows") { -docker exec nesci-sqlexpress-1 sqlcmd -l 60 -S localhost -U sa -P Password1 -Q "CREATE DATABASE NEventStore" -} diff --git a/docker/Readme.md b/docker/Readme.md index 4e5a5c9..68a7577 100644 --- a/docker/Readme.md +++ b/docker/Readme.md @@ -1,18 +1,100 @@ -# Docker +# Docker database environments -Docker must be configured to use LCOW in Windows OS. +The scripts in this directory start and stop the complete Linux-container database stack used by the SQL persistence tests. -Run the following scripts to start and stop all the Database engines: +Each Git worktree owns one Docker Compose project: +```text +worktree -> environment name -> Compose project -> all database services ``` -DockerComposeUp.ps1 -DockerComposeDown.ps1 + +The Compose project name is `neventstore-sql-`. Containers, networks, and named volumes are isolated by that project name. + +## Start an environment + +Run the platform-specific script from any directory: + +```powershell +./docker/start-environment.ps1 +``` + +```bash +bash ./docker/start-environment.sh +``` + +Without an argument, the environment name is derived from the worktree directory. The name is normalized and dynamic host ports are assigned automatically. + +An explicit dynamic environment can be started with: + +```bash +bash ./docker/start-environment.sh issue-142 +``` + +Three reserved environment names use predictable ports: + +```bash +bash ./docker/start-environment.sh debug +bash ./docker/start-environment.sh test +bash ./docker/start-environment.sh ci +``` + +| Database | Debug | Test | CI | +| --- | ---: | ---: | ---: | +| SQL Server | 50001 | 51001 | 52001 | +| MySQL | 50003 | 51003 | 52003 | +| PostgreSQL | 50004 | 51004 | 52004 | +| Oracle | 50005 | 51005 | 52005 | + +The start script waits for the services to become healthy, creates the SQL Server `NEventStore` database when necessary, retrieves the assigned ports, and writes the resulting connection strings to the repository-root `.env` file. + +The generated `.env` file is excluded from Git. Test projects load it automatically when the corresponding process environment variable is not already set. + +## Stop an environment + +Data is preserved by default: + +```bash +bash ./docker/stop-environment.sh issue-142 +``` + +Remove only the selected environment's named volumes with: + +```bash +bash ./docker/stop-environment.sh issue-142 --remove-data +``` + +The PowerShell scripts accept the same environment names and behavior: + +```powershell +./docker/stop-environment.ps1 issue-142 --remove-data +``` + +## Agent and CI usage + +Agents should normally use a dynamic environment derived from their worktree or task: + +```bash +bash ./docker/start-environment.sh issue-142 +dotnet test ./src/NEventStore.Persistence.Sql.Core.sln +bash ./docker/stop-environment.sh issue-142 ``` -## Docker Environment Variables +Agents should not use `debug`, `test`, or `ci` unless explicitly instructed. `debug` and `test` reserve predictable ports for human workflows; `ci` is reserved for GitHub Actions. + +GitHub Actions starts the same stack through: + +```bash +bash ./docker/start-environment.sh ci +``` + +Each test-matrix job runs on its own hosted runner, so the fixed CI ports do not collide between jobs. The workflow stops the stack with `--remove-data` in an `always()` cleanup step. + +## Compose files + +- `docker-compose.yml` defines the Linux database services, health checks, and named volumes. +- `docker-compose.dynamic.yml` publishes each service on an automatically assigned local port. +- `docker-compose.debug.yml` publishes the fixed debug ports. +- `docker-compose.test.yml` publishes the fixed test ports. +- `docker-compose.ci.yml` publishes the fixed GitHub Actions CI ports. -NEventStore.MsSql="Server=localhost,50001;Database=NEventStore;User Id=sa;Password=Password1;TrustServerCertificate=True;" -NEventStore.MongoDB="mongodb://localhost:50002/NEventStore" -NEventStore.MySql="Server=localhost;Port=50003;Database=NEventStore;Uid=sa;Pwd=Password1;AutoEnlist=false;" -NEventStore.PostgreSql="Server=localhost;Port=50004;Database=NEventStore;Uid=sa;Pwd=Password1;Enlist=false;" -NEventStore.Oracle="Data Source=localhost:50005/XE;User Id=system;Password=Password1;Persist Security Info=True;" +The Compose files intentionally do not define `container_name`; Compose derives resource names from the project name. diff --git a/docker/docker-compose.ci.linux.db.yml b/docker/docker-compose.ci.linux.db.yml deleted file mode 100644 index 873c1f8..0000000 --- a/docker/docker-compose.ci.linux.db.yml +++ /dev/null @@ -1,70 +0,0 @@ -# Root Docker Compose file to run the tests on the development machine using some pre-initialized databases -# It must be used with powershell actions that mount the databases files. - -services: - sqlexpress: - container_name: nesci-sqlexpress-1 - platform: linux - image: mcr.microsoft.com/mssql/server:2022-RTM-GDR1-ubuntu-20.04 - mem_limit: 2000m - environment: - - SA_PASSWORD=Password1 - - ACCEPT_EULA=Y - - MSSQL_PID=Express - ports: - - "50001:1433" -# volumes: -# - C:\Temp/NEventStore:/data -# - nes_sqldata_linux_ci:/var/opt/mssql - -# mongo: -# container_name: nesci-mongo-1 -# platform: linux -# image: mongo #@sha256:52c3314bee611f91d37b9b1bc0cc2755b1388f2de5b396b441f3fe94bef6c56c -# ports: -# - "50002:27017" -## volumes: -## - nes_mongodata_linux_ci:/data/db -## - nes_mongoconfig_linux_ci:/data/configdb - - mysql: - container_name: nesci-mysql-1 - platform: linux - image: mysql:8.0 #5.7 - command: --default-authentication-plugin=mysql_native_password - restart: always - environment: - MYSQL_ROOT_PASSWORD: Password1 - MYSQL_DATABASE: NEventStore - MYSQL_USER: sa - MYSQL_PASSWORD: Password1 - ports: - - "50003:3306" - - postgres: - container_name: nesci-postgres-1 - platform: linux - image: postgres - restart: always - environment: - POSTGRES_PASSWORD: Password1 - POSTGRES_USER: sa - POSTGRES_DB: NEventStore - ports: - - "50004:5432" - - oracle: - container_name: nesci-oracle-1 - platform: linux - image: gvenzl/oracle-xe - restart: always - environment: - - ORACLE_ALLOW_REMOTE=true - - ORACLE_PASSWORD=Password1 - ports: - - "50005:1521" - -#volumes: -# nes_sqldata_linux_ci: -# nes_mongodata_linux_ci: -# nes_mongoconfig_linux_ci: \ No newline at end of file diff --git a/docker/docker-compose.ci.windows.db.yml b/docker/docker-compose.ci.windows.db.yml deleted file mode 100644 index ead22a6..0000000 --- a/docker/docker-compose.ci.windows.db.yml +++ /dev/null @@ -1,72 +0,0 @@ -# Root Docker Compose file to run the tests on the development machine using some pre-initialized databases -# It must be used with powershell actions that mount the databases files. - -services: - sqlexpress: - container_name: nesci-sqlexpress-1 - platform: windows - image: christianacca/mssql-server-windows-express - environment: - - SA_PASSWORD=Password1 - - ACCEPT_EULA=Y - ports: - - "50001:1433" -# volumes: -# - "C:\\Temp\\NEventStore:c:\\data" -# - "nes_sqldata_windows_ci:c:\\sql\\userdbs\\data" -# - "nes_sqllog_windows_ci:c:\\sql\\userdbs\\log" -# - "nes_sqlbackup_windows_ci:c:\\sql\\backup" - -# mongo: -# container_name: nesci-mongo-1 -# platform: windows -# image: mongo -# ports: -# - "50002:27017" -## volumes: -## - nes_mongodata_windows_ci:/data/db -## - nes_mongoconfig_windows_ci:/data/configdb - - mysql: - container_name: nesci-mysql-1 - platform: windows - image: mysql:8.0 #5.7 - command: --default-authentication-plugin=mysql_native_password - restart: always - environment: - MYSQL_ROOT_PASSWORD: Password1 - MYSQL_DATABASE: NEventStore - MYSQL_USER: sa - MYSQL_PASSWORD: Password1 - ports: - - "50003:3306" - - postgres: - container_name: nesci-postgres-1 - platform: windows - image: postgres - restart: always - environment: - POSTGRES_PASSWORD: Password1 - POSTGRES_USER: sa - POSTGRES_DB: NEventStore - ports: - - "50004:5432" - - oracle: - container_name: nesci-oracle-1 - platform: linux - image: gvenzl/oracle-xe - restart: always - environment: - - ORACLE_ALLOW_REMOTE=true - - ORACLE_PASSWORD=Password1 - ports: - - "50005:1521" - -#volumes: -# nes_sqldata_windows_ci: -# nes_sqllog_windows_ci: -# nes_sqlbackup_windows_ci: -# nes_mongodata_windows_ci: -# nes_mongoconfig_windows_ci: \ No newline at end of file diff --git a/docker/docker-compose.ci.yml b/docker/docker-compose.ci.yml new file mode 100644 index 0000000..7c39fb5 --- /dev/null +++ b/docker/docker-compose.ci.yml @@ -0,0 +1,16 @@ +services: + sqlexpress: + ports: + - "127.0.0.1:52001:1433" + + mysql: + ports: + - "127.0.0.1:52003:3306" + + postgres: + ports: + - "127.0.0.1:52004:5432" + + oracle: + ports: + - "127.0.0.1:52005:1521" diff --git a/docker/docker-compose.debug.yml b/docker/docker-compose.debug.yml new file mode 100644 index 0000000..1619f6d --- /dev/null +++ b/docker/docker-compose.debug.yml @@ -0,0 +1,16 @@ +services: + sqlexpress: + ports: + - "127.0.0.1:50001:1433" + + mysql: + ports: + - "127.0.0.1:50003:3306" + + postgres: + ports: + - "127.0.0.1:50004:5432" + + oracle: + ports: + - "127.0.0.1:50005:1521" diff --git a/docker/docker-compose.dynamic.yml b/docker/docker-compose.dynamic.yml new file mode 100644 index 0000000..f50d9c1 --- /dev/null +++ b/docker/docker-compose.dynamic.yml @@ -0,0 +1,16 @@ +services: + sqlexpress: + ports: + - "127.0.0.1::1433" + + mysql: + ports: + - "127.0.0.1::3306" + + postgres: + ports: + - "127.0.0.1::5432" + + oracle: + ports: + - "127.0.0.1::1521" diff --git a/docker/docker-compose.test.yml b/docker/docker-compose.test.yml new file mode 100644 index 0000000..d934711 --- /dev/null +++ b/docker/docker-compose.test.yml @@ -0,0 +1,16 @@ +services: + sqlexpress: + ports: + - "127.0.0.1:51001:1433" + + mysql: + ports: + - "127.0.0.1:51003:3306" + + postgres: + ports: + - "127.0.0.1:51004:5432" + + oracle: + ports: + - "127.0.0.1:51005:1521" diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..4ebf7f5 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,79 @@ +services: + sqlexpress: + platform: linux/amd64 + image: mcr.microsoft.com/mssql/server:2022-RTM-GDR1-ubuntu-20.04 + mem_limit: 2g + environment: + ACCEPT_EULA: "Y" + MSSQL_PID: Express + MSSQL_SA_PASSWORD: Password1 + healthcheck: + test: + - CMD-SHELL + - /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P "$$MSSQL_SA_PASSWORD" -Q "SELECT 1" || exit 1 + interval: 10s + timeout: 5s + retries: 30 + start_period: 30s + volumes: + - sqlserver-data:/var/opt/mssql + + mysql: + platform: linux/amd64 + image: mysql:8.0 + command: --default-authentication-plugin=mysql_native_password + environment: + MYSQL_ROOT_PASSWORD: Password1 + MYSQL_DATABASE: NEventStore + MYSQL_USER: sa + MYSQL_PASSWORD: Password1 + healthcheck: + test: + - CMD-SHELL + - mysqladmin ping -h localhost -uroot -p"$$MYSQL_ROOT_PASSWORD" --silent + interval: 10s + timeout: 5s + retries: 30 + start_period: 30s + volumes: + - mysql-data:/var/lib/mysql + + postgres: + platform: linux/amd64 + image: postgres:15-alpine + environment: + POSTGRES_PASSWORD: Password1 + POSTGRES_USER: sa + POSTGRES_DB: NEventStore + healthcheck: + test: + - CMD-SHELL + - pg_isready -U "$$POSTGRES_USER" -d "$$POSTGRES_DB" + interval: 10s + timeout: 5s + retries: 30 + start_period: 10s + volumes: + - postgres-data:/var/lib/postgresql/data + + oracle: + platform: linux/amd64 + image: gvenzl/oracle-xe:21-slim + environment: + ORACLE_PASSWORD: Password1 + healthcheck: + test: + - CMD + - healthcheck.sh + interval: 15s + timeout: 10s + retries: 40 + start_period: 60s + volumes: + - oracle-data:/opt/oracle/oradata + +volumes: + sqlserver-data: + mysql-data: + postgres-data: + oracle-data: diff --git a/docker/start-environment.ps1 b/docker/start-environment.ps1 new file mode 100644 index 0000000..4669499 --- /dev/null +++ b/docker/start-environment.ps1 @@ -0,0 +1,69 @@ +[CmdletBinding()] +param( + [Parameter(Position = 0)] + [string] $EnvironmentName +) + +if ($args.Count -gt 0) { throw 'Only one environment name may be provided.' } +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$scriptDirectory = Split-Path -Parent $MyInvocation.MyCommand.Path +$repositoryRoot = Split-Path -Parent $scriptDirectory +$projectPrefix = 'neventstore-sql' +$maximumEnvironmentNameLength = 48 + +function Normalize-EnvironmentName { + param([Parameter(Mandatory)][string] $Value) + $normalized = [Regex]::Replace($Value.ToLowerInvariant(), '[^a-z0-9]+', '-').Trim('-') + if ([string]::IsNullOrWhiteSpace($normalized)) { throw "Environment name '$Value' does not contain any supported characters." } + if ($normalized.Length -gt $maximumEnvironmentNameLength) { $normalized = $normalized.Substring(0, $maximumEnvironmentNameLength).TrimEnd('-') } + return $normalized +} + +function Invoke-DockerCompose { + param([Parameter(Mandatory)][string[]] $Arguments, [switch] $CaptureOutput) + $allArguments = $script:composeArguments + $Arguments + if ($CaptureOutput) { $output = & docker @allArguments } else { & docker @allArguments } + if ($LASTEXITCODE -ne 0) { throw "Docker Compose failed with exit code $LASTEXITCODE." } + if ($CaptureOutput) { return $output } +} + +function Get-AssignedPort { + param([Parameter(Mandatory)][string] $Service, [Parameter(Mandatory)][int] $ContainerPort) + $endpoint = (Invoke-DockerCompose -Arguments @('port', $Service, $ContainerPort.ToString()) -CaptureOutput | Select-Object -Last 1).Trim() + $match = [Regex]::Match($endpoint, ':(\d+)$') + if (-not $match.Success) { throw "Unable to determine the host port for ${Service}:${ContainerPort} from '$endpoint'." } + return $match.Groups[1].Value +} + +if ([string]::IsNullOrWhiteSpace($EnvironmentName)) { $EnvironmentName = Split-Path -Leaf $repositoryRoot } +$normalizedEnvironmentName = Normalize-EnvironmentName $EnvironmentName +$portMode = if ($normalizedEnvironmentName -in @('debug', 'test', 'ci')) { $normalizedEnvironmentName } else { 'dynamic' } +$projectName = "$projectPrefix-$normalizedEnvironmentName" +$envFile = Join-Path $repositoryRoot '.env' +$script:composeArguments = @('compose', '--project-name', $projectName, '--file', (Join-Path $scriptDirectory 'docker-compose.yml'), '--file', (Join-Path $scriptDirectory "docker-compose.$portMode.yml")) + +& docker compose version *> $null +if ($LASTEXITCODE -ne 0) { throw "Docker Compose v2 is required. Ensure 'docker compose' is available." } + +Write-Host "Starting '$projectName' using $portMode ports..." +Invoke-DockerCompose -Arguments @('up', '--detach', '--wait') +$databasePassword = (Invoke-DockerCompose -Arguments @('exec', '-T', 'sqlexpress', 'printenv', 'MSSQL_SA_PASSWORD') -CaptureOutput | Select-Object -Last 1).Trim() +Invoke-DockerCompose -Arguments @('exec', '-T', 'sqlexpress', '/opt/mssql-tools/bin/sqlcmd', '-S', 'localhost', '-U', 'sa', '-P', $databasePassword, '-Q', "IF DB_ID(N'NEventStore') IS NULL CREATE DATABASE [NEventStore];") + +$sqlServerPort = Get-AssignedPort -Service 'sqlexpress' -ContainerPort 1433 +$mySqlPort = Get-AssignedPort -Service 'mysql' -ContainerPort 3306 +$postgreSqlPort = Get-AssignedPort -Service 'postgres' -ContainerPort 5432 +$oraclePort = Get-AssignedPort -Service 'oracle' -ContainerPort 1521 +$connectionStrings = @( + "# Generated by docker/start-environment.ps1 for environment '$normalizedEnvironmentName'.", + "NEventStore.MsSql=Server=127.0.0.1,$sqlServerPort;Database=NEventStore;User Id=sa;Password=$databasePassword;TrustServerCertificate=True;", + "NEventStore.MySql=Server=127.0.0.1;Port=$mySqlPort;Database=NEventStore;Uid=sa;Pwd=$databasePassword;AutoEnlist=false;", + "NEventStore.PostgreSql=Server=127.0.0.1;Port=$postgreSqlPort;Database=NEventStore;Uid=sa;Pwd=$databasePassword;Enlist=false;", + "NEventStore.Oracle=Data Source=127.0.0.1:$oraclePort/XE;User Id=system;Password=$databasePassword;Persist Security Info=True;" +) +[System.IO.File]::WriteAllText($envFile, ($connectionStrings -join [Environment]::NewLine) + [Environment]::NewLine, [System.Text.UTF8Encoding]::new($false)) +Write-Host "Environment: $normalizedEnvironmentName" +Write-Host "Compose project: $projectName" +Write-Host "Connection strings written to: $envFile" diff --git a/docker/start-environment.sh b/docker/start-environment.sh new file mode 100644 index 0000000..d1173d2 --- /dev/null +++ b/docker/start-environment.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repository_root="$(cd "$script_dir/.." && pwd)" +project_prefix="neventstore-sql" +maximum_environment_name_length=48 + +normalize_environment_name() { + local value="$1" + local normalized + + normalized="$(printf '%s' "$value" \ + | tr '[:upper:]' '[:lower:]' \ + | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//')" + + if [[ -z "$normalized" ]]; then + echo "Environment name '$value' does not contain any supported characters." >&2 + return 1 + fi + + normalized="${normalized:0:$maximum_environment_name_length}" + printf '%s' "${normalized%-}" +} + +get_assigned_port() { + local service="$1" + local container_port="$2" + local endpoint + local port + + endpoint="$("${compose_command[@]}" port "$service" "$container_port" | tail -n 1)" + port="${endpoint##*:}" + + if [[ ! "$port" =~ ^[0-9]+$ ]]; then + echo "Unable to determine the host port for $service:$container_port from '$endpoint'." >&2 + return 1 + fi + + printf '%s' "$port" +} + +if [[ $# -gt 1 ]]; then + echo "Only one environment name may be provided." >&2 + exit 2 +fi + +raw_environment_name="${1:-$(basename "$repository_root")}" +environment_name="$(normalize_environment_name "$raw_environment_name")" + +case "$environment_name" in + debug|test|ci) + port_mode="$environment_name" + ;; + *) + port_mode="dynamic" + ;; +esac + +project_name="$project_prefix-$environment_name" +base_compose_file="$script_dir/docker-compose.yml" +override_compose_file="$script_dir/docker-compose.$port_mode.yml" +env_file="$repository_root/.env" + +if ! docker compose version >/dev/null 2>&1; then + echo "Docker Compose v2 is required. Ensure 'docker compose' is available." >&2 + exit 1 +fi + +compose_command=( + docker compose + --project-name "$project_name" + --file "$base_compose_file" + --file "$override_compose_file" +) + +echo "Starting '$project_name' using $port_mode ports..." +"${compose_command[@]}" up --detach --wait + +database_password="$("${compose_command[@]}" exec -T sqlexpress printenv MSSQL_SA_PASSWORD | tr -d '\r')" + +"${compose_command[@]}" exec -T sqlexpress \ + /opt/mssql-tools/bin/sqlcmd \ + -S localhost \ + -U sa \ + -P "$database_password" \ + -Q "IF DB_ID(N'NEventStore') IS NULL CREATE DATABASE [NEventStore];" + +sqlserver_port="$(get_assigned_port sqlexpress 1433)" +mysql_port="$(get_assigned_port mysql 3306)" +postgres_port="$(get_assigned_port postgres 5432)" +oracle_port="$(get_assigned_port oracle 1521)" + +temporary_env_file="$env_file.tmp" +cat > "$temporary_env_file" <&2 + return 1 + fi + normalized="${normalized:0:$maximum_environment_name_length}" + printf '%s' "${normalized%-}" +} + +for argument in "$@"; do + case "$argument" in + --remove-data) remove_data=true ;; + -*) echo "Unknown option: $argument" >&2; exit 2 ;; + *) + if [[ -n "$raw_environment_name" ]]; then echo "Only one environment name may be provided." >&2; exit 2; fi + raw_environment_name="$argument" + ;; + esac +done + +if [[ -z "$raw_environment_name" ]]; then raw_environment_name="$(basename "$repository_root")"; fi +environment_name="$(normalize_environment_name "$raw_environment_name")" +case "$environment_name" in + debug|test|ci) port_mode="$environment_name" ;; + *) port_mode="dynamic" ;; +esac + +project_name="$project_prefix-$environment_name" +compose_command=(docker compose --project-name "$project_name" --file "$script_dir/docker-compose.yml" --file "$script_dir/docker-compose.$port_mode.yml") +down_arguments=(down) +if [[ "$remove_data" == true ]]; then down_arguments+=(--volumes); fi + +echo "Stopping '$project_name'..." +"${compose_command[@]}" "${down_arguments[@]}" diff --git a/src/NEventStore.Persistence.Sql.Tests/DotEnvFile.cs b/src/NEventStore.Persistence.Sql.Tests/DotEnvFile.cs new file mode 100644 index 0000000..f812f1c --- /dev/null +++ b/src/NEventStore.Persistence.Sql.Tests/DotEnvFile.cs @@ -0,0 +1,60 @@ +namespace NEventStore.Persistence.Sql.Tests +{ + internal static class DotEnvFile + { + private static readonly object SyncRoot = new object(); + private static bool _loaded; + + public static void Load() + { + if (_loaded) return; + + lock (SyncRoot) + { + if (_loaded) return; + + var envFile = FindEnvFile(Environment.CurrentDirectory) + ?? FindEnvFile(AppContext.BaseDirectory); + + if (envFile != null) Load(envFile); + _loaded = true; + } + } + + private static string? FindEnvFile(string startDirectory) + { + var directory = new DirectoryInfo(startDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, ".env"); + if (File.Exists(candidate)) return candidate; + directory = directory.Parent; + } + return null; + } + + private static void Load(string path) + { + foreach (var sourceLine in File.ReadLines(path)) + { + var line = sourceLine.Trim(); + if (line.Length == 0 || line.StartsWith("#", StringComparison.Ordinal)) continue; + if (line.StartsWith("export ", StringComparison.Ordinal)) line = line.Substring("export ".Length).TrimStart(); + + var separatorIndex = line.IndexOf('='); + if (separatorIndex <= 0) continue; + + var key = line.Substring(0, separatorIndex).Trim(); + if (key.Length == 0 || !string.IsNullOrEmpty(Environment.GetEnvironmentVariable(key, EnvironmentVariableTarget.Process))) continue; + + var value = line.Substring(separatorIndex + 1).Trim(); + if (value.Length >= 2 && ((value[0] == '"' && value[value.Length - 1] == '"') || (value[0] == '\'' && value[value.Length - 1] == '\''))) + { + value = value.Substring(1, value.Length - 2); + } + + Environment.SetEnvironmentVariable(key, value, EnvironmentVariableTarget.Process); + } + } + } +} diff --git a/src/NEventStore.Persistence.Sql.Tests/EnvironmentConnectionFactory.cs b/src/NEventStore.Persistence.Sql.Tests/EnvironmentConnectionFactory.cs index 5deeb4e..645a88a 100644 --- a/src/NEventStore.Persistence.Sql.Tests/EnvironmentConnectionFactory.cs +++ b/src/NEventStore.Persistence.Sql.Tests/EnvironmentConnectionFactory.cs @@ -40,18 +40,7 @@ public Type GetDbProviderFactoryType() private DbConnection OpenInternal() { - var connectionString = Environment.GetEnvironmentVariable(_envVarKey, EnvironmentVariableTarget.Process); - if (connectionString == null) - { - string message = - string.Format( - "Failed to get '{0}' environment variable. Please ensure " + - "you have correctly setup the connection string environment variables. Refer to the " + - "NEventStore wiki for details.", - _envVarKey); - throw new InvalidOperationException(message); - } - connectionString = connectionString.TrimStart('"').TrimEnd('"'); + var connectionString = GetConnectionString(); var connection = _dbProviderFactory.CreateConnection(); Debug.Assert(connection != null, "connection == null"); connection!.ConnectionString = connectionString; @@ -68,18 +57,7 @@ private DbConnection OpenInternal() private async Task OpenInternalAsync(CancellationToken cancellationToken) { - var connectionString = Environment.GetEnvironmentVariable(_envVarKey, EnvironmentVariableTarget.Process); - if (connectionString == null) - { - string message = - string.Format( - "Failed to get '{0}' environment variable. Please ensure " + - "you have correctly setup the connection string environment variables. Refer to the " + - "NEventStore wiki for details.", - _envVarKey); - throw new InvalidOperationException(message); - } - connectionString = connectionString.TrimStart('"').TrimEnd('"'); + var connectionString = GetConnectionString(); var connection = _dbProviderFactory.CreateConnection(); Debug.Assert(connection != null, "connection == null"); connection!.ConnectionString = connectionString; @@ -93,5 +71,24 @@ private async Task OpenInternalAsync(CancellationToken cancellatio } return connection; } + + private string GetConnectionString() + { + DotEnvFile.Load(); + + var connectionString = Environment.GetEnvironmentVariable(_envVarKey, EnvironmentVariableTarget.Process); + if (connectionString == null) + { + string message = + string.Format( + "Failed to get '{0}' environment variable. Please ensure " + + "you have correctly setup the connection string environment variables or run " + + "docker/start-environment for this worktree.", + _envVarKey); + throw new InvalidOperationException(message); + } + + return connectionString.TrimStart('"').TrimEnd('"'); + } } -} \ No newline at end of file +}