grada-run 0.0.2 → 0.33.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/deploy-docs.yml +37 -0
- package/.github/workflows/e2e.yml +73 -0
- package/.github/workflows/iac-validation.yml +303 -0
- package/.github/workflows/publish.yml +68 -0
- package/.github/workflows/sync-bedrock-models.yml +57 -0
- package/.github/workflows/test.yml +43 -0
- package/.muserules +31 -0
- package/LICENSE +21 -0
- package/README.md +193 -3
- package/apps/docs/.astro/collections/docs.schema.json +644 -0
- package/apps/docs/.astro/content-assets.mjs +4 -0
- package/apps/docs/.astro/content-modules.mjs +4 -0
- package/apps/docs/.astro/content.d.ts +179 -0
- package/apps/docs/.astro/data-store.json +1 -0
- package/apps/docs/.astro/dev.json +14 -0
- package/apps/docs/.astro/settings.json +5 -0
- package/apps/docs/.astro/types.d.ts +2 -0
- package/apps/docs/astro.config.mjs +97 -0
- package/apps/docs/package.json +17 -0
- package/apps/docs/src/content/docs/adrs/0001-s3-native-state-locking.md +37 -0
- package/apps/docs/src/content/docs/adrs/0002-eject-mechanism-pure-iac.md +39 -0
- package/apps/docs/src/content/docs/adrs/0003-sync-ai-context-strategy.md +48 -0
- package/apps/docs/src/content/docs/adrs/0004-iac-driven-diagnostic-context.md +37 -0
- package/apps/docs/src/content/docs/adrs/0005-ecs-fargate-alb-runtime-target.md +38 -0
- package/apps/docs/src/content/docs/adrs/0006-github-oidc-no-stored-keys.md +37 -0
- package/apps/docs/src/content/docs/adrs/0007-framework-detection-with-fallback.md +37 -0
- package/apps/docs/src/content/docs/adrs/0008-secrets-names-in-git-values-in-aws.md +37 -0
- package/apps/docs/src/content/docs/adrs/0009-regenerate-with-backup-on-rerun.md +37 -0
- package/apps/docs/src/content/docs/adrs/0010-advisory-only-security-scans.md +37 -0
- package/apps/docs/src/content/docs/cli/add.md +85 -0
- package/apps/docs/src/content/docs/cli/apply.md +35 -0
- package/apps/docs/src/content/docs/cli/db.md +200 -0
- package/apps/docs/src/content/docs/cli/destroy.md +35 -0
- package/apps/docs/src/content/docs/cli/diagnose.md +37 -0
- package/apps/docs/src/content/docs/cli/doctor.md +28 -0
- package/apps/docs/src/content/docs/cli/domain.md +57 -0
- package/apps/docs/src/content/docs/cli/drift.md +40 -0
- package/apps/docs/src/content/docs/cli/eject.md +33 -0
- package/apps/docs/src/content/docs/cli/exec.md +49 -0
- package/apps/docs/src/content/docs/cli/gc.md +37 -0
- package/apps/docs/src/content/docs/cli/init.md +72 -0
- package/apps/docs/src/content/docs/cli/logs.md +39 -0
- package/apps/docs/src/content/docs/cli/rollback.md +51 -0
- package/apps/docs/src/content/docs/cli/secrets.md +73 -0
- package/apps/docs/src/content/docs/cli/sleep.md +53 -0
- package/apps/docs/src/content/docs/cli/status.md +34 -0
- package/apps/docs/src/content/docs/cli/sync-ai.md +27 -0
- package/apps/docs/src/content/docs/guides/architecture.md +87 -0
- package/apps/docs/src/content/docs/guides/aws-credentials.md +72 -0
- package/apps/docs/src/content/docs/guides/background-workers.md +45 -0
- package/apps/docs/src/content/docs/guides/cicd-pipeline.md +64 -0
- package/apps/docs/src/content/docs/guides/database-connections.md +64 -0
- package/apps/docs/src/content/docs/guides/docker-compose.md +37 -0
- package/apps/docs/src/content/docs/guides/dockerfiles.md +46 -0
- package/apps/docs/src/content/docs/guides/ephemeral-pr-previews.md +39 -0
- package/apps/docs/src/content/docs/guides/examples.md +50 -0
- package/apps/docs/src/content/docs/guides/frameworks.md +88 -0
- package/apps/docs/src/content/docs/guides/headless.md +87 -0
- package/apps/docs/src/content/docs/guides/quickstart.md +52 -0
- package/apps/docs/src/content/docs/guides/rerun-init.md +43 -0
- package/apps/docs/src/content/docs/guides/secrets-management.md +83 -0
- package/apps/docs/src/content/docs/guides/understanding-your-bill.md +63 -0
- package/apps/docs/src/content/docs/index.mdx +103 -0
- package/apps/docs/src/content/docs/migrations/astro-vercel-to-aws.md +55 -0
- package/apps/docs/src/content/docs/migrations/heroku-procfile-to-aws.md +41 -0
- package/apps/docs/src/content/docs/migrations/nextjs-vercel-to-aws.md +51 -0
- package/apps/docs/src/content/docs/migrations/sveltekit-vercel-to-aws.md +63 -0
- package/apps/docs/src/content/docs/roadmap.md +99 -0
- package/apps/docs/src/content/docs/testing-strategy.md +37 -0
- package/apps/docs/src/content.config.ts +7 -0
- package/apps/docs/src/custom.css +14 -0
- package/apps/docs/tsconfig.json +6 -0
- package/bin/cli.js +140 -0
- package/package.json +107 -7
- package/scripts/sync-bedrock-models.js +22 -0
- package/scripts/test-iac.js +261 -0
- package/specs/add-redis-sqs-bedrock.md +128 -0
- package/specs/add-storage-dynamodb.md +106 -0
- package/specs/bedrock-model-catalog.md +131 -0
- package/specs/ci-pipeline.md +17 -0
- package/specs/cost-transparency.md +115 -0
- package/specs/custom-domains-and-ses.md +153 -0
- package/specs/database-suite-expansion.md +151 -0
- package/specs/db-connect.md +69 -0
- package/specs/db-lifecycle-migrations.md +159 -0
- package/specs/dependency-aware-init.md +176 -0
- package/specs/deployment-safety.md +170 -0
- package/specs/diagnose.md +16 -0
- package/specs/docs-hub.md +16 -0
- package/specs/dx-polish.md +46 -0
- package/specs/e2e-testing.md +50 -0
- package/specs/exec.md +25 -0
- package/specs/finops-cron-drift.md +161 -0
- package/specs/gc.md +26 -0
- package/specs/integration-suite.md +16 -0
- package/specs/logs.md +32 -0
- package/specs/rollback-live-polling.md +40 -0
- package/specs/secrets-pull-audit.md +51 -0
- package/specs/serverless-lambda-target.md +133 -0
- package/specs/status.md +31 -0
- package/specs/telemetry-and-spawn-hardening.md +69 -0
- package/specs/telemetry-hardening.md +35 -0
- package/specs/test-suite-deduplication.md +42 -0
- package/src/commands/add.js +1111 -0
- package/src/commands/apply.js +214 -0
- package/src/commands/db/backup.js +229 -0
- package/src/commands/db/connect.js +304 -0
- package/src/commands/db/enable-vector.js +344 -0
- package/src/commands/db/import.js +604 -0
- package/src/commands/db/migrate.js +477 -0
- package/src/commands/db/restore.js +361 -0
- package/src/commands/db.js +87 -0
- package/src/commands/destroy.js +217 -0
- package/src/commands/diagnose.js +460 -0
- package/src/commands/doctor.js +109 -0
- package/src/commands/domain.js +685 -0
- package/src/commands/drift.js +243 -0
- package/src/commands/eject.js +130 -0
- package/src/commands/exec.js +222 -0
- package/src/commands/gc.js +250 -0
- package/src/commands/init.js +649 -0
- package/src/commands/logs.js +256 -0
- package/src/commands/rollback.js +323 -0
- package/src/commands/secrets.js +485 -0
- package/src/commands/sleep.js +347 -0
- package/src/commands/status.js +309 -0
- package/src/commands/sync-ai.js +115 -0
- package/src/commands/wake.js +337 -0
- package/src/core/parser.js +128 -0
- package/src/core/telemetry.js +244 -0
- package/src/data/bedrock-models.json +896 -0
- package/src/utils/addons.js +126 -0
- package/src/utils/ai-rules.js +59 -0
- package/src/utils/args.js +91 -0
- package/src/utils/aws.js +178 -0
- package/src/utils/backup.js +69 -0
- package/src/utils/bedrock-catalog.js +511 -0
- package/src/utils/capabilities.js +500 -0
- package/src/utils/command.js +78 -0
- package/src/utils/db-tunnel.js +164 -0
- package/src/utils/detector.js +298 -0
- package/src/utils/dockerCompose.js +65 -0
- package/src/utils/domains.js +73 -0
- package/src/utils/ecs-runner.js +289 -0
- package/src/utils/ecs.js +92 -0
- package/src/utils/frameworks.js +55 -0
- package/src/utils/generator.js +527 -0
- package/src/utils/hcl.js +426 -0
- package/src/utils/lambda-ecr.js +185 -0
- package/src/utils/prompts.js +278 -0
- package/src/utils/rds.js +131 -0
- package/src/utils/resolvers.js +174 -0
- package/src/utils/sleep-state.js +140 -0
- package/src/utils/sleep-targets.js +139 -0
- package/src/utils/system.js +42 -0
- package/src/utils/terraform.js +70 -0
- package/src/utils/visualizer.js +381 -0
- package/src/utils/warnings.js +49 -0
- package/templates/README.md +150 -0
- package/templates/docker/django.Dockerfile +40 -0
- package/templates/docker/go.Dockerfile +23 -0
- package/templates/docker/nestjs.Dockerfile +33 -0
- package/templates/docker/nextjs.Dockerfile +55 -0
- package/templates/docker/node.Dockerfile +24 -0
- package/templates/docker/nuxt.Dockerfile +47 -0
- package/templates/docker/python.Dockerfile +38 -0
- package/templates/docker/rails.Dockerfile +59 -0
- package/templates/docker/static.Dockerfile +32 -0
- package/templates/docker/svelte.Dockerfile +52 -0
- package/templates/github/deploy-lambda.yml +120 -0
- package/templates/github/deploy.yml +138 -0
- package/templates/github/drift.yml +112 -0
- package/templates/github/preview-lambda.yml +86 -0
- package/templates/github/preview.yml +69 -0
- package/templates/github/teardown.yml +43 -0
- package/templates/terraform/addons/bedrock.tf +34 -0
- package/templates/terraform/addons/cron-lambda.tf +78 -0
- package/templates/terraform/addons/cron.tf +101 -0
- package/templates/terraform/addons/dynamodb.tf +73 -0
- package/templates/terraform/addons/redis.tf +64 -0
- package/templates/terraform/addons/s3.tf +143 -0
- package/templates/terraform/addons/ses.tf +73 -0
- package/templates/terraform/addons/sqs.tf +67 -0
- package/templates/terraform/backend.tf +22 -0
- package/templates/terraform/cloudfront-lambda.tf +80 -0
- package/templates/terraform/cloudfront.tf +80 -0
- package/templates/terraform/database-aurora-postgresql.tf +92 -0
- package/templates/terraform/database-mysql.tf +72 -0
- package/templates/terraform/database.tf +71 -0
- package/templates/terraform/main-lambda.tf +229 -0
- package/templates/terraform/main.tf +296 -0
- package/templates/terraform/network.tf +95 -0
- package/templates/terraform/oidc.tf +64 -0
- package/templates/terraform/secrets.tf +31 -0
- package/templates/terraform/worker.tf +69 -0
- package/tests/__snapshots__/generator.test.js.snap +9633 -0
- package/tests/add.test.js +2018 -0
- package/tests/ai.test.js +91 -0
- package/tests/apply.test.js +446 -0
- package/tests/args.test.js +86 -0
- package/tests/aws.test.js +244 -0
- package/tests/capabilities.test.js +304 -0
- package/tests/cli.test.js +29 -0
- package/tests/command.test.js +99 -0
- package/tests/commands-import.test.js +74 -0
- package/tests/db.test.js +2673 -0
- package/tests/destroy.test.js +365 -0
- package/tests/detector.test.js +79 -0
- package/tests/diagnose.test.js +765 -0
- package/tests/doctor.test.js +195 -0
- package/tests/domain.test.js +882 -0
- package/tests/drift.test.js +228 -0
- package/tests/e2e/helpers.js +122 -0
- package/tests/e2e/tier0.e2e.test.js +142 -0
- package/tests/e2e/tier1.live.e2e.test.js +118 -0
- package/tests/ecs.test.js +130 -0
- package/tests/eject.test.js +65 -0
- package/tests/exec.test.js +366 -0
- package/tests/gc.test.js +466 -0
- package/tests/generator.test.js +794 -0
- package/tests/headless.test.js +491 -0
- package/tests/helpers/clack.js +103 -0
- package/tests/helpers/console.js +41 -0
- package/tests/helpers/telemetry.js +37 -0
- package/tests/helpers/tmpdir.js +36 -0
- package/tests/lambda-ecr.test.js +185 -0
- package/tests/logs.test.js +436 -0
- package/tests/parser.test.js +160 -0
- package/tests/rds.test.js +244 -0
- package/tests/resolvers.test.js +279 -0
- package/tests/rollback.test.js +670 -0
- package/tests/secrets.test.js +729 -0
- package/tests/sleep-wake.test.js +998 -0
- package/tests/status.test.js +356 -0
- package/tests/system.test.js +70 -0
- package/tests/telemetry.test.js +520 -0
- package/tests/terraform.test.js +84 -0
- package/tests/visualizer.test.js +480 -0
- package/vitest.config.js +10 -0
- package/vitest.e2e.tier0.config.js +8 -0
- package/vitest.e2e.tier1.config.js +8 -0
- package/index.js +0 -2
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import { spawnSync } from 'child_process';
|
|
3
|
+
import { ECSClient, UpdateServiceCommand } from '@aws-sdk/client-ecs';
|
|
4
|
+
import { RDSClient, StopDBInstanceCommand, StopDBClusterCommand } from '@aws-sdk/client-rds';
|
|
5
|
+
import color from 'picocolors';
|
|
6
|
+
import { intro, outro, spinner, confirm, cancel } from '@clack/prompts';
|
|
7
|
+
import { trackSuccess, trackFailure } from '../core/telemetry.js';
|
|
8
|
+
import { failCommand, failProjectNotInitialized } from '../utils/command.js';
|
|
9
|
+
import { parseFlags, normalizeOptions, normalizeArgv } from '../utils/args.js';
|
|
10
|
+
import { handleAuthErrorBranch, resolveClient } from '../utils/aws.js';
|
|
11
|
+
import { resolveRegion, resolveProjectName, resolveHeadless, resolveCwd, readTerraformComputeTarget } from '../utils/resolvers.js';
|
|
12
|
+
import { findDbTarget } from '../utils/rds.js';
|
|
13
|
+
import { fetchActiveService } from '../utils/ecs.js';
|
|
14
|
+
import { parseTerraformConfig, estimateSleepSavings } from '../utils/visualizer.js';
|
|
15
|
+
import {
|
|
16
|
+
resolveSleepTarget,
|
|
17
|
+
readSleepState,
|
|
18
|
+
writeSleepState,
|
|
19
|
+
ensureSleepGitignore,
|
|
20
|
+
computeAutoRestartAt,
|
|
21
|
+
formatUtcTimestamp,
|
|
22
|
+
} from '../utils/sleep-state.js';
|
|
23
|
+
import {
|
|
24
|
+
readCronScheduleName,
|
|
25
|
+
hasCronAddon,
|
|
26
|
+
hasSqsWorkerScaling,
|
|
27
|
+
hasRedisAddon,
|
|
28
|
+
setSchedulerState,
|
|
29
|
+
setWorkerScalingSuspended,
|
|
30
|
+
} from '../utils/sleep-targets.js';
|
|
31
|
+
|
|
32
|
+
export const SLEEP_CONFIRM_MESSAGE = "Put environment to sleep? This will take the web service offline until you run 'grada wake'.";
|
|
33
|
+
|
|
34
|
+
export function parseSleepArgs(argv = []) {
|
|
35
|
+
const args = normalizeArgv(argv);
|
|
36
|
+
if (args[0] === 'sleep') args.shift();
|
|
37
|
+
const { options, rest } = parseFlags(args, {
|
|
38
|
+
string: ['project-name', 'cluster', 'service', 'db-identifier', 'region', 'workspace'],
|
|
39
|
+
boolean: ['skip-db', 'wait', 'no-wait', 'yes', 'force', 'headless'],
|
|
40
|
+
});
|
|
41
|
+
const positionals = rest.filter((arg) => typeof arg === 'string' && !arg.startsWith('-'));
|
|
42
|
+
if (positionals.length > 0) options.env = positionals[0];
|
|
43
|
+
if (positionals.length > 1) options.unexpectedPositionals = positionals.slice(1);
|
|
44
|
+
return options;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isMysqlEngine(engine) {
|
|
48
|
+
return engine === 'mysql' || engine === 'aurora-mysql';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function runSleep(input = {}) {
|
|
52
|
+
const options = normalizeOptions(input);
|
|
53
|
+
let cwd;
|
|
54
|
+
let region;
|
|
55
|
+
let projectName;
|
|
56
|
+
try {
|
|
57
|
+
cwd = resolveCwd(options);
|
|
58
|
+
region = resolveRegion(options, cwd);
|
|
59
|
+
projectName = resolveProjectName(options, cwd);
|
|
60
|
+
} catch {
|
|
61
|
+
return failProjectNotInitialized({ event: 'sleep_run' });
|
|
62
|
+
}
|
|
63
|
+
const target = resolveSleepTarget(options, cwd);
|
|
64
|
+
const isLambda = readTerraformComputeTarget(cwd) === 'lambda';
|
|
65
|
+
const headless = resolveHeadless(options);
|
|
66
|
+
const skipDb = options.skipDb === true || options.skipDb === 'true';
|
|
67
|
+
const confirmed = options.yes === true || options.yes === 'true'
|
|
68
|
+
|| options.force === true || options.force === 'true';
|
|
69
|
+
|
|
70
|
+
intro(color.bgCyan(color.black(' grada sleep 💤 ')));
|
|
71
|
+
|
|
72
|
+
if (Array.isArray(options.unexpectedPositionals) && options.unexpectedPositionals.length > 0) {
|
|
73
|
+
return failCommand({
|
|
74
|
+
message: `\n✖ Unexpected argument "${options.unexpectedPositionals[0]}". Pass a single environment: sleep [env].\n`,
|
|
75
|
+
event: 'sleep_run',
|
|
76
|
+
telemetry: { projectName },
|
|
77
|
+
errorCode: 'UNEXPECTED_POSITIONAL_ARGS',
|
|
78
|
+
reason: 'unexpected-positional-args',
|
|
79
|
+
resultExtra: { cluster: target.cluster, region },
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Safety guard: sleeping the default (production) environment takes the
|
|
84
|
+
// web service offline, so it needs an explicit confirmation.
|
|
85
|
+
if (target.requiresConfirm && !confirmed) {
|
|
86
|
+
if (headless) {
|
|
87
|
+
return failCommand({
|
|
88
|
+
message: '\n✖ Sleeping the default environment requires confirmation. Re-run with --yes.\n',
|
|
89
|
+
event: 'sleep_run',
|
|
90
|
+
telemetry: { projectName, env_kind: target.envKind },
|
|
91
|
+
errorCode: 'CONFIRMATION_REQUIRED',
|
|
92
|
+
reason: 'confirmation-required',
|
|
93
|
+
resultExtra: { cluster: target.cluster, region },
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
console.log(color.yellow('\n⚠ This will take the web service offline until you run "grada wake".'));
|
|
97
|
+
const answer = await confirm({ message: SLEEP_CONFIRM_MESSAGE, initialValue: false });
|
|
98
|
+
if (answer !== true) {
|
|
99
|
+
cancel('Cancelled. Nothing was changed.');
|
|
100
|
+
return { ok: false, reason: 'cancelled', cluster: target.cluster, region };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const ecsClient = resolveClient(options.ecsClient, ECSClient, { region });
|
|
105
|
+
const rdsClient = resolveClient(options.rdsClient, RDSClient, { region });
|
|
106
|
+
const runSync = options.spawnSyncImpl || spawnSync;
|
|
107
|
+
|
|
108
|
+
const s = spinner();
|
|
109
|
+
s.start(`Putting ${target.envKey} to sleep...`);
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
// Lambda compute is already scale-to-zero: there are no services to
|
|
113
|
+
// scale, so sleep only stops the database and pauses schedules.
|
|
114
|
+
const appService = isLambda ? null : await fetchActiveService(ecsClient, target.cluster, target.appService);
|
|
115
|
+
const workerService = isLambda ? null : await fetchActiveService(ecsClient, target.cluster, target.workerService);
|
|
116
|
+
|
|
117
|
+
let ecsScaled = 0;
|
|
118
|
+
let prevApp = 0;
|
|
119
|
+
let prevWorker = 0;
|
|
120
|
+
const serviceNotes = [];
|
|
121
|
+
for (const [label, service, serviceName] of [
|
|
122
|
+
['app', appService, target.appService],
|
|
123
|
+
['worker', workerService, target.workerService],
|
|
124
|
+
]) {
|
|
125
|
+
if (!service) continue;
|
|
126
|
+
const desired = service.desiredCount ?? 0;
|
|
127
|
+
if (desired > 0) {
|
|
128
|
+
await ecsClient.send(new UpdateServiceCommand({
|
|
129
|
+
cluster: target.cluster,
|
|
130
|
+
service: serviceName,
|
|
131
|
+
desiredCount: 0,
|
|
132
|
+
}));
|
|
133
|
+
ecsScaled += 1;
|
|
134
|
+
serviceNotes.push({ label, name: serviceName, scaled: true, prev: desired });
|
|
135
|
+
} else {
|
|
136
|
+
serviceNotes.push({ label, name: serviceName, scaled: false, prev: 0 });
|
|
137
|
+
}
|
|
138
|
+
if (label === 'app') prevApp = desired;
|
|
139
|
+
else prevWorker = desired;
|
|
140
|
+
}
|
|
141
|
+
const servicesMissing = !appService && !workerService;
|
|
142
|
+
|
|
143
|
+
let dbTarget = null;
|
|
144
|
+
let dbStopped = false;
|
|
145
|
+
let dbNote = null;
|
|
146
|
+
if (!skipDb) {
|
|
147
|
+
dbTarget = await findDbTarget(rdsClient, {
|
|
148
|
+
dbIdentifier: target.dbIdentifier,
|
|
149
|
+
dbClusterIdentifier: target.dbClusterIdentifier,
|
|
150
|
+
});
|
|
151
|
+
if (!dbTarget) {
|
|
152
|
+
dbNote = 'no database provisioned';
|
|
153
|
+
} else if (dbTarget.status === 'available') {
|
|
154
|
+
if (dbTarget.kind === 'cluster') {
|
|
155
|
+
await rdsClient.send(new StopDBClusterCommand({ DBClusterIdentifier: dbTarget.id }));
|
|
156
|
+
} else {
|
|
157
|
+
await rdsClient.send(new StopDBInstanceCommand({ DBInstanceIdentifier: dbTarget.id }));
|
|
158
|
+
}
|
|
159
|
+
dbStopped = true;
|
|
160
|
+
} else if (dbTarget.status === 'stopped' || dbTarget.status === 'stopping') {
|
|
161
|
+
dbNote = `already ${dbTarget.status}`;
|
|
162
|
+
} else {
|
|
163
|
+
dbNote = `transitional (${dbTarget.status}), skipping`;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (servicesMissing && !dbTarget) {
|
|
168
|
+
s.stop(color.yellow('Nothing to sleep.'));
|
|
169
|
+
return failCommand({
|
|
170
|
+
print: () => {
|
|
171
|
+
if (isLambda) {
|
|
172
|
+
console.log(`\n No databases found for ${color.cyan(target.appPrefix)} — and Lambda compute is already scale-to-zero.`);
|
|
173
|
+
} else {
|
|
174
|
+
console.log(`\n No ECS services or databases found for ${color.cyan(target.appPrefix)}.`);
|
|
175
|
+
}
|
|
176
|
+
console.log(` Run ${color.green('npx grada-run apply')} to provision your infrastructure.\n`);
|
|
177
|
+
},
|
|
178
|
+
event: 'sleep_run',
|
|
179
|
+
telemetry: { projectName, env_kind: target.envKind, error_code: 'NOTHING_TO_SLEEP' },
|
|
180
|
+
reason: 'nothing-to-sleep',
|
|
181
|
+
resultExtra: { cluster: target.cluster, region },
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Pause scheduled work so nothing wakes the environment back up: a
|
|
186
|
+
// firing cron schedule would keep launching (and billing) tasks
|
|
187
|
+
// against the stopped database, and queue-depth scaling would
|
|
188
|
+
// revive the worker on the next message. Skips degrade to notes —
|
|
189
|
+
// sleep itself still succeeds.
|
|
190
|
+
const cronConfigured = hasCronAddon(cwd);
|
|
191
|
+
let cronName = null;
|
|
192
|
+
let cronPaused = false;
|
|
193
|
+
let cronNote = null;
|
|
194
|
+
if (cronConfigured) {
|
|
195
|
+
cronName = readCronScheduleName(cwd, target.appPrefix);
|
|
196
|
+
if (!cronName) {
|
|
197
|
+
cronNote = 'schedule name unreadable, skipped';
|
|
198
|
+
} else {
|
|
199
|
+
const paused = setSchedulerState({ name: cronName, enabled: false, region, run: runSync });
|
|
200
|
+
if (paused.ok) {
|
|
201
|
+
cronPaused = true;
|
|
202
|
+
if (paused.skipped) cronNote = 'already paused';
|
|
203
|
+
} else if (paused.reason === 'not-deployed') {
|
|
204
|
+
cronNote = 'not deployed yet, skipped';
|
|
205
|
+
} else if (paused.reason === 'cli-missing') {
|
|
206
|
+
cronNote = 'skipped (AWS CLI not found)';
|
|
207
|
+
} else {
|
|
208
|
+
cronNote = `could not pause (${paused.detail || paused.reason})`;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const scalingConfigured = hasSqsWorkerScaling(cwd);
|
|
213
|
+
let scalingSuspended = false;
|
|
214
|
+
let scalingNote = null;
|
|
215
|
+
if (scalingConfigured) {
|
|
216
|
+
const suspended = setWorkerScalingSuspended({
|
|
217
|
+
cluster: target.cluster, service: target.workerService, suspended: true, region, run: runSync,
|
|
218
|
+
});
|
|
219
|
+
if (suspended.ok) {
|
|
220
|
+
scalingSuspended = true;
|
|
221
|
+
if (suspended.skipped) scalingNote = 'already suspended';
|
|
222
|
+
} else if (suspended.reason === 'not-registered') {
|
|
223
|
+
scalingNote = 'not registered, skipped';
|
|
224
|
+
} else if (suspended.reason === 'cli-missing') {
|
|
225
|
+
scalingNote = 'skipped (AWS CLI not found)';
|
|
226
|
+
} else {
|
|
227
|
+
scalingNote = `could not suspend (${suspended.detail || suspended.reason})`;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Persist the ledger: a repeat sleep never overwrites the original
|
|
232
|
+
// replica counts with 0, and keeps the original 7-day window (AWS
|
|
233
|
+
// counts from the actual stop, not from re-runs).
|
|
234
|
+
const nowMs = options.nowMs ?? Date.now();
|
|
235
|
+
const state = readSleepState(cwd);
|
|
236
|
+
const existing = state[target.envKey] && typeof state[target.envKey] === 'object'
|
|
237
|
+
? state[target.envKey]
|
|
238
|
+
: null;
|
|
239
|
+
const storedApp = isLambda ? 0 : (prevApp > 0 ? prevApp : (existing?.services?.app ?? 1));
|
|
240
|
+
const storedWorker = isLambda ? 0 : (prevWorker > 0 ? prevWorker : (existing?.services?.worker ?? (workerService ? 1 : 0)));
|
|
241
|
+
const sleptAt = existing?.sleptAt ?? new Date(nowMs).toISOString();
|
|
242
|
+
const autoRestartAt = existing?.autoRestartAt ?? computeAutoRestartAt(nowMs).toISOString();
|
|
243
|
+
state[target.envKey] = {
|
|
244
|
+
env: target.envKey,
|
|
245
|
+
sleptAt,
|
|
246
|
+
autoRestartAt,
|
|
247
|
+
services: { app: storedApp, worker: storedWorker },
|
|
248
|
+
dbId: dbTarget?.id ?? existing?.dbId ?? null,
|
|
249
|
+
dbKind: dbTarget?.kind ?? existing?.dbKind ?? null,
|
|
250
|
+
};
|
|
251
|
+
writeSleepState(cwd, state);
|
|
252
|
+
ensureSleepGitignore(cwd);
|
|
253
|
+
|
|
254
|
+
const tfConfig = parseTerraformConfig(path.join(cwd, 'terraform'));
|
|
255
|
+
const hasDb = tfConfig.hasDb || Boolean(dbTarget);
|
|
256
|
+
const dbEngine = dbTarget
|
|
257
|
+
? (dbTarget.kind === 'cluster' ? 'aurora-postgresql' : (isMysqlEngine(dbTarget.engine) ? 'mysql' : 'postgres'))
|
|
258
|
+
: tfConfig.dbEngine;
|
|
259
|
+
const savings = estimateSleepSavings({
|
|
260
|
+
cpu: tfConfig.cpu,
|
|
261
|
+
memory: tfConfig.memory,
|
|
262
|
+
hasDb,
|
|
263
|
+
dbEngine,
|
|
264
|
+
appReplicas: appService ? storedApp : 0,
|
|
265
|
+
workerReplicas: workerService ? storedWorker : 0,
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
s.stop(color.green('Environment asleep. 💤'));
|
|
269
|
+
console.log('');
|
|
270
|
+
if (isLambda) {
|
|
271
|
+
console.log(` ${color.dim('lambda:')} scale-to-zero compute — nothing to scale`);
|
|
272
|
+
}
|
|
273
|
+
for (const note of serviceNotes) {
|
|
274
|
+
if (note.scaled) {
|
|
275
|
+
console.log(` ${color.dim('ecs:')} ${color.cyan(note.name)} scaled to 0 (was ${note.prev})`);
|
|
276
|
+
} else {
|
|
277
|
+
console.log(` ${color.dim('ecs:')} ${color.cyan(note.name)} already asleep`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (skipDb) {
|
|
281
|
+
console.log(` ${color.dim('rds:')} skipped (--skip-db)`);
|
|
282
|
+
} else if (dbStopped) {
|
|
283
|
+
console.log(` ${color.dim('rds:')} ${color.cyan(dbTarget.id)} stopping...`);
|
|
284
|
+
} else {
|
|
285
|
+
console.log(` ${color.dim('rds:')} ${dbNote}`);
|
|
286
|
+
}
|
|
287
|
+
if (cronConfigured) {
|
|
288
|
+
const label = cronName ? `${color.cyan(cronName)} ` : '';
|
|
289
|
+
console.log(` ${color.dim('cron:')} ${label}${cronPaused && !cronNote ? 'paused' : cronNote}`);
|
|
290
|
+
}
|
|
291
|
+
if (scalingConfigured) {
|
|
292
|
+
console.log(` ${color.dim('scaling:')} ${scalingSuspended && !scalingNote ? 'worker auto-scaling suspended' : scalingNote}`);
|
|
293
|
+
}
|
|
294
|
+
if (hasRedisAddon(cwd)) {
|
|
295
|
+
console.log(` ${color.dim('cache:')} Valkey keeps billing (~$9.49/mo) — ElastiCache has no pause API`);
|
|
296
|
+
}
|
|
297
|
+
console.log(color.yellow(`\n ⚠ AWS Note: Stopped RDS databases automatically restart after 7 days (${formatUtcTimestamp(autoRestartAt)}). Re-run "npx grada-run sleep" or "npx grada-run destroy" for longer archiving.`));
|
|
298
|
+
console.log(color.green(`\n 💰 Estimated Savings While Asleep: ~$${savings.hourly}/hr (~$${savings.monthly}/mo in ${isLambda ? 'RDS compute' : 'Fargate + RDS compute'} paused)`));
|
|
299
|
+
const wakeEnv = target.envKey === 'default' ? '' : ` ${target.envKey}`;
|
|
300
|
+
console.log(color.dim(` Wake anytime with: npx grada-run wake${wakeEnv}\n`));
|
|
301
|
+
|
|
302
|
+
await trackSuccess('sleep_run', {
|
|
303
|
+
projectName,
|
|
304
|
+
env_kind: target.envKind,
|
|
305
|
+
ecs_scaled: ecsScaled,
|
|
306
|
+
db_stopped: dbStopped,
|
|
307
|
+
db_kind: dbTarget?.kind ?? 'none',
|
|
308
|
+
cron_paused: cronPaused,
|
|
309
|
+
scaling_suspended: scalingSuspended,
|
|
310
|
+
});
|
|
311
|
+
outro(color.green('Done.'));
|
|
312
|
+
return {
|
|
313
|
+
ok: true,
|
|
314
|
+
env: target.envKey,
|
|
315
|
+
cluster: target.cluster,
|
|
316
|
+
region,
|
|
317
|
+
ecsScaled,
|
|
318
|
+
dbStopped,
|
|
319
|
+
dbKind: dbTarget?.kind ?? null,
|
|
320
|
+
cronPaused,
|
|
321
|
+
scalingSuspended,
|
|
322
|
+
autoRestartAt,
|
|
323
|
+
savings,
|
|
324
|
+
};
|
|
325
|
+
} catch (error) {
|
|
326
|
+
await trackFailure('sleep_run', {
|
|
327
|
+
projectName,
|
|
328
|
+
env_kind: target.envKind,
|
|
329
|
+
error_code: error?.name || 'UNKNOWN',
|
|
330
|
+
error_message: error?.message,
|
|
331
|
+
});
|
|
332
|
+
if (handleAuthErrorBranch(error, s, options)) {
|
|
333
|
+
return { ok: false, reason: 'error', cluster: target.cluster, region };
|
|
334
|
+
}
|
|
335
|
+
try { s.stop(color.red('❌ Sleep failed.')); } catch { /* spinner already stopped */ }
|
|
336
|
+
return failCommand({
|
|
337
|
+
message: `✖ ${error?.message || error}`,
|
|
338
|
+
hint: 'Check your AWS credentials and region, then try again.',
|
|
339
|
+
reason: 'error',
|
|
340
|
+
resultExtra: { cluster: target.cluster, region },
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export const sleepCommand = runSleep;
|
|
346
|
+
|
|
347
|
+
export default runSleep;
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { spawnSync } from 'child_process';
|
|
2
|
+
import { ECSClient, DescribeServicesCommand } from '@aws-sdk/client-ecs';
|
|
3
|
+
import { CloudWatchClient, DescribeAlarmsCommand } from '@aws-sdk/client-cloudwatch';
|
|
4
|
+
import color from 'picocolors';
|
|
5
|
+
import { intro, outro, spinner } from '@clack/prompts';
|
|
6
|
+
import { trackEvent, flushTelemetry, trackSuccess, trackFailure } from '../core/telemetry.js';
|
|
7
|
+
import { failCommand, failProjectNotInitialized } from '../utils/command.js';
|
|
8
|
+
import { parseFlags, normalizeOptions, normalizeArgv } from '../utils/args.js';
|
|
9
|
+
import { handleAuthErrorBranch, resolveClient } from '../utils/aws.js';
|
|
10
|
+
import { resolveRegion, resolveProjectName, resolveCwd, readTerraformComputeTarget } from '../utils/resolvers.js';
|
|
11
|
+
import { runDiagnose } from './diagnose.js';
|
|
12
|
+
|
|
13
|
+
export const DEGRADED_MESSAGE = '⚠️ Degraded state detected. Running automated diagnostics...';
|
|
14
|
+
|
|
15
|
+
export function parseStatusArgs(argv = []) {
|
|
16
|
+
const args = normalizeArgv(argv);
|
|
17
|
+
if (args[0] === 'status') args.shift();
|
|
18
|
+
const { options } = parseFlags(args, {
|
|
19
|
+
string: ['region'],
|
|
20
|
+
bareBoolean: ['json'],
|
|
21
|
+
});
|
|
22
|
+
return options;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function normalizeAlarm(alarm) {
|
|
26
|
+
return {
|
|
27
|
+
name: alarm.AlarmName || alarm.name || 'unknown',
|
|
28
|
+
state: alarm.StateValue || alarm.state || 'UNKNOWN',
|
|
29
|
+
reason: alarm.StateReason || alarm.reason,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function getServiceHealth(serviceDesc, alarms = []) {
|
|
34
|
+
const desiredCount = serviceDesc?.desiredCount ?? 0;
|
|
35
|
+
const runningCount = serviceDesc?.runningCount ?? 0;
|
|
36
|
+
const pendingCount = serviceDesc?.pendingCount ?? 0;
|
|
37
|
+
const status = serviceDesc?.status || 'UNKNOWN';
|
|
38
|
+
const normalizedAlarms = (alarms || []).map(normalizeAlarm);
|
|
39
|
+
const alarmed = normalizedAlarms.filter((a) => a.state === 'ALARM');
|
|
40
|
+
const healthy = Boolean(serviceDesc) && runningCount >= desiredCount && alarmed.length === 0;
|
|
41
|
+
return { serviceDesc: serviceDesc || null, desiredCount, runningCount, pendingCount, status, alarms: normalizedAlarms, alarmed, healthy };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function formatAlarmBadge(state) {
|
|
45
|
+
if (state === 'ALARM') return color.red('[ALARM]');
|
|
46
|
+
if (state === 'OK') return color.green('[OK]');
|
|
47
|
+
if (state === 'INSUFFICIENT_DATA') return color.yellow('[INSUFFICIENT_DATA]');
|
|
48
|
+
return color.dim(`[${state}]`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function formatReplicas(runningCount, desiredCount, pendingCount) {
|
|
52
|
+
const summary = `${runningCount}/${desiredCount}`;
|
|
53
|
+
let painted = summary;
|
|
54
|
+
if (runningCount <= 0) painted = color.red(summary);
|
|
55
|
+
else if (runningCount < desiredCount) painted = color.yellow(summary);
|
|
56
|
+
else painted = color.green(summary);
|
|
57
|
+
if (pendingCount > 0) painted += color.dim(` (${pendingCount} pending)`);
|
|
58
|
+
return painted;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function getLambdaHealth(configuration = {}) {
|
|
62
|
+
const state = configuration.State || 'UNKNOWN';
|
|
63
|
+
const lastUpdateStatus = configuration.LastUpdateStatus || 'UNKNOWN';
|
|
64
|
+
const healthy = state === 'Active' && lastUpdateStatus === 'Successful';
|
|
65
|
+
return { state, lastUpdateStatus, healthy };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function printLambdaDashboard({ functionName, configuration, health }) {
|
|
69
|
+
console.log('');
|
|
70
|
+
console.log(` ${color.bold('Function:')} ${color.cyan(functionName)} ${health.state === 'Active' ? color.green('(ACTIVE)') : color.yellow(`(${health.state})`)}`);
|
|
71
|
+
console.log(` ${color.bold('Last update:')} ${health.lastUpdateStatus === 'Successful' ? color.green(health.lastUpdateStatus) : color.yellow(health.lastUpdateStatus)}`);
|
|
72
|
+
if (configuration.LastModified) console.log(` ${color.dim('Modified:')} ${configuration.LastModified}`);
|
|
73
|
+
if (configuration.MemorySize) console.log(` ${color.dim('Memory:')} ${configuration.MemorySize} MB ${color.dim(`(timeout ${configuration.Timeout ?? '?'}s)`)}`);
|
|
74
|
+
if (configuration.Code?.ImageUri) console.log(` ${color.dim('Image:')} ${configuration.Code.ImageUri.split('/').pop()}`);
|
|
75
|
+
console.log('');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Lambda health via the AWS CLI (`get-function`), mirroring the ECS status
|
|
79
|
+
// flow above without adding an @aws-sdk/client-lambda dependency.
|
|
80
|
+
export async function runLambdaStatus(input = {}) {
|
|
81
|
+
const options = normalizeOptions(input);
|
|
82
|
+
const cwd = options.cwd || process.cwd();
|
|
83
|
+
const region = options.region;
|
|
84
|
+
const projectName = options.projectName;
|
|
85
|
+
const functionName = typeof options.functionName === 'string' && options.functionName.trim()
|
|
86
|
+
? options.functionName.trim()
|
|
87
|
+
: `${projectName}-fn`;
|
|
88
|
+
const asJson = Boolean(options.json);
|
|
89
|
+
const runSync = options.spawnSyncImpl || spawnSync;
|
|
90
|
+
|
|
91
|
+
if (!asJson) intro(color.bgCyan(color.black(' grada status 📊 ')));
|
|
92
|
+
|
|
93
|
+
const s = asJson ? null : spinner();
|
|
94
|
+
if (s) s.start('Checking function health...');
|
|
95
|
+
|
|
96
|
+
const res = runSync('aws', [
|
|
97
|
+
'lambda', 'get-function',
|
|
98
|
+
'--function-name', functionName,
|
|
99
|
+
'--region', region,
|
|
100
|
+
'--output', 'json',
|
|
101
|
+
], { encoding: 'utf8' });
|
|
102
|
+
|
|
103
|
+
if (res?.error?.code === 'ENOENT') {
|
|
104
|
+
if (s) s.stop(color.red('❌ Status check failed.'));
|
|
105
|
+
await trackFailure('status_run', { projectName, error_code: 'AWS_CLI_MISSING' });
|
|
106
|
+
return failCommand({
|
|
107
|
+
message: '✖ The AWS CLI is required for Lambda status checks.',
|
|
108
|
+
hint: 'Install it from https://aws.amazon.com/cli/, then try again.',
|
|
109
|
+
reason: 'aws-cli-missing',
|
|
110
|
+
resultExtra: { functionName, region },
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (res?.status !== 0) {
|
|
115
|
+
const detail = String(res?.stderr || res?.stdout || '').trim();
|
|
116
|
+
if (/ResourceNotFound/i.test(detail)) {
|
|
117
|
+
if (s) s.stop(color.yellow('Function not found.'));
|
|
118
|
+
console.log(`\n The Lambda function ${color.cyan(functionName)} does not exist.`);
|
|
119
|
+
console.log(` Run ${color.green('npx grada-run apply')} to provision your infrastructure.\n`);
|
|
120
|
+
|
|
121
|
+
await trackSuccess('status_run', { projectName, healthy: false, missing: true });
|
|
122
|
+
|
|
123
|
+
if (asJson) return { healthy: false, missing: true, function: null, region, computeTarget: 'lambda' };
|
|
124
|
+
process.exit(0);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (s) s.stop(color.red('❌ Status check failed.'));
|
|
128
|
+
await trackFailure('status_run', { projectName, error_code: 'GET_FUNCTION_FAILED' });
|
|
129
|
+
return failCommand({
|
|
130
|
+
message: `✖ ${detail || 'aws lambda get-function failed.'}`,
|
|
131
|
+
hint: 'Check your AWS credentials and region, then try again.',
|
|
132
|
+
resultExtra: { functionName, region },
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let configuration;
|
|
137
|
+
try {
|
|
138
|
+
configuration = JSON.parse(res.stdout || '{}').Configuration || {};
|
|
139
|
+
} catch {
|
|
140
|
+
if (s) s.stop(color.red('❌ Status check failed.'));
|
|
141
|
+
await trackFailure('status_run', { projectName, error_code: 'BAD_RESPONSE' });
|
|
142
|
+
return failCommand({
|
|
143
|
+
message: '✖ Could not parse the Lambda get-function response.',
|
|
144
|
+
hint: 'Check your AWS CLI version, then try again.',
|
|
145
|
+
resultExtra: { functionName, region },
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const health = getLambdaHealth(configuration);
|
|
150
|
+
const payload = {
|
|
151
|
+
function: {
|
|
152
|
+
name: functionName,
|
|
153
|
+
state: health.state,
|
|
154
|
+
lastUpdateStatus: health.lastUpdateStatus,
|
|
155
|
+
lastModified: configuration.LastModified,
|
|
156
|
+
memorySize: configuration.MemorySize,
|
|
157
|
+
timeout: configuration.Timeout,
|
|
158
|
+
imageUri: configuration.Code?.ImageUri,
|
|
159
|
+
},
|
|
160
|
+
healthy: health.healthy,
|
|
161
|
+
region,
|
|
162
|
+
computeTarget: 'lambda',
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
if (asJson) {
|
|
166
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
167
|
+
await trackSuccess('status_run', { projectName, healthy: health.healthy, json: true });
|
|
168
|
+
return payload;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (s) s.stop('Health check complete.\n');
|
|
172
|
+
printLambdaDashboard({ functionName, configuration, health });
|
|
173
|
+
|
|
174
|
+
if (!health.healthy) {
|
|
175
|
+
console.log(color.yellow(DEGRADED_MESSAGE));
|
|
176
|
+
await trackFailure('status_run', { projectName, healthy: false, degraded: true });
|
|
177
|
+
const diagnosis = await runDiagnose({ cwd, region });
|
|
178
|
+
await failCommand({ exitCode: 1 });
|
|
179
|
+
return { ...payload, diagnosis };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
outro(color.green('All systems healthy. ✅'));
|
|
183
|
+
await trackSuccess('status_run', { projectName, healthy: true });
|
|
184
|
+
return payload;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function printDashboard({ serviceName, cluster, health }) {
|
|
188
|
+
console.log('');
|
|
189
|
+
console.log(` ${color.bold('Service:')} ${color.cyan(serviceName)} ${health.status === 'ACTIVE' ? color.green('(ACTIVE)') : color.yellow(`(${health.status})`)}`);
|
|
190
|
+
console.log(` ${color.dim('Cluster:')} ${cluster}`);
|
|
191
|
+
console.log(` ${color.bold('Replicas:')} ${formatReplicas(health.runningCount, health.desiredCount, health.pendingCount)}`);
|
|
192
|
+
if (health.alarms.length === 0) {
|
|
193
|
+
console.log(` ${color.bold('Alarms:')} ${color.dim('No alarms configured.')}`);
|
|
194
|
+
} else {
|
|
195
|
+
console.log(` ${color.bold('Alarms:')}`);
|
|
196
|
+
for (const alarm of health.alarms) {
|
|
197
|
+
console.log(` ${formatAlarmBadge(alarm.state)} ${alarm.name}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
console.log('');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export async function runStatus(input = {}) {
|
|
204
|
+
const options = normalizeOptions(input);
|
|
205
|
+
let cwd;
|
|
206
|
+
let region;
|
|
207
|
+
let projectName;
|
|
208
|
+
try {
|
|
209
|
+
cwd = resolveCwd(options);
|
|
210
|
+
region = resolveRegion(options, cwd);
|
|
211
|
+
projectName = resolveProjectName(options, cwd);
|
|
212
|
+
} catch {
|
|
213
|
+
return failProjectNotInitialized({ event: 'status_run' });
|
|
214
|
+
}
|
|
215
|
+
if (readTerraformComputeTarget(cwd) === 'lambda') {
|
|
216
|
+
return runLambdaStatus({ ...options, cwd, region, projectName });
|
|
217
|
+
}
|
|
218
|
+
const cluster = options.cluster || process.env.ECS_CLUSTER || `${projectName}-cluster`;
|
|
219
|
+
const serviceName = options.service || process.env.ECS_SERVICE || `${projectName}-service`;
|
|
220
|
+
const logGroup = options.logGroup || process.env.ECS_LOG_GROUP || `/ecs/${projectName}`;
|
|
221
|
+
const asJson = Boolean(options.json);
|
|
222
|
+
|
|
223
|
+
const ecsClient = resolveClient(options.ecsClient, ECSClient, { region });
|
|
224
|
+
// Both key casings are accepted for backward compatibility; first wins.
|
|
225
|
+
const cloudWatchClient = resolveClient(
|
|
226
|
+
options.cloudWatchClient ?? options.cloudwatchClient, CloudWatchClient, { region }
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
if (!asJson) intro(color.bgCyan(color.black(' grada status 📊 ')));
|
|
230
|
+
|
|
231
|
+
const s = asJson ? null : spinner();
|
|
232
|
+
if (s) s.start('Checking service health...');
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
const svcResp = await ecsClient.send(new DescribeServicesCommand({ cluster, services: [serviceName] }));
|
|
236
|
+
const serviceDesc = (svcResp.services || [])[0] || null;
|
|
237
|
+
|
|
238
|
+
if (!serviceDesc || serviceDesc.status === 'INACTIVE') {
|
|
239
|
+
if (s) s.stop(color.yellow('Service not found.'));
|
|
240
|
+
console.log(`\n The ECS service ${color.cyan(serviceName)} does not exist or is inactive.`);
|
|
241
|
+
console.log(` Run ${color.green('npx grada-run apply')} to provision your infrastructure.\n`);
|
|
242
|
+
|
|
243
|
+
await trackSuccess('status_run', { projectName, healthy: false, missing: true });
|
|
244
|
+
|
|
245
|
+
if (asJson) return { healthy: false, missing: true, service: null, alarms: [], region };
|
|
246
|
+
process.exit(0);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const alarmsResp = await cloudWatchClient.send(new DescribeAlarmsCommand({ AlarmNamePrefix: projectName, MaxRecords: 100 }));
|
|
251
|
+
const rawAlarms = [...(alarmsResp.MetricAlarms || []), ...(alarmsResp.CompositeAlarms || [])];
|
|
252
|
+
|
|
253
|
+
const health = getServiceHealth(serviceDesc, rawAlarms);
|
|
254
|
+
const payload = {
|
|
255
|
+
service: {
|
|
256
|
+
name: serviceName,
|
|
257
|
+
cluster,
|
|
258
|
+
status: health.status,
|
|
259
|
+
desiredCount: health.desiredCount,
|
|
260
|
+
runningCount: health.runningCount,
|
|
261
|
+
pendingCount: health.pendingCount,
|
|
262
|
+
},
|
|
263
|
+
alarms: health.alarms,
|
|
264
|
+
healthy: health.healthy,
|
|
265
|
+
region,
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
if (asJson) {
|
|
269
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
270
|
+
await trackSuccess('status_run', { projectName, healthy: health.healthy, json: true });
|
|
271
|
+
return payload;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (s) s.stop('Health check complete.\n');
|
|
275
|
+
printDashboard({ serviceName, cluster, health });
|
|
276
|
+
|
|
277
|
+
if (!health.healthy) {
|
|
278
|
+
console.log(color.yellow(DEGRADED_MESSAGE));
|
|
279
|
+
await trackFailure('status_run', { projectName, healthy: false, degraded: true });
|
|
280
|
+
const diagnosis = await runDiagnose({ cluster, region, logGroup });
|
|
281
|
+
await failCommand({ exitCode: 1 });
|
|
282
|
+
return { ...payload, diagnosis };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
outro(color.green('All systems healthy. ✅'));
|
|
286
|
+
await trackSuccess('status_run', { projectName, healthy: true });
|
|
287
|
+
return payload;
|
|
288
|
+
} catch (error) {
|
|
289
|
+
await trackFailure('status_run', {
|
|
290
|
+
projectName,
|
|
291
|
+
error_code: error?.name || 'UNKNOWN',
|
|
292
|
+
error_message: error?.message,
|
|
293
|
+
});
|
|
294
|
+
if (handleAuthErrorBranch(error, s, options)) {
|
|
295
|
+
return { healthy: false, service: null, alarms: [], region };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (s) s.stop(color.red('❌ Status check failed.'));
|
|
299
|
+
return failCommand({
|
|
300
|
+
message: `✖ ${error?.message || error}`,
|
|
301
|
+
hint: 'Check your AWS credentials and region, then try again.',
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Convenience alias mirroring the CLI verb.
|
|
307
|
+
export const statusCommand = runStatus;
|
|
308
|
+
|
|
309
|
+
export default runStatus;
|