Compare commits

...

7 Commits

Author SHA1 Message Date
mortenolsenzn
683de402ff Merge pull request #1 from morten-olsen/rewrite2
Rewrite2
2025-09-03 12:24:40 +02:00
Morten Olsen
e8e939ad19 fixes 2025-08-22 11:44:53 +02:00
Morten Olsen
1b5b5145b0 stuff 2025-08-22 07:35:50 +02:00
Morten Olsen
cfd2d76873 more 2025-08-20 22:45:30 +02:00
Morten Olsen
9e5081ed9b updates 2025-08-20 14:58:34 +02:00
Morten Olsen
3ab2b1969a stuff 2025-08-19 22:05:41 +02:00
Morten Olsen
a27b563113 rewrite2 2025-08-18 08:02:48 +02:00
135 changed files with 2980 additions and 3652 deletions

4
.gitignore vendored
View File

@@ -33,4 +33,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Finder (MacOS) folder config # Finder (MacOS) folder config
.DS_Store .DS_Store
/data/ /data/
/cloudflare.yaml

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.13

View File

@@ -1,6 +1,6 @@
FROM node:23-alpine FROM node:23-slim
RUN corepack enable RUN corepack enable
COPY package.json pnpm-lock.yaml ./ COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod RUN pnpm install --frozen-lockfile --prod
COPY . . COPY . .
CMD ["node", "src/index.ts"] CMD ["node", "src/index.ts"]

View File

@@ -4,7 +4,7 @@ dev-destroy:
colima delete -f colima delete -f
dev-recreate: dev-destroy dev-recreate: dev-destroy
colima start --network-address --kubernetes -m 8 --k3s-arg="--disable=helm-controller,local-storage,traefik" # --mount ${PWD}/data:/data:w colima start --network-address --kubernetes -m 8 --k3s-arg="--disable helm-controller,local-storage,traefik --docker" # --mount ${PWD}/data:/data:w
flux install --components="source-controller,helm-controller" flux install --components="source-controller,helm-controller"
setup-flux: setup-flux:

0
README.md Normal file
View File

View File

@@ -1,19 +0,0 @@
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
annotations:
argocd.argoproj.io/sync-options: SkipDryRunOnMissingResource=true
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: alice@alice.com
privateKeySecretRef:
name: letsencrypt-prod-account-key
solvers:
- dns01:
cloudflare:
email: alice@alice.com
apiTokenSecretRef:
name: cloudflare-api-token
key: api-token

View File

@@ -0,0 +1,3 @@
apiVersion: v2
version: 1.0.0
name: ByteStash

View File

@@ -0,0 +1,9 @@
apiVersion: homelab.mortenolsen.pro/v1
kind: OidcClient
metadata:
name: '{{ .Release.Name }}'
spec:
environment: '{{ .Values.environment }}'
redirectUris:
- url: https://localhost:3000/api/v1/authentik/oauth2/callback
matchingMode: strict

View File

@@ -0,0 +1,11 @@
apiVersion: homelab.mortenolsen.pro/v1
kind: ExternalHttpService
metadata:
name: '{{ .Release.Name }}'
spec:
environment: '{{ .Values.environment }}'
subdomain: '{{ .Values.subdomain }}-external'
destination:
host: '{{ .Release.Name }}.{{ .Release.Namespace }}.svc.cluster.local'
port:
number: 80

View File

@@ -0,0 +1,13 @@
apiVersion: v1
kind: Service
metadata:
name: '{{ .Release.Name }}-headless'
labels:
app: '{{ .Release.Name }}'
spec:
clusterIP: None
ports:
- port: 5000
name: http
selector:
app: '{{ .Release.Name }}'

View File

@@ -0,0 +1,11 @@
apiVersion: homelab.mortenolsen.pro/v1
kind: HttpService
metadata:
name: '{{ .Release.Name }}'
spec:
environment: '{{ .Values.environment }}'
subdomain: '{{ .Values.subdomain }}'
destination:
host: '{{ .Release.Name }}'
port:
number: 80

View File

@@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: '{{ .Release.Name }}'
labels:
app: '{{ .Release.Name }}'
spec:
type: ClusterIP
ports:
- port: 80
targetPort: 5000
protocol: TCP
name: http
selector:
app: '{{ .Release.Name }}'

View File

@@ -0,0 +1,68 @@
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: '{{ .Release.Name }}'
labels:
app: '{{ .Release.Name }}'
spec:
serviceName: '{{ .Release.Name }}-headless'
replicas: 1
selector:
matchLabels:
app: '{{ .Release.Name }}'
template:
metadata:
labels:
app: '{{ .Release.Name }}'
spec:
containers:
- name: '{{ .Release.Name }}'
image: ghcr.io/jordan-dalby/bytestash:latest
ports:
- containerPort: 5000
name: http
env:
- name: OIDC_ENABLED
value: 'true'
- name: OIDC_DISPLAY_NAME
value: OIDC
- name: OIDC_CLIENT_ID
valueFrom:
secretKeyRef:
name: '{{ .Release.Name }}-client'
key: clientId
- name: OIDC_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: '{{ .Release.Name }}-client'
key: clientSecret
- name: OIDC_ISSUER_URL
valueFrom:
secretKeyRef:
name: '{{ .Release.Name }}-client'
key: configuration
# !! IMPORTANT !!
# You MUST update this Redirect URI to match your external URL.
# This URI must also be configured in your Authentik provider settings for this client.
#- name: BS_OIDC_REDIRECT_URI
#value: 'https://bytestash.your-domain.com/login/oauth2/code/oidc'
volumeMounts:
- mountPath: /data/snippets
name: bytestash-data
# Defines security context for the pod to avoid running as root.
# securityContext:
# runAsUser: 1000
# runAsGroup: 1000
# fsGroup: 1000
volumeClaimTemplates:
- metadata:
name: bytestash-data
spec:
accessModes: ['ReadWriteOnce']
storageClassName: '{{ .Values.environment }}'
resources:
requests:
storage: 5Gi

View File

@@ -0,0 +1,2 @@
environment: dev
subdomain: bytestash

View File

@@ -4,7 +4,7 @@
image: image:
repository: ghcr.io/morten-olsen/homelab-operator repository: ghcr.io/morten-olsen/homelab-operator
pullPolicy: Always pullPolicy: IfNotPresent
# Overrides the image tag whose default is the chart appVersion. # Overrides the image tag whose default is the chart appVersion.
tag: main tag: main

View File

@@ -1,9 +1,9 @@
apiVersion: homelab.mortenolsen.pro/v1 apiVersion: homelab.mortenolsen.pro/v1
kind: AuthentikClient kind: OidcClient
metadata: metadata:
name: test-client name: test-client
spec: spec:
server: dev/dev-authentik-server environment: dev
redirectUris: redirectUris:
- url: https://localhost:3000/api/v1/authentik/oauth2/callback - url: https://localhost:3000/api/v1/authentik/oauth2/callback
matchingMode: strict matchingMode: strict

View File

@@ -7,8 +7,8 @@ apiVersion: homelab.mortenolsen.pro/v1
kind: Environment kind: Environment
metadata: metadata:
name: dev name: dev
namespace: dev
spec: spec:
domain: one.dev.olsen.cloud domain: one.dev.olsen.cloud
networkIp: 192.168.107.2
tls: tls:
issuer: letsencrypt-prod issuer: lets-encrypt-prod

View File

@@ -0,0 +1,14 @@
apiVersion: networking.istio.io/v1alpha3
kind: ServiceEntry
metadata:
name: test-example-com
namespace: dev
spec:
hosts:
- authentik.one.dev.olsen.cloud
# (the address field is optional if you use 'resolution: DNS')
ports:
- number: 80
name: https
protocol: HTTPS
resolution: DNS

View File

