diff --git a/.github/arm-leases/README.md b/.github/arm-leases/README.md index 327e201ec8d3..e3cb9b6a86ec 100644 --- a/.github/arm-leases/README.md +++ b/.github/arm-leases/README.md @@ -95,3 +95,56 @@ If your PR check **"ARM Lease Validation"** is failing, review the error message - **Invalid duration**: Use a valid ISO 8601 duration that does not exceed 180 days (e.g., `P180D`, `P90D`, `P5M`) - **Missing or empty fields**: All fields (`resource-provider`, `startdate`, `duration`, `reviewer`) are required - **Disallowed files**: Only `lease.yaml` and `README.md` files are permitted in `.github/arm-leases/` + +## Automation Scripts + +Scripts are located in `.github/workflows/cmd/`. Run from the repository root. + +### Single Resource Provider + +**Syntax:** + +```bash +# Without service groups +node .github/workflows/cmd/arm-lease-generate-lease-files.js --orgName --rpNamespace --reviewer "@youralias" --startdate --duration + +# With service groups +node .github/workflows/cmd/arm-lease-generate-lease-files.js --orgName --rpNamespace --reviewer "@youralias" --startdate --duration --serviceName "," +``` + +**Examples:** + +```bash +# Without service groups +node .github/workflows/cmd/arm-lease-generate-lease-files.js --orgName storage --rpNamespace Microsoft.Storage --reviewer "@johndoe" --startdate 2026-05-01 --duration P90D + +# With service groups +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" +``` + +### Bulk Generation + +**Syntax:** + +```bash +# Generate resource provider list (without service groups) +node .github/workflows/cmd/arm-lease-fetch-resource-providers.js --output + +# Generate resource provider list (with service groups) +node .github/workflows/cmd/arm-lease-fetch-resource-providers.js --with-service-groups --output + +# Generate lease files from list +node .github/workflows/cmd/arm-lease-generate-lease-files.js --input --reviewer "@youralias" --startdate --duration +``` + +**Examples:** + +```bash +# RPs without service groups (e.g., Microsoft.Storage) +node .github/workflows/cmd/arm-lease-fetch-resource-providers.js --output rps-simple.txt +node .github/workflows/cmd/arm-lease-generate-lease-files.js --input rps-simple.txt --reviewer "@johndoe" --startdate 2026-04-16 --duration P180D + +# RPs with service groups (e.g., Microsoft.Compute with DiskRP, ComputeRP) +node .github/workflows/cmd/arm-lease-fetch-resource-providers.js --with-service-groups --output rps-groups.txt +node .github/workflows/cmd/arm-lease-generate-lease-files.js --input rps-groups.txt --reviewer "@johndoe" --startdate 2026-04-16 --duration P180D +``` diff --git a/.github/workflows/cmd/arm-lease-fetch-resource-providers.js b/.github/workflows/cmd/arm-lease-fetch-resource-providers.js new file mode 100644 index 000000000000..a685572dd5e9 --- /dev/null +++ b/.github/workflows/cmd/arm-lease-fetch-resource-providers.js @@ -0,0 +1,249 @@ +import { existsSync, readdirSync, statSync, writeFileSync } from "fs"; +import { basename, dirname, join, relative, resolve } from "path"; +import { fileURLToPath } from "url"; +import { parseArgs } from "util"; + +/** + * Check if a directory is a service name directory (not stable, preview, common-types, or examples) + * @param {string} dirPath - Path to the directory + * @returns {boolean} True if it's a valid service name directory + */ +export function isServiceNameDirectory(dirPath) { + const excludeNames = new Set(["stable", "preview", "common-types", "examples"]); + try { + return !excludeNames.has(basename(dirPath)) && statSync(dirPath).isDirectory(); + } catch { + return false; + } +} + +/** + * Check if a resource provider path has version directories (stable or preview) + * @param {string} rpPath - Path to the resource provider + * @returns {boolean} True if it has version directories + */ +export function hasVersionDirectories(rpPath) { + return existsSync(join(rpPath, "stable")) || existsSync(join(rpPath, "preview")); +} + +/** + * Check if a directory looks like the repository root + * @param {string} dir - Directory to check + * @returns {boolean} True if it looks like repo root + */ +function looksLikeRepoRoot(dir) { + return ( + existsSync(join(dir, ".git")) || + existsSync(join(dir, "specification")) || + existsSync(join(dir, ".github")) + ); +} + +/** + * Find the repository root by looking for repo markers (.git, specification, .github) + * Supports sparse checkouts in CI by checking GITHUB_WORKSPACE first + * @param {string} [startPath] - Starting path to search from + * @returns {string} The repository root path + * @throws {Error} If repository root cannot be found + */ +export function findRepoRoot(startPath = process.cwd()) { + const workspace = process.env.GITHUB_WORKSPACE; + if (workspace && looksLikeRepoRoot(workspace)) { + return workspace; + } + + let current = resolve(startPath); + for (let i = 0; i < 20; i++) { + if (looksLikeRepoRoot(current)) return current; + const parent = dirname(current); + if (parent === current) break; + current = parent; + } + throw new Error("Could not find repository root. Run from within azure-rest-api-specs."); +} + +/** + * @typedef {Object} ResourceProvider + * @property {string} rpNamespace - Resource provider namespace (e.g., Microsoft.Storage) + * @property {string} path - Relative path to the resource provider + * @property {string} orgName - Organization name from specification folder + * @property {string[]} [serviceNames] - Service names if withServiceNames is true + */ + +/** + * Find all resource providers in the specification directory + * @param {string} repoRoot - Repository root path + * @param {boolean} [withServiceNames=false] - Whether to include only RPs with service names + * @returns {ResourceProvider[]} Array of resource provider objects + */ +export function findResourceProviders(repoRoot, withServiceNames = false) { + const results = []; + const specDir = join(repoRoot, "specification"); + if (!existsSync(specDir)) { + // In sparse checkouts (CI), specification/ may not exist. Return empty list. + return []; + } + + for (const orgName of readdirSync(specDir)) { + const orgDir = join(specDir, orgName); + if (!statSync(orgDir).isDirectory()) continue; + + const rmDir = join(orgDir, "resource-manager"); + if (!existsSync(rmDir)) continue; + + for (const rpNamespace of readdirSync(rmDir)) { + const rpPath = join(rmDir, rpNamespace); + if (!statSync(rpPath).isDirectory() || !rpNamespace.startsWith("Microsoft.")) { + continue; + } + + const serviceNames = readdirSync(rpPath) + .filter((sn) => isServiceNameDirectory(join(rpPath, sn))) + .sort(); + + if (withServiceNames && serviceNames.length > 0) { + results.push({ + rpNamespace: rpNamespace, + path: relative(repoRoot, rpPath), + orgName: orgName, + serviceNames: serviceNames, + }); + } else if (!withServiceNames && serviceNames.length === 0 && hasVersionDirectories(rpPath)) { + results.push({ + rpNamespace: rpNamespace, + path: relative(repoRoot, rpPath), + orgName: orgName, + }); + } + } + } + return results.sort((a, b) => a.rpNamespace.localeCompare(b.rpNamespace)); +} + +/** + * Format resource providers for output + * @param {ResourceProvider[]} rps - Array of resource providers + * @param {"list"|"json"|"table"} fmt - Output format + * @param {boolean} withSN - Whether resource providers have service names + * @returns {string} Formatted output string + */ +export function formatOutput(rps, fmt, withSN) { + if (fmt === "json") return JSON.stringify(rps, null, 2); + if (rps.length === 0) { + return `No resource providers ${withSN ? "with" : "without"} serviceNames found.`; + } + + if (fmt === "table") { + const maxOrg = Math.max(...rps.map((r) => r.orgName.length)); + const maxRp = Math.max(...rps.map((r) => r.rpNamespace.length)); + const header = withSN + ? `${"orgName".padEnd(maxOrg)} ${"rpNamespace".padEnd(maxRp)} serviceNames` + : `${"orgName".padEnd(maxOrg)} ${"rpNamespace".padEnd(maxRp)} Path`; + const sep = `${"-".repeat(maxOrg)} ${"-".repeat(maxRp)} ${"-".repeat(60)}`; + const rows = withSN + ? rps.map( + (r) => + `${r.orgName.padEnd(maxOrg)} ${r.rpNamespace.padEnd(maxRp)} ${/** @type {string[]} */ (r.serviceNames).join(", ")}`, + ) + : rps.map((r) => `${r.orgName.padEnd(maxOrg)} ${r.rpNamespace.padEnd(maxRp)} ${r.path}`); + return [header, sep, ...rows].join("\n"); + } + + return withSN + ? rps + .map( + (r) => + `${r.orgName}, ${r.rpNamespace}, [${/** @type {string[]} */ (r.serviceNames).join(", ")}]`, + ) + .join("\n") + : rps.map((r) => `${r.orgName}, ${r.rpNamespace}`).join("\n"); +} + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +function usage() { + console.log(`Usage: +npx arm-lease-fetch-resource-providers [options] + +Options: + --with-service-groups Include only RPs with serviceNames (service groups) + --format Output format: list, json, table (default: list) + --count Output only the count + --output Write output to file instead of stdout + --repo-root Repository root path (auto-detected if not provided) + --help Show this help message + +Examples: + # RPs without service names + npx arm-lease-fetch-resource-providers + + # RPs with service names + npx arm-lease-fetch-resource-providers --with-service-groups + + # Output to file + npx arm-lease-fetch-resource-providers --output rps.txt + + # JSON format + npx arm-lease-fetch-resource-providers --format json`); +} + +// Only run CLI if this file is executed directly (not imported) +if (process.argv[1] === __filename) { + const { + values: { + "with-service-groups": withServiceGroups, + format, + count, + output, + "repo-root": repoRootArg, + help, + }, + } = parseArgs({ + options: { + "with-service-groups": { type: "boolean", default: false }, + format: { type: "string", default: "list" }, + count: { type: "boolean", default: false }, + output: { type: "string", default: "" }, + "repo-root": { type: "string", default: "" }, + help: { type: "boolean", default: false }, + }, + allowPositionals: false, + }); + + if (help) { + usage(); + process.exit(0); + } + + try { + const repoRoot = repoRootArg + ? resolve(repoRootArg) + : findRepoRoot(resolve(__dirname, "../../../")); + const rps = findResourceProviders(repoRoot, withServiceGroups); + + let outputText; + if (count) { + outputText = String(rps.length); + } else { + outputText = formatOutput( + rps, + /** @type {"list"|"json"|"table"} */ (format), + withServiceGroups, + ); + if (format !== "json") { + outputText += `\n\nTotal: ${rps.length} resource provider(s) ${withServiceGroups ? "with" : "without"} serviceNames`; + } + } + + if (output) { + writeFileSync(output, outputText + "\n"); + console.log(`Output written to ${output}`); + } else { + console.log(outputText); + } + } catch (error) { + console.error(`Error: ${/** @type {Error} */ (error).message}`); + process.exit(1); + } +} diff --git a/.github/workflows/cmd/arm-lease-generate-lease-files.js b/.github/workflows/cmd/arm-lease-generate-lease-files.js new file mode 100644 index 000000000000..b5634434abcd --- /dev/null +++ b/.github/workflows/cmd/arm-lease-generate-lease-files.js @@ -0,0 +1,439 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; +import { dirname, join, resolve } from "path"; +import { createInterface } from "readline"; +import { fileURLToPath } from "url"; +import { parseArgs } from "util"; +import { findRepoRoot } from "./arm-lease-fetch-resource-providers.js"; + +const DEFAULT_DURATION = "P180D"; +const LEASE_BASE_PATH = ".github/arm-leases"; + +/** + * Get today's date in ISO format (YYYY-MM-DD) + * @returns {string} Today's date + */ +export function getTodayDate() { + return new Date().toISOString().split("T")[0]; +} + +/** + * Validate a start date + * @param {string} date - Date string in YYYY-MM-DD format + * @returns {string} The validated date + */ +export function validateStartDate(date) { + if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) { + throw new Error(`Invalid date format: ${date}. Expected YYYY-MM-DD`); + } + const dateObj = new Date(date); + if (isNaN(dateObj.getTime())) { + throw new Error(`Invalid calendar date: ${date}`); + } + const gracePeriodDate = new Date(); + gracePeriodDate.setHours(0, 0, 0, 0); + gracePeriodDate.setDate(gracePeriodDate.getDate() - 10); + + if (dateObj < gracePeriodDate) { + throw new Error(`Startdate is too far in the past: ${date} (must be within 10 days of today)`); + } + return date; +} + +/** + * Validate a duration string + * @param {string} duration - Duration in ISO 8601 format (e.g., P180D) + * @returns {string} The validated duration (uppercase) + */ +export function validateDuration(duration) { + const match = duration.match(/^P(\d+)D$/i); + if (!match) { + throw new Error(`Invalid duration format: ${duration}. Expected P#D (e.g., P180D)`); + } + const days = parseInt(match[1], 10); + if (days <= 0 || days > 180) { + throw new Error(`Duration must be between 1 and 180 days. Got: ${days}`); + } + return duration.toUpperCase(); +} + +/** + * Validate a resource provider namespace + * @param {string} rpNamespace - Resource provider namespace (e.g., Microsoft.Storage) + * @returns {string} The validated namespace + */ +export function validateRpNamespace(rpNamespace) { + const parts = rpNamespace.split("."); + for (const part of parts) { + if (!/^[A-Z]/.test(part)) { + throw new Error(`rpNamespace parts must start with capital letter: ${rpNamespace}`); + } + } + return rpNamespace; +} + +/** + * Validate an organization name + * @param {string} orgName - Organization name + * @returns {string} The validated org name + */ +export function validateOrgName(orgName) { + if (!/^[a-z0-9]+$/.test(orgName)) { + throw new Error(`orgName must be lowercase alphanumeric: ${orgName}`); + } + return orgName; +} + +/** + * Validate a reviewer GitHub alias + * @param {string} reviewer - Reviewer GitHub alias + * @returns {string} The validated reviewer (trimmed) + */ +export function validateReviewer(reviewer) { + if (!reviewer || reviewer.trim().length === 0) { + throw new Error("Reviewer is required and cannot be empty"); + } + const trimmed = reviewer.trim(); + if (!trimmed.startsWith("@") || trimmed.length <= 1) { + throw new Error( + `Reviewer must be a GitHub alias starting with @ (e.g., @githubUser). Got: ${reviewer}`, + ); + } + return trimmed; +} + +/** + * Parse an input line in CSV format + * @param {string} line - Input line to parse + * @returns {{orgName: string, rpNamespace: string, serviceNames: string[]}|null} Parsed entry or null + */ +export function parseInputLine(line) { + line = line.trim(); + if (!line || line.startsWith("#")) return null; + + const match = line.match(/^([^,]+),\s*([^,[]+)(?:,\s*\[([^\]]+)\])?$/); + if (!match) { + console.warn(`Skipping invalid line: ${line}`); + return null; + } + + const orgName = match[1].trim(); + const rpNamespace = match[2].trim(); + const serviceNamesStr = match[3]; + const serviceNames = serviceNamesStr + ? serviceNamesStr + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + : []; + + return { orgName, rpNamespace, serviceNames }; +} + +/** + * Generate lease.yaml content + * @param {string} rpNamespace - Resource provider namespace + * @param {string} startdate - Start date in YYYY-MM-DD format + * @param {string} duration - Duration in ISO 8601 format + * @param {string} reviewer - Reviewer GitHub alias + * @returns {string} YAML content for lease file + */ +export function generateLeaseYaml(rpNamespace, startdate, duration, reviewer) { + return `lease: + resource-provider: ${rpNamespace} + startdate: "${startdate}" + duration: ${duration} + reviewer: "${reviewer}" +`; +} + +/** + * Get the path for a lease file + * @param {string} repoRoot - Repository root path + * @param {string} orgName - Organization name + * @param {string} rpNamespace - Resource provider namespace + * @param {string|null} [serviceName] - Optional service name + * @returns {string} Full path to lease.yaml file + */ +export function getLeasePath(repoRoot, orgName, rpNamespace, serviceName = null) { + const basePath = join(repoRoot, LEASE_BASE_PATH, orgName, rpNamespace); + return serviceName ? join(basePath, serviceName, "lease.yaml") : join(basePath, "lease.yaml"); +} + +/** + * Create a lease file + * @param {string} filePath - Path to the file + * @param {string} content - File content + * @param {boolean} isDryRun - Whether this is a dry run + */ +function createLeaseFile(filePath, content, isDryRun) { + if (isDryRun) { + console.log(`[DRY RUN] Would create: ${filePath}`); + console.log(content); + console.log("---"); + return; + } + + const dir = dirname(filePath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + if (existsSync(filePath)) { + console.warn(`Warning: File already exists, skipping: ${filePath}`); + return; + } + + writeFileSync(filePath, content, "utf-8"); + console.log(`Created: ${filePath}`); +} + +/** + * Process a single entry + * @param {{orgName: string, rpNamespace: string, serviceNames: string[]}} entry + * @param {{startdate: string, duration: string, reviewer: string, dryRun: boolean}} options + * @param {string} repoRoot + */ +function processEntry(entry, options, repoRoot) { + const { orgName, rpNamespace, serviceNames } = entry; + const { startdate, duration, reviewer, dryRun: isDryRun } = options; + + try { + validateOrgName(orgName); + validateRpNamespace(rpNamespace); + + const content = generateLeaseYaml(rpNamespace, startdate, duration, reviewer); + + if (serviceNames.length === 0) { + createLeaseFile(getLeasePath(repoRoot, orgName, rpNamespace), content, isDryRun); + } else { + for (const serviceName of serviceNames) { + createLeaseFile( + getLeasePath(repoRoot, orgName, rpNamespace, serviceName), + content, + isDryRun, + ); + } + } + } catch (error) { + console.error( + `Error processing ${orgName}/${rpNamespace}: ${/** @type {Error} */ (error).message}`, + ); + } +} + +/** + * Run interactive mode + * @returns {Promise<{reviewer: string, startdate: string, duration: string, entries: Array<{orgName: string, rpNamespace: string, serviceNames: string[]}>, dryRun: boolean}>} + */ +async function promptInteractive() { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + /** @param {string} prompt */ + const question = (prompt) => + /** @type {Promise} */ (new Promise((resolve) => rl.question(prompt, resolve))); + + console.log("\nInteractive Lease File Generator"); + console.log("=================================\n"); + + const reviewer = await question("Enter reviewer GitHub alias (required, e.g., @githubUser): "); + if (!reviewer.trim()) { + console.error("Error: Reviewer name is required"); + rl.close(); + process.exit(1); + } + + const startdateInput = await question( + `Enter start date [YYYY-MM-DD] (default: ${getTodayDate()}): `, + ); + const startdate = startdateInput.trim() || getTodayDate(); + + const durationInput = await question(`Enter duration [P#D] (default: ${DEFAULT_DURATION}): `); + const duration = durationInput.trim() || DEFAULT_DURATION; + + console.log("\nEnter resource provider entries (one per line, empty line to finish):"); + console.log("Format: orgName, rpNamespace, [optional serviceNames]"); + console.log("Example: storage, Microsoft.Storage"); + console.log("Example: compute, Microsoft.Compute, [ComputeRP, DiskRP]\n"); + + /** @type {Array<{orgName: string, rpNamespace: string, serviceNames: string[]}>} */ + const entries = []; + while (true) { + const line = await question("> "); + if (!line.trim()) break; + const entry = parseInputLine(line); + if (entry) entries.push(entry); + } + + rl.close(); + return { reviewer: reviewer.trim(), startdate, duration, entries, dryRun: false }; +} + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +function usage() { + console.log(`Usage: +npx arm-lease-generate-lease-files --orgName --rpNamespace --reviewer [options] +npx arm-lease-generate-lease-files --input --reviewer [options] +npx arm-lease-generate-lease-files --interactive + +Options: + --orgName Organization/service name (lowercase alphanumeric) + --rpNamespace Resource provider namespace (e.g., Microsoft.Storage) + --serviceName Comma-separated service groups (e.g., DiskRP,ComputeRP) + --reviewer Reviewer GitHub alias starting with @ (e.g., @githubUser) + --startdate Lease start date YYYY-MM-DD (default: today) + --duration Lease duration (default: P180D, max: P180D) + --input Input file with RP entries (one per line) + --repo-root Repository root path (auto-detected if not provided) + --dry-run Show what would be created without writing files + --interactive, -i Interactive mode with prompts + --help Show this help message + +Input File Format: + - Without service groups: orgName, rpNamespace + - With service groups: orgName, rpNamespace, [serviceName1, serviceName2, ...] + +Examples: + # Single RP + npx arm-lease-generate-lease-files --orgName storage --rpNamespace Microsoft.Storage --reviewer "@johndoe" + + # With service groups + npx arm-lease-generate-lease-files --orgName compute --rpNamespace Microsoft.Compute --serviceName "DiskRP,ComputeRP" --reviewer "@johndoe" + + # From file + npx arm-lease-fetch-resource-providers --output rps.txt + npx arm-lease-generate-lease-files --input rps.txt --reviewer "@johndoe" + + # Interactive mode + npx arm-lease-generate-lease-files --interactive`); +} + +// Only run CLI if this file is executed directly (not imported) +if (process.argv[1] === __filename) { + const { + values: { + orgName: orgNameArg, + rpNamespace: rpNamespaceArg, + serviceName: serviceNameArg, + reviewer: reviewerArg, + startdate: startdateArg, + duration: durationArg, + input: inputArg, + "repo-root": repoRootArg, + "dry-run": dryRun, + interactive, + help, + }, + } = parseArgs({ + options: { + orgName: { type: "string", default: "" }, + rpNamespace: { type: "string", default: "" }, + serviceName: { type: "string", default: "" }, + reviewer: { type: "string", default: "" }, + startdate: { type: "string", default: "" }, + duration: { type: "string", default: DEFAULT_DURATION }, + input: { type: "string", default: "" }, + "repo-root": { type: "string", default: "" }, + "dry-run": { type: "boolean", default: false }, + interactive: { type: "boolean", short: "i", default: false }, + help: { type: "boolean", default: false }, + }, + allowPositionals: false, + }); + + if (help) { + usage(); + process.exit(0); + } + + async function main() { + try { + const repoRoot = repoRootArg + ? resolve(repoRootArg) + : findRepoRoot(resolve(__dirname, "../../../")); + /** @type {Array<{orgName: string, rpNamespace: string, serviceNames: string[]}>} */ + let entries = []; + let reviewer = reviewerArg; + let startdate = startdateArg || getTodayDate(); + let duration = durationArg; + + if (interactive) { + const result = await promptInteractive(); + reviewer = result.reviewer; + startdate = result.startdate; + duration = result.duration; + entries = result.entries; + + if (entries.length === 0) { + console.error("Error: No entries provided"); + process.exit(1); + } + } else if (inputArg) { + if (!reviewer) { + console.error("Error: --reviewer is required"); + usage(); + process.exit(1); + } + + if (!existsSync(inputArg)) { + throw new Error(`Input file not found: ${inputArg}`); + } + + const content = readFileSync(inputArg, "utf-8"); + for (const line of content.split("\n")) { + const entry = parseInputLine(line); + if (entry) entries.push(entry); + } + + if (entries.length === 0) { + console.warn("Warning: No valid entries found in input file"); + process.exit(0); + } + } else if (orgNameArg && rpNamespaceArg) { + if (!reviewer) { + console.error("Error: --reviewer is required"); + usage(); + process.exit(1); + } + + entries.push({ + orgName: orgNameArg, + rpNamespace: rpNamespaceArg, + serviceNames: serviceNameArg + ? serviceNameArg + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + : [], + }); + } else { + console.error( + "Error: Either --input, --interactive, or both --orgName and --rpNamespace are required", + ); + usage(); + process.exit(1); + } + + // Validate inputs + reviewer = validateReviewer(reviewer); + startdate = validateStartDate(startdate); + duration = validateDuration(duration); + + console.log(`Repository root: ${repoRoot}`); + console.log(`Reviewer: ${reviewer}`); + console.log(`Start date: ${startdate}`); + console.log(`Duration: ${duration}\n`); + + for (const entry of entries) { + processEntry(entry, { startdate, duration, reviewer, dryRun }, repoRoot); + } + + console.log(`\nProcessed ${entries.length} entries`); + } catch (error) { + console.error(`Error: ${/** @type {Error} */ (error).message}`); + process.exit(1); + } + } + + void main(); +} diff --git a/.github/workflows/test/arm-lease-validation/arm-lease-fetch-resource-providers.test.js b/.github/workflows/test/arm-lease-validation/arm-lease-fetch-resource-providers.test.js new file mode 100644 index 000000000000..2b53e13632e9 --- /dev/null +++ b/.github/workflows/test/arm-lease-validation/arm-lease-fetch-resource-providers.test.js @@ -0,0 +1,121 @@ +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; +import { describe, expect, it } from "vitest"; + +import { + findRepoRoot, + findResourceProviders, + formatOutput, +} from "../../cmd/arm-lease-fetch-resource-providers.js"; + +// Get the directory of the current test file to find repo root reliably +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Check if specification directory exists (may not exist in sparse checkouts) +const hasSpecificationDir = () => { + try { + const repoRoot = findRepoRoot(__dirname); + return fs.existsSync(path.join(repoRoot, "specification")); + } catch { + return false; + } +}; + +describe("fetch-resource-providers", () => { + describe("findRepoRoot", () => { + it("finds the repository root", () => { + const repoRoot = findRepoRoot(__dirname); + expect(repoRoot.endsWith("azure-rest-api-specs")).toBe(true); + // In sparse checkouts, .github exists but specification may not + expect( + fs.existsSync(path.join(repoRoot, "specification")) || + fs.existsSync(path.join(repoRoot, ".github")), + ).toBe(true); + }); + }); + + describe("findResourceProviders (without service names)", () => { + it("finds resource providers without service names", () => { + const repoRoot = findRepoRoot(__dirname); + const rps = findResourceProviders(repoRoot, false); + + // In sparse checkouts, specification/ may not exist, so rps could be empty + if (!hasSpecificationDir()) { + expect(rps).toEqual([]); + return; + } + + expect(rps.length).toBeGreaterThan(0); + expect(rps.every((rp) => rp.rpNamespace && rp.orgName && rp.path)).toBe(true); + expect(rps.every((rp) => !rp.serviceNames)).toBe(true); + expect(rps.some((rp) => rp.rpNamespace === "Microsoft.Storage")).toBe(true); + expect(rps.some((rp) => rp.rpNamespace === "Microsoft.Compute")).toBe(false); + }); + }); + + describe("findResourceProviders (with service names)", () => { + it("finds resource providers with service names", () => { + const repoRoot = findRepoRoot(__dirname); + const rps = findResourceProviders(repoRoot, true); + + // In sparse checkouts, specification/ may not exist, so rps could be empty + if (!hasSpecificationDir()) { + expect(rps).toEqual([]); + return; + } + + expect(rps.length).toBeGreaterThan(0); + expect(rps.every((rp) => rp.rpNamespace && rp.orgName && rp.path && rp.serviceNames)).toBe( + true, + ); + expect(rps.every((rp) => Array.isArray(rp.serviceNames))).toBe(true); + + const compute = rps.find((rp) => rp.rpNamespace === "Microsoft.Compute"); + expect(compute).toBeDefined(); + expect(compute?.orgName).toBe("compute"); + expect(compute?.serviceNames?.includes("Compute")).toBe(true); + expect(rps.some((rp) => rp.rpNamespace === "Microsoft.Storage")).toBe(false); + }); + }); + + describe("formatOutput", () => { + const rpsWithout = [{ rpNamespace: "Microsoft.Test", orgName: "test", path: "test/path" }]; + const rpsWith = [ + { + rpNamespace: "Microsoft.Test2", + orgName: "test2", + path: "test2/path", + serviceNames: ["Group1", "Group2"], + }, + ]; + + it("formats list output without service names", () => { + const output = formatOutput(rpsWithout, "list", false); + expect(output).toContain("test, Microsoft.Test"); + }); + + it("formats list output with service names", () => { + const output = formatOutput(rpsWith, "list", true); + expect(output).toContain("test2, Microsoft.Test2, [Group1, Group2]"); + }); + + it("formats JSON output", () => { + const output = formatOutput(rpsWithout, "json", false); + /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access */ + const parsed = JSON.parse(output); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed[0].rpNamespace).toBe("Microsoft.Test"); + /* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access */ + }); + + it("formats table output", () => { + const output = formatOutput(rpsWithout, "table", false); + expect(output).toContain("orgName"); + expect(output).toContain("rpNamespace"); + expect(output).toContain("test"); + expect(output).toContain("Microsoft.Test"); + }); + }); +}); diff --git a/.github/workflows/test/arm-lease-validation/arm-lease-generate-lease-files.test.js b/.github/workflows/test/arm-lease-validation/arm-lease-generate-lease-files.test.js new file mode 100644 index 000000000000..28ad476cba7d --- /dev/null +++ b/.github/workflows/test/arm-lease-validation/arm-lease-generate-lease-files.test.js @@ -0,0 +1,177 @@ +import path from "path"; +import { describe, expect, it } from "vitest"; + +import { + generateLeaseYaml, + getLeasePath, + getTodayDate, + parseInputLine, + validateDuration, + validateOrgName, + validateReviewer, + validateRpNamespace, + validateStartDate, +} from "../../cmd/arm-lease-generate-lease-files.js"; + +describe("generate-lease-files", () => { + describe("parseInputLine", () => { + it("parses line without service names", () => { + const result = parseInputLine("storage, Microsoft.Storage"); + expect(result?.orgName).toBe("storage"); + expect(result?.rpNamespace).toBe("Microsoft.Storage"); + expect(result?.serviceNames).toHaveLength(0); + }); + + it("parses line with service names", () => { + const result = parseInputLine("compute, Microsoft.Compute, [ComputeRP, DiskRP]"); + expect(result?.orgName).toBe("compute"); + expect(result?.rpNamespace).toBe("Microsoft.Compute"); + expect(result?.serviceNames).toHaveLength(2); + expect(result?.serviceNames[0]).toBe("ComputeRP"); + expect(result?.serviceNames[1]).toBe("DiskRP"); + }); + + it("returns null for empty line", () => { + expect(parseInputLine("")).toBeNull(); + }); + + it("returns null for comment", () => { + expect(parseInputLine("# This is a comment")).toBeNull(); + }); + }); + + describe("generateLeaseYaml", () => { + it("generates valid YAML content", () => { + const yaml = generateLeaseYaml("Microsoft.Test", "2026-06-01", "P180D", "@johnDoe"); + + expect(yaml).toContain("resource-provider: Microsoft.Test"); + expect(yaml).toContain('startdate: "2026-06-01"'); + expect(yaml).toContain("duration: P180D"); + expect(yaml).toContain('reviewer: "@johnDoe"'); + expect(yaml.endsWith("\n")).toBe(true); + }); + + it("does not use duration-days field", () => { + const yaml = generateLeaseYaml("Microsoft.Test", "2026-06-01", "P180D", "@johnDoe"); + expect(yaml).not.toContain("duration-days:"); + }); + }); + + describe("getLeasePath", () => { + it("returns correct path without service name", () => { + const result = getLeasePath("/repo", "storage", "Microsoft.Storage"); + const expected = path.join( + "/repo", + ".github", + "arm-leases", + "storage", + "Microsoft.Storage", + "lease.yaml", + ); + expect(result).toBe(expected); + }); + + it("returns correct path with service name", () => { + const result = getLeasePath("/repo", "compute", "Microsoft.Compute", "DiskRP"); + const expected = path.join( + "/repo", + ".github", + "arm-leases", + "compute", + "Microsoft.Compute", + "DiskRP", + "lease.yaml", + ); + expect(result).toBe(expected); + }); + }); + + describe("validateStartDate", () => { + it("accepts today's date", () => { + const today = getTodayDate(); + expect(() => validateStartDate(today)).not.toThrow(); + }); + + it("accepts future date", () => { + expect(() => validateStartDate("2027-12-31")).not.toThrow(); + }); + + it("accepts date within 10-day grace period", () => { + const fiveDaysAgo = new Date(); + fiveDaysAgo.setDate(fiveDaysAgo.getDate() - 5); + const fiveDaysAgoStr = fiveDaysAgo.toISOString().split("T")[0]; + expect(() => validateStartDate(fiveDaysAgoStr)).not.toThrow(); + }); + + it("rejects invalid date format", () => { + expect(() => validateStartDate("12/31/2026")).toThrow(/Invalid date format/); + }); + + it("rejects date too far in the past", () => { + expect(() => validateStartDate("2020-01-01")).toThrow(/past/); + }); + }); + + describe("validateDuration", () => { + it("accepts valid durations", () => { + expect(validateDuration("P180D")).toBe("P180D"); + expect(validateDuration("P90D")).toBe("P90D"); + expect(validateDuration("P1D")).toBe("P1D"); + }); + + it("converts lowercase to uppercase", () => { + expect(validateDuration("p30d")).toBe("P30D"); + }); + + it("rejects invalid format", () => { + expect(() => validateDuration("180 days")).toThrow(/Invalid duration format/); + }); + + it("rejects duration over 180 days", () => { + expect(() => validateDuration("P200D")).toThrow(/between 1 and 180/); + }); + + it("rejects zero duration", () => { + expect(() => validateDuration("P0D")).toThrow(/between 1 and 180/); + }); + }); + + describe("validateRpNamespace", () => { + it("accepts valid RP namespaces", () => { + expect(() => validateRpNamespace("Microsoft.Test")).not.toThrow(); + expect(() => validateRpNamespace("Azure.Widget")).not.toThrow(); + expect(() => validateRpNamespace("Contoso.Manager")).not.toThrow(); + }); + + it("rejects lowercase start", () => { + expect(() => validateRpNamespace("microsoft.Test")).toThrow(/capital letter/); + }); + }); + + describe("validateOrgName", () => { + it("accepts valid org names", () => { + expect(() => validateOrgName("storage")).not.toThrow(); + expect(() => validateOrgName("compute")).not.toThrow(); + expect(() => validateOrgName("test123")).not.toThrow(); + }); + + it("rejects empty org name", () => { + expect(() => validateOrgName("")).toThrow(); + }); + }); + + describe("validateReviewer", () => { + it("accepts valid reviewer format", () => { + expect(() => validateReviewer("@johnDoe")).not.toThrow(); + expect(() => validateReviewer("@jane-smith")).not.toThrow(); + }); + + it("rejects reviewer without @", () => { + expect(() => validateReviewer("johnDoe")).toThrow(/@/); + }); + + it("rejects empty reviewer", () => { + expect(() => validateReviewer("")).toThrow(); + }); + }); +}); diff --git a/.github/workflows/test/arm-lease-validation/arm-lease-validation.test.js b/.github/workflows/test/arm-lease-validation/arm-lease-validation.test.js index a034e03e63d2..0d968d04ee1f 100644 --- a/.github/workflows/test/arm-lease-validation/arm-lease-validation.test.js +++ b/.github/workflows/test/arm-lease-validation/arm-lease-validation.test.js @@ -35,6 +35,9 @@ describe("validate-arm-leases", () => { expect(isFileAllowed(".github/arm-leases/anything/here")).toBe(false); expect(isFileAllowed(".github/arm-leases/testservice/Microsoft.Test/other.yaml")).toBe(false); expect(isFileAllowed(".github/arm-leases/badtest/No.Yaml/no.md")).toBe(false); + // Scripts should now be in .github/workflows/cmd/, not allowed in arm-leases folder + expect(isFileAllowed(".github/arm-leases/scripts/fetch-resource-providers.js")).toBe(false); + expect(isFileAllowed(".github/arm-leases/scripts/generate-lease-files.js")).toBe(false); }); });