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

feat(cli): watch streams resources' CloudWatch logs to the terminal #18159

Merged
merged 36 commits into from Jan 12, 2022
Merged
Show file tree
Hide file tree
Changes from 34 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
3add949
feat(cli): cdk watch can now stream lambda function logs to the terminal
corymhall Dec 23, 2021
151b41d
simplifying some stuff
corymhall Dec 23, 2021
d290fee
Apply suggestions from code review
corymhall Dec 28, 2021
a13dafb
refactoring to include all hotswap resources
corymhall Dec 28, 2021
ad1b4a4
updates
corymhall Dec 29, 2021
18fbefd
monitor logs regardless of whether we are hotswapping or not
corymhall Dec 29, 2021
5bba356
pause the logs monitor during a deployment
corymhall Dec 29, 2021
94fc4c8
updates
corymhall Dec 30, 2021
9e0bef8
update to include all cloudwatch log groups and only exclude certain
corymhall Dec 30, 2021
809d3d8
updating readme
corymhall Dec 30, 2021
cb078bf
reset tracked log groups prior to each deployment
corymhall Dec 30, 2021
2f1d9da
Apply suggestions from code review
corymhall Jan 3, 2022
a0c8f21
moving things around
corymhall Jan 3, 2022
2aa585d
Merge branch 'master' into corymhall/hotswap-logs
corymhall Jan 3, 2022
8e0e475
renaming `addSdk` to `setSdk`
corymhall Jan 3, 2022
5c2af13
using lookup role to read events from cloudwatch
corymhall Jan 3, 2022
beb9fc4
reverting formatting
corymhall Jan 3, 2022
87afeab
reverting some formatting changes
corymhall Jan 3, 2022
0dc861d
revert changes to cloud-assembly-schema
corymhall Jan 4, 2022
4329b9c
Merge branch 'master' into corymhall/hotswap-logs
corymhall Jan 4, 2022
ca38736
updates
corymhall Jan 4, 2022
81e6634
updates
corymhall Jan 5, 2022
d718fdb
adding codebuild to excluded resource types
corymhall Jan 5, 2022
b3ae63c
updates
corymhall Jan 6, 2022
200fd60
Apply suggestions from code review
corymhall Jan 7, 2022
bf6ef3f
updates based on comments
corymhall Jan 7, 2022
c38de39
Merge branch 'master' into corymhall/hotswap-logs
corymhall Jan 10, 2022
3f3547a
using lookup role for reading cloudwatch logs
corymhall Jan 10, 2022
5043f4a
Revert "using lookup role for reading cloudwatch logs"
corymhall Jan 10, 2022
965f963
use lookup role for reading events from cloudwatch logs
corymhall Jan 10, 2022
e46e971
updates based on review comments
corymhall Jan 11, 2022
4369e08
Merge branch 'master' into corymhall/hotswap-logs
corymhall Jan 11, 2022
15f3e91
switching from colors to chalk
corymhall Jan 11, 2022
2c07e8f
refactoring findCloudWatchLogGroups to lookup the list of CloudWatch
corymhall Jan 12, 2022
87d8d9d
fixing urlSuffix
corymhall Jan 12, 2022
d875b92
Merge branch 'master' into corymhall/hotswap-logs
mergify[bot] Jan 12, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/aws-cdk/README.md
Expand Up @@ -436,6 +436,13 @@ for example:
Note that `watch` by default uses hotswap deployments (see above for details) --
to turn them off, pass the `--no-hotswap` option when invoking it.

By default `watch` will also monitor all CloudWatch Log Groups in your application and stream the log events
locally to your terminal. To disable this feature you can pass the `--no-logs` option when invoking it:

```console
$ cdk watch --no-logs
```

**Note**: This command is considered experimental,
and might have breaking changes in the future.

