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:
master
2026-02-23 15:30:50 +02:00
parent bd8fee6ed8
commit e746577380
1424 changed files with 81225 additions and 25251 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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