microsoft/TypeAgent
Publicmirrored fromhttps://github.com/microsoft/TypeAgentAvailable
ts/tools/scripts/dedupeDeployments.mjs
801lines · modecode
| 1 | #!/usr/bin/env node |
| 2 | // Copyright (c) Microsoft Corporation. |
| 3 | // Licensed under the MIT License. |
| 4 | // |
| 5 | // Dedupe Azure OpenAI deployments that share (account, model family, mode). |
| 6 | // For each duplicate group, picks a winner (highest SKU tier, then highest |
| 7 | // capacity) and plans the deletion of the losers. BEFORE deleting, scans the |
| 8 | // shared Key Vault for any secret whose value references the loser's |
| 9 | // endpoint URL and re-points those secrets to the winner. This prevents |
| 10 | // runtime breakage on the live app. |
| 11 | // |
| 12 | // Dry-run by default. Nothing mutates until you pass --commit. |
| 13 | // |
| 14 | // Usage: |
| 15 | // node tools/scripts/dedupeDeployments.mjs [--vault aisystems] |
| 16 | // node tools/scripts/dedupeDeployments.mjs --commit |
| 17 | |
| 18 | import chalk from "chalk"; |
| 19 | import child_process from "node:child_process"; |
| 20 | import { execAzCliCommand, getAzCliLoggedInInfo } from "./lib/azureUtils.mjs"; |
| 21 | |
| 22 | // --------------- arg parsing --------------- |
| 23 | |
| 24 | function parseArgs() { |
| 25 | const args = process.argv.slice(2); |
| 26 | const options = { |
| 27 | vault: "aisystems", |
| 28 | commit: false, |
| 29 | dropDeployments: new Set(), |
| 30 | }; |
| 31 | for (let i = 0; i < args.length; i++) { |
| 32 | switch (args[i]) { |
| 33 | case "--vault": |
| 34 | options.vault = args[++i]; |
| 35 | break; |
| 36 | case "--commit": |
| 37 | options.commit = true; |
| 38 | break; |
| 39 | case "--dry-run": |
| 40 | // Explicitly no-op: dry-run is the default. Accepted for |
| 41 | // clarity / muscle memory. |
| 42 | options.commit = false; |
| 43 | break; |
| 44 | case "--drop-deployment": |
| 45 | // Force-drop these specific deployments regardless of |
| 46 | // classification. Useful when you want to retire an entire |
| 47 | // alias group (e.g. France's gpt-4/gpt-4-32k) without any |
| 48 | // replacement. Any KV secret referencing a dropped |
| 49 | // deployment with no available winner is deleted too. |
| 50 | for (const name of (args[++i] ?? "") |
| 51 | .split(",") |
| 52 | .map((s) => s.trim()) |
| 53 | .filter(Boolean)) { |
| 54 | options.dropDeployments.add(name); |
| 55 | } |
| 56 | break; |
| 57 | default: |
| 58 | throw new Error(`Unknown argument: ${args[i]}`); |
| 59 | } |
| 60 | } |
| 61 | return options; |
| 62 | } |
| 63 | |
| 64 | // --------------- logging --------------- |
| 65 | |
| 66 | const status = (m) => console.log(chalk.gray(m)); |
| 67 | const info = (m) => console.log(m); |
| 68 | const ok = (m) => console.log(chalk.greenBright(m)); |
| 69 | const warn = (m) => console.error(chalk.yellowBright(m)); |
| 70 | const errLog = (m) => console.error(chalk.redBright(m)); |
| 71 | |
| 72 | // --------------- classification --------------- |
| 73 | |
| 74 | function skuMode(skuName) { |
| 75 | if (!skuName) return "unknown"; |
| 76 | if (skuName.includes("Provisioned")) return "PTU"; |
| 77 | return "PAYG"; |
| 78 | } |
| 79 | |
| 80 | // Higher rank = better. GlobalStandard > Standard. ProvisionedManaged is in |
| 81 | // its own mode bucket so we never rank it against PAYG deployments. |
| 82 | function skuRank(skuName) { |
| 83 | if (!skuName) return 0; |
| 84 | if (skuName === "GlobalStandard") return 3; |
| 85 | if (skuName === "Standard") return 2; |
| 86 | if (skuName === "ProvisionedManaged") return 10; |
| 87 | return 1; |
| 88 | } |
| 89 | |
| 90 | function escapeRegExp(s) { |
| 91 | return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); |
| 92 | } |
| 93 | |
| 94 | // Mirrors modelNameToSuffix in syncPoolSecrets.mjs — translate an OpenAI model |
| 95 | // name (e.g. "gpt-4o", "text-embedding-ada-002") into the env-var suffix |
| 96 | // convention this repo uses (e.g. "GPT_4_O", "EMBEDDING"). |
| 97 | function modelNameToSuffix(modelName) { |
| 98 | if (!modelName) return undefined; |
| 99 | let n = modelName.toLowerCase(); |
| 100 | // Specific embedding models before the generic EMBEDDING fallback. |
| 101 | if (n === "text-embedding-3-small") return "EMBEDDING_3_SMALL"; |
| 102 | if (n === "text-embedding-3-large") return "EMBEDDING_3_LARGE"; |
| 103 | if (n === "text-embedding-ada-002" || /^ada(-\d+)?$/.test(n)) { |
| 104 | return "EMBEDDING"; |
| 105 | } |
| 106 | if (n.includes("embedding")) return "EMBEDDING"; |
| 107 | if (n === "gpt-image-1.5") return "GPT_IMAGE_1_5"; |
| 108 | if (n === "gpt-image-1") return "GPT_IMAGE_1"; |
| 109 | if (n.startsWith("dall-e") || n === "dalle") return "DALLE"; |
| 110 | if (n === "sora-2" || n === "sora") return "SORA_2"; |
| 111 | // Preserve the repo's "GPT_4_O" separator convention (see note in |
| 112 | // syncPoolSecrets.mjs). |
| 113 | n = n.replace(/gpt-4o/g, "gpt-4-o"); |
| 114 | const upper = n.replace(/[.\-]/g, "_").replace(/__+/g, "_").toUpperCase(); |
| 115 | if (upper === "GPT_35_TURBO_16K") return "GPT_35_TURBO"; |
| 116 | return upper; |
| 117 | } |
| 118 | |
| 119 | const REGION_TOKENS = new Set([ |
| 120 | "EASTUS", |
| 121 | "EASTUS2", |
| 122 | "WESTUS", |
| 123 | "WESTUS2", |
| 124 | "WESTUS3", |
| 125 | "CENTRALUS", |
| 126 | "NORTHCENTRALUS", |
| 127 | "SOUTHCENTRALUS", |
| 128 | "WESTCENTRALUS", |
| 129 | "SWEDENCENTRAL", |
| 130 | "FRANCECENTRAL", |
| 131 | "GERMANYWESTCENTRAL", |
| 132 | "NORWAYEAST", |
| 133 | "NORTHEUROPE", |
| 134 | "WESTEUROPE", |
| 135 | "UKSOUTH", |
| 136 | "UKWEST", |
| 137 | "SWITZERLANDNORTH", |
| 138 | "JAPANEAST", |
| 139 | "JAPANWEST", |
| 140 | "AUSTRALIAEAST", |
| 141 | "KOREACENTRAL", |
| 142 | "SOUTHEASTASIA", |
| 143 | "EASTASIA", |
| 144 | "CENTRALINDIA", |
| 145 | "SOUTHINDIA", |
| 146 | "BRAZILSOUTH", |
| 147 | "CANADACENTRAL", |
| 148 | "CANADAEAST", |
| 149 | "SWEDEN", |
| 150 | "JAPAN", |
| 151 | "AUSTRALIA", |
| 152 | "BRAZIL", |
| 153 | "CANADA", |
| 154 | "KOREA", |
| 155 | "UK", |
| 156 | ]); |
| 157 | |
| 158 | // Normalize a model or deployment name for comparison: |
| 159 | // lowercase, drop dots (so "gpt-4.1-mini" == "gpt-41-mini"), strip common |
| 160 | // verbose prefixes ("text-embedding-ada-002" → "ada-002"). |
| 161 | function normalizeForMatch(name) { |
| 162 | if (!name) return ""; |
| 163 | return name |
| 164 | .toLowerCase() |
| 165 | .replace(/^text-embedding-/, "") |
| 166 | .replace(/\./g, ""); |
| 167 | } |
| 168 | |
| 169 | // Classify a deployment's name relative to the model it actually serves: |
| 170 | // canonical: name matches the model (e.g. "gpt-4o" serving gpt-4o, |
| 171 | // "ada-002" serving text-embedding-ada-002). |
| 172 | // tagged: name starts with the model and adds a purpose token |
| 173 | // (e.g. "ada-002-indexing" serving text-embedding-ada-002). |
| 174 | // The tag is kept and surfaces as a distinct secret name. |
| 175 | // legacy: name starts with the model and adds a numeric suffix |
| 176 | // (e.g. "gpt-4o-2", "gpt-4o-v3"). Historical capacity-stacking |
| 177 | // variants, not purposeful ones. We don't surface these as pool |
| 178 | // members and don't dedupe them — keep them running for |
| 179 | // existing consumers while new canonical names get provisioned |
| 180 | // and migrated to. |
| 181 | // alias: name doesn't match the model (e.g. "gpt-35-turbo" |
| 182 | // serving gpt-4.1-mini — a historical in-place upgrade). An |
| 183 | // alias is a dedupe candidate; we'd rather drop it than keep |
| 184 | // a misleading deployment name. |
| 185 | function classifyDeployment(deploymentName, modelName) { |
| 186 | const model = normalizeForMatch(modelName); |
| 187 | const d = normalizeForMatch(deploymentName); |
| 188 | if (!model || !d) return { kind: "alias", tag: undefined }; |
| 189 | if (d === model) return { kind: "canonical", tag: undefined }; |
| 190 | if (d.startsWith(model + "-")) { |
| 191 | const tag = d.slice(model.length + 1); |
| 192 | // Numeric-only tags (including v-prefixed) are legacy capacity-stacking |
| 193 | // variants, not purposeful tags. |
| 194 | if (/^v?\d+$/i.test(tag)) return { kind: "legacy", tag }; |
| 195 | return { kind: "tagged", tag }; |
| 196 | } |
| 197 | return { kind: "alias", tag: undefined }; |
| 198 | } |
| 199 | |
| 200 | // Parse the model-family suffix out of a secret name: |
| 201 | // AZURE-OPENAI-ENDPOINT-GPT-35-TURBO -> "GPT_35_TURBO" |
| 202 | // AZURE-OPENAI-ENDPOINT-GPT-4-O-EASTUS -> "GPT_4_O" |
| 203 | // AZURE-OPENAI-API-KEY-GPT-4-O-EASTUS-PTU -> "GPT_4_O" |
| 204 | // AZURE-OPENAI-ENDPOINT-EMBEDDING -> "EMBEDDING" |
| 205 | // Returns undefined if the name doesn't fit the convention. |
| 206 | function extractModelFromSecret(secretName) { |
| 207 | let s = secretName; |
| 208 | if (s.startsWith("AZURE-OPENAI-ENDPOINT-")) { |
| 209 | s = s.slice("AZURE-OPENAI-ENDPOINT-".length); |
| 210 | } else if (s.startsWith("AZURE-OPENAI-API-KEY-")) { |
| 211 | s = s.slice("AZURE-OPENAI-API-KEY-".length); |
| 212 | } else { |
| 213 | return undefined; |
| 214 | } |
| 215 | if (s.endsWith("-PTU")) s = s.slice(0, -"-PTU".length); |
| 216 | const tokens = s.split("-"); |
| 217 | // Strip a trailing region token if present. |
| 218 | if (tokens.length > 1 && REGION_TOKENS.has(tokens[tokens.length - 1])) { |
| 219 | tokens.pop(); |
| 220 | } |
| 221 | return tokens.join("_"); |
| 222 | } |
| 223 | |
| 224 | // --------------- azure queries --------------- |
| 225 | |
| 226 | async function listAccounts(subscriptionId) { |
| 227 | status("Listing OpenAI / AIServices accounts..."); |
| 228 | const raw = await execAzCliCommand([ |
| 229 | "cognitiveservices", |
| 230 | "account", |
| 231 | "list", |
| 232 | "--subscription", |
| 233 | subscriptionId, |
| 234 | ]); |
| 235 | return JSON.parse(raw).filter( |
| 236 | (a) => a.kind === "OpenAI" || a.kind === "AIServices", |
| 237 | ); |
| 238 | } |
| 239 | |
| 240 | async function listDeployments(account) { |
| 241 | const raw = await execAzCliCommand([ |
| 242 | "cognitiveservices", |
| 243 | "account", |
| 244 | "deployment", |
| 245 | "list", |
| 246 | "--name", |
| 247 | account.name, |
| 248 | "--resource-group", |
| 249 | account.resourceGroup, |
| 250 | ]); |
| 251 | return JSON.parse(raw); |
| 252 | } |
| 253 | |
| 254 | async function listVaultSecretNames(vault) { |
| 255 | return new Promise((resolve, reject) => { |
| 256 | child_process.execFile( |
| 257 | "az", |
| 258 | [ |
| 259 | "keyvault", |
| 260 | "secret", |
| 261 | "list", |
| 262 | "--vault-name", |
| 263 | vault, |
| 264 | "--query", |
| 265 | "[].name", |
| 266 | ], |
| 267 | { shell: true }, |
| 268 | (e, stdout, stderr) => { |
| 269 | if (e) { |
| 270 | reject( |
| 271 | new Error( |
| 272 | `az keyvault secret list failed: ${stderr || e.message}`, |
| 273 | ), |
| 274 | ); |
| 275 | return; |
| 276 | } |
| 277 | try { |
| 278 | resolve(JSON.parse(stdout)); |
| 279 | } catch { |
| 280 | resolve([]); |
| 281 | } |
| 282 | }, |
| 283 | ); |
| 284 | }); |
| 285 | } |
| 286 | |
| 287 | async function readSecret(vault, name) { |
| 288 | return new Promise((resolve, reject) => { |
| 289 | child_process.execFile( |
| 290 | "az", |
| 291 | [ |
| 292 | "keyvault", |
| 293 | "secret", |
| 294 | "show", |
| 295 | "--vault-name", |
| 296 | vault, |
| 297 | "--name", |
| 298 | name, |
| 299 | "--query", |
| 300 | "value", |
| 301 | "-o", |
| 302 | "tsv", |
| 303 | ], |
| 304 | { shell: true }, |
| 305 | (e, stdout, stderr) => { |
| 306 | if (e) { |
| 307 | reject( |
| 308 | new Error( |
| 309 | `az keyvault secret show ${name} failed: ${stderr || e.message}`, |
| 310 | ), |
| 311 | ); |
| 312 | return; |
| 313 | } |
| 314 | resolve(stdout.trimEnd()); |
| 315 | }, |
| 316 | ); |
| 317 | }); |
| 318 | } |
| 319 | |
| 320 | async function writeSecret(vault, name, value) { |
| 321 | return new Promise((resolve, reject) => { |
| 322 | child_process.execFile( |
| 323 | "az", |
| 324 | [ |
| 325 | "keyvault", |
| 326 | "secret", |
| 327 | "set", |
| 328 | "--vault-name", |
| 329 | vault, |
| 330 | "--name", |
| 331 | name, |
| 332 | "--value", |
| 333 | value, |
| 334 | "--output", |
| 335 | "none", |
| 336 | ], |
| 337 | { shell: true }, |
| 338 | (e, _stdout, stderr) => { |
| 339 | if (e) { |
| 340 | reject( |
| 341 | new Error( |
| 342 | `az keyvault secret set ${name} failed: ${stderr || e.message}`, |
| 343 | ), |
| 344 | ); |
| 345 | return; |
| 346 | } |
| 347 | resolve(); |
| 348 | }, |
| 349 | ); |
| 350 | }); |
| 351 | } |
| 352 | |
| 353 | async function deleteSecret(vault, name) { |
| 354 | return new Promise((resolve, reject) => { |
| 355 | child_process.execFile( |
| 356 | "az", |
| 357 | [ |
| 358 | "keyvault", |
| 359 | "secret", |
| 360 | "delete", |
| 361 | "--vault-name", |
| 362 | vault, |
| 363 | "--name", |
| 364 | name, |
| 365 | "--output", |
| 366 | "none", |
| 367 | ], |
| 368 | { shell: true }, |
| 369 | (e, _stdout, stderr) => { |
| 370 | if (e) { |
| 371 | reject( |
| 372 | new Error( |
| 373 | `az keyvault secret delete ${name} failed: ${stderr || e.message}`, |
| 374 | ), |
| 375 | ); |
| 376 | return; |
| 377 | } |
| 378 | resolve(); |
| 379 | }, |
| 380 | ); |
| 381 | }); |
| 382 | } |
| 383 | |
| 384 | async function deleteDeployment(account, deploymentName) { |
| 385 | return execAzCliCommand([ |
| 386 | "cognitiveservices", |
| 387 | "account", |
| 388 | "deployment", |
| 389 | "delete", |
| 390 | "--name", |
| 391 | account.name, |
| 392 | "--resource-group", |
| 393 | account.resourceGroup, |
| 394 | "--deployment-name", |
| 395 | deploymentName, |
| 396 | ]); |
| 397 | } |
| 398 | |
| 399 | // --------------- dedupe plan --------------- |
| 400 | |
| 401 | function compareBySkuAndCap(a, b) { |
| 402 | const sa = skuRank(a.sku?.name); |
| 403 | const sb = skuRank(b.sku?.name); |
| 404 | if (sa !== sb) return sb - sa; |
| 405 | const ca = a.sku?.capacity ?? a.properties?.currentCapacity ?? 0; |
| 406 | const cb = b.sku?.capacity ?? b.properties?.currentCapacity ?? 0; |
| 407 | if (ca !== cb) return cb - ca; |
| 408 | const va = a.properties?.model?.version ?? ""; |
| 409 | const vb = b.properties?.model?.version ?? ""; |
| 410 | return vb.localeCompare(va); |
| 411 | } |
| 412 | |
| 413 | function buildDedupePlan(accounts, dropDeployments) { |
| 414 | // group key: accountId | modelName | mode |
| 415 | // Only ALIAS deployments are dedupe candidates by default. Canonical and |
| 416 | // tagged deployments are kept as-is. Anything in `dropDeployments` is |
| 417 | // force-dropped regardless of classification. |
| 418 | const groups = new Map(); |
| 419 | for (const { account, deployments } of accounts) { |
| 420 | for (const d of deployments) { |
| 421 | const model = d.properties?.model?.name; |
| 422 | if (!model) continue; |
| 423 | const mode = skuMode(d.sku?.name); |
| 424 | const { kind, tag } = classifyDeployment(d.name, model); |
| 425 | const key = `${account.id}|${model}|${mode}`; |
| 426 | if (!groups.has(key)) { |
| 427 | groups.set(key, { account, model, mode, entries: [] }); |
| 428 | } |
| 429 | groups.get(key).entries.push({ d, kind, tag }); |
| 430 | } |
| 431 | } |
| 432 | |
| 433 | const dupes = []; |
| 434 | for (const group of groups.values()) { |
| 435 | // Partition by kind, subtracting force-dropped deployments. |
| 436 | const forceDropped = group.entries |
| 437 | .filter((e) => dropDeployments.has(e.d.name)) |
| 438 | .map((e) => e.d); |
| 439 | const surviving = group.entries.filter( |
| 440 | (e) => !dropDeployments.has(e.d.name), |
| 441 | ); |
| 442 | const canonical = surviving |
| 443 | .filter((e) => e.kind === "canonical") |
| 444 | .map((e) => e.d); |
| 445 | const tagged = surviving |
| 446 | .filter((e) => e.kind === "tagged") |
| 447 | .map((e) => e.d); |
| 448 | const legacy = surviving |
| 449 | .filter((e) => e.kind === "legacy") |
| 450 | .map((e) => e.d); |
| 451 | const aliases = surviving |
| 452 | .filter((e) => e.kind === "alias") |
| 453 | .map((e) => e.d); |
| 454 | |
| 455 | const aliasesToDrop = [...aliases]; // default behavior: aliases will be reduced below |
| 456 | const allDrops = [...forceDropped]; |
| 457 | |
| 458 | // Pick a winner among survivors if any aliases need re-pointing. |
| 459 | let winner; |
| 460 | if (canonical.length > 0) { |
| 461 | winner = canonical.sort(compareBySkuAndCap)[0]; |
| 462 | allDrops.push(...aliasesToDrop); |
| 463 | } else if (tagged.length > 0) { |
| 464 | winner = tagged.sort(compareBySkuAndCap)[0]; |
| 465 | allDrops.push(...aliasesToDrop); |
| 466 | } else if (legacy.length > 0) { |
| 467 | // Legacy deployments are kept but not a great re-point target; |
| 468 | // still usable if nothing else exists in the group. |
| 469 | winner = legacy.sort(compareBySkuAndCap)[0]; |
| 470 | allDrops.push(...aliasesToDrop); |
| 471 | } else if (aliasesToDrop.length > 1) { |
| 472 | // All aliases, no force drops affected us yet — promote best |
| 473 | // alias as effective winner, drop the rest. |
| 474 | const sorted = [...aliasesToDrop].sort(compareBySkuAndCap); |
| 475 | winner = sorted[0]; |
| 476 | allDrops.push(...sorted.slice(1)); |
| 477 | } else { |
| 478 | // 0 or 1 alias surviving and no other survivors — nothing to |
| 479 | // promote. Dedupe still emits a plan entry if force drops exist |
| 480 | // (so KV secret handling runs), but with winner === undefined. |
| 481 | if (allDrops.length === 0) continue; |
| 482 | winner = undefined; |
| 483 | } |
| 484 | |
| 485 | if (allDrops.length === 0) continue; |
| 486 | |
| 487 | dupes.push({ |
| 488 | account: group.account, |
| 489 | model: group.model, |
| 490 | mode: group.mode, |
| 491 | winner, |
| 492 | losers: allDrops, |
| 493 | }); |
| 494 | } |
| 495 | return dupes; |
| 496 | } |
| 497 | |
| 498 | // For each secret whose value references a loser deployment, decide whether |
| 499 | // to re-point to the winner or drop the secret outright. Drop is preferred |
| 500 | // when the secret's name implies a *different* model than what the winner |
| 501 | // actually serves — re-pointing in that case would perpetuate misleading |
| 502 | // naming (e.g. a secret named AZURE-OPENAI-ENDPOINT-GPT-35-TURBO pointing at |
| 503 | // a deployment that actually serves gpt-4.1-mini). |
| 504 | // |
| 505 | // Returns { repoints: [...], drops: [...] } where |
| 506 | // repoint = { secretName, oldValue, newValue, loser, winner, account } |
| 507 | // drop = { secretName, loser, winner, account, reason } |
| 508 | function planSecretRewrites(secrets, dupes) { |
| 509 | const repoints = []; |
| 510 | const drops = []; |
| 511 | for (const dupe of dupes) { |
| 512 | const accountEndpoint = dupe.account.properties?.endpoint?.replace( |
| 513 | /\/+$/, |
| 514 | "", |
| 515 | ); |
| 516 | if (!accountEndpoint) continue; |
| 517 | const winner = dupe.winner; |
| 518 | const winnerPath = winner |
| 519 | ? `/openai/deployments/${winner.name}/` |
| 520 | : undefined; |
| 521 | const winnerModel = winner?.properties?.model?.name; |
| 522 | const winnerModelSuffix = winnerModel |
| 523 | ? modelNameToSuffix(winnerModel) |
| 524 | : undefined; |
| 525 | for (const loser of dupe.losers) { |
| 526 | const loserPath = `/openai/deployments/${loser.name}/`; |
| 527 | const loserFull = `${accountEndpoint}${loserPath}`; |
| 528 | for (const { name, value } of secrets) { |
| 529 | if (!value || typeof value !== "string") continue; |
| 530 | if (!value.includes(loserFull)) continue; |
| 531 | const secretModel = extractModelFromSecret(name); |
| 532 | |
| 533 | // Case 1: no winner in the group at all (e.g. user dropped |
| 534 | // every deployment in a group). Any referencing secret has |
| 535 | // nowhere to go → drop it. |
| 536 | if (!winner || !winnerPath) { |
| 537 | drops.push({ |
| 538 | secretName: name, |
| 539 | loser: loser.name, |
| 540 | winner: undefined, |
| 541 | account: dupe.account.name, |
| 542 | reason: `no replacement in ${dupe.account.name} for ${dupe.model}/${dupe.mode}`, |
| 543 | }); |
| 544 | continue; |
| 545 | } |
| 546 | |
| 547 | // Case 2: winner exists and matches the secret's implied |
| 548 | // model → re-point. |
| 549 | // Unknown secret shape → fall back to re-point (don't |
| 550 | // silently delete something we don't understand). |
| 551 | if ( |
| 552 | !secretModel || |
| 553 | !winnerModelSuffix || |
| 554 | secretModel === winnerModelSuffix |
| 555 | ) { |
| 556 | const newValue = value.replace( |
| 557 | new RegExp(escapeRegExp(loserFull), "g"), |
| 558 | `${accountEndpoint}${winnerPath}`, |
| 559 | ); |
| 560 | repoints.push({ |
| 561 | secretName: name, |
| 562 | oldValue: value, |
| 563 | newValue, |
| 564 | loser: loser.name, |
| 565 | winner: winner.name, |
| 566 | account: dupe.account.name, |
| 567 | }); |
| 568 | } else { |
| 569 | // Case 3: winner exists but serves a different model |
| 570 | // than the secret name implies → drop to avoid |
| 571 | // perpetuating misleading naming. |
| 572 | drops.push({ |
| 573 | secretName: name, |
| 574 | loser: loser.name, |
| 575 | winner: winner.name, |
| 576 | account: dupe.account.name, |
| 577 | reason: `secret says ${secretModel}, winner serves ${winnerModelSuffix}`, |
| 578 | }); |
| 579 | } |
| 580 | } |
| 581 | } |
| 582 | } |
| 583 | return { repoints, drops }; |
| 584 | } |
| 585 | |
| 586 | // --------------- main --------------- |
| 587 | |
| 588 | async function main() { |
| 589 | const options = parseArgs(); |
| 590 | const azInfo = await getAzCliLoggedInInfo(); |
| 591 | const mode = options.commit |
| 592 | ? chalk.redBright("COMMIT (will mutate)") |
| 593 | : chalk.cyan("dry-run (no changes)"); |
| 594 | info(`Mode: ${mode}`); |
| 595 | |
| 596 | const accountList = await listAccounts(azInfo.subscription.id); |
| 597 | const accounts = []; |
| 598 | for (const a of accountList) { |
| 599 | accounts.push({ account: a, deployments: await listDeployments(a) }); |
| 600 | } |
| 601 | |
| 602 | const dupes = buildDedupePlan(accounts, options.dropDeployments); |
| 603 | if (dupes.length === 0) { |
| 604 | ok("No duplicate deployments found. Nothing to do."); |
| 605 | return; |
| 606 | } |
| 607 | |
| 608 | info( |
| 609 | `\n${chalk.cyanBright("Deployments to drop")} (aliases, force-dropped via --drop-deployment, or redundant)`, |
| 610 | ); |
| 611 | for (const d of dupes) { |
| 612 | info( |
| 613 | ` ${chalk.yellow(d.account.name)} (${d.account.location}) — ${d.model} [${d.mode}]`, |
| 614 | ); |
| 615 | if (d.winner) { |
| 616 | const winnerKind = classifyDeployment( |
| 617 | d.winner.name, |
| 618 | d.winner.properties?.model?.name, |
| 619 | ).kind; |
| 620 | info( |
| 621 | ` keep: ${chalk.green(d.winner.name)} [${winnerKind}] sku=${d.winner.sku?.name} cap=${d.winner.sku?.capacity} version=${d.winner.properties?.model?.version}`, |
| 622 | ); |
| 623 | } else { |
| 624 | info( |
| 625 | ` keep: ${chalk.gray("(nothing — entire group dropped)")}`, |
| 626 | ); |
| 627 | } |
| 628 | for (const l of d.losers) { |
| 629 | const { kind } = classifyDeployment( |
| 630 | l.name, |
| 631 | l.properties?.model?.name, |
| 632 | ); |
| 633 | info( |
| 634 | ` drop: ${chalk.red(l.name)} [${kind}] sku=${l.sku?.name} cap=${l.sku?.capacity} version=${l.properties?.model?.version}`, |
| 635 | ); |
| 636 | } |
| 637 | } |
| 638 | |
| 639 | // Also report tagged / legacy variants we're intentionally NOT touching — |
| 640 | // helpful visibility so the user sees them. |
| 641 | const tagged = []; |
| 642 | const legacy = []; |
| 643 | for (const { account, deployments } of accounts) { |
| 644 | for (const d of deployments) { |
| 645 | const model = d.properties?.model?.name; |
| 646 | if (!model) continue; |
| 647 | if (options.dropDeployments.has(d.name)) continue; |
| 648 | const { kind, tag } = classifyDeployment(d.name, model); |
| 649 | if (kind === "tagged") tagged.push({ account, d, tag }); |
| 650 | if (kind === "legacy") legacy.push({ account, d, tag }); |
| 651 | } |
| 652 | } |
| 653 | if (tagged.length > 0) { |
| 654 | info( |
| 655 | `\n${chalk.cyanBright("Tagged variants kept")} (distinct purpose; will get their own secret from syncPoolSecrets)`, |
| 656 | ); |
| 657 | for (const k of tagged) { |
| 658 | info( |
| 659 | ` ${chalk.yellow(k.account.name)} (${k.account.location}) — ${k.d.name} [tag=${k.tag}] model=${k.d.properties?.model?.name}`, |
| 660 | ); |
| 661 | } |
| 662 | } |
| 663 | if (legacy.length > 0) { |
| 664 | info( |
| 665 | `\n${chalk.cyanBright("Legacy deployments kept (but excluded from pool)")} — numeric-tagged capacity variants. Left running for existing consumers; not added to the new pool secrets.`, |
| 666 | ); |
| 667 | for (const k of legacy) { |
| 668 | info( |
| 669 | ` ${chalk.yellow(k.account.name)} (${k.account.location}) — ${k.d.name} [tag=${k.tag}] model=${k.d.properties?.model?.name} sku=${k.d.sku?.name} cap=${k.d.sku?.capacity}`, |
| 670 | ); |
| 671 | } |
| 672 | info( |
| 673 | ` ${chalk.gray("→ When ready, provision replacement capacity under the canonical name, migrate consumers, then delete these manually.")}`, |
| 674 | ); |
| 675 | } |
| 676 | |
| 677 | // Read shared vault secrets to plan re-points. |
| 678 | status( |
| 679 | `\nReading secrets from vault ${chalk.cyanBright(options.vault)}...`, |
| 680 | ); |
| 681 | const names = await listVaultSecretNames(options.vault); |
| 682 | const secrets = []; |
| 683 | const concurrency = 5; |
| 684 | for (let i = 0; i < names.length; i += concurrency) { |
| 685 | const batch = names.slice(i, i + concurrency); |
| 686 | const vals = await Promise.all( |
| 687 | batch.map(async (n) => { |
| 688 | try { |
| 689 | return { |
| 690 | name: n, |
| 691 | value: await readSecret(options.vault, n), |
| 692 | }; |
| 693 | } catch (e) { |
| 694 | warn(` could not read ${n}: ${e.message}`); |
| 695 | return { name: n, value: undefined }; |
| 696 | } |
| 697 | }), |
| 698 | ); |
| 699 | secrets.push(...vals); |
| 700 | } |
| 701 | |
| 702 | const { repoints, drops } = planSecretRewrites(secrets, dupes); |
| 703 | if (repoints.length === 0 && drops.length === 0) { |
| 704 | info( |
| 705 | `\n${chalk.cyanBright("Secret re-points / drops")}: none — no shared-vault secrets reference the losers.`, |
| 706 | ); |
| 707 | } else { |
| 708 | if (repoints.length > 0) { |
| 709 | info(`\n${chalk.cyanBright("Secret re-points")}`); |
| 710 | for (const r of repoints) { |
| 711 | info( |
| 712 | ` ${r.secretName}: ${chalk.red(r.loser)} → ${chalk.green(r.winner)} (account ${r.account})`, |
| 713 | ); |
| 714 | } |
| 715 | } |
| 716 | if (drops.length > 0) { |
| 717 | info( |
| 718 | `\n${chalk.cyanBright("Secret drops")} (model mismatch — don't want to perpetuate misleading naming)`, |
| 719 | ); |
| 720 | for (const d of drops) { |
| 721 | info( |
| 722 | ` ${chalk.red(d.secretName)}: ${d.reason} (loser ${d.loser}, account ${d.account})`, |
| 723 | ); |
| 724 | } |
| 725 | } |
| 726 | } |
| 727 | |
| 728 | if (!options.commit) { |
| 729 | info( |
| 730 | `\n${chalk.cyan("Dry-run: no secrets written or deleted, no deployments deleted.")} Re-run with ${chalk.yellowBright("--commit")} to apply.`, |
| 731 | ); |
| 732 | return; |
| 733 | } |
| 734 | |
| 735 | // Apply re-points first, then deletions, then deployment deletions. |
| 736 | // Order matters — if we deleted deployments first, live traffic going |
| 737 | // through the old secret values would fail until the re-point landed. |
| 738 | // Secret drops happen after re-points so any caller that was relying on |
| 739 | // the mis-named secret gets a clear 404 rather than silent wrong-model |
| 740 | // traffic. |
| 741 | |
| 742 | if (repoints.length > 0) { |
| 743 | info(`\n${chalk.redBright("Applying re-points...")}`); |
| 744 | for (const r of repoints) { |
| 745 | try { |
| 746 | await writeSecret(options.vault, r.secretName, r.newValue); |
| 747 | info(` re-pointed ${r.secretName}`); |
| 748 | } catch (e) { |
| 749 | errLog(` FAILED ${r.secretName}: ${e.message}`); |
| 750 | errLog( |
| 751 | "Aborting — losers are NOT being deleted because a re-point failed. Investigate and rerun.", |
| 752 | ); |
| 753 | process.exit(2); |
| 754 | } |
| 755 | } |
| 756 | } |
| 757 | |
| 758 | if (drops.length > 0) { |
| 759 | info(`\n${chalk.redBright("Deleting mis-named secrets...")}`); |
| 760 | for (const d of drops) { |
| 761 | try { |
| 762 | await deleteSecret(options.vault, d.secretName); |
| 763 | info(` deleted secret ${d.secretName}`); |
| 764 | } catch (e) { |
| 765 | errLog(` FAILED to delete ${d.secretName}: ${e.message}`); |
| 766 | errLog( |
| 767 | "Aborting — loser deployments NOT being deleted because a secret delete failed.", |
| 768 | ); |
| 769 | process.exit(2); |
| 770 | } |
| 771 | } |
| 772 | } |
| 773 | |
| 774 | info(`\n${chalk.redBright("Deleting loser deployments...")}`); |
| 775 | let deletedDeployments = 0; |
| 776 | for (const d of dupes) { |
| 777 | for (const loser of d.losers) { |
| 778 | try { |
| 779 | await deleteDeployment(d.account, loser.name); |
| 780 | info(` deleted ${d.account.name}/${loser.name}`); |
| 781 | deletedDeployments++; |
| 782 | } catch (e) { |
| 783 | errLog( |
| 784 | ` FAILED to delete ${d.account.name}/${loser.name}: ${e.message}`, |
| 785 | ); |
| 786 | } |
| 787 | } |
| 788 | } |
| 789 | |
| 790 | ok( |
| 791 | `\nDedupe complete. ${repoints.length} secret(s) re-pointed, ${drops.length} secret(s) deleted, ${deletedDeployments} deployment(s) deleted.`, |
| 792 | ); |
| 793 | info( |
| 794 | `Next: run 'node tools/scripts/syncPoolSecrets.mjs --commit' to populate regional pool secrets.`, |
| 795 | ); |
| 796 | } |
| 797 | |
| 798 | main().catch((e) => { |
| 799 | errLog(`ERROR: ${e.message}`); |
| 800 | process.exit(1); |
| 801 | }); |
| 802 | |