Skip to content

Commit

Permalink
feat: support cfn outputs (#67)
Browse files Browse the repository at this point in the history
This PR adds support for cfn outputs. You can specify a Cfn Output as an environment variable like so:

```ts
pipeline.addStage(myStage, {
      pre: [new ShellStep('Pre', {
        commands: ['echo hello'],
      })],
      post: [new ShellStep('Post', {
        envFromCfnOutputs: {
          FN_NAME: myStage.fnName,
        },
        commands: ['echo FN_NAME equals: $FN_NAME'],
      })],
    });
```

This translates to a `deploy.yml` file with an output for the deploy step:

```yaml
    outputs:
      myout: \${{ steps.Deploy.outputs.myout }}
```

As well as sets the environment variable for the Post step:

```yaml
    env:
      FN_NAME: \${{ needs.StageA-FunctionStack-Deploy.outputs.myout }}
    steps:
      - run: \\"echo FN_NAME equals: $FN_NAME\\"
```
  • Loading branch information
kaizencc authored Jan 19, 2022
1 parent d240752 commit a5d1eba
Show file tree
Hide file tree
Showing 7 changed files with 131 additions and 22 deletions.
2 changes: 1 addition & 1 deletion API.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ Name | Type | Description
**if**? | <code>string</code> | You can use the if conditional to prevent a job from running unless a condition is met.<br/>__*Optional*__
**name**? | <code>string</code> | The name of the job displayed on GitHub.<br/>__*Optional*__
**needs**? | <code>Array<string></code> | Identifies any jobs that must complete successfully before this job will run.<br/>__*Optional*__
**outputs**? | <code>Map<string, [JobStepOutput](#cdk-pipelines-github-jobstepoutput)></code> | A map of outputs for a job.<br/>__*Optional*__
**outputs**? | <code>Map<string, string></code> | A map of outputs for a job.<br/>__*Optional*__
**services**? | <code>Map<string, [ContainerOptions](#cdk-pipelines-github-containeroptions)></code> | Used to host service containers for a job in a workflow.<br/>__*Optional*__
**strategy**? | <code>[JobStrategy](#cdk-pipelines-github-jobstrategy)</code> | A strategy creates a build matrix for your jobs.<br/>__*Optional*__
**timeoutMinutes**? | <code>number</code> | The maximum number of minutes to let a job run before GitHub automatically cancels it.<br/>__*Default*__: 360
Expand Down
2 changes: 0 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,6 @@ This is work in progress. The following features are still not supported:

* [ ] Credentials and roles (document permissions required, etc)
* [ ] Support Docker image assets
* [ ] Support Pre/post steps
* [ ] Support CFN output bindings
* [ ] Anti-tamper check for CI runs (`synth` should fail if `CI=1` and the workflow has changed)
* [ ] Revise Documentation

Expand Down
73 changes: 65 additions & 8 deletions src/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { mkdirSync, writeFileSync } from 'fs';
import * as path from 'path';
import { Stage } from 'aws-cdk-lib';
import { EnvironmentPlaceholders } from 'aws-cdk-lib/cx-api';
import { PipelineBase, PipelineBaseProps, ShellStep, StackAsset, StackDeployment, Step } from 'aws-cdk-lib/pipelines';
import { PipelineBase, PipelineBaseProps, ShellStep, StackAsset, StackDeployment, StackOutputReference, Step } from 'aws-cdk-lib/pipelines';
import { AGraphNode, PipelineGraph, Graph, isGraph } from 'aws-cdk-lib/pipelines/lib/helpers-internal';
import { Construct } from 'constructs';
import * as decamelize from 'decamelize';
Expand Down Expand Up @@ -93,6 +93,7 @@ export class GitHubWorkflow extends PipelineBase {
private readonly buildContainer?: github.ContainerOptions;
private readonly preBuildSteps: github.JobStep[];
private readonly postBuildSteps: github.JobStep[];
private readonly jobOutputs: Record<string, github.JobStepOutput[]> = {};

constructor(scope: Construct, id: string, props: GitHubWorkflowProps) {
super(scope, id, props);
Expand Down Expand Up @@ -158,7 +159,6 @@ export class GitHubWorkflow extends PipelineBase {
}

// convert jobs to a map and make sure there are no duplicates

const jobmap: Record<string, github.Job> = {};
for (const job of jobs) {
if (job.id in jobmap) {
Expand All @@ -167,6 +167,9 @@ export class GitHubWorkflow extends PipelineBase {
jobmap[job.id] = snakeCaseKeys(job.definition);
}

// Update jobs with late-bound output requests
this.insertJobOutputs(jobmap);

const workflow = {
name: this.workflowName,
on: snakeCaseKeys(this.workflowTriggers, '_'),
Expand All @@ -184,6 +187,26 @@ export class GitHubWorkflow extends PipelineBase {
writeFileSync(this.workflowPath, yaml);
}

private insertJobOutputs(jobmap: Record<string, github.Job>) {
for (const [jobId, jobOutputs] of Object.entries(this.jobOutputs)) {
jobmap[jobId] = {
...jobmap[jobId],
outputs: {
...jobmap[jobId].outputs,
...this.renderJobOutputs(jobOutputs),
},
};
}
}

private renderJobOutputs(outputs: github.JobStepOutput[]) {
const renderedOutputs: Record<string, string> = {};
for (const output of outputs) {
renderedOutputs[output.outputName] = `\${{ steps.${output.stepId}.outputs.${output.outputName} }}`;
}
return renderedOutputs;
}

/**
* Make an action from the given node and/or step
*/
Expand Down Expand Up @@ -287,7 +310,6 @@ export class GitHubWorkflow extends PipelineBase {
if (stack.executionRoleArn) {
params['role-arn'] = resolve(stack.executionRoleArn);
}

const assumeRoleArn = stack.assumeRoleArn ? resolve(stack.assumeRoleArn) : undefined;

return {
Expand All @@ -300,6 +322,7 @@ export class GitHubWorkflow extends PipelineBase {
steps: [
...this.stepsToConfigureAws({ region, assumeRoleArn }),
{
id: 'Deploy',
uses: 'aws-actions/aws-cloudformation-github-deploy@v1',
with: params,
},
Expand Down Expand Up @@ -358,10 +381,40 @@ export class GitHubWorkflow extends PipelineBase {
};
}

private jobForScriptStep(node: AGraphNode, step: ShellStep): Job {
/**
* Searches for the stack that produced the output via the current
* job's dependencies.
*
* This function should always find a stack, since it is guaranteed
* that a CfnOutput comes from a referenced stack.
*/
private findStackOfOutput(ref: StackOutputReference, node: AGraphNode) {
for (const dep of node.allDeps) {
if (dep.data?.type === 'execute' && ref.isProducedBy(dep.data.stack)) {
return dep.uniqueId;
}
}
// Should never happen
throw new Error(`The output ${ref.outputName} is not referenced by any of the dependent stacks!`);
}

private addJobOutput(jobId: string, output: github.JobStepOutput) {
if (this.jobOutputs[jobId] === undefined) {
this.jobOutputs[jobId] = [output];
} else {
this.jobOutputs[jobId].push(output);
}
}

if (Object.keys(step.envFromCfnOutputs).length > 0) {
throw new Error('"envFromOutputs" is not supported');
private jobForScriptStep(node: AGraphNode, step: ShellStep): Job {
const envVariables: Record<string, string> = {};
for (const [envName, ref] of Object.entries(step.envFromCfnOutputs)) {
const jobId = this.findStackOfOutput(ref, node);
this.addJobOutput(jobId, {
outputName: ref.outputName,
stepId: 'Deploy',
});
envVariables[envName] = `\${{ needs.${jobId}.outputs.${ref.outputName} }}`;
}

const downloadInputs = new Array<github.JobStep>();
Expand Down Expand Up @@ -401,7 +454,10 @@ export class GitHubWorkflow extends PipelineBase {
},
runsOn: RUNS_ON,
needs: this.renderDependencies(node),
env: step.env,
env: {
...step.env,
...envVariables,
},
steps: [
...downloadInputs,
...installSteps,
Expand Down Expand Up @@ -518,7 +574,8 @@ function snakeCaseKeys<T = unknown>(obj: T, sep = '-'): T {

const result: Record<string, unknown> = {};
for (let [k, v] of Object.entries(obj)) {
if (typeof v === 'object' && v != null) {
// we don't want to snake case environment variables
if (k !== 'env' && typeof v === 'object' && v != null) {
v = snakeCaseKeys(v);
}
result[decamelize(k, { separator: sep })] = v;
Expand Down
2 changes: 1 addition & 1 deletion src/workflows-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export interface Job {
* A map of outputs for a job. Job outputs are available to all downstream
* jobs that depend on this job.
*/
readonly outputs?: Record<string, JobStepOutput>;
readonly outputs?: Record<string, string>;

/**
* A map of environment variables that are available to all steps in the
Expand Down
51 changes: 44 additions & 7 deletions test/__snapshots__/github.test.ts.snap

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

21 changes: 19 additions & 2 deletions test/example-app.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { App, RemovalPolicy, Stack, Stage, StageProps } from 'aws-cdk-lib';
import { App, CfnOutput, RemovalPolicy, Stack, Stage, StageProps } from 'aws-cdk-lib';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as s3 from 'aws-cdk-lib/aws-s3';
import { EnvironmentUtils } from 'aws-cdk-lib/cx-api';
Expand Down Expand Up @@ -78,12 +78,25 @@ export class GitHubExampleApp extends App {
],
});

pipeline.addStage(new MyStage(this, 'StageA', { env: EnvironmentUtils.parse(props.envA) }));
const myStage = new MyStage(this, 'StageA', { env: EnvironmentUtils.parse(props.envA) });
pipeline.addStage(myStage, {
pre: [new ShellStep('Pre', {
commands: ['echo hello'],
})],
post: [new ShellStep('Post', {
envFromCfnOutputs: {
FN_NAME: myStage.fnName,
},
commands: ['echo FN_NAME equals: $FN_NAME'],
})],
});

pipeline.addStage(new MyStage(this, 'StageB', { env: EnvironmentUtils.parse(props.envB) }));
}
}

class MyStage extends Stage {
public readonly fnName: CfnOutput;
constructor(scope: App, id: string, props: StageProps) {
super(scope, id, props);

Expand All @@ -104,6 +117,10 @@ class MyStage extends Stage {
},
});

this.fnName = new CfnOutput(fnStack, 'myout', {
value: fn.functionName,
});

bucket.grantRead(fn);
}
}
2 changes: 1 addition & 1 deletion test/manual-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ if (!account) {
const app = new GitHubExampleApp({
repoDir: '.',
envA: `aws://${account}/us-east-1`,
envB: `aws://${account}/eu-west-2`,
envB: `aws://${account}/eu-west-1`,
});

app.synth();

0 comments on commit a5d1eba

Please sign in to comment.