Compare commits

..

9 Commits

Author SHA1 Message Date
Morten Olsen
7fc90c82f2 publish operator yaml 2025-08-12 23:55:38 +02:00
Morten Olsen
2add15d283 fix: authentik port 2025-08-12 23:25:03 +02:00
Morten Olsen
5426495be5 updates 2025-08-12 23:22:47 +02:00
Morten Olsen
b8bb16ccbb updates 2025-08-12 22:32:09 +02:00
Morten Olsen
d4b56007f1 add authentik connection crd 2025-08-12 08:36:29 +02:00
Morten Olsen
130bfec468 fix reconciliation of db 2025-08-11 20:00:01 +02:00
Morten Olsen
ddb3c79657 fix pg db 2025-08-11 15:00:06 +02:00
Morten Olsen
47cf43b44e Added storage provisioner 2025-08-11 12:07:36 +02:00
Morten Olsen
aa6d14738a simplify 2025-08-07 23:26:33 +02:00
111 changed files with 2578 additions and 3048 deletions

View File

@@ -75,5 +75,7 @@ jobs:
with:
config-name: release-drafter-config.yml
publish: true
assets: |
operator.yaml
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -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

View File

@@ -1,14 +0,0 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: {{ include "homelab-operator.fullname" . }}
rules:
- apiGroups: [""]
resources: ["secrets"]
verbs: ["create", "get", "watch", "list"]
- apiGroups: ["*"]
resources: ["*"]
verbs: ["get", "watch", "list", "patch"]
- apiGroups: ["apiextensions.k8s.io"]
resources: ["customresourcedefinitions"]
verbs: ["get", "create", "replace"]

View File

@@ -1,108 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "homelab-operator.fullname" . }}
labels:
{{- include "homelab-operator.labels" . | nindent 4 }}
spec:
replicas: 1
selector:
matchLabels:
{{- include "homelab-operator.selectorLabels" . | nindent 6 }}
template:
metadata:
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "homelab-operator.selectorLabels" . | nindent 8 }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "homelab-operator.serviceAccountName" . }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: {{ .Chart.Name }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
env:
# PostgreSQL Host
- name: POSTGRES_HOST
{{- if .Values.config.postgres.host.fromSecret.enabled }}
valueFrom:
secretKeyRef:
name: {{ .Values.config.postgres.host.fromSecret.secretName }}
key: {{ .Values.config.postgres.host.fromSecret.key }}
{{- else }}
value: {{ .Values.config.postgres.host.value | quote }}
{{- end }}
# PostgreSQL Port
- name: POSTGRES_PORT
{{- if .Values.config.postgres.port.fromSecret.enabled }}
valueFrom:
secretKeyRef:
name: {{ .Values.config.postgres.port.fromSecret.secretName }}
key: {{ .Values.config.postgres.port.fromSecret.key }}
{{- else }}
value: {{ .Values.config.postgres.port.value | quote }}
{{- end }}
# PostgreSQL User
- name: POSTGRES_USER
{{- if .Values.config.postgres.user.fromSecret.enabled }}
valueFrom:
secretKeyRef:
name: {{ .Values.config.postgres.user.fromSecret.secretName }}
key: {{ .Values.config.postgres.user.fromSecret.key }}
{{- else }}
value: {{ .Values.config.postgres.user.value | quote }}
{{- end }}
# PostgreSQL Password
- name: POSTGRES_PASSWORD
{{- if .Values.config.postgres.password.fromSecret.enabled }}
valueFrom:
secretKeyRef:
name: {{ .Values.config.postgres.password.fromSecret.secretName }}
key: {{ .Values.config.postgres.password.fromSecret.key }}
{{- else }}
value: {{ .Values.config.postgres.password.value | quote }}
{{- end }}
# Certificate Manager
- name: CERT_MANAGER
{{- if .Values.config.certManager.fromSecret.enabled }}
valueFrom:
secretKeyRef:
name: {{ .Values.config.certManager.fromSecret.secretName }}
key: {{ .Values.config.certManager.fromSecret.key }}
{{- else }}
value: {{ .Values.config.certManager.value | quote }}
{{- end }}
# Istio Gateway
- name: ISTIO_GATEWAY
{{- if .Values.config.istioGateway.fromSecret.enabled }}
valueFrom:
secretKeyRef:
name: {{ .Values.config.istioGateway.fromSecret.secretName }}
key: {{ .Values.config.istioGateway.fromSecret.key }}
{{- else }}
value: {{ .Values.config.istioGateway.value | quote }}
{{- end }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}

View File

@@ -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" }}

View File

@@ -0,0 +1,29 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: {{ include "homelab-operator.fullname" . }}
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"]
- apiGroups: ["apiextensions.k8s.io"]
resources: ["customresourcedefinitions"]
verbs: ["get", "create", "replace"]

View File

@@ -0,0 +1,55 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "homelab-operator.fullname" . }}
labels:
{{- include "homelab-operator.labels" . | nindent 4 }}
spec:
replicas: 1
selector:
matchLabels:
{{- include "homelab-operator.selectorLabels" . | nindent 6 }}
template:
metadata:
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "homelab-operator.selectorLabels" . | nindent 8 }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "homelab-operator.serviceAccountName" . }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: {{ .Chart.Name }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
volumeMounts:
- name: data-volumes
mountPath: {{ .Values.storage.path }}
volumes:
- name: data-volumes
hostPath:
path: {{ .Values.storage.path }}
type: DirectoryOrCreate
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}

View File

@@ -9,8 +9,14 @@ image:
tag: main
imagePullSecrets: []
nameOverride: ""
fullnameOverride: ""
nameOverride: ''
fullnameOverride: ''
storage:
path: /data/volumes
reclaimPolicy: Retain
allowVolumeExpansion: false
volumeBindingMode: WaitForFirstConsumer
serviceAccount:
# Specifies whether a service account should be created
@@ -19,7 +25,7 @@ serviceAccount:
annotations: {}
# The name of the service account to use.
# If not set and create is true, a name is generated using the fullname template
name: ""
name: ''
podAnnotations: {}
@@ -51,53 +57,3 @@ nodeSelector: {}
tolerations: []
affinity: {}
# Configuration for the homelab operator
config:
# PostgreSQL database configuration
postgres:
host:
# Direct value (used when fromSecret.enabled is false)
value: "127.0.0.1"
# Secret reference (used when fromSecret.enabled is true)
fromSecret:
enabled: false
secretName: ""
key: "POSTGRES_HOST"
port:
value: "5432"
fromSecret:
enabled: false
secretName: ""
key: "POSTGRES_PORT"
user:
value: "postgres"
fromSecret:
enabled: false
secretName: ""
key: "POSTGRES_USER"
password:
value: ""
fromSecret:
enabled: true # Default to secret for sensitive data
secretName: "postgres-secret"
key: "POSTGRES_PASSWORD"
# Certificate manager configuration
certManager:
value: "letsencrypt-prod"
fromSecret:
enabled: false
secretName: ""
key: "CERT_MANAGER"
# Istio gateway configuration
istioGateway:
value: "istio-ingress"
fromSecret:
enabled: false
secretName: ""
key: "ISTIO_GATEWAY"

View File

@@ -0,0 +1,14 @@
apiVersion: v1
kind: Namespace
metadata:
name: dev
---
apiVersion: homelab.mortenolsen.pro/v1
kind: Environment
metadata:
name: dev
namespace: dev
spec:
domain: one.dev.olsen.cloud
tls:
issuer: letsencrypt-prod

View File

@@ -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

34
operator.yaml Normal file
View File

@@ -0,0 +1,34 @@
apiVersion: v1
kind: Namespace
metadata:
name: homelab
---
apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
metadata:
name: homelab
namespace: homelab
spec:
interval: 60m
url: https://github.com/morten-olsen/homelab-operator
ref:
branch: main
---
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: operator
namespace: homelab
spec:
releaseName: operator
chart:
spec:
chart: chart
sourceRef:
kind: GitRepository
name: homelab
namespace: homelab

15
scripts/list-manifests.ts Executable file
View File

@@ -0,0 +1,15 @@
#!/usr/bin/env node
import { K8sService } from '../src/services/k8s/k8s.ts';
import { Services } from '../src/utils/service.ts';
const services = new Services();
const k8s = services.get(K8sService);
const manifests = await k8s.extensionsApi.listCustomResourceDefinition();
for (const manifest of manifests.items) {
for (const version of manifest.spec.versions) {
console.log(`group: ${manifest.spec.group}, plural: ${manifest.spec.names.plural}, version: ${version.name}`);
}
}

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -1,36 +1,31 @@
import type { V1Secret } from '@kubernetes/client-node';
import type { z } from 'zod';
import deepEqual from 'deep-equal';
import {
CustomResource,
type CustomResourceObject,
type CustomResourceOptions,
type SubresourceResult,
} from '../../services/custom-resources/custom-resources.custom-resource.ts';
import { ResourceReference } from '../../services/resources/resources.ref.ts';
import { ResourceService, type Resource } from '../../services/resources/resources.ts';
import { getWithNamespace } from '../../utils/naming.ts';
import type { authentikServerSpecSchema } from '../authentik-server/authentik-server.scemas.ts';
import type { domainSpecSchema } from '../domain/domain.schemas.ts';
import { decodeSecret, encodeSecret } from '../../utils/secrets.ts';
import { API_VERSION, CONTROLLED_LABEL } from '../../utils/consts.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, type authentikClientSpecSchema } from './authentik-client.schemas.ts';
class AuthentikClientResource extends CustomResource<typeof authentikClientSpecSchema> {
#serverResource: ResourceReference<CustomResourceObject<typeof authentikServerSpecSchema>>;
#serverSecretResource: ResourceReference<V1Secret>;
#domainResource: ResourceReference<CustomResourceObject<typeof domainSpecSchema>>;
#serverSecret: ResourceReference<V1Secret>;
#clientSecretResource: Resource<V1Secret>;
constructor(options: CustomResourceOptions<typeof authentikClientSpecSchema>) {
super(options);
const resourceService = this.services.get(ResourceService);
this.#serverResource = new ResourceReference();
this.#serverSecretResource = new ResourceReference();
this.#domainResource = new ResourceReference();
this.#serverSecret = new ResourceReference();
this.#clientSecretResource = resourceService.get({
apiVersion: 'v1',
kind: 'Secret',
@@ -40,93 +35,45 @@ class AuthentikClientResource extends CustomResource<typeof authentikClientSpecS
this.#updateResouces();
this.#serverResource.on('changed', this.queueReconcile);
this.#serverSecretResource.on('changed', this.queueReconcile);
this.#domainResource.on('changed', this.queueReconcile);
this.#serverSecret.on('changed', this.queueReconcile);
this.#clientSecretResource.on('changed', this.queueReconcile);
}
get server() {
return this.#serverResource.current;
}
get serverSecret() {
return this.#serverSecretResource.current;
}
get serverSecretValue() {
return decodeSecret(this.#serverSecretResource.current?.data);
}
get domain() {
return this.#domainResource.current;
}
get clientSecret() {
return this.#clientSecretResource;
}
get clientSecretValue() {
const values = decodeSecret(this.#clientSecretResource.data);
const parsed = authentikClientSecretSchema.safeParse(values);
if (!parsed.success) {
return undefined;
}
return parsed.data;
}
#updateResouces = () => {
const serverNames = getWithNamespace(this.spec.server, this.namespace);
const serverSecretNames = getWithNamespace(`${this.spec.server}-server`, this.namespace);
const resourceService = this.services.get(ResourceService);
this.#serverResource.current = resourceService.get({
apiVersion: API_VERSION,
kind: 'AuthentikServer',
name: serverNames.name,
namespace: serverNames.namespace,
});
this.#serverSecretResource.current = resourceService.get({
this.#serverSecret.current = resourceService.get({
apiVersion: 'v1',
kind: 'Secret',
name: `authentik-server-${serverNames.name}`,
namespace: serverNames.namespace,
name: serverSecretNames.name,
namespace: serverSecretNames.namespace,
});
const server = this.#serverResource.current;
if (server && server.spec) {
const domainNames = getWithNamespace(server.spec.domain, server.namespace);
this.#domainResource.current = resourceService.get({
apiVersion: API_VERSION,
kind: 'Domain',
name: domainNames.name,
namespace: domainNames.namespace,
});
} else {
this.#domainResource.current = undefined;
}
};
#reconcileClientSecret = async (): Promise<SubresourceResult> => {
const domain = this.domain;
const server = this.server;
const serverSecret = this.serverSecret;
if (!server?.exists || !server?.spec || !serverSecret?.exists || !serverSecret.data) {
const serverSecret = this.#serverSecret.current;
if (!serverSecret?.exists || !serverSecret.data) {
return {
ready: false,
failed: true,
message: 'Server or server secret not found',
};
}
if (!domain?.exists || !domain?.spec) {
const serverSecretData = authentikServerSecretSchema.safeParse(decodeSecret(serverSecret.data));
if (!serverSecretData.success || !serverSecretData.data) {
return {
ready: false,
failed: true,
message: 'Domain not found',
message: 'Server secret not found',
};
}
const url = `https://authentik.${domain.spec?.hostname}`;
const url = serverSecretData.data.url;
const appName = this.name;
const values = this.clientSecretValue;
const expectedValues: Omit<z.infer<typeof authentikClientSecretSchema>, 'clientSecret'> = {
const clientSecretData = authentikClientSecretSchema.safeParse(decodeSecret(this.#clientSecretResource.data));
const expectedValues: z.infer<typeof authentikClientSecretSchema> = {
clientId: this.name,
clientSecret: clientSecretData.data?.clientSecret || crypto.randomUUID(),
configuration: new URL(`/application/o/${appName}/.well-known/openid-configuration`, url).toString(),
configurationIssuer: new URL(`/application/o/${appName}/`, url).toString(),
authorization: new URL(`/application/o/${appName}/authorize/`, url).toString(),
@@ -135,31 +82,8 @@ class AuthentikClientResource extends CustomResource<typeof authentikClientSpecS
endSession: new URL(`/application/o/${appName}/end-session/`, url).toString(),
jwks: new URL(`/application/o/${appName}/jwks/`, url).toString(),
};
if (!values) {
await this.clientSecret.patch({
metadata: {
ownerReferences: [this.ref],
labels: {
...CONTROLLED_LABEL,
},
},
data: encodeSecret({
...expectedValues,
clientSecret: crypto.randomUUID(),
}),
});
return {
ready: false,
syncing: true,
message: 'UpdatingManifest',
};
}
const compareData = {
...values,
clientSecret: undefined,
};
if (!deepEqual(compareData, expectedValues)) {
await this.clientSecret.patch({
if (!isDeepSubset(clientSecretData.data, expectedValues)) {
await this.#clientSecretResource.patch({
metadata: {
ownerReferences: [this.ref],
labels: {
@@ -168,23 +92,82 @@ class AuthentikClientResource extends CustomResource<typeof authentikClientSpecS
},
data: encodeSecret(expectedValues),
});
return {
ready: false,
syncing: true,
message: 'UpdatingManifest',
};
}
return {
ready: true,
};
};
#reconcileServer = async (): Promise<SubresourceResult> => {
const serverSecret = this.#serverSecret.current;
const clientSecret = this.#clientSecretResource;
if (!serverSecret?.exists || !serverSecret.data) {
return {
ready: false,
failed: true,
message: 'Server secret not found',
};
}
const serverSecretData = authentikServerSecretSchema.safeParse(decodeSecret(serverSecret.data));
if (!serverSecretData.success || !serverSecretData.data) {
return {
ready: false,
failed: true,
message: 'Server secret not found',
};
}
const clientSecretData = authentikClientSecretSchema.safeParse(decodeSecret(clientSecret.data));
if (!clientSecretData.success || !clientSecretData.data) {
return {
ready: false,
failed: true,
message: 'Client secret not found',
};
}
const authentikService = this.services.get(AuthentikService);
const authentikServer = authentikService.get({
url: {
internal: `http://${serverSecretData.data.host}`,
external: serverSecretData.data.url,
},
token: serverSecretData.data.token,
});
(await authentikServer).upsertClient({
...this.spec,
name: this.name,
secret: clientSecretData.data.clientSecret,
});
return {
ready: true,
};
};
public reconcile = async () => {
if (!this.exists || this.metadata.deletionTimestamp) {
if (!this.exists || this.metadata?.deletionTimestamp) {
return;
}
this.#updateResouces();
await Promise.all([this.reconcileSubresource('Secret', this.#reconcileClientSecret)]);
await Promise.all([
this.reconcileSubresource('Secret', this.#reconcileClientSecret),
this.reconcileSubresource('Server', this.#reconcileServer),
]);
const secretReady = this.conditions.get('Secret')?.status === 'True';
const serverReady = this.conditions.get('Server')?.status === 'True';
await this.conditions.set('Ready', {
status: secretReady ? 'True' : 'False',
status: secretReady && serverReady ? 'True' : 'False',
});
};
}

View File

@@ -1,4 +1,4 @@
import { ClientTypeEnum, MatchingModeEnum, SubModeEnum } from '@goauthentik/api';
import { ClientTypeEnum, SubModeEnum } from '@goauthentik/api';
import { z } from 'zod';
const authentikClientSpecSchema = z.object({
@@ -8,7 +8,7 @@ const authentikClientSpecSchema = z.object({
redirectUris: z.array(
z.object({
url: z.string(),
matchingMode: z.enum(MatchingModeEnum).optional(),
matchingMode: z.enum(['strict', 'regex']),
}),
),
});

View File

@@ -0,0 +1,246 @@
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 { HttpServiceInstance } from '../../instances/http-service.ts';
import type { redisServerSpecSchema } from '../redis-server/redis-server.schemas.ts';
import { authentikServerInitSecretSchema, type authentikServerSpecSchema } from './authentik-server.schemas.ts';
class AuthentikServerController extends CustomResource<typeof authentikServerSpecSchema> {
#environment: ResourceReference<CustomResourceObject<typeof environmentSpecSchema>>;
#authentikInitSecret: EnsuredSecret<typeof authentikServerInitSecretSchema>;
#authentikSecret: SecretInstance;
#authentikRelease: HelmReleaseInstance;
#postgresSecret: ResourceReference<V1Secret>;
#httpService: HttpServiceInstance;
#redisServer: ResourceReference<CustomResourceObject<typeof redisServerSpecSchema>>;
constructor(options: CustomResourceOptions<typeof authentikServerSpecSchema>) {
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.#httpService = resourceService.getInstance(
{
apiVersion: API_VERSION,
kind: 'HttpService',
name: this.name,
namespace: this.namespace,
},
HttpServiceInstance,
);
this.#redisServer = new ResourceReference();
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);
this.#httpService.on('changed', this.queueReconcile);
this.#redisServer.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);
const redisNames = getWithNamespace(this.spec.redisServer, this.namespace);
const redisHost = `${redisNames.name}.${redisNames.namespace}.svc.cluster.local`;
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: redisHost,
},
},
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,
},
],
},
},
},
});
await this.#httpService.ensure({
metadata: {
ownerReferences: [this.ref],
},
spec: {
environment: this.spec.environment,
subdomain: this.spec.subdomain,
destination: {
host: `${this.name}-server.${this.namespace}.svc.cluster.local`,
port: {
number: 80,
},
},
},
});
};
}
export { AuthentikServerController };

