@everystack/cli 0.4.32 → 0.4.33

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.32",
3
+ "version": "0.4.33",
4
4
  "description": "CLI and OTA updates for Expo apps on everystack",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -100,6 +100,7 @@
100
100
  "@aws-sdk/client-lambda": "3.1053.0",
101
101
  "@aws-sdk/client-s3": "3.1053.0",
102
102
  "@aws-sdk/client-ssm": "3.1053.0",
103
+ "@aws-sdk/lib-storage": "3.1053.0",
103
104
  "@aws-sdk/client-sts": "3.1053.0",
104
105
  "@aws-sdk/signature-v4a": "3.1048.0",
105
106
  "glob": "13.0.6",
package/src/cli/aws.ts CHANGED
@@ -13,7 +13,7 @@ let lambdaClient: InstanceType<typeof import('@aws-sdk/client-lambda').LambdaCli
13
13
  let kvsClient: InstanceType<typeof import('@aws-sdk/client-cloudfront-keyvaluestore').CloudFrontKeyValueStoreClient> | null = null;
14
14
  let rdsClient: InstanceType<typeof import('@aws-sdk/client-rds').RDSClient> | null = null;
15
15
 
16
- async function getS3(region: string) {
16
+ export async function getS3(region: string) {
17
17
  if (!s3Client) {
18
18
  const { S3Client } = await importOptionalAws(
19
19
  () => import('@aws-sdk/client-s3'), '@aws-sdk/client-s3', 'this command',
@@ -4,14 +4,152 @@
4
4
  * db:backup/db:export run in the ephemeral Task lane (the ops Lambda holds no pg binaries); the CLI
5
5
  * dispatches and polls. db:backups/db:restore/download are thin invokeAction wrappers. The image's
6
6
  * version handshake is the compatibility gate — verify a deploy with `everystack task:probe`.
7
+ *
8
+ * db:backup has a SECOND venue for break-glass: `--database-url <local> --stage <target>` dumps a
9
+ * LOCAL database and uploads it into the target stage's backups bucket as a db:backup-shaped object
10
+ * (same key scheme + .meta.json + dump flags as a server backup). The standard credential-free
11
+ * `db:restore --from <id> --stage <target>` then lands it via the Task lane (pg_restore runs as the
12
+ * privileged `migrator` role, which can CREATE SCHEMA + own objects). The local dump is the only
13
+ * held connection; the upload rides the operator's AWS credentials — the same trust boundary as
14
+ * db:fork's cross-stage presign. This is the sanctioned local→stage full replace on a role-managed
15
+ * stage (raw pg_restore can't: the operator/api role can't CREATE SCHEMA, and only the master could).
7
16
  */
8
17
 
18
+ import { spawn } from 'node:child_process';
19
+ import { createGzip } from 'node:zlib';
20
+ import { pipeline } from 'node:stream/promises';
21
+ import { randomBytes } from 'node:crypto';
9
22
  import { resolveConfig, opsFunction, type CliConfig } from '../config.js';
10
- import { invokeAction, presignGet } from '../aws.js';
11
- import { parseBackupRef, keyForId, crossStageGuard, restoreTargetGuard } from '../backup.js';
23
+ import { invokeAction, presignGet, getS3 } from '../aws.js';
24
+ import {
25
+ parseBackupRef, keyForId, crossStageGuard, restoreTargetGuard,
26
+ backupKey, backupId, metaKey, utcStamp,
27
+ } from '../backup.js';
28
+ import { pgEnvFromUrl } from './db.js';
12
29
  import { pollTaskUntilStopped } from '../task-poll.js';
13
30
  import { step, success, fail, info, warn } from '../output.js';
14
31
 
32
+ /** Which database db:backup dumps. LOCAL requires a target --stage (its bucket receives the upload
33
+ * AND names the backup id). Note this DIVERGES from db:export, where --database-url and --stage are
34
+ * mutually-exclusive venues: here the local venue COMBINES them (dump local → upload to the stage). */
35
+ export type BackupVenue =
36
+ | { kind: 'local'; url: string; stage: string }
37
+ | { kind: 'stage'; stage: string | undefined };
38
+
39
+ export function resolveBackupVenue(flags: Record<string, string>): BackupVenue {
40
+ const url = flags['database-url'];
41
+ if (url) {
42
+ if (!flags.stage) {
43
+ throw new Error(
44
+ 'db:backup --database-url <local> also needs --stage <target> — the local dump uploads into '
45
+ + "that stage's backups bucket, and the stage names the backup id `db:restore --from` consumes.",
46
+ );
47
+ }
48
+ return { kind: 'local', url, stage: flags.stage };
49
+ }
50
+ return { kind: 'stage', stage: flags.stage };
51
+ }
52
+
53
+ /**
54
+ * Full-DB streaming pg_dump args — IDENTICAL to the server's db:backup dump (packages/server
55
+ * backup.ts pgDumpArgs()), minus `-f`: streamed to stdout so it pipes into gzip → S3. Excludes the
56
+ * everystack ops schema (task_log/schema_log/etc — the TARGET's own audit history, not app data;
57
+ * restoring it would rewind that history AND makes pg_restore --clean fail on DROP SCHEMA everystack).
58
+ */
59
+ export function pgDumpFullArgs(): string[] {
60
+ return ['-Fc', '--no-owner', '--no-privileges', '--no-comments', '--exclude-schema=everystack'];
61
+ }
62
+
63
+ /** The .meta.json sidecar — byte-shape identical to the server's runBackup meta (backup-run.ts:156),
64
+ * so db:backups list and the destructive-apply gate read a local upload exactly like a stage backup.
65
+ * `bytes` is undefined-safe (JSON.stringify drops it) exactly as the server leaves it on a failed
66
+ * HeadObject — never coerced to 0. */
67
+ export function localBackupMeta(opts: {
68
+ id: string;
69
+ stage: string;
70
+ createdAt: Date;
71
+ bytes?: number;
72
+ key: string;
73
+ }): Record<string, unknown> {
74
+ return {
75
+ id: opts.id,
76
+ stage: opts.stage,
77
+ createdAt: opts.createdAt.toISOString(),
78
+ bytes: opts.bytes,
79
+ key: opts.key,
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Stream a local database into a stage's backups bucket as a db:backup-shaped object. Mirrors the
85
+ * server's runBackup streaming AND its load-bearing correctness rule: a completed S3 multipart upload
86
+ * does NOT mean pg_dump succeeded (pg_dump can exit non-zero mid-stream while the upload "completes"),
87
+ * so we await the child exit code SEPARATELY and, on any failure, abort the upload and delete the
88
+ * partial object. A backup tool that ships a truncated dump is worse than none.
89
+ */
90
+ export async function runLocalBackupUpload(deps: {
91
+ region: string;
92
+ bucket: string;
93
+ url: string;
94
+ stage: string;
95
+ }): Promise<{ id: string; key: string; bytes?: number }> {
96
+ const now = new Date();
97
+ const stamp = utcStamp(now);
98
+ const shortId = randomBytes(3).toString('hex');
99
+ const key = backupKey(deps.stage, stamp, shortId);
100
+ const id = backupId(deps.stage, stamp, shortId);
101
+ const mkey = metaKey(key);
102
+
103
+ const env = { ...process.env, ...pgEnvFromUrl(deps.url) };
104
+ const s3 = await getS3(deps.region);
105
+ const { Upload } = await import('@aws-sdk/lib-storage');
106
+ const { DeleteObjectCommand, PutObjectCommand, HeadObjectCommand } = await import('@aws-sdk/client-s3');
107
+
108
+ const child = spawn('pg_dump', pgDumpFullArgs(), { env, stdio: ['ignore', 'pipe', 'pipe'] });
109
+ let stderr = '';
110
+ child.stderr.on('data', (d) => { stderr += d.toString(); });
111
+ const childExit = new Promise<void>((resolve, reject) => {
112
+ child.on('error', reject);
113
+ child.on('close', (code) =>
114
+ code === 0 ? resolve() : reject(new Error(`pg_dump exited ${code}: ${stderr.trim()}`)));
115
+ });
116
+
117
+ const gzip = createGzip();
118
+ const upload = new Upload({
119
+ client: s3,
120
+ params: {
121
+ Bucket: deps.bucket, Key: key, Body: gzip,
122
+ ContentType: 'application/gzip', ServerSideEncryption: 'AES256',
123
+ },
124
+ });
125
+
126
+ try {
127
+ await Promise.all([
128
+ pipeline(child.stdout!, gzip), // pg_dump → gzip; errors propagate, streams destroyed
129
+ upload.done(), // gzip → S3 multipart
130
+ childExit, // the guard: non-zero pg_dump rejects the whole thing
131
+ ]);
132
+ } catch (err) {
133
+ try { await upload.abort(); } catch { /* may already have completed */ }
134
+ await s3.send(new DeleteObjectCommand({ Bucket: deps.bucket, Key: key })).catch(() => {});
135
+ throw err;
136
+ }
137
+
138
+ let bytes: number | undefined;
139
+ try {
140
+ const head = await s3.send(new HeadObjectCommand({ Bucket: deps.bucket, Key: key }));
141
+ bytes = head.ContentLength;
142
+ } catch { /* size is best-effort */ }
143
+
144
+ await s3.send(new PutObjectCommand({
145
+ Bucket: deps.bucket, Key: mkey,
146
+ Body: JSON.stringify(localBackupMeta({ id, stage: deps.stage, createdAt: now, bytes, key })),
147
+ ContentType: 'application/json', ServerSideEncryption: 'AES256',
148
+ }));
149
+
150
+ return { id, key, bytes };
151
+ }
152
+
15
153
  function fmtBytes(n?: number): string {
16
154
  if (n == null) return '-';
17
155
  if (n < 1024) return `${n} B`;
@@ -27,6 +165,53 @@ function fmtBytes(n?: number): string {
27
165
  * the compatibility gate (a too-old image fails the run loudly — no separate layer pre-flight).
28
166
  */
29
167
  export async function dbBackupCommand(flags: Record<string, string>): Promise<void> {
168
+ let venue: BackupVenue;
169
+ try {
170
+ venue = resolveBackupVenue(flags);
171
+ } catch (err: any) {
172
+ fail(err.message);
173
+ process.exit(1);
174
+ }
175
+
176
+ // Local venue: dump a reachable database here and upload it into the target stage's backups bucket
177
+ // as a db:backup-shaped object, so `db:restore --from <id> --stage <target>` lands it credential-free.
178
+ if (venue.kind === 'local') {
179
+ step('Resolving deployed config...');
180
+ let config: CliConfig;
181
+ try {
182
+ config = await resolveConfig(venue.stage);
183
+ } catch (err: any) {
184
+ fail(err.message);
185
+ process.exit(1);
186
+ }
187
+ if (!config.backupsBucket) {
188
+ fail('No backupsBucket in the deployed config. Add `backupsBucket: backups.name` to sst.config outputs and redeploy.');
189
+ process.exit(1);
190
+ }
191
+ info(`Region: ${config.region}, Bucket: ${config.backupsBucket}`);
192
+ // Version gate for the local venue: unlike the stage venue (whose pg binaries are the Task image),
193
+ // this dumps with the OPERATOR's pg_dump. pg_restore refuses an archive from a NEWER pg_dump major,
194
+ // so a mismatch surfaces only at restore time ("unsupported version in file header") — loud and
195
+ // lossless (it fails before --clean drops anything), but discovered exactly when you need the
196
+ // backup. Surface the local major up front so the operator can match it to the target's PG major.
197
+ try {
198
+ const { execFileSync } = await import('node:child_process');
199
+ const v = execFileSync('pg_dump', ['--version']).toString().trim();
200
+ info(`Local ${v} — must be ≤ the target stage's PostgreSQL major, or db:restore will reject the dump.`);
201
+ } catch { /* a missing pg_dump surfaces loudly in the dump step below */ }
202
+ step(`Running pg_dump (full, --exclude-schema=everystack) against --database-url → S3 (this may take a while for large databases)...`);
203
+ try {
204
+ const res = await runLocalBackupUpload({
205
+ region: config.region, bucket: config.backupsBucket, url: venue.url, stage: venue.stage,
206
+ });
207
+ success(`Backup ${res.id} (${fmtBytes(res.bytes)}). Restore with: everystack db:restore --from ${res.id} --stage ${venue.stage} --confirm`);
208
+ } catch (err: any) {
209
+ fail(`Backup failed: ${err.message}`);
210
+ process.exit(1);
211
+ }
212
+ return;
213
+ }
214
+
30
215
  step('Resolving deployed config...');
31
216
  let config: CliConfig;
32
217
  try {
package/src/cli/index.ts CHANGED
@@ -368,7 +368,7 @@ Usage:
368
368
  everystack db:provision --stage <name> [--direct | --database-url <url>] Create the least-privilege role chain on an EXISTING database (idempotent; creates no DB). No flag = ops-Lambda venue (auto-falls to direct via the ADMIN_DATABASE_URL secret if the handler has no dbPlugin). --direct = direct connection reading that secret (master never on argv); --database-url = explicit URL. Declared schemas are set as the ROLE default (ALTER ROLE … SET search_path) — the secret stays credentials-only, and the URL is libpq-clean
369
369
  everystack db:snapshot [--stage <name>] [--instance <id>] Take a physical RDS snapshot (instant DR point; RDS only — use db:backup for portable logical backups)
370
370
  everystack db:snapshots [--stage <name>] [--instance <id>] List manual RDS snapshots for the instance
371
- everystack db:backup [--stage <name>] Logical pg_dump of the DB → private S3 backups bucket (prints the backup id)
371
+ everystack db:backup [--stage <name>] Logical pg_dump of the DB → private S3 backups bucket (prints the backup id). Add --database-url <local> --stage <target> to dump a LOCAL database and upload it into the target stage's bucket as a restorable backup — the credential-free break-glass local→stage full replace (then: db:restore --from <id> --stage <target>)
372
372
  everystack db:backups [--stage <name>] List logical backups (id, size, created)
373
373
  everystack db:restore --from <id> [--stage <name>] --confirm Restore a backup INTO the stage's DB (DESTRUCTIVE)
374
374
  everystack db:backup:download <id> [--stage <name>] Presigned URL to download a backup's dump (valid 1h)