diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cbe6a7f..a4478dc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,20 +11,12 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup .NET - uses: actions/setup-dotnet@v3 + uses: actions/setup-dotnet@v5 with: - dotnet-version: '8.0.x' - - - name: Add Garage Group NuGet Source - run: > - dotnet nuget add source ${{ vars.GG_NUGET_SOURCE_URL }} - -n garage - -u ${{ secrets.GG_NUGET_SOURCE_USER_NAME }} - -p ${{ secrets.GG_NUGET_SOURCE_USER_PASSWORD }} - --store-password-in-clear-text + dotnet-version: '10.0.x' - name: Restore run: dotnet restore diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index aa31b81..6cf4a72 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -6,6 +6,10 @@ on: types: - created +permissions: + contents: read + packages: write + jobs: build-and-push-docker-image: name: Build and push Docker image @@ -13,44 +17,36 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set output id: vars - run: echo ::set-output name=tag::${GITHUB_REF#refs/*/} + run: echo "tag=${GITHUB_REF#refs/*/}" >> "$GITHUB_OUTPUT" - name: Setup .NET - uses: actions/setup-dotnet@v3 + uses: actions/setup-dotnet@v5 with: - dotnet-version: '8.0.x' + dotnet-version: '10.0.x' - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 - - - name: Add Garage Group NuGet Source - run: > - dotnet nuget add source ${{ vars.GG_NUGET_SOURCE_URL }} - -n garage - -u ${{ secrets.GG_NUGET_SOURCE_USER_NAME }} - -p ${{ secrets.GG_NUGET_SOURCE_USER_PASSWORD }} - --store-password-in-clear-text + uses: docker/setup-buildx-action@v4 - name: Publish Console.csproj run: dotnet publish ./src/*/Console.csproj -c Release -o './publish' - - name: Login to Github Packages - uses: docker/login-action@v2 + - name: Login to GitHub Container Registry + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build image and push to GitHub Container Registry - uses: docker/build-push-action@v4 + uses: docker/build-push-action@v7 with: context: . file: ./Dockerfile tags: | ghcr.io/garagegroup/gg-ping-health-check:${{ steps.vars.outputs.tag }} ghcr.io/garagegroup/gg-ping-health-check:latest - push: true + push: true \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index be63961..534db88 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/dotnet/runtime:8.0 +FROM mcr.microsoft.com/dotnet/runtime:10.0 WORKDIR /app COPY ./publish ./ diff --git a/Platform.Ping.sln b/Platform.Ping.sln deleted file mode 100644 index 881cf1a..0000000 --- a/Platform.Ping.sln +++ /dev/null @@ -1,28 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.31903.59 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".sln", ".sln", "{8CA9B029-557C-4F01-97BA-27DA6634F956}" - ProjectSection(SolutionItems) = preProject - action.yml = action.yml - Dockerfile = Dockerfile - EndProjectSection -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Console", "src\Console\Console.csproj", "{933BFA35-3FFF-434B-82A5-0CB3DF1AB979}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {933BFA35-3FFF-434B-82A5-0CB3DF1AB979}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {933BFA35-3FFF-434B-82A5-0CB3DF1AB979}.Debug|Any CPU.Build.0 = Debug|Any CPU - {933BFA35-3FFF-434B-82A5-0CB3DF1AB979}.Release|Any CPU.ActiveCfg = Release|Any CPU - {933BFA35-3FFF-434B-82A5-0CB3DF1AB979}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection -EndGlobal diff --git a/Platform.Ping.slnx b/Platform.Ping.slnx new file mode 100644 index 0000000..3369505 --- /dev/null +++ b/Platform.Ping.slnx @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/action.yml b/action.yml index 615e476..4e68075 100644 --- a/action.yml +++ b/action.yml @@ -1,17 +1,21 @@ name: 'gg-ping-health-check' -description: 'Ping web service health check' +description: 'Checks the health status of a web service by pinging its health check endpoint.' inputs: health_check_url: - description: 'Web service health check URL' + description: 'The URL of the web service health check endpoint.' required: true contains: - description: 'Expected message' + description: 'The expected text or pattern that must be present in the health check response.' required: false + is_end_of_line_match: + description: 'Indicates whether the expected value should match only at the end of a line.' + required: false + default: 'false' retry_delay_in_seconds: - description: 'Retry delay in seconds' + description: 'The delay, in seconds, between retry attempts.' required: false max_attempts: - description: 'Max number of attempts' + description: 'The maximum number of health check attempts before failing.' required: false runs: using: 'composite' @@ -20,6 +24,7 @@ runs: docker run --rm \ -e In__HealthCheckUrl="${{ inputs.health_check_url }}" \ -e In__ContainedMessage="${{ inputs.contains }}" \ + -e In__IsEndOfLineMatch="${{ inputs.is_end_of_line_match }}" \ -e In__RetryDelayInSeconds="${{ inputs.retry_delay_in_seconds }}" \ -e In__MaxAttempts="${{ inputs.max_attempts }}" \ ghcr.io/garagegroup/gg-ping-health-check:latest diff --git a/src/Console/Application.cs b/src/Console/Application.cs index e4ce01a..2bebddd 100644 --- a/src/Console/Application.cs +++ b/src/Console/Application.cs @@ -10,5 +10,6 @@ internal static Dependency> UsePingHandler() => PrimaryHandler.UseStandardSocketsHttpHandler() .UseLogging("PingHandler") + .UseHttpApi() .Map>(PingHandler.Resolve); } \ No newline at end of file diff --git a/src/Console/Console.csproj b/src/Console/Console.csproj index 0dfc0ca..cdd2b95 100644 --- a/src/Console/Console.csproj +++ b/src/Console/Console.csproj @@ -1,12 +1,13 @@  - net8.0 + net10.0 Exe disable enable false true + $(NoWarn);IDE0130;CA1859;CA1873 GarageGroup.Platform.Ping GarageGroup.Platform.Ping.Console @@ -15,8 +16,10 @@ - - + + + + \ No newline at end of file diff --git a/src/Console/Handler/Contract/IPingHandler.cs b/src/Console/Handler.Contract/IPingHandler.cs similarity index 100% rename from src/Console/Handler/Contract/IPingHandler.cs rename to src/Console/Handler.Contract/IPingHandler.cs diff --git a/src/Console/Handler/Contract/PingIn.cs b/src/Console/Handler.Contract/PingIn.cs similarity index 55% rename from src/Console/Handler/Contract/PingIn.cs rename to src/Console/Handler.Contract/PingIn.cs index 0259ae6..41b4cc7 100644 --- a/src/Console/Handler/Contract/PingIn.cs +++ b/src/Console/Handler.Contract/PingIn.cs @@ -1,13 +1,13 @@ -using System; - -namespace GarageGroup.Platform.Ping; +namespace GarageGroup.Platform.Ping; internal sealed record class PingIn { - public Uri? HealthCheckUrl { get; init; } + public string? HealthCheckUrl { get; init; } public string? ContainedMessage { get; init; } + public bool? IsEndOfLineMatch { get; init; } + public int? RetryDelayInSeconds { get; init; } public int? MaxAttempts { get; init; } diff --git a/src/Console/Handler/Handler.Handler.cs b/src/Console/Handler/Handler.Handler.cs index 98569fd..53ca6ea 100644 --- a/src/Console/Handler/Handler.Handler.cs +++ b/src/Console/Handler/Handler.Handler.cs @@ -1,5 +1,5 @@ using System; -using System.Net.Http; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using GarageGroup.Infra; @@ -9,11 +9,12 @@ namespace GarageGroup.Platform.Ping; partial class PingHandler { - public async ValueTask>> HandleAsync(PingIn? input, CancellationToken cancellationToken) + public async ValueTask>> HandleAsync( + PingIn? input, CancellationToken cancellationToken) { - if (input?.HealthCheckUrl is null) + if (string.IsNullOrWhiteSpace(input?.HealthCheckUrl)) { - return Failure.Create(HandlerFailureCode.Persistent, "HealthCheckUrl must be specified"); + return Failure.Create(HandlerFailureCode.Persistent, "HealthCheckUrl must be specified."); } var retryDelayInSeconds = input.RetryDelayInSeconds > 0 ? input.RetryDelayInSeconds.Value : DefaultRetryDelayInSeconds; @@ -21,8 +22,8 @@ public async ValueTask>> HandleAsync(Pi for (var i = 0; i < maxAttempts; i++) { - logger.LogInformation("Attempt {attemmpt} of {maxAttempts}", i + 1, maxAttempts); - var result = await PingAsync(input, cancellationToken).ConfigureAwait(false); + logger.LogInformation("Attempt {attemmpt} of {maxAttempts}.", i + 1, maxAttempts); + var result = await InnerPingAsync(input, cancellationToken); if (result.IsSuccess) { @@ -32,43 +33,51 @@ public async ValueTask>> HandleAsync(Pi var failure = result.FailureOrThrow(); logger.LogError(failure.SourceException, "{failureMessage}", failure.FailureMessage); - logger.LogInformation("Delay {delay}s", retryDelayInSeconds); - await Task.Delay(retryDelayInSeconds * 1000, cancellationToken).ConfigureAwait(false); + logger.LogInformation("Delay {delay}s.", retryDelayInSeconds); + await Task.Delay(retryDelayInSeconds * 1000, cancellationToken); } - return Failure.Create(HandlerFailureCode.Transient, "All attempts were unsuccessful"); + return Failure.Create(HandlerFailureCode.Transient, "All attempts were unsuccessful."); } - private async ValueTask>> PingAsync(PingIn input, CancellationToken cancellationToken) + private ValueTask>> InnerPingAsync(PingIn input, CancellationToken cancellationToken) + => + AsyncPipeline.Pipe( + input, cancellationToken) + .Pipe( + static @in => new HttpSendIn(HttpVerb.Get, @in.HealthCheckUrl.OrEmpty())) + .PipeValue( + httpApi.SendAsync) + .Map( + static success => success.Body.Content?.ToString(), + static failure => failure.ToStandardFailure("An unsuccessful HTTP request:").WithFailureCode(default)) + .Forward( + response => VerifyResponse(response.OrEmpty(), input)); + + private static Result> VerifyResponse(string response, PingIn input) { - try + Console.WriteLine(response); + if (string.IsNullOrWhiteSpace(input.ContainedMessage)) { - using var httpClient = new HttpClient(httpMessageHandler, disposeHandler: false); - using var httpResponse = await httpClient.GetAsync(input.HealthCheckUrl, cancellationToken).ConfigureAwait(false); - - var httpResponseBody = await httpResponse.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); - logger.LogInformation("{responseBody}", httpResponseBody); - - if (httpResponse.IsSuccessStatusCode is false) - { - return Failure.Create("An unsuccessful HTTP request"); - } - - if (string.IsNullOrWhiteSpace(input.ContainedMessage)) - { - return Result.Success(default); - } + return Result.Success(default); + } - if (httpResponseBody.Contains(input.ContainedMessage, StringComparison.InvariantCulture)) + if (input.IsEndOfLineMatch is not true) + { + if (response.Contains(input.ContainedMessage, StringComparison.InvariantCultureIgnoreCase)) { return Result.Success(default); } - return Failure.Create($"Response does not contain required message '{input.ContainedMessage}'"); + return Failure.Create($"Response does not contain required message '{input.ContainedMessage}'."); } - catch (OperationCanceledException ex) + + var pattern = Regex.Escape(input.ContainedMessage) + "$"; + if (Regex.IsMatch(response, pattern, RegexOptions.Multiline | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)) { - return ex.ToFailure("An HTTP request was canceled"); + return Result.Success(default); } + + return Failure.Create($"Response does not contain strict required message '{input.ContainedMessage}'."); } } \ No newline at end of file diff --git a/src/Console/Handler/PingHandler.cs b/src/Console/Handler/PingHandler.cs index 51c83ef..b88e873 100644 --- a/src/Console/Handler/PingHandler.cs +++ b/src/Console/Handler/PingHandler.cs @@ -1,5 +1,5 @@ using System; -using System.Net.Http; +using GarageGroup.Infra; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -7,13 +7,13 @@ namespace GarageGroup.Platform.Ping; internal sealed partial class PingHandler : IPingHandler { - public static PingHandler Resolve(IServiceProvider serviceProvider, HttpMessageHandler httpMessageHandler) + public static PingHandler Resolve(IServiceProvider serviceProvider, IHttpApi httpApi) { ArgumentNullException.ThrowIfNull(serviceProvider); - ArgumentNullException.ThrowIfNull(httpMessageHandler); + ArgumentNullException.ThrowIfNull(httpApi); return new( - httpMessageHandler: httpMessageHandler, + httpApi: httpApi, logger: serviceProvider.GetRequiredService().CreateLogger()); } @@ -21,13 +21,13 @@ public static PingHandler Resolve(IServiceProvider serviceProvider, HttpMessageH private const int DefaultMaxAttempts = 3; - private readonly HttpMessageHandler httpMessageHandler; + private readonly IHttpApi httpApi; private readonly ILogger logger; - private PingHandler(HttpMessageHandler httpMessageHandler, ILogger logger) + private PingHandler(IHttpApi httpApi, ILogger logger) { - this.httpMessageHandler = httpMessageHandler; + this.httpApi = httpApi; this.logger = logger; } } \ No newline at end of file diff --git a/src/Console/Program.cs b/src/Console/Program.cs index c1268b6..febe31d 100644 --- a/src/Console/Program.cs +++ b/src/Console/Program.cs @@ -3,9 +3,9 @@ namespace GarageGroup.Platform.Ping; -public static class Program +static class Program { - public static Task Main(string[] args) + static Task Main(string[] args) => - Application.UsePingHandler().RunConsoleAsync("In", args); + Application.UsePingHandler().UseConsoleRunner("In", args).RunAsync(); } \ No newline at end of file diff --git a/src/Console/appsettings.json b/src/Console/appsettings.json index 054f959..dbae1d8 100644 --- a/src/Console/appsettings.json +++ b/src/Console/appsettings.json @@ -3,6 +3,7 @@ "In": { "HealthCheckUrl": "", "ContainedMessage": "", + "IsEndOfLineMatch": false, "RetryDelayInSeconds": 5, "MaxAttempts": 3 }