@sysnee/pgs 0.1.7-rc.10 → 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 -16
- 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');
|
|
@@ -75,7 +77,6 @@ function generateTraefikDynamicConfig(tenantsManifests = {}) {
|
|
|
75
77
|
const dynamicConfig = {
|
|
76
78
|
tcp: {
|
|
77
79
|
routers: {},
|
|
78
|
-
middlewares: {},
|
|
79
80
|
services: {}
|
|
80
81
|
},
|
|
81
82
|
tls: {
|
|
@@ -115,7 +116,10 @@ function generateTraefikDynamicConfig(tenantsManifests = {}) {
|
|
|
115
116
|
|
|
116
117
|
if (sourceRanges.length > 0) {
|
|
117
118
|
router.middlewares = [middlewareName];
|
|
118
|
-
|
|
119
|
+
|
|
120
|
+
if (!dynamicConfig.tcp.middlewares) {
|
|
121
|
+
dynamicConfig.tcp.middlewares = {};
|
|
122
|
+
}
|
|
119
123
|
if (!dynamicConfig.tcp.middlewares[middlewareName]) {
|
|
120
124
|
dynamicConfig.tcp.middlewares[middlewareName] = {
|
|
121
125
|
ipAllowList: {
|
|
@@ -231,15 +235,15 @@ function ensureNetwork(doc) {
|
|
|
231
235
|
}
|
|
232
236
|
}
|
|
233
237
|
|
|
234
|
-
function initialSetup() {
|
|
238
|
+
async function initialSetup() {
|
|
235
239
|
installDocker()
|
|
236
240
|
createInitialFiles()
|
|
237
241
|
ensureDockerPrivilegies()
|
|
242
|
+
await setupCertificates()
|
|
238
243
|
console.log('All ready!')
|
|
239
244
|
}
|
|
240
245
|
|
|
241
246
|
function installDocker() {
|
|
242
|
-
|
|
243
247
|
try {
|
|
244
248
|
execSync('docker info', { stdio: 'pipe' })
|
|
245
249
|
console.log('Docker already installed')
|
|
@@ -250,7 +254,7 @@ function installDocker() {
|
|
|
250
254
|
execSync('curl -fsSL https://get.docker.com -o get-docker.sh && sudo sh get-docker.sh', { stdio: 'inherit' })
|
|
251
255
|
execSync('rm get-docker.sh')
|
|
252
256
|
console.log('Docker installed successfully')
|
|
253
|
-
|
|
257
|
+
|
|
254
258
|
console.log('Installing Docker Compose plugin...')
|
|
255
259
|
execSync('sudo apt-get install -y docker-compose-plugin', { stdio: 'inherit' })
|
|
256
260
|
console.log('Docker Compose plugin installed successfully')
|
|
@@ -261,6 +265,107 @@ function ensureDockerPrivilegies() {
|
|
|
261
265
|
writeFileSync(SETUP_STATUS_PATH, 'container:ok')
|
|
262
266
|
}
|
|
263
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
|
+
|
|
264
369
|
function createInitialFiles() {
|
|
265
370
|
// config dir
|
|
266
371
|
if (!existsSync(CONFIG_DIR)) {
|
|
@@ -446,10 +551,21 @@ function removeTenant(tenantId, manifests = {}) {
|
|
|
446
551
|
throw new Error(`Tenant ${tenantId} not found`);
|
|
447
552
|
}
|
|
448
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
|
+
|
|
449
565
|
delete doc.services[serviceName];
|
|
450
566
|
|
|
451
567
|
const volumeName = `pgdata_${tenantId}`;
|
|
452
|
-
if (doc.volumes && doc.volumes
|
|
568
|
+
if (doc.volumes && Object.prototype.hasOwnProperty.call(doc.volumes, volumeName)) {
|
|
453
569
|
delete doc.volumes[volumeName];
|
|
454
570
|
}
|
|
455
571
|
|
|
@@ -464,9 +580,101 @@ function removeTenant(tenantId, manifests = {}) {
|
|
|
464
580
|
generateTraefikDynamicConfig(manifests);
|
|
465
581
|
updateTraefikService();
|
|
466
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
|
+
|
|
467
603
|
console.log(`✓ Removed tenant: ${tenantId}`);
|
|
468
|
-
|
|
469
|
-
|
|
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');
|
|
470
678
|
}
|
|
471
679
|
|
|
472
680
|
function executeCommand(command) {
|
|
@@ -491,9 +699,33 @@ program
|
|
|
491
699
|
|
|
492
700
|
program
|
|
493
701
|
.command('setup')
|
|
494
|
-
.description('Initial required setup')
|
|
495
|
-
.action(() => {
|
|
496
|
-
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()
|
|
497
729
|
})
|
|
498
730
|
|
|
499
731
|
program
|
|
@@ -510,9 +742,6 @@ program
|
|
|
510
742
|
try {
|
|
511
743
|
|
|
512
744
|
const providedTenantId = args[0];
|
|
513
|
-
const randomSuffix = Math.random().toString(36).substring(2, 8);
|
|
514
|
-
const tenantId = `${providedTenantId}-${randomSuffix}`;
|
|
515
|
-
|
|
516
745
|
const opts = args[1];
|
|
517
746
|
|
|
518
747
|
const tenantOptions = {
|
|
@@ -522,6 +751,8 @@ program
|
|
|
522
751
|
manifest: null
|
|
523
752
|
}
|
|
524
753
|
|
|
754
|
+
let tenantId;
|
|
755
|
+
|
|
525
756
|
if (opts.file) {
|
|
526
757
|
const manifestFilePath = path.resolve(opts.file)
|
|
527
758
|
const optsFromManifest = JSON.parse(readFileSync(manifestFilePath))
|
|
@@ -534,15 +765,23 @@ program
|
|
|
534
765
|
tenantOptions.limits = optsFromManifest.shared_limits
|
|
535
766
|
tenantOptions.version = optsFromManifest.version
|
|
536
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
|
+
}
|
|
537
774
|
} else {
|
|
538
775
|
tenantOptions.version = opts.version;
|
|
539
776
|
tenantOptions.limits = {
|
|
540
777
|
cpu: parseFloat(opts.cpu),
|
|
541
778
|
memory: parseInt(opts.memory, 10),
|
|
542
779
|
};
|
|
543
|
-
|
|
780
|
+
tenantOptions.password = opts.password || generateRandomPassword();
|
|
544
781
|
|
|
545
|
-
|
|
782
|
+
const randomSuffix = Math.random().toString(36).substring(2, 8);
|
|
783
|
+
tenantId = `${providedTenantId}-${randomSuffix}`;
|
|
784
|
+
}
|
|
546
785
|
|
|
547
786
|
createTenant(tenantId, tenantOptions);
|
|
548
787
|
} catch (error) {
|