From f202397c8a9b0345abae46bf19d48adecd14f712 Mon Sep 17 00:00:00 2001 From: Aaron Dernley Date: Thu, 22 Jan 2026 20:42:59 +0000 Subject: [PATCH] fix(clover): cloudformation only asset overhaul --- bin/clover/src/pipelines/aws/funcs.ts | 19 ++ .../aws/funcs/attribute/awsCfOnlyAttr.ts | 12 + .../attribute/awsCloudControlCfAssetAttr.ts | 10 - .../aws/funcs/code-gen/awsCfOnlyCodeGen.ts | 32 +++ .../AWS::Route53::RecordSet/actions/create.ts | 46 ++-- .../actions/refresh.ts | 38 +-- .../management/discover.ts | 17 +- .../management/import.ts | 20 +- .../aws/funcs/qualifications/awsCfOnlyLint.ts | 119 +++++++++ bin/clover/src/pipelines/aws/overrides.ts | 8 +- .../aws/pipeline-steps/pruneCfAssets.ts | 240 +++++++++++++++--- 11 files changed, 463 insertions(+), 98 deletions(-) create mode 100644 bin/clover/src/pipelines/aws/funcs/attribute/awsCfOnlyAttr.ts delete mode 100644 bin/clover/src/pipelines/aws/funcs/attribute/awsCloudControlCfAssetAttr.ts create mode 100644 bin/clover/src/pipelines/aws/funcs/code-gen/awsCfOnlyCodeGen.ts create mode 100644 bin/clover/src/pipelines/aws/funcs/qualifications/awsCfOnlyLint.ts diff --git a/bin/clover/src/pipelines/aws/funcs.ts b/bin/clover/src/pipelines/aws/funcs.ts index 1be98c5101..8aa71df5fb 100644 --- a/bin/clover/src/pipelines/aws/funcs.ts +++ b/bin/clover/src/pipelines/aws/funcs.ts @@ -66,6 +66,25 @@ export const CODE_GENERATION_FUNC_SPECS = { }, } as const satisfies Record; +// CF-only specific funcs - NOT included in provider-wide specs +// These are only added to CF-only assets in pruneCfAssets.ts +export const CF_ONLY_FUNC_SPECS = { + awsCfOnlyCodeGen: { + id: "f66e1afc8ff0f43f3b8a15c87f0a916243904de894e33322beb7f84dc792305a", + backendKind: "jsAttribute", + responseType: "codeGeneration", + displayName: "Code Gen for CloudFormation-only assets", + path: "./src/pipelines/aws/funcs/code-gen/awsCfOnlyCodeGen.ts", + }, + awsCfOnlyLint: { + id: "dba872348ce39bfa8c15002037263b2528d81833c6c1f24fef5c9bc4115145b4", + backendKind: "jsAttribute", + responseType: "qualification", + displayName: "CloudFormation Lint Validation", + path: "./src/pipelines/aws/funcs/qualifications/awsCfOnlyLint.ts", + }, +} as const satisfies Record; + export const MANAGEMENT_FUNCS = { // Management "Discover on AWS": { diff --git a/bin/clover/src/pipelines/aws/funcs/attribute/awsCfOnlyAttr.ts b/bin/clover/src/pipelines/aws/funcs/attribute/awsCfOnlyAttr.ts new file mode 100644 index 0000000000..bb9df5a452 --- /dev/null +++ b/bin/clover/src/pipelines/aws/funcs/attribute/awsCfOnlyAttr.ts @@ -0,0 +1,12 @@ +async function main({ + properties, + extra +}: Input): Promise { + if (!properties) { + return null; + } + return JSON.stringify({ + Type: extra?.AwsResourceType, + Properties: properties + }, null, 2); +} diff --git a/bin/clover/src/pipelines/aws/funcs/attribute/awsCloudControlCfAssetAttr.ts b/bin/clover/src/pipelines/aws/funcs/attribute/awsCloudControlCfAssetAttr.ts deleted file mode 100644 index 123ef93b66..0000000000 --- a/bin/clover/src/pipelines/aws/funcs/attribute/awsCloudControlCfAssetAttr.ts +++ /dev/null @@ -1,10 +0,0 @@ -async function main(input: Input): Promise { - const properties = _.cloneDeep(input.cfnProperties); - delete properties["extra"]; - const result = { - "LogicalResourceName": input.cfnLogicalResourceName, - "Type": input.cfnType, - "Properties": properties, - }; - return result; -} diff --git a/bin/clover/src/pipelines/aws/funcs/code-gen/awsCfOnlyCodeGen.ts b/bin/clover/src/pipelines/aws/funcs/code-gen/awsCfOnlyCodeGen.ts new file mode 100644 index 0000000000..94474a5b92 --- /dev/null +++ b/bin/clover/src/pipelines/aws/funcs/code-gen/awsCfOnlyCodeGen.ts @@ -0,0 +1,32 @@ +async function main(component: Input): Promise { + const resourceBody = component.domain?.CloudFormationResourceBody; + + if (!resourceBody) { + return { + format: "json", + code: JSON.stringify( + { + AWSTemplateFormatVersion: "2010-09-09", + Resources: {}, + }, + null, + 2, + ), + }; + } + + // CloudFormationResourceBody is already a JSON string from the attribute function + const resource = JSON.parse(resourceBody); + + const cloudFormationTemplate = { + AWSTemplateFormatVersion: "2010-09-09", + Resources: { + cfnResource: resource, + }, + }; + + return { + format: "json", + code: JSON.stringify(cloudFormationTemplate, null, 2), + }; +} diff --git a/bin/clover/src/pipelines/aws/funcs/overrides/AWS::Route53::RecordSet/actions/create.ts b/bin/clover/src/pipelines/aws/funcs/overrides/AWS::Route53::RecordSet/actions/create.ts index 161854729a..3e69d165ea 100644 --- a/bin/clover/src/pipelines/aws/funcs/overrides/AWS::Route53::RecordSet/actions/create.ts +++ b/bin/clover/src/pipelines/aws/funcs/overrides/AWS::Route53::RecordSet/actions/create.ts @@ -7,10 +7,11 @@ async function main(component: Input): Promise { }; } - const domain = component.properties?.domain; - const hostedZoneId = domain?.HostedZoneId; - const name = domain?.Name; - const type = domain?.Type; + // Changed: access via domain.properties + const properties = component.properties?.domain?.properties; + const hostedZoneId = properties?.HostedZoneId; + const name = properties?.Name; + const type = properties?.Type; if (!hostedZoneId) { return { @@ -40,24 +41,26 @@ async function main(component: Input): Promise { ResourceRecordSet: { Name: name, Type: type, - TTL: domain?.TTL ? parseInt(domain.TTL) : undefined, - ResourceRecords: domain?.ResourceRecords?.map((record: string) => ({ - Value: record, - })), - AliasTarget: domain?.AliasTarget + TTL: properties?.TTL ? parseInt(properties.TTL) : undefined, + ResourceRecords: properties?.ResourceRecords?.map( + (record: string) => ({ + Value: record, + }), + ), + AliasTarget: properties?.AliasTarget ? { - DNSName: domain.AliasTarget.DNSName, - EvaluateTargetHealth: domain.AliasTarget.EvaluateTargetHealth, - HostedZoneId: domain.AliasTarget.HostedZoneId, - } + DNSName: properties.AliasTarget.DNSName, + EvaluateTargetHealth: properties.AliasTarget.EvaluateTargetHealth, + HostedZoneId: properties.AliasTarget.HostedZoneId, + } : undefined, - SetIdentifier: domain?.SetIdentifier, - Weight: domain?.Weight, - Region: domain?.Region, - GeoLocation: domain?.GeoLocation, - Failover: domain?.Failover, - MultiValueAnswer: domain?.MultiValueAnswer, - HealthCheckId: domain?.HealthCheckId, + SetIdentifier: properties?.SetIdentifier, + Weight: properties?.Weight, + Region: properties?.Region, + GeoLocation: properties?.GeoLocation, + Failover: properties?.Failover, + MultiValueAnswer: properties?.MultiValueAnswer, + HealthCheckId: properties?.HealthCheckId, }, }, ], @@ -76,8 +79,7 @@ async function main(component: Input): Promise { console.error(child.stderr); return { status: "error", - message: - `Unable to create; AWS CLI exited with non zero code: ${child.exitCode}`, + message: `Unable to create; AWS CLI exited with non zero code: ${child.exitCode}`, }; } diff --git a/bin/clover/src/pipelines/aws/funcs/overrides/AWS::Route53::RecordSet/actions/refresh.ts b/bin/clover/src/pipelines/aws/funcs/overrides/AWS::Route53::RecordSet/actions/refresh.ts index 849a4683a9..e274d94431 100644 --- a/bin/clover/src/pipelines/aws/funcs/overrides/AWS::Route53::RecordSet/actions/refresh.ts +++ b/bin/clover/src/pipelines/aws/funcs/overrides/AWS::Route53::RecordSet/actions/refresh.ts @@ -1,8 +1,9 @@ async function main(component: Input): Promise { - const domain = component.properties?.domain; - const hostedZoneId = domain?.HostedZoneId; - const name = domain?.Name; - const type = domain?.Type; + // Changed: access via domain.properties + const properties = component.properties?.domain?.properties; + const hostedZoneId = properties?.HostedZoneId; + const name = properties?.Name; + const type = properties?.Type; if (!hostedZoneId) { return { @@ -54,20 +55,23 @@ async function main(component: Input): Promise { const recordSet = recordSets[0]; + // Changed: nest payload under properties const payload = { - Name: recordSet.Name, - Type: recordSet.Type, - TTL: recordSet.TTL, - ResourceRecords: recordSet.ResourceRecords?.map((rr: any) => rr.Value), - AliasTarget: recordSet.AliasTarget, - SetIdentifier: recordSet.SetIdentifier, - Weight: recordSet.Weight, - Region: recordSet.Region, - GeoLocation: recordSet.GeoLocation, - Failover: recordSet.Failover, - MultiValueAnswer: recordSet.MultiValueAnswer, - HealthCheckId: recordSet.HealthCheckId, - HostedZoneId: hostedZoneId, + properties: { + Name: recordSet.Name, + Type: recordSet.Type, + TTL: recordSet.TTL, + ResourceRecords: recordSet.ResourceRecords?.map((rr: any) => rr.Value), + AliasTarget: recordSet.AliasTarget, + SetIdentifier: recordSet.SetIdentifier, + Weight: recordSet.Weight, + Region: recordSet.Region, + GeoLocation: recordSet.GeoLocation, + Failover: recordSet.Failover, + MultiValueAnswer: recordSet.MultiValueAnswer, + HealthCheckId: recordSet.HealthCheckId, + HostedZoneId: hostedZoneId, + }, }; return { diff --git a/bin/clover/src/pipelines/aws/funcs/overrides/AWS::Route53::RecordSet/management/discover.ts b/bin/clover/src/pipelines/aws/funcs/overrides/AWS::Route53::RecordSet/management/discover.ts index 01b8a90a2f..116c72501a 100644 --- a/bin/clover/src/pipelines/aws/funcs/overrides/AWS::Route53::RecordSet/management/discover.ts +++ b/bin/clover/src/pipelines/aws/funcs/overrides/AWS::Route53::RecordSet/management/discover.ts @@ -1,7 +1,8 @@ async function main({ thisComponent, }: Input): Promise { - const domain = thisComponent.properties.domain; + // Changed: access via domain.properties + const properties = thisComponent.properties.domain.properties; const region = _.get(thisComponent, [ "properties", "domain", @@ -11,7 +12,7 @@ async function main({ console.log("=== ROUTE53 DISCOVER RECORD SETS ==="); console.log("Starting Route53 record discovery"); - console.log("Domain attributes:", JSON.stringify(domain, null, 2)); + console.log("Domain properties:", JSON.stringify(properties, null, 2)); console.log("Region:", region); if (!region) { @@ -19,8 +20,8 @@ async function main({ } // Get hosted zone ID - either directly or by looking up the name - let hostedZoneId = domain.HostedZoneId; - const hostedZoneName = domain.HostedZoneName; + let hostedZoneId = properties?.HostedZoneId; + const hostedZoneName = properties?.HostedZoneName; // If we don't have a hosted zone ID but have a name, look it up if (!hostedZoneId && hostedZoneName) { @@ -94,7 +95,7 @@ async function main({ const baseName = record.Name.replace(/\.$/, "").replace(/\./g, "-"); const componentName = `${baseName}-${record.Type}`; - // Build the domain properties + // Changed: nest domainProps under properties const domainProps: any = { Name: record.Name, Type: record.Type, @@ -156,14 +157,16 @@ async function main({ domainProps.GeoProximityLocation = record.GeoProximityLocation; } - // Build the component spec + // Build the component spec with nested properties specs[componentName] = { kind: "AWS::Route53::RecordSet", properties: { si: { name: componentName, }, - domain: domainProps, + domain: { + properties: domainProps, + }, resource: record, }, attributes: { diff --git a/bin/clover/src/pipelines/aws/funcs/overrides/AWS::Route53::RecordSet/management/import.ts b/bin/clover/src/pipelines/aws/funcs/overrides/AWS::Route53::RecordSet/management/import.ts index f341e80734..c500ca7246 100644 --- a/bin/clover/src/pipelines/aws/funcs/overrides/AWS::Route53::RecordSet/management/import.ts +++ b/bin/clover/src/pipelines/aws/funcs/overrides/AWS::Route53::RecordSet/management/import.ts @@ -1,7 +1,8 @@ async function main({ thisComponent, }: Input): Promise { - const domain = thisComponent.properties.domain; + // Changed: access via domain.properties + const properties = thisComponent.properties.domain.properties; const region = _.get(thisComponent, [ "properties", "domain", @@ -13,7 +14,7 @@ async function main({ console.log("=== ROUTE53 IMPORT WITH 'ops' KEY ==="); console.log( "Starting Route53 record import for:", - JSON.stringify(domain, null, 2), + JSON.stringify(properties, null, 2), ); console.log("Resource ID:", resourceId); console.log("Region:", region); @@ -113,13 +114,16 @@ async function main({ console.log("Found target record:", JSON.stringify(targetRecord, null, 2)); - // Build the domain properties object + // Changed: nest domainProps under properties const domainProps = { - Name: targetRecord.Name, - Type: targetRecord.Type, - HostedZoneId: hostedZoneId, - TTL: targetRecord.TTL?.toString(), - ResourceRecords: targetRecord.ResourceRecords?.map((rr) => rr.Value) || [], + properties: { + Name: targetRecord.Name, + Type: targetRecord.Type, + HostedZoneId: hostedZoneId, + TTL: targetRecord.TTL?.toString(), + ResourceRecords: + targetRecord.ResourceRecords?.map((rr) => rr.Value) || [], + }, }; console.log("Built domain properties:", JSON.stringify(domainProps, null, 2)); diff --git a/bin/clover/src/pipelines/aws/funcs/qualifications/awsCfOnlyLint.ts b/bin/clover/src/pipelines/aws/funcs/qualifications/awsCfOnlyLint.ts new file mode 100644 index 0000000000..3a1a0612ae --- /dev/null +++ b/bin/clover/src/pipelines/aws/funcs/qualifications/awsCfOnlyLint.ts @@ -0,0 +1,119 @@ +async function main(component: Input): Promise { + const resourceBody = component.domain?.CloudFormationResourceBody; + + if (!resourceBody) { + return { + result: "warning", + message: "No CloudFormationResourceBody found - nothing to validate", + }; + } + + // Parse the resource body + let resource; + try { + resource = JSON.parse(resourceBody); + } catch (e) { + return { + result: "failure", + message: `Invalid JSON in CloudFormationResourceBody: ${e.message}`, + }; + } + + // Validate basic structure + if (!resource.Type) { + return { + result: "failure", + message: "CloudFormation resource is missing 'Type' field", + }; + } + + if (!resource.Properties) { + return { + result: "warning", + message: "CloudFormation resource has no 'Properties' - this may be intentional", + }; + } + + // Build a full CloudFormation template for validation + const cloudFormationTemplate = { + AWSTemplateFormatVersion: "2010-09-09", + Resources: { + CfnResource: resource, + }, + }; + + const templateJson = JSON.stringify(cloudFormationTemplate, null, 2); + + // Try to run cfn-lint if available + const lintResult = await siExec.waitUntilEnd("cfn-lint", [ + "--template", + "/dev/stdin", + "--format", + "json", + ], { + input: templateJson, + }); + + if (lintResult.exitCode === 127) { + // cfn-lint not installed - do basic validation only + console.log("cfn-lint not available, performing basic validation only"); + return { + result: "success", + message: `CloudFormation resource structure is valid (Type: ${resource.Type}). Install cfn-lint for deeper validation.`, + }; + } + + if (lintResult.exitCode === 0 && (!lintResult.stdout || lintResult.stdout.trim() === "[]")) { + return { + result: "success", + message: `CloudFormation template passed cfn-lint validation (Type: ${resource.Type})`, + }; + } + + // Parse lint results + try { + const lintErrors = JSON.parse(lintResult.stdout || "[]"); + + if (lintErrors.length === 0) { + return { + result: "success", + message: `CloudFormation template passed cfn-lint validation (Type: ${resource.Type})`, + }; + } + + // Categorize errors vs warnings + const errors = lintErrors.filter((e: any) => e.Level === "Error"); + const warnings = lintErrors.filter((e: any) => e.Level === "Warning"); + + const messages = lintErrors.map((e: any) => + `[${e.Level}] ${e.Rule.Id}: ${e.Message}` + ); + + console.log("cfn-lint results:"); + messages.forEach((m: string) => console.log(m)); + + if (errors.length > 0) { + return { + result: "failure", + message: `CloudFormation validation failed: ${errors.length} error(s), ${warnings.length} warning(s). First error: ${errors[0].Message}`, + }; + } + + return { + result: "warning", + message: `CloudFormation validation passed with ${warnings.length} warning(s): ${warnings[0]?.Message || "See logs for details"}`, + }; + } catch (e) { + // If we can't parse the output, check if there was stderr + if (lintResult.stderr) { + return { + result: "failure", + message: `cfn-lint error: ${lintResult.stderr}`, + }; + } + return { + result: "warning", + message: `Could not parse cfn-lint output. Exit code: ${lintResult.exitCode}`, + }; + } +} diff --git a/bin/clover/src/pipelines/aws/overrides.ts b/bin/clover/src/pipelines/aws/overrides.ts index 92daff8587..e8882d839e 100644 --- a/bin/clover/src/pipelines/aws/overrides.ts +++ b/bin/clover/src/pipelines/aws/overrides.ts @@ -268,7 +268,7 @@ export const AWS_SCHEMA_OVERRIDES = new Map([ attachExtraManagementFunction( "./src/pipelines/aws/funcs/overrides/AWS::Route53::RecordSet/management/discover.ts", "Discover on AWS", - "2432de7a389b82f644353e9807b27fbb42769784b095a0f14abdad07c35bdb19", + "3596627c92df6aa894e9b7643e03bfc80c24f98b0e2fdddb180c5d203919a233", ); spec.funcs.push(discoverFunc); variant.managementFuncs.push(discoverFuncSpec); @@ -277,7 +277,7 @@ export const AWS_SCHEMA_OVERRIDES = new Map([ attachExtraManagementFunction( "./src/pipelines/aws/funcs/overrides/AWS::Route53::RecordSet/management/import.ts", "Import from AWS", - "9d01a77a07f2c30ed6a8af62b59455e9d720ae50b09e747c9f8c0e2b0306a4a4", + "4995a7ec45cd41197507676772814cfddfbc07fc8ba398d1bc2edd52763edb23", ); spec.funcs.push(importFunc); variant.managementFuncs.push(importFuncSpec); @@ -289,7 +289,7 @@ export const AWS_SCHEMA_OVERRIDES = new Map([ "./src/pipelines/aws/funcs/overrides/AWS::Route53::RecordSet/actions/create.ts", "Create Route53 RecordSet", "create", - "2ebacb3b5a978529f2d047f43e812187d11a06b692a933decc8d4c31d81e4424", + "14f1667fbfa7d7deb97da604684835bca07f77208cbff340987f35790675ddf2", ); spec.funcs.push(createDeploymentFunc); variant.actionFuncs.push(createActionFuncSpec); @@ -301,7 +301,7 @@ export const AWS_SCHEMA_OVERRIDES = new Map([ "./src/pipelines/aws/funcs/overrides/AWS::Route53::RecordSet/actions/refresh.ts", "Refresh Route53 RecordSet", "refresh", - "a2c37fc391c835788c5138b94ac91d3b7b60f7882e6713ad5c69bb52e62b8594", + "690eba8ded9aa72319ee5a2c16da6d80808afb8a0b7187a73289c6ebc02f6b48", ); spec.funcs.push(refreshDeploymentFunc); variant.actionFuncs.push(refreshActionFuncSpec); diff --git a/bin/clover/src/pipelines/aws/pipeline-steps/pruneCfAssets.ts b/bin/clover/src/pipelines/aws/pipeline-steps/pruneCfAssets.ts index af05c9a9fc..7521449d8a 100644 --- a/bin/clover/src/pipelines/aws/pipeline-steps/pruneCfAssets.ts +++ b/bin/clover/src/pipelines/aws/pipeline-steps/pruneCfAssets.ts @@ -1,11 +1,20 @@ import { ExpandedPkgSpec } from "../../../spec/pkgs.ts"; import _logger from "../../../logger.ts"; -import { createFunc, strippedBase64 } from "../../../spec/funcs.ts"; -import { CODE_GENERATION_FUNC_SPECS } from "../funcs.ts"; +import { + createDefaultFuncSpec, + createFunc, + createLeafFuncSpec, + strippedBase64, +} from "../../../spec/funcs.ts"; +import { CF_ONLY_FUNC_SPECS } from "../funcs.ts"; import { ulid } from "https://deno.land/x/ulid@v0.3.0/mod.ts"; import { FuncArgumentSpec } from "../../../bindings/FuncArgumentSpec.ts"; +import { AttrFuncInputSpec } from "../../../bindings/AttrFuncInputSpec.ts"; import { + bfsPropTree, + createObjectProp, createScalarProp, + ExpandedPropSpec, ExpandedPropSpecFor, findPropByName, } from "../../../spec/props.ts"; @@ -25,28 +34,56 @@ export function pruneCfAssets(specs: ExpandedPkgSpec[]): ExpandedPkgSpec[] { variant.managementFuncs = []; - variant.leafFunctions = variant.leafFunctions.filter( - (func) => - func.funcUniqueId === - CODE_GENERATION_FUNC_SPECS.awsCloudFormationLint.id, + // Replace all leaf functions with CF-only codegen and qualification functions + variant.leafFunctions = []; + + // Add CF-only codegen function + const codeGenFunc = createDefaultFuncSpec( + CF_ONLY_FUNC_SPECS.awsCfOnlyCodeGen.displayName, + CF_ONLY_FUNC_SPECS.awsCfOnlyCodeGen, + [], ); + spec.funcs.push(codeGenFunc); + variant.leafFunctions.push( + createLeafFuncSpec( + "codeGeneration", + CF_ONLY_FUNC_SPECS.awsCfOnlyCodeGen.id, + ["domain"], + ), + ); + + // Add CF-only lint qualification function + const qualificationFunc = createDefaultFuncSpec( + CF_ONLY_FUNC_SPECS.awsCfOnlyLint.displayName, + CF_ONLY_FUNC_SPECS.awsCfOnlyLint, + [], + ); + spec.funcs.push(qualificationFunc); + variant.leafFunctions.push( + createLeafFuncSpec( + "qualification", + CF_ONLY_FUNC_SPECS.awsCfOnlyLint.id, + ["domain"], + ), + ); + + // Restructure domain: wrap all CF props under "properties", keep "extra" as sibling + restructureDomainProps(variant.domain, spec.name); - // Add CloudFormationOnly prop to extra + // Add CloudFormationOnly prop to extra (extra is always created by restructureDomainProps) const extraProp = findPropByName( variant.domain, "extra", ) as ExpandedPropSpecFor["object"]; - if (extraProp) { - const cfOnlyProp = createScalarProp( - "CloudFormationOnly", - "boolean", - extraProp.metadata.propPath, - false, - ); - cfOnlyProp.data.defaultValue = "true"; - cfOnlyProp.data.hidden = true; - extraProp.entries.push(cfOnlyProp); - } + const cfOnlyProp = createScalarProp( + "CloudFormationOnly", + "boolean", + extraProp.metadata.propPath, + false, + ); + cfOnlyProp.data.defaultValue = "true"; + cfOnlyProp.data.hidden = true; + extraProp.entries.push(cfOnlyProp); const attrFunc = createAttributeFunc(); spec.funcs.push(attrFunc); @@ -56,27 +93,20 @@ export function pruneCfAssets(specs: ExpandedPkgSpec[]): ExpandedPkgSpec[] { function createAttributeFunc() { const code = Deno.readTextFileSync( - "./src/pipelines/aws/funcs/attribute/awsCloudControlCfAssetAttr.ts", + "./src/pipelines/aws/funcs/attribute/awsCfOnlyAttr.ts", ); const codeBase64: string = strippedBase64(code); const args: FuncArgumentSpec[] = [ { - name: "cfnType", - kind: "string", - elementKind: null, - uniqueId: ulid(), - deleted: false, - }, - { - name: "cfnProperties", + name: "properties", kind: "object", elementKind: null, uniqueId: ulid(), deleted: false, }, { - name: "cfnLogicalResourceName", - kind: "string", + name: "extra", + kind: "object", elementKind: null, uniqueId: ulid(), deleted: false, @@ -88,7 +118,157 @@ function createAttributeFunc() { "jsAttribute", "json", codeBase64, - "4dbf74c51d38d38a9247a501fc49e6f8332addab4343c5e46d3453fee55cfb6a", + "a2b5a6a39ce093738e0e19a55ecfaeca5bd99716519163c8c15f3674d95d096e", args, ); } + +/** + * Restructures the domain prop for CF-only assets: + * - Creates a new "properties" object under domain + * - Moves all CF props (everything except "extra") into "properties" + * - Adds "AwsResourceType" to "extra" with the schema name + */ +function restructureDomainProps( + domain: ExpandedPropSpecFor["object"], + schemaName: string, +) { + // Separate extra from the other props, or create it if it doesn't exist + let extraProp = domain.entries.find( + (p) => p.name === "extra", + ) as ExpandedPropSpecFor["object"] | undefined; + const cfProps = domain.entries.filter((p) => p.name !== "extra"); + + // Create extra prop if it doesn't exist + if (!extraProp) { + extraProp = createObjectProp("extra", ["root", "domain"], undefined, false); + } + + // Create the new "properties" object prop + const propertiesProp = createObjectProp( + "properties", + ["root", "domain"], + undefined, + false, + ); + + // Move CF props into properties and update their paths + for (const prop of cfProps) { + updatePropPaths(prop, ["root", "domain"], ["root", "domain", "properties"]); + propertiesProp.entries.push(prop); + } + + // Add or update AwsResourceType in extra + { + const existingAwsResourceType = extraProp.entries.find( + (p) => p.name === "AwsResourceType", + ); + if (existingAwsResourceType) { + // Update existing prop's defaultValue + existingAwsResourceType.data.defaultValue = schemaName; + existingAwsResourceType.data.hidden = true; + } else { + // Create new prop if it doesn't exist + const awsResourceTypeProp = createScalarProp( + "AwsResourceType", + "string", + extraProp.metadata.propPath, + false, + ); + awsResourceTypeProp.data.defaultValue = schemaName; + awsResourceTypeProp.data.hidden = true; + extraProp.entries.push(awsResourceTypeProp); + } + } + + // Create CloudFormationResourceBody prop (output for attribute function) + const cloudFormationResourceBodyProp = createJsonProp( + "CloudFormationResourceBody", + ["root", "domain"], + false, + ); + + // Bind the attribute function to the output prop + cloudFormationResourceBodyProp.data.funcUniqueId = + "a2b5a6a39ce093738e0e19a55ecfaeca5bd99716519163c8c15f3674d95d096e"; + cloudFormationResourceBodyProp.data.inputs = [ + { + kind: "prop", + name: "properties", + prop_path: "/root/domain/properties", + unique_id: null, + deleted: false, + }, + { + kind: "prop", + name: "extra", + prop_path: "/root/domain/extra", + unique_id: null, + deleted: false, + }, + ] as AttrFuncInputSpec[]; + + // Rebuild domain entries: properties, extra, CloudFormationResourceBody + domain.entries = [propertiesProp, extraProp, cloudFormationResourceBodyProp]; +} + +/** + * Updates the propPath for a prop and all its descendants + */ +function updatePropPaths( + prop: ExpandedPropSpec, + oldPrefix: string[], + newPrefix: string[], +) { + bfsPropTree(prop, (p) => { + // Replace the old prefix with the new prefix in the propPath + const oldPath = p.metadata.propPath; + if ( + oldPath.length >= oldPrefix.length && + oldPath.slice(0, oldPrefix.length).join("/") === oldPrefix.join("/") + ) { + p.metadata.propPath = [...newPrefix, ...oldPath.slice(oldPrefix.length)]; + } + }); +} + +/** + * Creates a JSON prop with CodeEditor widget + */ +function createJsonProp( + name: string, + parentPath: string[], + required: boolean, +): ExpandedPropSpec { + const data: ExpandedPropSpec["data"] = { + name, + validationFormat: null, + defaultValue: null, + funcUniqueId: null, + inputs: [], + widgetKind: "CodeEditor", + widgetOptions: null, + hidden: false, + docLink: null, + documentation: null, + uiOptionals: null, + }; + + const prop: ExpandedPropSpec = { + kind: "json", + data, + name, + uniqueId: ulid(), + metadata: { + createOnly: false, + readOnly: false, + writeOnly: false, + primaryIdentifier: false, + propPath: [...parentPath, name], + required, + }, + cfProp: undefined, + }; + + return prop; +}