Merge branch 'main' of https://git.stella-ops.org/stella-ops.org/git.stella-ops.org
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
using System.CommandLine;
|
||||
using StellaOps.Policy;
|
||||
|
||||
namespace StellaOps.Policy.Tools;
|
||||
|
||||
public static class PolicyDslValidatorApp
|
||||
{
|
||||
public static Task<int> RunAsync(string[] args)
|
||||
{
|
||||
var runner = new PolicyValidationRunner(new PolicyValidationCli());
|
||||
return RunAsync(args, runner);
|
||||
}
|
||||
|
||||
public static async Task<int> RunAsync(string[] args, IPolicyValidationRunner runner)
|
||||
{
|
||||
if (runner is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(runner));
|
||||
}
|
||||
|
||||
var root = PolicyDslValidatorCommand.Build(runner);
|
||||
var parseResult = root.Parse(args, new ParserConfiguration());
|
||||
var invocationConfiguration = new InvocationConfiguration();
|
||||
|
||||
if (parseResult.Errors.Count > 0)
|
||||
{
|
||||
await parseResult.InvokeAsync(invocationConfiguration, CancellationToken.None);
|
||||
return 64; // EX_USAGE
|
||||
}
|
||||
|
||||
return await parseResult.InvokeAsync(invocationConfiguration, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.CommandLine;
|
||||
using StellaOps.Policy;
|
||||
|
||||
namespace StellaOps.Policy.Tools;
|
||||
|
||||
public static class PolicyDslValidatorCommand
|
||||
{
|
||||
public static RootCommand Build(IPolicyValidationRunner runner, CancellationToken? cancellationTokenOverride = null)
|
||||
{
|
||||
var root = new RootCommand("Validate StellaOps policy DSL files.");
|
||||
Configure(root, runner, cancellationTokenOverride);
|
||||
return root;
|
||||
}
|
||||
|
||||
public static Command BuildCommand(IPolicyValidationRunner runner, CancellationToken? cancellationTokenOverride = null)
|
||||
{
|
||||
var command = new Command("policy-dsl-validate", "Validate StellaOps policy DSL files.");
|
||||
Configure(command, runner, cancellationTokenOverride);
|
||||
return command;
|
||||
}
|
||||
|
||||
private static void Configure(Command command, IPolicyValidationRunner runner, CancellationToken? cancellationTokenOverride)
|
||||
{
|
||||
var inputs = new Argument<List<string>>("inputs")
|
||||
{
|
||||
Description = "Policy files, directories, or globs to validate.",
|
||||
Arity = ArgumentArity.OneOrMore
|
||||
};
|
||||
|
||||
var strict = new Option<bool>("--strict", new[] { "-s" })
|
||||
{
|
||||
Description = "Treat warnings as errors."
|
||||
};
|
||||
|
||||
var outputJson = new Option<bool>("--json", new[] { "-j" })
|
||||
{
|
||||
Description = "Emit machine-readable JSON output."
|
||||
};
|
||||
|
||||
command.Add(inputs);
|
||||
command.Add(strict);
|
||||
command.Add(outputJson);
|
||||
|
||||
command.SetAction(async (parseResult, cancellationToken) =>
|
||||
{
|
||||
var options = new PolicyValidationCliOptions
|
||||
{
|
||||
Inputs = parseResult.GetValue(inputs) ?? new List<string>(),
|
||||
Strict = parseResult.GetValue(strict),
|
||||
OutputJson = parseResult.GetValue(outputJson),
|
||||
};
|
||||
|
||||
var effectiveCancellationToken = cancellationTokenOverride ?? cancellationToken;
|
||||
return await runner.RunAsync(options, effectiveCancellationToken);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.CommandLine;
|
||||
|
||||
namespace StellaOps.Policy.Tools;
|
||||
|
||||
public static class PolicySchemaExporterApp
|
||||
{
|
||||
public static async Task<int> RunAsync(string[] args)
|
||||
{
|
||||
var runner = new PolicySchemaExporterRunner();
|
||||
var root = PolicySchemaExporterCommand.Build(runner);
|
||||
var parseResult = root.Parse(args, new ParserConfiguration());
|
||||
var invocationConfiguration = new InvocationConfiguration();
|
||||
|
||||
if (parseResult.Errors.Count > 0)
|
||||
{
|
||||
await parseResult.InvokeAsync(invocationConfiguration, CancellationToken.None);
|
||||
return 64; // EX_USAGE
|
||||
}
|
||||
|
||||
return await parseResult.InvokeAsync(invocationConfiguration, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System.CommandLine;
|
||||
|
||||
namespace StellaOps.Policy.Tools;
|
||||
|
||||
public static class PolicySchemaExporterCommand
|
||||
{
|
||||
public static RootCommand Build(PolicySchemaExporterRunner runner, CancellationToken? cancellationTokenOverride = null)
|
||||
{
|
||||
var root = new RootCommand("Export policy schema JSON files.");
|
||||
Configure(root, runner, cancellationTokenOverride);
|
||||
return root;
|
||||
}
|
||||
|
||||
public static Command BuildCommand(PolicySchemaExporterRunner runner, CancellationToken? cancellationTokenOverride = null)
|
||||
{
|
||||
var command = new Command("policy-schema-export", "Export policy schema JSON files.");
|
||||
Configure(command, runner, cancellationTokenOverride);
|
||||
return command;
|
||||
}
|
||||
|
||||
private static void Configure(Command command, PolicySchemaExporterRunner runner, CancellationToken? cancellationTokenOverride)
|
||||
{
|
||||
var output = new Option<string?>("--output", new[] { "-o" })
|
||||
{
|
||||
Description = "Output directory for schema files."
|
||||
};
|
||||
|
||||
var repoRoot = new Option<string?>("--repo-root", new[] { "-r" })
|
||||
{
|
||||
Description = "Repository root used to resolve default output path."
|
||||
};
|
||||
|
||||
command.Add(output);
|
||||
command.Add(repoRoot);
|
||||
|
||||
command.SetAction((parseResult, cancellationToken) =>
|
||||
{
|
||||
var options = new PolicySchemaExportOptions
|
||||
{
|
||||
OutputDirectory = parseResult.GetValue(output),
|
||||
RepoRoot = parseResult.GetValue(repoRoot),
|
||||
};
|
||||
|
||||
var effectiveCancellationToken = cancellationTokenOverride ?? cancellationToken;
|
||||
return runner.RunAsync(options, effectiveCancellationToken);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
using System.Collections.Immutable;
|
||||
using NJsonSchema;
|
||||
using NJsonSchema.Generation;
|
||||
using Newtonsoft.Json;
|
||||
using StellaOps.Scheduler.Models;
|
||||
|
||||
namespace StellaOps.Policy.Tools;
|
||||
|
||||
public sealed record PolicySchemaExportOptions
|
||||
{
|
||||
public string? OutputDirectory { get; init; }
|
||||
public string? RepoRoot { get; init; }
|
||||
}
|
||||
|
||||
public sealed record SchemaExportDefinition(string FileName, Type Type);
|
||||
|
||||
public sealed class PolicySchemaExporterRunner
|
||||
{
|
||||
public async Task<int> RunAsync(PolicySchemaExportOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
if (options is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
var repoRoot = NormalizePath(options.RepoRoot)
|
||||
?? PolicySchemaExporterPaths.TryFindRepoRoot(Directory.GetCurrentDirectory())
|
||||
?? PolicySchemaExporterPaths.TryFindRepoRoot(AppContext.BaseDirectory);
|
||||
|
||||
string? outputDirectory;
|
||||
if (!string.IsNullOrWhiteSpace(options.OutputDirectory))
|
||||
{
|
||||
outputDirectory = PolicySchemaExporterPaths.ResolveOutputDirectory(options.OutputDirectory!, repoRoot);
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(repoRoot))
|
||||
{
|
||||
outputDirectory = PolicySchemaExporterPaths.ResolveDefaultOutputDirectory(repoRoot);
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Error.WriteLine("Unable to resolve repo root. Provide --output or --repo-root.");
|
||||
return 64; // EX_USAGE
|
||||
}
|
||||
|
||||
if (!TryEnsureOutputDirectory(outputDirectory, out var error))
|
||||
{
|
||||
Console.Error.WriteLine(error);
|
||||
return 73; // EX_CANTCREAT
|
||||
}
|
||||
|
||||
var generator = PolicySchemaExporterSchema.CreateGenerator();
|
||||
var exports = PolicySchemaExporterSchema.BuildExports();
|
||||
var schemas = PolicySchemaExporterSchema.GenerateSchemas(generator, exports);
|
||||
|
||||
foreach (var export in exports)
|
||||
{
|
||||
if (!schemas.TryGetValue(export.FileName, out var json))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var outputPath = Path.Combine(outputDirectory, export.FileName);
|
||||
await File.WriteAllTextAsync(outputPath, json + Environment.NewLine, cancellationToken);
|
||||
Console.WriteLine($"Wrote {outputPath}");
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static string? NormalizePath(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Path.GetFullPath(path);
|
||||
}
|
||||
|
||||
private static bool TryEnsureOutputDirectory(string outputDirectory, out string? error)
|
||||
{
|
||||
error = null;
|
||||
try
|
||||
{
|
||||
if (File.Exists(outputDirectory))
|
||||
{
|
||||
error = $"Output path '{outputDirectory}' is a file, expected a directory.";
|
||||
return false;
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(outputDirectory);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = $"Failed to create output directory '{outputDirectory}': {ex.Message}";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static class PolicySchemaExporterSchema
|
||||
{
|
||||
public static ImmutableArray<SchemaExportDefinition> BuildExports()
|
||||
=> ImmutableArray.Create(
|
||||
new SchemaExportDefinition("policy-run-request.schema.json", typeof(PolicyRunRequest)),
|
||||
new SchemaExportDefinition("policy-run-status.schema.json", typeof(PolicyRunStatus)),
|
||||
new SchemaExportDefinition("policy-diff-summary.schema.json", typeof(PolicyDiffSummary)),
|
||||
new SchemaExportDefinition("policy-explain-trace.schema.json", typeof(PolicyExplainTrace))
|
||||
);
|
||||
|
||||
public static JsonSchemaGenerator CreateGenerator()
|
||||
{
|
||||
var generatorSettings = new NJsonSchema.NewtonsoftJson.Generation.NewtonsoftJsonSchemaGeneratorSettings
|
||||
{
|
||||
SchemaType = SchemaType.JsonSchema,
|
||||
DefaultReferenceTypeNullHandling = ReferenceTypeNullHandling.NotNull,
|
||||
SerializerSettings = new JsonSerializerSettings
|
||||
{
|
||||
ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver(),
|
||||
NullValueHandling = NullValueHandling.Ignore,
|
||||
},
|
||||
};
|
||||
|
||||
return new JsonSchemaGenerator(generatorSettings);
|
||||
}
|
||||
|
||||
public static IReadOnlyDictionary<string, string> GenerateSchemas(JsonSchemaGenerator generator, IEnumerable<SchemaExportDefinition> exports)
|
||||
{
|
||||
if (generator is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(generator));
|
||||
}
|
||||
|
||||
if (exports is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(exports));
|
||||
}
|
||||
|
||||
var results = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var export in exports)
|
||||
{
|
||||
var schema = generator.Generate(export.Type);
|
||||
schema.Title = export.Type.Name;
|
||||
schema.AllowAdditionalProperties = false;
|
||||
results[export.FileName] = schema.ToJson(Formatting.Indented);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
public static class PolicySchemaExporterPaths
|
||||
{
|
||||
public static string? TryFindRepoRoot(string startDirectory)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(startDirectory))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var current = new DirectoryInfo(Path.GetFullPath(startDirectory));
|
||||
while (current is not null)
|
||||
{
|
||||
var candidate = Path.Combine(current.FullName, "src", "Directory.Build.props");
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return current.FullName;
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static string ResolveDefaultOutputDirectory(string repoRoot)
|
||||
=> Path.GetFullPath(Path.Combine(repoRoot, "docs", "schemas"));
|
||||
|
||||
public static string ResolveOutputDirectory(string outputPath, string? repoRoot)
|
||||
{
|
||||
if (Path.IsPathRooted(outputPath))
|
||||
{
|
||||
return Path.GetFullPath(outputPath);
|
||||
}
|
||||
|
||||
var baseDirectory = !string.IsNullOrWhiteSpace(repoRoot) ? repoRoot : Directory.GetCurrentDirectory();
|
||||
return Path.GetFullPath(Path.Combine(baseDirectory, outputPath));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.CommandLine;
|
||||
|
||||
namespace StellaOps.Policy.Tools;
|
||||
|
||||
public static class PolicySimulationSmokeApp
|
||||
{
|
||||
public static async Task<int> RunAsync(string[] args)
|
||||
{
|
||||
var runner = new PolicySimulationSmokeRunner();
|
||||
var root = PolicySimulationSmokeCommand.Build(runner);
|
||||
var parseResult = root.Parse(args, new ParserConfiguration());
|
||||
var invocationConfiguration = new InvocationConfiguration();
|
||||
|
||||
if (parseResult.Errors.Count > 0)
|
||||
{
|
||||
await parseResult.InvokeAsync(invocationConfiguration, CancellationToken.None);
|
||||
return 64; // EX_USAGE
|
||||
}
|
||||
|
||||
return await parseResult.InvokeAsync(invocationConfiguration, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using System.CommandLine;
|
||||
|
||||
namespace StellaOps.Policy.Tools;
|
||||
|
||||
public static class PolicySimulationSmokeCommand
|
||||
{
|
||||
public static RootCommand Build(PolicySimulationSmokeRunner runner, CancellationToken? cancellationTokenOverride = null)
|
||||
{
|
||||
var root = new RootCommand("Run policy simulation smoke scenarios.");
|
||||
Configure(root, runner, cancellationTokenOverride);
|
||||
return root;
|
||||
}
|
||||
|
||||
public static Command BuildCommand(PolicySimulationSmokeRunner runner, CancellationToken? cancellationTokenOverride = null)
|
||||
{
|
||||
var command = new Command("policy-simulation-smoke", "Run policy simulation smoke scenarios.");
|
||||
Configure(command, runner, cancellationTokenOverride);
|
||||
return command;
|
||||
}
|
||||
|
||||
private static void Configure(Command command, PolicySimulationSmokeRunner runner, CancellationToken? cancellationTokenOverride)
|
||||
{
|
||||
var scenarioRoot = new Option<string>("--scenario-root", new[] { "-r" })
|
||||
{
|
||||
Description = "Path to the policy simulation scenarios."
|
||||
};
|
||||
|
||||
var output = new Option<string?>("--output", new[] { "-o" })
|
||||
{
|
||||
Description = "Directory for summary output."
|
||||
};
|
||||
|
||||
var repoRoot = new Option<string?>("--repo-root", Array.Empty<string>())
|
||||
{
|
||||
Description = "Repository root for resolving relative paths."
|
||||
};
|
||||
|
||||
var fixedTime = new Option<string?>("--fixed-time", Array.Empty<string>())
|
||||
{
|
||||
Description = "Fixed ISO-8601 timestamp for deterministic runs."
|
||||
};
|
||||
|
||||
command.Add(scenarioRoot);
|
||||
command.Add(output);
|
||||
command.Add(repoRoot);
|
||||
command.Add(fixedTime);
|
||||
|
||||
command.SetAction(async (parseResult, cancellationToken) =>
|
||||
{
|
||||
var fixedTimeValue = parseResult.GetValue(fixedTime);
|
||||
DateTimeOffset? fixedTimeParsed = null;
|
||||
if (!string.IsNullOrWhiteSpace(fixedTimeValue))
|
||||
{
|
||||
if (!PolicySimulationSmokeParsing.TryParseFixedTime(fixedTimeValue!, out var parsed))
|
||||
{
|
||||
Console.Error.WriteLine("Invalid --fixed-time value. Use ISO-8601 (e.g., 2025-01-02T03:04:05Z).");
|
||||
return 64; // EX_USAGE
|
||||
}
|
||||
|
||||
fixedTimeParsed = parsed;
|
||||
}
|
||||
|
||||
var options = new PolicySimulationSmokeOptions
|
||||
{
|
||||
ScenarioRoot = parseResult.GetValue(scenarioRoot) ?? "samples/policy/simulations",
|
||||
OutputDirectory = parseResult.GetValue(output),
|
||||
RepoRoot = parseResult.GetValue(repoRoot),
|
||||
FixedTime = fixedTimeParsed,
|
||||
};
|
||||
|
||||
var effectiveCancellationToken = cancellationTokenOverride ?? cancellationToken;
|
||||
return await runner.RunAsync(options, effectiveCancellationToken);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using StellaOps.Policy;
|
||||
|
||||
namespace StellaOps.Policy.Tools;
|
||||
|
||||
public sealed record PolicySimulationScenario
|
||||
{
|
||||
public string Name { get; init; } = "scenario";
|
||||
public string PolicyPath { get; init; } = string.Empty;
|
||||
public List<ScenarioFinding> Findings { get; init; } = new();
|
||||
public List<ScenarioExpectedDiff> ExpectedDiffs { get; init; } = new();
|
||||
public List<ScenarioBaseline>? Baseline { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ScenarioFinding
|
||||
{
|
||||
public string FindingId { get; init; } = string.Empty;
|
||||
public string Severity { get; init; } = "Low";
|
||||
public string? Environment { get; init; }
|
||||
public string? Source { get; init; }
|
||||
public string? Vendor { get; init; }
|
||||
public string? License { get; init; }
|
||||
public string? Image { get; init; }
|
||||
public string? Repository { get; init; }
|
||||
public string? Package { get; init; }
|
||||
public string? Purl { get; init; }
|
||||
public string? Cve { get; init; }
|
||||
public string? Path { get; init; }
|
||||
public string? LayerDigest { get; init; }
|
||||
public string[]? Tags { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ScenarioExpectedDiff
|
||||
{
|
||||
public string FindingId { get; init; } = string.Empty;
|
||||
public string Status { get; init; } = "Pass";
|
||||
}
|
||||
|
||||
public sealed record ScenarioBaseline
|
||||
{
|
||||
public string FindingId { get; init; } = string.Empty;
|
||||
public string Status { get; init; } = "Pass";
|
||||
public string? RuleName { get; init; }
|
||||
public string? RuleAction { get; init; }
|
||||
public string? Notes { get; init; }
|
||||
public double Score { get; init; }
|
||||
public string? ConfigVersion { get; init; }
|
||||
public Dictionary<string, double>? Inputs { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ScenarioResult(string ScenarioName)
|
||||
{
|
||||
public bool Success { get; init; } = true;
|
||||
public int ChangedCount { get; init; }
|
||||
public List<string> Failures { get; } = new();
|
||||
public Dictionary<string, string> ActualStatuses { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public sealed class NullPolicySnapshotRepository : IPolicySnapshotRepository
|
||||
{
|
||||
public Task AddAsync(PolicySnapshot snapshot, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
|
||||
public Task<PolicySnapshot?> GetLatestAsync(CancellationToken cancellationToken = default) => Task.FromResult<PolicySnapshot?>(null);
|
||||
|
||||
public Task<IReadOnlyList<PolicySnapshot>> ListAsync(int limit, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<PolicySnapshot>>(Array.Empty<PolicySnapshot>());
|
||||
}
|
||||
|
||||
public sealed class NullPolicyAuditRepository : IPolicyAuditRepository
|
||||
{
|
||||
public Task AddAsync(PolicyAuditEntry entry, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
|
||||
public Task<IReadOnlyList<PolicyAuditEntry>> ListAsync(int limit, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<PolicyAuditEntry>>(Array.Empty<PolicyAuditEntry>());
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Policy;
|
||||
|
||||
namespace StellaOps.Policy.Tools;
|
||||
|
||||
public sealed record PolicySimulationSmokeOptions
|
||||
{
|
||||
public string ScenarioRoot { get; init; } = "samples/policy/simulations";
|
||||
public string? OutputDirectory { get; init; }
|
||||
public string? RepoRoot { get; init; }
|
||||
public DateTimeOffset? FixedTime { get; init; }
|
||||
}
|
||||
|
||||
public sealed class PolicySimulationSmokeRunner
|
||||
{
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
|
||||
public PolicySimulationSmokeRunner(ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
_loggerFactory = loggerFactory ?? NullLoggerFactory.Instance;
|
||||
}
|
||||
|
||||
public async Task<int> RunAsync(PolicySimulationSmokeOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
if (options is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
var repoRoot = PolicySimulationSmokePaths.ResolveRepoRoot(options.RepoRoot);
|
||||
var scenarioRoot = PolicySimulationSmokePaths.ResolveScenarioRoot(options.ScenarioRoot, repoRoot);
|
||||
if (scenarioRoot is null)
|
||||
{
|
||||
Console.Error.WriteLine("Scenario root is relative; provide --repo-root or use an absolute path.");
|
||||
return 64; // EX_USAGE
|
||||
}
|
||||
|
||||
if (!Directory.Exists(scenarioRoot))
|
||||
{
|
||||
Console.Error.WriteLine($"Scenario root '{scenarioRoot}' does not exist.");
|
||||
return 66; // EX_NOINPUT
|
||||
}
|
||||
|
||||
var scenarioFiles = Directory.GetFiles(scenarioRoot, "scenario.json", SearchOption.AllDirectories)
|
||||
.OrderBy(static path => path, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
if (scenarioFiles.Length == 0)
|
||||
{
|
||||
Console.Error.WriteLine($"No scenario.json files found under '{scenarioRoot}'.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
var timeProvider = options.FixedTime.HasValue
|
||||
? new FixedTimeProvider(options.FixedTime.Value)
|
||||
: TimeProvider.System;
|
||||
|
||||
var snapshotStore = new PolicySnapshotStore(
|
||||
new NullPolicySnapshotRepository(),
|
||||
new NullPolicyAuditRepository(),
|
||||
timeProvider,
|
||||
_loggerFactory.CreateLogger<PolicySnapshotStore>());
|
||||
var previewService = new PolicyPreviewService(snapshotStore, _loggerFactory.CreateLogger<PolicyPreviewService>());
|
||||
|
||||
var serializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
};
|
||||
|
||||
var summary = new List<ScenarioResult>();
|
||||
var success = true;
|
||||
|
||||
foreach (var scenarioFile in scenarioFiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var scenarioText = await File.ReadAllTextAsync(scenarioFile, cancellationToken);
|
||||
var scenario = JsonSerializer.Deserialize<PolicySimulationScenario>(scenarioText, serializerOptions);
|
||||
if (scenario is null)
|
||||
{
|
||||
Console.Error.WriteLine($"Failed to deserialize scenario '{scenarioFile}'.");
|
||||
success = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
var policyPath = PolicySimulationSmokePaths.ResolvePolicyPath(scenario.PolicyPath, repoRoot);
|
||||
if (policyPath is null)
|
||||
{
|
||||
Console.Error.WriteLine($"Policy path '{scenario.PolicyPath}' is relative; provide --repo-root or use an absolute path.");
|
||||
success = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!File.Exists(policyPath))
|
||||
{
|
||||
Console.Error.WriteLine($"Policy file '{scenario.PolicyPath}' referenced by scenario '{scenario.Name}' does not exist.");
|
||||
success = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
var policyContent = await File.ReadAllTextAsync(policyPath, cancellationToken);
|
||||
var policyFormat = PolicySchema.DetectFormat(policyPath);
|
||||
var findings = scenario.Findings.Select(ToPolicyFinding).ToImmutableArray();
|
||||
var baseline = scenario.Baseline?.Select(ToPolicyVerdict).ToImmutableArray() ?? ImmutableArray<PolicyVerdict>.Empty;
|
||||
|
||||
var request = new PolicyPreviewRequest(
|
||||
ImageDigest: $"sha256:simulation-{scenario.Name}",
|
||||
Findings: findings,
|
||||
BaselineVerdicts: baseline,
|
||||
SnapshotOverride: null,
|
||||
ProposedPolicy: new PolicySnapshotContent(
|
||||
Content: policyContent,
|
||||
Format: policyFormat,
|
||||
Actor: "ci",
|
||||
Source: "ci/simulation-smoke",
|
||||
Description: $"CI simulation for scenario '{scenario.Name}'"));
|
||||
|
||||
var response = await previewService.PreviewAsync(request, cancellationToken);
|
||||
var scenarioResult = PolicySimulationSmokeEvaluator.EvaluateScenario(scenario, response);
|
||||
summary.Add(scenarioResult);
|
||||
|
||||
if (!scenarioResult.Success)
|
||||
{
|
||||
success = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (options.OutputDirectory is not null)
|
||||
{
|
||||
var outputDirectory = PolicySimulationSmokePaths.ResolveOutputDirectory(options.OutputDirectory, repoRoot);
|
||||
if (outputDirectory is null)
|
||||
{
|
||||
Console.Error.WriteLine("Output path is relative; provide --repo-root or use an absolute path.");
|
||||
return 64; // EX_USAGE
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(outputDirectory);
|
||||
var summaryPath = Path.Combine(outputDirectory, "policy-simulation-summary.json");
|
||||
var summaryJson = JsonSerializer.Serialize(summary, new JsonSerializerOptions { WriteIndented = true });
|
||||
await File.WriteAllTextAsync(summaryPath, summaryJson, cancellationToken);
|
||||
}
|
||||
|
||||
return success ? 0 : 1;
|
||||
}
|
||||
|
||||
private static PolicyFinding ToPolicyFinding(ScenarioFinding finding)
|
||||
{
|
||||
var tags = finding.Tags is null ? ImmutableArray<string>.Empty : ImmutableArray.CreateRange(finding.Tags);
|
||||
var severity = Enum.Parse<PolicySeverity>(finding.Severity, ignoreCase: true);
|
||||
return new PolicyFinding(
|
||||
finding.FindingId,
|
||||
severity,
|
||||
finding.Environment,
|
||||
finding.Source,
|
||||
finding.Vendor,
|
||||
finding.License,
|
||||
finding.Image,
|
||||
finding.Repository,
|
||||
finding.Package,
|
||||
finding.Purl,
|
||||
finding.Cve,
|
||||
finding.Path,
|
||||
finding.LayerDigest,
|
||||
tags);
|
||||
}
|
||||
|
||||
private static PolicyVerdict ToPolicyVerdict(ScenarioBaseline baseline)
|
||||
{
|
||||
var status = Enum.Parse<PolicyVerdictStatus>(baseline.Status, ignoreCase: true);
|
||||
var inputs = baseline.Inputs?.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase) ?? ImmutableDictionary<string, double>.Empty;
|
||||
return new PolicyVerdict(
|
||||
baseline.FindingId,
|
||||
status,
|
||||
RuleName: baseline.RuleName,
|
||||
RuleAction: baseline.RuleAction,
|
||||
Notes: baseline.Notes,
|
||||
Score: baseline.Score,
|
||||
ConfigVersion: baseline.ConfigVersion ?? PolicyScoringConfig.Default.Version,
|
||||
Inputs: inputs,
|
||||
QuietedBy: null,
|
||||
Quiet: false,
|
||||
UnknownConfidence: null,
|
||||
ConfidenceBand: null,
|
||||
UnknownAgeDays: null,
|
||||
SourceTrust: null,
|
||||
Reachability: null);
|
||||
}
|
||||
}
|
||||
|
||||
public static class PolicySimulationSmokeEvaluator
|
||||
{
|
||||
public static ScenarioResult EvaluateScenario(PolicySimulationScenario scenario, PolicyPreviewResponse response)
|
||||
{
|
||||
var result = new ScenarioResult(scenario.Name);
|
||||
if (!response.Success)
|
||||
{
|
||||
result.Failures.Add("Preview failed.");
|
||||
return result with { Success = false, ChangedCount = response.ChangedCount };
|
||||
}
|
||||
|
||||
var diffs = response.Diffs.ToDictionary(diff => diff.Projected.FindingId, StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var expected in scenario.ExpectedDiffs)
|
||||
{
|
||||
if (!diffs.TryGetValue(expected.FindingId, out var diff))
|
||||
{
|
||||
result.Failures.Add($"Expected finding '{expected.FindingId}' missing from diff.");
|
||||
continue;
|
||||
}
|
||||
|
||||
var projectedStatus = diff.Projected.Status.ToString();
|
||||
result.ActualStatuses[expected.FindingId] = projectedStatus;
|
||||
if (!string.Equals(projectedStatus, expected.Status, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
result.Failures.Add($"Finding '{expected.FindingId}' expected status '{expected.Status}' but was '{projectedStatus}'.");
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var diff in diffs.Values)
|
||||
{
|
||||
if (!result.ActualStatuses.ContainsKey(diff.Projected.FindingId))
|
||||
{
|
||||
result.ActualStatuses[diff.Projected.FindingId] = diff.Projected.Status.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
var success = result.Failures.Count == 0;
|
||||
return result with
|
||||
{
|
||||
Success = success,
|
||||
ChangedCount = response.ChangedCount
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public static class PolicySimulationSmokePaths
|
||||
{
|
||||
public static string? ResolveRepoRoot(string? explicitRepoRoot)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(explicitRepoRoot))
|
||||
{
|
||||
return Path.GetFullPath(explicitRepoRoot);
|
||||
}
|
||||
|
||||
return TryFindRepoRoot(Directory.GetCurrentDirectory())
|
||||
?? TryFindRepoRoot(AppContext.BaseDirectory);
|
||||
}
|
||||
|
||||
public static string? ResolveScenarioRoot(string scenarioRoot, string? repoRoot)
|
||||
{
|
||||
if (Path.IsPathRooted(scenarioRoot))
|
||||
{
|
||||
return Path.GetFullPath(scenarioRoot);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(repoRoot))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Path.GetFullPath(Path.Combine(repoRoot, scenarioRoot));
|
||||
}
|
||||
|
||||
public static string? ResolvePolicyPath(string policyPath, string? repoRoot)
|
||||
{
|
||||
if (Path.IsPathRooted(policyPath))
|
||||
{
|
||||
return Path.GetFullPath(policyPath);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(repoRoot))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Path.GetFullPath(Path.Combine(repoRoot, policyPath));
|
||||
}
|
||||
|
||||
public static string? ResolveOutputDirectory(string outputDirectory, string? repoRoot)
|
||||
{
|
||||
if (Path.IsPathRooted(outputDirectory))
|
||||
{
|
||||
return Path.GetFullPath(outputDirectory);
|
||||
}
|
||||
|
||||
var baseDirectory = !string.IsNullOrWhiteSpace(repoRoot) ? repoRoot : Directory.GetCurrentDirectory();
|
||||
return Path.GetFullPath(Path.Combine(baseDirectory, outputDirectory));
|
||||
}
|
||||
|
||||
public static string? TryFindRepoRoot(string startDirectory)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(startDirectory))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var current = new DirectoryInfo(Path.GetFullPath(startDirectory));
|
||||
while (current is not null)
|
||||
{
|
||||
var candidate = Path.Combine(current.FullName, "src", "Directory.Build.props");
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return current.FullName;
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static class PolicySimulationSmokeParsing
|
||||
{
|
||||
public static bool TryParseFixedTime(string value, out DateTimeOffset fixedTime)
|
||||
=> DateTimeOffset.TryParse(
|
||||
value,
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
|
||||
out fixedTime);
|
||||
}
|
||||
|
||||
public sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _fixedTime;
|
||||
|
||||
public FixedTimeProvider(DateTimeOffset fixedTime)
|
||||
{
|
||||
_fixedTime = fixedTime.ToUniversalTime();
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _fixedTime;
|
||||
|
||||
public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using StellaOps.Policy;
|
||||
|
||||
namespace StellaOps.Policy.Tools;
|
||||
|
||||
public interface IPolicyValidationRunner
|
||||
{
|
||||
Task<int> RunAsync(PolicyValidationCliOptions options, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class PolicyValidationRunner : IPolicyValidationRunner
|
||||
{
|
||||
private readonly PolicyValidationCli _cli;
|
||||
|
||||
public PolicyValidationRunner(PolicyValidationCli cli)
|
||||
{
|
||||
_cli = cli ?? throw new ArgumentNullException(nameof(cli));
|
||||
}
|
||||
|
||||
public Task<int> RunAsync(PolicyValidationCliOptions options, CancellationToken cancellationToken)
|
||||
=> _cli.RunAsync(options, cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Newtonsoft.Json" />
|
||||
<PackageReference Include="NJsonSchema" />
|
||||
<PackageReference Include="NJsonSchema.CodeGeneration.CSharp" />
|
||||
<PackageReference Include="NJsonSchema.NewtonsoftJson" />
|
||||
<PackageReference Include="System.CommandLine" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Policy\__Libraries\StellaOps.Policy\StellaOps.Policy.csproj" />
|
||||
<ProjectReference Include="..\..\Scheduler\__Libraries\StellaOps.Scheduler.Models\StellaOps.Scheduler.Models.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user