diff --git a/examples/index.ts b/examples/index.ts index a0ca481..6117af6 100644 --- a/examples/index.ts +++ b/examples/index.ts @@ -1,6 +1,22 @@ import { modulate, config, lambda, api, topic, zone, Provider, Terraform } from '../src/index' +import { namespace } from '../src/config' import { dockerize } from '../src/modules/docker' import { ecr_repository } from '../src/modules/ecr' +import { setInUnsafe } from '@thi.ng/paths' + +const test = {} +const proof = [ + { + docker: { + registry_auth: { + a: 1, + b: 2, + }, + }, + }, +] + +setInUnsafe(test, ['provider'], proof) //? const tags = { Moms: 'Spaghetti' } const apex = 'chopshop-test.net' @@ -20,6 +36,7 @@ const zone_id = out_zone?.zone?.data?.route53_zone?.zone_id // const snsTopic = ({ name, tags }) => ({ sns: topic({ name, tags }), }) + const [Topic, out_topic] = modulate({ topic: snsTopic })({ name, tags }) const topic_arn = out_topic?.sns?.resource?.sns_topic?.arn // @@ -44,8 +61,8 @@ const [Docker, out_docker] = dockerMod({ repo: repo_name, }, }) +JSON.stringify(Docker, null, 4) //? -//JSON.stringify(Docker, null, 4) // const image_uri = out_docker?.registry_img?.resource?.docker_registry_image?.name // ======= LAMBDA ======= @@ -78,7 +95,9 @@ const [Lambda, out_lambda] = lambdaMod({ }) JSON.stringify(Lambda, null, 4) //? -const functionInvokeArn = out_lambda?.lambda?.resource?.lambda_function?.invoke_arn + +const functionInvokeArn = out_lambda?.lambda?.resource?.lambda_function?.invoke_arn //? + const functionName = out_lambda?.lambda?.resource?.lambda_function?.function_name // ======= API ======= @@ -100,36 +119,44 @@ const [Api, out_api] = modulate({ api })({ // ======= COMPILE ======= -const provider: Provider = { - aws: { - region: 'us-east-2', - profile: 'chopshop', - }, +// Type = Provider +const provider = { + provider: [ + { + aws: { + region: 'us-east-2', + profile: 'chopshop', + }, + }, + ], } -const terraform: Terraform = { - required_providers: { - aws: { - source: 'hashicorp/aws', - version: '>= 5.20', - }, - // for docker - docker: { - source: 'kreuzwerker/docker', - version: '>= 3.0', - }, - // for null resources - null: { - source: 'hashicorp/null', - version: '>= 2.0', +// Type = Terraform +const terraform = { + terraform: { + required_providers: { + aws: { + source: 'hashicorp/aws', + version: '>= 5.20', + }, + // for docker + docker: { + source: 'kreuzwerker/docker', + version: '>= 3.0', + }, + // for null resources + null: { + source: 'hashicorp/null', + version: '>= 2.0', + }, }, }, } -const compile = config(provider, terraform, 'main.tf.json') -const micro = { Zone, Topic, Repo, Docker, Lambda, Api } +const micro = { Zone, Topic, Repo, Docker, Lambda, Api, provider, terraform } + +const compiled = namespace({ micro }) -const compiled = compile({ micro }) console.log(JSON.stringify(compiled, null, 4)) //? // ~~~888~~~ ,88~-_ 888~-_ ,88~-_ diff --git a/src/config.ts b/src/config.ts index a92a668..bea535f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,66 +1,16 @@ -import { Provider, Terraform, NestedObject } from './types' -import { isPlainObject, isObject, isArray, isString } from '@thi.ng/checks' +import { Provider, Terraform, NestedObject, AWS } from './types' +import { isPlainObject, isArray, isString, isNumber } from '@thi.ng/checks' import { isEmpty } from './utils/index' import { writeFileSync } from 'fs' -import { getIn } from '@thi.ng/paths' -import { EquivMap } from '@thi.ng/associative' +import { getInUnsafe, setIn, setInUnsafe } from '@thi.ng/paths' -// regex that replaces a number surrounded by periods .0. with a number surrounded by brackets [0] +// replaces a number surrounded by periods .0. with a number surrounded by brackets [0] const bracketRegex = /\.\d+\./g // function that replaces any internal .0. with [0]. to allow for terraform interpolation const bracketifyTF = (str: string) => str.replace(bracketRegex, (match) => `[${match.slice(1, -1)}].`) const bracketify = (str: string) => str.replace(bracketRegex, (match) => `[${match.slice(1, -1)}]`) -/** - * produces terraform string templates for exported (--> prefixed) values - * recursively - */ -const exporter = ( - target: object, - scoped: string, - pivot: string, - type: string, - path: string[] | any = [] -): NestedObject | string => { - const basePath = `${pivot}.${type}.${scoped}` - const accessPath = path.length ? path.join('.') + '.' : '' - const stringTemplate = (v: string, path: any[] = []) => { - const key = path.slice(-1)[0] - const access = `\${${basePath}.${accessPath}${key}}` - const fixed = bracketifyTF(access) - const [head, tail] = bracketify(accessPath).split('[') - //const tolist = `\${tolist(${basePath}.${head})[${tail}.${k}}` - if (v.startsWith('-->*')) { - // [1] tolist alternative for set unpacking a single item - from apparentlymart - const one = `\${one(${basePath}.${head})${tail && tail.replace(/\d]/, '')}.${key}}` - return one - } else if (v.startsWith('-->')) { - return fixed - } else { - return v - } - } - if (isString(target)) return stringTemplate(target, path) - if (!isPlainObject(target)) return target as NestedObject - return Object.entries(target).reduce((a, c) => { - const [k, v] = c - if (isString(v)) { - return { ...a, [k]: stringTemplate(v, [...path, k]) } - } else if (isPlainObject(v)) { - return { ...a, [k]: exporter(v, scoped, pivot, type, [...path, k]) } - } else if (isArray(v)) { - return { - ...a, - [k]: v.map((x, i) => exporter(x, scoped, pivot, type, [...path, k, i])), - } - } else { - //console.log(`passthrough in exporter function...`) - //console.log({ k, v, type, pivot, scoped }) - return { ...a, [k]: v } - } - }, {}) -} /** * recursive function that takes a path of strings or numbers * and returns an object with nested objects and arrays @@ -80,142 +30,534 @@ const stub = (path: any[]) => { else return [...Array(head).fill('...'), '🔥'] } } -const stringTemplate = (v: string, scoped) => { - if (v.startsWith('-->')) { - const cleaned = v.replace(/-->\*?/, '') - if (cleaned === '') { - return null - } else { - return cleaned - } - } else if (v.includes('$SCOPE')) { - const replaced = v.replace('$SCOPE', scoped) - return replaced - } else { - return v - } -} + const warn = (path: string[]) => { const reminder = '🔥 Dependency missing. Could be a missing export (-->)' const trouble = '🔥 or a mispelled root key/id in a provisioning function.' const problems = [reminder, trouble] console.warn(`${problems.join('\n')}\nRequired by:${JSON.stringify(stub(path), null, 4)}`) } + +// 888-~88e e88~~8e Y88b e / +// 888 888 d888 88b Y88b d8b / +// 888 888 8888__888 Y888/Y88b/ +// 888 888 Y888 , Y8/ Y8/ +// 888 888 "88___/ Y Y + +type FnParams any> = T extends (...args: infer P) => any ? P : never +type FnReturn any> = T extends (...args: any[]) => infer R ? R : never + +const PIVOT_POINTS = ['resource', 'data'] +const ROOT_MEMBERS = ['provider', 'terraform'] + +const beforeAfter = (array, idx) => [array.slice(0, idx), array.slice(idx)] +//const access = `\${${basePath}.${accessPath}${key}}` +//const fixed = bracketifyTF(access) /** - * cleans out any export-specific values (--> prefixed) recursively and warns - * the user if they forgot to export a value using the --> prefix + * only works for number-indexed dependencies (arrays ≠ TF sets) + * currently, only 0-indexed dependencies are supported (see TODO below) */ -const exportFinalizer = (target: object, path, scoped): NestedObject | any => { - if (isString(target)) return stringTemplate(target, scoped) - if (!isPlainObject(target)) return target as NestedObject - return Object.entries(target).reduce((a, c) => { - const [k, v] = c - if (v === '-->') return a - if (v === undefined || v === null) return warn([...path, k]), a - if (isString(v)) { - if (v === 'undefined' || v === 'null') return warn([...path, k]), a - return { - ...a, - [k]: stringTemplate(v, scoped), - } - } else if (isPlainObject(v)) { - return { ...a, [k]: exportFinalizer(v, [...path, k], scoped) } - } else if (isArray(v)) { - //console.log(`array found for ${k}: ${JSON.stringify(v)}`) - return { - ...a, - [k]: [ - ...(a[k] || []), - ...v.map((x, i) => exportFinalizer(x, [...path, k], scoped)), - ], +const exportArrow = (target, path, provider) => { + const pivotIdx = path.findIndex((x) => PIVOT_POINTS.includes(x)) + const [before, after] = beforeAfter(path, pivotIdx) + const namespace = before.join('_') + const [pivot, type, ...decendants] = after + const basePath = `${pivot}.${provider && provider + '_'}${type}` + const scoped = `${namespace}.${decendants.join('.')}` + if (target.startsWith('-->*')) { + const [head, tail] = bracketify(scoped).split('[') + //console.log({ head, tail }) + // TODO if index !== 0, we should use list + //const tolist = `\${tolist(...)[${tail}}` + const one = `\${one(${basePath}.${head})${tail && tail.replace(/\d]/, '.')}}` + return one + } else if (target.startsWith('-->')) { + const access = `\${${basePath}.${scoped}}` + const fixed = bracketifyTF(access) + return fixed + } else { + return target + } +} + +// TEST 🤔 +const TEST_PATH_exportArrow = [ + 'api', + 'cert', + 'test1', + 'resource', + 'acm_certificate', + 'domain_validation_options', + 0, + 'resource_record_type', +] +const TEST_OUTPUT_exportArrow = + '${one(resource.aws_acm_certificate.api_cert_test1.domain_validation_options).resource_record_type}' +const TEST_exportArrow = + TEST_OUTPUT_exportArrow === exportArrow('-->*', TEST_PATH_exportArrow, 'aws') // + +const clean = (target) => { + if (!target) { + return null + } else if (isEmpty(target)) { + return null + } else if (isArray(target)) { + // if members of the array terminate in null/undefined values, return empy array + return target.reduce((a, c) => { + if (clean(c)) { + return [...a, clean(c)] + } else { + return a } - } else { - return { ...a, [k]: v } + }, []) + } else if (isPlainObject(target)) { + // if values of the object terminate in null/undefined values, return empty object + if (isEmpty(target)) { + return null } - }, {}) + return Object.entries(target).reduce((a, c) => { + const [k, v] = c + if (clean(v)) { + return { ...a, [k]: clean(v) } + } else { + return a + } + }, {}) + } else { + return target + } +} + +// TEST 🤔 +const TEST_TARGET_clean = { + api: { + cert: { + array: [ + { + resource_record_type: null, + another_thing: 'hello', + }, + null, + ], + something_else: '', + yet_another_thing: { + key: null, + }, + }, + finally: {}, + }, } -const pivotPoints = ['resource', 'data'] -const rootPoints = ['provider', 'terraform'] +const TEST_OUTPUT_clean = JSON.stringify({ api: { cert: { array: [{ another_thing: 'hello' }] } } }) +const TEST_INPUT_clean = clean(TEST_TARGET_clean) +const TEST_clean = TEST_OUTPUT_clean === JSON.stringify(TEST_INPUT_clean) // /** - * flattens modules into a single object, with unique keys created by - * joining nested key identifiers until the function reaches a pivot point - * (resource or data) and then prepending the module name to the key ("_"). + * recursively deep merge for any nested objects or arrays that share the same + * structure. Arrays are concatenated, objects are merged. + * + * FIXME ingnore parent paths to ROOT_MEMBERS (spread into root) */ -const flattenPreservingPaths = ( - obj: object, - provider = 'aws', // FIXME: adds this to everything, even things you may not want - path: any[] = [], - acc = {}, - refs = false -) => { - if (!isPlainObject(obj)) return obj - return Object.entries(obj).reduce((acc, cur) => { - const [key, val] = cur - if (pivotPoints.includes(key)) { - return Object.entries(val).reduce((a, c) => { - const [k, v] = c as [string, object] - const scoped = path.join('_') - const scope = path.slice(-1)[0] - const type = `${provider ? provider + '_' : ''}${k}` - const parent_path = path.slice(0, -1) - const parent_scope = parent_path.join('_') - return refs - ? { - ...a, - [scope]: { - ...a[scope], - [key]: { - ...(a[scope] && a[scope][key]), - [k]: exporter(v, scoped, key, type), - }, - }, - } - : { - ...a, - [key]: { - ...a[key], - [type]: { - ...(a[key] && a[key][type]), - [scoped]: exportFinalizer(v, [key, k], parent_scope), - }, - }, - } - }, acc) - } else if (isPlainObject(val)) { - return { - ...acc, - ...flattenPreservingPaths(val, provider, [...path, key], acc, refs), +const merge = (target, existing) => { + if (!existing || isEmpty(existing)) return target + if (isPlainObject(target) && isPlainObject(existing)) { + return Object.entries(existing).reduce((a, c) => { + const [k, v] = c + if (isPlainObject(v)) { + return { ...a, [k]: merge(a[k], v) } + } else if (isArray(v)) { + return { ...a, [k]: [...a[k], ...v] } + } else { + return { ...a, [k]: v } } - } else if (isArray(val)) { - // FIXME - /** - * "provider": [ - * { - * "address": "${data.aws_caller_identity.docker_auth.account_id}.dkr.ecr.${data.aws_region.docker_auth.name}.amazonaws.com", - * "username": "${data.aws_ecr_authorization_token.docker_auth.user_name}", - * "password": "${data.aws_ecr_authorization_token.docker_auth.password}" - * } - * ] - */ - return { - ...acc, - [key]: [ - ...(acc[key] || []), - ...val.map((x) => - flattenPreservingPaths(x, provider, [...path, key], acc, refs) - ), - ], + }, target) + } else if (isArray(target) && isArray(existing)) { + return [...target, ...existing] + } else { + return target + } +} + +// TEST 🤔 +const TEST_TARGET_merge = { + provider: [ + { + docker: { + registry: '', + }, + }, + ], +} +const TEST_SOURCE_merge = { + provider: [ + { + aws: { + something: '', + }, + }, + ], +} + +const TEST_merge = merge(TEST_TARGET_merge, TEST_SOURCE_merge) // + +const GLOBALS = ['null_resource', 'external'] +const STUBBIES = ['aws_region', 'aws_caller_identity', 'aws_ecr_authorization_token'] + +interface Fold { + target: any + path?: any + /** e.g., 'aws' */ + provider?: string + refs?: boolean + out?: NestedObject +} +const fold = ({ target, provider, path = [], refs = false, out = {} }: Fold) => { + if (!target || isNumber(target)) { + return target + } + if (refs) { + // return + if (isString(target)) { + if (target.startsWith('-->')) { + return exportArrow(target, path, provider) + } else { + return target } + } else if (isArray(target)) { + return target.map((x, i) => fold({ target: x, provider, path: [...path, i], refs })) + } else if (isPlainObject(target)) { + return Object.entries(target).reduce((a, c) => { + const [k, v] = c + return { ...a, [k]: fold({ target: v, provider, path: [...path, k], refs }) } + }, {}) } else { - return { ...acc, [key]: val } + //console.log(`REF passed through: ${target}`) + return target } - }, acc) + } else { + const [x, y, resource, type, ...decendants] = path + if (!type) { + // mutate + if (ROOT_MEMBERS.includes(y)) { + out = setInUnsafe(out, [y], target) // + } else { + // defer setting into root until the paths are resolved + Object.entries(target).forEach((c) => { + const [k, v] = c + const lens = [...path, k] + const result = fold({ target: v, provider, path: lens, out }) + out = setInUnsafe(out, [], result) + }) + } + } else { + if (isString(target)) { + // return + if (target.startsWith('-->')) { + const cleaned = target.replace(/-->\*?/, '') + if (cleaned === '') { + return // SKIP IT + } else { + return cleaned + } + } else if (target.includes('$SCOPE')) { + // TODO: handle `depends_on` meta argument (no template strings) + const replaced = target.replace('$SCOPE', path.join('_')) + return replaced + } else if (target === 'undefined' || target === 'null') { + // TODO: warn on missing dependencies + return warn(path), target + } else { + return target + } + } else if (isPlainObject(target)) { + const pv = provider + const STUBBY_STUBS = STUBBIES.map((x) => x.replace(`${pv}_`, '')) + const scoped = `${!GLOBALS.includes(type) && pv ? pv + '_' : ''}${type}` + if (decendants.length) { + // return + // once decendants are present, we can return the object + const result = Object.entries(target).reduce((a, c) => { + const [k, v] = c + // at this point, the path has fulfilled it's purpose + const result = fold({ target: v, provider, path }) + return setInUnsafe(a, [k], result) + }, {}) + if (isEmpty(result)) { + console.log(`empty object found at path: ${path}`) + return // SKIP IT + } else { + return result + } + } else if (STUBBY_STUBS.includes(type)) { + // mutate + const injection = [resource, scoped] + out = setInUnsafe(out, injection, {}) // + } else { + // mutate + const ns = `${x}_${y}` + const injection = [resource, scoped, ns] + Object.entries(target).forEach((c) => { + const [k, v] = c + const lens = [...path, k] + const result = fold({ target: v, provider, path: lens }) + out = setInUnsafe(out, [...injection, k], result) + }) + } + } else if (isArray(target)) { + // return + return target.map((x) => fold({ target: x, provider, path, out })) + } else { + // return + //console.log(`passthrough in fold function at path: ${path}: ${target}`) + return target + } + } + // final return + return out + } } -type FnParams any> = T extends (...args: infer P) => any ? P : never -type FnReturn any> = T extends (...args: any[]) => infer R ? R : never +// regular expression that matches 'resource'|'data' followed by .*.* +const resourceRegex = /(resource|data)\.(\w*).(\w*)/ +const TEST_STR_resourceRegex = '${resource.aws_sns_topic.topic_sns.arn}' +const TEST_resourceRegex = TEST_STR_resourceRegex.match(resourceRegex) //? + +export const namespace = (target, path: any[] = [], out = {}) => { + if (!target || isNumber(target)) return target + const [x, y, resource, type, name] = path + if (!type) { + if (ROOT_MEMBERS.includes(resource)) { + const existing = getInUnsafe(out, [resource]) + const merged = (existing && merge(target, existing)) || target + console.log({ [resource]: merged }) + out = setInUnsafe(out, [resource], merged) // + } else { + //console.log(`!type and NOT ROOT_MEMBER: ${JSON.stringify(path)}`) + Object.entries(target).forEach((c) => { + const [k, v] = c + const lens = [...path, k] + const result = namespace(v, lens, out) + out = setInUnsafe(out, [], result) + }) + } + } else { + const ns = `${x}_${y}` + if (isString(target)) { + // return + if (PIVOT_POINTS.some((x) => target.includes(x))) { + const [_, _pivot, _type, _name] = target.match(resourceRegex) || [] + if (!_name) return target + return target.replace(_name, `${ns}_${_name}`) + } else { + return target + } + } else if (isPlainObject(target)) { + if (STUBBIES.includes(type)) { + // mutate + const injection = [resource, type] + out = setInUnsafe(out, injection, target) // + } else if (name) { + // return + return Object.entries(target).reduce((a, c) => { + const [k, v] = c + const lens = [...path, k] + const result = namespace(v, lens, out) + return setInUnsafe(a, [k], result) + }, {}) + } else { + const injection = [resource, type] + // mutate + Object.entries(target).forEach((c) => { + const [k, v] = c + const lens = [...path, k] + const result = namespace(v, lens, out) + out = setInUnsafe(out, [...injection, `${ns}_${k}`], result) + }) + } + } else if (isArray(target)) { + // return + return target.map((x) => namespace(x, path, out)) + } else { + // return + //console.log(`passthrough in namespace function at path: ${path}: ${target}`) + return target + } + } + return out +} + +// TEST 🤔 +const TEST_TARGET_modulator = { + ms1: { + lambda_creds: { + data: { + iam_policy_document: { + statement: { + effect: 'Allow', + actions: ['sts:AssumeRole'], + principals: { + identifiers: ['lambda.amazonaws.com', 'apigateway.amazonaws.com'], + type: 'Service', + }, + }, + json: '${data.aws_iam_policy_document.ms1_lambda_creds.json}', + }, + }, + }, + lambda_role: { + resource: { + iam_role: { + name: '${resource.aws_iam_role.ms1_lambda_role.name}', + tags: { + BroughtToYouBy: '@-0/micro', + Moms: 'Spaghetti', + }, + arn: '${resource.aws_iam_role.ms1_lambda_role.arn}', + }, + }, + }, + bucket: { + resource: { + s3_bucket: { + bucket: '${resource.aws_s3_bucket.ms1_bucket.bucket}', + tags: { + BroughtToYouBy: '@-0/micro', + Moms: 'Spaghetti', + }, + }, + }, + }, + bucket_access_creds: { + data: { + iam_policy_document: { + statement: [], + json: '${data.aws_iam_policy_document.ms1_bucket_access_creds.json}', + }, + }, + }, + bucket_cors: { + resource: { + s3_bucket_cors_configuration: { + cors_rule: { + allowed_methods: ['POST', 'GET', 'HEAD', 'DELETE', 'PUT'], + allowed_origins: ['*'], + allowed_headers: ['*'], + expose_headers: ['ETag'], + max_age_seconds: 3000, + }, + }, + }, + }, + bucket_policy: { + resource: { + s3_bucket_policy: {}, + }, + }, + cloudwatch: { + resource: { + cloudwatch_log_group: { + name: '/aws/lambda/throwaway-test-123-log-group', + retention_in_days: 7, + tags: { + BroughtToYouBy: '@-0/micro', + Moms: 'Spaghetti', + }, + arn: '${resource.aws_cloudwatch_log_group.ms1_cloudwatch.arn}', + }, + }, + }, + lambda_access_creds: { + data: { + iam_policy_document: { + statement: [], + json: '${data.aws_iam_policy_document.ms1_lambda_access_creds.json}', + }, + }, + }, + lambda_policy: { + resource: { + iam_policy: { + name: '${resource.aws_iam_policy.ms1_lambda_policy.name}', + tags: { + BroughtToYouBy: '@-0/micro', + Moms: 'Spaghetti', + }, + arn: '${resource.aws_iam_policy.ms1_lambda_policy.arn}', + }, + }, + }, + lambda_policy_attachment: { + resource: { + iam_role_policy_attachment: {}, + }, + }, + lambda: { + resource: { + lambda_function: { + runtime: 'python3.8', + handler: 'handler.handler', + package_type: 'Zip', + function_name: '${resource.aws_lambda_function.ms1_lambda.function_name}', + environment: { + variables: { + SNS_MESSAGE_ATTRS: + '{"type":{"DataType":"String","StringValue":"audio"}}', + }, + }, + filename: '${path.root}/lambdas/template/zipped/handler.py.zip', + tags: { + BroughtToYouBy: '@-0/micro', + Moms: 'Spaghetti', + }, + arn: '${resource.aws_lambda_function.ms1_lambda.arn}', + invoke_arn: '${resource.aws_lambda_function.ms1_lambda.invoke_arn}', + }, + }, + }, + sns_invoke_cred: { + resource: { + lambda_permission: { + statement_id: 'AllowExecutionFromSNS', + action: 'lambda:InvokeFunction', + principal: 'sns.amazonaws.com', + }, + }, + }, + subscription: { + resource: { + sns_topic_subscription: { + protocol: 'lambda', + filter_policy: '{"type":["video"]}', + filter_policy_scope: 'MessageAttributes', + arn: '${resource.aws_sns_topic_subscription.ms1_subscription.arn}', + }, + }, + }, + ...TEST_SOURCE_merge, + }, +} +const TEST_OUTPUT_modulator = fold({ + target: TEST_TARGET_modulator, + provider: 'aws', + refs: false, +}) +const TEST_JSON_modulator = JSON.stringify(TEST_OUTPUT_modulator, null, 4) +const TEST_modulator = + TEST_JSON_modulator === + JSON.stringify(setIn(TEST_TARGET_modulator, TEST_PATH_exportArrow, TEST_OUTPUT_exportArrow)) //? + +const TEST_DATA_namespace = { + bleep: { + bloop: TEST_OUTPUT_modulator, + beep: TEST_OUTPUT_modulator, + }, + ferp: { + bloop: TEST_OUTPUT_modulator, + beep: TEST_OUTPUT_modulator, + }, +} +const TEST_OUTPUT_namespace = namespace(TEST_DATA_namespace) +const TEST_JSON_namespace = JSON.stringify(TEST_OUTPUT_namespace, null, 4) //? /** * @@ -234,158 +576,46 @@ export const modulate = any }>( const [key, fn] = Object.entries(obj)[0] return (...args: [FnParams[0], ...Partial>[]]) => { - const ref = { [key]: fn(...args) } - const refs = flattenPreservingPaths(ref, provider, [], {}, true) + const ref = fn(...args) + //console.log({ ref }) + const refs = fold({ target: ref, provider, path: [key], refs: true }) const obj = { [key]: fn(...args, refs) } - const out = flattenPreservingPaths(obj, provider, [], {}, false) + //console.log({ obj }) + const out = fold({ target: obj, provider, refs: false }) return [out, refs] as [FnReturn, FnReturn] } } -const isRef = (str) => pivotPoints.map((x) => `${x}.`).some((x) => str.includes(x)) - -const test = - '${one(resource.aws_acm_certificate.api_cert_test1.domain_validation_options).resource_record_name}' -const test2 = '${data.aws_iam_policy_document.ms1_lambda_access_creds.json}' - -const test3 = - '{"docker_pip_cache":null,"docker_build_root":"${path.root}/src/docker","docker_file":"Dockerfile","docker_image":"${data.aws_caller_identity.docker_auth.account_id}.dkr.ecr.${data.aws_region.docker_auth.name}.amazonaws.com/throwaway-test-123/test1:throwaway-test-123","with_ssh_agent":false,"docker_additional_options":[],"docker_entrypoint":null}' /** - * function that looks for matches to either the funcRegex or templateRx and - * returns them as an array - * - * uses regular expression that matches everything between ${ and } or ( and ) - * and filters out any matches that contain the other - * - * e.g., "${data.aws_iam_policy_document.ms1_lambda_access_creds.json}" => - * "data.aws_iam_policy_document.ms1_lambda_access_creds.json" - * "${one(resource.aws_acm_certificate.api_cert_test1.domain_validation_options).resource_record_name}" - * => "resource.aws_acm_certificate.api_cert_test1.domain_validation_options" + * deep merges arbitrary number of objects into one */ -const grabRefs = (str: string) => { - const funcRegex = /(?<=\().*?(?=\))/g - const templateRx = /(?<=\${).*?(?=\})/g - const funcs = str.match(funcRegex) || [] - const templates = str.match(templateRx) || [] - const filtered = templates.filter((x) => !funcs.some((y) => x.includes(y))) - const results = [...funcs, ...filtered] - const paths = results.map((x) => x.split('.').slice(0, 3)).filter((x) => x.length === 3) - //console.log({ paths }) - return paths -} - -grabRefs(test3) //? - -/* -FIXME -{"docker_pip_cache":null,"docker_build_root":"${path.root}/src/docker","docker_file":"Dockerfile","docker_image":"${data.aws_caller_identity.micro_Docker_micro_Docker_docker_auth.account_id}.dkr.ecr.${data.aws_region.docker_auth.name}.amazonaws.com/throwaway-test-123/test1:throwaway-test-123","with_ssh_agent":false,"docker_additional_options":[],"docker_entrypoint":null}  -*/ - -const replaceTemplated = (val, path: any[] = [], ready = false) => { - if (isString(val)) { - if (isRef(val)) { - const refs = grabRefs(val) - refs.forEach((coll) => { - const idx = path.findIndex((x) => pivotPoints.includes(x)) - const trimmed = path.slice(0, idx) - const ns = trimmed.join('_') - const asis = coll.pop() - const namespaced = ns + '_' + asis - val = val.replace(asis, namespaced) - }) - return val - } else { - return val - } - } else if (isPlainObject(val)) { - return Object.entries(val).reduce((acc, cur) => { - const [type, cand] = cur - return { ...acc, [type]: replaceTemplated(cand, [...path, type], ready) } - }, {}) - } else if (isArray(val)) { - return val.map((x) => replaceTemplated(x, path, ready)) - } else { - //console.log(`passthrough in replaceTemplated function: ${val}`) - return val - } -} - -/** - * two-pass namespacer: - * 1. get all the existing namespaced keys and store them in a dictionary with - * the original key as the value and the path [] to and including namespaced - * key as the value - * - renames the actual key in place - * - returns two objects: the updated object and the dictionary - * 2. use the dictionary to update the template strings in the object - * - pass in the dictionary and the object - * - for each template string that contains a ref: - * - - */ -/* - -*/ -// return { -// ...a, -// [type]: { -// ...a[type], -// [scoped]: { -// ...(a[type] && a[type][scoped]), -// ...replaceTemplated(payload, [...path, type], acc, true), -// }, -// }, -// } -const rescope = (obj: object, path: any[] = [], ready = false) => { - if (!isPlainObject(obj)) return obj - return Object.entries(obj).reduce((acc, cur) => { - const [type, cand] = cur - if (pivotPoints.includes(type)) { - return Object.entries(cand).reduce((a, c) => { - const [k, v] = c as [string, object] - //console.log({ k, v }) - return { - ...a, - [type]: { - ...a[type], - [k]: { - ...(a[type] && a[type][k]), - ...rescope(v, path, true), - }, - }, - } - }, acc) - } else { - if (ready) { - return { - ...acc, - [[...path, type].join('_')]: rescope(cand, [], ready), +const deepMerge = (...objs) => { + const result = {} + for (const obj of objs) { + for (const key in obj) { + const val = obj[key] + if (key === 'provider' && result[key] && 'alias' in val) { + // don't duplicate providers + continue + } + if (Array.isArray(val)) { + // clean out arrays with empty contents + const filtered = val.filter((x) => !isEmpty(x)) + if (!filtered.length) continue + else { + result[key] = result[key] || [] + result[key].push(...filtered) } + } else if (typeof val === 'object') { + result[key] = deepMerge(result[key] || {}, val) } else { - return { - ...acc, - ...rescope(cand, [...path, type], ready), - } + result[key] = val } } - }, {}) -} - -const folder = (obj) => { - const templatesCorrected = replaceTemplated(obj) - const result = rescope(templatesCorrected) + } return result } -/** - * Fix the deepMerge function so that it that takes an arbitrary number of - * objects and deeply merges them into a single object. If only a single object - * is provided, it is returned as-is. If an object's value is an array, the - * array is concatenated with the array of the same key in the result object. If - * an object's value is an object, the object is deeply merged with the object - * of the same key in the result object. If an object's value is a primitive, it - * is assigned to the result object. - */ - /** * Takes a provider and a terraform configuration and returns a compiler function */ @@ -401,13 +631,11 @@ export const config = ( terraform, provider, } - return (obj) => { - //const merged = flatten({ ...obj, ...providerWrapped }) - const merged = { ...obj, ...providerWrapped } - const folded = folder(merged) - const out = JSON.stringify(folded, null, 2) + return (...objs) => { + const merged = deepMerge(...objs, providerWrapped) + const out = JSON.stringify(merged, null, 2) writeFileSync(outputFile, out) - return folded + return merged } } diff --git a/src/modules/docker.ts b/src/modules/docker.ts index 33fb674..c58fea6 100644 --- a/src/modules/docker.ts +++ b/src/modules/docker.ts @@ -272,7 +272,8 @@ const docker_img = ({ * ``` * * @param { string } name - name of the image - * @param { boolean } keep_remotely - if Docker image is kept/deleted (true/false) on `terraform destroy` + * @param { boolean } keep_remotely - if Docker image is kept/deleted (true/false) + * on `terraform destroy` */ const registry_img = ({ img_name, keep_remotely = false }) => ({ resource: { @@ -286,6 +287,7 @@ const registry_img = ({ img_name, keep_remotely = false }) => ({ interface DockerOptOmissions { root: string } + type Omit = Pick> interface DockerOptions extends Omit {