@@ -22,6 +22,8 @@
"dependencies": { "dependencies": {
"@goauthentik/api": "2025.6.3-1751754396", "@goauthentik/api": "2025.6.3-1751754396",
"@kubernetes/client-node": "^1.3.0", "@kubernetes/client-node": "^1.3.0",
"cloudflare": "^4.5.0",
"cron": "^4.3.3",
"debounce": "^2.2.0", "debounce": "^2.2.0",
"deep-equal": "^2.2.3", "deep-equal": "^2.2.3",
"dotenv": "^17.2.1", "dotenv": "^17.2.1",
@@ -35,6 +37,12 @@
"yaml": "^2.8.0", "yaml": "^2.8.0",
"zod": "^4.0.14" "zod": "^4.0.14"
}, },
"imports": {
"#services/*": "./src/services/*",
"#resources/*": "./src/resources/*",
"#bootstrap/*": "./src/bootstrap/*",
"#utils/*": "./src/utils/*"
},
"packageManager": "pnpm@10.6.0", "packageManager": "pnpm@10.6.0",
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [

96
pnpm-lock.yaml generated
View File

@@ -14,6 +14,12 @@ importers:
'@kubernetes/client-node': '@kubernetes/client-node':
specifier: ^1.3.0 specifier: ^1.3.0
version: 1.3.0(encoding@0.1.13) version: 1.3.0(encoding@0.1.13)
cloudflare:
specifier: ^4.5.0
version: 4.5.0(encoding@0.1.13)
cron:
specifier: ^4.3.3
version: 4.3.3
debounce: debounce:
specifier: ^2.2.0 specifier: ^2.2.0
version: 2.2.0 version: 2.2.0
@@ -229,9 +235,15 @@ packages:
'@types/lodash@4.17.20': '@types/lodash@4.17.20':
resolution: {integrity: sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==} resolution: {integrity: sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==}
'@types/luxon@3.7.1':
resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==}
'@types/node-fetch@2.6.12': '@types/node-fetch@2.6.12':
resolution: {integrity: sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==} resolution: {integrity: sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==}
'@types/node@18.19.123':
resolution: {integrity: sha512-K7DIaHnh0mzVxreCR9qwgNxp3MH9dltPNIEddW9MYUlcKAzm+3grKNSTe2vCJHI1FaLpvpL5JGJrz1UZDKYvDg==}
'@types/node@22.16.5': '@types/node@22.16.5':
resolution: {integrity: sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ==} resolution: {integrity: sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ==}
@@ -303,6 +315,10 @@ packages:
abbrev@1.1.1: abbrev@1.1.1:
resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
abort-controller@3.0.0:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'}
acorn-jsx@5.3.2: acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies: peerDependencies:
@@ -479,6 +495,9 @@ packages:
resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==}
engines: {node: '>=6'} engines: {node: '>=6'}
cloudflare@4.5.0:
resolution: {integrity: sha512-fPcbPKx4zF45jBvQ0z7PCdgejVAPBBCZxwqk1k7krQNfpM07Cfj97/Q6wBzvYqlWXx/zt1S9+m8vnfCe06umbQ==}
color-convert@2.0.1: color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'} engines: {node: '>=7.0.0'}
@@ -507,6 +526,10 @@ packages:
console-control-strings@1.1.0: console-control-strings@1.1.0:
resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==} resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==}
cron@4.3.3:
resolution: {integrity: sha512-B/CJj5yL3sjtlun6RtYHvoSB26EmQ2NUmhq9ZiJSyKIM4K/fqfh9aelDFlIayD2YMeFZqWLi9hHV+c+pq2Djkw==}
engines: {node: '>=18.x'}
cross-spawn@7.0.6: cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -754,6 +777,10 @@ packages:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
event-target-shim@5.0.1:
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
engines: {node: '>=6'}
eventemitter3@5.0.1: eventemitter3@5.0.1:
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
@@ -825,10 +852,17 @@ packages:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
form-data-encoder@1.7.2:
resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==}
form-data@4.0.4: form-data@4.0.4:
resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
formdata-node@4.4.1:
resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==}
engines: {node: '>= 12.20'}
fs-constants@1.0.0: fs-constants@1.0.0:
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
@@ -1238,6 +1272,10 @@ packages:
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
engines: {node: '>=10'} engines: {node: '>=10'}
luxon@3.7.1:
resolution: {integrity: sha512-RkRWjA926cTvz5rAb1BqyWkKbbjzCGchDUIKMCUvNi17j6f6j8uHGDV82Aqcqtzd+icoYpELmG3ksgGiFNNcNg==}
engines: {node: '>=12'}
make-fetch-happen@9.1.0: make-fetch-happen@9.1.0:
resolution: {integrity: sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==} resolution: {integrity: sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
@@ -1339,6 +1377,11 @@ packages:
node-addon-api@7.1.1: node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
node-domexception@1.0.0:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'}
deprecated: Use your platform's native DOMException instead
node-fetch@2.7.0: node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0} engines: {node: 4.x || >=6.0.0}
@@ -1886,6 +1929,9 @@ packages:
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
undici-types@5.26.5:
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
undici-types@6.21.0: undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
@@ -1905,6 +1951,10 @@ packages:
util-deprecate@1.0.2: util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
web-streams-polyfill@4.0.0-beta.3:
resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==}
engines: {node: '>= 14'}
webidl-conversions@3.0.1: webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
@@ -2129,11 +2179,17 @@ snapshots:
'@types/lodash@4.17.20': {} '@types/lodash@4.17.20': {}
'@types/luxon@3.7.1': {}
'@types/node-fetch@2.6.12': '@types/node-fetch@2.6.12':
dependencies: dependencies:
'@types/node': 22.16.5 '@types/node': 22.16.5
form-data: 4.0.4 form-data: 4.0.4
'@types/node@18.19.123':
dependencies:
undici-types: 5.26.5
'@types/node@22.16.5': '@types/node@22.16.5':
dependencies: dependencies:
undici-types: 6.21.0 undici-types: 6.21.0
@@ -2240,6 +2296,10 @@ snapshots:
abbrev@1.1.1: abbrev@1.1.1:
optional: true optional: true
abort-controller@3.0.0:
dependencies:
event-target-shim: 5.0.1
acorn-jsx@5.3.2(acorn@8.15.0): acorn-jsx@5.3.2(acorn@8.15.0):
dependencies: dependencies:
acorn: 8.15.0 acorn: 8.15.0
@@ -2258,7 +2318,6 @@ snapshots:
agentkeepalive@4.6.0: agentkeepalive@4.6.0:
dependencies: dependencies:
humanize-ms: 1.2.1 humanize-ms: 1.2.1
optional: true
aggregate-error@3.1.0: aggregate-error@3.1.0:
dependencies: dependencies:
@@ -2463,6 +2522,18 @@ snapshots:
clean-stack@2.2.0: clean-stack@2.2.0:
optional: true optional: true
cloudflare@4.5.0(encoding@0.1.13):
dependencies:
'@types/node': 18.19.123
'@types/node-fetch': 2.6.12
abort-controller: 3.0.0
agentkeepalive: 4.6.0
form-data-encoder: 1.7.2
formdata-node: 4.4.1
node-fetch: 2.7.0(encoding@0.1.13)
transitivePeerDependencies:
- encoding
color-convert@2.0.1: color-convert@2.0.1:
dependencies: dependencies:
color-name: 1.1.4 color-name: 1.1.4
@@ -2485,6 +2556,11 @@ snapshots:
console-control-strings@1.1.0: console-control-strings@1.1.0:
optional: true optional: true
cron@4.3.3:
dependencies:
'@types/luxon': 3.7.1
luxon: 3.7.1
cross-spawn@7.0.6: cross-spawn@7.0.6:
dependencies: dependencies:
path-key: 3.1.1 path-key: 3.1.1
@@ -2828,6 +2904,8 @@ snapshots:
esutils@2.0.3: {} esutils@2.0.3: {}
event-target-shim@5.0.1: {}
eventemitter3@5.0.1: {} eventemitter3@5.0.1: {}
execa@9.6.0: execa@9.6.0:
@@ -2903,6 +2981,8 @@ snapshots:
dependencies: dependencies:
is-callable: 1.2.7 is-callable: 1.2.7
form-data-encoder@1.7.2: {}
form-data@4.0.4: form-data@4.0.4:
dependencies: dependencies:
asynckit: 0.4.0 asynckit: 0.4.0
@@ -2911,6 +2991,11 @@ snapshots:
hasown: 2.0.2 hasown: 2.0.2
mime-types: 2.1.35 mime-types: 2.1.35
formdata-node@4.4.1:
dependencies:
node-domexception: 1.0.0
web-streams-polyfill: 4.0.0-beta.3
fs-constants@1.0.0: {} fs-constants@1.0.0: {}
fs-minipass@2.1.0: fs-minipass@2.1.0:
@@ -3064,7 +3149,6 @@ snapshots:
humanize-ms@1.2.1: humanize-ms@1.2.1:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
optional: true
iconv-lite@0.6.3: iconv-lite@0.6.3:
dependencies: dependencies:
@@ -3329,6 +3413,8 @@ snapshots:
yallist: 4.0.0 yallist: 4.0.0
optional: true optional: true
luxon@3.7.1: {}
make-fetch-happen@9.1.0: make-fetch-happen@9.1.0:
dependencies: dependencies:
agentkeepalive: 4.6.0 agentkeepalive: 4.6.0
@@ -3440,6 +3526,8 @@ snapshots:
node-addon-api@7.1.1: {} node-addon-api@7.1.1: {}
node-domexception@1.0.0: {}
node-fetch@2.7.0(encoding@0.1.13): node-fetch@2.7.0(encoding@0.1.13):
dependencies: dependencies:
whatwg-url: 5.0.0 whatwg-url: 5.0.0
@@ -4098,6 +4186,8 @@ snapshots:
has-symbols: 1.1.0 has-symbols: 1.1.0
which-boxed-primitive: 1.1.1 which-boxed-primitive: 1.1.1
undici-types@5.26.5: {}
undici-types@6.21.0: {} undici-types@6.21.0: {}
unicorn-magic@0.3.0: {} unicorn-magic@0.3.0: {}
@@ -4118,6 +4208,8 @@ snapshots:
util-deprecate@1.0.2: {} util-deprecate@1.0.2: {}
web-streams-polyfill@4.0.0-beta.3: {}
webidl-conversions@3.0.1: {} webidl-conversions@3.0.1: {}
whatwg-url@5.0.0: whatwg-url@5.0.0:

9
pyproject.toml Normal file
View File

@@ -0,0 +1,9 @@
[project]
name = "homelab-operator"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"kubediagrams>=0.5.0",
]

View File

@@ -1,4 +0,0 @@
for f in "./test-manifests/"*; do
echo "Applying $f"
kubectl apply -f "$f"
done

View File

@@ -1,20 +0,0 @@
#!/bin/bash
# Load environment variables from .env file
if [ -f .env ]; then
export $(cat .env | grep -v '#' | awk '/=/ {print $1}')
fi
# Check if CLOUDFLARE_API_KEY is set
if [ -z "${CLOUDFLARE_API_KEY}" ]; then
echo "Error: CLOUDFLARE_API_KEY is not set. Please add it to your .env file."
exit 1
fi
# Create the postgres namespace if it doesn't exist
kubectl get namespace postgres > /dev/null 2>&1 || kubectl create namespace postgres
# Create the secret
kubectl create secret generic cloudflare-api-token \
--namespace cert-manager \
--from-literal=api-token="${CLOUDFLARE_API_KEY}"

