emilsoftware-utilities 2.0.0-dev.81 → 2.0.0-dev.83
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/deploy/README.md +45 -0
- package/deploy/docker/docker-compose.yml +51 -0
- package/deploy/nginx/accessi-tenants.conf +41 -0
- package/deploy/systemd/accessi-tenant@.service +46 -0
- package/deploy/systemd/tenant.secrets.example +11 -0
- package/deploy/traefik/dynamic.yml +57 -0
- package/dist/accessi/server/index.d.ts +1 -0
- package/dist/accessi/server/index.js +1 -0
- package/dist/accessi/server/index.js.map +1 -1
- package/dist/accessi/server/tenantSupervisor.d.ts +60 -0
- package/dist/accessi/server/tenantSupervisor.js +296 -0
- package/dist/accessi/server/tenantSupervisor.js.map +1 -0
- package/dist/accessi/server/tenants.js +8 -0
- package/dist/accessi/server/tenants.js.map +1 -1
- package/dist/accessi-module/Console/console.js +2 -2
- package/dist/accessi-module/Console/wiki.js +1 -1
- package/dist/accessi-module/Console/wiki.js.map +1 -1
- package/dist/emilsoftware-utilities-2.0.0-dev.83.tgz +0 -0
- package/docs/adr/0002-multi-tenancy.md +4 -4
- package/docs/multi-tenancy.md +33 -3
- package/package.json +5 -3
- package/dist/emilsoftware-utilities-2.0.0-dev.81.tgz +0 -0
package/deploy/README.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Template di deployment multi-tenant
|
|
2
|
+
|
|
3
|
+
File pronti da adattare. Non contengono segreti reali: usare `<placeholder>` o
|
|
4
|
+
variabili d'ambiente/secret manager.
|
|
5
|
+
|
|
6
|
+
| Percorso | Uso |
|
|
7
|
+
| --- | --- |
|
|
8
|
+
| `systemd/accessi-tenant@.service` | unità template, una istanza per tenant |
|
|
9
|
+
| `systemd/tenant.secrets.example` | soli segreti, permessi `0600` |
|
|
10
|
+
| `docker/docker-compose.yml` | due tenant con volumi config read-only |
|
|
11
|
+
| `nginx/accessi-tenants.conf` | routing per host e variante path |
|
|
12
|
+
| `traefik/dynamic.yml` | routing host, header e health check |
|
|
13
|
+
|
|
14
|
+
## Flusso
|
|
15
|
+
|
|
16
|
+
1. Creare `tenants/<id>.environment.ts` (vedi `docs/multi-tenancy.md`).
|
|
17
|
+
2. Validare: `npm run accessi:tenants -- --dir ./tenants --check`.
|
|
18
|
+
3. Preparare i segreti fuori dal repository (`/etc/accessi/tenants/<id>.secrets`
|
|
19
|
+
o `secrets/<id>.env` con permessi `0600`).
|
|
20
|
+
4. Avviare il processo con `ACCESSI_TENANT_ENVIRONMENT` e, se serve,
|
|
21
|
+
`PORT`/`ACCESSI_TENANT_ID`.
|
|
22
|
+
5. Configurare il proxy sull'host o sul path del tenant.
|
|
23
|
+
6. Verificare `/health/live`, `/health/ready` e `/health/metrics`.
|
|
24
|
+
|
|
25
|
+
La porta predefinita è `tenant.port`; `PORT` ha precedenza. I template non
|
|
26
|
+
pubblicano le porte interne: il proxy è l'unico punto esposto.
|
|
27
|
+
|
|
28
|
+
## Variante path
|
|
29
|
+
|
|
30
|
+
Per `tenant.path = '/acme'` il proxy deve rimuovere `/acme` prima di inoltrare
|
|
31
|
+
(vedi `nginx/accessi-tenants.conf` e `traefik/dynamic.yml`). In alternativa
|
|
32
|
+
servire ogni tenant su un host dedicato.
|
|
33
|
+
|
|
34
|
+
## Sicurezza
|
|
35
|
+
|
|
36
|
+
- Non committare `.secrets`/`secrets/*.env`.
|
|
37
|
+
- Montare la cartella configurazione in read-only.
|
|
38
|
+
- `read_only: true`, `no-new-privileges`, utente non root (immagine
|
|
39
|
+
personalizzata) e `ProtectSystem=strict` nell'unità systemd.
|
|
40
|
+
- Non usare l'header `x-accessi-tenant` dal client: solo il proxy può
|
|
41
|
+
impostarlo, con `allowHeader: true` lato router.
|
|
42
|
+
- Un processo per tenant: non condividere `AccessiModule` tra tenant diversi.
|
|
43
|
+
|
|
44
|
+
La CI esegue `npm run check:deploy` per verificare presenza e coerenza dei
|
|
45
|
+
template e l'assenza di segreti.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Accessi multi-tenant con Docker Compose.
|
|
2
|
+
#
|
|
3
|
+
# I file *.environment.ts sono montati read-only; i segreti arrivano da
|
|
4
|
+
# env_file esterni al repository. La porta viene da tenant.port; PORT puo
|
|
5
|
+
# sovrascriverla.
|
|
6
|
+
version: "3.9"
|
|
7
|
+
|
|
8
|
+
x-accessi-tenant: &accessi-tenant
|
|
9
|
+
image: emilsoftware/accessi:2
|
|
10
|
+
restart: unless-stopped
|
|
11
|
+
read_only: true
|
|
12
|
+
tmpfs:
|
|
13
|
+
- /tmp
|
|
14
|
+
security_opt:
|
|
15
|
+
- no-new-privileges:true
|
|
16
|
+
healthcheck:
|
|
17
|
+
test:
|
|
18
|
+
- CMD-SHELL
|
|
19
|
+
- >-
|
|
20
|
+
node -e "fetch('http://127.0.0.1:' + (process.env.PORT || '3200') + '/health/live')
|
|
21
|
+
.then((response) => process.exit(response.ok ? 0 : 1))
|
|
22
|
+
.catch(() => process.exit(1))"
|
|
23
|
+
interval: 15s
|
|
24
|
+
timeout: 5s
|
|
25
|
+
retries: 5
|
|
26
|
+
start_period: 30s
|
|
27
|
+
|
|
28
|
+
services:
|
|
29
|
+
accessi-acme:
|
|
30
|
+
<<: *accessi-tenant
|
|
31
|
+
environment:
|
|
32
|
+
ACCESSI_TENANT_ENVIRONMENT: /config/acme.environment.ts
|
|
33
|
+
ACCESSI_TENANT_ID: acme
|
|
34
|
+
env_file:
|
|
35
|
+
- ./secrets/acme.env
|
|
36
|
+
volumes:
|
|
37
|
+
- ./tenants:/config:ro
|
|
38
|
+
ports:
|
|
39
|
+
- "127.0.0.1:3201:3201"
|
|
40
|
+
|
|
41
|
+
accessi-beta:
|
|
42
|
+
<<: *accessi-tenant
|
|
43
|
+
environment:
|
|
44
|
+
ACCESSI_TENANT_ENVIRONMENT: /config/beta.environment.ts
|
|
45
|
+
ACCESSI_TENANT_ID: beta
|
|
46
|
+
env_file:
|
|
47
|
+
- ./secrets/beta.env
|
|
48
|
+
volumes:
|
|
49
|
+
- ./tenants:/config:ro
|
|
50
|
+
ports:
|
|
51
|
+
- "127.0.0.1:3202:3202"
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Accessi multi-tenant dietro Nginx.
|
|
2
|
+
#
|
|
3
|
+
# Ogni tenant ha un processo sulla propria porta interna. Il proxy instrada per
|
|
4
|
+
# host e non espone mai le porte interne. Sostituire i domini e le porte.
|
|
5
|
+
|
|
6
|
+
map $host $accessi_tenant_upstream {
|
|
7
|
+
accessi.acme.example.com http://127.0.0.1:3201;
|
|
8
|
+
accessi.beta.example.com http://127.0.0.1:3202;
|
|
9
|
+
default http://127.0.0.1:3201;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
server {
|
|
13
|
+
listen 443 ssl http2;
|
|
14
|
+
server_name accessi.acme.example.com accessi.beta.example.com;
|
|
15
|
+
|
|
16
|
+
ssl_certificate /etc/letsencrypt/live/accessi.example.com/fullchain.pem;
|
|
17
|
+
ssl_certificate_key /etc/letsencrypt/live/accessi.example.com/privkey.pem;
|
|
18
|
+
|
|
19
|
+
client_max_body_size 10m;
|
|
20
|
+
|
|
21
|
+
location / {
|
|
22
|
+
proxy_pass $accessi_tenant_upstream;
|
|
23
|
+
proxy_http_version 1.1;
|
|
24
|
+
proxy_set_header Host $host;
|
|
25
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
26
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
27
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
28
|
+
proxy_set_header X-Forwarded-Host $host;
|
|
29
|
+
proxy_read_timeout 60s;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
# Variante path-based: il prefix /acme viene rimosso prima di inoltrare ad
|
|
34
|
+
# Accessi, che serve il tenant alla radice.
|
|
35
|
+
#
|
|
36
|
+
# location /acme/ {
|
|
37
|
+
# rewrite ^/acme/(.*)$ /$1 break;
|
|
38
|
+
# proxy_pass http://127.0.0.1:3201;
|
|
39
|
+
# proxy_set_header Host $host;
|
|
40
|
+
# proxy_set_header X-Forwarded-Proto $scheme;
|
|
41
|
+
# }
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Accessi multi-tenant con systemd
|
|
2
|
+
#
|
|
3
|
+
# Installazione:
|
|
4
|
+
# cp deploy/systemd/accessi-tenant@.service /etc/systemd/system/
|
|
5
|
+
# mkdir -p /etc/accessi/tenants
|
|
6
|
+
# cp tenants/acme.environment.ts /etc/accessi/tenants/acme.environment.ts
|
|
7
|
+
# install -m 0600 deploy/systemd/tenant.secrets.example /etc/accessi/tenants/acme.secrets
|
|
8
|
+
# systemctl daemon-reload
|
|
9
|
+
# systemctl enable --now accessi-tenant@acme
|
|
10
|
+
#
|
|
11
|
+
# La porta viene letta da tenant.port nel file *.environment.ts. Per forzarla:
|
|
12
|
+
# systemctl edit accessi-tenant@acme
|
|
13
|
+
# [Service]
|
|
14
|
+
# Environment=PORT=3201
|
|
15
|
+
|
|
16
|
+
[Unit]
|
|
17
|
+
Description=Accessi Service tenant %i
|
|
18
|
+
Documentation=file:///opt/accessi/docs/multi-tenancy.md
|
|
19
|
+
After=network-online.target
|
|
20
|
+
Wants=network-online.target
|
|
21
|
+
|
|
22
|
+
[Service]
|
|
23
|
+
Type=simple
|
|
24
|
+
WorkingDirectory=/opt/accessi
|
|
25
|
+
# Solo segreti (password DB, SMTP, chiavi JWT): il manifest tipizzato e' separato.
|
|
26
|
+
EnvironmentFile=-/etc/accessi/tenants/%i.secrets
|
|
27
|
+
Environment=ACCESSI_TENANT_ENVIRONMENT=/etc/accessi/tenants/%i.environment.ts
|
|
28
|
+
Environment=ACCESSI_TENANT_ID=%i
|
|
29
|
+
|
|
30
|
+
ExecStart=/usr/bin/node /opt/accessi/dist/scripts/startAccessiTenantService.js
|
|
31
|
+
Restart=on-failure
|
|
32
|
+
RestartSec=5
|
|
33
|
+
TimeoutStopSec=30
|
|
34
|
+
KillSignal=SIGTERM
|
|
35
|
+
|
|
36
|
+
User=accessi
|
|
37
|
+
Group=accessi
|
|
38
|
+
NoNewPrivileges=true
|
|
39
|
+
PrivateTmp=true
|
|
40
|
+
ProtectSystem=strict
|
|
41
|
+
ProtectHome=true
|
|
42
|
+
ReadWritePaths=/var/lib/accessi
|
|
43
|
+
LimitNOFILE=65536
|
|
44
|
+
|
|
45
|
+
[Install]
|
|
46
|
+
WantedBy=multi-user.target
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Esempio di file segreti per un tenant systemd.
|
|
2
|
+
# Copiare in /etc/accessi/tenants/<id>.secrets con permessi 0600.
|
|
3
|
+
# Non committare valori reali.
|
|
4
|
+
|
|
5
|
+
ACME_DB_PASSWORD=<db-password>
|
|
6
|
+
ACME_JWT_SECRET=<jwt-secret-almeno-32-caratteri>
|
|
7
|
+
ACME_JWT_PRIVATE_KEY=<pem-su-una-riga-con-\n>
|
|
8
|
+
ACME_JWT_PUBLIC_KEY=<pem-su-una-riga-con-\n>
|
|
9
|
+
ACME_ENCRYPTION_KEY=<32-caratteri-casuali>
|
|
10
|
+
ACME_SMTP_USER=<smtp-user>
|
|
11
|
+
ACME_SMTP_PASSWORD=<smtp-password>
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Accessi multi-tenant con Traefik (file provider).
|
|
2
|
+
#
|
|
3
|
+
# I servizi Accessi ascoltano su porte interne; Traefik termina TLS e instrada
|
|
4
|
+
# per host. Per la variante path usare un middleware StripPrefix.
|
|
5
|
+
|
|
6
|
+
http:
|
|
7
|
+
routers:
|
|
8
|
+
accessi-acme:
|
|
9
|
+
rule: Host(`accessi.acme.example.com`)
|
|
10
|
+
entryPoints:
|
|
11
|
+
- websecure
|
|
12
|
+
tls: {}
|
|
13
|
+
service: accessi-acme
|
|
14
|
+
middlewares:
|
|
15
|
+
- accessi-headers
|
|
16
|
+
|
|
17
|
+
accessi-beta:
|
|
18
|
+
rule: Host(`accessi.beta.example.com`)
|
|
19
|
+
entryPoints:
|
|
20
|
+
- websecure
|
|
21
|
+
tls: {}
|
|
22
|
+
service: accessi-beta
|
|
23
|
+
middlewares:
|
|
24
|
+
- accessi-headers
|
|
25
|
+
|
|
26
|
+
middlewares:
|
|
27
|
+
accessi-headers:
|
|
28
|
+
headers:
|
|
29
|
+
stsSeconds: 31536000
|
|
30
|
+
stsIncludeSubdomains: true
|
|
31
|
+
contentTypeNosniff: true
|
|
32
|
+
referrerPolicy: no-referrer
|
|
33
|
+
customResponseHeaders:
|
|
34
|
+
Permissions-Policy: "camera=(), microphone=(), geolocation=()"
|
|
35
|
+
# Variante path-based:
|
|
36
|
+
# accessi-strip-acme:
|
|
37
|
+
# stripPrefix:
|
|
38
|
+
# prefixes:
|
|
39
|
+
# - /acme
|
|
40
|
+
|
|
41
|
+
services:
|
|
42
|
+
accessi-acme:
|
|
43
|
+
loadBalancer:
|
|
44
|
+
healthCheck:
|
|
45
|
+
path: /health/ready
|
|
46
|
+
interval: 15s
|
|
47
|
+
timeout: 3s
|
|
48
|
+
servers:
|
|
49
|
+
- url: http://127.0.0.1:3201
|
|
50
|
+
accessi-beta:
|
|
51
|
+
loadBalancer:
|
|
52
|
+
healthCheck:
|
|
53
|
+
path: /health/ready
|
|
54
|
+
interval: 15s
|
|
55
|
+
timeout: 3s
|
|
56
|
+
servers:
|
|
57
|
+
- url: http://127.0.0.1:3202
|
|
@@ -9,5 +9,6 @@ export * from "../../accessi-module";
|
|
|
9
9
|
export * from "./standalone";
|
|
10
10
|
export * from "./tenants";
|
|
11
11
|
export * from "./tenantStores";
|
|
12
|
+
export * from "./tenantSupervisor";
|
|
12
13
|
export type { AccessiOptions as AccessiServerOptions } from "../../accessi-module";
|
|
13
14
|
export { createAccessiOptionsFromEnv, createCatalogScriptsOptionsFromEnv, parseAccessiPreviousPublicKeys, } from "../../accessi-module/database-updates/accessiOptionsFromEnv";
|
|
@@ -26,6 +26,7 @@ __exportStar(require("../../accessi-module"), exports);
|
|
|
26
26
|
__exportStar(require("./standalone"), exports);
|
|
27
27
|
__exportStar(require("./tenants"), exports);
|
|
28
28
|
__exportStar(require("./tenantStores"), exports);
|
|
29
|
+
__exportStar(require("./tenantSupervisor"), exports);
|
|
29
30
|
var accessiOptionsFromEnv_1 = require("../../accessi-module/database-updates/accessiOptionsFromEnv");
|
|
30
31
|
Object.defineProperty(exports, "createAccessiOptionsFromEnv", { enumerable: true, get: function () { return accessiOptionsFromEnv_1.createAccessiOptionsFromEnv; } });
|
|
31
32
|
Object.defineProperty(exports, "createCatalogScriptsOptionsFromEnv", { enumerable: true, get: function () { return accessiOptionsFromEnv_1.createCatalogScriptsOptionsFromEnv; } });
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/accessi/server/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;AAAA;;;;;;GAMG;AACH,uDAAqC;AACrC,+CAA6B;AAC7B,4CAA0B;AAC1B,iDAA+B;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/accessi/server/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;AAAA;;;;;;GAMG;AACH,uDAAqC;AACrC,+CAA6B;AAC7B,4CAA0B;AAC1B,iDAA+B;AAC/B,qDAAmC;AAEnC,qGAIqE;AAHnE,oIAAA,2BAA2B,OAAA;AAC3B,2IAAA,kCAAkC,OAAA;AAClC,uIAAA,8BAA8B,OAAA","sourcesContent":["/**\n * Entry point server Accessi.\n *\n * Questo e l'unico entry point che puo importare database, SMTP, migrazioni,\n * console e servizi Nest completi. I backend fratelli devono usare\n * `accessi/resource-server` e `accessi/client`.\n */\nexport * from \"../../accessi-module\";\nexport * from \"./standalone\";\nexport * from \"./tenants\";\nexport * from \"./tenantStores\";\nexport * from \"./tenantSupervisor\";\nexport type { AccessiOptions as AccessiServerOptions } from \"../../accessi-module\";\nexport {\n createAccessiOptionsFromEnv,\n createCatalogScriptsOptionsFromEnv,\n parseAccessiPreviousPublicKeys,\n} from \"../../accessi-module/database-updates/accessiOptionsFromEnv\";\n"]}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { AccessiTenant } from './tenants';
|
|
2
|
+
/** Handle minimo di un processo figlio (compatibile con `child_process.ChildProcess`). */
|
|
3
|
+
export interface AccessiTenantProcessHandle {
|
|
4
|
+
readonly pid?: number;
|
|
5
|
+
kill(signal?: NodeJS.Signals | number): boolean;
|
|
6
|
+
once(event: 'exit', listener: (code: number | null, signal: NodeJS.Signals | null) => void): unknown;
|
|
7
|
+
}
|
|
8
|
+
export type AccessiTenantSpawn = (tenant: AccessiTenant, port: number) => AccessiTenantProcessHandle;
|
|
9
|
+
export interface AccessiTenantSupervisorLoggerEvent {
|
|
10
|
+
level: 'info' | 'warn' | 'error';
|
|
11
|
+
message: string;
|
|
12
|
+
}
|
|
13
|
+
export interface AccessiTenantSupervisorOptions {
|
|
14
|
+
directory: string;
|
|
15
|
+
basePort: number;
|
|
16
|
+
spawn: AccessiTenantSpawn;
|
|
17
|
+
allowHeader?: boolean;
|
|
18
|
+
/** Intervallo di polling della cartella quando `watch()` e attivo. Default 1000 ms. */
|
|
19
|
+
pollIntervalMs?: number;
|
|
20
|
+
/** Attesa prima di applicare un reload dopo una modifica. Default 200 ms. */
|
|
21
|
+
debounceMs?: number;
|
|
22
|
+
/** Timeout per SIGTERM prima di SIGKILL. Default 5000 ms. */
|
|
23
|
+
stopTimeoutMs?: number;
|
|
24
|
+
logger?: (event: AccessiTenantSupervisorLoggerEvent) => void;
|
|
25
|
+
}
|
|
26
|
+
export interface AccessiTenantSupervisorState {
|
|
27
|
+
id: string;
|
|
28
|
+
port: number;
|
|
29
|
+
file: string;
|
|
30
|
+
signature: string;
|
|
31
|
+
}
|
|
32
|
+
export interface AccessiTenantReloadResult {
|
|
33
|
+
added: string[];
|
|
34
|
+
removed: string[];
|
|
35
|
+
restarted: string[];
|
|
36
|
+
unchanged: string[];
|
|
37
|
+
}
|
|
38
|
+
export interface AccessiTenantSupervisor {
|
|
39
|
+
start(): Promise<void>;
|
|
40
|
+
reload(): Promise<AccessiTenantReloadResult>;
|
|
41
|
+
stop(): Promise<void>;
|
|
42
|
+
state(): AccessiTenantSupervisorState[];
|
|
43
|
+
watch(): void;
|
|
44
|
+
unwatch(): void;
|
|
45
|
+
}
|
|
46
|
+
/** Firma della cartella: file + contenuto, ordinata per nome. */
|
|
47
|
+
export declare function accessiTenantDirectorySignature(directory: string): string;
|
|
48
|
+
/**
|
|
49
|
+
* Supervisore multi-tenant: avvia un processo per tenant e, con `watch()`,
|
|
50
|
+
* riapplica la configurazione quando i file `*.environment.ts` cambiano.
|
|
51
|
+
*
|
|
52
|
+
* Regole di reload:
|
|
53
|
+
* - un file invalido non tocca i tenant attivi;
|
|
54
|
+
* - i tenant invariati non vengono riavviati;
|
|
55
|
+
* - aggiunte/rimozioni/modifiche producono spawn/stop/restart;
|
|
56
|
+
* - la porta di un tenant esistente non cambia durante un restart.
|
|
57
|
+
*/
|
|
58
|
+
export declare function createAccessiTenantSupervisor(options: AccessiTenantSupervisorOptions): AccessiTenantSupervisor;
|
|
59
|
+
/** Attende che una condizione diventi vera (utile nei test e nei runbook). */
|
|
60
|
+
export declare function waitForTenantState(supervisor: AccessiTenantSupervisor, predicate: (state: AccessiTenantSupervisorState[]) => boolean, timeoutMs?: number): Promise<void>;
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.accessiTenantDirectorySignature = accessiTenantDirectorySignature;
|
|
13
|
+
exports.createAccessiTenantSupervisor = createAccessiTenantSupervisor;
|
|
14
|
+
exports.waitForTenantState = waitForTenantState;
|
|
15
|
+
const crypto_1 = require("crypto");
|
|
16
|
+
const fs_1 = require("fs");
|
|
17
|
+
const path_1 = require("path");
|
|
18
|
+
const tenants_1 = require("./tenants");
|
|
19
|
+
const ENVIRONMENT_FILE = /\.environment\.(ts|mts|cts|js|mjs|cjs)$/;
|
|
20
|
+
function fileSignature(file) {
|
|
21
|
+
try {
|
|
22
|
+
return (0, crypto_1.createHash)('sha256').update((0, fs_1.readFileSync)(file)).digest('hex');
|
|
23
|
+
}
|
|
24
|
+
catch (_a) {
|
|
25
|
+
return 'unreadable';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** Firma della cartella: file + contenuto, ordinata per nome. */
|
|
29
|
+
function accessiTenantDirectorySignature(directory) {
|
|
30
|
+
let files;
|
|
31
|
+
try {
|
|
32
|
+
files = (0, fs_1.readdirSync)(directory, { withFileTypes: true })
|
|
33
|
+
.filter((entry) => entry.isFile() && ENVIRONMENT_FILE.test(entry.name))
|
|
34
|
+
.map((entry) => entry.name)
|
|
35
|
+
.sort();
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
return `error:${error instanceof Error ? error.message : String(error)}`;
|
|
39
|
+
}
|
|
40
|
+
return (0, crypto_1.createHash)('sha256')
|
|
41
|
+
.update(files.map((file) => `${file}:${fileSignature((0, path_1.join)(directory, file))}`).join('|'))
|
|
42
|
+
.digest('hex');
|
|
43
|
+
}
|
|
44
|
+
function delay(ms) {
|
|
45
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
46
|
+
}
|
|
47
|
+
function stopProcess(handle, timeoutMs) {
|
|
48
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
49
|
+
yield new Promise((resolve) => {
|
|
50
|
+
let settled = false;
|
|
51
|
+
const done = () => {
|
|
52
|
+
if (settled)
|
|
53
|
+
return;
|
|
54
|
+
settled = true;
|
|
55
|
+
resolve();
|
|
56
|
+
};
|
|
57
|
+
handle.once('exit', done);
|
|
58
|
+
try {
|
|
59
|
+
if (!handle.kill('SIGTERM'))
|
|
60
|
+
done();
|
|
61
|
+
}
|
|
62
|
+
catch (_a) {
|
|
63
|
+
done();
|
|
64
|
+
}
|
|
65
|
+
const timer = setTimeout(() => {
|
|
66
|
+
if (settled)
|
|
67
|
+
return;
|
|
68
|
+
try {
|
|
69
|
+
handle.kill('SIGKILL');
|
|
70
|
+
}
|
|
71
|
+
catch (_a) {
|
|
72
|
+
// Il processo potrebbe essere gia terminato.
|
|
73
|
+
}
|
|
74
|
+
done();
|
|
75
|
+
}, Math.max(50, timeoutMs));
|
|
76
|
+
if (typeof timer.unref === 'function')
|
|
77
|
+
timer.unref();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Supervisore multi-tenant: avvia un processo per tenant e, con `watch()`,
|
|
83
|
+
* riapplica la configurazione quando i file `*.environment.ts` cambiano.
|
|
84
|
+
*
|
|
85
|
+
* Regole di reload:
|
|
86
|
+
* - un file invalido non tocca i tenant attivi;
|
|
87
|
+
* - i tenant invariati non vengono riavviati;
|
|
88
|
+
* - aggiunte/rimozioni/modifiche producono spawn/stop/restart;
|
|
89
|
+
* - la porta di un tenant esistente non cambia durante un restart.
|
|
90
|
+
*/
|
|
91
|
+
function createAccessiTenantSupervisor(options) {
|
|
92
|
+
var _a;
|
|
93
|
+
if (!(options === null || options === void 0 ? void 0 : options.directory))
|
|
94
|
+
throw new Error('createAccessiTenantSupervisor: directory obbligatoria.');
|
|
95
|
+
if (!Number.isInteger(options.basePort) || options.basePort < 1) {
|
|
96
|
+
throw new Error('createAccessiTenantSupervisor: basePort non valida.');
|
|
97
|
+
}
|
|
98
|
+
if (typeof options.spawn !== 'function') {
|
|
99
|
+
throw new Error('createAccessiTenantSupervisor: spawn obbligatorio.');
|
|
100
|
+
}
|
|
101
|
+
const log = (_a = options.logger) !== null && _a !== void 0 ? _a : (() => undefined);
|
|
102
|
+
const running = new Map();
|
|
103
|
+
let lastSignature = '';
|
|
104
|
+
let watchTimer;
|
|
105
|
+
let reloading = false;
|
|
106
|
+
let stopping = false;
|
|
107
|
+
let pendingReload;
|
|
108
|
+
const nextPort = () => {
|
|
109
|
+
const used = new Set([...running.values()].map((entry) => entry.port));
|
|
110
|
+
let port = options.basePort;
|
|
111
|
+
while (used.has(port))
|
|
112
|
+
port += 1;
|
|
113
|
+
return port;
|
|
114
|
+
};
|
|
115
|
+
const spawnTenant = (tenant, port, signature) => {
|
|
116
|
+
try {
|
|
117
|
+
const process = options.spawn(tenant, port);
|
|
118
|
+
return { tenant, port, signature, process };
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
log({
|
|
122
|
+
level: 'error',
|
|
123
|
+
message: `Avvio tenant ${tenant.id} fallito: ${error instanceof Error ? error.message : String(error)}`,
|
|
124
|
+
});
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
const load = () => {
|
|
129
|
+
const registry = (0, tenants_1.loadAccessiTenants)(Object.assign({ directory: options.directory }, (options.allowHeader !== undefined ? { allowHeader: options.allowHeader } : {})));
|
|
130
|
+
const signatures = new Map();
|
|
131
|
+
for (const tenant of registry.tenants) {
|
|
132
|
+
signatures.set(tenant.id, fileSignature(tenant.environmentFile));
|
|
133
|
+
}
|
|
134
|
+
return { registry, signatures };
|
|
135
|
+
};
|
|
136
|
+
const remove = (id) => __awaiter(this, void 0, void 0, function* () {
|
|
137
|
+
var _a;
|
|
138
|
+
const entry = running.get(id);
|
|
139
|
+
if (!entry)
|
|
140
|
+
return;
|
|
141
|
+
running.delete(id);
|
|
142
|
+
log({ level: 'info', message: `Stop tenant ${id} (porta ${entry.port}).` });
|
|
143
|
+
yield stopProcess(entry.process, (_a = options.stopTimeoutMs) !== null && _a !== void 0 ? _a : 5000);
|
|
144
|
+
});
|
|
145
|
+
const startTenant = (tenant, signatures, port) => {
|
|
146
|
+
var _a, _b, _c;
|
|
147
|
+
const assignedPort = (_a = port !== null && port !== void 0 ? port : tenant.port) !== null && _a !== void 0 ? _a : nextPort();
|
|
148
|
+
if ([...running.values()].some((entry) => entry.port === assignedPort)) {
|
|
149
|
+
log({ level: 'error', message: `Porta ${assignedPort} gia in uso: tenant ${tenant.id} non avviato.` });
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
const entry = spawnTenant(tenant, assignedPort, (_b = signatures.get(tenant.id)) !== null && _b !== void 0 ? _b : '');
|
|
153
|
+
if (!entry)
|
|
154
|
+
return false;
|
|
155
|
+
running.set(tenant.id, entry);
|
|
156
|
+
log({
|
|
157
|
+
level: 'info',
|
|
158
|
+
message: `Avvio tenant ${tenant.id} sulla porta ${assignedPort} (pid ${(_c = entry.process.pid) !== null && _c !== void 0 ? _c : '?'}).`,
|
|
159
|
+
});
|
|
160
|
+
return true;
|
|
161
|
+
};
|
|
162
|
+
const apply = (registry, signatures) => __awaiter(this, void 0, void 0, function* () {
|
|
163
|
+
var _a;
|
|
164
|
+
const desired = new Map(registry.tenants.filter((tenant) => tenant.enabled).map((tenant) => [tenant.id, tenant]));
|
|
165
|
+
const result = { added: [], removed: [], restarted: [], unchanged: [] };
|
|
166
|
+
for (const id of [...running.keys()]) {
|
|
167
|
+
if (!desired.has(id)) {
|
|
168
|
+
yield remove(id);
|
|
169
|
+
result.removed.push(id);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
for (const [id, tenant] of desired) {
|
|
173
|
+
const current = running.get(id);
|
|
174
|
+
if (!current) {
|
|
175
|
+
if (startTenant(tenant, signatures))
|
|
176
|
+
result.added.push(id);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const signature = (_a = signatures.get(id)) !== null && _a !== void 0 ? _a : '';
|
|
180
|
+
if (signature === current.signature) {
|
|
181
|
+
result.unchanged.push(id);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
// Riavvio sulla stessa porta: il proxy non cambia configurazione.
|
|
185
|
+
const port = current.port;
|
|
186
|
+
yield remove(id);
|
|
187
|
+
if (startTenant(tenant, signatures, port))
|
|
188
|
+
result.restarted.push(id);
|
|
189
|
+
}
|
|
190
|
+
return result;
|
|
191
|
+
});
|
|
192
|
+
const unwatch = () => {
|
|
193
|
+
if (watchTimer)
|
|
194
|
+
clearInterval(watchTimer);
|
|
195
|
+
if (pendingReload)
|
|
196
|
+
clearTimeout(pendingReload);
|
|
197
|
+
watchTimer = undefined;
|
|
198
|
+
pendingReload = undefined;
|
|
199
|
+
};
|
|
200
|
+
const reload = () => __awaiter(this, void 0, void 0, function* () {
|
|
201
|
+
if (reloading || stopping)
|
|
202
|
+
return { added: [], removed: [], restarted: [], unchanged: [] };
|
|
203
|
+
reloading = true;
|
|
204
|
+
try {
|
|
205
|
+
const signature = accessiTenantDirectorySignature(options.directory);
|
|
206
|
+
let loaded;
|
|
207
|
+
try {
|
|
208
|
+
loaded = load();
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
log({
|
|
212
|
+
level: 'error',
|
|
213
|
+
message: `Reload annullato, configurazione non valida: ${error instanceof Error ? error.message : String(error)}`,
|
|
214
|
+
});
|
|
215
|
+
lastSignature = signature;
|
|
216
|
+
return { added: [], removed: [], restarted: [], unchanged: [] };
|
|
217
|
+
}
|
|
218
|
+
const result = yield apply(loaded.registry, loaded.signatures);
|
|
219
|
+
lastSignature = signature;
|
|
220
|
+
log({
|
|
221
|
+
level: 'info',
|
|
222
|
+
message: `Reload tenant: +${result.added.length} -${result.removed.length} ~${result.restarted.length} =${result.unchanged.length}.`,
|
|
223
|
+
});
|
|
224
|
+
return result;
|
|
225
|
+
}
|
|
226
|
+
finally {
|
|
227
|
+
reloading = false;
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
const stop = () => __awaiter(this, void 0, void 0, function* () {
|
|
231
|
+
if (stopping)
|
|
232
|
+
return;
|
|
233
|
+
stopping = true;
|
|
234
|
+
unwatch();
|
|
235
|
+
yield Promise.all([...running.keys()].map((id) => remove(id)));
|
|
236
|
+
});
|
|
237
|
+
const watch = () => {
|
|
238
|
+
var _a, _b;
|
|
239
|
+
if (watchTimer)
|
|
240
|
+
return;
|
|
241
|
+
const interval = Math.max(100, (_a = options.pollIntervalMs) !== null && _a !== void 0 ? _a : 1000);
|
|
242
|
+
const debounce = Math.max(0, (_b = options.debounceMs) !== null && _b !== void 0 ? _b : 200);
|
|
243
|
+
watchTimer = setInterval(() => {
|
|
244
|
+
if (stopping || reloading)
|
|
245
|
+
return;
|
|
246
|
+
const signature = accessiTenantDirectorySignature(options.directory);
|
|
247
|
+
if (signature === lastSignature)
|
|
248
|
+
return;
|
|
249
|
+
if (pendingReload)
|
|
250
|
+
clearTimeout(pendingReload);
|
|
251
|
+
pendingReload = setTimeout(() => {
|
|
252
|
+
pendingReload = undefined;
|
|
253
|
+
void reload();
|
|
254
|
+
}, debounce);
|
|
255
|
+
}, interval);
|
|
256
|
+
if (typeof watchTimer.unref === 'function')
|
|
257
|
+
watchTimer.unref();
|
|
258
|
+
log({ level: 'info', message: `Watch attivo su ${options.directory} (${interval} ms).` });
|
|
259
|
+
};
|
|
260
|
+
return {
|
|
261
|
+
start() {
|
|
262
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
263
|
+
const { registry, signatures } = load();
|
|
264
|
+
lastSignature = accessiTenantDirectorySignature(options.directory);
|
|
265
|
+
yield apply(registry, signatures);
|
|
266
|
+
for (const tenant of registry.tenants.filter((entry) => !entry.enabled)) {
|
|
267
|
+
log({ level: 'info', message: `Tenant ${tenant.id} disabilitato: non avviato.` });
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
},
|
|
271
|
+
reload,
|
|
272
|
+
stop,
|
|
273
|
+
state() {
|
|
274
|
+
return [...running.values()].map((entry) => ({
|
|
275
|
+
id: entry.tenant.id,
|
|
276
|
+
port: entry.port,
|
|
277
|
+
file: entry.tenant.file,
|
|
278
|
+
signature: entry.signature,
|
|
279
|
+
}));
|
|
280
|
+
},
|
|
281
|
+
watch,
|
|
282
|
+
unwatch,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
/** Attende che una condizione diventi vera (utile nei test e nei runbook). */
|
|
286
|
+
function waitForTenantState(supervisor_1, predicate_1) {
|
|
287
|
+
return __awaiter(this, arguments, void 0, function* (supervisor, predicate, timeoutMs = 5000) {
|
|
288
|
+
const deadline = Date.now() + timeoutMs;
|
|
289
|
+
while (!predicate(supervisor.state())) {
|
|
290
|
+
if (Date.now() > deadline)
|
|
291
|
+
throw new Error('timeout in attesa dello stato tenant');
|
|
292
|
+
yield delay(25);
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
//# sourceMappingURL=tenantSupervisor.js.map
|