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.
Files changed (231) hide show
  1. package/.github/workflows/deploy-docs.yml +37 -0
  2. package/.github/workflows/iac-validation.yml +303 -0
  3. package/.github/workflows/publish.yml +68 -0
  4. package/.github/workflows/sync-bedrock-models.yml +57 -0
  5. package/.github/workflows/test.yml +43 -0
  6. package/.muserules +31 -0
  7. package/LICENSE +21 -0
  8. package/README.md +193 -3
  9. package/apps/docs/.astro/collections/docs.schema.json +644 -0
  10. package/apps/docs/.astro/content-assets.mjs +4 -0
  11. package/apps/docs/.astro/content-modules.mjs +4 -0
  12. package/apps/docs/.astro/content.d.ts +179 -0
  13. package/apps/docs/.astro/data-store.json +1 -0
  14. package/apps/docs/.astro/dev.json +14 -0
  15. package/apps/docs/.astro/settings.json +5 -0
  16. package/apps/docs/.astro/types.d.ts +2 -0
  17. package/apps/docs/astro.config.mjs +97 -0
  18. package/apps/docs/package.json +17 -0
  19. package/apps/docs/src/content/docs/adrs/0001-s3-native-state-locking.md +37 -0
  20. package/apps/docs/src/content/docs/adrs/0002-eject-mechanism-pure-iac.md +39 -0
  21. package/apps/docs/src/content/docs/adrs/0003-sync-ai-context-strategy.md +48 -0
  22. package/apps/docs/src/content/docs/adrs/0004-iac-driven-diagnostic-context.md +37 -0
  23. package/apps/docs/src/content/docs/adrs/0005-ecs-fargate-alb-runtime-target.md +38 -0
  24. package/apps/docs/src/content/docs/adrs/0006-github-oidc-no-stored-keys.md +37 -0
  25. package/apps/docs/src/content/docs/adrs/0007-framework-detection-with-fallback.md +37 -0
  26. package/apps/docs/src/content/docs/adrs/0008-secrets-names-in-git-values-in-aws.md +37 -0
  27. package/apps/docs/src/content/docs/adrs/0009-regenerate-with-backup-on-rerun.md +37 -0
  28. package/apps/docs/src/content/docs/adrs/0010-advisory-only-security-scans.md +37 -0
  29. package/apps/docs/src/content/docs/cli/add.md +84 -0
  30. package/apps/docs/src/content/docs/cli/apply.md +32 -0
  31. package/apps/docs/src/content/docs/cli/db.md +200 -0
  32. package/apps/docs/src/content/docs/cli/destroy.md +31 -0
  33. package/apps/docs/src/content/docs/cli/diagnose.md +37 -0
  34. package/apps/docs/src/content/docs/cli/doctor.md +28 -0
  35. package/apps/docs/src/content/docs/cli/domain.md +57 -0
  36. package/apps/docs/src/content/docs/cli/drift.md +40 -0
  37. package/apps/docs/src/content/docs/cli/eject.md +29 -0
  38. package/apps/docs/src/content/docs/cli/exec.md +49 -0
  39. package/apps/docs/src/content/docs/cli/gc.md +37 -0
  40. package/apps/docs/src/content/docs/cli/init.md +72 -0
  41. package/apps/docs/src/content/docs/cli/logs.md +39 -0
  42. package/apps/docs/src/content/docs/cli/rollback.md +51 -0
  43. package/apps/docs/src/content/docs/cli/secrets.md +73 -0
  44. package/apps/docs/src/content/docs/cli/sleep.md +53 -0
  45. package/apps/docs/src/content/docs/cli/status.md +34 -0
  46. package/apps/docs/src/content/docs/cli/sync-ai.md +27 -0
  47. package/apps/docs/src/content/docs/guides/architecture.md +87 -0
  48. package/apps/docs/src/content/docs/guides/aws-credentials.md +72 -0
  49. package/apps/docs/src/content/docs/guides/background-workers.md +45 -0
  50. package/apps/docs/src/content/docs/guides/cicd-pipeline.md +64 -0
  51. package/apps/docs/src/content/docs/guides/database-connections.md +64 -0
  52. package/apps/docs/src/content/docs/guides/docker-compose.md +37 -0
  53. package/apps/docs/src/content/docs/guides/dockerfiles.md +46 -0
  54. package/apps/docs/src/content/docs/guides/ephemeral-pr-previews.md +39 -0
  55. package/apps/docs/src/content/docs/guides/examples.md +50 -0
  56. package/apps/docs/src/content/docs/guides/frameworks.md +88 -0
  57. package/apps/docs/src/content/docs/guides/headless.md +75 -0
  58. package/apps/docs/src/content/docs/guides/quickstart.md +52 -0
  59. package/apps/docs/src/content/docs/guides/rerun-init.md +43 -0
  60. package/apps/docs/src/content/docs/guides/secrets-management.md +83 -0
  61. package/apps/docs/src/content/docs/guides/understanding-your-bill.md +63 -0
  62. package/apps/docs/src/content/docs/index.mdx +103 -0
  63. package/apps/docs/src/content/docs/migrations/astro-vercel-to-aws.md +55 -0
  64. package/apps/docs/src/content/docs/migrations/heroku-procfile-to-aws.md +41 -0
  65. package/apps/docs/src/content/docs/migrations/nextjs-vercel-to-aws.md +51 -0
  66. package/apps/docs/src/content/docs/migrations/sveltekit-vercel-to-aws.md +63 -0
  67. package/apps/docs/src/content/docs/roadmap.md +97 -0
  68. package/apps/docs/src/content/docs/testing-strategy.md +32 -0
  69. package/apps/docs/src/content.config.ts +7 -0
  70. package/apps/docs/src/custom.css +14 -0
  71. package/apps/docs/tsconfig.json +6 -0
  72. package/bin/cli.js +140 -0
  73. package/package.json +105 -7
  74. package/scripts/sync-bedrock-models.js +22 -0
  75. package/scripts/test-iac.js +261 -0
  76. package/specs/add-redis-sqs-bedrock.md +128 -0
  77. package/specs/add-storage-dynamodb.md +106 -0
  78. package/specs/bedrock-model-catalog.md +131 -0
  79. package/specs/ci-pipeline.md +17 -0
  80. package/specs/cost-transparency.md +115 -0
  81. package/specs/custom-domains-and-ses.md +153 -0
  82. package/specs/database-suite-expansion.md +151 -0
  83. package/specs/db-connect.md +69 -0
  84. package/specs/db-lifecycle-migrations.md +159 -0
  85. package/specs/dependency-aware-init.md +176 -0
  86. package/specs/deploy-stack-to-grada-run-rebrand.md +45 -0
  87. package/specs/deployment-safety.md +170 -0
  88. package/specs/diagnose.md +16 -0
  89. package/specs/docs-hub.md +16 -0
  90. package/specs/dx-polish.md +46 -0
  91. package/specs/exec.md +25 -0
  92. package/specs/finops-cron-drift.md +161 -0
  93. package/specs/gc.md +26 -0
  94. package/specs/integration-suite.md +16 -0
  95. package/specs/logs.md +32 -0
  96. package/specs/rollback-live-polling.md +40 -0
  97. package/specs/secrets-pull-audit.md +51 -0
  98. package/specs/serverless-lambda-target.md +133 -0
  99. package/specs/status.md +31 -0
  100. package/specs/telemetry-and-spawn-hardening.md +69 -0
  101. package/specs/telemetry-hardening.md +35 -0
  102. package/src/commands/add.js +1111 -0
  103. package/src/commands/apply.js +214 -0
  104. package/src/commands/db/backup.js +229 -0
  105. package/src/commands/db/connect.js +304 -0
  106. package/src/commands/db/enable-vector.js +344 -0
  107. package/src/commands/db/import.js +604 -0
  108. package/src/commands/db/migrate.js +477 -0
  109. package/src/commands/db/restore.js +361 -0
  110. package/src/commands/db.js +87 -0
  111. package/src/commands/destroy.js +217 -0
  112. package/src/commands/diagnose.js +460 -0
  113. package/src/commands/doctor.js +109 -0
  114. package/src/commands/domain.js +685 -0
  115. package/src/commands/drift.js +243 -0
  116. package/src/commands/eject.js +127 -0
  117. package/src/commands/exec.js +222 -0
  118. package/src/commands/gc.js +250 -0
  119. package/src/commands/init.js +649 -0
  120. package/src/commands/logs.js +256 -0
  121. package/src/commands/rollback.js +323 -0
  122. package/src/commands/secrets.js +485 -0
  123. package/src/commands/sleep.js +347 -0
  124. package/src/commands/status.js +309 -0
  125. package/src/commands/sync-ai.js +115 -0
  126. package/src/commands/wake.js +337 -0
  127. package/src/core/parser.js +126 -0
  128. package/src/core/telemetry.js +244 -0
  129. package/src/data/bedrock-models.json +896 -0
  130. package/src/utils/addons.js +126 -0
  131. package/src/utils/ai-rules.js +59 -0
  132. package/src/utils/args.js +91 -0
  133. package/src/utils/aws.js +178 -0
  134. package/src/utils/backup.js +69 -0
  135. package/src/utils/bedrock-catalog.js +511 -0
  136. package/src/utils/capabilities.js +500 -0
  137. package/src/utils/command.js +65 -0
  138. package/src/utils/db-tunnel.js +164 -0
  139. package/src/utils/detector.js +298 -0
  140. package/src/utils/dockerCompose.js +65 -0
  141. package/src/utils/domains.js +73 -0
  142. package/src/utils/ecs-runner.js +289 -0
  143. package/src/utils/ecs.js +92 -0
  144. package/src/utils/frameworks.js +55 -0
  145. package/src/utils/generator.js +527 -0
  146. package/src/utils/hcl.js +426 -0
  147. package/src/utils/lambda-ecr.js +185 -0
  148. package/src/utils/prompts.js +278 -0
  149. package/src/utils/rds.js +131 -0
  150. package/src/utils/resolvers.js +174 -0
  151. package/src/utils/sleep-state.js +140 -0
  152. package/src/utils/sleep-targets.js +139 -0
  153. package/src/utils/system.js +42 -0
  154. package/src/utils/terraform.js +70 -0
  155. package/src/utils/visualizer.js +381 -0
  156. package/src/utils/warnings.js +49 -0
  157. package/templates/README.md +150 -0
  158. package/templates/docker/django.Dockerfile +40 -0
  159. package/templates/docker/go.Dockerfile +23 -0
  160. package/templates/docker/nestjs.Dockerfile +33 -0
  161. package/templates/docker/nextjs.Dockerfile +55 -0
  162. package/templates/docker/node.Dockerfile +24 -0
  163. package/templates/docker/nuxt.Dockerfile +47 -0
  164. package/templates/docker/python.Dockerfile +38 -0
  165. package/templates/docker/rails.Dockerfile +59 -0
  166. package/templates/docker/static.Dockerfile +32 -0
  167. package/templates/docker/svelte.Dockerfile +52 -0
  168. package/templates/github/deploy-lambda.yml +120 -0
  169. package/templates/github/deploy.yml +138 -0
  170. package/templates/github/drift.yml +112 -0
  171. package/templates/github/preview-lambda.yml +86 -0
  172. package/templates/github/preview.yml +69 -0
  173. package/templates/github/teardown.yml +43 -0
  174. package/templates/terraform/addons/bedrock.tf +34 -0
  175. package/templates/terraform/addons/cron-lambda.tf +78 -0
  176. package/templates/terraform/addons/cron.tf +101 -0
  177. package/templates/terraform/addons/dynamodb.tf +73 -0
  178. package/templates/terraform/addons/redis.tf +64 -0
  179. package/templates/terraform/addons/s3.tf +143 -0
  180. package/templates/terraform/addons/ses.tf +73 -0
  181. package/templates/terraform/addons/sqs.tf +67 -0
  182. package/templates/terraform/backend.tf +22 -0
  183. package/templates/terraform/cloudfront-lambda.tf +80 -0
  184. package/templates/terraform/cloudfront.tf +80 -0
  185. package/templates/terraform/database-aurora-postgresql.tf +92 -0
  186. package/templates/terraform/database-mysql.tf +72 -0
  187. package/templates/terraform/database.tf +71 -0
  188. package/templates/terraform/main-lambda.tf +229 -0
  189. package/templates/terraform/main.tf +296 -0
  190. package/templates/terraform/network.tf +95 -0
  191. package/templates/terraform/oidc.tf +64 -0
  192. package/templates/terraform/secrets.tf +31 -0
  193. package/templates/terraform/worker.tf +69 -0
  194. package/tests/__snapshots__/generator.test.js.snap +9633 -0
  195. package/tests/add.test.js +2037 -0
  196. package/tests/ai.test.js +94 -0
  197. package/tests/apply.test.js +488 -0
  198. package/tests/args.test.js +86 -0
  199. package/tests/aws.test.js +244 -0
  200. package/tests/capabilities.test.js +307 -0
  201. package/tests/cli.test.js +29 -0
  202. package/tests/command.test.js +100 -0
  203. package/tests/commands-import.test.js +74 -0
  204. package/tests/db.test.js +2704 -0
  205. package/tests/destroy.test.js +391 -0
  206. package/tests/detector.test.js +79 -0
  207. package/tests/diagnose.test.js +779 -0
  208. package/tests/doctor.test.js +202 -0
  209. package/tests/domain.test.js +899 -0
  210. package/tests/drift.test.js +243 -0
  211. package/tests/ecs.test.js +130 -0
  212. package/tests/eject.test.js +65 -0
  213. package/tests/exec.test.js +380 -0
  214. package/tests/gc.test.js +496 -0
  215. package/tests/generator.test.js +794 -0
  216. package/tests/headless.test.js +562 -0
  217. package/tests/lambda-ecr.test.js +185 -0
  218. package/tests/logs.test.js +447 -0
  219. package/tests/parser.test.js +160 -0
  220. package/tests/rds.test.js +244 -0
  221. package/tests/resolvers.test.js +282 -0
  222. package/tests/rollback.test.js +692 -0
  223. package/tests/secrets.test.js +752 -0
  224. package/tests/sleep-wake.test.js +1016 -0
  225. package/tests/status.test.js +370 -0
  226. package/tests/system.test.js +70 -0
  227. package/tests/telemetry.test.js +520 -0
  228. package/tests/terraform.test.js +84 -0
  229. package/tests/visualizer.test.js +496 -0
  230. package/vitest.config.js +9 -0
  231. package/index.js +0 -2
