microsoft/TypeAgent

Public

mirrored from https://github.com/microsoft/TypeAgentAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
ddafff38a07a44e82b0d96d83cd609dad05b37ff

Branches

Tags

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

Clone

HTTPS

Download ZIP

ts/tools/scripts/dedupeDeployments.mjs

803lines · 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 "-o",
267 "json",
268 ],
269 { shell: true },
270 (e, stdout, stderr) => {
271 if (e) {
272 reject(
273 new Error(
274 `az keyvault secret list failed: ${stderr || e.message}`,
275 ),
276 );
277 return;
278 }
279 try {
280 resolve(JSON.parse(stdout));
281 } catch {
282 resolve([]);
283 }
284 },
285 );
286 });
287}
288
289async function readSecret(vault, name) {
290 return new Promise((resolve, reject) => {
291 child_process.execFile(
292 "az",
293 [
294 "keyvault",
295 "secret",
296 "show",
297 "--vault-name",
298 vault,
299 "--name",
300 name,
301 "--query",
302 "value",
303 "-o",
304 "tsv",
305 ],
306 { shell: true },
307 (e, stdout, stderr) => {
308 if (e) {
309 reject(
310 new Error(
311 `az keyvault secret show ${name} failed: ${stderr || e.message}`,
312 ),
313 );
314 return;
315 }
316 resolve(stdout.trimEnd());
317 },
318 );
319 });
320}
321
322async function writeSecret(vault, name, value) {
323 return new Promise((resolve, reject) => {
324 child_process.execFile(
325 "az",
326 [
327 "keyvault",
328 "secret",
329 "set",
330 "--vault-name",
331 vault,
332 "--name",
333 name,
334 "--value",
335 value,
336 "--output",
337 "none",
338 ],
339 { shell: true },
340 (e, _stdout, stderr) => {
341 if (e) {
342 reject(
343 new Error(
344 `az keyvault secret set ${name} failed: ${stderr || e.message}`,
345 ),
346 );
347 return;
348 }
349 resolve();
350 },
351 );
352 });
353}
354
355async function deleteSecret(vault, name) {
356 return new Promise((resolve, reject) => {
357 child_process.execFile(
358 "az",
359 [
360 "keyvault",
361 "secret",
362 "delete",
363 "--vault-name",
364 vault,
365 "--name",
366 name,
367 "--output",
368 "none",
369 ],
370 { shell: true },
371 (e, _stdout, stderr) => {
372 if (e) {
373 reject(
374 new Error(
375 `az keyvault secret delete ${name} failed: ${stderr || e.message}`,
376 ),
377 );
378 return;
379 }
380 resolve();
381 },
382 );
383 });
384}
385
386async function deleteDeployment(account, deploymentName) {
387 return execAzCliCommand([
388 "cognitiveservices",
389 "account",
390 "deployment",
391 "delete",
392 "--name",
393 account.name,
394 "--resource-group",
395 account.resourceGroup,
396 "--deployment-name",
397 deploymentName,
398 ]);
399}
400
401// --------------- dedupe plan ---------------
402
403function compareBySkuAndCap(a, b) {
404 const sa = skuRank(a.sku?.name);
405 const sb = skuRank(b.sku?.name);
406 if (sa !== sb) return sb - sa;
407 const ca = a.sku?.capacity ?? a.properties?.currentCapacity ?? 0;
408 const cb = b.sku?.capacity ?? b.properties?.currentCapacity ?? 0;
409 if (ca !== cb) return cb - ca;
410 const va = a.properties?.model?.version ?? "";
411 const vb = b.properties?.model?.version ?? "";
412 return vb.localeCompare(va);
413}
414
415function buildDedupePlan(accounts, dropDeployments) {
416 // group key: accountId | modelName | mode
417 // Only ALIAS deployments are dedupe candidates by default. Canonical and
418 // tagged deployments are kept as-is. Anything in `dropDeployments` is
419 // force-dropped regardless of classification.
420 const groups = new Map();
421 for (const { account, deployments } of accounts) {
422 for (const d of deployments) {
423 const model = d.properties?.model?.name;
424 if (!model) continue;
425 const mode = skuMode(d.sku?.name);
426 const { kind, tag } = classifyDeployment(d.name, model);
427 const key = `${account.id}|${model}|${mode}`;
428 if (!groups.has(key)) {
429 groups.set(key, { account, model, mode, entries: [] });
430 }
431 groups.get(key).entries.push({ d, kind, tag });
432 }
433 }
434
435 const dupes = [];
436 for (const group of groups.values()) {
437 // Partition by kind, subtracting force-dropped deployments.
438 const forceDropped = group.entries
439 .filter((e) => dropDeployments.has(e.d.name))
440 .map((e) => e.d);
441 const surviving = group.entries.filter(
442 (e) => !dropDeployments.has(e.d.name),
443 );
444 const canonical = surviving
445 .filter((e) => e.kind === "canonical")
446 .map((e) => e.d);
447 const tagged = surviving
448 .filter((e) => e.kind === "tagged")
449 .map((e) => e.d);
450 const legacy = surviving
451 .filter((e) => e.kind === "legacy")
452 .map((e) => e.d);
453 const aliases = surviving
454 .filter((e) => e.kind === "alias")
455 .map((e) => e.d);
456
457 const aliasesToDrop = [...aliases]; // default behavior: aliases will be reduced below
458 const allDrops = [...forceDropped];
459
460 // Pick a winner among survivors if any aliases need re-pointing.
461 let winner;
462 if (canonical.length > 0) {
463 winner = canonical.sort(compareBySkuAndCap)[0];
464 allDrops.push(...aliasesToDrop);
465 } else if (tagged.length > 0) {
466 winner = tagged.sort(compareBySkuAndCap)[0];
467 allDrops.push(...aliasesToDrop);
468 } else if (legacy.length > 0) {
469 // Legacy deployments are kept but not a great re-point target;
470 // still usable if nothing else exists in the group.
471 winner = legacy.sort(compareBySkuAndCap)[0];
472 allDrops.push(...aliasesToDrop);
473 } else if (aliasesToDrop.length > 1) {
474 // All aliases, no force drops affected us yet — promote best
475 // alias as effective winner, drop the rest.
476 const sorted = [...aliasesToDrop].sort(compareBySkuAndCap);
477 winner = sorted[0];
478 allDrops.push(...sorted.slice(1));
479 } else {
480 // 0 or 1 alias surviving and no other survivors — nothing to
481 // promote. Dedupe still emits a plan entry if force drops exist
482 // (so KV secret handling runs), but with winner === undefined.
483 if (allDrops.length === 0) continue;
484 winner = undefined;
485 }
486
487 if (allDrops.length === 0) continue;
488
489 dupes.push({
490 account: group.account,
491 model: group.model,
492 mode: group.mode,
493 winner,
494 losers: allDrops,
495 });
496 }
497 return dupes;
498}
499
500// For each secret whose value references a loser deployment, decide whether
501// to re-point to the winner or drop the secret outright. Drop is preferred
502// when the secret's name implies a *different* model than what the winner
503// actually serves — re-pointing in that case would perpetuate misleading
504// naming (e.g. a secret named AZURE-OPENAI-ENDPOINT-GPT-35-TURBO pointing at
505// a deployment that actually serves gpt-4.1-mini).
506//
507// Returns { repoints: [...], drops: [...] } where
508// repoint = { secretName, oldValue, newValue, loser, winner, account }
509// drop = { secretName, loser, winner, account, reason }
510function planSecretRewrites(secrets, dupes) {
511 const repoints = [];
512 const drops = [];
513 for (const dupe of dupes) {
514 const accountEndpoint = dupe.account.properties?.endpoint?.replace(
515 /\/+$/,
516 "",
517 );
518 if (!accountEndpoint) continue;
519 const winner = dupe.winner;
520 const winnerPath = winner
521 ? `/openai/deployments/${winner.name}/`
522 : undefined;
523 const winnerModel = winner?.properties?.model?.name;
524 const winnerModelSuffix = winnerModel
525 ? modelNameToSuffix(winnerModel)
526 : undefined;
527 for (const loser of dupe.losers) {
528 const loserPath = `/openai/deployments/${loser.name}/`;
529 const loserFull = `${accountEndpoint}${loserPath}`;
530 for (const { name, value } of secrets) {
531 if (!value || typeof value !== "string") continue;
532 if (!value.includes(loserFull)) continue;
533 const secretModel = extractModelFromSecret(name);
534
535 // Case 1: no winner in the group at all (e.g. user dropped
536 // every deployment in a group). Any referencing secret has
537 // nowhere to go → drop it.
538 if (!winner || !winnerPath) {
539 drops.push({
540 secretName: name,
541 loser: loser.name,
542 winner: undefined,
543 account: dupe.account.name,
544 reason: `no replacement in ${dupe.account.name} for ${dupe.model}/${dupe.mode}`,
545 });
546 continue;
547 }
548
549 // Case 2: winner exists and matches the secret's implied
550 // model → re-point.
551 // Unknown secret shape → fall back to re-point (don't
552 // silently delete something we don't understand).
553 if (
554 !secretModel ||
555 !winnerModelSuffix ||
556 secretModel === winnerModelSuffix
557 ) {
558 const newValue = value.replace(
559 new RegExp(escapeRegExp(loserFull), "g"),
560 `${accountEndpoint}${winnerPath}`,
561 );
562 repoints.push({
563 secretName: name,
564 oldValue: value,
565 newValue,
566 loser: loser.name,
567 winner: winner.name,
568 account: dupe.account.name,
569 });
570 } else {
571 // Case 3: winner exists but serves a different model
572 // than the secret name implies → drop to avoid
573 // perpetuating misleading naming.
574 drops.push({
575 secretName: name,
576 loser: loser.name,
577 winner: winner.name,
578 account: dupe.account.name,
579 reason: `secret says ${secretModel}, winner serves ${winnerModelSuffix}`,
580 });
581 }
582 }
583 }
584 }
585 return { repoints, drops };
586}
587
588// --------------- main ---------------
589
590async function main() {
591 const options = parseArgs();
592 const azInfo = await getAzCliLoggedInInfo();
593 const mode = options.commit
594 ? chalk.redBright("COMMIT (will mutate)")
595 : chalk.cyan("dry-run (no changes)");
596 info(`Mode: ${mode}`);
597
598 const accountList = await listAccounts(azInfo.subscription.id);
599 const accounts = [];
600 for (const a of accountList) {
601 accounts.push({ account: a, deployments: await listDeployments(a) });
602 }
603
604 const dupes = buildDedupePlan(accounts, options.dropDeployments);
605 if (dupes.length === 0) {
606 ok("No duplicate deployments found. Nothing to do.");
607 return;
608 }
609
610 info(
611 `\n${chalk.cyanBright("Deployments to drop")} (aliases, force-dropped via --drop-deployment, or redundant)`,
612 );
613 for (const d of dupes) {
614 info(
615 ` ${chalk.yellow(d.account.name)} (${d.account.location}) — ${d.model} [${d.mode}]`,
616 );
617 if (d.winner) {
618 const winnerKind = classifyDeployment(
619 d.winner.name,
620 d.winner.properties?.model?.name,
621 ).kind;
622 info(
623 ` keep: ${chalk.green(d.winner.name)} [${winnerKind}] sku=${d.winner.sku?.name} cap=${d.winner.sku?.capacity} version=${d.winner.properties?.model?.version}`,
624 );
625 } else {
626 info(
627 ` keep: ${chalk.gray("(nothing — entire group dropped)")}`,
628 );
629 }
630 for (const l of d.losers) {
631 const { kind } = classifyDeployment(
632 l.name,
633 l.properties?.model?.name,
634 );
635 info(
636 ` drop: ${chalk.red(l.name)} [${kind}] sku=${l.sku?.name} cap=${l.sku?.capacity} version=${l.properties?.model?.version}`,
637 );
638 }
639 }
640
641 // Also report tagged / legacy variants we're intentionally NOT touching —
642 // helpful visibility so the user sees them.
643 const tagged = [];
644 const legacy = [];
645 for (const { account, deployments } of accounts) {
646 for (const d of deployments) {
647 const model = d.properties?.model?.name;
648 if (!model) continue;
649 if (options.dropDeployments.has(d.name)) continue;
650 const { kind, tag } = classifyDeployment(d.name, model);
651 if (kind === "tagged") tagged.push({ account, d, tag });
652 if (kind === "legacy") legacy.push({ account, d, tag });
653 }
654 }
655 if (tagged.length > 0) {
656 info(
657 `\n${chalk.cyanBright("Tagged variants kept")} (distinct purpose; will get their own secret from syncPoolSecrets)`,
658 );
659 for (const k of tagged) {
660 info(
661 ` ${chalk.yellow(k.account.name)} (${k.account.location}) — ${k.d.name} [tag=${k.tag}] model=${k.d.properties?.model?.name}`,
662 );
663 }
664 }
665 if (legacy.length > 0) {
666 info(
667 `\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.`,
668 );
669 for (const k of legacy) {
670 info(
671 ` ${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}`,
672 );
673 }
674 info(
675 ` ${chalk.gray("→ When ready, provision replacement capacity under the canonical name, migrate consumers, then delete these manually.")}`,
676 );
677 }
678
679 // Read shared vault secrets to plan re-points.
680 status(
681 `\nReading secrets from vault ${chalk.cyanBright(options.vault)}...`,
682 );
683 const names = await listVaultSecretNames(options.vault);
684 const secrets = [];
685 const concurrency = 5;
686 for (let i = 0; i < names.length; i += concurrency) {
687 const batch = names.slice(i, i + concurrency);
688 const vals = await Promise.all(
689 batch.map(async (n) => {
690 try {
691 return {
692 name: n,
693 value: await readSecret(options.vault, n),
694 };
695 } catch (e) {
696 warn(` could not read ${n}: ${e.message}`);
697 return { name: n, value: undefined };
698 }
699 }),
700 );
701 secrets.push(...vals);
702 }
703
704 const { repoints, drops } = planSecretRewrites(secrets, dupes);
705 if (repoints.length === 0 && drops.length === 0) {
706 info(
707 `\n${chalk.cyanBright("Secret re-points / drops")}: none — no shared-vault secrets reference the losers.`,
708 );
709 } else {
710 if (repoints.length > 0) {
711 info(`\n${chalk.cyanBright("Secret re-points")}`);
712 for (const r of repoints) {
713 info(
714 ` ${r.secretName}: ${chalk.red(r.loser)} → ${chalk.green(r.winner)} (account ${r.account})`,
715 );
716 }
717 }
718 if (drops.length > 0) {
719 info(
720 `\n${chalk.cyanBright("Secret drops")} (model mismatch — don't want to perpetuate misleading naming)`,
721 );
722 for (const d of drops) {
723 info(
724 ` ${chalk.red(d.secretName)}: ${d.reason} (loser ${d.loser}, account ${d.account})`,
725 );
726 }
727 }
728 }
729
730 if (!options.commit) {
731 info(
732 `\n${chalk.cyan("Dry-run: no secrets written or deleted, no deployments deleted.")} Re-run with ${chalk.yellowBright("--commit")} to apply.`,
733 );
734 return;
735 }
736
737 // Apply re-points first, then deletions, then deployment deletions.
738 // Order matters — if we deleted deployments first, live traffic going
739 // through the old secret values would fail until the re-point landed.
740 // Secret drops happen after re-points so any caller that was relying on
741 // the mis-named secret gets a clear 404 rather than silent wrong-model
742 // traffic.
743
744 if (repoints.length > 0) {
745 info(`\n${chalk.redBright("Applying re-points...")}`);
746 for (const r of repoints) {
747 try {
748 await writeSecret(options.vault, r.secretName, r.newValue);
749 info(` re-pointed ${r.secretName}`);
750 } catch (e) {
751 errLog(` FAILED ${r.secretName}: ${e.message}`);
752 errLog(
753 "Aborting — losers are NOT being deleted because a re-point failed. Investigate and rerun.",
754 );
755 process.exit(2);
756 }
757 }
758 }
759
760 if (drops.length > 0) {
761 info(`\n${chalk.redBright("Deleting mis-named secrets...")}`);
762 for (const d of drops) {
763 try {
764 await deleteSecret(options.vault, d.secretName);
765 info(` deleted secret ${d.secretName}`);
766 } catch (e) {
767 errLog(` FAILED to delete ${d.secretName}: ${e.message}`);
768 errLog(
769 "Aborting — loser deployments NOT being deleted because a secret delete failed.",
770 );
771 process.exit(2);
772 }
773 }
774 }
775
776 info(`\n${chalk.redBright("Deleting loser deployments...")}`);
777 let deletedDeployments = 0;
778 for (const d of dupes) {
779 for (const loser of d.losers) {
780 try {
781 await deleteDeployment(d.account, loser.name);
782 info(` deleted ${d.account.name}/${loser.name}`);
783 deletedDeployments++;
784 } catch (e) {
785 errLog(
786 ` FAILED to delete ${d.account.name}/${loser.name}: ${e.message}`,
787 );
788 }
789 }
790 }
791
792 ok(
793 `\nDedupe complete. ${repoints.length} secret(s) re-pointed, ${drops.length} secret(s) deleted, ${deletedDeployments} deployment(s) deleted.`,
794 );
795 info(
796 `Next: run 'node tools/scripts/syncPoolSecrets.mjs --commit' to populate regional pool secrets.`,
797 );
798}
799
800main().catch((e) => {
801 errLog(`ERROR: ${e.message}`);
802 process.exit(1);
803});
804