grada-run 0.0.2 ā 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
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
import { SecretsManagerClient, UpdateSecretCommand, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";
|
|
2
|
+
import { ECSClient, UpdateServiceCommand } from "@aws-sdk/client-ecs";
|
|
3
|
+
import dotenv from "dotenv";
|
|
4
|
+
import fs from 'fs/promises';
|
|
5
|
+
import { spinner, confirm, outro, isCancel } from '@clack/prompts';
|
|
6
|
+
import color from 'picocolors';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import { trackEvent, flushTelemetry, trackSuccess, trackFailure } from '../core/telemetry.js';
|
|
9
|
+
import { failCommand } from '../utils/command.js';
|
|
10
|
+
import { handleAuthErrorBranch, resolveClient } from '../utils/aws.js';
|
|
11
|
+
import { readTerraformComputeTarget } from '../utils/resolvers.js';
|
|
12
|
+
|
|
13
|
+
function resolveSecretsFile(envFilePath) {
|
|
14
|
+
let resolvedFilePath = (typeof envFilePath === 'string' && envFilePath.trim())
|
|
15
|
+
? envFilePath.trim()
|
|
16
|
+
: '.env';
|
|
17
|
+
if (!resolvedFilePath.includes('.env') && !resolvedFilePath.includes('txt') && !resolvedFilePath.includes('json')) {
|
|
18
|
+
console.log(color.yellow(`ā ļø Warning: "${resolvedFilePath}" does not look like a standard secrets file. Defaulting to ".env"`));
|
|
19
|
+
resolvedFilePath = '.env';
|
|
20
|
+
}
|
|
21
|
+
return resolvedFilePath;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function resolveSecretsProject(projectName) {
|
|
25
|
+
return (typeof projectName === 'string' && projectName.trim())
|
|
26
|
+
? projectName.trim()
|
|
27
|
+
: path.basename(process.cwd());
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function resolveSecretsRegion(options = {}) {
|
|
31
|
+
const fromOptions = typeof options.region === 'string' ? options.region.trim() : '';
|
|
32
|
+
if (fromOptions) return fromOptions;
|
|
33
|
+
for (const key of ['AWS_REGION', 'AWS_DEFAULT_REGION']) {
|
|
34
|
+
const value = typeof process.env[key] === 'string' ? process.env[key].trim() : '';
|
|
35
|
+
if (value) return value;
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const backendTfPath = path.join(process.cwd(), 'terraform', 'backend.tf');
|
|
39
|
+
const backendTfContent = await fs.readFile(backendTfPath, 'utf-8');
|
|
40
|
+
const regionMatch = backendTfContent.match(/region\s*=\s*"([^"]+)"/);
|
|
41
|
+
if (regionMatch && !regionMatch[1].includes('{{')) {
|
|
42
|
+
return regionMatch[1];
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
// Fall through to the product default region if the file is missing or unreadable
|
|
46
|
+
}
|
|
47
|
+
return 'us-east-2';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function escapeEnvValue(value) {
|
|
51
|
+
return String(value ?? '').replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function serializeEnv(orderedKeys, values) {
|
|
55
|
+
return orderedKeys.map((key) => `${key}="${escapeEnvValue(values[key])}"`).join('\n') + '\n';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function normalizeSecretsArgs(projectName, options) {
|
|
59
|
+
// Supports (file, project, opts) and (file, opts) call shapes.
|
|
60
|
+
if (projectName !== null && typeof projectName === 'object' && !Array.isArray(projectName)) {
|
|
61
|
+
return { projectName: undefined, options: projectName };
|
|
62
|
+
}
|
|
63
|
+
return { projectName, options: options && typeof options === 'object' ? options : {} };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function resolvePullAuditArgs(projectName, options) {
|
|
67
|
+
const normalized = normalizeSecretsArgs(projectName, options);
|
|
68
|
+
return {
|
|
69
|
+
resolvedProjectName: resolveSecretsProject(normalized.projectName),
|
|
70
|
+
opts: normalized.options,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function diffSecretsEnvs(localSecrets = {}, remoteSecrets = {}) {
|
|
75
|
+
const missingLocally = Object.keys(remoteSecrets).filter((k) => !(k in localSecrets));
|
|
76
|
+
const untrackedLocally = Object.keys(localSecrets).filter((k) => !(k in remoteSecrets));
|
|
77
|
+
const mismatched = Object.keys(remoteSecrets).filter(
|
|
78
|
+
(k) => k in localSecrets && String(localSecrets[k]) !== String(remoteSecrets[k])
|
|
79
|
+
);
|
|
80
|
+
return { missingLocally, mismatched, untrackedLocally };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function resolveEcsCluster(projectName, options = {}) {
|
|
84
|
+
if (typeof options.cluster === 'string' && options.cluster.trim()) return options.cluster.trim();
|
|
85
|
+
if (typeof process.env.ECS_CLUSTER === 'string' && process.env.ECS_CLUSTER.trim()) {
|
|
86
|
+
return process.env.ECS_CLUSTER.trim();
|
|
87
|
+
}
|
|
88
|
+
return `${projectName}-cluster`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function resolveEcsService(projectName, options = {}) {
|
|
92
|
+
for (const key of ['service', 'serviceName']) {
|
|
93
|
+
if (typeof options[key] === 'string' && options[key].trim()) return options[key].trim();
|
|
94
|
+
}
|
|
95
|
+
if (typeof process.env.ECS_SERVICE === 'string' && process.env.ECS_SERVICE.trim()) {
|
|
96
|
+
return process.env.ECS_SERVICE.trim();
|
|
97
|
+
}
|
|
98
|
+
return `${projectName}-service`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function pushSecrets(envFilePath, projectName, options = {}) {
|
|
102
|
+
const normalized = normalizeSecretsArgs(projectName, options);
|
|
103
|
+
const opts = normalized.options;
|
|
104
|
+
const explicitProjectName = normalized.projectName;
|
|
105
|
+
// Ensure envFilePath is a valid string, defaulting to '.env' if undefined or an object
|
|
106
|
+
let resolvedFilePath = (typeof envFilePath === 'string' && envFilePath.trim())
|
|
107
|
+
? envFilePath.trim()
|
|
108
|
+
: '.env';
|
|
109
|
+
|
|
110
|
+
// Guard against CI injection bugs where "event" or "push" gets passed as the file name
|
|
111
|
+
if (!resolvedFilePath.includes('.env') && !resolvedFilePath.includes('txt') && !resolvedFilePath.includes('json')) {
|
|
112
|
+
console.log(color.yellow(`ā ļø Warning: "${resolvedFilePath}" does not look like a standard secrets file. Defaulting to ".env"`));
|
|
113
|
+
resolvedFilePath = '.env';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const resolvedProjectName = (typeof explicitProjectName === 'string' && explicitProjectName.trim())
|
|
117
|
+
? explicitProjectName.trim()
|
|
118
|
+
: path.basename(process.cwd());
|
|
119
|
+
|
|
120
|
+
const s = spinner();
|
|
121
|
+
s.start(`Reading ${envFilePath} and pushing to AWS Secrets Manager...`);
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
// 1. Read and parse the local .env file
|
|
125
|
+
const envPath = path.resolve(process.cwd(), resolvedFilePath);
|
|
126
|
+
let envContent;
|
|
127
|
+
try {
|
|
128
|
+
envContent = await fs.readFile(envPath, 'utf-8');
|
|
129
|
+
} catch (fsError) {
|
|
130
|
+
if (fsError.code !== 'ENOENT') throw fsError;
|
|
131
|
+
s.stop('No .env file found.');
|
|
132
|
+
if (opts.isHeadless || process.env.CI) {
|
|
133
|
+
return failCommand({
|
|
134
|
+
message: 'No .env file found. In automated environments, please ensure the file is generated before running secrets push.',
|
|
135
|
+
event: 'secrets_pushed',
|
|
136
|
+
telemetry: {
|
|
137
|
+
projectName: resolvedProjectName,
|
|
138
|
+
error_code: 'ENV_FILE_MISSING',
|
|
139
|
+
reason: 'missing_env_headless',
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
const confirmed = await confirm({
|
|
144
|
+
message: `Would you like to create an empty ${resolvedFilePath} file now to get started?`,
|
|
145
|
+
});
|
|
146
|
+
if (isCancel(confirmed)) {
|
|
147
|
+
console.log(color.dim('Secrets push cancelled. No file was created.'));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (confirmed) {
|
|
151
|
+
await fs.mkdir(path.dirname(envPath), { recursive: true });
|
|
152
|
+
await fs.writeFile(envPath, '# Add your environment variables here\n');
|
|
153
|
+
console.log(color.green(`Created empty ${resolvedFilePath}. Add your variables to it, then re-run secrets push.`));
|
|
154
|
+
await trackFailure('secrets_pushed', {
|
|
155
|
+
projectName: resolvedProjectName,
|
|
156
|
+
reason: 'created_empty_file',
|
|
157
|
+
});
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
console.log(color.dim('Secrets push cancelled. No file was created.'));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const parsedSecrets = dotenv.parse(envContent);
|
|
165
|
+
|
|
166
|
+
if (Object.keys(parsedSecrets).length === 0) {
|
|
167
|
+
s.stop('No secrets found in file.');
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 2. Resolve the region (explicit flag > env > backend.tf > default)
|
|
172
|
+
// so the AWS client is never constructed without one.
|
|
173
|
+
const targetRegion = await resolveSecretsRegion(opts);
|
|
174
|
+
|
|
175
|
+
// 3. Initialize the AWS Client locked to the correct region
|
|
176
|
+
const client = resolveClient(opts.client, SecretsManagerClient, targetRegion ? { region: targetRegion } : {});
|
|
177
|
+
|
|
178
|
+
// 4. Fetch the existing secret payload to detect key changes (safe on new vaults)
|
|
179
|
+
let existingKeys = null;
|
|
180
|
+
try {
|
|
181
|
+
const existingResp = await client.send(new GetSecretValueCommand({
|
|
182
|
+
SecretId: `${resolvedProjectName}-secrets`,
|
|
183
|
+
}));
|
|
184
|
+
if (existingResp.SecretString) {
|
|
185
|
+
try {
|
|
186
|
+
existingKeys = Object.keys(JSON.parse(existingResp.SecretString));
|
|
187
|
+
} catch {
|
|
188
|
+
existingKeys = [];
|
|
189
|
+
}
|
|
190
|
+
} else {
|
|
191
|
+
existingKeys = [];
|
|
192
|
+
}
|
|
193
|
+
} catch (fetchError) {
|
|
194
|
+
if (fetchError && fetchError.name === 'ResourceNotFoundException') {
|
|
195
|
+
existingKeys = [];
|
|
196
|
+
} else {
|
|
197
|
+
throw fetchError;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 5. Update the secret string in AWS
|
|
202
|
+
const command = new UpdateSecretCommand({
|
|
203
|
+
SecretId: `${resolvedProjectName}-secrets`,
|
|
204
|
+
SecretString: JSON.stringify(parsedSecrets),
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
await client.send(command);
|
|
208
|
+
|
|
209
|
+
const keys = Object.keys(parsedSecrets);
|
|
210
|
+
const keysFilePath = path.join(process.cwd(), 'terraform', 'secret_keys.json');
|
|
211
|
+
|
|
212
|
+
await fs.writeFile(keysFilePath, JSON.stringify(keys, null, 2));
|
|
213
|
+
|
|
214
|
+
s.stop(`ā
Successfully pushed ${Object.keys(parsedSecrets).length} secrets to AWS (${targetRegion || 'default region'})!`);
|
|
215
|
+
|
|
216
|
+
const sortedExisting = [...(existingKeys ?? [])].sort();
|
|
217
|
+
const sortedNew = [...keys].sort();
|
|
218
|
+
const keysChanged = sortedExisting.length !== sortedNew.length
|
|
219
|
+
|| sortedExisting.some((key, index) => key !== sortedNew[index]);
|
|
220
|
+
|
|
221
|
+
if (keysChanged) {
|
|
222
|
+
console.log(color.cyan(`\nUpdated ${keysFilePath}`));
|
|
223
|
+
console.log(color.green('Commit this file and push to GitHub to trigger a deployment with your new variables.'));
|
|
224
|
+
} else {
|
|
225
|
+
console.log(color.cyan(`\nUpdated ${keysFilePath}`));
|
|
226
|
+
// Lambda functions read secrets at invocation time, so new values
|
|
227
|
+
// apply to fresh execution environments with no restart to trigger.
|
|
228
|
+
if (readTerraformComputeTarget(process.cwd()) === 'lambda') {
|
|
229
|
+
console.log(color.dim('Lambda target detected: new secret values apply to fresh invocations automatically ā no restart needed.'));
|
|
230
|
+
console.log(color.blue(`\nš Learn how secrets reach your app: ${color.underline('https://github.com/grada-run/grada/blob/main/apps/docs/src/content/docs/guides/secrets-management.md')}`));
|
|
231
|
+
await trackSuccess('secrets_pushed', {
|
|
232
|
+
projectName: resolvedProjectName,
|
|
233
|
+
secret_count: Object.keys(parsedSecrets).length,
|
|
234
|
+
keys_changed: false,
|
|
235
|
+
ecs_restart: false
|
|
236
|
+
});
|
|
237
|
+
return { keysChanged: false, restarted: false };
|
|
238
|
+
}
|
|
239
|
+
const shouldRestart = await confirm({
|
|
240
|
+
message: 'Keys are unchanged. Trigger a rolling ECS restart to apply new values immediately?',
|
|
241
|
+
initialValue: false,
|
|
242
|
+
});
|
|
243
|
+
if (shouldRestart) {
|
|
244
|
+
const cluster = resolveEcsCluster(resolvedProjectName, opts);
|
|
245
|
+
const service = resolveEcsService(resolvedProjectName, opts);
|
|
246
|
+
const ecsClient = resolveClient(opts.ecsClient ?? opts.ecs, ECSClient, targetRegion ? { region: targetRegion } : {});
|
|
247
|
+
const restartSpinner = spinner();
|
|
248
|
+
restartSpinner.start('Triggering rolling ECS restart...');
|
|
249
|
+
try {
|
|
250
|
+
await ecsClient.send(new UpdateServiceCommand({
|
|
251
|
+
cluster,
|
|
252
|
+
service,
|
|
253
|
+
forceNewDeployment: true,
|
|
254
|
+
}));
|
|
255
|
+
restartSpinner.stop('ā
ECS rolling restart triggered.');
|
|
256
|
+
outro(`Rolling restart initiated for ${service} in ${cluster}. ECS will roll tasks with the new secret values.`);
|
|
257
|
+
} catch (ecsError) {
|
|
258
|
+
restartSpinner.stop(`ā Failed to trigger ECS restart: ${ecsError.message}`);
|
|
259
|
+
throw ecsError;
|
|
260
|
+
}
|
|
261
|
+
await trackSuccess('secrets_pushed', {
|
|
262
|
+
projectName: resolvedProjectName,
|
|
263
|
+
secret_count: Object.keys(parsedSecrets).length,
|
|
264
|
+
keys_changed: false,
|
|
265
|
+
ecs_restart: true
|
|
266
|
+
});
|
|
267
|
+
console.log(color.blue(`\nš Learn how secrets reach your app: ${color.underline('https://github.com/grada-run/grada/blob/main/apps/docs/src/content/docs/guides/secrets-management.md')}`));
|
|
268
|
+
return { keysChanged: false, restarted: true, cluster, service };
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
console.log(color.blue(`\nš Learn how secrets reach your app: ${color.underline('https://github.com/grada-run/grada/blob/main/apps/docs/src/content/docs/guides/secrets-management.md')}`));
|
|
272
|
+
|
|
273
|
+
await trackSuccess('secrets_pushed', {
|
|
274
|
+
projectName: resolvedProjectName,
|
|
275
|
+
secret_count: Object.keys(parsedSecrets).length,
|
|
276
|
+
keys_changed: keysChanged
|
|
277
|
+
});
|
|
278
|
+
return { keysChanged };
|
|
279
|
+
|
|
280
|
+
} catch (error) {
|
|
281
|
+
await trackFailure('secrets_pushed', {
|
|
282
|
+
projectName: resolvedProjectName,
|
|
283
|
+
error_code: error.code || error.name || 'UNKNOWN',
|
|
284
|
+
error_message: error.message,
|
|
285
|
+
stack_trace: error.name === 'TypeError' ? error.stack : undefined
|
|
286
|
+
});
|
|
287
|
+
if (error.name === 'ResourceNotFoundException') {
|
|
288
|
+
s.stop(color.red(`ā Secrets Vault "${resolvedProjectName}-secrets" does not exist in AWS yet.`));
|
|
289
|
+
console.log(color.yellow('\nš” Next Step:'));
|
|
290
|
+
console.log(`Run ${color.cyan('npx --yes grada-run apply')} first to provision the infrastructure and Secrets Manager vault.`);
|
|
291
|
+
console.log(`Once applied, run ${color.cyan(`npx grada-run secrets push ${resolvedFilePath}`)} to upload your environment variables.\n`);
|
|
292
|
+
} else if (handleAuthErrorBranch(error, s, opts)) {
|
|
293
|
+
return;
|
|
294
|
+
} else {
|
|
295
|
+
s.stop(`ā Failed to push secrets: ${error.message}`);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return failCommand({ exitCode: 1 });
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export async function pullSecrets(envFilePath, projectName, options = {}) {
|
|
303
|
+
const { resolvedProjectName, opts } = resolvePullAuditArgs(projectName, options);
|
|
304
|
+
const resolvedFilePath = resolveSecretsFile(envFilePath);
|
|
305
|
+
const { isHeadless = false, client: injectedClient } = opts;
|
|
306
|
+
|
|
307
|
+
const s = spinner();
|
|
308
|
+
s.start('Fetching secrets from AWS...');
|
|
309
|
+
|
|
310
|
+
try {
|
|
311
|
+
const targetRegion = await resolveSecretsRegion(opts);
|
|
312
|
+
const client = resolveClient(injectedClient, SecretsManagerClient, targetRegion ? { region: targetRegion } : {});
|
|
313
|
+
|
|
314
|
+
const resp = await client.send(new GetSecretValueCommand({
|
|
315
|
+
SecretId: `${resolvedProjectName}-secrets`,
|
|
316
|
+
}));
|
|
317
|
+
|
|
318
|
+
let remoteSecrets = {};
|
|
319
|
+
if (resp.SecretString) {
|
|
320
|
+
try {
|
|
321
|
+
remoteSecrets = JSON.parse(resp.SecretString);
|
|
322
|
+
} catch {
|
|
323
|
+
const jsonError = new Error('Remote secret payload is not valid JSON.');
|
|
324
|
+
jsonError.code = 'REMOTE_SECRET_MALFORMED';
|
|
325
|
+
throw jsonError;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
const remoteKeys = Object.keys(remoteSecrets);
|
|
329
|
+
s.stop('Remote secrets fetched.');
|
|
330
|
+
|
|
331
|
+
const envPath = path.resolve(process.cwd(), resolvedFilePath);
|
|
332
|
+
let localSecrets = {};
|
|
333
|
+
let localExists = true;
|
|
334
|
+
try {
|
|
335
|
+
const envContent = await fs.readFile(envPath, 'utf-8');
|
|
336
|
+
localSecrets = dotenv.parse(envContent);
|
|
337
|
+
} catch (fsError) {
|
|
338
|
+
if (fsError.code === 'ENOENT') {
|
|
339
|
+
localExists = false;
|
|
340
|
+
localSecrets = {};
|
|
341
|
+
} else {
|
|
342
|
+
throw fsError;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const { mismatched } = diffSecretsEnvs(localSecrets, remoteSecrets);
|
|
347
|
+
let overwrite = true;
|
|
348
|
+
if (localExists && mismatched.length > 0) {
|
|
349
|
+
if (isHeadless) {
|
|
350
|
+
overwrite = true;
|
|
351
|
+
} else {
|
|
352
|
+
const answer = await confirm({
|
|
353
|
+
message: 'Conflicting variables found. Overwrite local values with remote?',
|
|
354
|
+
initialValue: false,
|
|
355
|
+
});
|
|
356
|
+
overwrite = answer === true;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const merged = { ...localSecrets };
|
|
361
|
+
for (const [key, value] of Object.entries(remoteSecrets)) {
|
|
362
|
+
if (!(key in merged) || overwrite) {
|
|
363
|
+
merged[key] = String(value);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const orderedKeys = [
|
|
368
|
+
...Object.keys(localSecrets),
|
|
369
|
+
...remoteKeys.filter((k) => !(k in localSecrets)),
|
|
370
|
+
];
|
|
371
|
+
await fs.writeFile(envPath, serializeEnv(orderedKeys, merged));
|
|
372
|
+
|
|
373
|
+
outro(`Successfully synced ${remoteKeys.length} secrets to ${resolvedFilePath}`);
|
|
374
|
+
|
|
375
|
+
trackEvent('secrets_pull', {
|
|
376
|
+
projectName: resolvedProjectName,
|
|
377
|
+
variablesCount: remoteKeys.length,
|
|
378
|
+
});
|
|
379
|
+
await flushTelemetry();
|
|
380
|
+
|
|
381
|
+
return { synced: remoteKeys.length, file: resolvedFilePath, overwritten: overwrite, conflicts: mismatched };
|
|
382
|
+
} catch (error) {
|
|
383
|
+
await trackFailure('secrets_pull', {
|
|
384
|
+
projectName: resolvedProjectName,
|
|
385
|
+
error_code: error.code || error.name || 'UNKNOWN',
|
|
386
|
+
error_message: error.message,
|
|
387
|
+
});
|
|
388
|
+
if (error.name === 'ResourceNotFoundException') {
|
|
389
|
+
s.stop(color.red(`ā No remote secrets found for "${resolvedProjectName}-secrets".`));
|
|
390
|
+
console.log(color.yellow('\nš” Next Step:'));
|
|
391
|
+
console.log(`Run ${color.cyan(`npx grada-run secrets push ${resolvedFilePath}`)} first to upload your environment variables.\n`);
|
|
392
|
+
} else if (handleAuthErrorBranch(error, s, opts)) {
|
|
393
|
+
return;
|
|
394
|
+
} else {
|
|
395
|
+
s.stop(`ā Failed to pull secrets: ${error.message}`);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return failCommand({ exitCode: 1 });
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
export async function auditSecrets(envFilePath, projectName, options = {}) {
|
|
403
|
+
const { resolvedProjectName, opts } = resolvePullAuditArgs(projectName, options);
|
|
404
|
+
const resolvedFilePath = resolveSecretsFile(envFilePath);
|
|
405
|
+
const { client: injectedClient } = opts;
|
|
406
|
+
|
|
407
|
+
const s = spinner();
|
|
408
|
+
s.start('Auditing local environment against AWS...');
|
|
409
|
+
|
|
410
|
+
try {
|
|
411
|
+
const targetRegion = await resolveSecretsRegion(opts);
|
|
412
|
+
const client = resolveClient(injectedClient, SecretsManagerClient, targetRegion ? { region: targetRegion } : {});
|
|
413
|
+
|
|
414
|
+
const resp = await client.send(new GetSecretValueCommand({
|
|
415
|
+
SecretId: `${resolvedProjectName}-secrets`,
|
|
416
|
+
}));
|
|
417
|
+
|
|
418
|
+
let remoteSecrets = {};
|
|
419
|
+
if (resp.SecretString) {
|
|
420
|
+
try {
|
|
421
|
+
remoteSecrets = JSON.parse(resp.SecretString);
|
|
422
|
+
} catch {
|
|
423
|
+
const jsonError = new Error('Remote secret payload is not valid JSON.');
|
|
424
|
+
jsonError.code = 'REMOTE_SECRET_MALFORMED';
|
|
425
|
+
throw jsonError;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const envPath = path.resolve(process.cwd(), resolvedFilePath);
|
|
430
|
+
let localSecrets = {};
|
|
431
|
+
try {
|
|
432
|
+
const envContent = await fs.readFile(envPath, 'utf-8');
|
|
433
|
+
localSecrets = dotenv.parse(envContent);
|
|
434
|
+
} catch (fsError) {
|
|
435
|
+
if (fsError.code !== 'ENOENT') throw fsError;
|
|
436
|
+
localSecrets = {};
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
s.stop('Audit complete.');
|
|
440
|
+
|
|
441
|
+
const { missingLocally, mismatched, untrackedLocally } = diffSecretsEnvs(localSecrets, remoteSecrets);
|
|
442
|
+
const driftCount = missingLocally.length + mismatched.length + untrackedLocally.length;
|
|
443
|
+
|
|
444
|
+
if (driftCount === 0) {
|
|
445
|
+
console.log(color.green('No drift detected. Local and remote secrets are in sync.'));
|
|
446
|
+
} else {
|
|
447
|
+
for (const key of missingLocally) {
|
|
448
|
+
console.log(color.green(`+ ${key} (Missing locally)`));
|
|
449
|
+
}
|
|
450
|
+
for (const key of mismatched) {
|
|
451
|
+
console.log(color.yellow(`~ ${key} (Mismatched value)`));
|
|
452
|
+
}
|
|
453
|
+
for (const key of untrackedLocally) {
|
|
454
|
+
console.log(color.dim(`- ${key} (Not tracked in AWS)`));
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
outro(`Audit complete. ${driftCount} drifted variable(s) found.`);
|
|
459
|
+
|
|
460
|
+
trackEvent('secrets_audit', {
|
|
461
|
+
projectName: resolvedProjectName,
|
|
462
|
+
driftCount,
|
|
463
|
+
});
|
|
464
|
+
await flushTelemetry();
|
|
465
|
+
|
|
466
|
+
return { missingLocally, mismatched, untrackedLocally, driftCount };
|
|
467
|
+
} catch (error) {
|
|
468
|
+
await trackFailure('secrets_audit', {
|
|
469
|
+
projectName: resolvedProjectName,
|
|
470
|
+
error_code: error.code || error.name || 'UNKNOWN',
|
|
471
|
+
error_message: error.message,
|
|
472
|
+
});
|
|
473
|
+
if (error.name === 'ResourceNotFoundException') {
|
|
474
|
+
s.stop(color.red(`ā No remote secrets found for "${resolvedProjectName}-secrets".`));
|
|
475
|
+
console.log(color.yellow('\nš” Next Step:'));
|
|
476
|
+
console.log(`Run ${color.cyan(`npx grada-run secrets push ${resolvedFilePath}`)} first to upload your environment variables.\n`);
|
|
477
|
+
} else if (handleAuthErrorBranch(error, s, opts)) {
|
|
478
|
+
return;
|
|
479
|
+
} else {
|
|
480
|
+
s.stop(`ā Failed to audit secrets: ${error.message}`);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return failCommand({ exitCode: 1 });
|
|
484
|
+
}
|
|
485
|
+
}
|