deploy-stack 0.17.12 → 0.17.14
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/.muserules +16 -0
- package/README.md +6 -2
- package/bin/cli.js +3 -0
- package/docs/ROADMAP.md +2 -2
- package/docs/guides/headless.md +1 -0
- package/package.json +3 -1
- package/specs/diagnose.md +16 -0
- package/src/commands/diagnose.js +180 -0
- package/src/utils/aws.js +1 -1
- package/templates/docker/rails.Dockerfile +3 -3
- package/tests/__snapshots__/generator.test.js.snap +3 -3
- package/tests/aws.test.js +85 -0
- package/tests/diagnose.test.js +143 -0
package/.muserules
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# deploy-stack: AI Agent Core Directives
|
|
2
|
+
|
|
3
|
+
## 1. Architectural Boundaries
|
|
4
|
+
- You are strictly forbidden from modifying the user's application code (e.g., `src/`). You only manage infrastructure and deployment scaffolding.
|
|
5
|
+
- All Dockerfiles must remain at **0 CVEs**. Do not introduce unvetted packages.
|
|
6
|
+
- All AWS IAM configurations must use OIDC. Never generate long-lived IAM access keys.
|
|
7
|
+
|
|
8
|
+
## 2. Tech Stack & Formatting
|
|
9
|
+
- **Testing:** Use `vitest` for all unit and snapshot testing.
|
|
10
|
+
- **Terminal UI:** Use `picocolors` for all CLI output. Standardize on green for success, yellow for warnings, red for errors, and blue for info.
|
|
11
|
+
- **CLI Framework:** Use `commander`.
|
|
12
|
+
|
|
13
|
+
## 3. Workflow
|
|
14
|
+
- Read the relevant `specs/` document before writing any code.
|
|
15
|
+
- Run `npm test` continuously to verify your work.
|
|
16
|
+
- Do not conclude your task until the test suite passes completely.
|
package/README.md
CHANGED
|
@@ -89,6 +89,9 @@ The interactive wizard will analyze your codebase, detect your framework, estima
|
|
|
89
89
|
* **`npx deploy-stack doctor`**
|
|
90
90
|
Scans your local environment and generated files to ensure all required dependencies (Docker, Terraform, AWS CLI) are installed and configured correctly.
|
|
91
91
|
|
|
92
|
+
* **`npx deploy-stack diagnose`** (alias: `wtf`)
|
|
93
|
+
Troubleshoots a failing ECS deployment by reporting the most recent stopped task's `stoppedReason`, failing container (with exit code), and the last 50 CloudWatch log lines.
|
|
94
|
+
|
|
92
95
|
* **`npx deploy-stack destroy`**
|
|
93
96
|
Safely tears down your ECS cluster, Load Balancers, and networking resources to stop AWS billing. Includes an interactive prompt to optionally retain or delete your S3 remote state bucket.
|
|
94
97
|
|
|
@@ -97,6 +100,7 @@ The interactive wizard will analyze your codebase, detect your framework, estima
|
|
|
97
100
|
|
|
98
101
|
* **`npx deploy-stack --headless`**
|
|
99
102
|
Bypasses the interactive wizard for fully programmatic execution. Perfect for CI/CD pipelines, custom scripts, or AI agent integration. Accepts flags like `--framework=static`, `--region=us-east-2`, and `--size=micro`.
|
|
103
|
+
Pass `--preconfigured` when invoking via an external schematic or integration (e.g., `nest add nest-deploy-stack`) to suppress framework warnings for pre-validated configs.
|
|
100
104
|
|
|
101
105
|
* **`npx deploy-stack sync-ai`**
|
|
102
106
|
Selectively generates architecture rules for AI coding assistants (Cursor, Copilot, Windsurf, Claude). Automatically extracts your AWS Region and Container Port to prevent Terraform hallucinations.
|
|
@@ -172,8 +176,8 @@ npx deploy-stack --no-telemetry
|
|
|
172
176
|
- [x] `cookiecutter-django-deploy-stack` (Listed on Django Packages)
|
|
173
177
|
- [x] `cookiecutter-fastapi-deploy-stack` (Cookiecutter for modern async Python)
|
|
174
178
|
- [x] `nest-deploy-stack` (Native `nest add` schematic for NestJS)
|
|
175
|
-
- [
|
|
176
|
-
- [
|
|
179
|
+
- [x] `rails-template-deploy-stack` (Zero-click Ruby on Rails application template)
|
|
180
|
+
- [x] **Automated Troubleshooting:** `deploy-stack diagnose` (alias: `wtf`) automatically analyzes common day-2 AWS operational issues (e.g., Fargate OOM kills, ALB 502s) directly from the terminal.
|
|
177
181
|
|
|
178
182
|
👉 **[See the full project history and future plans in ROADMAP.md](./ROADMAP.md)**
|
|
179
183
|
|
package/bin/cli.js
CHANGED
|
@@ -6,6 +6,7 @@ import { runDoctor } from '../src/commands/doctor.js';
|
|
|
6
6
|
import { pushSecrets } from '../src/commands/secrets.js';
|
|
7
7
|
import { ejectStack } from '../src/commands/eject.js';
|
|
8
8
|
import { applyStack } from '../src/commands/apply.js';
|
|
9
|
+
import { runDiagnose } from '../src/commands/diagnose.js';
|
|
9
10
|
import { syncAi } from '../src/commands/sync-ai.js';
|
|
10
11
|
import { parseCliArgs } from '../src/core/parser.js';
|
|
11
12
|
|
|
@@ -33,6 +34,8 @@ if (positionalArgs[0] === 'secrets' && positionalArgs[1] === 'push') {
|
|
|
33
34
|
ejectStack().catch(e => { console.error(e); process.exit(1); });
|
|
34
35
|
} else if (positionalArgs[0] === 'sync-ai') {
|
|
35
36
|
syncAi().catch(e => { console.error(e); process.exit(1); });
|
|
37
|
+
} else if (positionalArgs[0] === 'diagnose' || positionalArgs[0] === 'wtf') {
|
|
38
|
+
runDiagnose().catch(e => { console.error(e); process.exit(1); });
|
|
36
39
|
} else {
|
|
37
40
|
mainStack({ isHeadless, headlessOptions }).catch(e => { console.error(e); process.exit(1); });
|
|
38
41
|
}
|
package/docs/ROADMAP.md
CHANGED
|
@@ -32,5 +32,5 @@
|
|
|
32
32
|
- [x] `cookiecutter-django-deploy-stack` (Listed on Django Packages)
|
|
33
33
|
- [x] `cookiecutter-fastapi-deploy-stack` (Cookiecutter for modern async Python)
|
|
34
34
|
- [x] `nest-deploy-stack` (Native `nest add` schematic for NestJS)
|
|
35
|
-
- [
|
|
36
|
-
- [
|
|
35
|
+
- [x] `rails-template-deploy-stack` (Zero-click Ruby on Rails application template)
|
|
36
|
+
- [x] **Automated Troubleshooting:** `deploy-stack diagnose` (alias: `wtf`) automatically analyzes common day-2 AWS operational issues (e.g., Fargate OOM kills, ALB 502s) directly from the terminal.
|
package/docs/guides/headless.md
CHANGED
|
@@ -28,6 +28,7 @@ You can append any of these flags to customize the generated architecture. These
|
|
|
28
28
|
| `--enablePrPreviews` | Generates workflows for Ephemeral PR Previews. | `false` |
|
|
29
29
|
| `--yes` | Automatically bypasses confirmation prompts during apply/destroy. | `false` |
|
|
30
30
|
| `--no-telemetry` | Disables anonymous usage analytics. | `false` |
|
|
31
|
+
| `--preconfigured` | Suppresses framework warnings for pre-validated configs from external schematics/integrations (e.g., `nest add`). | `false` |
|
|
31
32
|
|
|
32
33
|
*(Note: Boolean flags like `--needsDatabase` and `--enablePrPreviews` can be passed alone or as `--flag=true`).*
|
|
33
34
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "deploy-stack",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.14",
|
|
4
4
|
"description": "Provision production-ready AWS infrastructure and CI/CD pipelines in seconds.",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=18.0.0"
|
|
@@ -42,7 +42,9 @@
|
|
|
42
42
|
],
|
|
43
43
|
"license": "MIT",
|
|
44
44
|
"dependencies": {
|
|
45
|
+
"@aws-sdk/client-cloudwatch-logs": "3.1119.0",
|
|
45
46
|
"@aws-sdk/client-dynamodb": "3.1119.0",
|
|
47
|
+
"@aws-sdk/client-ecs": "3.1119.0",
|
|
46
48
|
"@aws-sdk/client-s3": "3.1115.0",
|
|
47
49
|
"@aws-sdk/client-secrets-manager": "3.1112.0",
|
|
48
50
|
"@aws-sdk/client-sts": "3.1115.0",
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Spec: `deploy-stack diagnose` (alias: `wtf`)
|
|
2
|
+
|
|
3
|
+
## Objective
|
|
4
|
+
Build a new CLI command that automatically queries AWS to troubleshoot why a deployed application is failing (e.g., container crash loops, ALB 502 Bad Gateway).
|
|
5
|
+
|
|
6
|
+
## Requirements
|
|
7
|
+
1. **Command Registration:** Register `diagnose` and its alias `wtf` in `bin/cli.js` using Commander.
|
|
8
|
+
2. **AWS SDK Integration:** Use `@aws-sdk/client-ecs` and `@aws-sdk/client-cloudwatch-logs` to:
|
|
9
|
+
- Find the most recent stopped tasks in the ECS Fargate cluster.
|
|
10
|
+
- Extract the `stoppedReason` (e.g., OutOfMemory, Essential container in task exited).
|
|
11
|
+
- Fetch the last 50 lines of logs from CloudWatch for the failing container.
|
|
12
|
+
3. **Output:** Format the output beautifully using `picocolors`. Highlight the exact error clearly so the user doesn't have to dig through JSON.
|
|
13
|
+
|
|
14
|
+
## Constraints
|
|
15
|
+
- Mock the AWS SDK calls in the tests so they don't require real AWS credentials to pass.
|
|
16
|
+
- Do not modify the existing `apply` or `destroy` commands.
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { ECSClient, ListTasksCommand, DescribeTasksCommand } from '@aws-sdk/client-ecs';
|
|
2
|
+
import { CloudWatchLogsClient, FilterLogEventsCommand } from '@aws-sdk/client-cloudwatch-logs';
|
|
3
|
+
import fsSync from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import color from 'picocolors';
|
|
6
|
+
import { intro, outro, spinner } from '@clack/prompts';
|
|
7
|
+
import { trackEvent, flushTelemetry } from '../core/telemetry.js';
|
|
8
|
+
|
|
9
|
+
export const LOG_FETCH_LIMIT = 50;
|
|
10
|
+
|
|
11
|
+
export function extractStoppedReason(task) {
|
|
12
|
+
return task?.stoppedReason || 'Unknown';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function getFailingContainer(task) {
|
|
16
|
+
const containers = task?.containers || [];
|
|
17
|
+
return (
|
|
18
|
+
containers.find((c) => c.exitCode !== undefined && c.exitCode !== 0) ||
|
|
19
|
+
containers.find((c) => c.reason && c.reason !== 'Essential container in task exited') ||
|
|
20
|
+
containers[0] ||
|
|
21
|
+
{}
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function pickMostRecentTask(tasks) {
|
|
26
|
+
if (!tasks || tasks.length === 0) return null;
|
|
27
|
+
return [...tasks].sort((a, b) => {
|
|
28
|
+
const aTime = a.stoppedAt ? new Date(a.stoppedAt).getTime() : 0;
|
|
29
|
+
const bTime = b.stoppedAt ? new Date(b.stoppedAt).getTime() : 0;
|
|
30
|
+
return bTime - aTime;
|
|
31
|
+
})[0];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function runDiagnose(options = {}) {
|
|
35
|
+
const projectName = path.basename(process.cwd());
|
|
36
|
+
|
|
37
|
+
// Attempt to read the region from the generated Terraform variables
|
|
38
|
+
let autoRegion = 'us-east-1';
|
|
39
|
+
try {
|
|
40
|
+
const mainTfPath = path.join(process.cwd(), 'terraform', 'main.tf');
|
|
41
|
+
if (fsSync.existsSync(mainTfPath)) {
|
|
42
|
+
const mainTf = fsSync.readFileSync(mainTfPath, 'utf8');
|
|
43
|
+
// Matches: region = "us-east-2"
|
|
44
|
+
const regionMatch = mainTf.match(/region\s*=\s*"([^"]+)"/);
|
|
45
|
+
if (regionMatch) autoRegion = regionMatch[1];
|
|
46
|
+
}
|
|
47
|
+
} catch (e) {
|
|
48
|
+
// Fallback silently
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const region = options.region || process.env.AWS_REGION || autoRegion;
|
|
52
|
+
const cluster = options.cluster || process.env.ECS_CLUSTER || `${projectName}-cluster`;
|
|
53
|
+
const logGroup = options.logGroup || process.env.ECS_LOG_GROUP || `/ecs/${projectName}`;
|
|
54
|
+
|
|
55
|
+
intro(color.bgCyan(color.black(' deploy-stack diagnose 🩺 ')));
|
|
56
|
+
|
|
57
|
+
const s = spinner();
|
|
58
|
+
s.start('Looking up recent stopped ECS tasks...');
|
|
59
|
+
|
|
60
|
+
const ecsClient = options.ecsClient || new ECSClient({ region });
|
|
61
|
+
const logsClient = options.logsClient || new CloudWatchLogsClient({ region });
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const listResp = await ecsClient.send(
|
|
65
|
+
new ListTasksCommand({
|
|
66
|
+
cluster,
|
|
67
|
+
desiredStatus: 'STOPPED',
|
|
68
|
+
sort: 'DESC',
|
|
69
|
+
maxResults: 10
|
|
70
|
+
})
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const taskArns = listResp.taskArns || [];
|
|
74
|
+
|
|
75
|
+
if (taskArns.length === 0) {
|
|
76
|
+
s.stop('No stopped tasks found.');
|
|
77
|
+
console.log(color.green('✅ No stopped tasks — your service looks healthy.'));
|
|
78
|
+
outro(color.green('Diagnose complete. Nothing to fix!'));
|
|
79
|
+
trackEvent('diagnose_run', { success: true, healthy: true });
|
|
80
|
+
await flushTelemetry();
|
|
81
|
+
return { healthy: true, stoppedReason: null, logs: [] };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
s.message(`Describing ${Math.min(taskArns.length, 5)} stopped task(s)...`);
|
|
85
|
+
const descResp = await ecsClient.send(
|
|
86
|
+
new DescribeTasksCommand({
|
|
87
|
+
cluster,
|
|
88
|
+
tasks: taskArns.slice(0, 5)
|
|
89
|
+
})
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const tasks = descResp.tasks || [];
|
|
93
|
+
if (tasks.length === 0) {
|
|
94
|
+
s.stop('No task details returned.');
|
|
95
|
+
console.log(color.yellow('⚠ Stopped task ARNs were listed, but ECS returned no task details.'));
|
|
96
|
+
outro(color.yellow('Diagnose finished with no details.'));
|
|
97
|
+
trackEvent('diagnose_run', { success: false, error_code: 'NO_TASK_DETAILS' });
|
|
98
|
+
await flushTelemetry();
|
|
99
|
+
return { healthy: false, stoppedReason: null, logs: [] };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const failedTask = pickMostRecentTask(tasks);
|
|
103
|
+
const stoppedReason = extractStoppedReason(failedTask);
|
|
104
|
+
const failingContainer = getFailingContainer(failedTask);
|
|
105
|
+
const containerName = failingContainer.name || 'unknown';
|
|
106
|
+
const exitCode = failingContainer.exitCode;
|
|
107
|
+
const containerReason = failingContainer.reason;
|
|
108
|
+
|
|
109
|
+
s.message(`Fetching last ${LOG_FETCH_LIMIT} log lines for "${containerName}"...`);
|
|
110
|
+
let logs = [];
|
|
111
|
+
try {
|
|
112
|
+
const logsResp = await logsClient.send(
|
|
113
|
+
new FilterLogEventsCommand({
|
|
114
|
+
logGroupName: logGroup,
|
|
115
|
+
limit: LOG_FETCH_LIMIT
|
|
116
|
+
})
|
|
117
|
+
);
|
|
118
|
+
const events = logsResp.events || [];
|
|
119
|
+
logs = events.slice(-LOG_FETCH_LIMIT).map((e) => e.message);
|
|
120
|
+
} catch (logError) {
|
|
121
|
+
logs = [];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
s.stop('Diagnosis complete.\n');
|
|
125
|
+
|
|
126
|
+
console.log(` ${color.red(color.bold('✖ Stopped reason:'))} ${color.red(stoppedReason)}`);
|
|
127
|
+
console.log(` ${color.dim('Cluster:')} ${color.cyan(cluster)}`);
|
|
128
|
+
console.log(` ${color.dim('Task:')} ${color.dim(failedTask.taskArn || taskArns[0])}`);
|
|
129
|
+
console.log(` ${color.dim('Container:')} ${color.yellow(containerName)}${exitCode !== undefined ? color.dim(` (exit code ${exitCode})`) : ''}`);
|
|
130
|
+
if (containerReason && containerReason !== stoppedReason) {
|
|
131
|
+
console.log(` ${color.dim('Container reason:')} ${color.yellow(containerReason)}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (logs.length > 0) {
|
|
135
|
+
console.log(`\n ${color.bold(`Last ${logs.length} log lines (${color.cyan(logGroup)}):`)}`);
|
|
136
|
+
for (const line of logs) {
|
|
137
|
+
console.log(` ${color.dim('│')} ${highlightErrorLine(line)}`);
|
|
138
|
+
}
|
|
139
|
+
} else {
|
|
140
|
+
console.log(color.dim(`\n No recent log events found in ${logGroup}.`));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
outro(color.green('Diagnose complete. Fix the error above, then redeploy. 🚀'));
|
|
144
|
+
|
|
145
|
+
trackEvent('diagnose_run', { success: true, healthy: false });
|
|
146
|
+
await flushTelemetry();
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
healthy: false,
|
|
150
|
+
cluster,
|
|
151
|
+
taskArn: failedTask.taskArn || taskArns[0],
|
|
152
|
+
stoppedReason,
|
|
153
|
+
containerName,
|
|
154
|
+
exitCode,
|
|
155
|
+
containerReason,
|
|
156
|
+
logs
|
|
157
|
+
};
|
|
158
|
+
} catch (error) {
|
|
159
|
+
s.stop(color.red('❌ Diagnose failed.'));
|
|
160
|
+
console.log(color.red(`✖ ${error.message || error}`));
|
|
161
|
+
console.log(color.dim('Check your AWS credentials and region, then try again.'));
|
|
162
|
+
trackEvent('diagnose_run', {
|
|
163
|
+
success: false,
|
|
164
|
+
error_code: error.name || 'UNKNOWN',
|
|
165
|
+
error_message: error.message
|
|
166
|
+
});
|
|
167
|
+
await flushTelemetry();
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function highlightErrorLine(line) {
|
|
173
|
+
if (/error|exception|failed|fatal|outofmemory|killed/i.test(line)) {
|
|
174
|
+
return color.red(line);
|
|
175
|
+
}
|
|
176
|
+
if (/warn/i.test(line)) {
|
|
177
|
+
return color.yellow(line);
|
|
178
|
+
}
|
|
179
|
+
return color.gray(line);
|
|
180
|
+
}
|
package/src/utils/aws.js
CHANGED
|
@@ -21,7 +21,7 @@ export async function provisionStateBucket(region, projectName) {
|
|
|
21
21
|
try {
|
|
22
22
|
await s3Client.send(new CreateBucketCommand({
|
|
23
23
|
Bucket: stateBucketName,
|
|
24
|
-
CreateBucketConfiguration: region === 'us-east-
|
|
24
|
+
CreateBucketConfiguration: region === 'us-east-1' ? undefined : { LocationConstraint: region }
|
|
25
25
|
}));
|
|
26
26
|
|
|
27
27
|
await s3Client.send(new PutBucketTaggingCommand({
|
|
@@ -37,11 +37,11 @@ RUN apk update && apk upgrade --no-cache && \
|
|
|
37
37
|
apk add --no-cache --virtual .build-deps build-base zlib-dev && \
|
|
38
38
|
gem install erb net-imap resolv rexml uri zlib && \
|
|
39
39
|
rm -f /usr/local/lib/ruby/gems/*/specifications/default/erb-*.gemspec \
|
|
40
|
-
/usr/local/lib/ruby/gems/*/specifications/default/net-imap-*.gemspec \
|
|
41
40
|
/usr/local/lib/ruby/gems/*/specifications/default/resolv-*.gemspec \
|
|
42
|
-
/usr/local/lib/ruby/gems/*/specifications/default/rexml-*.gemspec \
|
|
43
41
|
/usr/local/lib/ruby/gems/*/specifications/default/uri-*.gemspec \
|
|
44
|
-
/usr/local/lib/ruby/gems/*/specifications/default/zlib-*.gemspec
|
|
42
|
+
/usr/local/lib/ruby/gems/*/specifications/default/zlib-*.gemspec \
|
|
43
|
+
/usr/local/lib/ruby/gems/*/specifications/net-imap-0.4.*.gemspec \
|
|
44
|
+
/usr/local/lib/ruby/gems/*/specifications/rexml-3.3.6.gemspec && \
|
|
45
45
|
apk del .build-deps && \
|
|
46
46
|
rm -rf /var/cache/apk/*
|
|
47
47
|
|
|
@@ -4632,11 +4632,11 @@ RUN apk update && apk upgrade --no-cache && \\
|
|
|
4632
4632
|
apk add --no-cache --virtual .build-deps build-base zlib-dev && \\
|
|
4633
4633
|
gem install erb net-imap resolv rexml uri zlib && \\
|
|
4634
4634
|
rm -f /usr/local/lib/ruby/gems/*/specifications/default/erb-*.gemspec \\
|
|
4635
|
-
/usr/local/lib/ruby/gems/*/specifications/default/net-imap-*.gemspec \\
|
|
4636
4635
|
/usr/local/lib/ruby/gems/*/specifications/default/resolv-*.gemspec \\
|
|
4637
|
-
/usr/local/lib/ruby/gems/*/specifications/default/rexml-*.gemspec \\
|
|
4638
4636
|
/usr/local/lib/ruby/gems/*/specifications/default/uri-*.gemspec \\
|
|
4639
|
-
/usr/local/lib/ruby/gems/*/specifications/default/zlib-*.gemspec
|
|
4637
|
+
/usr/local/lib/ruby/gems/*/specifications/default/zlib-*.gemspec \\
|
|
4638
|
+
/usr/local/lib/ruby/gems/*/specifications/net-imap-0.4.*.gemspec \\
|
|
4639
|
+
/usr/local/lib/ruby/gems/*/specifications/rexml-3.3.6.gemspec && \\
|
|
4640
4640
|
apk del .build-deps && \\
|
|
4641
4641
|
rm -rf /var/cache/apk/*
|
|
4642
4642
|
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { provisionStateBucket } from '../src/utils/aws.js';
|
|
3
|
+
|
|
4
|
+
const {
|
|
5
|
+
mockS3Send,
|
|
6
|
+
mockStsSend,
|
|
7
|
+
MockS3Client,
|
|
8
|
+
MockSTSClient,
|
|
9
|
+
MockGetCallerIdentityCommand,
|
|
10
|
+
MockCreateBucketCommand,
|
|
11
|
+
MockPutBucketVersioningCommand,
|
|
12
|
+
MockPutBucketTaggingCommand,
|
|
13
|
+
} = vi.hoisted(() => {
|
|
14
|
+
const s3Send = vi.fn();
|
|
15
|
+
const stsSend = vi.fn();
|
|
16
|
+
const mockCommand = (input) => Object.assign({}, input);
|
|
17
|
+
return {
|
|
18
|
+
mockS3Send: s3Send,
|
|
19
|
+
mockStsSend: stsSend,
|
|
20
|
+
MockS3Client: vi.fn(function () {
|
|
21
|
+
this.send = s3Send;
|
|
22
|
+
}),
|
|
23
|
+
MockSTSClient: vi.fn(function () {
|
|
24
|
+
this.send = stsSend;
|
|
25
|
+
}),
|
|
26
|
+
MockGetCallerIdentityCommand: vi.fn(function (input) {
|
|
27
|
+
Object.assign(this, mockCommand(input));
|
|
28
|
+
}),
|
|
29
|
+
MockCreateBucketCommand: vi.fn(function (input) {
|
|
30
|
+
Object.assign(this, mockCommand(input));
|
|
31
|
+
}),
|
|
32
|
+
MockPutBucketVersioningCommand: vi.fn(function (input) {
|
|
33
|
+
Object.assign(this, mockCommand(input));
|
|
34
|
+
}),
|
|
35
|
+
MockPutBucketTaggingCommand: vi.fn(function (input) {
|
|
36
|
+
Object.assign(this, mockCommand(input));
|
|
37
|
+
}),
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
vi.mock('@aws-sdk/client-s3', () => {
|
|
42
|
+
return {
|
|
43
|
+
S3Client: MockS3Client,
|
|
44
|
+
CreateBucketCommand: MockCreateBucketCommand,
|
|
45
|
+
PutBucketVersioningCommand: MockPutBucketVersioningCommand,
|
|
46
|
+
PutBucketTaggingCommand: MockPutBucketTaggingCommand,
|
|
47
|
+
};
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
vi.mock('@aws-sdk/client-sts', () => {
|
|
51
|
+
return {
|
|
52
|
+
STSClient: MockSTSClient,
|
|
53
|
+
GetCallerIdentityCommand: MockGetCallerIdentityCommand,
|
|
54
|
+
};
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('provisionStateBucket', () => {
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
vi.clearAllMocks();
|
|
60
|
+
mockS3Send.mockReset().mockResolvedValue({});
|
|
61
|
+
mockStsSend.mockReset().mockResolvedValue({ Account: '123456789012' });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("when region is 'us-east-1', CreateBucketConfiguration is undefined", async () => {
|
|
65
|
+
await provisionStateBucket('us-east-1', 'my-project');
|
|
66
|
+
|
|
67
|
+
expect(MockCreateBucketCommand).toHaveBeenCalledWith(
|
|
68
|
+
expect.objectContaining({ CreateBucketConfiguration: undefined }),
|
|
69
|
+
);
|
|
70
|
+
expect(MockCreateBucketCommand.mock.calls[0][0].CreateBucketConfiguration).toBeUndefined();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("when region is 'us-east-2', CreateBucketConfiguration has LocationConstraint 'us-east-2'", async () => {
|
|
74
|
+
await provisionStateBucket('us-east-2', 'my-project');
|
|
75
|
+
|
|
76
|
+
expect(MockCreateBucketCommand).toHaveBeenCalledWith(
|
|
77
|
+
expect.objectContaining({
|
|
78
|
+
CreateBucketConfiguration: { LocationConstraint: 'us-east-2' },
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
expect(MockCreateBucketCommand.mock.calls[0][0].CreateBucketConfiguration).toEqual({
|
|
82
|
+
LocationConstraint: 'us-east-2',
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { runDiagnose } from '../src/commands/diagnose.js';
|
|
3
|
+
|
|
4
|
+
// Mock the AWS SDK clients (no real credentials needed)
|
|
5
|
+
const {
|
|
6
|
+
mockEcsSend,
|
|
7
|
+
mockLogsSend,
|
|
8
|
+
MockECSClient,
|
|
9
|
+
MockListTasksCommand,
|
|
10
|
+
MockDescribeTasksCommand,
|
|
11
|
+
MockLogsClient,
|
|
12
|
+
MockFilterLogEventsCommand
|
|
13
|
+
} = vi.hoisted(() => {
|
|
14
|
+
const ecsSend = vi.fn();
|
|
15
|
+
const logsSend = vi.fn();
|
|
16
|
+
return {
|
|
17
|
+
mockEcsSend: ecsSend,
|
|
18
|
+
mockLogsSend: logsSend,
|
|
19
|
+
MockECSClient: vi.fn(function () {
|
|
20
|
+
this.send = ecsSend;
|
|
21
|
+
}),
|
|
22
|
+
MockListTasksCommand: vi.fn(function (input) {
|
|
23
|
+
Object.assign(this, input);
|
|
24
|
+
}),
|
|
25
|
+
MockDescribeTasksCommand: vi.fn(function (input) {
|
|
26
|
+
Object.assign(this, input);
|
|
27
|
+
}),
|
|
28
|
+
MockLogsClient: vi.fn(function () {
|
|
29
|
+
this.send = logsSend;
|
|
30
|
+
}),
|
|
31
|
+
MockFilterLogEventsCommand: vi.fn(function (input) {
|
|
32
|
+
Object.assign(this, input);
|
|
33
|
+
})
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
vi.mock('@aws-sdk/client-ecs', () => {
|
|
38
|
+
return {
|
|
39
|
+
ECSClient: MockECSClient,
|
|
40
|
+
DescribeTasksCommand: MockDescribeTasksCommand,
|
|
41
|
+
ListTasksCommand: MockListTasksCommand
|
|
42
|
+
};
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
vi.mock('@aws-sdk/client-cloudwatch-logs', () => {
|
|
46
|
+
return {
|
|
47
|
+
CloudWatchLogsClient: MockLogsClient,
|
|
48
|
+
FilterLogEventsCommand: MockFilterLogEventsCommand
|
|
49
|
+
};
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Silence interactive prompts and telemetry during tests
|
|
53
|
+
vi.mock('@clack/prompts', () => ({
|
|
54
|
+
intro: vi.fn(),
|
|
55
|
+
outro: vi.fn(),
|
|
56
|
+
spinner: () => ({ start: vi.fn(), stop: vi.fn(), message: vi.fn() })
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
vi.mock('../src/core/telemetry.js', () => ({
|
|
60
|
+
trackEvent: vi.fn(),
|
|
61
|
+
flushTelemetry: vi.fn().mockResolvedValue()
|
|
62
|
+
}));
|
|
63
|
+
|
|
64
|
+
describe('Command: diagnose', () => {
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
vi.clearAllMocks();
|
|
67
|
+
mockEcsSend.mockReset();
|
|
68
|
+
mockLogsSend.mockReset();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should extract and format the stoppedReason from a failed ECS task', async () => {
|
|
72
|
+
const stoppedReason = 'OutOfMemoryError: Container killed due to memory usage';
|
|
73
|
+
|
|
74
|
+
// 1. ListTasks -> one stopped task; DescribeTasks -> failed task detail
|
|
75
|
+
mockEcsSend
|
|
76
|
+
.mockResolvedValueOnce({
|
|
77
|
+
taskArns: ['arn:aws:ecs:us-east-1:123456789012:task/test-cluster/abc123']
|
|
78
|
+
})
|
|
79
|
+
.mockResolvedValueOnce({
|
|
80
|
+
tasks: [
|
|
81
|
+
{
|
|
82
|
+
taskArn: 'arn:aws:ecs:us-east-1:123456789012:task/test-cluster/abc123',
|
|
83
|
+
stoppedReason,
|
|
84
|
+
stoppedAt: new Date().toISOString(),
|
|
85
|
+
containers: [
|
|
86
|
+
{ name: 'app', exitCode: 137, reason: 'Essential container in task exited' }
|
|
87
|
+
]
|
|
88
|
+
}
|
|
89
|
+
]
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// 2. CloudWatch -> 55 events, only the last 50 should be used
|
|
93
|
+
const events = Array.from({ length: 55 }, (_, i) => ({
|
|
94
|
+
message: i === 54 ? `FATAL ${stoppedReason}` : `log line ${i + 1}`
|
|
95
|
+
}));
|
|
96
|
+
mockLogsSend.mockResolvedValueOnce({ events });
|
|
97
|
+
|
|
98
|
+
// 3. Capture formatted console output
|
|
99
|
+
const output = [];
|
|
100
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation((...args) => {
|
|
101
|
+
output.push(args.join(' '));
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const result = await runDiagnose({
|
|
106
|
+
cluster: 'test-cluster',
|
|
107
|
+
region: 'us-east-1',
|
|
108
|
+
logGroup: '/ecs/test'
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Return value carries the extracted reason
|
|
112
|
+
expect(result.stoppedReason).toBe(stoppedReason);
|
|
113
|
+
// Last-50-lines limit honored
|
|
114
|
+
expect(result.logs).toHaveLength(50);
|
|
115
|
+
// Formatted output highlights the exact error
|
|
116
|
+
expect(output.join('\n')).toContain(stoppedReason);
|
|
117
|
+
// Log fetch asked CloudWatch for the last 50 lines
|
|
118
|
+
expect(MockFilterLogEventsCommand).toHaveBeenCalledWith(
|
|
119
|
+
expect.objectContaining({ limit: 50 })
|
|
120
|
+
);
|
|
121
|
+
} finally {
|
|
122
|
+
consoleSpy.mockRestore();
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should report healthy when there are no stopped tasks', async () => {
|
|
127
|
+
mockEcsSend.mockResolvedValueOnce({ taskArns: [] });
|
|
128
|
+
|
|
129
|
+
const output = [];
|
|
130
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation((...args) => {
|
|
131
|
+
output.push(args.join(' '));
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
const result = await runDiagnose({ cluster: 'test-cluster', region: 'us-east-1' });
|
|
136
|
+
expect(result.healthy).toBe(true);
|
|
137
|
+
expect(mockLogsSend).not.toHaveBeenCalled();
|
|
138
|
+
expect(output.join('\n')).toMatch(/healthy|No stopped tasks/i);
|
|
139
|
+
} finally {
|
|
140
|
+
consoleSpy.mockRestore();
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
});
|