Restructure solution layout by module
This commit is contained in:
169
src/Cli/StellaOps.Cli.sln
Normal file
169
src/Cli/StellaOps.Cli.sln
Normal file
@@ -0,0 +1,169 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{41F15E67-7190-CF23-3BC4-77E87134CADD}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cli", "StellaOps.Cli\StellaOps.Cli.csproj", "{9258A5D3-2567-4BBA-8F0B-D018E431B7F8}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Configuration", "..\__Libraries\StellaOps.Configuration\StellaOps.Configuration.csproj", "{2846557F-1917-4A55-9EDB-EB28398D22EB}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Plugins.Abstractions", "..\Authority\StellaOps.Authority\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj", "{16D7BF0B-AEFE-4D3D-AE3F-88F96CD483AB}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography", "..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj", "{39C8D95B-08FB-486A-9A0B-1559D70E8689}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Abstractions", "..\Authority\StellaOps.Authority\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj", "{D42AC6A1-BB0E-48AD-A609-5672B6B888A2}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Client", "..\Authority\StellaOps.Authority\StellaOps.Auth.Client\StellaOps.Auth.Client.csproj", "{77853EC3-FED1-490B-B680-E9A1BDDC0D7C}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Plugin", "..\__Libraries\StellaOps.Plugin\StellaOps.Plugin.csproj", "{376B4717-AD51-4775-9B25-2C573F1E6215}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.DependencyInjection", "..\__Libraries\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj", "{429E5D21-7ABE-4A19-B3C3-BBEF97337ADA}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cli.Plugins.NonCore", "__Libraries\StellaOps.Cli.Plugins.NonCore\StellaOps.Cli.Plugins.NonCore.csproj", "{30E528B3-0EB1-4A89-8130-F69D3C0F1962}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Tests", "__Tests", "{56BCE1BF-7CBA-7CE8-203D-A88051F1D642}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cli.Tests", "__Tests\StellaOps.Cli.Tests\StellaOps.Cli.Tests.csproj", "{B434D60B-8A05-44EC-ADA6-07C9E2CB1D92}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{9258A5D3-2567-4BBA-8F0B-D018E431B7F8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{9258A5D3-2567-4BBA-8F0B-D018E431B7F8}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{9258A5D3-2567-4BBA-8F0B-D018E431B7F8}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{9258A5D3-2567-4BBA-8F0B-D018E431B7F8}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{9258A5D3-2567-4BBA-8F0B-D018E431B7F8}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{9258A5D3-2567-4BBA-8F0B-D018E431B7F8}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{9258A5D3-2567-4BBA-8F0B-D018E431B7F8}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{9258A5D3-2567-4BBA-8F0B-D018E431B7F8}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{9258A5D3-2567-4BBA-8F0B-D018E431B7F8}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{9258A5D3-2567-4BBA-8F0B-D018E431B7F8}.Release|x64.Build.0 = Release|Any CPU
|
||||
{9258A5D3-2567-4BBA-8F0B-D018E431B7F8}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{9258A5D3-2567-4BBA-8F0B-D018E431B7F8}.Release|x86.Build.0 = Release|Any CPU
|
||||
{2846557F-1917-4A55-9EDB-EB28398D22EB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{2846557F-1917-4A55-9EDB-EB28398D22EB}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{2846557F-1917-4A55-9EDB-EB28398D22EB}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{2846557F-1917-4A55-9EDB-EB28398D22EB}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{2846557F-1917-4A55-9EDB-EB28398D22EB}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{2846557F-1917-4A55-9EDB-EB28398D22EB}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{2846557F-1917-4A55-9EDB-EB28398D22EB}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{2846557F-1917-4A55-9EDB-EB28398D22EB}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{2846557F-1917-4A55-9EDB-EB28398D22EB}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{2846557F-1917-4A55-9EDB-EB28398D22EB}.Release|x64.Build.0 = Release|Any CPU
|
||||
{2846557F-1917-4A55-9EDB-EB28398D22EB}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{2846557F-1917-4A55-9EDB-EB28398D22EB}.Release|x86.Build.0 = Release|Any CPU
|
||||
{16D7BF0B-AEFE-4D3D-AE3F-88F96CD483AB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{16D7BF0B-AEFE-4D3D-AE3F-88F96CD483AB}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{16D7BF0B-AEFE-4D3D-AE3F-88F96CD483AB}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{16D7BF0B-AEFE-4D3D-AE3F-88F96CD483AB}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{16D7BF0B-AEFE-4D3D-AE3F-88F96CD483AB}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{16D7BF0B-AEFE-4D3D-AE3F-88F96CD483AB}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{16D7BF0B-AEFE-4D3D-AE3F-88F96CD483AB}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{16D7BF0B-AEFE-4D3D-AE3F-88F96CD483AB}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{16D7BF0B-AEFE-4D3D-AE3F-88F96CD483AB}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{16D7BF0B-AEFE-4D3D-AE3F-88F96CD483AB}.Release|x64.Build.0 = Release|Any CPU
|
||||
{16D7BF0B-AEFE-4D3D-AE3F-88F96CD483AB}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{16D7BF0B-AEFE-4D3D-AE3F-88F96CD483AB}.Release|x86.Build.0 = Release|Any CPU
|
||||
{39C8D95B-08FB-486A-9A0B-1559D70E8689}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{39C8D95B-08FB-486A-9A0B-1559D70E8689}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{39C8D95B-08FB-486A-9A0B-1559D70E8689}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{39C8D95B-08FB-486A-9A0B-1559D70E8689}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{39C8D95B-08FB-486A-9A0B-1559D70E8689}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{39C8D95B-08FB-486A-9A0B-1559D70E8689}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{39C8D95B-08FB-486A-9A0B-1559D70E8689}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{39C8D95B-08FB-486A-9A0B-1559D70E8689}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{39C8D95B-08FB-486A-9A0B-1559D70E8689}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{39C8D95B-08FB-486A-9A0B-1559D70E8689}.Release|x64.Build.0 = Release|Any CPU
|
||||
{39C8D95B-08FB-486A-9A0B-1559D70E8689}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{39C8D95B-08FB-486A-9A0B-1559D70E8689}.Release|x86.Build.0 = Release|Any CPU
|
||||
{D42AC6A1-BB0E-48AD-A609-5672B6B888A2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{D42AC6A1-BB0E-48AD-A609-5672B6B888A2}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D42AC6A1-BB0E-48AD-A609-5672B6B888A2}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{D42AC6A1-BB0E-48AD-A609-5672B6B888A2}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{D42AC6A1-BB0E-48AD-A609-5672B6B888A2}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{D42AC6A1-BB0E-48AD-A609-5672B6B888A2}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{D42AC6A1-BB0E-48AD-A609-5672B6B888A2}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{D42AC6A1-BB0E-48AD-A609-5672B6B888A2}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{D42AC6A1-BB0E-48AD-A609-5672B6B888A2}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{D42AC6A1-BB0E-48AD-A609-5672B6B888A2}.Release|x64.Build.0 = Release|Any CPU
|
||||
{D42AC6A1-BB0E-48AD-A609-5672B6B888A2}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{D42AC6A1-BB0E-48AD-A609-5672B6B888A2}.Release|x86.Build.0 = Release|Any CPU
|
||||
{77853EC3-FED1-490B-B680-E9A1BDDC0D7C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{77853EC3-FED1-490B-B680-E9A1BDDC0D7C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{77853EC3-FED1-490B-B680-E9A1BDDC0D7C}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{77853EC3-FED1-490B-B680-E9A1BDDC0D7C}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{77853EC3-FED1-490B-B680-E9A1BDDC0D7C}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{77853EC3-FED1-490B-B680-E9A1BDDC0D7C}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{77853EC3-FED1-490B-B680-E9A1BDDC0D7C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{77853EC3-FED1-490B-B680-E9A1BDDC0D7C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{77853EC3-FED1-490B-B680-E9A1BDDC0D7C}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{77853EC3-FED1-490B-B680-E9A1BDDC0D7C}.Release|x64.Build.0 = Release|Any CPU
|
||||
{77853EC3-FED1-490B-B680-E9A1BDDC0D7C}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{77853EC3-FED1-490B-B680-E9A1BDDC0D7C}.Release|x86.Build.0 = Release|Any CPU
|
||||
{376B4717-AD51-4775-9B25-2C573F1E6215}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{376B4717-AD51-4775-9B25-2C573F1E6215}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{376B4717-AD51-4775-9B25-2C573F1E6215}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{376B4717-AD51-4775-9B25-2C573F1E6215}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{376B4717-AD51-4775-9B25-2C573F1E6215}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{376B4717-AD51-4775-9B25-2C573F1E6215}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{376B4717-AD51-4775-9B25-2C573F1E6215}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{376B4717-AD51-4775-9B25-2C573F1E6215}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{376B4717-AD51-4775-9B25-2C573F1E6215}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{376B4717-AD51-4775-9B25-2C573F1E6215}.Release|x64.Build.0 = Release|Any CPU
|
||||
{376B4717-AD51-4775-9B25-2C573F1E6215}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{376B4717-AD51-4775-9B25-2C573F1E6215}.Release|x86.Build.0 = Release|Any CPU
|
||||
{429E5D21-7ABE-4A19-B3C3-BBEF97337ADA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{429E5D21-7ABE-4A19-B3C3-BBEF97337ADA}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{429E5D21-7ABE-4A19-B3C3-BBEF97337ADA}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{429E5D21-7ABE-4A19-B3C3-BBEF97337ADA}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{429E5D21-7ABE-4A19-B3C3-BBEF97337ADA}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{429E5D21-7ABE-4A19-B3C3-BBEF97337ADA}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{429E5D21-7ABE-4A19-B3C3-BBEF97337ADA}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{429E5D21-7ABE-4A19-B3C3-BBEF97337ADA}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{429E5D21-7ABE-4A19-B3C3-BBEF97337ADA}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{429E5D21-7ABE-4A19-B3C3-BBEF97337ADA}.Release|x64.Build.0 = Release|Any CPU
|
||||
{429E5D21-7ABE-4A19-B3C3-BBEF97337ADA}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{429E5D21-7ABE-4A19-B3C3-BBEF97337ADA}.Release|x86.Build.0 = Release|Any CPU
|
||||
{30E528B3-0EB1-4A89-8130-F69D3C0F1962}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{30E528B3-0EB1-4A89-8130-F69D3C0F1962}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{30E528B3-0EB1-4A89-8130-F69D3C0F1962}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{30E528B3-0EB1-4A89-8130-F69D3C0F1962}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{30E528B3-0EB1-4A89-8130-F69D3C0F1962}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{30E528B3-0EB1-4A89-8130-F69D3C0F1962}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{30E528B3-0EB1-4A89-8130-F69D3C0F1962}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{30E528B3-0EB1-4A89-8130-F69D3C0F1962}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{30E528B3-0EB1-4A89-8130-F69D3C0F1962}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{30E528B3-0EB1-4A89-8130-F69D3C0F1962}.Release|x64.Build.0 = Release|Any CPU
|
||||
{30E528B3-0EB1-4A89-8130-F69D3C0F1962}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{30E528B3-0EB1-4A89-8130-F69D3C0F1962}.Release|x86.Build.0 = Release|Any CPU
|
||||
{B434D60B-8A05-44EC-ADA6-07C9E2CB1D92}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B434D60B-8A05-44EC-ADA6-07C9E2CB1D92}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B434D60B-8A05-44EC-ADA6-07C9E2CB1D92}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{B434D60B-8A05-44EC-ADA6-07C9E2CB1D92}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{B434D60B-8A05-44EC-ADA6-07C9E2CB1D92}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{B434D60B-8A05-44EC-ADA6-07C9E2CB1D92}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{B434D60B-8A05-44EC-ADA6-07C9E2CB1D92}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B434D60B-8A05-44EC-ADA6-07C9E2CB1D92}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B434D60B-8A05-44EC-ADA6-07C9E2CB1D92}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{B434D60B-8A05-44EC-ADA6-07C9E2CB1D92}.Release|x64.Build.0 = Release|Any CPU
|
||||
{B434D60B-8A05-44EC-ADA6-07C9E2CB1D92}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{B434D60B-8A05-44EC-ADA6-07C9E2CB1D92}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{9258A5D3-2567-4BBA-8F0B-D018E431B7F8} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{30E528B3-0EB1-4A89-8130-F69D3C0F1962} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{B434D60B-8A05-44EC-ADA6-07C9E2CB1D92} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
32
src/Cli/StellaOps.Cli/AGENTS.md
Normal file
32
src/Cli/StellaOps.Cli/AGENTS.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# StellaOps.Cli — Agent Brief
|
||||
|
||||
## Mission
|
||||
- Deliver an offline-capable command-line interface that drives StellaOps back-end operations: scanner distribution, scan execution, result uploads, and Concelier database lifecycle calls (init/resume/export).
|
||||
- Honour StellaOps principles of determinism, observability, and offline-first behaviour while providing a polished operator experience.
|
||||
|
||||
## Role Charter
|
||||
| Role | Mandate | Collaboration |
|
||||
| --- | --- | --- |
|
||||
| **DevEx/CLI** | Own CLI UX, command routing, and configuration model. Ensure commands work with empty/default config and document overrides. | Coordinate with Backend/WebService for API contracts and with Docs for operator workflows. |
|
||||
| **Ops Integrator** | Maintain integration paths for shell/dotnet/docker tooling. Validate that air-gapped runners can bootstrap required binaries. | Work with Concelier/Agent teams to mirror packaging and signing requirements. |
|
||||
| **QA** | Provide command-level fixtures, golden outputs, and regression coverage (unit & smoke). Ensure commands respect cancellation and deterministic logging. | Partner with QA guild for shared harnesses and test data. |
|
||||
|
||||
## Working Agreements
|
||||
- Configuration is centralised in `StellaOps.Configuration`; always consume the bootstrapper instead of hand rolling builders. Env vars (`API_KEY`, `STELLAOPS_BACKEND_URL`, `StellaOps:*`) override JSON/YAML and default to empty values.
|
||||
- Command verbs (`scanner`, `scan`, `db`, `config`) are wired through System.CommandLine 2.0; keep handlers composable, cancellation-aware, and unit-testable.
|
||||
- `scanner download` must verify digests/signatures, install containers locally (docker load), and log artefact metadata.
|
||||
- `scan run` must execute the container against a directory, materialise artefacts in `ResultsDirectory`, and auto-upload them on success; `scan upload` is the manual retry path.
|
||||
- Emit structured console logs (single line, UTC timestamps) and honour offline-first expectations—no hidden network calls.
|
||||
- Mirror repository guidance: stay within `src/Cli/StellaOps.Cli` unless collaborating via documented handshakes.
|
||||
- Update `TASKS.md` as states change (TODO → DOING → DONE/BLOCKED) and record added tests/fixtures alongside implementation notes.
|
||||
|
||||
## Reference Materials
|
||||
- `docs/ARCHITECTURE_CONCELIER.md` for database operations surface area.
|
||||
- Backend OpenAPI/contract docs (once available) for job triggers and scanner endpoints.
|
||||
- Existing module AGENTS/TASKS files for style and coordination cues.
|
||||
- `docs/09_API_CLI_REFERENCE.md` (section 3) for the user-facing synopsis of the CLI verbs and flags.
|
||||
|
||||
### Attestor Command Guild
|
||||
- Owns the `stella attest` verb family (sign, verify, list, fetch) plus key lifecycle helpers (create, import, rotate, revoke).
|
||||
- Ensures all attestation flows use the official SDK transport, support offline bundles, and surface JSON/table outputs for automation.
|
||||
- Guards parity with attestor service policies (verification policies, explainability) and keeps fixtures/tests covering file-based and KMS-backed keys.
|
||||
994
src/Cli/StellaOps.Cli/Commands/CommandFactory.cs
Normal file
994
src/Cli/StellaOps.Cli/Commands/CommandFactory.cs
Normal file
@@ -0,0 +1,994 @@
|
||||
using System;
|
||||
using System.CommandLine;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Plugins;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
internal static class CommandFactory
|
||||
{
|
||||
public static RootCommand Create(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
CancellationToken cancellationToken,
|
||||
ILoggerFactory loggerFactory)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(loggerFactory);
|
||||
|
||||
var verboseOption = new Option<bool>("--verbose", new[] { "-v" })
|
||||
{
|
||||
Description = "Enable verbose logging output."
|
||||
};
|
||||
|
||||
var root = new RootCommand("StellaOps command-line interface")
|
||||
{
|
||||
TreatUnmatchedTokensAsErrors = true
|
||||
};
|
||||
root.Add(verboseOption);
|
||||
|
||||
root.Add(BuildScannerCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildScanCommand(services, options, verboseOption, cancellationToken));
|
||||
root.Add(BuildDatabaseCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildSourcesCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildAocCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildAuthCommand(services, options, verboseOption, cancellationToken));
|
||||
root.Add(BuildPolicyCommand(services, options, verboseOption, cancellationToken));
|
||||
root.Add(BuildFindingsCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildConfigCommand(options));
|
||||
root.Add(BuildVulnCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
var pluginLogger = loggerFactory.CreateLogger<CliCommandModuleLoader>();
|
||||
var pluginLoader = new CliCommandModuleLoader(services, options, pluginLogger);
|
||||
pluginLoader.RegisterModules(root, verboseOption, cancellationToken);
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
private static Command BuildScannerCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var scanner = new Command("scanner", "Manage scanner artifacts and lifecycle.");
|
||||
|
||||
var download = new Command("download", "Download the latest scanner bundle.");
|
||||
var channelOption = new Option<string>("--channel", new[] { "-c" })
|
||||
{
|
||||
Description = "Scanner channel (stable, beta, nightly)."
|
||||
};
|
||||
|
||||
var outputOption = new Option<string?>("--output")
|
||||
{
|
||||
Description = "Optional output path for the downloaded bundle."
|
||||
};
|
||||
|
||||
var overwriteOption = new Option<bool>("--overwrite")
|
||||
{
|
||||
Description = "Overwrite existing bundle if present."
|
||||
};
|
||||
|
||||
var noInstallOption = new Option<bool>("--no-install")
|
||||
{
|
||||
Description = "Skip installing the scanner container after download."
|
||||
};
|
||||
|
||||
download.Add(channelOption);
|
||||
download.Add(outputOption);
|
||||
download.Add(overwriteOption);
|
||||
download.Add(noInstallOption);
|
||||
|
||||
download.SetAction((parseResult, _) =>
|
||||
{
|
||||
var channel = parseResult.GetValue(channelOption) ?? "stable";
|
||||
var output = parseResult.GetValue(outputOption);
|
||||
var overwrite = parseResult.GetValue(overwriteOption);
|
||||
var install = !parseResult.GetValue(noInstallOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return CommandHandlers.HandleScannerDownloadAsync(services, channel, output, overwrite, install, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
scanner.Add(download);
|
||||
return scanner;
|
||||
}
|
||||
|
||||
private static Command BuildScanCommand(IServiceProvider services, StellaOpsCliOptions options, Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var scan = new Command("scan", "Execute scanners and manage scan outputs.");
|
||||
|
||||
var run = new Command("run", "Execute a scanner bundle with the configured runner.");
|
||||
var runnerOption = new Option<string>("--runner")
|
||||
{
|
||||
Description = "Execution runtime (dotnet, self, docker)."
|
||||
};
|
||||
var entryOption = new Option<string>("--entry")
|
||||
{
|
||||
Description = "Path to the scanner entrypoint or Docker image.",
|
||||
Required = true
|
||||
};
|
||||
var targetOption = new Option<string>("--target")
|
||||
{
|
||||
Description = "Directory to scan.",
|
||||
Required = true
|
||||
};
|
||||
|
||||
var argsArgument = new Argument<string[]>("scanner-args")
|
||||
{
|
||||
Arity = ArgumentArity.ZeroOrMore
|
||||
};
|
||||
|
||||
run.Add(runnerOption);
|
||||
run.Add(entryOption);
|
||||
run.Add(targetOption);
|
||||
run.Add(argsArgument);
|
||||
|
||||
run.SetAction((parseResult, _) =>
|
||||
{
|
||||
var runner = parseResult.GetValue(runnerOption) ?? options.DefaultRunner;
|
||||
var entry = parseResult.GetValue(entryOption) ?? string.Empty;
|
||||
var target = parseResult.GetValue(targetOption) ?? string.Empty;
|
||||
var forwardedArgs = parseResult.GetValue(argsArgument) ?? Array.Empty<string>();
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return CommandHandlers.HandleScannerRunAsync(services, runner, entry, target, forwardedArgs, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
var upload = new Command("upload", "Upload completed scan results to the backend.");
|
||||
var fileOption = new Option<string>("--file")
|
||||
{
|
||||
Description = "Path to the scan result artifact.",
|
||||
Required = true
|
||||
};
|
||||
upload.Add(fileOption);
|
||||
upload.SetAction((parseResult, _) =>
|
||||
{
|
||||
var file = parseResult.GetValue(fileOption) ?? string.Empty;
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return CommandHandlers.HandleScanUploadAsync(services, file, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
scan.Add(run);
|
||||
scan.Add(upload);
|
||||
return scan;
|
||||
}
|
||||
|
||||
private static Command BuildDatabaseCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var db = new Command("db", "Trigger Concelier database operations via backend jobs.");
|
||||
|
||||
var fetch = new Command("fetch", "Trigger connector fetch/parse/map stages.");
|
||||
var sourceOption = new Option<string>("--source")
|
||||
{
|
||||
Description = "Connector source identifier (e.g. redhat, osv, vmware).",
|
||||
Required = true
|
||||
};
|
||||
var stageOption = new Option<string>("--stage")
|
||||
{
|
||||
Description = "Stage to trigger: fetch, parse, or map."
|
||||
};
|
||||
var modeOption = new Option<string?>("--mode")
|
||||
{
|
||||
Description = "Optional connector-specific mode (init, resume, cursor)."
|
||||
};
|
||||
|
||||
fetch.Add(sourceOption);
|
||||
fetch.Add(stageOption);
|
||||
fetch.Add(modeOption);
|
||||
fetch.SetAction((parseResult, _) =>
|
||||
{
|
||||
var source = parseResult.GetValue(sourceOption) ?? string.Empty;
|
||||
var stage = parseResult.GetValue(stageOption) ?? "fetch";
|
||||
var mode = parseResult.GetValue(modeOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return CommandHandlers.HandleConnectorJobAsync(services, source, stage, mode, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
var merge = new Command("merge", "Run canonical merge reconciliation.");
|
||||
merge.SetAction((parseResult, _) =>
|
||||
{
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return CommandHandlers.HandleMergeJobAsync(services, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
var export = new Command("export", "Run Concelier export jobs.");
|
||||
var formatOption = new Option<string>("--format")
|
||||
{
|
||||
Description = "Export format: json or trivy-db."
|
||||
};
|
||||
var deltaOption = new Option<bool>("--delta")
|
||||
{
|
||||
Description = "Request a delta export when supported."
|
||||
};
|
||||
var publishFullOption = new Option<bool?>("--publish-full")
|
||||
{
|
||||
Description = "Override whether full exports push to ORAS (true/false)."
|
||||
};
|
||||
var publishDeltaOption = new Option<bool?>("--publish-delta")
|
||||
{
|
||||
Description = "Override whether delta exports push to ORAS (true/false)."
|
||||
};
|
||||
var includeFullOption = new Option<bool?>("--bundle-full")
|
||||
{
|
||||
Description = "Override whether offline bundles include full exports (true/false)."
|
||||
};
|
||||
var includeDeltaOption = new Option<bool?>("--bundle-delta")
|
||||
{
|
||||
Description = "Override whether offline bundles include delta exports (true/false)."
|
||||
};
|
||||
|
||||
export.Add(formatOption);
|
||||
export.Add(deltaOption);
|
||||
export.Add(publishFullOption);
|
||||
export.Add(publishDeltaOption);
|
||||
export.Add(includeFullOption);
|
||||
export.Add(includeDeltaOption);
|
||||
export.SetAction((parseResult, _) =>
|
||||
{
|
||||
var format = parseResult.GetValue(formatOption) ?? "json";
|
||||
var delta = parseResult.GetValue(deltaOption);
|
||||
var publishFull = parseResult.GetValue(publishFullOption);
|
||||
var publishDelta = parseResult.GetValue(publishDeltaOption);
|
||||
var includeFull = parseResult.GetValue(includeFullOption);
|
||||
var includeDelta = parseResult.GetValue(includeDeltaOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return CommandHandlers.HandleExportJobAsync(services, format, delta, publishFull, publishDelta, includeFull, includeDelta, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
db.Add(fetch);
|
||||
db.Add(merge);
|
||||
db.Add(export);
|
||||
return db;
|
||||
}
|
||||
|
||||
private static Command BuildSourcesCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var sources = new Command("sources", "Interact with source ingestion workflows.");
|
||||
|
||||
var ingest = new Command("ingest", "Validate source documents before ingestion.");
|
||||
|
||||
var dryRunOption = new Option<bool>("--dry-run")
|
||||
{
|
||||
Description = "Evaluate guard rules without writing to persistent storage."
|
||||
};
|
||||
|
||||
var sourceOption = new Option<string>("--source")
|
||||
{
|
||||
Description = "Logical source identifier (e.g. redhat, ubuntu, osv).",
|
||||
Required = true
|
||||
};
|
||||
|
||||
var inputOption = new Option<string>("--input")
|
||||
{
|
||||
Description = "Path to a local document or HTTPS URI.",
|
||||
Required = true
|
||||
};
|
||||
|
||||
var tenantOption = new Option<string?>("--tenant")
|
||||
{
|
||||
Description = "Tenant identifier override."
|
||||
};
|
||||
|
||||
var formatOption = new Option<string>("--format")
|
||||
{
|
||||
Description = "Output format: table or json."
|
||||
};
|
||||
|
||||
var noColorOption = new Option<bool>("--no-color")
|
||||
{
|
||||
Description = "Disable ANSI colouring in console output."
|
||||
};
|
||||
|
||||
var outputOption = new Option<string?>("--output")
|
||||
{
|
||||
Description = "Write the JSON report to the specified file path."
|
||||
};
|
||||
|
||||
ingest.Add(dryRunOption);
|
||||
ingest.Add(sourceOption);
|
||||
ingest.Add(inputOption);
|
||||
ingest.Add(tenantOption);
|
||||
ingest.Add(formatOption);
|
||||
ingest.Add(noColorOption);
|
||||
ingest.Add(outputOption);
|
||||
|
||||
ingest.SetAction((parseResult, _) =>
|
||||
{
|
||||
var dryRun = parseResult.GetValue(dryRunOption);
|
||||
var source = parseResult.GetValue(sourceOption) ?? string.Empty;
|
||||
var input = parseResult.GetValue(inputOption) ?? string.Empty;
|
||||
var tenant = parseResult.GetValue(tenantOption);
|
||||
var format = parseResult.GetValue(formatOption) ?? "table";
|
||||
var noColor = parseResult.GetValue(noColorOption);
|
||||
var output = parseResult.GetValue(outputOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return CommandHandlers.HandleSourcesIngestAsync(
|
||||
services,
|
||||
dryRun,
|
||||
source,
|
||||
input,
|
||||
tenant,
|
||||
format,
|
||||
noColor,
|
||||
output,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
sources.Add(ingest);
|
||||
return sources;
|
||||
}
|
||||
|
||||
private static Command BuildAocCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var aoc = new Command("aoc", "Aggregation-Only Contract verification commands.");
|
||||
|
||||
var verify = new Command("verify", "Verify stored raw documents against AOC guardrails.");
|
||||
|
||||
var sinceOption = new Option<string?>("--since")
|
||||
{
|
||||
Description = "Verification window start (ISO-8601 timestamp) or relative duration (e.g. 24h, 7d)."
|
||||
};
|
||||
|
||||
var limitOption = new Option<int?>("--limit")
|
||||
{
|
||||
Description = "Maximum number of violations to include per code (0 = no limit)."
|
||||
};
|
||||
|
||||
var sourcesOption = new Option<string?>("--sources")
|
||||
{
|
||||
Description = "Comma-separated list of sources (e.g. redhat,ubuntu,osv)."
|
||||
};
|
||||
|
||||
var codesOption = new Option<string?>("--codes")
|
||||
{
|
||||
Description = "Comma-separated list of violation codes (ERR_AOC_00x)."
|
||||
};
|
||||
|
||||
var formatOption = new Option<string>("--format")
|
||||
{
|
||||
Description = "Output format: table or json."
|
||||
};
|
||||
|
||||
var exportOption = new Option<string?>("--export")
|
||||
{
|
||||
Description = "Write the JSON report to the specified file path."
|
||||
};
|
||||
|
||||
var tenantOption = new Option<string?>("--tenant")
|
||||
{
|
||||
Description = "Tenant identifier override."
|
||||
};
|
||||
|
||||
var noColorOption = new Option<bool>("--no-color")
|
||||
{
|
||||
Description = "Disable ANSI colouring in console output."
|
||||
};
|
||||
|
||||
verify.Add(sinceOption);
|
||||
verify.Add(limitOption);
|
||||
verify.Add(sourcesOption);
|
||||
verify.Add(codesOption);
|
||||
verify.Add(formatOption);
|
||||
verify.Add(exportOption);
|
||||
verify.Add(tenantOption);
|
||||
verify.Add(noColorOption);
|
||||
|
||||
verify.SetAction((parseResult, _) =>
|
||||
{
|
||||
var since = parseResult.GetValue(sinceOption);
|
||||
var limit = parseResult.GetValue(limitOption);
|
||||
var sources = parseResult.GetValue(sourcesOption);
|
||||
var codes = parseResult.GetValue(codesOption);
|
||||
var format = parseResult.GetValue(formatOption) ?? "table";
|
||||
var export = parseResult.GetValue(exportOption);
|
||||
var tenant = parseResult.GetValue(tenantOption);
|
||||
var noColor = parseResult.GetValue(noColorOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return CommandHandlers.HandleAocVerifyAsync(
|
||||
services,
|
||||
since,
|
||||
limit,
|
||||
sources,
|
||||
codes,
|
||||
format,
|
||||
export,
|
||||
tenant,
|
||||
noColor,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
aoc.Add(verify);
|
||||
return aoc;
|
||||
}
|
||||
|
||||
private static Command BuildAuthCommand(IServiceProvider services, StellaOpsCliOptions options, Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var auth = new Command("auth", "Manage authentication with StellaOps Authority.");
|
||||
|
||||
var login = new Command("login", "Acquire and cache access tokens using the configured credentials.");
|
||||
var forceOption = new Option<bool>("--force")
|
||||
{
|
||||
Description = "Ignore existing cached tokens and force re-authentication."
|
||||
};
|
||||
login.Add(forceOption);
|
||||
login.SetAction((parseResult, _) =>
|
||||
{
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
var force = parseResult.GetValue(forceOption);
|
||||
return CommandHandlers.HandleAuthLoginAsync(services, options, verbose, force, cancellationToken);
|
||||
});
|
||||
|
||||
var logout = new Command("logout", "Remove cached tokens for the current credentials.");
|
||||
logout.SetAction((parseResult, _) =>
|
||||
{
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return CommandHandlers.HandleAuthLogoutAsync(services, options, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
var status = new Command("status", "Display cached token status.");
|
||||
status.SetAction((parseResult, _) =>
|
||||
{
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return CommandHandlers.HandleAuthStatusAsync(services, options, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
var whoami = new Command("whoami", "Display cached token claims (subject, scopes, expiry).");
|
||||
whoami.SetAction((parseResult, _) =>
|
||||
{
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return CommandHandlers.HandleAuthWhoAmIAsync(services, options, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
var revoke = new Command("revoke", "Manage revocation exports.");
|
||||
var export = new Command("export", "Export the revocation bundle and signature to disk.");
|
||||
var outputOption = new Option<string?>("--output")
|
||||
{
|
||||
Description = "Directory to write exported revocation files (defaults to current directory)."
|
||||
};
|
||||
export.Add(outputOption);
|
||||
export.SetAction((parseResult, _) =>
|
||||
{
|
||||
var output = parseResult.GetValue(outputOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return CommandHandlers.HandleAuthRevokeExportAsync(services, options, output, verbose, cancellationToken);
|
||||
});
|
||||
revoke.Add(export);
|
||||
var verify = new Command("verify", "Verify a revocation bundle against a detached JWS signature.");
|
||||
var bundleOption = new Option<string>("--bundle")
|
||||
{
|
||||
Description = "Path to the revocation-bundle.json file."
|
||||
};
|
||||
var signatureOption = new Option<string>("--signature")
|
||||
{
|
||||
Description = "Path to the revocation-bundle.json.jws file."
|
||||
};
|
||||
var keyOption = new Option<string>("--key")
|
||||
{
|
||||
Description = "Path to the PEM-encoded public/private key used for verification."
|
||||
};
|
||||
verify.Add(bundleOption);
|
||||
verify.Add(signatureOption);
|
||||
verify.Add(keyOption);
|
||||
verify.SetAction((parseResult, _) =>
|
||||
{
|
||||
var bundlePath = parseResult.GetValue(bundleOption) ?? string.Empty;
|
||||
var signaturePath = parseResult.GetValue(signatureOption) ?? string.Empty;
|
||||
var keyPath = parseResult.GetValue(keyOption) ?? string.Empty;
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return CommandHandlers.HandleAuthRevokeVerifyAsync(bundlePath, signaturePath, keyPath, verbose, cancellationToken);
|
||||
});
|
||||
revoke.Add(verify);
|
||||
|
||||
auth.Add(login);
|
||||
auth.Add(logout);
|
||||
auth.Add(status);
|
||||
auth.Add(whoami);
|
||||
auth.Add(revoke);
|
||||
return auth;
|
||||
}
|
||||
|
||||
private static Command BuildPolicyCommand(IServiceProvider services, StellaOpsCliOptions options, Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
_ = options;
|
||||
var policy = new Command("policy", "Interact with Policy Engine operations.");
|
||||
|
||||
var simulate = new Command("simulate", "Simulate a policy revision against selected SBOMs and environment.");
|
||||
var policyIdArgument = new Argument<string>("policy-id")
|
||||
{
|
||||
Description = "Policy identifier (e.g. P-7)."
|
||||
};
|
||||
simulate.Add(policyIdArgument);
|
||||
|
||||
var baseOption = new Option<int?>("--base")
|
||||
{
|
||||
Description = "Base policy version for diff calculations."
|
||||
};
|
||||
var candidateOption = new Option<int?>("--candidate")
|
||||
{
|
||||
Description = "Candidate policy version. Defaults to latest approved."
|
||||
};
|
||||
var sbomOption = new Option<string[]>("--sbom")
|
||||
{
|
||||
Description = "SBOM identifier to include (repeatable).",
|
||||
Arity = ArgumentArity.ZeroOrMore
|
||||
};
|
||||
sbomOption.AllowMultipleArgumentsPerToken = true;
|
||||
|
||||
var envOption = new Option<string[]>("--env")
|
||||
{
|
||||
Description = "Environment override (key=value, repeatable).",
|
||||
Arity = ArgumentArity.ZeroOrMore
|
||||
};
|
||||
envOption.AllowMultipleArgumentsPerToken = true;
|
||||
|
||||
var formatOption = new Option<string?>("--format")
|
||||
{
|
||||
Description = "Output format: table or json."
|
||||
};
|
||||
var outputOption = new Option<string?>("--output")
|
||||
{
|
||||
Description = "Write JSON output to the specified file."
|
||||
};
|
||||
var explainOption = new Option<bool>("--explain")
|
||||
{
|
||||
Description = "Request explain traces for diffed findings."
|
||||
};
|
||||
var failOnDiffOption = new Option<bool>("--fail-on-diff")
|
||||
{
|
||||
Description = "Exit with code 20 when findings are added or removed."
|
||||
};
|
||||
|
||||
simulate.Add(baseOption);
|
||||
simulate.Add(candidateOption);
|
||||
simulate.Add(sbomOption);
|
||||
simulate.Add(envOption);
|
||||
simulate.Add(formatOption);
|
||||
simulate.Add(outputOption);
|
||||
simulate.Add(explainOption);
|
||||
simulate.Add(failOnDiffOption);
|
||||
|
||||
simulate.SetAction((parseResult, _) =>
|
||||
{
|
||||
var policyId = parseResult.GetValue(policyIdArgument) ?? string.Empty;
|
||||
var baseVersion = parseResult.GetValue(baseOption);
|
||||
var candidateVersion = parseResult.GetValue(candidateOption);
|
||||
var sbomSet = parseResult.GetValue(sbomOption) ?? Array.Empty<string>();
|
||||
var environment = parseResult.GetValue(envOption) ?? Array.Empty<string>();
|
||||
var format = parseResult.GetValue(formatOption);
|
||||
var output = parseResult.GetValue(outputOption);
|
||||
var explain = parseResult.GetValue(explainOption);
|
||||
var failOnDiff = parseResult.GetValue(failOnDiffOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return CommandHandlers.HandlePolicySimulateAsync(
|
||||
services,
|
||||
policyId,
|
||||
baseVersion,
|
||||
candidateVersion,
|
||||
sbomSet,
|
||||
environment,
|
||||
format,
|
||||
output,
|
||||
explain,
|
||||
failOnDiff,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
policy.Add(simulate);
|
||||
|
||||
var activate = new Command("activate", "Activate an approved policy revision.");
|
||||
var activatePolicyIdArgument = new Argument<string>("policy-id")
|
||||
{
|
||||
Description = "Policy identifier (e.g. P-7)."
|
||||
};
|
||||
activate.Add(activatePolicyIdArgument);
|
||||
|
||||
var activateVersionOption = new Option<int>("--version")
|
||||
{
|
||||
Description = "Revision version to activate."
|
||||
};
|
||||
|
||||
var activationNoteOption = new Option<string?>("--note")
|
||||
{
|
||||
Description = "Optional activation note recorded with the approval."
|
||||
};
|
||||
|
||||
var runNowOption = new Option<bool>("--run-now")
|
||||
{
|
||||
Description = "Trigger an immediate full policy run after activation."
|
||||
};
|
||||
|
||||
var scheduledAtOption = new Option<string?>("--scheduled-at")
|
||||
{
|
||||
Description = "Schedule activation at the provided ISO-8601 timestamp."
|
||||
};
|
||||
|
||||
var priorityOption = new Option<string?>("--priority")
|
||||
{
|
||||
Description = "Optional activation priority label (e.g. low, standard, high)."
|
||||
};
|
||||
|
||||
var rollbackOption = new Option<bool>("--rollback")
|
||||
{
|
||||
Description = "Indicate that this activation is a rollback to a previous version."
|
||||
};
|
||||
|
||||
var incidentOption = new Option<string?>("--incident")
|
||||
{
|
||||
Description = "Associate the activation with an incident identifier."
|
||||
};
|
||||
|
||||
activate.Add(activateVersionOption);
|
||||
activate.Add(activationNoteOption);
|
||||
activate.Add(runNowOption);
|
||||
activate.Add(scheduledAtOption);
|
||||
activate.Add(priorityOption);
|
||||
activate.Add(rollbackOption);
|
||||
activate.Add(incidentOption);
|
||||
|
||||
activate.SetAction((parseResult, _) =>
|
||||
{
|
||||
var policyId = parseResult.GetValue(activatePolicyIdArgument) ?? string.Empty;
|
||||
var version = parseResult.GetValue(activateVersionOption);
|
||||
var note = parseResult.GetValue(activationNoteOption);
|
||||
var runNow = parseResult.GetValue(runNowOption);
|
||||
var scheduledAt = parseResult.GetValue(scheduledAtOption);
|
||||
var priority = parseResult.GetValue(priorityOption);
|
||||
var rollback = parseResult.GetValue(rollbackOption);
|
||||
var incident = parseResult.GetValue(incidentOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return CommandHandlers.HandlePolicyActivateAsync(
|
||||
services,
|
||||
policyId,
|
||||
version,
|
||||
note,
|
||||
runNow,
|
||||
scheduledAt,
|
||||
priority,
|
||||
rollback,
|
||||
incident,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
policy.Add(activate);
|
||||
return policy;
|
||||
}
|
||||
|
||||
private static Command BuildFindingsCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var findings = new Command("findings", "Inspect policy findings.");
|
||||
|
||||
var list = new Command("ls", "List effective findings that match the provided filters.");
|
||||
var policyOption = new Option<string>("--policy")
|
||||
{
|
||||
Description = "Policy identifier (e.g. P-7).",
|
||||
Required = true
|
||||
};
|
||||
var sbomOption = new Option<string[]>("--sbom")
|
||||
{
|
||||
Description = "Filter by SBOM identifier (repeatable).",
|
||||
Arity = ArgumentArity.ZeroOrMore
|
||||
};
|
||||
sbomOption.AllowMultipleArgumentsPerToken = true;
|
||||
|
||||
var statusOption = new Option<string[]>("--status")
|
||||
{
|
||||
Description = "Filter by finding status (repeatable).",
|
||||
Arity = ArgumentArity.ZeroOrMore
|
||||
};
|
||||
statusOption.AllowMultipleArgumentsPerToken = true;
|
||||
|
||||
var severityOption = new Option<string[]>("--severity")
|
||||
{
|
||||
Description = "Filter by severity label (repeatable).",
|
||||
Arity = ArgumentArity.ZeroOrMore
|
||||
};
|
||||
severityOption.AllowMultipleArgumentsPerToken = true;
|
||||
|
||||
var sinceOption = new Option<string?>("--since")
|
||||
{
|
||||
Description = "Filter by last-updated timestamp (ISO-8601)."
|
||||
};
|
||||
var cursorOption = new Option<string?>("--cursor")
|
||||
{
|
||||
Description = "Resume listing from the provided cursor."
|
||||
};
|
||||
var pageOption = new Option<int?>("--page")
|
||||
{
|
||||
Description = "Page number (starts at 1)."
|
||||
};
|
||||
var pageSizeOption = new Option<int?>("--page-size")
|
||||
{
|
||||
Description = "Results per page (default backend limit applies)."
|
||||
};
|
||||
var formatOption = new Option<string?>("--format")
|
||||
{
|
||||
Description = "Output format: table or json."
|
||||
};
|
||||
var outputOption = new Option<string?>("--output")
|
||||
{
|
||||
Description = "Write JSON payload to the specified file."
|
||||
};
|
||||
|
||||
list.Add(policyOption);
|
||||
list.Add(sbomOption);
|
||||
list.Add(statusOption);
|
||||
list.Add(severityOption);
|
||||
list.Add(sinceOption);
|
||||
list.Add(cursorOption);
|
||||
list.Add(pageOption);
|
||||
list.Add(pageSizeOption);
|
||||
list.Add(formatOption);
|
||||
list.Add(outputOption);
|
||||
|
||||
list.SetAction((parseResult, _) =>
|
||||
{
|
||||
var policy = parseResult.GetValue(policyOption) ?? string.Empty;
|
||||
var sboms = parseResult.GetValue(sbomOption) ?? Array.Empty<string>();
|
||||
var statuses = parseResult.GetValue(statusOption) ?? Array.Empty<string>();
|
||||
var severities = parseResult.GetValue(severityOption) ?? Array.Empty<string>();
|
||||
var since = parseResult.GetValue(sinceOption);
|
||||
var cursor = parseResult.GetValue(cursorOption);
|
||||
var page = parseResult.GetValue(pageOption);
|
||||
var pageSize = parseResult.GetValue(pageSizeOption);
|
||||
var selectedFormat = parseResult.GetValue(formatOption);
|
||||
var output = parseResult.GetValue(outputOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return CommandHandlers.HandlePolicyFindingsListAsync(
|
||||
services,
|
||||
policy,
|
||||
sboms,
|
||||
statuses,
|
||||
severities,
|
||||
since,
|
||||
cursor,
|
||||
page,
|
||||
pageSize,
|
||||
selectedFormat,
|
||||
output,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
var get = new Command("get", "Retrieve a specific finding.");
|
||||
var findingArgument = new Argument<string>("finding-id")
|
||||
{
|
||||
Description = "Finding identifier (e.g. P-7:S-42:pkg:...)."
|
||||
};
|
||||
var getPolicyOption = new Option<string>("--policy")
|
||||
{
|
||||
Description = "Policy identifier for the finding.",
|
||||
Required = true
|
||||
};
|
||||
var getFormatOption = new Option<string?>("--format")
|
||||
{
|
||||
Description = "Output format: table or json."
|
||||
};
|
||||
var getOutputOption = new Option<string?>("--output")
|
||||
{
|
||||
Description = "Write JSON payload to the specified file."
|
||||
};
|
||||
|
||||
get.Add(findingArgument);
|
||||
get.Add(getPolicyOption);
|
||||
get.Add(getFormatOption);
|
||||
get.Add(getOutputOption);
|
||||
|
||||
get.SetAction((parseResult, _) =>
|
||||
{
|
||||
var policy = parseResult.GetValue(getPolicyOption) ?? string.Empty;
|
||||
var finding = parseResult.GetValue(findingArgument) ?? string.Empty;
|
||||
var selectedFormat = parseResult.GetValue(getFormatOption);
|
||||
var output = parseResult.GetValue(getOutputOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return CommandHandlers.HandlePolicyFindingsGetAsync(
|
||||
services,
|
||||
policy,
|
||||
finding,
|
||||
selectedFormat,
|
||||
output,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
var explain = new Command("explain", "Fetch explain trace for a finding.");
|
||||
var explainFindingArgument = new Argument<string>("finding-id")
|
||||
{
|
||||
Description = "Finding identifier."
|
||||
};
|
||||
var explainPolicyOption = new Option<string>("--policy")
|
||||
{
|
||||
Description = "Policy identifier.",
|
||||
Required = true
|
||||
};
|
||||
var modeOption = new Option<string?>("--mode")
|
||||
{
|
||||
Description = "Explain mode (for example: verbose)."
|
||||
};
|
||||
var explainFormatOption = new Option<string?>("--format")
|
||||
{
|
||||
Description = "Output format: table or json."
|
||||
};
|
||||
var explainOutputOption = new Option<string?>("--output")
|
||||
{
|
||||
Description = "Write JSON payload to the specified file."
|
||||
};
|
||||
|
||||
explain.Add(explainFindingArgument);
|
||||
explain.Add(explainPolicyOption);
|
||||
explain.Add(modeOption);
|
||||
explain.Add(explainFormatOption);
|
||||
explain.Add(explainOutputOption);
|
||||
|
||||
explain.SetAction((parseResult, _) =>
|
||||
{
|
||||
var policy = parseResult.GetValue(explainPolicyOption) ?? string.Empty;
|
||||
var finding = parseResult.GetValue(explainFindingArgument) ?? string.Empty;
|
||||
var mode = parseResult.GetValue(modeOption);
|
||||
var selectedFormat = parseResult.GetValue(explainFormatOption);
|
||||
var output = parseResult.GetValue(explainOutputOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return CommandHandlers.HandlePolicyFindingsExplainAsync(
|
||||
services,
|
||||
policy,
|
||||
finding,
|
||||
mode,
|
||||
selectedFormat,
|
||||
output,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
findings.Add(list);
|
||||
findings.Add(get);
|
||||
findings.Add(explain);
|
||||
return findings;
|
||||
}
|
||||
|
||||
private static Command BuildVulnCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var vuln = new Command("vuln", "Explore vulnerability observations and overlays.");
|
||||
|
||||
var observations = new Command("observations", "List raw advisory observations for overlay consumers.");
|
||||
|
||||
var tenantOption = new Option<string>("--tenant")
|
||||
{
|
||||
Description = "Tenant identifier.",
|
||||
Required = true
|
||||
};
|
||||
var observationIdOption = new Option<string[]>("--observation-id")
|
||||
{
|
||||
Description = "Filter by observation identifier (repeatable).",
|
||||
Arity = ArgumentArity.ZeroOrMore
|
||||
};
|
||||
var aliasOption = new Option<string[]>("--alias")
|
||||
{
|
||||
Description = "Filter by vulnerability alias (repeatable).",
|
||||
Arity = ArgumentArity.ZeroOrMore
|
||||
};
|
||||
var purlOption = new Option<string[]>("--purl")
|
||||
{
|
||||
Description = "Filter by Package URL (repeatable).",
|
||||
Arity = ArgumentArity.ZeroOrMore
|
||||
};
|
||||
var cpeOption = new Option<string[]>("--cpe")
|
||||
{
|
||||
Description = "Filter by CPE value (repeatable).",
|
||||
Arity = ArgumentArity.ZeroOrMore
|
||||
};
|
||||
var jsonOption = new Option<bool>("--json")
|
||||
{
|
||||
Description = "Emit raw JSON payload instead of a table."
|
||||
};
|
||||
var limitOption = new Option<int?>("--limit")
|
||||
{
|
||||
Description = "Maximum number of observations to return (default 200, max 500)."
|
||||
};
|
||||
var cursorOption = new Option<string?>("--cursor")
|
||||
{
|
||||
Description = "Opaque cursor token returned by a previous page."
|
||||
};
|
||||
|
||||
observations.Add(tenantOption);
|
||||
observations.Add(observationIdOption);
|
||||
observations.Add(aliasOption);
|
||||
observations.Add(purlOption);
|
||||
observations.Add(cpeOption);
|
||||
observations.Add(limitOption);
|
||||
observations.Add(cursorOption);
|
||||
observations.Add(jsonOption);
|
||||
|
||||
observations.SetAction((parseResult, _) =>
|
||||
{
|
||||
var tenant = parseResult.GetValue(tenantOption) ?? string.Empty;
|
||||
var observationIds = parseResult.GetValue(observationIdOption) ?? Array.Empty<string>();
|
||||
var aliases = parseResult.GetValue(aliasOption) ?? Array.Empty<string>();
|
||||
var purls = parseResult.GetValue(purlOption) ?? Array.Empty<string>();
|
||||
var cpes = parseResult.GetValue(cpeOption) ?? Array.Empty<string>();
|
||||
var limit = parseResult.GetValue(limitOption);
|
||||
var cursor = parseResult.GetValue(cursorOption);
|
||||
var emitJson = parseResult.GetValue(jsonOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return CommandHandlers.HandleVulnObservationsAsync(
|
||||
services,
|
||||
tenant,
|
||||
observationIds,
|
||||
aliases,
|
||||
purls,
|
||||
cpes,
|
||||
limit,
|
||||
cursor,
|
||||
emitJson,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
vuln.Add(observations);
|
||||
return vuln;
|
||||
}
|
||||
|
||||
private static Command BuildConfigCommand(StellaOpsCliOptions options)
|
||||
{
|
||||
var config = new Command("config", "Inspect CLI configuration state.");
|
||||
var show = new Command("show", "Display resolved configuration values.");
|
||||
|
||||
show.SetAction((_, _) =>
|
||||
{
|
||||
var authority = options.Authority ?? new StellaOpsCliAuthorityOptions();
|
||||
var lines = new[]
|
||||
{
|
||||
$"Backend URL: {MaskIfEmpty(options.BackendUrl)}",
|
||||
$"Concelier URL: {MaskIfEmpty(options.ConcelierUrl)}",
|
||||
$"API Key: {DescribeSecret(options.ApiKey)}",
|
||||
$"Scanner Cache: {options.ScannerCacheDirectory}",
|
||||
$"Results Directory: {options.ResultsDirectory}",
|
||||
$"Default Runner: {options.DefaultRunner}",
|
||||
$"Authority URL: {MaskIfEmpty(authority.Url)}",
|
||||
$"Authority Client ID: {MaskIfEmpty(authority.ClientId)}",
|
||||
$"Authority Client Secret: {DescribeSecret(authority.ClientSecret ?? string.Empty)}",
|
||||
$"Authority Username: {MaskIfEmpty(authority.Username)}",
|
||||
$"Authority Password: {DescribeSecret(authority.Password ?? string.Empty)}",
|
||||
$"Authority Scope: {MaskIfEmpty(authority.Scope)}",
|
||||
$"Authority Token Cache: {MaskIfEmpty(authority.TokenCacheDirectory ?? string.Empty)}"
|
||||
};
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
Console.WriteLine(line);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
config.Add(show);
|
||||
return config;
|
||||
}
|
||||
|
||||
private static string MaskIfEmpty(string value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? "<not configured>" : value;
|
||||
|
||||
private static string DescribeSecret(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return "<not configured>";
|
||||
}
|
||||
|
||||
return value.Length switch
|
||||
{
|
||||
<= 4 => "****",
|
||||
_ => $"{value[..2]}***{value[^2..]}"
|
||||
};
|
||||
}
|
||||
}
|
||||
5638
src/Cli/StellaOps.Cli/Commands/CommandHandlers.cs
Normal file
5638
src/Cli/StellaOps.Cli/Commands/CommandHandlers.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,58 @@
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
|
||||
namespace StellaOps.Cli.Configuration;
|
||||
|
||||
internal static class AuthorityTokenUtilities
|
||||
{
|
||||
public static string ResolveScope(StellaOpsCliOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var scope = options.Authority?.Scope;
|
||||
return string.IsNullOrWhiteSpace(scope)
|
||||
? StellaOpsScopes.ConcelierJobsTrigger
|
||||
: scope.Trim();
|
||||
}
|
||||
|
||||
public static string BuildCacheKey(StellaOpsCliOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
if (options.Authority is null)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var scope = ResolveScope(options);
|
||||
var credential = !string.IsNullOrWhiteSpace(options.Authority.Username)
|
||||
? $"user:{options.Authority.Username}"
|
||||
: $"client:{options.Authority.ClientId}";
|
||||
|
||||
var cacheKey = $"{options.Authority.Url}|{credential}|{scope}";
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(scope) && scope.Contains("orch:operate", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var reasonHash = HashOperatorMetadata(options.Authority.OperatorReason);
|
||||
var ticketHash = HashOperatorMetadata(options.Authority.OperatorTicket);
|
||||
cacheKey = $"{cacheKey}|op_reason:{reasonHash}|op_ticket:{ticketHash}";
|
||||
}
|
||||
|
||||
return cacheKey;
|
||||
}
|
||||
|
||||
private static string HashOperatorMetadata(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return "none";
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
var bytes = Encoding.UTF8.GetBytes(trimmed);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
418
src/Cli/StellaOps.Cli/Configuration/CliBootstrapper.cs
Normal file
418
src/Cli/StellaOps.Cli/Configuration/CliBootstrapper.cs
Normal file
@@ -0,0 +1,418 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
|
||||
namespace StellaOps.Cli.Configuration;
|
||||
|
||||
public static class CliBootstrapper
|
||||
{
|
||||
public static (StellaOpsCliOptions Options, IConfigurationRoot Configuration) Build(string[] args)
|
||||
{
|
||||
var bootstrap = StellaOpsConfigurationBootstrapper.Build<StellaOpsCliOptions>(options =>
|
||||
{
|
||||
options.BindingSection = "StellaOps";
|
||||
options.ConfigureBuilder = builder =>
|
||||
{
|
||||
if (args.Length > 0)
|
||||
{
|
||||
builder.AddCommandLine(args);
|
||||
}
|
||||
};
|
||||
options.PostBind = (cliOptions, configuration) =>
|
||||
{
|
||||
cliOptions.ApiKey = ResolveWithFallback(cliOptions.ApiKey, configuration, "API_KEY", "StellaOps:ApiKey", "ApiKey");
|
||||
cliOptions.BackendUrl = ResolveWithFallback(cliOptions.BackendUrl, configuration, "STELLAOPS_BACKEND_URL", "StellaOps:BackendUrl", "BackendUrl");
|
||||
cliOptions.ConcelierUrl = ResolveWithFallback(cliOptions.ConcelierUrl, configuration, "STELLAOPS_CONCELIER_URL", "StellaOps:ConcelierUrl", "ConcelierUrl");
|
||||
cliOptions.ScannerSignaturePublicKeyPath = ResolveWithFallback(cliOptions.ScannerSignaturePublicKeyPath, configuration, "SCANNER_PUBLIC_KEY", "STELLAOPS_SCANNER_PUBLIC_KEY", "StellaOps:ScannerSignaturePublicKeyPath", "ScannerSignaturePublicKeyPath");
|
||||
|
||||
cliOptions.ApiKey = cliOptions.ApiKey?.Trim() ?? string.Empty;
|
||||
cliOptions.BackendUrl = cliOptions.BackendUrl?.Trim() ?? string.Empty;
|
||||
cliOptions.ConcelierUrl = cliOptions.ConcelierUrl?.Trim() ?? string.Empty;
|
||||
cliOptions.ScannerSignaturePublicKeyPath = cliOptions.ScannerSignaturePublicKeyPath?.Trim() ?? string.Empty;
|
||||
|
||||
var attemptsRaw = ResolveWithFallback(
|
||||
string.Empty,
|
||||
configuration,
|
||||
"SCANNER_DOWNLOAD_ATTEMPTS",
|
||||
"STELLAOPS_SCANNER_DOWNLOAD_ATTEMPTS",
|
||||
"StellaOps:ScannerDownloadAttempts",
|
||||
"ScannerDownloadAttempts");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(attemptsRaw))
|
||||
{
|
||||
attemptsRaw = cliOptions.ScannerDownloadAttempts.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
if (int.TryParse(attemptsRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedAttempts) && parsedAttempts > 0)
|
||||
{
|
||||
cliOptions.ScannerDownloadAttempts = parsedAttempts;
|
||||
}
|
||||
|
||||
if (cliOptions.ScannerDownloadAttempts <= 0)
|
||||
{
|
||||
cliOptions.ScannerDownloadAttempts = 3;
|
||||
}
|
||||
|
||||
cliOptions.Authority ??= new StellaOpsCliAuthorityOptions();
|
||||
var authority = cliOptions.Authority;
|
||||
|
||||
authority.Url = ResolveWithFallback(
|
||||
authority.Url,
|
||||
configuration,
|
||||
"STELLAOPS_AUTHORITY_URL",
|
||||
"StellaOps:Authority:Url",
|
||||
"Authority:Url",
|
||||
"Authority:Issuer");
|
||||
|
||||
authority.ClientId = ResolveWithFallback(
|
||||
authority.ClientId,
|
||||
configuration,
|
||||
"STELLAOPS_AUTHORITY_CLIENT_ID",
|
||||
"StellaOps:Authority:ClientId",
|
||||
"Authority:ClientId");
|
||||
|
||||
authority.ClientSecret = ResolveWithFallback(
|
||||
authority.ClientSecret ?? string.Empty,
|
||||
configuration,
|
||||
"STELLAOPS_AUTHORITY_CLIENT_SECRET",
|
||||
"StellaOps:Authority:ClientSecret",
|
||||
"Authority:ClientSecret");
|
||||
|
||||
authority.Username = ResolveWithFallback(
|
||||
authority.Username,
|
||||
configuration,
|
||||
"STELLAOPS_AUTHORITY_USERNAME",
|
||||
"StellaOps:Authority:Username",
|
||||
"Authority:Username");
|
||||
|
||||
authority.Password = ResolveWithFallback(
|
||||
authority.Password ?? string.Empty,
|
||||
configuration,
|
||||
"STELLAOPS_AUTHORITY_PASSWORD",
|
||||
"StellaOps:Authority:Password",
|
||||
"Authority:Password");
|
||||
|
||||
authority.Scope = ResolveWithFallback(
|
||||
authority.Scope,
|
||||
configuration,
|
||||
"STELLAOPS_AUTHORITY_SCOPE",
|
||||
"StellaOps:Authority:Scope",
|
||||
"Authority:Scope");
|
||||
|
||||
authority.OperatorReason = ResolveWithFallback(
|
||||
authority.OperatorReason,
|
||||
configuration,
|
||||
"STELLAOPS_ORCH_REASON",
|
||||
"StellaOps:Authority:OperatorReason",
|
||||
"Authority:OperatorReason");
|
||||
|
||||
authority.OperatorTicket = ResolveWithFallback(
|
||||
authority.OperatorTicket,
|
||||
configuration,
|
||||
"STELLAOPS_ORCH_TICKET",
|
||||
"StellaOps:Authority:OperatorTicket",
|
||||
"Authority:OperatorTicket");
|
||||
|
||||
authority.TokenCacheDirectory = ResolveWithFallback(
|
||||
authority.TokenCacheDirectory,
|
||||
configuration,
|
||||
"STELLAOPS_AUTHORITY_TOKEN_CACHE_DIR",
|
||||
"StellaOps:Authority:TokenCacheDirectory",
|
||||
"Authority:TokenCacheDirectory");
|
||||
|
||||
authority.Url = authority.Url?.Trim() ?? string.Empty;
|
||||
authority.ClientId = authority.ClientId?.Trim() ?? string.Empty;
|
||||
authority.ClientSecret = string.IsNullOrWhiteSpace(authority.ClientSecret) ? null : authority.ClientSecret.Trim();
|
||||
authority.Username = authority.Username?.Trim() ?? string.Empty;
|
||||
authority.Password = string.IsNullOrWhiteSpace(authority.Password) ? null : authority.Password.Trim();
|
||||
authority.Scope = string.IsNullOrWhiteSpace(authority.Scope) ? StellaOpsScopes.ConcelierJobsTrigger : authority.Scope.Trim();
|
||||
authority.OperatorReason = authority.OperatorReason?.Trim() ?? string.Empty;
|
||||
authority.OperatorTicket = authority.OperatorTicket?.Trim() ?? string.Empty;
|
||||
|
||||
authority.Resilience ??= new StellaOpsCliAuthorityResilienceOptions();
|
||||
authority.Resilience.RetryDelays ??= new List<TimeSpan>();
|
||||
var resilience = authority.Resilience;
|
||||
|
||||
if (!resilience.EnableRetries.HasValue)
|
||||
{
|
||||
var raw = ResolveWithFallback(
|
||||
string.Empty,
|
||||
configuration,
|
||||
"STELLAOPS_AUTHORITY_ENABLE_RETRIES",
|
||||
"StellaOps:Authority:Resilience:EnableRetries",
|
||||
"StellaOps:Authority:EnableRetries",
|
||||
"Authority:Resilience:EnableRetries",
|
||||
"Authority:EnableRetries");
|
||||
|
||||
if (TryParseBoolean(raw, out var parsed))
|
||||
{
|
||||
resilience.EnableRetries = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
var retryDelaysRaw = ResolveWithFallback(
|
||||
string.Empty,
|
||||
configuration,
|
||||
"STELLAOPS_AUTHORITY_RETRY_DELAYS",
|
||||
"StellaOps:Authority:Resilience:RetryDelays",
|
||||
"StellaOps:Authority:RetryDelays",
|
||||
"Authority:Resilience:RetryDelays",
|
||||
"Authority:RetryDelays");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(retryDelaysRaw))
|
||||
{
|
||||
resilience.RetryDelays.Clear();
|
||||
foreach (var delay in ParseRetryDelays(retryDelaysRaw))
|
||||
{
|
||||
if (delay > TimeSpan.Zero)
|
||||
{
|
||||
resilience.RetryDelays.Add(delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!resilience.AllowOfflineCacheFallback.HasValue)
|
||||
{
|
||||
var raw = ResolveWithFallback(
|
||||
string.Empty,
|
||||
configuration,
|
||||
"STELLAOPS_AUTHORITY_ALLOW_OFFLINE_CACHE_FALLBACK",
|
||||
"StellaOps:Authority:Resilience:AllowOfflineCacheFallback",
|
||||
"StellaOps:Authority:AllowOfflineCacheFallback",
|
||||
"Authority:Resilience:AllowOfflineCacheFallback",
|
||||
"Authority:AllowOfflineCacheFallback");
|
||||
|
||||
if (TryParseBoolean(raw, out var parsed))
|
||||
{
|
||||
resilience.AllowOfflineCacheFallback = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
if (!resilience.OfflineCacheTolerance.HasValue)
|
||||
{
|
||||
var raw = ResolveWithFallback(
|
||||
string.Empty,
|
||||
configuration,
|
||||
"STELLAOPS_AUTHORITY_OFFLINE_CACHE_TOLERANCE",
|
||||
"StellaOps:Authority:Resilience:OfflineCacheTolerance",
|
||||
"StellaOps:Authority:OfflineCacheTolerance",
|
||||
"Authority:Resilience:OfflineCacheTolerance",
|
||||
"Authority:OfflineCacheTolerance");
|
||||
|
||||
if (TimeSpan.TryParse(raw, CultureInfo.InvariantCulture, out var tolerance) && tolerance >= TimeSpan.Zero)
|
||||
{
|
||||
resilience.OfflineCacheTolerance = tolerance;
|
||||
}
|
||||
}
|
||||
|
||||
var defaultTokenCache = GetDefaultTokenCacheDirectory();
|
||||
if (string.IsNullOrWhiteSpace(authority.TokenCacheDirectory))
|
||||
{
|
||||
authority.TokenCacheDirectory = defaultTokenCache;
|
||||
}
|
||||
else
|
||||
{
|
||||
authority.TokenCacheDirectory = Path.GetFullPath(authority.TokenCacheDirectory);
|
||||
}
|
||||
|
||||
cliOptions.Offline ??= new StellaOpsCliOfflineOptions();
|
||||
var offline = cliOptions.Offline;
|
||||
|
||||
var kitsDirectory = ResolveWithFallback(
|
||||
string.Empty,
|
||||
configuration,
|
||||
"STELLAOPS_OFFLINE_KITS_DIRECTORY",
|
||||
"STELLAOPS_OFFLINE_KITS_DIR",
|
||||
"StellaOps:Offline:KitsDirectory",
|
||||
"StellaOps:Offline:KitDirectory",
|
||||
"Offline:KitsDirectory",
|
||||
"Offline:KitDirectory");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(kitsDirectory))
|
||||
{
|
||||
kitsDirectory = offline.KitsDirectory ?? "offline-kits";
|
||||
}
|
||||
|
||||
offline.KitsDirectory = Path.GetFullPath(kitsDirectory);
|
||||
if (!Directory.Exists(offline.KitsDirectory))
|
||||
{
|
||||
Directory.CreateDirectory(offline.KitsDirectory);
|
||||
}
|
||||
|
||||
var mirror = ResolveWithFallback(
|
||||
string.Empty,
|
||||
configuration,
|
||||
"STELLAOPS_OFFLINE_MIRROR_URL",
|
||||
"StellaOps:Offline:KitMirror",
|
||||
"Offline:KitMirror",
|
||||
"Offline:MirrorUrl");
|
||||
|
||||
offline.MirrorUrl = string.IsNullOrWhiteSpace(mirror) ? null : mirror.Trim();
|
||||
|
||||
cliOptions.Plugins ??= new StellaOpsCliPluginOptions();
|
||||
var pluginOptions = cliOptions.Plugins;
|
||||
|
||||
pluginOptions.BaseDirectory = ResolveWithFallback(
|
||||
pluginOptions.BaseDirectory,
|
||||
configuration,
|
||||
"STELLAOPS_CLI_PLUGIN_BASE_DIRECTORY",
|
||||
"StellaOps:Plugins:BaseDirectory",
|
||||
"Plugins:BaseDirectory");
|
||||
|
||||
pluginOptions.BaseDirectory = (pluginOptions.BaseDirectory ?? string.Empty).Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(pluginOptions.BaseDirectory))
|
||||
{
|
||||
pluginOptions.BaseDirectory = AppContext.BaseDirectory;
|
||||
}
|
||||
|
||||
pluginOptions.BaseDirectory = Path.GetFullPath(pluginOptions.BaseDirectory);
|
||||
|
||||
pluginOptions.Directory = ResolveWithFallback(
|
||||
pluginOptions.Directory,
|
||||
configuration,
|
||||
"STELLAOPS_CLI_PLUGIN_DIRECTORY",
|
||||
"StellaOps:Plugins:Directory",
|
||||
"Plugins:Directory");
|
||||
|
||||
pluginOptions.Directory = (pluginOptions.Directory ?? string.Empty).Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(pluginOptions.Directory))
|
||||
{
|
||||
pluginOptions.Directory = Path.Combine("plugins", "cli");
|
||||
}
|
||||
|
||||
if (!Path.IsPathRooted(pluginOptions.Directory))
|
||||
{
|
||||
pluginOptions.Directory = Path.GetFullPath(Path.Combine(pluginOptions.BaseDirectory, pluginOptions.Directory));
|
||||
}
|
||||
else
|
||||
{
|
||||
pluginOptions.Directory = Path.GetFullPath(pluginOptions.Directory);
|
||||
}
|
||||
|
||||
pluginOptions.ManifestSearchPattern = ResolveWithFallback(
|
||||
pluginOptions.ManifestSearchPattern,
|
||||
configuration,
|
||||
"STELLAOPS_CLI_PLUGIN_MANIFEST_PATTERN",
|
||||
"StellaOps:Plugins:ManifestSearchPattern",
|
||||
"Plugins:ManifestSearchPattern");
|
||||
|
||||
pluginOptions.ManifestSearchPattern = (pluginOptions.ManifestSearchPattern ?? string.Empty).Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(pluginOptions.ManifestSearchPattern))
|
||||
{
|
||||
pluginOptions.ManifestSearchPattern = "*.manifest.json";
|
||||
}
|
||||
|
||||
if (pluginOptions.SearchPatterns is null || pluginOptions.SearchPatterns.Count == 0)
|
||||
{
|
||||
pluginOptions.SearchPatterns = new List<string> { "StellaOps.Cli.Plugin.*.dll" };
|
||||
}
|
||||
else
|
||||
{
|
||||
pluginOptions.SearchPatterns = pluginOptions.SearchPatterns
|
||||
.Where(pattern => !string.IsNullOrWhiteSpace(pattern))
|
||||
.Select(pattern => pattern.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
if (pluginOptions.SearchPatterns.Count == 0)
|
||||
{
|
||||
pluginOptions.SearchPatterns.Add("StellaOps.Cli.Plugin.*.dll");
|
||||
}
|
||||
}
|
||||
|
||||
if (pluginOptions.PluginOrder is null)
|
||||
{
|
||||
pluginOptions.PluginOrder = new List<string>();
|
||||
}
|
||||
else
|
||||
{
|
||||
pluginOptions.PluginOrder = pluginOptions.PluginOrder
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||
.Select(name => name.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
return (bootstrap.Options, bootstrap.Configuration);
|
||||
}
|
||||
|
||||
private static string ResolveWithFallback(string currentValue, IConfiguration configuration, params string[] keys)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(currentValue))
|
||||
{
|
||||
return currentValue;
|
||||
}
|
||||
|
||||
foreach (var key in keys)
|
||||
{
|
||||
var value = configuration[key];
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private static bool TryParseBoolean(string value, out bool parsed)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
parsed = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (bool.TryParse(value, out parsed))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var numeric))
|
||||
{
|
||||
parsed = numeric != 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
parsed = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static IEnumerable<TimeSpan> ParseRetryDelays(string raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
var separators = new[] { ',', ';', ' ' };
|
||||
foreach (var token in raw.Split(separators, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (TimeSpan.TryParse(token, CultureInfo.InvariantCulture, out var delay) && delay > TimeSpan.Zero)
|
||||
{
|
||||
yield return delay;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetDefaultTokenCacheDirectory()
|
||||
{
|
||||
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
if (string.IsNullOrWhiteSpace(home))
|
||||
{
|
||||
home = AppContext.BaseDirectory;
|
||||
}
|
||||
|
||||
return Path.GetFullPath(Path.Combine(home, ".stellaops", "tokens"));
|
||||
}
|
||||
}
|
||||
87
src/Cli/StellaOps.Cli/Configuration/StellaOpsCliOptions.cs
Normal file
87
src/Cli/StellaOps.Cli/Configuration/StellaOpsCliOptions.cs
Normal file
@@ -0,0 +1,87 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using System.IO;
|
||||
|
||||
namespace StellaOps.Cli.Configuration;
|
||||
|
||||
public sealed class StellaOpsCliOptions
|
||||
{
|
||||
public string ApiKey { get; set; } = string.Empty;
|
||||
|
||||
public string BackendUrl { get; set; } = string.Empty;
|
||||
|
||||
public string ConcelierUrl { get; set; } = string.Empty;
|
||||
|
||||
public string ScannerCacheDirectory { get; set; } = "scanners";
|
||||
|
||||
public string ResultsDirectory { get; set; } = "results";
|
||||
|
||||
public string DefaultRunner { get; set; } = "docker";
|
||||
|
||||
public string ScannerSignaturePublicKeyPath { get; set; } = string.Empty;
|
||||
|
||||
public int ScannerDownloadAttempts { get; set; } = 3;
|
||||
|
||||
public int ScanUploadAttempts { get; set; } = 3;
|
||||
|
||||
public StellaOpsCliAuthorityOptions Authority { get; set; } = new();
|
||||
|
||||
public StellaOpsCliOfflineOptions Offline { get; set; } = new();
|
||||
|
||||
public StellaOpsCliPluginOptions Plugins { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class StellaOpsCliAuthorityOptions
|
||||
{
|
||||
public string Url { get; set; } = string.Empty;
|
||||
|
||||
public string ClientId { get; set; } = string.Empty;
|
||||
|
||||
public string? ClientSecret { get; set; }
|
||||
|
||||
public string Username { get; set; } = string.Empty;
|
||||
|
||||
public string? Password { get; set; }
|
||||
|
||||
public string Scope { get; set; } = StellaOpsScopes.ConcelierJobsTrigger;
|
||||
|
||||
public string OperatorReason { get; set; } = string.Empty;
|
||||
|
||||
public string OperatorTicket { get; set; } = string.Empty;
|
||||
|
||||
public string TokenCacheDirectory { get; set; } = string.Empty;
|
||||
|
||||
public StellaOpsCliAuthorityResilienceOptions Resilience { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class StellaOpsCliAuthorityResilienceOptions
|
||||
{
|
||||
public bool? EnableRetries { get; set; }
|
||||
|
||||
public IList<TimeSpan> RetryDelays { get; set; } = new List<TimeSpan>();
|
||||
|
||||
public bool? AllowOfflineCacheFallback { get; set; }
|
||||
|
||||
public TimeSpan? OfflineCacheTolerance { get; set; }
|
||||
}
|
||||
|
||||
public sealed class StellaOpsCliOfflineOptions
|
||||
{
|
||||
public string KitsDirectory { get; set; } = "offline-kits";
|
||||
|
||||
public string? MirrorUrl { get; set; }
|
||||
}
|
||||
|
||||
public sealed class StellaOpsCliPluginOptions
|
||||
{
|
||||
public string BaseDirectory { get; set; } = string.Empty;
|
||||
|
||||
public string Directory { get; set; } = "plugins/cli";
|
||||
|
||||
public IList<string> SearchPatterns { get; set; } = new List<string>();
|
||||
|
||||
public IList<string> PluginOrder { get; set; } = new List<string>();
|
||||
|
||||
public string ManifestSearchPattern { get; set; } = "*.manifest.json";
|
||||
}
|
||||
278
src/Cli/StellaOps.Cli/Plugins/CliCommandModuleLoader.cs
Normal file
278
src/Cli/StellaOps.Cli/Plugins/CliCommandModuleLoader.cs
Normal file
@@ -0,0 +1,278 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.CommandLine;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Plugin.Hosting;
|
||||
|
||||
namespace StellaOps.Cli.Plugins;
|
||||
|
||||
internal sealed class CliCommandModuleLoader
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly StellaOpsCliOptions _options;
|
||||
private readonly ILogger<CliCommandModuleLoader> _logger;
|
||||
private readonly RestartOnlyCliPluginGuard _guard = new();
|
||||
|
||||
private IReadOnlyList<ICliCommandModule> _modules = Array.Empty<ICliCommandModule>();
|
||||
private bool _loaded;
|
||||
|
||||
public CliCommandModuleLoader(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
ILogger<CliCommandModuleLoader> logger)
|
||||
{
|
||||
_services = services ?? throw new ArgumentNullException(nameof(services));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public IReadOnlyList<ICliCommandModule> LoadModules()
|
||||
{
|
||||
if (_loaded)
|
||||
{
|
||||
return _modules;
|
||||
}
|
||||
|
||||
var pluginOptions = _options.Plugins ?? new StellaOpsCliPluginOptions();
|
||||
|
||||
var baseDirectory = ResolveBaseDirectory(pluginOptions);
|
||||
var pluginsDirectory = ResolvePluginsDirectory(pluginOptions, baseDirectory);
|
||||
var searchPatterns = ResolveSearchPatterns(pluginOptions);
|
||||
var manifestPattern = string.IsNullOrWhiteSpace(pluginOptions.ManifestSearchPattern)
|
||||
? "*.manifest.json"
|
||||
: pluginOptions.ManifestSearchPattern;
|
||||
|
||||
_logger.LogDebug("Loading CLI plug-ins from '{Directory}' (base: '{Base}').", pluginsDirectory, baseDirectory);
|
||||
|
||||
var manifestLoader = new CliPluginManifestLoader(pluginsDirectory, manifestPattern);
|
||||
IReadOnlyList<CliPluginManifest> manifests;
|
||||
try
|
||||
{
|
||||
manifests = manifestLoader.LoadAsync(CancellationToken.None).GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to enumerate CLI plug-in manifests from '{Directory}'.", pluginsDirectory);
|
||||
manifests = Array.Empty<CliPluginManifest>();
|
||||
}
|
||||
|
||||
if (manifests.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("No CLI plug-in manifests discovered under '{Directory}'.", pluginsDirectory);
|
||||
_loaded = true;
|
||||
_guard.Seal();
|
||||
_modules = Array.Empty<ICliCommandModule>();
|
||||
return _modules;
|
||||
}
|
||||
|
||||
var hostOptions = new PluginHostOptions
|
||||
{
|
||||
BaseDirectory = baseDirectory,
|
||||
PluginsDirectory = pluginsDirectory,
|
||||
EnsureDirectoryExists = false,
|
||||
RecursiveSearch = true,
|
||||
PrimaryPrefix = "StellaOps.Cli"
|
||||
};
|
||||
|
||||
foreach (var pattern in searchPatterns)
|
||||
{
|
||||
hostOptions.SearchPatterns.Add(pattern);
|
||||
}
|
||||
|
||||
foreach (var ordered in pluginOptions.PluginOrder ?? Array.Empty<string>())
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(ordered))
|
||||
{
|
||||
hostOptions.PluginOrder.Add(ordered);
|
||||
}
|
||||
}
|
||||
|
||||
var loadResult = PluginHost.LoadPlugins(hostOptions, _logger);
|
||||
|
||||
var assemblies = loadResult.Plugins.ToDictionary(
|
||||
descriptor => Normalize(descriptor.AssemblyPath),
|
||||
descriptor => descriptor.Assembly,
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var modules = new List<ICliCommandModule>(manifests.Count);
|
||||
|
||||
foreach (var manifest in manifests)
|
||||
{
|
||||
try
|
||||
{
|
||||
var assemblyPath = ResolveAssemblyPath(manifest);
|
||||
_guard.EnsureRegistrationAllowed(assemblyPath);
|
||||
|
||||
if (!assemblies.TryGetValue(assemblyPath, out var assembly))
|
||||
{
|
||||
if (!File.Exists(assemblyPath))
|
||||
{
|
||||
throw new FileNotFoundException($"Plug-in assembly '{assemblyPath}' referenced by manifest '{manifest.Id}' was not found.");
|
||||
}
|
||||
|
||||
assembly = Assembly.LoadFrom(assemblyPath);
|
||||
assemblies[assemblyPath] = assembly;
|
||||
}
|
||||
|
||||
var module = CreateModule(assembly, manifest);
|
||||
if (module is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
modules.Add(module);
|
||||
_logger.LogInformation("Registered CLI plug-in '{PluginId}' ({PluginName}) from '{AssemblyPath}'.", manifest.Id, module.Name, assemblyPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to register CLI plug-in '{PluginId}'.", manifest.Id);
|
||||
}
|
||||
}
|
||||
|
||||
_modules = modules;
|
||||
_loaded = true;
|
||||
_guard.Seal();
|
||||
return _modules;
|
||||
}
|
||||
|
||||
public void RegisterModules(RootCommand root, Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
if (root is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(root));
|
||||
}
|
||||
if (verboseOption is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(verboseOption));
|
||||
}
|
||||
|
||||
var modules = LoadModules();
|
||||
if (modules.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var module in modules)
|
||||
{
|
||||
if (!module.IsAvailable(_services))
|
||||
{
|
||||
_logger.LogDebug("CLI plug-in '{Name}' reported unavailable; skipping registration.", module.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
module.RegisterCommands(root, _services, _options, verboseOption, cancellationToken);
|
||||
_logger.LogInformation("CLI plug-in '{Name}' commands registered.", module.Name);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "CLI plug-in '{Name}' failed to register commands.", module.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveAssemblyPath(CliPluginManifest manifest)
|
||||
{
|
||||
if (manifest.EntryPoint is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Manifest '{manifest.SourcePath}' does not define an entry point.");
|
||||
}
|
||||
|
||||
var assemblyPath = manifest.EntryPoint.Assembly;
|
||||
if (string.IsNullOrWhiteSpace(assemblyPath))
|
||||
{
|
||||
throw new InvalidOperationException($"Manifest '{manifest.SourcePath}' specifies an empty assembly path.");
|
||||
}
|
||||
|
||||
if (!Path.IsPathRooted(assemblyPath))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(manifest.SourceDirectory))
|
||||
{
|
||||
throw new InvalidOperationException($"Manifest '{manifest.SourcePath}' cannot resolve relative assembly path without source directory metadata.");
|
||||
}
|
||||
|
||||
assemblyPath = Path.Combine(manifest.SourceDirectory, assemblyPath);
|
||||
}
|
||||
|
||||
return Normalize(assemblyPath);
|
||||
}
|
||||
|
||||
private ICliCommandModule? CreateModule(Assembly assembly, CliPluginManifest manifest)
|
||||
{
|
||||
if (manifest.EntryPoint is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var type = assembly.GetType(manifest.EntryPoint.TypeName, throwOnError: true);
|
||||
if (type is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Plug-in type '{manifest.EntryPoint.TypeName}' could not be loaded from assembly '{assembly.FullName}'.");
|
||||
}
|
||||
|
||||
var module = ActivatorUtilities.CreateInstance(_services, type) as ICliCommandModule;
|
||||
if (module is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Plug-in type '{manifest.EntryPoint.TypeName}' does not implement {nameof(ICliCommandModule)}.");
|
||||
}
|
||||
|
||||
return module;
|
||||
}
|
||||
|
||||
private static string ResolveBaseDirectory(StellaOpsCliPluginOptions options)
|
||||
{
|
||||
var baseDirectory = options.BaseDirectory;
|
||||
if (string.IsNullOrWhiteSpace(baseDirectory))
|
||||
{
|
||||
baseDirectory = AppContext.BaseDirectory;
|
||||
}
|
||||
|
||||
return Path.GetFullPath(baseDirectory);
|
||||
}
|
||||
|
||||
private static string ResolvePluginsDirectory(StellaOpsCliPluginOptions options, string baseDirectory)
|
||||
{
|
||||
var directory = options.Directory;
|
||||
if (string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
directory = Path.Combine("plugins", "cli");
|
||||
}
|
||||
|
||||
directory = directory.Trim();
|
||||
|
||||
if (!Path.IsPathRooted(directory))
|
||||
{
|
||||
directory = Path.Combine(baseDirectory, directory);
|
||||
}
|
||||
|
||||
return Path.GetFullPath(directory);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ResolveSearchPatterns(StellaOpsCliPluginOptions options)
|
||||
{
|
||||
if (options.SearchPatterns is null || options.SearchPatterns.Count == 0)
|
||||
{
|
||||
return new[] { "StellaOps.Cli.Plugin.*.dll" };
|
||||
}
|
||||
|
||||
return options.SearchPatterns
|
||||
.Where(pattern => !string.IsNullOrWhiteSpace(pattern))
|
||||
.Select(pattern => pattern.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static string Normalize(string path)
|
||||
{
|
||||
var full = Path.GetFullPath(path);
|
||||
return full.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
}
|
||||
}
|
||||
39
src/Cli/StellaOps.Cli/Plugins/CliPluginManifest.cs
Normal file
39
src/Cli/StellaOps.Cli/Plugins/CliPluginManifest.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Plugins;
|
||||
|
||||
public sealed record CliPluginManifest
|
||||
{
|
||||
public const string CurrentSchemaVersion = "1.0";
|
||||
|
||||
public string SchemaVersion { get; init; } = CurrentSchemaVersion;
|
||||
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
public string DisplayName { get; init; } = string.Empty;
|
||||
|
||||
public string Version { get; init; } = "0.0.0";
|
||||
|
||||
public bool RequiresRestart { get; init; } = true;
|
||||
|
||||
public CliPluginEntryPoint? EntryPoint { get; init; }
|
||||
|
||||
public IReadOnlyList<string> Capabilities { get; init; } = Array.Empty<string>();
|
||||
|
||||
public IReadOnlyDictionary<string, string> Metadata { get; init; } =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public string? SourcePath { get; init; }
|
||||
|
||||
public string? SourceDirectory { get; init; }
|
||||
}
|
||||
|
||||
public sealed record CliPluginEntryPoint
|
||||
{
|
||||
public string Type { get; init; } = "dotnet";
|
||||
|
||||
public string Assembly { get; init; } = string.Empty;
|
||||
|
||||
public string TypeName { get; init; } = string.Empty;
|
||||
}
|
||||
150
src/Cli/StellaOps.Cli/Plugins/CliPluginManifestLoader.cs
Normal file
150
src/Cli/StellaOps.Cli/Plugins/CliPluginManifestLoader.cs
Normal file
@@ -0,0 +1,150 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Cli.Plugins;
|
||||
|
||||
internal sealed class CliPluginManifestLoader
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
AllowTrailingCommas = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly string _directory;
|
||||
private readonly string _searchPattern;
|
||||
|
||||
public CliPluginManifestLoader(string directory, string searchPattern)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
throw new ArgumentException("Plug-in manifest directory is required.", nameof(directory));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(searchPattern))
|
||||
{
|
||||
throw new ArgumentException("Manifest search pattern is required.", nameof(searchPattern));
|
||||
}
|
||||
|
||||
_directory = Path.GetFullPath(directory);
|
||||
_searchPattern = searchPattern;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<CliPluginManifest>> LoadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Directory.Exists(_directory))
|
||||
{
|
||||
return Array.Empty<CliPluginManifest>();
|
||||
}
|
||||
|
||||
var manifests = new List<CliPluginManifest>();
|
||||
|
||||
foreach (var file in Directory.EnumerateFiles(_directory, _searchPattern, SearchOption.AllDirectories))
|
||||
{
|
||||
if (IsHidden(file))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var manifest = await DeserializeAsync(file, cancellationToken).ConfigureAwait(false);
|
||||
manifests.Add(manifest);
|
||||
}
|
||||
|
||||
return manifests
|
||||
.OrderBy(static m => m.Id, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(static m => m.Version, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static bool IsHidden(string path)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(path);
|
||||
while (!string.IsNullOrEmpty(directory))
|
||||
{
|
||||
var name = Path.GetFileName(directory);
|
||||
if (name.StartsWith(".", StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
directory = Path.GetDirectoryName(directory);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static async Task<CliPluginManifest> DeserializeAsync(string file, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, FileOptions.Asynchronous);
|
||||
CliPluginManifest? manifest;
|
||||
|
||||
try
|
||||
{
|
||||
manifest = await JsonSerializer.DeserializeAsync<CliPluginManifest>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to parse CLI plug-in manifest '{file}'.", ex);
|
||||
}
|
||||
|
||||
if (manifest is null)
|
||||
{
|
||||
throw new InvalidOperationException($"CLI plug-in manifest '{file}' is empty or invalid.");
|
||||
}
|
||||
|
||||
ValidateManifest(manifest, file);
|
||||
|
||||
var directory = Path.GetDirectoryName(file);
|
||||
return manifest with
|
||||
{
|
||||
SourcePath = file,
|
||||
SourceDirectory = directory
|
||||
};
|
||||
}
|
||||
|
||||
private static void ValidateManifest(CliPluginManifest manifest, string file)
|
||||
{
|
||||
if (!string.Equals(manifest.SchemaVersion, CliPluginManifest.CurrentSchemaVersion, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Manifest '{file}' uses unsupported schema version '{manifest.SchemaVersion}'. Expected '{CliPluginManifest.CurrentSchemaVersion}'.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(manifest.Id))
|
||||
{
|
||||
throw new InvalidOperationException($"Manifest '{file}' must specify a non-empty 'id'.");
|
||||
}
|
||||
|
||||
if (manifest.EntryPoint is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Manifest '{file}' must specify an 'entryPoint'.");
|
||||
}
|
||||
|
||||
if (!string.Equals(manifest.EntryPoint.Type, "dotnet", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException($"Manifest '{file}' entry point type '{manifest.EntryPoint.Type}' is not supported. Expected 'dotnet'.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(manifest.EntryPoint.Assembly))
|
||||
{
|
||||
throw new InvalidOperationException($"Manifest '{file}' must specify an entry point assembly.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(manifest.EntryPoint.TypeName))
|
||||
{
|
||||
throw new InvalidOperationException($"Manifest '{file}' must specify an entry point type.");
|
||||
}
|
||||
|
||||
if (!manifest.RequiresRestart)
|
||||
{
|
||||
throw new InvalidOperationException($"Manifest '{file}' must set 'requiresRestart' to true.");
|
||||
}
|
||||
}
|
||||
}
|
||||
20
src/Cli/StellaOps.Cli/Plugins/ICliCommandModule.cs
Normal file
20
src/Cli/StellaOps.Cli/Plugins/ICliCommandModule.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using System;
|
||||
using System.CommandLine;
|
||||
using System.Threading;
|
||||
using StellaOps.Cli.Configuration;
|
||||
|
||||
namespace StellaOps.Cli.Plugins;
|
||||
|
||||
public interface ICliCommandModule
|
||||
{
|
||||
string Name { get; }
|
||||
|
||||
bool IsAvailable(IServiceProvider services);
|
||||
|
||||
void RegisterCommands(
|
||||
RootCommand root,
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
41
src/Cli/StellaOps.Cli/Plugins/RestartOnlyCliPluginGuard.cs
Normal file
41
src/Cli/StellaOps.Cli/Plugins/RestartOnlyCliPluginGuard.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
|
||||
namespace StellaOps.Cli.Plugins;
|
||||
|
||||
internal sealed class RestartOnlyCliPluginGuard
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, byte> _plugins = new(StringComparer.OrdinalIgnoreCase);
|
||||
private bool _sealed;
|
||||
|
||||
public IReadOnlyCollection<string> KnownPlugins => _plugins.Keys.ToArray();
|
||||
|
||||
public bool IsSealed => Volatile.Read(ref _sealed);
|
||||
|
||||
public void EnsureRegistrationAllowed(string pluginPath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(pluginPath);
|
||||
|
||||
var normalized = Normalize(pluginPath);
|
||||
if (IsSealed && !_plugins.ContainsKey(normalized))
|
||||
{
|
||||
throw new InvalidOperationException($"Plug-in '{pluginPath}' cannot be registered after startup. Restart required.");
|
||||
}
|
||||
|
||||
_plugins.TryAdd(normalized, 0);
|
||||
}
|
||||
|
||||
public void Seal()
|
||||
{
|
||||
Volatile.Write(ref _sealed, true);
|
||||
}
|
||||
|
||||
private static string Normalize(string path)
|
||||
{
|
||||
var full = Path.GetFullPath(path);
|
||||
return full.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
}
|
||||
}
|
||||
141
src/Cli/StellaOps.Cli/Program.cs
Normal file
141
src/Cli/StellaOps.Cli/Program.cs
Normal file
@@ -0,0 +1,141 @@
|
||||
using System;
|
||||
using System.CommandLine;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Cli.Commands;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Cli.Telemetry;
|
||||
|
||||
namespace StellaOps.Cli;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
internal static async Task<int> Main(string[] args)
|
||||
{
|
||||
var (options, configuration) = CliBootstrapper.Build(args);
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton(configuration);
|
||||
services.AddSingleton(options);
|
||||
|
||||
var verbosityState = new VerbosityState();
|
||||
services.AddSingleton(verbosityState);
|
||||
|
||||
services.AddLogging(builder =>
|
||||
{
|
||||
builder.ClearProviders();
|
||||
builder.AddSimpleConsole(logOptions =>
|
||||
{
|
||||
logOptions.TimestampFormat = "HH:mm:ss ";
|
||||
logOptions.SingleLine = true;
|
||||
});
|
||||
builder.AddFilter((category, level) => level >= verbosityState.MinimumLevel);
|
||||
});
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.Authority.Url))
|
||||
{
|
||||
services.AddStellaOpsAuthClient(clientOptions =>
|
||||
{
|
||||
clientOptions.Authority = options.Authority.Url;
|
||||
clientOptions.ClientId = options.Authority.ClientId ?? string.Empty;
|
||||
clientOptions.ClientSecret = options.Authority.ClientSecret;
|
||||
clientOptions.DefaultScopes.Clear();
|
||||
clientOptions.DefaultScopes.Add(string.IsNullOrWhiteSpace(options.Authority.Scope)
|
||||
? StellaOps.Auth.Abstractions.StellaOpsScopes.ConcelierJobsTrigger
|
||||
: options.Authority.Scope);
|
||||
|
||||
var resilience = options.Authority.Resilience ?? new StellaOpsCliAuthorityResilienceOptions();
|
||||
clientOptions.EnableRetries = resilience.EnableRetries ?? true;
|
||||
|
||||
if (resilience.RetryDelays is { Count: > 0 })
|
||||
{
|
||||
clientOptions.RetryDelays.Clear();
|
||||
foreach (var delay in resilience.RetryDelays)
|
||||
{
|
||||
if (delay > TimeSpan.Zero)
|
||||
{
|
||||
clientOptions.RetryDelays.Add(delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (resilience.AllowOfflineCacheFallback.HasValue)
|
||||
{
|
||||
clientOptions.AllowOfflineCacheFallback = resilience.AllowOfflineCacheFallback.Value;
|
||||
}
|
||||
|
||||
if (resilience.OfflineCacheTolerance.HasValue && resilience.OfflineCacheTolerance.Value >= TimeSpan.Zero)
|
||||
{
|
||||
clientOptions.OfflineCacheTolerance = resilience.OfflineCacheTolerance.Value;
|
||||
}
|
||||
});
|
||||
|
||||
var cacheDirectory = options.Authority.TokenCacheDirectory;
|
||||
if (!string.IsNullOrWhiteSpace(cacheDirectory))
|
||||
{
|
||||
Directory.CreateDirectory(cacheDirectory);
|
||||
services.AddStellaOpsFileTokenCache(cacheDirectory);
|
||||
}
|
||||
|
||||
services.AddHttpClient<IAuthorityRevocationClient, AuthorityRevocationClient>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromMinutes(2);
|
||||
if (Uri.TryCreate(options.Authority.Url, UriKind.Absolute, out var authorityUri))
|
||||
{
|
||||
client.BaseAddress = authorityUri;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
services.AddHttpClient<IBackendOperationsClient, BackendOperationsClient>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromMinutes(5);
|
||||
if (!string.IsNullOrWhiteSpace(options.BackendUrl) &&
|
||||
Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var backendUri))
|
||||
{
|
||||
client.BaseAddress = backendUri;
|
||||
}
|
||||
});
|
||||
|
||||
services.AddHttpClient<IConcelierObservationsClient, ConcelierObservationsClient>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
if (!string.IsNullOrWhiteSpace(options.ConcelierUrl) &&
|
||||
Uri.TryCreate(options.ConcelierUrl, UriKind.Absolute, out var concelierUri))
|
||||
{
|
||||
client.BaseAddress = concelierUri;
|
||||
}
|
||||
});
|
||||
|
||||
services.AddSingleton<IScannerExecutor, ScannerExecutor>();
|
||||
services.AddSingleton<IScannerInstaller, ScannerInstaller>();
|
||||
|
||||
await using var serviceProvider = services.BuildServiceProvider();
|
||||
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
|
||||
var startupLogger = loggerFactory.CreateLogger("StellaOps.Cli.Startup");
|
||||
AuthorityDiagnosticsReporter.Emit(configuration, startupLogger);
|
||||
using var cts = new CancellationTokenSource();
|
||||
Console.CancelKeyPress += (_, eventArgs) =>
|
||||
{
|
||||
eventArgs.Cancel = true;
|
||||
cts.Cancel();
|
||||
};
|
||||
|
||||
var rootCommand = CommandFactory.Create(serviceProvider, options, cts.Token, loggerFactory);
|
||||
var commandConfiguration = new CommandLineConfiguration(rootCommand);
|
||||
var commandExit = await commandConfiguration.InvokeAsync(args, cts.Token).ConfigureAwait(false);
|
||||
|
||||
var finalExit = Environment.ExitCode != 0 ? Environment.ExitCode : commandExit;
|
||||
if (cts.IsCancellationRequested && finalExit == 0)
|
||||
{
|
||||
finalExit = 130; // Typical POSIX cancellation exit code
|
||||
}
|
||||
|
||||
return finalExit;
|
||||
}
|
||||
}
|
||||
52
src/Cli/StellaOps.Cli/Prompts/TrivyDbExportPrompt.cs
Normal file
52
src/Cli/StellaOps.Cli/Prompts/TrivyDbExportPrompt.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using Spectre.Console;
|
||||
|
||||
namespace StellaOps.Cli.Prompts;
|
||||
|
||||
internal static class TrivyDbExportPrompt
|
||||
{
|
||||
public static (bool? publishFull, bool? publishDelta, bool? includeFull, bool? includeDelta) PromptOverrides()
|
||||
{
|
||||
if (!AnsiConsole.Profile.Capabilities.Interactive)
|
||||
{
|
||||
return (null, null, null, null);
|
||||
}
|
||||
|
||||
AnsiConsole.Write(
|
||||
new Panel("[bold]Trivy DB Export Overrides[/]")
|
||||
.Border(BoxBorder.Rounded)
|
||||
.Header("Trivy DB")
|
||||
.Collapse());
|
||||
|
||||
var shouldOverride = AnsiConsole.Prompt(
|
||||
new SelectionPrompt<string>()
|
||||
.Title("Adjust publishing or offline bundle behaviour?")
|
||||
.AddChoices("Leave defaults", "Override"));
|
||||
|
||||
if (shouldOverride == "Leave defaults")
|
||||
{
|
||||
return (null, null, null, null);
|
||||
}
|
||||
|
||||
var publishFull = PromptBoolean("Push full exports to ORAS?");
|
||||
var publishDelta = PromptBoolean("Push delta exports to ORAS?");
|
||||
var includeFull = PromptBoolean("Include full exports in offline bundle?");
|
||||
var includeDelta = PromptBoolean("Include delta exports in offline bundle?");
|
||||
|
||||
return (publishFull, publishDelta, includeFull, includeDelta);
|
||||
}
|
||||
|
||||
private static bool? PromptBoolean(string question)
|
||||
{
|
||||
var choice = AnsiConsole.Prompt(
|
||||
new SelectionPrompt<string>()
|
||||
.Title($"{question} [grey](select override or keep default)[/]")
|
||||
.AddChoices("Keep default", "Yes", "No"));
|
||||
|
||||
return choice switch
|
||||
{
|
||||
"Yes" => true,
|
||||
"No" => false,
|
||||
_ => (bool?)null,
|
||||
};
|
||||
}
|
||||
}
|
||||
4
src/Cli/StellaOps.Cli/Properties/AssemblyInfo.cs
Normal file
4
src/Cli/StellaOps.Cli/Properties/AssemblyInfo.cs
Normal file
@@ -0,0 +1,4 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Cli.Tests")]
|
||||
[assembly: InternalsVisibleTo("StellaOps.Cli.Plugins.NonCore")]
|
||||
123
src/Cli/StellaOps.Cli/Services/AuthorityDiagnosticsReporter.cs
Normal file
123
src/Cli/StellaOps.Cli/Services/AuthorityDiagnosticsReporter.cs
Normal file
@@ -0,0 +1,123 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Configuration;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Emits Authority configuration diagnostics discovered during CLI startup.
|
||||
/// </summary>
|
||||
internal static class AuthorityDiagnosticsReporter
|
||||
{
|
||||
public static void Emit(IConfiguration configuration, ILogger logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
var basePath = Directory.GetCurrentDirectory();
|
||||
EmitInternal(configuration, logger, basePath);
|
||||
}
|
||||
|
||||
internal static void Emit(IConfiguration configuration, ILogger logger, string basePath)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
ArgumentNullException.ThrowIfNull(basePath);
|
||||
|
||||
EmitInternal(configuration, logger, basePath);
|
||||
}
|
||||
|
||||
private static void EmitInternal(IConfiguration configuration, ILogger logger, string basePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(basePath))
|
||||
{
|
||||
basePath = Directory.GetCurrentDirectory();
|
||||
}
|
||||
|
||||
var authoritySection = configuration.GetSection("Authority");
|
||||
if (!authoritySection.Exists())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var authorityOptions = new StellaOpsAuthorityOptions();
|
||||
authoritySection.Bind(authorityOptions);
|
||||
|
||||
if (authorityOptions.Plugins.Descriptors.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var resolvedBasePath = Path.GetFullPath(basePath);
|
||||
IReadOnlyList<AuthorityPluginContext> contexts;
|
||||
|
||||
try
|
||||
{
|
||||
contexts = AuthorityPluginConfigurationLoader.Load(authorityOptions, resolvedBasePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogDebug(ex, "Failed to load Authority plug-in configuration for diagnostics.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (contexts.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IReadOnlyList<AuthorityConfigurationDiagnostic> diagnostics;
|
||||
try
|
||||
{
|
||||
diagnostics = AuthorityPluginConfigurationAnalyzer.Analyze(contexts);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogDebug(ex, "Failed to analyze Authority plug-in configuration for diagnostics.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (diagnostics.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var contextLookup = new Dictionary<string, AuthorityPluginContext>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var context in contexts)
|
||||
{
|
||||
if (context?.Manifest?.Name is { Length: > 0 } name && !contextLookup.ContainsKey(name))
|
||||
{
|
||||
contextLookup[name] = context;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var diagnostic in diagnostics)
|
||||
{
|
||||
var level = diagnostic.Severity switch
|
||||
{
|
||||
AuthorityConfigurationDiagnosticSeverity.Error => LogLevel.Error,
|
||||
AuthorityConfigurationDiagnosticSeverity.Warning => LogLevel.Warning,
|
||||
_ => LogLevel.Information
|
||||
};
|
||||
|
||||
if (!logger.IsEnabled(level))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (contextLookup.TryGetValue(diagnostic.PluginName, out var context) &&
|
||||
context?.Manifest?.ConfigPath is { Length: > 0 } configPath)
|
||||
{
|
||||
logger.Log(level, "{DiagnosticMessage} (config: {ConfigPath})", diagnostic.Message, configPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.Log(level, "{DiagnosticMessage}", diagnostic.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
223
src/Cli/StellaOps.Cli/Services/AuthorityRevocationClient.cs
Normal file
223
src/Cli/StellaOps.Cli/Services/AuthorityRevocationClient.cs
Normal file
@@ -0,0 +1,223 @@
|
||||
using System;
|
||||
using System.Buffers.Text;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
internal sealed class AuthorityRevocationClient : IAuthorityRevocationClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
private static readonly TimeSpan TokenRefreshSkew = TimeSpan.FromSeconds(30);
|
||||
|
||||
private readonly HttpClient httpClient;
|
||||
private readonly StellaOpsCliOptions options;
|
||||
private readonly ILogger<AuthorityRevocationClient> logger;
|
||||
private readonly IStellaOpsTokenClient? tokenClient;
|
||||
private readonly object tokenSync = new();
|
||||
|
||||
private string? cachedAccessToken;
|
||||
private DateTimeOffset cachedAccessTokenExpiresAt = DateTimeOffset.MinValue;
|
||||
|
||||
public AuthorityRevocationClient(
|
||||
HttpClient httpClient,
|
||||
StellaOpsCliOptions options,
|
||||
ILogger<AuthorityRevocationClient> logger,
|
||||
IStellaOpsTokenClient? tokenClient = null)
|
||||
{
|
||||
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
this.options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
this.tokenClient = tokenClient;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.Authority?.Url) && httpClient.BaseAddress is null && Uri.TryCreate(options.Authority.Url, UriKind.Absolute, out var authorityUri))
|
||||
{
|
||||
httpClient.BaseAddress = authorityUri;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AuthorityRevocationExportResult> ExportAsync(bool verbose, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureAuthorityConfigured();
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "internal/revocations/export");
|
||||
var accessToken = await AcquireAccessTokenAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!string.IsNullOrWhiteSpace(accessToken))
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
}
|
||||
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
var message = $"Authority export request failed with {(int)response.StatusCode} {response.ReasonPhrase}: {body}";
|
||||
throw new InvalidOperationException(message);
|
||||
}
|
||||
|
||||
var payload = await JsonSerializer.DeserializeAsync<ExportResponseDto>(
|
||||
await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false),
|
||||
SerializerOptions,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (payload is null)
|
||||
{
|
||||
throw new InvalidOperationException("Authority export response payload was empty.");
|
||||
}
|
||||
|
||||
var bundleBytes = Convert.FromBase64String(payload.Bundle.Data);
|
||||
var digest = payload.Digest?.Value ?? string.Empty;
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Received revocation export sequence {Sequence} (sha256:{Digest}, signing key {KeyId}, provider {Provider}).",
|
||||
payload.Sequence,
|
||||
digest,
|
||||
payload.SigningKeyId ?? "<unspecified>",
|
||||
string.IsNullOrWhiteSpace(payload.Signature?.Provider) ? "default" : payload.Signature!.Provider);
|
||||
}
|
||||
|
||||
return new AuthorityRevocationExportResult
|
||||
{
|
||||
BundleBytes = bundleBytes,
|
||||
Signature = payload.Signature?.Value ?? string.Empty,
|
||||
Digest = digest,
|
||||
Sequence = payload.Sequence,
|
||||
IssuedAt = payload.IssuedAt,
|
||||
SigningKeyId = payload.SigningKeyId,
|
||||
SigningProvider = payload.Signature?.Provider
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<string?> AcquireAccessTokenAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (tokenClient is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
lock (tokenSync)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(cachedAccessToken) && cachedAccessTokenExpiresAt - TokenRefreshSkew > DateTimeOffset.UtcNow)
|
||||
{
|
||||
return cachedAccessToken;
|
||||
}
|
||||
}
|
||||
|
||||
var scope = AuthorityTokenUtilities.ResolveScope(options);
|
||||
var token = await RequestAccessTokenAsync(scope, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
lock (tokenSync)
|
||||
{
|
||||
cachedAccessToken = token.AccessToken;
|
||||
cachedAccessTokenExpiresAt = token.ExpiresAtUtc;
|
||||
return cachedAccessToken;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<StellaOpsTokenResult> RequestAccessTokenAsync(string scope, CancellationToken cancellationToken)
|
||||
{
|
||||
if (options.Authority is null)
|
||||
{
|
||||
throw new InvalidOperationException("Authority credentials are not configured.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.Authority.Username))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.Authority.Password))
|
||||
{
|
||||
throw new InvalidOperationException("Authority password must be configured or run 'auth login'.");
|
||||
}
|
||||
|
||||
return await tokenClient!.RequestPasswordTokenAsync(
|
||||
options.Authority.Username,
|
||||
options.Authority.Password!,
|
||||
scope,
|
||||
null,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return await tokenClient!.RequestClientCredentialsTokenAsync(scope, null, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private void EnsureAuthorityConfigured()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.Authority?.Url))
|
||||
{
|
||||
throw new InvalidOperationException("Authority URL is not configured. Set STELLAOPS_AUTHORITY_URL or update stellaops.yaml.");
|
||||
}
|
||||
|
||||
if (httpClient.BaseAddress is null)
|
||||
{
|
||||
if (!Uri.TryCreate(options.Authority.Url, UriKind.Absolute, out var baseUri))
|
||||
{
|
||||
throw new InvalidOperationException("Authority URL is invalid.");
|
||||
}
|
||||
|
||||
httpClient.BaseAddress = baseUri;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ExportResponseDto
|
||||
{
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public string SchemaVersion { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("bundleId")]
|
||||
public string BundleId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sequence")]
|
||||
public long Sequence { get; set; }
|
||||
|
||||
[JsonPropertyName("issuedAt")]
|
||||
public DateTimeOffset IssuedAt { get; set; }
|
||||
|
||||
[JsonPropertyName("signingKeyId")]
|
||||
public string? SigningKeyId { get; set; }
|
||||
|
||||
[JsonPropertyName("bundle")]
|
||||
public ExportPayloadDto Bundle { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("signature")]
|
||||
public ExportSignatureDto? Signature { get; set; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public ExportDigestDto? Digest { get; set; }
|
||||
}
|
||||
|
||||
private sealed class ExportPayloadDto
|
||||
{
|
||||
[JsonPropertyName("data")]
|
||||
public string Data { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed class ExportSignatureDto
|
||||
{
|
||||
[JsonPropertyName("algorithm")]
|
||||
public string Algorithm { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("keyId")]
|
||||
public string KeyId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("provider")]
|
||||
public string Provider { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("value")]
|
||||
public string Value { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed class ExportDigestDto
|
||||
{
|
||||
[JsonPropertyName("value")]
|
||||
public string Value { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
2486
src/Cli/StellaOps.Cli/Services/BackendOperationsClient.cs
Normal file
2486
src/Cli/StellaOps.Cli/Services/BackendOperationsClient.cs
Normal file
File diff suppressed because it is too large
Load Diff
250
src/Cli/StellaOps.Cli/Services/ConcelierObservationsClient.cs
Normal file
250
src/Cli/StellaOps.Cli/Services/ConcelierObservationsClient.cs
Normal file
@@ -0,0 +1,250 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Globalization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
internal sealed class ConcelierObservationsClient : IConcelierObservationsClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
private static readonly TimeSpan TokenRefreshSkew = TimeSpan.FromSeconds(30);
|
||||
|
||||
private readonly HttpClient httpClient;
|
||||
private readonly StellaOpsCliOptions options;
|
||||
private readonly ILogger<ConcelierObservationsClient> logger;
|
||||
private readonly IStellaOpsTokenClient? tokenClient;
|
||||
private readonly object tokenSync = new();
|
||||
|
||||
private string? cachedAccessToken;
|
||||
private DateTimeOffset cachedAccessTokenExpiresAt = DateTimeOffset.MinValue;
|
||||
|
||||
public ConcelierObservationsClient(
|
||||
HttpClient httpClient,
|
||||
StellaOpsCliOptions options,
|
||||
ILogger<ConcelierObservationsClient> logger,
|
||||
IStellaOpsTokenClient? tokenClient = null)
|
||||
{
|
||||
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
this.options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
this.tokenClient = tokenClient;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.ConcelierUrl) && httpClient.BaseAddress is null)
|
||||
{
|
||||
if (Uri.TryCreate(options.ConcelierUrl, UriKind.Absolute, out var baseUri))
|
||||
{
|
||||
httpClient.BaseAddress = baseUri;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AdvisoryObservationsResponse> GetObservationsAsync(
|
||||
AdvisoryObservationsQuery query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
EnsureConfigured();
|
||||
|
||||
var requestUri = BuildRequestUri(query);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
||||
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to query observations (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<AdvisoryObservationsResponse>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new AdvisoryObservationsResponse();
|
||||
}
|
||||
|
||||
private static string BuildRequestUri(AdvisoryObservationsQuery query)
|
||||
{
|
||||
var builder = new StringBuilder("/concelier/observations?tenant=");
|
||||
builder.Append(Uri.EscapeDataString(query.Tenant));
|
||||
|
||||
AppendValues(builder, "observationId", query.ObservationIds);
|
||||
AppendValues(builder, "alias", query.Aliases);
|
||||
AppendValues(builder, "purl", query.Purls);
|
||||
AppendValues(builder, "cpe", query.Cpes);
|
||||
|
||||
if (query.Limit.HasValue && query.Limit.Value > 0)
|
||||
{
|
||||
builder.Append('&');
|
||||
builder.Append("limit=");
|
||||
builder.Append(query.Limit.Value.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Cursor))
|
||||
{
|
||||
builder.Append('&');
|
||||
builder.Append("cursor=");
|
||||
builder.Append(Uri.EscapeDataString(query.Cursor));
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
|
||||
static void AppendValues(StringBuilder builder, string name, IReadOnlyList<string> values)
|
||||
{
|
||||
if (values is null || values.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var value in values)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.Append('&');
|
||||
builder.Append(name);
|
||||
builder.Append('=');
|
||||
builder.Append(Uri.EscapeDataString(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureConfigured()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(options.ConcelierUrl))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
"ConcelierUrl is not configured. Set StellaOps:ConcelierUrl or STELLAOPS_CONCELIER_URL.");
|
||||
}
|
||||
|
||||
private async Task AuthorizeRequestAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var token = await ResolveAccessTokenAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string?> ResolveAccessTokenAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(options.ApiKey))
|
||||
{
|
||||
return options.ApiKey;
|
||||
}
|
||||
|
||||
if (tokenClient is null || string.IsNullOrWhiteSpace(options.Authority.Url))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
lock (tokenSync)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(cachedAccessToken) && now < cachedAccessTokenExpiresAt - TokenRefreshSkew)
|
||||
{
|
||||
return cachedAccessToken;
|
||||
}
|
||||
}
|
||||
|
||||
var (scope, cacheKey) = BuildScopeAndCacheKey(options);
|
||||
var cachedEntry = await tokenClient.GetCachedTokenAsync(cacheKey, cancellationToken).ConfigureAwait(false);
|
||||
if (cachedEntry is not null && now < cachedEntry.ExpiresAtUtc - TokenRefreshSkew)
|
||||
{
|
||||
lock (tokenSync)
|
||||
{
|
||||
cachedAccessToken = cachedEntry.AccessToken;
|
||||
cachedAccessTokenExpiresAt = cachedEntry.ExpiresAtUtc;
|
||||
return cachedAccessToken;
|
||||
}
|
||||
}
|
||||
|
||||
StellaOpsTokenResult token;
|
||||
if (!string.IsNullOrWhiteSpace(options.Authority.Username))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.Authority.Password))
|
||||
{
|
||||
throw new InvalidOperationException("Authority password must be configured when username is provided.");
|
||||
}
|
||||
|
||||
token = await tokenClient.RequestPasswordTokenAsync(
|
||||
options.Authority.Username,
|
||||
options.Authority.Password!,
|
||||
scope,
|
||||
null,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
token = await tokenClient.RequestClientCredentialsTokenAsync(scope, null, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await tokenClient.CacheTokenAsync(cacheKey, token.ToCacheEntry(), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
lock (tokenSync)
|
||||
{
|
||||
cachedAccessToken = token.AccessToken;
|
||||
cachedAccessTokenExpiresAt = token.ExpiresAtUtc;
|
||||
return cachedAccessToken;
|
||||
}
|
||||
}
|
||||
|
||||
private static (string Scope, string CacheKey) BuildScopeAndCacheKey(StellaOpsCliOptions options)
|
||||
{
|
||||
var baseScope = AuthorityTokenUtilities.ResolveScope(options);
|
||||
var finalScope = EnsureScope(baseScope, StellaOpsScopes.VulnRead);
|
||||
|
||||
var credential = !string.IsNullOrWhiteSpace(options.Authority.Username)
|
||||
? $"user:{options.Authority.Username}"
|
||||
: $"client:{options.Authority.ClientId}";
|
||||
|
||||
var cacheKey = $"{options.Authority.Url}|{credential}|{finalScope}";
|
||||
return (finalScope, cacheKey);
|
||||
}
|
||||
|
||||
private static string EnsureScope(string scopes, string required)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scopes))
|
||||
{
|
||||
return required;
|
||||
}
|
||||
|
||||
var parts = scopes
|
||||
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(static scope => scope.ToLowerInvariant())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (!parts.Contains(required, StringComparer.Ordinal))
|
||||
{
|
||||
parts.Add(required);
|
||||
}
|
||||
|
||||
return string.Join(' ', parts);
|
||||
}
|
||||
}
|
||||
10
src/Cli/StellaOps.Cli/Services/IAuthorityRevocationClient.cs
Normal file
10
src/Cli/StellaOps.Cli/Services/IAuthorityRevocationClient.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
internal interface IAuthorityRevocationClient
|
||||
{
|
||||
Task<AuthorityRevocationExportResult> ExportAsync(bool verbose, CancellationToken cancellationToken);
|
||||
}
|
||||
45
src/Cli/StellaOps.Cli/Services/IBackendOperationsClient.cs
Normal file
45
src/Cli/StellaOps.Cli/Services/IBackendOperationsClient.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
internal interface IBackendOperationsClient
|
||||
{
|
||||
Task<ScannerArtifactResult> DownloadScannerAsync(string channel, string outputPath, bool overwrite, bool verbose, CancellationToken cancellationToken);
|
||||
|
||||
Task UploadScanResultsAsync(string filePath, CancellationToken cancellationToken);
|
||||
|
||||
Task<JobTriggerResult> TriggerJobAsync(string jobKind, IDictionary<string, object?> parameters, CancellationToken cancellationToken);
|
||||
|
||||
Task<ExcititorOperationResult> ExecuteExcititorOperationAsync(string route, HttpMethod method, object? payload, CancellationToken cancellationToken);
|
||||
|
||||
Task<ExcititorExportDownloadResult> DownloadExcititorExportAsync(string exportId, string destinationPath, string? expectedDigestAlgorithm, string? expectedDigest, CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<ExcititorProviderSummary>> GetExcititorProvidersAsync(bool includeDisabled, CancellationToken cancellationToken);
|
||||
|
||||
Task<RuntimePolicyEvaluationResult> EvaluateRuntimePolicyAsync(RuntimePolicyEvaluationRequest request, CancellationToken cancellationToken);
|
||||
|
||||
Task<PolicySimulationResult> SimulatePolicyAsync(string policyId, PolicySimulationInput input, CancellationToken cancellationToken);
|
||||
|
||||
Task<PolicyActivationResult> ActivatePolicyRevisionAsync(string policyId, int version, PolicyActivationRequest request, CancellationToken cancellationToken);
|
||||
|
||||
Task<OfflineKitDownloadResult> DownloadOfflineKitAsync(string? bundleId, string destinationDirectory, bool overwrite, bool resume, CancellationToken cancellationToken);
|
||||
|
||||
Task<OfflineKitImportResult> ImportOfflineKitAsync(OfflineKitImportRequest request, CancellationToken cancellationToken);
|
||||
|
||||
Task<OfflineKitStatus> GetOfflineKitStatusAsync(CancellationToken cancellationToken);
|
||||
|
||||
Task<AocIngestDryRunResponse> ExecuteAocIngestDryRunAsync(AocIngestDryRunRequest request, CancellationToken cancellationToken);
|
||||
|
||||
Task<AocVerifyResponse> ExecuteAocVerifyAsync(AocVerifyRequest request, CancellationToken cancellationToken);
|
||||
|
||||
Task<PolicyFindingsPage> GetPolicyFindingsAsync(PolicyFindingsQuery query, CancellationToken cancellationToken);
|
||||
|
||||
Task<PolicyFindingDocument> GetPolicyFindingAsync(string policyId, string findingId, CancellationToken cancellationToken);
|
||||
|
||||
Task<PolicyFindingExplainResult> GetPolicyFindingExplainAsync(string policyId, string findingId, string? mode, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
internal interface IConcelierObservationsClient
|
||||
{
|
||||
Task<AdvisoryObservationsResponse> GetObservationsAsync(
|
||||
AdvisoryObservationsQuery query,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
17
src/Cli/StellaOps.Cli/Services/IScannerExecutor.cs
Normal file
17
src/Cli/StellaOps.Cli/Services/IScannerExecutor.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
internal interface IScannerExecutor
|
||||
{
|
||||
Task<ScannerExecutionResult> RunAsync(
|
||||
string runner,
|
||||
string entry,
|
||||
string targetDirectory,
|
||||
string resultsDirectory,
|
||||
IReadOnlyList<string> arguments,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
9
src/Cli/StellaOps.Cli/Services/IScannerInstaller.cs
Normal file
9
src/Cli/StellaOps.Cli/Services/IScannerInstaller.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
internal interface IScannerInstaller
|
||||
{
|
||||
Task InstallAsync(string artifactPath, bool verbose, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed record AdvisoryObservationsQuery(
|
||||
string Tenant,
|
||||
IReadOnlyList<string> ObservationIds,
|
||||
IReadOnlyList<string> Aliases,
|
||||
IReadOnlyList<string> Purls,
|
||||
IReadOnlyList<string> Cpes,
|
||||
int? Limit,
|
||||
string? Cursor);
|
||||
|
||||
internal sealed class AdvisoryObservationsResponse
|
||||
{
|
||||
[JsonPropertyName("observations")]
|
||||
public IReadOnlyList<AdvisoryObservationDocument> Observations { get; init; } =
|
||||
Array.Empty<AdvisoryObservationDocument>();
|
||||
|
||||
[JsonPropertyName("linkset")]
|
||||
public AdvisoryObservationLinksetAggregate Linkset { get; init; } =
|
||||
new();
|
||||
|
||||
[JsonPropertyName("nextCursor")]
|
||||
public string? NextCursor { get; init; }
|
||||
|
||||
[JsonPropertyName("hasMore")]
|
||||
public bool HasMore { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryObservationDocument
|
||||
{
|
||||
[JsonPropertyName("observationId")]
|
||||
public string ObservationId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public AdvisoryObservationSource Source { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("upstream")]
|
||||
public AdvisoryObservationUpstream Upstream { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("linkset")]
|
||||
public AdvisoryObservationLinkset Linkset { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryObservationSource
|
||||
{
|
||||
[JsonPropertyName("vendor")]
|
||||
public string Vendor { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("stream")]
|
||||
public string Stream { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("api")]
|
||||
public string Api { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("collectorVersion")]
|
||||
public string? CollectorVersion { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryObservationUpstream
|
||||
{
|
||||
[JsonPropertyName("upstreamId")]
|
||||
public string UpstreamId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("documentVersion")]
|
||||
public string? DocumentVersion { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryObservationLinkset
|
||||
{
|
||||
[JsonPropertyName("aliases")]
|
||||
public IReadOnlyList<string> Aliases { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("purls")]
|
||||
public IReadOnlyList<string> Purls { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("cpes")]
|
||||
public IReadOnlyList<string> Cpes { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("references")]
|
||||
public IReadOnlyList<AdvisoryObservationReference> References { get; init; } =
|
||||
Array.Empty<AdvisoryObservationReference>();
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryObservationReference
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("url")]
|
||||
public string Url { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryObservationLinksetAggregate
|
||||
{
|
||||
[JsonPropertyName("aliases")]
|
||||
public IReadOnlyList<string> Aliases { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("purls")]
|
||||
public IReadOnlyList<string> Purls { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("cpes")]
|
||||
public IReadOnlyList<string> Cpes { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("references")]
|
||||
public IReadOnlyList<AdvisoryObservationReference> References { get; init; } =
|
||||
Array.Empty<AdvisoryObservationReference>();
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed class AocIngestDryRunRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public string Source { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("document")]
|
||||
public AocIngestDryRunDocument Document { get; init; } = new();
|
||||
}
|
||||
|
||||
internal sealed class AocIngestDryRunDocument
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; init; }
|
||||
|
||||
[JsonPropertyName("content")]
|
||||
public string Content { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("contentType")]
|
||||
public string ContentType { get; init; } = "application/json";
|
||||
|
||||
[JsonPropertyName("contentEncoding")]
|
||||
public string? ContentEncoding { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AocIngestDryRunResponse
|
||||
{
|
||||
[JsonPropertyName("source")]
|
||||
public string? Source { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("guardVersion")]
|
||||
public string? GuardVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string? Status { get; init; }
|
||||
|
||||
[JsonPropertyName("document")]
|
||||
public AocIngestDryRunDocumentResult Document { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("violations")]
|
||||
public IReadOnlyList<AocIngestDryRunViolation> Violations { get; init; } =
|
||||
Array.Empty<AocIngestDryRunViolation>();
|
||||
}
|
||||
|
||||
internal sealed class AocIngestDryRunDocumentResult
|
||||
{
|
||||
[JsonPropertyName("contentHash")]
|
||||
public string? ContentHash { get; init; }
|
||||
|
||||
[JsonPropertyName("supersedes")]
|
||||
public string? Supersedes { get; init; }
|
||||
|
||||
[JsonPropertyName("provenance")]
|
||||
public AocIngestDryRunProvenance Provenance { get; init; } = new();
|
||||
}
|
||||
|
||||
internal sealed class AocIngestDryRunProvenance
|
||||
{
|
||||
[JsonPropertyName("signature")]
|
||||
public AocIngestDryRunSignature Signature { get; init; } = new();
|
||||
}
|
||||
|
||||
internal sealed class AocIngestDryRunSignature
|
||||
{
|
||||
[JsonPropertyName("format")]
|
||||
public string? Format { get; init; }
|
||||
|
||||
[JsonPropertyName("present")]
|
||||
public bool Present { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AocIngestDryRunViolation
|
||||
{
|
||||
[JsonPropertyName("code")]
|
||||
public string Code { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string Message { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("path")]
|
||||
public string? Path { get; init; }
|
||||
}
|
||||
100
src/Cli/StellaOps.Cli/Services/Models/AocVerifyModels.cs
Normal file
100
src/Cli/StellaOps.Cli/Services/Models/AocVerifyModels.cs
Normal file
@@ -0,0 +1,100 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed class AocVerifyRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("since")]
|
||||
public string? Since { get; init; }
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public int? Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("sources")]
|
||||
public IReadOnlyList<string>? Sources { get; init; }
|
||||
|
||||
[JsonPropertyName("codes")]
|
||||
public IReadOnlyList<string>? Codes { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AocVerifyResponse
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("window")]
|
||||
public AocVerifyWindow Window { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("checked")]
|
||||
public AocVerifyChecked Checked { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("violations")]
|
||||
public IReadOnlyList<AocVerifyViolation> Violations { get; init; } =
|
||||
Array.Empty<AocVerifyViolation>();
|
||||
|
||||
[JsonPropertyName("metrics")]
|
||||
public AocVerifyMetrics Metrics { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("truncated")]
|
||||
public bool? Truncated { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AocVerifyWindow
|
||||
{
|
||||
[JsonPropertyName("from")]
|
||||
public DateTimeOffset? From { get; init; }
|
||||
|
||||
[JsonPropertyName("to")]
|
||||
public DateTimeOffset? To { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AocVerifyChecked
|
||||
{
|
||||
[JsonPropertyName("advisories")]
|
||||
public int Advisories { get; init; }
|
||||
|
||||
[JsonPropertyName("vex")]
|
||||
public int Vex { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AocVerifyViolation
|
||||
{
|
||||
[JsonPropertyName("code")]
|
||||
public string Code { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("count")]
|
||||
public int Count { get; init; }
|
||||
|
||||
[JsonPropertyName("examples")]
|
||||
public IReadOnlyList<AocVerifyViolationExample> Examples { get; init; } =
|
||||
Array.Empty<AocVerifyViolationExample>();
|
||||
}
|
||||
|
||||
internal sealed class AocVerifyViolationExample
|
||||
{
|
||||
[JsonPropertyName("source")]
|
||||
public string? Source { get; init; }
|
||||
|
||||
[JsonPropertyName("documentId")]
|
||||
public string? DocumentId { get; init; }
|
||||
|
||||
[JsonPropertyName("contentHash")]
|
||||
public string? ContentHash { get; init; }
|
||||
|
||||
[JsonPropertyName("path")]
|
||||
public string? Path { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AocVerifyMetrics
|
||||
{
|
||||
[JsonPropertyName("ingestion_write_total")]
|
||||
public int? IngestionWriteTotal { get; init; }
|
||||
|
||||
[JsonPropertyName("aoc_violation_total")]
|
||||
public int? AocViolationTotal { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed class AuthorityRevocationExportResult
|
||||
{
|
||||
public required byte[] BundleBytes { get; init; }
|
||||
|
||||
public required string Signature { get; init; }
|
||||
|
||||
public required string Digest { get; init; }
|
||||
|
||||
public required long Sequence { get; init; }
|
||||
|
||||
public required DateTimeOffset IssuedAt { get; init; }
|
||||
|
||||
public string? SigningKeyId { get; init; }
|
||||
|
||||
public string? SigningProvider { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed record ExcititorExportDownloadResult(
|
||||
string Path,
|
||||
long SizeBytes,
|
||||
bool FromCache);
|
||||
@@ -0,0 +1,9 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed record ExcititorOperationResult(
|
||||
bool Success,
|
||||
string Message,
|
||||
string? Location,
|
||||
JsonElement? Payload);
|
||||
@@ -0,0 +1,11 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed record ExcititorProviderSummary(
|
||||
string Id,
|
||||
string Kind,
|
||||
string DisplayName,
|
||||
string TrustTier,
|
||||
bool Enabled,
|
||||
DateTimeOffset? LastIngestedAt);
|
||||
@@ -0,0 +1,9 @@
|
||||
using StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed record JobTriggerResult(
|
||||
bool Success,
|
||||
string Message,
|
||||
string? Location,
|
||||
JobRunResponse? Run);
|
||||
111
src/Cli/StellaOps.Cli/Services/Models/OfflineKitModels.cs
Normal file
111
src/Cli/StellaOps.Cli/Services/Models/OfflineKitModels.cs
Normal file
@@ -0,0 +1,111 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed record OfflineKitBundleDescriptor(
|
||||
string BundleId,
|
||||
string BundleName,
|
||||
string BundleSha256,
|
||||
long BundleSize,
|
||||
Uri BundleDownloadUri,
|
||||
string ManifestName,
|
||||
string ManifestSha256,
|
||||
Uri ManifestDownloadUri,
|
||||
DateTimeOffset CapturedAt,
|
||||
string? Channel,
|
||||
string? Kind,
|
||||
bool IsDelta,
|
||||
string? BaseBundleId,
|
||||
string? BundleSignatureName,
|
||||
Uri? BundleSignatureDownloadUri,
|
||||
string? ManifestSignatureName,
|
||||
Uri? ManifestSignatureDownloadUri,
|
||||
long? ManifestSize);
|
||||
|
||||
internal sealed record OfflineKitDownloadResult(
|
||||
OfflineKitBundleDescriptor Descriptor,
|
||||
string BundlePath,
|
||||
string ManifestPath,
|
||||
string? BundleSignaturePath,
|
||||
string? ManifestSignaturePath,
|
||||
string MetadataPath,
|
||||
bool FromCache);
|
||||
|
||||
internal sealed record OfflineKitImportRequest(
|
||||
string BundlePath,
|
||||
string? ManifestPath,
|
||||
string? BundleSignaturePath,
|
||||
string? ManifestSignaturePath,
|
||||
string? BundleId,
|
||||
string? BundleSha256,
|
||||
long? BundleSize,
|
||||
DateTimeOffset? CapturedAt,
|
||||
string? Channel,
|
||||
string? Kind,
|
||||
bool? IsDelta,
|
||||
string? BaseBundleId,
|
||||
string? ManifestSha256,
|
||||
long? ManifestSize);
|
||||
|
||||
internal sealed record OfflineKitImportResult(
|
||||
string? ImportId,
|
||||
string? Status,
|
||||
DateTimeOffset SubmittedAt,
|
||||
string? Message);
|
||||
|
||||
internal sealed record OfflineKitStatus(
|
||||
string? BundleId,
|
||||
string? Channel,
|
||||
string? Kind,
|
||||
bool IsDelta,
|
||||
string? BaseBundleId,
|
||||
DateTimeOffset? CapturedAt,
|
||||
DateTimeOffset? ImportedAt,
|
||||
string? BundleSha256,
|
||||
long? BundleSize,
|
||||
IReadOnlyList<OfflineKitComponentStatus> Components);
|
||||
|
||||
internal sealed record OfflineKitComponentStatus(
|
||||
string Name,
|
||||
string? Version,
|
||||
string? Digest,
|
||||
DateTimeOffset? CapturedAt,
|
||||
long? SizeBytes);
|
||||
|
||||
internal sealed record OfflineKitMetadataDocument
|
||||
{
|
||||
public string? BundleId { get; init; }
|
||||
|
||||
public string BundleName { get; init; } = string.Empty;
|
||||
|
||||
public string BundleSha256 { get; init; } = string.Empty;
|
||||
|
||||
public long BundleSize { get; init; }
|
||||
|
||||
public string BundlePath { get; init; } = string.Empty;
|
||||
|
||||
public DateTimeOffset CapturedAt { get; init; }
|
||||
|
||||
public DateTimeOffset DownloadedAt { get; init; }
|
||||
|
||||
public string? Channel { get; init; }
|
||||
|
||||
public string? Kind { get; init; }
|
||||
|
||||
public bool IsDelta { get; init; }
|
||||
|
||||
public string? BaseBundleId { get; init; }
|
||||
|
||||
public string ManifestName { get; init; } = string.Empty;
|
||||
|
||||
public string ManifestSha256 { get; init; } = string.Empty;
|
||||
|
||||
public long? ManifestSize { get; init; }
|
||||
|
||||
public string ManifestPath { get; init; } = string.Empty;
|
||||
|
||||
public string? BundleSignaturePath { get; init; }
|
||||
|
||||
public string? ManifestSignaturePath { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed record PolicyActivationRequest(
|
||||
bool RunNow,
|
||||
DateTimeOffset? ScheduledAt,
|
||||
string? Priority,
|
||||
bool Rollback,
|
||||
string? IncidentId,
|
||||
string? Comment);
|
||||
|
||||
internal sealed record PolicyActivationResult(
|
||||
string Status,
|
||||
PolicyActivationRevision Revision);
|
||||
|
||||
internal sealed record PolicyActivationRevision(
|
||||
string PolicyId,
|
||||
int Version,
|
||||
string Status,
|
||||
bool RequiresTwoPersonApproval,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? ActivatedAt,
|
||||
IReadOnlyList<PolicyActivationApproval> Approvals);
|
||||
|
||||
internal sealed record PolicyActivationApproval(
|
||||
string ActorId,
|
||||
DateTimeOffset ApprovedAt,
|
||||
string? Comment);
|
||||
@@ -0,0 +1,50 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed record PolicyFindingsQuery(
|
||||
string PolicyId,
|
||||
IReadOnlyList<string> SbomIds,
|
||||
IReadOnlyList<string> Statuses,
|
||||
IReadOnlyList<string> Severities,
|
||||
string? Cursor,
|
||||
int? Page,
|
||||
int? PageSize,
|
||||
DateTimeOffset? Since);
|
||||
|
||||
internal sealed record PolicyFindingsPage(
|
||||
IReadOnlyList<PolicyFindingDocument> Items,
|
||||
string? NextCursor,
|
||||
int? TotalCount);
|
||||
|
||||
internal sealed record PolicyFindingDocument(
|
||||
string FindingId,
|
||||
string Status,
|
||||
PolicyFindingSeverity Severity,
|
||||
string SbomId,
|
||||
IReadOnlyList<string> AdvisoryIds,
|
||||
PolicyFindingVexMetadata? Vex,
|
||||
int PolicyVersion,
|
||||
DateTimeOffset UpdatedAt,
|
||||
string? RunId);
|
||||
|
||||
internal sealed record PolicyFindingSeverity(string Normalized, double? Score);
|
||||
|
||||
internal sealed record PolicyFindingVexMetadata(string? WinningStatementId, string? Source, string? Status);
|
||||
|
||||
internal sealed record PolicyFindingExplainResult(
|
||||
string FindingId,
|
||||
int PolicyVersion,
|
||||
IReadOnlyList<PolicyFindingExplainStep> Steps,
|
||||
IReadOnlyList<PolicyFindingExplainHint> SealedHints);
|
||||
|
||||
internal sealed record PolicyFindingExplainStep(
|
||||
string Rule,
|
||||
string? Status,
|
||||
string? Action,
|
||||
double? Score,
|
||||
IReadOnlyDictionary<string, string> Inputs,
|
||||
IReadOnlyDictionary<string, string>? Evidence);
|
||||
|
||||
internal sealed record PolicyFindingExplainHint(string Message);
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed record PolicySimulationInput(
|
||||
int? BaseVersion,
|
||||
int? CandidateVersion,
|
||||
IReadOnlyList<string> SbomSet,
|
||||
IReadOnlyDictionary<string, object?> Environment,
|
||||
bool Explain);
|
||||
|
||||
internal sealed record PolicySimulationResult(
|
||||
PolicySimulationDiff Diff,
|
||||
string? ExplainUri);
|
||||
|
||||
internal sealed record PolicySimulationDiff(
|
||||
string? SchemaVersion,
|
||||
int Added,
|
||||
int Removed,
|
||||
int Unchanged,
|
||||
IReadOnlyDictionary<string, PolicySimulationSeverityDelta> BySeverity,
|
||||
IReadOnlyList<PolicySimulationRuleDelta> RuleHits);
|
||||
|
||||
internal sealed record PolicySimulationSeverityDelta(int? Up, int? Down);
|
||||
|
||||
internal sealed record PolicySimulationRuleDelta(string RuleId, string RuleName, int? Up, int? Down);
|
||||
@@ -0,0 +1,25 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed record RuntimePolicyEvaluationRequest(
|
||||
string? Namespace,
|
||||
IReadOnlyDictionary<string, string> Labels,
|
||||
IReadOnlyList<string> Images);
|
||||
|
||||
internal sealed record RuntimePolicyEvaluationResult(
|
||||
int TtlSeconds,
|
||||
DateTimeOffset? ExpiresAtUtc,
|
||||
string? PolicyRevision,
|
||||
IReadOnlyDictionary<string, RuntimePolicyImageDecision> Decisions);
|
||||
|
||||
internal sealed record RuntimePolicyImageDecision(
|
||||
string PolicyVerdict,
|
||||
bool? Signed,
|
||||
bool? HasSbomReferrers,
|
||||
IReadOnlyList<string> Reasons,
|
||||
RuntimePolicyRekorReference? Rekor,
|
||||
IReadOnlyDictionary<string, object?> AdditionalProperties);
|
||||
|
||||
internal sealed record RuntimePolicyRekorReference(string? Uuid, string? Url, bool? Verified);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed record ScannerArtifactResult(string Path, long SizeBytes, bool FromCache);
|
||||
@@ -0,0 +1,27 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
internal sealed class JobRunResponse
|
||||
{
|
||||
public Guid RunId { get; set; }
|
||||
|
||||
public string Kind { get; set; } = string.Empty;
|
||||
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
public string Trigger { get; set; } = string.Empty;
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
public DateTimeOffset? StartedAt { get; set; }
|
||||
|
||||
public DateTimeOffset? CompletedAt { get; set; }
|
||||
|
||||
public string? Error { get; set; }
|
||||
|
||||
public TimeSpan? Duration { get; set; }
|
||||
|
||||
public IReadOnlyDictionary<string, object?> Parameters { get; set; } = new Dictionary<string, object?>(StringComparer.Ordinal);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
internal sealed class JobTriggerRequest
|
||||
{
|
||||
public string Trigger { get; set; } = "cli";
|
||||
|
||||
public Dictionary<string, object?> Parameters { get; set; } = new(StringComparer.Ordinal);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
internal sealed class OfflineKitBundleDescriptorTransport
|
||||
{
|
||||
public string? BundleId { get; set; }
|
||||
|
||||
public string? BundleName { get; set; }
|
||||
|
||||
public string? BundleSha256 { get; set; }
|
||||
|
||||
public long BundleSize { get; set; }
|
||||
|
||||
public string? BundleUrl { get; set; }
|
||||
|
||||
public string? BundlePath { get; set; }
|
||||
|
||||
public string? BundleSignatureName { get; set; }
|
||||
|
||||
public string? BundleSignatureUrl { get; set; }
|
||||
|
||||
public string? BundleSignaturePath { get; set; }
|
||||
|
||||
public string? ManifestName { get; set; }
|
||||
|
||||
public string? ManifestSha256 { get; set; }
|
||||
|
||||
public long? ManifestSize { get; set; }
|
||||
|
||||
public string? ManifestUrl { get; set; }
|
||||
|
||||
public string? ManifestPath { get; set; }
|
||||
|
||||
public string? ManifestSignatureName { get; set; }
|
||||
|
||||
public string? ManifestSignatureUrl { get; set; }
|
||||
|
||||
public string? ManifestSignaturePath { get; set; }
|
||||
|
||||
public DateTimeOffset? CapturedAt { get; set; }
|
||||
|
||||
public string? Channel { get; set; }
|
||||
|
||||
public string? Kind { get; set; }
|
||||
|
||||
public bool? IsDelta { get; set; }
|
||||
|
||||
public string? BaseBundleId { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class OfflineKitStatusBundleTransport
|
||||
{
|
||||
public string? BundleId { get; set; }
|
||||
|
||||
public string? Channel { get; set; }
|
||||
|
||||
public string? Kind { get; set; }
|
||||
|
||||
public bool? IsDelta { get; set; }
|
||||
|
||||
public string? BaseBundleId { get; set; }
|
||||
|
||||
public string? BundleSha256 { get; set; }
|
||||
|
||||
public long? BundleSize { get; set; }
|
||||
|
||||
public DateTimeOffset? CapturedAt { get; set; }
|
||||
|
||||
public DateTimeOffset? ImportedAt { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class OfflineKitStatusTransport
|
||||
{
|
||||
public OfflineKitStatusBundleTransport? Current { get; set; }
|
||||
|
||||
public List<OfflineKitComponentStatusTransport>? Components { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class OfflineKitComponentStatusTransport
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
|
||||
public string? Version { get; set; }
|
||||
|
||||
public string? Digest { get; set; }
|
||||
|
||||
public DateTimeOffset? CapturedAt { get; set; }
|
||||
|
||||
public long? SizeBytes { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class OfflineKitImportResponseTransport
|
||||
{
|
||||
public string? ImportId { get; set; }
|
||||
|
||||
public string? Status { get; set; }
|
||||
|
||||
public DateTimeOffset? SubmittedAt { get; set; }
|
||||
|
||||
public string? Message { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
internal sealed class PolicyActivationRequestDocument
|
||||
{
|
||||
public string? Comment { get; set; }
|
||||
|
||||
public bool? RunNow { get; set; }
|
||||
|
||||
public DateTimeOffset? ScheduledAt { get; set; }
|
||||
|
||||
public string? Priority { get; set; }
|
||||
|
||||
public bool? Rollback { get; set; }
|
||||
|
||||
public string? IncidentId { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicyActivationResponseDocument
|
||||
{
|
||||
public string? Status { get; set; }
|
||||
|
||||
public PolicyActivationRevisionDocument? Revision { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicyActivationRevisionDocument
|
||||
{
|
||||
public string? PackId { get; set; }
|
||||
|
||||
public int? Version { get; set; }
|
||||
|
||||
public string? Status { get; set; }
|
||||
|
||||
public bool? RequiresTwoPersonApproval { get; set; }
|
||||
|
||||
public DateTimeOffset? CreatedAt { get; set; }
|
||||
|
||||
public DateTimeOffset? ActivatedAt { get; set; }
|
||||
|
||||
public List<PolicyActivationApprovalDocument>? Approvals { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicyActivationApprovalDocument
|
||||
{
|
||||
public string? ActorId { get; set; }
|
||||
|
||||
public DateTimeOffset? ApprovedAt { get; set; }
|
||||
|
||||
public string? Comment { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
internal sealed class PolicyFindingsResponseDocument
|
||||
{
|
||||
public List<PolicyFindingDocumentDocument>? Items { get; set; }
|
||||
|
||||
public string? NextCursor { get; set; }
|
||||
|
||||
public int? TotalCount { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicyFindingDocumentDocument
|
||||
{
|
||||
public string? FindingId { get; set; }
|
||||
|
||||
public string? Status { get; set; }
|
||||
|
||||
public PolicyFindingSeverityDocument? Severity { get; set; }
|
||||
|
||||
public string? SbomId { get; set; }
|
||||
|
||||
public List<string>? AdvisoryIds { get; set; }
|
||||
|
||||
public PolicyFindingVexDocument? Vex { get; set; }
|
||||
|
||||
public int? PolicyVersion { get; set; }
|
||||
|
||||
public DateTimeOffset? UpdatedAt { get; set; }
|
||||
|
||||
public string? RunId { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicyFindingSeverityDocument
|
||||
{
|
||||
public string? Normalized { get; set; }
|
||||
|
||||
public double? Score { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicyFindingVexDocument
|
||||
{
|
||||
public string? WinningStatementId { get; set; }
|
||||
|
||||
public string? Source { get; set; }
|
||||
|
||||
public string? Status { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicyFindingExplainResponseDocument
|
||||
{
|
||||
public string? FindingId { get; set; }
|
||||
|
||||
public int? PolicyVersion { get; set; }
|
||||
|
||||
public List<PolicyFindingExplainStepDocument>? Steps { get; set; }
|
||||
|
||||
public List<PolicyFindingExplainHintDocument>? SealedHints { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicyFindingExplainStepDocument
|
||||
{
|
||||
public string? Rule { get; set; }
|
||||
|
||||
public string? Status { get; set; }
|
||||
|
||||
public string? Action { get; set; }
|
||||
|
||||
public double? Score { get; set; }
|
||||
|
||||
public Dictionary<string, JsonElement>? Inputs { get; set; }
|
||||
|
||||
public Dictionary<string, JsonElement>? Evidence { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicyFindingExplainHintDocument
|
||||
{
|
||||
public string? Message { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
internal sealed class PolicySimulationRequestDocument
|
||||
{
|
||||
public int? BaseVersion { get; set; }
|
||||
|
||||
public int? CandidateVersion { get; set; }
|
||||
|
||||
public IReadOnlyList<string>? SbomSet { get; set; }
|
||||
|
||||
public Dictionary<string, JsonElement>? Env { get; set; }
|
||||
|
||||
public bool? Explain { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicySimulationResponseDocument
|
||||
{
|
||||
public PolicySimulationDiffDocument? Diff { get; set; }
|
||||
|
||||
public string? ExplainUri { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicySimulationDiffDocument
|
||||
{
|
||||
public string? SchemaVersion { get; set; }
|
||||
|
||||
public int? Added { get; set; }
|
||||
|
||||
public int? Removed { get; set; }
|
||||
|
||||
public int? Unchanged { get; set; }
|
||||
|
||||
public Dictionary<string, PolicySimulationSeverityDeltaDocument>? BySeverity { get; set; }
|
||||
|
||||
public List<PolicySimulationRuleDeltaDocument>? RuleHits { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicySimulationSeverityDeltaDocument
|
||||
{
|
||||
public int? Up { get; set; }
|
||||
|
||||
public int? Down { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicySimulationRuleDeltaDocument
|
||||
{
|
||||
public string? RuleId { get; set; }
|
||||
|
||||
public string? RuleName { get; set; }
|
||||
|
||||
public int? Up { get; set; }
|
||||
|
||||
public int? Down { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
internal sealed class ProblemDocument
|
||||
{
|
||||
public string? Type { get; set; }
|
||||
|
||||
public string? Title { get; set; }
|
||||
|
||||
public string? Detail { get; set; }
|
||||
|
||||
public int? Status { get; set; }
|
||||
|
||||
public string? Instance { get; set; }
|
||||
|
||||
public Dictionary<string, object?>? Extensions { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
internal sealed class RuntimePolicyEvaluationRequestDocument
|
||||
{
|
||||
[JsonPropertyName("namespace")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Namespace { get; set; }
|
||||
|
||||
[JsonPropertyName("labels")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public Dictionary<string, string>? Labels { get; set; }
|
||||
|
||||
[JsonPropertyName("images")]
|
||||
public List<string> Images { get; set; } = new();
|
||||
}
|
||||
|
||||
internal sealed class RuntimePolicyEvaluationResponseDocument
|
||||
{
|
||||
[JsonPropertyName("ttlSeconds")]
|
||||
public int? TtlSeconds { get; set; }
|
||||
|
||||
[JsonPropertyName("expiresAtUtc")]
|
||||
public DateTimeOffset? ExpiresAtUtc { get; set; }
|
||||
|
||||
[JsonPropertyName("policyRevision")]
|
||||
public string? PolicyRevision { get; set; }
|
||||
|
||||
[JsonPropertyName("results")]
|
||||
public Dictionary<string, RuntimePolicyEvaluationImageDocument>? Results { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class RuntimePolicyEvaluationImageDocument
|
||||
{
|
||||
[JsonPropertyName("policyVerdict")]
|
||||
public string? PolicyVerdict { get; set; }
|
||||
|
||||
[JsonPropertyName("signed")]
|
||||
public bool? Signed { get; set; }
|
||||
|
||||
[JsonPropertyName("hasSbomReferrers")]
|
||||
public bool? HasSbomReferrers { get; set; }
|
||||
|
||||
// Legacy field kept for pre-contract-sync services.
|
||||
[JsonPropertyName("hasSbom")]
|
||||
public bool? HasSbomLegacy { get; set; }
|
||||
|
||||
[JsonPropertyName("reasons")]
|
||||
public List<string>? Reasons { get; set; }
|
||||
|
||||
[JsonPropertyName("rekor")]
|
||||
public RuntimePolicyRekorDocument? Rekor { get; set; }
|
||||
|
||||
[JsonExtensionData]
|
||||
public Dictionary<string, JsonElement>? ExtensionData { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class RuntimePolicyRekorDocument
|
||||
{
|
||||
[JsonPropertyName("uuid")]
|
||||
public string? Uuid { get; set; }
|
||||
|
||||
[JsonPropertyName("url")]
|
||||
public string? Url { get; set; }
|
||||
|
||||
[JsonPropertyName("verified")]
|
||||
public bool? Verified { get; set; }
|
||||
}
|
||||
18
src/Cli/StellaOps.Cli/Services/PolicyApiException.cs
Normal file
18
src/Cli/StellaOps.Cli/Services/PolicyApiException.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
internal sealed class PolicyApiException : Exception
|
||||
{
|
||||
public PolicyApiException(string message, HttpStatusCode statusCode, string? errorCode, Exception? innerException = null)
|
||||
: base(message, innerException)
|
||||
{
|
||||
StatusCode = statusCode;
|
||||
ErrorCode = errorCode;
|
||||
}
|
||||
|
||||
public HttpStatusCode StatusCode { get; }
|
||||
|
||||
public string? ErrorCode { get; }
|
||||
}
|
||||
3
src/Cli/StellaOps.Cli/Services/ScannerExecutionResult.cs
Normal file
3
src/Cli/StellaOps.Cli/Services/ScannerExecutionResult.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
internal sealed record ScannerExecutionResult(int ExitCode, string ResultsPath, string RunMetadataPath);
|
||||
329
src/Cli/StellaOps.Cli/Services/ScannerExecutor.cs
Normal file
329
src/Cli/StellaOps.Cli/Services/ScannerExecutor.cs
Normal file
@@ -0,0 +1,329 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
internal sealed class ScannerExecutor : IScannerExecutor
|
||||
{
|
||||
private readonly ILogger<ScannerExecutor> _logger;
|
||||
|
||||
public ScannerExecutor(ILogger<ScannerExecutor> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<ScannerExecutionResult> RunAsync(
|
||||
string runner,
|
||||
string entry,
|
||||
string targetDirectory,
|
||||
string resultsDirectory,
|
||||
IReadOnlyList<string> arguments,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(targetDirectory))
|
||||
{
|
||||
throw new ArgumentException("Target directory must be provided.", nameof(targetDirectory));
|
||||
}
|
||||
|
||||
runner = string.IsNullOrWhiteSpace(runner) ? "docker" : runner.Trim().ToLowerInvariant();
|
||||
entry = entry?.Trim() ?? string.Empty;
|
||||
|
||||
var normalizedTarget = Path.GetFullPath(targetDirectory);
|
||||
if (!Directory.Exists(normalizedTarget))
|
||||
{
|
||||
throw new DirectoryNotFoundException($"Scan target directory '{normalizedTarget}' does not exist.");
|
||||
}
|
||||
|
||||
resultsDirectory = string.IsNullOrWhiteSpace(resultsDirectory)
|
||||
? Path.Combine(Directory.GetCurrentDirectory(), "scan-results")
|
||||
: Path.GetFullPath(resultsDirectory);
|
||||
|
||||
Directory.CreateDirectory(resultsDirectory);
|
||||
var executionTimestamp = DateTimeOffset.UtcNow;
|
||||
var baselineFiles = Directory.GetFiles(resultsDirectory, "*", SearchOption.AllDirectories);
|
||||
var baseline = new HashSet<string>(baselineFiles, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var startInfo = BuildProcessStartInfo(runner, entry, normalizedTarget, resultsDirectory, arguments);
|
||||
using var process = new Process { StartInfo = startInfo, EnableRaisingEvents = true };
|
||||
|
||||
var stdout = new List<string>();
|
||||
var stderr = new List<string>();
|
||||
|
||||
process.OutputDataReceived += (_, args) =>
|
||||
{
|
||||
if (args.Data is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
stdout.Add(args.Data);
|
||||
if (verbose)
|
||||
{
|
||||
_logger.LogInformation("[scan] {Line}", args.Data);
|
||||
}
|
||||
};
|
||||
|
||||
process.ErrorDataReceived += (_, args) =>
|
||||
{
|
||||
if (args.Data is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
stderr.Add(args.Data);
|
||||
_logger.LogError("[scan] {Line}", args.Data);
|
||||
};
|
||||
|
||||
_logger.LogInformation("Launching scanner via {Runner} (entry: {Entry})...", runner, entry);
|
||||
if (!process.Start())
|
||||
{
|
||||
throw new InvalidOperationException("Failed to start scanner process.");
|
||||
}
|
||||
|
||||
process.BeginOutputReadLine();
|
||||
process.BeginErrorReadLine();
|
||||
|
||||
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
|
||||
var completionTimestamp = DateTimeOffset.UtcNow;
|
||||
|
||||
if (process.ExitCode == 0)
|
||||
{
|
||||
_logger.LogInformation("Scanner completed successfully.");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Scanner exited with code {Code}.", process.ExitCode);
|
||||
}
|
||||
|
||||
var resultsPath = ResolveResultsPath(resultsDirectory, executionTimestamp, baseline);
|
||||
if (string.IsNullOrWhiteSpace(resultsPath))
|
||||
{
|
||||
resultsPath = CreatePlaceholderResult(resultsDirectory);
|
||||
}
|
||||
|
||||
var metadataPath = WriteRunMetadata(
|
||||
resultsDirectory,
|
||||
executionTimestamp,
|
||||
completionTimestamp,
|
||||
runner,
|
||||
entry,
|
||||
normalizedTarget,
|
||||
resultsPath,
|
||||
arguments,
|
||||
process.ExitCode,
|
||||
stdout,
|
||||
stderr);
|
||||
|
||||
return new ScannerExecutionResult(process.ExitCode, resultsPath, metadataPath);
|
||||
}
|
||||
|
||||
private ProcessStartInfo BuildProcessStartInfo(
|
||||
string runner,
|
||||
string entry,
|
||||
string targetDirectory,
|
||||
string resultsDirectory,
|
||||
IReadOnlyList<string> args)
|
||||
{
|
||||
return runner switch
|
||||
{
|
||||
"self" or "native" => BuildNativeStartInfo(entry, args),
|
||||
"dotnet" => BuildDotNetStartInfo(entry, args),
|
||||
"docker" => BuildDockerStartInfo(entry, targetDirectory, resultsDirectory, args),
|
||||
_ => BuildCustomRunnerStartInfo(runner, entry, args)
|
||||
};
|
||||
}
|
||||
|
||||
private static ProcessStartInfo BuildNativeStartInfo(string binaryPath, IReadOnlyList<string> args)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(binaryPath) || !File.Exists(binaryPath))
|
||||
{
|
||||
throw new FileNotFoundException("Scanner entrypoint not found.", binaryPath);
|
||||
}
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = binaryPath,
|
||||
WorkingDirectory = Directory.GetCurrentDirectory()
|
||||
};
|
||||
|
||||
foreach (var argument in args)
|
||||
{
|
||||
startInfo.ArgumentList.Add(argument);
|
||||
}
|
||||
|
||||
startInfo.RedirectStandardError = true;
|
||||
startInfo.RedirectStandardOutput = true;
|
||||
startInfo.UseShellExecute = false;
|
||||
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
private static ProcessStartInfo BuildDotNetStartInfo(string binaryPath, IReadOnlyList<string> args)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "dotnet",
|
||||
WorkingDirectory = Directory.GetCurrentDirectory()
|
||||
};
|
||||
|
||||
startInfo.ArgumentList.Add(binaryPath);
|
||||
foreach (var argument in args)
|
||||
{
|
||||
startInfo.ArgumentList.Add(argument);
|
||||
}
|
||||
|
||||
startInfo.RedirectStandardError = true;
|
||||
startInfo.RedirectStandardOutput = true;
|
||||
startInfo.UseShellExecute = false;
|
||||
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
private static ProcessStartInfo BuildDockerStartInfo(string image, string targetDirectory, string resultsDirectory, IReadOnlyList<string> args)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(image))
|
||||
{
|
||||
throw new ArgumentException("Docker image must be provided when runner is 'docker'.", nameof(image));
|
||||
}
|
||||
|
||||
var cwd = Directory.GetCurrentDirectory();
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "docker",
|
||||
WorkingDirectory = cwd
|
||||
};
|
||||
|
||||
startInfo.ArgumentList.Add("run");
|
||||
startInfo.ArgumentList.Add("--rm");
|
||||
startInfo.ArgumentList.Add("-v");
|
||||
startInfo.ArgumentList.Add($"{cwd}:{cwd}");
|
||||
startInfo.ArgumentList.Add("-v");
|
||||
startInfo.ArgumentList.Add($"{targetDirectory}:/scan-target:ro");
|
||||
startInfo.ArgumentList.Add("-v");
|
||||
startInfo.ArgumentList.Add($"{resultsDirectory}:/scan-results");
|
||||
startInfo.ArgumentList.Add("-w");
|
||||
startInfo.ArgumentList.Add(cwd);
|
||||
startInfo.ArgumentList.Add(image);
|
||||
startInfo.ArgumentList.Add("--target");
|
||||
startInfo.ArgumentList.Add("/scan-target");
|
||||
startInfo.ArgumentList.Add("--output");
|
||||
startInfo.ArgumentList.Add("/scan-results/scan.json");
|
||||
|
||||
foreach (var argument in args)
|
||||
{
|
||||
startInfo.ArgumentList.Add(argument);
|
||||
}
|
||||
|
||||
startInfo.RedirectStandardError = true;
|
||||
startInfo.RedirectStandardOutput = true;
|
||||
startInfo.UseShellExecute = false;
|
||||
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
private static ProcessStartInfo BuildCustomRunnerStartInfo(string runner, string entry, IReadOnlyList<string> args)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = runner,
|
||||
WorkingDirectory = Directory.GetCurrentDirectory()
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(entry))
|
||||
{
|
||||
startInfo.ArgumentList.Add(entry);
|
||||
}
|
||||
|
||||
foreach (var argument in args)
|
||||
{
|
||||
startInfo.ArgumentList.Add(argument);
|
||||
}
|
||||
|
||||
startInfo.RedirectStandardError = true;
|
||||
startInfo.RedirectStandardOutput = true;
|
||||
startInfo.UseShellExecute = false;
|
||||
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
private static string ResolveResultsPath(string resultsDirectory, DateTimeOffset startTimestamp, HashSet<string> baseline)
|
||||
{
|
||||
var candidates = Directory.GetFiles(resultsDirectory, "*", SearchOption.AllDirectories);
|
||||
string? newest = null;
|
||||
DateTimeOffset newestTimestamp = startTimestamp;
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (baseline.Contains(candidate))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var info = new FileInfo(candidate);
|
||||
if (info.LastWriteTimeUtc >= newestTimestamp)
|
||||
{
|
||||
newestTimestamp = info.LastWriteTimeUtc;
|
||||
newest = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return newest ?? string.Empty;
|
||||
}
|
||||
|
||||
private static string CreatePlaceholderResult(string resultsDirectory)
|
||||
{
|
||||
var fileName = $"scan-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}.json";
|
||||
var path = Path.Combine(resultsDirectory, fileName);
|
||||
File.WriteAllText(path, "{\"status\":\"placeholder\"}");
|
||||
return path;
|
||||
}
|
||||
|
||||
private static string WriteRunMetadata(
|
||||
string resultsDirectory,
|
||||
DateTimeOffset startedAt,
|
||||
DateTimeOffset completedAt,
|
||||
string runner,
|
||||
string entry,
|
||||
string targetDirectory,
|
||||
string resultsPath,
|
||||
IReadOnlyList<string> arguments,
|
||||
int exitCode,
|
||||
IReadOnlyList<string> stdout,
|
||||
IReadOnlyList<string> stderr)
|
||||
{
|
||||
var duration = completedAt - startedAt;
|
||||
var payload = new
|
||||
{
|
||||
runner,
|
||||
entry,
|
||||
targetDirectory,
|
||||
resultsPath,
|
||||
arguments,
|
||||
exitCode,
|
||||
startedAt = startedAt,
|
||||
completedAt = completedAt,
|
||||
durationSeconds = Math.Round(duration.TotalSeconds, 3, MidpointRounding.AwayFromZero),
|
||||
stdout,
|
||||
stderr
|
||||
};
|
||||
|
||||
var fileName = $"scan-run-{startedAt:yyyyMMddHHmmssfff}.json";
|
||||
var path = Path.Combine(resultsDirectory, fileName);
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
var json = JsonSerializer.Serialize(payload, options);
|
||||
File.WriteAllText(path, json);
|
||||
return path;
|
||||
}
|
||||
}
|
||||
79
src/Cli/StellaOps.Cli/Services/ScannerInstaller.cs
Normal file
79
src/Cli/StellaOps.Cli/Services/ScannerInstaller.cs
Normal file
@@ -0,0 +1,79 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
internal sealed class ScannerInstaller : IScannerInstaller
|
||||
{
|
||||
private readonly ILogger<ScannerInstaller> _logger;
|
||||
|
||||
public ScannerInstaller(ILogger<ScannerInstaller> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task InstallAsync(string artifactPath, bool verbose, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(artifactPath) || !File.Exists(artifactPath))
|
||||
{
|
||||
throw new FileNotFoundException("Scanner artifact not found for installation.", artifactPath);
|
||||
}
|
||||
|
||||
// Current implementation assumes docker-based scanner bundle.
|
||||
var processInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "docker",
|
||||
ArgumentList = { "load", "-i", artifactPath },
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false
|
||||
};
|
||||
|
||||
using var process = new Process { StartInfo = processInfo, EnableRaisingEvents = true };
|
||||
|
||||
process.OutputDataReceived += (_, args) =>
|
||||
{
|
||||
if (args.Data is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
_logger.LogInformation("[install] {Line}", args.Data);
|
||||
}
|
||||
};
|
||||
|
||||
process.ErrorDataReceived += (_, args) =>
|
||||
{
|
||||
if (args.Data is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogError("[install] {Line}", args.Data);
|
||||
};
|
||||
|
||||
_logger.LogInformation("Installing scanner container from {Path}...", artifactPath);
|
||||
if (!process.Start())
|
||||
{
|
||||
throw new InvalidOperationException("Failed to start container installation process.");
|
||||
}
|
||||
|
||||
process.BeginOutputReadLine();
|
||||
process.BeginErrorReadLine();
|
||||
|
||||
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Container installation failed with exit code {process.ExitCode}.");
|
||||
}
|
||||
|
||||
_logger.LogInformation("Scanner container installed successfully.");
|
||||
}
|
||||
}
|
||||
47
src/Cli/StellaOps.Cli/StellaOps.Cli.csproj
Normal file
47
src/Cli/StellaOps.Cli/StellaOps.Cli.csproj
Normal file
@@ -0,0 +1,47 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.CommandLine" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="NetEscapades.Configuration.Yaml" Version="2.1.0" />
|
||||
<PackageReference Include="Spectre.Console" Version="0.48.0" />
|
||||
<PackageReference Include="System.CommandLine" Version="2.0.0-beta5.25306.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="appsettings.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="appsettings.local.json" Condition="Exists('appsettings.local.json')">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="appsettings.yaml" Condition="Exists('appsettings.yaml')">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="appsettings.local.yaml" Condition="Exists('appsettings.local.yaml')">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
204
src/Cli/StellaOps.Cli/TASKS.md
Normal file
204
src/Cli/StellaOps.Cli/TASKS.md
Normal file
@@ -0,0 +1,204 @@
|
||||
# CLI Task Board — Epic 1: Aggregation-Only Contract
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| CLI-AOC-19-001 | DONE (2025-10-27) | DevEx/CLI Guild | CONCELIER-WEB-AOC-19-001, EXCITITOR-WEB-AOC-19-001 | Implement `stella sources ingest --dry-run` printing would-write payloads with forbidden field scan results and guard status. | Command displays diff-safe JSON, highlights forbidden fields, exits non-zero on guard violation, and has unit tests. |
|
||||
> Docs ready (2025-10-26): Reference behaviour/spec in `docs/cli/cli-reference.md` §2 and AOC reference §5.
|
||||
> 2025-10-27: CLI command scaffolded with backend client call, JSON/table output, gzip/base64 normalisation, and exit-code mapping. Awaiting Concelier dry-run endpoint + integration tests once backend lands.
|
||||
> 2025-10-27: Progress paused before adding CLI unit tests; blocked on extending `StubBackendClient` + fixtures for `ExecuteAocIngestDryRunAsync` coverage.
|
||||
> 2025-10-27: Added stubbed ingest responses + unit tests covering success/violation paths, output writing, and exit-code mapping.
|
||||
| CLI-AOC-19-002 | DONE (2025-10-27) | DevEx/CLI Guild | CLI-AOC-19-001 | Add `stella aoc verify` command supporting `--since`/`--limit`, mapping `ERR_AOC_00x` to exit codes, with JSON/table output. | Command integrates with both services, exit codes documented, regression tests green. |
|
||||
> Docs ready (2025-10-26): CLI guide §3 covers options/exit codes; deployment doc `docs/deploy/containers.md` describes required verifier user.
|
||||
> 2025-10-27: CLI wiring in progress; backend client/command surface being added with table/JSON output.
|
||||
> 2025-10-27: Added JSON/table Spectre output, integration tests for exit-code handling, CLI metrics, and updated quickstart/architecture docs to cover guard workflows.
|
||||
| CLI-AOC-19-003 | DONE (2025-10-27) | Docs/CLI Guild | CLI-AOC-19-001, CLI-AOC-19-002 | Update CLI reference and quickstart docs to cover new commands, exit codes, and offline verification workflows. | Docs updated; examples recorded; release notes mention new commands. |
|
||||
> Docs note (2025-10-26): `docs/cli/cli-reference.md` now describes both commands, exit codes, and offline usage—sync help text once implementation lands.
|
||||
> 2025-10-27: CLI reference now reflects final summary fields/JSON schema, quickstart includes verification/dry-run workflows, and API reference tables list both `sources ingest --dry-run` and `aoc verify`.
|
||||
|
||||
## Policy Engine v2
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| CLI-POLICY-20-001 | TODO | DevEx/CLI Guild | WEB-POLICY-20-001 | Add `stella policy new|edit|submit|approve` commands with local editor integration, version pinning, and approval workflow wiring. | Commands round-trip policy drafts with temp files; approval requires correct scopes; unit tests cover happy/error paths. |
|
||||
| CLI-POLICY-20-002 | DONE (2025-10-27) | DevEx/CLI Guild | CLI-POLICY-20-001, WEB-POLICY-20-001, WEB-POLICY-20-002 | Implement `stella policy simulate` with SBOM/env arguments and diff output (table/JSON), handling exit codes for `ERR_POL_*`. | Simulation outputs deterministic diffs; JSON schema documented; tests validate exit codes + piping of env variables. |
|
||||
> 2025-10-26: Scheduler Models expose canonical run/diff schemas (`src/Scheduler/__Libraries/StellaOps.Scheduler.Models/docs/SCHED-MODELS-20-001-POLICY-RUNS.md`). Schema exporter lives at `scripts/export-policy-schemas.sh`; wire schema validation once DevOps publishes artifacts (see DEVOPS-POLICY-20-004).
|
||||
> 2025-10-27: DevOps pipeline now publishes `policy-schema-exports` artefacts per commit (see `.gitea/workflows/build-test-deploy.yml`); Slack `#policy-engine` alerts trigger on schema diffs. Pull the JSON from the CI artifact instead of committing local copies.
|
||||
> 2025-10-27: CLI command supports table/JSON output, environment parsing, `--fail-on-diff`, and maps `ERR_POL_*` to exit codes; tested in `StellaOps.Cli.Tests` against stubbed backend.
|
||||
| CLI-POLICY-20-003 | DONE (2025-10-30) | DevEx/CLI Guild, Docs Guild | CLI-POLICY-20-002, WEB-POLICY-20-003, DOCS-POLICY-20-006 | Extend `stella findings ls|get` commands for policy-filtered retrieval with pagination, severity filters, and explain output. | Commands stream paginated results; explain view renders rationale entries; docs/help updated; end-to-end tests cover filters. |
|
||||
> 2025-10-27: Work paused after stubbing backend parsing helpers; command wiring/tests still pending. Resume by finishing backend query serialization + CLI output paths.
|
||||
> 2025-10-30: Resuming implementation; wiring backend query DTOs, CLI handlers, and tests for paginated policy-filtered findings.
|
||||
> 2025-10-30: Implemented backend client + CLI command surface for policy findings list/get/explain, added telemetry, interactive/json output, file writes, and unit tests covering filters + explain traces.
|
||||
> 2025-10-30: Pending POLICY-ENGINE-20-006 change-stream orchestration to validate live pagination/cursor behaviour once engine emits incremental updates.
|
||||
|
||||
## Graph Explorer v1
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
|
||||
## Link-Not-Merge v1
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| CLI-LNM-22-001 | TODO | DevEx/CLI Guild | WEB-LNM-21-001 | Implement `stella advisory obs get/linkset show/export` commands with JSON/OSV output, pagination, and conflict display; ensure `ERR_AGG_*` mapping. | Commands fetch observation/linkset data; exports validated against fixtures; unit tests cover error handling. |
|
||||
| CLI-LNM-22-002 | TODO | DevEx/CLI Guild | WEB-LNM-21-002 | Implement `stella vex obs get/linkset show` commands with product filters, status filters, and JSON output for CI usage. | Commands support filters + streaming; integration tests use sample linksets; docs updated. |
|
||||
|
||||
## Policy Engine + Editor v1
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| CLI-POLICY-23-004 | TODO | DevEx/CLI Guild | WEB-POLICY-23-001 | Add `stella policy lint` command validating SPL files with compiler diagnostics; support JSON output. | Command returns lint diagnostics; exit codes documented; tests cover error scenarios. |
|
||||
| CLI-POLICY-23-005 | DOING (2025-10-28) | DevEx/CLI Guild | POLICY-GATEWAY-18-002..003, WEB-POLICY-23-002 | Implement `stella policy activate` with scheduling window, approval enforcement, and summary output. | Activation command integrates with API, handles 2-person rule failures; tests cover success/error. |
|
||||
> 2025-10-28: CLI command implemented with gateway integration (`policy activate`), interactive summary output, retry-aware metrics, and exit codes (0 success, 75 pending second approval). Tests cover success/pending/error paths.
|
||||
| CLI-POLICY-23-006 | TODO | DevEx/CLI Guild | WEB-POLICY-23-004 | Provide `stella policy history` and `stella policy explain` commands to pull run history and explanation trees. | Commands output JSON/table; integration tests with fixtures; docs updated. |
|
||||
|
||||
## Graph & Vuln Explorer v1
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
|
||||
## Exceptions v1
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| CLI-EXC-25-001 | TODO | DevEx/CLI Guild | WEB-EXC-25-001 | Implement `stella exceptions list|draft|propose|approve|revoke` commands with JSON/table output, validation, and workflow exit codes. | Commands exercise end-to-end workflow; unit/integration tests cover errors; docs updated. |
|
||||
| CLI-EXC-25-002 | TODO | DevEx/CLI Guild | WEB-EXC-25-002 | Extend `stella policy simulate` with `--with-exception`/`--without-exception` flags to preview exception impact. | Simulation handles overrides; regression tests cover presence/absence; help text updated. |
|
||||
|
||||
## Reachability v1
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| CLI-SIG-26-001 | TODO | DevEx/CLI Guild | WEB-SIG-26-001 | Implement `stella reachability upload-callgraph` and `stella reachability list/explain` commands with streaming upload, pagination, and exit codes. | Commands operate end-to-end; integration tests with fixtures; docs updated. |
|
||||
| CLI-SIG-26-002 | TODO | DevEx/CLI Guild | WEB-SIG-26-003 | Extend `stella policy simulate` with reachability override flags (`--reachability-state`, `--reachability-score`). | Simulation command accepts overrides; regression tests cover adjustments; help text updated. |
|
||||
|
||||
## Policy Studio (Sprint 27)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| CLI-POLICY-27-001 | TODO | DevEx/CLI Guild | REGISTRY-API-27-001, WEB-POLICY-27-001 | Implement policy workspace commands (`stella policy init`, `edit`, `lint`, `compile`, `test`) with template selection, local cache, JSON output, and deterministic temp directories. | Commands operate offline with cached templates; diagnostics mirror API responses; unit tests cover happy/error paths; help text updated. |
|
||||
> Docs dependency: `DOCS-POLICY-27-007` blocked until CLI commands + help output land.
|
||||
| CLI-POLICY-27-002 | TODO | DevEx/CLI Guild | REGISTRY-API-27-006, WEB-POLICY-27-002 | Add submission/review workflow commands (`stella policy version bump`, `submit`, `review comment`, `approve`, `reject`) supporting reviewer assignment, changelog capture, and exit codes. | Workflow commands enforce required approvers; comments upload correctly; integration tests cover approval failure; docs updated. |
|
||||
> Docs dependency: `DOCS-POLICY-27-007` and `DOCS-POLICY-27-006` require review/promotion CLI flows.
|
||||
| CLI-POLICY-27-003 | TODO | DevEx/CLI Guild | REGISTRY-API-27-005, SCHED-CONSOLE-27-001 | Implement `stella policy simulate` enhancements (quick vs batch, SBOM selectors, heatmap summary, manifest download) with `--json` and Markdown report output for CI. | CLI can trigger batch sim, poll progress, download artifacts; outputs deterministic schemas; CI sample workflow documented; tests cover cancellation/timeouts. |
|
||||
> Docs dependency: `DOCS-POLICY-27-004` needs simulate CLI examples.
|
||||
| CLI-POLICY-27-004 | TODO | DevEx/CLI Guild | REGISTRY-API-27-007, REGISTRY-API-27-008, AUTH-POLICY-27-002 | Add lifecycle commands for publish/promote/rollback/sign (`stella policy publish --sign`, `promote --env`, `rollback`) with attestation verification and canary arguments. | Commands enforce signing requirement, support dry-run, produce audit logs; integration tests cover promotion + rollback; documentation updated. |
|
||||
> Docs dependency: `DOCS-POLICY-27-006` requires publish/promote/rollback CLI examples.
|
||||
| CLI-POLICY-27-005 | TODO | DevEx/CLI Guild, Docs Guild | DOCS-CONSOLE-27-007, DOCS-POLICY-27-007 | Update CLI reference and samples for Policy Studio including JSON schemas, exit codes, and CI snippets. | CLI docs merged with screenshots/transcripts; parity matrix updated; acceptance tests ensure `--help` examples compile. |
|
||||
| CLI-POLICY-27-006 | TODO | DevEx/CLI Guild | AUTH-POLICY-27-001, CLI-POLICY-27-001 | Update CLI policy profiles/help text to request the new Policy Studio scope family, surface ProblemDetails guidance for `invalid_scope`, and adjust regression tests for scope failures. | Default CLI profiles reference new scopes, `stella policy` commands emit updated guidance, automated tests cover missing-scope responses, and docs regenerated via `scripts/update-cli-docs.sh`. |
|
||||
> Heads-up: Gateway/Authority now reject `policy:write`/`policy:submit` tokens; automation will fail until profiles switch to the new scope bundle.
|
||||
|
||||
## Vulnerability Explorer (Sprint 29)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| CLI-VULN-29-001 | TODO | DevEx/CLI Guild | VULN-API-29-002, AUTH-VULN-29-001 | Implement `stella vuln list` with grouping, paging, filters, `--json/--csv`, and policy selection. | Command returns deterministic output; paging works; regression tests cover filters/grouping. |
|
||||
| CLI-VULN-29-002 | TODO | DevEx/CLI Guild | VULN-API-29-003 | Implement `stella vuln show` displaying evidence, policy rationale, paths, ledger summary; support `--json` for automation. | Output matches schema; evidence rendered with provenance; tests cover missing data. |
|
||||
| CLI-VULN-29-003 | TODO | DevEx/CLI Guild | VULN-API-29-004, LEDGER-29-005 | Add workflow commands (`assign`, `comment`, `accept-risk`, `verify-fix`, `target-fix`, `reopen`) with filter selection (`--filter`) and idempotent retries. | Commands create ledger events; exit codes documented; integration tests cover role enforcement. |
|
||||
| CLI-VULN-29-004 | TODO | DevEx/CLI Guild | VULN-API-29-005 | Implement `stella vuln simulate` producing delta summaries and optional Markdown report for CI. | CLI simulation returns diff tables + JSON; tests verify diff correctness; docs updated. |
|
||||
| CLI-VULN-29-005 | TODO | DevEx/CLI Guild | VULN-API-29-008 | Add `stella vuln export` and `stella vuln bundle verify` commands to trigger/download evidence bundles and verify signatures. | Export command streams to file; verify command checks signatures; tests cover success/failure. |
|
||||
| CLI-VULN-29-006 | TODO | DevEx/CLI Guild, Docs Guild | DOCS-VULN-29-004, DOCS-VULN-29-005 | Update CLI docs/examples for Vulnerability Explorer with compliance checklist and CI snippets. | Docs merged; automated examples validated; compliance checklist appended. |
|
||||
|
||||
## VEX Lens (Sprint 30)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| CLI-VEX-30-001 | TODO | DevEx/CLI Guild | VEXLENS-30-007 | Implement `stella vex consensus list` with filters, paging, policy selection, `--json/--csv`. | Command returns deterministic output; regression tests cover filters/paging; docs updated. |
|
||||
| CLI-VEX-30-002 | TODO | DevEx/CLI Guild | VEXLENS-30-007 | Implement `stella vex consensus show` displaying quorum, evidence, rationale, signature status. | Output matches schema; tests cover conflicting evidence; docs updated. |
|
||||
| CLI-VEX-30-003 | TODO | DevEx/CLI Guild | VEXLENS-30-007 | Implement `stella vex simulate` for trust/threshold overrides with JSON diff output. | Simulation command returns diff summary; tests cover policy scenarios; docs updated. |
|
||||
| CLI-VEX-30-004 | TODO | DevEx/CLI Guild | VEXLENS-30-007 | Implement `stella vex export` for consensus NDJSON bundles with signature verification helper. | Export & verify commands operational; tests cover file output; docs updated. |
|
||||
|
||||
## Advisory AI (Sprint 31)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| CLI-AIAI-31-001 | TODO | DevEx/CLI Guild | AIAI-31-006 | Implement `stella advise summarize` command with JSON/Markdown outputs and citation display. | Command returns summary + JSON; citations preserved; tests cover filters. |
|
||||
| CLI-AIAI-31-002 | TODO | DevEx/CLI Guild | AIAI-31-006 | Implement `stella advise explain` showing conflict narrative and structured rationale. | Output matches schemas; tests cover disputed cases. |
|
||||
| CLI-AIAI-31-003 | TODO | DevEx/CLI Guild | AIAI-31-006 | Implement `stella advise remediate` generating remediation plans with `--strategy` filters and file output. | Plans saved to file; exit codes documented; tests cover version mapping. |
|
||||
| CLI-AIAI-31-004 | TODO | DevEx/CLI Guild | AIAI-31-006 | Implement `stella advise batch` for summaries/conflicts/remediation with progress + multi-status responses. | Batch command handles 207 responses; tests cover partial failures. |
|
||||
|
||||
## Export Center (Epic 10)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| CLI-EXPORT-35-001 | BLOCKED (2025-10-29) | DevEx/CLI Guild | WEB-EXPORT-35-001, AUTH-EXPORT-35-001 | Implement `stella export profiles|runs` list/show, `run create`, `run status`, and resumable download commands with manifest/provenance retrieval. | Commands respect viewer/operator scopes; downloads resume via range requests; integration tests cover filters and offline mode. |
|
||||
> Blocked: Gateway routing (`WEB-EXPORT-35-001`) and Authority scopes pending; CLI cannot hit Export APIs until those services land.
|
||||
| CLI-EXPORT-36-001 | TODO | DevEx/CLI Guild | CLI-EXPORT-35-001, WEB-EXPORT-36-001 | Add distribution commands (`stella export distribute`, `run download --resume` enhancements) and improved status polling with progress bars. | Distribution commands push OCI/object storage; status polling handles SSE fallback; tests cover failure cases. |
|
||||
| CLI-EXPORT-37-001 | TODO | DevEx/CLI Guild | CLI-EXPORT-36-001, WEB-EXPORT-37-001 | Provide scheduling (`stella export schedule`), retention, and `export verify` commands performing signature/hash validation. | Scheduling/retention commands enforce admin scopes; verify command checks signatures/hashes; examples documented; tests cover success/failure. |
|
||||
## Orchestrator Dashboard (Epic 9)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| CLI-ORCH-32-001 | TODO | DevEx/CLI Guild | WEB-ORCH-32-001, AUTH-ORCH-32-001 | Implement `stella orch sources|runs|jobs` list/show commands with filters, pagination, table/JSON output, and deterministic exit codes. | Commands respect viewer scope; JSON schema documented; integration tests cover filters/paging/offline mode. |
|
||||
| CLI-ORCH-33-001 | TODO | DevEx/CLI Guild | CLI-ORCH-32-001, WEB-ORCH-33-001, AUTH-ORCH-33-001 | Add action verbs (`sources test|pause|resume|sync-now`, `jobs retry|cancel|tail`) with streaming output, reason prompts, and retry/backoff handling. | Actions succeed with operator scope; streaming tail resilient to reconnect; tests cover permission failures and retries. |
|
||||
| CLI-ORCH-34-001 | TODO | DevEx/CLI Guild | CLI-ORCH-33-001, WEB-ORCH-34-001, AUTH-ORCH-34-001 | Provide backfill wizard (`--from/--to --dry-run`), quota management (`quotas get|set`), and safety guardrails for orchestrator GA. | Backfill preview output matches API; quota updates require reason; CLI docs/help updated; regression tests cover dry-run + failure paths. |
|
||||
|
||||
## Notifications Studio (Epic 11)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| CLI-NOTIFY-38-001 | BLOCKED (2025-10-29) | DevEx/CLI Guild | WEB-NOTIFY-38-001, AUTH-NOTIFY-38-001 | Implement `stella notify rules|templates|incidents` commands (list/create/update/test/ack) with file inputs, JSON output, and RBAC-aware flow. | Commands invoke notifier APIs successfully; rule test uses local events file; integration tests cover create/test/ack; help docs updated. |
|
||||
> Blocked: Gateway routing (`WEB-NOTIFY-38-001`) and Authority scopes (`AUTH-NOTIFY-38-001`) pending; CLI cannot exercise APIs until endpoints and token scopes are published.
|
||||
| CLI-NOTIFY-39-001 | BLOCKED (2025-10-29) | DevEx/CLI Guild | CLI-NOTIFY-38-001, WEB-NOTIFY-39-001 | Add simulation (`stella notify simulate`) and digest commands with diff output and schedule triggering, including dry-run mode. | Simulation command returns deterministic diff; digest command triggers run and polls status; tests cover filters and failures. |
|
||||
> Blocked: Foundation commands (`CLI-NOTIFY-38-001`) and gateway digest/simulation APIs (`WEB-NOTIFY-39-001`) not available yet.
|
||||
| CLI-NOTIFY-40-001 | TODO | DevEx/CLI Guild | CLI-NOTIFY-39-001, WEB-NOTIFY-40-001 | Provide ack token redemption workflow, escalation management, localization previews, and channel health checks. | Ack redemption validates signed tokens; escalation commands manage schedules; localization preview shows variants; integration tests cover negative cases. |
|
||||
|
||||
## CLI Parity & Task Packs (Epic 12)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| CLI-CORE-41-001 | TODO | DevEx/CLI Guild | AUTH-PACKS-41-001 | Implement CLI core features: config precedence, profiles/contexts, auth flows, output renderer (json/yaml/table), error mapping, global flags, telemetry opt-in. | CLI loads config deterministically; auth works (device/PAT); outputs render correctly; tests cover precedence and exit codes. |
|
||||
| CLI-PARITY-41-001 | TODO | DevEx/CLI Guild | CLI-CORE-41-001 | Deliver parity command groups (`policy`, `sbom`, `vuln`, `vex`, `advisory`, `export`, `orchestrator`) with `--explain`, deterministic outputs, and parity matrix entries. | Commands match Console behavior; parity matrix green for covered actions; integration tests cover major flows. |
|
||||
| CLI-PARITY-41-002 | TODO | DevEx/CLI Guild | CLI-PARITY-41-001, WEB-NOTIFY-38-001 | Implement `notify`, `aoc`, `auth` command groups, idempotency keys, shell completions, config docs, and parity matrix export tooling. | Commands functional; completions generated; docs updated; parity matrix auto-exported; CI checks gating. |
|
||||
| CLI-PACKS-42-001 | TODO | DevEx/CLI Guild | CLI-CORE-41-001, PACKS-REG-41-001, TASKRUN-41-001 | Implement Task Pack commands (`pack plan/run/push/pull/verify`) with schema validation, expression sandbox, plan/simulate engine, remote execution. | Pack commands operational; plan/sim produce accurate graph; remote run streams logs; schema validation enforced. |
|
||||
| CLI-PACKS-43-001 | TODO | DevEx/CLI Guild | CLI-PACKS-42-001, TASKRUN-42-001 | Deliver advanced pack features (approvals pause/resume, secret injection, localization, man pages, offline cache). | Approvals handled; secrets redacted; localization supported; man pages built; offline cache documented; integration tests cover scenarios. |
|
||||
|
||||
## Authority-Backed Scopes & Tenancy (Epic 14)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| CLI-TEN-47-001 | TODO | DevEx/CLI Guild | AUTH-TEN-47-001 | Implement `stella login`, `whoami`, `tenants list`, persistent profiles, secure token storage, and `--tenant` override with validation. | Commands functional across platforms; tokens stored securely; tenancy header set on requests; integration tests cover login/tenant switch. |
|
||||
| CLI-TEN-49-001 | TODO | DevEx/CLI Guild | CLI-TEN-47-001, AUTH-TEN-49-001 | Add service account token minting, delegation (`stella token delegate`), impersonation banner, and audit-friendly logging. | Service tokens minted with scopes/TTL; delegation recorded; CLI displays impersonation banner; docs updated. |
|
||||
|
||||
## Observability & Forensics (Epic 15)
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| CLI-OBS-50-001 | TODO | DevEx/CLI Guild | TELEMETRY-OBS-50-002, WEB-OBS-50-001 | Ensure CLI HTTP client propagates `traceparent` headers for all commands, prints correlation IDs on failure, and records trace IDs in verbose logs (scrubbed). | Trace headers observed in integration tests; verbose logs include trace IDs; redaction guard verified. |
|
||||
| CLI-OBS-51-001 | TODO | DevEx/CLI Guild | CLI-OBS-50-001, WEB-OBS-51-001 | Implement `stella obs top` command streaming service health metrics, SLO status, and burn-rate alerts with TUI view and JSON output. | Command streams metrics; JSON output documented; integration tests cover streaming and exit codes. |
|
||||
| CLI-OBS-52-001 | TODO | DevEx/CLI Guild | CLI-OBS-51-001, TIMELINE-OBS-52-003 | Add `stella obs trace <trace_id>` and `stella obs logs --from/--to` commands that correlate timeline events, logs, and evidence links with pagination + guardrails. | Commands fetch timeline/log data; paging tokens handled; fixtures stored under `samples/obs/`; tests cover errors. |
|
||||
| CLI-FORENSICS-53-001 | TODO | DevEx/CLI Guild, Evidence Locker Guild | CLI-OBS-52-001, EVID-OBS-53-003 | Implement `stella forensic snapshot create --case` and `snapshot list/show` commands invoking evidence locker APIs, surfacing manifest digests, and storing local cache metadata. | Snapshot commands functional; manifests displayed; cache metadata deterministic; docs/help updated. |
|
||||
| CLI-FORENSICS-54-001 | TODO | DevEx/CLI Guild, Provenance Guild | CLI-FORENSICS-53-001, PROV-OBS-54-001 | Provide `stella forensic verify <bundle>` command validating checksums, DSSE signatures, and timeline chain-of-custody. Support JSON/pretty output and exit codes for CI. | Verification works with sample bundles; tests cover success/failure; docs updated. |
|
||||
| CLI-FORENSICS-54-002 | TODO | DevEx/CLI Guild, Provenance Guild | CLI-FORENSICS-54-001 | Implement `stella forensic attest show <artifact>` listing attestation details (signer, timestamp, subjects) and verifying signatures. | Command prints attestation summary; verification errors flagged; tests cover offline mode. |
|
||||
| CLI-OBS-55-001 | TODO | DevEx/CLI Guild, DevOps Guild | CLI-OBS-52-001, WEB-OBS-55-001, DEVOPS-OBS-55-001 | Add `stella obs incident-mode enable|disable|status` commands with confirmation guards, cooldown timers, and audit logging. | Commands manage incident mode; audit logs verified; tests cover permissions and cooldown. |
|
||||
|
||||
## Air-Gapped Mode (Epic 16)
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| CLI-AIRGAP-56-001 | TODO | DevEx/CLI Guild | MIRROR-CRT-56-001, AIRGAP-IMP-56-001 | Implement `stella mirror create|verify` and `stella airgap verify` commands with DSSE/TUF results, dry-run mode, and deterministic manifests. | Commands produce deterministic bundles; verify outputs structured DSSE/TUF results; integration tests cover tampering scenarios. |
|
||||
| CLI-AIRGAP-56-002 | TODO | DevEx/CLI Guild | CLI-OBS-50-001, AIRGAP-IMP-56-001 | Ensure telemetry propagation under sealed mode (no remote exporters) while preserving correlation IDs; add label `AirGapped-Phase-1`. | CLI traces flow via local exporters in sealed mode; correlation IDs still printed; tests cover sealed toggle + fallback. |
|
||||
| CLI-AIRGAP-57-001 | TODO | DevEx/CLI Guild | CLI-AIRGAP-56-001, AIRGAP-IMP-58-001 | Add `stella airgap import` with diff preview, bundle scope selection (`--tenant`, `--global`), audit logging, and progress reporting. | Import updates catalog; diff preview rendered; audit entries include bundle ID + scope; tests cover idempotent re-import. |
|
||||
| CLI-AIRGAP-57-002 | TODO | DevEx/CLI Guild | CLI-AIRGAP-56-001, AIRGAP-CTL-56-002 | Provide `stella airgap seal|status` commands surfacing sealing state, drift, staleness metrics, and remediation guidance with safe confirmation prompts. | Status command prints drift/staleness; seal requires confirmation + scope; integration tests cover RBAC denials. |
|
||||
| CLI-AIRGAP-58-001 | TODO | DevEx/CLI Guild, Evidence Locker Guild | CLI-AIRGAP-57-001, CLI-FORENSICS-54-001 | Implement `stella airgap export evidence` helper for portable evidence packages, including checksum manifest and verification. | Command generates portable bundle; verification step validates signatures; docs/help updated with examples. |
|
||||
|
||||
## SDKs & OpenAPI (Epic 17)
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| CLI-SDK-62-001 | TODO | DevEx/CLI Guild, SDK Generator Guild | SDKGEN-63-001 | Replace bespoke HTTP clients with official SDK (TS/Go) for all CLI commands; ensure modular transport for air-gapped mode. | CLI builds using SDK; regression suite passes; telemetry shows SDK version. |
|
||||
| CLI-SDK-62-002 | TODO | DevEx/CLI Guild | CLI-SDK-62-001, APIGOV-61-001 | Update CLI error handling to surface standardized API error envelope with `error.code` and `trace_id`. | CLI displays envelope data; integration tests cover new output. |
|
||||
| CLI-SDK-63-001 | TODO | DevEx/CLI Guild, API Governance Guild | OAS-61-002 | Expose `stella api spec download` command retrieving aggregate OAS and verifying checksum/ETag. | Command downloads + verifies spec; docs updated; tests cover failure cases. |
|
||||
| CLI-SDK-64-001 | TODO | DevEx/CLI Guild, SDK Release Guild | SDKREL-63-001 | Add CLI subcommand `stella sdk update` to fetch latest SDK manifests/changelogs; integrate with Notifications for deprecations. | Command lists versions/changelogs; notifications triggered on updates. |
|
||||
|
||||
## Risk Profiles (Epic 18)
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| CLI-RISK-66-001 | TODO | DevEx/CLI Guild, Policy Guild | POLICY-RISK-67-002 | Implement `stella risk profile list|get|create|publish` commands with schema validation and scope selectors. | Commands operate against API; validation errors surfaced; tests cover CRUD. |
|
||||
| CLI-RISK-66-002 | TODO | DevEx/CLI Guild, Risk Engine Guild | RISK-ENGINE-69-001 | Ship `stella risk simulate` supporting SBOM/asset inputs, diff mode, and export to JSON/CSV. | Simulation runs via CLI; output tested; docs updated. |
|
||||
| CLI-RISK-67-001 | TODO | DevEx/CLI Guild, Findings Ledger Guild | LEDGER-RISK-67-001 | Provide `stella risk results` with filtering, severity thresholds, explainability fetch. | Results command returns paginated data; explaination fetch command outputs artifact; tests pass. |
|
||||
| CLI-RISK-68-001 | TODO | DevEx/CLI Guild, Export Guild | RISK-BUNDLE-70-001 | Add `stella risk bundle verify` and integrate with offline risk bundles. | Verification command validates signatures; integration tests cover tampered bundle. |
|
||||
|
||||
## Attestor Console (Epic 19)
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| CLI-ATTEST-73-001 | TODO | CLI Attestor Guild | ATTESTOR-73-001, SDKGEN-63-001 | Implement `stella attest sign` (payload selection, subject digest, key reference, output format) using official SDK transport. | Command signs envelopes; tests cover file/KMS keys; docs updated. |
|
||||
| CLI-ATTEST-73-002 | TODO | CLI Attestor Guild | ATTESTOR-73-002 | Implement `stella attest verify` with policy selection, explainability output, and JSON/table formatting. | Verification command returns structured report; exit codes match pass/fail; integration tests pass. |
|
||||
| CLI-ATTEST-74-001 | TODO | CLI Attestor Guild | ATTESTOR-73-003 | Implement `stella attest list` with filters (subject, type, issuer, scope) and pagination. | Command outputs table/JSON; tests cover filters. |
|
||||
| CLI-ATTEST-74-002 | TODO | CLI Attestor Guild | ATTESTOR-73-003 | Implement `stella attest fetch` to download envelopes and payloads to disk. | Fetch command saves files; checks digests; tests cover air-gap use. |
|
||||
| CLI-ATTEST-75-001 | TODO | CLI Attestor Guild, KMS Guild | KMS-72-001 | Implement `stella attest key create|import|rotate|revoke` commands. | Key commands work with file/KMS drivers; tests cover rotation/revocation. |
|
||||
| CLI-ATTEST-75-002 | TODO | CLI Attestor Guild, Export Guild | ATTESTOR-75-001 | Add support for building/verifying attestation bundles in CLI. | Bundle commands functional; verification catches tampering; docs updated. |
|
||||
8
src/Cli/StellaOps.Cli/Telemetry/CliActivitySource.cs
Normal file
8
src/Cli/StellaOps.Cli/Telemetry/CliActivitySource.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace StellaOps.Cli.Telemetry;
|
||||
|
||||
internal static class CliActivitySource
|
||||
{
|
||||
public static readonly ActivitySource Instance = new("StellaOps.Cli");
|
||||
}
|
||||
126
src/Cli/StellaOps.Cli/Telemetry/CliMetrics.cs
Normal file
126
src/Cli/StellaOps.Cli/Telemetry/CliMetrics.cs
Normal file
@@ -0,0 +1,126 @@
|
||||
using System;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.Cli.Telemetry;
|
||||
|
||||
internal static class CliMetrics
|
||||
{
|
||||
private static readonly Meter Meter = new("StellaOps.Cli", "1.0.0");
|
||||
|
||||
private static readonly Counter<long> ScannerDownloadCounter = Meter.CreateCounter<long>("stellaops.cli.scanner.download.count");
|
||||
private static readonly Counter<long> ScannerInstallCounter = Meter.CreateCounter<long>("stellaops.cli.scanner.install.count");
|
||||
private static readonly Counter<long> ScanRunCounter = Meter.CreateCounter<long>("stellaops.cli.scan.run.count");
|
||||
private static readonly Counter<long> OfflineKitDownloadCounter = Meter.CreateCounter<long>("stellaops.cli.offline.kit.download.count");
|
||||
private static readonly Counter<long> OfflineKitImportCounter = Meter.CreateCounter<long>("stellaops.cli.offline.kit.import.count");
|
||||
private static readonly Counter<long> PolicySimulationCounter = Meter.CreateCounter<long>("stellaops.cli.policy.simulate.count");
|
||||
private static readonly Counter<long> PolicyActivationCounter = Meter.CreateCounter<long>("stellaops.cli.policy.activate.count");
|
||||
private static readonly Counter<long> SourcesDryRunCounter = Meter.CreateCounter<long>("stellaops.cli.sources.dryrun.count");
|
||||
private static readonly Counter<long> AocVerifyCounter = Meter.CreateCounter<long>("stellaops.cli.aoc.verify.count");
|
||||
private static readonly Counter<long> PolicyFindingsListCounter = Meter.CreateCounter<long>("stellaops.cli.policy.findings.list.count");
|
||||
private static readonly Counter<long> PolicyFindingsGetCounter = Meter.CreateCounter<long>("stellaops.cli.policy.findings.get.count");
|
||||
private static readonly Counter<long> PolicyFindingsExplainCounter = Meter.CreateCounter<long>("stellaops.cli.policy.findings.explain.count");
|
||||
private static readonly Histogram<double> CommandDurationHistogram = Meter.CreateHistogram<double>("stellaops.cli.command.duration.ms");
|
||||
|
||||
public static void RecordScannerDownload(string channel, bool fromCache)
|
||||
=> ScannerDownloadCounter.Add(1, new KeyValuePair<string, object?>[]
|
||||
{
|
||||
new("channel", channel),
|
||||
new("cache", fromCache ? "hit" : "miss")
|
||||
});
|
||||
|
||||
public static void RecordScannerInstall(string channel)
|
||||
=> ScannerInstallCounter.Add(1, new KeyValuePair<string, object?>[] { new("channel", channel) });
|
||||
|
||||
public static void RecordScanRun(string runner, int exitCode)
|
||||
=> ScanRunCounter.Add(1, new KeyValuePair<string, object?>[]
|
||||
{
|
||||
new("runner", runner),
|
||||
new("exit_code", exitCode)
|
||||
});
|
||||
|
||||
public static void RecordOfflineKitDownload(string kind, bool fromCache)
|
||||
=> OfflineKitDownloadCounter.Add(1, new KeyValuePair<string, object?>[]
|
||||
{
|
||||
new("kind", string.IsNullOrWhiteSpace(kind) ? "unknown" : kind),
|
||||
new("cache", fromCache ? "hit" : "miss")
|
||||
});
|
||||
|
||||
public static void RecordOfflineKitImport(string? status)
|
||||
=> OfflineKitImportCounter.Add(1, new KeyValuePair<string, object?>[]
|
||||
{
|
||||
new("status", string.IsNullOrWhiteSpace(status) ? "queued" : status)
|
||||
});
|
||||
|
||||
public static void RecordPolicySimulation(string outcome)
|
||||
=> PolicySimulationCounter.Add(1, new KeyValuePair<string, object?>[]
|
||||
{
|
||||
new("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)
|
||||
});
|
||||
|
||||
public static void RecordPolicyActivation(string outcome)
|
||||
=> PolicyActivationCounter.Add(1, new KeyValuePair<string, object?>[]
|
||||
{
|
||||
new("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)
|
||||
});
|
||||
|
||||
public static void RecordSourcesDryRun(string status)
|
||||
=> SourcesDryRunCounter.Add(1, new KeyValuePair<string, object?>[]
|
||||
{
|
||||
new("status", string.IsNullOrWhiteSpace(status) ? "unknown" : status)
|
||||
});
|
||||
|
||||
public static void RecordAocVerify(string outcome)
|
||||
=> AocVerifyCounter.Add(1, new KeyValuePair<string, object?>[]
|
||||
{
|
||||
new("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)
|
||||
});
|
||||
|
||||
public static void RecordPolicyFindingsList(string outcome)
|
||||
=> PolicyFindingsListCounter.Add(1, new KeyValuePair<string, object?>[]
|
||||
{
|
||||
new("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)
|
||||
});
|
||||
|
||||
public static void RecordPolicyFindingsGet(string outcome)
|
||||
=> PolicyFindingsGetCounter.Add(1, new KeyValuePair<string, object?>[]
|
||||
{
|
||||
new("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)
|
||||
});
|
||||
|
||||
public static void RecordPolicyFindingsExplain(string outcome)
|
||||
=> PolicyFindingsExplainCounter.Add(1, new KeyValuePair<string, object?>[]
|
||||
{
|
||||
new("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)
|
||||
});
|
||||
|
||||
public static IDisposable MeasureCommandDuration(string command)
|
||||
{
|
||||
var start = DateTime.UtcNow;
|
||||
return new DurationScope(command, start);
|
||||
}
|
||||
|
||||
private sealed class DurationScope : IDisposable
|
||||
{
|
||||
private readonly string _command;
|
||||
private readonly DateTime _start;
|
||||
private bool _disposed;
|
||||
|
||||
public DurationScope(string command, DateTime start)
|
||||
{
|
||||
_command = command;
|
||||
_start = start;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
var elapsed = (DateTime.UtcNow - _start).TotalMilliseconds;
|
||||
CommandDurationHistogram.Record(elapsed, new KeyValuePair<string, object?>[] { new("command", _command) });
|
||||
}
|
||||
}
|
||||
}
|
||||
8
src/Cli/StellaOps.Cli/Telemetry/VerbosityState.cs
Normal file
8
src/Cli/StellaOps.Cli/Telemetry/VerbosityState.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Cli.Telemetry;
|
||||
|
||||
internal sealed class VerbosityState
|
||||
{
|
||||
public LogLevel MinimumLevel { get; set; } = LogLevel.Information;
|
||||
}
|
||||
12
src/Cli/StellaOps.Cli/appsettings.json
Normal file
12
src/Cli/StellaOps.Cli/appsettings.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"StellaOps": {
|
||||
"ApiKey": "",
|
||||
"BackendUrl": "",
|
||||
"ConcelierUrl": "",
|
||||
"ScannerCacheDirectory": "scanners",
|
||||
"ResultsDirectory": "results",
|
||||
"DefaultRunner": "dotnet",
|
||||
"ScannerSignaturePublicKeyPath": "",
|
||||
"ScannerDownloadAttempts": 3
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
using System;
|
||||
using System.CommandLine;
|
||||
using System.Threading;
|
||||
using StellaOps.Cli.Commands;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Plugins;
|
||||
|
||||
namespace StellaOps.Cli.Plugins.NonCore;
|
||||
|
||||
public sealed class NonCoreCliCommandModule : ICliCommandModule
|
||||
{
|
||||
public string Name => "stellaops.cli.plugins.noncore";
|
||||
|
||||
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(BuildExcititorCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildRuntimeCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildOfflineCommand(services, verboseOption, cancellationToken));
|
||||
}
|
||||
|
||||
private static Command BuildExcititorCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var excititor = new Command("excititor", "Manage Excititor ingest, exports, and reconciliation workflows.");
|
||||
|
||||
var init = new Command("init", "Initialize Excititor ingest state.");
|
||||
var initProviders = new Option<string[]>("--provider", new[] { "-p" })
|
||||
{
|
||||
Description = "Optional provider identifier(s) to initialize.",
|
||||
Arity = ArgumentArity.ZeroOrMore
|
||||
};
|
||||
var resumeOption = new Option<bool>("--resume")
|
||||
{
|
||||
Description = "Resume ingest from the last persisted checkpoint instead of starting fresh."
|
||||
};
|
||||
init.Add(initProviders);
|
||||
init.Add(resumeOption);
|
||||
init.SetAction((parseResult, _) =>
|
||||
{
|
||||
var providers = parseResult.GetValue(initProviders) ?? Array.Empty<string>();
|
||||
var resume = parseResult.GetValue(resumeOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return CommandHandlers.HandleExcititorInitAsync(services, providers, resume, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
var pull = new Command("pull", "Trigger Excititor ingest for configured providers.");
|
||||
var pullProviders = new Option<string[]>("--provider", new[] { "-p" })
|
||||
{
|
||||
Description = "Optional provider identifier(s) to ingest.",
|
||||
Arity = ArgumentArity.ZeroOrMore
|
||||
};
|
||||
var sinceOption = new Option<DateTimeOffset?>("--since")
|
||||
{
|
||||
Description = "Optional ISO-8601 timestamp to begin the ingest window."
|
||||
};
|
||||
var windowOption = new Option<TimeSpan?>("--window")
|
||||
{
|
||||
Description = "Optional window duration (e.g. 24:00:00)."
|
||||
};
|
||||
var forceOption = new Option<bool>("--force")
|
||||
{
|
||||
Description = "Force ingestion even if the backend reports no pending work."
|
||||
};
|
||||
pull.Add(pullProviders);
|
||||
pull.Add(sinceOption);
|
||||
pull.Add(windowOption);
|
||||
pull.Add(forceOption);
|
||||
pull.SetAction((parseResult, _) =>
|
||||
{
|
||||
var providers = parseResult.GetValue(pullProviders) ?? Array.Empty<string>();
|
||||
var since = parseResult.GetValue(sinceOption);
|
||||
var window = parseResult.GetValue(windowOption);
|
||||
var force = parseResult.GetValue(forceOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return CommandHandlers.HandleExcititorPullAsync(services, providers, since, window, force, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
var resume = new Command("resume", "Resume Excititor ingest using a checkpoint token.");
|
||||
var resumeProviders = new Option<string[]>("--provider", new[] { "-p" })
|
||||
{
|
||||
Description = "Optional provider identifier(s) to resume.",
|
||||
Arity = ArgumentArity.ZeroOrMore
|
||||
};
|
||||
var checkpointOption = new Option<string?>("--checkpoint")
|
||||
{
|
||||
Description = "Optional checkpoint identifier to resume from."
|
||||
};
|
||||
resume.Add(resumeProviders);
|
||||
resume.Add(checkpointOption);
|
||||
resume.SetAction((parseResult, _) =>
|
||||
{
|
||||
var providers = parseResult.GetValue(resumeProviders) ?? Array.Empty<string>();
|
||||
var checkpoint = parseResult.GetValue(checkpointOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return CommandHandlers.HandleExcititorResumeAsync(services, providers, checkpoint, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
var list = new Command("list-providers", "List Excititor providers and their ingest status.");
|
||||
var includeDisabledOption = new Option<bool>("--include-disabled")
|
||||
{
|
||||
Description = "Include disabled providers in the listing."
|
||||
};
|
||||
list.Add(includeDisabledOption);
|
||||
list.SetAction((parseResult, _) =>
|
||||
{
|
||||
var includeDisabled = parseResult.GetValue(includeDisabledOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return CommandHandlers.HandleExcititorListProvidersAsync(services, includeDisabled, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
var export = new Command("export", "Trigger Excititor export generation.");
|
||||
var formatOption = new Option<string>("--format")
|
||||
{
|
||||
Description = "Export format (e.g. openvex, json)."
|
||||
};
|
||||
var exportDeltaOption = new Option<bool>("--delta")
|
||||
{
|
||||
Description = "Request a delta export when supported."
|
||||
};
|
||||
var exportScopeOption = new Option<string?>("--scope")
|
||||
{
|
||||
Description = "Optional policy scope or tenant identifier."
|
||||
};
|
||||
var exportSinceOption = new Option<DateTimeOffset?>("--since")
|
||||
{
|
||||
Description = "Optional ISO-8601 timestamp to restrict export contents."
|
||||
};
|
||||
var exportProviderOption = new Option<string?>("--provider")
|
||||
{
|
||||
Description = "Optional provider identifier when requesting targeted exports."
|
||||
};
|
||||
var exportOutputOption = new Option<string?>("--output")
|
||||
{
|
||||
Description = "Optional path to download the export artifact."
|
||||
};
|
||||
export.Add(formatOption);
|
||||
export.Add(exportDeltaOption);
|
||||
export.Add(exportScopeOption);
|
||||
export.Add(exportSinceOption);
|
||||
export.Add(exportProviderOption);
|
||||
export.Add(exportOutputOption);
|
||||
export.SetAction((parseResult, _) =>
|
||||
{
|
||||
var format = parseResult.GetValue(formatOption) ?? "openvex";
|
||||
var delta = parseResult.GetValue(exportDeltaOption);
|
||||
var scope = parseResult.GetValue(exportScopeOption);
|
||||
var since = parseResult.GetValue(exportSinceOption);
|
||||
var provider = parseResult.GetValue(exportProviderOption);
|
||||
var output = parseResult.GetValue(exportOutputOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return CommandHandlers.HandleExcititorExportAsync(services, format, delta, scope, since, provider, output, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
var backfill = new Command("backfill-statements", "Replay historical raw documents into Excititor statements.");
|
||||
var backfillRetrievedSinceOption = new Option<DateTimeOffset?>("--retrieved-since")
|
||||
{
|
||||
Description = "Only process raw documents retrieved on or after the provided ISO-8601 timestamp."
|
||||
};
|
||||
var backfillForceOption = new Option<bool>("--force")
|
||||
{
|
||||
Description = "Reprocess documents even if statements already exist."
|
||||
};
|
||||
var backfillBatchSizeOption = new Option<int>("--batch-size")
|
||||
{
|
||||
Description = "Number of raw documents to fetch per batch (default 100)."
|
||||
};
|
||||
var backfillMaxDocumentsOption = new Option<int?>("--max-documents")
|
||||
{
|
||||
Description = "Optional maximum number of raw documents to process."
|
||||
};
|
||||
backfill.Add(backfillRetrievedSinceOption);
|
||||
backfill.Add(backfillForceOption);
|
||||
backfill.Add(backfillBatchSizeOption);
|
||||
backfill.Add(backfillMaxDocumentsOption);
|
||||
backfill.SetAction((parseResult, _) =>
|
||||
{
|
||||
var retrievedSince = parseResult.GetValue(backfillRetrievedSinceOption);
|
||||
var force = parseResult.GetValue(backfillForceOption);
|
||||
var batchSize = parseResult.GetValue(backfillBatchSizeOption);
|
||||
if (batchSize <= 0)
|
||||
{
|
||||
batchSize = 100;
|
||||
}
|
||||
var maxDocuments = parseResult.GetValue(backfillMaxDocumentsOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return CommandHandlers.HandleExcititorBackfillStatementsAsync(
|
||||
services,
|
||||
retrievedSince,
|
||||
force,
|
||||
batchSize,
|
||||
maxDocuments,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
var verify = new Command("verify", "Verify Excititor exports or attestations.");
|
||||
var exportIdOption = new Option<string?>("--export-id")
|
||||
{
|
||||
Description = "Export identifier to verify."
|
||||
};
|
||||
var digestOption = new Option<string?>("--digest")
|
||||
{
|
||||
Description = "Expected digest for the export or attestation."
|
||||
};
|
||||
var attestationOption = new Option<string?>("--attestation")
|
||||
{
|
||||
Description = "Path to a local attestation file to verify (base64 content will be uploaded)."
|
||||
};
|
||||
verify.Add(exportIdOption);
|
||||
verify.Add(digestOption);
|
||||
verify.Add(attestationOption);
|
||||
verify.SetAction((parseResult, _) =>
|
||||
{
|
||||
var exportId = parseResult.GetValue(exportIdOption);
|
||||
var digest = parseResult.GetValue(digestOption);
|
||||
var attestation = parseResult.GetValue(attestationOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return CommandHandlers.HandleExcititorVerifyAsync(services, exportId, digest, attestation, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
var reconcile = new Command("reconcile", "Trigger Excititor reconciliation against canonical advisories.");
|
||||
var reconcileProviders = new Option<string[]>("--provider", new[] { "-p" })
|
||||
{
|
||||
Description = "Optional provider identifier(s) to reconcile.",
|
||||
Arity = ArgumentArity.ZeroOrMore
|
||||
};
|
||||
var maxAgeOption = new Option<TimeSpan?>("--max-age")
|
||||
{
|
||||
Description = "Optional maximum age window (e.g. 7.00:00:00)."
|
||||
};
|
||||
reconcile.Add(reconcileProviders);
|
||||
reconcile.Add(maxAgeOption);
|
||||
reconcile.SetAction((parseResult, _) =>
|
||||
{
|
||||
var providers = parseResult.GetValue(reconcileProviders) ?? Array.Empty<string>();
|
||||
var maxAge = parseResult.GetValue(maxAgeOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return CommandHandlers.HandleExcititorReconcileAsync(services, providers, maxAge, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
excititor.Add(init);
|
||||
excititor.Add(pull);
|
||||
excititor.Add(resume);
|
||||
excititor.Add(list);
|
||||
excititor.Add(export);
|
||||
excititor.Add(backfill);
|
||||
excititor.Add(verify);
|
||||
excititor.Add(reconcile);
|
||||
return excititor;
|
||||
}
|
||||
|
||||
private static Command BuildRuntimeCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var runtime = new Command("runtime", "Interact with runtime admission policy APIs.");
|
||||
var policy = new Command("policy", "Runtime policy operations.");
|
||||
|
||||
var test = new Command("test", "Evaluate runtime policy decisions for image digests.");
|
||||
var namespaceOption = new Option<string?>("--namespace", new[] { "--ns" })
|
||||
{
|
||||
Description = "Namespace or logical scope for the evaluation."
|
||||
};
|
||||
|
||||
var imageOption = new Option<string[]>("--image", new[] { "-i", "--images" })
|
||||
{
|
||||
Description = "Image digests to evaluate (repeatable).",
|
||||
Arity = ArgumentArity.ZeroOrMore
|
||||
};
|
||||
|
||||
var fileOption = new Option<string?>("--file", new[] { "-f" })
|
||||
{
|
||||
Description = "Path to a file containing image digests (one per line)."
|
||||
};
|
||||
|
||||
var labelOption = new Option<string[]>("--label", new[] { "-l", "--labels" })
|
||||
{
|
||||
Description = "Pod labels in key=value format (repeatable).",
|
||||
Arity = ArgumentArity.ZeroOrMore
|
||||
};
|
||||
|
||||
var jsonOption = new Option<bool>("--json")
|
||||
{
|
||||
Description = "Emit the raw JSON response."
|
||||
};
|
||||
|
||||
test.Add(namespaceOption);
|
||||
test.Add(imageOption);
|
||||
test.Add(fileOption);
|
||||
test.Add(labelOption);
|
||||
test.Add(jsonOption);
|
||||
|
||||
test.SetAction((parseResult, _) =>
|
||||
{
|
||||
var nsValue = parseResult.GetValue(namespaceOption);
|
||||
var images = parseResult.GetValue(imageOption) ?? Array.Empty<string>();
|
||||
var file = parseResult.GetValue(fileOption);
|
||||
var labels = parseResult.GetValue(labelOption) ?? Array.Empty<string>();
|
||||
var outputJson = parseResult.GetValue(jsonOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return CommandHandlers.HandleRuntimePolicyTestAsync(
|
||||
services,
|
||||
nsValue,
|
||||
images,
|
||||
file,
|
||||
labels,
|
||||
outputJson,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
policy.Add(test);
|
||||
runtime.Add(policy);
|
||||
return runtime;
|
||||
}
|
||||
|
||||
private static Command BuildOfflineCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var offline = new Command("offline", "Offline kit workflows and utilities.");
|
||||
|
||||
var kit = new Command("kit", "Manage offline kit bundles.");
|
||||
|
||||
var pull = new Command("pull", "Download the latest offline kit bundle.");
|
||||
var bundleIdOption = new Option<string?>("--bundle-id")
|
||||
{
|
||||
Description = "Optional bundle identifier. Defaults to the latest available."
|
||||
};
|
||||
var destinationOption = new Option<string?>("--destination")
|
||||
{
|
||||
Description = "Directory to store downloaded bundles (defaults to the configured offline kits directory)."
|
||||
};
|
||||
var overwriteOption = new Option<bool>("--overwrite")
|
||||
{
|
||||
Description = "Overwrite existing files even if checksums match."
|
||||
};
|
||||
var noResumeOption = new Option<bool>("--no-resume")
|
||||
{
|
||||
Description = "Disable resuming partial downloads."
|
||||
};
|
||||
|
||||
pull.Add(bundleIdOption);
|
||||
pull.Add(destinationOption);
|
||||
pull.Add(overwriteOption);
|
||||
pull.Add(noResumeOption);
|
||||
pull.SetAction((parseResult, _) =>
|
||||
{
|
||||
var bundleId = parseResult.GetValue(bundleIdOption);
|
||||
var destination = parseResult.GetValue(destinationOption);
|
||||
var overwrite = parseResult.GetValue(overwriteOption);
|
||||
var resume = !parseResult.GetValue(noResumeOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return CommandHandlers.HandleOfflineKitPullAsync(services, bundleId, destination, overwrite, resume, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
var import = new Command("import", "Upload an offline kit bundle to the backend.");
|
||||
var bundleArgument = new Argument<string>("bundle")
|
||||
{
|
||||
Description = "Path to the offline kit tarball (.tgz)."
|
||||
};
|
||||
var manifestOption = new Option<string?>("--manifest")
|
||||
{
|
||||
Description = "Offline manifest JSON path (defaults to metadata or sibling file)."
|
||||
};
|
||||
var bundleSignatureOption = new Option<string?>("--bundle-signature")
|
||||
{
|
||||
Description = "Detached signature for the offline bundle (e.g. .sig)."
|
||||
};
|
||||
var manifestSignatureOption = new Option<string?>("--manifest-signature")
|
||||
{
|
||||
Description = "Detached signature for the offline manifest (e.g. .jws)."
|
||||
};
|
||||
|
||||
import.Add(bundleArgument);
|
||||
import.Add(manifestOption);
|
||||
import.Add(bundleSignatureOption);
|
||||
import.Add(manifestSignatureOption);
|
||||
import.SetAction((parseResult, _) =>
|
||||
{
|
||||
var bundlePath = parseResult.GetValue(bundleArgument) ?? string.Empty;
|
||||
var manifest = parseResult.GetValue(manifestOption);
|
||||
var bundleSignature = parseResult.GetValue(bundleSignatureOption);
|
||||
var manifestSignature = parseResult.GetValue(manifestSignatureOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return CommandHandlers.HandleOfflineKitImportAsync(services, bundlePath, manifest, bundleSignature, manifestSignature, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
var status = new Command("status", "Display offline kit installation status.");
|
||||
var jsonOption = new Option<bool>("--json")
|
||||
{
|
||||
Description = "Emit status as JSON."
|
||||
};
|
||||
status.Add(jsonOption);
|
||||
status.SetAction((parseResult, _) =>
|
||||
{
|
||||
var asJson = parseResult.GetValue(jsonOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return CommandHandlers.HandleOfflineKitStatusAsync(services, asJson, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
kit.Add(pull);
|
||||
kit.Add(import);
|
||||
kit.Add(status);
|
||||
|
||||
offline.Add(kit);
|
||||
return offline;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<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.NonCore\\'))</PluginOutputDirectory>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Cli\StellaOps.Cli.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="CopyPluginBinaries" AfterTargets="Build">
|
||||
<MakeDir Directories="$(PluginOutputDirectory)" />
|
||||
<Copy SourceFiles="$(TargetDir)$(TargetFileName)" DestinationFolder="$(PluginOutputDirectory)" />
|
||||
<Copy SourceFiles="$(TargetDir)$(TargetName).pdb"
|
||||
DestinationFolder="$(PluginOutputDirectory)"
|
||||
Condition="Exists('$(TargetDir)$(TargetName).pdb')" />
|
||||
</Target>
|
||||
</Project>
|
||||
2489
src/Cli/__Tests/StellaOps.Cli.Tests/Commands/CommandHandlersTests.cs
Normal file
2489
src/Cli/__Tests/StellaOps.Cli.Tests/Commands/CommandHandlersTests.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,110 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Configuration;
|
||||
|
||||
public sealed class CliBootstrapperTests : IDisposable
|
||||
{
|
||||
private readonly string _originalDirectory = Directory.GetCurrentDirectory();
|
||||
private readonly string _tempDirectory = Path.Combine(Path.GetTempPath(), $"stellaops-cli-tests-{Guid.NewGuid():N}");
|
||||
|
||||
public CliBootstrapperTests()
|
||||
{
|
||||
Directory.CreateDirectory(_tempDirectory);
|
||||
Directory.SetCurrentDirectory(_tempDirectory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_UsesEnvironmentVariablesWhenPresent()
|
||||
{
|
||||
Environment.SetEnvironmentVariable("API_KEY", "env-key");
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_BACKEND_URL", "https://env-backend.example");
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_URL", "https://authority.env");
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_CLIENT_ID", "cli-env");
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_SCOPE", "concelier.jobs.trigger");
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_ENABLE_RETRIES", "false");
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_RETRY_DELAYS", "00:00:02,00:00:05");
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_ALLOW_OFFLINE_CACHE_FALLBACK", "false");
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_OFFLINE_CACHE_TOLERANCE", "00:20:00");
|
||||
|
||||
try
|
||||
{
|
||||
var (options, _) = CliBootstrapper.Build(Array.Empty<string>());
|
||||
|
||||
Assert.Equal("env-key", options.ApiKey);
|
||||
Assert.Equal("https://env-backend.example", options.BackendUrl);
|
||||
Assert.Equal("https://authority.env", options.Authority.Url);
|
||||
Assert.Equal("cli-env", options.Authority.ClientId);
|
||||
Assert.Equal("concelier.jobs.trigger", options.Authority.Scope);
|
||||
|
||||
Assert.NotNull(options.Authority.Resilience);
|
||||
Assert.False(options.Authority.Resilience.EnableRetries);
|
||||
Assert.Equal(new[] { TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(5) }, options.Authority.Resilience.RetryDelays);
|
||||
Assert.False(options.Authority.Resilience.AllowOfflineCacheFallback);
|
||||
Assert.Equal(TimeSpan.FromMinutes(20), options.Authority.Resilience.OfflineCacheTolerance);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("API_KEY", null);
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_BACKEND_URL", null);
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_URL", null);
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_CLIENT_ID", null);
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_SCOPE", null);
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_ENABLE_RETRIES", null);
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_RETRY_DELAYS", null);
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_ALLOW_OFFLINE_CACHE_FALLBACK", null);
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_OFFLINE_CACHE_TOLERANCE", null);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_FallsBackToAppSettings()
|
||||
{
|
||||
WriteAppSettings(new
|
||||
{
|
||||
StellaOps = new
|
||||
{
|
||||
ApiKey = "file-key",
|
||||
BackendUrl = "https://file-backend.example",
|
||||
Authority = new
|
||||
{
|
||||
Url = "https://authority.file",
|
||||
ClientId = "cli-file",
|
||||
Scope = "concelier.jobs.trigger"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
var (options, _) = CliBootstrapper.Build(Array.Empty<string>());
|
||||
|
||||
Assert.Equal("file-key", options.ApiKey);
|
||||
Assert.Equal("https://file-backend.example", options.BackendUrl);
|
||||
Assert.Equal("https://authority.file", options.Authority.Url);
|
||||
Assert.Equal("cli-file", options.Authority.ClientId);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Directory.SetCurrentDirectory(_originalDirectory);
|
||||
if (Directory.Exists(_tempDirectory))
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(_tempDirectory, recursive: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignored.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteAppSettings<T>(T payload)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true });
|
||||
File.WriteAllText("appsettings.json", json);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using System;
|
||||
using System.CommandLine;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Plugins;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Plugins;
|
||||
|
||||
public sealed class CliCommandModuleLoaderTests
|
||||
{
|
||||
[Fact]
|
||||
public void RegisterModules_LoadsNonCoreCommandsFromPlugin()
|
||||
{
|
||||
var options = new StellaOpsCliOptions();
|
||||
var repoRoot = Path.GetFullPath(
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", ".."));
|
||||
|
||||
options.Plugins.BaseDirectory = repoRoot;
|
||||
options.Plugins.Directory = "plugins/cli";
|
||||
options.Plugins.ManifestSearchPattern = "manifest.json";
|
||||
|
||||
var services = new ServiceCollection()
|
||||
.AddSingleton(options)
|
||||
.BuildServiceProvider();
|
||||
|
||||
var logger = NullLoggerFactory.Instance.CreateLogger<CliCommandModuleLoader>();
|
||||
var loader = new CliCommandModuleLoader(services, options, logger);
|
||||
|
||||
var root = new RootCommand();
|
||||
var verbose = new Option<bool>("--verbose");
|
||||
|
||||
loader.RegisterModules(root, verbose, CancellationToken.None);
|
||||
|
||||
Assert.Contains(root.Children, command => string.Equals(command.Name, "excititor", StringComparison.Ordinal));
|
||||
Assert.Contains(root.Children, command => string.Equals(command.Name, "runtime", StringComparison.Ordinal));
|
||||
Assert.Contains(root.Children, command => string.Equals(command.Name, "offline", StringComparison.Ordinal));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using StellaOps.Cli.Plugins;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Plugins;
|
||||
|
||||
public sealed class RestartOnlyCliPluginGuardTests
|
||||
{
|
||||
[Fact]
|
||||
public void EnsureRegistrationAllowed_AllowsDuringStartup()
|
||||
{
|
||||
var guard = new RestartOnlyCliPluginGuard();
|
||||
guard.EnsureRegistrationAllowed("./plugins/sample.dll");
|
||||
guard.Seal();
|
||||
|
||||
// Re-registering known plug-ins after sealing should succeed.
|
||||
guard.EnsureRegistrationAllowed("./plugins/sample.dll");
|
||||
Assert.True(guard.IsSealed);
|
||||
Assert.Single(guard.KnownPlugins);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureRegistrationAllowed_ThrowsForUnknownAfterSeal()
|
||||
{
|
||||
var guard = new RestartOnlyCliPluginGuard();
|
||||
guard.Seal();
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => guard.EnsureRegistrationAllowed("./plugins/new.dll"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Services;
|
||||
|
||||
public sealed class AuthorityDiagnosticsReporterTests : IDisposable
|
||||
{
|
||||
private readonly string _originalDirectory = Directory.GetCurrentDirectory();
|
||||
private readonly string _tempDirectory = Path.Combine(Path.GetTempPath(), $"stellaops-cli-tests-{Guid.NewGuid():N}");
|
||||
|
||||
public AuthorityDiagnosticsReporterTests()
|
||||
{
|
||||
Directory.CreateDirectory(_tempDirectory);
|
||||
Directory.SetCurrentDirectory(_tempDirectory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Emit_LogsWarning_WhenPasswordPolicyWeakened()
|
||||
{
|
||||
WriteAuthorityConfiguration(minimumLength: 8);
|
||||
|
||||
var (_, configuration) = CliBootstrapper.Build(Array.Empty<string>());
|
||||
var logger = new ListLogger();
|
||||
|
||||
AuthorityDiagnosticsReporter.Emit(configuration, logger);
|
||||
|
||||
var warning = Assert.Single(logger.Entries, entry => entry.Level == LogLevel.Warning);
|
||||
Assert.Contains("minimum length 8 < 12", warning.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("standard.yaml", warning.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Emit_EmitsNoWarnings_WhenPasswordPolicyMeetsBaseline()
|
||||
{
|
||||
WriteAuthorityConfiguration(minimumLength: 12);
|
||||
|
||||
var (_, configuration) = CliBootstrapper.Build(Array.Empty<string>());
|
||||
var logger = new ListLogger();
|
||||
|
||||
AuthorityDiagnosticsReporter.Emit(configuration, logger);
|
||||
|
||||
Assert.DoesNotContain(logger.Entries, entry => entry.Level >= LogLevel.Warning);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Directory.SetCurrentDirectory(_originalDirectory);
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(_tempDirectory))
|
||||
{
|
||||
Directory.Delete(_tempDirectory, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignored.
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteAuthorityConfiguration(int minimumLength)
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
Authority = new
|
||||
{
|
||||
Plugins = new
|
||||
{
|
||||
ConfigurationDirectory = "plugins",
|
||||
Descriptors = new
|
||||
{
|
||||
standard = new
|
||||
{
|
||||
AssemblyName = "StellaOps.Authority.Plugin.Standard",
|
||||
Enabled = true,
|
||||
ConfigFile = "standard.yaml"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true });
|
||||
File.WriteAllText("appsettings.json", json);
|
||||
|
||||
var pluginDirectory = Path.Combine(Directory.GetCurrentDirectory(), "plugins");
|
||||
Directory.CreateDirectory(pluginDirectory);
|
||||
|
||||
var pluginConfig = $"""
|
||||
bootstrapUser:
|
||||
username: "admin"
|
||||
password: "changeme"
|
||||
|
||||
passwordPolicy:
|
||||
minimumLength: {minimumLength}
|
||||
requireUppercase: true
|
||||
requireLowercase: true
|
||||
requireDigit: true
|
||||
requireSymbol: true
|
||||
""";
|
||||
|
||||
File.WriteAllText(Path.Combine(pluginDirectory, "standard.yaml"), pluginConfig);
|
||||
}
|
||||
|
||||
private sealed class ListLogger : ILogger
|
||||
{
|
||||
public readonly record struct LogEntry(LogLevel Level, string Message);
|
||||
|
||||
public List<LogEntry> Entries { get; } = new();
|
||||
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
var message = formatter(state, exception);
|
||||
Entries.Add(new LogEntry(logLevel, message));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NullScope : IDisposable
|
||||
{
|
||||
public static NullScope Instance { get; } = new();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Spectre.Console.Testing" Version="0.48.0" />
|
||||
<ProjectReference Include="../../StellaOps.Cli/StellaOps.Cli.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
90
src/Cli/__Tests/StellaOps.Cli.Tests/Testing/TestHelpers.cs
Normal file
90
src/Cli/__Tests/StellaOps.Cli.Tests/Testing/TestHelpers.cs
Normal file
@@ -0,0 +1,90 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Testing;
|
||||
|
||||
internal sealed class TempDirectory : IDisposable
|
||||
{
|
||||
public TempDirectory()
|
||||
{
|
||||
Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"stellaops-cli-tests-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(Path);
|
||||
}
|
||||
|
||||
public string Path { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(Path))
|
||||
{
|
||||
Directory.Delete(Path, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TempFile : IDisposable
|
||||
{
|
||||
public TempFile(string fileName, byte[] contents)
|
||||
{
|
||||
var directory = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"stellaops-cli-file-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(directory);
|
||||
Path = System.IO.Path.Combine(directory, fileName);
|
||||
File.WriteAllBytes(Path, contents);
|
||||
}
|
||||
|
||||
public string Path { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(Path))
|
||||
{
|
||||
File.Delete(Path);
|
||||
}
|
||||
|
||||
var directory = System.IO.Path.GetDirectoryName(Path);
|
||||
if (!string.IsNullOrEmpty(directory) && Directory.Exists(directory))
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignored intentionally
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class StubHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Queue<Func<HttpRequestMessage, CancellationToken, HttpResponseMessage>> _responses;
|
||||
|
||||
public StubHttpMessageHandler(params Func<HttpRequestMessage, CancellationToken, HttpResponseMessage>[] handlers)
|
||||
{
|
||||
if (handlers is null || handlers.Length == 0)
|
||||
{
|
||||
throw new ArgumentException("At least one handler must be provided.", nameof(handlers));
|
||||
}
|
||||
|
||||
_responses = new Queue<Func<HttpRequestMessage, CancellationToken, HttpResponseMessage>>(handlers);
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var factory = _responses.Count > 1 ? _responses.Dequeue() : _responses.Peek();
|
||||
return Task.FromResult(factory(request, cancellationToken));
|
||||
}
|
||||
}
|
||||
10
src/Cli/__Tests/StellaOps.Cli.Tests/UnitTest1.cs
Normal file
10
src/Cli/__Tests/StellaOps.Cli.Tests/UnitTest1.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace StellaOps.Cli.Tests;
|
||||
|
||||
public class UnitTest1
|
||||
{
|
||||
[Fact]
|
||||
public void Test1()
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
3
src/Cli/__Tests/StellaOps.Cli.Tests/xunit.runner.json
Normal file
3
src/Cli/__Tests/StellaOps.Cli.Tests/xunit.runner.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json"
|
||||
}
|
||||
Reference in New Issue
Block a user