View File

@@ -1,15 +0,0 @@
#!/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

@@ -1,3 +0,0 @@
#!/bin/bash
flux install --components="source-controller,helm-controller"
kubectl create namespace homelab

31
skaffold.yaml Normal file
View File

@@ -0,0 +1,31 @@
apiVersion: skaffold/v4beta7
kind: Config
metadata:
name: homelab-operator
build:
# This tells Skaffold to build the image locally using your Docker daemon.
local:
push: false
# This is the crucial part for your workflow. Instead of pushing to a
# registry, it loads the built image directly into your cluster's nodes.
# load: true
artifacts:
# Defines the image to build. It matches the placeholder in deployment.yaml.
- image: homelaboperator
context: . # The build context is the root directory
docker:
dockerfile: Dockerfile
manifests:
helm:
releases:
- name: homelab-operator
chartPath: charts/operator
setValueTemplates:
image.repository: '{{.IMAGE_REPO_homelaboperator}}'
image.tag: '{{.IMAGE_TAG_homelaboperator}}'
deploy:
# Use kubectl to apply the manifests.
kubectl: {}

View File

@@ -1,9 +1,10 @@
import { CloudflareTunnel } from '#resources/homelab/cloudflare-tunnel/cloudflare-tunnel.ts';
import { ResourceService } from '#services/resources/resources.ts';
import type { Services } from '../utils/service.ts'; import type { Services } from '../utils/service.ts';
import { NamespaceService } from './namespaces/namespaces.ts'; import { NamespaceService } from './namespaces/namespaces.ts';
import { ReleaseService } from './releases/releases.ts'; import { ReleaseService } from './releases/releases.ts';
import { RepoService } from './repos/repos.ts'; import { RepoService } from './repos/repos.ts';
import { ClusterIssuerService } from './resources/issuer.ts';
class BootstrapService { class BootstrapService {
#services: Services; #services: Services;
@@ -23,15 +24,18 @@ class BootstrapService {
return this.#services.get(ReleaseService); return this.#services.get(ReleaseService);
} }
public get clusterIssuer() { public get cloudflareTunnel() {
return this.#services.get(ClusterIssuerService); const resourceService = this.#services.get(ResourceService);
return resourceService.get(CloudflareTunnel, 'cloudflare-tunnel', this.namespaces.homelab.name);
} }
public ensure = async () => { public ensure = async () => {
await this.namespaces.ensure(); await this.namespaces.ensure();
await this.repos.ensure(); await this.repos.ensure();
await this.releases.ensure(); await this.releases.ensure();
await this.clusterIssuer.ensure(); await this.cloudflareTunnel.ensure({
spec: {},
});
}; };
} }

View File

@@ -1,38 +1,19 @@
import { NamespaceInstance } from '../../instances/namespace.ts';
import type { Services } from '../../utils/service.ts'; import type { Services } from '../../utils/service.ts';
import { ResourceService } from '../../services/resources/resources.ts'; import { ResourceService } from '../../services/resources/resources.ts';
import { Namespace } from '#resources/core/namespace/namespace.ts';
class NamespaceService { class NamespaceService {
#homelab: NamespaceInstance; #homelab: Namespace;
#istioSystem: NamespaceInstance; #istioSystem: Namespace;
#certManager: NamespaceInstance; #certManager: Namespace;
constructor(services: Services) { constructor(services: Services) {
const resourceService = services.get(ResourceService); const resourceService = services.get(ResourceService);
this.#homelab = resourceService.getInstance( this.#homelab = resourceService.get(Namespace, 'homelab');
{ this.#istioSystem = resourceService.get(Namespace, 'istio-system');
apiVersion: 'v1', this.#certManager = resourceService.get(Namespace, 'cert-manager');
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.#homelab.on('changed', this.ensure);
this.#istioSystem.on('changed', this.ensure); this.#istioSystem.on('changed', this.ensure);
this.#certManager.on('changed', this.ensure); this.#certManager.on('changed', this.ensure);

View File

@@ -1,56 +1,26 @@
import { HelmReleaseInstance } from '../../instances/helm-release.ts';
import { ResourceService } from '../../services/resources/resources.ts'; import { ResourceService } from '../../services/resources/resources.ts';
import { NAMESPACE } from '../../utils/consts.ts'; import { NAMESPACE } from '../../utils/consts.ts';
import { Services } from '../../utils/service.ts'; import { Services } from '../../utils/service.ts';
import { NamespaceService } from '../namespaces/namespaces.ts'; import { NamespaceService } from '../namespaces/namespaces.ts';
import { RepoService } from '../repos/repos.ts'; import { RepoService } from '../repos/repos.ts';
import { HelmRelease } from '#resources/flux/helm-release/helm-release.ts';
class ReleaseService { class ReleaseService {
#services: Services; #services: Services;
#certManager: HelmReleaseInstance; #certManager: HelmRelease;
#istioBase: HelmReleaseInstance; #istioBase: HelmRelease;
#istiod: HelmReleaseInstance; #istiod: HelmRelease;
#istioGateway: HelmReleaseInstance; #istioGateway: HelmRelease;
constructor(services: Services) { constructor(services: Services) {
this.#services = services; this.#services = services;
const resourceService = services.get(ResourceService); const resourceService = services.get(ResourceService);
this.#certManager = resourceService.getInstance( this.#certManager = resourceService.get(HelmRelease, 'cert-manager', NAMESPACE);
{ this.#istioBase = resourceService.get(HelmRelease, 'istio-base', NAMESPACE);
apiVersion: 'helm.toolkit.fluxcd.io/v2', this.#istiod = resourceService.get(HelmRelease, 'istiod', NAMESPACE);
kind: 'HelmRelease', this.#istioGateway = resourceService.get(HelmRelease, 'istio-gateway', NAMESPACE);
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.#certManager.on('changed', this.ensure);
this.#istioBase.on('changed', this.ensure); this.#istioBase.on('changed', this.ensure);
this.#istiod.on('changed', this.ensure); this.#istiod.on('changed', this.ensure);

View File

@@ -1,110 +1,59 @@
import type { Services } from '../../utils/service.ts'; import type { Services } from '../../utils/service.ts';
import { ResourceService } from '../../services/resources/resources.ts'; import { ResourceService } from '../../services/resources/resources.ts';
import { HelmRepoInstance } from '../../instances/helm-repo.ts';
import { NAMESPACE } from '../../utils/consts.ts'; import { NAMESPACE } from '../../utils/consts.ts';
import { HelmRepo } from '#resources/flux/helm-repo/helm-repo.ts';
class RepoService { class RepoService {
#jetstack: HelmRepoInstance; #jetstack: HelmRepo;
#istio: HelmRepoInstance; #istio: HelmRepo;
#authentik: HelmRepoInstance; #authentik: HelmRepo;
#containerro: HelmRepoInstance; #cloudflare: HelmRepo;
constructor(services: Services) { constructor(services: Services) {
const resourceService = services.get(ResourceService); const resourceService = services.get(ResourceService);
this.#jetstack = resourceService.getInstance( this.#jetstack = resourceService.get(HelmRepo, 'jetstack', NAMESPACE);
{ this.#istio = resourceService.get(HelmRepo, 'istio', NAMESPACE);
apiVersion: 'source.toolkit.fluxcd.io/v1', this.#authentik = resourceService.get(HelmRepo, 'authentik', NAMESPACE);
kind: 'HelmRepository', this.#cloudflare = resourceService.get(HelmRepo, 'cloudflare', NAMESPACE);
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.#jetstack.on('changed', this.ensure);
this.#istio.on('changed', this.ensure); this.#istio.on('changed', this.ensure);
this.#authentik.on('changed', this.ensure); this.#authentik.on('changed', this.ensure);
this.#containerro.on('changed', this.ensure); this.#cloudflare.on('changed', this.ensure);
} }
public get jetstack() { public get jetstack() {
return this.#jetstack; return this.#jetstack;
} }
public get istio() { public get istio() {
return this.#istio; return this.#istio;
} }
public get authentik() { public get authentik() {
return this.#authentik; return this.#authentik;
} }
public get containerro() {
return this.#containerro; public get cloudflare() {
return this.#cloudflare;
} }
public ensure = async () => { public ensure = async () => {
await this.#jetstack.ensure({ await this.#jetstack.set({
metadata: { url: 'https://charts.jetstack.io',
name: 'jetstack',
},
spec: {
interval: '1h',
url: 'https://charts.jetstack.io',
},
}); });
await this.#istio.ensure({ await this.#istio.set({
metadata: { url: 'https://istio-release.storage.googleapis.com/charts',
name: 'istio',
},
spec: {
interval: '1h',
url: 'https://istio-release.storage.googleapis.com/charts',
},
}); });
await this.#authentik.ensure({ await this.#authentik.set({
metadata: { url: 'https://charts.goauthentik.io',
name: 'authentik',
},
spec: {
interval: '1h',
url: 'https://charts.goauthentik.io',
},
}); });
await this.#containerro.ensure({ await this.#cloudflare.set({
metadata: { url: 'https://cloudflare.github.io/helm-charts',
name: 'containerro',
},
spec: {
interval: '1h',
url: 'https://charts.containeroo.ch',
},
}); });
}; };
} }

View File

