@occam-scaly/scaly-cli 0.1.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/README.md +39 -0
- package/bin/scaly-public.js +64 -0
- package/bin/scaly.js +3083 -0
- package/lib/scaly-api.js +331 -0
- package/lib/scaly-apply.js +85 -0
- package/lib/scaly-auth.js +411 -0
- package/lib/scaly-deploy.js +137 -0
- package/lib/scaly-logs.js +110 -0
- package/lib/scaly-plan.js +392 -0
- package/lib/scaly-project.js +303 -0
- package/lib/scaly-secrets.js +91 -0
- package/lib/stable-json.js +45 -0
- package/package.json +27 -0
package/bin/scaly.js
ADDED
|
@@ -0,0 +1,3083 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { spawnSync } = require('child_process');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
function run(cmd, args, opts = {}) {
|
|
8
|
+
const res = spawnSync(cmd, args, { stdio: 'inherit', shell: false, ...opts });
|
|
9
|
+
if (res.error) throw res.error;
|
|
10
|
+
return res.status || 0;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function inVault() {
|
|
14
|
+
return (
|
|
15
|
+
Boolean(process.env.AWS_VAULT) || Boolean(process.env.AWS_ACCESS_KEY_ID)
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function withVault(args) {
|
|
20
|
+
// Default to scaly-dev unless AWS_VAULT already active
|
|
21
|
+
if (inVault()) return args;
|
|
22
|
+
const profile = process.env.SCALY_PROFILE || 'scaly-dev';
|
|
23
|
+
return ['aws-vault', 'exec', profile, '--', ...args];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function shellQuote(arg) {
|
|
27
|
+
if (/^[A-Za-z0-9_/:.=+-]+$/.test(arg)) {
|
|
28
|
+
return arg;
|
|
29
|
+
}
|
|
30
|
+
return `'${arg.replace(/'/g, "'\\''")}'`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function normalizeCommand(cmd) {
|
|
34
|
+
if (Array.isArray(cmd)) {
|
|
35
|
+
return cmd.map(shellQuote).join(' ');
|
|
36
|
+
}
|
|
37
|
+
return cmd;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isProbablyJwt(token) {
|
|
41
|
+
// Fast heuristic: three dot-separated base64url-ish segments.
|
|
42
|
+
// This is intentionally loose; we just want to distinguish JWTs from API keys.
|
|
43
|
+
return typeof token === 'string' && token.split('.').length === 3;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function withDeployerEnv(inner) {
|
|
47
|
+
// Load deployer env if present so helpers can reach API without extra flags
|
|
48
|
+
const command = normalizeCommand(inner);
|
|
49
|
+
const pre = [
|
|
50
|
+
'bash',
|
|
51
|
+
'-lc',
|
|
52
|
+
`set -a && [ -f .env.dev.scaly-deployer.local ] && source .env.dev.scaly-deployer.local || true && set +a && ${command}`
|
|
53
|
+
];
|
|
54
|
+
return inVault() ? pre : withVault(pre);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function printHelp() {
|
|
58
|
+
console.log(`
|
|
59
|
+
Scaly CLI (preview)
|
|
60
|
+
|
|
61
|
+
Usage:
|
|
62
|
+
scaly init [--force]
|
|
63
|
+
scaly plan [--env dev|prod] [--app <name>] [--json]
|
|
64
|
+
scaly apply [--env dev|prod] [--app <name>] --plan-hash <sha256:...> [--auto-approve] [--json]
|
|
65
|
+
scaly pull [--force] [--stack <id>] [--app <id>] [--json]
|
|
66
|
+
|
|
67
|
+
scaly auth login [--stage prod|dev|qa] [--no-open]
|
|
68
|
+
scaly auth status [--json]
|
|
69
|
+
scaly auth logout [--json]
|
|
70
|
+
|
|
71
|
+
scaly secrets list --app <appId> [--json]
|
|
72
|
+
scaly secrets set --app <appId> --name <KEY> [--from-env ENV] [--stdin] [--json]
|
|
73
|
+
scaly secrets delete --app <appId> (--name <KEY> | --id <secretId>) [--yes] [--json]
|
|
74
|
+
scaly secrets sync --app <appId> [--config-app <name>] [--env dev|prod] [--dry-run] [--apply] [--json]
|
|
75
|
+
|
|
76
|
+
scaly login --token <api-bearer> [--endpoint https://api.<stage>.scalyapps.io/graphql] | --clear
|
|
77
|
+
scaly db connect --addon <addOnId> [--ttl-minutes 60] [--local-port 5432] [--host 127.0.0.1] [--copy] [--show] [--json]
|
|
78
|
+
scaly db shell --addon <addOnId> [--ttl-minutes 60] [--local-port 5432] [--host 127.0.0.1]
|
|
79
|
+
scaly db schema dump --addon <addOnId> [--out .scaly/schema.sql] [--ttl-minutes 60]
|
|
80
|
+
scaly db migrate <sql-file> --addon <addOnId> [--ttl-minutes 60] [--yes]
|
|
81
|
+
scaly deploy --app <appId> [--watch] [--strategy auto|git|restart] [--json]
|
|
82
|
+
scaly logs --follow --app <appId> [--since 10m] [--level error|warn|info|debug|all] [--q <str>] [--duration-seconds N] [--max-lines N] [--json]
|
|
83
|
+
scaly accounts create --email <email> [--name <org>] [--region EU|US|CANADA|ASIA_PACIFIC]
|
|
84
|
+
scaly stacks create --account <id> --name <stackName> [--size Eco|Basic|...] [--min-idle N]
|
|
85
|
+
scaly apps create --account <id> [--stack <id>] --name <appName> [--template <tpl>]
|
|
86
|
+
scaly logs stack --id <stackId> [--since 30m] [--limit 300] [--json]
|
|
87
|
+
scaly logs account --id <accountId> [--since 30m] [--limit 300] [--json]
|
|
88
|
+
scaly logs tail stack <stackId>
|
|
89
|
+
scaly logs tail app <appId>
|
|
90
|
+
scaly e2e eu-smoke --template fastapi-hello|flask-hello|streamlit-hello
|
|
91
|
+
scaly insights diagnose-app --account <acctId> --stack <stackId> --app <name> [--json]
|
|
92
|
+
|
|
93
|
+
Notes:
|
|
94
|
+
- Wraps existing npm QA helpers and auto-loads .env.dev.scaly-deployer.local
|
|
95
|
+
- If no AWS creds in env, runs under 'aws-vault exec scaly-dev'
|
|
96
|
+
- DB tunnel requires a short-lived Scaly session token. Run: scaly auth login
|
|
97
|
+
`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function main(argv) {
|
|
101
|
+
const [_node, _bin, cmd, sub, ...rest] = argv;
|
|
102
|
+
const args = sub ? [sub, ...rest] : rest;
|
|
103
|
+
|
|
104
|
+
const parseFlags = (arr) => {
|
|
105
|
+
const out = {};
|
|
106
|
+
for (let i = 0; i < arr.length; i++) {
|
|
107
|
+
const a = arr[i];
|
|
108
|
+
if (a.startsWith('--')) {
|
|
109
|
+
const key = a.slice(2);
|
|
110
|
+
const val =
|
|
111
|
+
arr[i + 1] && !arr[i + 1].startsWith('--') ? arr[++i] : 'true';
|
|
112
|
+
out[key] = val;
|
|
113
|
+
} else if (!out._) {
|
|
114
|
+
out._ = [a];
|
|
115
|
+
} else {
|
|
116
|
+
out._.push(a);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
if (!cmd || cmd === '-h' || cmd === '--help') return printHelp();
|
|
123
|
+
|
|
124
|
+
if (cmd === 'login') {
|
|
125
|
+
return runLogin(sub, rest).then((code) => process.exit(code));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (cmd === 'auth') {
|
|
129
|
+
const flags = parseFlags(args);
|
|
130
|
+
const { runAuth } = require('../lib/scaly-auth');
|
|
131
|
+
return runAuth(sub, flags).then((code) => process.exit(code));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (cmd === 'secrets') {
|
|
135
|
+
return runSecrets(sub, rest).then((code) => process.exit(code));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (cmd === 'deploy') {
|
|
139
|
+
return runDeploy([sub, ...rest].filter(Boolean)).then((code) =>
|
|
140
|
+
process.exit(code)
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (cmd === 'init') {
|
|
145
|
+
return runProjectInit(args).then((code) => process.exit(code));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (cmd === 'plan') {
|
|
149
|
+
return runProjectPlan(args).then((code) => process.exit(code));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (cmd === 'apply') {
|
|
153
|
+
return runProjectApply(args).then((code) => process.exit(code));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (cmd === 'pull') {
|
|
157
|
+
return runProjectPull(args).then((code) => process.exit(code));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (cmd === 'db' && sub === 'connect') {
|
|
161
|
+
return runDbConnect(rest).then((code) => process.exit(code));
|
|
162
|
+
}
|
|
163
|
+
if (cmd === 'db' && sub === 'shell') {
|
|
164
|
+
return runDbShell(rest).then((code) => process.exit(code));
|
|
165
|
+
}
|
|
166
|
+
if (cmd === 'db' && sub === 'schema' && rest[0] === 'dump') {
|
|
167
|
+
return runDbSchemaDump(rest.slice(1)).then((code) => process.exit(code));
|
|
168
|
+
}
|
|
169
|
+
if (cmd === 'db' && sub === 'migrate') {
|
|
170
|
+
return runDbMigrate(rest).then((code) => process.exit(code));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (cmd === 'accounts' && sub === 'create') {
|
|
174
|
+
const f = parseFlags(rest);
|
|
175
|
+
if (!f.email) {
|
|
176
|
+
console.error('--email is required');
|
|
177
|
+
process.exit(2);
|
|
178
|
+
}
|
|
179
|
+
const regionArg = f.region ? ['--region', f.region] : [];
|
|
180
|
+
const nameArg = f.name ? ['--name', f.name] : [];
|
|
181
|
+
const args = withDeployerEnv([
|
|
182
|
+
'npm',
|
|
183
|
+
'run',
|
|
184
|
+
'qa:create-account',
|
|
185
|
+
'--',
|
|
186
|
+
'--email',
|
|
187
|
+
f.email,
|
|
188
|
+
...nameArg,
|
|
189
|
+
...regionArg
|
|
190
|
+
]);
|
|
191
|
+
process.exit(run(args[0], args.slice(1)));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (cmd === 'apps' && sub === 'update') {
|
|
195
|
+
return runAppsUpdate(rest).then((code) => process.exit(code));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (cmd === 'apps' && sub === 'delete') {
|
|
199
|
+
return runAppsDelete(rest).then((code) => process.exit(code));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (cmd === 'apps' && sub === 'delete-by-prefix') {
|
|
203
|
+
return runAppsDeleteByPrefix(rest).then((code) => process.exit(code));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (cmd === 'stacks' && sub === 'create') {
|
|
207
|
+
const f = parseFlags(rest);
|
|
208
|
+
if (!f.account) {
|
|
209
|
+
console.error('--account is required');
|
|
210
|
+
process.exit(2);
|
|
211
|
+
}
|
|
212
|
+
const nameArg = f.name ? ['--name', f.name] : [];
|
|
213
|
+
const sizeArg = f.size ? ['--size', f.size] : [];
|
|
214
|
+
const minIdleArg = f['min-idle'] ? ['--min-idle', f['min-idle']] : [];
|
|
215
|
+
const args = withDeployerEnv([
|
|
216
|
+
'npm',
|
|
217
|
+
'run',
|
|
218
|
+
'qa:create-stack',
|
|
219
|
+
'--',
|
|
220
|
+
'--account',
|
|
221
|
+
f.account,
|
|
222
|
+
...nameArg,
|
|
223
|
+
...sizeArg,
|
|
224
|
+
...minIdleArg
|
|
225
|
+
]);
|
|
226
|
+
process.exit(run(args[0], args.slice(1)));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (cmd === 'stacks' && sub === 'update') {
|
|
230
|
+
return runStacksUpdate(rest).then((code) => process.exit(code));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (cmd === 'stacks' && sub === 'delete') {
|
|
234
|
+
return runStacksDelete(rest).then((code) => process.exit(code));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (cmd === 'stacks' && sub === 'delete-by-prefix') {
|
|
238
|
+
return runStacksDeleteByPrefix(rest).then((code) => process.exit(code));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (cmd === 'cleanup' && sub === 'purge') {
|
|
242
|
+
return runCleanupPurge(rest).then((code) => process.exit(code));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (cmd === 'apps' && sub === 'create') {
|
|
246
|
+
const f = parseFlags(rest);
|
|
247
|
+
if (!f.account) {
|
|
248
|
+
console.error('--account is required');
|
|
249
|
+
process.exit(2);
|
|
250
|
+
}
|
|
251
|
+
const stackArg = f.stack ? ['--stack', f.stack] : [];
|
|
252
|
+
const nameArg = f.name ? ['--name', f.name] : [];
|
|
253
|
+
const tplArg = f.template ? ['--template', f.template] : [];
|
|
254
|
+
const args = withDeployerEnv([
|
|
255
|
+
'npm',
|
|
256
|
+
'run',
|
|
257
|
+
'qa:create-app',
|
|
258
|
+
'--',
|
|
259
|
+
'--account',
|
|
260
|
+
f.account,
|
|
261
|
+
...stackArg,
|
|
262
|
+
...nameArg,
|
|
263
|
+
...tplArg
|
|
264
|
+
]);
|
|
265
|
+
process.exit(run(args[0], args.slice(1)));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (cmd === 'logs' && sub === 'tail') {
|
|
269
|
+
const kind = rest[0];
|
|
270
|
+
const id = rest[1];
|
|
271
|
+
if (!kind || !id) {
|
|
272
|
+
console.error('Usage: scaly logs tail <stack|app> <id>');
|
|
273
|
+
process.exit(2);
|
|
274
|
+
}
|
|
275
|
+
const script = kind === 'stack' ? 'qa:tail-stack' : 'qa:tail-app';
|
|
276
|
+
const args = withDeployerEnv(['npm', 'run', script, '--', id]);
|
|
277
|
+
process.exit(run(args[0], args.slice(1)));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (
|
|
281
|
+
cmd === 'logs' &&
|
|
282
|
+
(sub === 'follow' || sub === '--follow' || rest.includes('--follow'))
|
|
283
|
+
) {
|
|
284
|
+
const combined = sub && sub.startsWith('--') ? [sub, ...rest] : rest;
|
|
285
|
+
return runLogsFollow(combined).then((code) => process.exit(code));
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (cmd === 'logs' && sub === 'stack') {
|
|
289
|
+
return runLogsGql('stack', rest).then((code) => process.exit(code));
|
|
290
|
+
}
|
|
291
|
+
if (cmd === 'logs' && sub === 'account') {
|
|
292
|
+
return runLogsGql('account', rest).then((code) => process.exit(code));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (
|
|
296
|
+
cmd === 'e2e' &&
|
|
297
|
+
(sub === 'eu-smoke' || sub === 'eu-smoke-node' || sub === 'eu-smoke-r')
|
|
298
|
+
) {
|
|
299
|
+
return runEuSmokeFromArgs(sub, rest).then((code) => process.exit(code));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (cmd === 'insights' && sub === 'diagnose-app') {
|
|
303
|
+
return runDiagnoseApp(rest).then((code) => process.exit(code));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (cmd === 'auth' && sub === 'aws') {
|
|
307
|
+
return runAuthAws(rest).then((code) => process.exit(code));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (cmd === 'templates' && sub === 'matrix') {
|
|
311
|
+
return runTemplatesMatrix(rest).then((code) => process.exit(code));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
printHelp();
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function runEuSmokeFromArgs(which, rest) {
|
|
318
|
+
const f = (function parseFlags(arr) {
|
|
319
|
+
const out = {};
|
|
320
|
+
for (let i = 0; i < arr.length; i++) {
|
|
321
|
+
const a = arr[i];
|
|
322
|
+
if (a.startsWith('--')) {
|
|
323
|
+
const key = a.slice(2);
|
|
324
|
+
const val =
|
|
325
|
+
arr[i + 1] && !arr[i + 1].startsWith('--') ? arr[++i] : 'true';
|
|
326
|
+
out[key] = val;
|
|
327
|
+
} else if (!out._) {
|
|
328
|
+
out._ = [a];
|
|
329
|
+
} else {
|
|
330
|
+
out._.push(a);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return out;
|
|
334
|
+
})(rest);
|
|
335
|
+
|
|
336
|
+
// Ensure we run under aws-vault if creds not present
|
|
337
|
+
if (!inVault()) {
|
|
338
|
+
const repl = withVault(['node', __filename, 'e2e', which, ...rest]);
|
|
339
|
+
return run(repl[0], repl.slice(1));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Default template per variant
|
|
343
|
+
if (!f.template) {
|
|
344
|
+
if (which === 'eu-smoke-node') f.template = 'react-nest-hello';
|
|
345
|
+
else if (which === 'eu-smoke-r') f.template = 'shiny-r-hello';
|
|
346
|
+
else f.template = 'fastapi-hello';
|
|
347
|
+
}
|
|
348
|
+
return runEuSmoke(f);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function runEuSmoke(f) {
|
|
352
|
+
const template = f.template || 'fastapi-hello';
|
|
353
|
+
const name = f.name || `qa-${template}-${Date.now().toString(36)}`;
|
|
354
|
+
const accountId = f.account;
|
|
355
|
+
const stackId = f.stack;
|
|
356
|
+
if (!accountId || !stackId) {
|
|
357
|
+
console.error('--account and --stack are required for e2e eu-smoke');
|
|
358
|
+
return 2;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Load deployer env if present
|
|
362
|
+
await sourceLocalEnv();
|
|
363
|
+
|
|
364
|
+
const { getToken, apiPost, deriveDomain, regionToAws } =
|
|
365
|
+
await importHelpers();
|
|
366
|
+
try {
|
|
367
|
+
const token = await getToken();
|
|
368
|
+
|
|
369
|
+
// 1) Create app and queue template deploy
|
|
370
|
+
const createRes = await apiPost(token, CREATE_APP_MUTATION, {
|
|
371
|
+
data: {
|
|
372
|
+
account: { connect: { id: accountId } },
|
|
373
|
+
name,
|
|
374
|
+
minIdle: 0,
|
|
375
|
+
stack: { connect: { id: stackId } }
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
const app = createRes?.createApp;
|
|
379
|
+
if (!app?.id) throw new Error('createApp did not return id');
|
|
380
|
+
|
|
381
|
+
console.log(`[smoke] created app ${app.name} (${app.id})`);
|
|
382
|
+
await apiPost(token, DEPLOY_TEMPLATE_APP_MUTATION, {
|
|
383
|
+
appId: app.id,
|
|
384
|
+
templateId: template
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
// 2) Wait for deployment to complete
|
|
388
|
+
const depList = await apiPost(token, LIST_DEPLOYMENTS_QUERY, {
|
|
389
|
+
appId: app.id
|
|
390
|
+
});
|
|
391
|
+
let deploymentId = depList?.listDeployments?.[0]?.id;
|
|
392
|
+
if (!deploymentId)
|
|
393
|
+
throw new Error('no deployment id after queuing template');
|
|
394
|
+
|
|
395
|
+
const deadline =
|
|
396
|
+
Date.now() + (Number(f['timeout-minutes']) || 20) * 60 * 1000;
|
|
397
|
+
const pollMs = (Number(f['poll-seconds']) || 10) * 1000;
|
|
398
|
+
let status = '';
|
|
399
|
+
while (Date.now() < deadline) {
|
|
400
|
+
const dep = await apiPost(token, GET_DEPLOYMENT_QUERY, {
|
|
401
|
+
id: deploymentId
|
|
402
|
+
});
|
|
403
|
+
const d = dep?.getDeployment;
|
|
404
|
+
if (d && d.status !== status) {
|
|
405
|
+
status = d.status;
|
|
406
|
+
console.log(
|
|
407
|
+
`[smoke] deployment ${deploymentId} status=${status} step=${d.step || 'n/a'}`
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
if (d && ['Successful', 'Failed', 'Cancelled'].includes(d.status)) break;
|
|
411
|
+
await wait(pollMs);
|
|
412
|
+
}
|
|
413
|
+
const finished = await apiPost(token, GET_DEPLOYMENT_QUERY, {
|
|
414
|
+
id: deploymentId
|
|
415
|
+
});
|
|
416
|
+
const finalDep = finished?.getDeployment;
|
|
417
|
+
if (!finalDep || finalDep.status !== 'Successful') {
|
|
418
|
+
console.error(
|
|
419
|
+
'[smoke] deployment did not succeed',
|
|
420
|
+
JSON.stringify(finalDep || {}, null, 2)
|
|
421
|
+
);
|
|
422
|
+
return 1;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// 3) Resolve URL
|
|
426
|
+
const stackRes = await apiPost(token, GET_STACK_QUERY, {
|
|
427
|
+
where: { id: stackId }
|
|
428
|
+
});
|
|
429
|
+
const stack = stackRes?.getStack;
|
|
430
|
+
if (!stack?.name) throw new Error('could not resolve stack name');
|
|
431
|
+
const domain = deriveDomain();
|
|
432
|
+
const accountPrefix = String(accountId).split('-')[0];
|
|
433
|
+
const url = `https://${accountPrefix}-${stack.name}.${domain}/${name}/`;
|
|
434
|
+
|
|
435
|
+
// 4) HTTP 200 check with backoff
|
|
436
|
+
const tries = Number(f.tries) > 0 ? Number(f.tries) : 12;
|
|
437
|
+
const delayMs = Number(f['delay-ms']) > 0 ? Number(f['delay-ms']) : 5000;
|
|
438
|
+
const httpOk = await checkHttp200(url, { tries, delayMs });
|
|
439
|
+
|
|
440
|
+
// 5) CloudWatch logs check in EU region (or account region if available)
|
|
441
|
+
const regionEnum = stack?.account?.region || 'EU';
|
|
442
|
+
const logRegion = regionToAws(regionEnum) || 'eu-west-1';
|
|
443
|
+
const logs = await checkCloudwatchLogs(accountId, app.id, logRegion);
|
|
444
|
+
|
|
445
|
+
const summary = {
|
|
446
|
+
ok: Boolean(httpOk) && logs.found,
|
|
447
|
+
url,
|
|
448
|
+
http: httpOk,
|
|
449
|
+
logs,
|
|
450
|
+
app: { id: app.id, name: app.name },
|
|
451
|
+
stack: { id: stackId, name: stack.name },
|
|
452
|
+
accountId
|
|
453
|
+
};
|
|
454
|
+
if (f.json && String(f.json).toLowerCase() !== 'false') {
|
|
455
|
+
console.log(JSON.stringify(summary));
|
|
456
|
+
} else {
|
|
457
|
+
console.log('SMOKE_RESULT:', JSON.stringify(summary, null, 2));
|
|
458
|
+
}
|
|
459
|
+
return summary.ok ? 0 : 1;
|
|
460
|
+
} catch (err) {
|
|
461
|
+
console.error('[smoke] error', (err && err.stack) || err);
|
|
462
|
+
return 1;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
async function sourceLocalEnv() {
|
|
467
|
+
const fs = require('fs');
|
|
468
|
+
const p = '.env.dev.scaly-deployer.local';
|
|
469
|
+
if (!fs.existsSync(p)) return;
|
|
470
|
+
const text = fs.readFileSync(p, 'utf8');
|
|
471
|
+
for (const line of text.split(/\r?\n/)) {
|
|
472
|
+
if (!line || line.trim().startsWith('#')) continue;
|
|
473
|
+
const m = line.match(/^([A-Za-z0-9_]+)=(.*)$/);
|
|
474
|
+
if (!m) continue;
|
|
475
|
+
const key = m[1];
|
|
476
|
+
let val = m[2];
|
|
477
|
+
// Strip surrounding quotes if present
|
|
478
|
+
if (
|
|
479
|
+
(val.startsWith('"') && val.endsWith('"')) ||
|
|
480
|
+
(val.startsWith("'") && val.endsWith("'"))
|
|
481
|
+
) {
|
|
482
|
+
val = val.slice(1, -1);
|
|
483
|
+
}
|
|
484
|
+
if (!(key in process.env)) process.env[key] = val;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
async function importHelpers() {
|
|
489
|
+
const {
|
|
490
|
+
SecretsManagerClient,
|
|
491
|
+
GetSecretValueCommand
|
|
492
|
+
} = require('@aws-sdk/client-secrets-manager');
|
|
493
|
+
const {
|
|
494
|
+
CloudWatchLogsClient,
|
|
495
|
+
DescribeLogStreamsCommand
|
|
496
|
+
} = require('@aws-sdk/client-cloudwatch-logs');
|
|
497
|
+
const {
|
|
498
|
+
ECSClient,
|
|
499
|
+
ListClustersCommand,
|
|
500
|
+
ListServicesCommand,
|
|
501
|
+
DescribeServicesCommand,
|
|
502
|
+
ListTasksCommand,
|
|
503
|
+
DescribeTasksCommand
|
|
504
|
+
} = require('@aws-sdk/client-ecs');
|
|
505
|
+
const {
|
|
506
|
+
ElasticLoadBalancingV2Client,
|
|
507
|
+
DescribeTargetGroupsCommand,
|
|
508
|
+
DescribeTargetHealthCommand
|
|
509
|
+
} = require('@aws-sdk/client-elastic-load-balancing-v2');
|
|
510
|
+
const axios = require('axios');
|
|
511
|
+
|
|
512
|
+
const API = () => process.env.API_ENDPOINT;
|
|
513
|
+
const deriveDomain = () => {
|
|
514
|
+
try {
|
|
515
|
+
const u = new URL(API());
|
|
516
|
+
return u.host.replace(/^api\./, '');
|
|
517
|
+
} catch {
|
|
518
|
+
return process.env.SCALY_DOMAIN || 'dev.scalyapps.io';
|
|
519
|
+
}
|
|
520
|
+
};
|
|
521
|
+
const regionToAws = (r) =>
|
|
522
|
+
({
|
|
523
|
+
CANADA: 'ca-central-1',
|
|
524
|
+
US: 'us-east-1',
|
|
525
|
+
EU: 'eu-west-1',
|
|
526
|
+
ASIA_PACIFIC: 'ap-southeast-1'
|
|
527
|
+
})[String(r || '').toUpperCase()] || null;
|
|
528
|
+
|
|
529
|
+
async function getToken() {
|
|
530
|
+
// Prefer stored bearer token or env override to avoid aws-vault
|
|
531
|
+
const t = getStoredBearer();
|
|
532
|
+
if (t) return t;
|
|
533
|
+
if (process.env.SCALY_API_BEARER) return process.env.SCALY_API_BEARER;
|
|
534
|
+
if (!process.env.COGNITO_SECRET) throw new Error('COGNITO_SECRET not set');
|
|
535
|
+
const region = process.env.COGNITO_SECRET_REGION || 'ca-central-1';
|
|
536
|
+
const sm = new SecretsManagerClient({ region });
|
|
537
|
+
const res = await sm.send(
|
|
538
|
+
new GetSecretValueCommand({ SecretId: process.env.COGNITO_SECRET })
|
|
539
|
+
);
|
|
540
|
+
const sec = JSON.parse(res.SecretString || '{}');
|
|
541
|
+
const resp = await axios.post(
|
|
542
|
+
`${sec.cognitoBaseUrl}/oauth2/token`,
|
|
543
|
+
new URLSearchParams({
|
|
544
|
+
grant_type: 'client_credentials',
|
|
545
|
+
client_id: sec.cognitoClientId,
|
|
546
|
+
client_secret: sec.cognitoClientSecret
|
|
547
|
+
}).toString(),
|
|
548
|
+
{ headers: { 'content-type': 'application/x-www-form-urlencoded' } }
|
|
549
|
+
);
|
|
550
|
+
return resp.data.access_token;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
async function apiPost(token, query, variables) {
|
|
554
|
+
const resp = await axios.post(
|
|
555
|
+
API(),
|
|
556
|
+
{ query, variables },
|
|
557
|
+
{ headers: { authorization: `Bearer ${token}` } }
|
|
558
|
+
);
|
|
559
|
+
if (resp.data && resp.data.errors)
|
|
560
|
+
throw new Error(JSON.stringify(resp.data.errors));
|
|
561
|
+
return resp.data.data;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function makeCreds(c) {
|
|
565
|
+
return {
|
|
566
|
+
accessKeyId: c.accessKeyId,
|
|
567
|
+
secretAccessKey: c.secretAccessKey,
|
|
568
|
+
sessionToken: c.sessionToken
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function elbClient(region, creds) {
|
|
573
|
+
return new ElasticLoadBalancingV2Client({ region, credentials: creds });
|
|
574
|
+
}
|
|
575
|
+
function ecsClient(region, creds) {
|
|
576
|
+
return new ECSClient({ region, credentials: creds });
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
async function checkHttp200(url, { tries = 10, delayMs = 3000 } = {}) {
|
|
580
|
+
const axios = require('axios');
|
|
581
|
+
for (let i = 0; i < tries; i++) {
|
|
582
|
+
try {
|
|
583
|
+
const r = await axios.get(url, {
|
|
584
|
+
timeout: 5000,
|
|
585
|
+
validateStatus: () => true
|
|
586
|
+
});
|
|
587
|
+
if (r.status === 200) return { status: r.status };
|
|
588
|
+
console.log(
|
|
589
|
+
`[smoke] HTTP ${r.status} at attempt ${i + 1}; retrying in ${Math.round(delayMs / 1000)}s`
|
|
590
|
+
);
|
|
591
|
+
} catch (e) {
|
|
592
|
+
console.log(`[smoke] request failed at attempt ${i + 1}; retrying…`);
|
|
593
|
+
}
|
|
594
|
+
await wait(delayMs);
|
|
595
|
+
}
|
|
596
|
+
return null;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
async function checkCloudwatchLogs(accountId, appId, region) {
|
|
600
|
+
try {
|
|
601
|
+
const cwl = new CloudWatchLogsClient({ region });
|
|
602
|
+
const group = `/scaly/account/${accountId}`;
|
|
603
|
+
const cmd = new DescribeLogStreamsCommand({
|
|
604
|
+
logGroupName: group,
|
|
605
|
+
logStreamNamePrefix: `${appId}-`,
|
|
606
|
+
orderBy: 'LastEventTime',
|
|
607
|
+
descending: true,
|
|
608
|
+
limit: 5
|
|
609
|
+
});
|
|
610
|
+
const res = await cwl.send(cmd);
|
|
611
|
+
const streams = res.logStreams || [];
|
|
612
|
+
const top = streams[0];
|
|
613
|
+
return {
|
|
614
|
+
region,
|
|
615
|
+
group,
|
|
616
|
+
found: streams.length > 0,
|
|
617
|
+
latestStream: top ? top.logStreamName : null,
|
|
618
|
+
lastEventTs: top ? top.lastEventTimestamp : null
|
|
619
|
+
};
|
|
620
|
+
} catch (e) {
|
|
621
|
+
return {
|
|
622
|
+
region,
|
|
623
|
+
group: `/scaly/account/${accountId}`,
|
|
624
|
+
found: false,
|
|
625
|
+
error: String(e)
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
return {
|
|
631
|
+
getToken,
|
|
632
|
+
apiPost,
|
|
633
|
+
deriveDomain,
|
|
634
|
+
regionToAws,
|
|
635
|
+
checkHttp200,
|
|
636
|
+
makeCreds,
|
|
637
|
+
elbClient,
|
|
638
|
+
ecsClient
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function wait(ms) {
|
|
643
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// GraphQL used by smoke
|
|
647
|
+
const CREATE_APP_MUTATION = `
|
|
648
|
+
mutation CreateApp($data: AppCreateInput!) {
|
|
649
|
+
createApp(data: $data) { id name account { id } stackId }
|
|
650
|
+
}
|
|
651
|
+
`;
|
|
652
|
+
const DEPLOY_TEMPLATE_APP_MUTATION = `
|
|
653
|
+
mutation DeployTemplateApp($appId: String!, $templateId: String!) {
|
|
654
|
+
deployTemplateApp(input: { appId: $appId, templateId: $templateId }) { appId templateId }
|
|
655
|
+
}
|
|
656
|
+
`;
|
|
657
|
+
const LIST_DEPLOYMENTS_QUERY = `
|
|
658
|
+
query AppDeployments($appId: String!) { listDeployments(where: { appId: { equals: $appId } }, orderBy: { createdAt: DESC }, take: 3) { id status step } }
|
|
659
|
+
`;
|
|
660
|
+
const GET_DEPLOYMENT_QUERY = `
|
|
661
|
+
query GetDeployment($id: String!) { getDeployment(where: { id: $id }) { id status step } }
|
|
662
|
+
`;
|
|
663
|
+
const GET_STACK_QUERY = `
|
|
664
|
+
query GetStack($where: StackWhereUniqueInput!) { getStack(where: $where) { id name account { id region } } }
|
|
665
|
+
`;
|
|
666
|
+
|
|
667
|
+
// --- Logs over GraphQL ---
|
|
668
|
+
const GET_SCALY_STACK_LOGS = `
|
|
669
|
+
query GetScalyStackLogs($where: StackLogsWhereInput!) {
|
|
670
|
+
getScalyStackLogs(where: $where) {
|
|
671
|
+
events { id timestamp message logStreamName }
|
|
672
|
+
truncated
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
`;
|
|
676
|
+
const GET_SCALY_ACCOUNT_LOGS = `
|
|
677
|
+
query GetScalyAccountLogs($where: StackLogsWhereInput!) {
|
|
678
|
+
getScalyAccountLogs(where: $where) {
|
|
679
|
+
events { id timestamp message logStreamName }
|
|
680
|
+
truncated
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
`;
|
|
684
|
+
|
|
685
|
+
// --- STS Broker ---
|
|
686
|
+
const ISSUE_AWS_CREDENTIALS_MUTATION = `
|
|
687
|
+
mutation IssueAwsCredentials($input: IssueAwsCredentialsInput!) {
|
|
688
|
+
issueAwsCredentials(input: $input) {
|
|
689
|
+
accessKeyId
|
|
690
|
+
secretAccessKey
|
|
691
|
+
sessionToken
|
|
692
|
+
expiration
|
|
693
|
+
region
|
|
694
|
+
assumedRoleArn
|
|
695
|
+
requestId
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
`;
|
|
699
|
+
|
|
700
|
+
// --- DB local access ---
|
|
701
|
+
const CREATE_DATABASE_PROXY_TOKEN_MUTATION = `
|
|
702
|
+
mutation CreateDatabaseProxyToken($addOnId: String!, $ttlMinutes: Int) {
|
|
703
|
+
createDatabaseProxyToken(where: { id: $addOnId }, ttlMinutes: $ttlMinutes) {
|
|
704
|
+
token
|
|
705
|
+
expiresAt
|
|
706
|
+
host
|
|
707
|
+
port
|
|
708
|
+
database
|
|
709
|
+
username
|
|
710
|
+
ttlMinutes
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
`;
|
|
714
|
+
|
|
715
|
+
// --- Apps update/delete helpers ---
|
|
716
|
+
const UPDATE_APP_MUTATION = `
|
|
717
|
+
mutation UpdateApp($where: AppWhereUniqueInput!, $data: AppUpdateInput) {
|
|
718
|
+
updateApp(where: $where, data: $data) { id name minIdle stackId }
|
|
719
|
+
}
|
|
720
|
+
`;
|
|
721
|
+
const DELETE_APP_MUTATION = `
|
|
722
|
+
mutation DeleteApp($where: AppWhereUniqueInput!) {
|
|
723
|
+
deleteApp(where: $where) { id name }
|
|
724
|
+
}
|
|
725
|
+
`;
|
|
726
|
+
const LIST_APPS_QUERY = `
|
|
727
|
+
query ListApps($where: AppWhereInput) {
|
|
728
|
+
listApps(where: $where) { id name accountId }
|
|
729
|
+
}
|
|
730
|
+
`;
|
|
731
|
+
|
|
732
|
+
// --- Stacks update/delete helpers ---
|
|
733
|
+
const UPDATE_STACK_MUTATION = `
|
|
734
|
+
mutation UpdateStack($where: StackWhereUniqueInput!, $data: StackUpdateInput) {
|
|
735
|
+
updateStack(where: $where, data: $data) { id name minIdle size }
|
|
736
|
+
}
|
|
737
|
+
`;
|
|
738
|
+
const DELETE_STACK_MUTATION = `
|
|
739
|
+
mutation DeleteStack($where: StackWhereUniqueInput!) {
|
|
740
|
+
deleteStack(where: $where) { id name }
|
|
741
|
+
}
|
|
742
|
+
`;
|
|
743
|
+
const LIST_STACKS_QUERY = `
|
|
744
|
+
query ListStacks($where: StackWhereInput) {
|
|
745
|
+
listStacks(where: $where) { id name accountId }
|
|
746
|
+
}
|
|
747
|
+
`;
|
|
748
|
+
|
|
749
|
+
async function runAppsUpdate(rest) {
|
|
750
|
+
if (!inVault()) {
|
|
751
|
+
const repl = withVault(['node', __filename, 'apps', 'update', ...rest]);
|
|
752
|
+
return run(repl[0], repl.slice(1));
|
|
753
|
+
}
|
|
754
|
+
const f = parseKv(rest);
|
|
755
|
+
if (!f.id && !(f.name && f.account)) {
|
|
756
|
+
console.error('apps update requires --id OR (--name and --account)');
|
|
757
|
+
return 2;
|
|
758
|
+
}
|
|
759
|
+
await sourceLocalEnv();
|
|
760
|
+
const { getToken, apiPost } = await importHelpers();
|
|
761
|
+
const token = await getToken();
|
|
762
|
+
let id = f.id;
|
|
763
|
+
if (!id) {
|
|
764
|
+
// resolve by account + name
|
|
765
|
+
const res = await apiPost(token, LIST_APPS_QUERY, {
|
|
766
|
+
where: { accountId: { equals: f.account }, name: { equals: f.name } }
|
|
767
|
+
});
|
|
768
|
+
const apps = (res && res.listApps) || [];
|
|
769
|
+
if (apps.length !== 1) {
|
|
770
|
+
console.error(
|
|
771
|
+
`[apps update] expected exactly 1 match, found ${apps.length}`
|
|
772
|
+
);
|
|
773
|
+
return 1;
|
|
774
|
+
}
|
|
775
|
+
id = apps[0].id;
|
|
776
|
+
}
|
|
777
|
+
const data = {};
|
|
778
|
+
if (f['min-idle']) data.minIdle = Number(f['min-idle']);
|
|
779
|
+
if (f.name && f.id) data.name = f.name; // allow rename only with explicit id
|
|
780
|
+
if (Object.keys(data).length === 0) {
|
|
781
|
+
console.error('nothing to update; pass --min-idle or --name');
|
|
782
|
+
return 2;
|
|
783
|
+
}
|
|
784
|
+
const out = await apiPost(token, UPDATE_APP_MUTATION, {
|
|
785
|
+
where: { id },
|
|
786
|
+
data
|
|
787
|
+
});
|
|
788
|
+
console.log('apps.update:', JSON.stringify(out, null, 2));
|
|
789
|
+
return 0;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
async function runAppsDelete(rest) {
|
|
793
|
+
if (!inVault()) {
|
|
794
|
+
const repl = withVault(['node', __filename, 'apps', 'delete', ...rest]);
|
|
795
|
+
return run(repl[0], repl.slice(1));
|
|
796
|
+
}
|
|
797
|
+
const f = parseKv(rest);
|
|
798
|
+
if (!f.id && !(f.name && f.account)) {
|
|
799
|
+
console.error('apps delete requires --id OR (--name and --account)');
|
|
800
|
+
return 2;
|
|
801
|
+
}
|
|
802
|
+
await sourceLocalEnv();
|
|
803
|
+
const { getToken, apiPost } = await importHelpers();
|
|
804
|
+
const token = await getToken();
|
|
805
|
+
let id = f.id;
|
|
806
|
+
if (!id) {
|
|
807
|
+
const res = await apiPost(token, LIST_APPS_QUERY, {
|
|
808
|
+
where: { accountId: { equals: f.account }, name: { equals: f.name } }
|
|
809
|
+
});
|
|
810
|
+
const apps = (res && res.listApps) || [];
|
|
811
|
+
if (apps.length !== 1) {
|
|
812
|
+
console.error(
|
|
813
|
+
`[apps delete] expected exactly 1 match, found ${apps.length}`
|
|
814
|
+
);
|
|
815
|
+
return 1;
|
|
816
|
+
}
|
|
817
|
+
id = apps[0].id;
|
|
818
|
+
}
|
|
819
|
+
const out = await apiPost(token, DELETE_APP_MUTATION, { where: { id } });
|
|
820
|
+
console.log('apps.delete:', JSON.stringify(out, null, 2));
|
|
821
|
+
return 0;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
async function runAppsDeleteByPrefix(rest) {
|
|
825
|
+
if (!inVault()) {
|
|
826
|
+
const repl = withVault([
|
|
827
|
+
'node',
|
|
828
|
+
__filename,
|
|
829
|
+
'apps',
|
|
830
|
+
'delete-by-prefix',
|
|
831
|
+
...rest
|
|
832
|
+
]);
|
|
833
|
+
return run(repl[0], repl.slice(1));
|
|
834
|
+
}
|
|
835
|
+
const f = parseKv(rest);
|
|
836
|
+
if (!f.account || !f.prefix) {
|
|
837
|
+
console.error('apps delete-by-prefix requires --account and --prefix');
|
|
838
|
+
return 2;
|
|
839
|
+
}
|
|
840
|
+
await sourceLocalEnv();
|
|
841
|
+
const { getToken, apiPost } = await importHelpers();
|
|
842
|
+
const token = await getToken();
|
|
843
|
+
const res = await apiPost(token, LIST_APPS_QUERY, {
|
|
844
|
+
where: { accountId: { equals: f.account }, name: { startsWith: f.prefix } }
|
|
845
|
+
});
|
|
846
|
+
const apps = (res && res.listApps) || [];
|
|
847
|
+
if (apps.length === 0) {
|
|
848
|
+
console.log('apps.delete-by-prefix: no matches');
|
|
849
|
+
return 0;
|
|
850
|
+
}
|
|
851
|
+
console.log(`[apps delete-by-prefix] deleting ${apps.length} apps…`);
|
|
852
|
+
for (const a of apps) {
|
|
853
|
+
await apiPost(token, DELETE_APP_MUTATION, { where: { id: a.id } });
|
|
854
|
+
console.log(` deleted ${a.name} (${a.id})`);
|
|
855
|
+
}
|
|
856
|
+
return 0;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
async function runStacksUpdate(rest) {
|
|
860
|
+
if (!inVault()) {
|
|
861
|
+
const repl = withVault(['node', __filename, 'stacks', 'update', ...rest]);
|
|
862
|
+
return run(repl[0], repl.slice(1));
|
|
863
|
+
}
|
|
864
|
+
const f = parseKv(rest);
|
|
865
|
+
if (!f.id && !(f.name && f.account)) {
|
|
866
|
+
console.error('stacks update requires --id OR (--name and --account)');
|
|
867
|
+
return 2;
|
|
868
|
+
}
|
|
869
|
+
await sourceLocalEnv();
|
|
870
|
+
const { getToken, apiPost } = await importHelpers();
|
|
871
|
+
const token = await getToken();
|
|
872
|
+
let id = f.id;
|
|
873
|
+
if (!id) {
|
|
874
|
+
const res = await apiPost(token, LIST_STACKS_QUERY, {
|
|
875
|
+
where: { accountId: { equals: f.account }, name: { equals: f.name } }
|
|
876
|
+
});
|
|
877
|
+
const stacks = (res && res.listStacks) || [];
|
|
878
|
+
if (stacks.length !== 1) {
|
|
879
|
+
console.error(
|
|
880
|
+
`[stacks update] expected exactly 1 match, found ${stacks.length}`
|
|
881
|
+
);
|
|
882
|
+
return 1;
|
|
883
|
+
}
|
|
884
|
+
id = stacks[0].id;
|
|
885
|
+
}
|
|
886
|
+
const data = {};
|
|
887
|
+
if (f['min-idle']) data.minIdle = Number(f['min-idle']);
|
|
888
|
+
if (f.size) data.size = f.size;
|
|
889
|
+
if (f.name && f.id) data.name = f.name;
|
|
890
|
+
if (Object.keys(data).length === 0) {
|
|
891
|
+
console.error('nothing to update; pass --min-idle, --size or --name');
|
|
892
|
+
return 2;
|
|
893
|
+
}
|
|
894
|
+
const out = await apiPost(token, UPDATE_STACK_MUTATION, {
|
|
895
|
+
where: { id },
|
|
896
|
+
data
|
|
897
|
+
});
|
|
898
|
+
console.log('stacks.update:', JSON.stringify(out, null, 2));
|
|
899
|
+
return 0;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
async function runStacksDelete(rest) {
|
|
903
|
+
if (!inVault()) {
|
|
904
|
+
const repl = withVault(['node', __filename, 'stacks', 'delete', ...rest]);
|
|
905
|
+
return run(repl[0], repl.slice(1));
|
|
906
|
+
}
|
|
907
|
+
const f = parseKv(rest);
|
|
908
|
+
if (!f.id && !(f.name && f.account)) {
|
|
909
|
+
console.error('stacks delete requires --id OR (--name and --account)');
|
|
910
|
+
return 2;
|
|
911
|
+
}
|
|
912
|
+
await sourceLocalEnv();
|
|
913
|
+
const { getToken, apiPost } = await importHelpers();
|
|
914
|
+
const token = await getToken();
|
|
915
|
+
let id = f.id;
|
|
916
|
+
if (!id) {
|
|
917
|
+
const res = await apiPost(token, LIST_STACKS_QUERY, {
|
|
918
|
+
where: { accountId: { equals: f.account }, name: { equals: f.name } }
|
|
919
|
+
});
|
|
920
|
+
const stacks = (res && res.listStacks) || [];
|
|
921
|
+
if (stacks.length !== 1) {
|
|
922
|
+
console.error(
|
|
923
|
+
`[stacks delete] expected exactly 1 match, found ${stacks.length}`
|
|
924
|
+
);
|
|
925
|
+
return 1;
|
|
926
|
+
}
|
|
927
|
+
id = stacks[0].id;
|
|
928
|
+
}
|
|
929
|
+
const out = await apiPost(token, DELETE_STACK_MUTATION, { where: { id } });
|
|
930
|
+
console.log('stacks.delete:', JSON.stringify(out, null, 2));
|
|
931
|
+
return 0;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
async function runStacksDeleteByPrefix(rest) {
|
|
935
|
+
if (!inVault()) {
|
|
936
|
+
const repl = withVault([
|
|
937
|
+
'node',
|
|
938
|
+
__filename,
|
|
939
|
+
'stacks',
|
|
940
|
+
'delete-by-prefix',
|
|
941
|
+
...rest
|
|
942
|
+
]);
|
|
943
|
+
return run(repl[0], repl.slice(1));
|
|
944
|
+
}
|
|
945
|
+
const f = parseKv(rest);
|
|
946
|
+
if (!f.account || !f.prefix) {
|
|
947
|
+
console.error('stacks delete-by-prefix requires --account and --prefix');
|
|
948
|
+
return 2;
|
|
949
|
+
}
|
|
950
|
+
await sourceLocalEnv();
|
|
951
|
+
const { getToken, apiPost } = await importHelpers();
|
|
952
|
+
const token = await getToken();
|
|
953
|
+
const res = await apiPost(token, LIST_STACKS_QUERY, {
|
|
954
|
+
where: { accountId: { equals: f.account }, name: { startsWith: f.prefix } }
|
|
955
|
+
});
|
|
956
|
+
const stacks = (res && res.listStacks) || [];
|
|
957
|
+
if (stacks.length === 0) {
|
|
958
|
+
console.log('stacks.delete-by-prefix: no matches');
|
|
959
|
+
return 0;
|
|
960
|
+
}
|
|
961
|
+
console.log(`[stacks delete-by-prefix] deleting ${stacks.length} stacks…`);
|
|
962
|
+
for (const s of stacks) {
|
|
963
|
+
await apiPost(token, DELETE_STACK_MUTATION, { where: { id: s.id } });
|
|
964
|
+
console.log(` deleted ${s.name} (${s.id})`);
|
|
965
|
+
}
|
|
966
|
+
return 0;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
async function runCleanupPurge(rest) {
|
|
970
|
+
if (!inVault()) {
|
|
971
|
+
const repl = withVault(['node', __filename, 'cleanup', 'purge', ...rest]);
|
|
972
|
+
return run(repl[0], repl.slice(1));
|
|
973
|
+
}
|
|
974
|
+
const f = parseKv(rest);
|
|
975
|
+
const account = f.account;
|
|
976
|
+
if (!account) {
|
|
977
|
+
console.error('cleanup purge requires --account <id>');
|
|
978
|
+
return 2;
|
|
979
|
+
}
|
|
980
|
+
const prefixesArg = f.prefixes || f.prefix || '';
|
|
981
|
+
const prefixes = String(prefixesArg)
|
|
982
|
+
.split(',')
|
|
983
|
+
.map((s) => s.trim())
|
|
984
|
+
.filter(Boolean);
|
|
985
|
+
const dry = !!(
|
|
986
|
+
f['dry-run'] && String(f['dry-run']).toLowerCase() !== 'false'
|
|
987
|
+
);
|
|
988
|
+
const scaleAll = !!(
|
|
989
|
+
f['scale-all-stacks'] &&
|
|
990
|
+
String(f['scale-all-stacks']).toLowerCase() !== 'false'
|
|
991
|
+
);
|
|
992
|
+
const stackId = f.stack;
|
|
993
|
+
const stackName = f['stack-name'];
|
|
994
|
+
|
|
995
|
+
await sourceLocalEnv();
|
|
996
|
+
const { getToken, apiPost } = await importHelpers();
|
|
997
|
+
const token = await getToken();
|
|
998
|
+
|
|
999
|
+
// Collect apps by prefix
|
|
1000
|
+
let appsToDelete = [];
|
|
1001
|
+
if (prefixes.length > 0) {
|
|
1002
|
+
const where = { accountId: { equals: account } };
|
|
1003
|
+
if (prefixes.length === 1) where.name = { startsWith: prefixes[0] };
|
|
1004
|
+
else where.OR = prefixes.map((p) => ({ name: { startsWith: p } }));
|
|
1005
|
+
const res = await apiPost(token, LIST_APPS_QUERY, { where });
|
|
1006
|
+
appsToDelete = (res && res.listApps) || [];
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// Collect stacks to scale
|
|
1010
|
+
let stacksToScale = [];
|
|
1011
|
+
if (scaleAll) {
|
|
1012
|
+
const res = await apiPost(token, LIST_STACKS_QUERY, {
|
|
1013
|
+
where: { accountId: { equals: account } }
|
|
1014
|
+
});
|
|
1015
|
+
stacksToScale = (res && res.listStacks) || [];
|
|
1016
|
+
} else if (stackId || stackName) {
|
|
1017
|
+
if (stackId) stacksToScale = [{ id: stackId }];
|
|
1018
|
+
else {
|
|
1019
|
+
const res = await apiPost(token, LIST_STACKS_QUERY, {
|
|
1020
|
+
where: { accountId: { equals: account }, name: { equals: stackName } }
|
|
1021
|
+
});
|
|
1022
|
+
const stacks = (res && res.listStacks) || [];
|
|
1023
|
+
if (stacks.length !== 1) {
|
|
1024
|
+
console.error(
|
|
1025
|
+
`[cleanup purge] expected exactly 1 stack named ${stackName}, found ${stacks.length}`
|
|
1026
|
+
);
|
|
1027
|
+
return 1;
|
|
1028
|
+
}
|
|
1029
|
+
stacksToScale = [stacks[0]];
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
console.log(`[cleanup purge] account=${account}`);
|
|
1034
|
+
if (prefixes.length) console.log(` app prefixes: ${prefixes.join(', ')}`);
|
|
1035
|
+
console.log(` apps matched: ${appsToDelete.length}`);
|
|
1036
|
+
console.log(` stacks to scale: ${stacksToScale.length}`);
|
|
1037
|
+
|
|
1038
|
+
if (dry) {
|
|
1039
|
+
for (const a of appsToDelete)
|
|
1040
|
+
console.log(` [dry-run] would delete app ${a.name} (${a.id})`);
|
|
1041
|
+
for (const s of stacksToScale)
|
|
1042
|
+
console.log(` [dry-run] would scale stack ${s.id} to minIdle=0`);
|
|
1043
|
+
return 0;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
for (const a of appsToDelete) {
|
|
1047
|
+
await apiPost(token, DELETE_APP_MUTATION, { where: { id: a.id } });
|
|
1048
|
+
console.log(` deleted app ${a.name} (${a.id})`);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
for (const s of stacksToScale) {
|
|
1052
|
+
await apiPost(token, UPDATE_STACK_MUTATION, {
|
|
1053
|
+
where: { id: s.id },
|
|
1054
|
+
data: { minIdle: 0 }
|
|
1055
|
+
});
|
|
1056
|
+
console.log(` scaled stack ${s.id} to minIdle=0`);
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
console.log(
|
|
1060
|
+
`[cleanup purge] done. deleted=${appsToDelete.length}, scaled=${stacksToScale.length}`
|
|
1061
|
+
);
|
|
1062
|
+
return 0;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
function parseKv(arr) {
|
|
1066
|
+
const out = {};
|
|
1067
|
+
for (let i = 0; i < arr.length; i++) {
|
|
1068
|
+
const a = arr[i];
|
|
1069
|
+
if (a.startsWith('--')) {
|
|
1070
|
+
const key = a.slice(2);
|
|
1071
|
+
const val =
|
|
1072
|
+
arr[i + 1] && !arr[i + 1].startsWith('--') ? arr[++i] : 'true';
|
|
1073
|
+
out[key] = val;
|
|
1074
|
+
} else if (!out._) {
|
|
1075
|
+
out._ = [a];
|
|
1076
|
+
} else {
|
|
1077
|
+
out._.push(a);
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
return out;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
// -------------------------
|
|
1084
|
+
// Templates matrix runner
|
|
1085
|
+
// -------------------------
|
|
1086
|
+
async function runTemplatesMatrix(rest) {
|
|
1087
|
+
const f = parseKv(rest);
|
|
1088
|
+
await sourceLocalEnv();
|
|
1089
|
+
const { getToken, apiPost, deriveDomain, regionToAws, checkHttp200 } =
|
|
1090
|
+
await importHelpers();
|
|
1091
|
+
|
|
1092
|
+
const token = await getToken();
|
|
1093
|
+
const accountId = f.account || process.env.SCALY_ACCOUNT_ID;
|
|
1094
|
+
const stackId = f.stack || process.env.SCALY_STACK_ID;
|
|
1095
|
+
if (!accountId || !stackId) {
|
|
1096
|
+
console.error(
|
|
1097
|
+
'templates matrix requires --account <id> and --stack <id> (or set SCALY_ACCOUNT_ID/SCALY_STACK_ID)'
|
|
1098
|
+
);
|
|
1099
|
+
return 2;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
const tplArg = f.templates || f.template || 'all';
|
|
1103
|
+
const ALL_TEMPLATES = [
|
|
1104
|
+
'fastapi-hello',
|
|
1105
|
+
'flask-hello',
|
|
1106
|
+
'streamlit-hello',
|
|
1107
|
+
'dash-hello',
|
|
1108
|
+
'gradio-hello',
|
|
1109
|
+
'shiny-r-hello',
|
|
1110
|
+
'shiny-python-hello',
|
|
1111
|
+
'react-nest-hello',
|
|
1112
|
+
'mkdocs-hello',
|
|
1113
|
+
'docusaurus-hello'
|
|
1114
|
+
];
|
|
1115
|
+
const templates =
|
|
1116
|
+
tplArg === 'all'
|
|
1117
|
+
? ALL_TEMPLATES
|
|
1118
|
+
: String(tplArg)
|
|
1119
|
+
.split(',')
|
|
1120
|
+
.map((s) => s.trim())
|
|
1121
|
+
.filter(Boolean);
|
|
1122
|
+
const keep = !!(f.keep && String(f.keep).toLowerCase() !== 'false');
|
|
1123
|
+
const autoFix = !!(
|
|
1124
|
+
f['auto-fix'] && String(f['auto-fix']).toLowerCase() !== 'false'
|
|
1125
|
+
);
|
|
1126
|
+
const tries = Number(f.tries) || 8;
|
|
1127
|
+
const delayMs = Number(f['delay-ms']) || 5000;
|
|
1128
|
+
|
|
1129
|
+
const stackRes = await apiPost(token, GET_STACK_QUERY, {
|
|
1130
|
+
where: { id: stackId }
|
|
1131
|
+
});
|
|
1132
|
+
const stack = stackRes?.getStack;
|
|
1133
|
+
if (!stack?.name) {
|
|
1134
|
+
console.error('Could not resolve stack by id');
|
|
1135
|
+
return 1;
|
|
1136
|
+
}
|
|
1137
|
+
const domain = deriveDomain();
|
|
1138
|
+
const accountPrefix = String(accountId).split('-')[0];
|
|
1139
|
+
|
|
1140
|
+
const results = [];
|
|
1141
|
+
for (const templateId of templates) {
|
|
1142
|
+
const name = `${templateId.replace(/[^a-z0-9-]/gi, '-')}-${Date.now().toString(36).slice(-5)}`;
|
|
1143
|
+
const url = `https://${accountPrefix}-${stack.name}.${domain}/${name}/`;
|
|
1144
|
+
const startedAt = new Date().toISOString();
|
|
1145
|
+
let appId = null;
|
|
1146
|
+
let deploymentId = null;
|
|
1147
|
+
let status = 'Unknown';
|
|
1148
|
+
let http = null;
|
|
1149
|
+
let attempts = 0;
|
|
1150
|
+
let notes = [];
|
|
1151
|
+
let autoFixApplied = false;
|
|
1152
|
+
|
|
1153
|
+
try {
|
|
1154
|
+
// Create app
|
|
1155
|
+
const createRes = await apiPost(token, CREATE_APP_MUTATION, {
|
|
1156
|
+
data: {
|
|
1157
|
+
account: { connect: { id: accountId } },
|
|
1158
|
+
name,
|
|
1159
|
+
minIdle: 0,
|
|
1160
|
+
stack: { connect: { id: stackId } }
|
|
1161
|
+
}
|
|
1162
|
+
});
|
|
1163
|
+
appId = createRes?.createApp?.id;
|
|
1164
|
+
if (!appId) throw new Error('createApp returned no id');
|
|
1165
|
+
|
|
1166
|
+
// Deploy template
|
|
1167
|
+
await apiPost(token, DEPLOY_TEMPLATE_APP_MUTATION, { appId, templateId });
|
|
1168
|
+
const dl = await apiPost(token, LIST_DEPLOYMENTS_QUERY, { appId });
|
|
1169
|
+
deploymentId = dl?.listDeployments?.[0]?.id;
|
|
1170
|
+
|
|
1171
|
+
// Wait terminal
|
|
1172
|
+
const deadline =
|
|
1173
|
+
Date.now() + (Number(f['timeout-minutes']) || 20) * 60 * 1000;
|
|
1174
|
+
let last = '';
|
|
1175
|
+
while (Date.now() < deadline) {
|
|
1176
|
+
const d = await apiPost(token, GET_DEPLOYMENT_QUERY, {
|
|
1177
|
+
id: deploymentId
|
|
1178
|
+
});
|
|
1179
|
+
const dep = d?.getDeployment;
|
|
1180
|
+
if (dep && dep.status !== last) {
|
|
1181
|
+
last = dep.status;
|
|
1182
|
+
}
|
|
1183
|
+
if (dep && ['Successful', 'Failed', 'Cancelled'].includes(dep.status)) {
|
|
1184
|
+
status = dep.status;
|
|
1185
|
+
break;
|
|
1186
|
+
}
|
|
1187
|
+
await wait(
|
|
1188
|
+
Number(f['poll-seconds']) ? Number(f['poll-seconds']) * 1000 : 10000
|
|
1189
|
+
);
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
// HTTP probe
|
|
1193
|
+
attempts++;
|
|
1194
|
+
http = await checkHttp200(url, { tries, delayMs });
|
|
1195
|
+
if (!http && autoFix) {
|
|
1196
|
+
// Try a one-time redeploy of the template
|
|
1197
|
+
autoFixApplied = true;
|
|
1198
|
+
notes.push('Auto-fix: redeploy template once');
|
|
1199
|
+
await apiPost(token, DEPLOY_TEMPLATE_APP_MUTATION, {
|
|
1200
|
+
appId,
|
|
1201
|
+
templateId
|
|
1202
|
+
});
|
|
1203
|
+
await wait(10000);
|
|
1204
|
+
attempts++;
|
|
1205
|
+
http = await checkHttp200(url, { tries, delayMs });
|
|
1206
|
+
}
|
|
1207
|
+
} catch (e) {
|
|
1208
|
+
notes.push(String((e && e.message) || e));
|
|
1209
|
+
} finally {
|
|
1210
|
+
const finishedAt = new Date().toISOString();
|
|
1211
|
+
results.push({
|
|
1212
|
+
templateId,
|
|
1213
|
+
appId,
|
|
1214
|
+
deploymentId,
|
|
1215
|
+
name,
|
|
1216
|
+
url,
|
|
1217
|
+
status,
|
|
1218
|
+
http,
|
|
1219
|
+
attempts,
|
|
1220
|
+
autoFixApplied,
|
|
1221
|
+
notes,
|
|
1222
|
+
startedAt,
|
|
1223
|
+
finishedAt
|
|
1224
|
+
});
|
|
1225
|
+
if (!keep && appId) {
|
|
1226
|
+
try {
|
|
1227
|
+
await apiPost(token, DELETE_APP_MUTATION, { where: { id: appId } });
|
|
1228
|
+
} catch {}
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
const summary = {
|
|
1234
|
+
accountId,
|
|
1235
|
+
stackId,
|
|
1236
|
+
stackName: stack.name,
|
|
1237
|
+
domain,
|
|
1238
|
+
total: results.length,
|
|
1239
|
+
passed: results.filter((r) => r.http && r.http.status === 200).length,
|
|
1240
|
+
failed: results.filter((r) => !(r.http && r.http.status === 200)).length,
|
|
1241
|
+
results
|
|
1242
|
+
};
|
|
1243
|
+
|
|
1244
|
+
if (f.json && String(f.json).toLowerCase() !== 'false') {
|
|
1245
|
+
console.log(JSON.stringify(summary));
|
|
1246
|
+
} else {
|
|
1247
|
+
console.log('MATRIX_RESULT:', JSON.stringify(summary, null, 2));
|
|
1248
|
+
}
|
|
1249
|
+
return summary.failed ? 1 : 0;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
// -------------------------
|
|
1253
|
+
// Login (store/clear bearer)
|
|
1254
|
+
// -------------------------
|
|
1255
|
+
async function runLogin(sub, rest) {
|
|
1256
|
+
const f = parseKv(rest);
|
|
1257
|
+
if (f.clear) {
|
|
1258
|
+
clearStoredBearer();
|
|
1259
|
+
console.log('Cleared stored bearer token');
|
|
1260
|
+
return 0;
|
|
1261
|
+
}
|
|
1262
|
+
const token = f.token || (f._ && f._[0]);
|
|
1263
|
+
const endpoint = f.endpoint || process.env.API_ENDPOINT;
|
|
1264
|
+
if (!token) {
|
|
1265
|
+
console.error('login requires --token <api-bearer>');
|
|
1266
|
+
return 2;
|
|
1267
|
+
}
|
|
1268
|
+
if (!endpoint) {
|
|
1269
|
+
console.error(
|
|
1270
|
+
'login requires --endpoint <https://api...> (or set API_ENDPOINT)'
|
|
1271
|
+
);
|
|
1272
|
+
return 2;
|
|
1273
|
+
}
|
|
1274
|
+
saveStoredBearer({ token, endpoint });
|
|
1275
|
+
console.log(`Saved API bearer for ${endpoint}`);
|
|
1276
|
+
return 0;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
function getConfigPath() {
|
|
1280
|
+
const os = require('os');
|
|
1281
|
+
const fs = require('fs');
|
|
1282
|
+
const path = require('path');
|
|
1283
|
+
const base =
|
|
1284
|
+
process.env.SCALY_CONFIG_DIR ||
|
|
1285
|
+
process.env.XDG_CONFIG_HOME ||
|
|
1286
|
+
path.join(os.homedir(), '.config');
|
|
1287
|
+
const dir = path.join(base, 'scaly');
|
|
1288
|
+
const file = path.join(dir, 'credentials.json');
|
|
1289
|
+
return { dir, file, fs, path };
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
function saveStoredBearer({ token, endpoint }) {
|
|
1293
|
+
const { dir, file, fs } = getConfigPath();
|
|
1294
|
+
try {
|
|
1295
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1296
|
+
} catch {}
|
|
1297
|
+
const data = { token, endpoint, savedAt: new Date().toISOString() };
|
|
1298
|
+
fs.writeFileSync(file, JSON.stringify(data, null, 2));
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
function clearStoredBearer() {
|
|
1302
|
+
const { file, fs } = getConfigPath();
|
|
1303
|
+
try {
|
|
1304
|
+
fs.unlinkSync(file);
|
|
1305
|
+
} catch {}
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
function loadStoredBearer() {
|
|
1309
|
+
const { file, fs } = getConfigPath();
|
|
1310
|
+
try {
|
|
1311
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
1312
|
+
const obj = JSON.parse(text);
|
|
1313
|
+
return obj && obj.token && obj.endpoint ? obj : null;
|
|
1314
|
+
} catch {
|
|
1315
|
+
return null;
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
function getStoredBearer() {
|
|
1320
|
+
const obj = loadStoredBearer();
|
|
1321
|
+
if (obj) process.env.API_ENDPOINT = process.env.API_ENDPOINT || obj.endpoint;
|
|
1322
|
+
return obj ? obj.token : null;
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
function parseBool(v, defaultValue = false) {
|
|
1326
|
+
if (v === undefined || v === null) return defaultValue;
|
|
1327
|
+
const s = String(v).trim().toLowerCase();
|
|
1328
|
+
if (s === '') return defaultValue;
|
|
1329
|
+
if (['1', 'true', 'yes', 'y', 'on'].includes(s)) return true;
|
|
1330
|
+
if (['0', 'false', 'no', 'n', 'off'].includes(s)) return false;
|
|
1331
|
+
return defaultValue;
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
function promptYesNo(question) {
|
|
1335
|
+
const readline = require('readline');
|
|
1336
|
+
const rl = readline.createInterface({
|
|
1337
|
+
input: process.stdin,
|
|
1338
|
+
output: process.stdout
|
|
1339
|
+
});
|
|
1340
|
+
return new Promise((resolve) => {
|
|
1341
|
+
rl.question(question, (answer) => {
|
|
1342
|
+
rl.close();
|
|
1343
|
+
resolve(/^y(es)?$/i.test(String(answer || '').trim()));
|
|
1344
|
+
});
|
|
1345
|
+
});
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
function promptHidden(question) {
|
|
1349
|
+
const readline = require('readline');
|
|
1350
|
+
const rl = readline.createInterface({
|
|
1351
|
+
input: process.stdin,
|
|
1352
|
+
output: process.stdout,
|
|
1353
|
+
terminal: true
|
|
1354
|
+
});
|
|
1355
|
+
// Mask input with '*' while typing.
|
|
1356
|
+
// eslint-disable-next-line no-underscore-dangle
|
|
1357
|
+
rl._writeToOutput = function _writeToOutput(stringToWrite) {
|
|
1358
|
+
if (rl.stdoutMuted) rl.output.write('*');
|
|
1359
|
+
else rl.output.write(stringToWrite);
|
|
1360
|
+
};
|
|
1361
|
+
rl.stdoutMuted = true;
|
|
1362
|
+
return new Promise((resolve) => {
|
|
1363
|
+
rl.question(question, (value) => {
|
|
1364
|
+
rl.stdoutMuted = false;
|
|
1365
|
+
rl.output.write('\n');
|
|
1366
|
+
rl.close();
|
|
1367
|
+
resolve(String(value || ''));
|
|
1368
|
+
});
|
|
1369
|
+
});
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
function readStdinAll() {
|
|
1373
|
+
return new Promise((resolve, reject) => {
|
|
1374
|
+
let data = '';
|
|
1375
|
+
process.stdin.setEncoding('utf8');
|
|
1376
|
+
process.stdin.on('data', (chunk) => (data += chunk));
|
|
1377
|
+
process.stdin.on('end', () => resolve(data));
|
|
1378
|
+
process.stdin.on('error', reject);
|
|
1379
|
+
});
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
function copyToClipboard(text) {
|
|
1383
|
+
const { spawnSync } = require('child_process');
|
|
1384
|
+
const plat = process.platform;
|
|
1385
|
+
|
|
1386
|
+
const tryCmd = (cmd, args = []) => {
|
|
1387
|
+
const res = spawnSync(cmd, args, {
|
|
1388
|
+
input: text,
|
|
1389
|
+
stdio: ['pipe', 'ignore', 'ignore'],
|
|
1390
|
+
shell: false
|
|
1391
|
+
});
|
|
1392
|
+
return res && res.status === 0;
|
|
1393
|
+
};
|
|
1394
|
+
|
|
1395
|
+
if (plat === 'darwin') {
|
|
1396
|
+
if (tryCmd('pbcopy')) return true;
|
|
1397
|
+
} else if (plat === 'win32') {
|
|
1398
|
+
if (tryCmd('clip')) return true;
|
|
1399
|
+
} else {
|
|
1400
|
+
if (tryCmd('wl-copy')) return true;
|
|
1401
|
+
if (tryCmd('xclip', ['-selection', 'clipboard'])) return true;
|
|
1402
|
+
if (tryCmd('xsel', ['--clipboard', '--input'])) return true;
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
return false;
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
// -------------------------
|
|
1409
|
+
// GraphQL Logs
|
|
1410
|
+
// -------------------------
|
|
1411
|
+
async function runLogsGql(kind, rest) {
|
|
1412
|
+
const f = parseKv(rest);
|
|
1413
|
+
const id = f.id || (f._ && f._[0]);
|
|
1414
|
+
if (!id) {
|
|
1415
|
+
console.error(
|
|
1416
|
+
`logs ${kind} requires --id <${kind === 'stack' ? 'stackId' : 'accountId'}>`
|
|
1417
|
+
);
|
|
1418
|
+
return 2;
|
|
1419
|
+
}
|
|
1420
|
+
const since = f.since || '30m';
|
|
1421
|
+
const limit = Number(f.limit) > 0 ? Number(f.limit) : 300;
|
|
1422
|
+
await sourceLocalEnv();
|
|
1423
|
+
const { getToken, apiPost } = await importHelpers();
|
|
1424
|
+
const token = await getToken();
|
|
1425
|
+
const startTime = sinceToIso(since);
|
|
1426
|
+
const where = { id, startTime };
|
|
1427
|
+
const query =
|
|
1428
|
+
kind === 'stack' ? GET_SCALY_STACK_LOGS : GET_SCALY_ACCOUNT_LOGS;
|
|
1429
|
+
const res = await apiPost(token, query, { where });
|
|
1430
|
+
const data =
|
|
1431
|
+
kind === 'stack' ? res.getScalyStackLogs : res.getScalyAccountLogs;
|
|
1432
|
+
const events = data && data.events ? data.events.slice(-limit) : [];
|
|
1433
|
+
if (f.json && String(f.json).toLowerCase() !== 'false') {
|
|
1434
|
+
console.log(
|
|
1435
|
+
JSON.stringify({
|
|
1436
|
+
id,
|
|
1437
|
+
kind,
|
|
1438
|
+
count: events.length,
|
|
1439
|
+
truncated: !!data?.truncated,
|
|
1440
|
+
events
|
|
1441
|
+
})
|
|
1442
|
+
);
|
|
1443
|
+
return 0;
|
|
1444
|
+
}
|
|
1445
|
+
for (const e of events) {
|
|
1446
|
+
console.log(`${e.timestamp} ${e.logStreamName || ''} ${e.message}`);
|
|
1447
|
+
}
|
|
1448
|
+
if (data?.truncated) console.log('[truncated]');
|
|
1449
|
+
return 0;
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
function sinceToIso(s) {
|
|
1453
|
+
const now = Date.now();
|
|
1454
|
+
const m = String(s).match(/^(\d+)([smhd])$/i);
|
|
1455
|
+
let ms = 30 * 60 * 1000; // default 30m
|
|
1456
|
+
if (m) {
|
|
1457
|
+
const n = Number(m[1]);
|
|
1458
|
+
const u = m[2].toLowerCase();
|
|
1459
|
+
if (u === 's') ms = n * 1000;
|
|
1460
|
+
if (u === 'm') ms = n * 60 * 1000;
|
|
1461
|
+
if (u === 'h') ms = n * 60 * 60 * 1000;
|
|
1462
|
+
if (u === 'd') ms = n * 24 * 60 * 60 * 1000;
|
|
1463
|
+
}
|
|
1464
|
+
return new Date(now - ms).toISOString();
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
// -------------------------
|
|
1468
|
+
// .scaly project config (Phase 3)
|
|
1469
|
+
// -------------------------
|
|
1470
|
+
async function runProjectInit(rest) {
|
|
1471
|
+
const fs = require('fs');
|
|
1472
|
+
const path = require('path');
|
|
1473
|
+
const { writeYamlFile } = require('../lib/scaly-project');
|
|
1474
|
+
|
|
1475
|
+
const f = parseKv(rest);
|
|
1476
|
+
const force = String(f.force || '').toLowerCase() === 'true';
|
|
1477
|
+
|
|
1478
|
+
const root = process.cwd();
|
|
1479
|
+
const project = path.basename(root).replace(/[^a-zA-Z0-9-_]/g, '') || 'app';
|
|
1480
|
+
const scalyDir = path.join(root, '.scaly');
|
|
1481
|
+
const configPath = path.join(scalyDir, 'config.yaml');
|
|
1482
|
+
|
|
1483
|
+
if (fs.existsSync(configPath) && !force) {
|
|
1484
|
+
console.error(
|
|
1485
|
+
`Refusing to overwrite existing ${configPath}. Re-run with --force to replace.`
|
|
1486
|
+
);
|
|
1487
|
+
return 2;
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
const baseConfig = {
|
|
1491
|
+
version: '1',
|
|
1492
|
+
account: { slug: 'mycompany' },
|
|
1493
|
+
stack: {
|
|
1494
|
+
name: `${project}-stack`,
|
|
1495
|
+
size: 'Eco',
|
|
1496
|
+
region: 'CANADA',
|
|
1497
|
+
minIdle: 0
|
|
1498
|
+
},
|
|
1499
|
+
app: {
|
|
1500
|
+
name: project,
|
|
1501
|
+
framework: 'fastapi',
|
|
1502
|
+
env: { LOG_LEVEL: 'info' },
|
|
1503
|
+
secrets: []
|
|
1504
|
+
},
|
|
1505
|
+
addons: [],
|
|
1506
|
+
domains: [],
|
|
1507
|
+
jobs: []
|
|
1508
|
+
};
|
|
1509
|
+
|
|
1510
|
+
writeYamlFile(configPath, baseConfig);
|
|
1511
|
+
|
|
1512
|
+
const schemaPath = path.join(scalyDir, 'schema.sql');
|
|
1513
|
+
fs.writeFileSync(schemaPath, '', 'utf8');
|
|
1514
|
+
|
|
1515
|
+
const migrationsDir = path.join(scalyDir, 'migrations');
|
|
1516
|
+
fs.mkdirSync(migrationsDir, { recursive: true });
|
|
1517
|
+
const keep = path.join(migrationsDir, '.gitkeep');
|
|
1518
|
+
fs.writeFileSync(keep, '', 'utf8');
|
|
1519
|
+
|
|
1520
|
+
console.log(`Created ${configPath}`);
|
|
1521
|
+
console.log(`Created ${schemaPath} (empty)`);
|
|
1522
|
+
console.log(`Created ${migrationsDir}/`);
|
|
1523
|
+
return 0;
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
async function runProjectPlan(rest) {
|
|
1527
|
+
const f = parseKv(rest);
|
|
1528
|
+
const env = f.env || null;
|
|
1529
|
+
const appName = f.app || null;
|
|
1530
|
+
const json = String(f.json || '').toLowerCase() === 'true';
|
|
1531
|
+
|
|
1532
|
+
// Allow existing `scaly login` flow to set API_ENDPOINT.
|
|
1533
|
+
try {
|
|
1534
|
+
const t = getStoredBearer();
|
|
1535
|
+
if (t && !process.env.SCALY_API_BEARER) process.env.SCALY_API_BEARER = t;
|
|
1536
|
+
} catch {}
|
|
1537
|
+
|
|
1538
|
+
const {
|
|
1539
|
+
loadScalyConfig,
|
|
1540
|
+
computeConfigHash,
|
|
1541
|
+
readLastApply
|
|
1542
|
+
} = require('../lib/scaly-project');
|
|
1543
|
+
const { buildPlan } = require('../lib/scaly-plan');
|
|
1544
|
+
|
|
1545
|
+
let loaded;
|
|
1546
|
+
try {
|
|
1547
|
+
loaded = loadScalyConfig({ cwd: process.cwd(), env });
|
|
1548
|
+
} catch (err) {
|
|
1549
|
+
console.error(String(err && err.message ? err.message : err));
|
|
1550
|
+
return 2;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
if (!loaded.validation.ok) {
|
|
1554
|
+
console.error('Invalid .scaly/config.yaml:');
|
|
1555
|
+
for (const e of loaded.validation.errors) console.error(`- ${e}`);
|
|
1556
|
+
return 2;
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
const plan = await buildPlan({ config: loaded.config, env, appName });
|
|
1560
|
+
const config_hash = computeConfigHash({
|
|
1561
|
+
config: loaded.config,
|
|
1562
|
+
env: plan.env,
|
|
1563
|
+
appName
|
|
1564
|
+
});
|
|
1565
|
+
const lastApply = readLastApply(loaded.root);
|
|
1566
|
+
const drift =
|
|
1567
|
+
lastApply && lastApply.config_hash === config_hash
|
|
1568
|
+
? (plan.operations || [])
|
|
1569
|
+
.filter((o) => o.action === 'create' || o.action === 'update')
|
|
1570
|
+
.flatMap((op) => {
|
|
1571
|
+
const diffs = Array.isArray(op.diff) ? op.diff : [];
|
|
1572
|
+
return diffs.map((d) => ({
|
|
1573
|
+
resource: op.kind,
|
|
1574
|
+
id: op.resource?.id || null,
|
|
1575
|
+
name: op.resource?.name || null,
|
|
1576
|
+
field: d.field,
|
|
1577
|
+
live: d.current ?? null,
|
|
1578
|
+
config: d.desired ?? null
|
|
1579
|
+
}));
|
|
1580
|
+
})
|
|
1581
|
+
: plan.drift || [];
|
|
1582
|
+
|
|
1583
|
+
const out = {
|
|
1584
|
+
ok: true,
|
|
1585
|
+
project_root: loaded.root,
|
|
1586
|
+
config_path: loaded.basePath,
|
|
1587
|
+
overlay_path: loaded.overlayPath,
|
|
1588
|
+
plan_hash: plan.plan_hash,
|
|
1589
|
+
env: plan.env,
|
|
1590
|
+
summary: plan.summary,
|
|
1591
|
+
drift,
|
|
1592
|
+
warnings: plan.warnings,
|
|
1593
|
+
operations: plan.operations
|
|
1594
|
+
};
|
|
1595
|
+
|
|
1596
|
+
if (json) {
|
|
1597
|
+
console.log(JSON.stringify(out));
|
|
1598
|
+
return 0;
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
console.log(
|
|
1602
|
+
`Plan: create=${plan.summary.create} update=${plan.summary.update} noop=${plan.summary.noop}`
|
|
1603
|
+
);
|
|
1604
|
+
for (const op of plan.operations) {
|
|
1605
|
+
if (op.action === 'noop') continue;
|
|
1606
|
+
const name = op.resource?.name || op.resource?.id || op.id;
|
|
1607
|
+
console.log(`- ${op.action.toUpperCase()} ${op.kind} ${name}`);
|
|
1608
|
+
for (const d of op.diff || []) {
|
|
1609
|
+
console.log(
|
|
1610
|
+
` - ${d.field}: ${JSON.stringify(d.current)} → ${JSON.stringify(d.desired)}`
|
|
1611
|
+
);
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
for (const w of plan.warnings || []) console.log(`[warn] ${w}`);
|
|
1615
|
+
console.log('');
|
|
1616
|
+
console.log(`plan_hash: ${plan.plan_hash}`);
|
|
1617
|
+
console.log(
|
|
1618
|
+
`Run: scaly apply --plan-hash ${plan.plan_hash}${env ? ` --env ${env}` : ''}${appName ? ` --app ${appName}` : ''}`
|
|
1619
|
+
);
|
|
1620
|
+
return 0;
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
async function runProjectApply(rest) {
|
|
1624
|
+
const readline = require('readline');
|
|
1625
|
+
|
|
1626
|
+
const f = parseKv(rest);
|
|
1627
|
+
const env = f.env || null;
|
|
1628
|
+
const appName = f.app || null;
|
|
1629
|
+
const json = String(f.json || '').toLowerCase() === 'true';
|
|
1630
|
+
const autoApprove = String(f['auto-approve'] || '').toLowerCase() === 'true';
|
|
1631
|
+
const planHash = f['plan-hash'] || f.plan_hash || f.planHash;
|
|
1632
|
+
if (!planHash) {
|
|
1633
|
+
console.error(
|
|
1634
|
+
'apply requires --plan-hash <sha256:...> (run `scaly plan` first).'
|
|
1635
|
+
);
|
|
1636
|
+
return 2;
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
try {
|
|
1640
|
+
const t = getStoredBearer();
|
|
1641
|
+
if (t && !process.env.SCALY_API_BEARER) process.env.SCALY_API_BEARER = t;
|
|
1642
|
+
} catch {}
|
|
1643
|
+
|
|
1644
|
+
const {
|
|
1645
|
+
loadScalyConfig,
|
|
1646
|
+
computeConfigHash,
|
|
1647
|
+
writeLastApply
|
|
1648
|
+
} = require('../lib/scaly-project');
|
|
1649
|
+
const { buildPlan } = require('../lib/scaly-plan');
|
|
1650
|
+
const { applyPlan } = require('../lib/scaly-apply');
|
|
1651
|
+
|
|
1652
|
+
let loaded;
|
|
1653
|
+
try {
|
|
1654
|
+
loaded = loadScalyConfig({ cwd: process.cwd(), env });
|
|
1655
|
+
} catch (err) {
|
|
1656
|
+
console.error(String(err && err.message ? err.message : err));
|
|
1657
|
+
return 2;
|
|
1658
|
+
}
|
|
1659
|
+
if (!loaded.validation.ok) {
|
|
1660
|
+
console.error('Invalid .scaly/config.yaml:');
|
|
1661
|
+
for (const e of loaded.validation.errors) console.error(`- ${e}`);
|
|
1662
|
+
return 2;
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
const plan = await buildPlan({ config: loaded.config, env, appName });
|
|
1666
|
+
if (plan.plan_hash !== planHash) {
|
|
1667
|
+
console.error('Plan hash mismatch.');
|
|
1668
|
+
console.error(`Expected: ${plan.plan_hash}`);
|
|
1669
|
+
console.error(`Provided: ${planHash}`);
|
|
1670
|
+
console.error('Re-run `scaly plan` and use the printed hash.');
|
|
1671
|
+
return 2;
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
const actionable = (plan.operations || []).filter(
|
|
1675
|
+
(o) => o.action === 'create' || o.action === 'update'
|
|
1676
|
+
);
|
|
1677
|
+
if (actionable.length === 0) {
|
|
1678
|
+
if (json)
|
|
1679
|
+
console.log(
|
|
1680
|
+
JSON.stringify({
|
|
1681
|
+
ok: true,
|
|
1682
|
+
plan_hash: plan.plan_hash,
|
|
1683
|
+
applied: 0,
|
|
1684
|
+
results: []
|
|
1685
|
+
})
|
|
1686
|
+
);
|
|
1687
|
+
else console.log('No changes to apply.');
|
|
1688
|
+
return 0;
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
if (!autoApprove) {
|
|
1692
|
+
const rl = readline.createInterface({
|
|
1693
|
+
input: process.stdin,
|
|
1694
|
+
output: process.stdout
|
|
1695
|
+
});
|
|
1696
|
+
const answer = await new Promise((resolve) =>
|
|
1697
|
+
rl.question(`Apply ${actionable.length} changes? (y/N) `, resolve)
|
|
1698
|
+
);
|
|
1699
|
+
rl.close();
|
|
1700
|
+
if (!/^y(es)?$/i.test(String(answer || '').trim())) {
|
|
1701
|
+
console.error('Aborted.');
|
|
1702
|
+
return 1;
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
let res;
|
|
1707
|
+
try {
|
|
1708
|
+
res = await applyPlan({ config: loaded.config, plan, autoApprove });
|
|
1709
|
+
} catch (err) {
|
|
1710
|
+
const msg = String(err && err.message ? err.message : err);
|
|
1711
|
+
if (json)
|
|
1712
|
+
console.log(
|
|
1713
|
+
JSON.stringify({
|
|
1714
|
+
ok: false,
|
|
1715
|
+
plan_hash: plan.plan_hash,
|
|
1716
|
+
error: { message: msg }
|
|
1717
|
+
})
|
|
1718
|
+
);
|
|
1719
|
+
else console.error(msg);
|
|
1720
|
+
return 1;
|
|
1721
|
+
}
|
|
1722
|
+
const out = { ...res, plan_hash: plan.plan_hash };
|
|
1723
|
+
|
|
1724
|
+
if (res.ok) {
|
|
1725
|
+
const config_hash = computeConfigHash({
|
|
1726
|
+
config: loaded.config,
|
|
1727
|
+
env: plan.env,
|
|
1728
|
+
appName
|
|
1729
|
+
});
|
|
1730
|
+
writeLastApply(loaded.root, {
|
|
1731
|
+
version: 1,
|
|
1732
|
+
applied_at: new Date().toISOString(),
|
|
1733
|
+
env: plan.env || null,
|
|
1734
|
+
app: appName || null,
|
|
1735
|
+
plan_hash: plan.plan_hash,
|
|
1736
|
+
config_hash,
|
|
1737
|
+
applied: out.applied
|
|
1738
|
+
});
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
if (json) {
|
|
1742
|
+
console.log(JSON.stringify(out));
|
|
1743
|
+
return res.ok ? 0 : 1;
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
console.log(`Applied ${out.applied} changes.`);
|
|
1747
|
+
for (const r of out.results || []) {
|
|
1748
|
+
if (r.ok) console.log(`- OK ${r.op_id}`);
|
|
1749
|
+
else
|
|
1750
|
+
console.log(`- FAIL ${r.op_id}: ${r.error?.message || 'unknown error'}`);
|
|
1751
|
+
}
|
|
1752
|
+
return res.ok ? 0 : 1;
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
async function runProjectPull(rest) {
|
|
1756
|
+
const fs = require('fs');
|
|
1757
|
+
const path = require('path');
|
|
1758
|
+
const api = require('../lib/scaly-api');
|
|
1759
|
+
const { writeYamlFile } = require('../lib/scaly-project');
|
|
1760
|
+
|
|
1761
|
+
const f = parseKv(rest);
|
|
1762
|
+
const json = String(f.json || '').toLowerCase() === 'true';
|
|
1763
|
+
const force = String(f.force || '').toLowerCase() === 'true';
|
|
1764
|
+
const stackId = f.stack || null;
|
|
1765
|
+
const appId = f.app || null;
|
|
1766
|
+
|
|
1767
|
+
try {
|
|
1768
|
+
const t = getStoredBearer();
|
|
1769
|
+
if (t && !process.env.SCALY_API_BEARER) process.env.SCALY_API_BEARER = t;
|
|
1770
|
+
} catch {}
|
|
1771
|
+
|
|
1772
|
+
const root = process.cwd();
|
|
1773
|
+
const scalyDir = path.join(root, '.scaly');
|
|
1774
|
+
const configPath = path.join(scalyDir, 'config.yaml');
|
|
1775
|
+
|
|
1776
|
+
if (fs.existsSync(configPath) && !force) {
|
|
1777
|
+
console.error(
|
|
1778
|
+
`Refusing to overwrite existing ${configPath}. Re-run with --force to replace.`
|
|
1779
|
+
);
|
|
1780
|
+
return 2;
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
const stacks = await api.listStacks({ take: 50 });
|
|
1784
|
+
const chosenStack = stackId
|
|
1785
|
+
? stacks.find((s) => s.id === stackId)
|
|
1786
|
+
: stacks.length === 1
|
|
1787
|
+
? stacks[0]
|
|
1788
|
+
: null;
|
|
1789
|
+
if (!chosenStack) {
|
|
1790
|
+
console.error(
|
|
1791
|
+
stackId
|
|
1792
|
+
? `Stack not found: ${stackId}`
|
|
1793
|
+
: `Multiple stacks found (${stacks.length}). Re-run with --stack <id>.`
|
|
1794
|
+
);
|
|
1795
|
+
return 2;
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
const apps = await api.listApps({ take: 200 });
|
|
1799
|
+
const appsInStack = apps.filter((a) => a.stackId === chosenStack.id);
|
|
1800
|
+
const chosenApp = appId
|
|
1801
|
+
? apps.find((a) => a.id === appId)
|
|
1802
|
+
: appsInStack.length === 1
|
|
1803
|
+
? appsInStack[0]
|
|
1804
|
+
: null;
|
|
1805
|
+
if (!chosenApp) {
|
|
1806
|
+
console.error(
|
|
1807
|
+
appId
|
|
1808
|
+
? `App not found: ${appId}`
|
|
1809
|
+
: `Multiple apps found in stack (${appsInStack.length}). Re-run with --app <id>.`
|
|
1810
|
+
);
|
|
1811
|
+
return 2;
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
const config = {
|
|
1815
|
+
version: '1',
|
|
1816
|
+
account: { id: chosenStack.accountId },
|
|
1817
|
+
stack: {
|
|
1818
|
+
name: chosenStack.name,
|
|
1819
|
+
size: chosenStack.size,
|
|
1820
|
+
minIdle: chosenStack.minIdle
|
|
1821
|
+
},
|
|
1822
|
+
app: {
|
|
1823
|
+
name: chosenApp.name,
|
|
1824
|
+
framework: 'unknown'
|
|
1825
|
+
},
|
|
1826
|
+
addons: [],
|
|
1827
|
+
domains: [],
|
|
1828
|
+
jobs: []
|
|
1829
|
+
};
|
|
1830
|
+
|
|
1831
|
+
writeYamlFile(configPath, config);
|
|
1832
|
+
fs.mkdirSync(path.join(scalyDir, 'migrations'), { recursive: true });
|
|
1833
|
+
const schemaPath = path.join(scalyDir, 'schema.sql');
|
|
1834
|
+
if (!fs.existsSync(schemaPath)) fs.writeFileSync(schemaPath, '', 'utf8');
|
|
1835
|
+
|
|
1836
|
+
const out = {
|
|
1837
|
+
ok: true,
|
|
1838
|
+
wrote: configPath,
|
|
1839
|
+
stack: chosenStack,
|
|
1840
|
+
app: chosenApp
|
|
1841
|
+
};
|
|
1842
|
+
|
|
1843
|
+
if (json) console.log(JSON.stringify(out));
|
|
1844
|
+
else console.log(`Wrote ${configPath}`);
|
|
1845
|
+
return 0;
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
// -------------------------
|
|
1849
|
+
// Deploy
|
|
1850
|
+
// -------------------------
|
|
1851
|
+
function sleep(ms) {
|
|
1852
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
async function runDeploy(rest) {
|
|
1856
|
+
const f = parseKv(rest);
|
|
1857
|
+
const json = parseBool(f.json, false);
|
|
1858
|
+
const watch = f.watch !== undefined ? parseBool(f.watch, true) : false;
|
|
1859
|
+
const strategy = String(f.strategy || 'auto').toLowerCase(); // auto|git|restart
|
|
1860
|
+
const pollSeconds = Number(f['poll-seconds'] || 5);
|
|
1861
|
+
const timeoutMinutes = Number(f['timeout-minutes'] || 20);
|
|
1862
|
+
|
|
1863
|
+
const appId = f.app || f['app-id'] || (f._ && f._[0]);
|
|
1864
|
+
if (!appId) {
|
|
1865
|
+
const msg = '--app <appId> is required';
|
|
1866
|
+
if (json)
|
|
1867
|
+
console.log(JSON.stringify({ ok: false, error: { message: msg } }));
|
|
1868
|
+
else console.error(msg);
|
|
1869
|
+
return 2;
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
try {
|
|
1873
|
+
const t = getStoredBearer();
|
|
1874
|
+
if (t && !process.env.SCALY_API_BEARER) process.env.SCALY_API_BEARER = t;
|
|
1875
|
+
} catch {}
|
|
1876
|
+
|
|
1877
|
+
const deploy = require('../lib/scaly-deploy');
|
|
1878
|
+
|
|
1879
|
+
const app = await deploy.getAppBasic(appId);
|
|
1880
|
+
if (!app) {
|
|
1881
|
+
const msg = `App not found: ${appId}`;
|
|
1882
|
+
if (json)
|
|
1883
|
+
console.log(JSON.stringify({ ok: false, error: { message: msg } }));
|
|
1884
|
+
else console.error(msg);
|
|
1885
|
+
return 2;
|
|
1886
|
+
}
|
|
1887
|
+
if (!app.stackId) {
|
|
1888
|
+
const msg = `App has no stackId: ${appId}`;
|
|
1889
|
+
if (json)
|
|
1890
|
+
console.log(JSON.stringify({ ok: false, error: { message: msg } }));
|
|
1891
|
+
else console.error(msg);
|
|
1892
|
+
return 2;
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
let chosen = strategy;
|
|
1896
|
+
let gitSource = null;
|
|
1897
|
+
if (strategy === 'auto') {
|
|
1898
|
+
gitSource = await deploy.getAppGitSource(appId);
|
|
1899
|
+
chosen = gitSource ? 'git' : 'restart';
|
|
1900
|
+
} else if (strategy === 'git') {
|
|
1901
|
+
gitSource = await deploy.getAppGitSource(appId);
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
if (chosen === 'git') {
|
|
1905
|
+
const trigger = await deploy.triggerGitDeploy(appId);
|
|
1906
|
+
if (!trigger?.id) {
|
|
1907
|
+
const msg = 'Failed to trigger git deploy';
|
|
1908
|
+
if (json)
|
|
1909
|
+
console.log(JSON.stringify({ ok: false, error: { message: msg } }));
|
|
1910
|
+
else console.error(msg);
|
|
1911
|
+
return 1;
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
if (!watch) {
|
|
1915
|
+
const out = { ok: true, strategy: 'git', git_deploy: trigger, app };
|
|
1916
|
+
if (json) console.log(JSON.stringify(out));
|
|
1917
|
+
else
|
|
1918
|
+
console.log(
|
|
1919
|
+
`[deploy] triggered git deploy ${trigger.id} status=${trigger.status}`
|
|
1920
|
+
);
|
|
1921
|
+
return 0;
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
const deadline = Date.now() + timeoutMinutes * 60_000;
|
|
1925
|
+
let lastStatus = null;
|
|
1926
|
+
let matched = null;
|
|
1927
|
+
while (Date.now() < deadline) {
|
|
1928
|
+
const items = await deploy.listGitDeployments(appId, 20);
|
|
1929
|
+
matched = items.find((d) => d.id === trigger.id) || null;
|
|
1930
|
+
if (matched && matched.status !== lastStatus) {
|
|
1931
|
+
lastStatus = matched.status;
|
|
1932
|
+
if (!json)
|
|
1933
|
+
console.log(
|
|
1934
|
+
`[deploy] git status=${matched.status} deploymentId=${matched.deploymentId || 'n/a'}`
|
|
1935
|
+
);
|
|
1936
|
+
}
|
|
1937
|
+
if (
|
|
1938
|
+
matched &&
|
|
1939
|
+
['successful', 'failed', 'cancelled'].includes(
|
|
1940
|
+
String(matched.status || '').toLowerCase()
|
|
1941
|
+
)
|
|
1942
|
+
)
|
|
1943
|
+
break;
|
|
1944
|
+
await sleep(pollSeconds * 1000);
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
const final = matched || trigger;
|
|
1948
|
+
const finalStatus = String(final?.status || '').toLowerCase();
|
|
1949
|
+
const ok = finalStatus === 'successful';
|
|
1950
|
+
if (!['successful', 'failed', 'cancelled'].includes(finalStatus)) {
|
|
1951
|
+
const out = {
|
|
1952
|
+
ok: false,
|
|
1953
|
+
strategy: 'git',
|
|
1954
|
+
git_deploy: final,
|
|
1955
|
+
app,
|
|
1956
|
+
error: { message: 'Timed out waiting for git deployment' }
|
|
1957
|
+
};
|
|
1958
|
+
if (json) console.log(JSON.stringify(out));
|
|
1959
|
+
else console.error('[deploy] timed out waiting for git deployment');
|
|
1960
|
+
return 1;
|
|
1961
|
+
}
|
|
1962
|
+
const out = { ok, strategy: 'git', git_deploy: final, app };
|
|
1963
|
+
if (json) console.log(JSON.stringify(out));
|
|
1964
|
+
return ok ? 0 : 1;
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
// restart strategy
|
|
1968
|
+
const dep = await deploy.restartStackServices(app.stackId);
|
|
1969
|
+
if (!dep?.id) {
|
|
1970
|
+
const msg =
|
|
1971
|
+
'Failed to start deployment (restartStackServices returned no id)';
|
|
1972
|
+
if (json)
|
|
1973
|
+
console.log(JSON.stringify({ ok: false, error: { message: msg } }));
|
|
1974
|
+
else console.error(msg);
|
|
1975
|
+
return 1;
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
if (!watch) {
|
|
1979
|
+
const out = { ok: true, strategy: 'restart', deployment: dep, app };
|
|
1980
|
+
if (json) console.log(JSON.stringify(out));
|
|
1981
|
+
else
|
|
1982
|
+
console.log(`[deploy] started deployment ${dep.id} status=${dep.status}`);
|
|
1983
|
+
return 0;
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
const deadline = Date.now() + timeoutMinutes * 60_000;
|
|
1987
|
+
let last = null;
|
|
1988
|
+
while (Date.now() < deadline) {
|
|
1989
|
+
const current = await deploy.getDeployment(dep.id);
|
|
1990
|
+
if (
|
|
1991
|
+
current &&
|
|
1992
|
+
(current.status !== last?.status ||
|
|
1993
|
+
current.step !== last?.step ||
|
|
1994
|
+
current.progressPct !== last?.progressPct)
|
|
1995
|
+
) {
|
|
1996
|
+
last = current;
|
|
1997
|
+
if (!json) {
|
|
1998
|
+
console.log(
|
|
1999
|
+
`[deploy] ${current.id} status=${current.status} step=${current.step} progress=${current.progressPct}%`
|
|
2000
|
+
);
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
const status = String(current?.status || '').toLowerCase();
|
|
2004
|
+
if (current && ['successful', 'failed', 'cancelled'].includes(status)) {
|
|
2005
|
+
const out = {
|
|
2006
|
+
ok: status === 'successful',
|
|
2007
|
+
strategy: 'restart',
|
|
2008
|
+
deployment: current,
|
|
2009
|
+
app
|
|
2010
|
+
};
|
|
2011
|
+
if (json) console.log(JSON.stringify(out));
|
|
2012
|
+
return out.ok ? 0 : 1;
|
|
2013
|
+
}
|
|
2014
|
+
await sleep(pollSeconds * 1000);
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
const out = {
|
|
2018
|
+
ok: false,
|
|
2019
|
+
error: { message: 'Timed out waiting for deployment' },
|
|
2020
|
+
deployment_id: dep.id
|
|
2021
|
+
};
|
|
2022
|
+
if (json) console.log(JSON.stringify(out));
|
|
2023
|
+
else console.error(out.error.message);
|
|
2024
|
+
return 1;
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
// -------------------------
|
|
2028
|
+
// Logs follow (Unified Logs)
|
|
2029
|
+
// -------------------------
|
|
2030
|
+
async function runLogsFollow(rest) {
|
|
2031
|
+
const f = parseKv(rest);
|
|
2032
|
+
const appId = f.app || f['app-id'] || (f._ && f._[0]);
|
|
2033
|
+
const since = f.since || '10m';
|
|
2034
|
+
const level = f.level || 'all';
|
|
2035
|
+
const q = f.q || null;
|
|
2036
|
+
const pollMs = Number(f['poll-ms'] || 2000);
|
|
2037
|
+
const durationSeconds =
|
|
2038
|
+
f['duration-seconds'] !== undefined ? Number(f['duration-seconds']) : null;
|
|
2039
|
+
const maxLines = f['max-lines'] !== undefined ? Number(f['max-lines']) : null;
|
|
2040
|
+
const json = parseBool(f.json, false);
|
|
2041
|
+
|
|
2042
|
+
if (!appId) {
|
|
2043
|
+
console.error('--app <appId> is required');
|
|
2044
|
+
return 2;
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
try {
|
|
2048
|
+
const t = getStoredBearer();
|
|
2049
|
+
if (t && !process.env.SCALY_API_BEARER) process.env.SCALY_API_BEARER = t;
|
|
2050
|
+
} catch {}
|
|
2051
|
+
|
|
2052
|
+
const logs = require('../lib/scaly-logs');
|
|
2053
|
+
const app = await logs.getAppBasic(appId);
|
|
2054
|
+
if (!app) {
|
|
2055
|
+
console.error(`App not found: ${appId}`);
|
|
2056
|
+
return 2;
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
const lookbackHours = logs.parseTimeRangeToLookbackHours(since);
|
|
2060
|
+
if (!lookbackHours) {
|
|
2061
|
+
console.error('Invalid --since; expected like 15m, 1h, 1d');
|
|
2062
|
+
return 2;
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
const deadline =
|
|
2066
|
+
durationSeconds && Number.isFinite(durationSeconds) && durationSeconds > 0
|
|
2067
|
+
? Date.now() + durationSeconds * 1000
|
|
2068
|
+
: null;
|
|
2069
|
+
const shouldStopByLines =
|
|
2070
|
+
maxLines && Number.isFinite(maxLines) && maxLines > 0 ? maxLines : null;
|
|
2071
|
+
let printed = 0;
|
|
2072
|
+
|
|
2073
|
+
let liveFromTs = null;
|
|
2074
|
+
// Initial batch
|
|
2075
|
+
const first = await logs.getUnifiedLogs({
|
|
2076
|
+
accountId: app.accountId,
|
|
2077
|
+
appIds: [appId],
|
|
2078
|
+
lookbackHours,
|
|
2079
|
+
liveFromTs: undefined,
|
|
2080
|
+
limit: 200,
|
|
2081
|
+
q,
|
|
2082
|
+
level,
|
|
2083
|
+
pageToken: undefined
|
|
2084
|
+
});
|
|
2085
|
+
const seen = new Set();
|
|
2086
|
+
const seenQueue = [];
|
|
2087
|
+
const maxSeen = 5000;
|
|
2088
|
+
const remember = (id) => {
|
|
2089
|
+
if (seen.has(id)) return false;
|
|
2090
|
+
seen.add(id);
|
|
2091
|
+
seenQueue.push(id);
|
|
2092
|
+
while (seenQueue.length > maxSeen) {
|
|
2093
|
+
const old = seenQueue.shift();
|
|
2094
|
+
if (old) seen.delete(old);
|
|
2095
|
+
}
|
|
2096
|
+
return true;
|
|
2097
|
+
};
|
|
2098
|
+
if (first && Array.isArray(first.events)) {
|
|
2099
|
+
for (const e of first.events) {
|
|
2100
|
+
if (!remember(e.id)) continue;
|
|
2101
|
+
if (json) console.log(JSON.stringify(e));
|
|
2102
|
+
else console.log(`${e.ts} ${e.level || 'Unknown'} ${e.message}`);
|
|
2103
|
+
printed++;
|
|
2104
|
+
if (!liveFromTs || e.ts > liveFromTs) liveFromTs = e.ts;
|
|
2105
|
+
if (shouldStopByLines && printed >= shouldStopByLines) return 0;
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
if (!liveFromTs) liveFromTs = new Date().toISOString();
|
|
2109
|
+
|
|
2110
|
+
// Tail loop
|
|
2111
|
+
// eslint-disable-next-line no-constant-condition
|
|
2112
|
+
while (true) {
|
|
2113
|
+
if (deadline && Date.now() >= deadline) return 0;
|
|
2114
|
+
const res = await logs.getUnifiedLogs({
|
|
2115
|
+
accountId: app.accountId,
|
|
2116
|
+
appIds: [appId],
|
|
2117
|
+
lookbackHours: undefined,
|
|
2118
|
+
liveFromTs,
|
|
2119
|
+
limit: 200,
|
|
2120
|
+
q,
|
|
2121
|
+
level,
|
|
2122
|
+
pageToken: undefined
|
|
2123
|
+
});
|
|
2124
|
+
if (res && Array.isArray(res.events) && res.events.length) {
|
|
2125
|
+
for (const e of res.events) {
|
|
2126
|
+
if (!remember(e.id)) continue;
|
|
2127
|
+
if (json) console.log(JSON.stringify(e));
|
|
2128
|
+
else console.log(`${e.ts} ${e.level || 'Unknown'} ${e.message}`);
|
|
2129
|
+
printed++;
|
|
2130
|
+
if (e.ts > liveFromTs) liveFromTs = e.ts;
|
|
2131
|
+
if (shouldStopByLines && printed >= shouldStopByLines) return 0;
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
2134
|
+
await sleep(pollMs);
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
// -------------------------
|
|
2139
|
+
// Secrets (App Build Secrets)
|
|
2140
|
+
// -------------------------
|
|
2141
|
+
async function runSecrets(sub, rest) {
|
|
2142
|
+
const f = parseKv(rest);
|
|
2143
|
+
const json = parseBool(f.json, false);
|
|
2144
|
+
|
|
2145
|
+
// OIDC-only for build secrets.
|
|
2146
|
+
try {
|
|
2147
|
+
const t = getStoredBearer();
|
|
2148
|
+
if (t && !process.env.SCALY_API_BEARER) process.env.SCALY_API_BEARER = t;
|
|
2149
|
+
} catch {}
|
|
2150
|
+
|
|
2151
|
+
const bearer =
|
|
2152
|
+
process.env.SCALY_API_BEARER ||
|
|
2153
|
+
process.env.API_BEARER_TOKEN ||
|
|
2154
|
+
process.env.API_BEARER;
|
|
2155
|
+
if (!bearer || !isProbablyJwt(bearer)) {
|
|
2156
|
+
const msg =
|
|
2157
|
+
'secrets commands require a Cognito OIDC access token (JWT). ' +
|
|
2158
|
+
'Run `scaly login --token <access_token_jwt>` first.';
|
|
2159
|
+
if (json)
|
|
2160
|
+
console.log(JSON.stringify({ ok: false, error: { message: msg } }));
|
|
2161
|
+
else console.error(msg);
|
|
2162
|
+
return 2;
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
const appId = f.app || f['app-id'] || (f._ && f._[0]);
|
|
2166
|
+
if (!appId) {
|
|
2167
|
+
const msg = '--app <appId> is required';
|
|
2168
|
+
if (json)
|
|
2169
|
+
console.log(JSON.stringify({ ok: false, error: { message: msg } }));
|
|
2170
|
+
else console.error(msg);
|
|
2171
|
+
return 2;
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
const secrets = require('../lib/scaly-secrets');
|
|
2175
|
+
const { loadScalyConfig } = require('../lib/scaly-project');
|
|
2176
|
+
|
|
2177
|
+
if (sub === 'list') {
|
|
2178
|
+
const items = await secrets.listBuildSecrets(appId);
|
|
2179
|
+
const out = {
|
|
2180
|
+
ok: true,
|
|
2181
|
+
app_id: appId,
|
|
2182
|
+
secrets: items,
|
|
2183
|
+
count: items.length
|
|
2184
|
+
};
|
|
2185
|
+
if (json) console.log(JSON.stringify(out));
|
|
2186
|
+
else {
|
|
2187
|
+
for (const s of items) console.log(`${s.name}\t${s.id}`);
|
|
2188
|
+
}
|
|
2189
|
+
return 0;
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
if (sub === 'set') {
|
|
2193
|
+
const name = f.name;
|
|
2194
|
+
const fromEnv = f['from-env'] || f.from_env;
|
|
2195
|
+
const stdin = parseBool(f.stdin, false);
|
|
2196
|
+
if (!name) {
|
|
2197
|
+
const msg = '--name <KEY> is required';
|
|
2198
|
+
if (json)
|
|
2199
|
+
console.log(JSON.stringify({ ok: false, error: { message: msg } }));
|
|
2200
|
+
else console.error(msg);
|
|
2201
|
+
return 2;
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
let value = null;
|
|
2205
|
+
if (fromEnv) {
|
|
2206
|
+
value = process.env[String(fromEnv)] ?? null;
|
|
2207
|
+
if (value === null) {
|
|
2208
|
+
const msg = `Environment variable not set: ${fromEnv}`;
|
|
2209
|
+
if (json)
|
|
2210
|
+
console.log(JSON.stringify({ ok: false, error: { message: msg } }));
|
|
2211
|
+
else console.error(msg);
|
|
2212
|
+
return 2;
|
|
2213
|
+
}
|
|
2214
|
+
} else if (stdin) {
|
|
2215
|
+
value = String(await readStdinAll()).replace(/\r?\n$/, '');
|
|
2216
|
+
} else {
|
|
2217
|
+
value = await promptHidden(`Value for ${name}: `);
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
const res = await secrets.setBuildSecret({ appId, name, value });
|
|
2221
|
+
const out = {
|
|
2222
|
+
ok: true,
|
|
2223
|
+
app_id: appId,
|
|
2224
|
+
action: res.action,
|
|
2225
|
+
name,
|
|
2226
|
+
id: res.secret?.id || null
|
|
2227
|
+
};
|
|
2228
|
+
if (json) console.log(JSON.stringify(out));
|
|
2229
|
+
else console.log(`[secrets] ${res.action} ${name}`);
|
|
2230
|
+
return 0;
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
if (sub === 'delete') {
|
|
2234
|
+
const id = f.id || null;
|
|
2235
|
+
const name = f.name || null;
|
|
2236
|
+
const yes = parseBool(f.yes, false);
|
|
2237
|
+
if (!id && !name) {
|
|
2238
|
+
const msg = 'delete requires --id <secretId> or --name <KEY>';
|
|
2239
|
+
if (json)
|
|
2240
|
+
console.log(JSON.stringify({ ok: false, error: { message: msg } }));
|
|
2241
|
+
else console.error(msg);
|
|
2242
|
+
return 2;
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
let secretId = id;
|
|
2246
|
+
if (!secretId) {
|
|
2247
|
+
const items = await secrets.listBuildSecrets(appId);
|
|
2248
|
+
const match = items.find((s) => s.name === name);
|
|
2249
|
+
if (!match) {
|
|
2250
|
+
const msg = `Secret not found: ${name}`;
|
|
2251
|
+
if (json)
|
|
2252
|
+
console.log(JSON.stringify({ ok: false, error: { message: msg } }));
|
|
2253
|
+
else console.error(msg);
|
|
2254
|
+
return 2;
|
|
2255
|
+
}
|
|
2256
|
+
secretId = match.id;
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
const proceed = yes
|
|
2260
|
+
? true
|
|
2261
|
+
: await promptYesNo(`Delete secret ${name || secretId}? (y/N) `);
|
|
2262
|
+
if (!proceed) {
|
|
2263
|
+
const out = { ok: false, aborted: true };
|
|
2264
|
+
if (json) console.log(JSON.stringify(out));
|
|
2265
|
+
else console.error('Aborted.');
|
|
2266
|
+
return 1;
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
await secrets.deleteBuildSecret({ id: secretId });
|
|
2270
|
+
const out = { ok: true, deleted: true, id: secretId };
|
|
2271
|
+
if (json) console.log(JSON.stringify(out));
|
|
2272
|
+
else console.log('[secrets] deleted');
|
|
2273
|
+
return 0;
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
if (sub === 'sync') {
|
|
2277
|
+
const env = f.env || null;
|
|
2278
|
+
const apply = parseBool(f.apply, false);
|
|
2279
|
+
const dryRun =
|
|
2280
|
+
f['dry-run'] !== undefined ? parseBool(f['dry-run'], true) : !apply;
|
|
2281
|
+
const configAppName =
|
|
2282
|
+
f['config-app'] || f.config_app || f['app-name'] || f.app_name || null;
|
|
2283
|
+
|
|
2284
|
+
let loaded;
|
|
2285
|
+
try {
|
|
2286
|
+
loaded = loadScalyConfig({ cwd: process.cwd(), env });
|
|
2287
|
+
} catch (err) {
|
|
2288
|
+
const msg = String(err && err.message ? err.message : err);
|
|
2289
|
+
if (json)
|
|
2290
|
+
console.log(JSON.stringify({ ok: false, error: { message: msg } }));
|
|
2291
|
+
else console.error(msg);
|
|
2292
|
+
return 2;
|
|
2293
|
+
}
|
|
2294
|
+
if (!loaded.validation.ok) {
|
|
2295
|
+
const msg = 'Invalid .scaly/config.yaml';
|
|
2296
|
+
if (json)
|
|
2297
|
+
console.log(
|
|
2298
|
+
JSON.stringify({
|
|
2299
|
+
ok: false,
|
|
2300
|
+
error: { message: msg },
|
|
2301
|
+
details: loaded.validation.errors
|
|
2302
|
+
})
|
|
2303
|
+
);
|
|
2304
|
+
else {
|
|
2305
|
+
console.error(msg);
|
|
2306
|
+
for (const e of loaded.validation.errors) console.error(`- ${e}`);
|
|
2307
|
+
}
|
|
2308
|
+
return 2;
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
const apps = loaded.config.apps || [];
|
|
2312
|
+
const appsWithSecrets = apps.filter(
|
|
2313
|
+
(a) =>
|
|
2314
|
+
a &&
|
|
2315
|
+
typeof a === 'object' &&
|
|
2316
|
+
Array.isArray(a.secrets) &&
|
|
2317
|
+
a.secrets.length
|
|
2318
|
+
);
|
|
2319
|
+
|
|
2320
|
+
let selectedApp = null;
|
|
2321
|
+
if (configAppName) {
|
|
2322
|
+
selectedApp = apps.find((a) => a && a.name === configAppName) || null;
|
|
2323
|
+
if (!selectedApp) {
|
|
2324
|
+
const available = apps
|
|
2325
|
+
.map((a) => a && a.name)
|
|
2326
|
+
.filter(Boolean)
|
|
2327
|
+
.join(', ');
|
|
2328
|
+
const msg = `Unknown --config-app ${configAppName}. Available: ${available || '(none)'}`;
|
|
2329
|
+
if (json)
|
|
2330
|
+
console.log(JSON.stringify({ ok: false, error: { message: msg } }));
|
|
2331
|
+
else console.error(msg);
|
|
2332
|
+
return 2;
|
|
2333
|
+
}
|
|
2334
|
+
} else if (apps.length === 1) {
|
|
2335
|
+
selectedApp = apps[0];
|
|
2336
|
+
} else if (apps.some((a) => a && a.name === appId)) {
|
|
2337
|
+
// Convenience: if user passed an app name (not id) to --app, treat it as config app selector.
|
|
2338
|
+
selectedApp = apps.find((a) => a && a.name === appId) || null;
|
|
2339
|
+
} else if (appsWithSecrets.length === 1) {
|
|
2340
|
+
selectedApp = appsWithSecrets[0];
|
|
2341
|
+
} else {
|
|
2342
|
+
const available = apps
|
|
2343
|
+
.map((a) => a && a.name)
|
|
2344
|
+
.filter(Boolean)
|
|
2345
|
+
.join(', ');
|
|
2346
|
+
const msg =
|
|
2347
|
+
`Multiple apps in .scaly/config.yaml. Use --config-app <name> to select which app's secrets to sync ` +
|
|
2348
|
+
`to --app ${appId}. Available: ${available || '(none)'}`;
|
|
2349
|
+
if (json)
|
|
2350
|
+
console.log(JSON.stringify({ ok: false, error: { message: msg } }));
|
|
2351
|
+
else console.error(msg);
|
|
2352
|
+
return 2;
|
|
2353
|
+
}
|
|
2354
|
+
|
|
2355
|
+
const wantedSecrets = [];
|
|
2356
|
+
if (selectedApp && Array.isArray(selectedApp.secrets)) {
|
|
2357
|
+
for (const s of selectedApp.secrets) {
|
|
2358
|
+
if (!s || typeof s !== 'object') continue;
|
|
2359
|
+
if (!s.name) continue;
|
|
2360
|
+
wantedSecrets.push({ name: s.name, source_env: s.source_env || null });
|
|
2361
|
+
}
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2364
|
+
const actions = [];
|
|
2365
|
+
for (const s of wantedSecrets) {
|
|
2366
|
+
const source = s.source_env;
|
|
2367
|
+
const present = source
|
|
2368
|
+
? Object.prototype.hasOwnProperty.call(process.env, source)
|
|
2369
|
+
: false;
|
|
2370
|
+
actions.push({
|
|
2371
|
+
name: s.name,
|
|
2372
|
+
source_env: source,
|
|
2373
|
+
present,
|
|
2374
|
+
action: present ? (dryRun ? 'would_set' : 'set') : 'skipped'
|
|
2375
|
+
});
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
if (!dryRun) {
|
|
2379
|
+
for (const a of actions) {
|
|
2380
|
+
if (a.action !== 'set') continue;
|
|
2381
|
+
const value = process.env[a.source_env];
|
|
2382
|
+
await secrets.setBuildSecret({ appId, name: a.name, value });
|
|
2383
|
+
}
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
const out = {
|
|
2387
|
+
ok: true,
|
|
2388
|
+
app_id: appId,
|
|
2389
|
+
config_app: selectedApp ? selectedApp.name : null,
|
|
2390
|
+
dry_run: dryRun,
|
|
2391
|
+
actions
|
|
2392
|
+
};
|
|
2393
|
+
if (json) console.log(JSON.stringify(out));
|
|
2394
|
+
else {
|
|
2395
|
+
for (const a of actions) {
|
|
2396
|
+
const tag =
|
|
2397
|
+
a.action === 'skipped' ? 'MISSING_ENV' : a.action.toUpperCase();
|
|
2398
|
+
console.log(
|
|
2399
|
+
`[secrets] ${tag} ${a.name}${a.source_env ? ` (${a.source_env})` : ''}`
|
|
2400
|
+
);
|
|
2401
|
+
}
|
|
2402
|
+
if (dryRun)
|
|
2403
|
+
console.log('[secrets] dry-run only. Re-run with --apply to write.');
|
|
2404
|
+
}
|
|
2405
|
+
return 0;
|
|
2406
|
+
}
|
|
2407
|
+
|
|
2408
|
+
console.error('Usage: scaly secrets <list|set|delete|sync> ...');
|
|
2409
|
+
return 2;
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
// -------------------------
|
|
2413
|
+
// DB tunnel: localhost port-forward via WSS
|
|
2414
|
+
// -------------------------
|
|
2415
|
+
async function mintDbProxyInfo(rest) {
|
|
2416
|
+
const f = parseKv(rest);
|
|
2417
|
+
const addOnId = f.addon || f['add-on'] || (f._ && f._[0]);
|
|
2418
|
+
if (!addOnId) {
|
|
2419
|
+
const e = new Error('db requires --addon <addOnId>');
|
|
2420
|
+
e.code = 'SCALY_DB_ADDON_REQUIRED';
|
|
2421
|
+
throw e;
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
const { getToken, apiPost, deriveDomain } = await importHelpers();
|
|
2425
|
+
const bearer = await getToken();
|
|
2426
|
+
if (!isProbablyJwt(bearer)) {
|
|
2427
|
+
const e = new Error(
|
|
2428
|
+
'db commands require a Cognito OIDC access token (JWT) in Authorization: Bearer.'
|
|
2429
|
+
);
|
|
2430
|
+
e.code = 'SCALY_DB_JWT_REQUIRED';
|
|
2431
|
+
throw e;
|
|
2432
|
+
}
|
|
2433
|
+
|
|
2434
|
+
const ttlMinutes =
|
|
2435
|
+
f['ttl-minutes'] !== undefined ? Number(f['ttl-minutes']) : undefined;
|
|
2436
|
+
const tokenRes = await apiPost(bearer, CREATE_DATABASE_PROXY_TOKEN_MUTATION, {
|
|
2437
|
+
addOnId,
|
|
2438
|
+
ttlMinutes
|
|
2439
|
+
});
|
|
2440
|
+
const info = tokenRes?.createDatabaseProxyToken;
|
|
2441
|
+
if (!info?.token) {
|
|
2442
|
+
const e = new Error('Could not mint database proxy token for add-on');
|
|
2443
|
+
e.code = 'SCALY_DB_TOKEN_FAILED';
|
|
2444
|
+
throw e;
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2447
|
+
const domain = deriveDomain();
|
|
2448
|
+
const tunnelUrl = `wss://dbt.${domain}`;
|
|
2449
|
+
|
|
2450
|
+
return { f, addOnId, bearer, info, tunnelUrl };
|
|
2451
|
+
}
|
|
2452
|
+
|
|
2453
|
+
async function startDbTunnelServer({ bearer, tunnelUrl, host, localPort }) {
|
|
2454
|
+
const net = require('net');
|
|
2455
|
+
const { WebSocket, createWebSocketStream } = require('ws');
|
|
2456
|
+
const { pipeline } = require('stream');
|
|
2457
|
+
|
|
2458
|
+
const server = net.createServer((socket) => {
|
|
2459
|
+
socket.setNoDelay(true);
|
|
2460
|
+
|
|
2461
|
+
const ws = new WebSocket(tunnelUrl, {
|
|
2462
|
+
headers: {
|
|
2463
|
+
authorization: `Bearer ${bearer}`
|
|
2464
|
+
}
|
|
2465
|
+
});
|
|
2466
|
+
|
|
2467
|
+
let closed = false;
|
|
2468
|
+
const closeAll = () => {
|
|
2469
|
+
if (closed) return;
|
|
2470
|
+
closed = true;
|
|
2471
|
+
try {
|
|
2472
|
+
socket.destroy();
|
|
2473
|
+
} catch {}
|
|
2474
|
+
try {
|
|
2475
|
+
ws.terminate();
|
|
2476
|
+
} catch {}
|
|
2477
|
+
};
|
|
2478
|
+
|
|
2479
|
+
const done = () => closeAll();
|
|
2480
|
+
const wsStream = createWebSocketStream(ws);
|
|
2481
|
+
pipeline(socket, wsStream, done);
|
|
2482
|
+
pipeline(wsStream, socket, done);
|
|
2483
|
+
|
|
2484
|
+
ws.on('close', done);
|
|
2485
|
+
ws.on('error', done);
|
|
2486
|
+
socket.on('close', done);
|
|
2487
|
+
socket.on('error', done);
|
|
2488
|
+
});
|
|
2489
|
+
|
|
2490
|
+
await new Promise((resolve, reject) => {
|
|
2491
|
+
server.once('error', reject);
|
|
2492
|
+
server.listen(localPort, host, resolve);
|
|
2493
|
+
});
|
|
2494
|
+
|
|
2495
|
+
return server;
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2498
|
+
async function runDbConnect(rest) {
|
|
2499
|
+
const f = parseKv(rest);
|
|
2500
|
+
const json = parseBool(f.json, false);
|
|
2501
|
+
const show = parseBool(f.show, false);
|
|
2502
|
+
const copy = f.copy !== undefined ? parseBool(f.copy, true) : !show;
|
|
2503
|
+
|
|
2504
|
+
let minted;
|
|
2505
|
+
try {
|
|
2506
|
+
minted = await mintDbProxyInfo(rest);
|
|
2507
|
+
} catch (e) {
|
|
2508
|
+
const msg = String(e && e.message ? e.message : e);
|
|
2509
|
+
if (json)
|
|
2510
|
+
console.log(JSON.stringify({ ok: false, error: { message: msg } }));
|
|
2511
|
+
else console.error(msg);
|
|
2512
|
+
return 2;
|
|
2513
|
+
}
|
|
2514
|
+
const { addOnId, bearer, info, tunnelUrl } = minted;
|
|
2515
|
+
|
|
2516
|
+
const host = f.host || '127.0.0.1';
|
|
2517
|
+
const localPort = Number(f['local-port'] || info.port || 5432);
|
|
2518
|
+
|
|
2519
|
+
const server = await startDbTunnelServer({
|
|
2520
|
+
bearer,
|
|
2521
|
+
tunnelUrl,
|
|
2522
|
+
host,
|
|
2523
|
+
localPort
|
|
2524
|
+
});
|
|
2525
|
+
|
|
2526
|
+
const psqlCmd = `PGSSLMODE=require PGPASSWORD='${info.token}' psql -h ${host} -p ${localPort} -U ${info.username} -d ${info.database}`;
|
|
2527
|
+
let copied = false;
|
|
2528
|
+
if (copy && !show) {
|
|
2529
|
+
copied = copyToClipboard(psqlCmd);
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
if (json) {
|
|
2533
|
+
console.log(
|
|
2534
|
+
JSON.stringify({
|
|
2535
|
+
ok: true,
|
|
2536
|
+
addon_id: addOnId,
|
|
2537
|
+
host,
|
|
2538
|
+
local_port: localPort,
|
|
2539
|
+
username: info.username,
|
|
2540
|
+
database: info.database,
|
|
2541
|
+
expires_at: info.expiresAt,
|
|
2542
|
+
copied,
|
|
2543
|
+
show_supported: true
|
|
2544
|
+
})
|
|
2545
|
+
);
|
|
2546
|
+
} else {
|
|
2547
|
+
console.log(`[db connect] addOn=${addOnId}`);
|
|
2548
|
+
console.log(
|
|
2549
|
+
`[db connect] tunnel=${tunnelUrl} -> localhost ${host}:${localPort}`
|
|
2550
|
+
);
|
|
2551
|
+
console.log(`[db connect] token expiresAt=${info.expiresAt}`);
|
|
2552
|
+
console.log('');
|
|
2553
|
+
if (show) {
|
|
2554
|
+
console.log(psqlCmd);
|
|
2555
|
+
console.log('');
|
|
2556
|
+
} else if (copy) {
|
|
2557
|
+
console.log(
|
|
2558
|
+
copied
|
|
2559
|
+
? '[db connect] copied psql command to clipboard (use --show to print)'
|
|
2560
|
+
: '[db connect] could not copy to clipboard; re-run with --show to print'
|
|
2561
|
+
);
|
|
2562
|
+
console.log('');
|
|
2563
|
+
}
|
|
2564
|
+
}
|
|
2565
|
+
|
|
2566
|
+
if (!json) {
|
|
2567
|
+
console.log(
|
|
2568
|
+
`[db connect] listening on ${host}:${localPort} (Ctrl-C to stop)`
|
|
2569
|
+
);
|
|
2570
|
+
}
|
|
2571
|
+
|
|
2572
|
+
const closeServer = () => {
|
|
2573
|
+
try {
|
|
2574
|
+
server.close(() => process.exit(0));
|
|
2575
|
+
} catch {
|
|
2576
|
+
process.exit(0);
|
|
2577
|
+
}
|
|
2578
|
+
};
|
|
2579
|
+
process.once('SIGINT', closeServer);
|
|
2580
|
+
process.once('SIGTERM', closeServer);
|
|
2581
|
+
await new Promise(() => {});
|
|
2582
|
+
}
|
|
2583
|
+
|
|
2584
|
+
async function runDbShell(rest) {
|
|
2585
|
+
const { spawnSync } = require('child_process');
|
|
2586
|
+
const f = parseKv(rest);
|
|
2587
|
+
let minted;
|
|
2588
|
+
try {
|
|
2589
|
+
minted = await mintDbProxyInfo(rest);
|
|
2590
|
+
} catch (e) {
|
|
2591
|
+
console.error(String(e && e.message ? e.message : e));
|
|
2592
|
+
return 2;
|
|
2593
|
+
}
|
|
2594
|
+
const { addOnId, bearer, info, tunnelUrl } = minted;
|
|
2595
|
+
const host = f.host || '127.0.0.1';
|
|
2596
|
+
const localPort = Number(f['local-port'] || info.port || 5432);
|
|
2597
|
+
const server = await startDbTunnelServer({
|
|
2598
|
+
bearer,
|
|
2599
|
+
tunnelUrl,
|
|
2600
|
+
host,
|
|
2601
|
+
localPort
|
|
2602
|
+
});
|
|
2603
|
+
|
|
2604
|
+
const env = {
|
|
2605
|
+
...process.env,
|
|
2606
|
+
PGPASSWORD: info.token,
|
|
2607
|
+
PGSSLMODE: 'require'
|
|
2608
|
+
};
|
|
2609
|
+
|
|
2610
|
+
console.log(`[db shell] addOn=${addOnId} on ${host}:${localPort}`);
|
|
2611
|
+
const res = spawnSync(
|
|
2612
|
+
'psql',
|
|
2613
|
+
[
|
|
2614
|
+
'-h',
|
|
2615
|
+
host,
|
|
2616
|
+
'-p',
|
|
2617
|
+
String(localPort),
|
|
2618
|
+
'-U',
|
|
2619
|
+
info.username,
|
|
2620
|
+
'-d',
|
|
2621
|
+
info.database
|
|
2622
|
+
],
|
|
2623
|
+
{
|
|
2624
|
+
stdio: 'inherit',
|
|
2625
|
+
env
|
|
2626
|
+
}
|
|
2627
|
+
);
|
|
2628
|
+
|
|
2629
|
+
try {
|
|
2630
|
+
server.close();
|
|
2631
|
+
} catch {}
|
|
2632
|
+
return typeof res.status === 'number' ? res.status : 1;
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2635
|
+
async function runDbSchemaDump(rest) {
|
|
2636
|
+
const { spawnSync } = require('child_process');
|
|
2637
|
+
const fs = require('fs');
|
|
2638
|
+
const path = require('path');
|
|
2639
|
+
const f = parseKv(rest);
|
|
2640
|
+
|
|
2641
|
+
const outPath = f.out || path.join('.scaly', 'schema.sql');
|
|
2642
|
+
|
|
2643
|
+
let minted;
|
|
2644
|
+
try {
|
|
2645
|
+
minted = await mintDbProxyInfo(rest);
|
|
2646
|
+
} catch (e) {
|
|
2647
|
+
console.error(String(e && e.message ? e.message : e));
|
|
2648
|
+
return 2;
|
|
2649
|
+
}
|
|
2650
|
+
const { addOnId, bearer, info, tunnelUrl } = minted;
|
|
2651
|
+
const host = f.host || '127.0.0.1';
|
|
2652
|
+
const localPort = Number(f['local-port'] || info.port || 5432);
|
|
2653
|
+
const server = await startDbTunnelServer({
|
|
2654
|
+
bearer,
|
|
2655
|
+
tunnelUrl,
|
|
2656
|
+
host,
|
|
2657
|
+
localPort
|
|
2658
|
+
});
|
|
2659
|
+
|
|
2660
|
+
const env = {
|
|
2661
|
+
...process.env,
|
|
2662
|
+
PGPASSWORD: info.token,
|
|
2663
|
+
PGSSLMODE: 'require'
|
|
2664
|
+
};
|
|
2665
|
+
|
|
2666
|
+
console.log(`[db schema dump] addOn=${addOnId} -> ${outPath}`);
|
|
2667
|
+
|
|
2668
|
+
const res = spawnSync(
|
|
2669
|
+
'pg_dump',
|
|
2670
|
+
[
|
|
2671
|
+
'--schema-only',
|
|
2672
|
+
'--no-owner',
|
|
2673
|
+
'--no-privileges',
|
|
2674
|
+
'-h',
|
|
2675
|
+
host,
|
|
2676
|
+
'-p',
|
|
2677
|
+
String(localPort),
|
|
2678
|
+
'-U',
|
|
2679
|
+
info.username,
|
|
2680
|
+
'-d',
|
|
2681
|
+
info.database
|
|
2682
|
+
],
|
|
2683
|
+
{ encoding: 'utf8', env }
|
|
2684
|
+
);
|
|
2685
|
+
|
|
2686
|
+
try {
|
|
2687
|
+
server.close();
|
|
2688
|
+
} catch {}
|
|
2689
|
+
|
|
2690
|
+
if (res.status !== 0) {
|
|
2691
|
+
console.error(res.stderr || '[db schema dump] pg_dump failed');
|
|
2692
|
+
return res.status || 1;
|
|
2693
|
+
}
|
|
2694
|
+
|
|
2695
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
2696
|
+
fs.writeFileSync(outPath, res.stdout, 'utf8');
|
|
2697
|
+
console.log(`[db schema dump] wrote ${outPath}`);
|
|
2698
|
+
return 0;
|
|
2699
|
+
}
|
|
2700
|
+
|
|
2701
|
+
async function runDbMigrate(rest) {
|
|
2702
|
+
const { spawnSync } = require('child_process');
|
|
2703
|
+
const fs = require('fs');
|
|
2704
|
+
const path = require('path');
|
|
2705
|
+
const f = parseKv(rest);
|
|
2706
|
+
const file = (f._ && f._[0]) || null;
|
|
2707
|
+
if (!file) {
|
|
2708
|
+
console.error(
|
|
2709
|
+
'Usage: scaly db migrate <sql-file> --addon <addOnId> [--yes]'
|
|
2710
|
+
);
|
|
2711
|
+
return 2;
|
|
2712
|
+
}
|
|
2713
|
+
const yes = parseBool(f.yes, false);
|
|
2714
|
+
|
|
2715
|
+
const abs = path.resolve(file);
|
|
2716
|
+
if (!fs.existsSync(abs)) {
|
|
2717
|
+
console.error(`Migration file not found: ${abs}`);
|
|
2718
|
+
return 2;
|
|
2719
|
+
}
|
|
2720
|
+
if (!yes) {
|
|
2721
|
+
const ok = await promptYesNo(`Apply migration ${abs}? (y/N) `);
|
|
2722
|
+
if (!ok) {
|
|
2723
|
+
console.error('Aborted.');
|
|
2724
|
+
return 1;
|
|
2725
|
+
}
|
|
2726
|
+
}
|
|
2727
|
+
|
|
2728
|
+
let minted;
|
|
2729
|
+
try {
|
|
2730
|
+
minted = await mintDbProxyInfo(rest);
|
|
2731
|
+
} catch (e) {
|
|
2732
|
+
console.error(String(e && e.message ? e.message : e));
|
|
2733
|
+
return 2;
|
|
2734
|
+
}
|
|
2735
|
+
const { addOnId, bearer, info, tunnelUrl } = minted;
|
|
2736
|
+
const host = f.host || '127.0.0.1';
|
|
2737
|
+
const localPort = Number(f['local-port'] || info.port || 5432);
|
|
2738
|
+
const server = await startDbTunnelServer({
|
|
2739
|
+
bearer,
|
|
2740
|
+
tunnelUrl,
|
|
2741
|
+
host,
|
|
2742
|
+
localPort
|
|
2743
|
+
});
|
|
2744
|
+
|
|
2745
|
+
const env = {
|
|
2746
|
+
...process.env,
|
|
2747
|
+
PGPASSWORD: info.token,
|
|
2748
|
+
PGSSLMODE: 'require'
|
|
2749
|
+
};
|
|
2750
|
+
|
|
2751
|
+
console.log(`[db migrate] addOn=${addOnId} file=${abs}`);
|
|
2752
|
+
const res = spawnSync(
|
|
2753
|
+
'psql',
|
|
2754
|
+
[
|
|
2755
|
+
'-v',
|
|
2756
|
+
'ON_ERROR_STOP=1',
|
|
2757
|
+
'-h',
|
|
2758
|
+
host,
|
|
2759
|
+
'-p',
|
|
2760
|
+
String(localPort),
|
|
2761
|
+
'-U',
|
|
2762
|
+
info.username,
|
|
2763
|
+
'-d',
|
|
2764
|
+
info.database,
|
|
2765
|
+
'-f',
|
|
2766
|
+
abs
|
|
2767
|
+
],
|
|
2768
|
+
{ stdio: 'inherit', env }
|
|
2769
|
+
);
|
|
2770
|
+
|
|
2771
|
+
try {
|
|
2772
|
+
server.close();
|
|
2773
|
+
} catch {}
|
|
2774
|
+
|
|
2775
|
+
return typeof res.status === 'number' ? res.status : 1;
|
|
2776
|
+
}
|
|
2777
|
+
|
|
2778
|
+
// -------------------------
|
|
2779
|
+
// Insights: diagnose-app
|
|
2780
|
+
// -------------------------
|
|
2781
|
+
async function runDiagnoseApp(rest) {
|
|
2782
|
+
const f = parseKv(rest);
|
|
2783
|
+
const accountId = f.account;
|
|
2784
|
+
const stackId = f.stack;
|
|
2785
|
+
const appName = f.app || (f._ && f._[0]);
|
|
2786
|
+
if (!accountId || !stackId || !appName) {
|
|
2787
|
+
console.error(
|
|
2788
|
+
'diagnose-app requires --account <id> --stack <id> --app <name>'
|
|
2789
|
+
);
|
|
2790
|
+
return 2;
|
|
2791
|
+
}
|
|
2792
|
+
await sourceLocalEnv();
|
|
2793
|
+
const {
|
|
2794
|
+
getToken,
|
|
2795
|
+
apiPost,
|
|
2796
|
+
deriveDomain,
|
|
2797
|
+
regionToAws,
|
|
2798
|
+
checkHttp200,
|
|
2799
|
+
makeCreds,
|
|
2800
|
+
elbClient,
|
|
2801
|
+
ecsClient
|
|
2802
|
+
} = await importHelpers();
|
|
2803
|
+
const token = await getToken();
|
|
2804
|
+
|
|
2805
|
+
const stackRes = await apiPost(token, GET_STACK_QUERY, {
|
|
2806
|
+
where: { id: stackId }
|
|
2807
|
+
});
|
|
2808
|
+
const stack = stackRes?.getStack;
|
|
2809
|
+
const domain = deriveDomain();
|
|
2810
|
+
const accountPrefix = String(accountId).split('-')[0];
|
|
2811
|
+
const url = `https://${accountPrefix}-${stack?.name}.${domain}/${appName}/`;
|
|
2812
|
+
|
|
2813
|
+
// Probe URL
|
|
2814
|
+
const http = await checkHttp200(url, {
|
|
2815
|
+
tries: Number(f.tries) || 6,
|
|
2816
|
+
delayMs: Number(f['delay-ms']) || 5000
|
|
2817
|
+
});
|
|
2818
|
+
|
|
2819
|
+
// Pull stack logs for last N (default 30m)
|
|
2820
|
+
const lookback = f.since || '30m';
|
|
2821
|
+
const logsRes = await apiPost(token, GET_SCALY_STACK_LOGS, {
|
|
2822
|
+
where: { id: stackId, startTime: sinceToIso(lookback) }
|
|
2823
|
+
});
|
|
2824
|
+
const events = (logsRes?.getScalyStackLogs?.events || []).slice(-400);
|
|
2825
|
+
|
|
2826
|
+
const findings = [];
|
|
2827
|
+
const text = events.map((e) => e.message || '').join('\n');
|
|
2828
|
+
if (/fork\/exec\s+venv\/bin\/python: no such file/i.test(text)) {
|
|
2829
|
+
findings.push({
|
|
2830
|
+
code: 'APP_VENV_MISSING',
|
|
2831
|
+
severity: 'high',
|
|
2832
|
+
hint: 'Replicator/unpacker missing venv. Confirm rootfs contains venv and python shim.'
|
|
2833
|
+
});
|
|
2834
|
+
}
|
|
2835
|
+
if (
|
|
2836
|
+
/http:\s+proxy error: dial tcp 127\.0\.0\.1/i.test(text) ||
|
|
2837
|
+
/connection refused/i.test(text)
|
|
2838
|
+
) {
|
|
2839
|
+
findings.push({
|
|
2840
|
+
code: 'APP_PORT_REFUSED',
|
|
2841
|
+
severity: 'high',
|
|
2842
|
+
hint: 'App process not listening. Check start command and runtime.'
|
|
2843
|
+
});
|
|
2844
|
+
}
|
|
2845
|
+
if (/Unable to determine type of app/i.test(text)) {
|
|
2846
|
+
findings.push({
|
|
2847
|
+
code: 'APP_TYPE_UNKNOWN',
|
|
2848
|
+
severity: 'medium',
|
|
2849
|
+
hint: 'Server could not infer app type; ensure template sets it or requirements specify.'
|
|
2850
|
+
});
|
|
2851
|
+
}
|
|
2852
|
+
if (/User pool .* does not exist/i.test(text)) {
|
|
2853
|
+
findings.push({
|
|
2854
|
+
code: 'COGNITO_POOL_MISSING',
|
|
2855
|
+
severity: 'medium',
|
|
2856
|
+
hint: 'Identity add-on misconfigured; verify user pool id/region.'
|
|
2857
|
+
});
|
|
2858
|
+
}
|
|
2859
|
+
if (/ResourceAlreadyExistsException/i.test(text)) {
|
|
2860
|
+
findings.push({
|
|
2861
|
+
code: 'RESOURCE_EXISTS',
|
|
2862
|
+
severity: 'low',
|
|
2863
|
+
hint: 'Installer attempted to create a resource owned by the stack. Remove ad-hoc creation.'
|
|
2864
|
+
});
|
|
2865
|
+
}
|
|
2866
|
+
// Node runtime signals
|
|
2867
|
+
if (/MODULE_NOT_FOUND|Cannot find module/i.test(text)) {
|
|
2868
|
+
findings.push({
|
|
2869
|
+
code: 'NODE_MODULE_NOT_FOUND',
|
|
2870
|
+
severity: 'high',
|
|
2871
|
+
hint: 'node_modules missing or build incomplete. Ensure deps bundled or installed server-side.'
|
|
2872
|
+
});
|
|
2873
|
+
}
|
|
2874
|
+
if (/npm ERR!/i.test(text)) {
|
|
2875
|
+
findings.push({
|
|
2876
|
+
code: 'NPM_ERROR',
|
|
2877
|
+
severity: 'medium',
|
|
2878
|
+
hint: 'NPM failed during build/start. Inspect logs for first error.'
|
|
2879
|
+
});
|
|
2880
|
+
}
|
|
2881
|
+
if (/EADDRINUSE|address already in use/i.test(text)) {
|
|
2882
|
+
findings.push({
|
|
2883
|
+
code: 'PORT_IN_USE',
|
|
2884
|
+
severity: 'medium',
|
|
2885
|
+
hint: 'App bound to a busy port. Ensure it binds to the provided port.'
|
|
2886
|
+
});
|
|
2887
|
+
}
|
|
2888
|
+
// R runtime signals
|
|
2889
|
+
if (
|
|
2890
|
+
/there is no package called/i.test(text) ||
|
|
2891
|
+
/package or namespace load failed/i.test(text)
|
|
2892
|
+
) {
|
|
2893
|
+
findings.push({
|
|
2894
|
+
code: 'R_PACKAGE_MISSING',
|
|
2895
|
+
severity: 'high',
|
|
2896
|
+
hint: 'renv restore likely incomplete. Verify renv.lock and cache installer finished.'
|
|
2897
|
+
});
|
|
2898
|
+
}
|
|
2899
|
+
if (/cannot open shared object file/i.test(text)) {
|
|
2900
|
+
findings.push({
|
|
2901
|
+
code: 'R_SHARED_LIB_MISSING',
|
|
2902
|
+
severity: 'medium',
|
|
2903
|
+
hint: 'System library missing. Consider adding runtime shim with required libs.'
|
|
2904
|
+
});
|
|
2905
|
+
}
|
|
2906
|
+
|
|
2907
|
+
// Infra checks (ALB target health + ECS tasks) using brokered creds
|
|
2908
|
+
let infra = {};
|
|
2909
|
+
try {
|
|
2910
|
+
const region = regionToAws(stack?.account?.region || 'EU') || 'eu-west-1';
|
|
2911
|
+
const credsRes = await apiPost(token, ISSUE_AWS_CREDENTIALS_MUTATION, {
|
|
2912
|
+
input: {
|
|
2913
|
+
accountId,
|
|
2914
|
+
regionAws: region,
|
|
2915
|
+
scopes: ['ELBV2_READ', 'ECS_READ'],
|
|
2916
|
+
durationSeconds: 900
|
|
2917
|
+
}
|
|
2918
|
+
});
|
|
2919
|
+
const c = credsRes?.issueAwsCredentials;
|
|
2920
|
+
if (c?.accessKeyId) {
|
|
2921
|
+
const creds = makeCreds(c);
|
|
2922
|
+
// Target group heuristic: scaly-<first8-of-stackId>
|
|
2923
|
+
const shortId = String(stackId).split('-')[0];
|
|
2924
|
+
const elb = elbClient(region, creds);
|
|
2925
|
+
const tgResp = await elb.send(new DescribeTargetGroupsCommand({}));
|
|
2926
|
+
const tgs = (tgResp?.TargetGroups || []).filter((t) =>
|
|
2927
|
+
(t.TargetGroupName || '').includes(shortId)
|
|
2928
|
+
);
|
|
2929
|
+
let targetHealth = null;
|
|
2930
|
+
if (tgs.length > 0 && tgs[0].TargetGroupArn) {
|
|
2931
|
+
const th = await elb.send(
|
|
2932
|
+
new DescribeTargetHealthCommand({
|
|
2933
|
+
TargetGroupArn: tgs[0].TargetGroupArn
|
|
2934
|
+
})
|
|
2935
|
+
);
|
|
2936
|
+
targetHealth = (th?.TargetHealthDescriptions || []).map((d) => ({
|
|
2937
|
+
id: d.Target?.Id,
|
|
2938
|
+
port: d.Target?.Port,
|
|
2939
|
+
state: d.TargetHealth?.State,
|
|
2940
|
+
reason: d.TargetHealth?.Reason
|
|
2941
|
+
}));
|
|
2942
|
+
}
|
|
2943
|
+
|
|
2944
|
+
const ecs = ecsClient(region, creds);
|
|
2945
|
+
const clusters = await ecs.send(new ListClustersCommand({}));
|
|
2946
|
+
const clusterArns = (clusters?.clusterArns || []).filter((a) =>
|
|
2947
|
+
/scaly-/.test(a)
|
|
2948
|
+
);
|
|
2949
|
+
let services = [];
|
|
2950
|
+
for (const cluster of clusterArns) {
|
|
2951
|
+
const ls = await ecs.send(new ListServicesCommand({ cluster }));
|
|
2952
|
+
const svcArns = (ls?.serviceArns || []).filter((a) =>
|
|
2953
|
+
a.includes(shortId)
|
|
2954
|
+
);
|
|
2955
|
+
if (svcArns.length) {
|
|
2956
|
+
const ds = await ecs.send(
|
|
2957
|
+
new DescribeServicesCommand({ cluster, services: svcArns })
|
|
2958
|
+
);
|
|
2959
|
+
for (const s of ds?.services || []) {
|
|
2960
|
+
const lt = await ecs.send(
|
|
2961
|
+
new ListTasksCommand({ cluster, serviceName: s.serviceName })
|
|
2962
|
+
);
|
|
2963
|
+
let tasks = [];
|
|
2964
|
+
if ((lt?.taskArns || []).length) {
|
|
2965
|
+
const dt = await ecs.send(
|
|
2966
|
+
new DescribeTasksCommand({ cluster, tasks: lt.taskArns })
|
|
2967
|
+
);
|
|
2968
|
+
tasks = (dt?.tasks || []).map((t) => ({
|
|
2969
|
+
taskArn: t.taskArn,
|
|
2970
|
+
lastStatus: t.lastStatus,
|
|
2971
|
+
desiredStatus: t.desiredStatus
|
|
2972
|
+
}));
|
|
2973
|
+
}
|
|
2974
|
+
services.push({
|
|
2975
|
+
cluster,
|
|
2976
|
+
serviceName: s.serviceName,
|
|
2977
|
+
desired: s.desiredCount,
|
|
2978
|
+
running: s.runningCount,
|
|
2979
|
+
tasks
|
|
2980
|
+
});
|
|
2981
|
+
}
|
|
2982
|
+
}
|
|
2983
|
+
}
|
|
2984
|
+
infra = { region, targetGroupCount: tgs.length, targetHealth, services };
|
|
2985
|
+
}
|
|
2986
|
+
} catch (e) {
|
|
2987
|
+
infra = { error: String((e && e.message) || e) };
|
|
2988
|
+
}
|
|
2989
|
+
|
|
2990
|
+
const ok = !!http;
|
|
2991
|
+
const summary = {
|
|
2992
|
+
ok,
|
|
2993
|
+
url,
|
|
2994
|
+
http: http || null,
|
|
2995
|
+
findings,
|
|
2996
|
+
sample: events.slice(-10),
|
|
2997
|
+
infra
|
|
2998
|
+
};
|
|
2999
|
+
if (f.json && String(f.json).toLowerCase() !== 'false') {
|
|
3000
|
+
console.log(JSON.stringify(summary));
|
|
3001
|
+
} else {
|
|
3002
|
+
console.log('DIAG_RESULT:', JSON.stringify(summary, null, 2));
|
|
3003
|
+
}
|
|
3004
|
+
return ok ? 0 : 1;
|
|
3005
|
+
}
|
|
3006
|
+
|
|
3007
|
+
// -------------------------
|
|
3008
|
+
// Auth: AWS via STS broker
|
|
3009
|
+
// -------------------------
|
|
3010
|
+
async function runAuthAws(rest) {
|
|
3011
|
+
const f = parseKv(rest);
|
|
3012
|
+
const regionArg = f.region || f.r;
|
|
3013
|
+
const scopesArg = f.scopes || f.s;
|
|
3014
|
+
const duration = Number(f.duration) || 1800;
|
|
3015
|
+
if (!regionArg || !scopesArg) {
|
|
3016
|
+
console.error(
|
|
3017
|
+
'auth aws requires --region <eu-west-1|EU|US|...> and --scopes <logs:read,ecs:read,...>'
|
|
3018
|
+
);
|
|
3019
|
+
return 2;
|
|
3020
|
+
}
|
|
3021
|
+
await sourceLocalEnv();
|
|
3022
|
+
const { getToken, apiPost, regionToAws } = await importHelpers();
|
|
3023
|
+
const token = await getToken();
|
|
3024
|
+
const region = regionArg.includes('-')
|
|
3025
|
+
? regionArg
|
|
3026
|
+
: regionToAws(regionArg) || regionArg;
|
|
3027
|
+
const scopes = String(scopesArg)
|
|
3028
|
+
.split(',')
|
|
3029
|
+
.map((s) =>
|
|
3030
|
+
s
|
|
3031
|
+
.trim()
|
|
3032
|
+
.toUpperCase()
|
|
3033
|
+
.replace(/[^A-Z_]/g, '_')
|
|
3034
|
+
)
|
|
3035
|
+
.filter(Boolean);
|
|
3036
|
+
const accountId = f.account || process.env.SCALY_ACCOUNT_ID || '';
|
|
3037
|
+
if (!accountId) {
|
|
3038
|
+
console.error(
|
|
3039
|
+
'--account <id> is recommended for proper scoping (or set SCALY_ACCOUNT_ID)'
|
|
3040
|
+
);
|
|
3041
|
+
}
|
|
3042
|
+
try {
|
|
3043
|
+
const res = await apiPost(token, ISSUE_AWS_CREDENTIALS_MUTATION, {
|
|
3044
|
+
input: {
|
|
3045
|
+
accountId: accountId || 'UNKNOWN',
|
|
3046
|
+
regionAws: region,
|
|
3047
|
+
scopes,
|
|
3048
|
+
durationSeconds: duration
|
|
3049
|
+
}
|
|
3050
|
+
});
|
|
3051
|
+
const c = res && res.issueAwsCredentials;
|
|
3052
|
+
if (!c || !c.accessKeyId)
|
|
3053
|
+
throw new Error(
|
|
3054
|
+
'Broker did not return credentials (API not yet enabled?)'
|
|
3055
|
+
);
|
|
3056
|
+
if (f.export && String(f.export).toLowerCase() !== 'false') {
|
|
3057
|
+
console.log(
|
|
3058
|
+
`# region=${c.region} role=${c.assumedRoleArn} exp=${c.expiration}`
|
|
3059
|
+
);
|
|
3060
|
+
console.log(`export AWS_ACCESS_KEY_ID='${c.accessKeyId}'`);
|
|
3061
|
+
console.log(`export AWS_SECRET_ACCESS_KEY='${c.secretAccessKey}'`);
|
|
3062
|
+
console.log(`export AWS_SESSION_TOKEN='${c.sessionToken}'`);
|
|
3063
|
+
return 0;
|
|
3064
|
+
}
|
|
3065
|
+
// Default: JSON for programmatic/agent use
|
|
3066
|
+
console.log(JSON.stringify(c));
|
|
3067
|
+
return 0;
|
|
3068
|
+
} catch (e) {
|
|
3069
|
+
console.error(
|
|
3070
|
+
'auth aws failed:',
|
|
3071
|
+
(e && e.response && e.response.data) || String(e)
|
|
3072
|
+
);
|
|
3073
|
+
// Provide an inline fallback template
|
|
3074
|
+
if (!(f.json && String(f.json).toLowerCase() !== 'false')) {
|
|
3075
|
+
console.log(
|
|
3076
|
+
`# Broker not available yet. Once enabled, rerun this command.`
|
|
3077
|
+
);
|
|
3078
|
+
}
|
|
3079
|
+
return 1;
|
|
3080
|
+
}
|
|
3081
|
+
}
|
|
3082
|
+
|
|
3083
|
+
main(process.argv);
|