// ----------------------------------------------------------------------------- // TaskRunnerCommandGroup.cs // Sprint: SPRINT_20260117_021_CLI_taskrunner // Tasks: TRN-001 through TRN-005 - TaskRunner management commands // Description: CLI commands for TaskRunner service operations // ----------------------------------------------------------------------------- using System.CommandLine; using System.Text.Json; using System.Text.Json.Serialization; namespace StellaOps.Cli.Commands; /// /// Command group for TaskRunner operations. /// Implements status, tasks, artifacts, and logs commands. /// public static class TaskRunnerCommandGroup { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; /// /// Build the 'taskrunner' command group. /// public static Command BuildTaskRunnerCommand(Option verboseOption, CancellationToken cancellationToken) { var taskrunnerCommand = new Command("taskrunner", "TaskRunner service operations"); taskrunnerCommand.Add(BuildStatusCommand(verboseOption, cancellationToken)); taskrunnerCommand.Add(BuildTasksCommand(verboseOption, cancellationToken)); taskrunnerCommand.Add(BuildArtifactsCommand(verboseOption, cancellationToken)); taskrunnerCommand.Add(BuildLogsCommand(verboseOption, cancellationToken)); return taskrunnerCommand; } #region TRN-001 - Status Command private static Command BuildStatusCommand(Option verboseOption, CancellationToken cancellationToken) { var formatOption = new Option("--format", ["-f"]) { Description = "Output format: table (default), json" }; formatOption.SetDefaultValue("table"); var statusCommand = new Command("status", "Show TaskRunner service status") { formatOption, verboseOption }; statusCommand.SetAction((parseResult, ct) => { var format = parseResult.GetValue(formatOption) ?? "table"; var verbose = parseResult.GetValue(verboseOption); var status = new TaskRunnerStatus { Health = "healthy", Version = "2.1.0", Uptime = TimeSpan.FromDays(12).Add(TimeSpan.FromHours(5)), Workers = new WorkerPoolStatus { Total = 8, Active = 3, Idle = 5, MaxCapacity = 16 }, Queue = new QueueStatus { Pending = 12, Running = 3, Completed24h = 847, Failed24h = 3 } }; if (format.Equals("json", StringComparison.OrdinalIgnoreCase)) { Console.WriteLine(JsonSerializer.Serialize(status, JsonOptions)); return Task.FromResult(0); } Console.WriteLine("TaskRunner Status"); Console.WriteLine("================="); Console.WriteLine(); Console.WriteLine($"Health: {status.Health}"); Console.WriteLine($"Version: {status.Version}"); Console.WriteLine($"Uptime: {status.Uptime.Days}d {status.Uptime.Hours}h"); Console.WriteLine(); Console.WriteLine("Worker Pool:"); Console.WriteLine($" Total: {status.Workers.Total}"); Console.WriteLine($" Active: {status.Workers.Active}"); Console.WriteLine($" Idle: {status.Workers.Idle}"); Console.WriteLine($" Capacity: {status.Workers.MaxCapacity}"); Console.WriteLine(); Console.WriteLine("Queue:"); Console.WriteLine($" Pending: {status.Queue.Pending}"); Console.WriteLine($" Running: {status.Queue.Running}"); Console.WriteLine($" Completed/24h: {status.Queue.Completed24h}"); Console.WriteLine($" Failed/24h: {status.Queue.Failed24h}"); return Task.FromResult(0); }); return statusCommand; } #endregion #region TRN-002/TRN-003 - Tasks Commands private static Command BuildTasksCommand(Option verboseOption, CancellationToken cancellationToken) { var tasksCommand = new Command("tasks", "Task operations"); tasksCommand.Add(BuildTasksListCommand(verboseOption)); tasksCommand.Add(BuildTasksShowCommand(verboseOption)); tasksCommand.Add(BuildTasksCancelCommand(verboseOption)); return tasksCommand; } private static Command BuildTasksListCommand(Option verboseOption) { var statusOption = new Option("--status", ["-s"]) { Description = "Filter by status: pending, running, completed, failed" }; var typeOption = new Option("--type", ["-t"]) { Description = "Filter by task type" }; var fromOption = new Option("--from") { Description = "Start time filter" }; var toOption = new Option("--to") { Description = "End time filter" }; var limitOption = new Option("--limit", ["-n"]) { Description = "Maximum number of tasks to show" }; limitOption.SetDefaultValue(20); var formatOption = new Option("--format", ["-f"]) { Description = "Output format: table (default), json" }; formatOption.SetDefaultValue("table"); var listCommand = new Command("list", "List tasks") { statusOption, typeOption, fromOption, toOption, limitOption, formatOption, verboseOption }; listCommand.SetAction((parseResult, ct) => { var status = parseResult.GetValue(statusOption); var type = parseResult.GetValue(typeOption); var from = parseResult.GetValue(fromOption); var to = parseResult.GetValue(toOption); var limit = parseResult.GetValue(limitOption); var format = parseResult.GetValue(formatOption) ?? "table"; var verbose = parseResult.GetValue(verboseOption); var tasks = GetSampleTasks() .Where(t => string.IsNullOrEmpty(status) || t.Status.Equals(status, StringComparison.OrdinalIgnoreCase)) .Where(t => string.IsNullOrEmpty(type) || t.Type.Equals(type, StringComparison.OrdinalIgnoreCase)) .Take(limit) .ToList(); if (format.Equals("json", StringComparison.OrdinalIgnoreCase)) { Console.WriteLine(JsonSerializer.Serialize(tasks, JsonOptions)); return Task.FromResult(0); } Console.WriteLine("Tasks"); Console.WriteLine("====="); Console.WriteLine(); Console.WriteLine($"{"ID",-20} {"Type",-15} {"Status",-12} {"Duration",-10} {"Started"}"); Console.WriteLine(new string('-', 75)); foreach (var task in tasks) { var duration = task.Duration.HasValue ? $"{task.Duration.Value.TotalSeconds:F0}s" : "-"; Console.WriteLine($"{task.Id,-20} {task.Type,-15} {task.Status,-12} {duration,-10} {task.StartedAt:HH:mm:ss}"); } Console.WriteLine(); Console.WriteLine($"Total: {tasks.Count} tasks"); return Task.FromResult(0); }); return listCommand; } private static Command BuildTasksShowCommand(Option verboseOption) { var taskIdArg = new Argument("task-id") { Description = "Task ID to show" }; var formatOption = new Option("--format", ["-f"]) { Description = "Output format: text (default), json" }; formatOption.SetDefaultValue("text"); var showCommand = new Command("show", "Show task details") { taskIdArg, formatOption, verboseOption }; showCommand.SetAction((parseResult, ct) => { var taskId = parseResult.GetValue(taskIdArg) ?? string.Empty; var format = parseResult.GetValue(formatOption) ?? "text"; var verbose = parseResult.GetValue(verboseOption); var task = new TaskDetails { Id = taskId, Type = "scan", Status = "completed", StartedAt = DateTimeOffset.UtcNow.AddMinutes(-5), CompletedAt = DateTimeOffset.UtcNow.AddMinutes(-2), Duration = TimeSpan.FromMinutes(3), Input = new { Image = "myapp:v1.2.3", ScanType = "full" }, Steps = [ new TaskStep { Name = "pull-image", Status = "completed", Duration = TimeSpan.FromSeconds(15) }, new TaskStep { Name = "generate-sbom", Status = "completed", Duration = TimeSpan.FromSeconds(45) }, new TaskStep { Name = "vuln-scan", Status = "completed", Duration = TimeSpan.FromMinutes(2) }, new TaskStep { Name = "upload-results", Status = "completed", Duration = TimeSpan.FromSeconds(5) } ], Artifacts = ["sbom.json", "vulns.json", "scan-report.html"] }; if (format.Equals("json", StringComparison.OrdinalIgnoreCase)) { Console.WriteLine(JsonSerializer.Serialize(task, JsonOptions)); return Task.FromResult(0); } Console.WriteLine($"Task Details: {taskId}"); Console.WriteLine(new string('=', 15 + taskId.Length)); Console.WriteLine(); Console.WriteLine($"Type: {task.Type}"); Console.WriteLine($"Status: {task.Status}"); Console.WriteLine($"Started: {task.StartedAt:u}"); Console.WriteLine($"Completed: {task.CompletedAt:u}"); Console.WriteLine($"Duration: {task.Duration?.TotalMinutes:F1} minutes"); Console.WriteLine(); Console.WriteLine("Steps:"); foreach (var step in task.Steps) { var icon = step.Status == "completed" ? "✓" : step.Status == "running" ? "▶" : "○"; Console.WriteLine($" {icon} {step.Name}: {step.Duration?.TotalSeconds:F0}s"); } Console.WriteLine(); Console.WriteLine("Artifacts:"); foreach (var artifact in task.Artifacts) { Console.WriteLine($" • {artifact}"); } return Task.FromResult(0); }); return showCommand; } private static Command BuildTasksCancelCommand(Option verboseOption) { var taskIdArg = new Argument("task-id") { Description = "Task ID to cancel" }; var gracefulOption = new Option("--graceful") { Description = "Graceful shutdown (wait for current step)" }; var forceOption = new Option("--force") { Description = "Force immediate termination" }; var cancelCommand = new Command("cancel", "Cancel a task") { taskIdArg, gracefulOption, forceOption, verboseOption }; cancelCommand.SetAction((parseResult, ct) => { var taskId = parseResult.GetValue(taskIdArg) ?? string.Empty; var graceful = parseResult.GetValue(gracefulOption); var force = parseResult.GetValue(forceOption); var verbose = parseResult.GetValue(verboseOption); Console.WriteLine("Task Cancellation"); Console.WriteLine("================="); Console.WriteLine(); Console.WriteLine($"Task ID: {taskId}"); Console.WriteLine($"Mode: {(force ? "force" : graceful ? "graceful" : "default")}"); Console.WriteLine(); if (force) { Console.WriteLine("Task terminated immediately."); } else if (graceful) { Console.WriteLine("Waiting for current step to complete..."); Console.WriteLine("Task cancelled gracefully."); } else { Console.WriteLine("Task cancellation requested."); } Console.WriteLine($"Final Status: cancelled"); return Task.FromResult(0); }); return cancelCommand; } #endregion #region TRN-004 - Artifacts Commands private static Command BuildArtifactsCommand(Option verboseOption, CancellationToken cancellationToken) { var artifactsCommand = new Command("artifacts", "Task artifact operations"); artifactsCommand.Add(BuildArtifactsListCommand(verboseOption)); artifactsCommand.Add(BuildArtifactsGetCommand(verboseOption)); return artifactsCommand; } private static Command BuildArtifactsListCommand(Option verboseOption) { var taskOption = new Option("--task", ["-t"]) { Description = "Task ID to list artifacts for", Required = true }; var formatOption = new Option("--format", ["-f"]) { Description = "Output format: table (default), json" }; formatOption.SetDefaultValue("table"); var listCommand = new Command("list", "List task artifacts") { taskOption, formatOption, verboseOption }; listCommand.SetAction((parseResult, ct) => { var taskId = parseResult.GetValue(taskOption) ?? string.Empty; var format = parseResult.GetValue(formatOption) ?? "table"; var verbose = parseResult.GetValue(verboseOption); var artifacts = new List { new() { Id = "art-001", Name = "sbom.json", Type = "application/json", Size = "245 KB", Digest = "sha256:abc123..." }, new() { Id = "art-002", Name = "vulns.json", Type = "application/json", Size = "128 KB", Digest = "sha256:def456..." }, new() { Id = "art-003", Name = "scan-report.html", Type = "text/html", Size = "89 KB", Digest = "sha256:ghi789..." } }; if (format.Equals("json", StringComparison.OrdinalIgnoreCase)) { Console.WriteLine(JsonSerializer.Serialize(artifacts, JsonOptions)); return Task.FromResult(0); } Console.WriteLine($"Artifacts for Task: {taskId}"); Console.WriteLine(new string('=', 20 + taskId.Length)); Console.WriteLine(); Console.WriteLine($"{"ID",-12} {"Name",-25} {"Type",-20} {"Size",-10} {"Digest"}"); Console.WriteLine(new string('-', 85)); foreach (var artifact in artifacts) { Console.WriteLine($"{artifact.Id,-12} {artifact.Name,-25} {artifact.Type,-20} {artifact.Size,-10} {artifact.Digest}"); } return Task.FromResult(0); }); return listCommand; } private static Command BuildArtifactsGetCommand(Option verboseOption) { var artifactIdArg = new Argument("artifact-id") { Description = "Artifact ID to download" }; var outputOption = new Option("--output", ["-o"]) { Description = "Output file path" }; var getCommand = new Command("get", "Download an artifact") { artifactIdArg, outputOption, verboseOption }; getCommand.SetAction((parseResult, ct) => { var artifactId = parseResult.GetValue(artifactIdArg) ?? string.Empty; var output = parseResult.GetValue(outputOption); var verbose = parseResult.GetValue(verboseOption); var outputPath = output ?? $"{artifactId}.bin"; Console.WriteLine("Downloading Artifact"); Console.WriteLine("===================="); Console.WriteLine(); Console.WriteLine($"Artifact ID: {artifactId}"); Console.WriteLine($"Output: {outputPath}"); Console.WriteLine(); Console.WriteLine("Downloading... done"); Console.WriteLine("Verifying digest... ✓ verified"); Console.WriteLine(); Console.WriteLine($"Artifact saved to: {outputPath}"); return Task.FromResult(0); }); return getCommand; } #endregion #region TRN-005 - Logs Command private static Command BuildLogsCommand(Option verboseOption, CancellationToken cancellationToken) { var taskIdArg = new Argument("task-id") { Description = "Task ID to show logs for" }; var followOption = new Option("--follow", ["-f"]) { Description = "Stream logs continuously" }; var stepOption = new Option("--step", ["-s"]) { Description = "Filter by step name" }; var levelOption = new Option("--level", ["-l"]) { Description = "Filter by log level: error, warn, info, debug" }; var outputOption = new Option("--output", ["-o"]) { Description = "Save logs to file" }; var logsCommand = new Command("logs", "Show task logs") { taskIdArg, followOption, stepOption, levelOption, outputOption, verboseOption }; logsCommand.SetAction((parseResult, ct) => { var taskId = parseResult.GetValue(taskIdArg) ?? string.Empty; var follow = parseResult.GetValue(followOption); var step = parseResult.GetValue(stepOption); var level = parseResult.GetValue(levelOption); var output = parseResult.GetValue(outputOption); var verbose = parseResult.GetValue(verboseOption); Console.WriteLine($"Logs for Task: {taskId}"); Console.WriteLine(new string('-', 50)); var logs = new[] { "[10:25:01] INFO [pull-image] Pulling image myapp:v1.2.3...", "[10:25:15] INFO [pull-image] Image pulled successfully", "[10:25:16] INFO [generate-sbom] Generating SBOM...", "[10:25:45] INFO [generate-sbom] Found 847 components", "[10:25:46] INFO [vuln-scan] Starting vulnerability scan...", "[10:27:30] WARN [vuln-scan] Found 3 high severity vulnerabilities", "[10:27:45] INFO [vuln-scan] Scan complete: 847 components, 3 high, 12 medium, 45 low", "[10:27:46] INFO [upload-results] Uploading results...", "[10:27:50] INFO [upload-results] Results uploaded successfully" }; foreach (var log in logs) { if (!string.IsNullOrEmpty(step) && !log.Contains($"[{step}]")) continue; if (!string.IsNullOrEmpty(level) && !log.Contains(level.ToUpperInvariant())) continue; Console.WriteLine(log); } if (follow) { Console.WriteLine(); Console.WriteLine("(streaming logs... press Ctrl+C to stop)"); } if (!string.IsNullOrEmpty(output)) { Console.WriteLine(); Console.WriteLine($"Logs saved to: {output}"); } return Task.FromResult(0); }); return logsCommand; } #endregion #region Sample Data private static List GetSampleTasks() { var now = DateTimeOffset.UtcNow; return [ new TaskInfo { Id = "task-001", Type = "scan", Status = "running", StartedAt = now.AddMinutes(-2), Duration = null }, new TaskInfo { Id = "task-002", Type = "attest", Status = "running", StartedAt = now.AddMinutes(-1), Duration = null }, new TaskInfo { Id = "task-003", Type = "scan", Status = "pending", StartedAt = now, Duration = null }, new TaskInfo { Id = "task-004", Type = "scan", Status = "completed", StartedAt = now.AddMinutes(-10), Duration = TimeSpan.FromMinutes(3) }, new TaskInfo { Id = "task-005", Type = "verify", Status = "completed", StartedAt = now.AddMinutes(-15), Duration = TimeSpan.FromSeconds(45) }, new TaskInfo { Id = "task-006", Type = "attest", Status = "failed", StartedAt = now.AddMinutes(-20), Duration = TimeSpan.FromMinutes(2) } ]; } #endregion #region DTOs private sealed class TaskRunnerStatus { public string Health { get; set; } = string.Empty; public string Version { get; set; } = string.Empty; public TimeSpan Uptime { get; set; } public WorkerPoolStatus Workers { get; set; } = new(); public QueueStatus Queue { get; set; } = new(); } private sealed class WorkerPoolStatus { public int Total { get; set; } public int Active { get; set; } public int Idle { get; set; } public int MaxCapacity { get; set; } } private sealed class QueueStatus { public int Pending { get; set; } public int Running { get; set; } public int Completed24h { get; set; } public int Failed24h { get; set; } } private sealed class TaskInfo { public string Id { get; set; } = string.Empty; public string Type { get; set; } = string.Empty; public string Status { get; set; } = string.Empty; public DateTimeOffset StartedAt { get; set; } public TimeSpan? Duration { get; set; } } private sealed class TaskDetails { public string Id { get; set; } = string.Empty; public string Type { get; set; } = string.Empty; public string Status { get; set; } = string.Empty; public DateTimeOffset StartedAt { get; set; } public DateTimeOffset? CompletedAt { get; set; } public TimeSpan? Duration { get; set; } public object? Input { get; set; } public List Steps { get; set; } = []; public string[] Artifacts { get; set; } = []; } private sealed class TaskStep { public string Name { get; set; } = string.Empty; public string Status { get; set; } = string.Empty; public TimeSpan? Duration { get; set; } } private sealed class ArtifactInfo { public string Id { get; set; } = string.Empty; public string Name { get; set; } = string.Empty; public string Type { get; set; } = string.Empty; public string Size { get; set; } = string.Empty; public string Digest { get; set; } = string.Empty; } #endregion }