| 1 | import * as Effect from "effect/Effect"; |
| 2 | import * as Redacted from "effect/Redacted"; |
| 3 | import { havePropsChanged } from "../Diff.ts"; |
| 4 | import * as Output from "../Output.ts"; |
| 5 | import { isPlainData } from "../Util/data.ts"; |
| 6 | import { stringify } from "yaml"; |
| 7 | |
| 8 | export type YamlDisplayValue = |
| 9 | | string |
| 10 | | number |
| 11 | | boolean |
| 12 | | null |
| 13 | | YamlDisplayValue[] |
| 14 | | { readonly [key: string]: YamlDisplayValue }; |
| 15 | |
| 16 | export interface DeclaredPropertyYaml { |
| 17 | readonly kind: "create" | "change" | "drift"; |
| 18 | readonly lines: ReadonlyArray<string>; |
| 19 | } |
| 20 | |
| 21 | const unifiedDriftLines = ( |
| 22 | expected: YamlDisplayValue, |
| 23 | actual: YamlDisplayValue, |
| 24 | depth = 0, |
| 25 | ): string[] => { |
| 26 | const padding = " ".repeat(depth); |
| 27 | if (JSON.stringify(expected) === JSON.stringify(actual)) { |
| 28 | return indent(formatYamlLines(expected), depth); |
| 29 | } |
| 30 | if ( |
| 31 | expected !== null && |
| 32 | actual !== null && |
| 33 | !Array.isArray(expected) && |
| 34 | !Array.isArray(actual) && |
| 35 | typeof expected === "object" && |
| 36 | typeof actual === "object" |
| 37 | ) { |
| 38 | const lines: string[] = []; |
| 39 | for (const key of [ |
| 40 | ...new Set([...Object.keys(expected), ...Object.keys(actual)]), |
| 41 | ].sort((a, b) => a.localeCompare(b))) { |
| 42 | const hasExpected = Object.hasOwn(expected, key); |
| 43 | const hasActual = Object.hasOwn(actual, key); |
| 44 | const expectedValue = hasExpected ? expected[key]! : UNDEFINED; |
| 45 | const actualValue = hasActual ? actual[key]! : UNDEFINED; |
| 46 | if ( |
| 47 | hasExpected && |
| 48 | hasActual && |
| 49 | expectedValue !== null && |
| 50 | actualValue !== null && |
| 51 | !Array.isArray(expectedValue) && |
| 52 | !Array.isArray(actualValue) && |
| 53 | typeof expectedValue === "object" && |
| 54 | typeof actualValue === "object" && |
| 55 | JSON.stringify(expectedValue) !== JSON.stringify(actualValue) |
| 56 | ) { |
| 57 | lines.push(`${padding}${key}:`); |
| 58 | lines.push(...unifiedDriftLines(expectedValue, actualValue, depth + 2)); |
| 59 | } else if ( |
| 60 | JSON.stringify(expectedValue) === JSON.stringify(actualValue) |
| 61 | ) { |
| 62 | lines.push(...indent(formatYamlLines({ [key]: expectedValue }), depth)); |
| 63 | } else { |
| 64 | if (hasExpected) { |
| 65 | lines.push( |
| 66 | ...indent(formatYamlLines({ [key]: expectedValue }), depth).map( |
| 67 | mark("-"), |
| 68 | ), |
| 69 | ); |
| 70 | } |
| 71 | if (hasActual) { |
| 72 | lines.push( |
| 73 | ...indent(formatYamlLines({ [key]: actualValue }), depth).map( |
| 74 | mark("+"), |
| 75 | ), |
| 76 | ); |
| 77 | } |
| 78 | } |
| 79 | } |
| 80 | return lines; |
| 81 | } |
| 82 | return [ |
| 83 | ...indent(formatYamlLines(expected), depth).map(mark("-")), |
| 84 | ...indent(formatYamlLines(actual), depth).map(mark("+")), |
| 85 | ]; |
| 86 | }; |
| 87 | |
| 88 | const mark = (marker: "-" | "+") => (line: string) => { |
| 89 | return `${marker} ${line}`; |
| 90 | }; |
| 91 | |
| 92 | export interface YamlChangeMatch { |
| 93 | readonly marker: "-" | "+"; |
| 94 | readonly content: string; |
| 95 | } |
| 96 | |
| 97 | /** Match a `- `/`+ ` change marker emitted by {@link unifiedDriftLines}. */ |
| 98 | export const matchYamlChange = (line: string): YamlChangeMatch | undefined => { |
| 99 | const match = line.match(/^([+-]) (.*)$/); |
| 100 | return match === null |
| 101 | ? undefined |
| 102 | : { marker: match[1] as "-" | "+", content: match[2]! }; |
| 103 | }; |
| 104 | |
| 105 | export interface YamlKeyMatch { |
| 106 | readonly indent: string; |
| 107 | /** The key including its trailing colon. */ |
| 108 | readonly key: string; |
| 109 | readonly value: string; |
| 110 | } |
| 111 | |
| 112 | /** Match an `indent` + `key:` + rest display-YAML line for colorizing. */ |
| 113 | export const matchYamlKey = (line: string): YamlKeyMatch | undefined => { |
| 114 | const match = line.match(/^(\s*)([A-Za-z_][\w .-]*:)(.*)$/); |
| 115 | return match === null |
| 116 | ? undefined |
| 117 | : { indent: match[1]!, key: match[2]!, value: match[3]! }; |
| 118 | }; |
| 119 | |
| 120 | /** Format the changed cloud attributes carried by a drift-repair plan. */ |
| 121 | export const formatDriftPropertyYaml = ( |
| 122 | expected: unknown, |
| 123 | actual: unknown, |
| 124 | missing = false, |
| 125 | ): DeclaredPropertyYaml => { |
| 126 | const expectedValue = toYamlDisplayValue(expected); |
| 127 | const actualValue = missing ? "(missing)" : toYamlDisplayValue(actual); |
| 128 | return { |
| 129 | kind: "drift", |
| 130 | lines: unifiedDriftLines(expectedValue, actualValue), |
| 131 | }; |
| 132 | }; |
| 133 | |
| 134 | const REDACTED = "(redacted)"; |
| 135 | const KNOWN_AFTER_APPLY = "(known after apply)"; |
| 136 | const COMPUTED = "(computed)"; |
| 137 | const UNDEFINED = "(undefined)"; |
| 138 | const OPAQUE = "(opaque)"; |
| 139 | const CIRCULAR = "(circular)"; |
| 140 | |
| 141 | /** |
| 142 | * Turn arbitrary declared inputs or persisted state into safe, deterministic |
| 143 | * plain data for terminal display. Deferred values are described, never run, |
| 144 | * and Redacted values are replaced before their contents can reach YAML. |
| 145 | */ |
| 146 | export const toYamlDisplayValue = ( |
| 147 | value: unknown, |
| 148 | ancestors: WeakSet<object> = new WeakSet(), |
| 149 | ): YamlDisplayValue => { |
| 150 | // Output expressions are Effects too, so this order is security-sensitive. |
| 151 | if (Redacted.isRedacted(value)) return REDACTED; |
| 152 | if (Output.isExpr(value)) return KNOWN_AFTER_APPLY; |
| 153 | if (Effect.isEffect(value) || typeof value === "function") return COMPUTED; |
| 154 | if (value === undefined) return UNDEFINED; |
| 155 | if (value === null || typeof value === "string") return value; |
| 156 | if (typeof value === "boolean" || typeof value === "number") return value; |
| 157 | if (typeof value === "bigint") return `${value}n`; |
| 158 | if (!isPlainData(value)) return OPAQUE; |
| 159 | if (ancestors.has(value)) return CIRCULAR; |
| 160 | |
| 161 | ancestors.add(value); |
| 162 | try { |
| 163 | if (Array.isArray(value)) { |
| 164 | return value.map((item) => toYamlDisplayValue(item, ancestors)); |
| 165 | } |
| 166 | return Object.fromEntries( |
| 167 | Object.keys(value) |
| 168 | .sort((a, b) => a.localeCompare(b)) |
| 169 | .map((key) => [ |
| 170 | key, |
| 171 | toYamlDisplayValue( |
| 172 | (value as Record<string, unknown>)[key], |
| 173 | ancestors, |
| 174 | ), |
| 175 | ]), |
| 176 | ); |
| 177 | } finally { |
| 178 | ancestors.delete(value); |
| 179 | } |
| 180 | }; |
| 181 | |
| 182 | /** Format a value as stable, unadorned YAML lines for terminal renderers. */ |
| 183 | export const formatYamlLines = (value: unknown): string[] => { |
| 184 | const yaml = stringify(toYamlDisplayValue(value), { |
| 185 | lineWidth: 0, |
| 186 | sortMapEntries: true, |
| 187 | }); |
| 188 | return (yaml || `${UNDEFINED}\n`).trimEnd().split("\n"); |
| 189 | }; |
| 190 | |
| 191 | /** |
| 192 | * Build the detailed property document for a plan resource. This compares |
| 193 | * persisted declared props to desired declared props; it is not cloud drift. |
| 194 | */ |
| 195 | export const formatDeclaredPropertyYaml = ( |
| 196 | oldProps: unknown, |
| 197 | newProps: unknown, |
| 198 | action: "create" | "update" | "replace", |
| 199 | ): DeclaredPropertyYaml | undefined => { |
| 200 | const desired = newProps ?? {}; |
| 201 | if (action === "create") { |
| 202 | return { |
| 203 | kind: "create", |
| 204 | lines: ["properties:", ...indent(formatYamlLines(desired))], |
| 205 | }; |
| 206 | } |
| 207 | if ( |
| 208 | !havePropsChanged( |
| 209 | (oldProps ?? {}) as Record<string, unknown>, |
| 210 | desired as Record<string, unknown>, |
| 211 | ) |
| 212 | ) { |
| 213 | return undefined; |
| 214 | } |
| 215 | return { |
| 216 | kind: "change", |
| 217 | lines: [ |
| 218 | "properties:", |
| 219 | ...unifiedDriftLines( |
| 220 | toYamlDisplayValue(oldProps ?? {}), |
| 221 | toYamlDisplayValue(desired), |
| 222 | 2, |
| 223 | ), |
| 224 | ], |
| 225 | }; |
| 226 | }; |
| 227 | |
| 228 | const indent = (lines: ReadonlyArray<string>, spaces = 2): string[] => |
| 229 | lines.map((line) => `${" ".repeat(spaces)}${line}`); |