@sysnee/pgs 0.1.7-rc.11 → 0.1.7-rc.12
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/docs/DEV_SETUP.md +282 -0
- package/manager.js +255 -15
- package/package.json +1 -1
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
# Local Dev Environment — PGS
|
|
2
|
+
|
|
3
|
+
Simulates the production setup (Node.js + `pgs` CLI + Traefik + PostgreSQL containers) locally, without installing the package globally or needing real DNS/SSL certificates.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
| Concern | Production | Local Dev |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| Domain | `pgs.br-sp-1.sysnee.com` | `pgs.local` |
|
|
10
|
+
| DNS | Real DNS wildcard A record | dnsmasq (`*.pgs.local → 127.0.0.1`) |
|
|
11
|
+
| TLS cert | Let's Encrypt wildcard | Self-signed wildcard CA |
|
|
12
|
+
| CLI | `pgs` (global npm install) | `node ./manager.js` (source directly) |
|
|
13
|
+
| Setup | `pgs setup` (certbot, installs Docker) | `dev-setup.sh` (manual bootstrap) |
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Prerequisites
|
|
18
|
+
|
|
19
|
+
- Docker & Docker Compose installed
|
|
20
|
+
- Node.js 24
|
|
21
|
+
- `dnsmasq` (install below)
|
|
22
|
+
- `openssl` (usually pre-installed)
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## Step 1 — Make `DOMAIN` configurable in `manager.js`
|
|
27
|
+
|
|
28
|
+
Change line 20 from:
|
|
29
|
+
|
|
30
|
+
```js
|
|
31
|
+
const DOMAIN = 'pgs.br-sp-1.sysnee.com';
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
To:
|
|
35
|
+
|
|
36
|
+
```js
|
|
37
|
+
const DOMAIN = process.env.PGS_DOMAIN || 'pgs.br-sp-1.sysnee.com';
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
This is the only code change needed. Production behavior is unaffected (env var not set → falls back to real domain).
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Step 2 — Install and configure dnsmasq
|
|
45
|
+
|
|
46
|
+
dnsmasq resolves all `*.pgs.local` subdomains to `127.0.0.1` automatically, without needing to edit `/etc/hosts` per tenant.
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
sudo apt-get install -y dnsmasq
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Add the wildcard rule:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
echo "address=/.pgs.local/127.0.0.1" | sudo tee /etc/dnsmasq.d/pgs-local.conf
|
|
56
|
+
sudo systemctl restart dnsmasq
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
On systems using `systemd-resolved` (Ubuntu 20.04+), you need to tell it to use dnsmasq for `.local` queries. Check if it's active:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
systemctl is-active systemd-resolved
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
If active, add to `/etc/systemd/resolved.conf`:
|
|
66
|
+
|
|
67
|
+
```ini
|
|
68
|
+
[Resolve]
|
|
69
|
+
DNS=127.0.0.1
|
|
70
|
+
Domains=~local
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Then restart:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
sudo systemctl restart systemd-resolved
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Verify resolution works:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
dig tenant1.pgs.local @127.0.0.1
|
|
83
|
+
# or
|
|
84
|
+
nslookup tenant1.pgs.local 127.0.0.1
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## Step 3 — Bootstrap `~/.sysnee-config/` (replaces `pgs setup`)
|
|
90
|
+
|
|
91
|
+
Create the file `pgs/dev-setup.sh`:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
#!/usr/bin/env bash
|
|
95
|
+
set -e
|
|
96
|
+
|
|
97
|
+
CONFIG_DIR="$HOME/.sysnee-config"
|
|
98
|
+
CERTS_DIR="$CONFIG_DIR/certs"
|
|
99
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
100
|
+
|
|
101
|
+
echo "── Bootstrapping ~/.sysnee-config/ ──"
|
|
102
|
+
|
|
103
|
+
mkdir -p "$CERTS_DIR"
|
|
104
|
+
|
|
105
|
+
# Copy template files from source
|
|
106
|
+
cp "$SCRIPT_DIR/docker-compose.yml" "$CONFIG_DIR/docker-compose.yml"
|
|
107
|
+
cp "$SCRIPT_DIR/traefik.yml" "$CONFIG_DIR/traefik.yml"
|
|
108
|
+
|
|
109
|
+
[ -f "$CONFIG_DIR/tenant-access.json" ] || echo '{}' > "$CONFIG_DIR/tenant-access.json"
|
|
110
|
+
|
|
111
|
+
# Write setup status so checkSetupStatus() passes
|
|
112
|
+
echo -n "container:ok" > "$CONFIG_DIR/status.txt"
|
|
113
|
+
|
|
114
|
+
echo "── Generating self-signed wildcard cert for *.pgs.local ──"
|
|
115
|
+
|
|
116
|
+
# CA
|
|
117
|
+
openssl genrsa -out "$CERTS_DIR/ca.key" 4096 2>/dev/null
|
|
118
|
+
openssl req -x509 -new -nodes \
|
|
119
|
+
-key "$CERTS_DIR/ca.key" \
|
|
120
|
+
-sha256 -days 825 \
|
|
121
|
+
-out "$CERTS_DIR/ca.pem" \
|
|
122
|
+
-subj "/CN=PGS Dev CA/O=Local Dev"
|
|
123
|
+
|
|
124
|
+
# Wildcard cert
|
|
125
|
+
openssl genrsa -out "$CERTS_DIR/privkey.pem" 4096 2>/dev/null
|
|
126
|
+
openssl req -new \
|
|
127
|
+
-key "$CERTS_DIR/privkey.pem" \
|
|
128
|
+
-out /tmp/pgs-dev.csr \
|
|
129
|
+
-subj "/CN=*.pgs.local"
|
|
130
|
+
|
|
131
|
+
# SAN required by modern TLS clients (Chrome, DBeaver, psql)
|
|
132
|
+
cat > /tmp/pgs-dev-ext.cnf <<EOF
|
|
133
|
+
subjectAltName=DNS:*.pgs.local,DNS:pgs.local
|
|
134
|
+
basicConstraints=CA:FALSE
|
|
135
|
+
keyUsage=digitalSignature,keyEncipherment
|
|
136
|
+
extendedKeyUsage=serverAuth
|
|
137
|
+
EOF
|
|
138
|
+
|
|
139
|
+
openssl x509 -req \
|
|
140
|
+
-in /tmp/pgs-dev.csr \
|
|
141
|
+
-CA "$CERTS_DIR/ca.pem" \
|
|
142
|
+
-CAkey "$CERTS_DIR/ca.key" \
|
|
143
|
+
-CAcreateserial \
|
|
144
|
+
-out "$CERTS_DIR/fullchain.pem" \
|
|
145
|
+
-days 825 \
|
|
146
|
+
-sha256 \
|
|
147
|
+
-extfile /tmp/pgs-dev-ext.cnf
|
|
148
|
+
|
|
149
|
+
echo "── Generating initial dynamic.yml ──"
|
|
150
|
+
cat > "$CONFIG_DIR/dynamic.yml" <<'EOF'
|
|
151
|
+
tcp:
|
|
152
|
+
routers: {}
|
|
153
|
+
services: {}
|
|
154
|
+
tls:
|
|
155
|
+
certificates:
|
|
156
|
+
- certFile: /etc/traefik/certs/fullchain.pem
|
|
157
|
+
keyFile: /etc/traefik/certs/privkey.pem
|
|
158
|
+
EOF
|
|
159
|
+
|
|
160
|
+
echo ""
|
|
161
|
+
echo "✓ Dev environment ready at $CONFIG_DIR"
|
|
162
|
+
echo ""
|
|
163
|
+
echo "Cert files:"
|
|
164
|
+
echo " CA: $CERTS_DIR/ca.pem ← import this into DBeaver / OS trust store"
|
|
165
|
+
echo " Cert: $CERTS_DIR/fullchain.pem"
|
|
166
|
+
echo " Key: $CERTS_DIR/privkey.pem"
|
|
167
|
+
echo ""
|
|
168
|
+
echo "Next: PGS_DOMAIN=pgs.local node ./manager.js create <tenant-id>"
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Make it executable:
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
chmod +x pgs/dev-setup.sh
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Run once:
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
cd pgs && ./dev-setup.sh
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## Step 4 — Workflow: creating and starting a tenant
|
|
186
|
+
|
|
187
|
+
All commands use `node ./manager.js` directly from source. Changes are reflected instantly.
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
# From the pgs/ directory
|
|
191
|
+
export PGS_DOMAIN=pgs.local
|
|
192
|
+
|
|
193
|
+
node ./manager.js create tenant1
|
|
194
|
+
node ./manager.js start tenant1
|
|
195
|
+
|
|
196
|
+
# Verify
|
|
197
|
+
node ./manager.js list
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
No reinstall required between code changes.
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
## Step 5 — Connect from DBeaver
|
|
205
|
+
|
|
206
|
+
### Trust the dev CA (once)
|
|
207
|
+
|
|
208
|
+
DBeaver needs to trust the self-signed CA to accept the TLS connection.
|
|
209
|
+
|
|
210
|
+
In DBeaver: **Window → Preferences → Connections → SSL** → add `~/.sysnee-config/certs/ca.pem` as a trusted CA.
|
|
211
|
+
|
|
212
|
+
Alternatively, add it to the system trust store (so any tool trusts it):
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
sudo cp ~/.sysnee-config/certs/ca.pem /usr/local/share/ca-certificates/pgs-dev-ca.crt
|
|
216
|
+
sudo update-ca-certificates
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
### Connection settings
|
|
220
|
+
|
|
221
|
+
| Field | Value |
|
|
222
|
+
|---|---|
|
|
223
|
+
| Host | `tenant1.pgs.local` |
|
|
224
|
+
| Port | `5432` |
|
|
225
|
+
| Database | `tenant1` |
|
|
226
|
+
| Username | `postgres` |
|
|
227
|
+
| Password | (set at create time) |
|
|
228
|
+
| SSL mode | `require` |
|
|
229
|
+
| SSL CA cert | `~/.sysnee-config/certs/ca.pem` |
|
|
230
|
+
|
|
231
|
+
### psql equivalent
|
|
232
|
+
|
|
233
|
+
```bash
|
|
234
|
+
psql "postgresql://postgres:PASSWORD@tenant1.pgs.local:5432/tenant1?sslmode=require&sslrootcert=$HOME/.sysnee-config/certs/ca.pem"
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
---
|
|
238
|
+
|
|
239
|
+
## Teardown
|
|
240
|
+
|
|
241
|
+
```bash
|
|
242
|
+
export PGS_DOMAIN=pgs.local
|
|
243
|
+
|
|
244
|
+
node ./manager.js stop
|
|
245
|
+
node ./manager.js remove tenant1
|
|
246
|
+
|
|
247
|
+
# Full reset (removes all config and containers)
|
|
248
|
+
docker compose -f ~/.sysnee-config/docker-compose.yml down -v
|
|
249
|
+
rm -rf ~/.sysnee-config
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
---
|
|
253
|
+
|
|
254
|
+
## Troubleshooting
|
|
255
|
+
|
|
256
|
+
**DNS not resolving**
|
|
257
|
+
|
|
258
|
+
```bash
|
|
259
|
+
# Check dnsmasq is running
|
|
260
|
+
sudo systemctl status dnsmasq
|
|
261
|
+
|
|
262
|
+
# Test directly
|
|
263
|
+
dig +short tenant1.pgs.local @127.0.0.1
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
**TLS handshake error in DBeaver / psql**
|
|
267
|
+
|
|
268
|
+
Make sure the CA cert is trusted and `sslrootcert` points to `ca.pem`, not `fullchain.pem`.
|
|
269
|
+
|
|
270
|
+
**`checkSetupStatus()` fails (exits with "Please run pgs setup")**
|
|
271
|
+
|
|
272
|
+
```bash
|
|
273
|
+
echo -n "container:ok" > ~/.sysnee-config/status.txt
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
**Port 5432 already in use**
|
|
277
|
+
|
|
278
|
+
A local PostgreSQL instance may be listening. Stop it temporarily:
|
|
279
|
+
|
|
280
|
+
```bash
|
|
281
|
+
sudo systemctl stop postgresql
|
|
282
|
+
```
|
package/manager.js
CHANGED
|
@@ -8,6 +8,7 @@ import { Command } from 'commander';
|
|
|
8
8
|
import { execSync } from 'child_process';
|
|
9
9
|
import { fileURLToPath } from 'url';
|
|
10
10
|
import { dirname } from 'path';
|
|
11
|
+
import { createInterface } from 'readline';
|
|
11
12
|
|
|
12
13
|
import packageJson from './package.json' with { type: 'json' };
|
|
13
14
|
|
|
@@ -16,6 +17,7 @@ const __filename = fileURLToPath(import.meta.url);
|
|
|
16
17
|
const __dirname = dirname(__filename);
|
|
17
18
|
|
|
18
19
|
const CONFIG_DIR = path.join(os.homedir(), '.sysnee-config');
|
|
20
|
+
const DOMAIN = 'pgs.br-sp-1.sysnee.com';
|
|
19
21
|
|
|
20
22
|
const COMPOSE_FILE_PATH = path.join(CONFIG_DIR, 'docker-compose.yml');
|
|
21
23
|
const INIT_DIR_PATH = path.join(CONFIG_DIR, 'init');
|
|
@@ -114,7 +116,10 @@ function generateTraefikDynamicConfig(tenantsManifests = {}) {
|
|
|
114
116
|
|
|
115
117
|
if (sourceRanges.length > 0) {
|
|
116
118
|
router.middlewares = [middlewareName];
|
|
117
|
-
|
|
119
|
+
|
|
120
|
+
if (!dynamicConfig.tcp.middlewares) {
|
|
121
|
+
dynamicConfig.tcp.middlewares = {};
|
|
122
|
+
}
|
|
118
123
|
if (!dynamicConfig.tcp.middlewares[middlewareName]) {
|
|
119
124
|
dynamicConfig.tcp.middlewares[middlewareName] = {
|
|
120
125
|
ipAllowList: {
|
|
@@ -230,15 +235,15 @@ function ensureNetwork(doc) {
|
|
|
230
235
|
}
|
|
231
236
|
}
|
|
232
237
|
|
|
233
|
-
function initialSetup() {
|
|
238
|
+
async function initialSetup() {
|
|
234
239
|
installDocker()
|
|
235
240
|
createInitialFiles()
|
|
236
241
|
ensureDockerPrivilegies()
|
|
242
|
+
await setupCertificates()
|
|
237
243
|
console.log('All ready!')
|
|
238
244
|
}
|
|
239
245
|
|
|
240
246
|
function installDocker() {
|
|
241
|
-
|
|
242
247
|
try {
|
|
243
248
|
execSync('docker info', { stdio: 'pipe' })
|
|
244
249
|
console.log('Docker already installed')
|
|
@@ -249,7 +254,7 @@ function installDocker() {
|
|
|
249
254
|
execSync('curl -fsSL https://get.docker.com -o get-docker.sh && sudo sh get-docker.sh', { stdio: 'inherit' })
|
|
250
255
|
execSync('rm get-docker.sh')
|
|
251
256
|
console.log('Docker installed successfully')
|
|
252
|
-
|
|
257
|
+
|
|
253
258
|
console.log('Installing Docker Compose plugin...')
|
|
254
259
|
execSync('sudo apt-get install -y docker-compose-plugin', { stdio: 'inherit' })
|
|
255
260
|
console.log('Docker Compose plugin installed successfully')
|
|
@@ -260,6 +265,107 @@ function ensureDockerPrivilegies() {
|
|
|
260
265
|
writeFileSync(SETUP_STATUS_PATH, 'container:ok')
|
|
261
266
|
}
|
|
262
267
|
|
|
268
|
+
function waitForEnter(message) {
|
|
269
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
270
|
+
return new Promise(resolve => {
|
|
271
|
+
rl.question(message, () => {
|
|
272
|
+
rl.close();
|
|
273
|
+
resolve();
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function installCertbot() {
|
|
279
|
+
try {
|
|
280
|
+
execSync('certbot --version', { stdio: 'pipe' });
|
|
281
|
+
console.log('Certbot already installed');
|
|
282
|
+
return;
|
|
283
|
+
} catch (_) {}
|
|
284
|
+
|
|
285
|
+
console.log('Installing certbot...');
|
|
286
|
+
execSync('sudo apt-get install -y certbot', { stdio: 'inherit' });
|
|
287
|
+
console.log('Certbot installed successfully');
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function findCertbotLivePath() {
|
|
291
|
+
const basePath = '/etc/letsencrypt/live';
|
|
292
|
+
const candidates = [
|
|
293
|
+
path.join(basePath, DOMAIN),
|
|
294
|
+
path.join(basePath, `*.${DOMAIN}`),
|
|
295
|
+
];
|
|
296
|
+
|
|
297
|
+
try {
|
|
298
|
+
const dirs = fs.readdirSync(basePath);
|
|
299
|
+
for (const dir of dirs) {
|
|
300
|
+
const p = path.join(basePath, dir);
|
|
301
|
+
if (!candidates.includes(p)) candidates.push(p);
|
|
302
|
+
}
|
|
303
|
+
} catch (_) {}
|
|
304
|
+
|
|
305
|
+
for (const candidate of candidates) {
|
|
306
|
+
const fullchain = path.join(candidate, 'fullchain.pem');
|
|
307
|
+
const privkey = path.join(candidate, 'privkey.pem');
|
|
308
|
+
if (existsSync(fullchain) && existsSync(privkey)) {
|
|
309
|
+
return candidate;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function copyCertificates(retries = 5, retryDelaySecs = 3) {
|
|
316
|
+
mkdirSync(CERTS_DIR_PATH, { recursive: true });
|
|
317
|
+
|
|
318
|
+
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
319
|
+
const certPath = findCertbotLivePath();
|
|
320
|
+
if (certPath) {
|
|
321
|
+
const username = os.userInfo().username;
|
|
322
|
+
execSync(`sudo cp "${path.join(certPath, 'fullchain.pem')}" "${path.join(CERTS_DIR_PATH, 'fullchain.pem')}"`, { stdio: 'inherit' });
|
|
323
|
+
execSync(`sudo cp "${path.join(certPath, 'privkey.pem')}" "${path.join(CERTS_DIR_PATH, 'privkey.pem')}"`, { stdio: 'inherit' });
|
|
324
|
+
execSync(`sudo chown ${username}:${username} "${CERTS_DIR_PATH}/fullchain.pem" "${CERTS_DIR_PATH}/privkey.pem"`, { stdio: 'inherit' });
|
|
325
|
+
console.log(`✓ Certificates copied from ${certPath}`);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (attempt < retries) {
|
|
329
|
+
console.log(`Certificate path not found, retrying in ${retryDelaySecs}s... (${attempt}/${retries})`);
|
|
330
|
+
execSync(`sleep ${retryDelaySecs}`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
throw new Error('Could not find certbot certificates. Check /etc/letsencrypt/live/');
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function setupCertificates() {
|
|
337
|
+
installCertbot();
|
|
338
|
+
|
|
339
|
+
let serverIp = 'YOUR_SERVER_IP';
|
|
340
|
+
try {
|
|
341
|
+
serverIp = execSync('curl -s --max-time 5 ifconfig.me || hostname -I | awk \'{print $1}\'', { stdio: 'pipe' }).toString().trim();
|
|
342
|
+
} catch (_) {}
|
|
343
|
+
|
|
344
|
+
console.log('\n── DNS Setup Required ────────────────────────────');
|
|
345
|
+
console.log(` Add the following A records in your DNS provider:`);
|
|
346
|
+
console.log(` ${DOMAIN} → ${serverIp}`);
|
|
347
|
+
console.log(` *.${DOMAIN} → ${serverIp}`);
|
|
348
|
+
console.log('──────────────────────────────────────────────────\n');
|
|
349
|
+
await waitForEnter('Press Enter once the A records are set (propagation may take a few minutes)...\n');
|
|
350
|
+
|
|
351
|
+
console.log('Running certbot DNS-01 challenge...');
|
|
352
|
+
console.log(`Certbot will ask you to create a TXT record at _acme-challenge.${DOMAIN}`);
|
|
353
|
+
execSync(
|
|
354
|
+
`sudo certbot certonly --manual --preferred-challenges dns -d "*.${DOMAIN}" --agree-tos`,
|
|
355
|
+
{ stdio: 'inherit' }
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
console.log('Copying certificates...');
|
|
359
|
+
copyCertificates();
|
|
360
|
+
|
|
361
|
+
console.log('Restarting Traefik...');
|
|
362
|
+
try {
|
|
363
|
+
execSync('docker restart postgres_proxy', { stdio: 'inherit' });
|
|
364
|
+
} catch (_) {
|
|
365
|
+
console.log('Traefik not running yet, skipping restart');
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
263
369
|
function createInitialFiles() {
|
|
264
370
|
// config dir
|
|
265
371
|
if (!existsSync(CONFIG_DIR)) {
|
|
@@ -445,10 +551,21 @@ function removeTenant(tenantId, manifests = {}) {
|
|
|
445
551
|
throw new Error(`Tenant ${tenantId} not found`);
|
|
446
552
|
}
|
|
447
553
|
|
|
554
|
+
try {
|
|
555
|
+
execSync(`docker compose stop ${serviceName}`, { stdio: 'inherit', cwd: CONFIG_DIR });
|
|
556
|
+
} catch (err) {
|
|
557
|
+
console.warn(`Warning: failed to stop ${serviceName}: ${err.message}`);
|
|
558
|
+
}
|
|
559
|
+
try {
|
|
560
|
+
execSync(`docker compose rm -f ${serviceName}`, { stdio: 'inherit', cwd: CONFIG_DIR });
|
|
561
|
+
} catch (err) {
|
|
562
|
+
console.warn(`Warning: failed to remove container ${serviceName}: ${err.message}`);
|
|
563
|
+
}
|
|
564
|
+
|
|
448
565
|
delete doc.services[serviceName];
|
|
449
566
|
|
|
450
567
|
const volumeName = `pgdata_${tenantId}`;
|
|
451
|
-
if (doc.volumes && doc.volumes
|
|
568
|
+
if (doc.volumes && Object.prototype.hasOwnProperty.call(doc.volumes, volumeName)) {
|
|
452
569
|
delete doc.volumes[volumeName];
|
|
453
570
|
}
|
|
454
571
|
|
|
@@ -463,9 +580,101 @@ function removeTenant(tenantId, manifests = {}) {
|
|
|
463
580
|
generateTraefikDynamicConfig(manifests);
|
|
464
581
|
updateTraefikService();
|
|
465
582
|
|
|
583
|
+
try {
|
|
584
|
+
const matching = execSync(
|
|
585
|
+
`docker volume ls --format '{{.Name}}' | grep -E '_${volumeName}$' || true`,
|
|
586
|
+
{ stdio: 'pipe', cwd: CONFIG_DIR }
|
|
587
|
+
).toString().trim();
|
|
588
|
+
if (matching) {
|
|
589
|
+
for (const vol of matching.split('\n')) {
|
|
590
|
+
if (vol.trim()) {
|
|
591
|
+
try {
|
|
592
|
+
execSync(`docker volume rm ${vol.trim()}`, { stdio: 'inherit', cwd: CONFIG_DIR });
|
|
593
|
+
} catch (err) {
|
|
594
|
+
console.warn(`Warning: failed to remove volume ${vol}: ${err.message}`);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
} catch (err) {
|
|
600
|
+
console.warn(`Warning: could not enumerate volumes: ${err.message}`);
|
|
601
|
+
}
|
|
602
|
+
|
|
466
603
|
console.log(`✓ Removed tenant: ${tenantId}`);
|
|
467
|
-
|
|
468
|
-
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function getTenantStatus(tenantId) {
|
|
607
|
+
const doc = loadCompose();
|
|
608
|
+
const serviceName = `pgs_${tenantId}`;
|
|
609
|
+
const exists = !!(doc.services && doc.services[serviceName]);
|
|
610
|
+
|
|
611
|
+
if (!exists) {
|
|
612
|
+
return { tenantId, exists: false, running: false };
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
try {
|
|
616
|
+
const inspect = JSON.parse(
|
|
617
|
+
execSync(`docker inspect ${serviceName}`, { stdio: 'pipe' }).toString()
|
|
618
|
+
);
|
|
619
|
+
const state = inspect[0]?.State || {};
|
|
620
|
+
return {
|
|
621
|
+
tenantId,
|
|
622
|
+
exists: true,
|
|
623
|
+
running: state.Running === true,
|
|
624
|
+
status: state.Status,
|
|
625
|
+
startedAt: state.StartedAt,
|
|
626
|
+
};
|
|
627
|
+
} catch (_) {
|
|
628
|
+
return { tenantId, exists: true, running: false, status: 'not-created' };
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function showStatus() {
|
|
633
|
+
console.log('\n── Traefik Proxy ─────────────────────────────────');
|
|
634
|
+
try {
|
|
635
|
+
const inspect = JSON.parse(execSync('docker inspect postgres_proxy', { stdio: 'pipe' }).toString());
|
|
636
|
+
const state = inspect[0]?.State;
|
|
637
|
+
const running = state?.Running;
|
|
638
|
+
const status = state?.Status;
|
|
639
|
+
const startedAt = state?.StartedAt;
|
|
640
|
+
const exitCode = state?.ExitCode;
|
|
641
|
+
|
|
642
|
+
console.log(` Status: ${running ? '✓ running' : '✗ stopped'} (${status})`);
|
|
643
|
+
if (startedAt) console.log(` Started at: ${new Date(startedAt).toLocaleString()}`);
|
|
644
|
+
if (!running && exitCode !== undefined) console.log(` Exit code: ${exitCode}`);
|
|
645
|
+
} catch (_) {
|
|
646
|
+
console.log(' Status: container not found');
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
console.log('\n── Traefik Errors (last 100 lines) ───────────────');
|
|
650
|
+
try {
|
|
651
|
+
const logs = execSync('docker logs postgres_proxy --tail 100 2>&1', { stdio: 'pipe' }).toString();
|
|
652
|
+
const errorLines = logs
|
|
653
|
+
.split('\n')
|
|
654
|
+
.filter(line => /\b(ERR|error|WARN|warn)\b/i.test(line) && line.trim().length > 0);
|
|
655
|
+
|
|
656
|
+
if (errorLines.length === 0) {
|
|
657
|
+
console.log(' No errors or warnings found');
|
|
658
|
+
} else {
|
|
659
|
+
errorLines.forEach(line => console.log(` ${line}`));
|
|
660
|
+
}
|
|
661
|
+
} catch (_) {
|
|
662
|
+
console.log(' Could not read container logs');
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
console.log('\n── Certificates ──────────────────────────────────');
|
|
666
|
+
const fullchainPath = path.join(CERTS_DIR_PATH, 'fullchain.pem');
|
|
667
|
+
if (existsSync(fullchainPath)) {
|
|
668
|
+
try {
|
|
669
|
+
const certInfo = execSync(`openssl x509 -in "${fullchainPath}" -noout -enddate -subject 2>/dev/null`, { stdio: 'pipe' }).toString().trim();
|
|
670
|
+
certInfo.split('\n').forEach(line => console.log(` ${line}`));
|
|
671
|
+
} catch (_) {
|
|
672
|
+
console.log(' Could not read certificate info');
|
|
673
|
+
}
|
|
674
|
+
} else {
|
|
675
|
+
console.log(' ✗ fullchain.pem not found');
|
|
676
|
+
}
|
|
677
|
+
console.log('──────────────────────────────────────────────────\n');
|
|
469
678
|
}
|
|
470
679
|
|
|
471
680
|
function executeCommand(command) {
|
|
@@ -490,9 +699,33 @@ program
|
|
|
490
699
|
|
|
491
700
|
program
|
|
492
701
|
.command('setup')
|
|
493
|
-
.description('Initial required setup')
|
|
494
|
-
.action(() => {
|
|
495
|
-
initialSetup()
|
|
702
|
+
.description('Initial required setup (installs Docker, certbot, configures SSL)')
|
|
703
|
+
.action(async () => {
|
|
704
|
+
await initialSetup()
|
|
705
|
+
})
|
|
706
|
+
|
|
707
|
+
program
|
|
708
|
+
.command('status')
|
|
709
|
+
.description('Show status of postgres_proxy container (or a tenant container if tenant-id is given)')
|
|
710
|
+
.argument('[tenant-id]', 'Tenant identifier (optional)')
|
|
711
|
+
.option('--json', 'Output tenant status as JSON (only with tenant-id)')
|
|
712
|
+
.action((tenantId, opts) => {
|
|
713
|
+
if (tenantId) {
|
|
714
|
+
checkSetupStatus()
|
|
715
|
+
try {
|
|
716
|
+
const info = getTenantStatus(tenantId);
|
|
717
|
+
if (opts.json) {
|
|
718
|
+
process.stdout.write(JSON.stringify(info));
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
console.log(info);
|
|
722
|
+
} catch (error) {
|
|
723
|
+
console.error(`Error: ${error.message}`);
|
|
724
|
+
process.exit(1);
|
|
725
|
+
}
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
showStatus()
|
|
496
729
|
})
|
|
497
730
|
|
|
498
731
|
program
|
|
@@ -509,9 +742,6 @@ program
|
|
|
509
742
|
try {
|
|
510
743
|
|
|
511
744
|
const providedTenantId = args[0];
|
|
512
|
-
const randomSuffix = Math.random().toString(36).substring(2, 8);
|
|
513
|
-
const tenantId = `${providedTenantId}-${randomSuffix}`;
|
|
514
|
-
|
|
515
745
|
const opts = args[1];
|
|
516
746
|
|
|
517
747
|
const tenantOptions = {
|
|
@@ -521,6 +751,8 @@ program
|
|
|
521
751
|
manifest: null
|
|
522
752
|
}
|
|
523
753
|
|
|
754
|
+
let tenantId;
|
|
755
|
+
|
|
524
756
|
if (opts.file) {
|
|
525
757
|
const manifestFilePath = path.resolve(opts.file)
|
|
526
758
|
const optsFromManifest = JSON.parse(readFileSync(manifestFilePath))
|
|
@@ -533,15 +765,23 @@ program
|
|
|
533
765
|
tenantOptions.limits = optsFromManifest.shared_limits
|
|
534
766
|
tenantOptions.version = optsFromManifest.version
|
|
535
767
|
tenantOptions.manifest = optsFromManifest
|
|
768
|
+
tenantOptions.password = opts.password || optsFromManifest.postgres?.password || generateRandomPassword();
|
|
769
|
+
|
|
770
|
+
tenantId = optsFromManifest.slug || providedTenantId;
|
|
771
|
+
if (!tenantId) {
|
|
772
|
+
throw new Error('Manifest must contain "slug" or tenant-id must be provided');
|
|
773
|
+
}
|
|
536
774
|
} else {
|
|
537
775
|
tenantOptions.version = opts.version;
|
|
538
776
|
tenantOptions.limits = {
|
|
539
777
|
cpu: parseFloat(opts.cpu),
|
|
540
778
|
memory: parseInt(opts.memory, 10),
|
|
541
779
|
};
|
|
542
|
-
|
|
780
|
+
tenantOptions.password = opts.password || generateRandomPassword();
|
|
543
781
|
|
|
544
|
-
|
|
782
|
+
const randomSuffix = Math.random().toString(36).substring(2, 8);
|
|
783
|
+
tenantId = `${providedTenantId}-${randomSuffix}`;
|
|
784
|
+
}
|
|
545
785
|
|
|
546
786
|
createTenant(tenantId, tenantOptions);
|
|
547
787
|
} catch (error) {
|