test fixes and new product advisories work

This commit is contained in:
master
2026-01-28 02:30:48 +02:00
parent 82caceba56
commit 644887997c
288 changed files with 69101 additions and 375 deletions

View File

@@ -0,0 +1,308 @@
using System.Globalization;
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Doctor.Models;
using StellaOps.Doctor.Plugins;
namespace StellaOps.Doctor.Plugins.Integration.Checks;
/// <summary>
/// Probes OCI registry for comprehensive capability detection.
/// </summary>
public sealed class RegistryCapabilityProbeCheck : IDoctorCheck
{
/// <inheritdoc />
public string CheckId => "check.integration.oci.capabilities";
/// <inheritdoc />
public string Name => "OCI Registry Capability Matrix";
/// <inheritdoc />
public string Description => "Detect and report registry capabilities for OCI compliance";
/// <inheritdoc />
public DoctorSeverity DefaultSeverity => DoctorSeverity.Info;
/// <inheritdoc />
public IReadOnlyList<string> Tags => ["registry", "oci", "capabilities", "compatibility"];
/// <inheritdoc />
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(15);
/// <inheritdoc />
public bool CanRun(DoctorPluginContext context)
{
var registryUrl = context.Configuration.GetValue<string>("OCI:RegistryUrl")
?? context.Configuration.GetValue<string>("Registry:Url");
return !string.IsNullOrEmpty(registryUrl);
}
/// <inheritdoc />
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
{
var registryUrl = (context.Configuration.GetValue<string>("OCI:RegistryUrl")
?? context.Configuration.GetValue<string>("Registry:Url"))!.TrimEnd('/');
var testRepo = context.Configuration.GetValue<string>("OCI:TestRepository")
?? context.Configuration.GetValue<string>("Registry:TestRepository")
?? "library/alpine";
var builder = context.CreateResult(CheckId, "stellaops.doctor.integration", DoctorCategory.Integration.ToString());
var httpClientFactory = context.Services.GetService<IHttpClientFactory>();
if (httpClientFactory == null)
{
return builder.Skip("IHttpClientFactory not available").Build();
}
try
{
using var httpClient = httpClientFactory.CreateClient();
httpClient.Timeout = TimeSpan.FromSeconds(10);
ApplyAuthentication(context, httpClient);
// Probe all capabilities
var distributionVersion = await ProbeDistributionVersionAsync(httpClient, registryUrl, ct);
var supportsReferrersApi = await ProbeReferrersApiAsync(httpClient, registryUrl, testRepo, ct);
var supportsChunkedUpload = await ProbeChunkedUploadAsync(httpClient, registryUrl, testRepo, ct);
var supportsCrossRepoMount = await ProbeCrossRepoMountAsync(httpClient, registryUrl, testRepo, ct);
var supportsManifestDelete = await ProbeDeleteSupportAsync(httpClient, registryUrl, testRepo, "manifests", ct);
var supportsBlobDelete = await ProbeDeleteSupportAsync(httpClient, registryUrl, testRepo, "blobs", ct);
// Calculate capability score
var supportedCount = new[] {
supportsReferrersApi,
supportsChunkedUpload,
supportsCrossRepoMount,
supportsManifestDelete,
supportsBlobDelete
}.Count(c => c == true);
var totalCapabilities = 5;
var capabilityScore = $"{supportedCount}/{totalCapabilities}";
// Determine severity
var severity = DoctorSeverity.Pass;
var diagnosis = $"Registry supports {supportedCount} of {totalCapabilities} probed capabilities";
if (supportsReferrersApi == false)
{
severity = DoctorSeverity.Warn;
diagnosis = $"Registry missing referrers API support ({supportedCount}/{totalCapabilities} capabilities)";
}
else if (supportedCount < totalCapabilities)
{
severity = DoctorSeverity.Info;
}
return builder
.WithSeverity(severity, diagnosis)
.WithEvidence("Registry Capabilities", eb => eb
.Add("registry_url", registryUrl)
.Add("distribution_version", distributionVersion ?? "unknown")
.Add("supports_referrers_api", FormatBool(supportsReferrersApi))
.Add("supports_chunked_upload", FormatBool(supportsChunkedUpload))
.Add("supports_cross_repo_mount", FormatBool(supportsCrossRepoMount))
.Add("supports_manifest_delete", FormatBool(supportsManifestDelete))
.Add("supports_blob_delete", FormatBool(supportsBlobDelete))
.Add("capability_score", capabilityScore))
.Build();
}
catch (TaskCanceledException)
{
return builder
.Warn("Registry capability probe timed out")
.WithEvidence("Registry Capabilities", eb => eb
.Add("registry_url", registryUrl)
.Add("error", "Connection timeout"))
.Build();
}
catch (HttpRequestException ex)
{
return builder
.Fail($"Cannot reach registry: {ex.Message}")
.WithEvidence("Registry Capabilities", eb => eb
.Add("registry_url", registryUrl)
.Add("error", ex.Message))
.Build();
}
}
private static string FormatBool(bool? value) => value switch
{
true => "true",
false => "false",
null => "unknown"
};
private static void ApplyAuthentication(DoctorPluginContext context, HttpClient httpClient)
{
var username = context.Configuration.GetValue<string>("OCI:Username")
?? context.Configuration.GetValue<string>("Registry:Username");
var password = context.Configuration.GetValue<string>("OCI:Password")
?? context.Configuration.GetValue<string>("Registry:Password");
if (!string.IsNullOrEmpty(username) && !string.IsNullOrEmpty(password))
{
var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}"));
httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Basic", credentials);
}
var bearerToken = context.Configuration.GetValue<string>("OCI:Token")
?? context.Configuration.GetValue<string>("Registry:Token");
if (!string.IsNullOrEmpty(bearerToken))
{
httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", bearerToken);
}
}
private static async Task<string?> ProbeDistributionVersionAsync(
HttpClient httpClient,
string registryUrl,
CancellationToken ct)
{
try
{
using var response = await httpClient.GetAsync($"{registryUrl}/v2/", ct);
if (response.Headers.TryGetValues("OCI-Distribution-API-Version", out var versions))
{
return string.Join(", ", versions);
}
if (response.Headers.TryGetValues("Docker-Distribution-API-Version", out var dockerVersions))
{
return $"Docker: {string.Join(", ", dockerVersions)}";
}
return response.IsSuccessStatusCode ? "1.0 (assumed)" : null;
}
catch
{
return null;
}
}
private static async Task<bool?> ProbeReferrersApiAsync(
HttpClient httpClient,
string registryUrl,
string testRepo,
CancellationToken ct)
{
try
{
var fakeDigest = "sha256:0000000000000000000000000000000000000000000000000000000000000000";
using var request = new HttpRequestMessage(HttpMethod.Get, $"{registryUrl}/v2/{testRepo}/referrers/{fakeDigest}");
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.oci.image.index.v1+json"));
using var response = await httpClient.SendAsync(request, ct);
if (response.StatusCode == HttpStatusCode.OK)
return true;
if (response.StatusCode == HttpStatusCode.NotFound)
{
var contentType = response.Content.Headers.ContentType?.MediaType;
if (contentType?.Contains("oci") == true || contentType?.Contains("json") == true)
{
var content = await response.Content.ReadAsStringAsync(ct);
if (content.Contains("\"schemaVersion\"") || content.Contains("manifests"))
return true;
}
return false;
}
if (response.StatusCode == HttpStatusCode.MethodNotAllowed)
return false;
return null;
}
catch
{
return null;
}
}
private static async Task<bool?> ProbeChunkedUploadAsync(
HttpClient httpClient,
string registryUrl,
string testRepo,
CancellationToken ct)
{
try
{
using var response = await httpClient.PostAsync($"{registryUrl}/v2/{testRepo}/blobs/uploads/", null, ct);
if (response.StatusCode == HttpStatusCode.Accepted)
{
var location = response.Headers.Location;
if (location != null)
{
try { await httpClient.DeleteAsync(location, ct); } catch { /* Ignore cleanup errors */ }
}
return true;
}
return response.StatusCode == HttpStatusCode.Unauthorized ? null : false;
}
catch
{
return null;
}
}
private static async Task<bool?> ProbeCrossRepoMountAsync(
HttpClient httpClient,
string registryUrl,
string testRepo,
CancellationToken ct)
{
try
{
var fakeDigest = "sha256:0000000000000000000000000000000000000000000000000000000000000000";
var mountUrl = $"{registryUrl}/v2/{testRepo}/blobs/uploads/?mount={fakeDigest}&from=library/alpine";
using var response = await httpClient.PostAsync(mountUrl, null, ct);
return response.StatusCode is HttpStatusCode.Created or HttpStatusCode.Accepted;
}
catch
{
return null;
}
}
private static async Task<bool?> ProbeDeleteSupportAsync(
HttpClient httpClient,
string registryUrl,
string testRepo,
string resourceType,
CancellationToken ct)
{
try
{
var fakeRef = resourceType == "manifests" ? "nonexistent" : "sha256:0000000000000000000000000000000000000000000000000000000000000000";
using var request = new HttpRequestMessage(HttpMethod.Options, $"{registryUrl}/v2/{testRepo}/{resourceType}/{fakeRef}");
using var response = await httpClient.SendAsync(request, ct);
if (response.Headers.TryGetValues("Allow", out var allowedMethods))
{
var methods = string.Join(",", allowedMethods).ToUpperInvariant();
return methods.Contains("DELETE");
}
return null;
}
catch
{
return null;
}
}
}

