generator/generate.ts (242 lines of code) (raw):

// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. import path from 'path'; import os from 'os'; import { findDirRecursive, rmdirRecursive, lowerCaseCompare, lowerCaseCompareLists, lowerCaseStartsWith, readJsonFile, writeJsonFile, safeMkdir, safeUnlink, lowerCaseEquals } from './utils'; import * as constants from './constants'; import colors from 'colors'; import { ScopeType, AutoGenConfig } from './models'; import { get, set, flatten, uniq, concat, Dictionary, groupBy, keys, difference } from 'lodash'; import { generateAutorestConfig, runAutorest } from './autorest'; export const apiVersionRegex = /^\d{4}-\d{2}-\d{2}(|-preview)$/; export interface SchemaConfiguration { references: SchemaReference[]; temporaryPath: string; relativePath: string; } export interface SchemaReference { scope: ScopeType; type: string; reference: string; } interface RootSchemaConfiguration { file: string; jsonPath: string; } const RootSchemaConfigs: Map<ScopeType, RootSchemaConfiguration> = new Map([ [ScopeType.Tenant, constants.tenantRootSchema], [ScopeType.Subscription, constants.subscriptionRootSchema], [ScopeType.ResourceGroup, constants.resourceGroupRootSchema], [ScopeType.ManagementGroup, constants.managementGroupRootSchema] ]); export async function detectProviderNamespaces(readme: string) { const searchPath = path.resolve(`${readme}/..`); // To try and detect possible provider namespaces, assume a folder structure of <provider>/preview|stable/<api-version>/..., based on convention const apiVersionPaths = await findDirRecursive(searchPath, p => path.basename(p).match(apiVersionRegex) !== null); return uniq(apiVersionPaths.map(p => path.relative(searchPath, p).split(path.sep)[0])); } export async function generateSchemas(readme: string, autoGenConfig: AutoGenConfig): Promise<SchemaConfiguration[]> { const bicepReadmePath = `${path.dirname(readme)}/readme.bicep.md`; await generateAutorestConfig(readme, bicepReadmePath); const schemaConfigs: SchemaConfiguration[] = []; const tmpFolder = path.join(os.tmpdir(), Math.random().toString(36).substr(2)); try { const generatedSchemas = await runAutorest(readme, tmpFolder); for (const schemaPath of generatedSchemas) { const contents = await readJsonFile(schemaPath); const namespace = contents.title as string; if (!lowerCaseEquals(autoGenConfig!.namespace, namespace)) { continue; } const generatedSchemaConfigs = await handleGeneratedSchema(readme, schemaPath, namespace, autoGenConfig); schemaConfigs.push(...generatedSchemaConfigs); } } finally { await rmdirRecursive(tmpFolder); } return schemaConfigs; } async function handleGeneratedSchema(readme: string, schemaPath: string, namespace: string, autoGenConfig?: AutoGenConfig) { const apiVersion = path.basename(path.resolve(`${schemaPath}/..`)); const schemaConfigs = await generateSchemaConfigs(schemaPath, namespace, apiVersion, autoGenConfig); for (const schemaConfig of schemaConfigs) { const unknownScopeResources = schemaConfig.references.filter(x => x.scope & ScopeType.Unknown); if (autoGenConfig && unknownScopeResources.length > 0) { throw new Error(`Unable to determine scope for resource types ${unknownScopeResources.map(x => x.type).join(', ')} in readme ${readme}`); } await saveSchemaFile(schemaConfig); } return schemaConfigs; } // eslint-disable-next-line @typescript-eslint/no-explicit-any function getSchemaRefs(output: any, scopeType: ScopeType, resourceDefinitionsPath: string): SchemaReference[] { const resourceDefs = output[resourceDefinitionsPath] || {}; const resourceKeys = Object.keys(resourceDefs); return resourceKeys.map(r => ({ scope: scopeType, type: resourceDefs[r].description.substr(resourceDefs[r].description.indexOf('/') + 1), reference: `${resourceDefinitionsPath}/${r}`, })); } function getFilePathFromRef(schemaRef: string) { const schemaUri = schemaRef.split('#')[0]; if (!lowerCaseStartsWith(schemaUri, constants.schemasBaseUri)) { throw new Error(`Unrecognized schema reference ${schemaRef}`); } return path.resolve(path.join(constants.schemasBasePath, schemaUri.substring(constants.schemasBaseUri.length + 1))); } function assignScopesToUnknownReferences(knownReferences: SchemaReference[], unknownReferences: SchemaReference[], autoGenConfig?: AutoGenConfig) { const resourceConfig = (autoGenConfig || {}).resourceConfig || []; for (const schemaRef of unknownReferences) { const config = resourceConfig.find(c => lowerCaseCompare(c.type, schemaRef.type) === 0); if (config && (schemaRef.scope & ScopeType.Unknown)) { schemaRef.scope = config.scopes || ScopeType.None; } else { schemaRef.scope = ScopeType.Tenant | ScopeType.Subscription | ScopeType.ResourceGroup | ScopeType.ManagementGroup | ScopeType.Extension; } for (const knownReference of knownReferences.filter(r => lowerCaseCompare(r.type, schemaRef.type) === 0)) { // remove resources for scopes that have already been declared elsewhere to avoid duplication schemaRef.scope &= ~knownReference.scope; } } } function getSchemaFileName(namespace: string, suffix: string | undefined) { if (suffix === undefined) { return `${namespace}.json` } return `${namespace}.${suffix}.json`; } // eslint-disable-next-line @typescript-eslint/no-explicit-any function getSchemaConfig(filePath: string, output: any, namespace: string, apiVersion: string, relativePath: string, autoGenConfig?: AutoGenConfig): SchemaConfiguration { const knownReferences = [ ...getSchemaRefs(output, ScopeType.Tenant, 'tenant_resourceDefinitions'), ...getSchemaRefs(output, ScopeType.ManagementGroup, 'managementGroup_resourceDefinitions'), ...getSchemaRefs(output, ScopeType.Subscription, 'subscription_resourceDefinitions'), ...getSchemaRefs(output, ScopeType.ResourceGroup, 'resourceDefinitions'), ...getSchemaRefs(output, ScopeType.Extension, 'extension_resourceDefinitions'), ]; const unknownReferences = getSchemaRefs(output, ScopeType.Unknown, 'unknown_resourceDefinitions'); assignScopesToUnknownReferences(knownReferences, unknownReferences, autoGenConfig); const references = [ ...knownReferences, ...unknownReferences, ]; const schemaPath = path.join(constants.schemasBasePath, relativePath); console.log('================================================================================================================================'); console.log('Filename: ' + colors.green(schemaPath)); console.log('Provider Namespace: ' + colors.green(namespace)); console.log('API Version: ' + colors.green(apiVersion)); const tenantSchemaRefs = references.filter(x => x.scope & ScopeType.Tenant); if (tenantSchemaRefs.length > 0) { console.log('Resource Types (Tenant Scope):'); for (const schemaRef of tenantSchemaRefs) { console.log('- ' + colors.green(schemaRef.type)); } } const managementGroupSchemaRefs = references.filter(x => x.scope & ScopeType.ManagementGroup); if (managementGroupSchemaRefs.length > 0) { console.log('Resource Types (Management Group Scope):'); for (const schemaRef of managementGroupSchemaRefs) { console.log('- ' + colors.green(schemaRef.type)); } } const subscriptionSchemaRefs = references.filter(x => x.scope & ScopeType.Subscription); if (subscriptionSchemaRefs.length > 0) { console.log('Resource Types (Subscription Scope):'); for (const schemaRef of subscriptionSchemaRefs) { console.log('- ' + colors.green(schemaRef.type)); } } const resourceGroupSchemaRefs = references.filter(x => x.scope & ScopeType.ResourceGroup); if (resourceGroupSchemaRefs.length > 0) { console.log('Resource Types (Resource Group Scope):'); for (const schemaRef of resourceGroupSchemaRefs) { console.log('- ' + colors.green(schemaRef.type)); } } const extensionSchemaRefs = references.filter(x => x.scope & ScopeType.Extension); if (extensionSchemaRefs.length > 0) { console.log('Resource Types (Extension Scope):'); for (const schemaRef of extensionSchemaRefs) { console.log('- ' + colors.green(schemaRef.type)); } } const unknownSchemaRefs = references.filter(x => x.scope & ScopeType.Unknown); if (unknownSchemaRefs.length > 0) { console.log('Resource Types (Unknown Scope):'); for (const schemaRef of unknownSchemaRefs) { console.log('- ' + colors.red(schemaRef.type)); } } console.log('================================================================================================================================'); return { references, temporaryPath: filePath, relativePath, }; } async function generateSchemaConfigs(schemaFilePath: string, namespace: string, apiVersion: string, autoGenConfig?: AutoGenConfig): Promise<SchemaConfiguration[]> { namespace = autoGenConfig?.namespace ?? namespace; const suffix = autoGenConfig?.suffix; const relativePath = `${apiVersion}/${getSchemaFileName(namespace, suffix)}`; const configs = []; const mainSchema = await readJsonFile(schemaFilePath); if (autoGenConfig?.postProcessor) { await autoGenConfig.postProcessor(namespace, apiVersion, mainSchema, async (additionalFileName, additionalSchema) => { const additionalFilePath = path.resolve(schemaFilePath, '..', additionalFileName); await writeJsonFile(additionalFilePath, additionalSchema); configs.push(getSchemaConfig(additionalFilePath, additionalSchema, namespace, apiVersion, `${apiVersion}/${additionalFileName}`, autoGenConfig)); }); await writeJsonFile(schemaFilePath, mainSchema); } configs.push(getSchemaConfig(schemaFilePath, mainSchema, namespace, apiVersion, relativePath, autoGenConfig)); return configs; } async function saveSchemaFile(schemaConfig: SchemaConfiguration) { const schemaRef = `${constants.schemasBaseUri}/${schemaConfig.relativePath}#` const schemaPath = path.join(constants.schemasBasePath, schemaConfig.relativePath); await safeMkdir(path.dirname(schemaPath)); const output = await readJsonFile(schemaConfig.temporaryPath); output.id = schemaRef; await writeJsonFile(schemaPath, output); } function schemaRefComparer(schemaRefA: string, schemaRefB: string) { const splitA = schemaRefA.substr(constants.schemasBaseUri.length + 1).split('/'); const splitB = schemaRefB.substr(constants.schemasBaseUri.length + 1).split('/'); // order by namespace, then API version, then the rest return lowerCaseCompareLists( [...splitA.slice(1, 2), ...splitA.slice(0, 1), ...splitA.slice(2)], [...splitB.slice(1, 2), ...splitB.slice(0, 1), ...splitB.slice(2)] ); } async function getCurrentTemplateRefs(scopeType: ScopeType, rootSchemaConfig: RootSchemaConfiguration) { const current = await readJsonFile(rootSchemaConfig.file); const currentRefsOneOf = get(current, rootSchemaConfig.jsonPath) as Dictionary<string>[]; return currentRefsOneOf.map(v => v['$ref']); } export async function clearAutoGeneratedSchemaRefs(autoGenList: AutoGenConfig[]) { for (const [scopeType, rootSchemaConfig] of RootSchemaConfigs) { const currentRefs = await getCurrentTemplateRefs(scopeType, rootSchemaConfig); const autogenlistedFiles = new Set(autoGenList.map(x => getSchemaFileName(x.namespace, x.suffix).toLowerCase())); const schemasToRemove = []; const schemasByFilePath = groupBy(currentRefs, getFilePathFromRef); // clean up existing schemas to detect deletions for (const schemaFile of keys(schemasByFilePath)) { const fileName = path.basename(schemaFile).toLowerCase(); if (autogenlistedFiles.has(fileName)) { schemasToRemove.push(...schemasByFilePath[schemaFile]); await safeUnlink(schemaFile); } } const newRefs = difference(currentRefs, schemasToRemove); const template = await readJsonFile(rootSchemaConfig.file); set(template, rootSchemaConfig.jsonPath, newRefs.map(ref => ({ '$ref': ref }))); await writeJsonFile(rootSchemaConfig.file, template); } } export async function saveAutoGeneratedSchemaRefs(schemaConfigs: SchemaConfiguration[], isGenerateAll: boolean) { for (const [scopeType, rootSchemaConfig] of RootSchemaConfigs) { const refs = flatten(schemaConfigs .map(c => c.references .filter(x => x.scope & scopeType) .map(x => `${constants.schemasBaseUri}/${c.relativePath}#/${x.reference}`))); const currentRefs = !isGenerateAll ? await getCurrentTemplateRefs(scopeType, rootSchemaConfig): []; const newRefs = uniq(concat(currentRefs, refs)).sort(schemaRefComparer); const template = await readJsonFile(rootSchemaConfig.file); set(template, rootSchemaConfig.jsonPath, newRefs.map(ref => ({ '$ref': ref }))); await writeJsonFile(rootSchemaConfig.file, template); } }