RedHatOpenShiftHcp.Autorest/custom/KubeConfigHelper.cs
|
// ----------------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the Apache License, Version 2.0 (the "License"); see LICENSE for details. // ---------------------------------------------------------------------------------- // // C1 customization helper for the custom Request-AzRedHatOpenShiftHcpClusterAdminCredential cmdlet. // // Pure string -> string transforms (no file I/O — the PowerShell cmdlet does the I/O): // * PrepareKubeconfig : embed the locally-generated private key into the returned kubeconfig and // rename the cluster/user/context to a stable, collision-free name. // * MergeKubeConfig : AKS-style merge of our prepared kubeconfig into an existing one. // // Implemented with the BCL only (no third-party YAML package) because this is an AutoRest-generated // module and the regenerated project cannot carry an extra NuGet PackageReference. kubeconfig is regular // block-style YAML, so these transforms work at the line/block level: // * We only ever PARSE content WE produced/received from the RP (regular, single-entry block YAML). // * We NEVER fully parse or re-serialize the user's existing kubeconfig. MergeKubeConfig splices our // named blocks into the existing text, preserving every byte we do not replace (comments, quoting, // exec auth-plugin arg/env lists, ordering). This is the safety invariant. // // Out of scope (kubeconfigs are block-style in practice): YAML anchors/aliases, flow style ({a: b}), // and multi-document streams in the EXISTING file are not interpreted. // // Targets netstandard2.0 / C# 7.1 (module csproj). using System; using System.Collections.Generic; using System.Text; using System.Text.RegularExpressions; namespace Microsoft.Azure.PowerShell.Cmdlets.RedHatOpenShiftHcp.Custom { public static class KubeConfigHelper { private static readonly string[] Sections = { "clusters", "users", "contexts" }; private const string CurrentContext = "current-context"; // A list item within a clusters/users/contexts section, located by indentation. private sealed class Item { public int Start; // absolute index of the "- ..." line public int End; // exclusive absolute index where the item ends public int ItemIndent; // leading spaces before "-" public int ContentIndent; // indent of the item's own keys (ItemIndent + 2) public int NameLineIndex; // line holding the item's own "name:" (-1 if none) public string NamePrefix; // everything up to and including "name: " on that line public string Name; // the item's name value (null if none) } /// <summary> /// Embed the private key into the admin user and give the single cluster/user/context a stable, /// collision-free name so multiple HCP clusters can coexist in one kubeconfig. /// </summary> public static string PrepareKubeconfig(string kubeconfigYaml, string privateKeyPem, string name) { if (string.IsNullOrWhiteSpace(kubeconfigYaml)) { throw new ArgumentException("The returned kubeconfig was empty.", nameof(kubeconfigYaml)); } string nl; var lines = SplitLines(kubeconfigYaml, out nl); var keyData = Convert.ToBase64String(Encoding.UTF8.GetBytes(privateKeyPem ?? string.Empty)); foreach (var section in Sections) { var items = FindSectionItems(lines, section); if (items.Count == 0) { throw new InvalidOperationException("The returned kubeconfig is missing its '" + section + "' section."); } var item = items[0]; SetItemName(lines, item, name); if (section == "users") { EmbedClientKey(lines, item, keyData); } else if (section == "contexts") { SetContextReferences(lines, item, name); } } SetCurrentContext(lines, name); return string.Join(nl, lines); } /// <summary> /// Merge <paramref name="additions"/> (our prepared, regular kubeconfig) into /// <paramref name="original"/> (the user's existing, arbitrary kubeconfig) by name for clusters, users /// and contexts (additions win on name collision), and adopt the new current-context. The existing /// content is spliced, not re-serialized, so unrelated entries are preserved verbatim. /// </summary> public static string MergeKubeConfig(string original, string additions) { if (string.IsNullOrWhiteSpace(original)) { return additions; } string nl; var originalLines = SplitLines(original, out nl); string nlA; var additionLines = SplitLines(additions, out nlA); foreach (var section in Sections) { var addItems = FindSectionItems(additionLines, section); if (addItems.Count == 0) { continue; } var addBlocks = new List<List<string>>(); var addNames = new HashSet<string>(StringComparer.Ordinal); foreach (var it in addItems) { addBlocks.Add(additionLines.GetRange(it.Start, it.End - it.Start)); if (it.Name != null) { addNames.Add(it.Name); } } originalLines = SpliceSection(originalLines, section, addBlocks, addNames); } var newCurrent = GetCurrentContextValue(additionLines); if (newCurrent != null) { SetCurrentContext(originalLines, newCurrent); } return string.Join(nl, originalLines); } // --- section/item splicing ------------------------------------------------------------------- // Replace colliding items in the section by name and append the additions, preserving all other // lines of the original verbatim. Only line BLOCKS are cut/appended; nothing is re-serialized. private static List<string> SpliceSection(List<string> lines, string section, List<List<string>> addBlocks, HashSet<string> addNames) { int headerIdx = FindSectionHeader(lines, section); if (headerIdx < 0) { // Section absent in the original: append it (header + reindented items) at the end. var appended = new List<string>(lines); appended.Add(section + ":"); foreach (var block in addBlocks) { appended.AddRange(Reindent(block, 0)); } return appended; } int bodyStart = headerIdx + 1; int bodyEnd = SectionBodyEnd(lines, bodyStart); var items = ParseItems(lines, bodyStart, bodyEnd); int targetIndent = items.Count > 0 ? items[0].ItemIndent : 0; var removeLines = new HashSet<int>(); foreach (var it in items) { if (it.Name != null && addNames.Contains(it.Name)) { for (int k = it.Start; k < it.End; k++) { removeLines.Add(k); } } } var result = new List<string>(lines.Count + 8); for (int i = 0; i <= lines.Count; i++) { if (i == bodyEnd) { foreach (var block in addBlocks) { result.AddRange(Reindent(block, targetIndent)); } } if (i < lines.Count && !removeLines.Contains(i)) { result.Add(NormalizeEmptyCollectionHeader(lines[i], i == headerIdx, section)); } } return result; } // "clusters: []" / "clusters: {}" must become "clusters:" once we append block items under it. private static string NormalizeEmptyCollectionHeader(string line, bool isHeader, string section) { if (!isHeader) { return line; } var m = Regex.Match(line, @"^(\s*)" + Regex.Escape(section) + @":\s*(\[\s*\]|\{\s*\})\s*$"); if (m.Success) { return m.Groups[1].Value + section + ":"; } return line; } private static List<Item> FindSectionItems(List<string> lines, string section) { int headerIdx = FindSectionHeader(lines, section); if (headerIdx < 0) { return new List<Item>(); } int bodyStart = headerIdx + 1; int bodyEnd = SectionBodyEnd(lines, bodyStart); return ParseItems(lines, bodyStart, bodyEnd); } private static int FindSectionHeader(List<string> lines, string section) { for (int i = 0; i < lines.Count; i++) { var m = Regex.Match(lines[i], @"^([A-Za-z0-9_.-]+):\s*(.*)$"); if (m.Success && m.Groups[1].Value == section) { return i; } } return -1; } // The section body runs until the next top-level key (column-0 "key:") or end of file. private static int SectionBodyEnd(List<string> lines, int bodyStart) { int j = bodyStart; while (j < lines.Count && !IsTopLevelKey(lines[j])) { j++; } return j; } private static bool IsTopLevelKey(string line) { return Regex.IsMatch(line, @"^[A-Za-z0-9_.-]+:(\s|$)"); } // Parse list items in [start, end). Items begin with "- " at the first item's indent; an item ends at // the next "- " at that same indent, or at end. The item's own name is the "name:" key at the item's // content indent (either on the "- name:" line itself or a sibling " name:" line) — never a deeper // nested "- name:" (e.g. inside an exec plugin's env list). private static List<Item> ParseItems(List<string> lines, int start, int end) { var items = new List<Item>(); int itemIndent = -1; int i = start; while (i < end) { var m = Regex.Match(lines[i], @"^(\s*)-\s"); if (m.Success) { int indent = m.Groups[1].Length; if (itemIndent < 0) { itemIndent = indent; } if (indent == itemIndent) { int j = i + 1; while (j < end) { var mm = Regex.Match(lines[j], @"^(\s*)-\s"); if (mm.Success && mm.Groups[1].Length == itemIndent) { break; } j++; } items.Add(BuildItem(lines, i, j, itemIndent)); i = j; continue; } } i++; } return items; } private static Item BuildItem(List<string> lines, int start, int end, int itemIndent) { var item = new Item { Start = start, End = end, ItemIndent = itemIndent, ContentIndent = itemIndent + 2, NameLineIndex = -1, NamePrefix = null, Name = null }; // Case 1: "- name: value" on the item's first line. var first = Regex.Match(lines[start], @"^(\s*-\s+)(.*)$"); if (first.Success) { var rest = first.Groups[2].Value; var nm = Regex.Match(rest, @"^name:\s*(.*)$"); if (nm.Success) { item.NameLineIndex = start; item.NamePrefix = first.Groups[1].Value + "name: "; item.Name = nm.Groups[1].Value.Trim(); return item; } } // Case 2: a sibling " name: value" line at the item's content indent. for (int k = start + 1; k < end; k++) { var nm = Regex.Match(lines[k], @"^( *)name:\s*(.*)$"); if (nm.Success && nm.Groups[1].Length == item.ContentIndent) { item.NameLineIndex = k; item.NamePrefix = nm.Groups[1].Value + "name: "; item.Name = nm.Groups[2].Value.Trim(); break; } } return item; } // --- PrepareKubeconfig field edits (operate on the controlled, single-entry RP output) ---------- private static void SetItemName(List<string> lines, Item item, string name) { if (item.NameLineIndex >= 0) { lines[item.NameLineIndex] = item.NamePrefix + name; } } private static void EmbedClientKey(List<string> lines, Item item, string keyData) { for (int k = item.Start; k < item.End; k++) { var m = Regex.Match(lines[k], @"^(\s*)client-key(-data)?:\s"); if (m.Success) { lines[k] = m.Groups[1].Value + "client-key-data: " + keyData; return; } } } private static void SetContextReferences(List<string> lines, Item item, string name) { for (int k = item.Start; k < item.End; k++) { var m = Regex.Match(lines[k], @"^(\s*)(cluster|user):\s"); if (m.Success && m.Groups[1].Length == item.ContentIndent + 2) { lines[k] = m.Groups[1].Value + m.Groups[2].Value + ": " + name; } } } private static void SetCurrentContext(List<string> lines, string value) { for (int i = 0; i < lines.Count; i++) { if (Regex.IsMatch(lines[i], @"^current-context:(\s|$)")) { lines[i] = CurrentContext + ": " + value; return; } } lines.Add(CurrentContext + ": " + value); } private static string GetCurrentContextValue(List<string> lines) { foreach (var line in lines) { var m = Regex.Match(line, @"^current-context:\s*(.*)$"); if (m.Success) { return m.Groups[1].Value.Trim(); } } return null; } // --- text helpers --------------------------------------------------------------------------- private static List<string> SplitLines(string text, out string nl) { nl = text.IndexOf("\r\n", StringComparison.Ordinal) >= 0 ? "\r\n" : "\n"; return new List<string>(text.Replace("\r\n", "\n").Split('\n')); } private static int LeadingSpaces(string s) { int n = 0; while (n < s.Length && s[n] == ' ') { n++; } return n; } // Shift a whole block's indentation so its "- " line sits at targetIndent, keeping relative structure. private static List<string> Reindent(List<string> block, int targetIndent) { if (block.Count == 0) { return block; } int delta = targetIndent - LeadingSpaces(block[0]); if (delta == 0) { return new List<string>(block); } var result = new List<string>(block.Count); foreach (var line in block) { if (line.Length == 0) { result.Add(line); } else if (delta > 0) { result.Add(new string(' ', delta) + line); } else { int remove = Math.Min(-delta, LeadingSpaces(line)); result.Add(line.Substring(remove)); } } return result; } } } |