View File

@@ -1,176 +0,0 @@
import type { CustomResourceObject } from '../../services/custom-resources/custom-resources.custom-resource.ts';
import { API_VERSION, CONTROLLED_LABEL } from '../../utils/consts.ts';
import type { domainServiceSpecSchema } from '../domain-service/domain-service.schemas.ts';
type CreateContainerManifestOptions = {
name: string;
namespace: string;
command: string;
owner: ExpectedAny;
secret: string;
bootstrap: {
email: string;
password: string;
token: string;
};
posgtres: {
host: string;
port: string;
name: string;
user: string;
password: string;
};
redis: {
host: string;
port: string;
};
};
const createManifest = (options: CreateContainerManifestOptions) => ({
apiVersion: 'apps/v1',
kind: 'Deployment',
metadata: {
name: options.name,
namespace: options.namespace,
labels: {
'app.kubernetes.io/name': options.name,
...CONTROLLED_LABEL,
},
ownerReferences: [options.owner],
},
spec: {
replicas: 1,
selector: {
matchLabels: {
'app.kubernetes.io/name': options.name,
},
},
template: {
metadata: {
labels: {
'app.kubernetes.io/name': options.name,
},
},
spec: {
containers: [
{
name: options.name,
image: 'ghcr.io/goauthentik/server:2025.6.4',
args: [options.command],
env: [
{ name: 'AUTHENTIK_SECRET_KEY', value: options.secret },
{ name: 'AUTHENTIK_POSTGRESQL__HOST', value: options.posgtres.host },
{
name: 'AUTHENTIK_POSTGRESQL__PORT',
value: '5432',
},
{
name: 'AUTHENTIK_POSTGRESQL__NAME',
value: options.posgtres.name,
},
{
name: 'AUTHENTIK_POSTGRESQL__USER',
value: options.posgtres.user,
},
{
name: 'AUTHENTIK_POSTGRESQL__PASSWORD',
value: options.posgtres.password,
},
{
name: 'AUTHENTIK_REDIS__HOST',
value: options.redis.host,
},
{
name: 'AUTHENTIK_REDIS__PORT',
value: options.redis.port,
},
{
name: 'AUTHENTIK_BOOTSTRAP_PASSWORD',
value: options.bootstrap.password,
},
{
name: 'AUTHENTIK_BOOTSTRAP_TOKEN',
value: options.bootstrap.token,
},
{
name: 'AUTHENTIK_BOOTSTRAP_EMAIL',
value: options.bootstrap.email,
},
],
ports: [
{
name: 'http',
containerPort: 9000,
protocol: 'TCP',
},
],
},
],
},
},
},
});
type CreateServiceManifestOptions = {
name: string;
namespace: string;
owner: ExpectedAny;
appName: string;
};
const createServiceManifest = (options: CreateServiceManifestOptions) => ({
apiVersion: 'v1',
kind: 'Service',
metadata: {
name: options.name,
namespace: options.namespace,
labels: {
...CONTROLLED_LABEL,
},
ownerReferences: [options.owner],
},
spec: {
type: 'ClusterIP',
ports: [
{
port: 9000,
targetPort: 9000,
protocol: 'TCP',
name: 'http',
},
],
selector: {
'app.kubernetes.io/name': options.appName,
},
},
});
type CreateDomainServiceOptions = {
name: string;
namespace: string;
owner: ExpectedAny;
subdomain: string;
host: string;
domain: string;
};
const createDomainService = (
options: CreateDomainServiceOptions,
): Omit<CustomResourceObject<typeof domainServiceSpecSchema>, 'status'> => ({
apiVersion: API_VERSION,
kind: 'DomainService',
metadata: {
name: options.name,
namespace: options.namespace,
ownerReferences: [options.owner],
},
spec: {
domain: options.domain,
subdomain: options.subdomain,
destination: {
host: options.host,
port: {
number: 9000,
},
},
},
});
export { createManifest, createServiceManifest, createDomainService };

View File

