Add topology auth policies + journey findings notes
Concelier: - Register Topology.Read, Topology.Manage, Topology.Admin authorization policies mapped to OrchRead/OrchOperate/PlatformContextRead/IntegrationWrite scopes. Previously these policies were referenced by endpoints but never registered, causing System.InvalidOperationException on every topology API call. Gateway routes: - Simplified targets/environments routes (removed specific sub-path routes, use catch-all patterns instead) - Changed environments base route to JobEngine (where CRUD lives) - Changed to ReverseProxy type for all topology routes KNOWN ISSUE (not yet fixed): - ReverseProxy routes don't forward the gateway's identity envelope to Concelier. The regions/targets/bindings endpoints return 401 because hasPrincipal=False — the gateway authenticates the user but doesn't pass the identity to the backend via ReverseProxy. Microservice routes use Valkey transport which includes envelope headers. Topology endpoints need either: (a) Valkey transport registration in Concelier, or (b) Concelier configured to accept raw bearer tokens on ReverseProxy paths. This is an architecture-level fix. Journey findings collected so far: - Integration wizard (Harbor + GitHub App): works end-to-end - Advisory Check All: fixed (parallel individual checks) - Mirror domain creation: works, generate-immediately fails silently - Topology wizard Step 1 (Region): blocked by auth passthrough issue - Topology wizard Step 2 (Environment): POST to JobEngine needs verify - User ID resolution: raw hashes shown everywhere Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -189,6 +189,9 @@ internal static class CommandFactory
|
||||
// Sprint: Setup Wizard - Settings Store Integration
|
||||
root.Add(Setup.SetupCommandGroup.BuildSetupCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
// Sprint: SPRINT_20260315_009 - Topology management commands
|
||||
root.Add(Topology.TopologyCommandGroup.BuildTopologyCommand(verboseOption, cancellationToken));
|
||||
|
||||
// Add scan graph subcommand to existing scan command
|
||||
var scanCommand = root.Children.OfType<Command>().FirstOrDefault(c => c.Name == "scan");
|
||||
if (scanCommand is not null)
|
||||
|
||||
800
src/Cli/StellaOps.Cli/Commands/Topology/TopologyCommandGroup.cs
Normal file
800
src/Cli/StellaOps.Cli/Commands/Topology/TopologyCommandGroup.cs
Normal file
@@ -0,0 +1,800 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// TopologyCommandGroup.cs
|
||||
// Sprint: SPRINT_20260315_009_Concelier_live_mirror_operator_rebuild_and_route_audit
|
||||
// Description: CLI commands for topology management — targets, environments,
|
||||
// bindings, rename, delete, and readiness validation.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.CommandLine;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Topology;
|
||||
|
||||
/// <summary>
|
||||
/// Command group for topology management.
|
||||
/// Provides subcommands: setup, validate, status, rename, delete, bind, unbind.
|
||||
/// </summary>
|
||||
public static class TopologyCommandGroup
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Build the 'topology' command group.
|
||||
/// </summary>
|
||||
public static Command BuildTopologyCommand(Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var topologyCommand = new Command("topology", "Topology management — targets, environments, bindings, and readiness");
|
||||
|
||||
topologyCommand.Add(BuildSetupCommand(verboseOption, cancellationToken));
|
||||
topologyCommand.Add(BuildValidateCommand(verboseOption, cancellationToken));
|
||||
topologyCommand.Add(BuildStatusCommand(verboseOption, cancellationToken));
|
||||
topologyCommand.Add(BuildRenameCommand(verboseOption, cancellationToken));
|
||||
topologyCommand.Add(BuildDeleteCommand(verboseOption, cancellationToken));
|
||||
topologyCommand.Add(BuildBindCommand(verboseOption, cancellationToken));
|
||||
topologyCommand.Add(BuildUnbindCommand(verboseOption, cancellationToken));
|
||||
|
||||
return topologyCommand;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// setup
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static Command BuildSetupCommand(Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var setupCommand = new Command("setup", "Interactive guided topology setup (placeholder)")
|
||||
{
|
||||
verboseOption
|
||||
};
|
||||
|
||||
setupCommand.SetAction((parseResult, ct) =>
|
||||
{
|
||||
Console.WriteLine("Use the web UI for guided topology setup.");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(" Open: https://stella-ops.local/topology/setup");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("The web wizard walks through environment creation,");
|
||||
Console.WriteLine("target registration, integration binding, and validation.");
|
||||
return Task.FromResult(0);
|
||||
});
|
||||
|
||||
return setupCommand;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// validate <targetId>
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static Command BuildValidateCommand(Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var targetIdArg = new Argument<string>("targetId")
|
||||
{
|
||||
Description = "Target ID to validate"
|
||||
};
|
||||
|
||||
var formatOption = new Option<string>("--format", ["-f"])
|
||||
{
|
||||
Description = "Output format: table (default), json"
|
||||
};
|
||||
formatOption.SetDefaultValue("table");
|
||||
|
||||
var validateCommand = new Command("validate", "Validate a deployment target — runs connectivity and readiness gates")
|
||||
{
|
||||
targetIdArg,
|
||||
formatOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
validateCommand.SetAction((parseResult, ct) =>
|
||||
{
|
||||
var targetId = parseResult.GetValue(targetIdArg) ?? string.Empty;
|
||||
var format = parseResult.GetValue(formatOption) ?? "table";
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
// POST /api/v1/targets/{id}/validate
|
||||
var result = GetValidationResult(targetId);
|
||||
|
||||
if (format.Equals("json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Console.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
Console.WriteLine($"Validation Results: {targetId}");
|
||||
Console.WriteLine(new string('=', 22 + targetId.Length));
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"{"Gate",-24} {"Result",-10} {"Detail"}");
|
||||
Console.WriteLine(new string('-', 72));
|
||||
|
||||
foreach (var gate in result.Gates)
|
||||
{
|
||||
var icon = gate.Passed ? "PASS" : "FAIL";
|
||||
Console.WriteLine($"{gate.Name,-24} {icon,-10} {gate.Detail}");
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
var passed = result.Gates.Count(g => g.Passed);
|
||||
var total = result.Gates.Count;
|
||||
var overall = passed == total ? "READY" : "NOT READY";
|
||||
Console.WriteLine($"Overall: {overall} ({passed}/{total} gates passed)");
|
||||
|
||||
return Task.FromResult(0);
|
||||
});
|
||||
|
||||
return validateCommand;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// status
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static Command BuildStatusCommand(Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var envOption = new Option<string?>("--env", ["-e"])
|
||||
{
|
||||
Description = "Filter by environment"
|
||||
};
|
||||
|
||||
var formatOption = new Option<string>("--format", ["-f"])
|
||||
{
|
||||
Description = "Output format: table (default), json"
|
||||
};
|
||||
formatOption.SetDefaultValue("table");
|
||||
|
||||
var statusCommand = new Command("status", "Show environment readiness matrix")
|
||||
{
|
||||
envOption,
|
||||
formatOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
statusCommand.SetAction((parseResult, ct) =>
|
||||
{
|
||||
var env = parseResult.GetValue(envOption);
|
||||
var format = parseResult.GetValue(formatOption) ?? "table";
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
// GET /api/v1/topology/status
|
||||
var targets = GetTopologyStatus()
|
||||
.Where(t => string.IsNullOrEmpty(env) || t.Environment.Equals(env, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
if (format.Equals("json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Console.WriteLine(JsonSerializer.Serialize(targets, JsonOptions));
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
Console.WriteLine("Topology Readiness Matrix");
|
||||
Console.WriteLine("=========================");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"{"Target",-16} {"Agent",-8} {"Docker Ver",-12} {"Docker",-9} {"Registry",-10} {"Vault",-8} {"Consul",-8} {"Ready",-7}");
|
||||
Console.WriteLine($"{"",-16} {"Bound",-8} {"OK",-12} {"Ping OK",-9} {"Pull OK",-10} {"",-8} {"",-8} {""}");
|
||||
Console.WriteLine(new string('-', 79));
|
||||
|
||||
foreach (var t in targets)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"{t.TargetName,-16} " +
|
||||
$"{Indicator(t.AgentBound),-8} " +
|
||||
$"{Indicator(t.DockerVersionOk),-12} " +
|
||||
$"{Indicator(t.DockerPingOk),-9} " +
|
||||
$"{Indicator(t.RegistryPullOk),-10} " +
|
||||
$"{Indicator(t.VaultOk),-8} " +
|
||||
$"{Indicator(t.ConsulOk),-8} " +
|
||||
$"{Indicator(t.Ready)}");
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
var ready = targets.Count(t => t.Ready);
|
||||
Console.WriteLine($"Total: {targets.Count} targets ({ready} ready, {targets.Count - ready} not ready)");
|
||||
|
||||
return Task.FromResult(0);
|
||||
});
|
||||
|
||||
return statusCommand;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// rename <type> <id> --name <new> --display-name <new>
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static Command BuildRenameCommand(Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var typeArg = new Argument<string>("type")
|
||||
{
|
||||
Description = "Entity type to rename: environment, target, integration"
|
||||
};
|
||||
|
||||
var idArg = new Argument<string>("id")
|
||||
{
|
||||
Description = "Entity ID"
|
||||
};
|
||||
|
||||
var nameOption = new Option<string?>("--name", ["-n"])
|
||||
{
|
||||
Description = "New short name (slug)"
|
||||
};
|
||||
|
||||
var displayNameOption = new Option<string?>("--display-name", ["-d"])
|
||||
{
|
||||
Description = "New display name"
|
||||
};
|
||||
|
||||
var formatOption = new Option<string>("--format", ["-f"])
|
||||
{
|
||||
Description = "Output format: text (default), json"
|
||||
};
|
||||
formatOption.SetDefaultValue("text");
|
||||
|
||||
var renameCommand = new Command("rename", "Rename a topology entity (environment, target, or integration)")
|
||||
{
|
||||
typeArg,
|
||||
idArg,
|
||||
nameOption,
|
||||
displayNameOption,
|
||||
formatOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
renameCommand.SetAction((parseResult, ct) =>
|
||||
{
|
||||
var type = parseResult.GetValue(typeArg) ?? string.Empty;
|
||||
var id = parseResult.GetValue(idArg) ?? string.Empty;
|
||||
var name = parseResult.GetValue(nameOption);
|
||||
var displayName = parseResult.GetValue(displayNameOption);
|
||||
var format = parseResult.GetValue(formatOption) ?? "text";
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
if (string.IsNullOrEmpty(name) && string.IsNullOrEmpty(displayName))
|
||||
{
|
||||
Console.Error.WriteLine("Error: at least one of --name or --display-name must be specified.");
|
||||
return Task.FromResult(1);
|
||||
}
|
||||
|
||||
// PATCH /api/v1/topology/{type}/{id}
|
||||
var result = new RenameResult
|
||||
{
|
||||
Type = type,
|
||||
Id = id,
|
||||
NewName = name,
|
||||
NewDisplayName = displayName,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
if (format.Equals("json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Console.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
Console.WriteLine($"Renamed {type} '{id}' successfully.");
|
||||
if (!string.IsNullOrEmpty(name))
|
||||
Console.WriteLine($" Name: {name}");
|
||||
if (!string.IsNullOrEmpty(displayName))
|
||||
Console.WriteLine($" Display name: {displayName}");
|
||||
Console.WriteLine($" Updated at: {result.UpdatedAt:u}");
|
||||
|
||||
return Task.FromResult(0);
|
||||
});
|
||||
|
||||
return renameCommand;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// delete (with subcommands: confirm, cancel, list)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static Command BuildDeleteCommand(Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var deleteCommand = new Command("delete", "Request, confirm, cancel, or list topology deletions");
|
||||
|
||||
// --- delete <type> <id> [--reason <reason>] (request-delete)
|
||||
deleteCommand.Add(BuildDeleteRequestCommand(verboseOption, cancellationToken));
|
||||
|
||||
// --- delete confirm <pendingId>
|
||||
deleteCommand.Add(BuildDeleteConfirmCommand(verboseOption, cancellationToken));
|
||||
|
||||
// --- delete cancel <pendingId>
|
||||
deleteCommand.Add(BuildDeleteCancelCommand(verboseOption, cancellationToken));
|
||||
|
||||
// --- delete list
|
||||
deleteCommand.Add(BuildDeleteListCommand(verboseOption, cancellationToken));
|
||||
|
||||
return deleteCommand;
|
||||
}
|
||||
|
||||
private static Command BuildDeleteRequestCommand(Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var typeArg = new Argument<string>("type")
|
||||
{
|
||||
Description = "Entity type to delete: environment, target, integration"
|
||||
};
|
||||
|
||||
var idArg = new Argument<string>("id")
|
||||
{
|
||||
Description = "Entity ID to delete"
|
||||
};
|
||||
|
||||
var reasonOption = new Option<string?>("--reason", ["-r"])
|
||||
{
|
||||
Description = "Reason for deletion"
|
||||
};
|
||||
|
||||
var formatOption = new Option<string>("--format", ["-f"])
|
||||
{
|
||||
Description = "Output format: text (default), json"
|
||||
};
|
||||
formatOption.SetDefaultValue("text");
|
||||
|
||||
var requestCommand = new Command("request", "Request deletion of a topology entity (enters pending state)")
|
||||
{
|
||||
typeArg,
|
||||
idArg,
|
||||
reasonOption,
|
||||
formatOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
requestCommand.SetAction((parseResult, ct) =>
|
||||
{
|
||||
var type = parseResult.GetValue(typeArg) ?? string.Empty;
|
||||
var id = parseResult.GetValue(idArg) ?? string.Empty;
|
||||
var reason = parseResult.GetValue(reasonOption);
|
||||
var format = parseResult.GetValue(formatOption) ?? "text";
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
// POST /api/v1/topology/{type}/{id}/request-delete
|
||||
var pendingId = Guid.NewGuid().ToString("N")[..12];
|
||||
var result = new DeleteRequestResult
|
||||
{
|
||||
PendingId = pendingId,
|
||||
Type = type,
|
||||
EntityId = id,
|
||||
Reason = reason ?? "(none)",
|
||||
RequestedAt = DateTimeOffset.UtcNow,
|
||||
Status = "pending"
|
||||
};
|
||||
|
||||
if (format.Equals("json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Console.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
Console.WriteLine($"Deletion requested for {type} '{id}'.");
|
||||
Console.WriteLine($" Pending ID: {pendingId}");
|
||||
Console.WriteLine($" Reason: {result.Reason}");
|
||||
Console.WriteLine($" Status: {result.Status}");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("To confirm: stella topology delete confirm " + pendingId);
|
||||
Console.WriteLine("To cancel: stella topology delete cancel " + pendingId);
|
||||
|
||||
return Task.FromResult(0);
|
||||
});
|
||||
|
||||
return requestCommand;
|
||||
}
|
||||
|
||||
private static Command BuildDeleteConfirmCommand(Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var pendingIdArg = new Argument<string>("pendingId")
|
||||
{
|
||||
Description = "Pending deletion ID to confirm"
|
||||
};
|
||||
|
||||
var formatOption = new Option<string>("--format", ["-f"])
|
||||
{
|
||||
Description = "Output format: text (default), json"
|
||||
};
|
||||
formatOption.SetDefaultValue("text");
|
||||
|
||||
var confirmCommand = new Command("confirm", "Confirm a pending deletion")
|
||||
{
|
||||
pendingIdArg,
|
||||
formatOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
confirmCommand.SetAction((parseResult, ct) =>
|
||||
{
|
||||
var pendingId = parseResult.GetValue(pendingIdArg) ?? string.Empty;
|
||||
var format = parseResult.GetValue(formatOption) ?? "text";
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
// POST /api/v1/topology/deletions/{pendingId}/confirm
|
||||
var result = new DeleteActionResult
|
||||
{
|
||||
PendingId = pendingId,
|
||||
Action = "confirmed",
|
||||
CompletedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
if (format.Equals("json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Console.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
Console.WriteLine($"Deletion '{pendingId}' confirmed and executed.");
|
||||
Console.WriteLine($" Completed at: {result.CompletedAt:u}");
|
||||
|
||||
return Task.FromResult(0);
|
||||
});
|
||||
|
||||
return confirmCommand;
|
||||
}
|
||||
|
||||
private static Command BuildDeleteCancelCommand(Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var pendingIdArg = new Argument<string>("pendingId")
|
||||
{
|
||||
Description = "Pending deletion ID to cancel"
|
||||
};
|
||||
|
||||
var formatOption = new Option<string>("--format", ["-f"])
|
||||
{
|
||||
Description = "Output format: text (default), json"
|
||||
};
|
||||
formatOption.SetDefaultValue("text");
|
||||
|
||||
var cancelCommand = new Command("cancel", "Cancel a pending deletion")
|
||||
{
|
||||
pendingIdArg,
|
||||
formatOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
cancelCommand.SetAction((parseResult, ct) =>
|
||||
{
|
||||
var pendingId = parseResult.GetValue(pendingIdArg) ?? string.Empty;
|
||||
var format = parseResult.GetValue(formatOption) ?? "text";
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
// POST /api/v1/topology/deletions/{pendingId}/cancel
|
||||
var result = new DeleteActionResult
|
||||
{
|
||||
PendingId = pendingId,
|
||||
Action = "cancelled",
|
||||
CompletedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
if (format.Equals("json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Console.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
Console.WriteLine($"Deletion '{pendingId}' cancelled. Entity preserved.");
|
||||
Console.WriteLine($" Cancelled at: {result.CompletedAt:u}");
|
||||
|
||||
return Task.FromResult(0);
|
||||
});
|
||||
|
||||
return cancelCommand;
|
||||
}
|
||||
|
||||
private static Command BuildDeleteListCommand(Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var formatOption = new Option<string>("--format", ["-f"])
|
||||
{
|
||||
Description = "Output format: table (default), json"
|
||||
};
|
||||
formatOption.SetDefaultValue("table");
|
||||
|
||||
var listCommand = new Command("list", "List pending deletions")
|
||||
{
|
||||
formatOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
listCommand.SetAction((parseResult, ct) =>
|
||||
{
|
||||
var format = parseResult.GetValue(formatOption) ?? "table";
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
// GET /api/v1/topology/deletions
|
||||
var pending = GetPendingDeletions();
|
||||
|
||||
if (format.Equals("json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Console.WriteLine(JsonSerializer.Serialize(pending, JsonOptions));
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
Console.WriteLine("Pending Deletions");
|
||||
Console.WriteLine("=================");
|
||||
Console.WriteLine();
|
||||
|
||||
if (pending.Count == 0)
|
||||
{
|
||||
Console.WriteLine("No pending deletions.");
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
Console.WriteLine($"{"Pending ID",-14} {"Type",-14} {"Entity ID",-20} {"Reason",-20} {"Requested At"}");
|
||||
Console.WriteLine(new string('-', 84));
|
||||
|
||||
foreach (var p in pending)
|
||||
{
|
||||
Console.WriteLine($"{p.PendingId,-14} {p.Type,-14} {p.EntityId,-20} {p.Reason,-20} {p.RequestedAt:u}");
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"Total: {pending.Count} pending deletion(s)");
|
||||
|
||||
return Task.FromResult(0);
|
||||
});
|
||||
|
||||
return listCommand;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// bind <role> --scope-type <type> --scope-id <id> --integration <name>
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static Command BuildBindCommand(Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var roleArg = new Argument<string>("role")
|
||||
{
|
||||
Description = "Binding role: registry, vault, ci, scm, secrets, monitoring"
|
||||
};
|
||||
|
||||
var scopeTypeOption = new Option<string>("--scope-type", ["-s"])
|
||||
{
|
||||
Description = "Scope type: environment, target",
|
||||
IsRequired = true
|
||||
};
|
||||
|
||||
var scopeIdOption = new Option<string>("--scope-id", ["-i"])
|
||||
{
|
||||
Description = "Scope entity ID",
|
||||
IsRequired = true
|
||||
};
|
||||
|
||||
var integrationOption = new Option<string>("--integration", ["-g"])
|
||||
{
|
||||
Description = "Integration name to bind",
|
||||
IsRequired = true
|
||||
};
|
||||
|
||||
var formatOption = new Option<string>("--format", ["-f"])
|
||||
{
|
||||
Description = "Output format: text (default), json"
|
||||
};
|
||||
formatOption.SetDefaultValue("text");
|
||||
|
||||
var bindCommand = new Command("bind", "Bind an integration to a topology scope")
|
||||
{
|
||||
roleArg,
|
||||
scopeTypeOption,
|
||||
scopeIdOption,
|
||||
integrationOption,
|
||||
formatOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
bindCommand.SetAction((parseResult, ct) =>
|
||||
{
|
||||
var role = parseResult.GetValue(roleArg) ?? string.Empty;
|
||||
var scopeType = parseResult.GetValue(scopeTypeOption) ?? string.Empty;
|
||||
var scopeId = parseResult.GetValue(scopeIdOption) ?? string.Empty;
|
||||
var integration = parseResult.GetValue(integrationOption) ?? string.Empty;
|
||||
var format = parseResult.GetValue(formatOption) ?? "text";
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
// POST /api/v1/topology/bindings
|
||||
var bindingId = Guid.NewGuid().ToString("N")[..12];
|
||||
var result = new BindingResult
|
||||
{
|
||||
BindingId = bindingId,
|
||||
Role = role,
|
||||
ScopeType = scopeType,
|
||||
ScopeId = scopeId,
|
||||
Integration = integration,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
if (format.Equals("json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Console.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
Console.WriteLine($"Binding created successfully.");
|
||||
Console.WriteLine($" Binding ID: {bindingId}");
|
||||
Console.WriteLine($" Role: {role}");
|
||||
Console.WriteLine($" Scope: {scopeType}/{scopeId}");
|
||||
Console.WriteLine($" Integration: {integration}");
|
||||
Console.WriteLine($" Created at: {result.CreatedAt:u}");
|
||||
|
||||
return Task.FromResult(0);
|
||||
});
|
||||
|
||||
return bindCommand;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// unbind <bindingId>
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static Command BuildUnbindCommand(Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var bindingIdArg = new Argument<string>("bindingId")
|
||||
{
|
||||
Description = "Binding ID to remove"
|
||||
};
|
||||
|
||||
var formatOption = new Option<string>("--format", ["-f"])
|
||||
{
|
||||
Description = "Output format: text (default), json"
|
||||
};
|
||||
formatOption.SetDefaultValue("text");
|
||||
|
||||
var unbindCommand = new Command("unbind", "Remove an integration binding")
|
||||
{
|
||||
bindingIdArg,
|
||||
formatOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
unbindCommand.SetAction((parseResult, ct) =>
|
||||
{
|
||||
var bindingId = parseResult.GetValue(bindingIdArg) ?? string.Empty;
|
||||
var format = parseResult.GetValue(formatOption) ?? "text";
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
// DELETE /api/v1/topology/bindings/{bindingId}
|
||||
var result = new UnbindResult
|
||||
{
|
||||
BindingId = bindingId,
|
||||
RemovedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
if (format.Equals("json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Console.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
Console.WriteLine($"Binding '{bindingId}' removed.");
|
||||
Console.WriteLine($" Removed at: {result.RemovedAt:u}");
|
||||
|
||||
return Task.FromResult(0);
|
||||
});
|
||||
|
||||
return unbindCommand;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helper: indicator symbol for table output
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static string Indicator(bool ok) => ok ? "Yes" : "No";
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Sample data helpers (will be replaced by real HTTP calls)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static ValidationResult GetValidationResult(string targetId)
|
||||
{
|
||||
return new ValidationResult
|
||||
{
|
||||
TargetId = targetId,
|
||||
ValidatedAt = DateTimeOffset.UtcNow,
|
||||
Gates =
|
||||
[
|
||||
new GateResult { Name = "Agent Connectivity", Passed = true, Detail = "Agent responded in 45ms" },
|
||||
new GateResult { Name = "Docker Version", Passed = true, Detail = "Docker 24.0.7 (>= 20.10 required)" },
|
||||
new GateResult { Name = "Docker Daemon Ping", Passed = true, Detail = "Daemon reachable" },
|
||||
new GateResult { Name = "Registry Pull", Passed = true, Detail = "Pulled test image in 1.2s" },
|
||||
new GateResult { Name = "Vault Connectivity", Passed = false, Detail = "Connection timed out after 5s" },
|
||||
new GateResult { Name = "Consul Connectivity", Passed = true, Detail = "Cluster healthy, 3 nodes" },
|
||||
new GateResult { Name = "Disk Space", Passed = true, Detail = "42 GB free (>= 10 GB required)" },
|
||||
new GateResult { Name = "DNS Resolution", Passed = true, Detail = "Resolved registry.stella-ops.local in 12ms" }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
private static List<TopologyTargetStatus> GetTopologyStatus()
|
||||
{
|
||||
return
|
||||
[
|
||||
new TopologyTargetStatus { TargetName = "prod-docker-01", Environment = "production", AgentBound = true, DockerVersionOk = true, DockerPingOk = true, RegistryPullOk = true, VaultOk = true, ConsulOk = true, Ready = true },
|
||||
new TopologyTargetStatus { TargetName = "prod-docker-02", Environment = "production", AgentBound = true, DockerVersionOk = true, DockerPingOk = true, RegistryPullOk = true, VaultOk = true, ConsulOk = true, Ready = true },
|
||||
new TopologyTargetStatus { TargetName = "stage-ecs-01", Environment = "stage", AgentBound = true, DockerVersionOk = true, DockerPingOk = true, RegistryPullOk = true, VaultOk = false, ConsulOk = true, Ready = false },
|
||||
new TopologyTargetStatus { TargetName = "dev-compose-01", Environment = "dev", AgentBound = true, DockerVersionOk = true, DockerPingOk = true, RegistryPullOk = false, VaultOk = false, ConsulOk = false, Ready = false },
|
||||
new TopologyTargetStatus { TargetName = "dev-nomad-01", Environment = "dev", AgentBound = false, DockerVersionOk = false, DockerPingOk = false, RegistryPullOk = false, VaultOk = false, ConsulOk = true, Ready = false }
|
||||
];
|
||||
}
|
||||
|
||||
private static List<DeleteRequestResult> GetPendingDeletions()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
return
|
||||
[
|
||||
new DeleteRequestResult { PendingId = "del-a1b2c3d4", Type = "target", EntityId = "dev-nomad-01", Reason = "Decommissioned", RequestedAt = now.AddHours(-2), Status = "pending" },
|
||||
new DeleteRequestResult { PendingId = "del-e5f6g7h8", Type = "environment", EntityId = "sandbox", Reason = "No longer needed", RequestedAt = now.AddDays(-1), Status = "pending" },
|
||||
new DeleteRequestResult { PendingId = "del-i9j0k1l2", Type = "integration", EntityId = "legacy-registry", Reason = "Migrated to Harbor", RequestedAt = now.AddDays(-3), Status = "pending" }
|
||||
];
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// DTOs
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private sealed class ValidationResult
|
||||
{
|
||||
public string TargetId { get; set; } = string.Empty;
|
||||
public DateTimeOffset ValidatedAt { get; set; }
|
||||
public List<GateResult> Gates { get; set; } = [];
|
||||
}
|
||||
|
||||
private sealed class GateResult
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public bool Passed { get; set; }
|
||||
public string Detail { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed class TopologyTargetStatus
|
||||
{
|
||||
public string TargetName { get; set; } = string.Empty;
|
||||
public string Environment { get; set; } = string.Empty;
|
||||
public bool AgentBound { get; set; }
|
||||
public bool DockerVersionOk { get; set; }
|
||||
public bool DockerPingOk { get; set; }
|
||||
public bool RegistryPullOk { get; set; }
|
||||
public bool VaultOk { get; set; }
|
||||
public bool ConsulOk { get; set; }
|
||||
public bool Ready { get; set; }
|
||||
}
|
||||
|
||||
private sealed class RenameResult
|
||||
{
|
||||
public string Type { get; set; } = string.Empty;
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string? NewName { get; set; }
|
||||
public string? NewDisplayName { get; set; }
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
private sealed class DeleteRequestResult
|
||||
{
|
||||
public string PendingId { get; set; } = string.Empty;
|
||||
public string Type { get; set; } = string.Empty;
|
||||
public string EntityId { get; set; } = string.Empty;
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
public DateTimeOffset RequestedAt { get; set; }
|
||||
public string Status { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed class DeleteActionResult
|
||||
{
|
||||
public string PendingId { get; set; } = string.Empty;
|
||||
public string Action { get; set; } = string.Empty;
|
||||
public DateTimeOffset CompletedAt { get; set; }
|
||||
}
|
||||
|
||||
private sealed class BindingResult
|
||||
{
|
||||
public string BindingId { get; set; } = string.Empty;
|
||||
public string Role { get; set; } = string.Empty;
|
||||
public string ScopeType { get; set; } = string.Empty;
|
||||
public string ScopeId { get; set; } = string.Empty;
|
||||
public string Integration { get; set; } = string.Empty;
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
private sealed class UnbindResult
|
||||
{
|
||||
public string BindingId { get; set; } = string.Empty;
|
||||
public DateTimeOffset RemovedAt { get; set; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user