feat(transport): import name-map plumbing via CLI + ManagementActor (M8 D3)

This commit is contained in:
Joseph Doherty
2026-06-18 07:08:33 -04:00
parent d974477e87
commit 565d53d0fe
5 changed files with 721 additions and 7 deletions
@@ -222,6 +222,11 @@ public static class BundleCommands
timeout: BundleCommandTimeout,
onSuccess: jsonOk =>
{
// Surface the required site/connection mappings so an operator
// sees which references need resolving on `bundle import`. The
// full PreviewBundleResult JSON (including the new fields) is
// still printed verbatim afterwards for tooling/jq.
PrintRequiredMappingSummary(jsonOk);
Console.WriteLine(jsonOk);
return 0;
});
@@ -229,6 +234,60 @@ public static class BundleCommands
return cmd;
}
/// <summary>
/// Parses the <see cref="PreviewBundleResult"/> JSON envelope and writes a
/// human-readable summary of the <c>requiredSiteMappings</c> /
/// <c>requiredConnectionMappings</c> arrays to stdout. Best-effort: any parse
/// hiccup is swallowed so the raw JSON (printed by the caller) remains the
/// authoritative output.
/// </summary>
/// <param name="jsonOk">The success-body JSON returned by the management endpoint.</param>
private static void PrintRequiredMappingSummary(string jsonOk)
{
try
{
using var doc = JsonDocument.Parse(jsonOk);
var root = doc.RootElement;
var sites = root.TryGetProperty("requiredSiteMappings", out var s)
&& s.ValueKind == JsonValueKind.Array ? s : default;
var conns = root.TryGetProperty("requiredConnectionMappings", out var c)
&& c.ValueKind == JsonValueKind.Array ? c : default;
var siteCount = sites.ValueKind == JsonValueKind.Array ? sites.GetArrayLength() : 0;
var connCount = conns.ValueKind == JsonValueKind.Array ? conns.GetArrayLength() : 0;
if (siteCount == 0 && connCount == 0) return;
Console.WriteLine("Mappings required before import:");
foreach (var site in sites.EnumerateArray())
{
var src = site.TryGetProperty("sourceSiteIdentifier", out var v) ? v.GetString() : null;
var name = site.TryGetProperty("sourceSiteName", out var n) ? n.GetString() : null;
var auto = site.TryGetProperty("autoMatchTargetIdentifier", out var a)
&& a.ValueKind == JsonValueKind.String ? a.GetString() : null;
var hint = auto is null
? "no auto-match (use --map-site or --create-missing-sites)"
: $"auto-matches '{auto}'";
Console.WriteLine($" site: {src} ({name}) — {hint}");
}
foreach (var conn in conns.EnumerateArray())
{
var src = conn.TryGetProperty("sourceSiteIdentifier", out var v) ? v.GetString() : null;
var cname = conn.TryGetProperty("sourceConnectionName", out var n) ? n.GetString() : null;
var auto = conn.TryGetProperty("autoMatchTargetName", out var a)
&& a.ValueKind == JsonValueKind.String ? a.GetString() : null;
var hint = auto is null
? "no auto-match (use --map-connection or --create-missing-connections)"
: $"auto-matches '{auto}'";
Console.WriteLine($" connection: {src}/{cname} — {hint}");
}
Console.WriteLine();
}
catch (JsonException)
{
// Best-effort: the raw JSON is still printed by the caller.
}
}
// ====================================================================
// bundle import
// ====================================================================
@@ -250,6 +309,30 @@ public static class BundleCommands
Description = "Resolution policy applied to every Modified row: skip, overwrite, or rename. Default: overwrite.",
DefaultValueFactory = _ => "overwrite",
};
// M8 (D3): site/connection name mapping. Repeatable. A token with no
// "=dst" part, or "=" with an empty right-hand side, means CreateNew (the
// destination is created from the bundle payload); otherwise the source
// is mapped to the named existing destination.
var mapSiteOption = new Option<string[]>("--map-site")
{
Description = "Map a source site to a destination: 'srcIdentifier=dstIdentifier'. " +
"'srcIdentifier' or 'srcIdentifier=' (no/empty dst) means create-new. Repeatable.",
AllowMultipleArgumentsPerToken = true,
};
var mapConnectionOption = new Option<string[]>("--map-connection")
{
Description = "Map a source connection to a destination: 'srcSiteIdentifier/srcName=dstName'. " +
"'srcSiteIdentifier/srcName' or '…=' (no/empty dst) means create-new. Repeatable.",
AllowMultipleArgumentsPerToken = true,
};
var createMissingSitesOption = new Option<bool>("--create-missing-sites")
{
Description = "Create any unmapped/unmatched source site from the bundle payload instead of failing.",
};
var createMissingConnectionsOption = new Option<bool>("--create-missing-connections")
{
Description = "Create any unmapped/unmatched source connection from the bundle payload instead of failing.",
};
var cmd = new Command("import")
{
@@ -258,6 +341,10 @@ public static class BundleCommands
cmd.Add(inputOption);
cmd.Add(passphraseOption);
cmd.Add(onConflictOption);
cmd.Add(mapSiteOption);
cmd.Add(mapConnectionOption);
cmd.Add(createMissingSitesOption);
cmd.Add(createMissingConnectionsOption);
cmd.SetAction(async (ParseResult result) =>
{
@@ -267,11 +354,29 @@ public static class BundleCommands
OutputFormatter.WriteError($"Bundle file not found: {input}", "FILE_NOT_FOUND");
return 1;
}
List<SiteMappingSpec> siteMappings;
List<ConnectionMappingSpec> connectionMappings;
try
{
siteMappings = ParseSiteMappings(result.GetValue(mapSiteOption));
connectionMappings = ParseConnectionMappings(result.GetValue(mapConnectionOption));
}
catch (FormatException ex)
{
OutputFormatter.WriteError(ex.Message, "INVALID_ARGUMENT");
return 1;
}
var bytes = await File.ReadAllBytesAsync(input);
var payload = new ImportBundleCommand(
Base64Bundle: Convert.ToBase64String(bytes),
Passphrase: result.GetValue(passphraseOption),
DefaultConflictPolicy: result.GetValue(onConflictOption)!);
DefaultConflictPolicy: result.GetValue(onConflictOption)!,
SiteMappings: siteMappings.Count == 0 ? null : siteMappings,
ConnectionMappings: connectionMappings.Count == 0 ? null : connectionMappings,
CreateMissingSites: result.GetValue(createMissingSitesOption),
CreateMissingConnections: result.GetValue(createMissingConnectionsOption));
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
@@ -389,4 +494,82 @@ public static class BundleCommands
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToArray();
}
/// <summary>
/// Parses the repeatable <c>--map-site</c> tokens into
/// <see cref="SiteMappingSpec"/>s. Each token is
/// <c>srcIdentifier=dstIdentifier</c> (map to an existing destination) or
/// <c>srcIdentifier</c> / <c>srcIdentifier=</c> (no/empty right-hand side →
/// CreateNew, surfaced as a null target). Exposed <c>internal</c> so the
/// flag-parse tests can assert the split + CreateNew convention.
/// </summary>
/// <param name="tokens">Raw <c>--map-site</c> token values, or <c>null</c>.</param>
/// <returns>The parsed spec list (possibly empty).</returns>
/// <exception cref="FormatException">A token has an empty source identifier.</exception>
internal static List<SiteMappingSpec> ParseSiteMappings(string[]? tokens)
{
var result = new List<SiteMappingSpec>();
if (tokens is null) return result;
foreach (var token in tokens)
{
if (string.IsNullOrWhiteSpace(token)) continue;
var (left, right) = SplitOnFirst(token, '=');
var src = left.Trim();
if (src.Length == 0)
{
throw new FormatException(
$"Invalid --map-site '{token}': source identifier is required (use 'src=dst' or 'src' for create-new).");
}
// null target == CreateNew, per the documented convention.
result.Add(new SiteMappingSpec(src, string.IsNullOrWhiteSpace(right) ? null : right!.Trim()));
}
return result;
}
/// <summary>
/// Parses the repeatable <c>--map-connection</c> tokens into
/// <see cref="ConnectionMappingSpec"/>s. Each token is
/// <c>srcSiteIdentifier/srcName=dstName</c> (map to an existing destination)
/// or <c>srcSiteIdentifier/srcName</c> / <c>…=</c> (no/empty right-hand side →
/// CreateNew, surfaced as a null target). Exposed <c>internal</c> for the
/// flag-parse tests.
/// </summary>
/// <param name="tokens">Raw <c>--map-connection</c> token values, or <c>null</c>.</param>
/// <returns>The parsed spec list (possibly empty).</returns>
/// <exception cref="FormatException">A token is missing the <c>site/name</c> source shape.</exception>
internal static List<ConnectionMappingSpec> ParseConnectionMappings(string[]? tokens)
{
var result = new List<ConnectionMappingSpec>();
if (tokens is null) return result;
foreach (var token in tokens)
{
if (string.IsNullOrWhiteSpace(token)) continue;
var (lhs, right) = SplitOnFirst(token, '=');
var (site, name) = SplitOnFirst(lhs, '/');
var srcSite = site.Trim();
var srcName = (name ?? string.Empty).Trim();
if (srcSite.Length == 0 || name is null || srcName.Length == 0)
{
throw new FormatException(
$"Invalid --map-connection '{token}': expected 'srcSiteIdentifier/srcName[=dstName]'.");
}
// null target == CreateNew, per the documented convention.
result.Add(new ConnectionMappingSpec(srcSite, srcName, string.IsNullOrWhiteSpace(right) ? null : right!.Trim()));
}
return result;
}
/// <summary>
/// Splits <paramref name="value"/> on the first occurrence of
/// <paramref name="separator"/>. When the separator is absent the right side
/// is <c>null</c> (distinguishing "src" from "src=" — both mean CreateNew here,
/// but callers that need to detect the separator's presence can).
/// </summary>
private static (string Left, string? Right) SplitOnFirst(string value, char separator)
{
var idx = value.IndexOf(separator);
return idx < 0
? (value, null)
: (value[..idx], value[(idx + 1)..]);
}
}