relight-cli 0.1.0 → 0.3.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/README.md +77 -34
- package/package.json +12 -4
- package/src/cli.js +350 -1
- package/src/commands/apps.js +128 -0
- package/src/commands/auth.js +13 -4
- package/src/commands/config.js +282 -0
- package/src/commands/cost.js +593 -0
- package/src/commands/db.js +775 -0
- package/src/commands/deploy.js +264 -0
- package/src/commands/doctor.js +69 -13
- package/src/commands/domains.js +223 -0
- package/src/commands/logs.js +111 -0
- package/src/commands/open.js +42 -0
- package/src/commands/ps.js +121 -0
- package/src/commands/scale.js +132 -0
- package/src/commands/service.js +227 -0
- package/src/lib/clouds/aws.js +309 -35
- package/src/lib/clouds/cf.js +401 -2
- package/src/lib/clouds/gcp.js +255 -4
- package/src/lib/clouds/neon.js +147 -0
- package/src/lib/clouds/slicervm.js +139 -0
- package/src/lib/config.js +200 -2
- package/src/lib/docker.js +34 -0
- package/src/lib/link.js +31 -5
- package/src/lib/providers/aws/app.js +481 -0
- package/src/lib/providers/aws/db.js +504 -0
- package/src/lib/providers/aws/dns.js +232 -0
- package/src/lib/providers/aws/registry.js +59 -0
- package/src/lib/providers/cf/app.js +596 -0
- package/src/lib/providers/cf/bundle.js +70 -0
- package/src/lib/providers/cf/db.js +181 -0
- package/src/lib/providers/cf/dns.js +148 -0
- package/src/lib/providers/cf/registry.js +17 -0
- package/src/lib/providers/gcp/app.js +429 -0
- package/src/lib/providers/gcp/db.js +372 -0
- package/src/lib/providers/gcp/dns.js +166 -0
- package/src/lib/providers/gcp/registry.js +30 -0
- package/src/lib/providers/neon/db.js +306 -0
- package/src/lib/providers/resolve.js +79 -0
- package/src/lib/providers/slicervm/app.js +396 -0
- package/src/lib/providers/slicervm/db.js +33 -0
- package/src/lib/providers/slicervm/dns.js +58 -0
- package/src/lib/providers/slicervm/registry.js +7 -0
- package/worker-template/package.json +10 -0
- package/worker-template/src/index.js +260 -0
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import { randomBytes } from "crypto";
|
|
2
|
+
import {
|
|
3
|
+
mintAccessToken,
|
|
4
|
+
createSqlInstance,
|
|
5
|
+
getSqlInstance,
|
|
6
|
+
deleteSqlInstance,
|
|
7
|
+
createSqlDatabase,
|
|
8
|
+
deleteSqlDatabase,
|
|
9
|
+
createSqlUser,
|
|
10
|
+
updateSqlUser,
|
|
11
|
+
deleteSqlUser,
|
|
12
|
+
listSqlDatabases,
|
|
13
|
+
} from "../../clouds/gcp.js";
|
|
14
|
+
import { getCloudMeta, setCloudMeta } from "../../config.js";
|
|
15
|
+
|
|
16
|
+
var SHARED_INSTANCE = "relight-shared";
|
|
17
|
+
|
|
18
|
+
function userName(name) {
|
|
19
|
+
return `app_${name.replace(/-/g, "_")}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function dbName(name) {
|
|
23
|
+
return `relight_${name.replace(/-/g, "_")}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isSharedInstance(dbId) {
|
|
27
|
+
return dbId === SHARED_INSTANCE;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function connectPg(connectionUrl) {
|
|
31
|
+
var pg = await import("pg");
|
|
32
|
+
var Client = pg.default?.Client || pg.Client;
|
|
33
|
+
var client = new Client({ connectionString: connectionUrl });
|
|
34
|
+
await client.connect();
|
|
35
|
+
return client;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function getPublicIp(instance) {
|
|
39
|
+
for (var addr of (instance.ipAddresses || [])) {
|
|
40
|
+
if (addr.type === "PRIMARY") return addr.ipAddress;
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function getOrCreateSharedInstance(cfg, region) {
|
|
46
|
+
var token = await mintAccessToken(cfg.clientEmail, cfg.privateKey);
|
|
47
|
+
var meta = getCloudMeta("gcp", "sharedDb");
|
|
48
|
+
|
|
49
|
+
if (meta && meta.instance) {
|
|
50
|
+
// Verify instance still exists
|
|
51
|
+
try {
|
|
52
|
+
var instance = await getSqlInstance(token, cfg.project, SHARED_INSTANCE);
|
|
53
|
+
var ip = getPublicIp(instance);
|
|
54
|
+
if (ip && ip !== meta.ip) {
|
|
55
|
+
meta.ip = ip;
|
|
56
|
+
setCloudMeta("gcp", "sharedDb", meta);
|
|
57
|
+
}
|
|
58
|
+
return { token, ip: ip || meta.ip, meta };
|
|
59
|
+
} catch (e) {
|
|
60
|
+
// Instance gone, recreate
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Create shared instance
|
|
65
|
+
process.stderr.write(" Creating shared Cloud SQL instance (one-time, takes 5-15 minutes)...\n");
|
|
66
|
+
await createSqlInstance(token, cfg.project, {
|
|
67
|
+
name: SHARED_INSTANCE,
|
|
68
|
+
region: region || "us-central1",
|
|
69
|
+
databaseVersion: "POSTGRES_15",
|
|
70
|
+
settings: {
|
|
71
|
+
tier: "db-f1-micro",
|
|
72
|
+
ipConfiguration: {
|
|
73
|
+
ipv4Enabled: true,
|
|
74
|
+
authorizedNetworks: [
|
|
75
|
+
{ name: "all", value: "0.0.0.0/0" },
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
backupConfiguration: { enabled: false },
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Create master user with random password
|
|
83
|
+
var masterPassword = randomBytes(24).toString("base64url");
|
|
84
|
+
await createSqlUser(token, cfg.project, SHARED_INSTANCE, "relight_admin", masterPassword);
|
|
85
|
+
|
|
86
|
+
var instance = await getSqlInstance(token, cfg.project, SHARED_INSTANCE);
|
|
87
|
+
var ip = getPublicIp(instance);
|
|
88
|
+
if (!ip) throw new Error("No public IP assigned to shared Cloud SQL instance.");
|
|
89
|
+
|
|
90
|
+
meta = { instance: SHARED_INSTANCE, ip, masterPassword };
|
|
91
|
+
setCloudMeta("gcp", "sharedDb", meta);
|
|
92
|
+
|
|
93
|
+
return { token, ip, meta };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function connectAsAdmin(cfg) {
|
|
97
|
+
var meta = getCloudMeta("gcp", "sharedDb");
|
|
98
|
+
if (!meta || !meta.masterPassword) {
|
|
99
|
+
throw new Error("Shared DB master credentials not found. Run `relight db create` first.");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
var token = await mintAccessToken(cfg.clientEmail, cfg.privateKey);
|
|
103
|
+
var instance = await getSqlInstance(token, cfg.project, SHARED_INSTANCE);
|
|
104
|
+
var ip = getPublicIp(instance);
|
|
105
|
+
if (!ip) throw new Error("No public IP on shared instance.");
|
|
106
|
+
|
|
107
|
+
var url = `postgresql://relight_admin:${encodeURIComponent(meta.masterPassword)}@${ip}:5432/postgres`;
|
|
108
|
+
var client = await connectPg(url);
|
|
109
|
+
return { client, ip };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function destroySharedInstanceIfEmpty(cfg) {
|
|
113
|
+
var { client } = await connectAsAdmin(cfg);
|
|
114
|
+
try {
|
|
115
|
+
var res = await client.query(
|
|
116
|
+
"SELECT datname FROM pg_database WHERE datname LIKE 'relight_%'"
|
|
117
|
+
);
|
|
118
|
+
if (res.rows.length > 0) return false;
|
|
119
|
+
} finally {
|
|
120
|
+
await client.end();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// No relight databases remain - destroy the shared instance
|
|
124
|
+
var token = await mintAccessToken(cfg.clientEmail, cfg.privateKey);
|
|
125
|
+
await deleteSqlInstance(token, cfg.project, SHARED_INSTANCE);
|
|
126
|
+
setCloudMeta("gcp", "sharedDb", undefined);
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// --- Public API ---
|
|
131
|
+
|
|
132
|
+
export async function createDatabase(cfg, name, opts = {}) {
|
|
133
|
+
var region = opts.location || "us-central1";
|
|
134
|
+
|
|
135
|
+
var { token, ip, meta } = await getOrCreateSharedInstance(cfg, region);
|
|
136
|
+
var database = dbName(name);
|
|
137
|
+
var user = userName(name);
|
|
138
|
+
var password = randomBytes(24).toString("base64url");
|
|
139
|
+
|
|
140
|
+
// Connect as admin to create database and user
|
|
141
|
+
var adminUrl = `postgresql://relight_admin:${encodeURIComponent(meta.masterPassword)}@${ip}:5432/postgres`;
|
|
142
|
+
var client = await connectPg(adminUrl);
|
|
143
|
+
try {
|
|
144
|
+
await client.query(`CREATE USER ${user} WITH PASSWORD '${password.replace(/'/g, "''")}'`);
|
|
145
|
+
await client.query(`CREATE DATABASE ${database} OWNER ${user}`);
|
|
146
|
+
} finally {
|
|
147
|
+
await client.end();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
var connectionUrl = `postgresql://${user}:${encodeURIComponent(password)}@${ip}:5432/${database}`;
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
dbId: SHARED_INSTANCE,
|
|
154
|
+
dbName: database,
|
|
155
|
+
dbUser: user,
|
|
156
|
+
dbToken: password,
|
|
157
|
+
connectionUrl,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function destroyDatabase(cfg, name, opts = {}) {
|
|
162
|
+
var dbId = opts.dbId;
|
|
163
|
+
if (!dbId) {
|
|
164
|
+
throw new Error("dbId is required to destroy a GCP database.");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Legacy per-app instance: delete the whole instance
|
|
168
|
+
if (!isSharedInstance(dbId)) {
|
|
169
|
+
var token = await mintAccessToken(cfg.clientEmail, cfg.privateKey);
|
|
170
|
+
await deleteSqlInstance(token, cfg.project, dbId);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Shared instance: drop database and user
|
|
175
|
+
var database = dbName(name);
|
|
176
|
+
var user = userName(name);
|
|
177
|
+
|
|
178
|
+
var { client } = await connectAsAdmin(cfg);
|
|
179
|
+
try {
|
|
180
|
+
// Terminate active connections to the database
|
|
181
|
+
await client.query(
|
|
182
|
+
`SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '${database}' AND pid <> pg_backend_pid()`
|
|
183
|
+
);
|
|
184
|
+
await client.query(`DROP DATABASE IF EXISTS ${database}`);
|
|
185
|
+
await client.query(`DROP USER IF EXISTS ${user}`);
|
|
186
|
+
} finally {
|
|
187
|
+
await client.end();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Check if shared instance should be destroyed
|
|
191
|
+
await destroySharedInstanceIfEmpty(cfg);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export async function getDatabaseInfo(cfg, name, opts = {}) {
|
|
195
|
+
var dbId = opts.dbId;
|
|
196
|
+
if (!dbId) {
|
|
197
|
+
throw new Error("dbId is required to get GCP database info.");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
var token = await mintAccessToken(cfg.clientEmail, cfg.privateKey);
|
|
201
|
+
var instance = await getSqlInstance(token, cfg.project, dbId);
|
|
202
|
+
var publicIp = getPublicIp(instance);
|
|
203
|
+
|
|
204
|
+
var displayUser = isSharedInstance(dbId) ? userName(name) : "relight";
|
|
205
|
+
var database = dbName(name);
|
|
206
|
+
|
|
207
|
+
var connectionUrl = publicIp
|
|
208
|
+
? `postgresql://${displayUser}:****@${publicIp}:5432/${database}`
|
|
209
|
+
: null;
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
dbId,
|
|
213
|
+
dbName: database,
|
|
214
|
+
connectionUrl,
|
|
215
|
+
size: null,
|
|
216
|
+
numTables: null,
|
|
217
|
+
createdAt: instance.createTime || null,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export async function queryDatabase(cfg, name, sql, params, opts = {}) {
|
|
222
|
+
if (!opts.connectionUrl) {
|
|
223
|
+
throw new Error("connectionUrl is required to query a GCP database.");
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
var client = await connectPg(opts.connectionUrl);
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
var result = await client.query(sql, params || []);
|
|
230
|
+
return {
|
|
231
|
+
results: result.rows,
|
|
232
|
+
meta: { changes: result.rowCount, rows_read: result.rows.length },
|
|
233
|
+
};
|
|
234
|
+
} finally {
|
|
235
|
+
await client.end();
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export async function importDatabase(cfg, name, sqlContent, opts = {}) {
|
|
240
|
+
if (!opts.connectionUrl) {
|
|
241
|
+
throw new Error("connectionUrl is required to import into a GCP database.");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
var client = await connectPg(opts.connectionUrl);
|
|
245
|
+
|
|
246
|
+
try {
|
|
247
|
+
await client.query(sqlContent);
|
|
248
|
+
} finally {
|
|
249
|
+
await client.end();
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export async function exportDatabase(cfg, name, opts = {}) {
|
|
254
|
+
if (!opts.connectionUrl) {
|
|
255
|
+
throw new Error("connectionUrl is required to export a GCP database.");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
var database = dbName(name);
|
|
259
|
+
var client = await connectPg(opts.connectionUrl);
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
// Get all user tables
|
|
263
|
+
var tablesRes = await client.query(
|
|
264
|
+
"SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename"
|
|
265
|
+
);
|
|
266
|
+
var tables = tablesRes.rows.map((r) => r.tablename);
|
|
267
|
+
|
|
268
|
+
var dump = [];
|
|
269
|
+
dump.push("-- PostgreSQL dump generated by relight");
|
|
270
|
+
dump.push(`-- Database: ${database}`);
|
|
271
|
+
dump.push(`-- Date: ${new Date().toISOString()}`);
|
|
272
|
+
dump.push("");
|
|
273
|
+
|
|
274
|
+
for (var t of tables) {
|
|
275
|
+
// Get CREATE TABLE via information_schema
|
|
276
|
+
var colsRes = await client.query(
|
|
277
|
+
`SELECT column_name, data_type, is_nullable, column_default
|
|
278
|
+
FROM information_schema.columns
|
|
279
|
+
WHERE table_name = $1 AND table_schema = 'public'
|
|
280
|
+
ORDER BY ordinal_position`,
|
|
281
|
+
[t]
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
var cols = colsRes.rows.map((c) => {
|
|
285
|
+
var def = ` "${c.column_name}" ${c.data_type}`;
|
|
286
|
+
if (c.column_default) def += ` DEFAULT ${c.column_default}`;
|
|
287
|
+
if (c.is_nullable === "NO") def += " NOT NULL";
|
|
288
|
+
return def;
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
dump.push(`CREATE TABLE IF NOT EXISTS "${t}" (`);
|
|
292
|
+
dump.push(cols.join(",\n"));
|
|
293
|
+
dump.push(");");
|
|
294
|
+
dump.push("");
|
|
295
|
+
|
|
296
|
+
// Dump data
|
|
297
|
+
var dataRes = await client.query(`SELECT * FROM "${t}"`);
|
|
298
|
+
for (var row of dataRes.rows) {
|
|
299
|
+
var values = Object.values(row).map((v) => {
|
|
300
|
+
if (v === null) return "NULL";
|
|
301
|
+
if (typeof v === "number") return String(v);
|
|
302
|
+
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
|
|
303
|
+
return "'" + String(v).replace(/'/g, "''") + "'";
|
|
304
|
+
});
|
|
305
|
+
var colNames = Object.keys(row).map((c) => `"${c}"`).join(", ");
|
|
306
|
+
dump.push(`INSERT INTO "${t}" (${colNames}) VALUES (${values.join(", ")});`);
|
|
307
|
+
}
|
|
308
|
+
dump.push("");
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return dump.join("\n");
|
|
312
|
+
} finally {
|
|
313
|
+
await client.end();
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export async function rotateToken(cfg, name, opts = {}) {
|
|
318
|
+
var dbId = opts.dbId;
|
|
319
|
+
if (!dbId) {
|
|
320
|
+
throw new Error("dbId is required to rotate a GCP database token.");
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
var database = dbName(name);
|
|
324
|
+
var newPassword = randomBytes(24).toString("base64url");
|
|
325
|
+
var connectionUrl;
|
|
326
|
+
|
|
327
|
+
if (isSharedInstance(dbId)) {
|
|
328
|
+
// Update via admin connection
|
|
329
|
+
var user = userName(name);
|
|
330
|
+
var { client, ip } = await connectAsAdmin(cfg);
|
|
331
|
+
try {
|
|
332
|
+
await client.query(`ALTER USER ${user} WITH PASSWORD '${newPassword.replace(/'/g, "''")}'`);
|
|
333
|
+
} finally {
|
|
334
|
+
await client.end();
|
|
335
|
+
}
|
|
336
|
+
connectionUrl = `postgresql://${user}:${encodeURIComponent(newPassword)}@${ip}:5432/${database}`;
|
|
337
|
+
} else {
|
|
338
|
+
// Legacy: update via Cloud SQL API
|
|
339
|
+
var token = await mintAccessToken(cfg.clientEmail, cfg.privateKey);
|
|
340
|
+
await updateSqlUser(token, cfg.project, dbId, "relight", newPassword);
|
|
341
|
+
var instance = await getSqlInstance(token, cfg.project, dbId);
|
|
342
|
+
var publicIp = getPublicIp(instance);
|
|
343
|
+
connectionUrl = publicIp
|
|
344
|
+
? `postgresql://relight:${encodeURIComponent(newPassword)}@${publicIp}:5432/${database}`
|
|
345
|
+
: null;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return { dbToken: newPassword, connectionUrl };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export async function resetDatabase(cfg, name, opts = {}) {
|
|
352
|
+
if (!opts.connectionUrl) {
|
|
353
|
+
throw new Error("connectionUrl is required to reset a GCP database.");
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
var client = await connectPg(opts.connectionUrl);
|
|
357
|
+
|
|
358
|
+
try {
|
|
359
|
+
var tablesRes = await client.query(
|
|
360
|
+
"SELECT tablename FROM pg_tables WHERE schemaname = 'public'"
|
|
361
|
+
);
|
|
362
|
+
var tables = tablesRes.rows.map((r) => r.tablename);
|
|
363
|
+
|
|
364
|
+
for (var t of tables) {
|
|
365
|
+
await client.query(`DROP TABLE IF EXISTS "${t}" CASCADE`);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return tables;
|
|
369
|
+
} finally {
|
|
370
|
+
await client.end();
|
|
371
|
+
}
|
|
372
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mintAccessToken,
|
|
3
|
+
listManagedZones,
|
|
4
|
+
createDnsChange,
|
|
5
|
+
listResourceRecordSets,
|
|
6
|
+
} from "../../clouds/gcp.js";
|
|
7
|
+
import { getAppConfig, pushAppConfig, getAppUrl } from "./app.js";
|
|
8
|
+
|
|
9
|
+
export async function getZones(cfg) {
|
|
10
|
+
var token = await mintAccessToken(cfg.clientEmail, cfg.privateKey);
|
|
11
|
+
var zones = await listManagedZones(token, cfg.project);
|
|
12
|
+
return zones.map((z) => ({
|
|
13
|
+
id: z.name,
|
|
14
|
+
name: z.dnsName.replace(/\.$/, ""),
|
|
15
|
+
}));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function findZoneForHostname(zones, hostname) {
|
|
19
|
+
var match = null;
|
|
20
|
+
for (var zone of zones) {
|
|
21
|
+
if (hostname === zone.name || hostname.endsWith("." + zone.name)) {
|
|
22
|
+
if (!match || zone.name.length > match.name.length) {
|
|
23
|
+
match = zone;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return match;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function listDomains(cfg, appName) {
|
|
31
|
+
var token = await mintAccessToken(cfg.clientEmail, cfg.privateKey);
|
|
32
|
+
|
|
33
|
+
// Get Cloud Run service URL as default domain
|
|
34
|
+
var url = await getAppUrl(cfg, appName);
|
|
35
|
+
var defaultDomain = url ? new URL(url).hostname : null;
|
|
36
|
+
|
|
37
|
+
// Get custom domains from app config
|
|
38
|
+
var appConfig = await getAppConfig(cfg, appName);
|
|
39
|
+
var custom = appConfig?.domains || [];
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
default: defaultDomain,
|
|
43
|
+
custom,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function addDomain(cfg, appName, domain, { zone }) {
|
|
48
|
+
var token = await mintAccessToken(cfg.clientEmail, cfg.privateKey);
|
|
49
|
+
|
|
50
|
+
// Get Cloud Run URL to use as CNAME target
|
|
51
|
+
var url = await getAppUrl(cfg, appName);
|
|
52
|
+
if (!url) throw new Error("Could not determine app URL for CNAME target.");
|
|
53
|
+
var target = new URL(url).hostname;
|
|
54
|
+
|
|
55
|
+
// FQDN for Cloud DNS (trailing dot)
|
|
56
|
+
var fqdn = domain.endsWith(".") ? domain : domain + ".";
|
|
57
|
+
var targetFqdn = target.endsWith(".") ? target : target + ".";
|
|
58
|
+
|
|
59
|
+
// Check for existing records
|
|
60
|
+
var records = await listResourceRecordSets(token, cfg.project, zone.id);
|
|
61
|
+
var existing = records.find(
|
|
62
|
+
(r) => r.name === fqdn && r.type === "CNAME"
|
|
63
|
+
);
|
|
64
|
+
if (existing) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`CNAME record already exists for ${domain} -> ${existing.rrdatas.join(", ")}`
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Create CNAME record
|
|
71
|
+
await createDnsChange(token, cfg.project, zone.id, {
|
|
72
|
+
additions: [
|
|
73
|
+
{
|
|
74
|
+
name: fqdn,
|
|
75
|
+
type: "CNAME",
|
|
76
|
+
ttl: 300,
|
|
77
|
+
rrdatas: [targetFqdn],
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Update app config
|
|
83
|
+
var appConfig = await getAppConfig(cfg, appName);
|
|
84
|
+
if (appConfig) {
|
|
85
|
+
if (!appConfig.domains) appConfig.domains = [];
|
|
86
|
+
if (!appConfig.domains.includes(domain)) {
|
|
87
|
+
appConfig.domains.push(domain);
|
|
88
|
+
await pushAppConfig(cfg, appName, appConfig);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function removeDomain(cfg, appName, domain) {
|
|
94
|
+
var token = await mintAccessToken(cfg.clientEmail, cfg.privateKey);
|
|
95
|
+
var fqdn = domain.endsWith(".") ? domain : domain + ".";
|
|
96
|
+
|
|
97
|
+
// Find the zone for this domain
|
|
98
|
+
var zones = await getZones(cfg);
|
|
99
|
+
var zone = findZoneForHostname(zones, domain);
|
|
100
|
+
|
|
101
|
+
if (zone) {
|
|
102
|
+
// Find and delete CNAME record
|
|
103
|
+
var records = await listResourceRecordSets(token, cfg.project, zone.id);
|
|
104
|
+
var existing = records.find(
|
|
105
|
+
(r) => r.name === fqdn && r.type === "CNAME"
|
|
106
|
+
);
|
|
107
|
+
if (existing) {
|
|
108
|
+
await createDnsChange(token, cfg.project, zone.id, {
|
|
109
|
+
deletions: [existing],
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Update app config
|
|
115
|
+
var appConfig = await getAppConfig(cfg, appName);
|
|
116
|
+
if (appConfig) {
|
|
117
|
+
appConfig.domains = (appConfig.domains || []).filter((d) => d !== domain);
|
|
118
|
+
await pushAppConfig(cfg, appName, appConfig);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// --- Pure DNS record operations (for cross-cloud use) ---
|
|
123
|
+
|
|
124
|
+
export async function addDnsRecord(cfg, domain, target, zone) {
|
|
125
|
+
var token = await mintAccessToken(cfg.clientEmail, cfg.privateKey);
|
|
126
|
+
var fqdn = domain.endsWith(".") ? domain : domain + ".";
|
|
127
|
+
var targetFqdn = target.endsWith(".") ? target : target + ".";
|
|
128
|
+
|
|
129
|
+
// Check for existing CNAME
|
|
130
|
+
var records = await listResourceRecordSets(token, cfg.project, zone.id);
|
|
131
|
+
var existing = records.find((r) => r.name === fqdn && r.type === "CNAME");
|
|
132
|
+
if (existing) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`CNAME record already exists for ${domain} -> ${existing.rrdatas.join(", ")}`
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Create CNAME record
|
|
139
|
+
await createDnsChange(token, cfg.project, zone.id, {
|
|
140
|
+
additions: [
|
|
141
|
+
{
|
|
142
|
+
name: fqdn,
|
|
143
|
+
type: "CNAME",
|
|
144
|
+
ttl: 300,
|
|
145
|
+
rrdatas: [targetFqdn],
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function removeDnsRecord(cfg, domain) {
|
|
152
|
+
var token = await mintAccessToken(cfg.clientEmail, cfg.privateKey);
|
|
153
|
+
var fqdn = domain.endsWith(".") ? domain : domain + ".";
|
|
154
|
+
|
|
155
|
+
var zones = await getZones(cfg);
|
|
156
|
+
var zone = findZoneForHostname(zones, domain);
|
|
157
|
+
if (!zone) return;
|
|
158
|
+
|
|
159
|
+
var records = await listResourceRecordSets(token, cfg.project, zone.id);
|
|
160
|
+
var existing = records.find((r) => r.name === fqdn && r.type === "CNAME");
|
|
161
|
+
if (existing) {
|
|
162
|
+
await createDnsChange(token, cfg.project, zone.id, {
|
|
163
|
+
deletions: [existing],
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mintAccessToken,
|
|
3
|
+
getRepository,
|
|
4
|
+
createRepository,
|
|
5
|
+
} from "../../clouds/gcp.js";
|
|
6
|
+
|
|
7
|
+
var AR_LOCATION = "us";
|
|
8
|
+
var AR_HOST = "us-docker.pkg.dev";
|
|
9
|
+
var REPO_NAME = "relight";
|
|
10
|
+
|
|
11
|
+
export async function getCredentials(cfg) {
|
|
12
|
+
var token = await mintAccessToken(cfg.clientEmail, cfg.privateKey);
|
|
13
|
+
|
|
14
|
+
// Ensure repository exists (idempotent)
|
|
15
|
+
try {
|
|
16
|
+
await getRepository(token, cfg.project, AR_LOCATION, REPO_NAME);
|
|
17
|
+
} catch {
|
|
18
|
+
await createRepository(token, cfg.project, AR_LOCATION, REPO_NAME);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
registry: `https://${AR_HOST}`,
|
|
23
|
+
username: "oauth2accesstoken",
|
|
24
|
+
password: token,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function getImageTag(cfg, appName, tag) {
|
|
29
|
+
return `${AR_HOST}/${cfg.project}/${REPO_NAME}/relight-${appName}:${tag}`;
|
|
30
|
+
}
|