microsoft/TypeAgent

Public

mirrored fromhttps://github.com/microsoft/TypeAgentAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
copilot/fix-shell-and-cli-windows-job

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

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
18import chalk from "chalk";
19import child_process from "node:child_process";
20import { execAzCliCommand, getAzCliLoggedInInfo } from "./lib/azureUtils.mjs";
21
22// --------------- arg parsing ---------------
23
24function 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
66const status = (m) => console.log(chalk.gray(m));
67const info = (m) => console.log(m);
68const ok = (m) => console.log(chalk.greenBright(m));
69const warn = (m) => console.error(chalk.yellowBright(m));
70const errLog = (m) => console.error(chalk.redBright(m));
71
72// --------------- classification ---------------
73
74function 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.
82function 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
90function 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").
97function 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
119const 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").
161function 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.
185function 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.
206function 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
226async 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
240async 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
254async 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
287async 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
320async 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
353async 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
384async 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
401function 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
413function 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 }
508function 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
588async 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
798main().catch((e) => {
799 errLog(`ERROR: ${e.message}`);
800 process.exit(1);
801});
802