@grada-run/grada 0.0.1 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/deploy-docs.yml +37 -0
- package/.github/workflows/e2e.yml +73 -0
- package/.github/workflows/iac-validation.yml +303 -0
- package/.github/workflows/publish.yml +68 -0
- package/.github/workflows/sync-bedrock-models.yml +57 -0
- package/.github/workflows/test.yml +43 -0
- package/.muserules +31 -0
- package/LICENSE +21 -0
- package/README.md +193 -3
- package/apps/docs/.astro/collections/docs.schema.json +644 -0
- package/apps/docs/.astro/content-assets.mjs +4 -0
- package/apps/docs/.astro/content-modules.mjs +4 -0
- package/apps/docs/.astro/content.d.ts +179 -0
- package/apps/docs/.astro/data-store.json +1 -0
- package/apps/docs/.astro/dev.json +14 -0
- package/apps/docs/.astro/settings.json +5 -0
- package/apps/docs/.astro/types.d.ts +2 -0
- package/apps/docs/astro.config.mjs +97 -0
- package/apps/docs/package.json +17 -0
- package/apps/docs/src/content/docs/adrs/0001-s3-native-state-locking.md +37 -0
- package/apps/docs/src/content/docs/adrs/0002-eject-mechanism-pure-iac.md +39 -0
- package/apps/docs/src/content/docs/adrs/0003-sync-ai-context-strategy.md +48 -0
- package/apps/docs/src/content/docs/adrs/0004-iac-driven-diagnostic-context.md +37 -0
- package/apps/docs/src/content/docs/adrs/0005-ecs-fargate-alb-runtime-target.md +38 -0
- package/apps/docs/src/content/docs/adrs/0006-github-oidc-no-stored-keys.md +37 -0
- package/apps/docs/src/content/docs/adrs/0007-framework-detection-with-fallback.md +37 -0
- package/apps/docs/src/content/docs/adrs/0008-secrets-names-in-git-values-in-aws.md +37 -0
- package/apps/docs/src/content/docs/adrs/0009-regenerate-with-backup-on-rerun.md +37 -0
- package/apps/docs/src/content/docs/adrs/0010-advisory-only-security-scans.md +37 -0
- package/apps/docs/src/content/docs/cli/add.md +85 -0
- package/apps/docs/src/content/docs/cli/apply.md +35 -0
- package/apps/docs/src/content/docs/cli/db.md +200 -0
- package/apps/docs/src/content/docs/cli/destroy.md +35 -0
- package/apps/docs/src/content/docs/cli/diagnose.md +37 -0
- package/apps/docs/src/content/docs/cli/doctor.md +28 -0
- package/apps/docs/src/content/docs/cli/domain.md +57 -0
- package/apps/docs/src/content/docs/cli/drift.md +40 -0
- package/apps/docs/src/content/docs/cli/eject.md +33 -0
- package/apps/docs/src/content/docs/cli/exec.md +49 -0
- package/apps/docs/src/content/docs/cli/gc.md +37 -0
- package/apps/docs/src/content/docs/cli/init.md +72 -0
- package/apps/docs/src/content/docs/cli/logs.md +39 -0
- package/apps/docs/src/content/docs/cli/rollback.md +51 -0
- package/apps/docs/src/content/docs/cli/secrets.md +73 -0
- package/apps/docs/src/content/docs/cli/sleep.md +53 -0
- package/apps/docs/src/content/docs/cli/status.md +34 -0
- package/apps/docs/src/content/docs/cli/sync-ai.md +27 -0
- package/apps/docs/src/content/docs/guides/architecture.md +87 -0
- package/apps/docs/src/content/docs/guides/aws-credentials.md +72 -0
- package/apps/docs/src/content/docs/guides/background-workers.md +45 -0
- package/apps/docs/src/content/docs/guides/cicd-pipeline.md +64 -0
- package/apps/docs/src/content/docs/guides/database-connections.md +64 -0
- package/apps/docs/src/content/docs/guides/docker-compose.md +37 -0
- package/apps/docs/src/content/docs/guides/dockerfiles.md +46 -0
- package/apps/docs/src/content/docs/guides/ephemeral-pr-previews.md +39 -0
- package/apps/docs/src/content/docs/guides/examples.md +50 -0
- package/apps/docs/src/content/docs/guides/frameworks.md +88 -0
- package/apps/docs/src/content/docs/guides/headless.md +87 -0
- package/apps/docs/src/content/docs/guides/quickstart.md +52 -0
- package/apps/docs/src/content/docs/guides/rerun-init.md +43 -0
- package/apps/docs/src/content/docs/guides/secrets-management.md +83 -0
- package/apps/docs/src/content/docs/guides/understanding-your-bill.md +63 -0
- package/apps/docs/src/content/docs/index.mdx +103 -0
- package/apps/docs/src/content/docs/migrations/astro-vercel-to-aws.md +55 -0
- package/apps/docs/src/content/docs/migrations/heroku-procfile-to-aws.md +41 -0
- package/apps/docs/src/content/docs/migrations/nextjs-vercel-to-aws.md +51 -0
- package/apps/docs/src/content/docs/migrations/sveltekit-vercel-to-aws.md +63 -0
- package/apps/docs/src/content/docs/roadmap.md +99 -0
- package/apps/docs/src/content/docs/testing-strategy.md +37 -0
- package/apps/docs/src/content.config.ts +7 -0
- package/apps/docs/src/custom.css +14 -0
- package/apps/docs/tsconfig.json +6 -0
- package/bin/cli.js +140 -0
- package/package.json +107 -7
- package/scripts/sync-bedrock-models.js +22 -0
- package/scripts/test-iac.js +261 -0
- package/specs/add-redis-sqs-bedrock.md +128 -0
- package/specs/add-storage-dynamodb.md +106 -0
- package/specs/bedrock-model-catalog.md +131 -0
- package/specs/ci-pipeline.md +17 -0
- package/specs/cost-transparency.md +115 -0
- package/specs/custom-domains-and-ses.md +153 -0
- package/specs/database-suite-expansion.md +151 -0
- package/specs/db-connect.md +69 -0
- package/specs/db-lifecycle-migrations.md +159 -0
- package/specs/dependency-aware-init.md +176 -0
- package/specs/deployment-safety.md +170 -0
- package/specs/diagnose.md +16 -0
- package/specs/docs-hub.md +16 -0
- package/specs/dx-polish.md +46 -0
- package/specs/e2e-testing.md +50 -0
- package/specs/exec.md +25 -0
- package/specs/finops-cron-drift.md +161 -0
- package/specs/gc.md +26 -0
- package/specs/integration-suite.md +16 -0
- package/specs/logs.md +32 -0
- package/specs/rollback-live-polling.md +40 -0
- package/specs/secrets-pull-audit.md +51 -0
- package/specs/serverless-lambda-target.md +133 -0
- package/specs/status.md +31 -0
- package/specs/telemetry-and-spawn-hardening.md +69 -0
- package/specs/telemetry-hardening.md +35 -0
- package/specs/test-suite-deduplication.md +42 -0
- package/src/commands/add.js +1111 -0
- package/src/commands/apply.js +214 -0
- package/src/commands/db/backup.js +229 -0
- package/src/commands/db/connect.js +304 -0
- package/src/commands/db/enable-vector.js +344 -0
- package/src/commands/db/import.js +604 -0
- package/src/commands/db/migrate.js +477 -0
- package/src/commands/db/restore.js +361 -0
- package/src/commands/db.js +87 -0
- package/src/commands/destroy.js +217 -0
- package/src/commands/diagnose.js +460 -0
- package/src/commands/doctor.js +109 -0
- package/src/commands/domain.js +685 -0
- package/src/commands/drift.js +243 -0
- package/src/commands/eject.js +130 -0
- package/src/commands/exec.js +222 -0
- package/src/commands/gc.js +250 -0
- package/src/commands/init.js +649 -0
- package/src/commands/logs.js +256 -0
- package/src/commands/rollback.js +323 -0
- package/src/commands/secrets.js +485 -0
- package/src/commands/sleep.js +347 -0
- package/src/commands/status.js +309 -0
- package/src/commands/sync-ai.js +115 -0
- package/src/commands/wake.js +337 -0
- package/src/core/parser.js +128 -0
- package/src/core/telemetry.js +244 -0
- package/src/data/bedrock-models.json +896 -0
- package/src/utils/addons.js +126 -0
- package/src/utils/ai-rules.js +59 -0
- package/src/utils/args.js +91 -0
- package/src/utils/aws.js +178 -0
- package/src/utils/backup.js +69 -0
- package/src/utils/bedrock-catalog.js +511 -0
- package/src/utils/capabilities.js +500 -0
- package/src/utils/command.js +78 -0
- package/src/utils/db-tunnel.js +164 -0
- package/src/utils/detector.js +298 -0
- package/src/utils/dockerCompose.js +65 -0
- package/src/utils/domains.js +73 -0
- package/src/utils/ecs-runner.js +289 -0
- package/src/utils/ecs.js +92 -0
- package/src/utils/frameworks.js +55 -0
- package/src/utils/generator.js +527 -0
- package/src/utils/hcl.js +426 -0
- package/src/utils/lambda-ecr.js +185 -0
- package/src/utils/prompts.js +278 -0
- package/src/utils/rds.js +131 -0
- package/src/utils/resolvers.js +174 -0
- package/src/utils/sleep-state.js +140 -0
- package/src/utils/sleep-targets.js +139 -0
- package/src/utils/system.js +42 -0
- package/src/utils/terraform.js +70 -0
- package/src/utils/visualizer.js +381 -0
- package/src/utils/warnings.js +49 -0
- package/templates/README.md +150 -0
- package/templates/docker/django.Dockerfile +40 -0
- package/templates/docker/go.Dockerfile +23 -0
- package/templates/docker/nestjs.Dockerfile +33 -0
- package/templates/docker/nextjs.Dockerfile +55 -0
- package/templates/docker/node.Dockerfile +24 -0
- package/templates/docker/nuxt.Dockerfile +47 -0
- package/templates/docker/python.Dockerfile +38 -0
- package/templates/docker/rails.Dockerfile +59 -0
- package/templates/docker/static.Dockerfile +32 -0
- package/templates/docker/svelte.Dockerfile +52 -0
- package/templates/github/deploy-lambda.yml +120 -0
- package/templates/github/deploy.yml +138 -0
- package/templates/github/drift.yml +112 -0
- package/templates/github/preview-lambda.yml +86 -0
- package/templates/github/preview.yml +69 -0
- package/templates/github/teardown.yml +43 -0
- package/templates/terraform/addons/bedrock.tf +34 -0
- package/templates/terraform/addons/cron-lambda.tf +78 -0
- package/templates/terraform/addons/cron.tf +101 -0
- package/templates/terraform/addons/dynamodb.tf +73 -0
- package/templates/terraform/addons/redis.tf +64 -0
- package/templates/terraform/addons/s3.tf +143 -0
- package/templates/terraform/addons/ses.tf +73 -0
- package/templates/terraform/addons/sqs.tf +67 -0
- package/templates/terraform/backend.tf +22 -0
- package/templates/terraform/cloudfront-lambda.tf +80 -0
- package/templates/terraform/cloudfront.tf +80 -0
- package/templates/terraform/database-aurora-postgresql.tf +92 -0
- package/templates/terraform/database-mysql.tf +72 -0
- package/templates/terraform/database.tf +71 -0
- package/templates/terraform/main-lambda.tf +229 -0
- package/templates/terraform/main.tf +296 -0
- package/templates/terraform/network.tf +95 -0
- package/templates/terraform/oidc.tf +64 -0
- package/templates/terraform/secrets.tf +31 -0
- package/templates/terraform/worker.tf +69 -0
- package/tests/__snapshots__/generator.test.js.snap +9633 -0
- package/tests/add.test.js +2018 -0
- package/tests/ai.test.js +91 -0
- package/tests/apply.test.js +446 -0
- package/tests/args.test.js +86 -0
- package/tests/aws.test.js +244 -0
- package/tests/capabilities.test.js +304 -0
- package/tests/cli.test.js +29 -0
- package/tests/command.test.js +99 -0
- package/tests/commands-import.test.js +74 -0
- package/tests/db.test.js +2673 -0
- package/tests/destroy.test.js +365 -0
- package/tests/detector.test.js +79 -0
- package/tests/diagnose.test.js +765 -0
- package/tests/doctor.test.js +195 -0
- package/tests/domain.test.js +882 -0
- package/tests/drift.test.js +228 -0
- package/tests/e2e/helpers.js +122 -0
- package/tests/e2e/tier0.e2e.test.js +142 -0
- package/tests/e2e/tier1.live.e2e.test.js +118 -0
- package/tests/ecs.test.js +130 -0
- package/tests/eject.test.js +65 -0
- package/tests/exec.test.js +366 -0
- package/tests/gc.test.js +466 -0
- package/tests/generator.test.js +794 -0
- package/tests/headless.test.js +491 -0
- package/tests/helpers/clack.js +103 -0
- package/tests/helpers/console.js +41 -0
- package/tests/helpers/telemetry.js +37 -0
- package/tests/helpers/tmpdir.js +36 -0
- package/tests/lambda-ecr.test.js +185 -0
- package/tests/logs.test.js +436 -0
- package/tests/parser.test.js +160 -0
- package/tests/rds.test.js +244 -0
- package/tests/resolvers.test.js +279 -0
- package/tests/rollback.test.js +670 -0
- package/tests/secrets.test.js +729 -0
- package/tests/sleep-wake.test.js +998 -0
- package/tests/status.test.js +356 -0
- package/tests/system.test.js +70 -0
- package/tests/telemetry.test.js +520 -0
- package/tests/terraform.test.js +84 -0
- package/tests/visualizer.test.js +480 -0
- package/vitest.config.js +10 -0
- package/vitest.e2e.tier0.config.js +8 -0
- package/vitest.e2e.tier1.config.js +8 -0
- package/index.js +0 -2
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
// NOTE: helper imports must stay above src imports: vi.mock factories run
|
|
3
|
+
// during module evaluation and need the factories initialized.
|
|
4
|
+
import { clackPromptsMockFactory } from './helpers/clack.js';
|
|
5
|
+
import { telemetryMockFactory } from './helpers/telemetry.js';
|
|
6
|
+
import { createTmpDirTracker } from './helpers/tmpdir.js';
|
|
7
|
+
import fs from 'fs';
|
|
8
|
+
import os from 'os';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import { load as loadYaml } from 'js-yaml';
|
|
11
|
+
import {
|
|
12
|
+
runDrift,
|
|
13
|
+
parseDriftArgs,
|
|
14
|
+
extractRoleArn,
|
|
15
|
+
extractPlanSummary,
|
|
16
|
+
scaffoldDriftWorkflow,
|
|
17
|
+
} from '../src/commands/drift.js';
|
|
18
|
+
import { trackEvent, flushTelemetry } from '../src/core/telemetry.js';
|
|
19
|
+
|
|
20
|
+
vi.mock('@clack/prompts', () => clackPromptsMockFactory());
|
|
21
|
+
|
|
22
|
+
vi.mock('../src/core/telemetry.js', (importOriginal) => telemetryMockFactory(importOriginal));
|
|
23
|
+
|
|
24
|
+
const tmp = createTmpDirTracker();
|
|
25
|
+
let exitSpy;
|
|
26
|
+
let logSpy;
|
|
27
|
+
|
|
28
|
+
function makeTmp() {
|
|
29
|
+
return tmp.makeTmp('drift-test-');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function writeDeployYml(dir, roleArn = 'arn:aws:iam::123456789012:role/myapp-github-actions-role') {
|
|
33
|
+
const workflows = path.join(dir, '.github', 'workflows');
|
|
34
|
+
fs.mkdirSync(workflows, { recursive: true });
|
|
35
|
+
fs.writeFileSync(
|
|
36
|
+
path.join(workflows, 'deploy.yml'),
|
|
37
|
+
`name: Deploy\njobs:\n deploy:\n steps:\n - uses: anton-codes-iac/deploy-stack-action@v1\n with:\n role-to-assume: ${roleArn}\n`
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function writeMainTf(dir, region = 'us-east-2') {
|
|
42
|
+
fs.mkdirSync(path.join(dir, 'terraform'), { recursive: true });
|
|
43
|
+
fs.writeFileSync(
|
|
44
|
+
path.join(dir, 'terraform', 'main.tf'),
|
|
45
|
+
`provider "aws" {\n region = "${region}"\n}\nlocals {\n app_name = "myapp\${local.env_suffix}"\n}\n`
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
tmp.reset();
|
|
51
|
+
vi.clearAllMocks();
|
|
52
|
+
exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {});
|
|
53
|
+
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
exitSpy.mockRestore();
|
|
58
|
+
logSpy.mockRestore();
|
|
59
|
+
tmp.cleanup();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('parseDriftArgs', () => {
|
|
63
|
+
it('parses flags and treats drift init as --setup', () => {
|
|
64
|
+
expect(parseDriftArgs(['drift', '--setup', '--region', 'eu-west-1'])).toMatchObject({ setup: true, region: 'eu-west-1' });
|
|
65
|
+
expect(parseDriftArgs(['drift', 'init']).setup).toBe(true);
|
|
66
|
+
expect(parseDriftArgs(['drift']).setup).toBeUndefined();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('collects unexpected positionals', () => {
|
|
70
|
+
expect(parseDriftArgs(['drift', 'bogus']).unexpectedPositionals).toEqual(['bogus']);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('extractRoleArn / extractPlanSummary', () => {
|
|
75
|
+
it('finds the OIDC role ARN in deploy.yml', () => {
|
|
76
|
+
expect(extractRoleArn(' role-to-assume: arn:aws:iam::1:role/x # comment')).toBe('arn:aws:iam::1:role/x');
|
|
77
|
+
expect(extractRoleArn('no anchor here')).toBeNull();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('condenses plan output to changes plus the summary', () => {
|
|
81
|
+
const output = [
|
|
82
|
+
'random log line',
|
|
83
|
+
'# aws_lb.main will be updated',
|
|
84
|
+
' ~ dns_name = "a" -> "b"',
|
|
85
|
+
' + new = true',
|
|
86
|
+
' - old = true',
|
|
87
|
+
'Plan: 0 to add, 1 to change, 0 to destroy.',
|
|
88
|
+
].join('\n');
|
|
89
|
+
const summary = extractPlanSummary(output);
|
|
90
|
+
expect(summary).not.toContain('random log line');
|
|
91
|
+
expect(summary).toContain('# aws_lb.main will be updated');
|
|
92
|
+
expect(summary).toContain('Plan: 0 to add, 1 to change, 0 to destroy.');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('caps long summaries', () => {
|
|
96
|
+
const output = Array.from({ length: 100 }, (_, i) => ` + line${i} = true`).join('\n');
|
|
97
|
+
const summary = extractPlanSummary(output);
|
|
98
|
+
expect(summary.split('\n')).toHaveLength(61);
|
|
99
|
+
expect(summary).toContain('more lines');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('scaffoldDriftWorkflow', () => {
|
|
104
|
+
it('renders region and role ARN into valid workflow YAML', () => {
|
|
105
|
+
const dir = makeTmp();
|
|
106
|
+
const result = scaffoldDriftWorkflow(dir, { region: 'eu-west-1', roleArn: 'arn:aws:iam::1:role/x' });
|
|
107
|
+
expect(result.ok).toBe(true);
|
|
108
|
+
const content = fs.readFileSync(path.join(dir, '.github', 'workflows', 'drift.yml'), 'utf8');
|
|
109
|
+
expect(content).toContain('AWS_REGION: eu-west-1');
|
|
110
|
+
expect(content).toContain('role-to-assume: arn:aws:iam::1:role/x');
|
|
111
|
+
expect(content).not.toContain('{{REGION}}');
|
|
112
|
+
expect(content).not.toContain('{{ROLE_ARN}}');
|
|
113
|
+
const parsed = loadYaml(content);
|
|
114
|
+
expect(parsed.on.schedule).toEqual([{ cron: '0 6 * * *' }]);
|
|
115
|
+
expect(parsed.on).toHaveProperty('workflow_dispatch');
|
|
116
|
+
expect(parsed.permissions).toMatchObject({ 'id-token': 'write', contents: 'read', issues: 'write' });
|
|
117
|
+
const steps = parsed.jobs.drift.steps;
|
|
118
|
+
expect(steps.some((step) => step.uses === 'actions/github-script@v7')).toBe(true);
|
|
119
|
+
expect(steps.some((step) => step.with?.terraform_wrapper === false)).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('refuses to overwrite without --force', () => {
|
|
123
|
+
const dir = makeTmp();
|
|
124
|
+
scaffoldDriftWorkflow(dir, { region: 'us-east-2', roleArn: 'arn:aws:iam::1:role/x' });
|
|
125
|
+
expect(scaffoldDriftWorkflow(dir, { region: 'us-east-2', roleArn: 'arn:aws:iam::1:role/y' }).reason).toBe('exists');
|
|
126
|
+
const forced = scaffoldDriftWorkflow(dir, { region: 'us-east-2', roleArn: 'arn:aws:iam::1:role/y', force: true });
|
|
127
|
+
expect(forced.ok).toBe(true);
|
|
128
|
+
expect(fs.readFileSync(path.join(dir, '.github', 'workflows', 'drift.yml'), 'utf8')).toContain('role/y');
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('runDrift --setup', () => {
|
|
133
|
+
it('scaffolds from the deploy workflow role ARN', async () => {
|
|
134
|
+
const dir = makeTmp();
|
|
135
|
+
writeDeployYml(dir);
|
|
136
|
+
writeMainTf(dir);
|
|
137
|
+
const result = await runDrift({ cwd: dir, setup: true });
|
|
138
|
+
expect(result.ok).toBe(true);
|
|
139
|
+
expect(result.file).toBe(path.join('.github', 'workflows', 'drift.yml'));
|
|
140
|
+
expect(fs.existsSync(path.join(dir, '.github', 'workflows', 'drift.yml'))).toBe(true);
|
|
141
|
+
expect(trackEvent).toHaveBeenCalledWith('drift_run', expect.objectContaining({ action: 'setup', success: true }));
|
|
142
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('soft-fails when the workflow exists without --force', async () => {
|
|
146
|
+
const dir = makeTmp();
|
|
147
|
+
writeDeployYml(dir);
|
|
148
|
+
writeMainTf(dir);
|
|
149
|
+
await runDrift({ cwd: dir, setup: true });
|
|
150
|
+
const second = await runDrift({ cwd: dir, setup: true });
|
|
151
|
+
expect(second).toMatchObject({ ok: false, reason: 'drift-workflow-exists' });
|
|
152
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
153
|
+
const forced = await runDrift({ cwd: dir, setup: true, force: true });
|
|
154
|
+
expect(forced.ok).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('fails without a deploy workflow or role ARN', async () => {
|
|
158
|
+
const missing = makeTmp();
|
|
159
|
+
writeMainTf(missing);
|
|
160
|
+
const noWorkflow = await runDrift({ cwd: missing, setup: true });
|
|
161
|
+
expect(noWorkflow.reason).toBe('workflow-not-found');
|
|
162
|
+
|
|
163
|
+
const noArn = makeTmp();
|
|
164
|
+
writeMainTf(noArn);
|
|
165
|
+
const workflows = path.join(noArn, '.github', 'workflows');
|
|
166
|
+
fs.mkdirSync(workflows, { recursive: true });
|
|
167
|
+
fs.writeFileSync(path.join(workflows, 'deploy.yml'), 'name: Deploy\n');
|
|
168
|
+
const result = await runDrift({ cwd: noArn, setup: true });
|
|
169
|
+
expect(result.reason).toBe('role-arn-not-found');
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe('runDrift check mode', () => {
|
|
174
|
+
function checkOptions(dir, planResult, initResult = { status: 0, stdout: '', stderr: '' }) {
|
|
175
|
+
return {
|
|
176
|
+
cwd: dir,
|
|
177
|
+
spawnSyncImpl: vi.fn((bin, args) => {
|
|
178
|
+
if (args[0] === 'init') return initResult;
|
|
179
|
+
return planResult;
|
|
180
|
+
}),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
it('reports clean on exit code 0', async () => {
|
|
185
|
+
const dir = makeTmp();
|
|
186
|
+
writeMainTf(dir);
|
|
187
|
+
const result = await runDrift(checkOptions(dir, { status: 0, stdout: 'No changes.', stderr: '' }));
|
|
188
|
+
expect(result).toMatchObject({ ok: true, drift: false });
|
|
189
|
+
expect(trackEvent).toHaveBeenCalledWith('drift_run', expect.objectContaining({ action: 'check', drift: false, success: true }));
|
|
190
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('reports DRIFT_DETECTED with exit code 2', async () => {
|
|
194
|
+
const dir = makeTmp();
|
|
195
|
+
writeMainTf(dir);
|
|
196
|
+
const result = await runDrift(checkOptions(dir, {
|
|
197
|
+
status: 2,
|
|
198
|
+
stdout: '# aws_lb.main will be updated\n ~ x = 1\nPlan: 0 to add, 1 to change, 0 to destroy.',
|
|
199
|
+
stderr: '',
|
|
200
|
+
}));
|
|
201
|
+
expect(result).toMatchObject({ ok: false, reason: 'drift-detected' });
|
|
202
|
+
expect(exitSpy).toHaveBeenCalledWith(2);
|
|
203
|
+
const output = logSpy.mock.calls.map((call) => String(call[0])).join('\n');
|
|
204
|
+
expect(output).toContain('Infrastructure drift detected');
|
|
205
|
+
expect(output).toContain('Plan: 0 to add, 1 to change, 0 to destroy.');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('fails on exit code 1 and on init failures', async () => {
|
|
209
|
+
const dir = makeTmp();
|
|
210
|
+
writeMainTf(dir);
|
|
211
|
+
const failed = await runDrift(checkOptions(dir, { status: 1, stdout: '', stderr: 'Error: boom' }));
|
|
212
|
+
expect(failed.reason).toBe('terraform-plan-failed');
|
|
213
|
+
|
|
214
|
+
const initFailed = await runDrift(checkOptions(dir, { status: 0, stdout: '', stderr: '' }, { status: 1, stdout: '', stderr: 'init no' }));
|
|
215
|
+
expect(initFailed.reason).toBe('terraform-init-failed');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('guides when terraform is missing or the project is uninitialized', async () => {
|
|
219
|
+
const dir = makeTmp();
|
|
220
|
+
writeMainTf(dir);
|
|
221
|
+
const enoent = await runDrift({ cwd: dir, spawnSyncImpl: () => ({ error: { code: 'ENOENT' } }) });
|
|
222
|
+
expect(enoent.reason).toBe('terraform-not-installed');
|
|
223
|
+
|
|
224
|
+
const bare = makeTmp();
|
|
225
|
+
const uninit = await runDrift({ cwd: bare });
|
|
226
|
+
expect(uninit.reason).toBe('terraform-not-initialized');
|
|
227
|
+
});
|
|
228
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Shared helpers for the E2E suites (Tier 0 + Tier 1). Kept separate from
|
|
2
|
+
// tests/helpers/ on purpose: unit mocks must never leak into black-box runs.
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
export const ROOT = path.resolve(HERE, '..', '..');
|
|
11
|
+
export const CLI = path.join(ROOT, 'bin', 'cli.js');
|
|
12
|
+
|
|
13
|
+
// Repo-local workspace root so CI can upload it as an artifact on failure.
|
|
14
|
+
// Overridable via E2E_TMP_ROOT for local debugging.
|
|
15
|
+
export const E2E_TMP_ROOT = process.env.E2E_TMP_ROOT || path.join(HERE, '.tmp');
|
|
16
|
+
|
|
17
|
+
// Shared provider cache across runs (mirrors scripts/test-iac.js).
|
|
18
|
+
const pluginCacheDir = path.join(os.tmpdir(), 'grada-e2e-plugin-cache');
|
|
19
|
+
|
|
20
|
+
export function e2eEnv({ mockAws }) {
|
|
21
|
+
fs.mkdirSync(pluginCacheDir, { recursive: true });
|
|
22
|
+
const env = {
|
|
23
|
+
...process.env,
|
|
24
|
+
DO_NOT_TRACK: '1',
|
|
25
|
+
TF_PLUGIN_CACHE_DIR: pluginCacheDir,
|
|
26
|
+
};
|
|
27
|
+
if (mockAws) {
|
|
28
|
+
env.CI_MOCK_AWS = 'true';
|
|
29
|
+
} else {
|
|
30
|
+
delete env.CI_MOCK_AWS;
|
|
31
|
+
}
|
|
32
|
+
return env;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function commandExists(cmd) {
|
|
36
|
+
try {
|
|
37
|
+
const result = spawnSync(cmd, ['--version'], { stdio: 'pipe' });
|
|
38
|
+
return result.status === 0;
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Fail fast with a helpful error when local prerequisites are missing.
|
|
45
|
+
export function assertPrerequisites() {
|
|
46
|
+
if (!commandExists('terraform')) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
'E2E requires the Terraform CLI on PATH (https://developer.hashicorp.com/terraform/install).'
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function makeTmpDir(prefix) {
|
|
54
|
+
fs.mkdirSync(E2E_TMP_ROOT, { recursive: true });
|
|
55
|
+
return fs.mkdtempSync(path.join(E2E_TMP_ROOT, `${prefix}-`));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function removeDir(dir) {
|
|
59
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Run the real CLI with stdin always closed (headless purity: a prompt
|
|
63
|
+
// crashes loudly instead of hanging). Output is inherited for visibility
|
|
64
|
+
// unless capture is requested for assertions.
|
|
65
|
+
export function runCli(args, { cwd, env, capture = false } = {}) {
|
|
66
|
+
const result = spawnSync('node', [CLI, ...args], {
|
|
67
|
+
cwd,
|
|
68
|
+
env,
|
|
69
|
+
stdio: capture ? ['ignore', 'pipe', 'pipe'] : ['ignore', 'inherit', 'inherit'],
|
|
70
|
+
encoding: 'utf-8',
|
|
71
|
+
});
|
|
72
|
+
return {
|
|
73
|
+
status: result.status,
|
|
74
|
+
stdout: result.stdout ?? '',
|
|
75
|
+
stderr: result.stderr ?? '',
|
|
76
|
+
output: `${result.stdout ?? ''}\n${result.stderr ?? ''}`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function runTerraform(args, { cwd, env }) {
|
|
81
|
+
const result = spawnSync('terraform', args, {
|
|
82
|
+
cwd,
|
|
83
|
+
env,
|
|
84
|
+
stdio: ['ignore', 'inherit', 'inherit'],
|
|
85
|
+
encoding: 'utf-8',
|
|
86
|
+
});
|
|
87
|
+
if (result.status !== 0) {
|
|
88
|
+
throw new Error(`terraform ${args.join(' ')} failed with exit ${result.status}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Isolated workspace that is removed on success and preserved (with its
|
|
93
|
+
// path printed) on failure for debugging and CI artifact upload.
|
|
94
|
+
export async function withTmpDir(prefix, fn) {
|
|
95
|
+
const dir = makeTmpDir(prefix);
|
|
96
|
+
try {
|
|
97
|
+
await fn(dir);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
console.log(`E2E workspace preserved for debugging: ${dir}`);
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
removeDir(dir);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function sleep(ms) {
|
|
106
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function readBackendValue(projectDir, key) {
|
|
110
|
+
const backend = fs.readFileSync(path.join(projectDir, 'terraform', 'backend.tf'), 'utf-8');
|
|
111
|
+
const match = backend.match(new RegExp(`${key}\\s*=\\s*"([^"]+)"`));
|
|
112
|
+
if (!match) throw new Error(`could not parse ${key} from terraform/backend.tf`);
|
|
113
|
+
return match[1];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function readBackendBucket(projectDir) {
|
|
117
|
+
return readBackendValue(projectDir, 'bucket');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function readBackendRegion(projectDir) {
|
|
121
|
+
return readBackendValue(projectDir, 'region');
|
|
122
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Tier 0 E2E: fast, PR-friendly, no AWS credentials. Mock-AWS scaffold,
|
|
2
|
+
// local checks, and failure-path contract pinning against the real CLI.
|
|
3
|
+
import { describe, it, expect, beforeAll } from 'vitest';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import {
|
|
7
|
+
e2eEnv,
|
|
8
|
+
assertPrerequisites,
|
|
9
|
+
runCli,
|
|
10
|
+
runTerraform,
|
|
11
|
+
withTmpDir,
|
|
12
|
+
} from './helpers.js';
|
|
13
|
+
|
|
14
|
+
const env = e2eEnv({ mockAws: true });
|
|
15
|
+
|
|
16
|
+
beforeAll(() => {
|
|
17
|
+
assertPrerequisites();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('Tier 0: scaffold (ECS)', () => {
|
|
21
|
+
it('inits, adds an addon, and generates valid Terraform', async () => {
|
|
22
|
+
await withTmpDir('tier0-ecs', async (dir) => {
|
|
23
|
+
const init = runCli(['init', '--target', 'ecs', '--headless'], { cwd: dir, env });
|
|
24
|
+
expect(init.status).toBe(0);
|
|
25
|
+
|
|
26
|
+
const add = runCli(['add', 'queue:sqs', '--headless'], { cwd: dir, env });
|
|
27
|
+
expect(add.status).toBe(0);
|
|
28
|
+
|
|
29
|
+
expect(fs.existsSync(path.join(dir, 'README.md'))).toBe(true);
|
|
30
|
+
expect(fs.existsSync(path.join(dir, 'terraform', 'main.tf'))).toBe(true);
|
|
31
|
+
expect(fs.existsSync(path.join(dir, 'terraform', 'sqs.tf'))).toBe(true);
|
|
32
|
+
const mainTf = fs.readFileSync(path.join(dir, 'terraform', 'main.tf'), 'utf-8');
|
|
33
|
+
expect(mainTf).toContain('SQS_QUEUE_URL');
|
|
34
|
+
|
|
35
|
+
runTerraform(['init', '-backend=false'], { cwd: path.join(dir, 'terraform'), env });
|
|
36
|
+
runTerraform(['validate'], { cwd: path.join(dir, 'terraform'), env });
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('Tier 0: scaffold (Lambda)', () => {
|
|
42
|
+
it('inits a Lambda project with valid Terraform', async () => {
|
|
43
|
+
await withTmpDir('tier0-lambda', async (dir) => {
|
|
44
|
+
const init = runCli(['init', '--target', 'lambda', '--headless'], { cwd: dir, env });
|
|
45
|
+
expect(init.status).toBe(0);
|
|
46
|
+
|
|
47
|
+
const mainTf = fs.readFileSync(path.join(dir, 'terraform', 'main.tf'), 'utf-8');
|
|
48
|
+
expect(mainTf).toContain('resource "aws_lambda_function" "app"');
|
|
49
|
+
|
|
50
|
+
runTerraform(['init', '-backend=false'], { cwd: path.join(dir, 'terraform'), env });
|
|
51
|
+
runTerraform(['validate'], { cwd: path.join(dir, 'terraform'), env });
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('Tier 0: addon matrix', () => {
|
|
57
|
+
// queue:sqs is covered by the ECS scaffold test above (including the
|
|
58
|
+
// SQS_QUEUE_URL env-injection assertion); the rest get one
|
|
59
|
+
// scaffold-and-validate pass each.
|
|
60
|
+
const ADDON_MATRIX = [
|
|
61
|
+
{ capability: 'storage:s3', file: 's3.tf', extraArgs: [] },
|
|
62
|
+
{ capability: 'db:dynamodb', file: 'dynamodb.tf', extraArgs: [] },
|
|
63
|
+
{ capability: 'db:redis', file: 'redis.tf', extraArgs: [] },
|
|
64
|
+
{ capability: 'ai:bedrock', file: 'bedrock.tf', extraArgs: [] },
|
|
65
|
+
{ capability: 'email:ses', file: 'ses.tf', extraArgs: ['--domain', 'example.com'] },
|
|
66
|
+
{ capability: 'cron', file: 'cron.tf', extraArgs: [] },
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
for (const { capability, file, extraArgs } of ADDON_MATRIX) {
|
|
70
|
+
it(`scaffolds valid Terraform for ${capability}`, async () => {
|
|
71
|
+
await withTmpDir(`tier0-add-${capability.replace(':', '-')}`, async (dir) => {
|
|
72
|
+
const init = runCli(['init', '--target', 'ecs', '--headless'], { cwd: dir, env });
|
|
73
|
+
expect(init.status).toBe(0);
|
|
74
|
+
|
|
75
|
+
const add = runCli(['add', capability, ...extraArgs, '--headless'], { cwd: dir, env });
|
|
76
|
+
expect(add.status).toBe(0);
|
|
77
|
+
expect(fs.existsSync(path.join(dir, 'terraform', file))).toBe(true);
|
|
78
|
+
|
|
79
|
+
runTerraform(['init', '-backend=false'], { cwd: path.join(dir, 'terraform'), env });
|
|
80
|
+
runTerraform(['validate'], { cwd: path.join(dir, 'terraform'), env });
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
it('composes multiple addons via init --with', async () => {
|
|
86
|
+
await withTmpDir('tier0-add-combo', async (dir) => {
|
|
87
|
+
const init = runCli(['init', '--target', 'ecs', '--with', 'queue:sqs,db:redis', '--headless'], { cwd: dir, env });
|
|
88
|
+
expect(init.status).toBe(0);
|
|
89
|
+
expect(fs.existsSync(path.join(dir, 'terraform', 'sqs.tf'))).toBe(true);
|
|
90
|
+
expect(fs.existsSync(path.join(dir, 'terraform', 'redis.tf'))).toBe(true);
|
|
91
|
+
|
|
92
|
+
runTerraform(['init', '-backend=false'], { cwd: path.join(dir, 'terraform'), env });
|
|
93
|
+
runTerraform(['validate'], { cwd: path.join(dir, 'terraform'), env });
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('Tier 0: local checks', () => {
|
|
99
|
+
it('ejects metadata from a scaffolded project', async () => {
|
|
100
|
+
await withTmpDir('tier0-eject', async (dir) => {
|
|
101
|
+
const init = runCli(['init', '--headless'], { cwd: dir, env });
|
|
102
|
+
expect(init.status).toBe(0);
|
|
103
|
+
|
|
104
|
+
const mainTfPath = path.join(dir, 'terraform', 'main.tf');
|
|
105
|
+
expect(fs.readFileSync(mainTfPath, 'utf-8')).toContain('ManagedBy');
|
|
106
|
+
|
|
107
|
+
const eject = runCli(['eject', '--yes'], { cwd: dir, env });
|
|
108
|
+
expect(eject.status).toBe(0);
|
|
109
|
+
expect(fs.readFileSync(mainTfPath, 'utf-8')).not.toContain('ManagedBy');
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('doctor exits 0', async () => {
|
|
114
|
+
await withTmpDir('tier0-doctor', async (dir) => {
|
|
115
|
+
const doctor = runCli(['doctor'], { cwd: dir, env });
|
|
116
|
+
expect(doctor.status).toBe(0);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('Tier 0: failure paths', () => {
|
|
122
|
+
it('apply outside a project exits 1 cleanly', async () => {
|
|
123
|
+
await withTmpDir('tier0-fail-apply', async (dir) => {
|
|
124
|
+
const result = runCli(['apply', '--headless'], { cwd: dir, env, capture: true });
|
|
125
|
+
expect(result.status).toBe(1);
|
|
126
|
+
expect(result.output).toContain('No terraform directory');
|
|
127
|
+
expect(result.output).not.toMatch(/^\s+at\s/m);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('init with an invalid target exits 1 cleanly', async () => {
|
|
132
|
+
await withTmpDir('tier0-fail-target', async (dir) => {
|
|
133
|
+
// NOTE: --headless is required to reach flag validation: without
|
|
134
|
+
// it, init prompts for the target directory first and exits 0 on
|
|
135
|
+
// EOF (the spec shows this command without --headless).
|
|
136
|
+
const result = runCli(['init', '--target', 'fake-target', '--headless'], { cwd: dir, env, capture: true });
|
|
137
|
+
expect(result.status).toBe(1);
|
|
138
|
+
expect(result.output).toContain('Invalid compute target');
|
|
139
|
+
expect(result.output).not.toMatch(/^\s+at\s/m);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Tier 1 E2E: full live lifecycle against real AWS (nightly/manual only).
|
|
2
|
+
// Skips gracefully without credentials. Validated in CI, not locally.
|
|
3
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
4
|
+
import { S3Client, HeadBucketCommand } from '@aws-sdk/client-s3';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import {
|
|
8
|
+
e2eEnv,
|
|
9
|
+
assertPrerequisites,
|
|
10
|
+
makeTmpDir,
|
|
11
|
+
removeDir,
|
|
12
|
+
runCli,
|
|
13
|
+
sleep,
|
|
14
|
+
readBackendBucket,
|
|
15
|
+
readBackendRegion,
|
|
16
|
+
} from './helpers.js';
|
|
17
|
+
|
|
18
|
+
const env = e2eEnv({ mockAws: false });
|
|
19
|
+
const STATUS_POLL_INTERVAL_MS = 15000;
|
|
20
|
+
const STATUS_POLL_BUDGET_MS = 6 * 60 * 1000;
|
|
21
|
+
const BUCKET_POLL_INTERVAL_MS = 10000;
|
|
22
|
+
const BUCKET_POLL_BUDGET_MS = 3 * 60 * 1000;
|
|
23
|
+
|
|
24
|
+
// Serial execution is mandatory (ordered lifecycle); the whole suite is
|
|
25
|
+
// skipped without credentials. (Vitest 5 cannot chain .serial.skipIf.)
|
|
26
|
+
const describeLive = process.env.AWS_ACCESS_KEY_ID ? describe.serial : describe.skip;
|
|
27
|
+
|
|
28
|
+
describeLive('Tier 1: live lifecycle', () => {
|
|
29
|
+
let dir;
|
|
30
|
+
let suiteFailed = false;
|
|
31
|
+
|
|
32
|
+
beforeAll(() => {
|
|
33
|
+
assertPrerequisites();
|
|
34
|
+
dir = makeTmpDir('tier1-live');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterAll(() => {
|
|
38
|
+
if (suiteFailed) {
|
|
39
|
+
console.log(`Tier 1 workspace preserved for debugging: ${dir}`);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
removeDir(dir);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
async function step(fn) {
|
|
46
|
+
try {
|
|
47
|
+
await fn();
|
|
48
|
+
} catch (error) {
|
|
49
|
+
suiteFailed = true;
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
it('init scaffolds the project', async () => {
|
|
55
|
+
await step(async () => {
|
|
56
|
+
const init = runCli(['init', '--target', 'ecs', '--headless'], { cwd: dir, env });
|
|
57
|
+
expect(init.status).toBe(0);
|
|
58
|
+
expect(fs.existsSync(path.join(dir, 'terraform', 'main.tf'))).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('apply provisions infrastructure', async () => {
|
|
63
|
+
await step(async () => {
|
|
64
|
+
const apply = runCli(['apply', '--auto-approve'], { cwd: dir, env });
|
|
65
|
+
expect(apply.status).toBe(0);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('status becomes healthy', async () => {
|
|
70
|
+
await step(async () => {
|
|
71
|
+
const deadline = Date.now() + STATUS_POLL_BUDGET_MS;
|
|
72
|
+
let healthy = false;
|
|
73
|
+
let lastOutput = '';
|
|
74
|
+
while (Date.now() < deadline) {
|
|
75
|
+
// --json exits 0 even when degraded, so the payload (not the
|
|
76
|
+
// exit code) drives the loop; parse failures just mean "wait".
|
|
77
|
+
const result = runCli(['status', '--json'], { cwd: dir, env, capture: true });
|
|
78
|
+
lastOutput = result.output;
|
|
79
|
+
try {
|
|
80
|
+
healthy = JSON.parse(result.stdout).healthy === true;
|
|
81
|
+
} catch {
|
|
82
|
+
healthy = false;
|
|
83
|
+
}
|
|
84
|
+
if (healthy) break;
|
|
85
|
+
await sleep(STATUS_POLL_INTERVAL_MS);
|
|
86
|
+
}
|
|
87
|
+
expect([healthy, lastOutput]).toEqual([true, expect.any(String)]);
|
|
88
|
+
|
|
89
|
+
const final = runCli(['status'], { cwd: dir, env });
|
|
90
|
+
expect(final.status).toBe(0);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('destroy tears everything down', async () => {
|
|
95
|
+
await step(async () => {
|
|
96
|
+
const bucket = readBackendBucket(dir);
|
|
97
|
+
const region = readBackendRegion(dir);
|
|
98
|
+
|
|
99
|
+
const destroy = runCli(['destroy', '--yes'], { cwd: dir, env });
|
|
100
|
+
expect(destroy.status).toBe(0);
|
|
101
|
+
|
|
102
|
+
const s3 = new S3Client({ region });
|
|
103
|
+
const deadline = Date.now() + BUCKET_POLL_BUDGET_MS;
|
|
104
|
+
for (;;) {
|
|
105
|
+
try {
|
|
106
|
+
await s3.send(new HeadBucketCommand({ Bucket: bucket }));
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (error?.name === 'NotFound' || error?.$metadata?.httpStatusCode === 404) return;
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
if (Date.now() >= deadline) {
|
|
112
|
+
throw new Error(`state bucket ${bucket} still exists after destroy`);
|
|
113
|
+
}
|
|
114
|
+
await sleep(BUCKET_POLL_INTERVAL_MS);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
});
|