Expand Down
15 changes: 15 additions & 0 deletions packages/aws-cdk/bin/cdk.ts
Expand Up @@ -124,6 +124,13 @@ async function parseCommandLineArguments() {
desc: 'Continuously observe the project files, ' +
'and deploy the given stack(s) automatically when changes are detected. ' +
'Implies --hotswap by default',
})
.options('logs', {
type: 'boolean',
default: true,
desc: 'Show CloudWatch log events from all resources in the selected Stacks in the terminal. ' +
"'true' by default, use --no-logs to turn off. " +
"Only in effect if specified alongside the '--watch' option",
}),
)
.command('watch [STACKS..]', "Shortcut for 'deploy --watch'", yargs => yargs
Expand Down Expand Up @@ -157,6 +164,12 @@ async function parseCommandLineArguments() {
'which skips CloudFormation and updates the resources directly, ' +
'and falls back to a full deployment if that is not possible. ' +
"'true' by default, use --no-hotswap to turn off",
})
.options('logs', {
corymhall marked this conversation as resolved.
Show resolved Hide resolved
type: 'boolean',
corymhall marked this conversation as resolved.
Show resolved Hide resolved
default: true,
desc: 'Show CloudWatch log events from all resources in the selected Stacks in the terminal. ' +
"'true' by default, use --no-logs to turn off",
}),
)
.command('destroy [STACKS..]', 'Destroy the stack(s) named STACKS', yargs => yargs
Expand Down Expand Up @@ -376,6 +389,7 @@ async function initCommandLine() {
rollback: configuration.settings.get(['rollback']),
hotswap: args.hotswap,
watch: args.watch,
traceLogs: args.logs,
});

case 'watch':
Expand All @@ -395,6 +409,7 @@ async function initCommandLine() {
progress: configuration.settings.get(['progress']),
rollback: configuration.settings.get(['rollback']),
hotswap: args.hotswap,
traceLogs: args.logs,
});

case 'destroy':
Expand Down
5 changes: 5 additions & 0 deletions packages/aws-cdk/lib/api/aws-auth/sdk.ts
Expand Up @@ -62,6 +62,7 @@ export interface ISDK {
kms(): AWS.KMS;
stepFunctions(): AWS.StepFunctions;
codeBuild(): AWS.CodeBuild
cloudWatchLogs(): AWS.CloudWatchLogs;
}

/**
Expand Down Expand Up @@ -185,6 +186,10 @@ export class SDK implements ISDK {
return this.wrapServiceErrorHandling(new AWS.CodeBuild(this.config));
}

public cloudWatchLogs(): AWS.CloudWatchLogs {
return this.wrapServiceErrorHandling(new AWS.CloudWatchLogs(this.config));
}

public async currentAccount(): Promise<Account> {
// Get/refresh if necessary before we can access `accessKeyId`
await this.forceCredentialRetrieval();
Expand Down
160 changes: 81 additions & 79 deletions packages/aws-cdk/lib/api/cloudformation-deployments.ts
Expand Up @@ -27,6 +27,86 @@ export async function replaceEnvPlaceholders<A extends { }>(object: A, env: cxap
});
}

/**
* SDK obtained by assuming the lookup role
* for a given environment
*/
export interface PreparedSdkWithLookupRoleForEnvironment {
/**
* The SDK for the given environment
*/
readonly sdk: ISDK;

/**
* The resolved environment for the stack
* (no more 'unknown-account/unknown-region')
*/
readonly resolvedEnvironment: cxapi.Environment;

/**
* Whether or not the assume role was successful.
* If the assume role was not successful (false)
* then that means that the 'sdk' returned contains
* the default credentials (not the assume role credentials)
*/
readonly didAssumeRole: boolean;
}

