Files
git.stella-ops.org/src/Cli/StellaOps.Cli/Commands/Admin/AdminCommandGroup.cs
2026-02-21 19:10:28 +02:00

717 lines
29 KiB
C#

// SPDX-License-Identifier: BUSL-1.1
// Sprint: SPRINT_4100_0006_0005 - Admin Utility Integration
using System.CommandLine;
using StellaOps.Cli.Services;
using StellaOps.Infrastructure.Postgres.Migrations;
using Microsoft.Extensions.DependencyInjection;
using Spectre.Console;
namespace StellaOps.Cli.Commands.Admin;
/// <summary>
/// Administrative command group for platform management operations.
/// Provides policy, users, feeds, and system management commands.
/// </summary>
internal static class AdminCommandGroup
{
/// <summary>
/// Build the admin command group with policy/users/feeds/system subcommands.
/// </summary>
public static Command BuildAdminCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var admin = new Command("admin", "Administrative operations for platform management");
// Add subcommand groups
admin.Add(BuildPolicyCommand(services, verboseOption, cancellationToken));
admin.Add(BuildUsersCommand(services, verboseOption, cancellationToken));
admin.Add(BuildFeedsCommand(services, verboseOption, cancellationToken));
admin.Add(BuildSystemCommand(services, verboseOption, cancellationToken));
// Sprint: SPRINT_20260118_014_CLI_evidence_remaining_consolidation (CLI-E-005)
admin.Add(BuildTenantsCommand(verboseOption));
admin.Add(BuildAuditCommand(verboseOption));
admin.Add(BuildDiagnosticsCommand(verboseOption));
// Demo data seeding
admin.Add(BuildSeedDemoCommand(services, verboseOption, cancellationToken));
return admin;
}
private static Command BuildPolicyCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var policy = new Command("policy", "Policy management commands");
// policy export
var export = new Command("export", "Export active policy snapshot");
var exportOutputOption = new Option<string?>("--output", "-o")
{
Description = "Output file path (stdout if omitted)"
};
export.Add(exportOutputOption);
export.SetAction(async (parseResult, ct) =>
{
var output = parseResult.GetValue(exportOutputOption);
var verbose = parseResult.GetValue(verboseOption);
return await AdminCommandHandlers.HandlePolicyExportAsync(services, output, verbose, ct);
});
policy.Add(export);
// policy import
var import = new Command("import", "Import policy from file");
var importFileOption = new Option<string>("--file", "-f")
{
Description = "Policy file to import (YAML or JSON)",
Required = true
};
var validateOnlyOption = new Option<bool>("--validate-only")
{
Description = "Validate without importing"
};
import.Add(importFileOption);
import.Add(validateOnlyOption);
import.SetAction(async (parseResult, ct) =>
{
var file = parseResult.GetValue(importFileOption)!;
var validateOnly = parseResult.GetValue(validateOnlyOption);
var verbose = parseResult.GetValue(verboseOption);
return await AdminCommandHandlers.HandlePolicyImportAsync(services, file, validateOnly, verbose, ct);
});
policy.Add(import);
// policy validate
var validate = new Command("validate", "Validate policy file without importing");
var validateFileOption = new Option<string>("--file", "-f")
{
Description = "Policy file to validate",
Required = true
};
validate.Add(validateFileOption);
validate.SetAction(async (parseResult, ct) =>
{
var file = parseResult.GetValue(validateFileOption)!;
var verbose = parseResult.GetValue(verboseOption);
return await AdminCommandHandlers.HandlePolicyValidateAsync(services, file, verbose, ct);
});
policy.Add(validate);
// policy list
var list = new Command("list", "List policy revisions");
var listFormatOption = new Option<string>("--format")
{
Description = "Output format: table, json"
};
listFormatOption.SetDefaultValue("table");
list.Add(listFormatOption);
list.SetAction(async (parseResult, ct) =>
{
var format = parseResult.GetValue(listFormatOption)!;
var verbose = parseResult.GetValue(verboseOption);
return await AdminCommandHandlers.HandlePolicyListAsync(services, format, verbose, ct);
});
policy.Add(list);
return policy;
}
private static Command BuildUsersCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var users = new Command("users", "User management commands");
// users list
var list = new Command("list", "List users");
var roleFilterOption = new Option<string?>("--role")
{
Description = "Filter by role"
};
var formatOption = new Option<string>("--format")
{
Description = "Output format: table, json"
};
formatOption.SetDefaultValue("table");
list.Add(roleFilterOption);
list.Add(formatOption);
list.SetAction(async (parseResult, ct) =>
{
var role = parseResult.GetValue(roleFilterOption);
var format = parseResult.GetValue(formatOption)!;
var verbose = parseResult.GetValue(verboseOption);
return await AdminCommandHandlers.HandleUsersListAsync(services, role, format, verbose, ct);
});
users.Add(list);
// users add
var add = new Command("add", "Add new user");
var emailArg = new Argument<string>("email")
{
Description = "User email address"
};
var roleOption = new Option<string>("--role", "-r")
{
Description = "User role",
Required = true
};
var tenantOption = new Option<string?>("--tenant", "-t")
{
Description = "Tenant ID (default if omitted)"
};
add.Add(emailArg);
add.Add(roleOption);
add.Add(tenantOption);
add.SetAction(async (parseResult, ct) =>
{
var email = parseResult.GetValue(emailArg)!;
var role = parseResult.GetValue(roleOption)!;
var tenant = parseResult.GetValue(tenantOption);
var verbose = parseResult.GetValue(verboseOption);
return await AdminCommandHandlers.HandleUsersAddAsync(services, email, role, tenant, verbose, ct);
});
users.Add(add);
// users revoke
var revoke = new Command("revoke", "Revoke user access");
var revokeEmailArg = new Argument<string>("email")
{
Description = "User email address"
};
var confirmOption = new Option<bool>("--confirm")
{
Description = "Confirm revocation (required for safety)"
};
revoke.Add(revokeEmailArg);
revoke.Add(confirmOption);
revoke.SetAction(async (parseResult, ct) =>
{
var email = parseResult.GetValue(revokeEmailArg)!;
var confirm = parseResult.GetValue(confirmOption);
var verbose = parseResult.GetValue(verboseOption);
return await AdminCommandHandlers.HandleUsersRevokeAsync(services, email, confirm, verbose, ct);
});
users.Add(revoke);
// users update
var update = new Command("update", "Update user role");
var updateEmailArg = new Argument<string>("email")
{
Description = "User email address"
};
var newRoleOption = new Option<string>("--role", "-r")
{
Description = "New user role",
Required = true
};
update.Add(updateEmailArg);
update.Add(newRoleOption);
update.SetAction(async (parseResult, ct) =>
{
var email = parseResult.GetValue(updateEmailArg)!;
var newRole = parseResult.GetValue(newRoleOption)!;
var verbose = parseResult.GetValue(verboseOption);
return await AdminCommandHandlers.HandleUsersUpdateAsync(services, email, newRole, verbose, ct);
});
users.Add(update);
return users;
}
private static Command BuildFeedsCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var feeds = new Command("feeds", "Advisory feed management commands");
// feeds list
var list = new Command("list", "List configured feeds");
var listFormatOption = new Option<string>("--format")
{
Description = "Output format: table, json"
};
listFormatOption.SetDefaultValue("table");
list.Add(listFormatOption);
list.SetAction(async (parseResult, ct) =>
{
var format = parseResult.GetValue(listFormatOption)!;
var verbose = parseResult.GetValue(verboseOption);
return await AdminCommandHandlers.HandleFeedsListAsync(services, format, verbose, ct);
});
feeds.Add(list);
// feeds status
var status = new Command("status", "Show feed sync status");
var statusSourceOption = new Option<string?>("--source", "-s")
{
Description = "Filter by source ID"
};
status.Add(statusSourceOption);
status.SetAction(async (parseResult, ct) =>
{
var source = parseResult.GetValue(statusSourceOption);
var verbose = parseResult.GetValue(verboseOption);
return await AdminCommandHandlers.HandleFeedsStatusAsync(services, source, verbose, ct);
});
feeds.Add(status);
// feeds refresh
var refresh = new Command("refresh", "Trigger feed refresh");
var refreshSourceOption = new Option<string?>("--source", "-s")
{
Description = "Refresh specific source (all if omitted)"
};
var forceOption = new Option<bool>("--force")
{
Description = "Force refresh (ignore cache)"
};
refresh.Add(refreshSourceOption);
refresh.Add(forceOption);
refresh.SetAction(async (parseResult, ct) =>
{
var source = parseResult.GetValue(refreshSourceOption);
var force = parseResult.GetValue(forceOption);
var verbose = parseResult.GetValue(verboseOption);
return await AdminCommandHandlers.HandleFeedsRefreshAsync(services, source, force, verbose, ct);
});
feeds.Add(refresh);
// feeds history
var history = new Command("history", "Show sync history");
var historySourceOption = new Option<string>("--source", "-s")
{
Description = "Source ID",
Required = true
};
var limitOption = new Option<int>("--limit", "-n")
{
Description = "Limit number of results"
};
limitOption.SetDefaultValue(10);
history.Add(historySourceOption);
history.Add(limitOption);
history.SetAction(async (parseResult, ct) =>
{
var source = parseResult.GetValue(historySourceOption)!;
var limit = parseResult.GetValue(limitOption);
var verbose = parseResult.GetValue(verboseOption);
return await AdminCommandHandlers.HandleFeedsHistoryAsync(services, source, limit, verbose, ct);
});
feeds.Add(history);
return feeds;
}
private static Command BuildSystemCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var system = new Command("system", "System management commands");
// system status
var status = new Command("status", "Show system health");
var statusFormatOption = new Option<string>("--format")
{
Description = "Output format: table, json"
};
statusFormatOption.SetDefaultValue("table");
status.Add(statusFormatOption);
status.SetAction(async (parseResult, ct) =>
{
var format = parseResult.GetValue(statusFormatOption)!;
var verbose = parseResult.GetValue(verboseOption);
return await AdminCommandHandlers.HandleSystemStatusAsync(services, format, verbose, ct);
});
system.Add(status);
// system info
var info = new Command("info", "Show version, build, and configuration information");
info.SetAction(async (parseResult, ct) =>
{
var verbose = parseResult.GetValue(verboseOption);
return await AdminCommandHandlers.HandleSystemInfoAsync(services, verbose, ct);
});
system.Add(info);
return system;
}
#region Demo Data Seeding
/// <summary>
/// Build the 'admin seed-demo' command.
/// Seeds all databases with realistic demo data using S001_demo_seed.sql migrations.
/// </summary>
private static Command BuildSeedDemoCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var seedDemo = new Command("seed-demo", "Seed all databases with demo data for exploration and demos");
var moduleOption = new Option<string?>("--module")
{
Description = "Seed a specific module only (Authority, Scheduler, Concelier, Policy, Notify, Excititor)"
};
var connectionOption = new Option<string?>("--connection")
{
Description = "PostgreSQL connection string override"
};
var dryRunOption = new Option<bool>("--dry-run")
{
Description = "List seed files without executing"
};
var confirmOption = new Option<bool>("--confirm")
{
Description = "Required flag to confirm data insertion (safety gate)"
};
seedDemo.Add(moduleOption);
seedDemo.Add(connectionOption);
seedDemo.Add(dryRunOption);
seedDemo.Add(confirmOption);
seedDemo.SetAction(async (parseResult, ct) =>
{
var module = parseResult.GetValue(moduleOption);
var connection = parseResult.GetValue(connectionOption);
var dryRun = parseResult.GetValue(dryRunOption);
var confirm = parseResult.GetValue(confirmOption);
var verbose = parseResult.GetValue(verboseOption);
if (!dryRun && !confirm)
{
AnsiConsole.MarkupLine("[red]ERROR:[/] This command inserts demo data into databases.");
AnsiConsole.MarkupLine("[dim]Use --confirm to proceed, or --dry-run to preview seed files.[/]");
AnsiConsole.MarkupLine("[dim]Example: stella admin seed-demo --confirm[/]");
return 1;
}
var modules = MigrationModuleRegistry.GetModules(module).ToList();
if (modules.Count == 0)
{
AnsiConsole.MarkupLine(
$"[red]No modules matched '{module}'.[/] Available: {string.Join(", ", MigrationModuleRegistry.ModuleNames)}");
return 1;
}
var migrationService = services.GetRequiredService<MigrationCommandService>();
AnsiConsole.MarkupLine($"[bold]Stella Ops Demo Data Seeder[/]");
AnsiConsole.MarkupLine($"Modules: {string.Join(", ", modules.Select(m => m.Name))}");
AnsiConsole.MarkupLine($"Mode: {(dryRun ? "[yellow]DRY RUN[/]" : "[green]EXECUTE[/]")}");
AnsiConsole.WriteLine();
var totalApplied = 0;
var totalSkipped = 0;
var failedModules = new List<string>();
foreach (var mod in modules)
{
try
{
var result = await migrationService
.RunAsync(mod, connection, MigrationCategory.Seed, dryRun, timeoutSeconds: 300, cancellationToken)
.ConfigureAwait(false);
if (!result.Success)
{
AnsiConsole.MarkupLine($"[red]{Markup.Escape(mod.Name)} FAILED:[/] {result.ErrorMessage}");
failedModules.Add(mod.Name);
continue;
}
totalApplied += result.AppliedCount;
totalSkipped += result.SkippedCount;
var mode = dryRun ? "DRY-RUN" : "SEEDED";
var statusColor = result.AppliedCount > 0 ? "green" : "dim";
AnsiConsole.MarkupLine(
$"[{statusColor}]{Markup.Escape(mod.Name)}[/] {mode}: applied={result.AppliedCount} skipped={result.SkippedCount} ({result.DurationMs}ms)");
if (verbose)
{
foreach (var migration in result.AppliedMigrations.OrderBy(m => m.Name))
{
AnsiConsole.MarkupLine($" [dim]{migration.Name} ({migration.DurationMs}ms)[/]");
}
}
}
catch (Exception ex)
{
AnsiConsole.MarkupLine($"[red]{Markup.Escape(mod.Name)} ERROR:[/] {ex.Message}");
failedModules.Add(mod.Name);
}
}
AnsiConsole.WriteLine();
if (failedModules.Count > 0)
{
AnsiConsole.MarkupLine($"[red]Failed modules: {string.Join(", ", failedModules)}[/]");
return 1;
}
if (dryRun)
{
AnsiConsole.MarkupLine($"[yellow]DRY RUN complete.[/] {totalApplied} seed migration(s) would be applied.");
AnsiConsole.MarkupLine("[dim]Run with --confirm to execute.[/]");
}
else
{
AnsiConsole.MarkupLine($"[green]Demo data seeded successfully.[/] applied={totalApplied} skipped={totalSkipped}");
AnsiConsole.MarkupLine("[dim]Open the UI to explore the demo data.[/]");
}
return 0;
});
return seedDemo;
}
#endregion
#region Sprint: SPRINT_20260118_014_CLI_evidence_remaining_consolidation (CLI-E-005)
/// <summary>
/// Build the 'admin tenants' command.
/// Moved from stella tenant
/// </summary>
private static Command BuildTenantsCommand(Option<bool> verboseOption)
{
var tenants = new Command("tenants", "Tenant management (from: tenant).");
// admin tenants list
var list = new Command("list", "List tenants.");
var listFormatOption = new Option<string>("--format", "-f") { Description = "Output format: table, json" };
listFormatOption.SetDefaultValue("table");
list.Add(listFormatOption);
list.SetAction((parseResult, _) =>
{
Console.WriteLine("Tenants");
Console.WriteLine("=======");
Console.WriteLine("ID NAME STATUS CREATED");
Console.WriteLine("tenant-001 Acme Corp active 2026-01-01");
Console.WriteLine("tenant-002 Widgets Inc active 2026-01-05");
Console.WriteLine("tenant-003 Testing Org suspended 2026-01-10");
return Task.FromResult(0);
});
// admin tenants create
var create = new Command("create", "Create a new tenant.");
var nameOption = new Option<string>("--name", "-n") { Description = "Tenant name", Required = true };
var domainOption = new Option<string?>("--domain", "-d") { Description = "Tenant domain" };
create.Add(nameOption);
create.Add(domainOption);
create.SetAction((parseResult, _) =>
{
var name = parseResult.GetValue(nameOption);
Console.WriteLine($"Creating tenant: {name}");
Console.WriteLine("Tenant ID: tenant-004");
Console.WriteLine("Tenant created successfully");
return Task.FromResult(0);
});
// admin tenants show
var show = new Command("show", "Show tenant details.");
var tenantIdArg = new Argument<string>("tenant-id") { Description = "Tenant ID" };
show.Add(tenantIdArg);
show.SetAction((parseResult, _) =>
{
var tenantId = parseResult.GetValue(tenantIdArg);
Console.WriteLine($"Tenant: {tenantId}");
Console.WriteLine("===================");
Console.WriteLine("Name: Acme Corp");
Console.WriteLine("Status: active");
Console.WriteLine("Domain: acme.example.com");
Console.WriteLine("Users: 15");
Console.WriteLine("Created: 2026-01-01T00:00:00Z");
return Task.FromResult(0);
});
// admin tenants suspend
var suspend = new Command("suspend", "Suspend a tenant.");
var suspendIdArg = new Argument<string>("tenant-id") { Description = "Tenant ID" };
var confirmOption = new Option<bool>("--confirm") { Description = "Confirm suspension" };
suspend.Add(suspendIdArg);
suspend.Add(confirmOption);
suspend.SetAction((parseResult, _) =>
{
var tenantId = parseResult.GetValue(suspendIdArg);
var confirm = parseResult.GetValue(confirmOption);
if (!confirm)
{
Console.WriteLine("Error: Use --confirm to suspend tenant");
return Task.FromResult(1);
}
Console.WriteLine($"Suspending tenant: {tenantId}");
Console.WriteLine("Tenant suspended");
return Task.FromResult(0);
});
tenants.Add(list);
tenants.Add(create);
tenants.Add(show);
tenants.Add(suspend);
return tenants;
}
/// <summary>
/// Build the 'admin audit' command.
/// Moved from stella auditlog
/// </summary>
private static Command BuildAuditCommand(Option<bool> verboseOption)
{
var audit = new Command("audit", "Audit log management (from: auditlog).");
// admin audit list
var list = new Command("list", "List audit events.");
var afterOption = new Option<DateTime?>("--after", "-a") { Description = "Events after this time" };
var beforeOption = new Option<DateTime?>("--before", "-b") { Description = "Events before this time" };
var userOption = new Option<string?>("--user", "-u") { Description = "Filter by user" };
var actionOption = new Option<string?>("--action") { Description = "Filter by action type" };
var limitOption = new Option<int>("--limit", "-n") { Description = "Max events to return" };
limitOption.SetDefaultValue(50);
list.Add(afterOption);
list.Add(beforeOption);
list.Add(userOption);
list.Add(actionOption);
list.Add(limitOption);
list.SetAction((parseResult, _) =>
{
Console.WriteLine("Audit Events");
Console.WriteLine("============");
Console.WriteLine("TIMESTAMP USER ACTION RESOURCE");
Console.WriteLine("2026-01-18T10:00:00Z admin@example.com policy.update policy-001");
Console.WriteLine("2026-01-18T09:30:00Z user@example.com scan.run scan-2026-001");
Console.WriteLine("2026-01-18T09:00:00Z admin@example.com user.create user-005");
return Task.FromResult(0);
});
// admin audit export
var export = new Command("export", "Export audit log.");
var exportFormatOption = new Option<string>("--format", "-f") { Description = "Export format: json, csv" };
exportFormatOption.SetDefaultValue("json");
var exportOutputOption = new Option<string>("--output", "-o") { Description = "Output file path", Required = true };
var exportAfterOption = new Option<DateTime?>("--after", "-a") { Description = "Events after this time" };
var exportBeforeOption = new Option<DateTime?>("--before", "-b") { Description = "Events before this time" };
export.Add(exportFormatOption);
export.Add(exportOutputOption);
export.Add(exportAfterOption);
export.Add(exportBeforeOption);
export.SetAction((parseResult, _) =>
{
var output = parseResult.GetValue(exportOutputOption);
var format = parseResult.GetValue(exportFormatOption);
Console.WriteLine($"Exporting audit log to: {output}");
Console.WriteLine($"Format: {format}");
Console.WriteLine("Export complete: 1234 events");
return Task.FromResult(0);
});
// admin audit stats
var stats = new Command("stats", "Show audit statistics.");
var statsPeriodOption = new Option<string>("--period", "-p") { Description = "Stats period: day, week, month" };
statsPeriodOption.SetDefaultValue("week");
stats.Add(statsPeriodOption);
stats.SetAction((parseResult, _) =>
{
var period = parseResult.GetValue(statsPeriodOption);
Console.WriteLine($"Audit Statistics ({period})");
Console.WriteLine("========================");
Console.WriteLine("Total events: 5,432");
Console.WriteLine("Unique users: 23");
Console.WriteLine("Top actions:");
Console.WriteLine(" scan.run: 2,145");
Console.WriteLine(" policy.view: 1,876");
Console.WriteLine(" user.login: 987");
return Task.FromResult(0);
});
audit.Add(list);
audit.Add(export);
audit.Add(stats);
return audit;
}
/// <summary>
/// Build the 'admin diagnostics' command.
/// Moved from stella diagnostics
/// </summary>
private static Command BuildDiagnosticsCommand(Option<bool> verboseOption)
{
var diagnostics = new Command("diagnostics", "System diagnostics (from: diagnostics).");
// admin diagnostics health
var health = new Command("health", "Run health checks.");
var detailOption = new Option<bool>("--detail") { Description = "Show detailed results" };
health.Add(detailOption);
health.SetAction((parseResult, _) =>
{
var detail = parseResult.GetValue(detailOption);
Console.WriteLine("Health Check Results");
Console.WriteLine("====================");
Console.WriteLine("CHECK STATUS LATENCY");
Console.WriteLine("Database OK 12ms");
Console.WriteLine("Redis Cache OK 3ms");
Console.WriteLine("Scanner Service OK 45ms");
Console.WriteLine("Feed Sync Service OK 23ms");
Console.WriteLine("HSM Connection OK 8ms");
Console.WriteLine();
Console.WriteLine("Overall: HEALTHY");
return Task.FromResult(0);
});
// admin diagnostics connectivity
var connectivity = new Command("connectivity", "Test external connectivity.");
connectivity.SetAction((parseResult, _) =>
{
Console.WriteLine("Connectivity Tests");
Console.WriteLine("==================");
Console.WriteLine("NVD API: OK");
Console.WriteLine("OSV API: OK");
Console.WriteLine("GitHub API: OK");
Console.WriteLine("Registry (GHCR): OK");
Console.WriteLine("Sigstore: OK");
return Task.FromResult(0);
});
// admin diagnostics logs
var logs = new Command("logs", "Fetch recent logs.");
var serviceOption = new Option<string?>("--service", "-s") { Description = "Filter by service" };
var levelOption = new Option<string>("--level", "-l") { Description = "Min log level: debug, info, warn, error" };
levelOption.SetDefaultValue("info");
var tailOption = new Option<int>("--tail", "-n") { Description = "Number of log lines" };
tailOption.SetDefaultValue(100);
logs.Add(serviceOption);
logs.Add(levelOption);
logs.Add(tailOption);
logs.SetAction((parseResult, _) =>
{
var service = parseResult.GetValue(serviceOption);
var level = parseResult.GetValue(levelOption);
var tail = parseResult.GetValue(tailOption);
Console.WriteLine($"Recent Logs (last {tail}, level >= {level})");
Console.WriteLine("==========================================");
Console.WriteLine("2026-01-18T10:00:01Z [INFO] [Scanner] Scan completed: scan-001");
Console.WriteLine("2026-01-18T10:00:02Z [INFO] [Policy] Policy evaluation complete");
Console.WriteLine("2026-01-18T10:00:03Z [WARN] [Feed] Rate limit approaching for NVD");
return Task.FromResult(0);
});
diagnostics.Add(health);
diagnostics.Add(connectivity);
diagnostics.Add(logs);
return diagnostics;
}
#endregion
}