Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor to use cloud assembly #167

Merged
merged 8 commits into from
Oct 7, 2024
Merged

Refactor to use cloud assembly #167

merged 8 commits into from
Oct 7, 2024

Conversation

corymhall
Copy link
Contributor

Previously this library walked the construct tree in-memory and called the internal _toCloudFormation() method each Cfn* construct has to get the CloudFormation resource fragment. This is both inefficient (because we call synth which internally does the same thing, and then we do it again), and potentially inaccurate (synth internally does a lot more than we do when we walk the construct tree).

This PR is a refactor (maybe closer to a rewrite) that switches to taking the output of app.synth() and processing the resulting CloudAssembly. The CloudAssembly has everything necessary to do the conversion and contains the fully resolved CloudFormation template (we don't have to worry about unresolved tokens!).

This also sets us up for future work like nested stacks, multiple stacks, and #153.

Note to reviewers. This is a pretty big refactor so I would recommend reviewing this instead as a restart rather than trying to figure out what the old code is doing.

Closes #18

Previously this library walked the construct tree in-memory and called the
internal `_toCloudFormation()` method each `Cfn*` construct has to get
the CloudFormation resource fragment. This is both inefficient (because
we call `synth` which internally does the same thing, and then we do it
again), and potentially inaccurate (`synth` internally does a lot more
than we do when we walk the construct tree).

This PR is a refactor (maybe closer to a rewrite) that switches to
taking the output of `app.synth()` and processing the resulting
CloudAssembly. The CloudAssembly has everything necessary to do the
conversion and contains the fully resolved CloudFormation template (we don't
have to worry about unresolved tokens!).

This also sets us up for future work like nested stacks, multiple
stacks, and #153.

Note to reviewers. This is a pretty big refactor so I would recommend
reviewing this instead as a restart rather than trying to figure out
what the old code is doing.

Closes #18
@corymhall corymhall marked this pull request as ready for review October 3, 2024 16:52
@corymhall corymhall requested review from t0yv0 and flostadler October 3, 2024 16:52
src/assembly/manifest.ts Outdated Show resolved Hide resolved
this.manifestReader = AssemblyManifestReader.fromPath(host.assemblyDir);
}

convert() {
Copy link
Member

@t0yv0 t0yv0 Oct 3, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I've been following right convert() methods actually issue Pulumi resource calls e.g. new Bucket(). So the protocol here is SomeConverter is a partial result, but SomeConverter.convert() "runs" the Pulumi program. Just checking.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes I think that is correct.

return this.processIntrinsics(value) as T;
}

private processIntrinsics(obj: any): any {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've been staring at this and it's a bit fast and furious for me, do we know what the domain and range are? What can these things be? Not entirely any right? Is incremental typing not worth it here? Perhaps can at least document the domain and range better.

Looking one thing this is applied on is this:

export interface CloudFormationResource {
    readonly Type: string;
    readonly Properties: any;
    readonly Condition?: string;
    readonly DependsOn?: string | string[];
}

Already model is Properties: any so perhaps it's a necessity.

    readonly Properties: any;

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might be able to do better, worth looking into.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could be a follow up for later productization phase though. If it works it works.

}

private isNoValue(obj: any): boolean {
return obj?.Ref === 'AWS::NoValue';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Eh, what fun is this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fun CloudFormation! This is how you specify undefined in CloudFormation 🙃

}

case 'Fn::Join':
return lift(([delim, strings]) => strings.join(delim), this.processIntrinsics(params));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Powerful meta-programming here. Again I'd like to discuss a bit other representations then any if they add any value for us here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I would like to refactor this whole lift thing. It is very hard to track and I already fixed one bug where Fn::Base64 wasn't even working.

}
}

private resolveRef(target: any): any {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like it's emitting Pulumi data source calls?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes it does. Some builtin CloudFormation intrinsics are akin to data source calls. I should better document this.

* This will only be set if this node represents a CloudFormation resource.
* It will not be set for wrapper constructs
*/
logicalId?: string;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth commenting what incoming and outgoing edges are? Something with dependency information? I was not super sure. What does it mean to have an outgoingEdge?

Also, Set<GraphNode> compares on hash equality? SO this is what we want here, one node = one in-mem object?

src/types.ts Outdated Show resolved Hide resolved
attributes?: { [name: string]: pulumi.Input<any> };
};

