@rulebricks/cli 2.1.7 → 2.3.2
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 +51 -16
- package/cluster-setup/aws/README.md +96 -47
- package/cluster-setup/aws/check-aws-access.sh +216 -52
- package/cluster-setup/aws/parameters.json +13 -0
- package/cluster-setup/aws/rulebricks-cluster.cfn.yaml +355 -0
- package/cluster-setup/azure/README.md +103 -55
- package/cluster-setup/azure/check-aks-prereqs.sh +236 -56
- package/cluster-setup/azure/parameters.json +30 -0
- package/cluster-setup/azure/rulebricks-cluster.bicep +546 -0
- package/cluster-setup/gcp/README.md +51 -34
- package/cluster-setup/gcp/check-gke-prereqs.sh +222 -60
- package/dist/commands/backup.d.ts +5 -0
- package/dist/commands/backup.js +104 -0
- package/dist/commands/deploy.d.ts +3 -1
- package/dist/commands/deploy.js +226 -326
- package/dist/commands/destroy.d.ts +1 -1
- package/dist/commands/destroy.js +73 -123
- package/dist/commands/init.d.ts +5 -1
- package/dist/commands/init.js +78 -54
- package/dist/commands/list.d.ts +1 -0
- package/dist/commands/list.js +74 -0
- package/dist/commands/open.d.ts +1 -1
- package/dist/commands/open.js +4 -12
- package/dist/commands/redeploy.d.ts +6 -0
- package/dist/commands/redeploy.js +310 -0
- package/dist/commands/restore.d.ts +5 -0
- package/dist/commands/restore.js +338 -0
- package/dist/commands/status.js +62 -49
- package/dist/commands/upgrade.js +74 -51
- package/dist/components/DNSWaitScreen.d.ts +5 -1
- package/dist/components/DNSWaitScreen.js +47 -41
- package/dist/components/Wizard/WizardContext.d.ts +157 -36
- package/dist/components/Wizard/WizardContext.js +872 -160
- package/dist/components/Wizard/steps/CloudProviderStep.js +192 -107
- package/dist/components/Wizard/steps/DomainStep.js +5 -24
- package/dist/components/Wizard/steps/ExternalServicesStep.d.ts +6 -0
- package/dist/components/Wizard/steps/ExternalServicesStep.js +645 -0
- package/dist/components/Wizard/steps/FeatureConfigStep.d.ts +2 -1
- package/dist/components/Wizard/steps/FeatureConfigStep.js +739 -425
- package/dist/components/Wizard/steps/FeaturesStep.js +31 -35
- package/dist/components/Wizard/steps/ObservabilityStep.d.ts +6 -0
- package/dist/components/Wizard/steps/ObservabilityStep.js +137 -0
- package/dist/components/Wizard/steps/ReviewStep.d.ts +2 -1
- package/dist/components/Wizard/steps/ReviewStep.js +56 -12
- package/dist/components/Wizard/steps/StorageStep.d.ts +9 -0
- package/dist/components/Wizard/steps/StorageStep.js +592 -0
- package/dist/components/Wizard/steps/SupabaseCredentialsStep.js +20 -21
- package/dist/components/Wizard/steps/VersionStep.js +45 -23
- package/dist/components/Wizard/steps/index.d.ts +3 -3
- package/dist/components/Wizard/steps/index.js +3 -3
- package/dist/components/common/CommandApproval.d.ts +12 -0
- package/dist/components/common/CommandApproval.js +91 -0
- package/dist/components/common/DeploymentPicker.d.ts +14 -0
- package/dist/components/common/DeploymentPicker.js +16 -0
- package/dist/components/common/index.d.ts +2 -0
- package/dist/components/common/index.js +2 -0
- package/dist/index.js +94 -62
- package/dist/lib/cloudCli.d.ts +134 -63
- package/dist/lib/cloudCli.js +512 -220
- package/dist/lib/clusterSetupDefaults.d.ts +30 -0
- package/dist/lib/clusterSetupDefaults.js +64 -0
- package/dist/lib/commandApproval.d.ts +26 -0
- package/dist/lib/commandApproval.js +114 -0
- package/dist/lib/config.d.ts +12 -10
- package/dist/lib/config.js +91 -33
- package/dist/lib/configFixtures.d.ts +5 -0
- package/dist/lib/configFixtures.js +513 -0
- package/dist/lib/deploymentHealth.d.ts +32 -0
- package/dist/lib/deploymentHealth.js +157 -0
- package/dist/lib/dns.d.ts +1 -1
- package/dist/lib/dns.js +19 -1
- package/dist/lib/dns.test.d.ts +1 -0
- package/dist/lib/dns.test.js +27 -0
- package/dist/lib/dockerHub.d.ts +12 -1
- package/dist/lib/dockerHub.js +18 -8
- package/dist/lib/helm.d.ts +4 -0
- package/dist/lib/helm.js +16 -0
- package/dist/lib/helmValues.d.ts +25 -0
- package/dist/lib/helmValues.js +1841 -289
- package/dist/lib/helmValues.test.d.ts +1 -0
- package/dist/lib/helmValues.test.js +1012 -0
- package/dist/lib/htpasswd.d.ts +1 -0
- package/dist/lib/htpasswd.js +15 -0
- package/dist/lib/kubernetes.d.ts +124 -17
- package/dist/lib/kubernetes.js +576 -145
- package/dist/lib/secrets.d.ts +23 -0
- package/dist/lib/secrets.js +158 -0
- package/dist/lib/validateValues.d.ts +31 -0
- package/dist/lib/validateValues.js +253 -0
- package/dist/lib/versions.d.ts +82 -11
- package/dist/lib/versions.js +131 -31
- package/dist/lib/versions.test.d.ts +1 -0
- package/dist/lib/versions.test.js +81 -0
- package/dist/lib/wizardSteps.d.ts +14 -0
- package/dist/lib/wizardSteps.js +23 -0
- package/dist/lib/workloadIdentity.d.ts +26 -0
- package/dist/lib/workloadIdentity.js +323 -0
- package/dist/lib/workloadIdentity.test.d.ts +1 -0
- package/dist/lib/workloadIdentity.test.js +57 -0
- package/dist/types/index.d.ts +1860 -164
- package/dist/types/index.js +518 -295
- package/package.json +9 -4
- package/schema/values.schema.json +1934 -0
- package/cluster-setup/aws/cluster.yaml +0 -33
- package/cluster-setup/azure/main.bicep +0 -282
- package/cluster-setup/azure/main.parameters.json +0 -21
- package/dist/components/Wizard/steps/CredentialsStep.d.ts +0 -6
- package/dist/components/Wizard/steps/CredentialsStep.js +0 -22
- package/dist/components/Wizard/steps/DeploymentModeStep.d.ts +0 -5
- package/dist/components/Wizard/steps/DeploymentModeStep.js +0 -26
- package/dist/components/Wizard/steps/TierStep.d.ts +0 -6
- package/dist/components/Wizard/steps/TierStep.js +0 -29
- package/dist/lib/terraform.d.ts +0 -66
- package/dist/lib/terraform.js +0 -754
- package/terraform/aws/main.tf +0 -355
- package/terraform/azure/main.tf +0 -371
- package/terraform/gcp/main.tf +0 -407
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { Box, Text, useApp } from "ink";
|
|
4
|
+
import SelectInput from "ink-select-input";
|
|
5
|
+
import TextInput from "ink-text-input";
|
|
6
|
+
import { BorderBox, Logo, Spinner, StatusLine, ThemeProvider, useTheme, CommandApprovalProvider, } from "../components/common/index.js";
|
|
7
|
+
import { loadDeploymentConfig } from "../lib/config.js";
|
|
8
|
+
import { updateKubeconfig } from "../lib/cloudCli.js";
|
|
9
|
+
import { CommandDeniedError } from "../lib/commandApproval.js";
|
|
10
|
+
import { checkClusterAccessible, getDeploymentReplicas, isKubectlInstalled, runEphemeralJob, scaleDeployment, waitForDeploymentReady, } from "../lib/kubernetes.js";
|
|
11
|
+
import { RCLONE_IMAGE, SUPABASE_POSTGRES_IMAGE_REPOSITORY, SUPABASE_POSTGRES_IMAGE_TAG, } from "../lib/versions.js";
|
|
12
|
+
import { getNamespace, getReleaseName } from "../types/index.js";
|
|
13
|
+
const DB_IMAGE = `${SUPABASE_POSTGRES_IMAGE_REPOSITORY}:${SUPABASE_POSTGRES_IMAGE_TAG}`;
|
|
14
|
+
function k8sName(value) {
|
|
15
|
+
return value.toLowerCase().replace(/[^a-z0-9-]/g, "-").slice(0, 63).replace(/-+$/, "");
|
|
16
|
+
}
|
|
17
|
+
// The single bucket/container plus the db-backups prefix, e.g. "my-bucket/db-backups"
|
|
18
|
+
// (S3/GCS) or "my-container/db-backups" (azure-blob).
|
|
19
|
+
function dbBackupsTarget(config) {
|
|
20
|
+
const storage = config.storage;
|
|
21
|
+
if (!storage)
|
|
22
|
+
throw new Error("Shared object storage is required.");
|
|
23
|
+
const prefix = (storage.paths?.dbBackups || "db-backups").replace(/^\/+|\/+$/g, "");
|
|
24
|
+
if (storage.provider === "azure-blob") {
|
|
25
|
+
return `${storage.azureBlobContainer || "rulebricks"}/${prefix}`;
|
|
26
|
+
}
|
|
27
|
+
return `${storage.bucket}/${prefix}`;
|
|
28
|
+
}
|
|
29
|
+
// rclone on-the-fly remote "dest" config via env vars (no config file). Auth is
|
|
30
|
+
// the pod's workload identity (env_auth) for every provider, or an Azure Blob
|
|
31
|
+
// connection string Secret in the fallback path.
|
|
32
|
+
function rcloneEnv(config) {
|
|
33
|
+
const storage = config.storage;
|
|
34
|
+
if (!storage)
|
|
35
|
+
throw new Error("Shared object storage is required.");
|
|
36
|
+
const env = [];
|
|
37
|
+
switch (storage.provider) {
|
|
38
|
+
case "azure-blob":
|
|
39
|
+
env.push({ name: "RCLONE_CONFIG_DEST_TYPE", value: "azureblob" });
|
|
40
|
+
env.push({ name: "RCLONE_CONFIG_DEST_ACCOUNT", value: storage.bucket });
|
|
41
|
+
if (storage.cloudAuthMode === "secret") {
|
|
42
|
+
if (!storage.azureBlobConnectionStringSecretRef) {
|
|
43
|
+
throw new Error("Azure Blob connection string secret ref is required.");
|
|
44
|
+
}
|
|
45
|
+
env.push({
|
|
46
|
+
name: "RCLONE_CONFIG_DEST_CONNECTION_STRING",
|
|
47
|
+
valueFrom: {
|
|
48
|
+
secretKeyRef: {
|
|
49
|
+
name: storage.azureBlobConnectionStringSecretRef.name,
|
|
50
|
+
key: storage.azureBlobConnectionStringSecretRef.key,
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
env.push({ name: "RCLONE_CONFIG_DEST_ENV_AUTH", value: "true" });
|
|
57
|
+
}
|
|
58
|
+
break;
|
|
59
|
+
case "gcs":
|
|
60
|
+
env.push({ name: "RCLONE_CONFIG_DEST_TYPE", value: "google cloud storage" });
|
|
61
|
+
env.push({ name: "RCLONE_CONFIG_DEST_ENV_AUTH", value: "true" });
|
|
62
|
+
env.push({ name: "RCLONE_CONFIG_DEST_BUCKET_POLICY_ONLY", value: "true" });
|
|
63
|
+
break;
|
|
64
|
+
default:
|
|
65
|
+
env.push({ name: "RCLONE_CONFIG_DEST_TYPE", value: "s3" });
|
|
66
|
+
env.push({ name: "RCLONE_CONFIG_DEST_PROVIDER", value: "AWS" });
|
|
67
|
+
env.push({ name: "RCLONE_CONFIG_DEST_ENV_AUTH", value: "true" });
|
|
68
|
+
env.push({ name: "RCLONE_CONFIG_DEST_REGION", value: storage.region });
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
return env;
|
|
72
|
+
}
|
|
73
|
+
function pgEnv(config, releaseName) {
|
|
74
|
+
const secret = `${releaseName}-supabase-db`;
|
|
75
|
+
return [
|
|
76
|
+
{ name: "PGHOST", value: `${releaseName}-supabase-db` },
|
|
77
|
+
{ name: "PGPORT", value: "5432" },
|
|
78
|
+
{
|
|
79
|
+
name: "PGDATABASE",
|
|
80
|
+
valueFrom: { secretKeyRef: { name: secret, key: "database" } },
|
|
81
|
+
},
|
|
82
|
+
// Restore as a superuser so pg_restore --clean and the globals.sql roles can
|
|
83
|
+
// drop/recreate objects in schemas owned by supabase_admin (auth, storage,
|
|
84
|
+
// realtime, etc.). The secret's `username` role (postgres) is not a superuser.
|
|
85
|
+
{ name: "PGUSER", value: "supabase_admin" },
|
|
86
|
+
{
|
|
87
|
+
name: "PGPASSWORD",
|
|
88
|
+
valueFrom: { secretKeyRef: { name: secret, key: "password" } },
|
|
89
|
+
},
|
|
90
|
+
];
|
|
91
|
+
}
|
|
92
|
+
function jobLabels(config) {
|
|
93
|
+
const labels = {
|
|
94
|
+
"app.kubernetes.io/component": "db-restore",
|
|
95
|
+
};
|
|
96
|
+
// Azure Workload Identity requires this pod label so the projected token is
|
|
97
|
+
// injected for the rclone download. S3 (IRSA) and GCS (GKE WI) work via the SA.
|
|
98
|
+
if (config.storage?.provider === "azure-blob" &&
|
|
99
|
+
config.storage.cloudAuthMode !== "secret") {
|
|
100
|
+
labels["azure.workload.identity/use"] = "true";
|
|
101
|
+
}
|
|
102
|
+
return labels;
|
|
103
|
+
}
|
|
104
|
+
function parseBackups(output) {
|
|
105
|
+
return output
|
|
106
|
+
.split("\n")
|
|
107
|
+
.map((line) => line.trim().replace(/\/+$/, ""))
|
|
108
|
+
.filter((line) => line.length > 0 && !line.includes("/"))
|
|
109
|
+
.sort()
|
|
110
|
+
.reverse()
|
|
111
|
+
.map((id) => ({ id, label: id }));
|
|
112
|
+
}
|
|
113
|
+
function RestoreCommandInner({ name }) {
|
|
114
|
+
const { exit } = useApp();
|
|
115
|
+
const { colors } = useTheme();
|
|
116
|
+
const [step, setStep] = useState("loading");
|
|
117
|
+
const [config, setConfig] = useState(null);
|
|
118
|
+
const [backups, setBackups] = useState([]);
|
|
119
|
+
const [selectedBackup, setSelectedBackup] = useState(null);
|
|
120
|
+
const [confirmation, setConfirmation] = useState("");
|
|
121
|
+
const [error, setError] = useState(null);
|
|
122
|
+
const [logs, setLogs] = useState("");
|
|
123
|
+
const [status, setStatus] = useState({
|
|
124
|
+
preflight: "pending",
|
|
125
|
+
list: "pending",
|
|
126
|
+
scaleDown: "pending",
|
|
127
|
+
restore: "pending",
|
|
128
|
+
scaleUp: "pending",
|
|
129
|
+
});
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
prepare();
|
|
132
|
+
}, []);
|
|
133
|
+
async function prepare() {
|
|
134
|
+
try {
|
|
135
|
+
const cfg = await loadDeploymentConfig(name);
|
|
136
|
+
validateConfig(cfg);
|
|
137
|
+
setConfig(cfg);
|
|
138
|
+
setStep("preflight");
|
|
139
|
+
setStatus((current) => ({ ...current, preflight: "running" }));
|
|
140
|
+
await runPreflight(cfg);
|
|
141
|
+
setStatus((current) => ({ ...current, preflight: "success" }));
|
|
142
|
+
setStep("listing");
|
|
143
|
+
setStatus((current) => ({ ...current, list: "running" }));
|
|
144
|
+
const available = await listBackups(cfg);
|
|
145
|
+
setBackups(available);
|
|
146
|
+
setStatus((current) => ({ ...current, list: "success" }));
|
|
147
|
+
setStep("select");
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
setError(err instanceof Error ? err.message : "Restore preparation failed");
|
|
151
|
+
setStep("error");
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function validateConfig(cfg) {
|
|
155
|
+
if (cfg.database.type !== "self-hosted") {
|
|
156
|
+
throw new Error("Restore is only available for self-hosted Supabase.");
|
|
157
|
+
}
|
|
158
|
+
if (!cfg.storage) {
|
|
159
|
+
throw new Error("Shared object storage is required for restore.");
|
|
160
|
+
}
|
|
161
|
+
if (!cfg.backup?.enabled) {
|
|
162
|
+
throw new Error("Database backups are disabled for this deployment.");
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
async function runPreflight(cfg) {
|
|
166
|
+
if (!(await isKubectlInstalled())) {
|
|
167
|
+
throw new Error("kubectl is not installed. Please install kubectl first.");
|
|
168
|
+
}
|
|
169
|
+
let clusterError = await checkClusterAccessible();
|
|
170
|
+
if (clusterError &&
|
|
171
|
+
cfg.infrastructure.provider &&
|
|
172
|
+
cfg.infrastructure.region &&
|
|
173
|
+
cfg.infrastructure.clusterName) {
|
|
174
|
+
try {
|
|
175
|
+
await updateKubeconfig(cfg.infrastructure.provider, cfg.infrastructure.clusterName, cfg.infrastructure.region, {
|
|
176
|
+
gcpProjectId: cfg.infrastructure.gcpProjectId,
|
|
177
|
+
azureResourceGroup: cfg.infrastructure.azureResourceGroup,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
catch (err) {
|
|
181
|
+
if (!(err instanceof CommandDeniedError)) {
|
|
182
|
+
throw err;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
clusterError = await checkClusterAccessible();
|
|
186
|
+
}
|
|
187
|
+
if (clusterError) {
|
|
188
|
+
throw new Error(`Cannot access Kubernetes cluster:\n${clusterError}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
async function listBackups(cfg) {
|
|
192
|
+
const namespace = getNamespace(cfg.name);
|
|
193
|
+
const releaseName = getReleaseName(cfg.name);
|
|
194
|
+
const target = dbBackupsTarget(cfg);
|
|
195
|
+
const result = await runEphemeralJob({
|
|
196
|
+
name: k8sName(`${releaseName}-backup-list-${Date.now()}`),
|
|
197
|
+
namespace,
|
|
198
|
+
serviceAccountName: `${releaseName}-backup`,
|
|
199
|
+
image: RCLONE_IMAGE,
|
|
200
|
+
command: [
|
|
201
|
+
"/bin/sh",
|
|
202
|
+
"-c",
|
|
203
|
+
`rclone lsf "dest:${target}/" --dirs-only`,
|
|
204
|
+
],
|
|
205
|
+
env: rcloneEnv(cfg),
|
|
206
|
+
labels: jobLabels(cfg),
|
|
207
|
+
timeoutSeconds: 300,
|
|
208
|
+
});
|
|
209
|
+
const parsed = parseBackups(result.logs);
|
|
210
|
+
if (parsed.length === 0) {
|
|
211
|
+
throw new Error("No database backups found in object storage.");
|
|
212
|
+
}
|
|
213
|
+
return parsed;
|
|
214
|
+
}
|
|
215
|
+
async function handleRestore() {
|
|
216
|
+
if (!config || !selectedBackup)
|
|
217
|
+
return;
|
|
218
|
+
if (confirmation !== config.name) {
|
|
219
|
+
setError(`Type "${config.name}" to confirm restore.`);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
setError(null);
|
|
223
|
+
setStep("restoring");
|
|
224
|
+
let originalReplicas = [];
|
|
225
|
+
try {
|
|
226
|
+
setStatus((current) => ({ ...current, scaleDown: "running" }));
|
|
227
|
+
originalReplicas = await scaleDownForRestore(config);
|
|
228
|
+
setStatus((current) => ({ ...current, scaleDown: "success" }));
|
|
229
|
+
setStatus((current) => ({ ...current, restore: "running" }));
|
|
230
|
+
const result = await runRestoreJob(config, selectedBackup.id);
|
|
231
|
+
setLogs(result.logs);
|
|
232
|
+
setStatus((current) => ({ ...current, restore: "success" }));
|
|
233
|
+
setStatus((current) => ({ ...current, scaleUp: "running" }));
|
|
234
|
+
await scaleBackUp(config, originalReplicas);
|
|
235
|
+
setStatus((current) => ({ ...current, scaleUp: "success" }));
|
|
236
|
+
setStep("complete");
|
|
237
|
+
setTimeout(() => exit(), 8000);
|
|
238
|
+
}
|
|
239
|
+
catch (err) {
|
|
240
|
+
if (originalReplicas.length > 0) {
|
|
241
|
+
await scaleBackUp(config, originalReplicas).catch(() => { });
|
|
242
|
+
}
|
|
243
|
+
setError(err instanceof Error ? err.message : "Restore failed");
|
|
244
|
+
setStep("error");
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
// Logical restore runs pg_restore against the live database, so we keep the DB
|
|
248
|
+
// up and instead pause the application tier to stop writes during the restore.
|
|
249
|
+
async function scaleDownForRestore(cfg) {
|
|
250
|
+
const namespace = getNamespace(cfg.name);
|
|
251
|
+
const releaseName = getReleaseName(cfg.name);
|
|
252
|
+
const appName = `${releaseName}-app`;
|
|
253
|
+
const replicas = await getDeploymentReplicas(namespace, appName);
|
|
254
|
+
if (replicas === null || replicas <= 0) {
|
|
255
|
+
return [];
|
|
256
|
+
}
|
|
257
|
+
await scaleDeployment(namespace, appName, 0);
|
|
258
|
+
await waitForDeploymentReady(namespace, appName, 120).catch(() => { });
|
|
259
|
+
return [{ name: appName, replicas }];
|
|
260
|
+
}
|
|
261
|
+
async function scaleBackUp(cfg, originalReplicas) {
|
|
262
|
+
const namespace = getNamespace(cfg.name);
|
|
263
|
+
for (const item of originalReplicas) {
|
|
264
|
+
if (item.replicas <= 0)
|
|
265
|
+
continue;
|
|
266
|
+
await scaleDeployment(namespace, item.name, item.replicas);
|
|
267
|
+
await waitForDeploymentReady(namespace, item.name).catch(() => { });
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
async function runRestoreJob(cfg, backupId) {
|
|
271
|
+
const namespace = getNamespace(cfg.name);
|
|
272
|
+
const releaseName = getReleaseName(cfg.name);
|
|
273
|
+
const target = dbBackupsTarget(cfg);
|
|
274
|
+
return runEphemeralJob({
|
|
275
|
+
name: k8sName(`${releaseName}-db-restore-${Date.now()}`),
|
|
276
|
+
namespace,
|
|
277
|
+
serviceAccountName: `${releaseName}-backup`,
|
|
278
|
+
// Init container downloads the selected backup; the main container restores
|
|
279
|
+
// it into the live database over the network. They share an emptyDir.
|
|
280
|
+
initContainers: [
|
|
281
|
+
{
|
|
282
|
+
name: "download",
|
|
283
|
+
image: RCLONE_IMAGE,
|
|
284
|
+
imagePullPolicy: "IfNotPresent",
|
|
285
|
+
command: [
|
|
286
|
+
"/bin/sh",
|
|
287
|
+
"-c",
|
|
288
|
+
`set -e; echo "Downloading backup ${backupId}"; rclone copy "dest:${target}/${backupId}/" /work/`,
|
|
289
|
+
],
|
|
290
|
+
env: rcloneEnv(cfg),
|
|
291
|
+
volumeMounts: [{ name: "work", mountPath: "/work" }],
|
|
292
|
+
},
|
|
293
|
+
],
|
|
294
|
+
image: DB_IMAGE,
|
|
295
|
+
command: [
|
|
296
|
+
"/bin/bash",
|
|
297
|
+
"-c",
|
|
298
|
+
[
|
|
299
|
+
"set -euo pipefail",
|
|
300
|
+
'if [ -f /work/globals.sql ]; then',
|
|
301
|
+
' echo "Applying cluster globals (roles)"',
|
|
302
|
+
' psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" -v ON_ERROR_STOP=0 -f /work/globals.sql || true',
|
|
303
|
+
"fi",
|
|
304
|
+
'echo "Restoring database from /work/db.dump"',
|
|
305
|
+
'pg_restore -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" --clean --if-exists --no-owner --no-privileges /work/db.dump',
|
|
306
|
+
'echo "Restore complete"',
|
|
307
|
+
].join("\n"),
|
|
308
|
+
],
|
|
309
|
+
env: pgEnv(cfg, releaseName),
|
|
310
|
+
labels: jobLabels(cfg),
|
|
311
|
+
volumeMounts: [{ name: "work", mountPath: "/work" }],
|
|
312
|
+
volumes: [{ name: "work", emptyDir: {} }],
|
|
313
|
+
timeoutSeconds: 3600,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
if (step === "error") {
|
|
317
|
+
return (_jsx(BorderBox, { title: "Restore Failed", children: _jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsx(Text, { color: colors.error, bold: true, children: "\u2717 Error" }), _jsx(Text, { color: colors.error, children: error })] }) }));
|
|
318
|
+
}
|
|
319
|
+
if (step === "select") {
|
|
320
|
+
return (_jsx(BorderBox, { title: "Select Backup", children: _jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsx(Text, { children: "Select a backup to restore:" }), _jsx(Box, { marginTop: 1, children: _jsx(SelectInput, { items: backups.map((backup) => ({
|
|
321
|
+
label: backup.label,
|
|
322
|
+
value: backup,
|
|
323
|
+
})), onSelect: (item) => {
|
|
324
|
+
setSelectedBackup(item.value);
|
|
325
|
+
setStep("confirm");
|
|
326
|
+
}, indicatorComponent: () => null, itemComponent: ({ isSelected, label }) => (_jsxs(Text, { color: isSelected ? colors.accent : undefined, children: [isSelected ? "❯ " : " ", label] })) }) })] }) }));
|
|
327
|
+
}
|
|
328
|
+
if (step === "confirm" && config && selectedBackup) {
|
|
329
|
+
return (_jsx(BorderBox, { title: "Confirm Restore", children: _jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsx(Text, { color: colors.warning, bold: true, children: "WARNING" }), _jsxs(Text, { children: ["This will overwrite the live database for ", config.name, "."] }), _jsxs(Text, { children: ["Selected backup: ", selectedBackup.id] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { children: "Type the deployment name to continue:" }) }), _jsx(Box, { marginTop: 1, children: _jsx(TextInput, { value: confirmation, onChange: setConfirmation, onSubmit: handleRestore, placeholder: config.name }) }), error && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: colors.error, children: error }) }))] }) }));
|
|
330
|
+
}
|
|
331
|
+
if (step === "complete") {
|
|
332
|
+
return (_jsx(BorderBox, { title: "Restore Complete", children: _jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsx(Text, { color: colors.success, bold: true, children: "\u2713 Database restore completed" }), logs && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: colors.muted, children: "Restore output:" }), logs.split("\n").slice(-8).map((line, index) => (_jsx(Text, { color: colors.muted, children: line }, index)))] }))] }) }));
|
|
333
|
+
}
|
|
334
|
+
return (_jsx(BorderBox, { title: `Restoring ${name}`, children: _jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsx(StatusLine, { status: status.preflight, label: "Preflight checks" }), _jsx(StatusLine, { status: status.list, label: "Listing backups" }), _jsx(StatusLine, { status: status.scaleDown, label: "Pausing application writers" }), _jsx(StatusLine, { status: status.restore, label: "Restoring selected backup" }), _jsx(StatusLine, { status: status.scaleUp, label: "Resuming application" }), _jsx(Box, { marginTop: 1, children: _jsx(Spinner, { label: step === "listing" ? "Listing backups..." : "Preparing restore..." }) })] }) }));
|
|
335
|
+
}
|
|
336
|
+
export function RestoreCommand(props) {
|
|
337
|
+
return (_jsxs(ThemeProvider, { theme: "status", children: [_jsx(Logo, {}), _jsx(CommandApprovalProvider, { children: _jsx(RestoreCommandInner, { ...props }) })] }));
|
|
338
|
+
}
|
package/dist/commands/status.js
CHANGED
|
@@ -2,14 +2,12 @@ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-run
|
|
|
2
2
|
import { useState, useEffect } from "react";
|
|
3
3
|
import { Box, Text, useApp } from "ink";
|
|
4
4
|
import { BorderBox, Section, Spinner, ThemeProvider, useTheme, Logo, } from "../components/common/index.js";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import { getInstalledVersion } from "../lib/helm.js";
|
|
8
|
-
import { getNamespace, getReleaseName, } from "../types/index.js";
|
|
5
|
+
import { getServiceStatus, getIngressStatus, getCertificateStatus, } from "../lib/kubernetes.js";
|
|
6
|
+
import { arePodsHealthy, loadDeploymentHealth, } from "../lib/deploymentHealth.js";
|
|
9
7
|
function StatusCommandInner({ name, data, }) {
|
|
10
8
|
const { exit } = useApp();
|
|
11
9
|
const { colors } = useTheme();
|
|
12
|
-
const { config, state, clusterStatus } = data;
|
|
10
|
+
const { config, state, health, clusterStatus } = data;
|
|
13
11
|
useEffect(() => {
|
|
14
12
|
// Auto-exit after displaying
|
|
15
13
|
const timer = setTimeout(() => exit(), 10000);
|
|
@@ -17,36 +15,42 @@ function StatusCommandInner({ name, data, }) {
|
|
|
17
15
|
}, [exit]);
|
|
18
16
|
// Determine overall status based on deployment state and pod health
|
|
19
17
|
const getOverallStatus = () => {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
if (
|
|
25
|
-
return "
|
|
26
|
-
if (
|
|
18
|
+
if (health.kind === "online")
|
|
19
|
+
return "healthy";
|
|
20
|
+
if (health.kind === "installed-unreachable")
|
|
21
|
+
return "unreachable";
|
|
22
|
+
if (health.kind === "installed-degraded")
|
|
23
|
+
return "degraded";
|
|
24
|
+
if (health.kind === "cluster-unreachable")
|
|
25
|
+
return "cluster-unreachable";
|
|
26
|
+
if (health.kind === "destroyed")
|
|
27
27
|
return "destroyed";
|
|
28
|
-
if (
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
return state.status === "running" ? "degraded" : "unknown";
|
|
28
|
+
if (health.kind === "not-installed") {
|
|
29
|
+
if (state?.status === "failed")
|
|
30
|
+
return "failed";
|
|
31
|
+
if (state?.status === "pending")
|
|
32
|
+
return "pending";
|
|
33
|
+
if (state?.status === "deploying")
|
|
34
|
+
return "deploying";
|
|
35
|
+
if (state?.status === "waiting-dns")
|
|
36
|
+
return "waiting-dns";
|
|
37
|
+
return state ? "not-installed" : "not-deployed";
|
|
39
38
|
}
|
|
40
|
-
|
|
41
|
-
const allPodsHealthy = pods.every((p) => p.ready || p.status === "Succeeded" || p.status === "Completed");
|
|
42
|
-
return allPodsHealthy ? "healthy" : "degraded";
|
|
39
|
+
return "unknown";
|
|
43
40
|
};
|
|
44
41
|
const overallStatus = getOverallStatus();
|
|
45
42
|
const statusDisplay = {
|
|
46
43
|
healthy: { icon: "●", label: "Healthy", color: colors.success },
|
|
44
|
+
unreachable: { icon: "◐", label: "Installed, URL Unreachable", color: colors.warning },
|
|
47
45
|
degraded: { icon: "◐", label: "Degraded", color: colors.warning },
|
|
48
46
|
failed: { icon: "✗", label: "Failed", color: colors.error },
|
|
47
|
+
"cluster-unreachable": {
|
|
48
|
+
icon: "?",
|
|
49
|
+
label: "Cluster Unreachable",
|
|
50
|
+
color: colors.warning,
|
|
51
|
+
},
|
|
49
52
|
destroyed: { icon: "○", label: "Destroyed", color: colors.muted },
|
|
53
|
+
"not-installed": { icon: "○", label: "Not Installed", color: colors.muted },
|
|
50
54
|
pending: { icon: "○", label: "Pending", color: colors.muted },
|
|
51
55
|
deploying: { icon: "◐", label: "Deploying", color: colors.accent },
|
|
52
56
|
"waiting-dns": {
|
|
@@ -58,11 +62,8 @@ function StatusCommandInner({ name, data, }) {
|
|
|
58
62
|
unknown: { icon: "?", label: "Unknown", color: colors.muted },
|
|
59
63
|
};
|
|
60
64
|
const status = statusDisplay[overallStatus] || statusDisplay["unknown"];
|
|
61
|
-
return (_jsx(BorderBox, { title: `Status: ${name}`, children: _jsxs(Box, { flexDirection: "column", children: [_jsxs(Section, { title: "Overview", children: [_jsxs(Text, { children: ["Status:", " ", _jsxs(Text, { color: status.color, children: [status.icon, " ", status.label] })] }), state && (_jsxs(Text, { children: ["Version:", " ", _jsx(Text, { color: colors.accent, children: clusterStatus.version || "Unknown" })] })), _jsxs(Text, { children: ["URL: ", _jsxs(Text, { color: colors.accent, children: ["https://", config.domain] })] })] }), overallStatus === "not-deployed" && (_jsxs(Box, { marginY: 1, flexDirection: "column", children: [_jsx(Text, { color: colors.muted, children: "This configuration has not been deployed yet." }), _jsx(Box, { marginTop: 0, children: _jsxs(Text, { color: colors.muted, children: ["Run: ", _jsxs(Text, { color: colors.accent, children: ["rulebricks deploy ", name] })] }) })] })),
|
|
62
|
-
|
|
63
|
-
const isHealthy = pod.ready ||
|
|
64
|
-
pod.status === "Succeeded" ||
|
|
65
|
-
pod.status === "Completed";
|
|
65
|
+
return (_jsx(BorderBox, { title: `Status: ${name}`, children: _jsxs(Box, { flexDirection: "column", children: [_jsxs(Section, { title: "Overview", children: [_jsxs(Text, { children: ["Status:", " ", _jsxs(Text, { color: status.color, children: [status.icon, " ", status.label] })] }), state && (_jsxs(Text, { children: ["Version:", " ", _jsx(Text, { color: colors.accent, children: clusterStatus.version || "Unknown" })] })), _jsxs(Text, { children: ["URL: ", _jsxs(Text, { color: colors.accent, children: ["https://", config.domain] })] }), _jsxs(Text, { children: ["URL Health:", " ", _jsx(Text, { color: health.httpReachable ? colors.success : colors.warning, children: health.httpReachable ? "Reachable" : "Unreachable" })] }), health.clusterError && (_jsx(Text, { color: colors.warning, children: "Cluster: Unreachable" }))] }), overallStatus === "not-deployed" && (_jsxs(Box, { marginY: 1, flexDirection: "column", children: [_jsx(Text, { color: colors.muted, children: "This configuration has not been deployed yet." }), _jsx(Box, { marginTop: 0, children: _jsxs(Text, { color: colors.muted, children: ["Run: ", _jsxs(Text, { color: colors.accent, children: ["rulebricks deploy ", name] })] }) })] })), !health.clusterError && health.kind !== "not-installed" && health.kind !== "destroyed" && (_jsxs(_Fragment, { children: [_jsx(Section, { title: "Pods", children: clusterStatus.pods.length === 0 ? (_jsx(Text, { color: colors.muted, children: "No pods found" })) : (clusterStatus.pods.map((pod) => {
|
|
66
|
+
const isHealthy = arePodsHealthy([pod]);
|
|
66
67
|
return (_jsxs(Box, { children: [_jsx(Text, { color: isHealthy ? colors.success : colors.warning, children: isHealthy ? "✓" : "○" }), _jsxs(Text, { children: [" ", truncate(pod.name, 40)] }), _jsxs(Text, { color: colors.muted, children: [" ", pod.status] }), pod.restarts > 0 && (_jsxs(Text, { color: colors.warning, children: [" ", "(", pod.restarts, " restarts)"] }))] }, pod.name));
|
|
67
68
|
})) }), _jsxs(Section, { title: "Services", children: [clusterStatus.services.length === 0 ? (_jsx(Text, { color: colors.muted, children: "No services found" })) : (clusterStatus.services.slice(0, 5).map((svc) => (_jsxs(Box, { children: [_jsx(Text, { color: colors.success, children: "\u2713" }), _jsxs(Text, { children: [" ", truncate(svc.name, 30)] }), _jsxs(Text, { color: colors.muted, children: [" ", svc.type] }), svc.externalIP && (_jsxs(Text, { color: colors.accent, children: [" \u2192 ", svc.externalIP] }))] }, svc.name)))), (clusterStatus.services.length || 0) > 5 && (_jsxs(Text, { color: colors.muted, children: ["... and ", (clusterStatus.services.length || 0) - 5, " more"] }))] }), _jsx(Section, { title: "Ingress", children: clusterStatus.ingresses.length === 0 ? (_jsx(Text, { color: colors.muted, children: "No ingresses found" })) : (clusterStatus.ingresses.map((ing) => (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: ing.address ? colors.success : colors.warning, children: ing.address ? "✓" : "○" }), _jsxs(Text, { children: [" ", ing.name] })] }), ing.hosts.map((host) => (_jsx(Box, { marginLeft: 2, children: _jsxs(Text, { color: colors.muted, children: ["\u2192 ", host, " ", ing.tls ? "(TLS)" : ""] }) }, host)))] }, ing.name)))) }), _jsx(Section, { title: "TLS Certificates", children: clusterStatus.certificates.length === 0 ? (_jsx(Text, { color: colors.muted, children: "No certificates found" })) : (clusterStatus.certificates.map((cert) => (_jsxs(Box, { children: [_jsx(Text, { color: cert.ready ? colors.success : colors.warning, children: cert.ready ? "✓" : "○" }), _jsxs(Text, { children: [" ", cert.name] }), _jsx(Text, { color: cert.ready ? colors.success : colors.warning, children: cert.ready ? " Ready" : " Pending" })] }, cert.name)))) })] })), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: colors.muted, children: "Press Ctrl+C to exit" }) })] }) }));
|
|
68
69
|
}
|
|
@@ -79,26 +80,38 @@ function StatusLoader({ name }) {
|
|
|
79
80
|
}, []);
|
|
80
81
|
async function loadStatus() {
|
|
81
82
|
try {
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
83
|
+
const health = await loadDeploymentHealth(name, {
|
|
84
|
+
refreshKubeconfig: true,
|
|
85
|
+
});
|
|
86
|
+
if (!health.config) {
|
|
87
|
+
setError(health.configError || "Invalid deployment config");
|
|
88
|
+
setLoading(false);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const selectedTheme = health.kind === "online" ||
|
|
92
|
+
health.kind === "installed-unreachable" ||
|
|
93
|
+
health.kind === "installed-degraded"
|
|
94
|
+
? "status"
|
|
95
|
+
: "logs";
|
|
87
96
|
setTheme(selectedTheme);
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
getCertificateStatus(namespace),
|
|
96
|
-
getInstalledVersion(releaseName, namespace),
|
|
97
|
-
]);
|
|
97
|
+
const [services, ingresses, certificates] = health.clusterError
|
|
98
|
+
? [[], [], []]
|
|
99
|
+
: await Promise.all([
|
|
100
|
+
getServiceStatus(health.namespace),
|
|
101
|
+
getIngressStatus(health.namespace),
|
|
102
|
+
getCertificateStatus(health.namespace),
|
|
103
|
+
]);
|
|
98
104
|
setData({
|
|
99
|
-
config,
|
|
100
|
-
state,
|
|
101
|
-
|
|
105
|
+
config: health.config,
|
|
106
|
+
state: health.state,
|
|
107
|
+
health,
|
|
108
|
+
clusterStatus: {
|
|
109
|
+
pods: health.pods,
|
|
110
|
+
services,
|
|
111
|
+
ingresses,
|
|
112
|
+
certificates,
|
|
113
|
+
version: health.helmVersion,
|
|
114
|
+
},
|
|
102
115
|
});
|
|
103
116
|
setLoading(false);
|
|
104
117
|
}
|