deploy-stack 0.17.13 → 0.18.0
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/.github/workflows/iac-validation.yml +62 -0
- package/.muserules +16 -0
- package/README.md +11 -11
- package/bin/cli.js +3 -0
- package/docs/ROADMAP.md +11 -4
- package/docs/adr/0001-s3-native-state-locking.md +0 -1
- package/docs/adr/0002-eject-mechanism-pure-iac.md +0 -1
- package/docs/adr/0003-sync-ai-context-strategy.md +0 -1
- package/docs/adr/0004-iac-driven-diagnostic-context.md +34 -0
- package/docs/guides/headless.md +1 -0
- package/docs/testing-strategy.md +7 -3
- package/package.json +3 -1
- package/specs/ci-pipeline.md +17 -0
- package/specs/diagnose.md +16 -0
- package/specs/docs-hub.md +16 -0
- package/src/commands/diagnose.js +180 -0
- package/src/utils/aws.js +1 -1
- package/tests/aws.test.js +85 -0
- package/tests/diagnose.test.js +143 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
name: IaC Validation
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
workflow_dispatch:
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
validate:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
strategy:
|
|
13
|
+
matrix:
|
|
14
|
+
framework: [nestjs, nextjs, nuxt, node, svelte, static, python, django, rails, go]
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
|
|
18
|
+
- name: Setup Node.js
|
|
19
|
+
uses: actions/setup-node@v4
|
|
20
|
+
with:
|
|
21
|
+
node-version: '24.x'
|
|
22
|
+
|
|
23
|
+
- name: Install dependencies
|
|
24
|
+
run: npm install
|
|
25
|
+
|
|
26
|
+
- name: Setup Terraform
|
|
27
|
+
uses: hashicorp/setup-terraform@v3
|
|
28
|
+
|
|
29
|
+
- name: Setup TFLint
|
|
30
|
+
uses: terraform-linters/setup-tflint@v4
|
|
31
|
+
with:
|
|
32
|
+
github_token: ${{ secrets.GITHUB_TOKEN }}
|
|
33
|
+
|
|
34
|
+
- name: Scaffold app (${{ matrix.framework }})
|
|
35
|
+
run: |
|
|
36
|
+
TMPDIR=$(mktemp -d)
|
|
37
|
+
echo "GENERATED_DIR=$TMPDIR" >> "$GITHUB_ENV"
|
|
38
|
+
cd "$TMPDIR"
|
|
39
|
+
node "$GITHUB_WORKSPACE/bin/cli.js" --headless --preconfigured --framework=${{ matrix.framework }}
|
|
40
|
+
|
|
41
|
+
- name: Terraform Compile
|
|
42
|
+
working-directory: ${{ env.GENERATED_DIR }}/terraform
|
|
43
|
+
run: |
|
|
44
|
+
terraform init -backend=false
|
|
45
|
+
terraform validate
|
|
46
|
+
|
|
47
|
+
- name: Init TFLint
|
|
48
|
+
working-directory: ${{ env.GENERATED_DIR }}/terraform
|
|
49
|
+
run: tflint --init
|
|
50
|
+
|
|
51
|
+
- name: Terraform Lint
|
|
52
|
+
working-directory: ${{ env.GENERATED_DIR }}/terraform
|
|
53
|
+
run: tflint
|
|
54
|
+
|
|
55
|
+
- name: DevSecOps Scan
|
|
56
|
+
uses: aquasecurity/trivy-action@master
|
|
57
|
+
with:
|
|
58
|
+
scan-type: 'fs'
|
|
59
|
+
scan-ref: ${{ env.GENERATED_DIR }}
|
|
60
|
+
format: 'table'
|
|
61
|
+
severity: 'HIGH,CRITICAL'
|
|
62
|
+
exit-code: '1'
|
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
|
@@ -35,6 +35,7 @@ You retain complete ownership of your infrastructure code without relying on bla
|
|
|
35
35
|
|
|
36
36
|
**🛡️ DevSecOps & Security**
|
|
37
37
|
* **Automated Trivy Scanning:** Integrated IaC and container vulnerability scanning on every GitHub Actions run.
|
|
38
|
+
* **Continuous IaC Validation:** Matrix pipeline scaffolds all 10 supported frameworks headlessly and gates every commit on `terraform validate`, `tflint`, and Trivy (HIGH/CRITICAL).
|
|
38
39
|
* **Hardened Containers:** Explicitly drops root privileges using `nginx-unprivileged` and distroless bases for strict Fargate security compliance.
|
|
39
40
|
* **Zero-Secret CI/CD:** Utilizes AWS IAM OpenID Connect (OIDC) for automated deployments—no long-lived AWS keys in GitHub.
|
|
40
41
|
* **Built-in Secrets Manager:** Push local `.env` variables directly into encrypted AWS Secrets Manager vaults with a single CLI command.
|
|
@@ -89,6 +90,9 @@ The interactive wizard will analyze your codebase, detect your framework, estima
|
|
|
89
90
|
* **`npx deploy-stack doctor`**
|
|
90
91
|
Scans your local environment and generated files to ensure all required dependencies (Docker, Terraform, AWS CLI) are installed and configured correctly.
|
|
91
92
|
|
|
93
|
+
* **`npx deploy-stack diagnose`** (alias: `wtf`)
|
|
94
|
+
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. Stateless: derives region/cluster context from `terraform/main.tf` (see ADR-0004), no local state file required.
|
|
95
|
+
|
|
92
96
|
* **`npx deploy-stack destroy`**
|
|
93
97
|
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
98
|
|
|
@@ -97,6 +101,7 @@ The interactive wizard will analyze your codebase, detect your framework, estima
|
|
|
97
101
|
|
|
98
102
|
* **`npx deploy-stack --headless`**
|
|
99
103
|
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`.
|
|
104
|
+
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
105
|
|
|
101
106
|
* **`npx deploy-stack sync-ai`**
|
|
102
107
|
Selectively generates architecture rules for AI coding assistants (Cursor, Copilot, Windsurf, Claude). Automatically extracts your AWS Region and Container Port to prevent Terraform hallucinations.
|
|
@@ -163,17 +168,12 @@ npx deploy-stack --no-telemetry
|
|
|
163
168
|
|
|
164
169
|
## 🗺️ Roadmap
|
|
165
170
|
|
|
166
|
-
### Current Focus
|
|
167
|
-
|
|
168
|
-
- [
|
|
169
|
-
- [ ] **
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
- [x] `cookiecutter-django-deploy-stack` (Listed on Django Packages)
|
|
173
|
-
- [x] `cookiecutter-fastapi-deploy-stack` (Cookiecutter for modern async Python)
|
|
174
|
-
- [x] `nest-deploy-stack` (Native `nest add` schematic for NestJS)
|
|
175
|
-
- [ ] `rails-template-deploy-stack` (Zero-click Ruby on Rails application template)
|
|
176
|
-
- [ ] **Automated Troubleshooting:** Build `deploy-stack diagnose` (alias: `wtf`) to automatically analyze and troubleshoot common day-2 AWS operational issues (e.g., Fargate OOM kills, ALB 502s) directly from the terminal.
|
|
171
|
+
### Current Focus: Phase 8: Platform Hardening & Developer Experience
|
|
172
|
+
**Goal:** Solidify the core engine's reliability, prove security compliance, and establish documentation hub before introducing Day-2 operational commands.
|
|
173
|
+
- [ ] **Documentation Hub:** Launch a dedicated Astro Starlight documentation site featuring interactive architecture diagrams, core concept deep-dives, and detailed CLI references.
|
|
174
|
+
- [ ] **Continuous Infrastructure Validation:** Implement a GitHub Actions matrix pipeline that automatically generates, compiles, and validates Terraform syntax (`terraform validate`, `tflint`) against all supported frameworks on every commit.
|
|
175
|
+
- [ ] **Automated Security & Compliance Proving:** Integrate DevSecOps infrastructure scanning (`trivy` or `tfsec`) directly into the CI pipeline to mathematically guarantee zero-CVE, secure-by-default AWS provisioning.
|
|
176
|
+
- [ ] **Integration Stability Suite:** Expand Vitest coverage to enforce strict contracts for headless execution flags (`--preconfigured`, `--headless`), ensuring seamless interoperability with third-party scaffolding tools.
|
|
177
177
|
|
|
178
178
|
👉 **[See the full project history and future plans in ROADMAP.md](./ROADMAP.md)**
|
|
179
179
|
|
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
|
@@ -22,15 +22,22 @@
|
|
|
22
22
|
- [x] **Docker Compose to ECS Translator:** Automatically converting a familiar local `docker-compose.yml` into production ECS task definitions.
|
|
23
23
|
- [x] **AI Agent Rulesets:** Publishing `.cursorrules` and Copilot instructions that teach AI assistants exactly how to utilize the CLI on the user's behalf.
|
|
24
24
|
|
|
25
|
-
### Phase 7: Team Workflows & Ecosystem Integrations (
|
|
25
|
+
### Phase 7: Team Workflows & Ecosystem Integrations (Completed)
|
|
26
26
|
*Focus: Enhance collaborative development and expand native support across major framework ecosystems.*
|
|
27
27
|
- [x] **Ephemeral PR Previews:** Generate GitHub Actions workflows that spin up temporary ECS Fargate tasks and post live preview URLs directly in pull request comments to streamline team code reviews.
|
|
28
28
|
- [x] **AI Context Synchronization:** Implement `deploy-stack sync-ai` to automatically generate `.cursorrules` and AI context files, ensuring coding assistants generate accurate deployment commands tailored to the project.
|
|
29
|
-
- [
|
|
29
|
+
- [x] **Native Ecosystem Integrations:** Publish seamless, push-button plugins across major frameworks.
|
|
30
30
|
- [x] `vite-plugin-deploy-stack` (Live on NPM)
|
|
31
31
|
- [x] `svelte-adapter-deploy-stack` (SvelteKit adapter integration)
|
|
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.
|
|
37
|
+
|
|
38
|
+
### Phase 8: Platform Hardening & Developer Experience (Current)
|
|
39
|
+
*Focus: Solidify the core engine's reliability, prove security compliance, and establish documentation hub before introducing Day-2 operational commands.*
|
|
40
|
+
- [ ] **Documentation Hub:** Launch a dedicated Astro Starlight documentation site featuring interactive architecture diagrams, core concept deep-dives, and detailed CLI references.
|
|
41
|
+
- [ ] **Continuous Infrastructure Validation:** Implement a GitHub Actions matrix pipeline that automatically generates, compiles, and validates Terraform syntax (`terraform validate`, `tflint`) against all supported frameworks on every commit.
|
|
42
|
+
- [ ] **Automated Security & Compliance Proving:** Integrate DevSecOps infrastructure scanning (`trivy` or `tfsec`) directly into the CI pipeline to mathematically guarantee zero-CVE, secure-by-default AWS provisioning.
|
|
43
|
+
- [ ] **Integration Stability Suite:** Expand Vitest coverage to enforce strict contracts for headless execution flags (`--preconfigured`, `--headless`), ensuring seamless interoperability with third-party scaffolding tools.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# 0004. IaC-Driven Diagnostic Context (Stateless CLI)
|
|
2
|
+
|
|
3
|
+
* **Status:** Accepted
|
|
4
|
+
* **Date:** 2026-09-19
|
|
5
|
+
|
|
6
|
+
## Context and Problem Statement
|
|
7
|
+
|
|
8
|
+
To provide a seamless developer experience, the `deploy-stack diagnose` command needs to automatically fetch CloudWatch logs and ECS task failures without requiring the user to manually input their AWS Region, Cluster Name, or Log Group.
|
|
9
|
+
|
|
10
|
+
We needed a mechanism to persist or infer the deployment context locally so the CLI knows where to look for errors.
|
|
11
|
+
|
|
12
|
+
## Decision Drivers
|
|
13
|
+
|
|
14
|
+
* **Statelessness:** The CLI should avoid managing internal database files or proprietary local state files that can fall out of sync with actual infrastructure.
|
|
15
|
+
* **Single Source of Truth:** Terraform is already the declarative source of truth for the project's infrastructure.
|
|
16
|
+
* **Ecosystem Compatibility:** Developers often delete node_modules or switch laptops; context retrieval must survive typical Git workflows.
|
|
17
|
+
|
|
18
|
+
## Considered Options
|
|
19
|
+
|
|
20
|
+
1. **Local State File:** Create a `.deploy-stack/context.json` file upon generation. (Rejected: creates state drift and pollutes version control).
|
|
21
|
+
2. **AWS Tag Querying:** Use the AWS SDK to query all clusters for a specific tag. (Rejected: too slow, requires broad IAM `ListClusters` permissions, and fails if multiple environments exist).
|
|
22
|
+
3. **IaC Parsing (Stateless):** Parse the generated `terraform/main.tf` to extract the AWS Region and infer the cluster name from the local directory structure.
|
|
23
|
+
|
|
24
|
+
## Decision Outcome
|
|
25
|
+
|
|
26
|
+
**Chosen Option:** IaC Parsing (Stateless). The `diagnose` command reads the AWS Region directly via regex from `terraform/main.tf` and constructs standard AWS resource names based on the current working directory.
|
|
27
|
+
|
|
28
|
+
### Positive Consequences
|
|
29
|
+
* The CLI remains entirely stateless. If the Terraform files exist, the diagnostics work.
|
|
30
|
+
* Enforces the architectural philosophy that the generated IaC is the ultimate source of truth.
|
|
31
|
+
* Zero additional files are added to the user's repository.
|
|
32
|
+
|
|
33
|
+
### Negative Consequences
|
|
34
|
+
* If a user manually alters the `region` string inside `main.tf` using non-standard formatting, the regex parser may fail to detect it, falling back to a default region.
|
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/docs/testing-strategy.md
CHANGED
|
@@ -5,6 +5,10 @@ To ensure zero regressions in infrastructure generation and safe local execution
|
|
|
5
5
|
## 1. Unit & Argument Testing
|
|
6
6
|
We use pure Node.js unit tests (via Vitest) to validate the CLI argument parser (`src/core/parser.js`). This ensures that flags (like `--headless` or `--no-telemetry`) are routed correctly and never hijack positional arguments like file paths.
|
|
7
7
|
|
|
8
|
+
## 1.5. Ecosystem Integration Contracts
|
|
9
|
+
Because `deploy-stack` acts as the underlying engine for ecosystem wrappers (e.g., `nest-deploy-stack`, `cookiecutter-fastapi`), we strictly test execution flags that bypass interactive prompts:
|
|
10
|
+
* **Headless Validation:** Vitest specifically asserts that when `--headless` and `--preconfigured` are passed, the CLI never initializes the `inquirer` prompt module and never throws interactive warnings. This guarantees stability for automated ecosystem integrations.
|
|
11
|
+
|
|
8
12
|
## 2. Infrastructure Snapshot Harness (The Static Contract)
|
|
9
13
|
Because `deploy-stack` generates highly dynamic Terraform (`.tf`), GitHub Actions (`.yml`), and `Dockerfile` configurations, we use **Vitest Snapshots** to lock in the expected text outputs.
|
|
10
14
|
* **The Matrix:** The test suite generates dummy projects across 11 architectural topologies (including Django, Rails, Go, Nuxt, Next.js, SvelteKit, and Vercel/Heroku migrations).
|
|
@@ -17,7 +21,7 @@ To ensure tests run sub-second and deterministically without requiring real AWS
|
|
|
17
21
|
* **Telemetry:** PostHog tracking is mocked to prevent test executions from polluting production analytics.
|
|
18
22
|
|
|
19
23
|
## 4. Continuous Integration & Execution Validation (CI)
|
|
20
|
-
While Vitest proves the CLI generates the *correct* files, GitHub Actions proves those files *actually work*.
|
|
24
|
+
While Vitest proves the CLI generates the *correct* files, GitHub Actions proves those files *actually work*. Unit and snapshot tests are gated via `.github/workflows/test.yml`; live template compilation is gated via `.github/workflows/iac-validation.yml`.
|
|
21
25
|
* **Phase 1 (Generation):** Vitest runs unit and snapshot tests to verify the CLI contract.
|
|
22
|
-
* **Phase 2 (Static Application Security Testing - SAST):**
|
|
23
|
-
* **Phase 3 (IaC Validation):** The
|
|
26
|
+
* **Phase 2 (Static Application Security Testing - SAST):** CI runs `trivy config` against the generated `Dockerfile` and Terraform snapshots to guarantee they remain compliant with strict security policies.
|
|
27
|
+
* **Phase 3 (IaC Validation):** The `iac-validation` matrix workflow scaffolds all 10 supported frameworks headlessly (`--headless --preconfigured`), then runs `terraform init -backend=false` + `terraform validate`, `tflint`, and a Trivy filesystem scan (fails on HIGH/CRITICAL) — fully offline with no AWS credentials.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "deploy-stack",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
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,17 @@
|
|
|
1
|
+
# Spec: Continuous Infrastructure Validation Pipeline
|
|
2
|
+
|
|
3
|
+
## Objective
|
|
4
|
+
Create a GitHub Actions workflow that automatically generates, compiles, and security-scans our IaC templates across all supported frameworks to prevent regressions.
|
|
5
|
+
|
|
6
|
+
## Requirements
|
|
7
|
+
1. **Workflow Registration:** Create `.github/workflows/iac-validation.yml` triggered on push to `main` and all pull requests.
|
|
8
|
+
2. **Matrix Strategy:** Run tests across all 10 supported framework permutations: `nestjs`, `nextjs`, `nuxt`, `node`, `svelte`, `static`, `python`, `django`, `rails`, and `go`.
|
|
9
|
+
3. **Execution Steps:** For each framework in the matrix:
|
|
10
|
+
- Scaffold the app: Run `node bin/cli.js --headless --preconfigured --framework=<matrix-framework>` in a temporary directory.
|
|
11
|
+
- Terraform Compile: Run `terraform init -backend=false` followed by `terraform validate`.
|
|
12
|
+
- Terraform Lint: Run `tflint` to catch deprecated syntax.
|
|
13
|
+
- DevSecOps Scan: Run Trivy (via `aquasecurity/trivy-action`) to scan the generated `Dockerfile` and `terraform/` directory.
|
|
14
|
+
|
|
15
|
+
## Constraints
|
|
16
|
+
- The pipeline must execute completely offline without AWS credentials (hence `-backend=false`).
|
|
17
|
+
- Trivy must be configured to fail the build if it detects HIGH or CRITICAL vulnerabilities.
|
|
@@ -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,16 @@
|
|
|
1
|
+
# Spec: Starlight Documentation Hub
|
|
2
|
+
|
|
3
|
+
## Objective
|
|
4
|
+
Initialize an Astro Starlight documentation site inside the monorepo to serve as the official, version-controlled developer portal for `deploy-stack`.
|
|
5
|
+
|
|
6
|
+
## Requirements
|
|
7
|
+
1. **Scaffolding:** Initialize a new Astro Starlight project inside the `apps/docs/` directory.
|
|
8
|
+
2. **Information Architecture:** Configure `astro.config.mjs` to render a sidebar with three main sections:
|
|
9
|
+
- **Core Concepts:** (Link to ADRs and architectural decisions).
|
|
10
|
+
- **Guides:** (Link to deployment guides and PaaS migrations).
|
|
11
|
+
- **CLI Reference:** (Command reference for init, apply, diagnose).
|
|
12
|
+
3. **Migration:** Move all existing Markdown files from the root `docs/adrs/` folder into `apps/docs/src/content/docs/adrs/` so they are immediately searchable.
|
|
13
|
+
|
|
14
|
+
## Constraints
|
|
15
|
+
- Use the standard Astro Starlight dependency tree.
|
|
16
|
+
- Ensure a script (e.g., `"docs:dev": "npm run dev --workspace=apps/docs"`) is added to the root `package.json` to allow starting the docs from the top level.
|
|
@@ -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({
|
|
@@ -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
|
+
});
|