Compare commits

...

10 Commits

Author SHA1 Message Date
Morten Olsen
1259e3e752 more-stuff 2025-09-03 17:12:42 +02:00
Morten Olsen
03e406322f more stuff 2025-09-03 15:16:50 +02:00
Morten Olsen
5ee7a76443 more stuff 2025-09-03 14:33:48 +02:00
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
161 changed files with 3495 additions and 3654 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,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,10 @@
apiVersion: homelab.mortenolsen.pro/v1
kind: OidcClient
metadata:
name: '{{ .Release.Name }}'
spec:
environment: '{{ .Values.environment }}'
redirectUris:
- path: /api/auth/oidc/callback
subdomain: bytestash
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 }}'
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,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,63 @@
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
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: prod
subdomain: bytestash

View File

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

View File

@@ -0,0 +1 @@
https://www.authelia.com/integration/openid-connect/clients/jellyfin/

View File

@@ -0,0 +1,10 @@
apiVersion: homelab.mortenolsen.pro/v1
kind: OidcClient
metadata:
name: '{{ .Release.Name }}'
spec:
environment: '{{ .Values.environment }}'
redirectUris:
- path: /sso/OID/redirect/Authentik
subdomain: '{{ .Values.subdomain }}'
matchingMode: strict

View File

@@ -0,0 +1,11 @@
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
name: '{{ .Release.Name }}-config'
spec:
accessModes:
- 'ReadWriteOnce'
resources:
requests:
storage: '1Gi'
storageClassName: '{{ .Values.environment }}'

View File

@@ -0,0 +1,52 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: '{{ .Release.Name }}'
spec:
strategy:
type: Recreate
replicas: 1
selector:
matchLabels:
app: '{{ .Release.Name }}'
template:
metadata:
labels:
app: '{{ .Release.Name }}'
spec:
containers:
- name: '{{ .Release.Name }}'
image: '{{ .Values.image.repository }}:{{ .Values.image.tag }}'
imagePullPolicy: '{{ .Values.image.pullPolicy }}'
ports:
- name: http
containerPort: 8096
protocol: TCP
livenessProbe:
tcpSocket:
port: http
readinessProbe:
tcpSocket:
port: http
volumeMounts:
- mountPath: /config
name: config
- mountPath: /media/movies
name: movies
- mountPath: /media/tv-shows
name: tvshows
- mountPath: /media/music
name: music
volumes:
- name: config
persistentVolumeClaim:
claimName: '{{ .Release.Name }}-config'
- name: movies
persistentVolumeClaim:
claimName: movies
- name: tvshows
persistentVolumeClaim:
claimName: tvshows
- name: music
persistentVolumeClaim:
claimName: music

View File

@@ -0,0 +1,11 @@
apiVersion: homelab.mortenolsen.pro/v1
kind: ExternalHttpService
metadata:
name: '{{ .Release.Name }}'
spec:
environment: '{{ .Values.environment }}'
subdomain: '{{ .Values.subdomain }}'
destination:
host: '{{ .Release.Name }}.{{ .Release.Namespace }}.svc.cluster.local'
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: 8096
protocol: TCP
name: http
selector:
app: '{{ .Release.Name }}'

View File

@@ -0,0 +1,6 @@
image:
repository: docker.io/jellyfin/jellyfin
tag: latest
pullPolicy: IfNotPresent
environment: prod
subdomain: jellyfin

View File

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

View File

@@ -0,0 +1,52 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: '{{ .Release.Name }}'
labels:
app: '{{ .Release.Name }}'
spec:
replicas: 1
selector:
matchLabels:
app: '{{ .Release.Name }}'
template:
metadata:
labels:
app: '{{ .Release.Name }}'
spec:
containers:
- name: ollama
image: ghcr.io/ollama/ollama:latest # Official image
imagePullPolicy: IfNotPresent
ports:
- containerPort: 11434
name: http
volumeMounts:
- name: ollama-data
mountPath: /root/.ollama
env:
# If you want to prestart a model, set this env var to the
# model name (e.g., "gpt-4o-mini"). The container will download
# it automatically at startup.
# - name: OLLAMA_MODEL
# value: "gpt-4o-mini"
readinessProbe:
httpGet:
scheme: HTTP
path: /api/status
port: 11434
initialDelaySeconds: 5
periodSeconds: 10
successThreshold: 1
failureThreshold: 3
resources:
requests:
cpu: 500m
memory: 1Gi
limits:
cpu: 2000m
memory: 4Gi
volumes:
- name: ollama-data
persistentVolumeClaim:
claimName: '{{ .Release.Name }}-data'

View File

@@ -0,0 +1,11 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: '{{ .Release.Name }}-data'
spec:
storageClassName: '{{ .Values.environment }}'
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 20Gi

View File

@@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: '{{ .Release.Name }}'
labels:
app: '{{ .Release.Name }}'
spec:
type: LoadBalancer # Set to NodePort/ClusterIP if you prefer
ports:
- name: http
port: 11434
targetPort: http
protocol: TCP
selector:
app: '{{ .Release.Name }}'

View File

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

0
charts/apps/values.yaml Normal file
View File

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

3
charts/root/Chart.yaml Normal file
View File

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

View File

@@ -0,0 +1,32 @@
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
name: homelab-apps
namespace: '{{ .Values.env }}-argo'
spec:
generators:
- directory:
path: charts/apps
recurse: false
include: '.*'
filter: |
.is_dir == true and (.path | endswith(".disabled") | not)
template:
metadata:
name: '{{path.basename}}'
spec:
project: default
source:
repoURL: '{{ .Values.repo }}'
targetRevision: '{{ .Values.ref }}'
path: charts/apps/{{path.basename}}
helm:
values: |
global: {{ .Values.global | toYaml | nindent 14 }}
destination:
server: https://kubernetes.default.svc
namespace: '{{ .Values.globals.env }}'
syncPolicy:
automated:
prune: true
selfHeal: true

