mirror of
https://github.com/morten-olsen/homelab-operator.git
synced 2026-02-08 01:36:28 +01:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
683de402ff | ||
|
|
e8e939ad19 | ||
|
|
1b5b5145b0 | ||
|
|
cfd2d76873 | ||
|
|
9e5081ed9b | ||
|
|
3ab2b1969a | ||
|
|
a27b563113 | ||
|
|
295472a028 | ||
|
|
91298b3cf7 | ||
|
|
638c288a5c | ||
|
|
2be6bdca84 | ||
|
|
f362f4afc4 | ||
|
|
9fadbf75fb | ||
|
|
2add15d283 | ||
|
|
5426495be5 | ||
|
|
b8bb16ccbb | ||
|
|
d4b56007f1 | ||
|
|
130bfec468 | ||
|
|
ddb3c79657 |
16
.github/workflows/main.yml
vendored
16
.github/workflows/main.yml
vendored
@@ -71,9 +71,23 @@ jobs:
|
|||||||
environment: release
|
environment: release
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: release-drafter/release-drafter@v6
|
- id: create-release
|
||||||
|
uses: release-drafter/release-drafter@v6
|
||||||
with:
|
with:
|
||||||
config-name: release-drafter-config.yml
|
config-name: release-drafter-config.yml
|
||||||
publish: true
|
publish: true
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- 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
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -34,3 +34,5 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
/data/
|
/data/
|
||||||
|
|
||||||
|
/cloudflare.yaml
|
||||||
|
|||||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3.13
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:23-alpine
|
FROM node:23-slim
|
||||||
RUN corepack enable
|
RUN corepack enable
|
||||||
COPY package.json pnpm-lock.yaml ./
|
COPY package.json pnpm-lock.yaml ./
|
||||||
RUN pnpm install --frozen-lockfile --prod
|
RUN pnpm install --frozen-lockfile --prod
|
||||||
|
|||||||
13
Makefile
13
Makefile
@@ -1,15 +1,14 @@
|
|||||||
.PHONY: setup dev-recreate dev-create dev-destroy
|
.PHONY: dev-recreate dev-destroy server-install
|
||||||
|
|
||||||
setup:
|
|
||||||
./scripts/setup-server.sh
|
|
||||||
|
|
||||||
dev-destroy:
|
dev-destroy:
|
||||||
colima delete -f
|
colima delete -f
|
||||||
|
|
||||||
dev-create:
|
dev-recreate: dev-destroy
|
||||||
colima start --network-address --kubernetes -m 8 --mount ${PWD}/data:/data:w --k3s-arg="--disable=helm-controller,local-storage"
|
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-recreate: dev-destroy dev-create setup
|
setup-flux:
|
||||||
|
flux install --components="source-controller,helm-controller"
|
||||||
|
|
||||||
server-install:
|
server-install:
|
||||||
curl -sfL https://get.k3s.io | sh -s - --disable traefik,local-storage,helm-controller
|
curl -sfL https://get.k3s.io | sh -s - --disable traefik,local-storage,helm-controller
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
## Bootstrap repo
|
|
||||||
|
|
||||||
```
|
|
||||||
brew install fluxcd/tap/flux
|
|
||||||
make setup-server
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
apiVersion: cert-manager.io/v1
|
|
||||||
kind: ClusterIssuer
|
|
||||||
metadata:
|
|
||||||
name: letsencrypt-prod
|
|
||||||
annotations:
|
|
||||||
argocd.argoproj.io/sync-options: SkipDryRunOnMissingResource=true
|
|
||||||
spec:
|
|
||||||
acme:
|
|
||||||
server: https://acme-v02.api.letsencrypt.org/directory
|
|
||||||
email: alice@alice.com
|
|
||||||
privateKeySecretRef:
|
|
||||||
name: letsencrypt-prod-account-key
|
|
||||||
solvers:
|
|
||||||
- dns01:
|
|
||||||
cloudflare:
|
|
||||||
email: alice@alice.com
|
|
||||||
apiTokenSecretRef:
|
|
||||||
name: cloudflare-api-token
|
|
||||||
key: api-token
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
apiVersion: rbac.authorization.k8s.io/v1
|
|
||||||
kind: ClusterRole
|
|
||||||
metadata:
|
|
||||||
name: {{ include "homelab-operator.fullname" . }}
|
|
||||||
rules:
|
|
||||||
- apiGroups: [""]
|
|
||||||
resources: ["secrets"]
|
|
||||||
verbs: ["create", "get", "watch", "list"]
|
|
||||||
- apiGroups: ["*"]
|
|
||||||
resources: ["*"]
|
|
||||||
verbs: ["get", "watch", "list", "patch"]
|
|
||||||
- apiGroups: ["apiextensions.k8s.io"]
|
|
||||||
resources: ["customresourcedefinitions"]
|
|
||||||
verbs: ["get", "create", "replace"]
|
|
||||||
3
charts/apps/bytestash/Chart.yaml
Normal file
3
charts/apps/bytestash/Chart.yaml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
apiVersion: v2
|
||||||
|
version: 1.0.0
|
||||||
|
name: ByteStash
|
||||||
9
charts/apps/bytestash/templates/client.yaml
Normal file
9
charts/apps/bytestash/templates/client.yaml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
apiVersion: homelab.mortenolsen.pro/v1
|
||||||
|
kind: OidcClient
|
||||||
|
metadata:
|
||||||
|
name: '{{ .Release.Name }}'
|
||||||
|
spec:
|
||||||
|
environment: '{{ .Values.environment }}'
|
||||||
|
redirectUris:
|
||||||
|
- url: https://localhost:3000/api/v1/authentik/oauth2/callback
|
||||||
|
matchingMode: strict
|
||||||
11
charts/apps/bytestash/templates/external-http-service.yaml
Normal file
11
charts/apps/bytestash/templates/external-http-service.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
apiVersion: homelab.mortenolsen.pro/v1
|
||||||
|
kind: ExternalHttpService
|
||||||
|
metadata:
|
||||||
|
name: '{{ .Release.Name }}'
|
||||||
|
spec:
|
||||||
|
environment: '{{ .Values.environment }}'
|
||||||
|
subdomain: '{{ .Values.subdomain }}-external'
|
||||||
|
destination:
|
||||||
|
host: '{{ .Release.Name }}.{{ .Release.Namespace }}.svc.cluster.local'
|
||||||
|
port:
|
||||||
|
number: 80
|
||||||
13
charts/apps/bytestash/templates/headless-service.yaml
Normal file
13
charts/apps/bytestash/templates/headless-service.yaml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: '{{ .Release.Name }}-headless'
|
||||||
|
labels:
|
||||||
|
app: '{{ .Release.Name }}'
|
||||||
|
spec:
|
||||||
|
clusterIP: None
|
||||||
|
ports:
|
||||||
|
- port: 5000
|
||||||
|
name: http
|
||||||
|
selector:
|
||||||
|
app: '{{ .Release.Name }}'
|
||||||
11
charts/apps/bytestash/templates/http-service.yaml
Normal file
11
charts/apps/bytestash/templates/http-service.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
apiVersion: homelab.mortenolsen.pro/v1
|
||||||
|
kind: HttpService
|
||||||
|
metadata:
|
||||||
|
name: '{{ .Release.Name }}'
|
||||||
|
spec:
|
||||||
|
environment: '{{ .Values.environment }}'
|
||||||
|
subdomain: '{{ .Values.subdomain }}'
|
||||||
|
destination:
|
||||||
|
host: '{{ .Release.Name }}'
|
||||||
|
port:
|
||||||
|
number: 80
|
||||||
15
charts/apps/bytestash/templates/service.yaml
Normal file
15
charts/apps/bytestash/templates/service.yaml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: '{{ .Release.Name }}'
|
||||||
|
labels:
|
||||||
|
app: '{{ .Release.Name }}'
|
||||||
|
spec:
|
||||||
|
type: ClusterIP
|
||||||
|
ports:
|
||||||
|
- port: 80
|
||||||
|
targetPort: 5000
|
||||||
|
protocol: TCP
|
||||||
|
name: http
|
||||||
|
selector:
|
||||||
|
app: '{{ .Release.Name }}'
|
||||||
68
charts/apps/bytestash/templates/stateful-set.yaml
Normal file
68
charts/apps/bytestash/templates/stateful-set.yaml
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: StatefulSet
|
||||||
|
metadata:
|
||||||
|
name: '{{ .Release.Name }}'
|
||||||
|
labels:
|
||||||
|
app: '{{ .Release.Name }}'
|
||||||
|
spec:
|
||||||
|
serviceName: '{{ .Release.Name }}-headless'
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: '{{ .Release.Name }}'
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: '{{ .Release.Name }}'
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: '{{ .Release.Name }}'
|
||||||
|
image: ghcr.io/jordan-dalby/bytestash:latest
|
||||||
|
ports:
|
||||||
|
- containerPort: 5000
|
||||||
|
name: http
|
||||||
|
env:
|
||||||
|
- name: OIDC_ENABLED
|
||||||
|
value: 'true'
|
||||||
|
- name: OIDC_DISPLAY_NAME
|
||||||
|
value: OIDC
|
||||||
|
- name: OIDC_CLIENT_ID
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: '{{ .Release.Name }}-client'
|
||||||
|
key: clientId
|
||||||
|
- name: OIDC_CLIENT_SECRET
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: '{{ .Release.Name }}-client'
|
||||||
|
key: clientSecret
|
||||||
|
- name: OIDC_ISSUER_URL
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: '{{ .Release.Name }}-client'
|
||||||
|
key: configuration
|
||||||
|
|
||||||
|
# !! IMPORTANT !!
|
||||||
|
# You MUST update this Redirect URI to match your external URL.
|
||||||
|
# This URI must also be configured in your Authentik provider settings for this client.
|
||||||
|
#- name: BS_OIDC_REDIRECT_URI
|
||||||
|
#value: 'https://bytestash.your-domain.com/login/oauth2/code/oidc'
|
||||||
|
volumeMounts:
|
||||||
|
- mountPath: /data/snippets
|
||||||
|
name: bytestash-data
|
||||||
|
|
||||||
|
# Defines security context for the pod to avoid running as root.
|
||||||
|
# securityContext:
|
||||||
|
# runAsUser: 1000
|
||||||
|
# runAsGroup: 1000
|
||||||
|
# fsGroup: 1000
|
||||||
|
|
||||||
|
volumeClaimTemplates:
|
||||||
|
- metadata:
|
||||||
|
name: bytestash-data
|
||||||
|
spec:
|
||||||
|
accessModes: ['ReadWriteOnce']
|
||||||
|
storageClassName: '{{ .Values.environment }}'
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 5Gi
|
||||||
2
charts/apps/bytestash/values.yaml
Normal file
2
charts/apps/bytestash/values.yaml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
environment: dev
|
||||||
|
subdomain: bytestash
|
||||||
12
charts/operator/templates/_storageclass.yaml
Normal file
12
charts/operator/templates/_storageclass.yaml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
apiVersion: storage.k8s.io/v1
|
||||||
|
kind: StorageClass
|
||||||
|
metadata:
|
||||||
|
name: {{ include "homelab-operator.fullname" . }}-local-path
|
||||||
|
labels:
|
||||||
|
{{- include "homelab-operator.labels" . | nindent 4 }}
|
||||||
|
provisioner: reuse-local-path-provisioner
|
||||||
|
parameters:
|
||||||
|
# Add any provisioner-specific parameters here
|
||||||
|
reclaimPolicy: {{ .Values.storage.reclaimPolicy | default "Retain" }}
|
||||||
|
allowVolumeExpansion: {{ .Values.storage.allowVolumeExpansion | default false }}
|
||||||
|
volumeBindingMode: {{ .Values.storage.volumeBindingMode | default "WaitForFirstConsumer" }}
|
||||||
32
charts/operator/templates/clusterrole.yaml
Normal file
32
charts/operator/templates/clusterrole.yaml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
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"]
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
image:
|
image:
|
||||||
repository: ghcr.io/morten-olsen/homelab-operator
|
repository: ghcr.io/morten-olsen/homelab-operator
|
||||||
pullPolicy: Always
|
pullPolicy: IfNotPresent
|
||||||
# Overrides the image tag whose default is the chart appVersion.
|
# Overrides the image tag whose default is the chart appVersion.
|
||||||
tag: main
|
tag: main
|
||||||
|
|
||||||
@@ -14,6 +14,9 @@ fullnameOverride: ''
|
|||||||
|
|
||||||
storage:
|
storage:
|
||||||
path: /data/volumes
|
path: /data/volumes
|
||||||
|
reclaimPolicy: Retain
|
||||||
|
allowVolumeExpansion: false
|
||||||
|
volumeBindingMode: WaitForFirstConsumer
|
||||||
|
|
||||||
serviceAccount:
|
serviceAccount:
|
||||||
# Specifies whether a service account should be created
|
# Specifies whether a service account should be created
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
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,901 +0,0 @@
|
|||||||
# 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/`.
|
|
||||||
9
manifests/client.yaml
Normal file
9
manifests/client.yaml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
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
|
||||||
14
manifests/environment.yaml
Normal file
14
manifests/environment.yaml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: dev
|
||||||
|
---
|
||||||
|
apiVersion: homelab.mortenolsen.pro/v1
|
||||||
|
kind: Environment
|
||||||
|
metadata:
|
||||||
|
name: dev
|
||||||
|
spec:
|
||||||
|
domain: one.dev.olsen.cloud
|
||||||
|
networkIp: 192.168.107.2
|
||||||
|
tls:
|
||||||
|
issuer: lets-encrypt-prod
|
||||||
39
manifests/example-pvc.yaml
Normal file
39
manifests/example-pvc.yaml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: example-pvc
|
||||||
|
namespace: default
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 1Gi
|
||||||
|
storageClassName: homelab-operator-local-path
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Pod
|
||||||
|
metadata:
|
||||||
|
name: example-pod
|
||||||
|
namespace: default
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: example-container
|
||||||
|
image: alpine
|
||||||
|
command: ["/bin/sh", "-c", "sleep infinity"]
|
||||||
|
volumeMounts:
|
||||||
|
- name: example-volume
|
||||||
|
mountPath: /data
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 100Mi
|
||||||
|
cpu: "0.1"
|
||||||
|
requests:
|
||||||
|
memory: 50Mi
|
||||||
|
cpu: "0.05"
|
||||||
|
volumes:
|
||||||
|
- name: example-volume
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: example-pvc
|
||||||
14
manifests/test-service.yaml
Normal file
14
manifests/test-service.yaml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
apiVersion: networking.istio.io/v1alpha3
|
||||||
|
kind: ServiceEntry
|
||||||
|
metadata:
|
||||||
|
name: test-example-com
|
||||||
|
namespace: dev
|
||||||
|
spec:
|
||||||
|
hosts:
|
||||||
|
- authentik.one.dev.olsen.cloud
|
||||||
|
# (the address field is optional if you use 'resolution: DNS')
|
||||||
|
ports:
|
||||||
|
- number: 80
|
||||||
|
name: https
|
||||||
|
protocol: HTTPS
|
||||||
|
resolution: DNS
|
||||||
35
operator.yaml
Normal file
35
operator.yaml
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
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
|
||||||
@@ -22,6 +22,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@goauthentik/api": "2025.6.3-1751754396",
|
"@goauthentik/api": "2025.6.3-1751754396",
|
||||||
"@kubernetes/client-node": "^1.3.0",
|
"@kubernetes/client-node": "^1.3.0",
|
||||||
|
"cloudflare": "^4.5.0",
|
||||||
|
"cron": "^4.3.3",
|
||||||
"debounce": "^2.2.0",
|
"debounce": "^2.2.0",
|
||||||
"deep-equal": "^2.2.3",
|
"deep-equal": "^2.2.3",
|
||||||
"dotenv": "^17.2.1",
|
"dotenv": "^17.2.1",
|
||||||
@@ -35,6 +37,12 @@
|
|||||||
"yaml": "^2.8.0",
|
"yaml": "^2.8.0",
|
||||||
"zod": "^4.0.14"
|
"zod": "^4.0.14"
|
||||||
},
|
},
|
||||||
|
"imports": {
|
||||||
|
"#services/*": "./src/services/*",
|
||||||
|
"#resources/*": "./src/resources/*",
|
||||||
|
"#bootstrap/*": "./src/bootstrap/*",
|
||||||
|
"#utils/*": "./src/utils/*"
|
||||||
|
},
|
||||||
"packageManager": "pnpm@10.6.0",
|
"packageManager": "pnpm@10.6.0",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
|
|||||||
96
pnpm-lock.yaml
generated
96
pnpm-lock.yaml
generated
@@ -14,6 +14,12 @@ importers:
|
|||||||
'@kubernetes/client-node':
|
'@kubernetes/client-node':
|
||||||
specifier: ^1.3.0
|
specifier: ^1.3.0
|
||||||
version: 1.3.0(encoding@0.1.13)
|
version: 1.3.0(encoding@0.1.13)
|
||||||
|
cloudflare:
|
||||||
|
specifier: ^4.5.0
|
||||||
|
version: 4.5.0(encoding@0.1.13)
|
||||||
|
cron:
|
||||||
|
specifier: ^4.3.3
|
||||||
|
version: 4.3.3
|
||||||
debounce:
|
debounce:
|
||||||
specifier: ^2.2.0
|
specifier: ^2.2.0
|
||||||
version: 2.2.0
|
version: 2.2.0
|
||||||
@@ -229,9 +235,15 @@ packages:
|
|||||||
'@types/lodash@4.17.20':
|
'@types/lodash@4.17.20':
|
||||||
resolution: {integrity: sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==}
|
resolution: {integrity: sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==}
|
||||||
|
|
||||||
|
'@types/luxon@3.7.1':
|
||||||
|
resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==}
|
||||||
|
|
||||||
'@types/node-fetch@2.6.12':
|
'@types/node-fetch@2.6.12':
|
||||||
resolution: {integrity: sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==}
|
resolution: {integrity: sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==}
|
||||||
|
|
||||||
|
'@types/node@18.19.123':
|
||||||
|
resolution: {integrity: sha512-K7DIaHnh0mzVxreCR9qwgNxp3MH9dltPNIEddW9MYUlcKAzm+3grKNSTe2vCJHI1FaLpvpL5JGJrz1UZDKYvDg==}
|
||||||
|
|
||||||
'@types/node@22.16.5':
|
'@types/node@22.16.5':
|
||||||
resolution: {integrity: sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ==}
|
resolution: {integrity: sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ==}
|
||||||
|
|
||||||
@@ -303,6 +315,10 @@ packages:
|
|||||||
abbrev@1.1.1:
|
abbrev@1.1.1:
|
||||||
resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
|
resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
|
||||||
|
|
||||||
|
abort-controller@3.0.0:
|
||||||
|
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
|
||||||
|
engines: {node: '>=6.5'}
|
||||||
|
|
||||||
acorn-jsx@5.3.2:
|
acorn-jsx@5.3.2:
|
||||||
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -479,6 +495,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==}
|
resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
cloudflare@4.5.0:
|
||||||
|
resolution: {integrity: sha512-fPcbPKx4zF45jBvQ0z7PCdgejVAPBBCZxwqk1k7krQNfpM07Cfj97/Q6wBzvYqlWXx/zt1S9+m8vnfCe06umbQ==}
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||||
engines: {node: '>=7.0.0'}
|
engines: {node: '>=7.0.0'}
|
||||||
@@ -507,6 +526,10 @@ packages:
|
|||||||
console-control-strings@1.1.0:
|
console-control-strings@1.1.0:
|
||||||
resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==}
|
resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==}
|
||||||
|
|
||||||
|
cron@4.3.3:
|
||||||
|
resolution: {integrity: sha512-B/CJj5yL3sjtlun6RtYHvoSB26EmQ2NUmhq9ZiJSyKIM4K/fqfh9aelDFlIayD2YMeFZqWLi9hHV+c+pq2Djkw==}
|
||||||
|
engines: {node: '>=18.x'}
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@@ -754,6 +777,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
event-target-shim@5.0.1:
|
||||||
|
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
eventemitter3@5.0.1:
|
eventemitter3@5.0.1:
|
||||||
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
|
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
|
||||||
|
|
||||||
@@ -825,10 +852,17 @@ packages:
|
|||||||
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
|
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
form-data-encoder@1.7.2:
|
||||||
|
resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==}
|
||||||
|
|
||||||
form-data@4.0.4:
|
form-data@4.0.4:
|
||||||
resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
|
resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
|
formdata-node@4.4.1:
|
||||||
|
resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==}
|
||||||
|
engines: {node: '>= 12.20'}
|
||||||
|
|
||||||
fs-constants@1.0.0:
|
fs-constants@1.0.0:
|
||||||
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
|
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
|
||||||
|
|
||||||
@@ -1238,6 +1272,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
|
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
luxon@3.7.1:
|
||||||
|
resolution: {integrity: sha512-RkRWjA926cTvz5rAb1BqyWkKbbjzCGchDUIKMCUvNi17j6f6j8uHGDV82Aqcqtzd+icoYpELmG3ksgGiFNNcNg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
make-fetch-happen@9.1.0:
|
make-fetch-happen@9.1.0:
|
||||||
resolution: {integrity: sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==}
|
resolution: {integrity: sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
@@ -1339,6 +1377,11 @@ packages:
|
|||||||
node-addon-api@7.1.1:
|
node-addon-api@7.1.1:
|
||||||
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
|
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
|
||||||
|
|
||||||
|
node-domexception@1.0.0:
|
||||||
|
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
|
||||||
|
engines: {node: '>=10.5.0'}
|
||||||
|
deprecated: Use your platform's native DOMException instead
|
||||||
|
|
||||||
node-fetch@2.7.0:
|
node-fetch@2.7.0:
|
||||||
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
|
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
|
||||||
engines: {node: 4.x || >=6.0.0}
|
engines: {node: 4.x || >=6.0.0}
|
||||||
@@ -1886,6 +1929,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
|
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
undici-types@5.26.5:
|
||||||
|
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
|
||||||
|
|
||||||
undici-types@6.21.0:
|
undici-types@6.21.0:
|
||||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||||
|
|
||||||
@@ -1905,6 +1951,10 @@ packages:
|
|||||||
util-deprecate@1.0.2:
|
util-deprecate@1.0.2:
|
||||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||||
|
|
||||||
|
web-streams-polyfill@4.0.0-beta.3:
|
||||||
|
resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==}
|
||||||
|
engines: {node: '>= 14'}
|
||||||
|
|
||||||
webidl-conversions@3.0.1:
|
webidl-conversions@3.0.1:
|
||||||
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
|
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
|
||||||
|
|
||||||
@@ -2129,11 +2179,17 @@ snapshots:
|
|||||||
|
|
||||||
'@types/lodash@4.17.20': {}
|
'@types/lodash@4.17.20': {}
|
||||||
|
|
||||||
|
'@types/luxon@3.7.1': {}
|
||||||
|
|
||||||
'@types/node-fetch@2.6.12':
|
'@types/node-fetch@2.6.12':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 22.16.5
|
'@types/node': 22.16.5
|
||||||
form-data: 4.0.4
|
form-data: 4.0.4
|
||||||
|
|
||||||
|
'@types/node@18.19.123':
|
||||||
|
dependencies:
|
||||||
|
undici-types: 5.26.5
|
||||||
|
|
||||||
'@types/node@22.16.5':
|
'@types/node@22.16.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 6.21.0
|
undici-types: 6.21.0
|
||||||
@@ -2240,6 +2296,10 @@ snapshots:
|
|||||||
abbrev@1.1.1:
|
abbrev@1.1.1:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
abort-controller@3.0.0:
|
||||||
|
dependencies:
|
||||||
|
event-target-shim: 5.0.1
|
||||||
|
|
||||||
acorn-jsx@5.3.2(acorn@8.15.0):
|
acorn-jsx@5.3.2(acorn@8.15.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
acorn: 8.15.0
|
acorn: 8.15.0
|
||||||
@@ -2258,7 +2318,6 @@ snapshots:
|
|||||||
agentkeepalive@4.6.0:
|
agentkeepalive@4.6.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
humanize-ms: 1.2.1
|
humanize-ms: 1.2.1
|
||||||
optional: true
|
|
||||||
|
|
||||||
aggregate-error@3.1.0:
|
aggregate-error@3.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -2463,6 +2522,18 @@ snapshots:
|
|||||||
clean-stack@2.2.0:
|
clean-stack@2.2.0:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
cloudflare@4.5.0(encoding@0.1.13):
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 18.19.123
|
||||||
|
'@types/node-fetch': 2.6.12
|
||||||
|
abort-controller: 3.0.0
|
||||||
|
agentkeepalive: 4.6.0
|
||||||
|
form-data-encoder: 1.7.2
|
||||||
|
formdata-node: 4.4.1
|
||||||
|
node-fetch: 2.7.0(encoding@0.1.13)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- encoding
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
color-name: 1.1.4
|
color-name: 1.1.4
|
||||||
@@ -2485,6 +2556,11 @@ snapshots:
|
|||||||
console-control-strings@1.1.0:
|
console-control-strings@1.1.0:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
cron@4.3.3:
|
||||||
|
dependencies:
|
||||||
|
'@types/luxon': 3.7.1
|
||||||
|
luxon: 3.7.1
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
path-key: 3.1.1
|
path-key: 3.1.1
|
||||||
@@ -2828,6 +2904,8 @@ snapshots:
|
|||||||
|
|
||||||
esutils@2.0.3: {}
|
esutils@2.0.3: {}
|
||||||
|
|
||||||
|
event-target-shim@5.0.1: {}
|
||||||
|
|
||||||
eventemitter3@5.0.1: {}
|
eventemitter3@5.0.1: {}
|
||||||
|
|
||||||
execa@9.6.0:
|
execa@9.6.0:
|
||||||
@@ -2903,6 +2981,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
is-callable: 1.2.7
|
is-callable: 1.2.7
|
||||||
|
|
||||||
|
form-data-encoder@1.7.2: {}
|
||||||
|
|
||||||
form-data@4.0.4:
|
form-data@4.0.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
asynckit: 0.4.0
|
asynckit: 0.4.0
|
||||||
@@ -2911,6 +2991,11 @@ snapshots:
|
|||||||
hasown: 2.0.2
|
hasown: 2.0.2
|
||||||
mime-types: 2.1.35
|
mime-types: 2.1.35
|
||||||
|
|
||||||
|
formdata-node@4.4.1:
|
||||||
|
dependencies:
|
||||||
|
node-domexception: 1.0.0
|
||||||
|
web-streams-polyfill: 4.0.0-beta.3
|
||||||
|
|
||||||
fs-constants@1.0.0: {}
|
fs-constants@1.0.0: {}
|
||||||
|
|
||||||
fs-minipass@2.1.0:
|
fs-minipass@2.1.0:
|
||||||
@@ -3064,7 +3149,6 @@ snapshots:
|
|||||||
humanize-ms@1.2.1:
|
humanize-ms@1.2.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
optional: true
|
|
||||||
|
|
||||||
iconv-lite@0.6.3:
|
iconv-lite@0.6.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -3329,6 +3413,8 @@ snapshots:
|
|||||||
yallist: 4.0.0
|
yallist: 4.0.0
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
luxon@3.7.1: {}
|
||||||
|
|
||||||
make-fetch-happen@9.1.0:
|
make-fetch-happen@9.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
agentkeepalive: 4.6.0
|
agentkeepalive: 4.6.0
|
||||||
@@ -3440,6 +3526,8 @@ snapshots:
|
|||||||
|
|
||||||
node-addon-api@7.1.1: {}
|
node-addon-api@7.1.1: {}
|
||||||
|
|
||||||
|
node-domexception@1.0.0: {}
|
||||||
|
|
||||||
node-fetch@2.7.0(encoding@0.1.13):
|
node-fetch@2.7.0(encoding@0.1.13):
|
||||||
dependencies:
|
dependencies:
|
||||||
whatwg-url: 5.0.0
|
whatwg-url: 5.0.0
|
||||||
@@ -4098,6 +4186,8 @@ snapshots:
|
|||||||
has-symbols: 1.1.0
|
has-symbols: 1.1.0
|
||||||
which-boxed-primitive: 1.1.1
|
which-boxed-primitive: 1.1.1
|
||||||
|
|
||||||
|
undici-types@5.26.5: {}
|
||||||
|
|
||||||
undici-types@6.21.0: {}
|
undici-types@6.21.0: {}
|
||||||
|
|
||||||
unicorn-magic@0.3.0: {}
|
unicorn-magic@0.3.0: {}
|
||||||
@@ -4118,6 +4208,8 @@ snapshots:
|
|||||||
|
|
||||||
util-deprecate@1.0.2: {}
|
util-deprecate@1.0.2: {}
|
||||||
|
|
||||||
|
web-streams-polyfill@4.0.0-beta.3: {}
|
||||||
|
|
||||||
webidl-conversions@3.0.1: {}
|
webidl-conversions@3.0.1: {}
|
||||||
|
|
||||||
whatwg-url@5.0.0:
|
whatwg-url@5.0.0:
|
||||||
|
|||||||
9
pyproject.toml
Normal file
9
pyproject.toml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[project]
|
||||||
|
name = "homelab-operator"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Add your description here"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
dependencies = [
|
||||||
|
"kubediagrams>=0.5.0",
|
||||||
|
]
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
for f in "./test-manifests/"*; do
|
|
||||||
echo "Applying $f"
|
|
||||||
kubectl apply -f "$f"
|
|
||||||
done
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Load environment variables from .env file
|
|
||||||
if [ -f .env ]; then
|
|
||||||
export $(cat .env | grep -v '#' | awk '/=/ {print $1}')
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check if CLOUDFLARE_API_KEY is set
|
|
||||||
if [ -z "${CLOUDFLARE_API_KEY}" ]; then
|
|
||||||
echo "Error: CLOUDFLARE_API_KEY is not set. Please add it to your .env file."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create the postgres namespace if it doesn't exist
|
|
||||||
kubectl get namespace postgres > /dev/null 2>&1 || kubectl create namespace postgres
|
|
||||||
|
|
||||||
# Create the secret
|
|
||||||
kubectl create secret generic cloudflare-api-token \
|
|
||||||
--namespace cert-manager \
|
|
||||||
--from-literal=api-token="${CLOUDFLARE_API_KEY}"
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
import { K8sService } from '../src/services/k8s/k8s.ts';
|
|
||||||
import { Services } from '../src/utils/service.ts';
|
|
||||||
|
|
||||||
const services = new Services();
|
|
||||||
const k8s = services.get(K8sService);
|
|
||||||
|
|
||||||
const manifests = await k8s.extensionsApi.listCustomResourceDefinition();
|
|
||||||
|
|
||||||
for (const manifest of manifests.items) {
|
|
||||||
for (const version of manifest.spec.versions) {
|
|
||||||
console.log(`group: ${manifest.spec.group}, plural: ${manifest.spec.names.plural}, version: ${version.name}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
flux install --components="source-controller,helm-controller"
|
|
||||||
kubectl create namespace homelab
|
|
||||||
31
skaffold.yaml
Normal file
31
skaffold.yaml
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
apiVersion: skaffold/v4beta7
|
||||||
|
kind: Config
|
||||||
|
metadata:
|
||||||
|
name: homelab-operator
|
||||||
|
|
||||||
|
build:
|
||||||
|
# This tells Skaffold to build the image locally using your Docker daemon.
|
||||||
|
local:
|
||||||
|
push: false
|
||||||
|
# This is the crucial part for your workflow. Instead of pushing to a
|
||||||
|
# registry, it loads the built image directly into your cluster's nodes.
|
||||||
|
# load: true
|
||||||
|
artifacts:
|
||||||
|
# Defines the image to build. It matches the placeholder in deployment.yaml.
|
||||||
|
- image: homelaboperator
|
||||||
|
context: . # The build context is the root directory
|
||||||
|
docker:
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
|
||||||
|
manifests:
|
||||||
|
helm:
|
||||||
|
releases:
|
||||||
|
- name: homelab-operator
|
||||||
|
chartPath: charts/operator
|
||||||
|
setValueTemplates:
|
||||||
|
image.repository: '{{.IMAGE_REPO_homelaboperator}}'
|
||||||
|
image.tag: '{{.IMAGE_TAG_homelaboperator}}'
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
# Use kubectl to apply the manifests.
|
||||||
|
kubectl: {}
|
||||||
42
src/bootstrap/bootstrap.ts
Normal file
42
src/bootstrap/bootstrap.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
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 };
|
||||||
45
src/bootstrap/namespaces/namespaces.ts
Normal file
45
src/bootstrap/namespaces/namespaces.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
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 };
|
||||||
141
src/bootstrap/releases/releases.ts
Normal file
141
src/bootstrap/releases/releases.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
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 };
|
||||||
61
src/bootstrap/repos/repos.ts
Normal file
61
src/bootstrap/repos/repos.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
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.#jetstack.on('changed', this.ensure);
|
||||||
|
this.#istio.on('changed', this.ensure);
|
||||||
|
this.#authentik.on('changed', this.ensure);
|
||||||
|
this.#cloudflare.on('changed', this.ensure);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get jetstack() {
|
||||||
|
return this.#jetstack;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get istio() {
|
||||||
|
return this.#istio;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get authentik() {
|
||||||
|
return this.#authentik;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get cloudflare() {
|
||||||
|
return this.#cloudflare;
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { RepoService };
|
||||||
58661
src/clients/authentik/authentik.types.d.ts
vendored
58661
src/clients/authentik/authentik.types.d.ts
vendored
File diff suppressed because it is too large
Load Diff
@@ -1,178 +0,0 @@
|
|||||||
import type { V1Secret } from '@kubernetes/client-node';
|
|
||||||
import type { z } from 'zod';
|
|
||||||
|
|
||||||
import {
|
|
||||||
CustomResource,
|
|
||||||
type CustomResourceOptions,
|
|
||||||
type SubresourceResult,
|
|
||||||
} from '../../services/custom-resources/custom-resources.custom-resource.ts';
|
|
||||||
import { ResourceReference } from '../../services/resources/resources.ref.ts';
|
|
||||||
import { ResourceService, type Resource } from '../../services/resources/resources.ts';
|
|
||||||
import { getWithNamespace } from '../../utils/naming.ts';
|
|
||||||
import { decodeSecret, encodeSecret } from '../../utils/secrets.ts';
|
|
||||||
import { CONTROLLED_LABEL } from '../../utils/consts.ts';
|
|
||||||
import { isDeepSubset } from '../../utils/objects.ts';
|
|
||||||
import { AuthentikService } from '../../services/authentik/authentik.service.ts';
|
|
||||||
|
|
||||||
import {
|
|
||||||
authentikClientSecretSchema,
|
|
||||||
authentikClientServerSecretSchema,
|
|
||||||
type authentikClientSpecSchema,
|
|
||||||
} from './authentik-client.schemas.ts';
|
|
||||||
|
|
||||||
class AuthentikClientResource extends CustomResource<typeof authentikClientSpecSchema> {
|
|
||||||
#serverSecret: ResourceReference<V1Secret>;
|
|
||||||
#clientSecretResource: Resource<V1Secret>;
|
|
||||||
|
|
||||||
constructor(options: CustomResourceOptions<typeof authentikClientSpecSchema>) {
|
|
||||||
super(options);
|
|
||||||
const resourceService = this.services.get(ResourceService);
|
|
||||||
|
|
||||||
this.#serverSecret = new ResourceReference();
|
|
||||||
this.#clientSecretResource = resourceService.get({
|
|
||||||
apiVersion: 'v1',
|
|
||||||
kind: 'Secret',
|
|
||||||
name: `authentik-client-${this.name}`,
|
|
||||||
namespace: this.namespace,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.#updateResouces();
|
|
||||||
|
|
||||||
this.#serverSecret.on('changed', this.queueReconcile);
|
|
||||||
this.#clientSecretResource.on('changed', this.queueReconcile);
|
|
||||||
}
|
|
||||||
|
|
||||||
#updateResouces = () => {
|
|
||||||
const serverSecretNames = getWithNamespace(this.spec.secretRef, this.namespace);
|
|
||||||
const resourceService = this.services.get(ResourceService);
|
|
||||||
this.#serverSecret.current = resourceService.get({
|
|
||||||
apiVersion: 'v1',
|
|
||||||
kind: 'Secret',
|
|
||||||
name: serverSecretNames.name,
|
|
||||||
namespace: serverSecretNames.namespace,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
#reconcileClientSecret = async (): Promise<SubresourceResult> => {
|
|
||||||
const serverSecret = this.#serverSecret.current;
|
|
||||||
if (!serverSecret?.exists || !serverSecret.data) {
|
|
||||||
return {
|
|
||||||
ready: false,
|
|
||||||
failed: true,
|
|
||||||
message: 'Server or server secret not found',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const serverSecretData = authentikClientServerSecretSchema.safeParse(decodeSecret(serverSecret.data));
|
|
||||||
if (!serverSecretData.success || !serverSecretData.data) {
|
|
||||||
return {
|
|
||||||
ready: false,
|
|
||||||
failed: true,
|
|
||||||
message: 'Server secret not found',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const url = serverSecretData.data.external_url;
|
|
||||||
const appName = this.name;
|
|
||||||
const clientSecretData = authentikClientSecretSchema.safeParse(decodeSecret(this.#clientSecretResource.data));
|
|
||||||
|
|
||||||
const expectedValues: z.infer<typeof authentikClientSecretSchema> = {
|
|
||||||
clientId: this.name,
|
|
||||||
clientSecret: clientSecretData.data?.clientSecret || crypto.randomUUID(),
|
|
||||||
configuration: new URL(`/application/o/${appName}/.well-known/openid-configuration`, url).toString(),
|
|
||||||
configurationIssuer: new URL(`/application/o/${appName}/`, url).toString(),
|
|
||||||
authorization: new URL(`/application/o/${appName}/authorize/`, url).toString(),
|
|
||||||
token: new URL(`/application/o/${appName}/token/`, url).toString(),
|
|
||||||
userinfo: new URL(`/application/o/${appName}/userinfo/`, url).toString(),
|
|
||||||
endSession: new URL(`/application/o/${appName}/end-session/`, url).toString(),
|
|
||||||
jwks: new URL(`/application/o/${appName}/jwks/`, url).toString(),
|
|
||||||
};
|
|
||||||
if (!isDeepSubset(clientSecretData.data, expectedValues)) {
|
|
||||||
await this.#clientSecretResource.patch({
|
|
||||||
metadata: {
|
|
||||||
ownerReferences: [this.ref],
|
|
||||||
labels: {
|
|
||||||
...CONTROLLED_LABEL,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
data: encodeSecret(expectedValues),
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
ready: false,
|
|
||||||
syncing: true,
|
|
||||||
message: 'UpdatingManifest',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
ready: true,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
#reconcileServer = async (): Promise<SubresourceResult> => {
|
|
||||||
const serverSecret = this.#serverSecret.current;
|
|
||||||
const clientSecret = this.#clientSecretResource;
|
|
||||||
|
|
||||||
if (!serverSecret?.exists || !serverSecret.data) {
|
|
||||||
return {
|
|
||||||
ready: false,
|
|
||||||
failed: true,
|
|
||||||
message: 'Server secret not found',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const serverSecretData = authentikClientServerSecretSchema.safeParse(decodeSecret(serverSecret.data));
|
|
||||||
if (!serverSecretData.success || !serverSecretData.data) {
|
|
||||||
return {
|
|
||||||
ready: false,
|
|
||||||
failed: true,
|
|
||||||
message: 'Server secret not found',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const clientSecretData = authentikClientSecretSchema.safeParse(decodeSecret(clientSecret.data));
|
|
||||||
if (!clientSecretData.success || !clientSecretData.data) {
|
|
||||||
return {
|
|
||||||
ready: false,
|
|
||||||
failed: true,
|
|
||||||
message: 'Client secret not found',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const authentikService = this.services.get(AuthentikService);
|
|
||||||
const authentikServer = authentikService.get({
|
|
||||||
url: {
|
|
||||||
internal: serverSecretData.data.internal_url,
|
|
||||||
external: serverSecretData.data.external_url,
|
|
||||||
},
|
|
||||||
token: serverSecretData.data.token,
|
|
||||||
});
|
|
||||||
|
|
||||||
(await authentikServer).upsertClient({
|
|
||||||
...this.spec,
|
|
||||||
name: this.name,
|
|
||||||
secret: clientSecretData.data.clientSecret,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
ready: true,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
public reconcile = async () => {
|
|
||||||
if (!this.exists || this.metadata?.deletionTimestamp) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.#updateResouces();
|
|
||||||
await Promise.all([
|
|
||||||
this.reconcileSubresource('Secret', this.#reconcileClientSecret),
|
|
||||||
this.reconcileSubresource('Server', this.#reconcileServer),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const secretReady = this.conditions.get('Secret')?.status === 'True';
|
|
||||||
const serverReady = this.conditions.get('Server')?.status === 'True';
|
|
||||||
|
|
||||||
await this.conditions.set('Ready', {
|
|
||||||
status: secretReady && serverReady ? 'True' : 'False',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export { AuthentikClientResource };
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { ClientTypeEnum, SubModeEnum } from '@goauthentik/api';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
const authentikClientSpecSchema = z.object({
|
|
||||||
secretRef: z.string(),
|
|
||||||
subMode: z.enum(SubModeEnum).optional(),
|
|
||||||
clientType: z.enum(ClientTypeEnum).optional(),
|
|
||||||
redirectUris: z.array(
|
|
||||||
z.object({
|
|
||||||
url: z.string(),
|
|
||||||
matchingMode: z.enum(['strict', 'regex']),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
const authentikClientServerSecretSchema = z.object({
|
|
||||||
internal_url: z.string(),
|
|
||||||
external_url: z.string(),
|
|
||||||
token: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const authentikClientSecretSchema = z.object({
|
|
||||||
clientId: z.string(),
|
|
||||||
clientSecret: z.string().optional(),
|
|
||||||
configuration: z.string(),
|
|
||||||
configurationIssuer: z.string(),
|
|
||||||
authorization: z.string(),
|
|
||||||
token: z.string(),
|
|
||||||
userinfo: z.string(),
|
|
||||||
endSession: z.string(),
|
|
||||||
jwks: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export { authentikClientSpecSchema, authentikClientSecretSchema, authentikClientServerSecretSchema };
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts';
|
|
||||||
import { GROUP } from '../../utils/consts.ts';
|
|
||||||
|
|
||||||
import { AuthentikClientResource } from './authentik-client.resource.ts';
|
|
||||||
import { authentikClientSpecSchema } from './authentik-client.schemas.ts';
|
|
||||||
|
|
||||||
const authentikClientDefinition = createCustomResourceDefinition({
|
|
||||||
group: GROUP,
|
|
||||||
version: 'v1',
|
|
||||||
kind: 'AuthentikClient',
|
|
||||||
names: {
|
|
||||||
plural: 'authentikclients',
|
|
||||||
singular: 'authentikclient',
|
|
||||||
},
|
|
||||||
create: (options) => new AuthentikClientResource(options),
|
|
||||||
spec: authentikClientSpecSchema,
|
|
||||||
});
|
|
||||||
|
|
||||||
export { authentikClientDefinition };
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { authentikClientDefinition } from './authentik-client/authentik-client.ts';
|
|
||||||
import { generateSecretDefinition } from './generate-secret/generate-secret.ts';
|
|
||||||
import { postgresDatabaseDefinition } from './postgres-database/postgres-database.ts';
|
|
||||||
|
|
||||||
const customResources = [postgresDatabaseDefinition, authentikClientDefinition, generateSecretDefinition];
|
|
||||||
|
|
||||||
export { customResources };
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import type { V1Secret } from '@kubernetes/client-node';
|
|
||||||
|
|
||||||
import {
|
|
||||||
CustomResource,
|
|
||||||
type CustomResourceOptions,
|
|
||||||
} from '../../services/custom-resources/custom-resources.custom-resource.ts';
|
|
||||||
import { Resource, ResourceService } from '../../services/resources/resources.ts';
|
|
||||||
import { decodeSecret, encodeSecret } from '../../utils/secrets.ts';
|
|
||||||
import { isDeepSubset } from '../../utils/objects.ts';
|
|
||||||
|
|
||||||
import { generateSecrets } from './generate-secret.utils.ts';
|
|
||||||
import { generateSecretSpecSchema } from './generate-secret.schemas.ts';
|
|
||||||
|
|
||||||
class GenerateSecretResource extends CustomResource<typeof generateSecretSpecSchema> {
|
|
||||||
#secretResource: Resource<V1Secret>;
|
|
||||||
|
|
||||||
constructor(options: CustomResourceOptions<typeof generateSecretSpecSchema>) {
|
|
||||||
super(options);
|
|
||||||
const resourceService = this.services.get(ResourceService);
|
|
||||||
|
|
||||||
this.#secretResource = resourceService.get({
|
|
||||||
apiVersion: 'v1',
|
|
||||||
kind: 'Secret',
|
|
||||||
name: this.name,
|
|
||||||
namespace: this.namespace,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.#secretResource.on('changed', this.queueReconcile);
|
|
||||||
}
|
|
||||||
|
|
||||||
public reconcile = async () => {
|
|
||||||
if (!this.exists || this.metadata?.deletionTimestamp) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const secrets = generateSecrets(this.spec);
|
|
||||||
const current = decodeSecret(this.#secretResource.data) || {};
|
|
||||||
|
|
||||||
const expected = {
|
|
||||||
...secrets,
|
|
||||||
...current,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isDeepSubset(current, expected)) {
|
|
||||||
this.#secretResource.patch({
|
|
||||||
data: encodeSecret(expected),
|
|
||||||
});
|
|
||||||
this.conditions.set('SecretUpdated', {
|
|
||||||
status: 'False',
|
|
||||||
reason: 'SecretUpdated',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
this.conditions.set('Ready', {
|
|
||||||
status: 'True',
|
|
||||||
reason: 'Ready',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export { GenerateSecretResource };
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
const generateSecretFieldSchema = z.object({
|
|
||||||
name: z.string(),
|
|
||||||
value: z.string().optional(),
|
|
||||||
encoding: z.enum(['base64', 'base64url', 'hex', 'utf8', 'numeric']).optional(),
|
|
||||||
length: z.number().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const generateSecretSpecSchema = z.object({
|
|
||||||
fields: z.array(generateSecretFieldSchema),
|
|
||||||
});
|
|
||||||
|
|
||||||
type GenerateSecretField = z.infer<typeof generateSecretFieldSchema>;
|
|
||||||
type GenerateSecretSpec = z.infer<typeof generateSecretSpecSchema>;
|
|
||||||
|
|
||||||
export { generateSecretSpecSchema, type GenerateSecretField, type GenerateSecretSpec };
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts';
|
|
||||||
import { GROUP } from '../../utils/consts.ts';
|
|
||||||
|
|
||||||
import { GenerateSecretResource } from './generate-secret.resource.ts';
|
|
||||||
import { generateSecretSpecSchema } from './generate-secret.schemas.ts';
|
|
||||||
|
|
||||||
const generateSecretDefinition = createCustomResourceDefinition({
|
|
||||||
group: GROUP,
|
|
||||||
version: 'v1',
|
|
||||||
kind: 'GenerateSecret',
|
|
||||||
names: {
|
|
||||||
plural: 'generate-secrets',
|
|
||||||
singular: 'generate-secret',
|
|
||||||
},
|
|
||||||
spec: generateSecretSpecSchema,
|
|
||||||
create: (options) => new GenerateSecretResource(options),
|
|
||||||
});
|
|
||||||
|
|
||||||
export { generateSecretDefinition };
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
const postgresDatabaseSpecSchema = z.object({
|
|
||||||
secretRef: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const postgresDatabaseSecretSchema = z.object({
|
|
||||||
host: z.string(),
|
|
||||||
port: z.string(),
|
|
||||||
user: z.string(),
|
|
||||||
password: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const postgresDatabaseConnectionSecretSchema = z.object({
|
|
||||||
host: z.string(),
|
|
||||||
port: z.string(),
|
|
||||||
user: z.string(),
|
|
||||||
password: z.string(),
|
|
||||||
database: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export { postgresDatabaseSpecSchema, postgresDatabaseSecretSchema, postgresDatabaseConnectionSecretSchema };
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
import { z } from 'zod';
|
|
||||||
import type { V1Secret } from '@kubernetes/client-node';
|
|
||||||
|
|
||||||
import {
|
|
||||||
CustomResource,
|
|
||||||
type CustomResourceOptions,
|
|
||||||
type SubresourceResult,
|
|
||||||
} from '../../services/custom-resources/custom-resources.custom-resource.ts';
|
|
||||||
import { PostgresService } from '../../services/postgres/postgres.service.ts';
|
|
||||||
import { ResourceReference } from '../../services/resources/resources.ref.ts';
|
|
||||||
import { Resource, ResourceService } from '../../services/resources/resources.ts';
|
|
||||||
import { getWithNamespace } from '../../utils/naming.ts';
|
|
||||||
import { decodeSecret, encodeSecret } from '../../utils/secrets.ts';
|
|
||||||
import { isDeepSubset } from '../../utils/objects.ts';
|
|
||||||
|
|
||||||
import {
|
|
||||||
postgresDatabaseConnectionSecretSchema,
|
|
||||||
postgresDatabaseSecretSchema,
|
|
||||||
type postgresDatabaseSpecSchema,
|
|
||||||
} from './portgres-database.schemas.ts';
|
|
||||||
|
|
||||||
const SECRET_READY_CONDITION = 'Secret';
|
|
||||||
const DATABASE_READY_CONDITION = 'Database';
|
|
||||||
|
|
||||||
const secretDataSchema = z.object({
|
|
||||||
host: z.string(),
|
|
||||||
port: z.string().optional(),
|
|
||||||
database: z.string(),
|
|
||||||
user: z.string(),
|
|
||||||
password: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
class PostgresDatabaseResource extends CustomResource<typeof postgresDatabaseSpecSchema> {
|
|
||||||
#serverSecret: ResourceReference<V1Secret>;
|
|
||||||
#databaseSecret: Resource<V1Secret>;
|
|
||||||
|
|
||||||
constructor(options: CustomResourceOptions<typeof postgresDatabaseSpecSchema>) {
|
|
||||||
super(options);
|
|
||||||
this.#serverSecret = new ResourceReference();
|
|
||||||
|
|
||||||
const resourceService = this.services.get(ResourceService);
|
|
||||||
this.#databaseSecret = resourceService.get({
|
|
||||||
apiVersion: 'v1',
|
|
||||||
kind: 'Secret',
|
|
||||||
name: `${this.name}-connection`,
|
|
||||||
namespace: this.namespace,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.#updateSecret();
|
|
||||||
this.#serverSecret.on('changed', this.queueReconcile);
|
|
||||||
}
|
|
||||||
|
|
||||||
get #dbName() {
|
|
||||||
return `${this.namespace}_${this.name}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
get #userName() {
|
|
||||||
return `${this.namespace}_${this.name}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
#updateSecret = () => {
|
|
||||||
const resourceService = this.services.get(ResourceService);
|
|
||||||
const secretNames = getWithNamespace(this.spec.secretRef, this.namespace);
|
|
||||||
this.#serverSecret.current = resourceService.get({
|
|
||||||
apiVersion: 'v1',
|
|
||||||
kind: 'Secret',
|
|
||||||
name: secretNames.name,
|
|
||||||
namespace: secretNames.namespace,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
#reconcileSecret = async (): Promise<SubresourceResult> => {
|
|
||||||
const serverSecret = this.#serverSecret.current;
|
|
||||||
const databaseSecret = this.#databaseSecret;
|
|
||||||
|
|
||||||
if (!serverSecret?.exists || !serverSecret.data) {
|
|
||||||
return {
|
|
||||||
ready: false,
|
|
||||||
failed: true,
|
|
||||||
reason: 'MissingConnectionSecret',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const serverSecretData = postgresDatabaseSecretSchema.safeParse(decodeSecret(serverSecret.data));
|
|
||||||
if (!serverSecretData.success || !serverSecretData.data) {
|
|
||||||
return {
|
|
||||||
ready: false,
|
|
||||||
syncing: true,
|
|
||||||
reason: 'SecretMissing',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const databaseSecretData = postgresDatabaseConnectionSecretSchema.safeParse(decodeSecret(databaseSecret.data));
|
|
||||||
const expectedSecret = {
|
|
||||||
password: crypto.randomUUID(),
|
|
||||||
host: serverSecretData.data.host,
|
|
||||||
port: serverSecretData.data.port,
|
|
||||||
user: this.#userName,
|
|
||||||
database: this.#dbName,
|
|
||||||
...databaseSecretData.data,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isDeepSubset(databaseSecretData.data, expectedSecret)) {
|
|
||||||
databaseSecret.patch({
|
|
||||||
data: encodeSecret(expectedSecret),
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
ready: false,
|
|
||||||
syncing: true,
|
|
||||||
reason: 'SecretNotReady',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
ready: true,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
#reconcileDatabase = async (): Promise<SubresourceResult> => {
|
|
||||||
const connectionSecret = this.#serverSecret.current;
|
|
||||||
if (!connectionSecret?.exists || !connectionSecret.data) {
|
|
||||||
return {
|
|
||||||
ready: false,
|
|
||||||
failed: true,
|
|
||||||
reason: 'MissingConnectionSecret',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const connectionSecretData = postgresDatabaseSecretSchema.safeParse(decodeSecret(connectionSecret.data));
|
|
||||||
if (!connectionSecretData.success || !connectionSecretData.data) {
|
|
||||||
return {
|
|
||||||
ready: false,
|
|
||||||
syncing: true,
|
|
||||||
reason: 'SecretMissing',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const secretData = postgresDatabaseConnectionSecretSchema.safeParse(decodeSecret(this.#databaseSecret.data));
|
|
||||||
if (!secretData.success || !secretData.data) {
|
|
||||||
return {
|
|
||||||
ready: false,
|
|
||||||
syncing: true,
|
|
||||||
reason: 'ConnectionSecretMissing',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const postgresService = this.services.get(PostgresService);
|
|
||||||
const database = postgresService.get({
|
|
||||||
...connectionSecretData.data,
|
|
||||||
port: connectionSecretData.data.port ? Number(connectionSecretData.data.port) : 5432,
|
|
||||||
});
|
|
||||||
await database.upsertRole({
|
|
||||||
name: secretData.data.user,
|
|
||||||
password: secretData.data.password,
|
|
||||||
});
|
|
||||||
await database.upsertDatabase({
|
|
||||||
name: secretData.data.database,
|
|
||||||
owner: secretData.data.user,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
ready: true,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
public reconcile = async () => {
|
|
||||||
if (!this.exists || this.metadata?.deletionTimestamp) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.#updateSecret();
|
|
||||||
await Promise.allSettled([
|
|
||||||
await this.reconcileSubresource(DATABASE_READY_CONDITION, this.#reconcileDatabase),
|
|
||||||
await this.reconcileSubresource(SECRET_READY_CONDITION, this.#reconcileSecret),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const secretReady = this.conditions.get(SECRET_READY_CONDITION)?.status === 'True';
|
|
||||||
const databaseReady = this.conditions.get(DATABASE_READY_CONDITION)?.status === 'True';
|
|
||||||
await this.conditions.set('Ready', {
|
|
||||||
status: secretReady && databaseReady ? 'True' : 'False',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export { PostgresDatabaseResource, secretDataSchema as postgresDatabaseSecretSchema };
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts';
|
|
||||||
import { GROUP } from '../../utils/consts.ts';
|
|
||||||
|
|
||||||
import { postgresDatabaseSpecSchema } from './portgres-database.schemas.ts';
|
|
||||||
import { PostgresDatabaseResource } from './postgres-database.resource.ts';
|
|
||||||
|
|
||||||
const postgresDatabaseDefinition = createCustomResourceDefinition({
|
|
||||||
group: GROUP,
|
|
||||||
version: 'v1',
|
|
||||||
kind: 'PostgresDatabase',
|
|
||||||
names: {
|
|
||||||
plural: 'postgresdatabases',
|
|
||||||
singular: 'postgresdatabase',
|
|
||||||
},
|
|
||||||
spec: postgresDatabaseSpecSchema,
|
|
||||||
create: (options) => new PostgresDatabaseResource(options),
|
|
||||||
});
|
|
||||||
|
|
||||||
export { postgresDatabaseDefinition };
|
|
||||||
87
src/index.ts
87
src/index.ts
@@ -1,82 +1,17 @@
|
|||||||
import 'dotenv/config';
|
import { ResourceService } from './services/resources/resources.ts';
|
||||||
import { ApiException } from '@kubernetes/client-node';
|
|
||||||
|
|
||||||
import { Services } from './utils/service.ts';
|
import { Services } from './utils/service.ts';
|
||||||
import { CustomResourceService } from './services/custom-resources/custom-resources.ts';
|
import { BootstrapService } from './bootstrap/bootstrap.ts';
|
||||||
import { WatcherService } from './services/watchers/watchers.ts';
|
|
||||||
import { customResources } from './custom-resouces/custom-resources.ts';
|
|
||||||
import { StorageProvider } from './storage-provider/storage-provider.ts';
|
|
||||||
|
|
||||||
process.on('uncaughtException', (error) => {
|
import { resources } from '#resources/resources.ts';
|
||||||
console.log('UNCAUGHT EXCEPTION');
|
import { homelab } from '#resources/homelab/homelab.ts';
|
||||||
if (error instanceof ApiException) {
|
|
||||||
return console.error(error.body);
|
|
||||||
}
|
|
||||||
console.error(error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
process.on('unhandledRejection', (error) => {
|
|
||||||
console.log('UNHANDLED REJECTION');
|
|
||||||
if (error instanceof Error) {
|
|
||||||
console.error(error.stack);
|
|
||||||
}
|
|
||||||
if (error instanceof ApiException) {
|
|
||||||
return console.error(error.body);
|
|
||||||
}
|
|
||||||
console.error(error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
const services = new Services();
|
const services = new Services();
|
||||||
const watcherService = services.get(WatcherService);
|
const resourceService = services.get(ResourceService);
|
||||||
const storageProvider = services.get(StorageProvider);
|
|
||||||
await storageProvider.start();
|
|
||||||
await watcherService
|
|
||||||
.create({
|
|
||||||
path: '/apis/apiextensions.k8s.io/v1/customresourcedefinitions',
|
|
||||||
list: async (k8s) => {
|
|
||||||
return await k8s.extensionsApi.listCustomResourceDefinition();
|
|
||||||
},
|
|
||||||
verbs: ['add', 'update', 'delete'],
|
|
||||||
transform: (manifest) => ({
|
|
||||||
apiVersion: 'apiextensions.k8s.io/v1',
|
|
||||||
kind: 'CustomResourceDefinition',
|
|
||||||
...manifest,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
.start();
|
|
||||||
await watcherService
|
|
||||||
.create({
|
|
||||||
path: '/api/v1/secrets',
|
|
||||||
list: async (k8s) => {
|
|
||||||
return await k8s.api.listSecretForAllNamespaces();
|
|
||||||
},
|
|
||||||
verbs: ['add', 'update', 'delete'],
|
|
||||||
transform: (manifest) => ({
|
|
||||||
apiVersion: 'v1',
|
|
||||||
kind: 'Secret',
|
|
||||||
...manifest,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
.start();
|
|
||||||
await watcherService
|
|
||||||
.create({
|
|
||||||
path: '/apis/apps/v1/deployments',
|
|
||||||
list: async (k8s) => {
|
|
||||||
return await k8s.apps.listDeploymentForAllNamespaces({});
|
|
||||||
},
|
|
||||||
verbs: ['add', 'update', 'delete'],
|
|
||||||
transform: (manifest) => ({
|
|
||||||
apiVersion: 'apps/v1',
|
|
||||||
kind: 'Deployment',
|
|
||||||
...manifest,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
.start();
|
|
||||||
|
|
||||||
const customResourceService = services.get(CustomResourceService);
|
await resourceService.install(...Object.values(homelab));
|
||||||
customResourceService.register(...customResources);
|
await resourceService.register(...Object.values(resources));
|
||||||
|
|
||||||
await customResourceService.install(true);
|
const bootstrapService = services.get(BootstrapService);
|
||||||
await customResourceService.watch();
|
await bootstrapService.ensure();
|
||||||
|
|
||||||
|
console.log('Started');
|
||||||
|
|||||||
9
src/resources/cert-manager/cert-manager.ts
Normal file
9
src/resources/cert-manager/cert-manager.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Certificate } from './certificate/certificate.ts';
|
||||||
|
|
||||||
|
import type { ResourceClass } from '#services/resources/resources.ts';
|
||||||
|
|
||||||
|
const certManager = {
|
||||||
|
certificate: Certificate,
|
||||||
|
} satisfies Record<string, ResourceClass<ExpectedAny>>;
|
||||||
|
|
||||||
|
export { certManager };
|
||||||
37
src/resources/cert-manager/certificate/certificate.ts
Normal file
37
src/resources/cert-manager/certificate/certificate.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import type { KubernetesObject } from '@kubernetes/client-node';
|
||||||
|
import type { K8SCertificateV1 } from 'src/__generated__/resources/K8SCertificateV1.ts';
|
||||||
|
|
||||||
|
import { CRD } from '#resources/core/crd/crd.ts';
|
||||||
|
import { Resource, ResourceService, type ResourceOptions } from '#services/resources/resources.ts';
|
||||||
|
import { NotReadyError } from '#utils/errors.ts';
|
||||||
|
|
||||||
|
class Certificate extends Resource<KubernetesObject & K8SCertificateV1> {
|
||||||
|
public static readonly apiVersion = 'cert-manager.io/v1';
|
||||||
|
public static readonly kind = 'Certificate';
|
||||||
|
|
||||||
|
#crd: CRD;
|
||||||
|
|
||||||
|
constructor(options: ResourceOptions<KubernetesObject & K8SCertificateV1>) {
|
||||||
|
super(options);
|
||||||
|
const resourceService = this.services.get(ResourceService);
|
||||||
|
this.#crd = resourceService.get(CRD, 'certificates.cert-manager.io');
|
||||||
|
this.#crd.on('changed', this.#handleCrdChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
#handleCrdChanged = () => {
|
||||||
|
this.emit('changed', this.manifest);
|
||||||
|
};
|
||||||
|
|
||||||
|
public get hasCRD() {
|
||||||
|
return this.#crd.exists;
|
||||||
|
}
|
||||||
|
|
||||||
|
public set = async (manifest: KubernetesObject & K8SCertificateV1) => {
|
||||||
|
if (!this.hasCRD) {
|
||||||
|
throw new NotReadyError('MissingCRD', 'certificates.cert-manager.io does not exist');
|
||||||
|
}
|
||||||
|
return this.ensure(manifest);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Certificate };
|
||||||
23
src/resources/core/core.ts
Normal file
23
src/resources/core/core.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { CRD } from './crd/crd.ts';
|
||||||
|
import { Deployment } from './deployment/deployment.ts';
|
||||||
|
import { Namespace } from './namespace/namespace.ts';
|
||||||
|
import { PersistentVolume } from './pv/pv.ts';
|
||||||
|
import { PVC } from './pvc/pvc.ts';
|
||||||
|
import { Secret } from './secret/secret.ts';
|
||||||
|
import { Service } from './service/service.ts';
|
||||||
|
import { StatefulSet } from './stateful-set/stateful-set.ts';
|
||||||
|
import { StorageClass } from './storage-class/storage-class.ts';
|
||||||
|
|
||||||
|
const core = {
|
||||||
|
namespace: Namespace,
|
||||||
|
storageClass: StorageClass,
|
||||||
|
pvc: PVC,
|
||||||
|
pv: PersistentVolume,
|
||||||
|
secret: Secret,
|
||||||
|
crd: CRD,
|
||||||
|
service: Service,
|
||||||
|
deployment: Deployment,
|
||||||
|
statefulSet: StatefulSet,
|
||||||
|
};
|
||||||
|
|
||||||
|
export { core };
|
||||||
10
src/resources/core/crd/crd.ts
Normal file
10
src/resources/core/crd/crd.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { V1CustomResourceDefinition } from '@kubernetes/client-node';
|
||||||
|
|
||||||
|
import { Resource } from '#services/resources/resources.ts';
|
||||||
|
|
||||||
|
class CRD extends Resource<V1CustomResourceDefinition> {
|
||||||
|
public static readonly apiVersion = 'apiextensions.k8s.io/v1';
|
||||||
|
public static readonly kind = 'CustomResourceDefinition';
|
||||||
|
}
|
||||||
|
|
||||||
|
export { CRD };
|
||||||
10
src/resources/core/deployment/deployment.ts
Normal file
10
src/resources/core/deployment/deployment.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { V1Deployment } from '@kubernetes/client-node';
|
||||||
|
|
||||||
|
import { Resource } from '#services/resources/resources.ts';
|
||||||
|
|
||||||
|
class Deployment extends Resource<V1Deployment> {
|
||||||
|
public static readonly apiVersion = 'apps/v1';
|
||||||
|
public static readonly kind = 'Deployment';
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Deployment };
|
||||||
10
src/resources/core/namespace/namespace.ts
Normal file
10
src/resources/core/namespace/namespace.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { V1Namespace } from '@kubernetes/client-node';
|
||||||
|
|
||||||
|
import { Resource } from '#services/resources/resources.ts';
|
||||||
|
|
||||||
|
class Namespace extends Resource<V1Namespace> {
|
||||||
|
public static readonly apiVersion = 'v1';
|
||||||
|
public static readonly kind = 'Namespace';
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Namespace };
|
||||||
10
src/resources/core/pv/pv.ts
Normal file
10
src/resources/core/pv/pv.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { V1PersistentVolume } from '@kubernetes/client-node';
|
||||||
|
|
||||||
|
import { Resource } from '#services/resources/resources.ts';
|
||||||
|
|
||||||
|
class PersistentVolume extends Resource<V1PersistentVolume> {
|
||||||
|
public static readonly apiVersion = 'v1';
|
||||||
|
public static readonly kind = 'PersistentVolume';
|
||||||
|
}
|
||||||
|
|
||||||
|
export { PersistentVolume };
|
||||||
80
src/resources/core/pvc/pvc.ts
Normal file
80
src/resources/core/pvc/pvc.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import type { V1PersistentVolumeClaim } from '@kubernetes/client-node';
|
||||||
|
|
||||||
|
import { StorageClass } from '../storage-class/storage-class.ts';
|
||||||
|
import { PersistentVolume } from '../pv/pv.ts';
|
||||||
|
|
||||||
|
import { Resource, ResourceService, type ResourceOptions } from '#services/resources/resources.ts';
|
||||||
|
|
||||||
|
const PROVISIONER = 'homelab-operator';
|
||||||
|
|
||||||
|
class PVC extends Resource<V1PersistentVolumeClaim> {
|
||||||
|
public static readonly apiVersion = 'v1';
|
||||||
|
public static readonly kind = 'PersistentVolumeClaim';
|
||||||
|
|
||||||
|
constructor(options: ResourceOptions<V1PersistentVolumeClaim>) {
|
||||||
|
super(options);
|
||||||
|
this.on('changed', this.reconcile);
|
||||||
|
}
|
||||||
|
|
||||||
|
public reconcile = async () => {
|
||||||
|
const storageClassName = this.spec?.storageClassName;
|
||||||
|
console.log('PVC', this.name, storageClassName);
|
||||||
|
if (!storageClassName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const resourceService = this.services.get(ResourceService);
|
||||||
|
const storageClass = resourceService.get(StorageClass, storageClassName);
|
||||||
|
|
||||||
|
if (!storageClass.exists || storageClass.manifest?.provisioner !== PROVISIONER) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.status?.phase === 'Pending' && !this.spec?.volumeName) {
|
||||||
|
await this.#provisionVolume(storageClass);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
#provisionVolume = async (storageClass: StorageClass) => {
|
||||||
|
const pvName = `pv-${this.namespace}-${this.name}`;
|
||||||
|
const storageLocation = storageClass.manifest?.parameters?.storageLocation || '/data/volumes';
|
||||||
|
const target = `${storageLocation}/${this.namespace}/${this.name}`;
|
||||||
|
|
||||||
|
const resourceService = this.services.get(ResourceService);
|
||||||
|
const pv = resourceService.get(PersistentVolume, pvName);
|
||||||
|
|
||||||
|
await pv.ensure({
|
||||||
|
metadata: {
|
||||||
|
name: pvName,
|
||||||
|
labels: {
|
||||||
|
provisioner: PROVISIONER,
|
||||||
|
'pvc-namespace': this.namespace || 'default',
|
||||||
|
'pvc-name': this.name || 'unknown',
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
'pv.kubernetes.io/provisioned-by': PROVISIONER,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
hostPath: {
|
||||||
|
path: target,
|
||||||
|
type: 'DirectoryOrCreate',
|
||||||
|
},
|
||||||
|
capacity: {
|
||||||
|
storage: this.spec?.resources?.requests?.storage ?? '1Gi',
|
||||||
|
},
|
||||||
|
persistentVolumeReclaimPolicy: 'Retain',
|
||||||
|
accessModes: this.spec?.accessModes ?? ['ReadWriteOnce'],
|
||||||
|
storageClassName: this.spec?.storageClassName,
|
||||||
|
claimRef: {
|
||||||
|
uid: this.metadata?.uid,
|
||||||
|
resourceVersion: this.metadata?.resourceVersion,
|
||||||
|
apiVersion: this.apiVersion,
|
||||||
|
kind: 'PersistentVolumeClaim',
|
||||||
|
name: this.name,
|
||||||
|
namespace: this.namespace,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { PVC, PROVISIONER };
|
||||||
25
src/resources/core/secret/secret.ts
Normal file
25
src/resources/core/secret/secret.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type { KubernetesObject, V1Secret } from '@kubernetes/client-node';
|
||||||
|
|
||||||
|
import { Resource } from '#services/resources/resources.ts';
|
||||||
|
import { decodeSecret, encodeSecret } from '#utils/secrets.ts';
|
||||||
|
|
||||||
|
type SetOptions<T extends Record<string, string | undefined>> = T | ((current: T | undefined) => T | Promise<T>);
|
||||||
|
|
||||||
|
class Secret<T extends Record<string, string> = Record<string, string>> extends Resource<V1Secret> {
|
||||||
|
public static readonly apiVersion = 'v1';
|
||||||
|
public static readonly kind = 'Secret';
|
||||||
|
|
||||||
|
public get value() {
|
||||||
|
return decodeSecret(this.data) as T | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
public set = async (options: SetOptions<T>, data?: KubernetesObject) => {
|
||||||
|
const value = typeof options === 'function' ? await Promise.resolve(options(this.value)) : options;
|
||||||
|
await this.ensure({
|
||||||
|
...data,
|
||||||
|
data: encodeSecret(value),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Secret };
|
||||||
14
src/resources/core/service/service.ts
Normal file
14
src/resources/core/service/service.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { V1Service } from '@kubernetes/client-node';
|
||||||
|
|
||||||
|
import { Resource } from '#services/resources/resources.ts';
|
||||||
|
|
||||||
|
class Service extends Resource<V1Service> {
|
||||||
|
public static readonly apiVersion = 'v1';
|
||||||
|
public static readonly kind = 'Service';
|
||||||
|
|
||||||
|
public get hostname() {
|
||||||
|
return `${this.name}.${this.namespace}.svc.cluster.local`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Service };
|
||||||
10
src/resources/core/stateful-set/stateful-set.ts
Normal file
10
src/resources/core/stateful-set/stateful-set.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { V1StatefulSet } from '@kubernetes/client-node';
|
||||||
|
|
||||||
|
import { Resource } from '#services/resources/resources.ts';
|
||||||
|
|
||||||
|
class StatefulSet extends Resource<V1StatefulSet> {
|
||||||
|
public static readonly apiVersion = 'apps/v1';
|
||||||
|
public static readonly kind = 'StatefulSet';
|
||||||
|
}
|
||||||
|
|
||||||
|
export { StatefulSet };
|
||||||
11
src/resources/core/storage-class/storage-class.ts
Normal file
11
src/resources/core/storage-class/storage-class.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import type { V1StorageClass } from '@kubernetes/client-node';
|
||||||
|
|
||||||
|
import { Resource } from '#services/resources/resources.ts';
|
||||||
|
|
||||||
|
class StorageClass extends Resource<V1StorageClass> {
|
||||||
|
public static readonly apiVersion = 'storage.k8s.io/v1';
|
||||||
|
public static readonly kind = 'StorageClass';
|
||||||
|
public static readonly plural = 'storageclasses';
|
||||||
|
}
|
||||||
|
|
||||||
|
export { StorageClass };
|
||||||
11
src/resources/flux/flux.ts
Normal file
11
src/resources/flux/flux.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { HelmRelease } from './helm-release/helm-release.ts';
|
||||||
|
import { HelmRepo } from './helm-repo/helm-repo.ts';
|
||||||
|
|
||||||
|
import type { ResourceClass } from '#services/resources/resources.ts';
|
||||||
|
|
||||||
|
const flux = {
|
||||||
|
helmRelease: HelmRelease,
|
||||||
|
helmRepo: HelmRepo,
|
||||||
|
} satisfies Record<string, ResourceClass<ExpectedAny>>;
|
||||||
|
|
||||||
|
export { flux };
|
||||||
42
src/resources/flux/helm-release/helm-release.ts
Normal file
42
src/resources/flux/helm-release/helm-release.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import type { KubernetesObject } from '@kubernetes/client-node';
|
||||||
|
import type { K8SHelmReleaseV2 } from 'src/__generated__/resources/K8SHelmReleaseV2.ts';
|
||||||
|
|
||||||
|
import { Resource } from '#services/resources/resources.ts';
|
||||||
|
|
||||||
|
type SetOptions = {
|
||||||
|
namespace?: string;
|
||||||
|
values?: Record<string, unknown>;
|
||||||
|
chart: {
|
||||||
|
name: string;
|
||||||
|
namespace?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
class HelmRelease extends Resource<KubernetesObject & K8SHelmReleaseV2> {
|
||||||
|
public static readonly apiVersion = 'helm.toolkit.fluxcd.io/v2';
|
||||||
|
public static readonly kind = 'HelmRelease';
|
||||||
|
|
||||||
|
public set = async (options: SetOptions) => {
|
||||||
|
return await this.ensure({
|
||||||
|
spec: {
|
||||||
|
targetNamespace: options.namespace,
|
||||||
|
interval: '1h',
|
||||||
|
values: options.values,
|
||||||
|
chart: {
|
||||||
|
spec: {
|
||||||
|
chart: 'cert-manager',
|
||||||
|
version: 'v1.18.2',
|
||||||
|
sourceRef: {
|
||||||
|
apiVersion: 'source.toolkit.fluxcd.io/v1',
|
||||||
|
kind: 'HelmRepository',
|
||||||
|
name: options.chart.name,
|
||||||
|
namespace: options.chart.namespace,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { HelmRelease };
|
||||||
24
src/resources/flux/helm-repo/helm-repo.ts
Normal file
24
src/resources/flux/helm-repo/helm-repo.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import type { KubernetesObject } from '@kubernetes/client-node';
|
||||||
|
import type { K8SHelmRepositoryV1 } from 'src/__generated__/resources/K8SHelmRepositoryV1.ts';
|
||||||
|
|
||||||
|
import { Resource } from '#services/resources/resources.ts';
|
||||||
|
|
||||||
|
type SetOptions = {
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
class HelmRepo extends Resource<KubernetesObject & K8SHelmRepositoryV1> {
|
||||||
|
public static readonly apiVersion = 'source.toolkit.fluxcd.io/v1';
|
||||||
|
public static readonly kind = 'HelmRepository';
|
||||||
|
public static readonly plural = 'helmrepositories';
|
||||||
|
|
||||||
|
public set = async ({ url }: SetOptions) => {
|
||||||
|
await this.ensure({
|
||||||
|
spec: {
|
||||||
|
interval: '1h',
|
||||||
|
url,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { HelmRepo };
|
||||||
284
src/resources/homelab/authentik-server/authentik-server.ts
Normal file
284
src/resources/homelab/authentik-server/authentik-server.ts
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { PostgresDatabase } from '../postgres-database/postgres-database.ts';
|
||||||
|
import { Environment } from '../environment/environment.ts';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CustomResource,
|
||||||
|
ResourceReference,
|
||||||
|
ResourceService,
|
||||||
|
type CustomResourceOptions,
|
||||||
|
} from '#services/resources/resources.ts';
|
||||||
|
import { API_VERSION } from '#utils/consts.ts';
|
||||||
|
import { Secret } from '#resources/core/secret/secret.ts';
|
||||||
|
import { generateRandomHexPass } from '#utils/secrets.ts';
|
||||||
|
import { Service } from '#resources/core/service/service.ts';
|
||||||
|
import { HelmRelease } from '#resources/flux/helm-release/helm-release.ts';
|
||||||
|
import { RepoService } from '#bootstrap/repos/repos.ts';
|
||||||
|
import { VirtualService } from '#resources/istio/virtual-service/virtual-service.ts';
|
||||||
|
import { DestinationRule } from '#resources/istio/destination-rule/destination-rule.ts';
|
||||||
|
import { NotReadyError } from '#utils/errors.ts';
|
||||||
|
|
||||||
|
const specSchema = z.object({
|
||||||
|
environment: z.string(),
|
||||||
|
subdomain: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type SecretData = { url: string; host: string; token: string };
|
||||||
|
type InitSecretData = {
|
||||||
|
AUTHENTIK_BOOTSTRAP_TOKEN: string;
|
||||||
|
AUTHENTIK_BOOTSTRAP_PASSWORD: string;
|
||||||
|
AUTHENTIK_BOOTSTRAP_EMAIL: string;
|
||||||
|
AUTHENTIK_SECRET_KEY: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
class AuthentikServer extends CustomResource<typeof specSchema> {
|
||||||
|
public static readonly apiVersion = API_VERSION;
|
||||||
|
public static readonly kind = 'AuthentikServer';
|
||||||
|
public static readonly spec = specSchema;
|
||||||
|
public static readonly scope = 'Namespaced';
|
||||||
|
|
||||||
|
#environment: ResourceReference<typeof Environment>;
|
||||||
|
#database: PostgresDatabase;
|
||||||
|
#secret: Secret<SecretData>;
|
||||||
|
#initSecret: Secret<InitSecretData>;
|
||||||
|
#service: Service;
|
||||||
|
#helmRelease: HelmRelease;
|
||||||
|
#virtualService: VirtualService;
|
||||||
|
#destinationRule: DestinationRule;
|
||||||
|
|
||||||
|
constructor(options: CustomResourceOptions<typeof specSchema>) {
|
||||||
|
super(options);
|
||||||
|
|
||||||
|
const resourceService = this.services.get(ResourceService);
|
||||||
|
this.#environment = new ResourceReference();
|
||||||
|
this.#environment.on('changed', this.queueReconcile);
|
||||||
|
|
||||||
|
this.#database = resourceService.get(PostgresDatabase, this.name, this.namespace);
|
||||||
|
this.#database.on('changed', this.queueReconcile);
|
||||||
|
|
||||||
|
this.#secret = resourceService.get(Secret<SecretData>, this.name, this.namespace);
|
||||||
|
this.#secret.on('changed', this.queueReconcile);
|
||||||
|
|
||||||
|
this.#initSecret = resourceService.get(Secret<InitSecretData>, `${this.name}-init`, this.namespace);
|
||||||
|
|
||||||
|
this.#service = resourceService.get(Service, `${this.name}-server`, this.namespace);
|
||||||
|
// this.#service.on('changed', this.queueReconcile);
|
||||||
|
|
||||||
|
this.#helmRelease = resourceService.get(HelmRelease, this.name, this.namespace);
|
||||||
|
this.#helmRelease.on('changed', this.queueReconcile);
|
||||||
|
|
||||||
|
this.#virtualService = resourceService.get(VirtualService, this.name, this.namespace);
|
||||||
|
this.#virtualService.on('changed', this.queueReconcile);
|
||||||
|
|
||||||
|
this.#destinationRule = resourceService.get(DestinationRule, this.name, this.namespace);
|
||||||
|
this.#destinationRule.on('changed', this.queueReconcile);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get service() {
|
||||||
|
return this.#service;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get secret() {
|
||||||
|
return this.#secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get subdomain() {
|
||||||
|
return this.spec?.subdomain || 'authentik';
|
||||||
|
}
|
||||||
|
|
||||||
|
public get domain() {
|
||||||
|
return `${this.subdomain}.${this.#environment.current?.spec?.domain}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get url() {
|
||||||
|
return `https://${this.domain}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public reconcile = async () => {
|
||||||
|
if (!this.spec) {
|
||||||
|
throw new NotReadyError('MissingSpec');
|
||||||
|
}
|
||||||
|
const resourceService = this.services.get(ResourceService);
|
||||||
|
|
||||||
|
this.#environment.current = resourceService.get(Environment, this.spec.environment);
|
||||||
|
if (!this.#environment.current.spec) {
|
||||||
|
throw new NotReadyError('MissingEnvSpev');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.#database.ensure({
|
||||||
|
metadata: {
|
||||||
|
ownerReferences: [this.ref],
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
environment: this.#environment.current.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const databaseSecret = this.#database.secret.value;
|
||||||
|
if (!databaseSecret) {
|
||||||
|
throw new NotReadyError('MissingDatabaseSecret');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.#initSecret.set(
|
||||||
|
(current) => ({
|
||||||
|
AUTHENTIK_BOOTSTRAP_EMAIL: 'admin@example.com',
|
||||||
|
AUTHENTIK_BOOTSTRAP_PASSWORD: generateRandomHexPass(24),
|
||||||
|
AUTHENTIK_BOOTSTRAP_TOKEN: generateRandomHexPass(32),
|
||||||
|
AUTHENTIK_SECRET_KEY: generateRandomHexPass(32),
|
||||||
|
...current,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
metadata: {
|
||||||
|
ownerReferences: [this.ref],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const initSecret = this.#initSecret.value;
|
||||||
|
if (!initSecret) {
|
||||||
|
throw new NotReadyError('MissingInitSecret');
|
||||||
|
}
|
||||||
|
|
||||||
|
const domain = `${this.spec?.subdomain || 'authentik'}.${this.#environment.current.spec.domain}`;
|
||||||
|
await this.#secret.set(
|
||||||
|
{
|
||||||
|
url: `https://${domain}`,
|
||||||
|
host: this.#service.hostname,
|
||||||
|
token: initSecret.AUTHENTIK_BOOTSTRAP_TOKEN,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metadata: {
|
||||||
|
ownerReferences: [this.ref],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const secret = this.#secret.value;
|
||||||
|
if (!secret) {
|
||||||
|
throw new NotReadyError('MissingSecret');
|
||||||
|
}
|
||||||
|
|
||||||
|
const repoService = this.services.get(RepoService);
|
||||||
|
|
||||||
|
await this.#helmRelease.ensure({
|
||||||
|
metadata: {
|
||||||
|
ownerReferences: [this.ref],
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
interval: '60m',
|
||||||
|
chart: {
|
||||||
|
spec: {
|
||||||
|
chart: 'authentik',
|
||||||
|
version: '2025.6.4',
|
||||||
|
sourceRef: {
|
||||||
|
apiVersion: 'source.toolkit.fluxcd.io/v1',
|
||||||
|
kind: 'HelmRepository',
|
||||||
|
name: repoService.authentik.name,
|
||||||
|
namespace: repoService.authentik.namespace,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
values: {
|
||||||
|
global: {
|
||||||
|
envFrom: [
|
||||||
|
{
|
||||||
|
secretRef: {
|
||||||
|
name: this.#initSecret.name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
authentik: {
|
||||||
|
error_reporting: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
postgresql: {
|
||||||
|
host: databaseSecret.host,
|
||||||
|
name: databaseSecret.database,
|
||||||
|
user: databaseSecret.user,
|
||||||
|
password: 'file:///postgres-creds/password',
|
||||||
|
},
|
||||||
|
redis: {
|
||||||
|
host: this.#environment.current.redisServer.service.hostname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
volumes: [
|
||||||
|
{
|
||||||
|
name: 'postgres-creds',
|
||||||
|
secret: {
|
||||||
|
secretName: this.#database.secret.name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
volumeMounts: [
|
||||||
|
{
|
||||||
|
name: 'postgres-creds',
|
||||||
|
mountPath: '/postgres-creds',
|
||||||
|
readOnly: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
worker: {
|
||||||
|
volumes: [
|
||||||
|
{
|
||||||
|
name: 'postgres-creds',
|
||||||
|
secret: {
|
||||||
|
secretName: this.#database.secret.name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
volumeMounts: [
|
||||||
|
{
|
||||||
|
name: 'postgres-creds',
|
||||||
|
mountPath: '/postgres-creds',
|
||||||
|
readOnly: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.#destinationRule.ensure({
|
||||||
|
metadata: {
|
||||||
|
ownerReferences: [this.ref],
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
host: this.#service.hostname,
|
||||||
|
trafficPolicy: {
|
||||||
|
tls: {
|
||||||
|
mode: 'DISABLE',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const gateway = this.#environment.current.gateway;
|
||||||
|
await this.#virtualService.set({
|
||||||
|
metadata: {
|
||||||
|
ownerReferences: [this.ref],
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
gateways: [`${gateway.namespace}/${gateway.name}`, 'mesh'],
|
||||||
|
hosts: [domain],
|
||||||
|
http: [
|
||||||
|
{
|
||||||
|
route: [
|
||||||
|
{
|
||||||
|
destination: {
|
||||||
|
host: this.#service.hostname,
|
||||||
|
port: {
|
||||||
|
number: 80,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { AuthentikServer };
|
||||||
94
src/resources/homelab/cloudflare-tunnel/cloudflare-tunnel.ts
Normal file
94
src/resources/homelab/cloudflare-tunnel/cloudflare-tunnel.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import {
|
||||||
|
CustomResource,
|
||||||
|
Resource,
|
||||||
|
ResourceService,
|
||||||
|
type CustomResourceOptions,
|
||||||
|
} from '#services/resources/resources.ts';
|
||||||
|
import z from 'zod';
|
||||||
|
import { ExternalHttpService } from '../external-http-service.ts/external-http-service.ts';
|
||||||
|
import { API_VERSION } from '#utils/consts.ts';
|
||||||
|
import { HelmRelease } from '#resources/flux/helm-release/helm-release.ts';
|
||||||
|
import { RepoService } from '#bootstrap/repos/repos.ts';
|
||||||
|
import { Secret } from '#resources/core/secret/secret.ts';
|
||||||
|
import { NotReadyError } from '#utils/errors.ts';
|
||||||
|
import { NamespaceService } from '#bootstrap/namespaces/namespaces.ts';
|
||||||
|
|
||||||
|
const specSchema = z.object({});
|
||||||
|
|
||||||
|
type SecretData = {
|
||||||
|
account: string;
|
||||||
|
tunnelName: string;
|
||||||
|
tunnelId: string;
|
||||||
|
secret: string;
|
||||||
|
};
|
||||||
|
class CloudflareTunnel extends CustomResource<typeof specSchema> {
|
||||||
|
public static readonly apiVersion = API_VERSION;
|
||||||
|
public static readonly kind = 'CloudflareTunnel';
|
||||||
|
public static readonly spec = specSchema;
|
||||||
|
public static readonly scope = 'Cluster';
|
||||||
|
|
||||||
|
#helmRelease: HelmRelease;
|
||||||
|
#secret: Secret<SecretData>;
|
||||||
|
|
||||||
|
constructor(options: CustomResourceOptions<typeof specSchema>) {
|
||||||
|
super(options);
|
||||||
|
const resourceService = this.services.get(ResourceService);
|
||||||
|
const namespaceService = this.services.get(NamespaceService);
|
||||||
|
const namespace = namespaceService.homelab.name;
|
||||||
|
resourceService.on('changed', this.#handleResourceChanged);
|
||||||
|
|
||||||
|
this.#helmRelease = resourceService.get(HelmRelease, this.name, namespace);
|
||||||
|
this.#secret = resourceService.get(Secret<SecretData>, 'cloudflare', namespace);
|
||||||
|
this.#secret.on('changed', this.queueReconcile);
|
||||||
|
}
|
||||||
|
|
||||||
|
#handleResourceChanged = (resource: Resource<ExpectedAny>) => {
|
||||||
|
if (resource instanceof CloudflareTunnel) {
|
||||||
|
this.queueReconcile();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public reconcile = async () => {
|
||||||
|
const secret = this.#secret.value;
|
||||||
|
if (!secret) {
|
||||||
|
throw new NotReadyError('MissingSecret', `Secret ${this.#secret.namespace}/${this.#secret.name} does not exist`);
|
||||||
|
}
|
||||||
|
const resourceService = this.services.get(ResourceService);
|
||||||
|
const repoService = this.services.get(RepoService);
|
||||||
|
const routes = resourceService.getAllOfKind(ExternalHttpService);
|
||||||
|
const ingress = routes.map(({ rule }) => ({
|
||||||
|
hostname: rule?.hostname,
|
||||||
|
service: `http://${rule?.destination.host}:${rule?.destination.port.number}`,
|
||||||
|
}));
|
||||||
|
await this.#helmRelease.ensure({
|
||||||
|
metadata: {
|
||||||
|
ownerReferences: [this.ref],
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
interval: '1h',
|
||||||
|
values: {
|
||||||
|
cloudflare: {
|
||||||
|
account: secret.account,
|
||||||
|
tunnelName: secret.tunnelName,
|
||||||
|
tunnelId: secret.tunnelId,
|
||||||
|
secret: secret.secret,
|
||||||
|
ingress,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
chart: {
|
||||||
|
spec: {
|
||||||
|
chart: 'cloudflare-tunnel',
|
||||||
|
sourceRef: {
|
||||||
|
apiVersion: 'source.toolkit.fluxcd.io/v1',
|
||||||
|
kind: 'HelmRepository',
|
||||||
|
name: repoService.cloudflare.name,
|
||||||
|
namespace: repoService.cloudflare.namespace,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { CloudflareTunnel };
|
||||||
214
src/resources/homelab/environment/environment.ts
Normal file
214
src/resources/homelab/environment/environment.ts
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { PostgresCluster } from '../postgres-cluster/postgres-cluster.ts';
|
||||||
|
import { RedisServer } from '../redis-server/redis-server.ts';
|
||||||
|
import { AuthentikServer } from '../authentik-server/authentik-server.ts';
|
||||||
|
|
||||||
|
import { CustomResource, ResourceService, type CustomResourceOptions } from '#services/resources/resources.ts';
|
||||||
|
import { API_VERSION } from '#utils/consts.ts';
|
||||||
|
import { Namespace } from '#resources/core/namespace/namespace.ts';
|
||||||
|
import { Certificate } from '#resources/cert-manager/certificate/certificate.ts';
|
||||||
|
import { StorageClass } from '#resources/core/storage-class/storage-class.ts';
|
||||||
|
import { PROVISIONER } from '#resources/core/pvc/pvc.ts';
|
||||||
|
import { Gateway } from '#resources/istio/gateway/gateway.ts';
|
||||||
|
import { NotReadyError } from '#utils/errors.ts';
|
||||||
|
import { NamespaceService } from '#bootstrap/namespaces/namespaces.ts';
|
||||||
|
import { CloudflareService } from '#services/cloudflare/cloudflare.ts';
|
||||||
|
|
||||||
|
const specSchema = z.object({
|
||||||
|
domain: z.string(),
|
||||||
|
networkIp: z.string().optional(),
|
||||||
|
tls: z.object({
|
||||||
|
issuer: z.string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
class Environment extends CustomResource<typeof specSchema> {
|
||||||
|
public static readonly apiVersion = API_VERSION;
|
||||||
|
public static readonly kind = 'Environment';
|
||||||
|
public static readonly spec = specSchema;
|
||||||
|
public static readonly scope = 'Cluster';
|
||||||
|
|
||||||
|
#namespace: Namespace;
|
||||||
|
#certificate: Certificate;
|
||||||
|
#storageClass: StorageClass;
|
||||||
|
#gateway: Gateway;
|
||||||
|
#postgresCluster: PostgresCluster;
|
||||||
|
#redisServer: RedisServer;
|
||||||
|
#authentikServer: AuthentikServer;
|
||||||
|
#cloudflareService: CloudflareService;
|
||||||
|
|
||||||
|
constructor(options: CustomResourceOptions<typeof specSchema>) {
|
||||||
|
super(options);
|
||||||
|
const resourceService = this.services.get(ResourceService);
|
||||||
|
const namespaceService = this.services.get(NamespaceService);
|
||||||
|
const homelabNamespace = namespaceService.homelab.name;
|
||||||
|
|
||||||
|
this.#cloudflareService = this.services.get(CloudflareService);
|
||||||
|
this.#cloudflareService.on('changed', this.queueReconcile);
|
||||||
|
|
||||||
|
this.#namespace = resourceService.get(Namespace, this.name);
|
||||||
|
this.#namespace.on('changed', this.queueReconcile);
|
||||||
|
|
||||||
|
this.#certificate = resourceService.get(Certificate, this.name, homelabNamespace);
|
||||||
|
this.#certificate.on('changed', this.queueReconcile);
|
||||||
|
|
||||||
|
this.#storageClass = resourceService.get(StorageClass, this.name);
|
||||||
|
this.#storageClass.on('changed', this.queueReconcile);
|
||||||
|
|
||||||
|
this.#postgresCluster = resourceService.get(PostgresCluster, `${this.name}-postgres-cluster`, homelabNamespace);
|
||||||
|
this.#postgresCluster.on('changed', this.queueReconcile);
|
||||||
|
|
||||||
|
this.#redisServer = resourceService.get(RedisServer, `${this.name}-redis-server`, homelabNamespace);
|
||||||
|
this.#redisServer.on('changed', this.queueReconcile);
|
||||||
|
|
||||||
|
this.#gateway = resourceService.get(Gateway, this.name, homelabNamespace);
|
||||||
|
this.#gateway.on('changed', this.queueReconcile);
|
||||||
|
|
||||||
|
this.#authentikServer = resourceService.get(AuthentikServer, `${this.name}-authentik`, homelabNamespace);
|
||||||
|
this.#authentikServer.on('changed', this.queueReconcile);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get certificate() {
|
||||||
|
return this.#certificate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get storageClass() {
|
||||||
|
return this.#storageClass;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get postgresCluster() {
|
||||||
|
return this.#postgresCluster;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get redisServer() {
|
||||||
|
return this.#redisServer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get gateway() {
|
||||||
|
return this.#gateway;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get authentikServer() {
|
||||||
|
return this.#authentikServer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public reconcile = async () => {
|
||||||
|
const { data: spec, success } = specSchema.safeParse(this.spec);
|
||||||
|
if (!success || !spec) {
|
||||||
|
throw new NotReadyError('InvalidSpec');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.#cloudflareService.ready && spec.networkIp) {
|
||||||
|
const client = this.#cloudflareService.client;
|
||||||
|
const zones = await client.zones.list({
|
||||||
|
name: spec.domain,
|
||||||
|
});
|
||||||
|
const [zone] = zones.result;
|
||||||
|
if (!zone) {
|
||||||
|
throw new NotReadyError('NoZoneFound');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingRecords = await client.dns.records.list({
|
||||||
|
zone_id: zone.id,
|
||||||
|
name: {
|
||||||
|
exact: `*.${spec.domain}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Cloudflare records', existingRecords);
|
||||||
|
|
||||||
|
// zones.result[0].
|
||||||
|
}
|
||||||
|
await this.#namespace.ensure({
|
||||||
|
metadata: {
|
||||||
|
labels: {
|
||||||
|
'istio-injection': 'enabled',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await this.#certificate.ensure({
|
||||||
|
spec: {
|
||||||
|
secretName: `${this.name}-tls`,
|
||||||
|
issuerRef: {
|
||||||
|
name: spec.tls.issuer,
|
||||||
|
kind: 'ClusterIssuer',
|
||||||
|
},
|
||||||
|
dnsNames: [`*.${spec.domain}`],
|
||||||
|
privateKey: {
|
||||||
|
rotationPolicy: 'Always',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await this.#storageClass.ensure({
|
||||||
|
metadata: {
|
||||||
|
ownerReferences: [this.ref],
|
||||||
|
},
|
||||||
|
provisioner: PROVISIONER,
|
||||||
|
reclaimPolicy: 'Retain',
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.#postgresCluster.ensure({
|
||||||
|
metadata: {
|
||||||
|
ownerReferences: [this.ref],
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
storageClass: this.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.#redisServer.ensure({
|
||||||
|
metadata: {
|
||||||
|
ownerReferences: [this.ref],
|
||||||
|
},
|
||||||
|
spec: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.#authentikServer.ensure({
|
||||||
|
metadata: {
|
||||||
|
ownerReferences: [this.ref],
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
environment: this.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.#gateway.set({
|
||||||
|
metadata: {
|
||||||
|
ownerReferences: [this.ref],
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
selector: {
|
||||||
|
istio: 'homelab-istio-gateway',
|
||||||
|
},
|
||||||
|
servers: [
|
||||||
|
{
|
||||||
|
hosts: [`*.${spec.domain}`],
|
||||||
|
port: {
|
||||||
|
name: 'http',
|
||||||
|
number: 80,
|
||||||
|
protocol: 'HTTP',
|
||||||
|
},
|
||||||
|
tls: {
|
||||||
|
httpsRedirect: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hosts: [`*.${spec.domain}`],
|
||||||
|
port: {
|
||||||
|
name: 'https',
|
||||||
|
number: 443,
|
||||||
|
protocol: 'HTTPS',
|
||||||
|
},
|
||||||
|
tls: {
|
||||||
|
mode: 'SIMPLE',
|
||||||
|
credentialName: `${this.name}-tls`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Environment };
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { CustomResource, ResourceService, type CustomResourceOptions } from '#services/resources/resources.ts';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { Environment } from '../environment/environment.ts';
|
||||||
|
import { API_VERSION } from '#utils/consts.ts';
|
||||||
|
|
||||||
|
const specSchema = z.object({
|
||||||
|
environment: z.string(),
|
||||||
|
subdomain: z.string(),
|
||||||
|
destination: z.object({
|
||||||
|
host: z.string(),
|
||||||
|
port: z.object({
|
||||||
|
number: z.number(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
class ExternalHttpService extends CustomResource<typeof specSchema> {
|
||||||
|
public static readonly apiVersion = API_VERSION;
|
||||||
|
public static readonly kind = 'ExternalHttpService';
|
||||||
|
public static readonly spec = specSchema;
|
||||||
|
public static readonly scope = 'Namespaced';
|
||||||
|
|
||||||
|
constructor(options: CustomResourceOptions<typeof specSchema>) {
|
||||||
|
super(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get rule() {
|
||||||
|
if (!this.spec) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const resourceService = this.services.get(ResourceService);
|
||||||
|
const env = resourceService.get(Environment, this.spec.environment);
|
||||||
|
const hostname = `${this.spec.subdomain}.${env.spec?.domain}`;
|
||||||
|
return {
|
||||||
|
domain: env.spec?.domain,
|
||||||
|
subdomain: this.spec.subdomain,
|
||||||
|
hostname,
|
||||||
|
destination: this.spec.destination,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ExternalHttpService };
|
||||||
47
src/resources/homelab/generate-secret/generate-secret.ts
Normal file
47
src/resources/homelab/generate-secret/generate-secret.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { Secret } from '#resources/core/secret/secret.ts';
|
||||||
|
import { CustomResource, ResourceService, type CustomResourceOptions } from '#services/resources/resources.ts';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { generateSecrets } from './generate-secret.utils.ts';
|
||||||
|
import { API_VERSION } from '#utils/consts.ts';
|
||||||
|
|
||||||
|
const generateSecretFieldSchema = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
value: z.string().optional(),
|
||||||
|
encoding: z.enum(['base64', 'base64url', 'hex', 'utf8', 'numeric']).optional(),
|
||||||
|
length: z.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const specSchema = z.object({
|
||||||
|
fields: z.array(generateSecretFieldSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
class GenerateSecret extends CustomResource<typeof specSchema> {
|
||||||
|
public static readonly apiVersion = API_VERSION;
|
||||||
|
public static readonly kind = 'GenerateSecret';
|
||||||
|
public static readonly spec = specSchema;
|
||||||
|
public static readonly scope = 'Namespaced';
|
||||||
|
|
||||||
|
#secret: Secret;
|
||||||
|
|
||||||
|
constructor(options: CustomResourceOptions<typeof specSchema>) {
|
||||||
|
super(options);
|
||||||
|
|
||||||
|
const resourceService = this.services.get(ResourceService);
|
||||||
|
|
||||||
|
this.#secret = resourceService.get(Secret, this.name, this.namespace);
|
||||||
|
}
|
||||||
|
|
||||||
|
public reconcile = async () => {
|
||||||
|
const secrets = generateSecrets(this.spec);
|
||||||
|
const current = this.#secret.value;
|
||||||
|
|
||||||
|
const expected = {
|
||||||
|
...secrets,
|
||||||
|
...current,
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.#secret.ensure(expected);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { GenerateSecret };
|
||||||
27
src/resources/homelab/homelab.ts
Normal file
27
src/resources/homelab/homelab.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Environment } from './environment/environment.ts';
|
||||||
|
import { PostgresCluster } from './postgres-cluster/postgres-cluster.ts';
|
||||||
|
import { RedisServer } from './redis-server/redis-server.ts';
|
||||||
|
import { PostgresDatabase } from './postgres-database/postgres-database.ts';
|
||||||
|
import { AuthentikServer } from './authentik-server/authentik-server.ts';
|
||||||
|
|
||||||
|
import type { InstallableResourceClass } from '#services/resources/resources.ts';
|
||||||
|
import { OIDCClient } from './oidc-client/oidc-client.ts';
|
||||||
|
import { HttpService } from './http-service/http-service.ts';
|
||||||
|
import { GenerateSecret } from './generate-secret/generate-secret.ts';
|
||||||
|
import { ExternalHttpService } from './external-http-service.ts/external-http-service.ts';
|
||||||
|
import { CloudflareTunnel } from './cloudflare-tunnel/cloudflare-tunnel.ts';
|
||||||
|
|
||||||
|
const homelab = {
|
||||||
|
PostgresCluster,
|
||||||
|
RedisServer,
|
||||||
|
Environment,
|
||||||
|
ExternalHttpService,
|
||||||
|
CloudflareTunnel,
|
||||||
|
AuthentikServer,
|
||||||
|
PostgresDatabase,
|
||||||
|
OIDCClient,
|
||||||
|
HttpService,
|
||||||
|
GenerateSecret,
|
||||||
|
} satisfies Record<string, InstallableResourceClass<ExpectedAny>>;
|
||||||
|
|
||||||
|
export { homelab };
|
||||||
83
src/resources/homelab/http-service/http-service.ts
Normal file
83
src/resources/homelab/http-service/http-service.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { VirtualService } from '#resources/istio/virtual-service/virtual-service.ts';
|
||||||
|
import {
|
||||||
|
CustomResource,
|
||||||
|
ResourceReference,
|
||||||
|
ResourceService,
|
||||||
|
type CustomResourceOptions,
|
||||||
|
} from '#services/resources/resources.ts';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { Environment } from '../environment/environment.ts';
|
||||||
|
import { NotReadyError } from '#utils/errors.ts';
|
||||||
|
import { API_VERSION } from '#utils/consts.ts';
|
||||||
|
|
||||||
|
const specSchema = z.object({
|
||||||
|
environment: z.string(),
|
||||||
|
subdomain: z.string(),
|
||||||
|
destination: z.object({
|
||||||
|
host: z.string(),
|
||||||
|
port: z.object({
|
||||||
|
number: z.number().optional(),
|
||||||
|
name: z.string().optional(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
class HttpService extends CustomResource<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 };
|
||||||
109
src/resources/homelab/oidc-client/oidc-client.ts
Normal file
109
src/resources/homelab/oidc-client/oidc-client.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import {
|
||||||
|
CustomResource,
|
||||||
|
ResourceReference,
|
||||||
|
ResourceService,
|
||||||
|
type CustomResourceOptions,
|
||||||
|
} from '#services/resources/resources.ts';
|
||||||
|
import { ClientTypeEnum, SubModeEnum } from '@goauthentik/api';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { Environment } from '../environment/environment.ts';
|
||||||
|
import { API_VERSION } from '#utils/consts.ts';
|
||||||
|
import { NotReadyError } from '#utils/errors.ts';
|
||||||
|
import { Secret } from '#resources/core/secret/secret.ts';
|
||||||
|
import { generateRandomHexPass } from '#utils/secrets.ts';
|
||||||
|
import { AuthentikService } from '#services/authentik/authentik.service.ts';
|
||||||
|
|
||||||
|
const specSchema = z.object({
|
||||||
|
environment: z.string(),
|
||||||
|
subMode: z.enum(SubModeEnum).optional(),
|
||||||
|
clientType: z.enum(ClientTypeEnum).optional(),
|
||||||
|
redirectUris: z.array(
|
||||||
|
z.object({
|
||||||
|
url: z.string(),
|
||||||
|
matchingMode: z.enum(['strict', 'regex']),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
type SecretData = {
|
||||||
|
clientId: string;
|
||||||
|
clientSecret?: string;
|
||||||
|
configuration: string;
|
||||||
|
configurationIssuer: string;
|
||||||
|
authorization: string;
|
||||||
|
token: string;
|
||||||
|
userinfo: string;
|
||||||
|
endSession: string;
|
||||||
|
jwks: string;
|
||||||
|
};
|
||||||
|
class OIDCClient extends CustomResource<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/${this.appName}/authorize/`, url).toString(),
|
||||||
|
token: new URL(`/application/o/${this.appName}/token/`, url).toString(),
|
||||||
|
userinfo: new URL(`/application/o/${this.appName}/userinfo/`, url).toString(),
|
||||||
|
endSession: new URL(`/application/o/${this.appName}/end-session/`, url).toString(),
|
||||||
|
jwks: new URL(`/application/o/${this.appName}/jwks/`, url).toString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const secret = this.#secret.value;
|
||||||
|
if (!secret) {
|
||||||
|
throw new NotReadyError('MissingSecret');
|
||||||
|
}
|
||||||
|
const authentikService = this.services.get(AuthentikService);
|
||||||
|
const authentikServer = await authentikService.get({
|
||||||
|
url: {
|
||||||
|
internal: `http://${authentikSecret.host}`,
|
||||||
|
external: authentikSecret.url,
|
||||||
|
},
|
||||||
|
token: authentikSecret.token,
|
||||||
|
});
|
||||||
|
|
||||||
|
await authentikServer.upsertClient({
|
||||||
|
...this.spec,
|
||||||
|
name: this.name,
|
||||||
|
secret: secret.clientSecret,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { OIDCClient };
|
||||||
172
src/resources/homelab/postgres-cluster/postgres-cluster.ts
Normal file
172
src/resources/homelab/postgres-cluster/postgres-cluster.ts
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { Secret } from '#resources/core/secret/secret.ts';
|
||||||
|
import { StatefulSet } from '#resources/core/stateful-set/stateful-set.ts';
|
||||||
|
import { CustomResource, ResourceService, type CustomResourceOptions } from '#services/resources/resources.ts';
|
||||||
|
import { API_VERSION } from '#utils/consts.ts';
|
||||||
|
import { Service } from '#resources/core/service/service.ts';
|
||||||
|
import { generateRandomHexPass } from '#utils/secrets.ts';
|
||||||
|
|
||||||
|
const specSchema = z.object({
|
||||||
|
storageClass: z.string(),
|
||||||
|
storage: z
|
||||||
|
.object({
|
||||||
|
size: z.string().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type SecretData = {
|
||||||
|
host: string;
|
||||||
|
port: string;
|
||||||
|
user: string;
|
||||||
|
password: string;
|
||||||
|
database: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
class PostgresCluster extends CustomResource<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: 'postgres:17',
|
||||||
|
ports: [{ containerPort: 5432, name: 'postgres' }],
|
||||||
|
env: [
|
||||||
|
{ name: 'POSTGRES_PASSWORD', valueFrom: { secretKeyRef: { name: secretName, key: 'password' } } },
|
||||||
|
{ name: 'POSTGRES_USER', valueFrom: { secretKeyRef: { name: secretName, key: 'user' } } },
|
||||||
|
{ name: 'POSTGRES_DB', valueFrom: { secretKeyRef: { name: secretName, key: 'database' } } },
|
||||||
|
{ name: 'PGDATA', value: '/var/lib/postgresql/data/pgdata' },
|
||||||
|
],
|
||||||
|
volumeMounts: [{ name: this.name, mountPath: '/var/lib/postgresql/data' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
volumeClaimTemplates: [
|
||||||
|
{
|
||||||
|
metadata: {
|
||||||
|
name: this.name,
|
||||||
|
ownerReferences: [this.ref],
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
accessModes: ['ReadWriteOnce'],
|
||||||
|
storageClassName: this.spec?.storageClass,
|
||||||
|
resources: {
|
||||||
|
requests: {
|
||||||
|
storage: this.spec?.storage?.size || '1Gi',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.#headlessService.ensure({
|
||||||
|
metadata: {
|
||||||
|
ownerReferences: [this.ref],
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
clusterIP: 'None',
|
||||||
|
selector: {
|
||||||
|
app: this.name,
|
||||||
|
},
|
||||||
|
ports: [{ name: 'postgres', port: 5432, targetPort: 5432 }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.#service.ensure({
|
||||||
|
metadata: {
|
||||||
|
ownerReferences: [this.ref],
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
type: 'ClusterIP',
|
||||||
|
selector: {
|
||||||
|
app: this.name,
|
||||||
|
},
|
||||||
|
ports: [{ name: 'postgres', port: 5432, targetPort: 5432 }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { PostgresCluster };
|
||||||
135
src/resources/homelab/postgres-database/postgres-database.ts
Normal file
135
src/resources/homelab/postgres-database/postgres-database.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { PostgresCluster } from '../postgres-cluster/postgres-cluster.ts';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CustomResource,
|
||||||
|
ResourceReference,
|
||||||
|
ResourceService,
|
||||||
|
type CustomResourceOptions,
|
||||||
|
} from '#services/resources/resources.ts';
|
||||||
|
import { Secret } from '#resources/core/secret/secret.ts';
|
||||||
|
import { API_VERSION } from '#utils/consts.ts';
|
||||||
|
import { getWithNamespace } from '#utils/naming.ts';
|
||||||
|
import { PostgresService } from '#services/postgres/postgres.service.ts';
|
||||||
|
import { NotReadyError } from '#utils/errors.ts';
|
||||||
|
import { generateRandomHexPass } from '#utils/secrets.ts';
|
||||||
|
|
||||||
|
const specSchema = z.object({
|
||||||
|
environment: z.string().optional(),
|
||||||
|
cluster: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type SecretData = {
|
||||||
|
password: string;
|
||||||
|
user: string;
|
||||||
|
database: string;
|
||||||
|
host: string;
|
||||||
|
port: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sanitizeName = (input: string) => {
|
||||||
|
return input.replace(/[^a-zA-Z0-9_]+/g, '_').toLowerCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
class PostgresDatabase extends CustomResource<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');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.#secret.set(
|
||||||
|
(current) => ({
|
||||||
|
password: generateRandomHexPass(),
|
||||||
|
user: this.username,
|
||||||
|
database: this.database,
|
||||||
|
...current,
|
||||||
|
host: clusterSecret.host,
|
||||||
|
port: clusterSecret.port,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
metadata: {
|
||||||
|
ownerReferences: [this.ref],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const secret = this.#secret.value;
|
||||||
|
if (!secret) {
|
||||||
|
throw new NotReadyError('MissingSecret');
|
||||||
|
}
|
||||||
|
|
||||||
|
const postgresService = this.services.get(PostgresService);
|
||||||
|
const database = postgresService.get({
|
||||||
|
host: clusterSecret.host,
|
||||||
|
port: clusterSecret.port ? Number(clusterSecret.port) : 5432,
|
||||||
|
database: clusterSecret.database,
|
||||||
|
user: clusterSecret.user,
|
||||||
|
password: clusterSecret.password,
|
||||||
|
});
|
||||||
|
const connectionError = await database.ping();
|
||||||
|
if (connectionError) {
|
||||||
|
console.error('Failed to connect', connectionError);
|
||||||
|
throw new NotReadyError('FailedToConnectToDatabase');
|
||||||
|
}
|
||||||
|
await database.upsertRole({
|
||||||
|
name: secret.user,
|
||||||
|
password: secret.password,
|
||||||
|
});
|
||||||
|
await database.upsertDatabase({
|
||||||
|
name: secret.database,
|
||||||
|
owner: secret.user,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { PostgresDatabase };
|
||||||
79
src/resources/homelab/redis-server/redis-server.ts
Normal file
79
src/resources/homelab/redis-server/redis-server.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { Deployment } from '#resources/core/deployment/deployment.ts';
|
||||||
|
import { Service } from '#resources/core/service/service.ts';
|
||||||
|
import { CustomResource, ResourceService, type CustomResourceOptions } from '#services/resources/resources.ts';
|
||||||
|
import { API_VERSION } from '#utils/consts.ts';
|
||||||
|
|
||||||
|
const specSchema = z.object({});
|
||||||
|
|
||||||
|
class RedisServer extends CustomResource<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 };
|
||||||
37
src/resources/istio/destination-rule/destination-rule.ts
Normal file
37
src/resources/istio/destination-rule/destination-rule.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import type { KubernetesObject } from '@kubernetes/client-node';
|
||||||
|
import type { K8SDestinationRuleV1 } from 'src/__generated__/resources/K8SDestinationRuleV1.ts';
|
||||||
|
|
||||||
|
import { Resource, ResourceService, type ResourceOptions } from '#services/resources/resources.ts';
|
||||||
|
import { CRD } from '#resources/core/crd/crd.ts';
|
||||||
|
import { NotReadyError } from '#utils/errors.ts';
|
||||||
|
|
||||||
|
class DestinationRule extends Resource<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 };
|
||||||
37
src/resources/istio/gateway/gateway.ts
Normal file
37
src/resources/istio/gateway/gateway.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import type { KubernetesObject } from '@kubernetes/client-node';
|
||||||
|
import type { K8SGatewayV1 } from 'src/__generated__/resources/K8SGatewayV1.ts';
|
||||||
|
|
||||||
|
import { Resource, ResourceService, type ResourceOptions } from '#services/resources/resources.ts';
|
||||||
|
import { CRD } from '#resources/core/crd/crd.ts';
|
||||||
|
import { NotReadyError } from '#utils/errors.ts';
|
||||||
|
|
||||||
|
class Gateway extends Resource<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 };
|
||||||
11
src/resources/istio/istio.ts
Normal file
11
src/resources/istio/istio.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { DestinationRule } from './destination-rule/destination-rule.ts';
|
||||||
|
import { Gateway } from './gateway/gateway.ts';
|
||||||
|
import { VirtualService } from './virtual-service/virtual-service.ts';
|
||||||
|
|
||||||
|
const istio = {
|
||||||
|
gateway: Gateway,
|
||||||
|
destinationRule: DestinationRule,
|
||||||
|
virtualService: VirtualService,
|
||||||
|
};
|
||||||
|
|
||||||
|
export { istio };
|
||||||
37
src/resources/istio/virtual-service/virtual-service.ts
Normal file
37
src/resources/istio/virtual-service/virtual-service.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import type { KubernetesObject } from '@kubernetes/client-node';
|
||||||
|
import type { K8SVirtualServiceV1 } from 'src/__generated__/resources/K8SVirtualServiceV1.ts';
|
||||||
|
|
||||||
|
import { Resource, ResourceService, type ResourceOptions } from '#services/resources/resources.ts';
|
||||||
|
import { CRD } from '#resources/core/crd/crd.ts';
|
||||||
|
import { NotReadyError } from '#utils/errors.ts';
|
||||||
|
|
||||||
|
class VirtualService extends Resource<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 };
|
||||||
17
src/resources/resources.ts
Normal file
17
src/resources/resources.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { core } from './core/core.ts';
|
||||||
|
import { flux } from './flux/flux.ts';
|
||||||
|
import { homelab } from './homelab/homelab.ts';
|
||||||
|
import { certManager } from './cert-manager/cert-manager.ts';
|
||||||
|
import { istio } from './istio/istio.ts';
|
||||||
|
|
||||||
|
import type { ResourceClass } from '#services/resources/resources.ts';
|
||||||
|
|
||||||
|
const resources = {
|
||||||
|
...core,
|
||||||
|
...flux,
|
||||||
|
...certManager,
|
||||||
|
...istio,
|
||||||
|
...homelab,
|
||||||
|
} satisfies Record<string, ResourceClass<ExpectedAny>>;
|
||||||
|
|
||||||
|
export { resources };
|
||||||
57
src/services/cloudflare/cloudflare.ts
Normal file
57
src/services/cloudflare/cloudflare.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { Cloudflare } from 'cloudflare';
|
||||||
|
import { EventEmitter } from 'eventemitter3';
|
||||||
|
|
||||||
|
import { NamespaceService } from '#bootstrap/namespaces/namespaces.ts';
|
||||||
|
import { Secret } from '#resources/core/secret/secret.ts';
|
||||||
|
import { ResourceService } from '#services/resources/resources.ts';
|
||||||
|
import type { Services } from '#utils/service.ts';
|
||||||
|
|
||||||
|
type SecretData = {
|
||||||
|
account: string;
|
||||||
|
tunnelName: string;
|
||||||
|
tunnelId: string;
|
||||||
|
secret: string;
|
||||||
|
token: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CloudflareServiceEvents = {
|
||||||
|
changed: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
class CloudflareService extends EventEmitter<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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { CloudflareService };
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
import { EventEmitter } from 'eventemitter3';
|
|
||||||
import equal from 'deep-equal';
|
|
||||||
|
|
||||||
import type { CustomResource } from './custom-resources.custom-resource.ts';
|
|
||||||
import type { CustomResourceStatus } from './custom-resources.types.ts';
|
|
||||||
|
|
||||||
type CustomResourceStatusOptions = {
|
|
||||||
resource: CustomResource<ExpectedAny>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CustomResourceConditionsEvents = {
|
|
||||||
changed: (type: string, condition: Condition) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Condition = {
|
|
||||||
lastTransitionTime: Date;
|
|
||||||
status: 'True' | 'False' | 'Unknown';
|
|
||||||
syncing?: boolean;
|
|
||||||
failed?: boolean;
|
|
||||||
resource?: boolean;
|
|
||||||
reason?: string;
|
|
||||||
message?: string;
|
|
||||||
observedGeneration?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
class CustomResourceConditions extends EventEmitter<CustomResourceConditionsEvents> {
|
|
||||||
#options: CustomResourceStatusOptions;
|
|
||||||
#conditions: Record<string, Condition>;
|
|
||||||
#changed: boolean;
|
|
||||||
|
|
||||||
constructor(options: CustomResourceStatusOptions) {
|
|
||||||
super();
|
|
||||||
this.#options = options;
|
|
||||||
this.#conditions = Object.fromEntries(
|
|
||||||
(options.resource.status?.conditions || []).map(({ type, lastTransitionTime, ...condition }) => [
|
|
||||||
type,
|
|
||||||
{
|
|
||||||
...condition,
|
|
||||||
lastTransitionTime: new Date(lastTransitionTime),
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
options.resource.on('changed', this.#handleChange);
|
|
||||||
this.#changed = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
#handleChange = () => {
|
|
||||||
const { resource } = this.#options;
|
|
||||||
for (const { type, ...condition } of resource.status?.conditions || []) {
|
|
||||||
const next = {
|
|
||||||
...condition,
|
|
||||||
lastTransitionTime: new Date(condition.lastTransitionTime),
|
|
||||||
};
|
|
||||||
const current = this.#conditions[type];
|
|
||||||
const isEqual = equal(current, next);
|
|
||||||
const isNewer = !current || next.lastTransitionTime > current.lastTransitionTime;
|
|
||||||
if (isEqual || !isNewer) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.#conditions[type] = next;
|
|
||||||
this.emit('changed', type, next);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
public get = (type: string): Condition | undefined => {
|
|
||||||
return this.#conditions[type];
|
|
||||||
};
|
|
||||||
|
|
||||||
public set = async (type: string, condition: Omit<Condition, 'lastTransitionTime'>) => {
|
|
||||||
const current = this.#conditions[type];
|
|
||||||
const isEqual = equal(
|
|
||||||
{ ...current, lastTransitionTime: undefined },
|
|
||||||
{ ...condition, lastTransitionTime: undefined },
|
|
||||||
);
|
|
||||||
if (isEqual) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.#changed = true;
|
|
||||||
this.#conditions[type] = {
|
|
||||||
...condition,
|
|
||||||
lastTransitionTime: current && current.status === condition.status ? current.lastTransitionTime : new Date(),
|
|
||||||
observedGeneration: this.#options.resource.metadata?.generation,
|
|
||||||
};
|
|
||||||
await this.save();
|
|
||||||
};
|
|
||||||
|
|
||||||
public save = async () => {
|
|
||||||
if (!this.#changed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
this.#changed = false;
|
|
||||||
const { resource } = this.#options;
|
|
||||||
const status: CustomResourceStatus = {
|
|
||||||
conditions: Object.entries(this.#conditions).map(([type, condition]) => ({
|
|
||||||
...condition,
|
|
||||||
type,
|
|
||||||
lastTransitionTime: condition.lastTransitionTime.toISOString(),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
await resource.patchStatus(status);
|
|
||||||
} catch (error) {
|
|
||||||
this.#changed = true;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export { CustomResourceConditions };
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
import type { z, ZodObject } from 'zod';
|
|
||||||
import { ApiException, PatchStrategy, setHeaderOptions, type KubernetesObject } from '@kubernetes/client-node';
|
|
||||||
import { EventEmitter } from 'eventemitter3';
|
|
||||||
|
|
||||||
import type { Resource } from '../resources/resources.resource.ts';
|
|
||||||
import type { Services } from '../../utils/service.ts';
|
|
||||||
import { K8sService } from '../k8s/k8s.ts';
|
|
||||||
import { CoalescingQueued } from '../../utils/queues.ts';
|
|
||||||
|
|
||||||
import type { CustomResourceDefinition, CustomResourceStatus } from './custom-resources.types.ts';
|
|
||||||
import { CustomResourceConditions } from './custom-resources.conditions.ts';
|
|
||||||
|
|
||||||
type CustomResourceObject<TSpec extends ZodObject> = KubernetesObject & {
|
|
||||||
spec: z.infer<TSpec>;
|
|
||||||
status?: CustomResourceStatus;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CustomResourceOptions<TSpec extends ZodObject> = {
|
|
||||||
resource: Resource<CustomResourceObject<TSpec>>;
|
|
||||||
services: Services;
|
|
||||||
definition: CustomResourceDefinition<TSpec>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CustomResourceEvents<TSpec extends ZodObject> = {
|
|
||||||
changed: () => void;
|
|
||||||
changedStatus: (options: { previous: CustomResourceStatus; next: CustomResourceStatus }) => void;
|
|
||||||
changedMetadate: (options: { previous: KubernetesObject['metadata']; next: KubernetesObject['metadata'] }) => void;
|
|
||||||
changedSpec: (options: { previous: z.infer<TSpec>; next: z.infer<TSpec> }) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type SubresourceResult = {
|
|
||||||
ready: boolean;
|
|
||||||
syncing?: boolean;
|
|
||||||
failed?: boolean;
|
|
||||||
reason?: string;
|
|
||||||
message?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
abstract class CustomResource<TSpec extends ZodObject> extends EventEmitter<CustomResourceEvents<TSpec>> {
|
|
||||||
#options: CustomResourceOptions<TSpec>;
|
|
||||||
#conditions: CustomResourceConditions;
|
|
||||||
#queue: CoalescingQueued<void>;
|
|
||||||
|
|
||||||
constructor(options: CustomResourceOptions<TSpec>) {
|
|
||||||
super();
|
|
||||||
this.#options = options;
|
|
||||||
this.#conditions = new CustomResourceConditions({
|
|
||||||
resource: this,
|
|
||||||
});
|
|
||||||
options.resource.on('changed', this.#handleChanged);
|
|
||||||
this.#queue = new CoalescingQueued({
|
|
||||||
action: async () => {
|
|
||||||
if (this.exists && !this.isValidSpec) {
|
|
||||||
this.services.log.error(
|
|
||||||
`Invalid spec for ${this.apiVersion}/${this.kind}/${this.namespace}/${this.name}`,
|
|
||||||
this.spec,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.log('Reconcileing', this.apiVersion, this.kind, this.namespace, this.name);
|
|
||||||
await this.reconcile?.();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public get conditions() {
|
|
||||||
return this.#conditions;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get names() {
|
|
||||||
return this.#options.definition.names;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get services() {
|
|
||||||
const { services } = this.#options;
|
|
||||||
return services;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get resource() {
|
|
||||||
const { resource } = this.#options;
|
|
||||||
return resource;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get apiVersion() {
|
|
||||||
return this.resource.apiVersion;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get kind() {
|
|
||||||
return this.resource.kind;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get metadata(): KubernetesObject['metadata'] {
|
|
||||||
const metadata = this.resource.metadata;
|
|
||||||
return (
|
|
||||||
metadata || {
|
|
||||||
name: this.name,
|
|
||||||
namespace: this.namespace,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public get name() {
|
|
||||||
return this.resource.specifier.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get namespace() {
|
|
||||||
const namespace = this.resource.specifier.namespace;
|
|
||||||
if (!namespace) {
|
|
||||||
throw new Error('Custom resources needs a namespace');
|
|
||||||
}
|
|
||||||
return namespace;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get exists() {
|
|
||||||
return this.resource.exists;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get ref() {
|
|
||||||
return this.resource.ref;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get spec(): z.infer<TSpec> {
|
|
||||||
return this.resource.spec as ExpectedAny;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get status() {
|
|
||||||
return this.resource.manifest?.status;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get isSeen() {
|
|
||||||
return this.metadata?.generation === this.status?.observedGeneration;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get isValidSpec() {
|
|
||||||
const { success } = this.#options.definition.spec.safeParse(this.spec);
|
|
||||||
return success;
|
|
||||||
}
|
|
||||||
|
|
||||||
public setup?: () => Promise<void>;
|
|
||||||
public reconcile?: () => Promise<void>;
|
|
||||||
|
|
||||||
public markSeen = async () => {
|
|
||||||
if (this.isSeen) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await this.patchStatus({
|
|
||||||
observedGeneration: this.metadata?.generation,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
public queueReconcile = async () => {
|
|
||||||
return this.#queue.run();
|
|
||||||
};
|
|
||||||
|
|
||||||
#handleChanged = () => {
|
|
||||||
this.emit('changed');
|
|
||||||
};
|
|
||||||
|
|
||||||
public reconcileSubresource = async (name: string, action: () => Promise<SubresourceResult>) => {
|
|
||||||
try {
|
|
||||||
const result = await action();
|
|
||||||
await this.conditions.set(name, {
|
|
||||||
status: result.ready ? 'True' : 'False',
|
|
||||||
syncing: result.syncing,
|
|
||||||
failed: result.failed ?? false,
|
|
||||||
resource: true,
|
|
||||||
reason: result.reason,
|
|
||||||
message: result.message,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
await this.conditions.set(name, {
|
|
||||||
status: 'False',
|
|
||||||
failed: true,
|
|
||||||
reason: 'Failed',
|
|
||||||
resource: true,
|
|
||||||
message: err instanceof Error ? err.message : String(err),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
public patchStatus = async (status: Partial<CustomResourceStatus>) => {
|
|
||||||
const k8s = this.services.get(K8sService);
|
|
||||||
const [group, version] = this.apiVersion?.split('/') || [];
|
|
||||||
try {
|
|
||||||
await k8s.customObjectsApi.patchNamespacedCustomObjectStatus(
|
|
||||||
{
|
|
||||||
group,
|
|
||||||
version,
|
|
||||||
plural: this.names.plural,
|
|
||||||
name: this.name,
|
|
||||||
namespace: this.namespace,
|
|
||||||
body: {
|
|
||||||
status,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
setHeaderOptions('Content-Type', PatchStrategy.MergePatch),
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof ApiException && err.code === 404) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export { CustomResource, type CustomResourceOptions, type CustomResourceObject, type SubresourceResult };
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
import { ApiException, type KubernetesObject } from '@kubernetes/client-node';
|
|
||||||
import type { ZodObject } from 'zod';
|
|
||||||
|
|
||||||
import type { Services } from '../../utils/service.ts';
|
|
||||||
import type { Resource } from '../resources/resources.resource.ts';
|
|
||||||
import { WatcherService } from '../watchers/watchers.ts';
|
|
||||||
import { K8sService } from '../k8s/k8s.ts';
|
|
||||||
import { Queue } from '../queue/queue.ts';
|
|
||||||
|
|
||||||
import type { CustomResourceDefinition } from './custom-resources.types.ts';
|
|
||||||
import type { CustomResource } from './custom-resources.custom-resource.ts';
|
|
||||||
import { createManifest } from './custom-resources.utils.ts';
|
|
||||||
|
|
||||||
type DefinitionItem = {
|
|
||||||
definition: CustomResourceDefinition<ExpectedAny>;
|
|
||||||
queue: Queue;
|
|
||||||
};
|
|
||||||
|
|
||||||
class CustomResourceService {
|
|
||||||
#services: Services;
|
|
||||||
#definitions: DefinitionItem[];
|
|
||||||
#resources: Map<string, CustomResource<ExpectedAny>>;
|
|
||||||
|
|
||||||
constructor(services: Services) {
|
|
||||||
this.#definitions = [];
|
|
||||||
this.#resources = new Map();
|
|
||||||
this.#services = services;
|
|
||||||
}
|
|
||||||
|
|
||||||
#handleChanged = async (resource: Resource<KubernetesObject>) => {
|
|
||||||
const uid = resource.metadata?.uid;
|
|
||||||
if (!uid) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let current = this.#resources.get(uid);
|
|
||||||
if (!current) {
|
|
||||||
const entry = this.#definitions.find(
|
|
||||||
({ definition: r }) =>
|
|
||||||
r.version === resource.version &&
|
|
||||||
r.group === resource.group &&
|
|
||||||
r.version === resource.version &&
|
|
||||||
r.kind === resource.kind,
|
|
||||||
);
|
|
||||||
if (!entry) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { definition } = entry;
|
|
||||||
current = definition.create({
|
|
||||||
resource: resource as Resource<ExpectedAny>,
|
|
||||||
services: this.#services,
|
|
||||||
definition,
|
|
||||||
});
|
|
||||||
this.#resources.set(uid, current);
|
|
||||||
await current.setup?.();
|
|
||||||
if (!current.isSeen) {
|
|
||||||
await current.markSeen();
|
|
||||||
}
|
|
||||||
await current.queueReconcile();
|
|
||||||
} else if (!current.isSeen) {
|
|
||||||
await current.markSeen();
|
|
||||||
await current.queueReconcile();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
public register = (...resources: CustomResourceDefinition<ExpectedAny>[]) => {
|
|
||||||
this.#definitions.push(
|
|
||||||
...resources.map((definition) => ({
|
|
||||||
definition,
|
|
||||||
queue: new Queue(),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
public install = async (replace = false) => {
|
|
||||||
const k8sService = this.#services.get(K8sService);
|
|
||||||
for (const { definition: crd } of this.#definitions) {
|
|
||||||
this.#services.log.info('Installing CRD', { kind: crd.kind });
|
|
||||||
try {
|
|
||||||
const manifest = createManifest(crd);
|
|
||||||
try {
|
|
||||||
await k8sService.extensionsApi.createCustomResourceDefinition({
|
|
||||||
body: manifest,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof ApiException && error.code === 409) {
|
|
||||||
if (replace) {
|
|
||||||
await k8sService.extensionsApi.patchCustomResourceDefinition({
|
|
||||||
name: manifest.metadata.name,
|
|
||||||
body: [{ op: 'replace', path: '/spec', value: manifest.spec }],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof ApiException) {
|
|
||||||
throw new Error(`Failed to install ${crd.kind}: ${error.body}`);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
public watch = async () => {
|
|
||||||
const watcherService = this.#services.get(WatcherService);
|
|
||||||
for (const { definition, queue } of this.#definitions) {
|
|
||||||
const watcher = watcherService.create({
|
|
||||||
path: `/apis/${definition.group}/${definition.version}/${definition.names.plural}`,
|
|
||||||
list: (k8s) =>
|
|
||||||
k8s.customObjectsApi.listCustomObjectForAllNamespaces({
|
|
||||||
version: definition.version,
|
|
||||||
group: definition.group,
|
|
||||||
plural: definition.names.plural,
|
|
||||||
}),
|
|
||||||
verbs: ['add', 'update', 'delete'],
|
|
||||||
});
|
|
||||||
watcher.on('changed', (resource) => {
|
|
||||||
queue.add(() => this.#handleChanged(resource));
|
|
||||||
});
|
|
||||||
await watcher.start();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const createCustomResourceDefinition = <TSpec extends ZodObject>(options: CustomResourceDefinition<TSpec>) => options;
|
|
||||||
|
|
||||||
export { CustomResourceService, createCustomResourceDefinition };
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import { z, type ZodObject } from 'zod';
|
|
||||||
|
|
||||||
import type { CustomResource, CustomResourceOptions } from './custom-resources.custom-resource.ts';
|
|
||||||
|
|
||||||
type CustomResourceDefinition<TSpec extends ZodObject> = {
|
|
||||||
group: string;
|
|
||||||
version: string;
|
|
||||||
kind: string;
|
|
||||||
names: {
|
|
||||||
plural: string;
|
|
||||||
singular: string;
|
|
||||||
};
|
|
||||||
spec: TSpec;
|
|
||||||
create: (options: CustomResourceOptions<TSpec>) => CustomResource<TSpec>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const customResourceStatusSchema = z.object({
|
|
||||||
observedGeneration: z.number().optional(),
|
|
||||||
conditions: z
|
|
||||||
.array(
|
|
||||||
z.object({
|
|
||||||
observedGeneration: z.number().optional(),
|
|
||||||
type: z.string(),
|
|
||||||
status: z.enum(['True', 'False', 'Unknown']),
|
|
||||||
lastTransitionTime: z.string().datetime(),
|
|
||||||
resource: z.boolean().optional(),
|
|
||||||
failed: z.boolean().optional(),
|
|
||||||
syncing: z.boolean().optional(),
|
|
||||||
reason: z.string().optional().optional(),
|
|
||||||
message: z.string().optional().optional(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
type CustomResourceStatus = z.infer<typeof customResourceStatusSchema>;
|
|
||||||
|
|
||||||
export { customResourceStatusSchema, type CustomResourceDefinition, type CustomResourceStatus };
|
|
||||||
@@ -5,8 +5,8 @@ import {
|
|||||||
CustomObjectsApi,
|
CustomObjectsApi,
|
||||||
EventsV1Api,
|
EventsV1Api,
|
||||||
KubernetesObjectApi,
|
KubernetesObjectApi,
|
||||||
ApiException,
|
|
||||||
AppsV1Api,
|
AppsV1Api,
|
||||||
|
StorageV1Api,
|
||||||
} from '@kubernetes/client-node';
|
} from '@kubernetes/client-node';
|
||||||
|
|
||||||
class K8sService {
|
class K8sService {
|
||||||
@@ -17,6 +17,7 @@ class K8sService {
|
|||||||
#k8sEventsApi: EventsV1Api;
|
#k8sEventsApi: EventsV1Api;
|
||||||
#k8sObjectsApi: KubernetesObjectApi;
|
#k8sObjectsApi: KubernetesObjectApi;
|
||||||
#k8sAppsApi: AppsV1Api;
|
#k8sAppsApi: AppsV1Api;
|
||||||
|
#k8sStorageApi: StorageV1Api;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.#kc = new KubeConfig();
|
this.#kc = new KubeConfig();
|
||||||
@@ -27,6 +28,7 @@ class K8sService {
|
|||||||
this.#k8sEventsApi = this.#kc.makeApiClient(EventsV1Api);
|
this.#k8sEventsApi = this.#kc.makeApiClient(EventsV1Api);
|
||||||
this.#k8sObjectsApi = this.#kc.makeApiClient(KubernetesObjectApi);
|
this.#k8sObjectsApi = this.#kc.makeApiClient(KubernetesObjectApi);
|
||||||
this.#k8sAppsApi = this.#kc.makeApiClient(AppsV1Api);
|
this.#k8sAppsApi = this.#kc.makeApiClient(AppsV1Api);
|
||||||
|
this.#k8sStorageApi = this.#kc.makeApiClient(StorageV1Api);
|
||||||
}
|
}
|
||||||
|
|
||||||
public get config() {
|
public get config() {
|
||||||
@@ -56,6 +58,10 @@ class K8sService {
|
|||||||
public get apps() {
|
public get apps() {
|
||||||
return this.#k8sAppsApi;
|
return this.#k8sAppsApi;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public get storageApi() {
|
||||||
|
return this.#k8sStorageApi;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { K8sService };
|
export { K8sService };
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type PostgresInstanceOptions = {
|
|||||||
port?: number;
|
port?: number;
|
||||||
user: string;
|
user: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
database?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
class PostgresInstance {
|
class PostgresInstance {
|
||||||
@@ -19,10 +20,11 @@ class PostgresInstance {
|
|||||||
this.#db = knex({
|
this.#db = knex({
|
||||||
client: 'pg',
|
client: 'pg',
|
||||||
connection: {
|
connection: {
|
||||||
host: process.env.FORCE_PG_HOST ?? options.host,
|
host: options.host,
|
||||||
user: process.env.FORCE_PG_USER ?? options.user,
|
user: options.user,
|
||||||
password: process.env.FORCE_PG_PASSWORD ?? options.password,
|
password: options.password,
|
||||||
port: process.env.FORCE_PG_PORT ? parseInt(process.env.FORCE_PG_PORT) : options.port,
|
port: options.port,
|
||||||
|
database: options.database,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -30,29 +32,32 @@ class PostgresInstance {
|
|||||||
public ping = async () => {
|
public ping = async () => {
|
||||||
try {
|
try {
|
||||||
await this.#db.raw('SELECT 1');
|
await this.#db.raw('SELECT 1');
|
||||||
return true;
|
return;
|
||||||
} catch {
|
} catch (err) {
|
||||||
return false;
|
return err;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
public upsertRole = async (role: PostgresRole) => {
|
public upsertRole = async (role: PostgresRole) => {
|
||||||
const existingRole = await this.#db.raw('SELECT 1 FROM pg_roles WHERE rolname = ?', [role.name]);
|
const name = role.name;
|
||||||
|
const existingRole = await this.#db.raw('SELECT 1 FROM pg_roles WHERE rolname = ?', [name]);
|
||||||
|
|
||||||
if (existingRole.rows.length === 0) {
|
if (existingRole.rows.length === 0) {
|
||||||
await this.#db.raw(`CREATE ROLE ${role.name} WITH LOGIN PASSWORD '${role.password}'`);
|
await this.#db.raw(`CREATE ROLE "${name}" WITH LOGIN PASSWORD '${role.password}'`);
|
||||||
} else {
|
} else {
|
||||||
await this.#db.raw(`ALTER ROLE ${role.name} WITH PASSWORD '${role.password}'`);
|
await this.#db.raw(`ALTER ROLE "${name}" WITH PASSWORD '${role.password}'`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
public upsertDatabase = async (database: PostgresDatabase) => {
|
public upsertDatabase = async (database: PostgresDatabase) => {
|
||||||
const existingDatabase = await this.#db.raw('SELECT * FROM pg_database WHERE datname = ?', [database.name]);
|
const owner = database.owner;
|
||||||
|
const name = database.name;
|
||||||
|
const existingDatabase = await this.#db.raw('SELECT * FROM pg_database WHERE datname = ?', [name]);
|
||||||
|
|
||||||
if (existingDatabase.rows.length === 0) {
|
if (existingDatabase.rows.length === 0) {
|
||||||
await this.#db.raw(`CREATE DATABASE ${database.name} OWNER ${database.owner}`);
|
await this.#db.raw(`CREATE DATABASE "${name}" OWNER "${owner}"`);
|
||||||
} else {
|
} else {
|
||||||
await this.#db.raw(`ALTER DATABASE ${database.name} OWNER TO ${database.owner}`);
|
await this.#db.raw(`ALTER DATABASE "${name}" OWNER TO "${owner}"`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
186
src/services/resources/resource/resource.custom.ts
Normal file
186
src/services/resources/resource/resource.custom.ts
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import { z, type ZodType } from 'zod';
|
||||||
|
import { PatchStrategy, setHeaderOptions, type KubernetesObject } from '@kubernetes/client-node';
|
||||||
|
|
||||||
|
import { Resource, type ResourceOptions } from './resource.ts';
|
||||||
|
|
||||||
|
import { API_VERSION } from '#utils/consts.ts';
|
||||||
|
import { CoalescingQueued } from '#utils/queues.ts';
|
||||||
|
import { NotReadyError } from '#utils/errors.ts';
|
||||||
|
import { K8sService } from '#services/k8s/k8s.ts';
|
||||||
|
import { CronJob, CronTime } from 'cron';
|
||||||
|
|
||||||
|
const customResourceStatusSchema = z.object({
|
||||||
|
observedGeneration: z.number().optional(),
|
||||||
|
conditions: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
observedGeneration: z.number().optional(),
|
||||||
|
type: z.string(),
|
||||||
|
status: z.enum(['True', 'False', 'Unknown']),
|
||||||
|
lastTransitionTime: z.string().datetime().optional(),
|
||||||
|
resource: z.boolean().optional(),
|
||||||
|
failed: z.boolean().optional(),
|
||||||
|
syncing: z.boolean().optional(),
|
||||||
|
reason: z.string().optional().optional(),
|
||||||
|
message: z.string().optional().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type CustomResourceOptions<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 };
|
||||||
38
src/services/resources/resource/resource.reference.ts
Normal file
38
src/services/resources/resource/resource.reference.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
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 };
|
||||||
187
src/services/resources/resource/resource.ts
Normal file
187
src/services/resources/resource/resource.ts
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import { ApiException, PatchStrategy, type KubernetesObject } from '@kubernetes/client-node';
|
||||||
|
import { EventEmitter } from 'eventemitter3';
|
||||||
|
import deepEqual from 'deep-equal';
|
||||||
|
|
||||||
|
import type { Services } from '../../../utils/service.ts';
|
||||||
|
import { Queue } from '../../queue/queue.ts';
|
||||||
|
import { K8sService } from '../../k8s/k8s.ts';
|
||||||
|
import { isDeepSubset } from '../../../utils/objects.ts';
|
||||||
|
|
||||||
|
type ResourceSelector = {
|
||||||
|
apiVersion: string;
|
||||||
|
kind: string;
|
||||||
|
name: string;
|
||||||
|
namespace?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResourceOptions<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,81 +0,0 @@
|
|||||||
import type { KubernetesObject } from '@kubernetes/client-node';
|
|
||||||
import { EventEmitter } from 'eventemitter3';
|
|
||||||
|
|
||||||
import type { Resource } from './resources.ts';
|
|
||||||
import type { ResourceEvents } from './resources.resource.ts';
|
|
||||||
|
|
||||||
type ResourceReferenceEvents<T extends KubernetesObject> = ResourceEvents<T> & {
|
|
||||||
replaced: (options: { previous: Resource<T> | undefined; next: Resource<T> | undefined }) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
class ResourceReference<T extends KubernetesObject = KubernetesObject> extends EventEmitter<
|
|
||||||
ResourceReferenceEvents<T>
|
|
||||||
> {
|
|
||||||
#current?: Resource<T>;
|
|
||||||
#updatedEvent: ResourceEvents<T>['updated'];
|
|
||||||
#changedEvent: ResourceEvents<T>['changed'];
|
|
||||||
#changedMetadateEvent: ResourceEvents<T>['changedMetadate'];
|
|
||||||
#changedSpecEvent: ResourceEvents<T>['changedSpec'];
|
|
||||||
#changedStatusEvent: ResourceEvents<T>['changedStatus'];
|
|
||||||
#deletedEvent: ResourceEvents<T>['deleted'];
|
|
||||||
|
|
||||||
constructor(current?: Resource<T>) {
|
|
||||||
super();
|
|
||||||
this.#updatedEvent = this.emit.bind(this, 'updated');
|
|
||||||
this.#changedEvent = this.emit.bind(this, 'changed');
|
|
||||||
this.#changedMetadateEvent = this.emit.bind(this, 'changedMetadate');
|
|
||||||
this.#changedSpecEvent = this.emit.bind(this, 'changedSpec');
|
|
||||||
this.#changedStatusEvent = this.emit.bind(this, 'changedStatus');
|
|
||||||
this.#deletedEvent = this.emit.bind(this, 'deleted');
|
|
||||||
this.current = current;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get current() {
|
|
||||||
return this.#current;
|
|
||||||
}
|
|
||||||
|
|
||||||
public set current(next: Resource<T> | undefined) {
|
|
||||||
const previous = this.#current;
|
|
||||||
if (next === previous) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this.#current) {
|
|
||||||
this.#current.off('updated', this.#updatedEvent);
|
|
||||||
this.#current.off('changed', this.#changedEvent);
|
|
||||||
this.#current.off('changedMetadate', this.#changedMetadateEvent);
|
|
||||||
this.#current.off('changedSpec', this.#changedSpecEvent);
|
|
||||||
this.#current.off('changedStatus', this.#changedStatusEvent);
|
|
||||||
this.#current.off('deleted', this.#deletedEvent);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (next) {
|
|
||||||
next.on('updated', this.#updatedEvent);
|
|
||||||
next.on('changed', this.#changedEvent);
|
|
||||||
next.on('changedMetadate', this.#changedMetadateEvent);
|
|
||||||
next.on('changedSpec', this.#changedSpecEvent);
|
|
||||||
next.on('changedStatus', this.#changedStatusEvent);
|
|
||||||
next.on('deleted', this.#deletedEvent);
|
|
||||||
}
|
|
||||||
this.#current = next;
|
|
||||||
this.emit('replaced', {
|
|
||||||
previous,
|
|
||||||
next,
|
|
||||||
});
|
|
||||||
this.emit('changedStatus', {
|
|
||||||
previous: previous && 'status' in previous ? (previous.status as ExpectedAny) : undefined,
|
|
||||||
next: next && 'status' in next ? (next.status as ExpectedAny) : undefined,
|
|
||||||
});
|
|
||||||
this.emit('changedMetadate', {
|
|
||||||
previous: previous && 'metadata' in previous ? (previous.metadata as ExpectedAny) : undefined,
|
|
||||||
next: next && 'metadata' in next ? (next.metadata as ExpectedAny) : undefined,
|
|
||||||
});
|
|
||||||
this.emit('changedSpec', {
|
|
||||||
previous: previous && 'spec' in previous ? (previous.spec as ExpectedAny) : undefined,
|
|
||||||
next: next && 'spec' in next ? (next.spec as ExpectedAny) : undefined,
|
|
||||||
});
|
|
||||||
this.emit('changed');
|
|
||||||
this.emit('updated');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { ResourceReference };
|
|
||||||
@@ -1,298 +0,0 @@
|
|||||||
import { ApiException, PatchStrategy, V1MicroTime, type KubernetesObject } from '@kubernetes/client-node';
|
|
||||||
import { EventEmitter } from 'eventemitter3';
|
|
||||||
import equal from 'deep-equal';
|
|
||||||
|
|
||||||
import { Services } from '../../utils/service.ts';
|
|
||||||
import { K8sService } from '../k8s/k8s.ts';
|
|
||||||
import { Queue } from '../queue/queue.ts';
|
|
||||||
import { GROUP } from '../../utils/consts.ts';
|
|
||||||
|
|
||||||
import { ResourceService } from './resources.ts';
|
|
||||||
|
|
||||||
type ResourceOptions<T extends KubernetesObject> = {
|
|
||||||
services: Services;
|
|
||||||
manifest?: T;
|
|
||||||
data: {
|
|
||||||
apiVersion: string;
|
|
||||||
kind: string;
|
|
||||||
name: string;
|
|
||||||
namespace?: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type UnknownResource = KubernetesObject & {
|
|
||||||
spec: ExpectedAny;
|
|
||||||
data: ExpectedAny;
|
|
||||||
};
|
|
||||||
|
|
||||||
type EventOptions = {
|
|
||||||
reason: string;
|
|
||||||
message: string;
|
|
||||||
action: string;
|
|
||||||
type: 'Normal' | 'Warning' | 'Error';
|
|
||||||
};
|
|
||||||
|
|
||||||
type ResourceEvents<T extends KubernetesObject> = {
|
|
||||||
updated: () => void;
|
|
||||||
deleted: () => void;
|
|
||||||
changed: () => void;
|
|
||||||
changedStatus: (options: {
|
|
||||||
previous: T extends { status: infer K } ? K | undefined : never;
|
|
||||||
next: T extends { status: infer K } ? K | undefined : never;
|
|
||||||
}) => void;
|
|
||||||
changedMetadate: (options: { previous: T['metadata'] | undefined; next: T['metadata'] | undefined }) => void;
|
|
||||||
changedSpec: (options: {
|
|
||||||
previous: T extends { spec: infer K } ? K | undefined : never;
|
|
||||||
next: T extends { spec: infer K } ? K | undefined : never;
|
|
||||||
}) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
class Resource<T extends KubernetesObject = UnknownResource> extends EventEmitter<ResourceEvents<T>> {
|
|
||||||
#options: ResourceOptions<T>;
|
|
||||||
#queue: Queue;
|
|
||||||
|
|
||||||
constructor(options: ResourceOptions<T>) {
|
|
||||||
super();
|
|
||||||
this.#options = options;
|
|
||||||
this.#queue = new Queue({ concurrency: 1 });
|
|
||||||
}
|
|
||||||
|
|
||||||
public get specifier() {
|
|
||||||
return this.#options.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get manifest() {
|
|
||||||
return this.#options?.manifest;
|
|
||||||
}
|
|
||||||
|
|
||||||
public set manifest(obj: T | undefined) {
|
|
||||||
if (equal(obj, this.manifest)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.#options.manifest = obj;
|
|
||||||
const nextManifest = obj || {};
|
|
||||||
const currentManifest = this.manifest || {};
|
|
||||||
const nextStatus = 'status' in nextManifest ? nextManifest.status : undefined;
|
|
||||||
const currentStatus = 'status' in currentManifest ? currentManifest.status : undefined;
|
|
||||||
if (!equal(nextStatus, currentStatus)) {
|
|
||||||
this.emit('changedStatus', {
|
|
||||||
previous: currentStatus as ExpectedAny,
|
|
||||||
next: nextStatus as ExpectedAny,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextSpec = 'spec' in nextManifest ? nextManifest.spec : undefined;
|
|
||||||
const currentSpec = 'spec' in currentManifest ? currentManifest.spec : undefined;
|
|
||||||
if (!equal(nextSpec, currentSpec)) {
|
|
||||||
this.emit('changedSpec', {
|
|
||||||
next: nextSpec as ExpectedAny,
|
|
||||||
previous: currentSpec as ExpectedAny,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextMetadata = 'metadata' in nextManifest ? nextManifest.metadata : undefined;
|
|
||||||
const currentMetadata = 'metadata' in currentManifest ? currentManifest.metadata : undefined;
|
|
||||||
if (!equal(nextMetadata, currentMetadata)) {
|
|
||||||
this.emit('changedMetadate', {
|
|
||||||
next: nextMetadata as ExpectedAny,
|
|
||||||
previous: currentMetadata as ExpectedAny,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
this.emit('updated');
|
|
||||||
this.emit('changed');
|
|
||||||
}
|
|
||||||
|
|
||||||
public get ref() {
|
|
||||||
if (!this.metadata?.uid) {
|
|
||||||
throw new Error('No uid for resource');
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
apiVersion: this.apiVersion,
|
|
||||||
kind: this.kind,
|
|
||||||
name: this.name,
|
|
||||||
uid: this.metadata.uid,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public get exists() {
|
|
||||||
return !!this.manifest;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get apiVersion() {
|
|
||||||
return this.#options.data.apiVersion;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get group() {
|
|
||||||
const [group] = this.apiVersion?.split('/') || [];
|
|
||||||
return group;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get version() {
|
|
||||||
const [, version] = this.apiVersion?.split('/') || [];
|
|
||||||
return version;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get kind() {
|
|
||||||
return this.#options.data.kind;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get metadata() {
|
|
||||||
return this.manifest?.metadata;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get name() {
|
|
||||||
return this.#options.data.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get namespace() {
|
|
||||||
return this.#options.data.namespace;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get spec(): T extends { spec?: infer K } ? K | undefined : never {
|
|
||||||
if (this.manifest && 'spec' in this.manifest) {
|
|
||||||
return this.manifest.spec as ExpectedAny;
|
|
||||||
}
|
|
||||||
return undefined as ExpectedAny;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get data(): T extends { data?: infer K } ? K | undefined : never {
|
|
||||||
if (this.manifest && 'data' in this.manifest) {
|
|
||||||
return this.manifest.data as ExpectedAny;
|
|
||||||
}
|
|
||||||
return undefined as ExpectedAny;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get owners() {
|
|
||||||
const { services } = this.#options;
|
|
||||||
const references = this.metadata?.ownerReferences || [];
|
|
||||||
const resourceService = services.get(ResourceService);
|
|
||||||
return references.map((ref) =>
|
|
||||||
resourceService.get({
|
|
||||||
apiVersion: ref.apiVersion,
|
|
||||||
kind: ref.kind,
|
|
||||||
name: ref.name,
|
|
||||||
namespace: this.namespace,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public patch = (patch: T) =>
|
|
||||||
this.#queue.add(async () => {
|
|
||||||
const { services } = this.#options;
|
|
||||||
services.log.debug(`Patching ${this.apiVersion}/${this.kind}/${this.namespace}/${this.name}`, {
|
|
||||||
specifier: this.specifier,
|
|
||||||
current: this.manifest,
|
|
||||||
patch,
|
|
||||||
});
|
|
||||||
const k8s = services.get(K8sService);
|
|
||||||
const body = {
|
|
||||||
...patch,
|
|
||||||
apiVersion: this.specifier.apiVersion,
|
|
||||||
kind: this.specifier.kind,
|
|
||||||
metadata: {
|
|
||||||
...patch.metadata,
|
|
||||||
name: this.specifier.name,
|
|
||||||
namespace: this.specifier.namespace,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
this.manifest = await k8s.objectsApi.patch(
|
|
||||||
body,
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
PatchStrategy.MergePatch,
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof ApiException && err.code === 404) {
|
|
||||||
this.manifest = await k8s.objectsApi.create(body);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
public delete = () =>
|
|
||||||
this.#queue.add(async () => {
|
|
||||||
try {
|
|
||||||
const { services } = this.#options;
|
|
||||||
services.log.debug(`Deleting ${this.apiVersion}/${this.kind}/${this.namespace}/${this.name}`);
|
|
||||||
const k8s = services.get(K8sService);
|
|
||||||
await k8s.objectsApi.delete({
|
|
||||||
apiVersion: this.specifier.apiVersion,
|
|
||||||
kind: this.specifier.kind,
|
|
||||||
metadata: {
|
|
||||||
name: this.specifier.name,
|
|
||||||
namespace: this.specifier.namespace,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
this.manifest = undefined;
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof ApiException && err.code === 404) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
public load = () =>
|
|
||||||
this.#queue.add(async () => {
|
|
||||||
const { services } = this.#options;
|
|
||||||
const k8s = services.get(K8sService);
|
|
||||||
try {
|
|
||||||
const manifest = await k8s.objectsApi.read({
|
|
||||||
apiVersion: this.specifier.apiVersion,
|
|
||||||
kind: this.specifier.kind,
|
|
||||||
metadata: {
|
|
||||||
name: this.specifier.name,
|
|
||||||
namespace: this.specifier.namespace,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
this.manifest = manifest as T;
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof ApiException && err.code === 404) {
|
|
||||||
this.manifest = undefined;
|
|
||||||
} else {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
public addEvent = (event: EventOptions) =>
|
|
||||||
this.#queue.add(async () => {
|
|
||||||
const { services } = this.#options;
|
|
||||||
const k8sService = services.get(K8sService);
|
|
||||||
|
|
||||||
services.log.debug(`Adding event ${this.apiVersion}/${this.kind}/${this.namespace}/${this.name}`, event);
|
|
||||||
|
|
||||||
await k8sService.eventsApi.createNamespacedEvent({
|
|
||||||
namespace: this.specifier.namespace || 'default',
|
|
||||||
body: {
|
|
||||||
kind: 'Event',
|
|
||||||
metadata: {
|
|
||||||
name: `${this.specifier.name}-${Date.now()}-${Buffer.from(crypto.getRandomValues(new Uint8Array(8))).toString('hex')}`,
|
|
||||||
namespace: this.specifier.namespace,
|
|
||||||
},
|
|
||||||
eventTime: new V1MicroTime(),
|
|
||||||
note: event.message,
|
|
||||||
action: event.action,
|
|
||||||
reason: event.reason,
|
|
||||||
type: event.type,
|
|
||||||
reportingController: GROUP,
|
|
||||||
reportingInstance: this.name,
|
|
||||||
regarding: {
|
|
||||||
apiVersion: this.specifier.apiVersion,
|
|
||||||
resourceVersion: this.metadata?.resourceVersion,
|
|
||||||
kind: this.specifier.kind,
|
|
||||||
name: this.specifier.name,
|
|
||||||
namespace: this.specifier.namespace,
|
|
||||||
uid: this.metadata?.uid,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Resource, type UnknownResource, type ResourceEvents };
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user