Files
git.stella-ops.org/src/Cli/StellaOps.Cli/Commands/TaskRunnerCommandGroup.cs
2026-01-16 23:30:47 +02:00

653 lines
23 KiB
C#

// -----------------------------------------------------------------------------
// 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;
/// <summary>
/// Command group for TaskRunner operations.
/// Implements status, tasks, artifacts, and logs commands.
/// </summary>
public static class TaskRunnerCommandGroup
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
/// <summary>
/// Build the 'taskrunner' command group.
/// </summary>
public static Command BuildTaskRunnerCommand(Option<bool> 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<bool> verboseOption, CancellationToken cancellationToken)
{
var formatOption = new Option<string>("--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<bool> 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<bool> verboseOption)
{
var statusOption = new Option<string?>("--status", ["-s"])
{
Description = "Filter by status: pending, running, completed, failed"
};
var typeOption = new Option<string?>("--type", ["-t"])
{
Description = "Filter by task type"
};
var fromOption = new Option<string?>("--from")
{
Description = "Start time filter"
};
var toOption = new Option<string?>("--to")
{
Description = "End time filter"
};
var limitOption = new Option<int>("--limit", ["-n"])
{
Description = "Maximum number of tasks to show"
};
limitOption.SetDefaultValue(20);
var formatOption = new Option<string>("--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<bool> verboseOption)
{
var taskIdArg = new Argument<string>("task-id")
{
Description = "Task ID to show"
};
var formatOption = new Option<string>("--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<bool> verboseOption)
{
var taskIdArg = new Argument<string>("task-id")
{
Description = "Task ID to cancel"
};
var gracefulOption = new Option<bool>("--graceful")
{
Description = "Graceful shutdown (wait for current step)"
};
var forceOption = new Option<bool>("--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<bool> 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<bool> verboseOption)
{
var taskOption = new Option<string>("--task", ["-t"])
{
Description = "Task ID to list artifacts for",
Required = true
};
var formatOption = new Option<string>("--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<ArtifactInfo>
{
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<bool> verboseOption)
{
var artifactIdArg = new Argument<string>("artifact-id")
{
Description = "Artifact ID to download"
};
var outputOption = new Option<string?>("--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<bool> verboseOption, CancellationToken cancellationToken)
{
var taskIdArg = new Argument<string>("task-id")
{
Description = "Task ID to show logs for"
};
var followOption = new Option<bool>("--follow", ["-f"])
{
Description = "Stream logs continuously"
};
var stepOption = new Option<string?>("--step", ["-s"])
{
Description = "Filter by step name"
};
var levelOption = new Option<string?>("--level", ["-l"])
{
Description = "Filter by log level: error, warn, info, debug"
};
var outputOption = new Option<string?>("--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<TaskInfo> 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<TaskStep> 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
}