@@ -1,382 +0,0 @@
import type { V1Service, V1Deployment, V1Secret } from '@kubernetes/client-node';
import { z } from 'zod';
import {
CustomResource,
type CustomResourceObject,
type CustomResourceOptions,
type SubresourceResult,
} from '../../services/custom-resources/custom-resources.custom-resource.ts';
import { ResourceReference } from '../../services/resources/resources.ref.ts';
import { ResourceService, type Resource } from '../../services/resources/resources.ts';
import type { domainSpecSchema } from '../domain/domain.schemas.ts';
import type { domainServiceSpecSchema } from '../domain-service/domain-service.schemas.ts';
import type { EnsuredSecret } from '../../services/secrets/secrets.secret.ts';
import { getWithNamespace } from '../../utils/naming.ts';
import { API_VERSION } from '../../utils/consts.ts';
import { SecretService } from '../../services/secrets/secrets.ts';
import { decodeSecret } from '../../utils/secrets.ts';
import type { postgresDatabaseSecretSchema } from '../postgres-database/postgres-database.resource.ts';
import type { redisConnectionSpecSchema } from '../redis-connection/redis-connection.schemas.ts';
import { isDeepSubset } from '../../utils/objects.ts';
import { authentikServerSecretSchema, type authentikServerSpecSchema } from './authentik-server.scemas.ts';
import { createDomainService, createManifest, createServiceManifest } from './authentik-server.create-manifests.ts';
class AuthentikServerResource extends CustomResource<typeof authentikServerSpecSchema> {
#domainResource: ResourceReference<CustomResourceObject<typeof domainSpecSchema>>;
#databaseSecretResource: ResourceReference<V1Secret>;
#redisResource: ResourceReference<CustomResourceObject<typeof redisConnectionSpecSchema>>;
#redisSecretResource: ResourceReference<V1Secret>;
#deploymentServerResource: Resource<V1Deployment>;
#deploymentWorkerResource: Resource<V1Deployment>;
#service: Resource<V1Service>;
#domainServiceResource: Resource<CustomResourceObject<typeof domainServiceSpecSchema>>;
#secret: EnsuredSecret<typeof authentikServerSecretSchema>;
constructor(options: CustomResourceOptions<typeof authentikServerSpecSchema>) {
super(options);
const resourceService = this.services.get(ResourceService);
const secretService = this.services.get(SecretService);
this.#domainResource = new ResourceReference();
this.#databaseSecretResource = new ResourceReference();
this.#redisResource = new ResourceReference();
this.#redisSecretResource = new ResourceReference();
this.#deploymentServerResource = resourceService.get({
apiVersion: 'apps/v1',
kind: 'Deployment',
name: this.#serverName,
namespace: this.namespace,
});
this.#deploymentWorkerResource = resourceService.get({
apiVersion: 'apps/v1',
kind: 'Deployment',
name: this.#workerName,
namespace: this.namespace,
});
this.#domainServiceResource = resourceService.get({
apiVersion: API_VERSION,
kind: 'DomainService',
name: this.name,
namespace: this.namespace,
});
this.#service = resourceService.get({
apiVersion: 'v1',
kind: 'Service',
name: this.name,
namespace: this.namespace,
});
this.#secret = secretService.ensure({
name: `authentik-server-${this.name}`,
namespace: this.namespace,
schema: authentikServerSecretSchema,
generator: () => ({
secret: crypto.randomUUID(),
token: crypto.randomUUID(),
password: crypto.randomUUID(),
}),
});
this.#domainServiceResource = resourceService.get({
apiVersion: API_VERSION,
kind: 'DomainService',
name: this.name,
namespace: this.namespace,
});
this.#updateResources();
this.#domainResource.on('changed', this.queueReconcile);
this.#databaseSecretResource.on('changed', this.queueReconcile);
this.#redisResource.on('changed', this.queueReconcile);
this.#redisSecretResource.on('changed', this.queueReconcile);
this.#deploymentServerResource.on('changed', this.queueReconcile);
this.#deploymentWorkerResource.on('changed', this.queueReconcile);
this.#domainServiceResource.on('changed', this.queueReconcile);
this.#service.on('changed', this.queueReconcile);
this.#secret.resouce.on('changed', this.queueReconcile);
}
get #databaseSecretName() {
const { name } = getWithNamespace(this.spec.database);
return `postgres-database-${name}`;
}
get #workerName() {
return `${this.name}-worker`;
}
get #serverName() {
return `${this.name}-server`;
}
#updateResources = () => {
if (!this.isValidSpec) {
return;
}
const resourceService = this.services.get(ResourceService);
const redisNames = getWithNamespace(this.spec.redis, this.namespace);
const redisResource = resourceService.get<CustomResourceObject<typeof redisConnectionSpecSchema>>({
apiVersion: API_VERSION,
kind: 'RedisConnection',
name: redisNames.name,
namespace: redisNames.namespace,
});
this.#redisResource.current = redisResource;
const redis = this.#redisResource.current;
if (redis.exists && redis.spec) {
const redisSecretNames = getWithNamespace(redis.spec.secret, redis.namespace);
this.#redisSecretResource.current = resourceService.get({
apiVersion: 'v1',
kind: 'Secret',
name: redisSecretNames.name,
namespace: redisSecretNames.namespace,
});
} else {
this.#redisSecretResource.current = undefined;
}
const domainNames = getWithNamespace(this.spec.domain, this.namespace);
const databaseNames = getWithNamespace(this.spec.database, this.namespace);
this.#domainResource.current = resourceService.get({
apiVersion: API_VERSION,
kind: 'Domain',
name: domainNames.name,
namespace: domainNames.namespace,
});
this.#databaseSecretResource.current = resourceService.get({
apiVersion: 'v1',
kind: 'Secret',
name: this.#databaseSecretName,
namespace: databaseNames.namespace,
});
};
#reconcileWorkerDeployment = async (): Promise<SubresourceResult> => {
const domainService = this.#domainResource.current;
if (!domainService?.exists || !domainService.spec) {
return {
ready: false,
failed: true,
reason: 'MissingDomain',
};
}
const databaseSecret = decodeSecret<z.infer<typeof postgresDatabaseSecretSchema>>(
this.#databaseSecretResource.current?.data,
);
if (!databaseSecret) {
return {
ready: false,
failed: true,
reason: 'MissingDatabase',
};
}
const secret = this.#secret.value;
if (!this.#secret.isValid || !secret) {
return {
ready: false,
syncing: true,
reason: 'WaitingForSecret',
};
}
const redisSecret = decodeSecret(this.#redisSecretResource.current?.data);
if (!redisSecret || !redisSecret.host) {
return {
ready: false,
failed: true,
reason: 'MissingRedisSecret',
};
}
const email = `admin@${domainService.spec.hostname}`;
const manifest = createManifest({
name: this.#workerName,
namespace: this.namespace,
secret: secret.secret,
command: 'worker',
owner: this.ref,
bootstrap: {
email,
token: secret.token,
password: secret.password,
},
redis: {
host: redisSecret.host,
port: redisSecret.port ?? '6379',
},
posgtres: {
host: databaseSecret.host,
port: databaseSecret.port || '5432',
name: databaseSecret.database,
user: databaseSecret.user,
password: databaseSecret.password,
},
});
if (!isDeepSubset(this.#deploymentWorkerResource.spec, manifest.spec)) {
await this.#deploymentWorkerResource.patch(manifest);
return {
ready: false,
syncing: true,
reason: 'ManifestNeedsPatching',
};
}
return {
ready: true,
};
};
#reconcileServerDeployment = async (): Promise<SubresourceResult> => {
const domainService = this.#domainResource.current;
if (!domainService?.exists || !domainService.spec) {
return {
ready: false,
failed: true,
reason: 'MissingDomain',
};
}
const databaseSecret = decodeSecret<z.infer<typeof postgresDatabaseSecretSchema>>(
this.#databaseSecretResource.current?.data,
);
if (!databaseSecret) {
return {
ready: false,
failed: true,
reason: 'MissingDatabase',
};
}
const secret = this.#secret.value;
if (!this.#secret.isValid || !secret) {
return {
ready: false,
syncing: true,
reason: 'WaitingForSecret',
};
}
const redisSecret = decodeSecret(this.#redisSecretResource.current?.data);
if (!redisSecret || !redisSecret.host) {
return {
ready: false,
failed: true,
reason: 'MissingRedisSecret',
};
}
const email = `admin@${domainService.spec.hostname}`;
const manifest = createManifest({
name: this.#serverName,
namespace: this.namespace,
secret: secret.secret,
command: 'server',
owner: this.ref,
bootstrap: {
email,
token: secret.token,
password: secret.password,
},
redis: {
host: redisSecret.host,
port: redisSecret.port ?? '6379',
},
posgtres: {
host: databaseSecret.host,
port: databaseSecret.port || '5432',
name: databaseSecret.database,
user: databaseSecret.user,
password: databaseSecret.password,
},
});
if (!isDeepSubset(this.#deploymentServerResource.spec, manifest.spec)) {
await this.#deploymentServerResource.patch(manifest);
return {
ready: false,
syncing: true,
reason: 'ManifestNeedsPatching',
};
}
return {
ready: true,
};
};
#reconcileService = async (): Promise<SubresourceResult> => {
const manifest = createServiceManifest({
name: this.name,
namespace: this.namespace,
owner: this.ref,
appName: this.#serverName,
});
if (!isDeepSubset(manifest.spec, this.#service.manifest)) {
await this.#service.patch(manifest);
return {
ready: false,
syncing: true,
reason: 'UpdatingManifest',
};
}
return {
ready: true,
};
};
#reconcileDomainService = async (): Promise<SubresourceResult> => {
const manifest = createDomainService({
name: this.name,
namespace: this.namespace,
owner: this.ref,
domain: this.spec.domain,
host: `${this.name}.${this.namespace}.svc.cluster.local`,
subdomain: this.spec.subdomain,
});
if (!isDeepSubset(manifest.spec, this.#domainServiceResource.spec)) {
await this.#domainServiceResource.patch(manifest);
return {
ready: false,
syncing: true,
reason: 'UpdatingManifest',
};
}
return {
ready: true,
};
};
public reconcile = async () => {
if (!this.isValidSpec) {
await this.conditions.set('Ready', {
status: 'False',
reason: 'Invalid spec',
});
}
this.#updateResources();
await Promise.allSettled([
this.reconcileSubresource('Worker', this.#reconcileWorkerDeployment),
this.reconcileSubresource('Server', this.#reconcileServerDeployment),
this.reconcileSubresource('Service', this.#reconcileService),
this.reconcileSubresource('DomainService', this.#reconcileDomainService),
]);
const workerReady = this.conditions.get('Worker')?.status === 'True';
const serverReady = this.conditions.get('Server')?.status === 'True';
const serviceReady = this.conditions.get('Service')?.status === 'True';
const domainServiceReady = this.conditions.get('DomainService')?.status === 'True';
await this.conditions.set('Ready', {
status: workerReady && serverReady && serviceReady && domainServiceReady ? 'True' : 'False',
});
};
}
export { AuthentikServerResource };

View File

@@ -1,16 +0,0 @@
import { z } from 'zod';
const authentikServerSpecSchema = z.object({
domain: z.string(),
subdomain: z.string(),
database: z.string(),
redis: z.string(),
});
const authentikServerSecretSchema = z.object({
secret: z.string(),
password: z.string(),
token: z.string(),
});
export { authentikServerSpecSchema, authentikServerSecretSchema };

View File

@@ -0,0 +1,23 @@
import { z } from 'zod';
const authentikServerSpecSchema = z.object({
redisServer: z.string(),
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 };

View File

@@ -1,8 +1,8 @@
import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts';
import { GROUP } from '../../utils/consts.ts';
import { AuthentikServerResource } from './authentik-server.resource.ts';
import { authentikServerSpecSchema } from './authentik-server.scemas.ts';
import { authentikServerSpecSchema } from './authentik-server.schemas.ts';
import { AuthentikServerController } from './authentik-server.controller.ts';
const authentikServerDefinition = createCustomResourceDefinition({
group: GROUP,
@@ -13,7 +13,7 @@ const authentikServerDefinition = createCustomResourceDefinition({
singular: 'authentikserver',
},
spec: authentikServerSpecSchema,
create: (options) => new AuthentikServerResource(options),
create: (options) => new AuthentikServerController(options),
});
export { authentikServerDefinition };

View File

@@ -1,25 +1,21 @@
import { authentikServerDefinition } from './authentik-server/authentik-server.ts';
import { authentikClientDefinition } from './authentik-client/authentik-client.ts';
import { domainServiceDefinition } from './domain-service/domain-service.ts';
import { domainDefinition } from './domain/domain.ts';
import { postgresConnectionDefinition } from './postgres-connection/postgres-connection.ts';
import { postgresDatabaseDefinition } from './postgres-database/postgres-database.ts';
import { redisConnectionDefinition } from './redis-connection/redis-connection.ts';
import { homelabDefinition } from './homelab/homelab.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';
import { redisServerDefinition } from './redis-server/redis-server.ts';
const customResources = [
homelabDefinition,
domainDefinition,
domainServiceDefinition,
postgresClusterDefinition,
postgresConnectionDefinition,
postgresDatabaseDefinition,
redisServerDefinition,
redisConnectionDefinition,
authentikServerDefinition,
authentikClientDefinition,
generateSecretDefinition,
environmentDefinition,
postgresClusterDefinition,
authentikServerDefinition,
httpServiceDefinition,
redisServerDefinition,
];
export { customResources };

View File

@@ -1,86 +0,0 @@
import type { KubernetesObject } from '@kubernetes/client-node';
import type { K8SVirtualServiceV1 } from '../../__generated__/resources/K8SVirtualServiceV1.ts';
import type { K8SDestinationRuleV1 } from '../../__generated__/resources/K8SDestinationRuleV1.ts';
import { CONTROLLED_LABEL } from '../../utils/consts.ts';
type CreateVirtualServiceManifestOptions = {
name: string;
namespace: string;
owner: ExpectedAny;
host: string;
gateway: string;
destination: {
host: string;
port: {
number?: number;
name?: string;
};
};
};
const createVirtualServiceManifest = (
options: CreateVirtualServiceManifestOptions,
): KubernetesObject & K8SVirtualServiceV1 => ({
apiVersion: 'networking.istio.io/v1',
kind: 'VirtualService',
metadata: {
name: options.name,
namespace: options.namespace,
ownerReferences: [options.owner],
labels: {
...CONTROLLED_LABEL,
},
},
spec: {
hosts: [options.host],
gateways: [options.gateway],
http: [
{
match: [
{
uri: {
prefix: '/',
},
},
],
route: [
{
destination: {
host: options.destination.host,
port: options.destination.port,
},
},
],
},
],
},
});
type CreateDestinationRuleManifestOptions = {
name: string;
namespace: string;
host: string;
};
const createDestinationRuleManifest = (
options: CreateDestinationRuleManifestOptions,
): KubernetesObject & K8SDestinationRuleV1 => ({
apiVersion: 'networking.istio.io/v1',
kind: 'DestinationRule',
metadata: {
name: options.name,
namespace: options.namespace,
labels: {
...CONTROLLED_LABEL,
},
},
spec: {
host: options.host,
trafficPolicy: {
tls: {
mode: 'DISABLE',
},
},
},
});
export { createVirtualServiceManifest, createDestinationRuleManifest };

View File

@@ -1,169 +0,0 @@
import type { KubernetesObject } from '@kubernetes/client-node';
import deepEqual from 'deep-equal';
import type { K8SVirtualServiceV1 } from '../../__generated__/resources/K8SVirtualServiceV1.ts';
import {
CustomResource,
type CustomResourceObject,
type CustomResourceOptions,
type SubresourceResult,
} from '../../services/custom-resources/custom-resources.custom-resource.ts';
import { ResourceReference, ResourceService, type Resource } from '../../services/resources/resources.ts';
import type { K8SDestinationRuleV1 } from '../../__generated__/resources/K8SDestinationRuleV1.ts';
import type { domainSpecSchema } from '../domain/domain.schemas.ts';
import { getWithNamespace } from '../../utils/naming.ts';
import { GROUP } from '../../utils/consts.ts';
import type { domainServiceSpecSchema } from './domain-service.schemas.ts';
import { createDestinationRuleManifest, createVirtualServiceManifest } from './domain-service.create-manifests.ts';
const VIRTUAL_SERVICE_CONDITION = 'VirtualService';
const DESTINAION_RULE_CONDITION = 'DestinationRule';
class DomainServiceResource extends CustomResource<typeof domainServiceSpecSchema> {
#virtualServiceResource: Resource<KubernetesObject & K8SVirtualServiceV1>;
#virtualServiceCRDResource: Resource<KubernetesObject>;
#destinationRuleResource: Resource<KubernetesObject & K8SDestinationRuleV1>;
#destinationRuleCRDResource: Resource<KubernetesObject>;
#domainResource: ResourceReference<CustomResourceObject<typeof domainSpecSchema>>;
constructor(options: CustomResourceOptions<typeof domainServiceSpecSchema>) {
super(options);
const resourceService = this.services.get(ResourceService);
this.#virtualServiceResource = resourceService.get({
apiVersion: 'networking.istio.io/v1',
kind: 'VirtualService',
name: this.name,
namespace: this.namespace,
});
this.#virtualServiceCRDResource = resourceService.get({
apiVersion: 'apiextensions.k8s.io/v1',
kind: 'CustomResourceDefinition',
name: 'virtualservices.networking.istio.io',
});
this.#destinationRuleResource = resourceService.get({
apiVersion: 'networking.istio.io/v1',
kind: 'DestinationRule',
name: this.name,
namespace: this.namespace,
});
this.#destinationRuleCRDResource = resourceService.get({
apiVersion: 'apiextensions.k8s.io/v1',
kind: 'CustomResourceDefinition',
name: 'destinationrules.networking.istio.io',
});
const gatewayNames = getWithNamespace(this.spec.domain);
this.#domainResource = new ResourceReference(
resourceService.get({
apiVersion: `${GROUP}/v1`,
kind: 'Domain',
name: gatewayNames.name,
namespace: gatewayNames.namespace,
}),
);
this.#virtualServiceResource.on('changed', this.queueReconcile);
this.#virtualServiceCRDResource.on('changed', this.queueReconcile);
this.#destinationRuleResource.on('changed', this.queueReconcile);
this.#destinationRuleCRDResource.on('changed', this.queueReconcile);
this.#domainResource.on('changed', this.queueReconcile);
}
#reconcileVirtualService = async (): Promise<SubresourceResult> => {
if (!this.#virtualServiceCRDResource.exists) {
return {
ready: false,
failed: true,
reason: 'MissingCRD',
};
}
const domain = this.#domainResource.current;
if (!domain?.exists || !domain.spec) {
return {
ready: false,
failed: true,
reason: 'MissingDomain',
};
}
const manifest = createVirtualServiceManifest({
name: this.name,
namespace: this.namespace,
gateway: `${domain.namespace}/${domain.name}`,
owner: this.ref,
host: `${this.spec.subdomain}.${domain.spec.hostname}`,
destination: this.spec.destination,
});
if (!deepEqual(this.#virtualServiceResource.spec, manifest.spec)) {
await this.#virtualServiceResource.patch(manifest);
return {
ready: false,
syncing: true,
reason: 'ManifestNeedsPatching',
};
}
return {
ready: true,
};
};
#reconcileDestinationRule = async (): Promise<SubresourceResult> => {
if (!this.#destinationRuleCRDResource.exists) {
return {
ready: false,
failed: true,
reason: 'MissingCRD',
};
}
const manifest = createDestinationRuleManifest({
name: this.name,
namespace: this.namespace,
host: this.spec.destination.host,
});
if (!deepEqual(this.#destinationRuleResource.spec, manifest.spec)) {
await this.#destinationRuleResource.patch(manifest);
return {
ready: false,
syncing: true,
reason: 'ManifestNeedsPatching',
};
}
return {
ready: true,
};
};
public reconcile = async () => {
if (!this.exists || this.metadata.deletionTimestamp) {
return;
}
const resourceService = this.services.get(ResourceService);
const gatewayNames = getWithNamespace(this.spec.domain, this.namespace);
this.#domainResource.current = resourceService.get({
apiVersion: `${GROUP}/v1`,
kind: 'Domain',
name: gatewayNames.name,
namespace: gatewayNames.namespace,
});
await this.reconcileSubresource(VIRTUAL_SERVICE_CONDITION, this.#reconcileVirtualService);
await this.reconcileSubresource(DESTINAION_RULE_CONDITION, this.#reconcileDestinationRule);
const virtualServiceReady = this.conditions.get(VIRTUAL_SERVICE_CONDITION)?.status === 'True';
const destinationruleReady = this.conditions.get(DESTINAION_RULE_CONDITION)?.status === 'True';
await this.conditions.set('Ready', {
status: virtualServiceReady && destinationruleReady ? 'True' : 'False',
});
};
}
export { DomainServiceResource };

View File

@@ -1,15 +0,0 @@
import { z } from 'zod';
const domainServiceSpecSchema = z.object({
domain: z.string(),
subdomain: z.string(),
destination: z.object({
host: z.string(),
port: z.object({
number: z.number().optional(),
name: z.string().optional(),
}),
}),
});
export { domainServiceSpecSchema };

View File

