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:
@@ -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();
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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.|
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user