@@ -1,64 +0,0 @@
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,175 +0,0 @@
import type { V1Secret } from '@kubernetes/client-node';
import type { z } from 'zod';
import {
CustomResource,
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 { decodeSecret, encodeSecret } from '../../utils/secrets.ts';
import { CONTROLLED_LABEL } from '../../utils/consts.ts';
import { isDeepSubset } from '../../utils/objects.ts';
import { AuthentikService } from '../../services/authentik/authentik.service.ts';
import { authentikServerSecretSchema } from '../authentik-server/authentik-server.schemas.ts';
import { authentikClientSecretSchema, type authentikClientSpecSchema } from './authentik-client.schemas.ts';
class AuthentikClientResource extends CustomResource<typeof authentikClientSpecSchema> {
#serverSecret: ResourceReference<V1Secret>;
#clientSecretResource: Resource<V1Secret>;
constructor(options: CustomResourceOptions<typeof authentikClientSpecSchema>) {
super(options);
const resourceService = this.services.get(ResourceService);
this.#serverSecret = new ResourceReference();
this.#clientSecretResource = resourceService.get({
apiVersion: 'v1',
kind: 'Secret',
name: `authentik-client-${this.name}`,
namespace: this.namespace,
});
this.#updateResouces();
this.#serverSecret.on('changed', this.queueReconcile);
this.#clientSecretResource.on('changed', this.queueReconcile);
}
#updateResouces = () => {
const serverSecretNames = getWithNamespace(`${this.spec.server}-server`, this.namespace);
const resourceService = this.services.get(ResourceService);
this.#serverSecret.current = resourceService.get({
apiVersion: 'v1',
kind: 'Secret',
name: serverSecretNames.name,
namespace: serverSecretNames.namespace,
});
};
#reconcileClientSecret = async (): Promise<SubresourceResult> => {
const serverSecret = this.#serverSecret.current;
if (!serverSecret?.exists || !serverSecret.data) {
return {
ready: false,
failed: true,
message: 'Server or 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 url = serverSecretData.data.url;
const appName = this.name;
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(),
token: new URL(`/application/o/${appName}/token/`, url).toString(),
userinfo: new URL(`/application/o/${appName}/userinfo/`, url).toString(),
endSession: new URL(`/application/o/${appName}/end-session/`, url).toString(),
jwks: new URL(`/application/o/${appName}/jwks/`, url).toString(),
};
if (!isDeepSubset(clientSecretData.data, expectedValues)) {
await this.#clientSecretResource.patch({
metadata: {
ownerReferences: [this.ref],
labels: {
...CONTROLLED_LABEL,
},
},
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) {
return;
}
this.#updateResouces();
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 && serverReady ? 'True' : 'False',
});
};
}
export { AuthentikClientResource };

View File

@@ -1,28 +0,0 @@
import { ClientTypeEnum, SubModeEnum } from '@goauthentik/api';
import { z } from 'zod';
const authentikClientSpecSchema = z.object({
server: z.string(),
subMode: z.enum(SubModeEnum).optional(),
clientType: z.enum(ClientTypeEnum).optional(),
redirectUris: z.array(
z.object({
url: z.string(),
matchingMode: z.enum(['strict', 'regex']),
}),
),
});
const authentikClientSecretSchema = z.object({
clientId: z.string(),
clientSecret: z.string().optional(),
configuration: z.string(),
configurationIssuer: z.string(),
authorization: z.string(),
token: z.string(),
userinfo: z.string(),
endSession: z.string(),
jwks: z.string(),
});
export { authentikClientSpecSchema, authentikClientSecretSchema };

View File

@@ -1,19 +0,0 @@
import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts';
import { GROUP } from '../../utils/consts.ts';
import { AuthentikClientResource } from './authentik-client.resource.ts';
import { authentikClientSpecSchema } from './authentik-client.schemas.ts';
const authentikClientDefinition = createCustomResourceDefinition({
group: GROUP,
version: 'v1',
kind: 'AuthentikClient',
names: {
plural: 'authentikclients',
singular: 'authentikclient',
},
create: (options) => new AuthentikClientResource(options),
spec: authentikClientSpecSchema,
});
export { authentikClientDefinition };

View File

