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:
master
2026-03-16 08:12:39 +02:00
parent 602df77467
commit da76d6e93e
223 changed files with 24763 additions and 489 deletions

View File

@@ -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)

View 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; }
}
}