diff --git a/.github/scripts/docker-wait-for-health.sh b/.github/scripts/docker-wait-for-health.sh new file mode 100644 index 0000000000..b49690faee --- /dev/null +++ b/.github/scripts/docker-wait-for-health.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "$#" -lt 1 ] || [ "$#" -gt 3 ]; then + echo "Usage: $0 [timeout-seconds] [poll-interval-seconds]" >&2 + exit 2 +fi + +container="$1" +timeout_seconds="${2:-60}" +poll_interval_seconds="${3:-2}" +deadline=$((SECONDS + timeout_seconds)) + +while true; do + status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$container")" + + case "$status" in + healthy) + exit 0 + ;; + unhealthy) + echo "Container ${container} is unhealthy." >&2 + docker logs "$container" >&2 || true + exit 1 + ;; + starting) + ;; + none) + echo "Container ${container} does not define a Docker health check." >&2 + docker inspect "$container" >&2 || true + exit 1 + ;; + *) + echo "Container ${container} has unexpected health status: ${status}" >&2 + docker inspect "$container" >&2 || true + exit 1 + ;; + esac + + if [ "$SECONDS" -ge "$deadline" ]; then + echo "Container ${container} did not become healthy within ${timeout_seconds}s." >&2 + docker logs "$container" >&2 || true + exit 1 + fi + + sleep "$poll_interval_seconds" +done diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7c566a0b68..625a33e570 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,10 +15,19 @@ permissions: env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 DOTNET_NOLOGO: true - # Manual docker-run images are centralized so they can be switched to GHCR mirrors if MCR access flakes. + # Docker images started by workflow steps are centralized so pulls can be retried and mirrors can be swapped in one place. AZURITE_IMAGE: mcr.microsoft.com/azure-storage/azurite:3.35.0@sha256:647c63a91102a9d8e8000aab803436e1fc85fbb285e7ce830a82ee5d6661cf37 EVENTHUBS_EMULATOR_IMAGE: mcr.microsoft.com/azure-messaging/eventhubs-emulator:2.2.0@sha256:2c8e0d4dd93a5fc078df2721eeb3e211442d555d61293ac3972df931c6d9333a COSMOSDB_EMULATOR_IMAGE: mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:vnext-preview@sha256:bf3bd3cbe3fae2005a0745f77b01d1fc8cd04112193f3ee32289ee15ee9ae5fa + CONSUL_IMAGE: public.ecr.aws/hashicorp/consul:1.19 + DYNAMODB_IMAGE: amazon/dynamodb-local:latest + ELASTICMQ_IMAGE: softwaremill/elasticmq-native:latest + MSSQL_IMAGE: mcr.microsoft.com/mssql/server:latest + NATS_IMAGE: nats:latest + POSTGRES_IMAGE: postgres + REDIS_IMAGE: redis + TESTCONTAINERS_RYUK_IMAGE: testcontainers/ryuk:0.14.0 + ZOOKEEPER_IMAGE: zookeeper:3.9 jobs: build: name: Build @@ -56,18 +65,24 @@ jobs: matrix: provider: ["Redis"] framework: ["net8.0", "net10.0"] - services: - redis: - image: redis - ports: - - 6379:6379 - options: >- - --health-cmd "redis-cli ping" - --health-interval 10s - --health-timeout 5s - --health-retries 5 steps: - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + - name: Start Redis + shell: bash + run: | + set -euo pipefail + + bash .github/scripts/docker-pull-with-retry.sh "$REDIS_IMAGE" + + docker run -d --pull=never --name redis \ + -p 6379:6379 \ + --health-cmd "redis-cli ping" \ + --health-interval 10s \ + --health-timeout 5s \ + --health-retries 5 \ + "$REDIS_IMAGE" + + bash .github/scripts/docker-wait-for-health.sh redis - name: Setup .NET uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5 with: @@ -84,6 +99,9 @@ jobs: -parallel none -noshadow env: ORLEANSREDISCONNECTIONSTRING: "localhost:6379,ssl=False,abortConnect=False" + - name: Clean up Redis + if: always() + run: docker rm -f redis || true - name: Archive Test Results if: always() continue-on-error: true @@ -100,6 +118,7 @@ jobs: continue-on-error: true env: BuildExternalAssets: "false" + CASSANDRA_IMAGE: cassandra:${{ matrix.dbversion }} strategy: matrix: provider: ["Cassandra"] @@ -107,6 +126,13 @@ jobs: framework: ["net8.0", "net10.0"] steps: - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + - name: Pre-pull Cassandra test images + shell: bash + run: | + set -euo pipefail + + bash .github/scripts/docker-pull-with-retry.sh "$TESTCONTAINERS_RYUK_IMAGE" + bash .github/scripts/docker-pull-with-retry.sh "$CASSANDRA_IMAGE" - name: Setup .NET uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5 with: @@ -139,25 +165,31 @@ jobs: continue-on-error: true env: BuildExternalAssets: "false" +# [SuppressMessage("Microsoft.Security", "CS002:SecretInNextLine", Justification="Not a secret")] + POSTGRES_PASSWORD: postgres strategy: matrix: provider: ["PostgreSql"] framework: ["net8.0", "net10.0"] - services: - postgres: - image: postgres - env: -# [SuppressMessage("Microsoft.Security", "CS002:SecretInNextLine", Justification="False positive")] - POSTGRES_PASSWORD: postgres - ports: - - 5432:5432 - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 steps: - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + - name: Start PostgreSQL + shell: bash + run: | + set -euo pipefail + + bash .github/scripts/docker-pull-with-retry.sh "$POSTGRES_IMAGE" + + docker run -d --pull=never --name postgres \ + -p 5432:5432 \ + -e POSTGRES_PASSWORD="$POSTGRES_PASSWORD" \ + --health-cmd "pg_isready -U postgres" \ + --health-interval 10s \ + --health-timeout 5s \ + --health-retries 5 \ + "$POSTGRES_IMAGE" + + bash .github/scripts/docker-wait-for-health.sh postgres - name: Setup .NET uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5 with: @@ -175,6 +207,9 @@ jobs: env: # [SuppressMessage("Microsoft.Security", "CS002:SecretInNextLine", Justification="False positive")] ORLEANSPOSTGRESCONNECTIONSTRING: "Server=127.0.0.1;Port=5432;Pooling=false;User Id=postgres;Password=postgres;SSL Mode=Disable" + - name: Clean up PostgreSQL + if: always() + run: docker rm -f postgres || true - name: Archive Test Results if: always() continue-on-error: true @@ -238,22 +273,33 @@ jobs: continue-on-error: true env: BuildExternalAssets: "false" + ACCEPT_EULA: "Y" + MSSQL_PID: "Developer" +# [SuppressMessage("Microsoft.Security", "CS002:SecretInNextLine", Justification="Not a secret")] + SA_PASSWORD: "yourWeak(!)Password" strategy: matrix: provider: ["SqlServer"] framework: ["net8.0", "net10.0"] - services: - mssql: - image: mcr.microsoft.com/mssql/server:latest - ports: - - 1433:1433 - env: - ACCEPT_EULA: "Y" - MSSQL_PID: "Developer" -# [SuppressMessage("Microsoft.Security", "CS002:SecretInNextLine", Justification="False positive")] - SA_PASSWORD: "yourWeak(!)Password" steps: - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + - name: Start SQL Server + shell: bash + run: | + set -euo pipefail + + bash .github/scripts/docker-pull-with-retry.sh "$MSSQL_IMAGE" + + docker run -d --pull=never --name mssql \ + -p 1433:1433 \ + -e ACCEPT_EULA="$ACCEPT_EULA" \ + -e MSSQL_PID="$MSSQL_PID" \ + -e SA_PASSWORD="$SA_PASSWORD" \ + "$MSSQL_IMAGE" + + echo "Waiting for SQL Server to accept TCP connections..." + timeout 120 bash -c 'until nc -z localhost 1433; do sleep 2; done' + echo "SQL Server is accepting TCP connections" - name: Setup .NET uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5 with: @@ -271,6 +317,9 @@ jobs: env: # [SuppressMessage("Microsoft.Security", "CS002:SecretInNextLine", Justification="Not a secret")] ORLEANSMSSQLCONNECTIONSTRING: "Server=127.0.0.1,1433;User Id=SA;Password=yourWeak(!)Password;TrustServerCertificate=True;" + - name: Clean up SQL Server + if: always() + run: docker rm -f mssql || true - name: Archive Test Results if: always() continue-on-error: true @@ -532,6 +581,13 @@ jobs: framework: ["net8.0", "net10.0"] steps: - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + - name: Pre-pull Consul test images + shell: bash + run: | + set -euo pipefail + + bash .github/scripts/docker-pull-with-retry.sh "$TESTCONTAINERS_RYUK_IMAGE" + bash .github/scripts/docker-pull-with-retry.sh "$CONSUL_IMAGE" - name: Setup .NET uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5 with: @@ -566,13 +622,22 @@ jobs: matrix: provider: ["ZooKeeper"] framework: ["net8.0", "net10.0"] - services: - zookeeper: - image: zookeeper:3.9 - ports: - - 2181:2181 steps: - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + - name: Start ZooKeeper + shell: bash + run: | + set -euo pipefail + + bash .github/scripts/docker-pull-with-retry.sh "$ZOOKEEPER_IMAGE" + + docker run -d --pull=never --name zookeeper \ + -p 2181:2181 \ + "$ZOOKEEPER_IMAGE" + + echo "Waiting for ZooKeeper to accept TCP connections..." + timeout 60 bash -c 'until nc -z localhost 2181; do sleep 1; done' + echo "ZooKeeper is accepting TCP connections" - name: Setup .NET uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5 with: @@ -589,6 +654,9 @@ jobs: -parallel none -noshadow env: ORLEANSZOOKEEPERCONNECTIONSTRING: "localhost:2181" + - name: Clean up ZooKeeper + if: always() + run: docker rm -f zookeeper || true - name: Archive Test Results if: always() continue-on-error: true @@ -605,23 +673,34 @@ jobs: continue-on-error: true env: BuildExternalAssets: "false" +# [SuppressMessage("Microsoft.Security", "CS002:SecretInNextLine", Justification="Not a secret")] + AWS_ACCESS_KEY_ID: root +# [SuppressMessage("Microsoft.Security", "CS002:SecretInNextLine", Justification="Not a secret")] + AWS_SECRET_ACCESS_KEY: pass + AWS_REGION: us-east-1 strategy: matrix: provider: ["DynamoDB"] framework: ["net8.0", "net10.0"] - services: - dynamodb: - image: amazon/dynamodb-local:latest - ports: - - 8000:8000 - env: -# [SuppressMessage("Microsoft.Security", "CS002:SecretInNextLine", Justification="Not a secret")] - AWS_ACCESS_KEY_ID: root -# [SuppressMessage("Microsoft.Security", "CS002:SecretInNextLine", Justification="Not a secret")] - AWS_SECRET_ACCESS_KEY: pass - AWS_REGION: us-east-1 steps: - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + - name: Start DynamoDB Local + shell: bash + run: | + set -euo pipefail + + bash .github/scripts/docker-pull-with-retry.sh "$DYNAMODB_IMAGE" + + docker run -d --pull=never --name dynamodb \ + -p 8000:8000 \ + -e AWS_ACCESS_KEY_ID="$AWS_ACCESS_KEY_ID" \ + -e AWS_SECRET_ACCESS_KEY="$AWS_SECRET_ACCESS_KEY" \ + -e AWS_REGION="$AWS_REGION" \ + "$DYNAMODB_IMAGE" + + echo "Waiting for DynamoDB Local to accept TCP connections..." + timeout 60 bash -c 'until nc -z localhost 8000; do sleep 1; done' + echo "DynamoDB Local is accepting TCP connections" - name: Setup .NET uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5 with: @@ -642,6 +721,9 @@ jobs: ORLEANSDYNAMODBACCESSKEY: "root" # [SuppressMessage("Microsoft.Security", "CS002:SecretInNextLine", Justification="Not a secret")] ORLEANSDYNAMODBSECRETKEY: "pass" + - name: Clean up DynamoDB Local + if: always() + run: docker rm -f dynamodb || true - name: Archive Test Results if: always() continue-on-error: true @@ -658,30 +740,45 @@ jobs: continue-on-error: true env: BuildExternalAssets: "false" +# [SuppressMessage("Microsoft.Security", "CS002:SecretInNextLine", Justification="Not a secret")] + AWS_ACCESS_KEY_ID: root +# [SuppressMessage("Microsoft.Security", "CS002:SecretInNextLine", Justification="Not a secret")] + AWS_SECRET_ACCESS_KEY: pass + AWS_REGION: us-east-1 strategy: matrix: provider: ["SQS"] framework: ["net8.0", "net10.0"] - services: - dynamodb: - image: amazon/dynamodb-local:latest - ports: - - 8000:8000 - env: -# [SuppressMessage("Microsoft.Security", "CS002:SecretInNextLine", Justification="Not a secret")] - AWS_ACCESS_KEY_ID: root -# [SuppressMessage("Microsoft.Security", "CS002:SecretInNextLine", Justification="Not a secret")] - AWS_SECRET_ACCESS_KEY: pass - AWS_REGION: us-east-1 steps: + - uses: actions/checkout@v4 + - name: Start DynamoDB Local + shell: bash + run: | + set -euo pipefail + + bash .github/scripts/docker-pull-with-retry.sh "$DYNAMODB_IMAGE" + + docker run -d --pull=never --name dynamodb \ + -p 8000:8000 \ + -e AWS_ACCESS_KEY_ID="$AWS_ACCESS_KEY_ID" \ + -e AWS_SECRET_ACCESS_KEY="$AWS_SECRET_ACCESS_KEY" \ + -e AWS_REGION="$AWS_REGION" \ + "$DYNAMODB_IMAGE" - name: Start ElasticMQ - run: docker run -d --name elasticmq -p 9324:9324 softwaremill/elasticmq-native:latest + shell: bash + run: | + set -euo pipefail + + bash .github/scripts/docker-pull-with-retry.sh "$ELASTICMQ_IMAGE" + + docker run -d --pull=never --name elasticmq \ + -p 9324:9324 \ + "$ELASTICMQ_IMAGE" - name: Wait for ElasticMQ and DynamoDB run: | echo "Waiting for ElasticMQ and DynamoDB Local to be ready..." timeout 60 bash -c 'until nc -z localhost 9324 && nc -z localhost 8000; do sleep 1; done' echo "ElasticMQ and DynamoDB Local are ready" - - uses: actions/checkout@v4 - name: Setup .NET uses: actions/setup-dotnet@v4 with: @@ -706,7 +803,10 @@ jobs: ORLEANSDYNAMODBSECRETKEY: "pass" - name: Clean up ElasticMQ if: always() - run: docker rm -f elasticmq + run: docker rm -f elasticmq || true + - name: Clean up DynamoDB Local + if: always() + run: docker rm -f dynamodb || true - name: Archive Test Results if: always() continue-on-error: true @@ -736,8 +836,19 @@ jobs: # env: # HTTP_PORT: 8222 steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - name: Start NATS - run: docker run -d --name nats -p 4222:4222 -p 8222:8222 nats:latest --js --http_port=8222 + shell: bash + run: | + set -euo pipefail + + bash .github/scripts/docker-pull-with-retry.sh "$NATS_IMAGE" + + docker run -d --pull=never --name nats \ + -p 4222:4222 \ + -p 8222:8222 \ + "$NATS_IMAGE" \ + --js --http_port=8222 - name: Wait for NATS run: | echo "Waiting for NATS to be ready..." @@ -746,7 +857,6 @@ jobs: exit 1 fi echo "NATS is ready" - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - name: Setup .NET uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5 with: @@ -764,7 +874,7 @@ jobs: -parallel none -noshadow - name: Clean up container if: always() - run: docker rm -f nats + run: docker rm -f nats || true - name: Archive Test Results if: always() continue-on-error: true