mirror of
https://github.com/morten-olsen/homelab-operator.git
synced 2026-02-08 01:36:28 +01:00
Compare commits
1 Commits
renovate/c
...
v0.1.16
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d2c4b8f53 |
22
.github/workflows/main.yml
vendored
22
.github/workflows/main.yml
vendored
@@ -27,9 +27,9 @@ jobs:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v5
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "${{ env.NODE_VERSION }}"
|
||||
registry-url: "${{ env.NODE_REGISTRY }}"
|
||||
@@ -55,12 +55,10 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
working-directory: images/operator
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Run tests
|
||||
working-directory: images/operator
|
||||
run: pnpm test
|
||||
|
||||
update-release-draft:
|
||||
@@ -73,23 +71,9 @@ jobs:
|
||||
environment: release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: create-release
|
||||
uses: release-drafter/release-drafter@v6
|
||||
- uses: release-drafter/release-drafter@v6
|
||||
with:
|
||||
config-name: release-drafter-config.yml
|
||||
publish: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Upload Release Asset
|
||||
id: upload-release-asset
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create-release.outputs.upload_url }}
|
||||
asset_path: ./operator.yaml
|
||||
asset_name: operator.yaml
|
||||
asset_content_type: application/yaml
|
||||
|
||||
65
.github/workflows/publish-backup-tag.yml
vendored
65
.github/workflows/publish-backup-tag.yml
vendored
@@ -1,65 +0,0 @@
|
||||
name: Publish tag
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "main"
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
env:
|
||||
environment: test
|
||||
release_channel: latest
|
||||
DO_NOT_TRACK: "1"
|
||||
NODE_VERSION: "23.x"
|
||||
DOCKER_REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}-backup
|
||||
PNPM_VERSION: 10.6.0
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
|
||||
jobs:
|
||||
release:
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
attestations: write
|
||||
id-token: write
|
||||
pages: write
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@5b7b28b1cc417bbd34cd8c225a957c9ce9adf7f2
|
||||
with:
|
||||
registry: ${{ env.DOCKER_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@032a4b3bda1b716928481836ac5bfe36e1feaad6
|
||||
with:
|
||||
images: ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: push
|
||||
uses: docker/build-push-action@cb8fc7586f9ad9441b20c33e0f6e8b1b58d8b4c6
|
||||
with:
|
||||
context: ./images/backup
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
- name: Generate artifact attestation
|
||||
uses: actions/attest-build-provenance@v3
|
||||
with:
|
||||
subject-name: ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME}}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
push-to-registry: true
|
||||
14
.github/workflows/publish-tag.yml
vendored
14
.github/workflows/publish-tag.yml
vendored
@@ -3,7 +3,7 @@ name: Publish tag
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "main"
|
||||
- 'main'
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
@@ -31,12 +31,12 @@ jobs:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@5b7b28b1cc417bbd34cd8c225a957c9ce9adf7f2
|
||||
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
|
||||
with:
|
||||
registry: ${{ env.DOCKER_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -44,21 +44,21 @@ jobs:
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@032a4b3bda1b716928481836ac5bfe36e1feaad6
|
||||
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
|
||||
with:
|
||||
images: ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: push
|
||||
uses: docker/build-push-action@cb8fc7586f9ad9441b20c33e0f6e8b1b58d8b4c6
|
||||
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
|
||||
with:
|
||||
context: ./images/operator
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
- name: Generate artifact attestation
|
||||
uses: actions/attest-build-provenance@v3
|
||||
uses: actions/attest-build-provenance@v2
|
||||
with:
|
||||
subject-name: ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME}}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
|
||||
16
.github/workflows/renovate.yml
vendored
16
.github/workflows/renovate.yml
vendored
@@ -1,16 +0,0 @@
|
||||
name: Renovate
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 */6 * * *"
|
||||
jobs:
|
||||
renovate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
- name: Self-hosted Renovate
|
||||
uses: renovatebot/github-action@v43.0.13
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
configurationFile: ./renovate.json5
|
||||
38
.gitignore
vendored
38
.gitignore
vendored
@@ -1,4 +1,36 @@
|
||||
/secret.*.yaml
|
||||
# dependencies (bun install)
|
||||
node_modules
|
||||
|
||||
# output
|
||||
out
|
||||
dist
|
||||
*.tgz
|
||||
|
||||
# code coverage
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# logs
|
||||
logs
|
||||
_.log
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# caches
|
||||
.eslintcache
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
|
||||
/data/
|
||||
/.envrc
|
||||
*.DS_Store
|
||||
|
||||
6
Dockerfile
Normal file
6
Dockerfile
Normal file
@@ -0,0 +1,6 @@
|
||||
FROM node:23-alpine
|
||||
RUN corepack enable
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile --prod
|
||||
COPY . .
|
||||
CMD ["node", "src/index.ts"]
|
||||
13
Makefile
13
Makefile
@@ -1,14 +1,15 @@
|
||||
.PHONY: dev-recreate dev-destroy server-install
|
||||
.PHONY: setup dev-recreate dev-create dev-destroy
|
||||
|
||||
setup:
|
||||
./scripts/setup-server.sh
|
||||
|
||||
dev-destroy:
|
||||
colima delete -f
|
||||
|
||||
dev-recreate: dev-destroy
|
||||
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"
|
||||
dev-create:
|
||||
colima start --network-address --kubernetes -m 8 --mount ${PWD}/data:/data:w --k3s-arg="--disable=helm-controller,local-storage"
|
||||
|
||||
setup-flux:
|
||||
flux install --components="source-controller,helm-controller"
|
||||
dev-recreate: dev-destroy dev-create setup
|
||||
|
||||
server-install:
|
||||
curl -sfL https://get.k3s.io | sh -s - --disable traefik,local-storage,helm-controller
|
||||
@@ -0,0 +1,6 @@
|
||||
## Bootstrap repo
|
||||
|
||||
```
|
||||
brew install fluxcd/tap/flux
|
||||
make setup-server
|
||||
```
|
||||
|
||||
1
TODO.md
Normal file
1
TODO.md
Normal file
@@ -0,0 +1 @@
|
||||
- Fix issue with incompatible spec breaking the server
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 7.1 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 7.1 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 7.1 KiB |
19
cert-issuer.yaml
Normal file
19
cert-issuer.yaml
Normal file
@@ -0,0 +1,19 @@
|
||||
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
|
||||
14
chart/templates/clusterrole.yaml
Normal file
14
chart/templates/clusterrole.yaml
Normal file
@@ -0,0 +1,14 @@
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: {{ include "homelab-operator.fullname" . }}
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["secrets"]
|
||||
verbs: ["create", "get", "watch", "list"]
|
||||
- apiGroups: ["*"]
|
||||
resources: ["*"]
|
||||
verbs: ["get", "watch", "list", "patch"]
|
||||
- apiGroups: ["apiextensions.k8s.io"]
|
||||
resources: ["customresourcedefinitions"]
|
||||
verbs: ["get", "create", "replace"]
|
||||
12
chart/templates/clusterrolebinding.yaml
Normal file
12
chart/templates/clusterrolebinding.yaml
Normal file
@@ -0,0 +1,12 @@
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: {{ include "homelab-operator.fullname" . }}
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: {{ include "homelab-operator.serviceAccountName" . }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
roleRef:
|
||||
kind: ClusterRole
|
||||
name: {{ include "homelab-operator.fullname" . }}
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
@@ -2,7 +2,6 @@ apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "homelab-operator.fullname" . }}
|
||||
namespace: "{{ .Release.Namespace }}"
|
||||
labels:
|
||||
{{- include "homelab-operator.labels" . | nindent 4 }}
|
||||
spec:
|
||||
@@ -34,14 +33,6 @@ spec:
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
resources:
|
||||
{{- toYaml .Values.resources | nindent 12 }}
|
||||
volumeMounts:
|
||||
- name: data-volumes
|
||||
mountPath: {{ .Values.storage.path }}
|
||||
volumes:
|
||||
- name: data-volumes
|
||||
hostPath:
|
||||
path: {{ .Values.storage.path }}
|
||||
type: DirectoryOrCreate
|
||||
{{- with .Values.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
@@ -3,7 +3,6 @@ apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: {{ include "homelab-operator.serviceAccountName" . }}
|
||||
namespace: "{{ .Release.Namespace }}"
|
||||
labels:
|
||||
{{- include "homelab-operator.labels" . | nindent 4 }}
|
||||
{{- with .Values.serviceAccount.annotations }}
|
||||
@@ -4,20 +4,14 @@
|
||||
|
||||
image:
|
||||
repository: ghcr.io/morten-olsen/homelab-operator
|
||||
pullPolicy: IfNotPresent
|
||||
pullPolicy: Always
|
||||
# Overrides the image tag whose default is the chart appVersion.
|
||||
tag: main@sha256:df20d7e4f48bd886cef63ab882de9c6df76b0b297724d1cdf3a79aba8de6f896
|
||||
tag: main
|
||||
|
||||
imagePullSecrets: []
|
||||
nameOverride: ""
|
||||
fullnameOverride: ""
|
||||
|
||||
storage:
|
||||
path: /data/volumes
|
||||
reclaimPolicy: Retain
|
||||
allowVolumeExpansion: false
|
||||
volumeBindingMode: WaitForFirstConsumer
|
||||
|
||||
serviceAccount:
|
||||
# Specifies whether a service account should be created
|
||||
create: true
|
||||
@@ -1,12 +0,0 @@
|
||||
apiVersion: storage.k8s.io/v1
|
||||
kind: StorageClass
|
||||
metadata:
|
||||
name: {{ include "homelab-operator.fullname" . }}-local-path
|
||||
labels:
|
||||
{{- include "homelab-operator.labels" . | nindent 4 }}
|
||||
provisioner: reuse-local-path-provisioner
|
||||
parameters:
|
||||
# Add any provisioner-specific parameters here
|
||||
reclaimPolicy: {{ .Values.storage.reclaimPolicy | default "Retain" }}
|
||||
allowVolumeExpansion: {{ .Values.storage.allowVolumeExpansion | default false }}
|
||||
volumeBindingMode: {{ .Values.storage.volumeBindingMode | default "WaitForFirstConsumer" }}
|
||||
@@ -1,32 +0,0 @@
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: {{ include "homelab-operator.fullname" . }}
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["secrets"]
|
||||
verbs: ["create", "get", "watch", "list"]
|
||||
- apiGroups: [""]
|
||||
resources: ["namespaces"]
|
||||
verbs: ["get", "list", "watch", "create", "update", "patch"]
|
||||
- apiGroups: [""]
|
||||
resources: ["persistentvolumes"]
|
||||
verbs: ["get", "list", "watch", "create", "delete", "patch", "update"]
|
||||
- apiGroups: [""]
|
||||
resources: ["persistentvolumeclaims"]
|
||||
verbs: ["get", "list", "watch", "update", "patch"]
|
||||
- apiGroups: [""]
|
||||
resources: ["persistentvolumeclaims/status"]
|
||||
verbs: ["update", "patch"]
|
||||
- apiGroups: [""]
|
||||
resources: ["events"]
|
||||
verbs: ["create", "patch"]
|
||||
- apiGroups: ["storage.k8s.io"]
|
||||
resources: ["storageclasses"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: ["*"]
|
||||
resources: ["*"]
|
||||
verbs: ["get", "watch", "list", "patch", "create", "update", "replace"]
|
||||
- apiGroups: ["apiextensions.k8s.io"]
|
||||
resources: ["customresourcedefinitions"]
|
||||
verbs: ["get", "create", "update", "replace", "patch"]
|
||||
@@ -1,12 +0,0 @@
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: '{{ include "homelab-operator.fullname" . }}'
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: '{{ include "homelab-operator.serviceAccountName" . }}'
|
||||
namespace: "{{ .Release.Namespace }}"
|
||||
roleRef:
|
||||
kind: ClusterRole
|
||||
name: '{{ include "homelab-operator.fullname" . }}'
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
@@ -1,32 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: cloudflare
|
||||
namespace: homelab
|
||||
data:
|
||||
token: WDhqQ1Z2WGtHUVh4XzIzb0d2WmNUcWZkWm8zZGpsMXE0dGIxU0J3Zg==
|
||||
account: ZThkZDYwMDQ5MTI2NDM3MDhhNGZlMDI4YjNkNWEzMzM=
|
||||
tunnelName: aG9tZWxhYg==
|
||||
tunnelId: YTI1ZTI1MDEtNzNiNi00MDc1LWI3MjYtZDc1YWViZmE4ZmNk
|
||||
secret: UWgvRWtGNkY2MUNxSnFwMGlCQXJ3MUxyd245ZldtcTd1RDNrZk1VUEVBVT0=
|
||||
|
||||
---
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: ClusterIssuer
|
||||
metadata:
|
||||
name: lets-encrypt-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
|
||||
key: token
|
||||
12
docker-compose.dev.yaml
Normal file
12
docker-compose.dev.yaml
Normal file
@@ -0,0 +1,12 @@
|
||||
name: homelab
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17
|
||||
ports:
|
||||
- 5432:5432
|
||||
environment:
|
||||
POSTGRES_USER: $POSTGRES_USER
|
||||
POSTGRES_PASSWORD: $POSTGRES_PASSWORD
|
||||
POSTGRES_DB: ${POSTGRES_DB:-postgres}
|
||||
volumes:
|
||||
- $PWD/.data/local/postgres:/var/lib/postgresql/data
|
||||
@@ -1,476 +0,0 @@
|
||||
# Home Kubernetes Cluster Setup: Monitoring & Security Quickstart
|
||||
|
||||
This guide provides a practical, lightweight setup for monitoring and security on your home Kubernetes cluster. It uses Helm for easy installation and focuses on essential features with minimal complexity.
|
||||
|
||||
## Overview
|
||||
|
||||
This setup includes:
|
||||
|
||||
* **Monitoring:** Prometheus + node-exporter + kube-state-metrics + Grafana (via the `kube-prometheus-stack` Helm chart).
|
||||
* **Image Scanning & Supply-Chain:** Trivy (Trivy Operator) for automated in-cluster image vulnerability scanning.
|
||||
* **Policy / Admission Control / Pod Security:** Kyverno for policy enforcement and Kubernetes Pod Security Admission (PSA) for baseline security.
|
||||
* **Runtime Security / IDS:** Falco to detect suspicious syscalls and pod activity.
|
||||
* **Network Segmentation:** Calico (or Cilium) CNI with basic NetworkPolicy configuration.
|
||||
* **Ad-Hoc Checks:** kube-bench (CIS benchmarks), kube-linter/kube-score (static analysis), and kube-hunter (penetration testing).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
* A functional Kubernetes cluster (managed or self-hosted).
|
||||
* `kubectl` installed and configured to connect to your cluster.
|
||||
* Helm v3 installed.
|
||||
|
||||
## Installation
|
||||
|
||||
These instructions assume you have `kubectl` and Helm set up and authenticated to your cluster.
|
||||
|
||||
### 1. Monitoring (Prometheus + Grafana)
|
||||
|
||||
* Add the Prometheus community Helm repository:
|
||||
|
||||
```bash
|
||||
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
|
||||
helm repo update
|
||||
```
|
||||
|
||||
* Create the `monitoring` namespace and install the `kube-prometheus-stack` chart:
|
||||
|
||||
```bash
|
||||
kubectl create ns monitoring
|
||||
helm install kube-prometheus prometheus-community/kube-prometheus-stack --namespace monitoring
|
||||
```
|
||||
|
||||
*Optional*: Customize the installation by creating a `values.yaml` file to configure persistence, resource limits, and scrape intervals. See *Configuration* below for a potential `values.yaml` you can adapt.
|
||||
|
||||
* Access Grafana:
|
||||
|
||||
```bash
|
||||
kubectl -n monitoring port-forward svc/kube-prometheus-grafana 3000:80
|
||||
```
|
||||
|
||||
Open `http://localhost:3000` in your browser. The default `admin` user password can be found in the chart's secrets (check the Helm chart documentation).
|
||||
|
||||
This provides node-exporter, kube-state-metrics, a Prometheus server, Alertmanager, and pre-built dashboards for your cluster.
|
||||
|
||||
### 2. Image Scanning (Trivy Operator)
|
||||
|
||||
* Add the Aqua Security Helm repository:
|
||||
|
||||
```bash
|
||||
helm repo add aqua https://aquasecurity.github.io/helm-charts
|
||||
helm repo update
|
||||
```
|
||||
|
||||
* Create the `trivy-system` namespace and install the `trivy-operator` chart:
|
||||
|
||||
```bash
|
||||
kubectl create ns trivy-system
|
||||
helm install trivy-operator aqua/trivy-operator --namespace trivy-system
|
||||
```
|
||||
|
||||
Trivy Operator creates `VulnerabilityReport` and `ConfigAuditReport` CRDs. It scans images running in the cluster for vulnerabilities.
|
||||
|
||||
### 3. Policy Admission (Kyverno)
|
||||
|
||||
* Create the `kyverno` namespace and install Kyverno:
|
||||
|
||||
```bash
|
||||
kubectl create ns kyverno
|
||||
kubectl apply -f https://github.com/kyverno/kyverno/releases/latest/download/install.yaml
|
||||
```
|
||||
|
||||
* Apply the example `ClusterPolicy` to deny privileged containers and hostPath mounts:
|
||||
|
||||
```yaml
|
||||
apiVersion: kyverno.io/v1
|
||||
kind: ClusterPolicy
|
||||
metadata:
|
||||
name: deny-privileged-and-hostpath
|
||||
spec:
|
||||
rules:
|
||||
- name: deny-privileged
|
||||
match:
|
||||
resources:
|
||||
kinds: ["Pod","PodTemplate","CronJob","Job","Deployment","StatefulSet"]
|
||||
validate:
|
||||
message: "Privileged containers are not allowed"
|
||||
deny:
|
||||
conditions:
|
||||
- key: "{{ request.object.spec.containers[].securityContext.privileged }}"
|
||||
operator: Equals
|
||||
value: true
|
||||
- name: deny-hostpath
|
||||
match:
|
||||
resources:
|
||||
kinds: ["Pod","PodTemplate","Deployment","StatefulSet"]
|
||||
validate:
|
||||
message: "hostPath volumes are not allowed"
|
||||
pattern:
|
||||
spec:
|
||||
volumes:
|
||||
- "*":
|
||||
hostPath: null
|
||||
```
|
||||
|
||||
Save the above as `kyverno-policy.yaml` and apply it:
|
||||
|
||||
```bash
|
||||
kubectl apply -f kyverno-policy.yaml
|
||||
```
|
||||
|
||||
Adapt the `match` section to target specific workload types. See *Example Kyverno Policy* below.
|
||||
|
||||
### 4. Pod Security Admission (PSA)
|
||||
|
||||
* Apply the `baseline` Pod Security Standard to the `default` namespace:
|
||||
|
||||
```bash
|
||||
kubectl label ns default pod-security.kubernetes.io/enforce=baseline
|
||||
```
|
||||
|
||||
* For a stricter security posture, use the `restricted` profile:
|
||||
|
||||
```bash
|
||||
kubectl label ns default pod-security.kubernetes.io/enforce=restricted
|
||||
```
|
||||
|
||||
PSA provides controls like preventing privileged containers and restricting host networking.
|
||||
|
||||
### 5. Runtime Detection (Falco)
|
||||
|
||||
* Add the Falco Helm repository:
|
||||
|
||||
```bash
|
||||
helm repo add falcosecurity https://falcosecurity.github.io/charts
|
||||
helm repo update
|
||||
```
|
||||
|
||||
* Create the `falco` namespace and install the `falco` chart:
|
||||
|
||||
```bash
|
||||
kubectl create ns falco
|
||||
helm install falco falcosecurity/falco --namespace falco
|
||||
```
|
||||
|
||||
Falco detects suspicious container behavior and system calls.
|
||||
|
||||
### 6. Network Policy & CNI
|
||||
|
||||
* If you haven't already, install a CNI that supports NetworkPolicy, such as Calico:
|
||||
|
||||
```bash
|
||||
kubectl apply -f https://docs.projectcalico.org/manifests/calico.yaml
|
||||
```
|
||||
|
||||
Alternatively, consider Cilium.
|
||||
|
||||
* Implement a default-deny NetworkPolicy:
|
||||
|
||||
```yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: default-deny
|
||||
namespace: my-namespace
|
||||
spec:
|
||||
podSelector: {}
|
||||
policyTypes:
|
||||
- Ingress
|
||||
- Egress
|
||||
```
|
||||
|
||||
Save the above as `default-deny.yaml` and apply it to your namespace:
|
||||
|
||||
```bash
|
||||
kubectl apply -f default-deny.yaml
|
||||
```
|
||||
|
||||
Follow this up with explicit `allow` policies for necessary services.
|
||||
|
||||
### 7. Cluster Hardening & Scans
|
||||
|
||||
* **kube-bench (CIS Benchmarks):**
|
||||
|
||||
```bash
|
||||
kubectl run --rm -it --image aquasec/kube-bench:latest kube-bench -- /kube-bench --version 1.23
|
||||
```
|
||||
|
||||
Refer to the kube-bench documentation for running as a Job or Pod.
|
||||
|
||||
* **kube-linter / kube-score (Static Manifest Checks):**
|
||||
|
||||
Install the CLI tool locally and analyze your Kubernetes manifests.
|
||||
|
||||
* **kube-hunter (Penetration Testing):**
|
||||
|
||||
```bash
|
||||
docker run aquasec/kube-hunter:latest --remote <K8S_API_ENDPOINT>
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
This section provides example configuration files and tips to customize the setup for a home Kubernetes cluster.
|
||||
|
||||
### Example `values.yaml` for `kube-prometheus-stack`
|
||||
|
||||
This reduces resource usage and avoids the need for external object storage for Alertmanager, which is not needed at home. It disables default dashboards you might not need initially and cuts down some Prometheus retention.
|
||||
|
||||
```yaml
|
||||
# values.yaml for kube-prometheus-stack
|
||||
|
||||
prometheus:
|
||||
prometheusSpec:
|
||||
# reduce resource rqts / limits
|
||||
resources:
|
||||
requests:
|
||||
memory: 1Gi
|
||||
cpu: 200m
|
||||
limits:
|
||||
memory: 2Gi
|
||||
cpu: 500m
|
||||
|
||||
# Reduce storage retention
|
||||
retention: 7d
|
||||
storageSpec:
|
||||
volumeClaimTemplate:
|
||||
spec:
|
||||
storageClassName: "local-path" # Or your storage class
|
||||
accessModes: ["ReadWriteOnce"]
|
||||
resources:
|
||||
requests:
|
||||
storage: 10Gi # adjust as needed
|
||||
|
||||
alertmanager:
|
||||
enabled: false # for quick home setup, send directly to telegram etc.
|
||||
grafana:
|
||||
enabled: true
|
||||
defaultDashboardsEnabled: false # Disable default dashboards
|
||||
sidecar:
|
||||
dashboards:
|
||||
enabled: true
|
||||
provider:
|
||||
folders:
|
||||
fromConfigMap: true # Load custom dashboards from ConfigMaps
|
||||
|
||||
kube-state-metrics:
|
||||
enabled: true
|
||||
|
||||
nodeExporter:
|
||||
enabled: true
|
||||
```
|
||||
|
||||
To use this configuration, save it as `values.yaml` and run:
|
||||
|
||||
```bash
|
||||
helm install kube-prometheus prometheus-community/kube-prometheus-stack --namespace monitoring -f values.yaml
|
||||
```
|
||||
|
||||
Adapt the `storageClassName` and storage amounts to your environment.
|
||||
|
||||
### Example Kyverno Policy - Disallow Root User / Require Distroless
|
||||
|
||||
This example expands on the previous policy. It requires images not run as UID 0 and suggests distroless images. It still requires privilege escalation to be forbidden:
|
||||
|
||||
```yaml
|
||||
apiVersion: kyverno.io/v1
|
||||
kind: ClusterPolicy
|
||||
metadata:
|
||||
name: require-non-root-user-and-distroless
|
||||
annotations:
|
||||
policies.kyverno.io/title: Require Non-Root User and Distroless Images
|
||||
policies.kyverno.io/category: Security
|
||||
policies.kyverno.io/severity: medium
|
||||
policies.kyverno.io/subject: Pod
|
||||
policies.kyverno.io/description: >-
|
||||
Containers should not run as root, and ideally, be based on Distroless
|
||||
images where possible. This policy requires that containers define
|
||||
`runAsUser`, and that `runAsUser` is not `0`. It also generates a warning
|
||||
if the image is not based on a distroless image, although does not reject
|
||||
the deployment.
|
||||
|
||||
spec:
|
||||
validationFailureAction: Enforce
|
||||
rules:
|
||||
- name: check-runasnonroot
|
||||
match:
|
||||
any:
|
||||
- resources:
|
||||
kinds:
|
||||
- Pod
|
||||
validate:
|
||||
message: "Containers must not run as root. Specify a non-zero runAsUser in securityContext."
|
||||
pattern:
|
||||
spec:
|
||||
containers:
|
||||
- securityContext:
|
||||
runAsUser: "!0" # not equal to zero
|
||||
- name: check-allowprivilegeescalation
|
||||
match:
|
||||
any:
|
||||
- resources:
|
||||
kinds:
|
||||
- Pod
|
||||
validate:
|
||||
message: "Containers must set allowPrivilegeEscalation to false."
|
||||
pattern:
|
||||
spec:
|
||||
containers:
|
||||
- securityContext:
|
||||
allowPrivilegeEscalation: "false"
|
||||
- name: warn-distroless
|
||||
match:
|
||||
any:
|
||||
- resources:
|
||||
kinds:
|
||||
- Pod
|
||||
verifyImages:
|
||||
- imageReferences:
|
||||
- "*" # all images
|
||||
attestations:
|
||||
- policy:
|
||||
subjects:
|
||||
- name: distroless
|
||||
conditions:
|
||||
all:
|
||||
- key: "ghcr.io/distroless/static:latest" # Example - Check if the image is distroless. You can use wildcards
|
||||
operator: In
|
||||
value: "{{ image.repoDigests }}"
|
||||
# You can add other keys and values to check
|
||||
|
||||
mutate:
|
||||
overlay:
|
||||
metadata:
|
||||
annotations:
|
||||
"image.distroless.warn": "This image isn't distroless -- see https://github.com/GoogleContainerTools/distroless"
|
||||
```
|
||||
|
||||
### Alertmanager to Telegram
|
||||
|
||||
1. **Create a Telegram Bot:** Search for `@BotFather` on Telegram. Use the `/newbot` command. Give your bot a name and a unique username. BotFather will give you the bot's API token.
|
||||
|
||||
2. **Get your Telegram Chat ID:** Send a message to your bot. Then, in a browser, go to `https://api.telegram.org/bot<YOUR_BOT_API_TOKEN>/getUpdates` (replace `<YOUR_BOT_API_TOKEN>`). The `chat.id` value in the JSON response is your chat ID.
|
||||
|
||||
3. **Create a Secret in Kubernetes:**
|
||||
|
||||
```bash
|
||||
kubectl create secret generic telegram-secrets \
|
||||
--from-literal=bot_token="<YOUR_BOT_API_TOKEN>" \
|
||||
--from-literal=chat_id="<YOUR_CHAT_ID>"
|
||||
```
|
||||
|
||||
Replace the placeholders with the correct values.
|
||||
|
||||
4. **Add Alertmanager Configuration:**
|
||||
|
||||
You'll need to patch the default Alertmanager configuration provided by `kube-prometheus-stack`. Because we disabled the Alertmanager component from the chart for simplicitly's sake, we'll instead rely on defining an additional prometheusRule that sends alerts to a webhook (and have a small sidecar container forward them to telegram).
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: PrometheusRule
|
||||
metadata:
|
||||
labels:
|
||||
prometheus: k8s
|
||||
role: alert-rules
|
||||
name: promethus-to-telegram
|
||||
namespace: monitoring
|
||||
spec:
|
||||
groups:
|
||||
- name: kubernetes-home-cluster
|
||||
rules:
|
||||
- alert: PrometheusToTelegramAlert
|
||||
annotations:
|
||||
description: 'Alert sent from Prometheus goes to telegram'
|
||||
expr: vector(1)
|
||||
labels:
|
||||
severity: critical
|
||||
for: 1s
|
||||
actions:
|
||||
- name: SendToTelegramAction
|
||||
url: 'http://localhost:8080/message'
|
||||
parameters:
|
||||
text: Alert from Prometheus: {{ .Alerts.Firing | len }} firing alert{{ if gt (len .Alerts.Firing) 1 }}s{{ end }}.\nSeverity: {{ .CommonLabels.severity }}\nDescription: {{ .CommonAnnotations.description }}
|
||||
```
|
||||
|
||||
Now you will create a deployment that runs a small webhook server forwarding these alerts to telegram:
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: prometheus-telegram
|
||||
namespace: monitoring
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: prometheus-telegram
|
||||
replicas: 1
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: prometheus-telegram
|
||||
spec:
|
||||
containers:
|
||||
- name: webhook
|
||||
image: nginx
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
- name: telegram-forwarder
|
||||
image: alpine/curl
|
||||
command: ["/bin/sh"]
|
||||
args:
|
||||
- "-c"
|
||||
- |
|
||||
while true; do
|
||||
nc -l -p 8080 | sed 's/text=/text=Alert from Prometheus: /g' | curl -sS --fail -X POST "https://api.telegram.org/bot$(TELEGRAM_BOT_TOKEN)/sendMessage" -d chat_id=$(TELEGRAM_CHAT_ID) -d "$$(cat)"
|
||||
sleep 1;
|
||||
done
|
||||
env:
|
||||
- name: TELEGRAM_BOT_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: telegram-secrets
|
||||
key: bot_token
|
||||
- name: TELEGRAM_CHAT_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: telegram-secrets
|
||||
key: chat_id
|
||||
```
|
||||
|
||||
**Explanation:**
|
||||
|
||||
* It creates an Nginx pod for a HTTP listener to avoid unnecessary security errors in Promethues,
|
||||
* The `telegram-forwarder` container uses `curl` and `nc` to forward the POST from Prometheus to the Telegram API, using the secrets for authentication.
|
||||
|
||||
## Operational Tips
|
||||
|
||||
* **Resource Management:** Set resource limits and requests for components, especially Prometheus and Grafana. Adjust scrape intervals for Prometheus to reduce load.
|
||||
* **Persistence:** Use persistent volumes for Grafana and Prometheus to preserve dashboards and historical data.
|
||||
* **Alerting:** Configure Alertmanager with a Telegram or Discord webhook for notifications. This is *simpler* than email for home setups.
|
||||
* **Trivy & Image Blocking:** To automatically block vulnerable images, integrate Trivy with admission webhooks (using Kyverno to reject deployments based on Trivy reports).
|
||||
* **Backups:** Regularly back up etcd (if self-hosting the control plane) and potentially Prometheus/Grafana data.
|
||||
|
||||
## Getting Started Quickly
|
||||
|
||||
Follow this installation order:
|
||||
|
||||
1. Install your `CNI`.
|
||||
2. Install `kube-prometheus-stack`, using `values.yaml` to reduce resources.
|
||||
3. Install Grafana and import dashboards.
|
||||
4. Enable PSA on namespaces.
|
||||
5. Install Kyverno and create deny policies.
|
||||
6. Install Trivy Operator for image scanning visibility.
|
||||
7. Install Falco for runtime detection.
|
||||
8. Run `kube-bench` and `kube-linter` for initial assessment.
|
||||
|
||||
## Useful Resources
|
||||
|
||||
* [kube-prometheus-stack (Helm)](https://github.com/prometheus-community/helm-charts)
|
||||
* [trivy-operator](https://github.com/aquasecurity/trivy-operator)
|
||||
* [Kyverno](https://kyverno.io/)
|
||||
* [Falco](https://falco.org/)
|
||||
* [Calico CNI](https://www.tigera.io/project-calico/)
|
||||
* [Aqua kube-hunter, kube-bench, kube-linter](https://www.aquasec.com/)
|
||||
|
||||
This README provides a solid foundation for setting up monitoring and security on your home Kubernetes cluster. Adapt the configurations and policies to your specific needs and experiment!
|
||||
@@ -1,216 +0,0 @@
|
||||
Here's the guide formatted as a `README.md` file, ready for a GitHub repository or local documentation.
|
||||
|
||||
```markdown
|
||||
# Optimizing Debian for K3s
|
||||
|
||||
This guide outlines steps to optimize a Debian server for running K3s (Lightweight Kubernetes). Optimization involves a combination of general Linux best practices, K3s-specific recommendations, and considerations for your specific workload.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [1. Debian Base System Optimization](#1-debian-base-system-optimization)
|
||||
- [a. Kernel Parameters (sysctl.conf)](#a-kernel-parameters-sysctlconf)
|
||||
- [b. User Limits (ulimit)](#b-user-limits-ulimit)
|
||||
- [c. Disable Unnecessary Services](#c-disable-unnecessary-services)
|
||||
- [d. Update System](#d-update-system)
|
||||
- [e. Swap Configuration](#e-swap-configuration)
|
||||
- [2. K3s Specific Optimizations](#2-k3s-specific-optimizations)
|
||||
- [a. Choose a Performant Storage Backend](#a-choose-a-performant-storage-backend)
|
||||
- [b. Containerd Tuning](#b-containerd-tuning)
|
||||
- [c. K3s Server and Agent Configuration](#c-k3s-server-and-agent-configuration)
|
||||
- [d. CNI Choice](#d-cni-choice)
|
||||
- [3. General Server Best Practices](#3-general-server-best-practices)
|
||||
- [a. Fast Storage](#a-fast-storage)
|
||||
- [b. Adequate RAM and CPU](#b-adequate-ram-and-cpu)
|
||||
- [c. Network Configuration](#c-network-configuration)
|
||||
- [d. Monitoring](#d-monitoring)
|
||||
- [e. Logging](#e-logging)
|
||||
- [4. Post-Optimization Verification](#4-post-optimization-verification)
|
||||
|
||||
---
|
||||
|
||||
## 1. Debian Base System Optimization
|
||||
|
||||
These steps are generally beneficial for any server, but particularly important for containerized environments like K3s.
|
||||
|
||||
### a. Kernel Parameters (sysctl.conf)
|
||||
|
||||
Edit `/etc/sysctl.conf` and apply changes with `sudo sysctl -p`.
|
||||
|
||||
```ini
|
||||
# Increase maximum open files (for container processes, K3s components)
|
||||
fs.inotify.max_user_watches = 524288 # For fs-based operations within containers
|
||||
fs.inotify.max_user_instances = 8192 # For fs-based operations within containers
|
||||
fs.file-max = 2097152 # Increase overall system file handle limit
|
||||
|
||||
# Increase limits for network connections
|
||||
net.core.somaxconn = 65535 # Max backlog of pending connections
|
||||
net.ipv4.tcp_tw_reuse = 1 # Allow reuse of TIME_WAIT sockets (caution: can sometimes mask issues)
|
||||
net.ipv4.tcp_fin_timeout = 30 # Reduce TIME_WAIT duration
|
||||
net.ipv4.tcp_max_syn_backlog = 65535 # Max number of remembered connection requests
|
||||
net.ipv4.tcp_keepalive_time=600 # Shorter keepalive interval
|
||||
net.ipv4.tcp_keepalive_intvl=60 # Keepalive interval
|
||||
net.ipv4.tcp_keepalive_probes=3 # Keepalive probes
|
||||
|
||||
# Increase memory limits for network buffers (especially if high network traffic)
|
||||
net.core.rmem_max = 26214400
|
||||
net.core.wmem_max = 26214400
|
||||
net.core.rmem_default = 26214400
|
||||
net.core.wmem_default = 26214400
|
||||
|
||||
# Other useful parameters
|
||||
vm.max_map_count = 262144 # Essential for Elasticsearch, MongoDB, etc.
|
||||
vm.dirty_ratio = 5 # Reduce dirty page percentage for better write performance
|
||||
vm.dirty_background_ratio = 10 # Reduce dirty page percentage for better write performance
|
||||
kernel.pid_max = 4194304 # Increase max PIDs
|
||||
```
|
||||
|
||||
**Explanation:**
|
||||
- `fs.file-max`: K3s and its deployed containers can open a large number of files. Increasing this prevents "Too many open files" errors.
|
||||
- `net.*`: These parameters help in handling a high number of concurrent network connections crucial for a Kubernetes cluster.
|
||||
- `vm.max_map_count`: Required by some applications that run on Kubernetes (e.g., Elasticsearch).
|
||||
|
||||
### b. User Limits (ulimit)
|
||||
|
||||
Edit `/etc/security/limits.conf` (or create a file like `/etc/security/limits.d/k3s.conf`) for all users, or specifically for the user K3s runs as (often `root` by default or a dedicated `k3s` user).
|
||||
|
||||
```
|
||||
# For all users (or a specific k3s user if you configure it)
|
||||
* soft nofile 65536
|
||||
* hard nofile 131072
|
||||
* soft nproc 65536
|
||||
* hard nproc 131072
|
||||
```
|
||||
**Note:** A reboot or logging out/in is often required for these changes to take effect for user sessions. Services typically pick up new limits upon restart.
|
||||
|
||||
**Explanation:**
|
||||
- `nofile` (number of open files): Sets the per-user/per-process limit. K3s and pods need a high limit.
|
||||
- `nproc` (number of processes): Each container consumes processes. A high limit prevents hitting a ceiling.
|
||||
|
||||
### c. Disable Unnecessary Services
|
||||
|
||||
Reducing background services frees up CPU, RAM, and I/O.
|
||||
```bash
|
||||
sudo systemctl disable --now apache2 # Example, replace with actual unused services
|
||||
sudo systemctl disable --now nginx # Example
|
||||
sudo systemctl disable --now cups # If not using printing
|
||||
sudo systemctl disable --now modemmanager # If not using a modem
|
||||
sudo systemctl disable --now bluetooth # If no bluetooth devices
|
||||
# Review active services using:
|
||||
# systemctl list-unit-files --type=service --state=enabled
|
||||
```
|
||||
|
||||
### d. Update System
|
||||
|
||||
Keep your system packages up-to-date for security and performance bug fixes.
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt upgrade -y
|
||||
sudo apt dist-upgrade -y # For major version changes (if applicable)
|
||||
sudo apt autoremove -y
|
||||
sudo reboot # After significant kernel or base system updates
|
||||
```
|
||||
|
||||
### e. Swap Configuration
|
||||
|
||||
**It is generally recommended to disable swap on K3s nodes, especially worker nodes.** Swapping can severely degrade performance in containerized environments due to unpredictable latency.
|
||||
|
||||
If you absolutely must have swap (e.g., very low memory server, not recommended for production):
|
||||
* Reduce swappiness: `sudo sysctl vm.swappiness=10` (or even `1`). Add `vm.swappiness = 10` to `/etc/sysctl.conf`.
|
||||
* Preferably, disable swap entirely if you have sufficient RAM:
|
||||
```bash
|
||||
sudo swapoff -a
|
||||
sudo sed -i '/ swap / s/^/#/' /etc/fstab
|
||||
```
|
||||
**WARNING:** Only disable swap if your system has sufficient RAM to handle its workload without it. If nodes run out of memory without swap, processes will be OOM-killed, leading to instability.
|
||||
|
||||
## 2. K3s Specific Optimizations
|
||||
|
||||
### a. Choose a Performant Storage Backend
|
||||
|
||||
The choice of K3s's data store significantly impacts performance and availability.
|
||||
|
||||
* **SQLite (Default):** Good for single-node setups or small, non-critical clusters. Performance can degrade with many changes or large clusters.
|
||||
* **External Database (MariaDB/MySQL, PostgreSQL):**
|
||||
* **Recommended for Production:** Offers high availability and better performance than embedded SQLite for multi-node K3s server configurations.
|
||||
* **Placement:** Place the external database on a separate server or on a dedicated, fast storage volume.
|
||||
* **External etcd:** Offers the best performance and scalability, but is more complex to manage and requires its own dedicated etcd cluster.
|
||||
|
||||
### b. Containerd Tuning
|
||||
|
||||
K3s uses containerd as its container runtime.
|
||||
|
||||
* **Fast Storage for Containerd:** Ensure the directories where containerd stores its data are on fast storage (NVMe SSDs are ideal).
|
||||
* `/var/lib/rancher/k3s/agent/containerd/io.containerd.snapshotter.v1.overlayfs` (K3s specific)
|
||||
* (`/var/lib/containerd` if using a standalone containerd setup)
|
||||
This is critical for image pulls, container startup, and overlayfs performance.
|
||||
|
||||
### c. K3s Server and Agent Configuration
|
||||
|
||||
Configure K3s using a configuration file (e.g., `/etc/rancher/k3s/config.yaml`) or command-line flags.
|
||||
|
||||
* **Disable Unused Components:** Reduce resource consumption by disabling features you don't need.
|
||||
* `--disable traefik`: If using Nginx Ingress Controller or another ingress.
|
||||
* `--disable servicelb`: If using a cloud provider Load Balancer, MetalLB, or another solution.
|
||||
* `--disable local-storage`: If using cloud provider storage, NFS, or another remote storage solution.
|
||||
* `--disable metrics-server`: If using a different metrics solution or don't need it.
|
||||
* `--disable helm-controller`: If exclusively using `kubectl` for deployments.
|
||||
|
||||
**Example `/etc/rancher/k3s/config.yaml` for a server node:**
|
||||
```yaml
|
||||
# /etc/rancher/k3s/config.yaml
|
||||
server: true
|
||||
disable:
|
||||
- traefik
|
||||
- servicelb
|
||||
- local-storage
|
||||
- metrics-server
|
||||
# Example for external database
|
||||
# datastore-endpoint: "mysql://k3s:password@tcp(db-server:3306)/kube?parseTime=true"
|
||||
```
|
||||
|
||||
### d. CNI Choice
|
||||
|
||||
K3s defaults to Flannel (with VXLAN), which is performant for many use cases.
|
||||
* **Alternative CNIs (Calico, Cilium):** If you require advanced network policies, superior performance in high-throughput scenarios, or specific networking features, consider replacing Flannel. These can offer better raw throughput or lower latency but add complexity.
|
||||
* If installing K3s, you'd typically skip Flannel installation (`--flannel-backend=none`) then install your chosen CNI.
|
||||
* Ensure your chosen CNI is optimized with the correct kernel modules and sysctls.
|
||||
|
||||
## 3. General Server Best Practices
|
||||
|
||||
### a. Fast Storage
|
||||
|
||||
* **SSD/NVMe:** Absolutely crucial for K3s performance, especially for the K3s data directory (`$K3S_DATA_DIR`, default: `/var/lib/rancher/k3s`), `/var/lib/containerd`, and the operating system itself. Pod startup times, image pulls, and database operations are heavily I/O bound.
|
||||
* **RAID:** If using multiple drives, consider RAID1 or RAID10 for redundancy and increased I/O performance.
|
||||
|
||||
### b. Adequate RAM and CPU
|
||||
|
||||
* **RAM:** K3s servers (especially with embedded SQLite) require more RAM. Worker nodes also need ample RAM for their pods. Err on the side of more RAM.
|
||||
* **CPU:** Ensure sufficient CPU cores for K3s components, containers, and your workloads.
|
||||
|
||||
### c. Network Configuration
|
||||
|
||||
* **Gigabit Ethernet (at least):** 10Gbps or faster is ideal for larger clusters or high-bandwidth applications.
|
||||
* **MTU:** Ensure consistent MTU settings across all nodes and your network infrastructure. K3s default CNI (Flannel VXLAN) might use a smaller MTU (e.g., 1450) due to encapsulation overhead. Misconfigured MTU can lead to packet fragmentation and performance issues.
|
||||
* **Jumbo Frames:** If your network supports it and all components are configured for it, jumbo frames (e.g., 9000 bytes MTU) can reduce overhead and improve throughput, but requires careful and consistent configuration.
|
||||
|
||||
### d. Monitoring
|
||||
|
||||
* **Prometheus/Grafana:** Essential for monitoring resource usage (CPU, RAM, disk I/O, network) of your nodes and K3s components. This helps identify and diagnose bottlenecks.
|
||||
* **Kube-state-metrics:** Provides metrics about Kubernetes objects.
|
||||
* **Node Exporter:** Provides system-level metrics.
|
||||
* **cAdvisor (usually bundled with container runtimes):** Provides container-level metrics.
|
||||
|
||||
### e. Logging
|
||||
|
||||
* **Centralized Logging (ELK Stack, Loki, etc.):** Stream logs from K3s components and pods to a central logging system for easier debugging, troubleshooting, and performance analysis.
|
||||
|
||||
## 4. Post-Optimization Verification
|
||||
|
||||
1. **Reboot:** After making changes to kernel parameters or `limits.conf`, a full system reboot is often the safest way to ensure all changes are fully applied.
|
||||
2. **Verify sysctl settings:** `sudo sysctl -a | grep -i <parameter_name>` (e.g., `sudo sysctl -a | grep -i fs.file-max`)
|
||||
3. **Verify ulimits:** Check `ulimit -n` and `ulimit -u` in a new shell. For specific running processes, inspect `/proc/<pid>/limits`.
|
||||
4. **Monitor Performance:** Use tools like `htop`, `iostat`, `netstat`, `dstat`, and your installed monitoring stack (Prometheus/Grafana) to observe the impact of your changes. Look for reduced CPU usage, lower I/O wait, improved network throughput, and stable memory usage.
|
||||
5. **Test Workloads:** Deploy your actual applications and perform load testing to ensure the optimizations yield the desired performance benefits under realistic conditions.
|
||||
|
||||
By diligently following these steps, you can establish a robust and highly performant Debian environment for your K3s cluster. Always test changes in a staging or development environment before applying them to production systems.
|
||||
```
|
||||
901
docs/writing-custom-resources.md
Normal file
901
docs/writing-custom-resources.md
Normal file
@@ -0,0 +1,901 @@
|
||||
# Writing Custom Resources
|
||||
|
||||
This guide explains how to create and implement custom resources in the
|
||||
homelab-operator.
|
||||
|
||||
## Overview
|
||||
|
||||
Custom resources in this operator follow a structured pattern that includes:
|
||||
|
||||
- **Specification schemas** using Zod for runtime validation
|
||||
- **Resource implementations** that extend the base `CustomResource` class
|
||||
- **Manifest creation** helpers for generating Kubernetes resources
|
||||
- **Reconciliation logic** to manage the desired state
|
||||
|
||||
## Project Structure
|
||||
|
||||
Each custom resource should be organized in its own directory under
|
||||
`src/custom-resouces/` with the following structure:
|
||||
|
||||
```
|
||||
src/custom-resouces/{resource-name}/
|
||||
├── {resource-name}.ts # Main definition file
|
||||
├── {resource-name}.schemas.ts # Zod validation schemas
|
||||
├── {resource-name}.resource.ts # Resource implementation
|
||||
└── {resource-name}.create-manifests.ts # Manifest generation helpers
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
This section walks through creating a complete custom resource from scratch.
|
||||
We'll build a `MyResource` that manages a web application with a deployment and
|
||||
service.
|
||||
|
||||
### 1. Define Your Resource
|
||||
|
||||
The main definition file registers your custom resource with the operator
|
||||
framework. This file serves as the entry point that ties together your schemas,
|
||||
implementation, and Kubernetes CRD definition.
|
||||
|
||||
Create the main definition file (`{resource-name}.ts`):
|
||||
|
||||
```typescript
|
||||
import { createCustomResourceDefinition } from "../../services/custom-resources/custom-resources.ts";
|
||||
import { GROUP } from "../../utils/consts.ts";
|
||||
|
||||
import { MyResourceResource } from "./my-resource.resource.ts";
|
||||
import { myResourceSpecSchema } from "./my-resource.schemas.ts";
|
||||
|
||||
const myResourceDefinition = createCustomResourceDefinition({
|
||||
group: GROUP, // Uses your operator's API group (homelab.mortenolsen.pro)
|
||||
version: "v1", // API version for this resource
|
||||
kind: "MyResource", // The Kubernetes kind name (PascalCase)
|
||||
names: {
|
||||
plural: "myresources", // Plural name for kubectl (lowercase)
|
||||
singular: "myresource", // Singular name for kubectl (lowercase)
|
||||
},
|
||||
spec: myResourceSpecSchema, // Zod schema for validation
|
||||
create: (options) => new MyResourceResource(options), // Factory function
|
||||
});
|
||||
|
||||
export { myResourceDefinition };
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
|
||||
- The `group` should always use the `GROUP` constant to maintain consistency
|
||||
- `kind` should be descriptive and follow Kubernetes naming conventions
|
||||
(PascalCase)
|
||||
- `names.plural` is used in kubectl commands (`kubectl get myresources`)
|
||||
- The `create` function instantiates your resource implementation when a CR is
|
||||
detected
|
||||
|
||||
### 2. Create Validation Schemas
|
||||
|
||||
Schemas define the structure and validation rules for your custom resource's
|
||||
specification. Using Zod provides runtime type safety and automatic validation
|
||||
of user input.
|
||||
|
||||
Define your spec schema (`{resource-name}.schemas.ts`):
|
||||
|
||||
```typescript
|
||||
import { z } from "zod";
|
||||
|
||||
const myResourceSpecSchema = z.object({
|
||||
// Required fields - these must be provided by users
|
||||
hostname: z.string(), // Base hostname for the application
|
||||
port: z.number().min(1).max(65535), // Container port (validated range)
|
||||
|
||||
// Optional fields with defaults - provide sensible fallbacks
|
||||
replicas: z.number().min(1).default(1), // Number of pod replicas
|
||||
|
||||
// Enums - restrict to specific values with defaults
|
||||
protocol: z.enum(["http", "https"]).default("https"),
|
||||
|
||||
// Nested objects - for complex configuration
|
||||
database: z.object({
|
||||
host: z.string(), // Database hostname
|
||||
port: z.number(), // Database port
|
||||
name: z.string(), // Database name
|
||||
}).optional(), // Entire database config is optional
|
||||
});
|
||||
|
||||
// Additional schemas for secrets, status, etc.
|
||||
// Separate schemas help organize different data types
|
||||
const myResourceSecretSchema = z.object({
|
||||
apiKey: z.string(), // API key for external services
|
||||
password: z.string(), // Database or service password
|
||||
});
|
||||
|
||||
export { myResourceSecretSchema, myResourceSpecSchema };
|
||||
```
|
||||
|
||||
**Schema Design Best Practices:**
|
||||
|
||||
- **Required vs Optional**: Make fields required only when absolutely necessary
|
||||
- **Defaults**: Provide sensible defaults to reduce user configuration burden
|
||||
- **Validation**: Use Zod's built-in validators (`.min()`, `.max()`, `.email()`,
|
||||
etc.)
|
||||
- **Enums**: Restrict values to prevent invalid configurations
|
||||
- **Nested Objects**: Group related configuration together
|
||||
- **Separate Schemas**: Create different schemas for different purposes (spec,
|
||||
secrets, status)
|
||||
|
||||
### 3. Implement the Resource
|
||||
|
||||
The resource implementation is the core of your custom resource. It contains the
|
||||
business logic for managing Kubernetes resources and maintains the desired
|
||||
state. This class extends `CustomResource` and implements the reconciliation
|
||||
logic.
|
||||
|
||||
Create the resource implementation (`{resource-name}.resource.ts`):
|
||||
|
||||
```typescript
|
||||
import type { KubernetesObject } from "@kubernetes/client-node";
|
||||
import deepEqual from "deep-equal";
|
||||
|
||||
import {
|
||||
CustomResource,
|
||||
type CustomResourceOptions,
|
||||
type SubresourceResult,
|
||||
} from "../../services/custom-resources/custom-resources.custom-resource.ts";
|
||||
import {
|
||||
ResourceReference,
|
||||
ResourceService,
|
||||
} from "../../services/resources/resources.ts";
|
||||
|
||||
import type { myResourceSpecSchema } from "./my-resource.schemas.ts";
|
||||
import {
|
||||
createDeploymentManifest,
|
||||
createServiceManifest,
|
||||
} from "./my-resource.create-manifests.ts";
|
||||
|
||||
class MyResourceResource extends CustomResource<typeof myResourceSpecSchema> {
|
||||
#deploymentResource = new ResourceReference();
|
||||
#serviceResource = new ResourceReference();
|
||||
|
||||
constructor(options: CustomResourceOptions<typeof myResourceSpecSchema>) {
|
||||
super(options);
|
||||
const resourceService = this.services.get(ResourceService);
|
||||
|
||||
// Initialize resource references
|
||||
this.#deploymentResource.current = resourceService.get({
|
||||
apiVersion: "apps/v1",
|
||||
kind: "Deployment",
|
||||
name: this.name,
|
||||
namespace: this.namespace,
|
||||
});
|
||||
|
||||
this.#serviceResource.current = resourceService.get({
|
||||
apiVersion: "v1",
|
||||
kind: "Service",
|
||||
name: this.name,
|
||||
namespace: this.namespace,
|
||||
});
|
||||
|
||||
// Set up event handlers for reconciliation
|
||||
this.#deploymentResource.on("changed", this.queueReconcile);
|
||||
this.#serviceResource.on("changed", this.queueReconcile);
|
||||
}
|
||||
|
||||
#reconcileDeployment = async (): Promise<SubresourceResult> => {
|
||||
const manifest = createDeploymentManifest({
|
||||
name: this.name,
|
||||
namespace: this.namespace,
|
||||
ref: this.ref,
|
||||
spec: this.spec,
|
||||
});
|
||||
|
||||
if (!this.#deploymentResource.current?.exists) {
|
||||
await this.#deploymentResource.current?.patch(manifest);
|
||||
return {
|
||||
ready: false,
|
||||
syncing: true,
|
||||
reason: "Creating",
|
||||
message: "Creating deployment",
|
||||
};
|
||||
}
|
||||
|
||||
if (!deepEqual(this.#deploymentResource.current.spec, manifest.spec)) {
|
||||
await this.#deploymentResource.current.patch(manifest);
|
||||
return {
|
||||
ready: false,
|
||||
syncing: true,
|
||||
reason: "Updating",
|
||||
message: "Deployment needs updates",
|
||||
};
|
||||
}
|
||||
|
||||
// Check if deployment is ready
|
||||
const deployment = this.#deploymentResource.current;
|
||||
const isReady =
|
||||
deployment.status?.readyReplicas === deployment.status?.replicas;
|
||||
|
||||
return {
|
||||
ready: isReady,
|
||||
reason: isReady ? "Ready" : "Pending",
|
||||
message: isReady ? "Deployment is ready" : "Waiting for pods to be ready",
|
||||
};
|
||||
};
|
||||
|
||||
#reconcileService = async (): Promise<SubresourceResult> => {
|
||||
const manifest = createServiceManifest({
|
||||
name: this.name,
|
||||
namespace: this.namespace,
|
||||
ref: this.ref,
|
||||
spec: this.spec,
|
||||
});
|
||||
|
||||
if (!deepEqual(this.#serviceResource.current?.spec, manifest.spec)) {
|
||||
await this.#serviceResource.current?.patch(manifest);
|
||||
return {
|
||||
ready: false,
|
||||
syncing: true,
|
||||
reason: "Updating",
|
||||
message: "Service needs updates",
|
||||
};
|
||||
}
|
||||
|
||||
return { ready: true };
|
||||
};
|
||||
|
||||
public reconcile = async () => {
|
||||
if (!this.exists || this.metadata.deletionTimestamp) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reconcile subresources
|
||||
await this.reconcileSubresource("Deployment", this.#reconcileDeployment);
|
||||
await this.reconcileSubresource("Service", this.#reconcileService);
|
||||
|
||||
// Update overall ready condition
|
||||
const deploymentReady =
|
||||
this.conditions.get("Deployment")?.status === "True";
|
||||
const serviceReady = this.conditions.get("Service")?.status === "True";
|
||||
|
||||
await this.conditions.set("Ready", {
|
||||
status: deploymentReady && serviceReady ? "True" : "False",
|
||||
reason: deploymentReady && serviceReady ? "Ready" : "Pending",
|
||||
message: deploymentReady && serviceReady
|
||||
? "All resources are ready"
|
||||
: "Waiting for resources to be ready",
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export { MyResourceResource };
|
||||
```
|
||||
|
||||
**Resource Implementation Breakdown:**
|
||||
|
||||
**Constructor Setup:**
|
||||
|
||||
- **Resource References**: Create `ResourceReference` objects to track managed
|
||||
Kubernetes resources
|
||||
- **Service Access**: Use dependency injection to access operator services
|
||||
(`ResourceService`)
|
||||
- **Event Handlers**: Listen for changes in managed resources to trigger
|
||||
reconciliation
|
||||
- **Resource Registration**: Register references for Deployment and Service that
|
||||
will be managed
|
||||
|
||||
**Reconciliation Methods:**
|
||||
|
||||
- **`#reconcileDeployment`**: Manages the application's Deployment resource
|
||||
- Creates manifests using helper functions
|
||||
- Checks if resource exists and creates/updates as needed
|
||||
- Uses `deepEqual` to avoid unnecessary updates
|
||||
- Returns status indicating readiness state
|
||||
- **`#reconcileService`**: Manages the Service resource for network access
|
||||
- Similar pattern to deployment but typically simpler
|
||||
- Services are usually ready immediately after creation
|
||||
|
||||
**Main Reconcile Loop:**
|
||||
|
||||
- **Deletion Check**: Early return if resource is being deleted
|
||||
- **Subresource Management**: Calls individual reconciliation methods
|
||||
- **Condition Updates**: Aggregates status from all subresources
|
||||
- **Status Reporting**: Updates the overall "Ready" condition
|
||||
|
||||
**Key Design Patterns:**
|
||||
|
||||
- **Private Methods**: Use `#` for private reconciliation methods
|
||||
- **Async/Await**: All reconciliation is asynchronous
|
||||
- **Resource References**: Track external resources with type safety
|
||||
- **Condition Management**: Provide clear status through Kubernetes conditions
|
||||
- **Event-Driven**: React to changes in managed resources automatically
|
||||
|
||||
### 4. Create Manifest Helpers
|
||||
|
||||
Manifest helpers are pure functions that generate Kubernetes resource
|
||||
definitions. They transform your custom resource's specification into standard
|
||||
Kubernetes objects. This separation keeps your reconciliation logic clean and
|
||||
makes manifests easy to test and modify.
|
||||
|
||||
Define manifest creation functions (`{resource-name}.create-manifests.ts`):
|
||||
|
||||
```typescript
|
||||
type CreateDeploymentManifestOptions = {
|
||||
name: string;
|
||||
namespace: string;
|
||||
ref: any; // Owner reference
|
||||
spec: {
|
||||
hostname: string;
|
||||
port: number;
|
||||
replicas: number;
|
||||
};
|
||||
};
|
||||
|
||||
const createDeploymentManifest = (
|
||||
options: CreateDeploymentManifestOptions,
|
||||
) => ({
|
||||
apiVersion: "apps/v1",
|
||||
kind: "Deployment",
|
||||
metadata: {
|
||||
name: options.name,
|
||||
namespace: options.namespace,
|
||||
ownerReferences: [options.ref],
|
||||
},
|
||||
spec: {
|
||||
replicas: options.spec.replicas,
|
||||
selector: {
|
||||
matchLabels: {
|
||||
app: options.name,
|
||||
},
|
||||
},
|
||||
template: {
|
||||
metadata: {
|
||||
labels: {
|
||||
app: options.name,
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
containers: [
|
||||
{
|
||||
name: options.name,
|
||||
image: "nginx:latest",
|
||||
ports: [
|
||||
{
|
||||
containerPort: options.spec.port,
|
||||
},
|
||||
],
|
||||
env: [
|
||||
{
|
||||
name: "HOSTNAME",
|
||||
value: options.spec.hostname,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
type CreateServiceManifestOptions = {
|
||||
name: string;
|
||||
namespace: string;
|
||||
ref: any;
|
||||
spec: {
|
||||
port: number;
|
||||
};
|
||||
};
|
||||
|
||||
const createServiceManifest = (options: CreateServiceManifestOptions) => ({
|
||||
apiVersion: "v1",
|
||||
kind: "Service",
|
||||
metadata: {
|
||||
name: options.name,
|
||||
namespace: options.namespace,
|
||||
ownerReferences: [options.ref],
|
||||
},
|
||||
spec: {
|
||||
selector: {
|
||||
app: options.name,
|
||||
},
|
||||
ports: [
|
||||
{
|
||||
port: 80,
|
||||
targetPort: options.spec.port,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
export { createDeploymentManifest, createServiceManifest };
|
||||
```
|
||||
|
||||
**Manifest Helper Patterns:**
|
||||
|
||||
**Type Definitions:**
|
||||
|
||||
- **Options Types**: Define clear interfaces for function parameters
|
||||
- **Structured Input**: Group related parameters in nested objects
|
||||
- **Type Safety**: Leverage TypeScript to catch configuration errors at compile
|
||||
time
|
||||
|
||||
**Deployment Manifest:**
|
||||
|
||||
- **Owner References**: Ensures garbage collection when parent resource is
|
||||
deleted
|
||||
- **Labels & Selectors**: Consistent labeling for pod selection and organization
|
||||
- **Container Configuration**: Maps custom resource spec to container settings
|
||||
- **Environment Variables**: Passes configuration from spec to running
|
||||
containers
|
||||
- **Port Configuration**: Exposes application ports based on spec
|
||||
|
||||
**Service Manifest:**
|
||||
|
||||
- **Service Discovery**: Creates stable network endpoint for the deployment
|
||||
- **Port Mapping**: Routes external traffic to container ports
|
||||
- **Selector Matching**: Uses same labels as deployment for proper routing
|
||||
- **Owner References**: Links service lifecycle to custom resource
|
||||
|
||||
**Best Practices for Manifest Helpers:**
|
||||
|
||||
- **Pure Functions**: No side effects, same input always produces same output
|
||||
- **Immutable Objects**: Return new objects rather than modifying inputs
|
||||
- **Validation**: Let TypeScript catch type mismatches
|
||||
- **Consistent Naming**: Use predictable patterns for resource names
|
||||
- **Owner References**: Always set for proper cleanup
|
||||
- **Documentation**: Comment non-obvious configuration choices
|
||||
|
||||
### 5. Register Your Resource
|
||||
|
||||
Add your resource to `src/custom-resouces/custom-resources.ts`:
|
||||
|
||||
```typescript
|
||||
import { myResourceDefinition } from "./my-resource/my-resource.ts";
|
||||
|
||||
const customResources = [
|
||||
// ... existing resources
|
||||
myResourceDefinition,
|
||||
];
|
||||
```
|
||||
|
||||
## Core Concepts
|
||||
|
||||
These fundamental patterns are used throughout the operator framework.
|
||||
Understanding them is essential for building robust custom resources.
|
||||
|
||||
### Resource References
|
||||
|
||||
`ResourceReference` objects provide a strongly-typed way to track and manage
|
||||
Kubernetes resources that your custom resource creates or depends on. They
|
||||
automatically handle resource watching, caching, and change notifications.
|
||||
|
||||
Use `ResourceReference` to manage related Kubernetes resources:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
ResourceReference,
|
||||
ResourceService,
|
||||
} from "../../services/resources/resources.ts";
|
||||
|
||||
class MyResource extends CustomResource<typeof myResourceSpecSchema> {
|
||||
#deploymentResource = new ResourceReference();
|
||||
|
||||
constructor(options: CustomResourceOptions<typeof myResourceSpecSchema>) {
|
||||
super(options);
|
||||
const resourceService = this.services.get(ResourceService);
|
||||
|
||||
this.#deploymentResource.current = resourceService.get({
|
||||
apiVersion: "apps/v1",
|
||||
kind: "Deployment",
|
||||
name: this.name,
|
||||
namespace: this.namespace,
|
||||
});
|
||||
|
||||
// Listen for changes
|
||||
this.#deploymentResource.on("changed", this.queueReconcile);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Why Resource References Matter:**
|
||||
|
||||
- **Automatic Watching**: Changes to referenced resources trigger reconciliation
|
||||
- **Type Safety**: Get compile-time checking for resource properties
|
||||
- **Lifecycle Management**: Easily check if resources exist and their current
|
||||
state
|
||||
- **Event Handling**: React to external changes without polling
|
||||
- **Caching**: Avoid repeated API calls for the same resource data
|
||||
|
||||
### Conditions
|
||||
|
||||
Kubernetes conditions provide a standardized way to communicate resource status.
|
||||
They follow the Kubernetes convention of expressing current state, reasons for
|
||||
that state, and human-readable messages. Conditions are crucial for operators
|
||||
and users to understand what's happening with resources.
|
||||
|
||||
Use conditions to track the status of your resource:
|
||||
|
||||
```typescript
|
||||
// Set a condition
|
||||
await this.conditions.set("Ready", {
|
||||
status: "True",
|
||||
reason: "AllResourcesReady",
|
||||
message: "All subresources are ready",
|
||||
});
|
||||
|
||||
// Get a condition
|
||||
const isReady = this.conditions.get("Ready")?.status === "True";
|
||||
```
|
||||
|
||||
**Condition Best Practices:**
|
||||
|
||||
- **Standard Names**: Use common condition types like "Ready", "Available",
|
||||
"Progressing"
|
||||
- **Clear Status**: Use "True", "False", or "Unknown" following Kubernetes
|
||||
conventions
|
||||
- **Descriptive Reasons**: Provide specific reason codes for troubleshooting
|
||||
- **Helpful Messages**: Include actionable information for users
|
||||
- **Consistent Updates**: Always update conditions during reconciliation
|
||||
|
||||
### Subresource Reconciliation
|
||||
|
||||
The `reconcileSubresource` method provides a standardized way to manage
|
||||
individual components of your custom resource. It automatically handles
|
||||
condition updates, error management, and status aggregation. This pattern keeps
|
||||
your main reconciliation loop clean and ensures consistent error handling.
|
||||
|
||||
Use `reconcileSubresource` to manage individual components:
|
||||
|
||||
```typescript
|
||||
public reconcile = async () => {
|
||||
// This automatically manages conditions and error handling
|
||||
await this.reconcileSubresource("Deployment", this.#reconcileDeployment);
|
||||
await this.reconcileSubresource("Service", this.#reconcileService);
|
||||
};
|
||||
```
|
||||
|
||||
**Subresource Reconciliation Benefits:**
|
||||
|
||||
- **Automatic Condition Management**: Sets conditions based on reconciliation
|
||||
results
|
||||
- **Error Isolation**: Failures in one subresource don't stop others
|
||||
- **Status Aggregation**: Combines individual component status into overall
|
||||
status
|
||||
- **Consistent Patterns**: Same error handling and retry logic across all
|
||||
components
|
||||
- **Observability**: Clear visibility into which components are having issues
|
||||
|
||||
### Deep Equality Checks
|
||||
|
||||
Deep equality checks prevent unnecessary API calls and resource churn.
|
||||
Kubernetes resources should only be updated when their desired state actually
|
||||
differs from their current state. This improves performance and reduces cluster
|
||||
load.
|
||||
|
||||
Use `deepEqual` to avoid unnecessary updates:
|
||||
|
||||
```typescript
|
||||
import deepEqual from "deep-equal";
|
||||
|
||||
if (!deepEqual(currentResource.spec, desiredManifest.spec)) {
|
||||
await currentResource.patch(desiredManifest);
|
||||
}
|
||||
```
|
||||
|
||||
**Deep Equality Benefits:**
|
||||
|
||||
- **Performance**: Avoids unnecessary API calls to Kubernetes
|
||||
- **Reduced Churn**: Prevents resource version conflicts and unnecessary events
|
||||
- **Stability**: Reduces reconciliation loops and system noise
|
||||
- **Efficiency**: Lets you focus compute on actual changes
|
||||
- **Observability**: Cleaner audit logs with only meaningful changes
|
||||
|
||||
**When to Use Deep Equality:**
|
||||
|
||||
- **Spec Comparisons**: Before updating any Kubernetes resource
|
||||
- **Status Updates**: Only update status when values actually change
|
||||
- **Metadata Updates**: Check labels and annotations before patching
|
||||
- **Complex Objects**: Especially useful for nested configuration objects
|
||||
|
||||
## Advanced Patterns
|
||||
|
||||
These patterns handle more complex scenarios like secret management, resource
|
||||
dependencies, and sophisticated error handling. Use these when building
|
||||
production-ready operators that need to handle real-world complexity.
|
||||
|
||||
### Working with Secrets
|
||||
|
||||
Many resources need to manage secrets. Here's a pattern for secret management:
|
||||
|
||||
```typescript
|
||||
import { SecretService } from "../../services/secrets/secrets.ts";
|
||||
|
||||
class MyResource extends CustomResource<typeof myResourceSpecSchema> {
|
||||
constructor(options: CustomResourceOptions<typeof myResourceSpecSchema>) {
|
||||
super(options);
|
||||
const secretService = this.services.get(SecretService);
|
||||
|
||||
// Get or create a secret
|
||||
this.secretRef = secretService.get({
|
||||
name: `${this.name}-secret`,
|
||||
namespace: this.namespace,
|
||||
});
|
||||
}
|
||||
|
||||
#ensureSecret = async () => {
|
||||
const secretData = {
|
||||
apiKey: generateApiKey(),
|
||||
password: generatePassword(),
|
||||
};
|
||||
|
||||
if (!this.secretRef.current?.exists) {
|
||||
await this.secretRef.current?.patch({
|
||||
apiVersion: "v1",
|
||||
kind: "Secret",
|
||||
metadata: {
|
||||
name: this.secretRef.current.name,
|
||||
namespace: this.secretRef.current.namespace,
|
||||
ownerReferences: [this.ref],
|
||||
},
|
||||
data: secretData,
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Cross-Resource Dependencies
|
||||
|
||||
When your resource depends on other custom resources:
|
||||
|
||||
```typescript
|
||||
class MyResource extends CustomResource<typeof myResourceSpecSchema> {
|
||||
#dependentResource = new ResourceReference();
|
||||
|
||||
constructor(options: CustomResourceOptions<typeof myResourceSpecSchema>) {
|
||||
super(options);
|
||||
const resourceService = this.services.get(ResourceService);
|
||||
|
||||
// Reference another custom resource
|
||||
this.#dependentResource.current = resourceService.get({
|
||||
apiVersion: "homelab.mortenolsen.pro/v1",
|
||||
kind: "PostgresDatabase",
|
||||
name: this.spec.database,
|
||||
namespace: this.namespace,
|
||||
});
|
||||
|
||||
this.#dependentResource.on("changed", this.queueReconcile);
|
||||
}
|
||||
|
||||
#reconcileApp = async (): Promise<SubresourceResult> => {
|
||||
// Check if dependency is ready
|
||||
const dependency = this.#dependentResource.current;
|
||||
if (!dependency?.exists) {
|
||||
return {
|
||||
ready: false,
|
||||
failed: true,
|
||||
reason: "MissingDependency",
|
||||
message: `PostgresDatabase ${this.spec.database} not found`,
|
||||
};
|
||||
}
|
||||
|
||||
const dependencyReady = dependency.status?.conditions?.find(
|
||||
(c) => c.type === "Ready" && c.status === "True",
|
||||
);
|
||||
|
||||
if (!dependencyReady) {
|
||||
return {
|
||||
ready: false,
|
||||
reason: "WaitingForDependency",
|
||||
message:
|
||||
`Waiting for PostgresDatabase ${this.spec.database} to be ready`,
|
||||
};
|
||||
}
|
||||
|
||||
// Continue with reconciliation...
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
Proper error handling in reconciliation:
|
||||
|
||||
```typescript
|
||||
#reconcileDeployment = async (): Promise<SubresourceResult> => {
|
||||
try {
|
||||
// Reconciliation logic...
|
||||
return { ready: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
ready: false,
|
||||
failed: true,
|
||||
reason: 'ReconciliationError',
|
||||
message: `Failed to reconcile deployment: ${error.message}`,
|
||||
};
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Example Usage
|
||||
|
||||
Once your custom resource is implemented and registered, users can create
|
||||
instances using standard Kubernetes manifests. The operator will automatically
|
||||
detect new resources and begin reconciliation based on your implementation
|
||||
logic.
|
||||
|
||||
```yaml
|
||||
apiVersion: homelab.mortenolsen.pro/v1
|
||||
kind: MyResource
|
||||
metadata:
|
||||
name: my-app
|
||||
namespace: default
|
||||
spec:
|
||||
hostname: my-app.example.com
|
||||
port: 8080
|
||||
replicas: 3
|
||||
protocol: https
|
||||
database:
|
||||
host: postgres.default.svc.cluster.local
|
||||
port: 5432
|
||||
name: myapp
|
||||
```
|
||||
|
||||
**What happens when this resource is created:**
|
||||
|
||||
1. **Validation**: The operator validates the spec against your Zod schema
|
||||
2. **Resource Creation**: Your `MyResourceResource` class is instantiated
|
||||
3. **Reconciliation**: The operator creates a Deployment with 3 replicas and a
|
||||
Service
|
||||
4. **Status Updates**: Conditions are set to track deployment and service
|
||||
readiness
|
||||
5. **Event Handling**: The operator watches for changes and re-reconciles as
|
||||
needed
|
||||
|
||||
Users can then monitor the resource status with:
|
||||
|
||||
```bash
|
||||
kubectl get myresources my-app -o yaml
|
||||
kubectl describe myresource my-app
|
||||
```
|
||||
|
||||
## Real Examples
|
||||
|
||||
These examples show how the patterns described above are used in practice within
|
||||
the homelab-operator.
|
||||
|
||||
### Simple Resource: Domain
|
||||
|
||||
The `Domain` resource demonstrates a straightforward custom resource that
|
||||
manages external dependencies. It creates and manages TLS certificates through
|
||||
cert-manager and configures Istio gateways for HTTPS traffic routing.
|
||||
|
||||
**What it does:**
|
||||
|
||||
- Creates a cert-manager Certificate for TLS termination
|
||||
- Configures an Istio Gateway for traffic routing
|
||||
- Manages the lifecycle of both resources through owner references
|
||||
- Provides wildcard certificate support for subdomains
|
||||
|
||||
```yaml
|
||||
apiVersion: homelab.mortenolsen.pro/v1
|
||||
kind: Domain
|
||||
metadata:
|
||||
name: homelab
|
||||
namespace: homelab
|
||||
spec:
|
||||
hostname: local.olsen.cloud # Domain for certificate and gateway
|
||||
issuer: letsencrypt-prod # cert-manager ClusterIssuer to use
|
||||
```
|
||||
|
||||
**Key Implementation Features:**
|
||||
|
||||
- **CRD Dependency Checking**: Validates that cert-manager and Istio CRDs exist
|
||||
- **Cross-Namespace Resources**: Certificate is created in the istio-ingress
|
||||
namespace
|
||||
- **Status Aggregation**: Combines certificate and gateway readiness into
|
||||
overall status
|
||||
- **Wildcard Support**: Automatically configures `*.hostname` for subdomains
|
||||
|
||||
### Complex Resource: AuthentikServer
|
||||
|
||||
The `AuthentikServer` resource showcases a complex custom resource with multiple
|
||||
dependencies and sophisticated reconciliation logic. It deploys a complete
|
||||
identity provider solution with database and Redis dependencies.
|
||||
|
||||
**What it does:**
|
||||
|
||||
- Deploys Authentik identity provider with proper configuration
|
||||
- Manages database schema and user creation
|
||||
- Configures Redis connection for session storage
|
||||
- Sets up domain integration for SSO endpoints
|
||||
- Handles secret generation and rotation
|
||||
|
||||
```yaml
|
||||
apiVersion: homelab.mortenolsen.pro/v1
|
||||
kind: AuthentikServer
|
||||
metadata:
|
||||
name: homelab
|
||||
namespace: homelab
|
||||
spec:
|
||||
domain: homelab # References a Domain resource
|
||||
database: test2 # References a PostgresDatabase resource
|
||||
redis: redis # References a Redis connection
|
||||
```
|
||||
|
||||
**Key Implementation Features:**
|
||||
|
||||
- **Resource Dependencies**: Waits for Domain, PostgresDatabase, and Redis
|
||||
resources
|
||||
- **Secret Management**: Generates and manages API keys, passwords, and tokens
|
||||
- **Service Configuration**: Creates comprehensive Kubernetes manifests
|
||||
(Deployment, Service, Ingress)
|
||||
- **Health Checking**: Monitors application readiness and database connectivity
|
||||
- **Cross-Resource Communication**: Uses other custom resources' status and
|
||||
outputs
|
||||
|
||||
### Database Resource: PostgresDatabase
|
||||
|
||||
The `PostgresDatabase` resource illustrates how to manage stateful resources and
|
||||
external system integration. It creates databases within an existing PostgreSQL
|
||||
instance and manages user permissions.
|
||||
|
||||
**What it does:**
|
||||
|
||||
- Creates a new database in an existing PostgreSQL server
|
||||
- Generates dedicated database user with appropriate permissions
|
||||
- Manages connection secrets for applications
|
||||
- Handles database cleanup and user removal
|
||||
|
||||
```yaml
|
||||
apiVersion: homelab.mortenolsen.pro/v1
|
||||
kind: PostgresDatabase
|
||||
metadata:
|
||||
name: test2
|
||||
namespace: homelab
|
||||
spec:
|
||||
connection: homelab/db # References PostgreSQL connection (namespace/name)
|
||||
```
|
||||
|
||||
**Key Implementation Features:**
|
||||
|
||||
- **External System Integration**: Connects to existing PostgreSQL instances
|
||||
- **User Management**: Creates database-specific users with minimal required
|
||||
permissions
|
||||
- **Secret Generation**: Provides connection details to consuming applications
|
||||
- **Cleanup Handling**: Safely removes databases and users when resource is
|
||||
deleted
|
||||
- **Connection Validation**: Verifies connectivity before marking as ready
|
||||
|
||||
**Common Patterns Across Examples:**
|
||||
|
||||
- **Owner References**: All managed resources have proper ownership for garbage
|
||||
collection
|
||||
- **Condition Management**: Consistent status reporting through Kubernetes
|
||||
conditions
|
||||
- **Resource Dependencies**: Graceful handling of missing or unready
|
||||
dependencies
|
||||
- **Secret Management**: Secure generation and storage of credentials
|
||||
- **Cross-Resource Integration**: Resources reference and depend on each other
|
||||
appropriately
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Validation**: Always use Zod schemas for comprehensive spec validation
|
||||
2. **Idempotency**: Use `deepEqual` checks to avoid unnecessary updates
|
||||
3. **Conditions**: Provide clear status information through conditions
|
||||
4. **Owner References**: Always set owner references for created resources
|
||||
5. **Error Handling**: Provide meaningful error messages and failure reasons
|
||||
6. **Dependencies**: Handle missing dependencies gracefully
|
||||
7. **Cleanup**: Leverage Kubernetes garbage collection through owner references
|
||||
8. **Testing**: Create test manifests in `test-manifests/` for your resources
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Resource not reconciling**: Check if the resource is properly registered in
|
||||
`custom-resources.ts`
|
||||
- **Validation errors**: Ensure your Zod schema matches the expected spec
|
||||
structure
|
||||
- **Missing dependencies**: Verify that referenced resources exist and are ready
|
||||
- **Owner reference issues**: Make sure `ownerReferences` are set correctly for
|
||||
garbage collection
|
||||
- **Condition not updating**: Ensure you're calling `this.conditions.set()` with
|
||||
proper status values
|
||||
|
||||
For more examples, refer to the existing custom resources in
|
||||
`src/custom-resouces/`.
|
||||
@@ -1,10 +0,0 @@
|
||||
FROM alpine/git:latest@sha256:bd54f921f6d803dfa3a4fe14b7defe36df1b71349a3e416547e333aa960f86e3
|
||||
# or a more specific image like a Debian slim or Ubuntu base image.
|
||||
RUN apk add --no-cache restic fuse-overlayfs
|
||||
WORKDIR /app
|
||||
|
||||
COPY backup.sh /app/backup.sh
|
||||
COPY cleanup.sh /app/cleanup.sh
|
||||
|
||||
# Make scripts executable
|
||||
RUN chmod +x /app/backup.sh /app/cleanup.sh
|
||||
@@ -1,35 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -e
|
||||
|
||||
if [ -z "$RESTIC_PASSWORD" ]; then
|
||||
echo "Error: RESTIC_PASSWORD environment variable is not set." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RESTIC_REPOSITORY="/mnt/backup"
|
||||
SOURCE_DIR="/mnt/source"
|
||||
|
||||
mkdir -p "$SOURCE_DIR"
|
||||
mkdir -p "/mnt/backup"
|
||||
|
||||
echo "Starting Restic backup from $SOURCE_DIR to $RESTIC_REPOSITORY"
|
||||
|
||||
echo "Checking/Initializing Restic repository..."
|
||||
restic init --repo "$RESTIC_REPOSITORY" || true
|
||||
|
||||
echo "Running Restic backup..."
|
||||
restic backup \
|
||||
-r "$RESTIC_REPOSITORY" \
|
||||
"$SOURCE_DIR" \
|
||||
--verbose \
|
||||
--tag "daily"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "Restic backup completed successfully!"
|
||||
else
|
||||
echo "Restic backup failed!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Backup finished."
|
||||
@@ -1,42 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -e
|
||||
|
||||
if [ -z "$RESTIC_PASSWORD" ]; then
|
||||
echo "Error: RESTIC_PASSWORD environment variable is not set." >&2
|
||||
exit 1
|
||||
fi
|
||||
RESTIC_REPOSITORY="/mnt/backup"
|
||||
|
||||
echo "Starting Restic cleanup for repository $RESTIC_REPOSITORY"
|
||||
|
||||
echo "Checking Restic repository existence..."
|
||||
restic snapshots --repository "$RESTIC_REPOSITORY"
|
||||
|
||||
# Restic forget and prune strategy
|
||||
# --keep-daily 7: Keep 7 most recent daily backups
|
||||
# --keep-weekly 4: Keep 4 most recent weekly backups
|
||||
# --keep-monthly 6: Keep 6 most recent monthly backups
|
||||
# --keep-yearly 1: Keep 1 most recent yearly backup
|
||||
# --prune: Actually delete data that's no longer referenced
|
||||
# --group-by host,paths: Group snapshots for retention by host and path.
|
||||
echo "Running Restic forget and prune..."
|
||||
restic forget \
|
||||
--group-by host,paths \
|
||||
--tag "daily" \
|
||||
--keep-daily 7 \
|
||||
--keep-weekly 4 \
|
||||
--keep-monthly 6 \
|
||||
--keep-yearly 1 \
|
||||
--prune \
|
||||
--verbose \
|
||||
--repository "$RESTIC_REPOSITORY"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "Restic cleanup completed successfully!"
|
||||
else
|
||||
echo "Restic cleanup failed!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Cleanup finished."
|
||||
39
images/operator/.gitignore
vendored
39
images/operator/.gitignore
vendored
@@ -1,39 +0,0 @@
|
||||
# dependencies (bun install)
|
||||
node_modules
|
||||
|
||||
# output
|
||||
out
|
||||
dist
|
||||
*.tgz
|
||||
|
||||
# code coverage
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# logs
|
||||
logs
|
||||
_.log
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# caches
|
||||
.eslintcache
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
|
||||
/data/
|
||||
|
||||
/cloudflare.yaml
|
||||
/secret.*.yaml
|
||||
@@ -1,8 +0,0 @@
|
||||
FROM node:23-slim@sha256:86191b94d2a163be41f3dc7fe5e5fcaca8ba2f1be7275d98a06343483c17414a
|
||||
RUN corepack enable
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
COPY patches ./patches
|
||||
RUN pnpm install --frozen-lockfile --prod
|
||||
COPY . .
|
||||
CMD ["node", "src/index.ts"]
|
||||
@@ -1,42 +0,0 @@
|
||||
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';
|
||||
|
||||
class BootstrapService {
|
||||
#services: Services;
|
||||
|
||||
constructor(services: Services) {
|
||||
this.#services = services;
|
||||
}
|
||||
public get namespaces() {
|
||||
return this.#services.get(NamespaceService);
|
||||
}
|
||||
|
||||
public get repos() {
|
||||
return this.#services.get(RepoService);
|
||||
}
|
||||
|
||||
public get releases() {
|
||||
return this.#services.get(ReleaseService);
|
||||
}
|
||||
|
||||
public get 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.cloudflareTunnel.ensure({
|
||||
spec: {},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export { BootstrapService };
|
||||
@@ -1,45 +0,0 @@
|
||||
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: Namespace;
|
||||
#istioSystem: Namespace;
|
||||
#certManager: Namespace;
|
||||
|
||||
constructor(services: Services) {
|
||||
const resourceService = services.get(ResourceService);
|
||||
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);
|
||||
}
|
||||
|
||||
public get homelab() {
|
||||
return this.#homelab;
|
||||
}
|
||||
public get istioSystem() {
|
||||
return this.#istioSystem;
|
||||
}
|
||||
public get certManager() {
|
||||
return this.#certManager;
|
||||
}
|
||||
|
||||
public ensure = async () => {
|
||||
await this.#homelab.ensure({
|
||||
metadata: {
|
||||
labels: {
|
||||
'istio-injection': 'enabled',
|
||||
},
|
||||
},
|
||||
});
|
||||
await this.#istioSystem.ensure({});
|
||||
await this.#certManager.ensure({});
|
||||
};
|
||||
}
|
||||
|
||||
export { NamespaceService };
|
||||
@@ -1,141 +0,0 @@
|
||||
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: HelmRelease;
|
||||
#istioBase: HelmRelease;
|
||||
#istiod: HelmRelease;
|
||||
#istioGateway: HelmRelease;
|
||||
|
||||
constructor(services: Services) {
|
||||
this.#services = services;
|
||||
const resourceService = services.get(ResourceService);
|
||||
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);
|
||||
this.#istioGateway.on('changed', this.ensure);
|
||||
}
|
||||
|
||||
public get certManager() {
|
||||
return this.#certManager;
|
||||
}
|
||||
public get istioBase() {
|
||||
return this.#istioBase;
|
||||
}
|
||||
public get istiod() {
|
||||
return this.#istiod;
|
||||
}
|
||||
|
||||
public ensure = async () => {
|
||||
const namespaceService = this.#services.get(NamespaceService);
|
||||
const repoService = this.#services.get(RepoService);
|
||||
await this.#certManager.ensure({
|
||||
spec: {
|
||||
targetNamespace: namespaceService.certManager.name,
|
||||
interval: '1h',
|
||||
values: {
|
||||
installCRDs: true,
|
||||
},
|
||||
chart: {
|
||||
spec: {
|
||||
chart: 'cert-manager',
|
||||
version: 'v1.18.2',
|
||||
sourceRef: {
|
||||
apiVersion: 'source.toolkit.fluxcd.io/v1',
|
||||
kind: 'HelmRepository',
|
||||
name: repoService.jetstack.name,
|
||||
namespace: repoService.jetstack.namespace,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await this.#istioBase.ensure({
|
||||
spec: {
|
||||
targetNamespace: namespaceService.istioSystem.name,
|
||||
interval: '1h',
|
||||
values: {
|
||||
defaultRevision: 'default',
|
||||
profile: 'ambient',
|
||||
},
|
||||
chart: {
|
||||
spec: {
|
||||
chart: 'base',
|
||||
version: '1.24.3',
|
||||
sourceRef: {
|
||||
apiVersion: 'source.toolkit.fluxcd.io/v1',
|
||||
kind: 'HelmRepository',
|
||||
name: repoService.istio.name,
|
||||
namespace: repoService.istio.namespace,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await this.#istiod.ensure({
|
||||
spec: {
|
||||
targetNamespace: namespaceService.istioSystem.name,
|
||||
interval: '1h',
|
||||
dependsOn: [
|
||||
{
|
||||
name: this.#istioBase.name,
|
||||
namespace: this.#istioBase.namespace,
|
||||
},
|
||||
],
|
||||
chart: {
|
||||
spec: {
|
||||
chart: 'istiod',
|
||||
version: '1.24.3',
|
||||
sourceRef: {
|
||||
apiVersion: 'source.toolkit.fluxcd.io/v1',
|
||||
kind: 'HelmRepository',
|
||||
name: repoService.istio.name,
|
||||
namespace: repoService.istio.namespace,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await this.#istioGateway.ensure({
|
||||
spec: {
|
||||
targetNamespace: NAMESPACE,
|
||||
interval: '1h',
|
||||
dependsOn: [
|
||||
{
|
||||
name: this.#istioBase.name,
|
||||
namespace: this.#istioBase.namespace,
|
||||
},
|
||||
{
|
||||
name: this.#istiod.name,
|
||||
namespace: this.#istiod.namespace,
|
||||
},
|
||||
],
|
||||
chart: {
|
||||
spec: {
|
||||
chart: 'gateway',
|
||||
version: '1.24.3',
|
||||
sourceRef: {
|
||||
apiVersion: 'source.toolkit.fluxcd.io/v1',
|
||||
kind: 'HelmRepository',
|
||||
name: repoService.istio.name,
|
||||
namespace: repoService.istio.namespace,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export { ReleaseService };
|
||||
@@ -1,72 +0,0 @@
|
||||
import type { Services } from '../../utils/service.ts';
|
||||
import { ResourceService } from '../../services/resources/resources.ts';
|
||||
import { NAMESPACE } from '../../utils/consts.ts';
|
||||
|
||||
import { HelmRepo } from '#resources/flux/helm-repo/helm-repo.ts';
|
||||
|
||||
class RepoService {
|
||||
#jetstack: HelmRepo;
|
||||
#istio: HelmRepo;
|
||||
#authentik: HelmRepo;
|
||||
#cloudflare: HelmRepo;
|
||||
#argo: HelmRepo;
|
||||
|
||||
constructor(services: Services) {
|
||||
const resourceService = services.get(ResourceService);
|
||||
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.#argo = resourceService.get(HelmRepo, 'argo', NAMESPACE);
|
||||
|
||||
this.#jetstack.on('changed', this.ensure);
|
||||
this.#istio.on('changed', this.ensure);
|
||||
this.#authentik.on('changed', this.ensure);
|
||||
this.#cloudflare.on('changed', this.ensure);
|
||||
this.#argo.on('changed', this.ensure);
|
||||
}
|
||||
|
||||
public get jetstack() {
|
||||
return this.#jetstack;
|
||||
}
|
||||
|
||||
public get istio() {
|
||||
return this.#istio;
|
||||
}
|
||||
|
||||
public get authentik() {
|
||||
return this.#authentik;
|
||||
}
|
||||
|
||||
public get cloudflare() {
|
||||
return this.#cloudflare;
|
||||
}
|
||||
|
||||
public get argo() {
|
||||
return this.#argo;
|
||||
}
|
||||
|
||||
public ensure = async () => {
|
||||
await this.#jetstack.set({
|
||||
url: 'https://charts.jetstack.io',
|
||||
});
|
||||
|
||||
await this.#istio.set({
|
||||
url: 'https://istio-release.storage.googleapis.com/charts',
|
||||
});
|
||||
|
||||
await this.#authentik.set({
|
||||
url: 'https://charts.goauthentik.io',
|
||||
});
|
||||
|
||||
await this.#cloudflare.set({
|
||||
url: 'https://cloudflare.github.io/helm-charts',
|
||||
});
|
||||
|
||||
await this.#argo.set({
|
||||
url: 'https://argoproj.github.io/argo-helm',
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export { RepoService };
|
||||
@@ -1,17 +0,0 @@
|
||||
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);
|
||||
|
||||
await resourceService.install(...Object.values(homelab));
|
||||
await resourceService.register(...Object.values(resources));
|
||||
|
||||
const bootstrapService = services.get(BootstrapService);
|
||||
await bootstrapService.ensure();
|
||||
|
||||
console.log('Started');
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Certificate } from './certificate/certificate.ts';
|
||||
|
||||
import type { ResourceClass } from '#services/resources/resources.ts';
|
||||
|
||||
const certManager = {
|
||||
certificate: Certificate,
|
||||
} satisfies Record<string, ResourceClass<ExpectedAny>>;
|
||||
|
||||
export { certManager };
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { KubernetesObject } from '@kubernetes/client-node';
|
||||
import type { K8SCertificateV1 } from 'src/__generated__/resources/K8SCertificateV1.ts';
|
||||
|
||||
import { CRD } from '#resources/core/crd/crd.ts';
|
||||
import { Resource, ResourceService, type ResourceOptions } from '#services/resources/resources.ts';
|
||||
import { NotReadyError } from '#utils/errors.ts';
|
||||
|
||||
class Certificate extends Resource<KubernetesObject & K8SCertificateV1> {
|
||||
public static readonly apiVersion = 'cert-manager.io/v1';
|
||||
public static readonly kind = 'Certificate';
|
||||
|
||||
#crd: CRD;
|
||||
|
||||
constructor(options: ResourceOptions<KubernetesObject & K8SCertificateV1>) {
|
||||
super(options);
|
||||
const resourceService = this.services.get(ResourceService);
|
||||
this.#crd = resourceService.get(CRD, 'certificates.cert-manager.io');
|
||||
this.#crd.on('changed', this.#handleCrdChanged);
|
||||
}
|
||||
|
||||
#handleCrdChanged = () => {
|
||||
this.emit('changed', this.manifest);
|
||||
};
|
||||
|
||||
public get hasCRD() {
|
||||
return this.#crd.exists;
|
||||
}
|
||||
|
||||
public set = async (manifest: KubernetesObject & K8SCertificateV1) => {
|
||||
if (!this.hasCRD) {
|
||||
throw new NotReadyError('MissingCRD', 'certificates.cert-manager.io does not exist');
|
||||
}
|
||||
return this.ensure(manifest);
|
||||
};
|
||||
}
|
||||
|
||||
export { Certificate };
|
||||
@@ -1,23 +0,0 @@
|
||||
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 };
|
||||
@@ -1,10 +0,0 @@
|
||||
import type { V1CustomResourceDefinition } from '@kubernetes/client-node';
|
||||
|
||||
import { Resource } from '#services/resources/resources.ts';
|
||||
|
||||
class CRD extends Resource<V1CustomResourceDefinition> {
|
||||
public static readonly apiVersion = 'apiextensions.k8s.io/v1';
|
||||
public static readonly kind = 'CustomResourceDefinition';
|
||||
}
|
||||
|
||||
export { CRD };
|
||||
@@ -1,10 +0,0 @@
|
||||
import type { V1Deployment } from '@kubernetes/client-node';
|
||||
|
||||
import { Resource } from '#services/resources/resources.ts';
|
||||
|
||||
class Deployment extends Resource<V1Deployment> {
|
||||
public static readonly apiVersion = 'apps/v1';
|
||||
public static readonly kind = 'Deployment';
|
||||
}
|
||||
|
||||
export { Deployment };
|
||||
@@ -1,10 +0,0 @@
|
||||
import type { V1Namespace } from '@kubernetes/client-node';
|
||||
|
||||
import { Resource } from '#services/resources/resources.ts';
|
||||
|
||||
class Namespace extends Resource<V1Namespace> {
|
||||
public static readonly apiVersion = 'v1';
|
||||
public static readonly kind = 'Namespace';
|
||||
}
|
||||
|
||||
export { Namespace };
|
||||
@@ -1,10 +0,0 @@
|
||||
import type { V1PersistentVolume } from '@kubernetes/client-node';
|
||||
|
||||
import { Resource } from '#services/resources/resources.ts';
|
||||
|
||||
class PersistentVolume extends Resource<V1PersistentVolume> {
|
||||
public static readonly apiVersion = 'v1';
|
||||
public static readonly kind = 'PersistentVolume';
|
||||
}
|
||||
|
||||
export { PersistentVolume };
|
||||
@@ -1,92 +0,0 @@
|
||||
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';
|
||||
import { chmod, mkdir } from 'fs/promises';
|
||||
|
||||
const PROVISIONER = 'homelab-operator';
|
||||
|
||||
class PVC extends Resource<V1PersistentVolumeClaim> {
|
||||
public static readonly apiVersion = 'v1';
|
||||
public static readonly kind = 'PersistentVolumeClaim';
|
||||
|
||||
constructor(options: ResourceOptions<V1PersistentVolumeClaim>) {
|
||||
super(options);
|
||||
this.on('changed', this.reconcile);
|
||||
}
|
||||
|
||||
public reconcile = async () => {
|
||||
const storageClassName = this.spec?.storageClassName;
|
||||
console.log('PVC', this.name, storageClassName);
|
||||
if (!storageClassName) {
|
||||
return;
|
||||
}
|
||||
const resourceService = this.services.get(ResourceService);
|
||||
const storageClass = resourceService.get(StorageClass, storageClassName);
|
||||
|
||||
if (!storageClass.exists || storageClass.manifest?.provisioner !== PROVISIONER) {
|
||||
return;
|
||||
}
|
||||
if (this.status?.phase === 'Pending' && !this.spec?.volumeName) {
|
||||
await this.#provisionVolume(storageClass);
|
||||
}
|
||||
};
|
||||
|
||||
#provisionVolume = async (storageClass: StorageClass) => {
|
||||
const pvName = `pv-${this.namespace}-${this.name}`;
|
||||
const storageLocation = storageClass.manifest?.parameters?.storageLocation || '/data/volumes';
|
||||
const target = `${storageLocation}/${this.namespace}/${this.name}`;
|
||||
|
||||
const resourceService = this.services.get(ResourceService);
|
||||
const pv = resourceService.get(PersistentVolume, pvName);
|
||||
|
||||
try {
|
||||
await mkdir(target, { recursive: true });
|
||||
} catch (error) {
|
||||
console.error('Error creating directory', error);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
await chmod(target, 0o777);
|
||||
} catch (error) {
|
||||
console.error('Error changing directory permissions', error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export { PVC, PROVISIONER };
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { KubernetesObject, V1Secret } from '@kubernetes/client-node';
|
||||
|
||||
import { Resource } from '#services/resources/resources.ts';
|
||||
import { decodeSecret, encodeSecret } from '#utils/secrets.ts';
|
||||
|
||||
type SetOptions<T extends Record<string, string | undefined>> = T | ((current: T | undefined) => T | Promise<T>);
|
||||
|
||||
class Secret<T extends Record<string, string> = Record<string, string>> extends Resource<V1Secret> {
|
||||
public static readonly apiVersion = 'v1';
|
||||
public static readonly kind = 'Secret';
|
||||
|
||||
public get value() {
|
||||
return decodeSecret(this.data) as T | undefined;
|
||||
}
|
||||
|
||||
public set = async (options: SetOptions<T>, data?: KubernetesObject) => {
|
||||
const value = typeof options === 'function' ? await Promise.resolve(options(this.value)) : options;
|
||||
await this.ensure({
|
||||
...data,
|
||||
data: encodeSecret(value),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export { Secret };
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { V1Service } from '@kubernetes/client-node';
|
||||
|
||||
import { Resource } from '#services/resources/resources.ts';
|
||||
|
||||
class Service extends Resource<V1Service> {
|
||||
public static readonly apiVersion = 'v1';
|
||||
public static readonly kind = 'Service';
|
||||
|
||||
public get hostname() {
|
||||
return `${this.name}.${this.namespace}.svc.cluster.local`;
|
||||
}
|
||||
}
|
||||
|
||||
export { Service };
|
||||
@@ -1,10 +0,0 @@
|
||||
import type { V1StatefulSet } from '@kubernetes/client-node';
|
||||
|
||||
import { Resource } from '#services/resources/resources.ts';
|
||||
|
||||
class StatefulSet extends Resource<V1StatefulSet> {
|
||||
public static readonly apiVersion = 'apps/v1';
|
||||
public static readonly kind = 'StatefulSet';
|
||||
}
|
||||
|
||||
export { StatefulSet };
|
||||
@@ -1,11 +0,0 @@
|
||||
import type { V1StorageClass } from '@kubernetes/client-node';
|
||||
|
||||
import { Resource } from '#services/resources/resources.ts';
|
||||
|
||||
class StorageClass extends Resource<V1StorageClass> {
|
||||
public static readonly apiVersion = 'storage.k8s.io/v1';
|
||||
public static readonly kind = 'StorageClass';
|
||||
public static readonly plural = 'storageclasses';
|
||||
}
|
||||
|
||||
export { StorageClass };
|
||||
@@ -1,11 +0,0 @@
|
||||
import { HelmRelease } from './helm-release/helm-release.ts';
|
||||
import { HelmRepo } from './helm-repo/helm-repo.ts';
|
||||
|
||||
import type { ResourceClass } from '#services/resources/resources.ts';
|
||||
|
||||
const flux = {
|
||||
helmRelease: HelmRelease,
|
||||
helmRepo: HelmRepo,
|
||||
} satisfies Record<string, ResourceClass<ExpectedAny>>;
|
||||
|
||||
export { flux };
|
||||
@@ -1,42 +0,0 @@
|
||||
import type { KubernetesObject } from '@kubernetes/client-node';
|
||||
import type { K8SHelmReleaseV2 } from 'src/__generated__/resources/K8SHelmReleaseV2.ts';
|
||||
|
||||
import { Resource } from '#services/resources/resources.ts';
|
||||
|
||||
type SetOptions = {
|
||||
namespace?: string;
|
||||
values?: Record<string, unknown>;
|
||||
chart: {
|
||||
name: string;
|
||||
namespace?: string;
|
||||
};
|
||||
};
|
||||
|
||||
class HelmRelease extends Resource<KubernetesObject & K8SHelmReleaseV2> {
|
||||
public static readonly apiVersion = 'helm.toolkit.fluxcd.io/v2';
|
||||
public static readonly kind = 'HelmRelease';
|
||||
|
||||
public set = async (options: SetOptions) => {
|
||||
return await this.ensure({
|
||||
spec: {
|
||||
targetNamespace: options.namespace,
|
||||
interval: '1h',
|
||||
values: options.values,
|
||||
chart: {
|
||||
spec: {
|
||||
chart: 'cert-manager',
|
||||
version: 'v1.18.2',
|
||||
sourceRef: {
|
||||
apiVersion: 'source.toolkit.fluxcd.io/v1',
|
||||
kind: 'HelmRepository',
|
||||
name: options.chart.name,
|
||||
namespace: options.chart.namespace,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export { HelmRelease };
|
||||
@@ -1,24 +0,0 @@
|
||||
import type { KubernetesObject } from '@kubernetes/client-node';
|
||||
import type { K8SHelmRepositoryV1 } from 'src/__generated__/resources/K8SHelmRepositoryV1.ts';
|
||||
|
||||
import { Resource } from '#services/resources/resources.ts';
|
||||
|
||||
type SetOptions = {
|
||||
url: string;
|
||||
};
|
||||
class HelmRepo extends Resource<KubernetesObject & K8SHelmRepositoryV1> {
|
||||
public static readonly apiVersion = 'source.toolkit.fluxcd.io/v1';
|
||||
public static readonly kind = 'HelmRepository';
|
||||
public static readonly plural = 'helmrepositories';
|
||||
|
||||
public set = async ({ url }: SetOptions) => {
|
||||
await this.ensure({
|
||||
spec: {
|
||||
interval: '1h',
|
||||
url,
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export { HelmRepo };
|
||||
@@ -1,274 +0,0 @@
|
||||
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 { DestinationRule } from '#resources/istio/destination-rule/destination-rule.ts';
|
||||
import { NotReadyError } from '#utils/errors.ts';
|
||||
import { ExternalHttpService } from '../external-http-service.ts/external-http-service.ts';
|
||||
|
||||
const specSchema = z.object({
|
||||
environment: z.string(),
|
||||
subdomain: z.string().optional(),
|
||||
});
|
||||
|
||||
type SecretData = { url: string; host: string; token: string };
|
||||
type InitSecretData = {
|
||||
AUTHENTIK_BOOTSTRAP_TOKEN: string;
|
||||
AUTHENTIK_BOOTSTRAP_PASSWORD: string;
|
||||
AUTHENTIK_BOOTSTRAP_EMAIL: string;
|
||||
AUTHENTIK_SECRET_KEY: string;
|
||||
};
|
||||
|
||||
class AuthentikServer extends CustomResource<typeof specSchema> {
|
||||
public static readonly apiVersion = API_VERSION;
|
||||
public static readonly kind = 'AuthentikServer';
|
||||
public static readonly spec = specSchema;
|
||||
public static readonly scope = 'Namespaced';
|
||||
|
||||
#environment: ResourceReference<typeof Environment>;
|
||||
#database: PostgresDatabase;
|
||||
#secret: Secret<SecretData>;
|
||||
#initSecret: Secret<InitSecretData>;
|
||||
#service: Service;
|
||||
#helmRelease: HelmRelease;
|
||||
#externalHttpService: ExternalHttpService;
|
||||
#destinationRule: DestinationRule;
|
||||
|
||||
constructor(options: CustomResourceOptions<typeof specSchema>) {
|
||||
super(options);
|
||||
|
||||
const resourceService = this.services.get(ResourceService);
|
||||
this.#environment = new ResourceReference();
|
||||
this.#environment.on('changed', this.queueReconcile);
|
||||
|
||||
this.#database = resourceService.get(PostgresDatabase, this.name, this.namespace);
|
||||
this.#database.on('changed', this.queueReconcile);
|
||||
|
||||
this.#secret = resourceService.get(Secret<SecretData>, this.name, this.namespace);
|
||||
this.#secret.on('changed', this.queueReconcile);
|
||||
|
||||
this.#initSecret = resourceService.get(Secret<InitSecretData>, `${this.name}-init`, this.namespace);
|
||||
|
||||
this.#service = resourceService.get(Service, `${this.name}-server`, this.namespace);
|
||||
// this.#service.on('changed', this.queueReconcile);
|
||||
|
||||
this.#helmRelease = resourceService.get(HelmRelease, this.name, this.namespace);
|
||||
this.#helmRelease.on('changed', this.queueReconcile);
|
||||
|
||||
this.#destinationRule = resourceService.get(DestinationRule, this.name, this.namespace);
|
||||
this.#destinationRule.on('changed', this.queueReconcile);
|
||||
|
||||
this.#externalHttpService = resourceService.get(ExternalHttpService, this.name, this.namespace);
|
||||
}
|
||||
|
||||
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.10.3',
|
||||
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',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await this.#externalHttpService.ensure({
|
||||
metadata: {
|
||||
ownerReferences: [this.ref],
|
||||
},
|
||||
spec: {
|
||||
environment: this.spec.environment,
|
||||
subdomain: this.spec.subdomain || 'authentik',
|
||||
destination: {
|
||||
host: this.#service.hostname,
|
||||
port: {
|
||||
number: 80,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export { AuthentikServer };
|
||||
@@ -1,111 +0,0 @@
|
||||
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';
|
||||
import { CloudflareService } from '#services/cloudflare/cloudflare.ts';
|
||||
|
||||
const specSchema = z.object({});
|
||||
|
||||
type SecretData = {
|
||||
account: string;
|
||||
tunnelName: string;
|
||||
tunnelId: string;
|
||||
secret: string;
|
||||
};
|
||||
class CloudflareTunnel extends CustomResource<typeof specSchema> {
|
||||
public static readonly apiVersion = API_VERSION;
|
||||
public static readonly kind = 'CloudflareTunnel';
|
||||
public static readonly spec = specSchema;
|
||||
public static readonly scope = 'Cluster';
|
||||
|
||||
#helmRelease: HelmRelease;
|
||||
#secret: Secret<SecretData>;
|
||||
#cloudflareService;
|
||||
|
||||
constructor(options: CustomResourceOptions<typeof specSchema>) {
|
||||
super(options);
|
||||
const resourceService = this.services.get(ResourceService);
|
||||
const namespaceService = this.services.get(NamespaceService);
|
||||
const namespace = namespaceService.homelab.name;
|
||||
resourceService.on('changed', this.#handleResourceChanged);
|
||||
|
||||
this.#helmRelease = resourceService.get(HelmRelease, this.name, namespace);
|
||||
this.#secret = resourceService.get(Secret<SecretData>, 'cloudflare', namespace);
|
||||
this.#secret.on('changed', this.queueReconcile);
|
||||
|
||||
this.#cloudflareService = this.services.get(CloudflareService);
|
||||
this.#cloudflareService.on('changed', this.queueReconcile);
|
||||
}
|
||||
|
||||
#handleResourceChanged = (resource: Resource<ExpectedAny>) => {
|
||||
if (resource instanceof CloudflareTunnel) {
|
||||
this.queueReconcile();
|
||||
}
|
||||
};
|
||||
|
||||
public reconcile = async () => {
|
||||
const secret = this.#secret.value;
|
||||
if (!secret) {
|
||||
throw new NotReadyError('MissingSecret', `Secret ${this.#secret.namespace}/${this.#secret.name} does not exist`);
|
||||
}
|
||||
const resourceService = this.services.get(ResourceService);
|
||||
const repoService = this.services.get(RepoService);
|
||||
const routes = resourceService.getAllOfKind(ExternalHttpService);
|
||||
const ingress = routes.map(({ rule }) => ({
|
||||
hostname: rule?.hostname,
|
||||
service: `http://${rule?.destination.host}:${rule?.destination.port.number}`,
|
||||
}));
|
||||
if (this.#cloudflareService.ready) {
|
||||
for (const route of ingress) {
|
||||
if (!route.hostname) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await this.#cloudflareService.ensureTunnel(route.hostname);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
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 };
|
||||
@@ -1,229 +0,0 @@
|
||||
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';
|
||||
//import { HelmRelease } from '#resources/flux/helm-release/helm-release.ts';
|
||||
// import { RepoService } from '#bootstrap/repos/repos.ts';
|
||||
|
||||
const specSchema = z.object({
|
||||
domain: z.string(),
|
||||
networkIp: z.string().optional(),
|
||||
tls: z.object({
|
||||
issuer: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
class Environment extends CustomResource<typeof specSchema> {
|
||||
public static readonly apiVersion = API_VERSION;
|
||||
public static readonly kind = 'Environment';
|
||||
public static readonly spec = specSchema;
|
||||
public static readonly scope = 'Cluster';
|
||||
|
||||
#namespace: Namespace;
|
||||
#certificate: Certificate;
|
||||
#storageClass: StorageClass;
|
||||
#gateway: Gateway;
|
||||
#postgresCluster: PostgresCluster;
|
||||
#redisServer: RedisServer;
|
||||
#authentikServer: AuthentikServer;
|
||||
#cloudflareService: CloudflareService;
|
||||
//#argoRelease: HelmRelease;
|
||||
//#argoNamespace: Namespace;
|
||||
|
||||
constructor(options: CustomResourceOptions<typeof specSchema>) {
|
||||
super(options);
|
||||
const resourceService = this.services.get(ResourceService);
|
||||
const namespaceService = this.services.get(NamespaceService);
|
||||
const homelabNamespace = namespaceService.homelab.name;
|
||||
|
||||
this.#cloudflareService = this.services.get(CloudflareService);
|
||||
this.#cloudflareService.on('changed', this.queueReconcile);
|
||||
|
||||
this.#namespace = resourceService.get(Namespace, this.name);
|
||||
this.#namespace.on('changed', this.queueReconcile);
|
||||
|
||||
this.#certificate = resourceService.get(Certificate, this.name, homelabNamespace);
|
||||
this.#certificate.on('changed', this.queueReconcile);
|
||||
|
||||
this.#storageClass = resourceService.get(StorageClass, this.name);
|
||||
this.#storageClass.on('changed', this.queueReconcile);
|
||||
|
||||
this.#postgresCluster = resourceService.get(PostgresCluster, `${this.name}-postgres-cluster`, homelabNamespace);
|
||||
this.#postgresCluster.on('changed', this.queueReconcile);
|
||||
|
||||
this.#redisServer = resourceService.get(RedisServer, `${this.name}-redis-server`, homelabNamespace);
|
||||
this.#redisServer.on('changed', this.queueReconcile);
|
||||
|
||||
this.#gateway = resourceService.get(Gateway, this.name, homelabNamespace);
|
||||
this.#gateway.on('changed', this.queueReconcile);
|
||||
|
||||
this.#authentikServer = resourceService.get(AuthentikServer, `${this.name}-authentik`, homelabNamespace);
|
||||
this.#authentikServer.on('changed', this.queueReconcile);
|
||||
|
||||
// this.#argoNamespace = resourceService.get(Namespace, `${this.name}-argo`);
|
||||
|
||||
// this.#argoRelease = resourceService.get(HelmRelease, `${this.name}-argo`, homelabNamespace);
|
||||
// this.#argoRelease.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');
|
||||
}
|
||||
|
||||
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`,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// await this.#argoNamespace.ensure({});
|
||||
|
||||
// const repoService = this.services.get(RepoService);
|
||||
// await this.#argoRelease.ensure({
|
||||
// spec: {
|
||||
// targetNamespace: this.#argoNamespace.name,
|
||||
// interval: '1h',
|
||||
// values: {
|
||||
// applicationset: {
|
||||
// enabled: true,
|
||||
// },
|
||||
// },
|
||||
// chart: {
|
||||
// spec: {
|
||||
// chart: 'argo-cd',
|
||||
// version: '3.9.0',
|
||||
// sourceRef: {
|
||||
// apiVersion: 'source.toolkit.fluxcd.io/v1',
|
||||
// kind: 'HelmRepository',
|
||||
// name: repoService.argo.name,
|
||||
// namespace: repoService.argo.namespace,
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// });
|
||||
};
|
||||
}
|
||||
|
||||
export { Environment };
|
||||
@@ -1,43 +0,0 @@
|
||||
import { CustomResource, ResourceService, type CustomResourceOptions } from '#services/resources/resources.ts';
|
||||
import { z } from 'zod';
|
||||
import { Environment } from '../environment/environment.ts';
|
||||
import { API_VERSION } from '#utils/consts.ts';
|
||||
|
||||
const specSchema = z.object({
|
||||
environment: z.string(),
|
||||
subdomain: z.string(),
|
||||
destination: z.object({
|
||||
host: z.string(),
|
||||
port: z.object({
|
||||
number: z.number(),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
class ExternalHttpService extends CustomResource<typeof specSchema> {
|
||||
public static readonly apiVersion = API_VERSION;
|
||||
public static readonly kind = 'ExternalHttpService';
|
||||
public static readonly spec = specSchema;
|
||||
public static readonly scope = 'Namespaced';
|
||||
|
||||
constructor(options: CustomResourceOptions<typeof specSchema>) {
|
||||
super(options);
|
||||
}
|
||||
|
||||
public get rule() {
|
||||
if (!this.spec) {
|
||||
return undefined;
|
||||
}
|
||||
const resourceService = this.services.get(ResourceService);
|
||||
const env = resourceService.get(Environment, this.spec.environment);
|
||||
const hostname = `${this.spec.subdomain}.${env.spec?.domain}`;
|
||||
return {
|
||||
domain: env.spec?.domain,
|
||||
subdomain: this.spec.subdomain,
|
||||
hostname,
|
||||
destination: this.spec.destination,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export { ExternalHttpService };
|
||||
@@ -1,47 +0,0 @@
|
||||
import { Secret } from '#resources/core/secret/secret.ts';
|
||||
import { CustomResource, ResourceService, type CustomResourceOptions } from '#services/resources/resources.ts';
|
||||
import { z } from 'zod';
|
||||
import { generateSecrets } from './generate-secret.utils.ts';
|
||||
import { API_VERSION } from '#utils/consts.ts';
|
||||
|
||||
const generateSecretFieldSchema = z.object({
|
||||
name: z.string(),
|
||||
value: z.string().optional(),
|
||||
encoding: z.enum(['base64', 'base64url', 'hex', 'utf8', 'numeric']).optional(),
|
||||
length: z.number().optional(),
|
||||
});
|
||||
|
||||
const specSchema = z.object({
|
||||
fields: z.array(generateSecretFieldSchema),
|
||||
});
|
||||
|
||||
class GenerateSecret extends CustomResource<typeof specSchema> {
|
||||
public static readonly apiVersion = API_VERSION;
|
||||
public static readonly kind = 'GenerateSecret';
|
||||
public static readonly spec = specSchema;
|
||||
public static readonly scope = 'Namespaced';
|
||||
|
||||
#secret: Secret;
|
||||
|
||||
constructor(options: CustomResourceOptions<typeof specSchema>) {
|
||||
super(options);
|
||||
|
||||
const resourceService = this.services.get(ResourceService);
|
||||
|
||||
this.#secret = resourceService.get(Secret, this.name, this.namespace);
|
||||
}
|
||||
|
||||
public reconcile = async () => {
|
||||
const secrets = generateSecrets(this.spec);
|
||||
const current = this.#secret.value;
|
||||
|
||||
const expected = {
|
||||
...secrets,
|
||||
...current,
|
||||
};
|
||||
|
||||
await this.#secret.set(expected);
|
||||
};
|
||||
}
|
||||
|
||||
export { GenerateSecret };
|
||||
@@ -1,27 +0,0 @@
|
||||
import { Environment } from './environment/environment.ts';
|
||||
import { PostgresCluster } from './postgres-cluster/postgres-cluster.ts';
|
||||
import { RedisServer } from './redis-server/redis-server.ts';
|
||||
import { PostgresDatabase } from './postgres-database/postgres-database.ts';
|
||||
import { AuthentikServer } from './authentik-server/authentik-server.ts';
|
||||
|
||||
import type { InstallableResourceClass } from '#services/resources/resources.ts';
|
||||
import { OIDCClient } from './oidc-client/oidc-client.ts';
|
||||
import { HttpService } from './http-service/http-service.ts';
|
||||
import { GenerateSecret } from './generate-secret/generate-secret.ts';
|
||||
import { ExternalHttpService } from './external-http-service.ts/external-http-service.ts';
|
||||
import { CloudflareTunnel } from './cloudflare-tunnel/cloudflare-tunnel.ts';
|
||||
|
||||
const homelab = {
|
||||
PostgresCluster,
|
||||
RedisServer,
|
||||
Environment,
|
||||
ExternalHttpService,
|
||||
CloudflareTunnel,
|
||||
AuthentikServer,
|
||||
PostgresDatabase,
|
||||
OIDCClient,
|
||||
HttpService,
|
||||
GenerateSecret,
|
||||
} satisfies Record<string, InstallableResourceClass<ExpectedAny>>;
|
||||
|
||||
export { homelab };
|
||||
@@ -1,83 +0,0 @@
|
||||
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<typeof specSchema> {
|
||||
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<typeof Environment>;
|
||||
|
||||
constructor(options: CustomResourceOptions<typeof specSchema>) {
|
||||
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 };
|
||||
@@ -1,116 +0,0 @@
|
||||
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({
|
||||
subdomain: z.string(),
|
||||
path: 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<typeof specSchema> {
|
||||
public static readonly apiVersion = API_VERSION;
|
||||
public static readonly kind = 'OidcClient';
|
||||
public static readonly spec = specSchema;
|
||||
public static readonly scope = 'Namespaced';
|
||||
|
||||
#environment = new ResourceReference<typeof Environment>();
|
||||
#secret: Secret<SecretData>;
|
||||
|
||||
constructor(options: CustomResourceOptions<typeof specSchema>) {
|
||||
super(options);
|
||||
const resourceService = this.services.get(ResourceService);
|
||||
this.#secret = resourceService.get(Secret<SecretData>, `${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/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,
|
||||
});
|
||||
|
||||
const redirectUris = this.spec.redirectUris.map((uri) => ({
|
||||
matchingMode: uri.matchingMode,
|
||||
url: new URL(uri.path, `https://${uri.subdomain}.${this.#environment.current?.spec?.domain}`).toString(),
|
||||
}));
|
||||
|
||||
await authentikServer.upsertClient({
|
||||
...this.spec,
|
||||
redirectUris,
|
||||
name: this.name,
|
||||
secret: secret.clientSecret,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export { OIDCClient };
|
||||
@@ -1,172 +0,0 @@
|
||||
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<typeof specSchema> {
|
||||
public static readonly apiVersion = API_VERSION;
|
||||
public static readonly kind = 'PostgresCluster';
|
||||
public static readonly spec = specSchema;
|
||||
public static readonly scope = 'Namespaced';
|
||||
|
||||
#secret: Secret<SecretData>;
|
||||
#statefulSet: StatefulSet;
|
||||
#headlessService: Service;
|
||||
#service: Service;
|
||||
|
||||
constructor(options: CustomResourceOptions<typeof specSchema>) {
|
||||
super(options);
|
||||
|
||||
const resourceService = this.services.get(ResourceService);
|
||||
this.#secret = resourceService.get(Secret<SecretData>, 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: 'pgvector/pgvector:pg17-trixie',
|
||||
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 };
|
||||
@@ -1,147 +0,0 @@
|
||||
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;
|
||||
url: string;
|
||||
};
|
||||
|
||||
const sanitizeName = (input: string) => {
|
||||
return input.replace(/[^a-zA-Z0-9_]+/g, '_').toLowerCase();
|
||||
};
|
||||
|
||||
class PostgresDatabase extends CustomResource<typeof specSchema> {
|
||||
public static readonly apiVersion = API_VERSION;
|
||||
public static readonly kind = 'PostgresDatabase';
|
||||
public static readonly spec = specSchema;
|
||||
public static readonly scope = 'Namespaced';
|
||||
|
||||
#cluster: ResourceReference<typeof PostgresCluster>;
|
||||
#secret: Secret<SecretData>;
|
||||
|
||||
constructor(options: CustomResourceOptions<typeof specSchema>) {
|
||||
super(options);
|
||||
const resourceService = this.services.get(ResourceService);
|
||||
|
||||
this.#cluster = new ResourceReference();
|
||||
this.#cluster.on('changed', this.queueReconcile);
|
||||
|
||||
this.#secret = resourceService.get(Secret<SecretData>, `${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');
|
||||
}
|
||||
|
||||
const expected = {
|
||||
password: generateRandomHexPass(),
|
||||
user: this.username,
|
||||
database: this.database,
|
||||
...this.#secret.value,
|
||||
host: clusterSecret.host,
|
||||
port: clusterSecret.port,
|
||||
};
|
||||
|
||||
const url = `postgresql://${expected.user}:${expected.password}@${expected.host}:${expected.port}/${expected.database}?sslmode=disable`;
|
||||
|
||||
await this.#secret.set(
|
||||
{
|
||||
...expected,
|
||||
url,
|
||||
},
|
||||
{
|
||||
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,
|
||||
});
|
||||
try {
|
||||
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,
|
||||
});
|
||||
} finally {
|
||||
await database.close();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export { PostgresDatabase };
|
||||
@@ -1,79 +0,0 @@
|
||||
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<typeof specSchema> {
|
||||
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<typeof specSchema>) {
|
||||
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 };
|
||||
@@ -1,37 +0,0 @@
|
||||
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<KubernetesObject & K8SDestinationRuleV1> {
|
||||
public static readonly apiVersion = 'networking.istio.io/v1';
|
||||
public static readonly kind = 'DestinationRule';
|
||||
|
||||
#crd: CRD;
|
||||
|
||||
constructor(options: ResourceOptions<KubernetesObject & K8SDestinationRuleV1>) {
|
||||
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 };
|
||||
@@ -1,37 +0,0 @@
|
||||
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<KubernetesObject & K8SGatewayV1> {
|
||||
public static readonly apiVersion = 'networking.istio.io/v1';
|
||||
public static readonly kind = 'Gateway';
|
||||
|
||||
#crd: CRD;
|
||||
|
||||
constructor(options: ResourceOptions<KubernetesObject & K8SGatewayV1>) {
|
||||
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 };
|
||||
@@ -1,11 +0,0 @@
|
||||
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 };
|
||||
@@ -1,37 +0,0 @@
|
||||
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<KubernetesObject & K8SVirtualServiceV1> {
|
||||
public static readonly apiVersion = 'networking.istio.io/v1';
|
||||
public static readonly kind = 'VirtualService';
|
||||
|
||||
#crd: CRD;
|
||||
|
||||
constructor(options: ResourceOptions<KubernetesObject & K8SVirtualServiceV1>) {
|
||||
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 };
|
||||
@@ -1,17 +0,0 @@
|
||||
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<string, ResourceClass<ExpectedAny>>;
|
||||
|
||||
export { resources };
|
||||
@@ -1,106 +0,0 @@
|
||||
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<CloudflareServiceEvents> {
|
||||
#services: Services;
|
||||
#secret: Secret<SecretData>;
|
||||
|
||||
constructor(services: Services) {
|
||||
super();
|
||||
this.#services = services;
|
||||
const resourceService = this.#services.get(ResourceService);
|
||||
const namespaceService = this.#services.get(NamespaceService);
|
||||
this.#secret = resourceService.get(Secret<SecretData>, '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;
|
||||
}
|
||||
|
||||
public ensureTunnel = async (route: string) => {
|
||||
const secret = this.#secret.value;
|
||||
if (!secret) {
|
||||
return;
|
||||
}
|
||||
const client = this.client;
|
||||
const domainParts = route.split('.');
|
||||
const cname = `${secret.tunnelId}.cfargotunnel.com`;
|
||||
const tld = domainParts.pop();
|
||||
const root = domainParts.pop();
|
||||
const zoneName = `${root}.${tld}`;
|
||||
const name = domainParts.join('.');
|
||||
|
||||
const zones = await client.zones.list({
|
||||
name: zoneName,
|
||||
});
|
||||
const [zone] = zones.result;
|
||||
if (!zone) {
|
||||
return;
|
||||
}
|
||||
const records = await client.dns.records.list({
|
||||
zone_id: zone.id,
|
||||
name: {
|
||||
exact: route,
|
||||
},
|
||||
type: 'CNAME',
|
||||
});
|
||||
const [record] = records.result;
|
||||
if (record) {
|
||||
await client.dns.records.edit(record.id, {
|
||||
zone_id: zone.id,
|
||||
type: 'CNAME',
|
||||
content: cname,
|
||||
name: name,
|
||||
ttl: 1,
|
||||
proxied: true,
|
||||
});
|
||||
} else {
|
||||
await client.dns.records.create({
|
||||
zone_id: zone.id,
|
||||
type: 'CNAME',
|
||||
content: cname,
|
||||
name: name,
|
||||
ttl: 1,
|
||||
proxied: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export { CloudflareService };
|
||||
@@ -1,186 +0,0 @@
|
||||
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<TSpec extends ZodType> = ResourceOptions<KubernetesObject & { spec: z.infer<TSpec> }>;
|
||||
|
||||
class CustomResource<TSpec extends ZodType> extends Resource<
|
||||
KubernetesObject & { spec: z.infer<TSpec>; status?: z.infer<typeof customResourceStatusSchema> }
|
||||
> {
|
||||
public static readonly apiVersion = API_VERSION;
|
||||
public static readonly status = customResourceStatusSchema;
|
||||
|
||||
#reconcileQueue: CoalescingQueued<void>;
|
||||
#cron: CronJob;
|
||||
|
||||
constructor(options: CustomResourceOptions<TSpec>) {
|
||||
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<TSpec>; status?: z.infer<typeof customResourceStatusSchema> },
|
||||
) => {
|
||||
if (this.isSeen && previous) {
|
||||
return;
|
||||
}
|
||||
return await this.queueReconcile();
|
||||
};
|
||||
|
||||
public reconcile?: () => Promise<void>;
|
||||
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<z.infer<typeof customResourceStatusSchema>>) =>
|
||||
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 };
|
||||
@@ -1,38 +0,0 @@
|
||||
import { EventEmitter } from 'eventemitter3';
|
||||
|
||||
import type { ResourceClass } from '../resources.ts';
|
||||
|
||||
import type { ResourceEvents } from './resource.ts';
|
||||
|
||||
class ResourceReference<T extends ResourceClass<ExpectedAny>> extends EventEmitter<ResourceEvents> {
|
||||
#current?: InstanceType<T>;
|
||||
|
||||
constructor(current?: InstanceType<T>) {
|
||||
super();
|
||||
this.#current = current;
|
||||
}
|
||||
|
||||
public get current() {
|
||||
return this.#current;
|
||||
}
|
||||
|
||||
public set current(value: InstanceType<T> | 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 };
|
||||
@@ -1,187 +0,0 @@
|
||||
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<T extends KubernetesObject> = {
|
||||
services: Services;
|
||||
selector: ResourceSelector;
|
||||
manifest?: T;
|
||||
};
|
||||
|
||||
type ResourceEvents<T extends KubernetesObject> = {
|
||||
changed: (from?: T) => void;
|
||||
};
|
||||
|
||||
class Resource<T extends KubernetesObject> extends EventEmitter<ResourceEvents<T>> {
|
||||
#manifest?: T;
|
||||
#queue: Queue;
|
||||
#options: ResourceOptions<T>;
|
||||
|
||||
constructor(options: ResourceOptions<T>) {
|
||||
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 };
|
||||
@@ -1,139 +0,0 @@
|
||||
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, type ResourceOptions } from './resource/resource.ts';
|
||||
import { createManifest } from './resources.utils.ts';
|
||||
|
||||
import { K8sService } from '#services/k8s/k8s.ts';
|
||||
import { EventEmitter } from 'eventemitter3';
|
||||
|
||||
type ResourceClass<T extends KubernetesObject> = (new (options: ResourceOptions<T>) => Resource<T>) & {
|
||||
apiVersion: string;
|
||||
kind: string;
|
||||
plural?: string;
|
||||
};
|
||||
|
||||
type InstallableResourceClass<T extends KubernetesObject> = ResourceClass<T> & {
|
||||
spec: ZodType;
|
||||
status: ZodType;
|
||||
scope: 'Namespaced' | 'Cluster';
|
||||
};
|
||||
|
||||
type ResourceServiceEvents = {
|
||||
changed: (resource: Resource<ExpectedAny>) => void;
|
||||
};
|
||||
|
||||
class ResourceService extends EventEmitter<ResourceServiceEvents> {
|
||||
#services: Services;
|
||||
#registry: Map<
|
||||
ResourceClass<ExpectedAny>,
|
||||
{
|
||||
apiVersion: string;
|
||||
kind: string;
|
||||
plural?: string;
|
||||
resources: Resource<ExpectedAny>[];
|
||||
}
|
||||
>;
|
||||
|
||||
constructor(services: Services) {
|
||||
super();
|
||||
this.#services = services;
|
||||
this.#registry = new Map();
|
||||
}
|
||||
|
||||
public register = async (...resources: ResourceClass<ExpectedAny>[]) => {
|
||||
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 getAllOfKind = <T extends ResourceClass<ExpectedAny>>(type: T) => {
|
||||
return (this.#registry.get(type)?.resources?.filter((r) => r.exists) as InstanceType<T>[]) || [];
|
||||
};
|
||||
|
||||
public get = <T extends ResourceClass<ExpectedAny>>(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<T>;
|
||||
};
|
||||
|
||||
public install = async (...resources: InstallableResourceClass<ExpectedAny>[]) => {
|
||||
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;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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 };
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { Services } from '../../utils/service.ts';
|
||||
|
||||
import { Watcher, type WatcherOptions } from './watchers.watcher.ts';
|
||||
|
||||
class WatcherService {
|
||||
#services: Services;
|
||||
|
||||
constructor(services: Services) {
|
||||
this.#services = services;
|
||||
}
|
||||
|
||||
public create = (options: Omit<WatcherOptions, 'services'>) => {
|
||||
const instance = new Watcher({
|
||||
...options,
|
||||
services: this.#services,
|
||||
});
|
||||
return instance;
|
||||
};
|
||||
}
|
||||
|
||||
export { WatcherService, Watcher };
|
||||
@@ -1,69 +0,0 @@
|
||||
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';
|
||||
|
||||
type ResourceChangedAction = 'add' | 'update' | 'delete';
|
||||
|
||||
type WatcherEvents<T extends KubernetesObject> = {
|
||||
changed: (manifest: T) => void;
|
||||
};
|
||||
|
||||
type WatcherOptions = {
|
||||
apiVersion: string;
|
||||
kind: string;
|
||||
plural?: string;
|
||||
selector?: string;
|
||||
services: Services;
|
||||
verbs: ResourceChangedAction[];
|
||||
};
|
||||
|
||||
class Watcher<T extends KubernetesObject> extends EventEmitter<WatcherEvents<T>> {
|
||||
#options: WatcherOptions;
|
||||
#informer: Informer<T>;
|
||||
|
||||
constructor(options: WatcherOptions) {
|
||||
super();
|
||||
this.#options = options;
|
||||
this.#informer = this.#setup();
|
||||
}
|
||||
|
||||
#setup = () => {
|
||||
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 path = group ? `/apis/${group}/${version}/${plural}` : `/api/${version}/${plural}`;
|
||||
const informer = makeInformer<T>(
|
||||
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) => {
|
||||
console.log('Watcher failed, will retry in 3 seconds', path, err);
|
||||
setTimeout(this.start, 3000);
|
||||
});
|
||||
return informer;
|
||||
};
|
||||
|
||||
#handleResource = (action: ResourceChangedAction, manifest: T) => {
|
||||
this.emit('changed', manifest);
|
||||
};
|
||||
|
||||
public stop = async () => {
|
||||
await this.#informer.stop();
|
||||
};
|
||||
|
||||
public start = async () => {
|
||||
await this.#informer.start();
|
||||
};
|
||||
}
|
||||
|
||||
export { Watcher, type WatcherOptions, type ResourceChangedAction };
|
||||
@@ -1,14 +0,0 @@
|
||||
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 };
|
||||
@@ -1,9 +0,0 @@
|
||||
apiVersion: homelab.mortenolsen.pro/v1
|
||||
kind: OidcClient
|
||||
metadata:
|
||||
name: test-client
|
||||
spec:
|
||||
environment: dev
|
||||
redirectUris:
|
||||
- url: https://localhost:3000/api/v1/authentik/oauth2/callback
|
||||
matchingMode: strict
|
||||
@@ -1,14 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: dev
|
||||
---
|
||||
apiVersion: homelab.mortenolsen.pro/v1
|
||||
kind: Environment
|
||||
metadata:
|
||||
name: prod
|
||||
spec:
|
||||
domain: olsen.cloud
|
||||
networkIp: 192.168.20.180
|
||||
tls:
|
||||
issuer: lets-encrypt-prod
|
||||
@@ -1,39 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: example-pvc
|
||||
namespace: default
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
storageClassName: homelab-operator-local-path
|
||||
|
||||
---
|
||||
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: example-pod
|
||||
namespace: default
|
||||
spec:
|
||||
containers:
|
||||
- name: example-container
|
||||
image: alpine
|
||||
command: ["/bin/sh", "-c", "sleep infinity"]
|
||||
volumeMounts:
|
||||
- name: example-volume
|
||||
mountPath: /data
|
||||
resources:
|
||||
limits:
|
||||
memory: 100Mi
|
||||
cpu: "0.1"
|
||||
requests:
|
||||
memory: 50Mi
|
||||
cpu: "0.05"
|
||||
volumes:
|
||||
- name: example-volume
|
||||
persistentVolumeClaim:
|
||||
claimName: example-pvc
|
||||
@@ -1,14 +0,0 @@
|
||||
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
|
||||
@@ -1,35 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: homelab
|
||||
|
||||
---
|
||||
|
||||
apiVersion: source.toolkit.fluxcd.io/v1
|
||||
kind: GitRepository
|
||||
metadata:
|
||||
name: homelab
|
||||
namespace: homelab
|
||||
spec:
|
||||
interval: 60m
|
||||
url: https://github.com/morten-olsen/homelab-operator
|
||||
ref:
|
||||
branch: main
|
||||
|
||||
---
|
||||
|
||||
apiVersion: helm.toolkit.fluxcd.io/v2
|
||||
kind: HelmRelease
|
||||
metadata:
|
||||
name: operator
|
||||
namespace: homelab
|
||||
spec:
|
||||
releaseName: operator
|
||||
interval: 60m
|
||||
chart:
|
||||
spec:
|
||||
chart: charts/operator
|
||||
sourceRef:
|
||||
kind: GitRepository
|
||||
name: homelab
|
||||
namespace: homelab
|
||||
@@ -5,15 +5,15 @@
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "3.3.1",
|
||||
"@eslint/js": "9.36.0",
|
||||
"@eslint/js": "9.32.0",
|
||||
"@types/deep-equal": "^1.0.4",
|
||||
"eslint": "9.36.0",
|
||||
"eslint": "9.32.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"eslint-plugin-prettier": "5.5.4",
|
||||
"eslint-plugin-prettier": "5.5.3",
|
||||
"json-schema-to-typescript": "^15.0.4",
|
||||
"prettier": "3.6.2",
|
||||
"typescript": "5.9.2",
|
||||
"typescript": "5.8.3",
|
||||
"typescript-eslint": "8.38.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -22,8 +22,6 @@
|
||||
"dependencies": {
|
||||
"@goauthentik/api": "2025.6.3-1751754396",
|
||||
"@kubernetes/client-node": "^1.3.0",
|
||||
"cloudflare": "^5.0.0",
|
||||
"cron": "^4.3.3",
|
||||
"debounce": "^2.2.0",
|
||||
"deep-equal": "^2.2.3",
|
||||
"dotenv": "^17.2.1",
|
||||
@@ -31,25 +29,19 @@
|
||||
"execa": "^9.6.0",
|
||||
"knex": "^3.1.0",
|
||||
"p-queue": "^8.1.0",
|
||||
"p-retry": "^7.0.0",
|
||||
"p-retry": "^6.2.1",
|
||||
"pg": "^8.16.3",
|
||||
"sqlite3": "^5.1.7",
|
||||
"yaml": "^2.8.0",
|
||||
"zod": "^4.0.14"
|
||||
},
|
||||
"imports": {
|
||||
"#services/*": "./src/services/*",
|
||||
"#resources/*": "./src/resources/*",
|
||||
"#bootstrap/*": "./src/bootstrap/*",
|
||||
"#utils/*": "./src/utils/*"
|
||||
},
|
||||
"packageManager": "pnpm@10.17.1",
|
||||
"packageManager": "pnpm@10.6.0",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"sqlite3"
|
||||
],
|
||||
"patchedDependencies": {
|
||||
"@kubernetes/client-node": "./patches/@kubernetes__client-node.patch"
|
||||
"@kubernetes/client-node": "patches/@kubernetes__client-node.patch"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
377
images/operator/pnpm-lock.yaml → pnpm-lock.yaml
generated
377
images/operator/pnpm-lock.yaml → pnpm-lock.yaml
generated
@@ -4,11 +4,6 @@ settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
patchedDependencies:
|
||||
'@kubernetes/client-node':
|
||||
hash: 0b0e5d32aa2930107c8c9b45df2639faf53fa12a389a551885d6e42d71f9429d
|
||||
path: patches/@kubernetes__client-node.patch
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
@@ -18,13 +13,7 @@ importers:
|
||||
version: 2025.6.3-1751754396
|
||||
'@kubernetes/client-node':
|
||||
specifier: ^1.3.0
|
||||
version: 1.3.0(patch_hash=0b0e5d32aa2930107c8c9b45df2639faf53fa12a389a551885d6e42d71f9429d)(encoding@0.1.13)
|
||||
cloudflare:
|
||||
specifier: ^5.0.0
|
||||
version: 5.2.0(encoding@0.1.13)
|
||||
cron:
|
||||
specifier: ^4.3.3
|
||||
version: 4.3.3
|
||||
version: 1.3.0(encoding@0.1.13)
|
||||
debounce:
|
||||
specifier: ^2.2.0
|
||||
version: 2.2.0
|
||||
@@ -33,7 +22,7 @@ importers:
|
||||
version: 2.2.3
|
||||
dotenv:
|
||||
specifier: ^17.2.1
|
||||
version: 17.2.2
|
||||
version: 17.2.1
|
||||
eventemitter3:
|
||||
specifier: ^5.0.1
|
||||
version: 5.0.1
|
||||
@@ -45,10 +34,10 @@ importers:
|
||||
version: 3.1.0(pg@8.16.3)(sqlite3@5.1.7)
|
||||
p-queue:
|
||||
specifier: ^8.1.0
|
||||
version: 8.1.1
|
||||
version: 8.1.0
|
||||
p-retry:
|
||||
specifier: ^7.0.0
|
||||
version: 7.0.0
|
||||
specifier: ^6.2.1
|
||||
version: 6.2.1
|
||||
pg:
|
||||
specifier: ^8.16.3
|
||||
version: 8.16.3
|
||||
@@ -57,32 +46,32 @@ importers:
|
||||
version: 5.1.7
|
||||
yaml:
|
||||
specifier: ^2.8.0
|
||||
version: 2.8.1
|
||||
version: 2.8.0
|
||||
zod:
|
||||
specifier: ^4.0.14
|
||||
version: 4.1.11
|
||||
version: 4.0.14
|
||||
devDependencies:
|
||||
'@eslint/eslintrc':
|
||||
specifier: 3.3.1
|
||||
version: 3.3.1
|
||||
'@eslint/js':
|
||||
specifier: 9.36.0
|
||||
version: 9.36.0
|
||||
specifier: 9.32.0
|
||||
version: 9.32.0
|
||||
'@types/deep-equal':
|
||||
specifier: ^1.0.4
|
||||
version: 1.0.4
|
||||
eslint:
|
||||
specifier: 9.36.0
|
||||
version: 9.36.0
|
||||
specifier: 9.32.0
|
||||
version: 9.32.0
|
||||
eslint-config-prettier:
|
||||
specifier: 10.1.8
|
||||
version: 10.1.8(eslint@9.36.0)
|
||||
version: 10.1.8(eslint@9.32.0)
|
||||
eslint-plugin-import:
|
||||
specifier: 2.32.0
|
||||
version: 2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.36.0)(typescript@5.9.2))(eslint@9.36.0)
|
||||
version: 2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0)(typescript@5.8.3))(eslint@9.32.0)
|
||||
eslint-plugin-prettier:
|
||||
specifier: 5.5.4
|
||||
version: 5.5.4(eslint-config-prettier@10.1.8(eslint@9.36.0))(eslint@9.36.0)(prettier@3.6.2)
|
||||
specifier: 5.5.3
|
||||
version: 5.5.3(eslint-config-prettier@10.1.8(eslint@9.32.0))(eslint@9.32.0)(prettier@3.6.2)
|
||||
json-schema-to-typescript:
|
||||
specifier: ^15.0.4
|
||||
version: 15.0.4
|
||||
@@ -90,11 +79,11 @@ importers:
|
||||
specifier: 3.6.2
|
||||
version: 3.6.2
|
||||
typescript:
|
||||
specifier: 5.9.2
|
||||
version: 5.9.2
|
||||
specifier: 5.8.3
|
||||
version: 5.8.3
|
||||
typescript-eslint:
|
||||
specifier: 8.38.0
|
||||
version: 8.38.0(eslint@9.36.0)(typescript@5.9.2)
|
||||
version: 8.38.0(eslint@9.32.0)(typescript@5.8.3)
|
||||
|
||||
packages:
|
||||
|
||||
@@ -108,12 +97,6 @@ packages:
|
||||
peerDependencies:
|
||||
eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
|
||||
|
||||
'@eslint-community/eslint-utils@4.9.0':
|
||||
resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==}
|
||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||
peerDependencies:
|
||||
eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
|
||||
|
||||
'@eslint-community/regexpp@4.12.1':
|
||||
resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==}
|
||||
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
|
||||
@@ -122,28 +105,28 @@ packages:
|
||||
resolution: {integrity: sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@eslint/config-helpers@0.3.1':
|
||||
resolution: {integrity: sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==}
|
||||
'@eslint/config-helpers@0.3.0':
|
||||
resolution: {integrity: sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@eslint/core@0.15.2':
|
||||
resolution: {integrity: sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==}
|
||||
'@eslint/core@0.15.1':
|
||||
resolution: {integrity: sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@eslint/eslintrc@3.3.1':
|
||||
resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@eslint/js@9.36.0':
|
||||
resolution: {integrity: sha512-uhCbYtYynH30iZErszX78U+nR3pJU3RHGQ57NXy5QupD4SBVwDeU8TNBy+MjMngc1UyIW9noKqsRqfjQTBU2dw==}
|
||||
'@eslint/js@9.32.0':
|
||||
resolution: {integrity: sha512-BBpRFZK3eX6uMLKz8WxFOBIFFcGFJ/g8XuwjTHCqHROSIsopI+ddn/d5Cfh36+7+e5edVS8dbSHnBNhrLEX0zg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@eslint/object-schema@2.1.6':
|
||||
resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@eslint/plugin-kit@0.3.5':
|
||||
resolution: {integrity: sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==}
|
||||
'@eslint/plugin-kit@0.3.4':
|
||||
resolution: {integrity: sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@gar/promisify@1.1.3':
|
||||
@@ -246,21 +229,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-fetch@2.6.13':
|
||||
resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==}
|
||||
|
||||
'@types/node@18.19.130':
|
||||
resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==}
|
||||
|
||||
'@types/node@22.16.5':
|
||||
resolution: {integrity: sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ==}
|
||||
|
||||
'@types/retry@0.12.2':
|
||||
resolution: {integrity: sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==}
|
||||
|
||||
'@types/stream-buffers@3.0.7':
|
||||
resolution: {integrity: sha512-azOCy05sXVXrO+qklf0c/B07H/oHaIuDDAiHPVwlk3A9Ek+ksHyTeMajLZl3r76FxpPpxem//4Te61G1iW3Giw==}
|
||||
|
||||
@@ -326,10 +303,6 @@ 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:
|
||||
@@ -506,9 +479,6 @@ packages:
|
||||
resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
cloudflare@5.2.0:
|
||||
resolution: {integrity: sha512-dVzqDpPFYR9ApEC9e+JJshFJZXcw4HzM8W+3DHzO5oy9+8rLC53G7x6fEf9A7/gSuSCxuvndzui5qJKftfIM9A==}
|
||||
|
||||
color-convert@2.0.1:
|
||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||
engines: {node: '>=7.0.0'}
|
||||
@@ -537,10 +507,6 @@ 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'}
|
||||
@@ -625,8 +591,8 @@ packages:
|
||||
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
dotenv@17.2.2:
|
||||
resolution: {integrity: sha512-Sf2LSQP+bOlhKWWyhFsn0UsfdK/kCWRv1iuA2gXAwt3dyNabr6QSj00I2V10pidqz69soatm9ZwZvpQMTIOd5Q==}
|
||||
dotenv@17.2.1:
|
||||
resolution: {integrity: sha512-kQhDYKZecqnM0fCnzI5eIv5L4cAe/iRI+HqMbO/hbRdTAeXDG+M9FjipUxNfbARuEg4iHIbhnhs78BCHNbSxEQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
@@ -728,8 +694,8 @@ packages:
|
||||
'@typescript-eslint/parser':
|
||||
optional: true
|
||||
|
||||
eslint-plugin-prettier@5.5.4:
|
||||
resolution: {integrity: sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg==}
|
||||
eslint-plugin-prettier@5.5.3:
|
||||
resolution: {integrity: sha512-NAdMYww51ehKfDyDhv59/eIItUVzU0Io9H2E8nHNGKEeeqlnci+1gCvrHib6EmZdf6GxF+LCV5K7UC65Ezvw7w==}
|
||||
engines: {node: ^14.18.0 || >=16.0.0}
|
||||
peerDependencies:
|
||||
'@types/eslint': '>=8.0.0'
|
||||
@@ -754,8 +720,8 @@ packages:
|
||||
resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
eslint@9.36.0:
|
||||
resolution: {integrity: sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==}
|
||||
eslint@9.32.0:
|
||||
resolution: {integrity: sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@@ -788,10 +754,6 @@ 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==}
|
||||
|
||||
@@ -863,21 +825,10 @@ 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'}
|
||||
|
||||
form-data@4.0.5:
|
||||
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
|
||||
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==}
|
||||
|
||||
@@ -1287,10 +1238,6 @@ 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'}
|
||||
@@ -1392,11 +1339,6 @@ 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}
|
||||
@@ -1482,13 +1424,13 @@ packages:
|
||||
resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
p-queue@8.1.1:
|
||||
resolution: {integrity: sha512-aNZ+VfjobsWryoiPnEApGGmf5WmNsCo9xu8dfaYamG5qaLP7ClhLN6NgsFe6SwJ2UbLEBK5dv9x8Mn5+RVhMWQ==}
|
||||
p-queue@8.1.0:
|
||||
resolution: {integrity: sha512-mxLDbbGIBEXTJL0zEx8JIylaj3xQ7Z/7eEVjcF9fJX4DBiH9oqe+oahYnlKKxm0Ci9TlWTyhSHgygxMxjIB2jw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
p-retry@7.0.0:
|
||||
resolution: {integrity: sha512-3BgO9rjULJYyr0Y0pcsG7FZ+7JB/hfOODO8kx9ppumiO5jprUF92WK/Y7Q0xppZtq4VhTcPiVq7qWLQfIV5aKQ==}
|
||||
engines: {node: '>=20'}
|
||||
p-retry@6.2.1:
|
||||
resolution: {integrity: sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ==}
|
||||
engines: {node: '>=16.17'}
|
||||
|
||||
p-timeout@6.1.4:
|
||||
resolution: {integrity: sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==}
|
||||
@@ -1667,6 +1609,10 @@ packages:
|
||||
resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
retry@0.13.1:
|
||||
resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
reusify@1.1.0:
|
||||
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
|
||||
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
||||
@@ -1931,8 +1877,8 @@ packages:
|
||||
eslint: ^8.57.0 || ^9.0.0
|
||||
typescript: '>=4.8.4 <5.9.0'
|
||||
|
||||
typescript@5.9.2:
|
||||
resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==}
|
||||
typescript@5.8.3:
|
||||
resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
@@ -1940,9 +1886,6 @@ 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==}
|
||||
|
||||
@@ -1962,10 +1905,6 @@ 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==}
|
||||
|
||||
@@ -2022,8 +1961,8 @@ packages:
|
||||
yallist@4.0.0:
|
||||
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
|
||||
|
||||
yaml@2.8.1:
|
||||
resolution: {integrity: sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==}
|
||||
yaml@2.8.0:
|
||||
resolution: {integrity: sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==}
|
||||
engines: {node: '>= 14.6'}
|
||||
hasBin: true
|
||||
|
||||
@@ -2035,8 +1974,8 @@ packages:
|
||||
resolution: {integrity: sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
zod@4.1.11:
|
||||
resolution: {integrity: sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==}
|
||||
zod@4.0.14:
|
||||
resolution: {integrity: sha512-nGFJTnJN6cM2v9kXL+SOBq3AtjQby3Mv5ySGFof5UGRHrRioSJ5iG680cYNjE/yWk671nROcpPj4hAS8nyLhSw==}
|
||||
|
||||
snapshots:
|
||||
|
||||
@@ -2046,14 +1985,9 @@ snapshots:
|
||||
'@types/json-schema': 7.0.15
|
||||
js-yaml: 4.1.0
|
||||
|
||||
'@eslint-community/eslint-utils@4.7.0(eslint@9.36.0)':
|
||||
'@eslint-community/eslint-utils@4.7.0(eslint@9.32.0)':
|
||||
dependencies:
|
||||
eslint: 9.36.0
|
||||
eslint-visitor-keys: 3.4.3
|
||||
|
||||
'@eslint-community/eslint-utils@4.9.0(eslint@9.36.0)':
|
||||
dependencies:
|
||||
eslint: 9.36.0
|
||||
eslint: 9.32.0
|
||||
eslint-visitor-keys: 3.4.3
|
||||
|
||||
'@eslint-community/regexpp@4.12.1': {}
|
||||
@@ -2066,9 +2000,9 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@eslint/config-helpers@0.3.1': {}
|
||||
'@eslint/config-helpers@0.3.0': {}
|
||||
|
||||
'@eslint/core@0.15.2':
|
||||
'@eslint/core@0.15.1':
|
||||
dependencies:
|
||||
'@types/json-schema': 7.0.15
|
||||
|
||||
@@ -2086,13 +2020,13 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@eslint/js@9.36.0': {}
|
||||
'@eslint/js@9.32.0': {}
|
||||
|
||||
'@eslint/object-schema@2.1.6': {}
|
||||
|
||||
'@eslint/plugin-kit@0.3.5':
|
||||
'@eslint/plugin-kit@0.3.4':
|
||||
dependencies:
|
||||
'@eslint/core': 0.15.2
|
||||
'@eslint/core': 0.15.1
|
||||
levn: 0.4.1
|
||||
|
||||
'@gar/promisify@1.1.3':
|
||||
@@ -2123,7 +2057,7 @@ snapshots:
|
||||
dependencies:
|
||||
jsep: 1.4.0
|
||||
|
||||
'@kubernetes/client-node@1.3.0(patch_hash=0b0e5d32aa2930107c8c9b45df2639faf53fa12a389a551885d6e42d71f9429d)(encoding@0.1.13)':
|
||||
'@kubernetes/client-node@1.3.0(encoding@0.1.13)':
|
||||
dependencies:
|
||||
'@types/js-yaml': 4.0.9
|
||||
'@types/node': 22.16.5
|
||||
@@ -2195,65 +2129,56 @@ 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-fetch@2.6.13':
|
||||
dependencies:
|
||||
'@types/node': 18.19.130
|
||||
form-data: 4.0.5
|
||||
|
||||
'@types/node@18.19.130':
|
||||
dependencies:
|
||||
undici-types: 5.26.5
|
||||
|
||||
'@types/node@22.16.5':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/retry@0.12.2': {}
|
||||
|
||||
'@types/stream-buffers@3.0.7':
|
||||
dependencies:
|
||||
'@types/node': 22.16.5
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.38.0(@typescript-eslint/parser@8.38.0(eslint@9.36.0)(typescript@5.9.2))(eslint@9.36.0)(typescript@5.9.2)':
|
||||
'@typescript-eslint/eslint-plugin@8.38.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0)(typescript@5.8.3))(eslint@9.32.0)(typescript@5.8.3)':
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.12.1
|
||||
'@typescript-eslint/parser': 8.38.0(eslint@9.36.0)(typescript@5.9.2)
|
||||
'@typescript-eslint/parser': 8.38.0(eslint@9.32.0)(typescript@5.8.3)
|
||||
'@typescript-eslint/scope-manager': 8.38.0
|
||||
'@typescript-eslint/type-utils': 8.38.0(eslint@9.36.0)(typescript@5.9.2)
|
||||
'@typescript-eslint/utils': 8.38.0(eslint@9.36.0)(typescript@5.9.2)
|
||||
'@typescript-eslint/type-utils': 8.38.0(eslint@9.32.0)(typescript@5.8.3)
|
||||
'@typescript-eslint/utils': 8.38.0(eslint@9.32.0)(typescript@5.8.3)
|
||||
'@typescript-eslint/visitor-keys': 8.38.0
|
||||
eslint: 9.36.0
|
||||
eslint: 9.32.0
|
||||
graphemer: 1.4.0
|
||||
ignore: 7.0.5
|
||||
natural-compare: 1.4.0
|
||||
ts-api-utils: 2.1.0(typescript@5.9.2)
|
||||
typescript: 5.9.2
|
||||
ts-api-utils: 2.1.0(typescript@5.8.3)
|
||||
typescript: 5.8.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/parser@8.38.0(eslint@9.36.0)(typescript@5.9.2)':
|
||||
'@typescript-eslint/parser@8.38.0(eslint@9.32.0)(typescript@5.8.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/scope-manager': 8.38.0
|
||||
'@typescript-eslint/types': 8.38.0
|
||||
'@typescript-eslint/typescript-estree': 8.38.0(typescript@5.9.2)
|
||||
'@typescript-eslint/typescript-estree': 8.38.0(typescript@5.8.3)
|
||||
'@typescript-eslint/visitor-keys': 8.38.0
|
||||
debug: 4.4.1
|
||||
eslint: 9.36.0
|
||||
typescript: 5.9.2
|
||||
eslint: 9.32.0
|
||||
typescript: 5.8.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/project-service@8.38.0(typescript@5.9.2)':
|
||||
'@typescript-eslint/project-service@8.38.0(typescript@5.8.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/tsconfig-utils': 8.38.0(typescript@5.9.2)
|
||||
'@typescript-eslint/tsconfig-utils': 8.38.0(typescript@5.8.3)
|
||||
'@typescript-eslint/types': 8.38.0
|
||||
debug: 4.4.1
|
||||
typescript: 5.9.2
|
||||
typescript: 5.8.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -2262,28 +2187,28 @@ snapshots:
|
||||
'@typescript-eslint/types': 8.38.0
|
||||
'@typescript-eslint/visitor-keys': 8.38.0
|
||||
|
||||
'@typescript-eslint/tsconfig-utils@8.38.0(typescript@5.9.2)':
|
||||
'@typescript-eslint/tsconfig-utils@8.38.0(typescript@5.8.3)':
|
||||
dependencies:
|
||||
typescript: 5.9.2
|
||||
typescript: 5.8.3
|
||||
|
||||
'@typescript-eslint/type-utils@8.38.0(eslint@9.36.0)(typescript@5.9.2)':
|
||||
'@typescript-eslint/type-utils@8.38.0(eslint@9.32.0)(typescript@5.8.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.38.0
|
||||
'@typescript-eslint/typescript-estree': 8.38.0(typescript@5.9.2)
|
||||
'@typescript-eslint/utils': 8.38.0(eslint@9.36.0)(typescript@5.9.2)
|
||||
'@typescript-eslint/typescript-estree': 8.38.0(typescript@5.8.3)
|
||||
'@typescript-eslint/utils': 8.38.0(eslint@9.32.0)(typescript@5.8.3)
|
||||
debug: 4.4.1
|
||||
eslint: 9.36.0
|
||||
ts-api-utils: 2.1.0(typescript@5.9.2)
|
||||
typescript: 5.9.2
|
||||
eslint: 9.32.0
|
||||
ts-api-utils: 2.1.0(typescript@5.8.3)
|
||||
typescript: 5.8.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/types@8.38.0': {}
|
||||
|
||||
'@typescript-eslint/typescript-estree@8.38.0(typescript@5.9.2)':
|
||||
'@typescript-eslint/typescript-estree@8.38.0(typescript@5.8.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/project-service': 8.38.0(typescript@5.9.2)
|
||||
'@typescript-eslint/tsconfig-utils': 8.38.0(typescript@5.9.2)
|
||||
'@typescript-eslint/project-service': 8.38.0(typescript@5.8.3)
|
||||
'@typescript-eslint/tsconfig-utils': 8.38.0(typescript@5.8.3)
|
||||
'@typescript-eslint/types': 8.38.0
|
||||
'@typescript-eslint/visitor-keys': 8.38.0
|
||||
debug: 4.4.1
|
||||
@@ -2291,19 +2216,19 @@ snapshots:
|
||||
is-glob: 4.0.3
|
||||
minimatch: 9.0.5
|
||||
semver: 7.7.2
|
||||
ts-api-utils: 2.1.0(typescript@5.9.2)
|
||||
typescript: 5.9.2
|
||||
ts-api-utils: 2.1.0(typescript@5.8.3)
|
||||
typescript: 5.8.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/utils@8.38.0(eslint@9.36.0)(typescript@5.9.2)':
|
||||
'@typescript-eslint/utils@8.38.0(eslint@9.32.0)(typescript@5.8.3)':
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.7.0(eslint@9.36.0)
|
||||
'@eslint-community/eslint-utils': 4.7.0(eslint@9.32.0)
|
||||
'@typescript-eslint/scope-manager': 8.38.0
|
||||
'@typescript-eslint/types': 8.38.0
|
||||
'@typescript-eslint/typescript-estree': 8.38.0(typescript@5.9.2)
|
||||
eslint: 9.36.0
|
||||
typescript: 5.9.2
|
||||
'@typescript-eslint/typescript-estree': 8.38.0(typescript@5.8.3)
|
||||
eslint: 9.32.0
|
||||
typescript: 5.8.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -2315,10 +2240,6 @@ 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
|
||||
@@ -2337,6 +2258,7 @@ snapshots:
|
||||
agentkeepalive@4.6.0:
|
||||
dependencies:
|
||||
humanize-ms: 1.2.1
|
||||
optional: true
|
||||
|
||||
aggregate-error@3.1.0:
|
||||
dependencies:
|
||||
@@ -2541,18 +2463,6 @@ snapshots:
|
||||
clean-stack@2.2.0:
|
||||
optional: true
|
||||
|
||||
cloudflare@5.2.0(encoding@0.1.13):
|
||||
dependencies:
|
||||
'@types/node': 18.19.130
|
||||
'@types/node-fetch': 2.6.13
|
||||
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
|
||||
@@ -2575,11 +2485,6 @@ 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
|
||||
@@ -2670,7 +2575,7 @@ snapshots:
|
||||
dependencies:
|
||||
esutils: 2.0.3
|
||||
|
||||
dotenv@17.2.2: {}
|
||||
dotenv@17.2.1: {}
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
dependencies:
|
||||
@@ -2794,9 +2699,9 @@ snapshots:
|
||||
|
||||
escape-string-regexp@4.0.0: {}
|
||||
|
||||
eslint-config-prettier@10.1.8(eslint@9.36.0):
|
||||
eslint-config-prettier@10.1.8(eslint@9.32.0):
|
||||
dependencies:
|
||||
eslint: 9.36.0
|
||||
eslint: 9.32.0
|
||||
|
||||
eslint-import-resolver-node@0.3.9:
|
||||
dependencies:
|
||||
@@ -2806,17 +2711,17 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.38.0(eslint@9.36.0)(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint@9.36.0):
|
||||
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.38.0(eslint@9.32.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint@9.32.0):
|
||||
dependencies:
|
||||
debug: 3.2.7
|
||||
optionalDependencies:
|
||||
'@typescript-eslint/parser': 8.38.0(eslint@9.36.0)(typescript@5.9.2)
|
||||
eslint: 9.36.0
|
||||
'@typescript-eslint/parser': 8.38.0(eslint@9.32.0)(typescript@5.8.3)
|
||||
eslint: 9.32.0
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.36.0)(typescript@5.9.2))(eslint@9.36.0):
|
||||
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0)(typescript@5.8.3))(eslint@9.32.0):
|
||||
dependencies:
|
||||
'@rtsao/scc': 1.1.0
|
||||
array-includes: 3.1.9
|
||||
@@ -2825,9 +2730,9 @@ snapshots:
|
||||
array.prototype.flatmap: 1.3.3
|
||||
debug: 3.2.7
|
||||
doctrine: 2.1.0
|
||||
eslint: 9.36.0
|
||||
eslint: 9.32.0
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.38.0(eslint@9.36.0)(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint@9.36.0)
|
||||
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.38.0(eslint@9.32.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint@9.32.0)
|
||||
hasown: 2.0.2
|
||||
is-core-module: 2.16.1
|
||||
is-glob: 4.0.3
|
||||
@@ -2839,20 +2744,20 @@ snapshots:
|
||||
string.prototype.trimend: 1.0.9
|
||||
tsconfig-paths: 3.15.0
|
||||
optionalDependencies:
|
||||
'@typescript-eslint/parser': 8.38.0(eslint@9.36.0)(typescript@5.9.2)
|
||||
'@typescript-eslint/parser': 8.38.0(eslint@9.32.0)(typescript@5.8.3)
|
||||
transitivePeerDependencies:
|
||||
- eslint-import-resolver-typescript
|
||||
- eslint-import-resolver-webpack
|
||||
- supports-color
|
||||
|
||||
eslint-plugin-prettier@5.5.4(eslint-config-prettier@10.1.8(eslint@9.36.0))(eslint@9.36.0)(prettier@3.6.2):
|
||||
eslint-plugin-prettier@5.5.3(eslint-config-prettier@10.1.8(eslint@9.32.0))(eslint@9.32.0)(prettier@3.6.2):
|
||||
dependencies:
|
||||
eslint: 9.36.0
|
||||
eslint: 9.32.0
|
||||
prettier: 3.6.2
|
||||
prettier-linter-helpers: 1.0.0
|
||||
synckit: 0.11.11
|
||||
optionalDependencies:
|
||||
eslint-config-prettier: 10.1.8(eslint@9.36.0)
|
||||
eslint-config-prettier: 10.1.8(eslint@9.32.0)
|
||||
|
||||
eslint-scope@8.4.0:
|
||||
dependencies:
|
||||
@@ -2863,16 +2768,16 @@ snapshots:
|
||||
|
||||
eslint-visitor-keys@4.2.1: {}
|
||||
|
||||
eslint@9.36.0:
|
||||
eslint@9.32.0:
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.0(eslint@9.36.0)
|
||||
'@eslint-community/eslint-utils': 4.7.0(eslint@9.32.0)
|
||||
'@eslint-community/regexpp': 4.12.1
|
||||
'@eslint/config-array': 0.21.0
|
||||
'@eslint/config-helpers': 0.3.1
|
||||
'@eslint/core': 0.15.2
|
||||
'@eslint/config-helpers': 0.3.0
|
||||
'@eslint/core': 0.15.1
|
||||
'@eslint/eslintrc': 3.3.1
|
||||
'@eslint/js': 9.36.0
|
||||
'@eslint/plugin-kit': 0.3.5
|
||||
'@eslint/js': 9.32.0
|
||||
'@eslint/plugin-kit': 0.3.4
|
||||
'@humanfs/node': 0.16.6
|
||||
'@humanwhocodes/module-importer': 1.0.1
|
||||
'@humanwhocodes/retry': 0.4.3
|
||||
@@ -2923,8 +2828,6 @@ snapshots:
|
||||
|
||||
esutils@2.0.3: {}
|
||||
|
||||
event-target-shim@5.0.1: {}
|
||||
|
||||
eventemitter3@5.0.1: {}
|
||||
|
||||
execa@9.6.0:
|
||||
@@ -3000,8 +2903,6 @@ snapshots:
|
||||
dependencies:
|
||||
is-callable: 1.2.7
|
||||
|
||||
form-data-encoder@1.7.2: {}
|
||||
|
||||
form-data@4.0.4:
|
||||
dependencies:
|
||||
asynckit: 0.4.0
|
||||
@@ -3010,19 +2911,6 @@ snapshots:
|
||||
hasown: 2.0.2
|
||||
mime-types: 2.1.35
|
||||
|
||||
form-data@4.0.5:
|
||||
dependencies:
|
||||
asynckit: 0.4.0
|
||||
combined-stream: 1.0.8
|
||||
es-set-tostringtag: 2.1.0
|
||||
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:
|
||||
@@ -3176,6 +3064,7 @@ snapshots:
|
||||
humanize-ms@1.2.1:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
optional: true
|
||||
|
||||
iconv-lite@0.6.3:
|
||||
dependencies:
|
||||
@@ -3440,8 +3329,6 @@ snapshots:
|
||||
yallist: 4.0.0
|
||||
optional: true
|
||||
|
||||
luxon@3.7.1: {}
|
||||
|
||||
make-fetch-happen@9.1.0:
|
||||
dependencies:
|
||||
agentkeepalive: 4.6.0
|
||||
@@ -3553,8 +3440,6 @@ 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
|
||||
@@ -3673,14 +3558,16 @@ snapshots:
|
||||
aggregate-error: 3.1.0
|
||||
optional: true
|
||||
|
||||
p-queue@8.1.1:
|
||||
p-queue@8.1.0:
|
||||
dependencies:
|
||||
eventemitter3: 5.0.1
|
||||
p-timeout: 6.1.4
|
||||
|
||||
p-retry@7.0.0:
|
||||
p-retry@6.2.1:
|
||||
dependencies:
|
||||
'@types/retry': 0.12.2
|
||||
is-network-error: 1.1.0
|
||||
retry: 0.13.1
|
||||
|
||||
p-timeout@6.1.4: {}
|
||||
|
||||
@@ -3849,6 +3736,8 @@ snapshots:
|
||||
retry@0.12.0:
|
||||
optional: true
|
||||
|
||||
retry@0.13.1: {}
|
||||
|
||||
reusify@1.1.0: {}
|
||||
|
||||
rfc4648@1.5.4: {}
|
||||
@@ -4137,9 +4026,9 @@ snapshots:
|
||||
|
||||
tr46@0.0.3: {}
|
||||
|
||||
ts-api-utils@2.1.0(typescript@5.9.2):
|
||||
ts-api-utils@2.1.0(typescript@5.8.3):
|
||||
dependencies:
|
||||
typescript: 5.9.2
|
||||
typescript: 5.8.3
|
||||
|
||||
tsconfig-paths@3.15.0:
|
||||
dependencies:
|
||||
@@ -4189,18 +4078,18 @@ snapshots:
|
||||
possible-typed-array-names: 1.1.0
|
||||
reflect.getprototypeof: 1.0.10
|
||||
|
||||
typescript-eslint@8.38.0(eslint@9.36.0)(typescript@5.9.2):
|
||||
typescript-eslint@8.38.0(eslint@9.32.0)(typescript@5.8.3):
|
||||
dependencies:
|
||||
'@typescript-eslint/eslint-plugin': 8.38.0(@typescript-eslint/parser@8.38.0(eslint@9.36.0)(typescript@5.9.2))(eslint@9.36.0)(typescript@5.9.2)
|
||||
'@typescript-eslint/parser': 8.38.0(eslint@9.36.0)(typescript@5.9.2)
|
||||
'@typescript-eslint/typescript-estree': 8.38.0(typescript@5.9.2)
|
||||
'@typescript-eslint/utils': 8.38.0(eslint@9.36.0)(typescript@5.9.2)
|
||||
eslint: 9.36.0
|
||||
typescript: 5.9.2
|
||||
'@typescript-eslint/eslint-plugin': 8.38.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0)(typescript@5.8.3))(eslint@9.32.0)(typescript@5.8.3)
|
||||
'@typescript-eslint/parser': 8.38.0(eslint@9.32.0)(typescript@5.8.3)
|
||||
'@typescript-eslint/typescript-estree': 8.38.0(typescript@5.8.3)
|
||||
'@typescript-eslint/utils': 8.38.0(eslint@9.32.0)(typescript@5.8.3)
|
||||
eslint: 9.32.0
|
||||
typescript: 5.8.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
typescript@5.9.2: {}
|
||||
typescript@5.8.3: {}
|
||||
|
||||
unbox-primitive@1.1.0:
|
||||
dependencies:
|
||||
@@ -4209,8 +4098,6 @@ 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: {}
|
||||
@@ -4231,8 +4118,6 @@ snapshots:
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
web-streams-polyfill@4.0.0-beta.3: {}
|
||||
|
||||
webidl-conversions@3.0.1: {}
|
||||
|
||||
whatwg-url@5.0.0:
|
||||
@@ -4300,10 +4185,10 @@ snapshots:
|
||||
|
||||
yallist@4.0.0: {}
|
||||
|
||||
yaml@2.8.1: {}
|
||||
yaml@2.8.0: {}
|
||||
|
||||
yocto-queue@0.1.0: {}
|
||||
|
||||
yoctocolors@2.1.1: {}
|
||||
|
||||
zod@4.1.11: {}
|
||||
zod@4.0.14: {}
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
$schema: 'https://docs.renovatebot.com/renovate-schema.json',
|
||||
extends: [
|
||||
'config:recommended',
|
||||
],
|
||||
packageRules: [
|
||||
{
|
||||
groupName: 'Docker images',
|
||||
groupSlug: 'dockerimages',
|
||||
matchDatasources: [
|
||||
'docker',
|
||||
],
|
||||
pinDigests: true,
|
||||
},
|
||||
],
|
||||
'helm-values': {
|
||||
managerFilePatterns: [
|
||||
'/^charts/.*/values\\.yaml$/',
|
||||
],
|
||||
},
|
||||
customManagers: [
|
||||
{
|
||||
customType: 'regex',
|
||||
managerFilePatterns: [
|
||||
'/^charts/.*/values\\.yaml$/',
|
||||
],
|
||||
matchStrings: [
|
||||
"repository:s*'(?<depName>.*?)'\ns*tag:s*'(?<currentValue>.*?)'",
|
||||
'repository:s*"(?<depName>.*?)"\ns*tag:s*"(?<currentValue>.*?)"',
|
||||
'repository:s*(?<depName>.*?)\ns*tag:s*(?<currentValue>.*)',
|
||||
],
|
||||
datasourceTemplate: 'docker',
|
||||
},
|
||||
],
|
||||
}
|
||||
4
scripts/apply-test.sh
Executable file
4
scripts/apply-test.sh
Executable file
@@ -0,0 +1,4 @@
|
||||
for f in "./test-manifests/"*; do
|
||||
echo "Applying $f"
|
||||
kubectl apply -f "$f"
|
||||
done
|
||||
20
scripts/create-secrets.sh
Executable file
20
scripts/create-secrets.sh
Executable file
@@ -0,0 +1,20 @@
|
||||
#!/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}"
|
||||
3
scripts/setup-server.sh
Executable file
3
scripts/setup-server.sh
Executable file
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
flux install --components="source-controller,helm-controller"
|
||||
kubectl create namespace homelab
|
||||
3
security/trivy-report/.gitignore
vendored
3
security/trivy-report/.gitignore
vendored
@@ -1,3 +0,0 @@
|
||||
/security_report.pdf
|
||||
/transformed_data.json
|
||||
/all_data.json
|
||||
@@ -1,24 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Data Extraction Function
|
||||
extract_data() {
|
||||
crd_type="$1"
|
||||
|
||||
kubectl get "$crd_type" -A -o json | jq -r '.items[] | {
|
||||
namespace: .metadata.namespace,
|
||||
name: .metadata.name,
|
||||
report: .report
|
||||
}'
|
||||
}
|
||||
|
||||
# Vulnerability Reports
|
||||
vulnerability_data=$(extract_data vulnerabilityreports)
|
||||
|
||||
# Example of capturing ConfigAuditReports (adjust jq filter as needed)
|
||||
config_audit_data=$(extract_data configauditreports)
|
||||
|
||||
# Combine the data into a proper JSON array using jq
|
||||
{
|
||||
echo "$vulnerability_data"
|
||||
echo "$config_audit_data"
|
||||
} | jq -s '.' > all_data.json
|
||||
@@ -1,82 +0,0 @@
|
||||
import json
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
import weasyprint
|
||||
|
||||
|
||||
def generate_pdf_report(transformed_data, template_file, output_file):
|
||||
"""Generates a PDF report from the transformed data and Jinja2 template."""
|
||||
|
||||
env = Environment(
|
||||
loader=FileSystemLoader(".")
|
||||
) # Load templates from the current directory
|
||||
template = env.get_template(template_file)
|
||||
html_output = template.render(transformed_data) # Render the template with the data
|
||||
|
||||
# Generate PDF using WeasyPrint
|
||||
weasyprint.HTML(string=html_output).write_pdf(output_file)
|
||||
|
||||
|
||||
# Load the already transformed JSON data
|
||||
with open("transformed_data.json", "r") as f:
|
||||
raw_data = json.load(f)
|
||||
|
||||
# Sort by severity (CRITICAL, HIGH, MEDIUM, LOW)
|
||||
severity_order = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3}
|
||||
|
||||
# Group vulnerabilities by CVE ID
|
||||
vuln_groups = {}
|
||||
for vuln in raw_data["vulnerabilities"]:
|
||||
cve_id = vuln["vulnerabilityID"]
|
||||
if cve_id not in vuln_groups:
|
||||
vuln_groups[cve_id] = {
|
||||
"vulnerabilityID": vuln["vulnerabilityID"],
|
||||
"severity": vuln["severity"],
|
||||
"title": vuln["title"],
|
||||
"packagePURL": vuln.get("packagePURL"),
|
||||
"installedVersion": vuln.get("installedVersion"),
|
||||
"fixedVersion": vuln.get("fixedVersion"),
|
||||
"affected_resources": [],
|
||||
}
|
||||
|
||||
vuln_groups[cve_id]["affected_resources"].append(
|
||||
{"namespace": vuln["namespace"], "resource": vuln["resource"]}
|
||||
)
|
||||
|
||||
# Convert to list and sort by severity
|
||||
grouped_vulnerabilities = sorted(
|
||||
list(vuln_groups.values()), key=lambda x: severity_order.get(x["severity"], 4)
|
||||
)
|
||||
|
||||
# Group config issues by checkID
|
||||
config_groups = {}
|
||||
for issue in raw_data["config_issues"]:
|
||||
check_id = issue["checkID"]
|
||||
if check_id not in config_groups:
|
||||
config_groups[check_id] = {
|
||||
"checkID": issue["checkID"],
|
||||
"severity": issue["severity"],
|
||||
"title": issue["title"],
|
||||
"description": issue["description"],
|
||||
"remediation": issue["remediation"],
|
||||
"affected_resources": [],
|
||||
}
|
||||
|
||||
config_groups[check_id]["affected_resources"].append(
|
||||
{"namespace": issue["namespace"], "resource": issue["resource"]}
|
||||
)
|
||||
|
||||
# Convert to list and sort by severity
|
||||
grouped_config_issues = sorted(
|
||||
list(config_groups.values()), key=lambda x: severity_order.get(x["severity"], 4)
|
||||
)
|
||||
|
||||
transformed_data = {
|
||||
"vulnerabilities": grouped_vulnerabilities,
|
||||
"config_issues": grouped_config_issues,
|
||||
}
|
||||
|
||||
|
||||
# Generate the PDF report
|
||||
generate_pdf_report(transformed_data, "report_template.html", "security_report.pdf")
|
||||
|
||||
print("PDF report generated successfully: security_report.pdf")
|
||||
@@ -1,6 +0,0 @@
|
||||
def main():
|
||||
print("Hello from trivy-report!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,10 +0,0 @@
|
||||
[project]
|
||||
name = "trivy-report"
|
||||
version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"jinja2>=3.1.6",
|
||||
"weasyprint>=66.0",
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user