feat(secrets-cli): bundle export/import flows
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
using System.Globalization;
|
||||
using Spectre.Console;
|
||||
|
||||
namespace ZB.MOM.WW.Secrets.Cli.Interactive.Flows;
|
||||
|
||||
/// <summary>
|
||||
/// Exports the session's store to a ciphertext-only <see cref="SecretBundle"/> on disk. The operator is
|
||||
/// offered a default filename (<c>secrets-bundle-<target>-<yyyyMMdd>.json</c>) they can accept
|
||||
/// or override, and a confirmation for whether tombstoned rows are included. The bundle carries no
|
||||
/// plaintext — only the encrypted rows and the source KEK id — so it is safe at rest and useless without
|
||||
/// the source key. Requires a KEK-capable session (the shell upgrades a degraded session before dispatch).
|
||||
/// </summary>
|
||||
public sealed class BundleExportFlow : IInteractiveFlow
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
/// <summary>Creates the flow.</summary>
|
||||
/// <param name="timeProvider">
|
||||
/// Clock used for the default filename's date stamp and the bundle's <see cref="SecretBundle.ExportedUtc"/>
|
||||
/// timestamp; defaults to <see cref="TimeProvider.System"/>.
|
||||
/// </param>
|
||||
public BundleExportFlow(TimeProvider? timeProvider = null) =>
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Title => "Export bundle (ciphertext-only)";
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool RequiresKek => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RunAsync(IAnsiConsole console, SecretsSession session, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(console);
|
||||
ArgumentNullException.ThrowIfNull(session);
|
||||
|
||||
string defaultPath = DefaultBundleFileName(session.Target.Name, _timeProvider.GetUtcNow());
|
||||
string path = console.Prompt(
|
||||
new TextPrompt<string>("Bundle [green]path[/]:").DefaultValue(defaultPath));
|
||||
|
||||
bool includeDeleted = console.Prompt(
|
||||
new ConfirmationPrompt("Include tombstoned (deleted) rows?") { DefaultValue = false });
|
||||
|
||||
int count;
|
||||
try
|
||||
{
|
||||
count = await new BundleService(_timeProvider)
|
||||
.ExportAsync(session, path, includeDeleted, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
console.MarkupLineInterpolated($"[red]Could not write bundle to '{path}': {ex.Message}[/]");
|
||||
return;
|
||||
}
|
||||
|
||||
console.MarkupLineInterpolated($"[green]Exported {count} row(s) to '{path}'.[/]");
|
||||
}
|
||||
|
||||
// Builds a filesystem-safe default name: secrets-bundle-<target>-<yyyyMMdd>.json, with any character
|
||||
// illegal in a file name collapsed to '-' so the offered default is always a valid path segment.
|
||||
private static string DefaultBundleFileName(string targetName, DateTimeOffset now)
|
||||
{
|
||||
string sanitized = Sanitize(targetName);
|
||||
string date = now.ToString("yyyyMMdd", CultureInfo.InvariantCulture);
|
||||
return $"secrets-bundle-{sanitized}-{date}.json";
|
||||
}
|
||||
|
||||
private static string Sanitize(string name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return "store";
|
||||
}
|
||||
|
||||
char[] invalid = Path.GetInvalidFileNameChars();
|
||||
char[] chars = name.Trim().Select(c => invalid.Contains(c) ? '-' : c).ToArray();
|
||||
return new string(chars);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user