wip: doctor/cli/docs/api to vector db consolidation; api hardening for descriptions, tenant, and scopes; migrations and conversions of all DALs to EF v10
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cli.Services;
|
||||
using System.CommandLine;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
@@ -21,6 +22,8 @@ namespace StellaOps.Cli.Commands.Budget;
|
||||
/// </summary>
|
||||
public static class RiskBudgetCommandGroup
|
||||
{
|
||||
private const string TenantHeaderName = "X-Tenant-Id";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true,
|
||||
@@ -37,12 +40,17 @@ public static class RiskBudgetCommandGroup
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var budgetCommand = new Command("budget", "Risk budget management for release gates");
|
||||
var tenantOption = new Option<string?>("--tenant", new[] { "-t" })
|
||||
{
|
||||
Description = "Tenant context for budget operations. Overrides profile and STELLAOPS_TENANT."
|
||||
};
|
||||
budgetCommand.Add(tenantOption);
|
||||
|
||||
budgetCommand.Add(BuildStatusCommand(services, verboseOption, cancellationToken));
|
||||
budgetCommand.Add(BuildConsumeCommand(services, verboseOption, cancellationToken));
|
||||
budgetCommand.Add(BuildCheckCommand(services, verboseOption, cancellationToken));
|
||||
budgetCommand.Add(BuildHistoryCommand(services, verboseOption, cancellationToken));
|
||||
budgetCommand.Add(BuildListCommand(services, verboseOption, cancellationToken));
|
||||
budgetCommand.Add(BuildStatusCommand(services, verboseOption, tenantOption, cancellationToken));
|
||||
budgetCommand.Add(BuildConsumeCommand(services, verboseOption, tenantOption, cancellationToken));
|
||||
budgetCommand.Add(BuildCheckCommand(services, verboseOption, tenantOption, cancellationToken));
|
||||
budgetCommand.Add(BuildHistoryCommand(services, verboseOption, tenantOption, cancellationToken));
|
||||
budgetCommand.Add(BuildListCommand(services, verboseOption, tenantOption, cancellationToken));
|
||||
|
||||
return budgetCommand;
|
||||
}
|
||||
@@ -54,6 +62,7 @@ public static class RiskBudgetCommandGroup
|
||||
private static Command BuildStatusCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
Option<string?> tenantOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var serviceOption = new Option<string>("--service", "-s")
|
||||
@@ -85,9 +94,11 @@ public static class RiskBudgetCommandGroup
|
||||
var window = parseResult.GetValue(windowOption);
|
||||
var output = parseResult.GetValue(outputOption) ?? "text";
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
var tenant = parseResult.GetValue(tenantOption);
|
||||
|
||||
return await HandleStatusAsync(
|
||||
services,
|
||||
tenant,
|
||||
serviceId,
|
||||
window,
|
||||
output,
|
||||
@@ -105,6 +116,7 @@ public static class RiskBudgetCommandGroup
|
||||
private static Command BuildConsumeCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
Option<string?> tenantOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var serviceOption = new Option<string>("--service", "-s")
|
||||
@@ -152,9 +164,11 @@ public static class RiskBudgetCommandGroup
|
||||
var releaseId = parseResult.GetValue(releaseIdOption);
|
||||
var output = parseResult.GetValue(outputOption) ?? "text";
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
var tenant = parseResult.GetValue(tenantOption);
|
||||
|
||||
return await HandleConsumeAsync(
|
||||
services,
|
||||
tenant,
|
||||
serviceId,
|
||||
points,
|
||||
reason,
|
||||
@@ -174,6 +188,7 @@ public static class RiskBudgetCommandGroup
|
||||
private static Command BuildCheckCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
Option<string?> tenantOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var serviceOption = new Option<string>("--service", "-s")
|
||||
@@ -214,9 +229,11 @@ public static class RiskBudgetCommandGroup
|
||||
var failOnExceed = parseResult.GetValue(failOnExceedOption);
|
||||
var output = parseResult.GetValue(outputOption) ?? "text";
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
var tenant = parseResult.GetValue(tenantOption);
|
||||
|
||||
return await HandleCheckAsync(
|
||||
services,
|
||||
tenant,
|
||||
serviceId,
|
||||
points,
|
||||
failOnExceed,
|
||||
@@ -235,6 +252,7 @@ public static class RiskBudgetCommandGroup
|
||||
private static Command BuildHistoryCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
Option<string?> tenantOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var serviceOption = new Option<string>("--service", "-s")
|
||||
@@ -274,9 +292,11 @@ public static class RiskBudgetCommandGroup
|
||||
var limit = parseResult.GetValue(limitOption);
|
||||
var output = parseResult.GetValue(outputOption) ?? "text";
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
var tenant = parseResult.GetValue(tenantOption);
|
||||
|
||||
return await HandleHistoryAsync(
|
||||
services,
|
||||
tenant,
|
||||
serviceId,
|
||||
window,
|
||||
limit,
|
||||
@@ -295,6 +315,7 @@ public static class RiskBudgetCommandGroup
|
||||
private static Command BuildListCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
Option<string?> tenantOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var statusOption = new Option<string?>("--status")
|
||||
@@ -333,9 +354,11 @@ public static class RiskBudgetCommandGroup
|
||||
var limit = parseResult.GetValue(limitOption);
|
||||
var output = parseResult.GetValue(outputOption) ?? "text";
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
var tenant = parseResult.GetValue(tenantOption);
|
||||
|
||||
return await HandleListAsync(
|
||||
services,
|
||||
tenant,
|
||||
status,
|
||||
tier,
|
||||
limit,
|
||||
@@ -351,6 +374,7 @@ public static class RiskBudgetCommandGroup
|
||||
|
||||
private static async Task<int> HandleStatusAsync(
|
||||
IServiceProvider services,
|
||||
string? tenant,
|
||||
string serviceId,
|
||||
string? window,
|
||||
string output,
|
||||
@@ -374,7 +398,7 @@ public static class RiskBudgetCommandGroup
|
||||
logger?.LogDebug("Getting budget status for service {ServiceId}", serviceId);
|
||||
}
|
||||
|
||||
var client = httpClientFactory.CreateClient("PolicyApi");
|
||||
var client = CreatePolicyApiClient(httpClientFactory, tenant);
|
||||
var query = $"/api/v1/policy/risk-budget/status/{Uri.EscapeDataString(serviceId)}";
|
||||
if (!string.IsNullOrEmpty(window))
|
||||
{
|
||||
@@ -412,6 +436,7 @@ public static class RiskBudgetCommandGroup
|
||||
|
||||
private static async Task<int> HandleConsumeAsync(
|
||||
IServiceProvider services,
|
||||
string? tenant,
|
||||
string serviceId,
|
||||
int points,
|
||||
string reason,
|
||||
@@ -437,7 +462,7 @@ public static class RiskBudgetCommandGroup
|
||||
logger?.LogDebug("Consuming {Points} points from service {ServiceId}", points, serviceId);
|
||||
}
|
||||
|
||||
var client = httpClientFactory.CreateClient("PolicyApi");
|
||||
var client = CreatePolicyApiClient(httpClientFactory, tenant);
|
||||
var request = new ConsumeRequest(serviceId, points, reason, releaseId);
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
@@ -475,6 +500,7 @@ public static class RiskBudgetCommandGroup
|
||||
|
||||
private static async Task<int> HandleCheckAsync(
|
||||
IServiceProvider services,
|
||||
string? tenant,
|
||||
string serviceId,
|
||||
int points,
|
||||
bool failOnExceed,
|
||||
@@ -499,7 +525,7 @@ public static class RiskBudgetCommandGroup
|
||||
logger?.LogDebug("Checking if {Points} points would exceed budget for {ServiceId}", points, serviceId);
|
||||
}
|
||||
|
||||
var client = httpClientFactory.CreateClient("PolicyApi");
|
||||
var client = CreatePolicyApiClient(httpClientFactory, tenant);
|
||||
var request = new CheckRequest(serviceId, points);
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
@@ -543,6 +569,7 @@ public static class RiskBudgetCommandGroup
|
||||
|
||||
private static async Task<int> HandleHistoryAsync(
|
||||
IServiceProvider services,
|
||||
string? tenant,
|
||||
string serviceId,
|
||||
string? window,
|
||||
int limit,
|
||||
@@ -567,7 +594,7 @@ public static class RiskBudgetCommandGroup
|
||||
logger?.LogDebug("Getting budget history for service {ServiceId}", serviceId);
|
||||
}
|
||||
|
||||
var client = httpClientFactory.CreateClient("PolicyApi");
|
||||
var client = CreatePolicyApiClient(httpClientFactory, tenant);
|
||||
var query = $"/api/v1/policy/risk-budget/history/{Uri.EscapeDataString(serviceId)}?limit={limit}";
|
||||
if (!string.IsNullOrEmpty(window))
|
||||
{
|
||||
@@ -604,6 +631,7 @@ public static class RiskBudgetCommandGroup
|
||||
|
||||
private static async Task<int> HandleListAsync(
|
||||
IServiceProvider services,
|
||||
string? tenant,
|
||||
string? status,
|
||||
int? tier,
|
||||
int limit,
|
||||
@@ -628,7 +656,7 @@ public static class RiskBudgetCommandGroup
|
||||
logger?.LogDebug("Listing budgets with status={Status}, tier={Tier}", status, tier);
|
||||
}
|
||||
|
||||
var client = httpClientFactory.CreateClient("PolicyApi");
|
||||
var client = CreatePolicyApiClient(httpClientFactory, tenant);
|
||||
var query = $"/api/v1/policy/risk-budget?limit={limit}";
|
||||
if (!string.IsNullOrEmpty(status))
|
||||
{
|
||||
@@ -667,6 +695,26 @@ public static class RiskBudgetCommandGroup
|
||||
}
|
||||
}
|
||||
|
||||
private static HttpClient CreatePolicyApiClient(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
string? tenantOverride)
|
||||
{
|
||||
var client = httpClientFactory.CreateClient("PolicyApi");
|
||||
ApplyTenantHeader(client, tenantOverride);
|
||||
return client;
|
||||
}
|
||||
|
||||
private static void ApplyTenantHeader(HttpClient client, string? tenantOverride)
|
||||
{
|
||||
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenantOverride);
|
||||
client.DefaultRequestHeaders.Remove(TenantHeaderName);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(effectiveTenant))
|
||||
{
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation(TenantHeaderName, effectiveTenant.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Output Formatters
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
using StellaOps.Cli.Services.Models.AdvisoryAi;
|
||||
using StellaOps.Doctor.Engine;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.CommandLine;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -19,6 +23,12 @@ internal static class KnowledgeSearchCommandGroup
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
private const string DefaultDocsAllowListPath = "src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/knowledge-docs-allowlist.json";
|
||||
private const string DefaultDocsManifestPath = "src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/knowledge-docs-manifest.json";
|
||||
private const string DefaultDoctorSeedPath = "src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/doctor-search-seed.json";
|
||||
private const string DefaultDoctorControlsPath = "src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/doctor-search-controls.json";
|
||||
private const string DefaultOpenApiAggregatePath = "devops/compose/openapi_current.json";
|
||||
|
||||
private static readonly HashSet<string> AllowedTypes = new(StringComparer.Ordinal)
|
||||
{
|
||||
"docs",
|
||||
@@ -124,22 +134,97 @@ internal static class KnowledgeSearchCommandGroup
|
||||
var advisoryAi = new Command("advisoryai", "AdvisoryAI maintenance commands.");
|
||||
var index = new Command("index", "Knowledge index operations.");
|
||||
var rebuild = new Command("rebuild", "Rebuild AdvisoryAI deterministic knowledge index.");
|
||||
var jsonOption = new Option<bool>("--json")
|
||||
var rebuildJsonOption = new Option<bool>("--json")
|
||||
{
|
||||
Description = "Emit machine-readable JSON output."
|
||||
};
|
||||
|
||||
rebuild.Add(jsonOption);
|
||||
rebuild.Add(rebuildJsonOption);
|
||||
rebuild.Add(verboseOption);
|
||||
rebuild.SetAction(async (parseResult, _) =>
|
||||
{
|
||||
var emitJson = parseResult.GetValue(jsonOption);
|
||||
var emitJson = parseResult.GetValue(rebuildJsonOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
await ExecuteRebuildAsync(services, emitJson, verbose, cancellationToken).ConfigureAwait(false);
|
||||
});
|
||||
|
||||
var sources = new Command("sources", "Prepare deterministic knowledge source artifacts.");
|
||||
var prepare = new Command("prepare", "Aggregate docs allowlist, OpenAPI snapshot, and doctor controls seed data.");
|
||||
var repoRootOption = new Option<string>("--repo-root")
|
||||
{
|
||||
Description = "Repository root used to resolve relative source paths."
|
||||
};
|
||||
repoRootOption.SetDefaultValue(".");
|
||||
|
||||
var docsAllowListOption = new Option<string>("--docs-allowlist")
|
||||
{
|
||||
Description = "Path to docs allowlist JSON (include/includes/paths)."
|
||||
};
|
||||
docsAllowListOption.SetDefaultValue(DefaultDocsAllowListPath);
|
||||
|
||||
var docsManifestOutputOption = new Option<string>("--docs-manifest-output")
|
||||
{
|
||||
Description = "Output path for resolved markdown manifest JSON."
|
||||
};
|
||||
docsManifestOutputOption.SetDefaultValue(DefaultDocsManifestPath);
|
||||
|
||||
var openApiOutputOption = new Option<string>("--openapi-output")
|
||||
{
|
||||
Description = "Output path for aggregated OpenAPI snapshot."
|
||||
};
|
||||
openApiOutputOption.SetDefaultValue(DefaultOpenApiAggregatePath);
|
||||
|
||||
var doctorSeedOption = new Option<string>("--doctor-seed")
|
||||
{
|
||||
Description = "Input doctor seed JSON used to derive controls."
|
||||
};
|
||||
doctorSeedOption.SetDefaultValue(DefaultDoctorSeedPath);
|
||||
|
||||
var doctorControlsOutputOption = new Option<string>("--doctor-controls-output")
|
||||
{
|
||||
Description = "Output path for doctor controls JSON."
|
||||
};
|
||||
doctorControlsOutputOption.SetDefaultValue(DefaultDoctorControlsPath);
|
||||
|
||||
var overwriteOption = new Option<bool>("--overwrite")
|
||||
{
|
||||
Description = "Overwrite generated artifacts and OpenAPI snapshot."
|
||||
};
|
||||
|
||||
var prepareJsonOption = new Option<bool>("--json")
|
||||
{
|
||||
Description = "Emit machine-readable JSON output."
|
||||
};
|
||||
|
||||
prepare.Add(repoRootOption);
|
||||
prepare.Add(docsAllowListOption);
|
||||
prepare.Add(docsManifestOutputOption);
|
||||
prepare.Add(openApiOutputOption);
|
||||
prepare.Add(doctorSeedOption);
|
||||
prepare.Add(doctorControlsOutputOption);
|
||||
prepare.Add(overwriteOption);
|
||||
prepare.Add(prepareJsonOption);
|
||||
prepare.Add(verboseOption);
|
||||
prepare.SetAction(async (parseResult, _) =>
|
||||
{
|
||||
await ExecutePrepareSourcesAsync(
|
||||
services,
|
||||
repoRoot: parseResult.GetValue(repoRootOption) ?? ".",
|
||||
docsAllowListPath: parseResult.GetValue(docsAllowListOption) ?? DefaultDocsAllowListPath,
|
||||
docsManifestOutputPath: parseResult.GetValue(docsManifestOutputOption) ?? DefaultDocsManifestPath,
|
||||
openApiOutputPath: parseResult.GetValue(openApiOutputOption) ?? DefaultOpenApiAggregatePath,
|
||||
doctorSeedPath: parseResult.GetValue(doctorSeedOption) ?? DefaultDoctorSeedPath,
|
||||
doctorControlsOutputPath: parseResult.GetValue(doctorControlsOutputOption) ?? DefaultDoctorControlsPath,
|
||||
overwrite: parseResult.GetValue(overwriteOption),
|
||||
emitJson: parseResult.GetValue(prepareJsonOption),
|
||||
verbose: parseResult.GetValue(verboseOption),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
});
|
||||
|
||||
sources.Add(prepare);
|
||||
index.Add(rebuild);
|
||||
advisoryAi.Add(index);
|
||||
advisoryAi.Add(sources);
|
||||
return advisoryAi;
|
||||
}
|
||||
|
||||
@@ -318,6 +403,585 @@ internal static class KnowledgeSearchCommandGroup
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ExecutePrepareSourcesAsync(
|
||||
IServiceProvider services,
|
||||
string repoRoot,
|
||||
string docsAllowListPath,
|
||||
string docsManifestOutputPath,
|
||||
string openApiOutputPath,
|
||||
string doctorSeedPath,
|
||||
string doctorControlsOutputPath,
|
||||
bool overwrite,
|
||||
bool emitJson,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var repositoryRoot = ResolvePath(Directory.GetCurrentDirectory(), repoRoot);
|
||||
var docsAllowListAbsolute = ResolvePath(repositoryRoot, docsAllowListPath);
|
||||
var docsManifestAbsolute = ResolvePath(repositoryRoot, docsManifestOutputPath);
|
||||
var openApiAbsolute = ResolvePath(repositoryRoot, openApiOutputPath);
|
||||
var doctorSeedAbsolute = ResolvePath(repositoryRoot, doctorSeedPath);
|
||||
var doctorControlsAbsolute = ResolvePath(repositoryRoot, doctorControlsOutputPath);
|
||||
|
||||
var includeEntries = LoadAllowListIncludes(docsAllowListAbsolute);
|
||||
var markdownFiles = ResolveMarkdownFiles(repositoryRoot, includeEntries);
|
||||
var markdownDocuments = markdownFiles
|
||||
.Select(path => new DocsManifestDocument(
|
||||
path,
|
||||
ComputeSha256Hex(ResolvePath(repositoryRoot, path))))
|
||||
.ToArray();
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
Console.WriteLine($"Docs allowlist: {docsAllowListAbsolute}");
|
||||
Console.WriteLine($"Resolved markdown files: {markdownFiles.Count}");
|
||||
}
|
||||
|
||||
WriteDocsManifest(
|
||||
docsManifestAbsolute,
|
||||
includeEntries,
|
||||
markdownDocuments,
|
||||
overwrite);
|
||||
|
||||
var backend = services.GetRequiredService<IBackendOperationsClient>();
|
||||
var apiResult = await backend.DownloadApiSpecAsync(
|
||||
new ApiSpecDownloadRequest
|
||||
{
|
||||
OutputPath = openApiAbsolute,
|
||||
Service = null,
|
||||
Format = "openapi-json",
|
||||
Overwrite = overwrite,
|
||||
ChecksumAlgorithm = "sha256"
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!apiResult.Success)
|
||||
{
|
||||
Console.Error.WriteLine($"OpenAPI aggregation failed: {apiResult.Error ?? "unknown error"}");
|
||||
Environment.ExitCode = CliExitCodes.GeneralError;
|
||||
return;
|
||||
}
|
||||
|
||||
var configuredDoctorSeedEntries = LoadDoctorSeedEntries(doctorSeedAbsolute);
|
||||
var discoveredDoctorEntries = LoadDoctorSeedEntriesFromEngine(services, verbose);
|
||||
var doctorSeedEntries = MergeDoctorSeedEntries(configuredDoctorSeedEntries, discoveredDoctorEntries);
|
||||
var doctorControlEntries = BuildDoctorControls(doctorSeedEntries);
|
||||
WriteDoctorControls(
|
||||
doctorControlsAbsolute,
|
||||
doctorControlEntries,
|
||||
overwrite);
|
||||
|
||||
if (emitJson)
|
||||
{
|
||||
WriteJson(new
|
||||
{
|
||||
repositoryRoot,
|
||||
docs = new
|
||||
{
|
||||
allowListPath = ToRelativePath(repositoryRoot, docsAllowListAbsolute),
|
||||
manifestPath = ToRelativePath(repositoryRoot, docsManifestAbsolute),
|
||||
includeCount = includeEntries.Count,
|
||||
documentCount = markdownDocuments.Length
|
||||
},
|
||||
openApi = new
|
||||
{
|
||||
path = ToRelativePath(repositoryRoot, openApiAbsolute),
|
||||
fromCache = apiResult.FromCache,
|
||||
checksum = apiResult.Checksum
|
||||
},
|
||||
doctor = new
|
||||
{
|
||||
seedPath = ToRelativePath(repositoryRoot, doctorSeedAbsolute),
|
||||
configuredSeedCount = configuredDoctorSeedEntries.Count,
|
||||
discoveredSeedCount = discoveredDoctorEntries.Count,
|
||||
mergedSeedCount = doctorSeedEntries.Count,
|
||||
controlsPath = ToRelativePath(repositoryRoot, doctorControlsAbsolute),
|
||||
controlCount = doctorControlEntries.Length
|
||||
}
|
||||
});
|
||||
Environment.ExitCode = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine("AdvisoryAI source artifacts prepared.");
|
||||
Console.WriteLine($" Docs allowlist: {ToRelativePath(repositoryRoot, docsAllowListAbsolute)}");
|
||||
Console.WriteLine($" Docs manifest: {ToRelativePath(repositoryRoot, docsManifestAbsolute)} ({markdownDocuments.Length} markdown files)");
|
||||
Console.WriteLine($" OpenAPI aggregate: {ToRelativePath(repositoryRoot, openApiAbsolute)}");
|
||||
Console.WriteLine($" Doctor seed (configured/discovered/merged): {configuredDoctorSeedEntries.Count}/{discoveredDoctorEntries.Count}/{doctorSeedEntries.Count}");
|
||||
Console.WriteLine($" Doctor controls: {ToRelativePath(repositoryRoot, doctorControlsAbsolute)} ({doctorControlEntries.Length} checks)");
|
||||
Environment.ExitCode = 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"AdvisoryAI source preparation failed: {ex.Message}");
|
||||
Environment.ExitCode = CliExitCodes.GeneralError;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolvePath(string repositoryRoot, string configuredPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(configuredPath))
|
||||
{
|
||||
return repositoryRoot;
|
||||
}
|
||||
|
||||
return Path.IsPathRooted(configuredPath)
|
||||
? Path.GetFullPath(configuredPath)
|
||||
: Path.GetFullPath(Path.Combine(repositoryRoot, configuredPath));
|
||||
}
|
||||
|
||||
private static string ToRelativePath(string repositoryRoot, string absolutePath)
|
||||
{
|
||||
var relative = Path.GetRelativePath(repositoryRoot, absolutePath).Replace('\\', '/');
|
||||
return string.IsNullOrWhiteSpace(relative) ? "." : relative;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> LoadAllowListIncludes(string allowListPath)
|
||||
{
|
||||
if (!File.Exists(allowListPath))
|
||||
{
|
||||
throw new FileNotFoundException($"Docs allowlist file was not found: {allowListPath}", allowListPath);
|
||||
}
|
||||
|
||||
using var stream = File.OpenRead(allowListPath);
|
||||
using var document = JsonDocument.Parse(stream);
|
||||
var root = document.RootElement;
|
||||
if (root.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
return ReadStringList(root);
|
||||
}
|
||||
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
if (TryGetStringListProperty(root, "include", out var include))
|
||||
{
|
||||
return include;
|
||||
}
|
||||
|
||||
if (TryGetStringListProperty(root, "includes", out include))
|
||||
{
|
||||
return include;
|
||||
}
|
||||
|
||||
if (TryGetStringListProperty(root, "paths", out include))
|
||||
{
|
||||
return include;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ResolveMarkdownFiles(string repositoryRoot, IReadOnlyList<string> includeEntries)
|
||||
{
|
||||
var files = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var include in includeEntries)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(include))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var absolutePath = ResolvePath(repositoryRoot, include);
|
||||
if (File.Exists(absolutePath))
|
||||
{
|
||||
if (Path.GetExtension(absolutePath).Equals(".md", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
files.Add(ToRelativePath(repositoryRoot, Path.GetFullPath(absolutePath)));
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!Directory.Exists(absolutePath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var file in Directory.EnumerateFiles(absolutePath, "*.md", SearchOption.AllDirectories))
|
||||
{
|
||||
files.Add(ToRelativePath(repositoryRoot, Path.GetFullPath(file)));
|
||||
}
|
||||
}
|
||||
|
||||
return files.OrderBy(static file => file, StringComparer.Ordinal).ToArray();
|
||||
}
|
||||
|
||||
private static void WriteDocsManifest(
|
||||
string outputPath,
|
||||
IReadOnlyList<string> includeEntries,
|
||||
IReadOnlyList<DocsManifestDocument> documents,
|
||||
bool overwrite)
|
||||
{
|
||||
if (File.Exists(outputPath) && !overwrite)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var directory = Path.GetDirectoryName(outputPath);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
var payload = new
|
||||
{
|
||||
schema = "stellaops.advisoryai.docs-manifest.v1",
|
||||
include = includeEntries.OrderBy(static entry => entry, StringComparer.Ordinal).ToArray(),
|
||||
documents = documents.OrderBy(static entry => entry.Path, StringComparer.Ordinal).ToArray()
|
||||
};
|
||||
|
||||
File.WriteAllText(outputPath, JsonSerializer.Serialize(payload, JsonOutputOptions));
|
||||
}
|
||||
|
||||
private static IReadOnlyList<DoctorSeedEntry> LoadDoctorSeedEntries(string doctorSeedPath)
|
||||
{
|
||||
if (!File.Exists(doctorSeedPath))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
using var stream = File.OpenRead(doctorSeedPath);
|
||||
using var document = JsonDocument.Parse(stream);
|
||||
if (document.RootElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var entries = new List<DoctorSeedEntry>();
|
||||
foreach (var item in document.RootElement.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var checkCode = ReadString(item, "checkCode");
|
||||
if (string.IsNullOrWhiteSpace(checkCode))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
entries.Add(new DoctorSeedEntry(
|
||||
checkCode,
|
||||
ReadString(item, "title") ?? checkCode,
|
||||
ReadString(item, "severity") ?? "warn",
|
||||
ReadString(item, "description") ?? string.Empty,
|
||||
ReadString(item, "remediation") ?? string.Empty,
|
||||
ReadString(item, "runCommand") ?? $"stella doctor run --check {checkCode}",
|
||||
TryGetStringListProperty(item, "symptoms", out var symptoms) ? symptoms : [],
|
||||
TryGetStringListProperty(item, "tags", out var tags) ? tags : [],
|
||||
TryGetStringListProperty(item, "references", out var references) ? references : []));
|
||||
}
|
||||
|
||||
return entries
|
||||
.OrderBy(static entry => entry.CheckCode, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<DoctorSeedEntry> LoadDoctorSeedEntriesFromEngine(IServiceProvider services, bool verbose)
|
||||
{
|
||||
var engine = services.GetService<DoctorEngine>();
|
||||
if (engine is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var checks = engine.ListChecks();
|
||||
return checks
|
||||
.OrderBy(static check => check.CheckId, StringComparer.Ordinal)
|
||||
.Select(static check =>
|
||||
{
|
||||
var runCommand = $"stella doctor run --check {check.CheckId}";
|
||||
var references = MergeUniqueStrings(
|
||||
string.IsNullOrWhiteSpace(check.Category) ? [] : [$"category:{check.Category}"],
|
||||
string.IsNullOrWhiteSpace(check.PluginId) ? [] : [$"plugin:{check.PluginId}"]);
|
||||
var tags = MergeUniqueStrings(
|
||||
check.Tags,
|
||||
string.IsNullOrWhiteSpace(check.Category) ? [] : [check.Category],
|
||||
["doctor", "diagnostics"]);
|
||||
|
||||
return new DoctorSeedEntry(
|
||||
check.CheckId,
|
||||
string.IsNullOrWhiteSpace(check.Name) ? check.CheckId : check.Name.Trim(),
|
||||
check.DefaultSeverity.ToString().ToLowerInvariant(),
|
||||
(check.Description ?? string.Empty).Trim(),
|
||||
$"Run `{runCommand}` and follow the diagnosis output.",
|
||||
runCommand,
|
||||
BuildSymptomsFromCheckText(check.Name, check.Description, check.Tags),
|
||||
tags,
|
||||
references);
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (verbose)
|
||||
{
|
||||
Console.Error.WriteLine($"Doctor check discovery from engine failed: {ex.Message}");
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<DoctorSeedEntry> MergeDoctorSeedEntries(
|
||||
IReadOnlyList<DoctorSeedEntry> configuredEntries,
|
||||
IReadOnlyList<DoctorSeedEntry> discoveredEntries)
|
||||
{
|
||||
var merged = discoveredEntries
|
||||
.ToDictionary(static entry => entry.CheckCode, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var configured in configuredEntries)
|
||||
{
|
||||
if (!merged.TryGetValue(configured.CheckCode, out var discovered))
|
||||
{
|
||||
merged[configured.CheckCode] = configured;
|
||||
continue;
|
||||
}
|
||||
|
||||
merged[configured.CheckCode] = new DoctorSeedEntry(
|
||||
configured.CheckCode,
|
||||
PreferConfigured(configured.Title, discovered.Title, configured.CheckCode),
|
||||
PreferConfigured(configured.Severity, discovered.Severity, "warn"),
|
||||
PreferConfigured(configured.Description, discovered.Description, string.Empty),
|
||||
PreferConfigured(configured.Remediation, discovered.Remediation, string.Empty),
|
||||
PreferConfigured(configured.RunCommand, discovered.RunCommand, $"stella doctor run --check {configured.CheckCode}"),
|
||||
MergeUniqueStrings(configured.Symptoms, discovered.Symptoms),
|
||||
MergeUniqueStrings(configured.Tags, discovered.Tags),
|
||||
MergeUniqueStrings(configured.References, discovered.References));
|
||||
}
|
||||
|
||||
return merged.Values
|
||||
.OrderBy(static entry => entry.CheckCode, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static DoctorControlEntry[] BuildDoctorControls(IReadOnlyList<DoctorSeedEntry> seedEntries)
|
||||
{
|
||||
return seedEntries
|
||||
.OrderBy(static entry => entry.CheckCode, StringComparer.Ordinal)
|
||||
.Select(static entry =>
|
||||
{
|
||||
var mode = InferControlMode(entry.Severity);
|
||||
var requiresConfirmation = !mode.Equals("safe", StringComparison.Ordinal);
|
||||
return new DoctorControlEntry(
|
||||
entry.CheckCode,
|
||||
mode,
|
||||
requiresConfirmation,
|
||||
IsDestructive: false,
|
||||
RequiresBackup: false,
|
||||
InspectCommand: $"stella doctor run --check {entry.CheckCode} --mode quick",
|
||||
VerificationCommand: string.IsNullOrWhiteSpace(entry.RunCommand)
|
||||
? $"stella doctor run --check {entry.CheckCode}"
|
||||
: entry.RunCommand.Trim(),
|
||||
Keywords: BuildKeywordList(entry),
|
||||
Title: entry.Title,
|
||||
Severity: entry.Severity,
|
||||
Description: entry.Description,
|
||||
Remediation: entry.Remediation,
|
||||
RunCommand: string.IsNullOrWhiteSpace(entry.RunCommand)
|
||||
? $"stella doctor run --check {entry.CheckCode}"
|
||||
: entry.RunCommand.Trim(),
|
||||
Symptoms: entry.Symptoms,
|
||||
Tags: entry.Tags,
|
||||
References: entry.References);
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static string InferControlMode(string severity)
|
||||
{
|
||||
var normalized = (severity ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"critical" => "manual",
|
||||
"error" => "manual",
|
||||
"fail" => "manual",
|
||||
"failure" => "manual",
|
||||
"high" => "manual",
|
||||
_ => "safe"
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> BuildKeywordList(DoctorSeedEntry entry)
|
||||
{
|
||||
var terms = new SortedSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var symptom in entry.Symptoms)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(symptom))
|
||||
{
|
||||
terms.Add(symptom.Trim().ToLowerInvariant());
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var token in (entry.Title + " " + entry.Description)
|
||||
.Split(new[] { ' ', '\t', '\r', '\n', ',', ';', ':', '.', '(', ')', '[', ']', '{', '}', '"', '\'' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (token.Length >= 4)
|
||||
{
|
||||
terms.Add(token.Trim().ToLowerInvariant());
|
||||
}
|
||||
}
|
||||
|
||||
return terms.Take(12).ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> BuildSymptomsFromCheckText(
|
||||
string? title,
|
||||
string? description,
|
||||
IReadOnlyList<string>? tags)
|
||||
{
|
||||
var terms = new SortedSet<string>(StringComparer.Ordinal);
|
||||
foreach (var token in (title + " " + description)
|
||||
.Split(new[] { ' ', '\t', '\r', '\n', ',', ';', ':', '.', '(', ')', '[', ']', '{', '}', '"', '\'' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (token.Length >= 5)
|
||||
{
|
||||
terms.Add(token.Trim().ToLowerInvariant());
|
||||
}
|
||||
}
|
||||
|
||||
if (tags is not null)
|
||||
{
|
||||
foreach (var tag in tags)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(tag))
|
||||
{
|
||||
terms.Add(tag.Trim().ToLowerInvariant());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return terms.Take(12).ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> MergeUniqueStrings(params IReadOnlyList<string>?[] values)
|
||||
{
|
||||
var merged = new SortedSet<string>(StringComparer.Ordinal);
|
||||
foreach (var source in values)
|
||||
{
|
||||
if (source is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var value in source)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
merged.Add(value.Trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return merged.ToArray();
|
||||
}
|
||||
|
||||
private static string PreferConfigured(string? configured, string? discovered, string fallback)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(configured))
|
||||
{
|
||||
return configured.Trim();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(discovered))
|
||||
{
|
||||
return discovered.Trim();
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private static void WriteDoctorControls(string outputPath, IReadOnlyList<DoctorControlEntry> controls, bool overwrite)
|
||||
{
|
||||
if (File.Exists(outputPath) && !overwrite)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var directory = Path.GetDirectoryName(outputPath);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
var payload = controls
|
||||
.OrderBy(static entry => entry.CheckCode, StringComparer.Ordinal)
|
||||
.Select(static entry => new
|
||||
{
|
||||
checkCode = entry.CheckCode,
|
||||
control = entry.Control,
|
||||
requiresConfirmation = entry.RequiresConfirmation,
|
||||
isDestructive = entry.IsDestructive,
|
||||
requiresBackup = entry.RequiresBackup,
|
||||
inspectCommand = entry.InspectCommand,
|
||||
verificationCommand = entry.VerificationCommand,
|
||||
keywords = entry.Keywords,
|
||||
title = entry.Title,
|
||||
severity = entry.Severity,
|
||||
description = entry.Description,
|
||||
remediation = entry.Remediation,
|
||||
runCommand = entry.RunCommand,
|
||||
symptoms = entry.Symptoms,
|
||||
tags = entry.Tags,
|
||||
references = entry.References
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
File.WriteAllText(outputPath, JsonSerializer.Serialize(payload, JsonOutputOptions));
|
||||
}
|
||||
|
||||
private static string ComputeSha256Hex(string absolutePath)
|
||||
{
|
||||
using var stream = File.OpenRead(absolutePath);
|
||||
using var sha = SHA256.Create();
|
||||
var hash = sha.ComputeHash(stream);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static bool TryGetStringListProperty(JsonElement element, string propertyName, out IReadOnlyList<string> values)
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.Object &&
|
||||
element.TryGetProperty(propertyName, out var property) &&
|
||||
property.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
values = ReadStringList(property);
|
||||
return true;
|
||||
}
|
||||
|
||||
values = [];
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string? ReadString(JsonElement element, string propertyName)
|
||||
{
|
||||
return element.ValueKind == JsonValueKind.Object &&
|
||||
element.TryGetProperty(propertyName, out var property) &&
|
||||
property.ValueKind == JsonValueKind.String
|
||||
? property.GetString()
|
||||
: null;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ReadStringList(JsonElement array)
|
||||
{
|
||||
return array.EnumerateArray()
|
||||
.Where(static item => item.ValueKind == JsonValueKind.String)
|
||||
.Select(static item => item.GetString())
|
||||
.Where(static item => !string.IsNullOrWhiteSpace(item))
|
||||
.Select(static item => item!.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(static item => item, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static AdvisoryKnowledgeSearchFilterModel? BuildFilter(
|
||||
IReadOnlyList<string> types,
|
||||
IReadOnlyList<string> tags,
|
||||
@@ -518,7 +1182,7 @@ internal static class KnowledgeSearchCommandGroup
|
||||
if (result.Type.Equals("doctor", StringComparison.OrdinalIgnoreCase) && result.Open.Doctor is not null)
|
||||
{
|
||||
var doctor = result.Open.Doctor;
|
||||
return $"doctor: {doctor.CheckCode} severity={doctor.Severity} run=\"{doctor.RunCommand}\"";
|
||||
return $"doctor: {doctor.CheckCode} severity={doctor.Severity} control={doctor.Control} confirm={doctor.RequiresConfirmation.ToString().ToLowerInvariant()} run=\"{doctor.RunCommand}\"";
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
@@ -580,6 +1244,39 @@ internal static class KnowledgeSearchCommandGroup
|
||||
};
|
||||
}
|
||||
|
||||
private sealed record DocsManifestDocument(
|
||||
string Path,
|
||||
string Sha256);
|
||||
|
||||
private sealed record DoctorSeedEntry(
|
||||
string CheckCode,
|
||||
string Title,
|
||||
string Severity,
|
||||
string Description,
|
||||
string Remediation,
|
||||
string RunCommand,
|
||||
IReadOnlyList<string> Symptoms,
|
||||
IReadOnlyList<string> Tags,
|
||||
IReadOnlyList<string> References);
|
||||
|
||||
private sealed record DoctorControlEntry(
|
||||
string CheckCode,
|
||||
string Control,
|
||||
bool RequiresConfirmation,
|
||||
bool IsDestructive,
|
||||
bool RequiresBackup,
|
||||
string InspectCommand,
|
||||
string VerificationCommand,
|
||||
IReadOnlyList<string> Keywords,
|
||||
string Title,
|
||||
string Severity,
|
||||
string Description,
|
||||
string Remediation,
|
||||
string RunCommand,
|
||||
IReadOnlyList<string> Symptoms,
|
||||
IReadOnlyList<string> Tags,
|
||||
IReadOnlyList<string> References);
|
||||
|
||||
private static void WriteJson(object payload)
|
||||
{
|
||||
Console.WriteLine(JsonSerializer.Serialize(payload, JsonOutputOptions));
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cli.Extensions;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Policy.Unknowns.Models;
|
||||
using System.CommandLine;
|
||||
using System.Net.Http.Json;
|
||||
@@ -24,6 +25,7 @@ namespace StellaOps.Cli.Commands;
|
||||
public static class UnknownsCommandGroup
|
||||
{
|
||||
private const string DefaultUnknownsExportSchemaVersion = "unknowns.export.v1";
|
||||
private const string TenantHeaderName = "X-Tenant-Id";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
@@ -41,18 +43,23 @@ public static class UnknownsCommandGroup
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var unknownsCommand = new Command("unknowns", "Unknowns registry operations for unmatched vulnerabilities");
|
||||
var tenantOption = new Option<string?>("--tenant", new[] { "-t" })
|
||||
{
|
||||
Description = "Tenant context for unknowns operations. Overrides profile and STELLAOPS_TENANT."
|
||||
};
|
||||
unknownsCommand.Add(tenantOption);
|
||||
|
||||
unknownsCommand.Add(BuildListCommand(services, verboseOption, cancellationToken));
|
||||
unknownsCommand.Add(BuildEscalateCommand(services, verboseOption, cancellationToken));
|
||||
unknownsCommand.Add(BuildResolveCommand(services, verboseOption, cancellationToken));
|
||||
unknownsCommand.Add(BuildBudgetCommand(services, verboseOption, cancellationToken));
|
||||
unknownsCommand.Add(BuildListCommand(services, verboseOption, tenantOption, cancellationToken));
|
||||
unknownsCommand.Add(BuildEscalateCommand(services, verboseOption, tenantOption, cancellationToken));
|
||||
unknownsCommand.Add(BuildResolveCommand(services, verboseOption, tenantOption, cancellationToken));
|
||||
unknownsCommand.Add(BuildBudgetCommand(services, verboseOption, tenantOption, cancellationToken));
|
||||
|
||||
// Sprint: SPRINT_20260112_010_CLI_unknowns_grey_queue_cli (CLI-UNK-001, CLI-UNK-002, CLI-UNK-003)
|
||||
unknownsCommand.Add(BuildSummaryCommand(services, verboseOption, cancellationToken));
|
||||
unknownsCommand.Add(BuildShowCommand(services, verboseOption, cancellationToken));
|
||||
unknownsCommand.Add(BuildProofCommand(services, verboseOption, cancellationToken));
|
||||
unknownsCommand.Add(BuildExportCommand(services, verboseOption, cancellationToken));
|
||||
unknownsCommand.Add(BuildTriageCommand(services, verboseOption, cancellationToken));
|
||||
unknownsCommand.Add(BuildSummaryCommand(services, verboseOption, tenantOption, cancellationToken));
|
||||
unknownsCommand.Add(BuildShowCommand(services, verboseOption, tenantOption, cancellationToken));
|
||||
unknownsCommand.Add(BuildProofCommand(services, verboseOption, tenantOption, cancellationToken));
|
||||
unknownsCommand.Add(BuildExportCommand(services, verboseOption, tenantOption, cancellationToken));
|
||||
unknownsCommand.Add(BuildTriageCommand(services, verboseOption, tenantOption, cancellationToken));
|
||||
|
||||
return unknownsCommand;
|
||||
}
|
||||
@@ -64,17 +71,19 @@ public static class UnknownsCommandGroup
|
||||
private static Command BuildBudgetCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
Option<string?> tenantOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var budgetCommand = new Command("budget", "Unknowns budget operations for CI gates");
|
||||
budgetCommand.Add(BuildBudgetCheckCommand(services, verboseOption, cancellationToken));
|
||||
budgetCommand.Add(BuildBudgetStatusCommand(services, verboseOption, cancellationToken));
|
||||
budgetCommand.Add(BuildBudgetCheckCommand(services, verboseOption, tenantOption, cancellationToken));
|
||||
budgetCommand.Add(BuildBudgetStatusCommand(services, verboseOption, tenantOption, cancellationToken));
|
||||
return budgetCommand;
|
||||
}
|
||||
|
||||
private static Command BuildBudgetCheckCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
Option<string?> tenantOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scanIdOption = new Option<string?>("--scan-id", new[] { "-s" })
|
||||
@@ -128,9 +137,11 @@ public static class UnknownsCommandGroup
|
||||
var failOnExceed = parseResult.GetValue(failOnExceedOption);
|
||||
var output = parseResult.GetValue(outputOption) ?? "text";
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
var tenant = parseResult.GetValue(tenantOption);
|
||||
|
||||
return await HandleBudgetCheckAsync(
|
||||
services,
|
||||
tenant,
|
||||
scanId,
|
||||
verdictPath,
|
||||
environment,
|
||||
@@ -147,6 +158,7 @@ public static class UnknownsCommandGroup
|
||||
private static Command BuildBudgetStatusCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
Option<string?> tenantOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var environmentOption = new Option<string>("--environment", new[] { "-e" })
|
||||
@@ -171,9 +183,11 @@ public static class UnknownsCommandGroup
|
||||
var environment = parseResult.GetValue(environmentOption) ?? "prod";
|
||||
var output = parseResult.GetValue(outputOption) ?? "text";
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
var tenant = parseResult.GetValue(tenantOption);
|
||||
|
||||
return await HandleBudgetStatusAsync(
|
||||
services,
|
||||
tenant,
|
||||
environment,
|
||||
output,
|
||||
verbose,
|
||||
@@ -186,6 +200,7 @@ public static class UnknownsCommandGroup
|
||||
private static Command BuildListCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
Option<string?> tenantOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var bandOption = new Option<string?>("--band", new[] { "-b" })
|
||||
@@ -229,11 +244,13 @@ public static class UnknownsCommandGroup
|
||||
var format = parseResult.GetValue(formatOption) ?? "table";
|
||||
var sort = parseResult.GetValue(sortOption) ?? "age";
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
var tenant = parseResult.GetValue(tenantOption);
|
||||
|
||||
if (limit <= 0) limit = 50;
|
||||
|
||||
return await HandleListAsync(
|
||||
services,
|
||||
tenant,
|
||||
band,
|
||||
limit,
|
||||
offset,
|
||||
@@ -249,6 +266,7 @@ public static class UnknownsCommandGroup
|
||||
private static Command BuildEscalateCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
Option<string?> tenantOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var idOption = new Option<string>("--id", new[] { "-i" })
|
||||
@@ -272,9 +290,11 @@ public static class UnknownsCommandGroup
|
||||
var id = parseResult.GetValue(idOption) ?? string.Empty;
|
||||
var reason = parseResult.GetValue(reasonOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
var tenant = parseResult.GetValue(tenantOption);
|
||||
|
||||
return await HandleEscalateAsync(
|
||||
services,
|
||||
tenant,
|
||||
id,
|
||||
reason,
|
||||
verbose,
|
||||
@@ -288,6 +308,7 @@ public static class UnknownsCommandGroup
|
||||
private static Command BuildSummaryCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
Option<string?> tenantOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var formatOption = new Option<string>("--format", new[] { "-f" })
|
||||
@@ -304,8 +325,9 @@ public static class UnknownsCommandGroup
|
||||
{
|
||||
var format = parseResult.GetValue(formatOption) ?? "table";
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
var tenant = parseResult.GetValue(tenantOption);
|
||||
|
||||
return await HandleSummaryAsync(services, format, verbose, cancellationToken);
|
||||
return await HandleSummaryAsync(services, tenant, format, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
return summaryCommand;
|
||||
@@ -315,6 +337,7 @@ public static class UnknownsCommandGroup
|
||||
private static Command BuildShowCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
Option<string?> tenantOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var idOption = new Option<string>("--id", new[] { "-i" })
|
||||
@@ -339,8 +362,9 @@ public static class UnknownsCommandGroup
|
||||
var id = parseResult.GetValue(idOption) ?? string.Empty;
|
||||
var format = parseResult.GetValue(formatOption) ?? "table";
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
var tenant = parseResult.GetValue(tenantOption);
|
||||
|
||||
return await HandleShowAsync(services, id, format, verbose, cancellationToken);
|
||||
return await HandleShowAsync(services, tenant, id, format, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
return showCommand;
|
||||
@@ -350,6 +374,7 @@ public static class UnknownsCommandGroup
|
||||
private static Command BuildProofCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
Option<string?> tenantOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var idOption = new Option<string>("--id", new[] { "-i" })
|
||||
@@ -374,8 +399,9 @@ public static class UnknownsCommandGroup
|
||||
var id = parseResult.GetValue(idOption) ?? string.Empty;
|
||||
var format = parseResult.GetValue(formatOption) ?? "json";
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
var tenant = parseResult.GetValue(tenantOption);
|
||||
|
||||
return await HandleProofAsync(services, id, format, verbose, cancellationToken);
|
||||
return await HandleProofAsync(services, tenant, id, format, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
return proofCommand;
|
||||
@@ -385,6 +411,7 @@ public static class UnknownsCommandGroup
|
||||
private static Command BuildExportCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
Option<string?> tenantOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var bandOption = new Option<string?>("--band", new[] { "-b" })
|
||||
@@ -424,8 +451,9 @@ public static class UnknownsCommandGroup
|
||||
var schemaVersion = parseResult.GetValue(schemaVersionOption) ?? DefaultUnknownsExportSchemaVersion;
|
||||
var output = parseResult.GetValue(outputOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
var tenant = parseResult.GetValue(tenantOption);
|
||||
|
||||
return await HandleExportAsync(services, band, format, schemaVersion, output, verbose, cancellationToken);
|
||||
return await HandleExportAsync(services, tenant, band, format, schemaVersion, output, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
return exportCommand;
|
||||
@@ -435,6 +463,7 @@ public static class UnknownsCommandGroup
|
||||
private static Command BuildTriageCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
Option<string?> tenantOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var idOption = new Option<string>("--id", new[] { "-i" })
|
||||
@@ -474,8 +503,9 @@ public static class UnknownsCommandGroup
|
||||
var reason = parseResult.GetValue(reasonOption) ?? string.Empty;
|
||||
var duration = parseResult.GetValue(durationOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
var tenant = parseResult.GetValue(tenantOption);
|
||||
|
||||
return await HandleTriageAsync(services, id, action, reason, duration, verbose, cancellationToken);
|
||||
return await HandleTriageAsync(services, tenant, id, action, reason, duration, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
return triageCommand;
|
||||
@@ -484,6 +514,7 @@ public static class UnknownsCommandGroup
|
||||
private static Command BuildResolveCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
Option<string?> tenantOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var idOption = new Option<string>("--id", new[] { "-i" })
|
||||
@@ -515,9 +546,11 @@ public static class UnknownsCommandGroup
|
||||
var resolution = parseResult.GetValue(resolutionOption) ?? string.Empty;
|
||||
var note = parseResult.GetValue(noteOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
var tenant = parseResult.GetValue(tenantOption);
|
||||
|
||||
return await HandleResolveAsync(
|
||||
services,
|
||||
tenant,
|
||||
id,
|
||||
resolution,
|
||||
note,
|
||||
@@ -530,6 +563,7 @@ public static class UnknownsCommandGroup
|
||||
|
||||
private static async Task<int> HandleListAsync(
|
||||
IServiceProvider services,
|
||||
string? tenant,
|
||||
string? band,
|
||||
int limit,
|
||||
int offset,
|
||||
@@ -556,7 +590,7 @@ public static class UnknownsCommandGroup
|
||||
band ?? "all", limit, offset);
|
||||
}
|
||||
|
||||
var client = httpClientFactory.CreateClient("PolicyApi");
|
||||
var client = CreatePolicyApiClient(httpClientFactory, tenant);
|
||||
var query = $"/api/v1/policy/unknowns?limit={limit}&offset={offset}&sort={sort}";
|
||||
|
||||
if (!string.IsNullOrEmpty(band))
|
||||
@@ -662,6 +696,7 @@ public static class UnknownsCommandGroup
|
||||
|
||||
private static async Task<int> HandleEscalateAsync(
|
||||
IServiceProvider services,
|
||||
string? tenant,
|
||||
string id,
|
||||
string? reason,
|
||||
bool verbose,
|
||||
@@ -684,7 +719,7 @@ public static class UnknownsCommandGroup
|
||||
logger?.LogDebug("Escalating unknown {Id}", id);
|
||||
}
|
||||
|
||||
var client = httpClientFactory.CreateClient("PolicyApi");
|
||||
var client = CreatePolicyApiClient(httpClientFactory, tenant);
|
||||
var request = new EscalateRequest(reason);
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
@@ -714,6 +749,7 @@ public static class UnknownsCommandGroup
|
||||
|
||||
private static async Task<int> HandleResolveAsync(
|
||||
IServiceProvider services,
|
||||
string? tenant,
|
||||
string id,
|
||||
string resolution,
|
||||
string? note,
|
||||
@@ -737,7 +773,7 @@ public static class UnknownsCommandGroup
|
||||
logger?.LogDebug("Resolving unknown {Id} as {Resolution}", id, resolution);
|
||||
}
|
||||
|
||||
var client = httpClientFactory.CreateClient("PolicyApi");
|
||||
var client = CreatePolicyApiClient(httpClientFactory, tenant);
|
||||
var request = new ResolveRequest(resolution, note);
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
@@ -768,6 +804,7 @@ public static class UnknownsCommandGroup
|
||||
// Sprint: SPRINT_20260112_010_CLI_unknowns_grey_queue_cli (CLI-UNK-001)
|
||||
private static async Task<int> HandleSummaryAsync(
|
||||
IServiceProvider services,
|
||||
string? tenant,
|
||||
string format,
|
||||
bool verbose,
|
||||
CancellationToken ct)
|
||||
@@ -789,7 +826,7 @@ public static class UnknownsCommandGroup
|
||||
logger?.LogDebug("Fetching unknowns summary");
|
||||
}
|
||||
|
||||
var client = httpClientFactory.CreateClient("PolicyApi");
|
||||
var client = CreatePolicyApiClient(httpClientFactory, tenant);
|
||||
var response = await client.GetAsync("/api/v1/policy/unknowns/summary", ct);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
@@ -834,6 +871,7 @@ public static class UnknownsCommandGroup
|
||||
// Sprint: SPRINT_20260112_010_CLI_unknowns_grey_queue_cli (CLI-UNK-001)
|
||||
private static async Task<int> HandleShowAsync(
|
||||
IServiceProvider services,
|
||||
string? tenant,
|
||||
string id,
|
||||
string format,
|
||||
bool verbose,
|
||||
@@ -856,7 +894,7 @@ public static class UnknownsCommandGroup
|
||||
logger?.LogDebug("Fetching unknown {Id}", id);
|
||||
}
|
||||
|
||||
var client = httpClientFactory.CreateClient("PolicyApi");
|
||||
var client = CreatePolicyApiClient(httpClientFactory, tenant);
|
||||
var response = await client.GetAsync($"/api/v1/policy/unknowns/{id}", ct);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
@@ -948,6 +986,7 @@ public static class UnknownsCommandGroup
|
||||
// Sprint: SPRINT_20260112_010_CLI_unknowns_grey_queue_cli (CLI-UNK-002)
|
||||
private static async Task<int> HandleProofAsync(
|
||||
IServiceProvider services,
|
||||
string? tenant,
|
||||
string id,
|
||||
string format,
|
||||
bool verbose,
|
||||
@@ -970,7 +1009,7 @@ public static class UnknownsCommandGroup
|
||||
logger?.LogDebug("Fetching proof for unknown {Id}", id);
|
||||
}
|
||||
|
||||
var client = httpClientFactory.CreateClient("PolicyApi");
|
||||
var client = CreatePolicyApiClient(httpClientFactory, tenant);
|
||||
var response = await client.GetAsync($"/api/v1/policy/unknowns/{id}", ct);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
@@ -1018,6 +1057,7 @@ public static class UnknownsCommandGroup
|
||||
// Sprint: SPRINT_20260112_010_CLI_unknowns_grey_queue_cli (CLI-UNK-002)
|
||||
private static async Task<int> HandleExportAsync(
|
||||
IServiceProvider services,
|
||||
string? tenant,
|
||||
string? band,
|
||||
string format,
|
||||
string schemaVersion,
|
||||
@@ -1042,7 +1082,7 @@ public static class UnknownsCommandGroup
|
||||
logger?.LogDebug("Exporting unknowns: band={Band}, format={Format}", band ?? "all", format);
|
||||
}
|
||||
|
||||
var client = httpClientFactory.CreateClient("PolicyApi");
|
||||
var client = CreatePolicyApiClient(httpClientFactory, tenant);
|
||||
var url = string.IsNullOrEmpty(band) || band == "all"
|
||||
? "/api/v1/policy/unknowns?limit=10000"
|
||||
: $"/api/v1/policy/unknowns?band={band}&limit=10000";
|
||||
@@ -1175,6 +1215,7 @@ public static class UnknownsCommandGroup
|
||||
// Sprint: SPRINT_20260112_010_CLI_unknowns_grey_queue_cli (CLI-UNK-003)
|
||||
private static async Task<int> HandleTriageAsync(
|
||||
IServiceProvider services,
|
||||
string? tenant,
|
||||
string id,
|
||||
string action,
|
||||
string reason,
|
||||
@@ -1207,7 +1248,7 @@ public static class UnknownsCommandGroup
|
||||
logger?.LogDebug("Triaging unknown {Id} with action {Action}", id, action);
|
||||
}
|
||||
|
||||
var client = httpClientFactory.CreateClient("PolicyApi");
|
||||
var client = CreatePolicyApiClient(httpClientFactory, tenant);
|
||||
var request = new TriageRequest(action, reason, durationDays);
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
@@ -1246,6 +1287,7 @@ public static class UnknownsCommandGroup
|
||||
/// </summary>
|
||||
private static async Task<int> HandleBudgetCheckAsync(
|
||||
IServiceProvider services,
|
||||
string? tenant,
|
||||
string? scanId,
|
||||
string? verdictPath,
|
||||
string environment,
|
||||
@@ -1298,7 +1340,7 @@ public static class UnknownsCommandGroup
|
||||
else if (!string.IsNullOrEmpty(scanId))
|
||||
{
|
||||
// Fetch from API
|
||||
var client = httpClientFactory.CreateClient("PolicyApi");
|
||||
var client = CreatePolicyApiClient(httpClientFactory, tenant);
|
||||
var response = await client.GetAsync($"/api/v1/policy/unknowns?scanId={scanId}&limit=1000", ct);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
@@ -1322,7 +1364,7 @@ public static class UnknownsCommandGroup
|
||||
}
|
||||
|
||||
// Check budget via API
|
||||
var budgetClient = httpClientFactory.CreateClient("PolicyApi");
|
||||
var budgetClient = CreatePolicyApiClient(httpClientFactory, tenant);
|
||||
var checkRequest = new BudgetCheckRequest(environment, unknowns);
|
||||
|
||||
var checkResponse = await budgetClient.PostAsJsonAsync(
|
||||
@@ -1471,6 +1513,7 @@ public static class UnknownsCommandGroup
|
||||
|
||||
private static async Task<int> HandleBudgetStatusAsync(
|
||||
IServiceProvider services,
|
||||
string? tenant,
|
||||
string environment,
|
||||
string output,
|
||||
bool verbose,
|
||||
@@ -1493,7 +1536,7 @@ public static class UnknownsCommandGroup
|
||||
logger?.LogDebug("Getting budget status for environment {Environment}", environment);
|
||||
}
|
||||
|
||||
var client = httpClientFactory.CreateClient("PolicyApi");
|
||||
var client = CreatePolicyApiClient(httpClientFactory, tenant);
|
||||
var response = await client.GetAsync($"/api/v1/policy/unknowns/budget/status?environment={environment}", ct);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
@@ -1544,6 +1587,26 @@ public static class UnknownsCommandGroup
|
||||
}
|
||||
}
|
||||
|
||||
private static HttpClient CreatePolicyApiClient(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
string? tenantOverride)
|
||||
{
|
||||
var client = httpClientFactory.CreateClient("PolicyApi");
|
||||
ApplyTenantHeader(client, tenantOverride);
|
||||
return client;
|
||||
}
|
||||
|
||||
private static void ApplyTenantHeader(HttpClient client, string? tenantOverride)
|
||||
{
|
||||
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenantOverride);
|
||||
client.DefaultRequestHeaders.Remove(TenantHeaderName);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(effectiveTenant))
|
||||
{
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation(TenantHeaderName, effectiveTenant.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
#region DTOs
|
||||
|
||||
private sealed record LegacyUnknownsListResponse(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Cli.Services;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
@@ -44,6 +45,7 @@ public static class StellaOpsTokenClientExtensions
|
||||
/// <summary>
|
||||
/// Gets a cached access token or requests a new one if not cached or expired.
|
||||
/// This is a compatibility shim for the old GetCachedAccessTokenAsync pattern.
|
||||
/// Cache key includes effective tenant to prevent cross-tenant cache collisions.
|
||||
/// </summary>
|
||||
public static async Task<StellaOpsTokenCacheEntry> GetCachedAccessTokenAsync(
|
||||
this IStellaOpsTokenClient client,
|
||||
@@ -54,7 +56,7 @@ public static class StellaOpsTokenClientExtensions
|
||||
|
||||
var scopeList = scopes?.Where(s => !string.IsNullOrWhiteSpace(s)).OrderBy(s => s).ToArray() ?? [];
|
||||
var scope = string.Join(" ", scopeList);
|
||||
var cacheKey = $"cc:{scope}";
|
||||
var cacheKey = BuildCacheKey(scope);
|
||||
|
||||
// Check cache first
|
||||
var cached = await client.GetCachedTokenAsync(cacheKey, cancellationToken).ConfigureAwait(false);
|
||||
@@ -84,7 +86,7 @@ public static class StellaOpsTokenClientExtensions
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(client);
|
||||
|
||||
var cacheKey = $"cc:{scope ?? "default"}";
|
||||
var cacheKey = BuildCacheKey(scope ?? "default");
|
||||
|
||||
// Check cache first
|
||||
var cached = await client.GetCachedTokenAsync(cacheKey, cancellationToken).ConfigureAwait(false);
|
||||
@@ -113,4 +115,14 @@ public static class StellaOpsTokenClientExtensions
|
||||
ArgumentNullException.ThrowIfNull(client);
|
||||
return await client.RequestClientCredentialsTokenAsync(null, null, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a cache key that includes the effective tenant to prevent cross-tenant
|
||||
/// token cache collisions when users switch tenants between CLI invocations.
|
||||
/// </summary>
|
||||
private static string BuildCacheKey(string scope)
|
||||
{
|
||||
var tenant = TenantProfileStore.GetEffectiveTenant(null) ?? "none";
|
||||
return $"cc:{tenant}:{scope}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ internal static class Program
|
||||
clientOptions.Authority = options.Authority.Url;
|
||||
clientOptions.ClientId = options.Authority.ClientId ?? string.Empty;
|
||||
clientOptions.ClientSecret = options.Authority.ClientSecret;
|
||||
clientOptions.DefaultTenant = TenantProfileStore.GetEffectiveTenant(null);
|
||||
clientOptions.DefaultScopes.Clear();
|
||||
clientOptions.DefaultScopes.Add(string.IsNullOrWhiteSpace(options.Authority.Scope)
|
||||
? StellaOps.Auth.Abstractions.StellaOpsScopes.ConcelierJobsTrigger
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Migrations;
|
||||
using StellaOps.Platform.Database;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
@@ -23,7 +25,7 @@ internal sealed class MigrationCommandService
|
||||
_loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory));
|
||||
}
|
||||
|
||||
public Task<MigrationResult> RunAsync(
|
||||
public async Task<MigrationResult> RunAsync(
|
||||
MigrationModuleInfo module,
|
||||
string? connectionOverride,
|
||||
MigrationCategory? category,
|
||||
@@ -32,7 +34,21 @@ internal sealed class MigrationCommandService
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var connectionString = ResolveConnectionString(module, connectionOverride);
|
||||
var consolidatedArtifact = MigrationModuleConsolidation.Build(module);
|
||||
|
||||
var runner = CreateRunner(module, connectionString);
|
||||
var appliedMigrations = await runner
|
||||
.GetAppliedMigrationInfoAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var hasConsolidatedApplied = appliedMigrations.Any(migration =>
|
||||
string.Equals(migration.Name, consolidatedArtifact.MigrationName, StringComparison.Ordinal));
|
||||
var consolidatedApplied = hasConsolidatedApplied
|
||||
? appliedMigrations.First(migration =>
|
||||
string.Equals(migration.Name, consolidatedArtifact.MigrationName, StringComparison.Ordinal))
|
||||
: (MigrationInfo?)null;
|
||||
var missingLegacyMigrations = GetMissingLegacyMigrations(consolidatedArtifact, appliedMigrations);
|
||||
var consolidatedInSync = IsConsolidatedInSync(consolidatedArtifact, consolidatedApplied);
|
||||
|
||||
var options = new MigrationRunOptions
|
||||
{
|
||||
@@ -43,7 +59,40 @@ internal sealed class MigrationCommandService
|
||||
FailOnChecksumMismatch = true
|
||||
};
|
||||
|
||||
return runner.RunFromAssemblyAsync(module.MigrationsAssembly, module.ResourcePrefix, options, cancellationToken);
|
||||
if (appliedMigrations.Count == 0)
|
||||
{
|
||||
var result = await RunConsolidatedAsync(
|
||||
module,
|
||||
connectionString,
|
||||
consolidatedArtifact,
|
||||
options,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (result.Success && !options.DryRun)
|
||||
{
|
||||
await BackfillLegacyHistoryAsync(
|
||||
module,
|
||||
connectionString,
|
||||
consolidatedArtifact.SourceMigrations,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
if (hasConsolidatedApplied && consolidatedInSync && missingLegacyMigrations.Count > 0 && !options.DryRun)
|
||||
{
|
||||
await BackfillLegacyHistoryAsync(
|
||||
module,
|
||||
connectionString,
|
||||
missingLegacyMigrations,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return await RunAcrossSourcesAsync(module, connectionString, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<MigrationStatus> GetStatusAsync(
|
||||
@@ -52,27 +101,296 @@ internal sealed class MigrationCommandService
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var connectionString = ResolveConnectionString(module, connectionOverride);
|
||||
var consolidatedArtifact = MigrationModuleConsolidation.Build(module);
|
||||
var runner = CreateRunner(module, connectionString);
|
||||
var appliedMigrations = await runner
|
||||
.GetAppliedMigrationInfoAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var hasConsolidatedApplied = appliedMigrations.Any(migration =>
|
||||
string.Equals(migration.Name, consolidatedArtifact.MigrationName, StringComparison.Ordinal));
|
||||
var consolidatedApplied = hasConsolidatedApplied
|
||||
? appliedMigrations.First(migration =>
|
||||
string.Equals(migration.Name, consolidatedArtifact.MigrationName, StringComparison.Ordinal))
|
||||
: (MigrationInfo?)null;
|
||||
var missingLegacyMigrations = GetMissingLegacyMigrations(consolidatedArtifact, appliedMigrations);
|
||||
var consolidatedInSync = IsConsolidatedInSync(consolidatedArtifact, consolidatedApplied);
|
||||
|
||||
if (appliedMigrations.Count == 0 || (hasConsolidatedApplied && consolidatedInSync && missingLegacyMigrations.Count > 0))
|
||||
{
|
||||
return BuildConsolidatedStatus(module, consolidatedArtifact, appliedMigrations, consolidatedApplied);
|
||||
}
|
||||
|
||||
var logger = _loggerFactory.CreateLogger($"migrationstatus.{module.Name}");
|
||||
var sources = module.Sources
|
||||
.Select(static source => new MigrationAssemblySource(source.MigrationsAssembly, source.ResourcePrefix))
|
||||
.ToArray();
|
||||
var statusService = new MigrationStatusService(
|
||||
connectionString,
|
||||
module.SchemaName,
|
||||
module.Name,
|
||||
module.MigrationsAssembly,
|
||||
sources,
|
||||
logger);
|
||||
|
||||
return await statusService.GetStatusAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<string>> VerifyAsync(
|
||||
public async Task<IReadOnlyList<string>> VerifyAsync(
|
||||
MigrationModuleInfo module,
|
||||
string? connectionOverride,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var connectionString = ResolveConnectionString(module, connectionOverride);
|
||||
var consolidatedArtifact = MigrationModuleConsolidation.Build(module);
|
||||
var runner = CreateRunner(module, connectionString);
|
||||
return runner.ValidateChecksumsAsync(module.MigrationsAssembly, module.ResourcePrefix, cancellationToken);
|
||||
var appliedMigrations = await runner
|
||||
.GetAppliedMigrationInfoAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var hasConsolidatedApplied = appliedMigrations.Any(migration =>
|
||||
string.Equals(migration.Name, consolidatedArtifact.MigrationName, StringComparison.Ordinal));
|
||||
var consolidatedApplied = hasConsolidatedApplied
|
||||
? appliedMigrations.First(migration =>
|
||||
string.Equals(migration.Name, consolidatedArtifact.MigrationName, StringComparison.Ordinal))
|
||||
: (MigrationInfo?)null;
|
||||
var missingLegacyMigrations = GetMissingLegacyMigrations(consolidatedArtifact, appliedMigrations);
|
||||
var consolidatedInSync = IsConsolidatedInSync(consolidatedArtifact, consolidatedApplied);
|
||||
|
||||
if (appliedMigrations.Count > 0 && hasConsolidatedApplied && consolidatedInSync && missingLegacyMigrations.Count > 0)
|
||||
{
|
||||
return ValidateConsolidatedChecksum(consolidatedArtifact, consolidatedApplied!.Value);
|
||||
}
|
||||
|
||||
var errors = new HashSet<string>(StringComparer.Ordinal);
|
||||
if (hasConsolidatedApplied)
|
||||
{
|
||||
foreach (var error in ValidateConsolidatedChecksum(consolidatedArtifact, consolidatedApplied!.Value))
|
||||
{
|
||||
errors.Add(error);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var source in module.Sources)
|
||||
{
|
||||
var sourceErrors = await runner
|
||||
.ValidateChecksumsAsync(source.MigrationsAssembly, source.ResourcePrefix, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
foreach (var error in sourceErrors)
|
||||
{
|
||||
errors.Add(error);
|
||||
}
|
||||
}
|
||||
|
||||
return errors.OrderBy(static error => error, StringComparer.Ordinal).ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ValidateConsolidatedChecksum(
|
||||
MigrationModuleConsolidatedArtifact artifact,
|
||||
MigrationInfo appliedMigration)
|
||||
{
|
||||
if (string.Equals(appliedMigration.Checksum, artifact.Checksum, StringComparison.Ordinal))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return
|
||||
[
|
||||
$"Checksum mismatch for '{artifact.MigrationName}': expected '{artifact.Checksum[..16]}...', found '{appliedMigration.Checksum[..16]}...'"
|
||||
];
|
||||
}
|
||||
|
||||
private async Task<MigrationResult> RunConsolidatedAsync(
|
||||
MigrationModuleInfo module,
|
||||
string connectionString,
|
||||
MigrationModuleConsolidatedArtifact consolidatedArtifact,
|
||||
MigrationRunOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tempRoot = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"stellaops-migrations",
|
||||
Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempRoot);
|
||||
var migrationPath = Path.Combine(tempRoot, consolidatedArtifact.MigrationName);
|
||||
|
||||
await File.WriteAllTextAsync(migrationPath, consolidatedArtifact.Script, cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var runner = CreateRunner(module, connectionString);
|
||||
return await runner.RunAsync(tempRoot, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(tempRoot);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task BackfillLegacyHistoryAsync(
|
||||
MigrationModuleInfo module,
|
||||
string connectionString,
|
||||
IReadOnlyList<MigrationModuleConsolidatedSourceMigration> migrationsToBackfill,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (migrationsToBackfill.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var connection = new NpgsqlConnection(connectionString);
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var schemaName = QuoteIdentifier(module.SchemaName);
|
||||
var sql = $"""
|
||||
INSERT INTO {schemaName}.schema_migrations (migration_name, category, checksum, applied_by, duration_ms)
|
||||
VALUES (@name, @category, @checksum, @appliedBy, @durationMs)
|
||||
ON CONFLICT (migration_name) DO NOTHING;
|
||||
""";
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
var nameParam = command.Parameters.Add("name", NpgsqlTypes.NpgsqlDbType.Text);
|
||||
var categoryParam = command.Parameters.Add("category", NpgsqlTypes.NpgsqlDbType.Text);
|
||||
var checksumParam = command.Parameters.Add("checksum", NpgsqlTypes.NpgsqlDbType.Text);
|
||||
var appliedByParam = command.Parameters.Add("appliedBy", NpgsqlTypes.NpgsqlDbType.Text);
|
||||
var durationParam = command.Parameters.Add("durationMs", NpgsqlTypes.NpgsqlDbType.Integer);
|
||||
|
||||
foreach (var migration in migrationsToBackfill)
|
||||
{
|
||||
nameParam.Value = migration.Name;
|
||||
categoryParam.Value = migration.Category.ToString().ToLowerInvariant();
|
||||
checksumParam.Value = migration.Checksum;
|
||||
appliedByParam.Value = Environment.MachineName;
|
||||
durationParam.Value = 0;
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static MigrationStatus BuildConsolidatedStatus(
|
||||
MigrationModuleInfo module,
|
||||
MigrationModuleConsolidatedArtifact consolidatedArtifact,
|
||||
IReadOnlyList<MigrationInfo> appliedMigrations,
|
||||
MigrationInfo? consolidatedApplied)
|
||||
{
|
||||
var pending = consolidatedApplied is null
|
||||
? new[] { new PendingMigrationInfo(consolidatedArtifact.MigrationName, MigrationCategory.Release) }
|
||||
: [];
|
||||
var checksumErrors = consolidatedApplied is null
|
||||
? []
|
||||
: ValidateConsolidatedChecksum(consolidatedArtifact, consolidatedApplied.Value);
|
||||
var lastApplied = appliedMigrations
|
||||
.OrderByDescending(static migration => migration.AppliedAt)
|
||||
.FirstOrDefault();
|
||||
|
||||
return new MigrationStatus
|
||||
{
|
||||
ModuleName = module.Name,
|
||||
SchemaName = module.SchemaName,
|
||||
AppliedCount = appliedMigrations.Count,
|
||||
PendingStartupCount = 0,
|
||||
PendingReleaseCount = pending.Length,
|
||||
LastAppliedMigration = lastApplied.Name,
|
||||
LastAppliedAt = lastApplied.Name is null ? null : lastApplied.AppliedAt,
|
||||
PendingMigrations = pending,
|
||||
ChecksumErrors = checksumErrors
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<MigrationResult> RunAcrossSourcesAsync(
|
||||
MigrationModuleInfo module,
|
||||
string connectionString,
|
||||
MigrationRunOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var results = new List<MigrationResult>(module.Sources.Count);
|
||||
foreach (var source in module.Sources)
|
||||
{
|
||||
var runner = CreateRunner(module, connectionString);
|
||||
var result = await runner
|
||||
.RunFromAssemblyAsync(source.MigrationsAssembly, source.ResourcePrefix, options, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
results.Add(result);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return AggregateRunResults(results);
|
||||
}
|
||||
|
||||
private static MigrationResult AggregateRunResults(IReadOnlyList<MigrationResult> results)
|
||||
{
|
||||
if (results.Count == 0)
|
||||
{
|
||||
return MigrationResult.Successful(0, 0, 0, 0, []);
|
||||
}
|
||||
|
||||
if (results.Count == 1)
|
||||
{
|
||||
return results[0];
|
||||
}
|
||||
|
||||
var firstFailure = results.FirstOrDefault(static result => !result.Success);
|
||||
return new MigrationResult
|
||||
{
|
||||
Success = firstFailure is null,
|
||||
AppliedCount = results.Sum(static result => result.AppliedCount),
|
||||
SkippedCount = results.Max(static result => result.SkippedCount),
|
||||
FilteredCount = results.Sum(static result => result.FilteredCount),
|
||||
DurationMs = results.Sum(static result => result.DurationMs),
|
||||
AppliedMigrations = results.SelectMany(static result => result.AppliedMigrations).ToArray(),
|
||||
ChecksumErrors = results
|
||||
.SelectMany(static result => result.ChecksumErrors)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(static error => error, StringComparer.Ordinal)
|
||||
.ToArray(),
|
||||
ErrorMessage = firstFailure?.ErrorMessage
|
||||
};
|
||||
}
|
||||
|
||||
private static string QuoteIdentifier(string identifier)
|
||||
{
|
||||
var escaped = identifier.Replace("\"", "\"\"", StringComparison.Ordinal);
|
||||
return $"\"{escaped}\"";
|
||||
}
|
||||
|
||||
private static void TryDeleteDirectory(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(path))
|
||||
{
|
||||
Directory.Delete(path, recursive: true);
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<MigrationModuleConsolidatedSourceMigration> GetMissingLegacyMigrations(
|
||||
MigrationModuleConsolidatedArtifact consolidatedArtifact,
|
||||
IReadOnlyList<MigrationInfo> appliedMigrations)
|
||||
{
|
||||
var appliedNames = appliedMigrations
|
||||
.Select(static migration => migration.Name)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
return consolidatedArtifact.SourceMigrations
|
||||
.Where(migration => !appliedNames.Contains(migration.Name))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
internal static bool IsConsolidatedInSync(
|
||||
MigrationModuleConsolidatedArtifact consolidatedArtifact,
|
||||
MigrationInfo? consolidatedApplied) =>
|
||||
consolidatedApplied is not null &&
|
||||
string.Equals(consolidatedApplied.Value.Checksum, consolidatedArtifact.Checksum, StringComparison.Ordinal);
|
||||
|
||||
private MigrationRunner CreateRunner(MigrationModuleInfo module, string connectionString) =>
|
||||
new(connectionString, module.SchemaName, module.Name, _loggerFactory.CreateLogger($"migration.{module.Name}"));
|
||||
|
||||
|
||||
@@ -239,6 +239,12 @@ internal sealed class AdvisoryKnowledgeOpenDoctorActionModel
|
||||
public bool CanRun { get; init; } = true;
|
||||
|
||||
public string RunCommand { get; init; } = string.Empty;
|
||||
|
||||
public string Control { get; init; } = "safe";
|
||||
|
||||
public bool RequiresConfirmation { get; init; }
|
||||
|
||||
public bool IsDestructive { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryKnowledgeSearchDiagnosticsModel
|
||||
|
||||
@@ -5,8 +5,10 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| SPRINT_20260222_051-AKS-CLI | DONE | Added `stella advisoryai sources prepare` to generate deterministic AKS seed artifacts: docs manifest, aggregated OpenAPI export target, and enriched doctor controls projection JSON merged from configured seed + discovered `DoctorEngine` checks. |
|
||||
| SPRINT_20260222_051-MGC-04-W1 | DONE | Expanded migration registry coverage to `AirGap`, `Scanner`, `TimelineIndexer`, and `Platform` (10 total modules); moved registry ownership to `StellaOps.Platform.Database` and rewired CLI migration commands to consume the platform-owned registry. |
|
||||
| SPRINT_20260222_051-MGC-04-W1-PLUGINS | DONE | CLI migration commands now consume plugin auto-discovered module catalog from `StellaOps.Platform.Database` (`IMigrationModulePlugin`) instead of hardcoded module registration. |
|
||||
| SPRINT_20260222_051-MGC-04-W1-SOURCES | DONE | CLI migration run/status/verify now executes against per-service plugin source sets and uses synthesized per-plugin consolidated migration on empty history with legacy history backfill for update compatibility; partial backfill states are auto-healed before per-source execution. |
|
||||
| SPRINT_20260221_043-CLI-SEED-001 | DONE | Sprint `docs/implplan/SPRINT_20260221_043_DOCS_setup_seed_error_handling_stabilization.md`: harden seed/migration first-run flow and fix dry-run migration reporting semantics. |
|
||||
| AUDIT-0137-M | DONE | Revalidated 2026-01-06. |
|
||||
| AUDIT-0137-T | DONE | Revalidated 2026-01-06. |
|
||||
|
||||
@@ -7,7 +7,9 @@ using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Moq;
|
||||
using StellaOps.Cli.Commands;
|
||||
using StellaOps.Cli.Tests.Testing;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
using StellaOps.Cli.Services.Models.AdvisoryAi;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
@@ -150,6 +152,113 @@ public sealed class KnowledgeSearchCommandGroupTests
|
||||
backend.VerifyAll();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdvisoryAiSourcesPrepareCommand_GeneratesSeedArtifacts()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
var docsDirectory = Path.Combine(temp.Path, "docs", "runbooks");
|
||||
Directory.CreateDirectory(docsDirectory);
|
||||
var markdownPath = Path.Combine(docsDirectory, "network.md");
|
||||
await File.WriteAllTextAsync(markdownPath, "# Network\n## Retry\nUse retries.");
|
||||
|
||||
var docsAllowListPath = Path.Combine(temp.Path, "knowledge-docs-allowlist.json");
|
||||
await File.WriteAllTextAsync(
|
||||
docsAllowListPath,
|
||||
"""
|
||||
{
|
||||
"include": [
|
||||
"docs"
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
var doctorSeedPath = Path.Combine(temp.Path, "doctor-search-seed.json");
|
||||
await File.WriteAllTextAsync(
|
||||
doctorSeedPath,
|
||||
"""
|
||||
[
|
||||
{
|
||||
"checkCode": "check.core.db.connectivity",
|
||||
"title": "PostgreSQL connectivity",
|
||||
"severity": "high",
|
||||
"description": "Connectivity issue.",
|
||||
"remediation": "Fix database connection settings.",
|
||||
"runCommand": "stella doctor run --check check.core.db.connectivity",
|
||||
"symptoms": ["connection refused"],
|
||||
"tags": ["doctor"],
|
||||
"references": ["docs/INSTALL_GUIDE.md"]
|
||||
}
|
||||
]
|
||||
""");
|
||||
|
||||
var docsManifestPath = Path.Combine(temp.Path, "knowledge-docs-manifest.json");
|
||||
var openApiOutputPath = Path.Combine(temp.Path, "openapi.aggregate.json");
|
||||
var doctorControlsPath = Path.Combine(temp.Path, "doctor-search-controls.json");
|
||||
|
||||
ApiSpecDownloadRequest? capturedApiRequest = null;
|
||||
var backend = new Mock<IBackendOperationsClient>(MockBehavior.Strict);
|
||||
backend
|
||||
.Setup(client => client.DownloadApiSpecAsync(
|
||||
It.IsAny<ApiSpecDownloadRequest>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<ApiSpecDownloadRequest, CancellationToken>((request, _) => capturedApiRequest = request)
|
||||
.ReturnsAsync(new ApiSpecDownloadResult
|
||||
{
|
||||
Success = true,
|
||||
Path = openApiOutputPath,
|
||||
FromCache = false,
|
||||
Checksum = "deadbeef",
|
||||
ChecksumAlgorithm = "sha256"
|
||||
});
|
||||
|
||||
using var services = new ServiceCollection()
|
||||
.AddSingleton(backend.Object)
|
||||
.BuildServiceProvider();
|
||||
|
||||
var root = new RootCommand();
|
||||
root.Add(KnowledgeSearchCommandGroup.BuildAdvisoryAiCommand(
|
||||
services,
|
||||
new Option<bool>("--verbose"),
|
||||
CancellationToken.None));
|
||||
|
||||
var invocation = await InvokeWithCapturedConsoleAsync(
|
||||
root,
|
||||
$"advisoryai sources prepare --repo-root \"{temp.Path}\" --docs-allowlist \"{docsAllowListPath}\" --docs-manifest-output \"{docsManifestPath}\" --openapi-output \"{openApiOutputPath}\" --doctor-seed \"{doctorSeedPath}\" --doctor-controls-output \"{doctorControlsPath}\" --json");
|
||||
|
||||
Assert.Equal(0, invocation.ExitCode);
|
||||
Assert.NotNull(capturedApiRequest);
|
||||
Assert.Equal(openApiOutputPath, capturedApiRequest!.OutputPath);
|
||||
Assert.Equal("openapi-json", capturedApiRequest.Format);
|
||||
Assert.Null(capturedApiRequest.Service);
|
||||
Assert.True(File.Exists(docsManifestPath));
|
||||
Assert.True(File.Exists(doctorControlsPath));
|
||||
|
||||
using var manifest = JsonDocument.Parse(await File.ReadAllTextAsync(docsManifestPath));
|
||||
var include = manifest.RootElement.GetProperty("include");
|
||||
Assert.True(include.GetArrayLength() >= 1);
|
||||
Assert.Contains(include.EnumerateArray(), element => element.GetString() == "docs");
|
||||
var documents = manifest.RootElement.GetProperty("documents");
|
||||
Assert.Equal(1, documents.GetArrayLength());
|
||||
Assert.Equal("docs/runbooks/network.md", documents[0].GetProperty("path").GetString());
|
||||
|
||||
using var controls = JsonDocument.Parse(await File.ReadAllTextAsync(doctorControlsPath));
|
||||
Assert.Equal(1, controls.RootElement.GetArrayLength());
|
||||
Assert.Equal("check.core.db.connectivity", controls.RootElement[0].GetProperty("checkCode").GetString());
|
||||
Assert.Equal("manual", controls.RootElement[0].GetProperty("control").GetString());
|
||||
Assert.True(controls.RootElement[0].GetProperty("requiresConfirmation").GetBoolean());
|
||||
Assert.Equal("PostgreSQL connectivity", controls.RootElement[0].GetProperty("title").GetString());
|
||||
Assert.Equal("high", controls.RootElement[0].GetProperty("severity").GetString());
|
||||
Assert.Equal("stella doctor run --check check.core.db.connectivity", controls.RootElement[0].GetProperty("runCommand").GetString());
|
||||
Assert.Contains(
|
||||
controls.RootElement[0].GetProperty("tags").EnumerateArray(),
|
||||
static tag => string.Equals(tag.GetString(), "doctor", StringComparison.Ordinal));
|
||||
Assert.Contains(
|
||||
controls.RootElement[0].GetProperty("references").EnumerateArray(),
|
||||
static reference => string.Equals(reference.GetString(), "docs/INSTALL_GUIDE.md", StringComparison.Ordinal));
|
||||
|
||||
backend.VerifyAll();
|
||||
}
|
||||
|
||||
private static AdvisoryKnowledgeSearchResponseModel CreateSearchResponse()
|
||||
{
|
||||
return new AdvisoryKnowledgeSearchResponseModel
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Infrastructure.Postgres.Migrations;
|
||||
using StellaOps.Platform.Database;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Commands;
|
||||
|
||||
public sealed class MigrationCommandServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetMissingLegacyMigrations_WhenConsolidatedOnlyApplied_ReturnsAllLegacyMigrations()
|
||||
{
|
||||
var module = MigrationModuleRegistry.FindModule("Scanner");
|
||||
Assert.NotNull(module);
|
||||
|
||||
var artifact = MigrationModuleConsolidation.Build(module!);
|
||||
var applied = new[]
|
||||
{
|
||||
new MigrationInfo(artifact.MigrationName, DateTimeOffset.UtcNow, artifact.Checksum)
|
||||
};
|
||||
|
||||
var missing = MigrationCommandService.GetMissingLegacyMigrations(artifact, applied);
|
||||
|
||||
Assert.Equal(artifact.SourceMigrations.Count, missing.Count);
|
||||
Assert.Equal(
|
||||
artifact.SourceMigrations.Select(static migration => migration.Name),
|
||||
missing.Select(static migration => migration.Name));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMissingLegacyMigrations_WhenPartiallyBackfilled_ReturnsOnlyMissingMigrations()
|
||||
{
|
||||
var module = MigrationModuleRegistry.FindModule("Scanner");
|
||||
Assert.NotNull(module);
|
||||
|
||||
var artifact = MigrationModuleConsolidation.Build(module!);
|
||||
var backfilled = artifact.SourceMigrations.Take(2).ToArray();
|
||||
var applied = backfilled
|
||||
.Select(static migration => new MigrationInfo(migration.Name, DateTimeOffset.UtcNow, migration.Checksum))
|
||||
.Concat(new[] { new MigrationInfo(artifact.MigrationName, DateTimeOffset.UtcNow, artifact.Checksum) })
|
||||
.ToArray();
|
||||
|
||||
var missing = MigrationCommandService.GetMissingLegacyMigrations(artifact, applied);
|
||||
|
||||
Assert.Equal(artifact.SourceMigrations.Count - backfilled.Length, missing.Count);
|
||||
Assert.DoesNotContain(missing, migration => backfilled.Any(existing => existing.Name == migration.Name));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsConsolidatedInSync_WhenChecksumsMatch_ReturnsTrue()
|
||||
{
|
||||
var module = MigrationModuleRegistry.FindModule("Platform");
|
||||
Assert.NotNull(module);
|
||||
|
||||
var artifact = MigrationModuleConsolidation.Build(module!);
|
||||
var applied = new MigrationInfo(artifact.MigrationName, DateTimeOffset.UtcNow, artifact.Checksum);
|
||||
|
||||
var inSync = MigrationCommandService.IsConsolidatedInSync(artifact, applied);
|
||||
|
||||
Assert.True(inSync);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsConsolidatedInSync_WhenChecksumsDiffer_ReturnsFalse()
|
||||
{
|
||||
var module = MigrationModuleRegistry.FindModule("Platform");
|
||||
Assert.NotNull(module);
|
||||
|
||||
var artifact = MigrationModuleConsolidation.Build(module!);
|
||||
var applied = new MigrationInfo(artifact.MigrationName, DateTimeOffset.UtcNow, "deadbeef");
|
||||
|
||||
var inSync = MigrationCommandService.IsConsolidatedInSync(artifact, applied);
|
||||
|
||||
Assert.False(inSync);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsConsolidatedInSync_WhenConsolidatedNotApplied_ReturnsFalse()
|
||||
{
|
||||
var module = MigrationModuleRegistry.FindModule("Platform");
|
||||
Assert.NotNull(module);
|
||||
|
||||
var artifact = MigrationModuleConsolidation.Build(module!);
|
||||
|
||||
var inSync = MigrationCommandService.IsConsolidatedInSync(artifact, null);
|
||||
|
||||
Assert.False(inSync);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using StellaOps.Platform.Database;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Commands;
|
||||
|
||||
public sealed class MigrationModuleConsolidationTests
|
||||
{
|
||||
[Fact]
|
||||
public void Build_ForEveryRegisteredModule_ProducesOneUniqueConsolidatedMigration()
|
||||
{
|
||||
var modules = MigrationModuleRegistry.GetModules(null).ToArray();
|
||||
var migrationNames = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var module in modules)
|
||||
{
|
||||
var artifact = MigrationModuleConsolidation.Build(module);
|
||||
Assert.NotNull(artifact);
|
||||
Assert.NotEmpty(artifact.Script);
|
||||
Assert.NotEmpty(artifact.Checksum);
|
||||
Assert.NotEmpty(artifact.SourceMigrations);
|
||||
Assert.True(
|
||||
migrationNames.Add(artifact.MigrationName),
|
||||
$"Duplicate consolidated migration name '{artifact.MigrationName}' for module '{module.Name}'.");
|
||||
}
|
||||
|
||||
Assert.Equal(modules.Length, migrationNames.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ForScanner_ProducesSingleConsolidatedMigration()
|
||||
{
|
||||
var scanner = MigrationModuleRegistry.FindModule("Scanner");
|
||||
Assert.NotNull(scanner);
|
||||
|
||||
var artifact = MigrationModuleConsolidation.Build(scanner!);
|
||||
Assert.Equal("100_consolidated_scanner.sql", artifact.MigrationName);
|
||||
Assert.Equal(36, artifact.SourceMigrations.Count);
|
||||
Assert.Contains(
|
||||
artifact.SourceMigrations,
|
||||
static migration => string.Equals(
|
||||
migration.Name,
|
||||
"022a_runtime_observations_compat.sql",
|
||||
StringComparison.Ordinal));
|
||||
Assert.Contains(
|
||||
artifact.SourceMigrations,
|
||||
static migration => string.Equals(
|
||||
migration.Name,
|
||||
"V3700_001__triage_schema.sql",
|
||||
StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_IsDeterministic_ForSameModule()
|
||||
{
|
||||
var module = MigrationModuleRegistry.FindModule("Platform");
|
||||
Assert.NotNull(module);
|
||||
|
||||
var first = MigrationModuleConsolidation.Build(module!);
|
||||
var second = MigrationModuleConsolidation.Build(module!);
|
||||
|
||||
Assert.Equal(first.MigrationName, second.MigrationName);
|
||||
Assert.Equal(first.Checksum, second.Checksum);
|
||||
Assert.Equal(first.Script, second.Script);
|
||||
Assert.Equal(first.SourceMigrations.Select(static migration => migration.Name), second.SourceMigrations.Select(static migration => migration.Name));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using StellaOps.Platform.Database;
|
||||
using Xunit;
|
||||
@@ -10,18 +11,35 @@ public class MigrationModuleRegistryTests
|
||||
public void Modules_Populated_With_All_Postgres_Modules()
|
||||
{
|
||||
var modules = MigrationModuleRegistry.Modules;
|
||||
Assert.Equal(10, modules.Count);
|
||||
Assert.True(modules.Count >= 20, $"Expected at least 20 registered modules, found {modules.Count}");
|
||||
Assert.Contains(modules, m => m.Name == "AdvisoryAI" && m.SchemaName == "advisoryai");
|
||||
Assert.Contains(modules, m => m.Name == "AirGap" && m.SchemaName == "airgap");
|
||||
Assert.Contains(modules, m => m.Name == "Authority" && m.SchemaName == "authority");
|
||||
Assert.Contains(modules, m => m.Name == "Eventing" && m.SchemaName == "timeline");
|
||||
Assert.Contains(modules, m => m.Name == "Evidence" && m.SchemaName == "evidence");
|
||||
Assert.Contains(modules, m => m.Name == "Scheduler" && m.SchemaName == "scheduler");
|
||||
Assert.Contains(modules, m => m.Name == "Concelier" && m.SchemaName == "vuln");
|
||||
Assert.Contains(modules, m => m.Name == "Policy" && m.SchemaName == "policy");
|
||||
Assert.Contains(modules, m => m.Name == "Notify" && m.SchemaName == "notify");
|
||||
Assert.Contains(modules, m => m.Name == "Excititor" && m.SchemaName == "vex");
|
||||
Assert.Contains(modules, m => m.Name == "PluginRegistry" && m.SchemaName == "platform");
|
||||
Assert.Contains(modules, m => m.Name == "Platform" && m.SchemaName == "release");
|
||||
Assert.Contains(modules, m => m.Name == "Scanner" && m.SchemaName == "scanner");
|
||||
var scanner = Assert.Single(modules, static module => module.Name == "Scanner" && module.SchemaName == "scanner");
|
||||
Assert.Equal(2, scanner.Sources.Count);
|
||||
Assert.Contains(
|
||||
scanner.Sources,
|
||||
static source => string.Equals(
|
||||
source.ResourcePrefix,
|
||||
"StellaOps.Scanner.Triage.Migrations",
|
||||
StringComparison.Ordinal));
|
||||
Assert.Contains(modules, m => m.Name == "TimelineIndexer" && m.SchemaName == "timeline");
|
||||
Assert.Equal(10, MigrationModuleRegistry.ModuleNames.Count());
|
||||
Assert.Contains(modules, m => m.Name == "VexHub" && m.SchemaName == "vexhub");
|
||||
Assert.Contains(modules, m => m.Name == "Remediation" && m.SchemaName == "remediation");
|
||||
Assert.Contains(modules, m => m.Name == "VexLens" && m.SchemaName == "vexlens");
|
||||
Assert.Contains(modules, m => m.Name == "SbomLineage" && m.SchemaName == "sbom");
|
||||
Assert.Contains(modules, m => m.Name == "ReachGraph" && m.SchemaName == "reachgraph");
|
||||
Assert.Contains(modules, m => m.Name == "Verdict" && m.SchemaName == "stellaops");
|
||||
Assert.True(MigrationModuleRegistry.ModuleNames.Count() >= 20);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -60,6 +78,6 @@ public class MigrationModuleRegistryTests
|
||||
public void GetModules_All_Returns_All()
|
||||
{
|
||||
var result = MigrationModuleRegistry.GetModules(null);
|
||||
Assert.Equal(10, result.Count());
|
||||
Assert.True(result.Count() >= 20);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
using System.CommandLine;
|
||||
using System.Net;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Moq.Protected;
|
||||
using StellaOps.Cli.Commands.Budget;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Commands;
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public sealed class RiskBudgetCommandTenantHeaderTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task BudgetStatus_AddsTenantHeader_FromTenantOption()
|
||||
{
|
||||
// Arrange
|
||||
var (services, handlerMock) = CreateServices();
|
||||
HttpRequestMessage? capturedRequest = null;
|
||||
handlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.Is<HttpRequestMessage>(request =>
|
||||
request.Method == HttpMethod.Get &&
|
||||
request.RequestUri != null &&
|
||||
request.RequestUri.ToString().Contains("/api/v1/policy/risk-budget/status/", StringComparison.Ordinal)),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((request, _) => capturedRequest = request)
|
||||
.ReturnsAsync(CreateStatusResponse());
|
||||
|
||||
var command = RiskBudgetCommandGroup.BuildBudgetCommand(services, new Option<bool>("--verbose"), CancellationToken.None);
|
||||
var root = new RootCommand { command };
|
||||
using var writer = new StringWriter();
|
||||
var originalOut = Console.Out;
|
||||
int exitCode;
|
||||
try
|
||||
{
|
||||
Console.SetOut(writer);
|
||||
exitCode = await root.Parse("budget status --service svc-a --output json --tenant Tenant-Bravo").InvokeAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(originalOut);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, exitCode);
|
||||
Assert.NotNull(capturedRequest);
|
||||
Assert.True(capturedRequest.Headers.TryGetValues("X-Tenant-Id", out var tenantValues));
|
||||
Assert.Equal("tenant-bravo", Assert.Single(tenantValues));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BudgetStatus_AddsTenantHeader_FromEnvironmentFallback()
|
||||
{
|
||||
// Arrange
|
||||
var originalTenant = Environment.GetEnvironmentVariable("STELLAOPS_TENANT");
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_TENANT", "Tenant-Env");
|
||||
|
||||
var (services, handlerMock) = CreateServices();
|
||||
HttpRequestMessage? capturedRequest = null;
|
||||
handlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.Is<HttpRequestMessage>(request =>
|
||||
request.Method == HttpMethod.Get &&
|
||||
request.RequestUri != null &&
|
||||
request.RequestUri.ToString().Contains("/api/v1/policy/risk-budget/status/", StringComparison.Ordinal)),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((request, _) => capturedRequest = request)
|
||||
.ReturnsAsync(CreateStatusResponse());
|
||||
|
||||
var command = RiskBudgetCommandGroup.BuildBudgetCommand(services, new Option<bool>("--verbose"), CancellationToken.None);
|
||||
var root = new RootCommand { command };
|
||||
using var writer = new StringWriter();
|
||||
var originalOut = Console.Out;
|
||||
int exitCode;
|
||||
try
|
||||
{
|
||||
Console.SetOut(writer);
|
||||
exitCode = await root.Parse("budget status --service svc-a --output json").InvokeAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(originalOut);
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_TENANT", originalTenant);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, exitCode);
|
||||
Assert.NotNull(capturedRequest);
|
||||
Assert.True(capturedRequest.Headers.TryGetValues("X-Tenant-Id", out var tenantValues));
|
||||
Assert.Equal("tenant-env", Assert.Single(tenantValues));
|
||||
}
|
||||
|
||||
private static (IServiceProvider Services, Mock<HttpMessageHandler> HandlerMock) CreateServices()
|
||||
{
|
||||
var handlerMock = new Mock<HttpMessageHandler>();
|
||||
var httpClient = new HttpClient(handlerMock.Object)
|
||||
{
|
||||
BaseAddress = new Uri("http://localhost:8080"),
|
||||
};
|
||||
|
||||
var factoryMock = new Mock<IHttpClientFactory>();
|
||||
factoryMock
|
||||
.Setup(factory => factory.CreateClient("PolicyApi"))
|
||||
.Returns(httpClient);
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton(factoryMock.Object);
|
||||
services.AddSingleton(NullLoggerFactory.Instance);
|
||||
return (services.BuildServiceProvider(), handlerMock);
|
||||
}
|
||||
|
||||
private static HttpResponseMessage CreateStatusResponse()
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(
|
||||
"""
|
||||
{
|
||||
"serviceId": "svc-a",
|
||||
"window": "2026-02",
|
||||
"tier": 1,
|
||||
"allocated": 100,
|
||||
"consumed": 15,
|
||||
"remaining": 85,
|
||||
"percentageUsed": 15.0,
|
||||
"status": "green"
|
||||
}
|
||||
""")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -372,6 +372,94 @@ public class UnknownsGreyQueueCommandTests
|
||||
Assert.Contains("id,package_id,package_version,band,score", output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnknownsList_AddsTenantHeader_FromTenantOption()
|
||||
{
|
||||
// Arrange
|
||||
HttpRequestMessage? capturedRequest = null;
|
||||
_httpHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.Is<HttpRequestMessage>(request =>
|
||||
request.Method == HttpMethod.Get &&
|
||||
request.RequestUri != null &&
|
||||
request.RequestUri.ToString().Contains("/api/v1/policy/unknowns", StringComparison.Ordinal)),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((request, _) => capturedRequest = request)
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("""{ "items": [], "totalCount": 0 }""")
|
||||
});
|
||||
|
||||
var command = UnknownsCommandGroup.BuildUnknownsCommand(_services, new Option<bool>("--verbose"), CancellationToken.None);
|
||||
var root = new RootCommand { command };
|
||||
using var writer = new StringWriter();
|
||||
var originalOut = Console.Out;
|
||||
int exitCode;
|
||||
try
|
||||
{
|
||||
Console.SetOut(writer);
|
||||
exitCode = await root.Parse("unknowns --tenant Tenant-Bravo list --format json").InvokeAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(originalOut);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, exitCode);
|
||||
Assert.NotNull(capturedRequest);
|
||||
Assert.True(capturedRequest.Headers.TryGetValues("X-Tenant-Id", out var tenantValues));
|
||||
Assert.Equal("tenant-bravo", Assert.Single(tenantValues));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnknownsList_AddsTenantHeader_FromEnvironmentFallback()
|
||||
{
|
||||
// Arrange
|
||||
var originalTenant = Environment.GetEnvironmentVariable("STELLAOPS_TENANT");
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_TENANT", "Tenant-Env");
|
||||
|
||||
HttpRequestMessage? capturedRequest = null;
|
||||
_httpHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.Is<HttpRequestMessage>(request =>
|
||||
request.Method == HttpMethod.Get &&
|
||||
request.RequestUri != null &&
|
||||
request.RequestUri.ToString().Contains("/api/v1/policy/unknowns", StringComparison.Ordinal)),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((request, _) => capturedRequest = request)
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("""{ "items": [], "totalCount": 0 }""")
|
||||
});
|
||||
|
||||
var command = UnknownsCommandGroup.BuildUnknownsCommand(_services, new Option<bool>("--verbose"), CancellationToken.None);
|
||||
var root = new RootCommand { command };
|
||||
using var writer = new StringWriter();
|
||||
var originalOut = Console.Out;
|
||||
int exitCode;
|
||||
try
|
||||
{
|
||||
Console.SetOut(writer);
|
||||
exitCode = await root.Parse("unknowns list --format json").InvokeAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(originalOut);
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_TENANT", originalTenant);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, exitCode);
|
||||
Assert.NotNull(capturedRequest);
|
||||
Assert.True(capturedRequest.Headers.TryGetValues("X-Tenant-Id", out var tenantValues));
|
||||
Assert.Equal("tenant-env", Assert.Single(tenantValues));
|
||||
}
|
||||
|
||||
private void SetupPolicyUnknownsResponse(string json)
|
||||
{
|
||||
_httpHandlerMock
|
||||
|
||||
@@ -5,7 +5,9 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| SPRINT_20260222_051-AKS-CLI-TESTS | DONE | Added AKS CLI source-preparation command coverage (`AdvisoryAiSourcesPrepareCommand_GeneratesSeedArtifacts`) including enriched doctor control metadata assertions, and revalidated knowledge-search command group tests (4/4 on 2026-02-22). |
|
||||
| SPRINT_20260222_051-MGC-04-W1-TESTS | DONE | Updated migration registry/system command tests for platform-owned 10-module coverage and validated with `dotnet test` (1182 passed on 2026-02-22). |
|
||||
| SPRINT_20260222_051-MGC-04-W1-SOURCES-TESTS | DONE | Extended migration tests to assert per-service source-set flattening metadata, deterministic synthesized consolidated artifact generation (including unique consolidated artifact per registered plugin), and partial-backfill missing-legacy detection behavior; revalidated with `dotnet test` (`1194` passed on 2026-02-22). |
|
||||
| AUDIT-0143-M | DONE | Revalidated 2026-01-06. |
|
||||
| AUDIT-0143-T | DONE | Revalidated 2026-01-06. |
|
||||
| AUDIT-0143-A | DONE | Waived (test project; revalidated 2026-01-06). |
|
||||
|
||||
@@ -12,6 +12,14 @@ namespace StellaOps.Policy.Unknowns.Models
|
||||
public sealed record UnknownPlaceholder;
|
||||
}
|
||||
|
||||
namespace StellaOps.Cli.Services
|
||||
{
|
||||
internal static class TenantProfileStore
|
||||
{
|
||||
public static string? GetEffectiveTenant(string? commandLineTenant) => commandLineTenant;
|
||||
}
|
||||
}
|
||||
|
||||
namespace System.CommandLine
|
||||
{
|
||||
// Compatibility shims for the System.CommandLine API shape expected by UnknownsCommandGroup.
|
||||
|
||||
Reference in New Issue
Block a user