ninedeploy 0.18.26 → 0.18.27
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/dist/commands/databaseUpgrade.d.ts +25 -0
- package/dist/commands/databaseUpgrade.d.ts.map +1 -0
- package/dist/commands/databaseUpgrade.js +136 -0
- package/dist/commands/databaseUpgrade.js.map +1 -0
- package/dist/commands/importApply.d.ts +114 -0
- package/dist/commands/importApply.d.ts.map +1 -0
- package/dist/commands/importApply.js +403 -0
- package/dist/commands/importApply.js.map +1 -0
- package/dist/commands/importCutover.d.ts +95 -0
- package/dist/commands/importCutover.d.ts.map +1 -0
- package/dist/commands/importCutover.js +293 -0
- package/dist/commands/importCutover.js.map +1 -0
- package/dist/commands/importData.d.ts +60 -0
- package/dist/commands/importData.d.ts.map +1 -0
- package/dist/commands/importData.js +286 -0
- package/dist/commands/importData.js.map +1 -0
- package/dist/commands/importPlan.d.ts +32 -0
- package/dist/commands/importPlan.d.ts.map +1 -0
- package/dist/commands/importPlan.js +138 -0
- package/dist/commands/importPlan.js.map +1 -0
- package/dist/commands/importVolumes.d.ts +38 -0
- package/dist/commands/importVolumes.d.ts.map +1 -0
- package/dist/commands/importVolumes.js +161 -0
- package/dist/commands/importVolumes.js.map +1 -0
- package/dist/index.js +59 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/importers/caprover.d.ts +13 -0
- package/dist/lib/importers/caprover.d.ts.map +1 -0
- package/dist/lib/importers/caprover.js +150 -0
- package/dist/lib/importers/caprover.js.map +1 -0
- package/dist/lib/importers/connections.d.ts +39 -0
- package/dist/lib/importers/connections.d.ts.map +1 -0
- package/dist/lib/importers/connections.js +119 -0
- package/dist/lib/importers/connections.js.map +1 -0
- package/dist/lib/importers/coolify.d.ts +10 -0
- package/dist/lib/importers/coolify.d.ts.map +1 -0
- package/dist/lib/importers/coolify.js +174 -0
- package/dist/lib/importers/coolify.js.map +1 -0
- package/dist/lib/importers/dokploy.d.ts +13 -0
- package/dist/lib/importers/dokploy.d.ts.map +1 -0
- package/dist/lib/importers/dokploy.js +183 -0
- package/dist/lib/importers/dokploy.js.map +1 -0
- package/dist/lib/importers/heroku.d.ts +50 -0
- package/dist/lib/importers/heroku.d.ts.map +1 -0
- package/dist/lib/importers/heroku.js +159 -0
- package/dist/lib/importers/heroku.js.map +1 -0
- package/dist/lib/importers/plan.d.ts +137 -0
- package/dist/lib/importers/plan.d.ts.map +1 -0
- package/dist/lib/importers/plan.js +110 -0
- package/dist/lib/importers/plan.js.map +1 -0
- package/dist/lib/importers/shell.d.ts +24 -0
- package/dist/lib/importers/shell.d.ts.map +1 -0
- package/dist/lib/importers/shell.js +45 -0
- package/dist/lib/importers/shell.js.map +1 -0
- package/dist/lib/importers/sources.d.ts +26 -0
- package/dist/lib/importers/sources.d.ts.map +1 -0
- package/dist/lib/importers/sources.js +100 -0
- package/dist/lib/importers/sources.js.map +1 -0
- package/package.json +3 -3
- package/src/commands/databaseUpgrade.ts +124 -0
- package/src/commands/importApply.ts +445 -0
- package/src/commands/importCutover.ts +329 -0
- package/src/commands/importData.ts +302 -0
- package/src/commands/importPlan.ts +143 -0
- package/src/commands/importVolumes.ts +168 -0
- package/src/index.ts +61 -0
- package/src/lib/importers/caprover.ts +141 -0
- package/src/lib/importers/connections.ts +133 -0
- package/src/lib/importers/coolify.ts +164 -0
- package/src/lib/importers/dokploy.ts +167 -0
- package/src/lib/importers/heroku.ts +168 -0
- package/src/lib/importers/plan.ts +209 -0
- package/src/lib/importers/shell.ts +56 -0
- package/src/lib/importers/sources.ts +96 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ninedeploy import cutover <plan.json> [--yes]`
|
|
3
|
+
*
|
|
4
|
+
* A1 step 3, domain half (docs/plans/COMPETITIVE_ROADMAP_2026-11.md). Adds
|
|
5
|
+
* each planned domain to the service `import apply` created, but only once
|
|
6
|
+
* that service is ready and the hostname's DNS already points at this panel.
|
|
7
|
+
*
|
|
8
|
+
* - Ready means the service is `running` and its latest deployment is
|
|
9
|
+
* `running` (succeeded). It is not an HTTP probe of the app, and this
|
|
10
|
+
* command never deploys.
|
|
11
|
+
* - The DNS gate resolves each host here, with a direct DNS query (not the
|
|
12
|
+
* OS cache or hosts file), and passes when every A/AAAA address is one this
|
|
13
|
+
* panel expects: --expect, else Settings → DNS records' content. It stays
|
|
14
|
+
* strict because adding a domain makes Traefik request a certificate, and
|
|
15
|
+
* Let's Encrypt limits failed validations per hostname.
|
|
16
|
+
* - --skip-dns-check <host> is for records behind a proxy (Cloudflare's
|
|
17
|
+
* orange cloud resolves to Cloudflare). It is refused unless the panel's
|
|
18
|
+
* Cloudflare integration is known to be off: that integration POSTs a new
|
|
19
|
+
* A record on every domain add, which next to a record that still points
|
|
20
|
+
* at the source would split traffic between the two hosts.
|
|
21
|
+
* - It never adds the source's own generated hosts (sourceDefault) and
|
|
22
|
+
* reports wildcard hosts for a person: HTTP-01 cannot issue them.
|
|
23
|
+
* - The panel is the truth for what exists: every run lists the service's
|
|
24
|
+
* domains first, so a domain added by hand or by a run that stopped counts
|
|
25
|
+
* as done. A `pending` domain (ownership proof outstanding) is verified
|
|
26
|
+
* once its DNS passes; the TXT challenge is printed, never stored.
|
|
27
|
+
*/
|
|
28
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
29
|
+
import { Resolver } from 'node:dns/promises';
|
|
30
|
+
import { isIP } from 'node:net';
|
|
31
|
+
import { getClient, type NineDeployClient } from '../client.js';
|
|
32
|
+
import { loadConfig } from '../config.js';
|
|
33
|
+
import { c, error, header, info, success } from '../lib/format.js';
|
|
34
|
+
import type { ImportPlan, PlanDomain } from '../lib/importers/plan.js';
|
|
35
|
+
import { type ImportReceipt, type ReceiptItem, renderReceipt, validatePlan, writeReceipt } from './importApply.js';
|
|
36
|
+
|
|
37
|
+
/** The panel calls `import cutover` makes; NineDeployClient satisfies it. */
|
|
38
|
+
export interface CutoverClient {
|
|
39
|
+
services: { get: (id: number) => Promise<{ status: string }> };
|
|
40
|
+
deploys: { list: (serviceId: number) => Promise<Array<{ id: number; status: string }>> };
|
|
41
|
+
domains: {
|
|
42
|
+
list: NineDeployClient['domains']['list'];
|
|
43
|
+
create: NineDeployClient['domains']['create'];
|
|
44
|
+
verify: NineDeployClient['domains']['verify'];
|
|
45
|
+
};
|
|
46
|
+
settings: { dnsRecords: { get: () => Promise<{ enabled: boolean; content: string | null }> } };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface DnsLookup {
|
|
50
|
+
resolve4: (host: string) => Promise<string[]>;
|
|
51
|
+
resolve6: (host: string) => Promise<string[]>;
|
|
52
|
+
/** The servers queried, for the report. */
|
|
53
|
+
servers: () => string[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function dnsLookup(server?: string): DnsLookup {
|
|
57
|
+
const r = new Resolver({ timeout: 5000, tries: 2 });
|
|
58
|
+
if (server) r.setServers([server]);
|
|
59
|
+
return { resolve4: (h) => r.resolve4(h), resolve6: (h) => r.resolve6(h), servers: () => r.getServers() };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A and AAAA of a host; a name with no record of a type gives none of it. */
|
|
63
|
+
export async function resolveHost(dns: DnsLookup, host: string): Promise<string[]> {
|
|
64
|
+
const none = (err: unknown) => {
|
|
65
|
+
const code = (err as { code?: string }).code;
|
|
66
|
+
if (code === 'ENOTFOUND' || code === 'ENODATA') return [] as string[];
|
|
67
|
+
throw err;
|
|
68
|
+
};
|
|
69
|
+
const [a, aaaa] = await Promise.all([dns.resolve4(host).catch(none), dns.resolve6(host).catch(none)]);
|
|
70
|
+
return [...a, ...aaaa];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type CloudflareState = 'on' | 'off' | 'unknown';
|
|
74
|
+
export interface Expected {
|
|
75
|
+
addresses: string[];
|
|
76
|
+
source: string;
|
|
77
|
+
cloudflare: CloudflareState;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The addresses a host must resolve to. --expect wins; else the content the
|
|
82
|
+
* panel writes into DNS records (Settings → DNS records), an admin-only read.
|
|
83
|
+
* A hostname (CNAME mode) is resolved here; the panel's own DNS check
|
|
84
|
+
* compares it literally, so it can never match there.
|
|
85
|
+
*/
|
|
86
|
+
export async function expectedAddresses(client: CutoverClient, dns: DnsLookup, expect: string[]): Promise<Expected> {
|
|
87
|
+
let settings: { enabled: boolean; content: string | null } | null = null;
|
|
88
|
+
try {
|
|
89
|
+
settings = await client.settings.dnsRecords.get();
|
|
90
|
+
} catch (err) {
|
|
91
|
+
const status = (err as { status?: number }).status;
|
|
92
|
+
if (status !== 401 && status !== 403) throw err;
|
|
93
|
+
}
|
|
94
|
+
const cloudflare: CloudflareState = settings ? (settings.enabled ? 'on' : 'off') : 'unknown';
|
|
95
|
+
const wanted = expect.length > 0 ? expect : settings?.content ? [settings.content] : [];
|
|
96
|
+
const source = expect.length > 0 ? '--expect' : 'Settings → DNS records';
|
|
97
|
+
if (wanted.length === 0) {
|
|
98
|
+
throw new Error(settings
|
|
99
|
+
? 'This panel has no DNS records address set (Settings → DNS records), so there is nothing to compare DNS with. Pass --expect <this panel\'s public IP>.'
|
|
100
|
+
: 'Only an admin can read the panel\'s DNS records address. Pass --expect <this panel\'s public IP>.');
|
|
101
|
+
}
|
|
102
|
+
const addresses: string[] = [];
|
|
103
|
+
for (const w of wanted) {
|
|
104
|
+
if (isIP(w)) addresses.push(canonicalIp(w));
|
|
105
|
+
else {
|
|
106
|
+
const resolved = await resolveHost(dns, w);
|
|
107
|
+
if (resolved.length === 0) throw new Error(`The expected address ${w} (${source}) does not resolve.`);
|
|
108
|
+
addresses.push(...resolved.map(canonicalIp));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return { addresses: [...new Set(addresses)], source: wanted.some((w) => !isIP(w)) ? `${source}, ${wanted.join(', ')} resolved` : source, cloudflare };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface CutoverOptions {
|
|
115
|
+
yes: boolean;
|
|
116
|
+
/** Hosts whose DNS gate is skipped (proxied records). */
|
|
117
|
+
skipDnsCheck: string[];
|
|
118
|
+
}
|
|
119
|
+
export interface CutoverDeps {
|
|
120
|
+
client: CutoverClient;
|
|
121
|
+
dns: DnsLookup;
|
|
122
|
+
expected: Expected;
|
|
123
|
+
receipt: ImportReceipt;
|
|
124
|
+
save: (receipt: ImportReceipt) => void;
|
|
125
|
+
now?: () => Date;
|
|
126
|
+
}
|
|
127
|
+
export interface CutoverResult {
|
|
128
|
+
lines: string[];
|
|
129
|
+
added: number;
|
|
130
|
+
waiting: number;
|
|
131
|
+
failed: number;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const lower = (h: string) => h.toLowerCase().replace(/\.$/, '');
|
|
135
|
+
|
|
136
|
+
/** One spelling per address, so `2001:DB8:0::1` and `2001:db8::1` compare equal. */
|
|
137
|
+
export function canonicalIp(ip: string): string {
|
|
138
|
+
return isIP(ip) === 6 ? new URL(`http://[${ip}]/`).hostname.slice(1, -1) : ip;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Why a host's DNS is not ready for a certificate, or null. Every A and AAAA
|
|
143
|
+
* must be this panel's: Let's Encrypt validates over IPv6 first when an AAAA
|
|
144
|
+
* exists, so a leftover record that still names the source fails the
|
|
145
|
+
* certificate even when another record already points here.
|
|
146
|
+
*/
|
|
147
|
+
export function dnsProblem(resolved: string[], expected: string[]): string | null {
|
|
148
|
+
if (resolved.length === 0) return 'does not resolve';
|
|
149
|
+
const stray = resolved.map(canonicalIp).filter((a) => !expected.includes(a));
|
|
150
|
+
if (stray.length === 0) return null;
|
|
151
|
+
const which = stray.map((a) => `${isIP(a) === 6 ? 'AAAA' : 'A'} ${a}`).join(', ');
|
|
152
|
+
return stray.length === resolved.length
|
|
153
|
+
? `resolves to ${which}, not ${expected.join(', ')}`
|
|
154
|
+
: `${which} ${stray.length === 1 ? 'is' : 'are'} not this panel (expected ${expected.join(', ')}): remove ${stray.length === 1 ? 'it' : 'them'}, or pass --expect with ${stray.length === 1 ? 'it' : 'them'} if ${stray.length === 1 ? 'it is' : 'they are'} this panel's`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Check readiness and DNS for every planned domain; with yes, add or verify those that pass. */
|
|
158
|
+
export async function cutover(plan: ImportPlan, opts: CutoverOptions, deps: CutoverDeps): Promise<CutoverResult> {
|
|
159
|
+
const { client, dns, expected, receipt } = deps;
|
|
160
|
+
const now = deps.now ?? (() => new Date());
|
|
161
|
+
const skip = new Set(opts.skipDnsCheck.map(lower));
|
|
162
|
+
const out: string[] = [];
|
|
163
|
+
const planned = new Set(plan.services.flatMap((s) => s.domains.map((d) => lower(d.host))));
|
|
164
|
+
for (const h of skip) if (!planned.has(h)) out.push(` ! --skip-dns-check ${h}: no planned domain has that host`);
|
|
165
|
+
const res: CutoverResult = { lines: out, added: 0, waiting: 0, failed: 0 };
|
|
166
|
+
const put = (item: Omit<ReceiptItem, 'at'>) => {
|
|
167
|
+
receipt.items[`domain:${item.ref}`] = { ...item, at: now().toISOString() };
|
|
168
|
+
deps.save(receipt);
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
for (const s of plan.services) {
|
|
172
|
+
const hosts: PlanDomain[] = [];
|
|
173
|
+
const seen = new Set<string>();
|
|
174
|
+
for (const d of s.domains) {
|
|
175
|
+
if (d.sourceDefault || seen.has(lower(d.host))) continue;
|
|
176
|
+
seen.add(lower(d.host));
|
|
177
|
+
hosts.push(d);
|
|
178
|
+
}
|
|
179
|
+
if (hosts.length === 0) continue;
|
|
180
|
+
const created = receipt.items[`service:${s.ref}`];
|
|
181
|
+
if (!created || created.state !== 'done' || created.id === undefined) {
|
|
182
|
+
out.push(` – ${s.name}: not created yet (run import apply first)`);
|
|
183
|
+
res.waiting += hosts.length;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const serviceId = created.id;
|
|
187
|
+
const existing = new Map((await client.domains.list(serviceId)).map((d) => [lower(d.hostname), d]));
|
|
188
|
+
const svc = await client.services.get(serviceId);
|
|
189
|
+
const last = (await client.deploys.list(serviceId))[0];
|
|
190
|
+
const ready = svc.status === 'running' && last?.status === 'running';
|
|
191
|
+
out.push(` ${s.name} (#${serviceId}): service ${svc.status}, last deploy ${last ? last.status : 'none'}`);
|
|
192
|
+
|
|
193
|
+
for (const d of hosts) {
|
|
194
|
+
const host = lower(d.host);
|
|
195
|
+
const ref = `${s.ref}:${host}`;
|
|
196
|
+
const row = existing.get(host);
|
|
197
|
+
if (row?.status === 'active') {
|
|
198
|
+
out.push(` ✓ ${host}: added (#${row.id})`);
|
|
199
|
+
const prev = receipt.items[`domain:${ref}`];
|
|
200
|
+
if (opts.yes && (prev?.state !== 'done' || prev.id !== row.id)) put({ kind: 'domain', ref, name: host, state: 'done', id: row.id, notes: [] });
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (host.startsWith('*.')) {
|
|
204
|
+
out.push(` – ${host}: a wildcard needs a DNS-01 certificate; add it by hand`);
|
|
205
|
+
res.waiting++;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (!ready) {
|
|
209
|
+
out.push(` – ${host}: waits until the service runs a successful deploy (deploy it and check it first)`);
|
|
210
|
+
res.waiting++;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
// DNS gate.
|
|
214
|
+
let note: string | null = null;
|
|
215
|
+
if (skip.has(host)) {
|
|
216
|
+
if (expected.cloudflare !== 'off') {
|
|
217
|
+
out.push(` ✗ ${host}: --skip-dns-check refused: ${expected.cloudflare === 'on'
|
|
218
|
+
? 'the panel\'s Cloudflare integration is on and would add a second A record next to the source\'s'
|
|
219
|
+
: 'only an admin can see whether the panel\'s Cloudflare integration is on (it would add a second A record next to the source\'s)'}`);
|
|
220
|
+
res.waiting++;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
note = 'DNS check skipped (--skip-dns-check)';
|
|
224
|
+
} else {
|
|
225
|
+
const gate = dnsProblem(await resolveHost(dns, host), expected.addresses);
|
|
226
|
+
if (gate) {
|
|
227
|
+
out.push(` ✗ ${host}: ${gate}; fix DNS first`);
|
|
228
|
+
res.waiting++;
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (!opts.yes) {
|
|
233
|
+
out.push(` + ${host}: ${note ?? 'DNS points here'}; would ${row ? 'verify the pending domain' : `add it (${d.https ? 'https' : 'http'})`}`);
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
const notes = note ? [note] : [];
|
|
237
|
+
if (row) {
|
|
238
|
+
// Pending ownership proof: verifying flips it live, so it waited for the gate too.
|
|
239
|
+
let v: Awaited<ReturnType<CutoverClient['domains']['verify']>>;
|
|
240
|
+
try {
|
|
241
|
+
v = await client.domains.verify(serviceId, row.id);
|
|
242
|
+
} catch (err) {
|
|
243
|
+
out.push(` ✗ ${host}: verify failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
244
|
+
res.failed++;
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
if (v.verified) {
|
|
248
|
+
if (v.dnsWarning) notes.push(`DNS provider: ${v.dnsWarning}`);
|
|
249
|
+
put({ kind: 'domain', ref, name: host, state: 'done', id: row.id, notes });
|
|
250
|
+
out.push(` ✓ ${host}: verified and live (#${row.id})`);
|
|
251
|
+
res.added++;
|
|
252
|
+
} else {
|
|
253
|
+
out.push(` – ${host}: ownership not proven yet${v.error ? ` (${v.error})` : ''}`);
|
|
254
|
+
if (v.verification) out.push(` publish TXT ${v.verification.recordName} = ${v.verification.recordValue}, then run this again`);
|
|
255
|
+
res.waiting++;
|
|
256
|
+
}
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
put({ kind: 'domain', ref, name: host, state: 'pending', notes });
|
|
260
|
+
let made: Awaited<ReturnType<CutoverClient['domains']['create']>>;
|
|
261
|
+
try {
|
|
262
|
+
made = await client.domains.create(serviceId, { hostname: host, ssl: d.https });
|
|
263
|
+
} catch (err) {
|
|
264
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
265
|
+
put({ kind: 'domain', ref, name: host, state: 'failed', notes: [...notes, msg] });
|
|
266
|
+
out.push(` ✗ ${host}: ${msg}`);
|
|
267
|
+
res.failed++;
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
if (made.dnsWarning) notes.push(`DNS provider: ${made.dnsWarning}`);
|
|
271
|
+
if (made.status === 'pending') notes.push('ownership proof pending (TXT)');
|
|
272
|
+
put({ kind: 'domain', ref, name: host, state: 'done', id: made.id, notes });
|
|
273
|
+
// A pending domain is not routed yet: it waits for its TXT proof.
|
|
274
|
+
if (made.status === 'pending') res.waiting++;
|
|
275
|
+
else res.added++;
|
|
276
|
+
out.push(` ✓ ${host}: added (#${made.id}, ${made.status})`);
|
|
277
|
+
if (made.verification) {
|
|
278
|
+
out.push(` publish TXT ${made.verification.recordName} = ${made.verification.recordValue}, then run this again to verify`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return res;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export interface ImportCutoverOpts {
|
|
286
|
+
receipt?: string;
|
|
287
|
+
yes?: boolean;
|
|
288
|
+
expect?: string[];
|
|
289
|
+
resolver?: string;
|
|
290
|
+
skipDnsCheck?: string[];
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export async function importCutoverAction(
|
|
294
|
+
planPath: string,
|
|
295
|
+
opts: ImportCutoverOpts,
|
|
296
|
+
deps: { client?: CutoverClient; panel?: string; dns?: DnsLookup } = {},
|
|
297
|
+
): Promise<void> {
|
|
298
|
+
try {
|
|
299
|
+
const plan = validatePlan(JSON.parse(readFileSync(planPath, 'utf8')));
|
|
300
|
+
const receiptPath = opts.receipt ?? `${planPath.replace(/\.json$/i, '')}.receipt.json`;
|
|
301
|
+
if (!existsSync(receiptPath)) throw new Error(`No receipt at ${receiptPath}: run \`ninedeploy import apply ${planPath} --yes\` first.`);
|
|
302
|
+
const receipt = JSON.parse(readFileSync(receiptPath, 'utf8')) as ImportReceipt;
|
|
303
|
+
const panel = (deps.panel ?? loadConfig().baseUrl).replace(/\/+$/, '');
|
|
304
|
+
if (receipt.panel !== panel) throw new Error(`The receipt is for ${receipt.panel}, but the CLI is signed in to ${panel}.`);
|
|
305
|
+
if (opts.resolver && !isIP(opts.resolver)) throw new Error(`--resolver takes an IP address, got "${opts.resolver}".`);
|
|
306
|
+
const client = deps.client ?? getClient();
|
|
307
|
+
const dns = deps.dns ?? dnsLookup(opts.resolver);
|
|
308
|
+
const expected = await expectedAddresses(client, dns, opts.expect ?? []);
|
|
309
|
+
header(`Cutover into ${panel}`);
|
|
310
|
+
info(`Expected address: ${expected.addresses.join(', ')} (${expected.source})`);
|
|
311
|
+
info(`Resolver: ${dns.servers().join(', ') || 'system default'}`);
|
|
312
|
+
if (expected.cloudflare === 'on') info('The Cloudflare integration is on: each domain added here also gets an A record created in Cloudflare.');
|
|
313
|
+
const result = await cutover(plan, { yes: !!opts.yes, skipDnsCheck: opts.skipDnsCheck ?? [] }, {
|
|
314
|
+
client, dns, expected, receipt, save: (r) => writeReceipt(receiptPath, r),
|
|
315
|
+
});
|
|
316
|
+
console.log(result.lines.length ? result.lines.join('\n') : ' No domains to move (the plan has none besides the source\'s own hosts).');
|
|
317
|
+
if (!opts.yes) {
|
|
318
|
+
info(`${c.dim('Nothing was added.')} Run again with --yes to add the domains marked +.`);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
const items = Object.values(receipt.items).filter((i) => i.kind === 'domain');
|
|
322
|
+
if (items.length) console.log(renderReceipt({ ...receipt, items: Object.fromEntries(items.map((i) => [`domain:${i.ref}`, i])) }));
|
|
323
|
+
if (result.failed) error(`${result.failed} domain(s) failed. Fix the cause and run the same command again. Receipt: ${receiptPath}`);
|
|
324
|
+
else if (result.waiting) info(`${result.added} added, ${result.waiting} waiting. Run the same command again once they are ready. Receipt: ${receiptPath}`);
|
|
325
|
+
else success(`Done: every planned domain is on this panel. Receipt: ${receiptPath}`);
|
|
326
|
+
} catch (err) {
|
|
327
|
+
error(err instanceof Error ? err.message : String(err));
|
|
328
|
+
}
|
|
329
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ninedeploy import data <plan.json> [--dump <database>=<file> …] [--yes]`
|
|
3
|
+
*
|
|
4
|
+
* A1 step 3, data half (docs/plans/COMPETITIVE_ROADMAP_2026-11.md). Moves
|
|
5
|
+
* database contents into the databases `import apply` created.
|
|
6
|
+
*
|
|
7
|
+
* - Without --yes it prints the cutover order and, for each database, the
|
|
8
|
+
* command that takes a dump on the source host. The commands read the
|
|
9
|
+
* credentials from the database container's own environment, inside
|
|
10
|
+
* single quotes, so no password reaches this output or the shell history.
|
|
11
|
+
* - With --dump and --yes it uploads each file through the panel's chunked
|
|
12
|
+
* dump import, declaring the file's sha256. The panel checks it before it
|
|
13
|
+
* restores anything.
|
|
14
|
+
* - The receipt records each import's id, sha256 and size, never content. A
|
|
15
|
+
* run that stops during the upload resumes the same import. A database whose
|
|
16
|
+
* data is already in is not imported again.
|
|
17
|
+
*/
|
|
18
|
+
import { createHash } from 'node:crypto';
|
|
19
|
+
import { createReadStream, existsSync, readFileSync, statSync } from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import type { DatabaseImport, DatabaseImportOptions } from '@ninedeploy/schemas';
|
|
22
|
+
import { getClient, type NineDeployClient } from '../client.js';
|
|
23
|
+
import { loadConfig } from '../config.js';
|
|
24
|
+
import { c, error, header, info, success } from '../lib/format.js';
|
|
25
|
+
import type { ImportPlan, PlanDatabase, PlanEngine, SourceKind } from '../lib/importers/plan.js';
|
|
26
|
+
import { fileBase, flagArg, isPlainName, sourceTarget } from '../lib/importers/shell.js';
|
|
27
|
+
import { type ImportReceipt, renderReceipt, validatePlan, writeReceipt } from './importApply.js';
|
|
28
|
+
import { importVolumes, renderVolumePreview, type VolumeClient } from './importVolumes.js';
|
|
29
|
+
|
|
30
|
+
/** The panel calls `import data` makes; NineDeployClient satisfies it. */
|
|
31
|
+
export interface DataClient extends VolumeClient {
|
|
32
|
+
databases: {
|
|
33
|
+
importFile: NineDeployClient['databases']['importFile'];
|
|
34
|
+
imports: {
|
|
35
|
+
get: NineDeployClient['databases']['imports']['get'];
|
|
36
|
+
wait: NineDeployClient['databases']['imports']['wait'];
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Engines the panel's dump import accepts (schemas databaseImport.ts). */
|
|
42
|
+
const IMPORTABLE: Record<PlanEngine, boolean> = {
|
|
43
|
+
postgres: true, mysql: true, mariadb: true, mongo: true, redis: true, keydb: true, dragonfly: true, clickhouse: false,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** Dump file name per engine, by format. */
|
|
47
|
+
const DUMP_FILE: Record<PlanEngine, (n: string) => string> = {
|
|
48
|
+
postgres: (n) => `${n}.dump`, mysql: (n) => `${n}.sql`, mariadb: (n) => `${n}.sql`, mongo: (n) => `${n}.archive.gz`,
|
|
49
|
+
redis: (n) => `${n}.rdb`, keydb: (n) => `${n}.rdb`, dragonfly: (n) => `${n}.rdb`, clickhouse: (n) => n,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The command, run on the source host, that writes a dump the panel's import
|
|
54
|
+
* accepts: pg_dump custom format, mysqldump/mariadb-dump SQL of the app
|
|
55
|
+
* database (not the system schemas, which the import refuses), a gzipped
|
|
56
|
+
* mongodump archive, an RDB snapshot.
|
|
57
|
+
*
|
|
58
|
+
* The MySQL/MariaDB dump names the database positionally, never with
|
|
59
|
+
* --databases: that form writes `CREATE DATABASE shopdb; USE shopdb;`, and
|
|
60
|
+
* the panel feeds the file to `mysql … app` as it is, so the rows would land
|
|
61
|
+
* in `shopdb` instead of the managed `app` database (and the panel refuses
|
|
62
|
+
* any `USE` other than `USE app` from a non-operator).
|
|
63
|
+
*/
|
|
64
|
+
export function dumpCommand(db: PlanDatabase): { command: string; file: string; notes: string[] } | null {
|
|
65
|
+
if (!db.engine || !IMPORTABLE[db.engine]) return null;
|
|
66
|
+
const file = DUMP_FILE[db.engine](fileBase(db.name));
|
|
67
|
+
if (db.runtime?.kind === 'heroku-app') {
|
|
68
|
+
// Heroku Postgres: a pg_dump custom-format backup taken by Heroku, downloaded with the Heroku CLI.
|
|
69
|
+
if (db.engine !== 'postgres') return null;
|
|
70
|
+
const app = isPlainName(db.runtime.name) ? db.runtime.name : '<app>';
|
|
71
|
+
return {
|
|
72
|
+
command: `heroku pg:backups:capture -a ${app} && curl -sSfo ${file} "$(heroku pg:backups:url -a ${app})"`,
|
|
73
|
+
file,
|
|
74
|
+
notes: app === '<app>' ? ['Replace <app> with the Heroku app name.'] : [],
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
const notes: string[] = [];
|
|
78
|
+
const { target, note } = sourceTarget(db.runtime, 'the name of the database container');
|
|
79
|
+
if (note) notes.push(note);
|
|
80
|
+
let inner: string;
|
|
81
|
+
let auth = '';
|
|
82
|
+
switch (db.engine) {
|
|
83
|
+
case 'postgres':
|
|
84
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: shell parameter expansion, run inside the container
|
|
85
|
+
inner = 'pg_dump -Fc -U "${POSTGRES_USER:-postgres}" "${POSTGRES_DB:-${POSTGRES_USER:-postgres}}"';
|
|
86
|
+
break;
|
|
87
|
+
case 'mysql':
|
|
88
|
+
inner = 'exec mysqldump -uroot -p"$MYSQL_ROOT_PASSWORD" --single-transaction --routines --triggers "$MYSQL_DATABASE"';
|
|
89
|
+
notes.push('If the container has no MYSQL_DATABASE, put the database name in its place.');
|
|
90
|
+
break;
|
|
91
|
+
case 'mariadb':
|
|
92
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: shell parameter expansion, run inside the container
|
|
93
|
+
inner = 'exec mariadb-dump -uroot -p"${MARIADB_ROOT_PASSWORD:-$MYSQL_ROOT_PASSWORD}" --single-transaction --routines --triggers "${MARIADB_DATABASE:-$MYSQL_DATABASE}"';
|
|
94
|
+
notes.push('If the container has no MARIADB_DATABASE or MYSQL_DATABASE, put the database name in its place.');
|
|
95
|
+
break;
|
|
96
|
+
case 'mongo':
|
|
97
|
+
inner = 'exec mongodump --archive --gzip -u "$MONGO_INITDB_ROOT_USERNAME" -p "$MONGO_INITDB_ROOT_PASSWORD" --authenticationDatabase admin';
|
|
98
|
+
break;
|
|
99
|
+
default: {
|
|
100
|
+
// redis, keydb, dragonfly: an RDB snapshot through the CLI the image ships.
|
|
101
|
+
const cli = db.engine === 'keydb' ? 'keydb-cli' : 'redis-cli';
|
|
102
|
+
inner = `${cli} --rdb /tmp/nd-dump.rdb >/dev/null && cat /tmp/nd-dump.rdb && rm -f /tmp/nd-dump.rdb`;
|
|
103
|
+
auth = ' -e REDISCLI_AUTH';
|
|
104
|
+
notes.push('With a password, first run `read -rs REDISCLI_AUTH; export REDISCLI_AUTH` on the source host; -e passes it by name, never on the command line.');
|
|
105
|
+
if (db.engine === 'dragonfly') notes.push('Dragonfly images do not always ship redis-cli; run it from a redis image on the same network if it is missing.');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return { command: `docker exec${auth} ${target} sh -c '${inner}' > ${file}`, file, notes };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The order that loses no writes, and the steps this command does not do. */
|
|
112
|
+
export const CUTOVER_ORDER = [
|
|
113
|
+
'A day ahead: lower the TTL of each domain\'s DNS record (for example to 60 seconds).',
|
|
114
|
+
'Stop writes on the source: stop the apps that write, or put them in maintenance. A dump of a live database misses every write made after it.',
|
|
115
|
+
'Take the final dump of each database with the commands below, on the source host.',
|
|
116
|
+
'Import them: ninedeploy import data plan.json --dump <database>=<file> --yes',
|
|
117
|
+
'Deploy each service in NineDeploy and check it is healthy. import apply attached the new database where a copied connection string allowed it; change the variables it listed by hand.',
|
|
118
|
+
'Switch DNS to this panel, then add the domains: ninedeploy import cutover plan.json --yes (it adds only hosts whose DNS already points here).',
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
const SOURCE_CHECKED_LIVE: Record<SourceKind, boolean> = { caprover: true, coolify: false, dokploy: true, heroku: false };
|
|
122
|
+
|
|
123
|
+
export function renderDataPreview(plan: ImportPlan, receipt: ImportReceipt | null): string {
|
|
124
|
+
const out: string[] = ['Order:'];
|
|
125
|
+
for (const [i, step] of CUTOVER_ORDER.entries()) out.push(` ${i + 1}. ${step}`);
|
|
126
|
+
out.push('', 'Databases:');
|
|
127
|
+
for (const db of plan.databases) {
|
|
128
|
+
const created = receipt?.items[`database:${db.ref}`];
|
|
129
|
+
const data = receipt?.items[`data:${db.ref}`];
|
|
130
|
+
if (!created || created.state !== 'done') {
|
|
131
|
+
out.push(` – ${db.name}: not created yet (run import apply first)`);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (data?.state === 'done') {
|
|
135
|
+
out.push(` ✓ ${db.name}: imported (#${data.importId}, sha256 ${data.sha256?.slice(0, 12)}…)`);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
// A Heroku add-on without the app name has no command (and no container to docker exec into).
|
|
139
|
+
const cmd = plan.source.kind === 'heroku' && db.runtime?.kind !== 'heroku-app' ? null : dumpCommand(db);
|
|
140
|
+
if (!cmd && plan.source.kind === 'heroku') {
|
|
141
|
+
out.push(` – ${db.name}: ${db.engine === 'postgres' ? 'pass --app <heroku app> to import plan for the pg:backups command, or download a backup from Heroku' : 'Heroku has no export command for it; move its keys by hand'}`);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (!cmd) {
|
|
145
|
+
out.push(` – ${db.name}: the panel has no dump import for ${db.engine ?? 'this engine'}; move its data by hand`);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
out.push(` + ${db.name} (${db.engine}, NineDeploy database #${created.id}):`);
|
|
149
|
+
out.push(` ${cmd.command}`);
|
|
150
|
+
for (const n of cmd.notes) out.push(` ${n}`);
|
|
151
|
+
out.push(` then: --dump ${flagArg(db.name, cmd.file)}`);
|
|
152
|
+
}
|
|
153
|
+
if (plan.source.kind === 'heroku') out.push('', 'The Heroku commands follow the Heroku CLI documentation and have not been run against Heroku.');
|
|
154
|
+
else if (!SOURCE_CHECKED_LIVE[plan.source.kind]) out.push('', `The ${plan.source.kind} commands follow its source code and have not been run against a live instance yet.`);
|
|
155
|
+
out.push(...renderVolumePreview(plan, receipt));
|
|
156
|
+
return out.join('\n');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface DataDeps {
|
|
160
|
+
client: DataClient;
|
|
161
|
+
receipt: ImportReceipt;
|
|
162
|
+
save: (receipt: ImportReceipt) => void;
|
|
163
|
+
/** sha256 and size of a local file. */
|
|
164
|
+
hashFile: (file: string) => Promise<{ sha256: string; bytes: number }>;
|
|
165
|
+
/** Stream of the file from a byte offset (for a resumed upload). */
|
|
166
|
+
open: (file: string) => (offset: number) => AsyncIterable<Uint8Array>;
|
|
167
|
+
now?: () => Date;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const FINISHED_OK: ReadonlyArray<DatabaseImport['status']> = ['completed', 'completed_with_warnings'];
|
|
171
|
+
|
|
172
|
+
/** Import each named dump, recording every step in the receipt. */
|
|
173
|
+
export async function importData(plan: ImportPlan, dumps: Record<string, string>, deps: DataDeps): Promise<ImportReceipt> {
|
|
174
|
+
const now = deps.now ?? (() => new Date());
|
|
175
|
+
const { receipt } = deps;
|
|
176
|
+
if (receipt.source.kind !== plan.source.kind || receipt.source.url !== plan.source.url) {
|
|
177
|
+
throw new Error(`The receipt is for a plan of ${receipt.source.kind} at ${receipt.source.url}, not ${plan.source.kind} at ${plan.source.url}. Pass another --receipt.`);
|
|
178
|
+
}
|
|
179
|
+
// Check every dump before uploading any.
|
|
180
|
+
const jobs: Array<{ db: PlanDatabase; databaseId: number; file: string; sha256: string; bytes: number }> = [];
|
|
181
|
+
for (const [name, file] of Object.entries(dumps)) {
|
|
182
|
+
const db = plan.databases.find((d) => d.name === name);
|
|
183
|
+
if (!db) throw new Error(`--dump names no database in the plan: ${name}.`);
|
|
184
|
+
const created = receipt.items[`database:${db.ref}`];
|
|
185
|
+
if (created?.state !== 'done' || created.id === undefined) throw new Error(`${name} has not been created yet: run \`ninedeploy import apply\` first.`);
|
|
186
|
+
if (!db.engine || !IMPORTABLE[db.engine]) throw new Error(`The panel has no dump import for ${name} (${db.engine ?? 'no managed engine'}).`);
|
|
187
|
+
const { sha256, bytes } = await deps.hashFile(file);
|
|
188
|
+
if (bytes === 0) throw new Error(`${file} is empty.`);
|
|
189
|
+
const prior = receipt.items[`data:${db.ref}`];
|
|
190
|
+
if (prior?.state === 'done' && prior.sha256 !== sha256) {
|
|
191
|
+
throw new Error(`${name} already has a dump imported (sha256 ${prior.sha256?.slice(0, 12)}…), and this one differs. import data does not import twice; use \`ninedeploy databases import ${created.id} --file …\` if you mean to.`);
|
|
192
|
+
}
|
|
193
|
+
if ((prior?.state === 'pending' || prior?.state === 'failed') && prior.importId !== undefined && prior.sha256 !== sha256) {
|
|
194
|
+
throw new Error(`${name} has import #${prior.importId} recorded for a different dump. Check it with \`ninedeploy databases imports ${created.id}\`; use \`ninedeploy databases import ${created.id} --file …\` to deliberately import another dump.`);
|
|
195
|
+
}
|
|
196
|
+
jobs.push({ db, databaseId: created.id, file, sha256, bytes });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
for (const job of jobs) {
|
|
200
|
+
const key = `data:${job.db.ref}`;
|
|
201
|
+
const prior = receipt.items[key];
|
|
202
|
+
if (prior?.state === 'done') continue;
|
|
203
|
+
const base = { kind: 'data' as const, ref: job.db.ref, name: job.db.name, id: job.databaseId, sha256: job.sha256, bytes: job.bytes };
|
|
204
|
+
const put = (state: 'pending' | 'done' | 'failed', notes: string[], importId?: number) => {
|
|
205
|
+
receipt.items[key] = { ...base, state, notes, ...(importId !== undefined ? { importId } : {}), at: now().toISOString() };
|
|
206
|
+
deps.save(receipt);
|
|
207
|
+
};
|
|
208
|
+
// An upload of this same file that stopped part-way continues where the panel stands.
|
|
209
|
+
let resumeImportId = (prior?.state === 'pending' || prior?.state === 'failed') && prior.sha256 === job.sha256 ? prior.importId : undefined;
|
|
210
|
+
put('pending', [], resumeImportId);
|
|
211
|
+
const options: DatabaseImportOptions = ['redis', 'keydb', 'dragonfly'].includes(job.db.engine!) ? { confirmReplace: true } : {};
|
|
212
|
+
let row: DatabaseImport;
|
|
213
|
+
try {
|
|
214
|
+
const existing = resumeImportId === undefined ? undefined : await deps.client.databases.imports.get(job.databaseId, resumeImportId);
|
|
215
|
+
if (existing && FINISHED_OK.includes(existing.status)) {
|
|
216
|
+
row = existing;
|
|
217
|
+
} else if (existing?.status === 'running') {
|
|
218
|
+
row = await deps.client.databases.imports.wait(job.databaseId, existing.id);
|
|
219
|
+
} else {
|
|
220
|
+
if (existing && ['failed', 'cancelled', 'expired'].includes(existing.status)) {
|
|
221
|
+
resumeImportId = undefined;
|
|
222
|
+
put('pending', []);
|
|
223
|
+
}
|
|
224
|
+
row = await deps.client.databases.importFile(job.databaseId, deps.open(job.file), {
|
|
225
|
+
sizeBytes: job.bytes,
|
|
226
|
+
sha256: job.sha256,
|
|
227
|
+
filename: path.basename(job.file),
|
|
228
|
+
...(resumeImportId !== undefined ? { resumeImportId } : { options }),
|
|
229
|
+
onCreated: (r) => put('pending', [], r.id),
|
|
230
|
+
});
|
|
231
|
+
row = await deps.client.databases.imports.wait(job.databaseId, row.id);
|
|
232
|
+
}
|
|
233
|
+
} catch (err) {
|
|
234
|
+
put('failed', [err instanceof Error ? err.message : String(err)], receipt.items[key]?.importId);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
// The panel's own message can quote the dump, so the receipt keeps the status only.
|
|
238
|
+
if (FINISHED_OK.includes(row.status)) {
|
|
239
|
+
put('done', [`Imported${row.status === 'completed_with_warnings' ? ' with warnings' : ''}: ${job.bytes} bytes, sha256 checked by the panel.`], row.id);
|
|
240
|
+
} else {
|
|
241
|
+
put('failed', [`Import #${row.id} ended ${row.status}. Details: ninedeploy databases imports ${job.databaseId}`], row.id);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return receipt;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function hashFile(file: string): Promise<{ sha256: string; bytes: number }> {
|
|
248
|
+
const st = statSync(file);
|
|
249
|
+
if (!st.isFile()) throw new Error(`${file} is not a regular file.`);
|
|
250
|
+
const hash = createHash('sha256');
|
|
251
|
+
for await (const chunk of createReadStream(file)) hash.update(chunk as Buffer);
|
|
252
|
+
return { sha256: hash.digest('hex'), bytes: st.size };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export interface ImportDataOpts {
|
|
256
|
+
receipt?: string;
|
|
257
|
+
dump?: string[];
|
|
258
|
+
volume?: string[];
|
|
259
|
+
yes?: boolean;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export async function importDataAction(planPath: string, opts: ImportDataOpts, deps: { client?: DataClient; panel?: string } = {}): Promise<void> {
|
|
263
|
+
try {
|
|
264
|
+
const plan = validatePlan(JSON.parse(readFileSync(planPath, 'utf8')));
|
|
265
|
+
const receiptPath = opts.receipt ?? `${planPath.replace(/\.json$/i, '')}.receipt.json`;
|
|
266
|
+
if (!existsSync(receiptPath)) throw new Error(`No receipt at ${receiptPath}: run \`ninedeploy import apply ${planPath} --yes\` first.`);
|
|
267
|
+
const receipt = JSON.parse(readFileSync(receiptPath, 'utf8')) as ImportReceipt;
|
|
268
|
+
const panel = (deps.panel ?? loadConfig().baseUrl).replace(/\/+$/, '');
|
|
269
|
+
if (receipt.panel !== panel) throw new Error(`The receipt is for ${receipt.panel}, but the CLI is signed in to ${panel}.`);
|
|
270
|
+
const pairs = (flag: string, what: string, specs: string[] = []) => {
|
|
271
|
+
const out: Record<string, string> = {};
|
|
272
|
+
for (const spec of specs) {
|
|
273
|
+
const eq = spec.indexOf('=');
|
|
274
|
+
if (eq <= 0 || eq === spec.length - 1) throw new Error(`--${flag} takes <${what}>=<file>, got "${spec}".`);
|
|
275
|
+
out[spec.slice(0, eq)] = spec.slice(eq + 1);
|
|
276
|
+
}
|
|
277
|
+
return out;
|
|
278
|
+
};
|
|
279
|
+
const dumps = pairs('dump', 'database', opts.dump);
|
|
280
|
+
const archives = pairs('volume', 'service', opts.volume);
|
|
281
|
+
header(`Data into ${panel}`);
|
|
282
|
+
if (!opts.yes || Object.keys(dumps).length + Object.keys(archives).length === 0) {
|
|
283
|
+
console.log(renderDataPreview(plan, receipt));
|
|
284
|
+
info(`${c.dim('Nothing was imported.')} Pass --dump <database>=<file> or --volume <service>=<file> with --yes to import.`);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
const client = deps.client ?? getClient();
|
|
288
|
+
const common = {
|
|
289
|
+
client, receipt, save: (r: ImportReceipt) => writeReceipt(receiptPath, r), hashFile,
|
|
290
|
+
open: (file: string) => (offset: number) => createReadStream(file, { start: offset }),
|
|
291
|
+
};
|
|
292
|
+
await importData(plan, dumps, common);
|
|
293
|
+
await importVolumes(plan, archives, common);
|
|
294
|
+
const items = Object.values(receipt.items).filter((i) => i.kind === 'data' || i.kind === 'volume');
|
|
295
|
+
console.log(renderReceipt({ ...receipt, items: Object.fromEntries(items.map((i) => [`${i.kind}:${i.ref}`, i])) }));
|
|
296
|
+
const failed = items.filter((i) => i.state === 'failed').length;
|
|
297
|
+
if (failed) error(`${failed} import(s) failed. Fix the cause and run the same command again. Receipt: ${receiptPath}`);
|
|
298
|
+
else success(`Done. Receipt: ${receiptPath}. Next: deploy and check each service, switch DNS, then run import cutover.`);
|
|
299
|
+
} catch (err) {
|
|
300
|
+
error(err instanceof Error ? err.message : String(err));
|
|
301
|
+
}
|
|
302
|
+
}
|