vibecarbon 0.5.0 → 0.6.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 +1 -1
- package/carbon/.env.example +3 -0
- package/carbon/.github/workflows/deploy.yml +24 -21
- package/carbon/.github/workflows/vibecarbon-build.yml +14 -0
- package/carbon/Dockerfile +3 -0
- package/carbon/biome.json +1 -1
- package/carbon/cloud-init/k3s/master-init.sh +26 -2
- package/carbon/cloud-init/k3s/supabase-init.sh +22 -2
- package/carbon/cloud-init/k3s/worker-init.sh +22 -2
- package/carbon/content/docs/cli.mdx +1 -1
- package/carbon/docker-compose.yml +6 -0
- package/carbon/k8s/base/app/deployment.yaml +5 -0
- package/carbon/k8s/values/supabase.values.yaml +30 -0
- package/carbon/package.json +34 -36
- package/carbon/scripts/generate-sitemap.ts +18 -3
- package/carbon/src/client/App.tsx +2 -0
- package/carbon/src/client/components/NewsletterSignup.tsx +4 -3
- package/carbon/src/client/components/PricingSection.tsx +44 -1
- package/carbon/src/client/components/SEO.tsx +6 -3
- package/carbon/src/client/components/ui/calendar.tsx +1 -1
- package/carbon/src/client/index.css +9 -1
- package/carbon/src/client/index.html +3 -3
- package/carbon/src/client/locales/en.json +1 -1
- package/carbon/src/client/pages/Pricing.tsx +169 -0
- package/carbon/src/client/pages/settings/Billing.tsx +2 -6
- package/carbon/src/server/index.ts +23 -4
- package/carbon/src/shared/billing-catalog.ts +25 -0
- package/carbon/src/shared/billing-catalog.types.ts +35 -0
- package/carbon/src/shared/pricing.ts +56 -1
- package/package.json +13 -13
- package/src/activate.js +1 -1
- package/src/backup.js +33 -87
- package/src/configure.js +365 -137
- package/src/create.js +5 -2
- package/src/deploy.js +24 -7
- package/src/destroy.js +11 -4
- package/src/failover.js +64 -47
- package/src/lib/backup-format.js +84 -0
- package/src/lib/billing/stripe-catalog.js +152 -0
- package/src/lib/billing/write-catalog.js +123 -0
- package/src/lib/ci-setup.js +11 -1
- package/src/lib/config-registry.js +116 -0
- package/src/lib/deploy/compose/build-args.js +6 -0
- package/src/lib/deploy/compose/ha.js +25 -25
- package/src/lib/deploy/compose/index.js +29 -18
- package/src/lib/deploy/k8s/gitops-deploy.js +38 -14
- package/src/lib/deploy/k8s/k3s.js +20 -10
- package/src/lib/deploy/orchestrator.js +22 -1
- package/src/lib/deploy/prompts.js +21 -2
- package/src/lib/deploy/utils.js +56 -32
- package/src/lib/github-environments.js +29 -0
- package/src/lib/licensing/index.js +8 -3
- package/src/lib/licensing/tiers.js +0 -3
- package/src/lib/pod-backups.js +3 -3
- package/src/lib/providers/base.js +1 -1
- package/src/lib/providers/hetzner.js +74 -82
- package/src/lib/server-types.js +3 -31
- package/src/lib/ssh.js +19 -18
- package/src/lib/walg-backups.js +81 -0
- package/src/restore.js +122 -202
- package/src/scale.js +25 -29
- package/src/status.js +0 -69
- package/src/lib/cost.js +0 -103
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
import { fetchWithRetry } from '../fetch-retry.js';
|
|
11
11
|
import { BaseProvider } from './base.js';
|
|
12
12
|
|
|
13
|
+
// Canonical link to Hetzner's live pricing. We don't hard-code prices anywhere
|
|
14
|
+
// (they change), so surface this wherever a user would want current costs.
|
|
15
|
+
export const HETZNER_PRICING_URL = 'https://www.hetzner.com/cloud/';
|
|
16
|
+
|
|
13
17
|
export class HetznerProvider extends BaseProvider {
|
|
14
18
|
static NAME = 'Hetzner Cloud';
|
|
15
19
|
static API_BASE = 'https://api.hetzner.cloud/v1';
|
|
@@ -22,33 +26,68 @@ export class HetznerProvider extends BaseProvider {
|
|
|
22
26
|
hil: 'Hillsboro, Oregon, USA',
|
|
23
27
|
};
|
|
24
28
|
|
|
29
|
+
// Continent grouping for HA standby selection. The goal is to keep a primary
|
|
30
|
+
// and its failover standby on the same continent so cross-region replication
|
|
31
|
+
// (wal-g/S3 + Postgres streaming) doesn't pay a transatlantic RTT.
|
|
32
|
+
static REGION_CONTINENT = {
|
|
33
|
+
fsn1: 'eu',
|
|
34
|
+
nbg1: 'eu',
|
|
35
|
+
hel1: 'eu',
|
|
36
|
+
ash: 'na',
|
|
37
|
+
hil: 'na',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Default HA standby region for a given primary: a DIFFERENT region on the
|
|
42
|
+
* same continent, so failover stays intra-continent (a US primary fails over
|
|
43
|
+
* to the other US region, not across the Atlantic). Used as the pre-selected
|
|
44
|
+
* value in the interactive standby prompt and as the headless default.
|
|
45
|
+
* @param {string} primaryRegion
|
|
46
|
+
* @returns {string}
|
|
47
|
+
*/
|
|
48
|
+
static getDefaultStandbyRegion(primaryRegion) {
|
|
49
|
+
const continent = HetznerProvider.REGION_CONTINENT[primaryRegion];
|
|
50
|
+
const sameContinent = Object.keys(HetznerProvider.REGION_CONTINENT).filter(
|
|
51
|
+
(r) => r !== primaryRegion && HetznerProvider.REGION_CONTINENT[r] === continent,
|
|
52
|
+
);
|
|
53
|
+
if (sameContinent.length > 0) {
|
|
54
|
+
// Keep the conventional nbg1<->fsn1 EU pairing where it applies.
|
|
55
|
+
if (primaryRegion === 'nbg1') return 'fsn1';
|
|
56
|
+
return sameContinent[0];
|
|
57
|
+
}
|
|
58
|
+
// No same-continent partner (e.g. a future single-region continent) —
|
|
59
|
+
// fall back to the robust EU default, but never to the primary itself.
|
|
60
|
+
return primaryRegion === 'nbg1' ? 'fsn1' : 'nbg1';
|
|
61
|
+
}
|
|
62
|
+
|
|
25
63
|
// Offline fallback catalog — used when the API is unreachable or no token is available.
|
|
26
|
-
//
|
|
64
|
+
// Specs only; availability is approximate and live data from fetchServerTypes() takes
|
|
65
|
+
// precedence. Prices are intentionally not hard-coded here — see HETZNER_PRICING_URL.
|
|
27
66
|
static FALLBACK_SERVER_TYPES = {
|
|
28
|
-
cx23: { vcpu: 2, ram: 4, disk: 40
|
|
29
|
-
cx33: { vcpu: 4, ram: 8, disk: 80
|
|
30
|
-
cx43: { vcpu: 8, ram: 16, disk: 160
|
|
31
|
-
cx53: { vcpu: 16, ram: 32, disk: 320
|
|
32
|
-
cx22: { vcpu: 2, ram: 4, disk: 40
|
|
33
|
-
cx32: { vcpu: 4, ram: 8, disk: 80
|
|
34
|
-
cx42: { vcpu: 8, ram: 16, disk: 160
|
|
35
|
-
cx52: { vcpu: 16, ram: 32, disk: 320
|
|
36
|
-
cpx11: { vcpu: 2, ram: 2, disk: 40
|
|
37
|
-
cpx21: { vcpu: 3, ram: 4, disk: 80
|
|
38
|
-
cpx22: { vcpu: 3, ram: 4, disk: 80
|
|
39
|
-
cpx31: { vcpu: 4, ram: 8, disk: 160
|
|
40
|
-
cpx41: { vcpu: 8, ram: 16, disk: 240
|
|
41
|
-
cpx51: { vcpu: 16, ram: 32, disk: 360
|
|
42
|
-
cax11: { vcpu: 2, ram: 4, disk: 40
|
|
43
|
-
cax21: { vcpu: 4, ram: 8, disk: 80
|
|
44
|
-
cax31: { vcpu: 8, ram: 16, disk: 160
|
|
45
|
-
cax41: { vcpu: 16, ram: 32, disk: 320
|
|
46
|
-
ccx13: { vcpu: 2, ram: 8, disk: 80
|
|
47
|
-
ccx23: { vcpu: 4, ram: 16, disk: 160
|
|
48
|
-
ccx33: { vcpu: 8, ram: 32, disk: 240
|
|
49
|
-
ccx43: { vcpu: 16, ram: 64, disk: 360
|
|
50
|
-
ccx53: { vcpu: 32, ram: 128, disk: 600
|
|
51
|
-
ccx63: { vcpu: 48, ram: 192, disk: 960
|
|
67
|
+
cx23: { vcpu: 2, ram: 4, disk: 40 },
|
|
68
|
+
cx33: { vcpu: 4, ram: 8, disk: 80 },
|
|
69
|
+
cx43: { vcpu: 8, ram: 16, disk: 160 },
|
|
70
|
+
cx53: { vcpu: 16, ram: 32, disk: 320 },
|
|
71
|
+
cx22: { vcpu: 2, ram: 4, disk: 40 },
|
|
72
|
+
cx32: { vcpu: 4, ram: 8, disk: 80 },
|
|
73
|
+
cx42: { vcpu: 8, ram: 16, disk: 160 },
|
|
74
|
+
cx52: { vcpu: 16, ram: 32, disk: 320 },
|
|
75
|
+
cpx11: { vcpu: 2, ram: 2, disk: 40 },
|
|
76
|
+
cpx21: { vcpu: 3, ram: 4, disk: 80 },
|
|
77
|
+
cpx22: { vcpu: 3, ram: 4, disk: 80 },
|
|
78
|
+
cpx31: { vcpu: 4, ram: 8, disk: 160 },
|
|
79
|
+
cpx41: { vcpu: 8, ram: 16, disk: 240 },
|
|
80
|
+
cpx51: { vcpu: 16, ram: 32, disk: 360 },
|
|
81
|
+
cax11: { vcpu: 2, ram: 4, disk: 40 },
|
|
82
|
+
cax21: { vcpu: 4, ram: 8, disk: 80 },
|
|
83
|
+
cax31: { vcpu: 8, ram: 16, disk: 160 },
|
|
84
|
+
cax41: { vcpu: 16, ram: 32, disk: 320 },
|
|
85
|
+
ccx13: { vcpu: 2, ram: 8, disk: 80 },
|
|
86
|
+
ccx23: { vcpu: 4, ram: 16, disk: 160 },
|
|
87
|
+
ccx33: { vcpu: 8, ram: 32, disk: 240 },
|
|
88
|
+
ccx43: { vcpu: 16, ram: 64, disk: 360 },
|
|
89
|
+
ccx53: { vcpu: 32, ram: 128, disk: 600 },
|
|
90
|
+
ccx63: { vcpu: 48, ram: 192, disk: 960 },
|
|
52
91
|
};
|
|
53
92
|
|
|
54
93
|
// Live catalog populated by fetchServerTypes(). Falls back to FALLBACK_SERVER_TYPES.
|
|
@@ -61,24 +100,6 @@ export class HetznerProvider extends BaseProvider {
|
|
|
61
100
|
static DEFAULT_TYPE = 'cpx11';
|
|
62
101
|
static HA_REGIONS = ['fsn1', 'hel1'];
|
|
63
102
|
|
|
64
|
-
// Infrastructure costs (EUR/mo, as of Apr 2026)
|
|
65
|
-
static INFRA_COSTS = {
|
|
66
|
-
floatingIp: 3.57,
|
|
67
|
-
volumePerGb: 0.052, // Hetzner CSI volumes
|
|
68
|
-
s3MinBucket: 4.49, // 250 GB Object Storage minimum
|
|
69
|
-
firewall: 0, // Free
|
|
70
|
-
network: 0, // Free (first 1 TB)
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
// Known PVC sizes (Gi) per service
|
|
74
|
-
static VOLUME_SIZES = {
|
|
75
|
-
base: 30, // postgres 20Gi + storage 10Gi
|
|
76
|
-
observability: 25, // prometheus 10Gi + loki 10Gi + grafana 5Gi
|
|
77
|
-
n8n: 5,
|
|
78
|
-
metabase: 1,
|
|
79
|
-
redis: 1,
|
|
80
|
-
};
|
|
81
|
-
|
|
82
103
|
static EU_REGIONS = ['fsn1', 'nbg1', 'hel1'];
|
|
83
104
|
static US_REGIONS = ['ash', 'hil'];
|
|
84
105
|
|
|
@@ -123,27 +144,10 @@ export class HetznerProvider extends BaseProvider {
|
|
|
123
144
|
locationTypes[locName].add(t.name);
|
|
124
145
|
}
|
|
125
146
|
|
|
126
|
-
// Build per-location price map
|
|
127
|
-
const locationPrices = {};
|
|
128
|
-
for (const p of t.prices || []) {
|
|
129
|
-
const loc = p.location;
|
|
130
|
-
if (loc) {
|
|
131
|
-
locationPrices[loc] = `€${parseFloat(p.price_monthly.gross).toFixed(2)}/mo`;
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// Use first available location price as the default display price
|
|
136
|
-
const firstPrice = t.prices[0];
|
|
137
|
-
const defaultMonthly = firstPrice
|
|
138
|
-
? `€${parseFloat(firstPrice.price_monthly.gross).toFixed(2)}/mo`
|
|
139
|
-
: '';
|
|
140
|
-
|
|
141
147
|
types[t.name] = {
|
|
142
148
|
vcpu: t.cores,
|
|
143
149
|
ram: t.memory,
|
|
144
150
|
disk: t.disk,
|
|
145
|
-
price: defaultMonthly,
|
|
146
|
-
locationPrices,
|
|
147
151
|
cpuType: t.cpu_type,
|
|
148
152
|
architecture: t.architecture,
|
|
149
153
|
};
|
|
@@ -227,17 +231,19 @@ export class HetznerProvider extends BaseProvider {
|
|
|
227
231
|
return defaults.workerType;
|
|
228
232
|
}
|
|
229
233
|
|
|
230
|
-
// Offline fallback
|
|
231
|
-
//
|
|
232
|
-
//
|
|
234
|
+
// Offline fallback. The ONLY architecture that isn't universally available
|
|
235
|
+
// is ARM (cax): EU regions have both cpx (x86) and cax (ARM); US regions
|
|
236
|
+
// have cpx but NO cax. So the only valid conversion is cax→cpx in a US
|
|
237
|
+
// region (the requested ARM type can't exist there). x86 (cpx) is available
|
|
238
|
+
// everywhere, so it must NEVER be flipped to cax — doing so converted an
|
|
239
|
+
// x86 primary's cpx11 into ARM cax11 on an EU standby, which both mismatches
|
|
240
|
+
// the primary's architecture (the x86 app image won't run on ARM) and chases
|
|
241
|
+
// scarcer ARM capacity (RCA 2026-06-23: nbg1 standby failed
|
|
242
|
+
// resource_unavailable on cax). Keep cpx as-is.
|
|
233
243
|
if (HetznerProvider.US_REGIONS.includes(targetRegion) && serverType.startsWith('cax')) {
|
|
234
244
|
const alt = `cpx${serverType.replace(/^cax/, '')}`;
|
|
235
245
|
if (HetznerProvider.FALLBACK_SERVER_TYPES[alt]) return alt;
|
|
236
246
|
}
|
|
237
|
-
if (HetznerProvider.EU_REGIONS.includes(targetRegion) && serverType.startsWith('cpx')) {
|
|
238
|
-
const alt = `cax${serverType.replace(/^cpx/, '')}`;
|
|
239
|
-
if (HetznerProvider.FALLBACK_SERVER_TYPES[alt]) return alt;
|
|
240
|
-
}
|
|
241
247
|
return serverType;
|
|
242
248
|
}
|
|
243
249
|
|
|
@@ -251,11 +257,7 @@ export class HetznerProvider extends BaseProvider {
|
|
|
251
257
|
if (available) {
|
|
252
258
|
return [...available]
|
|
253
259
|
.filter((name) => name in HetznerProvider.SERVER_TYPES)
|
|
254
|
-
.map((name) => {
|
|
255
|
-
const spec = HetznerProvider.SERVER_TYPES[name];
|
|
256
|
-
const price = spec.locationPrices?.[region] || spec.price;
|
|
257
|
-
return { name, ...spec, price };
|
|
258
|
-
})
|
|
260
|
+
.map((name) => ({ name, ...HetznerProvider.SERVER_TYPES[name] }))
|
|
259
261
|
.sort((a, b) => a.vcpu - b.vcpu || a.ram - b.ram);
|
|
260
262
|
}
|
|
261
263
|
|
|
@@ -268,16 +270,6 @@ export class HetznerProvider extends BaseProvider {
|
|
|
268
270
|
.map(([name, info]) => ({ name, ...info }));
|
|
269
271
|
}
|
|
270
272
|
|
|
271
|
-
/**
|
|
272
|
-
* Returns the monthly price for a server type in a specific location.
|
|
273
|
-
* Only available after fetchServerTypes() — returns null otherwise.
|
|
274
|
-
*/
|
|
275
|
-
static getPrice(serverType, location) {
|
|
276
|
-
const spec = HetznerProvider.SERVER_TYPES[serverType];
|
|
277
|
-
if (!spec) return null;
|
|
278
|
-
return (location && spec.locationPrices?.[location]) || spec.price || null;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
273
|
/**
|
|
282
274
|
* Create a new Hetzner server
|
|
283
275
|
* @param {object} config - Server configuration
|
package/src/lib/server-types.js
CHANGED
|
@@ -36,14 +36,6 @@ export const K8S_PROFILES = [
|
|
|
36
36
|
},
|
|
37
37
|
];
|
|
38
38
|
|
|
39
|
-
/**
|
|
40
|
-
* Parse a price string like "€7.99/mo" into a number.
|
|
41
|
-
*/
|
|
42
|
-
export function parsePrice(priceStr) {
|
|
43
|
-
const match = (priceStr || '').match(/[\d.]+/);
|
|
44
|
-
return match ? Number.parseFloat(match[0]) : 0;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
39
|
/**
|
|
48
40
|
* Format a server type label with aligned columns.
|
|
49
41
|
* Pre-computes column widths from the full list so values line up.
|
|
@@ -67,7 +59,6 @@ function buildAlignedLabels(types) {
|
|
|
67
59
|
* Disables types that are too small, marks minimum and recommended.
|
|
68
60
|
*/
|
|
69
61
|
export function buildComposeTypeOptions(regionTypes, defaultType) {
|
|
70
|
-
const { INFRA_COSTS } = HetznerProvider;
|
|
71
62
|
const filtered = regionTypes.filter((t) => !t.cpuType || t.cpuType === 'shared');
|
|
72
63
|
const labels = buildAlignedLabels(filtered);
|
|
73
64
|
|
|
@@ -76,18 +67,13 @@ export function buildComposeTypeOptions(regionTypes, defaultType) {
|
|
|
76
67
|
const isMinimum = !tooSmall && t.ram === COMPOSE_MIN_RAM_GB && t.disk <= 40;
|
|
77
68
|
const isRecommended = t.name === defaultType;
|
|
78
69
|
|
|
79
|
-
const serverCost = parsePrice(t.price);
|
|
80
|
-
const totalCost = serverCost + INFRA_COSTS.s3MinBucket;
|
|
81
|
-
|
|
82
70
|
let hint = '';
|
|
83
71
|
if (tooSmall) {
|
|
84
72
|
hint = `need ${COMPOSE_MIN_RAM_GB}GB+ RAM for Supabase`;
|
|
85
73
|
} else if (isMinimum) {
|
|
86
|
-
hint =
|
|
74
|
+
hint = 'minimum';
|
|
87
75
|
} else if (isRecommended) {
|
|
88
|
-
hint =
|
|
89
|
-
} else {
|
|
90
|
-
hint = `~€${totalCost.toFixed(0)}/mo total`;
|
|
76
|
+
hint = 'recommended';
|
|
91
77
|
}
|
|
92
78
|
|
|
93
79
|
return {
|
|
@@ -101,11 +87,8 @@ export function buildComposeTypeOptions(regionTypes, defaultType) {
|
|
|
101
87
|
|
|
102
88
|
/**
|
|
103
89
|
* Build select options for K8s cluster size profiles.
|
|
104
|
-
* Calculates total monthly cost including all provisioned infra.
|
|
105
90
|
*/
|
|
106
91
|
export function buildK8sProfileOptions(_region, regionTypes, secondaryRegion) {
|
|
107
|
-
const { INFRA_COSTS, VOLUME_SIZES, SERVER_TYPES } = HetznerProvider;
|
|
108
|
-
|
|
109
92
|
// Determine available types (intersection of both regions for HA)
|
|
110
93
|
const availableNames = new Set(regionTypes.map((t) => t.name));
|
|
111
94
|
let effectiveAvailable = availableNames;
|
|
@@ -117,10 +100,6 @@ export function buildK8sProfileOptions(_region, regionTypes, secondaryRegion) {
|
|
|
117
100
|
}
|
|
118
101
|
|
|
119
102
|
const hasArm = effectiveAvailable.has('cax11');
|
|
120
|
-
const regionMultiplier = secondaryRegion ? 2 : 1;
|
|
121
|
-
|
|
122
|
-
// Per-cluster fixed costs: floating IP + base CSI volumes
|
|
123
|
-
const fixedPerCluster = INFRA_COSTS.floatingIp + VOLUME_SIZES.base * INFRA_COSTS.volumePerGb;
|
|
124
103
|
|
|
125
104
|
return K8S_PROFILES.map((profile) => {
|
|
126
105
|
const variant = hasArm ? profile.arm : profile.x86;
|
|
@@ -131,16 +110,9 @@ export function buildK8sProfileOptions(_region, regionTypes, secondaryRegion) {
|
|
|
131
110
|
);
|
|
132
111
|
if (!allAvailable) return null;
|
|
133
112
|
|
|
134
|
-
const serverCost =
|
|
135
|
-
parsePrice(SERVER_TYPES[variant.master]?.price) +
|
|
136
|
-
parsePrice(SERVER_TYPES[variant.supabase]?.price) +
|
|
137
|
-
parsePrice(SERVER_TYPES[variant.worker]?.price);
|
|
138
|
-
|
|
139
|
-
const totalCost = (serverCost + fixedPerCluster) * regionMultiplier + INFRA_COSTS.s3MinBucket;
|
|
140
|
-
|
|
141
113
|
return {
|
|
142
114
|
value: profile.name,
|
|
143
|
-
label:
|
|
115
|
+
label: profile.label,
|
|
144
116
|
hint: `${profile.hint} (${variant.master} + ${variant.supabase} + ${variant.worker})`,
|
|
145
117
|
_variant: variant,
|
|
146
118
|
};
|
package/src/lib/ssh.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
17
17
|
import { tmpdir } from 'node:os';
|
|
18
18
|
import { join } from 'node:path';
|
|
19
|
-
import {
|
|
19
|
+
import { runCommandAsync } from './command.js';
|
|
20
20
|
import { knownHostsPath } from './host-keys.js';
|
|
21
21
|
import { shEscape } from './shell.js';
|
|
22
22
|
|
|
@@ -130,10 +130,11 @@ export const NO_PIN_SSH_OPTS = sshHostKeyOpts().join(' ');
|
|
|
130
130
|
* @param {boolean} [options.firstConnect=false]
|
|
131
131
|
* @param {number} [options.timeout=120000]
|
|
132
132
|
* @param {boolean} [options.silent=true]
|
|
133
|
-
* @
|
|
133
|
+
* @param {string} [options.input] - stdin piped to the remote command
|
|
134
|
+
* @returns {Promise<string>} - trimmed stdout
|
|
134
135
|
*/
|
|
135
|
-
export function sshRun(ip, sshKeyPath, argv, options = {}) {
|
|
136
|
-
const { env, firstConnect = false, timeout = 120_000, silent = true } = options;
|
|
136
|
+
export async function sshRun(ip, sshKeyPath, argv, options = {}) {
|
|
137
|
+
const { env, firstConnect = false, timeout = 120_000, silent = true, input } = options;
|
|
137
138
|
if (!Array.isArray(argv) || argv.length === 0) {
|
|
138
139
|
throw new Error('sshRun requires a non-empty argv array');
|
|
139
140
|
}
|
|
@@ -147,7 +148,7 @@ export function sshRun(ip, sshKeyPath, argv, options = {}) {
|
|
|
147
148
|
`root@${ip}`,
|
|
148
149
|
remoteCmd,
|
|
149
150
|
];
|
|
150
|
-
const out =
|
|
151
|
+
const out = await runCommandAsync(cmd, { silent, timeout, returnOutput: true, input });
|
|
151
152
|
return typeof out === 'string' ? out.trim() : '';
|
|
152
153
|
}
|
|
153
154
|
|
|
@@ -157,17 +158,17 @@ export function sshRun(ip, sshKeyPath, argv, options = {}) {
|
|
|
157
158
|
* via bash, then removed. Use for pipelines that genuinely need shell
|
|
158
159
|
* features; never interpolate untrusted values into the script string.
|
|
159
160
|
*/
|
|
160
|
-
export function sshRunScript(ip, sshKeyPath, bashScript, options = {}) {
|
|
161
|
+
export async function sshRunScript(ip, sshKeyPath, bashScript, options = {}) {
|
|
161
162
|
const localTmp = mkdtempSync(join(tmpdir(), 'vb-sshscript-'));
|
|
162
163
|
const localPath = join(localTmp, 'script.sh');
|
|
163
164
|
const remotePath = `/tmp/vb-script-${Date.now()}-${Math.random().toString(36).slice(2, 10)}.sh`;
|
|
164
165
|
try {
|
|
165
166
|
writeFileSync(localPath, bashScript, { mode: 0o700 });
|
|
166
|
-
scpUpload(ip, sshKeyPath, localPath, remotePath, options);
|
|
167
|
-
return sshRun(ip, sshKeyPath, ['bash', remotePath], options);
|
|
167
|
+
await scpUpload(ip, sshKeyPath, localPath, remotePath, options);
|
|
168
|
+
return await sshRun(ip, sshKeyPath, ['bash', remotePath], options);
|
|
168
169
|
} finally {
|
|
169
170
|
try {
|
|
170
|
-
sshRun(ip, sshKeyPath, ['rm', '-f', remotePath], {
|
|
171
|
+
await sshRun(ip, sshKeyPath, ['rm', '-f', remotePath], {
|
|
171
172
|
...options,
|
|
172
173
|
silent: true,
|
|
173
174
|
timeout: 15_000,
|
|
@@ -182,7 +183,7 @@ export function sshRunScript(ip, sshKeyPath, bashScript, options = {}) {
|
|
|
182
183
|
/**
|
|
183
184
|
* Download a file from a remote server via SCP. Argv form.
|
|
184
185
|
*/
|
|
185
|
-
export function scpDownload(ip, sshKeyPath, remotePath, localPath, options = {}) {
|
|
186
|
+
export async function scpDownload(ip, sshKeyPath, remotePath, localPath, options = {}) {
|
|
186
187
|
const { env, firstConnect = false, timeout = 300_000 } = options;
|
|
187
188
|
const cmd = [
|
|
188
189
|
'scp',
|
|
@@ -193,13 +194,13 @@ export function scpDownload(ip, sshKeyPath, remotePath, localPath, options = {})
|
|
|
193
194
|
`root@${ip}:${remotePath}`,
|
|
194
195
|
localPath,
|
|
195
196
|
];
|
|
196
|
-
|
|
197
|
+
await runCommandAsync(cmd, { silent: true, timeout });
|
|
197
198
|
}
|
|
198
199
|
|
|
199
200
|
/**
|
|
200
201
|
* Upload a file to a remote server via SCP. Argv form.
|
|
201
202
|
*/
|
|
202
|
-
export function scpUpload(ip, sshKeyPath, localPath, remotePath, options = {}) {
|
|
203
|
+
export async function scpUpload(ip, sshKeyPath, localPath, remotePath, options = {}) {
|
|
203
204
|
const { env, firstConnect = false, timeout = 300_000 } = options;
|
|
204
205
|
const cmd = [
|
|
205
206
|
'scp',
|
|
@@ -210,7 +211,7 @@ export function scpUpload(ip, sshKeyPath, localPath, remotePath, options = {}) {
|
|
|
210
211
|
localPath,
|
|
211
212
|
`root@${ip}:${remotePath}`,
|
|
212
213
|
];
|
|
213
|
-
|
|
214
|
+
await runCommandAsync(cmd, { silent: true, timeout });
|
|
214
215
|
}
|
|
215
216
|
|
|
216
217
|
/**
|
|
@@ -222,13 +223,13 @@ export function scpUpload(ip, sshKeyPath, localPath, remotePath, options = {}) {
|
|
|
222
223
|
* @param {string} sshKeyPath
|
|
223
224
|
* @param {string[]} kubectlArgv - kubectl subcommand + args (e.g. ['get', 'pods', '-n', 'vibecarbon'])
|
|
224
225
|
* @param {object} [options]
|
|
225
|
-
* @returns {string} trimmed stdout
|
|
226
|
+
* @returns {Promise<string>} trimmed stdout
|
|
226
227
|
*/
|
|
227
|
-
export function sshKubectl(ip, sshKeyPath, kubectlArgv, options = {}) {
|
|
228
|
+
export async function sshKubectl(ip, sshKeyPath, kubectlArgv, options = {}) {
|
|
228
229
|
if (!Array.isArray(kubectlArgv) || kubectlArgv.length === 0) {
|
|
229
230
|
throw new Error('sshKubectl requires a non-empty argv array (without leading "kubectl")');
|
|
230
231
|
}
|
|
231
|
-
return sshRun(
|
|
232
|
+
return await sshRun(
|
|
232
233
|
ip,
|
|
233
234
|
sshKeyPath,
|
|
234
235
|
['env', `KUBECONFIG=${K3S_KUBECONFIG}`, 'kubectl', ...kubectlArgv],
|
|
@@ -239,8 +240,8 @@ export function sshKubectl(ip, sshKeyPath, kubectlArgv, options = {}) {
|
|
|
239
240
|
/**
|
|
240
241
|
* Get the name of the PostgreSQL pod in the vibecarbon namespace.
|
|
241
242
|
*/
|
|
242
|
-
export function getPostgresPod(ip, sshKeyPath) {
|
|
243
|
-
return sshKubectl(ip, sshKeyPath, [
|
|
243
|
+
export async function getPostgresPod(ip, sshKeyPath) {
|
|
244
|
+
return await sshKubectl(ip, sshKeyPath, [
|
|
244
245
|
'get',
|
|
245
246
|
'pods',
|
|
246
247
|
'-n',
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* List wal-g base backups by running `wal-g backup-list --json` inside the
|
|
3
|
+
* Postgres container (compose) or pod (k8s). wal-g is the source of truth for
|
|
4
|
+
* what is actually restorable — the legacy `backups/*_full.tar.gz` S3 objects
|
|
5
|
+
* are NOT wal-g backups and must not be offered as restore targets.
|
|
6
|
+
*
|
|
7
|
+
* Restore itself always fetches the LATEST base backup and replays WAL (to the
|
|
8
|
+
* present or a point-in-time target), so this listing is informational — it
|
|
9
|
+
* tells the operator the available recovery window for PITR.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { getPostgresPod, sshKubectl, sshRun } from './ssh.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Parse `wal-g backup-list --json` output into normalized entries, newest-first.
|
|
16
|
+
* Pure (no I/O) so it's unit-testable. Tolerant of wal-g version field-name
|
|
17
|
+
* drift (backup_name/BackupName, time/start_time/finish_time/…).
|
|
18
|
+
*
|
|
19
|
+
* @param {string} jsonStr
|
|
20
|
+
* @returns {Array<{ name: string, time: Date }>}
|
|
21
|
+
*/
|
|
22
|
+
export function parseWalgBackupList(jsonStr) {
|
|
23
|
+
let arr;
|
|
24
|
+
try {
|
|
25
|
+
arr = JSON.parse(jsonStr);
|
|
26
|
+
} catch {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
if (!Array.isArray(arr)) return [];
|
|
30
|
+
return arr
|
|
31
|
+
.map((b) => {
|
|
32
|
+
const name = b.backup_name ?? b.BackupName ?? b.name ?? null;
|
|
33
|
+
const rawTime =
|
|
34
|
+
b.time ?? b.start_time ?? b.StartTime ?? b.finish_time ?? b.FinishTime ?? b.creation_time;
|
|
35
|
+
const time = rawTime ? new Date(rawTime) : null;
|
|
36
|
+
return { name, time };
|
|
37
|
+
})
|
|
38
|
+
.filter((b) => b.name && b.time && !Number.isNaN(b.time.getTime()))
|
|
39
|
+
.sort((a, b) => b.time.getTime() - a.time.getTime());
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* List wal-g base backups for an environment. Best-effort: returns [] on any
|
|
44
|
+
* failure (db down, wal-g missing, no --json support, parse error) so callers
|
|
45
|
+
* can degrade gracefully without breaking the restore/backup flow.
|
|
46
|
+
*
|
|
47
|
+
* @param {object} opts
|
|
48
|
+
* @param {string} opts.serverIp - master/single node IP
|
|
49
|
+
* @param {string} opts.sshKeyPath
|
|
50
|
+
* @param {string} opts.projectName
|
|
51
|
+
* @param {boolean} opts.isCompose - true for compose, false for k8s
|
|
52
|
+
* @returns {Promise<Array<{ name: string, time: Date }>>}
|
|
53
|
+
*/
|
|
54
|
+
export async function listWalgBackups({ serverIp, sshKeyPath, projectName, isCompose }) {
|
|
55
|
+
try {
|
|
56
|
+
let out;
|
|
57
|
+
if (isCompose) {
|
|
58
|
+
out = await sshRun(serverIp, sshKeyPath, [
|
|
59
|
+
'bash',
|
|
60
|
+
'-lc',
|
|
61
|
+
`cd /opt/${projectName} && docker compose exec -T db wal-g backup-list --json`,
|
|
62
|
+
]);
|
|
63
|
+
} else {
|
|
64
|
+
const pod = await getPostgresPod(serverIp, sshKeyPath);
|
|
65
|
+
if (!pod) return [];
|
|
66
|
+
out = await sshKubectl(serverIp, sshKeyPath, [
|
|
67
|
+
'exec',
|
|
68
|
+
'-n',
|
|
69
|
+
'vibecarbon',
|
|
70
|
+
pod,
|
|
71
|
+
'--',
|
|
72
|
+
'wal-g',
|
|
73
|
+
'backup-list',
|
|
74
|
+
'--json',
|
|
75
|
+
]);
|
|
76
|
+
}
|
|
77
|
+
return typeof out === 'string' ? parseWalgBackupList(out) : [];
|
|
78
|
+
} catch {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
}
|