@aws-blocks/core 0.1.13 → 0.1.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +180 -17
- package/dist/cdk/blocks-backend.d.ts +4 -0
- package/dist/cdk/blocks-backend.d.ts.map +1 -1
- package/dist/cdk/blocks-backend.js +23 -1
- package/dist/cdk/blocks-backend.test.js +71 -1
- package/dist/cdk/blocks-stack.test.js +32 -1
- package/dist/cdk/index.d.ts +13 -0
- package/dist/cdk/index.d.ts.map +1 -1
- package/dist/cdk/index.js +24 -0
- package/dist/cors.d.ts +27 -1
- package/dist/cors.d.ts.map +1 -1
- package/dist/cors.js +55 -2
- package/dist/cors.test.js +81 -2
- package/dist/errors.test.js +26 -1
- package/dist/hosting.d.ts.map +1 -1
- package/dist/hosting.js +26 -1
- package/dist/hosting.test.js +73 -0
- package/dist/lambda-handler.d.ts.map +1 -1
- package/dist/lambda-handler.js +4 -17
- package/dist/lambda-handler.test.js +59 -2
- package/dist/rpc.test.js +77 -1
- package/dist/scripts/console.d.ts.map +1 -1
- package/dist/scripts/console.js +30 -2
- package/dist/scripts/deploy-stream.d.ts +181 -0
- package/dist/scripts/deploy-stream.d.ts.map +1 -0
- package/dist/scripts/deploy-stream.js +332 -0
- package/dist/scripts/deploy-stream.test.d.ts +2 -0
- package/dist/scripts/deploy-stream.test.d.ts.map +1 -0
- package/dist/scripts/deploy-stream.test.js +845 -0
- package/dist/scripts/deploy.d.ts.map +1 -1
- package/dist/scripts/deploy.js +16 -9
- package/dist/scripts/dev-server-cors.test.js +19 -1
- package/dist/scripts/dev-server-rpc.test.js +50 -0
- package/dist/scripts/dev-server.d.ts +8 -0
- package/dist/scripts/dev-server.d.ts.map +1 -1
- package/dist/scripts/dev-server.js +35 -8
- package/dist/scripts/sandbox.js +1 -1
- package/dist/telemetry/client.js +4 -4
- package/dist/telemetry/telemetry-send-worker.js +4 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +10 -1
- package/src/cdk/blocks-backend.test.ts +90 -1
- package/src/cdk/blocks-backend.ts +24 -1
- package/src/cdk/blocks-stack.test.ts +41 -1
- package/src/cdk/index.ts +25 -0
- package/src/cors.test.ts +96 -2
- package/src/cors.ts +59 -2
- package/src/errors.test.ts +29 -1
- package/src/hosting.test.ts +107 -0
- package/src/hosting.ts +27 -1
- package/src/lambda-handler.test.ts +71 -2
- package/src/lambda-handler.ts +4 -20
- package/src/rpc.test.ts +96 -1
- package/src/scripts/console.ts +29 -2
- package/src/scripts/deploy-stream.test.ts +1035 -0
- package/src/scripts/deploy-stream.ts +475 -0
- package/src/scripts/deploy.ts +18 -11
- package/src/scripts/dev-server-cors.test.ts +26 -1
- package/src/scripts/dev-server-rpc.test.ts +54 -0
- package/src/scripts/dev-server.ts +38 -8
- package/src/scripts/sandbox.ts +1 -1
- package/src/telemetry/client.ts +4 -4
- package/src/telemetry/telemetry-send-worker.ts +5 -0
- package/src/version.ts +1 -1
package/dist/scripts/console.js
CHANGED
|
@@ -3,6 +3,34 @@
|
|
|
3
3
|
import { execFileSync } from 'node:child_process';
|
|
4
4
|
import { readFileSync } from 'node:fs';
|
|
5
5
|
import { trackCommand } from '../telemetry/trackCommand.js';
|
|
6
|
+
function resolveRegion() {
|
|
7
|
+
const fromEnv = process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION;
|
|
8
|
+
if (fromEnv)
|
|
9
|
+
return fromEnv;
|
|
10
|
+
try {
|
|
11
|
+
const fromConfig = execFileSync('aws', ['configure', 'get', 'region'], { encoding: 'utf-8' }).trim();
|
|
12
|
+
if (fromConfig)
|
|
13
|
+
return fromConfig;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
// aws CLI not configured — fall through to default.
|
|
17
|
+
}
|
|
18
|
+
return 'us-east-1';
|
|
19
|
+
}
|
|
20
|
+
/** Launch the URL in the default browser. Best-effort: no opener (headless/CI) is not a failure. */
|
|
21
|
+
function openInBrowser(url) {
|
|
22
|
+
const opener = process.platform === 'darwin' ? 'open' :
|
|
23
|
+
process.platform === 'win32' ? 'cmd' :
|
|
24
|
+
'xdg-open';
|
|
25
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
26
|
+
try {
|
|
27
|
+
execFileSync(opener, args, { stdio: 'ignore' });
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// Headless environment (CI, remote shell) — the URL is already printed above.
|
|
31
|
+
console.log('(Could not launch a browser automatically — open the URL above manually.)');
|
|
32
|
+
}
|
|
33
|
+
}
|
|
6
34
|
export async function openConsole(options) {
|
|
7
35
|
return trackCommand('console', async () => {
|
|
8
36
|
let stackName;
|
|
@@ -16,10 +44,10 @@ export async function openConsole(options) {
|
|
|
16
44
|
else {
|
|
17
45
|
throw new Error('Must provide either stackId or outputsFile');
|
|
18
46
|
}
|
|
19
|
-
const region =
|
|
47
|
+
const region = resolveRegion();
|
|
20
48
|
const stackUrl = `https://${region}.console.aws.amazon.com/cloudformation/home?region=${region}#/stacks?filteringText=${encodeURIComponent(stackName)}`;
|
|
21
49
|
console.log('Opening AWS Console...');
|
|
22
50
|
console.log(stackUrl);
|
|
23
|
-
|
|
51
|
+
openInBrowser(stackUrl);
|
|
24
52
|
});
|
|
25
53
|
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/** What to do with a signal that arrives while a deploy is in flight. */
|
|
2
|
+
export type DeploySignalAction = 'defer' | 'abort';
|
|
3
|
+
export interface DeploySignalResponse {
|
|
4
|
+
action: DeploySignalAction;
|
|
5
|
+
/** Operator-facing line explaining what happened and how to force an abort. */
|
|
6
|
+
message: string;
|
|
7
|
+
/**
|
|
8
|
+
* True when this signal is a duplicate delivery of one the operator already
|
|
9
|
+
* sent (see {@link SIGNAL_COALESCE_MS}), so the caller can skip logging it
|
|
10
|
+
* twice.
|
|
11
|
+
*/
|
|
12
|
+
coalesced?: boolean;
|
|
13
|
+
}
|
|
14
|
+
/** Signals whose delivery we take over while a deploy is in flight. */
|
|
15
|
+
export declare const DEPLOY_SIGNALS: readonly NodeJS.Signals[];
|
|
16
|
+
/** Default gap (ms) of silence after which the runner prints a progress heartbeat. */
|
|
17
|
+
export declare const DEFAULT_HEARTBEAT_MS = 30000;
|
|
18
|
+
/** Grace (ms) given to the child tree to exit after an operator-requested abort. */
|
|
19
|
+
export declare const ABORT_GRACE_MS = 10000;
|
|
20
|
+
/**
|
|
21
|
+
* Grace (ms) we wait after the child exits for its stdout/stderr pipes to end,
|
|
22
|
+
* so the last CloudFormation lines are relayed before we resolve. Bounded
|
|
23
|
+
* because a lingering grandchild could hold the pipe open forever; losing a
|
|
24
|
+
* trailing line is strictly better than hanging a finished deploy.
|
|
25
|
+
*/
|
|
26
|
+
export declare const STREAM_FLUSH_GRACE_MS = 2000;
|
|
27
|
+
/**
|
|
28
|
+
* Window (ms) in which repeated SIGTERMs count as ONE operator request.
|
|
29
|
+
*
|
|
30
|
+
* A single external signal reaches this process more than once. `npm run deploy`
|
|
31
|
+
* runs `npm -> sh -> tsx -> node`, so a process-group SIGTERM is delivered to
|
|
32
|
+
* the node process directly AND relayed to it a second time by `tsx`, which
|
|
33
|
+
* forwards SIGTERM/SIGINT to its child. Measured on a real deploy: one
|
|
34
|
+
* `kill -TERM -<pgid>` produced two SIGTERMs about 50ms apart. Without this
|
|
35
|
+
* window the second delivery is read as "the operator insisted" and the deploy
|
|
36
|
+
* is abandoned, which is the exact failure this module exists to prevent.
|
|
37
|
+
*
|
|
38
|
+
* 2s is far longer than a delivery burst (tens of ms) and far shorter than a
|
|
39
|
+
* deliberate repeat (a human running `kill` twice, or a supervisor's
|
|
40
|
+
* SIGTERM-then-escalate cycle), so both intents stay distinguishable.
|
|
41
|
+
*/
|
|
42
|
+
export declare const SIGNAL_COALESCE_MS = 2000;
|
|
43
|
+
/**
|
|
44
|
+
* Decide how to answer a signal that arrives while CloudFormation is still
|
|
45
|
+
* converging. This is the whole "decouple the CLI lifecycle from the in-flight
|
|
46
|
+
* deploy" policy, kept pure so it can be asserted directly.
|
|
47
|
+
*
|
|
48
|
+
* - `SIGHUP` → always `defer`. A hangup means the terminal or parent shell went
|
|
49
|
+
* away (a backgrounded `npm run deploy &`, a closed SSH session). The deploy
|
|
50
|
+
* is server-side work that is already paid for; killing the CLI here is what
|
|
51
|
+
* produced the phantom failures, so we keep streaming instead. Duplicate
|
|
52
|
+
* deliveries inside {@link SIGNAL_COALESCE_MS} are coalesced so one hangup
|
|
53
|
+
* logs one line, not one per delivery.
|
|
54
|
+
* - `SIGTERM` → `defer` the first time. A lone SIGTERM is almost always
|
|
55
|
+
* process-group collateral (a harness reaping the parent shell, a supervisor
|
|
56
|
+
* tidying up) rather than a deliberate "stop the deploy", so it only warns.
|
|
57
|
+
* Repeats inside {@link SIGNAL_COALESCE_MS} are duplicate *deliveries* of that
|
|
58
|
+
* same signal (the group delivers it, then `tsx` relays it) and are coalesced
|
|
59
|
+
* into the first. A SIGTERM after that window is a deliberate repeat and
|
|
60
|
+
* aborts. A SIGKILL follow-up (`docker stop`, most CI cancels) is uncatchable
|
|
61
|
+
* and still ends the process immediately, so this cannot wedge a shutdown.
|
|
62
|
+
* - `SIGINT` → always `abort`. Ctrl-C is unambiguous, interactive intent, so it
|
|
63
|
+
* stays responsive on the first press.
|
|
64
|
+
*
|
|
65
|
+
* @param signal - the signal received.
|
|
66
|
+
* @param msSinceFirstDeferral - ms since the first deferral of *this* signal, or
|
|
67
|
+
* `null` when this signal has not been deferred yet. Each signal is tracked
|
|
68
|
+
* separately, so a deferred SIGHUP never consumes the SIGTERM abort budget
|
|
69
|
+
* (and a repeated SIGHUP is deduped the same way a repeated SIGTERM is).
|
|
70
|
+
* @param coalesceWindowMs - see {@link SIGNAL_COALESCE_MS}.
|
|
71
|
+
*/
|
|
72
|
+
export declare function decideSignalResponse(signal: NodeJS.Signals, msSinceFirstDeferral: number | null, coalesceWindowMs?: number): DeploySignalResponse;
|
|
73
|
+
/**
|
|
74
|
+
* Split a byte stream into whole lines across chunk boundaries.
|
|
75
|
+
*
|
|
76
|
+
* The child's output arrives in arbitrary chunks, so a naive
|
|
77
|
+
* `chunk.toString().split('\n')` emits torn lines (and drops the tail). This
|
|
78
|
+
* keeps the partial trailing line buffered until it completes; {@link flush}
|
|
79
|
+
* returns whatever is left when the stream ends (CDK's final line has no
|
|
80
|
+
* trailing newline).
|
|
81
|
+
*/
|
|
82
|
+
export declare function createLineAssembler(): {
|
|
83
|
+
push(chunk: string): string[];
|
|
84
|
+
flush(): string[];
|
|
85
|
+
};
|
|
86
|
+
/** Human-readable elapsed time (`45s`, `4m 05s`) for progress lines. */
|
|
87
|
+
export declare function formatElapsed(ms: number): string;
|
|
88
|
+
export interface CdkDeployArgsOptions {
|
|
89
|
+
/** Project root passed to synth as `--context projectRoot=…`. */
|
|
90
|
+
projectRoot: string;
|
|
91
|
+
/** Path (relative to the project root) CDK writes stack outputs to. */
|
|
92
|
+
outputsFile: string;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Build the `cdk deploy` argv used by `npm run deploy`.
|
|
96
|
+
*
|
|
97
|
+
* Two of these flags exist purely so the deploy is observable — losing either
|
|
98
|
+
* one brings back the 0-byte stdout:
|
|
99
|
+
*
|
|
100
|
+
* - `--ci`: the CDK CLI picks its log stream as `isCI ? stdout : stderr`, so
|
|
101
|
+
* without it every CloudFormation event goes to **stderr** and a caller
|
|
102
|
+
* capturing stdout (`npm run deploy > deploy.log`) sees nothing for the whole
|
|
103
|
+
* multi-minute deploy. With it, progress goes to stdout and only error-level
|
|
104
|
+
* messages stay on stderr.
|
|
105
|
+
* - `--progress events`: print one line per resource transition instead of the
|
|
106
|
+
* redrawing progress bar. The bar needs a TTY, which a piped/backgrounded
|
|
107
|
+
* deploy does not have, and a half-rendered bar is not a usable progress
|
|
108
|
+
* signal in a log file.
|
|
109
|
+
*/
|
|
110
|
+
export declare function buildCdkDeployArgs({ projectRoot, outputsFile }: CdkDeployArgsOptions): string[];
|
|
111
|
+
/** Minimal sink surface so tests can capture the relayed streams. */
|
|
112
|
+
export interface OutputSink {
|
|
113
|
+
write(chunk: string): unknown;
|
|
114
|
+
}
|
|
115
|
+
/** Minimal signal-registration surface ({@link process} satisfies it). */
|
|
116
|
+
export interface SignalRegistry {
|
|
117
|
+
on(signal: NodeJS.Signals, handler: () => void): unknown;
|
|
118
|
+
off(signal: NodeJS.Signals, handler: () => void): unknown;
|
|
119
|
+
}
|
|
120
|
+
export interface RunStreamingOptions {
|
|
121
|
+
cwd?: string;
|
|
122
|
+
env?: NodeJS.ProcessEnv;
|
|
123
|
+
/** Prefix used by the runner's own progress/status lines. Defaults to `deploy`. */
|
|
124
|
+
label?: string;
|
|
125
|
+
/** Idle gap before a heartbeat line is printed. `0` disables heartbeats. */
|
|
126
|
+
heartbeatMs?: number;
|
|
127
|
+
/** Where child stdout and runner progress lines go. Defaults to `process.stdout`. */
|
|
128
|
+
stdout?: OutputSink;
|
|
129
|
+
/** Where child stderr goes. Defaults to `process.stderr`. */
|
|
130
|
+
stderr?: OutputSink;
|
|
131
|
+
/** Injected for tests. */
|
|
132
|
+
now?: () => number;
|
|
133
|
+
/** Injected for tests: signal registration seam. */
|
|
134
|
+
signalTarget?: SignalRegistry;
|
|
135
|
+
}
|
|
136
|
+
/** Raised when the child exits non-zero, is killed, or the operator aborts. */
|
|
137
|
+
export declare class DeployProcessError extends Error {
|
|
138
|
+
readonly exitCode: number | null;
|
|
139
|
+
readonly signal: NodeJS.Signals | null;
|
|
140
|
+
readonly aborted: boolean;
|
|
141
|
+
constructor(message: string, details?: {
|
|
142
|
+
exitCode?: number | null;
|
|
143
|
+
signal?: NodeJS.Signals | null;
|
|
144
|
+
aborted?: boolean;
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Run a long deployment command, relaying its output line by line as it happens
|
|
149
|
+
* and keeping it alive across a stray SIGTERM/SIGHUP.
|
|
150
|
+
*
|
|
151
|
+
* Behaviour that matters to callers:
|
|
152
|
+
* - **Streamed, non-empty stdout.** Child stdout is relayed to `stdout` the
|
|
153
|
+
* moment a line completes (never buffered until exit) and child stderr to
|
|
154
|
+
* `stderr`, so `npm run deploy | tee` shows CloudFormation progress live.
|
|
155
|
+
* - **Idle heartbeat.** While the child is silent for `heartbeatMs`, a
|
|
156
|
+
* `still deploying` line with elapsed time is written to `stdout`, so a
|
|
157
|
+
* ten-minute RDS resource never looks like a hung process.
|
|
158
|
+
* - **Own process group (POSIX only).** The child is spawned `detached` on
|
|
159
|
+
* POSIX, so a process-group signal aimed at the parent shell
|
|
160
|
+
* (`kill -TERM -pgid`, a harness reaping a backgrounded job) cannot kill the
|
|
161
|
+
* CDK CLI behind our back; this runner is the only thing that signals it.
|
|
162
|
+
* Windows has neither process groups nor OS-delivered SIGTERM/SIGHUP, so the
|
|
163
|
+
* signal resilience below does not apply there — a `taskkill` on the tree ends
|
|
164
|
+
* the deploy, and the abort path reaps by pid via `terminateProcessTree`.
|
|
165
|
+
* - **No stdin.** The child gets `ignore` for stdin so a backgrounded deploy
|
|
166
|
+
* can never be stopped by SIGTTIN trying to read a terminal it no longer
|
|
167
|
+
* owns; the caller must keep passing `--require-approval never`.
|
|
168
|
+
* - **Signal policy (POSIX).** See {@link decideSignalResponse}: a deferred
|
|
169
|
+
* signal only logs (which itself doubles as a progress signal on stdout),
|
|
170
|
+
* while an abort reaps the child tree and throws a {@link DeployProcessError}
|
|
171
|
+
* with `aborted: true`. Repeat deliveries of the same signal inside
|
|
172
|
+
* {@link SIGNAL_COALESCE_MS} log once, not once per delivery.
|
|
173
|
+
* - **Streams stay separated.** Child stdout and child stderr are relayed to
|
|
174
|
+
* their own sinks and never merged, so a deploy failure reason (which the CDK
|
|
175
|
+
* CLI keeps on stderr even under `--ci`) stays on stderr while progress is on
|
|
176
|
+
* stdout.
|
|
177
|
+
*
|
|
178
|
+
* Resolves when the child exits 0; otherwise throws {@link DeployProcessError}.
|
|
179
|
+
*/
|
|
180
|
+
export declare function runStreaming(command: string, args: string[], options?: RunStreamingOptions): Promise<void>;
|
|
181
|
+
//# sourceMappingURL=deploy-stream.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"deploy-stream.d.ts","sourceRoot":"","sources":["../../src/scripts/deploy-stream.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import { spawnCommand } from './run-command.js';
|
|
4
|
+
import { terminateProcessTree } from './process-tree.js';
|
|
5
|
+
/** Signals whose delivery we take over while a deploy is in flight. */
|
|
6
|
+
export const DEPLOY_SIGNALS = ['SIGTERM', 'SIGINT', 'SIGHUP'];
|
|
7
|
+
/** Default gap (ms) of silence after which the runner prints a progress heartbeat. */
|
|
8
|
+
export const DEFAULT_HEARTBEAT_MS = 30_000;
|
|
9
|
+
/** Grace (ms) given to the child tree to exit after an operator-requested abort. */
|
|
10
|
+
export const ABORT_GRACE_MS = 10_000;
|
|
11
|
+
/**
|
|
12
|
+
* Grace (ms) we wait after the child exits for its stdout/stderr pipes to end,
|
|
13
|
+
* so the last CloudFormation lines are relayed before we resolve. Bounded
|
|
14
|
+
* because a lingering grandchild could hold the pipe open forever; losing a
|
|
15
|
+
* trailing line is strictly better than hanging a finished deploy.
|
|
16
|
+
*/
|
|
17
|
+
export const STREAM_FLUSH_GRACE_MS = 2_000;
|
|
18
|
+
/**
|
|
19
|
+
* Window (ms) in which repeated SIGTERMs count as ONE operator request.
|
|
20
|
+
*
|
|
21
|
+
* A single external signal reaches this process more than once. `npm run deploy`
|
|
22
|
+
* runs `npm -> sh -> tsx -> node`, so a process-group SIGTERM is delivered to
|
|
23
|
+
* the node process directly AND relayed to it a second time by `tsx`, which
|
|
24
|
+
* forwards SIGTERM/SIGINT to its child. Measured on a real deploy: one
|
|
25
|
+
* `kill -TERM -<pgid>` produced two SIGTERMs about 50ms apart. Without this
|
|
26
|
+
* window the second delivery is read as "the operator insisted" and the deploy
|
|
27
|
+
* is abandoned, which is the exact failure this module exists to prevent.
|
|
28
|
+
*
|
|
29
|
+
* 2s is far longer than a delivery burst (tens of ms) and far shorter than a
|
|
30
|
+
* deliberate repeat (a human running `kill` twice, or a supervisor's
|
|
31
|
+
* SIGTERM-then-escalate cycle), so both intents stay distinguishable.
|
|
32
|
+
*/
|
|
33
|
+
export const SIGNAL_COALESCE_MS = 2_000;
|
|
34
|
+
/**
|
|
35
|
+
* Decide how to answer a signal that arrives while CloudFormation is still
|
|
36
|
+
* converging. This is the whole "decouple the CLI lifecycle from the in-flight
|
|
37
|
+
* deploy" policy, kept pure so it can be asserted directly.
|
|
38
|
+
*
|
|
39
|
+
* - `SIGHUP` → always `defer`. A hangup means the terminal or parent shell went
|
|
40
|
+
* away (a backgrounded `npm run deploy &`, a closed SSH session). The deploy
|
|
41
|
+
* is server-side work that is already paid for; killing the CLI here is what
|
|
42
|
+
* produced the phantom failures, so we keep streaming instead. Duplicate
|
|
43
|
+
* deliveries inside {@link SIGNAL_COALESCE_MS} are coalesced so one hangup
|
|
44
|
+
* logs one line, not one per delivery.
|
|
45
|
+
* - `SIGTERM` → `defer` the first time. A lone SIGTERM is almost always
|
|
46
|
+
* process-group collateral (a harness reaping the parent shell, a supervisor
|
|
47
|
+
* tidying up) rather than a deliberate "stop the deploy", so it only warns.
|
|
48
|
+
* Repeats inside {@link SIGNAL_COALESCE_MS} are duplicate *deliveries* of that
|
|
49
|
+
* same signal (the group delivers it, then `tsx` relays it) and are coalesced
|
|
50
|
+
* into the first. A SIGTERM after that window is a deliberate repeat and
|
|
51
|
+
* aborts. A SIGKILL follow-up (`docker stop`, most CI cancels) is uncatchable
|
|
52
|
+
* and still ends the process immediately, so this cannot wedge a shutdown.
|
|
53
|
+
* - `SIGINT` → always `abort`. Ctrl-C is unambiguous, interactive intent, so it
|
|
54
|
+
* stays responsive on the first press.
|
|
55
|
+
*
|
|
56
|
+
* @param signal - the signal received.
|
|
57
|
+
* @param msSinceFirstDeferral - ms since the first deferral of *this* signal, or
|
|
58
|
+
* `null` when this signal has not been deferred yet. Each signal is tracked
|
|
59
|
+
* separately, so a deferred SIGHUP never consumes the SIGTERM abort budget
|
|
60
|
+
* (and a repeated SIGHUP is deduped the same way a repeated SIGTERM is).
|
|
61
|
+
* @param coalesceWindowMs - see {@link SIGNAL_COALESCE_MS}.
|
|
62
|
+
*/
|
|
63
|
+
export function decideSignalResponse(signal, msSinceFirstDeferral, coalesceWindowMs = SIGNAL_COALESCE_MS) {
|
|
64
|
+
if (signal === 'SIGINT') {
|
|
65
|
+
return {
|
|
66
|
+
action: 'abort',
|
|
67
|
+
message: '\n🛑 Interrupted. Stopping the CDK CLI — CloudFormation may keep applying the change set server-side.',
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
if (signal === 'SIGHUP') {
|
|
71
|
+
return {
|
|
72
|
+
action: 'defer',
|
|
73
|
+
message: '⚠️ Ignoring SIGHUP: the deploy is still running and CloudFormation is still converging. Streaming continues; send SIGTERM twice to abort.',
|
|
74
|
+
coalesced: msSinceFirstDeferral !== null && msSinceFirstDeferral < coalesceWindowMs,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
if (signal === 'SIGTERM' && msSinceFirstDeferral === null) {
|
|
78
|
+
return {
|
|
79
|
+
action: 'defer',
|
|
80
|
+
message: '⚠️ Ignoring SIGTERM: the deploy is still running and CloudFormation is still converging. Send SIGTERM again to abort (the stack update continues server-side either way).',
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if (signal === 'SIGTERM' && msSinceFirstDeferral !== null && msSinceFirstDeferral < coalesceWindowMs) {
|
|
84
|
+
// Same signal reaching us twice (process group + a wrapper's relay), not a
|
|
85
|
+
// second request — keep deploying and stay quiet about the duplicate.
|
|
86
|
+
return {
|
|
87
|
+
action: 'defer',
|
|
88
|
+
message: '',
|
|
89
|
+
coalesced: true,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
action: 'abort',
|
|
94
|
+
message: `\n🛑 Received ${signal} while deploying. Stopping the CDK CLI — CloudFormation may keep applying the change set server-side.`,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Split a byte stream into whole lines across chunk boundaries.
|
|
99
|
+
*
|
|
100
|
+
* The child's output arrives in arbitrary chunks, so a naive
|
|
101
|
+
* `chunk.toString().split('\n')` emits torn lines (and drops the tail). This
|
|
102
|
+
* keeps the partial trailing line buffered until it completes; {@link flush}
|
|
103
|
+
* returns whatever is left when the stream ends (CDK's final line has no
|
|
104
|
+
* trailing newline).
|
|
105
|
+
*/
|
|
106
|
+
export function createLineAssembler() {
|
|
107
|
+
let buffered = '';
|
|
108
|
+
return {
|
|
109
|
+
push(chunk) {
|
|
110
|
+
buffered += chunk;
|
|
111
|
+
const lines = buffered.split('\n');
|
|
112
|
+
buffered = lines.pop() ?? '';
|
|
113
|
+
return lines.map((line) => (line.endsWith('\r') ? line.slice(0, -1) : line));
|
|
114
|
+
},
|
|
115
|
+
flush() {
|
|
116
|
+
if (!buffered)
|
|
117
|
+
return [];
|
|
118
|
+
const last = buffered.endsWith('\r') ? buffered.slice(0, -1) : buffered;
|
|
119
|
+
buffered = '';
|
|
120
|
+
return last ? [last] : [];
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/** Human-readable elapsed time (`45s`, `4m 05s`) for progress lines. */
|
|
125
|
+
export function formatElapsed(ms) {
|
|
126
|
+
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
|
127
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
128
|
+
const seconds = totalSeconds % 60;
|
|
129
|
+
return minutes > 0 ? `${minutes}m ${String(seconds).padStart(2, '0')}s` : `${seconds}s`;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Build the `cdk deploy` argv used by `npm run deploy`.
|
|
133
|
+
*
|
|
134
|
+
* Two of these flags exist purely so the deploy is observable — losing either
|
|
135
|
+
* one brings back the 0-byte stdout:
|
|
136
|
+
*
|
|
137
|
+
* - `--ci`: the CDK CLI picks its log stream as `isCI ? stdout : stderr`, so
|
|
138
|
+
* without it every CloudFormation event goes to **stderr** and a caller
|
|
139
|
+
* capturing stdout (`npm run deploy > deploy.log`) sees nothing for the whole
|
|
140
|
+
* multi-minute deploy. With it, progress goes to stdout and only error-level
|
|
141
|
+
* messages stay on stderr.
|
|
142
|
+
* - `--progress events`: print one line per resource transition instead of the
|
|
143
|
+
* redrawing progress bar. The bar needs a TTY, which a piped/backgrounded
|
|
144
|
+
* deploy does not have, and a half-rendered bar is not a usable progress
|
|
145
|
+
* signal in a log file.
|
|
146
|
+
*/
|
|
147
|
+
export function buildCdkDeployArgs({ projectRoot, outputsFile }) {
|
|
148
|
+
return [
|
|
149
|
+
'cdk',
|
|
150
|
+
'deploy',
|
|
151
|
+
'--require-approval',
|
|
152
|
+
'never',
|
|
153
|
+
'--ci',
|
|
154
|
+
'--progress',
|
|
155
|
+
'events',
|
|
156
|
+
'--outputs-file',
|
|
157
|
+
outputsFile,
|
|
158
|
+
'--context',
|
|
159
|
+
`projectRoot=${projectRoot}`,
|
|
160
|
+
];
|
|
161
|
+
}
|
|
162
|
+
/** Unref'd sleep: a pending flush grace must never keep the process alive. */
|
|
163
|
+
function delay(ms) {
|
|
164
|
+
return new Promise((resolve) => {
|
|
165
|
+
setTimeout(resolve, ms).unref?.();
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
/** Raised when the child exits non-zero, is killed, or the operator aborts. */
|
|
169
|
+
export class DeployProcessError extends Error {
|
|
170
|
+
exitCode;
|
|
171
|
+
signal;
|
|
172
|
+
aborted;
|
|
173
|
+
constructor(message, details = {}) {
|
|
174
|
+
super(message);
|
|
175
|
+
this.name = 'DeployProcessError';
|
|
176
|
+
this.exitCode = details.exitCode ?? null;
|
|
177
|
+
this.signal = details.signal ?? null;
|
|
178
|
+
this.aborted = details.aborted ?? false;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Run a long deployment command, relaying its output line by line as it happens
|
|
183
|
+
* and keeping it alive across a stray SIGTERM/SIGHUP.
|
|
184
|
+
*
|
|
185
|
+
* Behaviour that matters to callers:
|
|
186
|
+
* - **Streamed, non-empty stdout.** Child stdout is relayed to `stdout` the
|
|
187
|
+
* moment a line completes (never buffered until exit) and child stderr to
|
|
188
|
+
* `stderr`, so `npm run deploy | tee` shows CloudFormation progress live.
|
|
189
|
+
* - **Idle heartbeat.** While the child is silent for `heartbeatMs`, a
|
|
190
|
+
* `still deploying` line with elapsed time is written to `stdout`, so a
|
|
191
|
+
* ten-minute RDS resource never looks like a hung process.
|
|
192
|
+
* - **Own process group (POSIX only).** The child is spawned `detached` on
|
|
193
|
+
* POSIX, so a process-group signal aimed at the parent shell
|
|
194
|
+
* (`kill -TERM -pgid`, a harness reaping a backgrounded job) cannot kill the
|
|
195
|
+
* CDK CLI behind our back; this runner is the only thing that signals it.
|
|
196
|
+
* Windows has neither process groups nor OS-delivered SIGTERM/SIGHUP, so the
|
|
197
|
+
* signal resilience below does not apply there — a `taskkill` on the tree ends
|
|
198
|
+
* the deploy, and the abort path reaps by pid via `terminateProcessTree`.
|
|
199
|
+
* - **No stdin.** The child gets `ignore` for stdin so a backgrounded deploy
|
|
200
|
+
* can never be stopped by SIGTTIN trying to read a terminal it no longer
|
|
201
|
+
* owns; the caller must keep passing `--require-approval never`.
|
|
202
|
+
* - **Signal policy (POSIX).** See {@link decideSignalResponse}: a deferred
|
|
203
|
+
* signal only logs (which itself doubles as a progress signal on stdout),
|
|
204
|
+
* while an abort reaps the child tree and throws a {@link DeployProcessError}
|
|
205
|
+
* with `aborted: true`. Repeat deliveries of the same signal inside
|
|
206
|
+
* {@link SIGNAL_COALESCE_MS} log once, not once per delivery.
|
|
207
|
+
* - **Streams stay separated.** Child stdout and child stderr are relayed to
|
|
208
|
+
* their own sinks and never merged, so a deploy failure reason (which the CDK
|
|
209
|
+
* CLI keeps on stderr even under `--ci`) stays on stderr while progress is on
|
|
210
|
+
* stdout.
|
|
211
|
+
*
|
|
212
|
+
* Resolves when the child exits 0; otherwise throws {@link DeployProcessError}.
|
|
213
|
+
*/
|
|
214
|
+
export async function runStreaming(command, args, options = {}) {
|
|
215
|
+
const { cwd, env, label = 'deploy', heartbeatMs = DEFAULT_HEARTBEAT_MS, stdout = process.stdout, stderr = process.stderr, now = Date.now, signalTarget = process, } = options;
|
|
216
|
+
const startedAt = now();
|
|
217
|
+
let lastOutputAt = startedAt;
|
|
218
|
+
let aborting = false;
|
|
219
|
+
let exitObserved = false;
|
|
220
|
+
// Per signal, when its current deferral window opened. Kept per signal so a
|
|
221
|
+
// deferred SIGHUP neither consumes the SIGTERM abort budget nor silently
|
|
222
|
+
// swallows its own log line.
|
|
223
|
+
const deferredAt = new Map();
|
|
224
|
+
const child = spawnCommand(command, args, {
|
|
225
|
+
cwd,
|
|
226
|
+
env,
|
|
227
|
+
// stdin ignored (see doc comment); stdout/stderr piped so we can relay them
|
|
228
|
+
// line by line instead of handing the child our fds and going blind.
|
|
229
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
230
|
+
detached: process.platform !== 'win32',
|
|
231
|
+
});
|
|
232
|
+
const relay = (stream, sink) => {
|
|
233
|
+
if (!stream)
|
|
234
|
+
return;
|
|
235
|
+
const assembler = createLineAssembler();
|
|
236
|
+
stream.setEncoding('utf-8');
|
|
237
|
+
stream.on('data', (chunk) => {
|
|
238
|
+
lastOutputAt = now();
|
|
239
|
+
for (const line of assembler.push(chunk))
|
|
240
|
+
sink.write(`${line}\n`);
|
|
241
|
+
});
|
|
242
|
+
stream.on('end', () => {
|
|
243
|
+
for (const line of assembler.flush())
|
|
244
|
+
sink.write(`${line}\n`);
|
|
245
|
+
});
|
|
246
|
+
};
|
|
247
|
+
relay(child.stdout, stdout);
|
|
248
|
+
relay(child.stderr, stderr);
|
|
249
|
+
const heartbeat = heartbeatMs > 0
|
|
250
|
+
? setInterval(() => {
|
|
251
|
+
if (now() - lastOutputAt < heartbeatMs)
|
|
252
|
+
return;
|
|
253
|
+
stdout.write(`⏳ [${label}] still running after ${formatElapsed(now() - startedAt)} — CloudFormation is converging (pid ${child.pid ?? '?'})\n`);
|
|
254
|
+
}, heartbeatMs)
|
|
255
|
+
: undefined;
|
|
256
|
+
// Never let the heartbeat timer be the reason the process stays alive.
|
|
257
|
+
heartbeat?.unref?.();
|
|
258
|
+
const exited = new Promise((resolve, reject) => {
|
|
259
|
+
child.once('error', reject);
|
|
260
|
+
child.once('exit', (code, signal) => {
|
|
261
|
+
exitObserved = true;
|
|
262
|
+
resolve({ code, signal });
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
const streamsEnded = Promise.all([child.stdout, child.stderr].map((stream) => new Promise((resolve) => {
|
|
266
|
+
if (!stream)
|
|
267
|
+
return resolve();
|
|
268
|
+
stream.once('end', () => resolve());
|
|
269
|
+
stream.once('close', () => resolve());
|
|
270
|
+
})));
|
|
271
|
+
const handlers = new Map();
|
|
272
|
+
const removeHandlers = () => {
|
|
273
|
+
for (const [signal, handler] of handlers)
|
|
274
|
+
signalTarget.off(signal, handler);
|
|
275
|
+
handlers.clear();
|
|
276
|
+
};
|
|
277
|
+
for (const signal of DEPLOY_SIGNALS) {
|
|
278
|
+
const handler = () => {
|
|
279
|
+
// The deploy already finished (we are just draining pipes / reporting):
|
|
280
|
+
// there is nothing left to defer or abort, and reporting an abort here
|
|
281
|
+
// would turn a successful deploy into a failure.
|
|
282
|
+
if (exitObserved)
|
|
283
|
+
return;
|
|
284
|
+
const openedAt = deferredAt.get(signal);
|
|
285
|
+
const { action, message, coalesced } = decideSignalResponse(signal, openedAt === undefined ? null : now() - openedAt);
|
|
286
|
+
if (action === 'defer') {
|
|
287
|
+
// A coalesced duplicate is the same signal arriving twice (process group
|
|
288
|
+
// plus a wrapper relay); log it once, not once per delivery. Anything
|
|
289
|
+
// outside the window is a fresh request, so it opens a new window and
|
|
290
|
+
// reports again instead of being muted for the rest of the deploy.
|
|
291
|
+
if (!coalesced) {
|
|
292
|
+
deferredAt.set(signal, now());
|
|
293
|
+
lastOutputAt = now();
|
|
294
|
+
stdout.write(`${message}\n`);
|
|
295
|
+
}
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
if (aborting)
|
|
299
|
+
return; // already tearing down; a repeat signal is a no-op
|
|
300
|
+
aborting = true;
|
|
301
|
+
stdout.write(`${message}\n`);
|
|
302
|
+
// Reap the whole tree (npx → cdk → node), not just the npx parent, so an
|
|
303
|
+
// abort cannot leave an orphaned CDK CLI still driving the stack.
|
|
304
|
+
void terminateProcessTree(child, ABORT_GRACE_MS);
|
|
305
|
+
};
|
|
306
|
+
handlers.set(signal, handler);
|
|
307
|
+
signalTarget.on(signal, handler);
|
|
308
|
+
}
|
|
309
|
+
try {
|
|
310
|
+
const { code, signal } = await exited;
|
|
311
|
+
// Let the pipes drain so a caller reading our stdout sees the child's final
|
|
312
|
+
// lines (CDK's summary / failure reason) before the terminal status.
|
|
313
|
+
await Promise.race([streamsEnded, delay(STREAM_FLUSH_GRACE_MS)]);
|
|
314
|
+
if (aborting) {
|
|
315
|
+
throw new DeployProcessError(`${command} was aborted by an operator signal after ${formatElapsed(now() - startedAt)}`, { exitCode: code, signal, aborted: true });
|
|
316
|
+
}
|
|
317
|
+
if (signal) {
|
|
318
|
+
throw new DeployProcessError(`${command} was terminated by signal ${signal}`, {
|
|
319
|
+
exitCode: code,
|
|
320
|
+
signal,
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
if (code !== 0) {
|
|
324
|
+
throw new DeployProcessError(`${command} ${args.join(' ')} exited with code ${code}`, { exitCode: code });
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
finally {
|
|
328
|
+
if (heartbeat)
|
|
329
|
+
clearInterval(heartbeat);
|
|
330
|
+
removeHandlers();
|
|
331
|
+
}
|
|
332
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"deploy-stream.test.d.ts","sourceRoot":"","sources":["../../src/scripts/deploy-stream.test.ts"],"names":[],"mappings":""}
|