Implement MongoDB-based storage for Pack Run approval, artifact, log, and state management
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Added MongoPackRunApprovalStore for managing approval states with MongoDB. - Introduced MongoPackRunArtifactUploader for uploading and storing artifacts. - Created MongoPackRunLogStore to handle logging of pack run events. - Developed MongoPackRunStateStore for persisting and retrieving pack run states. - Implemented unit tests for MongoDB stores to ensure correct functionality. - Added MongoTaskRunnerTestContext for setting up MongoDB test environment. - Enhanced PackRunStateFactory to correctly initialize state with gate reasons.
This commit is contained in:
@@ -18,7 +18,8 @@ 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;
|
||||
using StellaOps.Cli.Services.Models.AdvisoryAi;
|
||||
using StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
@@ -30,10 +31,12 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
private static readonly IReadOnlyDictionary<string, object?> EmptyMetadata =
|
||||
new ReadOnlyDictionary<string, object?>(new Dictionary<string, object?>(0, StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
private const string OperatorReasonParameterName = "operator_reason";
|
||||
private const string OperatorTicketParameterName = "operator_ticket";
|
||||
private const string BackfillReasonParameterName = "backfill_reason";
|
||||
private const string BackfillTicketParameterName = "backfill_ticket";
|
||||
private const string OperatorReasonParameterName = "operator_reason";
|
||||
private const string OperatorTicketParameterName = "operator_ticket";
|
||||
private const string BackfillReasonParameterName = "backfill_reason";
|
||||
private const string BackfillTicketParameterName = "backfill_ticket";
|
||||
private const string AdvisoryScopesHeader = "X-StellaOps-Scopes";
|
||||
private const string AdvisoryRunScope = "advisory:run";
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly StellaOpsCliOptions _options;
|
||||
@@ -885,13 +888,122 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
throw new InvalidOperationException("EntryTrace response payload was empty.");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ExcititorProviderSummary>> GetExcititorProvidersAsync(bool includeDisabled, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureBackendConfigured();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<AdvisoryPipelinePlanResponseModel> CreateAdvisoryPipelinePlanAsync(
|
||||
AdvisoryAiTaskType taskType,
|
||||
AdvisoryPipelinePlanRequestModel request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var taskSegment = taskType.ToString().ToLowerInvariant();
|
||||
var relative = $"v1/advisory-ai/pipeline/{taskSegment}";
|
||||
|
||||
var payload = new AdvisoryPipelinePlanRequestModel
|
||||
{
|
||||
TaskType = taskType,
|
||||
AdvisoryKey = string.IsNullOrWhiteSpace(request.AdvisoryKey) ? string.Empty : request.AdvisoryKey.Trim(),
|
||||
ArtifactId = string.IsNullOrWhiteSpace(request.ArtifactId) ? null : request.ArtifactId!.Trim(),
|
||||
ArtifactPurl = string.IsNullOrWhiteSpace(request.ArtifactPurl) ? null : request.ArtifactPurl!.Trim(),
|
||||
PolicyVersion = string.IsNullOrWhiteSpace(request.PolicyVersion) ? null : request.PolicyVersion!.Trim(),
|
||||
Profile = string.IsNullOrWhiteSpace(request.Profile) ? "default" : request.Profile!.Trim(),
|
||||
PreferredSections = request.PreferredSections is null
|
||||
? null
|
||||
: request.PreferredSections
|
||||
.Where(static section => !string.IsNullOrWhiteSpace(section))
|
||||
.Select(static section => section.Trim())
|
||||
.ToArray(),
|
||||
ForceRefresh = request.ForceRefresh
|
||||
};
|
||||
|
||||
using var httpRequest = CreateRequest(HttpMethod.Post, relative);
|
||||
ApplyAdvisoryAiEndpoint(httpRequest, taskType);
|
||||
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
httpRequest.Content = JsonContent.Create(payload, options: SerializerOptions);
|
||||
|
||||
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException(failure);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var plan = await response.Content.ReadFromJsonAsync<AdvisoryPipelinePlanResponseModel>(SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
if (plan is null)
|
||||
{
|
||||
throw new InvalidOperationException("Advisory AI plan response was empty.");
|
||||
}
|
||||
|
||||
return plan;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
var raw = response.Content is null
|
||||
? string.Empty
|
||||
: await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException($"Failed to parse advisory plan response. {ex.Message}", ex)
|
||||
{
|
||||
Data = { ["payload"] = raw }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AdvisoryPipelineOutputModel?> TryGetAdvisoryPipelineOutputAsync(
|
||||
string cacheKey,
|
||||
AdvisoryAiTaskType taskType,
|
||||
string profile,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(cacheKey))
|
||||
{
|
||||
throw new ArgumentException("Cache key is required.", nameof(cacheKey));
|
||||
}
|
||||
|
||||
var encodedKey = Uri.EscapeDataString(cacheKey);
|
||||
var taskSegment = Uri.EscapeDataString(taskType.ToString().ToLowerInvariant());
|
||||
var resolvedProfile = string.IsNullOrWhiteSpace(profile) ? "default" : profile.Trim();
|
||||
var relative = $"v1/advisory-ai/outputs/{encodedKey}?taskType={taskSegment}&profile={Uri.EscapeDataString(resolvedProfile)}";
|
||||
|
||||
using var request = CreateRequest(HttpMethod.Get, relative);
|
||||
ApplyAdvisoryAiEndpoint(request, taskType);
|
||||
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException(failure);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await response.Content.ReadFromJsonAsync<AdvisoryPipelineOutputModel>(SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
var raw = response.Content is null
|
||||
? string.Empty
|
||||
: await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException($"Failed to parse advisory output response. {ex.Message}", ex)
|
||||
{
|
||||
Data = { ["payload"] = raw }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ExcititorProviderSummary>> GetExcititorProvidersAsync(bool includeDisabled, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureBackendConfigured();
|
||||
|
||||
var query = includeDisabled ? "?includeDisabled=true" : string.Empty;
|
||||
using var request = CreateRequest(HttpMethod.Get, $"excititor/providers{query}");
|
||||
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
@@ -1778,7 +1890,44 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
private HttpRequestMessage CreateRequest(HttpMethod method, string relativeUri)
|
||||
private void ApplyAdvisoryAiEndpoint(HttpRequestMessage request, AdvisoryAiTaskType taskType)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
var requestUri = request.RequestUri ?? throw new InvalidOperationException("Request URI was not initialized.");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_options.AdvisoryAiUrl) &&
|
||||
Uri.TryCreate(_options.AdvisoryAiUrl, UriKind.Absolute, out var advisoryBase))
|
||||
{
|
||||
if (!requestUri.IsAbsoluteUri)
|
||||
{
|
||||
request.RequestUri = new Uri(advisoryBase, requestUri.ToString());
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(_options.AdvisoryAiUrl))
|
||||
{
|
||||
throw new InvalidOperationException($"Advisory AI URL '{_options.AdvisoryAiUrl}' is not a valid absolute URI.");
|
||||
}
|
||||
else
|
||||
{
|
||||
EnsureBackendConfigured();
|
||||
}
|
||||
|
||||
var taskScope = $"advisory:{taskType.ToString().ToLowerInvariant()}";
|
||||
var combined = $"{AdvisoryRunScope} {taskScope}";
|
||||
|
||||
if (request.Headers.Contains(AdvisoryScopesHeader))
|
||||
{
|
||||
request.Headers.Remove(AdvisoryScopesHeader);
|
||||
}
|
||||
|
||||
request.Headers.TryAddWithoutValidation(AdvisoryScopesHeader, combined);
|
||||
}
|
||||
|
||||
private HttpRequestMessage CreateRequest(HttpMethod method, string relativeUri)
|
||||
{
|
||||
if (!Uri.TryCreate(relativeUri, UriKind.RelativeOrAbsolute, out var requestUri))
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
using StellaOps.Cli.Services.Models.AdvisoryAi;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
@@ -46,4 +47,8 @@ internal interface IBackendOperationsClient
|
||||
Task<PolicyFindingExplainResult> GetPolicyFindingExplainAsync(string policyId, string findingId, string? mode, CancellationToken cancellationToken);
|
||||
|
||||
Task<EntryTraceResponseModel?> GetEntryTraceAsync(string scanId, CancellationToken cancellationToken);
|
||||
|
||||
Task<AdvisoryPipelinePlanResponseModel> CreateAdvisoryPipelinePlanAsync(AdvisoryAiTaskType taskType, AdvisoryPipelinePlanRequestModel request, CancellationToken cancellationToken);
|
||||
|
||||
Task<AdvisoryPipelineOutputModel?> TryGetAdvisoryPipelineOutputAsync(string cacheKey, AdvisoryAiTaskType taskType, string profile, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models.AdvisoryAi;
|
||||
|
||||
internal enum AdvisoryAiTaskType
|
||||
{
|
||||
Summary,
|
||||
Conflict,
|
||||
Remediation
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryPipelinePlanRequestModel
|
||||
{
|
||||
public AdvisoryAiTaskType TaskType { get; init; }
|
||||
|
||||
public string AdvisoryKey { get; init; } = string.Empty;
|
||||
|
||||
public string? ArtifactId { get; init; }
|
||||
|
||||
public string? ArtifactPurl { get; init; }
|
||||
|
||||
public string? PolicyVersion { get; init; }
|
||||
|
||||
public string Profile { get; init; } = "default";
|
||||
|
||||
public IReadOnlyList<string>? PreferredSections { get; init; }
|
||||
|
||||
public bool ForceRefresh { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryPipelinePlanResponseModel
|
||||
{
|
||||
public string CacheKey { get; init; } = string.Empty;
|
||||
|
||||
public string TaskType { get; init; } = string.Empty;
|
||||
|
||||
public string PromptTemplate { get; init; } = string.Empty;
|
||||
|
||||
public AdvisoryTaskBudgetModel Budget { get; init; } = new();
|
||||
|
||||
public IReadOnlyList<PipelineChunkSummaryModel> Chunks { get; init; } = Array.Empty<PipelineChunkSummaryModel>();
|
||||
|
||||
public IReadOnlyList<PipelineVectorSummaryModel> Vectors { get; init; } = Array.Empty<PipelineVectorSummaryModel>();
|
||||
|
||||
public Dictionary<string, string> Metadata { get; init; } = new(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryTaskBudgetModel
|
||||
{
|
||||
public int PromptTokens { get; init; }
|
||||
|
||||
public int CompletionTokens { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class PipelineChunkSummaryModel
|
||||
{
|
||||
public string DocumentId { get; init; } = string.Empty;
|
||||
|
||||
public string ChunkId { get; init; } = string.Empty;
|
||||
|
||||
public string Section { get; init; } = string.Empty;
|
||||
|
||||
public string? DisplaySection { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class PipelineVectorSummaryModel
|
||||
{
|
||||
public string Query { get; init; } = string.Empty;
|
||||
|
||||
public IReadOnlyList<PipelineVectorMatchSummaryModel> Matches { get; init; } = Array.Empty<PipelineVectorMatchSummaryModel>();
|
||||
}
|
||||
|
||||
internal sealed class PipelineVectorMatchSummaryModel
|
||||
{
|
||||
public string ChunkId { get; init; } = string.Empty;
|
||||
|
||||
public double Score { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryPipelineOutputModel
|
||||
{
|
||||
public string CacheKey { get; init; } = string.Empty;
|
||||
|
||||
public string TaskType { get; init; } = string.Empty;
|
||||
|
||||
public string Profile { get; init; } = string.Empty;
|
||||
|
||||
public string Prompt { get; init; } = string.Empty;
|
||||
|
||||
public IReadOnlyList<AdvisoryOutputCitationModel> Citations { get; init; } = Array.Empty<AdvisoryOutputCitationModel>();
|
||||
|
||||
public Dictionary<string, string> Metadata { get; init; } = new(StringComparer.Ordinal);
|
||||
|
||||
public AdvisoryOutputGuardrailModel Guardrail { get; init; } = new();
|
||||
|
||||
public AdvisoryOutputProvenanceModel Provenance { get; init; } = new();
|
||||
|
||||
public DateTimeOffset GeneratedAtUtc { get; init; }
|
||||
|
||||
public bool PlanFromCache { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryOutputCitationModel
|
||||
{
|
||||
public int Index { get; init; }
|
||||
|
||||
public string DocumentId { get; init; } = string.Empty;
|
||||
|
||||
public string ChunkId { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryOutputGuardrailModel
|
||||
{
|
||||
public bool Blocked { get; init; }
|
||||
|
||||
public string SanitizedPrompt { get; init; } = string.Empty;
|
||||
|
||||
public IReadOnlyList<AdvisoryOutputGuardrailViolationModel> Violations { get; init; } = Array.Empty<AdvisoryOutputGuardrailViolationModel>();
|
||||
|
||||
public Dictionary<string, string> Metadata { get; init; } = new(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryOutputGuardrailViolationModel
|
||||
{
|
||||
public string Code { get; init; } = string.Empty;
|
||||
|
||||
public string Message { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryOutputProvenanceModel
|
||||
{
|
||||
public string InputDigest { get; init; } = string.Empty;
|
||||
|
||||
public string OutputHash { get; init; } = string.Empty;
|
||||
|
||||
public IReadOnlyList<string> Signatures { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
Reference in New Issue
Block a user