relight-cli 0.2.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.
@@ -5,32 +5,26 @@ import {
5
5
  getSqlInstance,
6
6
  deleteSqlInstance,
7
7
  createSqlDatabase,
8
+ deleteSqlDatabase,
8
9
  createSqlUser,
9
10
  updateSqlUser,
11
+ deleteSqlUser,
12
+ listSqlDatabases,
10
13
  } from "../../clouds/gcp.js";
11
- import { getAppConfig, pushAppConfig } from "./app.js";
14
+ import { getCloudMeta, setCloudMeta } from "../../config.js";
12
15
 
13
- function instanceName(appName) {
14
- return `relight-${appName}`;
15
- }
16
+ var SHARED_INSTANCE = "relight-shared";
16
17
 
17
- function dbName(appName) {
18
- return `relight_${appName}`;
18
+ function userName(name) {
19
+ return `app_${name.replace(/-/g, "_")}`;
19
20
  }
20
21
 
21
- async function getDbPassword(cfg, appName) {
22
- var token = await mintAccessToken(cfg.clientEmail, cfg.privateKey);
23
-
24
- // Read from Cloud Run service env vars
25
- var { listAllServices } = await import("../../clouds/gcp.js");
26
- var all = await listAllServices(token, cfg.project);
27
- var svc = all.find((s) => s.name.split("/").pop() === `relight-${appName}`);
28
- if (!svc) throw new Error(`Service relight-${appName} not found.`);
22
+ function dbName(name) {
23
+ return `relight_${name.replace(/-/g, "_")}`;
24
+ }
29
25
 
30
- var envVars = svc.template?.containers?.[0]?.env || [];
31
- var dbToken = envVars.find((e) => e.name === "DB_TOKEN");
32
- if (!dbToken) throw new Error("DB_TOKEN not found on service.");
33
- return dbToken.value;
26
+ function isSharedInstance(dbId) {
27
+ return dbId === SHARED_INSTANCE;
34
28
  }
35
29
 
36
30
  async function connectPg(connectionUrl) {
@@ -41,27 +35,37 @@ async function connectPg(connectionUrl) {
41
35
  return client;
42
36
  }
43
37
 
44
- export async function createDatabase(cfg, appName, opts = {}) {
45
- var region = "us-central1";
46
-
47
- if (!opts.skipAppConfig) {
48
- var appConfig = await getAppConfig(cfg, appName);
49
- if (!appConfig) {
50
- throw new Error(`App ${appName} not found.`);
51
- }
52
- if (appConfig.dbId) {
53
- throw new Error(`App ${appName} already has a database: ${appConfig.dbId}`);
54
- }
55
- region = appConfig.regions?.[0] || "us-central1";
38
+ function getPublicIp(instance) {
39
+ for (var addr of (instance.ipAddresses || [])) {
40
+ if (addr.type === "PRIMARY") return addr.ipAddress;
56
41
  }
42
+ return null;
43
+ }
57
44
 
45
+ async function getOrCreateSharedInstance(cfg, region) {
58
46
  var token = await mintAccessToken(cfg.clientEmail, cfg.privateKey);
59
- var instName = instanceName(appName);
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
+ }
60
63
 
61
- // Create Cloud SQL instance
64
+ // Create shared instance
65
+ process.stderr.write(" Creating shared Cloud SQL instance (one-time, takes 5-15 minutes)...\n");
62
66
  await createSqlInstance(token, cfg.project, {
63
- name: instName,
64
- region,
67
+ name: SHARED_INSTANCE,
68
+ region: region || "us-central1",
65
69
  databaseVersion: "POSTGRES_15",
66
70
  settings: {
67
71
  tier: "db-f1-micro",
@@ -75,115 +79,138 @@ export async function createDatabase(cfg, appName, opts = {}) {
75
79
  },
76
80
  });
77
81
 
78
- // Create database
79
- var database = dbName(appName);
80
- await createSqlDatabase(token, cfg.project, instName, database);
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);
81
85
 
82
- // Create user with random password
83
- var password = randomBytes(24).toString("base64url");
84
- await createSqlUser(token, cfg.project, instName, "relight", password);
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.");
85
89
 
86
- // Get public IP
87
- var instance = await getSqlInstance(token, cfg.project, instName);
88
- var publicIp = null;
89
- for (var addr of (instance.ipAddresses || [])) {
90
- if (addr.type === "PRIMARY") {
91
- publicIp = addr.ipAddress;
92
- break;
93
- }
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.");
94
100
  }
95
101
 
96
- if (!publicIp) throw new Error("No public IP assigned to Cloud SQL instance.");
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
+ }
97
111
 
98
- var connectionUrl = `postgresql://relight:${encodeURIComponent(password)}@${publicIp}:5432/${database}`;
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
+ }
99
122
 
100
- if (!opts.skipAppConfig) {
101
- // Store in app config
102
- appConfig.dbId = instName;
103
- appConfig.dbName = database;
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
+ }
104
129
 
105
- if (!appConfig.envKeys) appConfig.envKeys = [];
106
- if (!appConfig.secretKeys) appConfig.secretKeys = [];
107
- if (!appConfig.env) appConfig.env = {};
130
+ // --- Public API ---
108
131
 
109
- appConfig.env["DATABASE_URL"] = connectionUrl;
110
- if (!appConfig.envKeys.includes("DATABASE_URL")) appConfig.envKeys.push("DATABASE_URL");
132
+ export async function createDatabase(cfg, name, opts = {}) {
133
+ var region = opts.location || "us-central1";
111
134
 
112
- appConfig.env["DB_TOKEN"] = "[hidden]";
113
- appConfig.secretKeys = appConfig.secretKeys.filter((k) => k !== "DB_TOKEN");
114
- appConfig.secretKeys.push("DB_TOKEN");
115
- appConfig.envKeys = appConfig.envKeys.filter((k) => k !== "DB_TOKEN");
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");
116
139
 
117
- var newSecrets = { DB_TOKEN: password };
118
- await pushAppConfig(cfg, appName, appConfig, { newSecrets });
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();
119
148
  }
120
149
 
150
+ var connectionUrl = `postgresql://${user}:${encodeURIComponent(password)}@${ip}:5432/${database}`;
151
+
121
152
  return {
122
- dbId: instName,
153
+ dbId: SHARED_INSTANCE,
123
154
  dbName: database,
155
+ dbUser: user,
124
156
  dbToken: password,
125
157
  connectionUrl,
126
158
  };
127
159
  }
128
160
 
129
- export async function destroyDatabase(cfg, appName, opts = {}) {
161
+ export async function destroyDatabase(cfg, name, opts = {}) {
130
162
  var dbId = opts.dbId;
131
163
  if (!dbId) {
132
- var appConfig = await getAppConfig(cfg, appName);
133
- if (!appConfig || !appConfig.dbId) {
134
- throw new Error(`App ${appName} does not have a database.`);
135
- }
136
- dbId = appConfig.dbId;
164
+ throw new Error("dbId is required to destroy a GCP database.");
137
165
  }
138
166
 
139
- var token = await mintAccessToken(cfg.clientEmail, cfg.privateKey);
140
- await deleteSqlInstance(token, cfg.project, dbId);
141
-
142
- if (!opts.dbId) {
143
- delete appConfig.dbId;
144
- delete appConfig.dbName;
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
+ }
145
173
 
146
- if (appConfig.env) {
147
- delete appConfig.env["DATABASE_URL"];
148
- delete appConfig.env["DB_TOKEN"];
149
- }
150
- if (appConfig.envKeys) appConfig.envKeys = appConfig.envKeys.filter((k) => k !== "DATABASE_URL");
151
- if (appConfig.secretKeys) appConfig.secretKeys = appConfig.secretKeys.filter((k) => k !== "DB_TOKEN");
174
+ // Shared instance: drop database and user
175
+ var database = dbName(name);
176
+ var user = userName(name);
152
177
 
153
- await pushAppConfig(cfg, appName, appConfig);
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();
154
188
  }
189
+
190
+ // Check if shared instance should be destroyed
191
+ await destroySharedInstanceIfEmpty(cfg);
155
192
  }
156
193
 
157
- export async function getDatabaseInfo(cfg, appName, opts = {}) {
194
+ export async function getDatabaseInfo(cfg, name, opts = {}) {
158
195
  var dbId = opts.dbId;
159
- var dbNameVal;
160
196
  if (!dbId) {
161
- var appConfig = await getAppConfig(cfg, appName);
162
- if (!appConfig || !appConfig.dbId) {
163
- throw new Error(`App ${appName} does not have a database.`);
164
- }
165
- dbId = appConfig.dbId;
166
- dbNameVal = appConfig.dbName;
197
+ throw new Error("dbId is required to get GCP database info.");
167
198
  }
168
199
 
169
200
  var token = await mintAccessToken(cfg.clientEmail, cfg.privateKey);
170
201
  var instance = await getSqlInstance(token, cfg.project, dbId);
202
+ var publicIp = getPublicIp(instance);
171
203
 
172
- var publicIp = null;
173
- for (var addr of (instance.ipAddresses || [])) {
174
- if (addr.type === "PRIMARY") {
175
- publicIp = addr.ipAddress;
176
- break;
177
- }
178
- }
204
+ var displayUser = isSharedInstance(dbId) ? userName(name) : "relight";
205
+ var database = dbName(name);
179
206
 
180
207
  var connectionUrl = publicIp
181
- ? `postgresql://relight:****@${publicIp}:5432/${appConfig.dbName}`
208
+ ? `postgresql://${displayUser}:****@${publicIp}:5432/${database}`
182
209
  : null;
183
210
 
184
211
  return {
185
212
  dbId,
186
- dbName: dbNameVal || dbName(appName),
213
+ dbName: database,
187
214
  connectionUrl,
188
215
  size: null,
189
216
  numTables: null,
@@ -191,34 +218,12 @@ export async function getDatabaseInfo(cfg, appName, opts = {}) {
191
218
  };
192
219
  }
193
220
 
194
- export async function queryDatabase(cfg, appName, sql, params, opts = {}) {
195
- var dbId = opts.dbId;
196
- var database;
197
- if (!dbId) {
198
- var appConfig = await getAppConfig(cfg, appName);
199
- if (!appConfig || !appConfig.dbId) {
200
- throw new Error(`App ${appName} does not have a database.`);
201
- }
202
- dbId = appConfig.dbId;
203
- database = appConfig.dbName;
204
- } else {
205
- database = dbName(appName);
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.");
206
224
  }
207
225
 
208
- var password = await getDbPassword(cfg, appName);
209
- var token = await mintAccessToken(cfg.clientEmail, cfg.privateKey);
210
- var instance = await getSqlInstance(token, cfg.project, dbId);
211
-
212
- var publicIp = null;
213
- for (var addr of (instance.ipAddresses || [])) {
214
- if (addr.type === "PRIMARY") {
215
- publicIp = addr.ipAddress;
216
- break;
217
- }
218
- }
219
-
220
- var connectionUrl = `postgresql://relight:${encodeURIComponent(password)}@${publicIp}:5432/${database}`;
221
- var client = await connectPg(connectionUrl);
226
+ var client = await connectPg(opts.connectionUrl);
222
227
 
223
228
  try {
224
229
  var result = await client.query(sql, params || []);
@@ -231,34 +236,12 @@ export async function queryDatabase(cfg, appName, sql, params, opts = {}) {
231
236
  }
232
237
  }
233
238
 
234
- export async function importDatabase(cfg, appName, sqlContent, opts = {}) {
235
- var dbId = opts.dbId;
236
- var database;
237
- if (!dbId) {
238
- var appConfig = await getAppConfig(cfg, appName);
239
- if (!appConfig || !appConfig.dbId) {
240
- throw new Error(`App ${appName} does not have a database.`);
241
- }
242
- dbId = appConfig.dbId;
243
- database = appConfig.dbName;
244
- } else {
245
- database = dbName(appName);
246
- }
247
-
248
- var password = await getDbPassword(cfg, appName);
249
- var token = await mintAccessToken(cfg.clientEmail, cfg.privateKey);
250
- var instance = await getSqlInstance(token, cfg.project, dbId);
251
-
252
- var publicIp = null;
253
- for (var addr of (instance.ipAddresses || [])) {
254
- if (addr.type === "PRIMARY") {
255
- publicIp = addr.ipAddress;
256
- break;
257
- }
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.");
258
242
  }
259
243
 
260
- var connectionUrl = `postgresql://relight:${encodeURIComponent(password)}@${publicIp}:5432/${database}`;
261
- var client = await connectPg(connectionUrl);
244
+ var client = await connectPg(opts.connectionUrl);
262
245
 
263
246
  try {
264
247
  await client.query(sqlContent);
@@ -267,34 +250,13 @@ export async function importDatabase(cfg, appName, sqlContent, opts = {}) {
267
250
  }
268
251
  }
269
252
 
270
- export async function exportDatabase(cfg, appName, opts = {}) {
271
- var dbId = opts.dbId;
272
- var database;
273
- if (!dbId) {
274
- var appConfig = await getAppConfig(cfg, appName);
275
- if (!appConfig || !appConfig.dbId) {
276
- throw new Error(`App ${appName} does not have a database.`);
277
- }
278
- dbId = appConfig.dbId;
279
- database = appConfig.dbName;
280
- } else {
281
- database = dbName(appName);
282
- }
283
-
284
- var password = await getDbPassword(cfg, appName);
285
- var token = await mintAccessToken(cfg.clientEmail, cfg.privateKey);
286
- var instance = await getSqlInstance(token, cfg.project, dbId);
287
-
288
- var publicIp = null;
289
- for (var addr of (instance.ipAddresses || [])) {
290
- if (addr.type === "PRIMARY") {
291
- publicIp = addr.ipAddress;
292
- break;
293
- }
253
+ export async function exportDatabase(cfg, name, opts = {}) {
254
+ if (!opts.connectionUrl) {
255
+ throw new Error("connectionUrl is required to export a GCP database.");
294
256
  }
295
257
 
296
- var connectionUrl = `postgresql://relight:${encodeURIComponent(password)}@${publicIp}:5432/${database}`;
297
- var client = await connectPg(connectionUrl);
258
+ var database = dbName(name);
259
+ var client = await connectPg(opts.connectionUrl);
298
260
 
299
261
  try {
300
262
  // Get all user tables
@@ -352,93 +314,46 @@ export async function exportDatabase(cfg, appName, opts = {}) {
352
314
  }
353
315
  }
354
316
 
355
- export async function rotateToken(cfg, appName, opts = {}) {
317
+ export async function rotateToken(cfg, name, opts = {}) {
356
318
  var dbId = opts.dbId;
357
- var database;
358
319
  if (!dbId) {
359
- var appConfig = await getAppConfig(cfg, appName);
360
- if (!appConfig || !appConfig.dbId) {
361
- throw new Error(`App ${appName} does not have a database.`);
362
- }
363
- dbId = appConfig.dbId;
364
- database = appConfig.dbName;
365
- } else {
366
- database = dbName(appName);
320
+ throw new Error("dbId is required to rotate a GCP database token.");
367
321
  }
368
322
 
369
- var token = await mintAccessToken(cfg.clientEmail, cfg.privateKey);
323
+ var database = dbName(name);
370
324
  var newPassword = randomBytes(24).toString("base64url");
371
-
372
- // Update SQL user password
373
- await updateSqlUser(token, cfg.project, dbId, "relight", newPassword);
374
-
375
- // Get public IP for connection URL
376
- var instance = await getSqlInstance(token, cfg.project, dbId);
377
- var publicIp = null;
378
- for (var addr of (instance.ipAddresses || [])) {
379
- if (addr.type === "PRIMARY") {
380
- publicIp = addr.ipAddress;
381
- break;
382
- }
383
- }
384
-
385
- var connectionUrl = publicIp
386
- ? `postgresql://relight:${encodeURIComponent(newPassword)}@${publicIp}:5432/${database}`
387
- : null;
388
-
389
- if (!opts.skipAppConfig) {
390
- if (!appConfig) {
391
- appConfig = await getAppConfig(cfg, appName);
392
- }
393
-
394
- // Update app config
395
- if (!appConfig.envKeys) appConfig.envKeys = [];
396
- if (!appConfig.secretKeys) appConfig.secretKeys = [];
397
- if (!appConfig.env) appConfig.env = {};
398
-
399
- appConfig.env["DB_TOKEN"] = "[hidden]";
400
- if (!appConfig.secretKeys.includes("DB_TOKEN")) appConfig.secretKeys.push("DB_TOKEN");
401
- appConfig.envKeys = appConfig.envKeys.filter((k) => k !== "DB_TOKEN");
402
-
403
- if (connectionUrl) {
404
- appConfig.env["DATABASE_URL"] = connectionUrl;
405
- if (!appConfig.envKeys.includes("DATABASE_URL")) appConfig.envKeys.push("DATABASE_URL");
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();
406
335
  }
407
-
408
- await pushAppConfig(cfg, appName, appConfig, { newSecrets: { DB_TOKEN: newPassword } });
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;
409
346
  }
410
347
 
411
348
  return { dbToken: newPassword, connectionUrl };
412
349
  }
413
350
 
414
- export async function resetDatabase(cfg, appName, opts = {}) {
415
- var dbId = opts.dbId;
416
- var database;
417
- if (!dbId) {
418
- var appConfig = await getAppConfig(cfg, appName);
419
- if (!appConfig || !appConfig.dbId) {
420
- throw new Error(`App ${appName} does not have a database.`);
421
- }
422
- dbId = appConfig.dbId;
423
- database = appConfig.dbName;
424
- } else {
425
- database = dbName(appName);
426
- }
427
-
428
- var password = await getDbPassword(cfg, appName);
429
- var token = await mintAccessToken(cfg.clientEmail, cfg.privateKey);
430
- var instance = await getSqlInstance(token, cfg.project, dbId);
431
-
432
- var publicIp = null;
433
- for (var addr of (instance.ipAddresses || [])) {
434
- if (addr.type === "PRIMARY") {
435
- publicIp = addr.ipAddress;
436
- break;
437
- }
351
+ export async function resetDatabase(cfg, name, opts = {}) {
352
+ if (!opts.connectionUrl) {
353
+ throw new Error("connectionUrl is required to reset a GCP database.");
438
354
  }
439
355
 
440
- var connectionUrl = `postgresql://relight:${encodeURIComponent(password)}@${publicIp}:5432/${database}`;
441
- var client = await connectPg(connectionUrl);
356
+ var client = await connectPg(opts.connectionUrl);
442
357
 
443
358
  try {
444
359
  var tablesRes = await client.query(