/**
* Try to use the bootstrap lookupRole. There are two scenarios that are handled here
* 1. The lookup role may not exist (it was added in bootstrap stack version 7)
* 2. The lookup role may not have the correct permissions (ReadOnlyAccess was added in
* bootstrap stack version 8)
*
* In the case of 1 (lookup role doesn't exist) `forEnvironment` will either:
* 1. Return the default credentials if the default credentials are for the stack account
* 2. Throw an error if the default credentials are not for the stack account.
*
* If we successfully assume the lookup role we then proceed to 2 and check whether the bootstrap
* stack version is valid. If it is not we throw an error which should be handled in the calling
* function (and fallback to use a different role, etc)
*
* If we do not successfully assume the lookup role, but do get back the default credentials
* then return those and note that we are returning the default credentials. The calling
* function can then decide to use them or fallback to another role.
*/
export async function prepareSdkWithLookupRoleFor(
Copy link
Contributor

Choose a reason for hiding this comment

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

Any chance we can make this a method of SdkProvider?

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 originally had it as a method of SdkProvider, but after discussing with @rix0rrr we decided to put it here.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah, the SdkProvider shouldn't know about CloudFormationStackArtifacts or the fact that SSM parameters hold bootstrap stack versions.

sdkProvider: SdkProvider,
stack: cxapi.CloudFormationStackArtifact,
): Promise<PreparedSdkWithLookupRoleForEnvironment> {
const resolvedEnvironment = await sdkProvider.resolveEnvironment(stack.environment);

// Substitute any placeholders with information about the current environment
const arns = await replaceEnvPlaceholders({
lookupRoleArn: stack.lookupRole?.arn,
}, resolvedEnvironment, sdkProvider);

// try to assume the lookup role
const warningMessage = `Could not assume ${arns.lookupRoleArn}, proceeding anyway.`;
const upgradeMessage = `(To get rid of this warning, please upgrade to bootstrap version >= ${stack.lookupRole?.requiresBootstrapStackVersion})`;
try {
const stackSdk = await sdkProvider.forEnvironment(resolvedEnvironment, Mode.ForReading, {
assumeRoleArn: arns.lookupRoleArn,
assumeRoleExternalId: stack.lookupRole?.assumeRoleExternalId,
});

// if we succeed in assuming the lookup role, make sure we have the correct bootstrap stack version
if (stackSdk.didAssumeRole && stack.lookupRole?.bootstrapStackVersionSsmParameter && stack.lookupRole.requiresBootstrapStackVersion) {
const version = await ToolkitInfo.versionFromSsmParameter(stackSdk.sdk, stack.lookupRole.bootstrapStackVersionSsmParameter);
if (version < stack.lookupRole.requiresBootstrapStackVersion) {
throw new Error(`Bootstrap stack version '${stack.lookupRole.requiresBootstrapStackVersion}' is required, found version '${version}'.`);
}
} else if (!stackSdk.didAssumeRole) {
warning(upgradeMessage);
}
return { ...stackSdk, resolvedEnvironment };
} catch (e) {
debug(e);
warning(warningMessage);
warning(upgradeMessage);
throw (e);
}
}

