276 lines
11 KiB
C#
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"
|
|
};
|
|
}
|
|
}
|