@microteia/console 2.10.0 → 2.19.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/CONSOLE-VERSIONS.md +57 -0
- package/README.md +1 -1
- package/lib/console/appliedFunctions.d.ts +114 -0
- package/lib/console/appliedFunctions.js +123 -0
- package/lib/console/backendResidue.d.ts +55 -0
- package/lib/console/backendResidue.js +53 -0
- package/lib/console/customFunctions.d.ts +51 -1
- package/lib/console/customFunctions.js +93 -1
- package/lib/console/deploy.js +31 -7
- package/lib/console/environment.d.ts +7 -6
- package/lib/console/firestoreRest.d.ts +27 -0
- package/lib/console/firestoreRest.js +23 -0
- package/lib/console/functionGate.d.ts +228 -0
- package/lib/console/functionGate.js +437 -0
- package/lib/console/provision.d.ts +26 -13
- package/lib/console/provision.js +393 -58
- package/lib/kernel/backendShape.d.ts +27 -0
- package/lib/kernel/backendShape.js +38 -0
- package/lib/shared/paths.d.ts +1 -1
- package/lib/shared/paths.js +1 -1
- package/lib/vendor/packageTemplates.d.ts +12 -1
- package/lib/vendor/packageTemplates.js +42 -2
- package/package.json +1 -1
package/lib/console/provision.js
CHANGED
|
@@ -2,7 +2,7 @@ import { dirname, join, resolve } from 'node:path';
|
|
|
2
2
|
import { hasFlag, requiredFlag, flagValue } from '../shared/args.js';
|
|
3
3
|
import { run } from '../shared/run.js';
|
|
4
4
|
import { blank, bullets, failure, heading, info, step, success, warn } from '../shared/ui.js';
|
|
5
|
-
import { backendFingerprint, claimedIndexes, claimedParameters, functionClaimProblems, hasBackendRequirement, indexKey, } from '../kernel/backendShape.js';
|
|
5
|
+
import { backendFingerprint, baselineClaimProblems, claimedIndexes, claimedParameters, functionClaimProblems, hasBackendRequirement, indexKey, sharedClaimProblems, } from '../kernel/backendShape.js';
|
|
6
6
|
import { convergeIndexes, describeIndex, parseIndexState, readIndexFile, serializeIndexState, writeIndexFile, } from './backendIndexes.js';
|
|
7
7
|
import { officeEnvPath, stageOfficeEnv } from './backendEnv.js';
|
|
8
8
|
import { installPath, readInstalledManifest } from './unpack.js';
|
|
@@ -14,8 +14,11 @@ import { clearRegionOrphans, listDeployedFunctions } from './regionOrphans.js';
|
|
|
14
14
|
import { outdatedByStamp } from './functionsUpToDate.js';
|
|
15
15
|
import { checkCallables, invokerGrantCommand } from './callableAccess.js';
|
|
16
16
|
import { asString, asTimestamp, patchMapField } from './firestoreRest.js';
|
|
17
|
+
import { writeBackendResidue } from './backendResidue.js';
|
|
17
18
|
import { listTenants, readInstalledExtensions } from './installedExtensions.js';
|
|
18
19
|
import { gatherCustomFunctions, readOfficeFunctions } from './customFunctions.js';
|
|
20
|
+
import { describeMisgated, gateProblems, misgatedTriggers, singleClaimants, } from './functionGate.js';
|
|
21
|
+
import { appliedAfterRun, classifyApplied, describeApplied, parseAppliedFunctions, serializeAppliedFunctions, straysInProject, } from './appliedFunctions.js';
|
|
19
22
|
/**
|
|
20
23
|
* O que cada escritório do projeto instalou e exige do backend.
|
|
21
24
|
*
|
|
@@ -23,13 +26,17 @@ import { gatherCustomFunctions, readOfficeFunctions } from './customFunctions.js
|
|
|
23
26
|
* contra `manifest.functions` antes de qualquer deploy. Nome de function fora do pacote é recusado com
|
|
24
27
|
* explicação; não há injeção possível, porque o código é sempre o do pacote.
|
|
25
28
|
*/
|
|
26
|
-
export const
|
|
29
|
+
export const surveyInstallations = async (projectId) => {
|
|
27
30
|
const tenants = await listTenants(projectId);
|
|
28
31
|
const claims = [];
|
|
32
|
+
const installedIds = new Set();
|
|
33
|
+
let extensions = 0;
|
|
29
34
|
for (const tenant of tenants) {
|
|
30
35
|
for (const extension of await readInstalledExtensions(projectId, tenant.id)) {
|
|
36
|
+
extensions += 1;
|
|
31
37
|
if (!extension.active)
|
|
32
38
|
continue;
|
|
39
|
+
installedIds.add(extension.id);
|
|
33
40
|
const backend = extension.manifest?.backend;
|
|
34
41
|
if (!hasBackendRequirement(backend))
|
|
35
42
|
continue;
|
|
@@ -45,7 +52,12 @@ export const readInstalledClaims = async (projectId) => {
|
|
|
45
52
|
});
|
|
46
53
|
}
|
|
47
54
|
}
|
|
48
|
-
return
|
|
55
|
+
return {
|
|
56
|
+
tenants: tenants.length,
|
|
57
|
+
extensions,
|
|
58
|
+
claims: claims.sort((a, b) => (a.pluginId < b.pluginId ? -1 : a.pluginId > b.pluginId ? 1 : 0)),
|
|
59
|
+
installedIds,
|
|
60
|
+
};
|
|
49
61
|
};
|
|
50
62
|
const uniqueByPlugin = (claims) => {
|
|
51
63
|
const seen = new Set();
|
|
@@ -56,7 +68,6 @@ const uniqueByPlugin = (claims) => {
|
|
|
56
68
|
return true;
|
|
57
69
|
});
|
|
58
70
|
};
|
|
59
|
-
export const parseAppliedFunctions = (value) => (value ?? '').split(',').map((name) => name.trim()).filter((name) => name !== '');
|
|
60
71
|
/**
|
|
61
72
|
* O motor: alinha o projeto Firebase com o que as extensões instaladas exigem, e carimba nelas o
|
|
62
73
|
* estado. Um motor só, chamado pelo `deploy` no fim e pelo `provision` sozinho — duas implementações
|
|
@@ -77,12 +88,26 @@ export const provisionBackend = async (options) => {
|
|
|
77
88
|
*/
|
|
78
89
|
const office = await readOfficeFunctions(root);
|
|
79
90
|
const deployable = [...manifest.functions, ...office.all];
|
|
80
|
-
const
|
|
81
|
-
const
|
|
91
|
+
const survey = await surveyInstallations(projectId);
|
|
92
|
+
const claims = survey.claims;
|
|
93
|
+
const missing = functionClaimProblems(claims, deployable);
|
|
94
|
+
const overlapping = baselineClaimProblems(claims, manifest.baselineFunctions ?? []);
|
|
95
|
+
// A comum do escritório tem o MESMO desfecho da function da plataforma, e por isso a mesma recusa: o
|
|
96
|
+
// `deploy` a sobe sempre, então reivindicá-la não muda nada — até o dia em que `--prune` a apaga.
|
|
97
|
+
const appropriated = sharedClaimProblems(claims, office.shared);
|
|
98
|
+
// O portão aberto para o id de OUTRA extensão. Só aqui se pode perguntar: é a reivindicação que diz de
|
|
99
|
+
// quem a function é, e ela vem do banco do escritório — o `deploy` não a tem.
|
|
100
|
+
const misgated = misgatedTriggers(office.sources, singleClaimants(claims));
|
|
101
|
+
const problems = [
|
|
102
|
+
...missing,
|
|
103
|
+
...overlapping,
|
|
104
|
+
...appropriated,
|
|
105
|
+
...misgated.map(describeMisgated),
|
|
106
|
+
];
|
|
82
107
|
const rejected = new Set();
|
|
83
|
-
if (
|
|
108
|
+
if (missing.length > 0) {
|
|
84
109
|
warn('some extensions ask for backend artifacts that are nowhere to be found:');
|
|
85
|
-
bullets(
|
|
110
|
+
bullets(missing);
|
|
86
111
|
// A mensagem sozinha é um beco sem saída: ela diz que o nome não existe, e não onde ele deveria
|
|
87
112
|
// existir. As duas respostas possíveis são estas, e a segunda é a que mais acontece — a function é
|
|
88
113
|
// da extensão, o autor a declarou, e ninguém a exportou do `index.ts` do diretório dela.
|
|
@@ -93,18 +118,96 @@ export const provisionBackend = async (options) => {
|
|
|
93
118
|
]);
|
|
94
119
|
info(` this installation currently exports ${office.all.length} function(s) of its own`
|
|
95
120
|
+ `${office.all.length === 0 ? '' : `: ${office.all.join(', ')}`}`);
|
|
121
|
+
}
|
|
122
|
+
if (overlapping.length > 0) {
|
|
123
|
+
warn('some extensions claim a function that belongs to the platform itself:');
|
|
124
|
+
bullets(overlapping);
|
|
125
|
+
info(' every "microteia deploy" puts those up, extension or no extension, so claiming one changes');
|
|
126
|
+
info(' nothing while it is installed — and takes a working trigger out of this project the day it');
|
|
127
|
+
info(' is uninstalled. Rename the extension\'s own function, or drop the name from its');
|
|
128
|
+
info(' manifest.backend.functions.');
|
|
129
|
+
}
|
|
130
|
+
if (appropriated.length > 0) {
|
|
131
|
+
warn('some extensions claim a function that is shared by this whole installation:');
|
|
132
|
+
bullets(appropriated);
|
|
133
|
+
info(' functions/ next to microteia.yaml is the place for a function that serves SEVERAL');
|
|
134
|
+
info(' extensions, so it has no owner and every "microteia deploy" puts it up. Claiming one changes');
|
|
135
|
+
info(' nothing while the extension is installed — and the day it is uninstalled the function turns');
|
|
136
|
+
info(' into an orphan, --prune deletes it, and the next deploy puts it back. Whoever else depended');
|
|
137
|
+
info(' on it loses their backend in a window nothing explains. Move the function into');
|
|
138
|
+
info(' extensions/<folder>/functions/ if it is really this extension\'s, drop the name from its');
|
|
139
|
+
info(' manifest.backend.functions, or — if several extensions need it — name them in "microteia" ->');
|
|
140
|
+
info(' "serves" in functions/package.json, which is how a shared function gets withdrawn.');
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* O portão é RELATADO por inteiro aqui, e COBRADO uma a uma na fila de deploy, mais abaixo.
|
|
144
|
+
*
|
|
145
|
+
* A assimetria é deliberada, e é o que este comando exige: ele é o único capaz de RETIRAR uma function
|
|
146
|
+
* do projeto. Recusar o comando inteiro trancaria `--prune` — que é justamente quem apaga a órfã — por
|
|
147
|
+
* causa de uma fonte que a órfã não tem nada a ver. Então a fonte defeituosa sai da fila, é dita em voz
|
|
148
|
+
* alta, o comando termina em erro por causa dela, e tudo o mais acontece.
|
|
149
|
+
*
|
|
150
|
+
* O relato é de TODAS, e não só das que estão subindo agora: uma fonte com portão faltando é uma
|
|
151
|
+
* function que ninguém vai conseguir deployar, e descobrir isso no dia da instalação da extensão é
|
|
152
|
+
* tarde. Aqui o que ela custa é uma linha.
|
|
153
|
+
*/
|
|
154
|
+
const gateFaults = gateProblems(office.sources);
|
|
155
|
+
const gateFaultBy = new Map(gateFaults.map((fault) => [fault.name, fault.message]));
|
|
156
|
+
if (gateFaults.length > 0) {
|
|
157
|
+
warn(`${gateFaults.length} function(s) written in this installation act outside any office's`
|
|
158
|
+
+ ' installation:');
|
|
159
|
+
bullets(gateFaults.map((fault) => fault.message));
|
|
160
|
+
info(' each one is refused the moment this command tries to deploy IT — the others go up as');
|
|
161
|
+
info(' usual, and a source sitting here for an extension nobody installed blocks nothing.');
|
|
162
|
+
info(' Whatever is already in the air keeps acting on every office in this project.');
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* A comum SEM declaração de quem serve. Ela continua subindo no `deploy`, sempre, e nada a retira — é
|
|
166
|
+
* o comportamento anterior, mantido para não quebrar instalação nenhuma.
|
|
167
|
+
*
|
|
168
|
+
* O que muda é que ele deixa de ser invisível. Era esse o defeito: quem herdasse a instalação não tinha
|
|
169
|
+
* como saber que aquelas functions estavam fora do mecanismo de retirada, e a única forma de descobrir
|
|
170
|
+
* era um gatilho disparando anos depois de a última extensão que o usava ter saído.
|
|
171
|
+
*/
|
|
172
|
+
if (office.unserved.length > 0) {
|
|
173
|
+
blank();
|
|
174
|
+
info(` ${office.unserved.length} shared function(s) do not say which extensions they serve:`);
|
|
175
|
+
bullets(office.unserved);
|
|
176
|
+
info(' they go up with every "microteia deploy" and nothing here withdraws them — not even --prune.');
|
|
177
|
+
info(' Name the extensions each one exists for in "microteia" -> "serves" in functions/package.json,');
|
|
178
|
+
info(' and they are deployed only while one of those is installed, and removed when none is.');
|
|
179
|
+
}
|
|
180
|
+
if (misgated.length > 0) {
|
|
181
|
+
warn('some extensions gate a trigger for an extension id that is not their own:');
|
|
182
|
+
bullets(misgated.map(describeMisgated));
|
|
183
|
+
info(' it is what a copied function leaves behind. The trigger deploys, fires on every write, and');
|
|
184
|
+
info(' the handler never runs — the gate asks about an extension nobody installed. Fix the id in');
|
|
185
|
+
info(' the forInstalledExtension call.');
|
|
186
|
+
}
|
|
187
|
+
if (problems.length > 0) {
|
|
96
188
|
for (const claim of claims) {
|
|
97
189
|
if (problems.some((problem) => problem.includes(claim.pluginId)))
|
|
98
190
|
rejected.add(claim.pluginId);
|
|
99
191
|
}
|
|
192
|
+
// O dono de um portão errado é pendurado por IDENTIDADE, e não por o id dele aparecer no texto: a
|
|
193
|
+
// mensagem cita DOIS ids, e casar por substring penduraria também a extensão cujo id foi copiado.
|
|
194
|
+
for (const entry of misgated)
|
|
195
|
+
rejected.add(entry.owner);
|
|
100
196
|
}
|
|
101
197
|
const valid = uniqueByPlugin(claims.filter((claim) => !rejected.has(claim.pluginId)));
|
|
102
198
|
/**
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
199
|
+
* O nome que uma reivindicação RECUSADA carrega. Ele não entra em `wantedFunctions` — não há como
|
|
200
|
+
* deployá-lo —, mas também não pode virar órfão: a extensão que o pede continua instalada, e o que
|
|
201
|
+
* falhou foi resolver a declaração dela. Sem esta lista, um export renomeado em
|
|
202
|
+
* `extensions/<x>/functions/index.ts` fazia `--prune` apagar a function de uma extensão viva.
|
|
107
203
|
*/
|
|
204
|
+
const unresolved = new Map();
|
|
205
|
+
for (const claim of claims) {
|
|
206
|
+
if (!rejected.has(claim.pluginId))
|
|
207
|
+
continue;
|
|
208
|
+
for (const name of claim.backend.functions ?? [])
|
|
209
|
+
unresolved.set(name, claim.pluginId);
|
|
210
|
+
}
|
|
108
211
|
if (valid.length === 0) {
|
|
109
212
|
info(' no installed extension asks for backend artifacts');
|
|
110
213
|
}
|
|
@@ -113,14 +216,60 @@ export const provisionBackend = async (options) => {
|
|
|
113
216
|
bullets(valid.map((claim) => `${claim.name} ${claim.version} (${claim.pluginId})`));
|
|
114
217
|
}
|
|
115
218
|
const settled = await settleIndexes(options, valid);
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
219
|
+
/**
|
|
220
|
+
* NÃO existe mais um desvio de "nenhuma extensão exige nada" aqui — e é a correção do defeito que
|
|
221
|
+
* este comando tinha justamente no caso mais comum. Desinstalar a ÚLTIMA extensão com backend caía
|
|
222
|
+
* naquele desvio, e o desvio devolvia antes do cálculo de órfã: a Cloud Function dela continuava no
|
|
223
|
+
* ar para sempre, `--prune` não a alcançava, e a lista de aplicadas era regravada intacta. O sintoma
|
|
224
|
+
* era um gatilho `onDocumentWritten` de uma extensão que ninguém tem mais, disparando sobre os dados
|
|
225
|
+
* do escritório a cada gravação.
|
|
226
|
+
*
|
|
227
|
+
* O índice já havia sido salvo dessa mesma armadilha — foi por isso que `settleIndexes` subiu para
|
|
228
|
+
* antes do desvio. A function ficou para trás, e a lição é que o desvio é que estava errado: o que
|
|
229
|
+
* ele pulava não era "trabalho inútil", era a retirada.
|
|
230
|
+
*
|
|
231
|
+
* O que sobrou dele são duas guardas pontuais mais abaixo: não perguntar ao projeto o que ele roda
|
|
232
|
+
* quando não há nada a conferir, e não anunciar que "as 0 functions já estão deployadas".
|
|
233
|
+
*/
|
|
234
|
+
/**
|
|
235
|
+
* A function COMUM que declara quem serve entra aqui, e é o que a tira da invisibilidade.
|
|
236
|
+
*
|
|
237
|
+
* Ela não tem dono que possa reivindicá-la — nenhuma extensão pode —, então ela ficava fora do
|
|
238
|
+
* mecanismo por inteiro: nunca entrava no registro de aplicadas, nunca virava órfã, nunca era
|
|
239
|
+
* relatada, e o `--prune` não a alcançava. Com a declaração, a pergunta passa a ter resposta: ela é
|
|
240
|
+
* devida enquanto ALGUMA das extensões que ela serve estiver instalada em algum escritório do projeto.
|
|
241
|
+
*
|
|
242
|
+
* "Basta uma" é a única regra possível: a function existe porque mais de uma extensão precisa dela.
|
|
243
|
+
* Exigir todas faria desinstalar uma desligar o backend das outras.
|
|
244
|
+
*/
|
|
245
|
+
const sharedWanted = office.served.filter((entry) => entry.serves.some((id) => survey.installedIds.has(id)));
|
|
246
|
+
const sharedWantedNames = new Set(sharedWanted.map((entry) => entry.name));
|
|
247
|
+
const wantedFunctions = deployable.filter((name) => sharedWantedNames.has(name)
|
|
248
|
+
|| valid.some((claim) => (claim.backend.functions ?? []).includes(name)));
|
|
249
|
+
/** Quem reivindica cada nome AGORA — é com este dono que o registro é reconciliado. */
|
|
250
|
+
const owners = new Map();
|
|
251
|
+
for (const claim of valid) {
|
|
252
|
+
for (const name of claim.backend.functions ?? [])
|
|
253
|
+
owners.set(name, claim.pluginId);
|
|
120
254
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const
|
|
255
|
+
// A comum tem VÁRIOS donos, e o registro guarda a lista declarada em vez de eleger um deles: é ela que
|
|
256
|
+
// explica, meses depois, por que aquela function estava ali — e por que ela deixou de estar.
|
|
257
|
+
for (const entry of sharedWanted)
|
|
258
|
+
owners.set(entry.name, entry.serves.join('+'));
|
|
259
|
+
const applied = parseAppliedFunctions(options.environment.backend.functions);
|
|
260
|
+
const appliedNames = new Set(applied.map((entry) => entry.name));
|
|
261
|
+
const classification = classifyApplied({
|
|
262
|
+
applied,
|
|
263
|
+
wanted: wantedFunctions,
|
|
264
|
+
baseline: manifest.baselineFunctions ?? [],
|
|
265
|
+
unresolved,
|
|
266
|
+
});
|
|
267
|
+
// Os do diretório comum entram: são eles que as functions comuns servidas carregam, e um parâmetro
|
|
268
|
+
// que ninguém acompanha é um valor que nunca chega à function que o lê.
|
|
269
|
+
const trackedParameters = [...new Set([
|
|
270
|
+
...claimedParameters(valid),
|
|
271
|
+
...(office.sources.find((source) => source.shared)?.parameters ?? []).map((p) => p.name),
|
|
272
|
+
])].sort();
|
|
124
273
|
// A raiz, e não o `install/`: a fonte é o arquivo que o escritório edita ao lado do `microteia.yaml`.
|
|
125
274
|
const filledParameters = await readBackendEnv(dirname(options.environmentFile), projectId);
|
|
126
275
|
const recordedDigests = parseParameterState(options.environment.backend.parameters);
|
|
@@ -137,13 +286,47 @@ export const provisionBackend = async (options) => {
|
|
|
137
286
|
// instalação fica sem nenhum comando que o aplique, e a feature falha no runtime.
|
|
138
287
|
const changed = changedParameters(recordedDigests, currentDigests, trackedParameters);
|
|
139
288
|
const staleFunctions = new Set(functionsDeclaringParameters(valid, changed)
|
|
140
|
-
.filter((name) => wantedFunctions.includes(name) &&
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
289
|
+
.filter((name) => wantedFunctions.includes(name) && appliedNames.has(name)));
|
|
290
|
+
/**
|
|
291
|
+
* A comum que agora sobe pelo provisionamento também precisa voltar à fila quando um parâmetro muda.
|
|
292
|
+
* Antes ela subia a cada `deploy`, e isso mascarava a pergunta.
|
|
293
|
+
*
|
|
294
|
+
* A granularidade é o DIRETÓRIO, e não a function: `functions/` declara os `defineString` num conjunto
|
|
295
|
+
* só, sem dizer qual function lê qual. Redeployar todas as comuns servidas quando um parâmetro do
|
|
296
|
+
* diretório muda erra para o lado de fazer o que é devido — o inverso deixaria uma delas rodando com o
|
|
297
|
+
* valor antigo, que é o defeito que este mecanismo existe para fechar.
|
|
298
|
+
*/
|
|
299
|
+
const sharedSource = office.sources.find((source) => source.shared);
|
|
300
|
+
const sharedParameters = new Set((sharedSource?.parameters ?? []).map((parameter) => parameter.name));
|
|
301
|
+
if (changed.some((name) => sharedParameters.has(name))) {
|
|
302
|
+
for (const entry of sharedWanted) {
|
|
303
|
+
if (appliedNames.has(entry.name))
|
|
304
|
+
staleFunctions.add(entry.name);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Uma pergunta só ao projeto, e duas decisões a partir dela: o que ele já roda, e qual cópia ficou
|
|
309
|
+
* numa região abandonada. Perguntar duas vezes não é caro, é ambíguo — as duas respostas podem
|
|
310
|
+
* divergir, e aí a decisão passa a depender de qual delas o código olhou.
|
|
311
|
+
*
|
|
312
|
+
* Ela é feita SEMPRE, e a versão anterior a pulava quando não havia function aplicada a conferir —
|
|
313
|
+
* economia que custava exatamente o caso que mais importa. É a instalação sem extensão nenhuma que
|
|
314
|
+
* precisa da pergunta: é ali que mora a function de uma extensão que saiu, e sem perguntar ao projeto
|
|
315
|
+
* a resposta é o silêncio. A ida vale segundos; a alternativa é uma callable órfã que nenhum comando
|
|
316
|
+
* menciona.
|
|
317
|
+
*/
|
|
318
|
+
let asked;
|
|
319
|
+
const projectFunctions = async () => {
|
|
320
|
+
if (asked === undefined)
|
|
321
|
+
asked = await listDeployedFunctions(projectId);
|
|
322
|
+
return asked;
|
|
323
|
+
};
|
|
144
324
|
// O que a lista de nomes nunca respondeu: a function aplicada que sumiu do projeto, e a que segue
|
|
145
325
|
// lá carregando o carimbo de um código anterior. As duas voltam para a fila.
|
|
146
|
-
const
|
|
326
|
+
const appliedAndWanted = wantedFunctions.filter((name) => appliedNames.has(name));
|
|
327
|
+
const outdated = appliedAndWanted.length === 0
|
|
328
|
+
? { absent: [], stale: [] }
|
|
329
|
+
: outdatedByStamp(appliedAndWanted, await projectFunctions(), region, options.refreshedStamps);
|
|
147
330
|
for (const name of [...outdated.absent, ...outdated.stale])
|
|
148
331
|
staleFunctions.add(name);
|
|
149
332
|
if (outdated.absent.length > 0) {
|
|
@@ -180,9 +363,31 @@ export const provisionBackend = async (options) => {
|
|
|
180
363
|
info(' Cloud Function at ITS deploy, so a function already running keeps the previous one.');
|
|
181
364
|
blank();
|
|
182
365
|
}
|
|
183
|
-
const
|
|
366
|
+
const queued = wantedFunctions.filter((name) => options.all === true || !appliedNames.has(name) || staleFunctions.has(name));
|
|
367
|
+
/**
|
|
368
|
+
* A recusa do portão, cobrada de quem está na fila — e só dela.
|
|
369
|
+
*
|
|
370
|
+
* Ela sai da fila em vez de derrubar o comando, e as duas metades disso importam. Sair da fila é o que
|
|
371
|
+
* impede a function de ir para o ar agindo sobre escritório que não a instalou, que é o defeito
|
|
372
|
+
* inteiro. Não derrubar é o que deixa o resto acontecer: as outras extensões recebem o backend delas,
|
|
373
|
+
* os índices convergem, e `--prune` apaga a órfã. O comando termina em erro por causa dela, no fim,
|
|
374
|
+
* quando já não há nada a atrapalhar.
|
|
375
|
+
*/
|
|
376
|
+
const refused = queued.filter((name) => gateFaultBy.has(name));
|
|
377
|
+
const toDeploy = queued.filter((name) => !gateFaultBy.has(name));
|
|
378
|
+
if (refused.length > 0) {
|
|
379
|
+
blank();
|
|
380
|
+
failure(`${refused.length} function(s) are needed by an installed extension and cannot go up:`);
|
|
381
|
+
bullets(refused.map((name) => gateFaultBy.get(name) ?? name));
|
|
382
|
+
info(' they are the only ones held back. Fix the guard and run "microteia provision" again.');
|
|
383
|
+
outcome.failed.push(...refused);
|
|
384
|
+
}
|
|
184
385
|
if (toDeploy.length === 0) {
|
|
185
|
-
|
|
386
|
+
// Anunciar "as 0 functions já estão deployadas" seria o preço de ter tirado o desvio, e não é
|
|
387
|
+
// preciso pagá-lo: quando não há exigência nenhuma, quem já falou foi a linha lá em cima.
|
|
388
|
+
if (wantedFunctions.length > 0 && refused.length === 0) {
|
|
389
|
+
success(`the ${wantedFunctions.length} function(s) these extensions need are already deployed`);
|
|
390
|
+
}
|
|
186
391
|
}
|
|
187
392
|
else {
|
|
188
393
|
// A cópia que o `firebase-functions` lê é atualizada AGORA, e não só no `deploy`: este comando é o
|
|
@@ -199,18 +404,18 @@ export const provisionBackend = async (options) => {
|
|
|
199
404
|
}
|
|
200
405
|
// A extensão que mudou de região deixa a cópia antiga para trás igual ao kernel, e ali o efeito
|
|
201
406
|
// é o mesmo: deploy recusado em toda tentativa, e gatilho disparando em duas regiões.
|
|
202
|
-
await clearRegionOrphans(projectId, toDeploy, region, options.prune,
|
|
407
|
+
await clearRegionOrphans(projectId, toDeploy, region, options.prune, await projectFunctions());
|
|
203
408
|
info(` deploying ${toDeploy.length} function(s), one at a time…`);
|
|
204
409
|
const report = await deployFunctionsOneByOne(toDeploy, {
|
|
205
410
|
projectId, cwd: install, delaySeconds, force: false,
|
|
206
411
|
});
|
|
207
412
|
outcome.deployed = report.deployed;
|
|
208
|
-
outcome.failed
|
|
413
|
+
outcome.failed.push(...report.failed.map((entry) => entry.name));
|
|
209
414
|
reportFailures(report.failed);
|
|
210
415
|
reportNotices(report.notices);
|
|
211
416
|
await checkNewCallables([...(manifest.callables ?? []), ...office.callables], report.deployed, projectId, region);
|
|
212
417
|
}
|
|
213
|
-
const deployedNow = new Set([...
|
|
418
|
+
const deployedNow = new Set([...appliedNames, ...outcome.deployed]);
|
|
214
419
|
for (const claim of valid) {
|
|
215
420
|
const satisfied = (claim.backend.functions ?? []).every((name) => deployedNow.has(name));
|
|
216
421
|
if (satisfied) {
|
|
@@ -220,14 +425,49 @@ export const provisionBackend = async (options) => {
|
|
|
220
425
|
outcome.pending.push(claim);
|
|
221
426
|
}
|
|
222
427
|
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
428
|
+
/**
|
|
429
|
+
* O que o PROJETO roda, cruzado com o que esta instalação consegue justificar.
|
|
430
|
+
*
|
|
431
|
+
* O registro respondia sozinho por essa pergunta, e é um registro que se perde — e quando se perde,
|
|
432
|
+
* some com ele a única memória de que aquela function existe. Perguntar ao projeto é o que fecha o
|
|
433
|
+
* buraco: uma callable de extensão desinstalada, no ar, publicamente invocável, com o
|
|
434
|
+
* `backend.functions` do `microteia.yaml` vazio, não era mencionada por comando nenhum.
|
|
435
|
+
*
|
|
436
|
+
* O limite está em `straysInProject`, e é o que torna isto seguro: só vira órfã o nome que ESTA
|
|
437
|
+
* instalação sabe produzir. O que a órfã ganha aqui, além da remoção, é entrar no registro quando não
|
|
438
|
+
* for removida — a partir da próxima execução ela é conhecida mesmo que o código-fonte dela saia do
|
|
439
|
+
* disco.
|
|
440
|
+
*/
|
|
441
|
+
const running = await projectFunctions();
|
|
442
|
+
const stray = straysInProject({
|
|
443
|
+
// A região é o filtro, e o ESTADO não é. Uma cópia em região abandonada tem endereço próprio
|
|
444
|
+
// (`clearRegionOrphans`); já o estado só decide se dá para comparar carimbo — para existir no
|
|
445
|
+
// projeto, basta estar na listagem. E o `functions:list` nem sempre traz o campo: exigir `ACTIVE`
|
|
446
|
+
// deixaria a varredura cega em silêncio no dia em que ele viesse vazio.
|
|
447
|
+
running: (running ?? []).filter((entry) => entry.region === region).map((entry) => entry.name),
|
|
448
|
+
known: [...manifest.functions, ...office.all],
|
|
449
|
+
justified: [
|
|
450
|
+
...wantedFunctions,
|
|
451
|
+
// A baseline do PACOTE, com o mesmo fallback que o `deploy` usa para decidir o que subir. Um
|
|
452
|
+
// `?? []` aqui faria toda function da plataforma virar órfã num pacote sem o campo.
|
|
453
|
+
...(manifest.baselineFunctions ?? manifest.functions),
|
|
454
|
+
...office.unserved,
|
|
455
|
+
...unresolved.keys(),
|
|
456
|
+
],
|
|
457
|
+
recorded: applied.map((entry) => entry.name),
|
|
458
|
+
});
|
|
459
|
+
reportStrays(stray, running);
|
|
460
|
+
reportPreserved(classification.preserved);
|
|
461
|
+
const orphans = [...classification.orphans, ...stray.orphans];
|
|
462
|
+
outcome.orphans = orphans.map((entry) => entry.name);
|
|
463
|
+
const remaining = await settleOrphans(options, orphans, survey);
|
|
226
464
|
await recordBackendState(options, appliedAfterRun({
|
|
227
|
-
|
|
465
|
+
applied,
|
|
228
466
|
wanted: wantedFunctions,
|
|
229
467
|
deployed: outcome.deployed,
|
|
230
468
|
keptOrphans: remaining,
|
|
469
|
+
preserved: classification.preserved.map((item) => item.entry),
|
|
470
|
+
owners,
|
|
231
471
|
}), appliedParameterState({
|
|
232
472
|
recorded: recordedDigests,
|
|
233
473
|
current: currentDigests,
|
|
@@ -236,8 +476,37 @@ export const provisionBackend = async (options) => {
|
|
|
236
476
|
claims: valid,
|
|
237
477
|
}), settled.declared);
|
|
238
478
|
reportOrphanIndexes(projectId, settled.orphans, options.prune);
|
|
479
|
+
await publishResidue(projectId, remaining, settled.orphans, survey);
|
|
239
480
|
return outcome;
|
|
240
481
|
};
|
|
482
|
+
/**
|
|
483
|
+
* O que sobrou, devolvido ao banco para a aplicação poder mostrá-lo.
|
|
484
|
+
*
|
|
485
|
+
* A lista é a que sobra DEPOIS do `--prune`: o que ele apagou saiu, e o que ele não conseguiu apagar
|
|
486
|
+
* fica — é o estado do projeto agora, e não o do começo da execução.
|
|
487
|
+
*
|
|
488
|
+
* Falhar aqui não derruba o comando, e a exceção é justificada: as functions e os índices já foram
|
|
489
|
+
* aplicados, e o que ficou de fazer era um RELATO. Recusar o `provision` inteiro por causa dele mandaria
|
|
490
|
+
* quem instala rodar de novo um comando que já terminou o trabalho. Mas o aviso é dito em voz alta —
|
|
491
|
+
* calar deixaria a tela silenciosa parecendo "não há resíduo", que é a afirmação oposta.
|
|
492
|
+
*/
|
|
493
|
+
const publishResidue = async (projectId, functions, indexes, survey) => {
|
|
494
|
+
try {
|
|
495
|
+
await writeBackendResidue(projectId, {
|
|
496
|
+
scannedAt: new Date(),
|
|
497
|
+
tenants: survey.tenants,
|
|
498
|
+
extensions: survey.extensions,
|
|
499
|
+
functions: functions.map((entry) => ({ name: entry.name, owner: entry.owner ?? '' })),
|
|
500
|
+
indexes: indexes.map(describeIndex),
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
catch (error) {
|
|
504
|
+
warn('could not record what is left over where the application can show it:');
|
|
505
|
+
info(` ${error instanceof Error ? error.message : String(error)}`);
|
|
506
|
+
info(' the artifacts above were applied. What did not happen is the Extensions screen learning');
|
|
507
|
+
info(' about the leftovers — it keeps showing what the previous run recorded.');
|
|
508
|
+
}
|
|
509
|
+
};
|
|
241
510
|
/**
|
|
242
511
|
* O que o projeto tem de declarar de índice composto, e o que sobrou de uma extensão que saiu.
|
|
243
512
|
*
|
|
@@ -320,34 +589,110 @@ const stampApplied = async (projectId, all, claim) => {
|
|
|
320
589
|
success(`${claim.name} is ready in ${projectId}`);
|
|
321
590
|
};
|
|
322
591
|
/**
|
|
323
|
-
*
|
|
324
|
-
*
|
|
592
|
+
* O que ninguém exige e mesmo assim NÃO é órfã.
|
|
593
|
+
*
|
|
594
|
+
* Relatar é o ponto. As duas causas são configuração errada de um lado ou do outro, e o silêncio sobre
|
|
595
|
+
* elas faria a próxima pessoa a abrir o `microteia.yaml` concluir que a lista está suja — e apagar à
|
|
596
|
+
* mão exatamente o que estas linhas protegem.
|
|
597
|
+
*/
|
|
598
|
+
const reportPreserved = (preserved) => {
|
|
599
|
+
const baseline = preserved.filter((item) => item.reason === 'baseline');
|
|
600
|
+
const unresolved = preserved.filter((item) => item.reason === 'unresolved');
|
|
601
|
+
if (baseline.length > 0) {
|
|
602
|
+
blank();
|
|
603
|
+
warn(`${baseline.length} function(s) in this installation's record belong to the platform:`);
|
|
604
|
+
bullets(baseline.map((item) => describeApplied(item.entry)));
|
|
605
|
+
info(' every "microteia deploy" puts them up, extension or no extension, so nothing here removes');
|
|
606
|
+
info(' them — not even --prune.');
|
|
607
|
+
}
|
|
608
|
+
if (unresolved.length > 0) {
|
|
609
|
+
blank();
|
|
610
|
+
warn(`${unresolved.length} function(s) belong to an extension whose declaration did not resolve:`);
|
|
611
|
+
bullets(unresolved.map((item) => `${item.entry.name} — claimed by ${item.claimant}`));
|
|
612
|
+
info(' the extension is still installed, so they are not orphans and nothing removes them.');
|
|
613
|
+
info(' Fix what is reported above and run "microteia provision" again.');
|
|
614
|
+
}
|
|
615
|
+
};
|
|
616
|
+
/**
|
|
617
|
+
* O que a varredura do projeto achou, e que o registro não conhecia.
|
|
618
|
+
*
|
|
619
|
+
* As duas listas são ditas porque respondem a perguntas diferentes de quem lê. A órfã é uma decisão a
|
|
620
|
+
* tomar — ela vai para `settleOrphans` logo abaixo, e sai com `--prune`. O estranho não é decisão
|
|
621
|
+
* nenhuma: é um aviso de que o projeto Firebase tem inquilino que a Microteia não pôs ali, e dizer que
|
|
622
|
+
* nada o toca vale mais do que o silêncio, porque o silêncio aqui parece "não há mais nada".
|
|
623
|
+
*/
|
|
624
|
+
const reportStrays = (stray, running) => {
|
|
625
|
+
if (running === null) {
|
|
626
|
+
blank();
|
|
627
|
+
warn('could not list what this project is running, so nothing was compared against it.');
|
|
628
|
+
info(' a function left behind by an extension that is gone would not be found this run. The');
|
|
629
|
+
info(' account needs permission to run "firebase functions:list".');
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
if (stray.orphans.length > 0) {
|
|
633
|
+
blank();
|
|
634
|
+
info(` ${stray.orphans.length} function(s) are running in this project and this installation's`);
|
|
635
|
+
info(' record does not mention them:');
|
|
636
|
+
bullets(stray.orphans.map((entry) => entry.name));
|
|
637
|
+
info(' this installation is what produces them, and nothing installed needs them — so they are');
|
|
638
|
+
info(' treated as orphans below. A record is lost by a re-init, a restored backup, or a Console');
|
|
639
|
+
info(' older than the field; the function it forgot stays in the air either way.');
|
|
640
|
+
}
|
|
641
|
+
if (stray.strangers.length > 0) {
|
|
642
|
+
blank();
|
|
643
|
+
info(` ${stray.strangers.length} function(s) run in this project and did not come from this`);
|
|
644
|
+
info(' installation:');
|
|
645
|
+
bullets(stray.strangers);
|
|
646
|
+
info(' nothing here touches them — not even --prune. A Firebase project can be shared, and');
|
|
647
|
+
info(' deleting what Microteia did not put there is not this command\'s call.');
|
|
648
|
+
info(' A function of yours lands here once its source is gone from this installation: run');
|
|
649
|
+
info(' "microteia provision" BEFORE deleting extensions/<folder>/, and it is recognized.');
|
|
650
|
+
}
|
|
651
|
+
};
|
|
652
|
+
/**
|
|
653
|
+
* Function que nenhuma extensão instalada exige mais.
|
|
654
|
+
*
|
|
655
|
+
* A CERTEZA vem antes da remoção, e é dita em voz alta: a Cloud Function é do PROJETO e a instalação é
|
|
656
|
+
* do ESCRITÓRIO, então "ninguém mais exige" é uma afirmação sobre TODOS os escritórios do projeto, e
|
|
657
|
+
* não sobre o último que foi lido. A varredura é completa ou não existe — `listDocuments` pagina até o
|
|
658
|
+
* fim e qualquer leitura recusada derruba o comando lá em cima —, e é o número dito aqui que permite a
|
|
659
|
+
* quem lê conferir a afirmação em vez de acreditar nela.
|
|
660
|
+
*
|
|
661
|
+
* Apagar é destrutivo e irreversível, então o padrão RELATA e só `--prune` remove — e o que foi
|
|
662
|
+
* removido sai da lista de aplicadas.
|
|
325
663
|
*/
|
|
326
|
-
const settleOrphans = async (options, orphans) => {
|
|
664
|
+
const settleOrphans = async (options, orphans, survey) => {
|
|
327
665
|
if (orphans.length === 0)
|
|
328
666
|
return [];
|
|
329
667
|
blank();
|
|
668
|
+
warn(`${orphans.length} function(s) are deployed and no installed extension needs them:`);
|
|
669
|
+
bullets(orphans.map(describeApplied));
|
|
670
|
+
info(` read from every office in this project: ${survey.tenants} office(s), `
|
|
671
|
+
+ `${survey.extensions} installed extension(s).`);
|
|
330
672
|
if (!options.prune) {
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
673
|
+
// O custo mudou de descrição porque a descrição estava errada. Uma callable ociosa não custa nada;
|
|
674
|
+
// um gatilho de Firestore não fica ocioso — ele segue disparando a cada gravação do escritório,
|
|
675
|
+
// rodando o código de uma extensão que ninguém tem mais, e é esse efeito que faz a remoção valer
|
|
676
|
+
// a pena antes de "algum dia".
|
|
677
|
+
info(' a callable just sits there. A Firestore trigger does not: it keeps firing on every write,');
|
|
678
|
+
info(' running the code of an extension nobody has any more. To remove them:');
|
|
334
679
|
bullets(['microteia provision --prune']);
|
|
335
680
|
blank();
|
|
336
681
|
return orphans;
|
|
337
682
|
}
|
|
338
683
|
info(` deleting ${orphans.length} orphan function(s)…`);
|
|
339
|
-
const deleted =
|
|
340
|
-
for (const
|
|
684
|
+
const deleted = new Set();
|
|
685
|
+
for (const orphan of orphans) {
|
|
341
686
|
try {
|
|
342
|
-
await run(FIREBASE_BIN, ['functions:delete', name, '--project', options.projectId, '--region', options.region, '--force'], { cwd: options.install });
|
|
343
|
-
deleted.
|
|
687
|
+
await run(FIREBASE_BIN, ['functions:delete', orphan.name, '--project', options.projectId, '--region', options.region, '--force'], { cwd: options.install });
|
|
688
|
+
deleted.add(orphan.name);
|
|
344
689
|
}
|
|
345
690
|
catch {
|
|
346
|
-
warn(`could not delete ${name}; it stays recorded as deployed`);
|
|
691
|
+
warn(`could not delete ${orphan.name}; it stays recorded as deployed`);
|
|
347
692
|
}
|
|
348
693
|
}
|
|
349
|
-
success(`${deleted.
|
|
350
|
-
return orphans.filter((
|
|
694
|
+
success(`${deleted.size} orphan function(s) removed`);
|
|
695
|
+
return orphans.filter((orphan) => !deleted.has(orphan.name));
|
|
351
696
|
};
|
|
352
697
|
/**
|
|
353
698
|
* Índice órfão é RELATADO, nunca apagado às cegas: o `firebase deploy` não remove índice por conta, e a
|
|
@@ -380,16 +725,6 @@ const reportOrphanIndexes = (projectId, orphans, prune) => {
|
|
|
380
725
|
]);
|
|
381
726
|
blank();
|
|
382
727
|
};
|
|
383
|
-
/**
|
|
384
|
-
* A lista aplicada CONVERGE para o que ainda é exigido: o que já estava e continua sendo pedido, mais
|
|
385
|
-
* o que subiu agora, mais o órfão que não foi removido. Reescrevê-la só com o que subiu NESTA execução
|
|
386
|
-
* a esvaziava na segunda passada — e a terceira redeployava tudo, achando que nada havia sido feito.
|
|
387
|
-
*/
|
|
388
|
-
export const appliedAfterRun = (input) => [...new Set([
|
|
389
|
-
...input.alreadyApplied.filter((name) => input.wanted.includes(name)),
|
|
390
|
-
...input.deployed,
|
|
391
|
-
...input.keptOrphans,
|
|
392
|
-
])].sort();
|
|
393
728
|
/**
|
|
394
729
|
* A impressão digital do parâmetro só avança quando TODAS as functions que o carregam subiram. Gravar
|
|
395
730
|
* antes disso é o que transformaria uma falha de deploy em silêncio permanente: o valor novo ficaria
|
|
@@ -414,7 +749,7 @@ export const appliedParameterState = (input) => {
|
|
|
414
749
|
}
|
|
415
750
|
return next;
|
|
416
751
|
};
|
|
417
|
-
const recordBackendState = async (options,
|
|
752
|
+
const recordBackendState = async (options, applied, parameters, indexes) => {
|
|
418
753
|
const state = serializeIndexState(indexes);
|
|
419
754
|
/**
|
|
420
755
|
* Índice que a codificação não representa sem ambiguidade fica de fora COM AVISO, em vez de voltar do
|
|
@@ -431,7 +766,7 @@ const recordBackendState = async (options, names, parameters, indexes) => {
|
|
|
431
766
|
await writeEnvironment(options.environmentFile, {
|
|
432
767
|
...options.environment,
|
|
433
768
|
backend: {
|
|
434
|
-
functions:
|
|
769
|
+
functions: serializeAppliedFunctions(applied),
|
|
435
770
|
parameters: serializeParameterState(parameters),
|
|
436
771
|
indexes: state.value,
|
|
437
772
|
},
|