@@ -1,19 +0,0 @@
import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts';
import { GROUP } from '../../utils/consts.ts';
import { DomainServiceResource } from './domain-service.resource.ts';
import { domainServiceSpecSchema } from './domain-service.schemas.ts';
const domainServiceDefinition = createCustomResourceDefinition({
group: GROUP,
kind: 'DomainService',
version: 'v1',
spec: domainServiceSpecSchema,
names: {
plural: 'domainservices',
singular: 'domainservice',
},
create: (options) => new DomainServiceResource(options),
});
export { domainServiceDefinition };

View File

@@ -1,73 +0,0 @@
type CreateGatewayManifestOptions = {
name: string;
namespace: string;
ref: ExpectedAny;
gateway: string;
domain: string;
secretName: string;
};
const createGatewayManifest = (options: CreateGatewayManifestOptions) => ({
apiVersion: 'networking.istio.io/v1alpha3',
kind: 'Gateway',
metadata: {
name: options.name,
namespace: options.namespace,
ownerReferences: [options.ref],
},
spec: {
selector: {
istio: options.gateway,
},
servers: [
{
port: {
number: 80,
name: 'http',
protocol: 'HTTP',
},
hosts: [`*.${options.domain}`],
tls: {
httpsRedirect: true,
},
},
{
port: {
number: 443,
name: 'https',
protocol: 'HTTPS',
},
hosts: [`*.${options.domain}`],
tls: {
mode: 'SIMPLE' as const,
credentialName: options.secretName,
},
},
],
},
});
type CreateCertificateManifestOptions = {
name: string;
namespace: string;
domain: string;
secretName: string;
issuer: string;
};
const createCertificateManifest = (options: CreateCertificateManifestOptions) => ({
apiVersion: 'cert-manager.io/v1',
kind: 'Certificate',
metadata: {
name: options.name,
namespace: 'homelab', // TODO: use namespace of gateway controller
},
spec: {
secretName: options.secretName,
dnsNames: [`*.${options.domain}`],
issuerRef: {
name: options.issuer,
kind: 'ClusterIssuer',
},
},
});
export { createGatewayManifest, createCertificateManifest };

View File

@@ -1,174 +0,0 @@
import type { KubernetesObject } from '@kubernetes/client-node';
import deepEqual from 'deep-equal';
import type { K8SGatewayV1 } from '../../__generated__/resources/K8SGatewayV1.ts';
import {
CustomResource,
type CustomResourceOptions,
type SubresourceResult,
} from '../../services/custom-resources/custom-resources.custom-resource.ts';
import { ResourceReference, ResourceService } from '../../services/resources/resources.ts';
import type { K8SCertificateV1 } from '../../__generated__/resources/K8SCertificateV1.ts';
import { IstioService } from '../../services/istio/istio.ts';
import type { domainSpecSchema } from './domain.schemas.ts';
import { createCertificateManifest, createGatewayManifest } from './domain.create-manifests.ts';
class DomainResource extends CustomResource<typeof domainSpecSchema> {
#gatewayCrdResource = new ResourceReference();
#gatewayResource = new ResourceReference<KubernetesObject & K8SGatewayV1>();
#certificateCrdResource = new ResourceReference();
#certificateResource = new ResourceReference<KubernetesObject & K8SCertificateV1>();
constructor(options: CustomResourceOptions<typeof domainSpecSchema>) {
super(options);
const resourceService = this.services.get(ResourceService);
const istioService = this.services.get(IstioService);
this.#gatewayCrdResource.current = resourceService.get({
apiVersion: 'apiextensions.k8s.io/v1',
kind: 'CustomResourceDefinition',
name: 'gateways.networking.istio.io',
});
this.#gatewayResource.current = resourceService.get({
apiVersion: 'networking.istio.io/v1',
kind: 'Gateway',
name: this.name,
namespace: this.namespace,
});
this.#certificateCrdResource.current = resourceService.get({
apiVersion: 'apiextensions.k8s.io/v1',
kind: 'CustomResourceDefinition',
name: 'certificates.cert-manager.io',
});
this.#certificateResource.current = resourceService.get({
apiVersion: 'cert-manager.io/v1',
kind: 'Certificate',
name: `domain-${this.name}`,
namespace: 'homelab',
});
this.#gatewayResource.on('changed', this.queueReconcile);
this.#certificateResource.on('changed', this.queueReconcile);
this.#gatewayCrdResource.on('changed', this.queueReconcile);
this.#certificateCrdResource.on('changed', this.queueReconcile);
istioService.gateway.on('changed', this.queueReconcile);
}
get #certSecret() {
return `cert-secret-${this.namespace}-${this.name}`;
}
#reconcileGateway = async (): Promise<SubresourceResult> => {
if (!this.#gatewayCrdResource.current?.exists) {
return {
ready: false,
failed: true,
reason: 'MissingCRD',
message: 'Missing Gateway CRD',
};
}
const istioService = this.services.get(IstioService);
if (!istioService.gateway.current) {
return {
ready: false,
failed: true,
reason: 'MissingGatewayController',
message: 'No istio gateway controller could be found',
};
}
const manifest = createGatewayManifest({
name: this.name,
namespace: this.name,
domain: this.spec.hostname,
ref: this.ref,
gateway: istioService.gateway.current.metadata?.labels?.istio || 'gateway-controller',
secretName: this.#certSecret,
});
if (!deepEqual(this.#gatewayResource.current?.spec, manifest.spec)) {
await this.#gatewayResource.current?.patch(manifest);
return {
ready: false,
syncing: true,
reason: 'ChangingGateway',
message: 'Gateway need changes',
};
}
return {
ready: true,
};
};
#reconcileCertificate = async (): Promise<SubresourceResult> => {
if (!this.#certificateCrdResource.current?.exists) {
return {
ready: false,
syncing: false,
failed: true,
reason: 'MissingCRD',
message: 'Missing Certificate CRD',
};
}
const current = this.#certificateResource.current;
if (!current || !current.namespace) {
throw new Error('Missing certificate resource');
}
const istioService = this.services.get(IstioService);
if (!istioService.gateway.current) {
return {
ready: false,
syncing: false,
failed: true,
reason: 'MissingGatewayController',
message: 'No istio gateway controller could be found',
};
}
const manifest = createCertificateManifest({
name: current.name,
namespace: istioService.gateway.current.namespace || 'default',
domain: this.spec.hostname,
secretName: this.#certSecret,
issuer: this.spec.issuer,
});
if (!this.#certificateResource.current?.exists) {
await current.patch(manifest);
return {
ready: false,
syncing: true,
reason: 'Creating',
message: 'Creating certificate resource',
};
}
if (!deepEqual(current.spec, manifest.spec)) {
await this.conditions.set('CertificateReady', {
status: 'False',
reason: 'Changing',
message: 'Certificate need changes',
});
await current.patch(manifest);
}
return {
ready: true,
};
};
public reconcile = async () => {
if (!this.exists || this.metadata.deletionTimestamp) {
return;
}
await this.reconcileSubresource('Gateway', this.#reconcileGateway);
await this.reconcileSubresource('Certificate', this.#reconcileCertificate);
const gatewayReady = this.conditions.get('Gateway')?.status === 'True';
const certificateReady = this.conditions.get('Certificate')?.status === 'True';
await this.conditions.set('Ready', {
status: gatewayReady && certificateReady ? 'True' : 'False',
});
};
}
export { DomainResource };

View File

@@ -1,8 +0,0 @@
import { z } from 'zod';
const domainSpecSchema = z.object({
hostname: z.string(),
issuer: z.string(),
});
export { domainSpecSchema };

View File

@@ -1,19 +0,0 @@
import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts';
import { GROUP } from '../../utils/consts.ts';
import { DomainResource } from './domain.resource.ts';
import { domainSpecSchema } from './domain.schemas.ts';
const domainDefinition = createCustomResourceDefinition({
version: 'v1',
kind: 'Domain',
group: GROUP,
names: {
plural: 'domains',
singular: 'domain',
},
spec: domainSpecSchema,
create: (options) => new DomainResource(options),
});
export { domainDefinition };

View File

@@ -0,0 +1,224 @@
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 { RedisServerInstance } from '../../instances/redis-server.ts';
import { NamespaceService } from '../../bootstrap/namespaces/namespaces.ts';
import type { environmentSpecSchema } from './environment.schemas.ts';
class EnvironmentController extends CustomResource<typeof environmentSpecSchema> {
#namespace: NamespaceInstance;
#certificateCrd: CustomDefinitionInstance;
#certificate: CertificateInstance;
#gatewayCrd: CustomDefinitionInstance;
#gateway: GatewayInstance;
#storageClass: StorageClassInstance;
#postgresCluster: PostgresClusterInstance;
#authentikServer: AuthentikServerInstance;
#redisServer: RedisServerInstance;
constructor(options: CustomResourceOptions<typeof environmentSpecSchema>) {
super(options);
const resourceService = this.services.get(ResourceService);
const namespaceService = this.services.get(NamespaceService);
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}-tls`,
namespace: namespaceService.homelab.name,
},
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.#redisServer = resourceService.getInstance(
{
apiVersion: API_VERSION,
kind: 'RedisServer',
name: `${this.name}-redis-server`,
namespace: this.namespace,
},
RedisServerInstance,
);
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);
this.#redisServer.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({
spec: {
secretName: `${this.name}-tls`,
issuerRef: {
name: this.spec.tls.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: 'homelab-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`,
redisServer: `${this.name}-redis-server`,
},
});
await this.#redisServer.ensure({
metadata: {
ownerReferences: [this.ref],
},
spec: {},
});
}
};
}
export { EnvironmentController };

View File

@@ -0,0 +1,17 @@
import { z } from 'zod';
const environmentSpecSchema = z.object({
domain: z.string(),
tls: z.object({
issuer: z.string(),
}),
storage: z
.object({
location: z.string().optional(),
})
.optional(),
});
type EnvironmentSpec = z.infer<typeof environmentSpecSchema>;
export { environmentSpecSchema, type EnvironmentSpec };

View File

@@ -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 };

View File

@@ -0,0 +1,61 @@
import type { V1Secret } from '@kubernetes/client-node';
import {
CustomResource,
type CustomResourceOptions,
} from '../../services/custom-resources/custom-resources.custom-resource.ts';
import { Resource, ResourceService } from '../../services/resources/resources.ts';
import { decodeSecret, encodeSecret } from '../../utils/secrets.ts';
import { isDeepSubset } from '../../utils/objects.ts';
import { generateSecrets } from './generate-secret.utils.ts';
import { generateSecretSpecSchema } from './generate-secret.schemas.ts';
class GenerateSecretResource extends CustomResource<typeof generateSecretSpecSchema> {
#secretResource: Resource<V1Secret>;
constructor(options: CustomResourceOptions<typeof generateSecretSpecSchema>) {
super(options);
const resourceService = this.services.get(ResourceService);
this.#secretResource = resourceService.get({
apiVersion: 'v1',
kind: 'Secret',
name: this.name,
namespace: this.namespace,
});
this.#secretResource.on('changed', this.queueReconcile);
}
public reconcile = async () => {
if (!this.exists || this.metadata?.deletionTimestamp) {
return;
}
const secrets = generateSecrets(this.spec);
const current = decodeSecret(this.#secretResource.data) || {};
const expected = {
...secrets,
...current,
};
if (!isDeepSubset(current, expected)) {
this.#secretResource.patch({
data: encodeSecret(expected),
});
this.conditions.set('SecretUpdated', {
status: 'False',
reason: 'SecretUpdated',
});
}
this.conditions.set('Ready', {
status: 'True',
reason: 'Ready',
});
};
}
export { GenerateSecretResource };

View File

@@ -0,0 +1,17 @@
import { z } from 'zod';
const generateSecretFieldSchema = z.object({
name: z.string(),
value: z.string().optional(),
encoding: z.enum(['base64', 'base64url', 'hex', 'utf8', 'numeric']).optional(),
length: z.number().optional(),
});
const generateSecretSpecSchema = z.object({
fields: z.array(generateSecretFieldSchema),
});
type GenerateSecretField = z.infer<typeof generateSecretFieldSchema>;
type GenerateSecretSpec = z.infer<typeof generateSecretSpecSchema>;
export { generateSecretSpecSchema, type GenerateSecretField, type GenerateSecretSpec };

View File

@@ -0,0 +1,19 @@
import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts';
import { GROUP } from '../../utils/consts.ts';
import { GenerateSecretResource } from './generate-secret.resource.ts';
import { generateSecretSpecSchema } from './generate-secret.schemas.ts';
const generateSecretDefinition = createCustomResourceDefinition({
group: GROUP,
version: 'v1',
kind: 'GenerateSecret',
names: {
plural: 'generate-secrets',
singular: 'generate-secret',
},
spec: generateSecretSpecSchema,
create: (options) => new GenerateSecretResource(options),
});
export { generateSecretDefinition };

View File

@@ -0,0 +1,69 @@
import * as crypto from 'crypto';
import type { GenerateSecretField, GenerateSecretSpec } from './generate-secret.schemas.ts';
const generateRandomString = (length: number, encoding: GenerateSecretField['encoding']): string => {
let byteLength = 0;
switch (encoding) {
case 'base64':
case 'base64url':
// Base64 uses 4 characters for every 3 bytes, so we'll generate slightly more bytes
// than the final length to ensure we can get a string of at least the required length.
byteLength = Math.ceil((length * 3) / 4);
break;
case 'hex':
byteLength = Math.ceil(length / 2);
break;
case 'numeric':
case 'utf8':
byteLength = length;
break;
}
const randomBytes = crypto.randomBytes(byteLength);
let resultString = '';
switch (encoding) {
case 'base64':
resultString = randomBytes.toString('base64');
break;
case 'base64url':
resultString = randomBytes.toString('base64url');
break;
case 'hex':
resultString = randomBytes.toString('hex');
break;
case 'numeric':
resultString = Array.from(randomBytes)
.map((b) => (b % 10).toString()) // Get a single digit from each byte
.join('');
break;
case 'utf8':
resultString = randomBytes.toString('utf8');
break;
}
return resultString.slice(0, length);
};
const generateSecrets = (spec: GenerateSecretSpec): Record<string, string> => {
const secrets: Record<string, string> = {};
for (const field of spec.fields) {
if (field.value !== undefined) {
// If a value is provided, use it directly.
secrets[field.name] = field.value;
} else {
// Generate a new secret based on the specification.
// Use default values if encoding or length are not provided.
const encoding = field.encoding || 'base64url';
const length = field.length || 32;
secrets[field.name] = generateRandomString(length, encoding);
}
}
return secrets;
};
export { generateRandomString, generateSecrets };

View File

