Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions JenkinsFile
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,27 @@ pipeline {
networkShares()
filecopy copyOfflineOperations, 'test', env.WORKSPACE
filecopy copyOperations, 'test', env.WORKSPACE
powershell label: 'Verify /health returns 200', script: '''
$url = "https://secure-test.vetmed.ucdavis.edu/2/health"
$maxAttempts = 15
for ($i = 1; $i -le $maxAttempts; $i++) {
# 2s for the first few attempts then 4s - absorbs IIS app-pool warm-up
$delay = if ($i -le 5) { 2 } else { 4 }
try {
$response = Invoke-WebRequest -Uri $url -UseBasicParsing -TimeoutSec 10
if ($response.StatusCode -eq 200) {
Write-Host "Attempt ${i}: $url returned 200 OK"
exit 0
}
Write-Host "Attempt ${i}: $url returned $($response.StatusCode)"
} catch {
Write-Host "Attempt ${i}: $($_.Exception.Message)"
}
if ($i -lt $maxAttempts) { Start-Sleep -Seconds $delay }
}
Write-Error "Health check at $url failed after $maxAttempts attempts"
exit 1
'''
}
}
stage('Deploy to prod') {
Expand All @@ -122,6 +143,26 @@ pipeline {
networkShares()
filecopy copyOfflineOperations, 'prod', env.WORKSPACE
filecopy copyOperations, 'prod', env.WORKSPACE
powershell label: 'Verify /health returns 200', script: '''
$url = "https://secure.vetmed.ucdavis.edu/2/health"
$maxAttempts = 15
for ($i = 1; $i -le $maxAttempts; $i++) {
$delay = if ($i -le 5) { 2 } else { 4 }
try {
$response = Invoke-WebRequest -Uri $url -UseBasicParsing -TimeoutSec 10
if ($response.StatusCode -eq 200) {
Write-Host "Attempt ${i}: $url returned 200 OK"
exit 0
}
Write-Host "Attempt ${i}: $url returned $($response.StatusCode)"
} catch {
Write-Host "Attempt ${i}: $($_.Exception.Message)"
}
if ($i -lt $maxAttempts) { Start-Sleep -Seconds $delay }
}
Write-Error "Health check at $url failed after $maxAttempts attempts"
exit 1
'''
Comment on lines +146 to +165
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | 💤 Low value

Consider extracting duplicated health-check logic.

The test and prod PowerShell blocks are identical except for the URL. A shared function would reduce maintenance burden.

♻️ Example consolidation
def verifyHealth(String url) {
    powershell label: 'Verify /health returns 200', script: """
        \$url = "$url"
        \$maxAttempts = 15
        for (\$i = 1; \$i -le \$maxAttempts; \$i++) {
            \$delay = if (\$i -le 5) { 2 } else { 4 }
            try {
                \$response = Invoke-WebRequest -Uri \$url -UseBasicParsing -TimeoutSec 10
                if (\$response.StatusCode -eq 200) {
                    Write-Host "Attempt \${\$i}: \$url returned 200 OK"
                    exit 0
                }
                Write-Host "Attempt \${\$i}: \$url returned \$(\$response.StatusCode)"
            } catch {
                Write-Host "Attempt \${\$i}: \$(\$_.Exception.Message)"
            }
            if (\$i -lt \$maxAttempts) { Start-Sleep -Seconds \$delay }
        }
        Write-Error "Health check at \$url failed after \$maxAttempts attempts"
        exit 1
    """
}

Then call verifyHealth("https://secure-test.vetmed.ucdavis.edu/2/health") in each stage.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@JenkinsFile` around lines 146 - 165, The JenkinsFile contains duplicated
PowerShell health-check blocks; extract them into a single reusable Groovy
function (e.g., verifyHealth(String url)) that wraps the existing powershell
step logic and accepts the URL as a parameter, then replace the two inline
powershell blocks with calls to verifyHealth("<env-specific-url>"); ensure the
function preserves the maxAttempts, delay logic, try/catch and exit codes
exactly and keep the powershell label 'Verify /health returns 200' so behavior
and logs remain unchanged.

}
}
}
Expand Down
59 changes: 59 additions & 0 deletions web/Classes/CloudflareNetworks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
namespace Viper.Classes
{
/// <summary>
/// Cloudflare's published IPv4/IPv6 networks, used to mark CF as a known
/// proxy in ForwardedHeadersOptions. Fetched from cloudflare.com at startup
/// so we automatically pick up rotations; falls back to a hardcoded snapshot
/// when the fetch fails (CF outage during deploy, sandboxed network, etc).
/// </summary>
public static class CloudflareNetworks
{
// Snapshot of https://www.cloudflare.com/ips/ - only used when the
// runtime fetch fails. Refresh occasionally if logs show this falling
// through and current CF IPs aren't in the list.
private static readonly string[] HardcodedFallback =
[
"173.245.48.0/20",
"103.21.244.0/22",
"103.22.200.0/22",
"103.31.4.0/22",
"141.101.64.0/18",
"108.162.192.0/18",
"190.93.240.0/20",
"188.114.96.0/20",
"197.234.240.0/22",
"198.41.128.0/17",
"162.158.0.0/15",
"104.16.0.0/13",
"104.24.0.0/14",
"172.64.0.0/13",
"131.0.72.0/22",
"2400:cb00::/32",
"2606:4700::/32",
"2803:f800::/32",
"2405:b500::/32",
"2405:8100::/32",
"2a06:98c0::/29",
"2c0f:f248::/32",
];

public static IReadOnlyList<string> FetchOrFallback(NLog.Logger logger)
{
try
{
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(5) };
var v4 = http.GetStringAsync("https://www.cloudflare.com/ips-v4/").GetAwaiter().GetResult();
var v6 = http.GetStringAsync("https://www.cloudflare.com/ips-v6/").GetAwaiter().GetResult();
var cidrs = (v4 + "\n" + v6)
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
logger.Info("Fetched {Count} Cloudflare networks from cloudflare.com", cidrs.Length);
return cidrs;
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
{
logger.Warn(ex, "Failed to fetch Cloudflare IP ranges; using hardcoded fallback ({Count} entries)", HardcodedFallback.Length);
return HardcodedFallback;
}
}
Comment on lines +40 to +57
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | 💤 Low value

LGTM. The synchronous blocking via GetAwaiter().GetResult() is appropriate here since this runs once at startup before the host starts. The 5-second timeout and hardcoded fallback provide resilience.

One edge case: SocketException or DNS failures may not always be wrapped in HttpRequestException depending on the runtime. Consider broadening the catch or adding a general fallback.

♻️ Broader exception handling
-            catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
+            catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or System.Net.Sockets.SocketException)

Or use a general catch that still logs and falls back gracefully:

-            catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
+            catch (Exception ex)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/Classes/CloudflareNetworks.cs` around lines 40 - 57, The current catch in
FetchOrFallback only handles HttpRequestException and TaskCanceledException, but
DNS/socket failures can surface as other exception types; change the exception
handling on FetchOrFallback to catch broader failures (e.g., catch Exception ex
or add SocketException) so any network/DNS/socket error is logged via
logger.Warn and the method returns HardcodedFallback; keep the existing log
message and return behavior, and ensure the method still uses HardcodedFallback
when any network-related exception occurs.

}
}
81 changes: 81 additions & 0 deletions web/Classes/HealthChecks/AdaptivePollingHealthCheck.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;

