@neetru/cli 2.32.0 → 2.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/dist/commands/bug.js +4 -4
- package/dist/commands/db-frontdoor.d.ts +116 -0
- package/dist/commands/db-frontdoor.js +567 -0
- package/dist/commands/db-move.d.ts +5 -0
- package/dist/commands/db-move.js +27 -0
- package/dist/commands/db.js +3 -9
- package/dist/commands/deploy.js +9 -9
- package/dist/commands/init.js +2 -1
- package/dist/commands/products.js +2 -2
- package/dist/commands/servers.d.ts +29 -0
- package/dist/commands/servers.js +110 -2
- package/dist/index.js +56 -0
- package/dist/lib/api-client.d.ts +8 -1
- package/dist/lib/api-client.js +18 -2
- package/dist/lib/inquirer-sob-demanda.d.ts +14 -0
- package/dist/lib/inquirer-sob-demanda.js +18 -0
- package/dist/lib/pickers.js +5 -3
- package/package.json +1 -1
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `neetru db frontdoor` — PORTA DE ENTRADA privada de um banco Postgres em VM,
|
|
3
|
+
* SEM mover o banco (Core #727, bug_5c0e7738).
|
|
4
|
+
*
|
|
5
|
+
* Caso de uso: o banco fica onde está e o app passa a rodar (ou já roda) em
|
|
6
|
+
* OUTRA VM da rede interna — ex.: banco do Zelo na snee-003-db, app indo pra
|
|
7
|
+
* snee-002-multi. O `neetru db move` não serve (recusa destino == origem).
|
|
8
|
+
*
|
|
9
|
+
* enable <dbId> --app-server <serverId> --reason "<motivo>"
|
|
10
|
+
* O Core prepara o PgBouncer na VM do banco, testa a conexão a partir da VM
|
|
11
|
+
* do app, testa o login pelo IP interno NA PRÓPRIA VM do banco e só então
|
|
12
|
+
* grava o novo endereço no cadastro do banco. Falha em qualquer passo = nada
|
|
13
|
+
* muda para o app. `--dry-run` só confere os requisitos (sem MFA).
|
|
14
|
+
* status <dbId>
|
|
15
|
+
* disable <dbId> --reason "<motivo>" [--force]
|
|
16
|
+
* Volta o endereço do app para o local da VM do banco (desfazer).
|
|
17
|
+
*
|
|
18
|
+
* COMO ANDA (contrato do Core): a operação só avança DENTRO da chamada de
|
|
19
|
+
* `enable` (não há nada rodando no Core em segundo plano). Por isso o CLI faz o
|
|
20
|
+
* POST com espera longa e, enquanto ele roda, consulta o GET pra mostrar o
|
|
21
|
+
* passo em que está. Se o POST voltar com a operação ainda em curso, o CLI
|
|
22
|
+
* chama de novo (o Core RETOMA a mesma operação) até um fim ou o prazo total.
|
|
23
|
+
* Repetir o comando depois também retoma.
|
|
24
|
+
*
|
|
25
|
+
* MFA (igual ao `db move`/`products db set-status`): `--mfa-token` ou a sessão
|
|
26
|
+
* de 1h de `neetru auth mfa-session`; sessão recusada pelo Core ⇒ pede um TOTP
|
|
27
|
+
* fresco UMA vez. O TOTP é de uso único: nas retomadas o código já gasto não é
|
|
28
|
+
* reenviado (vale a sessão, ou um TOTP novo).
|
|
29
|
+
*
|
|
30
|
+
* Endpoints (Core):
|
|
31
|
+
* POST /api/cli/v1/db/{id}/frontdoor — liga/retoma/dry-run (MFA fora do dry-run)
|
|
32
|
+
* GET /api/cli/v1/db/{id}/frontdoor — estado
|
|
33
|
+
* POST /api/cli/v1/db/{id}/frontdoor/disable — desliga (MFA)
|
|
34
|
+
*
|
|
35
|
+
* Plain PT-BR em toda string visível ao usuário.
|
|
36
|
+
*/
|
|
37
|
+
import { createInterface } from 'node:readline';
|
|
38
|
+
import chalk from 'chalk';
|
|
39
|
+
import ora from 'ora';
|
|
40
|
+
import { apiRequest, CliApiError, 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, withStepUpRetry } from '../lib/cli-write.js';
|
|
44
|
+
import { log } from '../utils/logger.js';
|
|
45
|
+
import { isSafeExitHalt, safeExit, setSafeExitCode } from '../utils/safe-exit.js';
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Textos (puros — testáveis sem rede)
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
/** O que cada estado quer dizer, em português simples. */
|
|
50
|
+
export const STATE_LABELS = {
|
|
51
|
+
planning: 'conferindo os requisitos',
|
|
52
|
+
preparing: 'passo 1 de 3 — preparando o PgBouncer na VM do banco',
|
|
53
|
+
verifying_app: 'passo 2 de 3 — testando a conexão a partir da VM do app',
|
|
54
|
+
verifying_local: 'passo 3 de 3 — testando o login pelo IP interno na própria VM do banco',
|
|
55
|
+
enabled: 'ligada',
|
|
56
|
+
failed: 'falhou (nada mudou para o app)',
|
|
57
|
+
cancelled: 'cancelada (nada mudou para o app)',
|
|
58
|
+
disabled: 'desligada',
|
|
59
|
+
};
|
|
60
|
+
const IN_FLIGHT = new Set(['preparing', 'verifying_app', 'verifying_local']);
|
|
61
|
+
export function isInFlight(state) {
|
|
62
|
+
return !!state && IN_FLIGHT.has(state);
|
|
63
|
+
}
|
|
64
|
+
/** Nome legível de cada requisito do `--dry-run` (o Core manda o nome técnico). */
|
|
65
|
+
export const CHECK_LABELS = {
|
|
66
|
+
db_active: 'banco ativo',
|
|
67
|
+
engine_supported: 'tipo de banco suportado',
|
|
68
|
+
db_server_known: 'VM do banco conhecida',
|
|
69
|
+
credentials: 'credencial do banco registrada',
|
|
70
|
+
endpoint: 'endereço do banco legível',
|
|
71
|
+
distinct_server: 'app e banco em VMs diferentes',
|
|
72
|
+
db_server_ready: 'VM do banco no ar',
|
|
73
|
+
app_server_ready: 'VM do app no ar',
|
|
74
|
+
front_door_plan: 'IPs internos das duas VMs',
|
|
75
|
+
front_door_host_consistent: 'sem outro endereço já gravado',
|
|
76
|
+
no_active_relocate: 'nenhuma mudança de VM do banco em andamento',
|
|
77
|
+
app_environment: 'onde o produto diz que o app roda',
|
|
78
|
+
app_server_fleet: 'VM do app é da frota da Neetru',
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* O que fazer para cada código `FRONT_DOOR_*` que a rota devolve. A mensagem
|
|
82
|
+
* do Core (com VMs, IPs e o motivo do agente) vem antes; isto é o "e agora?".
|
|
83
|
+
*/
|
|
84
|
+
export const FRONT_DOOR_GUIDANCE = {
|
|
85
|
+
FRONT_DOOR_INPUT_INVALID: 'Algum dado do pedido está fora do formato: o banco é um id que começa com pdb_ e a VM do app é o id do servidor.',
|
|
86
|
+
FRONT_DOOR_DB_NOT_FOUND: 'Banco não encontrado. Confira o id do banco com `neetru db where --product <slug>`.',
|
|
87
|
+
FRONT_DOOR_DB_NOT_ACTIVE: 'O banco não está ativo (ou o cadastro dele está incompleto). Só dá para ligar a porta de entrada de um banco ativo.',
|
|
88
|
+
FRONT_DOOR_ENGINE_UNSUPPORTED: 'A porta de entrada só existe para Postgres em VM (vm-postgres-single).',
|
|
89
|
+
FRONT_DOOR_NO_CREDENTIALS: 'O banco não tem credencial registrada, então não há como testar o login pela porta nova. Fale com o Dev Core.',
|
|
90
|
+
FRONT_DOOR_SAME_SERVER: 'A VM do app informada é a própria VM do banco. Nesse caso o app já fala com o banco pelo endereço local — não há nada a ligar.',
|
|
91
|
+
FRONT_DOOR_DB_SERVER_NOT_READY: 'A VM do banco não está no ar ou o agente dela está sem dar sinal há mais de 5 minutos. Verifique a VM e tente de novo.',
|
|
92
|
+
FRONT_DOOR_APP_SERVER_NOT_READY: 'A VM do app não existe, não está no ar ou o agente dela está sem dar sinal há mais de 5 minutos. Confira o id passado em --app-server.',
|
|
93
|
+
FRONT_DOOR_APP_SERVER_NOT_FLEET: 'A VM do app não é uma VM da Neetru (é de cliente ou não tem identidade no Google Cloud do projeto). A liberação abre o banco para a VM inteira, então só vale para VM da frota. Confira o id passado em --app-server.',
|
|
94
|
+
FRONT_DOOR_NOT_PLANNABLE: 'Não foi possível descobrir o IP interno de uma das VMs (ou ele não é da rede interna). Nada foi alterado.',
|
|
95
|
+
FRONT_DOOR_RELOCATE_IN_PROGRESS: 'Este banco está mudando de VM. Até a mudança terminar a porta de entrada é dela — acompanhe com `neetru db move status <id>` e tente depois.',
|
|
96
|
+
FRONT_DOOR_HOST_CONFLICT: 'O cadastro do banco já aponta outro IP interno, diferente do IP atual da VM do banco. Desligue primeiro com `neetru db frontdoor disable <dbId>` e ligue de novo.',
|
|
97
|
+
FRONT_DOOR_IN_PROGRESS: 'Já há uma ligação em andamento para outra VM do app. Repita o comando com aquela VM para concluir, ou cancele com `neetru db frontdoor disable <dbId>`.',
|
|
98
|
+
FRONT_DOOR_PREPARE_FAILED: 'O preparo na VM do banco falhou (o motivo acima vem da própria VM). Nada mudou para o app: ele segue usando o endereço local.',
|
|
99
|
+
FRONT_DOOR_VERIFY_APP_FAILED: 'A VM do app não conseguiu chegar ao banco pela rede interna (rede, firewall ou certificado). Nada mudou para o app.',
|
|
100
|
+
FRONT_DOOR_VERIFY_LOCAL_FAILED: 'O teste de login pelo IP interno, feito na própria VM do banco, falhou — o app que roda lá poderia perder o banco, então nada foi gravado.',
|
|
101
|
+
FRONT_DOOR_UNVERIFIED: 'Os testes ficaram antigos (mais de 10 minutos) antes da gravação. Rode o mesmo comando de novo: ele recomeça e testa outra vez.',
|
|
102
|
+
FRONT_DOOR_STATE_CHANGED: 'O banco mudou (de VM, de credencial ou de estado) enquanto a porta era testada, ou a operação foi trocada por outra. Nada foi gravado — confira com `neetru db frontdoor status <dbId>` e tente de novo.',
|
|
103
|
+
// NÃO está na lista de códigos do Core (`FRONT_DOOR_OP_ERROR_CODES`): é o
|
|
104
|
+
// `lastError.code` que o desligar grava na operação que ele cancela
|
|
105
|
+
// (`frontdoor-op-deps.ts`). A rota o devolve quando uma ligação em curso
|
|
106
|
+
// percebe o cancelamento; e o ramo "terminou sem erro" do `enable` também o
|
|
107
|
+
// sintetiza a partir do estado (`FRONT_DOOR_<ESTADO>`) — defesa em
|
|
108
|
+
// profundidade, porque o Core sempre responde erro numa operação cancelada.
|
|
109
|
+
FRONT_DOOR_CANCELLED: 'A ligação foi cancelada porque a porta de entrada foi desligada enquanto ela andava. Nada foi gravado.',
|
|
110
|
+
FRONT_DOOR_APP_ON_OTHER_VM: 'Desligar faz o app voltar a receber o endereço local da VM do banco: um app que roda em OUTRA VM fica sem banco. ' +
|
|
111
|
+
'Desligar uma porta ligada (o desfazer normal) sempre pede --force, e também quando não se sabe em que VM o app roda. ' +
|
|
112
|
+
'Leve o app de volta para a VM do banco antes ou, se é isso mesmo, repita com --force.',
|
|
113
|
+
FRONT_DOOR_HOST_NOT_OWNED: 'O endereço gravado no cadastro do banco não foi ligado por este comando (veio de outra origem, por exemplo uma mudança de VM do banco). ' +
|
|
114
|
+
'Ele não é desfeito por aqui, nem com --force. Fale com o Dev Core.',
|
|
115
|
+
};
|
|
116
|
+
/** Mensagem do Core primeiro (dados reais), depois o que fazer. Pura. */
|
|
117
|
+
export function describeFrontDoorError(code, serverMessage) {
|
|
118
|
+
const guidance = FRONT_DOOR_GUIDANCE[code];
|
|
119
|
+
return guidance ? `${serverMessage}\n ${guidance}` : serverMessage;
|
|
120
|
+
}
|
|
121
|
+
/** Erro da rota: `FRONT_DOOR_*` vira mensagem + o que fazer; o resto vai pro handler comum. */
|
|
122
|
+
function failFrontDoor(err, json) {
|
|
123
|
+
if (isSafeExitHalt(err))
|
|
124
|
+
throw err;
|
|
125
|
+
if (err instanceof CliApiError && typeof err.code === 'string' && err.code.startsWith('FRONT_DOOR_')) {
|
|
126
|
+
if (json) {
|
|
127
|
+
console.log(JSON.stringify({
|
|
128
|
+
ok: false,
|
|
129
|
+
error: err.code,
|
|
130
|
+
message: err.message,
|
|
131
|
+
guidance: FRONT_DOOR_GUIDANCE[err.code] ?? null,
|
|
132
|
+
}));
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
log.error(describeFrontDoorError(err.code, err.message));
|
|
136
|
+
}
|
|
137
|
+
return safeExit(1);
|
|
138
|
+
}
|
|
139
|
+
return handleApiError(err, json);
|
|
140
|
+
}
|
|
141
|
+
function inputError(message, json) {
|
|
142
|
+
if (json)
|
|
143
|
+
console.log(JSON.stringify({ ok: false, error: 'input', message }));
|
|
144
|
+
else
|
|
145
|
+
log.error(message);
|
|
146
|
+
return safeExit(1);
|
|
147
|
+
}
|
|
148
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
149
|
+
/** Sinal interno: o POST longo caiu por rede/tempo — retoma na próxima volta. */
|
|
150
|
+
class RetomarVolta extends Error {
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Consulta o estado em paralelo ao POST longo, só pra mostrar o passo. Erro de
|
|
154
|
+
* consulta não importa (é exibição). Devolve a função que para.
|
|
155
|
+
*/
|
|
156
|
+
function acompanharPassos(dbId, intervalMs, onOp) {
|
|
157
|
+
let parado = false;
|
|
158
|
+
void (async () => {
|
|
159
|
+
while (!parado) {
|
|
160
|
+
await sleep(intervalMs);
|
|
161
|
+
if (parado)
|
|
162
|
+
break;
|
|
163
|
+
try {
|
|
164
|
+
const s = await apiRequest(`/api/cli/v1/db/${encodeURIComponent(dbId)}/frontdoor`);
|
|
165
|
+
if (!parado && s.op)
|
|
166
|
+
onOp(s.op);
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
/* só exibição */
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
})();
|
|
173
|
+
return () => {
|
|
174
|
+
parado = true;
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
export async function runDbFrontdoorEnable(dbId, opts = {}) {
|
|
178
|
+
if (!opts.json)
|
|
179
|
+
log.banner();
|
|
180
|
+
if (!dbId || !dbId.trim())
|
|
181
|
+
inputError('Informe o id do banco (pdb_…).', opts.json);
|
|
182
|
+
if (!opts.appServer || !opts.appServer.trim()) {
|
|
183
|
+
inputError('--app-server <serverId> é obrigatório (a VM onde o app roda ou vai rodar).', opts.json);
|
|
184
|
+
}
|
|
185
|
+
if (!opts.reason || !opts.reason.trim()) {
|
|
186
|
+
inputError('--reason "<texto>" é obrigatório (vai para o registro de auditoria).', opts.json);
|
|
187
|
+
}
|
|
188
|
+
const appServer = opts.appServer.trim();
|
|
189
|
+
await requireToken(opts.json);
|
|
190
|
+
// ── Estado ANTES: mostra o plano e falha cedo no caso óbvio. Rede instável
|
|
191
|
+
// não bloqueia (o Core confere tudo de novo). ─────────────────────────────
|
|
192
|
+
let before = null;
|
|
193
|
+
try {
|
|
194
|
+
before = await apiRequest(`/api/cli/v1/db/${encodeURIComponent(dbId)}/frontdoor`);
|
|
195
|
+
}
|
|
196
|
+
catch (err) {
|
|
197
|
+
if (!(err instanceof CliNetworkError))
|
|
198
|
+
failFrontDoor(err, opts.json);
|
|
199
|
+
}
|
|
200
|
+
const dbServer = before?.ledger.serverId ?? null;
|
|
201
|
+
if (dbServer && dbServer === appServer) {
|
|
202
|
+
const msg = describeFrontDoorError('FRONT_DOOR_SAME_SERVER', `A VM do app (${appServer}) é a mesma VM do banco.`);
|
|
203
|
+
if (opts.json) {
|
|
204
|
+
console.log(JSON.stringify({ ok: false, error: 'FRONT_DOOR_SAME_SERVER', message: msg }));
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
log.error(msg);
|
|
208
|
+
}
|
|
209
|
+
safeExit(1);
|
|
210
|
+
}
|
|
211
|
+
if (!opts.json) {
|
|
212
|
+
log.heading(opts.dryRun ? 'Porta de entrada do banco (só conferência)' : 'Porta de entrada do banco');
|
|
213
|
+
log.info(`Banco: ${chalk.cyan(dbId)}`);
|
|
214
|
+
log.info(`VM do banco: ${dbServer ? chalk.bold(dbServer) : chalk.dim('(o Core confere)')}`);
|
|
215
|
+
log.info(`VM do app: ${chalk.bold(appServer)}`);
|
|
216
|
+
log.info(`Hoje: ${before?.ledger.frontDoorHost
|
|
217
|
+
? `ligada em ${before.ledger.frontDoorHost}:5433`
|
|
218
|
+
: 'desligada — o app recebe o endereço local da VM do banco'}`);
|
|
219
|
+
}
|
|
220
|
+
const body = { appServerId: appServer, reason: opts.reason.trim() };
|
|
221
|
+
// ── Só conferência: nada é criado, sem MFA. ───────────────────────────────
|
|
222
|
+
if (opts.dryRun) {
|
|
223
|
+
const { headers } = await resolveStepUp({ dryRun: true, json: opts.json, mfaToken: opts.mfaToken });
|
|
224
|
+
let r;
|
|
225
|
+
try {
|
|
226
|
+
r = await apiRequest(`/api/cli/v1/db/${encodeURIComponent(dbId)}/frontdoor`, {
|
|
227
|
+
method: 'POST',
|
|
228
|
+
body: { ...body, dryRun: true },
|
|
229
|
+
headers,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
catch (err) {
|
|
233
|
+
failFrontDoor(err, opts.json);
|
|
234
|
+
}
|
|
235
|
+
if (opts.json) {
|
|
236
|
+
console.log(JSON.stringify({ ...r, ok: r.preflight.ok }));
|
|
237
|
+
if (!r.preflight.ok)
|
|
238
|
+
setSafeExitCode(1);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
log.heading('Requisitos');
|
|
242
|
+
for (const c of r.preflight.checks) {
|
|
243
|
+
const mark = c.ok ? chalk.green('✓') : chalk.red('✗');
|
|
244
|
+
log.dim(` ${mark} ${CHECK_LABELS[c.name] ?? c.name}${c.detail ? ` — ${c.detail}` : ''}`);
|
|
245
|
+
}
|
|
246
|
+
if (r.preflight.ok) {
|
|
247
|
+
log.success('Tudo certo para ligar. Nada foi alterado. Rode de novo sem --dry-run para ligar.');
|
|
248
|
+
}
|
|
249
|
+
else {
|
|
250
|
+
log.error('Algum requisito falhou — a porta de entrada não seria ligada. Nada foi alterado.');
|
|
251
|
+
setSafeExitCode(1);
|
|
252
|
+
}
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
await confirmDestructive(`Ligar a porta de entrada do banco ${dbId} para a VM ${appServer}? O PgBouncer da VM do banco ` +
|
|
256
|
+
'reinicia uma vez (as conexões do app caem e voltam em segundos). Depois de ligada, TODO app ' +
|
|
257
|
+
'deste banco passa a falar com ele pelo IP interno da VM do banco — isso é testado antes de gravar.', { yes: opts.yes, json: opts.json });
|
|
258
|
+
// ── Voltas de POST longo (o Core anda só durante a chamada) ───────────────
|
|
259
|
+
const intervalMs = opts.pollIntervalMs ?? 3000;
|
|
260
|
+
const totalMs = opts.timeoutMs ?? 20 * 60 * 1000;
|
|
261
|
+
const waitSeconds = opts.waitSeconds ?? 240;
|
|
262
|
+
const inicio = Date.now();
|
|
263
|
+
const spinner = opts.json ? null : ora({ text: 'Ligando a porta de entrada…', color: 'blue' }).start();
|
|
264
|
+
let ultimoEstado = '';
|
|
265
|
+
const mostrar = (state) => {
|
|
266
|
+
if (state === ultimoEstado)
|
|
267
|
+
return;
|
|
268
|
+
ultimoEstado = state;
|
|
269
|
+
if (spinner)
|
|
270
|
+
spinner.text = STATE_LABELS[state] ?? state;
|
|
271
|
+
};
|
|
272
|
+
let volta = 0;
|
|
273
|
+
// eslint-disable-next-line no-constant-condition
|
|
274
|
+
while (true) {
|
|
275
|
+
if (Date.now() - inicio >= totalMs) {
|
|
276
|
+
spinner?.fail('Prazo esgotado esperando a porta de entrada.');
|
|
277
|
+
const msg = 'A ligação continua registrada no Core e não mudou nada para o app. ' +
|
|
278
|
+
`Rode de novo \`neetru db frontdoor enable ${dbId} --app-server ${appServer} --reason "<motivo>"\` para retomar.`;
|
|
279
|
+
if (opts.json)
|
|
280
|
+
console.log(JSON.stringify({ ok: false, error: 'watch_timeout', dbId, message: msg }));
|
|
281
|
+
else
|
|
282
|
+
log.warn(msg);
|
|
283
|
+
setSafeExitCode(1);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
volta += 1;
|
|
287
|
+
const parar = spinner ? acompanharPassos(dbId, intervalMs, (op) => mostrar(op.state)) : () => undefined;
|
|
288
|
+
let r = null;
|
|
289
|
+
try {
|
|
290
|
+
r = await withStepUpRetry(
|
|
291
|
+
// O TOTP é de uso único: só a 1ª volta manda o `--mfa-token`; as
|
|
292
|
+
// retomadas usam a sessão de 1h (ou pedem um TOTP fresco).
|
|
293
|
+
{ mfaToken: volta === 1 ? opts.mfaToken : undefined, json: opts.json, yes: opts.yes }, (headers) => apiRequest(`/api/cli/v1/db/${encodeURIComponent(dbId)}/frontdoor`, {
|
|
294
|
+
method: 'POST',
|
|
295
|
+
body: { ...body, waitSeconds },
|
|
296
|
+
headers,
|
|
297
|
+
timeoutMs: (waitSeconds + 60) * 1000,
|
|
298
|
+
}), (err) => {
|
|
299
|
+
// POST longo que caiu por rede/tempo: o Core segue com a operação
|
|
300
|
+
// registrada — a próxima volta retoma.
|
|
301
|
+
if (err instanceof CliNetworkError)
|
|
302
|
+
throw new RetomarVolta();
|
|
303
|
+
parar();
|
|
304
|
+
spinner?.fail('A porta de entrada NÃO foi ligada.');
|
|
305
|
+
return failFrontDoor(err, opts.json);
|
|
306
|
+
}, () => {
|
|
307
|
+
parar();
|
|
308
|
+
spinner?.stop();
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
catch (err) {
|
|
312
|
+
if (!(err instanceof RetomarVolta))
|
|
313
|
+
throw err;
|
|
314
|
+
}
|
|
315
|
+
finally {
|
|
316
|
+
parar();
|
|
317
|
+
}
|
|
318
|
+
if (!r) {
|
|
319
|
+
if (spinner)
|
|
320
|
+
spinner.text = 'Conexão com o Core caiu — retomando…';
|
|
321
|
+
await sleep(Math.min(intervalMs, 3000));
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
mostrar(r.state);
|
|
325
|
+
if (r.done) {
|
|
326
|
+
// Só o `frontDoorHost` do cadastro diz que a porta está ligada. O Core #727
|
|
327
|
+
// grava `frontDoorCertNotAfter` antes (no despacho do preparo), inclusive
|
|
328
|
+
// em operação que depois falha — esse campo NUNCA é prova de "ligada".
|
|
329
|
+
const hostLigado = r.ledger?.frontDoorHost ?? null;
|
|
330
|
+
if (!hostLigado) {
|
|
331
|
+
spinner?.fail('A porta de entrada NÃO aparece ligada no cadastro do banco.');
|
|
332
|
+
const msg = `O Core respondeu que terminou, mas o cadastro do banco ${dbId} não tem o endereço da porta de entrada — ` +
|
|
333
|
+
`nada é dado como ligado. Confira com \`neetru db frontdoor status ${dbId}\`.`;
|
|
334
|
+
if (opts.json)
|
|
335
|
+
console.log(JSON.stringify({ ok: false, error: 'not_recorded', dbId, message: msg }));
|
|
336
|
+
else
|
|
337
|
+
log.error(msg);
|
|
338
|
+
setSafeExitCode(1);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
spinner?.succeed(r.alreadyEnabled ? 'A porta de entrada já estava ligada.' : 'Porta de entrada LIGADA.');
|
|
342
|
+
if (opts.json) {
|
|
343
|
+
console.log(JSON.stringify(r));
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
const op = r.op;
|
|
347
|
+
if (r.alreadyEnabled) {
|
|
348
|
+
log.info(`Já estava ligada para a VM ${appServer} — nada mudou.`);
|
|
349
|
+
}
|
|
350
|
+
else {
|
|
351
|
+
log.success(`O banco ${dbId} agora atende a VM ${appServer} pelo IP interno ${hostLigado}:${op?.port ?? 5433}.`);
|
|
352
|
+
log.dim(' Vale para todo app deste banco, inclusive o que ainda roda na VM do banco ' +
|
|
353
|
+
'(o login por esse caminho foi testado lá antes de gravar).');
|
|
354
|
+
}
|
|
355
|
+
const vence = r.ledger?.frontDoorCertNotAfter;
|
|
356
|
+
if (vence)
|
|
357
|
+
log.dim(` Certificado do PgBouncer vence em: ${vence}`);
|
|
358
|
+
log.info(`Próximo passo: faça o deploy do app na VM ${appServer} e confira login e uma operação real.`);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if (!isInFlight(r.state)) {
|
|
362
|
+
// Não deveria acontecer (o Core responde erro na falha), mas sem mentir:
|
|
363
|
+
const code = r.op?.lastError?.code ?? `FRONT_DOOR_${String(r.state).toUpperCase()}`;
|
|
364
|
+
const message = r.op?.lastError?.message ?? `Operação terminou em ${r.state}.`;
|
|
365
|
+
spinner?.fail('A porta de entrada NÃO foi ligada.');
|
|
366
|
+
if (opts.json) {
|
|
367
|
+
console.log(JSON.stringify({ ok: false, error: code, message, guidance: FRONT_DOOR_GUIDANCE[code] ?? null }));
|
|
368
|
+
}
|
|
369
|
+
else {
|
|
370
|
+
log.error(describeFrontDoorError(code, message));
|
|
371
|
+
}
|
|
372
|
+
setSafeExitCode(1);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
// Ainda andando depois da espera do Core: chama de novo (retoma a mesma operação).
|
|
376
|
+
await sleep(Math.min(intervalMs, 3000));
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
// ---------------------------------------------------------------------------
|
|
380
|
+
// neetru db frontdoor status <dbId>
|
|
381
|
+
// ---------------------------------------------------------------------------
|
|
382
|
+
export async function runDbFrontdoorStatus(dbId, opts = {}) {
|
|
383
|
+
await requireToken(opts.json);
|
|
384
|
+
let s;
|
|
385
|
+
try {
|
|
386
|
+
s = await apiRequest(`/api/cli/v1/db/${encodeURIComponent(dbId)}/frontdoor`);
|
|
387
|
+
}
|
|
388
|
+
catch (err) {
|
|
389
|
+
failFrontDoor(err, opts.json);
|
|
390
|
+
}
|
|
391
|
+
if (opts.json) {
|
|
392
|
+
console.log(JSON.stringify(s));
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
log.heading(`Porta de entrada do banco ${s.dbId}`);
|
|
396
|
+
log.info(`VM do banco: ${s.ledger.serverId ?? '—'}`);
|
|
397
|
+
log.info(`Porta: ${s.ledger.frontDoorHost
|
|
398
|
+
? chalk.green(`ligada em ${s.ledger.frontDoorHost}:5433`)
|
|
399
|
+
: 'desligada — o app recebe o endereço local da VM do banco'}`);
|
|
400
|
+
if (s.ledger.frontDoorCertNotAfter) {
|
|
401
|
+
log.dim(` certificado do PgBouncer vence em: ${s.ledger.frontDoorCertNotAfter}` +
|
|
402
|
+
(s.ledger.frontDoorHost ? '' : ' (ele segue na VM do banco mesmo com a porta desligada)'));
|
|
403
|
+
}
|
|
404
|
+
const op = s.op;
|
|
405
|
+
if (!op) {
|
|
406
|
+
log.dim(' Nenhuma ligação feita por este comando até agora.');
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
log.heading('Última operação');
|
|
410
|
+
log.info(`Estado: ${STATE_LABELS[op.state] ?? op.state}`);
|
|
411
|
+
log.info(`VM do app: ${op.appServerId}${op.allowedClientCidr ? ` (liberada: ${op.allowedClientCidr})` : ''}`);
|
|
412
|
+
log.dim(` pedido: ${op.reason}`);
|
|
413
|
+
if (op.preparedAt)
|
|
414
|
+
log.dim(` preparado na VM do banco em: ${op.preparedAt}`);
|
|
415
|
+
if (op.appVerifiedAt)
|
|
416
|
+
log.dim(` testado a partir da VM do app em: ${op.appVerifiedAt}`);
|
|
417
|
+
if (op.localVerifiedAt)
|
|
418
|
+
log.dim(` login testado na VM do banco em: ${op.localVerifiedAt}`);
|
|
419
|
+
if (op.enabledAt)
|
|
420
|
+
log.dim(` ligada em: ${op.enabledAt}`);
|
|
421
|
+
if (op.failedAt)
|
|
422
|
+
log.dim(` falhou em: ${op.failedAt}`);
|
|
423
|
+
if (op.cancelledAt)
|
|
424
|
+
log.dim(` cancelada em: ${op.cancelledAt}`);
|
|
425
|
+
if (op.disabledAt)
|
|
426
|
+
log.dim(` desligada em: ${op.disabledAt}`);
|
|
427
|
+
if (op.lastError)
|
|
428
|
+
log.warn(describeFrontDoorError(op.lastError.code, op.lastError.message));
|
|
429
|
+
if (isInFlight(op.state)) {
|
|
430
|
+
log.warn('Esta ligação só anda enquanto o comando de ligar estiver rodando. Para concluir, rode de novo ' +
|
|
431
|
+
`\`neetru db frontdoor enable ${s.dbId} --app-server ${op.appServerId} --reason "<motivo>"\`; ` +
|
|
432
|
+
`para cancelar, \`neetru db frontdoor disable ${s.dbId} --reason "<motivo>"\`.`);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
/** Uma pergunta no terminal (a confirmação forte do `disable --force`). */
|
|
436
|
+
function perguntar(pergunta) {
|
|
437
|
+
return new Promise((resolve) => {
|
|
438
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
439
|
+
rl.question(` ${pergunta} `, (resposta) => {
|
|
440
|
+
rl.close();
|
|
441
|
+
resolve(resposta.trim());
|
|
442
|
+
});
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Desligar (desfazer) — regras do Core #727 (rodada 2), decididas na transação:
|
|
447
|
+
* - porta LIGADA por este comando pra um app noutra VM (o desfazer normal) ⇒
|
|
448
|
+
* exige `--force`; idem quando não se sabe em que VM o app roda;
|
|
449
|
+
* - ligação em curso sem endereço gravado ⇒ só cancela, sem `--force`;
|
|
450
|
+
* - endereço gravado por OUTRA origem (ex.: mudança de VM do banco) nunca é
|
|
451
|
+
* tirado por aqui: `host_kept` (cancelou a ligação em curso) ou
|
|
452
|
+
* `FRONT_DOOR_HOST_NOT_OWNED` (409, nem com `--force`).
|
|
453
|
+
* Com `--force` a confirmação é forte: digitar o id do banco (ou `--yes`).
|
|
454
|
+
*/
|
|
455
|
+
export async function runDbFrontdoorDisable(dbId, opts = {}) {
|
|
456
|
+
if (!opts.json)
|
|
457
|
+
log.banner();
|
|
458
|
+
if (!dbId || !dbId.trim())
|
|
459
|
+
inputError('Informe o id do banco (pdb_…).', opts.json);
|
|
460
|
+
if (!opts.reason || !opts.reason.trim()) {
|
|
461
|
+
inputError('--reason "<texto>" é obrigatório (vai para o registro de auditoria).', opts.json);
|
|
462
|
+
}
|
|
463
|
+
await requireToken(opts.json);
|
|
464
|
+
let before = null;
|
|
465
|
+
try {
|
|
466
|
+
before = await apiRequest(`/api/cli/v1/db/${encodeURIComponent(dbId)}/frontdoor`);
|
|
467
|
+
}
|
|
468
|
+
catch (err) {
|
|
469
|
+
if (!(err instanceof CliNetworkError))
|
|
470
|
+
failFrontDoor(err, opts.json);
|
|
471
|
+
}
|
|
472
|
+
const host = before?.ledger.frontDoorHost ?? null;
|
|
473
|
+
const dbServer = before?.ledger.serverId ?? null;
|
|
474
|
+
const opAntes = before?.op ?? null;
|
|
475
|
+
// A VM do app que a porta ligada atende (se a porta é deste comando e está ligada).
|
|
476
|
+
const appLigado = host && opAntes && opAntes.state === 'enabled' && opAntes.host === host && opAntes.dbServerId === dbServer
|
|
477
|
+
? opAntes.appServerId
|
|
478
|
+
: null;
|
|
479
|
+
const appNoutraVm = appLigado && appLigado !== dbServer ? appLigado : null;
|
|
480
|
+
const vmDoBanco = dbServer ?? 'VM do banco';
|
|
481
|
+
// ── Desfazer uma porta ligada pede --force: explica ANTES de pedir MFA. ──
|
|
482
|
+
if (appNoutraVm && !opts.force) {
|
|
483
|
+
const msg = `A porta de entrada está ligada para a VM do app ${appNoutraVm}. Desligar exige --force: o app volta a ` +
|
|
484
|
+
`receber o endereço local da VM do banco ${vmDoBanco}, e um app que ainda rode em ${appNoutraVm} FICA SEM ` +
|
|
485
|
+
'BANCO até voltar para a VM do banco.';
|
|
486
|
+
if (opts.json) {
|
|
487
|
+
console.log(JSON.stringify({
|
|
488
|
+
ok: false,
|
|
489
|
+
error: 'FRONT_DOOR_APP_ON_OTHER_VM',
|
|
490
|
+
message: msg,
|
|
491
|
+
guidance: FRONT_DOOR_GUIDANCE.FRONT_DOOR_APP_ON_OTHER_VM,
|
|
492
|
+
}));
|
|
493
|
+
}
|
|
494
|
+
else {
|
|
495
|
+
log.error(msg);
|
|
496
|
+
}
|
|
497
|
+
safeExit(1);
|
|
498
|
+
}
|
|
499
|
+
if (opts.force) {
|
|
500
|
+
const efeito = `Desligar com --force: o app deixa de receber o IP interno${host ? ` ${host}:5433` : ''} e volta para o ` +
|
|
501
|
+
`endereço local da VM ${vmDoBanco}. ` +
|
|
502
|
+
(appNoutraVm
|
|
503
|
+
? `Se o app ainda roda em ${appNoutraVm}, ele CAI (fica sem banco) até voltar para a VM do banco.`
|
|
504
|
+
: 'Um app que rode em outra VM CAI (fica sem banco).');
|
|
505
|
+
if (!opts.yes) {
|
|
506
|
+
if (opts.json || !process.stdin.isTTY) {
|
|
507
|
+
const msg = 'Desligar com --force pede confirmação forte: digite o id do banco num terminal interativo, ' +
|
|
508
|
+
'ou passe --yes junto com --force.';
|
|
509
|
+
if (opts.json)
|
|
510
|
+
console.log(JSON.stringify({ ok: false, error: 'confirmation_required', message: msg }));
|
|
511
|
+
else
|
|
512
|
+
log.error(msg);
|
|
513
|
+
safeExit(1);
|
|
514
|
+
}
|
|
515
|
+
log.warn(efeito);
|
|
516
|
+
const digitado = await perguntar(`Para confirmar, digite o id do banco (${dbId}):`);
|
|
517
|
+
if (digitado !== dbId) {
|
|
518
|
+
log.error('O id não conferiu. Nada foi alterado.');
|
|
519
|
+
safeExit(1);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
else if (!opts.json) {
|
|
523
|
+
log.warn(efeito);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
else {
|
|
527
|
+
await confirmDestructive(`Desligar a porta de entrada do banco ${dbId}? O app volta a receber o endereço local da VM ${vmDoBanco}` +
|
|
528
|
+
(host ? ` (hoje recebe ${host}:5433)` : '') +
|
|
529
|
+
'. Uma ligação em andamento é cancelada.', { yes: opts.yes, json: opts.json });
|
|
530
|
+
}
|
|
531
|
+
const spinner = opts.json ? null : ora({ text: 'Desligando a porta de entrada…', color: 'blue' }).start();
|
|
532
|
+
const r = await withStepUpRetry({ mfaToken: opts.mfaToken, json: opts.json, yes: opts.yes }, (headers) => apiRequest(`/api/cli/v1/db/${encodeURIComponent(dbId)}/frontdoor/disable`, {
|
|
533
|
+
method: 'POST',
|
|
534
|
+
body: { reason: opts.reason.trim(), ...(opts.force ? { force: true } : {}) },
|
|
535
|
+
headers,
|
|
536
|
+
}), (err) => {
|
|
537
|
+
spinner?.fail('A porta de entrada NÃO foi desligada.');
|
|
538
|
+
return failFrontDoor(err, opts.json);
|
|
539
|
+
}, () => spinner?.stop());
|
|
540
|
+
spinner?.stop();
|
|
541
|
+
if (opts.json) {
|
|
542
|
+
console.log(JSON.stringify(r));
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
const vmFinal = r.ledger?.serverId ?? vmDoBanco;
|
|
546
|
+
if (r.outcome === 'already_disabled') {
|
|
547
|
+
log.info('A porta de entrada já estava desligada — o endereço do app não mudou.');
|
|
548
|
+
}
|
|
549
|
+
else if (r.outcome === 'host_kept') {
|
|
550
|
+
log.warn(`A ligação em andamento (${r.opId}) foi cancelada, mas o endereço gravado no cadastro` +
|
|
551
|
+
`${r.previousFrontDoorHost ? ` (${r.previousFrontDoorHost}:5433)` : ''} não é desta porta de entrada ` +
|
|
552
|
+
'(veio de outra origem, por exemplo uma mudança de VM do banco) e FICOU como estava.');
|
|
553
|
+
}
|
|
554
|
+
else {
|
|
555
|
+
log.success(`Porta de entrada DESLIGADA. O app volta a receber o endereço local da VM ${vmFinal}` +
|
|
556
|
+
`${r.previousFrontDoorHost ? ` (antes: ${r.previousFrontDoorHost}:5433)` : ''}.`);
|
|
557
|
+
if (appNoutraVm) {
|
|
558
|
+
log.warn(`Se o app ainda roda em ${appNoutraVm}, ele está SEM BANCO agora — leve-o para a VM ${vmFinal}.`);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
if (r.opTransition === 'cancelled' && r.outcome !== 'host_kept') {
|
|
562
|
+
log.warn(`A ligação que estava em andamento (${r.opId}) foi cancelada.`);
|
|
563
|
+
}
|
|
564
|
+
log.dim(' Na VM do banco o PgBouncer segue escutando no IP interno, liberado só para a VM do app — ' +
|
|
565
|
+
'isso não é desfeito por este comando.');
|
|
566
|
+
}
|
|
567
|
+
//# sourceMappingURL=db-frontdoor.js.map
|
|
@@ -182,6 +182,11 @@ export interface DbMoveStartOptions {
|
|
|
182
182
|
/** Somente testes: teto do wall-clock do poll (ms). Default 30min. */
|
|
183
183
|
pollTimeoutMs?: number;
|
|
184
184
|
}
|
|
185
|
+
/**
|
|
186
|
+
* Recusas do KICKOFF (`POST /db/{id}/relocate`) com código estável e o que
|
|
187
|
+
* fazer, em PT-BR. As outras recusas seguem pelo tratamento comum.
|
|
188
|
+
*/
|
|
189
|
+
export declare const RELOCATE_START_REFUSAL_GUIDANCE: Record<string, string>;
|
|
185
190
|
export declare function runDbMoveStart(opts: DbMoveStartOptions): Promise<void>;
|
|
186
191
|
/**
|
|
187
192
|
* Acompanha a máquina de estados até um estado terminal (done/rolled_back/failed)
|
package/dist/commands/db-move.js
CHANGED
|
@@ -283,6 +283,17 @@ async function resolveTargetDb(opts) {
|
|
|
283
283
|
}
|
|
284
284
|
return { dbId: dbs[0].id, source: dbs[0] };
|
|
285
285
|
}
|
|
286
|
+
/**
|
|
287
|
+
* Recusas do KICKOFF (`POST /db/{id}/relocate`) com código estável e o que
|
|
288
|
+
* fazer, em PT-BR. As outras recusas seguem pelo tratamento comum.
|
|
289
|
+
*/
|
|
290
|
+
export const RELOCATE_START_REFUSAL_GUIDANCE = {
|
|
291
|
+
// Core #727: o preflight do relocate recusa enquanto uma ligação da porta de
|
|
292
|
+
// entrada do banco (`neetru db frontdoor`) estiver em andamento.
|
|
293
|
+
RELOCATE_FRONT_DOOR_OP_IN_PROGRESS: 'Há uma ligação da porta de entrada deste banco em andamento. Espere ela terminar ' +
|
|
294
|
+
'(acompanhe com `neetru db frontdoor status <dbId>`) ou cancele com ' +
|
|
295
|
+
'`neetru db frontdoor disable <dbId> --reason "<motivo>"`, e então rode a mudança de VM de novo.',
|
|
296
|
+
};
|
|
286
297
|
export async function runDbMoveStart(opts) {
|
|
287
298
|
if (!opts.json)
|
|
288
299
|
log.banner();
|
|
@@ -371,6 +382,22 @@ export async function runDbMoveStart(opts) {
|
|
|
371
382
|
}
|
|
372
383
|
catch (err) {
|
|
373
384
|
spinner?.fail('Falha ao iniciar relocação.');
|
|
385
|
+
// Recusa conhecida do kickoff (409 com código estável) ⇒ o que significa +
|
|
386
|
+
// o que fazer, em vez da mensagem genérica.
|
|
387
|
+
if (err instanceof CliApiError && RELOCATE_START_REFUSAL_GUIDANCE[err.code]) {
|
|
388
|
+
if (opts.json) {
|
|
389
|
+
console.log(JSON.stringify({
|
|
390
|
+
ok: false,
|
|
391
|
+
error: err.code,
|
|
392
|
+
message: err.message,
|
|
393
|
+
guidance: RELOCATE_START_REFUSAL_GUIDANCE[err.code],
|
|
394
|
+
}));
|
|
395
|
+
}
|
|
396
|
+
else {
|
|
397
|
+
log.error(`${err.message}\n ${RELOCATE_START_REFUSAL_GUIDANCE[err.code]}`);
|
|
398
|
+
}
|
|
399
|
+
safeExit(1);
|
|
400
|
+
}
|
|
374
401
|
handleApiError(err, opts.json);
|
|
375
402
|
}
|
|
376
403
|
// ── Dry-run: só o preflight. Nada foi provisionado. ─────────────────────────
|
package/dist/commands/db.js
CHANGED
|
@@ -37,16 +37,10 @@ import { exitWithCliError } from '../lib/cli-error.js';
|
|
|
37
37
|
import { safeExit } from '../utils/safe-exit.js';
|
|
38
38
|
import { runApplyPipeline, runExecSqlPipeline, buildPipelineDeps, isPipelineError, } from '../lib/db-pipeline/index.js';
|
|
39
39
|
import { resolveStepUp } from '../lib/cli-write.js';
|
|
40
|
+
import { carregarInquirer } from '../lib/inquirer-sob-demanda.js';
|
|
40
41
|
import { loadProductConfig } from '../lib/product-config.js';
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
* interativos usam. Era o unico import estatico dele no grafo de `neetru db`
|
|
44
|
-
* — `db list`/`db status`/`db apply --json` (e os testes) pagavam a carga sem
|
|
45
|
-
* nunca perguntar nada.
|
|
46
|
-
*/
|
|
47
|
-
async function carregarInquirer() {
|
|
48
|
-
return (await import('inquirer')).default;
|
|
49
|
-
}
|
|
42
|
+
// `inquirer` sob demanda (bug_b1e9af59): só os prompts interativos o carregam,
|
|
43
|
+
// pelo `carregarInquirer()` único de `lib/inquirer-sob-demanda.ts` (P115).
|
|
50
44
|
// ---------------------------------------------------------------------------
|
|
51
45
|
// Constantes e tipos locais
|
|
52
46
|
// ---------------------------------------------------------------------------
|