@microteia/console 2.18.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 +8 -0
- package/README.md +1 -1
- package/lib/console/appliedFunctions.d.ts +35 -0
- package/lib/console/appliedFunctions.js +35 -0
- package/lib/console/customFunctions.js +6 -7
- package/lib/console/deploy.js +14 -0
- package/lib/console/functionGate.d.ts +36 -6
- package/lib/console/functionGate.js +56 -19
- package/lib/console/provision.js +117 -25
- package/lib/shared/paths.d.ts +1 -1
- package/lib/shared/paths.js +1 -1
- package/package.json +1 -1
package/CONSOLE-VERSIONS.md
CHANGED
|
@@ -22,6 +22,14 @@
|
|
|
22
22
|
- fix(command): what was broken, and what it did to whoever hit it.
|
|
23
23
|
-->
|
|
24
24
|
|
|
25
|
+
#### [2.19.0] — 2026-08-28
|
|
26
|
+
|
|
27
|
+
- fix(cli/deploy): the installation gate is charged against the functions that are GOING UP, not against every source found on disk. It lived in `functionProblems` — the door that answers "can this set be assembled?" — and that door reads every `extensions/<x>/functions/` directory that exists, including one left behind by an extension that was uninstalled and removed. The result was `microteia deploy` refusing in full — hosting, rules and indexes with it — over a callable no command would have deployed. `gateProblems` now carries the function's name, and `gateProblemsFor` filters by what is being deployed.
|
|
28
|
+
- fix(cli/provision): a gated function that fails the check leaves the deploy queue instead of taking the command down with it. Everything else provisions, `--prune` still removes the orphan, and the command ends in an error naming the function that was held back.
|
|
29
|
+
- feat(cli/provision): orphan functions are found in the PROJECT, not only in the record. `backend.functions` in `microteia.yaml` was the single source of truth about what had been deployed, and it is a truth that gets lost — a redone `init`, a restored backup, a Console older than the field. Lost it, and the Cloud Function of an extension nobody has stayed in the air with no command mentioning it. `straysInProject` crosses `firebase functions:list` with what the installation can justify; only a name this installation knows how to produce can become an orphan, and what it does not know is reported and never touched.
|
|
30
|
+
- feat(cli/provision): an orphan found that way enters the record when it is not removed, so it stays known from the next run on even if its source leaves the disk.
|
|
31
|
+
- fix(cli/provision): `functions:list` is asked on every run. It was skipped when there was nothing applied to check — the exact case that needed it, since an installation with no extensions left is where the departed extension's function sits. When the listing cannot be read, the command says so instead of letting silence read as "nothing left over".
|
|
32
|
+
|
|
25
33
|
#### [2.18.0] — 2026-08-28
|
|
26
34
|
|
|
27
35
|
- feat(console/functionGate): an extension's callable without `requireInstalledExtension` is refused, and a guard naming another extension's id is rejected the way a misgated trigger is. The refusal exists because the deploy makes every callable publicly invocable — this Console's own `callableAccess.ts` is there to confirm that grant went through — so the callable answers any signed-in user of the project, from any organization, whether or not they installed the extension, with the organization arriving in the payload.
|
package/README.md
CHANGED
|
@@ -77,3 +77,38 @@ export declare const appliedAfterRun: (input: {
|
|
|
77
77
|
}) => AppliedFunction[];
|
|
78
78
|
/** Como uma function aplicada aparece num relatório: sem dono conhecido, só o nome. */
|
|
79
79
|
export declare const describeApplied: (entry: AppliedFunction) => string;
|
|
80
|
+
export interface StrayFunctions {
|
|
81
|
+
/** Roda no projeto, esta instalação sabe produzi-la, e nada a justifica agora: órfã de verdade. */
|
|
82
|
+
orphans: AppliedFunction[];
|
|
83
|
+
/** Roda no projeto e esta instalação não a conhece. Relatada, nunca apagada. */
|
|
84
|
+
strangers: string[];
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* A órfã que o REGISTRO não conhece — e o limite que impede a varredura de virar um estrago.
|
|
88
|
+
*
|
|
89
|
+
* O registro (`backend.functions` do `microteia.yaml`) era a ÚNICA verdade sobre o que havia sido
|
|
90
|
+
* deployado, e é uma verdade que se perde: um Console anterior ao campo, um `init` refeito, um deploy
|
|
91
|
+
* dado à mão, um arquivo restaurado de um backup mais velho. Perdido o registro, `classifyApplied` não
|
|
92
|
+
* tinha o que classificar, `--prune` não tinha o que apagar, e a Cloud Function de uma extensão que
|
|
93
|
+
* ninguém tem mais ficava no ar para sempre, sem nenhum comando que a mencionasse. É o pior sintoma da
|
|
94
|
+
* família: uma callable publicamente invocável, de código que ninguém instalou, e silêncio.
|
|
95
|
+
*
|
|
96
|
+
* A resposta é perguntar ao PROJETO o que ele roda, em vez de só ao arquivo. E o limite é `known`: só é
|
|
97
|
+
* candidata a órfã a function que ESTA instalação sabe produzir — o que o pacote traz mais o que o
|
|
98
|
+
* escritório escreveu. O que o projeto roda e a instalação não conhece pode ser de qualquer um: um
|
|
99
|
+
* projeto Firebase é compartilhável, e apagar ali seria a Microteia decidir sobre o que não é dela.
|
|
100
|
+
* Esse caso é relatado e nunca tocado.
|
|
101
|
+
*
|
|
102
|
+
* `recorded` sai do resultado porque quem responde por ele é `classifyApplied`, que sabe mais: ele tem o
|
|
103
|
+
* dono e as duas razões de preservação. Contar duas vezes a mesma function proporia apagá-la duas vezes.
|
|
104
|
+
*/
|
|
105
|
+
export declare const straysInProject: (input: {
|
|
106
|
+
/** O que o projeto roda AGORA, na região do kernel. */
|
|
107
|
+
running: string[];
|
|
108
|
+
/** O que esta instalação sabe produzir: o pacote mais o que o escritório escreveu. */
|
|
109
|
+
known: string[];
|
|
110
|
+
/** O que tem razão de estar no ar agora: exigida, baseline, comum sem declaração, ou não resolvida. */
|
|
111
|
+
justified: string[];
|
|
112
|
+
/** O que o registro já conhece — respondido por `classifyApplied`. */
|
|
113
|
+
recorded: string[];
|
|
114
|
+
}) => StrayFunctions;
|
|
@@ -86,3 +86,38 @@ export const appliedAfterRun = (input) => {
|
|
|
86
86
|
};
|
|
87
87
|
/** Como uma function aplicada aparece num relatório: sem dono conhecido, só o nome. */
|
|
88
88
|
export const describeApplied = (entry) => entry.owner === null ? entry.name : `${entry.name} (${entry.owner})`;
|
|
89
|
+
/**
|
|
90
|
+
* A órfã que o REGISTRO não conhece — e o limite que impede a varredura de virar um estrago.
|
|
91
|
+
*
|
|
92
|
+
* O registro (`backend.functions` do `microteia.yaml`) era a ÚNICA verdade sobre o que havia sido
|
|
93
|
+
* deployado, e é uma verdade que se perde: um Console anterior ao campo, um `init` refeito, um deploy
|
|
94
|
+
* dado à mão, um arquivo restaurado de um backup mais velho. Perdido o registro, `classifyApplied` não
|
|
95
|
+
* tinha o que classificar, `--prune` não tinha o que apagar, e a Cloud Function de uma extensão que
|
|
96
|
+
* ninguém tem mais ficava no ar para sempre, sem nenhum comando que a mencionasse. É o pior sintoma da
|
|
97
|
+
* família: uma callable publicamente invocável, de código que ninguém instalou, e silêncio.
|
|
98
|
+
*
|
|
99
|
+
* A resposta é perguntar ao PROJETO o que ele roda, em vez de só ao arquivo. E o limite é `known`: só é
|
|
100
|
+
* candidata a órfã a function que ESTA instalação sabe produzir — o que o pacote traz mais o que o
|
|
101
|
+
* escritório escreveu. O que o projeto roda e a instalação não conhece pode ser de qualquer um: um
|
|
102
|
+
* projeto Firebase é compartilhável, e apagar ali seria a Microteia decidir sobre o que não é dela.
|
|
103
|
+
* Esse caso é relatado e nunca tocado.
|
|
104
|
+
*
|
|
105
|
+
* `recorded` sai do resultado porque quem responde por ele é `classifyApplied`, que sabe mais: ele tem o
|
|
106
|
+
* dono e as duas razões de preservação. Contar duas vezes a mesma function proporia apagá-la duas vezes.
|
|
107
|
+
*/
|
|
108
|
+
export const straysInProject = (input) => {
|
|
109
|
+
const known = new Set(input.known);
|
|
110
|
+
const justified = new Set(input.justified);
|
|
111
|
+
const recorded = new Set(input.recorded);
|
|
112
|
+
const stray = { orphans: [], strangers: [] };
|
|
113
|
+
for (const name of [...new Set(input.running)].sort()) {
|
|
114
|
+
if (!known.has(name)) {
|
|
115
|
+
stray.strangers.push(name);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (justified.has(name) || recorded.has(name))
|
|
119
|
+
continue;
|
|
120
|
+
stray.orphans.push({ name, owner: null });
|
|
121
|
+
}
|
|
122
|
+
return stray;
|
|
123
|
+
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { access, cp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { basename, join } from 'node:path';
|
|
3
3
|
import { callableFunctionNames, declaredParametersIn } from '../kernel/manifest.js';
|
|
4
|
-
import { callableDeclarations, triggerDeclarations,
|
|
4
|
+
import { callableDeclarations, triggerDeclarations, unscopedDeclarations, } from './functionGate.js';
|
|
5
5
|
/**
|
|
6
6
|
* As Cloud Functions do ESCRITÓRIO — as que ele escreve, não as do kernel.
|
|
7
7
|
*
|
|
@@ -273,12 +273,11 @@ export const functionProblems = (sources, kernelFunctions) => {
|
|
|
273
273
|
}
|
|
274
274
|
}
|
|
275
275
|
problems.push(...servesProblems(sources));
|
|
276
|
-
// O portão de instalação entra aqui
|
|
277
|
-
//
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
problems.push(...unguardedCallableProblems(sources));
|
|
276
|
+
// O portão de instalação NÃO entra aqui. Esta porta responde "dá para montar o conjunto?", e a
|
|
277
|
+
// resposta dela vale para toda fonte que existe no disco — inclusive a da extensão que foi
|
|
278
|
+
// desinstalada e cujo diretório ficou. O portão responde outra coisa: "esta function pode ir para o
|
|
279
|
+
// ar?", e ela só é devida de quem vai. Quem pergunta é `gateProblemsFor`, no ponto em que os nomes
|
|
280
|
+
// que sobem já estão decididos: `deploy` contra a baseline mais as comuns, `provision` contra a fila.
|
|
282
281
|
return problems;
|
|
283
282
|
};
|
|
284
283
|
const barrel = (sources) => `// Generated by "microteia deploy". Do not edit.
|
package/lib/console/deploy.js
CHANGED
|
@@ -7,6 +7,7 @@ import { ENVIRONMENT_FILE, readEnvironment, writeEnvironment } from './environme
|
|
|
7
7
|
import { FIREBASE_BIN } from './firebaseCli.js';
|
|
8
8
|
import { reportFailures, reportNotices } from './deployFunctions.js';
|
|
9
9
|
import { clearRegionOrphans, listDeployedFunctions } from './regionOrphans.js';
|
|
10
|
+
import { gateProblemsFor } from './functionGate.js';
|
|
10
11
|
import { deployFunctionsThatChanged } from './functionsUpToDate.js';
|
|
11
12
|
import { checkCallables, invokerGrantCommand } from './callableAccess.js';
|
|
12
13
|
import { checkHostingServed, htmlIsRevalidated } from './hostingCheck.js';
|
|
@@ -138,6 +139,19 @@ export const deploy = async (args) => {
|
|
|
138
139
|
* impede esta versão de tirar do ar function de uma instalação que já existe.
|
|
139
140
|
*/
|
|
140
141
|
const own = [...baseline, ...gathered.unserved];
|
|
142
|
+
/**
|
|
143
|
+
* O portão de instalação, cobrado do que ESTE passo sobe — e não de toda fonte que existe no
|
|
144
|
+
* disco.
|
|
145
|
+
*
|
|
146
|
+
* Na prática ele quase nunca tem o que dizer aqui, e isso é a confirmação de que o lugar é este:
|
|
147
|
+
* o que sobe no `deploy` é a baseline (do kernel) e a comum sem declaração (de quem não há id a
|
|
148
|
+
* cobrar). É no `provision` que a function de extensão entra na fila, e é lá que a recusa morde.
|
|
149
|
+
* Cobrar aqui mantém a regra atada ao ato — se um dia esta lista crescer, ela vem coberta.
|
|
150
|
+
*/
|
|
151
|
+
const refused = gateProblemsFor(gathered.sources, own);
|
|
152
|
+
if (refused.length > 0) {
|
|
153
|
+
throw new Error(`These Cloud Functions cannot go up as they are:\n${refused.map((line) => ` ${line}`).join('\n')}`);
|
|
154
|
+
}
|
|
141
155
|
step(current, total, `Cloud Functions (${own.length}, one at a time)`);
|
|
142
156
|
if (gathered.sources.length > 0) {
|
|
143
157
|
success(`${gathered.functions.length} function(s) of your own, from `
|
|
@@ -13,8 +13,11 @@
|
|
|
13
13
|
* código de uma extensão que ninguém tem mais rodando sobre os dados de quem nunca a pediu, sem erro em
|
|
14
14
|
* lugar nenhum.
|
|
15
15
|
*
|
|
16
|
-
* É recusa e não aviso pelo mesmo motivo do nome de function repetido
|
|
17
|
-
*
|
|
16
|
+
* É recusa, e não aviso, pelo mesmo motivo do nome de function repetido: o defeito é invisível depois de
|
|
17
|
+
* instalado, e um aviso na saída de um deploy é lido uma vez. Mas ela é cobrada da function que VAI
|
|
18
|
+
* SUBIR, e não de toda fonte que existe no disco — ver `gateProblemsFor`. Toda origem é lida, sempre, e
|
|
19
|
+
* o que não vai subir é relatado pelo `provision`: quem tem a fonte parada ao lado do `microteia.yaml`
|
|
20
|
+
* fica sabendo dela, sem ter o comando trancado por ela.
|
|
18
21
|
*
|
|
19
22
|
* A análise é por texto, no idioma dos outros leitores de fonte do Console (`callableFunctionNames`,
|
|
20
23
|
* `declaredParametersIn`). Ela erra para o lado ALTO — o que ela não entende ela recusa —, e é por isso
|
|
@@ -105,6 +108,22 @@ export interface GateSubject {
|
|
|
105
108
|
/** As callables declaradas, com o estado da guarda de cada uma. */
|
|
106
109
|
callableGuards: TriggerDeclaration[];
|
|
107
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* Um problema de portão, e a function a que ele pertence.
|
|
113
|
+
*
|
|
114
|
+
* O nome está aqui porque é ele que decide o PESO do problema. A verificação lê toda fonte que existe no
|
|
115
|
+
* disco — é assim que ela acha o que precisa achar —, mas o que existe no disco não é o que vai subir:
|
|
116
|
+
* `extensions/<x>/functions/` de uma extensão que ninguém instalou é lida, copiada e nunca deployada.
|
|
117
|
+
* Sem o nome, quem chama só tem a frase, e a única coisa que dá para fazer com uma frase é recusar tudo.
|
|
118
|
+
* Foi o que acontecia: uma extensão desinstalada, com a fonte parada ao lado do `microteia.yaml`,
|
|
119
|
+
* trancava o `deploy` inteiro — hosting, rules e índices junto — por causa de uma function que nenhum
|
|
120
|
+
* comando teria subido.
|
|
121
|
+
*/
|
|
122
|
+
export interface GateProblem {
|
|
123
|
+
/** A function a que o problema pertence. É por ela que se sabe se ela vai, de fato, subir. */
|
|
124
|
+
name: string;
|
|
125
|
+
message: string;
|
|
126
|
+
}
|
|
108
127
|
/**
|
|
109
128
|
* Gatilho de extensão sem portão.
|
|
110
129
|
*
|
|
@@ -113,7 +132,7 @@ export interface GateSubject {
|
|
|
113
132
|
* endereço próprio — enquanto ela não fechar, exigir aqui um portão impossível seria recusar a única
|
|
114
133
|
* forma que a plataforma oferece para uma function compartilhada.
|
|
115
134
|
*/
|
|
116
|
-
export declare const ungatedTriggerProblems: (subjects: GateSubject[]) =>
|
|
135
|
+
export declare const ungatedTriggerProblems: (subjects: GateSubject[]) => GateProblem[];
|
|
117
136
|
/**
|
|
118
137
|
* Function de extensão cujo evento não tem escritório, e que não passa pelo laço.
|
|
119
138
|
*
|
|
@@ -123,7 +142,7 @@ export declare const ungatedTriggerProblems: (subjects: GateSubject[]) => string
|
|
|
123
142
|
* `functions/` fica de fora pelo mesmo motivo de sempre: a comum não tem um id de extensão para dar ao
|
|
124
143
|
* laço. Quem responde por ela é a declaração de quem ela serve.
|
|
125
144
|
*/
|
|
126
|
-
export declare const unscopedTriggerProblems: (subjects: GateSubject[]) =>
|
|
145
|
+
export declare const unscopedTriggerProblems: (subjects: GateSubject[]) => GateProblem[];
|
|
127
146
|
/**
|
|
128
147
|
* Gatilho de uma function COMUM que declara quem serve, e não guarda ninguém.
|
|
129
148
|
*
|
|
@@ -139,7 +158,7 @@ export declare const unscopedTriggerProblems: (subjects: GateSubject[]) => strin
|
|
|
139
158
|
* respondem à mesma pergunta em dois lugares, e divergirem faz uma delas mentir — a `serves` decide a
|
|
140
159
|
* retirada, a guarda decide a execução, e a function passaria a agir onde não pode ser retirada.
|
|
141
160
|
*/
|
|
142
|
-
export declare const unguardedSharedProblems: (subjects: GateSubject[]) =>
|
|
161
|
+
export declare const unguardedSharedProblems: (subjects: GateSubject[]) => GateProblem[];
|
|
143
162
|
/**
|
|
144
163
|
* Callable de extensão sem a guarda de instalação.
|
|
145
164
|
*
|
|
@@ -157,7 +176,18 @@ export declare const unguardedSharedProblems: (subjects: GateSubject[]) => strin
|
|
|
157
176
|
* conhecida e está registrada — a guarda "alguma das que eu sirvo" existe para gatilho, não para
|
|
158
177
|
* callable.
|
|
159
178
|
*/
|
|
160
|
-
export declare const unguardedCallableProblems: (subjects: GateSubject[]) =>
|
|
179
|
+
export declare const unguardedCallableProblems: (subjects: GateSubject[]) => GateProblem[];
|
|
180
|
+
/** Todo problema de portão de um conjunto de origens, na ordem em que as mensagens os citam. */
|
|
181
|
+
export declare const gateProblems: (subjects: GateSubject[]) => GateProblem[];
|
|
182
|
+
/**
|
|
183
|
+
* Os problemas das functions que ESTÃO subindo — a lista que autoriza uma recusa.
|
|
184
|
+
*
|
|
185
|
+
* É a diferença entre "a fonte foi lida" e "a function vai para o ar", e ela não é sutil: a primeira
|
|
186
|
+
* inclui toda extensão cujo diretório ficou no disco depois de desinstalada, e recusar por causa dela é
|
|
187
|
+
* recusar um comando que não faria nada de errado. A segunda é o momento em que a recusa tem
|
|
188
|
+
* consequência — e é o único em que ela é devida.
|
|
189
|
+
*/
|
|
190
|
+
export declare const gateProblemsFor: (subjects: GateSubject[], deploying: string[]) => string[];
|
|
161
191
|
export interface MisgatedTrigger {
|
|
162
192
|
/** A origem, como as mensagens a citam. */
|
|
163
193
|
label: string;
|
|
@@ -13,8 +13,11 @@
|
|
|
13
13
|
* código de uma extensão que ninguém tem mais rodando sobre os dados de quem nunca a pediu, sem erro em
|
|
14
14
|
* lugar nenhum.
|
|
15
15
|
*
|
|
16
|
-
* É recusa e não aviso pelo mesmo motivo do nome de function repetido
|
|
17
|
-
*
|
|
16
|
+
* É recusa, e não aviso, pelo mesmo motivo do nome de function repetido: o defeito é invisível depois de
|
|
17
|
+
* instalado, e um aviso na saída de um deploy é lido uma vez. Mas ela é cobrada da function que VAI
|
|
18
|
+
* SUBIR, e não de toda fonte que existe no disco — ver `gateProblemsFor`. Toda origem é lida, sempre, e
|
|
19
|
+
* o que não vai subir é relatado pelo `provision`: quem tem a fonte parada ao lado do `microteia.yaml`
|
|
20
|
+
* fica sabendo dela, sem ter o comando trancado por ela.
|
|
18
21
|
*
|
|
19
22
|
* A análise é por texto, no idioma dos outros leitores de fonte do Console (`callableFunctionNames`,
|
|
20
23
|
* `declaredParametersIn`). Ela erra para o lado ALTO — o que ela não entende ela recusa —, e é por isso
|
|
@@ -269,9 +272,12 @@ export const ungatedTriggerProblems = (subjects) => subjects
|
|
|
269
272
|
.filter((subject) => !subject.shared)
|
|
270
273
|
.flatMap((subject) => exportedTriggers(subject)
|
|
271
274
|
.filter((trigger) => trigger.gatedFor === null)
|
|
272
|
-
.map((trigger) =>
|
|
273
|
-
|
|
274
|
-
|
|
275
|
+
.map((trigger) => ({
|
|
276
|
+
name: trigger.name,
|
|
277
|
+
message: `${subject.label} exports "${trigger.name}", a ${trigger.trigger} trigger with no `
|
|
278
|
+
+ `installation gate — wrap its handler in ${GATE}('<this extension's id>', …) or it fires for `
|
|
279
|
+
+ 'every office in the project, including the ones that never installed the extension',
|
|
280
|
+
})));
|
|
275
281
|
/**
|
|
276
282
|
* Function de extensão cujo evento não tem escritório, e que não passa pelo laço.
|
|
277
283
|
*
|
|
@@ -285,10 +291,13 @@ export const unscopedTriggerProblems = (subjects) => subjects
|
|
|
285
291
|
.filter((subject) => !subject.shared)
|
|
286
292
|
.flatMap((subject) => exportedUnscoped(subject)
|
|
287
293
|
.filter((trigger) => trigger.gatedFor === null)
|
|
288
|
-
.map((trigger) =>
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
294
|
+
.map((trigger) => ({
|
|
295
|
+
name: trigger.name,
|
|
296
|
+
message: `${subject.label} exports "${trigger.name}", a ${trigger.trigger} function whose `
|
|
297
|
+
+ `event carries no {tenantId} — ${GATE} cannot gate it (it fails closed, so the function would `
|
|
298
|
+
+ `never run at all). Loop with ${OFFICE_LOOP}('<this extension's id>', …) inside the handler, or `
|
|
299
|
+
+ 'it acts on every office in the project, including the ones that never installed the extension',
|
|
300
|
+
})));
|
|
292
301
|
/**
|
|
293
302
|
* Gatilho de uma function COMUM que declara quem serve, e não guarda ninguém.
|
|
294
303
|
*
|
|
@@ -311,16 +320,22 @@ export const unguardedSharedProblems = (subjects) => subjects
|
|
|
311
320
|
if (serves === undefined || serves.length === 0)
|
|
312
321
|
return [];
|
|
313
322
|
if (trigger.guards.length === 0) {
|
|
314
|
-
return [
|
|
315
|
-
|
|
316
|
-
|
|
323
|
+
return [{
|
|
324
|
+
name: trigger.name,
|
|
325
|
+
message: `${subject.label} exports "${trigger.name}", a ${trigger.trigger} trigger declared as `
|
|
326
|
+
+ `serving ${serves.join(', ')} — guard it with ${ANY_GATE}([${serves.map((id) => `'${id}'`).join(', ')}], …) `
|
|
327
|
+
+ 'or it fires for every office in the project, including the ones that installed none of them',
|
|
328
|
+
}];
|
|
317
329
|
}
|
|
318
330
|
const stranger = trigger.guards.filter((id) => !serves.includes(id));
|
|
319
331
|
if (stranger.length === 0)
|
|
320
332
|
return [];
|
|
321
|
-
return [
|
|
322
|
-
|
|
323
|
-
|
|
333
|
+
return [{
|
|
334
|
+
name: trigger.name,
|
|
335
|
+
message: `${subject.label} guards "${trigger.name}" for ${stranger.join(', ')}, which it does not `
|
|
336
|
+
+ `declare serving in microteia.serves (${serves.join(', ')}) — the two lists answer the same `
|
|
337
|
+
+ 'question, and one of them is wrong',
|
|
338
|
+
}];
|
|
324
339
|
}));
|
|
325
340
|
/**
|
|
326
341
|
* Callable de extensão sem a guarda de instalação.
|
|
@@ -343,10 +358,32 @@ export const unguardedCallableProblems = (subjects) => subjects
|
|
|
343
358
|
.filter((subject) => !subject.shared)
|
|
344
359
|
.flatMap((subject) => exportedCallables(subject)
|
|
345
360
|
.filter((callable) => callable.gatedFor === null)
|
|
346
|
-
.map((callable) =>
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
361
|
+
.map((callable) => ({
|
|
362
|
+
name: callable.name,
|
|
363
|
+
message: `${subject.label} exports "${callable.name}", a callable with no installation `
|
|
364
|
+
+ `check — call ${CALLABLE_GUARD}('<this extension's id>', tenantId, request) before you read the `
|
|
365
|
+
+ 'payload. The deploy makes every callable publicly invocable, so it answers any signed-in user '
|
|
366
|
+
+ 'of this project, from any office, whether or not they installed the extension',
|
|
367
|
+
})));
|
|
368
|
+
/** Todo problema de portão de um conjunto de origens, na ordem em que as mensagens os citam. */
|
|
369
|
+
export const gateProblems = (subjects) => [
|
|
370
|
+
...ungatedTriggerProblems(subjects),
|
|
371
|
+
...unscopedTriggerProblems(subjects),
|
|
372
|
+
...unguardedSharedProblems(subjects),
|
|
373
|
+
...unguardedCallableProblems(subjects),
|
|
374
|
+
];
|
|
375
|
+
/**
|
|
376
|
+
* Os problemas das functions que ESTÃO subindo — a lista que autoriza uma recusa.
|
|
377
|
+
*
|
|
378
|
+
* É a diferença entre "a fonte foi lida" e "a function vai para o ar", e ela não é sutil: a primeira
|
|
379
|
+
* inclui toda extensão cujo diretório ficou no disco depois de desinstalada, e recusar por causa dela é
|
|
380
|
+
* recusar um comando que não faria nada de errado. A segunda é o momento em que a recusa tem
|
|
381
|
+
* consequência — e é o único em que ela é devida.
|
|
382
|
+
*/
|
|
383
|
+
export const gateProblemsFor = (subjects, deploying) => {
|
|
384
|
+
const going = new Set(deploying);
|
|
385
|
+
return gateProblems(subjects).filter((problem) => going.has(problem.name)).map((p) => p.message);
|
|
386
|
+
};
|
|
350
387
|
/**
|
|
351
388
|
* Portão aberto para o id ERRADO.
|
|
352
389
|
*
|
package/lib/console/provision.js
CHANGED
|
@@ -17,8 +17,8 @@ import { asString, asTimestamp, patchMapField } from './firestoreRest.js';
|
|
|
17
17
|
import { writeBackendResidue } from './backendResidue.js';
|
|
18
18
|
import { listTenants, readInstalledExtensions } from './installedExtensions.js';
|
|
19
19
|
import { gatherCustomFunctions, readOfficeFunctions } from './customFunctions.js';
|
|
20
|
-
import { describeMisgated, misgatedTriggers, singleClaimants,
|
|
21
|
-
import { appliedAfterRun, classifyApplied, describeApplied, parseAppliedFunctions, serializeAppliedFunctions, } from './appliedFunctions.js';
|
|
20
|
+
import { describeMisgated, gateProblems, misgatedTriggers, singleClaimants, } from './functionGate.js';
|
|
21
|
+
import { appliedAfterRun, classifyApplied, describeApplied, parseAppliedFunctions, serializeAppliedFunctions, straysInProject, } from './appliedFunctions.js';
|
|
22
22
|
/**
|
|
23
23
|
* O que cada escritório do projeto instalou e exige do backend.
|
|
24
24
|
*
|
|
@@ -140,22 +140,26 @@ export const provisionBackend = async (options) => {
|
|
|
140
140
|
info(' "serves" in functions/package.json, which is how a shared function gets withdrawn.');
|
|
141
141
|
}
|
|
142
142
|
/**
|
|
143
|
-
* O
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
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.
|
|
147
153
|
*/
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
];
|
|
153
|
-
if (ungated.length > 0) {
|
|
154
|
-
warn(`${ungated.length} function(s) written in this installation act outside any office's`
|
|
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`
|
|
155
158
|
+ ' installation:');
|
|
156
|
-
bullets(
|
|
157
|
-
info('
|
|
158
|
-
info('
|
|
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.');
|
|
159
163
|
}
|
|
160
164
|
/**
|
|
161
165
|
* A comum SEM declaração de quem serve. Ela continua subindo no `deploy`, sempre, e nada a retira — é
|
|
@@ -305,10 +309,11 @@ export const provisionBackend = async (options) => {
|
|
|
305
309
|
* numa região abandonada. Perguntar duas vezes não é caro, é ambíguo — as duas respostas podem
|
|
306
310
|
* divergir, e aí a decisão passa a depender de qual delas o código olhou.
|
|
307
311
|
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
*
|
|
311
|
-
* resposta que
|
|
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.
|
|
312
317
|
*/
|
|
313
318
|
let asked;
|
|
314
319
|
const projectFunctions = async () => {
|
|
@@ -358,11 +363,29 @@ export const provisionBackend = async (options) => {
|
|
|
358
363
|
info(' Cloud Function at ITS deploy, so a function already running keeps the previous one.');
|
|
359
364
|
blank();
|
|
360
365
|
}
|
|
361
|
-
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
|
+
}
|
|
362
385
|
if (toDeploy.length === 0) {
|
|
363
386
|
// Anunciar "as 0 functions já estão deployadas" seria o preço de ter tirado o desvio, e não é
|
|
364
387
|
// preciso pagá-lo: quando não há exigência nenhuma, quem já falou foi a linha lá em cima.
|
|
365
|
-
if (wantedFunctions.length > 0) {
|
|
388
|
+
if (wantedFunctions.length > 0 && refused.length === 0) {
|
|
366
389
|
success(`the ${wantedFunctions.length} function(s) these extensions need are already deployed`);
|
|
367
390
|
}
|
|
368
391
|
}
|
|
@@ -387,7 +410,7 @@ export const provisionBackend = async (options) => {
|
|
|
387
410
|
projectId, cwd: install, delaySeconds, force: false,
|
|
388
411
|
});
|
|
389
412
|
outcome.deployed = report.deployed;
|
|
390
|
-
outcome.failed
|
|
413
|
+
outcome.failed.push(...report.failed.map((entry) => entry.name));
|
|
391
414
|
reportFailures(report.failed);
|
|
392
415
|
reportNotices(report.notices);
|
|
393
416
|
await checkNewCallables([...(manifest.callables ?? []), ...office.callables], report.deployed, projectId, region);
|
|
@@ -402,9 +425,42 @@ export const provisionBackend = async (options) => {
|
|
|
402
425
|
outcome.pending.push(claim);
|
|
403
426
|
}
|
|
404
427
|
}
|
|
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);
|
|
405
460
|
reportPreserved(classification.preserved);
|
|
406
|
-
|
|
407
|
-
|
|
461
|
+
const orphans = [...classification.orphans, ...stray.orphans];
|
|
462
|
+
outcome.orphans = orphans.map((entry) => entry.name);
|
|
463
|
+
const remaining = await settleOrphans(options, orphans, survey);
|
|
408
464
|
await recordBackendState(options, appliedAfterRun({
|
|
409
465
|
applied,
|
|
410
466
|
wanted: wantedFunctions,
|
|
@@ -557,6 +613,42 @@ const reportPreserved = (preserved) => {
|
|
|
557
613
|
info(' Fix what is reported above and run "microteia provision" again.');
|
|
558
614
|
}
|
|
559
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
|
+
};
|
|
560
652
|
/**
|
|
561
653
|
* Function que nenhuma extensão instalada exige mais.
|
|
562
654
|
*
|
package/lib/shared/paths.d.ts
CHANGED
|
@@ -87,4 +87,4 @@ export declare const consoleRoot: string;
|
|
|
87
87
|
* de código nunca chegou a quem instala. Este bloco fica sendo o que ele já era antes de crescer: a
|
|
88
88
|
* política de semver da ferramenta, mais o registro do que motivou as versões até a `2.5.0`.
|
|
89
89
|
*/
|
|
90
|
-
export declare const CLI_VERSION = "2.
|
|
90
|
+
export declare const CLI_VERSION = "2.19.0";
|
package/lib/shared/paths.js
CHANGED
|
@@ -94,4 +94,4 @@ export const consoleRoot = resolve(here, '..', '..');
|
|
|
94
94
|
* de código nunca chegou a quem instala. Este bloco fica sendo o que ele já era antes de crescer: a
|
|
95
95
|
* política de semver da ferramenta, mais o registro do que motivou as versões até a `2.5.0`.
|
|
96
96
|
*/
|
|
97
|
-
export const CLI_VERSION = '2.
|
|
97
|
+
export const CLI_VERSION = '2.19.0';
|