@@ -1,271 +0,0 @@
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 { PostgresDatabaseInstance } from '../../instances/postgres-database.ts';
import {
authentikServerInitSecretSchema,
authentikServerSecretSchema,
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>>;
#postgresDatabase: PostgresDatabaseInstance;
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<typeof authentikServerSecretSchema>,
);
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.#postgresDatabase = resourceService.getInstance(
{
apiVersion: API_VERSION,
kind: 'PostgresDatabase',
name: this.name,
namespace: this.namespace,
},
PostgresDatabaseInstance,
);
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) {
await this.markNotReady('MissingAuthentikInitSecret', 'The authentik init secret is not found');
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,
});
await this.#postgresDatabase.ensure({
metadata: {
ownerReferences: [this.ref],
},
spec: {
cluster: this.spec.postgresCluster,
},
});
const postgresSecret = this.#postgresDatabase.secret;
if (!postgresSecret.exists) {
await this.markNotReady('MissingPostgresSecret', 'The postgres secret is not found');
return;
}
const postgresSecretData = decodeSecret(postgresSecret.data) || {};
if (!this.#environment.current?.exists) {
await this.markNotReady(
'MissingEnvironment',
`Environment ${this.#environment.current?.namespace}/${this.#environment.current?.name} not found`,
);
return;
}
const domain = this.#environment.current.spec?.domain;
if (!domain) {
await this.markNotReady('MissingDomain', 'The domain is not set');
return;
}
const secretData = {
url: `https://${this.spec.subdomain}.${domain}`,
host: `${this.name}-server.${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: postgresSecretData.host,
name: postgresSecretData.database,
user: postgresSecretData.username,
password: 'file:///postgres-creds/password',
},
redis: {
host: redisHost,
},
},
server: {
volumes: [
{
name: 'postgres-creds',
secret: {
secretName: postgresSecret.name,
},
},
],
volumeMounts: [
{
name: 'postgres-creds',
mountPath: '/postgres-creds',
readOnly: true,
},
],
},
worker: {
volumes: [
{
name: 'postgres-creds',
secret: {
secretName: postgresSecret.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,
},
},
},
});
await this.markReady();
};
}
export { AuthentikServerController };

View File

@@ -1,23 +0,0 @@
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,19 +0,0 @@
import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts';
import { GROUP } from '../../utils/consts.ts';
import { authentikServerSpecSchema } from './authentik-server.schemas.ts';
import { AuthentikServerController } from './authentik-server.controller.ts';
const authentikServerDefinition = createCustomResourceDefinition({
group: GROUP,
version: 'v1',
kind: 'AuthentikServer',
names: {
plural: 'authentikservers',
singular: 'authentikserver',
},
spec: authentikServerSpecSchema,
create: (options) => new AuthentikServerController(options),
});
export { authentikServerDefinition };

View File

@@ -1,21 +0,0 @@
import { authentikClientDefinition } from './authentik-client/authentik-client.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 = [
postgresDatabaseDefinition,
authentikClientDefinition,
generateSecretDefinition,
environmentDefinition,
postgresClusterDefinition,
authentikServerDefinition,
httpServiceDefinition,
redisServerDefinition,
];
export { customResources };

View File

@@ -1,224 +0,0 @@
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

@@ -1,17 +0,0 @@
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

@@ -1,19 +0,0 @@
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

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

@@ -1,17 +0,0 @@
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

@@ -1,19 +0,0 @@
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

@@ -1,100 +0,0 @@
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

@@ -1,18 +0,0 @@
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

@@ -1,19 +0,0 @@
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

@@ -1,155 +0,0 @@
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,20 +0,0 @@
import { z } from 'zod';
const postgresClusterSpecSchema = z.object({
environment: z.string(),
storage: z
.object({
size: z.string().optional(),
})
.optional(),
});
const postgresClusterSecretSchema = z.object({
database: z.string(),
host: z.string(),
port: z.string(),
username: z.string(),
password: z.string(),
});
export { postgresClusterSpecSchema, postgresClusterSecretSchema };

View File

@@ -1,19 +0,0 @@
import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts';
import { GROUP } from '../../utils/consts.ts';
import { PostgresClusterController } from './postgres-cluster.controller.ts';
import { postgresClusterSpecSchema } from './postgres-cluster.schemas.ts';
const postgresClusterDefinition = createCustomResourceDefinition({
group: GROUP,
version: 'v1',
kind: 'PostgresCluster',
names: {
plural: 'postgres-clusters',
singular: 'postgres-cluster',
},
spec: postgresClusterSpecSchema,
create: (options) => new PostgresClusterController(options),
});
export { postgresClusterDefinition };

View File

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

View File

@@ -1,167 +0,0 @@
import type { V1Secret } from '@kubernetes/client-node';
import {
CustomResource,
type CustomResourceOptions,
type SubresourceResult,
} from '../../services/custom-resources/custom-resources.custom-resource.ts';
import { PostgresService } from '../../services/postgres/postgres.service.ts';
import { ResourceReference } from '../../services/resources/resources.ref.ts';
import { ResourceService } from '../../services/resources/resources.ts';
import { getWithNamespace } from '../../utils/naming.ts';
import { decodeSecret } from '../../utils/secrets.ts';
import { postgresClusterSecretSchema } from '../postgres-cluster/postgres-cluster.schemas.ts';
import { SecretInstance } from '../../instances/secret.ts';
import { type postgresDatabaseSpecSchema } from './portgres-database.schemas.ts';
const SECRET_READY_CONDITION = 'Secret';
const DATABASE_READY_CONDITION = 'Database';
class PostgresDatabaseResource extends CustomResource<typeof postgresDatabaseSpecSchema> {
#clusterSecret: ResourceReference<V1Secret>;
#databaseSecret: SecretInstance<typeof postgresClusterSecretSchema>;
constructor(options: CustomResourceOptions<typeof postgresDatabaseSpecSchema>) {
super(options);
const resourceService = this.services.get(ResourceService);
this.#clusterSecret = new ResourceReference();
this.#databaseSecret = resourceService.getInstance(
{
apiVersion: 'v1',
kind: 'Secret',
name: `${this.name}-postgres-database`,
namespace: this.namespace,
},
SecretInstance<typeof postgresClusterSecretSchema>,
);
this.#updateSecret();
this.#clusterSecret.on('changed', this.queueReconcile);
this.#databaseSecret.on('changed', this.queueReconcile);
}
get #dbName() {
return `${this.namespace}_${this.name}`;
}
get #userName() {
return `${this.namespace}_${this.name}`;
}
#updateSecret = () => {
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,
namespace: secretNames.namespace,
});
};
#reconcileSecret = async (): Promise<SubresourceResult> => {
const serverSecret = this.#clusterSecret.current;
const databaseSecret = this.#databaseSecret;
if (!serverSecret?.exists || !serverSecret.data) {
return {
ready: false,
failed: true,
reason: 'MissingConnectionSecret',
};
}
const serverSecretData = postgresClusterSecretSchema.safeParse(decodeSecret(serverSecret.data));
if (!serverSecretData.success || !serverSecretData.data) {
return {
ready: false,
syncing: true,
reason: 'SecretMissing',
};
}
const databaseSecretData = postgresClusterSecretSchema.safeParse(decodeSecret(databaseSecret.data));
const expectedSecret = {
password: crypto.randomUUID(),
host: serverSecretData.data.host,
port: serverSecretData.data.port,
username: this.#userName,
database: this.#dbName,
...databaseSecretData.data,
};
await databaseSecret.ensureData(expectedSecret);
return {
ready: true,
};
};
#reconcileDatabase = async (): Promise<SubresourceResult> => {
const clusterSecret = this.#clusterSecret.current;
if (!clusterSecret?.exists || !clusterSecret.data) {
return {
ready: false,
failed: true,
reason: 'MissingConnectionSecret',
};
}
const connectionSecretData = postgresClusterSecretSchema.safeParse(decodeSecret(clusterSecret.data));
if (!connectionSecretData.success || !connectionSecretData.data) {
return {
ready: false,
syncing: true,
reason: 'SecretMissing',
};
}
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.username,
password: secretData.data.password,
});
await database.upsertDatabase({
name: secretData.data.database,
owner: secretData.data.username,
});
return {
ready: true,
};
};
public reconcile = async () => {
if (!this.exists || this.metadata?.deletionTimestamp) {
return;
}
this.#updateSecret();
await Promise.allSettled([
this.reconcileSubresource(DATABASE_READY_CONDITION, this.#reconcileDatabase),
this.reconcileSubresource(SECRET_READY_CONDITION, this.#reconcileSecret),
]);
const secretReady = this.conditions.get(SECRET_READY_CONDITION)?.status === 'True';
const databaseReady = this.conditions.get(DATABASE_READY_CONDITION)?.status === 'True';
await this.conditions.set('Ready', {
status: secretReady && databaseReady ? 'True' : 'False',
});
};
}
export { PostgresDatabaseResource };

View File

@@ -1,19 +0,0 @@
import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts';
import { GROUP } from '../../utils/consts.ts';
import { postgresDatabaseSpecSchema } from './portgres-database.schemas.ts';
import { PostgresDatabaseResource } from './postgres-database.resource.ts';
const postgresDatabaseDefinition = createCustomResourceDefinition({
group: GROUP,
version: 'v1',
kind: 'PostgresDatabase',
names: {
plural: 'postgresdatabases',
singular: 'postgresdatabase',
},
spec: postgresDatabaseSpecSchema,
create: (options) => new PostgresDatabaseResource(options),
});
export { postgresDatabaseDefinition };

View File

@@ -1,82 +0,0 @@
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,5 +0,0 @@
import { z } from 'zod';
const redisServerSpecSchema = z.object({});
export { redisServerSpecSchema };

View File

@@ -1,19 +0,0 @@
import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts';
import { GROUP } from '../../utils/consts.ts';
import { RedisServerController } from './redis-server.controller.ts';
import { redisServerSpecSchema } from './redis-server.schemas.ts';
const redisServerDefinition = createCustomResourceDefinition({
group: GROUP,
version: 'v1',
kind: 'RedisServer',
names: {
plural: 'redis-servers',
singular: 'redis-server',
},
spec: redisServerSpecSchema,
create: (options) => new RedisServerController(options),
});
export { redisServerDefinition };

View File

@@ -1,115 +1,17 @@
import { BootstrapService } from './bootstrap/bootstrap.ts'; import { ResourceService } from './services/resources/resources.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 { StorageProvider } from './storage-provider/storage-provider.ts';
import { Services } from './utils/service.ts'; import { Services } from './utils/service.ts';
import { BootstrapService } from './bootstrap/bootstrap.ts';
import { resources } from '#resources/resources.ts';
import { homelab } from '#resources/homelab/homelab.ts';
const services = new Services(); const services = new Services();
const resourceService = services.get(ResourceService);
const watcherService = services.get(WatcherService); await resourceService.install(...Object.values(homelab));
await watcherService.watchCustomGroup('source.toolkit.fluxcd.io', 'v1', ['helmrepositories', 'gitrepositories']); await resourceService.register(...Object.values(resources));
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 const bootstrapService = services.get(BootstrapService);
.create({ await bootstrapService.ensure();
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 console.log('Started');
.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',
list: async (k8s) => {
return await k8s.extensionsApi.listCustomResourceDefinition();
},
verbs: ['add', 'update', 'delete'],
transform: (manifest) => ({
apiVersion: 'apiextensions.k8s.io/v1',
kind: 'CustomResourceDefinition',
...manifest,
}),
})
.start();
await watcherService
.create({
path: '/apis/storage.k8s.io/v1/storageclasses',
list: async (k8s) => {
return await k8s.storageApi.listStorageClass();
},
verbs: ['add', 'update', 'delete'],
transform: (manifest) => ({
apiVersion: 'storage.k8s.io/v1',
kind: 'StorageClass',
...manifest,
}),
})
.start();
const storageProvider = services.get(StorageProvider);
await storageProvider.start();
const bootstrap = services.get(BootstrapService);
await bootstrap.ensure();
const customResourceService = services.get(CustomResourceService);
customResourceService.register(...customResources);
await customResourceService.install(true);
await customResourceService.watch();

View File

@@ -1,7 +0,0 @@
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

@@ -1,8 +0,0 @@
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

@@ -1,12 +0,0 @@
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

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

View File

@@ -1,11 +0,0 @@
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

@@ -1,12 +0,0 @@
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

@@ -1,7 +0,0 @@
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 };

View File

@@ -1,8 +0,0 @@
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 };

View File

@@ -1,12 +0,0 @@
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

@@ -1,12 +0,0 @@
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

@@ -1,16 +0,0 @@
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

@@ -1,7 +0,0 @@
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

@@ -1,11 +0,0 @@
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

@@ -1,7 +0,0 @@
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

@@ -1,23 +0,0 @@
import type { postgresDatabaseSpecSchema } from '../custom-resouces/postgres-database/portgres-database.schemas.ts';
import type { CustomResourceObject } from '../services/custom-resources/custom-resources.custom-resource.ts';
import { ResourceInstance } from '../services/resources/resources.instance.ts';
import { ResourceService } from '../services/resources/resources.ts';
import { SecretInstance } from './secret.ts';
class PostgresDatabaseInstance extends ResourceInstance<CustomResourceObject<typeof postgresDatabaseSpecSchema>> {
public get secret() {
const resourceService = this.services.get(ResourceService);
return resourceService.getInstance(
{
apiVersion: 'v1',
kind: 'Secret',
name: `${this.name}-postgres-database`,
namespace: this.namespace,
},
SecretInstance,
);
}
}
export { PostgresDatabaseInstance };

View File

@@ -1,7 +0,0 @@
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 };

View File

@@ -1,23 +0,0 @@
import type { V1Secret } from '@kubernetes/client-node';
import type { z, ZodObject } from 'zod';
import { ResourceInstance } from '../services/resources/resources.instance.ts';
import { decodeSecret, encodeSecret } from '../utils/secrets.ts';
class SecretInstance<T extends ZodObject = ExpectedAny> extends ResourceInstance<V1Secret> {
public get values() {
return decodeSecret(this.data) as z.infer<T>;
}
public ensureData = async (values: z.infer<T>) => {
await this.ensure({
data: encodeSecret(values as Record<string, string>),
});
};
public get ready() {
return this.exists;
}
}
export { SecretInstance };

View File

@@ -1,11 +0,0 @@
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

@@ -1,11 +0,0 @@
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

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

View File

@@ -1,12 +0,0 @@
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

@@ -0,0 +1,9 @@
import { Certificate } from './certificate/certificate.ts';
import type { ResourceClass } from '#services/resources/resources.ts';
const certManager = {
certificate: Certificate,
} satisfies Record<string, ResourceClass<ExpectedAny>>;
export { certManager };

View File

@@ -0,0 +1,37 @@
import type { KubernetesObject } from '@kubernetes/client-node';
import type { K8SCertificateV1 } from 'src/__generated__/resources/K8SCertificateV1.ts';
import { CRD } from '#resources/core/crd/crd.ts';
import { Resource, ResourceService, type ResourceOptions } from '#services/resources/resources.ts';
import { NotReadyError } from '#utils/errors.ts';
class Certificate extends Resource<KubernetesObject & K8SCertificateV1> {
public static readonly apiVersion = 'cert-manager.io/v1';
public static readonly kind = 'Certificate';
#crd: CRD;
constructor(options: ResourceOptions<KubernetesObject & K8SCertificateV1>) {
super(options);
const resourceService = this.services.get(ResourceService);
this.#crd = resourceService.get(CRD, 'certificates.cert-manager.io');
this.#crd.on('changed', this.#handleCrdChanged);
}
#handleCrdChanged = () => {
this.emit('changed', this.manifest);
};
public get hasCRD() {
return this.#crd.exists;
}
public set = async (manifest: KubernetesObject & K8SCertificateV1) => {
if (!this.hasCRD) {
throw new NotReadyError('MissingCRD', 'certificates.cert-manager.io does not exist');
}
return this.ensure(manifest);
};
}
export { Certificate };

View File

@@ -0,0 +1,23 @@
import { CRD } from './crd/crd.ts';
import { Deployment } from './deployment/deployment.ts';
import { Namespace } from './namespace/namespace.ts';
import { PersistentVolume } from './pv/pv.ts';
import { PVC } from './pvc/pvc.ts';
import { Secret } from './secret/secret.ts';
import { Service } from './service/service.ts';
import { StatefulSet } from './stateful-set/stateful-set.ts';
import { StorageClass } from './storage-class/storage-class.ts';
const core = {
namespace: Namespace,
storageClass: StorageClass,
pvc: PVC,
pv: PersistentVolume,
secret: Secret,
crd: CRD,
service: Service,
deployment: Deployment,
statefulSet: StatefulSet,
};
export { core };

View File

@@ -0,0 +1,10 @@
import type { V1CustomResourceDefinition } from '@kubernetes/client-node';
import { Resource } from '#services/resources/resources.ts';
class CRD extends Resource<V1CustomResourceDefinition> {
public static readonly apiVersion = 'apiextensions.k8s.io/v1';
public static readonly kind = 'CustomResourceDefinition';
}
export { CRD };

View File

@@ -0,0 +1,10 @@
import type { V1Deployment } from '@kubernetes/client-node';
import { Resource } from '#services/resources/resources.ts';
class Deployment extends Resource<V1Deployment> {
public static readonly apiVersion = 'apps/v1';
public static readonly kind = 'Deployment';
}
export { Deployment };

View File

@@ -0,0 +1,10 @@
import type { V1Namespace } from '@kubernetes/client-node';
import { Resource } from '#services/resources/resources.ts';
class Namespace extends Resource<V1Namespace> {
public static readonly apiVersion = 'v1';
public static readonly kind = 'Namespace';
}
export { Namespace };

View File

@@ -0,0 +1,10 @@
import type { V1PersistentVolume } from '@kubernetes/client-node';
import { Resource } from '#services/resources/resources.ts';
class PersistentVolume extends Resource<V1PersistentVolume> {
public static readonly apiVersion = 'v1';
public static readonly kind = 'PersistentVolume';
}
export { PersistentVolume };

View File

@@ -0,0 +1,80 @@
import type { V1PersistentVolumeClaim } from '@kubernetes/client-node';
import { StorageClass } from '../storage-class/storage-class.ts';
import { PersistentVolume } from '../pv/pv.ts';
import { Resource, ResourceService, type ResourceOptions } from '#services/resources/resources.ts';
const PROVISIONER = 'homelab-operator';
class PVC extends Resource<V1PersistentVolumeClaim> {
public static readonly apiVersion = 'v1';
public static readonly kind = 'PersistentVolumeClaim';
constructor(options: ResourceOptions<V1PersistentVolumeClaim>) {
super(options);
this.on('changed', this.reconcile);
}
public reconcile = async () => {
const storageClassName = this.spec?.storageClassName;
console.log('PVC', this.name, storageClassName);
if (!storageClassName) {
return;
}
const resourceService = this.services.get(ResourceService);
const storageClass = resourceService.get(StorageClass, storageClassName);
if (!storageClass.exists || storageClass.manifest?.provisioner !== PROVISIONER) {
return;
}
if (this.status?.phase === 'Pending' && !this.spec?.volumeName) {
await this.#provisionVolume(storageClass);
}
};
#provisionVolume = async (storageClass: StorageClass) => {
const pvName = `pv-${this.namespace}-${this.name}`;
const storageLocation = storageClass.manifest?.parameters?.storageLocation || '/data/volumes';
const target = `${storageLocation}/${this.namespace}/${this.name}`;
const resourceService = this.services.get(ResourceService);
const pv = resourceService.get(PersistentVolume, pvName);
await pv.ensure({
metadata: {
name: pvName,
labels: {
provisioner: PROVISIONER,
'pvc-namespace': this.namespace || 'default',
'pvc-name': this.name || 'unknown',
},
annotations: {
'pv.kubernetes.io/provisioned-by': PROVISIONER,
},
},
spec: {
hostPath: {
path: target,
type: 'DirectoryOrCreate',
},
capacity: {
storage: this.spec?.resources?.requests?.storage ?? '1Gi',
},
persistentVolumeReclaimPolicy: 'Retain',
accessModes: this.spec?.accessModes ?? ['ReadWriteOnce'],
storageClassName: this.spec?.storageClassName,
claimRef: {
uid: this.metadata?.uid,
resourceVersion: this.metadata?.resourceVersion,
apiVersion: this.apiVersion,
kind: 'PersistentVolumeClaim',
name: this.name,
namespace: this.namespace,
},
},
});
};
}
export { PVC, PROVISIONER };

View File

@@ -0,0 +1,25 @@
import type { KubernetesObject, V1Secret } from '@kubernetes/client-node';
import { Resource } from '#services/resources/resources.ts';
import { decodeSecret, encodeSecret } from '#utils/secrets.ts';
type SetOptions<T extends Record<string, string | undefined>> = T | ((current: T | undefined) => T | Promise<T>);
class Secret<T extends Record<string, string> = Record<string, string>> extends Resource<V1Secret> {
public static readonly apiVersion = 'v1';
public static readonly kind = 'Secret';
public get value() {
return decodeSecret(this.data) as T | undefined;
}
public set = async (options: SetOptions<T>, data?: KubernetesObject) => {
const value = typeof options === 'function' ? await Promise.resolve(options(this.value)) : options;
await this.ensure({
...data,
data: encodeSecret(value),
});
};
}
export { Secret };

View File

@@ -0,0 +1,14 @@
import type { V1Service } from '@kubernetes/client-node';
import { Resource } from '#services/resources/resources.ts';
class Service extends Resource<V1Service> {
public static readonly apiVersion = 'v1';
public static readonly kind = 'Service';
public get hostname() {
return `${this.name}.${this.namespace}.svc.cluster.local`;
}
}
export { Service };

View File

@@ -0,0 +1,10 @@
import type { V1StatefulSet } from '@kubernetes/client-node';
import { Resource } from '#services/resources/resources.ts';
class StatefulSet extends Resource<V1StatefulSet> {
public static readonly apiVersion = 'apps/v1';
public static readonly kind = 'StatefulSet';
}
export { StatefulSet };

View File

@@ -0,0 +1,11 @@
import type { V1StorageClass } from '@kubernetes/client-node';
import { Resource } from '#services/resources/resources.ts';
class StorageClass extends Resource<V1StorageClass> {
public static readonly apiVersion = 'storage.k8s.io/v1';
public static readonly kind = 'StorageClass';
public static readonly plural = 'storageclasses';
}
export { StorageClass };

View File

@@ -0,0 +1,11 @@
import { HelmRelease } from './helm-release/helm-release.ts';
import { HelmRepo } from './helm-repo/helm-repo.ts';
import type { ResourceClass } from '#services/resources/resources.ts';
const flux = {
helmRelease: HelmRelease,
helmRepo: HelmRepo,
} satisfies Record<string, ResourceClass<ExpectedAny>>;
export { flux };

View File

@@ -0,0 +1,42 @@
import type { KubernetesObject } from '@kubernetes/client-node';
import type { K8SHelmReleaseV2 } from 'src/__generated__/resources/K8SHelmReleaseV2.ts';
import { Resource } from '#services/resources/resources.ts';
type SetOptions = {
namespace?: string;
values?: Record<string, unknown>;
chart: {
name: string;
namespace?: string;
};
};
class HelmRelease extends Resource<KubernetesObject & K8SHelmReleaseV2> {
public static readonly apiVersion = 'helm.toolkit.fluxcd.io/v2';
public static readonly kind = 'HelmRelease';
public set = async (options: SetOptions) => {
return await this.ensure({
spec: {
targetNamespace: options.namespace,
interval: '1h',
values: options.values,
chart: {
spec: {
chart: 'cert-manager',
version: 'v1.18.2',
sourceRef: {
apiVersion: 'source.toolkit.fluxcd.io/v1',
kind: 'HelmRepository',
name: options.chart.name,
namespace: options.chart.namespace,
},
},
},
},
});
};
}
export { HelmRelease };

View File

@@ -0,0 +1,24 @@
import type { KubernetesObject } from '@kubernetes/client-node';
import type { K8SHelmRepositoryV1 } from 'src/__generated__/resources/K8SHelmRepositoryV1.ts';
import { Resource } from '#services/resources/resources.ts';
type SetOptions = {
url: string;
};
class HelmRepo extends Resource<KubernetesObject & K8SHelmRepositoryV1> {
public static readonly apiVersion = 'source.toolkit.fluxcd.io/v1';
public static readonly kind = 'HelmRepository';
public static readonly plural = 'helmrepositories';
public set = async ({ url }: SetOptions) => {
await this.ensure({
spec: {
interval: '1h',
url,
},
});
};
}
export { HelmRepo };

View File

@@ -0,0 +1,284 @@
import { z } from 'zod';
import { PostgresDatabase } from '../postgres-database/postgres-database.ts';
import { Environment } from '../environment/environment.ts';
import {
CustomResource,
ResourceReference,
ResourceService,
type CustomResourceOptions,
} from '#services/resources/resources.ts';
import { API_VERSION } from '#utils/consts.ts';
import { Secret } from '#resources/core/secret/secret.ts';
import { generateRandomHexPass } from '#utils/secrets.ts';
import { Service } from '#resources/core/service/service.ts';
import { HelmRelease } from '#resources/flux/helm-release/helm-release.ts';
import { RepoService } from '#bootstrap/repos/repos.ts';
import { VirtualService } from '#resources/istio/virtual-service/virtual-service.ts';
import { DestinationRule } from '#resources/istio/destination-rule/destination-rule.ts';
import { NotReadyError } from '#utils/errors.ts';
const specSchema = z.object({
environment: z.string(),
subdomain: z.string().optional(),
});
type SecretData = { url: string; host: string; token: string };
type InitSecretData = {
AUTHENTIK_BOOTSTRAP_TOKEN: string;
AUTHENTIK_BOOTSTRAP_PASSWORD: string;
AUTHENTIK_BOOTSTRAP_EMAIL: string;
AUTHENTIK_SECRET_KEY: string;
};
class AuthentikServer extends CustomResource<typeof specSchema> {
public static readonly apiVersion = API_VERSION;
public static readonly kind = 'AuthentikServer';
public static readonly spec = specSchema;
public static readonly scope = 'Namespaced';
#environment: ResourceReference<typeof Environment>;
#database: PostgresDatabase;
#secret: Secret<SecretData>;
#initSecret: Secret<InitSecretData>;
#service: Service;
#helmRelease: HelmRelease;
#virtualService: VirtualService;
#destinationRule: DestinationRule;
constructor(options: CustomResourceOptions<typeof specSchema>) {
super(options);
const resourceService = this.services.get(ResourceService);
this.#environment = new ResourceReference();
this.#environment.on('changed', this.queueReconcile);
this.#database = resourceService.get(PostgresDatabase, this.name, this.namespace);
this.#database.on('changed', this.queueReconcile);
this.#secret = resourceService.get(Secret<SecretData>, this.name, this.namespace);
this.#secret.on('changed', this.queueReconcile);
this.#initSecret = resourceService.get(Secret<InitSecretData>, `${this.name}-init`, this.namespace);
this.#service = resourceService.get(Service, `${this.name}-server`, this.namespace);
// this.#service.on('changed', this.queueReconcile);
this.#helmRelease = resourceService.get(HelmRelease, this.name, this.namespace);
this.#helmRelease.on('changed', this.queueReconcile);
this.#virtualService = resourceService.get(VirtualService, this.name, this.namespace);
this.#virtualService.on('changed', this.queueReconcile);
this.#destinationRule = resourceService.get(DestinationRule, this.name, this.namespace);
this.#destinationRule.on('changed', this.queueReconcile);
}
public get service() {
return this.#service;
}
public get secret() {
return this.#secret;
}
public get subdomain() {
return this.spec?.subdomain || 'authentik';
}
public get domain() {
return `${this.subdomain}.${this.#environment.current?.spec?.domain}`;
}
public get url() {
return `https://${this.domain}`;
}
public reconcile = async () => {
if (!this.spec) {
throw new NotReadyError('MissingSpec');
}
const resourceService = this.services.get(ResourceService);
this.#environment.current = resourceService.get(Environment, this.spec.environment);
if (!this.#environment.current.spec) {
throw new NotReadyError('MissingEnvSpev');
}
await this.#database.ensure({
metadata: {
ownerReferences: [this.ref],
},
spec: {
environment: this.#environment.current.name,
},
});
const databaseSecret = this.#database.secret.value;
if (!databaseSecret) {
throw new NotReadyError('MissingDatabaseSecret');
}
await this.#initSecret.set(
(current) => ({
AUTHENTIK_BOOTSTRAP_EMAIL: 'admin@example.com',
AUTHENTIK_BOOTSTRAP_PASSWORD: generateRandomHexPass(24),
AUTHENTIK_BOOTSTRAP_TOKEN: generateRandomHexPass(32),
AUTHENTIK_SECRET_KEY: generateRandomHexPass(32),
...current,
}),
{
metadata: {
ownerReferences: [this.ref],
},
},
);
const initSecret = this.#initSecret.value;
if (!initSecret) {
throw new NotReadyError('MissingInitSecret');
}
const domain = `${this.spec?.subdomain || 'authentik'}.${this.#environment.current.spec.domain}`;
await this.#secret.set(
{
url: `https://${domain}`,
host: this.#service.hostname,
token: initSecret.AUTHENTIK_BOOTSTRAP_TOKEN,
},
{
metadata: {
ownerReferences: [this.ref],
},
},
);
const secret = this.#secret.value;
if (!secret) {
throw new NotReadyError('MissingSecret');
}
const repoService = this.services.get(RepoService);
await this.#helmRelease.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.#initSecret.name,
},
},
],
},
authentik: {
error_reporting: {
enabled: false,
},
postgresql: {
host: databaseSecret.host,
name: databaseSecret.database,
user: databaseSecret.user,
password: 'file:///postgres-creds/password',
},
redis: {
host: this.#environment.current.redisServer.service.hostname,
},
},
server: {
volumes: [
{
name: 'postgres-creds',
secret: {
secretName: this.#database.secret.name,
},
},
],
volumeMounts: [
{
name: 'postgres-creds',
mountPath: '/postgres-creds',
readOnly: true,
},
],
},
worker: {
volumes: [
{
name: 'postgres-creds',
secret: {
secretName: this.#database.secret.name,
},
},
],
volumeMounts: [
{
name: 'postgres-creds',
mountPath: '/postgres-creds',
readOnly: true,
},
],
},
},
},
});
await this.#destinationRule.ensure({
metadata: {
ownerReferences: [this.ref],
},
spec: {
host: this.#service.hostname,
trafficPolicy: {
tls: {
mode: 'DISABLE',
},
},
},
});
const gateway = this.#environment.current.gateway;
await this.#virtualService.set({
metadata: {
ownerReferences: [this.ref],
},
spec: {
gateways: [`${gateway.namespace}/${gateway.name}`, 'mesh'],
hosts: [domain],
http: [
{
route: [
{
destination: {
host: this.#service.hostname,
port: {
number: 80,
},
},
},
],
},
],
},
});
};
}
export { AuthentikServer };