namespace Viper.Classes.HealthChecks
{
/// <summary>
/// Throttles an inner health check by caching its last result for a
/// status-dependent duration: Healthy results are reused for longer, while
/// Unhealthy/Degraded results refresh on a tighter cycle so recovery is
/// noticed quickly. When a cached result is returned, the original probe
/// timestamp is appended to the description so operators can tell how
/// stale the reading is.
/// </summary>
public class AdaptivePollingHealthCheck : IHealthCheck
{
private readonly IHealthCheck _inner;
private readonly TimeSpan _healthyCacheDuration;
private readonly TimeSpan _unhealthyCacheDuration;
private readonly SemaphoreSlim _semaphore = new(1, 1);
private DateTime _lastCheckTime;
private HealthCheckResult? _lastResult;

public AdaptivePollingHealthCheck(
IHealthCheck inner,
TimeSpan healthyCacheDuration,
TimeSpan unhealthyCacheDuration)
{
_inner = inner;
_healthyCacheDuration = healthyCacheDuration;
_unhealthyCacheDuration = unhealthyCacheDuration;
}

public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
await _semaphore.WaitAsync(cancellationToken);
try
{
if (_lastResult.HasValue)
{
// S6561: DateTime.Now used for elapsed-time calc. Accepted
// because VIPER convention is DateTimeKind.Local and a
// sub-hour DST skew only shifts one cache window.
#pragma warning disable S6561
var elapsed = DateTime.Now - _lastCheckTime;
#pragma warning restore S6561
var cacheDuration = _lastResult.Value.Status == HealthStatus.Healthy
? _healthyCacheDuration
: _unhealthyCacheDuration;

if (elapsed < cacheDuration)
{
return AppendTimestamp(_lastResult.Value, _lastCheckTime);
}
}

var result = await _inner.CheckHealthAsync(context, cancellationToken);
_lastResult = result;
_lastCheckTime = DateTime.Now;
return result;
}
finally
{
_semaphore.Release();
}
}

private static HealthCheckResult AppendTimestamp(HealthCheckResult result, DateTime lastCheckedAt)
{
var stamp = $"Last checked: {lastCheckedAt:MMM d, h:mm tt}";
var description = string.IsNullOrWhiteSpace(result.Description)
? stamp
: $"{result.Description}\n{stamp}";
return new HealthCheckResult(
result.Status,
description,
result.Exception,
result.Data);
}
}
}
56 changes: 56 additions & 0 deletions web/Classes/HealthChecks/AwsSsmHealthCheck.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using Amazon;
using Amazon.Runtime;
using Amazon.SimpleSystemsManagement;
using Amazon.SimpleSystemsManagement.Model;
using Microsoft.Extensions.Diagnostics.HealthChecks;

