-
Notifications
You must be signed in to change notification settings - Fork 2
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
chore: emit Docker and Shell logging to ProgressListener #221
Changes from all commits
98ebc39
b51fd43
081ce2d
1b5a38b
057644a
5bcdaad
1dce3bf
f16aa2f
f5798b8
7fee2ff
39b52b6
2860f8d
b1c7851
154951f
d683acf
b2959f6
35c928d
3b98e84
91359d1
0aba325
cacbcf4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,7 +2,7 @@ import * as fs from 'fs'; | |
import * as os from 'os'; | ||
import * as path from 'path'; | ||
import { cdkCredentialsConfig, obtainEcrCredentials } from './docker-credentials'; | ||
import { Logger, shell, ShellOptions, ProcessFailedError } from './shell'; | ||
import { shell, ShellOptions, ProcessFailedError } from './shell'; | ||
import { createCriticalSection } from './util'; | ||
import { IECRClient } from '../aws'; | ||
|
||
|
@@ -55,8 +55,6 @@ export interface DockerCacheOption { | |
export class Docker { | ||
private configDir: string | undefined = undefined; | ||
|
||
constructor(private readonly logger?: Logger) {} | ||
|
||
/** | ||
* Whether an image with the given tag exists | ||
*/ | ||
|
@@ -200,10 +198,8 @@ export class Docker { | |
const pathToCdkAssets = path.resolve(__dirname, '..', '..', 'bin'); | ||
try { | ||
await shell([getDockerCmd(), ...configArgs, ...args], { | ||
logger: this.logger, | ||
...options, | ||
env: { | ||
...process.env, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why? |
||
...options.env, | ||
PATH: `${pathToCdkAssets}${path.delimiter}${options.env?.PATH ?? process.env.PATH}`, | ||
}, | ||
|
@@ -234,7 +230,6 @@ export class Docker { | |
export interface DockerFactoryOptions { | ||
readonly repoUri: string; | ||
readonly ecr: IECRClient; | ||
readonly logger: (m: string) => void; | ||
} | ||
|
||
/** | ||
|
@@ -249,7 +244,7 @@ export class DockerFactory { | |
* Gets a Docker instance for building images. | ||
*/ | ||
public async forBuild(options: DockerFactoryOptions): Promise<Docker> { | ||
const docker = new Docker(options.logger); | ||
const docker = new Docker(); | ||
|
||
// Default behavior is to login before build so that the Dockerfile can reference images in the ECR repo | ||
// However, if we're in a pipelines environment (for example), | ||
|
@@ -268,7 +263,7 @@ export class DockerFactory { | |
* Gets a Docker instance for pushing images to ECR. | ||
*/ | ||
public async forEcrPush(options: DockerFactoryOptions) { | ||
const docker = new Docker(options.logger); | ||
const docker = new Docker(); | ||
await this.loginOncePerDestination(docker, options); | ||
return docker; | ||
} | ||
|
Original file line number | Diff line number | Diff line change | ||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -1,23 +1,23 @@ | ||||||||||||||||
import * as child_process from 'child_process'; | ||||||||||||||||
import { EventType, globalOutputHandler } from '../progress'; | ||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do not have this import here. What was wrong with the Pretend the following:
Do this: The "dependencies" strictly go to the right:
If you need data to flow in the opposite direction, then you will have to use a callback mechanism with its own dedicated As for this PR: I think I would actually recommend you start again from scratch with this in mind, and potentially copy/paste bits over from this PR if you decide you need them. Whatever you do, no global variables! |
||||||||||||||||
|
||||||||||||||||
export type Logger = (x: string) => void; | ||||||||||||||||
|
||||||||||||||||
export interface ShellOptions extends child_process.SpawnOptions { | ||||||||||||||||
readonly quiet?: boolean; | ||||||||||||||||
readonly logger?: Logger; | ||||||||||||||||
readonly input?: string; | ||||||||||||||||
} | ||||||||||||||||
|
||||||||||||||||
/** | ||||||||||||||||
* OS helpers | ||||||||||||||||
* | ||||||||||||||||
* Shell function which both prints to stdout and collects the output into a | ||||||||||||||||
* string. | ||||||||||||||||
* Shell function which both emits the output to the configured output handler, and collects the output | ||||||||||||||||
* to return it as a string. | ||||||||||||||||
*/ | ||||||||||||||||
export async function shell(command: string[], options: ShellOptions = {}): Promise<string> { | ||||||||||||||||
if (options.logger) { | ||||||||||||||||
options.logger(renderCommandLine(command)); | ||||||||||||||||
} | ||||||||||||||||
globalOutputHandler.publishEvent(EventType.START, command.join(' ')); | ||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why? This seems like a new log message which i'm not sure is correct (or belongs in this PR). |
||||||||||||||||
globalOutputHandler.info(renderCommandLine(command)); | ||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So from what I could gather, it used to be that this message would eventually be logged as:
But now it changed to: Lines 125 to 127 in cacbcf4
Is this what you meant to happen? Also, it used to be that this message was logged via the implenentation of Lines 116 to 118 in cacbcf4
Which does some additional stuff apart from just logging. But now it just invokes the progress listener and it doesn't seem to be equivalent. |
||||||||||||||||
|
||||||||||||||||
const child = child_process.spawn(command[0], command.slice(1), { | ||||||||||||||||
...options, | ||||||||||||||||
stdio: [options.input ? 'pipe' : 'ignore', 'pipe', 'pipe'], | ||||||||||||||||
|
@@ -29,39 +29,42 @@ export async function shell(command: string[], options: ShellOptions = {}): Prom | |||||||||||||||
child.stdin!.end(); | ||||||||||||||||
} | ||||||||||||||||
|
||||||||||||||||
const stdout = new Array<any>(); | ||||||||||||||||
const stderr = new Array<any>(); | ||||||||||||||||
const stdoutChunks = new Array<any>(); | ||||||||||||||||
const stderrChunks = new Array<any>(); | ||||||||||||||||
|
||||||||||||||||
// Both write to stdout and collect | ||||||||||||||||
child.stdout!.on('data', (chunk) => { | ||||||||||||||||
if (!options.quiet) { | ||||||||||||||||
process.stdout.write(chunk); | ||||||||||||||||
globalOutputHandler.publishEvent(chunk, EventType.DEBUG); | ||||||||||||||||
} | ||||||||||||||||
stdout.push(chunk); | ||||||||||||||||
stdoutChunks.push(chunk); | ||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We probably want to stream output to the logger. Docker builds can take 10 minutes, and it's not very helpful if you're sitting there, waiting, not knowing what's going on... And then after 10 minutes you get a 2MB log dump of everything that happepend. |
||||||||||||||||
}); | ||||||||||||||||
|
||||||||||||||||
child.stderr!.on('data', (chunk) => { | ||||||||||||||||
if (!options.quiet) { | ||||||||||||||||
process.stderr.write(chunk); | ||||||||||||||||
globalOutputHandler.publishEvent(chunk, EventType.DEBUG); | ||||||||||||||||
} | ||||||||||||||||
|
||||||||||||||||
stderr.push(chunk); | ||||||||||||||||
stderrChunks.push(chunk); | ||||||||||||||||
}); | ||||||||||||||||
|
||||||||||||||||
child.once('error', reject); | ||||||||||||||||
child.once('error', (error) => { | ||||||||||||||||
globalOutputHandler.publishEvent(EventType.FAIL, error.message); | ||||||||||||||||
reject(error); | ||||||||||||||||
}); | ||||||||||||||||
|
||||||||||||||||
child.once('close', (code, signal) => { | ||||||||||||||||
if (code === 0) { | ||||||||||||||||
resolve(Buffer.concat(stdout).toString('utf-8')); | ||||||||||||||||
const output = Buffer.concat(stdoutChunks).toString('utf-8'); | ||||||||||||||||
resolve(output); | ||||||||||||||||
} else { | ||||||||||||||||
const out = Buffer.concat(stderr).toString('utf-8').trim(); | ||||||||||||||||
reject( | ||||||||||||||||
new ProcessFailed( | ||||||||||||||||
code, | ||||||||||||||||
signal, | ||||||||||||||||
`${renderCommandLine(command)} exited with ${code != null ? 'error code' : 'signal'} ${code ?? signal}: ${out}` | ||||||||||||||||
) | ||||||||||||||||
const errorOutput = Buffer.concat(stderrChunks).toString('utf-8').trim(); | ||||||||||||||||
const error_message = `${renderCommandLine(command)} exited with ${code != null ? 'error code' : 'signal'} ${code ?? signal}: ${errorOutput}`; | ||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||
globalOutputHandler.publishEvent(EventType.FAIL, error_message); | ||||||||||||||||
const error = new ProcessFailed( | ||||||||||||||||
code, | ||||||||||||||||
signal, | ||||||||||||||||
error_message, | ||||||||||||||||
); | ||||||||||||||||
reject(error); | ||||||||||||||||
} | ||||||||||||||||
}); | ||||||||||||||||
}); | ||||||||||||||||
|
Original file line number | Diff line number | Diff line change | ||||||||
---|---|---|---|---|---|---|---|---|---|---|
|
@@ -84,3 +84,51 @@ export interface IPublishProgress { | |||||||||
*/ | ||||||||||
abort(): void; | ||||||||||
} | ||||||||||
|
||||||||||
class GlobalOutputHandler { | ||||||||||
private progressListener: IPublishProgressListener | undefined; | ||||||||||
private completionProgress: number; | ||||||||||
|
||||||||||
constructor(completionProgress: number = 0, progressListener?: IPublishProgressListener) { | ||||||||||
this.progressListener = progressListener; | ||||||||||
this.completionProgress = completionProgress; | ||||||||||
} | ||||||||||
|
||||||||||
public setListener(listener: IPublishProgressListener) { | ||||||||||
this.progressListener = listener; | ||||||||||
} | ||||||||||
|
||||||||||
public setCompletionProgress(progress: number) { | ||||||||||
this.completionProgress = progress; | ||||||||||
} | ||||||||||
|
||||||||||
public publishEvent(eventType: EventType = EventType.DEBUG, text: string) { | ||||||||||
const progressEvent: IPublishProgress = { | ||||||||||
message: text, | ||||||||||
abort: () => {}, | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is bizarre. I think it might be functionally ok but signals a modeling problem IMO. |
||||||||||
percentComplete: this.completionProgress, | ||||||||||
}; | ||||||||||
// if no listener is passed we just swallow everything. | ||||||||||
if (this.progressListener) { | ||||||||||
this.progressListener.onPublishEvent(eventType, progressEvent); | ||||||||||
} | ||||||||||
} | ||||||||||
|
||||||||||
public verbose(text: string) { | ||||||||||
this.publishEvent(EventType.DEBUG, text); | ||||||||||
} | ||||||||||
|
||||||||||
public error(text: string) { | ||||||||||
this.publishEvent(EventType.FAIL, text); | ||||||||||
} | ||||||||||
|
||||||||||
public info(text: string) { | ||||||||||
this.publishEvent(EventType.SUCCESS, text); | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Mapping Lines 22 to 25 in cacbcf4
It might be the code currently doesn't make assumption on the event type being emitted, but thats likely to change and at some point calls to |
||||||||||
} | ||||||||||
|
||||||||||
public hasListener() { | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not used. Should it be? |
||||||||||
return this.progressListener !== undefined; | ||||||||||
} | ||||||||||
} | ||||||||||
|
||||||||||
export let globalOutputHandler = new GlobalOutputHandler(); | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Executing code on module load is not a great idea. Sure, right now all this code does is instantiate an empty object, but this can easily turn more heavy over time and either slow down or completely crash during import. Instead, you can use the singleton pattern so that this code is only executed (once) whenever the first consumer needs it. That access pattern would be something like: // for bits of the code that only read it
GlobalOutputHandler.get().verbose(...)
// for bits of the code that create and read it
GlobalOutputHandler.getOrCreate(listener) |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -4,7 +4,12 @@ import { IAssetHandler, IHandlerHost, type PublishOptions } from './private/asse | |
import { DockerFactory } from './private/docker'; | ||
import { makeAssetHandler } from './private/handlers'; | ||
import { pLimit } from './private/p-limit'; | ||
import { EventType, IPublishProgress, IPublishProgressListener } from './progress'; | ||
import { | ||
EventType, | ||
IPublishProgress, | ||
IPublishProgressListener, | ||
globalOutputHandler, | ||
} from './progress'; | ||
|
||
export interface AssetPublishingOptions { | ||
/** | ||
|
@@ -113,6 +118,10 @@ export class AssetPublishing implements IPublishProgress { | |
}, | ||
dockerFactory: new DockerFactory(), | ||
}; | ||
if (options.progressListener) { | ||
globalOutputHandler.setListener(options.progressListener); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So what happens if the CDK (or any consumer) instantiates two instances of Right now, this is a subtle bug waiting to happen. |
||
} | ||
globalOutputHandler.setCompletionProgress(this.percentComplete); | ||
} | ||
|
||
/** | ||
|
@@ -249,10 +258,12 @@ export class AssetPublishing implements IPublishProgress { | |
} | ||
|
||
public get percentComplete() { | ||
if (this.totalOperations === 0) { | ||
return 100; | ||
} | ||
return Math.floor((this.completedOperations / this.totalOperations) * 100); | ||
const completionProgress = | ||
this.totalOperations === 0 | ||
? 100 | ||
: Math.floor((this.completedOperations / this.totalOperations) * 100); | ||
globalOutputHandler.setCompletionProgress(completionProgress); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same problem as with setting the listener, breaks on multiple instances of |
||
return completionProgress; | ||
} | ||
|
||
public abort(): void { | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.