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:
master
2025-10-21 09:37:07 +03:00
parent d6cb41dd51
commit 48f3071e2a
298 changed files with 20490 additions and 5751 deletions

View File

@@ -27,6 +27,7 @@ internal static class CommandFactory
root.Add(BuildExcititorCommand(services, verboseOption, cancellationToken));
root.Add(BuildRuntimeCommand(services, verboseOption, cancellationToken));
root.Add(BuildAuthCommand(services, options, verboseOption, cancellationToken));
root.Add(BuildOfflineCommand(services, verboseOption, cancellationToken));
root.Add(BuildConfigCommand(options));
return root;
@@ -606,11 +607,102 @@ internal static class CommandFactory
return auth;
}
private static Command BuildConfigCommand(StellaOpsCliOptions options)
{
var config = new Command("config", "Inspect CLI configuration state.");
var show = new Command("show", "Display resolved configuration values.");
private static Command BuildOfflineCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
{
var offline = new Command("offline", "Offline kit workflows and utilities.");
var kit = new Command("kit", "Manage offline kit bundles.");
var pull = new Command("pull", "Download the latest offline kit bundle.");
var bundleIdOption = new Option<string?>("--bundle-id")
{
Description = "Optional bundle identifier. Defaults to the latest available."
};
var destinationOption = new Option<string?>("--destination")
{
Description = "Directory to store downloaded bundles (defaults to the configured offline kits directory)."
};
var overwriteOption = new Option<bool>("--overwrite")
{
Description = "Overwrite existing files even if checksums match."
};
var noResumeOption = new Option<bool>("--no-resume")
{
Description = "Disable resuming partial downloads."
};
pull.Add(bundleIdOption);
pull.Add(destinationOption);
pull.Add(overwriteOption);
pull.Add(noResumeOption);
pull.SetAction((parseResult, _) =>
{
var bundleId = parseResult.GetValue(bundleIdOption);
var destination = parseResult.GetValue(destinationOption);
var overwrite = parseResult.GetValue(overwriteOption);
var resume = !parseResult.GetValue(noResumeOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleOfflineKitPullAsync(services, bundleId, destination, overwrite, resume, verbose, cancellationToken);
});
var import = new Command("import", "Upload an offline kit bundle to the backend.");
var bundleArgument = new Argument<string>("bundle")
{
Description = "Path to the offline kit tarball (.tgz)."
};
var manifestOption = new Option<string?>("--manifest")
{
Description = "Offline manifest JSON path (defaults to metadata or sibling file)."
};
var bundleSignatureOption = new Option<string?>("--bundle-signature")
{
Description = "Detached signature for the offline bundle (e.g. .sig)."
};
var manifestSignatureOption = new Option<string?>("--manifest-signature")
{
Description = "Detached signature for the offline manifest (e.g. .jws)."
};
import.Add(bundleArgument);
import.Add(manifestOption);
import.Add(bundleSignatureOption);
import.Add(manifestSignatureOption);
import.SetAction((parseResult, _) =>
{
var bundlePath = parseResult.GetValue(bundleArgument) ?? string.Empty;
var manifest = parseResult.GetValue(manifestOption);
var bundleSignature = parseResult.GetValue(bundleSignatureOption);
var manifestSignature = parseResult.GetValue(manifestSignatureOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleOfflineKitImportAsync(services, bundlePath, manifest, bundleSignature, manifestSignature, verbose, cancellationToken);
});
var status = new Command("status", "Display offline kit installation status.");
var jsonOption = new Option<bool>("--json")
{
Description = "Emit status as JSON."
};
status.Add(jsonOption);
status.SetAction((parseResult, _) =>
{
var asJson = parseResult.GetValue(jsonOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleOfflineKitStatusAsync(services, asJson, verbose, cancellationToken);
});
kit.Add(pull);
kit.Add(import);
kit.Add(status);
offline.Add(kit);
return offline;
}
private static Command BuildConfigCommand(StellaOpsCliOptions options)
{
var config = new Command("config", "Inspect CLI configuration state.");
var show = new Command("show", "Display resolved configuration values.");
show.SetAction((_, _) =>
{
var authority = options.Authority ?? new StellaOpsCliAuthorityOptions();

View File

@@ -1448,17 +1448,415 @@ internal static class CommandHandlers
{
logger.LogError(ex, "Failed to verify revocation bundle.");
Environment.ExitCode = 1;
}
finally
{
loggerFactory.Dispose();
}
}
private static bool TryParseDetachedJws(string value, out string encodedHeader, out string encodedSignature)
{
encodedHeader = string.Empty;
encodedSignature = string.Empty;
}
finally
{
loggerFactory.Dispose();
}
}
public static async Task HandleOfflineKitPullAsync(
IServiceProvider services,
string? bundleId,
string? destinationDirectory,
bool overwrite,
bool resume,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
var options = scope.ServiceProvider.GetRequiredService<StellaOpsCliOptions>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("offline-kit-pull");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.offline.kit.pull", ActivityKind.Client);
activity?.SetTag("stellaops.cli.bundle_id", string.IsNullOrWhiteSpace(bundleId) ? "latest" : bundleId);
using var duration = CliMetrics.MeasureCommandDuration("offline kit pull");
try
{
var targetDirectory = string.IsNullOrWhiteSpace(destinationDirectory)
? options.Offline?.KitsDirectory ?? Path.Combine(Environment.CurrentDirectory, "offline-kits")
: destinationDirectory;
targetDirectory = Path.GetFullPath(targetDirectory);
Directory.CreateDirectory(targetDirectory);
var result = await client.DownloadOfflineKitAsync(bundleId, targetDirectory, overwrite, resume, cancellationToken).ConfigureAwait(false);
logger.LogInformation(
"Bundle {BundleId} stored at {Path} (captured {Captured:u}, sha256:{Digest}).",
result.Descriptor.BundleId,
result.BundlePath,
result.Descriptor.CapturedAt,
result.Descriptor.BundleSha256);
logger.LogInformation("Manifest saved to {Manifest}.", result.ManifestPath);
if (!string.IsNullOrWhiteSpace(result.MetadataPath))
{
logger.LogDebug("Metadata recorded at {Metadata}.", result.MetadataPath);
}
if (result.BundleSignaturePath is not null)
{
logger.LogInformation("Bundle signature saved to {Signature}.", result.BundleSignaturePath);
}
if (result.ManifestSignaturePath is not null)
{
logger.LogInformation("Manifest signature saved to {Signature}.", result.ManifestSignaturePath);
}
CliMetrics.RecordOfflineKitDownload(result.Descriptor.Kind ?? "unknown", result.FromCache);
activity?.SetTag("stellaops.cli.bundle_cache", result.FromCache);
Environment.ExitCode = 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to download offline kit bundle.");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
public static async Task HandleOfflineKitImportAsync(
IServiceProvider services,
string bundlePath,
string? manifestPath,
string? bundleSignaturePath,
string? manifestSignaturePath,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
var options = scope.ServiceProvider.GetRequiredService<StellaOpsCliOptions>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("offline-kit-import");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.offline.kit.import", ActivityKind.Client);
using var duration = CliMetrics.MeasureCommandDuration("offline kit import");
try
{
if (string.IsNullOrWhiteSpace(bundlePath))
{
logger.LogError("Bundle path is required.");
Environment.ExitCode = 1;
return;
}
bundlePath = Path.GetFullPath(bundlePath);
if (!File.Exists(bundlePath))
{
logger.LogError("Bundle file {Path} not found.", bundlePath);
Environment.ExitCode = 1;
return;
}
var metadata = await LoadOfflineKitMetadataAsync(bundlePath, cancellationToken).ConfigureAwait(false);
if (metadata is not null)
{
manifestPath ??= metadata.ManifestPath;
bundleSignaturePath ??= metadata.BundleSignaturePath;
manifestSignaturePath ??= metadata.ManifestSignaturePath;
}
manifestPath = NormalizeFilePath(manifestPath);
bundleSignaturePath = NormalizeFilePath(bundleSignaturePath);
manifestSignaturePath = NormalizeFilePath(manifestSignaturePath);
if (manifestPath is null)
{
manifestPath = TryInferManifestPath(bundlePath);
if (manifestPath is not null)
{
logger.LogDebug("Using inferred manifest path {Path}.", manifestPath);
}
}
if (manifestPath is not null && !File.Exists(manifestPath))
{
logger.LogError("Manifest file {Path} not found.", manifestPath);
Environment.ExitCode = 1;
return;
}
if (bundleSignaturePath is not null && !File.Exists(bundleSignaturePath))
{
logger.LogWarning("Bundle signature {Path} not found; skipping.", bundleSignaturePath);
bundleSignaturePath = null;
}
if (manifestSignaturePath is not null && !File.Exists(manifestSignaturePath))
{
logger.LogWarning("Manifest signature {Path} not found; skipping.", manifestSignaturePath);
manifestSignaturePath = null;
}
if (metadata is not null)
{
var computedBundleDigest = await ComputeSha256Async(bundlePath, cancellationToken).ConfigureAwait(false);
if (!DigestsEqual(computedBundleDigest, metadata.BundleSha256))
{
logger.LogError("Bundle digest mismatch. Expected sha256:{Expected} but computed sha256:{Actual}.", metadata.BundleSha256, computedBundleDigest);
Environment.ExitCode = 1;
return;
}
if (manifestPath is not null)
{
var computedManifestDigest = await ComputeSha256Async(manifestPath, cancellationToken).ConfigureAwait(false);
if (!DigestsEqual(computedManifestDigest, metadata.ManifestSha256))
{
logger.LogError("Manifest digest mismatch. Expected sha256:{Expected} but computed sha256:{Actual}.", metadata.ManifestSha256, computedManifestDigest);
Environment.ExitCode = 1;
return;
}
}
}
var request = new OfflineKitImportRequest(
bundlePath,
manifestPath,
bundleSignaturePath,
manifestSignaturePath,
metadata?.BundleId,
metadata?.BundleSha256,
metadata?.BundleSize,
metadata?.CapturedAt,
metadata?.Channel,
metadata?.Kind,
metadata?.IsDelta,
metadata?.BaseBundleId,
metadata?.ManifestSha256,
metadata?.ManifestSize);
var result = await client.ImportOfflineKitAsync(request, cancellationToken).ConfigureAwait(false);
CliMetrics.RecordOfflineKitImport(result.Status);
logger.LogInformation(
"Import {ImportId} submitted at {Submitted:u} with status {Status}.",
string.IsNullOrWhiteSpace(result.ImportId) ? "<pending>" : result.ImportId,
result.SubmittedAt,
string.IsNullOrWhiteSpace(result.Status) ? "queued" : result.Status);
if (!string.IsNullOrWhiteSpace(result.Message))
{
logger.LogInformation(result.Message);
}
Environment.ExitCode = 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Offline kit import failed.");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
public static async Task HandleOfflineKitStatusAsync(
IServiceProvider services,
bool asJson,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("offline-kit-status");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.offline.kit.status", ActivityKind.Client);
using var duration = CliMetrics.MeasureCommandDuration("offline kit status");
try
{
var status = await client.GetOfflineKitStatusAsync(cancellationToken).ConfigureAwait(false);
if (asJson)
{
var payload = new
{
bundleId = status.BundleId,
channel = status.Channel,
kind = status.Kind,
isDelta = status.IsDelta,
baseBundleId = status.BaseBundleId,
capturedAt = status.CapturedAt,
importedAt = status.ImportedAt,
sha256 = status.BundleSha256,
sizeBytes = status.BundleSize,
components = status.Components.Select(component => new
{
component.Name,
component.Version,
component.Digest,
component.CapturedAt,
component.SizeBytes
})
};
var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true });
Console.WriteLine(json);
}
else
{
if (string.IsNullOrWhiteSpace(status.BundleId))
{
logger.LogInformation("No offline kit bundle has been imported yet.");
}
else
{
logger.LogInformation(
"Current bundle {BundleId} ({Kind}) captured {Captured:u}, imported {Imported:u}, sha256:{Digest}, size {Size}.",
status.BundleId,
status.Kind ?? "unknown",
status.CapturedAt ?? default,
status.ImportedAt ?? default,
status.BundleSha256 ?? "<n/a>",
status.BundleSize.HasValue ? status.BundleSize.Value.ToString("N0", CultureInfo.InvariantCulture) : "<n/a>");
}
if (status.Components.Count > 0)
{
var table = new Table().AddColumns("Component", "Version", "Digest", "Captured", "Size (bytes)");
foreach (var component in status.Components)
{
table.AddRow(
component.Name,
string.IsNullOrWhiteSpace(component.Version) ? "-" : component.Version!,
string.IsNullOrWhiteSpace(component.Digest) ? "-" : $"sha256:{component.Digest}",
component.CapturedAt?.ToString("u", CultureInfo.InvariantCulture) ?? "-",
component.SizeBytes.HasValue ? component.SizeBytes.Value.ToString("N0", CultureInfo.InvariantCulture) : "-");
}
AnsiConsole.Write(table);
}
}
Environment.ExitCode = 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to read offline kit status.");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
private static async Task<OfflineKitMetadataDocument?> LoadOfflineKitMetadataAsync(string bundlePath, CancellationToken cancellationToken)
{
var metadataPath = bundlePath + ".metadata.json";
if (!File.Exists(metadataPath))
{
return null;
}
try
{
await using var stream = File.OpenRead(metadataPath);
return await JsonSerializer.DeserializeAsync<OfflineKitMetadataDocument>(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
}
catch
{
return null;
}
}
private static string? NormalizeFilePath(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
return null;
}
return Path.GetFullPath(path);
}
private static string? TryInferManifestPath(string bundlePath)
{
var directory = Path.GetDirectoryName(bundlePath);
if (string.IsNullOrWhiteSpace(directory))
{
return null;
}
var baseName = Path.GetFileName(bundlePath);
if (string.IsNullOrWhiteSpace(baseName))
{
return null;
}
baseName = Path.GetFileNameWithoutExtension(baseName);
if (baseName.EndsWith(".tar", StringComparison.OrdinalIgnoreCase))
{
baseName = Path.GetFileNameWithoutExtension(baseName);
}
var candidates = new[]
{
Path.Combine(directory, $"offline-manifest-{baseName}.json"),
Path.Combine(directory, "offline-manifest.json")
};
foreach (var candidate in candidates)
{
if (File.Exists(candidate))
{
return Path.GetFullPath(candidate);
}
}
return Directory.EnumerateFiles(directory, "offline-manifest*.json").FirstOrDefault();
}
private static bool DigestsEqual(string computed, string? expected)
{
if (string.IsNullOrWhiteSpace(expected))
{
return true;
}
return string.Equals(NormalizeDigest(computed), NormalizeDigest(expected), StringComparison.OrdinalIgnoreCase);
}
private static string NormalizeDigest(string digest)
{
var value = digest.Trim();
if (value.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
{
value = value.Substring("sha256:".Length);
}
return value.ToLowerInvariant();
}
private static async Task<string> ComputeSha256Async(string path, CancellationToken cancellationToken)
{
await using var stream = File.OpenRead(path);
var hash = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static bool TryParseDetachedJws(string value, out string encodedHeader, out string encodedSignature)
{
encodedHeader = string.Empty;
encodedSignature = string.Empty;
if (string.IsNullOrWhiteSpace(value))
{

View File

@@ -200,6 +200,40 @@ public static class CliBootstrapper
{
authority.TokenCacheDirectory = Path.GetFullPath(authority.TokenCacheDirectory);
}
cliOptions.Offline ??= new StellaOpsCliOfflineOptions();
var offline = cliOptions.Offline;
var kitsDirectory = ResolveWithFallback(
string.Empty,
configuration,
"STELLAOPS_OFFLINE_KITS_DIRECTORY",
"STELLAOPS_OFFLINE_KITS_DIR",
"StellaOps:Offline:KitsDirectory",
"StellaOps:Offline:KitDirectory",
"Offline:KitsDirectory",
"Offline:KitDirectory");
if (string.IsNullOrWhiteSpace(kitsDirectory))
{
kitsDirectory = offline.KitsDirectory ?? "offline-kits";
}
offline.KitsDirectory = Path.GetFullPath(kitsDirectory);
if (!Directory.Exists(offline.KitsDirectory))
{
Directory.CreateDirectory(offline.KitsDirectory);
}
var mirror = ResolveWithFallback(
string.Empty,
configuration,
"STELLAOPS_OFFLINE_MIRROR_URL",
"StellaOps:Offline:KitMirror",
"Offline:KitMirror",
"Offline:MirrorUrl");
offline.MirrorUrl = string.IsNullOrWhiteSpace(mirror) ? null : mirror.Trim();
};
});

View File

@@ -9,7 +9,7 @@ public sealed class StellaOpsCliOptions
public string ApiKey { get; set; } = string.Empty;
public string BackendUrl { get; set; } = string.Empty;
public string ScannerCacheDirectory { get; set; } = "scanners";
public string ResultsDirectory { get; set; } = "results";
@@ -23,6 +23,8 @@ public sealed class StellaOpsCliOptions
public int ScanUploadAttempts { get; set; } = 3;
public StellaOpsCliAuthorityOptions Authority { get; set; } = new();
public StellaOpsCliOfflineOptions Offline { get; set; } = new();
}
public sealed class StellaOpsCliAuthorityOptions
@@ -54,3 +56,10 @@ public sealed class StellaOpsCliAuthorityResilienceOptions
public TimeSpan? OfflineCacheTolerance { get; set; }
}
public sealed class StellaOpsCliOfflineOptions
{
public string KitsDirectory { get; set; } = "offline-kits";
public string? MirrorUrl { get; set; }
}

View File

@@ -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)

View File

@@ -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);
}

View 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; }
}

View File

@@ -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; }
}

View File

@@ -18,7 +18,7 @@ If you are working on this file you need to read docs/ARCHITECTURE_EXCITITOR.md
|EXCITITOR-CLI-01-002 Export download & attestation UX|DevEx/CLI|EXCITITOR-CLI-01-001, EXCITITOR-EXPORT-01-001|DONE (2025-10-19) CLI export prints digest/size/Rekor metadata, `--output` downloads with SHA-256 verification + cache reuse, and unit coverage validated via `dotnet test src/StellaOps.Cli.Tests`.|
|EXCITITOR-CLI-01-003 CLI docs & examples for Excititor|Docs/CLI|EXCITITOR-CLI-01-001|**DOING (2025-10-19)** Update docs/09_API_CLI_REFERENCE.md and quickstart snippets to cover Excititor verbs, offline guidance, and attestation verification workflow.|
|CLI-RUNTIME-13-005 Runtime policy test verbs|DevEx/CLI|SCANNER-RUNTIME-12-302, ZASTAVA-WEBHOOK-12-102|**DONE (2025-10-19)** Added `runtime policy test` command (stdin/file support, JSON output), backend client method + typed models, verdict table output, docs/tests updated (`dotnet test src/StellaOps.Cli.Tests`).|
|CLI-OFFLINE-13-006 Offline kit workflows|DevEx/CLI|DEVOPS-OFFLINE-14-002|TODO Implement `offline kit pull/import/status` commands with integrity checks, resumable downloads, and doc updates.|
|CLI-OFFLINE-13-006 Offline kit workflows|DevEx/CLI|DEVOPS-OFFLINE-14-002|**DONE (2025-10-21)** Added `offline kit pull/import/status` commands with resumable downloads, digest/metadata validation, metrics, docs updates, and regression coverage (`dotnet test src/StellaOps.Cli.Tests`).|
|CLI-PLUGIN-13-007 Plugin packaging|DevEx/CLI|CLI-RUNTIME-13-005, CLI-OFFLINE-13-006|TODO Package non-core verbs as restart-time plug-ins (manifest + loader updates, tests ensuring no hot reload).|
|CLI-RUNTIME-13-008 Runtime policy contract sync|DevEx/CLI, Scanner WebService Guild|SCANNER-RUNTIME-12-302|**DONE (2025-10-19)** CLI runtime table/JSON now align with SCANNER-RUNTIME-12-302 (SBOM referrers, quieted provenance, confidence, verified Rekor); docs/09 updated with joint sign-off note.|
|CLI-RUNTIME-13-009 Runtime policy smoke fixture|DevEx/CLI, QA Guild|CLI-RUNTIME-13-005|**DONE (2025-10-19)** Spectre console harness + regression tests cover table and `--json` output paths for `runtime policy test`, using stubbed backend and integrated into `dotnet test` suite.|

View File

@@ -7,14 +7,16 @@ internal static class CliMetrics
{
private static readonly Meter Meter = new("StellaOps.Cli", "1.0.0");
private static readonly Counter<long> ScannerDownloadCounter = Meter.CreateCounter<long>("stellaops.cli.scanner.download.count");
private static readonly Counter<long> ScannerInstallCounter = Meter.CreateCounter<long>("stellaops.cli.scanner.install.count");
private static readonly Counter<long> ScanRunCounter = Meter.CreateCounter<long>("stellaops.cli.scan.run.count");
private static readonly Histogram<double> CommandDurationHistogram = Meter.CreateHistogram<double>("stellaops.cli.command.duration.ms");
public static void RecordScannerDownload(string channel, bool fromCache)
=> ScannerDownloadCounter.Add(1, new KeyValuePair<string, object?>[]
{
private static readonly Counter<long> ScannerDownloadCounter = Meter.CreateCounter<long>("stellaops.cli.scanner.download.count");
private static readonly Counter<long> ScannerInstallCounter = Meter.CreateCounter<long>("stellaops.cli.scanner.install.count");
private static readonly Counter<long> ScanRunCounter = Meter.CreateCounter<long>("stellaops.cli.scan.run.count");
private static readonly Counter<long> OfflineKitDownloadCounter = Meter.CreateCounter<long>("stellaops.cli.offline.kit.download.count");
private static readonly Counter<long> OfflineKitImportCounter = Meter.CreateCounter<long>("stellaops.cli.offline.kit.import.count");
private static readonly Histogram<double> CommandDurationHistogram = Meter.CreateHistogram<double>("stellaops.cli.command.duration.ms");
public static void RecordScannerDownload(string channel, bool fromCache)
=> ScannerDownloadCounter.Add(1, new KeyValuePair<string, object?>[]
{
new("channel", channel),
new("cache", fromCache ? "hit" : "miss")
});
@@ -23,16 +25,29 @@ internal static class CliMetrics
=> ScannerInstallCounter.Add(1, new KeyValuePair<string, object?>[] { new("channel", channel) });
public static void RecordScanRun(string runner, int exitCode)
=> ScanRunCounter.Add(1, new KeyValuePair<string, object?>[]
{
new("runner", runner),
new("exit_code", exitCode)
});
public static IDisposable MeasureCommandDuration(string command)
{
var start = DateTime.UtcNow;
return new DurationScope(command, start);
=> ScanRunCounter.Add(1, new KeyValuePair<string, object?>[]
{
new("runner", runner),
new("exit_code", exitCode)
});
public static void RecordOfflineKitDownload(string kind, bool fromCache)
=> OfflineKitDownloadCounter.Add(1, new KeyValuePair<string, object?>[]
{
new("kind", string.IsNullOrWhiteSpace(kind) ? "unknown" : kind),
new("cache", fromCache ? "hit" : "miss")
});
public static void RecordOfflineKitImport(string? status)
=> OfflineKitImportCounter.Add(1, new KeyValuePair<string, object?>[]
{
new("status", string.IsNullOrWhiteSpace(status) ? "queued" : status)
});
public static IDisposable MeasureCommandDuration(string command)
{
var start = DateTime.UtcNow;
return new DurationScope(command, start);
}
private sealed class DurationScope : IDisposable