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.
@@ -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;AAE/B,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 type { AccessiOptions as AccessiServerOptions } from \"../../accessi-module\";\nexport {\n createAccessiOptionsFromEnv,\n createCatalogScriptsOptionsFromEnv,\n parseAccessiPreviousPublicKeys,\n} from \"../../accessi-module/database-updates/accessiOptionsFromEnv\";\n"]}
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