namespace Viper.Classes.HealthChecks
{
/// <summary>
/// Verifies AWS SSM Parameter Store is reachable with the app's credentials.
/// Uses a lightweight DescribeParameters probe (MaxResults=1) so the check
/// does not actually fetch any parameter values.
/// </summary>
public class AwsSsmHealthCheck : IHealthCheck
{
private readonly RegionEndpoint _region;
private readonly bool _healthyWhenMissing;

/// <param name="healthyWhenMissing">
/// If true, missing credentials or client-side SDK errors return Healthy
/// with a "skipped" description. Use for Development where local machines
/// may not have AWS credentials configured.
/// </param>
public AwsSsmHealthCheck(RegionEndpoint? region = null, bool healthyWhenMissing = false)
{
_region = region ?? RegionEndpoint.USWest1;
_healthyWhenMissing = healthyWhenMissing;
}

public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
try
{
using var client = new AmazonSimpleSystemsManagementClient(_region);
await client.DescribeParametersAsync(
new DescribeParametersRequest { MaxResults = 1 },
cancellationToken);
return HealthCheckResult.Healthy("AWS SSM reachable.");
}
catch (AmazonServiceException ex)
{
return _healthyWhenMissing
? HealthCheckResult.Healthy("AWS SSM not configured (skipped).")
: HealthCheckResult.Unhealthy($"AWS SSM unreachable: {ex.ErrorCode}.");
Comment on lines +36 to +46
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

What AWS SSM API operations does Amazon.Extensions.Configuration.SystemsManager use to load configuration, and how do their IAM permissions differ from DescribeParameters?

💡 Result:

Amazon.Extensions.Configuration.SystemsManager primarily uses the AWS SSM API operation GetParametersByPath (with Recursive=true and WithDecryption=true) to load configuration parameters from Parameter Store when a path prefix is specified (the typical usage via AddSystemsManager(path)). It may also use GetParameters (batched) if specific ParameterNames are provided, or GetParameter for Secrets Manager-referenced paths. The corresponding IAM permission is ssm:GetParametersByPath on the resource ARN matching the path prefix (e.g., "arn:aws:ssm:::parameter/myapp/"). This differs from ssm:DescribeParameters (used by the DescribeParameters API), which only lists metadata about parameters (names, types, etc.) but does not return parameter values. ssm:DescribeParameters requires no resource-level restrictions (often "Resource": "") and cannot access SecureString values or decrypt them. In contrast, ssm:GetParametersByPath directly retrieves and decrypts values but requires permission on the specific parameter resources matching the path, providing finer-grained control over value access.

Citations:


Use the same SSM operation the configuration loader uses.

The health check calls DescribeParameters to validate AWS SSM reachability, but the app loads configuration via GetParametersByPath. These require different IAM permissions (ssm:DescribeParameters vs. ssm:GetParametersByPath). A least-privilege role with only read access to the config path will fail this readiness check even though startup succeeds.

Probe an actual parameter from the configured path instead—either query a known key with GetParameter or use GetParametersByPath with the same path prefix the configuration loader uses.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/Classes/HealthChecks/AwsSsmHealthCheck.cs` around lines 36 - 46, The
health check in AwsSsmHealthCheck currently calls DescribeParametersAsync which
requires different IAM rights than the config loader; change the probe to call
GetParametersByPathAsync (or GetParameterAsync for a known key) against the same
path/key your configuration loader uses so the health check uses the same IAM
permissions. In practice, inside the health check method replace the
DescribeParametersAsync call with a call to client.GetParametersByPathAsync(new
GetParametersByPathRequest { Path = <your configured path prefix>, MaxResults =
1 }, cancellationToken) (or client.GetParameterAsync for a single known
parameter), ensure you pass cancellationToken, and keep the existing exception
handling that checks _healthyWhenMissing and returns HealthCheckResult
accordingly; update references in AwsSsmHealthCheck to use the configuration
path field (e.g. the class’s path/prefix member) along with existing _region and
_healthyWhenMissing.

}
catch (AmazonClientException)
{
return _healthyWhenMissing
? HealthCheckResult.Healthy("AWS SSM not configured (skipped).")
: HealthCheckResult.Unhealthy("AWS SSM client error (credentials or network).");
}
}
}
}
Loading
Loading