@sysnee/pgs 0.1.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/manager.js ADDED
@@ -0,0 +1,510 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs, { readFileSync } from 'fs';
4
+ import path from 'path';
5
+ import yaml from 'js-yaml';
6
+ import { Command } from 'commander';
7
+ import { execSync } from 'child_process';
8
+ import generateRandomPassword from '../common/helpers/password-generator.js';
9
+
10
+ const COMPOSE_FILE = path.join(process.cwd(), 'docker-compose.yml');
11
+ const INIT_DIR = path.join(process.cwd(), 'init');
12
+ const HAPROXY_CFG = path.join(process.cwd(), 'haproxy.cfg');
13
+ const TENANT_ACCESS_FILE = path.join(process.cwd(), 'tenant-access.json');
14
+
15
+ function loadCompose() {
16
+ if (!fs.existsSync(COMPOSE_FILE)) {
17
+ throw new Error('docker-compose.yml not found');
18
+ }
19
+ const content = fs.readFileSync(COMPOSE_FILE, 'utf8');
20
+ return yaml.load(content);
21
+ }
22
+
23
+ function saveCompose(doc) {
24
+ const content = yaml.dump(doc, { indent: 2, lineWidth: -1 });
25
+ fs.writeFileSync(COMPOSE_FILE, content);
26
+ }
27
+
28
+ // ==================== Tenant Access Management ====================
29
+
30
+ function loadTenantAccess() {
31
+ if (!fs.existsSync(TENANT_ACCESS_FILE)) {
32
+ fs.writeFileSync(TENANT_ACCESS_FILE, '{}');
33
+ }
34
+ const content = fs.readFileSync(TENANT_ACCESS_FILE, 'utf8');
35
+ return JSON.parse(content) || {};
36
+ }
37
+
38
+ function saveTenantAccess(access) {
39
+ const content = JSON.stringify(access, null, 2);
40
+ fs.writeFileSync(TENANT_ACCESS_FILE, content);
41
+ }
42
+
43
+ function setTenantAccess(tenantId, enabled) {
44
+ const access = loadTenantAccess();
45
+ access[tenantId] = enabled;
46
+ saveTenantAccess(access);
47
+ }
48
+
49
+ function removeTenantAccess(tenantId) {
50
+ const access = loadTenantAccess();
51
+ delete access[tenantId];
52
+ saveTenantAccess(access);
53
+ }
54
+
55
+ // ==================== HAProxy Configuration ====================
56
+
57
+ function generateHAProxyConfig() {
58
+ const doc = loadCompose();
59
+
60
+ // Get all tenant services
61
+ const tenants = Object.keys(doc.services || {})
62
+ .filter(name => name.startsWith('pgs_') && name !== 'haproxy');
63
+
64
+ // Generate full HAProxy config
65
+ let config = `global
66
+ log stdout format raw local0 info
67
+ maxconn 4096
68
+ lua-load /etc/haproxy/lua/pg-route.lua
69
+
70
+ defaults
71
+ log global
72
+ mode tcp
73
+ option tcplog
74
+ timeout connect 5s
75
+ timeout client 30s
76
+ timeout server 30s
77
+ retries 3
78
+
79
+ frontend postgres_frontend
80
+ bind *:5432
81
+ mode tcp
82
+
83
+ # Use Lua script to parse PostgreSQL startup packet and route by username
84
+ tcp-request inspect-delay 5s
85
+ tcp-request content lua.pg_route
86
+ tcp-request content reject if { var(txn.pg_blocked) -m bool }
87
+
88
+ # Route to backend based on username extracted by Lua
89
+ use_backend %[var(txn.pg_backend)] if { var(txn.pg_backend) -m found }
90
+
91
+ # Default backend for unknown connections
92
+ default_backend pg_reject
93
+
94
+ backend pg_reject
95
+ mode tcp
96
+ timeout server 1s
97
+
98
+ backend pg_ssl_pool
99
+ mode tcp
100
+ `;
101
+
102
+ // Add all tenant backends to SSL pool for SSL negotiation
103
+ // PostgreSQL will respond 'N' (no SSL) during negotiation, then client retries without SSL
104
+ if (tenants.length > 0) {
105
+ for (const serviceName of tenants) {
106
+ config += ` server ${serviceName}_ssl ${serviceName}:5432 check
107
+ `;
108
+ }
109
+ config += `
110
+ `;
111
+ } else {
112
+ config += ` # No tenants configured yet
113
+ `;
114
+ }
115
+
116
+ // Generate backend for each tenant
117
+ for (const serviceName of tenants) {
118
+ config += `backend ${serviceName}
119
+ mode tcp
120
+ server pg1 ${serviceName}:5432 check
121
+
122
+ `;
123
+ }
124
+
125
+ fs.writeFileSync(HAPROXY_CFG, config);
126
+ }
127
+
128
+ function updateHAProxyDependsOn() {
129
+ const doc = loadCompose();
130
+
131
+ // Get all tenant services
132
+ const tenants = Object.keys(doc.services || {})
133
+ .filter(name => name.startsWith('pgs_') && name !== 'haproxy');
134
+
135
+ // Ensure HAProxy service exists
136
+ if (!doc.services.haproxy) {
137
+ doc.services.haproxy = {
138
+ image: 'haproxy:latest',
139
+ container_name: 'postgres_proxy',
140
+ ports: ['5432:5432'],
141
+ volumes: [
142
+ './haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro',
143
+ './haproxy-lua:/etc/haproxy/lua:ro',
144
+ './tenant-access.json:/etc/haproxy/tenant-access.json:ro'
145
+ ],
146
+ networks: ['postgres_network'],
147
+ restart: 'unless-stopped'
148
+ };
149
+ }
150
+
151
+ // Update depends_on
152
+ if (tenants.length > 0) {
153
+ doc.services.haproxy.depends_on = tenants;
154
+ } else {
155
+ delete doc.services.haproxy.depends_on;
156
+ }
157
+
158
+ saveCompose(doc);
159
+
160
+ // Restart HAProxy
161
+ executeCommand('sudo docker restart postgres_proxy');
162
+ }
163
+
164
+ function createInitScript({ tenantId, password, databaseName }) {
165
+ if (!fs.existsSync(INIT_DIR)) {
166
+ fs.mkdirSync(INIT_DIR, { recursive: true });
167
+ }
168
+
169
+ const initFile = path.join(INIT_DIR, `init-${tenantId}.sql`);
170
+ const sql = `CREATE ROLE ${tenantId} WITH LOGIN PASSWORD '${password}' SUPERUSER CREATEDB CREATEROLE;
171
+ CREATE DATABASE ${databaseName} OWNER ${tenantId};
172
+ GRANT ALL PRIVILEGES ON DATABASE ${databaseName} TO ${tenantId};
173
+ `;
174
+
175
+ fs.writeFileSync(initFile, sql);
176
+ return initFile;
177
+ }
178
+
179
+ const DEFAULT_LIMITS = { cpu: 0.5, memory: 256 };
180
+
181
+ function createService({ tenantId, password, databaseName, version = '18', limits = DEFAULT_LIMITS }) {
182
+ const containerName = `pgs_${tenantId}`;
183
+ const volumeName = `pgdata_${tenantId}`;
184
+
185
+ return {
186
+ image: `postgres:${version}`,
187
+ container_name: containerName,
188
+ environment: {
189
+ POSTGRES_PASSWORD: password,
190
+ POSTGRES_DB: databaseName,
191
+ },
192
+ expose: ['5432'],
193
+ volumes: [
194
+ `${volumeName}:/var/lib/postgresql`,
195
+ `./init/init-${tenantId}.sql:/docker-entrypoint-initdb.d/init-${tenantId}.sql:ro`,
196
+ ],
197
+ networks: ['postgres_network'],
198
+ restart: 'unless-stopped',
199
+ deploy: {
200
+ resources: {
201
+ limits: {
202
+ cpus: String(limits.cpu),
203
+ memory: `${limits.memory}M`,
204
+ },
205
+ },
206
+ },
207
+ };
208
+ }
209
+
210
+ function ensureNetwork(doc) {
211
+ if (!doc.networks) {
212
+ doc.networks = {};
213
+ }
214
+ if (!doc.networks.postgres_network) {
215
+ doc.networks.postgres_network = {
216
+ driver: 'bridge'
217
+ };
218
+ }
219
+ }
220
+
221
+ function createTenant(tenantId, options = {}) {
222
+ const { version = '18', password, limits = DEFAULT_LIMITS } = options;
223
+ const doc = loadCompose();
224
+
225
+ if (doc.services && doc.services[`pgs_${tenantId}`]) {
226
+ throw new Error(`Tenant ${tenantId} already exists`);
227
+ }
228
+
229
+ if (!doc.services) {
230
+ doc.services = {};
231
+ }
232
+
233
+ if (!doc.volumes) {
234
+ doc.volumes = {};
235
+ }
236
+
237
+ createInitScript({ tenantId, password, databaseName: tenantId });
238
+
239
+ const serviceName = `pgs_${tenantId}`;
240
+ doc.services[serviceName] = createService({ tenantId, password, databaseName: tenantId, version, limits });
241
+
242
+ const volumeName = `pgdata_${tenantId}`;
243
+ if (!doc.volumes[volumeName]) {
244
+ doc.volumes[volumeName] = null;
245
+ }
246
+
247
+ ensureNetwork(doc);
248
+
249
+ saveCompose(doc);
250
+
251
+ // Add tenant to access control (disabled by default for security)
252
+ setTenantAccess(tenantId, true);
253
+
254
+ // Regenerate HAProxy config and update depends_on
255
+ generateHAProxyConfig();
256
+ updateHAProxyDependsOn();
257
+
258
+ // start the tenant
259
+ executeCommand(`sudo docker compose up -d ${serviceName}`.trim());
260
+
261
+ console.log(`✓ Created tenant: ${tenantId}`);
262
+ console.log(` Service: ${serviceName}`);
263
+ console.log(` Port: 5432`);
264
+ console.log(` Database: ${tenantId}`);
265
+ console.log(` Password: ${password}`);
266
+ console.log(` Access: enabled (use 'disable-access ${tenantId}' to disable)`);
267
+
268
+ return { tenantId, serviceName };
269
+ }
270
+
271
+ function listTenants() {
272
+ const doc = loadCompose();
273
+ const access = loadTenantAccess();
274
+
275
+ if (!doc.services) {
276
+ console.log('No tenants found');
277
+ return;
278
+ }
279
+
280
+ const tenants = Object.keys(doc.services)
281
+ .filter(name => name.startsWith('pgs_') && name !== 'haproxy')
282
+ .map(name => {
283
+ const service = doc.services[name];
284
+ const db = service.environment?.POSTGRES_DB || 'N/A';
285
+ const tenantId = name.replace('pgs_', '');
286
+ const accessEnabled = access[tenantId] === true;
287
+
288
+ return { tenantId, db, service: name, access: accessEnabled };
289
+ });
290
+
291
+ if (tenants.length === 0) {
292
+ console.log('No tenants found');
293
+ return;
294
+ }
295
+
296
+ console.log('\nTenants:');
297
+ console.log('─'.repeat(75));
298
+ console.log(` ${'ID'.padEnd(20)} ${'Database'.padEnd(20)} ${'Access'.padEnd(15)}`);
299
+ console.log('─'.repeat(75));
300
+ tenants.forEach(t => {
301
+ const accessStr = t.access ? '✓ enabled' : '✗ disabled';
302
+ console.log(` ${t.tenantId.padEnd(20)} ${t.db.padEnd(20)} ${accessStr}`);
303
+ });
304
+ console.log('─'.repeat(75));
305
+ }
306
+
307
+ function enableTenantAccess(tenantId) {
308
+ const doc = loadCompose();
309
+ const serviceName = `pgs_${tenantId}`;
310
+
311
+ if (!doc.services || !doc.services[serviceName]) {
312
+ throw new Error(`Tenant ${tenantId} not found`);
313
+ }
314
+
315
+ // Update access control
316
+ setTenantAccess(tenantId, true);
317
+
318
+ // Restart HAProxy
319
+ executeCommand('sudo docker restart postgres_proxy');
320
+
321
+ console.log(`✓ External access enabled for tenant: ${tenantId}`);
322
+ }
323
+
324
+ function disableTenantAccess(tenantId) {
325
+ const doc = loadCompose();
326
+ const serviceName = `pgs_${tenantId}`;
327
+
328
+ if (!doc.services || !doc.services[serviceName]) {
329
+ throw new Error(`Tenant ${tenantId} not found`);
330
+ }
331
+
332
+ // Update access control
333
+ setTenantAccess(tenantId, false);
334
+
335
+ // Restart HAProxy
336
+ executeCommand('sudo docker restart postgres_proxy');
337
+
338
+ console.log(`✓ External access disabled for tenant: ${tenantId}`);
339
+ }
340
+
341
+ function removeTenant(tenantId) {
342
+ const doc = loadCompose();
343
+ const serviceName = `pgs_${tenantId}`;
344
+
345
+ if (!doc.services || !doc.services[serviceName]) {
346
+ throw new Error(`Tenant ${tenantId} not found`);
347
+ }
348
+
349
+ delete doc.services[serviceName];
350
+
351
+ const volumeName = `pgdata_${tenantId}`;
352
+ if (doc.volumes && doc.volumes[volumeName]) {
353
+ delete doc.volumes[volumeName];
354
+ }
355
+
356
+ const initFile = path.join(INIT_DIR, `init-${tenantId}.sql`);
357
+ if (fs.existsSync(initFile)) {
358
+ fs.unlinkSync(initFile);
359
+ }
360
+
361
+ saveCompose(doc);
362
+
363
+ // Remove from tenant access and regenerate HAProxy config
364
+ removeTenantAccess(tenantId);
365
+ generateHAProxyConfig();
366
+ updateHAProxyDependsOn();
367
+
368
+ console.log(`✓ Removed tenant: ${tenantId}`);
369
+ console.log(` Run: docker compose down ${serviceName}`);
370
+ console.log(` Run: docker volume rm pgs_${volumeName}`);
371
+ }
372
+
373
+ function executeCommand(command) {
374
+ try {
375
+ execSync(command, { stdio: 'inherit', cwd: process.cwd() });
376
+ } catch (error) {
377
+ console.error(`Error executing: ${command}`);
378
+ process.exit(1);
379
+ }
380
+ }
381
+
382
+ const program = new Command();
383
+
384
+ program
385
+ .name('postgres-manager')
386
+ .description('Manage PostgreSQL tenant instances')
387
+ .version('1.0.0');
388
+
389
+ program
390
+ .command('create')
391
+ .description('Create a new PostgreSQL tenant instance')
392
+ .argument('<tenant-id>', 'Tenant identifier')
393
+ .option('-f, --file <file>', 'Manifest file path')
394
+ .option('-p, --password <password>', 'Database password')
395
+ .option('-v, --version <version>', 'PostgreSQL version', '18')
396
+ .option('--cpu <cpu>', 'CPU limit (cores)', '0.5')
397
+ .option('--memory <memory>', 'Memory limit (MB)', '256')
398
+ .action((...args) => {
399
+ try {
400
+ const providedTenantId = args[0];
401
+ const randomSuffix = Math.random().toString(36).substring(2, 8);
402
+ const tenantId = `${providedTenantId}_${randomSuffix}`;
403
+
404
+ const opts = args[1];
405
+
406
+ const tenantOptions = {
407
+ password: null,
408
+ version: null,
409
+ limits: null
410
+ }
411
+
412
+ if (opts.file) {
413
+ const manifestFilePath = path.resolve(opts.file)
414
+ const optsFromManifest = JSON.parse(readFileSync(manifestFilePath))
415
+
416
+ if (optsFromManifest.type !== 'postgres') {
417
+ throw new Error(`The type "${optsFromManifest.type}" is not valid for this agent.`)
418
+ }
419
+ console.log('optsFromManifest', optsFromManifest)
420
+
421
+ tenantOptions.limits = optsFromManifest.shared_limits
422
+ } else {
423
+ tenantOptions.version = opts.version;
424
+ tenantOptions.limits = {
425
+ cpu: parseFloat(opts.cpu),
426
+ memory: parseInt(opts.memory, 10),
427
+ };
428
+ }
429
+
430
+ tenantOptions.password = opts.password || generateRandomPassword();
431
+
432
+ createTenant(tenantId, tenantOptions);
433
+ } catch (error) {
434
+ console.error(`Error: ${error}`);
435
+ process.exit(1);
436
+ }
437
+ });
438
+
439
+ program
440
+ .command('list')
441
+ .description('List all tenant instances')
442
+ .action(() => {
443
+ try {
444
+ listTenants();
445
+ } catch (error) {
446
+ console.error(`Error: ${error.message}`);
447
+ process.exit(1);
448
+ }
449
+ });
450
+
451
+ program
452
+ .command('remove')
453
+ .description('Remove a tenant instance')
454
+ .argument('<tenant-id>', 'Tenant identifier')
455
+ .action((tenantId) => {
456
+ try {
457
+ removeTenant(tenantId);
458
+ } catch (error) {
459
+ console.error(`Error: ${error.message}`);
460
+ process.exit(1);
461
+ }
462
+ });
463
+
464
+ program
465
+ .command('start')
466
+ .description('Start all PostgreSQL services')
467
+ .argument('[tenant-id]', 'Tenant identifier (optional, starts all if omitted)')
468
+ .action((tenantId) => {
469
+ const service = tenantId ? `pgs_${tenantId}` : '';
470
+ executeCommand(`sudo docker compose up -d ${service}`.trim());
471
+ });
472
+
473
+ program
474
+ .command('stop')
475
+ .description('Stop all PostgreSQL services')
476
+ .argument('[tenant-id]', 'Tenant identifier (optional, stops all if omitted)')
477
+ .action((tenantId) => {
478
+ const service = tenantId ? `pgs_${tenantId}` : '';
479
+ executeCommand(`sudo docker compose stop ${service}`.trim());
480
+ executeCommand('sudo docker restart postgres_proxy');
481
+ });
482
+
483
+ program
484
+ .command('enable-access')
485
+ .description('Enable external access for a tenant')
486
+ .argument('<tenant-id>', 'Tenant identifier')
487
+ .action((tenantId) => {
488
+ try {
489
+ enableTenantAccess(tenantId);
490
+ } catch (error) {
491
+ console.error(`Error: ${error.message}`);
492
+ process.exit(1);
493
+ }
494
+ });
495
+
496
+ program
497
+ .command('disable-access')
498
+ .description('Disable external access for a tenant')
499
+ .argument('<tenant-id>', 'Tenant identifier')
500
+ .action((tenantId) => {
501
+ try {
502
+ disableTenantAccess(tenantId);
503
+ } catch (error) {
504
+ console.error(`Error: ${error.message}`);
505
+ process.exit(1);
506
+ }
507
+ });
508
+
509
+ program.parse();
510
+
package/manifest.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "type": "deploy",
3
+ "version": "18",
4
+ "tier": 1,
5
+ "purpose": "development",
6
+ "storage": 2,
7
+ "region": "us-central-1",
8
+ "provider": "gcp",
9
+ "zone": "a",
10
+ "shared": true,
11
+ "shared_limits": {
12
+ "cpu": 0.5,
13
+ "memory": 512
14
+ },
15
+ "postgres": {
16
+ "port": 5432,
17
+ "username": "postgres",
18
+ "password": "postgres",
19
+ "default_database": "postgres"
20
+ },
21
+ "firewall": {
22
+ "rules": [
23
+ {
24
+ "description": "Allow all traffic",
25
+ "type": "allow",
26
+ "protocol": "tcp",
27
+ "port": 5432,
28
+ "source": "0.0.0.0/0"
29
+ }
30
+ ]
31
+ }
32
+ }
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@sysnee/pgs",
3
+ "version": "0.1.0",
4
+ "description": "Dynamic PostgreSQL service instance manager",
5
+ "type": "module",
6
+ "bin": {
7
+ "pgs": "./manager.js"
8
+ },
9
+ "scripts": {
10
+ "create": "node manager.js create",
11
+ "list": "node manager.js list",
12
+ "remove": "node manager.js remove",
13
+ "start": "node manager.js start",
14
+ "stop": "node manager.js stop",
15
+ "enable-access": "node manager.js enable-access",
16
+ "disable-access": "node manager.js disable-access",
17
+ "reload-haproxy": "node manager.js reload-haproxy"
18
+ },
19
+ "dependencies": {
20
+ "js-yaml": "^4.1.0",
21
+ "commander": "^11.1.0"
22
+ }
23
+ }
24
+