vibecarbon 0.4.0 → 0.5.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/src/scale.js CHANGED
@@ -13,8 +13,7 @@
13
13
  * prompt covers that need without tying up two top-level letters.
14
14
  */
15
15
 
16
- import { existsSync, readFileSync, unlinkSync } from 'node:fs';
17
- import os from 'node:os';
16
+ import { existsSync, readFileSync } from 'node:fs';
18
17
  import { join } from 'node:path';
19
18
  import * as p from '@clack/prompts';
20
19
  import { getGHCRCredentials } from './lib/build.js';
@@ -49,7 +48,7 @@ import { assertInProjectDir } from './lib/project-guard.js';
49
48
  import { HetznerProvider } from './lib/providers/hetzner.js';
50
49
  import { buildComposeTypeOptions, buildSimpleTypeOptions } from './lib/server-types.js';
51
50
  import { parseDotenv } from './lib/shell.js';
52
- import { scpDownload, scpUpload, sshRun, sshRunScript } from './lib/ssh.js';
51
+ import { sshRun, sshRunScript } from './lib/ssh.js';
53
52
  import { VERSION } from './lib/version.js';
54
53
 
55
54
  // ============================================================================
@@ -296,12 +295,14 @@ async function scaleCompose(environment, envConfig, projectConfig, options = {})
296
295
  envConfig.deployMode === 'compose-ha' ? `${server.role} (${server.ip})` : server.ip;
297
296
  const s = p.spinner();
298
297
 
299
- // 1. Backup database on old server (--clean so restore can run without DROP DATABASE)
298
+ // 1. Backup database on old server via wal-g (pushes directly to S3)
300
299
  s.start(`Backing up database on ${label}...`);
301
- const archiveName = await perfAsync('scale.backupOldServer', async () =>
302
- backupCompose(server.ip, sshKeyPath, projectName, { clean: true }),
300
+ await perfAsync('scale.backupOldServer', async () =>
301
+ backupCompose(server.ip, sshKeyPath, projectName, {
302
+ retain: envConfig.backup?.retentionDays,
303
+ }),
303
304
  );
304
- s.stop(`Database backed up: ${archiveName}`);
305
+ s.stop('Database backed up (wal-g base backup pushed to S3)');
305
306
 
306
307
  // 2. Create new server. user_data front-loads ufw + unattended-upgrades
307
308
  // during boot so setupServer is a ~2s marker-file probe below.
@@ -487,41 +488,24 @@ async function scaleCompose(environment, envConfig, projectConfig, options = {})
487
488
  );
488
489
  s.stop('Services started');
489
490
 
490
- // 7. Wait for DB to be ready (backup uses --clean so no DROP DATABASE needed)
491
- s.start('Waiting for database to be ready...');
492
- const remoteDir = `/opt/${projectName}`;
493
- // Shell loop + `$(...)` needs a real shell; projectName is basename-validated.
494
- sshRunScript(
495
- newIp,
496
- sshKeyPath,
497
- `cd ${remoteDir} && for i in $(seq 1 30); do docker compose exec -T db pg_isready -U postgres >/dev/null 2>&1 && break || sleep 2; done`,
498
- { timeout: 120_000 },
499
- );
500
- s.stop('Database ready for restore');
501
-
502
- // 8. Transfer backup from old server to new server
503
- s.start('Transferring backup to new server...');
504
- const localTmpBackup = join(os.tmpdir(), archiveName);
505
- try {
506
- scpDownload(server.ip, sshKeyPath, `${remoteDir}/backups/${archiveName}`, localTmpBackup);
507
- sshRun(newIp, sshKeyPath, ['mkdir', '-p', `${remoteDir}/backups`]);
508
- scpUpload(newIp, sshKeyPath, localTmpBackup, `${remoteDir}/backups/${archiveName}`);
509
- } finally {
510
- try {
511
- unlinkSync(localTmpBackup);
512
- } catch {
513
- // ignore cleanup errors
514
- }
515
- }
516
- s.stop('Backup transferred');
517
-
518
- // 9. Restore database on new server
519
- s.start('Restoring database...');
491
+ // 7. Restore database on new server from S3 via wal-g. The base backup +
492
+ // WAL were pushed straight to S3 by step 1's backupCompose, so the
493
+ // restore fetches LATEST directly — no local archive to transfer.
494
+ // S3 credentials come from the db container's env (sourced from .env)
495
+ // so no opts are needed; restoreCompose handles its own verify loop
496
+ // (pg_isready + pg_is_in_recovery() = f) before returning.
497
+ s.start('Restoring database from S3 via wal-g...');
520
498
  await perfAsync('scale.restoreDb', async () =>
521
- restoreCompose(newIp, sshKeyPath, projectName, archiveName),
499
+ restoreCompose(newIp, sshKeyPath, projectName, 'latest'),
522
500
  );