export function containsEventuals(v: any): boolean {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this code live elsewhere?

@t0yv0
Copy link
Member

t0yv0 commented Oct 3, 2024

Can you highlight the app.synth() call in the code? Appreciated. I'll do another review round tomorrow, familiarizing with this codebase.

super('cdk:index:Stack', stack.node.id, {}, stack.options);
this.options = stack.options;

this.name = stack.node.id;

const assembly = stack.app.synth();
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@t0yv0 this is where synth is called.

src/assembly/manifest.ts Outdated Show resolved Hide resolved
src/assembly/manifest.ts Outdated Show resolved Hide resolved
src/assembly/manifest.ts Show resolved Hide resolved
src/assembly/manifest.ts Outdated Show resolved Hide resolved
src/assembly/manifest.ts Outdated Show resolved Hide resolved
src/converters/app-converter.ts Outdated Show resolved Hide resolved
src/converters/app-converter.ts Show resolved Hide resolved
src/converters/app-converter.ts Outdated Show resolved Hide resolved

private addEdgesForFragment(obj: any, source: GraphNode): void {
private addEdgesForCfnResource(obj: any, source: GraphNode): void {
// Since we are processing the final CloudFormation template, strings will always
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice!

return Object.values(v).some((e) => containsEventuals(e));
}

export function lift(f: (args: any) => any, args: any): any {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should be able to type this method, right?

* ConstructTree is a tree of the current CDK construct
* It represents the structure in the `tree.json` file and is based
* off the implementation here:
* https://github.com/aws/aws-cdk/blob/4bce941fc680ebd396569383f6cf07527541dcc2/packages/aws-cdk-lib/core/lib/private/tree-metadata.ts?plain=1#L177
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Presumably no aws-cdk code can be reused instead, since it's private?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For some reason I thought it wasn't exported, but it is. Updated to use the cdk import.

src/assembly/manifest.ts Outdated Show resolved Hide resolved
readonly parameters = new Map<string, any>();
readonly resources = new Map<string, Mapping<pulumi.Resource>>();
readonly constructs = new Map<ConstructInfo, pulumi.Resource>();
stackResource!: CdkConstruct;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm always a little wary of mutable state. This starts out as undefined, right? Then it's set in convert. Is it ever accessed before it's set? Would it be easier to read and guarantee sequencing works out if this wasn't in the state at all?

Also curious what does "!" do in TypeScript, reading up on this.

GPT-4o:

In TypeScript, the =!= after a variable name (e.g., =stackResource!=) is called the definite assignment assertion. It
tells the TypeScript compiler that you are confident the property will be assigned a value before it is accessed, even
if the compiler cannot determine this based on its normal flow analysis.

Sounds like a possible footgun later on, but perhaps not worth rewriting now if we're confident.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like this isn't actually used so I just removed it.

src/graph.ts Outdated
node.logicalId = logicalId;
this.cfnElementNodes.set(logicalId, node);
} else {
console.error('SOMETHING WENT WRONG: ', tree);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand this is temporary, but do we want console.error or do we want throw new Error (fail fast)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated!

/**
* Options specific to the Stack component.
*/
export interface StackOptions extends pulumi.ComponentResourceOptions {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is part of public API, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, I just updated the exports to only include this since it is the only thing we want exported from types.

/**
* ArtifactConverter
*/
export abstract class ArtifactConverter {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: does it have go be an abstract class? resolvePlaceholders could take stackComponent (or pulumi.Resource even) as an extra parameter and this would be just a function. Does it need state in the future?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's an abstract class because we have multiple types of things that will extend it (FileAssetConverter, DockerAssetConverter, StackConverter).

beforeEach(() => {
mockfs({
// Recursively loads all node_modules
node_modules: mockfs.load(path.resolve(__dirname, '../../node_modules')),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can get very big, no? It's needed?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I may be able to filter it down some to only the cdk libraries (which are needed).

registerOutput(outputId: string, output: any): void {}
}

describe('App Converter', () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reading this out loud, if I understand this right, this builds out some manifests that CDK would build, and feeds them into AppConverter to assert that the right Pulumi resources are created against the mocks. There is an implied CDK program that would build these manifests. WDYT about having that CDK program kept in the test suite and the manifests recomputed? Perhaps wouldn't work or is too high level?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could do that, but I prefer having the lower level tests rather than super high level tests. We had some very high level tests already and it made it very hard to know what areas were under test and which were not. I also think it helps with understanding what is being converted, otherwise you don't have anything to reference.

@t0yv0
Copy link
Member

t0yv0 commented Oct 7, 2024

This has been super helpful to deep dive in the codebase, thanks @corymhall ! I would like to complete a small tasks over the codebase to get even more familiar. Left you some non-binding low-level comments.

Copy link
Contributor

@flostadler flostadler left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great!
Looking forward to diving deeper into the codebase

@corymhall corymhall merged commit 5bb37e9 into main Oct 7, 2024
8 checks passed
@corymhall corymhall deleted the corymhall/from-assembly branch October 7, 2024 17:59
@pulumi-bot
Copy link
Contributor

This PR has been shipped in release v1.0.0.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Convert the output of App.synth rather than the construct tree.
4 participants