diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index b548f1f58d..a75a218da4 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -83,6 +83,7 @@ jobs: timeout-minutes: 30 outputs: version: ${{ steps.version.outputs.version }} + elasticsearch_image_tag: ${{ steps.elasticsearch_image.outputs.tag }} should_publish: ${{ (github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'dev-preview')) && secrets.DOCKER_USERNAME != '' && secrets.DOCKER_PASSWORD != '' }} is_prod_deploy: ${{ startsWith(github.ref, 'refs/tags/v') && github.event_name != 'pull_request' }} is_dev_deploy: ${{ (github.event_name == 'repository_dispatch' && github.event.action == 'preview') || (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'dev-preview')) }} @@ -129,9 +130,52 @@ jobs: echo "version=$version" >> $GITHUB_OUTPUT echo "### $version" >> $GITHUB_STEP_SUMMARY + - name: Resolve Elasticsearch image + id: elasticsearch_image + env: + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PUSH_BEFORE_SHA: ${{ github.event.before }} + run: | + version=$(sed -n 's/.*elasticsearch:\([^ ]*\).*/\1/p' build/docker/elasticsearch/9.x/Dockerfile) + tag=$version + image_changed=false + + if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]] && + ! git diff --quiet "$PR_BASE_SHA"...HEAD -- build/docker/elasticsearch/9.x .github/workflows/elasticsearch-docker-9.yml; then + image_changed=true + elif [[ "$GITHUB_EVENT_NAME" == "push" && "$GITHUB_REF" == "refs/heads/main" ]] && + ! git diff --quiet "$PUSH_BEFORE_SHA"..HEAD -- build/docker/elasticsearch/9.x .github/workflows/elasticsearch-docker-9.yml; then + image_changed=true + fi + + if [[ "$image_changed" == "true" ]]; then + image_sha=$(git ls-files -- build/docker/elasticsearch/9.x .github/workflows/elasticsearch-docker-9.yml | sort | xargs sha256sum | sha256sum | cut -d " " -f 1) + tag="$version-sha256-$image_sha" + image="exceptionless/elasticsearch:$tag" + + for attempt in {1..150}; do + if docker manifest inspect "$image" > /dev/null 2>&1; then + break + fi + + if [[ "$attempt" -eq 150 ]]; then + echo "::error::Timed out waiting for $image to be published." + exit 1 + fi + + sleep 10 + done + fi + + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "### Elasticsearch image: exceptionless/elasticsearch:$tag" >> "$GITHUB_STEP_SUMMARY" + test-api: + needs: version runs-on: ubuntu-latest timeout-minutes: 30 + env: + Elasticsearch__ImageTag: ${{ needs.version.outputs.elasticsearch_image_tag }} steps: - name: Checkout @@ -223,8 +267,11 @@ jobs: run: echo "npm run test:integration" test-e2e: + needs: version runs-on: ubuntu-latest timeout-minutes: 45 + env: + Elasticsearch__ImageTag: ${{ needs.version.outputs.elasticsearch_image_tag }} steps: - name: Checkout diff --git a/.github/workflows/elasticsearch-docker-8.yml b/.github/workflows/elasticsearch-docker-8.yml index 6f65a4ccd6..f8577d97c8 100644 --- a/.github/workflows/elasticsearch-docker-8.yml +++ b/.github/workflows/elasticsearch-docker-8.yml @@ -43,4 +43,4 @@ jobs: working-directory: build/docker/elasticsearch/8.x run: | VERSION=$(sed -n 's/.*elasticsearch:\([^ ]*\).*/\1/p' Dockerfile) - docker buildx build --platform linux/amd64,linux/arm64 --output "type=image,push=true" --file ./Dockerfile . --tag exceptionless/elasticsearch:$VERSION --tag exceptionless/elasticsearch:latest + docker buildx build --platform linux/amd64,linux/arm64 --output "type=image,push=true" --file ./Dockerfile . --tag exceptionless/elasticsearch:$VERSION diff --git a/.github/workflows/elasticsearch-docker-9.yml b/.github/workflows/elasticsearch-docker-9.yml new file mode 100644 index 0000000000..a911616691 --- /dev/null +++ b/.github/workflows/elasticsearch-docker-9.yml @@ -0,0 +1,52 @@ +name: Elasticsearch 9.x Docker Image CI + +on: + push: + paths: + - "build/docker/elasticsearch/9.x/**" + - ".github/workflows/elasticsearch-docker-9.yml" + +jobs: + build: + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/v') != true + + steps: + - uses: actions/checkout@v7 + - name: Setup .NET Core + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.301 + - name: Build Reason + env: + GITHUB_EVENT: ${{ toJson(github) }} + run: "echo ref: ${{github.ref}} event: ${{github.event_name}}" + - name: Build Version + run: | + dotnet tool install --global minver-cli --version 7.0.0 + version=$(minver --tag-prefix v) + echo "MINVERVERSIONOVERRIDE=$version" >> $GITHUB_ENV + echo "VERSION=$version" >> $GITHUB_ENV + echo "### Version: $version" >> $GITHUB_STEP_SUMMARY + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + - name: Login to DockerHub + uses: docker/login-action@v4 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + with: + platforms: linux/amd64,linux/arm64 + - name: Build custom Elasticsearch 9.x docker image + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + VERSION=$(sed -n 's/.*elasticsearch:\([^ ]*\).*/\1/p' build/docker/elasticsearch/9.x/Dockerfile) + IMAGE_SHA=$(git ls-files -- build/docker/elasticsearch/9.x .github/workflows/elasticsearch-docker-9.yml | sort | xargs sha256sum | sha256sum | cut -d " " -f 1) + TAGS=(--tag "exceptionless/elasticsearch:$VERSION-sha256-$IMAGE_SHA") + if [[ "$GITHUB_REF" == "refs/heads/$DEFAULT_BRANCH" ]]; then + TAGS+=(--tag "exceptionless/elasticsearch:$VERSION" --tag "exceptionless/elasticsearch:latest") + fi + docker buildx build --platform linux/amd64,linux/arm64 --output "type=image,push=true" --file build/docker/elasticsearch/9.x/Dockerfile build/docker/elasticsearch/9.x "${TAGS[@]}" diff --git a/Dockerfile b/Dockerfile index 80cc955548..bc0a259ca0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -100,7 +100,7 @@ ENTRYPOINT ["/app/app-docker-entrypoint.sh"] # completely self-contained -FROM exceptionless/elasticsearch:8.19.15 AS exceptionless +FROM exceptionless/elasticsearch:9.5.0 AS exceptionless WORKDIR /app COPY --from=job-publish /app/src/Exceptionless.Job/out ./ diff --git a/build/docker/elasticsearch/9.x/Dockerfile b/build/docker/elasticsearch/9.x/Dockerfile new file mode 100644 index 0000000000..cc03ebce5a --- /dev/null +++ b/build/docker/elasticsearch/9.x/Dockerfile @@ -0,0 +1,4 @@ +# https://www.docker.elastic.co/ +FROM docker.elastic.co/elasticsearch/elasticsearch:9.5.0 + +RUN elasticsearch-plugin install -b mapper-size diff --git a/docker/docker-compose.apm.yml b/docker/docker-compose.apm.yml index bd55972eb9..220098afad 100644 --- a/docker/docker-compose.apm.yml +++ b/docker/docker-compose.apm.yml @@ -2,7 +2,7 @@ version: "2.2" services: setup: - image: docker.elastic.co/elasticsearch/elasticsearch:8.19.15 + image: docker.elastic.co/elasticsearch/elasticsearch:9.5.0 volumes: - certs:/usr/share/elasticsearch/config/certs user: "0" @@ -53,7 +53,7 @@ services: depends_on: setup: condition: service_healthy - image: docker.elastic.co/elasticsearch/elasticsearch:8.19.15 + image: docker.elastic.co/elasticsearch/elasticsearch:9.5.0 volumes: - certs:/usr/share/elasticsearch/config/certs - esdata:/usr/share/elasticsearch/data @@ -98,7 +98,7 @@ services: depends_on: elasticsearch: condition: service_healthy - image: docker.elastic.co/kibana/kibana:8.19.15 + image: docker.elastic.co/kibana/kibana:9.5.0 volumes: - certs:/usr/share/kibana/config/certs ports: @@ -124,7 +124,7 @@ services: depends_on: elasticsearch: condition: service_healthy - image: docker.elastic.co/apm/apm-server:8.19.15 + image: docker.elastic.co/apm/apm-server:9.5.0 volumes: - certs:/usr/share/apm-server/certs ports: diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index a18d81b4cf..2b8518034b 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -50,7 +50,7 @@ services: - appdata:/app/storage elasticsearch: - image: exceptionless/elasticsearch:8.19.15 + image: exceptionless/elasticsearch:9.5.0 environment: discovery.type: single-node xpack.security.enabled: "false" @@ -59,6 +59,8 @@ services: - 9200:9200 - 9300:9300 volumes: + # Complete the Elasticsearch 8.19 upgrade preflight before reusing this volume with Elasticsearch 9. + # See https://exceptionless.com/docs/self-hosting/upgrading-self-hosted-instance - esdata7:/usr/share/elasticsearch/data healthcheck: test: @@ -74,7 +76,7 @@ services: kibana: depends_on: - elasticsearch - image: docker.elastic.co/kibana/kibana:8.19.15 + image: docker.elastic.co/kibana/kibana:9.5.0 ports: - 5601:5601 diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index c81594d41f..b1706174b1 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,6 +1,6 @@ services: elasticsearch: - image: exceptionless/elasticsearch:8.19.15 + image: exceptionless/elasticsearch:9.5.0 environment: node.name: elasticsearch cluster.name: exceptionless @@ -11,6 +11,8 @@ services: ports: - 9200:9200 volumes: + # Complete the Elasticsearch 8.19 upgrade preflight before reusing this volume with Elasticsearch 9. + # See https://exceptionless.com/docs/self-hosting/upgrading-self-hosted-instance - esdata:/usr/share/elasticsearch/data healthcheck: test: @@ -26,7 +28,7 @@ services: kibana: depends_on: - elasticsearch - image: docker.elastic.co/kibana/kibana:8.19.15 + image: docker.elastic.co/kibana/kibana:9.5.0 environment: xpack.security.enabled: "false" ports: diff --git a/docs/docs/self-hosting/upgrading-self-hosted-instance.md b/docs/docs/self-hosting/upgrading-self-hosted-instance.md index dd0974989f..2335c9e421 100644 --- a/docs/docs/self-hosting/upgrading-self-hosted-instance.md +++ b/docs/docs/self-hosting/upgrading-self-hosted-instance.md @@ -8,6 +8,41 @@ title: "Upgrading" **If you are upgrading from v1 or [v2](https://github.com/exceptionless/Exceptionless/releases/tag/v2.0.0) you will need to upgrade to [v3.0](https://github.com/exceptionless/Exceptionless/releases/tag/v3.0.0) before upgrading to the latest release.** +## Upgrading from v8 to v9 + +Exceptionless v9 uses Elasticsearch 9. Do not point an Elasticsearch 9 node at an existing data volume until the cluster has been prepared with Elasticsearch 8.19. Elasticsearch 9 can fail to start when incompatible indices created before Elasticsearch 8 remain. + +Use this upgrade path for an existing self-hosted installation: + +1. Take a current Elasticsearch snapshot or other verified backup and test that it can be restored. Elasticsearch does not support downgrading a data directory after it has been upgraded. +2. Upgrade Elasticsearch and Kibana to the latest 8.19.x patch release first, using the existing data volume. Do not start Elasticsearch 9 yet. +3. Stop the Exceptionless app and job services, but leave Elasticsearch and Kibana 8.19 running. This prevents writes while legacy indices are reindexed. +4. Open Kibana's **Upgrade Assistant** and resolve every critical issue. Reindex every active Exceptionless index created before Elasticsearch 8. Delete only indices you have confirmed are no longer needed; do not mark active Exceptionless indices as read-only. +5. If this data volume previously ran Elasticsearch 7, temporarily disable the GeoIP downloader while still on Elasticsearch 8.19. Elasticsearch deletes its downloaded `.geoip_databases` system index when this setting is disabled; Exceptionless data is not affected. + + ```bash + curl -fsS -X PUT "http://localhost:9200/_cluster/settings" \ + -H "Content-Type: application/json" \ + -d '{"persistent":{"ingest.geoip.downloader.enabled":false}}' + ``` + +6. Confirm that the deprecation API reports no critical issues: + + ```bash + curl -fsS "http://localhost:9200/_migration/deprecations?pretty" + ``` + +7. Stop Elasticsearch and Kibana 8.19 without deleting their data volume. Update the Elasticsearch and Kibana images to the v9 versions, start them, and verify cluster health before restarting the Exceptionless app and jobs. In Docker Compose, do not run `docker compose down -v` because `-v` deletes the data volume. +8. After Elasticsearch 9 is healthy, restore the default GeoIP downloader behavior: + + ```bash + curl -fsS -X PUT "http://localhost:9200/_cluster/settings" \ + -H "Content-Type: application/json" \ + -d '{"persistent":{"ingest.geoip.downloader.enabled":null}}' + ``` + +See Elastic's [prepare-to-upgrade guide](https://www.elastic.co/docs/deploy-manage/upgrade/prepare-to-upgrade) for the supported 8.x to 9.x upgrade requirements and Upgrade Assistant details. + ## Upgrading from v7.1 to v8 We simplified the self hosting process by integrating the UI into the existing app images. As such `exceptionless/ui` docker images are deprecated and we recommend using `exceptionless/app`. diff --git a/k8s/elastic-monitor.yaml b/k8s/elastic-monitor.yaml index 943cde867d..f1c47ad253 100644 --- a/k8s/elastic-monitor.yaml +++ b/k8s/elastic-monitor.yaml @@ -4,7 +4,7 @@ metadata: name: elastic-monitor namespace: elastic-system spec: - version: 8.19.15 + version: 9.5.0 podDisruptionBudget: {} nodeSets: - name: main @@ -228,7 +228,7 @@ metadata: name: kibana-monitor namespace: elastic-system spec: - version: 8.19.15 + version: 9.5.0 count: 1 http: tls: @@ -364,7 +364,7 @@ metadata: name: fleet-server namespace: elastic-system spec: - version: 8.19.15 + version: 9.5.0 kibanaRef: name: kibana-monitor elasticsearchRefs: @@ -388,7 +388,7 @@ metadata: name: elastic-agent namespace: elastic-system spec: - version: 8.19.15 + version: 9.5.0 kibanaRef: name: kibana-monitor fleetServerRef: diff --git a/k8s/ex-dev-elasticsearch.yaml b/k8s/ex-dev-elasticsearch.yaml index 46f14cc5eb..6a8b1fd851 100644 --- a/k8s/ex-dev-elasticsearch.yaml +++ b/k8s/ex-dev-elasticsearch.yaml @@ -14,8 +14,8 @@ metadata: name: ex-dev namespace: ex-dev spec: - version: 8.19.15 - image: exceptionless/elasticsearch:8.19.15 # https://github.com/exceptionless/Exceptionless/tree/main/build/docker/elasticsearch + version: 9.5.0 + image: exceptionless/elasticsearch:9.5.0 # https://github.com/exceptionless/Exceptionless/tree/main/build/docker/elasticsearch secureSettings: - secretName: ex-dev-snapshots http: @@ -68,7 +68,7 @@ metadata: name: ex-dev namespace: ex-dev spec: - version: 8.19.15 + version: 9.5.0 count: 1 elasticsearchRef: name: ex-dev diff --git a/k8s/ex-prod-elasticsearch.yaml b/k8s/ex-prod-elasticsearch.yaml index f159ae3046..de7848bce1 100644 --- a/k8s/ex-prod-elasticsearch.yaml +++ b/k8s/ex-prod-elasticsearch.yaml @@ -14,8 +14,8 @@ metadata: name: ex-prod namespace: ex-prod spec: - version: 8.19.15 - image: exceptionless/elasticsearch:8.19.15 # https://github.com/exceptionless/Exceptionless/tree/main/build/docker/elasticsearch + version: 9.5.0 + image: exceptionless/elasticsearch:9.5.0 # https://github.com/exceptionless/Exceptionless/tree/main/build/docker/elasticsearch monitoring: metrics: elasticsearchRefs: @@ -79,7 +79,7 @@ metadata: name: ex-prod namespace: ex-prod spec: - version: 8.19.15 + version: 9.5.0 count: 1 elasticsearchRef: name: ex-prod diff --git a/k8s/exceptionless/values.yaml b/k8s/exceptionless/values.yaml index 9b9febe39b..8018140e5f 100644 --- a/k8s/exceptionless/values.yaml +++ b/k8s/exceptionless/values.yaml @@ -35,7 +35,7 @@ elasticsearch: connectionString: image: repository: exceptionless/elasticsearch - tag: 8.19.15 + tag: 9.5.0 pullPolicy: IfNotPresent redis: diff --git a/samples/docker-compose.all-in-one.yml b/samples/docker-compose.all-in-one.yml index 5b1caf2d42..622e74830e 100644 --- a/samples/docker-compose.all-in-one.yml +++ b/samples/docker-compose.all-in-one.yml @@ -20,7 +20,7 @@ services: kibana: depends_on: - elasticsearch - image: docker.elastic.co/kibana/kibana:8.19.15 + image: docker.elastic.co/kibana/kibana:9.5.0 ports: - 5601:5601 diff --git a/samples/docker-compose.yml b/samples/docker-compose.yml index d73e0518c9..7fcb4cc0de 100644 --- a/samples/docker-compose.yml +++ b/samples/docker-compose.yml @@ -44,7 +44,7 @@ services: - ex_appdata:/app/storage elasticsearch: - image: exceptionless/elasticsearch:8.19.15 + image: exceptionless/elasticsearch:9.5.0 environment: discovery.type: single-node xpack.security.enabled: "false" @@ -58,7 +58,7 @@ services: kibana: depends_on: - elasticsearch - image: docker.elastic.co/kibana/kibana:8.19.15 + image: docker.elastic.co/kibana/kibana:9.5.0 ports: - 5601:5601 diff --git a/src/Exceptionless.AppHost/Extensions/ElasticsearchExtensions.cs b/src/Exceptionless.AppHost/Extensions/ElasticsearchExtensions.cs index ec5c5e4031..4022e89583 100644 --- a/src/Exceptionless.AppHost/Extensions/ElasticsearchExtensions.cs +++ b/src/Exceptionless.AppHost/Extensions/ElasticsearchExtensions.cs @@ -13,7 +13,7 @@ public static class ElasticsearchBuilderExtensions private const int KibanaPort = 5601; /// - /// Adds a Elasticsearch container to the application model. The default image is "docker.elastic.co/elasticsearch/elasticsearch". This version the package defaults to the 8.19.15 tag of the Elasticsearch container image + /// Adds a Elasticsearch container to the application model. The default image is "docker.elastic.co/elasticsearch/elasticsearch". This version the package defaults to the 9.5.0 tag of the Elasticsearch container image /// /// The . /// The name of the resource. This name will be used as the connection string name when referenced in a dependency. @@ -60,7 +60,11 @@ public static IResourceBuilder AddElasticsearch(this IDis .PublishAsConnectionString(); } - public static IResourceBuilder WithKibana(this IResourceBuilder builder, Action>? configureContainer = null, string? containerName = null) + public static IResourceBuilder WithKibana( + this IResourceBuilder builder, + Action>? configureContainer = null, + string? containerName = null, + int? port = null) { ArgumentNullException.ThrowIfNull(builder); @@ -79,7 +83,7 @@ public static IResourceBuilder WithKibana(this IResourceB var resourceBuilder = builder.ApplicationBuilder.AddResource(resource) .WithImage(ElasticsearchContainerImageTags.KibanaImage, ElasticsearchContainerImageTags.Tag) .WithImageRegistry(ElasticsearchContainerImageTags.KibanaRegistry) - .WithHttpEndpoint(targetPort: KibanaPort, name: containerName) + .WithHttpEndpoint(targetPort: KibanaPort, port: port, name: containerName) .WithUrlForEndpoint(containerName, u => u.DisplayText = "Kibana") .WithEnvironment("xpack.security.enabled", "false") .WithEnvironment(ctx => @@ -121,7 +125,7 @@ internal static class ElasticsearchContainerImageTags public const string Image = "exceptionless/elasticsearch"; public const string KibanaRegistry = "docker.elastic.co"; public const string KibanaImage = "kibana/kibana"; - public const string Tag = "8.19.15"; + public const string Tag = "9.5.0"; } internal sealed class ElasticsearchConnectionHealthCheck(Func connectionStringFactory) : IHealthCheck @@ -134,9 +138,17 @@ public async Task CheckHealthAsync(HealthCheckContext context using var settings = new ElasticsearchClientSettings(new Uri(connectionString)); var client = new ElasticsearchClient(settings); - var response = await client.PingAsync(cancellationToken); - return response.IsValidResponse - ? HealthCheckResult.Healthy() - : new HealthCheckResult(context.Registration.FailureStatus, $"Elasticsearch ping failed: {response.DebugInformation}"); + var response = await client.Cluster.HealthAsync( + request => request.WaitForStatus(Elastic.Clients.Elasticsearch.HealthStatus.Yellow), + cancellationToken); + bool isReady = response.IsValidResponse + && !response.TimedOut + && response.Status is Elastic.Clients.Elasticsearch.HealthStatus.Yellow or Elastic.Clients.Elasticsearch.HealthStatus.Green; + if (isReady) + return HealthCheckResult.Healthy(); + + return new HealthCheckResult( + context.Registration.FailureStatus, + $"Elasticsearch cluster health check failed. Timed out: {response.TimedOut}; status: {response.Status}. {response.DebugInformation}"); } } diff --git a/src/Exceptionless.AppHost/Program.cs b/src/Exceptionless.AppHost/Program.cs index 51db032edf..0917cd73d7 100644 --- a/src/Exceptionless.AppHost/Program.cs +++ b/src/Exceptionless.AppHost/Program.cs @@ -9,6 +9,13 @@ bool servicesOnly = HasArgument("--services-only"); bool ciE2E = HasArgument("--ci-e2e"); bool includeDevTools = !ciE2E; +int elasticsearchPort = GetPort("Elasticsearch:Port", 9200); +string elasticsearchImageTag = builder.Configuration["Elasticsearch:ImageTag"] ?? ElasticsearchContainerImageTags.Tag; +string kibanaImageTag = builder.Configuration["Elasticsearch:KibanaImageTag"] ?? ElasticsearchContainerImageTags.Tag; +string elasticsearchContainerName = builder.Configuration["Elasticsearch:ContainerName"] ?? "Exceptionless-Elasticsearch"; +string elasticsearchDataVolume = builder.Configuration["Elasticsearch:DataVolume"] ?? "exceptionless.data.v1"; +string kibanaContainerName = builder.Configuration["Elasticsearch:KibanaContainerName"] ?? "Exceptionless-Kibana"; +int kibanaPort = GetPort("Elasticsearch:KibanaPort", 5601); int oldAppHttpPort = worktreePorts?.OldAppHttp ?? 7120; int oldAppPort = worktreePorts?.OldAppHttps ?? 7121; int oldAppLiveReloadPort = worktreePorts?.OldAppLiveReload ?? 35729; @@ -19,8 +26,9 @@ string exceptionlessServerUrl = worktreePorts?.ApiHttpsUrl ?? $"https://api-ex.dev.localhost:{DefaultApiHttpsPort}"; const string SharedEmailConnectionString = "smtp://localhost:1026"; -var elastic = builder.AddElasticsearch("Elasticsearch", port: 9200) - .WithDataVolume("exceptionless.data.v1") +var elastic = builder.AddElasticsearch("Elasticsearch", port: elasticsearchPort) + .WithImageTag(elasticsearchImageTag) + .WithDataVolume(elasticsearchDataVolume) .WithEndpointProxySupport(false); var storage = builder.AddAzureStorage("Storage") @@ -65,15 +73,17 @@ var ownedElastic = elastic; elastic = ownedElastic .WithLifetime(ContainerLifetime.Persistent) - .WithContainerName("Exceptionless-Elasticsearch"); + .WithContainerName(elasticsearchContainerName); if (!servicesOnly && includeDevTools) { elastic = elastic.WithKibana(b => b + .WithImageTag(kibanaImageTag) .WithLifetime(ContainerLifetime.Persistent) .WithEndpointProxySupport(false) - .WithContainerName("Exceptionless-Kibana") - .WithParentRelationship(ownedElastic)); + .WithContainerName(kibanaContainerName) + .WithParentRelationship(ownedElastic), + port: kibanaPort); } var ownedCache = cache; @@ -231,3 +241,15 @@ await builder.Build().RunAsync(); bool HasArgument(string name) => args.Any(arg => StringComparer.OrdinalIgnoreCase.Equals(arg, name) || StringComparer.OrdinalIgnoreCase.Equals(arg, name.TrimStart('-'))); + +int GetPort(string key, int defaultValue) +{ + string? value = builder.Configuration[key]; + if (String.IsNullOrWhiteSpace(value)) + return defaultValue; + + if (!Int32.TryParse(value, out int port) || port is < 1 or > 65535) + throw new InvalidOperationException($"Configuration value '{key}' must be a valid TCP port."); + + return port; +} diff --git a/tests/Exceptionless.Tests/AppHostConfigurationTests.cs b/tests/Exceptionless.Tests/AppHostConfigurationTests.cs new file mode 100644 index 0000000000..5ec833ceaa --- /dev/null +++ b/tests/Exceptionless.Tests/AppHostConfigurationTests.cs @@ -0,0 +1,30 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Testing; +using Xunit; + +namespace Exceptionless.Tests; + +public class AppHostConfigurationTests +{ + [Fact] + public async Task CreateAsync_WithSeparateElasticsearchAndKibanaOverrides_UsesIndependentImageTags() + { + const string elasticsearchImageTag = "9.5.0-sha256-candidate"; + const string kibanaImageTag = "9.5.0"; + var appHost = await DistributedApplicationTestingBuilder.CreateAsync( + [ + $"--Elasticsearch:ImageTag={elasticsearchImageTag}", + $"--Elasticsearch:KibanaImageTag={kibanaImageTag}" + ], + TestContext.Current.CancellationToken); + + var elasticsearch = Assert.Single(appHost.Resources.OfType()); + var kibana = Assert.Single(appHost.Resources.OfType()); + var elasticsearchImage = Assert.Single(elasticsearch.Annotations.OfType()); + var kibanaImage = Assert.Single(kibana.Annotations.OfType()); + + Assert.Equal(elasticsearchImageTag, elasticsearchImage.Tag); + Assert.Equal(kibanaImageTag, kibanaImage.Tag); + } +} diff --git a/tests/Exceptionless.Tests/AppWebHostFactory.cs b/tests/Exceptionless.Tests/AppWebHostFactory.cs index fa1d2f73ee..8b8c0c4f5b 100644 --- a/tests/Exceptionless.Tests/AppWebHostFactory.cs +++ b/tests/Exceptionless.Tests/AppWebHostFactory.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using System.Net; +using System.Text.Json; using Aspire.Hosting; using Aspire.Hosting.Testing; using Exceptionless.Core; @@ -21,7 +22,7 @@ namespace Exceptionless.Tests; public class AppWebHostFactory : WebApplicationFactory, IAsyncLifetime { - private const string SharedElasticsearchUrl = "http://localhost:9200"; + private static readonly string SharedElasticsearchUrl = GetSharedElasticsearchUrl(); private static readonly TimeSpan SharedElasticsearchStartupTimeout = TimeSpan.FromMinutes(3); private static int s_counter = -1; private static readonly Lazy> s_sharedAppHost = new(StartSharedAppHostAsync, LazyThreadSafetyMode.ExecutionAndPublication); @@ -58,22 +59,40 @@ private static async Task StartSharedAppHostAsync() return app; } + private static string GetSharedElasticsearchUrl() + { + const int defaultPort = 9200; + string? configuredPort = Environment.GetEnvironmentVariable("Elasticsearch__Port"); + if (String.IsNullOrWhiteSpace(configuredPort)) + return $"http://localhost:{defaultPort}"; + + if (!Int32.TryParse(configuredPort, out int port) || port is < 1 or > 65535) + throw new InvalidOperationException("Environment variable 'Elasticsearch__Port' must be a valid TCP port."); + + return $"http://localhost:{port}"; + } + private static async Task WaitForElasticsearchAsync(Uri elasticsearchUri) { - using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(1) }; + using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(2) }; var deadline = TimeProvider.System.GetUtcNow() + SharedElasticsearchStartupTimeout; + var healthUri = new Uri(elasticsearchUri, "/_cluster/health?wait_for_status=yellow&timeout=1s"); while (TimeProvider.System.GetUtcNow() < deadline) { try { - using var response = await client.GetAsync(elasticsearchUri); - if (response.StatusCode == HttpStatusCode.OK) + using var response = await client.GetAsync(healthUri); + using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); + if (IsElasticsearchReady(response.StatusCode, document.RootElement)) return; } catch (HttpRequestException) { } + catch (JsonException) + { + } catch (TaskCanceledException) { } @@ -84,6 +103,20 @@ private static async Task WaitForElasticsearchAsync(Uri elasticsearchUri) throw new TimeoutException("Timed out waiting for the shared Elasticsearch container to be ready."); } + internal static bool IsElasticsearchReady(HttpStatusCode statusCode, JsonElement health) + { + if (statusCode != HttpStatusCode.OK) + return false; + + bool requestCompleted = health.TryGetProperty("timed_out", out var timedOut) + && timedOut.ValueKind == JsonValueKind.False; + bool clusterReady = health.TryGetProperty("status", out var status) + && status.ValueKind == JsonValueKind.String + && status.GetString() is "yellow" or "green"; + + return requestCompleted && clusterReady; + } + protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.UseEnvironment(Environments.Development); diff --git a/tests/Exceptionless.Tests/AppWebHostFactoryTests.cs b/tests/Exceptionless.Tests/AppWebHostFactoryTests.cs index 7fef487faa..539785b681 100644 --- a/tests/Exceptionless.Tests/AppWebHostFactoryTests.cs +++ b/tests/Exceptionless.Tests/AppWebHostFactoryTests.cs @@ -1,4 +1,6 @@ +using System.Net; using System.Text; +using System.Text.Json; using Foundatio.Storage; using Microsoft.Extensions.DependencyInjection; using Xunit; @@ -7,6 +9,22 @@ namespace Exceptionless.Tests; public sealed class AppWebHostFactoryTests { + [Theory] + [InlineData(HttpStatusCode.OK, """{"timed_out":false,"status":"yellow"}""", true)] + [InlineData(HttpStatusCode.OK, """{"timed_out":false,"status":"green"}""", true)] + [InlineData(HttpStatusCode.OK, """{"timed_out":true,"status":"yellow"}""", false)] + [InlineData(HttpStatusCode.OK, """{"timed_out":false,"status":"red"}""", false)] + [InlineData(HttpStatusCode.OK, """{"timed_out":false,"status":1}""", false)] + [InlineData(HttpStatusCode.ServiceUnavailable, """{"timed_out":false,"status":"yellow"}""", false)] + public void IsElasticsearchReady_ClusterHealthResponse_ReturnsExpectedResult(HttpStatusCode statusCode, string json, bool expected) + { + using var document = JsonDocument.Parse(json); + + bool isReady = AppWebHostFactory.IsElasticsearchReady(statusCode, document.RootElement); + + Assert.Equal(expected, isReady); + } + [Fact] public async Task ConfigureWebHost_MultipleFactories_IsolatesFileStorageByAppScope() {