Skip to content

Commit 0a38843

Browse files
authored
Scripts to generate ARM lease files (#41592)
1 parent b0a55df commit 0a38843

6 files changed

Lines changed: 1042 additions & 0 deletions

File tree

.github/arm-leases/README.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,3 +95,56 @@ If your PR check **"ARM Lease Validation"** is failing, review the error message
9595
- **Invalid duration**: Use a valid ISO 8601 duration that does not exceed 180 days (e.g., `P180D`, `P90D`, `P5M`)
9696
- **Missing or empty fields**: All fields (`resource-provider`, `startdate`, `duration`, `reviewer`) are required
9797
- **Disallowed files**: Only `lease.yaml` and `README.md` files are permitted in `.github/arm-leases/`
98+
99+
## Automation Scripts
100+
101+
Scripts are located in `.github/workflows/cmd/`. Run from the repository root.
102+
103+
### Single Resource Provider
104+
105+
**Syntax:**
106+
107+
```bash
108+
# Without service groups
109+
node .github/workflows/cmd/arm-lease-generate-lease-files.js --orgName <orgName> --rpNamespace <rpNamespace> --reviewer "@youralias" --startdate <YYYY-MM-DD> --duration <P#D>
110+
111+
# With service groups
112+
node .github/workflows/cmd/arm-lease-generate-lease-files.js --orgName <orgName> --rpNamespace <rpNamespace> --reviewer "@youralias" --startdate <YYYY-MM-DD> --duration <P#D> --serviceName "<serviceName1>,<serviceName2>"
113+
```
114+
115+
**Examples:**
116+
117+
```bash
118+
# Without service groups
119+
node .github/workflows/cmd/arm-lease-generate-lease-files.js --orgName storage --rpNamespace Microsoft.Storage --reviewer "@johndoe" --startdate 2026-05-01 --duration P90D
120+
121+
# With service groups
122+
node .github/workflows/cmd/arm-lease-generate-lease-files.js --orgName compute --rpNamespace Microsoft.Compute --reviewer "@janesmith" --startdate 2026-05-01 --duration P180D --serviceName "DiskRP,ComputeRP,GalleryRP"
123+
```
124+
125+
### Bulk Generation
126+
127+
**Syntax:**
128+
129+
```bash
130+
# Generate resource provider list (without service groups)
131+
node .github/workflows/cmd/arm-lease-fetch-resource-providers.js --output <filename>
132+
133+
# Generate resource provider list (with service groups)
134+
node .github/workflows/cmd/arm-lease-fetch-resource-providers.js --with-service-groups --output <filename>
135+
136+
# Generate lease files from list
137+
node .github/workflows/cmd/arm-lease-generate-lease-files.js --input <filename> --reviewer "@youralias" --startdate <YYYY-MM-DD> --duration <P#D>
138+
```
139+
140+
**Examples:**
141+
142+
```bash
143+
# RPs without service groups (e.g., Microsoft.Storage)
144+
node .github/workflows/cmd/arm-lease-fetch-resource-providers.js --output rps-simple.txt
145+
node .github/workflows/cmd/arm-lease-generate-lease-files.js --input rps-simple.txt --reviewer "@johndoe" --startdate 2026-04-16 --duration P180D
146+
147+
# RPs with service groups (e.g., Microsoft.Compute with DiskRP, ComputeRP)
148+
node .github/workflows/cmd/arm-lease-fetch-resource-providers.js --with-service-groups --output rps-groups.txt
149+
node .github/workflows/cmd/arm-lease-generate-lease-files.js --input rps-groups.txt --reviewer "@johndoe" --startdate 2026-04-16 --duration P180D
150+
```
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
import { existsSync, readdirSync, statSync, writeFileSync } from "fs";
2+
import { basename, dirname, join, relative, resolve } from "path";
3+
import { fileURLToPath } from "url";
4+
import { parseArgs } from "util";
5+
6+
/**
7+
* Check if a directory is a service name directory (not stable, preview, common-types, or examples)
8+
* @param {string} dirPath - Path to the directory
9+
* @returns {boolean} True if it's a valid service name directory
10+
*/
11+
export function isServiceNameDirectory(dirPath) {
12+
const excludeNames = new Set(["stable", "preview", "common-types", "examples"]);
13+
try {
14+
return !excludeNames.has(basename(dirPath)) && statSync(dirPath).isDirectory();
15+
} catch {
16+
return false;
17+
}
18+
}
19+
20+
/**
21+
* Check if a resource provider path has version directories (stable or preview)
22+
* @param {string} rpPath - Path to the resource provider
23+
* @returns {boolean} True if it has version directories
24+
*/
25+
export function hasVersionDirectories(rpPath) {
26+
return existsSync(join(rpPath, "stable")) || existsSync(join(rpPath, "preview"));
27+
}
28+
29+
/**
30+
* Check if a directory looks like the repository root
31+
* @param {string} dir - Directory to check
32+
* @returns {boolean} True if it looks like repo root
33+
*/
34+
function looksLikeRepoRoot(dir) {
35+
return (
36+
existsSync(join(dir, ".git")) ||
37+
existsSync(join(dir, "specification")) ||
38+
existsSync(join(dir, ".github"))
39+
);
40+
}
41+
42+
/**
43+
* Find the repository root by looking for repo markers (.git, specification, .github)
44+
* Supports sparse checkouts in CI by checking GITHUB_WORKSPACE first
45+
* @param {string} [startPath] - Starting path to search from
46+
* @returns {string} The repository root path
47+
* @throws {Error} If repository root cannot be found
48+
*/
49+
export function findRepoRoot(startPath = process.cwd()) {
50+
const workspace = process.env.GITHUB_WORKSPACE;
51+
if (workspace && looksLikeRepoRoot(workspace)) {
52+
return workspace;
53+
}
54+
55+
let current = resolve(startPath);
56+
for (let i = 0; i < 20; i++) {
57+
if (looksLikeRepoRoot(current)) return current;
58+
const parent = dirname(current);
59+
if (parent === current) break;
60+
current = parent;
61+
}
62+
throw new Error("Could not find repository root. Run from within azure-rest-api-specs.");
63+
}
64+
65+
/**
66+
* @typedef {Object} ResourceProvider
67+
* @property {string} rpNamespace - Resource provider namespace (e.g., Microsoft.Storage)
68+
* @property {string} path - Relative path to the resource provider
69+
* @property {string} orgName - Organization name from specification folder
70+
* @property {string[]} [serviceNames] - Service names if withServiceNames is true
71+
*/
72+
73+
/**
74+
* Find all resource providers in the specification directory
75+
* @param {string} repoRoot - Repository root path
76+
* @param {boolean} [withServiceNames=false] - Whether to include only RPs with service names
77+
* @returns {ResourceProvider[]} Array of resource provider objects
78+
*/
79+
export function findResourceProviders(repoRoot, withServiceNames = false) {
80+
const results = [];
81+
const specDir = join(repoRoot, "specification");
82+
if (!existsSync(specDir)) {
83+
// In sparse checkouts (CI), specification/ may not exist. Return empty list.
84+
return [];
85+
}
86+
87+
for (const orgName of readdirSync(specDir)) {
88+
const orgDir = join(specDir, orgName);
89+
if (!statSync(orgDir).isDirectory()) continue;
90+
91+
const rmDir = join(orgDir, "resource-manager");
92+
if (!existsSync(rmDir)) continue;
93+
94+
for (const rpNamespace of readdirSync(rmDir)) {
95+
const rpPath = join(rmDir, rpNamespace);
96+
if (!statSync(rpPath).isDirectory() || !rpNamespace.startsWith("Microsoft.")) {
97+
continue;
98+
}
99+
100+
const serviceNames = readdirSync(rpPath)
101+
.filter((sn) => isServiceNameDirectory(join(rpPath, sn)))
102+
.sort();
103+
104+
if (withServiceNames && serviceNames.length > 0) {
105+
results.push({
106+
rpNamespace: rpNamespace,
107+
path: relative(repoRoot, rpPath),
108+
orgName: orgName,
109+
serviceNames: serviceNames,
110+
});
111+
} else if (!withServiceNames && serviceNames.length === 0 && hasVersionDirectories(rpPath)) {
112+
results.push({
113+
rpNamespace: rpNamespace,
114+
path: relative(repoRoot, rpPath),
115+
orgName: orgName,
116+
});
117+
}
118+
}
119+
}
120+
return results.sort((a, b) => a.rpNamespace.localeCompare(b.rpNamespace));
121+
}
122+
123+
/**
124+
* Format resource providers for output
125+
* @param {ResourceProvider[]} rps - Array of resource providers
126+
* @param {"list"|"json"|"table"} fmt - Output format
127+
* @param {boolean} withSN - Whether resource providers have service names
128+
* @returns {string} Formatted output string
129+
*/
130+
export function formatOutput(rps, fmt, withSN) {
131+
if (fmt === "json") return JSON.stringify(rps, null, 2);
132+
if (rps.length === 0) {
133+
return `No resource providers ${withSN ? "with" : "without"} serviceNames found.`;
134+
}
135+
136+
if (fmt === "table") {
137+
const maxOrg = Math.max(...rps.map((r) => r.orgName.length));
138+
const maxRp = Math.max(...rps.map((r) => r.rpNamespace.length));
139+
const header = withSN
140+
? `${"orgName".padEnd(maxOrg)} ${"rpNamespace".padEnd(maxRp)} serviceNames`
141+
: `${"orgName".padEnd(maxOrg)} ${"rpNamespace".padEnd(maxRp)} Path`;
142+
const sep = `${"-".repeat(maxOrg)} ${"-".repeat(maxRp)} ${"-".repeat(60)}`;
143+
const rows = withSN
144+
? rps.map(
145+
(r) =>
146+
`${r.orgName.padEnd(maxOrg)} ${r.rpNamespace.padEnd(maxRp)} ${/** @type {string[]} */ (r.serviceNames).join(", ")}`,
147+
)
148+
: rps.map((r) => `${r.orgName.padEnd(maxOrg)} ${r.rpNamespace.padEnd(maxRp)} ${r.path}`);
149+
return [header, sep, ...rows].join("\n");
150+
}
151+
152+
return withSN
153+
? rps
154+
.map(
155+
(r) =>
156+
`${r.orgName}, ${r.rpNamespace}, [${/** @type {string[]} */ (r.serviceNames).join(", ")}]`,
157+
)
158+
.join("\n")
159+
: rps.map((r) => `${r.orgName}, ${r.rpNamespace}`).join("\n");
160+
}
161+
162+
const __filename = fileURLToPath(import.meta.url);
163+
const __dirname = dirname(__filename);
164+
165+
function usage() {
166+
console.log(`Usage:
167+
npx arm-lease-fetch-resource-providers [options]
168+
169+
Options:
170+
--with-service-groups Include only RPs with serviceNames (service groups)
171+
--format <format> Output format: list, json, table (default: list)
172+
--count Output only the count
173+
--output <file> Write output to file instead of stdout
174+
--repo-root <path> Repository root path (auto-detected if not provided)
175+
--help Show this help message
176+
177+
Examples:
178+
# RPs without service names
179+
npx arm-lease-fetch-resource-providers
180+
181+
# RPs with service names
182+
npx arm-lease-fetch-resource-providers --with-service-groups
183+
184+
# Output to file
185+
npx arm-lease-fetch-resource-providers --output rps.txt
186+
187+
# JSON format
188+
npx arm-lease-fetch-resource-providers --format json`);
189+
}
190+
191+
// Only run CLI if this file is executed directly (not imported)
192+
if (process.argv[1] === __filename) {
193+
const {
194+
values: {
195+
"with-service-groups": withServiceGroups,
196+
format,
197+
count,
198+
output,
199+
"repo-root": repoRootArg,
200+
help,
201+
},
202+
} = parseArgs({
203+
options: {
204+
"with-service-groups": { type: "boolean", default: false },
205+
format: { type: "string", default: "list" },
206+
count: { type: "boolean", default: false },
207+
output: { type: "string", default: "" },
208+
"repo-root": { type: "string", default: "" },
209+
help: { type: "boolean", default: false },
210+
},
211+
allowPositionals: false,
212+
});
213+
214+
if (help) {
215+
usage();
216+
process.exit(0);
217+
}
218+
219+
try {
220+
const repoRoot = repoRootArg
221+
? resolve(repoRootArg)
222+
: findRepoRoot(resolve(__dirname, "../../../"));
223+
const rps = findResourceProviders(repoRoot, withServiceGroups);
224+
225+
let outputText;
226+
if (count) {
227+
outputText = String(rps.length);
228+
} else {
229+
outputText = formatOutput(
230+
rps,
231+
/** @type {"list"|"json"|"table"} */ (format),
232+
withServiceGroups,
233+
);
234+
if (format !== "json") {
235+
outputText += `\n\nTotal: ${rps.length} resource provider(s) ${withServiceGroups ? "with" : "without"} serviceNames`;
236+
}
237+
}
238+
239+
if (output) {
240+
writeFileSync(output, outputText + "\n");
241+
console.log(`Output written to ${output}`);
242+
} else {
243+
console.log(outputText);
244+
}
245+
} catch (error) {
246+
console.error(`Error: ${/** @type {Error} */ (error).message}`);
247+
process.exit(1);
248+
}
249+
}

0 commit comments

Comments
 (0)