View File

@@ -0,0 +1,94 @@
import {
CustomResource,
Resource,
ResourceService,
type CustomResourceOptions,
} from '#services/resources/resources.ts';
import z from 'zod';
import { ExternalHttpService } from '../external-http-service.ts/external-http-service.ts';
import { API_VERSION } from '#utils/consts.ts';
import { HelmRelease } from '#resources/flux/helm-release/helm-release.ts';
import { RepoService } from '#bootstrap/repos/repos.ts';
import { Secret } from '#resources/core/secret/secret.ts';
import { NotReadyError } from '#utils/errors.ts';
import { NamespaceService } from '#bootstrap/namespaces/namespaces.ts';
const specSchema = z.object({});
type SecretData = {
account: string;
tunnelName: string;
tunnelId: string;
secret: string;
};
class CloudflareTunnel extends CustomResource<typeof specSchema> {
public static readonly apiVersion = API_VERSION;
public static readonly kind = 'CloudflareTunnel';
public static readonly spec = specSchema;
public static readonly scope = 'Cluster';
#helmRelease: HelmRelease;
#secret: Secret<SecretData>;
constructor(options: CustomResourceOptions<typeof specSchema>) {
super(options);
const resourceService = this.services.get(ResourceService);
const namespaceService = this.services.get(NamespaceService);
const namespace = namespaceService.homelab.name;
resourceService.on('changed', this.#handleResourceChanged);
this.#helmRelease = resourceService.get(HelmRelease, this.name, namespace);
this.#secret = resourceService.get(Secret<SecretData>, 'cloudflare', namespace);
this.#secret.on('changed', this.queueReconcile);
}
#handleResourceChanged = (resource: Resource<ExpectedAny>) => {
if (resource instanceof CloudflareTunnel) {
this.queueReconcile();
}
};
public reconcile = async () => {
const secret = this.#secret.value;
if (!secret) {
throw new NotReadyError('MissingSecret', `Secret ${this.#secret.namespace}/${this.#secret.name} does not exist`);
}
const resourceService = this.services.get(ResourceService);
const repoService = this.services.get(RepoService);
const routes = resourceService.getAllOfKind(ExternalHttpService);
const ingress = routes.map(({ rule }) => ({
hostname: rule?.hostname,
service: `http://${rule?.destination.host}:${rule?.destination.port.number}`,
}));
await this.#helmRelease.ensure({
metadata: {
ownerReferences: [this.ref],
},
spec: {
interval: '1h',
values: {
cloudflare: {
account: secret.account,
tunnelName: secret.tunnelName,
tunnelId: secret.tunnelId,
secret: secret.secret,
ingress,
},
},
chart: {
spec: {
chart: 'cloudflare-tunnel',
sourceRef: {
apiVersion: 'source.toolkit.fluxcd.io/v1',
kind: 'HelmRepository',
name: repoService.cloudflare.name,
namespace: repoService.cloudflare.namespace,
},
},
},
},
});
};
}
export { CloudflareTunnel };

