relight-cli 0.3.0 → 0.4.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/package.json +1 -1
- package/src/commands/apps.js +3 -0
- package/src/commands/auth.js +3 -1
- package/src/commands/doctor.js +2 -2
- package/src/commands/domains.js +21 -4
- package/src/commands/ps.js +16 -11
- package/src/lib/clouds/gcp.js +76 -0
- package/src/lib/providers/aws/dns.js +1 -1
- package/src/lib/providers/cf/dns.js +3 -3
- package/src/lib/providers/gcp/app.js +82 -7
- package/src/lib/providers/gcp/dns.js +9 -7
package/package.json
CHANGED
package/src/commands/apps.js
CHANGED
|
@@ -84,6 +84,9 @@ export async function appsInfo(name, options) {
|
|
|
84
84
|
if (appConfig.createdAt) {
|
|
85
85
|
console.log(`${fmt.bold("Created:")} ${appConfig.createdAt}`);
|
|
86
86
|
}
|
|
87
|
+
if (info.consoleUrl) {
|
|
88
|
+
console.log(`${fmt.bold("Console:")} ${fmt.url(info.consoleUrl)}`);
|
|
89
|
+
}
|
|
87
90
|
}
|
|
88
91
|
|
|
89
92
|
export async function appsDestroy(name, options) {
|
package/src/commands/auth.js
CHANGED
|
@@ -168,7 +168,7 @@ async function authGCP(rl) {
|
|
|
168
168
|
var SA_URL =
|
|
169
169
|
"https://console.cloud.google.com/iam-admin/serviceaccounts/create";
|
|
170
170
|
var ENABLE_APIS =
|
|
171
|
-
"https://console.cloud.google.com/apis/enableflow?apiid=run.googleapis.com,artifactregistry.googleapis.com,sqladmin.googleapis.com,dns.googleapis.com,logging.googleapis.com,monitoring.googleapis.com";
|
|
171
|
+
"https://console.cloud.google.com/apis/enableflow?apiid=run.googleapis.com,artifactregistry.googleapis.com,sqladmin.googleapis.com,dns.googleapis.com,logging.googleapis.com,monitoring.googleapis.com,firebase.googleapis.com,firebasehosting.googleapis.com";
|
|
172
172
|
|
|
173
173
|
process.stderr.write(`\n ${kleur.bold("Setup")}\n\n`);
|
|
174
174
|
process.stderr.write(` 1. Enable the required APIs:\n`);
|
|
@@ -181,6 +181,8 @@ async function authGCP(rl) {
|
|
|
181
181
|
process.stderr.write(` ${fmt.val("Service Account User")}\n`);
|
|
182
182
|
process.stderr.write(` ${fmt.val("Cloud SQL Admin")}\n`);
|
|
183
183
|
process.stderr.write(` ${fmt.val("DNS Administrator")}\n`);
|
|
184
|
+
process.stderr.write(` ${fmt.val("Firebase Admin")}\n`);
|
|
185
|
+
process.stderr.write(` ${fmt.val("Firebase Hosting Admin")}\n`);
|
|
184
186
|
process.stderr.write(` ${fmt.val("Logs Viewer")}\n`);
|
|
185
187
|
process.stderr.write(` ${fmt.val("Monitoring Viewer")}\n\n`);
|
|
186
188
|
process.stderr.write(` 3. Go to the service account → ${kleur.bold("Keys")} tab\n`);
|
package/src/commands/doctor.js
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
normalizeServiceConfig,
|
|
10
10
|
} from "../lib/config.js";
|
|
11
11
|
import { verifyToken as cfVerify, getWorkersSubdomain } from "../lib/clouds/cf.js";
|
|
12
|
-
import { mintAccessToken, verifyProject as gcpVerifyProject,
|
|
12
|
+
import { mintAccessToken, verifyProject as gcpVerifyProject, listAllServices as gcpListServices, gcpApi, AR_API, SQLADMIN_API, DNS_API } from "../lib/clouds/gcp.js";
|
|
13
13
|
import { verifyCredentials as awsVerify, checkAppRunner, awsJsonApi, awsQueryApi, awsRestXmlApi } from "../lib/clouds/aws.js";
|
|
14
14
|
import { verifyConnection as slicerVerify } from "../lib/clouds/slicervm.js";
|
|
15
15
|
import kleur from "kleur";
|
|
@@ -156,7 +156,7 @@ async function checkGCP(cfg) {
|
|
|
156
156
|
|
|
157
157
|
ok =
|
|
158
158
|
(await asyncCheck("Cloud Run API reachable", async () => {
|
|
159
|
-
await
|
|
159
|
+
await gcpListServices(token, cfg.project);
|
|
160
160
|
})) && ok;
|
|
161
161
|
|
|
162
162
|
ok =
|
package/src/commands/domains.js
CHANGED
|
@@ -144,12 +144,23 @@ export async function domainsAdd(args, options) {
|
|
|
144
144
|
rl.close();
|
|
145
145
|
|
|
146
146
|
if (crossCloud) {
|
|
147
|
-
// Cross-cloud:
|
|
147
|
+
// Cross-cloud: set up domain mapping on the app cloud first (e.g. Firebase Hosting for GCP)
|
|
148
|
+
var dnsTarget;
|
|
149
|
+
var dnsProxied = true;
|
|
150
|
+
if (appProvider.mapCustomDomain) {
|
|
151
|
+
status(`Setting up hosting for ${domain}...`);
|
|
152
|
+
var mapping = await appProvider.mapCustomDomain(appCfg, name, domain);
|
|
153
|
+
dnsTarget = mapping.dnsTarget;
|
|
154
|
+
if (mapping.proxied === false) dnsProxied = false;
|
|
155
|
+
} else {
|
|
156
|
+
var appUrl = await appProvider.getAppUrl(appCfg, name);
|
|
157
|
+
dnsTarget = new URL(appUrl).hostname;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Create DNS record pointing to the hosting provider
|
|
148
161
|
status(`Creating DNS record for ${domain}...`);
|
|
149
|
-
var appUrl = await appProvider.getAppUrl(appCfg, name);
|
|
150
|
-
var dnsTarget = new URL(appUrl).hostname;
|
|
151
162
|
try {
|
|
152
|
-
await dnsProvider.addDnsRecord(dnsCfg, domain, dnsTarget, zone);
|
|
163
|
+
await dnsProvider.addDnsRecord(dnsCfg, domain, dnsTarget, zone, { proxied: dnsProxied });
|
|
153
164
|
} catch (e) {
|
|
154
165
|
fatal(e.message);
|
|
155
166
|
}
|
|
@@ -209,6 +220,12 @@ export async function domainsRemove(args, options) {
|
|
|
209
220
|
await dnsProvider.removeDnsRecord(dnsCfg, domain);
|
|
210
221
|
|
|
211
222
|
var appProvider = await target.provider("app");
|
|
223
|
+
|
|
224
|
+
// Remove domain mapping on the app cloud if supported
|
|
225
|
+
if (appProvider.unmapCustomDomain) {
|
|
226
|
+
await appProvider.unmapCustomDomain(appCfg, name, domain);
|
|
227
|
+
}
|
|
228
|
+
|
|
212
229
|
var appConfig = await appProvider.getAppConfig(appCfg, name);
|
|
213
230
|
if (appConfig) {
|
|
214
231
|
appConfig.domains = (appConfig.domains || []).filter((d) => d !== domain);
|
package/src/commands/ps.js
CHANGED
|
@@ -23,22 +23,25 @@ export async function ps(name, options) {
|
|
|
23
23
|
// Fetch live metrics
|
|
24
24
|
var metrics = await appProvider.getContainerStatus(cfg, name);
|
|
25
25
|
|
|
26
|
-
// Aggregate: group by region +
|
|
26
|
+
// Aggregate: group by region + id, keep only active instances
|
|
27
27
|
var containers = [];
|
|
28
28
|
for (var row of metrics) {
|
|
29
29
|
var dim = row.dimensions;
|
|
30
|
-
if (
|
|
31
|
-
var
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
if (dim.active === false) continue;
|
|
31
|
+
var id = dim.durableObjectId || dim.hostname || null;
|
|
32
|
+
var region = dim.region || dim.hostname || "-";
|
|
33
|
+
var existing = id ? containers.find(
|
|
34
|
+
(c) => c.region === region && c.id === id
|
|
35
|
+
) : null;
|
|
34
36
|
if (existing) {
|
|
35
37
|
existing.cpuSamples++;
|
|
36
38
|
existing.cpuLoad += row.avg?.cpuLoad || 0;
|
|
37
39
|
existing.memory += row.avg?.memory || 0;
|
|
38
40
|
} else {
|
|
39
41
|
containers.push({
|
|
40
|
-
region
|
|
41
|
-
|
|
42
|
+
region,
|
|
43
|
+
id,
|
|
44
|
+
status: dim.status || null,
|
|
42
45
|
cpuLoad: row.avg?.cpuLoad || 0,
|
|
43
46
|
memory: row.avg?.memory || 0,
|
|
44
47
|
cpuSamples: 1,
|
|
@@ -49,7 +52,7 @@ export async function ps(name, options) {
|
|
|
49
52
|
c.cpuLoad = c.cpuLoad / c.cpuSamples;
|
|
50
53
|
c.memory = c.memory / c.cpuSamples;
|
|
51
54
|
}
|
|
52
|
-
containers.sort((a, b) => a.region.localeCompare(b.region
|
|
55
|
+
containers.sort((a, b) => (a.region || "").localeCompare(b.region || ""));
|
|
53
56
|
|
|
54
57
|
if (options.json) {
|
|
55
58
|
console.log(
|
|
@@ -61,7 +64,8 @@ export async function ps(name, options) {
|
|
|
61
64
|
instances,
|
|
62
65
|
containers: containers.map((c) => ({
|
|
63
66
|
region: c.region,
|
|
64
|
-
id: c.
|
|
67
|
+
id: c.id || null,
|
|
68
|
+
status: c.status || null,
|
|
65
69
|
cpu: +(c.cpuLoad * 100).toFixed(1),
|
|
66
70
|
memoryMiB: +(c.memory / 1024 / 1024).toFixed(0),
|
|
67
71
|
})),
|
|
@@ -102,11 +106,12 @@ export async function ps(name, options) {
|
|
|
102
106
|
console.log(`\n${fmt.bold("Containers:")}`);
|
|
103
107
|
|
|
104
108
|
if (containers.length > 0) {
|
|
105
|
-
var
|
|
109
|
+
var hasIds = containers.some((c) => c.id);
|
|
110
|
+
var headers = hasIds ? ["", "REGION", "ID", "CPU", "MEMORY"] : ["", "REGION", "STATUS", "CPU", "MEMORY"];
|
|
106
111
|
var rows = containers.map((c) => [
|
|
107
112
|
kleur.green("*"),
|
|
108
113
|
c.region,
|
|
109
|
-
c.
|
|
114
|
+
hasIds ? (c.id || "-").slice(0, 8) : (c.status || "running"),
|
|
110
115
|
(c.cpuLoad * 100).toFixed(1) + "%",
|
|
111
116
|
(c.memory / 1024 / 1024).toFixed(0) + " MiB",
|
|
112
117
|
]);
|
package/src/lib/clouds/gcp.js
CHANGED
|
@@ -2,6 +2,8 @@ import { createSign } from "crypto";
|
|
|
2
2
|
import { readFileSync } from "fs";
|
|
3
3
|
|
|
4
4
|
export var RUN_API = "https://run.googleapis.com/v2";
|
|
5
|
+
var FIREBASE_API = "https://firebase.googleapis.com/v1beta1";
|
|
6
|
+
var FIREBASE_HOSTING_API = "https://firebasehosting.googleapis.com/v1beta1";
|
|
5
7
|
var CRM_API = "https://cloudresourcemanager.googleapis.com/v1";
|
|
6
8
|
var TOKEN_URI = "https://oauth2.googleapis.com/token";
|
|
7
9
|
var SCOPE = "https://www.googleapis.com/auth/cloud-platform";
|
|
@@ -98,6 +100,8 @@ export async function gcpApi(method, url, body, token) {
|
|
|
98
100
|
throw new Error(`GCP API ${method} ${url}: ${res.status} ${text}`);
|
|
99
101
|
}
|
|
100
102
|
|
|
103
|
+
var contentType = res.headers.get("content-type") || "";
|
|
104
|
+
if (!contentType.includes("json")) return {};
|
|
101
105
|
return res.json();
|
|
102
106
|
}
|
|
103
107
|
|
|
@@ -353,6 +357,78 @@ export async function listResourceRecordSets(token, project, zoneName) {
|
|
|
353
357
|
return res.rrsets || [];
|
|
354
358
|
}
|
|
355
359
|
|
|
360
|
+
// --- Firebase ---
|
|
361
|
+
|
|
362
|
+
export async function ensureFirebaseProject(token, project) {
|
|
363
|
+
try {
|
|
364
|
+
await gcpApi("GET", `${FIREBASE_API}/projects/${project}`, null, token);
|
|
365
|
+
return;
|
|
366
|
+
} catch {}
|
|
367
|
+
|
|
368
|
+
// Try to add Firebase programmatically
|
|
369
|
+
try {
|
|
370
|
+
var op = await gcpApi("POST", `${FIREBASE_API}/projects/${project}:addFirebase`, {}, token);
|
|
371
|
+
if (op.name && !op.done) {
|
|
372
|
+
while (true) {
|
|
373
|
+
var status = await gcpApi("GET", `${FIREBASE_API}/${op.name}`, null, token);
|
|
374
|
+
if (status.done) {
|
|
375
|
+
if (status.error) throw new Error(status.error.message);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return;
|
|
382
|
+
} catch {
|
|
383
|
+
throw new Error(
|
|
384
|
+
"Could not enable Firebase for this project.\n" +
|
|
385
|
+
" This usually means the Firebase Terms of Service have not been accepted.\n" +
|
|
386
|
+
" Visit https://console.firebase.google.com/ and add your GCP project there first."
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// --- Firebase Hosting ---
|
|
392
|
+
|
|
393
|
+
export async function createHostingSite(token, project, siteId) {
|
|
394
|
+
return gcpApi("POST", `${FIREBASE_HOSTING_API}/projects/${project}/sites?siteId=${siteId}`, {}, token);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export async function getHostingSite(token, project, siteId) {
|
|
398
|
+
return gcpApi("GET", `${FIREBASE_HOSTING_API}/projects/${project}/sites/${siteId}`, null, token);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export async function deleteHostingSite(token, project, siteId) {
|
|
402
|
+
return gcpApi("DELETE", `${FIREBASE_HOSTING_API}/projects/${project}/sites/${siteId}`, null, token);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export async function deployHostingProxy(token, siteId, serviceId, region) {
|
|
406
|
+
// Create version with Cloud Run rewrite
|
|
407
|
+
var version = await gcpApi("POST", `${FIREBASE_HOSTING_API}/sites/${siteId}/versions`, {
|
|
408
|
+
config: {
|
|
409
|
+
rewrites: [{ glob: "**", run: { serviceId, region } }],
|
|
410
|
+
},
|
|
411
|
+
}, token);
|
|
412
|
+
|
|
413
|
+
var versionId = version.name.split("/").pop();
|
|
414
|
+
|
|
415
|
+
// Finalize version
|
|
416
|
+
await gcpApi("PATCH", `${FIREBASE_HOSTING_API}/sites/${siteId}/versions/${versionId}?update_mask=status`, {
|
|
417
|
+
status: "FINALIZED",
|
|
418
|
+
}, token);
|
|
419
|
+
|
|
420
|
+
// Create release
|
|
421
|
+
await gcpApi("POST", `${FIREBASE_HOSTING_API}/sites/${siteId}/releases?versionName=sites/${siteId}/versions/${versionId}`, {}, token);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export async function addHostingCustomDomain(token, project, siteId, domain) {
|
|
425
|
+
return gcpApi("POST", `${FIREBASE_HOSTING_API}/projects/${project}/sites/${siteId}/customDomains?customDomainId=${domain}`, {}, token);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export async function deleteHostingCustomDomain(token, project, siteId, domain) {
|
|
429
|
+
return gcpApi("DELETE", `${FIREBASE_HOSTING_API}/projects/${project}/sites/${siteId}/customDomains/${domain}`, null, token);
|
|
430
|
+
}
|
|
431
|
+
|
|
356
432
|
// --- Cloud Logging ---
|
|
357
433
|
|
|
358
434
|
export async function listLogEntries(token, body) {
|
|
@@ -149,7 +149,7 @@ export async function removeDomain(cfg, appName, domain) {
|
|
|
149
149
|
|
|
150
150
|
// --- Pure DNS record operations (for cross-cloud use) ---
|
|
151
151
|
|
|
152
|
-
export async function addDnsRecord(cfg, domain, target, zone) {
|
|
152
|
+
export async function addDnsRecord(cfg, domain, target, zone, opts = {}) {
|
|
153
153
|
var cr = { accessKeyId: cfg.accessKeyId, secretAccessKey: cfg.secretAccessKey };
|
|
154
154
|
var fqdn = domain.endsWith(".") ? domain : domain + ".";
|
|
155
155
|
var targetFqdn = target.endsWith(".") ? target : target + ".";
|
|
@@ -107,7 +107,7 @@ export async function removeDomain(cfg, appName, domain) {
|
|
|
107
107
|
|
|
108
108
|
// --- Pure DNS record operations (for cross-cloud use) ---
|
|
109
109
|
|
|
110
|
-
export async function addDnsRecord(cfg, domain, target, zone) {
|
|
110
|
+
export async function addDnsRecord(cfg, domain, target, zone, opts = {}) {
|
|
111
111
|
// Check for existing records
|
|
112
112
|
var existing = await listDnsRecords(cfg.accountId, cfg.apiToken, zone.id, { name: domain });
|
|
113
113
|
if (existing.length > 0) {
|
|
@@ -117,12 +117,12 @@ export async function addDnsRecord(cfg, domain, target, zone) {
|
|
|
117
117
|
);
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
-
|
|
120
|
+
var proxied = opts.proxied !== undefined ? opts.proxied : true;
|
|
121
121
|
await createDnsRecord(cfg.accountId, cfg.apiToken, zone.id, {
|
|
122
122
|
type: "CNAME",
|
|
123
123
|
name: domain,
|
|
124
124
|
content: target,
|
|
125
|
-
proxied
|
|
125
|
+
proxied,
|
|
126
126
|
});
|
|
127
127
|
}
|
|
128
128
|
|
|
@@ -9,6 +9,13 @@ import {
|
|
|
9
9
|
listLogEntries,
|
|
10
10
|
queryTimeSeries,
|
|
11
11
|
deleteSqlInstance,
|
|
12
|
+
ensureFirebaseProject,
|
|
13
|
+
createHostingSite,
|
|
14
|
+
getHostingSite,
|
|
15
|
+
deleteHostingSite,
|
|
16
|
+
deployHostingProxy,
|
|
17
|
+
addHostingCustomDomain,
|
|
18
|
+
deleteHostingCustomDomain,
|
|
12
19
|
} from "../../clouds/gcp.js";
|
|
13
20
|
|
|
14
21
|
// --- Helpers ---
|
|
@@ -199,8 +206,14 @@ export async function getAppInfo(cfg, appName) {
|
|
|
199
206
|
var svc = await findService(token, cfg.project, appName);
|
|
200
207
|
if (!svc) return null;
|
|
201
208
|
|
|
209
|
+
var region = parseRegionFromName(svc.name);
|
|
210
|
+
var svcId = svc.name.split("/").pop();
|
|
202
211
|
var appConfig = await getAppConfig(cfg, appName);
|
|
203
|
-
return {
|
|
212
|
+
return {
|
|
213
|
+
appConfig,
|
|
214
|
+
url: svc.uri || null,
|
|
215
|
+
consoleUrl: `https://console.cloud.google.com/run/detail/${region}/${svcId}?project=${cfg.project}`,
|
|
216
|
+
};
|
|
204
217
|
}
|
|
205
218
|
|
|
206
219
|
// --- Destroy ---
|
|
@@ -216,6 +229,11 @@ export async function destroyApp(cfg, appName) {
|
|
|
216
229
|
} catch {}
|
|
217
230
|
}
|
|
218
231
|
|
|
232
|
+
// Delete Firebase Hosting site if exists
|
|
233
|
+
try {
|
|
234
|
+
await deleteHostingSite(token, cfg.project, hostingSiteId(appName));
|
|
235
|
+
} catch {}
|
|
236
|
+
|
|
219
237
|
// Delete Cloud Run service
|
|
220
238
|
var svc = await findService(token, cfg.project, appName);
|
|
221
239
|
if (!svc) throw new Error(`Service relight-${appName} not found.`);
|
|
@@ -275,6 +293,48 @@ export async function getAppUrl(cfg, appName) {
|
|
|
275
293
|
return svc?.uri || null;
|
|
276
294
|
}
|
|
277
295
|
|
|
296
|
+
// --- Custom domain via Firebase Hosting ---
|
|
297
|
+
|
|
298
|
+
function hostingSiteId(appName) {
|
|
299
|
+
return `relight-${appName}`;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function ensureHostingSite(token, project, appName) {
|
|
303
|
+
var siteId = hostingSiteId(appName);
|
|
304
|
+
try {
|
|
305
|
+
await getHostingSite(token, project, siteId);
|
|
306
|
+
} catch {
|
|
307
|
+
await ensureFirebaseProject(token, project);
|
|
308
|
+
await createHostingSite(token, project, siteId);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Deploy proxy config pointing to Cloud Run
|
|
312
|
+
var svc = await findService(token, project, appName);
|
|
313
|
+
if (!svc) throw new Error(`Service relight-${appName} not found.`);
|
|
314
|
+
var region = parseRegionFromName(svc.name);
|
|
315
|
+
var svcId = svc.name.split("/").pop();
|
|
316
|
+
await deployHostingProxy(token, siteId, svcId, region);
|
|
317
|
+
|
|
318
|
+
return siteId;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export async function mapCustomDomain(cfg, appName, domain) {
|
|
322
|
+
var token = await mintAccessToken(cfg.clientEmail, cfg.privateKey);
|
|
323
|
+
var siteId = await ensureHostingSite(token, cfg.project, appName);
|
|
324
|
+
await addHostingCustomDomain(token, cfg.project, siteId, domain);
|
|
325
|
+
return { dnsTarget: `${siteId}.web.app`, proxied: false };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export async function unmapCustomDomain(cfg, appName, domain) {
|
|
329
|
+
var token = await mintAccessToken(cfg.clientEmail, cfg.privateKey);
|
|
330
|
+
var siteId = hostingSiteId(appName);
|
|
331
|
+
try {
|
|
332
|
+
await deleteHostingCustomDomain(token, cfg.project, siteId, domain);
|
|
333
|
+
} catch (e) {
|
|
334
|
+
if (!e.message.includes("404")) throw e;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
278
338
|
// --- Log streaming ---
|
|
279
339
|
|
|
280
340
|
export async function streamLogs(cfg, appName) {
|
|
@@ -328,6 +388,10 @@ export async function getCosts(cfg, appNames, dateRange) {
|
|
|
328
388
|
var token = await mintAccessToken(cfg.clientEmail, cfg.privateKey);
|
|
329
389
|
var { sinceISO, untilISO } = dateRange;
|
|
330
390
|
|
|
391
|
+
// MQL datetime format: YYYY/MM/DD-HH:MM:SS (not ISO 8601)
|
|
392
|
+
var sinceMql = isoToMql(sinceISO);
|
|
393
|
+
var untilMql = isoToMql(untilISO);
|
|
394
|
+
|
|
331
395
|
// Discover apps
|
|
332
396
|
var apps;
|
|
333
397
|
if (appNames) {
|
|
@@ -353,7 +417,7 @@ export async function getCosts(cfg, appNames, dateRange) {
|
|
|
353
417
|
query: `fetch cloud_run_revision
|
|
354
418
|
| metric 'run.googleapis.com/request_count'
|
|
355
419
|
| filter resource.service_name == '${app.serviceName}'
|
|
356
|
-
| within d'${
|
|
420
|
+
| within d'${sinceMql}', d'${untilMql}'
|
|
357
421
|
| group_by [], sum(val())`,
|
|
358
422
|
});
|
|
359
423
|
var reqData = reqRes.timeSeriesData || [];
|
|
@@ -362,7 +426,9 @@ export async function getCosts(cfg, appNames, dateRange) {
|
|
|
362
426
|
usage.requests += Number(pt.values?.[0]?.int64Value || 0);
|
|
363
427
|
}
|
|
364
428
|
}
|
|
365
|
-
} catch {
|
|
429
|
+
} catch (e) {
|
|
430
|
+
process.stderr.write(` Warning: failed to fetch request metrics: ${e.message}\n`);
|
|
431
|
+
}
|
|
366
432
|
|
|
367
433
|
try {
|
|
368
434
|
// CPU allocation
|
|
@@ -370,7 +436,7 @@ export async function getCosts(cfg, appNames, dateRange) {
|
|
|
370
436
|
query: `fetch cloud_run_revision
|
|
371
437
|
| metric 'run.googleapis.com/container/cpu/allocation_time'
|
|
372
438
|
| filter resource.service_name == '${app.serviceName}'
|
|
373
|
-
| within d'${
|
|
439
|
+
| within d'${sinceMql}', d'${untilMql}'
|
|
374
440
|
| group_by [], sum(val())`,
|
|
375
441
|
});
|
|
376
442
|
var cpuData = cpuRes.timeSeriesData || [];
|
|
@@ -379,7 +445,9 @@ export async function getCosts(cfg, appNames, dateRange) {
|
|
|
379
445
|
usage.cpuSeconds += Number(pt.values?.[0]?.doubleValue || 0);
|
|
380
446
|
}
|
|
381
447
|
}
|
|
382
|
-
} catch {
|
|
448
|
+
} catch (e) {
|
|
449
|
+
process.stderr.write(` Warning: failed to fetch CPU metrics: ${e.message}\n`);
|
|
450
|
+
}
|
|
383
451
|
|
|
384
452
|
try {
|
|
385
453
|
// Memory allocation
|
|
@@ -387,7 +455,7 @@ export async function getCosts(cfg, appNames, dateRange) {
|
|
|
387
455
|
query: `fetch cloud_run_revision
|
|
388
456
|
| metric 'run.googleapis.com/container/memory/allocation_time'
|
|
389
457
|
| filter resource.service_name == '${app.serviceName}'
|
|
390
|
-
| within d'${
|
|
458
|
+
| within d'${sinceMql}', d'${untilMql}'
|
|
391
459
|
| group_by [], sum(val())`,
|
|
392
460
|
});
|
|
393
461
|
var memData = memRes.timeSeriesData || [];
|
|
@@ -397,7 +465,9 @@ export async function getCosts(cfg, appNames, dateRange) {
|
|
|
397
465
|
usage.memGibSeconds += Number(pt.values?.[0]?.doubleValue || 0);
|
|
398
466
|
}
|
|
399
467
|
}
|
|
400
|
-
} catch {
|
|
468
|
+
} catch (e) {
|
|
469
|
+
process.stderr.write(` Warning: failed to fetch memory metrics: ${e.message}\n`);
|
|
470
|
+
}
|
|
401
471
|
|
|
402
472
|
results.push({ name: app.name, usage });
|
|
403
473
|
}
|
|
@@ -405,6 +475,11 @@ export async function getCosts(cfg, appNames, dateRange) {
|
|
|
405
475
|
return results;
|
|
406
476
|
}
|
|
407
477
|
|
|
478
|
+
function isoToMql(iso) {
|
|
479
|
+
// Convert 2026-03-01T00:00:00Z -> 2026/03/01-00:00:00
|
|
480
|
+
return iso.replace(/Z$/, "").replace(/^(\d{4})-(\d{2})-(\d{2})T/, "$1/$2/$3-");
|
|
481
|
+
}
|
|
482
|
+
|
|
408
483
|
// --- Regions ---
|
|
409
484
|
|
|
410
485
|
export function getRegions() {
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
createDnsChange,
|
|
5
5
|
listResourceRecordSets,
|
|
6
6
|
} from "../../clouds/gcp.js";
|
|
7
|
-
import { getAppConfig, pushAppConfig, getAppUrl } from "./app.js";
|
|
7
|
+
import { getAppConfig, pushAppConfig, getAppUrl, mapCustomDomain as mapDomain, unmapCustomDomain as unmapDomain } from "./app.js";
|
|
8
8
|
|
|
9
9
|
export async function getZones(cfg) {
|
|
10
10
|
var token = await mintAccessToken(cfg.clientEmail, cfg.privateKey);
|
|
@@ -47,10 +47,9 @@ export async function listDomains(cfg, appName) {
|
|
|
47
47
|
export async function addDomain(cfg, appName, domain, { zone }) {
|
|
48
48
|
var token = await mintAccessToken(cfg.clientEmail, cfg.privateKey);
|
|
49
49
|
|
|
50
|
-
//
|
|
51
|
-
var
|
|
52
|
-
|
|
53
|
-
var target = new URL(url).hostname;
|
|
50
|
+
// Set up Firebase Hosting and get CNAME target
|
|
51
|
+
var mapping = await mapDomain(cfg, appName, domain);
|
|
52
|
+
var target = mapping.dnsTarget;
|
|
54
53
|
|
|
55
54
|
// FQDN for Cloud DNS (trailing dot)
|
|
56
55
|
var fqdn = domain.endsWith(".") ? domain : domain + ".";
|
|
@@ -67,7 +66,7 @@ export async function addDomain(cfg, appName, domain, { zone }) {
|
|
|
67
66
|
);
|
|
68
67
|
}
|
|
69
68
|
|
|
70
|
-
// Create CNAME record
|
|
69
|
+
// Create CNAME record pointing to Firebase Hosting
|
|
71
70
|
await createDnsChange(token, cfg.project, zone.id, {
|
|
72
71
|
additions: [
|
|
73
72
|
{
|
|
@@ -111,6 +110,9 @@ export async function removeDomain(cfg, appName, domain) {
|
|
|
111
110
|
}
|
|
112
111
|
}
|
|
113
112
|
|
|
113
|
+
// Remove Firebase Hosting custom domain
|
|
114
|
+
await unmapDomain(cfg, appName, domain);
|
|
115
|
+
|
|
114
116
|
// Update app config
|
|
115
117
|
var appConfig = await getAppConfig(cfg, appName);
|
|
116
118
|
if (appConfig) {
|
|
@@ -121,7 +123,7 @@ export async function removeDomain(cfg, appName, domain) {
|
|
|
121
123
|
|
|
122
124
|
// --- Pure DNS record operations (for cross-cloud use) ---
|
|
123
125
|
|
|
124
|
-
export async function addDnsRecord(cfg, domain, target, zone) {
|
|
126
|
+
export async function addDnsRecord(cfg, domain, target, zone, opts = {}) {
|
|
125
127
|
var token = await mintAccessToken(cfg.clientEmail, cfg.privateKey);
|
|
126
128
|
var fqdn = domain.endsWith(".") ? domain : domain + ".";
|
|
127
129
|
var targetFqdn = target.endsWith(".") ? target : target + ".";
|