@grada-run/grada 0.0.1 → 0.32.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/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 +84 -0
- package/apps/docs/src/content/docs/cli/apply.md +32 -0
- package/apps/docs/src/content/docs/cli/db.md +200 -0
- package/apps/docs/src/content/docs/cli/destroy.md +31 -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 +29 -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 +75 -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 +97 -0
- package/apps/docs/src/content/docs/testing-strategy.md +32 -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 +105 -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/deploy-stack-to-grada-run-rebrand.md +45 -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/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/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 +127 -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 +126 -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 +65 -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 +2037 -0
- package/tests/ai.test.js +94 -0
- package/tests/apply.test.js +488 -0
- package/tests/args.test.js +86 -0
- package/tests/aws.test.js +244 -0
- package/tests/capabilities.test.js +307 -0
- package/tests/cli.test.js +29 -0
- package/tests/command.test.js +100 -0
- package/tests/commands-import.test.js +74 -0
- package/tests/db.test.js +2704 -0
- package/tests/destroy.test.js +391 -0
- package/tests/detector.test.js +79 -0
- package/tests/diagnose.test.js +779 -0
- package/tests/doctor.test.js +202 -0
- package/tests/domain.test.js +899 -0
- package/tests/drift.test.js +243 -0
- package/tests/ecs.test.js +130 -0
- package/tests/eject.test.js +65 -0
- package/tests/exec.test.js +380 -0
- package/tests/gc.test.js +496 -0
- package/tests/generator.test.js +794 -0
- package/tests/headless.test.js +562 -0
- package/tests/lambda-ecr.test.js +185 -0
- package/tests/logs.test.js +447 -0
- package/tests/parser.test.js +160 -0
- package/tests/rds.test.js +244 -0
- package/tests/resolvers.test.js +282 -0
- package/tests/rollback.test.js +692 -0
- package/tests/secrets.test.js +752 -0
- package/tests/sleep-wake.test.js +1016 -0
- package/tests/status.test.js +370 -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 +496 -0
- package/vitest.config.js +9 -0
- package/index.js +0 -2
package/src/utils/hcl.js
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
// Shared HCL block-manipulation toolkit for CLI commands that patch
|
|
2
|
+
// generated Terraform files (container env injection, worker lifecycle,
|
|
3
|
+
// snapshot pins, CloudFront domain wiring).
|
|
4
|
+
//
|
|
5
|
+
// All helpers are pure string functions. Every scanner is aware of
|
|
6
|
+
// double-quoted string literals (with backslash escapes) and `#` / `//`
|
|
7
|
+
// line comments plus `/* */` block comments, so brackets and identifiers
|
|
8
|
+
// inside them never disturb matching. Returned bounds are indices into the
|
|
9
|
+
// string that was passed in; `null` means "not found" and callers must
|
|
10
|
+
// leave the content unchanged.
|
|
11
|
+
const OPEN_TO_CLOSE = { '{': '}', '[': ']', '(': ')' };
|
|
12
|
+
|
|
13
|
+
function isIdentChar(ch) {
|
|
14
|
+
return ch !== undefined && /[A-Za-z0-9_-]/.test(ch);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Advances `index` past whitespace and comments. Returns the first index
|
|
18
|
+
// at or after `index` holding meaningful content.
|
|
19
|
+
function skipTrivia(content, index) {
|
|
20
|
+
let i = index;
|
|
21
|
+
for (;;) {
|
|
22
|
+
while (i < content.length && /\s/.test(content[i])) i++;
|
|
23
|
+
if (content.startsWith('//', i) || content[i] === '#') {
|
|
24
|
+
const end = content.indexOf('\n', i);
|
|
25
|
+
i = end === -1 ? content.length : end + 1;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (content.startsWith('/*', i)) {
|
|
29
|
+
const end = content.indexOf('*/', i + 2);
|
|
30
|
+
i = end === -1 ? content.length : end + 2;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
return i;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Skips one double-quoted string literal starting at the opening quote.
|
|
38
|
+
// Returns the index just past the closing quote (or content.length when
|
|
39
|
+
// unterminated).
|
|
40
|
+
function skipString(content, index) {
|
|
41
|
+
let i = index + 1;
|
|
42
|
+
while (i < content.length) {
|
|
43
|
+
if (content[i] === '\\') {
|
|
44
|
+
i += 2;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (content[i] === '"') return i + 1;
|
|
48
|
+
i++;
|
|
49
|
+
}
|
|
50
|
+
return i;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Finds the balanced close for the bracket at `openIdx`. Returns
|
|
54
|
+
// `{ openIdx, closeIdx }` or null when the opener is missing or the
|
|
55
|
+
// bracket is unterminated.
|
|
56
|
+
function scanBalanced(content, openIdx, openCh) {
|
|
57
|
+
const text = String(content ?? '');
|
|
58
|
+
if (text[openIdx] !== openCh) return null;
|
|
59
|
+
const closeCh = OPEN_TO_CLOSE[openCh];
|
|
60
|
+
let depth = 0;
|
|
61
|
+
let i = openIdx;
|
|
62
|
+
while (i < text.length) {
|
|
63
|
+
const ch = text[i];
|
|
64
|
+
if (ch === '"') {
|
|
65
|
+
i = skipString(text, i);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (ch === '#' || text.startsWith('//', i)) {
|
|
69
|
+
const end = text.indexOf('\n', i);
|
|
70
|
+
i = end === -1 ? text.length : end + 1;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (text.startsWith('/*', i)) {
|
|
74
|
+
const end = text.indexOf('*/', i + 2);
|
|
75
|
+
i = end === -1 ? text.length : end + 2;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (ch === openCh) {
|
|
79
|
+
depth++;
|
|
80
|
+
} else if (ch === closeCh) {
|
|
81
|
+
depth--;
|
|
82
|
+
if (depth === 0) return { openIdx, closeIdx: i };
|
|
83
|
+
}
|
|
84
|
+
i++;
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function escapeRegExp(raw) {
|
|
90
|
+
return String(raw).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Locates `resource "<type>" "<name>" { ... }`. Returns
|
|
94
|
+
// `{ headerIdx, openIdx, closeIdx }` or null when the resource or its
|
|
95
|
+
// braces cannot be found.
|
|
96
|
+
export function findResourceBlock(content, resourceType, resourceName) {
|
|
97
|
+
const text = String(content ?? '');
|
|
98
|
+
const pattern = new RegExp(
|
|
99
|
+
`resource\\s+"${escapeRegExp(resourceType)}"\\s+"${escapeRegExp(resourceName)}"`
|
|
100
|
+
);
|
|
101
|
+
const match = pattern.exec(text);
|
|
102
|
+
if (!match) return null;
|
|
103
|
+
const headerIdx = match.index;
|
|
104
|
+
const openIdx = text.indexOf('{', headerIdx + match[0].length);
|
|
105
|
+
if (openIdx === -1) return null;
|
|
106
|
+
const bounds = scanBalanced(text, openIdx, '{');
|
|
107
|
+
if (!bounds) return null;
|
|
108
|
+
return { headerIdx, openIdx, closeIdx: bounds.closeIdx };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Locates a nested `blockName { ... }` (with optional quoted labels)
|
|
112
|
+
// at the top level of a block slice. Returns
|
|
113
|
+
// `{ startIdx, openIdx, closeIdx }` (startIdx is the keyword) or null.
|
|
114
|
+
export function findNestedBlock(blockContent, blockName) {
|
|
115
|
+
const text = String(blockContent ?? '');
|
|
116
|
+
const name = String(blockName ?? '');
|
|
117
|
+
if (!name) return null;
|
|
118
|
+
let depth = 0;
|
|
119
|
+
let i = 0;
|
|
120
|
+
while (i < text.length) {
|
|
121
|
+
const ch = text[i];
|
|
122
|
+
if (ch === '"') {
|
|
123
|
+
i = skipString(text, i);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (ch === '#' || text.startsWith('//', i)) {
|
|
127
|
+
const end = text.indexOf('\n', i);
|
|
128
|
+
i = end === -1 ? text.length : end + 1;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (text.startsWith('/*', i)) {
|
|
132
|
+
const end = text.indexOf('*/', i + 2);
|
|
133
|
+
i = end === -1 ? text.length : end + 2;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (ch === '{') {
|
|
137
|
+
depth++;
|
|
138
|
+
i++;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (ch === '}') {
|
|
142
|
+
depth = Math.max(0, depth - 1);
|
|
143
|
+
i++;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (depth === 1 && /[A-Za-z_]/.test(ch) && (i === 0 || !isIdentChar(text[i - 1]))) {
|
|
147
|
+
let end = i + 1;
|
|
148
|
+
while (end < text.length && isIdentChar(text[end])) end++;
|
|
149
|
+
if (text.slice(i, end) === name) {
|
|
150
|
+
let j = skipTrivia(text, end);
|
|
151
|
+
// Skip optional quoted labels (`block "label" {`).
|
|
152
|
+
while (text[j] === '"') j = skipTrivia(text, skipString(text, j));
|
|
153
|
+
if (text[j] === '{') {
|
|
154
|
+
const bounds = scanBalanced(text, j, '{');
|
|
155
|
+
if (!bounds) return null;
|
|
156
|
+
return { startIdx: i, openIdx: bounds.openIdx, closeIdx: bounds.closeIdx };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
i = end;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
i++;
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Splices `replacement` over `bounds` (`{ startIdx, openIdx, closeIdx }`
|
|
168
|
+
// from the finders above; startIdx falls back to openIdx).
|
|
169
|
+
export function replaceBlock(content, bounds, replacement) {
|
|
170
|
+
const text = String(content ?? '');
|
|
171
|
+
if (!bounds || typeof bounds.closeIdx !== 'number') return text;
|
|
172
|
+
const start = typeof bounds.startIdx === 'number' ? bounds.startIdx : bounds.openIdx;
|
|
173
|
+
if (typeof start !== 'number') return text;
|
|
174
|
+
return text.slice(0, start) + String(replacement ?? '') + text.slice(bounds.closeIdx + 1);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Finds a top-level `key = <value>` attribute inside a block slice.
|
|
178
|
+
// Returns `{ lineStart, keyStart, valueStart, valueEnd }` (valueEnd is
|
|
179
|
+
// exclusive, before the trailing newline or closing brace) or null.
|
|
180
|
+
function findAttributeBounds(blockContent, key) {
|
|
181
|
+
const text = String(blockContent ?? '');
|
|
182
|
+
const name = String(key ?? '');
|
|
183
|
+
if (!name) return null;
|
|
184
|
+
let depth = 0;
|
|
185
|
+
let i = 0;
|
|
186
|
+
while (i < text.length) {
|
|
187
|
+
const ch = text[i];
|
|
188
|
+
if (ch === '"') {
|
|
189
|
+
i = skipString(text, i);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (ch === '#' || text.startsWith('//', i)) {
|
|
193
|
+
const end = text.indexOf('\n', i);
|
|
194
|
+
i = end === -1 ? text.length : end + 1;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (text.startsWith('/*', i)) {
|
|
198
|
+
const end = text.indexOf('*/', i + 2);
|
|
199
|
+
i = end === -1 ? text.length : end + 2;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (ch === '{' || ch === '[' || ch === '(') {
|
|
203
|
+
depth++;
|
|
204
|
+
i++;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (ch === '}' || ch === ']' || ch === ')') {
|
|
208
|
+
depth = Math.max(0, depth - 1);
|
|
209
|
+
i++;
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (depth === 1 && /[A-Za-z_]/.test(ch) && (i === 0 || !isIdentChar(text[i - 1]))) {
|
|
213
|
+
let end = i + 1;
|
|
214
|
+
while (end < text.length && isIdentChar(text[end])) end++;
|
|
215
|
+
if (text.slice(i, end) === name) {
|
|
216
|
+
const after = skipTrivia(text, end);
|
|
217
|
+
if (text[after] === '=') {
|
|
218
|
+
const valueStart = skipTrivia(text, after + 1);
|
|
219
|
+
const valueEnd = scanAttributeValueEnd(text, valueStart);
|
|
220
|
+
if (valueEnd === null) return null;
|
|
221
|
+
const lineStart = text.lastIndexOf('\n', i) + 1;
|
|
222
|
+
return { lineStart, keyStart: i, valueStart, valueEnd };
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
i = end;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
i++;
|
|
229
|
+
}
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Finds the end of an attribute value: the newline (or closing brace)
|
|
234
|
+
// reached while no brackets are open. Values may span lines.
|
|
235
|
+
function scanAttributeValueEnd(text, valueStart) {
|
|
236
|
+
let depth = 0;
|
|
237
|
+
let i = valueStart;
|
|
238
|
+
while (i < text.length) {
|
|
239
|
+
const ch = text[i];
|
|
240
|
+
if (ch === '"') {
|
|
241
|
+
i = skipString(text, i);
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (ch === '#' || text.startsWith('//', i)) {
|
|
245
|
+
// A `#` comment ends the value only outside brackets; inside
|
|
246
|
+
// (e.g. a heredoc-free list) it is skipped like content.
|
|
247
|
+
if (depth === 0) return i;
|
|
248
|
+
const end = text.indexOf('\n', i);
|
|
249
|
+
i = end === -1 ? text.length : end + 1;
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (text.startsWith('/*', i)) {
|
|
253
|
+
const end = text.indexOf('*/', i + 2);
|
|
254
|
+
i = end === -1 ? text.length : end + 2;
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (ch === '{' || ch === '[' || ch === '(') {
|
|
258
|
+
depth++;
|
|
259
|
+
i++;
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
if (ch === '}' || ch === ']' || ch === ')') {
|
|
263
|
+
if (depth === 0) return i;
|
|
264
|
+
depth--;
|
|
265
|
+
i++;
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
if (ch === '\n' && depth === 0) return i;
|
|
269
|
+
i++;
|
|
270
|
+
}
|
|
271
|
+
return i;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Returns the raw (trimmed) value of a top-level `key = <value>`
|
|
275
|
+
// attribute, or null when the attribute is absent.
|
|
276
|
+
export function getAttributeValue(blockContent, key) {
|
|
277
|
+
const bounds = findAttributeBounds(blockContent, key);
|
|
278
|
+
if (!bounds) return null;
|
|
279
|
+
return String(blockContent).slice(bounds.valueStart, bounds.valueEnd).trim();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Replaces (or inserts) a top-level `key = <valueExpr>` attribute.
|
|
283
|
+
// Insertions reuse the block's existing attribute indent. Returns the
|
|
284
|
+
// content unchanged when the value already matches.
|
|
285
|
+
export function upsertAttribute(blockContent, key, valueExpr) {
|
|
286
|
+
const text = String(blockContent ?? '');
|
|
287
|
+
const name = String(key ?? '');
|
|
288
|
+
const value = String(valueExpr ?? '');
|
|
289
|
+
const bounds = findAttributeBounds(text, name);
|
|
290
|
+
if (bounds) {
|
|
291
|
+
const current = text.slice(bounds.valueStart, bounds.valueEnd).trim();
|
|
292
|
+
if (current === value.trim()) return text;
|
|
293
|
+
const indent = text.slice(bounds.lineStart, bounds.keyStart);
|
|
294
|
+
return text.slice(0, bounds.lineStart) + `${indent}${name} = ${value}` + text.slice(bounds.valueEnd);
|
|
295
|
+
}
|
|
296
|
+
const closeIdx = text.lastIndexOf('}');
|
|
297
|
+
if (closeIdx === -1) return text;
|
|
298
|
+
const indentMatch = text.match(/^( +)[A-Za-z_][A-Za-z0-9_-]*\s*=/m);
|
|
299
|
+
const indent = indentMatch ? indentMatch[1] : ' ';
|
|
300
|
+
const glue = closeIdx > 0 && text[closeIdx - 1] === '\n' ? '' : '\n';
|
|
301
|
+
return `${text.slice(0, closeIdx)}${glue}${indent}${name} = ${value}\n${text.slice(closeIdx)}`;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Removes a top-level `key = <value>` attribute (including multi-line
|
|
305
|
+
// values) along with its line. Returns the content unchanged when absent.
|
|
306
|
+
export function removeAttribute(blockContent, key) {
|
|
307
|
+
const text = String(blockContent ?? '');
|
|
308
|
+
const bounds = findAttributeBounds(text, key);
|
|
309
|
+
if (!bounds) return text;
|
|
310
|
+
let end = bounds.valueEnd;
|
|
311
|
+
if (text[end] === '\n') end++;
|
|
312
|
+
return text.slice(0, bounds.lineStart) + text.slice(end);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Locates the first `key = [...]` array assigned in the given content
|
|
316
|
+
// (used for `environment = [...]` inside task definitions, where the
|
|
317
|
+
// array lives inside nested jsonencode structures rather than at the
|
|
318
|
+
// top level of the resource block). Returns `{ openIdx, closeIdx }`
|
|
319
|
+
// for the brackets, or null.
|
|
320
|
+
export function findArrayBounds(content, key) {
|
|
321
|
+
const text = String(content ?? '');
|
|
322
|
+
const name = String(key ?? '');
|
|
323
|
+
if (!name) return null;
|
|
324
|
+
let i = 0;
|
|
325
|
+
while (i < text.length) {
|
|
326
|
+
const ch = text[i];
|
|
327
|
+
if (ch === '"') {
|
|
328
|
+
i = skipString(text, i);
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
if (ch === '#' || text.startsWith('//', i)) {
|
|
332
|
+
const end = text.indexOf('\n', i);
|
|
333
|
+
i = end === -1 ? text.length : end + 1;
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
if (text.startsWith('/*', i)) {
|
|
337
|
+
const end = text.indexOf('*/', i + 2);
|
|
338
|
+
i = end === -1 ? text.length : end + 2;
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
if (/[A-Za-z_]/.test(ch) && (i === 0 || !isIdentChar(text[i - 1]))) {
|
|
342
|
+
let end = i + 1;
|
|
343
|
+
while (end < text.length && isIdentChar(text[end])) end++;
|
|
344
|
+
if (text.slice(i, end) === name) {
|
|
345
|
+
const after = skipTrivia(text, end);
|
|
346
|
+
if (text[after] === '=') {
|
|
347
|
+
const bracket = skipTrivia(text, after + 1);
|
|
348
|
+
if (text[bracket] === '[') return scanBalanced(text, bracket, '[');
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
i = end;
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
i++;
|
|
356
|
+
}
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Locates the first `key = {...}` map assigned in the given content (used
|
|
361
|
+
// for `variables = {...}` inside Lambda `environment` blocks, mirroring
|
|
362
|
+
// findArrayBounds for ECS `environment = [...]` arrays). Returns
|
|
363
|
+
// `{ openIdx, closeIdx }` for the braces, or null.
|
|
364
|
+
export function findMapBounds(content, key) {
|
|
365
|
+
const text = String(content ?? '');
|
|
366
|
+
const name = String(key ?? '');
|
|
367
|
+
if (!name) return null;
|
|
368
|
+
let i = 0;
|
|
369
|
+
while (i < text.length) {
|
|
370
|
+
const ch = text[i];
|
|
371
|
+
if (ch === '"') {
|
|
372
|
+
i = skipString(text, i);
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
if (ch === '#' || text.startsWith('//', i)) {
|
|
376
|
+
const end = text.indexOf('\n', i);
|
|
377
|
+
i = end === -1 ? text.length : end + 1;
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
if (text.startsWith('/*', i)) {
|
|
381
|
+
const end = text.indexOf('*/', i + 2);
|
|
382
|
+
i = end === -1 ? text.length : end + 2;
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
if (/[A-Za-z_]/.test(ch) && (i === 0 || !isIdentChar(text[i - 1]))) {
|
|
386
|
+
let end = i + 1;
|
|
387
|
+
while (end < text.length && isIdentChar(text[end])) end++;
|
|
388
|
+
if (text.slice(i, end) === name) {
|
|
389
|
+
const after = skipTrivia(text, end);
|
|
390
|
+
if (text[after] === '=') {
|
|
391
|
+
const brace = skipTrivia(text, after + 1);
|
|
392
|
+
if (text[brace] === '{') return scanBalanced(text, brace, '{');
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
i = end;
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
i++;
|
|
400
|
+
}
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Finds the `{ ... }` object enclosing `index` by brace depth.
|
|
405
|
+
// Interpolation braces inside quoted values (`${...}`) net to zero, so
|
|
406
|
+
// they never disturb the count. Returns null when the index is not
|
|
407
|
+
// inside an object.
|
|
408
|
+
export function enclosingBraceBounds(text, index) {
|
|
409
|
+
const content = String(text ?? '');
|
|
410
|
+
let openIdx = -1;
|
|
411
|
+
let depth = 0;
|
|
412
|
+
for (let i = index - 1; i >= 0; i--) {
|
|
413
|
+
if (content[i] === '}') depth++;
|
|
414
|
+
else if (content[i] === '{') {
|
|
415
|
+
if (depth === 0) {
|
|
416
|
+
openIdx = i;
|
|
417
|
+
break;
|
|
418
|
+
}
|
|
419
|
+
depth--;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
if (openIdx === -1) return null;
|
|
423
|
+
const bounds = scanBalanced(content, openIdx, '{');
|
|
424
|
+
if (!bounds) return null;
|
|
425
|
+
return { openIdx: bounds.openIdx, closeIdx: bounds.closeIdx };
|
|
426
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import zlib from 'node:zlib';
|
|
6
|
+
|
|
7
|
+
// Day-0 ECR seeding for `--target lambda` projects. Lambda's CreateFunction
|
|
8
|
+
// API rejects images outside a private same-account ECR repository, so a
|
|
9
|
+
// fresh `terraform apply` would fail before the first CI push. `apply.js`
|
|
10
|
+
// seeds a minimal valid Docker schema-2 image under `:latest` instead —
|
|
11
|
+
// built entirely in memory (no Docker daemon) and pushed with the AWS CLI,
|
|
12
|
+
// which `doctor` already requires. All helpers shell out through an
|
|
13
|
+
// injectable spawnSync-shaped `run` (mirroring sleep-targets.js) and never
|
|
14
|
+
// throw for AWS-side failures: they return `{ ok, reason, detail }`.
|
|
15
|
+
|
|
16
|
+
export const PLACEHOLDER_TAG = 'latest';
|
|
17
|
+
|
|
18
|
+
function sha256Hex(buffer) {
|
|
19
|
+
return crypto.createHash('sha256').update(buffer).digest('hex');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function tailLines(output, count = 5) {
|
|
23
|
+
return String(output ?? '').split('\n').slice(-count).join('\n').trim();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function cliResult(res, operation) {
|
|
27
|
+
if (res?.error?.code === 'ENOENT') return { ok: false, reason: 'cli-missing', operation };
|
|
28
|
+
if (res?.status !== 0) {
|
|
29
|
+
const tail = tailLines(res?.stderr || res?.stdout);
|
|
30
|
+
return { ok: false, reason: 'aws-failed', operation, detail: tail ? `${operation}: ${tail}` : operation };
|
|
31
|
+
}
|
|
32
|
+
return { ok: true, stdout: res?.stdout ?? '' };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Builds a minimal Docker schema-2 image (empty gzipped tar layer plus an
|
|
36
|
+
// amd64/linux config) with runtime-computed digests, so the manifest ECR
|
|
37
|
+
// receives is always self-consistent. Lambda only checks image existence
|
|
38
|
+
// and architecture at CreateFunction time; CI replaces this placeholder
|
|
39
|
+
// with the real application image on the first push.
|
|
40
|
+
export function buildPlaceholderImage() {
|
|
41
|
+
const emptyTar = Buffer.alloc(1024);
|
|
42
|
+
const layerBlob = zlib.gzipSync(emptyTar);
|
|
43
|
+
const layerDigest = `sha256:${sha256Hex(layerBlob)}`;
|
|
44
|
+
const config = {
|
|
45
|
+
architecture: 'amd64',
|
|
46
|
+
os: 'linux',
|
|
47
|
+
config: { Entrypoint: ['/bootstrap'] },
|
|
48
|
+
rootfs: { type: 'layers', diff_ids: [`sha256:${sha256Hex(emptyTar)}`] },
|
|
49
|
+
history: [{ created: '1970-01-01T00:00:00Z', comment: 'grada lambda placeholder' }],
|
|
50
|
+
};
|
|
51
|
+
const configBlob = Buffer.from(JSON.stringify(config));
|
|
52
|
+
const configDigest = `sha256:${sha256Hex(configBlob)}`;
|
|
53
|
+
const manifest = {
|
|
54
|
+
schemaVersion: 2,
|
|
55
|
+
mediaType: 'application/vnd.docker.distribution.manifest.v2+json',
|
|
56
|
+
config: {
|
|
57
|
+
mediaType: 'application/vnd.docker.container.image.v1+json',
|
|
58
|
+
size: configBlob.length,
|
|
59
|
+
digest: configDigest,
|
|
60
|
+
},
|
|
61
|
+
layers: [
|
|
62
|
+
{
|
|
63
|
+
mediaType: 'application/vnd.docker.image.rootfs.diff.tar.gzip',
|
|
64
|
+
size: layerBlob.length,
|
|
65
|
+
digest: layerDigest,
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
};
|
|
69
|
+
return {
|
|
70
|
+
manifest,
|
|
71
|
+
manifestJson: JSON.stringify(manifest),
|
|
72
|
+
configBlob,
|
|
73
|
+
configDigest,
|
|
74
|
+
layerBlob,
|
|
75
|
+
layerDigest,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// True when `repository:tag` already exists. A missing image resolves to
|
|
80
|
+
// `{ ok: true, exists: false }`; every other failure (missing CLI, missing
|
|
81
|
+
// repository, auth errors) resolves to `{ ok: false, reason }`.
|
|
82
|
+
export function ecrImageExists({ run, repository, tag = PLACEHOLDER_TAG, region }) {
|
|
83
|
+
const res = run('aws', [
|
|
84
|
+
'ecr', 'describe-images',
|
|
85
|
+
'--repository-name', repository,
|
|
86
|
+
'--image-ids', `imageTag=${tag}`,
|
|
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) {
|
|
92
|
+
const detail = tailLines(res?.stderr || res?.stdout);
|
|
93
|
+
if (/ImageNotFound/i.test(detail)) return { ok: true, exists: false };
|
|
94
|
+
return { ok: false, reason: 'describe-failed', detail };
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
const details = JSON.parse(res.stdout || '{}').imageDetails || [];
|
|
98
|
+
return { ok: true, exists: details.length > 0 };
|
|
99
|
+
} catch {
|
|
100
|
+
return { ok: false, reason: 'bad-response' };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function uploadBlob({ run, repository, region, blob, digest, tmpDir, label }) {
|
|
105
|
+
const blobPath = path.join(tmpDir, `${label}.blob`);
|
|
106
|
+
fs.writeFileSync(blobPath, blob);
|
|
107
|
+
|
|
108
|
+
const initiated = cliResult(run('aws', [
|
|
109
|
+
'ecr', 'initiate-layer-upload',
|
|
110
|
+
'--repository-name', repository,
|
|
111
|
+
'--region', region,
|
|
112
|
+
'--output', 'json',
|
|
113
|
+
], { encoding: 'utf8' }), 'initiate-layer-upload');
|
|
114
|
+
if (!initiated.ok) return initiated;
|
|
115
|
+
let uploadId;
|
|
116
|
+
try {
|
|
117
|
+
uploadId = JSON.parse(initiated.stdout || '{}').uploadId;
|
|
118
|
+
} catch {
|
|
119
|
+
return { ok: false, reason: 'bad-response' };
|
|
120
|
+
}
|
|
121
|
+
if (!uploadId) return { ok: false, reason: 'bad-response' };
|
|
122
|
+
|
|
123
|
+
const parted = cliResult(run('aws', [
|
|
124
|
+
'ecr', 'upload-layer-part',
|
|
125
|
+
'--repository-name', repository,
|
|
126
|
+
'--upload-id', uploadId,
|
|
127
|
+
'--part-first-byte', '0',
|
|
128
|
+
'--part-last-byte', String(blob.length - 1),
|
|
129
|
+
// The blob parameter is --layer-part-blob (not --body); fileb://
|
|
130
|
+
// forces the CLI to read blob bytes from disk, while a bare path
|
|
131
|
+
// would be uploaded as literal content and fail the digest check
|
|
132
|
+
// at complete-layer-upload.
|
|
133
|
+
'--layer-part-blob', `fileb://${blobPath}`,
|
|
134
|
+
'--region', region,
|
|
135
|
+
'--output', 'json',
|
|
136
|
+
], { encoding: 'utf8' }), 'upload-layer-part');
|
|
137
|
+
if (!parted.ok) return parted;
|
|
138
|
+
|
|
139
|
+
return cliResult(run('aws', [
|
|
140
|
+
'ecr', 'complete-layer-upload',
|
|
141
|
+
'--repository-name', repository,
|
|
142
|
+
'--upload-id', uploadId,
|
|
143
|
+
'--layer-digests', digest,
|
|
144
|
+
'--region', region,
|
|
145
|
+
'--output', 'json',
|
|
146
|
+
], { encoding: 'utf8' }), 'complete-layer-upload');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Pushes the placeholder image under `repository:tag`. ECR requires every
|
|
150
|
+
// referenced blob to exist before `put-image`, so both the config and the
|
|
151
|
+
// layer are uploaded first.
|
|
152
|
+
export function seedPlaceholderImage({ run, repository, tag = PLACEHOLDER_TAG, region }) {
|
|
153
|
+
const image = buildPlaceholderImage();
|
|
154
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'grada-lambda-seed-'));
|
|
155
|
+
try {
|
|
156
|
+
for (const [label, blob, digest] of [
|
|
157
|
+
['config', image.configBlob, image.configDigest],
|
|
158
|
+
['layer', image.layerBlob, image.layerDigest],
|
|
159
|
+
]) {
|
|
160
|
+
const uploaded = uploadBlob({ run, repository, region, blob, digest, tmpDir, label });
|
|
161
|
+
if (!uploaded.ok) return uploaded;
|
|
162
|
+
}
|
|
163
|
+
const put = cliResult(run('aws', [
|
|
164
|
+
'ecr', 'put-image',
|
|
165
|
+
'--repository-name', repository,
|
|
166
|
+
'--image-tag', tag,
|
|
167
|
+
'--image-manifest', image.manifestJson,
|
|
168
|
+
'--region', region,
|
|
169
|
+
'--output', 'json',
|
|
170
|
+
], { encoding: 'utf8' }), 'put-image');
|
|
171
|
+
if (!put.ok) return put;
|
|
172
|
+
return { ok: true, seeded: true };
|
|
173
|
+
} finally {
|
|
174
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Ensures `repository:tag` exists, seeding the placeholder only when the
|
|
179
|
+
// tag is absent (idempotent: reruns after a real CI push are no-ops).
|
|
180
|
+
export function ensureLambdaSeedImage({ run, repository, tag = PLACEHOLDER_TAG, region }) {
|
|
181
|
+
const checked = ecrImageExists({ run, repository, tag, region });
|
|
182
|
+
if (!checked.ok) return checked;
|
|
183
|
+
if (checked.exists) return { ok: true, seeded: false };
|
|
184
|
+
return seedPlaceholderImage({ run, repository, tag, region });
|
|
185
|
+
}
|