From b8bb16ccbb8899ce989acc8ac0e777ff2816b44b Mon Sep 17 00:00:00 2001 From: Morten Olsen Date: Tue, 12 Aug 2025 22:32:09 +0200 Subject: [PATCH] updates --- Makefile | 13 +- chart/templates/_storageclass.yaml | 12 + chart/templates/clusterrole.yaml | 15 ++ chart/values.yaml | 3 + manifests/environment.yaml | 12 + manifests/example-pvc.yaml | 39 ++++ manifests/storageclass.yaml | 10 + src/bootstrap/bootstrap.ts | 38 ++++ src/bootstrap/namespaces/namespaces.ts | 64 ++++++ src/bootstrap/releases/releases.ts | 171 ++++++++++++++ src/bootstrap/repos/repos.ts | 112 ++++++++++ src/bootstrap/resources/issuer.ts | 64 ++++++ .../authentik-client.resource.ts | 15 +- .../authentik-client.schemas.ts | 10 +- .../authentik-connection.resource.ts | 93 -------- .../authentik-connection.schemas.ts | 11 - .../authentik-connection.ts | 19 -- .../authentik-server.controller.ts | 211 ++++++++++++++++++ .../authentik-server.schemas.ts | 22 ++ .../authentik-server/authentik-server.ts | 19 ++ src/custom-resouces/custom-resources.ts | 10 +- .../environment/environment.controller.ts | 206 +++++++++++++++++ .../environment/environment.schemas.ts | 14 ++ .../environment/environment.ts | 19 ++ .../http-service/http-service.controller.ts | 100 +++++++++ .../http-service/http-service.schemas.ts | 18 ++ .../http-service/http-service.ts | 19 ++ .../postgres-cluster.controller.ts | 155 +++++++++++++ .../postgres-cluster.schemas.ts | 20 ++ .../postgres-cluster/postgres-cluster.ts | 19 ++ .../portgres-database.schemas.ts | 20 +- .../postgres-database.resource.ts | 50 ++--- src/index.ts | 125 +++++++---- src/instances/authentik-server.ts | 7 + src/instances/certificate.ts | 8 + src/instances/cluster-issuer.ts | 12 + src/instances/custom-resource-definition.ts | 7 + src/instances/deployment.ts | 11 + src/instances/destination-rule.ts | 12 + src/instances/environment.ts | 7 + src/instances/gateway.ts | 8 + src/instances/git-repo.ts | 12 + src/instances/helm-release.ts | 12 + src/instances/helm-repo.ts | 16 ++ src/instances/namespace.ts | 11 + src/instances/postgres-cluster.ts | 7 + src/instances/secret.ts | 20 ++ src/instances/service.ts | 11 + src/instances/stateful-set.ts | 11 + src/instances/storageclass.ts | 7 + src/instances/virtual-service.ts | 12 + src/services/k8s/k8s.ts | 8 +- src/services/postgres/postgres.instance.ts | 4 +- src/services/resources/resources.instance.ts | 31 ++- src/services/resources/resources.resource.ts | 7 + src/services/resources/resources.ts | 10 + src/services/secrets/secrets.secret.ts | 4 +- src/storage-provider/storage-provider.ts | 158 +++++++++---- src/utils/consts.ts | 4 +- 59 files changed, 1855 insertions(+), 290 deletions(-) create mode 100644 chart/templates/_storageclass.yaml create mode 100644 manifests/environment.yaml create mode 100644 manifests/example-pvc.yaml create mode 100644 manifests/storageclass.yaml create mode 100644 src/bootstrap/bootstrap.ts create mode 100644 src/bootstrap/namespaces/namespaces.ts create mode 100644 src/bootstrap/releases/releases.ts create mode 100644 src/bootstrap/repos/repos.ts create mode 100644 src/bootstrap/resources/issuer.ts delete mode 100644 src/custom-resouces/authentik-connection/authentik-connection.resource.ts delete mode 100644 src/custom-resouces/authentik-connection/authentik-connection.schemas.ts delete mode 100644 src/custom-resouces/authentik-connection/authentik-connection.ts create mode 100644 src/custom-resouces/authentik-server/authentik-server.controller.ts create mode 100644 src/custom-resouces/authentik-server/authentik-server.schemas.ts create mode 100644 src/custom-resouces/authentik-server/authentik-server.ts create mode 100644 src/custom-resouces/environment/environment.controller.ts create mode 100644 src/custom-resouces/environment/environment.schemas.ts create mode 100644 src/custom-resouces/environment/environment.ts create mode 100644 src/custom-resouces/http-service/http-service.controller.ts create mode 100644 src/custom-resouces/http-service/http-service.schemas.ts create mode 100644 src/custom-resouces/http-service/http-service.ts create mode 100644 src/custom-resouces/postgres-cluster/postgres-cluster.controller.ts create mode 100644 src/custom-resouces/postgres-cluster/postgres-cluster.schemas.ts create mode 100644 src/custom-resouces/postgres-cluster/postgres-cluster.ts create mode 100644 src/instances/authentik-server.ts create mode 100644 src/instances/certificate.ts create mode 100644 src/instances/cluster-issuer.ts create mode 100644 src/instances/custom-resource-definition.ts create mode 100644 src/instances/deployment.ts create mode 100644 src/instances/destination-rule.ts create mode 100644 src/instances/environment.ts create mode 100644 src/instances/gateway.ts create mode 100644 src/instances/git-repo.ts create mode 100644 src/instances/helm-release.ts create mode 100644 src/instances/helm-repo.ts create mode 100644 src/instances/namespace.ts create mode 100644 src/instances/postgres-cluster.ts create mode 100644 src/instances/secret.ts create mode 100644 src/instances/service.ts create mode 100644 src/instances/stateful-set.ts create mode 100644 src/instances/storageclass.ts create mode 100644 src/instances/virtual-service.ts diff --git a/Makefile b/Makefile index 002258b..6801232 100644 --- a/Makefile +++ b/Makefile @@ -1,15 +1,14 @@ -.PHONY: setup dev-recreate dev-create dev-destroy - -setup: - ./scripts/setup-server.sh +.PHONY: dev-recreate dev-destroy server-install dev-destroy: colima delete -f -dev-create: - colima start --network-address --kubernetes -m 8 --mount ${PWD}/data:/data:w --k3s-arg="--disable=helm-controller,local-storage" +dev-recreate: dev-destroy + colima start --network-address --kubernetes -m 8 --k3s-arg="--disable=helm-controller,local-storage,traefik" # --mount ${PWD}/data:/data:w + flux install --components="source-controller,helm-controller" -dev-recreate: dev-destroy dev-create setup +setup-flux: + flux install --components="source-controller,helm-controller" server-install: curl -sfL https://get.k3s.io | sh -s - --disable traefik,local-storage,helm-controller \ No newline at end of file diff --git a/chart/templates/_storageclass.yaml b/chart/templates/_storageclass.yaml new file mode 100644 index 0000000..7aea96e --- /dev/null +++ b/chart/templates/_storageclass.yaml @@ -0,0 +1,12 @@ +apiVersion: storage.k8s.io/v1 +kind: StorageClass +metadata: + name: {{ include "homelab-operator.fullname" . }}-local-path + labels: + {{- include "homelab-operator.labels" . | nindent 4 }} +provisioner: reuse-local-path-provisioner +parameters: + # Add any provisioner-specific parameters here +reclaimPolicy: {{ .Values.storage.reclaimPolicy | default "Retain" }} +allowVolumeExpansion: {{ .Values.storage.allowVolumeExpansion | default false }} +volumeBindingMode: {{ .Values.storage.volumeBindingMode | default "WaitForFirstConsumer" }} diff --git a/chart/templates/clusterrole.yaml b/chart/templates/clusterrole.yaml index 271ae62..7e3eaad 100644 --- a/chart/templates/clusterrole.yaml +++ b/chart/templates/clusterrole.yaml @@ -6,6 +6,21 @@ rules: - apiGroups: [""] resources: ["secrets"] verbs: ["create", "get", "watch", "list"] +- apiGroups: [""] + resources: ["persistentvolumes"] + verbs: ["get", "list", "watch", "create", "delete", "patch", "update"] +- apiGroups: [""] + resources: ["persistentvolumeclaims"] + verbs: ["get", "list", "watch", "update", "patch"] +- apiGroups: [""] + resources: ["persistentvolumeclaims/status"] + verbs: ["update", "patch"] +- apiGroups: [""] + resources: ["events"] + verbs: ["create", "patch"] +- apiGroups: ["storage.k8s.io"] + resources: ["storageclasses"] + verbs: ["get", "list", "watch"] - apiGroups: ["*"] resources: ["*"] verbs: ["get", "watch", "list", "patch"] diff --git a/chart/values.yaml b/chart/values.yaml index 2c5b80d..7de2c4d 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -14,6 +14,9 @@ fullnameOverride: '' storage: path: /data/volumes + reclaimPolicy: Retain + allowVolumeExpansion: false + volumeBindingMode: WaitForFirstConsumer serviceAccount: # Specifies whether a service account should be created diff --git a/manifests/environment.yaml b/manifests/environment.yaml new file mode 100644 index 0000000..f56be43 --- /dev/null +++ b/manifests/environment.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: dev +--- +apiVersion: homelab.mortenolsen.pro/v1 +kind: Environment +metadata: + name: dev + namespace: dev +spec: + domain: dev.mortenolsen.pro \ No newline at end of file diff --git a/manifests/example-pvc.yaml b/manifests/example-pvc.yaml new file mode 100644 index 0000000..05ff08d --- /dev/null +++ b/manifests/example-pvc.yaml @@ -0,0 +1,39 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: example-pvc + namespace: default +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi + storageClassName: homelab-operator-local-path + +--- + +apiVersion: v1 +kind: Pod +metadata: + name: example-pod + namespace: default +spec: + containers: + - name: example-container + image: alpine + command: ["/bin/sh", "-c", "sleep infinity"] + volumeMounts: + - name: example-volume + mountPath: /data + resources: + limits: + memory: 100Mi + cpu: "0.1" + requests: + memory: 50Mi + cpu: "0.05" + volumes: + - name: example-volume + persistentVolumeClaim: + claimName: example-pvc \ No newline at end of file diff --git a/manifests/storageclass.yaml b/manifests/storageclass.yaml new file mode 100644 index 0000000..770ac3c --- /dev/null +++ b/manifests/storageclass.yaml @@ -0,0 +1,10 @@ +apiVersion: storage.k8s.io/v1 +kind: StorageClass +metadata: + name: homelab-operator-local-path +provisioner: homelab-operator-local-path +reclaimPolicy: Retain +allowVolumeExpansion: true +volumeBindingMode: Immediate +parameters: + hello: 'world' diff --git a/src/bootstrap/bootstrap.ts b/src/bootstrap/bootstrap.ts new file mode 100644 index 0000000..d67d11b --- /dev/null +++ b/src/bootstrap/bootstrap.ts @@ -0,0 +1,38 @@ +import type { Services } from '../utils/service.ts'; + +import { NamespaceService } from './namespaces/namespaces.ts'; +import { ReleaseService } from './releases/releases.ts'; +import { RepoService } from './repos/repos.ts'; +import { ClusterIssuerService } from './resources/issuer.ts'; + +class BootstrapService { + #services: Services; + + constructor(services: Services) { + this.#services = services; + } + public get namespaces() { + return this.#services.get(NamespaceService); + } + + public get repos() { + return this.#services.get(RepoService); + } + + public get releases() { + return this.#services.get(ReleaseService); + } + + public get clusterIssuer() { + return this.#services.get(ClusterIssuerService); + } + + public ensure = async () => { + await this.namespaces.ensure(); + await this.repos.ensure(); + await this.releases.ensure(); + await this.clusterIssuer.ensure(); + }; +} + +export { BootstrapService }; diff --git a/src/bootstrap/namespaces/namespaces.ts b/src/bootstrap/namespaces/namespaces.ts new file mode 100644 index 0000000..f4e48bb --- /dev/null +++ b/src/bootstrap/namespaces/namespaces.ts @@ -0,0 +1,64 @@ +import { NamespaceInstance } from '../../instances/namespace.ts'; +import type { Services } from '../../utils/service.ts'; +import { ResourceService } from '../../services/resources/resources.ts'; + +class NamespaceService { + #homelab: NamespaceInstance; + #istioSystem: NamespaceInstance; + #certManager: NamespaceInstance; + + constructor(services: Services) { + const resourceService = services.get(ResourceService); + this.#homelab = resourceService.getInstance( + { + apiVersion: 'v1', + kind: 'Namespace', + name: 'homelab', + }, + NamespaceInstance, + ); + this.#istioSystem = resourceService.getInstance( + { + apiVersion: 'v1', + kind: 'Namespace', + name: 'istio-system', + }, + NamespaceInstance, + ); + this.#certManager = resourceService.getInstance( + { + apiVersion: 'v1', + kind: 'Namespace', + name: 'cert-manager', + }, + NamespaceInstance, + ); + this.#homelab.on('changed', this.ensure); + this.#istioSystem.on('changed', this.ensure); + this.#certManager.on('changed', this.ensure); + } + + public get homelab() { + return this.#homelab; + } + public get istioSystem() { + return this.#istioSystem; + } + public get certManager() { + return this.#certManager; + } + + public ensure = async () => { + await this.#homelab.ensure({ + metadata: { + labels: { + 'istio-injection': 'enabled', + }, + }, + }); + await this.#istioSystem.ensure({}); + await this.#certManager.ensure({}); + }; +} + +export { NamespaceService }; diff --git a/src/bootstrap/releases/releases.ts b/src/bootstrap/releases/releases.ts new file mode 100644 index 0000000..70b7b6a --- /dev/null +++ b/src/bootstrap/releases/releases.ts @@ -0,0 +1,171 @@ +import { HelmReleaseInstance } from '../../instances/helm-release.ts'; +import { ResourceService } from '../../services/resources/resources.ts'; +import { NAMESPACE } from '../../utils/consts.ts'; +import { Services } from '../../utils/service.ts'; +import { NamespaceService } from '../namespaces/namespaces.ts'; +import { RepoService } from '../repos/repos.ts'; + +class ReleaseService { + #services: Services; + #certManager: HelmReleaseInstance; + #istioBase: HelmReleaseInstance; + #istiod: HelmReleaseInstance; + #istioGateway: HelmReleaseInstance; + + constructor(services: Services) { + this.#services = services; + const resourceService = services.get(ResourceService); + this.#certManager = resourceService.getInstance( + { + apiVersion: 'helm.toolkit.fluxcd.io/v2', + kind: 'HelmRelease', + name: 'cert-manager', + namespace: NAMESPACE, + }, + HelmReleaseInstance, + ); + this.#istioBase = resourceService.getInstance( + { + apiVersion: 'helm.toolkit.fluxcd.io/v2', + kind: 'HelmRelease', + name: 'istio-base', + namespace: NAMESPACE, + }, + HelmReleaseInstance, + ); + this.#istiod = resourceService.getInstance( + { + apiVersion: 'helm.toolkit.fluxcd.io/v2', + kind: 'HelmRelease', + name: 'istiod', + namespace: NAMESPACE, + }, + HelmReleaseInstance, + ); + this.#istioGateway = resourceService.getInstance( + { + apiVersion: 'helm.toolkit.fluxcd.io/v2', + kind: 'HelmRelease', + name: 'istio-gateway', + namespace: NAMESPACE, + }, + HelmReleaseInstance, + ); + this.#certManager.on('changed', this.ensure); + this.#istioBase.on('changed', this.ensure); + this.#istiod.on('changed', this.ensure); + this.#istioGateway.on('changed', this.ensure); + } + + public get certManager() { + return this.#certManager; + } + public get istioBase() { + return this.#istioBase; + } + public get istiod() { + return this.#istiod; + } + + public ensure = async () => { + const namespaceService = this.#services.get(NamespaceService); + const repoService = this.#services.get(RepoService); + await this.#certManager.ensure({ + spec: { + targetNamespace: namespaceService.certManager.name, + interval: '1h', + values: { + installCRDs: true, + }, + chart: { + spec: { + chart: 'cert-manager', + version: 'v1.18.2', + sourceRef: { + apiVersion: 'source.toolkit.fluxcd.io/v1', + kind: 'HelmRepository', + name: repoService.jetstack.name, + namespace: repoService.jetstack.namespace, + }, + }, + }, + }, + }); + await this.#istioBase.ensure({ + spec: { + targetNamespace: namespaceService.istioSystem.name, + interval: '1h', + values: { + defaultRevision: 'default', + profile: 'ambient', + }, + chart: { + spec: { + chart: 'base', + version: '1.24.3', + sourceRef: { + apiVersion: 'source.toolkit.fluxcd.io/v1', + kind: 'HelmRepository', + name: repoService.istio.name, + namespace: repoService.istio.namespace, + }, + }, + }, + }, + }); + await this.#istiod.ensure({ + spec: { + targetNamespace: namespaceService.istioSystem.name, + interval: '1h', + dependsOn: [ + { + name: this.#istioBase.name, + namespace: this.#istioBase.namespace, + }, + ], + chart: { + spec: { + chart: 'istiod', + version: '1.24.3', + sourceRef: { + apiVersion: 'source.toolkit.fluxcd.io/v1', + kind: 'HelmRepository', + name: repoService.istio.name, + namespace: repoService.istio.namespace, + }, + }, + }, + }, + }); + await this.#istioGateway.ensure({ + spec: { + targetNamespace: NAMESPACE, + interval: '1h', + dependsOn: [ + { + name: this.#istioBase.name, + namespace: this.#istioBase.namespace, + }, + { + name: this.#istiod.name, + namespace: this.#istiod.namespace, + }, + ], + chart: { + spec: { + chart: 'gateway', + version: '1.24.3', + sourceRef: { + apiVersion: 'source.toolkit.fluxcd.io/v1', + kind: 'HelmRepository', + name: repoService.istio.name, + namespace: repoService.istio.namespace, + }, + }, + }, + }, + }); + }; +} + +export { ReleaseService }; diff --git a/src/bootstrap/repos/repos.ts b/src/bootstrap/repos/repos.ts new file mode 100644 index 0000000..0a845a2 --- /dev/null +++ b/src/bootstrap/repos/repos.ts @@ -0,0 +1,112 @@ +import type { Services } from '../../utils/service.ts'; +import { ResourceService } from '../../services/resources/resources.ts'; +import { HelmRepoInstance } from '../../instances/helm-repo.ts'; +import { NAMESPACE } from '../../utils/consts.ts'; + +class RepoService { + #jetstack: HelmRepoInstance; + #istio: HelmRepoInstance; + #authentik: HelmRepoInstance; + #containerro: HelmRepoInstance; + + constructor(services: Services) { + const resourceService = services.get(ResourceService); + this.#jetstack = resourceService.getInstance( + { + apiVersion: 'source.toolkit.fluxcd.io/v1', + kind: 'HelmRepository', + name: 'jetstack', + namespace: NAMESPACE, + }, + HelmRepoInstance, + ); + this.#istio = resourceService.getInstance( + { + apiVersion: 'source.toolkit.fluxcd.io/v1', + kind: 'HelmRepository', + name: 'istio', + namespace: NAMESPACE, + }, + HelmRepoInstance, + ); + this.#authentik = resourceService.getInstance( + { + apiVersion: 'source.toolkit.fluxcd.io/v1', + kind: 'HelmRepository', + name: 'authentik', + namespace: NAMESPACE, + }, + HelmRepoInstance, + ); + this.#containerro = resourceService.getInstance( + { + apiVersion: 'source.toolkit.fluxcd.io/v1', + kind: 'HelmRepository', + name: 'containerro', + namespace: NAMESPACE, + }, + HelmRepoInstance, + ); + this.#jetstack.on('changed', this.ensure); + this.#istio.on('changed', this.ensure); + this.#authentik.on('changed', this.ensure); + this.#containerro.on('changed', this.ensure); + } + + public get jetstack() { + return this.#jetstack; + } + public get istio() { + return this.#istio; + } + public get authentik() { + return this.#authentik; + } + public get containerro() { + return this.#containerro; + } + + public ensure = async () => { + await this.#jetstack.ensure({ + metadata: { + name: 'jetstack', + }, + spec: { + interval: '1h', + url: 'https://charts.jetstack.io', + }, + }); + + await this.#istio.ensure({ + metadata: { + name: 'istio', + }, + spec: { + interval: '1h', + url: 'https://istio-release.storage.googleapis.com/charts', + }, + }); + + await this.#authentik.ensure({ + metadata: { + name: 'authentik', + }, + spec: { + interval: '1h', + url: 'https://charts.goauthentik.io', + }, + }); + + await this.#containerro.ensure({ + metadata: { + name: 'containerro', + }, + spec: { + interval: '1h', + url: 'https://charts.containeroo.ch', + }, + }); + }; +} + +export { RepoService }; diff --git a/src/bootstrap/resources/issuer.ts b/src/bootstrap/resources/issuer.ts new file mode 100644 index 0000000..ec7f1be --- /dev/null +++ b/src/bootstrap/resources/issuer.ts @@ -0,0 +1,64 @@ +import { ClusterIssuerInstance } from '../../instances/cluster-issuer.ts'; +import { CustomDefinitionInstance } from '../../instances/custom-resource-definition.ts'; +import { ResourceService } from '../../services/resources/resources.ts'; +import type { Services } from '../../utils/service.ts'; + +class ClusterIssuerService { + #clusterIssuerCrd: CustomDefinitionInstance; + #clusterIssuer: ClusterIssuerInstance; + + constructor(services: Services) { + const resourceService = services.get(ResourceService); + this.#clusterIssuerCrd = resourceService.getInstance( + { + apiVersion: 'v1', + kind: 'CustomResourceDefinition', + name: 'clusterissuers.cert-manager.io', + }, + CustomDefinitionInstance, + ); + this.#clusterIssuer = resourceService.getInstance( + { + apiVersion: 'v1', + kind: 'ClusterIssuer', + name: 'cluster-issuer', + }, + ClusterIssuerInstance, + ); + + this.#clusterIssuerCrd.on('changed', this.ensure); + this.#clusterIssuer.on('changed', this.ensure); + } + + public ensure = async () => { + if (!this.#clusterIssuerCrd.ready) { + return; + } + await this.#clusterIssuer.ensure({ + spec: { + acme: { + server: 'https://acme-v02.api.letsencrypt.org/directory', + email: 'admin@example.com', + privateKeySecretRef: { + name: 'cluster-issuer-key', + }, + solvers: [ + { + dns01: { + cloudflare: { + email: 'admin@example.com', + apiKeySecretRef: { + name: 'cloudflare-api-key', + key: 'api-key', + }, + }, + }, + }, + ], + }, + }, + }); + }; +} + +export { ClusterIssuerService }; diff --git a/src/custom-resouces/authentik-client/authentik-client.resource.ts b/src/custom-resouces/authentik-client/authentik-client.resource.ts index 41a51a8..d7c9bec 100644 --- a/src/custom-resouces/authentik-client/authentik-client.resource.ts +++ b/src/custom-resouces/authentik-client/authentik-client.resource.ts @@ -13,12 +13,9 @@ import { decodeSecret, encodeSecret } from '../../utils/secrets.ts'; import { CONTROLLED_LABEL } from '../../utils/consts.ts'; import { isDeepSubset } from '../../utils/objects.ts'; import { AuthentikService } from '../../services/authentik/authentik.service.ts'; +import { authentikServerSecretSchema } from '../authentik-server/authentik-server.schemas.ts'; -import { - authentikClientSecretSchema, - authentikClientServerSecretSchema, - type authentikClientSpecSchema, -} from './authentik-client.schemas.ts'; +import { authentikClientSecretSchema, type authentikClientSpecSchema } from './authentik-client.schemas.ts'; class AuthentikClientResource extends CustomResource { #serverSecret: ResourceReference; @@ -43,7 +40,7 @@ class AuthentikClientResource extends CustomResource { - const serverSecretNames = getWithNamespace(this.spec.secretRef, this.namespace); + const serverSecretNames = getWithNamespace(`${this.spec.server}-server`, this.namespace); const resourceService = this.services.get(ResourceService); this.#serverSecret.current = resourceService.get({ apiVersion: 'v1', @@ -62,7 +59,7 @@ class AuthentikClientResource extends CustomResource { - #name: ValueReference; - #url: ValueReference; - #token: ValueReference; - #secret: Resource; - - constructor(options: CustomResourceOptions) { - super(options); - const valueReferenceService = this.services.get(ValueReferenceService); - const resourceService = this.services.get(ResourceService); - - this.#name = valueReferenceService.get(this.namespace); - this.#url = valueReferenceService.get(this.namespace); - this.#token = valueReferenceService.get(this.namespace); - this.#secret = resourceService.get({ - apiVersion: 'v1', - kind: 'Secret', - name: `${this.name}-authentik-server`, - namespace: this.namespace, - }); - - this.#name.on('changed', this.queueReconcile); - this.#url.on('changed', this.queueReconcile); - this.#token.on('changed', this.queueReconcile); - this.#secret.on('changed', this.queueReconcile); - } - - #updateResources = () => { - this.#name.ref = this.spec.name; - this.#url.ref = this.spec.url; - this.#token.ref = this.spec.token; - }; - - public reconcile = async () => { - this.#updateResources(); - const name = this.#name.value; - const url = this.#url.value; - const token = this.#token.value; - if (!name) { - return await this.conditions.set('Ready', { - status: 'False', - reason: 'MissingName', - }); - } - if (!url) { - return await this.conditions.set('Ready', { - status: 'False', - reason: 'MissingUrl', - }); - } - if (!token) { - return await this.conditions.set('Ready', { - status: 'False', - reason: 'MissingToken', - }); - } - const values = { - name, - url, - token, - }; - const secretValue = decodeSecret(this.#secret.data); - if (!deepEqual(secretValue, values)) { - await this.#secret.patch({ - data: encodeSecret(values), - }); - return await this.conditions.set('Ready', { - status: 'False', - reason: 'UpdatingSecret', - }); - } - - return await this.conditions.set('Ready', { - status: 'True', - }); - }; -} - -export { AuthentikConnectionResource }; diff --git a/src/custom-resouces/authentik-connection/authentik-connection.schemas.ts b/src/custom-resouces/authentik-connection/authentik-connection.schemas.ts deleted file mode 100644 index 1e6a415..0000000 --- a/src/custom-resouces/authentik-connection/authentik-connection.schemas.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { z } from 'zod'; - -import { valueReferenceInfoSchema } from '../../services/value-reference/value-reference.instance.ts'; - -const authentikConnectionSpecSchema = z.object({ - name: valueReferenceInfoSchema, - url: valueReferenceInfoSchema, - token: valueReferenceInfoSchema, -}); - -export { authentikConnectionSpecSchema }; diff --git a/src/custom-resouces/authentik-connection/authentik-connection.ts b/src/custom-resouces/authentik-connection/authentik-connection.ts deleted file mode 100644 index e5f10c5..0000000 --- a/src/custom-resouces/authentik-connection/authentik-connection.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts'; -import { GROUP } from '../../utils/consts.ts'; - -import { AuthentikConnectionResource } from './authentik-connection.resource.ts'; -import { authentikConnectionSpecSchema } from './authentik-connection.schemas.ts'; - -const authentikConnectionDefinition = createCustomResourceDefinition({ - group: GROUP, - version: 'v1', - kind: 'AuthentikConnection', - names: { - plural: 'authentikconnections', - singular: 'authentikconnection', - }, - spec: authentikConnectionSpecSchema, - create: (options) => new AuthentikConnectionResource(options), -}); - -export { authentikConnectionDefinition }; diff --git a/src/custom-resouces/authentik-server/authentik-server.controller.ts b/src/custom-resouces/authentik-server/authentik-server.controller.ts new file mode 100644 index 0000000..1d42b90 --- /dev/null +++ b/src/custom-resouces/authentik-server/authentik-server.controller.ts @@ -0,0 +1,211 @@ +import type { V1Secret } from '@kubernetes/client-node'; + +import { RepoService } from '../../bootstrap/repos/repos.ts'; +import { HelmReleaseInstance } from '../../instances/helm-release.ts'; +import { SecretInstance } from '../../instances/secret.ts'; +import { + CustomResource, + type CustomResourceOptions, + type CustomResourceObject, +} from '../../services/custom-resources/custom-resources.custom-resource.ts'; +import { ResourceReference } from '../../services/resources/resources.ref.ts'; +import { ResourceService } from '../../services/resources/resources.ts'; +import type { EnsuredSecret } from '../../services/secrets/secrets.secret.ts'; +import { SecretService } from '../../services/secrets/secrets.ts'; +import { API_VERSION } from '../../utils/consts.ts'; +import { getWithNamespace } from '../../utils/naming.ts'; +import { decodeSecret, encodeSecret } from '../../utils/secrets.ts'; +import type { environmentSpecSchema } from '../environment/environment.schemas.ts'; + +import { authentikServerInitSecretSchema, type authentikServerSpecSchema } from './authentik-server.schemas.ts'; + +class AuthentikServerController extends CustomResource { + #environment: ResourceReference>; + #authentikInitSecret: EnsuredSecret; + #authentikSecret: SecretInstance; + #authentikRelease: HelmReleaseInstance; + #postgresSecret: ResourceReference; + + constructor(options: CustomResourceOptions) { + super(options); + const secretService = this.services.get(SecretService); + const resourceService = this.services.get(ResourceService); + + this.#environment = new ResourceReference(); + this.#authentikInitSecret = secretService.ensure({ + owner: [this.ref], + name: `${this.name}-init`, + namespace: this.namespace, + schema: authentikServerInitSecretSchema, + generator: () => ({ + AUTHENTIK_BOOTSTRAP_TOKEN: crypto.randomUUID(), + AUTHENTIK_BOOTSTRAP_PASSWORD: crypto.randomUUID(), + AUTHENTIK_BOOTSTRAP_EMAIL: 'admin@example.com', + AUTHENTIK_SECRET_KEY: crypto.randomUUID(), + }), + }); + this.#authentikSecret = resourceService.getInstance( + { + apiVersion: 'v1', + kind: 'Secret', + name: `${this.name}-server`, + namespace: this.namespace, + }, + SecretInstance, + ); + this.#authentikRelease = resourceService.getInstance( + { + apiVersion: 'helm.toolkit.fluxcd.io/v2', + kind: 'HelmRelease', + name: this.name, + namespace: this.namespace, + }, + HelmReleaseInstance, + ); + this.#postgresSecret = new ResourceReference(); + this.#authentikSecret.on('changed', this.queueReconcile); + this.#authentikInitSecret.resource.on('deleted', this.queueReconcile); + this.#environment.on('changed', this.queueReconcile); + this.#authentikRelease.on('changed', this.queueReconcile); + this.#postgresSecret.on('changed', this.queueReconcile); + } + + public reconcile = async () => { + if (!this.exists || this.metadata?.deletionTimestamp) { + return; + } + + if (!this.#authentikInitSecret.isValid) { + return; + } + + const resourceService = this.services.get(ResourceService); + const environmentNames = getWithNamespace(this.spec.environment, this.namespace); + + this.#environment.current = resourceService.get({ + apiVersion: API_VERSION, + kind: 'Environment', + name: environmentNames.name, + namespace: this.namespace, + }); + + const postgresNames = getWithNamespace(this.spec.postgresCluster, this.namespace); + this.#postgresSecret.current = resourceService.get({ + apiVersion: 'v1', + kind: 'Secret', + name: postgresNames.name, + namespace: postgresNames.namespace, + }); + + if (!this.#postgresSecret.current?.exists) { + return; + } + const postgresSecret = decodeSecret(this.#postgresSecret.current.data) || {}; + + if (!this.#environment.current?.exists) { + return; + } + + const domain = this.#environment.current.spec?.domain; + if (!domain) { + return; + } + + const secretData = { + url: `https://${this.spec.subdomain}.${domain}`, + host: `${this.name}.${this.namespace}.svc.cluster.local`, + token: this.#authentikInitSecret.value?.AUTHENTIK_BOOTSTRAP_TOKEN ?? '', + }; + + await this.#authentikSecret.ensure({ + metadata: { + ownerReferences: [this.ref], + }, + data: encodeSecret(secretData), + }); + + const repoService = this.services.get(RepoService); + + await this.#authentikRelease.ensure({ + metadata: { + ownerReferences: [this.ref], + }, + spec: { + interval: '60m', + chart: { + spec: { + chart: 'authentik', + version: '2025.6.4', + sourceRef: { + apiVersion: 'source.toolkit.fluxcd.io/v1', + kind: 'HelmRepository', + name: repoService.authentik.name, + namespace: repoService.authentik.namespace, + }, + }, + }, + values: { + global: { + envFrom: [ + { + secretRef: { + name: this.#authentikInitSecret.name, + }, + }, + ], + }, + authentik: { + error_reporting: { + enabled: false, + }, + postgresql: { + host: postgresSecret.host, + name: postgresSecret.database, + user: postgresSecret.username, + password: 'file:///postgres-creds/password', + }, + redis: { + host: `redis.${this.namespace}.svc.cluster.local`, + }, + }, + server: { + volumes: [ + { + name: 'postgres-creds', + secret: { + secretName: this.#postgresSecret.current.name, + }, + }, + ], + volumeMounts: [ + { + name: 'postgres-creds', + mountPath: '/postgres-creds', + readOnly: true, + }, + ], + }, + worker: { + volumes: [ + { + name: 'postgres-creds', + secret: { + secretName: this.#postgresSecret.current.name, + }, + }, + ], + volumeMounts: [ + { + name: 'postgres-creds', + mountPath: '/postgres-creds', + readOnly: true, + }, + ], + }, + }, + }, + }); + }; +} + +export { AuthentikServerController }; diff --git a/src/custom-resouces/authentik-server/authentik-server.schemas.ts b/src/custom-resouces/authentik-server/authentik-server.schemas.ts new file mode 100644 index 0000000..55d9af0 --- /dev/null +++ b/src/custom-resouces/authentik-server/authentik-server.schemas.ts @@ -0,0 +1,22 @@ +import { z } from 'zod'; + +const authentikServerSpecSchema = z.object({ + postgresCluster: z.string(), + environment: z.string(), + subdomain: z.string(), +}); + +const authentikServerInitSecretSchema = z.object({ + AUTHENTIK_BOOTSTRAP_TOKEN: z.string(), + AUTHENTIK_BOOTSTRAP_PASSWORD: z.string(), + AUTHENTIK_BOOTSTRAP_EMAIL: z.string(), + AUTHENTIK_SECRET_KEY: z.string(), +}); + +const authentikServerSecretSchema = z.object({ + url: z.string(), + host: z.string(), + token: z.string(), +}); + +export { authentikServerSpecSchema, authentikServerInitSecretSchema, authentikServerSecretSchema }; diff --git a/src/custom-resouces/authentik-server/authentik-server.ts b/src/custom-resouces/authentik-server/authentik-server.ts new file mode 100644 index 0000000..d1ac8b7 --- /dev/null +++ b/src/custom-resouces/authentik-server/authentik-server.ts @@ -0,0 +1,19 @@ +import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts'; +import { GROUP } from '../../utils/consts.ts'; + +import { authentikServerSpecSchema } from './authentik-server.schemas.ts'; +import { AuthentikServerController } from './authentik-server.controller.ts'; + +const authentikServerDefinition = createCustomResourceDefinition({ + group: GROUP, + version: 'v1', + kind: 'AuthentikServer', + names: { + plural: 'authentikservers', + singular: 'authentikserver', + }, + spec: authentikServerSpecSchema, + create: (options) => new AuthentikServerController(options), +}); + +export { authentikServerDefinition }; diff --git a/src/custom-resouces/custom-resources.ts b/src/custom-resouces/custom-resources.ts index 906e224..5276148 100644 --- a/src/custom-resouces/custom-resources.ts +++ b/src/custom-resouces/custom-resources.ts @@ -1,13 +1,19 @@ import { authentikClientDefinition } from './authentik-client/authentik-client.ts'; -import { authentikConnectionDefinition } from './authentik-connection/authentik-connection.ts'; +import { authentikServerDefinition } from './authentik-server/authentik-server.ts'; +import { environmentDefinition } from './environment/environment.ts'; import { generateSecretDefinition } from './generate-secret/generate-secret.ts'; +import { httpServiceDefinition } from './http-service/http-service.ts'; +import { postgresClusterDefinition } from './postgres-cluster/postgres-cluster.ts'; import { postgresDatabaseDefinition } from './postgres-database/postgres-database.ts'; const customResources = [ postgresDatabaseDefinition, authentikClientDefinition, generateSecretDefinition, - authentikConnectionDefinition, + environmentDefinition, + postgresClusterDefinition, + authentikServerDefinition, + httpServiceDefinition, ]; export { customResources }; diff --git a/src/custom-resouces/environment/environment.controller.ts b/src/custom-resouces/environment/environment.controller.ts new file mode 100644 index 0000000..d42e944 --- /dev/null +++ b/src/custom-resouces/environment/environment.controller.ts @@ -0,0 +1,206 @@ +import { CertificateInstance } from '../../instances/certificate.ts'; +import { CustomDefinitionInstance } from '../../instances/custom-resource-definition.ts'; +import { NamespaceInstance } from '../../instances/namespace.ts'; +import { + CustomResource, + type CustomResourceOptions, +} from '../../services/custom-resources/custom-resources.custom-resource.ts'; +import { ResourceService } from '../../services/resources/resources.ts'; +import { GatewayInstance } from '../../instances/gateway.ts'; +import { PostgresClusterInstance } from '../../instances/postgres-cluster.ts'; +import { API_VERSION } from '../../utils/consts.ts'; +import { AuthentikServerInstance } from '../../instances/authentik-server.ts'; +import { StorageClassInstance } from '../../instances/storageclass.ts'; +import { PROVISIONER } from '../../storage-provider/storage-provider.ts'; + +import type { environmentSpecSchema } from './environment.schemas.ts'; + +class EnvironmentController extends CustomResource { + #namespace: NamespaceInstance; + #certificateCrd: CustomDefinitionInstance; + #certificate: CertificateInstance; + #gatewayCrd: CustomDefinitionInstance; + #gateway: GatewayInstance; + #storageClass: StorageClassInstance; + #postgresCluster: PostgresClusterInstance; + #authentikServer: AuthentikServerInstance; + + constructor(options: CustomResourceOptions) { + super(options); + const resourceService = this.services.get(ResourceService); + this.#namespace = resourceService.getInstance( + { + apiVersion: 'v1', + kind: 'Namespace', + name: this.namespace, + }, + NamespaceInstance, + ); + this.#certificateCrd = resourceService.getInstance( + { + apiVersion: 'apiextensions.k8s.io/v1', + kind: 'CustomResourceDefinition', + name: 'certificates.cert-manager.io', + }, + CustomDefinitionInstance, + ); + this.#certificate = resourceService.getInstance( + { + apiVersion: 'cert-manager.io/v1', + kind: 'Certificate', + name: this.name, + namespace: this.namespace, + }, + CertificateInstance, + ); + this.#gatewayCrd = resourceService.getInstance( + { + apiVersion: 'apiextensions.k8s.io/v1', + kind: 'CustomResourceDefinition', + name: 'gateways.networking.istio.io', + }, + CustomDefinitionInstance, + ); + this.#gateway = resourceService.getInstance( + { + apiVersion: 'networking.istio.io/v1', + kind: 'Gateway', + name: this.name, + namespace: this.namespace, + }, + GatewayInstance, + ); + this.#storageClass = resourceService.getInstance( + { + apiVersion: 'storage.k8s.io/v1', + kind: 'StorageClass', + name: `${this.name}-retain`, + }, + StorageClassInstance, + ); + this.#postgresCluster = resourceService.getInstance( + { + apiVersion: API_VERSION, + kind: 'PostgresCluster', + name: `${this.name}-postgres-cluster`, + namespace: this.namespace, + }, + PostgresClusterInstance, + ); + this.#authentikServer = resourceService.getInstance( + { + apiVersion: API_VERSION, + kind: 'AuthentikServer', + name: `${this.name}-authentik-server`, + namespace: this.namespace, + }, + AuthentikServerInstance, + ); + this.#gatewayCrd.on('changed', this.queueReconcile); + this.#gateway.on('changed', this.queueReconcile); + this.#certificateCrd.on('changed', this.queueReconcile); + this.#namespace.on('changed', this.queueReconcile); + this.#certificate.on('changed', this.queueReconcile); + this.#postgresCluster.on('changed', this.queueReconcile); + this.#authentikServer.on('changed', this.queueReconcile); + this.#storageClass.on('changed', this.queueReconcile); + } + + public reconcile = async () => { + if (!this.exists || this.metadata?.deletionTimestamp) { + return; + } + await this.#namespace.ensure({ + metadata: { + ownerReferences: [this.ref], + labels: { + 'istio-injection': 'enabled', + }, + }, + }); + if (this.#certificateCrd.ready) { + await this.#certificate.ensure({ + metadata: { + ownerReferences: [this.ref], + }, + spec: { + secretName: `${this.name}-tls`, + issuerRef: { + name: 'cluster-issuer', + kind: 'ClusterIssuer', + }, + dnsNames: [`*.${this.spec.domain}`], + privateKey: { + rotationPolicy: 'Always', + }, + }, + }); + } + if (this.#gatewayCrd.ready) { + await this.#gateway.ensure({ + metadata: { + ownerReferences: [this.ref], + }, + spec: { + selector: { + istio: 'gateway', + }, + servers: [ + { + hosts: [`*.${this.spec.domain}`], + port: { + name: 'http', + number: 80, + protocol: 'HTTP', + }, + tls: { + httpsRedirect: true, + }, + }, + { + hosts: [`*.${this.spec.domain}`], + port: { + name: 'https', + number: 443, + protocol: 'HTTPS', + }, + tls: { + mode: 'SIMPLE', + credentialName: `${this.name}-tls`, + }, + }, + ], + }, + }); + await this.#storageClass.ensure({ + provisioner: PROVISIONER, + parameters: { + storageLocation: this.spec.storage?.location || `/data/volumes/${this.name}`, + reclaimPolicy: 'Retain', + allowVolumeExpansion: 'true', + volumeBindingMode: 'Immediate', + }, + }); + await this.#postgresCluster.ensure({ + metadata: { + ownerReferences: [this.ref], + }, + spec: { + environment: this.name, + }, + }); + await this.#authentikServer.ensure({ + metadata: { + ownerReferences: [this.ref], + }, + spec: { + environment: `${this.namespace}/${this.name}`, + subdomain: 'authentik', + postgresCluster: `${this.name}-postgres-cluster`, + }, + }); + } + }; +} + +export { EnvironmentController }; diff --git a/src/custom-resouces/environment/environment.schemas.ts b/src/custom-resouces/environment/environment.schemas.ts new file mode 100644 index 0000000..b885003 --- /dev/null +++ b/src/custom-resouces/environment/environment.schemas.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +const environmentSpecSchema = z.object({ + domain: z.string(), + storage: z + .object({ + location: z.string().optional(), + }) + .optional(), +}); + +type EnvironmentSpec = z.infer; + +export { environmentSpecSchema, type EnvironmentSpec }; diff --git a/src/custom-resouces/environment/environment.ts b/src/custom-resouces/environment/environment.ts new file mode 100644 index 0000000..040735f --- /dev/null +++ b/src/custom-resouces/environment/environment.ts @@ -0,0 +1,19 @@ +import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts'; +import { GROUP } from '../../utils/consts.ts'; + +import { EnvironmentController } from './environment.controller.ts'; +import { environmentSpecSchema } from './environment.schemas.ts'; + +const environmentDefinition = createCustomResourceDefinition({ + group: GROUP, + version: 'v1', + kind: 'Environment', + names: { + plural: 'environments', + singular: 'environment', + }, + spec: environmentSpecSchema, + create: (options) => new EnvironmentController(options), +}); + +export { environmentDefinition }; diff --git a/src/custom-resouces/http-service/http-service.controller.ts b/src/custom-resouces/http-service/http-service.controller.ts new file mode 100644 index 0000000..45a3fcd --- /dev/null +++ b/src/custom-resouces/http-service/http-service.controller.ts @@ -0,0 +1,100 @@ +import { DestinationRuleInstance } from '../../instances/destination-rule.ts'; +import { VirtualServiceInstance } from '../../instances/virtual-service.ts'; +import { + CustomResource, + type CustomResourceObject, + type CustomResourceOptions, +} from '../../services/custom-resources/custom-resources.custom-resource.ts'; +import { ResourceReference, ResourceService } from '../../services/resources/resources.ts'; +import { API_VERSION } from '../../utils/consts.ts'; +import { getWithNamespace } from '../../utils/naming.ts'; +import { environmentSpecSchema } from '../environment/environment.schemas.ts'; + +import { httpServiceSpecSchema } from './http-service.schemas.ts'; + +class HttpServiceController extends CustomResource { + #environment: ResourceReference>; + #virtualService: VirtualServiceInstance; + #destinationRule: DestinationRuleInstance; + + constructor(options: CustomResourceOptions) { + super(options); + const resourceService = this.services.get(ResourceService); + this.#environment = new ResourceReference(); + this.#virtualService = resourceService.getInstance( + { + apiVersion: 'networking.istio.io/v1beta1', + kind: 'VirtualService', + name: `${this.name}-virtual-service`, + namespace: this.namespace, + }, + VirtualServiceInstance, + ); + this.#destinationRule = resourceService.getInstance( + { + apiVersion: 'networking.istio.io/v1beta1', + kind: 'DestinationRule', + name: `${this.name}-destination-rule`, + namespace: this.namespace, + }, + DestinationRuleInstance, + ); + this.#destinationRule.on('changed', this.queueReconcile); + this.#virtualService.on('changed', this.queueReconcile); + this.#environment.on('changed', this.queueReconcile); + } + + public reconcile = async () => { + if (!this.exists || this.metadata?.deletionTimestamp) { + return; + } + const resourceService = this.services.get(ResourceService); + const environmentNames = getWithNamespace(this.spec.environment, this.namespace); + this.#environment.current = resourceService.get({ + apiVersion: API_VERSION, + kind: 'Environment', + name: environmentNames.name, + namespace: environmentNames.namespace, + }); + const environment = this.#environment.current; + if (!environment?.exists) { + return; + } + await this.#virtualService.ensure({ + metadata: { + ownerReferences: [this.ref], + }, + spec: { + hosts: [`${this.spec.subdomain}.${environment.spec?.domain}`], + gateways: [`${this.#environment.current.namespace}/gateway`], + http: [ + { + route: [ + { + destination: { + host: this.spec.destination.host, + port: this.spec.destination.port, + }, + }, + ], + }, + ], + }, + }); + await this.#destinationRule.ensure({ + metadata: { + ownerReferences: [this.ref], + }, + spec: { + host: this.spec.destination.host, + trafficPolicy: { + tls: { + mode: 'DISABLE', + }, + }, + }, + }); + }; +} + +export { HttpServiceController }; diff --git a/src/custom-resouces/http-service/http-service.schemas.ts b/src/custom-resouces/http-service/http-service.schemas.ts new file mode 100644 index 0000000..e91daba --- /dev/null +++ b/src/custom-resouces/http-service/http-service.schemas.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; + +const httpServiceSpecSchema = z.object({ + environment: z.string(), + subdomain: z.string(), + destination: z.object({ + host: z.string(), + port: z + .object({ + number: z.number().optional(), + protocol: z.enum(['http', 'https']).optional(), + name: z.string().optional(), + }) + .optional(), + }), +}); + +export { httpServiceSpecSchema }; diff --git a/src/custom-resouces/http-service/http-service.ts b/src/custom-resouces/http-service/http-service.ts new file mode 100644 index 0000000..0d6a57a --- /dev/null +++ b/src/custom-resouces/http-service/http-service.ts @@ -0,0 +1,19 @@ +import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts'; +import { GROUP } from '../../utils/consts.ts'; + +import { HttpServiceController } from './http-service.controller.ts'; +import { httpServiceSpecSchema } from './http-service.schemas.ts'; + +const httpServiceDefinition = createCustomResourceDefinition({ + group: GROUP, + version: 'v1', + kind: 'HttpService', + names: { + plural: 'httpservices', + singular: 'httpservice', + }, + spec: httpServiceSpecSchema, + create: (options) => new HttpServiceController(options), +}); + +export { httpServiceDefinition }; diff --git a/src/custom-resouces/postgres-cluster/postgres-cluster.controller.ts b/src/custom-resouces/postgres-cluster/postgres-cluster.controller.ts new file mode 100644 index 0000000..fa21406 --- /dev/null +++ b/src/custom-resouces/postgres-cluster/postgres-cluster.controller.ts @@ -0,0 +1,155 @@ +import { ServiceInstance } from '../../instances/service.ts'; +import { StatefulSetInstance } from '../../instances/stateful-set.ts'; +import { ResourceService } from '../../services/resources/resources.ts'; +import { + CustomResource, + type CustomResourceOptions, +} from '../../services/custom-resources/custom-resources.custom-resource.ts'; +import type { EnsuredSecret } from '../../services/secrets/secrets.secret.ts'; +import { SecretService } from '../../services/secrets/secrets.ts'; + +import { postgresClusterSecretSchema, type postgresClusterSpecSchema } from './postgres-cluster.schemas.ts'; + +class PostgresClusterController extends CustomResource { + #statefulSet: StatefulSetInstance; + #headlessService: ServiceInstance; + #service: ServiceInstance; + #secret: EnsuredSecret; + + constructor(options: CustomResourceOptions) { + super(options); + const resourceService = this.services.get(ResourceService); + const secretService = this.services.get(SecretService); + this.#statefulSet = resourceService.getInstance( + { + apiVersion: 'apps/v1', + kind: 'StatefulSet', + name: this.name, + namespace: this.namespace, + }, + StatefulSetInstance, + ); + this.#headlessService = resourceService.getInstance( + { + apiVersion: 'v1', + kind: 'Service', + name: `${this.name}-headless`, + namespace: this.namespace, + }, + ServiceInstance, + ); + this.#service = resourceService.getInstance( + { + apiVersion: 'v1', + kind: 'Service', + name: this.name, + namespace: this.namespace, + }, + ServiceInstance, + ); + this.#secret = secretService.ensure({ + name: this.name, + namespace: this.namespace, + schema: postgresClusterSecretSchema, + generator: () => { + return { + database: 'postgres', + host: `${this.name}.${this.namespace}.svc.cluster.local`, + port: '5432', + username: 'postgres', + password: crypto.randomUUID(), + }; + }, + }); + this.#statefulSet.on('changed', this.queueReconcile); + this.#service.on('changed', this.queueReconcile); + this.#headlessService.on('changed', this.queueReconcile); + this.#secret.resource.on('changed', this.queueReconcile); + } + + public reconcile = async () => { + if (!this.exists || this.metadata?.deletionTimestamp || !this.#secret.isValid) { + return; + } + await this.#headlessService.ensure({ + metadata: { + ownerReferences: [this.ref], + }, + spec: { + clusterIP: 'None', + selector: { + app: this.name, + }, + ports: [{ name: 'postgres', port: 5432, targetPort: 5432 }], + }, + }); + await this.#statefulSet.ensure({ + metadata: { + ownerReferences: [this.ref], + }, + spec: { + replicas: 1, + serviceName: this.name, + selector: { + matchLabels: { + app: this.name, + }, + }, + template: { + metadata: { + labels: { + app: this.name, + }, + }, + spec: { + containers: [ + { + name: this.name, + image: 'postgres:17', + ports: [{ containerPort: 5432, name: 'postgres' }], + env: [ + { name: 'POSTGRES_PASSWORD', valueFrom: { secretKeyRef: { name: this.name, key: 'password' } } }, + { name: 'POSTGRES_USER', valueFrom: { secretKeyRef: { name: this.name, key: 'username' } } }, + { name: 'POSTGRES_DB', value: this.name }, + { name: 'PGDATA', value: '/var/lib/postgresql/data/pgdata' }, + ], + volumeMounts: [{ name: this.name, mountPath: '/var/lib/postgresql/data' }], + }, + ], + }, + }, + volumeClaimTemplates: [ + { + metadata: { + name: this.name, + }, + spec: { + accessModes: ['ReadWriteOnce'], + storageClassName: `${this.spec.environment}-retain`, + resources: { + requests: { + storage: this.spec.storage?.size || '1Gi', + }, + }, + }, + }, + ], + }, + }); + + await this.#service.ensure({ + metadata: { + ownerReferences: [this.ref], + }, + spec: { + type: 'ClusterIP', + selector: { + app: this.name, + }, + ports: [{ name: 'postgres', port: 5432, targetPort: 5432 }], + }, + }); + }; +} + +export { PostgresClusterController }; diff --git a/src/custom-resouces/postgres-cluster/postgres-cluster.schemas.ts b/src/custom-resouces/postgres-cluster/postgres-cluster.schemas.ts new file mode 100644 index 0000000..be6f5da --- /dev/null +++ b/src/custom-resouces/postgres-cluster/postgres-cluster.schemas.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; + +const postgresClusterSpecSchema = z.object({ + environment: z.string(), + storage: z + .object({ + size: z.string().optional(), + }) + .optional(), +}); + +const postgresClusterSecretSchema = z.object({ + database: z.string(), + host: z.string(), + port: z.string(), + username: z.string(), + password: z.string(), +}); + +export { postgresClusterSpecSchema, postgresClusterSecretSchema }; diff --git a/src/custom-resouces/postgres-cluster/postgres-cluster.ts b/src/custom-resouces/postgres-cluster/postgres-cluster.ts new file mode 100644 index 0000000..77af41c --- /dev/null +++ b/src/custom-resouces/postgres-cluster/postgres-cluster.ts @@ -0,0 +1,19 @@ +import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts'; +import { GROUP } from '../../utils/consts.ts'; + +import { PostgresClusterController } from './postgres-cluster.controller.ts'; +import { postgresClusterSpecSchema } from './postgres-cluster.schemas.ts'; + +const postgresClusterDefinition = createCustomResourceDefinition({ + group: GROUP, + version: 'v1', + kind: 'PostgresCluster', + names: { + plural: 'postgres-clusters', + singular: 'postgres-cluster', + }, + spec: postgresClusterSpecSchema, + create: (options) => new PostgresClusterController(options), +}); + +export { postgresClusterDefinition }; diff --git a/src/custom-resouces/postgres-database/portgres-database.schemas.ts b/src/custom-resouces/postgres-database/portgres-database.schemas.ts index 9b1f1e0..11eab8f 100644 --- a/src/custom-resouces/postgres-database/portgres-database.schemas.ts +++ b/src/custom-resouces/postgres-database/portgres-database.schemas.ts @@ -1,23 +1,7 @@ import { z } from 'zod'; const postgresDatabaseSpecSchema = z.object({ - secretRef: z.string(), + cluster: z.string(), }); -const postgresDatabaseSecretSchema = z.object({ - host: z.string(), - port: z.string(), - user: z.string(), - password: z.string(), - database: z.string().optional(), -}); - -const postgresDatabaseConnectionSecretSchema = z.object({ - host: z.string(), - port: z.string(), - user: z.string(), - password: z.string(), - database: z.string(), -}); - -export { postgresDatabaseSpecSchema, postgresDatabaseSecretSchema, postgresDatabaseConnectionSecretSchema }; +export { postgresDatabaseSpecSchema }; diff --git a/src/custom-resouces/postgres-database/postgres-database.resource.ts b/src/custom-resouces/postgres-database/postgres-database.resource.ts index 75e6309..6c2047b 100644 --- a/src/custom-resouces/postgres-database/postgres-database.resource.ts +++ b/src/custom-resouces/postgres-database/postgres-database.resource.ts @@ -1,4 +1,3 @@ -import { z } from 'zod'; import type { V1Secret } from '@kubernetes/client-node'; import { @@ -12,42 +11,31 @@ import { Resource, ResourceService } from '../../services/resources/resources.ts import { getWithNamespace } from '../../utils/naming.ts'; import { decodeSecret, encodeSecret } from '../../utils/secrets.ts'; import { isDeepSubset } from '../../utils/objects.ts'; +import { postgresClusterSecretSchema } from '../postgres-cluster/postgres-cluster.schemas.ts'; -import { - postgresDatabaseConnectionSecretSchema, - postgresDatabaseSecretSchema, - type postgresDatabaseSpecSchema, -} from './portgres-database.schemas.ts'; +import { type postgresDatabaseSpecSchema } from './portgres-database.schemas.ts'; const SECRET_READY_CONDITION = 'Secret'; const DATABASE_READY_CONDITION = 'Database'; -const secretDataSchema = z.object({ - host: z.string(), - port: z.string().optional(), - database: z.string(), - user: z.string(), - password: z.string(), -}); - class PostgresDatabaseResource extends CustomResource { - #serverSecret: ResourceReference; + #clusterSecret: ResourceReference; #databaseSecret: Resource; constructor(options: CustomResourceOptions) { super(options); - this.#serverSecret = new ResourceReference(); + this.#clusterSecret = new ResourceReference(); const resourceService = this.services.get(ResourceService); this.#databaseSecret = resourceService.get({ apiVersion: 'v1', kind: 'Secret', - name: `${this.name}-connection`, + name: `${this.name}-postgres-database`, namespace: this.namespace, }); this.#updateSecret(); - this.#serverSecret.on('changed', this.queueReconcile); + this.#clusterSecret.on('changed', this.queueReconcile); } get #dbName() { @@ -60,17 +48,17 @@ class PostgresDatabaseResource extends CustomResource { const resourceService = this.services.get(ResourceService); - const secretNames = getWithNamespace(this.spec.secretRef, this.namespace); - this.#serverSecret.current = resourceService.get({ + const secretNames = getWithNamespace(this.spec.cluster, this.namespace); + this.#clusterSecret.current = resourceService.get({ apiVersion: 'v1', kind: 'Secret', - name: secretNames.name, + name: `${secretNames.name}-postgres-cluster`, namespace: secretNames.namespace, }); }; #reconcileSecret = async (): Promise => { - const serverSecret = this.#serverSecret.current; + const serverSecret = this.#clusterSecret.current; const databaseSecret = this.#databaseSecret; if (!serverSecret?.exists || !serverSecret.data) { @@ -80,7 +68,7 @@ class PostgresDatabaseResource extends CustomResource => { - const connectionSecret = this.#serverSecret.current; - if (!connectionSecret?.exists || !connectionSecret.data) { + const clusterSecret = this.#clusterSecret.current; + if (!clusterSecret?.exists || !clusterSecret.data) { return { ready: false, failed: true, @@ -124,7 +112,7 @@ class PostgresDatabaseResource extends CustomResource { - console.log('UNCAUGHT EXCEPTION'); - if (error instanceof ApiException) { - return console.error(error.body); - } - console.error(error); - process.exit(1); -}); - -process.on('unhandledRejection', (error) => { - console.log('UNHANDLED REJECTION'); - if (error instanceof Error) { - console.error(error.stack); - } - if (error instanceof ApiException) { - return console.error(error.body); - } - console.error(error); - process.exit(1); -}); +import { Services } from './utils/service.ts'; const services = new Services(); + const watcherService = services.get(WatcherService); -const storageProvider = services.get(StorageProvider); -await storageProvider.start(); +await watcherService.watchCustomGroup('source.toolkit.fluxcd.io', 'v1', ['helmrepositories', 'gitrepositories']); +await watcherService.watchCustomGroup('helm.toolkit.fluxcd.io', 'v2', ['helmreleases']); +await watcherService.watchCustomGroup('cert-manager.io', 'v1', ['certificates']); +await watcherService.watchCustomGroup('networking.k8s.io', 'v1', ['gateways', 'virtualservices']); + +await watcherService + .create({ + path: '/api/v1/namespaces', + list: async (k8s) => { + return await k8s.api.listNamespace(); + }, + verbs: ['add', 'update', 'delete'], + transform: (manifest) => ({ + apiVersion: 'v1', + kind: 'Namespace', + ...manifest, + }), + }) + .start(); + +await watcherService + .create({ + path: '/api/v1/secrets', + list: async (k8s) => { + return await k8s.api.listSecretForAllNamespaces(); + }, + verbs: ['add', 'update', 'delete'], + transform: (manifest) => ({ + apiVersion: 'v1', + kind: 'Secret', + ...manifest, + }), + }) + .start(); + +await watcherService + .create({ + path: '/apis/apps/v1/statefulsets', + list: async (k8s) => { + return await k8s.apps.listStatefulSetForAllNamespaces({}); + }, + verbs: ['add', 'update', 'delete'], + transform: (manifest) => ({ + apiVersion: 'apps/v1', + kind: 'StatefulSet', + ...manifest, + }), + }) + .start(); + +await watcherService + .create({ + path: '/apis/apps/v1/deployments', + list: async (k8s) => { + return await k8s.apps.listDeploymentForAllNamespaces({}); + }, + verbs: ['add', 'update', 'delete'], + transform: (manifest) => ({ + apiVersion: 'apps/v1', + kind: 'Deployment', + ...manifest, + }), + }) + .start(); + await watcherService .create({ path: '/apis/apiextensions.k8s.io/v1/customresourcedefinitions', @@ -48,33 +89,25 @@ await watcherService .start(); await watcherService .create({ - path: '/api/v1/secrets', + path: '/apis/storage.k8s.io/v1/storageclasses', list: async (k8s) => { - return await k8s.api.listSecretForAllNamespaces(); + return await k8s.storageApi.listStorageClass(); }, verbs: ['add', 'update', 'delete'], transform: (manifest) => ({ - apiVersion: 'v1', - kind: 'Secret', - ...manifest, - }), - }) - .start(); -await watcherService - .create({ - path: '/apis/apps/v1/deployments', - list: async (k8s) => { - return await k8s.apps.listDeploymentForAllNamespaces({}); - }, - verbs: ['add', 'update', 'delete'], - transform: (manifest) => ({ - apiVersion: 'apps/v1', - kind: 'Deployment', + apiVersion: 'storage.k8s.io/v1', + kind: 'StorageClass', ...manifest, }), }) .start(); +const storageProvider = services.get(StorageProvider); +await storageProvider.start(); + +const bootstrap = services.get(BootstrapService); +await bootstrap.ensure(); + const customResourceService = services.get(CustomResourceService); customResourceService.register(...customResources); diff --git a/src/instances/authentik-server.ts b/src/instances/authentik-server.ts new file mode 100644 index 0000000..faef10c --- /dev/null +++ b/src/instances/authentik-server.ts @@ -0,0 +1,7 @@ +import type { authentikServerSpecSchema } from '../custom-resouces/authentik-server/authentik-server.schemas.ts'; +import type { CustomResourceObject } from '../services/custom-resources/custom-resources.custom-resource.ts'; +import { ResourceInstance } from '../services/resources/resources.instance.ts'; + +class AuthentikServerInstance extends ResourceInstance> {} + +export { AuthentikServerInstance }; diff --git a/src/instances/certificate.ts b/src/instances/certificate.ts new file mode 100644 index 0000000..bae02a5 --- /dev/null +++ b/src/instances/certificate.ts @@ -0,0 +1,8 @@ +import type { KubernetesObject } from '@kubernetes/client-node'; + +import { ResourceInstance } from '../services/resources/resources.instance.ts'; +import type { K8SCertificateV1 } from '../__generated__/resources/K8SCertificateV1.ts'; + +class CertificateInstance extends ResourceInstance {} + +export { CertificateInstance }; diff --git a/src/instances/cluster-issuer.ts b/src/instances/cluster-issuer.ts new file mode 100644 index 0000000..34bcb7f --- /dev/null +++ b/src/instances/cluster-issuer.ts @@ -0,0 +1,12 @@ +import type { KubernetesObject } from '@kubernetes/client-node'; + +import type { K8SClusterIssuerV1 } from '../__generated__/resources/K8SClusterIssuerV1.ts'; +import { ResourceInstance } from '../services/resources/resources.instance.ts'; + +class ClusterIssuerInstance extends ResourceInstance { + public get ready() { + return this.exists; + } +} + +export { ClusterIssuerInstance }; diff --git a/src/instances/custom-resource-definition.ts b/src/instances/custom-resource-definition.ts new file mode 100644 index 0000000..f0d098b --- /dev/null +++ b/src/instances/custom-resource-definition.ts @@ -0,0 +1,7 @@ +import type { V1CustomResourceDefinition } from '@kubernetes/client-node'; + +import { ResourceInstance } from '../services/resources/resources.instance.ts'; + +class CustomDefinitionInstance extends ResourceInstance {} + +export { CustomDefinitionInstance }; diff --git a/src/instances/deployment.ts b/src/instances/deployment.ts new file mode 100644 index 0000000..d7f7996 --- /dev/null +++ b/src/instances/deployment.ts @@ -0,0 +1,11 @@ +import type { V1Deployment } from '@kubernetes/client-node'; + +import { ResourceInstance } from '../services/resources/resources.ts'; + +class DeploymentInstance extends ResourceInstance { + public get ready() { + return this.exists && this.status?.readyReplicas === this.status?.replicas; + } +} + +export { DeploymentInstance }; diff --git a/src/instances/destination-rule.ts b/src/instances/destination-rule.ts new file mode 100644 index 0000000..671aa07 --- /dev/null +++ b/src/instances/destination-rule.ts @@ -0,0 +1,12 @@ +import type { KubernetesObject } from '@kubernetes/client-node'; + +import { ResourceInstance } from '../services/resources/resources.instance.ts'; +import type { K8SDestinationRuleV1 } from '../__generated__/resources/K8SDestinationRuleV1.ts'; + +class DestinationRuleInstance extends ResourceInstance { + public get ready() { + return this.exists; + } +} + +export { DestinationRuleInstance }; diff --git a/src/instances/environment.ts b/src/instances/environment.ts new file mode 100644 index 0000000..f1ca559 --- /dev/null +++ b/src/instances/environment.ts @@ -0,0 +1,7 @@ +import type { environmentSpecSchema } from '../custom-resouces/environment/environment.schemas.ts'; +import type { CustomResourceObject } from '../services/custom-resources/custom-resources.custom-resource.ts'; +import { ResourceInstance } from '../services/resources/resources.instance.ts'; + +class EnvironmentInstance extends ResourceInstance> {} + +export { EnvironmentInstance }; diff --git a/src/instances/gateway.ts b/src/instances/gateway.ts new file mode 100644 index 0000000..200abd1 --- /dev/null +++ b/src/instances/gateway.ts @@ -0,0 +1,8 @@ +import type { KubernetesObject } from '@kubernetes/client-node'; + +import { ResourceInstance } from '../services/resources/resources.instance.ts'; +import type { K8SGatewayV1 } from '../__generated__/resources/K8SGatewayV1.ts'; + +class GatewayInstance extends ResourceInstance {} + +export { GatewayInstance }; diff --git a/src/instances/git-repo.ts b/src/instances/git-repo.ts new file mode 100644 index 0000000..edb2850 --- /dev/null +++ b/src/instances/git-repo.ts @@ -0,0 +1,12 @@ +import type { KubernetesObject } from '@kubernetes/client-node'; + +import { ResourceInstance } from '../services/resources/resources.ts'; +import type { K8SGitRepositoryV1 } from '../__generated__/resources/K8SGitRepositoryV1.ts'; + +class GitRepoInstance extends ResourceInstance { + public get ready() { + return this.exists; + } +} + +export { GitRepoInstance }; diff --git a/src/instances/helm-release.ts b/src/instances/helm-release.ts new file mode 100644 index 0000000..5846993 --- /dev/null +++ b/src/instances/helm-release.ts @@ -0,0 +1,12 @@ +import type { KubernetesObject } from '@kubernetes/client-node'; + +import { ResourceInstance } from '../services/resources/resources.ts'; +import type { K8SHelmReleaseV2 } from '../__generated__/resources/K8SHelmReleaseV2.ts'; + +class HelmReleaseInstance extends ResourceInstance { + public get ready() { + return this.exists; + } +} + +export { HelmReleaseInstance }; diff --git a/src/instances/helm-repo.ts b/src/instances/helm-repo.ts new file mode 100644 index 0000000..5ba179f --- /dev/null +++ b/src/instances/helm-repo.ts @@ -0,0 +1,16 @@ +import type { KubernetesObject } from '@kubernetes/client-node'; + +import { ResourceInstance } from '../services/resources/resources.ts'; +import type { K8SHelmRepositoryV1 } from '../__generated__/resources/K8SHelmRepositoryV1.ts'; + +class HelmRepoInstance extends ResourceInstance { + public get ready() { + if (!this.exists) { + return false; + } + const condition = this.getCondition('Ready'); + return condition?.status === 'True'; + } +} + +export { HelmRepoInstance }; diff --git a/src/instances/namespace.ts b/src/instances/namespace.ts new file mode 100644 index 0000000..a1ee6d3 --- /dev/null +++ b/src/instances/namespace.ts @@ -0,0 +1,11 @@ +import type { V1Namespace } from '@kubernetes/client-node'; + +import { ResourceInstance } from '../services/resources/resources.ts'; + +class NamespaceInstance extends ResourceInstance { + public get ready() { + return this.exists; + } +} + +export { NamespaceInstance }; diff --git a/src/instances/postgres-cluster.ts b/src/instances/postgres-cluster.ts new file mode 100644 index 0000000..14ae5da --- /dev/null +++ b/src/instances/postgres-cluster.ts @@ -0,0 +1,7 @@ +import type { postgresClusterSpecSchema } from '../custom-resouces/postgres-cluster/postgres-cluster.schemas.ts'; +import type { CustomResourceObject } from '../services/custom-resources/custom-resources.custom-resource.ts'; +import { ResourceInstance } from '../services/resources/resources.instance.ts'; + +class PostgresClusterInstance extends ResourceInstance> {} + +export { PostgresClusterInstance }; diff --git a/src/instances/secret.ts b/src/instances/secret.ts new file mode 100644 index 0000000..262df1b --- /dev/null +++ b/src/instances/secret.ts @@ -0,0 +1,20 @@ +import type { V1Secret } from '@kubernetes/client-node'; + +import { ResourceInstance } from '../services/resources/resources.instance.ts'; +import { decodeSecret, encodeSecret } from '../utils/secrets.ts'; + +class SecretInstance extends ResourceInstance { + public get values() { + return decodeSecret(this.data); + } + + public ensureData = async (values: Record) => { + await this.ensure({ + data: encodeSecret(values), + }); + }; + + public readonly ready = true; +} + +export { SecretInstance }; diff --git a/src/instances/service.ts b/src/instances/service.ts new file mode 100644 index 0000000..f854195 --- /dev/null +++ b/src/instances/service.ts @@ -0,0 +1,11 @@ +import type { V1Service } from '@kubernetes/client-node'; + +import { ResourceInstance } from '../services/resources/resources.ts'; + +class ServiceInstance extends ResourceInstance { + public get ready() { + return this.exists; + } +} + +export { ServiceInstance }; diff --git a/src/instances/stateful-set.ts b/src/instances/stateful-set.ts new file mode 100644 index 0000000..a612fc7 --- /dev/null +++ b/src/instances/stateful-set.ts @@ -0,0 +1,11 @@ +import type { V1StatefulSet } from '@kubernetes/client-node'; + +import { ResourceInstance } from '../services/resources/resources.instance.ts'; + +class StatefulSetInstance extends ResourceInstance { + public get ready() { + return this.exists && this.manifest?.status?.readyReplicas === this.manifest?.status?.replicas; + } +} + +export { StatefulSetInstance }; diff --git a/src/instances/storageclass.ts b/src/instances/storageclass.ts new file mode 100644 index 0000000..47aae4f --- /dev/null +++ b/src/instances/storageclass.ts @@ -0,0 +1,7 @@ +import type { V1StorageClass } from '@kubernetes/client-node'; + +import { ResourceInstance } from '../services/resources/resources.instance.ts'; + +class StorageClassInstance extends ResourceInstance {} + +export { StorageClassInstance }; diff --git a/src/instances/virtual-service.ts b/src/instances/virtual-service.ts new file mode 100644 index 0000000..a4ceabf --- /dev/null +++ b/src/instances/virtual-service.ts @@ -0,0 +1,12 @@ +import type { KubernetesObject } from '@kubernetes/client-node'; + +import { ResourceInstance } from '../services/resources/resources.instance.ts'; +import type { K8SVirtualServiceV1 } from '../__generated__/resources/K8SVirtualServiceV1.ts'; + +class VirtualServiceInstance extends ResourceInstance { + public get ready() { + return this.exists; + } +} + +export { VirtualServiceInstance }; diff --git a/src/services/k8s/k8s.ts b/src/services/k8s/k8s.ts index dbe3b80..4062f27 100644 --- a/src/services/k8s/k8s.ts +++ b/src/services/k8s/k8s.ts @@ -5,8 +5,8 @@ import { CustomObjectsApi, EventsV1Api, KubernetesObjectApi, - ApiException, AppsV1Api, + StorageV1Api, } from '@kubernetes/client-node'; class K8sService { @@ -17,6 +17,7 @@ class K8sService { #k8sEventsApi: EventsV1Api; #k8sObjectsApi: KubernetesObjectApi; #k8sAppsApi: AppsV1Api; + #k8sStorageApi: StorageV1Api; constructor() { this.#kc = new KubeConfig(); @@ -27,6 +28,7 @@ class K8sService { this.#k8sEventsApi = this.#kc.makeApiClient(EventsV1Api); this.#k8sObjectsApi = this.#kc.makeApiClient(KubernetesObjectApi); this.#k8sAppsApi = this.#kc.makeApiClient(AppsV1Api); + this.#k8sStorageApi = this.#kc.makeApiClient(StorageV1Api); } public get config() { @@ -56,6 +58,10 @@ class K8sService { public get apps() { return this.#k8sAppsApi; } + + public get storageApi() { + return this.#k8sStorageApi; + } } export { K8sService }; diff --git a/src/services/postgres/postgres.instance.ts b/src/services/postgres/postgres.instance.ts index f347ad9..e97b150 100644 --- a/src/services/postgres/postgres.instance.ts +++ b/src/services/postgres/postgres.instance.ts @@ -8,7 +8,7 @@ type PostgresInstanceOptions = { services: Services; host: string; port?: number; - user: string; + username: string; password: string; database?: string; }; @@ -21,7 +21,7 @@ class PostgresInstance { client: 'pg', connection: { host: process.env.FORCE_PG_HOST ?? options.host, - user: process.env.FORCE_PG_USER ?? options.user, + user: process.env.FORCE_PG_USER ?? options.username, password: process.env.FORCE_PG_PASSWORD ?? options.password, port: process.env.FORCE_PG_PORT ? parseInt(process.env.FORCE_PG_PORT) : options.port, database: options.database, diff --git a/src/services/resources/resources.instance.ts b/src/services/resources/resources.instance.ts index 3bbaee5..3add795 100644 --- a/src/services/resources/resources.instance.ts +++ b/src/services/resources/resources.instance.ts @@ -1,5 +1,7 @@ import type { KubernetesObject } from '@kubernetes/client-node'; +import { isDeepSubset } from '../../utils/objects.ts'; + import { ResourceReference } from './resources.ref.ts'; abstract class ResourceInstance extends ResourceReference { @@ -10,8 +12,12 @@ abstract class ResourceInstance extends ResourceRefe return this.current; } + public get exists() { + return this.resource.exists; + } + public get manifest() { - return this.resource.metadata; + return this.resource.manifest; } public get apiVersion() { @@ -42,9 +48,32 @@ abstract class ResourceInstance extends ResourceRefe return this.resource.data; } + public get status() { + return this.resource.status; + } + public patch = this.resource.patch; public reload = this.resource.load; public delete = this.resource.delete; + + public ensure = async (manifest: T) => { + if (isDeepSubset(this.manifest, manifest)) { + return false; + } + await this.patch(manifest); + return true; + }; + + public get ready() { + return this.exists; + } + + public getCondition = ( + condition: string, + ): T extends { status?: { conditions?: (infer U)[] } } ? U | undefined : undefined => { + const status = this.status as ExpectedAny; + return status?.conditions?.find((c: ExpectedAny) => c?.type === condition); + }; } export { ResourceInstance }; diff --git a/src/services/resources/resources.resource.ts b/src/services/resources/resources.resource.ts index 2230c8b..0476786 100644 --- a/src/services/resources/resources.resource.ts +++ b/src/services/resources/resources.resource.ts @@ -163,6 +163,13 @@ class Resource extends EventEmitte return undefined as ExpectedAny; } + public get status(): T extends { status?: infer K } ? K | undefined : never { + if (this.manifest && 'status' in this.manifest) { + return this.manifest.status as ExpectedAny; + } + return undefined as ExpectedAny; + } + public get owners() { const { services } = this.#options; const references = this.metadata?.ownerReferences || []; diff --git a/src/services/resources/resources.ts b/src/services/resources/resources.ts index 4f2703d..f6189d8 100644 --- a/src/services/resources/resources.ts +++ b/src/services/resources/resources.ts @@ -3,6 +3,7 @@ import type { KubernetesObject } from '@kubernetes/client-node'; import type { Services } from '../../utils/service.ts'; import { Resource } from './resources.resource.ts'; +import type { ResourceInstance } from './resources.instance.ts'; type ResourceGetOptions = { apiVersion: string; @@ -19,6 +20,14 @@ class ResourceService { this.#services = services; } + public getInstance = >( + options: ResourceGetOptions, + instance: new (resource: Resource) => I, + ) => { + const resource = this.get(options); + return new instance(resource); + }; + public get = (options: ResourceGetOptions) => { const { apiVersion, kind, name, namespace } = options; let resource = this.#cache.find( @@ -40,5 +49,6 @@ class ResourceService { }; } +export { ResourceInstance } from './resources.instance.ts'; export { ResourceReference } from './resources.ref.ts'; export { ResourceService, Resource }; diff --git a/src/services/secrets/secrets.secret.ts b/src/services/secrets/secrets.secret.ts index 5563c00..ac71fdc 100644 --- a/src/services/secrets/secrets.secret.ts +++ b/src/services/secrets/secrets.secret.ts @@ -41,7 +41,7 @@ class EnsuredSecret { return this.#options.namespace; } - public get resouce() { + public get resource() { return this.#resource; } @@ -62,7 +62,7 @@ class EnsuredSecret { if (deepEqual(patched, this.value)) { return; } - await this.resouce.patch({ + await this.resource.patch({ data: patched, }); }; diff --git a/src/storage-provider/storage-provider.ts b/src/storage-provider/storage-provider.ts index dd6cd2c..e82ccd8 100644 --- a/src/storage-provider/storage-provider.ts +++ b/src/storage-provider/storage-provider.ts @@ -1,12 +1,10 @@ -import { mkdir } from 'fs/promises'; - -import { V1PersistentVolume, type V1PersistentVolumeClaim } from '@kubernetes/client-node'; +import { V1PersistentVolume, type V1PersistentVolumeClaim, CoreV1Event, V1StorageClass } from '@kubernetes/client-node'; import { Watcher, WatcherService } from '../services/watchers/watchers.ts'; import type { Services } from '../utils/service.ts'; import { ResourceService, type Resource } from '../services/resources/resources.ts'; -const PROVISIONER = 'reuse-local-path-provisioner'; +const PROVISIONER = 'homelab-operator-local-path'; class StorageProvider { #watcher: Watcher; @@ -32,46 +30,128 @@ class StorageProvider { } #handleChange = async (pvc: Resource) => { - if (pvc.metadata?.annotations?.['volume.kubernetes.io/storage-provisioner'] !== PROVISIONER) { - return; - } - const target = `/data/volumes/${pvc.namespace}/${pvc.name}`; try { - await mkdir(target, { recursive: true }); - } catch (err) { - console.error(err); + if (!pvc.exists || pvc.metadata?.deletionTimestamp) { + return; + } + + const storageClassName = pvc.spec?.storageClassName; + if (!storageClassName) { + return; + } + const resourceService = this.#services.get(ResourceService); + const storageClass = resourceService.get({ + apiVersion: 'storage.k8s.io/v1', + kind: 'StorageClass', + name: storageClassName, + }); + + if (!storageClass.exists || storageClass.manifest?.provisioner !== PROVISIONER) { + return; + } + + if (pvc.status?.phase === 'Pending' && !pvc.spec?.volumeName) { + await this.#provisionVolume(pvc, storageClass); + } + } catch (error) { + console.error(`Error handling PVC ${pvc.namespace}/${pvc.name}:`, error); + await this.#createEvent(pvc, 'Warning', 'ProvisioningFailed', `Failed to provision volume: ${error}`); } - const resourceService = this.#services.get(ResourceService); - const pv = resourceService.get({ - apiVersion: 'v1', - kind: 'PersistentVolume', - name: `${pvc.namespace}-${pvc.name}`, - }); - await pv.load(); - await pv.patch({ - metadata: { - labels: { - provisioner: PROVISIONER, + }; + + #provisionVolume = async (pvc: Resource, storageClass: Resource) => { + const pvName = `pv-${pvc.namespace}-${pvc.name}`; + const storageLocation = storageClass.manifest?.parameters?.storageLocation || '/data/volumes'; + const target = `${storageLocation}/${pvc.namespace}/${pvc.name}`; + + try { + const resourceService = this.#services.get(ResourceService); + const pv = resourceService.get({ + apiVersion: 'v1', + kind: 'PersistentVolume', + name: pvName, + }); + + await pv.patch({ + metadata: { + name: pvName, + labels: { + provisioner: PROVISIONER, + 'pvc-namespace': pvc.namespace || 'default', + 'pvc-name': pvc.name || 'unknown', + }, + annotations: { + 'pv.kubernetes.io/provisioned-by': PROVISIONER, + }, }, - }, - spec: { - hostPath: { - path: target, + spec: { + hostPath: { + path: target, + type: 'DirectoryOrCreate', + }, + capacity: { + storage: pvc.spec?.resources?.requests?.storage ?? '1Gi', + }, + persistentVolumeReclaimPolicy: 'Retain', + accessModes: pvc.spec?.accessModes ?? ['ReadWriteOnce'], + storageClassName: pvc.spec?.storageClassName, + claimRef: { + uid: pvc.metadata?.uid, + resourceVersion: pvc.metadata?.resourceVersion, + apiVersion: pvc.apiVersion, + kind: 'PersistentVolumeClaim', + name: pvc.name, + namespace: pvc.namespace, + }, }, - capacity: { - storage: pvc.spec?.resources?.requests?.storage ?? '1Gi', - }, - persistentVolumeReclaimPolicy: 'Retain', - accessModes: pvc.spec?.accessModes, - claimRef: { - uid: pvc.metadata?.uid, - resourceVersion: pvc.metadata?.resourceVersion, - apiVersion: pvc.apiVersion, - name: pvc.name, + }); + + await this.#createEvent(pvc, 'Normal', 'Provisioning', `Successfully provisioned volume ${pvName}`); + } catch (error) { + console.error(`Failed to provision volume for PVC ${pvc.namespace}/${pvc.name}:`, error); + throw error; + } + }; + + #createEvent = async (pvc: Resource, type: string, reason: string, message: string) => { + try { + const resourceService = this.#services.get(ResourceService); + const event = resourceService.get({ + apiVersion: 'v1', + kind: 'Event', + name: `${pvc.name}-${Date.now()}`, + namespace: pvc.namespace, + }); + + if (!pvc.name || !pvc.namespace || !pvc.metadata?.uid) { + console.error('Missing required PVC metadata for event creation'); + return; + } + + await event.patch({ + metadata: { namespace: pvc.namespace, }, - }, - }); + involvedObject: { + apiVersion: pvc.apiVersion, + kind: 'PersistentVolumeClaim', + name: pvc.name, + namespace: pvc.namespace, + uid: pvc.metadata.uid, + }, + type, + reason, + message, + source: { + component: PROVISIONER, + }, + firstTimestamp: new Date(), + lastTimestamp: new Date(), + count: 1, + }); + } catch (error) { + console.error(`Failed to create event for PVC ${pvc.namespace}/${pvc.name}:`, error); + } }; public start = async () => { @@ -79,4 +159,4 @@ class StorageProvider { }; } -export { StorageProvider }; +export { StorageProvider, PROVISIONER }; diff --git a/src/utils/consts.ts b/src/utils/consts.ts index fe3b21d..d5b643e 100644 --- a/src/utils/consts.ts +++ b/src/utils/consts.ts @@ -7,10 +7,12 @@ const FIELDS = { }, }; +const NAMESPACE = 'homelab'; + const CONTROLLED_LABEL = { [`${GROUP}/controlled`]: 'true', }; const CONTROLLED_LABEL_SELECTOR = `${GROUP}/controlled=true`; -export { GROUP, FIELDS, CONTROLLED_LABEL, CONTROLLED_LABEL_SELECTOR, API_VERSION }; +export { GROUP, FIELDS, CONTROLLED_LABEL, CONTROLLED_LABEL_SELECTOR, API_VERSION, NAMESPACE };