@grada-run/grada 0.0.1 → 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,289 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DescribeTasksCommand,
|
|
3
|
+
DescribeTaskDefinitionCommand,
|
|
4
|
+
RunTaskCommand,
|
|
5
|
+
StopTaskCommand,
|
|
6
|
+
} from '@aws-sdk/client-ecs';
|
|
7
|
+
import { FilterLogEventsCommand, GetLogEventsCommand } from '@aws-sdk/client-cloudwatch-logs';
|
|
8
|
+
import color from 'picocolors';
|
|
9
|
+
import { fetchActiveService, pickRuntimeContainer } from './ecs.js';
|
|
10
|
+
import { sleep, pollUntil } from './system.js';
|
|
11
|
+
import { isNotFoundError, buildLogStreamName } from '../commands/logs.js';
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_RUNNER_POLL_INTERVAL_MS = 2000;
|
|
14
|
+
export const DEFAULT_RUNNER_FLUSH_MAX_POLLS = 6;
|
|
15
|
+
export const DEFAULT_RUNNER_FLUSH_INTERVAL_MS = 1000;
|
|
16
|
+
|
|
17
|
+
function capNoun(noun) {
|
|
18
|
+
const text = String(noun || 'migration');
|
|
19
|
+
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Identity keys for one log line. FilterLogEvents results carry `eventId`
|
|
23
|
+
// but GetLogEvents results do not, so every line registers its
|
|
24
|
+
// timestamp:message fingerprint (present in both APIs) plus its `eventId`
|
|
25
|
+
// when it has one — the final head-flush then never reprints live lines.
|
|
26
|
+
function eventKeys(event) {
|
|
27
|
+
const keys = [`${event.timestamp ?? ''}:${event.message ?? ''}`];
|
|
28
|
+
if (event.eventId) keys.push(`id:${event.eventId}`);
|
|
29
|
+
return keys;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function printNewEvents(events, seen) {
|
|
33
|
+
if (seen.size > 5000) seen.clear();
|
|
34
|
+
for (const event of events || []) {
|
|
35
|
+
const keys = eventKeys(event);
|
|
36
|
+
if (keys.some((key) => seen.has(key))) continue;
|
|
37
|
+
for (const key of keys) seen.add(key);
|
|
38
|
+
console.log(String(event.message ?? '').replace(/\n$/, ''));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Live tail: FilterLogEvents scoped to the single task stream. `startTime`
|
|
43
|
+
// is deliberately omitted — the stream is unique per task run, so there is
|
|
44
|
+
// nothing stale to exclude and local clock skew can never drop early lines.
|
|
45
|
+
async function fetchNewLogEvents({ logsClient, logGroupName, logStreamName, seen }) {
|
|
46
|
+
let events = [];
|
|
47
|
+
try {
|
|
48
|
+
const resp = await logsClient.send(new FilterLogEventsCommand({
|
|
49
|
+
logGroupName,
|
|
50
|
+
logStreamNames: [logStreamName],
|
|
51
|
+
}));
|
|
52
|
+
events = resp.events || [];
|
|
53
|
+
} catch (error) {
|
|
54
|
+
// The stream does not exist until the container starts emitting.
|
|
55
|
+
if (isNotFoundError(error)) return;
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
printNewEvents(events, seen);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Final flush: GetLogEvents reads the exact stream from the head, catching
|
|
62
|
+
// anything the live tail missed (Fargate ingestion lags several seconds on
|
|
63
|
+
// sub-second commands). Shares the `seen` set so lines print exactly once.
|
|
64
|
+
async function flushRemainingLogs({ logsClient, logGroupName, logStreamName, seen }) {
|
|
65
|
+
let nextToken;
|
|
66
|
+
for (;;) {
|
|
67
|
+
let resp;
|
|
68
|
+
try {
|
|
69
|
+
resp = await logsClient.send(new GetLogEventsCommand({
|
|
70
|
+
logGroupName,
|
|
71
|
+
logStreamName,
|
|
72
|
+
startFromHead: true,
|
|
73
|
+
...(nextToken ? { nextToken } : {}),
|
|
74
|
+
}));
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (isNotFoundError(error)) return;
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
printNewEvents(resp.events, seen);
|
|
80
|
+
const forward = resp.nextForwardToken;
|
|
81
|
+
if (!forward || forward === nextToken) return;
|
|
82
|
+
nextToken = forward;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Runs a one-off Fargate task from the service's task definition, streams
|
|
87
|
+
// its CloudWatch logs live, and resolves with the container outcome.
|
|
88
|
+
// Shared by `db migrate` and `db enable-vector` so the RunTask / poll /
|
|
89
|
+
// log-streaming / SIGINT lifecycle lives in exactly one place.
|
|
90
|
+
//
|
|
91
|
+
// Outcomes (the spinner is left running on failure paths so the caller can
|
|
92
|
+
// stop it with command-specific wording before reporting):
|
|
93
|
+
// { ok: true, taskArn, exitCode, stoppedReason }
|
|
94
|
+
// { ok: false, code: 'ECS_SERVICE_NOT_FOUND' }
|
|
95
|
+
// { ok: false, code: 'NO_VPC_CONFIG' }
|
|
96
|
+
// { ok: false, code: 'CONTAINER_NOT_FOUND', targetTaskDef, available: [names] }
|
|
97
|
+
// { ok: false, code: 'RUN_TASK_FAILED', reason }
|
|
98
|
+
// { ok: false, code: 'TIMEOUT', taskArn } (task stop attempted best-effort)
|
|
99
|
+
//
|
|
100
|
+
// `command` is the container override: either the array itself or a builder
|
|
101
|
+
// `(containerDef) => string[]` invoked with the selected task-definition
|
|
102
|
+
// container (so callers can synthesize env-dependent commands without a
|
|
103
|
+
// second DescribeTaskDefinition call).
|
|
104
|
+
export async function runEphemeralEcsTask({
|
|
105
|
+
ecsClient,
|
|
106
|
+
logsClient,
|
|
107
|
+
cluster,
|
|
108
|
+
service,
|
|
109
|
+
containerName,
|
|
110
|
+
taskDef = null,
|
|
111
|
+
command,
|
|
112
|
+
startedBy,
|
|
113
|
+
logGroupName,
|
|
114
|
+
timeoutMs,
|
|
115
|
+
timeoutSeconds,
|
|
116
|
+
pollIntervalMs = DEFAULT_RUNNER_POLL_INTERVAL_MS,
|
|
117
|
+
maxFlushPolls = DEFAULT_RUNNER_FLUSH_MAX_POLLS,
|
|
118
|
+
flushIntervalMs = DEFAULT_RUNNER_FLUSH_INTERVAL_MS,
|
|
119
|
+
spinner = null,
|
|
120
|
+
taskNoun = 'migration',
|
|
121
|
+
}) {
|
|
122
|
+
const label = capNoun(taskNoun);
|
|
123
|
+
let taskArn = null;
|
|
124
|
+
const onSigint = () => {
|
|
125
|
+
if (taskArn) {
|
|
126
|
+
ecsClient.send(new StopTaskCommand({
|
|
127
|
+
cluster,
|
|
128
|
+
task: taskArn,
|
|
129
|
+
reason: 'Cancelled by user via SIGINT',
|
|
130
|
+
})).catch(() => {});
|
|
131
|
+
}
|
|
132
|
+
process.exit(130);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const serviceDesc = await fetchActiveService(ecsClient, cluster, service);
|
|
136
|
+
if (!serviceDesc) {
|
|
137
|
+
return { ok: false, code: 'ECS_SERVICE_NOT_FOUND' };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const vpcConfig = serviceDesc.networkConfiguration?.awsvpcConfiguration;
|
|
141
|
+
const subnets = vpcConfig?.subnets || [];
|
|
142
|
+
const securityGroups = vpcConfig?.securityGroups || [];
|
|
143
|
+
if (subnets.length === 0 || securityGroups.length === 0) {
|
|
144
|
+
return { ok: false, code: 'NO_VPC_CONFIG' };
|
|
145
|
+
}
|
|
146
|
+
// Mirror the service's own placement; the generated service uses
|
|
147
|
+
// public subnets with ENABLED, custom private-subnet services inherit
|
|
148
|
+
// their own value instead of a hard-coded default.
|
|
149
|
+
const assignPublicIp = vpcConfig.assignPublicIp || 'ENABLED';
|
|
150
|
+
|
|
151
|
+
const targetTaskDef = (typeof taskDef === 'string' && taskDef.trim())
|
|
152
|
+
? taskDef.trim()
|
|
153
|
+
: serviceDesc.taskDefinition;
|
|
154
|
+
const taskDefResp = await ecsClient.send(
|
|
155
|
+
new DescribeTaskDefinitionCommand({ taskDefinition: targetTaskDef })
|
|
156
|
+
);
|
|
157
|
+
const selected = pickRuntimeContainer(taskDefResp.taskDefinition, containerName);
|
|
158
|
+
if (!selected || selected.name !== containerName) {
|
|
159
|
+
return {
|
|
160
|
+
ok: false,
|
|
161
|
+
code: 'CONTAINER_NOT_FOUND',
|
|
162
|
+
targetTaskDef,
|
|
163
|
+
available: (taskDefResp.taskDefinition?.containerDefinitions || []).map((c) => c.name),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Stream coordinates from the task definition itself so custom log
|
|
168
|
+
// drivers, groups, and stream prefixes always match the live tail.
|
|
169
|
+
let groupName = logGroupName;
|
|
170
|
+
const logOptions = selected.logConfiguration?.options || {};
|
|
171
|
+
if (typeof logOptions['awslogs-group'] === 'string' && logOptions['awslogs-group']) {
|
|
172
|
+
groupName = logOptions['awslogs-group'];
|
|
173
|
+
}
|
|
174
|
+
let logStreamPrefix = 'ecs';
|
|
175
|
+
if (typeof logOptions['awslogs-stream-prefix'] === 'string' && logOptions['awslogs-stream-prefix']) {
|
|
176
|
+
logStreamPrefix = logOptions['awslogs-stream-prefix'];
|
|
177
|
+
}
|
|
178
|
+
const overrideCommand = typeof command === 'function' ? command(selected) : command;
|
|
179
|
+
const runResp = await ecsClient.send(new RunTaskCommand({
|
|
180
|
+
cluster,
|
|
181
|
+
taskDefinition: targetTaskDef,
|
|
182
|
+
launchType: 'FARGATE',
|
|
183
|
+
networkConfiguration: { awsvpcConfiguration: { subnets, securityGroups, assignPublicIp } },
|
|
184
|
+
startedBy,
|
|
185
|
+
overrides: { containerOverrides: [{ name: containerName, command: overrideCommand }] },
|
|
186
|
+
}));
|
|
187
|
+
taskArn = runResp.tasks?.[0]?.taskArn || null;
|
|
188
|
+
if (!taskArn || (runResp.failures || []).length > 0) {
|
|
189
|
+
const failureReason = runResp.failures?.[0]?.reason || 'no task ARN returned';
|
|
190
|
+
taskArn = null;
|
|
191
|
+
return { ok: false, code: 'RUN_TASK_FAILED', reason: failureReason };
|
|
192
|
+
}
|
|
193
|
+
const taskId = taskArn.split('/').pop();
|
|
194
|
+
const logStreamName = buildLogStreamName(containerName, taskId, logStreamPrefix);
|
|
195
|
+
const seen = new Set();
|
|
196
|
+
|
|
197
|
+
// Registered immediately so Ctrl+C aborts even while provisioning;
|
|
198
|
+
// the task line itself prints once the spinner stops (below) so the
|
|
199
|
+
// two never share a terminal line.
|
|
200
|
+
process.once('SIGINT', onSigint);
|
|
201
|
+
|
|
202
|
+
let finalTask = null;
|
|
203
|
+
let streaming = false;
|
|
204
|
+
let lastPhase = null;
|
|
205
|
+
const shortTaskId = taskId.slice(0, 8);
|
|
206
|
+
const phaseMessage = (status) => {
|
|
207
|
+
if (status === 'PROVISIONING' || status === 'PENDING' || status === 'ACTIVATING') {
|
|
208
|
+
return `Starting ${taskNoun} task (${status}, ${shortTaskId})...`;
|
|
209
|
+
}
|
|
210
|
+
return `Waiting on Fargate task (${status}, ${shortTaskId})...`;
|
|
211
|
+
};
|
|
212
|
+
// Strict by name only — never the containers[0] fallback: a sidecar
|
|
213
|
+
// finishing first must not end the run early.
|
|
214
|
+
const matchContainerStrict = (task) => {
|
|
215
|
+
const list = task?.containers || [];
|
|
216
|
+
return list.find((c) => c.name === containerName) || null;
|
|
217
|
+
};
|
|
218
|
+
try {
|
|
219
|
+
const outcome = await pollUntil({
|
|
220
|
+
intervalMs: pollIntervalMs,
|
|
221
|
+
timeoutMs,
|
|
222
|
+
onTick: async () => {
|
|
223
|
+
await fetchNewLogEvents({ logsClient, logGroupName: groupName, logStreamName, seen });
|
|
224
|
+
const descResp = await ecsClient.send(
|
|
225
|
+
new DescribeTasksCommand({ cluster, tasks: [taskArn] })
|
|
226
|
+
);
|
|
227
|
+
const task = (descResp.tasks || [])[0] || null;
|
|
228
|
+
const status = task?.lastStatus;
|
|
229
|
+
const match = matchContainerStrict(task);
|
|
230
|
+
const containerDone = !!match
|
|
231
|
+
&& match.lastStatus === 'STOPPED'
|
|
232
|
+
&& typeof match.exitCode === 'number';
|
|
233
|
+
if ((task && status === 'STOPPED') || containerDone) {
|
|
234
|
+
// Flush CloudWatch (retrying while nothing has printed
|
|
235
|
+
// yet: Fargate ingestion lags several seconds), then
|
|
236
|
+
// stop the task if Fargate has not already done so.
|
|
237
|
+
for (let attempt = 0; ; attempt++) {
|
|
238
|
+
await flushRemainingLogs({ logsClient, logGroupName: groupName, logStreamName, seen });
|
|
239
|
+
if (seen.size > 0 || attempt + 1 >= maxFlushPolls) break;
|
|
240
|
+
await sleep(flushIntervalMs);
|
|
241
|
+
}
|
|
242
|
+
if (task?.lastStatus !== 'STOPPED') {
|
|
243
|
+
try {
|
|
244
|
+
await ecsClient.send(new StopTaskCommand({
|
|
245
|
+
cluster,
|
|
246
|
+
task: taskArn,
|
|
247
|
+
reason: `${label} container finished; stopping task`,
|
|
248
|
+
}));
|
|
249
|
+
} catch {
|
|
250
|
+
// Best-effort: the result below is authoritative.
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (!streaming) spinner?.stop(`${label} task stopped.`);
|
|
254
|
+
return { done: true, value: task };
|
|
255
|
+
}
|
|
256
|
+
if (status === 'RUNNING' && !streaming) {
|
|
257
|
+
streaming = true;
|
|
258
|
+
spinner?.stop(color.green(`${label} container running. Streaming logs...`));
|
|
259
|
+
console.log(color.dim(` task ${taskId} — press Ctrl+C to abort and stop the remote task.\n`));
|
|
260
|
+
} else if (!streaming && status && status !== lastPhase) {
|
|
261
|
+
lastPhase = status;
|
|
262
|
+
spinner?.message(phaseMessage(status));
|
|
263
|
+
}
|
|
264
|
+
return { done: false };
|
|
265
|
+
},
|
|
266
|
+
});
|
|
267
|
+
if (outcome.timedOut) {
|
|
268
|
+
try {
|
|
269
|
+
await ecsClient.send(new StopTaskCommand({
|
|
270
|
+
cluster,
|
|
271
|
+
task: taskArn,
|
|
272
|
+
reason: `${label} timed out after ${timeoutSeconds}s`,
|
|
273
|
+
}));
|
|
274
|
+
} catch {
|
|
275
|
+
// Best-effort: the timeout below is authoritative.
|
|
276
|
+
}
|
|
277
|
+
return { ok: false, code: 'TIMEOUT', taskArn };
|
|
278
|
+
}
|
|
279
|
+
finalTask = outcome.value;
|
|
280
|
+
} finally {
|
|
281
|
+
process.removeListener('SIGINT', onSigint);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const containers = finalTask?.containers || [];
|
|
285
|
+
const match = containers.find((c) => c.name === containerName) || containers[0];
|
|
286
|
+
const exitCode = match?.exitCode;
|
|
287
|
+
const stoppedReason = match?.reason || finalTask?.stoppedReason || 'unknown';
|
|
288
|
+
return { ok: true, taskArn, exitCode, stoppedReason };
|
|
289
|
+
}
|
package/src/utils/ecs.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { spawnSync } from 'child_process';
|
|
2
|
+
import color from 'picocolors';
|
|
3
|
+
import { DescribeServicesCommand } from '@aws-sdk/client-ecs';
|
|
4
|
+
import { resolveProjectName } from './resolvers.js';
|
|
5
|
+
import { normalizeOptions } from './args.js';
|
|
6
|
+
import { AWS_CLI_INSTALL_URL } from './aws.js';
|
|
7
|
+
|
|
8
|
+
export const SESSION_MANAGER_PLUGIN_URL = 'https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-working-with-install-plugin.html';
|
|
9
|
+
|
|
10
|
+
// The container name an interactive command (exec, db connect) expects to
|
|
11
|
+
// find: explicit flag first, then ECS_CONTAINER, then the project default.
|
|
12
|
+
export function resolveContainer(options = {}, cwd = process.cwd()) {
|
|
13
|
+
const opts = normalizeOptions(options);
|
|
14
|
+
if (typeof opts.container === 'string' && opts.container.trim()) return opts.container.trim();
|
|
15
|
+
if (typeof opts.containerName === 'string' && opts.containerName.trim()) {
|
|
16
|
+
return opts.containerName.trim();
|
|
17
|
+
}
|
|
18
|
+
if (typeof process.env.ECS_CONTAINER === 'string' && process.env.ECS_CONTAINER.trim()) {
|
|
19
|
+
return process.env.ECS_CONTAINER.trim();
|
|
20
|
+
}
|
|
21
|
+
return `${resolveProjectName(opts, cwd)}-container`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Shared active-service lookup: the service object when it exists and is
|
|
25
|
+
// ACTIVE, otherwise null. Never throws for a missing/inactive service;
|
|
26
|
+
// unexpected AWS errors propagate to the caller's catch block.
|
|
27
|
+
export async function fetchActiveService(ecsClient, clusterName, serviceName) {
|
|
28
|
+
const resp = await ecsClient.send(
|
|
29
|
+
new DescribeServicesCommand({ cluster: clusterName, services: [serviceName] })
|
|
30
|
+
);
|
|
31
|
+
const service = (resp.services || [])[0] || null;
|
|
32
|
+
if (!service || service.status !== 'ACTIVE') return null;
|
|
33
|
+
return service;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Container preference shared by the interactive commands: the expected
|
|
37
|
+
// name first, then the first RUNNING container, then the first entry.
|
|
38
|
+
// Works for both live tasks and task-definition `containerDefinitions`
|
|
39
|
+
// (which simply never match the RUNNING step).
|
|
40
|
+
export function pickRuntimeContainer(task, expectedName) {
|
|
41
|
+
const containers = task?.containers || task?.containerDefinitions || [];
|
|
42
|
+
if (containers.length === 0) return null;
|
|
43
|
+
const exact = containers.find((c) => c.name === expectedName);
|
|
44
|
+
if (exact) return exact;
|
|
45
|
+
const running = containers.find((c) => c.lastStatus === 'RUNNING');
|
|
46
|
+
if (running) return running;
|
|
47
|
+
return containers[0];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function hasSessionManagerPlugin(options = {}) {
|
|
51
|
+
const runSync = options.spawnSyncImpl || spawnSync;
|
|
52
|
+
try {
|
|
53
|
+
const result = runSync('session-manager-plugin', ['--version'], { stdio: 'ignore' });
|
|
54
|
+
if (result && result.error && result.error.code === 'ENOENT') return false;
|
|
55
|
+
return true;
|
|
56
|
+
} catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Shared pre-flight guidance for the interactive ECS commands. `commandName`
|
|
62
|
+
// is the CLI verb shown in bold (`exec`, `db connect`); `purpose` completes
|
|
63
|
+
// the sentence ("needs the AWS CLI ... <purpose>.").
|
|
64
|
+
export function printAwsCliGuidance({ commandName = 'exec', purpose = 'to open a shell in your container' } = {}) {
|
|
65
|
+
console.log(color.red('\n✖ AWS CLI not found.'));
|
|
66
|
+
console.log(` ${color.bold(commandName)} needs the AWS CLI (plus the Session Manager plugin) ${purpose}.`);
|
|
67
|
+
console.log(` Install the AWS CLI: ${color.blue(color.underline(AWS_CLI_INSTALL_URL))}`);
|
|
68
|
+
console.log(` Install the Session Manager plugin: ${color.blue(color.underline(SESSION_MANAGER_PLUGIN_URL))}`);
|
|
69
|
+
console.log(color.dim(' Then run `aws sso login` (or `aws configure`) and try again.\n'));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function printSessionManagerGuidance({ commandName = 'exec', purpose = 'to securely tunnel into your container' } = {}) {
|
|
73
|
+
console.log(color.red('\n✖ AWS Session Manager plugin not found.'));
|
|
74
|
+
console.log(` ${color.bold(commandName)} requires a system-level AWS plugin ${purpose}.`);
|
|
75
|
+
console.log(color.yellow('\n To install it:'));
|
|
76
|
+
|
|
77
|
+
if (process.platform === 'darwin') {
|
|
78
|
+
console.log(` Mac: ${color.cyan('brew install session-manager-plugin')}`);
|
|
79
|
+
} else if (process.platform === 'win32') {
|
|
80
|
+
console.log(` Win: Download from ${color.blue(color.underline(`${SESSION_MANAGER_PLUGIN_URL}#install-plugin-windows`))}`);
|
|
81
|
+
} else {
|
|
82
|
+
console.log(` Linux: See ${color.blue(color.underline(`${SESSION_MANAGER_PLUGIN_URL}#install-plugin-linux`))}`);
|
|
83
|
+
}
|
|
84
|
+
console.log('');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function printNoTasksGuidance(service, cluster, reason = 'to open an interactive shell') {
|
|
88
|
+
console.log(color.yellow('\n⚠ No running containers found.'));
|
|
89
|
+
console.log(` The ECS service ${color.cyan(service)} in cluster ${color.cyan(cluster)} has no RUNNING tasks.`);
|
|
90
|
+
console.log(` A running container is required ${reason}.`);
|
|
91
|
+
console.log(` Check status with ${color.green('npx grada-run status')}, then run ${color.green('npx grada-run apply')} to start your service.\n`);
|
|
92
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import fsSync from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { text, confirm, cancel, log } from '@clack/prompts';
|
|
4
|
+
import color from 'picocolors';
|
|
5
|
+
|
|
6
|
+
export async function resolveDjangoWsgi(targetDir, procfile, framework, isHeadless) {
|
|
7
|
+
if (framework !== 'django') return 'core.wsgi';
|
|
8
|
+
|
|
9
|
+
let extractedWsgi = null;
|
|
10
|
+
if (procfile && procfile.web) {
|
|
11
|
+
const webCommand = procfile.web.join(' ');
|
|
12
|
+
const wsgiMatch = webCommand.match(/([a-zA-Z0-9_]+)\.wsgi/);
|
|
13
|
+
if (wsgiMatch) extractedWsgi = `${wsgiMatch[1]}.wsgi`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (extractedWsgi) {
|
|
17
|
+
if (!isHeadless) log.success(`Auto-detected Django WSGI from Procfile: ${color.cyan(extractedWsgi)}`);
|
|
18
|
+
return extractedWsgi;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (isHeadless) return 'core.wsgi';
|
|
22
|
+
|
|
23
|
+
const djangoWsgi = await text({
|
|
24
|
+
message: 'What is the Python module path to your Django wsgi.py?',
|
|
25
|
+
placeholder: 'core.wsgi',
|
|
26
|
+
initialValue: 'core.wsgi',
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
if (typeof djangoWsgi === 'symbol') process.exit(0);
|
|
30
|
+
return djangoWsgi;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function handleRailsCI(targetDir, framework, isHeadless) {
|
|
34
|
+
if (framework !== 'rails') return false;
|
|
35
|
+
|
|
36
|
+
const ciPath = path.join(targetDir, '.github', 'workflows', 'ci.yml');
|
|
37
|
+
const dependabotPath = path.join(targetDir, '.github', 'dependabot.yml');
|
|
38
|
+
|
|
39
|
+
if (fsSync.existsSync(ciPath) || fsSync.existsSync(dependabotPath)) {
|
|
40
|
+
if (isHeadless) return true;
|
|
41
|
+
|
|
42
|
+
console.log('');
|
|
43
|
+
const disable = await confirm({
|
|
44
|
+
message: color.yellow('We detected default Rails GitHub Actions (ci.yml, dependabot.yml) that usually crash in isolated CI environments without a database. Would you like grada to safely disable them by renaming them to .bak?'),
|
|
45
|
+
initialValue: true,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
if (typeof disable === 'symbol') {
|
|
49
|
+
cancel('Provisioning cancelled.');
|
|
50
|
+
process.exit(0);
|
|
51
|
+
}
|
|
52
|
+
return disable;
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
}
|