release orchestration strengthening
This commit is contained in:
227
src/Cli/StellaOps.Cli/Commands/Agent/BootstrapCommands.cs
Normal file
227
src/Cli/StellaOps.Cli/Commands/Agent/BootstrapCommands.cs
Normal file
@@ -0,0 +1,227 @@
|
||||
// Copyright (c) Stella Ops. All rights reserved. SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
using System.CommandLine;
|
||||
using StellaOps.Agent.Core.Bootstrap;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// CLI commands for agent bootstrapping.
|
||||
/// </summary>
|
||||
public static class BootstrapCommands
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates the 'agent bootstrap' command.
|
||||
/// </summary>
|
||||
public static Command CreateBootstrapCommand()
|
||||
{
|
||||
var command = new Command("bootstrap", "Bootstrap a new agent with zero-touch deployment");
|
||||
|
||||
var nameOption = new Option<string>(
|
||||
["--name", "-n"],
|
||||
"Agent name")
|
||||
{ IsRequired = true };
|
||||
|
||||
var envOption = new Option<string>(
|
||||
["--env", "-e"],
|
||||
() => "production",
|
||||
"Target environment");
|
||||
|
||||
var platformOption = new Option<string>(
|
||||
["--platform", "-p"],
|
||||
"Target platform (linux, windows, docker). Auto-detected if not specified.");
|
||||
|
||||
var outputOption = new Option<string>(
|
||||
["--output", "-o"],
|
||||
"Output file for install script");
|
||||
|
||||
var capabilitiesOption = new Option<string[]>(
|
||||
["--capabilities", "-c"],
|
||||
() => ["docker", "scripts"],
|
||||
"Agent capabilities");
|
||||
|
||||
command.AddOption(nameOption);
|
||||
command.AddOption(envOption);
|
||||
command.AddOption(platformOption);
|
||||
command.AddOption(outputOption);
|
||||
command.AddOption(capabilitiesOption);
|
||||
|
||||
command.SetHandler(async (name, env, platform, output, capabilities) =>
|
||||
{
|
||||
await HandleBootstrapAsync(name, env, platform, output, capabilities);
|
||||
}, nameOption, envOption, platformOption, outputOption, capabilitiesOption);
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the 'agent install-script' command.
|
||||
/// </summary>
|
||||
public static Command CreateInstallScriptCommand()
|
||||
{
|
||||
var command = new Command("install-script", "Generate an install script from a bootstrap token");
|
||||
|
||||
var tokenOption = new Option<string>(
|
||||
["--token", "-t"],
|
||||
"Bootstrap token")
|
||||
{ IsRequired = true };
|
||||
|
||||
var platformOption = new Option<string>(
|
||||
["--platform", "-p"],
|
||||
() => DetectPlatform(),
|
||||
"Target platform (linux, windows, docker)");
|
||||
|
||||
var outputOption = new Option<string>(
|
||||
["--output", "-o"],
|
||||
"Output file path");
|
||||
|
||||
command.AddOption(tokenOption);
|
||||
command.AddOption(platformOption);
|
||||
command.AddOption(outputOption);
|
||||
|
||||
command.SetHandler(async (token, platform, output) =>
|
||||
{
|
||||
await HandleInstallScriptAsync(token, platform, output);
|
||||
}, tokenOption, platformOption, outputOption);
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static async Task HandleBootstrapAsync(
|
||||
string name,
|
||||
string environment,
|
||||
string? platform,
|
||||
string? output,
|
||||
string[] capabilities)
|
||||
{
|
||||
Console.WriteLine($"🚀 Bootstrapping agent: {name}");
|
||||
Console.WriteLine($" Environment: {environment}");
|
||||
Console.WriteLine($" Capabilities: {string.Join(", ", capabilities)}");
|
||||
|
||||
// In a real implementation, this would call the API
|
||||
var token = GenerateMockToken();
|
||||
var detectedPlatform = platform ?? DetectPlatform();
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("✅ Bootstrap token generated!");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
||||
|
||||
switch (detectedPlatform.ToLowerInvariant())
|
||||
{
|
||||
case "linux":
|
||||
Console.WriteLine("📋 Linux one-liner (copy and run on target host):");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"curl -fsSL https://orchestrator.example.com/api/v1/agents/install.sh | STELLA_TOKEN=\"{token}\" bash");
|
||||
break;
|
||||
|
||||
case "windows":
|
||||
Console.WriteLine("📋 Windows one-liner (copy and run in PowerShell as Administrator):");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"$env:STELLA_TOKEN='{token}'; iwr -useb https://orchestrator.example.com/api/v1/agents/install.ps1 | iex");
|
||||
break;
|
||||
|
||||
case "docker":
|
||||
Console.WriteLine("📋 Docker one-liner:");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"docker run -d --name {name} -v /var/run/docker.sock:/var/run/docker.sock -e STELLA_TOKEN=\"{token}\" stellaops/agent:latest");
|
||||
break;
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("⚠️ Token expires in 15 minutes");
|
||||
|
||||
if (!string.IsNullOrEmpty(output))
|
||||
{
|
||||
// Write to file
|
||||
await File.WriteAllTextAsync(output, $"STELLA_TOKEN={token}");
|
||||
Console.WriteLine($"📁 Token saved to: {output}");
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static async Task HandleInstallScriptAsync(
|
||||
string token,
|
||||
string platform,
|
||||
string? output)
|
||||
{
|
||||
var script = platform.ToLowerInvariant() switch
|
||||
{
|
||||
"linux" => GenerateLinuxScript(token),
|
||||
"windows" => GenerateWindowsScript(token),
|
||||
"docker" => GenerateDockerCompose(token),
|
||||
_ => throw new ArgumentException($"Unknown platform: {platform}")
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(output))
|
||||
{
|
||||
await File.WriteAllTextAsync(output, script);
|
||||
Console.WriteLine($"✅ Install script written to: {output}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(script);
|
||||
}
|
||||
}
|
||||
|
||||
private static string DetectPlatform()
|
||||
{
|
||||
if (OperatingSystem.IsWindows()) return "windows";
|
||||
if (OperatingSystem.IsLinux()) return "linux";
|
||||
if (OperatingSystem.IsMacOS()) return "linux"; // Use Linux scripts for macOS
|
||||
return "docker";
|
||||
}
|
||||
|
||||
private static string GenerateMockToken() =>
|
||||
Convert.ToBase64String(Guid.NewGuid().ToByteArray()).Replace('+', '-').Replace('/', '_').TrimEnd('=');
|
||||
|
||||
private static string GenerateLinuxScript(string token) => $"""
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# Stella Ops Agent Installation Script
|
||||
STELLA_TOKEN="{token}"
|
||||
STELLA_ORCHESTRATOR="https://orchestrator.example.com"
|
||||
|
||||
echo "Installing Stella Ops Agent..."
|
||||
|
||||
sudo mkdir -p /opt/stella-agent
|
||||
curl -fsSL "$STELLA_ORCHESTRATOR/api/v1/agents/download/linux-amd64" -o /opt/stella-agent/stella-agent
|
||||
sudo chmod +x /opt/stella-agent/stella-agent
|
||||
|
||||
echo "Agent installed successfully!"
|
||||
""";
|
||||
|
||||
private static string GenerateWindowsScript(string token) => $"""
|
||||
# Stella Ops Agent Installation Script (Windows)
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$StellaToken = "{token}"
|
||||
$StellaOrchestrator = "https://orchestrator.example.com"
|
||||
|
||||
Write-Host "Installing Stella Ops Agent..."
|
||||
|
||||
New-Item -ItemType Directory -Force -Path "C:\Program Files\Stella Agent" | Out-Null
|
||||
Invoke-WebRequest -Uri "$StellaOrchestrator/api/v1/agents/download/windows-amd64" -OutFile "C:\Program Files\Stella Agent\stella-agent.exe"
|
||||
|
||||
Write-Host "Agent installed successfully!"
|
||||
""";
|
||||
|
||||
private static string GenerateDockerCompose(string token) => $"""
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
stella-agent:
|
||||
image: stellaops/agent:latest
|
||||
container_name: stella-agent
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- STELLA_TOKEN={token}
|
||||
- STELLA_ORCHESTRATOR=https://orchestrator.example.com
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
""";
|
||||
}
|
||||
127
src/Cli/StellaOps.Cli/Commands/Agent/CertificateCommands.cs
Normal file
127
src/Cli/StellaOps.Cli/Commands/Agent/CertificateCommands.cs
Normal file
@@ -0,0 +1,127 @@
|
||||
// Copyright (c) Stella Ops. All rights reserved. SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
using System.CommandLine;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// CLI commands for agent certificate management.
|
||||
/// </summary>
|
||||
public static class CertificateCommands
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates the 'agent renew-cert' command.
|
||||
/// </summary>
|
||||
public static Command CreateRenewCertCommand()
|
||||
{
|
||||
var command = new Command("renew-cert", "Renew agent mTLS certificate");
|
||||
|
||||
var forceOption = new Option<bool>(
|
||||
["--force", "-f"],
|
||||
() => false,
|
||||
"Force renewal even if certificate is not near expiry");
|
||||
|
||||
command.AddOption(forceOption);
|
||||
|
||||
command.SetHandler(async (force) =>
|
||||
{
|
||||
await HandleRenewCertAsync(force);
|
||||
}, forceOption);
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the 'agent cert-status' command.
|
||||
/// </summary>
|
||||
public static Command CreateCertStatusCommand()
|
||||
{
|
||||
var command = new Command("cert-status", "Show certificate status");
|
||||
|
||||
command.SetHandler(async () =>
|
||||
{
|
||||
await HandleCertStatusAsync();
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static async Task HandleRenewCertAsync(bool force)
|
||||
{
|
||||
Console.WriteLine("🔐 Certificate Renewal");
|
||||
Console.WriteLine();
|
||||
|
||||
if (force)
|
||||
{
|
||||
Console.WriteLine("⚠️ Force renewal requested");
|
||||
}
|
||||
|
||||
// Simulate certificate check
|
||||
Console.WriteLine("🔍 Checking current certificate...");
|
||||
await Task.Delay(300);
|
||||
|
||||
var daysUntilExpiry = 45;
|
||||
|
||||
if (!force && daysUntilExpiry > 7)
|
||||
{
|
||||
Console.WriteLine($"ℹ️ Current certificate is valid for {daysUntilExpiry} days");
|
||||
Console.WriteLine(" Renewal not required. Use --force to renew anyway.");
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine("📝 Generating certificate signing request...");
|
||||
await Task.Delay(200);
|
||||
|
||||
Console.WriteLine("📤 Submitting CSR to orchestrator...");
|
||||
await Task.Delay(500);
|
||||
|
||||
Console.WriteLine("📥 Receiving signed certificate...");
|
||||
await Task.Delay(300);
|
||||
|
||||
Console.WriteLine("💾 Storing new certificate...");
|
||||
await Task.Delay(200);
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("✅ Certificate renewed successfully!");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("New certificate details:");
|
||||
Console.WriteLine($" Subject: CN=agent-abc123");
|
||||
Console.WriteLine($" Issuer: CN=Stella Ops CA");
|
||||
Console.WriteLine($" Valid from: {DateTime.UtcNow:yyyy-MM-dd}");
|
||||
Console.WriteLine($" Valid until: {DateTime.UtcNow.AddDays(90):yyyy-MM-dd}");
|
||||
Console.WriteLine($" Thumbprint: 5A:B3:C2:D1:...");
|
||||
}
|
||||
|
||||
private static async Task HandleCertStatusAsync()
|
||||
{
|
||||
Console.WriteLine("🔐 Certificate Status");
|
||||
Console.WriteLine("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
||||
Console.WriteLine();
|
||||
|
||||
// Simulate certificate info
|
||||
await Task.Delay(100);
|
||||
|
||||
var expiresAt = DateTime.UtcNow.AddDays(45);
|
||||
var daysRemaining = 45;
|
||||
|
||||
Console.WriteLine("Current Certificate:");
|
||||
Console.WriteLine($" Subject: CN=agent-abc123");
|
||||
Console.WriteLine($" Issuer: CN=Stella Ops CA");
|
||||
Console.WriteLine($" Valid from: {DateTime.UtcNow.AddDays(-45):yyyy-MM-dd HH:mm:ss} UTC");
|
||||
Console.WriteLine($" Valid until: {expiresAt:yyyy-MM-dd HH:mm:ss} UTC");
|
||||
Console.WriteLine($" Thumbprint: 5A:B3:C2:D1:E5:F6:A7:B8:C9:D0:E1:F2:A3:B4:C5:D6:E7:F8:A9:B0");
|
||||
Console.WriteLine();
|
||||
|
||||
var statusIcon = daysRemaining > 14 ? "✅" : daysRemaining > 7 ? "⚠️" : "🚨";
|
||||
var statusText = daysRemaining > 14 ? "Valid" : daysRemaining > 7 ? "Expiring soon" : "Critical - renew immediately";
|
||||
|
||||
Console.WriteLine($"Status: {statusIcon} {statusText}");
|
||||
Console.WriteLine($"Days remaining: {daysRemaining}");
|
||||
Console.WriteLine();
|
||||
|
||||
if (daysRemaining <= 14)
|
||||
{
|
||||
Console.WriteLine("💡 Run 'stella agent renew-cert' to renew the certificate");
|
||||
}
|
||||
}
|
||||
}
|
||||
241
src/Cli/StellaOps.Cli/Commands/Agent/ConfigCommands.cs
Normal file
241
src/Cli/StellaOps.Cli/Commands/Agent/ConfigCommands.cs
Normal file
@@ -0,0 +1,241 @@
|
||||
// Copyright (c) Stella Ops. All rights reserved. SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
using System.CommandLine;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// CLI commands for agent configuration management.
|
||||
/// </summary>
|
||||
public static class ConfigCommands
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates the 'agent config' command.
|
||||
/// </summary>
|
||||
public static Command CreateConfigCommand()
|
||||
{
|
||||
var command = new Command("config", "Show agent configuration");
|
||||
|
||||
var diffOption = new Option<bool>(
|
||||
["--diff", "-d"],
|
||||
() => false,
|
||||
"Show drift between current and desired configuration");
|
||||
|
||||
var formatOption = new Option<string>(
|
||||
["--format"],
|
||||
() => "yaml",
|
||||
"Output format (yaml, json)");
|
||||
|
||||
command.AddOption(diffOption);
|
||||
command.AddOption(formatOption);
|
||||
|
||||
command.SetHandler(async (diff, format) =>
|
||||
{
|
||||
await HandleConfigAsync(diff, format);
|
||||
}, diffOption, formatOption);
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the 'agent apply' command.
|
||||
/// </summary>
|
||||
public static Command CreateApplyCommand()
|
||||
{
|
||||
var command = new Command("apply", "Apply agent configuration");
|
||||
|
||||
var fileOption = new Option<string>(
|
||||
["--file", "-f"],
|
||||
"Configuration file path")
|
||||
{ IsRequired = true };
|
||||
|
||||
var dryRunOption = new Option<bool>(
|
||||
["--dry-run"],
|
||||
() => false,
|
||||
"Validate without applying");
|
||||
|
||||
command.AddOption(fileOption);
|
||||
command.AddOption(dryRunOption);
|
||||
|
||||
command.SetHandler(async (file, dryRun) =>
|
||||
{
|
||||
await HandleApplyAsync(file, dryRun);
|
||||
}, fileOption, dryRunOption);
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static async Task HandleConfigAsync(bool diff, string format)
|
||||
{
|
||||
if (diff)
|
||||
{
|
||||
Console.WriteLine("🔍 Checking for configuration drift...");
|
||||
Console.WriteLine();
|
||||
|
||||
// Simulated drift output
|
||||
Console.WriteLine("Configuration Drift Report");
|
||||
Console.WriteLine("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("✅ No configuration drift detected");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Current version: 1");
|
||||
Console.WriteLine("Desired version: 1");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("# Current Agent Configuration");
|
||||
Console.WriteLine();
|
||||
|
||||
var config = GetMockConfiguration();
|
||||
|
||||
if (format == "json")
|
||||
{
|
||||
var json = JsonSerializer.Serialize(config, new JsonSerializerOptions { WriteIndented = true });
|
||||
Console.WriteLine(json);
|
||||
}
|
||||
else
|
||||
{
|
||||
// YAML-like output
|
||||
Console.WriteLine("identity:");
|
||||
Console.WriteLine($" agentId: {config.Identity.AgentId}");
|
||||
Console.WriteLine($" agentName: {config.Identity.AgentName}");
|
||||
Console.WriteLine($" environment: {config.Identity.Environment}");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("connection:");
|
||||
Console.WriteLine($" orchestratorUrl: {config.Connection.OrchestratorUrl}");
|
||||
Console.WriteLine($" heartbeatInterval: {config.Connection.HeartbeatInterval}");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("capabilities:");
|
||||
Console.WriteLine($" docker: {config.Capabilities.Docker}");
|
||||
Console.WriteLine($" scripts: {config.Capabilities.Scripts}");
|
||||
Console.WriteLine($" compose: {config.Capabilities.Compose}");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("resources:");
|
||||
Console.WriteLine($" maxConcurrentTasks: {config.Resources.MaxConcurrentTasks}");
|
||||
Console.WriteLine($" workDirectory: {config.Resources.WorkDirectory}");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("security:");
|
||||
Console.WriteLine(" certificate:");
|
||||
Console.WriteLine($" source: {config.Security.Certificate.Source}");
|
||||
}
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static async Task HandleApplyAsync(string file, bool dryRun)
|
||||
{
|
||||
if (!File.Exists(file))
|
||||
{
|
||||
Console.WriteLine($"❌ Configuration file not found: {file}");
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine($"📄 Loading configuration from: {file}");
|
||||
var content = await File.ReadAllTextAsync(file);
|
||||
|
||||
Console.WriteLine("🔍 Validating configuration...");
|
||||
|
||||
// Simulate validation
|
||||
await Task.Delay(200);
|
||||
|
||||
Console.WriteLine("✅ Configuration is valid");
|
||||
Console.WriteLine();
|
||||
|
||||
if (dryRun)
|
||||
{
|
||||
Console.WriteLine("🔵 Dry-run mode: no changes applied");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Changes that would be applied:");
|
||||
Console.WriteLine(" - resources.maxConcurrentTasks: 5 → 10");
|
||||
Console.WriteLine(" - observability.metrics.enabled: false → true");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("🚀 Applying configuration...");
|
||||
await Task.Delay(500);
|
||||
Console.WriteLine("✅ Configuration applied successfully");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Rollback version: 1 (use 'stella agent config rollback 1' to revert)");
|
||||
}
|
||||
}
|
||||
|
||||
private static AgentConfigModel GetMockConfiguration() => new()
|
||||
{
|
||||
Identity = new IdentityModel
|
||||
{
|
||||
AgentId = "agent-abc123",
|
||||
AgentName = "prod-agent-01",
|
||||
Environment = "production"
|
||||
},
|
||||
Connection = new ConnectionModel
|
||||
{
|
||||
OrchestratorUrl = "https://orchestrator.example.com",
|
||||
HeartbeatInterval = "30s"
|
||||
},
|
||||
Capabilities = new CapabilitiesModel
|
||||
{
|
||||
Docker = true,
|
||||
Scripts = true,
|
||||
Compose = true
|
||||
},
|
||||
Resources = new ResourcesModel
|
||||
{
|
||||
MaxConcurrentTasks = 5,
|
||||
WorkDirectory = "/var/lib/stella-agent"
|
||||
},
|
||||
Security = new SecurityModel
|
||||
{
|
||||
Certificate = new CertificateModel
|
||||
{
|
||||
Source = "AutoProvision"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private sealed record AgentConfigModel
|
||||
{
|
||||
public required IdentityModel Identity { get; init; }
|
||||
public required ConnectionModel Connection { get; init; }
|
||||
public required CapabilitiesModel Capabilities { get; init; }
|
||||
public required ResourcesModel Resources { get; init; }
|
||||
public required SecurityModel Security { get; init; }
|
||||
}
|
||||
|
||||
private sealed record IdentityModel
|
||||
{
|
||||
public required string AgentId { get; init; }
|
||||
public string? AgentName { get; init; }
|
||||
public required string Environment { get; init; }
|
||||
}
|
||||
|
||||
private sealed record ConnectionModel
|
||||
{
|
||||
public required string OrchestratorUrl { get; init; }
|
||||
public string HeartbeatInterval { get; init; } = "30s";
|
||||
}
|
||||
|
||||
private sealed record CapabilitiesModel
|
||||
{
|
||||
public bool Docker { get; init; } = true;
|
||||
public bool Scripts { get; init; } = true;
|
||||
public bool Compose { get; init; } = true;
|
||||
}
|
||||
|
||||
private sealed record ResourcesModel
|
||||
{
|
||||
public int MaxConcurrentTasks { get; init; } = 5;
|
||||
public string WorkDirectory { get; init; } = "/var/lib/stella-agent";
|
||||
}
|
||||
|
||||
private sealed record SecurityModel
|
||||
{
|
||||
public required CertificateModel Certificate { get; init; }
|
||||
}
|
||||
|
||||
private sealed record CertificateModel
|
||||
{
|
||||
public string Source { get; init; } = "AutoProvision";
|
||||
}
|
||||
}
|
||||
220
src/Cli/StellaOps.Cli/Commands/Agent/DoctorCommands.cs
Normal file
220
src/Cli/StellaOps.Cli/Commands/Agent/DoctorCommands.cs
Normal file
@@ -0,0 +1,220 @@
|
||||
// Copyright (c) Stella Ops. All rights reserved. SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
using System.CommandLine;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// CLI commands for agent diagnostics (Doctor).
|
||||
/// </summary>
|
||||
public static class DoctorCommands
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates the 'agent doctor' command.
|
||||
/// </summary>
|
||||
public static Command CreateDoctorCommand()
|
||||
{
|
||||
var command = new Command("doctor", "Run agent health diagnostics");
|
||||
|
||||
var agentIdOption = new Option<string?>(
|
||||
["--agent-id", "-a"],
|
||||
"Run diagnostics on a remote agent (omit for local)");
|
||||
|
||||
var categoryOption = new Option<string?>(
|
||||
["--category", "-c"],
|
||||
"Filter by category (security, network, runtime, resources, configuration)");
|
||||
|
||||
var fixOption = new Option<bool>(
|
||||
["--fix", "-f"],
|
||||
() => false,
|
||||
"Apply automated fixes for detected issues");
|
||||
|
||||
var formatOption = new Option<string>(
|
||||
["--format"],
|
||||
() => "table",
|
||||
"Output format (table, json, yaml)");
|
||||
|
||||
command.AddOption(agentIdOption);
|
||||
command.AddOption(categoryOption);
|
||||
command.AddOption(fixOption);
|
||||
command.AddOption(formatOption);
|
||||
|
||||
command.SetHandler(async (agentId, category, fix, format) =>
|
||||
{
|
||||
await HandleDoctorAsync(agentId, category, fix, format);
|
||||
}, agentIdOption, categoryOption, fixOption, formatOption);
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static async Task HandleDoctorAsync(
|
||||
string? agentId,
|
||||
string? category,
|
||||
bool fix,
|
||||
string format)
|
||||
{
|
||||
var isLocal = string.IsNullOrEmpty(agentId);
|
||||
|
||||
Console.WriteLine(isLocal
|
||||
? "🔍 Running local agent diagnostics..."
|
||||
: $"🔍 Running diagnostics on agent: {agentId}");
|
||||
|
||||
if (!string.IsNullOrEmpty(category))
|
||||
{
|
||||
Console.WriteLine($" Category filter: {category}");
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
|
||||
// Simulated diagnostic results
|
||||
var results = GetMockDiagnosticResults(category);
|
||||
|
||||
if (format == "json")
|
||||
{
|
||||
var json = JsonSerializer.Serialize(results, new JsonSerializerOptions { WriteIndented = true });
|
||||
Console.WriteLine(json);
|
||||
}
|
||||
else
|
||||
{
|
||||
RenderTableOutput(results);
|
||||
}
|
||||
|
||||
// Show summary
|
||||
var passed = results.Count(r => r.Status == "Healthy");
|
||||
var warnings = results.Count(r => r.Status == "Warning");
|
||||
var failed = results.Count(r => r.Status == "Unhealthy" || r.Status == "Critical");
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
||||
Console.WriteLine($"Summary: {passed} passed, {warnings} warnings, {failed} failed");
|
||||
|
||||
if (fix && (warnings > 0 || failed > 0))
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("🔧 Applying automated fixes...");
|
||||
await ApplyFixesAsync(results);
|
||||
}
|
||||
else if (warnings > 0 || failed > 0)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("💡 Run with --fix to apply automated remediation");
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static void RenderTableOutput(List<DiagnosticResult> results)
|
||||
{
|
||||
Console.WriteLine($"{"Check",-30} {"Category",-15} {"Status",-10} {"Message"}");
|
||||
Console.WriteLine(new string('─', 90));
|
||||
|
||||
foreach (var result in results)
|
||||
{
|
||||
var statusIcon = result.Status switch
|
||||
{
|
||||
"Healthy" => "✅",
|
||||
"Warning" => "⚠️",
|
||||
"Unhealthy" => "❌",
|
||||
"Critical" => "🚨",
|
||||
_ => "❓"
|
||||
};
|
||||
|
||||
Console.WriteLine($"{result.CheckName,-30} {result.Category,-15} {statusIcon,-10} {result.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ApplyFixesAsync(List<DiagnosticResult> results)
|
||||
{
|
||||
var fixableResults = results.Where(r =>
|
||||
r.Status != "Healthy" && r.AutomatedFix != null).ToList();
|
||||
|
||||
foreach (var result in fixableResults)
|
||||
{
|
||||
Console.WriteLine($" Fixing: {result.CheckName}...");
|
||||
await Task.Delay(500); // Simulate fix
|
||||
Console.WriteLine($" ✅ Fixed: {result.AutomatedFix}");
|
||||
}
|
||||
|
||||
if (fixableResults.Count == 0)
|
||||
{
|
||||
Console.WriteLine(" No automated fixes available for detected issues.");
|
||||
Console.WriteLine(" See remediation steps below for manual resolution.");
|
||||
}
|
||||
}
|
||||
|
||||
private static List<DiagnosticResult> GetMockDiagnosticResults(string? categoryFilter)
|
||||
{
|
||||
var results = new List<DiagnosticResult>
|
||||
{
|
||||
new()
|
||||
{
|
||||
CheckName = "CertificateExpiry",
|
||||
Category = "Security",
|
||||
Status = "Healthy",
|
||||
Message = "Certificate valid for 45 days"
|
||||
},
|
||||
new()
|
||||
{
|
||||
CheckName = "OrchestratorConnectivity",
|
||||
Category = "Network",
|
||||
Status = "Healthy",
|
||||
Message = "Connected to orchestrator"
|
||||
},
|
||||
new()
|
||||
{
|
||||
CheckName = "DockerConnectivity",
|
||||
Category = "Runtime",
|
||||
Status = "Healthy",
|
||||
Message = "Docker daemon accessible"
|
||||
},
|
||||
new()
|
||||
{
|
||||
CheckName = "DiskSpace",
|
||||
Category = "Resources",
|
||||
Status = "Warning",
|
||||
Message = "Disk space low: 5.2 GB available",
|
||||
AutomatedFix = "docker system prune"
|
||||
},
|
||||
new()
|
||||
{
|
||||
CheckName = "MemoryUsage",
|
||||
Category = "Resources",
|
||||
Status = "Healthy",
|
||||
Message = "Memory usage: 42%"
|
||||
},
|
||||
new()
|
||||
{
|
||||
CheckName = "ConfigurationDrift",
|
||||
Category = "Configuration",
|
||||
Status = "Healthy",
|
||||
Message = "No configuration drift detected"
|
||||
},
|
||||
new()
|
||||
{
|
||||
CheckName = "HeartbeatFreshness",
|
||||
Category = "Network",
|
||||
Status = "Healthy",
|
||||
Message = "Last heartbeat: 15s ago"
|
||||
}
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(categoryFilter))
|
||||
{
|
||||
results = results
|
||||
.Where(r => r.Category.Equals(categoryFilter, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private sealed record DiagnosticResult
|
||||
{
|
||||
public required string CheckName { get; init; }
|
||||
public required string Category { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public required string Message { get; init; }
|
||||
public string? AutomatedFix { get; init; }
|
||||
}
|
||||
}
|
||||
160
src/Cli/StellaOps.Cli/Commands/Agent/UpdateCommands.cs
Normal file
160
src/Cli/StellaOps.Cli/Commands/Agent/UpdateCommands.cs
Normal file
@@ -0,0 +1,160 @@
|
||||
// Copyright (c) Stella Ops. All rights reserved. SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
using System.CommandLine;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// CLI commands for agent updates.
|
||||
/// </summary>
|
||||
public static class UpdateCommands
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates the 'agent update' command.
|
||||
/// </summary>
|
||||
public static Command CreateUpdateCommand()
|
||||
{
|
||||
var command = new Command("update", "Check and apply agent updates");
|
||||
|
||||
var versionOption = new Option<string?>(
|
||||
["--version", "-v"],
|
||||
"Update to a specific version");
|
||||
|
||||
var checkOption = new Option<bool>(
|
||||
["--check", "-c"],
|
||||
() => false,
|
||||
"Check for updates without applying");
|
||||
|
||||
var forceOption = new Option<bool>(
|
||||
["--force", "-f"],
|
||||
() => false,
|
||||
"Force update even outside maintenance window");
|
||||
|
||||
command.AddOption(versionOption);
|
||||
command.AddOption(checkOption);
|
||||
command.AddOption(forceOption);
|
||||
|
||||
command.SetHandler(async (version, check, force) =>
|
||||
{
|
||||
await HandleUpdateAsync(version, check, force);
|
||||
}, versionOption, checkOption, forceOption);
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the 'agent rollback' command.
|
||||
/// </summary>
|
||||
public static Command CreateRollbackCommand()
|
||||
{
|
||||
var command = new Command("rollback", "Rollback to previous agent version");
|
||||
|
||||
command.SetHandler(async () =>
|
||||
{
|
||||
await HandleRollbackAsync();
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static async Task HandleUpdateAsync(string? version, bool checkOnly, bool force)
|
||||
{
|
||||
Console.WriteLine("🔄 Agent Update");
|
||||
Console.WriteLine("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
||||
Console.WriteLine();
|
||||
|
||||
// Check current version
|
||||
var currentVersion = "1.2.3";
|
||||
Console.WriteLine($"Current version: {currentVersion}");
|
||||
|
||||
// Check for updates
|
||||
Console.WriteLine("🔍 Checking for updates...");
|
||||
await Task.Delay(500);
|
||||
|
||||
var availableVersion = version ?? "1.3.0";
|
||||
var isNewer = string.Compare(availableVersion, currentVersion, StringComparison.Ordinal) > 0;
|
||||
|
||||
if (!isNewer && string.IsNullOrEmpty(version))
|
||||
{
|
||||
Console.WriteLine("✅ Already running the latest version");
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine($"Available version: {availableVersion}");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Release notes:");
|
||||
Console.WriteLine(" - Improved Docker container health monitoring");
|
||||
Console.WriteLine(" - Fixed certificate renewal edge case");
|
||||
Console.WriteLine(" - Performance improvements for task execution");
|
||||
Console.WriteLine();
|
||||
|
||||
if (checkOnly)
|
||||
{
|
||||
Console.WriteLine("ℹ️ Check-only mode. Run without --check to apply update.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check maintenance window (simulated)
|
||||
var inMaintenanceWindow = true;
|
||||
if (!inMaintenanceWindow && !force)
|
||||
{
|
||||
Console.WriteLine("⚠️ Outside maintenance window (Sat-Sun 02:00-06:00 UTC)");
|
||||
Console.WriteLine(" Use --force to update anyway");
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine("📥 Downloading update package...");
|
||||
await Task.Delay(800);
|
||||
|
||||
Console.WriteLine("🔐 Verifying package signature...");
|
||||
await Task.Delay(300);
|
||||
Console.WriteLine("✅ Signature verified");
|
||||
|
||||
Console.WriteLine("💾 Creating rollback point...");
|
||||
await Task.Delay(200);
|
||||
|
||||
Console.WriteLine("⏸️ Draining active tasks...");
|
||||
await Task.Delay(500);
|
||||
|
||||
Console.WriteLine("📦 Applying update...");
|
||||
await Task.Delay(1000);
|
||||
|
||||
Console.WriteLine("🔍 Verifying agent health...");
|
||||
await Task.Delay(500);
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("✅ Update completed successfully!");
|
||||
Console.WriteLine($" {currentVersion} → {availableVersion}");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("💡 Run 'stella agent rollback' if you encounter issues");
|
||||
}
|
||||
|
||||
private static async Task HandleRollbackAsync()
|
||||
{
|
||||
Console.WriteLine("🔄 Agent Rollback");
|
||||
Console.WriteLine("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
||||
Console.WriteLine();
|
||||
|
||||
Console.WriteLine("🔍 Finding rollback points...");
|
||||
await Task.Delay(300);
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Available rollback points:");
|
||||
Console.WriteLine(" 1. v1.2.3 (2026-01-16 14:30 UTC) - before update to 1.3.0");
|
||||
Console.WriteLine(" 2. v1.2.2 (2026-01-10 08:15 UTC) - before update to 1.2.3");
|
||||
Console.WriteLine();
|
||||
|
||||
Console.WriteLine("⏸️ Draining active tasks...");
|
||||
await Task.Delay(300);
|
||||
|
||||
Console.WriteLine("📦 Restoring previous version...");
|
||||
await Task.Delay(800);
|
||||
|
||||
Console.WriteLine("🔍 Verifying agent health...");
|
||||
await Task.Delay(400);
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("✅ Rollback completed successfully!");
|
||||
Console.WriteLine(" Restored to version: 1.2.3");
|
||||
}
|
||||
}
|
||||
370
src/Cli/StellaOps.Cli/Commands/DeployCommandHandler.cs
Normal file
370
src/Cli/StellaOps.Cli/Commands/DeployCommandHandler.cs
Normal file
@@ -0,0 +1,370 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// DeployCommandHandler.cs
|
||||
// Sprint: SPRINT_20260117_037_ReleaseOrchestrator_developer_experience
|
||||
// Task: TASK-037-04 - Deployment Commands (deploy, status, logs, rollback)
|
||||
// Description: Full implementation of deployment CLI commands
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Handles all deployment-related CLI commands.
|
||||
/// </summary>
|
||||
public sealed class DeployCommandHandler
|
||||
{
|
||||
private readonly IStellaApiClient _apiClient;
|
||||
private readonly IOutputFormatter _formatter;
|
||||
|
||||
public DeployCommandHandler(IStellaApiClient apiClient, IOutputFormatter formatter)
|
||||
{
|
||||
_apiClient = apiClient;
|
||||
_formatter = formatter;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts a deployment.
|
||||
/// </summary>
|
||||
public async Task StartAsync(string release, string target, string strategy, bool dryRun)
|
||||
{
|
||||
if (dryRun)
|
||||
{
|
||||
_formatter.WriteInfo($"[DRY RUN] Simulating deployment of {release} to {target}...");
|
||||
}
|
||||
else
|
||||
{
|
||||
_formatter.WriteInfo($"Starting deployment of {release} to {target}...");
|
||||
}
|
||||
|
||||
var request = new StartDeploymentRequest
|
||||
{
|
||||
ReleaseId = release,
|
||||
TargetEnvironment = target,
|
||||
Strategy = strategy,
|
||||
DryRun = dryRun
|
||||
};
|
||||
|
||||
var response = await _apiClient.PostAsync<StartDeploymentRequest, DeploymentResponse>(
|
||||
"/api/v1/deployments", request);
|
||||
|
||||
if (dryRun)
|
||||
{
|
||||
_formatter.WriteSuccess($"Dry run completed. No changes made.");
|
||||
PrintDryRunResults(response);
|
||||
}
|
||||
else
|
||||
{
|
||||
_formatter.WriteSuccess($"Deployment started: {response.Id}");
|
||||
_formatter.WriteInfo("\nWatch progress with:");
|
||||
Console.WriteLine($" stella deploy status {response.Id} --watch");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the status of a deployment.
|
||||
/// </summary>
|
||||
public async Task StatusAsync(string deploymentId, bool watch)
|
||||
{
|
||||
if (watch)
|
||||
{
|
||||
await WatchDeploymentAsync(deploymentId);
|
||||
return;
|
||||
}
|
||||
|
||||
var deployment = await _apiClient.GetAsync<DeploymentDetailResponse>(
|
||||
$"/api/v1/deployments/{deploymentId}");
|
||||
|
||||
PrintDeploymentDetail(deployment);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Streams deployment logs.
|
||||
/// </summary>
|
||||
public async Task LogsAsync(string deploymentId, bool follow, int tail)
|
||||
{
|
||||
if (follow)
|
||||
{
|
||||
await StreamLogsAsync(deploymentId);
|
||||
return;
|
||||
}
|
||||
|
||||
var logs = await _apiClient.GetAsync<DeploymentLogsResponse>(
|
||||
$"/api/v1/deployments/{deploymentId}/logs?tail={tail}");
|
||||
|
||||
foreach (var entry in logs.Entries)
|
||||
{
|
||||
PrintLogEntry(entry);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rolls back a deployment.
|
||||
/// </summary>
|
||||
public async Task RollbackAsync(string deploymentId, string? reason)
|
||||
{
|
||||
_formatter.WriteWarning($"Rolling back deployment {deploymentId}...");
|
||||
|
||||
var request = new RollbackDeploymentRequest
|
||||
{
|
||||
Reason = reason
|
||||
};
|
||||
|
||||
var response = await _apiClient.PostAsync<RollbackDeploymentRequest, DeploymentResponse>(
|
||||
$"/api/v1/deployments/{deploymentId}/rollback", request);
|
||||
|
||||
_formatter.WriteSuccess($"Rollback initiated. New deployment: {response.Id}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists deployments with optional filters.
|
||||
/// </summary>
|
||||
public async Task ListAsync(string? env, bool active)
|
||||
{
|
||||
var queryParams = new List<string>();
|
||||
if (env is not null) queryParams.Add($"environment={env}");
|
||||
if (active) queryParams.Add("active=true");
|
||||
|
||||
var query = queryParams.Any() ? "?" + string.Join("&", queryParams) : "";
|
||||
|
||||
var deployments = await _apiClient.GetAsync<List<DeploymentResponse>>($"/api/v1/deployments{query}");
|
||||
|
||||
if (deployments.Count == 0)
|
||||
{
|
||||
_formatter.WriteInfo("No deployments found.");
|
||||
return;
|
||||
}
|
||||
|
||||
_formatter.WriteTable(deployments,
|
||||
("ID", d => d.Id),
|
||||
("Release", d => d.ReleaseId),
|
||||
("Version", d => d.Version),
|
||||
("Target", d => d.TargetEnvironment),
|
||||
("Strategy", d => d.Strategy),
|
||||
("Status", d => d.Status),
|
||||
("Started", d => d.StartedAt.ToString("g")));
|
||||
}
|
||||
|
||||
private void PrintDeploymentDetail(DeploymentDetailResponse deployment)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"Deployment: {deployment.Id}");
|
||||
Console.WriteLine($"Release: {deployment.ReleaseId}");
|
||||
Console.WriteLine($"Version: {deployment.Version}");
|
||||
Console.WriteLine($"Target: {deployment.TargetEnvironment}");
|
||||
Console.WriteLine($"Strategy: {deployment.Strategy}");
|
||||
Console.WriteLine($"Status: {deployment.Status}");
|
||||
Console.WriteLine($"Started: {deployment.StartedAt:g}");
|
||||
|
||||
if (deployment.CompletedAt.HasValue)
|
||||
{
|
||||
var duration = deployment.CompletedAt.Value - deployment.StartedAt;
|
||||
Console.WriteLine($"Completed: {deployment.CompletedAt:g} (took {duration.TotalMinutes:F1} min)");
|
||||
}
|
||||
|
||||
if (deployment.Replicas is not null)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Replica Status:");
|
||||
Console.WriteLine($" Total: {deployment.Replicas.Total}");
|
||||
Console.WriteLine($" Ready: {deployment.Replicas.Ready}");
|
||||
Console.WriteLine($" Updated: {deployment.Replicas.Updated}");
|
||||
Console.WriteLine($" Available: {deployment.Replicas.Available}");
|
||||
}
|
||||
|
||||
if (deployment.Instances.Any())
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Instances:");
|
||||
_formatter.WriteTable(deployment.Instances,
|
||||
("Host", i => i.Host),
|
||||
("Status", i => i.Status),
|
||||
("Version", i => i.Version),
|
||||
("Health", i => i.HealthStatus));
|
||||
}
|
||||
|
||||
if (deployment.Events.Any())
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Recent Events:");
|
||||
foreach (var evt in deployment.Events.TakeLast(10))
|
||||
{
|
||||
Console.WriteLine($" [{evt.Timestamp:HH:mm:ss}] {evt.Type}: {evt.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void PrintDryRunResults(DeploymentResponse response)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Changes that would be made:");
|
||||
Console.WriteLine($" - Deploy version: {response.Version}");
|
||||
Console.WriteLine($" - Target environment: {response.TargetEnvironment}");
|
||||
Console.WriteLine($" - Strategy: {response.Strategy}");
|
||||
Console.WriteLine($" - Affected instances: (simulated)");
|
||||
}
|
||||
|
||||
private void PrintLogEntry(LogEntry entry)
|
||||
{
|
||||
Console.ForegroundColor = entry.Level switch
|
||||
{
|
||||
"Error" => ConsoleColor.Red,
|
||||
"Warning" => ConsoleColor.Yellow,
|
||||
"Info" => ConsoleColor.White,
|
||||
_ => ConsoleColor.Gray
|
||||
};
|
||||
|
||||
Console.WriteLine($"[{entry.Timestamp:HH:mm:ss}] [{entry.Source}] {entry.Message}");
|
||||
Console.ResetColor();
|
||||
}
|
||||
|
||||
private async Task WatchDeploymentAsync(string deploymentId)
|
||||
{
|
||||
Console.WriteLine("Watching deployment status (Ctrl+C to stop)...\n");
|
||||
|
||||
string? lastStatus = null;
|
||||
int lastProgress = -1;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var deployment = await _apiClient.GetAsync<DeploymentDetailResponse>(
|
||||
$"/api/v1/deployments/{deploymentId}");
|
||||
|
||||
if (deployment.Status != lastStatus || deployment.Progress != lastProgress)
|
||||
{
|
||||
Console.Write($"\r[{DateTime.Now:HH:mm:ss}] Status: {deployment.Status}");
|
||||
|
||||
if (deployment.Progress.HasValue)
|
||||
{
|
||||
var progressBar = new string('█', deployment.Progress.Value / 5) +
|
||||
new string('░', 20 - deployment.Progress.Value / 5);
|
||||
Console.Write($" [{progressBar}] {deployment.Progress}%");
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
|
||||
lastStatus = deployment.Status;
|
||||
lastProgress = deployment.Progress ?? -1;
|
||||
}
|
||||
|
||||
if (deployment.Status is "Completed" or "Failed" or "RolledBack")
|
||||
{
|
||||
Console.WriteLine();
|
||||
if (deployment.Status == "Completed")
|
||||
{
|
||||
_formatter.WriteSuccess("Deployment completed successfully!");
|
||||
}
|
||||
else
|
||||
{
|
||||
_formatter.WriteError($"Deployment ended with status: {deployment.Status}");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
await Task.Delay(2000);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task StreamLogsAsync(string deploymentId)
|
||||
{
|
||||
Console.WriteLine("Streaming logs (Ctrl+C to stop)...\n");
|
||||
|
||||
DateTimeOffset? lastTimestamp = null;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var query = lastTimestamp.HasValue
|
||||
? $"?since={lastTimestamp.Value:O}"
|
||||
: "?tail=10";
|
||||
|
||||
var logs = await _apiClient.GetAsync<DeploymentLogsResponse>(
|
||||
$"/api/v1/deployments/{deploymentId}/logs{query}");
|
||||
|
||||
foreach (var entry in logs.Entries)
|
||||
{
|
||||
PrintLogEntry(entry);
|
||||
lastTimestamp = entry.Timestamp;
|
||||
}
|
||||
|
||||
await Task.Delay(1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#region DTOs
|
||||
|
||||
public sealed record StartDeploymentRequest
|
||||
{
|
||||
public required string ReleaseId { get; init; }
|
||||
public required string TargetEnvironment { get; init; }
|
||||
public required string Strategy { get; init; }
|
||||
public bool DryRun { get; init; }
|
||||
}
|
||||
|
||||
public sealed record RollbackDeploymentRequest
|
||||
{
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
public sealed record DeploymentResponse
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string ReleaseId { get; init; }
|
||||
public required string Version { get; init; }
|
||||
public required string TargetEnvironment { get; init; }
|
||||
public required string Strategy { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public required DateTimeOffset StartedAt { get; init; }
|
||||
}
|
||||
|
||||
public sealed record DeploymentDetailResponse
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string ReleaseId { get; init; }
|
||||
public required string Version { get; init; }
|
||||
public required string TargetEnvironment { get; init; }
|
||||
public required string Strategy { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public required DateTimeOffset StartedAt { get; init; }
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
public int? Progress { get; init; }
|
||||
public ReplicaStatus? Replicas { get; init; }
|
||||
public List<InstanceStatus> Instances { get; init; } = [];
|
||||
public List<DeploymentEvent> Events { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed record ReplicaStatus
|
||||
{
|
||||
public int Total { get; init; }
|
||||
public int Ready { get; init; }
|
||||
public int Updated { get; init; }
|
||||
public int Available { get; init; }
|
||||
}
|
||||
|
||||
public sealed record InstanceStatus
|
||||
{
|
||||
public required string Host { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public required string Version { get; init; }
|
||||
public required string HealthStatus { get; init; }
|
||||
}
|
||||
|
||||
public sealed record DeploymentEvent
|
||||
{
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
public required string Type { get; init; }
|
||||
public required string Message { get; init; }
|
||||
}
|
||||
|
||||
public sealed record DeploymentLogsResponse
|
||||
{
|
||||
public List<LogEntry> Entries { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed record LogEntry
|
||||
{
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
public required string Level { get; init; }
|
||||
public required string Source { get; init; }
|
||||
public required string Message { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
311
src/Cli/StellaOps.Cli/Commands/PromoteCommandHandler.cs
Normal file
311
src/Cli/StellaOps.Cli/Commands/PromoteCommandHandler.cs
Normal file
@@ -0,0 +1,311 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PromoteCommandHandler.cs
|
||||
// Sprint: SPRINT_20260117_037_ReleaseOrchestrator_developer_experience
|
||||
// Task: TASK-037-03 - Promotion Commands (promote, status, approve, reject)
|
||||
// Description: Full implementation of promotion CLI commands
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Handles all promotion-related CLI commands.
|
||||
/// </summary>
|
||||
public sealed class PromoteCommandHandler
|
||||
{
|
||||
private readonly IStellaApiClient _apiClient;
|
||||
private readonly IOutputFormatter _formatter;
|
||||
|
||||
public PromoteCommandHandler(IStellaApiClient apiClient, IOutputFormatter formatter)
|
||||
{
|
||||
_apiClient = apiClient;
|
||||
_formatter = formatter;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts a promotion for a release to target environment.
|
||||
/// </summary>
|
||||
public async Task StartAsync(string release, string target, bool autoApprove)
|
||||
{
|
||||
_formatter.WriteInfo($"Starting promotion of {release} to {target}...");
|
||||
|
||||
var request = new StartPromotionRequest
|
||||
{
|
||||
ReleaseId = release,
|
||||
TargetEnvironment = target,
|
||||
AutoApprove = autoApprove
|
||||
};
|
||||
|
||||
var response = await _apiClient.PostAsync<StartPromotionRequest, PromotionResponse>(
|
||||
"/api/v1/promotions", request);
|
||||
|
||||
_formatter.WriteSuccess($"Promotion started: {response.Id}");
|
||||
|
||||
PrintPromotionStatus(response);
|
||||
|
||||
if (response.Status == "PendingApproval")
|
||||
{
|
||||
_formatter.WriteInfo("\nPromotion requires approval. Use:");
|
||||
Console.WriteLine($" stella promote approve {response.Id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the status of a promotion, optionally watching for updates.
|
||||
/// </summary>
|
||||
public async Task StatusAsync(string promotionId, bool watch)
|
||||
{
|
||||
if (watch)
|
||||
{
|
||||
await WatchPromotionAsync(promotionId);
|
||||
return;
|
||||
}
|
||||
|
||||
var promotion = await _apiClient.GetAsync<PromotionDetailResponse>(
|
||||
$"/api/v1/promotions/{promotionId}");
|
||||
|
||||
PrintPromotionDetail(promotion);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Approves a pending promotion.
|
||||
/// </summary>
|
||||
public async Task ApproveAsync(string promotionId, string? comment)
|
||||
{
|
||||
_formatter.WriteInfo($"Approving promotion {promotionId}...");
|
||||
|
||||
var request = new ApprovePromotionRequest
|
||||
{
|
||||
Comment = comment
|
||||
};
|
||||
|
||||
var response = await _apiClient.PostAsync<ApprovePromotionRequest, PromotionResponse>(
|
||||
$"/api/v1/promotions/{promotionId}/approve", request);
|
||||
|
||||
_formatter.WriteSuccess($"Promotion approved. Status: {response.Status}");
|
||||
|
||||
if (response.Status == "InProgress")
|
||||
{
|
||||
_formatter.WriteInfo("\nDeployment has started. Use:");
|
||||
Console.WriteLine($" stella promote status {promotionId} --watch");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rejects a pending promotion.
|
||||
/// </summary>
|
||||
public async Task RejectAsync(string promotionId, string reason)
|
||||
{
|
||||
_formatter.WriteInfo($"Rejecting promotion {promotionId}...");
|
||||
|
||||
var request = new RejectPromotionRequest
|
||||
{
|
||||
Reason = reason
|
||||
};
|
||||
|
||||
var response = await _apiClient.PostAsync<RejectPromotionRequest, PromotionResponse>(
|
||||
$"/api/v1/promotions/{promotionId}/reject", request);
|
||||
|
||||
_formatter.WriteSuccess($"Promotion rejected.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists promotions with optional filters.
|
||||
/// </summary>
|
||||
public async Task ListAsync(string? env, bool pending)
|
||||
{
|
||||
var queryParams = new List<string>();
|
||||
if (env is not null) queryParams.Add($"environment={env}");
|
||||
if (pending) queryParams.Add("status=PendingApproval");
|
||||
|
||||
var query = queryParams.Any() ? "?" + string.Join("&", queryParams) : "";
|
||||
|
||||
var promotions = await _apiClient.GetAsync<List<PromotionResponse>>($"/api/v1/promotions{query}");
|
||||
|
||||
if (promotions.Count == 0)
|
||||
{
|
||||
_formatter.WriteInfo("No promotions found.");
|
||||
return;
|
||||
}
|
||||
|
||||
_formatter.WriteTable(promotions,
|
||||
("ID", p => p.Id),
|
||||
("Release", p => p.ReleaseId),
|
||||
("Target", p => p.TargetEnvironment),
|
||||
("Status", p => p.Status),
|
||||
("Requester", p => p.RequestedBy),
|
||||
("Requested", p => p.RequestedAt.ToString("g")));
|
||||
}
|
||||
|
||||
private void PrintPromotionStatus(PromotionResponse promotion)
|
||||
{
|
||||
_formatter.WriteTable([promotion],
|
||||
("ID", p => p.Id),
|
||||
("Release", p => p.ReleaseId),
|
||||
("Target", p => p.TargetEnvironment),
|
||||
("Status", p => p.Status),
|
||||
("Requested", p => p.RequestedAt.ToString("g")));
|
||||
}
|
||||
|
||||
private void PrintPromotionDetail(PromotionDetailResponse promotion)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"Promotion: {promotion.Id}");
|
||||
Console.WriteLine($"Release: {promotion.ReleaseId}");
|
||||
Console.WriteLine($"Version: {promotion.Version}");
|
||||
Console.WriteLine($"Target: {promotion.TargetEnvironment}");
|
||||
Console.WriteLine($"Status: {promotion.Status}");
|
||||
Console.WriteLine($"Requested: {promotion.RequestedAt:g} by {promotion.RequestedBy}");
|
||||
|
||||
if (promotion.ApprovedAt.HasValue)
|
||||
{
|
||||
Console.WriteLine($"Approved: {promotion.ApprovedAt:g} by {promotion.ApprovedBy}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(promotion.RejectionReason))
|
||||
{
|
||||
Console.WriteLine($"Rejected: {promotion.RejectionReason}");
|
||||
}
|
||||
|
||||
if (promotion.PolicyResults.Any())
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Policy Results:");
|
||||
foreach (var result in promotion.PolicyResults)
|
||||
{
|
||||
var symbol = result.Passed ? "✓" : "✗";
|
||||
Console.ForegroundColor = result.Passed ? ConsoleColor.Green : ConsoleColor.Red;
|
||||
Console.WriteLine($" {symbol} {result.PolicyName}: {result.Message}");
|
||||
Console.ResetColor();
|
||||
}
|
||||
}
|
||||
|
||||
if (promotion.DeploymentSteps.Any())
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Deployment Progress:");
|
||||
foreach (var step in promotion.DeploymentSteps)
|
||||
{
|
||||
var symbol = step.Status switch
|
||||
{
|
||||
"Completed" => "✓",
|
||||
"InProgress" => "►",
|
||||
"Failed" => "✗",
|
||||
_ => "○"
|
||||
};
|
||||
Console.ForegroundColor = step.Status switch
|
||||
{
|
||||
"Completed" => ConsoleColor.Green,
|
||||
"InProgress" => ConsoleColor.Yellow,
|
||||
"Failed" => ConsoleColor.Red,
|
||||
_ => ConsoleColor.Gray
|
||||
};
|
||||
Console.Write($" {symbol} ");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine($"{step.Name} ({step.Status})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task WatchPromotionAsync(string promotionId)
|
||||
{
|
||||
Console.WriteLine("Watching promotion status (Ctrl+C to stop)...\n");
|
||||
|
||||
string? lastStatus = null;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var promotion = await _apiClient.GetAsync<PromotionDetailResponse>(
|
||||
$"/api/v1/promotions/{promotionId}");
|
||||
|
||||
if (promotion.Status != lastStatus)
|
||||
{
|
||||
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] Status: {promotion.Status}");
|
||||
lastStatus = promotion.Status;
|
||||
|
||||
// Print deployment progress
|
||||
foreach (var step in promotion.DeploymentSteps.Where(s => s.Status == "InProgress"))
|
||||
{
|
||||
Console.WriteLine($" ► {step.Name}");
|
||||
}
|
||||
}
|
||||
|
||||
if (promotion.Status is "Completed" or "Failed" or "Rejected" or "RolledBack")
|
||||
{
|
||||
Console.WriteLine();
|
||||
if (promotion.Status == "Completed")
|
||||
{
|
||||
_formatter.WriteSuccess("Promotion completed successfully!");
|
||||
}
|
||||
else
|
||||
{
|
||||
_formatter.WriteError($"Promotion ended with status: {promotion.Status}");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
await Task.Delay(2000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#region DTOs
|
||||
|
||||
public sealed record StartPromotionRequest
|
||||
{
|
||||
public required string ReleaseId { get; init; }
|
||||
public required string TargetEnvironment { get; init; }
|
||||
public bool AutoApprove { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ApprovePromotionRequest
|
||||
{
|
||||
public string? Comment { get; init; }
|
||||
}
|
||||
|
||||
public sealed record RejectPromotionRequest
|
||||
{
|
||||
public required string Reason { get; init; }
|
||||
}
|
||||
|
||||
public sealed record PromotionResponse
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string ReleaseId { get; init; }
|
||||
public required string TargetEnvironment { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public required string RequestedBy { get; init; }
|
||||
public required DateTimeOffset RequestedAt { get; init; }
|
||||
}
|
||||
|
||||
public sealed record PromotionDetailResponse
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string ReleaseId { get; init; }
|
||||
public required string Version { get; init; }
|
||||
public required string TargetEnvironment { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public required string RequestedBy { get; init; }
|
||||
public required DateTimeOffset RequestedAt { get; init; }
|
||||
public string? ApprovedBy { get; init; }
|
||||
public DateTimeOffset? ApprovedAt { get; init; }
|
||||
public string? RejectionReason { get; init; }
|
||||
public List<PolicyResult> PolicyResults { get; init; } = [];
|
||||
public List<DeploymentStep> DeploymentSteps { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed record PolicyResult
|
||||
{
|
||||
public required string PolicyName { get; init; }
|
||||
public required bool Passed { get; init; }
|
||||
public required string Message { get; init; }
|
||||
}
|
||||
|
||||
public sealed record DeploymentStep
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public DateTimeOffset? StartedAt { get; init; }
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
382
src/Cli/StellaOps.Cli/Commands/ReleaseCommandHandler.cs
Normal file
382
src/Cli/StellaOps.Cli/Commands/ReleaseCommandHandler.cs
Normal file
@@ -0,0 +1,382 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ReleaseCommandHandler.cs
|
||||
// Sprint: SPRINT_20260117_037_ReleaseOrchestrator_developer_experience
|
||||
// Task: TASK-037-02 - Release Commands (create, list, get, diff, history)
|
||||
// Description: Full implementation of release management CLI commands
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Handles all release-related CLI commands.
|
||||
/// </summary>
|
||||
public sealed class ReleaseCommandHandler
|
||||
{
|
||||
private readonly IStellaApiClient _apiClient;
|
||||
private readonly IOutputFormatter _formatter;
|
||||
|
||||
public ReleaseCommandHandler(IStellaApiClient apiClient, IOutputFormatter formatter)
|
||||
{
|
||||
_apiClient = apiClient;
|
||||
_formatter = formatter;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new release.
|
||||
/// </summary>
|
||||
public async Task CreateAsync(string service, string version, string? notes, bool draft)
|
||||
{
|
||||
_formatter.WriteInfo($"Creating release {version} for {service}...");
|
||||
|
||||
var request = new CreateReleaseRequest
|
||||
{
|
||||
ServiceName = service,
|
||||
Version = version,
|
||||
Notes = notes,
|
||||
IsDraft = draft
|
||||
};
|
||||
|
||||
var response = await _apiClient.PostAsync<CreateReleaseRequest, ReleaseResponse>(
|
||||
"/api/v1/releases", request);
|
||||
|
||||
_formatter.WriteSuccess($"Release created: {response.Id}");
|
||||
|
||||
_formatter.WriteTable([response],
|
||||
("ID", r => r.Id),
|
||||
("Service", r => r.ServiceName),
|
||||
("Version", r => r.Version),
|
||||
("Status", r => r.Status),
|
||||
("Created", r => r.CreatedAt.ToString("g")));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists releases with optional filters.
|
||||
/// </summary>
|
||||
public async Task ListAsync(string? service, int limit, string? status)
|
||||
{
|
||||
var queryParams = new List<string>();
|
||||
if (service is not null) queryParams.Add($"service={service}");
|
||||
if (status is not null) queryParams.Add($"status={status}");
|
||||
queryParams.Add($"limit={limit}");
|
||||
|
||||
var query = queryParams.Any() ? "?" + string.Join("&", queryParams) : "";
|
||||
|
||||
var releases = await _apiClient.GetAsync<List<ReleaseResponse>>($"/api/v1/releases{query}");
|
||||
|
||||
if (releases.Count == 0)
|
||||
{
|
||||
_formatter.WriteInfo("No releases found.");
|
||||
return;
|
||||
}
|
||||
|
||||
_formatter.WriteTable(releases,
|
||||
("ID", r => r.Id),
|
||||
("Service", r => r.ServiceName),
|
||||
("Version", r => r.Version),
|
||||
("Status", r => r.Status),
|
||||
("Environment", r => r.Environment ?? "-"),
|
||||
("Created", r => r.CreatedAt.ToString("g")));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets details of a specific release.
|
||||
/// </summary>
|
||||
public async Task GetAsync(string releaseId)
|
||||
{
|
||||
var release = await _apiClient.GetAsync<ReleaseDetailResponse>($"/api/v1/releases/{releaseId}");
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"Release: {release.Id}");
|
||||
Console.WriteLine($"Service: {release.ServiceName}");
|
||||
Console.WriteLine($"Version: {release.Version}");
|
||||
Console.WriteLine($"Status: {release.Status}");
|
||||
Console.WriteLine($"Created: {release.CreatedAt}");
|
||||
|
||||
if (!string.IsNullOrEmpty(release.Notes))
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Notes:");
|
||||
Console.WriteLine(release.Notes);
|
||||
}
|
||||
|
||||
if (release.ScanResults is not null)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Scan Results:");
|
||||
Console.WriteLine($" Critical: {release.ScanResults.Critical}");
|
||||
Console.WriteLine($" High: {release.ScanResults.High}");
|
||||
Console.WriteLine($" Medium: {release.ScanResults.Medium}");
|
||||
Console.WriteLine($" Low: {release.ScanResults.Low}");
|
||||
}
|
||||
|
||||
if (release.Approvals.Any())
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Approvals:");
|
||||
_formatter.WriteTable(release.Approvals,
|
||||
("Approver", a => a.ApprovedBy),
|
||||
("Status", a => a.Status),
|
||||
("Time", a => a.ApprovedAt?.ToString("g") ?? "-"));
|
||||
}
|
||||
|
||||
if (release.Evidence.Any())
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"Evidence: {release.Evidence.Count} item(s)");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows diff between two releases.
|
||||
/// </summary>
|
||||
public async Task DiffAsync(string from, string to)
|
||||
{
|
||||
var diff = await _apiClient.GetAsync<ReleaseDiffResponse>(
|
||||
$"/api/v1/releases/{from}/diff/{to}");
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"Diff: {from} → {to}");
|
||||
Console.WriteLine();
|
||||
|
||||
if (diff.ConfigChanges.Any())
|
||||
{
|
||||
Console.WriteLine("Configuration Changes:");
|
||||
foreach (var change in diff.ConfigChanges)
|
||||
{
|
||||
var symbol = change.ChangeType switch
|
||||
{
|
||||
"Added" => "+",
|
||||
"Removed" => "-",
|
||||
"Modified" => "~",
|
||||
_ => "?"
|
||||
};
|
||||
Console.ForegroundColor = change.ChangeType switch
|
||||
{
|
||||
"Added" => ConsoleColor.Green,
|
||||
"Removed" => ConsoleColor.Red,
|
||||
"Modified" => ConsoleColor.Yellow,
|
||||
_ => ConsoleColor.Gray
|
||||
};
|
||||
Console.WriteLine($" {symbol} {change.Key}");
|
||||
Console.ResetColor();
|
||||
}
|
||||
}
|
||||
|
||||
if (diff.DependencyChanges.Any())
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Dependency Changes:");
|
||||
_formatter.WriteTable(diff.DependencyChanges,
|
||||
("Package", d => d.Package),
|
||||
("From", d => d.FromVersion ?? "-"),
|
||||
("To", d => d.ToVersion ?? "-"),
|
||||
("Type", d => d.ChangeType));
|
||||
}
|
||||
|
||||
if (diff.VulnerabilityChanges.Any())
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Vulnerability Changes:");
|
||||
_formatter.WriteTable(diff.VulnerabilityChanges,
|
||||
("CVE", v => v.CveId),
|
||||
("Severity", v => v.Severity),
|
||||
("Status", v => v.Status));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows release history for a service.
|
||||
/// </summary>
|
||||
public async Task HistoryAsync(string service)
|
||||
{
|
||||
var history = await _apiClient.GetAsync<List<ReleaseHistoryEntry>>(
|
||||
$"/api/v1/services/{service}/release-history");
|
||||
|
||||
if (history.Count == 0)
|
||||
{
|
||||
_formatter.WriteInfo($"No release history for {service}.");
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine($"\nRelease history for {service}:\n");
|
||||
|
||||
foreach (var entry in history.Take(20))
|
||||
{
|
||||
var statusColor = entry.Status switch
|
||||
{
|
||||
"Deployed" => ConsoleColor.Green,
|
||||
"Failed" => ConsoleColor.Red,
|
||||
"RolledBack" => ConsoleColor.Yellow,
|
||||
_ => ConsoleColor.Gray
|
||||
};
|
||||
|
||||
Console.Write($" {entry.Timestamp:yyyy-MM-dd HH:mm} ");
|
||||
Console.ForegroundColor = statusColor;
|
||||
Console.Write($"{entry.Status,-12}");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine($" {entry.Version,-15} {entry.Environment}");
|
||||
|
||||
if (!string.IsNullOrEmpty(entry.Notes))
|
||||
{
|
||||
Console.WriteLine($" {entry.Notes}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#region API Client
|
||||
|
||||
public interface IStellaApiClient
|
||||
{
|
||||
Task<T> GetAsync<T>(string path);
|
||||
Task<TResponse> PostAsync<TRequest, TResponse>(string path, TRequest request);
|
||||
Task DeleteAsync(string path);
|
||||
}
|
||||
|
||||
public sealed class StellaApiClient : IStellaApiClient
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly CliConfig _config;
|
||||
|
||||
public StellaApiClient(HttpClient httpClient, CliConfig config)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_config = config;
|
||||
|
||||
_httpClient.BaseAddress = new Uri(config.ServerUrl);
|
||||
if (!string.IsNullOrEmpty(config.AccessToken))
|
||||
{
|
||||
_httpClient.DefaultRequestHeaders.Authorization =
|
||||
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", config.AccessToken);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<T> GetAsync<T>(string path)
|
||||
{
|
||||
var response = await _httpClient.GetAsync(path);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return (await response.Content.ReadFromJsonAsync<T>())!;
|
||||
}
|
||||
|
||||
public async Task<TResponse> PostAsync<TRequest, TResponse>(string path, TRequest request)
|
||||
{
|
||||
var response = await _httpClient.PostAsJsonAsync(path, request);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return (await response.Content.ReadFromJsonAsync<TResponse>())!;
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string path)
|
||||
{
|
||||
var response = await _httpClient.DeleteAsync(path);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DTOs
|
||||
|
||||
public sealed record CreateReleaseRequest
|
||||
{
|
||||
public required string ServiceName { get; init; }
|
||||
public required string Version { get; init; }
|
||||
public string? Notes { get; init; }
|
||||
public bool IsDraft { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ReleaseResponse
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string ServiceName { get; init; }
|
||||
public required string Version { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public string? Environment { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ReleaseDetailResponse
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string ServiceName { get; init; }
|
||||
public required string Version { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public string? Notes { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
public ScanResultSummary? ScanResults { get; init; }
|
||||
public List<ApprovalInfo> Approvals { get; init; } = [];
|
||||
public List<EvidenceInfo> Evidence { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed record ScanResultSummary
|
||||
{
|
||||
public int Critical { get; init; }
|
||||
public int High { get; init; }
|
||||
public int Medium { get; init; }
|
||||
public int Low { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ApprovalInfo
|
||||
{
|
||||
public required string ApprovedBy { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public DateTimeOffset? ApprovedAt { get; init; }
|
||||
}
|
||||
|
||||
public sealed record EvidenceInfo
|
||||
{
|
||||
public required string Type { get; init; }
|
||||
public required string Hash { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ReleaseDiffResponse
|
||||
{
|
||||
public List<ConfigChange> ConfigChanges { get; init; } = [];
|
||||
public List<DependencyChange> DependencyChanges { get; init; } = [];
|
||||
public List<VulnerabilityChange> VulnerabilityChanges { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed record ConfigChange
|
||||
{
|
||||
public required string Key { get; init; }
|
||||
public required string ChangeType { get; init; }
|
||||
public string? OldValue { get; init; }
|
||||
public string? NewValue { get; init; }
|
||||
}
|
||||
|
||||
public sealed record DependencyChange
|
||||
{
|
||||
public required string Package { get; init; }
|
||||
public string? FromVersion { get; init; }
|
||||
public string? ToVersion { get; init; }
|
||||
public required string ChangeType { get; init; }
|
||||
}
|
||||
|
||||
public sealed record VulnerabilityChange
|
||||
{
|
||||
public required string CveId { get; init; }
|
||||
public required string Severity { get; init; }
|
||||
public required string Status { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ReleaseHistoryEntry
|
||||
{
|
||||
public required string Version { get; init; }
|
||||
public required string Environment { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
public string? Notes { get; init; }
|
||||
}
|
||||
|
||||
public sealed record CliConfig
|
||||
{
|
||||
public string ServerUrl { get; set; } = "https://localhost:5001";
|
||||
public string? AccessToken { get; set; }
|
||||
public string? RefreshToken { get; set; }
|
||||
public DateTimeOffset? TokenExpiry { get; set; }
|
||||
public string OutputFormat { get; set; } = "table";
|
||||
}
|
||||
|
||||
#endregion
|
||||
Reference in New Issue
Block a user