vibecarbon 0.8.0 → 0.10.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/LICENSE +110 -663
- package/README.md +4 -4
- package/carbon/.env.example +11 -0
- package/carbon/backup/compose-backup.sh +8 -0
- package/carbon/docker-compose.yml +11 -0
- package/carbon/ha/primary-init.sql +10 -2
- package/carbon/k8s/base/backup/cronjob.yaml +9 -0
- package/carbon/k8s/base/repl-gateway/kustomization.yaml +11 -0
- package/carbon/k8s/base/repl-gateway/repl-gateway.yaml +92 -0
- package/carbon/k8s/values/supabase.values.yaml +6 -1
- package/carbon/scripts/dev-init.js +6 -2
- package/carbon/src/client/assets/vibecarbon-icon.svg +6 -6
- package/carbon/src/client/assets/vibecarbon-wordmark-text-dark.svg +12 -0
- package/carbon/src/client/assets/vibecarbon-wordmark-text-light.svg +12 -0
- package/carbon/src/client/components/AIIntegrationSection.tsx +1 -3
- package/carbon/src/client/components/AppSidebar.tsx +35 -28
- package/carbon/src/client/components/CTAFooter.tsx +1 -1
- package/carbon/src/client/components/ComparisonSection.tsx +1 -3
- package/carbon/src/client/components/ContentPanel.tsx +1 -1
- package/carbon/src/client/components/DeploymentScenariosSection.tsx +1 -3
- package/carbon/src/client/components/FAQSection.tsx +1 -1
- package/carbon/src/client/components/Hero.tsx +13 -1
- package/carbon/src/client/components/Logo.tsx +66 -49
- package/carbon/src/client/components/Nav.tsx +1 -1
- package/carbon/src/client/components/NewsletterSignup.tsx +1 -1
- package/carbon/src/client/components/NotificationDrawer.tsx +3 -3
- package/carbon/src/client/components/PageHeader.tsx +1 -1
- package/carbon/src/client/components/PricingSection.tsx +2 -6
- package/carbon/src/client/components/TechStackSection.tsx +1 -3
- package/carbon/src/client/components/effects/FilmGrainOverlay.tsx +5 -0
- package/carbon/src/client/components/ui/button.tsx +1 -1
- package/carbon/src/client/components/ui/card.tsx +1 -1
- package/carbon/src/client/components/ui/dialog.tsx +1 -1
- package/carbon/src/client/components/ui/dropdown-menu.tsx +2 -2
- package/carbon/src/client/components/ui/popover.tsx +1 -1
- package/carbon/src/client/components/ui/select.tsx +1 -1
- package/carbon/src/client/components/ui/sidebar.tsx +1 -1
- package/carbon/src/client/index.css +186 -104
- package/carbon/src/client/lib/supabase.ts +41 -0
- package/carbon/src/client/lib/theme.ts +14 -14
- package/carbon/src/client/locales/de.json +27 -12
- package/carbon/src/client/locales/en.json +33 -18
- package/carbon/src/client/locales/es.json +27 -12
- package/carbon/src/client/locales/fr.json +27 -12
- package/carbon/src/client/locales/pt.json +27 -12
- package/carbon/src/client/pages/Blog.tsx +1 -1
- package/carbon/src/client/pages/Changelog.tsx +1 -1
- package/carbon/src/client/pages/Checkout.tsx +1 -1
- package/carbon/src/client/pages/Dashboard.tsx +33 -40
- package/carbon/src/client/pages/Docs.tsx +1 -1
- package/carbon/src/client/pages/Home.tsx +1 -1
- package/carbon/src/client/pages/Legal.tsx +1 -1
- package/carbon/src/server/billing/providers/paddle.ts +21 -2
- package/carbon/src/server/billing/providers/polar.ts +14 -0
- package/carbon/src/server/index.ts +12 -9
- package/carbon/src/server/lib/client-ip.ts +68 -0
- package/carbon/src/server/lib/env.ts +20 -6
- package/carbon/src/server/lib/rate-limiter.ts +11 -65
- package/carbon/src/server/lib/supabase.ts +10 -4
- package/carbon/src/server/middleware/requireOrgRole.ts +76 -0
- package/carbon/src/server/middleware/requirePlan.ts +64 -34
- package/carbon/src/server/middleware/requireSuperAdmin.ts +26 -0
- package/carbon/src/server/routes/v1/admin/contact.ts +5 -20
- package/carbon/src/server/routes/v1/admin/jobs.ts +6 -20
- package/carbon/src/server/routes/v1/admin/newsletter.ts +26 -23
- package/carbon/src/server/routes/v1/auth.ts +5 -2
- package/carbon/src/server/routes/v1/billing.ts +24 -64
- package/carbon/src/server/routes/v1/index.ts +8 -1
- package/carbon/src/server/routes/v1/newsletter.ts +18 -12
- package/carbon/src/server/routes/v1/theme.ts +36 -15
- package/carbon/supabase/migrations/00003_pg_cron.sql +2 -2
- package/carbon/supabase/migrations/00004_contact_newsletter.sql +24 -14
- package/carbon/tests/component/supabase-env-guard.test.ts +54 -0
- package/carbon/tests/integration/server/middleware/rate-limit-ip-spoof.test.ts +48 -0
- package/carbon/tests/integration/server/middleware/require-plan.test.ts +101 -0
- package/carbon/tests/integration/server/routes/admin-newsletter.test.ts +93 -0
- package/carbon/tests/integration/server/routes/billing-subscription-idor.test.ts +92 -0
- package/carbon/tests/integration/server/routes/newsletter-unsubscribe.test.ts +65 -0
- package/carbon/tests/integration/server/routes/theme-validation.test.ts +66 -0
- package/carbon/tests/unit/server/client-ip.test.ts +76 -0
- package/carbon/tests/unit/server/env-bool.test.ts +63 -0
- package/carbon/tests/unit/server/paddle-webhook.test.ts +57 -0
- package/carbon/tests/unit/server/polar-webhook.test.ts +63 -0
- package/carbon/tests/unit/server/supabase-client.test.ts +30 -0
- package/carbon/volumes/db/wal-archive.sh +36 -0
- package/package.json +2 -2
- package/src/backup.js +0 -2
- package/src/cli.js +16 -0
- package/src/deploy.js +16 -5
- package/src/destroy.js +78 -0
- package/src/failover.js +118 -237
- package/src/lib/command.js +19 -1
- package/src/lib/deploy/compose/ha.js +180 -102
- package/src/lib/deploy/compose/index.js +63 -20
- package/src/lib/deploy/image.js +19 -23
- package/src/lib/deploy/k8s/ha/index.js +397 -150
- package/src/lib/deploy/k8s/k3s.js +65 -14
- package/src/lib/deploy/orchestrator.js +176 -6
- package/src/lib/deploy/remote-build.js +8 -1
- package/src/lib/deploy/replication.js +540 -0
- package/src/lib/deploy/state.js +62 -5
- package/src/lib/deploy/utils.js +30 -2
- package/src/lib/deploy/wireguard.js +146 -0
- package/src/lib/host-keys.js +120 -5
- package/src/lib/iac/index.js +57 -23
- package/src/lib/licensing/gate.js +59 -0
- package/src/lib/licensing/index.js +1 -1
- package/src/lib/licensing/tiers.js +6 -5
- package/src/lib/prod-confirm.js +65 -0
- package/src/lib/providers/hetzner-s3.js +85 -0
- package/src/lib/ssh.js +35 -35
- package/src/restore.js +102 -14
- package/src/scale.js +4 -2
- package/src/status.js +122 -0
- package/src/upgrade.js +0 -4
- package/carbon/ha/activate-standby.sh +0 -52
- package/carbon/ha/standby-init.sh +0 -74
- package/carbon/src/client/assets/vibecarbon-logo-dark.svg +0 -29
- package/carbon/src/client/assets/vibecarbon-logo-light.svg +0 -29
- package/carbon/src/server/lib/request.ts +0 -34
- package/src/lib/pod-backups.js +0 -53
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import {
|
|
19
19
|
AbortMultipartUploadCommand,
|
|
20
|
+
CopyObjectCommand,
|
|
20
21
|
CreateBucketCommand,
|
|
21
22
|
DeleteBucketCommand,
|
|
22
23
|
DeleteObjectsCommand,
|
|
@@ -251,6 +252,74 @@ export class HetznerS3Provider {
|
|
|
251
252
|
}
|
|
252
253
|
}
|
|
253
254
|
|
|
255
|
+
/**
|
|
256
|
+
* Return true if the bucket contains at least one object under `prefix`.
|
|
257
|
+
* Tolerant of NoSuchBucket (returns false) so callers can probe a bucket
|
|
258
|
+
* that may not exist yet. Used by the deploy-time Pulumi-state migration to
|
|
259
|
+
* decide whether the dedicated state bucket has already been seeded and
|
|
260
|
+
* whether the legacy app bucket still holds state to copy over.
|
|
261
|
+
* @param {string} bucketName
|
|
262
|
+
* @param {string} prefix
|
|
263
|
+
* @returns {Promise<boolean>}
|
|
264
|
+
*/
|
|
265
|
+
async hasObjectsWithPrefix(bucketName, prefix) {
|
|
266
|
+
try {
|
|
267
|
+
const res = await this._send(
|
|
268
|
+
new ListObjectsV2Command({ Bucket: bucketName, Prefix: prefix, MaxKeys: 1 }),
|
|
269
|
+
{
|
|
270
|
+
terminal: (err) => {
|
|
271
|
+
const status = err.$metadata?.httpStatusCode;
|
|
272
|
+
return err.name === 'NoSuchBucket' || status === 404;
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
);
|
|
276
|
+
return (res.KeyCount ?? res.Contents?.length ?? 0) > 0;
|
|
277
|
+
} catch (err) {
|
|
278
|
+
const status = err.$metadata?.httpStatusCode;
|
|
279
|
+
if (err.name === 'NoSuchBucket' || status === 404) return false;
|
|
280
|
+
throw err;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Copy every object under `prefix` from `srcBucket` to `destBucket`,
|
|
286
|
+
* preserving keys. Server-side copy (CopyObjectCommand) — no bytes transit
|
|
287
|
+
* the operator. Used to migrate legacy Pulumi state (the `.pulumi/` prefix)
|
|
288
|
+
* out of the app storage bucket into the dedicated state bucket. Both
|
|
289
|
+
* buckets must be in this provider's region.
|
|
290
|
+
* @param {string} srcBucket
|
|
291
|
+
* @param {string} destBucket
|
|
292
|
+
* @param {string} prefix
|
|
293
|
+
* @returns {Promise<{copied: number}>}
|
|
294
|
+
*/
|
|
295
|
+
async copyPrefix(srcBucket, destBucket, prefix) {
|
|
296
|
+
let continuationToken;
|
|
297
|
+
let copied = 0;
|
|
298
|
+
do {
|
|
299
|
+
const listResult = await this._send(
|
|
300
|
+
new ListObjectsV2Command({
|
|
301
|
+
Bucket: srcBucket,
|
|
302
|
+
Prefix: prefix,
|
|
303
|
+
ContinuationToken: continuationToken,
|
|
304
|
+
}),
|
|
305
|
+
);
|
|
306
|
+
for (const obj of listResult.Contents || []) {
|
|
307
|
+
await this._send(
|
|
308
|
+
new CopyObjectCommand({
|
|
309
|
+
Bucket: destBucket,
|
|
310
|
+
Key: obj.Key,
|
|
311
|
+
// CopySource must be URL-encoded; keys contain `/` path separators
|
|
312
|
+
// (`.pulumi/stacks/...`) which encodeURI preserves.
|
|
313
|
+
CopySource: encodeURI(`${srcBucket}/${obj.Key}`),
|
|
314
|
+
}),
|
|
315
|
+
);
|
|
316
|
+
copied += 1;
|
|
317
|
+
}
|
|
318
|
+
continuationToken = listResult.IsTruncated ? listResult.NextContinuationToken : undefined;
|
|
319
|
+
} while (continuationToken);
|
|
320
|
+
return { copied };
|
|
321
|
+
}
|
|
322
|
+
|
|
254
323
|
/**
|
|
255
324
|
* Search all S3 regions for a bucket we own.
|
|
256
325
|
* Each region's HeadBucket failure is intentionally swallowed — finding
|
|
@@ -674,3 +743,19 @@ export function sanitizeBucketName(projectName, suffix = 'storage') {
|
|
|
674
743
|
.replace(/^-|-$/g, '') // Remove leading/trailing hyphens
|
|
675
744
|
.slice(0, 63); // Truncate to max length
|
|
676
745
|
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* Derive the dedicated Pulumi-state bucket name from the app storage bucket
|
|
749
|
+
* name. Pulumi state used to live in the app storage bucket, which created a
|
|
750
|
+
* destroy-time circular hazard (deleting the storage bucket nuked the state
|
|
751
|
+
* backend mid-destroy). State now lives in a separate bucket derived once,
|
|
752
|
+
* deterministically, from the app bucket — so the derivation is stable across
|
|
753
|
+
* deploy / scale / destroy and identical for both HA stacks (they share one
|
|
754
|
+
* state bucket).
|
|
755
|
+
*
|
|
756
|
+
* @param {string} appBucketName - The app storage bucket (e.g. `myapp-storage`)
|
|
757
|
+
* @returns {string} - Valid state bucket name (e.g. `myapp-storage-pulumi-state`)
|
|
758
|
+
*/
|
|
759
|
+
export function deriveStateBucketName(appBucketName) {
|
|
760
|
+
return sanitizeBucketName(appBucketName, 'pulumi-state');
|
|
761
|
+
}
|
package/src/lib/ssh.js
CHANGED
|
@@ -8,16 +8,22 @@
|
|
|
8
8
|
* of dynamic tokens), or
|
|
9
9
|
* 2. Use sshRunScript to execute a bash script SCP'd as a file.
|
|
10
10
|
*
|
|
11
|
-
* Host-key checking:
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* Host-key checking: EVERY call pins against a per-environment known_hosts
|
|
12
|
+
* file — there is no /dev/null + StrictHostKeyChecking=no bypass. When a
|
|
13
|
+
* caller passes an explicit `env`, first-provision callers pass
|
|
14
|
+
* { firstConnect: true } to accept-new (TOFU) once and strict-check ('yes')
|
|
15
|
+
* thereafter. Callers that only have `(ip, sshKeyPath)` get the per-env
|
|
16
|
+
* known_hosts derived from the key path (knownHostsPathForKey) with
|
|
17
|
+
* StrictHostKeyChecking=accept-new — which still REJECTS a changed key for an
|
|
18
|
+
* already-pinned host (MITM on an established env fails) while TOFU-ing a fresh
|
|
19
|
+
* or recycled IP. The operator's ~/.ssh/known_hosts is never touched.
|
|
14
20
|
*/
|
|
15
21
|
|
|
16
22
|
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
17
23
|
import { tmpdir } from 'node:os';
|
|
18
24
|
import { join } from 'node:path';
|
|
19
25
|
import { runCommandAsync } from './command.js';
|
|
20
|
-
import { knownHostsPath } from './host-keys.js';
|
|
26
|
+
import { knownHostsPath, knownHostsPathForKey } from './host-keys.js';
|
|
21
27
|
import { shEscape } from './shell.js';
|
|
22
28
|
|
|
23
29
|
// k3s writes its kubeconfig here; kubectl doesn't find it by default in SSH sessions.
|
|
@@ -53,30 +59,36 @@ export function getSSHKeyPath(environment) {
|
|
|
53
59
|
}
|
|
54
60
|
|
|
55
61
|
/**
|
|
56
|
-
* Build the SSH `-o` option args for host-key checking.
|
|
62
|
+
* Build the SSH `-o` option args for host-key checking. Always pins against a
|
|
63
|
+
* per-environment known_hosts file under .vibecarbon/ — never /dev/null.
|
|
64
|
+
* GlobalKnownHostsFile=/dev/null ignores the system-wide file; the operator's
|
|
65
|
+
* ~/.ssh/known_hosts is never read or polluted (Hetzner recycles public IPs,
|
|
66
|
+
* and a stale entry there would reject an unrelated reconnect).
|
|
57
67
|
*
|
|
58
|
-
* - With `env`:
|
|
59
|
-
* (or accept-new
|
|
68
|
+
* - With explicit `env`: pin to .vibecarbon/known_hosts_<env>,
|
|
69
|
+
* StrictHostKeyChecking=yes (or accept-new when firstConnect=true, used
|
|
70
|
+
* during first provision).
|
|
60
71
|
*
|
|
61
|
-
* -
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
* remediation-design.md) replaces this with env-scoped pinned known_hosts
|
|
69
|
-
* across every CLI command — callers pass `env` once the deploy path
|
|
70
|
-
* populates .vibecarbon/known_hosts_<env>.
|
|
72
|
+
* - With only `sshKeyPath` (backup/restore/scale/failover only receive the key
|
|
73
|
+
* path, not an env): derive .vibecarbon/known_hosts_<env> from the key path
|
|
74
|
+
* (knownHostsPathForKey) and use StrictHostKeyChecking=accept-new. accept-new
|
|
75
|
+
* bootstraps an empty pin and TOFU's a fresh/recycled IP, but REJECTS a
|
|
76
|
+
* changed key for an already-pinned host — so a MITM against an established
|
|
77
|
+
* env fails, while destroy→redeploy (same IP, new key, re-seeded on
|
|
78
|
+
* provision) does not spuriously hard-fail.
|
|
71
79
|
*/
|
|
72
|
-
function sshHostKeyOpts(env, { firstConnect = false } = {}) {
|
|
80
|
+
function sshHostKeyOpts(env, { firstConnect = false, sshKeyPath } = {}) {
|
|
73
81
|
const opts = [];
|
|
74
82
|
if (env) {
|
|
75
83
|
opts.push('-o', `UserKnownHostsFile=${knownHostsPath(env)}`);
|
|
84
|
+
opts.push('-o', 'GlobalKnownHostsFile=/dev/null');
|
|
76
85
|
opts.push('-o', `StrictHostKeyChecking=${firstConnect ? 'accept-new' : 'yes'}`);
|
|
86
|
+
} else if (sshKeyPath) {
|
|
87
|
+
opts.push('-o', `UserKnownHostsFile=${knownHostsPathForKey(sshKeyPath)}`);
|
|
88
|
+
opts.push('-o', 'GlobalKnownHostsFile=/dev/null');
|
|
89
|
+
opts.push('-o', 'StrictHostKeyChecking=accept-new');
|
|
77
90
|
} else {
|
|
78
|
-
|
|
79
|
-
opts.push('-o', 'StrictHostKeyChecking=no');
|
|
91
|
+
throw new Error('sshHostKeyOpts requires either `env` or `sshKeyPath` for host-key pinning');
|
|
80
92
|
}
|
|
81
93
|
opts.push('-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10');
|
|
82
94
|
// ConnectTimeout only covers TCP connect — once the socket is open, ssh
|
|
@@ -94,18 +106,6 @@ function sshHostKeyOpts(env, { firstConnect = false } = {}) {
|
|
|
94
106
|
return opts;
|
|
95
107
|
}
|
|
96
108
|
|
|
97
|
-
/**
|
|
98
|
-
* The no-host-key-pinning SSH option set as a single space-joined string,
|
|
99
|
-
* for the compose deploy path's string-command runners (sshRun/sshRunAsync
|
|
100
|
-
* in compose/index.js) and raw `ssh ${SSH_OPTS}` interpolations.
|
|
101
|
-
*
|
|
102
|
-
* Derived from sshHostKeyOpts() so the string form can never drift from the
|
|
103
|
-
* argv form — the historical footgun was compose/ha.js hand-copying this
|
|
104
|
-
* string and silently dropping the ServerAlive keepalives, letting a
|
|
105
|
-
* banner-exchange hang stall an HA deploy for ~600s.
|
|
106
|
-
*/
|
|
107
|
-
export const NO_PIN_SSH_OPTS = sshHostKeyOpts().join(' ');
|
|
108
|
-
|
|
109
109
|
/**
|
|
110
110
|
* Execute a command on a remote server via SSH using argv form.
|
|
111
111
|
*
|
|
@@ -143,7 +143,7 @@ export async function sshRun(ip, sshKeyPath, argv, options = {}) {
|
|
|
143
143
|
'ssh',
|
|
144
144
|
'-i',
|
|
145
145
|
sshKeyPath,
|
|
146
|
-
...sshHostKeyOpts(env, { firstConnect }),
|
|
146
|
+
...sshHostKeyOpts(env, { firstConnect, sshKeyPath }),
|
|
147
147
|
'--',
|
|
148
148
|
`root@${ip}`,
|
|
149
149
|
remoteCmd,
|
|
@@ -189,7 +189,7 @@ export async function scpDownload(ip, sshKeyPath, remotePath, localPath, options
|
|
|
189
189
|
'scp',
|
|
190
190
|
'-i',
|
|
191
191
|
sshKeyPath,
|
|
192
|
-
...sshHostKeyOpts(env, { firstConnect }),
|
|
192
|
+
...sshHostKeyOpts(env, { firstConnect, sshKeyPath }),
|
|
193
193
|
'--',
|
|
194
194
|
`root@${ip}:${remotePath}`,
|
|
195
195
|
localPath,
|
|
@@ -206,7 +206,7 @@ export async function scpUpload(ip, sshKeyPath, localPath, remotePath, options =
|
|
|
206
206
|
'scp',
|
|
207
207
|
'-i',
|
|
208
208
|
sshKeyPath,
|
|
209
|
-
...sshHostKeyOpts(env, { firstConnect }),
|
|
209
|
+
...sshHostKeyOpts(env, { firstConnect, sshKeyPath }),
|
|
210
210
|
'--',
|
|
211
211
|
localPath,
|
|
212
212
|
`root@${ip}:${remotePath}`,
|
package/src/restore.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import { existsSync } from 'node:fs';
|
|
20
20
|
import { basename } from 'node:path';
|
|
21
21
|
import * as p from '@clack/prompts';
|
|
22
|
+
import { identifyServers } from './failover.js';
|
|
22
23
|
import { formatInstant } from './lib/backup-format.js';
|
|
23
24
|
import { renderHelp } from './lib/cli/help.js';
|
|
24
25
|
import { parseFlags } from './lib/cli/parse-flags.js';
|
|
@@ -31,9 +32,14 @@ import { loadCredentials, loadProjectConfig } from './lib/config.js';
|
|
|
31
32
|
// while still accepting injected mocks for unit tests.
|
|
32
33
|
import { configureStandbyReplication as realConfigureStandbyReplication } from './lib/deploy/compose/ha.js';
|
|
33
34
|
import { restoreCompose as realRestoreCompose } from './lib/deploy/compose/index.js';
|
|
34
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
readK8sReplicationState as realReadK8sReplicationState,
|
|
37
|
+
reseedStandbyFromPrimary as realReseedStandbyFromPrimary,
|
|
38
|
+
verifyStreaming,
|
|
39
|
+
} from './lib/deploy/replication.js';
|
|
35
40
|
import { ensureOperatorIpAccess } from './lib/operator-ip.js';
|
|
36
41
|
import { perfAsync } from './lib/perf.js';
|
|
42
|
+
import { confirmProdOrExit } from './lib/prod-confirm.js';
|
|
37
43
|
import { assertInProjectDir } from './lib/project-guard.js';
|
|
38
44
|
import { getPostgresPod, getSSHKeyPath, sshKubectl } from './lib/ssh.js';
|
|
39
45
|
import { createTracker } from './lib/tracker.js';
|
|
@@ -245,7 +251,6 @@ export async function run(args) {
|
|
|
245
251
|
// restore` from a parent directory emits the canonical message.
|
|
246
252
|
assertInProjectDir();
|
|
247
253
|
|
|
248
|
-
requireLicense('restore');
|
|
249
254
|
printBanner();
|
|
250
255
|
p.intro(`${c.bold('vibecarbon restore')} ${c.dim(`v${VERSION}`)}`);
|
|
251
256
|
|
|
@@ -392,19 +397,15 @@ export async function run(args) {
|
|
|
392
397
|
p.cancel('Operation cancelled.');
|
|
393
398
|
process.exit(0);
|
|
394
399
|
}
|
|
395
|
-
|
|
396
|
-
if (envName === 'prod' || envName === 'production') {
|
|
397
|
-
const doubleConfirm = await p.text({
|
|
398
|
-
message: `Type "${envName}" to confirm restoration of production database:`,
|
|
399
|
-
validate: (v) => (v !== envName ? `Please type "${envName}" to confirm` : undefined),
|
|
400
|
-
});
|
|
401
|
-
if (p.isCancel(doubleConfirm)) {
|
|
402
|
-
p.cancel('Operation cancelled.');
|
|
403
|
-
process.exit(0);
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
400
|
}
|
|
407
401
|
|
|
402
|
+
// SECURITY: the production type-to-confirm runs UNCONDITIONALLY — even with
|
|
403
|
+
// -y — so `restore -y prod` can never silently overwrite the production
|
|
404
|
+
// database. This is deliberately OUTSIDE the `!values.y` block above (the
|
|
405
|
+
// soft y/N confirm is skippable with -y; this hard gate is not). Mirrors
|
|
406
|
+
// destroy's prod guard; shared helper so failover can reuse it.
|
|
407
|
+
await confirmProdOrExit(envName, { actionLabel: 'restore', yes: !!values.y });
|
|
408
|
+
|
|
408
409
|
// Dispatch by mode
|
|
409
410
|
const tracker = createTracker('restore', { environment: envName });
|
|
410
411
|
const s = tracker.spinner();
|
|
@@ -427,6 +428,7 @@ export async function run(args) {
|
|
|
427
428
|
envName,
|
|
428
429
|
serverIp,
|
|
429
430
|
sshKeyPath,
|
|
431
|
+
envConfig,
|
|
430
432
|
});
|
|
431
433
|
}
|
|
432
434
|
} catch (error) {
|
|
@@ -645,7 +647,21 @@ async function setRestoreMarker(ip, sshKeyPath, value) {
|
|
|
645
647
|
]);
|
|
646
648
|
}
|
|
647
649
|
|
|
648
|
-
async function runK8sRestore({
|
|
650
|
+
export async function runK8sRestore({
|
|
651
|
+
s,
|
|
652
|
+
chosenSource,
|
|
653
|
+
envName,
|
|
654
|
+
serverIp,
|
|
655
|
+
sshKeyPath,
|
|
656
|
+
envConfig = {},
|
|
657
|
+
// Injected so the k8s-HA standby re-seed dispatch is unit-testable without a
|
|
658
|
+
// real cluster (mirrors runComposeRestore). Production passes none.
|
|
659
|
+
reseedStandbyFromPrimary = realReseedStandbyFromPrimary,
|
|
660
|
+
readReplicationState = realReadK8sReplicationState,
|
|
661
|
+
// Pacing for the post-reseed streaming verify — injectable so tests don't wait
|
|
662
|
+
// on real timers. Production uses the default setTimeout-backed sleep.
|
|
663
|
+
verifySleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
664
|
+
}) {
|
|
649
665
|
if (chosenSource.kind === 'local') {
|
|
650
666
|
throw new Error(
|
|
651
667
|
'k8s restore is wal-g-based and pulls from S3 — local backup files are not supported. Use `-source latest` (or a timestamp for PITR).',
|
|
@@ -695,6 +711,78 @@ async function runK8sRestore({ s, chosenSource, envName, serverIp, sshKeyPath })
|
|
|
695
711
|
|
|
696
712
|
p.log.success('Restore completed successfully');
|
|
697
713
|
p.log.info('The application has been scaled back up.');
|
|
714
|
+
|
|
715
|
+
// k8s-HA: the standby is a SEPARATE cluster deployed with `restore: null` — a
|
|
716
|
+
// streaming replica, never wal-g-restored (a wal-g fetch on the standby would
|
|
717
|
+
// create a divergent timeline; see k8s/ha/index.js). This restore rewound the
|
|
718
|
+
// PRIMARY to an earlier LSN, so the standby now has WAL AHEAD of the primary
|
|
719
|
+
// and CANNOT resume streaming (timeline divergence).
|
|
720
|
+
//
|
|
721
|
+
// Finding #2: instead of the old warn-and-point-to-`deploy`, perform a
|
|
722
|
+
// first-class VERIFIED re-seed of the standby using the SHARED reseed
|
|
723
|
+
// primitive (the same hardened staged-basebackup+atomic-swap the failover path
|
|
724
|
+
// uses), then confirm streaming from the restored primary. Fail loudly if it
|
|
725
|
+
// can't complete — never silently leave a diverged standby.
|
|
726
|
+
const isK8sHA = !!(envConfig.ha?.enabled || envConfig.ha === true || envConfig.secondaryRegion);
|
|
727
|
+
if (isK8sHA) {
|
|
728
|
+
const servers = identifyServers(envName, envConfig, {});
|
|
729
|
+
const standbyIp = servers?.standby?.ip;
|
|
730
|
+
const primarySupabaseIp = servers?.primary?.supabaseIp || servers?.primary?.ip;
|
|
731
|
+
if (!standbyIp || !primarySupabaseIp) {
|
|
732
|
+
// We can't locate the standby / primary source — fail loudly rather than
|
|
733
|
+
// report a clean restore while the standby silently diverges.
|
|
734
|
+
throw new Error(
|
|
735
|
+
'HA restore: the primary was restored, but the standby cluster or the primary ' +
|
|
736
|
+
'supabase IP could not be resolved from config, so the standby could NOT be ' +
|
|
737
|
+
`re-seeded and now has a DIVERGED timeline. DR is not guaranteed. Resync with ` +
|
|
738
|
+
`\`vibecarbon deploy ${envName}\` (re-runs the verified replication setup).`,
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
s.start('Re-seeding standby cluster from restored primary (pg_basebackup)');
|
|
743
|
+
let reseedResult;
|
|
744
|
+
try {
|
|
745
|
+
reseedResult = await reseedStandbyFromPrimary(standbyIp, sshKeyPath, primarySupabaseIp);
|
|
746
|
+
} catch (err) {
|
|
747
|
+
s.stop('Standby re-seed failed');
|
|
748
|
+
throw new Error(
|
|
749
|
+
`HA restore: the primary was restored and is serving, but re-seeding the standby ` +
|
|
750
|
+
`FAILED: ${err.message}\nThe standby has a DIVERGED timeline and is NOT replicating — ` +
|
|
751
|
+
`DR is not guaranteed. Resync with \`vibecarbon deploy ${envName}\`.`,
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
if (reseedResult === 'skipped') {
|
|
755
|
+
s.stop('Standby re-seed skipped — primary unreachable from standby');
|
|
756
|
+
throw new Error(
|
|
757
|
+
'HA restore: the standby could NOT reach the restored primary to re-seed ' +
|
|
758
|
+
'(cross-cluster connectivity). The standby has a DIVERGED timeline and is NOT ' +
|
|
759
|
+
`replicating — DR is not guaranteed. Verify the replication firewall/network, then ` +
|
|
760
|
+
`resync with \`vibecarbon deploy ${envName}\`.`,
|
|
761
|
+
);
|
|
762
|
+
}
|
|
763
|
+
s.stop('Standby re-seeded from restored primary');
|
|
764
|
+
|
|
765
|
+
// Confirm streaming from the restored primary — a re-seed that boots but
|
|
766
|
+
// never streams is still a diverged standby.
|
|
767
|
+
s.start('Verifying standby is streaming from restored primary');
|
|
768
|
+
const { streaming, lastState } = await verifyStreaming({
|
|
769
|
+
readState: async () => readReplicationState(servers.primary.ip, sshKeyPath),
|
|
770
|
+
attempts: 15,
|
|
771
|
+
delaysMs: [1000, 2000, 3000],
|
|
772
|
+
sleep: verifySleep,
|
|
773
|
+
});
|
|
774
|
+
if (!streaming) {
|
|
775
|
+
s.stop('Standby streaming NOT confirmed');
|
|
776
|
+
throw new Error(
|
|
777
|
+
`HA restore: the standby was re-seeded but streaming replication is NOT confirmed ` +
|
|
778
|
+
`(last pg_stat_replication.state=${
|
|
779
|
+
lastState ? JSON.stringify(lastState) : 'no replica connected'
|
|
780
|
+
}). DR is not guaranteed. Resync with \`vibecarbon deploy ${envName}\`.`,
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
s.stop('Standby streaming from restored primary — DR restored');
|
|
784
|
+
}
|
|
785
|
+
|
|
698
786
|
p.log.info(`Verify with: ${c.info(`vibecarbon status ${envName}`)}`);
|
|
699
787
|
}
|
|
700
788
|
|
package/src/scale.js
CHANGED
|
@@ -41,7 +41,6 @@ import { buildRemote } from './lib/deploy/remote-build.js';
|
|
|
41
41
|
import { DEFAULT_WORKER_MAX, DEFAULT_WORKER_MIN } from './lib/deploy/utils.js';
|
|
42
42
|
import { waitForDNSPropagation } from './lib/dns-propagation.js';
|
|
43
43
|
import { getApiToken, getS3Credentials } from './lib/hetzner-guided-setup.js';
|
|
44
|
-
import { requireLicense } from './lib/licensing/index.js';
|
|
45
44
|
import { ensureOperatorIpAccess } from './lib/operator-ip.js';
|
|
46
45
|
import { perfAsync } from './lib/perf.js';
|
|
47
46
|
import { assertInProjectDir } from './lib/project-guard.js';
|
|
@@ -764,7 +763,6 @@ export async function run(args) {
|
|
|
764
763
|
// scale` from a parent directory emits the canonical message.
|
|
765
764
|
const projectConfig = assertInProjectDir();
|
|
766
765
|
|
|
767
|
-
requireLicense('scale');
|
|
768
766
|
printBanner();
|
|
769
767
|
p.intro(`${c.bold('vibecarbon scale')} ${c.dim(`v${VERSION}`)}`);
|
|
770
768
|
|
|
@@ -1140,6 +1138,10 @@ export async function run(args) {
|
|
|
1140
1138
|
bucket: envConfig.s3.bucket,
|
|
1141
1139
|
region: envConfig.s3.region,
|
|
1142
1140
|
endpoint: envConfig.s3.endpoint,
|
|
1141
|
+
// Dedicated Pulumi-state bucket. Undefined for envs deployed before
|
|
1142
|
+
// the state-bucket split — resolveBackendUrl then falls back to the
|
|
1143
|
+
// app bucket, matching where their state actually still lives.
|
|
1144
|
+
stateBucket: envConfig.s3.stateBucket,
|
|
1143
1145
|
accessKey: s3Creds.accessKey,
|
|
1144
1146
|
secretKey: s3Creds.secretKey,
|
|
1145
1147
|
};
|
package/src/status.js
CHANGED
|
@@ -20,6 +20,7 @@ import { c, printBanner } from './lib/colors.js';
|
|
|
20
20
|
import { runCommand } from './lib/command.js';
|
|
21
21
|
import { cleanStaleProjects, loadGlobalRegistry, loadProjectConfig } from './lib/config.js';
|
|
22
22
|
import { HetznerProvider } from './lib/providers/hetzner.js';
|
|
23
|
+
import { getPostgresPod, getSSHKeyPath, sshKubectl, sshRun } from './lib/ssh.js';
|
|
23
24
|
import { VERSION } from './lib/version.js';
|
|
24
25
|
|
|
25
26
|
/** @type {import('./lib/cli/parse-flags.js').CommandSpec & { summary?: string, description?: string, examples?: Array<{ command: string, description?: string }> }} */
|
|
@@ -324,6 +325,108 @@ async function checkRemoteHealth(domain) {
|
|
|
324
325
|
};
|
|
325
326
|
}
|
|
326
327
|
|
|
328
|
+
/**
|
|
329
|
+
* Read the REAL streaming-replication state for an HA environment by querying
|
|
330
|
+
* `pg_stat_replication` on the CURRENT primary. Surfaces honest DR state instead
|
|
331
|
+
* of a hardcoded "streaming" string (finding #4). Best-effort and hard-bounded:
|
|
332
|
+
* any failure (unreachable, db down, non-HA) resolves to a value the renderer
|
|
333
|
+
* can degrade on, and the whole probe is raced against a short timeout so
|
|
334
|
+
* `status` never hangs.
|
|
335
|
+
*
|
|
336
|
+
* @returns {Promise<{ streaming: boolean, state: string, lagBytes: number|null } | null>}
|
|
337
|
+
*/
|
|
338
|
+
async function checkReplication(envName, envConfig, projectName, deps = {}) {
|
|
339
|
+
const {
|
|
340
|
+
sshRun: _sshRun = sshRun,
|
|
341
|
+
sshKubectl: _sshKubectl = sshKubectl,
|
|
342
|
+
getPostgresPod: _getPod = getPostgresPod,
|
|
343
|
+
getSSHKeyPath: _getKey = getSSHKeyPath,
|
|
344
|
+
timeoutMs = 10_000,
|
|
345
|
+
} = deps;
|
|
346
|
+
|
|
347
|
+
const isHA = !!(envConfig.ha?.enabled || envConfig.ha === true || envConfig.secondaryRegion);
|
|
348
|
+
if (!isHA) return null;
|
|
349
|
+
|
|
350
|
+
const servers = envConfig.servers || [];
|
|
351
|
+
const primary = servers.find((sv) => sv.role === 'primary') || servers[0];
|
|
352
|
+
if (!primary?.ip) return null;
|
|
353
|
+
|
|
354
|
+
const sshKeyPath = _getKey(envName);
|
|
355
|
+
if (!existsSync(sshKeyPath)) return null;
|
|
356
|
+
|
|
357
|
+
const isCompose = envConfig.deployMode === 'compose' || envConfig.deployMode === 'compose-ha';
|
|
358
|
+
// No string literals in the SQL → no shell/psql quoting hazards. `-tA` uses
|
|
359
|
+
// '|' as the field separator, so a connected standby yields `streaming|<lag>`.
|
|
360
|
+
const sql =
|
|
361
|
+
'SELECT state, pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) FROM pg_stat_replication';
|
|
362
|
+
|
|
363
|
+
const probe = (async () => {
|
|
364
|
+
let out;
|
|
365
|
+
if (isCompose) {
|
|
366
|
+
out = await _sshRun(
|
|
367
|
+
primary.ip,
|
|
368
|
+
sshKeyPath,
|
|
369
|
+
[
|
|
370
|
+
'bash',
|
|
371
|
+
'-lc',
|
|
372
|
+
`cd /opt/${projectName} && docker compose exec -T db psql -U supabase_admin -d postgres -tAc "${sql}"`,
|
|
373
|
+
],
|
|
374
|
+
{ timeout: timeoutMs },
|
|
375
|
+
);
|
|
376
|
+
} else {
|
|
377
|
+
const pod = await _getPod(primary.ip, sshKeyPath);
|
|
378
|
+
out = await _sshKubectl(
|
|
379
|
+
primary.ip,
|
|
380
|
+
sshKeyPath,
|
|
381
|
+
[
|
|
382
|
+
'exec',
|
|
383
|
+
'-n',
|
|
384
|
+
'vibecarbon',
|
|
385
|
+
pod,
|
|
386
|
+
'--',
|
|
387
|
+
'psql',
|
|
388
|
+
'-U',
|
|
389
|
+
'supabase_admin',
|
|
390
|
+
'-d',
|
|
391
|
+
'postgres',
|
|
392
|
+
'-tAc',
|
|
393
|
+
sql,
|
|
394
|
+
],
|
|
395
|
+
{ timeout: timeoutMs },
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
const line = (typeof out === 'string' ? out : '')
|
|
399
|
+
.split('\n')
|
|
400
|
+
.map((l) => l.trim())
|
|
401
|
+
.filter(Boolean)[0];
|
|
402
|
+
if (!line) return { streaming: false, state: 'no standby connected', lagBytes: null };
|
|
403
|
+
const [state, lagStr] = line.split('|');
|
|
404
|
+
const lag = lagStr ? Number.parseInt(lagStr, 10) : Number.NaN;
|
|
405
|
+
return {
|
|
406
|
+
streaming: state === 'streaming',
|
|
407
|
+
state: state || 'unknown',
|
|
408
|
+
lagBytes: Number.isNaN(lag) ? null : lag,
|
|
409
|
+
};
|
|
410
|
+
})();
|
|
411
|
+
|
|
412
|
+
try {
|
|
413
|
+
return await Promise.race([
|
|
414
|
+
probe,
|
|
415
|
+
new Promise((resolve) => {
|
|
416
|
+
// .unref() so a fast probe win doesn't leave this timer holding the
|
|
417
|
+
// event loop open and delaying `status` exit by the timeout window.
|
|
418
|
+
const t = setTimeout(
|
|
419
|
+
() => resolve({ streaming: false, state: 'unknown', lagBytes: null }),
|
|
420
|
+
timeoutMs + 2_000,
|
|
421
|
+
);
|
|
422
|
+
if (typeof t?.unref === 'function') t.unref();
|
|
423
|
+
}),
|
|
424
|
+
]);
|
|
425
|
+
} catch {
|
|
426
|
+
return { streaming: false, state: 'unknown', lagBytes: null };
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
327
430
|
async function getServerInfo(serverId) {
|
|
328
431
|
const apiToken = process.env.HETZNER_API_TOKEN;
|
|
329
432
|
if (!apiToken) return null;
|
|
@@ -506,6 +609,20 @@ function renderEnvironment(envName, envConfig, checks) {
|
|
|
506
609
|
? `${c.success('Enabled')} ${c.dim(`(failover: ${failoverRegion})`)}`
|
|
507
610
|
: c.success('Enabled');
|
|
508
611
|
lines.push(`${c.dim('HA')} ${haDisplay}`);
|
|
612
|
+
|
|
613
|
+
// Real replication state (finding #4) — never a hardcoded "streaming".
|
|
614
|
+
if (checks.replication) {
|
|
615
|
+
const r = checks.replication;
|
|
616
|
+
let replDisplay;
|
|
617
|
+
if (r.streaming) {
|
|
618
|
+
const lag =
|
|
619
|
+
r.lagBytes != null ? c.dim(` (lag: ${(r.lagBytes / 1024).toFixed(0)} KiB)`) : '';
|
|
620
|
+
replDisplay = `${c.success('streaming')}${lag}`;
|
|
621
|
+
} else {
|
|
622
|
+
replDisplay = `${c.error(r.state || 'not streaming')} ${c.dim('— DR not guaranteed')}`;
|
|
623
|
+
}
|
|
624
|
+
lines.push(`${c.dim('Replication')} ${replDisplay}`);
|
|
625
|
+
}
|
|
509
626
|
}
|
|
510
627
|
|
|
511
628
|
// Servers
|
|
@@ -816,6 +933,10 @@ async function main() {
|
|
|
816
933
|
// Git sync
|
|
817
934
|
checks.gitSync = checkGitSync(envName, envConfig);
|
|
818
935
|
|
|
936
|
+
// Real replication state for HA envs (best-effort, hard-bounded). null
|
|
937
|
+
// for non-HA or when the primary/key isn't locally reachable.
|
|
938
|
+
checks.replication = await checkReplication(envName, envConfig, projectConfig.projectName);
|
|
939
|
+
|
|
819
940
|
return { envName, config: envConfig, checks };
|
|
820
941
|
}),
|
|
821
942
|
);
|
|
@@ -902,6 +1023,7 @@ export {
|
|
|
902
1023
|
checkHttpEndpoint,
|
|
903
1024
|
checkLocalDev,
|
|
904
1025
|
checkRemoteHealth,
|
|
1026
|
+
checkReplication,
|
|
905
1027
|
formatRelativeTime,
|
|
906
1028
|
getBranchName,
|
|
907
1029
|
getServerInfo,
|
package/src/upgrade.js
CHANGED
|
@@ -26,7 +26,6 @@ import { renderHelp } from './lib/cli/help.js';
|
|
|
26
26
|
import { parseFlags } from './lib/cli/parse-flags.js';
|
|
27
27
|
import { c, printBanner } from './lib/colors.js';
|
|
28
28
|
import { runCommandAsync } from './lib/command.js';
|
|
29
|
-
import { requireLicense } from './lib/licensing/index.js';
|
|
30
29
|
import { mergePackageJson } from './lib/merge-package-json.js';
|
|
31
30
|
import {
|
|
32
31
|
adaptDockerfileForPackageManager,
|
|
@@ -294,9 +293,6 @@ async function main(cliArgs) {
|
|
|
294
293
|
// Detect project
|
|
295
294
|
assertInProjectDir();
|
|
296
295
|
|
|
297
|
-
// Require a paid license (Diamond or Fullerene)
|
|
298
|
-
requireLicense('upgrade');
|
|
299
|
-
|
|
300
296
|
const cwd = process.cwd();
|
|
301
297
|
const manifest = loadManifest(cwd);
|
|
302
298
|
const currentTemplateVersion = manifest.templateVersion || '0.0.0';
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
#!/bin/bash
|
|
2
|
-
# Promote a standby PostgreSQL to primary and scale up services.
|
|
3
|
-
#
|
|
4
|
-
# This script runs ON the standby master node and uses kubectl to:
|
|
5
|
-
# 1. Promote PostgreSQL from standby to primary
|
|
6
|
-
# 2. Wait for the promotion to complete
|
|
7
|
-
# 3. Scale up Supabase and application services
|
|
8
|
-
#
|
|
9
|
-
# Optional env vars:
|
|
10
|
-
# NAMESPACE — Kubernetes namespace (default: vibecarbon)
|
|
11
|
-
|
|
12
|
-
set -euo pipefail
|
|
13
|
-
|
|
14
|
-
NAMESPACE="${NAMESPACE:-vibecarbon}"
|
|
15
|
-
PGDATA="/var/lib/postgresql/data"
|
|
16
|
-
|
|
17
|
-
# Find the postgres pod
|
|
18
|
-
POD=$(kubectl get pods -n "$NAMESPACE" -l app.kubernetes.io/name=supabase-db -o jsonpath='{.items[0].metadata.name}')
|
|
19
|
-
if [ -z "$POD" ]; then
|
|
20
|
-
echo "ERROR: No postgres pod found in namespace $NAMESPACE"
|
|
21
|
-
exit 1
|
|
22
|
-
fi
|
|
23
|
-
|
|
24
|
-
echo "Promoting standby: pod=$POD"
|
|
25
|
-
|
|
26
|
-
# 1. Promote PostgreSQL
|
|
27
|
-
echo "Promoting PostgreSQL standby to primary..."
|
|
28
|
-
kubectl exec -n "$NAMESPACE" "$POD" -- su postgres -c "pg_ctl promote -D $PGDATA" 2>/dev/null || true
|
|
29
|
-
|
|
30
|
-
# 2. Wait for promotion to complete
|
|
31
|
-
echo "Waiting for promotion..."
|
|
32
|
-
for i in $(seq 1 30); do
|
|
33
|
-
result=$(kubectl exec -n "$NAMESPACE" "$POD" -- psql -U supabase_admin -tAc 'SELECT pg_is_in_recovery()' 2>/dev/null || echo "t")
|
|
34
|
-
if [ "$result" = "f" ]; then
|
|
35
|
-
echo "PostgreSQL promoted successfully"
|
|
36
|
-
break
|
|
37
|
-
fi
|
|
38
|
-
if [ "$i" = "30" ]; then
|
|
39
|
-
echo "WARNING: Promotion not confirmed after 60 seconds"
|
|
40
|
-
fi
|
|
41
|
-
sleep 2
|
|
42
|
-
done
|
|
43
|
-
|
|
44
|
-
# 3. Scale up services
|
|
45
|
-
echo "Scaling up services..."
|
|
46
|
-
kubectl scale deployment app -n "$NAMESPACE" --replicas=2
|
|
47
|
-
kubectl scale deployment auth -n "$NAMESPACE" --replicas=2
|
|
48
|
-
kubectl scale deployment rest -n "$NAMESPACE" --replicas=2
|
|
49
|
-
kubectl scale deployment realtime -n "$NAMESPACE" --replicas=2
|
|
50
|
-
kubectl scale deployment storage -n "$NAMESPACE" --replicas=1
|
|
51
|
-
|
|
52
|
-
echo "Standby activation complete"
|