test fixes and new product advisories work
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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..]}";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user