@@ -1,297 +0,0 @@
import type { KubernetesObject } from '@kubernetes/client-node';
import type { K8SHelmRepositoryV1 } from '../../__generated__/resources/K8SHelmRepositoryV1.ts';
import type { K8SHelmReleaseV2 } from '../../__generated__/resources/K8SHelmReleaseV2.ts';
type IstioRepoManifestOptions = {
owner: ExpectedAny;
};
const istioRepoManifest = (options: IstioRepoManifestOptions): KubernetesObject & K8SHelmRepositoryV1 => {
return {
apiVersion: 'source.toolkit.fluxcd.io/v1beta1',
kind: 'HelmRepository',
metadata: {
ownerReferences: [options.owner],
},
spec: {
interval: '1h',
url: 'https://istio-release.storage.googleapis.com/charts',
},
};
};
type CertManagerRepoManifestOptions = {
owner: ExpectedAny;
};
const certManagerRepoManifest = (options: CertManagerRepoManifestOptions): KubernetesObject & K8SHelmRepositoryV1 => {
return {
apiVersion: 'source.toolkit.fluxcd.io/v1',
kind: 'HelmRepository',
metadata: {
ownerReferences: [options.owner],
},
spec: {
interval: '1h',
url: 'https://charts.jetstack.io',
},
};
};
type RanchRepoManifestOptions = {
owner: ExpectedAny;
};
const ranchRepoManifest = (options: RanchRepoManifestOptions): KubernetesObject & K8SHelmRepositoryV1 => {
return {
apiVersion: 'source.toolkit.fluxcd.io/v1',
kind: 'HelmRepository',
metadata: {
ownerReferences: [options.owner],
},
spec: {
interval: '1h',
url: 'https://charts.containeroo.ch',
},
};
};
type IstioBaseManifestOptions = {
owner: ExpectedAny;
};
const istioBaseManifest = (options: IstioBaseManifestOptions): KubernetesObject & K8SHelmReleaseV2 => {
return {
apiVersion: 'helm.toolkit.fluxcd.io/v2',
kind: 'HelmRelease',
metadata: {
ownerReferences: [options.owner],
},
spec: {
interval: '1h',
targetNamespace: 'istio-system',
install: {
createNamespace: true,
},
values: {
defaultRevision: 'default',
},
chart: {
spec: {
chart: 'base',
sourceRef: {
apiVersion: 'source.toolkit.fluxcd.io/v1',
kind: 'HelmRepository',
name: 'homelab-istio',
},
reconcileStrategy: 'ChartVersion',
version: '1.24.3',
},
},
},
};
};
type IstiodManifestOptions = {
owner: ExpectedAny;
namespace: string;
};
const istiodManifest = (options: IstiodManifestOptions): KubernetesObject & K8SHelmReleaseV2 => {
return {
apiVersion: 'helm.toolkit.fluxcd.io/v2',
kind: 'HelmRelease',
metadata: {
ownerReferences: [options.owner],
},
spec: {
targetNamespace: 'istio-system',
interval: '1h',
install: {
createNamespace: true,
},
dependsOn: [
{
name: 'istio',
namespace: options.namespace,
},
],
chart: {
spec: {
chart: 'istiod',
sourceRef: {
apiVersion: 'source.toolkit.fluxcd.io/v1',
kind: 'HelmRepository',
name: 'homelab-istio',
},
reconcileStrategy: 'ChartVersion',
version: '1.24.3',
},
},
},
};
};
type IstioGatewayControllerManifestOptions = {
owner: ExpectedAny;
namespace: string;
};
const istioGatewayControllerManifest = (
options: IstioGatewayControllerManifestOptions,
): KubernetesObject & K8SHelmReleaseV2 => {
return {
apiVersion: 'helm.toolkit.fluxcd.io/v2',
kind: 'HelmRelease',
metadata: {
ownerReferences: [options.owner],
},
spec: {
interval: '1h',
install: {
createNamespace: true,
},
dependsOn: [
{
name: 'istio',
namespace: options.namespace,
},
{
name: 'istiod',
namespace: options.namespace,
},
],
values: {
service: {
ports: [
{
name: 'status-port',
port: 15021,
},
{
name: 'tls-istiod',
port: 15012,
},
{
name: 'tls',
port: 15443,
nodePort: 31371,
},
{
name: 'http2',
port: 80,
nodePort: 31381,
targetPort: 8280,
},
{
name: 'https',
port: 443,
nodePort: 31391,
targetPort: 8243,
},
],
},
},
chart: {
spec: {
chart: 'gateway',
sourceRef: {
apiVersion: 'source.toolkit.fluxcd.io/v1',
kind: 'HelmRepository',
name: 'homelab-istio',
},
reconcileStrategy: 'ChartVersion',
version: '1.24.3',
},
},
},
};
};
type CertManagerManifestOptions = {
owner: ExpectedAny;
};
const certManagerManifest = (options: CertManagerManifestOptions): KubernetesObject & K8SHelmReleaseV2 => {
return {
apiVersion: 'helm.toolkit.fluxcd.io/v2',
kind: 'HelmRelease',
metadata: {
ownerReferences: [options.owner],
},
spec: {
targetNamespace: 'cert-manager',
interval: '1h',
install: {
createNamespace: true,
},
values: {
installCRDs: true,
},
chart: {
spec: {
chart: 'cert-manager',
sourceRef: {
apiVersion: 'source.toolkit.fluxcd.io/v1',
kind: 'HelmRepository',
name: 'cert-manager',
},
version: 'v1.18.2',
},
},
},
};
};
type LocalStorageManifestOptions = {
owner: ExpectedAny;
storagePath: string;
};
const localStorageManifest = (options: LocalStorageManifestOptions): KubernetesObject & K8SHelmReleaseV2 => {
return {
apiVersion: 'helm.toolkit.fluxcd.io/v2',
kind: 'HelmRelease',
metadata: {
ownerReferences: [options.owner],
},
spec: {
targetNamespace: 'local-path-storage',
interval: '1h',
install: {
createNamespace: true,
},
values: {
storageClass: {
name: 'local-path',
provisionerName: 'rancher.io/local-path',
defaultClass: true,
},
nodePathMap: [
{
node: 'DEFAULT_PATH_FOR_NON_LISTED_NODES',
paths: [options.storagePath],
},
],
helper: {
reclaimPolicy: 'Retain',
},
},
chart: {
spec: {
chart: 'local-path-provisioner',
sourceRef: {
apiVersion: 'source.toolkit.fluxcd.io/v1',
kind: 'HelmRepository',
name: 'rancher',
},
version: '0.0.32',
},
},
},
};
};
export {
istioRepoManifest,
istioBaseManifest,
istiodManifest,
istioGatewayControllerManifest,
certManagerRepoManifest,
certManagerManifest,
ranchRepoManifest,
localStorageManifest,
};

View File

