import * as path from 'path'; import * as fs from 'fs'; import { createPackageConfigBuilder, PackageConfigPathOptions } from '@cirrusct/config'; import { has, isString, isArray, merge, get } from 'lodash'; import { createError } from '@cirrusct/core-node'; import { MrDeploymentConfig, MrDeployConfig } from './types'; import { MonoRepo, MonoRepoPackage } from '@cirrusct/mr-core'; import { BuildConfig, BuildSpecConfig, BuildPathsConfig, TypescriptTranspilerConfigOptions, MrBuild, } from '@cirrusct/mr-build'; import { TemplateSourceFile, TemplateSourceConfig, TemplateSourceFileConfig, resolveTemplateContent, } from '@cirrusct/templates'; import { MrDeployOptions, ResolvedDeploymentConfig, DeployTemplateModel } from './types'; import { findPackagenames } from './config'; import { getDashedName } from './DeployTemplateContext'; export class DeploymentConfigResolver { private _globalDeployProfilesPath: string; private _globalDeploymentsProfilesPath: string; constructor( public mrPackage: MonoRepoPackage, public deployConfig: MrDeployConfig, public buildSpec?: BuildSpecConfig, public options: MrDeployOptions = {}, public name?: string, public deploymentConfig?: MrDeploymentConfig, private profileSettingsBaseName = '.settings' ) {} private get globalDeployProfilesPath(): string { if (!this._globalDeployProfilesPath) { this._globalDeployProfilesPath = this.mrPackage.monoRepo.profiles.resolvePath('deploy'); } return this._globalDeployProfilesPath; } private get globalDeploymentsProfilesPath(): string { if (!this._globalDeploymentsProfilesPath) { this._globalDeploymentsProfilesPath = path.join(this.globalDeployProfilesPath, 'deployments'); } return this._globalDeploymentsProfilesPath; } private findPackagenames = (): string[] => { return findPackagenames(this.mrPackage); }; private resolveGlobalProfilePath = (profile: string): string | null => { const profilePath = path.resolve(this.globalDeploymentsProfilesPath, profile); return fs.existsSync(profilePath) ? profilePath : null; }; private resolveProfileConfig = async (profile: string): Promise => { const rootPath = this.resolveGlobalProfilePath(profile); if (rootPath) { const pathOptions: PackageConfigPathOptions = { rootPath: rootPath, baseName: this.profileSettingsBaseName, isRequired: false, }; const builder = createPackageConfigBuilder(pathOptions); return await builder.build(); } else { return null; } }; private resolveTemplateSource = async ( templates: string | string[] | TemplateSourceConfig, profile: string ): Promise => { // array of potential base paths to check for templates const tryBasePaths: string[] = [ // the templates folder within this defined global profile path.resolve(this.resolveGlobalProfilePath(profile), 'templates'), // off the root path this.mrPackage.monoRepo.path, // the root folder within this defined global profile this.resolveGlobalProfilePath(profile), ]; // function to create a TemplateFileSource, for a given file/config and optional base path const tryCreate = ( file: string | TemplateSourceFileConfig, basePath: string | null ): TemplateSourceFile | null => { // we either have a string with a path to the template file, or a TemplateSourceFileConfig object (with a path property) // normalize to a TemplateSourceFileConfig object const sourceFileConfig: TemplateSourceFileConfig = has(file, 'path') ? (file as TemplateSourceFileConfig) : { path: file as string }; // get the overall root root const root = this.mrPackage.monoRepo.path; // resolve to the overall root const buildBasePath = (base: string) => path.resolve(root, base); // build a path merging base like: [overall root]/-->[base]<--/[source file] const buildFilePath = (base: string) => path.resolve(buildBasePath(base), sourceFileConfig.path); // flag to indicate whether we were able to create using passed in base path let created: TemplateSourceFile = null; let tryBasePath: string; let tryFilePath: string; // if basePath passed in, try to find the base path using some rules if (basePath) { // resolve base path to root tryBasePath = buildBasePath(basePath); // get full file path to the source file tryFilePath = buildFilePath(basePath); // see if it exists if (fs.existsSync(tryFilePath)) { // file exists, create a TemplateSourceFile object to use in resolved config created = new TemplateSourceFile(tryFilePath, tryBasePath, sourceFileConfig); } } // If base path not passed in or we couldn't find a template with it, look in other known potential 'base' locations if (!created) { // loop through each potential locaion for (const b of tryBasePaths) { // same as aboe (hmm, should probably merge with above ??) tryBasePath = buildBasePath(b); tryFilePath = buildFilePath(b); if (fs.existsSync(tryFilePath)) { created = new TemplateSourceFile(tryFilePath, tryBasePath, sourceFileConfig); break; } } } return created; }; if (templates) { // use basePath (if defined) in the template source config. If not specifically defined, we use rules to check multiple locations for it const basePath = get(templates, 'path', null); // get array of files from the config let files: Array = get( templates, 'files', isArray(templates) ? templates : [templates] ); // go through each file in the config, resolve it's location, and create a TemplateFileSource object for each return files.reduce((acc, cur) => { const sourceFile = tryCreate(cur, basePath); if (sourceFile) { acc.push(sourceFile); } else { throw createError(`Could not resolve template source file: ${cur}`); } return acc; }, []); } else { return []; } }; private resolveDependentServers = async ( config: MrDeploymentConfig, output: string, skinnyModel: Partial ): Promise => { const resolved: ResolvedDeploymentConfig[] = []; if (config && config.dependsOnServers) { for (const subname in config.dependsOnServers) { const serverConfig = config.dependsOnServers[subname]; // need to resolve any tokens in the config names using the current (parent) context. They'll // get resolved by the server's context if we don't do it here. serverConfig.output = resolveTemplateContent(path.join(output, serverConfig.output), skinnyModel); // this gets a little thick, but we're constructing a child deploymentConfig from scratch for the each server // duplicating some logic from the root level construction, but const mrPackage = this.mrPackage.monoRepo.findPackage(serverConfig.package || subname); // using a buildspec labeled 'default', or otherwise the first one. This theoretically could fail for an exotic BuildConfig, // but at this point only really using it for it's 'type' to lookup default profiles const buildConfig = await MrBuild.getPackageBuildConfig(mrPackage); const spec = get( buildConfig.specs, 'default', get(buildConfig.specs, Object.keys(buildConfig.specs)[0]) ); // create child resolver for server const resolver = new DeploymentConfigResolver( mrPackage, this.deployConfig, spec, this.options, subname, serverConfig ); // build the server config const resolvedConfig = await resolver.resolveDeploymentConfig(subname, serverConfig); resolved.push(resolvedConfig); } } return resolved; }; // a little bit of a hack, but path configs could have tokens requiring substitution and we need to do the substitution // early before we construct the context as the values may be passed to a different context and resolved incorrectly // so this is duplicated and doesn't have the entire model, but should have about anything that could be used in setting // that needs resolving at this stage private buildSkinnyModel = (deploymentName: string, config: MrDeploymentConfig): Partial => { return { deploymentName, deploymentNameDashed: getDashedName(deploymentName), mrPackage: this.mrPackage, mrPackageNameDashed: this.mrPackage.formatName('dash'), mrPackageNameSnaked: this.mrPackage.formatName('snake'), mrPackageNameWithoutScope: this.mrPackage.nameWithoutScope, port: config.port, portWs: config.portWs, properties: config.properties, }; }; public resolveDeploymentConfig = async ( name: string, rawConfig?: MrDeploymentConfig ): Promise => { debugger; // main deployment config, either passed as argument or pulled from a lookup of global defined configs keyed on 'name' rawConfig = rawConfig || this.deployConfig.deployments[name]; // get profile key from the config or default to buildSpec type. Used to get base settings from the profiles store const profile = rawConfig.profile || this.buildSpec.builder; // load profile config from the global store const profileConfig = await this.resolveProfileConfig(profile); if (!profileConfig) { throw createError(`Deploy Profile '${profile}' not found`); } // use the templates from the passed config, or default to the templates defined in the profile // const templatesConfig = rawConfig.templates || profileConfig.templates; // get the output root from the passed config, or the profile (usually a sub-path) // getting this before we merge so we prioritize based on where output is defined const templateConfigOutput = rawConfig.output || profileConfig.output || ''; // merge the base-level configs from deployConfig, layer in profile's config, then over-ride with passed config const mergedConfig = merge({}, this.deployConfig, profileConfig, rawConfig); // build array of TemplateFileSource objects based on the merged configuration const templateSourceFiles = await this.resolveTemplateSource(mergedConfig.templates, profile); // construct the base output path, prioritizing passed options (ie cli), const baseOutput = this.options.output ? path.resolve(process.cwd(), this.options.output) : path.resolve(this.mrPackage.monoRepo.path, this.deployConfig.output || ''); // create final output path, layering template's output on top of the base const output = path.resolve(baseOutput, templateConfigOutput); // create a scaled-down version of the Template model as we need it to substitute paths const skinnyModel = this.buildSkinnyModel(name, mergedConfig); // resolve configs for any servers defined as dependencies. const dependsOnServersResolvedConfigs = await this.resolveDependentServers(mergedConfig, output, skinnyModel); // kill extraneous configs delete mergedConfig['deployments']; delete mergedConfig.templates; // first add the properties that can be safely substituted with skinny model const coreResolved = { ...(mergedConfig as MrDeploymentConfig), name: name, mrPackage: this.mrPackage, templateSourceFiles, output, options: this.options, buildSpec: this.buildSpec, }; // return resolved, doing substittion with skinny model, adding props we don't want to (or already have been) substituted return { ...resolveTemplateContent(coreResolved, skinnyModel), dependsOnServersResolvedConfigs, }; }; public resolve = async (): Promise => { const resolvedConfigs: ResolvedDeploymentConfig[] = []; if (this.name) { resolvedConfigs.push(await this.resolveDeploymentConfig(this.name, this.deploymentConfig)); } else { const pkgnames = this.findPackagenames(); for (const name of pkgnames) { resolvedConfigs.push(await this.resolveDeploymentConfig(name)); } } return resolvedConfigs; }; }