Initial commit (history squashed)
This commit is contained in:
535
src/StellaOps.Cli/Services/BackendOperationsClient.cs
Normal file
535
src/StellaOps.Cli/Services/BackendOperationsClient.cs
Normal file
@@ -0,0 +1,535 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Linq;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
using StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
private static readonly TimeSpan TokenRefreshSkew = TimeSpan.FromSeconds(30);
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly StellaOpsCliOptions _options;
|
||||
private readonly ILogger<BackendOperationsClient> _logger;
|
||||
private readonly IStellaOpsTokenClient? _tokenClient;
|
||||
private readonly object _tokenSync = new();
|
||||
private string? _cachedAccessToken;
|
||||
private DateTimeOffset _cachedAccessTokenExpiresAt = DateTimeOffset.MinValue;
|
||||
|
||||
public BackendOperationsClient(HttpClient httpClient, StellaOpsCliOptions options, ILogger<BackendOperationsClient> logger, IStellaOpsTokenClient? tokenClient = null)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_tokenClient = tokenClient;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_options.BackendUrl) && httpClient.BaseAddress is null)
|
||||
{
|
||||
if (Uri.TryCreate(_options.BackendUrl, UriKind.Absolute, out var baseUri))
|
||||
{
|
||||
httpClient.BaseAddress = baseUri;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ScannerArtifactResult> DownloadScannerAsync(string channel, string outputPath, bool overwrite, bool verbose, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureBackendConfigured();
|
||||
|
||||
channel = string.IsNullOrWhiteSpace(channel) ? "stable" : channel.Trim();
|
||||
outputPath = ResolveArtifactPath(outputPath, channel);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!);
|
||||
|
||||
if (!overwrite && File.Exists(outputPath))
|
||||
{
|
||||
var existing = new FileInfo(outputPath);
|
||||
_logger.LogInformation("Scanner artifact already cached at {Path} ({Size} bytes).", outputPath, existing.Length);
|
||||
return new ScannerArtifactResult(outputPath, existing.Length, true);
|
||||
}
|
||||
|
||||
var attempt = 0;
|
||||
var maxAttempts = Math.Max(1, _options.ScannerDownloadAttempts);
|
||||
|
||||
while (true)
|
||||
{
|
||||
attempt++;
|
||||
try
|
||||
{
|
||||
using var request = CreateRequest(HttpMethod.Get, $"api/scanner/artifacts/{channel}");
|
||||
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
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);
|
||||
}
|
||||
|
||||
return await ProcessScannerResponseAsync(response, outputPath, channel, verbose, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (attempt < maxAttempts)
|
||||
{
|
||||
var backoffSeconds = Math.Pow(2, attempt);
|
||||
_logger.LogWarning(ex, "Scanner download attempt {Attempt}/{MaxAttempts} failed. Retrying in {Delay:F0}s...", attempt, maxAttempts, backoffSeconds);
|
||||
await Task.Delay(TimeSpan.FromSeconds(backoffSeconds), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ScannerArtifactResult> ProcessScannerResponseAsync(HttpResponseMessage response, string outputPath, string channel, bool verbose, CancellationToken cancellationToken)
|
||||
{
|
||||
var tempFile = outputPath + ".tmp";
|
||||
await using (var payloadStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false))
|
||||
await using (var fileStream = File.Create(tempFile))
|
||||
{
|
||||
await payloadStream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var expectedDigest = ExtractHeaderValue(response.Headers, "X-StellaOps-Digest");
|
||||
var signatureHeader = ExtractHeaderValue(response.Headers, "X-StellaOps-Signature");
|
||||
|
||||
var digestHex = await ValidateDigestAsync(tempFile, expectedDigest, cancellationToken).ConfigureAwait(false);
|
||||
await ValidateSignatureAsync(signatureHeader, digestHex, verbose, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
var signatureNote = string.IsNullOrWhiteSpace(signatureHeader) ? "no signature" : "signature validated";
|
||||
_logger.LogDebug("Scanner digest sha256:{Digest} ({SignatureNote}).", digestHex, signatureNote);
|
||||
}
|
||||
|
||||
if (File.Exists(outputPath))
|
||||
{
|
||||
File.Delete(outputPath);
|
||||
}
|
||||
|
||||
File.Move(tempFile, outputPath);
|
||||
|
||||
PersistMetadata(outputPath, channel, digestHex, signatureHeader, response);
|
||||
|
||||
var downloaded = new FileInfo(outputPath);
|
||||
_logger.LogInformation("Scanner downloaded to {Path} ({Size} bytes).", outputPath, downloaded.Length);
|
||||
|
||||
return new ScannerArtifactResult(outputPath, downloaded.Length, false);
|
||||
}
|
||||
|
||||
public async Task UploadScanResultsAsync(string filePath, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureBackendConfigured();
|
||||
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
throw new FileNotFoundException("Scan result file not found.", filePath);
|
||||
}
|
||||
|
||||
var maxAttempts = Math.Max(1, _options.ScanUploadAttempts);
|
||||
var attempt = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
attempt++;
|
||||
try
|
||||
{
|
||||
using var content = new MultipartFormDataContent();
|
||||
await using var fileStream = File.OpenRead(filePath);
|
||||
var streamContent = new StreamContent(fileStream);
|
||||
streamContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
|
||||
content.Add(streamContent, "file", Path.GetFileName(filePath));
|
||||
|
||||
using var request = CreateRequest(HttpMethod.Post, "api/scanner/results");
|
||||
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
request.Content = content;
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogInformation("Scan results uploaded from {Path}.", filePath);
|
||||
return;
|
||||
}
|
||||
|
||||
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
if (attempt >= maxAttempts)
|
||||
{
|
||||
throw new InvalidOperationException(failure);
|
||||
}
|
||||
|
||||
var delay = GetRetryDelay(response, attempt);
|
||||
_logger.LogWarning(
|
||||
"Scan upload attempt {Attempt}/{MaxAttempts} failed ({Reason}). Retrying in {Delay:F1}s...",
|
||||
attempt,
|
||||
maxAttempts,
|
||||
failure,
|
||||
delay.TotalSeconds);
|
||||
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (attempt < maxAttempts)
|
||||
{
|
||||
var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt));
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Scan upload attempt {Attempt}/{MaxAttempts} threw an exception. Retrying in {Delay:F1}s...",
|
||||
attempt,
|
||||
maxAttempts,
|
||||
delay.TotalSeconds);
|
||||
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<JobTriggerResult> TriggerJobAsync(string jobKind, IDictionary<string, object?> parameters, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureBackendConfigured();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(jobKind))
|
||||
{
|
||||
throw new ArgumentException("Job kind must be provided.", nameof(jobKind));
|
||||
}
|
||||
|
||||
var requestBody = new JobTriggerRequest
|
||||
{
|
||||
Trigger = "cli",
|
||||
Parameters = parameters is null ? new Dictionary<string, object?>(StringComparer.Ordinal) : new Dictionary<string, object?>(parameters, StringComparer.Ordinal)
|
||||
};
|
||||
|
||||
var request = CreateRequest(HttpMethod.Post, $"jobs/{jobKind}");
|
||||
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
request.Content = JsonContent.Create(requestBody, options: SerializerOptions);
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (response.StatusCode == HttpStatusCode.Accepted)
|
||||
{
|
||||
JobRunResponse? run = null;
|
||||
if (response.Content.Headers.ContentLength is > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
run = await response.Content.ReadFromJsonAsync<JobRunResponse>(SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to deserialize job run response for job kind {Kind}.", jobKind);
|
||||
}
|
||||
}
|
||||
|
||||
var location = response.Headers.Location?.ToString();
|
||||
return new JobTriggerResult(true, "Accepted", location, run);
|
||||
}
|
||||
|
||||
var failureMessage = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
return new JobTriggerResult(false, failureMessage, null, null);
|
||||
}
|
||||
|
||||
private HttpRequestMessage CreateRequest(HttpMethod method, string relativeUri)
|
||||
{
|
||||
if (!Uri.TryCreate(relativeUri, UriKind.RelativeOrAbsolute, out var requestUri))
|
||||
{
|
||||
throw new InvalidOperationException($"Invalid request URI '{relativeUri}'.");
|
||||
}
|
||||
|
||||
if (requestUri.IsAbsoluteUri)
|
||||
{
|
||||
// Nothing to normalize.
|
||||
}
|
||||
else
|
||||
{
|
||||
requestUri = new Uri(relativeUri.TrimStart('/'), UriKind.Relative);
|
||||
}
|
||||
|
||||
return new HttpRequestMessage(method, requestUri);
|
||||
}
|
||||
|
||||
private async Task AuthorizeRequestAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var token = await ResolveAccessTokenAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string?> ResolveAccessTokenAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_options.ApiKey))
|
||||
{
|
||||
return _options.ApiKey;
|
||||
}
|
||||
|
||||
if (_tokenClient is null || string.IsNullOrWhiteSpace(_options.Authority.Url))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
lock (_tokenSync)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_cachedAccessToken) && now < _cachedAccessTokenExpiresAt - TokenRefreshSkew)
|
||||
{
|
||||
return _cachedAccessToken;
|
||||
}
|
||||
}
|
||||
|
||||
var cacheKey = AuthorityTokenUtilities.BuildCacheKey(_options);
|
||||
var cachedEntry = await _tokenClient.GetCachedTokenAsync(cacheKey, cancellationToken).ConfigureAwait(false);
|
||||
if (cachedEntry is not null && now < cachedEntry.ExpiresAtUtc - TokenRefreshSkew)
|
||||
{
|
||||
lock (_tokenSync)
|
||||
{
|
||||
_cachedAccessToken = cachedEntry.AccessToken;
|
||||
_cachedAccessTokenExpiresAt = cachedEntry.ExpiresAtUtc;
|
||||
return _cachedAccessToken;
|
||||
}
|
||||
}
|
||||
|
||||
var scope = AuthorityTokenUtilities.ResolveScope(_options);
|
||||
|
||||
StellaOpsTokenResult token;
|
||||
if (!string.IsNullOrWhiteSpace(_options.Authority.Username))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.Authority.Password))
|
||||
{
|
||||
throw new InvalidOperationException("Authority password must be configured when username is provided.");
|
||||
}
|
||||
|
||||
token = await _tokenClient.RequestPasswordTokenAsync(
|
||||
_options.Authority.Username,
|
||||
_options.Authority.Password!,
|
||||
scope,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
token = await _tokenClient.RequestClientCredentialsTokenAsync(scope, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await _tokenClient.CacheTokenAsync(cacheKey, token.ToCacheEntry(), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
lock (_tokenSync)
|
||||
{
|
||||
_cachedAccessToken = token.AccessToken;
|
||||
_cachedAccessTokenExpiresAt = token.ExpiresAtUtc;
|
||||
return _cachedAccessToken;
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureBackendConfigured()
|
||||
{
|
||||
if (_httpClient.BaseAddress is null)
|
||||
{
|
||||
throw new InvalidOperationException("Backend URL is not configured. Provide STELLAOPS_BACKEND_URL or configure appsettings.");
|
||||
}
|
||||
}
|
||||
|
||||
private string ResolveArtifactPath(string outputPath, string channel)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(outputPath))
|
||||
{
|
||||
return Path.GetFullPath(outputPath);
|
||||
}
|
||||
|
||||
var directory = string.IsNullOrWhiteSpace(_options.ScannerCacheDirectory)
|
||||
? Directory.GetCurrentDirectory()
|
||||
: Path.GetFullPath(_options.ScannerCacheDirectory);
|
||||
|
||||
Directory.CreateDirectory(directory);
|
||||
var fileName = $"stellaops-scanner-{channel}.tar.gz";
|
||||
return Path.Combine(directory, fileName);
|
||||
}
|
||||
|
||||
private async Task<string> CreateFailureMessageAsync(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||
{
|
||||
var statusCode = (int)response.StatusCode;
|
||||
var builder = new StringBuilder();
|
||||
builder.Append("Backend request failed with status ");
|
||||
builder.Append(statusCode);
|
||||
builder.Append(' ');
|
||||
builder.Append(response.ReasonPhrase ?? "Unknown");
|
||||
|
||||
if (response.Content.Headers.ContentLength is > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
var problem = await response.Content.ReadFromJsonAsync<ProblemDocument>(SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
if (problem is not null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(problem.Title))
|
||||
{
|
||||
builder.AppendLine().Append(problem.Title);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(problem.Detail))
|
||||
{
|
||||
builder.AppendLine().Append(problem.Detail);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
var raw = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
builder.AppendLine().Append(raw);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static string? ExtractHeaderValue(HttpResponseHeaders headers, string name)
|
||||
{
|
||||
if (headers.TryGetValues(name, out var values))
|
||||
{
|
||||
return values.FirstOrDefault();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<string> ValidateDigestAsync(string filePath, string? expectedDigest, CancellationToken cancellationToken)
|
||||
{
|
||||
string digestHex;
|
||||
await using (var stream = File.OpenRead(filePath))
|
||||
{
|
||||
var hash = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
|
||||
digestHex = Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(expectedDigest))
|
||||
{
|
||||
var normalized = NormalizeDigest(expectedDigest);
|
||||
if (!normalized.Equals(digestHex, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
File.Delete(filePath);
|
||||
throw new InvalidOperationException($"Scanner digest mismatch. Expected sha256:{normalized}, calculated sha256:{digestHex}.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Scanner download missing X-StellaOps-Digest header; relying on computed digest only.");
|
||||
}
|
||||
|
||||
return digestHex;
|
||||
}
|
||||
|
||||
private static string NormalizeDigest(string digest)
|
||||
{
|
||||
if (digest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return digest[7..];
|
||||
}
|
||||
|
||||
return digest;
|
||||
}
|
||||
|
||||
private async Task ValidateSignatureAsync(string? signatureHeader, string digestHex, bool verbose, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.ScannerSignaturePublicKeyPath))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(signatureHeader))
|
||||
{
|
||||
_logger.LogDebug("Signature header present but no public key configured; skipping validation.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(signatureHeader))
|
||||
{
|
||||
throw new InvalidOperationException("Scanner signature missing while a public key is configured.");
|
||||
}
|
||||
|
||||
var publicKeyPath = Path.GetFullPath(_options.ScannerSignaturePublicKeyPath);
|
||||
if (!File.Exists(publicKeyPath))
|
||||
{
|
||||
throw new FileNotFoundException("Scanner signature public key not found.", publicKeyPath);
|
||||
}
|
||||
|
||||
var signatureBytes = Convert.FromBase64String(signatureHeader);
|
||||
var digestBytes = Convert.FromHexString(digestHex);
|
||||
|
||||
var pem = await File.ReadAllTextAsync(publicKeyPath, cancellationToken).ConfigureAwait(false);
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportFromPem(pem);
|
||||
|
||||
var valid = rsa.VerifyHash(digestBytes, signatureBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
if (!valid)
|
||||
{
|
||||
throw new InvalidOperationException("Scanner signature validation failed.");
|
||||
}
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
_logger.LogDebug("Scanner signature validated using key {KeyPath}.", publicKeyPath);
|
||||
}
|
||||
}
|
||||
|
||||
private void PersistMetadata(string outputPath, string channel, string digestHex, string? signatureHeader, HttpResponseMessage response)
|
||||
{
|
||||
var metadata = new
|
||||
{
|
||||
channel,
|
||||
digest = $"sha256:{digestHex}",
|
||||
signature = signatureHeader,
|
||||
downloadedAt = DateTimeOffset.UtcNow,
|
||||
source = response.RequestMessage?.RequestUri?.ToString(),
|
||||
sizeBytes = new FileInfo(outputPath).Length,
|
||||
headers = new
|
||||
{
|
||||
etag = response.Headers.ETag?.Tag,
|
||||
lastModified = response.Content.Headers.LastModified,
|
||||
contentType = response.Content.Headers.ContentType?.ToString()
|
||||
}
|
||||
};
|
||||
|
||||
var metadataPath = outputPath + ".metadata.json";
|
||||
var json = JsonSerializer.Serialize(metadata, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true
|
||||
});
|
||||
|
||||
File.WriteAllText(metadataPath, json);
|
||||
}
|
||||
|
||||
private static TimeSpan GetRetryDelay(HttpResponseMessage response, int attempt)
|
||||
{
|
||||
if (response.Headers.TryGetValues("Retry-After", out var retryValues))
|
||||
{
|
||||
var value = retryValues.FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seconds) && seconds >= 0)
|
||||
{
|
||||
return TimeSpan.FromSeconds(Math.Min(seconds, 300));
|
||||
}
|
||||
|
||||
if (DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var when))
|
||||
{
|
||||
var delta = when - DateTimeOffset.UtcNow;
|
||||
if (delta > TimeSpan.Zero)
|
||||
{
|
||||
return delta < TimeSpan.FromMinutes(5) ? delta : TimeSpan.FromMinutes(5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var fallbackSeconds = Math.Min(60, Math.Pow(2, attempt));
|
||||
return TimeSpan.FromSeconds(fallbackSeconds);
|
||||
}
|
||||
}
|
||||
16
src/StellaOps.Cli/Services/IBackendOperationsClient.cs
Normal file
16
src/StellaOps.Cli/Services/IBackendOperationsClient.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
internal interface IBackendOperationsClient
|
||||
{
|
||||
Task<ScannerArtifactResult> DownloadScannerAsync(string channel, string outputPath, bool overwrite, bool verbose, CancellationToken cancellationToken);
|
||||
|
||||
Task UploadScanResultsAsync(string filePath, CancellationToken cancellationToken);
|
||||
|
||||
Task<JobTriggerResult> TriggerJobAsync(string jobKind, IDictionary<string, object?> parameters, CancellationToken cancellationToken);
|
||||
}
|
||||
17
src/StellaOps.Cli/Services/IScannerExecutor.cs
Normal file
17
src/StellaOps.Cli/Services/IScannerExecutor.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
internal interface IScannerExecutor
|
||||
{
|
||||
Task<ScannerExecutionResult> RunAsync(
|
||||
string runner,
|
||||
string entry,
|
||||
string targetDirectory,
|
||||
string resultsDirectory,
|
||||
IReadOnlyList<string> arguments,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
9
src/StellaOps.Cli/Services/IScannerInstaller.cs
Normal file
9
src/StellaOps.Cli/Services/IScannerInstaller.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
internal interface IScannerInstaller
|
||||
{
|
||||
Task InstallAsync(string artifactPath, bool verbose, CancellationToken cancellationToken);
|
||||
}
|
||||
9
src/StellaOps.Cli/Services/Models/JobTriggerResult.cs
Normal file
9
src/StellaOps.Cli/Services/Models/JobTriggerResult.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed record JobTriggerResult(
|
||||
bool Success,
|
||||
string Message,
|
||||
string? Location,
|
||||
JobRunResponse? Run);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed record ScannerArtifactResult(string Path, long SizeBytes, bool FromCache);
|
||||
@@ -0,0 +1,27 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
internal sealed class JobRunResponse
|
||||
{
|
||||
public Guid RunId { get; set; }
|
||||
|
||||
public string Kind { get; set; } = string.Empty;
|
||||
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
public string Trigger { get; set; } = string.Empty;
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
public DateTimeOffset? StartedAt { get; set; }
|
||||
|
||||
public DateTimeOffset? CompletedAt { get; set; }
|
||||
|
||||
public string? Error { get; set; }
|
||||
|
||||
public TimeSpan? Duration { get; set; }
|
||||
|
||||
public IReadOnlyDictionary<string, object?> Parameters { get; set; } = new Dictionary<string, object?>(StringComparer.Ordinal);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
internal sealed class JobTriggerRequest
|
||||
{
|
||||
public string Trigger { get; set; } = "cli";
|
||||
|
||||
public Dictionary<string, object?> Parameters { get; set; } = new(StringComparer.Ordinal);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
internal sealed class ProblemDocument
|
||||
{
|
||||
public string? Type { get; set; }
|
||||
|
||||
public string? Title { get; set; }
|
||||
|
||||
public string? Detail { get; set; }
|
||||
|
||||
public int? Status { get; set; }
|
||||
|
||||
public string? Instance { get; set; }
|
||||
|
||||
public Dictionary<string, object?>? Extensions { get; set; }
|
||||
}
|
||||
3
src/StellaOps.Cli/Services/ScannerExecutionResult.cs
Normal file
3
src/StellaOps.Cli/Services/ScannerExecutionResult.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
internal sealed record ScannerExecutionResult(int ExitCode, string ResultsPath, string RunMetadataPath);
|
||||
329
src/StellaOps.Cli/Services/ScannerExecutor.cs
Normal file
329
src/StellaOps.Cli/Services/ScannerExecutor.cs
Normal file
@@ -0,0 +1,329 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
internal sealed class ScannerExecutor : IScannerExecutor
|
||||
{
|
||||
private readonly ILogger<ScannerExecutor> _logger;
|
||||
|
||||
public ScannerExecutor(ILogger<ScannerExecutor> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<ScannerExecutionResult> RunAsync(
|
||||
string runner,
|
||||
string entry,
|
||||
string targetDirectory,
|
||||
string resultsDirectory,
|
||||
IReadOnlyList<string> arguments,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(targetDirectory))
|
||||
{
|
||||
throw new ArgumentException("Target directory must be provided.", nameof(targetDirectory));
|
||||
}
|
||||
|
||||
runner = string.IsNullOrWhiteSpace(runner) ? "docker" : runner.Trim().ToLowerInvariant();
|
||||
entry = entry?.Trim() ?? string.Empty;
|
||||
|
||||
var normalizedTarget = Path.GetFullPath(targetDirectory);
|
||||
if (!Directory.Exists(normalizedTarget))
|
||||
{
|
||||
throw new DirectoryNotFoundException($"Scan target directory '{normalizedTarget}' does not exist.");
|
||||
}
|
||||
|
||||
resultsDirectory = string.IsNullOrWhiteSpace(resultsDirectory)
|
||||
? Path.Combine(Directory.GetCurrentDirectory(), "scan-results")
|
||||
: Path.GetFullPath(resultsDirectory);
|
||||
|
||||
Directory.CreateDirectory(resultsDirectory);
|
||||
var executionTimestamp = DateTimeOffset.UtcNow;
|
||||
var baselineFiles = Directory.GetFiles(resultsDirectory, "*", SearchOption.AllDirectories);
|
||||
var baseline = new HashSet<string>(baselineFiles, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var startInfo = BuildProcessStartInfo(runner, entry, normalizedTarget, resultsDirectory, arguments);
|
||||
using var process = new Process { StartInfo = startInfo, EnableRaisingEvents = true };
|
||||
|
||||
var stdout = new List<string>();
|
||||
var stderr = new List<string>();
|
||||
|
||||
process.OutputDataReceived += (_, args) =>
|
||||
{
|
||||
if (args.Data is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
stdout.Add(args.Data);
|
||||
if (verbose)
|
||||
{
|
||||
_logger.LogInformation("[scan] {Line}", args.Data);
|
||||
}
|
||||
};
|
||||
|
||||
process.ErrorDataReceived += (_, args) =>
|
||||
{
|
||||
if (args.Data is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
stderr.Add(args.Data);
|
||||
_logger.LogError("[scan] {Line}", args.Data);
|
||||
};
|
||||
|
||||
_logger.LogInformation("Launching scanner via {Runner} (entry: {Entry})...", runner, entry);
|
||||
if (!process.Start())
|
||||
{
|
||||
throw new InvalidOperationException("Failed to start scanner process.");
|
||||
}
|
||||
|
||||
process.BeginOutputReadLine();
|
||||
process.BeginErrorReadLine();
|
||||
|
||||
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
|
||||
var completionTimestamp = DateTimeOffset.UtcNow;
|
||||
|
||||
if (process.ExitCode == 0)
|
||||
{
|
||||
_logger.LogInformation("Scanner completed successfully.");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Scanner exited with code {Code}.", process.ExitCode);
|
||||
}
|
||||
|
||||
var resultsPath = ResolveResultsPath(resultsDirectory, executionTimestamp, baseline);
|
||||
if (string.IsNullOrWhiteSpace(resultsPath))
|
||||
{
|
||||
resultsPath = CreatePlaceholderResult(resultsDirectory);
|
||||
}
|
||||
|
||||
var metadataPath = WriteRunMetadata(
|
||||
resultsDirectory,
|
||||
executionTimestamp,
|
||||
completionTimestamp,
|
||||
runner,
|
||||
entry,
|
||||
normalizedTarget,
|
||||
resultsPath,
|
||||
arguments,
|
||||
process.ExitCode,
|
||||
stdout,
|
||||
stderr);
|
||||
|
||||
return new ScannerExecutionResult(process.ExitCode, resultsPath, metadataPath);
|
||||
}
|
||||
|
||||
private ProcessStartInfo BuildProcessStartInfo(
|
||||
string runner,
|
||||
string entry,
|
||||
string targetDirectory,
|
||||
string resultsDirectory,
|
||||
IReadOnlyList<string> args)
|
||||
{
|
||||
return runner switch
|
||||
{
|
||||
"self" or "native" => BuildNativeStartInfo(entry, args),
|
||||
"dotnet" => BuildDotNetStartInfo(entry, args),
|
||||
"docker" => BuildDockerStartInfo(entry, targetDirectory, resultsDirectory, args),
|
||||
_ => BuildCustomRunnerStartInfo(runner, entry, args)
|
||||
};
|
||||
}
|
||||
|
||||
private static ProcessStartInfo BuildNativeStartInfo(string binaryPath, IReadOnlyList<string> args)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(binaryPath) || !File.Exists(binaryPath))
|
||||
{
|
||||
throw new FileNotFoundException("Scanner entrypoint not found.", binaryPath);
|
||||
}
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = binaryPath,
|
||||
WorkingDirectory = Directory.GetCurrentDirectory()
|
||||
};
|
||||
|
||||
foreach (var argument in args)
|
||||
{
|
||||
startInfo.ArgumentList.Add(argument);
|
||||
}
|
||||
|
||||
startInfo.RedirectStandardError = true;
|
||||
startInfo.RedirectStandardOutput = true;
|
||||
startInfo.UseShellExecute = false;
|
||||
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
private static ProcessStartInfo BuildDotNetStartInfo(string binaryPath, IReadOnlyList<string> args)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "dotnet",
|
||||
WorkingDirectory = Directory.GetCurrentDirectory()
|
||||
};
|
||||
|
||||
startInfo.ArgumentList.Add(binaryPath);
|
||||
foreach (var argument in args)
|
||||
{
|
||||
startInfo.ArgumentList.Add(argument);
|
||||
}
|
||||
|
||||
startInfo.RedirectStandardError = true;
|
||||
startInfo.RedirectStandardOutput = true;
|
||||
startInfo.UseShellExecute = false;
|
||||
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
private static ProcessStartInfo BuildDockerStartInfo(string image, string targetDirectory, string resultsDirectory, IReadOnlyList<string> args)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(image))
|
||||
{
|
||||
throw new ArgumentException("Docker image must be provided when runner is 'docker'.", nameof(image));
|
||||
}
|
||||
|
||||
var cwd = Directory.GetCurrentDirectory();
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "docker",
|
||||
WorkingDirectory = cwd
|
||||
};
|
||||
|
||||
startInfo.ArgumentList.Add("run");
|
||||
startInfo.ArgumentList.Add("--rm");
|
||||
startInfo.ArgumentList.Add("-v");
|
||||
startInfo.ArgumentList.Add($"{cwd}:{cwd}");
|
||||
startInfo.ArgumentList.Add("-v");
|
||||
startInfo.ArgumentList.Add($"{targetDirectory}:/scan-target:ro");
|
||||
startInfo.ArgumentList.Add("-v");
|
||||
startInfo.ArgumentList.Add($"{resultsDirectory}:/scan-results");
|
||||
startInfo.ArgumentList.Add("-w");
|
||||
startInfo.ArgumentList.Add(cwd);
|
||||
startInfo.ArgumentList.Add(image);
|
||||
startInfo.ArgumentList.Add("--target");
|
||||
startInfo.ArgumentList.Add("/scan-target");
|
||||
startInfo.ArgumentList.Add("--output");
|
||||
startInfo.ArgumentList.Add("/scan-results/scan.json");
|
||||
|
||||
foreach (var argument in args)
|
||||
{
|
||||
startInfo.ArgumentList.Add(argument);
|
||||
}
|
||||
|
||||
startInfo.RedirectStandardError = true;
|
||||
startInfo.RedirectStandardOutput = true;
|
||||
startInfo.UseShellExecute = false;
|
||||
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
private static ProcessStartInfo BuildCustomRunnerStartInfo(string runner, string entry, IReadOnlyList<string> args)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = runner,
|
||||
WorkingDirectory = Directory.GetCurrentDirectory()
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(entry))
|
||||
{
|
||||
startInfo.ArgumentList.Add(entry);
|
||||
}
|
||||
|
||||
foreach (var argument in args)
|
||||
{
|
||||
startInfo.ArgumentList.Add(argument);
|
||||
}
|
||||
|
||||
startInfo.RedirectStandardError = true;
|
||||
startInfo.RedirectStandardOutput = true;
|
||||
startInfo.UseShellExecute = false;
|
||||
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
private static string ResolveResultsPath(string resultsDirectory, DateTimeOffset startTimestamp, HashSet<string> baseline)
|
||||
{
|
||||
var candidates = Directory.GetFiles(resultsDirectory, "*", SearchOption.AllDirectories);
|
||||
string? newest = null;
|
||||
DateTimeOffset newestTimestamp = startTimestamp;
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (baseline.Contains(candidate))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var info = new FileInfo(candidate);
|
||||
if (info.LastWriteTimeUtc >= newestTimestamp)
|
||||
{
|
||||
newestTimestamp = info.LastWriteTimeUtc;
|
||||
newest = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return newest ?? string.Empty;
|
||||
}
|
||||
|
||||
private static string CreatePlaceholderResult(string resultsDirectory)
|
||||
{
|
||||
var fileName = $"scan-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}.json";
|
||||
var path = Path.Combine(resultsDirectory, fileName);
|
||||
File.WriteAllText(path, "{\"status\":\"placeholder\"}");
|
||||
return path;
|
||||
}
|
||||
|
||||
private static string WriteRunMetadata(
|
||||
string resultsDirectory,
|
||||
DateTimeOffset startedAt,
|
||||
DateTimeOffset completedAt,
|
||||
string runner,
|
||||
string entry,
|
||||
string targetDirectory,
|
||||
string resultsPath,
|
||||
IReadOnlyList<string> arguments,
|
||||
int exitCode,
|
||||
IReadOnlyList<string> stdout,
|
||||
IReadOnlyList<string> stderr)
|
||||
{
|
||||
var duration = completedAt - startedAt;
|
||||
var payload = new
|
||||
{
|
||||
runner,
|
||||
entry,
|
||||
targetDirectory,
|
||||
resultsPath,
|
||||
arguments,
|
||||
exitCode,
|
||||
startedAt = startedAt,
|
||||
completedAt = completedAt,
|
||||
durationSeconds = Math.Round(duration.TotalSeconds, 3, MidpointRounding.AwayFromZero),
|
||||
stdout,
|
||||
stderr
|
||||
};
|
||||
|
||||
var fileName = $"scan-run-{startedAt:yyyyMMddHHmmssfff}.json";
|
||||
var path = Path.Combine(resultsDirectory, fileName);
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
var json = JsonSerializer.Serialize(payload, options);
|
||||
File.WriteAllText(path, json);
|
||||
return path;
|
||||
}
|
||||
}
|
||||
79
src/StellaOps.Cli/Services/ScannerInstaller.cs
Normal file
79
src/StellaOps.Cli/Services/ScannerInstaller.cs
Normal file
@@ -0,0 +1,79 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
internal sealed class ScannerInstaller : IScannerInstaller
|
||||
{
|
||||
private readonly ILogger<ScannerInstaller> _logger;
|
||||
|
||||
public ScannerInstaller(ILogger<ScannerInstaller> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task InstallAsync(string artifactPath, bool verbose, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(artifactPath) || !File.Exists(artifactPath))
|
||||
{
|
||||
throw new FileNotFoundException("Scanner artifact not found for installation.", artifactPath);
|
||||
}
|
||||
|
||||
// Current implementation assumes docker-based scanner bundle.
|
||||
var processInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "docker",
|
||||
ArgumentList = { "load", "-i", artifactPath },
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false
|
||||
};
|
||||
|
||||
using var process = new Process { StartInfo = processInfo, EnableRaisingEvents = true };
|
||||
|
||||
process.OutputDataReceived += (_, args) =>
|
||||
{
|
||||
if (args.Data is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
_logger.LogInformation("[install] {Line}", args.Data);
|
||||
}
|
||||
};
|
||||
|
||||
process.ErrorDataReceived += (_, args) =>
|
||||
{
|
||||
if (args.Data is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogError("[install] {Line}", args.Data);
|
||||
};
|
||||
|
||||
_logger.LogInformation("Installing scanner container from {Path}...", artifactPath);
|
||||
if (!process.Start())
|
||||
{
|
||||
throw new InvalidOperationException("Failed to start container installation process.");
|
||||
}
|
||||
|
||||
process.BeginOutputReadLine();
|
||||
process.BeginErrorReadLine();
|
||||
|
||||
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Container installation failed with exit code {process.ExitCode}.");
|
||||
}
|
||||
|
||||
_logger.LogInformation("Scanner container installed successfully.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user