partly or unimplemented features - now implemented
This commit is contained in:
@@ -0,0 +1,342 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Plugins;
|
||||
using StellaOps.PolicyDsl;
|
||||
using System.CommandLine;
|
||||
using System.Text.Json;
|
||||
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Cli.Plugins.Policy;
|
||||
|
||||
/// <summary>
|
||||
/// CLI plugin module for policy DSL commands.
|
||||
/// Provides 'stella policy lint', 'stella policy compile', and 'stella policy simulate'.
|
||||
/// </summary>
|
||||
public sealed class PolicyCliCommandModule : ICliCommandModule
|
||||
{
|
||||
public string Name => "stellaops.cli.plugins.policy";
|
||||
|
||||
public bool IsAvailable(IServiceProvider services) => true;
|
||||
|
||||
public void RegisterCommands(
|
||||
RootCommand root,
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(root);
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(verboseOption);
|
||||
|
||||
root.Add(BuildPolicyCommand(services, verboseOption, options, cancellationToken));
|
||||
}
|
||||
|
||||
private static Command BuildPolicyCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
StellaOpsCliOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var policy = new Command("policy", "Policy DSL operations: lint, compile, and simulate.");
|
||||
|
||||
policy.Add(BuildLintCommand(services, verboseOption));
|
||||
policy.Add(BuildCompileCommand(services, verboseOption));
|
||||
policy.Add(BuildSimulateCommand(services, verboseOption));
|
||||
|
||||
return policy;
|
||||
}
|
||||
|
||||
private static Command BuildLintCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption)
|
||||
{
|
||||
var fileArg = new Argument<FileInfo>("file", "Path to .stella policy file to lint.");
|
||||
|
||||
var outputOption = new Option<string?>("--output", new[] { "-o" })
|
||||
{
|
||||
Description = "Output format: text, json. Default: text"
|
||||
};
|
||||
|
||||
var lintCommand = new Command("lint", "Lint a policy DSL file for syntax and semantic errors.")
|
||||
{
|
||||
fileArg,
|
||||
outputOption
|
||||
};
|
||||
|
||||
lintCommand.SetHandler(async (file, output, verbose) =>
|
||||
{
|
||||
var logger = services.GetRequiredService<ILogger<PolicyCliCommandModule>>();
|
||||
|
||||
if (!file.Exists)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: File not found: {file.FullName}");
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
var source = await File.ReadAllTextAsync(file.FullName);
|
||||
var result = PolicyParser.Parse(source);
|
||||
|
||||
var outputFormat = output?.ToLowerInvariant() ?? "text";
|
||||
|
||||
if (outputFormat == "json")
|
||||
{
|
||||
var jsonResult = new
|
||||
{
|
||||
file = file.FullName,
|
||||
success = !result.Diagnostics.Any(d => d.Severity == StellaOps.Policy.PolicyIssueSeverity.Error),
|
||||
diagnostics = result.Diagnostics.Select(d => new
|
||||
{
|
||||
severity = d.Severity.ToString().ToLowerInvariant(),
|
||||
code = d.Code,
|
||||
message = d.Message,
|
||||
path = d.Path,
|
||||
location = d.Location is not null ? new
|
||||
{
|
||||
line = d.Location.Line,
|
||||
column = d.Location.Column
|
||||
} : null
|
||||
})
|
||||
};
|
||||
Console.WriteLine(JsonSerializer.Serialize(jsonResult, new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
else
|
||||
{
|
||||
var errors = result.Diagnostics.Where(d => d.Severity == StellaOps.Policy.PolicyIssueSeverity.Error).ToList();
|
||||
var warnings = result.Diagnostics.Where(d => d.Severity == StellaOps.Policy.PolicyIssueSeverity.Warning).ToList();
|
||||
var infos = result.Diagnostics.Where(d => d.Severity == StellaOps.Policy.PolicyIssueSeverity.Info).ToList();
|
||||
|
||||
if (errors.Count == 0 && warnings.Count == 0)
|
||||
{
|
||||
Console.WriteLine($"✓ {file.Name}: No issues found.");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"Linting {file.Name}:");
|
||||
foreach (var diag in result.Diagnostics.OrderBy(d => d.Location?.Line ?? 0))
|
||||
{
|
||||
var symbol = diag.Severity switch
|
||||
{
|
||||
StellaOps.Policy.PolicyIssueSeverity.Error => "✗",
|
||||
StellaOps.Policy.PolicyIssueSeverity.Warning => "⚠",
|
||||
_ => "ℹ"
|
||||
};
|
||||
var location = diag.Location is not null ? $":{diag.Location.Line}:{diag.Location.Column}" : "";
|
||||
Console.WriteLine($" {symbol} [{diag.Code}]{location}: {diag.Message}");
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"Summary: {errors.Count} error(s), {warnings.Count} warning(s), {infos.Count} info(s)");
|
||||
}
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
}
|
||||
}, fileArg, outputOption, verboseOption);
|
||||
|
||||
return lintCommand;
|
||||
}
|
||||
|
||||
private static Command BuildCompileCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption)
|
||||
{
|
||||
var fileArg = new Argument<FileInfo>("file", "Path to .stella policy file to compile.");
|
||||
|
||||
var outputOption = new Option<FileInfo?>("--output", new[] { "-o" })
|
||||
{
|
||||
Description = "Output path for compiled IR (.json). Default: stdout"
|
||||
};
|
||||
|
||||
var checksumOnlyOption = new Option<bool>("--checksum-only")
|
||||
{
|
||||
Description = "Only output the deterministic checksum."
|
||||
};
|
||||
|
||||
var compileCommand = new Command("compile", "Compile a policy DSL file to intermediate representation.")
|
||||
{
|
||||
fileArg,
|
||||
outputOption,
|
||||
checksumOnlyOption
|
||||
};
|
||||
|
||||
compileCommand.SetHandler(async (file, output, checksumOnly, verbose) =>
|
||||
{
|
||||
var logger = services.GetRequiredService<ILogger<PolicyCliCommandModule>>();
|
||||
|
||||
if (!file.Exists)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: File not found: {file.FullName}");
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
var source = await File.ReadAllTextAsync(file.FullName);
|
||||
var compiler = new PolicyCompiler();
|
||||
var result = compiler.Compile(source);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
Console.Error.WriteLine($"Compilation failed for {file.Name}:");
|
||||
foreach (var diag in result.Diagnostics.Where(d => d.Severity == StellaOps.Policy.PolicyIssueSeverity.Error))
|
||||
{
|
||||
var location = diag.Location is not null ? $":{diag.Location.Line}:{diag.Location.Column}" : "";
|
||||
Console.Error.WriteLine($" ✗ [{diag.Code}]{location}: {diag.Message}");
|
||||
}
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (checksumOnly)
|
||||
{
|
||||
Console.WriteLine(result.Checksum);
|
||||
return;
|
||||
}
|
||||
|
||||
var irBytes = PolicyIrSerializer.Serialize(result.Document!);
|
||||
var irJson = Encoding.UTF8.GetString(irBytes.AsSpan());
|
||||
|
||||
if (output is not null)
|
||||
{
|
||||
await File.WriteAllTextAsync(output.FullName, irJson);
|
||||
Console.WriteLine($"✓ Compiled {file.Name} -> {output.Name}");
|
||||
Console.WriteLine($" Checksum: {result.Checksum}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(irJson);
|
||||
}
|
||||
}, fileArg, outputOption, checksumOnlyOption, verboseOption);
|
||||
|
||||
return compileCommand;
|
||||
}
|
||||
|
||||
private static Command BuildSimulateCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption)
|
||||
{
|
||||
var fileArg = new Argument<FileInfo>("file", "Path to .stella policy file.");
|
||||
|
||||
var signalsOption = new Option<FileInfo?>("--signals", new[] { "-s" })
|
||||
{
|
||||
Description = "Path to signals context JSON file."
|
||||
};
|
||||
|
||||
var outputOption = new Option<string?>("--output", new[] { "-o" })
|
||||
{
|
||||
Description = "Output format: text, json. Default: text"
|
||||
};
|
||||
|
||||
var simulateCommand = new Command("simulate", "Simulate policy evaluation with given signal context.")
|
||||
{
|
||||
fileArg,
|
||||
signalsOption,
|
||||
outputOption
|
||||
};
|
||||
|
||||
simulateCommand.SetHandler(async (file, signals, output, verbose) =>
|
||||
{
|
||||
var logger = services.GetRequiredService<ILogger<PolicyCliCommandModule>>();
|
||||
|
||||
if (!file.Exists)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: Policy file not found: {file.FullName}");
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
var source = await File.ReadAllTextAsync(file.FullName);
|
||||
var factory = new PolicyEngineFactory();
|
||||
var engineResult = factory.CreateFromSource(source);
|
||||
|
||||
if (engineResult.Engine is null)
|
||||
{
|
||||
Console.Error.WriteLine($"Compilation failed. Run 'stella policy lint' for details.");
|
||||
foreach (var diag in engineResult.Diagnostics.Where(d => d.Severity == StellaOps.Policy.PolicyIssueSeverity.Error))
|
||||
{
|
||||
var location = diag.Location is not null ? $":{diag.Location.Line}:{diag.Location.Column}" : "";
|
||||
Console.Error.WriteLine($" ✗ [{diag.Code}]{location}: {diag.Message}");
|
||||
}
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Load signals context
|
||||
SignalContext signalContext;
|
||||
if (signals is not null && signals.Exists)
|
||||
{
|
||||
var signalsJson = await File.ReadAllTextAsync(signals.FullName);
|
||||
var signalsDict = JsonSerializer.Deserialize<Dictionary<string, object?>>(signalsJson);
|
||||
signalContext = new SignalContext(signalsDict ?? new Dictionary<string, object?>());
|
||||
}
|
||||
else
|
||||
{
|
||||
signalContext = new SignalContext();
|
||||
}
|
||||
|
||||
// Run simulation
|
||||
var engine = engineResult.Engine;
|
||||
var evaluationResult = engine.Evaluate(signalContext);
|
||||
|
||||
var outputFormat = output?.ToLowerInvariant() ?? "text";
|
||||
var verdict = evaluationResult.MatchedRules.Length > 0 ? "match" : "no-match";
|
||||
|
||||
if (outputFormat == "json")
|
||||
{
|
||||
var jsonResult = new
|
||||
{
|
||||
policy = evaluationResult.PolicyName,
|
||||
policyChecksum = evaluationResult.PolicyChecksum,
|
||||
verdict,
|
||||
matchedRules = evaluationResult.MatchedRules,
|
||||
actions = evaluationResult.Actions.Select(a => new
|
||||
{
|
||||
ruleName = a.RuleName,
|
||||
action = a.Action.ActionName,
|
||||
wasElseBranch = a.WasElseBranch
|
||||
})
|
||||
};
|
||||
Console.WriteLine(JsonSerializer.Serialize(jsonResult, new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"Policy: {evaluationResult.PolicyName}");
|
||||
Console.WriteLine($"Checksum: {evaluationResult.PolicyChecksum}");
|
||||
Console.WriteLine();
|
||||
|
||||
if (evaluationResult.MatchedRules.Length > 0)
|
||||
{
|
||||
Console.WriteLine($"✓ Matched Rules ({evaluationResult.MatchedRules.Length}):");
|
||||
foreach (var rule in evaluationResult.MatchedRules)
|
||||
{
|
||||
Console.WriteLine($" - {rule}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("No rules matched.");
|
||||
}
|
||||
|
||||
if (evaluationResult.Actions.Length > 0)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"Actions ({evaluationResult.Actions.Length}):");
|
||||
foreach (var action in evaluationResult.Actions)
|
||||
{
|
||||
var branch = action.WasElseBranch ? " (else)" : "";
|
||||
Console.WriteLine($" - [{action.RuleName}]{branch}: {action.Action.ActionName}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}, fileArg, signalsOption, outputOption, verboseOption);
|
||||
|
||||
return simulateCommand;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!-- StellaOps.Cli.Plugins.Policy: CLI plugin for policy DSL operations (lint, compile, simulate) -->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<PluginOutputDirectory>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\..\plugins\cli\StellaOps.Cli.Plugins.Policy\'))</PluginOutputDirectory>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Cli\StellaOps.Cli.csproj" />
|
||||
<ProjectReference Include="..\..\..\Policy\StellaOps.PolicyDsl\StellaOps.PolicyDsl.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="CopyPluginBinaries" AfterTargets="Build">
|
||||
<MakeDir Directories="$(PluginOutputDirectory)" />
|
||||
<ItemGroup>
|
||||
<PluginArtifacts Include="$(TargetDir)$(TargetFileName)" />
|
||||
<PluginArtifacts Include="$(TargetDir)$(TargetName).pdb" Condition="Exists('$(TargetDir)$(TargetName).pdb')" />
|
||||
<PluginArtifacts Include="$(TargetDir)$(TargetName).deps.json" Condition="Exists('$(TargetDir)$(TargetName).deps.json')" />
|
||||
<PluginArtifacts Include="$(TargetDir)$(TargetName).runtimeconfig.json" Condition="Exists('$(TargetDir)$(TargetName).runtimeconfig.json')" />
|
||||
<PluginArtifacts Include="@(ReferenceCopyLocalPaths)" />
|
||||
</ItemGroup>
|
||||
<Copy SourceFiles="@(PluginArtifacts)" DestinationFolder="$(PluginOutputDirectory)" SkipUnchangedFiles="true" />
|
||||
</Target>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user