@@ -0,0 +1,1016 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import { stripVTControlCharacters } from 'node:util';
6
+ import { runSleep, parseSleepArgs } from '../src/commands/sleep.js';
7
+ import { runWake, parseWakeArgs } from '../src/commands/wake.js';
8
+ import {
9
+ resolveSleepTarget,
10
+ normalizeSleepEnv,
11
+ readSleepState,
12
+ writeSleepState,
13
+ removeSleepStateEntry,
14
+ ensureSleepGitignore,
15
+ computeAutoRestartAt,
16
+ formatUtcTimestamp,
17
+ RDS_AUTO_RESTART_MS,
18
+ } from '../src/utils/sleep-state.js';
19
+ import { estimateSleepSavings } from '../src/utils/visualizer.js';
20
+ import { trackEvent, flushTelemetry } from '../src/core/telemetry.js';
21
+ import { confirm } from '@clack/prompts';
22
+ import {
23
+ readCronScheduleName,
24
+ setSchedulerState,
25
+ setWorkerScalingSuspended,
26
+ } from '../src/utils/sleep-targets.js';
27
+
28
+ vi.mock('@clack/prompts', () => ({
29
+ intro: vi.fn(),
30
+ outro: vi.fn(),
31
+ spinner: () => ({ start: vi.fn(), stop: vi.fn(), message: vi.fn() }),
32
+ confirm: vi.fn(),
33
+ cancel: vi.fn(),
34
+ isCancel: (value) => typeof value === 'symbol',
35
+ }));
36
+
37
+ vi.mock('../src/core/telemetry.js', () => {
38
+ const trackEvent = vi.fn();
39
+ const flushTelemetry = vi.fn().mockResolvedValue();
40
+ const trackSuccess = vi.fn(async (event, properties) => {
41
+ trackEvent(event, { ...properties, success: true });
42
+ await flushTelemetry();
43
+ });
44
+ const trackFailure = vi.fn(async (event, properties) => {
45
+ trackEvent(event, { ...properties, success: false });
46
+ await flushTelemetry();
47
+ });
48
+ return { trackEvent, flushTelemetry, trackSuccess, trackFailure };
49
+ });
50
+
51
+ vi.mock('../src/utils/aws.js', () => ({
52
+ handleAuthErrorBranch: () => false,
53
+ resolveClient: (injected) => injected,
54
+ }));
55
+
56
+ let tmpDirs = [];
57
+ let exitSpy;
58
+ let logSpy;
59
+
60
+ function makeTmp() {
61
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sleep-wake-test-'));
62
+ tmpDirs.push(dir);
63
+ return dir;
64
+ }
65
+
66
+ // Minimal ECS/RDS recorder: `ecs` maps service names to service objects,
67
+ // `db` is the findDbTarget result inputs ({ instance, cluster }).
68
+ function mockClients({ ecs = {}, db = null, onCommand = null } = {}) {
69
+ const seen = [];
70
+ const ecsClient = {
71
+ send: vi.fn(async (command) => {
72
+ seen.push(command.constructor.name);
73
+ if (onCommand) await onCommand(command);
74
+ const name = command.constructor.name;
75
+ if (name === 'DescribeServicesCommand') {
76
+ const services = (command.input.services || [])
77
+ .map((serviceName) => ecs[serviceName] || null)
78
+ .filter(Boolean);
79
+ return { services };
80
+ }
81
+ if (name === 'UpdateServiceCommand') {
82
+ const service = ecs[command.input.service];
83
+ if (service) service.desiredCount = command.input.desiredCount;
84
+ return {};
85
+ }
86
+ throw new Error(`unexpected ECS command: ${name}`);
87
+ }),
88
+ };
89
+ const rdsClient = {
90
+ send: vi.fn(async (command) => {
91
+ seen.push(command.constructor.name);
92
+ const name = command.constructor.name;
93
+ if (name === 'DescribeDBInstancesCommand') {
94
+ if (db?.instance) return { DBInstances: [db.instance] };
95
+ throw Object.assign(new Error('not found'), { name: 'DBInstanceNotFound' });
96
+ }
97
+ if (name === 'DescribeDBClustersCommand') {
98
+ if (db?.cluster) return { DBClusters: [db.cluster] };
99
+ throw Object.assign(new Error('not found'), { name: 'DBClusterNotFoundFault' });
100
+ }
101
+ if (name === 'StopDBInstanceCommand' && db?.instance) {
102
+ db.instance.Status = 'stopping';
103
+ return {};
104
+ }
105
+ if (name === 'StopDBClusterCommand' && db?.cluster) {
106
+ db.cluster.Status = 'stopping';
107
+ return {};
108
+ }
109
+ if (name === 'StartDBInstanceCommand' && db?.instance) {
110
+ db.instance.Status = 'starting';
111
+ return {};
112
+ }
113
+ if (name === 'StartDBClusterCommand' && db?.cluster) {
114
+ db.cluster.Status = 'starting';
115
+ return {};
116
+ }
117
+ return {};
118
+ }),
119
+ };
120
+ return { ecsClient, rdsClient, seen };
121
+ }
122
+
123
+ function activeService(name, desiredCount) {
124
+ return { serviceName: name, status: 'ACTIVE', desiredCount, runningCount: desiredCount };
125
+ }
126
+
127
+ beforeEach(() => {
128
+ tmpDirs = [];
129
+ vi.clearAllMocks();
130
+ exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {});
131
+ logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
132
+ });
133
+
134
+ afterEach(() => {
135
+ exitSpy.mockRestore();
136
+ logSpy.mockRestore();
137
+ for (const dir of tmpDirs) {
138
+ fs.rmSync(dir, { recursive: true, force: true });
139
+ }
140
+ });
141
+
142
+ describe('parseSleepArgs / parseWakeArgs', () => {
143
+ it('parses the positional env and sleep flags', () => {
144
+ const options = parseSleepArgs(['sleep', 'staging', '--skip-db', '--region', 'eu-west-1', '--yes']);
145
+ expect(options).toMatchObject({ env: 'staging', skipDb: true, region: 'eu-west-1', yes: true });
146
+ });
147
+
148
+ it('parses wake flags including --no-wait and --workspace', () => {
149
+ const options = parseWakeArgs(['wake', '--no-wait', '--workspace', 'pr-42']);
150
+ expect(options).toMatchObject({ noWait: true, workspace: 'pr-42' });
151
+ expect(options.env).toBeUndefined();
152
+ });
153
+
154
+ it('collects extra positionals for the guard', () => {
155
+ expect(parseSleepArgs(['sleep', 'a', 'b']).unexpectedPositionals).toEqual(['b']);
156
+ });
157
+ });
158
+
159
+ describe('normalizeSleepEnv', () => {
160
+ it('maps default aliases and passes named envs through', () => {
161
+ expect(normalizeSleepEnv('default')).toBe('default');
162
+ expect(normalizeSleepEnv('prod')).toBe('default');
163
+ expect(normalizeSleepEnv('Production')).toBe('default');
164
+ expect(normalizeSleepEnv('staging')).toBe('staging');
165
+ expect(normalizeSleepEnv(' pr-42 ')).toBe('pr-42');
166
+ });
167
+
168
+ it('returns null for blank or non-string input', () => {
169
+ expect(normalizeSleepEnv('')).toBeNull();
170
+ expect(normalizeSleepEnv(' ')).toBeNull();
171
+ expect(normalizeSleepEnv(undefined)).toBeNull();
172
+ expect(normalizeSleepEnv(42)).toBeNull();
173
+ });
174
+ });
175
+
176
+ describe('resolveSleepTarget', () => {
177
+ it('resolves default names with no env', () => {
178
+ const dir = makeTmp();
179
+ const target = resolveSleepTarget({ projectName: 'myapp' }, dir);
180
+ expect(target).toMatchObject({
181
+ envKey: 'default',
182
+ envKind: 'default',
183
+ requiresConfirm: true,
184
+ appPrefix: 'myapp',
185
+ cluster: 'myapp-cluster',
186
+ appService: 'myapp-service',
187
+ workerService: 'myapp-worker-service',
188
+ dbIdentifier: 'myapp-db',
189
+ dbClusterIdentifier: 'myapp-db-cluster',
190
+ });
191
+ });
192
+
193
+ it('namespaces names for a positional env', () => {
194
+ const dir = makeTmp();
195
+ const target = resolveSleepTarget({ projectName: 'myapp', env: 'staging' }, dir);
196
+ expect(target).toMatchObject({
197
+ envKey: 'staging',
198
+ envKind: 'named',
199
+ requiresConfirm: false,
200
+ appPrefix: 'myapp-staging',
201
+ cluster: 'myapp-staging-cluster',
202
+ workerService: 'myapp-staging-worker-service',
203
+ dbIdentifier: 'myapp-staging-db',
204
+ });
205
+ });
206
+
207
+ it('prefers --workspace over the positional env', () => {
208
+ const dir = makeTmp();
209
+ const target = resolveSleepTarget({ projectName: 'myapp', env: 'staging', workspace: 'pr-42' }, dir);
210
+ expect(target.envKey).toBe('pr-42');
211
+ expect(target.appPrefix).toBe('myapp-pr-42');
212
+ });
213
+
214
+ it('lets explicit resource flags win', () => {
215
+ const dir = makeTmp();
216
+ const target = resolveSleepTarget({
217
+ projectName: 'myapp',
218
+ env: 'staging',
219
+ cluster: 'custom-cluster',
220
+ service: 'custom-service',
221
+ dbIdentifier: 'custom-db',
222
+ }, dir);
223
+ expect(target.cluster).toBe('custom-cluster');
224
+ expect(target.appService).toBe('custom-service');
225
+ expect(target.dbIdentifier).toBe('custom-db');
226
+ });
227
+
228
+ it('requires confirmation for prod aliases and auto-detected workspaces', () => {
229
+ const dir = makeTmp();
230
+ expect(resolveSleepTarget({ projectName: 'myapp', env: 'prod' }, dir).requiresConfirm).toBe(true);
231
+ fs.mkdirSync(path.join(dir, '.terraform'), { recursive: true });
232
+ fs.writeFileSync(path.join(dir, '.terraform', 'environment'), 'pr-42');
233
+ const auto = resolveSleepTarget({ projectName: 'myapp' }, dir);
234
+ expect(auto.envKey).toBe('pr-42');
235
+ expect(auto.requiresConfirm).toBe(true);
236
+ });
237
+ });
238
+
239
+ describe('sleep-state ledger', () => {
240
+ it('round-trips entries and removes by env', () => {
241
+ const dir = makeTmp();
242
+ expect(readSleepState(dir)).toEqual({});
243
+ writeSleepState(dir, { staging: { env: 'staging' } });
244
+ expect(readSleepState(dir)).toEqual({ staging: { env: 'staging' } });
245
+ expect(removeSleepStateEntry(dir, 'staging')).toBe(true);
246
+ expect(removeSleepStateEntry(dir, 'staging')).toBe(false);
247
+ expect(readSleepState(dir)).toEqual({});
248
+ });
249
+
250
+ it('reads corrupt files as empty', () => {
251
+ const dir = makeTmp();
252
+ fs.mkdirSync(path.join(dir, '.grada'), { recursive: true });
253
+ fs.writeFileSync(path.join(dir, '.grada', 'sleep-state.json'), '{nope');
254
+ expect(readSleepState(dir)).toEqual({});
255
+ });
256
+
257
+ it('falls back to the legacy ledger path', () => {
258
+ const dir = makeTmp();
259
+ fs.mkdirSync(path.join(dir, '.deploy-stack'), { recursive: true });
260
+ fs.writeFileSync(
261
+ path.join(dir, '.deploy-stack', 'sleep-state.json'),
262
+ JSON.stringify({ default: { env: 'default' } })
263
+ );
264
+ expect(readSleepState(dir)).toEqual({ default: { env: 'default' } });
265
+ });
266
+
267
+ it('prefers the new ledger path over legacy', () => {
268
+ const dir = makeTmp();
269
+ writeSleepState(dir, { staging: { env: 'staging' } });
270
+ fs.mkdirSync(path.join(dir, '.deploy-stack'), { recursive: true });
271
+ fs.writeFileSync(
272
+ path.join(dir, '.deploy-stack', 'sleep-state.json'),
273
+ JSON.stringify({ default: { env: 'default' } })
274
+ );
275
+ expect(readSleepState(dir)).toEqual({ staging: { env: 'staging' } });
276
+ });
277
+
278
+ it('gitignores the ledger exactly once', () => {
279
+ const dir = makeTmp();
280
+ expect(ensureSleepGitignore(dir)).toBe(true);
281
+ expect(fs.readFileSync(path.join(dir, '.gitignore'), 'utf8')).toContain('.grada/');
282
+ expect(ensureSleepGitignore(dir)).toBe(false);
283
+ });
284
+
285
+ it('leaves a legacy gitignore rule intact while adding the new one', () => {
286
+ const dir = makeTmp();
287
+ fs.writeFileSync(path.join(dir, '.gitignore'), '# Local deploy-stack runtime state (sleep/wake)\n.deploy-stack/\n');
288
+ expect(ensureSleepGitignore(dir)).toBe(true);
289
+ const content = fs.readFileSync(path.join(dir, '.gitignore'), 'utf8');
290
+ expect(content).toContain('.deploy-stack/');
291
+ expect(content).toContain('.grada/');
292
+ expect(ensureSleepGitignore(dir)).toBe(false);
293
+ });
294
+
295
+ it('computes the 7-day auto-restart timestamp in UTC', () => {
296
+ const nowMs = Date.UTC(2026, 0, 1, 12, 0, 0);
297
+ expect(computeAutoRestartAt(nowMs).getTime()).toBe(nowMs + RDS_AUTO_RESTART_MS);
298
+ expect(formatUtcTimestamp(computeAutoRestartAt(nowMs))).toBe('2026-01-08 12:00 UTC');
299
+ });
300
+ });
301
+
302
+ describe('estimateSleepSavings', () => {
303
+ it('pauses Fargate replicas plus RDS compute, never storage', () => {
304
+ const savings = estimateSleepSavings({ hasDb: true, appReplicas: 1, workerReplicas: 0 });
305
+ expect(savings.fargateMonthly).toBe('9.01');
306
+ expect(savings.dbComputeMonthly).toBe('11.68');
307
+ expect(savings.monthly).toBe('20.69');
308
+ expect(savings.hourly).toBe('0.028');
309
+ });
310
+
311
+ it('counts the worker and zeroes Aurora compute', () => {
312
+ const withWorker = estimateSleepSavings({ hasDb: true, appReplicas: 1, workerReplicas: 1 });
313
+ expect(withWorker.monthly).toBe('29.70');
314
+ const aurora = estimateSleepSavings({ hasDb: true, dbEngine: 'aurora-postgresql', appReplicas: 1 });
315
+ expect(aurora.dbComputeMonthly).toBe('0.00');
316
+ expect(aurora.monthly).toBe('9.01');
317
+ });
318
+ });
319
+
320
+ describe('runSleep', () => {
321
+ function sleepOptions(dir, overrides = {}) {
322
+ return {
323
+ cwd: dir,
324
+ projectName: 'myapp',
325
+ region: 'us-east-2',
326
+ env: 'staging',
327
+ nowMs: Date.UTC(2026, 0, 1, 12, 0, 0),
328
+ ...overrides,
329
+ };
330
+ }
331
+
332
+ it('scales services to zero, stops the instance, and persists state', async () => {
333
+ const dir = makeTmp();
334
+ const { ecsClient, rdsClient, seen } = mockClients({
335
+ ecs: {
336
+ 'myapp-staging-service': activeService('myapp-staging-service', 1),
337
+ 'myapp-staging-worker-service': activeService('myapp-staging-worker-service', 2),
338
+ },
339
+ db: { instance: { Engine: 'postgres', Status: 'available', DBName: 'db' } },
340
+ });
341
+ const result = await runSleep(sleepOptions(dir, { ecsClient, rdsClient }));
342
+ expect(result.ok).toBe(true);
343
+ expect(result.ecsScaled).toBe(2);
344
+ expect(result.dbStopped).toBe(true);
345
+ expect(seen).toContain('UpdateServiceCommand');
346
+ expect(seen).toContain('StopDBInstanceCommand');
347
+ expect(exitSpy).not.toHaveBeenCalled();
348
+
349
+ const state = readSleepState(dir);
350
+ expect(state.staging).toMatchObject({
351
+ env: 'staging',
352
+ services: { app: 1, worker: 2 },
353
+ dbKind: 'instance',
354
+ });
355
+ expect(state.staging.autoRestartAt).toBe('2026-01-08T12:00:00.000Z');
356
+ expect(fs.readFileSync(path.join(dir, '.gitignore'), 'utf8')).toContain('.grada/');
357
+
358
+ const output = logSpy.mock.calls.map((call) => String(call[0])).join('\n');
359
+ expect(output).toContain('2026-01-08 12:00 UTC');
360
+ expect(output).toContain('Estimated Savings While Asleep');
361
+ expect(output).toContain('npx grada-run wake staging');
362
+ expect(trackEvent).toHaveBeenCalledWith('sleep_run', expect.objectContaining({
363
+ env_kind: 'named', ecs_scaled: 2, db_stopped: true, db_kind: 'instance', success: true,
364
+ }));
365
+ });
366
+
367
+ it('stops Aurora clusters with StopDBClusterCommand', async () => {
368
+ const dir = makeTmp();
369
+ const { ecsClient, rdsClient, seen } = mockClients({
370
+ ecs: { 'myapp-staging-service': activeService('myapp-staging-service', 1) },
371
+ db: { cluster: { Engine: 'aurora-postgresql', Status: 'available' } },
372
+ });
373
+ const result = await runSleep(sleepOptions(dir, { ecsClient, rdsClient }));
374
+ expect(result.ok).toBe(true);
375
+ expect(result.dbKind).toBe('cluster');
376
+ expect(seen).toContain('StopDBClusterCommand');
377
+ expect(seen).not.toContain('StopDBInstanceCommand');
378
+ });
379
+
380
+ it('is idempotent when services are already asleep and the db is stopped', async () => {
381
+ const dir = makeTmp();
382
+ const { ecsClient, rdsClient, seen } = mockClients({
383
+ ecs: { 'myapp-staging-service': activeService('myapp-staging-service', 0) },
384
+ db: { instance: { Engine: 'postgres', Status: 'stopped' } },
385
+ });
386
+ const result = await runSleep(sleepOptions(dir, { ecsClient, rdsClient }));
387
+ expect(result.ok).toBe(true);
388
+ expect(result.ecsScaled).toBe(0);
389
+ expect(result.dbStopped).toBe(false);
390
+ expect(seen).not.toContain('UpdateServiceCommand');
391
+ expect(seen).not.toContain('StopDBInstanceCommand');
392
+ // No ledger history: defaults restore to 1/0 (no worker service live).
393
+ expect(readSleepState(dir).staging.services).toEqual({ app: 1, worker: 0 });
394
+ });
395
+
396
+ it('preserves the original replica counts on a repeat sleep', async () => {
397
+ const dir = makeTmp();
398
+ const ecs = { 'myapp-staging-service': activeService('myapp-staging-service', 3) };
399
+ const first = mockClients({ ecs, db: { instance: { Engine: 'postgres', Status: 'available' } } });
400
+ await runSleep(sleepOptions(dir, { ecsClient: first.ecsClient, rdsClient: first.rdsClient }));
401
+ expect(readSleepState(dir).staging.services.app).toBe(3);
402
+ const second = mockClients({ ecs, db: { instance: { Engine: 'postgres', Status: 'stopping' } } });
403
+ await runSleep(sleepOptions(dir, { ecsClient: second.ecsClient, rdsClient: second.rdsClient }));
404
+ expect(readSleepState(dir).staging.services.app).toBe(3);
405
+ });
406
+
407
+ it('honors --skip-db and skips transitional databases', async () => {
408
+ const dir = makeTmp();
409
+ const skipped = mockClients({
410
+ ecs: { 'myapp-staging-service': activeService('myapp-staging-service', 1) },
411
+ db: { instance: { Engine: 'postgres', Status: 'available' } },
412
+ });
413
+ await runSleep(sleepOptions(dir, { ecsClient: skipped.ecsClient, rdsClient: skipped.rdsClient, skipDb: true }));
414
+ expect(skipped.seen).not.toContain('DescribeDBInstancesCommand');
415
+
416
+ const transitional = mockClients({
417
+ ecs: { 'myapp-staging-service': activeService('myapp-staging-service', 1) },
418
+ db: { instance: { Engine: 'postgres', Status: 'starting' } },
419
+ });
420
+ const result = await runSleep(sleepOptions(dir, { ecsClient: transitional.ecsClient, rdsClient: transitional.rdsClient }));
421
+ expect(result.ok).toBe(true);
422
+ expect(result.dbStopped).toBe(false);
423
+ expect(transitional.seen).not.toContain('StopDBInstanceCommand');
424
+ });
425
+
426
+ it('fails when nothing exists to sleep', async () => {
427
+ const dir = makeTmp();
428
+ const { ecsClient, rdsClient } = mockClients({});
429
+ const result = await runSleep(sleepOptions(dir, { ecsClient, rdsClient }));
430
+ expect(result.ok).toBe(false);
431
+ expect(result.reason).toBe('nothing-to-sleep');
432
+ expect(exitSpy).toHaveBeenCalledWith(1);
433
+ });
434
+
435
+ it('guards the default environment: headless requires --yes', async () => {
436
+ const dir = makeTmp();
437
+ const { ecsClient, rdsClient } = mockClients({
438
+ ecs: { 'myapp-service': activeService('myapp-service', 1) },
439
+ });
440
+ const denied = await runSleep({ cwd: dir, projectName: 'myapp', region: 'us-east-2', isHeadless: true, ecsClient, rdsClient });
441
+ expect(denied.ok).toBe(false);
442
+ expect(denied.reason).toBe('confirmation-required');
443
+ expect(exitSpy).toHaveBeenCalledWith(1);
444
+ expect(ecsClient.send).not.toHaveBeenCalled();
445
+
446
+ vi.mocked(confirm).mockResolvedValueOnce(false);
447
+ const declined = await runSleep({ cwd: dir, projectName: 'myapp', region: 'us-east-2', isHeadless: false, ecsClient, rdsClient });
448
+ expect(declined).toMatchObject({ ok: false, reason: 'cancelled' });
449
+
450
+ vi.mocked(confirm).mockResolvedValueOnce(true);
451
+ const accepted = await runSleep({ cwd: dir, projectName: 'myapp', region: 'us-east-2', isHeadless: false, ecsClient, rdsClient });
452
+ expect(accepted.ok).toBe(true);
453
+ expect(vi.mocked(confirm)).toHaveBeenCalledWith(expect.objectContaining({ initialValue: false }));
454
+ });
455
+ });
456
+
457
+ describe('runWake', () => {
458
+ function wakeOptions(dir, overrides = {}) {
459
+ return {
460
+ cwd: dir,
461
+ projectName: 'myapp',
462
+ region: 'us-east-2',
463
+ env: 'staging',
464
+ pollIntervalMs: 1,
465
+ timeoutMs: 1000,
466
+ ...overrides,
467
+ };
468
+ }
469
+
470
+ it('starts the database first, then restores ledger counts', async () => {
471
+ const dir = makeTmp();
472
+ writeSleepState(dir, {
473
+ staging: {
474
+ env: 'staging',
475
+ sleptAt: new Date().toISOString(),
476
+ autoRestartAt: new Date(Date.now() + 3600_000).toISOString(),
477
+ services: { app: 2, worker: 1 },
478
+ dbId: 'myapp-staging-db',
479
+ dbKind: 'instance',
480
+ },
481
+ });
482
+ const db = { instance: { Engine: 'postgres', Status: 'stopped' } };
483
+ const order = [];
484
+ const { ecsClient, rdsClient } = mockClients({
485
+ ecs: {
486
+ 'myapp-staging-service': { ...activeService('myapp-staging-service', 0), runningCount: 2 },
487
+ 'myapp-staging-worker-service': { ...activeService('myapp-staging-worker-service', 0), runningCount: 1 },
488
+ },
489
+ db,
490
+ onCommand: async (command) => {
491
+ if (command.constructor.name === 'StartDBInstanceCommand') order.push('start-db');
492
+ if (command.constructor.name === 'UpdateServiceCommand') order.push('restore-ecs');
493
+ },
494
+ });
495
+ // RDS reports available as soon as the start lands.
496
+ rdsClient.send.mockImplementation(async (command) => {
497
+ const name = command.constructor.name;
498
+ if (name === 'DescribeDBInstancesCommand') {
499
+ if (db.instance.Status === 'starting') db.instance.Status = 'available';
500
+ return { DBInstances: [db.instance] };
501
+ }
502
+ if (name === 'StartDBInstanceCommand') {
503
+ order.push('start-db');
504
+ db.instance.Status = 'starting';
505
+ return {};
506
+ }
507
+ if (name === 'DescribeDBClustersCommand') throw Object.assign(new Error('x'), { name: 'DBClusterNotFoundFault' });
508
+ throw new Error(`unexpected RDS command: ${name}`);
509
+ });
510
+ const updates = [];
511
+ ecsClient.send.mockImplementation(async (command) => {
512
+ const name = command.constructor.name;
513
+ if (name === 'DescribeServicesCommand') {
514
+ return { services: (command.input.services || []).map((serviceName) => ({
515
+ serviceName, status: 'ACTIVE', desiredCount: 0, runningCount: serviceName.endsWith('worker-service') ? 1 : 2,
516
+ })) };
517
+ }
518
+ if (name === 'UpdateServiceCommand') {
519
+ order.push('restore-ecs');
520
+ updates.push({ service: command.input.service, desiredCount: command.input.desiredCount });
521
+ return {};
522
+ }
523
+ throw new Error(`unexpected ECS command: ${name}`);
524
+ });
525
+
526
+ const result = await runWake(wakeOptions(dir, { ecsClient, rdsClient }));
527
+ expect(result.ok).toBe(true);
528
+ expect(result.dbStarted).toBe(true);
529
+ expect(result.ecsRestored).toBe(2);
530
+ expect(result.waited).toBe(true);
531
+ expect(order[0]).toBe('start-db');
532
+ expect(updates).toContainEqual({ service: 'myapp-staging-service', desiredCount: 2 });
533
+ expect(updates).toContainEqual({ service: 'myapp-staging-worker-service', desiredCount: 1 });
534
+ expect(readSleepState(dir)).toEqual({});
535
+ expect(trackEvent).toHaveBeenCalledWith('wake_run', expect.objectContaining({
536
+ env_kind: 'named', ecs_restored: 2, db_started: true, waited: true, success: true,
537
+ }));
538
+ });
539
+
540
+ it('defaults to 1/1 without ledger state and skips waiting with --no-wait', async () => {
541
+ const dir = makeTmp();
542
+ const updates = [];
543
+ const ecsClient = {
544
+ send: vi.fn(async (command) => {
545
+ if (command.constructor.name === 'DescribeServicesCommand') {
546
+ return { services: (command.input.services || []).map((serviceName) => ({ serviceName, status: 'ACTIVE', desiredCount: 0, runningCount: 0 })) };
547
+ }
548
+ updates.push({ service: command.input.service, desiredCount: command.input.desiredCount });
549
+ return {};
550
+ }),
551
+ };
552
+ const rdsClient = {
553
+ send: vi.fn(async (command) => {
554
+ if (command.constructor.name === 'DescribeDBInstancesCommand') {
555
+ return { DBInstances: [{ Engine: 'postgres', Status: 'available' }] };
556
+ }
557
+ throw new Error(`unexpected RDS command: ${command.constructor.name}`);
558
+ }),
559
+ };
560
+ const result = await runWake(wakeOptions(dir, { ecsClient, rdsClient, noWait: true }));
561
+ expect(result.ok).toBe(true);
562
+ expect(result.waited).toBe(false);
563
+ expect(result.dbStarted).toBe(false);
564
+ expect(updates).toContainEqual({ service: 'myapp-staging-service', desiredCount: 1 });
565
+ expect(updates).toContainEqual({ service: 'myapp-staging-worker-service', desiredCount: 1 });
566
+ // No polling: two discovery Describes (app + worker), then UpdateService only.
567
+ expect(ecsClient.send.mock.calls.filter(([command]) => command.constructor.name === 'DescribeServicesCommand')).toHaveLength(2);
568
+ });
569
+
570
+ it('fails on RDS wake timeout and keeps the ledger for retry', async () => {
571
+ const dir = makeTmp();
572
+ writeSleepState(dir, { staging: { env: 'staging', services: { app: 1, worker: 0 } } });
573
+ const ecsClient = { send: vi.fn(async () => ({ services: [] })) };
574
+ const rdsClient = {
575
+ send: vi.fn(async (command) => {
576
+ if (command.constructor.name === 'DescribeDBInstancesCommand') {
577
+ return { DBInstances: [{ Engine: 'postgres', Status: 'starting' }] };
578
+ }
579
+ if (command.constructor.name === 'StartDBInstanceCommand') return {};
580
+ throw new Error(`unexpected: ${command.constructor.name}`);
581
+ }),
582
+ };
583
+ // First call: stopped → start; subsequent polls: starting forever.
584
+ let calls = 0;
585
+ const inner = rdsClient.send.getMockImplementation();
586
+ rdsClient.send.mockImplementation(async (command) => {
587
+ if (command.constructor.name === 'DescribeDBInstancesCommand' && calls++ === 0) {
588
+ return { DBInstances: [{ Engine: 'postgres', Status: 'stopped' }] };
589
+ }
590
+ return inner(command);
591
+ });
592
+ const result = await runWake(wakeOptions(dir, {
593
+ ecsClient,
594
+ rdsClient,
595
+ timeoutMs: 0,
596
+ sleepFn: async () => {},
597
+ nowFn: (() => { let now = 1000; return () => now++; })(),
598
+ }));
599
+ expect(result.ok).toBe(false);
600
+ expect(result.reason).toBe('rds-wake-timeout');
601
+ expect(readSleepState(dir).staging).toBeTruthy();
602
+ });
603
+
604
+ it('warns when the 7-day window elapsed', async () => {
605
+ const dir = makeTmp();
606
+ writeSleepState(dir, {
607
+ staging: {
608
+ env: 'staging',
609
+ autoRestartAt: new Date(Date.now() - 1000).toISOString(),
610
+ services: { app: 1, worker: 0 },
611
+ },
612
+ });
613
+ const ecsClient = {
614
+ send: vi.fn(async (command) => {
615
+ if (command.constructor.name === 'DescribeServicesCommand') {
616
+ return { services: [{ serviceName: command.input.services[0], status: 'ACTIVE', desiredCount: 0, runningCount: 1 }] };
617
+ }
618
+ return {};
619
+ }),
620
+ };
621
+ const rdsClient = {
622
+ send: vi.fn(async (command) => {
623
+ if (command.constructor.name === 'DescribeDBInstancesCommand') {
624
+ return { DBInstances: [{ Engine: 'postgres', Status: 'available' }] };
625
+ }
626
+ throw Object.assign(new Error('x'), { name: 'DBClusterNotFoundFault' });
627
+ }),
628
+ };
629
+ await runWake(wakeOptions(dir, { ecsClient, rdsClient, noWait: true }));
630
+ const output = logSpy.mock.calls.map((call) => String(call[0])).join('\n');
631
+ expect(output).toContain('7-day sleep window has elapsed');
632
+ });
633
+ });
634
+
635
+ describe('sleep-targets: cron schedule discovery', () => {
636
+ it('resolves the schedule name from cron.tf with the app prefix', () => {
637
+ const dir = makeTmp();
638
+ fs.mkdirSync(path.join(dir, 'terraform'), { recursive: true });
639
+ fs.writeFileSync(
640
+ path.join(dir, 'terraform', 'cron.tf'),
641
+ 'resource "aws_scheduler_schedule" "cron" {\n name = "${local.app_name}-cron-nightly-cleanup"\n}\n'
642
+ );
643
+ expect(readCronScheduleName(dir, 'myapp-staging')).toBe('myapp-staging-cron-nightly-cleanup');
644
+ });
645
+
646
+ it('returns null when cron.tf is missing or hand-edited', () => {
647
+ expect(readCronScheduleName(makeTmp(), 'myapp')).toBeNull();
648
+ const dir = makeTmp();
649
+ fs.mkdirSync(path.join(dir, 'terraform'), { recursive: true });
650
+ fs.writeFileSync(path.join(dir, 'terraform', 'cron.tf'), '# nothing scheduled here\n');
651
+ expect(readCronScheduleName(dir, 'myapp')).toBeNull();
652
+ });
653
+ });
654
+
655
+ describe('sleep-targets: scheduler state', () => {
656
+ const scheduleDoc = {
657
+ Name: 'myapp-staging-cron-nightly',
658
+ Arn: 'arn:aws:scheduler:us-east-2:123456789012:schedule/default/myapp-staging-cron-nightly',
659
+ CreationDate: '2026-01-01T00:00:00Z',
660
+ LastModificationDate: '2026-01-02T00:00:00Z',
661
+ State: 'ENABLED',
662
+ ScheduleExpression: 'rate(1 hour)',
663
+ ScheduleExpressionTimezone: 'UTC',
664
+ FlexibleTimeWindow: { Mode: 'OFF' },
665
+ Target: { Arn: 'arn:aws:ecs:cluster', RoleArn: 'arn:aws:iam:role' },
666
+ };
667
+
668
+ it('passes the fetched document back with only State flipped', () => {
669
+ const seen = [];
670
+ const run = vi.fn((cmd, args) => {
671
+ seen.push(args);
672
+ if (args[1] === 'get-schedule') return { status: 0, stdout: JSON.stringify(scheduleDoc), stderr: '' };
673
+ return { status: 0, stdout: '{}', stderr: '' };
674
+ });
675
+ expect(setSchedulerState({ name: scheduleDoc.Name, enabled: false, region: 'us-east-2', run })).toEqual({ ok: true });
676
+ const updateArgs = seen.find((args) => args[1] === 'update-schedule');
677
+ const payload = JSON.parse(updateArgs[updateArgs.indexOf('--cli-input-json') + 1]);
678
+ expect(payload).toMatchObject({
679
+ Name: scheduleDoc.Name,
680
+ State: 'DISABLED',
681
+ ScheduleExpression: 'rate(1 hour)',
682
+ ScheduleExpressionTimezone: 'UTC',
683
+ FlexibleTimeWindow: { Mode: 'OFF' },
684
+ Target: scheduleDoc.Target,
685
+ });
686
+ expect(payload).not.toHaveProperty('Arn');
687
+ expect(payload).not.toHaveProperty('CreationDate');
688
+ expect(payload).not.toHaveProperty('LastModificationDate');
689
+ });
690
+
691
+ it('skips the update when already in the desired state', () => {
692
+ const run = vi.fn(() => ({
693
+ status: 0, stdout: JSON.stringify({ ...scheduleDoc, State: 'DISABLED' }), stderr: '',
694
+ }));
695
+ expect(setSchedulerState({ name: scheduleDoc.Name, enabled: false, region: 'us-east-2', run }))
696
+ .toEqual({ ok: true, skipped: 'already' });
697
+ expect(run).toHaveBeenCalledTimes(1);
698
+ });
699
+
700
+ it('reports undeployed schedules and a missing CLI', () => {
701
+ const notFound = () => ({ status: 254, stdout: '', stderr: 'An error occurred (ResourceNotFoundException) when calling GetSchedule' });
702
+ expect(setSchedulerState({ name: 'x', enabled: false, region: 'us-east-2', run: notFound }).reason).toBe('not-deployed');
703
+ const noCli = () => ({ error: { code: 'ENOENT' } });
704
+ expect(setSchedulerState({ name: 'x', enabled: false, region: 'us-east-2', run: noCli }).reason).toBe('cli-missing');
705
+ });
706
+ });
707
+
708
+ describe('sleep-targets: worker scaling suspend/resume', () => {
709
+ it('suspends all three scaling dimensions when registered', () => {
710
+ const seen = [];
711
+ const run = vi.fn((cmd, args) => {
712
+ seen.push(args);
713
+ if (args[1] === 'describe-scalable-targets') {
714
+ return { status: 0, stdout: JSON.stringify({ ScalableTargets: [{ SuspendedState: {} }] }), stderr: '' };
715
+ }
716
+ return { status: 0, stdout: '{}', stderr: '' };
717
+ });
718
+ const result = setWorkerScalingSuspended({
719
+ cluster: 'myapp-staging-cluster', service: 'myapp-staging-worker-service', suspended: true, region: 'us-east-2', run,
720
+ });
721
+ expect(result).toEqual({ ok: true });
722
+ const register = seen.find((args) => args[1] === 'register-scalable-target');
723
+ expect(register).toContain('service/myapp-staging-cluster/myapp-staging-worker-service');
724
+ const flag = register[register.indexOf('--suspended-state') + 1];
725
+ expect(flag).toContain('DynamicScalingInSuspended=true');
726
+ expect(flag).toContain('DynamicScalingOutSuspended=true');
727
+ expect(flag).toContain('ScheduledScalingSuspended=true');
728
+ });
729
+
730
+ it('skips unregistered targets and already-matching state', () => {
731
+ const empty = vi.fn(() => ({ status: 0, stdout: JSON.stringify({ ScalableTargets: [] }), stderr: '' }));
732
+ expect(setWorkerScalingSuspended({ cluster: 'c', service: 's', suspended: true, region: 'us-east-2', run: empty }).reason)
733
+ .toBe('not-registered');
734
+ expect(empty).toHaveBeenCalledTimes(1);
735
+ const already = vi.fn(() => ({
736
+ status: 0,
737
+ stdout: JSON.stringify({ ScalableTargets: [{ SuspendedState: { DynamicScalingInSuspended: true, DynamicScalingOutSuspended: true, ScheduledScalingSuspended: true } }] }),
738
+ stderr: '',
739
+ }));
740
+ expect(setWorkerScalingSuspended({ cluster: 'c', service: 's', suspended: true, region: 'us-east-2', run: already }))
741
+ .toEqual({ ok: true, skipped: 'already' });
742
+ expect(already).toHaveBeenCalledTimes(1);
743
+ });
744
+ });
745
+
746
+ describe('runSleep: cron pause, scaling suspend, and cache note', () => {
747
+ function baseOptions(dir, overrides = {}) {
748
+ return {
749
+ cwd: dir,
750
+ projectName: 'myapp',
751
+ region: 'us-east-2',
752
+ env: 'staging',
753
+ nowMs: Date.UTC(2026, 0, 1, 12, 0, 0),
754
+ ...overrides,
755
+ };
756
+ }
757
+
758
+ function writeCronTf(dir, slug = 'nightly') {
759
+ fs.mkdirSync(path.join(dir, 'terraform'), { recursive: true });
760
+ fs.writeFileSync(
761
+ path.join(dir, 'terraform', 'cron.tf'),
762
+ `resource "aws_scheduler_schedule" "cron" {\n name = "\${local.app_name}-cron-${slug}"\n}\n`
763
+ );
764
+ }
765
+
766
+ it('pauses the cron schedule and suspends worker scaling', async () => {
767
+ const dir = makeTmp();
768
+ writeCronTf(dir);
769
+ fs.writeFileSync(path.join(dir, 'terraform', 'sqs.tf'), '# queue\n');
770
+ fs.writeFileSync(path.join(dir, 'terraform', 'worker.tf'), '# worker\n');
771
+ const { ecsClient, rdsClient } = mockClients({
772
+ ecs: {
773
+ 'myapp-staging-service': activeService('myapp-staging-service', 1),
774
+ 'myapp-staging-worker-service': activeService('myapp-staging-worker-service', 1),
775
+ },
776
+ });
777
+ const seen = [];
778
+ const spawnSyncImpl = vi.fn((cmd, args) => {
779
+ seen.push(args);
780
+ if (args[1] === 'get-schedule') {
781
+ return {
782
+ status: 0,
783
+ stdout: JSON.stringify({
784
+ State: 'ENABLED',
785
+ ScheduleExpression: 'rate(1 hour)',
786
+ ScheduleExpressionTimezone: 'UTC',
787
+ FlexibleTimeWindow: { Mode: 'OFF' },
788
+ Target: { Arn: 'a', RoleArn: 'r' },
789
+ }),
790
+ stderr: '',
791
+ };
792
+ }
793
+ if (args[1] === 'describe-scalable-targets') {
794
+ return { status: 0, stdout: JSON.stringify({ ScalableTargets: [{ SuspendedState: {} }] }), stderr: '' };
795
+ }
796
+ return { status: 0, stdout: '{}', stderr: '' };
797
+ });
798
+ const result = await runSleep(baseOptions(dir, { ecsClient, rdsClient, skipDb: true, spawnSyncImpl }));
799
+ expect(result.ok).toBe(true);
800
+ expect(result.cronPaused).toBe(true);
801
+ expect(result.scalingSuspended).toBe(true);
802
+ const getArgs = seen.find((args) => args[1] === 'get-schedule');
803
+ expect(getArgs).toContain('myapp-staging-cron-nightly');
804
+ expect(getArgs).toContain('us-east-2');
805
+ const updateArgs = seen.find((args) => args[1] === 'update-schedule');
806
+ expect(JSON.parse(updateArgs[updateArgs.indexOf('--cli-input-json') + 1])).toMatchObject({ State: 'DISABLED' });
807
+ const registerArgs = seen.find((args) => args[1] === 'register-scalable-target');
808
+ expect(registerArgs[registerArgs.indexOf('--suspended-state') + 1]).toContain('DynamicScalingOutSuspended=true');
809
+ const output = stripVTControlCharacters(logSpy.mock.calls.map((call) => String(call[0])).join('\n'));
810
+ expect(output).toContain('myapp-staging-cron-nightly paused');
811
+ expect(output).toContain('worker auto-scaling suspended');
812
+ expect(trackEvent).toHaveBeenCalledWith('sleep_run', expect.objectContaining({
813
+ cron_paused: true, scaling_suspended: true, success: true,
814
+ }));
815
+ });
816
+
817
+ it('skips gracefully when the schedule is undeployed and scaling is unregistered', async () => {
818
+ const dir = makeTmp();
819
+ writeCronTf(dir);
820
+ fs.writeFileSync(path.join(dir, 'terraform', 'sqs.tf'), '# queue\n');
821
+ fs.writeFileSync(path.join(dir, 'terraform', 'worker.tf'), '# worker\n');
822
+ const { ecsClient, rdsClient } = mockClients({
823
+ ecs: { 'myapp-staging-service': activeService('myapp-staging-service', 1) },
824
+ });
825
+ const spawnSyncImpl = vi.fn((cmd, args) => {
826
+ if (args[1] === 'get-schedule') return { status: 254, stdout: '', stderr: 'ResourceNotFoundException' };
827
+ if (args[1] === 'describe-scalable-targets') return { status: 0, stdout: JSON.stringify({ ScalableTargets: [] }), stderr: '' };
828
+ return { status: 0, stdout: '{}', stderr: '' };
829
+ });
830
+ const result = await runSleep(baseOptions(dir, { ecsClient, rdsClient, skipDb: true, spawnSyncImpl }));
831
+ expect(result.ok).toBe(true);
832
+ expect(result.cronPaused).toBe(false);
833
+ expect(result.scalingSuspended).toBe(false);
834
+ const output = logSpy.mock.calls.map((call) => String(call[0])).join('\n');
835
+ expect(output).toContain('not deployed yet, skipped');
836
+ expect(output).toContain('not registered, skipped');
837
+ });
838
+
839
+ it('notes unpausable Valkey billing when redis.tf exists', async () => {
840
+ const dir = makeTmp();
841
+ fs.mkdirSync(path.join(dir, 'terraform'), { recursive: true });
842
+ fs.writeFileSync(path.join(dir, 'terraform', 'redis.tf'), '# valkey\n');
843
+ const { ecsClient, rdsClient } = mockClients({
844
+ ecs: { 'myapp-staging-service': activeService('myapp-staging-service', 1) },
845
+ });
846
+ const result = await runSleep(baseOptions(dir, { ecsClient, rdsClient, skipDb: true }));
847
+ expect(result.ok).toBe(true);
848
+ const output = logSpy.mock.calls.map((call) => String(call[0])).join('\n');
849
+ expect(output).toContain('Valkey keeps billing (~$9.49/mo)');
850
+ });
851
+
852
+ it('stays silent about cron, scaling, and cache when unconfigured', async () => {
853
+ const dir = makeTmp();
854
+ const { ecsClient, rdsClient } = mockClients({
855
+ ecs: { 'myapp-staging-service': activeService('myapp-staging-service', 1) },
856
+ });
857
+ const spawnSyncImpl = vi.fn(() => ({ status: 0, stdout: '{}', stderr: '' }));
858
+ const result = await runSleep(baseOptions(dir, { ecsClient, rdsClient, skipDb: true, spawnSyncImpl }));
859
+ expect(result.ok).toBe(true);
860
+ expect(spawnSyncImpl).not.toHaveBeenCalled();
861
+ const output = logSpy.mock.calls.map((call) => String(call[0])).join('\n');
862
+ expect(output).not.toContain('cron:');
863
+ expect(output).not.toContain('scaling:');
864
+ expect(output).not.toContain('cache:');
865
+ });
866
+ });
867
+
868
+ describe('runWake: cron resume and scaling resume', () => {
869
+ it('resumes the cron schedule and worker scaling', async () => {
870
+ const dir = makeTmp();
871
+ fs.mkdirSync(path.join(dir, 'terraform'), { recursive: true });
872
+ fs.writeFileSync(
873
+ path.join(dir, 'terraform', 'cron.tf'),
874
+ 'resource "aws_scheduler_schedule" "cron" {\n name = "${local.app_name}-cron-nightly"\n}\n'
875
+ );
876
+ fs.writeFileSync(path.join(dir, 'terraform', 'sqs.tf'), '# queue\n');
877
+ fs.writeFileSync(path.join(dir, 'terraform', 'worker.tf'), '# worker\n');
878
+ writeSleepState(dir, {
879
+ staging: {
880
+ env: 'staging',
881
+ sleptAt: new Date().toISOString(),
882
+ autoRestartAt: new Date(Date.now() + 3600_000).toISOString(),
883
+ services: { app: 1, worker: 1 },
884
+ },
885
+ });
886
+ const { ecsClient, rdsClient } = mockClients({
887
+ ecs: {
888
+ 'myapp-staging-service': activeService('myapp-staging-service', 0),
889
+ 'myapp-staging-worker-service': activeService('myapp-staging-worker-service', 0),
890
+ },
891
+ });
892
+ const seen = [];
893
+ const spawnSyncImpl = vi.fn((cmd, args) => {
894
+ seen.push(args);
895
+ if (args[1] === 'get-schedule') {
896
+ return {
897
+ status: 0,
898
+ stdout: JSON.stringify({
899
+ State: 'DISABLED',
900
+ ScheduleExpression: 'rate(1 hour)',
901
+ ScheduleExpressionTimezone: 'UTC',
902
+ FlexibleTimeWindow: { Mode: 'OFF' },
903
+ Target: { Arn: 'a', RoleArn: 'r' },
904
+ }),
905
+ stderr: '',
906
+ };
907
+ }
908
+ if (args[1] === 'describe-scalable-targets') {
909
+ return {
910
+ status: 0,
911
+ stdout: JSON.stringify({ ScalableTargets: [{ SuspendedState: { DynamicScalingInSuspended: true, DynamicScalingOutSuspended: true, ScheduledScalingSuspended: true } }] }),
912
+ stderr: '',
913
+ };
914
+ }
915
+ return { status: 0, stdout: '{}', stderr: '' };
916
+ });
917
+ const result = await runWake({
918
+ cwd: dir, projectName: 'myapp', region: 'us-east-2', env: 'staging',
919
+ noWait: true, pollIntervalMs: 1, timeoutMs: 1000,
920
+ ecsClient, rdsClient, skipDb: true, spawnSyncImpl,
921
+ });
922
+ expect(result.ok).toBe(true);
923
+ expect(result.cronResumed).toBe(true);
924
+ expect(result.scalingResumed).toBe(true);
925
+ const updateArgs = seen.find((args) => args[1] === 'update-schedule');
926
+ expect(JSON.parse(updateArgs[updateArgs.indexOf('--cli-input-json') + 1])).toMatchObject({ State: 'ENABLED' });
927
+ const registerArgs = seen.find((args) => args[1] === 'register-scalable-target');
928
+ expect(registerArgs[registerArgs.indexOf('--suspended-state') + 1]).toContain('DynamicScalingOutSuspended=false');
929
+ const output = stripVTControlCharacters(logSpy.mock.calls.map((call) => String(call[0])).join('\n'));
930
+ expect(output).toContain('myapp-staging-cron-nightly resumed');
931
+ expect(output).toContain('worker auto-scaling resumed');
932
+ expect(trackEvent).toHaveBeenCalledWith('wake_run', expect.objectContaining({
933
+ cron_resumed: true, scaling_resumed: true, success: true,
934
+ }));
935
+ });
936
+ });
937
+
938
+ describe('runSleep on --target lambda projects', () => {
939
+ function lambdaDir() {
940
+ const dir = makeTmp();
941
+ fs.mkdirSync(path.join(dir, 'terraform'), { recursive: true });
942
+ fs.writeFileSync(
943
+ path.join(dir, 'terraform', 'main.tf'),
944
+ 'locals {\n app_name = "myapp${local.env_suffix}"\n}\nresource "aws_lambda_function" "app" {}\n'
945
+ );
946
+ return dir;
947
+ }
948
+
949
+ it('stops the database without touching ECS and ledgers zero replicas', async () => {
950
+ const dir = lambdaDir();
951
+ const { ecsClient, rdsClient, seen } = mockClients({
952
+ ecs: { 'myapp-staging-service': activeService('myapp-staging-service', 1) },
953
+ db: { instance: { Engine: 'postgres', Status: 'available', DBName: 'db' } },
954
+ });
955
+ const result = await runSleep({
956
+ cwd: dir, projectName: 'myapp', region: 'us-east-2', env: 'staging',
957
+ nowMs: Date.UTC(2026, 0, 1, 12, 0, 0), ecsClient, rdsClient,
958
+ });
959
+ expect(result.ok).toBe(true);
960
+ expect(result.ecsScaled).toBe(0);
961
+ expect(result.dbStopped).toBe(true);
962
+ expect(seen).not.toContain('DescribeServicesCommand');
963
+ expect(seen).not.toContain('UpdateServiceCommand');
964
+ expect(seen).toContain('StopDBInstanceCommand');
965
+ expect(readSleepState(dir).staging).toMatchObject({ services: { app: 0, worker: 0 } });
966
+ });
967
+
968
+ it('reports nothing to sleep when no database exists', async () => {
969
+ const dir = lambdaDir();
970
+ const { ecsClient, rdsClient } = mockClients({});
971
+ const result = await runSleep({
972
+ cwd: dir, projectName: 'myapp', region: 'us-east-2', env: 'staging', ecsClient, rdsClient,
973
+ });
974
+ expect(result).toMatchObject({ ok: false, reason: 'nothing-to-sleep' });
975
+ });
976
+ });
977
+
978
+ describe('runWake on --target lambda projects', () => {
979
+ function lambdaDir() {
980
+ const dir = makeTmp();
981
+ fs.mkdirSync(path.join(dir, 'terraform'), { recursive: true });
982
+ fs.writeFileSync(
983
+ path.join(dir, 'terraform', 'main.tf'),
984
+ 'locals {\n app_name = "myapp${local.env_suffix}"\n}\nresource "aws_lambda_function" "app" {}\n'
985
+ );
986
+ return dir;
987
+ }
988
+
989
+ it('starts the database without restoring ECS services', async () => {
990
+ const dir = lambdaDir();
991
+ writeSleepState(dir, {
992
+ staging: {
993
+ env: 'staging',
994
+ sleptAt: new Date(Date.UTC(2026, 0, 1)).toISOString(),
995
+ autoRestartAt: new Date(Date.UTC(2026, 0, 8)).toISOString(),
996
+ services: { app: 0, worker: 0 },
997
+ dbId: 'myapp-staging-db',
998
+ dbKind: 'instance',
999
+ },
1000
+ });
1001
+ const { ecsClient, rdsClient, seen } = mockClients({
1002
+ ecs: { 'myapp-staging-service': activeService('myapp-staging-service', 0) },
1003
+ db: { instance: { Engine: 'postgres', Status: 'stopped', DBName: 'db' } },
1004
+ });
1005
+ const result = await runWake({
1006
+ cwd: dir, projectName: 'myapp', region: 'us-east-2', env: 'staging',
1007
+ noWait: true, ecsClient, rdsClient,
1008
+ });
1009
+ expect(result.ok).toBe(true);
1010
+ expect(result.dbStarted).toBe(true);
1011
+ expect(result.ecsRestored).toBe(0);
1012
+ expect(seen).not.toContain('UpdateServiceCommand');
1013
+ expect(seen).toContain('StartDBInstanceCommand');
1014
+ expect(readSleepState(dir).staging).toBeUndefined();
1015
+ });
1016
+ });