Add tests and implement StubBearer authentication for Signer endpoints
- Created SignerEndpointsTests to validate the SignDsse and VerifyReferrers endpoints. - Implemented StubBearerAuthenticationDefaults and StubBearerAuthenticationHandler for token-based authentication. - Developed ConcelierExporterClient for managing Trivy DB settings and export operations. - Added TrivyDbSettingsPageComponent for UI interactions with Trivy DB settings, including form handling and export triggering. - Implemented styles and HTML structure for Trivy DB settings page. - Created NotifySmokeCheck tool for validating Redis event streams and Notify deliveries.
This commit is contained in:
@@ -535,7 +535,687 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
return list;
|
||||
}
|
||||
|
||||
private static List<string> NormalizeImages(IReadOnlyList<string> images)
|
||||
public async Task<OfflineKitDownloadResult> DownloadOfflineKitAsync(string? bundleId, string destinationDirectory, bool overwrite, bool resume, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureBackendConfigured();
|
||||
|
||||
var rootDirectory = ResolveOfflineDirectory(destinationDirectory);
|
||||
Directory.CreateDirectory(rootDirectory);
|
||||
|
||||
var descriptor = await FetchOfflineKitDescriptorAsync(bundleId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var bundlePath = Path.Combine(rootDirectory, descriptor.BundleName);
|
||||
var metadataPath = bundlePath + ".metadata.json";
|
||||
var manifestPath = Path.Combine(rootDirectory, descriptor.ManifestName);
|
||||
var bundleSignaturePath = descriptor.BundleSignatureName is not null ? Path.Combine(rootDirectory, descriptor.BundleSignatureName) : null;
|
||||
var manifestSignaturePath = descriptor.ManifestSignatureName is not null ? Path.Combine(rootDirectory, descriptor.ManifestSignatureName) : null;
|
||||
|
||||
var fromCache = false;
|
||||
if (!overwrite && File.Exists(bundlePath))
|
||||
{
|
||||
var digest = await ComputeSha256Async(bundlePath, cancellationToken).ConfigureAwait(false);
|
||||
if (string.Equals(digest, descriptor.BundleSha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
fromCache = true;
|
||||
}
|
||||
else if (resume)
|
||||
{
|
||||
var partial = bundlePath + ".partial";
|
||||
File.Move(bundlePath, partial, overwrite: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
File.Delete(bundlePath);
|
||||
}
|
||||
}
|
||||
|
||||
if (!fromCache)
|
||||
{
|
||||
await DownloadFileWithResumeAsync(descriptor.BundleDownloadUri, bundlePath, descriptor.BundleSha256, descriptor.BundleSize, resume, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await DownloadFileWithResumeAsync(descriptor.ManifestDownloadUri, manifestPath, descriptor.ManifestSha256, descriptor.ManifestSize ?? 0, resume: false, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (descriptor.BundleSignatureDownloadUri is not null && bundleSignaturePath is not null)
|
||||
{
|
||||
await DownloadAuxiliaryFileAsync(descriptor.BundleSignatureDownloadUri, bundleSignaturePath, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (descriptor.ManifestSignatureDownloadUri is not null && manifestSignaturePath is not null)
|
||||
{
|
||||
await DownloadAuxiliaryFileAsync(descriptor.ManifestSignatureDownloadUri, manifestSignaturePath, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await WriteOfflineKitMetadataAsync(metadataPath, descriptor, bundlePath, manifestPath, bundleSignaturePath, manifestSignaturePath, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new OfflineKitDownloadResult(
|
||||
descriptor,
|
||||
bundlePath,
|
||||
manifestPath,
|
||||
bundleSignaturePath,
|
||||
manifestSignaturePath,
|
||||
metadataPath,
|
||||
fromCache);
|
||||
}
|
||||
|
||||
public async Task<OfflineKitImportResult> ImportOfflineKitAsync(OfflineKitImportRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureBackendConfigured();
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
var bundlePath = Path.GetFullPath(request.BundlePath);
|
||||
if (!File.Exists(bundlePath))
|
||||
{
|
||||
throw new FileNotFoundException("Offline kit bundle not found.", bundlePath);
|
||||
}
|
||||
|
||||
string? manifestPath = null;
|
||||
if (!string.IsNullOrWhiteSpace(request.ManifestPath))
|
||||
{
|
||||
manifestPath = Path.GetFullPath(request.ManifestPath);
|
||||
if (!File.Exists(manifestPath))
|
||||
{
|
||||
throw new FileNotFoundException("Offline kit manifest not found.", manifestPath);
|
||||
}
|
||||
}
|
||||
|
||||
string? bundleSignaturePath = null;
|
||||
if (!string.IsNullOrWhiteSpace(request.BundleSignaturePath))
|
||||
{
|
||||
bundleSignaturePath = Path.GetFullPath(request.BundleSignaturePath);
|
||||
if (!File.Exists(bundleSignaturePath))
|
||||
{
|
||||
throw new FileNotFoundException("Offline kit bundle signature not found.", bundleSignaturePath);
|
||||
}
|
||||
}
|
||||
|
||||
string? manifestSignaturePath = null;
|
||||
if (!string.IsNullOrWhiteSpace(request.ManifestSignaturePath))
|
||||
{
|
||||
manifestSignaturePath = Path.GetFullPath(request.ManifestSignaturePath);
|
||||
if (!File.Exists(manifestSignaturePath))
|
||||
{
|
||||
throw new FileNotFoundException("Offline kit manifest signature not found.", manifestSignaturePath);
|
||||
}
|
||||
}
|
||||
|
||||
var bundleSize = request.BundleSize ?? new FileInfo(bundlePath).Length;
|
||||
var bundleSha = string.IsNullOrWhiteSpace(request.BundleSha256)
|
||||
? await ComputeSha256Async(bundlePath, cancellationToken).ConfigureAwait(false)
|
||||
: NormalizeSha(request.BundleSha256) ?? throw new InvalidOperationException("Bundle digest must not be empty.");
|
||||
|
||||
string? manifestSha = null;
|
||||
long? manifestSize = null;
|
||||
if (manifestPath is not null)
|
||||
{
|
||||
manifestSize = request.ManifestSize ?? new FileInfo(manifestPath).Length;
|
||||
manifestSha = string.IsNullOrWhiteSpace(request.ManifestSha256)
|
||||
? await ComputeSha256Async(manifestPath, cancellationToken).ConfigureAwait(false)
|
||||
: NormalizeSha(request.ManifestSha256);
|
||||
}
|
||||
|
||||
var metadata = new OfflineKitImportMetadataPayload
|
||||
{
|
||||
BundleId = request.BundleId,
|
||||
BundleSha256 = bundleSha,
|
||||
BundleSize = bundleSize,
|
||||
CapturedAt = request.CapturedAt,
|
||||
Channel = request.Channel,
|
||||
Kind = request.Kind,
|
||||
IsDelta = request.IsDelta,
|
||||
BaseBundleId = request.BaseBundleId,
|
||||
ManifestSha256 = manifestSha,
|
||||
ManifestSize = manifestSize
|
||||
};
|
||||
|
||||
using var message = CreateRequest(HttpMethod.Post, "api/offline-kit/import");
|
||||
await AuthorizeRequestAsync(message, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var content = new MultipartFormDataContent();
|
||||
|
||||
var metadataOptions = new JsonSerializerOptions(SerializerOptions)
|
||||
{
|
||||
WriteIndented = false
|
||||
};
|
||||
var metadataJson = JsonSerializer.Serialize(metadata, metadataOptions);
|
||||
var metadataContent = new StringContent(metadataJson, Encoding.UTF8, "application/json");
|
||||
content.Add(metadataContent, "metadata");
|
||||
|
||||
var bundleStream = File.OpenRead(bundlePath);
|
||||
var bundleContent = new StreamContent(bundleStream);
|
||||
bundleContent.Headers.ContentType = new MediaTypeHeaderValue("application/gzip");
|
||||
content.Add(bundleContent, "bundle", Path.GetFileName(bundlePath));
|
||||
|
||||
if (manifestPath is not null)
|
||||
{
|
||||
var manifestStream = File.OpenRead(manifestPath);
|
||||
var manifestContent = new StreamContent(manifestStream);
|
||||
manifestContent.Headers.ContentType = new MediaTypeHeaderValue("application/json");
|
||||
content.Add(manifestContent, "manifest", Path.GetFileName(manifestPath));
|
||||
}
|
||||
|
||||
if (bundleSignaturePath is not null)
|
||||
{
|
||||
var signatureStream = File.OpenRead(bundleSignaturePath);
|
||||
var signatureContent = new StreamContent(signatureStream);
|
||||
signatureContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
|
||||
content.Add(signatureContent, "bundleSignature", Path.GetFileName(bundleSignaturePath));
|
||||
}
|
||||
|
||||
if (manifestSignaturePath is not null)
|
||||
{
|
||||
var manifestSignatureStream = File.OpenRead(manifestSignaturePath);
|
||||
var manifestSignatureContent = new StreamContent(manifestSignatureStream);
|
||||
manifestSignatureContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
|
||||
content.Add(manifestSignatureContent, "manifestSignature", Path.GetFileName(manifestSignaturePath));
|
||||
}
|
||||
|
||||
message.Content = content;
|
||||
|
||||
using var response = await _httpClient.SendAsync(message, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException(failure);
|
||||
}
|
||||
|
||||
OfflineKitImportResponseTransport? document;
|
||||
try
|
||||
{
|
||||
document = await response.Content.ReadFromJsonAsync<OfflineKitImportResponseTransport>(SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
var raw = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException($"Failed to parse offline kit import response. {ex.Message}", ex)
|
||||
{
|
||||
Data = { ["payload"] = raw }
|
||||
};
|
||||
}
|
||||
|
||||
var submittedAt = document?.SubmittedAt ?? DateTimeOffset.UtcNow;
|
||||
|
||||
return new OfflineKitImportResult(
|
||||
document?.ImportId,
|
||||
document?.Status,
|
||||
submittedAt,
|
||||
document?.Message);
|
||||
}
|
||||
|
||||
public async Task<OfflineKitStatus> GetOfflineKitStatusAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureBackendConfigured();
|
||||
|
||||
using var request = CreateRequest(HttpMethod.Get, "api/offline-kit/status");
|
||||
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException(failure);
|
||||
}
|
||||
|
||||
if (response.Content is null || response.Content.Headers.ContentLength is 0)
|
||||
{
|
||||
return new OfflineKitStatus(null, null, null, false, null, null, null, null, null, Array.Empty<OfflineKitComponentStatus>());
|
||||
}
|
||||
|
||||
OfflineKitStatusTransport? document;
|
||||
try
|
||||
{
|
||||
document = await response.Content.ReadFromJsonAsync<OfflineKitStatusTransport>(SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
var raw = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException($"Failed to parse offline kit status response. {ex.Message}", ex)
|
||||
{
|
||||
Data = { ["payload"] = raw }
|
||||
};
|
||||
}
|
||||
|
||||
var current = document?.Current;
|
||||
var components = MapOfflineComponents(document?.Components);
|
||||
|
||||
if (current is null)
|
||||
{
|
||||
return new OfflineKitStatus(null, null, null, false, null, null, null, null, null, components);
|
||||
}
|
||||
|
||||
return new OfflineKitStatus(
|
||||
NormalizeOptionalString(current.BundleId),
|
||||
NormalizeOptionalString(current.Channel),
|
||||
NormalizeOptionalString(current.Kind),
|
||||
current.IsDelta ?? false,
|
||||
NormalizeOptionalString(current.BaseBundleId),
|
||||
current.CapturedAt?.ToUniversalTime(),
|
||||
current.ImportedAt?.ToUniversalTime(),
|
||||
NormalizeSha(current.BundleSha256),
|
||||
current.BundleSize,
|
||||
components);
|
||||
}
|
||||
|
||||
private string ResolveOfflineDirectory(string destinationDirectory)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(destinationDirectory))
|
||||
{
|
||||
return Path.GetFullPath(destinationDirectory);
|
||||
}
|
||||
|
||||
var configured = _options.Offline?.KitsDirectory;
|
||||
if (!string.IsNullOrWhiteSpace(configured))
|
||||
{
|
||||
return Path.GetFullPath(configured);
|
||||
}
|
||||
|
||||
return Path.GetFullPath(Path.Combine(Environment.CurrentDirectory, "offline-kits"));
|
||||
}
|
||||
|
||||
private async Task<OfflineKitBundleDescriptor> FetchOfflineKitDescriptorAsync(string? bundleId, CancellationToken cancellationToken)
|
||||
{
|
||||
var route = string.IsNullOrWhiteSpace(bundleId)
|
||||
? "api/offline-kit/bundles/latest"
|
||||
: $"api/offline-kit/bundles/{Uri.EscapeDataString(bundleId)}";
|
||||
|
||||
using var request = CreateRequest(HttpMethod.Get, route);
|
||||
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException(failure);
|
||||
}
|
||||
|
||||
OfflineKitBundleDescriptorTransport? payload;
|
||||
try
|
||||
{
|
||||
payload = await response.Content.ReadFromJsonAsync<OfflineKitBundleDescriptorTransport>(SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
var raw = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException($"Failed to parse offline kit metadata. {ex.Message}", ex)
|
||||
{
|
||||
Data = { ["payload"] = raw }
|
||||
};
|
||||
}
|
||||
|
||||
if (payload is null)
|
||||
{
|
||||
throw new InvalidOperationException("Offline kit metadata response was empty.");
|
||||
}
|
||||
|
||||
return MapOfflineKitDescriptor(payload);
|
||||
}
|
||||
|
||||
private OfflineKitBundleDescriptor MapOfflineKitDescriptor(OfflineKitBundleDescriptorTransport transport)
|
||||
{
|
||||
if (transport is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(transport));
|
||||
}
|
||||
|
||||
var bundleName = string.IsNullOrWhiteSpace(transport.BundleName)
|
||||
? throw new InvalidOperationException("Offline kit metadata missing bundleName.")
|
||||
: transport.BundleName!.Trim();
|
||||
|
||||
var bundleId = string.IsNullOrWhiteSpace(transport.BundleId) ? bundleName : transport.BundleId!.Trim();
|
||||
var bundleSha = NormalizeSha(transport.BundleSha256) ?? throw new InvalidOperationException("Offline kit metadata missing bundleSha256.");
|
||||
|
||||
var bundleSize = transport.BundleSize;
|
||||
if (bundleSize <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("Offline kit metadata missing bundle size.");
|
||||
}
|
||||
|
||||
var manifestName = string.IsNullOrWhiteSpace(transport.ManifestName) ? "offline-manifest.json" : transport.ManifestName!.Trim();
|
||||
var manifestSha = NormalizeSha(transport.ManifestSha256) ?? throw new InvalidOperationException("Offline kit metadata missing manifestSha256.");
|
||||
var capturedAt = transport.CapturedAt?.ToUniversalTime() ?? DateTimeOffset.UtcNow;
|
||||
|
||||
var bundleDownloadUri = ResolveDownloadUri(transport.BundleUrl, transport.BundlePath, bundleName);
|
||||
var manifestDownloadUri = ResolveDownloadUri(transport.ManifestUrl, transport.ManifestPath, manifestName);
|
||||
var bundleSignatureUri = ResolveOptionalDownloadUri(transport.BundleSignatureUrl, transport.BundleSignaturePath, transport.BundleSignatureName);
|
||||
var manifestSignatureUri = ResolveOptionalDownloadUri(transport.ManifestSignatureUrl, transport.ManifestSignaturePath, transport.ManifestSignatureName);
|
||||
var bundleSignatureName = ResolveArtifactName(transport.BundleSignatureName, bundleSignatureUri);
|
||||
var manifestSignatureName = ResolveArtifactName(transport.ManifestSignatureName, manifestSignatureUri);
|
||||
|
||||
return new OfflineKitBundleDescriptor(
|
||||
bundleId,
|
||||
bundleName,
|
||||
bundleSha,
|
||||
bundleSize,
|
||||
bundleDownloadUri,
|
||||
manifestName,
|
||||
manifestSha,
|
||||
manifestDownloadUri,
|
||||
capturedAt,
|
||||
NormalizeOptionalString(transport.Channel),
|
||||
NormalizeOptionalString(transport.Kind),
|
||||
transport.IsDelta ?? false,
|
||||
NormalizeOptionalString(transport.BaseBundleId),
|
||||
bundleSignatureName,
|
||||
bundleSignatureUri,
|
||||
manifestSignatureName,
|
||||
manifestSignatureUri,
|
||||
transport.ManifestSize);
|
||||
}
|
||||
|
||||
private static string? ResolveArtifactName(string? explicitName, Uri? uri)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(explicitName))
|
||||
{
|
||||
return explicitName.Trim();
|
||||
}
|
||||
|
||||
if (uri is not null)
|
||||
{
|
||||
var name = Path.GetFileName(uri.LocalPath);
|
||||
return string.IsNullOrWhiteSpace(name) ? null : name;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private Uri ResolveDownloadUri(string? absoluteOrRelativeUrl, string? relativePath, string fallbackFileName)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(absoluteOrRelativeUrl))
|
||||
{
|
||||
var candidate = new Uri(absoluteOrRelativeUrl, UriKind.RelativeOrAbsolute);
|
||||
if (candidate.IsAbsoluteUri)
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
if (_httpClient.BaseAddress is not null)
|
||||
{
|
||||
return new Uri(_httpClient.BaseAddress, candidate);
|
||||
}
|
||||
|
||||
return BuildUriFromRelative(candidate.ToString());
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(relativePath))
|
||||
{
|
||||
return BuildUriFromRelative(relativePath);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(fallbackFileName))
|
||||
{
|
||||
return BuildUriFromRelative(fallbackFileName);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Offline kit metadata did not include a download URL.");
|
||||
}
|
||||
|
||||
private Uri BuildUriFromRelative(string relative)
|
||||
{
|
||||
var normalized = relative.TrimStart('/');
|
||||
if (!string.IsNullOrWhiteSpace(_options.Offline?.MirrorUrl) &&
|
||||
Uri.TryCreate(_options.Offline.MirrorUrl, UriKind.Absolute, out var mirrorBase))
|
||||
{
|
||||
if (!mirrorBase.AbsoluteUri.EndsWith("/"))
|
||||
{
|
||||
mirrorBase = new Uri(mirrorBase.AbsoluteUri + "/");
|
||||
}
|
||||
|
||||
return new Uri(mirrorBase, normalized);
|
||||
}
|
||||
|
||||
if (_httpClient.BaseAddress is not null)
|
||||
{
|
||||
return new Uri(_httpClient.BaseAddress, normalized);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Cannot resolve offline kit URI for '{relative}' because no mirror or backend base address is configured.");
|
||||
}
|
||||
|
||||
private Uri? ResolveOptionalDownloadUri(string? absoluteOrRelativeUrl, string? relativePath, string? fallbackName)
|
||||
{
|
||||
var hasData = !string.IsNullOrWhiteSpace(absoluteOrRelativeUrl) ||
|
||||
!string.IsNullOrWhiteSpace(relativePath) ||
|
||||
!string.IsNullOrWhiteSpace(fallbackName);
|
||||
|
||||
if (!hasData)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return ResolveDownloadUri(absoluteOrRelativeUrl, relativePath, fallbackName ?? string.Empty);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DownloadFileWithResumeAsync(Uri downloadUri, string targetPath, string expectedSha256, long expectedSize, bool resume, CancellationToken cancellationToken)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(targetPath);
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
var partialPath = resume ? targetPath + ".partial" : targetPath + ".tmp";
|
||||
|
||||
if (!resume && File.Exists(targetPath))
|
||||
{
|
||||
File.Delete(targetPath);
|
||||
}
|
||||
|
||||
if (resume && File.Exists(targetPath))
|
||||
{
|
||||
File.Move(targetPath, partialPath, overwrite: true);
|
||||
}
|
||||
|
||||
long existingLength = 0;
|
||||
if (resume && File.Exists(partialPath))
|
||||
{
|
||||
existingLength = new FileInfo(partialPath).Length;
|
||||
if (expectedSize > 0 && existingLength >= expectedSize)
|
||||
{
|
||||
existingLength = expectedSize;
|
||||
}
|
||||
}
|
||||
|
||||
while (true)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, downloadUri);
|
||||
if (resume && existingLength > 0 && expectedSize > 0 && existingLength < expectedSize)
|
||||
{
|
||||
request.Headers.Range = new RangeHeaderValue(existingLength, null);
|
||||
}
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (resume && existingLength > 0 && expectedSize > 0 && existingLength < expectedSize && response.StatusCode == HttpStatusCode.OK)
|
||||
{
|
||||
existingLength = 0;
|
||||
if (File.Exists(partialPath))
|
||||
{
|
||||
File.Delete(partialPath);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode &&
|
||||
!(resume && existingLength > 0 && response.StatusCode == HttpStatusCode.PartialContent))
|
||||
{
|
||||
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException(failure);
|
||||
}
|
||||
|
||||
var destination = resume ? partialPath : targetPath;
|
||||
var mode = resume && existingLength > 0 ? FileMode.Append : FileMode.Create;
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using (var file = new FileStream(destination, mode, FileAccess.Write, FileShare.None, 81920, useAsync: true))
|
||||
{
|
||||
await stream.CopyToAsync(file, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
if (resume && File.Exists(partialPath))
|
||||
{
|
||||
File.Move(partialPath, targetPath, overwrite: true);
|
||||
}
|
||||
|
||||
var digest = await ComputeSha256Async(targetPath, cancellationToken).ConfigureAwait(false);
|
||||
if (!string.Equals(digest, expectedSha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
File.Delete(targetPath);
|
||||
throw new InvalidOperationException($"Digest mismatch for {Path.GetFileName(targetPath)}. Expected {expectedSha256} but computed {digest}.");
|
||||
}
|
||||
|
||||
if (expectedSize > 0)
|
||||
{
|
||||
var actualSize = new FileInfo(targetPath).Length;
|
||||
if (actualSize != expectedSize)
|
||||
{
|
||||
File.Delete(targetPath);
|
||||
throw new InvalidOperationException($"Size mismatch for {Path.GetFileName(targetPath)}. Expected {expectedSize:N0} bytes but downloaded {actualSize:N0} bytes.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DownloadAuxiliaryFileAsync(Uri downloadUri, string targetPath, CancellationToken cancellationToken)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(targetPath);
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, downloadUri);
|
||||
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException(failure);
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var file = new FileStream(targetPath, FileMode.Create, FileAccess.Write, FileShare.None, 81920, useAsync: true);
|
||||
await stream.CopyToAsync(file, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task WriteOfflineKitMetadataAsync(
|
||||
string metadataPath,
|
||||
OfflineKitBundleDescriptor descriptor,
|
||||
string bundlePath,
|
||||
string manifestPath,
|
||||
string? bundleSignaturePath,
|
||||
string? manifestSignaturePath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var document = new OfflineKitMetadataDocument
|
||||
{
|
||||
BundleId = descriptor.BundleId,
|
||||
BundleName = descriptor.BundleName,
|
||||
BundleSha256 = descriptor.BundleSha256,
|
||||
BundleSize = descriptor.BundleSize,
|
||||
BundlePath = Path.GetFullPath(bundlePath),
|
||||
CapturedAt = descriptor.CapturedAt,
|
||||
DownloadedAt = DateTimeOffset.UtcNow,
|
||||
Channel = descriptor.Channel,
|
||||
Kind = descriptor.Kind,
|
||||
IsDelta = descriptor.IsDelta,
|
||||
BaseBundleId = descriptor.BaseBundleId,
|
||||
ManifestName = descriptor.ManifestName,
|
||||
ManifestSha256 = descriptor.ManifestSha256,
|
||||
ManifestSize = descriptor.ManifestSize,
|
||||
ManifestPath = Path.GetFullPath(manifestPath),
|
||||
BundleSignaturePath = bundleSignaturePath is null ? null : Path.GetFullPath(bundleSignaturePath),
|
||||
ManifestSignaturePath = manifestSignaturePath is null ? null : Path.GetFullPath(manifestSignaturePath)
|
||||
};
|
||||
|
||||
var options = new JsonSerializerOptions(SerializerOptions)
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
var payload = JsonSerializer.Serialize(document, options);
|
||||
await File.WriteAllTextAsync(metadataPath, payload, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<OfflineKitComponentStatus> MapOfflineComponents(List<OfflineKitComponentStatusTransport>? transports)
|
||||
{
|
||||
if (transports is null || transports.Count == 0)
|
||||
{
|
||||
return Array.Empty<OfflineKitComponentStatus>();
|
||||
}
|
||||
|
||||
var list = new List<OfflineKitComponentStatus>();
|
||||
foreach (var transport in transports)
|
||||
{
|
||||
if (transport is null || string.IsNullOrWhiteSpace(transport.Name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
list.Add(new OfflineKitComponentStatus(
|
||||
transport.Name.Trim(),
|
||||
NormalizeOptionalString(transport.Version),
|
||||
NormalizeSha(transport.Digest),
|
||||
transport.CapturedAt?.ToUniversalTime(),
|
||||
transport.SizeBytes));
|
||||
}
|
||||
|
||||
return list.Count == 0 ? Array.Empty<OfflineKitComponentStatus>() : list;
|
||||
}
|
||||
|
||||
private static string? NormalizeSha(string? digest)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(digest))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var value = digest.Trim();
|
||||
if (value.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = value.Substring("sha256:".Length);
|
||||
}
|
||||
|
||||
return value.ToLowerInvariant();
|
||||
}
|
||||
|
||||
private sealed class OfflineKitImportMetadataPayload
|
||||
{
|
||||
public string? BundleId { get; set; }
|
||||
|
||||
public string BundleSha256 { get; set; } = string.Empty;
|
||||
|
||||
public long BundleSize { get; set; }
|
||||
|
||||
public DateTimeOffset? CapturedAt { get; set; }
|
||||
|
||||
public string? Channel { get; set; }
|
||||
|
||||
public string? Kind { get; set; }
|
||||
|
||||
public bool? IsDelta { get; set; }
|
||||
|
||||
public string? BaseBundleId { get; set; }
|
||||
|
||||
public string? ManifestSha256 { get; set; }
|
||||
|
||||
public long? ManifestSize { get; set; }
|
||||
}
|
||||
|
||||
private static List<string> NormalizeImages(IReadOnlyList<string> images)
|
||||
{
|
||||
var normalized = new List<string>();
|
||||
if (images is null)
|
||||
|
||||
@@ -22,4 +22,10 @@ internal interface IBackendOperationsClient
|
||||
Task<IReadOnlyList<ExcititorProviderSummary>> GetExcititorProvidersAsync(bool includeDisabled, CancellationToken cancellationToken);
|
||||
|
||||
Task<RuntimePolicyEvaluationResult> EvaluateRuntimePolicyAsync(RuntimePolicyEvaluationRequest request, CancellationToken cancellationToken);
|
||||
|
||||
Task<OfflineKitDownloadResult> DownloadOfflineKitAsync(string? bundleId, string destinationDirectory, bool overwrite, bool resume, CancellationToken cancellationToken);
|
||||
|
||||
Task<OfflineKitImportResult> ImportOfflineKitAsync(OfflineKitImportRequest request, CancellationToken cancellationToken);
|
||||
|
||||
Task<OfflineKitStatus> GetOfflineKitStatusAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
111
src/StellaOps.Cli/Services/Models/OfflineKitModels.cs
Normal file
111
src/StellaOps.Cli/Services/Models/OfflineKitModels.cs
Normal file
@@ -0,0 +1,111 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed record OfflineKitBundleDescriptor(
|
||||
string BundleId,
|
||||
string BundleName,
|
||||
string BundleSha256,
|
||||
long BundleSize,
|
||||
Uri BundleDownloadUri,
|
||||
string ManifestName,
|
||||
string ManifestSha256,
|
||||
Uri ManifestDownloadUri,
|
||||
DateTimeOffset CapturedAt,
|
||||
string? Channel,
|
||||
string? Kind,
|
||||
bool IsDelta,
|
||||
string? BaseBundleId,
|
||||
string? BundleSignatureName,
|
||||
Uri? BundleSignatureDownloadUri,
|
||||
string? ManifestSignatureName,
|
||||
Uri? ManifestSignatureDownloadUri,
|
||||
long? ManifestSize);
|
||||
|
||||
internal sealed record OfflineKitDownloadResult(
|
||||
OfflineKitBundleDescriptor Descriptor,
|
||||
string BundlePath,
|
||||
string ManifestPath,
|
||||
string? BundleSignaturePath,
|
||||
string? ManifestSignaturePath,
|
||||
string MetadataPath,
|
||||
bool FromCache);
|
||||
|
||||
internal sealed record OfflineKitImportRequest(
|
||||
string BundlePath,
|
||||
string? ManifestPath,
|
||||
string? BundleSignaturePath,
|
||||
string? ManifestSignaturePath,
|
||||
string? BundleId,
|
||||
string? BundleSha256,
|
||||
long? BundleSize,
|
||||
DateTimeOffset? CapturedAt,
|
||||
string? Channel,
|
||||
string? Kind,
|
||||
bool? IsDelta,
|
||||
string? BaseBundleId,
|
||||
string? ManifestSha256,
|
||||
long? ManifestSize);
|
||||
|
||||
internal sealed record OfflineKitImportResult(
|
||||
string? ImportId,
|
||||
string? Status,
|
||||
DateTimeOffset SubmittedAt,
|
||||
string? Message);
|
||||
|
||||
internal sealed record OfflineKitStatus(
|
||||
string? BundleId,
|
||||
string? Channel,
|
||||
string? Kind,
|
||||
bool IsDelta,
|
||||
string? BaseBundleId,
|
||||
DateTimeOffset? CapturedAt,
|
||||
DateTimeOffset? ImportedAt,
|
||||
string? BundleSha256,
|
||||
long? BundleSize,
|
||||
IReadOnlyList<OfflineKitComponentStatus> Components);
|
||||
|
||||
internal sealed record OfflineKitComponentStatus(
|
||||
string Name,
|
||||
string? Version,
|
||||
string? Digest,
|
||||
DateTimeOffset? CapturedAt,
|
||||
long? SizeBytes);
|
||||
|
||||
internal sealed record OfflineKitMetadataDocument
|
||||
{
|
||||
public string? BundleId { get; init; }
|
||||
|
||||
public string BundleName { get; init; } = string.Empty;
|
||||
|
||||
public string BundleSha256 { get; init; } = string.Empty;
|
||||
|
||||
public long BundleSize { get; init; }
|
||||
|
||||
public string BundlePath { get; init; } = string.Empty;
|
||||
|
||||
public DateTimeOffset CapturedAt { get; init; }
|
||||
|
||||
public DateTimeOffset DownloadedAt { get; init; }
|
||||
|
||||
public string? Channel { get; init; }
|
||||
|
||||
public string? Kind { get; init; }
|
||||
|
||||
public bool IsDelta { get; init; }
|
||||
|
||||
public string? BaseBundleId { get; init; }
|
||||
|
||||
public string ManifestName { get; init; } = string.Empty;
|
||||
|
||||
public string ManifestSha256 { get; init; } = string.Empty;
|
||||
|
||||
public long? ManifestSize { get; init; }
|
||||
|
||||
public string ManifestPath { get; init; } = string.Empty;
|
||||
|
||||
public string? BundleSignaturePath { get; init; }
|
||||
|
||||
public string? ManifestSignaturePath { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
internal sealed class OfflineKitBundleDescriptorTransport
|
||||
{
|
||||
public string? BundleId { get; set; }
|
||||
|
||||
public string? BundleName { get; set; }
|
||||
|
||||
public string? BundleSha256 { get; set; }
|
||||
|
||||
public long BundleSize { get; set; }
|
||||
|
||||
public string? BundleUrl { get; set; }
|
||||
|
||||
public string? BundlePath { get; set; }
|
||||
|
||||
public string? BundleSignatureName { get; set; }
|
||||
|
||||
public string? BundleSignatureUrl { get; set; }
|
||||
|
||||
public string? BundleSignaturePath { get; set; }
|
||||
|
||||
public string? ManifestName { get; set; }
|
||||
|
||||
public string? ManifestSha256 { get; set; }
|
||||
|
||||
public long? ManifestSize { get; set; }
|
||||
|
||||
public string? ManifestUrl { get; set; }
|
||||
|
||||
public string? ManifestPath { get; set; }
|
||||
|
||||
public string? ManifestSignatureName { get; set; }
|
||||
|
||||
public string? ManifestSignatureUrl { get; set; }
|
||||
|
||||
public string? ManifestSignaturePath { get; set; }
|
||||
|
||||
public DateTimeOffset? CapturedAt { get; set; }
|
||||
|
||||
public string? Channel { get; set; }
|
||||
|
||||
public string? Kind { get; set; }
|
||||
|
||||
public bool? IsDelta { get; set; }
|
||||
|
||||
public string? BaseBundleId { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class OfflineKitStatusBundleTransport
|
||||
{
|
||||
public string? BundleId { get; set; }
|
||||
|
||||
public string? Channel { get; set; }
|
||||
|
||||
public string? Kind { get; set; }
|
||||
|
||||
public bool? IsDelta { get; set; }
|
||||
|
||||
public string? BaseBundleId { get; set; }
|
||||
|
||||
public string? BundleSha256 { get; set; }
|
||||
|
||||
public long? BundleSize { get; set; }
|
||||
|
||||
public DateTimeOffset? CapturedAt { get; set; }
|
||||
|
||||
public DateTimeOffset? ImportedAt { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class OfflineKitStatusTransport
|
||||
{
|
||||
public OfflineKitStatusBundleTransport? Current { get; set; }
|
||||
|
||||
public List<OfflineKitComponentStatusTransport>? Components { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class OfflineKitComponentStatusTransport
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
|
||||
public string? Version { get; set; }
|
||||
|
||||
public string? Digest { get; set; }
|
||||
|
||||
public DateTimeOffset? CapturedAt { get; set; }
|
||||
|
||||
public long? SizeBytes { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class OfflineKitImportResponseTransport
|
||||
{
|
||||
public string? ImportId { get; set; }
|
||||
|
||||
public string? Status { get; set; }
|
||||
|
||||
public DateTimeOffset? SubmittedAt { get; set; }
|
||||
|
||||
public string? Message { get; set; }
|
||||
}
|
||||
Reference in New Issue
Block a user