Skip to content

Commit

Permalink
feat(cli): watch streams resources' CloudWatch logs to the terminal (#…
Browse files Browse the repository at this point in the history
…18159)

This adds a new `--logs` flag on `cdk watch` which is set to `true` by
default. Watch will monitor all CloudWatch Log groups in the application
and stream the log events back to the users terminal.


re #18122


----

*By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
  • Loading branch information
corymhall committed Jan 12, 2022
1 parent 7779c14 commit a9038ae
Show file tree
Hide file tree
Showing 19 changed files with 924 additions and 151 deletions.
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', {
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",
}),
)
.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(
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 {
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 Down Expand Up @@ -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,
// 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

0 comments on commit a9038ae

Please sign in to comment.