View File

@@ -0,0 +1,237 @@
using System.Globalization;
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Doctor.Models;
using StellaOps.Doctor.Plugins;
namespace StellaOps.Doctor.Plugins.Integration.Checks;
/// <summary>
/// Validates registry credential configuration and token validity.
/// </summary>
public sealed class RegistryCredentialsCheck : IDoctorCheck
{
/// <inheritdoc />
public string CheckId => "check.integration.oci.credentials";
/// <inheritdoc />
public string Name => "OCI Registry Credentials";
/// <inheritdoc />
public string Description => "Validate registry credentials configuration and token expiry";
/// <inheritdoc />
public DoctorSeverity DefaultSeverity => DoctorSeverity.Fail;
/// <inheritdoc />
public IReadOnlyList<string> Tags => ["registry", "oci", "credentials", "secrets", "auth"];
/// <inheritdoc />
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(5);
/// <inheritdoc />
public bool CanRun(DoctorPluginContext context)
{
var registryUrl = context.Configuration.GetValue<string>("OCI:RegistryUrl")
?? context.Configuration.GetValue<string>("Registry:Url");
return !string.IsNullOrEmpty(registryUrl);
}
/// <inheritdoc />
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
{
var registryUrl = (context.Configuration.GetValue<string>("OCI:RegistryUrl")
?? context.Configuration.GetValue<string>("Registry:Url"))!.TrimEnd('/');
var builder = context.CreateResult(CheckId, "stellaops.doctor.integration", DoctorCategory.Integration.ToString());
// Determine auth method being used
var username = context.Configuration.GetValue<string>("OCI:Username")
?? context.Configuration.GetValue<string>("Registry:Username");
var password = context.Configuration.GetValue<string>("OCI:Password")
?? context.Configuration.GetValue<string>("Registry:Password");
var token = context.Configuration.GetValue<string>("OCI:Token")
?? context.Configuration.GetValue<string>("Registry:Token");
string authMethod;
var hasCredentials = false;
if (!string.IsNullOrEmpty(token))
{
authMethod = "bearer";
hasCredentials = true;
}
else if (!string.IsNullOrEmpty(username) && !string.IsNullOrEmpty(password))
{
authMethod = "basic";
hasCredentials = true;
}
else if (!string.IsNullOrEmpty(username))
{
return builder
.Fail("Invalid credential configuration: username provided without password")
.WithEvidence("Credentials", eb => eb
.Add("registry_url", registryUrl)
.Add("auth_method", "incomplete")
.Add("username", username ?? "(not set)")
.Add("password", "(not set)")
.Add("token", "(not set)"))
.WithCauses(
"Password is missing from configuration",
"Password secret reference may not have resolved")
.WithRemediation(rb => rb
.AddManualStep(1, "Add password",
"Configure OCI:Password or Registry:Password")
.AddManualStep(2, "Check secret resolution",
"If using secret references, verify they resolve correctly"))
.Build();
}
else
{
authMethod = "anonymous";
hasCredentials = false;
}
// Validate credentials by attempting /v2/ authentication
var httpClientFactory = context.Services.GetService<IHttpClientFactory>();
if (httpClientFactory == null)
{
return builder.Skip("IHttpClientFactory not available").Build();
}
try
{
using var httpClient = httpClientFactory.CreateClient();
httpClient.Timeout = TimeSpan.FromSeconds(10);
// Apply authentication
if (authMethod == "bearer" && !string.IsNullOrEmpty(token))
{
httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", token);
}
else if (authMethod == "basic" && !string.IsNullOrEmpty(username))
{
var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}"));
httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Basic", credentials);
}
using var response = await httpClient.GetAsync($"{registryUrl}/v2/", ct);
if (response.StatusCode == HttpStatusCode.OK)
{
return builder
.Pass("Registry credentials are valid")
.WithEvidence("Credentials", eb => eb
.Add("registry_url", registryUrl)
.Add("auth_method", authMethod)
.Add("username", Redact(username))
.Add("password", Redact(password))
.Add("token_valid", hasCredentials ? "true" : "n/a"))
.Build();
}
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
// Check if this is a token exchange scenario (OAuth2 registries)
if (response.Headers.WwwAuthenticate.Any())
{
var wwwAuth = response.Headers.WwwAuthenticate.First().ToString();
if (wwwAuth.Contains("Bearer") && authMethod == "basic")
{
// This registry uses OAuth2 token exchange
// Basic auth credentials should work for token exchange
return builder
.Pass("Registry credentials are valid (OAuth2 token exchange required)")
.WithEvidence("Credentials", eb => eb
.Add("registry_url", registryUrl)
.Add("auth_method", authMethod)
.Add("token_exchange", "required")
.Add("username", Redact(username)))
.Build();
}
}
return builder
.Fail("Registry credentials validation failed: Authentication rejected")
.WithEvidence("Credentials", eb => eb
.Add("registry_url", registryUrl)
.Add("auth_method", authMethod)
.Add("username", Redact(username))
.Add("password", Redact(password))
.Add("token_valid", "false")
.Add("validation_error", "401 Unauthorized"))
.WithCauses(
"Credentials are invalid",
"Token has been revoked",
"Username/password combination incorrect")
.WithRemediation(rb => rb
.AddManualStep(1, "Verify credentials",
"Check that username and password are correct")
.AddStep(2, "Test with docker CLI",
$"docker login {new Uri(registryUrl).Host}",
CommandType.Shell)
.WithRunbookUrl("https://docs.stella-ops.org/runbooks/registry-auth-troubleshooting"))
.WithVerification($"stella doctor --check {CheckId}")
.Build();
}
if (response.StatusCode == HttpStatusCode.Forbidden)
{
return builder
.Fail("Registry credentials validation failed: Access forbidden")
.WithEvidence("Credentials", eb => eb
.Add("registry_url", registryUrl)
.Add("auth_method", authMethod)
.Add("token_valid", "false")
.Add("validation_error", "403 Forbidden"))
.WithCauses(
"Credentials valid but access is forbidden",
"IP address or network not allowed")
.Build();
}
// For other status codes, assume valid
return builder
.Pass("Registry credentials appear valid")
.WithEvidence("Credentials", eb => eb
.Add("registry_url", registryUrl)
.Add("auth_method", authMethod)
.Add("http_status", ((int)response.StatusCode).ToString(CultureInfo.InvariantCulture)))
.Build();
}
catch (TaskCanceledException)
{
return builder
.Fail("Credentials validation timed out")
.WithEvidence("Credentials", eb => eb
.Add("registry_url", registryUrl)
.Add("error", "Connection timeout"))
.Build();
}
catch (HttpRequestException ex)
{
return builder
.Fail($"Cannot reach registry: {ex.Message}")
.WithEvidence("Credentials", eb => eb
.Add("registry_url", registryUrl)
.Add("error", ex.Message))
.Build();
}
}
private static string Redact(string? value)
{
if (string.IsNullOrEmpty(value))
return "(not set)";
if (value.Length <= 4)
return "****";
return $"{value[..2]}****{value[^2..]}";
}
}