523
501
  s.stop('Database restored');
524
502
 
503
+ // Remote compose project dir on the new server — used by the ACME reset
504
+ // and the post-restore service recreate below. (Previously declared by a
505
+ // since-removed pre-restore readiness step; restoreCompose now owns that
506
+ // wait, so declare it here.)
507
+ const remoteDir = `/opt/${projectName}`;
508
+
525
509
  // 9a. Update DNS to the new server. On HTTP-01 this must happen BEFORE
526
510
  // recreating services (Traefik challenges the A record on startup; a
527
511
  // stale record fails all challenges with a 30+ min retry). On DNS-01 the
@@ -577,34 +561,26 @@ async function scaleCompose(environment, envConfig, projectConfig, options = {})
577
561
  : []),
578
562
  ...(services.redis ? ['-f docker-compose.redis.yml'] : []),
579
563
  ].join(' ');
564
+ // On compose-ha, docker-compose.replication.yml (written by configurePrimaryReplication)
565
+ // publishes 5433 → db:5432 for pg_basebackup / streaming replication.
566
+ // Append it when present so force-recreate doesn't silently drop the 5433 mapping.
567
+ // On single-server compose the file never exists — the shell conditional is a no-op.
568
+ const replOverlayFlag =
569
+ "$([ -f docker-compose.replication.yml ] && echo '-f docker-compose.replication.yml')";
580
570
  s.start('Recreating all services after restore...');
581
571
  sshRunScript(
582
572
  newIp,
583
573
  sshKeyPath,
584
- `cd ${remoteDir} && docker compose ${composeFlags} up -d --force-recreate 2>&1`,
574
+ `cd ${remoteDir} && docker compose ${composeFlags} ${replOverlayFlag} up -d --force-recreate 2>&1`,
585
575
  { timeout: 600_000 },
586
576
  );
587
577
  s.stop('All services running');
588
578
 
589
- // 11. Re-setup backup cron if configured. envConfig only persists
590
- // bucket/region/endpoint under `s3` accessKey/secretKey live in
591
- // ~/.vibecarbon/credentials.json (loaded into `creds` at step 4b).
592
- // Without merging them in, setupComposeBackupCron would write a
593
- // /etc/vibecarbon/backup.env with empty AWS_ACCESS_KEY_ID /
594
- // AWS_SECRET_ACCESS_KEY and every cron-triggered backup would 403
595
- // against S3.
579
+ // 11. Re-setup backup cron if configured. wal-g reads its S3 config from
580
+ // the db container's env (rendered into .env at deploy), so the cron
581
+ // just runs compose-backup.sh on schedule no separate S3 plumbing.
596
582
  if (envConfig.backup) {
597
- const backupS3Config =
598
- envConfig.s3 && creds.s3
599
- ? {
600
- bucket: envConfig.s3.bucket,
601
- accessKey: creds.s3.accessKey,
602
- secretKey: creds.s3.secretKey,
603
- endpoint: envConfig.s3.endpoint,
604
- region: envConfig.s3.region,
605
- }
606
- : null;
607
- setupComposeBackupCron(newIp, sshKeyPath, projectName, backupS3Config, envConfig.backup);
583
+ setupComposeBackupCron(newIp, sshKeyPath, projectName, envConfig.backup);
608
584
  p.log.info('Backup cron restored on new server');
609
585
  }
610
586