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/azureDeploy.mjs

461lines · modecode

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT License.
3
4import child_process, { exec } from "node:child_process";
5import chalk from "chalk";
6import registerDebug from "debug";
7import path from "node:path";
8import { fileURLToPath } from "node:url";
9import { getAzCliLoggedInInfo, execAzCliCommand } from "./lib/azureUtils.mjs";
10
11const debug = registerDebug("typeagent:azure:deploy");
12const debugError = registerDebug("typeagent:azure:deploy:error");
13
14const __filename = fileURLToPath(import.meta.url);
15const __dirname = path.dirname(__filename);
16
17function status(message) {
18 console.log(chalk.gray(message));
19}
20
21function success(message) {
22 console.log(chalk.greenBright(message));
23}
24
25function warn(message) {
26 console.error(chalk.yellowBright(message));
27}
28
29function error(message) {
30 console.error(chalk.redBright(message));
31}
32
33const nameColor = chalk.cyanBright;
34
35const defaultGlobalOptions = {
36 location: "eastus", // primary deployment location
37 name: "", // deployment name (per-region; defaults to <prefix>-<location>-deployment)
38 regions: "", // comma-separated list of extra regions to pool after the primary
39 prefix: "typeagent", // resource name prefix; override for other naming conventions
40 commit: false, // dry-run by default; require explicit opt-in to mutate
41 "skip-primary": false, // when true, only deploy --regions (no primary); useful when primary already exists
42};
43
44const commands = ["status", "create", "delete", "purge"];
45function parseArgs() {
46 const args = process.argv;
47 if (args.length < 3) {
48 throw new Error(
49 `Command not specified. Valid commands are: ${commands.map((c) => `'${c}'`).join(", ")}`,
50 );
51 }
52 const command = args[2];
53 if (!commands.includes(command)) {
54 throw new Error(
55 `Invalid command '${command}'. Valid commands are: ${commands.map((c) => `'${c}'`).join(", ")}`,
56 );
57 }
58
59 const options =
60 command === "delete"
61 ? {
62 ...defaultGlobalOptions,
63 purge: true, // for delete: default to purge.
64 }
65 : { ...defaultGlobalOptions };
66
67 for (let i = 3; i < args.length; i++) {
68 if (!args[i].startsWith("--")) {
69 throw new Error(
70 `Unknown argument for command ${command}: ${args[i]}`,
71 );
72 }
73 const key = args[i].slice(2);
74 if (!(key in options)) {
75 throw new Error(
76 `Unknown options for command ${command}: ${args[i]}`,
77 );
78 }
79
80 const value = args[i + 1];
81 if (typeof options[key] === "boolean") {
82 if (value === "false" || value === "0") {
83 options[key] = false;
84 } else {
85 options[key] = true;
86 if (value !== "true" && value !== "1") {
87 // Don't consume the next argument
88 continue;
89 }
90 }
91 } else {
92 // string
93 options[key] = value;
94 }
95 // Consume the next argument
96 i++;
97 }
98 return { command, options };
99}
100
101function parseRegionsList(regionsStr) {
102 if (!regionsStr) return [];
103 return regionsStr
104 .split(",")
105 .map((r) => r.trim().toLowerCase())
106 .filter(Boolean);
107}
108
109function getDeploymentName(options) {
110 return options.name
111 ? options.name
112 : `typeagent-${options.location}-deployment`;
113}
114
115async function createDeployment(options, location, primaryRegion) {
116 const effectiveLocation = location ?? options.location;
117 const prefix = options.prefix || "typeagent";
118 const deploymentName =
119 primaryRegion && options.name
120 ? options.name
121 : `${prefix}-${effectiveLocation}-deployment`;
122 status(
123 `Creating deployment ${nameColor(deploymentName)} in ${nameColor(effectiveLocation)}${primaryRegion ? " (primary)" : " (pool-only)"}...`,
124 );
125 if (!options.commit) {
126 const rg = `${prefix}-${effectiveLocation}-rg`;
127 warn(
128 ` [dry-run] would deploy ARM template ${path.resolve(__dirname, "./armTemplates/template.json")} to location=${effectiveLocation} prefix=${prefix} primaryRegion=${primaryRegion}`,
129 );
130 warn(
131 ` [dry-run] expected resource group: ${rg}; OpenAI account: ${prefix}-openai-${effectiveLocation}`,
132 );
133 return {
134 deploymentName,
135 resourceGroup: rg,
136 vaultName: undefined,
137 };
138 }
139 const output = JSON.parse(
140 await execAzCliCommand([
141 "deployment",
142 "sub",
143 "create",
144 "--location",
145 effectiveLocation,
146 "--template-file",
147 path.resolve(__dirname, "./armTemplates/template.json"),
148 "--name",
149 deploymentName,
150 "--parameters",
151 `primaryRegion=${primaryRegion ? "true" : "false"}`,
152 "--parameters",
153 `prefix=${prefix}`,
154 ]),
155 );
156
157 status("Resources created:");
158 status(
159 output.properties.outputResources.map((r) => ` ${r.id}`).join("\n"),
160 );
161
162 success(
163 `Deployment ${nameColor(deploymentName)} deployed to resource group ${nameColor(output.properties.parameters.group_name.value)}`,
164 );
165 return {
166 deploymentName,
167 resourceGroup: output.properties.parameters.group_name.value,
168 vaultName: output.properties.parameters.vaults_name.value,
169 };
170}
171
172async function getDeploymentDetails(deploymentName) {
173 status(`Getting details on deployment ${nameColor(deploymentName)}...`);
174 return JSON.parse(
175 await execAzCliCommand([
176 "deployment",
177 "sub",
178 "show",
179 "--name",
180 deploymentName,
181 ]),
182 );
183}
184
185async function deleteDeployment(options, subscriptionId) {
186 const deploymentName = getDeploymentName(options);
187 const deployment = await getDeploymentDetails(deploymentName);
188 const resourceGroupName = deployment.properties.parameters.group_name.value;
189 if (!options.commit) {
190 warn(
191 `[dry-run] would delete resource group ${resourceGroupName} and deployment ${deploymentName}${options.purge ? " and purge soft-deleted resources" : ""}`,
192 );
193 return;
194 }
195 try {
196 status(`Deleting resource group ${nameColor(resourceGroupName)}...`);
197 await execAzCliCommand([
198 "group",
199 "delete",
200 "--name",
201 resourceGroupName,
202 "--yes",
203 ]);
204
205 success(`Resource group ${nameColor(resourceGroupName)} deleted`);
206 } catch (e) {
207 if (!e.message.includes(" could not be found")) {
208 throw e;
209 }
210 warn(e.message);
211 }
212
213 status(`Deleting deployment ${nameColor(deploymentName)}...`);
214 await execAzCliCommand([
215 "deployment",
216 "sub",
217 "delete",
218 "--name",
219 deploymentName,
220 ]);
221
222 success(`Deployment ${nameColor(deploymentName)} deleted`);
223
224 if (options.purge) {
225 await purgeDeleted(options, subscriptionId);
226 }
227}
228
229async function getDeletedResources(uri, tag) {
230 const deleted = await execAzCliCommand([
231 "rest",
232 "--method",
233 "get",
234 "--header",
235 "Accept=application/json",
236 "-u",
237 uri,
238 ]);
239
240 debug(`Get delete: ${uri}\n${deleted}`);
241 const deletedJson = JSON.parse(deleted);
242 return deletedJson.value
243 .filter((r) => r.tags?.typeagent === tag)
244 .map((r) => r.id);
245}
246
247async function getDeleteKeyVaults(tag) {
248 const deleted = await execAzCliCommand(["keyvault", "list-deleted"]);
249 debug(`Get Delete KeyVault: ${deleted}`);
250 const deletedJson = JSON.parse(deleted);
251 return deletedJson
252 .filter((r) => r.properties?.tags?.typeagent === tag)
253 .map((r) => r.name);
254}
255
256async function purgeAIFoundryResources(options) {
257 const prefix = options.prefix || "typeagent";
258 await execAzCliCommand([
259 "cognitiveservices",
260 "account",
261 "purge",
262 "--resource-group",
263 `${prefix}-${options.location}-rg`,
264 "--location",
265 options.location,
266 "--name",
267 "typeagent-test-agent-resource",
268 ]);
269}
270
271async function purgeMaps(options) {
272 const prefix = options.prefix || "typeagent";
273 await execAzCliCommand([
274 "maps",
275 "account",
276 "delete",
277 "--resource-group",
278 `${prefix}-${options.location}-rg`,
279 "--name",
280 `${prefix}-maps-${options.location}`,
281 ]);
282}
283
284async function purgeDeleted(options, subscriptionId) {
285 const deploymentName = getDeploymentName(options);
286 status(`Purging resources for deployment ${nameColor(deploymentName)}...`);
287 if (!options.commit) {
288 warn(
289 `[dry-run] would purge soft-deleted Cognitive Services / Key Vault / AI Foundry resources tagged ${deploymentName}`,
290 );
291 return;
292 }
293 try {
294 const resources = await getDeletedResources(
295 `https://management.azure.com/subscriptions/${subscriptionId}/providers/Microsoft.CognitiveServices/deletedAccounts?api-version=2025-04-01-preview`,
296 deploymentName,
297 );
298
299 if (resources.length !== 0) {
300 status("Purging deleted cognitive services...");
301 status(resources.map((r) => ` ${r}`).join("\n"));
302 await execAzCliCommand(
303 ["resource", "delete", "--ids", ...resources],
304 { encoding: "utf8" },
305 );
306 }
307
308 const kvs = await getDeleteKeyVaults(deploymentName);
309 if (kvs.length !== 0) {
310 status("Purging deleted keyvault...");
311 status(kvs.map((r) => ` ${r}`).join("\n"));
312 for (const kv of kvs) {
313 await execAzCliCommand(
314 ["keyvault", "purge", "--no-wait", "--name", kv],
315 { encoding: "utf8" },
316 );
317 }
318 }
319
320 status("Purging AI Foundry resources...");
321 await purgeAIFoundryResources(options);
322
323 success("Purged Completed.");
324 } catch (e) {
325 e.message = `Error purging deleted resources.\n${e.message}`;
326 throw e;
327 }
328}
329
330function getErrorMessage(e) {
331 try {
332 const json = JSON.parse(e.message);
333 return JSON.stringify(json, null, 2);
334 } catch {}
335 return e.message;
336}
337
338function getKeys(vaultName, commit) {
339 if (!vaultName) {
340 warn(
341 `[dry-run] would run getKeys.mjs against the primary vault (name not yet available in dry-run).`,
342 );
343 return;
344 }
345 status(`Populating keys from ${nameColor(vaultName)}...`);
346 const args = [
347 path.resolve(__dirname, "./getKeys.mjs"),
348 "--vault",
349 vaultName,
350 ];
351 if (commit) args.push("--commit");
352 child_process.execFileSync(process.execPath, args);
353 success(`Keys populated from ${nameColor(vaultName)}.`);
354}
355
356async function main() {
357 let usage = true;
358 try {
359 const { command, options } = parseArgs();
360 usage = false;
361
362 const azInfo = await getAzCliLoggedInInfo();
363 const subscriptionId = azInfo.subscription.id;
364 const extraRegions = parseRegionsList(options.regions);
365 switch (command) {
366 case "status":
367 const deployment = await getDeploymentDetails(
368 getDeploymentName(options),
369 );
370 console.log(JSON.stringify(deployment, null, 2));
371 break;
372 case "create": {
373 // Primary region deploys the full stack (OpenAI + Maps +
374 // Speech + Key Vault + AI Foundry). Additional --regions deploy
375 // only the OpenAI account + deployments so they can serve as
376 // pool members. After all regions are up, run
377 // syncPoolSecrets.mjs to populate the shared vault.
378 //
379 // --skip-primary: when a primary already exists (possibly
380 // under a different naming convention than this template
381 // produces), skip the primary deploy to avoid name collisions
382 // and only provision the --regions as pool-only.
383 if (!options.commit) {
384 warn(
385 `[dry-run mode] — no Azure resources will be created. Re-run with --commit to apply.`,
386 );
387 }
388 let primary;
389 if (!options["skip-primary"]) {
390 primary = await createDeployment(
391 options,
392 options.location,
393 true,
394 );
395 if (options.commit) {
396 await getKeys(primary.vaultName, options.commit);
397 }
398 } else {
399 status(
400 `Skipping primary deploy (--skip-primary). Will only deploy pool-only regions.`,
401 );
402 }
403 for (const region of extraRegions) {
404 if (
405 !options["skip-primary"] &&
406 region === options.location
407 ) {
408 status(
409 `Skipping ${nameColor(region)} — already deployed as primary.`,
410 );
411 continue;
412 }
413 await createDeployment(options, region, false);
414 }
415 if (extraRegions.length > 0) {
416 success(
417 `Multi-region deploy ${options.commit ? "complete" : "planned (dry-run)"}. ` +
418 `Next: 'node tools/scripts/syncPoolSecrets.mjs --vault ${primary?.vaultName ?? "aisystems"}' ` +
419 `(add --commit when ready).`,
420 );
421 }
422 break;
423 }
424 case "delete":
425 await deleteDeployment(options, subscriptionId);
426 break;
427 case "purge":
428 await purgeDeleted(options, subscriptionId);
429 break;
430 }
431 } catch (e) {
432 error(`ERROR: ${getErrorMessage(e)}`);
433 if (usage) {
434 console.log(
435 [
436 "Usage: ",
437 " node azureDeploy.mjs create [--location <location>] [--name <name>] [--regions <csv>] [--prefix <prefix>] [--commit]",
438 " node azureDeploy.mjs delete [--location <location>] [--name <name>] [--purge] [--commit]",
439 " node azureDeploy.mjs purge [--location <location>] [--name <name>] [--commit]",
440 "",
441 "Options:",
442 " --location <location> Primary deployment location. Default: eastus",
443 " --name <name> Deployment name. Default: <prefix>-<location>-deployment",
444 " --regions <csv> Extra regions to deploy OpenAI-only pool members into",
445 " (e.g. --regions swedencentral,westus,eastus2). Each extra",
446 " region deploys with primaryRegion=false: OpenAI account +",
447 " deployments only, no Maps/Speech/KV/AI-Foundry.",
448 " --prefix <prefix> Resource name prefix. Default: typeagent",
449 " --skip-primary Skip the primary-region deploy (useful when primary already exists).",
450 " --purge [true|false] Purge deleted resources. Default: true",
451 "",
452 " --commit REQUIRED to actually mutate Azure. Without it, runs in",
453 " dry-run mode and prints what would change.",
454 ].join("\n"),
455 );
456 }
457 process.exit(1);
458 }
459}
460
461await main();
462