feat(secrets-cli): interactive shell skeleton — target picker, degraded-KEK upgrade, flow dispatch

This commit is contained in:
Joseph Doherty
2026-07-19 09:16:06 -04:00
parent 6255409f16
commit e49496c856
3 changed files with 638 additions and 0 deletions
@@ -0,0 +1,26 @@
using Spectre.Console;
namespace ZB.MOM.WW.Secrets.Cli.Interactive;
/// <summary>
/// One menu action of the interactive console. Implementations are UI-owning (they render to the
/// supplied <see cref="IAnsiConsole"/>) but store-logic-free — they operate through the seams on the
/// <see cref="SecretsSession"/> the shell hands them and never open stores or resolve keys themselves.
/// </summary>
public interface IInteractiveFlow
{
/// <summary>The menu label shown for this flow in the shell's action list.</summary>
string Title { get; }
/// <summary>
/// Whether the flow needs a decrypt/seal-capable session; the shell upgrades a degraded session
/// (by prompting the operator for a KEK) before dispatch when this is <see langword="true"/>.
/// </summary>
bool RequiresKek { get; }
/// <summary>Runs the flow against the current session, rendering its own UI to <paramref name="console"/>.</summary>
/// <param name="console">The console the flow renders to (the shell owns lifecycle; flows only draw).</param>
/// <param name="session">The open session — guaranteed KEK-capable when <see cref="RequiresKek"/> is <see langword="true"/>.</param>
/// <param name="ct">A token to cancel the flow.</param>
Task RunAsync(IAnsiConsole console, SecretsSession session, CancellationToken ct);
}
@@ -0,0 +1,328 @@
using System.Data.Common;
using Spectre.Console;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.MasterKey;
namespace ZB.MOM.WW.Secrets.Cli.Interactive;
/// <summary>
/// The interactive <c>secret</c> console: pick a store target, open a session, then loop a header +
/// action menu that dispatches to pluggable <see cref="IInteractiveFlow"/>s. This is the ONLY class
/// that touches <see cref="IAnsiConsole"/> — services stay UI-free and flows, though UI-owning, carry
/// no store logic. Flow failures are contained (rendered as an error panel) so one bad action never
/// tears down the session; a degraded (no-KEK) session is upgraded on demand before a KEK-requiring
/// flow runs.
/// </summary>
public sealed class InteractiveShell
{
private const string EnterPathLabel = "Enter an appsettings.json path…";
private const string ManualEntryLabel = "Manual entry (SQLite db + KEK env var)…";
private const string SwitchLabel = "Switch store target";
private const string QuitLabel = "Quit";
private const string PasteKeyLabel = "Paste base64 key";
private const string KeyFileLabel = "Key file path";
private const string CancelLabel = "Cancel";
private readonly IAnsiConsole _console;
private readonly RecentTargetsStore _recents;
private readonly TargetConfigReader _reader;
private readonly SecretsSessionFactory _sessions;
private readonly IReadOnlyList<IInteractiveFlow> _flows;
/// <summary>Creates the shell over its console, recents store, target reader, session factory, and flow set.</summary>
/// <param name="console">The single console surface the shell (and its flows) render to.</param>
/// <param name="recents">Recently-used target store, offered first in the picker and re-stamped on selection.</param>
/// <param name="reader">Composes a target's Secrets configuration from appsettings or manual input.</param>
/// <param name="sessions">Opens (and, on KEK upgrade, re-opens) a <see cref="SecretsSession"/> for a target.</param>
/// <param name="flows">The pluggable menu actions, listed in order above the built-in switch/quit entries.</param>
public InteractiveShell(
IAnsiConsole console,
RecentTargetsStore recents,
TargetConfigReader reader,
SecretsSessionFactory sessions,
IReadOnlyList<IInteractiveFlow> flows)
{
ArgumentNullException.ThrowIfNull(console);
ArgumentNullException.ThrowIfNull(recents);
ArgumentNullException.ThrowIfNull(reader);
ArgumentNullException.ThrowIfNull(sessions);
ArgumentNullException.ThrowIfNull(flows);
_console = console;
_recents = recents;
_reader = reader;
_sessions = sessions;
_flows = flows;
}
/// <summary>Runs the pick-target → menu loop until the operator quits.</summary>
/// <param name="ct">A token to cancel session opens and flow dispatch.</param>
/// <returns><c>0</c> on a clean quit.</returns>
public async Task<int> RunAsync(CancellationToken ct)
{
while (true)
{
StoreTarget? target = PickTarget();
if (target is null)
{
return 0; // operator cancelled the picker (Ctrl-C) → exit
}
SecretsSession session = await _sessions.OpenAsync(target, overrideKek: null, ct).ConfigureAwait(false);
RenderWarnings(session);
if (await RunMenuLoopAsync(session, ct).ConfigureAwait(false) == MenuOutcome.Quit)
{
return 0;
}
// MenuOutcome.SwitchTarget → fall through and re-open the picker.
}
}
// The outcome of the inner menu loop: the operator either quit outright or asked to switch targets.
private enum MenuOutcome
{
Quit,
SwitchTarget,
}
private async Task<MenuOutcome> RunMenuLoopAsync(SecretsSession session, CancellationToken ct)
{
while (true)
{
RenderHeader(session);
string choice;
try
{
choice = _console.Prompt(BuildMenu());
}
catch (OperationCanceledException)
{
return MenuOutcome.Quit; // Ctrl-C at the menu → clean exit
}
switch (choice)
{
case QuitLabel:
return MenuOutcome.Quit;
case SwitchLabel:
return MenuOutcome.SwitchTarget;
}
IInteractiveFlow flow = _flows.First(f => f.Title == choice);
try
{
if (flow.RequiresKek && !session.KekAvailable)
{
SecretsSession? upgraded = await TryUpgradeSessionAsync(session, ct).ConfigureAwait(false);
if (upgraded is null)
{
continue; // cancelled or key rejected → back to the menu
}
session = upgraded; // keep the upgraded session for the rest of the loop
}
await flow.RunAsync(_console, session, ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Ctrl-C mid-flow → abandon the action and return to the menu.
}
catch (Exception ex) when (IsContainable(ex))
{
RenderErrorPanel(ex);
}
}
}
// The store/key faults a flow may realistically surface. SecretDecryptionException and
// MasterKeyUnavailableException both derive from InvalidOperationException; DbException (base of both
// Microsoft.Data.Sqlite and Microsoft.Data.SqlClient exceptions) covers store-provider faults. A
// blanket catch is deliberately avoided so genuinely unexpected faults still surface.
private static bool IsContainable(Exception ex) =>
ex is InvalidOperationException or IOException or ArgumentException or DbException;
// Presents the target picker: recents newest-first, then the two entry options. Returns the resolved
// target, or null if the operator cancelled the selection (Ctrl-C). Re-prompts on a recoverable
// read error so a bad path does not drop the operator out of the console.
private StoreTarget? PickTarget()
{
while (true)
{
IReadOnlyList<RecentTargetsStore.RecentTarget> recents = _recents.Load();
List<string> choices = [];
Dictionary<string, RecentTargetsStore.RecentTarget> byLabel = new(StringComparer.Ordinal);
foreach (RecentTargetsStore.RecentTarget recent in recents)
{
string label = $"{recent.Name} — {recent.AppSettingsPath}";
if (byLabel.ContainsKey(label))
{
continue; // Load() already de-dups by path, but guard against colliding labels.
}
choices.Add(label);
byLabel[label] = recent;
}
choices.Add(EnterPathLabel);
choices.Add(ManualEntryLabel);
string picked;
try
{
picked = _console.Prompt(new SelectionPrompt<string>()
.Title("Select a store target")
.AddChoices(choices));
}
catch (OperationCanceledException)
{
return null;
}
try
{
if (picked == ManualEntryLabel)
{
return ReadManualTarget();
}
string appSettingsPath = picked == EnterPathLabel
? _console.Prompt(new TextPrompt<string>("appsettings.json path:"))
: byLabel[picked].AppSettingsPath;
StoreTarget target = _reader.Read(appSettingsPath);
_recents.Touch(target.Name, target.AppSettingsPath!); // Read targets always carry a path
return target;
}
catch (OperationCanceledException)
{
return null;
}
catch (Exception ex) when (IsContainable(ex))
{
RenderErrorPanel(ex); // e.g. FileNotFoundException — show it and re-open the picker
}
}
}
private StoreTarget ReadManualTarget()
{
string dbPath = _console.Prompt(new TextPrompt<string>("SQLite database path:"));
string envVar = _console.Prompt(new TextPrompt<string>("Master-key environment variable name:")
.DefaultValue("ZB_SECRETS_MASTER_KEY"));
// Manual targets have no appsettings.json, so they are not remembered as recents.
return _reader.Manual(dbPath, new MasterKeyOptions
{
Source = MasterKeySource.Environment,
EnvVarName = envVar,
});
}
// Prompts the operator for a KEK to lift a degraded session, re-opening it with the supplied key.
// Returns the upgraded (KEK-capable) session, or null if the operator cancelled or the key was
// rejected — in which case the caller stays on the menu.
private async Task<SecretsSession?> TryUpgradeSessionAsync(SecretsSession session, CancellationToken ct)
{
_console.MarkupLine("[yellow]This action needs a KEK, but the session is degraded.[/]");
string source;
try
{
source = _console.Prompt(new SelectionPrompt<string>()
.Title("Provide the key")
.AddChoices(PasteKeyLabel, KeyFileLabel, CancelLabel));
}
catch (OperationCanceledException)
{
return null;
}
if (source == CancelLabel)
{
return null;
}
IMasterKeyProvider provider;
try
{
if (source == PasteKeyLabel)
{
string base64 = _console.Prompt(new TextPrompt<string>("Paste base64 KEK:").Secret());
provider = new LiteralMasterKeyProvider(base64);
}
else
{
string filePath = _console.Prompt(new TextPrompt<string>("Key file path:"));
provider = MasterKeyProviderFactory.Create(new MasterKeyOptions
{
Source = MasterKeySource.File,
FilePath = filePath,
});
}
}
catch (ArgumentException ex)
{
// LiteralMasterKeyProvider rejects a bad paste eagerly; name the problem and stay on the menu.
_console.MarkupLineInterpolated($"[red]Invalid key: {ex.Message}[/]");
return null;
}
SecretsSession upgraded = await _sessions.OpenAsync(session.Target, provider, ct).ConfigureAwait(false);
if (!upgraded.KekAvailable)
{
RenderWarnings(upgraded);
_console.MarkupLine("[red]That key did not unlock the session.[/]");
return null;
}
return upgraded;
}
private SelectionPrompt<string> BuildMenu()
{
List<string> actions = [];
actions.AddRange(_flows.Select(f => f.Title));
actions.Add(SwitchLabel);
actions.Add(QuitLabel);
return new SelectionPrompt<string>()
.Title("Action")
.AddChoices(actions);
}
private void RenderHeader(SecretsSession session)
{
string kek = session.KekAvailable
? "[green]KEK OK[/]"
: "[red]KEK UNAVAILABLE (degraded)[/]";
Markup body = new(
$"[bold]{Markup.Escape(session.Target.Name)}[/] " +
$"store: {Markup.Escape(session.StoreKind)} {kek}");
_console.Write(new Panel(body).Header("Target"));
}
private void RenderWarnings(SecretsSession session)
{
foreach (string warning in session.Warnings)
{
_console.MarkupLineInterpolated($"[yellow]{warning}[/]");
}
}
private void RenderErrorPanel(Exception ex)
{
Markup body = new(
$"[red]{Markup.Escape(ex.Message)}[/]\n\nIf this is a key problem, run the KEK doctor.");
_console.Write(new Panel(body)
.Header("[red]Action failed[/]")
.BorderColor(Color.Red));
}
}