diff --git a/packages/standard/k8s.apps/assets/charts.json b/packages/standard/k8s.apps/assets/charts.json index 5fd8690..f93c7ac 100644 --- a/packages/standard/k8s.apps/assets/charts.json +++ b/packages/standard/k8s.apps/assets/charts.json @@ -35,6 +35,12 @@ "version": "0.5.7", "sha256": "184d60dd6b6a723842957b2d642cf13ed145c3c70564f475d5b58825aa556e00" }, + "matrix-stack": { + "repo": "oci://ghcr.io/element-hq/ess-helm", + "name": "matrix-stack", + "version": "26.2.0", + "sha256": "96881b2746acf6251a7fbdd2a06bab6c0d42b0e2c3a75b20210bb4e2cc47a165" + }, "valkey": { "repo": "oci://ghcr.io/cloudpirates-io/helm-charts", "name": "valkey", diff --git a/packages/standard/k8s.apps/package.json b/packages/standard/k8s.apps/package.json index fc22bbc..6b49c7e 100644 --- a/packages/standard/k8s.apps/package.json +++ b/packages/standard/k8s.apps/package.json @@ -15,6 +15,7 @@ "./mariadb/app": "./dist/mariadb/app/index.js", "./mariadb/database": "./dist/mariadb/database/index.js", "./maybe": "./dist/maybe/index.js", + "./matrix-stack": "./dist/matrix-stack/index.js", "./postgresql": "./dist/postgresql/index.js", "./postgresql/app": "./dist/postgresql/app/index.js", "./postgresql/database": "./dist/postgresql/database/index.js", diff --git a/packages/standard/k8s.apps/src/matrix-stack/index.ts b/packages/standard/k8s.apps/src/matrix-stack/index.ts new file mode 100644 index 0000000..09cc881 --- /dev/null +++ b/packages/standard/k8s.apps/src/matrix-stack/index.ts @@ -0,0 +1,220 @@ +import { chmod, stat } from "node:fs/promises" +import { fileURLToPath } from "node:url" +import { AccessPointRoute, l4EndpointToString } from "@highstate/common" +import { getProviderAsync, Namespace, resolveHelmChart, Service } from "@highstate/k8s" +import { k8s } from "@highstate/library" +import { forUnit, toPromise } from "@highstate/pulumi" +import { helm } from "@pulumi/kubernetes" +import { charts } from "../shared" + +const { args, inputs, outputs } = forUnit(k8s.apps.matrixStack) + +const namespace = Namespace.create(args.appName, { cluster: inputs.k8sCluster }) + +const synapseHost = `matrix.${args.fqdn}` +const elementWebHost = `chat.${args.fqdn}` +const matrixAuthenticationServiceHost = `account.${args.fqdn}` +const matrixRtcHost = `mrtc.${args.fqdn}` +const elementAdminHost = `admin.${args.fqdn}` +const HELM_INGRESS_DISABLED_VALUE = "none" +const postrenderScript = fileURLToPath(new URL("./postrender.js", import.meta.url)) +const EXECUTABLE_MASK = 0o111 +const EXECUTABLE_MODE = 0o755 +let postrenderExecutablePromise: Promise | undefined + +const ensurePostrenderExecutable = () => { + if (!postrenderExecutablePromise) { + postrenderExecutablePromise = (async () => { + try { + const postrenderMode = (await stat(postrenderScript)).mode + if ((postrenderMode & EXECUTABLE_MASK) === 0) { + await chmod(postrenderScript, EXECUTABLE_MODE) + } + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + throw new Error(`Helm postrender script not found: ${postrenderScript}`, { + cause: error, + }) + } + + throw new Error( + `Failed to mark Helm postrender script as executable: ${postrenderScript}`, + { + cause: error, + }, + ) + } + })() + } + + return postrenderExecutablePromise +} + +await ensurePostrenderExecutable() + +const provider = await getProviderAsync(inputs.k8sCluster) +const chartPath = await resolveHelmChart(charts["matrix-stack"]) + +const release = new helm.v3.Release( + args.appName, + { + chart: chartPath, + namespace: namespace.metadata.name, + + values: { + serverName: args.fqdn, + ingress: { + className: HELM_INGRESS_DISABLED_VALUE, + }, + + synapse: { + ingress: { + host: synapseHost, + }, + }, + elementWeb: { + ingress: { + host: elementWebHost, + }, + }, + elementAdmin: { + ingress: { + host: elementAdminHost, + }, + }, + matrixAuthenticationService: { + ingress: { + host: matrixAuthenticationServiceHost, + }, + }, + matrixRTC: { + ingress: { + host: matrixRtcHost, + }, + }, + wellKnownDelegation: { + baseDomainRedirect: { + enabled: false, + }, + }, + }, + + postrender: postrenderScript, + }, + { provider, dependsOn: namespace }, +) + +const serviceOptions = { dependsOn: release } +const serviceName = (name: string) => `${args.appName}-${name}` +const synapseServiceName = serviceName("synapse") +const elementWebServiceName = serviceName("element-web") +const elementAdminServiceName = serviceName("element-admin") +const matrixAuthenticationServiceName = serviceName("matrix-authentication-service") +const matrixRtcAuthorisationServiceName = serviceName("matrix-rtc-authorisation-service") +const matrixRtcSfuServiceName = serviceName("matrix-rtc-sfu") +const wellKnownServiceName = serviceName("well-known") +const getService = (serviceName: string) => + Service.get(serviceName, { namespace, name: serviceName }, serviceOptions) +const synapseService = getService(synapseServiceName) +const elementWebService = getService(elementWebServiceName) +const elementAdminService = getService(elementAdminServiceName) +const matrixAuthenticationService = getService(matrixAuthenticationServiceName) +const matrixRtcAuthorisationService = getService(matrixRtcAuthorisationServiceName) +const matrixRtcSfuService = getService(matrixRtcSfuServiceName) +const wellKnownService = getService(wellKnownServiceName) + +const commonRouteArgs = { + accessPoint: inputs.accessPoint, + type: "http" as const, + tlsCertificateNativeData: namespace, +} + +new AccessPointRoute( + `${args.appName}-synapse`, + { + ...commonRouteArgs, + fqdn: synapseHost, + endpoints: synapseService.endpoints, + gatewayNativeData: synapseService, + }, + { dependsOn: release }, +) +new AccessPointRoute( + `${args.appName}-element-web`, + { + ...commonRouteArgs, + fqdn: elementWebHost, + endpoints: elementWebService.endpoints, + gatewayNativeData: elementWebService, + }, + { dependsOn: release }, +) +new AccessPointRoute( + `${args.appName}-element-admin`, + { + ...commonRouteArgs, + fqdn: elementAdminHost, + endpoints: elementAdminService.endpoints, + gatewayNativeData: elementAdminService, + }, + { dependsOn: release }, +) +new AccessPointRoute( + `${args.appName}-matrix-authentication-service`, + { + ...commonRouteArgs, + fqdn: matrixAuthenticationServiceHost, + endpoints: matrixAuthenticationService.endpoints, + gatewayNativeData: matrixAuthenticationService, + }, + { dependsOn: release }, +) +new AccessPointRoute( + `${args.appName}-matrix-rtc-sfu`, + { + ...commonRouteArgs, + fqdn: matrixRtcHost, + endpoints: matrixRtcSfuService.endpoints, + gatewayNativeData: matrixRtcSfuService, + }, + { dependsOn: release }, +) +new AccessPointRoute( + `${args.appName}-matrix-rtc-authorisation`, + { + ...commonRouteArgs, + fqdn: matrixRtcHost, + path: "/sfu/get", + endpoints: matrixRtcAuthorisationService.endpoints, + gatewayNativeData: matrixRtcAuthorisationService, + }, + { dependsOn: release }, +) +new AccessPointRoute( + `${args.appName}-well-known`, + { + ...commonRouteArgs, + fqdn: args.fqdn, + path: "/.well-known/matrix", + endpoints: wellKnownService.endpoints, + gatewayNativeData: wellKnownService, + }, + { dependsOn: release }, +) + +const endpoints = await toPromise(synapseService.endpoints) + +export default outputs({ + service: synapseService.entity, + endpoints: synapseService.endpoints, + + $statusFields: { + serverName: args.fqdn, + synapse: `https://${synapseHost}`, + elementWeb: `https://${elementWebHost}`, + elementAdmin: `https://${elementAdminHost}`, + matrixAuthenticationService: `https://${matrixAuthenticationServiceHost}`, + matrixRtc: `https://${matrixRtcHost}`, + endpoints: endpoints.map(l4EndpointToString), + }, +}) diff --git a/packages/standard/k8s.apps/src/matrix-stack/postrender.js b/packages/standard/k8s.apps/src/matrix-stack/postrender.js new file mode 100755 index 0000000..209162d --- /dev/null +++ b/packages/standard/k8s.apps/src/matrix-stack/postrender.js @@ -0,0 +1,14 @@ +#!/usr/bin/env node +import { readFileSync } from "node:fs" + +const input = readFileSync(0, "utf8") +const normalizedInput = input.replace(/\r\n/g, "\n") +const rawDocuments = normalizedInput.split(/\n---\s*\n/) +const documents = (normalizedInput.startsWith("---") ? rawDocuments.slice(1) : rawDocuments).filter( + Boolean, +) +const ingressPattern = /^\s*kind:\s*Ingress\s*(?:#.*)?$/m +const filtered = documents.filter(document => !ingressPattern.test(document)) +const output = filtered.filter(document => document.trim().length > 0).join("\n---\n") + +process.stdout.write(output.endsWith("\n") ? output : `${output}\n`) diff --git a/packages/standard/k8s.apps/tsconfig.json b/packages/standard/k8s.apps/tsconfig.json index e833636..e73b08d 100644 --- a/packages/standard/k8s.apps/tsconfig.json +++ b/packages/standard/k8s.apps/tsconfig.json @@ -1,4 +1,4 @@ { "extends": "./node_modules/@highstate/cli/assets/tsconfig.base.json", - "include": ["./src/**/*.ts", "./package.json", "./assets/**/*.json"] + "include": ["./src/**/*.{js,ts}", "./package.json", "./assets/**/*.json"] } diff --git a/packages/standard/library/src/k8s/apps/index.ts b/packages/standard/library/src/k8s/apps/index.ts index f6bbb0a..cb0df3a 100644 --- a/packages/standard/library/src/k8s/apps/index.ts +++ b/packages/standard/library/src/k8s/apps/index.ts @@ -4,6 +4,7 @@ export * from "./grocy" export * from "./hubble" export * from "./kubernetes-dashboard" export * from "./mariadb" +export * from "./matrix-stack" export * from "./maybe" export * from "./minio" export * from "./mongodb" diff --git a/packages/standard/library/src/k8s/apps/matrix-stack.ts b/packages/standard/library/src/k8s/apps/matrix-stack.ts new file mode 100644 index 0000000..144e8bc --- /dev/null +++ b/packages/standard/library/src/k8s/apps/matrix-stack.ts @@ -0,0 +1,47 @@ +import { defineUnit, z } from "@highstate/contract" +import { pick } from "remeda" +import { l4EndpointEntity } from "../../network" +import { serviceEntity } from "../service" +import { appName, sharedInputs, source } from "./shared" + +/** + * The Matrix stack deployed on Kubernetes. + */ +export const matrixStack = defineUnit({ + type: "k8s.apps.matrix-stack.v1", + + args: { + ...appName("matrix-stack"), + + /** + * The base domain for the Matrix stack services. + * + * Subdomains for Matrix services are generated automatically. + * This value cannot be changed after the first deployment. + */ + fqdn: { + schema: z.string(), + }, + }, + + inputs: { + ...pick(sharedInputs, ["k8sCluster", "accessPoint"]), + }, + + outputs: { + service: serviceEntity, + endpoints: { + entity: l4EndpointEntity, + multiple: true, + }, + }, + + meta: { + title: "Matrix Stack", + icon: "simple-icons:matrixdotorg", + secondaryIcon: "simple-icons:element", + category: "Communication", + }, + + source: source("matrix-stack"), +})