|
import { Construct } from 'constructs'; |
|
import * as k8s from '../../imports/k8s'; |
|
import { ResourceRequirements, SolidtimeConfig } from '../types'; |
|
|
|
export interface AppConstructProps { |
|
config: SolidtimeConfig; |
|
dbAppSecret: string; // solidtime-postgres-app (CNPG-generated) |
|
dbHost: string; // solidtime-postgres-rw |
|
appSecret: string; // solidtime-app-secrets (key: app-key) |
|
smtpSecret: string; // solidtime-smtp-secrets (keys: username/password) |
|
s3Secret: string; // solidtime-s3-credentials (AWS_ACCESS_KEY_ID/_SECRET) |
|
} |
|
|
|
const APP_LABELS = { 'app.kubernetes.io/name': 'solidtime', 'app.kubernetes.io/part-of': 'solidtime' }; |
|
const IMAGE_BASE = 'solidtime/solidtime'; |
|
const HTTP_PORT = 8000; |
|
const GOTENBERG_PORT = 3000; |
|
const GOTENBERG_SVC = 'solidtime-gotenberg'; |
|
|
|
export class AppConstruct extends Construct { |
|
public readonly httpServiceName: string = 'solidtime'; |
|
|
|
constructor(scope: Construct, id: string, props: AppConstructProps) { |
|
super(scope, id); |
|
|
|
const { config, dbAppSecret, dbHost, appSecret, smtpSecret, s3Secret } = props; |
|
const ns = config.namespace; |
|
const image = `${IMAGE_BASE}:${config.versions.solidtime}`; |
|
|
|
// ---- Non-secret env shared by all three Solidtime roles (ConfigMap) ---- |
|
const envConfig = new k8s.KubeConfigMap(this, 'env', { |
|
metadata: { name: 'solidtime-env', namespace: ns, annotations: { 'argocd.argoproj.io/sync-wave': '4' } }, |
|
data: { |
|
APP_ENV: 'production', |
|
APP_DEBUG: 'false', |
|
APP_URL: `https://${config.domain}`, |
|
// Log to stderr (visible via `kubectl logs`) instead of storage/logs/laravel.log. |
|
LOG_CHANNEL: 'stderr', |
|
// DB |
|
DB_CONNECTION: 'pgsql', |
|
DB_HOST: dbHost, |
|
DB_PORT: '5432', |
|
DB_DATABASE: 'solidtime', |
|
// Cache/queue/sessions via database (no Redis) |
|
CACHE_STORE: 'database', |
|
QUEUE_CONNECTION: 'database', |
|
SESSION_DRIVER: 'database', |
|
// Mail (Mailjet) |
|
MAIL_MAILER: 'smtp', |
|
MAIL_HOST: config.smtp.host, |
|
MAIL_PORT: String(config.smtp.port), |
|
MAIL_ENCRYPTION: config.smtp.useTls ? 'tls' : 'null', |
|
MAIL_FROM_ADDRESS: config.smtp.from, |
|
MAIL_FROM_NAME: 'Solidtime', |
|
// Registration / admin (flip APP_ENABLE_REGISTRATION to "false" after |
|
// the first super-admin signs up). |
|
APP_ENABLE_REGISTRATION: config.enableRegistration, |
|
SUPER_ADMINS: config.superAdmins, |
|
// Filesystem -> hel1 S3. Solidtime's config/filesystems.php reads S3_* |
|
// (NOT AWS_*) env vars for the s3 disk. |
|
FILESYSTEM_DISK: 's3', |
|
S3_BUCKET: config.s3.files.bucket, |
|
S3_ENDPOINT: config.s3.files.endpoint, |
|
S3_REGION: config.s3.files.region, |
|
S3_USE_PATH_STYLE_ENDPOINT: 'true', |
|
// Gotenberg (PDF export) |
|
GOTENBERG_URL: `http://${GOTENBERG_SVC}:${GOTENBERG_PORT}`, |
|
}, |
|
}); |
|
|
|
// ---- Secret-derived env shared by all roles ---- |
|
const secretEnv = (): k8s.EnvVar[] => [ |
|
{ name: 'APP_KEY', valueFrom: { secretKeyRef: { name: appSecret, key: 'app-key' } } }, |
|
{ name: 'PASSPORT_PRIVATE_KEY', valueFrom: { secretKeyRef: { name: appSecret, key: 'passport-private-key' } } }, |
|
{ name: 'PASSPORT_PUBLIC_KEY', valueFrom: { secretKeyRef: { name: appSecret, key: 'passport-public-key' } } }, |
|
{ name: 'DB_USERNAME', valueFrom: { secretKeyRef: { name: dbAppSecret, key: 'username' } } }, |
|
{ name: 'DB_PASSWORD', valueFrom: { secretKeyRef: { name: dbAppSecret, key: 'password' } } }, |
|
{ name: 'MAIL_USERNAME', valueFrom: { secretKeyRef: { name: smtpSecret, key: 'username' } } }, |
|
{ name: 'MAIL_PASSWORD', valueFrom: { secretKeyRef: { name: smtpSecret, key: 'password' } } }, |
|
{ name: 'S3_ACCESS_KEY_ID', valueFrom: { secretKeyRef: { name: s3Secret, key: 'AWS_ACCESS_KEY_ID' } } }, |
|
{ name: 'S3_SECRET_ACCESS_KEY', valueFrom: { secretKeyRef: { name: s3Secret, key: 'AWS_SECRET_ACCESS_KEY' } } }, |
|
]; |
|
|
|
const res = (r: ResourceRequirements): k8s.ResourceRequirements => ({ |
|
requests: { |
|
cpu: k8s.Quantity.fromString(r.requests?.cpu || '100m'), |
|
memory: k8s.Quantity.fromString(r.requests?.memory || '128Mi'), |
|
}, |
|
limits: { |
|
cpu: k8s.Quantity.fromString(r.limits?.cpu || '500m'), |
|
memory: k8s.Quantity.fromString(r.limits?.memory || '512Mi'), |
|
}, |
|
}); |
|
|
|
// ---- Helper to build a Solidtime role deployment ---- |
|
const roleDeployment = ( |
|
role: 'http' | 'scheduler' | 'worker', |
|
replicas: number, |
|
resources: ResourceRequirements, |
|
extras?: { ports?: k8s.ContainerPort[]; probe?: boolean; extraEnv?: k8s.EnvVar[] }, |
|
) => { |
|
const name = `solidtime-${role}`; |
|
const roleLabels = { ...APP_LABELS, 'app.kubernetes.io/component': role }; |
|
new k8s.KubeDeployment(this, name, { |
|
metadata: { name, namespace: ns, annotations: { 'argocd.argoproj.io/sync-wave': '5' }, labels: roleLabels }, |
|
spec: { |
|
replicas, |
|
selector: { matchLabels: roleLabels }, |
|
// scheduler must never run >1 concurrently; Recreate avoids overlap |
|
strategy: role === 'scheduler' ? { type: 'Recreate' } : undefined, |
|
template: { |
|
metadata: { labels: roleLabels }, |
|
spec: { |
|
containers: [{ |
|
name: 'solidtime', |
|
image, |
|
imagePullPolicy: 'IfNotPresent', |
|
env: [{ name: 'CONTAINER_MODE', value: role }, ...secretEnv(), ...(extras?.extraEnv ?? [])], |
|
envFrom: [{ configMapRef: { name: envConfig.name } }], |
|
ports: extras?.ports, |
|
resources: res(resources), |
|
...(extras?.probe && { |
|
// Solidtime has no /health or /up route. "/" returns a 302 to the |
|
// absolute https:// URL (APP_URL), which kubelet follows over HTTPS |
|
// against the plain-HTTP server -> probe fails. "/login" returns 200 |
|
// directly with no redirect. |
|
readinessProbe: { |
|
httpGet: { path: '/login', port: k8s.IntOrString.fromNumber(HTTP_PORT) }, |
|
initialDelaySeconds: 20, periodSeconds: 15, timeoutSeconds: 5, failureThreshold: 6, |
|
}, |
|
livenessProbe: { |
|
httpGet: { path: '/login', port: k8s.IntOrString.fromNumber(HTTP_PORT) }, |
|
initialDelaySeconds: 40, periodSeconds: 20, timeoutSeconds: 5, failureThreshold: 6, |
|
}, |
|
}), |
|
}], |
|
}, |
|
}, |
|
}, |
|
}); |
|
}; |
|
|
|
roleDeployment('http', config.replicas.http, config.resources.http, { |
|
ports: [{ containerPort: HTTP_PORT, name: 'http' }], |
|
probe: true, |
|
}); |
|
// AUTO_DB_MIGRATE on the scheduler only (single replica) runs `artisan migrate` |
|
// on startup — the documented self-host migration path. Setting it solely here |
|
// avoids a migration race across the http/worker replicas. |
|
roleDeployment('scheduler', 1, config.resources.scheduler, { |
|
extraEnv: [{ name: 'AUTO_DB_MIGRATE', value: 'true' }], |
|
}); |
|
// The image's CONTAINER_MODE=worker execs $WORKER_COMMAND — it must be set. |
|
roleDeployment('worker', 1, config.resources.worker, { |
|
extraEnv: [{ name: 'WORKER_COMMAND', value: 'php artisan queue:work --tries=3 --sleep=3 --max-time=3600' }], |
|
}); |
|
|
|
// ---- http Service ---- |
|
new k8s.KubeService(this, 'service', { |
|
metadata: { name: this.httpServiceName, namespace: ns, annotations: { 'argocd.argoproj.io/sync-wave': '5' }, labels: APP_LABELS }, |
|
spec: { |
|
selector: { ...APP_LABELS, 'app.kubernetes.io/component': 'http' }, |
|
ports: [{ name: 'http', port: 80, targetPort: k8s.IntOrString.fromNumber(HTTP_PORT) }], |
|
}, |
|
}); |
|
|
|
// ---- Gotenberg (PDF export) — listens on 3000 by default ---- |
|
const gotenbergLabels = { ...APP_LABELS, 'app.kubernetes.io/component': 'gotenberg' }; |
|
new k8s.KubeDeployment(this, 'gotenberg', { |
|
metadata: { name: GOTENBERG_SVC, namespace: ns, annotations: { 'argocd.argoproj.io/sync-wave': '5' }, labels: gotenbergLabels }, |
|
spec: { |
|
replicas: 1, |
|
selector: { matchLabels: gotenbergLabels }, |
|
template: { |
|
metadata: { labels: gotenbergLabels }, |
|
spec: { |
|
containers: [{ |
|
name: 'gotenberg', |
|
image: `gotenberg/gotenberg:${config.versions.gotenberg}`, |
|
ports: [{ containerPort: GOTENBERG_PORT, name: 'http' }], |
|
resources: res(config.resources.gotenberg), |
|
}], |
|
}, |
|
}, |
|
}, |
|
}); |
|
new k8s.KubeService(this, 'gotenberg-service', { |
|
metadata: { name: GOTENBERG_SVC, namespace: ns, annotations: { 'argocd.argoproj.io/sync-wave': '5' }, labels: gotenbergLabels }, |
|
spec: { |
|
selector: gotenbergLabels, |
|
ports: [{ name: 'http', port: GOTENBERG_PORT, targetPort: k8s.IntOrString.fromNumber(GOTENBERG_PORT) }], |
|
}, |
|
}); |
|
|
|
// DB migrations run on scheduler startup via AUTO_DB_MIGRATE=true (set above) — |
|
// the documented Solidtime self-host migration path. No separate Job needed. |
|
} |
|
} |