import child_process from "child_process" ; import commander from "commander" ; import fs from "fs" ; import yaml from "js-yaml" ; import path from "path" ; import process from "process" ; import shelljs , { TestOptions } from "shelljs" ; import { Bedrock } from "../../config" ; import { assertIsStringWithContent } from "../../lib/assertions" ; import * as bedrock from "../../lib/bedrockYaml" ; import { build as buildCmd , exit as exitCmd } from "../../lib/commandBuilder" ; import { generateAccessYaml } from "../../lib/fileutils" ; import { tryGetGitOrigin } from "../../lib/gitutils" ; import * as dns from "../../lib/net/dns" ; import * as ingressRoute from "../../lib/traefik/ingress-route" ; import * as middleware from "../../lib/traefik/middleware" ; import { logger } from "../../logger" ; import { BedrockFile , BedrockServiceConfig } from "../../types" ; import decorator from "./reconcile.decorator.json" ; import { build as buildError , log as logError } from "../../lib/errorBuilder" ; import { errorStatusCode } from "../../lib/errorStatusCode" ; import { getAllFilesInDirectory } from "../../lib/ioUtil" ; /**
 * IExecResult represents the possible return value of a Promise based wrapper
 * for child_process.exec(). `error` would specify an optional ExecException
 * which can be passed via a resolve() value instead of throwing an untyped
 * reject()
 */ interface ExecResult { error ? : child_process . ExecException ; value ? : { stdout : string ; stderr : string ; } ; } param cmd param pipeIO const exec = async ( cmd , pipeIO = false ) : Promise < ExecResult > => { return new Promise < ExecResult > ( ( resolve ) => { const child = child_process . exec ( cmd , ( error , stdout , stderr ) => { return resolve ( { error : error ?? undefined , value : { stdout , stderr } , } ) ; } ) ; if ( pipeIO ) { child . stdin ?. pipe ( process . stdin ) ; child . stdout ?. pipe ( process . stdout ) ; child . stderr ?. pipe ( process . stderr ) ; } return child ; } ) ; } ; param commandToRun throws export const execAndLog = async ( commandToRun ) : Promise < ExecResult > => { logger . info ( `Running: ${ commandToRun }` ) ; const pipeOutputToCurrentShell = true ; const result = await exec ( commandToRun , pipeOutputToCurrentShell ) ; if ( result . error ) { throw buildError ( errorStatusCode . CMD_EXE_ERR , { errorKey : "hld-reconcile-err-cmd-failed" , values : [ commandToRun ] , } , result . error ) ; } return result ; } ; export const createAccessYaml = async ( getGitOrigin , writeAccessYaml , absBedrockApplicationPath , absRepositoryPathInHldPath ) : Promise < void > => { const originUrl = await getGitOrigin ( absBedrockApplicationPath ) ; logger . info ( `Writing access.yaml for ${ originUrl } to ${ absRepositoryPathInHldPath }` ) ; writeAccessYaml ( absRepositoryPathInHldPath , originUrl ) ; } ; type MiddlewareMap < T = Partial < ReturnType < typeof middleware . create > > > = { ringed : T ; default ? : T ; } ; param absHldPath param repositoryName export const purgeRepositoryComponents = ( absHldPath , repositoryName ) : void => { assertIsStringWithContent ( absHldPath , "hld-path" ) ; assertIsStringWithContent ( repositoryName , "repository-name" ) ; if ( ! fs . existsSync ( path . join ( absHldPath , repositoryName ) ) ) { logger . info ( `repository: ${ repositoryName } not found in ${ absHldPath }. Will skip deletion step of reconcile.` ) ; return ; } const filesToDelete = getAllFilesInDirectory ( path . join ( absHldPath , repositoryName ) ) . filter ( ( filePath ) => ! filePath . endsWith ( "access.yaml" ) && ! filePath . match ( /config\/.*\.yaml$/ ) ) ; try { filesToDelete . forEach ( ( file ) => { fs . unlink ( file , function ( err ) { if ( err ) throw err ; console . log ( `${ file } deleted!` ) ; } ) ; } ) ; } catch ( err ) { throw buildError ( errorStatusCode . FILE_IO_ERR , { errorKey : "hld-reconcile-err-purge-repo-comps" , values : [ repositoryName , absHldPath ] , } , err ) ; } return ; } ; export const createRepositoryComponent = async ( execCmd , absHldPath , repositoryName ) : Promise < ExecResult > => { assertIsStringWithContent ( absHldPath , "hld-path" ) ; assertIsStringWithContent ( repositoryName , "repository-name" ) ; return execCmd ( `cd ${ absHldPath } && mkdir -p ${ repositoryName } && fab add ${ repositoryName } --path ./${ repositoryName } --method local` ) . catch ( ( err ) => { throw buildError ( errorStatusCode . EXE_FLOW_ERR , { errorKey : "hld-reconcile-err-repo-create" , values : [ repositoryName , absHldPath ] , } , err ) ; } ) ; } ; /**
 * Normalizes the provided service name to a DNS-1123 and Fabrikate command safe
 * name.
 * All non-alphanumerics and non-dashes are converted to dashes
 */ export const normalizedName = ( name ) : string => { return dns . replaceIllegalCharacters ( name ) . replace ( /\./g , "-" ) ; } ; export const configureChartForRing = async ( execCmd , normalizedRingPathInHld , normalizedRingName , serviceConfig , normalizedServiceName ) : Promise < ExecResult > => { const k8sBackendName = serviceConfig . k8sBackend || normalizedServiceName ; const k8sSvcBackendAndName = [ normalizedName ( k8sBackendName ) , normalizedRingName , ] . join ( "-" ) ; const fabConfigureCommand = `cd ${ normalizedRingPathInHld } && fab set --subcomponent "chart" serviceName="${ k8sSvcBackendAndName }"` ; return execCmd ( fabConfigureCommand ) . catch ( ( err ) => { throw buildError ( errorStatusCode . EXE_FLOW_ERR , "hld-reconcile-err-helm-config" , err ) ; } ) ; } ; export const createServiceComponent = async ( execCmd , absRepositoryInHldPath , serviceName ) : Promise < ExecResult > => { assertIsStringWithContent ( absRepositoryInHldPath , "project-path" ) ; assertIsStringWithContent ( serviceName , "service-name" ) ; return execCmd ( `cd ${ absRepositoryInHldPath } && mkdir -p ${ serviceName } config && fab add ${ serviceName } --path ./${ serviceName } --method local --type component && touch ./config/common.yaml` ) . catch ( ( err ) => { throw buildError ( errorStatusCode . EXE_FLOW_ERR , { errorKey : "hld-reconcile-err-service-create" , values : [ serviceName , absRepositoryInHldPath ] , } , err ) ; } ) ; } ; export const createRingComponent = async ( execCmd , svcPathInHld , normalizedRingName ) : Promise < ExecResult > => { assertIsStringWithContent ( svcPathInHld , "service-path" ) ; assertIsStringWithContent ( normalizedRingName , "ring-name" ) ; const createRingInSvcCommand = `cd ${ svcPathInHld } && mkdir -p ${ normalizedRingName } config && fab add ${ normalizedRingName } --path ./${ normalizedRingName } --method local --type component && touch ./config/common.yaml` ; return execCmd ( createRingInSvcCommand ) . catch ( ( err ) => { throw buildError ( errorStatusCode . EXE_FLOW_ERR , { errorKey : "hld-reconcile-err-ring-create" , values : [ normalizedRingName , svcPathInHld ] , } , err ) ; } ) ; } ; export const addChartToRing = async ( execCmd , ringPathInHld , serviceConfig ) : Promise < ExecResult > => { let addHelmChartCommand = "" ; const { chart } = serviceConfig . helm ; if ( "git" in chart ) { let chartVersioning = "" ; if ( "branch" in chart ) { assertIsStringWithContent ( chart . branch , "git-branch" ) ; chartVersioning = `--branch ${ chart . branch }` ; } else { assertIsStringWithContent ( chart . sha , "git-sha" ) ; chartVersioning = `--version ${ chart . sha }` ; } assertIsStringWithContent ( chart . git , "git-url" ) ; assertIsStringWithContent ( chart . path , "git-path" ) ; addHelmChartCommand = `fab add chart --source ${ chart . git } --path ${ chart . path } ${ chartVersioning } --type helm` ; } else if ( "repository" in chart ) { assertIsStringWithContent ( chart . repository , "helm-repo" ) ; assertIsStringWithContent ( chart . chart , "helm-chart-name" ) ; addHelmChartCommand = `fab add chart --source ${ chart . repository } --path ${ chart . chart } --type helm` ; } return execCmd ( `cd ${ ringPathInHld } && ${ addHelmChartCommand }` ) . catch ( ( err ) => { throw buildError ( errorStatusCode . EXE_FLOW_ERR , { errorKey : "hld-reconcile-err-helm-add" , values : [ JSON . stringify ( serviceConfig ) , ringPathInHld ] , } , err ) ; } ) ; } ; export const createStaticComponent = async ( execCmd , ringPathInHld ) : Promise < ExecResult > => { assertIsStringWithContent ( ringPathInHld , "ring-path" ) ; const createConfigAndStaticComponentCommand = `cd ${ ringPathInHld } && mkdir -p config static && fab add static --path ./static --method local --type static && touch ./config/common.yaml` ; return execCmd ( createConfigAndStaticComponentCommand ) . catch ( ( err ) => { throw buildError ( errorStatusCode . EXE_FLOW_ERR , { errorKey : "hld-reconcile-err-static-create" , values : [ ringPathInHld ] , } , err ) ; } ) ; } ; export const createIngressRouteForRing = ( ringPathInHld , serviceName , serviceConfig , middlewares , ring , ingressVersionAndPath , ringIsDefault = false ) : ReturnType < typeof ingressRoute . create > [ ] => { const staticComponentPathInRing = path . join ( ringPathInHld , "static" ) ; const ingressRoutePathInStaticComponent = path . join ( staticComponentPathInRing , "ingress-route.yaml" ) ; const ingressRoutes = [ ] ; const ringedRoute = ingressRoute . create ( serviceName , ring , serviceConfig . k8sBackendPort , ingressVersionAndPath , { isDefault : false , k8sBackend : serviceConfig . k8sBackend , middlewares : [ middlewares . ringed . metadata ?. name , ... ( serviceConfig . middlewares ?? [ ] ) , ] . filter ( ( e ) : e is NonNullable < typeof e > => ! ! e ) , } ) ; ingressRoutes . push ( ringedRoute ) ; if ( ringIsDefault ) { const defaultRingRoute = ingressRoute . create ( serviceName , ring , serviceConfig . k8sBackendPort , ingressVersionAndPath , { isDefault : ringIsDefault , k8sBackend : serviceConfig . k8sBackend , middlewares : [ middlewares . default ?. metadata ?. name , ... ( serviceConfig . middlewares ?? [ ] ) , ] . filter ( ( e ) : e is NonNullable < typeof e > => ! ! e ) , } ) ; ingressRoutes . push ( defaultRingRoute ) ; } const routeYaml = ingressRoutes . map ( ( str ) => { return yaml . safeDump ( str , { lineWidth : Number . MAX_SAFE_INTEGER , } ) ; } ) . join ( "\n---\n" ) ; logger . info ( `Writing IngressRoute YAML to '${ ingressRoutePathInStaticComponent }'` ) ; fs . writeFileSync ( ingressRoutePathInStaticComponent , routeYaml ) ; return ingressRoutes ; } ; export const createMiddlewareForRing = ( ringPathInHld , serviceName , ring , ingressVersionAndPath , ringIsDefault = false ) : MiddlewareMap => { const staticComponentPathInRing = path . join ( ringPathInHld , "static" ) ; const middlewaresPathInStaticComponent = path . join ( staticComponentPathInRing , "middlewares.yaml" ) ; const middlewares = { ringed : middleware . create ( serviceName , ring , [ ingressVersionAndPath ] ) , default : ringIsDefault ? middleware . create ( serviceName , "" , [ ingressVersionAndPath ] ) : undefined , } ; const middlewareYaml = Object . values ( middlewares ) . filter ( ( e ) : e is NonNullable < typeof e > => ! ! e ) . map ( ( str ) => yaml . safeDump ( str , { lineWidth : Number . MAX_SAFE_INTEGER , } ) ) . join ( "\n---\n" ) ; logger . info ( `Writing Middlewares YAML to '${ middlewaresPathInStaticComponent }'` ) ; fs . writeFileSync ( middlewaresPathInStaticComponent , middlewareYaml ) ; return middlewares ; } ; export interface ReconcileDependencies { exec : typeof execAndLog ; writeFile : typeof fs . writeFileSync ; getGitOrigin : typeof tryGetGitOrigin ; generateAccessYaml : typeof generateAccessYaml ; createAccessYaml : typeof createAccessYaml ; purgeRepositoryComponents : typeof purgeRepositoryComponents ; createRepositoryComponent : typeof createRepositoryComponent ; configureChartForRing : typeof configureChartForRing ; createServiceComponent : typeof createServiceComponent ; createRingComponent : typeof createRingComponent ; addChartToRing : typeof addChartToRing ; createStaticComponent : typeof createStaticComponent ; createIngressRouteForRing : typeof createIngressRouteForRing ; createMiddlewareForRing : typeof createMiddlewareForRing ; } export const validateInputs = ( repositoryName , hldPath , bedrockApplicationRepoPath ) : void => { assertIsStringWithContent ( repositoryName , "repository-name" ) ; assertIsStringWithContent ( hldPath , "hld-path" ) ; assertIsStringWithContent ( bedrockApplicationRepoPath , "bedrock-application-repo-path" ) ; } ; export const checkForFabrikate = ( which ) : void => { const fabrikateInstalled = which ( "fab" ) ; if ( fabrikateInstalled === "" ) { throw buildError ( errorStatusCode . VALIDATION_ERR , "hld-reconcile-err-fab" ) ; } } ; export const testAndGetAbsPath = ( test , log , possiblyRelativePath , pathType ) : string => { const absPath = path . resolve ( possiblyRelativePath ) ; if ( ! test ( "-e" , absPath ) && ! test ( "-d" , absPath ) ) { throw buildError ( errorStatusCode . VALIDATION_ERR , { errorKey : "hld-reconcile-err-path" , values : [ pathType ] , } ) ; } log ( `Found ${ pathType } at ${ absPath }` ) ; return absPath ; } ; param majorVersion param pathPrefix param serviceName export const getFullPathPrefix = ( majorVersion , pathPrefix , serviceName ) : string => { const versionPath = majorVersion ? `/${ majorVersion }` : "" ; const servicePath = pathPrefix || serviceName ; return `${ versionPath }/${ servicePath }` ; } ; export const reconcileHld = async ( dependencies , bedrockYaml , repositoryName , absHldPath , absBedrockPath ) : Promise < void > => { const { services : managedServices , rings : managedRings } = bedrockYaml ; dependencies . purgeRepositoryComponents ( absHldPath , normalizedName ( repositoryName ) ) ; await dependencies . createRepositoryComponent ( dependencies . exec , absHldPath , normalizedName ( repositoryName ) ) ; const normalizedAbsRepositoryInHldPath = path . join ( absHldPath , normalizedName ( repositoryName ) ) ; await dependencies . createAccessYaml ( dependencies . getGitOrigin , dependencies . generateAccessYaml , absBedrockPath , normalizedAbsRepositoryInHldPath ) ; for ( const serviceConfig of managedServices ) { const serviceRelPath = serviceConfig . path ; const serviceName = serviceConfig . displayName || path . basename ( serviceRelPath ) ; if ( serviceName === "." || ! serviceName ) { logger . warn ( "Service displayName not provided or service path is `.` - not reconciling service" ) ; continue ; } const normalizedSvcName = normalizedName ( serviceName ) ; logger . info ( `Reconciling service: ${ normalizedSvcName }` ) ; const helmChartConfig = serviceConfig . helm . chart ; if ( "git" in helmChartConfig && helmChartConfig . git !== "" ) { const accessToken = helmChartConfig . accessTokenVariable || undefined ; logger . info ( `Git repository found in helm configuration of ${ serviceName } -- adding to access.yaml` ) ; dependencies . generateAccessYaml ( normalizedAbsRepositoryInHldPath , helmChartConfig . git , accessToken ) ; } await dependencies . createServiceComponent ( dependencies . exec , normalizedAbsRepositoryInHldPath , normalizedSvcName ) ; const normalizedSvcPathInHld = path . join ( normalizedAbsRepositoryInHldPath , normalizedSvcName ) ; const ringsToCreate = Object . entries ( managedRings ) . map ( ( [ ring , { isDefault } ] ) => { const normalizedRingName = normalizedName ( ring ) ; return { isDefault : ! ! isDefault , normalizedRingName , normalizedRingPathInHld : path . join ( normalizedSvcPathInHld , normalizedRingName ) , } ; } ) ; for ( const ring of ringsToCreate ) { const { normalizedRingName , normalizedRingPathInHld , isDefault } = ring ; await dependencies . createRingComponent ( dependencies . exec , normalizedSvcPathInHld , normalizedRingName ) ; await dependencies . addChartToRing ( dependencies . exec , normalizedRingPathInHld , serviceConfig ) ; await dependencies . configureChartForRing ( dependencies . exec , normalizedRingPathInHld , normalizedRingName , serviceConfig , normalizedSvcName ) ; if ( serviceConfig . disableRouteScaffold ) { logger . info ( `Skipping ingress route generation for service ${ serviceName }` ) ; continue ; } await dependencies . createStaticComponent ( dependencies . exec , normalizedRingPathInHld ) ; const ingressVersionAndPath = getFullPathPrefix ( serviceConfig . pathPrefixMajorVersion || "" , serviceConfig . pathPrefix || "" , normalizedSvcName ) ; const middlewares = dependencies . createMiddlewareForRing ( normalizedRingPathInHld , normalizedSvcName , normalizedRingName , ingressVersionAndPath , isDefault ) ; dependencies . createIngressRouteForRing ( normalizedRingPathInHld , normalizedSvcName , serviceConfig , middlewares , normalizedRingName , ingressVersionAndPath , isDefault ) ; } } } ; export const execute = async ( repositoryName , hldPath , bedrockApplicationRepoPath , exitFn ) : Promise < void > => { try { validateInputs ( repositoryName , hldPath , bedrockApplicationRepoPath ) ; checkForFabrikate ( shelljs . which ) ; const absHldPath = testAndGetAbsPath ( shelljs . test , logger . info , hldPath , "HLD" ) ; const absBedrockPath = testAndGetAbsPath ( shelljs . test , logger . info , bedrockApplicationRepoPath , "Bedrock Application" ) ; const bedrockConfig = Bedrock ( absBedrockPath ) ; bedrock . validateRings ( bedrockConfig ) ; logger . info ( `Attempting to reconcile HLD with services tracked in bedrock.yaml` ) ; const reconcileDependencies = { addChartToRing , configureChartForRing , createAccessYaml , createIngressRouteForRing , createMiddlewareForRing , createRepositoryComponent , createRingComponent , createServiceComponent , createStaticComponent , exec : execAndLog , generateAccessYaml , getGitOrigin : tryGetGitOrigin , purgeRepositoryComponents , writeFile : fs . writeFileSync , } ; await reconcileHld ( reconcileDependencies , bedrockConfig , repositoryName , absHldPath , absBedrockPath ) ; await exitFn ( 0 ) ; } catch ( err ) { logError ( buildError ( errorStatusCode . CMD_EXE_ERR , "hld-reconcile-err-cmd-exe" , err ) ) ; await exitFn ( 1 ) ; } } ; export const commandDecorator = ( command ) : void => { buildCmd ( command , decorator ) . action ( async ( repositoryName , hldPath , bedrockApplicationRepoPath ) => { await execute ( repositoryName , hldPath , bedrockApplicationRepoPath , async ( status ) => { await exitCmd ( logger , process . exit , status ) ; } ) ; } ) ; } ;