View File

@@ -0,0 +1,239 @@
using System.Globalization;
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Doctor.Models;
using StellaOps.Doctor.Plugins;
namespace StellaOps.Doctor.Plugins.Integration.Checks;
/// <summary>
/// Verifies pull authorization from the configured OCI registry.
/// Uses non-destructive HEAD request to test permissions.
/// </summary>
public sealed class RegistryPullAuthorizationCheck : IDoctorCheck
{
/// <inheritdoc />
public string CheckId => "check.integration.oci.pull";
/// <inheritdoc />
public string Name => "OCI Registry Pull Authorization";
/// <inheritdoc />
public string Description => "Verify credentials have pull access to the registry";
/// <inheritdoc />
public DoctorSeverity DefaultSeverity => DoctorSeverity.Fail;
/// <inheritdoc />
public IReadOnlyList<string> Tags => ["registry", "oci", "pull", "authorization", "credentials"];
/// <inheritdoc />
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(5);
/// <inheritdoc />
public bool CanRun(DoctorPluginContext context)
{
var registryUrl = context.Configuration.GetValue<string>("OCI:RegistryUrl")
?? context.Configuration.GetValue<string>("Registry:Url");
return !string.IsNullOrEmpty(registryUrl);
}
/// <inheritdoc />
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
{
var registryUrl = (context.Configuration.GetValue<string>("OCI:RegistryUrl")
?? context.Configuration.GetValue<string>("Registry:Url"))!.TrimEnd('/');
var testRepo = context.Configuration.GetValue<string>("OCI:TestRepository")
?? context.Configuration.GetValue<string>("Registry:TestRepository")
?? "library/alpine";
var testTag = context.Configuration.GetValue<string>("OCI:TestTag")
?? context.Configuration.GetValue<string>("Registry:TestTag")
?? "latest";
var builder = context.CreateResult(CheckId, "stellaops.doctor.integration", DoctorCategory.Integration.ToString());
var httpClientFactory = context.Services.GetService<IHttpClientFactory>();
if (httpClientFactory == null)
{
return builder.Skip("IHttpClientFactory not available").Build();
}
try
{
using var httpClient = httpClientFactory.CreateClient();
httpClient.Timeout = TimeSpan.FromSeconds(10);
ApplyAuthentication(context, httpClient);
using var request = new HttpRequestMessage(
HttpMethod.Head,
$"{registryUrl}/v2/{testRepo}/manifests/{testTag}");
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.oci.image.manifest.v1+json"));
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.docker.distribution.manifest.v2+json"));
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.oci.image.index.v1+json"));
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.docker.distribution.manifest.list.v2+json"));
using var response = await httpClient.SendAsync(request, ct);
if (response.IsSuccessStatusCode)
{
var digest = response.Headers.TryGetValues("Docker-Content-Digest", out var digestValues)
? digestValues.FirstOrDefault() ?? "unknown"
: "unknown";
var contentType = response.Content.Headers.ContentType?.MediaType ?? "unknown";
return builder
.Pass("Pull authorization verified")
.WithEvidence("Pull Authorization", eb => eb
.Add("registry_url", registryUrl)
.Add("test_repository", testRepo)
.Add("test_tag", testTag)
.Add("http_status", ((int)response.StatusCode).ToString(CultureInfo.InvariantCulture))
.Add("pull_authorized", "true")
.Add("manifest_digest", digest)
.Add("manifest_type", contentType))
.Build();
}
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
return builder
.Fail("Pull authorization failed: Invalid credentials")
.WithEvidence("Pull Authorization", eb => eb
.Add("registry_url", registryUrl)
.Add("test_repository", testRepo)
.Add("test_tag", testTag)
.Add("http_status", "401 Unauthorized")
.Add("pull_authorized", "false"))
.WithCauses(
"Credentials are invalid or expired",
"Token has been revoked",
"Anonymous pull not allowed",
"Wrong username/password combination")
.WithRemediation(rb => rb
.AddManualStep(1, "Verify credentials",
"Check that configured username/password or token is correct")
.AddStep(2, "Test with docker CLI",
$"docker pull {new Uri(registryUrl).Host}/{testRepo}:{testTag}",
CommandType.Shell)
.AddManualStep(3, "Check if anonymous pull is supported",
"Some private registries require authentication for all operations")
.WithRunbookUrl("https://docs.stella-ops.org/runbooks/registry-auth-troubleshooting"))
.WithVerification($"stella doctor --check {CheckId}")
.Build();
}
if (response.StatusCode == HttpStatusCode.Forbidden)
{
return builder
.Fail("Pull authorization failed: No pull permission")
.WithEvidence("Pull Authorization", eb => eb
.Add("registry_url", registryUrl)
.Add("test_repository", testRepo)
.Add("test_tag", testTag)
.Add("http_status", "403 Forbidden")
.Add("pull_authorized", "false")
.Add("credentials_valid", "true"))
.WithCauses(
"Credentials valid but lack pull permissions",
"Repository access restricted",
"IP address or network not allowed")
.WithRemediation(rb => rb
.AddManualStep(1, "Check repository permissions",
$"Ensure service account has pull access to {testRepo}")
.AddManualStep(2, "Check access control lists",
"Verify the service account or IP is allowed to pull")
.WithRunbookUrl("https://docs.stella-ops.org/runbooks/registry-auth-troubleshooting"))
.WithVerification($"stella doctor --check {CheckId}")
.Build();
}
if (response.StatusCode == HttpStatusCode.NotFound)
{
return builder
.Info("Cannot verify pull authorization - test image not found")
.WithEvidence("Pull Authorization", eb => eb
.Add("registry_url", registryUrl)
.Add("test_repository", testRepo)
.Add("test_tag", testTag)
.Add("http_status", "404 Not Found")
.Add("pull_authorized", "unknown")
.Add("reason", "Test image does not exist"))
.WithCauses(
"Test image does not exist in registry",
"Repository name format incorrect",
"Tag does not exist")
.WithRemediation(rb => rb
.AddManualStep(1, "Configure a valid test image",
"Set OCI:TestRepository and OCI:TestTag to an existing image in your registry"))
.Build();
}
return builder
.Fail($"Pull authorization check failed: {response.StatusCode}")
.WithEvidence("Pull Authorization", eb => eb
.Add("registry_url", registryUrl)
.Add("test_repository", testRepo)
.Add("test_tag", testTag)
.Add("http_status", ((int)response.StatusCode).ToString(CultureInfo.InvariantCulture)))
.WithCauses(
"Registry returned unexpected response",
"Registry configuration issue")
.Build();
}
catch (TaskCanceledException)
{
return builder
.Fail("Pull authorization check timed out")
.WithEvidence("Pull Authorization", eb => eb
.Add("registry_url", registryUrl)
.Add("error", "Connection timeout"))
.WithCauses(
"Registry is slow to respond",
"Network connectivity issue")
.Build();
}
catch (HttpRequestException ex)
{
return builder
.Fail($"Cannot reach registry: {ex.Message}")
.WithEvidence("Pull Authorization", eb => eb
.Add("registry_url", registryUrl)
.Add("error", ex.Message))
.WithCauses(
"Registry is unreachable",
"DNS resolution failure",
"TLS certificate error")
.Build();
}
}
private static void ApplyAuthentication(DoctorPluginContext context, HttpClient httpClient)
{
var username = context.Configuration.GetValue<string>("OCI:Username")
?? context.Configuration.GetValue<string>("Registry:Username");
var password = context.Configuration.GetValue<string>("OCI:Password")
?? context.Configuration.GetValue<string>("Registry:Password");
if (!string.IsNullOrEmpty(username) && !string.IsNullOrEmpty(password))
{
var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}"));
httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Basic", credentials);
}
var bearerToken = context.Configuration.GetValue<string>("OCI:Token")
?? context.Configuration.GetValue<string>("Registry:Token");
if (!string.IsNullOrEmpty(bearerToken))
{
httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", bearerToken);
}
}
}

