Initial commit — LoginMaster tenant deployment toolkit

Toolkit per deployare/aggiornare un tenant LoginMaster su qualsiasi Kubernetes
(EKS/AKS/DOKS/Scaleway/vSphere/...). Contiene:

- deploy.sh: bootstrap di un nuovo tenant (idempotente, re-run protection,
  storage class auto-rilevata, prompt separati api/admin tag, generazione
  segreti crittografici via openssl rand).
- update.sh: rolling update zero-downtime con tag api/admin separati, rollback
  hint via 'kubectl rollout undo', riapplicazione opzionale del ConfigMap.
- templates/: 8 manifest parametrici (envsubst): namespace, cert-manager TLS
  Mongo, NetworkPolicy intra-namespace, ConfigMap, MongoDB StatefulSet 3 repliche
  con TLS interno + initContainer per keyfile/PEM, tenant-api Deployment 2 repliche
  con CA validation, tenant-admin, ingress nginx + Let's Encrypt.

Sicurezza: TLS interno Mongo (cert-manager CA self-signed 10y), keyFile per
auth replica set, password client mai in argv, NetworkPolicy che isola il
tenant, pod Mongo non-root (uid 999) con initContainer come root per i file
runtime in tmpfs.
This commit is contained in:
Luca
2026-05-06 11:44:04 +02:00
commit 468d4562c7
12 changed files with 1557 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: ${NAMESPACE}
+76
View File
@@ -0,0 +1,76 @@
# TLS interno MongoDB (cert-manager).
# Catena: Issuer self-signed → CA cert → Issuer CA → cert membri replica set.
# Il Secret 'mongodb-tenant-tls' (creato da cert-manager) contiene tls.crt, tls.key, ca.crt.
#
# Note operative:
# - mongod usa keyFile per cluster auth e TLS solo per il transport (non x.509).
# - Il rinnovo del cert membri (renewBefore: 30d, duration: 1y) NON riavvia
# automaticamente i pod mongo. Quando cert-manager riemette il Secret va
# fatto manualmente: kubectl rollout restart statefulset/mongodb-tenant.
# - SAN coprono: nome service headless, FQDN brevi e completi dei 3 pod.
# Per connessioni da deploy.sh via 127.0.0.1 si usa --tlsAllowInvalidHostnames
# (la CA è validata, solo l'hostname check è skippato).
---
apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
name: mongodb-tenant-selfsigned
namespace: ${NAMESPACE}
spec:
selfSigned: {}
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: mongodb-tenant-ca
namespace: ${NAMESPACE}
spec:
isCA: true
commonName: mongodb-tenant-ca
duration: 87600h # 10 anni (CA interna)
renewBefore: 720h # 30 giorni
secretName: mongodb-tenant-ca
privateKey:
algorithm: ECDSA
size: 256
issuerRef:
name: mongodb-tenant-selfsigned
kind: Issuer
---
apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
name: mongodb-tenant-ca
namespace: ${NAMESPACE}
spec:
ca:
secretName: mongodb-tenant-ca
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: mongodb-tenant-tls
namespace: ${NAMESPACE}
spec:
secretName: mongodb-tenant-tls
duration: 8760h # 1 anno
renewBefore: 720h # 30 giorni
privateKey:
algorithm: ECDSA
size: 256
issuerRef:
name: mongodb-tenant-ca
kind: Issuer
dnsNames:
- mongodb-tenant-headless
- mongodb-tenant-headless.${NAMESPACE}.svc
- mongodb-tenant-headless.${NAMESPACE}.svc.cluster.local
- mongodb-tenant-0
- mongodb-tenant-1
- mongodb-tenant-2
- mongodb-tenant-0.mongodb-tenant-headless
- mongodb-tenant-1.mongodb-tenant-headless
- mongodb-tenant-2.mongodb-tenant-headless
- mongodb-tenant-0.mongodb-tenant-headless.${NAMESPACE}.svc.cluster.local
- mongodb-tenant-1.mongodb-tenant-headless.${NAMESPACE}.svc.cluster.local
- mongodb-tenant-2.mongodb-tenant-headless.${NAMESPACE}.svc.cluster.local
+71
View File
@@ -0,0 +1,71 @@
# NetworkPolicy: isolamento del namespace tenant.
# - mongodb-tenant: ingress solo dai pod tenant-api e dagli altri membri del replica set.
# - tenant-api: ingress solo dall'ingress controller (namespace 'ingress-nginx').
# - tenant-admin: ingress solo dall'ingress controller.
# Egress: non vincolato (DNS, registry, SMTP, LOGINMASTER_API_URL devono restare raggiungibili).
#
# Nota: la namespace label 'kubernetes.io/metadata.name' è automatica dal K8s 1.22+.
# Se l'ingress controller è installato in un namespace diverso, modificare il selector.
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: mongodb-tenant
namespace: ${NAMESPACE}
spec:
podSelector:
matchLabels:
app: mongodb-tenant
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app: mongodb-tenant
- podSelector:
matchLabels:
app: tenant-api
ports:
- protocol: TCP
port: 27017
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: tenant-api
namespace: ${NAMESPACE}
spec:
podSelector:
matchLabels:
app: tenant-api
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: ingress-nginx
ports:
- protocol: TCP
port: 3000
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: tenant-admin
namespace: ${NAMESPACE}
spec:
podSelector:
matchLabels:
app: tenant-admin
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: ingress-nginx
ports:
- protocol: TCP
port: 80
+25
View File
@@ -0,0 +1,25 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: tenant-api-config
namespace: ${NAMESPACE}
data:
NODE_ENV: "production"
PORT: "3000"
# MONGODB_URI è nel Secret mongodb-tenant-auth (contiene credenziali)
LOGINMASTER_API_URL: "${LOGINMASTER_API_URL}"
ADMIN_ALLOWED_ORIGIN: "${ADMIN_ALLOWED_ORIGIN}"
TENANT_ADMIN_URL: "${TENANT_ADMIN_URL}"
# CORS_ORIGIN e MANAGE_ALLOWED_ORIGIN sono alias di TENANT_ADMIN_URL: nomi
# storici letti dal codice API. Tenuti per compat — se l'API ne legge solo
# uno è innocuo, ma è preferibile che il codice legga TENANT_ADMIN_URL.
CORS_ORIGIN: "${TENANT_ADMIN_URL}"
MANAGE_ALLOWED_ORIGIN: "${TENANT_ADMIN_URL}"
JWT_ACCESS_EXPIRY: "15m"
JWT_REFRESH_EXPIRY: "7d"
LOG_LEVEL: "info"
SMTP_HOST: "${SMTP_HOST}"
SMTP_PORT: "${SMTP_PORT}"
SMTP_SECURE: "${SMTP_SECURE}"
SMTP_USER: "${SMTP_USER}"
EMAIL_FROM: "${EMAIL_FROM}"
+198
View File
@@ -0,0 +1,198 @@
apiVersion: v1
kind: Service
metadata:
name: mongodb-tenant-headless
namespace: ${NAMESPACE}
spec:
clusterIP: None
selector:
app: mongodb-tenant
ports:
- port: 27017
targetPort: 27017
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: mongodb-tenant
namespace: ${NAMESPACE}
spec:
serviceName: mongodb-tenant-headless
replicas: 3
podManagementPolicy: Parallel
selector:
matchLabels:
app: mongodb-tenant
template:
metadata:
labels:
app: mongodb-tenant
spec:
imagePullSecrets:
- name: registry-codebaker
securityContext:
# fsGroup garantisce ownership corretta sul PVC (mongo data) per uid 999.
# runAsUser/Group: il container principale gira come mongodb (uid 999),
# non root. L'init invece deve essere root per poter chmod 400 + chown.
runAsUser: 999
runAsGroup: 999
fsGroup: 999
initContainers:
# Prepara keyfile e PEM nel tmpfs /run/mongo con owner=mongodb mode=0400.
# Mongod richiede esattamente 0400/0600 owned-by-self e quel mode non si
# può ottenere via secret defaultMode senza far girare il container come
# root. Quindi: init come root, main come 999.
- name: init-tls
image: mongo:6.0
securityContext:
runAsUser: 0
runAsGroup: 0
command:
- /bin/sh
- -c
- >-
cp /etc/secrets/keyfile /run/mongo/mongodb-keyfile &&
cat /etc/mongo-tls/tls.crt /etc/mongo-tls/tls.key > /run/mongo/mongo-server.pem &&
chown 999:999 /run/mongo/mongodb-keyfile /run/mongo/mongo-server.pem &&
chmod 400 /run/mongo/mongodb-keyfile /run/mongo/mongo-server.pem
volumeMounts:
- name: mongo-runtime
mountPath: /run/mongo
- name: mongo-keyfile
mountPath: /etc/secrets
readOnly: true
- name: mongo-tls
mountPath: /etc/mongo-tls
readOnly: true
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchLabels:
app: mongodb-tenant
topologyKey: kubernetes.io/hostname
containers:
- name: mongodb
image: mongo:6.0
# I file runtime (keyfile + PEM cert+key) sono già stati preparati dall'
# initContainer in /run/mongo con owner mongodb:mongodb mode 0400.
command:
- mongod
- --bind_ip_all
- --replSet
- rs0
- --auth
- --keyFile
- /run/mongo/mongodb-keyfile
- --tlsMode
- requireTLS
- --tlsCertificateKeyFile
- /run/mongo/mongo-server.pem
- --tlsCAFile
- /etc/mongo-tls/ca.crt
# Senza questo flag, --tlsCAFile mette mongod in mTLS e rifiuta ogni
# client che non presenta un cert. I membri del replica set si autenticano
# via keyFile (clusterAuthMode keyFile, default), non x.509: quindi non
# serve mTLS. I client (API, probe, mongosh) presentano solo password.
- --tlsAllowConnectionsWithoutCertificates
ports:
- containerPort: 27017
volumeMounts:
- name: mongodb-data
mountPath: /data/db
- name: mongo-runtime
mountPath: /run/mongo
readOnly: true
- name: mongo-tls
mountPath: /etc/mongo-tls
readOnly: true
resources:
requests:
cpu: "${MONGO_CPU_REQUEST}"
memory: "${MONGO_MEM_REQUEST}"
limits:
cpu: "${MONGO_CPU_LIMIT}"
memory: "${MONGO_MEM_LIMIT}"
# quit(N) propaga N come exit code: probe fallisce solo se ping.ok != 1.
# Connessione su 127.0.0.1 con --tlsAllowInvalidHostnames perché il cert
# non include localhost nei SAN (la CA però viene validata).
livenessProbe:
exec:
command:
- mongosh
- --quiet
- --tls
- --tlsCAFile
- /etc/mongo-tls/ca.crt
- --tlsAllowInvalidHostnames
- --host
- "127.0.0.1"
- --eval
- "quit(db.adminCommand('ping').ok===1?0:1)"
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
readinessProbe:
exec:
command:
- mongosh
- --quiet
- --tls
- --tlsCAFile
- /etc/mongo-tls/ca.crt
- --tlsAllowInvalidHostnames
- --host
- "127.0.0.1"
- --eval
- "quit(db.adminCommand('ping').ok===1?0:1)"
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
volumes:
- name: mongo-runtime
emptyDir:
medium: Memory
sizeLimit: 16Mi
- name: mongo-keyfile
secret:
secretName: mongodb-tenant-auth
items:
- key: keyfile
path: keyfile
# Con fsGroup=999 il file è root:mongodb 0440 — leggibile solo dal gruppo
# mongodb (uid/gid 999), non world-readable. Il container poi lo copia in
# /data/db/ e fa chmod 400 perché mongod richiede mode 0400/0600 owner-only.
defaultMode: 0440
- name: mongo-tls
secret:
# Secret popolato da cert-manager (vedi templates/01-mongodb-tls.yaml).
# Contiene tls.crt, tls.key, ca.crt — leggibili dal gruppo 999 via fsGroup.
secretName: mongodb-tenant-tls
defaultMode: 0440
volumeClaimTemplates:
- metadata:
name: mongodb-data
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: "${STORAGE_CLASS}"
resources:
requests:
storage: ${STORAGE_SIZE}
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: mongodb-tenant-pdb
namespace: ${NAMESPACE}
spec:
# Il replica set tollera la perdita di 1 membro (quorum = 2/3).
# minAvailable: 2 evita che drain/voluntary disruption tolga il quorum.
minAvailable: 2
selector:
matchLabels:
app: mongodb-tenant
# NB: rs.initiate e la creazione degli utenti root/app sono eseguite da
# `deploy.sh` via `kubectl exec` nel pod mongodb-tenant-0 (sfrutta la
# "localhost exception" di Mongo per creare il primo utente senza auth).
+111
View File
@@ -0,0 +1,111 @@
apiVersion: v1
kind: Service
metadata:
name: tenant-api
namespace: ${NAMESPACE}
spec:
selector:
app: tenant-api
ports:
- port: 3000
targetPort: 3000
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: tenant-api
namespace: ${NAMESPACE}
spec:
replicas: 2
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
selector:
matchLabels:
app: tenant-api
template:
metadata:
labels:
app: tenant-api
spec:
imagePullSecrets:
- name: registry-codebaker
containers:
- name: tenant-api
image: hub.codebaker.it/loginmaster-tenant/api-tenant:${IMAGE_TAG_API}
ports:
- containerPort: 3000
envFrom:
- configMapRef:
name: tenant-api-config
env:
- name: MONGODB_URI
valueFrom:
secretKeyRef:
name: mongodb-tenant-auth
key: MONGODB_URI
- name: MASTER_ENCRYPTION_KEY
valueFrom:
secretKeyRef:
name: tenant-api-secrets
key: MASTER_ENCRYPTION_KEY
- name: SMTP_PASSWORD
valueFrom:
secretKeyRef:
name: tenant-api-secrets
key: SMTP_PASSWORD
volumeMounts:
- name: mongo-ca
mountPath: /etc/mongo-tls
readOnly: true
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
startupProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 30
readinessProbe:
httpGet:
path: /health
port: 3000
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
livenessProbe:
httpGet:
path: /health
port: 3000
periodSeconds: 20
timeoutSeconds: 5
failureThreshold: 3
volumes:
- name: mongo-ca
secret:
# Solo la ca.crt dal Secret cert-manager; il cert di servizio non serve
# all'API (non fa mTLS, valida solo il server).
secretName: mongodb-tenant-tls
items:
- key: ca.crt
path: ca.crt
defaultMode: 0444
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: tenant-api-pdb
namespace: ${NAMESPACE}
spec:
minAvailable: 1
selector:
matchLabels:
app: tenant-api
+72
View File
@@ -0,0 +1,72 @@
apiVersion: v1
kind: Service
metadata:
name: tenant-admin
namespace: ${NAMESPACE}
spec:
selector:
app: tenant-admin
ports:
- port: 80
targetPort: 80
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: tenant-admin
namespace: ${NAMESPACE}
spec:
replicas: 1
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
selector:
matchLabels:
app: tenant-admin
template:
metadata:
labels:
app: tenant-admin
spec:
imagePullSecrets:
- name: registry-codebaker
containers:
- name: tenant-admin
image: hub.codebaker.it/loginmaster-tenant/admin-tenant:${IMAGE_TAG_ADMIN}
env:
- name: VITE_APP_NAME
value: "LoginMaster Tenant Admin"
- name: VITE_API_BASE_URL
value: "https://${DOMAIN_API}"
ports:
- containerPort: 80
resources:
requests:
cpu: 50m
memory: 32Mi
limits:
cpu: 100m
memory: 64Mi
startupProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 3
periodSeconds: 5
failureThreshold: 20
readinessProbe:
httpGet:
path: /
port: 80
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
livenessProbe:
httpGet:
path: /
port: 80
periodSeconds: 20
timeoutSeconds: 5
failureThreshold: 3
+36
View File
@@ -0,0 +1,36 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: tenant-ingress
namespace: ${NAMESPACE}
annotations:
nginx.ingress.kubernetes.io/proxy-body-size: "10m"
cert-manager.io/cluster-issuer: "letsencrypt-prod"
spec:
ingressClassName: nginx
tls:
- hosts:
- ${DOMAIN_API}
- ${DOMAIN_ADMIN}
secretName: tenant-tls
rules:
- host: ${DOMAIN_API}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: tenant-api
port:
number: 3000
- host: ${DOMAIN_ADMIN}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: tenant-admin
port:
number: 80