View File

@@ -0,0 +1,214 @@
import { z } from 'zod';
import { PostgresCluster } from '../postgres-cluster/postgres-cluster.ts';
import { RedisServer } from '../redis-server/redis-server.ts';
import { AuthentikServer } from '../authentik-server/authentik-server.ts';
import { CustomResource, ResourceService, type CustomResourceOptions } from '#services/resources/resources.ts';
import { API_VERSION } from '#utils/consts.ts';
import { Namespace } from '#resources/core/namespace/namespace.ts';
import { Certificate } from '#resources/cert-manager/certificate/certificate.ts';
import { StorageClass } from '#resources/core/storage-class/storage-class.ts';
import { PROVISIONER } from '#resources/core/pvc/pvc.ts';
import { Gateway } from '#resources/istio/gateway/gateway.ts';
import { NotReadyError } from '#utils/errors.ts';
import { NamespaceService } from '#bootstrap/namespaces/namespaces.ts';
import { CloudflareService } from '#services/cloudflare/cloudflare.ts';
const specSchema = z.object({
domain: z.string(),
networkIp: z.string().optional(),
tls: z.object({
issuer: z.string(),
}),
});
class Environment extends CustomResource<typeof specSchema> {
public static readonly apiVersion = API_VERSION;
public static readonly kind = 'Environment';
public static readonly spec = specSchema;
public static readonly scope = 'Cluster';
#namespace: Namespace;
#certificate: Certificate;
#storageClass: StorageClass;
#gateway: Gateway;
#postgresCluster: PostgresCluster;
#redisServer: RedisServer;
#authentikServer: AuthentikServer;
#cloudflareService: CloudflareService;
constructor(options: CustomResourceOptions<typeof specSchema>) {
super(options);
const resourceService = this.services.get(ResourceService);
const namespaceService = this.services.get(NamespaceService);
const homelabNamespace = namespaceService.homelab.name;
this.#cloudflareService = this.services.get(CloudflareService);
this.#cloudflareService.on('changed', this.queueReconcile);
this.#namespace = resourceService.get(Namespace, this.name);
this.#namespace.on('changed', this.queueReconcile);
this.#certificate = resourceService.get(Certificate, this.name, homelabNamespace);
this.#certificate.on('changed', this.queueReconcile);
this.#storageClass = resourceService.get(StorageClass, this.name);
this.#storageClass.on('changed', this.queueReconcile);
this.#postgresCluster = resourceService.get(PostgresCluster, `${this.name}-postgres-cluster`, homelabNamespace);
this.#postgresCluster.on('changed', this.queueReconcile);
this.#redisServer = resourceService.get(RedisServer, `${this.name}-redis-server`, homelabNamespace);
this.#redisServer.on('changed', this.queueReconcile);
this.#gateway = resourceService.get(Gateway, this.name, homelabNamespace);
this.#gateway.on('changed', this.queueReconcile);
this.#authentikServer = resourceService.get(AuthentikServer, `${this.name}-authentik`, homelabNamespace);
this.#authentikServer.on('changed', this.queueReconcile);
}
public get certificate() {
return this.#certificate;
}
public get storageClass() {
return this.#storageClass;
}
public get postgresCluster() {
return this.#postgresCluster;
}
public get redisServer() {
return this.#redisServer;
}
public get gateway() {
return this.#gateway;
}
public get authentikServer() {
return this.#authentikServer;
}
public reconcile = async () => {
const { data: spec, success } = specSchema.safeParse(this.spec);
if (!success || !spec) {
throw new NotReadyError('InvalidSpec');
}
if (this.#cloudflareService.ready && spec.networkIp) {
const client = this.#cloudflareService.client;
const zones = await client.zones.list({
name: spec.domain,
});
const [zone] = zones.result;
if (!zone) {
throw new NotReadyError('NoZoneFound');
}
const existingRecords = await client.dns.records.list({
zone_id: zone.id,
name: {
exact: `*.${spec.domain}`,
},
});
console.log('Cloudflare records', existingRecords);
// zones.result[0].
}
await this.#namespace.ensure({
metadata: {
labels: {
'istio-injection': 'enabled',
},
},
});
await this.#certificate.ensure({
spec: {
secretName: `${this.name}-tls`,
issuerRef: {
name: spec.tls.issuer,
kind: 'ClusterIssuer',
},
dnsNames: [`*.${spec.domain}`],
privateKey: {
rotationPolicy: 'Always',
},
},
});
await this.#storageClass.ensure({
metadata: {
ownerReferences: [this.ref],
},
provisioner: PROVISIONER,
reclaimPolicy: 'Retain',
});
await this.#postgresCluster.ensure({
metadata: {
ownerReferences: [this.ref],
},
spec: {
storageClass: this.name,
},
});
await this.#redisServer.ensure({
metadata: {
ownerReferences: [this.ref],
},
spec: {},
});
await this.#authentikServer.ensure({
metadata: {
ownerReferences: [this.ref],
},
spec: {
environment: this.name,
},
});
await this.#gateway.set({
metadata: {
ownerReferences: [this.ref],
},
spec: {
selector: {
istio: 'homelab-istio-gateway',
},
servers: [
{
hosts: [`*.${spec.domain}`],
port: {
name: 'http',
number: 80,
protocol: 'HTTP',
},
tls: {
httpsRedirect: true,
},
},
{
hosts: [`*.${spec.domain}`],
port: {
name: 'https',
number: 443,
protocol: 'HTTPS',
},
tls: {
mode: 'SIMPLE',
credentialName: `${this.name}-tls`,
},
},
],
},
});
};
}
export { Environment };

