vibecarbon 0.11.0 → 0.12.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/package.json +1 -1
- package/src/lib/deploy/compose/ha.js +176 -97
package/package.json
CHANGED
|
@@ -28,8 +28,10 @@ import { useDnsChallenge } from '../acme.js';
|
|
|
28
28
|
import {
|
|
29
29
|
assertReplicationStreamingOrDegraded,
|
|
30
30
|
buildPrimaryConninfo,
|
|
31
|
+
buildReplicationFirewallRules,
|
|
31
32
|
buildReplicationHbaLine,
|
|
32
33
|
buildStagedBasebackupScript,
|
|
34
|
+
REPL_PORT,
|
|
33
35
|
verifyStreaming,
|
|
34
36
|
} from '../replication.js';
|
|
35
37
|
import {
|
|
@@ -38,7 +40,13 @@ import {
|
|
|
38
40
|
pinnedSshOptsString,
|
|
39
41
|
readReplPassword,
|
|
40
42
|
} from '../utils.js';
|
|
41
|
-
import {
|
|
43
|
+
import {
|
|
44
|
+
exchangeAndBringUpTunnel,
|
|
45
|
+
REPL_GATEWAY_PORT,
|
|
46
|
+
WG_PRIMARY_IP,
|
|
47
|
+
WG_STANDBY_IP,
|
|
48
|
+
WG_SUBNET_CIDR,
|
|
49
|
+
} from '../wireguard.js';
|
|
42
50
|
import {
|
|
43
51
|
createAdminUser,
|
|
44
52
|
dockerLoginOnServer,
|
|
@@ -221,17 +229,83 @@ async function deleteFirewallByName(name, hetznerHeaders, deadlineMs = 30_000) {
|
|
|
221
229
|
// deployMode === 'compose-ha' (skipped for single-region compose, which has no
|
|
222
230
|
// standby). configureStandbyReplication is exported from this file for that.
|
|
223
231
|
|
|
232
|
+
/**
|
|
233
|
+
* Build the compose replication overlay (`docker-compose.replication.yml`) for
|
|
234
|
+
* ONE node, with that node's own WireGuard tunnel IP baked into the relay.
|
|
235
|
+
*
|
|
236
|
+
* Two pieces:
|
|
237
|
+
* 1. `db` publishes the raw replication port (REPL_PORT=5433 → container 5432)
|
|
238
|
+
* so the local socat relay has a plaintext postgres to forward to
|
|
239
|
+
* (supavisor owns 5432 and can't speak the replication protocol).
|
|
240
|
+
* 2. `repl-gateway` — a socat relay on the HOST network namespace (so it sees
|
|
241
|
+
* the host's `wg0`, brought up by exchangeAndBringUpTunnel) that binds this
|
|
242
|
+
* node's OWN tunnel IP (`selfWgIp:REPL_GATEWAY_PORT`) and forwards to the
|
|
243
|
+
* local db (`127.0.0.1:REPL_PORT`). Binding the tunnel IP (not 0.0.0.0)
|
|
244
|
+
* means the relay is reachable ONLY over wg0 — never on the public IP — so
|
|
245
|
+
* it needs no firewall rule of its own. NO `NET_ADMIN`/in-container
|
|
246
|
+
* WireGuard: the tunnel lives on the host, the container is a pure relay.
|
|
247
|
+
* `restart: unless-stopped` so a transient socat exit self-heals.
|
|
248
|
+
*
|
|
249
|
+
* Each node self-exposes its LOCAL db at `<self-tunnel-ip>:REPL_GATEWAY_PORT`,
|
|
250
|
+
* which makes the transport symmetric for failover: whichever node is currently
|
|
251
|
+
* standby dials the current primary's tunnel IP over wg0 (docker bridge → host
|
|
252
|
+
* wg0), and the promoted node already exposes its db on its own tunnel IP.
|
|
253
|
+
*
|
|
254
|
+
* @param {string} selfWgIp - this node's WireGuard tunnel IP (WG_PRIMARY_IP on
|
|
255
|
+
* the primary, WG_STANDBY_IP on the standby)
|
|
256
|
+
* @returns {string} the YAML overlay content
|
|
257
|
+
*/
|
|
258
|
+
export function buildReplicationOverlay(selfWgIp) {
|
|
259
|
+
return `version: "3.8"
|
|
260
|
+
services:
|
|
261
|
+
db:
|
|
262
|
+
ports:
|
|
263
|
+
- "${REPL_PORT}:5432"
|
|
264
|
+
repl-gateway:
|
|
265
|
+
image: alpine/socat
|
|
266
|
+
network_mode: host
|
|
267
|
+
restart: unless-stopped
|
|
268
|
+
command:
|
|
269
|
+
- "TCP-LISTEN:${REPL_GATEWAY_PORT},bind=${selfWgIp},fork,reuseaddr"
|
|
270
|
+
- "TCP:127.0.0.1:${REPL_PORT}"
|
|
271
|
+
`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// The compose flags that include the replication overlay so `up`/`restart`
|
|
275
|
+
// against the repl-gateway service resolves it.
|
|
276
|
+
const REPL_COMPOSE_FLAGS =
|
|
277
|
+
'-f docker-compose.yml -f docker-compose.prod.yml -f docker-compose.replication.yml';
|
|
278
|
+
|
|
224
279
|
/**
|
|
225
280
|
* Configure the primary PostgreSQL server for streaming replication.
|
|
226
281
|
*
|
|
227
|
-
* 1.
|
|
228
|
-
*
|
|
282
|
+
* 1. Bring up the point-to-point WireGuard tunnel (host wg0, UDP 51821) between
|
|
283
|
+
* the two nodes and start the primary's `repl-gateway` socat relay
|
|
284
|
+
* 2. Create a dedicated replication user
|
|
285
|
+
* 3. Allow replication from the WireGuard tunnel subnet in pg_hba.conf (plain
|
|
229
286
|
* `host` — WireGuard encrypts the wire, so Postgres doesn't also need TLS)
|
|
230
|
-
*
|
|
231
|
-
*
|
|
287
|
+
* 4. Set wal_level=replica and max_wal_senders
|
|
288
|
+
* 5. Restart PostgreSQL to apply changes
|
|
232
289
|
*/
|
|
233
|
-
async function configurePrimaryReplication(primaryIp, sshKeyPath, projectName) {
|
|
290
|
+
export async function configurePrimaryReplication(primaryIp, standbyIp, sshKeyPath, projectName) {
|
|
234
291
|
const remoteDir = `/opt/${projectName}`;
|
|
292
|
+
|
|
293
|
+
// Establish the WireGuard replication transport. Keys are generated on-node
|
|
294
|
+
// (private key never leaves the node); only the derived public keys transit
|
|
295
|
+
// the orchestrator. Endpoints/SSH targets are the two VPS PUBLIC IPs. This
|
|
296
|
+
// must run BEFORE starting the socat relay, since the relay binds the tunnel
|
|
297
|
+
// IP (10.99.0.x) which only exists once `wg0` is up.
|
|
298
|
+
await exchangeAndBringUpTunnel({ primaryIp, standbyIp, sshKeyPath });
|
|
299
|
+
|
|
300
|
+
// Start the primary's repl-gateway (socat, host netns). The overlay file was
|
|
301
|
+
// written to the node earlier in the deploy flow; `up -d repl-gateway` binds
|
|
302
|
+
// WG_PRIMARY_IP:REPL_GATEWAY_PORT → 127.0.0.1:REPL_PORT now that wg0 exists.
|
|
303
|
+
await sshRun(
|
|
304
|
+
primaryIp,
|
|
305
|
+
sshKeyPath,
|
|
306
|
+
`cd ${remoteDir} && docker compose ${REPL_COMPOSE_FLAGS} up -d repl-gateway 2>&1`,
|
|
307
|
+
{ timeout: 60_000 },
|
|
308
|
+
);
|
|
235
309
|
// replPassword is generated at create time from crypto.randomBytes and is
|
|
236
310
|
// restricted to base64url characters — no shell escaping needed.
|
|
237
311
|
// Read from process.env first (CI), then .env.local (the `vibecarbon create`
|
|
@@ -315,14 +389,10 @@ END $$;
|
|
|
315
389
|
{ timeout: 30_000 },
|
|
316
390
|
);
|
|
317
391
|
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
// ssl = 'on' + ssl_cert_file/ssl_key_file/ssl_ca_file. That's public-IP-era
|
|
323
|
-
// plumbing for the TLS transport being replaced — WireGuard encrypts the
|
|
324
|
-
// wire instead, so there's no cert material to install here. Task 6 wires
|
|
325
|
-
// the actual wg0 tunnel bring-up for compose-ha.
|
|
392
|
+
// No app-layer TLS. Replication runs over the WireGuard tunnel (brought up
|
|
393
|
+
// above), which encrypts the wire, so Postgres itself speaks plaintext — the
|
|
394
|
+
// retired verify-ca transport's cert install + `ALTER SYSTEM SET ssl='on'`
|
|
395
|
+
// are gone (public-IP-era plumbing).
|
|
326
396
|
|
|
327
397
|
// Restart PostgreSQL to apply WAL changes
|
|
328
398
|
await sshRun(primaryIp, sshKeyPath, `cd ${remoteDir} && docker compose restart db`, {
|
|
@@ -394,6 +464,19 @@ END $$;`;
|
|
|
394
464
|
{ timeout: 30_000 },
|
|
395
465
|
);
|
|
396
466
|
|
|
467
|
+
// Start the standby's repl-gateway (socat, host netns). The tunnel (host wg0)
|
|
468
|
+
// is already up — configurePrimaryReplication brought it up on both nodes at
|
|
469
|
+
// deploy time, and it persists across the restore/re-seed re-entry. Binding
|
|
470
|
+
// WG_STANDBY_IP:REPL_GATEWAY_PORT self-exposes the standby's LOCAL db over the
|
|
471
|
+
// tunnel so the transport is symmetric after a failover (the promoted standby
|
|
472
|
+
// is then the primary, and the old primary streams from WG_STANDBY_IP).
|
|
473
|
+
await sshRun(
|
|
474
|
+
standbyIp,
|
|
475
|
+
sshKeyPath,
|
|
476
|
+
`cd ${remoteDir} && docker compose ${REPL_COMPOSE_FLAGS} up -d repl-gateway 2>&1`,
|
|
477
|
+
{ timeout: 60_000 },
|
|
478
|
+
);
|
|
479
|
+
|
|
397
480
|
// Stop the standby database
|
|
398
481
|
await sshRun(standbyIp, sshKeyPath, `cd ${remoteDir} && docker compose stop db`, {
|
|
399
482
|
timeout: 60_000,
|
|
@@ -406,9 +489,17 @@ END $$;`;
|
|
|
406
489
|
// atomically swaps in only after verifying PG_VERSION, so a failed/partial
|
|
407
490
|
// basebackup never leaves PGDATA half-formed (the `initdb: directory exists
|
|
408
491
|
// but is not empty` crash-loop). probeFirst aborts cleanly (exit 0, PGDATA
|
|
409
|
-
// untouched) when the primary's
|
|
410
|
-
// PGSSLMODE/PGSSLROOTCERT) —
|
|
411
|
-
//
|
|
492
|
+
// untouched) when the primary's gateway is unreachable. Plaintext (no
|
|
493
|
+
// PGSSLMODE/PGSSLROOTCERT) — WireGuard is the encryption layer.
|
|
494
|
+
//
|
|
495
|
+
// The connection targets the PRIMARY's WireGuard tunnel IP (WG_PRIMARY_IP) on
|
|
496
|
+
// the repl-gateway relay port (REPL_GATEWAY_PORT), NOT the primary's public
|
|
497
|
+
// IP: the standby db container reaches 10.99.0.1:15433 via the host's wg0
|
|
498
|
+
// (docker bridge → host route → wg0), where the primary's socat relay
|
|
499
|
+
// forwards to the primary db (127.0.0.1:REPL_PORT). WG_PRIMARY_IP is fixed to
|
|
500
|
+
// whichever node is currently primary (exchangeAndBringUpTunnel assigns it),
|
|
501
|
+
// so this stays correct across a role swap once failover re-establishes the
|
|
502
|
+
// tunnel with swapped endpoints.
|
|
412
503
|
//
|
|
413
504
|
// After the swap we pin primary_conninfo explicitly (last entry in
|
|
414
505
|
// postgresql.auto.conf wins): pg_basebackup -R records a conninfo from the
|
|
@@ -418,12 +509,13 @@ END $$;`;
|
|
|
418
509
|
// left untouched.
|
|
419
510
|
const basebackupScript = `${buildStagedBasebackupScript({
|
|
420
511
|
replPassword,
|
|
421
|
-
primaryHost:
|
|
512
|
+
primaryHost: WG_PRIMARY_IP,
|
|
513
|
+
primaryPort: String(REPL_GATEWAY_PORT),
|
|
422
514
|
probeFirst: true,
|
|
423
515
|
label: 'compose-ha-repl',
|
|
424
516
|
})}
|
|
425
517
|
cat >> /var/lib/postgresql/data/postgresql.auto.conf <<'REPL_CONNINFO_EOF'
|
|
426
|
-
primary_conninfo = '${buildPrimaryConninfo({ primaryHost:
|
|
518
|
+
primary_conninfo = '${buildPrimaryConninfo({ primaryHost: WG_PRIMARY_IP, port: REPL_GATEWAY_PORT, replPassword })}'
|
|
427
519
|
REPL_CONNINFO_EOF
|
|
428
520
|
chown postgres:postgres /var/lib/postgresql/data/postgresql.auto.conf
|
|
429
521
|
`;
|
|
@@ -549,44 +641,41 @@ chown postgres:postgres /var/lib/postgresql/data/postgresql.auto.conf
|
|
|
549
641
|
// ============================================================================
|
|
550
642
|
|
|
551
643
|
/**
|
|
552
|
-
* Open
|
|
553
|
-
* Only allows traffic from the peer's IP — not from the public internet.
|
|
554
|
-
*/
|
|
555
|
-
/**
|
|
556
|
-
* Open the dedicated replication port between HA peers.
|
|
644
|
+
* Open the WireGuard tunnel port (UDP 51821) from the HA peer in UFW.
|
|
557
645
|
*
|
|
558
|
-
*
|
|
559
|
-
*
|
|
560
|
-
*
|
|
561
|
-
*
|
|
646
|
+
* Replication no longer traverses a public TCP port — it runs over a
|
|
647
|
+
* point-to-point WireGuard tunnel between the two nodes' public IPs. The only
|
|
648
|
+
* inbound the peer needs is the WG handshake/data on UDP 51821 (51820 is
|
|
649
|
+
* flannel-wg's in k3s; compose uses 51821 for parity). Scoped to the peer's IP,
|
|
650
|
+
* never the public internet. The raw replication port (5433) is no longer
|
|
651
|
+
* exposed to the peer at the host firewall — the socat relay binds the tunnel
|
|
652
|
+
* IP and is reachable only over wg0.
|
|
562
653
|
*/
|
|
563
|
-
async function
|
|
564
|
-
await sshRunAsync(ip, sshKeyPath, `ufw allow from ${peerIp} to any port
|
|
654
|
+
async function openWireguardPortUfw(ip, peerIp, sshKeyPath) {
|
|
655
|
+
await sshRunAsync(ip, sshKeyPath, `ufw allow from ${peerIp} to any port 51821 proto udp`, {
|
|
565
656
|
timeout: 15_000,
|
|
566
657
|
});
|
|
567
658
|
}
|
|
568
659
|
|
|
569
660
|
/**
|
|
570
|
-
*
|
|
571
|
-
* firewall. UFW alone isn't enough —
|
|
572
|
-
* the host and
|
|
573
|
-
*
|
|
574
|
-
*
|
|
575
|
-
* pg_basebackup, the catch path throws "pg_basebackup failed", but historically
|
|
576
|
-
* the script ran under dash (no `set -e -o pipefail` support) and silently
|
|
577
|
-
* proceeded — so the warning surfaced two stages later as "did not enter
|
|
578
|
-
* recovery mode within 60s" with the standby actually running its first-init
|
|
579
|
-
* primary-mode state. RCA: compose-ha 2026-05-01 fanout11.
|
|
661
|
+
* Open the WireGuard tunnel port (UDP 51821) scoped to the peer's IP on a
|
|
662
|
+
* server's Hetzner Cloud firewall. UFW alone isn't enough — the Cloud Firewall
|
|
663
|
+
* sits in front of the host and drops the packet before it ever reaches UFW.
|
|
664
|
+
* Without this, the WG handshake from the peer never lands, the tunnel never
|
|
665
|
+
* comes up, and pg_basebackup over the relay times out.
|
|
580
666
|
*
|
|
581
667
|
* The Pulumi-managed firewall (src/lib/iac/programs/hetzner-compose.js) only
|
|
582
|
-
* declares 22/80/443 because the
|
|
583
|
-
*
|
|
668
|
+
* declares 22/80/443 because the peer's IP isn't known at stack-up time. Patch
|
|
669
|
+
* in the UDP 51821 rule here, after both stacks are up. Uses the shared
|
|
670
|
+
* buildReplicationFirewallRules builder so compose-ha and k8s-ha compute the
|
|
671
|
+
* exact same rule set (peer-scoped udp/51821 + scrubs any stale tcp
|
|
672
|
+
* 5432/5433/30432 rules from the retired public-IP TLS transport).
|
|
584
673
|
*
|
|
585
|
-
* Idempotent:
|
|
586
|
-
*
|
|
587
|
-
*
|
|
674
|
+
* Idempotent: buildReplicationFirewallRules returns null when the rule already
|
|
675
|
+
* exists and no stale rules remain. Non-fatal on error — the deploy continues
|
|
676
|
+
* with a warning; HA replication just won't work until 51821 is open.
|
|
588
677
|
*/
|
|
589
|
-
async function
|
|
678
|
+
async function openWireguardPortHetznerFirewall(serverName, peerIp, hetznerToken) {
|
|
590
679
|
if (!hetznerToken) return;
|
|
591
680
|
const firewallName = `${serverName}-firewall`;
|
|
592
681
|
const headers = {
|
|
@@ -600,22 +689,8 @@ async function openReplicationPortHetznerFirewall(serverName, peerIp, hetznerTok
|
|
|
600
689
|
const fwListData = await fwListRes.json();
|
|
601
690
|
const firewall = fwListData.firewalls?.find((fw) => fw.name === firewallName);
|
|
602
691
|
if (!firewall) return;
|
|
603
|
-
const
|
|
604
|
-
|
|
605
|
-
const hasReplRule = existingRules.some(
|
|
606
|
-
(r) => r.port === '5433' && r.source_ips?.includes(peerCidr),
|
|
607
|
-
);
|
|
608
|
-
if (hasReplRule) return;
|
|
609
|
-
const updatedRules = [
|
|
610
|
-
...existingRules,
|
|
611
|
-
{
|
|
612
|
-
direction: 'in',
|
|
613
|
-
protocol: 'tcp',
|
|
614
|
-
port: '5433',
|
|
615
|
-
source_ips: [peerCidr],
|
|
616
|
-
description: 'PostgreSQL replication from HA peer',
|
|
617
|
-
},
|
|
618
|
-
];
|
|
692
|
+
const updatedRules = buildReplicationFirewallRules(firewall.rules || [], peerIp);
|
|
693
|
+
if (!updatedRules) return; // already correct
|
|
619
694
|
await fetchWithRetry(`https://api.hetzner.cloud/v1/firewalls/${firewall.id}/actions/set_rules`, {
|
|
620
695
|
method: 'POST',
|
|
621
696
|
headers,
|
|
@@ -958,27 +1033,28 @@ export async function deployComposeHA(options) {
|
|
|
958
1033
|
perfAsync('deploy.ha.compose.setupFiles.standby', () =>
|
|
959
1034
|
setupServerFiles(standbyIp, sshKeyPath, projectName, serviceOpts),
|
|
960
1035
|
),
|
|
961
|
-
perfAsync('deploy.ha.compose.
|
|
962
|
-
|
|
1036
|
+
perfAsync('deploy.ha.compose.openWgPortUfw.primary', () =>
|
|
1037
|
+
openWireguardPortUfw(primaryIp, standbyIp, sshKeyPath),
|
|
963
1038
|
),
|
|
964
|
-
perfAsync('deploy.ha.compose.
|
|
965
|
-
|
|
1039
|
+
perfAsync('deploy.ha.compose.openWgPortUfw.standby', () =>
|
|
1040
|
+
openWireguardPortUfw(standbyIp, primaryIp, sshKeyPath),
|
|
966
1041
|
),
|
|
967
|
-
// Open
|
|
968
|
-
// host-level layer
|
|
969
|
-
// calls the Cloud Firewall (Pulumi-declared with only 22/80/443)
|
|
970
|
-
// drops
|
|
971
|
-
// post-failover the standby becomes primary and
|
|
972
|
-
//
|
|
973
|
-
|
|
974
|
-
|
|
1042
|
+
// Open the WireGuard tunnel port (UDP 51821) in the Hetzner Cloud firewall
|
|
1043
|
+
// for both peers. UFW is the host-level layer configured above; without
|
|
1044
|
+
// these two calls the Cloud Firewall (Pulumi-declared with only 22/80/443)
|
|
1045
|
+
// silently drops the WG handshake at the edge and the tunnel never comes
|
|
1046
|
+
// up. Both directions because post-failover the standby becomes primary and
|
|
1047
|
+
// the WG endpoints reverse — the promoted node must already admit the old
|
|
1048
|
+
// primary's WG traffic.
|
|
1049
|
+
perfAsync('deploy.ha.compose.cloudFirewallWg.primary', () =>
|
|
1050
|
+
openWireguardPortHetznerFirewall(
|
|
975
1051
|
`${projectName}-${environment}-primary`,
|
|
976
1052
|
standbyIp,
|
|
977
1053
|
apiToken,
|
|
978
1054
|
),
|
|
979
1055
|
),
|
|
980
|
-
perfAsync('deploy.ha.compose.
|
|
981
|
-
|
|
1056
|
+
perfAsync('deploy.ha.compose.cloudFirewallWg.standby', () =>
|
|
1057
|
+
openWireguardPortHetznerFirewall(
|
|
982
1058
|
`${projectName}-${environment}-standby`,
|
|
983
1059
|
primaryIp,
|
|
984
1060
|
apiToken,
|
|
@@ -1186,42 +1262,45 @@ export async function deployComposeHA(options) {
|
|
|
1186
1262
|
);
|
|
1187
1263
|
}
|
|
1188
1264
|
|
|
1189
|
-
//
|
|
1190
|
-
//
|
|
1191
|
-
//
|
|
1192
|
-
//
|
|
1193
|
-
//
|
|
1194
|
-
//
|
|
1195
|
-
//
|
|
1196
|
-
//
|
|
1197
|
-
//
|
|
1198
|
-
//
|
|
1199
|
-
const replOverlay = 'version: "3.8"\nservices:\n db:\n ports:\n - "5433:5432"\n';
|
|
1265
|
+
// Write the WireGuard replication overlay (docker-compose.replication.yml) to
|
|
1266
|
+
// both nodes. It (a) publishes db:5432 → host:REPL_PORT so the socat relay has
|
|
1267
|
+
// a plaintext postgres to forward to, and (b) declares the `repl-gateway`
|
|
1268
|
+
// socat relay bound to THIS node's own tunnel IP (WG_PRIMARY_IP on the
|
|
1269
|
+
// primary, WG_STANDBY_IP on the standby). The relay is NOT started here —
|
|
1270
|
+
// socat binds the tunnel IP, which only exists after wg0 is up; the tunnel is
|
|
1271
|
+
// brought up (and the gateways started) inside configure{Primary,Standby}
|
|
1272
|
+
// Replication below. We DO recreate the db service now so its REPL_PORT
|
|
1273
|
+
// mapping is live before the relay attaches. Fan out — both nodes are
|
|
1274
|
+
// disjoint hosts.
|
|
1200
1275
|
await Promise.all(
|
|
1201
|
-
[
|
|
1276
|
+
[
|
|
1277
|
+
[primaryIp, WG_PRIMARY_IP],
|
|
1278
|
+
[standbyIp, WG_STANDBY_IP],
|
|
1279
|
+
].map(async ([ip, selfWgIp]) => {
|
|
1202
1280
|
await sshRunAsync(
|
|
1203
1281
|
ip,
|
|
1204
1282
|
sshKeyPath,
|
|
1205
|
-
`cat > /opt/${projectName}/docker-compose.replication.yml << 'REPL'\n${
|
|
1283
|
+
`cat > /opt/${projectName}/docker-compose.replication.yml << 'REPL'\n${buildReplicationOverlay(selfWgIp)}REPL`,
|
|
1206
1284
|
{ timeout: 10_000 },
|
|
1207
1285
|
);
|
|
1208
|
-
// Recreate only the db service with the new port mapping
|
|
1286
|
+
// Recreate only the db service with the new port mapping (the relay is
|
|
1287
|
+
// started later, once wg0 exists).
|
|
1209
1288
|
await sshRunAsync(
|
|
1210
1289
|
ip,
|
|
1211
1290
|
sshKeyPath,
|
|
1212
|
-
`cd /opt/${projectName} && docker compose
|
|
1291
|
+
`cd /opt/${projectName} && docker compose ${REPL_COMPOSE_FLAGS} up -d db 2>&1`,
|
|
1213
1292
|
{ timeout: 60_000 },
|
|
1214
1293
|
);
|
|
1215
1294
|
}),
|
|
1216
1295
|
);
|
|
1217
1296
|
|
|
1218
1297
|
// Configure PostgreSQL streaming replication.
|
|
1219
|
-
// Wrap the entire setup (primary + standby + slot
|
|
1220
|
-
// so the perf trace shows the headline replication cost —
|
|
1221
|
-
// dominated by pg_basebackup for the standby seed.
|
|
1298
|
+
// Wrap the entire setup (tunnel + gateways + primary + standby + slot +
|
|
1299
|
+
// pg_basebackup) so the perf trace shows the headline replication cost —
|
|
1300
|
+
// typical 30-90s, dominated by pg_basebackup for the standby seed.
|
|
1222
1301
|
await perfAsync('deploy.ha.replication.setup', async () => {
|
|
1223
1302
|
onProgress('Configuring PostgreSQL replication on primary...');
|
|
1224
|
-
await configurePrimaryReplication(primaryIp, sshKeyPath, projectName);
|
|
1303
|
+
await configurePrimaryReplication(primaryIp, standbyIp, sshKeyPath, projectName);
|
|
1225
1304
|
|
|
1226
1305
|
onProgress('Configuring standby as hot replica...');
|
|
1227
1306
|
let replicationOk = false;
|
|
@@ -1288,8 +1367,8 @@ export async function deployComposeHA(options) {
|
|
|
1288
1367
|
lastState: replLastState,
|
|
1289
1368
|
allowDegraded,
|
|
1290
1369
|
fixHint:
|
|
1291
|
-
'Verify the
|
|
1292
|
-
'completed pg_basebackup.',
|
|
1370
|
+
'Verify the WireGuard tunnel port (UDP 51821) is open between the two regions, wg0 ' +
|
|
1371
|
+
'is up on both nodes, and the standby completed pg_basebackup.',
|
|
1293
1372
|
});
|
|
1294
1373
|
if (replActive) {
|
|
1295
1374
|
p.log.success('Streaming replication active');
|