@neetru/cli 2.28.7 → 2.28.8
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/CHANGELOG.md +44 -0
- package/README.md +23 -0
- package/dist/commands/admin/oidc-login.js +6 -3
- package/dist/commands/admin/oidc-login.js.map +1 -1
- package/dist/commands/admin/sdk-credential.js +11 -6
- package/dist/commands/admin/sdk-credential.js.map +1 -1
- package/dist/commands/agent-release.js +14 -27
- package/dist/commands/agent-release.js.map +1 -1
- package/dist/commands/ai-models.js +9 -4
- package/dist/commands/ai-models.js.map +1 -1
- package/dist/commands/archive.js +9 -2
- package/dist/commands/archive.js.map +1 -1
- package/dist/commands/auth.js +17 -2
- package/dist/commands/auth.js.map +1 -1
- package/dist/commands/autocomplete.d.ts +1 -1
- package/dist/commands/autocomplete.js +2 -0
- package/dist/commands/autocomplete.js.map +1 -1
- package/dist/commands/chat-menu.js +20 -6
- package/dist/commands/chat-menu.js.map +1 -1
- package/dist/commands/chat.d.ts +14 -0
- package/dist/commands/chat.js +32 -4
- package/dist/commands/chat.js.map +1 -1
- package/dist/commands/config.js +7 -0
- package/dist/commands/config.js.map +1 -1
- package/dist/commands/db-move.d.ts +204 -0
- package/dist/commands/db-move.js +890 -0
- package/dist/commands/db-move.js.map +1 -0
- package/dist/commands/db.d.ts +14 -3
- package/dist/commands/db.js +102 -67
- package/dist/commands/db.js.map +1 -1
- package/dist/commands/deploy.js +13 -1
- package/dist/commands/deploy.js.map +1 -1
- package/dist/commands/dev-up.d.ts +5 -0
- package/dist/commands/dev-up.js +29 -4
- package/dist/commands/dev-up.js.map +1 -1
- package/dist/commands/domains.js +48 -36
- package/dist/commands/domains.js.map +1 -1
- package/dist/commands/init.d.ts +0 -8
- package/dist/commands/init.js +14 -3
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/logs.js +18 -21
- package/dist/commands/logs.js.map +1 -1
- package/dist/commands/mobile.d.ts +26 -0
- package/dist/commands/mobile.js +344 -0
- package/dist/commands/mobile.js.map +1 -0
- package/dist/commands/ops.d.ts +3 -0
- package/dist/commands/ops.js +21 -0
- package/dist/commands/ops.js.map +1 -1
- package/dist/commands/promote.js +13 -32
- package/dist/commands/promote.js.map +1 -1
- package/dist/commands/schema.d.ts +4 -0
- package/dist/commands/schema.js +25 -16
- package/dist/commands/schema.js.map +1 -1
- package/dist/commands/smoke.d.ts +123 -0
- package/dist/commands/smoke.js +529 -0
- package/dist/commands/smoke.js.map +1 -0
- package/dist/commands/vm-memory.d.ts +53 -0
- package/dist/commands/vm-memory.js +307 -0
- package/dist/commands/vm-memory.js.map +1 -0
- package/dist/commands/webhooks.d.ts +2 -0
- package/dist/commands/webhooks.js +7 -0
- package/dist/commands/webhooks.js.map +1 -1
- package/dist/index.js +351 -15
- package/dist/index.js.map +1 -1
- package/dist/lib/mobile-local/index.d.ts +7 -0
- package/dist/lib/mobile-local/index.js +7 -0
- package/dist/lib/mobile-local/index.js.map +1 -0
- package/dist/lib/mobile-local/mobile-json.d.ts +57 -0
- package/dist/lib/mobile-local/mobile-json.js +169 -0
- package/dist/lib/mobile-local/mobile-json.js.map +1 -0
- package/dist/lib/product-config.d.ts +5 -0
- package/dist/lib/product-config.js +37 -0
- package/dist/lib/product-config.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,890 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `neetru db move` — relocação ATÔMICA de um banco de produto entre VMs.
|
|
3
|
+
*
|
|
4
|
+
* Fase 2 do PR #423 (design em docs/_review/AUDITORIA_DESIGN_MIGRACAO_ENTRE_VMS).
|
|
5
|
+
* Este arquivo é a superfície CLI do relocate: dá o kickoff da máquina de estados
|
|
6
|
+
* no Core (Server Action `relocateProductDatabase`) e ACOMPANHA o progresso,
|
|
7
|
+
* reportando SUCESSO só quando o Core confirma o cutover VERIFICADO (state `done`).
|
|
8
|
+
*
|
|
9
|
+
* INVARIANTE que o CLI honra (espelha as invariantes do Core):
|
|
10
|
+
* - Nunca declara "movido" antes de o Core reportar `done`. Estados de rollback
|
|
11
|
+
* (`rolled_back`/`failed`) são reportados EXPLICITAMENTE como "o produto
|
|
12
|
+
* seguiu na origem" (fail-closed pro estado atual) e saem com exit != 0.
|
|
13
|
+
* - `move` é destrutivo sobre banco de produção → confirmação interativa
|
|
14
|
+
* (pulável com --yes) + step-up MFA (resolveStepUp: sessão 1h → --mfa-token
|
|
15
|
+
* → prompt). `--dry-run` roda só o preflight (sem MFA, sem confirmação).
|
|
16
|
+
*
|
|
17
|
+
* SEGURANÇA por construção (iteração 2 — abort/decommission NÃO podem apagar dado
|
|
18
|
+
* vivo, e o CLI NÃO pode MENTIR sobre isso):
|
|
19
|
+
* - `abort` agora exige MFA, faz GET do estado ANTES (mensagem pré vs pós-cutover)
|
|
20
|
+
* e ACOMPANHA o rollback até um estado terminal. Só afirma "nada perdido"
|
|
21
|
+
* quando o Core confirma o terminal + o ledger (`live.serverId`) aponta pra um
|
|
22
|
+
* servidor com os dados. `needs_attention` é HALT fail-closed (nada destruído,
|
|
23
|
+
* mas NÃO é all-clear). A iteração 1 imprimia o all-clear INCONDICIONALMENTE
|
|
24
|
+
* logo após o POST — a mentira que este arquivo corta.
|
|
25
|
+
* - `decommission` é o ÚNICO caminho que dropa a ORIGEM (comando SEPARADO, MFA),
|
|
26
|
+
* recusado client-side se o ledger ainda não aponta pro destino — espelho da
|
|
27
|
+
* INV-DESTROY do Core (nunca dropar quem o ledger diz ser o vivo).
|
|
28
|
+
*
|
|
29
|
+
* Endpoints (contrato com o Core — construídos pelo componente irmão):
|
|
30
|
+
* POST /api/cli/v1/db/{id}/relocate — kickoff (MFA)
|
|
31
|
+
* GET /api/cli/v1/db/relocate/{migrationId} — status (poll)
|
|
32
|
+
* POST /api/cli/v1/db/relocate/{migrationId}/confirm — cutover manual (MFA)
|
|
33
|
+
* POST /api/cli/v1/db/relocate/{migrationId}/abort — rollback seguro (MFA)
|
|
34
|
+
* POST /api/cli/v1/db/relocate/{migrationId}/decommission — drop da origem (MFA)
|
|
35
|
+
*
|
|
36
|
+
* Plain PT-BR em toda string visível ao usuário.
|
|
37
|
+
*/
|
|
38
|
+
import chalk from 'chalk';
|
|
39
|
+
import ora from 'ora';
|
|
40
|
+
import { apiRequest, CliNetworkError } from '../lib/api-client.js';
|
|
41
|
+
import { handleApiError } from '../lib/api-error.js';
|
|
42
|
+
import { requireToken } from '../lib/cli-read.js';
|
|
43
|
+
import { confirmDestructive, resolveStepUp } from '../lib/cli-write.js';
|
|
44
|
+
import { renderTable } from '../lib/render.js';
|
|
45
|
+
import { log } from '../utils/logger.js';
|
|
46
|
+
import { safeExit, setSafeExitCode } from '../utils/safe-exit.js';
|
|
47
|
+
/** Ordem "pra frente" do caminho feliz — usada só pra exibir progresso. */
|
|
48
|
+
export const FORWARD_STATES = [
|
|
49
|
+
'planning',
|
|
50
|
+
'provisioning_target',
|
|
51
|
+
'replicating',
|
|
52
|
+
'verifying_target',
|
|
53
|
+
'quiescing_source',
|
|
54
|
+
'final_sync',
|
|
55
|
+
'cutover',
|
|
56
|
+
'observing',
|
|
57
|
+
'draining_source',
|
|
58
|
+
'done',
|
|
59
|
+
];
|
|
60
|
+
/** Estados terminais de SUCESSO (o único ponto onde "movido" pode ser dito). */
|
|
61
|
+
export const TERMINAL_SUCCESS = new Set(['done']);
|
|
62
|
+
/** Estados terminais de FALHA/rollback (produto seguiu/voltou pra origem). */
|
|
63
|
+
export const TERMINAL_FAILURE = new Set([
|
|
64
|
+
'rolled_back',
|
|
65
|
+
'failed',
|
|
66
|
+
]);
|
|
67
|
+
/**
|
|
68
|
+
* Estado terminal de HALT fail-closed (iteração 2). O Core PARA a máquina aqui
|
|
69
|
+
* quando detecta drift de ledger ou um destroy recusado por INV-DESTROY. NÃO é
|
|
70
|
+
* sucesso nem "voltou pra origem" — é "parou e precisa de gente", e o dado NÃO
|
|
71
|
+
* foi destruído (fail-closed). Poll NUNCA pode tratar isto como `running` (senão
|
|
72
|
+
* ficaria pendurado até o timeout).
|
|
73
|
+
*/
|
|
74
|
+
export const TERMINAL_ATTENTION = new Set([
|
|
75
|
+
'needs_attention',
|
|
76
|
+
]);
|
|
77
|
+
/**
|
|
78
|
+
* Estados em que o cutover PODE já ter trocado o ledger pro destino (o "vivo"
|
|
79
|
+
* pode estar no destino). `cutover` entra aqui por ser AMBÍGUO — durante ele a
|
|
80
|
+
* troca do ledger pode ou não ter acontecido; tratamos como pós por segurança
|
|
81
|
+
* (mensagem cuidadosa, nunca afirmar "ledger intocado"). Usado pelo `abort`
|
|
82
|
+
* pra escolher a mensagem correta (pré vs pós-cutover).
|
|
83
|
+
*/
|
|
84
|
+
export const POST_CUTOVER_STATES = new Set([
|
|
85
|
+
'cutover',
|
|
86
|
+
'observing',
|
|
87
|
+
'draining_source',
|
|
88
|
+
'done',
|
|
89
|
+
'rolling_back_post',
|
|
90
|
+
]);
|
|
91
|
+
/** True se o estado já pode ter o ledger apontando pro destino (pós-cutover). */
|
|
92
|
+
export function isPostCutoverState(s) {
|
|
93
|
+
return POST_CUTOVER_STATES.has(s);
|
|
94
|
+
}
|
|
95
|
+
/** Rótulos PT-BR de cada estado da máquina (progresso ostensivo). */
|
|
96
|
+
export const STATE_LABELS = {
|
|
97
|
+
planning: 'planejando (preflight)',
|
|
98
|
+
provisioning_target: 'provisionando destino',
|
|
99
|
+
replicating: 'replicando dados (bulk)',
|
|
100
|
+
verifying_target: 'verificando destino (prova de dados + health-gate)',
|
|
101
|
+
quiescing_source: 'congelando origem (quiesce)',
|
|
102
|
+
final_sync: 'sincronizando delta final',
|
|
103
|
+
cutover: 'cutover (flip do ledger)',
|
|
104
|
+
observing: 'observando destino pós-cutover',
|
|
105
|
+
draining_source: 'drenando origem',
|
|
106
|
+
done: 'concluído — cutover verificado',
|
|
107
|
+
resume_source: 'retomando origem',
|
|
108
|
+
rolling_back: 'revertendo (rollback)',
|
|
109
|
+
rolling_back_post: 'revertendo pós-cutover (o vivo volta pra origem íntegra)',
|
|
110
|
+
rolled_back: 'revertido — produto seguiu na origem',
|
|
111
|
+
needs_attention: 'PAROU — precisa de atenção (drift de ledger ou destroy recusado; nada destruído)',
|
|
112
|
+
failed: 'falhou',
|
|
113
|
+
};
|
|
114
|
+
/**
|
|
115
|
+
* Classifica o estado de uma migração numa das FASES que o CLI trata.
|
|
116
|
+
* `await_confirm`: manual-cutover pausado em `verifying_target` com o health-gate
|
|
117
|
+
* já verde — o Core está esperando `db move confirm`.
|
|
118
|
+
* `attention`: HALT fail-closed (`needs_attention`) — parou, precisa de gente,
|
|
119
|
+
* nada destruído. É TERMINAL para o poll (não pode ser confundido com `running`).
|
|
120
|
+
*
|
|
121
|
+
* Função pura — não toca rede/relógio. É o coração testável do driver de watch.
|
|
122
|
+
*/
|
|
123
|
+
export function classifyRelocation(m) {
|
|
124
|
+
if (TERMINAL_SUCCESS.has(m.state)) {
|
|
125
|
+
return { phase: 'success', label: STATE_LABELS[m.state] };
|
|
126
|
+
}
|
|
127
|
+
if (TERMINAL_ATTENTION.has(m.state)) {
|
|
128
|
+
return { phase: 'attention', label: STATE_LABELS[m.state] };
|
|
129
|
+
}
|
|
130
|
+
if (TERMINAL_FAILURE.has(m.state)) {
|
|
131
|
+
return { phase: 'failure', label: STATE_LABELS[m.state] };
|
|
132
|
+
}
|
|
133
|
+
if (m.manualCutover === true &&
|
|
134
|
+
m.state === 'verifying_target' &&
|
|
135
|
+
!!m.healthGatePassedAt) {
|
|
136
|
+
return { phase: 'await_confirm', label: STATE_LABELS[m.state] };
|
|
137
|
+
}
|
|
138
|
+
return { phase: 'running', label: STATE_LABELS[m.state] ?? m.state };
|
|
139
|
+
}
|
|
140
|
+
/** Linhas humanas do verifyReport (contagem por tabela origem vs destino). */
|
|
141
|
+
export function formatVerifyReportLines(report) {
|
|
142
|
+
if (!report)
|
|
143
|
+
return [];
|
|
144
|
+
const lines = [];
|
|
145
|
+
for (const t of report.tables ?? []) {
|
|
146
|
+
const mark = t.ok ? chalk.green('✓') : chalk.red('✗');
|
|
147
|
+
lines.push(` ${mark} ${t.name}: origem=${t.srcRows} destino=${t.dstRows}`);
|
|
148
|
+
}
|
|
149
|
+
if (typeof report.checksumOk === 'boolean') {
|
|
150
|
+
lines.push(` checksum: ${report.checksumOk ? chalk.green('ok') : chalk.red('divergente')}`);
|
|
151
|
+
}
|
|
152
|
+
return lines;
|
|
153
|
+
}
|
|
154
|
+
/** Valida o teto duro de quiesce (guardrail INV-1). Retorna erro ou null. */
|
|
155
|
+
export function validateMaxQuiesceMs(raw) {
|
|
156
|
+
const DEFAULT = 15_000;
|
|
157
|
+
const CAP = 60_000;
|
|
158
|
+
if (raw === undefined || raw === null || raw === '')
|
|
159
|
+
return { value: DEFAULT };
|
|
160
|
+
const n = typeof raw === 'number' ? raw : Number(raw);
|
|
161
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
162
|
+
return { value: DEFAULT, error: '--max-quiesce-ms deve ser um número positivo' };
|
|
163
|
+
}
|
|
164
|
+
if (n > CAP) {
|
|
165
|
+
return {
|
|
166
|
+
value: CAP,
|
|
167
|
+
error: `--max-quiesce-ms acima do teto de segurança (${CAP}ms); recuse ou reduza`,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
return { value: Math.floor(n) };
|
|
171
|
+
}
|
|
172
|
+
const VALID_STRATEGIES = ['snapshot_quiesce', 'logical_stream'];
|
|
173
|
+
function colorState(s) {
|
|
174
|
+
if (TERMINAL_SUCCESS.has(s))
|
|
175
|
+
return chalk.green(s);
|
|
176
|
+
if (TERMINAL_ATTENTION.has(s))
|
|
177
|
+
return chalk.yellow(s);
|
|
178
|
+
if (TERMINAL_FAILURE.has(s))
|
|
179
|
+
return chalk.red(s);
|
|
180
|
+
if (s === 'cutover')
|
|
181
|
+
return chalk.magenta(s);
|
|
182
|
+
return chalk.cyan(s);
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Classifica o desfecho TERMINAL de um abort a partir do doc de migração.
|
|
186
|
+
*
|
|
187
|
+
* REGRA DE SEGURANÇA (iteração 2): o CLI só pode afirmar "nenhum dado perdido"
|
|
188
|
+
* quando o Core CONFIRMA um estado terminal + o ledger aponta pra um servidor
|
|
189
|
+
* que tem os dados. Nunca infere segurança do `state` sozinho, nunca antes do
|
|
190
|
+
* terminal. Um abort que para em `needs_attention` NÃO é "tudo certo" — é HALT
|
|
191
|
+
* (nada destruído, mas precisa de gente); afirmar all-clear ali seria mentir.
|
|
192
|
+
*
|
|
193
|
+
* Função pura — é o núcleo testável do relatório de abort.
|
|
194
|
+
*/
|
|
195
|
+
export function classifyAbortTerminal(m) {
|
|
196
|
+
const liveServerId = m.live?.serverId ?? undefined;
|
|
197
|
+
if (m.state === 'rolled_back') {
|
|
198
|
+
// Ledger de volta na origem (íntegra). Único estado onde o all-clear é honesto.
|
|
199
|
+
return { outcome: 'reverted_safe', dataSafe: true, liveServerId };
|
|
200
|
+
}
|
|
201
|
+
if (m.state === 'needs_attention') {
|
|
202
|
+
// HALT fail-closed: nada foi destruído, mas NÃO afirmamos "seguiu/voltou".
|
|
203
|
+
return { outcome: 'needs_attention', dataSafe: false, liveServerId };
|
|
204
|
+
}
|
|
205
|
+
if (m.state === 'failed') {
|
|
206
|
+
return { outcome: 'failed', dataSafe: false, liveServerId };
|
|
207
|
+
}
|
|
208
|
+
// Ainda em trânsito (rolling_back / rolling_back_post / etc.) — sem afirmação.
|
|
209
|
+
return { outcome: 'pending', dataSafe: false, liveServerId };
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Resolve o banco alvo a partir de `--db <id>` (explícito) OU `--product <slug>`
|
|
213
|
+
* (+ opcional `--env`). Fail-closed em ambiguidade — nunca "escolhe na dúvida".
|
|
214
|
+
*/
|
|
215
|
+
async function resolveTargetDb(opts) {
|
|
216
|
+
if (opts.db) {
|
|
217
|
+
// Explícito: busca o detalhe só pra exibir a origem no plano.
|
|
218
|
+
try {
|
|
219
|
+
const res = await apiRequest(`/api/cli/v1/db/${encodeURIComponent(opts.db)}/get`);
|
|
220
|
+
return { dbId: res.database.id, source: res.database };
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
// Se o /get falhar (rede/permissão), segue com o dbId cru — o Core valida.
|
|
224
|
+
return { dbId: opts.db };
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (!opts.product) {
|
|
228
|
+
if (opts.json) {
|
|
229
|
+
console.log(JSON.stringify({ ok: false, error: 'input', message: 'informe --product <slug> ou --db <dbId>' }));
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
log.error('Informe --product <slug> ou --db <dbId>.');
|
|
233
|
+
}
|
|
234
|
+
safeExit(1);
|
|
235
|
+
}
|
|
236
|
+
const qs = `?productId=${encodeURIComponent(opts.product)}`;
|
|
237
|
+
let list;
|
|
238
|
+
try {
|
|
239
|
+
list = await apiRequest(`/api/cli/v1/db${qs}`);
|
|
240
|
+
}
|
|
241
|
+
catch (err) {
|
|
242
|
+
handleApiError(err, opts.json);
|
|
243
|
+
}
|
|
244
|
+
let dbs = (list.databases ?? []).filter((d) => d.status !== 'archived');
|
|
245
|
+
if (opts.env) {
|
|
246
|
+
dbs = dbs.filter((d) => (d.environment ?? d.env) === opts.env);
|
|
247
|
+
}
|
|
248
|
+
if (dbs.length === 0) {
|
|
249
|
+
const msg = `Nenhum banco ativo encontrado para o produto "${opts.product}"${opts.env ? ` no ambiente "${opts.env}"` : ''}.`;
|
|
250
|
+
if (opts.json)
|
|
251
|
+
console.log(JSON.stringify({ ok: false, error: 'not_found', message: msg }));
|
|
252
|
+
else
|
|
253
|
+
log.error(msg);
|
|
254
|
+
safeExit(1);
|
|
255
|
+
}
|
|
256
|
+
if (dbs.length > 1) {
|
|
257
|
+
const msg = `O produto "${opts.product}" tem ${dbs.length} bancos ativos — ` +
|
|
258
|
+
'desambigue com --db <dbId> ou --env <ambiente>.';
|
|
259
|
+
if (opts.json) {
|
|
260
|
+
console.log(JSON.stringify({ ok: false, error: 'ambiguous', message: msg, databases: dbs }));
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
log.error(msg);
|
|
264
|
+
for (const d of dbs) {
|
|
265
|
+
log.dim(` · ${d.id} env=${d.environment ?? d.env ?? '—'} engine=${d.engine ?? '—'} vm=${d.serverId ?? '—'}`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
safeExit(1);
|
|
269
|
+
}
|
|
270
|
+
return { dbId: dbs[0].id, source: dbs[0] };
|
|
271
|
+
}
|
|
272
|
+
export async function runDbMoveStart(opts) {
|
|
273
|
+
if (!opts.json)
|
|
274
|
+
log.banner();
|
|
275
|
+
// ── Validação de entrada (fail-closed cedo, antes de tocar o Core) ──────────
|
|
276
|
+
if (!opts.to) {
|
|
277
|
+
if (opts.json)
|
|
278
|
+
console.log(JSON.stringify({ ok: false, error: 'input', message: '--to <serverId> é obrigatório' }));
|
|
279
|
+
else
|
|
280
|
+
log.error('--to <serverId> (VM destino) é obrigatório.');
|
|
281
|
+
safeExit(1);
|
|
282
|
+
}
|
|
283
|
+
const strategy = (opts.strategy ?? 'snapshot_quiesce');
|
|
284
|
+
if (!VALID_STRATEGIES.includes(strategy)) {
|
|
285
|
+
const msg = `--strategy inválida: "${opts.strategy}" (use ${VALID_STRATEGIES.join(' | ')})`;
|
|
286
|
+
if (opts.json)
|
|
287
|
+
console.log(JSON.stringify({ ok: false, error: 'input', message: msg }));
|
|
288
|
+
else
|
|
289
|
+
log.error(msg);
|
|
290
|
+
safeExit(1);
|
|
291
|
+
}
|
|
292
|
+
const quiesce = validateMaxQuiesceMs(opts.maxQuiesceMs);
|
|
293
|
+
if (quiesce.error) {
|
|
294
|
+
if (opts.json)
|
|
295
|
+
console.log(JSON.stringify({ ok: false, error: 'input', message: quiesce.error }));
|
|
296
|
+
else
|
|
297
|
+
log.error(quiesce.error);
|
|
298
|
+
safeExit(1);
|
|
299
|
+
}
|
|
300
|
+
if (!opts.dryRun && !opts.reason) {
|
|
301
|
+
const msg = '--reason "<texto>" é obrigatório (vai pro audit).';
|
|
302
|
+
if (opts.json)
|
|
303
|
+
console.log(JSON.stringify({ ok: false, error: 'input', message: msg }));
|
|
304
|
+
else
|
|
305
|
+
log.error(msg);
|
|
306
|
+
safeExit(1);
|
|
307
|
+
}
|
|
308
|
+
await requireToken(opts.json);
|
|
309
|
+
// ── Resolve o banco alvo ────────────────────────────────────────────────────
|
|
310
|
+
const { dbId, source } = await resolveTargetDb(opts);
|
|
311
|
+
const sourceServerId = source?.serverId ?? undefined;
|
|
312
|
+
// Guardrail cliente: destino == origem não faz sentido (o Core também recusa).
|
|
313
|
+
if (sourceServerId && sourceServerId === opts.to) {
|
|
314
|
+
const msg = `A VM destino (${opts.to}) é a mesma da origem — nada a mover.`;
|
|
315
|
+
if (opts.json)
|
|
316
|
+
console.log(JSON.stringify({ ok: false, error: 'same_server', message: msg }));
|
|
317
|
+
else
|
|
318
|
+
log.error(msg);
|
|
319
|
+
safeExit(1);
|
|
320
|
+
}
|
|
321
|
+
// ── Plano + confirmação interativa (pulável com --yes) ──────────────────────
|
|
322
|
+
if (!opts.json) {
|
|
323
|
+
log.heading(opts.dryRun ? 'Relocação de banco (DRY-RUN)' : 'Relocação de banco');
|
|
324
|
+
log.info(`Banco: ${chalk.cyan(dbId)}${source?.label ? ` (${source.label})` : ''}`);
|
|
325
|
+
log.info(`Origem: ${sourceServerId ? chalk.bold(sourceServerId) : chalk.dim('(o Core resolve)')}`);
|
|
326
|
+
log.info(`Destino: ${chalk.bold(String(opts.to))}`);
|
|
327
|
+
if (source?.engine)
|
|
328
|
+
log.info(`Engine: ${source.engine}`);
|
|
329
|
+
log.info(`Estratégia: ${strategy}`);
|
|
330
|
+
if (opts.manualCutover)
|
|
331
|
+
log.info('Cutover: MANUAL (pausa em verifying_target aguardando confirm)');
|
|
332
|
+
}
|
|
333
|
+
if (!opts.dryRun) {
|
|
334
|
+
await confirmDestructive(`Mover o banco ${dbId} de ${sourceServerId ?? '(origem atual)'} para ${opts.to}? ` +
|
|
335
|
+
'A origem serve read-write até o cutover; há rollback até o descomissionamento manual.', { yes: opts.yes, json: opts.json });
|
|
336
|
+
}
|
|
337
|
+
// ── MFA (step-up). Em dry-run só o header X-Neetru-Dry-Run. ──────────────────
|
|
338
|
+
const { headers } = await resolveStepUp({
|
|
339
|
+
mfaToken: opts.mfaToken,
|
|
340
|
+
json: opts.json,
|
|
341
|
+
dryRun: opts.dryRun,
|
|
342
|
+
yes: opts.yes,
|
|
343
|
+
});
|
|
344
|
+
const body = {
|
|
345
|
+
targetServerId: opts.to,
|
|
346
|
+
reason: opts.reason,
|
|
347
|
+
replicationStrategy: strategy,
|
|
348
|
+
manualCutover: !!opts.manualCutover,
|
|
349
|
+
maxQuiesceMs: quiesce.value,
|
|
350
|
+
dryRun: !!opts.dryRun,
|
|
351
|
+
};
|
|
352
|
+
const spinner = opts.json ? null : ora({ text: 'Iniciando relocação…', color: 'blue' }).start();
|
|
353
|
+
let start;
|
|
354
|
+
try {
|
|
355
|
+
start = await apiRequest(`/api/cli/v1/db/${encodeURIComponent(dbId)}/relocate`, { method: 'POST', body, headers, timeoutMs: 60_000 });
|
|
356
|
+
spinner?.stop();
|
|
357
|
+
}
|
|
358
|
+
catch (err) {
|
|
359
|
+
spinner?.fail('Falha ao iniciar relocação.');
|
|
360
|
+
handleApiError(err, opts.json);
|
|
361
|
+
}
|
|
362
|
+
// ── Dry-run: só o preflight. Nada foi provisionado. ─────────────────────────
|
|
363
|
+
if (opts.dryRun) {
|
|
364
|
+
if (opts.json) {
|
|
365
|
+
console.log(JSON.stringify({ ...start, dryRun: true, ok: start.preflight.ok }));
|
|
366
|
+
if (!start.preflight.ok)
|
|
367
|
+
setSafeExitCode(1);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
log.heading('Preflight');
|
|
371
|
+
for (const c of start.preflight.checks) {
|
|
372
|
+
const mark = c.ok ? chalk.green('✓') : chalk.red('✗');
|
|
373
|
+
log.dim(` ${mark} ${c.name}${c.detail ? ` — ${c.detail}` : ''}`);
|
|
374
|
+
}
|
|
375
|
+
if (start.preflight.ok) {
|
|
376
|
+
log.success('Preflight OK. Nada foi provisionado (dry-run). Rode sem --dry-run para executar.');
|
|
377
|
+
}
|
|
378
|
+
else {
|
|
379
|
+
log.error('Preflight REPROVADO — a relocação seria abortada. Nada foi tocado.');
|
|
380
|
+
setSafeExitCode(1);
|
|
381
|
+
}
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
if (!opts.json) {
|
|
385
|
+
log.success(`Relocação iniciada: ${chalk.cyan(start.migrationId)} (estado: ${colorState(start.state)})`);
|
|
386
|
+
}
|
|
387
|
+
// Sem --watch: só reporta que iniciou (NÃO afirma sucesso — INV do CLI).
|
|
388
|
+
const watch = opts.watch !== false;
|
|
389
|
+
if (!watch) {
|
|
390
|
+
if (opts.json) {
|
|
391
|
+
console.log(JSON.stringify({ ...start, started: true, ok: true }));
|
|
392
|
+
}
|
|
393
|
+
else {
|
|
394
|
+
log.info(`Acompanhe com: ${chalk.cyan(`neetru db move status ${start.migrationId}`)}`);
|
|
395
|
+
}
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
await pollRelocation(start.migrationId, opts);
|
|
399
|
+
}
|
|
400
|
+
// ---------------------------------------------------------------------------
|
|
401
|
+
// Poll da máquina de estados
|
|
402
|
+
// ---------------------------------------------------------------------------
|
|
403
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
404
|
+
/**
|
|
405
|
+
* Acompanha a máquina de estados até um estado terminal (done/rolled_back/failed)
|
|
406
|
+
* ou até a pausa de manual-cutover. Reporta SUCESSO só em `done`.
|
|
407
|
+
* Usado por `runDbMoveStart` (com --watch) e por `runDbMoveConfirm`.
|
|
408
|
+
*/
|
|
409
|
+
export async function pollRelocation(migrationId, opts) {
|
|
410
|
+
const intervalMs = opts.pollIntervalMs ?? 4000;
|
|
411
|
+
const timeoutMs = opts.pollTimeoutMs ?? 30 * 60 * 1000;
|
|
412
|
+
const start = Date.now();
|
|
413
|
+
let lastState = '';
|
|
414
|
+
const spinner = opts.json ? null : ora({ text: 'Acompanhando relocação…', color: 'blue' }).start();
|
|
415
|
+
// eslint-disable-next-line no-constant-condition
|
|
416
|
+
while (true) {
|
|
417
|
+
if (Date.now() - start >= timeoutMs) {
|
|
418
|
+
spinner?.fail('Timeout acompanhando a relocação.');
|
|
419
|
+
if (opts.json)
|
|
420
|
+
console.log(JSON.stringify({ ok: false, error: 'watch_timeout', migrationId }));
|
|
421
|
+
else
|
|
422
|
+
log.warn(`Timeout. Consulte: neetru db move status ${migrationId}`);
|
|
423
|
+
setSafeExitCode(1);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
let resp;
|
|
427
|
+
try {
|
|
428
|
+
resp = await apiRequest(`/api/cli/v1/db/relocate/${encodeURIComponent(migrationId)}`);
|
|
429
|
+
}
|
|
430
|
+
catch (err) {
|
|
431
|
+
// Erro transitório de rede → segue tentando (o Core/relocate é resumível).
|
|
432
|
+
if (err instanceof CliNetworkError) {
|
|
433
|
+
await sleep(Math.min(intervalMs, 3000));
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
spinner?.fail('Falha ao consultar o estado da relocação.');
|
|
437
|
+
handleApiError(err, opts.json);
|
|
438
|
+
}
|
|
439
|
+
const m = resp.migration;
|
|
440
|
+
const { phase, label } = classifyRelocation(m);
|
|
441
|
+
if (m.state !== lastState) {
|
|
442
|
+
lastState = m.state;
|
|
443
|
+
if (spinner)
|
|
444
|
+
spinner.text = `${chalk.bold(m.state)} — ${label}`;
|
|
445
|
+
}
|
|
446
|
+
if (phase === 'success') {
|
|
447
|
+
spinner?.succeed(`Cutover VERIFICADO. Banco ${m.dbId} agora vive em ${chalk.bold(m.targetServerId)}.`);
|
|
448
|
+
if (opts.json) {
|
|
449
|
+
console.log(JSON.stringify({ ok: true, ...m }));
|
|
450
|
+
}
|
|
451
|
+
else {
|
|
452
|
+
for (const line of formatVerifyReportLines(m.verifyReport))
|
|
453
|
+
console.log(line);
|
|
454
|
+
log.success('Relocação concluída com sucesso.');
|
|
455
|
+
log.dim(`A origem (${m.sourceServerId}) NÃO foi destruída — descomissione depois com ` +
|
|
456
|
+
`neetru db move status ${m.id} (janela de segurança de rollback).`);
|
|
457
|
+
}
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
if (phase === 'attention') {
|
|
461
|
+
// HALT fail-closed (iteração 2): drift de ledger ou destroy recusado por
|
|
462
|
+
// INV-DESTROY. NÃO é "voltou pra origem" nem "movido" — é "parou, precisa
|
|
463
|
+
// de gente, e nada foi destruído". Reportar como tal, com exit != 0.
|
|
464
|
+
const code = m.lastError?.code ?? 'RELOCATE_NEEDS_ATTENTION';
|
|
465
|
+
spinner?.fail(`Relocação PAROU e precisa de atenção (${m.state}).`);
|
|
466
|
+
if (opts.json) {
|
|
467
|
+
console.log(JSON.stringify({ ok: false, error: code, needsAttention: true, ...m }));
|
|
468
|
+
}
|
|
469
|
+
else {
|
|
470
|
+
if (m.lastError)
|
|
471
|
+
log.error(`${m.lastError.code}: ${m.lastError.message}`);
|
|
472
|
+
log.warn('A relocação foi PARADA de propósito (fail-closed): o Core detectou ' +
|
|
473
|
+
'drift de ledger ou recusou um destroy que atingiria o banco vivo. ' +
|
|
474
|
+
'NENHUM dado foi destruído. Investigue e resolva manualmente: ' +
|
|
475
|
+
`neetru db move status ${m.id}`);
|
|
476
|
+
}
|
|
477
|
+
setSafeExitCode(1);
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
if (phase === 'failure') {
|
|
481
|
+
const code = m.lastError?.code ?? m.state;
|
|
482
|
+
spinner?.fail(`Relocação NÃO concluída (${m.state}).`);
|
|
483
|
+
if (opts.json) {
|
|
484
|
+
console.log(JSON.stringify({ ok: false, error: code, ...m }));
|
|
485
|
+
}
|
|
486
|
+
else {
|
|
487
|
+
if (m.lastError)
|
|
488
|
+
log.error(`${m.lastError.code}: ${m.lastError.message}`);
|
|
489
|
+
// INV-2 explícito pro operador: o produto seguiu/voltou pra origem.
|
|
490
|
+
log.warn(`O produto NÃO foi movido — seguiu na origem (${m.sourceServerId}). ` +
|
|
491
|
+
'Nenhum dado foi perdido (fail-closed pro estado atual).');
|
|
492
|
+
}
|
|
493
|
+
setSafeExitCode(1);
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
if (phase === 'await_confirm') {
|
|
497
|
+
spinner?.stop();
|
|
498
|
+
if (opts.json) {
|
|
499
|
+
console.log(JSON.stringify({ ok: true, awaitingConfirm: true, ...m }));
|
|
500
|
+
}
|
|
501
|
+
else {
|
|
502
|
+
log.warn('Destino provado saudável. Cutover MANUAL aguardando confirmação.');
|
|
503
|
+
for (const line of formatVerifyReportLines(m.verifyReport))
|
|
504
|
+
console.log(line);
|
|
505
|
+
log.info(`Confirme com: ${chalk.cyan(`neetru db move confirm ${m.id} --mfa <código TOTP>`)}`);
|
|
506
|
+
}
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
await sleep(intervalMs);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
// ---------------------------------------------------------------------------
|
|
513
|
+
// neetru db move status <migrationId>
|
|
514
|
+
// ---------------------------------------------------------------------------
|
|
515
|
+
export async function runDbMoveStatus(migrationId, opts = {}) {
|
|
516
|
+
await requireToken(opts.json);
|
|
517
|
+
let resp;
|
|
518
|
+
try {
|
|
519
|
+
resp = await apiRequest(`/api/cli/v1/db/relocate/${encodeURIComponent(migrationId)}`);
|
|
520
|
+
}
|
|
521
|
+
catch (err) {
|
|
522
|
+
handleApiError(err, opts.json);
|
|
523
|
+
}
|
|
524
|
+
if (opts.json) {
|
|
525
|
+
console.log(JSON.stringify(resp));
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
const m = resp.migration;
|
|
529
|
+
log.heading(`Relocação ${m.id}`);
|
|
530
|
+
log.info(`Estado: ${colorState(m.state)} — ${STATE_LABELS[m.state] ?? m.state}`);
|
|
531
|
+
log.info(`Banco: ${m.dbId}${m.engine ? ` (${m.engine})` : ''}`);
|
|
532
|
+
log.info(`Origem: ${m.sourceServerId}`);
|
|
533
|
+
log.info(`Destino: ${m.targetServerId}`);
|
|
534
|
+
if (m.manualCutover)
|
|
535
|
+
log.dim(' cutover: MANUAL');
|
|
536
|
+
if (m.healthGatePassedAt)
|
|
537
|
+
log.dim(` health-gate OK em: ${m.healthGatePassedAt}`);
|
|
538
|
+
const verifyLines = formatVerifyReportLines(m.verifyReport);
|
|
539
|
+
if (verifyLines.length) {
|
|
540
|
+
log.heading('Verificação de dados (origem vs destino)');
|
|
541
|
+
for (const line of verifyLines)
|
|
542
|
+
console.log(line);
|
|
543
|
+
}
|
|
544
|
+
if (m.live?.serverId)
|
|
545
|
+
log.dim(` vivo agora em (ledger): ${m.live.serverId}`);
|
|
546
|
+
if (m.lastError) {
|
|
547
|
+
log.warn(`Último erro: ${m.lastError.code} — ${m.lastError.message}`);
|
|
548
|
+
}
|
|
549
|
+
if (TERMINAL_ATTENTION.has(m.state)) {
|
|
550
|
+
log.warn('PAROU (fail-closed): drift de ledger ou destroy recusado por INV-DESTROY. ' +
|
|
551
|
+
'NENHUM dado foi destruído — a máquina espera intervenção manual.');
|
|
552
|
+
}
|
|
553
|
+
else if (TERMINAL_FAILURE.has(m.state)) {
|
|
554
|
+
log.warn(`O produto seguiu na origem (${m.sourceServerId}). Nenhum dado perdido.`);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
// ---------------------------------------------------------------------------
|
|
558
|
+
// neetru db move confirm <migrationId> (cutover manual — MFA)
|
|
559
|
+
// ---------------------------------------------------------------------------
|
|
560
|
+
export async function runDbMoveConfirm(migrationId, opts = {}) {
|
|
561
|
+
if (!opts.json)
|
|
562
|
+
log.banner();
|
|
563
|
+
await requireToken(opts.json);
|
|
564
|
+
const { headers } = await resolveStepUp({ mfaToken: opts.mfa, json: opts.json });
|
|
565
|
+
const spinner = opts.json ? null : ora({ text: 'Confirmando cutover…', color: 'blue' }).start();
|
|
566
|
+
let resp;
|
|
567
|
+
try {
|
|
568
|
+
resp = await apiRequest(`/api/cli/v1/db/relocate/${encodeURIComponent(migrationId)}/confirm`, { method: 'POST', body: {}, headers });
|
|
569
|
+
spinner?.stop();
|
|
570
|
+
}
|
|
571
|
+
catch (err) {
|
|
572
|
+
spinner?.fail('Falha ao confirmar o cutover.');
|
|
573
|
+
handleApiError(err, opts.json);
|
|
574
|
+
}
|
|
575
|
+
if (!opts.json)
|
|
576
|
+
log.success(`Cutover liberado (estado: ${colorState(resp.state)}).`);
|
|
577
|
+
const watch = opts.watch !== false;
|
|
578
|
+
if (watch) {
|
|
579
|
+
await pollRelocation(migrationId, opts);
|
|
580
|
+
}
|
|
581
|
+
else if (opts.json) {
|
|
582
|
+
console.log(JSON.stringify({ ...resp, ok: true }));
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
// ---------------------------------------------------------------------------
|
|
586
|
+
// neetru db move abort <migrationId> (rollback do estado atual)
|
|
587
|
+
// ---------------------------------------------------------------------------
|
|
588
|
+
export async function runDbMoveAbort(migrationId, opts = {}) {
|
|
589
|
+
if (!opts.json)
|
|
590
|
+
log.banner();
|
|
591
|
+
if (!opts.reason) {
|
|
592
|
+
const msg = '--reason "<texto>" é obrigatório para abortar (vai pro audit).';
|
|
593
|
+
if (opts.json)
|
|
594
|
+
console.log(JSON.stringify({ ok: false, error: 'input', message: msg }));
|
|
595
|
+
else
|
|
596
|
+
log.error(msg);
|
|
597
|
+
safeExit(1);
|
|
598
|
+
}
|
|
599
|
+
await requireToken(opts.json);
|
|
600
|
+
// ── GET estado ANTES (iteração 2): a mensagem/confirmação DEPENDE de onde a
|
|
601
|
+
// migração está. Pré-cutover o ledger nunca foi tocado; pós-cutover o
|
|
602
|
+
// abort re-aponta o vivo de volta pra ORIGEM íntegra. Nunca confiar no CLI:
|
|
603
|
+
// é o Core+ledger que dizem a posição. GET transitório que falha não bloqueia
|
|
604
|
+
// o abort (o Core valida de novo), mas então NÃO afirmamos posição. ────────
|
|
605
|
+
let before = null;
|
|
606
|
+
try {
|
|
607
|
+
const s = await apiRequest(`/api/cli/v1/db/relocate/${encodeURIComponent(migrationId)}`);
|
|
608
|
+
before = s.migration;
|
|
609
|
+
}
|
|
610
|
+
catch (err) {
|
|
611
|
+
// 404/403 etc. são reais — reporta e sai. Rede transitória segue com before=null
|
|
612
|
+
// (o Core re-valida no POST; só não afirmamos posição pré-abort).
|
|
613
|
+
if (!(err instanceof CliNetworkError))
|
|
614
|
+
handleApiError(err, opts.json);
|
|
615
|
+
}
|
|
616
|
+
const postCutover = before ? isPostCutoverState(before.state) : false;
|
|
617
|
+
const srcLabel = before?.sourceServerId ?? 'a origem';
|
|
618
|
+
const confirmMsg = postCutover
|
|
619
|
+
? `Abortar a relocação ${migrationId}? Ela JÁ passou (ou pode ter passado) do ` +
|
|
620
|
+
`cutover — o rollback re-aponta o ledger de volta pra ORIGEM (${srcLabel}), ` +
|
|
621
|
+
'que segue íntegra. NENHUMA cópia com dado vivo é destruída pelo abort.'
|
|
622
|
+
: `Abortar a relocação ${migrationId}? PRÉ-cutover: o ledger nunca foi tocado; ` +
|
|
623
|
+
'o rollback só remove os recursos-shadow do destino (provados-não-vivos). ' +
|
|
624
|
+
`A origem (${srcLabel}) serve read-write o tempo todo.`;
|
|
625
|
+
await confirmDestructive(confirmMsg, { yes: opts.yes, json: opts.json });
|
|
626
|
+
// ── Step-up MFA (igual start/confirm) — abort dispara ação destrutiva no Core
|
|
627
|
+
// (drop de shadow); exige step-up. Fail-closed em não-interativo sem token. ──
|
|
628
|
+
const { headers } = await resolveStepUp({
|
|
629
|
+
mfaToken: opts.mfaToken,
|
|
630
|
+
json: opts.json,
|
|
631
|
+
yes: opts.yes,
|
|
632
|
+
});
|
|
633
|
+
const spinner = opts.json ? null : ora({ text: 'Abortando relocação…', color: 'blue' }).start();
|
|
634
|
+
let resp;
|
|
635
|
+
try {
|
|
636
|
+
resp = await apiRequest(`/api/cli/v1/db/relocate/${encodeURIComponent(migrationId)}/abort`, { method: 'POST', body: { reason: opts.reason }, headers });
|
|
637
|
+
spinner?.stop();
|
|
638
|
+
}
|
|
639
|
+
catch (err) {
|
|
640
|
+
spinner?.fail('Falha ao abortar a relocação.');
|
|
641
|
+
handleApiError(err, opts.json);
|
|
642
|
+
}
|
|
643
|
+
if (!opts.json) {
|
|
644
|
+
log.success(`Rollback disparado (estado: ${colorState(resp.state)}).`);
|
|
645
|
+
if (postCutover) {
|
|
646
|
+
log.info('Revertendo PÓS-cutover: o ledger volta pra origem íntegra. ' +
|
|
647
|
+
'O antigo destino só é limpo depois, guardado por INV-DESTROY.');
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
// ── Poll até um estado TERMINAL. SÓ afirmamos "nada perdido" quando o Core
|
|
651
|
+
// confirma o terminal + o ledger aponta pra um servidor com os dados. Isto
|
|
652
|
+
// mata a mentira da iteração 1 (all-clear incondicional logo após o POST). ──
|
|
653
|
+
const watch = opts.watch !== false;
|
|
654
|
+
if (!watch) {
|
|
655
|
+
if (opts.json)
|
|
656
|
+
console.log(JSON.stringify({ ...resp, ok: true }));
|
|
657
|
+
else
|
|
658
|
+
log.info(`Acompanhe com: ${chalk.cyan(`neetru db move status ${migrationId}`)}`);
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
await pollAbort(migrationId, opts);
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* Acompanha o rollback disparado por um abort até um estado terminal e reporta
|
|
665
|
+
* a segurança dos dados SÓ com base no ledger (`live.serverId`) + estado terminal
|
|
666
|
+
* confirmado pelo Core. Nunca afirma "nada perdido" antes do terminal, nem em
|
|
667
|
+
* `needs_attention` (HALT). Reusa o mesmo mecanismo de poll do caminho forward.
|
|
668
|
+
*/
|
|
669
|
+
async function pollAbort(migrationId, opts) {
|
|
670
|
+
const intervalMs = opts.pollIntervalMs ?? 4000;
|
|
671
|
+
const timeoutMs = opts.pollTimeoutMs ?? 30 * 60 * 1000;
|
|
672
|
+
const startedAt = Date.now();
|
|
673
|
+
let lastState = '';
|
|
674
|
+
const spinner = opts.json ? null : ora({ text: 'Acompanhando o rollback…', color: 'blue' }).start();
|
|
675
|
+
// eslint-disable-next-line no-constant-condition
|
|
676
|
+
while (true) {
|
|
677
|
+
if (Date.now() - startedAt >= timeoutMs) {
|
|
678
|
+
spinner?.fail('Timeout acompanhando o rollback.');
|
|
679
|
+
if (opts.json)
|
|
680
|
+
console.log(JSON.stringify({ ok: false, error: 'watch_timeout', migrationId }));
|
|
681
|
+
else
|
|
682
|
+
log.warn(`Timeout. Consulte: neetru db move status ${migrationId}`);
|
|
683
|
+
setSafeExitCode(1);
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
let resp;
|
|
687
|
+
try {
|
|
688
|
+
resp = await apiRequest(`/api/cli/v1/db/relocate/${encodeURIComponent(migrationId)}`);
|
|
689
|
+
}
|
|
690
|
+
catch (err) {
|
|
691
|
+
if (err instanceof CliNetworkError) {
|
|
692
|
+
await sleep(Math.min(intervalMs, 3000));
|
|
693
|
+
continue;
|
|
694
|
+
}
|
|
695
|
+
spinner?.fail('Falha ao consultar o estado do rollback.');
|
|
696
|
+
handleApiError(err, opts.json);
|
|
697
|
+
}
|
|
698
|
+
const m = resp.migration;
|
|
699
|
+
if (m.state !== lastState) {
|
|
700
|
+
lastState = m.state;
|
|
701
|
+
if (spinner)
|
|
702
|
+
spinner.text = `${chalk.bold(m.state)} — ${STATE_LABELS[m.state] ?? m.state}`;
|
|
703
|
+
}
|
|
704
|
+
// Terminal? (rolled_back / failed / needs_attention). `done` não deveria
|
|
705
|
+
// acontecer num abort, mas se acontecer não é "seguiu na origem" — trata como
|
|
706
|
+
// não-terminal-de-abort e deixa o loop achar o estado real ou o timeout pegar.
|
|
707
|
+
const terminalForAbort = TERMINAL_FAILURE.has(m.state) || TERMINAL_ATTENTION.has(m.state);
|
|
708
|
+
if (!terminalForAbort) {
|
|
709
|
+
await sleep(intervalMs);
|
|
710
|
+
continue;
|
|
711
|
+
}
|
|
712
|
+
const outcome = classifyAbortTerminal(m);
|
|
713
|
+
if (opts.json) {
|
|
714
|
+
console.log(JSON.stringify({
|
|
715
|
+
ok: outcome.outcome === 'reverted_safe',
|
|
716
|
+
outcome: outcome.outcome,
|
|
717
|
+
dataSafe: outcome.dataSafe,
|
|
718
|
+
liveServerId: outcome.liveServerId,
|
|
719
|
+
...m,
|
|
720
|
+
}));
|
|
721
|
+
if (outcome.outcome !== 'reverted_safe')
|
|
722
|
+
setSafeExitCode(1);
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
if (outcome.outcome === 'reverted_safe') {
|
|
726
|
+
const where = outcome.liveServerId ?? m.sourceServerId;
|
|
727
|
+
spinner?.succeed(`Rollback concluído — o produto vive em ${chalk.bold(where)}.`);
|
|
728
|
+
log.success(`Revertido. O ledger aponta pra origem (${where}), íntegra. ` +
|
|
729
|
+
'Nenhum dado foi perdido.');
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
if (outcome.outcome === 'needs_attention') {
|
|
733
|
+
spinner?.fail('Rollback PAROU e precisa de atenção.');
|
|
734
|
+
if (m.lastError)
|
|
735
|
+
log.error(`${m.lastError.code}: ${m.lastError.message}`);
|
|
736
|
+
log.warn('O abort foi PARADO de propósito (fail-closed): drift de ledger ou destroy ' +
|
|
737
|
+
'recusado por INV-DESTROY. NENHUM dado foi destruído. NÃO está "tudo certo" — ' +
|
|
738
|
+
`resolva manualmente e cheque: neetru db move status ${m.id}`);
|
|
739
|
+
setSafeExitCode(1);
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
// failed
|
|
743
|
+
spinner?.fail(`Rollback NÃO concluído (${m.state}).`);
|
|
744
|
+
if (m.lastError)
|
|
745
|
+
log.error(`${m.lastError.code}: ${m.lastError.message}`);
|
|
746
|
+
log.warn(`O rollback falhou. NÃO afirmo segurança automaticamente — cheque o ledger: ` +
|
|
747
|
+
`neetru db move status ${m.id}.`);
|
|
748
|
+
setSafeExitCode(1);
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
// ---------------------------------------------------------------------------
|
|
753
|
+
// neetru db move decommission <migrationId> (drop da ORIGEM — comando à parte)
|
|
754
|
+
// ---------------------------------------------------------------------------
|
|
755
|
+
/**
|
|
756
|
+
* Descomissiona (dropa) a ORIGEM de uma relocação JÁ concluída. É o ÚNICO caminho
|
|
757
|
+
* que destrói a origem, e por design é SEPARADO, explícito, gated por MFA e —
|
|
758
|
+
* client-side — recusado se o ledger ainda NÃO aponta pro destino (i.e., o move
|
|
759
|
+
* não terminou / foi revertido). Isto é o espelho-CLI da INV-DESTROY: nunca pedir
|
|
760
|
+
* o drop de um servidor que o ledger diz ser o vivo. O Core é a autoridade final
|
|
761
|
+
* (re-checa live==target + grace + INV-DESTROY), mas falhamos cedo e claro.
|
|
762
|
+
*/
|
|
763
|
+
export async function runDbMoveDecommission(migrationId, opts = {}) {
|
|
764
|
+
if (!opts.json)
|
|
765
|
+
log.banner();
|
|
766
|
+
if (!opts.reason) {
|
|
767
|
+
const msg = '--reason "<texto>" é obrigatório para descomissionar (vai pro audit).';
|
|
768
|
+
if (opts.json)
|
|
769
|
+
console.log(JSON.stringify({ ok: false, error: 'input', message: msg }));
|
|
770
|
+
else
|
|
771
|
+
log.error(msg);
|
|
772
|
+
safeExit(1);
|
|
773
|
+
}
|
|
774
|
+
await requireToken(opts.json);
|
|
775
|
+
// ── GET estado ANTES + guarda-cliente INV-DESTROY. Só a origem de um move
|
|
776
|
+
// CONFIRMADO pode ser dropada. Se o ledger ainda aponta pra origem (move
|
|
777
|
+
// não terminou ou foi revertido), RECUSAR — dropar aqui apagaria o vivo. ──
|
|
778
|
+
let m;
|
|
779
|
+
try {
|
|
780
|
+
const s = await apiRequest(`/api/cli/v1/db/relocate/${encodeURIComponent(migrationId)}`);
|
|
781
|
+
m = s.migration;
|
|
782
|
+
}
|
|
783
|
+
catch (err) {
|
|
784
|
+
handleApiError(err, opts.json);
|
|
785
|
+
}
|
|
786
|
+
const liveServerId = m.live?.serverId ?? undefined;
|
|
787
|
+
const movedConfirmed = m.state === 'done' &&
|
|
788
|
+
// Se o Core expõe o ledger, exigir live==target. Se não expõe, o `done`
|
|
789
|
+
// já é a confirmação de cutover verificado (o Core re-checa mesmo assim).
|
|
790
|
+
(liveServerId === undefined || liveServerId === m.targetServerId);
|
|
791
|
+
if (!movedConfirmed) {
|
|
792
|
+
const msg = `RECUSADO: a relocação ${migrationId} não está com o cutover confirmado no ` +
|
|
793
|
+
`destino (estado=${m.state}` +
|
|
794
|
+
(liveServerId ? `, vivo=${liveServerId}` : '') +
|
|
795
|
+
`). Descomissionar a origem (${m.sourceServerId}) agora poderia apagar o ` +
|
|
796
|
+
'banco VIVO. Conclua/relocação ou reverta primeiro.';
|
|
797
|
+
if (opts.json)
|
|
798
|
+
console.log(JSON.stringify({ ok: false, error: 'not_decommissionable', message: msg, state: m.state }));
|
|
799
|
+
else
|
|
800
|
+
log.error(msg);
|
|
801
|
+
safeExit(1);
|
|
802
|
+
}
|
|
803
|
+
await confirmDestructive(`Descomissionar (DROPAR) a ORIGEM ${m.sourceServerId} do banco ${m.dbId}? ` +
|
|
804
|
+
`O vivo já está no destino (${m.targetServerId}). Isto é IRREVERSÍVEL e ` +
|
|
805
|
+
'FECHA a janela de rollback.', { yes: opts.yes, json: opts.json });
|
|
806
|
+
const { headers } = await resolveStepUp({
|
|
807
|
+
mfaToken: opts.mfaToken,
|
|
808
|
+
json: opts.json,
|
|
809
|
+
yes: opts.yes,
|
|
810
|
+
});
|
|
811
|
+
const spinner = opts.json ? null : ora({ text: 'Descomissionando a origem…', color: 'blue' }).start();
|
|
812
|
+
let resp;
|
|
813
|
+
try {
|
|
814
|
+
resp = await apiRequest(`/api/cli/v1/db/relocate/${encodeURIComponent(migrationId)}/decommission`, { method: 'POST', body: { reason: opts.reason }, headers });
|
|
815
|
+
spinner?.stop();
|
|
816
|
+
}
|
|
817
|
+
catch (err) {
|
|
818
|
+
spinner?.fail('Falha ao descomissionar a origem.');
|
|
819
|
+
handleApiError(err, opts.json);
|
|
820
|
+
}
|
|
821
|
+
if (opts.json) {
|
|
822
|
+
console.log(JSON.stringify({ ...resp, ok: true }));
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
log.success(`Origem ${resp.dropped?.serverId ?? m.sourceServerId} descomissionada. ` +
|
|
826
|
+
`A janela de rollback foi fechada; o banco vive só em ${m.targetServerId}.`);
|
|
827
|
+
}
|
|
828
|
+
// ---------------------------------------------------------------------------
|
|
829
|
+
// neetru db where (onde o banco vive) — por --product <slug> ou --db <dbId>
|
|
830
|
+
// ---------------------------------------------------------------------------
|
|
831
|
+
export async function runDbWhere(opts = {}) {
|
|
832
|
+
if (!opts.json)
|
|
833
|
+
log.banner();
|
|
834
|
+
await requireToken(opts.json);
|
|
835
|
+
if (opts.db) {
|
|
836
|
+
let resp;
|
|
837
|
+
try {
|
|
838
|
+
resp = await apiRequest(`/api/cli/v1/db/${encodeURIComponent(opts.db)}/get`);
|
|
839
|
+
}
|
|
840
|
+
catch (err) {
|
|
841
|
+
handleApiError(err, opts.json);
|
|
842
|
+
}
|
|
843
|
+
if (opts.json) {
|
|
844
|
+
console.log(JSON.stringify(resp));
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
const d = resp.database;
|
|
848
|
+
log.heading(`Banco ${d.id}`);
|
|
849
|
+
log.info(`Produto: ${d.productId ?? '—'}`);
|
|
850
|
+
log.info(`Engine: ${d.engine ?? '—'}`);
|
|
851
|
+
log.info(`Ambiente: ${d.environment ?? d.env ?? '—'}`);
|
|
852
|
+
log.info(`Status: ${d.status ?? '—'}`);
|
|
853
|
+
log.info(`VM: ${d.serverId ? chalk.bold(d.serverId) : chalk.dim('(sem VM — engine gerenciado)')}`);
|
|
854
|
+
if (d.endpoint)
|
|
855
|
+
log.info(`Endpoint: ${d.endpoint}`);
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
if (!opts.product) {
|
|
859
|
+
const msg = 'Informe --product <slug> ou --db <dbId>.';
|
|
860
|
+
if (opts.json)
|
|
861
|
+
console.log(JSON.stringify({ ok: false, error: 'input', message: msg }));
|
|
862
|
+
else
|
|
863
|
+
log.error(msg);
|
|
864
|
+
safeExit(1);
|
|
865
|
+
}
|
|
866
|
+
const qs = `?productId=${encodeURIComponent(opts.product)}`;
|
|
867
|
+
let list;
|
|
868
|
+
try {
|
|
869
|
+
list = await apiRequest(`/api/cli/v1/db${qs}`);
|
|
870
|
+
}
|
|
871
|
+
catch (err) {
|
|
872
|
+
handleApiError(err, opts.json);
|
|
873
|
+
}
|
|
874
|
+
let dbs = list.databases ?? [];
|
|
875
|
+
if (opts.env)
|
|
876
|
+
dbs = dbs.filter((d) => (d.environment ?? d.env) === opts.env);
|
|
877
|
+
if (opts.json) {
|
|
878
|
+
console.log(JSON.stringify({ ok: true, count: dbs.length, databases: dbs }));
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
log.heading(`Bancos do produto "${opts.product}" (${dbs.length})`);
|
|
882
|
+
renderTable([
|
|
883
|
+
{ header: 'ID', value: (r) => r.id, maxWidth: 24 },
|
|
884
|
+
{ header: 'ENGINE', value: (r) => r.engine ?? '—', maxWidth: 20 },
|
|
885
|
+
{ header: 'ENV', value: (r) => r.environment ?? r.env ?? '—', maxWidth: 12 },
|
|
886
|
+
{ header: 'STATUS', value: (r) => r.status ?? '—', maxWidth: 14 },
|
|
887
|
+
{ header: 'VM (onde vive)', value: (r) => r.serverId ?? '—', maxWidth: 20 },
|
|
888
|
+
], dbs);
|
|
889
|
+
}
|
|
890
|
+
//# sourceMappingURL=db-move.js.map
|