@microteia/console 2.7.2 → 2.10.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 +56 -0
- package/README.md +1 -1
- package/lib/console/deploy.js +43 -5
- package/lib/console/environment.d.ts +15 -0
- package/lib/console/environment.js +11 -0
- package/lib/console/exportCatalog.d.ts +124 -0
- package/lib/console/exportCatalog.js +264 -0
- package/lib/console/functionsUpToDate.d.ts +55 -0
- package/lib/console/functionsUpToDate.js +192 -0
- package/lib/console/init.js +1 -0
- package/lib/console/installedExtensions.d.ts +37 -0
- package/lib/console/installedExtensions.js +37 -0
- package/lib/console/loginExtensions.d.ts +29 -0
- package/lib/console/loginExtensions.js +101 -0
- package/lib/console/provision.d.ts +12 -0
- package/lib/console/provision.js +39 -19
- package/lib/console/regionOrphans.d.ts +22 -1
- package/lib/console/regionOrphans.js +15 -3
- package/lib/index.js +10 -2
- package/lib/kernel/manifest.d.ts +16 -0
- package/lib/kernel/manifest.js +16 -0
- package/lib/shared/paths.d.ts +1 -1
- package/lib/shared/paths.js +1 -1
- package/lib/vendor/exampleSources.js +7 -3
- package/lib/vendor/installationDocs.js +33 -2
- package/lib/vendor/pack.js +3 -1
- package/package.json +1 -1
|
@@ -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
|
+
};
|
package/lib/console/init.js
CHANGED
|
@@ -31,6 +31,7 @@ export const init = async (args) => {
|
|
|
31
31
|
firebase: { projectId: null, region: DEFAULT_FUNCTIONS_REGION },
|
|
32
32
|
kernel: { version: null, deployed: null },
|
|
33
33
|
backend: { functions: null, parameters: null, indexes: null },
|
|
34
|
+
login: { extensions: null },
|
|
34
35
|
};
|
|
35
36
|
await writeEnvironment(file, environment);
|
|
36
37
|
// O diretório de trabalho guarda o pacote desempacotado e a configuração resolvida. Ele é local à
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* O CAMINHO ÚNICO até o que um escritório instalou.
|
|
3
|
+
*
|
|
4
|
+
* Estava dentro do `provision`, e é dele que a leitura nasceu: para saber quais Cloud Functions subir,
|
|
5
|
+
* o comando precisa do `manifest.backend` de cada extensão instalada. O `export-catalog` faz a mesma
|
|
6
|
+
* pergunta com outro propósito, e um segundo caminho até o mesmo documento seria uma segunda chance de
|
|
7
|
+
* discordar — a forma do documento, o que conta como instalada, o que fazer com a desativada.
|
|
8
|
+
*
|
|
9
|
+
* Então o caminho é este, e o `provision` também passa por aqui.
|
|
10
|
+
*/
|
|
11
|
+
/** Um escritório do projeto. O `name` existe para que escolher um `--tenant` não seja adivinhar um id. */
|
|
12
|
+
export interface TenantSummary {
|
|
13
|
+
id: string;
|
|
14
|
+
name: string;
|
|
15
|
+
}
|
|
16
|
+
export interface InstalledExtensionRecord {
|
|
17
|
+
tenantId: string;
|
|
18
|
+
documentPath: string;
|
|
19
|
+
id: string;
|
|
20
|
+
name: string;
|
|
21
|
+
version: string;
|
|
22
|
+
/** Desligada no escritório: o pacote continua ali, e nada dele está no ar. */
|
|
23
|
+
active: boolean;
|
|
24
|
+
/** O manifesto cru da cópia instalada — é dele que sai `backend`, e nada aqui o interpreta. */
|
|
25
|
+
manifest: Record<string, unknown> | undefined;
|
|
26
|
+
/** O documento inteiro, decodificado, para quem precisa de mais que o manifesto. */
|
|
27
|
+
data: Record<string, unknown>;
|
|
28
|
+
}
|
|
29
|
+
export declare const listTenants: (projectId: string) => Promise<TenantSummary[]>;
|
|
30
|
+
/**
|
|
31
|
+
* As extensões instaladas num escritório.
|
|
32
|
+
*
|
|
33
|
+
* A DESATIVADA vem junto, marcada. Quem decide o que fazer com ela é quem chamou: para o `provision`
|
|
34
|
+
* ela não exige backend nenhum; para o catálogo ela ainda explica de onde veio uma tela que continua
|
|
35
|
+
* gravada no banco.
|
|
36
|
+
*/
|
|
37
|
+
export declare const readInstalledExtensions: (projectId: string, tenantId: string) => Promise<InstalledExtensionRecord[]>;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { decodeFields, documentId, listDocuments } from './firestoreRest.js';
|
|
2
|
+
export const listTenants = async (projectId) => {
|
|
3
|
+
const documents = await listDocuments(projectId, '/tenants');
|
|
4
|
+
return documents
|
|
5
|
+
.map((document) => {
|
|
6
|
+
const id = documentId(document);
|
|
7
|
+
const data = decodeFields(document.fields);
|
|
8
|
+
return { id, name: typeof data.name === 'string' && data.name !== '' ? data.name : id };
|
|
9
|
+
})
|
|
10
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* As extensões instaladas num escritório.
|
|
14
|
+
*
|
|
15
|
+
* A DESATIVADA vem junto, marcada. Quem decide o que fazer com ela é quem chamou: para o `provision`
|
|
16
|
+
* ela não exige backend nenhum; para o catálogo ela ainda explica de onde veio uma tela que continua
|
|
17
|
+
* gravada no banco.
|
|
18
|
+
*/
|
|
19
|
+
export const readInstalledExtensions = async (projectId, tenantId) => {
|
|
20
|
+
const documents = await listDocuments(projectId, `/tenants/${tenantId}/installed_extensions`);
|
|
21
|
+
return documents.map((document) => {
|
|
22
|
+
const data = decodeFields(document.fields);
|
|
23
|
+
const pkg = data.package;
|
|
24
|
+
const manifest = pkg?.manifest;
|
|
25
|
+
const fallbackId = documentId(document);
|
|
26
|
+
return {
|
|
27
|
+
tenantId,
|
|
28
|
+
documentPath: `/tenants/${tenantId}/installed_extensions/${fallbackId}`,
|
|
29
|
+
id: String(manifest?.id ?? fallbackId),
|
|
30
|
+
name: String(manifest?.name ?? manifest?.id ?? fallbackId),
|
|
31
|
+
version: String(manifest?.version ?? '0.0.0'),
|
|
32
|
+
active: data.active !== false,
|
|
33
|
+
manifest,
|
|
34
|
+
data,
|
|
35
|
+
};
|
|
36
|
+
});
|
|
37
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Este arquivo BLOQUEIA a página: é um `<script>` no `<head>`, antes do bundle. Um pacote gordo aqui
|
|
3
|
+
* custa em toda visita, inclusive na de quem nem vai entrar. O aviso vem antes de doer, e a recusa
|
|
4
|
+
* vem quando já dói para todo mundo.
|
|
5
|
+
*/
|
|
6
|
+
export declare const STAMP_WARN_BYTES: number;
|
|
7
|
+
export declare const STAMP_MAX_BYTES: number;
|
|
8
|
+
export declare const STAMP_FILE = "microteia-extensions.js";
|
|
9
|
+
export interface LoginExtensionsResult {
|
|
10
|
+
/** O conteúdo do arquivo carimbado. Escrito sempre, mesmo vazio — ver `stampLoginExtensions`. */
|
|
11
|
+
contents: string;
|
|
12
|
+
/** `id@version` de cada pacote carimbado, na ordem declarada. */
|
|
13
|
+
stamped: string[];
|
|
14
|
+
/** O que quem instala precisa saber sem que o deploy pare. */
|
|
15
|
+
warnings: string[];
|
|
16
|
+
bytes: number;
|
|
17
|
+
}
|
|
18
|
+
export declare const parseExtensionPaths: (declared: string | null) => string[];
|
|
19
|
+
/**
|
|
20
|
+
* Monta o carimbo. `preLoginPoints` vem do manifesto do PACOTE do kernel instalado, e não de uma lista
|
|
21
|
+
* aqui: o Console é versionado separadamente, e uma cópia dela passaria a descrever o contrato de
|
|
22
|
+
* outra versão em silêncio. Ausente (pacote antigo), a checagem é pulada em vez de inventada.
|
|
23
|
+
*/
|
|
24
|
+
export declare const buildLoginExtensions: (root: string, declared: string | null, preLoginPoints: string[] | undefined) => Promise<LoginExtensionsResult>;
|
|
25
|
+
/**
|
|
26
|
+
* Escreve o carimbo SEMPRE, inclusive vazio. Pular a escrita quando a lista está vazia deixaria o
|
|
27
|
+
* carimbo do deploy anterior no ar: tirar um pacote de `login.extensions` não o tiraria da tela.
|
|
28
|
+
*/
|
|
29
|
+
export declare const stampPath: (install: string) => string;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Este arquivo BLOQUEIA a página: é um `<script>` no `<head>`, antes do bundle. Um pacote gordo aqui
|
|
5
|
+
* custa em toda visita, inclusive na de quem nem vai entrar. O aviso vem antes de doer, e a recusa
|
|
6
|
+
* vem quando já dói para todo mundo.
|
|
7
|
+
*/
|
|
8
|
+
export const STAMP_WARN_BYTES = 256 * 1024;
|
|
9
|
+
export const STAMP_MAX_BYTES = 1024 * 1024;
|
|
10
|
+
export const STAMP_FILE = 'microteia-extensions.js';
|
|
11
|
+
export const parseExtensionPaths = (declared) => (declared ?? '')
|
|
12
|
+
.split(',')
|
|
13
|
+
.map((entry) => entry.trim())
|
|
14
|
+
.filter((entry) => entry !== '');
|
|
15
|
+
const asStampedPackage = (parsed, path) => {
|
|
16
|
+
const manifest = parsed?.manifest;
|
|
17
|
+
if (!manifest || typeof manifest !== 'object') {
|
|
18
|
+
throw new Error(`${path} is not an extension package: it has no "manifest".`);
|
|
19
|
+
}
|
|
20
|
+
const { id, version, contributes } = manifest;
|
|
21
|
+
if (typeof id !== 'string' || id === '') {
|
|
22
|
+
throw new Error(`${path} has no "manifest.id".`);
|
|
23
|
+
}
|
|
24
|
+
if (typeof version !== 'string' || version === '') {
|
|
25
|
+
throw new Error(`${path} has no "manifest.version".`);
|
|
26
|
+
}
|
|
27
|
+
if (!Array.isArray(contributes)) {
|
|
28
|
+
throw new Error(`${path} has no "manifest.contributes" array.`);
|
|
29
|
+
}
|
|
30
|
+
return parsed;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Monta o carimbo. `preLoginPoints` vem do manifesto do PACOTE do kernel instalado, e não de uma lista
|
|
34
|
+
* aqui: o Console é versionado separadamente, e uma cópia dela passaria a descrever o contrato de
|
|
35
|
+
* outra versão em silêncio. Ausente (pacote antigo), a checagem é pulada em vez de inventada.
|
|
36
|
+
*/
|
|
37
|
+
export const buildLoginExtensions = async (root, declared, preLoginPoints) => {
|
|
38
|
+
const paths = parseExtensionPaths(declared);
|
|
39
|
+
const packages = [];
|
|
40
|
+
const stamped = [];
|
|
41
|
+
const warnings = [];
|
|
42
|
+
for (const path of paths) {
|
|
43
|
+
const absolute = resolve(root, path);
|
|
44
|
+
let parsed;
|
|
45
|
+
try {
|
|
46
|
+
parsed = JSON.parse(await readFile(absolute, 'utf8'));
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (error.code === 'ENOENT') {
|
|
50
|
+
throw new Error(`login.extensions lists ${path}, which does not exist.\n`
|
|
51
|
+
+ 'Compile the extension first ("microteia-plugin build" inside extensions/), '
|
|
52
|
+
+ 'or remove the path from microteia.yaml.', { cause: error });
|
|
53
|
+
}
|
|
54
|
+
throw new Error(`${path} is not valid JSON.`, { cause: error });
|
|
55
|
+
}
|
|
56
|
+
const pkg = asStampedPackage(parsed, path);
|
|
57
|
+
if (preLoginPoints !== undefined) {
|
|
58
|
+
const points = pkg.manifest.contributes
|
|
59
|
+
.map((contribution) => contribution.point)
|
|
60
|
+
.filter((point) => typeof point === 'string');
|
|
61
|
+
const effective = points.filter((point) => preLoginPoints.includes(point));
|
|
62
|
+
if (effective.length === 0) {
|
|
63
|
+
throw new Error(`${path} (${pkg.manifest.id}) contributes nothing that works before sign-in, so stamping it `
|
|
64
|
+
+ 'would load it with no effect.\n'
|
|
65
|
+
+ ` Points that work before sign-in: ${preLoginPoints.join(', ')}.\n`
|
|
66
|
+
+ ` This package contributes: ${points.length === 0 ? '(none)' : [...new Set(points)].join(', ')}.\n`
|
|
67
|
+
+ ' To USE it inside the organization, install it from the Extensions screen instead.');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (Array.isArray(pkg.views) && pkg.views.length > 0) {
|
|
71
|
+
warnings.push(`${pkg.manifest.id} ships ${pkg.views.length} view(s): stamped packages load untrusted, `
|
|
72
|
+
+ 'so their code does not run before sign-in — only declarations apply');
|
|
73
|
+
}
|
|
74
|
+
const duplicate = packages.find((existing) => existing.manifest.id === pkg.manifest.id);
|
|
75
|
+
if (duplicate !== undefined) {
|
|
76
|
+
throw new Error(`login.extensions lists ${pkg.manifest.id} twice. The Kernel would ignore the second one, `
|
|
77
|
+
+ 'so the file says something the installation does not do.');
|
|
78
|
+
}
|
|
79
|
+
packages.push(pkg);
|
|
80
|
+
stamped.push(`${pkg.manifest.id}@${pkg.manifest.version}`);
|
|
81
|
+
}
|
|
82
|
+
const contents = `// Generated by "microteia deploy". Do not edit by hand.\n`
|
|
83
|
+
+ `window.__MICROTEIA_EXTENSIONS__ = ${JSON.stringify(packages)};\n`;
|
|
84
|
+
const bytes = Buffer.byteLength(contents, 'utf8');
|
|
85
|
+
if (bytes > STAMP_MAX_BYTES) {
|
|
86
|
+
throw new Error(`The stamped extensions would be ${Math.round(bytes / 1024)} KiB, over the `
|
|
87
|
+
+ `${STAMP_MAX_BYTES / 1024} KiB limit. This file blocks the page on every visit, including `
|
|
88
|
+
+ 'visits by people who never sign in.\n'
|
|
89
|
+
+ ' Keep before-sign-in packages to what the sign-in screen needs, and install the rest.');
|
|
90
|
+
}
|
|
91
|
+
if (bytes > STAMP_WARN_BYTES) {
|
|
92
|
+
warnings.push(`the stamped extensions are ${Math.round(bytes / 1024)} KiB, and this file blocks the page on `
|
|
93
|
+
+ 'every visit');
|
|
94
|
+
}
|
|
95
|
+
return { contents, stamped, warnings, bytes };
|
|
96
|
+
};
|
|
97
|
+
/**
|
|
98
|
+
* Escreve o carimbo SEMPRE, inclusive vazio. Pular a escrita quando a lista está vazia deixaria o
|
|
99
|
+
* carimbo do deploy anterior no ar: tirar um pacote de `login.extensions` não o tiraria da tela.
|
|
100
|
+
*/
|
|
101
|
+
export const stampPath = (install) => join(install, 'hosting', STAMP_FILE);
|
|
@@ -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,9 +10,11 @@ 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
|
-
import { asString, asTimestamp,
|
|
16
|
+
import { asString, asTimestamp, patchMapField } from './firestoreRest.js';
|
|
17
|
+
import { listTenants, readInstalledExtensions } from './installedExtensions.js';
|
|
16
18
|
import { gatherCustomFunctions, readOfficeFunctions } from './customFunctions.js';
|
|
17
19
|
/**
|
|
18
20
|
* O que cada escritório do projeto instalou e exige do backend.
|
|
@@ -22,27 +24,22 @@ import { gatherCustomFunctions, readOfficeFunctions } from './customFunctions.js
|
|
|
22
24
|
* explicação; não há injeção possível, porque o código é sempre o do pacote.
|
|
23
25
|
*/
|
|
24
26
|
export const readInstalledClaims = async (projectId) => {
|
|
25
|
-
const tenants = await
|
|
27
|
+
const tenants = await listTenants(projectId);
|
|
26
28
|
const claims = [];
|
|
27
29
|
for (const tenant of tenants) {
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
for (const document of extensions) {
|
|
31
|
-
const data = decodeFields(document.fields);
|
|
32
|
-
if (data.active === false)
|
|
30
|
+
for (const extension of await readInstalledExtensions(projectId, tenant.id)) {
|
|
31
|
+
if (!extension.active)
|
|
33
32
|
continue;
|
|
34
|
-
const
|
|
35
|
-
const manifest = pkg?.manifest;
|
|
36
|
-
const backend = manifest?.backend;
|
|
33
|
+
const backend = extension.manifest?.backend;
|
|
37
34
|
if (!hasBackendRequirement(backend))
|
|
38
35
|
continue;
|
|
39
|
-
const state = data.backendState;
|
|
36
|
+
const state = extension.data.backendState;
|
|
40
37
|
claims.push({
|
|
41
|
-
tenantId,
|
|
42
|
-
documentPath:
|
|
43
|
-
pluginId:
|
|
44
|
-
name:
|
|
45
|
-
version:
|
|
38
|
+
tenantId: extension.tenantId,
|
|
39
|
+
documentPath: extension.documentPath,
|
|
40
|
+
pluginId: extension.id,
|
|
41
|
+
name: extension.name,
|
|
42
|
+
version: extension.version,
|
|
46
43
|
backend: backend,
|
|
47
44
|
applied: state?.fingerprint === backendFingerprint(backend),
|
|
48
45
|
});
|
|
@@ -141,6 +138,28 @@ export const provisionBackend = async (options) => {
|
|
|
141
138
|
const changed = changedParameters(recordedDigests, currentDigests, trackedParameters);
|
|
142
139
|
const staleFunctions = new Set(functionsDeclaringParameters(valid, changed)
|
|
143
140
|
.filter((name) => wantedFunctions.includes(name) && alreadyApplied.includes(name)));
|
|
141
|
+
// Uma pergunta só ao projeto, e duas decisões a partir dela: o que ele já roda, e qual cópia ficou
|
|
142
|
+
// numa região abandonada.
|
|
143
|
+
const deployedFunctions = await listDeployedFunctions(projectId);
|
|
144
|
+
// O que a lista de nomes nunca respondeu: a function aplicada que sumiu do projeto, e a que segue
|
|
145
|
+
// lá carregando o carimbo de um código anterior. As duas voltam para a fila.
|
|
146
|
+
const outdated = outdatedByStamp(wantedFunctions.filter((name) => alreadyApplied.includes(name)), deployedFunctions, region, options.refreshedStamps);
|
|
147
|
+
for (const name of [...outdated.absent, ...outdated.stale])
|
|
148
|
+
staleFunctions.add(name);
|
|
149
|
+
if (outdated.absent.length > 0) {
|
|
150
|
+
blank();
|
|
151
|
+
info(` ${outdated.absent.length} function(s) recorded as applied are not running in this project:`);
|
|
152
|
+
bullets(outdated.absent);
|
|
153
|
+
info(' they are deployed again — the record is a list of names, and a name does not know what');
|
|
154
|
+
info(' happened to the function after it went up.');
|
|
155
|
+
}
|
|
156
|
+
if (outdated.stale.length > 0) {
|
|
157
|
+
blank();
|
|
158
|
+
info(` ${outdated.stale.length} function(s) still carry the code of a previous package:`);
|
|
159
|
+
bullets(outdated.stale);
|
|
160
|
+
info(' Firebase stamps every function with what it was built from, and theirs no longer matches');
|
|
161
|
+
info(' what this package builds. They are deployed again.');
|
|
162
|
+
}
|
|
144
163
|
if (changed.length > 0) {
|
|
145
164
|
blank();
|
|
146
165
|
info(` ${changed.length} backend setting(s) changed since the last run: ${changed.join(', ')}`);
|
|
@@ -161,7 +180,7 @@ export const provisionBackend = async (options) => {
|
|
|
161
180
|
info(' Cloud Function at ITS deploy, so a function already running keeps the previous one.');
|
|
162
181
|
blank();
|
|
163
182
|
}
|
|
164
|
-
const toDeploy = wantedFunctions.filter((name) => !alreadyApplied.includes(name) || staleFunctions.has(name));
|
|
183
|
+
const toDeploy = wantedFunctions.filter((name) => options.all === true || !alreadyApplied.includes(name) || staleFunctions.has(name));
|
|
165
184
|
if (toDeploy.length === 0) {
|
|
166
185
|
success(`the ${wantedFunctions.length} function(s) these extensions need are already deployed`);
|
|
167
186
|
}
|
|
@@ -180,7 +199,7 @@ export const provisionBackend = async (options) => {
|
|
|
180
199
|
}
|
|
181
200
|
// A extensão que mudou de região deixa a cópia antiga para trás igual ao kernel, e ali o efeito
|
|
182
201
|
// é o mesmo: deploy recusado em toda tentativa, e gatilho disparando em duas regiões.
|
|
183
|
-
await clearRegionOrphans(projectId, toDeploy, region, options.prune);
|
|
202
|
+
await clearRegionOrphans(projectId, toDeploy, region, options.prune, deployedFunctions);
|
|
184
203
|
info(` deploying ${toDeploy.length} function(s), one at a time…`);
|
|
185
204
|
const report = await deployFunctionsOneByOne(toDeploy, {
|
|
186
205
|
projectId, cwd: install, delaySeconds, force: false,
|
|
@@ -456,6 +475,7 @@ export const provision = async (args) => {
|
|
|
456
475
|
manifest,
|
|
457
476
|
delaySeconds,
|
|
458
477
|
prune: hasFlag(args, 'prune'),
|
|
478
|
+
all: hasFlag(args, 'all'),
|
|
459
479
|
environmentFile: file,
|
|
460
480
|
environment,
|
|
461
481
|
});
|
|
@@ -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);
|