@microteia/console 2.7.1 → 2.8.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 +12 -0
- package/README.md +1 -1
- package/lib/console/deploy.js +28 -5
- package/lib/console/functionsUpToDate.d.ts +55 -0
- package/lib/console/functionsUpToDate.js +192 -0
- package/lib/console/provision.d.ts +12 -0
- package/lib/console/provision.js +27 -3
- package/lib/console/regionOrphans.d.ts +22 -1
- package/lib/console/regionOrphans.js +15 -3
- package/lib/index.js +2 -2
- package/lib/shared/paths.d.ts +1 -1
- package/lib/shared/paths.js +1 -1
- package/lib/vendor/exampleSources.js +1 -0
- package/lib/vendor/installationDocs.js +15 -2
- package/package.json +1 -1
package/CONSOLE-VERSIONS.md
CHANGED
|
@@ -22,6 +22,18 @@
|
|
|
22
22
|
- fix(command): what was broken, and what it did to whoever hit it.
|
|
23
23
|
-->
|
|
24
24
|
|
|
25
|
+
#### [2.8.0] — 2026-08-25
|
|
26
|
+
|
|
27
|
+
- feat(console/functionsUpToDate): new module. It reconstructs the skip that `firebase deploy` performs in bulk and that `--only functions:<name>` disables, WITHOUT reimplementing the hash: functions are grouped by the `firebase-functions-hash` label they carry, one member of each group is deployed, and the stamp it comes back with is the stamp of the current code. The probe being a MEMBER of its group is what makes the conclusion carry to the others — two functions with the same old stamp share source, environment and secret set, the three parcels of the hash. Absent, non-`ACTIVE`, unstamped or wrong-region always deploys: not knowing never becomes skipping.
|
|
28
|
+
- feat(console/deploy): asks the project once (`functions:list --json`) and feeds both decisions from that one answer — the region orphan check and the up-to-date check. Two questions could disagree, and then the decision depends on which answer the code happened to read.
|
|
29
|
+
- feat(console/provision): `outdatedByStamp` requeues an applied extension function that carries a stamp older than what this package builds, or that is missing from the project. The stamp comes from the probes the `deploy` step just ran — extension functions live in the same codebase, so it is their stamp too. Called on its own, provisioning decides as it always did: probing there would cost a deploy on every extension install to answer a question that is almost always "nothing".
|
|
30
|
+
- feat(console): `--all` on `deploy` and `provision` deploys every function without asking the project what it runs.
|
|
31
|
+
- fix(console/regionOrphans): `listDeployedFunctions` keeps `hash`, `state` and `platform` instead of discarding everything but id and region; `clearRegionOrphans` accepts an already-fetched listing.
|
|
32
|
+
|
|
33
|
+
#### [2.7.2] — 2026-08-25
|
|
34
|
+
|
|
35
|
+
- feat(vendor/exampleSources): stages `automation-example` alongside the others, so the workspace ships a complete automation to read. Every example that travelled carried `workflows: []`, and automation was the one mechanism an author had only prose for.
|
|
36
|
+
|
|
25
37
|
#### [2.7.1] — 2026-08-24
|
|
26
38
|
|
|
27
39
|
- fix(unpack): the default catalog URL is `https://microteia.github.io/releases/kernel/index.json`.
|
package/README.md
CHANGED
package/lib/console/deploy.js
CHANGED
|
@@ -5,8 +5,9 @@ import { run } from '../shared/run.js';
|
|
|
5
5
|
import { blank, bullets, failure, heading, info, step, success, warn } from '../shared/ui.js';
|
|
6
6
|
import { ENVIRONMENT_FILE, readEnvironment, writeEnvironment } from './environment.js';
|
|
7
7
|
import { FIREBASE_BIN } from './firebaseCli.js';
|
|
8
|
-
import {
|
|
9
|
-
import { clearRegionOrphans } from './regionOrphans.js';
|
|
8
|
+
import { reportFailures, reportNotices } from './deployFunctions.js';
|
|
9
|
+
import { clearRegionOrphans, listDeployedFunctions } from './regionOrphans.js';
|
|
10
|
+
import { deployFunctionsThatChanged } from './functionsUpToDate.js';
|
|
10
11
|
import { checkCallables, invokerGrantCommand } from './callableAccess.js';
|
|
11
12
|
import { checkHostingServed, htmlIsRevalidated } from './hostingCheck.js';
|
|
12
13
|
import { provisionBackend } from './provision.js';
|
|
@@ -83,6 +84,12 @@ export const deploy = async (args) => {
|
|
|
83
84
|
const failures = [];
|
|
84
85
|
const unreachableCallables = [];
|
|
85
86
|
let staleHosting = false;
|
|
87
|
+
let skippedFunctions = 0;
|
|
88
|
+
// O que as sondas do passo de functions descobriram: carimbo antigo -> carimbo de agora. O
|
|
89
|
+
// provisionamento está no MESMO codebase, então esse é o carimbo das functions de extensão também.
|
|
90
|
+
// Segue `undefined` quando o passo de functions não rodou: ali não há conclusão a tirar, e um mapa
|
|
91
|
+
// vazio diria a mesma coisa por acaso, e não por decisão.
|
|
92
|
+
let refreshedStamps;
|
|
86
93
|
for (const component of components) {
|
|
87
94
|
current += 1;
|
|
88
95
|
if (component === 'rules') {
|
|
@@ -132,16 +139,25 @@ export const deploy = async (args) => {
|
|
|
132
139
|
info(' npm may warn about the Node engine and about deprecated packages: both are expected —');
|
|
133
140
|
info(' "engines" names the Cloud Functions runtime, and the notices come from Firebase\'s deps.');
|
|
134
141
|
await run('npm', ['install', '--no-audit', '--no-fund'], { cwd: join(install, 'functions') });
|
|
142
|
+
// Uma pergunta só ao projeto, e duas decisões a partir dela: qual cópia ficou numa região
|
|
143
|
+
// abandonada, e o que ele já roda. Perguntar duas vezes não é caro, é ambíguo — as duas
|
|
144
|
+
// respostas podem divergir, e aí a decisão passa a depender de qual delas o código olhou.
|
|
145
|
+
const deployedNow = await listDeployedFunctions(projectId);
|
|
135
146
|
// ANTES de deployar: uma cópia da mesma function numa região que o pacote abandonou faz o
|
|
136
147
|
// `firebase` recusar o deploy dela, tentativa após tentativa, e faz um gatilho de Firestore
|
|
137
148
|
// disparar duas vezes por gravação enquanto ninguém percebe.
|
|
138
|
-
await clearRegionOrphans(projectId, own, region, hasFlag(args, 'prune'));
|
|
139
|
-
|
|
149
|
+
await clearRegionOrphans(projectId, own, region, hasFlag(args, 'prune'), deployedNow);
|
|
150
|
+
// Numa instalação nova não há carimbo nenhum com que comparar, e o mecanismo cai sozinho no
|
|
151
|
+
// caminho de sempre — não é preciso um desvio para isso. `--all` é o pedido explícito de subir
|
|
152
|
+
// tudo, para a configuração trocada por fora, que carimbo nenhum enxerga.
|
|
153
|
+
const report = await deployFunctionsThatChanged(own, {
|
|
140
154
|
projectId,
|
|
141
155
|
cwd: install,
|
|
142
156
|
delaySeconds,
|
|
143
157
|
force: firstInstall,
|
|
144
|
-
});
|
|
158
|
+
}, { deployed: deployedNow, region, all: hasFlag(args, 'all') });
|
|
159
|
+
refreshedStamps = report.refreshed;
|
|
160
|
+
skippedFunctions = report.skipped.length;
|
|
145
161
|
if (report.failed.length > 0) {
|
|
146
162
|
failures.push(`${report.failed.length} function(s): ${report.failed.map((entry) => entry.name).join(', ')}`);
|
|
147
163
|
blank();
|
|
@@ -227,6 +243,8 @@ export const deploy = async (args) => {
|
|
|
227
243
|
manifest,
|
|
228
244
|
delaySeconds,
|
|
229
245
|
prune: hasFlag(args, 'prune'),
|
|
246
|
+
all: hasFlag(args, 'all'),
|
|
247
|
+
refreshedStamps,
|
|
230
248
|
environmentFile: file,
|
|
231
249
|
environment: applied,
|
|
232
250
|
});
|
|
@@ -242,6 +260,11 @@ export const deploy = async (args) => {
|
|
|
242
260
|
blank();
|
|
243
261
|
throw new Error('Some components did not deploy.');
|
|
244
262
|
}
|
|
263
|
+
if (skippedFunctions > 0) {
|
|
264
|
+
info(`${skippedFunctions} Cloud Function(s) were already running this exact code and were left alone.`);
|
|
265
|
+
bullets(['microteia deploy --all', ' deploys every one of them, whatever the project already runs']);
|
|
266
|
+
blank();
|
|
267
|
+
}
|
|
245
268
|
info(`Microteia ${manifest.version} is installed in ${projectId}.`);
|
|
246
269
|
bullets([`https://${projectId}.web.app`]);
|
|
247
270
|
blank();
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { FunctionDeployOptions, FunctionDeployReport } from './deployFunctions.js';
|
|
2
|
+
import type { DeployedFunction } from './regionOrphans.js';
|
|
3
|
+
/** As functions que o projeto roda com exatamente o mesmo carimbo. */
|
|
4
|
+
export interface HashGroup {
|
|
5
|
+
hash: string;
|
|
6
|
+
platform: string;
|
|
7
|
+
names: string[];
|
|
8
|
+
}
|
|
9
|
+
export interface DeployPlan {
|
|
10
|
+
/**
|
|
11
|
+
* Sem carimbo com que comparar: ausente do projeto, em outra região, no meio de um deploy, ou
|
|
12
|
+
* deployada por um CLI que não gravava o label. Sobem sempre — não saber nunca vira pular.
|
|
13
|
+
*/
|
|
14
|
+
unverifiable: string[];
|
|
15
|
+
groups: HashGroup[];
|
|
16
|
+
}
|
|
17
|
+
export declare const planFunctionDeploy: (wanted: string[], deployed: DeployedFunction[] | null, region: string) => DeployPlan;
|
|
18
|
+
export interface UpToDateReport extends FunctionDeployReport {
|
|
19
|
+
/** Não subiram porque o projeto já roda exatamente este código. */
|
|
20
|
+
skipped: string[];
|
|
21
|
+
/**
|
|
22
|
+
* O carimbo antigo de cada grupo sondado, e o que a sonda dele voltou carregando. É o que deixa o
|
|
23
|
+
* provisionamento decidir sobre as functions de extensão sem sondar de novo: elas estão no MESMO
|
|
24
|
+
* codebase, então o carimbo de agora é o mesmo.
|
|
25
|
+
*/
|
|
26
|
+
refreshed: Map<string, string>;
|
|
27
|
+
}
|
|
28
|
+
export interface UpToDateContext {
|
|
29
|
+
/** A listagem que o comando já fez, para não perguntar duas vezes a mesma coisa ao projeto. */
|
|
30
|
+
deployed: DeployedFunction[] | null;
|
|
31
|
+
region: string;
|
|
32
|
+
/** `--all`: sobe todas, sem perguntar ao projeto o que ele já roda. */
|
|
33
|
+
all: boolean;
|
|
34
|
+
}
|
|
35
|
+
export declare const deployFunctionsThatChanged: (wanted: string[], options: FunctionDeployOptions, context: UpToDateContext) => Promise<UpToDateReport>;
|
|
36
|
+
/**
|
|
37
|
+
* A function de extensão que o projeto roda e que este pacote não constrói mais assim.
|
|
38
|
+
*
|
|
39
|
+
* Ela existe porque o provisionamento sempre decidiu por NOME: uma function já aplicada só voltava
|
|
40
|
+
* para a fila quando um parâmetro dela mudava de valor. Editar o código em
|
|
41
|
+
* `extensions/<plugin>/functions/` não movia nada — a fonte mudava, o pacote era remontado, e a
|
|
42
|
+
* function seguia rodando a versão anterior sem que comando nenhum a alcançasse.
|
|
43
|
+
*
|
|
44
|
+
* O carimbo responde isso de graça, e sem sondar nada: a function de extensão está no MESMO codebase
|
|
45
|
+
* da baseline, então o carimbo que a sonda do `deploy` acabou de observar é o dela também.
|
|
46
|
+
*/
|
|
47
|
+
export interface OutdatedFunctions {
|
|
48
|
+
/** Não estão rodando em lugar nenhum do projeto, apesar de a lista de nomes dizer que subiram. */
|
|
49
|
+
absent: string[];
|
|
50
|
+
/** Estão lá, e carregam um carimbo anterior ao que este pacote constrói. */
|
|
51
|
+
stale: string[];
|
|
52
|
+
}
|
|
53
|
+
export declare const outdatedByStamp: (applied: string[], deployed: DeployedFunction[] | null, region: string,
|
|
54
|
+
/** Carimbo antigo -> carimbo de agora, observado pelas sondas. Ausente: não há o que concluir. */
|
|
55
|
+
refreshed: Map<string, string> | undefined) => OutdatedFunctions;
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { blank, info, success, warn } from '../shared/ui.js';
|
|
2
|
+
import { deployFunctionsOneByOne } from './deployFunctions.js';
|
|
3
|
+
import { listDeployedFunctions } from './regionOrphans.js';
|
|
4
|
+
/**
|
|
5
|
+
* O deploy que não sobe de novo o que o projeto já roda.
|
|
6
|
+
*
|
|
7
|
+
* O `firebase` sabe responder isso, e sempre soube: ele grava em cada function o label
|
|
8
|
+
* `firebase-functions-hash` — sha1 do diretório de fontes INTEIRO, mais as variáveis de ambiente
|
|
9
|
+
* resolvidas, mais as versões de segredo — e num deploy em lote pula toda function cujo carimbo
|
|
10
|
+
* gravado é igual ao que o código atual produz. O que desliga esse pulo é justamente o
|
|
11
|
+
* `--only functions:<nome>` com que o Console deploya: `planner.js` só pula endpoint que
|
|
12
|
+
* `!targetedByOnly`, e nomear a function no `--only` a marca. Deployar uma a uma não é opcional (em
|
|
13
|
+
* lote estoura a quota de CPU do projeto), então o pulo teve de ser reconstruído aqui.
|
|
14
|
+
*
|
|
15
|
+
* **Não é um mecanismo de detecção novo, e essa é a decisão inteira.** Não há digest nosso, não há
|
|
16
|
+
* lista de insumos que alguém possa esquecer de atualizar, não há estado guardado que possa divergir
|
|
17
|
+
* do projeto. O carimbo é do `firebase`: calculado por ele, gravado por ele na function, lido do
|
|
18
|
+
* projeto a cada execução. Comparar por ele é tomar a MESMA decisão que o `firebase deploy` tomaria.
|
|
19
|
+
*
|
|
20
|
+
* O que faltava é o outro lado da comparação: o carimbo que o código de AGORA produziria. Calculá-lo
|
|
21
|
+
* aqui exigiria reimplementar o algoritmo dele — e uma reimplementação que envelhece em silêncio é
|
|
22
|
+
* exatamente o que não se quer num comando que decide NÃO publicar. Em vez disso ele é OBSERVADO: as
|
|
23
|
+
* functions são agrupadas pelo carimbo que carregam, UMA de cada grupo é deployada, e o carimbo que
|
|
24
|
+
* ela volta carregando é o carimbo do código de agora. Voltou igual, o grupo inteiro é byte a byte o
|
|
25
|
+
* que o projeto já roda; voltou diferente, o grupo inteiro sobe.
|
|
26
|
+
*
|
|
27
|
+
* A sonda ser MEMBRO do grupo é o que faz a conclusão valer para os outros: duas functions com o
|
|
28
|
+
* mesmo carimbo antigo têm, além da mesma fonte e do mesmo ambiente, o mesmo conjunto de segredos —
|
|
29
|
+
* é a terceira parcela do hash. Uma sonda de fora do grupo não provaria isso.
|
|
30
|
+
*
|
|
31
|
+
* Ler o carimbo da sonda logo depois de ela subir depende de o `functions:list` já enxergar o que o
|
|
32
|
+
* deploy acabou de gravar. Não é uma aposta nossa: é a MESMA leitura de que o `firebase deploy`
|
|
33
|
+
* depende para montar o `haveBackend` com que ele decide o que pular, e o comando só retorna depois
|
|
34
|
+
* de a operação do Google ter terminado.
|
|
35
|
+
*
|
|
36
|
+
* O custo do pior caso é o comportamento de hoje; o do caso bom é uma function em vez de 28.
|
|
37
|
+
*
|
|
38
|
+
* O que ele NÃO enxerga — porque o `firebase deploy` em lote também não — é configuração trocada por
|
|
39
|
+
* fora, pelo console do Google: memória, timeout, concorrência, política de invocação. Para isso
|
|
40
|
+
* existe `--all`. O alcance das callables continua conferido em TODAS a cada deploy, inclusive nas
|
|
41
|
+
* que não subiram, e é ele que acusa uma concessão de IAM removida por fora.
|
|
42
|
+
*/
|
|
43
|
+
const ACTIVE = 'ACTIVE';
|
|
44
|
+
const SEPARATOR = ' ';
|
|
45
|
+
export const planFunctionDeploy = (wanted, deployed, region) => {
|
|
46
|
+
if (deployed === null)
|
|
47
|
+
return { unverifiable: [...wanted], groups: [] };
|
|
48
|
+
const byName = new Map();
|
|
49
|
+
for (const entry of deployed) {
|
|
50
|
+
if (entry.region === region)
|
|
51
|
+
byName.set(entry.name, entry);
|
|
52
|
+
}
|
|
53
|
+
const unverifiable = [];
|
|
54
|
+
const grouped = new Map();
|
|
55
|
+
for (const name of wanted) {
|
|
56
|
+
const entry = byName.get(name);
|
|
57
|
+
if (entry === undefined || entry.state !== ACTIVE || entry.hash === null) {
|
|
58
|
+
unverifiable.push(name);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
// A plataforma entra na chave porque `gcfv1` e `gcfv2` empacotam fontes diferentes e carimbam
|
|
62
|
+
// hashes diferentes: o carimbo de uma nunca fala pela outra.
|
|
63
|
+
const key = `${entry.platform}${SEPARATOR}${entry.hash}`;
|
|
64
|
+
const names = grouped.get(key);
|
|
65
|
+
if (names === undefined)
|
|
66
|
+
grouped.set(key, [name]);
|
|
67
|
+
else
|
|
68
|
+
names.push(name);
|
|
69
|
+
}
|
|
70
|
+
const groups = [...grouped.entries()].map(([key, names]) => {
|
|
71
|
+
const [platform, hash] = key.split(SEPARATOR);
|
|
72
|
+
return { hash, platform, names };
|
|
73
|
+
});
|
|
74
|
+
return { unverifiable, groups };
|
|
75
|
+
};
|
|
76
|
+
const merge = (left, right) => ({
|
|
77
|
+
deployed: [...left.deployed, ...right.deployed],
|
|
78
|
+
failed: [...left.failed, ...right.failed],
|
|
79
|
+
notices: [...new Set([...left.notices, ...right.notices])],
|
|
80
|
+
});
|
|
81
|
+
const plural = (count, singular) => (count === 1 ? singular : `${singular}s`);
|
|
82
|
+
export const deployFunctionsThatChanged = async (wanted, options, context) => {
|
|
83
|
+
const plain = async (names) => ({
|
|
84
|
+
...(await deployFunctionsOneByOne(names, options)),
|
|
85
|
+
skipped: [],
|
|
86
|
+
refreshed: new Map(),
|
|
87
|
+
});
|
|
88
|
+
if (wanted.length === 0)
|
|
89
|
+
return plain(wanted);
|
|
90
|
+
if (context.all) {
|
|
91
|
+
info(` --all: deploying all ${wanted.length}, without asking the project what it already runs.`);
|
|
92
|
+
return plain(wanted);
|
|
93
|
+
}
|
|
94
|
+
const plan = planFunctionDeploy(wanted, context.deployed, context.region);
|
|
95
|
+
if (plan.groups.length === 0) {
|
|
96
|
+
// Nada com que comparar. Numa instalação nova é o caso normal, e é o certo: não há nada lá.
|
|
97
|
+
return plain(wanted);
|
|
98
|
+
}
|
|
99
|
+
const comparable = plan.groups.reduce((count, group) => count + group.names.length, 0);
|
|
100
|
+
const probes = plan.groups.map((group) => group.names[0]);
|
|
101
|
+
const candidates = comparable - probes.length;
|
|
102
|
+
info(` ${comparable} of the ${wanted.length} already run in this project carrying a Firebase stamp`);
|
|
103
|
+
info(` of the source, settings and secrets they were built with. Deploying ${probes.length} reference`);
|
|
104
|
+
info(` ${plural(probes.length, 'function')} first (${probes.join(', ')}): if the stamp comes back`);
|
|
105
|
+
info(` unchanged, the other ${candidates} are what the project already runs and are left alone.`);
|
|
106
|
+
info(' It is the same comparison "firebase deploy" makes when it deploys in bulk.');
|
|
107
|
+
blank();
|
|
108
|
+
// As sondas primeiro: é o que se aprende com elas que decide o resto.
|
|
109
|
+
const first = await deployFunctionsOneByOne([...probes, ...plan.unverifiable], options);
|
|
110
|
+
const fresh = await listDeployedFunctions(options.projectId);
|
|
111
|
+
const freshByName = new Map();
|
|
112
|
+
for (const entry of fresh ?? []) {
|
|
113
|
+
if (entry.region === context.region)
|
|
114
|
+
freshByName.set(entry.name, entry);
|
|
115
|
+
}
|
|
116
|
+
const refreshed = new Map();
|
|
117
|
+
const skipped = [];
|
|
118
|
+
const rest = [];
|
|
119
|
+
let unconfirmed = 0;
|
|
120
|
+
for (const group of plan.groups) {
|
|
121
|
+
const [probe, ...others] = group.names;
|
|
122
|
+
if (others.length === 0)
|
|
123
|
+
continue;
|
|
124
|
+
const entry = freshByName.get(probe);
|
|
125
|
+
const stamp = first.deployed.includes(probe) && entry !== undefined && entry.state === ACTIVE
|
|
126
|
+
? entry.hash
|
|
127
|
+
: null;
|
|
128
|
+
if (stamp === null) {
|
|
129
|
+
// Não deu para ler o carimbo novo da sonda: a resposta é subir, que é o que se fazia antes
|
|
130
|
+
// deste mecanismo existir. Não saber nunca vira pular.
|
|
131
|
+
unconfirmed += others.length;
|
|
132
|
+
rest.push(...others);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
refreshed.set(group.hash, stamp);
|
|
136
|
+
if (stamp === group.hash)
|
|
137
|
+
skipped.push(...others);
|
|
138
|
+
else
|
|
139
|
+
rest.push(...others);
|
|
140
|
+
}
|
|
141
|
+
blank();
|
|
142
|
+
if (skipped.length > 0) {
|
|
143
|
+
success(`${skipped.length} ${plural(skipped.length, 'function')} already up to date — same source,`);
|
|
144
|
+
info(' settings and secrets as what this project runs. Not deployed.');
|
|
145
|
+
}
|
|
146
|
+
if (unconfirmed > 0) {
|
|
147
|
+
warn(`could not read the new stamp of ${unconfirmed} ${plural(unconfirmed, 'function')}: deploying them.`);
|
|
148
|
+
}
|
|
149
|
+
if (rest.length > 0) {
|
|
150
|
+
info(` ${rest.length} ${plural(rest.length, 'function')} changed and go up now.`);
|
|
151
|
+
}
|
|
152
|
+
blank();
|
|
153
|
+
const second = await deployFunctionsOneByOne(rest, options);
|
|
154
|
+
return { ...merge(first, second), skipped, refreshed };
|
|
155
|
+
};
|
|
156
|
+
export const outdatedByStamp = (applied, deployed, region,
|
|
157
|
+
/** Carimbo antigo -> carimbo de agora, observado pelas sondas. Ausente: não há o que concluir. */
|
|
158
|
+
refreshed) => {
|
|
159
|
+
if (deployed === null)
|
|
160
|
+
return { absent: [], stale: [] };
|
|
161
|
+
const byName = new Map();
|
|
162
|
+
const running = new Set();
|
|
163
|
+
for (const entry of deployed) {
|
|
164
|
+
// A ausência é perguntada ao projeto INTEIRO, e não à região do pacote: uma function que o
|
|
165
|
+
// escritório escreveu de propósito noutra região está rodando, e devolvê-la à fila por não estar
|
|
166
|
+
// aqui a redeployaria em toda execução, para sempre. É a mesma distinção que `regionOrphans` faz
|
|
167
|
+
// ao casar por nome-que-vai-subir em vez de por região diferente da esperada.
|
|
168
|
+
if (entry.state === ACTIVE)
|
|
169
|
+
running.add(entry.name);
|
|
170
|
+
if (entry.region === region)
|
|
171
|
+
byName.set(entry.name, entry);
|
|
172
|
+
}
|
|
173
|
+
const absent = [];
|
|
174
|
+
const stale = [];
|
|
175
|
+
for (const name of applied) {
|
|
176
|
+
if (!running.has(name)) {
|
|
177
|
+
absent.push(name);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const entry = byName.get(name);
|
|
181
|
+
if (entry === undefined || entry.state !== ACTIVE)
|
|
182
|
+
continue;
|
|
183
|
+
// Sem carimbo, ou sem o carimbo de agora com que compará-lo, não há conclusão a tirar — e a
|
|
184
|
+
// ausência de conclusão mantém o comportamento anterior a este mecanismo, que é não mexer.
|
|
185
|
+
if (entry.hash === null || refreshed === undefined)
|
|
186
|
+
continue;
|
|
187
|
+
const now = refreshed.get(entry.hash);
|
|
188
|
+
if (now !== undefined && now !== entry.hash)
|
|
189
|
+
stale.push(name);
|
|
190
|
+
}
|
|
191
|
+
return { absent, stale };
|
|
192
|
+
};
|
|
@@ -24,6 +24,18 @@ export interface ProvisionOptions {
|
|
|
24
24
|
manifest: KernelManifest;
|
|
25
25
|
delaySeconds: number;
|
|
26
26
|
prune: boolean;
|
|
27
|
+
/** `--all`: ignora o carimbo e sobe toda function que as extensões instaladas exigem. */
|
|
28
|
+
all?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* O que as sondas do passo de functions do `deploy` observaram: carimbo antigo -> carimbo de
|
|
31
|
+
* agora. A function de extensão está no MESMO codebase da baseline, então esse carimbo vale para
|
|
32
|
+
* ela — e é por ele que uma cujo CÓDIGO mudou volta para a fila, coisa que o nome nunca disse.
|
|
33
|
+
*
|
|
34
|
+
* Sem ele — o `provision` chamado sozinho, logo depois de alguém instalar uma extensão pela
|
|
35
|
+
* aplicação — o comando decide como sempre decidiu. Sondar ali custaria um deploy a cada
|
|
36
|
+
* instalação de extensão para responder uma pergunta que naquele momento é quase sempre "nada".
|
|
37
|
+
*/
|
|
38
|
+
refreshedStamps?: Map<string, string>;
|
|
27
39
|
environmentFile: string;
|
|
28
40
|
environment: Environment;
|
|
29
41
|
}
|
package/lib/console/provision.js
CHANGED
|
@@ -10,7 +10,8 @@ import { changedParameters, functionsDeclaringParameters, parameterDigests, pars
|
|
|
10
10
|
import { ENVIRONMENT_FILE, readEnvironment, writeEnvironment } from './environment.js';
|
|
11
11
|
import { FIREBASE_BIN } from './firebaseCli.js';
|
|
12
12
|
import { deployFunctionsOneByOne, reportFailures, reportNotices } from './deployFunctions.js';
|
|
13
|
-
import { clearRegionOrphans } from './regionOrphans.js';
|
|
13
|
+
import { clearRegionOrphans, listDeployedFunctions } from './regionOrphans.js';
|
|
14
|
+
import { outdatedByStamp } from './functionsUpToDate.js';
|
|
14
15
|
import { checkCallables, invokerGrantCommand } from './callableAccess.js';
|
|
15
16
|
import { asString, asTimestamp, decodeFields, documentId, listDocuments, patchMapField } from './firestoreRest.js';
|
|
16
17
|
import { gatherCustomFunctions, readOfficeFunctions } from './customFunctions.js';
|
|
@@ -141,6 +142,28 @@ export const provisionBackend = async (options) => {
|
|
|
141
142
|
const changed = changedParameters(recordedDigests, currentDigests, trackedParameters);
|
|
142
143
|
const staleFunctions = new Set(functionsDeclaringParameters(valid, changed)
|
|
143
144
|
.filter((name) => wantedFunctions.includes(name) && alreadyApplied.includes(name)));
|
|
145
|
+
// Uma pergunta só ao projeto, e duas decisões a partir dela: o que ele já roda, e qual cópia ficou
|
|
146
|
+
// numa região abandonada.
|
|
147
|
+
const deployedFunctions = await listDeployedFunctions(projectId);
|
|
148
|
+
// O que a lista de nomes nunca respondeu: a function aplicada que sumiu do projeto, e a que segue
|
|
149
|
+
// lá carregando o carimbo de um código anterior. As duas voltam para a fila.
|
|
150
|
+
const outdated = outdatedByStamp(wantedFunctions.filter((name) => alreadyApplied.includes(name)), deployedFunctions, region, options.refreshedStamps);
|
|
151
|
+
for (const name of [...outdated.absent, ...outdated.stale])
|
|
152
|
+
staleFunctions.add(name);
|
|
153
|
+
if (outdated.absent.length > 0) {
|
|
154
|
+
blank();
|
|
155
|
+
info(` ${outdated.absent.length} function(s) recorded as applied are not running in this project:`);
|
|
156
|
+
bullets(outdated.absent);
|
|
157
|
+
info(' they are deployed again — the record is a list of names, and a name does not know what');
|
|
158
|
+
info(' happened to the function after it went up.');
|
|
159
|
+
}
|
|
160
|
+
if (outdated.stale.length > 0) {
|
|
161
|
+
blank();
|
|
162
|
+
info(` ${outdated.stale.length} function(s) still carry the code of a previous package:`);
|
|
163
|
+
bullets(outdated.stale);
|
|
164
|
+
info(' Firebase stamps every function with what it was built from, and theirs no longer matches');
|
|
165
|
+
info(' what this package builds. They are deployed again.');
|
|
166
|
+
}
|
|
144
167
|
if (changed.length > 0) {
|
|
145
168
|
blank();
|
|
146
169
|
info(` ${changed.length} backend setting(s) changed since the last run: ${changed.join(', ')}`);
|
|
@@ -161,7 +184,7 @@ export const provisionBackend = async (options) => {
|
|
|
161
184
|
info(' Cloud Function at ITS deploy, so a function already running keeps the previous one.');
|
|
162
185
|
blank();
|
|
163
186
|
}
|
|
164
|
-
const toDeploy = wantedFunctions.filter((name) => !alreadyApplied.includes(name) || staleFunctions.has(name));
|
|
187
|
+
const toDeploy = wantedFunctions.filter((name) => options.all === true || !alreadyApplied.includes(name) || staleFunctions.has(name));
|
|
165
188
|
if (toDeploy.length === 0) {
|
|
166
189
|
success(`the ${wantedFunctions.length} function(s) these extensions need are already deployed`);
|
|
167
190
|
}
|
|
@@ -180,7 +203,7 @@ export const provisionBackend = async (options) => {
|
|
|
180
203
|
}
|
|
181
204
|
// A extensão que mudou de região deixa a cópia antiga para trás igual ao kernel, e ali o efeito
|
|
182
205
|
// é o mesmo: deploy recusado em toda tentativa, e gatilho disparando em duas regiões.
|
|
183
|
-
await clearRegionOrphans(projectId, toDeploy, region, options.prune);
|
|
206
|
+
await clearRegionOrphans(projectId, toDeploy, region, options.prune, deployedFunctions);
|
|
184
207
|
info(` deploying ${toDeploy.length} function(s), one at a time…`);
|
|
185
208
|
const report = await deployFunctionsOneByOne(toDeploy, {
|
|
186
209
|
projectId, cwd: install, delaySeconds, force: false,
|
|
@@ -456,6 +479,7 @@ export const provision = async (args) => {
|
|
|
456
479
|
manifest,
|
|
457
480
|
delaySeconds,
|
|
458
481
|
prune: hasFlag(args, 'prune'),
|
|
482
|
+
all: hasFlag(args, 'all'),
|
|
459
483
|
environmentFile: file,
|
|
460
484
|
environment,
|
|
461
485
|
});
|
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
export interface DeployedFunction {
|
|
2
2
|
name: string;
|
|
3
3
|
region: string;
|
|
4
|
+
/**
|
|
5
|
+
* O carimbo que o PRÓPRIO `firebase` grava na function, no label `firebase-functions-hash`:
|
|
6
|
+
* sha1 do diretório de fontes inteiro, mais as variáveis de ambiente resolvidas, mais as versões
|
|
7
|
+
* de segredo. É o valor que ele compara consigo mesmo para pular uma function que não mudou num
|
|
8
|
+
* deploy em lote (`calculateChangesets`), e é por isso que ele serve aqui: comparar por ele é
|
|
9
|
+
* tomar a MESMA decisão que o `firebase deploy` tomaria, não uma parecida.
|
|
10
|
+
*
|
|
11
|
+
* `null` quando a function foi deployada por uma versão do CLI que não gravava o label — nesse
|
|
12
|
+
* caso não há o que comparar, e ela sobe.
|
|
13
|
+
*/
|
|
14
|
+
hash: string | null;
|
|
15
|
+
/** `ACTIVE` é a única resposta que autoriza comparar: o resto está no meio de alguma coisa. */
|
|
16
|
+
state: string;
|
|
17
|
+
/** `gcfv1` e `gcfv2` empacotam fontes diferentes, então carimbam hashes diferentes. */
|
|
18
|
+
platform: string;
|
|
4
19
|
}
|
|
5
20
|
/**
|
|
6
21
|
* A cópia da mesma function numa região que o pacote não usa mais.
|
|
@@ -40,4 +55,10 @@ export declare const settleRegionOrphans: (projectId: string, orphans: RegionOrp
|
|
|
40
55
|
* O passo inteiro, para quem só quer chamá-lo antes de deployar. Devolve `true` quando o caminho está
|
|
41
56
|
* livre — inclusive quando não deu para verificar, pela mesma razão de `listDeployedFunctions`.
|
|
42
57
|
*/
|
|
43
|
-
export declare const clearRegionOrphans: (projectId: string, wanted: string[], region: string, prune: boolean
|
|
58
|
+
export declare const clearRegionOrphans: (projectId: string, wanted: string[], region: string, prune: boolean,
|
|
59
|
+
/**
|
|
60
|
+
* A listagem já feita, quando quem chama vai usá-la para outra coisa também. Perguntar duas vezes
|
|
61
|
+
* a mesma coisa ao mesmo projeto não é caro, é ambíguo: as duas respostas podem divergir, e a
|
|
62
|
+
* decisão passa a depender de qual delas o código olhou.
|
|
63
|
+
*/
|
|
64
|
+
listed?: DeployedFunction[] | null) => Promise<boolean>;
|
|
@@ -17,7 +17,13 @@ export const listDeployedFunctions = async (projectId) => {
|
|
|
17
17
|
return null;
|
|
18
18
|
return parsed.result
|
|
19
19
|
.filter((entry) => entry !== null && typeof entry === 'object')
|
|
20
|
-
.map((entry) => ({
|
|
20
|
+
.map((entry) => ({
|
|
21
|
+
name: String(entry.id ?? ''),
|
|
22
|
+
region: String(entry.region ?? ''),
|
|
23
|
+
hash: typeof entry.hash === 'string' && entry.hash !== '' ? entry.hash : null,
|
|
24
|
+
state: typeof entry.state === 'string' ? entry.state : '',
|
|
25
|
+
platform: typeof entry.platform === 'string' ? entry.platform : '',
|
|
26
|
+
}))
|
|
21
27
|
.filter((entry) => entry.name !== '' && entry.region !== '');
|
|
22
28
|
}
|
|
23
29
|
catch {
|
|
@@ -91,8 +97,14 @@ export const settleRegionOrphans = async (projectId, orphans, prune) => {
|
|
|
91
97
|
* O passo inteiro, para quem só quer chamá-lo antes de deployar. Devolve `true` quando o caminho está
|
|
92
98
|
* livre — inclusive quando não deu para verificar, pela mesma razão de `listDeployedFunctions`.
|
|
93
99
|
*/
|
|
94
|
-
export const clearRegionOrphans = async (projectId, wanted, region, prune
|
|
95
|
-
|
|
100
|
+
export const clearRegionOrphans = async (projectId, wanted, region, prune,
|
|
101
|
+
/**
|
|
102
|
+
* A listagem já feita, quando quem chama vai usá-la para outra coisa também. Perguntar duas vezes
|
|
103
|
+
* a mesma coisa ao mesmo projeto não é caro, é ambíguo: as duas respostas podem divergir, e a
|
|
104
|
+
* decisão passa a depender de qual delas o código olhou.
|
|
105
|
+
*/
|
|
106
|
+
listed) => {
|
|
107
|
+
const deployed = listed === undefined ? await listDeployedFunctions(projectId) : listed;
|
|
96
108
|
if (deployed === null)
|
|
97
109
|
return true;
|
|
98
110
|
return settleRegionOrphans(projectId, regionOrphans(deployed, wanted, region), prune);
|
package/lib/index.js
CHANGED
|
@@ -37,14 +37,14 @@ const COMMANDS = [
|
|
|
37
37
|
},
|
|
38
38
|
{
|
|
39
39
|
name: 'deploy',
|
|
40
|
-
usage: 'microteia deploy [--only rules,indexes,functions,hosting] [--delay <seconds>] [--prune]',
|
|
40
|
+
usage: 'microteia deploy [--only rules,indexes,functions,hosting] [--delay <seconds>] [--prune] [--all]',
|
|
41
41
|
summary: 'Publish the package that is in place to the connected Firebase project.',
|
|
42
42
|
audience: 'office',
|
|
43
43
|
execute: deploy,
|
|
44
44
|
},
|
|
45
45
|
{
|
|
46
46
|
name: 'provision',
|
|
47
|
-
usage: 'microteia provision [--prune] [--delay <seconds>]',
|
|
47
|
+
usage: 'microteia provision [--prune] [--delay <seconds>] [--all]',
|
|
48
48
|
summary: 'Give the installed extensions the Cloud Functions and indexes they ask for.',
|
|
49
49
|
audience: 'office',
|
|
50
50
|
execute: provision,
|
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.8.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.8.0';
|
|
@@ -16,6 +16,7 @@ import { join } from 'node:path';
|
|
|
16
16
|
*/
|
|
17
17
|
/** Arquivo distribuído → módulo de origem. O nome da pasta do exemplo é o do `.json`, sem extensão. */
|
|
18
18
|
export const EXAMPLE_SOURCES = {
|
|
19
|
+
'automation-example': 'automation.ts',
|
|
19
20
|
brasil: 'brasil.ts',
|
|
20
21
|
calendar: 'calendar.ts',
|
|
21
22
|
dashboard: 'dashboard.ts',
|
|
@@ -90,8 +90,8 @@ const commandRows = () => [
|
|
|
90
90
|
'|---|---|',
|
|
91
91
|
'| `microteia unpack --version <latest\\|x.y.z>` | Downloads a platform version, verifies it, and puts it in place. Touches no Firebase — safe any time |',
|
|
92
92
|
'| `microteia unpack --kernel <pkg>.tgz` | The same, from a file you already have — for a machine with no access to the catalog |',
|
|
93
|
-
'| `microteia deploy [--only …] [--prune]` | Publishes what is in place: `rules`, `indexes`, `functions`, `hosting`. `--prune` also deletes functions left behind in a region this version no longer uses |',
|
|
94
|
-
'| `microteia provision [--prune]` | Gives installed extensions the functions and indexes they ask for |',
|
|
93
|
+
'| `microteia deploy [--only …] [--prune] [--all]` | Publishes what is in place: `rules`, `indexes`, `functions`, `hosting`. A Cloud Function the project already runs with this exact code is not deployed again; `--all` deploys every one of them anyway. `--prune` also deletes functions left behind in a region this version no longer uses |',
|
|
94
|
+
'| `microteia provision [--prune] [--all]` | Gives installed extensions the functions and indexes they ask for |',
|
|
95
95
|
'| `microteia connect --project <id>` | Binds this installation to a Firebase project you own |',
|
|
96
96
|
'| `microteia plugin-init` | Prepares `extensions/` for authoring in TypeScript |',
|
|
97
97
|
].join('\n');
|
|
@@ -179,6 +179,19 @@ carry that setting.
|
|
|
179
179
|
|
|
180
180
|
An empty line means "not configured". A feature that needs it stays off rather than failing halfway.
|
|
181
181
|
|
|
182
|
+
## 3.1 Why a deploy is usually much faster than the first one
|
|
183
|
+
|
|
184
|
+
Firebase stamps every Cloud Function with what it was built from — the whole source directory, the
|
|
185
|
+
resolved environment variables, and the secret versions. \`microteia deploy\` reads those stamps, deploys
|
|
186
|
+
one reference function, and compares: a function whose stamp comes back unchanged is byte-for-byte what
|
|
187
|
+
the project already runs, and is left alone. It is the same comparison \`firebase deploy\` makes when it
|
|
188
|
+
deploys in bulk, which is why a version that only changed the application's screens finishes in minutes
|
|
189
|
+
instead of publishing every function again.
|
|
190
|
+
|
|
191
|
+
What the stamp does not see is configuration changed **outside** this installation — memory, timeout,
|
|
192
|
+
concurrency or invoker policy edited in the Google console. \`microteia deploy --all\` deploys everything
|
|
193
|
+
regardless, and is the answer for that.
|
|
194
|
+
|
|
182
195
|
## 4. Your own Cloud Functions
|
|
183
196
|
|
|
184
197
|
Two places, answering two different questions:
|