export interface DeployStackOptions {
/**
Expand Down Expand Up @@ -171,31 +251,6 @@ export interface ProvisionerProps {
sdkProvider: SdkProvider;
}

/**
* SDK obtained by assuming the lookup role
* for a given environment
*/
export interface PreparedSdkWithLookupRoleForEnvironment {
/**
* The SDK for the given environment
*/
readonly sdk: ISDK;

/**
* The resolved environment for the stack
* (no more 'unknown-account/unknown-region')
*/
readonly resolvedEnvironment: cxapi.Environment;

/**
* Whether or not the assume role was successful.
* If the assume role was not successful (false)
* then that means that the 'sdk' returned contains
* the default credentials (not the assume role credentials)
*/
readonly didAssumeRole: boolean;
}

/**
* SDK obtained by assuming the deploy role
* for a given environment
Expand Down Expand Up @@ -237,7 +292,7 @@ export class CloudFormationDeployments {
let stackSdk: ISDK | undefined = undefined;
// try to assume the lookup role and fallback to the deploy role
try {
const result = await this.prepareSdkWithLookupRoleFor(stackArtifact);
const result = await prepareSdkWithLookupRoleFor(this.sdkProvider, stackArtifact);
if (result.didAssumeRole) {
stackSdk = result.sdk;
}
Expand Down Expand Up @@ -311,59 +366,6 @@ export class CloudFormationDeployments {
return stack.exists;
}

/**
* Try to use the bootstrap lookupRole. There are two scenarios that are handled here
* 1. The lookup role may not exist (it was added in bootstrap stack version 7)
* 2. The lookup role may not have the correct permissions (ReadOnlyAccess was added in
* bootstrap stack version 8)
*
* In the case of 1 (lookup role doesn't exist) `forEnvironment` will either:
* 1. Return the default credentials if the default credentials are for the stack account
* 2. Throw an error if the default credentials are not for the stack account.
*
* If we successfully assume the lookup role we then proceed to 2 and check whether the bootstrap
* stack version is valid. If it is not we throw an error which should be handled in the calling
* function (and fallback to use a different role, etc)
*
* If we do not successfully assume the lookup role, but do get back the default credentials
* then return those and note that we are returning the default credentials. The calling
* function can then decide to use them or fallback to another role.
*/
private async prepareSdkWithLookupRoleFor(stack: cxapi.CloudFormationStackArtifact): Promise<PreparedSdkWithLookupRoleForEnvironment> {
const resolvedEnvironment = await this.sdkProvider.resolveEnvironment(stack.environment);

// Substitute any placeholders with information about the current environment
const arns = await replaceEnvPlaceholders({
lookupRoleArn: stack.lookupRole?.arn,
}, resolvedEnvironment, this.sdkProvider);

// try to assume the lookup role
const warningMessage = `Could not assume ${arns.lookupRoleArn}, proceeding anyway.`;
const upgradeMessage = `(To get rid of this warning, please upgrade to bootstrap version >= ${stack.lookupRole?.requiresBootstrapStackVersion})`;
try {
const stackSdk = await this.sdkProvider.forEnvironment(resolvedEnvironment, Mode.ForReading, {
assumeRoleArn: arns.lookupRoleArn,
assumeRoleExternalId: stack.lookupRole?.assumeRoleExternalId,
});

// if we succeed in assuming the lookup role, make sure we have the correct bootstrap stack version
if (stackSdk.didAssumeRole && stack.lookupRole?.bootstrapStackVersionSsmParameter && stack.lookupRole.requiresBootstrapStackVersion) {
const version = await ToolkitInfo.versionFromSsmParameter(stackSdk.sdk, stack.lookupRole.bootstrapStackVersionSsmParameter);
if (version < stack.lookupRole.requiresBootstrapStackVersion) {
throw new Error(`Bootstrap stack version '${stack.lookupRole.requiresBootstrapStackVersion}' is required, found version '${version}'.`);
}
} else if (!stackSdk.didAssumeRole) {
warning(upgradeMessage);
}
return { ...stackSdk, resolvedEnvironment };
} catch (e) {
debug(e);
warning(warningMessage);
warning(upgradeMessage);
throw (e);
}
}

/**
* Get the environment necessary for touching the given stack
*
Expand Down
2 changes: 1 addition & 1 deletion packages/aws-cdk/lib/api/deploy-stack.ts
Expand Up @@ -9,9 +9,9 @@ import { AssetManifestBuilder } from '../util/asset-manifest-builder';
import { publishAssets } from '../util/asset-publishing';
import { contentHash } from '../util/content-hash';
import { ISDK, SdkProvider } from './aws-auth';
import { CfnEvaluationException } from './evaluate-cloudformation-template';
import { tryHotswapDeployment } from './hotswap-deployments';
import { ICON } from './hotswap/common';
import { CfnEvaluationException } from './hotswap/evaluate-cloudformation-template';
import { ToolkitInfo } from './toolkit-info';
import {
changeSetHasNoChanges, CloudFormationStack, TemplateParameters, waitForChangeSet,
Expand Down
@@ -1,6 +1,38 @@
import * as cxapi from '@aws-cdk/cx-api';
import * as AWS from 'aws-sdk';
import { ListStackResources } from './common';
import { ISDK } from './aws-auth';

export interface ListStackResources {
listStackResources(): Promise<AWS.CloudFormation.StackResourceSummary[]>;
}

export class LazyListStackResources implements ListStackResources {
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: it's more like CachedListStackResources than Lazy*.

Copy link
Contributor

Choose a reason for hiding this comment

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

Is there ever a chance we will list the stack resources before a particular resource is created, and then never update the list to account for the new resource?

Aha I guess since we only use this for hotswapping, that will never add new resources so doesn't apply.

Copy link
Contributor

Choose a reason for hiding this comment

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

In general, the PR description could do with a Cliff's Notes of the approach taken, biggest challenges, design decisions and refactorings. It's very helpful when evaluating a change.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

In general, the PR description could do with a Cliff's Notes of the approach taken, biggest challenges, design decisions and refactorings. It's very helpful when evaluating a change.

That is a good suggestion! This particular file was existing functionality that was moved here, which I could have called out.

Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: it's more like CachedListStackResources than Lazy*.

It is actually Lazy though (it will not make a service call unless you call listResources()).

Copy link
Contributor

Choose a reason for hiding this comment

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

That is true for most methods though 🤣

Copy link
Contributor

Choose a reason for hiding this comment

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

Not sure what you mean. I think a very sensible non-lazy implementation would be to make the service call in the constructor of the class, and not in listResources() (it would be simpler, for one thing).

private stackResources: AWS.CloudFormation.StackResourceSummary[] | undefined;

constructor(private readonly sdk: ISDK, private readonly stackName: string) {
}

public async listStackResources(): Promise<AWS.CloudFormation.StackResourceSummary[]> {
if (this.stackResources === undefined) {
this.stackResources = await this.getStackResources();
}
return this.stackResources;
}

private async getStackResources(): Promise<AWS.CloudFormation.StackResourceSummary[]> {
const ret = new Array<AWS.CloudFormation.StackResourceSummary>();
let nextToken: string | undefined;
do {
const stackResourcesResponse = await this.sdk.cloudFormation().listStackResources({
StackName: this.stackName,
NextToken: nextToken,
}).promise();
ret.push(...(stackResourcesResponse.StackResourceSummaries ?? []));
nextToken = stackResourcesResponse.NextToken;
} while (nextToken);
return ret;
}
}

export class CfnEvaluationException extends Error {}

Expand All @@ -16,7 +48,7 @@ export interface EvaluateCloudFormationTemplateProps {
readonly account: string;
readonly region: string;
readonly partition: string;
readonly urlSuffix: (region: string) => string;
readonly urlSuffix: string;
readonly listStackResources: ListStackResources;
}

Expand All @@ -27,7 +59,7 @@ export class EvaluateCloudFormationTemplate {
private readonly account: string;
private readonly region: string;
private readonly partition: string;
private readonly urlSuffix: (region: string) => string;
private readonly urlSuffix: string;
private cachedUrlSuffix: string | undefined;

constructor(props: EvaluateCloudFormationTemplateProps) {
Expand All @@ -45,6 +77,21 @@ export class EvaluateCloudFormationTemplate {
this.urlSuffix = props.urlSuffix;
}

public async establishResourcePhysicalName(logicalId: string, physicalNameInCfnTemplate: any): Promise<string | undefined> {
if (physicalNameInCfnTemplate != null) {
try {
return await this.evaluateCfnExpression(physicalNameInCfnTemplate);
} catch (e) {
// If we can't evaluate the resource's name CloudFormation expression,
Copy link
Contributor

Choose a reason for hiding this comment

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

What is this function used for? To predict physical names, before deployment happens?

Copy link
Contributor

Choose a reason for hiding this comment

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

Can't we just always list from the currently deployed stack?

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 think the intent here was to avoid a lookup if we could ( cc @skinny85 )

Copy link
Contributor

Choose a reason for hiding this comment

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

Yes, this is used in case we can get the physical name from the template, and in this way save a service call (not super relevant here, but very relevant for hotswapping, and this class is used in both places).

// just look it up in the currently deployed Stack
if (!(e instanceof CfnEvaluationException)) {
throw e;
}
}
}
return this.findPhysicalNameFor(logicalId);
}

public async findPhysicalNameFor(logicalId: string): Promise<string | undefined> {
const stackResources = await this.stackResources.listStackResources();
return stackResources.find(sr => sr.LogicalResourceId === logicalId)?.PhysicalResourceId;
Expand Down Expand Up @@ -193,7 +240,7 @@ export class EvaluateCloudFormationTemplate {
// first, check to see if the Ref is a Parameter who's value we have
if (logicalId === 'AWS::URLSuffix') {
if (!this.cachedUrlSuffix) {
this.cachedUrlSuffix = this.urlSuffix(this.region);
this.cachedUrlSuffix = this.urlSuffix;
}

return this.cachedUrlSuffix;
Expand Down