@@ -1,263 +0,0 @@
import { type KubernetesObject } from '@kubernetes/client-node';
import {
CustomResource,
type CustomResourceOptions,
type SubresourceResult,
} from '../../services/custom-resources/custom-resources.custom-resource.ts';
import { Resource, ResourceService } from '../../services/resources/resources.ts';
import type { K8SHelmRepositoryV1 } from '../../__generated__/resources/K8SHelmRepositoryV1.ts';
import type { K8SHelmReleaseV2 } from '../../__generated__/resources/K8SHelmReleaseV2.ts';
import { isDeepSubset } from '../../utils/objects.ts';
import type { homelabSpecSchema } from './homelab.schemas.ts';
import {
certManagerRepoManifest,
istioBaseManifest,
istiodManifest,
istioGatewayControllerManifest,
istioRepoManifest,
certManagerManifest,
ranchRepoManifest,
localStorageManifest,
} from './homelab.manifests.ts';
class HomelabResource extends CustomResource<typeof homelabSpecSchema> {
#resources: {
istioRepo: Resource<KubernetesObject & K8SHelmRepositoryV1>;
istioBase: Resource<KubernetesObject & K8SHelmReleaseV2>;
istiod: Resource<KubernetesObject & K8SHelmReleaseV2>;
istioGatewayController: Resource<KubernetesObject & K8SHelmReleaseV2>;
certManagerRepo: Resource<KubernetesObject & K8SHelmRepositoryV1>;
certManager: Resource<KubernetesObject & K8SHelmReleaseV2>;
ranchRepo: Resource<KubernetesObject & K8SHelmRepositoryV1>;
localStorage: Resource<KubernetesObject & K8SHelmReleaseV2>;
};
constructor(options: CustomResourceOptions<typeof homelabSpecSchema>) {
super(options);
const resourceService = this.services.get(ResourceService);
this.#resources = {
istioRepo: resourceService.get({
apiVersion: 'source.toolkit.fluxcd.io/v1',
kind: 'HelmRepository',
name: 'homelab-istio',
namespace: this.namespace,
}),
istioBase: resourceService.get({
apiVersion: 'helm.toolkit.fluxcd.io/v2',
kind: 'HelmRelease',
name: 'istio',
namespace: this.namespace,
}),
istiod: resourceService.get({
apiVersion: 'helm.toolkit.fluxcd.io/v2',
kind: 'HelmRelease',
name: 'istiod',
namespace: this.namespace,
}),
istioGatewayController: resourceService.get({
apiVersion: 'helm.toolkit.fluxcd.io/v2',
kind: 'HelmRelease',
name: 'istio-gateway-controller',
namespace: this.namespace,
}),
certManagerRepo: resourceService.get({
apiVersion: 'source.toolkit.fluxcd.io/v1',
kind: 'HelmRepository',
name: 'cert-manager',
namespace: this.namespace,
}),
certManager: resourceService.get({
apiVersion: 'helm.toolkit.fluxcd.io/v2',
kind: 'HelmRelease',
name: 'cert-manager',
namespace: this.namespace,
}),
ranchRepo: resourceService.get({
apiVersion: 'source.toolkit.fluxcd.io/v1',
kind: 'HelmRepository',
name: 'rancher',
namespace: this.namespace,
}),
localStorage: resourceService.get({
apiVersion: 'helm.toolkit.fluxcd.io/v2',
kind: 'HelmRelease',
name: 'local-storage',
namespace: this.namespace,
}),
};
for (const resource of Object.values(this.#resources)) {
resource.on('changed', this.queueReconcile);
}
}
#reconcileIstioRepo = async (): Promise<SubresourceResult> => {
const istioRepo = this.#resources.istioRepo;
const manifest = istioRepoManifest({
owner: this.ref,
});
if (!isDeepSubset(istioRepo.spec, manifest.spec)) {
await istioRepo.patch(manifest);
return {
ready: false,
syncing: true,
reason: 'UpdatingManifest',
};
}
return {
ready: true,
};
};
#reconcileCertManagerRepo = async (): Promise<SubresourceResult> => {
const certManagerRepo = this.#resources.certManagerRepo;
const manifest = certManagerRepoManifest({
owner: this.ref,
});
if (!isDeepSubset(certManagerRepo.spec, manifest.spec)) {
await certManagerRepo.patch(manifest);
return {
ready: false,
syncing: true,
reason: 'UpdatingManifest',
};
}
return {
ready: true,
};
};
#reconcileRanchRepo = async (): Promise<SubresourceResult> => {
const ranchRepo = this.#resources.ranchRepo;
const manifest = ranchRepoManifest({
owner: this.ref,
});
if (!isDeepSubset(ranchRepo.spec, manifest.spec)) {
await ranchRepo.patch(manifest);
return {
ready: false,
syncing: true,
reason: 'UpdatingManifest',
};
}
return {
ready: true,
};
};
#reconcileIstioBase = async (): Promise<SubresourceResult> => {
const istioBase = this.#resources.istioBase;
const manifest = istioBaseManifest({
owner: this.ref,
});
if (!isDeepSubset(istioBase.spec, manifest.spec)) {
await istioBase.patch(manifest);
return {
ready: false,
syncing: true,
reason: 'UpdatingManifest',
};
}
return {
ready: true,
};
};
#reconcileIstiod = async (): Promise<SubresourceResult> => {
const istiod = this.#resources.istiod;
const manifest = istiodManifest({
owner: this.ref,
namespace: this.namespace,
});
if (!isDeepSubset(istiod.spec, manifest.spec)) {
await istiod.patch(manifest);
return {
ready: false,
syncing: true,
reason: 'UpdatingManifest',
};
}
return {
ready: true,
};
};
#reconcileIstioGatewayController = async (): Promise<SubresourceResult> => {
const istioGatewayController = this.#resources.istioGatewayController;
const manifest = istioGatewayControllerManifest({
owner: this.ref,
namespace: this.namespace,
});
if (!isDeepSubset(istioGatewayController.spec, manifest.spec)) {
await istioGatewayController.patch(manifest);
return {
ready: false,
syncing: true,
reason: 'UpdatingManifest',
};
}
return {
ready: true,
};
};
#reconcileCertManager = async (): Promise<SubresourceResult> => {
const certManager = this.#resources.certManager;
const manifest = certManagerManifest({
owner: this.ref,
});
if (!isDeepSubset(certManager.spec, manifest.spec)) {
await certManager.patch(manifest);
return {
ready: false,
syncing: true,
reason: 'UpdatingManifest',
};
}
return {
ready: true,
};
};
#reconcileLocalStorage = async (): Promise<SubresourceResult> => {
const storage = this.spec.storage;
if (!storage || !storage.enabled) {
return {
ready: true,
};
}
const localStorage = this.#resources.localStorage;
const manifest = localStorageManifest({
owner: this.ref,
storagePath: storage.path,
});
if (!isDeepSubset(localStorage.spec, manifest.spec)) {
await localStorage.patch(manifest);
return {
ready: false,
syncing: true,
reason: 'UpdatingManifest',
};
}
return {
ready: true,
};
};
public reconcile = async () => {
await Promise.allSettled([
this.reconcileSubresource('IstioRepo', this.#reconcileIstioRepo),
this.reconcileSubresource('CertManagerRepo', this.#reconcileCertManagerRepo),
this.reconcileSubresource('IstioBase', this.#reconcileIstioBase),
this.reconcileSubresource('Istiod', this.#reconcileIstiod),
this.reconcileSubresource('IstioGatewayController', this.#reconcileIstioGatewayController),
this.reconcileSubresource('CertManager', this.#reconcileCertManager),
this.reconcileSubresource('RanchRepo', this.#reconcileRanchRepo),
this.reconcileSubresource('LocalStorage', this.#reconcileLocalStorage),
]);
};
}
export { HomelabResource };

View File

@@ -1,17 +0,0 @@
import { z } from 'zod';
const homelabSpecSchema = z.object({
storage: z
.object({
enabled: z.boolean(),
path: z.string(),
})
.optional(),
});
const homelabSecretSchema = z.object({
postgresPassword: z.string(),
redisPassword: z.string(),
});
export { homelabSpecSchema, homelabSecretSchema };

View File

@@ -1,19 +0,0 @@
import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts';
import { GROUP } from '../../utils/consts.ts';
import { HomelabResource } from './homelab.resource.ts';
import { homelabSpecSchema } from './homelab.schemas.ts';
const homelabDefinition = createCustomResourceDefinition({
group: GROUP,
version: 'v1',
kind: 'Homelab',
names: {
plural: 'homelabs',
singular: 'homelab',
},
spec: homelabSpecSchema,
create: (options) => new HomelabResource(options),
});
export { homelabDefinition };

View File

@@ -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<typeof httpServiceSpecSchema> {
#environment: ResourceReference<CustomResourceObject<typeof environmentSpecSchema>>;
#virtualService: VirtualServiceInstance;
#destinationRule: DestinationRuleInstance;
constructor(options: CustomResourceOptions<typeof httpServiceSpecSchema>) {
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}/${this.#environment.current.name}`],
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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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<typeof postgresClusterSpecSchema> {
#statefulSet: StatefulSetInstance;
#headlessService: ServiceInstance;
#service: ServiceInstance;
#secret: EnsuredSecret<typeof postgresClusterSecretSchema>;
constructor(options: CustomResourceOptions<typeof postgresClusterSpecSchema>) {
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 };

View File

@@ -1,124 +0,0 @@
import type { V1Deployment, V1PersistentVolumeClaim, V1Service } from '@kubernetes/client-node';
import type { CustomResourceObject } from '../../services/custom-resources/custom-resources.custom-resource.ts';
import type { postgresConnectionSpecSchema } from '../postgres-connection/posgtres-connection.schemas.ts';
import { API_VERSION } from '../../utils/consts.ts';
type PvcOptions = {
name: string;
owner: ExpectedAny;
};
const pvcManifest = (options: PvcOptions): V1PersistentVolumeClaim => {
return {
apiVersion: 'v1',
kind: 'PersistentVolumeClaim',
metadata: {
ownerReferences: [options.owner],
name: options.name,
labels: {
app: options.name,
},
annotations: {
'volume.kubernetes.io/storage-class': 'local-path',
},
},
spec: {
accessModes: ['ReadWriteOnce'],
resources: {
requests: {
storage: '10Gi',
},
},
},
};
};
type DeploymentManifetOptions = {
name: string;
owner: ExpectedAny;
user: string;
password: string;
};
const deploymentManifest = (options: DeploymentManifetOptions): V1Deployment => {
return {
apiVersion: 'apps/v1',
kind: 'Deployment',
metadata: {
ownerReferences: [options.owner],
},
spec: {
replicas: 1,
selector: {
matchLabels: {
app: options.name,
},
},
template: {
metadata: {
labels: {
app: options.name,
},
},
spec: {
volumes: [{ name: options.name, persistentVolumeClaim: { claimName: options.name } }],
containers: [
{
name: options.name,
image: 'postgres:17',
ports: [{ containerPort: 5432 }],
volumeMounts: [{ mountPath: '/var/lib/postgresql/data', name: options.name }],
env: [
{ name: 'POSTGRES_USER', value: options.user },
{ name: 'POSTGRES_PASSWORD', value: options.password },
],
},
],
},
},
},
};
};
type ServiceManifestOptions = {
name: string;
owner: ExpectedAny;
};
const serviceManifest = (options: ServiceManifestOptions): V1Service => {
return {
apiVersion: 'v1',
kind: 'Service',
metadata: {
ownerReferences: [options.owner],
name: options.name,
labels: {
app: options.name,
},
},
spec: {
type: 'ClusterIP',
ports: [{ port: 5432, targetPort: 5432 }],
selector: {
app: options.name,
},
},
};
};
type ConnectionManifestOptions = {
name: string;
owner: ExpectedAny;
};
const connectionManifest = (
options: ConnectionManifestOptions,
): CustomResourceObject<typeof postgresConnectionSpecSchema> => ({
apiVersion: API_VERSION,
kind: 'PostgresConnection',
metadata: {
ownerReferences: [options.owner],
},
spec: {
secret: `${options.name}-secret`,
},
});
export { pvcManifest, deploymentManifest, serviceManifest, connectionManifest };

View File

@@ -1,170 +0,0 @@
import type { V1Deployment, V1PersistentVolumeClaim, V1Service } from '@kubernetes/client-node';
import {
CustomResource,
type CustomResourceObject,
type CustomResourceOptions,
type SubresourceResult,
} from '../../services/custom-resources/custom-resources.custom-resource.ts';
import { ResourceService, type Resource } from '../../services/resources/resources.ts';
import {
postgresConnectionSecretDataSchema,
type postgresConnectionSpecSchema,
} from '../postgres-connection/posgtres-connection.schemas.ts';
import { API_VERSION } from '../../utils/consts.ts';
import { isDeepSubset } from '../../utils/objects.ts';
import type { EnsuredSecret } from '../../services/secrets/secrets.secret.ts';
import { SecretService } from '../../services/secrets/secrets.ts';
import type { postgresClusterSpecSchema } from './postgres-cluster.schemas.ts';
import { connectionManifest, deploymentManifest, pvcManifest, serviceManifest } from './postgres-cluster.manifests.ts';
class PostgresClusterResource extends CustomResource<typeof postgresClusterSpecSchema> {
#resources: {
pvc: Resource<V1PersistentVolumeClaim>;
deployment: Resource<V1Deployment>;
service: Resource<V1Service>;
connection: Resource<CustomResourceObject<typeof postgresConnectionSpecSchema>>;
secret: EnsuredSecret<typeof postgresConnectionSecretDataSchema>;
};
constructor(options: CustomResourceOptions<typeof postgresClusterSpecSchema>) {
super(options);
const resourceService = this.services.get(ResourceService);
const secretService = this.services.get(SecretService);
this.#resources = {
pvc: resourceService.get({
apiVersion: 'v1',
kind: 'PersistentVolumeClaim',
name: this.name,
namespace: this.namespace,
}),
deployment: resourceService.get({
apiVersion: 'apps/v1',
kind: 'Deployment',
name: this.name,
namespace: this.namespace,
}),
service: resourceService.get({
apiVersion: 'v1',
kind: 'Service',
name: this.name,
namespace: this.namespace,
}),
connection: resourceService.get({
apiVersion: API_VERSION,
kind: 'PostgresConnection',
name: this.name,
namespace: this.namespace,
}),
secret: secretService.ensure({
name: `${this.name}-secret`,
namespace: this.namespace,
schema: postgresConnectionSecretDataSchema,
generator: () => ({
host: `${this.name}.${this.namespace}.svc.cluster.local`,
port: '5432',
user: 'postgres',
password: Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString('hex'),
}),
}),
};
}
#reconcilePvc = async (): Promise<SubresourceResult> => {
const pvc = this.#resources.pvc;
const manifest = pvcManifest({
name: this.name,
owner: this.ref,
});
if (!isDeepSubset(pvc.spec, manifest.spec)) {
await pvc.patch(manifest);
return {
ready: false,
syncing: true,
reason: 'UpdatingManifest',
};
}
return {
ready: true,
};
};
#reconcileDeployment = async (): Promise<SubresourceResult> => {
const secret = this.#resources.secret;
if (!secret.isValid || !secret.value) {
return {
ready: false,
syncing: true,
reason: 'SecretNotReady',
};
}
const deployment = this.#resources.deployment;
const manifest = deploymentManifest({
name: this.name,
owner: this.ref,
user: secret.value.user,
password: secret.value.password,
});
if (!isDeepSubset(deployment.spec, manifest.spec)) {
await deployment.patch(manifest);
return {
ready: false,
syncing: true,
reason: 'UpdatingManifest',
};
}
return {
ready: true,
};
};
#reconcileService = async (): Promise<SubresourceResult> => {
const service = this.#resources.service;
const manifest = serviceManifest({
name: this.name,
owner: this.ref,
});
if (!isDeepSubset(service.spec, manifest.spec)) {
await service.patch(manifest);
return {
ready: false,
syncing: true,
reason: 'UpdatingManifest',
};
}
return {
ready: true,
};
};
#reconcileConnection = async (): Promise<SubresourceResult> => {
const connection = this.#resources.connection;
const manifest = connectionManifest({
name: this.name,
owner: this.ref,
});
if (!isDeepSubset(connection.spec, manifest.spec)) {
await connection.patch(manifest);
return {
ready: false,
syncing: true,
reason: 'UpdatingManifest',
};
}
return {
ready: true,
};
};
public reconcile = async () => {
await Promise.allSettled([
this.reconcileSubresource('PVC', this.#reconcilePvc),
this.reconcileSubresource('Deployment', this.#reconcileDeployment),
this.reconcileSubresource('Service', this.#reconcileService),
this.reconcileSubresource('Connection', this.#reconcileConnection),
]);
};
}
export { PostgresClusterResource };

View File

@@ -1,5 +1,20 @@
import { z } from 'zod';
const postgresClusterSpecSchema = z.object({});
const postgresClusterSpecSchema = z.object({
environment: z.string(),
storage: z
.object({
size: z.string().optional(),
})
.optional(),
});
export { postgresClusterSpecSchema };
const postgresClusterSecretSchema = z.object({
database: z.string(),
host: z.string(),
port: z.string(),
username: z.string(),
password: z.string(),
});
export { postgresClusterSpecSchema, postgresClusterSecretSchema };

View File

@@ -1,19 +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';
import { PostgresClusterResource } from './postgres-cluster.resource.ts';
const postgresClusterDefinition = createCustomResourceDefinition({
group: GROUP,
version: 'v1',
kind: 'PostgresCluster',
names: {
plural: 'postgresclusters',
singular: 'postgrescluster',
plural: 'postgres-clusters',
singular: 'postgres-cluster',
},
spec: postgresClusterSpecSchema,
create: (options) => new PostgresClusterResource(options),
create: (options) => new PostgresClusterController(options),
});
export { postgresClusterDefinition };

View File

@@ -1,14 +0,0 @@
import { z } from 'zod';
const postgresConnectionSpecSchema = z.object({
secret: z.string(),
});
const postgresConnectionSecretDataSchema = z.object({
host: z.string(),
port: z.string().optional(),
user: z.string(),
password: z.string(),
});
export { postgresConnectionSpecSchema, postgresConnectionSecretDataSchema };

View File

@@ -1,94 +0,0 @@
import type { V1Secret } from '@kubernetes/client-node';
import type { z } from 'zod';
import {
CustomResource,
type CustomResourceOptions,
} 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 { getWithNamespace } from '../../utils/naming.ts';
import { PostgresService } from '../../services/postgres/postgres.service.ts';
import { decodeSecret } from '../../utils/secrets.ts';
import type {
postgresConnectionSecretDataSchema,
postgresConnectionSpecSchema,
} from './posgtres-connection.schemas.ts';
class PostgresConnectionResource extends CustomResource<typeof postgresConnectionSpecSchema> {
#secret: ResourceReference<V1Secret>;
constructor(options: CustomResourceOptions<typeof postgresConnectionSpecSchema>) {
super(options);
const resourceService = this.services.get(ResourceService);
const secretNames = getWithNamespace(this.spec.secret, this.namespace);
this.#secret = new ResourceReference<V1Secret>(
resourceService.get({
apiVersion: 'v1',
kind: 'Secret',
name: secretNames.name,
namespace: secretNames.namespace,
}),
);
this.#secret.on('changed', this.queueReconcile);
}
public reconcile = async () => {
const resourceService = this.services.get(ResourceService);
const secretNames = getWithNamespace(this.spec.secret, this.namespace);
this.#secret.current = resourceService.get({
apiVersion: 'v1',
kind: 'Secret',
name: secretNames.name,
namespace: secretNames.namespace,
});
const current = this.#secret.current;
if (!current?.exists || !current.data) {
return this.conditions.set('Ready', {
status: 'False',
reason: 'MissingSecret',
});
}
const { host, user, password, port } = decodeSecret<z.infer<typeof postgresConnectionSecretDataSchema>>(
current.data,
)!;
if (!host) {
return this.conditions.set('Ready', {
status: 'False',
reason: 'MissingHost',
});
}
if (!user) {
return this.conditions.set('Ready', {
status: 'False',
reason: 'MissingUser',
});
}
if (!password) {
return this.conditions.set('Ready', {
status: 'False',
reason: 'MissingPassword',
});
}
const postgresService = this.services.get(PostgresService);
const database = postgresService.get({
host,
user,
port: port ? Number(port) : 5432,
password,
});
if (!(await database.ping())) {
return this.conditions.set('Ready', {
status: 'False',
reason: 'CanNotConnectToDatabase',
});
}
await this.conditions.set('Ready', {
status: 'True',
});
};
}
export { PostgresConnectionResource };

View File

@@ -1,19 +0,0 @@
import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts';
import { GROUP } from '../../utils/consts.ts';
import { postgresConnectionSpecSchema } from './posgtres-connection.schemas.ts';
import { PostgresConnectionResource } from './postgres-connection.resource.ts';
const postgresConnectionDefinition = createCustomResourceDefinition({
group: GROUP,
version: 'v1',
kind: 'PostgresConnection',
names: {
plural: 'postgresconnections',
singular: 'postgresconnection',
},
spec: postgresConnectionSpecSchema,
create: (options) => new PostgresConnectionResource(options),
});
export { postgresConnectionDefinition };

View File

@@ -1,7 +1,7 @@
import { z } from 'zod';
const postgresDatabaseSpecSchema = z.object({
connection: z.string(),
cluster: z.string(),
});
export { postgresDatabaseSpecSchema };

View File

@@ -1,62 +1,41 @@
import { z } from 'zod';
import type { V1Secret } from '@kubernetes/client-node';
import {
CustomResource,
type CustomResourceObject,
type CustomResourceOptions,
type SubresourceResult,
} from '../../services/custom-resources/custom-resources.custom-resource.ts';
import { PostgresService } from '../../services/postgres/postgres.service.ts';
import {
postgresConnectionSecretDataSchema,
type postgresConnectionSpecSchema,
} from '../postgres-connection/posgtres-connection.schemas.ts';
import { ResourceReference } from '../../services/resources/resources.ref.ts';
import { Resource, ResourceService } from '../../services/resources/resources.ts';
import { getWithNamespace } from '../../utils/naming.ts';
import { API_VERSION } from '../../utils/consts.ts';
import { decodeSecret } from '../../utils/secrets.ts';
import { decodeSecret, encodeSecret } from '../../utils/secrets.ts';
import { isDeepSubset } from '../../utils/objects.ts';
import { postgresClusterSecretSchema } from '../postgres-cluster/postgres-cluster.schemas.ts';
import 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<typeof postgresDatabaseSpecSchema> {
#secret: Resource<V1Secret>;
#secretName: string;
#connection: ResourceReference<CustomResourceObject<typeof postgresConnectionSpecSchema>>;
#connectionSecret: ResourceReference<V1Secret>;
#clusterSecret: ResourceReference<V1Secret>;
#databaseSecret: Resource<V1Secret>;
constructor(options: CustomResourceOptions<typeof postgresDatabaseSpecSchema>) {
super(options);
const resouceService = this.services.get(ResourceService);
this.#clusterSecret = new ResourceReference();
this.#secretName = `postgres-database-${this.name}`;
this.#secret = resouceService.get({
const resourceService = this.services.get(ResourceService);
this.#databaseSecret = resourceService.get({
apiVersion: 'v1',
kind: 'Secret',
name: this.#secretName,
name: `${this.name}-postgres-database`,
namespace: this.namespace,
});
this.#connection = new ResourceReference();
this.#connectionSecret = new ResourceReference();
this.#updateSecret();
this.#secret.on('changed', this.queueReconcile);
this.#connection.on('changed', this.queueReconcile);
this.#connectionSecret.on('changed', this.queueReconcile);
this.#clusterSecret.on('changed', this.queueReconcile);
}
get #dbName() {
@@ -68,68 +47,53 @@ class PostgresDatabaseResource extends CustomResource<typeof postgresDatabaseSpe
}
#updateSecret = () => {
const resouceService = this.services.get(ResourceService);
const connectionNames = getWithNamespace(this.spec.connection, this.namespace);
this.#connection.current = resouceService.get({
apiVersion: API_VERSION,
kind: 'PostgresConnection',
name: connectionNames.name,
namespace: connectionNames.namespace,
const resourceService = this.services.get(ResourceService);
const secretNames = getWithNamespace(this.spec.cluster, this.namespace);
this.#clusterSecret.current = resourceService.get({
apiVersion: 'v1',
kind: 'Secret',
name: `${secretNames.name}-postgres-cluster`,
namespace: secretNames.namespace,
});
if (this.#connection.current?.exists && this.#connection.current.spec) {
const connectionSecretNames = getWithNamespace(
this.#connection.current.spec.secret,
this.#connection.current.namespace,
);
this.#connectionSecret.current = resouceService.get({
apiVersion: 'v1',
kind: 'Secret',
name: connectionSecretNames.name,
namespace: connectionSecretNames.namespace,
});
}
};
#reconcileSecret = async (): Promise<SubresourceResult> => {
const connectionSecret = this.#connectionSecret.current;
if (!connectionSecret?.exists || !connectionSecret.data) {
const serverSecret = this.#clusterSecret.current;
const databaseSecret = this.#databaseSecret;
if (!serverSecret?.exists || !serverSecret.data) {
return {
ready: false,
failed: true,
reason: 'MissingConnectionSecret',
};
}
const connectionSecretData = decodeSecret(connectionSecret.data);
const secret = this.#secret;
const parsed = secretDataSchema.safeParse(decodeSecret(secret.data));
if (!parsed.success) {
this.#secret.patch({
data: {
host: Buffer.from(connectionSecretData?.host || '').toString('base64'),
port: connectionSecretData?.port ? Buffer.from(connectionSecretData.port).toString('base64') : undefined,
user: Buffer.from(this.#userName).toString('base64'),
database: Buffer.from(this.#dbName).toString('base64'),
password: Buffer.from(Buffer.from(crypto.randomUUID()).toString('hex')).toString('base64'),
},
});
const serverSecretData = postgresClusterSecretSchema.safeParse(decodeSecret(serverSecret.data));
if (!serverSecretData.success || !serverSecretData.data) {
return {
ready: false,
syncing: true,
reason: 'SecretMissing',
};
}
if (parsed.data?.host !== connectionSecretData?.host || parsed.data?.port !== connectionSecretData?.port) {
this.#secret.patch({
data: {
host: Buffer.from(connectionSecretData?.host || '').toString('base64'),
port: connectionSecretData?.port ? Buffer.from(connectionSecretData.port).toString('base64') : undefined,
},
const databaseSecretData = postgresClusterSecretSchema.safeParse(decodeSecret(databaseSecret.data));
const expectedSecret = {
password: crypto.randomUUID(),
host: serverSecretData.data.host,
port: serverSecretData.data.port,
user: this.#userName,
database: this.#dbName,
...databaseSecretData.data,
};
if (!isDeepSubset(databaseSecretData.data, expectedSecret)) {
databaseSecret.patch({
data: encodeSecret(expectedSecret),
});
return {
ready: false,
syncing: true,
reason: 'SecretNotReady',
};
}
@@ -139,8 +103,8 @@ class PostgresDatabaseResource extends CustomResource<typeof postgresDatabaseSpe
};
#reconcileDatabase = async (): Promise<SubresourceResult> => {
const connectionSecret = this.#connectionSecret.current;
if (!connectionSecret?.exists || !connectionSecret.data) {
const clusterSecret = this.#clusterSecret.current;
if (!clusterSecret?.exists || !clusterSecret.data) {
return {
ready: false,
failed: true,
@@ -148,17 +112,8 @@ class PostgresDatabaseResource extends CustomResource<typeof postgresDatabaseSpe
};
}
const connectionSecretData = postgresConnectionSecretDataSchema.safeParse(decodeSecret(connectionSecret.data));
const connectionSecretData = postgresClusterSecretSchema.safeParse(decodeSecret(clusterSecret.data));
if (!connectionSecretData.success || !connectionSecretData.data) {
return {
ready: false,
syncing: true,
reason: 'ConnectionSecretMissing',
};
}
const secretData = secretDataSchema.safeParse(decodeSecret(this.#secret.data));
if (!secretData.success || !secretData.data) {
return {
ready: false,
syncing: true,
@@ -166,18 +121,28 @@ class PostgresDatabaseResource extends CustomResource<typeof postgresDatabaseSpe
};
}
const secretData = postgresClusterSecretSchema.safeParse(decodeSecret(this.#databaseSecret.data));
if (!secretData.success || !secretData.data) {
return {
ready: false,
syncing: true,
reason: 'ConnectionSecretMissing',
};
}
const postgresService = this.services.get(PostgresService);
const database = postgresService.get({
...connectionSecretData.data,
port: connectionSecretData.data.port ? Number(connectionSecretData.data.port) : 5432,
database: connectionSecretData.data.database,
});
await database.upsertRole({
name: secretData.data.user,
name: secretData.data.username,
password: secretData.data.password,
});
await database.upsertDatabase({
name: secretData.data.database,
owner: secretData.data.user,
owner: secretData.data.username,
});
return {
@@ -186,13 +151,13 @@ class PostgresDatabaseResource extends CustomResource<typeof postgresDatabaseSpe
};
public reconcile = async () => {
if (!this.exists || this.metadata.deletionTimestamp) {
if (!this.exists || this.metadata?.deletionTimestamp) {
return;
}
this.#updateSecret();
await Promise.allSettled([
await this.reconcileSubresource(DATABASE_READY_CONDITION, this.#reconcileDatabase),
await this.reconcileSubresource(SECRET_READY_CONDITION, this.#reconcileSecret),
this.reconcileSubresource(DATABASE_READY_CONDITION, this.#reconcileDatabase),
this.reconcileSubresource(SECRET_READY_CONDITION, this.#reconcileSecret),
]);
const secretReady = this.conditions.get(SECRET_READY_CONDITION)?.status === 'True';
@@ -203,4 +168,4 @@ class PostgresDatabaseResource extends CustomResource<typeof postgresDatabaseSpe
};
}
export { PostgresDatabaseResource, secretDataSchema as postgresDatabaseSecretSchema };
export { PostgresDatabaseResource };

View File

@@ -1,61 +0,0 @@
import type { V1Secret } from '@kubernetes/client-node';
import {
CustomResource,
type CustomResourceOptions,
} 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 { getWithNamespace } from '../../utils/naming.ts';
import type { redisConnectionSpecSchema } from './redis-connection.schemas.ts';
class RedisConnectionResource extends CustomResource<typeof redisConnectionSpecSchema> {
#secret: ResourceReference<V1Secret>;
constructor(options: CustomResourceOptions<typeof redisConnectionSpecSchema>) {
super(options);
const resourceService = this.services.get(ResourceService);
const secretNames = getWithNamespace(this.spec.secret, this.namespace);
this.#secret = new ResourceReference<V1Secret>(
resourceService.get({
apiVersion: 'v1',
kind: 'Secret',
name: secretNames.name,
namespace: secretNames.namespace,
}),
);
this.#secret.on('changed', this.queueReconcile);
}
public reconcile = async () => {
const resourceService = this.services.get(ResourceService);
const secretNames = getWithNamespace(this.spec.secret, this.namespace);
this.#secret.current = resourceService.get({
apiVersion: 'v1',
kind: 'Secret',
name: secretNames.name,
namespace: secretNames.namespace,
});
const current = this.#secret.current;
if (!current?.exists || !current.data) {
return this.conditions.set('Ready', {
status: 'False',
reason: 'MissingSecret',
});
}
const { host } = current.data;
if (!host) {
return this.conditions.set('Ready', {
status: 'False',
reason: 'MissingHost',
});
}
await this.conditions.set('Ready', {
status: 'True',
});
};
}
export { RedisConnectionResource };

View File

@@ -1,14 +0,0 @@
import { z } from 'zod';
const redisConnectionSpecSchema = z.object({
secret: z.string(),
});
const redisConnectionSecretDataSchema = z.object({
host: z.string(),
port: z.string().optional(),
user: z.string().optional(),
password: z.string().optional(),
});
export { redisConnectionSpecSchema, redisConnectionSecretDataSchema };

View File

@@ -1,19 +0,0 @@
import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts';
import { GROUP } from '../../utils/consts.ts';
import { redisConnectionSpecSchema } from './redis-connection.schemas.ts';
import { RedisConnectionResource } from './redis-connection.resource.ts';
const redisConnectionDefinition = createCustomResourceDefinition({
group: GROUP,
version: 'v1',
kind: 'RedisConnection',
names: {
plural: 'redisconnections',
singular: 'redisconnection',
},
spec: redisConnectionSpecSchema,
create: (options) => new RedisConnectionResource(options),
});
export { redisConnectionDefinition };

View File

@@ -0,0 +1,82 @@
import { DeploymentInstance } from '../../instances/deployment.ts';
import { ServiceInstance } from '../../instances/service.ts';
import { CustomResource } from '../../services/custom-resources/custom-resources.custom-resource.ts';
import type { CustomResourceOptions } from '../../services/custom-resources/custom-resources.custom-resource.ts';
import { ResourceService } from '../../services/resources/resources.ts';
import type { redisServerSpecSchema } from './redis-server.schemas.ts';
class RedisServerController extends CustomResource<typeof redisServerSpecSchema> {
#deployment: DeploymentInstance;
#service: ServiceInstance;
constructor(options: CustomResourceOptions<typeof redisServerSpecSchema>) {
super(options);
const resourceService = this.services.get(ResourceService);
this.#deployment = resourceService.getInstance(
{
apiVersion: 'apps/v1',
kind: 'Deployment',
name: this.name,
namespace: this.namespace,
},
DeploymentInstance,
);
this.#service = resourceService.getInstance(
{
apiVersion: 'v1',
kind: 'Service',
name: this.name,
namespace: this.namespace,
},
ServiceInstance,
);
this.#deployment.on('changed', this.queueReconcile);
this.#service.on('changed', this.queueReconcile);
}
public reconcile = async () => {
await this.#deployment.ensure({
metadata: {
ownerReferences: [this.ref],
},
spec: {
replicas: 1,
selector: {
matchLabels: {
app: this.name,
},
},
template: {
metadata: {
labels: {
app: this.name,
},
},
spec: {
containers: [
{
name: this.name,
image: 'redis:latest',
ports: [{ containerPort: 6379 }],
},
],
},
},
},
});
await this.#service.ensure({
metadata: {
ownerReferences: [this.ref],
},
spec: {
selector: {
app: this.name,
},
ports: [{ port: 6379, targetPort: 6379 }],
},
});
};
}
export { RedisServerController };

View File

@@ -1,82 +0,0 @@
import type { V1Deployment, V1Service } from '@kubernetes/client-node';
import type { CustomResourceObject } from '../../services/custom-resources/custom-resources.custom-resource.ts';
import type { redisConnectionSpecSchema } from '../redis-connection/redis-connection.schemas.ts';
import { API_VERSION, CONTROLLED_LABEL } from '../../utils/consts.ts';
const deploymentManifest = (): V1Deployment => ({
apiVersion: 'apps/v1',
kind: 'Deployment',
metadata: {
name: 'redis-server',
namespace: 'homelab',
},
spec: {
replicas: 1,
selector: {
matchLabels: {
app: 'redis-server',
},
},
template: {
metadata: {
labels: {
app: 'redis-server',
},
},
spec: {
containers: [
{
name: 'redis-server',
image: 'redis:latest',
ports: [
{
containerPort: 6379,
},
],
},
],
},
},
},
});
const serviceManifest = (): V1Service => ({
apiVersion: 'v1',
kind: 'Service',
metadata: {
name: 'redis-server',
namespace: 'homelab',
},
spec: {
selector: {
app: 'redis-server',
},
ports: [
{
port: 6379,
},
],
},
});
type RedisConnectionManifestOptions = {
secretName: string;
};
const connectionManifest = (
options: RedisConnectionManifestOptions,
): CustomResourceObject<typeof redisConnectionSpecSchema> => ({
apiVersion: API_VERSION,
kind: 'RedisConnection',
metadata: {
labels: {
...CONTROLLED_LABEL,
},
},
spec: {
secret: options.secretName,
},
});
export { deploymentManifest, serviceManifest, connectionManifest };

View File

@@ -1,138 +0,0 @@
import type { V1Deployment, V1Service } from '@kubernetes/client-node';
import {
type CustomResourceOptions,
CustomResource,
type CustomResourceObject,
} from '../../services/custom-resources/custom-resources.custom-resource.ts';
import {
redisConnectionSecretDataSchema,
redisConnectionSpecSchema,
} from '../redis-connection/redis-connection.schemas.ts';
import { Resource, ResourceService } from '../../services/resources/resources.ts';
import { API_VERSION } from '../../utils/consts.ts';
import type { EnsuredSecret } from '../../services/secrets/secrets.secret.ts';
import { SecretService } from '../../services/secrets/secrets.ts';
import { isDeepSubset } from '../../utils/objects.ts';
import { redisServerSpecSchema } from './redis-server.schemas.ts';
import { connectionManifest, deploymentManifest, serviceManifest } from './redis-server.manifests.ts';
class RedisServerResource extends CustomResource<typeof redisServerSpecSchema> {
#resources: {
deployment: Resource<V1Deployment>;
service: Resource<V1Service>;
connection: Resource<CustomResourceObject<typeof redisConnectionSpecSchema>>;
secret: EnsuredSecret<typeof redisConnectionSecretDataSchema>;
};
constructor(options: CustomResourceOptions<typeof redisServerSpecSchema>) {
super(options);
const resourceService = this.services.get(ResourceService);
const secretService = this.services.get(SecretService);
this.#resources = {
deployment: resourceService.get({
apiVersion: 'apps/v1',
kind: 'Deployment',
name: this.name,
namespace: this.namespace,
}),
service: resourceService.get({
apiVersion: 'v1',
kind: 'Service',
name: this.name,
namespace: this.namespace,
}),
connection: resourceService.get({
apiVersion: API_VERSION,
kind: 'RedisConnection',
name: this.name,
namespace: this.namespace,
}),
secret: secretService.ensure({
name: `${this.name}-connection`,
namespace: this.namespace,
schema: redisConnectionSecretDataSchema,
generator: () => ({
host: `${this.name}.${this.namespace}.svc.cluster.local`,
}),
}),
};
}
#reconcileDeployment = async () => {
const { deployment } = this.#resources;
const manifest = deploymentManifest();
if (!isDeepSubset(deployment.spec, manifest.spec)) {
await deployment.patch(manifest);
return {
ready: false,
syncing: true,
reason: 'ChangingDeployment',
message: 'Deployment need changes',
};
}
return {
ready: true,
reason: 'DeploymentReady',
message: 'Deployment is ready',
};
};
#reconcileService = async () => {
const { service } = this.#resources;
const manifest = serviceManifest();
if (!isDeepSubset(service.spec, manifest.spec)) {
await service.patch(manifest);
return {
ready: false,
syncing: true,
reason: 'ChangingService',
message: 'Service need changes',
};
}
return {
ready: true,
reason: 'ServiceReady',
message: 'Service is ready',
};
};
#reconcileConnection = async () => {
const { connection, secret } = this.#resources;
if (!secret.isValid || !secret.value) {
return {
ready: false,
failed: true,
reason: 'MissingSecret',
message: 'Secret is missing',
};
}
const manifest = connectionManifest({
secretName: secret.name,
});
if (!isDeepSubset(connection.spec, manifest.spec)) {
await connection.patch(manifest);
return {
ready: false,
syncing: true,
reason: 'ChangingConnection',
message: 'Connection need changes',
};
}
return {
ready: true,
reason: 'ConnectionReady',
message: 'Connection is ready',
};
};
public reconcile = async () => {
await Promise.allSettled([
this.reconcileSubresource('Deployment', this.#reconcileDeployment),
this.reconcileSubresource('Service', this.#reconcileService),
this.reconcileSubresource('Connection', this.#reconcileConnection),
]);
};
}
export { RedisServerResource };

View File

@@ -1,7 +1,7 @@
import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts';
import { GROUP } from '../../utils/consts.ts';
import { RedisServerResource } from './redis-server.resource.ts';
import { RedisServerController } from './redis-server.controller.ts';
import { redisServerSpecSchema } from './redis-server.schemas.ts';
const redisServerDefinition = createCustomResourceDefinition({
@@ -13,7 +13,7 @@ const redisServerDefinition = createCustomResourceDefinition({
singular: 'redis-server',
},
spec: redisServerSpecSchema,
create: (options) => new RedisServerResource(options),
create: (options) => new RedisServerController(options),
});
export { redisServerDefinition };

View File

@@ -1,35 +1,78 @@
import 'dotenv/config';
import { ApiException } from '@kubernetes/client-node';
import { Services } from './utils/service.ts';
import { BootstrapService } from './bootstrap/bootstrap.ts';
import { customResources } from './custom-resouces/custom-resources.ts';
import { CustomResourceService } from './services/custom-resources/custom-resources.ts';
import { WatcherService } from './services/watchers/watchers.ts';
import { IstioService } from './services/istio/istio.ts';
import { customResources } from './custom-resouces/custom-resources.ts';
process.on('uncaughtException', (error) => {
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 { StorageProvider } from './storage-provider/storage-provider.ts';
import { Services } from './utils/service.ts';
const services = new Services();
const watcherService = services.get(WatcherService);
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',
@@ -46,40 +89,24 @@ 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();
await watcherService.watchCustomGroup('networking.istio.io', 'v1', ['gateways', 'virtualservices', 'destinationrules']);
await watcherService.watchCustomGroup('source.toolkit.fluxcd.io', 'v1', ['helmrepositories', 'helmcharts']);
await watcherService.watchCustomGroup('helm.toolkit.fluxcd.io', 'v2', ['helmreleases']);
await watcherService.watchCustomGroup('cert-manager.io', 'v1', ['issuers', 'certificates', 'clusterissuers']);
const storageProvider = services.get(StorageProvider);
await storageProvider.start();
const istio = services.get(IstioService);
await istio.start();
const bootstrap = services.get(BootstrapService);
await bootstrap.ensure();
const customResourceService = services.get(CustomResourceService);
customResourceService.register(...customResources);

View File

@@ -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<CustomResourceObject<typeof authentikServerSpecSchema>> {}
export { AuthentikServerInstance };

View File

@@ -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<KubernetesObject & K8SCertificateV1> {}
export { CertificateInstance };

View File

@@ -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<KubernetesObject & K8SClusterIssuerV1> {
public get ready() {
return this.exists;
}
}
export { ClusterIssuerInstance };

View File

@@ -0,0 +1,7 @@
import type { V1CustomResourceDefinition } from '@kubernetes/client-node';
import { ResourceInstance } from '../services/resources/resources.instance.ts';
class CustomDefinitionInstance extends ResourceInstance<V1CustomResourceDefinition> {}
export { CustomDefinitionInstance };

View File

@@ -0,0 +1,11 @@
import type { V1Deployment } from '@kubernetes/client-node';
import { ResourceInstance } from '../services/resources/resources.ts';
class DeploymentInstance extends ResourceInstance<V1Deployment> {
public get ready() {
return this.exists && this.status?.readyReplicas === this.status?.replicas;
}
}
export { DeploymentInstance };

View File

@@ -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<KubernetesObject & K8SDestinationRuleV1> {
public get ready() {
return this.exists;
}
}
export { DestinationRuleInstance };

View File

@@ -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<CustomResourceObject<typeof environmentSpecSchema>> {}
export { EnvironmentInstance };

8
src/instances/gateway.ts Normal file
View File

@@ -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<KubernetesObject & K8SGatewayV1> {}
export { GatewayInstance };

12
src/instances/git-repo.ts Normal file
View File

@@ -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<KubernetesObject & K8SGitRepositoryV1> {
public get ready() {
return this.exists;
}
}
export { GitRepoInstance };

View File

@@ -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<KubernetesObject & K8SHelmReleaseV2> {
public get ready() {
return this.exists;
}
}
export { HelmReleaseInstance };

View File

@@ -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<KubernetesObject & K8SHelmRepositoryV1> {
public get ready() {
if (!this.exists) {
return false;
}
const condition = this.getCondition('Ready');
return condition?.status === 'True';
}
}
export { HelmRepoInstance };

View File

@@ -0,0 +1,7 @@
import type { httpServiceSpecSchema } from '../custom-resouces/http-service/http-service.schemas.ts';
import type { CustomResourceObject } from '../services/custom-resources/custom-resources.custom-resource.ts';
import { ResourceInstance } from '../services/resources/resources.instance.ts';
class HttpServiceInstance extends ResourceInstance<CustomResourceObject<typeof httpServiceSpecSchema>> {}
export { HttpServiceInstance };

View File

@@ -0,0 +1,11 @@
import type { V1Namespace } from '@kubernetes/client-node';
import { ResourceInstance } from '../services/resources/resources.ts';
class NamespaceInstance extends ResourceInstance<V1Namespace> {
public get ready() {
return this.exists;
}
}
export { NamespaceInstance };

View File

@@ -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<CustomResourceObject<typeof postgresClusterSpecSchema>> {}
export { PostgresClusterInstance };

View File

@@ -0,0 +1,7 @@
import type { CustomResourceObject } from '../services/custom-resources/custom-resources.custom-resource.ts';
import { ResourceInstance } from '../services/resources/resources.instance.ts';
import type { redisServerSpecSchema } from '../custom-resouces/redis-server/redis-server.schemas.ts';
class RedisServerInstance extends ResourceInstance<CustomResourceObject<typeof redisServerSpecSchema>> {}
export { RedisServerInstance };

20
src/instances/secret.ts Normal file
View File

@@ -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<V1Secret> {
public get values() {
return decodeSecret(this.data);
}
public ensureData = async (values: Record<string, string>) => {
await this.ensure({
data: encodeSecret(values),
});
};
public readonly ready = true;
}
export { SecretInstance };

11
src/instances/service.ts Normal file
View File

@@ -0,0 +1,11 @@
import type { V1Service } from '@kubernetes/client-node';
import { ResourceInstance } from '../services/resources/resources.ts';
class ServiceInstance extends ResourceInstance<V1Service> {
public get ready() {
return this.exists;
}
}
export { ServiceInstance };

View File

@@ -0,0 +1,11 @@
import type { V1StatefulSet } from '@kubernetes/client-node';
import { ResourceInstance } from '../services/resources/resources.instance.ts';
class StatefulSetInstance extends ResourceInstance<V1StatefulSet> {
public get ready() {
return this.exists && this.manifest?.status?.readyReplicas === this.manifest?.status?.replicas;
}
}
export { StatefulSetInstance };

View File

@@ -0,0 +1,7 @@
import type { V1StorageClass } from '@kubernetes/client-node';
import { ResourceInstance } from '../services/resources/resources.instance.ts';
class StorageClassInstance extends ResourceInstance<V1StorageClass> {}
export { StorageClassInstance };

View File

@@ -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<KubernetesObject & K8SVirtualServiceV1> {
public get ready() {
return this.exists;
}
}
export { VirtualServiceInstance };

View File

@@ -10,7 +10,7 @@ type AuthentikServerInfo = {
type UpsertClientRequest = {
name: string;
secret: string;
secret?: string;
scopes?: string[];
flows?: {
authorization: string;

View File

@@ -1,48 +0,0 @@
import type { V1Deployment } from '@kubernetes/client-node';
import type { Services } from '../../utils/service.ts';
import { ResourceReference } from '../resources/resources.ref.ts';
import type { Watcher } from '../watchers/watchers.watcher.ts';
import { WatcherService } from '../watchers/watchers.ts';
import type { Resource } from '../resources/resources.ts';
const ISTIO_APP_SELECTOR = 'istio=gateway-controller';
class IstioService {
#gatewayResource: ResourceReference<V1Deployment>;
#gatewayWatcher: Watcher<V1Deployment>;
constructor(services: Services) {
this.#gatewayResource = new ResourceReference<V1Deployment>();
const watcherService = services.get(WatcherService);
this.#gatewayWatcher = watcherService.create({
path: '/apis/apps/v1/deployments',
list: async (k8s) => {
return await k8s.apps.listDeploymentForAllNamespaces({
labelSelector: ISTIO_APP_SELECTOR,
});
},
transform: (manifest) => ({
apiVersion: 'apps/v1',
kind: 'Deployment',
...manifest,
}),
verbs: ['add', 'update', 'delete'],
});
this.#gatewayWatcher.on('changed', this.#handleChange);
}
#handleChange = (resource: Resource<V1Deployment>) => {
this.#gatewayResource.current = resource;
};
public get gateway() {
return this.#gatewayResource;
}
public start = async () => {
await this.#gatewayWatcher.start();
};
}
export { IstioService };

View File

@@ -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 };

View File

@@ -8,8 +8,9 @@ type PostgresInstanceOptions = {
services: Services;
host: string;
port?: number;
user: string;
username: string;
password: string;
database?: string;
};
class PostgresInstance {
@@ -20,9 +21,10 @@ 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,
},
});
}

View File

@@ -0,0 +1,79 @@
import type { KubernetesObject } from '@kubernetes/client-node';
import { isDeepSubset } from '../../utils/objects.ts';
import { ResourceReference } from './resources.ref.ts';
abstract class ResourceInstance<T extends KubernetesObject> extends ResourceReference<T> {
public get resource() {
if (!this.current) {
throw new Error('Instance needs a resource');
}
return this.current;
}
public get exists() {
return this.resource.exists;
}
public get manifest() {
return this.resource.manifest;
}
public get apiVersion() {
return this.resource.apiVersion;
}
public get kind() {
return this.resource.kind;
}
public get name() {
return this.resource.name;
}
public get namespace() {
return this.resource.namespace;
}
public get metadata() {
return this.resource.metadata;
}
public get spec() {
return this.resource.spec;
}
public get data() {
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 };

View File

@@ -163,6 +163,13 @@ class Resource<T extends KubernetesObject = UnknownResource> 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 || [];

View File

@@ -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 = <T extends KubernetesObject, I extends ResourceInstance<T>>(
options: ResourceGetOptions,
instance: new (resource: Resource<T>) => I,
) => {
const resource = this.get<T>(options);
return new instance(resource);
};
public get = <T extends KubernetesObject>(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 };

View File

@@ -41,7 +41,7 @@ class EnsuredSecret<T extends ZodObject> {
return this.#options.namespace;
}
public get resouce() {
public get resource() {
return this.#resource;
}
@@ -62,7 +62,7 @@ class EnsuredSecret<T extends ZodObject> {
if (deepEqual(patched, this.value)) {
return;
}
await this.resouce.patch({
await this.resource.patch({
data: patched,
});
};

View File

@@ -0,0 +1,83 @@
import { z } from 'zod';
import { V1Secret } from '@kubernetes/client-node';
import { EventEmitter } from 'eventemitter3';
import deepEqual from 'deep-equal';
import { ResourceReference, ResourceService } from '../resources/resources.ts';
import type { Services } from '../../utils/service.ts';
import { getWithNamespace } from '../../utils/naming.ts';
import { decodeSecret } from '../../utils/secrets.ts';
const valueReferenceInfoSchema = z.object({
value: z.string().optional(),
secretRef: z.string().optional(),
key: z.string().optional(),
});
type ValueReferenceInfo = z.infer<typeof valueReferenceInfoSchema>;
type ValueRefOptions = {
services: Services;
namespace: string;
};
type ValueReferenceEvents = {
changed: () => void;
};
class ValueReference extends EventEmitter<ValueReferenceEvents> {
#options: ValueRefOptions;
#ref?: ValueReferenceInfo;
#resource: ResourceReference;
constructor(options: ValueRefOptions) {
super();
this.#options = options;
this.#resource = new ResourceReference<V1Secret>();
this.#resource.on('changed', this.#handleChange);
}
public get ref() {
return this.#ref;
}
public set ref(ref: ValueReferenceInfo | undefined) {
if (deepEqual(this.#ref, ref)) {
return;
}
if (ref?.secretRef && ref.key) {
const { services, namespace } = this.#options;
const resourceService = services.get(ResourceService);
const refNames = getWithNamespace(ref.secretRef, namespace);
this.#resource.current = resourceService.get({
apiVersion: 'v1',
kind: 'Secret',
name: refNames.name,
namespace: refNames.namespace,
});
} else {
this.#resource.current = undefined;
}
this.#ref = ref;
}
public get value() {
console.log('get', this.#ref);
if (!this.#ref) {
return undefined;
}
if (this.#ref.value) {
return this.#ref.value;
}
if (this.#resource.current && this.#ref.key) {
const decoded = decodeSecret(this.#resource.current.data);
return decoded?.[this.#ref.key];
}
return undefined;
}
#handleChange = () => {
this.emit('changed');
};
}
export { ValueReference, valueReferenceInfoSchema, type ValueReferenceInfo };

Some files were not shown because too many files have changed in this diff Show More