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,140 @@
|
|
|
1
|
+
import fsSync from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { normalizeOptions } from './args.js';
|
|
4
|
+
import { resolveProjectName, resolveWorkspaceSuffix, resolveCluster, resolveService, resolveCwd } from './resolvers.js';
|
|
5
|
+
import { resolveDbIdentifier, resolveDbClusterIdentifier } from './rds.js';
|
|
6
|
+
|
|
7
|
+
export const SLEEP_STATE_DIRNAME = '.grada';
|
|
8
|
+
export const LEGACY_SLEEP_STATE_DIRNAME = '.deploy-stack';
|
|
9
|
+
export const SLEEP_STATE_FILENAME = 'sleep-state.json';
|
|
10
|
+
export const RDS_AUTO_RESTART_DAYS = 7;
|
|
11
|
+
export const RDS_AUTO_RESTART_MS = RDS_AUTO_RESTART_DAYS * 24 * 60 * 60 * 1000;
|
|
12
|
+
|
|
13
|
+
// Environment aliases that address the default/production environment.
|
|
14
|
+
// Returns the canonical env name, or null when no env was requested.
|
|
15
|
+
export function normalizeSleepEnv(rawEnv) {
|
|
16
|
+
if (typeof rawEnv !== 'string') return null;
|
|
17
|
+
const trimmed = rawEnv.trim();
|
|
18
|
+
if (trimmed === '') return null;
|
|
19
|
+
const lowered = trimmed.toLowerCase();
|
|
20
|
+
if (lowered === 'default' || lowered === 'prod' || lowered === 'production') return 'default';
|
|
21
|
+
return trimmed;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Shared target resolution for `sleep` / `wake`: an explicit `--workspace`
|
|
25
|
+
// wins over the positional `[env]`; either beats the
|
|
26
|
+
// `.terraform/environment` auto-detect; otherwise the default environment.
|
|
27
|
+
// Explicit `--cluster` / `--service` / `--db-identifier` overrides still win
|
|
28
|
+
// inside the resolvers. Returns `{ envKey, envKind, requiresConfirm,
|
|
29
|
+
// appPrefix, cluster, appService, workerService, dbIdentifier,
|
|
30
|
+
// dbClusterIdentifier }`.
|
|
31
|
+
export function resolveSleepTarget(options = {}, cwd = process.cwd()) {
|
|
32
|
+
const opts = normalizeOptions(options);
|
|
33
|
+
const base = resolveCwd(opts, cwd);
|
|
34
|
+
const projectName = resolveProjectName(opts, base);
|
|
35
|
+
const rawEnv = (typeof opts.workspace === 'string' && opts.workspace.trim() !== '')
|
|
36
|
+
? opts.workspace
|
|
37
|
+
: opts.env;
|
|
38
|
+
const env = normalizeSleepEnv(rawEnv);
|
|
39
|
+
const scoped = { ...opts, workspace: env === null ? opts.workspace : env };
|
|
40
|
+
const suffix = resolveWorkspaceSuffix(scoped, base);
|
|
41
|
+
const appPrefix = `${projectName}${suffix}`;
|
|
42
|
+
const prefixed = { ...scoped, projectName: appPrefix };
|
|
43
|
+
const unprefixed = { ...scoped, projectName };
|
|
44
|
+
return {
|
|
45
|
+
envKey: suffix === '' ? 'default' : suffix.slice(1),
|
|
46
|
+
envKind: suffix === '' ? 'default' : 'named',
|
|
47
|
+
// The production guard fires unless an explicit non-default env was
|
|
48
|
+
// passed — an auto-detected named workspace still prompts, since the
|
|
49
|
+
// user never named it on the command line.
|
|
50
|
+
requiresConfirm: env === null || env === 'default',
|
|
51
|
+
appPrefix,
|
|
52
|
+
cluster: resolveCluster(prefixed, base),
|
|
53
|
+
appService: resolveService(prefixed, base),
|
|
54
|
+
workerService: `${appPrefix}-worker-service`,
|
|
55
|
+
dbIdentifier: resolveDbIdentifier(unprefixed, base),
|
|
56
|
+
dbClusterIdentifier: resolveDbClusterIdentifier(unprefixed, base),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function sleepStatePath(cwd = process.cwd()) {
|
|
61
|
+
return path.join(resolveCwd({}, cwd), SLEEP_STATE_DIRNAME, SLEEP_STATE_FILENAME);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function legacySleepStatePath(cwd = process.cwd()) {
|
|
65
|
+
return path.join(resolveCwd({}, cwd), LEGACY_SLEEP_STATE_DIRNAME, SLEEP_STATE_FILENAME);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function readLedgerFile(filePath) {
|
|
69
|
+
try {
|
|
70
|
+
const parsed = JSON.parse(fsSync.readFileSync(filePath, 'utf8'));
|
|
71
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
|
|
72
|
+
} catch {
|
|
73
|
+
// Fall through to null below.
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Reads the per-env sleep ledger (`{ [env]: entry }`). The new `.grada/`
|
|
79
|
+
// path wins; the legacy `.deploy-stack/` path is a read fallback so
|
|
80
|
+
// pre-rebrand projects wake cleanly. Missing or corrupt files read as
|
|
81
|
+
// empty so a hand-edited file never crashes wake/sleep.
|
|
82
|
+
export function readSleepState(cwd = process.cwd()) {
|
|
83
|
+
return readLedgerFile(sleepStatePath(cwd))
|
|
84
|
+
?? readLedgerFile(legacySleepStatePath(cwd))
|
|
85
|
+
?? {};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function writeSleepState(cwd = process.cwd(), state = {}) {
|
|
89
|
+
const filePath = sleepStatePath(cwd);
|
|
90
|
+
fsSync.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
91
|
+
fsSync.writeFileSync(filePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function removeSleepStateEntry(cwd = process.cwd(), envKey = 'default') {
|
|
95
|
+
const state = readSleepState(cwd);
|
|
96
|
+
if (!Object.prototype.hasOwnProperty.call(state, envKey)) return false;
|
|
97
|
+
delete state[envKey];
|
|
98
|
+
writeSleepState(cwd, state);
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Keeps the advisory sleep ledger out of git. Returns true when the file was
|
|
103
|
+
// created or amended, false when the rule already existed or the write failed.
|
|
104
|
+
export function ensureSleepGitignore(cwd = process.cwd()) {
|
|
105
|
+
const base = resolveCwd({}, cwd);
|
|
106
|
+
const file = path.join(base, '.gitignore');
|
|
107
|
+
let content = null;
|
|
108
|
+
try {
|
|
109
|
+
if (fsSync.existsSync(file)) content = fsSync.readFileSync(file, 'utf8');
|
|
110
|
+
} catch {
|
|
111
|
+
content = null;
|
|
112
|
+
}
|
|
113
|
+
if (typeof content === 'string' && content.split('\n').some((line) => line.trim() === `${SLEEP_STATE_DIRNAME}/`)) {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
const entry = `# Local grada runtime state (sleep/wake)\n${SLEEP_STATE_DIRNAME}/\n`;
|
|
117
|
+
try {
|
|
118
|
+
if (content === null) {
|
|
119
|
+
fsSync.writeFileSync(file, entry, 'utf8');
|
|
120
|
+
} else {
|
|
121
|
+
fsSync.appendFileSync(file, content.endsWith('\n') ? `\n${entry}` : `\n\n${entry}`, 'utf8');
|
|
122
|
+
}
|
|
123
|
+
} catch {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// AWS automatically restarts stopped RDS instances and Aurora clusters after
|
|
130
|
+
// 7 consecutive days. Pure over an injectable clock for unit tests.
|
|
131
|
+
export function computeAutoRestartAt(nowMs = Date.now()) {
|
|
132
|
+
return new Date(nowMs + RDS_AUTO_RESTART_MS);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function formatUtcTimestamp(date) {
|
|
136
|
+
const d = date instanceof Date ? date : new Date(date);
|
|
137
|
+
const pad = (value) => String(value).padStart(2, '0');
|
|
138
|
+
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`
|
|
139
|
+
+ ` ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())} UTC`;
|
|
140
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import fsSync from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
// Auxiliary sleep/wake targets driven through the AWS CLI: EventBridge
|
|
5
|
+
// Scheduler pause/resume and ECS Application Auto Scaling suspend/resume.
|
|
6
|
+
// The repo has no @aws-sdk/client-scheduler or
|
|
7
|
+
// @aws-sdk/client-application-auto-scaling dependency, and `doctor`
|
|
8
|
+
// already requires the AWS CLI — so these helpers shell out through an
|
|
9
|
+
// injectable `run` (spawnSync-shaped, mirroring drift.js `spawnSyncImpl`)
|
|
10
|
+
// instead of adding new npm packages. Every helper degrades to a skip
|
|
11
|
+
// reason; callers warn and continue so sleep/wake never fail on these.
|
|
12
|
+
|
|
13
|
+
// cron.tf renders `name = "${local.app_name}-cron-<slug>"`. Resolves the
|
|
14
|
+
// live schedule name for an app prefix, or null when cron isn't configured
|
|
15
|
+
// (or the file was hand-edited past recognition).
|
|
16
|
+
export function readCronScheduleName(cwd, appPrefix) {
|
|
17
|
+
let content;
|
|
18
|
+
try {
|
|
19
|
+
content = fsSync.readFileSync(path.join(cwd, 'terraform', 'cron.tf'), 'utf8');
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
const match = /\$\{local\.app_name\}-cron-([A-Za-z0-9-]+)/.exec(content);
|
|
24
|
+
if (!match) return null;
|
|
25
|
+
return `${appPrefix}-cron-${match[1]}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function hasCronAddon(cwd) {
|
|
29
|
+
return fsSync.existsSync(path.join(cwd, 'terraform', 'cron.tf'));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// The SQS queue-depth scaling target exists only when the queue addon was
|
|
33
|
+
// scaffolded alongside a worker service.
|
|
34
|
+
export function hasSqsWorkerScaling(cwd) {
|
|
35
|
+
return fsSync.existsSync(path.join(cwd, 'terraform', 'sqs.tf'))
|
|
36
|
+
&& fsSync.existsSync(path.join(cwd, 'terraform', 'worker.tf'));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function hasRedisAddon(cwd) {
|
|
40
|
+
return fsSync.existsSync(path.join(cwd, 'terraform', 'redis.tf'));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function tailLines(output, count = 5) {
|
|
44
|
+
return String(output ?? '').split('\n').slice(-count).join('\n').trim();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Fetches the full schedule document. Never throws: missing CLI,
|
|
48
|
+
// undeployed schedules, and parse failures all return skip reasons.
|
|
49
|
+
export function getSchedulerSchedule({ name, region, run }) {
|
|
50
|
+
const res = run('aws', [
|
|
51
|
+
'scheduler', 'get-schedule',
|
|
52
|
+
'--name', name,
|
|
53
|
+
'--region', region,
|
|
54
|
+
'--output', 'json',
|
|
55
|
+
], { encoding: 'utf8' });
|
|
56
|
+
if (res?.error?.code === 'ENOENT') return { ok: false, reason: 'cli-missing' };
|
|
57
|
+
if (res?.status !== 0) {
|
|
58
|
+
const detail = tailLines(res?.stderr || res?.stdout);
|
|
59
|
+
if (/ResourceNotFound/i.test(detail)) return { ok: false, reason: 'not-deployed' };
|
|
60
|
+
return { ok: false, reason: 'get-failed', detail };
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
return { ok: true, schedule: JSON.parse(res.stdout) };
|
|
64
|
+
} catch {
|
|
65
|
+
return { ok: false, reason: 'bad-response' };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// UpdateSchedule is a full PUT replacement: the fetched document is passed
|
|
70
|
+
// back with only State flipped. Get-only fields (Arn, CreationDate,
|
|
71
|
+
// LastModificationDate) are dropped so --cli-input-json validates.
|
|
72
|
+
export function setSchedulerState({ name, enabled, region, run }) {
|
|
73
|
+
const got = getSchedulerSchedule({ name, region, run });
|
|
74
|
+
if (!got.ok) return got;
|
|
75
|
+
const desired = enabled ? 'ENABLED' : 'DISABLED';
|
|
76
|
+
if (got.schedule?.State === desired) return { ok: true, skipped: 'already' };
|
|
77
|
+
const s = got.schedule;
|
|
78
|
+
const payload = { Name: name, State: desired };
|
|
79
|
+
for (const key of ['GroupName', 'ScheduleExpression', 'ScheduleExpressionTimezone',
|
|
80
|
+
'FlexibleTimeWindow', 'Target', 'Description', 'StartDate', 'EndDate',
|
|
81
|
+
'ActionAfterCompletion', 'KmsKeyArn']) {
|
|
82
|
+
if (s[key] !== undefined && s[key] !== null) payload[key] = s[key];
|
|
83
|
+
}
|
|
84
|
+
const res = run('aws', [
|
|
85
|
+
'scheduler', 'update-schedule',
|
|
86
|
+
'--cli-input-json', JSON.stringify(payload),
|
|
87
|
+
'--region', region,
|
|
88
|
+
'--output', 'json',
|
|
89
|
+
], { encoding: 'utf8' });
|
|
90
|
+
if (res?.error?.code === 'ENOENT') return { ok: false, reason: 'cli-missing' };
|
|
91
|
+
if (res?.status !== 0) return { ok: false, reason: 'update-failed', detail: tailLines(res?.stderr || res?.stdout) };
|
|
92
|
+
return { ok: true };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function scalableTargetId(cluster, service) {
|
|
96
|
+
return `service/${cluster}/${service}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Suspends (sleep) or resumes (wake) worker auto-scaling. Min/max capacity
|
|
100
|
+
// are omitted so a registered target keeps its limits; unregistered
|
|
101
|
+
// targets (sqs.tf rendered while workerless) skip quietly.
|
|
102
|
+
export function setWorkerScalingSuspended({ cluster, service, suspended, region, run }) {
|
|
103
|
+
const resourceId = scalableTargetId(cluster, service);
|
|
104
|
+
const described = run('aws', [
|
|
105
|
+
'application-autoscaling', 'describe-scalable-targets',
|
|
106
|
+
'--service-namespace', 'ecs',
|
|
107
|
+
'--resource-ids', resourceId,
|
|
108
|
+
'--region', region,
|
|
109
|
+
'--output', 'json',
|
|
110
|
+
], { encoding: 'utf8' });
|
|
111
|
+
if (described?.error?.code === 'ENOENT') return { ok: false, reason: 'cli-missing' };
|
|
112
|
+
if (described?.status !== 0) {
|
|
113
|
+
return { ok: false, reason: 'describe-failed', detail: tailLines(described?.stderr || described?.stdout) };
|
|
114
|
+
}
|
|
115
|
+
let targets = [];
|
|
116
|
+
try {
|
|
117
|
+
targets = JSON.parse(described.stdout || '{}').ScalableTargets || [];
|
|
118
|
+
} catch {
|
|
119
|
+
return { ok: false, reason: 'bad-response' };
|
|
120
|
+
}
|
|
121
|
+
if (targets.length === 0) return { ok: false, reason: 'not-registered' };
|
|
122
|
+
const state = targets[0].SuspendedState || {};
|
|
123
|
+
const already = suspended
|
|
124
|
+
? state.DynamicScalingInSuspended === true && state.DynamicScalingOutSuspended === true && state.ScheduledScalingSuspended === true
|
|
125
|
+
: state.DynamicScalingInSuspended !== true && state.DynamicScalingOutSuspended !== true && state.ScheduledScalingSuspended !== true;
|
|
126
|
+
if (already) return { ok: true, skipped: 'already' };
|
|
127
|
+
const v = suspended ? 'true' : 'false';
|
|
128
|
+
const res = run('aws', [
|
|
129
|
+
'application-autoscaling', 'register-scalable-target',
|
|
130
|
+
'--service-namespace', 'ecs',
|
|
131
|
+
'--scalable-dimension', 'ecs:service:DesiredCount',
|
|
132
|
+
'--resource-id', resourceId,
|
|
133
|
+
'--suspended-state', `DynamicScalingInSuspended=${v},DynamicScalingOutSuspended=${v},ScheduledScalingSuspended=${v}`,
|
|
134
|
+
'--region', region,
|
|
135
|
+
], { encoding: 'utf8' });
|
|
136
|
+
if (res?.error?.code === 'ENOENT') return { ok: false, reason: 'cli-missing' };
|
|
137
|
+
if (res?.status !== 0) return { ok: false, reason: 'update-failed', detail: tailLines(res?.stderr || res?.stdout) };
|
|
138
|
+
return { ok: true };
|
|
139
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import util from 'util';
|
|
2
|
+
import { exec } from 'child_process';
|
|
3
|
+
|
|
4
|
+
const execAsync = util.promisify(exec);
|
|
5
|
+
|
|
6
|
+
export async function checkDependency(command) {
|
|
7
|
+
try {
|
|
8
|
+
await execAsync(`${command} --version`);
|
|
9
|
+
return true;
|
|
10
|
+
} catch (error) {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function sleep(ms) {
|
|
16
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Parses a `--timeout` flag value (seconds). Returns the default when the
|
|
20
|
+
// flag is omitted, the positive integer when valid, or null when invalid.
|
|
21
|
+
export function parseTimeoutSeconds(value, defaultSeconds) {
|
|
22
|
+
if (value === undefined || value === null || String(value).trim() === '') return defaultSeconds;
|
|
23
|
+
if (!/^\d+$/.test(String(value).trim())) return null;
|
|
24
|
+
const seconds = Number(String(value).trim());
|
|
25
|
+
if (!Number.isSafeInteger(seconds) || seconds <= 0) return null;
|
|
26
|
+
return seconds;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Shared polling loop: runs `onTick({ elapsedMs })` immediately, then every
|
|
30
|
+
// `intervalMs`, until it resolves `{ done: true, value }` or `timeoutMs`
|
|
31
|
+
// elapses. Resolves `{ done: true, value }` or `{ timedOut: true }`.
|
|
32
|
+
// `sleepFn`/`nowFn` are injectable so tests never wait on real timers.
|
|
33
|
+
export async function pollUntil({ intervalMs = 2000, timeoutMs = 600000, sleepFn = sleep, nowFn = Date.now, onTick }) {
|
|
34
|
+
const startTime = nowFn();
|
|
35
|
+
for (;;) {
|
|
36
|
+
const elapsedMs = nowFn() - startTime;
|
|
37
|
+
if (elapsedMs >= timeoutMs) return { timedOut: true };
|
|
38
|
+
const outcome = await onTick({ elapsedMs });
|
|
39
|
+
if (outcome && outcome.done) return { done: true, value: outcome.value };
|
|
40
|
+
await sleepFn(intervalMs);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import color from 'picocolors';
|
|
3
|
+
|
|
4
|
+
// Runs a terraform command while piping the latest stdout line into a
|
|
5
|
+
// @clack spinner. Resolves on exit 0; rejects with the captured stderr
|
|
6
|
+
// (or the exit code) otherwise.
|
|
7
|
+
export function runTerraformCommand(args, cwd, spin, loadingPrefix) {
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
const child = spawn('terraform', args, { cwd });
|
|
10
|
+
let errorOutput = '';
|
|
11
|
+
let isDone = false;
|
|
12
|
+
|
|
13
|
+
child.stdout.on('data', (data) => {
|
|
14
|
+
if (isDone) return;
|
|
15
|
+
const lines = data.toString().split('\n').filter(line => line.trim() !== '');
|
|
16
|
+
if (lines.length > 0) {
|
|
17
|
+
const latestLine = lines[lines.length - 1].trim();
|
|
18
|
+
const display = latestLine.length > 120 ? latestLine.substring(0, 117) + '...' : latestLine;
|
|
19
|
+
spin.message(`${loadingPrefix} - ${color.dim(display)}`);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
child.stderr.on('data', (data) => {
|
|
24
|
+
errorOutput += data.toString();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
child.on('close', (code) => {
|
|
28
|
+
isDone = true;
|
|
29
|
+
if (code === 0) {
|
|
30
|
+
resolve();
|
|
31
|
+
} else {
|
|
32
|
+
reject(new Error(errorOutput || `Terraform exited with code ${code}`));
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
child.on('error', (err) => {
|
|
37
|
+
isDone = true;
|
|
38
|
+
reject(err);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Pulls JSON outputs from Terraform. Never throws: unparseable output,
|
|
44
|
+
// non-zero exit, and spawn errors all resolve to an empty object.
|
|
45
|
+
export function getTerraformOutputs(cwd) {
|
|
46
|
+
return new Promise((resolve) => {
|
|
47
|
+
const child = spawn('terraform', ['output', '-json'], { cwd });
|
|
48
|
+
let outputData = '';
|
|
49
|
+
|
|
50
|
+
child.stdout.on('data', (data) => {
|
|
51
|
+
outputData += data.toString();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
child.on('close', (code) => {
|
|
55
|
+
if (code === 0) {
|
|
56
|
+
try {
|
|
57
|
+
resolve(JSON.parse(outputData));
|
|
58
|
+
} catch {
|
|
59
|
+
resolve({});
|
|
60
|
+
}
|
|
61
|
+
} else {
|
|
62
|
+
resolve({});
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
child.on('error', () => {
|
|
67
|
+
resolve({});
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
}
|