View File

@@ -0,0 +1,221 @@
using System.Globalization;
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Doctor.Models;
using StellaOps.Doctor.Plugins;
namespace StellaOps.Doctor.Plugins.Integration.Checks;
/// <summary>
/// Verifies push authorization to the configured OCI registry.
/// Uses non-destructive blob upload initiation to test permissions.
/// </summary>
public sealed class RegistryPushAuthorizationCheck : IDoctorCheck
{
/// <inheritdoc />
public string CheckId => "check.integration.oci.push";
/// <inheritdoc />
public string Name => "OCI Registry Push Authorization";
/// <inheritdoc />
public string Description => "Verify credentials have push access to the registry";
/// <inheritdoc />
public DoctorSeverity DefaultSeverity => DoctorSeverity.Fail;
/// <inheritdoc />
public IReadOnlyList<string> Tags => ["registry", "oci", "push", "authorization", "credentials"];
/// <inheritdoc />
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(5);
/// <inheritdoc />
public bool CanRun(DoctorPluginContext context)
{
var registryUrl = context.Configuration.GetValue<string>("OCI:RegistryUrl")
?? context.Configuration.GetValue<string>("Registry:Url");
var hasAuth = HasAuthentication(context);
return !string.IsNullOrEmpty(registryUrl) && hasAuth;
}
/// <inheritdoc />
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
{
var registryUrl = (context.Configuration.GetValue<string>("OCI:RegistryUrl")
?? context.Configuration.GetValue<string>("Registry:Url"))!.TrimEnd('/');
var testRepo = context.Configuration.GetValue<string>("OCI:TestRepository")
?? context.Configuration.GetValue<string>("Registry:TestRepository")
?? context.Configuration.GetValue<string>("OCI:PushTestRepository")
?? "stellaops/doctor-test";
var builder = context.CreateResult(CheckId, "stellaops.doctor.integration", DoctorCategory.Integration.ToString());
var httpClientFactory = context.Services.GetService<IHttpClientFactory>();
if (httpClientFactory == null)
{
return builder.Skip("IHttpClientFactory not available").Build();
}
try
{
using var httpClient = httpClientFactory.CreateClient();
httpClient.Timeout = TimeSpan.FromSeconds(10);
ApplyAuthentication(context, httpClient);
var uploadUrl = $"{registryUrl}/v2/{testRepo}/blobs/uploads/";
using var response = await httpClient.PostAsync(uploadUrl, null, ct);
if (response.StatusCode == HttpStatusCode.Accepted)
{
var location = response.Headers.Location;
if (location != null)
{
try { await httpClient.DeleteAsync(location, ct); } catch { /* Ignore cleanup errors */ }
}
return builder
.Pass("Push authorization verified")
.WithEvidence("Push Authorization", eb => eb
.Add("registry_url", registryUrl)
.Add("test_repository", testRepo)
.Add("http_status", ((int)response.StatusCode).ToString(CultureInfo.InvariantCulture))
.Add("push_authorized", "true")
.Add("upload_session_cancelled", "true"))
.Build();
}
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
return builder
.Fail("Push authorization failed: Invalid credentials")
.WithEvidence("Push Authorization", eb => eb
.Add("registry_url", registryUrl)
.Add("test_repository", testRepo)
.Add("http_status", "401 Unauthorized")
.Add("push_authorized", "false"))
.WithCauses(
"Credentials are invalid or expired",
"Token has been revoked",
"Wrong username/password combination")
.WithRemediation(rb => rb
.AddManualStep(1, "Verify credentials",
"Check that configured username/password or token is correct")
.AddStep(2, "Test with docker CLI",
$"docker login {new Uri(registryUrl).Host}",
CommandType.Shell)
.AddManualStep(3, "Regenerate token if expired",
"Generate a new access token from your registry provider")
.WithRunbookUrl("https://docs.stella-ops.org/runbooks/registry-auth-troubleshooting"))
.WithVerification($"stella doctor --check {CheckId}")
.Build();
}
if (response.StatusCode == HttpStatusCode.Forbidden)
{
return builder
.Fail("Push authorization failed: No push permission")
.WithEvidence("Push Authorization", eb => eb
.Add("registry_url", registryUrl)
.Add("test_repository", testRepo)
.Add("http_status", "403 Forbidden")
.Add("push_authorized", "false")
.Add("credentials_valid", "true"))
.WithCauses(
"Credentials valid but lack push permissions",
"Repository does not exist and cannot be created",
"Repository permissions restrict push access",
"Organization/team permissions prevent push")
.WithRemediation(rb => rb
.AddManualStep(1, "Check repository permissions",
$"Ensure service account has push access to {testRepo}")
.AddManualStep(2, "Create repository if needed",
"Some registries require repository to exist before push")
.AddManualStep(3, "Contact registry administrator",
"Request push permissions for the service account")
.WithRunbookUrl("https://docs.stella-ops.org/runbooks/registry-auth-troubleshooting"))
.WithVerification($"stella doctor --check {CheckId}")
.Build();
}
return builder
.Fail($"Push authorization check failed: {response.StatusCode}")
.WithEvidence("Push Authorization", eb => eb
.Add("registry_url", registryUrl)
.Add("test_repository", testRepo)
.Add("http_status", ((int)response.StatusCode).ToString(CultureInfo.InvariantCulture)))
.WithCauses(
"Registry returned unexpected response",
"Repository path format incorrect",
"Registry configuration issue")
.Build();
}
catch (TaskCanceledException)
{
return builder
.Fail("Push authorization check timed out")
.WithEvidence("Push Authorization", eb => eb
.Add("registry_url", registryUrl)
.Add("error", "Connection timeout"))
.WithCauses(
"Registry is slow to respond",
"Network connectivity issue")
.Build();
}
catch (HttpRequestException ex)
{
return builder
.Fail($"Cannot reach registry: {ex.Message}")
.WithEvidence("Push Authorization", eb => eb
.Add("registry_url", registryUrl)
.Add("error", ex.Message))
.WithCauses(
"Registry is unreachable",
"DNS resolution failure",
"TLS certificate error")
.Build();
}
}
private static bool HasAuthentication(DoctorPluginContext context)
{
var username = context.Configuration.GetValue<string>("OCI:Username")
?? context.Configuration.GetValue<string>("Registry:Username");
var password = context.Configuration.GetValue<string>("OCI:Password")
?? context.Configuration.GetValue<string>("Registry:Password");
var token = context.Configuration.GetValue<string>("OCI:Token")
?? context.Configuration.GetValue<string>("Registry:Token");
return (!string.IsNullOrEmpty(username) && !string.IsNullOrEmpty(password)) ||
!string.IsNullOrEmpty(token);
}
private static void ApplyAuthentication(DoctorPluginContext context, HttpClient httpClient)
{
var username = context.Configuration.GetValue<string>("OCI:Username")
?? context.Configuration.GetValue<string>("Registry:Username");
var password = context.Configuration.GetValue<string>("OCI:Password")
?? context.Configuration.GetValue<string>("Registry:Password");
if (!string.IsNullOrEmpty(username) && !string.IsNullOrEmpty(password))
{
var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}"));
httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Basic", credentials);
}
var bearerToken = context.Configuration.GetValue<string>("OCI:Token")
?? context.Configuration.GetValue<string>("Registry:Token");
if (!string.IsNullOrEmpty(bearerToken))
{
httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", bearerToken);
}
}
}

