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; /// /// Verifies ability to pull a test artifact by digest. /// public sealed class TestArtifactPullCheck : VerificationCheckBase { private const string RunbookUrlValue = "docs/doctor/articles/verification/verification-artifact-pull.md"; /// public override string CheckId => "check.verification.artifact.pull"; /// public override string Name => "Test Artifact Pull"; /// public override string Description => "Verifies ability to pull a test artifact by digest from the configured registry"; /// public override IReadOnlyList Tags => ["verification", "artifact", "registry", "connectivity"]; /// public override TimeSpan EstimatedDuration => TimeSpan.FromSeconds(15); /// protected override string RunbookUrl => RunbookUrlValue; /// public override bool CanRun(DoctorPluginContext context) { if (!base.CanRun(context)) return false; var options = VerificationPlugin.GetOptions(context); return HasTestArtifactConfigured(options); } /// protected override async Task 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 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 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" }; } }