diff --git a/.gitignore b/.gitignore index 10bb0b5..8ccc75b 100644 --- a/.gitignore +++ b/.gitignore @@ -33,4 +33,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json # Finder (MacOS) folder config .DS_Store -/data/ \ No newline at end of file +/data/ + +/cloudflare.yaml diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/Dockerfile b/Dockerfile index 3f3e99c..515bc76 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ -FROM node:23-alpine +FROM node:23-slim RUN corepack enable COPY package.json pnpm-lock.yaml ./ RUN pnpm install --frozen-lockfile --prod COPY . . -CMD ["node", "src/index.ts"] \ No newline at end of file +CMD ["node", "src/index.ts"] diff --git a/Makefile b/Makefile index 6801232..4577849 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ dev-destroy: colima delete -f 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" setup-flux: diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/cert-issuer.yaml b/cert-issuer.yaml deleted file mode 100644 index db692f5..0000000 --- a/cert-issuer.yaml +++ /dev/null @@ -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 \ No newline at end of file diff --git a/charts/apps/bytestash/Chart.yaml b/charts/apps/bytestash/Chart.yaml new file mode 100644 index 0000000..8bc957b --- /dev/null +++ b/charts/apps/bytestash/Chart.yaml @@ -0,0 +1,3 @@ +apiVersion: v2 +version: 1.0.0 +name: ByteStash diff --git a/charts/apps/bytestash/templates/client.yaml b/charts/apps/bytestash/templates/client.yaml new file mode 100644 index 0000000..a21656c --- /dev/null +++ b/charts/apps/bytestash/templates/client.yaml @@ -0,0 +1,9 @@ +apiVersion: homelab.mortenolsen.pro/v1 +kind: OidcClient +metadata: + name: '{{ .Release.Name }}' +spec: + environment: '{{ .Values.environment }}' + redirectUris: + - url: https://localhost:3000/api/v1/authentik/oauth2/callback + matchingMode: strict diff --git a/charts/apps/bytestash/templates/external-http-service.yaml b/charts/apps/bytestash/templates/external-http-service.yaml new file mode 100644 index 0000000..ff86e79 --- /dev/null +++ b/charts/apps/bytestash/templates/external-http-service.yaml @@ -0,0 +1,11 @@ +apiVersion: homelab.mortenolsen.pro/v1 +kind: ExternalHttpService +metadata: + name: '{{ .Release.Name }}' +spec: + environment: '{{ .Values.environment }}' + subdomain: '{{ .Values.subdomain }}-external' + destination: + host: '{{ .Release.Name }}.{{ .Release.Namespace }}.svc.cluster.local' + port: + number: 80 diff --git a/charts/apps/bytestash/templates/headless-service.yaml b/charts/apps/bytestash/templates/headless-service.yaml new file mode 100644 index 0000000..5a253d1 --- /dev/null +++ b/charts/apps/bytestash/templates/headless-service.yaml @@ -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 }}' diff --git a/charts/apps/bytestash/templates/http-service.yaml b/charts/apps/bytestash/templates/http-service.yaml new file mode 100644 index 0000000..a57b37d --- /dev/null +++ b/charts/apps/bytestash/templates/http-service.yaml @@ -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 diff --git a/charts/apps/bytestash/templates/service.yaml b/charts/apps/bytestash/templates/service.yaml new file mode 100644 index 0000000..b8bedd6 --- /dev/null +++ b/charts/apps/bytestash/templates/service.yaml @@ -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 }}' diff --git a/charts/apps/bytestash/templates/stateful-set.yaml b/charts/apps/bytestash/templates/stateful-set.yaml new file mode 100644 index 0000000..46d5798 --- /dev/null +++ b/charts/apps/bytestash/templates/stateful-set.yaml @@ -0,0 +1,68 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: '{{ .Release.Name }}' + labels: + app: '{{ .Release.Name }}' +spec: + serviceName: '{{ .Release.Name }}-headless' + replicas: 1 + selector: + matchLabels: + app: '{{ .Release.Name }}' + template: + metadata: + labels: + app: '{{ .Release.Name }}' + spec: + containers: + - name: '{{ .Release.Name }}' + image: ghcr.io/jordan-dalby/bytestash:latest + ports: + - containerPort: 5000 + name: http + env: + - name: OIDC_ENABLED + value: 'true' + - name: OIDC_DISPLAY_NAME + value: OIDC + - name: OIDC_CLIENT_ID + valueFrom: + secretKeyRef: + name: '{{ .Release.Name }}-client' + key: clientId + - name: OIDC_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: '{{ .Release.Name }}-client' + key: clientSecret + - name: OIDC_ISSUER_URL + valueFrom: + secretKeyRef: + name: '{{ .Release.Name }}-client' + key: configuration + + # !! IMPORTANT !! + # You MUST update this Redirect URI to match your external URL. + # This URI must also be configured in your Authentik provider settings for this client. + #- name: BS_OIDC_REDIRECT_URI + #value: 'https://bytestash.your-domain.com/login/oauth2/code/oidc' + volumeMounts: + - mountPath: /data/snippets + name: bytestash-data + + # Defines security context for the pod to avoid running as root. + # securityContext: + # runAsUser: 1000 + # runAsGroup: 1000 + # fsGroup: 1000 + + volumeClaimTemplates: + - metadata: + name: bytestash-data + spec: + accessModes: ['ReadWriteOnce'] + storageClassName: '{{ .Values.environment }}' + resources: + requests: + storage: 5Gi diff --git a/charts/apps/bytestash/values.yaml b/charts/apps/bytestash/values.yaml new file mode 100644 index 0000000..3067066 --- /dev/null +++ b/charts/apps/bytestash/values.yaml @@ -0,0 +1,2 @@ +environment: dev +subdomain: bytestash diff --git a/charts/operator/values.yaml b/charts/operator/values.yaml index 7de2c4d..ec2a7a6 100644 --- a/charts/operator/values.yaml +++ b/charts/operator/values.yaml @@ -4,7 +4,7 @@ image: repository: ghcr.io/morten-olsen/homelab-operator - pullPolicy: Always + pullPolicy: IfNotPresent # Overrides the image tag whose default is the chart appVersion. tag: main diff --git a/manifests/client.yaml b/manifests/client.yaml index a896b0b..ee4449c 100644 --- a/manifests/client.yaml +++ b/manifests/client.yaml @@ -1,9 +1,9 @@ apiVersion: homelab.mortenolsen.pro/v1 -kind: AuthentikClient +kind: OidcClient metadata: name: test-client spec: - server: dev/dev-authentik-server + environment: dev redirectUris: - url: https://localhost:3000/api/v1/authentik/oauth2/callback - matchingMode: strict \ No newline at end of file + matchingMode: strict diff --git a/manifests/environment.yaml b/manifests/environment.yaml index 08dca25..38ed2d7 100644 --- a/manifests/environment.yaml +++ b/manifests/environment.yaml @@ -7,8 +7,8 @@ apiVersion: homelab.mortenolsen.pro/v1 kind: Environment metadata: name: dev - namespace: dev spec: domain: one.dev.olsen.cloud + networkIp: 192.168.107.2 tls: - issuer: letsencrypt-prod \ No newline at end of file + issuer: lets-encrypt-prod diff --git a/manifests/test-service.yaml b/manifests/test-service.yaml new file mode 100644 index 0000000..26a9991 --- /dev/null +++ b/manifests/test-service.yaml @@ -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 diff --git a/package.json b/package.json index f31cc37..20cf473 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,8 @@ "dependencies": { "@goauthentik/api": "2025.6.3-1751754396", "@kubernetes/client-node": "^1.3.0", + "cloudflare": "^4.5.0", + "cron": "^4.3.3", "debounce": "^2.2.0", "deep-equal": "^2.2.3", "dotenv": "^17.2.1", @@ -35,6 +37,12 @@ "yaml": "^2.8.0", "zod": "^4.0.14" }, + "imports": { + "#services/*": "./src/services/*", + "#resources/*": "./src/resources/*", + "#bootstrap/*": "./src/bootstrap/*", + "#utils/*": "./src/utils/*" + }, "packageManager": "pnpm@10.6.0", "pnpm": { "onlyBuiltDependencies": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1a3d73c..868027c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,12 @@ importers: '@kubernetes/client-node': specifier: ^1.3.0 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: specifier: ^2.2.0 version: 2.2.0 @@ -229,9 +235,15 @@ packages: '@types/lodash@4.17.20': resolution: {integrity: sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==} + '@types/luxon@3.7.1': + resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==} + '@types/node-fetch@2.6.12': resolution: {integrity: sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==} + '@types/node@18.19.123': + resolution: {integrity: sha512-K7DIaHnh0mzVxreCR9qwgNxp3MH9dltPNIEddW9MYUlcKAzm+3grKNSTe2vCJHI1FaLpvpL5JGJrz1UZDKYvDg==} + '@types/node@22.16.5': resolution: {integrity: sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ==} @@ -303,6 +315,10 @@ packages: abbrev@1.1.1: 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: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -479,6 +495,9 @@ packages: resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} engines: {node: '>=6'} + cloudflare@4.5.0: + resolution: {integrity: sha512-fPcbPKx4zF45jBvQ0z7PCdgejVAPBBCZxwqk1k7krQNfpM07Cfj97/Q6wBzvYqlWXx/zt1S9+m8vnfCe06umbQ==} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -507,6 +526,10 @@ packages: console-control-strings@1.1.0: 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: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -754,6 +777,10 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} 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: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} @@ -825,10 +852,17 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} + form-data-encoder@1.7.2: + resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==} + form-data@4.0.4: resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} engines: {node: '>= 6'} + formdata-node@4.4.1: + resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==} + engines: {node: '>= 12.20'} + fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} @@ -1238,6 +1272,10 @@ packages: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} + luxon@3.7.1: + resolution: {integrity: sha512-RkRWjA926cTvz5rAb1BqyWkKbbjzCGchDUIKMCUvNi17j6f6j8uHGDV82Aqcqtzd+icoYpELmG3ksgGiFNNcNg==} + engines: {node: '>=12'} + make-fetch-happen@9.1.0: resolution: {integrity: sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==} engines: {node: '>= 10'} @@ -1339,6 +1377,11 @@ packages: node-addon-api@7.1.1: 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: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} @@ -1886,6 +1929,9 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} + undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -1905,6 +1951,10 @@ packages: util-deprecate@1.0.2: 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: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -2129,11 +2179,17 @@ snapshots: '@types/lodash@4.17.20': {} + '@types/luxon@3.7.1': {} + '@types/node-fetch@2.6.12': dependencies: '@types/node': 22.16.5 form-data: 4.0.4 + '@types/node@18.19.123': + dependencies: + undici-types: 5.26.5 + '@types/node@22.16.5': dependencies: undici-types: 6.21.0 @@ -2240,6 +2296,10 @@ snapshots: abbrev@1.1.1: optional: true + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -2258,7 +2318,6 @@ snapshots: agentkeepalive@4.6.0: dependencies: humanize-ms: 1.2.1 - optional: true aggregate-error@3.1.0: dependencies: @@ -2463,6 +2522,18 @@ snapshots: clean-stack@2.2.0: 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: dependencies: color-name: 1.1.4 @@ -2485,6 +2556,11 @@ snapshots: console-control-strings@1.1.0: optional: true + cron@4.3.3: + dependencies: + '@types/luxon': 3.7.1 + luxon: 3.7.1 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -2828,6 +2904,8 @@ snapshots: esutils@2.0.3: {} + event-target-shim@5.0.1: {} + eventemitter3@5.0.1: {} execa@9.6.0: @@ -2903,6 +2981,8 @@ snapshots: dependencies: is-callable: 1.2.7 + form-data-encoder@1.7.2: {} + form-data@4.0.4: dependencies: asynckit: 0.4.0 @@ -2911,6 +2991,11 @@ snapshots: hasown: 2.0.2 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-minipass@2.1.0: @@ -3064,7 +3149,6 @@ snapshots: humanize-ms@1.2.1: dependencies: ms: 2.1.3 - optional: true iconv-lite@0.6.3: dependencies: @@ -3329,6 +3413,8 @@ snapshots: yallist: 4.0.0 optional: true + luxon@3.7.1: {} + make-fetch-happen@9.1.0: dependencies: agentkeepalive: 4.6.0 @@ -3440,6 +3526,8 @@ snapshots: node-addon-api@7.1.1: {} + node-domexception@1.0.0: {} + node-fetch@2.7.0(encoding@0.1.13): dependencies: whatwg-url: 5.0.0 @@ -4098,6 +4186,8 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 + undici-types@5.26.5: {} + undici-types@6.21.0: {} unicorn-magic@0.3.0: {} @@ -4118,6 +4208,8 @@ snapshots: util-deprecate@1.0.2: {} + web-streams-polyfill@4.0.0-beta.3: {} + webidl-conversions@3.0.1: {} whatwg-url@5.0.0: diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c572d07 --- /dev/null +++ b/pyproject.toml @@ -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", +] diff --git a/scripts/apply-test.sh b/scripts/apply-test.sh deleted file mode 100755 index cfbfe3d..0000000 --- a/scripts/apply-test.sh +++ /dev/null @@ -1,4 +0,0 @@ -for f in "./test-manifests/"*; do - echo "Applying $f" - kubectl apply -f "$f" -done diff --git a/scripts/create-secrets.sh b/scripts/create-secrets.sh deleted file mode 100755 index 6644134..0000000 --- a/scripts/create-secrets.sh +++ /dev/null @@ -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}" diff --git a/scripts/list-manifests.ts b/scripts/list-manifests.ts deleted file mode 100755 index b19dcf6..0000000 --- a/scripts/list-manifests.ts +++ /dev/null @@ -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}`); - } -} diff --git a/scripts/setup-server.sh b/scripts/setup-server.sh deleted file mode 100755 index 8bc4246..0000000 --- a/scripts/setup-server.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/bash -flux install --components="source-controller,helm-controller" -kubectl create namespace homelab \ No newline at end of file diff --git a/skaffold.yaml b/skaffold.yaml new file mode 100644 index 0000000..1a09589 --- /dev/null +++ b/skaffold.yaml @@ -0,0 +1,31 @@ +apiVersion: skaffold/v4beta7 +kind: Config +metadata: + name: homelab-operator + +build: + # This tells Skaffold to build the image locally using your Docker daemon. + local: + push: false + # This is the crucial part for your workflow. Instead of pushing to a + # registry, it loads the built image directly into your cluster's nodes. + # load: true + artifacts: + # Defines the image to build. It matches the placeholder in deployment.yaml. + - image: homelaboperator + context: . # The build context is the root directory + docker: + dockerfile: Dockerfile + +manifests: + helm: + releases: + - name: homelab-operator + chartPath: charts/operator + setValueTemplates: + image.repository: '{{.IMAGE_REPO_homelaboperator}}' + image.tag: '{{.IMAGE_TAG_homelaboperator}}' + +deploy: + # Use kubectl to apply the manifests. + kubectl: {} diff --git a/src/bootstrap/bootstrap.ts b/src/bootstrap/bootstrap.ts index d67d11b..4b1baae 100644 --- a/src/bootstrap/bootstrap.ts +++ b/src/bootstrap/bootstrap.ts @@ -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 { NamespaceService } from './namespaces/namespaces.ts'; import { ReleaseService } from './releases/releases.ts'; import { RepoService } from './repos/repos.ts'; -import { ClusterIssuerService } from './resources/issuer.ts'; class BootstrapService { #services: Services; @@ -23,15 +24,18 @@ class BootstrapService { return this.#services.get(ReleaseService); } - public get clusterIssuer() { - return this.#services.get(ClusterIssuerService); + public get cloudflareTunnel() { + const resourceService = this.#services.get(ResourceService); + return resourceService.get(CloudflareTunnel, 'cloudflare-tunnel', this.namespaces.homelab.name); } public ensure = async () => { await this.namespaces.ensure(); await this.repos.ensure(); await this.releases.ensure(); - await this.clusterIssuer.ensure(); + await this.cloudflareTunnel.ensure({ + spec: {}, + }); }; } diff --git a/src/bootstrap/namespaces/namespaces.ts b/src/bootstrap/namespaces/namespaces.ts index f4e48bb..e5eb390 100644 --- a/src/bootstrap/namespaces/namespaces.ts +++ b/src/bootstrap/namespaces/namespaces.ts @@ -1,38 +1,19 @@ -import { NamespaceInstance } from '../../instances/namespace.ts'; import type { Services } from '../../utils/service.ts'; import { ResourceService } from '../../services/resources/resources.ts'; +import { Namespace } from '#resources/core/namespace/namespace.ts'; + class NamespaceService { - #homelab: NamespaceInstance; - #istioSystem: NamespaceInstance; - #certManager: NamespaceInstance; + #homelab: Namespace; + #istioSystem: Namespace; + #certManager: Namespace; constructor(services: Services) { const resourceService = services.get(ResourceService); - this.#homelab = resourceService.getInstance( - { - apiVersion: 'v1', - kind: 'Namespace', - name: 'homelab', - }, - NamespaceInstance, - ); - this.#istioSystem = resourceService.getInstance( - { - apiVersion: 'v1', - kind: 'Namespace', - name: 'istio-system', - }, - NamespaceInstance, - ); - this.#certManager = resourceService.getInstance( - { - apiVersion: 'v1', - kind: 'Namespace', - name: 'cert-manager', - }, - NamespaceInstance, - ); + this.#homelab = resourceService.get(Namespace, 'homelab'); + this.#istioSystem = resourceService.get(Namespace, 'istio-system'); + this.#certManager = resourceService.get(Namespace, 'cert-manager'); + this.#homelab.on('changed', this.ensure); this.#istioSystem.on('changed', this.ensure); this.#certManager.on('changed', this.ensure); diff --git a/src/bootstrap/releases/releases.ts b/src/bootstrap/releases/releases.ts index 70b7b6a..7b9c978 100644 --- a/src/bootstrap/releases/releases.ts +++ b/src/bootstrap/releases/releases.ts @@ -1,56 +1,26 @@ -import { HelmReleaseInstance } from '../../instances/helm-release.ts'; import { ResourceService } from '../../services/resources/resources.ts'; import { NAMESPACE } from '../../utils/consts.ts'; import { Services } from '../../utils/service.ts'; import { NamespaceService } from '../namespaces/namespaces.ts'; import { RepoService } from '../repos/repos.ts'; +import { HelmRelease } from '#resources/flux/helm-release/helm-release.ts'; + class ReleaseService { #services: Services; - #certManager: HelmReleaseInstance; - #istioBase: HelmReleaseInstance; - #istiod: HelmReleaseInstance; - #istioGateway: HelmReleaseInstance; + #certManager: HelmRelease; + #istioBase: HelmRelease; + #istiod: HelmRelease; + #istioGateway: HelmRelease; constructor(services: Services) { this.#services = services; const resourceService = services.get(ResourceService); - this.#certManager = resourceService.getInstance( - { - apiVersion: 'helm.toolkit.fluxcd.io/v2', - kind: 'HelmRelease', - name: 'cert-manager', - namespace: NAMESPACE, - }, - HelmReleaseInstance, - ); - this.#istioBase = resourceService.getInstance( - { - apiVersion: 'helm.toolkit.fluxcd.io/v2', - kind: 'HelmRelease', - name: 'istio-base', - namespace: NAMESPACE, - }, - HelmReleaseInstance, - ); - this.#istiod = resourceService.getInstance( - { - apiVersion: 'helm.toolkit.fluxcd.io/v2', - kind: 'HelmRelease', - name: 'istiod', - namespace: NAMESPACE, - }, - HelmReleaseInstance, - ); - this.#istioGateway = resourceService.getInstance( - { - apiVersion: 'helm.toolkit.fluxcd.io/v2', - kind: 'HelmRelease', - name: 'istio-gateway', - namespace: NAMESPACE, - }, - HelmReleaseInstance, - ); + this.#certManager = resourceService.get(HelmRelease, 'cert-manager', NAMESPACE); + this.#istioBase = resourceService.get(HelmRelease, 'istio-base', NAMESPACE); + this.#istiod = resourceService.get(HelmRelease, 'istiod', NAMESPACE); + this.#istioGateway = resourceService.get(HelmRelease, 'istio-gateway', NAMESPACE); + this.#certManager.on('changed', this.ensure); this.#istioBase.on('changed', this.ensure); this.#istiod.on('changed', this.ensure); diff --git a/src/bootstrap/repos/repos.ts b/src/bootstrap/repos/repos.ts index 0a845a2..648d7df 100644 --- a/src/bootstrap/repos/repos.ts +++ b/src/bootstrap/repos/repos.ts @@ -1,110 +1,59 @@ import type { Services } from '../../utils/service.ts'; import { ResourceService } from '../../services/resources/resources.ts'; -import { HelmRepoInstance } from '../../instances/helm-repo.ts'; import { NAMESPACE } from '../../utils/consts.ts'; +import { HelmRepo } from '#resources/flux/helm-repo/helm-repo.ts'; + class RepoService { - #jetstack: HelmRepoInstance; - #istio: HelmRepoInstance; - #authentik: HelmRepoInstance; - #containerro: HelmRepoInstance; + #jetstack: HelmRepo; + #istio: HelmRepo; + #authentik: HelmRepo; + #cloudflare: HelmRepo; constructor(services: Services) { const resourceService = services.get(ResourceService); - this.#jetstack = resourceService.getInstance( - { - apiVersion: 'source.toolkit.fluxcd.io/v1', - kind: 'HelmRepository', - name: 'jetstack', - namespace: NAMESPACE, - }, - HelmRepoInstance, - ); - this.#istio = resourceService.getInstance( - { - apiVersion: 'source.toolkit.fluxcd.io/v1', - kind: 'HelmRepository', - name: 'istio', - namespace: NAMESPACE, - }, - HelmRepoInstance, - ); - this.#authentik = resourceService.getInstance( - { - apiVersion: 'source.toolkit.fluxcd.io/v1', - kind: 'HelmRepository', - name: 'authentik', - namespace: NAMESPACE, - }, - HelmRepoInstance, - ); - this.#containerro = resourceService.getInstance( - { - apiVersion: 'source.toolkit.fluxcd.io/v1', - kind: 'HelmRepository', - name: 'containerro', - namespace: NAMESPACE, - }, - HelmRepoInstance, - ); + this.#jetstack = resourceService.get(HelmRepo, 'jetstack', NAMESPACE); + this.#istio = resourceService.get(HelmRepo, 'istio', NAMESPACE); + this.#authentik = resourceService.get(HelmRepo, 'authentik', NAMESPACE); + this.#cloudflare = resourceService.get(HelmRepo, 'cloudflare', NAMESPACE); + this.#jetstack.on('changed', this.ensure); this.#istio.on('changed', this.ensure); this.#authentik.on('changed', this.ensure); - this.#containerro.on('changed', this.ensure); + this.#cloudflare.on('changed', this.ensure); } public get jetstack() { return this.#jetstack; } + public get istio() { return this.#istio; } + public get authentik() { return this.#authentik; } - public get containerro() { - return this.#containerro; + + public get cloudflare() { + return this.#cloudflare; } public ensure = async () => { - await this.#jetstack.ensure({ - metadata: { - name: 'jetstack', - }, - spec: { - interval: '1h', - url: 'https://charts.jetstack.io', - }, + await this.#jetstack.set({ + url: 'https://charts.jetstack.io', }); - await this.#istio.ensure({ - metadata: { - name: 'istio', - }, - spec: { - interval: '1h', - url: 'https://istio-release.storage.googleapis.com/charts', - }, + await this.#istio.set({ + url: 'https://istio-release.storage.googleapis.com/charts', }); - await this.#authentik.ensure({ - metadata: { - name: 'authentik', - }, - spec: { - interval: '1h', - url: 'https://charts.goauthentik.io', - }, + await this.#authentik.set({ + url: 'https://charts.goauthentik.io', }); - await this.#containerro.ensure({ - metadata: { - name: 'containerro', - }, - spec: { - interval: '1h', - url: 'https://charts.containeroo.ch', - }, + await this.#cloudflare.set({ + url: 'https://cloudflare.github.io/helm-charts', }); }; } diff --git a/src/bootstrap/resources/issuer.ts b/src/bootstrap/resources/issuer.ts deleted file mode 100644 index ec7f1be..0000000 --- a/src/bootstrap/resources/issuer.ts +++ /dev/null @@ -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 }; diff --git a/src/custom-resouces/authentik-client/authentik-client.resource.ts b/src/custom-resouces/authentik-client/authentik-client.resource.ts deleted file mode 100644 index d7c9bec..0000000 --- a/src/custom-resouces/authentik-client/authentik-client.resource.ts +++ /dev/null @@ -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 { - #serverSecret: ResourceReference; - #clientSecretResource: Resource; - - constructor(options: CustomResourceOptions) { - 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 => { - 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 = { - 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 => { - 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 }; diff --git a/src/custom-resouces/authentik-client/authentik-client.schemas.ts b/src/custom-resouces/authentik-client/authentik-client.schemas.ts deleted file mode 100644 index 0e5a058..0000000 --- a/src/custom-resouces/authentik-client/authentik-client.schemas.ts +++ /dev/null @@ -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 }; diff --git a/src/custom-resouces/authentik-client/authentik-client.ts b/src/custom-resouces/authentik-client/authentik-client.ts deleted file mode 100644 index 6e4783f..0000000 --- a/src/custom-resouces/authentik-client/authentik-client.ts +++ /dev/null @@ -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 }; diff --git a/src/custom-resouces/authentik-server/authentik-server.controller.ts b/src/custom-resouces/authentik-server/authentik-server.controller.ts deleted file mode 100644 index 353615f..0000000 --- a/src/custom-resouces/authentik-server/authentik-server.controller.ts +++ /dev/null @@ -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 { - #environment: ResourceReference>; - #authentikInitSecret: EnsuredSecret; - #authentikSecret: SecretInstance; - #authentikRelease: HelmReleaseInstance; - #postgresSecret: ResourceReference; - #httpService: HttpServiceInstance; - #redisServer: ResourceReference>; - #postgresDatabase: PostgresDatabaseInstance; - - constructor(options: CustomResourceOptions) { - super(options); - const secretService = this.services.get(SecretService); - const resourceService = this.services.get(ResourceService); - - this.#environment = new ResourceReference(); - this.#authentikInitSecret = secretService.ensure({ - owner: [this.ref], - name: `${this.name}-init`, - namespace: this.namespace, - schema: authentikServerInitSecretSchema, - generator: () => ({ - AUTHENTIK_BOOTSTRAP_TOKEN: crypto.randomUUID(), - AUTHENTIK_BOOTSTRAP_PASSWORD: crypto.randomUUID(), - AUTHENTIK_BOOTSTRAP_EMAIL: 'admin@example.com', - AUTHENTIK_SECRET_KEY: crypto.randomUUID(), - }), - }); - this.#authentikSecret = resourceService.getInstance( - { - apiVersion: 'v1', - kind: 'Secret', - name: `${this.name}-server`, - namespace: this.namespace, - }, - SecretInstance, - ); - this.#authentikRelease = resourceService.getInstance( - { - apiVersion: 'helm.toolkit.fluxcd.io/v2', - kind: 'HelmRelease', - name: this.name, - namespace: this.namespace, - }, - HelmReleaseInstance, - ); - this.#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 }; diff --git a/src/custom-resouces/authentik-server/authentik-server.schemas.ts b/src/custom-resouces/authentik-server/authentik-server.schemas.ts deleted file mode 100644 index 91c0129..0000000 --- a/src/custom-resouces/authentik-server/authentik-server.schemas.ts +++ /dev/null @@ -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 }; diff --git a/src/custom-resouces/authentik-server/authentik-server.ts b/src/custom-resouces/authentik-server/authentik-server.ts deleted file mode 100644 index d1ac8b7..0000000 --- a/src/custom-resouces/authentik-server/authentik-server.ts +++ /dev/null @@ -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 }; diff --git a/src/custom-resouces/custom-resources.ts b/src/custom-resouces/custom-resources.ts deleted file mode 100644 index b789b25..0000000 --- a/src/custom-resouces/custom-resources.ts +++ /dev/null @@ -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 }; diff --git a/src/custom-resouces/environment/environment.controller.ts b/src/custom-resouces/environment/environment.controller.ts deleted file mode 100644 index 66d4556..0000000 --- a/src/custom-resouces/environment/environment.controller.ts +++ /dev/null @@ -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 { - #namespace: NamespaceInstance; - #certificateCrd: CustomDefinitionInstance; - #certificate: CertificateInstance; - #gatewayCrd: CustomDefinitionInstance; - #gateway: GatewayInstance; - #storageClass: StorageClassInstance; - #postgresCluster: PostgresClusterInstance; - #authentikServer: AuthentikServerInstance; - #redisServer: RedisServerInstance; - - constructor(options: CustomResourceOptions) { - 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 }; diff --git a/src/custom-resouces/environment/environment.schemas.ts b/src/custom-resouces/environment/environment.schemas.ts deleted file mode 100644 index 3eca658..0000000 --- a/src/custom-resouces/environment/environment.schemas.ts +++ /dev/null @@ -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; - -export { environmentSpecSchema, type EnvironmentSpec }; diff --git a/src/custom-resouces/environment/environment.ts b/src/custom-resouces/environment/environment.ts deleted file mode 100644 index 040735f..0000000 --- a/src/custom-resouces/environment/environment.ts +++ /dev/null @@ -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 }; diff --git a/src/custom-resouces/generate-secret/generate-secret.resource.ts b/src/custom-resouces/generate-secret/generate-secret.resource.ts deleted file mode 100644 index c1eb56c..0000000 --- a/src/custom-resouces/generate-secret/generate-secret.resource.ts +++ /dev/null @@ -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 { - #secretResource: Resource; - - constructor(options: CustomResourceOptions) { - 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 }; diff --git a/src/custom-resouces/generate-secret/generate-secret.schemas.ts b/src/custom-resouces/generate-secret/generate-secret.schemas.ts deleted file mode 100644 index 826f5bf..0000000 --- a/src/custom-resouces/generate-secret/generate-secret.schemas.ts +++ /dev/null @@ -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; -type GenerateSecretSpec = z.infer; - -export { generateSecretSpecSchema, type GenerateSecretField, type GenerateSecretSpec }; diff --git a/src/custom-resouces/generate-secret/generate-secret.ts b/src/custom-resouces/generate-secret/generate-secret.ts deleted file mode 100644 index 6f52f26..0000000 --- a/src/custom-resouces/generate-secret/generate-secret.ts +++ /dev/null @@ -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 }; diff --git a/src/custom-resouces/http-service/http-service.controller.ts b/src/custom-resouces/http-service/http-service.controller.ts deleted file mode 100644 index 2392eea..0000000 --- a/src/custom-resouces/http-service/http-service.controller.ts +++ /dev/null @@ -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 { - #environment: ResourceReference>; - #virtualService: VirtualServiceInstance; - #destinationRule: DestinationRuleInstance; - - constructor(options: CustomResourceOptions) { - super(options); - const resourceService = this.services.get(ResourceService); - this.#environment = new ResourceReference(); - this.#virtualService = resourceService.getInstance( - { - apiVersion: 'networking.istio.io/v1beta1', - kind: 'VirtualService', - name: `${this.name}-virtual-service`, - namespace: this.namespace, - }, - VirtualServiceInstance, - ); - this.#destinationRule = resourceService.getInstance( - { - apiVersion: 'networking.istio.io/v1beta1', - kind: 'DestinationRule', - name: `${this.name}-destination-rule`, - namespace: this.namespace, - }, - DestinationRuleInstance, - ); - this.#destinationRule.on('changed', this.queueReconcile); - this.#virtualService.on('changed', this.queueReconcile); - this.#environment.on('changed', this.queueReconcile); - } - - public reconcile = async () => { - if (!this.exists || this.metadata?.deletionTimestamp) { - return; - } - const resourceService = this.services.get(ResourceService); - const environmentNames = getWithNamespace(this.spec.environment, this.namespace); - this.#environment.current = resourceService.get({ - apiVersion: API_VERSION, - kind: 'Environment', - name: environmentNames.name, - namespace: environmentNames.namespace, - }); - const environment = this.#environment.current; - if (!environment?.exists) { - return; - } - await this.#virtualService.ensure({ - metadata: { - ownerReferences: [this.ref], - }, - spec: { - hosts: [`${this.spec.subdomain}.${environment.spec?.domain}`], - gateways: [`${this.#environment.current.namespace}/${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 }; diff --git a/src/custom-resouces/http-service/http-service.schemas.ts b/src/custom-resouces/http-service/http-service.schemas.ts deleted file mode 100644 index e91daba..0000000 --- a/src/custom-resouces/http-service/http-service.schemas.ts +++ /dev/null @@ -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 }; diff --git a/src/custom-resouces/http-service/http-service.ts b/src/custom-resouces/http-service/http-service.ts deleted file mode 100644 index 0d6a57a..0000000 --- a/src/custom-resouces/http-service/http-service.ts +++ /dev/null @@ -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 }; diff --git a/src/custom-resouces/postgres-cluster/postgres-cluster.controller.ts b/src/custom-resouces/postgres-cluster/postgres-cluster.controller.ts deleted file mode 100644 index fa21406..0000000 --- a/src/custom-resouces/postgres-cluster/postgres-cluster.controller.ts +++ /dev/null @@ -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 { - #statefulSet: StatefulSetInstance; - #headlessService: ServiceInstance; - #service: ServiceInstance; - #secret: EnsuredSecret; - - constructor(options: CustomResourceOptions) { - super(options); - const resourceService = this.services.get(ResourceService); - const secretService = this.services.get(SecretService); - this.#statefulSet = resourceService.getInstance( - { - apiVersion: 'apps/v1', - kind: 'StatefulSet', - name: this.name, - namespace: this.namespace, - }, - StatefulSetInstance, - ); - this.#headlessService = resourceService.getInstance( - { - apiVersion: 'v1', - kind: 'Service', - name: `${this.name}-headless`, - namespace: this.namespace, - }, - ServiceInstance, - ); - this.#service = resourceService.getInstance( - { - apiVersion: 'v1', - kind: 'Service', - name: this.name, - namespace: this.namespace, - }, - ServiceInstance, - ); - this.#secret = secretService.ensure({ - name: this.name, - namespace: this.namespace, - schema: postgresClusterSecretSchema, - generator: () => { - return { - database: 'postgres', - host: `${this.name}.${this.namespace}.svc.cluster.local`, - port: '5432', - username: 'postgres', - password: crypto.randomUUID(), - }; - }, - }); - this.#statefulSet.on('changed', this.queueReconcile); - this.#service.on('changed', this.queueReconcile); - this.#headlessService.on('changed', this.queueReconcile); - this.#secret.resource.on('changed', this.queueReconcile); - } - - public reconcile = async () => { - if (!this.exists || this.metadata?.deletionTimestamp || !this.#secret.isValid) { - return; - } - await this.#headlessService.ensure({ - metadata: { - ownerReferences: [this.ref], - }, - spec: { - clusterIP: 'None', - selector: { - app: this.name, - }, - ports: [{ name: 'postgres', port: 5432, targetPort: 5432 }], - }, - }); - await this.#statefulSet.ensure({ - metadata: { - ownerReferences: [this.ref], - }, - spec: { - replicas: 1, - serviceName: this.name, - selector: { - matchLabels: { - app: this.name, - }, - }, - template: { - metadata: { - labels: { - app: this.name, - }, - }, - spec: { - containers: [ - { - name: this.name, - image: 'postgres:17', - ports: [{ containerPort: 5432, name: 'postgres' }], - env: [ - { name: 'POSTGRES_PASSWORD', valueFrom: { secretKeyRef: { name: this.name, key: 'password' } } }, - { name: 'POSTGRES_USER', valueFrom: { secretKeyRef: { name: this.name, key: 'username' } } }, - { name: 'POSTGRES_DB', value: this.name }, - { name: 'PGDATA', value: '/var/lib/postgresql/data/pgdata' }, - ], - volumeMounts: [{ name: this.name, mountPath: '/var/lib/postgresql/data' }], - }, - ], - }, - }, - volumeClaimTemplates: [ - { - metadata: { - name: this.name, - }, - spec: { - accessModes: ['ReadWriteOnce'], - storageClassName: `${this.spec.environment}-retain`, - resources: { - requests: { - storage: this.spec.storage?.size || '1Gi', - }, - }, - }, - }, - ], - }, - }); - - await this.#service.ensure({ - metadata: { - ownerReferences: [this.ref], - }, - spec: { - type: 'ClusterIP', - selector: { - app: this.name, - }, - ports: [{ name: 'postgres', port: 5432, targetPort: 5432 }], - }, - }); - }; -} - -export { PostgresClusterController }; diff --git a/src/custom-resouces/postgres-cluster/postgres-cluster.schemas.ts b/src/custom-resouces/postgres-cluster/postgres-cluster.schemas.ts deleted file mode 100644 index be6f5da..0000000 --- a/src/custom-resouces/postgres-cluster/postgres-cluster.schemas.ts +++ /dev/null @@ -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 }; diff --git a/src/custom-resouces/postgres-cluster/postgres-cluster.ts b/src/custom-resouces/postgres-cluster/postgres-cluster.ts deleted file mode 100644 index 77af41c..0000000 --- a/src/custom-resouces/postgres-cluster/postgres-cluster.ts +++ /dev/null @@ -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 }; diff --git a/src/custom-resouces/postgres-database/portgres-database.schemas.ts b/src/custom-resouces/postgres-database/portgres-database.schemas.ts deleted file mode 100644 index 11eab8f..0000000 --- a/src/custom-resouces/postgres-database/portgres-database.schemas.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { z } from 'zod'; - -const postgresDatabaseSpecSchema = z.object({ - cluster: z.string(), -}); - -export { postgresDatabaseSpecSchema }; diff --git a/src/custom-resouces/postgres-database/postgres-database.resource.ts b/src/custom-resouces/postgres-database/postgres-database.resource.ts deleted file mode 100644 index 46ff6f8..0000000 --- a/src/custom-resouces/postgres-database/postgres-database.resource.ts +++ /dev/null @@ -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 { - #clusterSecret: ResourceReference; - #databaseSecret: SecretInstance; - - constructor(options: CustomResourceOptions) { - 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, - ); - - 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 => { - 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 => { - 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 }; diff --git a/src/custom-resouces/postgres-database/postgres-database.ts b/src/custom-resouces/postgres-database/postgres-database.ts deleted file mode 100644 index 1baeb4c..0000000 --- a/src/custom-resouces/postgres-database/postgres-database.ts +++ /dev/null @@ -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 }; diff --git a/src/custom-resouces/redis-server/redis-server.controller.ts b/src/custom-resouces/redis-server/redis-server.controller.ts deleted file mode 100644 index 4016213..0000000 --- a/src/custom-resouces/redis-server/redis-server.controller.ts +++ /dev/null @@ -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 { - #deployment: DeploymentInstance; - #service: ServiceInstance; - - constructor(options: CustomResourceOptions) { - 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 }; diff --git a/src/custom-resouces/redis-server/redis-server.schemas.ts b/src/custom-resouces/redis-server/redis-server.schemas.ts deleted file mode 100644 index cbd36f2..0000000 --- a/src/custom-resouces/redis-server/redis-server.schemas.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { z } from 'zod'; - -const redisServerSpecSchema = z.object({}); - -export { redisServerSpecSchema }; diff --git a/src/custom-resouces/redis-server/redis-server.ts b/src/custom-resouces/redis-server/redis-server.ts deleted file mode 100644 index 861c12b..0000000 --- a/src/custom-resouces/redis-server/redis-server.ts +++ /dev/null @@ -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 }; diff --git a/src/index.ts b/src/index.ts index 5be5510..50172db 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,115 +1,17 @@ -import { BootstrapService } from './bootstrap/bootstrap.ts'; -import { customResources } from './custom-resouces/custom-resources.ts'; -import { CustomResourceService } from './services/custom-resources/custom-resources.ts'; -import { WatcherService } from './services/watchers/watchers.ts'; -import { StorageProvider } from './storage-provider/storage-provider.ts'; +import { ResourceService } from './services/resources/resources.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 resourceService = services.get(ResourceService); -const watcherService = services.get(WatcherService); -await watcherService.watchCustomGroup('source.toolkit.fluxcd.io', 'v1', ['helmrepositories', 'gitrepositories']); -await watcherService.watchCustomGroup('helm.toolkit.fluxcd.io', 'v2', ['helmreleases']); -await watcherService.watchCustomGroup('cert-manager.io', 'v1', ['certificates']); -await watcherService.watchCustomGroup('networking.k8s.io', 'v1', ['gateways', 'virtualservices']); +await resourceService.install(...Object.values(homelab)); +await resourceService.register(...Object.values(resources)); -await watcherService - .create({ - path: '/api/v1/namespaces', - list: async (k8s) => { - return await k8s.api.listNamespace(); - }, - verbs: ['add', 'update', 'delete'], - transform: (manifest) => ({ - apiVersion: 'v1', - kind: 'Namespace', - ...manifest, - }), - }) - .start(); +const bootstrapService = services.get(BootstrapService); +await bootstrapService.ensure(); -await watcherService - .create({ - path: '/api/v1/secrets', - list: async (k8s) => { - return await k8s.api.listSecretForAllNamespaces(); - }, - verbs: ['add', 'update', 'delete'], - transform: (manifest) => ({ - apiVersion: 'v1', - kind: 'Secret', - ...manifest, - }), - }) - .start(); - -await watcherService - .create({ - path: '/apis/apps/v1/statefulsets', - list: async (k8s) => { - return await k8s.apps.listStatefulSetForAllNamespaces({}); - }, - verbs: ['add', 'update', 'delete'], - transform: (manifest) => ({ - apiVersion: 'apps/v1', - kind: 'StatefulSet', - ...manifest, - }), - }) - .start(); - -await watcherService - .create({ - path: '/apis/apps/v1/deployments', - list: async (k8s) => { - return await k8s.apps.listDeploymentForAllNamespaces({}); - }, - verbs: ['add', 'update', 'delete'], - transform: (manifest) => ({ - apiVersion: 'apps/v1', - kind: 'Deployment', - ...manifest, - }), - }) - .start(); - -await watcherService - .create({ - path: '/apis/apiextensions.k8s.io/v1/customresourcedefinitions', - 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(); +console.log('Started'); diff --git a/src/instances/authentik-server.ts b/src/instances/authentik-server.ts deleted file mode 100644 index faef10c..0000000 --- a/src/instances/authentik-server.ts +++ /dev/null @@ -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> {} - -export { AuthentikServerInstance }; diff --git a/src/instances/certificate.ts b/src/instances/certificate.ts deleted file mode 100644 index bae02a5..0000000 --- a/src/instances/certificate.ts +++ /dev/null @@ -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 {} - -export { CertificateInstance }; diff --git a/src/instances/cluster-issuer.ts b/src/instances/cluster-issuer.ts deleted file mode 100644 index 34bcb7f..0000000 --- a/src/instances/cluster-issuer.ts +++ /dev/null @@ -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 { - public get ready() { - return this.exists; - } -} - -export { ClusterIssuerInstance }; diff --git a/src/instances/custom-resource-definition.ts b/src/instances/custom-resource-definition.ts deleted file mode 100644 index f0d098b..0000000 --- a/src/instances/custom-resource-definition.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { V1CustomResourceDefinition } from '@kubernetes/client-node'; - -import { ResourceInstance } from '../services/resources/resources.instance.ts'; - -class CustomDefinitionInstance extends ResourceInstance {} - -export { CustomDefinitionInstance }; diff --git a/src/instances/deployment.ts b/src/instances/deployment.ts deleted file mode 100644 index d7f7996..0000000 --- a/src/instances/deployment.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { V1Deployment } from '@kubernetes/client-node'; - -import { ResourceInstance } from '../services/resources/resources.ts'; - -class DeploymentInstance extends ResourceInstance { - public get ready() { - return this.exists && this.status?.readyReplicas === this.status?.replicas; - } -} - -export { DeploymentInstance }; diff --git a/src/instances/destination-rule.ts b/src/instances/destination-rule.ts deleted file mode 100644 index 671aa07..0000000 --- a/src/instances/destination-rule.ts +++ /dev/null @@ -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 { - public get ready() { - return this.exists; - } -} - -export { DestinationRuleInstance }; diff --git a/src/instances/environment.ts b/src/instances/environment.ts deleted file mode 100644 index f1ca559..0000000 --- a/src/instances/environment.ts +++ /dev/null @@ -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> {} - -export { EnvironmentInstance }; diff --git a/src/instances/gateway.ts b/src/instances/gateway.ts deleted file mode 100644 index 200abd1..0000000 --- a/src/instances/gateway.ts +++ /dev/null @@ -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 {} - -export { GatewayInstance }; diff --git a/src/instances/git-repo.ts b/src/instances/git-repo.ts deleted file mode 100644 index edb2850..0000000 --- a/src/instances/git-repo.ts +++ /dev/null @@ -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 { - public get ready() { - return this.exists; - } -} - -export { GitRepoInstance }; diff --git a/src/instances/helm-release.ts b/src/instances/helm-release.ts deleted file mode 100644 index 5846993..0000000 --- a/src/instances/helm-release.ts +++ /dev/null @@ -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 { - public get ready() { - return this.exists; - } -} - -export { HelmReleaseInstance }; diff --git a/src/instances/helm-repo.ts b/src/instances/helm-repo.ts deleted file mode 100644 index 5ba179f..0000000 --- a/src/instances/helm-repo.ts +++ /dev/null @@ -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 { - public get ready() { - if (!this.exists) { - return false; - } - const condition = this.getCondition('Ready'); - return condition?.status === 'True'; - } -} - -export { HelmRepoInstance }; diff --git a/src/instances/http-service.ts b/src/instances/http-service.ts deleted file mode 100644 index 9d2cddf..0000000 --- a/src/instances/http-service.ts +++ /dev/null @@ -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> {} - -export { HttpServiceInstance }; diff --git a/src/instances/namespace.ts b/src/instances/namespace.ts deleted file mode 100644 index a1ee6d3..0000000 --- a/src/instances/namespace.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { V1Namespace } from '@kubernetes/client-node'; - -import { ResourceInstance } from '../services/resources/resources.ts'; - -class NamespaceInstance extends ResourceInstance { - public get ready() { - return this.exists; - } -} - -export { NamespaceInstance }; diff --git a/src/instances/postgres-cluster.ts b/src/instances/postgres-cluster.ts deleted file mode 100644 index 14ae5da..0000000 --- a/src/instances/postgres-cluster.ts +++ /dev/null @@ -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> {} - -export { PostgresClusterInstance }; diff --git a/src/instances/postgres-database.ts b/src/instances/postgres-database.ts deleted file mode 100644 index 63e8089..0000000 --- a/src/instances/postgres-database.ts +++ /dev/null @@ -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> { - 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 }; diff --git a/src/instances/redis-server.ts b/src/instances/redis-server.ts deleted file mode 100644 index 0b94c96..0000000 --- a/src/instances/redis-server.ts +++ /dev/null @@ -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> {} - -export { RedisServerInstance }; diff --git a/src/instances/secret.ts b/src/instances/secret.ts deleted file mode 100644 index 853a54b..0000000 --- a/src/instances/secret.ts +++ /dev/null @@ -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 extends ResourceInstance { - public get values() { - return decodeSecret(this.data) as z.infer; - } - - public ensureData = async (values: z.infer) => { - await this.ensure({ - data: encodeSecret(values as Record), - }); - }; - - public get ready() { - return this.exists; - } -} - -export { SecretInstance }; diff --git a/src/instances/service.ts b/src/instances/service.ts deleted file mode 100644 index f854195..0000000 --- a/src/instances/service.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { V1Service } from '@kubernetes/client-node'; - -import { ResourceInstance } from '../services/resources/resources.ts'; - -class ServiceInstance extends ResourceInstance { - public get ready() { - return this.exists; - } -} - -export { ServiceInstance }; diff --git a/src/instances/stateful-set.ts b/src/instances/stateful-set.ts deleted file mode 100644 index a612fc7..0000000 --- a/src/instances/stateful-set.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { V1StatefulSet } from '@kubernetes/client-node'; - -import { ResourceInstance } from '../services/resources/resources.instance.ts'; - -class StatefulSetInstance extends ResourceInstance { - public get ready() { - return this.exists && this.manifest?.status?.readyReplicas === this.manifest?.status?.replicas; - } -} - -export { StatefulSetInstance }; diff --git a/src/instances/storageclass.ts b/src/instances/storageclass.ts deleted file mode 100644 index 47aae4f..0000000 --- a/src/instances/storageclass.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { V1StorageClass } from '@kubernetes/client-node'; - -import { ResourceInstance } from '../services/resources/resources.instance.ts'; - -class StorageClassInstance extends ResourceInstance {} - -export { StorageClassInstance }; diff --git a/src/instances/virtual-service.ts b/src/instances/virtual-service.ts deleted file mode 100644 index a4ceabf..0000000 --- a/src/instances/virtual-service.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { KubernetesObject } from '@kubernetes/client-node'; - -import { ResourceInstance } from '../services/resources/resources.instance.ts'; -import type { K8SVirtualServiceV1 } from '../__generated__/resources/K8SVirtualServiceV1.ts'; - -class VirtualServiceInstance extends ResourceInstance { - public get ready() { - return this.exists; - } -} - -export { VirtualServiceInstance }; diff --git a/src/resources/cert-manager/cert-manager.ts b/src/resources/cert-manager/cert-manager.ts new file mode 100644 index 0000000..5457531 --- /dev/null +++ b/src/resources/cert-manager/cert-manager.ts @@ -0,0 +1,9 @@ +import { Certificate } from './certificate/certificate.ts'; + +import type { ResourceClass } from '#services/resources/resources.ts'; + +const certManager = { + certificate: Certificate, +} satisfies Record>; + +export { certManager }; diff --git a/src/resources/cert-manager/certificate/certificate.ts b/src/resources/cert-manager/certificate/certificate.ts new file mode 100644 index 0000000..b2de4fa --- /dev/null +++ b/src/resources/cert-manager/certificate/certificate.ts @@ -0,0 +1,37 @@ +import type { KubernetesObject } from '@kubernetes/client-node'; +import type { K8SCertificateV1 } from 'src/__generated__/resources/K8SCertificateV1.ts'; + +import { CRD } from '#resources/core/crd/crd.ts'; +import { Resource, ResourceService, type ResourceOptions } from '#services/resources/resources.ts'; +import { NotReadyError } from '#utils/errors.ts'; + +class Certificate extends Resource { + public static readonly apiVersion = 'cert-manager.io/v1'; + public static readonly kind = 'Certificate'; + + #crd: CRD; + + constructor(options: ResourceOptions) { + super(options); + const resourceService = this.services.get(ResourceService); + this.#crd = resourceService.get(CRD, 'certificates.cert-manager.io'); + this.#crd.on('changed', this.#handleCrdChanged); + } + + #handleCrdChanged = () => { + this.emit('changed', this.manifest); + }; + + public get hasCRD() { + return this.#crd.exists; + } + + public set = async (manifest: KubernetesObject & K8SCertificateV1) => { + if (!this.hasCRD) { + throw new NotReadyError('MissingCRD', 'certificates.cert-manager.io does not exist'); + } + return this.ensure(manifest); + }; +} + +export { Certificate }; diff --git a/src/resources/core/core.ts b/src/resources/core/core.ts new file mode 100644 index 0000000..d8eafcb --- /dev/null +++ b/src/resources/core/core.ts @@ -0,0 +1,23 @@ +import { CRD } from './crd/crd.ts'; +import { Deployment } from './deployment/deployment.ts'; +import { Namespace } from './namespace/namespace.ts'; +import { PersistentVolume } from './pv/pv.ts'; +import { PVC } from './pvc/pvc.ts'; +import { Secret } from './secret/secret.ts'; +import { Service } from './service/service.ts'; +import { StatefulSet } from './stateful-set/stateful-set.ts'; +import { StorageClass } from './storage-class/storage-class.ts'; + +const core = { + namespace: Namespace, + storageClass: StorageClass, + pvc: PVC, + pv: PersistentVolume, + secret: Secret, + crd: CRD, + service: Service, + deployment: Deployment, + statefulSet: StatefulSet, +}; + +export { core }; diff --git a/src/resources/core/crd/crd.ts b/src/resources/core/crd/crd.ts new file mode 100644 index 0000000..7023fe3 --- /dev/null +++ b/src/resources/core/crd/crd.ts @@ -0,0 +1,10 @@ +import type { V1CustomResourceDefinition } from '@kubernetes/client-node'; + +import { Resource } from '#services/resources/resources.ts'; + +class CRD extends Resource { + public static readonly apiVersion = 'apiextensions.k8s.io/v1'; + public static readonly kind = 'CustomResourceDefinition'; +} + +export { CRD }; diff --git a/src/resources/core/deployment/deployment.ts b/src/resources/core/deployment/deployment.ts new file mode 100644 index 0000000..7e277ab --- /dev/null +++ b/src/resources/core/deployment/deployment.ts @@ -0,0 +1,10 @@ +import type { V1Deployment } from '@kubernetes/client-node'; + +import { Resource } from '#services/resources/resources.ts'; + +class Deployment extends Resource { + public static readonly apiVersion = 'apps/v1'; + public static readonly kind = 'Deployment'; +} + +export { Deployment }; diff --git a/src/resources/core/namespace/namespace.ts b/src/resources/core/namespace/namespace.ts new file mode 100644 index 0000000..930a49c --- /dev/null +++ b/src/resources/core/namespace/namespace.ts @@ -0,0 +1,10 @@ +import type { V1Namespace } from '@kubernetes/client-node'; + +import { Resource } from '#services/resources/resources.ts'; + +class Namespace extends Resource { + public static readonly apiVersion = 'v1'; + public static readonly kind = 'Namespace'; +} + +export { Namespace }; diff --git a/src/resources/core/pv/pv.ts b/src/resources/core/pv/pv.ts new file mode 100644 index 0000000..02375df --- /dev/null +++ b/src/resources/core/pv/pv.ts @@ -0,0 +1,10 @@ +import type { V1PersistentVolume } from '@kubernetes/client-node'; + +import { Resource } from '#services/resources/resources.ts'; + +class PersistentVolume extends Resource { + public static readonly apiVersion = 'v1'; + public static readonly kind = 'PersistentVolume'; +} + +export { PersistentVolume }; diff --git a/src/resources/core/pvc/pvc.ts b/src/resources/core/pvc/pvc.ts new file mode 100644 index 0000000..7d67349 --- /dev/null +++ b/src/resources/core/pvc/pvc.ts @@ -0,0 +1,80 @@ +import type { V1PersistentVolumeClaim } from '@kubernetes/client-node'; + +import { StorageClass } from '../storage-class/storage-class.ts'; +import { PersistentVolume } from '../pv/pv.ts'; + +import { Resource, ResourceService, type ResourceOptions } from '#services/resources/resources.ts'; + +const PROVISIONER = 'homelab-operator'; + +class PVC extends Resource { + public static readonly apiVersion = 'v1'; + public static readonly kind = 'PersistentVolumeClaim'; + + constructor(options: ResourceOptions) { + super(options); + this.on('changed', this.reconcile); + } + + public reconcile = async () => { + const storageClassName = this.spec?.storageClassName; + console.log('PVC', this.name, storageClassName); + if (!storageClassName) { + return; + } + const resourceService = this.services.get(ResourceService); + const storageClass = resourceService.get(StorageClass, storageClassName); + + if (!storageClass.exists || storageClass.manifest?.provisioner !== PROVISIONER) { + return; + } + if (this.status?.phase === 'Pending' && !this.spec?.volumeName) { + await this.#provisionVolume(storageClass); + } + }; + + #provisionVolume = async (storageClass: StorageClass) => { + const pvName = `pv-${this.namespace}-${this.name}`; + const storageLocation = storageClass.manifest?.parameters?.storageLocation || '/data/volumes'; + const target = `${storageLocation}/${this.namespace}/${this.name}`; + + const resourceService = this.services.get(ResourceService); + const pv = resourceService.get(PersistentVolume, pvName); + + await pv.ensure({ + metadata: { + name: pvName, + labels: { + provisioner: PROVISIONER, + 'pvc-namespace': this.namespace || 'default', + 'pvc-name': this.name || 'unknown', + }, + annotations: { + 'pv.kubernetes.io/provisioned-by': PROVISIONER, + }, + }, + spec: { + hostPath: { + path: target, + type: 'DirectoryOrCreate', + }, + capacity: { + storage: this.spec?.resources?.requests?.storage ?? '1Gi', + }, + persistentVolumeReclaimPolicy: 'Retain', + accessModes: this.spec?.accessModes ?? ['ReadWriteOnce'], + storageClassName: this.spec?.storageClassName, + claimRef: { + uid: this.metadata?.uid, + resourceVersion: this.metadata?.resourceVersion, + apiVersion: this.apiVersion, + kind: 'PersistentVolumeClaim', + name: this.name, + namespace: this.namespace, + }, + }, + }); + }; +} + +export { PVC, PROVISIONER }; diff --git a/src/resources/core/secret/secret.ts b/src/resources/core/secret/secret.ts new file mode 100644 index 0000000..c9d9e39 --- /dev/null +++ b/src/resources/core/secret/secret.ts @@ -0,0 +1,25 @@ +import type { KubernetesObject, V1Secret } from '@kubernetes/client-node'; + +import { Resource } from '#services/resources/resources.ts'; +import { decodeSecret, encodeSecret } from '#utils/secrets.ts'; + +type SetOptions> = T | ((current: T | undefined) => T | Promise); + +class Secret = Record> extends Resource { + public static readonly apiVersion = 'v1'; + public static readonly kind = 'Secret'; + + public get value() { + return decodeSecret(this.data) as T | undefined; + } + + public set = async (options: SetOptions, data?: KubernetesObject) => { + const value = typeof options === 'function' ? await Promise.resolve(options(this.value)) : options; + await this.ensure({ + ...data, + data: encodeSecret(value), + }); + }; +} + +export { Secret }; diff --git a/src/resources/core/service/service.ts b/src/resources/core/service/service.ts new file mode 100644 index 0000000..31b9bd2 --- /dev/null +++ b/src/resources/core/service/service.ts @@ -0,0 +1,14 @@ +import type { V1Service } from '@kubernetes/client-node'; + +import { Resource } from '#services/resources/resources.ts'; + +class Service extends Resource { + public static readonly apiVersion = 'v1'; + public static readonly kind = 'Service'; + + public get hostname() { + return `${this.name}.${this.namespace}.svc.cluster.local`; + } +} + +export { Service }; diff --git a/src/resources/core/stateful-set/stateful-set.ts b/src/resources/core/stateful-set/stateful-set.ts new file mode 100644 index 0000000..3ff3369 --- /dev/null +++ b/src/resources/core/stateful-set/stateful-set.ts @@ -0,0 +1,10 @@ +import type { V1StatefulSet } from '@kubernetes/client-node'; + +import { Resource } from '#services/resources/resources.ts'; + +class StatefulSet extends Resource { + public static readonly apiVersion = 'apps/v1'; + public static readonly kind = 'StatefulSet'; +} + +export { StatefulSet }; diff --git a/src/resources/core/storage-class/storage-class.ts b/src/resources/core/storage-class/storage-class.ts new file mode 100644 index 0000000..d3a14f7 --- /dev/null +++ b/src/resources/core/storage-class/storage-class.ts @@ -0,0 +1,11 @@ +import type { V1StorageClass } from '@kubernetes/client-node'; + +import { Resource } from '#services/resources/resources.ts'; + +class StorageClass extends Resource { + public static readonly apiVersion = 'storage.k8s.io/v1'; + public static readonly kind = 'StorageClass'; + public static readonly plural = 'storageclasses'; +} + +export { StorageClass }; diff --git a/src/resources/flux/flux.ts b/src/resources/flux/flux.ts new file mode 100644 index 0000000..285a814 --- /dev/null +++ b/src/resources/flux/flux.ts @@ -0,0 +1,11 @@ +import { HelmRelease } from './helm-release/helm-release.ts'; +import { HelmRepo } from './helm-repo/helm-repo.ts'; + +import type { ResourceClass } from '#services/resources/resources.ts'; + +const flux = { + helmRelease: HelmRelease, + helmRepo: HelmRepo, +} satisfies Record>; + +export { flux }; diff --git a/src/resources/flux/helm-release/helm-release.ts b/src/resources/flux/helm-release/helm-release.ts new file mode 100644 index 0000000..73ff7f7 --- /dev/null +++ b/src/resources/flux/helm-release/helm-release.ts @@ -0,0 +1,42 @@ +import type { KubernetesObject } from '@kubernetes/client-node'; +import type { K8SHelmReleaseV2 } from 'src/__generated__/resources/K8SHelmReleaseV2.ts'; + +import { Resource } from '#services/resources/resources.ts'; + +type SetOptions = { + namespace?: string; + values?: Record; + chart: { + name: string; + namespace?: string; + }; +}; + +class HelmRelease extends Resource { + public static readonly apiVersion = 'helm.toolkit.fluxcd.io/v2'; + public static readonly kind = 'HelmRelease'; + + public set = async (options: SetOptions) => { + return await this.ensure({ + spec: { + targetNamespace: options.namespace, + interval: '1h', + values: options.values, + chart: { + spec: { + chart: 'cert-manager', + version: 'v1.18.2', + sourceRef: { + apiVersion: 'source.toolkit.fluxcd.io/v1', + kind: 'HelmRepository', + name: options.chart.name, + namespace: options.chart.namespace, + }, + }, + }, + }, + }); + }; +} + +export { HelmRelease }; diff --git a/src/resources/flux/helm-repo/helm-repo.ts b/src/resources/flux/helm-repo/helm-repo.ts new file mode 100644 index 0000000..d9ae738 --- /dev/null +++ b/src/resources/flux/helm-repo/helm-repo.ts @@ -0,0 +1,24 @@ +import type { KubernetesObject } from '@kubernetes/client-node'; +import type { K8SHelmRepositoryV1 } from 'src/__generated__/resources/K8SHelmRepositoryV1.ts'; + +import { Resource } from '#services/resources/resources.ts'; + +type SetOptions = { + url: string; +}; +class HelmRepo extends Resource { + public static readonly apiVersion = 'source.toolkit.fluxcd.io/v1'; + public static readonly kind = 'HelmRepository'; + public static readonly plural = 'helmrepositories'; + + public set = async ({ url }: SetOptions) => { + await this.ensure({ + spec: { + interval: '1h', + url, + }, + }); + }; +} + +export { HelmRepo }; diff --git a/src/resources/homelab/authentik-server/authentik-server.ts b/src/resources/homelab/authentik-server/authentik-server.ts new file mode 100644 index 0000000..6354b74 --- /dev/null +++ b/src/resources/homelab/authentik-server/authentik-server.ts @@ -0,0 +1,284 @@ +import { z } from 'zod'; + +import { PostgresDatabase } from '../postgres-database/postgres-database.ts'; +import { Environment } from '../environment/environment.ts'; + +import { + CustomResource, + ResourceReference, + ResourceService, + type CustomResourceOptions, +} from '#services/resources/resources.ts'; +import { API_VERSION } from '#utils/consts.ts'; +import { Secret } from '#resources/core/secret/secret.ts'; +import { generateRandomHexPass } from '#utils/secrets.ts'; +import { Service } from '#resources/core/service/service.ts'; +import { HelmRelease } from '#resources/flux/helm-release/helm-release.ts'; +import { RepoService } from '#bootstrap/repos/repos.ts'; +import { VirtualService } from '#resources/istio/virtual-service/virtual-service.ts'; +import { DestinationRule } from '#resources/istio/destination-rule/destination-rule.ts'; +import { NotReadyError } from '#utils/errors.ts'; + +const specSchema = z.object({ + environment: z.string(), + subdomain: z.string().optional(), +}); + +type SecretData = { url: string; host: string; token: string }; +type InitSecretData = { + AUTHENTIK_BOOTSTRAP_TOKEN: string; + AUTHENTIK_BOOTSTRAP_PASSWORD: string; + AUTHENTIK_BOOTSTRAP_EMAIL: string; + AUTHENTIK_SECRET_KEY: string; +}; + +class AuthentikServer extends CustomResource { + public static readonly apiVersion = API_VERSION; + public static readonly kind = 'AuthentikServer'; + public static readonly spec = specSchema; + public static readonly scope = 'Namespaced'; + + #environment: ResourceReference; + #database: PostgresDatabase; + #secret: Secret; + #initSecret: Secret; + #service: Service; + #helmRelease: HelmRelease; + #virtualService: VirtualService; + #destinationRule: DestinationRule; + + constructor(options: CustomResourceOptions) { + super(options); + + const resourceService = this.services.get(ResourceService); + this.#environment = new ResourceReference(); + this.#environment.on('changed', this.queueReconcile); + + this.#database = resourceService.get(PostgresDatabase, this.name, this.namespace); + this.#database.on('changed', this.queueReconcile); + + this.#secret = resourceService.get(Secret, this.name, this.namespace); + this.#secret.on('changed', this.queueReconcile); + + this.#initSecret = resourceService.get(Secret, `${this.name}-init`, this.namespace); + + this.#service = resourceService.get(Service, `${this.name}-server`, this.namespace); + // this.#service.on('changed', this.queueReconcile); + + this.#helmRelease = resourceService.get(HelmRelease, this.name, this.namespace); + this.#helmRelease.on('changed', this.queueReconcile); + + this.#virtualService = resourceService.get(VirtualService, this.name, this.namespace); + this.#virtualService.on('changed', this.queueReconcile); + + this.#destinationRule = resourceService.get(DestinationRule, this.name, this.namespace); + this.#destinationRule.on('changed', this.queueReconcile); + } + + public get service() { + return this.#service; + } + + public get secret() { + return this.#secret; + } + + public get subdomain() { + return this.spec?.subdomain || 'authentik'; + } + + public get domain() { + return `${this.subdomain}.${this.#environment.current?.spec?.domain}`; + } + + public get url() { + return `https://${this.domain}`; + } + + public reconcile = async () => { + if (!this.spec) { + throw new NotReadyError('MissingSpec'); + } + const resourceService = this.services.get(ResourceService); + + this.#environment.current = resourceService.get(Environment, this.spec.environment); + if (!this.#environment.current.spec) { + throw new NotReadyError('MissingEnvSpev'); + } + + await this.#database.ensure({ + metadata: { + ownerReferences: [this.ref], + }, + spec: { + environment: this.#environment.current.name, + }, + }); + + const databaseSecret = this.#database.secret.value; + if (!databaseSecret) { + throw new NotReadyError('MissingDatabaseSecret'); + } + + await this.#initSecret.set( + (current) => ({ + AUTHENTIK_BOOTSTRAP_EMAIL: 'admin@example.com', + AUTHENTIK_BOOTSTRAP_PASSWORD: generateRandomHexPass(24), + AUTHENTIK_BOOTSTRAP_TOKEN: generateRandomHexPass(32), + AUTHENTIK_SECRET_KEY: generateRandomHexPass(32), + ...current, + }), + { + metadata: { + ownerReferences: [this.ref], + }, + }, + ); + + const initSecret = this.#initSecret.value; + if (!initSecret) { + throw new NotReadyError('MissingInitSecret'); + } + + const domain = `${this.spec?.subdomain || 'authentik'}.${this.#environment.current.spec.domain}`; + await this.#secret.set( + { + url: `https://${domain}`, + host: this.#service.hostname, + token: initSecret.AUTHENTIK_BOOTSTRAP_TOKEN, + }, + { + metadata: { + ownerReferences: [this.ref], + }, + }, + ); + const secret = this.#secret.value; + if (!secret) { + throw new NotReadyError('MissingSecret'); + } + + const repoService = this.services.get(RepoService); + + await this.#helmRelease.ensure({ + metadata: { + ownerReferences: [this.ref], + }, + spec: { + interval: '60m', + chart: { + spec: { + chart: 'authentik', + version: '2025.6.4', + sourceRef: { + apiVersion: 'source.toolkit.fluxcd.io/v1', + kind: 'HelmRepository', + name: repoService.authentik.name, + namespace: repoService.authentik.namespace, + }, + }, + }, + values: { + global: { + envFrom: [ + { + secretRef: { + name: this.#initSecret.name, + }, + }, + ], + }, + authentik: { + error_reporting: { + enabled: false, + }, + postgresql: { + host: databaseSecret.host, + name: databaseSecret.database, + user: databaseSecret.user, + password: 'file:///postgres-creds/password', + }, + redis: { + host: this.#environment.current.redisServer.service.hostname, + }, + }, + server: { + volumes: [ + { + name: 'postgres-creds', + secret: { + secretName: this.#database.secret.name, + }, + }, + ], + volumeMounts: [ + { + name: 'postgres-creds', + mountPath: '/postgres-creds', + readOnly: true, + }, + ], + }, + worker: { + volumes: [ + { + name: 'postgres-creds', + secret: { + secretName: this.#database.secret.name, + }, + }, + ], + volumeMounts: [ + { + name: 'postgres-creds', + mountPath: '/postgres-creds', + readOnly: true, + }, + ], + }, + }, + }, + }); + + await this.#destinationRule.ensure({ + metadata: { + ownerReferences: [this.ref], + }, + spec: { + host: this.#service.hostname, + trafficPolicy: { + tls: { + mode: 'DISABLE', + }, + }, + }, + }); + + const gateway = this.#environment.current.gateway; + await this.#virtualService.set({ + metadata: { + ownerReferences: [this.ref], + }, + spec: { + gateways: [`${gateway.namespace}/${gateway.name}`, 'mesh'], + hosts: [domain], + http: [ + { + route: [ + { + destination: { + host: this.#service.hostname, + port: { + number: 80, + }, + }, + }, + ], + }, + ], + }, + }); + }; +} + +export { AuthentikServer }; diff --git a/src/resources/homelab/cloudflare-tunnel/cloudflare-tunnel.ts b/src/resources/homelab/cloudflare-tunnel/cloudflare-tunnel.ts new file mode 100644 index 0000000..084fd93 --- /dev/null +++ b/src/resources/homelab/cloudflare-tunnel/cloudflare-tunnel.ts @@ -0,0 +1,94 @@ +import { + CustomResource, + Resource, + ResourceService, + type CustomResourceOptions, +} from '#services/resources/resources.ts'; +import z from 'zod'; +import { ExternalHttpService } from '../external-http-service.ts/external-http-service.ts'; +import { API_VERSION } from '#utils/consts.ts'; +import { HelmRelease } from '#resources/flux/helm-release/helm-release.ts'; +import { RepoService } from '#bootstrap/repos/repos.ts'; +import { Secret } from '#resources/core/secret/secret.ts'; +import { NotReadyError } from '#utils/errors.ts'; +import { NamespaceService } from '#bootstrap/namespaces/namespaces.ts'; + +const specSchema = z.object({}); + +type SecretData = { + account: string; + tunnelName: string; + tunnelId: string; + secret: string; +}; +class CloudflareTunnel extends CustomResource { + public static readonly apiVersion = API_VERSION; + public static readonly kind = 'CloudflareTunnel'; + public static readonly spec = specSchema; + public static readonly scope = 'Cluster'; + + #helmRelease: HelmRelease; + #secret: Secret; + + constructor(options: CustomResourceOptions) { + super(options); + const resourceService = this.services.get(ResourceService); + const namespaceService = this.services.get(NamespaceService); + const namespace = namespaceService.homelab.name; + resourceService.on('changed', this.#handleResourceChanged); + + this.#helmRelease = resourceService.get(HelmRelease, this.name, namespace); + this.#secret = resourceService.get(Secret, 'cloudflare', namespace); + this.#secret.on('changed', this.queueReconcile); + } + + #handleResourceChanged = (resource: Resource) => { + if (resource instanceof CloudflareTunnel) { + this.queueReconcile(); + } + }; + + public reconcile = async () => { + const secret = this.#secret.value; + if (!secret) { + throw new NotReadyError('MissingSecret', `Secret ${this.#secret.namespace}/${this.#secret.name} does not exist`); + } + const resourceService = this.services.get(ResourceService); + const repoService = this.services.get(RepoService); + const routes = resourceService.getAllOfKind(ExternalHttpService); + const ingress = routes.map(({ rule }) => ({ + hostname: rule?.hostname, + service: `http://${rule?.destination.host}:${rule?.destination.port.number}`, + })); + await this.#helmRelease.ensure({ + metadata: { + ownerReferences: [this.ref], + }, + spec: { + interval: '1h', + values: { + cloudflare: { + account: secret.account, + tunnelName: secret.tunnelName, + tunnelId: secret.tunnelId, + secret: secret.secret, + ingress, + }, + }, + chart: { + spec: { + chart: 'cloudflare-tunnel', + sourceRef: { + apiVersion: 'source.toolkit.fluxcd.io/v1', + kind: 'HelmRepository', + name: repoService.cloudflare.name, + namespace: repoService.cloudflare.namespace, + }, + }, + }, + }, + }); + }; +} + +export { CloudflareTunnel }; diff --git a/src/resources/homelab/environment/environment.ts b/src/resources/homelab/environment/environment.ts new file mode 100644 index 0000000..453be8c --- /dev/null +++ b/src/resources/homelab/environment/environment.ts @@ -0,0 +1,214 @@ +import { z } from 'zod'; + +import { PostgresCluster } from '../postgres-cluster/postgres-cluster.ts'; +import { RedisServer } from '../redis-server/redis-server.ts'; +import { AuthentikServer } from '../authentik-server/authentik-server.ts'; + +import { CustomResource, ResourceService, type CustomResourceOptions } from '#services/resources/resources.ts'; +import { API_VERSION } from '#utils/consts.ts'; +import { Namespace } from '#resources/core/namespace/namespace.ts'; +import { Certificate } from '#resources/cert-manager/certificate/certificate.ts'; +import { StorageClass } from '#resources/core/storage-class/storage-class.ts'; +import { PROVISIONER } from '#resources/core/pvc/pvc.ts'; +import { Gateway } from '#resources/istio/gateway/gateway.ts'; +import { NotReadyError } from '#utils/errors.ts'; +import { NamespaceService } from '#bootstrap/namespaces/namespaces.ts'; +import { CloudflareService } from '#services/cloudflare/cloudflare.ts'; + +const specSchema = z.object({ + domain: z.string(), + networkIp: z.string().optional(), + tls: z.object({ + issuer: z.string(), + }), +}); + +class Environment extends CustomResource { + public static readonly apiVersion = API_VERSION; + public static readonly kind = 'Environment'; + public static readonly spec = specSchema; + public static readonly scope = 'Cluster'; + + #namespace: Namespace; + #certificate: Certificate; + #storageClass: StorageClass; + #gateway: Gateway; + #postgresCluster: PostgresCluster; + #redisServer: RedisServer; + #authentikServer: AuthentikServer; + #cloudflareService: CloudflareService; + + constructor(options: CustomResourceOptions) { + super(options); + const resourceService = this.services.get(ResourceService); + const namespaceService = this.services.get(NamespaceService); + const homelabNamespace = namespaceService.homelab.name; + + this.#cloudflareService = this.services.get(CloudflareService); + this.#cloudflareService.on('changed', this.queueReconcile); + + this.#namespace = resourceService.get(Namespace, this.name); + this.#namespace.on('changed', this.queueReconcile); + + this.#certificate = resourceService.get(Certificate, this.name, homelabNamespace); + this.#certificate.on('changed', this.queueReconcile); + + this.#storageClass = resourceService.get(StorageClass, this.name); + this.#storageClass.on('changed', this.queueReconcile); + + this.#postgresCluster = resourceService.get(PostgresCluster, `${this.name}-postgres-cluster`, homelabNamespace); + this.#postgresCluster.on('changed', this.queueReconcile); + + this.#redisServer = resourceService.get(RedisServer, `${this.name}-redis-server`, homelabNamespace); + this.#redisServer.on('changed', this.queueReconcile); + + this.#gateway = resourceService.get(Gateway, this.name, homelabNamespace); + this.#gateway.on('changed', this.queueReconcile); + + this.#authentikServer = resourceService.get(AuthentikServer, `${this.name}-authentik`, homelabNamespace); + this.#authentikServer.on('changed', this.queueReconcile); + } + + public get certificate() { + return this.#certificate; + } + + public get storageClass() { + return this.#storageClass; + } + + public get postgresCluster() { + return this.#postgresCluster; + } + + public get redisServer() { + return this.#redisServer; + } + + public get gateway() { + return this.#gateway; + } + + public get authentikServer() { + return this.#authentikServer; + } + + public reconcile = async () => { + const { data: spec, success } = specSchema.safeParse(this.spec); + if (!success || !spec) { + throw new NotReadyError('InvalidSpec'); + } + + if (this.#cloudflareService.ready && spec.networkIp) { + const client = this.#cloudflareService.client; + const zones = await client.zones.list({ + name: spec.domain, + }); + const [zone] = zones.result; + if (!zone) { + throw new NotReadyError('NoZoneFound'); + } + + const existingRecords = await client.dns.records.list({ + zone_id: zone.id, + name: { + exact: `*.${spec.domain}`, + }, + }); + + console.log('Cloudflare records', existingRecords); + + // zones.result[0]. + } + await this.#namespace.ensure({ + metadata: { + labels: { + 'istio-injection': 'enabled', + }, + }, + }); + await this.#certificate.ensure({ + spec: { + secretName: `${this.name}-tls`, + issuerRef: { + name: spec.tls.issuer, + kind: 'ClusterIssuer', + }, + dnsNames: [`*.${spec.domain}`], + privateKey: { + rotationPolicy: 'Always', + }, + }, + }); + await this.#storageClass.ensure({ + metadata: { + ownerReferences: [this.ref], + }, + provisioner: PROVISIONER, + reclaimPolicy: 'Retain', + }); + + await this.#postgresCluster.ensure({ + metadata: { + ownerReferences: [this.ref], + }, + spec: { + storageClass: this.name, + }, + }); + + await this.#redisServer.ensure({ + metadata: { + ownerReferences: [this.ref], + }, + spec: {}, + }); + + await this.#authentikServer.ensure({ + metadata: { + ownerReferences: [this.ref], + }, + spec: { + environment: this.name, + }, + }); + + await this.#gateway.set({ + metadata: { + ownerReferences: [this.ref], + }, + spec: { + selector: { + istio: 'homelab-istio-gateway', + }, + servers: [ + { + hosts: [`*.${spec.domain}`], + port: { + name: 'http', + number: 80, + protocol: 'HTTP', + }, + tls: { + httpsRedirect: true, + }, + }, + { + hosts: [`*.${spec.domain}`], + port: { + name: 'https', + number: 443, + protocol: 'HTTPS', + }, + tls: { + mode: 'SIMPLE', + credentialName: `${this.name}-tls`, + }, + }, + ], + }, + }); + }; +} + +export { Environment }; diff --git a/src/resources/homelab/external-http-service.ts/external-http-service.ts b/src/resources/homelab/external-http-service.ts/external-http-service.ts new file mode 100644 index 0000000..1f99e77 --- /dev/null +++ b/src/resources/homelab/external-http-service.ts/external-http-service.ts @@ -0,0 +1,43 @@ +import { CustomResource, ResourceService, type CustomResourceOptions } from '#services/resources/resources.ts'; +import { z } from 'zod'; +import { Environment } from '../environment/environment.ts'; +import { API_VERSION } from '#utils/consts.ts'; + +const specSchema = z.object({ + environment: z.string(), + subdomain: z.string(), + destination: z.object({ + host: z.string(), + port: z.object({ + number: z.number(), + }), + }), +}); + +class ExternalHttpService extends CustomResource { + public static readonly apiVersion = API_VERSION; + public static readonly kind = 'ExternalHttpService'; + public static readonly spec = specSchema; + public static readonly scope = 'Namespaced'; + + constructor(options: CustomResourceOptions) { + super(options); + } + + public get rule() { + if (!this.spec) { + return undefined; + } + const resourceService = this.services.get(ResourceService); + const env = resourceService.get(Environment, this.spec.environment); + const hostname = `${this.spec.subdomain}.${env.spec?.domain}`; + return { + domain: env.spec?.domain, + subdomain: this.spec.subdomain, + hostname, + destination: this.spec.destination, + }; + } +} + +export { ExternalHttpService }; diff --git a/src/resources/homelab/generate-secret/generate-secret.ts b/src/resources/homelab/generate-secret/generate-secret.ts new file mode 100644 index 0000000..b47b5a8 --- /dev/null +++ b/src/resources/homelab/generate-secret/generate-secret.ts @@ -0,0 +1,47 @@ +import { Secret } from '#resources/core/secret/secret.ts'; +import { CustomResource, ResourceService, type CustomResourceOptions } from '#services/resources/resources.ts'; +import { z } from 'zod'; +import { generateSecrets } from './generate-secret.utils.ts'; +import { API_VERSION } from '#utils/consts.ts'; + +const generateSecretFieldSchema = z.object({ + name: z.string(), + value: z.string().optional(), + encoding: z.enum(['base64', 'base64url', 'hex', 'utf8', 'numeric']).optional(), + length: z.number().optional(), +}); + +const specSchema = z.object({ + fields: z.array(generateSecretFieldSchema), +}); + +class GenerateSecret extends CustomResource { + public static readonly apiVersion = API_VERSION; + public static readonly kind = 'GenerateSecret'; + public static readonly spec = specSchema; + public static readonly scope = 'Namespaced'; + + #secret: Secret; + + constructor(options: CustomResourceOptions) { + super(options); + + const resourceService = this.services.get(ResourceService); + + this.#secret = resourceService.get(Secret, this.name, this.namespace); + } + + public reconcile = async () => { + const secrets = generateSecrets(this.spec); + const current = this.#secret.value; + + const expected = { + ...secrets, + ...current, + }; + + await this.#secret.ensure(expected); + }; +} + +export { GenerateSecret }; diff --git a/src/custom-resouces/generate-secret/generate-secret.utils.ts b/src/resources/homelab/generate-secret/generate-secret.utils.ts similarity index 100% rename from src/custom-resouces/generate-secret/generate-secret.utils.ts rename to src/resources/homelab/generate-secret/generate-secret.utils.ts diff --git a/src/resources/homelab/homelab.ts b/src/resources/homelab/homelab.ts new file mode 100644 index 0000000..2d641fd --- /dev/null +++ b/src/resources/homelab/homelab.ts @@ -0,0 +1,27 @@ +import { Environment } from './environment/environment.ts'; +import { PostgresCluster } from './postgres-cluster/postgres-cluster.ts'; +import { RedisServer } from './redis-server/redis-server.ts'; +import { PostgresDatabase } from './postgres-database/postgres-database.ts'; +import { AuthentikServer } from './authentik-server/authentik-server.ts'; + +import type { InstallableResourceClass } from '#services/resources/resources.ts'; +import { OIDCClient } from './oidc-client/oidc-client.ts'; +import { HttpService } from './http-service/http-service.ts'; +import { GenerateSecret } from './generate-secret/generate-secret.ts'; +import { ExternalHttpService } from './external-http-service.ts/external-http-service.ts'; +import { CloudflareTunnel } from './cloudflare-tunnel/cloudflare-tunnel.ts'; + +const homelab = { + PostgresCluster, + RedisServer, + Environment, + ExternalHttpService, + CloudflareTunnel, + AuthentikServer, + PostgresDatabase, + OIDCClient, + HttpService, + GenerateSecret, +} satisfies Record>; + +export { homelab }; diff --git a/src/resources/homelab/http-service/http-service.ts b/src/resources/homelab/http-service/http-service.ts new file mode 100644 index 0000000..fedfbd9 --- /dev/null +++ b/src/resources/homelab/http-service/http-service.ts @@ -0,0 +1,83 @@ +import { VirtualService } from '#resources/istio/virtual-service/virtual-service.ts'; +import { + CustomResource, + ResourceReference, + ResourceService, + type CustomResourceOptions, +} from '#services/resources/resources.ts'; +import { z } from 'zod'; +import { Environment } from '../environment/environment.ts'; +import { NotReadyError } from '#utils/errors.ts'; +import { API_VERSION } from '#utils/consts.ts'; + +const specSchema = z.object({ + environment: z.string(), + subdomain: z.string(), + destination: z.object({ + host: z.string(), + port: z.object({ + number: z.number().optional(), + name: z.string().optional(), + }), + }), +}); + +class HttpService extends CustomResource { + public static readonly apiVersion = API_VERSION; + public static readonly kind = 'HttpService'; + public static readonly spec = specSchema; + public static readonly scope = 'Namespaced'; + + #virtualService: VirtualService; + #environment: ResourceReference; + + constructor(options: CustomResourceOptions) { + super(options); + + const resourceService = this.services.get(ResourceService); + this.#virtualService = resourceService.get(VirtualService, this.name, this.namespace); + this.#virtualService.on('changed', this.queueReconcile); + + this.#environment = new ResourceReference(); + this.#environment.on('changed', this.queueReconcile); + } + + public reconcile = async () => { + if (!this.spec) { + throw new NotReadyError('MissingSpec'); + } + const resourceService = this.services.get(ResourceService); + + this.#environment.current = resourceService.get(Environment, this.spec.environment); + const env = this.#environment.current; + if (!env.exists) { + throw new NotReadyError('MissingEnvironment'); + } + const gateway = env.gateway; + const domain = env.spec?.domain; + if (!domain) { + throw new NotReadyError('MissingDomain'); + } + const host = `${this.spec.subdomain}.${domain}`; + this.#virtualService.ensure({ + metadata: { + ownerReferences: [this.ref], + }, + spec: { + hosts: [host, 'mesh'], + gateways: [`${gateway.namespace}/${gateway.name}`, 'mesh'], + http: [ + { + route: [ + { + destination: this.spec.destination, + }, + ], + }, + ], + }, + }); + }; +} + +export { HttpService }; diff --git a/src/resources/homelab/oidc-client/oidc-client.ts b/src/resources/homelab/oidc-client/oidc-client.ts new file mode 100644 index 0000000..ee9df5c --- /dev/null +++ b/src/resources/homelab/oidc-client/oidc-client.ts @@ -0,0 +1,109 @@ +import { + CustomResource, + ResourceReference, + ResourceService, + type CustomResourceOptions, +} from '#services/resources/resources.ts'; +import { ClientTypeEnum, SubModeEnum } from '@goauthentik/api'; +import { z } from 'zod'; +import { Environment } from '../environment/environment.ts'; +import { API_VERSION } from '#utils/consts.ts'; +import { NotReadyError } from '#utils/errors.ts'; +import { Secret } from '#resources/core/secret/secret.ts'; +import { generateRandomHexPass } from '#utils/secrets.ts'; +import { AuthentikService } from '#services/authentik/authentik.service.ts'; + +const specSchema = z.object({ + environment: 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']), + }), + ), +}); + +type SecretData = { + clientId: string; + clientSecret?: string; + configuration: string; + configurationIssuer: string; + authorization: string; + token: string; + userinfo: string; + endSession: string; + jwks: string; +}; +class OIDCClient extends CustomResource { + public static readonly apiVersion = API_VERSION; + public static readonly kind = 'OidcClient'; + public static readonly spec = specSchema; + public static readonly scope = 'Namespaced'; + + #environment = new ResourceReference(); + #secret: Secret; + + constructor(options: CustomResourceOptions) { + super(options); + const resourceService = this.services.get(ResourceService); + this.#secret = resourceService.get(Secret, `${this.name}-client`, this.namespace); + } + + public get appName() { + return this.name; + } + + public reconcile = async () => { + if (!this.spec) { + throw new NotReadyError('MissingSpec'); + } + const resourceService = this.services.get(ResourceService); + this.#environment.current = resourceService.get(Environment, this.spec.environment); + if (!this.#environment.current.exists) { + throw new NotReadyError('EnvironmentNotFound'); + } + const authentik = this.#environment.current.authentikServer; + const authentikSecret = authentik.secret.value; + if (!authentikSecret) { + throw new Error('MissingAuthentikSecret'); + } + + const url = authentik.url; + + await this.#secret.set((current) => ({ + clientSecret: generateRandomHexPass(), + ...current, + clientId: this.name, + configuration: new URL(`/application/o/${this.appName}/.well-known/openid-configuration`, url).toString(), + configurationIssuer: new URL(`/application/o/${this.appName}/`, url).toString(), + authorization: new URL(`/application/o/${this.appName}/authorize/`, url).toString(), + token: new URL(`/application/o/${this.appName}/token/`, url).toString(), + userinfo: new URL(`/application/o/${this.appName}/userinfo/`, url).toString(), + endSession: new URL(`/application/o/${this.appName}/end-session/`, url).toString(), + jwks: new URL(`/application/o/${this.appName}/jwks/`, url).toString(), + })); + + const secret = this.#secret.value; + if (!secret) { + throw new NotReadyError('MissingSecret'); + } + const authentikService = this.services.get(AuthentikService); + const authentikServer = await authentikService.get({ + url: { + internal: `http://${authentikSecret.host}`, + external: authentikSecret.url, + }, + token: authentikSecret.token, + }); + + await authentikServer.upsertClient({ + ...this.spec, + name: this.name, + secret: secret.clientSecret, + }); + }; +} + +export { OIDCClient }; diff --git a/src/resources/homelab/postgres-cluster/postgres-cluster.ts b/src/resources/homelab/postgres-cluster/postgres-cluster.ts new file mode 100644 index 0000000..83ca2c8 --- /dev/null +++ b/src/resources/homelab/postgres-cluster/postgres-cluster.ts @@ -0,0 +1,172 @@ +import { z } from 'zod'; + +import { Secret } from '#resources/core/secret/secret.ts'; +import { StatefulSet } from '#resources/core/stateful-set/stateful-set.ts'; +import { CustomResource, ResourceService, type CustomResourceOptions } from '#services/resources/resources.ts'; +import { API_VERSION } from '#utils/consts.ts'; +import { Service } from '#resources/core/service/service.ts'; +import { generateRandomHexPass } from '#utils/secrets.ts'; + +const specSchema = z.object({ + storageClass: z.string(), + storage: z + .object({ + size: z.string().optional(), + }) + .optional(), +}); + +type SecretData = { + host: string; + port: string; + user: string; + password: string; + database: string; +}; + +class PostgresCluster extends CustomResource { + public static readonly apiVersion = API_VERSION; + public static readonly kind = 'PostgresCluster'; + public static readonly spec = specSchema; + public static readonly scope = 'Namespaced'; + + #secret: Secret; + #statefulSet: StatefulSet; + #headlessService: Service; + #service: Service; + + constructor(options: CustomResourceOptions) { + super(options); + + const resourceService = this.services.get(ResourceService); + this.#secret = resourceService.get(Secret, this.name, this.namespace); + this.#secret.on('changed', this.queueReconcile); + + this.#statefulSet = resourceService.get(StatefulSet, this.name, this.namespace); + this.#statefulSet.on('changed', this.queueReconcile); + + this.#service = resourceService.get(Service, this.name, this.namespace); + this.#service.on('changed', this.queueReconcile); + + this.#headlessService = resourceService.get(Service, `${this.name}-headless`, this.namespace); + this.#headlessService.on('changed', this.queueReconcile); + } + + public get secret() { + return this.#secret; + } + + public get statefulSet() { + return this.#statefulSet; + } + + public get headlessService() { + return this.#headlessService; + } + + public get service() { + return this.#service; + } + + public reconcile = async () => { + await this.#secret.set( + (current) => ({ + password: generateRandomHexPass(16), + user: 'homelab', + database: 'homelab', + ...current, + host: `${this.#service.name}.${this.#service.namespace}.svc.cluster.local`, + port: '5432', + }), + { + metadata: { + ownerReferences: [this.ref], + }, + }, + ); + + const secretName = this.#secret.name; + + await this.#statefulSet.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: 'postgres:17', + ports: [{ containerPort: 5432, name: 'postgres' }], + env: [ + { name: 'POSTGRES_PASSWORD', valueFrom: { secretKeyRef: { name: secretName, key: 'password' } } }, + { name: 'POSTGRES_USER', valueFrom: { secretKeyRef: { name: secretName, key: 'user' } } }, + { name: 'POSTGRES_DB', valueFrom: { secretKeyRef: { name: secretName, key: 'database' } } }, + { name: 'PGDATA', value: '/var/lib/postgresql/data/pgdata' }, + ], + volumeMounts: [{ name: this.name, mountPath: '/var/lib/postgresql/data' }], + }, + ], + }, + }, + volumeClaimTemplates: [ + { + metadata: { + name: this.name, + ownerReferences: [this.ref], + }, + spec: { + accessModes: ['ReadWriteOnce'], + storageClassName: this.spec?.storageClass, + resources: { + requests: { + storage: this.spec?.storage?.size || '1Gi', + }, + }, + }, + }, + ], + }, + }); + + await this.#headlessService.ensure({ + metadata: { + ownerReferences: [this.ref], + }, + spec: { + clusterIP: 'None', + selector: { + app: this.name, + }, + ports: [{ name: 'postgres', port: 5432, targetPort: 5432 }], + }, + }); + + await this.#service.ensure({ + metadata: { + ownerReferences: [this.ref], + }, + spec: { + type: 'ClusterIP', + selector: { + app: this.name, + }, + ports: [{ name: 'postgres', port: 5432, targetPort: 5432 }], + }, + }); + }; +} + +export { PostgresCluster }; diff --git a/src/resources/homelab/postgres-database/postgres-database.ts b/src/resources/homelab/postgres-database/postgres-database.ts new file mode 100644 index 0000000..7afe6ea --- /dev/null +++ b/src/resources/homelab/postgres-database/postgres-database.ts @@ -0,0 +1,135 @@ +import { z } from 'zod'; + +import { PostgresCluster } from '../postgres-cluster/postgres-cluster.ts'; + +import { + CustomResource, + ResourceReference, + ResourceService, + type CustomResourceOptions, +} from '#services/resources/resources.ts'; +import { Secret } from '#resources/core/secret/secret.ts'; +import { API_VERSION } from '#utils/consts.ts'; +import { getWithNamespace } from '#utils/naming.ts'; +import { PostgresService } from '#services/postgres/postgres.service.ts'; +import { NotReadyError } from '#utils/errors.ts'; +import { generateRandomHexPass } from '#utils/secrets.ts'; + +const specSchema = z.object({ + environment: z.string().optional(), + cluster: z.string().optional(), +}); + +type SecretData = { + password: string; + user: string; + database: string; + host: string; + port: string; +}; + +const sanitizeName = (input: string) => { + return input.replace(/[^a-zA-Z0-9_]+/g, '_').toLowerCase(); +}; + +class PostgresDatabase extends CustomResource { + public static readonly apiVersion = API_VERSION; + public static readonly kind = 'PostgresDatabase'; + public static readonly spec = specSchema; + public static readonly scope = 'Namespaced'; + + #cluster: ResourceReference; + #secret: Secret; + + constructor(options: CustomResourceOptions) { + super(options); + const resourceService = this.services.get(ResourceService); + + this.#cluster = new ResourceReference(); + this.#cluster.on('changed', this.queueReconcile); + + this.#secret = resourceService.get(Secret, `${this.name}-pg-connection`, this.namespace); + this.#secret.on('changed', this.queueReconcile); + } + + public get username() { + return sanitizeName(`${this.namespace}_${this.name}`); + } + + public get database() { + return sanitizeName(`${this.namespace}_${this.name}`); + } + + public get cluster() { + return this.#cluster; + } + + public get secret() { + return this.#secret; + } + + public reconcile = async () => { + const resourceService = this.services.get(ResourceService); + if (this.spec?.cluster) { + const clusterNames = getWithNamespace(this.spec.cluster, this.namespace); + this.#cluster.current = resourceService.get(PostgresCluster, clusterNames.name, clusterNames.namespace); + } else if (this.spec?.environment) { + const { Environment } = await import('../environment/environment.ts'); + const environment = resourceService.get(Environment, this.spec.environment); + this.#cluster.current = environment.postgresCluster; + } else { + this.#cluster.current = undefined; + throw new NotReadyError('MissingEnvOrClusterSpec'); + } + + const clusterSecret = this.#cluster.current.secret.value; + if (!clusterSecret) { + throw new NotReadyError('MissingClusterSecret'); + } + + await this.#secret.set( + (current) => ({ + password: generateRandomHexPass(), + user: this.username, + database: this.database, + ...current, + host: clusterSecret.host, + port: clusterSecret.port, + }), + { + metadata: { + ownerReferences: [this.ref], + }, + }, + ); + + const secret = this.#secret.value; + if (!secret) { + throw new NotReadyError('MissingSecret'); + } + + const postgresService = this.services.get(PostgresService); + const database = postgresService.get({ + host: clusterSecret.host, + port: clusterSecret.port ? Number(clusterSecret.port) : 5432, + database: clusterSecret.database, + user: clusterSecret.user, + password: clusterSecret.password, + }); + const connectionError = await database.ping(); + if (connectionError) { + console.error('Failed to connect', connectionError); + throw new NotReadyError('FailedToConnectToDatabase'); + } + await database.upsertRole({ + name: secret.user, + password: secret.password, + }); + await database.upsertDatabase({ + name: secret.database, + owner: secret.user, + }); + }; +} + +export { PostgresDatabase }; diff --git a/src/resources/homelab/redis-server/redis-server.ts b/src/resources/homelab/redis-server/redis-server.ts new file mode 100644 index 0000000..899a390 --- /dev/null +++ b/src/resources/homelab/redis-server/redis-server.ts @@ -0,0 +1,79 @@ +import { z } from 'zod'; + +import { Deployment } from '#resources/core/deployment/deployment.ts'; +import { Service } from '#resources/core/service/service.ts'; +import { CustomResource, ResourceService, type CustomResourceOptions } from '#services/resources/resources.ts'; +import { API_VERSION } from '#utils/consts.ts'; + +const specSchema = z.object({}); + +class RedisServer extends CustomResource { + public static readonly apiVersion = API_VERSION; + public static readonly kind = 'RedisServer'; + public static readonly spec = specSchema; + public static readonly scope = 'Namespaced'; + + #deployment: Deployment; + #service: Service; + + constructor(options: CustomResourceOptions) { + super(options); + const resourceService = this.services.get(ResourceService); + this.#deployment = resourceService.get(Deployment, this.name, this.namespace); + this.#service = resourceService.get(Service, this.name, this.namespace); + } + + public get deployment() { + return this.#deployment; + } + + public get service() { + return this.#service; + } + + 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 { RedisServer }; diff --git a/src/resources/istio/destination-rule/destination-rule.ts b/src/resources/istio/destination-rule/destination-rule.ts new file mode 100644 index 0000000..cea25e6 --- /dev/null +++ b/src/resources/istio/destination-rule/destination-rule.ts @@ -0,0 +1,37 @@ +import type { KubernetesObject } from '@kubernetes/client-node'; +import type { K8SDestinationRuleV1 } from 'src/__generated__/resources/K8SDestinationRuleV1.ts'; + +import { Resource, ResourceService, type ResourceOptions } from '#services/resources/resources.ts'; +import { CRD } from '#resources/core/crd/crd.ts'; +import { NotReadyError } from '#utils/errors.ts'; + +class DestinationRule extends Resource { + public static readonly apiVersion = 'networking.istio.io/v1'; + public static readonly kind = 'DestinationRule'; + + #crd: CRD; + + constructor(options: ResourceOptions) { + super(options); + const resourceService = this.services.get(ResourceService); + this.#crd = resourceService.get(CRD, 'destinationrules.networking.istio.io'); + this.#crd.on('changed', this.#handleChange); + } + + public get hasCRD() { + return this.#crd.exists; + } + + #handleChange = () => { + this.emit('changed', this.manifest); + }; + + public set = async (manifest: KubernetesObject & K8SDestinationRuleV1) => { + if (!this.hasCRD) { + throw new NotReadyError('CRD is not installed'); + } + await this.ensure(manifest); + }; +} + +export { DestinationRule }; diff --git a/src/resources/istio/gateway/gateway.ts b/src/resources/istio/gateway/gateway.ts new file mode 100644 index 0000000..6ca9503 --- /dev/null +++ b/src/resources/istio/gateway/gateway.ts @@ -0,0 +1,37 @@ +import type { KubernetesObject } from '@kubernetes/client-node'; +import type { K8SGatewayV1 } from 'src/__generated__/resources/K8SGatewayV1.ts'; + +import { Resource, ResourceService, type ResourceOptions } from '#services/resources/resources.ts'; +import { CRD } from '#resources/core/crd/crd.ts'; +import { NotReadyError } from '#utils/errors.ts'; + +class Gateway extends Resource { + public static readonly apiVersion = 'networking.istio.io/v1'; + public static readonly kind = 'Gateway'; + + #crd: CRD; + + constructor(options: ResourceOptions) { + super(options); + const resourceService = this.services.get(ResourceService); + this.#crd = resourceService.get(CRD, 'gateways.networking.istio.io'); + this.#crd.on('changed', this.#handleUpdate); + } + + #handleUpdate = async () => { + this.emit('changed', this.manifest); + }; + + public get hasCRD() { + return this.#crd.exists; + } + + public set = async (manifest: KubernetesObject & K8SGatewayV1) => { + if (!this.hasCRD) { + throw new NotReadyError('CRD is not installed'); + } + await this.ensure(manifest); + }; +} + +export { Gateway }; diff --git a/src/resources/istio/istio.ts b/src/resources/istio/istio.ts new file mode 100644 index 0000000..f873eac --- /dev/null +++ b/src/resources/istio/istio.ts @@ -0,0 +1,11 @@ +import { DestinationRule } from './destination-rule/destination-rule.ts'; +import { Gateway } from './gateway/gateway.ts'; +import { VirtualService } from './virtual-service/virtual-service.ts'; + +const istio = { + gateway: Gateway, + destinationRule: DestinationRule, + virtualService: VirtualService, +}; + +export { istio }; diff --git a/src/resources/istio/virtual-service/virtual-service.ts b/src/resources/istio/virtual-service/virtual-service.ts new file mode 100644 index 0000000..418e579 --- /dev/null +++ b/src/resources/istio/virtual-service/virtual-service.ts @@ -0,0 +1,37 @@ +import type { KubernetesObject } from '@kubernetes/client-node'; +import type { K8SVirtualServiceV1 } from 'src/__generated__/resources/K8SVirtualServiceV1.ts'; + +import { Resource, ResourceService, type ResourceOptions } from '#services/resources/resources.ts'; +import { CRD } from '#resources/core/crd/crd.ts'; +import { NotReadyError } from '#utils/errors.ts'; + +class VirtualService extends Resource { + public static readonly apiVersion = 'networking.istio.io/v1'; + public static readonly kind = 'VirtualService'; + + #crd: CRD; + + constructor(options: ResourceOptions) { + super(options); + const resourceService = this.services.get(ResourceService); + this.#crd = resourceService.get(CRD, 'virtualservices.networking.istio.io'); + this.#crd.on('changed', this.#handleChange); + } + + public get hasCRD() { + return this.#crd.exists; + } + + #handleChange = () => { + this.emit('changed', this.manifest); + }; + + public set = async (manifest: KubernetesObject & K8SVirtualServiceV1) => { + if (!this.hasCRD) { + throw new NotReadyError('CRD is not installed'); + } + await this.ensure(manifest); + }; +} + +export { VirtualService }; diff --git a/src/resources/resources.ts b/src/resources/resources.ts new file mode 100644 index 0000000..c2b9fc9 --- /dev/null +++ b/src/resources/resources.ts @@ -0,0 +1,17 @@ +import { core } from './core/core.ts'; +import { flux } from './flux/flux.ts'; +import { homelab } from './homelab/homelab.ts'; +import { certManager } from './cert-manager/cert-manager.ts'; +import { istio } from './istio/istio.ts'; + +import type { ResourceClass } from '#services/resources/resources.ts'; + +const resources = { + ...core, + ...flux, + ...certManager, + ...istio, + ...homelab, +} satisfies Record>; + +export { resources }; diff --git a/src/services/cloudflare/cloudflare.ts b/src/services/cloudflare/cloudflare.ts new file mode 100644 index 0000000..cc1e97b --- /dev/null +++ b/src/services/cloudflare/cloudflare.ts @@ -0,0 +1,57 @@ +import { Cloudflare } from 'cloudflare'; +import { EventEmitter } from 'eventemitter3'; + +import { NamespaceService } from '#bootstrap/namespaces/namespaces.ts'; +import { Secret } from '#resources/core/secret/secret.ts'; +import { ResourceService } from '#services/resources/resources.ts'; +import type { Services } from '#utils/service.ts'; + +type SecretData = { + account: string; + tunnelName: string; + tunnelId: string; + secret: string; + token: string; +}; + +type CloudflareServiceEvents = { + changed: () => void; +}; + +class CloudflareService extends EventEmitter { + #services: Services; + #secret: Secret; + + constructor(services: Services) { + super(); + this.#services = services; + const resourceService = this.#services.get(ResourceService); + const namespaceService = this.#services.get(NamespaceService); + this.#secret = resourceService.get(Secret, 'cloudflare', namespaceService.homelab.name); + + this.#secret.on('changed', this.emit.bind(this, 'changed')); + } + + public get secret() { + return this.#secret.value; + } + + public get ready() { + return !!this.secret; + } + + public get client() { + const token = this.#secret.value?.token; + if (!token) { + throw new Error('Cloudflare API token is not set'); + } + + const client = new Cloudflare({ + apiToken: token, + }); + + return client; + } +} + +export { CloudflareService }; diff --git a/src/services/custom-resources/custom-resources.conditions.ts b/src/services/custom-resources/custom-resources.conditions.ts deleted file mode 100644 index c176c65..0000000 --- a/src/services/custom-resources/custom-resources.conditions.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { EventEmitter } from 'eventemitter3'; -import equal from 'deep-equal'; - -import type { CustomResource } from './custom-resources.custom-resource.ts'; -import type { CustomResourceStatus } from './custom-resources.types.ts'; - -type CustomResourceStatusOptions = { - resource: CustomResource; -}; - -type CustomResourceConditionsEvents = { - changed: (type: string, condition: Condition) => void; -}; - -type Condition = { - lastTransitionTime: Date; - status: 'True' | 'False' | 'Unknown'; - syncing?: boolean; - failed?: boolean; - resource?: boolean; - reason?: string; - message?: string; - observedGeneration?: number; -}; - -class CustomResourceConditions extends EventEmitter { - #options: CustomResourceStatusOptions; - #conditions: Record; - #changed: boolean; - - constructor(options: CustomResourceStatusOptions) { - super(); - this.#options = options; - this.#conditions = Object.fromEntries( - (options.resource.status?.conditions || []).map(({ type, lastTransitionTime, ...condition }) => [ - type, - { - ...condition, - lastTransitionTime: new Date(lastTransitionTime), - }, - ]), - ); - options.resource.on('changed', this.#handleChange); - this.#changed = false; - } - - #handleChange = () => { - const { resource } = this.#options; - for (const { type, ...condition } of resource.status?.conditions || []) { - const next = { - ...condition, - lastTransitionTime: new Date(condition.lastTransitionTime), - }; - const current = this.#conditions[type]; - const isEqual = equal(current, next); - const isNewer = !current || next.lastTransitionTime > current.lastTransitionTime; - if (isEqual || !isNewer) { - return; - } - this.#conditions[type] = next; - this.emit('changed', type, next); - } - }; - - public get = (type: string): Condition | undefined => { - return this.#conditions[type]; - }; - - public set = async (type: string, condition: Omit) => { - const current = this.#conditions[type]; - const isEqual = equal( - { ...current, lastTransitionTime: undefined }, - { ...condition, lastTransitionTime: undefined }, - ); - if (isEqual) { - return; - } - this.#changed = true; - this.#conditions[type] = { - ...condition, - lastTransitionTime: current && current.status === condition.status ? current.lastTransitionTime : new Date(), - observedGeneration: this.#options.resource.metadata?.generation, - }; - await this.save(); - }; - - public save = async () => { - if (!this.#changed) { - return; - } - try { - this.#changed = false; - const { resource } = this.#options; - const status: CustomResourceStatus = { - conditions: Object.entries(this.#conditions).map(([type, condition]) => ({ - ...condition, - type, - lastTransitionTime: condition.lastTransitionTime.toISOString(), - })), - }; - await resource.patchStatus(status); - } catch (error) { - this.#changed = true; - throw error; - } - }; -} - -export { CustomResourceConditions }; diff --git a/src/services/custom-resources/custom-resources.custom-resource.ts b/src/services/custom-resources/custom-resources.custom-resource.ts deleted file mode 100644 index d725877..0000000 --- a/src/services/custom-resources/custom-resources.custom-resource.ts +++ /dev/null @@ -1,222 +0,0 @@ -import type { z, ZodObject } from 'zod'; -import { ApiException, PatchStrategy, setHeaderOptions, type KubernetesObject } from '@kubernetes/client-node'; -import { EventEmitter } from 'eventemitter3'; - -import type { Resource } from '../resources/resources.resource.ts'; -import type { Services } from '../../utils/service.ts'; -import { K8sService } from '../k8s/k8s.ts'; -import { CoalescingQueued } from '../../utils/queues.ts'; - -import type { CustomResourceDefinition, CustomResourceStatus } from './custom-resources.types.ts'; -import { CustomResourceConditions } from './custom-resources.conditions.ts'; - -type CustomResourceObject = KubernetesObject & { - spec: z.infer; - status?: CustomResourceStatus; -}; - -type CustomResourceOptions = { - resource: Resource>; - services: Services; - definition: CustomResourceDefinition; -}; - -type CustomResourceEvents = { - changed: () => void; - changedStatus: (options: { previous: CustomResourceStatus; next: CustomResourceStatus }) => void; - changedMetadate: (options: { previous: KubernetesObject['metadata']; next: KubernetesObject['metadata'] }) => void; - changedSpec: (options: { previous: z.infer; next: z.infer }) => void; -}; - -type SubresourceResult = { - ready: boolean; - syncing?: boolean; - failed?: boolean; - reason?: string; - message?: string; -}; - -abstract class CustomResource extends EventEmitter> { - #options: CustomResourceOptions; - #conditions: CustomResourceConditions; - #queue: CoalescingQueued; - - constructor(options: CustomResourceOptions) { - super(); - this.#options = options; - this.#conditions = new CustomResourceConditions({ - resource: this, - }); - options.resource.on('changed', this.#handleChanged); - this.#queue = new CoalescingQueued({ - action: async () => { - if (this.exists && !this.isValidSpec) { - this.services.log.error( - `Invalid spec for ${this.apiVersion}/${this.kind}/${this.namespace}/${this.name}`, - this.spec, - ); - return; - } - console.log('Reconcileing', this.apiVersion, this.kind, this.namespace, this.name); - await this.reconcile?.(); - }, - }); - } - - public get conditions() { - return this.#conditions; - } - - public get names() { - return this.#options.definition.names; - } - - public get services() { - const { services } = this.#options; - return services; - } - - public get resource() { - const { resource } = this.#options; - return resource; - } - - public get apiVersion() { - return this.resource.apiVersion; - } - - public get kind() { - return this.resource.kind; - } - - public get metadata(): KubernetesObject['metadata'] { - const metadata = this.resource.metadata; - return ( - metadata || { - name: this.name, - namespace: this.namespace, - } - ); - } - - public get name() { - return this.resource.specifier.name; - } - - public get namespace() { - const namespace = this.resource.specifier.namespace; - if (!namespace) { - throw new Error('Custom resources needs a namespace'); - } - return namespace; - } - - public get exists() { - return this.resource.exists; - } - - public get ref() { - return this.resource.ref; - } - - public get spec(): z.infer { - return this.resource.spec as ExpectedAny; - } - - public get status() { - return this.resource.manifest?.status; - } - - public get isSeen() { - return this.metadata?.generation === this.status?.observedGeneration; - } - - public get isValidSpec() { - const { success } = this.#options.definition.spec.safeParse(this.spec); - return success; - } - - public setup?: () => Promise; - public reconcile?: () => Promise; - - public markSeen = async () => { - if (this.isSeen) { - return; - } - await this.patchStatus({ - observedGeneration: this.metadata?.generation, - }); - }; - - public queueReconcile = async () => { - return this.#queue.run(); - }; - - #handleChanged = () => { - this.emit('changed'); - }; - - public reconcileSubresource = async (name: string, action: () => Promise) => { - try { - const result = await action(); - await this.conditions.set(name, { - status: result.ready ? 'True' : 'False', - syncing: result.syncing, - failed: result.failed ?? false, - resource: true, - reason: result.reason, - message: result.message, - }); - } catch (err) { - console.error(err); - await this.conditions.set(name, { - status: 'False', - failed: true, - reason: 'Failed', - resource: true, - message: err instanceof Error ? err.message : String(err), - }); - } - }; - - public markNotReady = async (reason?: string, message?: string) => { - await this.conditions.set('Ready', { - status: 'False', - reason, - message, - }); - }; - - public markReady = async () => { - await this.conditions.set('Ready', { - status: 'True', - }); - }; - - public patchStatus = async (status: Partial) => { - const k8s = this.services.get(K8sService); - const [group, version] = this.apiVersion?.split('/') || []; - try { - await k8s.customObjectsApi.patchNamespacedCustomObjectStatus( - { - group, - version, - plural: this.names.plural, - name: this.name, - namespace: this.namespace, - body: { - status, - }, - }, - setHeaderOptions('Content-Type', PatchStrategy.MergePatch), - ); - } catch (err) { - if (err instanceof ApiException && err.code === 404) { - return; - } - throw err; - } - }; -} - -export { CustomResource, type CustomResourceOptions, type CustomResourceObject, type SubresourceResult }; diff --git a/src/services/custom-resources/custom-resources.ts b/src/services/custom-resources/custom-resources.ts deleted file mode 100644 index 38d6823..0000000 --- a/src/services/custom-resources/custom-resources.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { ApiException, type KubernetesObject } from '@kubernetes/client-node'; -import type { ZodObject } from 'zod'; - -import type { Services } from '../../utils/service.ts'; -import type { Resource } from '../resources/resources.resource.ts'; -import { WatcherService } from '../watchers/watchers.ts'; -import { K8sService } from '../k8s/k8s.ts'; -import { Queue } from '../queue/queue.ts'; - -import type { CustomResourceDefinition } from './custom-resources.types.ts'; -import type { CustomResource } from './custom-resources.custom-resource.ts'; -import { createManifest } from './custom-resources.utils.ts'; - -type DefinitionItem = { - definition: CustomResourceDefinition; - queue: Queue; -}; - -class CustomResourceService { - #services: Services; - #definitions: DefinitionItem[]; - #resources: Map>; - - constructor(services: Services) { - this.#definitions = []; - this.#resources = new Map(); - this.#services = services; - } - - #handleChanged = async (resource: Resource) => { - const uid = resource.metadata?.uid; - if (!uid) { - return; - } - let current = this.#resources.get(uid); - if (!current) { - const entry = this.#definitions.find( - ({ definition: r }) => - r.version === resource.version && - r.group === resource.group && - r.version === resource.version && - r.kind === resource.kind, - ); - if (!entry) { - return; - } - const { definition } = entry; - current = definition.create({ - resource: resource as Resource, - services: this.#services, - definition, - }); - this.#resources.set(uid, current); - await current.setup?.(); - if (!current.isSeen) { - await current.markSeen(); - } - await current.queueReconcile(); - } else if (!current.isSeen) { - await current.markSeen(); - await current.queueReconcile(); - } - }; - - public register = (...resources: CustomResourceDefinition[]) => { - this.#definitions.push( - ...resources.map((definition) => ({ - definition, - queue: new Queue(), - })), - ); - }; - - public install = async (replace = false) => { - const k8sService = this.#services.get(K8sService); - for (const { definition: crd } of this.#definitions) { - this.#services.log.info('Installing CRD', { kind: crd.kind }); - try { - const manifest = createManifest(crd); - try { - await k8sService.extensionsApi.createCustomResourceDefinition({ - body: manifest, - }); - } catch (error) { - if (error instanceof ApiException && error.code === 409) { - if (replace) { - await k8sService.extensionsApi.patchCustomResourceDefinition({ - name: manifest.metadata.name, - body: [{ op: 'replace', path: '/spec', value: manifest.spec }], - }); - } - continue; - } - throw error; - } - } catch (error) { - if (error instanceof ApiException) { - throw new Error(`Failed to install ${crd.kind}: ${error.body}`); - } - throw error; - } - } - }; - - public watch = async () => { - const watcherService = this.#services.get(WatcherService); - for (const { definition, queue } of this.#definitions) { - const watcher = watcherService.create({ - path: `/apis/${definition.group}/${definition.version}/${definition.names.plural}`, - list: (k8s) => - k8s.customObjectsApi.listCustomObjectForAllNamespaces({ - version: definition.version, - group: definition.group, - plural: definition.names.plural, - }), - verbs: ['add', 'update', 'delete'], - }); - watcher.on('changed', (resource) => { - queue.add(() => this.#handleChanged(resource)); - }); - await watcher.start(); - } - }; -} - -const createCustomResourceDefinition = (options: CustomResourceDefinition) => options; - -export { CustomResourceService, createCustomResourceDefinition }; diff --git a/src/services/custom-resources/custom-resources.types.ts b/src/services/custom-resources/custom-resources.types.ts deleted file mode 100644 index b17fbb3..0000000 --- a/src/services/custom-resources/custom-resources.types.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { z, type ZodObject } from 'zod'; - -import type { CustomResource, CustomResourceOptions } from './custom-resources.custom-resource.ts'; - -type CustomResourceDefinition = { - group: string; - version: string; - kind: string; - names: { - plural: string; - singular: string; - }; - spec: TSpec; - create: (options: CustomResourceOptions) => CustomResource; -}; - -const customResourceStatusSchema = z.object({ - observedGeneration: z.number().optional(), - conditions: z - .array( - z.object({ - observedGeneration: z.number().optional(), - type: z.string(), - status: z.enum(['True', 'False', 'Unknown']), - lastTransitionTime: z.string().datetime(), - resource: z.boolean().optional(), - failed: z.boolean().optional(), - syncing: z.boolean().optional(), - reason: z.string().optional().optional(), - message: z.string().optional().optional(), - }), - ) - .optional(), -}); - -type CustomResourceStatus = z.infer; - -export { customResourceStatusSchema, type CustomResourceDefinition, type CustomResourceStatus }; diff --git a/src/services/postgres/postgres.instance.ts b/src/services/postgres/postgres.instance.ts index bb198f7..ad6855c 100644 --- a/src/services/postgres/postgres.instance.ts +++ b/src/services/postgres/postgres.instance.ts @@ -8,7 +8,7 @@ type PostgresInstanceOptions = { services: Services; host: string; port?: number; - username: string; + user: string; password: string; database?: string; }; @@ -20,10 +20,10 @@ class PostgresInstance { this.#db = knex({ client: 'pg', connection: { - host: process.env.FORCE_PG_HOST ?? options.host, - user: process.env.FORCE_PG_USER ?? options.username, - password: process.env.FORCE_PG_PASSWORD ?? options.password, - port: process.env.FORCE_PG_PORT ? parseInt(process.env.FORCE_PG_PORT) : options.port, + host: options.host, + user: options.user, + password: options.password, + port: options.port, database: options.database, }, }); @@ -32,29 +32,32 @@ class PostgresInstance { public ping = async () => { try { await this.#db.raw('SELECT 1'); - return true; - } catch { - return false; + return; + } catch (err) { + return err; } }; public upsertRole = async (role: PostgresRole) => { - const existingRole = await this.#db.raw('SELECT 1 FROM pg_roles WHERE rolname = ?', [role.name]); + const name = role.name; + const existingRole = await this.#db.raw('SELECT 1 FROM pg_roles WHERE rolname = ?', [name]); if (existingRole.rows.length === 0) { - await this.#db.raw(`CREATE ROLE "${role.name}" WITH LOGIN PASSWORD '${role.password}'`); + await this.#db.raw(`CREATE ROLE "${name}" WITH LOGIN PASSWORD '${role.password}'`); } else { - await this.#db.raw(`ALTER ROLE "${role.name}" WITH PASSWORD '${role.password}'`); + await this.#db.raw(`ALTER ROLE "${name}" WITH PASSWORD '${role.password}'`); } }; public upsertDatabase = async (database: PostgresDatabase) => { - const existingDatabase = await this.#db.raw('SELECT * FROM pg_database WHERE datname = ?', [database.name]); + const owner = database.owner; + const name = database.name; + const existingDatabase = await this.#db.raw('SELECT * FROM pg_database WHERE datname = ?', [name]); if (existingDatabase.rows.length === 0) { - await this.#db.raw(`CREATE DATABASE "${database.name}" OWNER "${database.owner}"`); + await this.#db.raw(`CREATE DATABASE "${name}" OWNER "${owner}"`); } else { - await this.#db.raw(`ALTER DATABASE "${database.name}" OWNER TO "${database.owner}"`); + await this.#db.raw(`ALTER DATABASE "${name}" OWNER TO "${owner}"`); } }; } diff --git a/src/services/resources/resource/resource.custom.ts b/src/services/resources/resource/resource.custom.ts new file mode 100644 index 0000000..e27ac0a --- /dev/null +++ b/src/services/resources/resource/resource.custom.ts @@ -0,0 +1,186 @@ +import { z, type ZodType } from 'zod'; +import { PatchStrategy, setHeaderOptions, type KubernetesObject } from '@kubernetes/client-node'; + +import { Resource, type ResourceOptions } from './resource.ts'; + +import { API_VERSION } from '#utils/consts.ts'; +import { CoalescingQueued } from '#utils/queues.ts'; +import { NotReadyError } from '#utils/errors.ts'; +import { K8sService } from '#services/k8s/k8s.ts'; +import { CronJob, CronTime } from 'cron'; + +const customResourceStatusSchema = z.object({ + observedGeneration: z.number().optional(), + conditions: z + .array( + z.object({ + observedGeneration: z.number().optional(), + type: z.string(), + status: z.enum(['True', 'False', 'Unknown']), + lastTransitionTime: z.string().datetime().optional(), + resource: z.boolean().optional(), + failed: z.boolean().optional(), + syncing: z.boolean().optional(), + reason: z.string().optional().optional(), + message: z.string().optional().optional(), + }), + ) + .optional(), +}); + +type CustomResourceOptions = ResourceOptions }>; + +class CustomResource extends Resource< + KubernetesObject & { spec: z.infer; status?: z.infer } +> { + public static readonly apiVersion = API_VERSION; + public static readonly status = customResourceStatusSchema; + + #reconcileQueue: CoalescingQueued; + #cron: CronJob; + + constructor(options: CustomResourceOptions) { + super(options); + this.#reconcileQueue = new CoalescingQueued({ + action: async () => { + try { + if (!this.exists || this.manifest?.metadata?.deletionTimestamp) { + return; + } + this.services.log.debug('Reconciling', { + apiVersion: this.apiVersion, + kind: this.kind, + namespace: this.namespace, + name: this.name, + }); + await this.markSeen(); + await this.reconcile?.(); + await this.markReady(); + } catch (err) { + if (err instanceof NotReadyError) { + await this.markNotReady(err.reason, err.message); + } else if (err instanceof Error) { + await this.markNotReady('Failed', err.message); + } else { + await this.markNotReady('Failed', String(err)); + } + console.error(err); + } + }, + }); + this.#cron = CronJob.from({ + cronTime: '*/2 * * * *', + onTick: this.queueReconcile, + start: true, + runOnInit: true, + }); + this.on('changed', this.#handleUpdate); + } + + public get reconcileTime() { + return this.#cron.cronTime.toString(); + } + + public set reconcileTime(pattern: string) { + this.#cron.cronTime = new CronTime(pattern); + } + + public get isSeen() { + return this.metadata?.generation === this.status?.observedGeneration; + } + + public get version() { + const [, version] = this.apiVersion.split('/'); + return version; + } + + public get group() { + const [group] = this.apiVersion.split('/'); + return group; + } + + public get scope() { + if (!('scope' in this.constructor) || typeof this.constructor.scope !== 'string') { + return; + } + return this.constructor.scope as 'Namespaced' | 'Cluster'; + } + + #handleUpdate = async ( + previous?: KubernetesObject & { spec: z.infer; status?: z.infer }, + ) => { + if (this.isSeen && previous) { + return; + } + return await this.queueReconcile(); + }; + + public reconcile?: () => Promise; + public queueReconcile = () => { + return this.#reconcileQueue.run(); + }; + + public markSeen = async () => { + if (this.isSeen) { + return; + } + await this.patchStatus({ + observedGeneration: this.metadata?.generation, + }); + }; + + public markNotReady = async (reason?: string, message?: string) => { + await this.patchStatus({ + conditions: [ + { + type: 'Ready', + status: 'False', + reason, + message, + }, + ], + }); + }; + + public markReady = async () => { + await this.patchStatus({ + conditions: [ + { + type: 'Ready', + status: 'True', + }, + ], + }); + }; + + public patchStatus = (status: Partial>) => + this.queue.add(async () => { + const k8sService = this.services.get(K8sService); + if (this.scope === 'Cluster') { + await k8sService.customObjectsApi.patchClusterCustomObjectStatus( + { + version: this.version, + group: this.group, + plural: this.plural, + name: this.name, + body: { status }, + }, + setHeaderOptions('Content-Type', PatchStrategy.MergePatch), + ); + } else { + await k8sService.customObjectsApi.patchNamespacedCustomObjectStatus( + { + version: this.version, + group: this.group, + plural: this.plural, + name: this.name, + namespace: this.namespace || 'default', + body: { status }, + }, + setHeaderOptions('Content-Type', PatchStrategy.MergePatch), + ); + } + }); +} + +export { CustomResource, type CustomResourceOptions }; diff --git a/src/services/resources/resource/resource.reference.ts b/src/services/resources/resource/resource.reference.ts new file mode 100644 index 0000000..ac85d29 --- /dev/null +++ b/src/services/resources/resource/resource.reference.ts @@ -0,0 +1,38 @@ +import { EventEmitter } from 'eventemitter3'; + +import type { ResourceClass } from '../resources.ts'; + +import type { ResourceEvents } from './resource.ts'; + +class ResourceReference> extends EventEmitter { + #current?: InstanceType; + + constructor(current?: InstanceType) { + super(); + this.#current = current; + } + + public get current() { + return this.#current; + } + + public set current(value: InstanceType | undefined) { + const previous = this.#current; + if (this.#current) { + this.#current.off('changed', this.#handleChange); + } + if (value) { + value.on('changed', this.#handleChange); + } + this.#current = value; + if (previous !== value) { + this.emit('changed'); + } + } + + #handleChange = () => { + this.emit('changed'); + }; +} + +export { ResourceReference }; diff --git a/src/services/resources/resource/resource.ts b/src/services/resources/resource/resource.ts new file mode 100644 index 0000000..6ad68a4 --- /dev/null +++ b/src/services/resources/resource/resource.ts @@ -0,0 +1,187 @@ +import { ApiException, PatchStrategy, type KubernetesObject } from '@kubernetes/client-node'; +import { EventEmitter } from 'eventemitter3'; +import deepEqual from 'deep-equal'; + +import type { Services } from '../../../utils/service.ts'; +import { Queue } from '../../queue/queue.ts'; +import { K8sService } from '../../k8s/k8s.ts'; +import { isDeepSubset } from '../../../utils/objects.ts'; + +type ResourceSelector = { + apiVersion: string; + kind: string; + name: string; + namespace?: string; +}; + +type ResourceOptions = { + services: Services; + selector: ResourceSelector; + manifest?: T; +}; + +type ResourceEvents = { + changed: (from?: T) => void; +}; + +class Resource extends EventEmitter> { + #manifest?: T; + #queue: Queue; + #options: ResourceOptions; + + constructor(options: ResourceOptions) { + super(); + this.#options = options; + this.#manifest = options.manifest; + this.#queue = new Queue({ concurrency: 1 }); + } + + protected get queue() { + return this.#queue; + } + + public get services() { + return this.#options.services; + } + + public get manifest() { + return this.#manifest; + } + + public set manifest(value: T | undefined) { + if (deepEqual(this.manifest, value)) { + return; + } + const previous = this.#manifest; + this.#manifest = value; + this.emit('changed', previous); + } + + public get plural() { + if ('plural' in this.constructor && typeof this.constructor.plural === 'string') { + return this.constructor.plural; + } + if ('kind' in this.constructor && typeof this.constructor.kind === 'string') { + return this.constructor.kind.toLowerCase() + 's'; + } + throw new Error('Unknown kind'); + } + + public get exists() { + return !!this.#manifest; + } + + public get ready() { + return this.exists; + } + + public get selector() { + return this.#options.selector; + } + + public get apiVersion() { + return this.selector.apiVersion; + } + + public get kind() { + return this.selector.kind; + } + + public get name() { + return this.selector.name; + } + + public get namespace() { + return this.selector.namespace; + } + + public get metadata() { + return this.manifest?.metadata; + } + + public get ref() { + if (!this.metadata?.uid) { + throw new Error('No uid for resource'); + } + return { + apiVersion: this.apiVersion, + kind: this.kind, + name: this.name, + uid: this.metadata.uid, + }; + } + + public get spec(): (T extends { spec?: infer K } ? K : never) | undefined { + const manifest = this.manifest; + if (!manifest || !('spec' in manifest)) { + return; + } + return manifest.spec as ExpectedAny; + } + + public get data(): (T extends { data?: infer K } ? K : never) | undefined { + const manifest = this.manifest; + if (!manifest || !('data' in manifest)) { + return; + } + return manifest.data as ExpectedAny; + } + + public get status(): (T extends { status?: infer K } ? K : never) | undefined { + const manifest = this.manifest; + if (!manifest || !('status' in manifest)) { + return; + } + return manifest.status as ExpectedAny; + } + + public patch = (patch: T) => + this.#queue.add(async () => { + const { services } = this.#options; + services.log.debug(`Patching ${this.apiVersion}/${this.kind}/${this.namespace}/${this.name}`); + const k8s = services.get(K8sService); + const body = { + ...patch, + apiVersion: this.selector.apiVersion, + kind: this.selector.kind, + metadata: { + ...patch.metadata, + name: this.selector.name, + namespace: this.selector.namespace, + }, + }; + try { + this.manifest = await k8s.objectsApi.patch( + body, + undefined, + undefined, + undefined, + undefined, + PatchStrategy.MergePatch, + ); + } catch (err) { + if (err instanceof ApiException && err.code === 404) { + this.manifest = await k8s.objectsApi.create(body); + return; + } + throw err; + } + }); + + public getCondition = ( + condition: string, + ): T extends { status?: { conditions?: (infer U)[] } } ? U | undefined : undefined => { + const status = this.status as ExpectedAny; + return status?.conditions?.find((c: ExpectedAny) => c?.type === condition); + }; + + public ensure = async (manifest: T) => { + if (isDeepSubset(this.manifest, manifest)) { + return false; + } + await this.patch(manifest); + return true; + }; +} + +export { Resource, type ResourceOptions, type ResourceEvents }; diff --git a/src/services/resources/resources.instance.ts b/src/services/resources/resources.instance.ts deleted file mode 100644 index 7ce791d..0000000 --- a/src/services/resources/resources.instance.ts +++ /dev/null @@ -1,83 +0,0 @@ -import type { KubernetesObject } from '@kubernetes/client-node'; - -import { isDeepSubset } from '../../utils/objects.ts'; - -import { ResourceReference } from './resources.ref.ts'; - -abstract class ResourceInstance extends ResourceReference { - public get resource() { - if (!this.current) { - throw new Error('Instance needs a resource'); - } - return this.current; - } - - public get services() { - return this.resource.services; - } - - public get exists() { - return this.resource.exists; - } - - public get manifest() { - return this.resource.manifest; - } - - public get apiVersion() { - return this.resource.apiVersion; - } - - public get kind() { - return this.resource.kind; - } - - public get name() { - return this.resource.name; - } - - public get namespace() { - return this.resource.namespace; - } - - public get metadata() { - return this.resource.metadata; - } - - public get spec() { - return this.resource.spec; - } - - public get data() { - return this.resource.data; - } - - public get status() { - return this.resource.status; - } - - public patch = this.resource.patch; - public reload = this.resource.load; - public delete = this.resource.delete; - - public ensure = async (manifest: T) => { - if (isDeepSubset(this.manifest, manifest)) { - return false; - } - await this.patch(manifest); - return true; - }; - - public get ready() { - return this.exists; - } - - public getCondition = ( - condition: string, - ): T extends { status?: { conditions?: (infer U)[] } } ? U | undefined : undefined => { - const status = this.status as ExpectedAny; - return status?.conditions?.find((c: ExpectedAny) => c?.type === condition); - }; -} - -export { ResourceInstance }; diff --git a/src/services/resources/resources.ref.ts b/src/services/resources/resources.ref.ts deleted file mode 100644 index 52f5a01..0000000 --- a/src/services/resources/resources.ref.ts +++ /dev/null @@ -1,85 +0,0 @@ -import type { KubernetesObject } from '@kubernetes/client-node'; -import { EventEmitter } from 'eventemitter3'; - -import type { Resource } from './resources.ts'; -import type { ResourceEvents } from './resources.resource.ts'; - -type ResourceReferenceEvents = ResourceEvents & { - replaced: (options: { previous: Resource | undefined; next: Resource | undefined }) => void; -}; - -class ResourceReference extends EventEmitter< - ResourceReferenceEvents -> { - #current?: Resource; - #updatedEvent: ResourceEvents['updated']; - #changedEvent: ResourceEvents['changed']; - #changedMetadateEvent: ResourceEvents['changedMetadate']; - #changedSpecEvent: ResourceEvents['changedSpec']; - #changedStatusEvent: ResourceEvents['changedStatus']; - #deletedEvent: ResourceEvents['deleted']; - - constructor(current?: Resource) { - super(); - this.#updatedEvent = this.emit.bind(this, 'updated'); - this.#changedEvent = this.emit.bind(this, 'changed'); - this.#changedMetadateEvent = this.emit.bind(this, 'changedMetadate'); - this.#changedSpecEvent = this.emit.bind(this, 'changedSpec'); - this.#changedStatusEvent = this.emit.bind(this, 'changedStatus'); - this.#deletedEvent = this.emit.bind(this, 'deleted'); - this.current = current; - } - - public get services() { - return this.#current?.services; - } - - public get current() { - return this.#current; - } - - public set current(next: Resource | undefined) { - const previous = this.#current; - if (next === previous) { - return; - } - if (this.#current) { - this.#current.off('updated', this.#updatedEvent); - this.#current.off('changed', this.#changedEvent); - this.#current.off('changedMetadate', this.#changedMetadateEvent); - this.#current.off('changedSpec', this.#changedSpecEvent); - this.#current.off('changedStatus', this.#changedStatusEvent); - this.#current.off('deleted', this.#deletedEvent); - } - - if (next) { - next.on('updated', this.#updatedEvent); - next.on('changed', this.#changedEvent); - next.on('changedMetadate', this.#changedMetadateEvent); - next.on('changedSpec', this.#changedSpecEvent); - next.on('changedStatus', this.#changedStatusEvent); - next.on('deleted', this.#deletedEvent); - } - this.#current = next; - this.emit('replaced', { - previous, - next, - }); - this.emit('changedStatus', { - previous: previous && 'status' in previous ? (previous.status as ExpectedAny) : undefined, - next: next && 'status' in next ? (next.status as ExpectedAny) : undefined, - }); - this.emit('changedMetadate', { - previous: previous && 'metadata' in previous ? (previous.metadata as ExpectedAny) : undefined, - next: next && 'metadata' in next ? (next.metadata as ExpectedAny) : undefined, - }); - this.emit('changedSpec', { - previous: previous && 'spec' in previous ? (previous.spec as ExpectedAny) : undefined, - next: next && 'spec' in next ? (next.spec as ExpectedAny) : undefined, - }); - this.emit('changed'); - this.emit('updated'); - } -} - -export { ResourceReference }; diff --git a/src/services/resources/resources.resource.ts b/src/services/resources/resources.resource.ts deleted file mode 100644 index b582d8d..0000000 --- a/src/services/resources/resources.resource.ts +++ /dev/null @@ -1,309 +0,0 @@ -import { ApiException, PatchStrategy, V1MicroTime, type KubernetesObject } from '@kubernetes/client-node'; -import { EventEmitter } from 'eventemitter3'; -import equal from 'deep-equal'; - -import { Services } from '../../utils/service.ts'; -import { K8sService } from '../k8s/k8s.ts'; -import { Queue } from '../queue/queue.ts'; -import { GROUP } from '../../utils/consts.ts'; - -import { ResourceService } from './resources.ts'; - -type ResourceOptions = { - services: Services; - manifest?: T; - data: { - apiVersion: string; - kind: string; - name: string; - namespace?: string; - }; -}; - -type UnknownResource = KubernetesObject & { - spec: ExpectedAny; - data: ExpectedAny; -}; - -type EventOptions = { - reason: string; - message: string; - action: string; - type: 'Normal' | 'Warning' | 'Error'; -}; - -type ResourceEvents = { - updated: () => void; - deleted: () => void; - changed: () => void; - changedStatus: (options: { - previous: T extends { status: infer K } ? K | undefined : never; - next: T extends { status: infer K } ? K | undefined : never; - }) => void; - changedMetadate: (options: { previous: T['metadata'] | undefined; next: T['metadata'] | undefined }) => void; - changedSpec: (options: { - previous: T extends { spec: infer K } ? K | undefined : never; - next: T extends { spec: infer K } ? K | undefined : never; - }) => void; -}; - -class Resource extends EventEmitter> { - #options: ResourceOptions; - #queue: Queue; - - constructor(options: ResourceOptions) { - super(); - this.#options = options; - this.#queue = new Queue({ concurrency: 1 }); - } - - public get services() { - return this.#options.services; - } - - public get specifier() { - return this.#options.data; - } - - public get manifest() { - return this.#options?.manifest; - } - - public set manifest(obj: T | undefined) { - if (equal(obj, this.manifest)) { - return; - } - this.#options.manifest = obj; - const nextManifest = obj || {}; - const currentManifest = this.manifest || {}; - const nextStatus = 'status' in nextManifest ? nextManifest.status : undefined; - const currentStatus = 'status' in currentManifest ? currentManifest.status : undefined; - if (!equal(nextStatus, currentStatus)) { - this.emit('changedStatus', { - previous: currentStatus as ExpectedAny, - next: nextStatus as ExpectedAny, - }); - } - - const nextSpec = 'spec' in nextManifest ? nextManifest.spec : undefined; - const currentSpec = 'spec' in currentManifest ? currentManifest.spec : undefined; - if (!equal(nextSpec, currentSpec)) { - this.emit('changedSpec', { - next: nextSpec as ExpectedAny, - previous: currentSpec as ExpectedAny, - }); - } - - const nextMetadata = 'metadata' in nextManifest ? nextManifest.metadata : undefined; - const currentMetadata = 'metadata' in currentManifest ? currentManifest.metadata : undefined; - if (!equal(nextMetadata, currentMetadata)) { - this.emit('changedMetadate', { - next: nextMetadata as ExpectedAny, - previous: currentMetadata as ExpectedAny, - }); - } - - this.emit('updated'); - this.emit('changed'); - } - - public get ref() { - if (!this.metadata?.uid) { - throw new Error('No uid for resource'); - } - return { - apiVersion: this.apiVersion, - kind: this.kind, - name: this.name, - uid: this.metadata.uid, - }; - } - - public get exists() { - return !!this.manifest; - } - - public get apiVersion() { - return this.#options.data.apiVersion; - } - - public get group() { - const [group] = this.apiVersion?.split('/') || []; - return group; - } - - public get version() { - const [, version] = this.apiVersion?.split('/') || []; - return version; - } - - public get kind() { - return this.#options.data.kind; - } - - public get metadata() { - return this.manifest?.metadata; - } - - public get name() { - return this.#options.data.name; - } - - public get namespace() { - return this.#options.data.namespace; - } - - public get spec(): T extends { spec?: infer K } ? K | undefined : never { - if (this.manifest && 'spec' in this.manifest) { - return this.manifest.spec as ExpectedAny; - } - return undefined as ExpectedAny; - } - - public get data(): T extends { data?: infer K } ? K | undefined : never { - if (this.manifest && 'data' in this.manifest) { - return this.manifest.data as ExpectedAny; - } - return undefined as ExpectedAny; - } - - public get status(): T extends { status?: infer K } ? K | undefined : never { - if (this.manifest && 'status' in this.manifest) { - return this.manifest.status as ExpectedAny; - } - return undefined as ExpectedAny; - } - - public get owners() { - const { services } = this.#options; - const references = this.metadata?.ownerReferences || []; - const resourceService = services.get(ResourceService); - return references.map((ref) => - resourceService.get({ - apiVersion: ref.apiVersion, - kind: ref.kind, - name: ref.name, - namespace: this.namespace, - }), - ); - } - - public patch = (patch: T) => - this.#queue.add(async () => { - const { services } = this.#options; - services.log.debug(`Patching ${this.apiVersion}/${this.kind}/${this.namespace}/${this.name}`, { - specifier: this.specifier, - current: this.manifest, - patch, - }); - const k8s = services.get(K8sService); - const body = { - ...patch, - apiVersion: this.specifier.apiVersion, - kind: this.specifier.kind, - metadata: { - ...patch.metadata, - name: this.specifier.name, - namespace: this.specifier.namespace, - }, - }; - try { - this.manifest = await k8s.objectsApi.patch( - body, - undefined, - undefined, - undefined, - undefined, - PatchStrategy.MergePatch, - ); - } catch (err) { - if (err instanceof ApiException && err.code === 404) { - this.manifest = await k8s.objectsApi.create(body); - return; - } - throw err; - } - }); - - public delete = () => - this.#queue.add(async () => { - try { - const { services } = this.#options; - services.log.debug(`Deleting ${this.apiVersion}/${this.kind}/${this.namespace}/${this.name}`); - const k8s = services.get(K8sService); - await k8s.objectsApi.delete({ - apiVersion: this.specifier.apiVersion, - kind: this.specifier.kind, - metadata: { - name: this.specifier.name, - namespace: this.specifier.namespace, - }, - }); - this.manifest = undefined; - } catch (err) { - if (err instanceof ApiException && err.code === 404) { - return; - } - throw err; - } - }); - - public load = () => - this.#queue.add(async () => { - const { services } = this.#options; - const k8s = services.get(K8sService); - try { - const manifest = await k8s.objectsApi.read({ - apiVersion: this.specifier.apiVersion, - kind: this.specifier.kind, - metadata: { - name: this.specifier.name, - namespace: this.specifier.namespace, - }, - }); - this.manifest = manifest as T; - } catch (err) { - if (err instanceof ApiException && err.code === 404) { - this.manifest = undefined; - } else { - throw err; - } - } - }); - - public addEvent = (event: EventOptions) => - this.#queue.add(async () => { - const { services } = this.#options; - const k8sService = services.get(K8sService); - - services.log.debug(`Adding event ${this.apiVersion}/${this.kind}/${this.namespace}/${this.name}`, event); - - await k8sService.eventsApi.createNamespacedEvent({ - namespace: this.specifier.namespace || 'default', - body: { - kind: 'Event', - metadata: { - name: `${this.specifier.name}-${Date.now()}-${Buffer.from(crypto.getRandomValues(new Uint8Array(8))).toString('hex')}`, - namespace: this.specifier.namespace, - }, - eventTime: new V1MicroTime(), - note: event.message, - action: event.action, - reason: event.reason, - type: event.type, - reportingController: GROUP, - reportingInstance: this.name, - regarding: { - apiVersion: this.specifier.apiVersion, - resourceVersion: this.metadata?.resourceVersion, - kind: this.specifier.kind, - name: this.specifier.name, - namespace: this.specifier.namespace, - uid: this.metadata?.uid, - }, - }, - }); - }); -} - -export { Resource, type UnknownResource, type ResourceEvents }; diff --git a/src/services/resources/resources.ts b/src/services/resources/resources.ts index f6189d8..296107c 100644 --- a/src/services/resources/resources.ts +++ b/src/services/resources/resources.ts @@ -1,54 +1,139 @@ -import type { KubernetesObject } from '@kubernetes/client-node'; +import { ApiException, type KubernetesObject } from '@kubernetes/client-node'; +import type { ZodType } from 'zod'; import type { Services } from '../../utils/service.ts'; +import { WatcherService } from '../watchers/watchers.ts'; -import { Resource } from './resources.resource.ts'; -import type { ResourceInstance } from './resources.instance.ts'; +import { Resource, type ResourceOptions } from './resource/resource.ts'; +import { createManifest } from './resources.utils.ts'; -type ResourceGetOptions = { +import { K8sService } from '#services/k8s/k8s.ts'; +import { EventEmitter } from 'eventemitter3'; + +type ResourceClass = (new (options: ResourceOptions) => Resource) & { apiVersion: string; kind: string; - name: string; - namespace?: string; + plural?: string; }; -class ResourceService { - #cache: Resource[] = []; +type InstallableResourceClass = ResourceClass & { + spec: ZodType; + status: ZodType; + scope: 'Namespaced' | 'Cluster'; +}; + +type ResourceServiceEvents = { + changed: (resource: Resource) => void; +}; + +class ResourceService extends EventEmitter { #services: Services; + #registry: Map< + ResourceClass, + { + apiVersion: string; + kind: string; + plural?: string; + resources: Resource[]; + } + >; constructor(services: Services) { + super(); this.#services = services; + this.#registry = new Map(); } - public getInstance = >( - options: ResourceGetOptions, - instance: new (resource: Resource) => I, - ) => { - const resource = this.get(options); - return new instance(resource); + public register = async (...resources: ResourceClass[]) => { + for (const resource of resources) { + if (!this.#registry.has(resource)) { + this.#registry.set(resource, { + apiVersion: resource.apiVersion, + kind: resource.kind, + plural: resource.plural, + resources: [], + }); + } + const watcherService = this.#services.get(WatcherService); + const watcher = watcherService.create({ + ...resource, + verbs: ['add', 'update', 'delete'], + }); + watcher.on('changed', (manifest) => { + const { name, namespace } = manifest.metadata || {}; + if (!name) { + return; + } + const current = this.get(resource, name, namespace); + current.manifest = manifest; + }); + await watcher.start(); + } }; - public get = (options: ResourceGetOptions) => { - const { apiVersion, kind, name, namespace } = options; - let resource = this.#cache.find( - (resource) => - resource.specifier.kind === kind && - resource.specifier.apiVersion === apiVersion && - resource.specifier.name === name && - resource.specifier.namespace === namespace, - ); - if (resource) { - return resource as Resource; + public getAllOfKind = >(type: T) => { + return (this.#registry.get(type)?.resources?.filter((r) => r.exists) as InstanceType[]) || []; + }; + + public get = >(type: T, name: string, namespace?: string) => { + let resourceRegistry = this.#registry.get(type); + if (!resourceRegistry) { + resourceRegistry = { + apiVersion: type.apiVersion, + kind: type.kind, + plural: type.plural, + resources: [], + }; + this.#registry.set(type, resourceRegistry); + } + const { resources, apiVersion, kind } = resourceRegistry; + let current = resources.find((resource) => resource.name === name && resource.namespace === namespace); + if (!current) { + current = new type({ + selector: { + apiVersion, + kind, + name, + namespace, + }, + services: this.#services, + }); + current.on('changed', this.emit.bind(this, 'changed', current)); + resources.push(current); + } + return current as InstanceType; + }; + + public install = async (...resources: InstallableResourceClass[]) => { + const k8sService = this.#services.get(K8sService); + for (const resource of resources) { + this.#services.log.info('Installing CRD', { kind: resource.kind }); + try { + const manifest = createManifest(resource); + try { + await k8sService.extensionsApi.createCustomResourceDefinition({ + body: manifest, + }); + } catch (error) { + if (error instanceof ApiException && error.code === 409) { + await k8sService.extensionsApi.patchCustomResourceDefinition({ + name: manifest.metadata.name, + body: [{ op: 'replace', path: '/spec', value: manifest.spec }], + }); + continue; + } + throw error; + } + } catch (error) { + if (error instanceof ApiException) { + throw new Error(`Failed to install ${resource.kind}: ${error.body}`); + } + throw error; + } } - resource = new Resource({ - data: options, - services: this.#services, - }); - this.#cache.push(resource); - return resource as Resource; }; } -export { ResourceInstance } from './resources.instance.ts'; -export { ResourceReference } from './resources.ref.ts'; -export { ResourceService, Resource }; +export { CustomResource, type CustomResourceOptions } from './resource/resource.custom.ts'; +export { ResourceReference } from './resource/resource.reference.ts'; +export { ResourceService, Resource, type ResourceOptions, type ResourceClass, type InstallableResourceClass }; diff --git a/src/services/custom-resources/custom-resources.utils.ts b/src/services/resources/resources.utils.ts similarity index 59% rename from src/services/custom-resources/custom-resources.utils.ts rename to src/services/resources/resources.utils.ts index d5101f2..4e71701 100644 --- a/src/services/custom-resources/custom-resources.utils.ts +++ b/src/services/resources/resources.utils.ts @@ -1,25 +1,27 @@ import { z } from 'zod'; -import { customResourceStatusSchema, type CustomResourceDefinition } from './custom-resources.types.ts'; +import type { InstallableResourceClass } from './resources.ts'; -const createManifest = (defintion: CustomResourceDefinition) => { +const createManifest = (defintion: InstallableResourceClass) => { + const plural = defintion.plural ?? defintion.kind.toLowerCase() + 's'; + const [version, group] = defintion.apiVersion.split('/').toReversed(); return { apiVersion: 'apiextensions.k8s.io/v1', kind: 'CustomResourceDefinition', metadata: { - name: `${defintion.names.plural}.${defintion.group}`, + name: `${plural}.${group}`, }, spec: { - group: defintion.group, + group: group, names: { kind: defintion.kind, - plural: defintion.names.plural, - singular: defintion.names.singular, + plural: plural, + singular: defintion.kind.toLowerCase(), }, - scope: 'Namespaced', + scope: defintion.scope, versions: [ { - name: defintion.version, + name: version, served: true, storage: true, schema: { @@ -27,12 +29,12 @@ const createManifest = (defintion: CustomResourceDefinition) => { type: 'object', properties: { spec: { - ...z.toJSONSchema(defintion.spec.strict(), { io: 'input' }), + ...z.toJSONSchema(defintion.spec, { io: 'input' }), $schema: undefined, additionalProperties: undefined, } as ExpectedAny, status: { - ...z.toJSONSchema(customResourceStatusSchema.strict(), { io: 'input' }), + ...z.toJSONSchema(defintion.status, { io: 'input' }), $schema: undefined, additionalProperties: undefined, } as ExpectedAny, diff --git a/src/services/secrets/secrets.secret.ts b/src/services/secrets/secrets.secret.ts deleted file mode 100644 index ac71fdc..0000000 --- a/src/services/secrets/secrets.secret.ts +++ /dev/null @@ -1,100 +0,0 @@ -import type { V1Secret } from '@kubernetes/client-node'; -import type { z, ZodObject } from 'zod'; -import deepEqual from 'deep-equal'; - -import { ResourceService, type Resource } from '../resources/resources.ts'; -import type { Services } from '../../utils/service.ts'; - -type EnsuredSecretOptions = { - services: Services; - name: string; - namespace: string; - schema: T; - owner?: ExpectedAny[]; - generator: (previous?: unknown) => z.infer; - validate?: (value: T) => boolean; -}; - -class EnsuredSecret { - #options: EnsuredSecretOptions; - #resource: Resource; - - constructor(options: EnsuredSecretOptions) { - this.#options = options; - const { services, name, namespace } = options; - const resourceService = services.get(ResourceService); - this.#resource = resourceService.get({ - apiVersion: 'v1', - kind: 'Secret', - name, - namespace, - }); - this.#resource.on('changed', this.#handleChanged); - this.#handleChanged(); - } - - public get name() { - return this.#options.name; - } - - public get namespace() { - return this.#options.namespace; - } - - public get resource() { - return this.#resource; - } - - public get value(): z.infer | undefined { - if (!this.#resource.data) { - return undefined; - } - return Object.fromEntries( - Object.entries(this.#resource.data).map(([name, value]) => [name, Buffer.from(value, 'base64').toString('utf8')]), - ) as ExpectedAny; - } - - public patch = async (value: ExpectedAny) => { - const patched = { - ...this.value, - ...value, - }; - if (deepEqual(patched, this.value)) { - return; - } - await this.resource.patch({ - data: patched, - }); - }; - - public get isValid() { - const { schema, validate } = this.#options; - const { success } = schema.safeParse(this.value); - if (!success) { - return false; - } - if (validate) { - return validate(this.value as unknown as T); - } - return true; - } - - #handleChanged = () => { - const { generator, owner } = this.#options; - if (this.isValid && deepEqual(this.#resource.metadata?.ownerReferences, owner)) { - return; - } - const data = generator(); - const encodedValues = Object.fromEntries( - Object.entries(data).map(([name, value]) => [name, Buffer.from(String(value)).toString('base64')]), - ); - this.#resource.patch({ - metadata: { - ownerReferences: owner, - }, - data: encodedValues, - }); - }; -} - -export { EnsuredSecret, type EnsuredSecretOptions }; diff --git a/src/services/secrets/secrets.ts b/src/services/secrets/secrets.ts deleted file mode 100644 index 8b6a8e0..0000000 --- a/src/services/secrets/secrets.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { ZodObject } from 'zod'; - -import type { Services } from '../../utils/service.ts'; - -import { EnsuredSecret, type EnsuredSecretOptions } from './secrets.secret.ts'; - -class SecretService { - #services: Services; - - constructor(services: Services) { - this.#services = services; - } - - public ensure = (options: Omit, 'services'>) => { - return new EnsuredSecret({ - ...options, - services: this.#services, - }); - }; -} - -export { SecretService }; diff --git a/src/services/value-reference/value-reference.instance.ts b/src/services/value-reference/value-reference.instance.ts deleted file mode 100644 index 8d256e7..0000000 --- a/src/services/value-reference/value-reference.instance.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { z } from 'zod'; -import { V1Secret } from '@kubernetes/client-node'; -import { EventEmitter } from 'eventemitter3'; -import deepEqual from 'deep-equal'; - -import { ResourceReference, ResourceService } from '../resources/resources.ts'; -import type { Services } from '../../utils/service.ts'; -import { getWithNamespace } from '../../utils/naming.ts'; -import { decodeSecret } from '../../utils/secrets.ts'; - -const valueReferenceInfoSchema = z.object({ - value: z.string().optional(), - secretRef: z.string().optional(), - key: z.string().optional(), -}); - -type ValueReferenceInfo = z.infer; - -type ValueRefOptions = { - services: Services; - namespace: string; -}; - -type ValueReferenceEvents = { - changed: () => void; -}; -class ValueReference extends EventEmitter { - #options: ValueRefOptions; - #ref?: ValueReferenceInfo; - #resource: ResourceReference; - - constructor(options: ValueRefOptions) { - super(); - this.#options = options; - this.#resource = new ResourceReference(); - this.#resource.on('changed', this.#handleChange); - } - - public get ref() { - return this.#ref; - } - - public set ref(ref: ValueReferenceInfo | undefined) { - if (deepEqual(this.#ref, ref)) { - return; - } - if (ref?.secretRef && ref.key) { - const { services, namespace } = this.#options; - const resourceService = services.get(ResourceService); - const refNames = getWithNamespace(ref.secretRef, namespace); - this.#resource.current = resourceService.get({ - apiVersion: 'v1', - kind: 'Secret', - name: refNames.name, - namespace: refNames.namespace, - }); - } else { - this.#resource.current = undefined; - } - this.#ref = ref; - } - - public get value() { - console.log('get', this.#ref); - if (!this.#ref) { - return undefined; - } - if (this.#ref.value) { - return this.#ref.value; - } - if (this.#resource.current && this.#ref.key) { - const decoded = decodeSecret(this.#resource.current.data); - return decoded?.[this.#ref.key]; - } - return undefined; - } - - #handleChange = () => { - this.emit('changed'); - }; -} - -export { ValueReference, valueReferenceInfoSchema, type ValueReferenceInfo }; diff --git a/src/services/value-reference/value-reference.ts b/src/services/value-reference/value-reference.ts deleted file mode 100644 index cc8f97e..0000000 --- a/src/services/value-reference/value-reference.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { Services } from '../../utils/service.ts'; - -import { ValueReference } from './value-reference.instance.ts'; - -class ValueReferenceService { - #services: Services; - - constructor(services: Services) { - this.#services = services; - } - - public get = (namespace: string) => { - return new ValueReference({ - namespace, - services: this.#services, - }); - }; -} - -export * from './value-reference.instance.ts'; -export { ValueReferenceService }; diff --git a/src/services/watchers/watchers.ts b/src/services/watchers/watchers.ts index 6964698..7e36b6a 100644 --- a/src/services/watchers/watchers.ts +++ b/src/services/watchers/watchers.ts @@ -16,22 +16,6 @@ class WatcherService { }); return instance; }; - - public watchCustomGroup = async (group: string, version: string, plurals: string[]) => { - for (const plural of plurals) { - await this.create({ - path: `/apis/${group}/${version}/${plural}`, - list: async (k8s) => { - return await k8s.customObjectsApi.listCustomObjectForAllNamespaces({ - group, - version, - plural, - }); - }, - verbs: ['add', 'update', 'delete'], - }).start(); - } - }; } export { WatcherService, Watcher }; diff --git a/src/services/watchers/watchers.watcher.ts b/src/services/watchers/watchers.watcher.ts index 16b3872..8c9c44e 100644 --- a/src/services/watchers/watchers.watcher.ts +++ b/src/services/watchers/watchers.watcher.ts @@ -1,79 +1,60 @@ -import { - ApiException, - makeInformer, - type Informer, - type KubernetesListObject, - type KubernetesObject, -} from '@kubernetes/client-node'; +import { ApiException, makeInformer, type Informer, type KubernetesObject } from '@kubernetes/client-node'; import { EventEmitter } from 'eventemitter3'; import { K8sService } from '../k8s/k8s.ts'; import type { Services } from '../../utils/service.ts'; -import { ResourceService, type Resource } from '../resources/resources.ts'; type ResourceChangedAction = 'add' | 'update' | 'delete'; type WatcherEvents = { - changed: (resource: Resource) => void; + changed: (manifest: T) => void; }; -type WatcherOptions = { - path: string; - list: (k8s: K8sService) => Promise>; +type WatcherOptions = { + apiVersion: string; + kind: string; + plural?: string; selector?: string; services: Services; verbs: ResourceChangedAction[]; - transform?: (input: T) => T; }; class Watcher extends EventEmitter> { - #options: WatcherOptions; + #options: WatcherOptions; #informer: Informer; - constructor(options: WatcherOptions) { + constructor(options: WatcherOptions) { super(); this.#options = options; this.#informer = this.#setup(); } #setup = () => { - const { services, path, list, selector } = this.#options; + const { services, apiVersion, kind, selector } = this.#options; + const plural = this.#options.plural ?? kind.toLowerCase() + 's'; + const [version, group] = apiVersion.split('/').toReversed(); const k8s = services.get(K8sService); - const informer = makeInformer(k8s.config, path, list.bind(this, k8s), selector); + const path = group ? `/apis/${group}/${version}/${plural}` : `/api/${version}/${plural}`; + const informer = makeInformer( + k8s.config, + path, + async () => { + return k8s.objectsApi.list(apiVersion, kind); + }, + selector, + ); informer.on('add', this.#handleResource.bind(this, 'add')); informer.on('update', this.#handleResource.bind(this, 'update')); informer.on('delete', this.#handleResource.bind(this, 'delete')); informer.on('error', (err) => { - if (!(err instanceof ApiException && err.code === 404)) { - console.log('Watcher failed, will retry in 3 seconds', path, err); - } + console.log('Watcher failed, will retry in 3 seconds', path, err); setTimeout(this.start, 3000); }); return informer; }; - #handleResource = (action: ResourceChangedAction, originalManifest: T) => { - const { services, transform } = this.#options; - const manifest = transform ? transform(originalManifest) : originalManifest; - const resourceService = services.get(ResourceService); - const { apiVersion, kind, metadata = {} } = manifest; - const { name, namespace } = metadata; - if (!name || !apiVersion || !kind) { - return; - } - const resource = resourceService.get({ - apiVersion, - kind, - name, - namespace, - }); - - if (action === 'delete') { - resource.manifest = undefined; - } else { - resource.manifest = manifest; - } - this.emit('changed', resource); + #handleResource = (action: ResourceChangedAction, manifest: T) => { + this.emit('changed', manifest); }; public stop = async () => { diff --git a/src/storage-provider/storage-provider.ts b/src/storage-provider/storage-provider.ts deleted file mode 100644 index e82ccd8..0000000 --- a/src/storage-provider/storage-provider.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { V1PersistentVolume, type V1PersistentVolumeClaim, CoreV1Event, V1StorageClass } from '@kubernetes/client-node'; - -import { Watcher, WatcherService } from '../services/watchers/watchers.ts'; -import type { Services } from '../utils/service.ts'; -import { ResourceService, type Resource } from '../services/resources/resources.ts'; - -const PROVISIONER = 'homelab-operator-local-path'; - -class StorageProvider { - #watcher: Watcher; - #services: Services; - - constructor(services: Services) { - this.#services = services; - const watchService = this.#services.get(WatcherService); - this.#watcher = watchService.create({ - path: '/api/v1/persistentvolumeclaims', - transform: (manifest) => ({ - apiVersion: 'v1', - kind: 'PersistentVolumeClaim', - ...manifest, - }), - list: async (k8s) => { - const current = await k8s.api.listPersistentVolumeClaimForAllNamespaces(); - return current; - }, - verbs: ['add', 'update', 'delete'], - }); - this.#watcher.on('changed', this.#handleChange); - } - - #handleChange = async (pvc: Resource) => { - try { - if (!pvc.exists || pvc.metadata?.deletionTimestamp) { - return; - } - - const storageClassName = pvc.spec?.storageClassName; - if (!storageClassName) { - return; - } - const resourceService = this.#services.get(ResourceService); - const storageClass = resourceService.get({ - apiVersion: 'storage.k8s.io/v1', - kind: 'StorageClass', - name: storageClassName, - }); - - if (!storageClass.exists || storageClass.manifest?.provisioner !== PROVISIONER) { - return; - } - - if (pvc.status?.phase === 'Pending' && !pvc.spec?.volumeName) { - await this.#provisionVolume(pvc, storageClass); - } - } catch (error) { - console.error(`Error handling PVC ${pvc.namespace}/${pvc.name}:`, error); - await this.#createEvent(pvc, 'Warning', 'ProvisioningFailed', `Failed to provision volume: ${error}`); - } - }; - - #provisionVolume = async (pvc: Resource, storageClass: Resource) => { - const pvName = `pv-${pvc.namespace}-${pvc.name}`; - const storageLocation = storageClass.manifest?.parameters?.storageLocation || '/data/volumes'; - const target = `${storageLocation}/${pvc.namespace}/${pvc.name}`; - - try { - const resourceService = this.#services.get(ResourceService); - const pv = resourceService.get({ - apiVersion: 'v1', - kind: 'PersistentVolume', - name: pvName, - }); - - await pv.patch({ - metadata: { - name: pvName, - labels: { - provisioner: PROVISIONER, - 'pvc-namespace': pvc.namespace || 'default', - 'pvc-name': pvc.name || 'unknown', - }, - annotations: { - 'pv.kubernetes.io/provisioned-by': PROVISIONER, - }, - }, - spec: { - hostPath: { - path: target, - type: 'DirectoryOrCreate', - }, - capacity: { - storage: pvc.spec?.resources?.requests?.storage ?? '1Gi', - }, - persistentVolumeReclaimPolicy: 'Retain', - accessModes: pvc.spec?.accessModes ?? ['ReadWriteOnce'], - storageClassName: pvc.spec?.storageClassName, - claimRef: { - uid: pvc.metadata?.uid, - resourceVersion: pvc.metadata?.resourceVersion, - apiVersion: pvc.apiVersion, - kind: 'PersistentVolumeClaim', - name: pvc.name, - namespace: pvc.namespace, - }, - }, - }); - - await this.#createEvent(pvc, 'Normal', 'Provisioning', `Successfully provisioned volume ${pvName}`); - } catch (error) { - console.error(`Failed to provision volume for PVC ${pvc.namespace}/${pvc.name}:`, error); - throw error; - } - }; - - #createEvent = async (pvc: Resource, type: string, reason: string, message: string) => { - try { - const resourceService = this.#services.get(ResourceService); - const event = resourceService.get({ - apiVersion: 'v1', - kind: 'Event', - name: `${pvc.name}-${Date.now()}`, - namespace: pvc.namespace, - }); - - if (!pvc.name || !pvc.namespace || !pvc.metadata?.uid) { - console.error('Missing required PVC metadata for event creation'); - return; - } - - await event.patch({ - metadata: { - namespace: pvc.namespace, - }, - involvedObject: { - apiVersion: pvc.apiVersion, - kind: 'PersistentVolumeClaim', - name: pvc.name, - namespace: pvc.namespace, - uid: pvc.metadata.uid, - }, - type, - reason, - message, - source: { - component: PROVISIONER, - }, - firstTimestamp: new Date(), - lastTimestamp: new Date(), - count: 1, - }); - } catch (error) { - console.error(`Failed to create event for PVC ${pvc.namespace}/${pvc.name}:`, error); - } - }; - - public start = async () => { - await this.#watcher.start(); - }; -} - -export { StorageProvider, PROVISIONER }; diff --git a/src/utils/errors.ts b/src/utils/errors.ts new file mode 100644 index 0000000..114bfe2 --- /dev/null +++ b/src/utils/errors.ts @@ -0,0 +1,14 @@ +class NotReadyError extends Error { + #reason?: string; + + constructor(reason?: string, message?: string) { + super(message || reason || 'Resource is not ready'); + this.#reason = reason; + } + + get reason() { + return this.#reason; + } +} + +export { NotReadyError }; diff --git a/src/utils/secrets.ts b/src/utils/secrets.ts index fd34572..fd490b9 100644 --- a/src/utils/secrets.ts +++ b/src/utils/secrets.ts @@ -9,12 +9,19 @@ const decodeSecret = >( ) as T; }; -const encodeSecret = >(data: T | undefined): Record | undefined => { +const encodeSecret = >( + data: T | undefined, +): Record | undefined => { if (!data) { return undefined; } return Object.fromEntries( - Object.entries(data).map(([name, value]) => [name, Buffer.from(value, 'utf8').toString('base64')]), + Object.entries(data).map(([name, value]) => [name, Buffer.from(value || '', 'utf8').toString('base64')]), ); }; -export { decodeSecret, encodeSecret }; + +const generateRandomHexPass = (bytes = 32) => { + return `pass_${Buffer.from(crypto.getRandomValues(new Uint8Array(bytes))).toString('hex')}`; +}; + +export { decodeSecret, encodeSecret, generateRandomHexPass }; diff --git a/tsconfig.json b/tsconfig.json index 1949cb1..ebfa601 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,7 +22,14 @@ // Some stricter flags (disabled by default) "noUnusedLocals": false, "noUnusedParameters": false, - "noPropertyAccessFromIndexSignature": false + "noPropertyAccessFromIndexSignature": false, + "baseUrl": ".", + "paths": { + "#services/*": ["./src/services/*"], + "#resources/*": ["./src/resources/*"] + "#utils/*": ["./src/utils/*"] + "#bootstrap/*": ["./src/bootstrap/*"] + } }, - "include": ["src/**/*"], + "include": ["src/**/*"] } diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..a71d74a --- /dev/null +++ b/uv.lock @@ -0,0 +1,191 @@ +version = 1 +revision = 2 +requires-python = ">=3.13" + +[[package]] +name = "cfgv" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, +] + +[[package]] +name = "diagrams" +version = "0.24.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "graphviz" }, + { name = "jinja2" }, + { name = "pre-commit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/50/abb3442117b933ae08c948b4c08672eba5f78a35ef820c03f5979affbe3b/diagrams-0.24.4.tar.gz", hash = "sha256:367b3056bc8875b5a32dcf66c7aad961cb83527b8f345dced82f17ee82781f72", size = 26913707, upload-time = "2025-03-10T07:11:56.402Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/f1/f83298463453662f27bd93a21a9cacc157b62f665db795f26e1a25143b7b/diagrams-0.24.4-py3-none-any.whl", hash = "sha256:41c5c03e1317ada0a64287f704e5e9d9c8794addce96b66dd831a536aa7e6507", size = 27830571, upload-time = "2025-03-10T07:11:49.623Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "filelock" +version = "3.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/bb/0ab3e58d22305b6f5440629d20683af28959bf793d98d11950e305c1c326/filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58", size = 17687, upload-time = "2025-08-14T16:56:03.016Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988, upload-time = "2025-08-14T16:56:01.633Z" }, +] + +[[package]] +name = "graphviz" +version = "0.20.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/83/5a40d19b8347f017e417710907f824915fba411a9befd092e52746b63e9f/graphviz-0.20.3.zip", hash = "sha256:09d6bc81e6a9fa392e7ba52135a9d49f1ed62526f96499325930e87ca1b5925d", size = 256455, upload-time = "2024-03-21T07:50:45.772Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/be/d59db2d1d52697c6adc9eacaf50e8965b6345cc143f671e1ed068818d5cf/graphviz-0.20.3-py3-none-any.whl", hash = "sha256:81f848f2904515d8cd359cc611faba817598d2feaac4027b266aa3eda7b3dde5", size = 47126, upload-time = "2024-03-21T07:50:43.091Z" }, +] + +[[package]] +name = "homelab-operator" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "kubediagrams" }, +] + +[package.metadata] +requires-dist = [{ name = "kubediagrams", specifier = ">=0.5.0" }] + +[[package]] +name = "identify" +version = "2.6.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ca/ffbabe3635bb839aa36b3a893c91a9b0d368cb4d8073e03a12896970af82/identify-2.6.13.tar.gz", hash = "sha256:da8d6c828e773620e13bfa86ea601c5a5310ba4bcd65edf378198b56a1f9fb32", size = 99243, upload-time = "2025-08-09T19:35:00.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/ce/461b60a3ee109518c055953729bf9ed089a04db895d47e95444071dcdef2/identify-2.6.13-py2.py3-none-any.whl", hash = "sha256:60381139b3ae39447482ecc406944190f690d4a2997f2584062089848361b33b", size = 99153, upload-time = "2025-08-09T19:34:59.1Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "kubediagrams" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "diagrams" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/8b/5abfbcc6b8005f36ad85cd2dea7888ffacdb45d23dcf34c9c4f12dc16cf7/kubediagrams-0.5.0.tar.gz", hash = "sha256:e2cd7726ef146e30cd6d44270909527c64bc2610952afbf2d13215f1e4775d05", size = 199206, upload-time = "2025-08-06T10:51:55.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/65/29c0672228968ac467fbc52b83b7097059205bd26144e78494d28d96e8d7/kubediagrams-0.5.0-py3-none-any.whl", hash = "sha256:3e1fc28855b901451d2a57495087c9305956cdfbc49be960f9edec8e05cd2742", size = 178758, upload-time = "2025-08-06T10:51:54.128Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" }, + { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" }, + { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" }, + { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" }, + { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" }, + { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" }, + { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" }, + { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" }, + { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" }, + { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" }, + { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" }, + { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" }, + { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" }, + { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" }, + { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" }, + { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.3.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/8b/3c73abc9c759ecd3f1f7ceff6685840859e8070c4d947c93fae71f6a0bf2/platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", size = 21362, upload-time = "2025-05-07T22:47:42.121Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567, upload-time = "2025-05-07T22:47:40.376Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/29/7cf5bbc236333876e4b41f56e06857a87937ce4bf91e117a6991a2dbb02a/pre_commit-4.3.0.tar.gz", hash = "sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16", size = 193792, upload-time = "2025-08-09T18:56:14.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8", size = 220965, upload-time = "2025-08-09T18:56:13.192Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, +] + +[[package]] +name = "virtualenv" +version = "20.34.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/14/37fcdba2808a6c615681cd216fecae00413c9dab44fb2e57805ecf3eaee3/virtualenv-20.34.0.tar.gz", hash = "sha256:44815b2c9dee7ed86e387b842a84f20b93f7f417f95886ca1996a72a4138eb1a", size = 6003808, upload-time = "2025-08-13T14:24:07.464Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/06/04c8e804f813cf972e3262f3f8584c232de64f0cde9f703b46cf53a45090/virtualenv-20.34.0-py3-none-any.whl", hash = "sha256:341f5afa7eee943e4984a9207c025feedd768baff6753cd660c857ceb3e36026", size = 5983279, upload-time = "2025-08-13T14:24:05.111Z" }, +]