View File

@@ -0,0 +1,43 @@
import { CustomResource, ResourceService, type CustomResourceOptions } from '#services/resources/resources.ts';
import { z } from 'zod';
import { Environment } from '../environment/environment.ts';
import { API_VERSION } from '#utils/consts.ts';
const specSchema = z.object({
environment: z.string(),
subdomain: z.string(),
destination: z.object({
host: z.string(),
port: z.object({
number: z.number(),
}),
}),
});
class ExternalHttpService extends CustomResource<typeof specSchema> {
public static readonly apiVersion = API_VERSION;
public static readonly kind = 'ExternalHttpService';
public static readonly spec = specSchema;
public static readonly scope = 'Namespaced';
constructor(options: CustomResourceOptions<typeof specSchema>) {
super(options);
}
public get rule() {
if (!this.spec) {
return undefined;
}
const resourceService = this.services.get(ResourceService);
const env = resourceService.get(Environment, this.spec.environment);
const hostname = `${this.spec.subdomain}.${env.spec?.domain}`;
return {
domain: env.spec?.domain,
subdomain: this.spec.subdomain,
hostname,
destination: this.spec.destination,
};
}
}
export { ExternalHttpService };

View File

@@ -0,0 +1,47 @@
import { Secret } from '#resources/core/secret/secret.ts';
import { CustomResource, ResourceService, type CustomResourceOptions } from '#services/resources/resources.ts';
import { z } from 'zod';
import { generateSecrets } from './generate-secret.utils.ts';
import { API_VERSION } from '#utils/consts.ts';
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 specSchema = z.object({
fields: z.array(generateSecretFieldSchema),
});
class GenerateSecret extends CustomResource<typeof specSchema> {
public static readonly apiVersion = API_VERSION;
public static readonly kind = 'GenerateSecret';
public static readonly spec = specSchema;
public static readonly scope = 'Namespaced';
#secret: Secret;
constructor(options: CustomResourceOptions<typeof specSchema>) {
super(options);
const resourceService = this.services.get(ResourceService);
this.#secret = resourceService.get(Secret, this.name, this.namespace);
}
public reconcile = async () => {
const secrets = generateSecrets(this.spec);
const current = this.#secret.value;
const expected = {
...secrets,
...current,
};
await this.#secret.ensure(expected);
};
}
export { GenerateSecret };

View File

@@ -0,0 +1,27 @@
import { Environment } from './environment/environment.ts';
import { PostgresCluster } from './postgres-cluster/postgres-cluster.ts';
import { RedisServer } from './redis-server/redis-server.ts';
import { PostgresDatabase } from './postgres-database/postgres-database.ts';
import { AuthentikServer } from './authentik-server/authentik-server.ts';
import type { InstallableResourceClass } from '#services/resources/resources.ts';
import { OIDCClient } from './oidc-client/oidc-client.ts';
import { HttpService } from './http-service/http-service.ts';
import { GenerateSecret } from './generate-secret/generate-secret.ts';
import { ExternalHttpService } from './external-http-service.ts/external-http-service.ts';
import { CloudflareTunnel } from './cloudflare-tunnel/cloudflare-tunnel.ts';
const homelab = {
PostgresCluster,
RedisServer,
Environment,
ExternalHttpService,
CloudflareTunnel,
AuthentikServer,
PostgresDatabase,
OIDCClient,
HttpService,
GenerateSecret,
} satisfies Record<string, InstallableResourceClass<ExpectedAny>>;
export { homelab };

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