Files
git.stella-ops.org/src/__Libraries/StellaOps.Doctor.Plugins.Verification/Checks/TestArtifactPullCheck.cs
2026-03-31 23:26:24 +03:00

276 lines
11 KiB
C#

using StellaOps.Doctor.Models;
using StellaOps.Doctor.Plugins;
using StellaOps.Doctor.Plugins.Builders;
using StellaOps.Doctor.Plugins.Verification.Configuration;
using System.Diagnostics;
namespace StellaOps.Doctor.Plugins.Verification.Checks;
/// <summary>
/// Verifies ability to pull a test artifact by digest.
/// </summary>
public sealed class TestArtifactPullCheck : VerificationCheckBase
{
private const string RunbookUrlValue = "docs/doctor/articles/verification/verification-artifact-pull.md";
/// <inheritdoc />
public override string CheckId => "check.verification.artifact.pull";
/// <inheritdoc />
public override string Name => "Test Artifact Pull";
/// <inheritdoc />
public override string Description => "Verifies ability to pull a test artifact by digest from the configured registry";
/// <inheritdoc />
public override IReadOnlyList<string> Tags => ["verification", "artifact", "registry", "connectivity"];
/// <inheritdoc />
public override TimeSpan EstimatedDuration => TimeSpan.FromSeconds(15);
/// <inheritdoc />
protected override string RunbookUrl => RunbookUrlValue;
/// <inheritdoc />
public override bool CanRun(DoctorPluginContext context)
{
if (!base.CanRun(context))
return false;
var options = VerificationPlugin.GetOptions(context);
return HasTestArtifactConfigured(options);
}
/// <inheritdoc />
protected override async Task<DoctorCheckResult> ExecuteCheckAsync(
DoctorPluginContext context,
VerificationPluginOptions options,
CheckResultBuilder result,
CancellationToken ct)
{
if (!HasTestArtifactConfigured(options))
{
return GetNoTestArtifactConfiguredResult(result, CheckId);
}
// Check offline bundle first if configured
if (!string.IsNullOrEmpty(options.TestArtifact.OfflineBundlePath))
{
return await CheckOfflineBundle(options, result, ct);
}
// Online artifact pull
return await CheckOnlineArtifact(options, result, ct);
}
private static Task<DoctorCheckResult> CheckOfflineBundle(
VerificationPluginOptions options,
CheckResultBuilder result,
CancellationToken ct)
{
var bundlePath = options.TestArtifact.OfflineBundlePath!;
if (!File.Exists(bundlePath))
{
return Task.FromResult(result
.Fail($"Offline test artifact bundle not found: {bundlePath}")
.WithEvidence("Bundle", e => e
.Add("BundlePath", bundlePath)
.Add("FileExists", "false"))
.WithCauses(
"Bundle file was deleted or moved",
"Path is incorrect")
.WithRemediation(r => r
.AddShellStep(1, "Verify file exists", $"ls -la {bundlePath}")
.AddShellStep(2, "Export bundle from online system", "stella verification bundle export --output " + bundlePath)
.WithRunbookUrl(RunbookUrlValue))
.WithVerification($"stella doctor --check check.verification.artifact.pull")
.Build());
}
var fileInfo = new FileInfo(bundlePath);
return Task.FromResult(result
.Pass($"Offline test artifact bundle available ({FormatFileSize(fileInfo.Length)})")
.WithEvidence("Bundle", e => e
.Add("BundlePath", bundlePath)
.Add("FileSize", FormatFileSize(fileInfo.Length))
.Add("Mode", "Offline"))
.Build());
}
private static async Task<DoctorCheckResult> CheckOnlineArtifact(
VerificationPluginOptions options,
CheckResultBuilder result,
CancellationToken ct)
{
var reference = options.TestArtifact.Reference!;
// Parse OCI reference
var (registry, repository, digest) = ParseOciReference(reference);
if (string.IsNullOrEmpty(registry) || string.IsNullOrEmpty(repository))
{
return result
.Fail($"Invalid OCI reference: {reference}")
.WithEvidence("Reference", e => e
.Add("Reference", reference)
.Add("Error", "Could not parse registry and repository"))
.WithCauses("Reference format is incorrect")
.WithRemediation(r => r
.AddManualStep(1, "Fix reference format", "Use format: oci://registry/repository@sha256:digest or registry/repository@sha256:digest")
.WithRunbookUrl(RunbookUrlValue))
.WithVerification($"stella doctor --check check.verification.artifact.pull")
.Build();
}
// Check if we can resolve the manifest (metadata only, no full pull)
using var httpClient = CreateHttpClient(options);
// Build registry API URL
var manifestUrl = $"https://{registry}/v2/{repository}/manifests/{digest ?? "latest"}";
var sw = Stopwatch.StartNew();
try
{
using var request = new HttpRequestMessage(HttpMethod.Head, manifestUrl);
request.Headers.Add("Accept", "application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json");
var response = await httpClient.SendAsync(request, ct);
sw.Stop();
if (!response.IsSuccessStatusCode)
{
return result
.Fail($"Cannot access test artifact: {(int)response.StatusCode} {response.ReasonPhrase}")
.WithEvidence("Artifact", e => e
.Add("Reference", reference)
.Add("Registry", registry)
.Add("Repository", repository)
.Add("StatusCode", ((int)response.StatusCode).ToString())
.Add("ResponseTime", $"{sw.ElapsedMilliseconds}ms"))
.WithCauses(
"Artifact does not exist",
"Authentication required",
"Insufficient permissions")
.WithRemediation(r => r
.AddShellStep(1, "Test with crane", $"crane manifest {reference}")
.AddManualStep(2, "Check registry credentials", "Ensure registry credentials are configured")
.AddManualStep(3, "Verify artifact exists", "Confirm the test artifact has been pushed to the registry")
.WithRunbookUrl(RunbookUrlValue))
.WithVerification($"stella doctor --check check.verification.artifact.pull")
.Build();
}
// Extract digest from response if available
var responseDigest = response.Headers.TryGetValues("Docker-Content-Digest", out var digestValues)
? digestValues.FirstOrDefault()
: null;
// Verify digest matches expected if configured
if (!string.IsNullOrEmpty(options.TestArtifact.ExpectedDigest)
&& !string.IsNullOrEmpty(responseDigest)
&& !responseDigest.Equals(options.TestArtifact.ExpectedDigest, StringComparison.OrdinalIgnoreCase))
{
return result
.Warn("Test artifact digest mismatch")
.WithEvidence("Artifact", e => e
.Add("Reference", reference)
.Add("ExpectedDigest", options.TestArtifact.ExpectedDigest)
.Add("ActualDigest", responseDigest)
.Add("ResponseTime", $"{sw.ElapsedMilliseconds}ms"))
.WithCauses(
"Test artifact was updated",
"Wrong artifact tag being pulled")
.WithRemediation(r => r
.AddManualStep(1, "Update expected digest", $"Set Doctor:Plugins:Verification:TestArtifact:ExpectedDigest to {responseDigest}")
.AddManualStep(2, "Or use digest in reference", "Use @sha256:... in the reference instead of :tag")
.WithRunbookUrl(RunbookUrlValue))
.WithVerification($"stella doctor --check check.verification.artifact.pull")
.Build();
}
return result
.Pass($"Test artifact accessible ({sw.ElapsedMilliseconds}ms)")
.WithEvidence("Artifact", e => e
.Add("Reference", reference)
.Add("Registry", registry)
.Add("Repository", repository)
.Add("Digest", responseDigest ?? "(not provided)")
.Add("ResponseTime", $"{sw.ElapsedMilliseconds}ms"))
.Build();
}
catch (HttpRequestException ex)
{
sw.Stop();
return result
.Fail($"Cannot reach registry: {ex.Message}")
.WithEvidence("Artifact", e => e
.Add("Reference", reference)
.Add("Registry", registry)
.Add("Error", ex.Message))
.WithCauses(
"Registry is unreachable",
"Network connectivity issue",
"DNS resolution failure")
.WithRemediation(r => r
.AddShellStep(1, "Test registry connectivity", $"curl -I https://{registry}/v2/")
.AddManualStep(2, "Check network configuration", "Ensure HTTPS traffic to the registry is allowed")
.WithRunbookUrl(RunbookUrlValue))
.WithVerification($"stella doctor --check check.verification.artifact.pull")
.Build();
}
}
private static (string? Registry, string? Repository, string? Digest) ParseOciReference(string reference)
{
// Remove oci:// prefix if present
var cleanRef = reference;
if (cleanRef.StartsWith("oci://", StringComparison.OrdinalIgnoreCase))
cleanRef = cleanRef[6..];
// Split by @ to get digest
string? digest = null;
var atIndex = cleanRef.IndexOf('@');
if (atIndex > 0)
{
digest = cleanRef[(atIndex + 1)..];
cleanRef = cleanRef[..atIndex];
}
// Split by : to remove tag (we prefer digest)
var colonIndex = cleanRef.LastIndexOf(':');
if (colonIndex > 0 && !cleanRef[..colonIndex].Contains('/'))
{
// This is a port, not a tag
}
else if (colonIndex > cleanRef.IndexOf('/'))
{
cleanRef = cleanRef[..colonIndex];
}
// First part is registry, rest is repository
var slashIndex = cleanRef.IndexOf('/');
if (slashIndex <= 0)
return (null, null, null);
var registry = cleanRef[..slashIndex];
var repository = cleanRef[(slashIndex + 1)..];
return (registry, repository, digest);
}
private static string FormatFileSize(long bytes)
{
return bytes switch
{
< 1024 => $"{bytes} B",
< 1024 * 1024 => $"{bytes / 1024.0:F1} KB",
< 1024 * 1024 * 1024 => $"{bytes / (1024.0 * 1024.0):F1} MB",
_ => $"{bytes / (1024.0 * 1024.0 * 1024.0):F1} GB"
};
}
}