Private/Import-AACTerraformPlanFlattener.ps1
|
function Import-AACTerraformPlanFlattener { <# .SYNOPSIS Compiles the Terraform plan flattener (AzureAdminConsole. TerraformPlanFlattener and its rows, AzureAdminConsole.TerraformLeaf) the first time Get-AACTerraformPlan needs it in a session. .DESCRIPTION A plan of a few thousand resources holds hundreds of thousands of values, and every one is walked and compared. Written in PowerShell that took over a minute for 3,000 resources - a function call per value; in C# it takes well under a second. Compiled once per session with Add-Type; a type can't be unloaded, so a later import of the module reuses it. TerraformPlanFlattener.Flatten(before, after, afterUnknown, beforeSensitive, afterSensitive, label, replacePaths) walks one change's before and after values side by side - as ConvertFrom-Json -AsHashtable reads them - and returns a TerraformLeaf per value that changes: Raw the path as plan keys and list indexes, to compare with replace_paths Attribute the path as it reads: tags["cost.centre"], site_config[0].always_on, security_rule[name=ssh].access, policy_rule{json}.then.effect Before, After, Change (Added, Removed, Modified, Known after apply, Reordered, Reformatted - the same JSON, spaced differently), Changed, Sensitive, Unknown, and ForcesReplacement when replace_paths names it or a path above it ReplacePathLabels(replacePaths) writes replace_paths the same way; Fact(first, second, name) reads a top-level text attribute (name, location, resource_group_name, id) from the first that has it. Maps are walked key by key, with keys only in after_unknown (values known after apply) included. A list of blocks is walked element by element, paired first when identical, then by their 'name', then in order; a list whose elements are the same in another order is one Reordered leaf. A string holding JSON on both sides is walked inside ({json}), unless only its spacing changed. Values marked in before_sensitive or after_sensitive come back as '(sensitive)' and values in after_unknown as '(known after apply)'. Empty maps and lists, and nulls, on both sides are left out. #> [CmdletBinding()] param() if ('AzureAdminConsole.TerraformPlanFlattener' -as [type]) { return } Add-Type -TypeDefinition @' using System; using System.Collections; using System.Collections.Generic; using System.Globalization; using System.Text; using System.Text.Encodings.Web; using System.Text.Json; namespace AzureAdminConsole { public sealed class TerraformLeaf { public object[] Raw; public string Attribute; public string Before; public string After; public string Change; public bool Changed; public bool Sensitive; public bool Unknown; public bool ForcesReplacement; } public static class TerraformPlanFlattener { public const string SensitiveText = "(sensitive)"; public const string UnknownText = "(known after apply)"; // The leaves that change, each marked when replace_paths names it // (or a path above it). public static List<TerraformLeaf> Flatten(object before, object after, object afterUnknown, object beforeSensitive, object afterSensitive, string label, object replacePaths) { var leaves = new List<TerraformLeaf>(); Walk(new List<object>(), label ?? "", Unwrap(before), Unwrap(after), Unwrap(afterUnknown), Unwrap(beforeSensitive), Unwrap(afterSensitive), leaves); var paths = ReplacePaths(replacePaths); var changed = new List<TerraformLeaf>(leaves.Count); foreach (var leaf in leaves) { if (!leaf.Changed) { continue; } foreach (var path in paths) { if (path.Length > leaf.Raw.Length) { continue; } bool match = true; for (int i = 0; match && i < path.Length; i++) { match = string.Equals(path[i], Convert.ToString(leaf.Raw[i], CultureInfo.InvariantCulture), StringComparison.Ordinal); } if (match) { leaf.ForcesReplacement = true; break; } } changed.Add(leaf); } return changed; } static List<string[]> ReplacePaths(object replacePaths) { var paths = new List<string[]>(); var list = Unwrap(replacePaths) as IList; if (list == null) { return paths; } foreach (var item in list) { var path = Unwrap(item) as IList; if (path == null) { continue; } var segments = new string[path.Count]; for (int i = 0; i < path.Count; i++) { segments[i] = Convert.ToString(Unwrap(path[i]), CultureInfo.InvariantCulture); } paths.Add(segments); } return paths; } // replace_paths as the attributes read: location, network_rules[0].default_action. public static string[] ReplacePathLabels(object replacePaths) { var labels = new List<string>(); foreach (var path in ReplacePaths(replacePaths)) { var label = ""; foreach (var segment in path) { int index; label = segment.Length > 0 && char.IsDigit(segment[0]) && int.TryParse(segment, NumberStyles.None, CultureInfo.InvariantCulture, out index) ? label + "[" + segment + "]" : JoinLabel(label, segment); } labels.Add(label); } return labels.ToArray(); } // A top-level text attribute (name, location, id) from the first // value that has it: after then before, or before then after. public static string Fact(object first, object second, string name) { foreach (var side in new[] { Unwrap(first), Unwrap(second) }) { var map = side as IDictionary; if (map == null || !map.Contains(name)) { continue; } var text = Unwrap(map[name]) as string; if (!string.IsNullOrEmpty(text)) { return text; } } return null; } // Values set from PowerShell can arrive wrapped in a PSObject. static object Unwrap(object value) { if (value != null && value.GetType().FullName == "System.Management.Automation.PSObject") { return value.GetType().GetProperty("BaseObject").GetValue(value); } return value; } static bool IsTrue(object value) { return value is bool && (bool)value; } static bool IsBlocks(object value) { var list = value as IList; if (list == null) { return false; } foreach (var item in list) { if (Unwrap(item) is IDictionary) { return true; } } return false; } // A sensitive or unknown marker: true, or a structure with true in it. static bool Marked(object marker) { marker = Unwrap(marker); if (marker is bool) { return (bool)marker; } var map = marker as IDictionary; if (map != null) { foreach (DictionaryEntry entry in map) { if (Marked(entry.Value)) { return true; } } return false; } var list = marker as IList; if (list != null) { foreach (var item in list) { if (Marked(item)) { return true; } } } return false; } static object Child(object container, object key) { if (key == null) { return null; } var map = container as IDictionary; if (map != null) { return map.Contains(key) ? Unwrap(map[key]) : null; } var list = container as IList; if (list != null && key is int) { int index = (int)key; return index >= 0 && index < list.Count ? Unwrap(list[index]) : null; } return null; } static bool Blank(object value) { if (value == null) { return true; } var map = value as IDictionary; if (map != null) { return map.Count == 0; } var list = value as IList; return list != null && list.Count == 0; } static string Quote(string text) { return "\"" + JsonEncodedText.Encode(text, JavaScriptEncoder.UnsafeRelaxedJsonEscaping).ToString() + "\""; } // One canonical text per value - keys sorted - to compare values. public static string Canonical(object value) { var text = new StringBuilder(); AppendCanonical(Unwrap(value), text); return text.ToString(); } static void AppendCanonical(object value, StringBuilder text) { value = Unwrap(value); if (value == null) { text.Append("null"); return; } var s = value as string; if (s != null) { text.Append(Quote(s)); return; } if (value is bool) { text.Append((bool)value ? "true" : "false"); return; } var map = value as IDictionary; if (map != null) { var keys = SortedKeys(map); text.Append('{'); for (int i = 0; i < keys.Count; i++) { if (i > 0) { text.Append(','); } text.Append(Quote(keys[i])).Append(':'); AppendCanonical(map[keys[i]], text); } text.Append('}'); return; } var list = value as IList; if (list != null) { text.Append('['); for (int i = 0; i < list.Count; i++) { if (i > 0) { text.Append(','); } AppendCanonical(list[i], text); } text.Append(']'); return; } var formattable = value as IFormattable; text.Append(formattable != null ? formattable.ToString(null, CultureInfo.InvariantCulture) : value.ToString()); } static List<string> SortedKeys(IDictionary map) { var keys = new List<string>(map.Count); foreach (var key in map.Keys) { keys.Add(Convert.ToString(key, CultureInfo.InvariantCulture)); } keys.Sort(StringComparer.Ordinal); return keys; } // A value as it reads: text as it is, anything else as compact JSON. static string Format(object value) { if (value == null) { return null; } var s = value as string; return s ?? Canonical(value); } static string JoinLabel(string label, string key) { bool plain = key.Length > 0 && (char.IsLetter(key[0]) || key[0] == '_'); for (int i = 1; plain && i < key.Length; i++) { plain = char.IsLetterOrDigit(key[i]) || key[i] == '_' || key[i] == '-'; } if (!plain) { return label + "[" + Quote(key) + "]"; } return label.Length > 0 ? label + "." + key : key; } static void Walk(List<object> raw, string label, object before, object after, object unknown, object beforeSensitive, object afterSensitive, List<TerraformLeaf> leaves) { bool wholeSecret = IsTrue(beforeSensitive) || IsTrue(afterSensitive); bool wholeUnknown = IsTrue(unknown); if (!wholeSecret && !wholeUnknown) { var beforeMap = before as IDictionary; var afterMap = after as IDictionary; if (beforeMap != null || afterMap != null) { var seen = new HashSet<string>(StringComparer.Ordinal); var keys = new List<string>(); foreach (var map in new[] { beforeMap, afterMap }) { if (map == null) { continue; } foreach (var key in SortedKeys(map)) { if (seen.Add(key)) { keys.Add(key); } } } // Attributes known only after apply are in after_unknown alone. var unknownMap = unknown as IDictionary; if (unknownMap != null) { foreach (var key in SortedKeys(unknownMap)) { if (Marked(unknownMap[key]) && seen.Add(key)) { keys.Add(key); } } } if (keys.Count > 0) { keys.Sort(StringComparer.Ordinal); foreach (var key in keys) { raw.Add(key); Walk(raw, JoinLabel(label, key), Child(before, key), Child(after, key), Child(unknown, key), Child(beforeSensitive, key), Child(afterSensitive, key), leaves); raw.RemoveAt(raw.Count - 1); } return; } } else if (IsBlocks(before) || IsBlocks(after)) { WalkBlocks(raw, label, before, after, unknown, beforeSensitive, afterSensitive, leaves); return; } } bool secret = wholeSecret || Marked(beforeSensitive) || Marked(afterSensitive); bool isUnknown = wholeUnknown || Marked(unknown); if (!isUnknown && Blank(before) && Blank(after)) { return; } // A JSON-encoded string (policy rules, ARM templates, azapi // bodies): the changes inside it, not two walls of text. var beforeText = before as string; var afterText = after as string; bool reformatted = false; if (!secret && !isUnknown && beforeText != null && afterText != null && !string.Equals(beforeText, afterText, StringComparison.Ordinal)) { var beforeJson = ParseJsonContainer(beforeText); var afterJson = beforeJson == null ? null : ParseJsonContainer(afterText); if (beforeJson != null && afterJson != null) { int start = leaves.Count; Walk(raw, label + "{json}", beforeJson, afterJson, null, null, null, leaves); for (int i = start; i < leaves.Count; i++) { if (leaves[i].Changed) { return; } } // Same JSON, different spacing: one leaf, not none. leaves.RemoveRange(start, leaves.Count - start); reformatted = true; } } leaves.Add(new TerraformLeaf { Raw = raw.ToArray(), Attribute = label, Before = secret && before != null ? SensitiveText : Format(before), After = isUnknown ? UnknownText : (secret && after != null ? SensitiveText : Format(after)), Change = isUnknown ? "Known after apply" : reformatted ? "Reformatted" : Blank(before) ? "Added" : Blank(after) ? "Removed" : "Modified", Changed = isUnknown || !string.Equals(Canonical(before), Canonical(after), StringComparison.Ordinal), Sensitive = secret, Unknown = isUnknown }); } static string NameOf(object item) { var map = item as IDictionary; if (map == null || !map.Contains("name")) { return null; } var name = Unwrap(map["name"]) as string; return string.IsNullOrEmpty(name) ? null : name; } static List<object> Items(object value) { var items = new List<object>(); var list = value as IList; if (list != null) { foreach (var item in list) { items.Add(Unwrap(item)); } } return items; } // A list of blocks: elements paired identical first, then by name, // then in order; what's left was added or removed. static void WalkBlocks(List<object> raw, string label, object before, object after, object unknown, object beforeSensitive, object afterSensitive, List<TerraformLeaf> leaves) { var beforeItems = Items(before); var afterItems = Items(after); var beforeKeys = beforeItems.ConvertAll(Canonical); var afterKeys = afterItems.ConvertAll(Canonical); var beforeUsed = new bool[beforeItems.Count]; var pairOf = new int[afterItems.Count]; for (int a = 0; a < pairOf.Length; a++) { pairOf[a] = -1; } for (int a = 0; a < afterItems.Count; a++) { if (Marked(Child(unknown, a))) { continue; } for (int b = 0; b < beforeItems.Count; b++) { if (!beforeUsed[b] && string.Equals(beforeKeys[b], afterKeys[a], StringComparison.Ordinal)) { beforeUsed[b] = true; pairOf[a] = b; break; } } } for (int a = 0; a < afterItems.Count; a++) { var name = NameOf(afterItems[a]); if (pairOf[a] >= 0 || name == null) { continue; } for (int b = 0; b < beforeItems.Count; b++) { if (!beforeUsed[b] && string.Equals(NameOf(beforeItems[b]), name, StringComparison.Ordinal)) { beforeUsed[b] = true; pairOf[a] = b; break; } } } for (int a = 0; a < afterItems.Count; a++) { if (pairOf[a] >= 0 || NameOf(afterItems[a]) != null) { continue; } for (int b = 0; b < beforeItems.Count; b++) { if (!beforeUsed[b] && NameOf(beforeItems[b]) == null) { beforeUsed[b] = true; pairOf[a] = b; break; } } } int start = leaves.Count; for (int a = 0; a < afterItems.Count; a++) { WalkElement(raw, label, beforeItems, afterItems, pairOf[a] >= 0 ? pairOf[a] : -1, a, unknown, beforeSensitive, afterSensitive, leaves); } for (int b = 0; b < beforeItems.Count; b++) { if (!beforeUsed[b]) { WalkElement(raw, label, beforeItems, afterItems, b, -1, unknown, beforeSensitive, afterSensitive, leaves); } } // Same elements, another order. for (int i = start; i < leaves.Count; i++) { if (leaves[i].Changed) { return; } } if (beforeItems.Count > 0 && afterItems.Count > 0 && !string.Equals(string.Join(",", beforeKeys), string.Join(",", afterKeys), StringComparison.Ordinal)) { leaves.Add(new TerraformLeaf { Raw = raw.ToArray(), Attribute = label, Before = Format(before), After = Format(after), Change = "Reordered", Changed = true }); } } static void WalkElement(List<object> raw, string label, List<object> beforeItems, List<object> afterItems, int b, int a, object unknown, object beforeSensitive, object afterSensitive, List<TerraformLeaf> leaves) { int index = a >= 0 ? a : b; var name = NameOf(a >= 0 ? afterItems[a] : beforeItems[b]); raw.Add(index); Walk(raw, name != null ? label + "[name=" + name + "]" : label + "[" + index.ToString(CultureInfo.InvariantCulture) + "]", b >= 0 ? beforeItems[b] : null, a >= 0 ? afterItems[a] : null, a >= 0 ? Child(unknown, a) : null, b >= 0 ? Child(beforeSensitive, b) : null, a >= 0 ? Child(afterSensitive, a) : null, leaves); raw.RemoveAt(raw.Count - 1); } static object ParseJsonContainer(string text) { var trimmed = text.Trim(); if (trimmed.Length < 2) { return null; } bool container = (trimmed[0] == '{' && trimmed[trimmed.Length - 1] == '}') || (trimmed[0] == '[' && trimmed[trimmed.Length - 1] == ']'); if (!container) { return null; } try { using (var document = JsonDocument.Parse(trimmed)) { return FromJson(document.RootElement); } } catch (JsonException) { return null; } } static object FromJson(JsonElement element) { switch (element.ValueKind) { case JsonValueKind.Object: var map = new Dictionary<string, object>(StringComparer.Ordinal); foreach (var property in element.EnumerateObject()) { map[property.Name] = FromJson(property.Value); } return map; case JsonValueKind.Array: var list = new List<object>(); foreach (var item in element.EnumerateArray()) { list.Add(FromJson(item)); } return list; case JsonValueKind.String: return element.GetString(); case JsonValueKind.Number: long whole; if (element.TryGetInt64(out whole)) { return whole; } decimal exact; if (element.TryGetDecimal(out exact)) { return exact; } return element.GetDouble(); case JsonValueKind.True: return true; case JsonValueKind.False: return false; default: return null; } } } } '@ } |