View File

@@ -0,0 +1,21 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: homelab-root
namespace: '{{ .Values.globals.env }}-argo'
spec:
project: default
source:
repoURL: '{{ .Values.repo }}'
targetRevision: '{{ .Values.ref }}'
path: charts/root
helm:
valueFiles:
- values.yaml
destination:
server: https://kubernetes.default.svc
namespace: '{{ .Values.globals.env }}-argo'
syncPolicy:
automated:
prune: true
selfHeal: true

4
charts/root/values.yaml Normal file
View File

@@ -0,0 +1,4 @@
globals:
env: prod
repo: https://github.com/morten-olsen/homelab-operator.git
ref: HEAD

View File

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

View File

@@ -0,0 +1,30 @@
apiVersion: v1
kind: PersistentVolume
metadata:
name: books
labels:
type: nfs
spec:
capacity:
storage: 10Gi
accessModes:
- ReadWriteMany
persistentVolumeReclaimPolicy: Retain
storageClassName: manual
hostPath: null
nfs:
path: '{{ .Values.books.path }}'
server: '{{ .Values.host }}'
readOnly: true
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: books
spec:
storageClassName: manual
accessModes:
- ReadWriteMany
resources:
requests:
storage: 10Gi

View File

@@ -0,0 +1,30 @@
apiVersion: v1
kind: PersistentVolume
metadata:
name: movies
labels:
type: nfs
spec:
capacity:
storage: 10Gi
accessModes:
- ReadWriteMany
persistentVolumeReclaimPolicy: Retain
storageClassName: manual
hostPath: null
nfs:
path: '{{ .Values.movies.path }}'
server: '{{ .Values.host }}'
readOnly: true
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: movies
spec:
storageClassName: manual
accessModes:
- ReadWriteMany
resources:
requests:
storage: 10Gi

View File

@@ -0,0 +1,30 @@
apiVersion: v1
kind: PersistentVolume
metadata:
name: music
labels:
type: nfs
spec:
capacity:
storage: 10Gi
accessModes:
- ReadWriteMany
persistentVolumeReclaimPolicy: Retain
storageClassName: manual
hostPath: null
nfs:
path: '{{ .Values.music.path }}'
server: '{{ .Values.host }}'
readOnly: true
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: music
spec:
storageClassName: manual
accessModes:
- ReadWriteMany
resources:
requests:
storage: 10Gi

View File

@@ -0,0 +1,30 @@
apiVersion: v1
kind: PersistentVolume
metadata:
name: podcasts
labels:
type: nfs
spec:
capacity:
storage: 10Gi
accessModes:
- ReadWriteMany
persistentVolumeReclaimPolicy: Retain
storageClassName: manual
hostPath: null
nfs:
path: '{{ .Values.podcasts.path }}'
server: '{{ .Values.host }}'
readOnly: true
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: podcasts
spec:
storageClassName: manual
accessModes:
- ReadWriteMany
resources:
requests:
storage: 10Gi

View File

@@ -0,0 +1,30 @@
apiVersion: v1
kind: PersistentVolume
metadata:
name: tvshows
labels:
type: nfs
spec:
capacity:
storage: 10Gi
accessModes:
- ReadWriteMany
persistentVolumeReclaimPolicy: Retain
storageClassName: manual
hostPath: null
nfs:
path: '{{ .Values.tvshows.path }}'
server: '{{ .Values.host }}'
readOnly: true
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: tvshows
spec:
storageClassName: manual
accessModes:
- ReadWriteMany
resources:
requests:
storage: 10Gi

View File

@@ -0,0 +1,11 @@
host: 192.168.20.106
movies:
path: /mnt/HDD/Movies
tvshows:
path: /mnt/HDD/TV-Shows
music:
path: /mnt/HDD/Music2
books:
path: /mnt/HDD/Books
podcasts:
path: /mnt/HDD/Podcasts

22
istio-test.yaml Normal file
View File

@@ -0,0 +1,22 @@
apiVersion: networking.istio.io/v1beta1
kind: ServiceEntry
metadata:
name: dev-authentik-override
namespace: dev
spec:
hosts:
- authentik.mortenolsen.nett
ports:
- number: 443
name: https
protocol: HTTPS
- number: 80
name: http
protocol: HTTP
location: MESH_EXTERNAL
resolution: STATIC
endpoints:
- address: 1.1.1.1
ports:
https: 443
http: 80

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

@@ -6,9 +6,9 @@ metadata:
apiVersion: homelab.mortenolsen.pro/v1 apiVersion: homelab.mortenolsen.pro/v1
kind: Environment kind: Environment
metadata: metadata:
name: dev name: prod
namespace: dev
spec: spec:
domain: one.dev.olsen.cloud domain: olsen.cloud
networkIp: 192.168.20.180
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

25
skaffold.yaml Normal file
View File

@@ -0,0 +1,25 @@
apiVersion: skaffold/v4beta7
kind: Config
metadata:
name: homelab-operator
build:
cluster: {}
artifacts:
- image: homelaboperator
context: .
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,70 @@
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;
#argo: 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', this.#argo = resourceService.get(HelmRepo, 'argo', NAMESPACE);
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);
this.#argo.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 get argo() {
return this.#argo;
} }
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: { await this.#argo.set({
interval: '1h', url: 'https://argoproj.github.io/argo-helm',
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 };

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