View File

@@ -0,0 +1,278 @@
using System.Globalization;
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Doctor.Models;
using StellaOps.Doctor.Plugins;
namespace StellaOps.Doctor.Plugins.Integration.Checks;
/// <summary>
/// Checks if the configured OCI registry supports the referrers API (OCI Distribution Spec v1.1+).
/// </summary>
public sealed class RegistryReferrersApiCheck : IDoctorCheck
{
/// <inheritdoc />
public string CheckId => "check.integration.oci.referrers";
/// <inheritdoc />
public string Name => "OCI Registry Referrers API Support";
/// <inheritdoc />
public string Description => "Verify registry supports OCI 1.1 referrers API for artifact linking";
/// <inheritdoc />
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
/// <inheritdoc />
public IReadOnlyList<string> Tags => ["registry", "oci", "referrers", "compatibility", "oci-1.1"];
/// <inheritdoc />
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(10);
/// <inheritdoc />
public bool CanRun(DoctorPluginContext context)
{
var registryUrl = context.Configuration.GetValue<string>("OCI:RegistryUrl")
?? context.Configuration.GetValue<string>("Registry:Url");
return !string.IsNullOrEmpty(registryUrl);
}
/// <inheritdoc />
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
{
var registryUrl = (context.Configuration.GetValue<string>("OCI:RegistryUrl")
?? context.Configuration.GetValue<string>("Registry:Url"))!.TrimEnd('/');
var testRepo = context.Configuration.GetValue<string>("OCI:TestRepository")
?? context.Configuration.GetValue<string>("Registry:TestRepository")
?? "library/alpine";
var testTag = context.Configuration.GetValue<string>("OCI:TestTag")
?? context.Configuration.GetValue<string>("Registry:TestTag")
?? "latest";
var builder = context.CreateResult(CheckId, "stellaops.doctor.integration", DoctorCategory.Integration.ToString());
var httpClientFactory = context.Services.GetService<IHttpClientFactory>();
if (httpClientFactory == null)
{
return builder.Skip("IHttpClientFactory not available").Build();
}
try
{
using var httpClient = httpClientFactory.CreateClient();
httpClient.Timeout = TimeSpan.FromSeconds(15);
ApplyAuthentication(context, httpClient);
// First, resolve the digest for the test tag
var manifestDigest = await ResolveManifestDigestAsync(httpClient, registryUrl, testRepo, testTag, ct);
if (string.IsNullOrEmpty(manifestDigest))
{
return builder
.Info("Cannot verify referrers API - test image not found")
.WithEvidence("Referrers API Check", eb => eb
.Add("registry_url", registryUrl)
.Add("test_repository", testRepo)
.Add("test_tag", testTag)
.Add("reason", "Test image not found or not accessible"))
.WithCauses(
"Test image does not exist in registry",
"Credentials lack pull permissions",
"Repository name format incorrect for registry")
.Build();
}
// Probe the referrers API endpoint
var referrersEndpoint = $"{registryUrl}/v2/{testRepo}/referrers/{manifestDigest}";
using var request = new HttpRequestMessage(HttpMethod.Get, referrersEndpoint);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.oci.image.index.v1+json"));
using var response = await httpClient.SendAsync(request, ct);
var ociVersion = response.Headers.TryGetValues("OCI-Distribution-API-Version", out var versionHeaders)
? string.Join(", ", versionHeaders)
: "unknown";
if (response.StatusCode == HttpStatusCode.OK)
{
return builder
.Pass("OCI referrers API is supported")
.WithEvidence("Referrers API Support", eb => eb
.Add("registry_url", registryUrl)
.Add("api_endpoint", referrersEndpoint)
.Add("http_status", ((int)response.StatusCode).ToString(CultureInfo.InvariantCulture))
.Add("oci_version", ociVersion)
.Add("referrers_supported", "true")
.Add("fallback_required", "false"))
.Build();
}
if (response.StatusCode == HttpStatusCode.NotFound)
{
var content = await response.Content.ReadAsStringAsync(ct);
var isOciIndex = content.Contains("\"schemaVersion\"") &&
(content.Contains("\"manifests\"") || content.Contains("application/vnd.oci.image.index"));
if (isOciIndex)
{
return builder
.Pass("OCI referrers API is supported (no referrers for test image)")
.WithEvidence("Referrers API Support", eb => eb
.Add("registry_url", registryUrl)
.Add("api_endpoint", referrersEndpoint)
.Add("http_status", "404 (with OCI index)")
.Add("oci_version", ociVersion)
.Add("referrers_supported", "true")
.Add("referrers_count", "0")
.Add("fallback_required", "false"))
.Build();
}
return builder
.Warn("OCI referrers API not supported - using tag-based fallback")
.WithEvidence("Referrers API Support", eb => eb
.Add("registry_url", registryUrl)
.Add("api_endpoint", referrersEndpoint)
.Add("http_status", ((int)response.StatusCode).ToString(CultureInfo.InvariantCulture))
.Add("oci_version", ociVersion)
.Add("referrers_supported", "false")
.Add("fallback_required", "true")
.Add("fallback_pattern", "sha256-{digest}.{artifactType}"))
.WithCauses(
"Registry does not implement OCI Distribution Spec v1.1",
"Registry version is too old",
"Referrers API disabled in registry configuration")
.WithRemediation(rb => rb
.AddManualStep(1, "Check registry version",
"Verify your registry version supports OCI Distribution Spec v1.1+")
.AddManualStep(2, "Upgrade registry",
"Upgrade to: Harbor 2.6+, Quay 3.12+, ACR (default), ECR (default), GCR/Artifact Registry (default)")
.AddManualStep(3, "Note: Fallback available",
"StellaOps automatically uses tag-based fallback (sha256-{digest}.*) when referrers API is unavailable")
.WithRunbookUrl("https://docs.stella-ops.org/runbooks/registry-referrer-troubleshooting"))
.WithVerification($"stella doctor --check {CheckId}")
.Build();
}
if (response.StatusCode == HttpStatusCode.MethodNotAllowed)
{
return builder
.Warn("OCI referrers API not supported - using tag-based fallback")
.WithEvidence("Referrers API Support", eb => eb
.Add("registry_url", registryUrl)
.Add("api_endpoint", referrersEndpoint)
.Add("http_status", "405 Method Not Allowed")
.Add("oci_version", ociVersion)
.Add("referrers_supported", "false")
.Add("fallback_required", "true"))
.WithCauses(
"Registry does not implement OCI Distribution Spec v1.1",
"Referrers API not enabled for this repository")
.WithRemediation(rb => rb
.AddManualStep(1, "Check registry documentation",
"Review registry documentation for OCI 1.1 referrers API support")
.AddManualStep(2, "Note: Fallback available",
"StellaOps automatically uses tag-based fallback when referrers API is unavailable")
.WithRunbookUrl("https://docs.stella-ops.org/runbooks/registry-referrer-troubleshooting"))
.Build();
}
return builder
.Fail($"Registry referrers API check failed: {response.StatusCode}")
.WithEvidence("Referrers API Check", eb => eb
.Add("registry_url", registryUrl)
.Add("api_endpoint", referrersEndpoint)
.Add("http_status", ((int)response.StatusCode).ToString(CultureInfo.InvariantCulture))
.Add("oci_version", ociVersion))
.WithCauses(
"Registry returned unexpected error",
"Authentication issue",
"Network connectivity problem")
.Build();
}
catch (TaskCanceledException)
{
return builder
.Warn("Registry referrers API check timed out")
.WithEvidence("Referrers API Check", eb => eb
.Add("registry_url", registryUrl)
.Add("error", "Connection timeout"))
.WithCauses(
"Registry is slow to respond",
"Network connectivity issue",
"Registry is under heavy load")
.Build();
}
catch (HttpRequestException ex)
{
return builder
.Fail($"Cannot reach registry: {ex.Message}")
.WithEvidence("Referrers API Check", eb => eb
.Add("registry_url", registryUrl)
.Add("error", ex.Message))
.WithCauses(
"Registry is unreachable",
"DNS resolution failure",
"TLS certificate error")
.Build();
}
}
private static void ApplyAuthentication(DoctorPluginContext context, HttpClient httpClient)
{
var username = context.Configuration.GetValue<string>("OCI:Username")
?? context.Configuration.GetValue<string>("Registry:Username");
var password = context.Configuration.GetValue<string>("OCI:Password")
?? context.Configuration.GetValue<string>("Registry:Password");
if (!string.IsNullOrEmpty(username) && !string.IsNullOrEmpty(password))
{
var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}"));
httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Basic", credentials);
}
var bearerToken = context.Configuration.GetValue<string>("OCI:Token")
?? context.Configuration.GetValue<string>("Registry:Token");
if (!string.IsNullOrEmpty(bearerToken))
{
httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", bearerToken);
}
}
private static async Task<string?> ResolveManifestDigestAsync(
HttpClient httpClient,
string registryUrl,
string repository,
string tag,
CancellationToken ct)
{
using var request = new HttpRequestMessage(
HttpMethod.Head,
$"{registryUrl}/v2/{repository}/manifests/{tag}");
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.oci.image.manifest.v1+json"));
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.docker.distribution.manifest.v2+json"));
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.oci.image.index.v1+json"));
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.docker.distribution.manifest.list.v2+json"));
using var response = await httpClient.SendAsync(request, ct);
if (!response.IsSuccessStatusCode)
return null;
if (response.Headers.TryGetValues("Docker-Content-Digest", out var digestValues))
{
return digestValues.FirstOrDefault();
}
return null;
}
}

View File

@@ -31,6 +31,11 @@ public sealed class IntegrationPlugin : IDoctorPlugin
public IReadOnlyList<IDoctorCheck> GetChecks(DoctorPluginContext context) =>
[
new OciRegistryCheck(),
new RegistryReferrersApiCheck(),
new RegistryCapabilityProbeCheck(),
new RegistryPushAuthorizationCheck(),
new RegistryPullAuthorizationCheck(),
new RegistryCredentialsCheck(),
new ObjectStorageCheck(),
new SmtpCheck(),
new SlackWebhookCheck(),