mirror of
https://github.com/morten-olsen/homelab-operator.git
synced 2026-02-08 01:36:28 +01:00
Compare commits
11 Commits
feat/rewri
...
v0.1.35
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2be6bdca84 | ||
|
|
f362f4afc4 | ||
|
|
9fadbf75fb | ||
|
|
2add15d283 | ||
|
|
5426495be5 | ||
|
|
b8bb16ccbb | ||
|
|
d4b56007f1 | ||
|
|
130bfec468 | ||
|
|
ddb3c79657 | ||
|
|
47cf43b44e | ||
|
|
aa6d14738a |
16
.github/workflows/main.yml
vendored
16
.github/workflows/main.yml
vendored
@@ -71,9 +71,23 @@ jobs:
|
||||
environment: release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: release-drafter/release-drafter@v6
|
||||
- id: create-release
|
||||
uses: release-drafter/release-drafter@v6
|
||||
with:
|
||||
config-name: release-drafter-config.yml
|
||||
publish: true
|
||||
env:
|
||||
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
|
||||
13
Makefile
13
Makefile
@@ -1,15 +1,14 @@
|
||||
.PHONY: setup dev-recreate dev-create dev-destroy
|
||||
|
||||
setup:
|
||||
./scripts/setup-server.sh
|
||||
.PHONY: dev-recreate dev-destroy server-install
|
||||
|
||||
dev-destroy:
|
||||
colima delete -f
|
||||
|
||||
dev-create:
|
||||
colima start --network-address --kubernetes -m 8 --mount ${PWD}/data:/data:w --k3s-arg="--disable=helm-controller,local-storage"
|
||||
dev-recreate: dev-destroy
|
||||
colima start --network-address --kubernetes -m 8 --k3s-arg="--disable=helm-controller,local-storage,traefik" # --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:
|
||||
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,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"]
|
||||
@@ -1,108 +0,0 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "homelab-operator.fullname" . }}
|
||||
labels:
|
||||
{{- include "homelab-operator.labels" . | nindent 4 }}
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "homelab-operator.selectorLabels" . | nindent 6 }}
|
||||
template:
|
||||
metadata:
|
||||
{{- with .Values.podAnnotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
labels:
|
||||
{{- include "homelab-operator.selectorLabels" . | nindent 8 }}
|
||||
spec:
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
serviceAccountName: {{ include "homelab-operator.serviceAccountName" . }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.podSecurityContext | nindent 8 }}
|
||||
containers:
|
||||
- name: {{ .Chart.Name }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.securityContext | nindent 12 }}
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
env:
|
||||
# PostgreSQL Host
|
||||
- name: POSTGRES_HOST
|
||||
{{- if .Values.config.postgres.host.fromSecret.enabled }}
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Values.config.postgres.host.fromSecret.secretName }}
|
||||
key: {{ .Values.config.postgres.host.fromSecret.key }}
|
||||
{{- else }}
|
||||
value: {{ .Values.config.postgres.host.value | quote }}
|
||||
{{- end }}
|
||||
# PostgreSQL Port
|
||||
- name: POSTGRES_PORT
|
||||
{{- if .Values.config.postgres.port.fromSecret.enabled }}
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Values.config.postgres.port.fromSecret.secretName }}
|
||||
key: {{ .Values.config.postgres.port.fromSecret.key }}
|
||||
{{- else }}
|
||||
value: {{ .Values.config.postgres.port.value | quote }}
|
||||
{{- end }}
|
||||
# PostgreSQL User
|
||||
- name: POSTGRES_USER
|
||||
{{- if .Values.config.postgres.user.fromSecret.enabled }}
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Values.config.postgres.user.fromSecret.secretName }}
|
||||
key: {{ .Values.config.postgres.user.fromSecret.key }}
|
||||
{{- else }}
|
||||
value: {{ .Values.config.postgres.user.value | quote }}
|
||||
{{- end }}
|
||||
# PostgreSQL Password
|
||||
- name: POSTGRES_PASSWORD
|
||||
{{- if .Values.config.postgres.password.fromSecret.enabled }}
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Values.config.postgres.password.fromSecret.secretName }}
|
||||
key: {{ .Values.config.postgres.password.fromSecret.key }}
|
||||
{{- else }}
|
||||
value: {{ .Values.config.postgres.password.value | quote }}
|
||||
{{- end }}
|
||||
# Certificate Manager
|
||||
- name: CERT_MANAGER
|
||||
{{- if .Values.config.certManager.fromSecret.enabled }}
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Values.config.certManager.fromSecret.secretName }}
|
||||
key: {{ .Values.config.certManager.fromSecret.key }}
|
||||
{{- else }}
|
||||
value: {{ .Values.config.certManager.value | quote }}
|
||||
{{- end }}
|
||||
# Istio Gateway
|
||||
- name: ISTIO_GATEWAY
|
||||
{{- if .Values.config.istioGateway.fromSecret.enabled }}
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Values.config.istioGateway.fromSecret.secretName }}
|
||||
key: {{ .Values.config.istioGateway.fromSecret.key }}
|
||||
{{- else }}
|
||||
value: {{ .Values.config.istioGateway.value | quote }}
|
||||
{{- end }}
|
||||
resources:
|
||||
{{- toYaml .Values.resources | nindent 12 }}
|
||||
{{- with .Values.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
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"]
|
||||
55
charts/operator/templates/deployment.yaml
Normal file
55
charts/operator/templates/deployment.yaml
Normal file
@@ -0,0 +1,55 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "homelab-operator.fullname" . }}
|
||||
labels:
|
||||
{{- include "homelab-operator.labels" . | nindent 4 }}
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "homelab-operator.selectorLabels" . | nindent 6 }}
|
||||
template:
|
||||
metadata:
|
||||
{{- with .Values.podAnnotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
labels:
|
||||
{{- include "homelab-operator.selectorLabels" . | nindent 8 }}
|
||||
spec:
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
serviceAccountName: {{ include "homelab-operator.serviceAccountName" . }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.podSecurityContext | nindent 8 }}
|
||||
containers:
|
||||
- name: {{ .Chart.Name }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.securityContext | nindent 12 }}
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
resources:
|
||||
{{- toYaml .Values.resources | nindent 12 }}
|
||||
volumeMounts:
|
||||
- name: data-volumes
|
||||
mountPath: {{ .Values.storage.path }}
|
||||
volumes:
|
||||
- name: data-volumes
|
||||
hostPath:
|
||||
path: {{ .Values.storage.path }}
|
||||
type: DirectoryOrCreate
|
||||
{{- with .Values.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
@@ -9,8 +9,14 @@ image:
|
||||
tag: main
|
||||
|
||||
imagePullSecrets: []
|
||||
nameOverride: ""
|
||||
fullnameOverride: ""
|
||||
nameOverride: ''
|
||||
fullnameOverride: ''
|
||||
|
||||
storage:
|
||||
path: /data/volumes
|
||||
reclaimPolicy: Retain
|
||||
allowVolumeExpansion: false
|
||||
volumeBindingMode: WaitForFirstConsumer
|
||||
|
||||
serviceAccount:
|
||||
# Specifies whether a service account should be created
|
||||
@@ -19,7 +25,7 @@ serviceAccount:
|
||||
annotations: {}
|
||||
# The name of the service account to use.
|
||||
# If not set and create is true, a name is generated using the fullname template
|
||||
name: ""
|
||||
name: ''
|
||||
|
||||
podAnnotations: {}
|
||||
|
||||
@@ -51,53 +57,3 @@ nodeSelector: {}
|
||||
tolerations: []
|
||||
|
||||
affinity: {}
|
||||
|
||||
# Configuration for the homelab operator
|
||||
config:
|
||||
# PostgreSQL database configuration
|
||||
postgres:
|
||||
host:
|
||||
# Direct value (used when fromSecret.enabled is false)
|
||||
value: "127.0.0.1"
|
||||
# Secret reference (used when fromSecret.enabled is true)
|
||||
fromSecret:
|
||||
enabled: false
|
||||
secretName: ""
|
||||
key: "POSTGRES_HOST"
|
||||
|
||||
port:
|
||||
value: "5432"
|
||||
fromSecret:
|
||||
enabled: false
|
||||
secretName: ""
|
||||
key: "POSTGRES_PORT"
|
||||
|
||||
user:
|
||||
value: "postgres"
|
||||
fromSecret:
|
||||
enabled: false
|
||||
secretName: ""
|
||||
key: "POSTGRES_USER"
|
||||
|
||||
password:
|
||||
value: ""
|
||||
fromSecret:
|
||||
enabled: true # Default to secret for sensitive data
|
||||
secretName: "postgres-secret"
|
||||
key: "POSTGRES_PASSWORD"
|
||||
|
||||
# Certificate manager configuration
|
||||
certManager:
|
||||
value: "letsencrypt-prod"
|
||||
fromSecret:
|
||||
enabled: false
|
||||
secretName: ""
|
||||
key: "CERT_MANAGER"
|
||||
|
||||
# Istio gateway configuration
|
||||
istioGateway:
|
||||
value: "istio-ingress"
|
||||
fromSecret:
|
||||
enabled: false
|
||||
secretName: ""
|
||||
key: "ISTIO_GATEWAY"
|
||||
@@ -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/`.
|
||||
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
|
||||
namespace: dev
|
||||
spec:
|
||||
domain: one.dev.olsen.cloud
|
||||
tls:
|
||||
issuer: letsencrypt-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
|
||||
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
|
||||
15
scripts/list-manifests.ts
Executable file
15
scripts/list-manifests.ts
Executable file
@@ -0,0 +1,15 @@
|
||||
#!/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}`);
|
||||
}
|
||||
}
|
||||
38
src/bootstrap/bootstrap.ts
Normal file
38
src/bootstrap/bootstrap.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { Services } from '../utils/service.ts';
|
||||
|
||||
import { NamespaceService } from './namespaces/namespaces.ts';
|
||||
import { ReleaseService } from './releases/releases.ts';
|
||||
import { RepoService } from './repos/repos.ts';
|
||||
import { ClusterIssuerService } from './resources/issuer.ts';
|
||||
|
||||
class BootstrapService {
|
||||
#services: Services;
|
||||
|
||||
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 clusterIssuer() {
|
||||
return this.#services.get(ClusterIssuerService);
|
||||
}
|
||||
|
||||
public ensure = async () => {
|
||||
await this.namespaces.ensure();
|
||||
await this.repos.ensure();
|
||||
await this.releases.ensure();
|
||||
await this.clusterIssuer.ensure();
|
||||
};
|
||||
}
|
||||
|
||||
export { BootstrapService };
|
||||
64
src/bootstrap/namespaces/namespaces.ts
Normal file
64
src/bootstrap/namespaces/namespaces.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { NamespaceInstance } from '../../instances/namespace.ts';
|
||||
import type { Services } from '../../utils/service.ts';
|
||||
import { ResourceService } from '../../services/resources/resources.ts';
|
||||
|
||||
class NamespaceService {
|
||||
#homelab: NamespaceInstance;
|
||||
#istioSystem: NamespaceInstance;
|
||||
#certManager: NamespaceInstance;
|
||||
|
||||
constructor(services: Services) {
|
||||
const resourceService = services.get(ResourceService);
|
||||
this.#homelab = resourceService.getInstance(
|
||||
{
|
||||
apiVersion: 'v1',
|
||||
kind: 'Namespace',
|
||||
name: 'homelab',
|
||||
},
|
||||
NamespaceInstance,
|
||||
);
|
||||
this.#istioSystem = resourceService.getInstance(
|
||||
{
|
||||
apiVersion: 'v1',
|
||||
kind: 'Namespace',
|
||||
name: 'istio-system',
|
||||
},
|
||||
NamespaceInstance,
|
||||
);
|
||||
this.#certManager = resourceService.getInstance(
|
||||
{
|
||||
apiVersion: 'v1',
|
||||
kind: 'Namespace',
|
||||
name: 'cert-manager',
|
||||
},
|
||||
NamespaceInstance,
|
||||
);
|
||||
this.#homelab.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 };
|
||||
171
src/bootstrap/releases/releases.ts
Normal file
171
src/bootstrap/releases/releases.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { HelmReleaseInstance } from '../../instances/helm-release.ts';
|
||||
import { ResourceService } from '../../services/resources/resources.ts';
|
||||
import { NAMESPACE } from '../../utils/consts.ts';
|
||||
import { Services } from '../../utils/service.ts';
|
||||
import { NamespaceService } from '../namespaces/namespaces.ts';
|
||||
import { RepoService } from '../repos/repos.ts';
|
||||
|
||||
class ReleaseService {
|
||||
#services: Services;
|
||||
#certManager: HelmReleaseInstance;
|
||||
#istioBase: HelmReleaseInstance;
|
||||
#istiod: HelmReleaseInstance;
|
||||
#istioGateway: HelmReleaseInstance;
|
||||
|
||||
constructor(services: Services) {
|
||||
this.#services = services;
|
||||
const resourceService = services.get(ResourceService);
|
||||
this.#certManager = resourceService.getInstance(
|
||||
{
|
||||
apiVersion: 'helm.toolkit.fluxcd.io/v2',
|
||||
kind: 'HelmRelease',
|
||||
name: 'cert-manager',
|
||||
namespace: NAMESPACE,
|
||||
},
|
||||
HelmReleaseInstance,
|
||||
);
|
||||
this.#istioBase = resourceService.getInstance(
|
||||
{
|
||||
apiVersion: 'helm.toolkit.fluxcd.io/v2',
|
||||
kind: 'HelmRelease',
|
||||
name: 'istio-base',
|
||||
namespace: NAMESPACE,
|
||||
},
|
||||
HelmReleaseInstance,
|
||||
);
|
||||
this.#istiod = resourceService.getInstance(
|
||||
{
|
||||
apiVersion: 'helm.toolkit.fluxcd.io/v2',
|
||||
kind: 'HelmRelease',
|
||||
name: 'istiod',
|
||||
namespace: NAMESPACE,
|
||||
},
|
||||
HelmReleaseInstance,
|
||||
);
|
||||
this.#istioGateway = resourceService.getInstance(
|
||||
{
|
||||
apiVersion: 'helm.toolkit.fluxcd.io/v2',
|
||||
kind: 'HelmRelease',
|
||||
name: 'istio-gateway',
|
||||
namespace: NAMESPACE,
|
||||
},
|
||||
HelmReleaseInstance,
|
||||
);
|
||||
this.#certManager.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 };
|
||||
112
src/bootstrap/repos/repos.ts
Normal file
112
src/bootstrap/repos/repos.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import type { Services } from '../../utils/service.ts';
|
||||
import { ResourceService } from '../../services/resources/resources.ts';
|
||||
import { HelmRepoInstance } from '../../instances/helm-repo.ts';
|
||||
import { NAMESPACE } from '../../utils/consts.ts';
|
||||
|
||||
class RepoService {
|
||||
#jetstack: HelmRepoInstance;
|
||||
#istio: HelmRepoInstance;
|
||||
#authentik: HelmRepoInstance;
|
||||
#containerro: HelmRepoInstance;
|
||||
|
||||
constructor(services: Services) {
|
||||
const resourceService = services.get(ResourceService);
|
||||
this.#jetstack = resourceService.getInstance(
|
||||
{
|
||||
apiVersion: 'source.toolkit.fluxcd.io/v1',
|
||||
kind: 'HelmRepository',
|
||||
name: 'jetstack',
|
||||
namespace: NAMESPACE,
|
||||
},
|
||||
HelmRepoInstance,
|
||||
);
|
||||
this.#istio = resourceService.getInstance(
|
||||
{
|
||||
apiVersion: 'source.toolkit.fluxcd.io/v1',
|
||||
kind: 'HelmRepository',
|
||||
name: 'istio',
|
||||
namespace: NAMESPACE,
|
||||
},
|
||||
HelmRepoInstance,
|
||||
);
|
||||
this.#authentik = resourceService.getInstance(
|
||||
{
|
||||
apiVersion: 'source.toolkit.fluxcd.io/v1',
|
||||
kind: 'HelmRepository',
|
||||
name: 'authentik',
|
||||
namespace: NAMESPACE,
|
||||
},
|
||||
HelmRepoInstance,
|
||||
);
|
||||
this.#containerro = resourceService.getInstance(
|
||||
{
|
||||
apiVersion: 'source.toolkit.fluxcd.io/v1',
|
||||
kind: 'HelmRepository',
|
||||
name: 'containerro',
|
||||
namespace: NAMESPACE,
|
||||
},
|
||||
HelmRepoInstance,
|
||||
);
|
||||
this.#jetstack.on('changed', this.ensure);
|
||||
this.#istio.on('changed', this.ensure);
|
||||
this.#authentik.on('changed', this.ensure);
|
||||
this.#containerro.on('changed', this.ensure);
|
||||
}
|
||||
|
||||
public get jetstack() {
|
||||
return this.#jetstack;
|
||||
}
|
||||
public get istio() {
|
||||
return this.#istio;
|
||||
}
|
||||
public get authentik() {
|
||||
return this.#authentik;
|
||||
}
|
||||
public get containerro() {
|
||||
return this.#containerro;
|
||||
}
|
||||
|
||||
public ensure = async () => {
|
||||
await this.#jetstack.ensure({
|
||||
metadata: {
|
||||
name: 'jetstack',
|
||||
},
|
||||
spec: {
|
||||
interval: '1h',
|
||||
url: 'https://charts.jetstack.io',
|
||||
},
|
||||
});
|
||||
|
||||
await this.#istio.ensure({
|
||||
metadata: {
|
||||
name: 'istio',
|
||||
},
|
||||
spec: {
|
||||
interval: '1h',
|
||||
url: 'https://istio-release.storage.googleapis.com/charts',
|
||||
},
|
||||
});
|
||||
|
||||
await this.#authentik.ensure({
|
||||
metadata: {
|
||||
name: 'authentik',
|
||||
},
|
||||
spec: {
|
||||
interval: '1h',
|
||||
url: 'https://charts.goauthentik.io',
|
||||
},
|
||||
});
|
||||
|
||||
await this.#containerro.ensure({
|
||||
metadata: {
|
||||
name: 'containerro',
|
||||
},
|
||||
spec: {
|
||||
interval: '1h',
|
||||
url: 'https://charts.containeroo.ch',
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export { RepoService };
|
||||
64
src/bootstrap/resources/issuer.ts
Normal file
64
src/bootstrap/resources/issuer.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { ClusterIssuerInstance } from '../../instances/cluster-issuer.ts';
|
||||
import { CustomDefinitionInstance } from '../../instances/custom-resource-definition.ts';
|
||||
import { ResourceService } from '../../services/resources/resources.ts';
|
||||
import type { Services } from '../../utils/service.ts';
|
||||
|
||||
class ClusterIssuerService {
|
||||
#clusterIssuerCrd: CustomDefinitionInstance;
|
||||
#clusterIssuer: ClusterIssuerInstance;
|
||||
|
||||
constructor(services: Services) {
|
||||
const resourceService = services.get(ResourceService);
|
||||
this.#clusterIssuerCrd = resourceService.getInstance(
|
||||
{
|
||||
apiVersion: 'v1',
|
||||
kind: 'CustomResourceDefinition',
|
||||
name: 'clusterissuers.cert-manager.io',
|
||||
},
|
||||
CustomDefinitionInstance,
|
||||
);
|
||||
this.#clusterIssuer = resourceService.getInstance(
|
||||
{
|
||||
apiVersion: 'v1',
|
||||
kind: 'ClusterIssuer',
|
||||
name: 'cluster-issuer',
|
||||
},
|
||||
ClusterIssuerInstance,
|
||||
);
|
||||
|
||||
this.#clusterIssuerCrd.on('changed', this.ensure);
|
||||
this.#clusterIssuer.on('changed', this.ensure);
|
||||
}
|
||||
|
||||
public ensure = async () => {
|
||||
if (!this.#clusterIssuerCrd.ready) {
|
||||
return;
|
||||
}
|
||||
await this.#clusterIssuer.ensure({
|
||||
spec: {
|
||||
acme: {
|
||||
server: 'https://acme-v02.api.letsencrypt.org/directory',
|
||||
email: 'admin@example.com',
|
||||
privateKeySecretRef: {
|
||||
name: 'cluster-issuer-key',
|
||||
},
|
||||
solvers: [
|
||||
{
|
||||
dns01: {
|
||||
cloudflare: {
|
||||
email: 'admin@example.com',
|
||||
apiKeySecretRef: {
|
||||
name: 'cloudflare-api-key',
|
||||
key: 'api-key',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export { ClusterIssuerService };
|
||||
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,36 +1,31 @@
|
||||
import type { V1Secret } from '@kubernetes/client-node';
|
||||
import type { z } from 'zod';
|
||||
import deepEqual from 'deep-equal';
|
||||
|
||||
import {
|
||||
CustomResource,
|
||||
type CustomResourceObject,
|
||||
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 type { authentikServerSpecSchema } from '../authentik-server/authentik-server.scemas.ts';
|
||||
import type { domainSpecSchema } from '../domain/domain.schemas.ts';
|
||||
import { decodeSecret, encodeSecret } from '../../utils/secrets.ts';
|
||||
import { API_VERSION, CONTROLLED_LABEL } from '../../utils/consts.ts';
|
||||
import { CONTROLLED_LABEL } from '../../utils/consts.ts';
|
||||
import { isDeepSubset } from '../../utils/objects.ts';
|
||||
import { AuthentikService } from '../../services/authentik/authentik.service.ts';
|
||||
import { authentikServerSecretSchema } from '../authentik-server/authentik-server.schemas.ts';
|
||||
|
||||
import { authentikClientSecretSchema, type authentikClientSpecSchema } from './authentik-client.schemas.ts';
|
||||
|
||||
class AuthentikClientResource extends CustomResource<typeof authentikClientSpecSchema> {
|
||||
#serverResource: ResourceReference<CustomResourceObject<typeof authentikServerSpecSchema>>;
|
||||
#serverSecretResource: ResourceReference<V1Secret>;
|
||||
#domainResource: ResourceReference<CustomResourceObject<typeof domainSpecSchema>>;
|
||||
#serverSecret: ResourceReference<V1Secret>;
|
||||
#clientSecretResource: Resource<V1Secret>;
|
||||
|
||||
constructor(options: CustomResourceOptions<typeof authentikClientSpecSchema>) {
|
||||
super(options);
|
||||
const resourceService = this.services.get(ResourceService);
|
||||
|
||||
this.#serverResource = new ResourceReference();
|
||||
this.#serverSecretResource = new ResourceReference();
|
||||
this.#domainResource = new ResourceReference();
|
||||
this.#serverSecret = new ResourceReference();
|
||||
this.#clientSecretResource = resourceService.get({
|
||||
apiVersion: 'v1',
|
||||
kind: 'Secret',
|
||||
@@ -40,93 +35,45 @@ class AuthentikClientResource extends CustomResource<typeof authentikClientSpecS
|
||||
|
||||
this.#updateResouces();
|
||||
|
||||
this.#serverResource.on('changed', this.queueReconcile);
|
||||
this.#serverSecretResource.on('changed', this.queueReconcile);
|
||||
this.#domainResource.on('changed', this.queueReconcile);
|
||||
this.#serverSecret.on('changed', this.queueReconcile);
|
||||
this.#clientSecretResource.on('changed', this.queueReconcile);
|
||||
}
|
||||
|
||||
get server() {
|
||||
return this.#serverResource.current;
|
||||
}
|
||||
|
||||
get serverSecret() {
|
||||
return this.#serverSecretResource.current;
|
||||
}
|
||||
|
||||
get serverSecretValue() {
|
||||
return decodeSecret(this.#serverSecretResource.current?.data);
|
||||
}
|
||||
|
||||
get domain() {
|
||||
return this.#domainResource.current;
|
||||
}
|
||||
|
||||
get clientSecret() {
|
||||
return this.#clientSecretResource;
|
||||
}
|
||||
|
||||
get clientSecretValue() {
|
||||
const values = decodeSecret(this.#clientSecretResource.data);
|
||||
const parsed = authentikClientSecretSchema.safeParse(values);
|
||||
if (!parsed.success) {
|
||||
return undefined;
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
#updateResouces = () => {
|
||||
const serverNames = getWithNamespace(this.spec.server, this.namespace);
|
||||
const serverSecretNames = getWithNamespace(`${this.spec.server}-server`, this.namespace);
|
||||
const resourceService = this.services.get(ResourceService);
|
||||
this.#serverResource.current = resourceService.get({
|
||||
apiVersion: API_VERSION,
|
||||
kind: 'AuthentikServer',
|
||||
name: serverNames.name,
|
||||
namespace: serverNames.namespace,
|
||||
});
|
||||
this.#serverSecretResource.current = resourceService.get({
|
||||
this.#serverSecret.current = resourceService.get({
|
||||
apiVersion: 'v1',
|
||||
kind: 'Secret',
|
||||
name: `authentik-server-${serverNames.name}`,
|
||||
namespace: serverNames.namespace,
|
||||
name: serverSecretNames.name,
|
||||
namespace: serverSecretNames.namespace,
|
||||
});
|
||||
const server = this.#serverResource.current;
|
||||
if (server && server.spec) {
|
||||
const domainNames = getWithNamespace(server.spec.domain, server.namespace);
|
||||
this.#domainResource.current = resourceService.get({
|
||||
apiVersion: API_VERSION,
|
||||
kind: 'Domain',
|
||||
name: domainNames.name,
|
||||
namespace: domainNames.namespace,
|
||||
});
|
||||
} else {
|
||||
this.#domainResource.current = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
#reconcileClientSecret = async (): Promise<SubresourceResult> => {
|
||||
const domain = this.domain;
|
||||
const server = this.server;
|
||||
const serverSecret = this.serverSecret;
|
||||
if (!server?.exists || !server?.spec || !serverSecret?.exists || !serverSecret.data) {
|
||||
const serverSecret = this.#serverSecret.current;
|
||||
if (!serverSecret?.exists || !serverSecret.data) {
|
||||
return {
|
||||
ready: false,
|
||||
failed: true,
|
||||
message: 'Server or server secret not found',
|
||||
};
|
||||
}
|
||||
if (!domain?.exists || !domain?.spec) {
|
||||
const serverSecretData = authentikServerSecretSchema.safeParse(decodeSecret(serverSecret.data));
|
||||
if (!serverSecretData.success || !serverSecretData.data) {
|
||||
return {
|
||||
ready: false,
|
||||
failed: true,
|
||||
message: 'Domain not found',
|
||||
message: 'Server secret not found',
|
||||
};
|
||||
}
|
||||
const url = `https://authentik.${domain.spec?.hostname}`;
|
||||
const url = serverSecretData.data.url;
|
||||
const appName = this.name;
|
||||
const values = this.clientSecretValue;
|
||||
const expectedValues: Omit<z.infer<typeof authentikClientSecretSchema>, 'clientSecret'> = {
|
||||
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(),
|
||||
@@ -135,31 +82,8 @@ class AuthentikClientResource extends CustomResource<typeof authentikClientSpecS
|
||||
endSession: new URL(`/application/o/${appName}/end-session/`, url).toString(),
|
||||
jwks: new URL(`/application/o/${appName}/jwks/`, url).toString(),
|
||||
};
|
||||
if (!values) {
|
||||
await this.clientSecret.patch({
|
||||
metadata: {
|
||||
ownerReferences: [this.ref],
|
||||
labels: {
|
||||
...CONTROLLED_LABEL,
|
||||
},
|
||||
},
|
||||
data: encodeSecret({
|
||||
...expectedValues,
|
||||
clientSecret: crypto.randomUUID(),
|
||||
}),
|
||||
});
|
||||
return {
|
||||
ready: false,
|
||||
syncing: true,
|
||||
message: 'UpdatingManifest',
|
||||
};
|
||||
}
|
||||
const compareData = {
|
||||
...values,
|
||||
clientSecret: undefined,
|
||||
};
|
||||
if (!deepEqual(compareData, expectedValues)) {
|
||||
await this.clientSecret.patch({
|
||||
if (!isDeepSubset(clientSecretData.data, expectedValues)) {
|
||||
await this.#clientSecretResource.patch({
|
||||
metadata: {
|
||||
ownerReferences: [this.ref],
|
||||
labels: {
|
||||
@@ -168,23 +92,82 @@ class AuthentikClientResource extends CustomResource<typeof authentikClientSpecS
|
||||
},
|
||||
data: encodeSecret(expectedValues),
|
||||
});
|
||||
return {
|
||||
ready: false,
|
||||
syncing: true,
|
||||
message: 'UpdatingManifest',
|
||||
};
|
||||
}
|
||||
return {
|
||||
ready: true,
|
||||
};
|
||||
};
|
||||
|
||||
#reconcileServer = async (): Promise<SubresourceResult> => {
|
||||
const serverSecret = this.#serverSecret.current;
|
||||
const clientSecret = this.#clientSecretResource;
|
||||
|
||||
if (!serverSecret?.exists || !serverSecret.data) {
|
||||
return {
|
||||
ready: false,
|
||||
failed: true,
|
||||
message: 'Server secret not found',
|
||||
};
|
||||
}
|
||||
|
||||
const serverSecretData = authentikServerSecretSchema.safeParse(decodeSecret(serverSecret.data));
|
||||
if (!serverSecretData.success || !serverSecretData.data) {
|
||||
return {
|
||||
ready: false,
|
||||
failed: true,
|
||||
message: 'Server secret not found',
|
||||
};
|
||||
}
|
||||
|
||||
const clientSecretData = authentikClientSecretSchema.safeParse(decodeSecret(clientSecret.data));
|
||||
if (!clientSecretData.success || !clientSecretData.data) {
|
||||
return {
|
||||
ready: false,
|
||||
failed: true,
|
||||
message: 'Client secret not found',
|
||||
};
|
||||
}
|
||||
|
||||
const authentikService = this.services.get(AuthentikService);
|
||||
const authentikServer = authentikService.get({
|
||||
url: {
|
||||
internal: `http://${serverSecretData.data.host}`,
|
||||
external: serverSecretData.data.url,
|
||||
},
|
||||
token: serverSecretData.data.token,
|
||||
});
|
||||
|
||||
(await authentikServer).upsertClient({
|
||||
...this.spec,
|
||||
name: this.name,
|
||||
secret: clientSecretData.data.clientSecret,
|
||||
});
|
||||
|
||||
return {
|
||||
ready: true,
|
||||
};
|
||||
};
|
||||
|
||||
public reconcile = async () => {
|
||||
if (!this.exists || this.metadata.deletionTimestamp) {
|
||||
if (!this.exists || this.metadata?.deletionTimestamp) {
|
||||
return;
|
||||
}
|
||||
this.#updateResouces();
|
||||
await Promise.all([this.reconcileSubresource('Secret', this.#reconcileClientSecret)]);
|
||||
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 ? 'True' : 'False',
|
||||
status: secretReady && serverReady ? 'True' : 'False',
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ClientTypeEnum, MatchingModeEnum, SubModeEnum } from '@goauthentik/api';
|
||||
import { ClientTypeEnum, SubModeEnum } from '@goauthentik/api';
|
||||
import { z } from 'zod';
|
||||
|
||||
const authentikClientSpecSchema = z.object({
|
||||
@@ -8,7 +8,7 @@ const authentikClientSpecSchema = z.object({
|
||||
redirectUris: z.array(
|
||||
z.object({
|
||||
url: z.string(),
|
||||
matchingMode: z.enum(MatchingModeEnum).optional(),
|
||||
matchingMode: z.enum(['strict', 'regex']),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import type { V1Secret } from '@kubernetes/client-node';
|
||||
|
||||
import { RepoService } from '../../bootstrap/repos/repos.ts';
|
||||
import { HelmReleaseInstance } from '../../instances/helm-release.ts';
|
||||
import { SecretInstance } from '../../instances/secret.ts';
|
||||
import {
|
||||
CustomResource,
|
||||
type CustomResourceOptions,
|
||||
type CustomResourceObject,
|
||||
} from '../../services/custom-resources/custom-resources.custom-resource.ts';
|
||||
import { ResourceReference } from '../../services/resources/resources.ref.ts';
|
||||
import { ResourceService } from '../../services/resources/resources.ts';
|
||||
import type { EnsuredSecret } from '../../services/secrets/secrets.secret.ts';
|
||||
import { SecretService } from '../../services/secrets/secrets.ts';
|
||||
import { API_VERSION } from '../../utils/consts.ts';
|
||||
import { getWithNamespace } from '../../utils/naming.ts';
|
||||
import { decodeSecret, encodeSecret } from '../../utils/secrets.ts';
|
||||
import type { environmentSpecSchema } from '../environment/environment.schemas.ts';
|
||||
import { HttpServiceInstance } from '../../instances/http-service.ts';
|
||||
import type { redisServerSpecSchema } from '../redis-server/redis-server.schemas.ts';
|
||||
import { PostgresDatabaseInstance } from '../../instances/postgres-database.ts';
|
||||
|
||||
import {
|
||||
authentikServerInitSecretSchema,
|
||||
authentikServerSecretSchema,
|
||||
type authentikServerSpecSchema,
|
||||
} from './authentik-server.schemas.ts';
|
||||
|
||||
class AuthentikServerController extends CustomResource<typeof authentikServerSpecSchema> {
|
||||
#environment: ResourceReference<CustomResourceObject<typeof environmentSpecSchema>>;
|
||||
#authentikInitSecret: EnsuredSecret<typeof authentikServerInitSecretSchema>;
|
||||
#authentikSecret: SecretInstance;
|
||||
#authentikRelease: HelmReleaseInstance;
|
||||
#postgresSecret: ResourceReference<V1Secret>;
|
||||
#httpService: HttpServiceInstance;
|
||||
#redisServer: ResourceReference<CustomResourceObject<typeof redisServerSpecSchema>>;
|
||||
#postgresDatabase: PostgresDatabaseInstance;
|
||||
|
||||
constructor(options: CustomResourceOptions<typeof authentikServerSpecSchema>) {
|
||||
super(options);
|
||||
const secretService = this.services.get(SecretService);
|
||||
const resourceService = this.services.get(ResourceService);
|
||||
|
||||
this.#environment = new ResourceReference();
|
||||
this.#authentikInitSecret = secretService.ensure({
|
||||
owner: [this.ref],
|
||||
name: `${this.name}-init`,
|
||||
namespace: this.namespace,
|
||||
schema: authentikServerInitSecretSchema,
|
||||
generator: () => ({
|
||||
AUTHENTIK_BOOTSTRAP_TOKEN: crypto.randomUUID(),
|
||||
AUTHENTIK_BOOTSTRAP_PASSWORD: crypto.randomUUID(),
|
||||
AUTHENTIK_BOOTSTRAP_EMAIL: 'admin@example.com',
|
||||
AUTHENTIK_SECRET_KEY: crypto.randomUUID(),
|
||||
}),
|
||||
});
|
||||
this.#authentikSecret = resourceService.getInstance(
|
||||
{
|
||||
apiVersion: 'v1',
|
||||
kind: 'Secret',
|
||||
name: `${this.name}-server`,
|
||||
namespace: this.namespace,
|
||||
},
|
||||
SecretInstance<typeof authentikServerSecretSchema>,
|
||||
);
|
||||
this.#authentikRelease = resourceService.getInstance(
|
||||
{
|
||||
apiVersion: 'helm.toolkit.fluxcd.io/v2',
|
||||
kind: 'HelmRelease',
|
||||
name: this.name,
|
||||
namespace: this.namespace,
|
||||
},
|
||||
HelmReleaseInstance,
|
||||
);
|
||||
this.#httpService = resourceService.getInstance(
|
||||
{
|
||||
apiVersion: API_VERSION,
|
||||
kind: 'HttpService',
|
||||
name: this.name,
|
||||
namespace: this.namespace,
|
||||
},
|
||||
HttpServiceInstance,
|
||||
);
|
||||
this.#postgresDatabase = resourceService.getInstance(
|
||||
{
|
||||
apiVersion: API_VERSION,
|
||||
kind: 'PostgresDatabase',
|
||||
name: this.name,
|
||||
namespace: this.namespace,
|
||||
},
|
||||
PostgresDatabaseInstance,
|
||||
);
|
||||
this.#redisServer = new ResourceReference();
|
||||
this.#postgresSecret = new ResourceReference();
|
||||
this.#authentikSecret.on('changed', this.queueReconcile);
|
||||
this.#authentikInitSecret.resource.on('deleted', this.queueReconcile);
|
||||
this.#environment.on('changed', this.queueReconcile);
|
||||
this.#authentikRelease.on('changed', this.queueReconcile);
|
||||
this.#postgresSecret.on('changed', this.queueReconcile);
|
||||
this.#httpService.on('changed', this.queueReconcile);
|
||||
this.#redisServer.on('changed', this.queueReconcile);
|
||||
}
|
||||
|
||||
public reconcile = async () => {
|
||||
if (!this.exists || this.metadata?.deletionTimestamp) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.#authentikInitSecret.isValid) {
|
||||
await this.markNotReady('MissingAuthentikInitSecret', 'The authentik init secret is not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const resourceService = this.services.get(ResourceService);
|
||||
const environmentNames = getWithNamespace(this.spec.environment, this.namespace);
|
||||
|
||||
this.#environment.current = resourceService.get({
|
||||
apiVersion: API_VERSION,
|
||||
kind: 'Environment',
|
||||
name: environmentNames.name,
|
||||
namespace: this.namespace,
|
||||
});
|
||||
|
||||
await this.#postgresDatabase.ensure({
|
||||
metadata: {
|
||||
ownerReferences: [this.ref],
|
||||
},
|
||||
spec: {
|
||||
cluster: this.spec.postgresCluster,
|
||||
},
|
||||
});
|
||||
const postgresSecret = this.#postgresDatabase.secret;
|
||||
|
||||
if (!postgresSecret.exists) {
|
||||
await this.markNotReady('MissingPostgresSecret', 'The postgres secret is not found');
|
||||
return;
|
||||
}
|
||||
const postgresSecretData = decodeSecret(postgresSecret.data) || {};
|
||||
|
||||
if (!this.#environment.current?.exists) {
|
||||
await this.markNotReady(
|
||||
'MissingEnvironment',
|
||||
`Environment ${this.#environment.current?.namespace}/${this.#environment.current?.name} not found`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const domain = this.#environment.current.spec?.domain;
|
||||
if (!domain) {
|
||||
await this.markNotReady('MissingDomain', 'The domain is not set');
|
||||
return;
|
||||
}
|
||||
|
||||
const secretData = {
|
||||
url: `https://${this.spec.subdomain}.${domain}`,
|
||||
host: `${this.name}.${this.namespace}.svc.cluster.local`,
|
||||
token: this.#authentikInitSecret.value?.AUTHENTIK_BOOTSTRAP_TOKEN ?? '',
|
||||
};
|
||||
|
||||
await this.#authentikSecret.ensure({
|
||||
metadata: {
|
||||
ownerReferences: [this.ref],
|
||||
},
|
||||
data: encodeSecret(secretData),
|
||||
});
|
||||
|
||||
const repoService = this.services.get(RepoService);
|
||||
|
||||
const redisNames = getWithNamespace(this.spec.redisServer, this.namespace);
|
||||
const redisHost = `${redisNames.name}.${redisNames.namespace}.svc.cluster.local`;
|
||||
|
||||
await this.#authentikRelease.ensure({
|
||||
metadata: {
|
||||
ownerReferences: [this.ref],
|
||||
},
|
||||
spec: {
|
||||
interval: '60m',
|
||||
chart: {
|
||||
spec: {
|
||||
chart: 'authentik',
|
||||
version: '2025.6.4',
|
||||
sourceRef: {
|
||||
apiVersion: 'source.toolkit.fluxcd.io/v1',
|
||||
kind: 'HelmRepository',
|
||||
name: repoService.authentik.name,
|
||||
namespace: repoService.authentik.namespace,
|
||||
},
|
||||
},
|
||||
},
|
||||
values: {
|
||||
global: {
|
||||
envFrom: [
|
||||
{
|
||||
secretRef: {
|
||||
name: this.#authentikInitSecret.name,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
authentik: {
|
||||
error_reporting: {
|
||||
enabled: false,
|
||||
},
|
||||
postgresql: {
|
||||
host: postgresSecretData.host,
|
||||
name: postgresSecretData.database,
|
||||
user: postgresSecretData.username,
|
||||
password: 'file:///postgres-creds/password',
|
||||
},
|
||||
redis: {
|
||||
host: redisHost,
|
||||
},
|
||||
},
|
||||
server: {
|
||||
volumes: [
|
||||
{
|
||||
name: 'postgres-creds',
|
||||
secret: {
|
||||
secretName: postgresSecret.name,
|
||||
},
|
||||
},
|
||||
],
|
||||
volumeMounts: [
|
||||
{
|
||||
name: 'postgres-creds',
|
||||
mountPath: '/postgres-creds',
|
||||
readOnly: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
worker: {
|
||||
volumes: [
|
||||
{
|
||||
name: 'postgres-creds',
|
||||
secret: {
|
||||
secretName: postgresSecret.name,
|
||||
},
|
||||
},
|
||||
],
|
||||
volumeMounts: [
|
||||
{
|
||||
name: 'postgres-creds',
|
||||
mountPath: '/postgres-creds',
|
||||
readOnly: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await this.#httpService.ensure({
|
||||
metadata: {
|
||||
ownerReferences: [this.ref],
|
||||
},
|
||||
spec: {
|
||||
environment: this.spec.environment,
|
||||
subdomain: this.spec.subdomain,
|
||||
destination: {
|
||||
host: `${this.name}-server.${this.namespace}.svc.cluster.local`,
|
||||
port: {
|
||||
number: 80,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await this.markReady();
|
||||
};
|
||||
}
|
||||
|
||||
export { AuthentikServerController };
|
||||
@@ -1,176 +0,0 @@
|
||||
import type { CustomResourceObject } from '../../services/custom-resources/custom-resources.custom-resource.ts';
|
||||
import { API_VERSION, CONTROLLED_LABEL } from '../../utils/consts.ts';
|
||||
import type { domainServiceSpecSchema } from '../domain-service/domain-service.schemas.ts';
|
||||
|
||||
type CreateContainerManifestOptions = {
|
||||
name: string;
|
||||
namespace: string;
|
||||
command: string;
|
||||
owner: ExpectedAny;
|
||||
secret: string;
|
||||
bootstrap: {
|
||||
email: string;
|
||||
password: string;
|
||||
token: string;
|
||||
};
|
||||
posgtres: {
|
||||
host: string;
|
||||
port: string;
|
||||
name: string;
|
||||
user: string;
|
||||
password: string;
|
||||
};
|
||||
redis: {
|
||||
host: string;
|
||||
port: string;
|
||||
};
|
||||
};
|
||||
const createManifest = (options: CreateContainerManifestOptions) => ({
|
||||
apiVersion: 'apps/v1',
|
||||
kind: 'Deployment',
|
||||
metadata: {
|
||||
name: options.name,
|
||||
namespace: options.namespace,
|
||||
labels: {
|
||||
'app.kubernetes.io/name': options.name,
|
||||
...CONTROLLED_LABEL,
|
||||
},
|
||||
ownerReferences: [options.owner],
|
||||
},
|
||||
spec: {
|
||||
replicas: 1,
|
||||
selector: {
|
||||
matchLabels: {
|
||||
'app.kubernetes.io/name': options.name,
|
||||
},
|
||||
},
|
||||
template: {
|
||||
metadata: {
|
||||
labels: {
|
||||
'app.kubernetes.io/name': options.name,
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
containers: [
|
||||
{
|
||||
name: options.name,
|
||||
image: 'ghcr.io/goauthentik/server:2025.6.4',
|
||||
args: [options.command],
|
||||
env: [
|
||||
{ name: 'AUTHENTIK_SECRET_KEY', value: options.secret },
|
||||
{ name: 'AUTHENTIK_POSTGRESQL__HOST', value: options.posgtres.host },
|
||||
{
|
||||
name: 'AUTHENTIK_POSTGRESQL__PORT',
|
||||
value: '5432',
|
||||
},
|
||||
{
|
||||
name: 'AUTHENTIK_POSTGRESQL__NAME',
|
||||
value: options.posgtres.name,
|
||||
},
|
||||
{
|
||||
name: 'AUTHENTIK_POSTGRESQL__USER',
|
||||
value: options.posgtres.user,
|
||||
},
|
||||
{
|
||||
name: 'AUTHENTIK_POSTGRESQL__PASSWORD',
|
||||
value: options.posgtres.password,
|
||||
},
|
||||
{
|
||||
name: 'AUTHENTIK_REDIS__HOST',
|
||||
value: options.redis.host,
|
||||
},
|
||||
{
|
||||
name: 'AUTHENTIK_REDIS__PORT',
|
||||
value: options.redis.port,
|
||||
},
|
||||
{
|
||||
name: 'AUTHENTIK_BOOTSTRAP_PASSWORD',
|
||||
value: options.bootstrap.password,
|
||||
},
|
||||
{
|
||||
name: 'AUTHENTIK_BOOTSTRAP_TOKEN',
|
||||
value: options.bootstrap.token,
|
||||
},
|
||||
{
|
||||
name: 'AUTHENTIK_BOOTSTRAP_EMAIL',
|
||||
value: options.bootstrap.email,
|
||||
},
|
||||
],
|
||||
ports: [
|
||||
{
|
||||
name: 'http',
|
||||
containerPort: 9000,
|
||||
protocol: 'TCP',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
type CreateServiceManifestOptions = {
|
||||
name: string;
|
||||
namespace: string;
|
||||
owner: ExpectedAny;
|
||||
appName: string;
|
||||
};
|
||||
const createServiceManifest = (options: CreateServiceManifestOptions) => ({
|
||||
apiVersion: 'v1',
|
||||
kind: 'Service',
|
||||
metadata: {
|
||||
name: options.name,
|
||||
namespace: options.namespace,
|
||||
labels: {
|
||||
...CONTROLLED_LABEL,
|
||||
},
|
||||
ownerReferences: [options.owner],
|
||||
},
|
||||
spec: {
|
||||
type: 'ClusterIP',
|
||||
ports: [
|
||||
{
|
||||
port: 9000,
|
||||
targetPort: 9000,
|
||||
protocol: 'TCP',
|
||||
name: 'http',
|
||||
},
|
||||
],
|
||||
selector: {
|
||||
'app.kubernetes.io/name': options.appName,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
type CreateDomainServiceOptions = {
|
||||
name: string;
|
||||
namespace: string;
|
||||
owner: ExpectedAny;
|
||||
subdomain: string;
|
||||
host: string;
|
||||
domain: string;
|
||||
};
|
||||
const createDomainService = (
|
||||
options: CreateDomainServiceOptions,
|
||||
): Omit<CustomResourceObject<typeof domainServiceSpecSchema>, 'status'> => ({
|
||||
apiVersion: API_VERSION,
|
||||
kind: 'DomainService',
|
||||
metadata: {
|
||||
name: options.name,
|
||||
namespace: options.namespace,
|
||||
ownerReferences: [options.owner],
|
||||
},
|
||||
spec: {
|
||||
domain: options.domain,
|
||||
subdomain: options.subdomain,
|
||||
destination: {
|
||||
host: options.host,
|
||||
port: {
|
||||
number: 9000,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export { createManifest, createServiceManifest, createDomainService };
|
||||
@@ -1,382 +0,0 @@
|
||||
import type { V1Service, V1Deployment, V1Secret } from '@kubernetes/client-node';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
CustomResource,
|
||||
type CustomResourceObject,
|
||||
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 type { domainSpecSchema } from '../domain/domain.schemas.ts';
|
||||
import type { domainServiceSpecSchema } from '../domain-service/domain-service.schemas.ts';
|
||||
import type { EnsuredSecret } from '../../services/secrets/secrets.secret.ts';
|
||||
import { getWithNamespace } from '../../utils/naming.ts';
|
||||
import { API_VERSION } from '../../utils/consts.ts';
|
||||
import { SecretService } from '../../services/secrets/secrets.ts';
|
||||
import { decodeSecret } from '../../utils/secrets.ts';
|
||||
import type { postgresDatabaseSecretSchema } from '../postgres-database/postgres-database.resource.ts';
|
||||
import type { redisConnectionSpecSchema } from '../redis-connection/redis-connection.schemas.ts';
|
||||
import { isDeepSubset } from '../../utils/objects.ts';
|
||||
|
||||
import { authentikServerSecretSchema, type authentikServerSpecSchema } from './authentik-server.scemas.ts';
|
||||
import { createDomainService, createManifest, createServiceManifest } from './authentik-server.create-manifests.ts';
|
||||
|
||||
class AuthentikServerResource extends CustomResource<typeof authentikServerSpecSchema> {
|
||||
#domainResource: ResourceReference<CustomResourceObject<typeof domainSpecSchema>>;
|
||||
#databaseSecretResource: ResourceReference<V1Secret>;
|
||||
#redisResource: ResourceReference<CustomResourceObject<typeof redisConnectionSpecSchema>>;
|
||||
#redisSecretResource: ResourceReference<V1Secret>;
|
||||
#deploymentServerResource: Resource<V1Deployment>;
|
||||
#deploymentWorkerResource: Resource<V1Deployment>;
|
||||
#service: Resource<V1Service>;
|
||||
#domainServiceResource: Resource<CustomResourceObject<typeof domainServiceSpecSchema>>;
|
||||
#secret: EnsuredSecret<typeof authentikServerSecretSchema>;
|
||||
|
||||
constructor(options: CustomResourceOptions<typeof authentikServerSpecSchema>) {
|
||||
super(options);
|
||||
const resourceService = this.services.get(ResourceService);
|
||||
const secretService = this.services.get(SecretService);
|
||||
|
||||
this.#domainResource = new ResourceReference();
|
||||
this.#databaseSecretResource = new ResourceReference();
|
||||
this.#redisResource = new ResourceReference();
|
||||
this.#redisSecretResource = new ResourceReference();
|
||||
|
||||
this.#deploymentServerResource = resourceService.get({
|
||||
apiVersion: 'apps/v1',
|
||||
kind: 'Deployment',
|
||||
name: this.#serverName,
|
||||
namespace: this.namespace,
|
||||
});
|
||||
|
||||
this.#deploymentWorkerResource = resourceService.get({
|
||||
apiVersion: 'apps/v1',
|
||||
kind: 'Deployment',
|
||||
name: this.#workerName,
|
||||
namespace: this.namespace,
|
||||
});
|
||||
|
||||
this.#domainServiceResource = resourceService.get({
|
||||
apiVersion: API_VERSION,
|
||||
kind: 'DomainService',
|
||||
name: this.name,
|
||||
namespace: this.namespace,
|
||||
});
|
||||
|
||||
this.#service = resourceService.get({
|
||||
apiVersion: 'v1',
|
||||
kind: 'Service',
|
||||
name: this.name,
|
||||
namespace: this.namespace,
|
||||
});
|
||||
|
||||
this.#secret = secretService.ensure({
|
||||
name: `authentik-server-${this.name}`,
|
||||
namespace: this.namespace,
|
||||
schema: authentikServerSecretSchema,
|
||||
generator: () => ({
|
||||
secret: crypto.randomUUID(),
|
||||
token: crypto.randomUUID(),
|
||||
password: crypto.randomUUID(),
|
||||
}),
|
||||
});
|
||||
|
||||
this.#domainServiceResource = resourceService.get({
|
||||
apiVersion: API_VERSION,
|
||||
kind: 'DomainService',
|
||||
name: this.name,
|
||||
namespace: this.namespace,
|
||||
});
|
||||
|
||||
this.#updateResources();
|
||||
|
||||
this.#domainResource.on('changed', this.queueReconcile);
|
||||
this.#databaseSecretResource.on('changed', this.queueReconcile);
|
||||
this.#redisResource.on('changed', this.queueReconcile);
|
||||
this.#redisSecretResource.on('changed', this.queueReconcile);
|
||||
this.#deploymentServerResource.on('changed', this.queueReconcile);
|
||||
this.#deploymentWorkerResource.on('changed', this.queueReconcile);
|
||||
this.#domainServiceResource.on('changed', this.queueReconcile);
|
||||
this.#service.on('changed', this.queueReconcile);
|
||||
this.#secret.resouce.on('changed', this.queueReconcile);
|
||||
}
|
||||
|
||||
get #databaseSecretName() {
|
||||
const { name } = getWithNamespace(this.spec.database);
|
||||
return `postgres-database-${name}`;
|
||||
}
|
||||
|
||||
get #workerName() {
|
||||
return `${this.name}-worker`;
|
||||
}
|
||||
|
||||
get #serverName() {
|
||||
return `${this.name}-server`;
|
||||
}
|
||||
|
||||
#updateResources = () => {
|
||||
if (!this.isValidSpec) {
|
||||
return;
|
||||
}
|
||||
const resourceService = this.services.get(ResourceService);
|
||||
const redisNames = getWithNamespace(this.spec.redis, this.namespace);
|
||||
const redisResource = resourceService.get<CustomResourceObject<typeof redisConnectionSpecSchema>>({
|
||||
apiVersion: API_VERSION,
|
||||
kind: 'RedisConnection',
|
||||
name: redisNames.name,
|
||||
namespace: redisNames.namespace,
|
||||
});
|
||||
this.#redisResource.current = redisResource;
|
||||
const redis = this.#redisResource.current;
|
||||
|
||||
if (redis.exists && redis.spec) {
|
||||
const redisSecretNames = getWithNamespace(redis.spec.secret, redis.namespace);
|
||||
this.#redisSecretResource.current = resourceService.get({
|
||||
apiVersion: 'v1',
|
||||
kind: 'Secret',
|
||||
name: redisSecretNames.name,
|
||||
namespace: redisSecretNames.namespace,
|
||||
});
|
||||
} else {
|
||||
this.#redisSecretResource.current = undefined;
|
||||
}
|
||||
|
||||
const domainNames = getWithNamespace(this.spec.domain, this.namespace);
|
||||
const databaseNames = getWithNamespace(this.spec.database, this.namespace);
|
||||
|
||||
this.#domainResource.current = resourceService.get({
|
||||
apiVersion: API_VERSION,
|
||||
kind: 'Domain',
|
||||
name: domainNames.name,
|
||||
namespace: domainNames.namespace,
|
||||
});
|
||||
|
||||
this.#databaseSecretResource.current = resourceService.get({
|
||||
apiVersion: 'v1',
|
||||
kind: 'Secret',
|
||||
name: this.#databaseSecretName,
|
||||
namespace: databaseNames.namespace,
|
||||
});
|
||||
};
|
||||
|
||||
#reconcileWorkerDeployment = async (): Promise<SubresourceResult> => {
|
||||
const domainService = this.#domainResource.current;
|
||||
if (!domainService?.exists || !domainService.spec) {
|
||||
return {
|
||||
ready: false,
|
||||
failed: true,
|
||||
reason: 'MissingDomain',
|
||||
};
|
||||
}
|
||||
const databaseSecret = decodeSecret<z.infer<typeof postgresDatabaseSecretSchema>>(
|
||||
this.#databaseSecretResource.current?.data,
|
||||
);
|
||||
if (!databaseSecret) {
|
||||
return {
|
||||
ready: false,
|
||||
failed: true,
|
||||
reason: 'MissingDatabase',
|
||||
};
|
||||
}
|
||||
const secret = this.#secret.value;
|
||||
if (!this.#secret.isValid || !secret) {
|
||||
return {
|
||||
ready: false,
|
||||
syncing: true,
|
||||
reason: 'WaitingForSecret',
|
||||
};
|
||||
}
|
||||
|
||||
const redisSecret = decodeSecret(this.#redisSecretResource.current?.data);
|
||||
if (!redisSecret || !redisSecret.host) {
|
||||
return {
|
||||
ready: false,
|
||||
failed: true,
|
||||
reason: 'MissingRedisSecret',
|
||||
};
|
||||
}
|
||||
|
||||
const email = `admin@${domainService.spec.hostname}`;
|
||||
const manifest = createManifest({
|
||||
name: this.#workerName,
|
||||
namespace: this.namespace,
|
||||
secret: secret.secret,
|
||||
command: 'worker',
|
||||
owner: this.ref,
|
||||
bootstrap: {
|
||||
email,
|
||||
token: secret.token,
|
||||
password: secret.password,
|
||||
},
|
||||
redis: {
|
||||
host: redisSecret.host,
|
||||
port: redisSecret.port ?? '6379',
|
||||
},
|
||||
posgtres: {
|
||||
host: databaseSecret.host,
|
||||
port: databaseSecret.port || '5432',
|
||||
name: databaseSecret.database,
|
||||
user: databaseSecret.user,
|
||||
password: databaseSecret.password,
|
||||
},
|
||||
});
|
||||
if (!isDeepSubset(this.#deploymentWorkerResource.spec, manifest.spec)) {
|
||||
await this.#deploymentWorkerResource.patch(manifest);
|
||||
return {
|
||||
ready: false,
|
||||
syncing: true,
|
||||
reason: 'ManifestNeedsPatching',
|
||||
};
|
||||
}
|
||||
return {
|
||||
ready: true,
|
||||
};
|
||||
};
|
||||
|
||||
#reconcileServerDeployment = async (): Promise<SubresourceResult> => {
|
||||
const domainService = this.#domainResource.current;
|
||||
if (!domainService?.exists || !domainService.spec) {
|
||||
return {
|
||||
ready: false,
|
||||
failed: true,
|
||||
reason: 'MissingDomain',
|
||||
};
|
||||
}
|
||||
const databaseSecret = decodeSecret<z.infer<typeof postgresDatabaseSecretSchema>>(
|
||||
this.#databaseSecretResource.current?.data,
|
||||
);
|
||||
if (!databaseSecret) {
|
||||
return {
|
||||
ready: false,
|
||||
failed: true,
|
||||
reason: 'MissingDatabase',
|
||||
};
|
||||
}
|
||||
const secret = this.#secret.value;
|
||||
if (!this.#secret.isValid || !secret) {
|
||||
return {
|
||||
ready: false,
|
||||
syncing: true,
|
||||
reason: 'WaitingForSecret',
|
||||
};
|
||||
}
|
||||
|
||||
const redisSecret = decodeSecret(this.#redisSecretResource.current?.data);
|
||||
if (!redisSecret || !redisSecret.host) {
|
||||
return {
|
||||
ready: false,
|
||||
failed: true,
|
||||
reason: 'MissingRedisSecret',
|
||||
};
|
||||
}
|
||||
|
||||
const email = `admin@${domainService.spec.hostname}`;
|
||||
const manifest = createManifest({
|
||||
name: this.#serverName,
|
||||
namespace: this.namespace,
|
||||
secret: secret.secret,
|
||||
command: 'server',
|
||||
owner: this.ref,
|
||||
bootstrap: {
|
||||
email,
|
||||
token: secret.token,
|
||||
password: secret.password,
|
||||
},
|
||||
redis: {
|
||||
host: redisSecret.host,
|
||||
port: redisSecret.port ?? '6379',
|
||||
},
|
||||
posgtres: {
|
||||
host: databaseSecret.host,
|
||||
port: databaseSecret.port || '5432',
|
||||
name: databaseSecret.database,
|
||||
user: databaseSecret.user,
|
||||
password: databaseSecret.password,
|
||||
},
|
||||
});
|
||||
if (!isDeepSubset(this.#deploymentServerResource.spec, manifest.spec)) {
|
||||
await this.#deploymentServerResource.patch(manifest);
|
||||
return {
|
||||
ready: false,
|
||||
syncing: true,
|
||||
reason: 'ManifestNeedsPatching',
|
||||
};
|
||||
}
|
||||
return {
|
||||
ready: true,
|
||||
};
|
||||
};
|
||||
|
||||
#reconcileService = async (): Promise<SubresourceResult> => {
|
||||
const manifest = createServiceManifest({
|
||||
name: this.name,
|
||||
namespace: this.namespace,
|
||||
owner: this.ref,
|
||||
appName: this.#serverName,
|
||||
});
|
||||
|
||||
if (!isDeepSubset(manifest.spec, this.#service.manifest)) {
|
||||
await this.#service.patch(manifest);
|
||||
return {
|
||||
ready: false,
|
||||
syncing: true,
|
||||
reason: 'UpdatingManifest',
|
||||
};
|
||||
}
|
||||
return {
|
||||
ready: true,
|
||||
};
|
||||
};
|
||||
|
||||
#reconcileDomainService = async (): Promise<SubresourceResult> => {
|
||||
const manifest = createDomainService({
|
||||
name: this.name,
|
||||
namespace: this.namespace,
|
||||
owner: this.ref,
|
||||
domain: this.spec.domain,
|
||||
host: `${this.name}.${this.namespace}.svc.cluster.local`,
|
||||
subdomain: this.spec.subdomain,
|
||||
});
|
||||
if (!isDeepSubset(manifest.spec, this.#domainServiceResource.spec)) {
|
||||
await this.#domainServiceResource.patch(manifest);
|
||||
return {
|
||||
ready: false,
|
||||
syncing: true,
|
||||
reason: 'UpdatingManifest',
|
||||
};
|
||||
}
|
||||
return {
|
||||
ready: true,
|
||||
};
|
||||
};
|
||||
|
||||
public reconcile = async () => {
|
||||
if (!this.isValidSpec) {
|
||||
await this.conditions.set('Ready', {
|
||||
status: 'False',
|
||||
reason: 'Invalid spec',
|
||||
});
|
||||
}
|
||||
this.#updateResources();
|
||||
|
||||
await Promise.allSettled([
|
||||
this.reconcileSubresource('Worker', this.#reconcileWorkerDeployment),
|
||||
this.reconcileSubresource('Server', this.#reconcileServerDeployment),
|
||||
this.reconcileSubresource('Service', this.#reconcileService),
|
||||
this.reconcileSubresource('DomainService', this.#reconcileDomainService),
|
||||
]);
|
||||
|
||||
const workerReady = this.conditions.get('Worker')?.status === 'True';
|
||||
const serverReady = this.conditions.get('Server')?.status === 'True';
|
||||
const serviceReady = this.conditions.get('Service')?.status === 'True';
|
||||
const domainServiceReady = this.conditions.get('DomainService')?.status === 'True';
|
||||
|
||||
await this.conditions.set('Ready', {
|
||||
status: workerReady && serverReady && serviceReady && domainServiceReady ? 'True' : 'False',
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export { AuthentikServerResource };
|
||||
@@ -1,16 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const authentikServerSpecSchema = z.object({
|
||||
domain: z.string(),
|
||||
subdomain: z.string(),
|
||||
database: z.string(),
|
||||
redis: z.string(),
|
||||
});
|
||||
|
||||
const authentikServerSecretSchema = z.object({
|
||||
secret: z.string(),
|
||||
password: z.string(),
|
||||
token: z.string(),
|
||||
});
|
||||
|
||||
export { authentikServerSpecSchema, authentikServerSecretSchema };
|
||||
@@ -0,0 +1,23 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const authentikServerSpecSchema = z.object({
|
||||
redisServer: z.string(),
|
||||
postgresCluster: z.string(),
|
||||
environment: z.string(),
|
||||
subdomain: z.string(),
|
||||
});
|
||||
|
||||
const authentikServerInitSecretSchema = z.object({
|
||||
AUTHENTIK_BOOTSTRAP_TOKEN: z.string(),
|
||||
AUTHENTIK_BOOTSTRAP_PASSWORD: z.string(),
|
||||
AUTHENTIK_BOOTSTRAP_EMAIL: z.string(),
|
||||
AUTHENTIK_SECRET_KEY: z.string(),
|
||||
});
|
||||
|
||||
const authentikServerSecretSchema = z.object({
|
||||
url: z.string(),
|
||||
host: z.string(),
|
||||
token: z.string(),
|
||||
});
|
||||
|
||||
export { authentikServerSpecSchema, authentikServerInitSecretSchema, authentikServerSecretSchema };
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts';
|
||||
import { GROUP } from '../../utils/consts.ts';
|
||||
|
||||
import { AuthentikServerResource } from './authentik-server.resource.ts';
|
||||
import { authentikServerSpecSchema } from './authentik-server.scemas.ts';
|
||||
import { authentikServerSpecSchema } from './authentik-server.schemas.ts';
|
||||
import { AuthentikServerController } from './authentik-server.controller.ts';
|
||||
|
||||
const authentikServerDefinition = createCustomResourceDefinition({
|
||||
group: GROUP,
|
||||
@@ -13,7 +13,7 @@ const authentikServerDefinition = createCustomResourceDefinition({
|
||||
singular: 'authentikserver',
|
||||
},
|
||||
spec: authentikServerSpecSchema,
|
||||
create: (options) => new AuthentikServerResource(options),
|
||||
create: (options) => new AuthentikServerController(options),
|
||||
});
|
||||
|
||||
export { authentikServerDefinition };
|
||||
|
||||
@@ -1,25 +1,21 @@
|
||||
import { authentikServerDefinition } from './authentik-server/authentik-server.ts';
|
||||
import { authentikClientDefinition } from './authentik-client/authentik-client.ts';
|
||||
import { domainServiceDefinition } from './domain-service/domain-service.ts';
|
||||
import { domainDefinition } from './domain/domain.ts';
|
||||
import { postgresConnectionDefinition } from './postgres-connection/postgres-connection.ts';
|
||||
import { postgresDatabaseDefinition } from './postgres-database/postgres-database.ts';
|
||||
import { redisConnectionDefinition } from './redis-connection/redis-connection.ts';
|
||||
import { homelabDefinition } from './homelab/homelab.ts';
|
||||
import { authentikServerDefinition } from './authentik-server/authentik-server.ts';
|
||||
import { environmentDefinition } from './environment/environment.ts';
|
||||
import { generateSecretDefinition } from './generate-secret/generate-secret.ts';
|
||||
import { httpServiceDefinition } from './http-service/http-service.ts';
|
||||
import { postgresClusterDefinition } from './postgres-cluster/postgres-cluster.ts';
|
||||
import { postgresDatabaseDefinition } from './postgres-database/postgres-database.ts';
|
||||
import { redisServerDefinition } from './redis-server/redis-server.ts';
|
||||
|
||||
const customResources = [
|
||||
homelabDefinition,
|
||||
domainDefinition,
|
||||
domainServiceDefinition,
|
||||
postgresClusterDefinition,
|
||||
postgresConnectionDefinition,
|
||||
postgresDatabaseDefinition,
|
||||
redisServerDefinition,
|
||||
redisConnectionDefinition,
|
||||
authentikServerDefinition,
|
||||
authentikClientDefinition,
|
||||
generateSecretDefinition,
|
||||
environmentDefinition,
|
||||
postgresClusterDefinition,
|
||||
authentikServerDefinition,
|
||||
httpServiceDefinition,
|
||||
redisServerDefinition,
|
||||
];
|
||||
|
||||
export { customResources };
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import type { KubernetesObject } from '@kubernetes/client-node';
|
||||
|
||||
import type { K8SVirtualServiceV1 } from '../../__generated__/resources/K8SVirtualServiceV1.ts';
|
||||
import type { K8SDestinationRuleV1 } from '../../__generated__/resources/K8SDestinationRuleV1.ts';
|
||||
import { CONTROLLED_LABEL } from '../../utils/consts.ts';
|
||||
|
||||
type CreateVirtualServiceManifestOptions = {
|
||||
name: string;
|
||||
namespace: string;
|
||||
owner: ExpectedAny;
|
||||
host: string;
|
||||
gateway: string;
|
||||
destination: {
|
||||
host: string;
|
||||
port: {
|
||||
number?: number;
|
||||
name?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
const createVirtualServiceManifest = (
|
||||
options: CreateVirtualServiceManifestOptions,
|
||||
): KubernetesObject & K8SVirtualServiceV1 => ({
|
||||
apiVersion: 'networking.istio.io/v1',
|
||||
kind: 'VirtualService',
|
||||
metadata: {
|
||||
name: options.name,
|
||||
namespace: options.namespace,
|
||||
ownerReferences: [options.owner],
|
||||
labels: {
|
||||
...CONTROLLED_LABEL,
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
hosts: [options.host],
|
||||
gateways: [options.gateway],
|
||||
http: [
|
||||
{
|
||||
match: [
|
||||
{
|
||||
uri: {
|
||||
prefix: '/',
|
||||
},
|
||||
},
|
||||
],
|
||||
route: [
|
||||
{
|
||||
destination: {
|
||||
host: options.destination.host,
|
||||
port: options.destination.port,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
type CreateDestinationRuleManifestOptions = {
|
||||
name: string;
|
||||
namespace: string;
|
||||
host: string;
|
||||
};
|
||||
const createDestinationRuleManifest = (
|
||||
options: CreateDestinationRuleManifestOptions,
|
||||
): KubernetesObject & K8SDestinationRuleV1 => ({
|
||||
apiVersion: 'networking.istio.io/v1',
|
||||
kind: 'DestinationRule',
|
||||
metadata: {
|
||||
name: options.name,
|
||||
namespace: options.namespace,
|
||||
labels: {
|
||||
...CONTROLLED_LABEL,
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
host: options.host,
|
||||
trafficPolicy: {
|
||||
tls: {
|
||||
mode: 'DISABLE',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export { createVirtualServiceManifest, createDestinationRuleManifest };
|
||||
@@ -1,169 +0,0 @@
|
||||
import type { KubernetesObject } from '@kubernetes/client-node';
|
||||
import deepEqual from 'deep-equal';
|
||||
|
||||
import type { K8SVirtualServiceV1 } from '../../__generated__/resources/K8SVirtualServiceV1.ts';
|
||||
import {
|
||||
CustomResource,
|
||||
type CustomResourceObject,
|
||||
type CustomResourceOptions,
|
||||
type SubresourceResult,
|
||||
} from '../../services/custom-resources/custom-resources.custom-resource.ts';
|
||||
import { ResourceReference, ResourceService, type Resource } from '../../services/resources/resources.ts';
|
||||
import type { K8SDestinationRuleV1 } from '../../__generated__/resources/K8SDestinationRuleV1.ts';
|
||||
import type { domainSpecSchema } from '../domain/domain.schemas.ts';
|
||||
import { getWithNamespace } from '../../utils/naming.ts';
|
||||
import { GROUP } from '../../utils/consts.ts';
|
||||
|
||||
import type { domainServiceSpecSchema } from './domain-service.schemas.ts';
|
||||
import { createDestinationRuleManifest, createVirtualServiceManifest } from './domain-service.create-manifests.ts';
|
||||
|
||||
const VIRTUAL_SERVICE_CONDITION = 'VirtualService';
|
||||
const DESTINAION_RULE_CONDITION = 'DestinationRule';
|
||||
|
||||
class DomainServiceResource extends CustomResource<typeof domainServiceSpecSchema> {
|
||||
#virtualServiceResource: Resource<KubernetesObject & K8SVirtualServiceV1>;
|
||||
#virtualServiceCRDResource: Resource<KubernetesObject>;
|
||||
#destinationRuleResource: Resource<KubernetesObject & K8SDestinationRuleV1>;
|
||||
#destinationRuleCRDResource: Resource<KubernetesObject>;
|
||||
#domainResource: ResourceReference<CustomResourceObject<typeof domainSpecSchema>>;
|
||||
|
||||
constructor(options: CustomResourceOptions<typeof domainServiceSpecSchema>) {
|
||||
super(options);
|
||||
const resourceService = this.services.get(ResourceService);
|
||||
this.#virtualServiceResource = resourceService.get({
|
||||
apiVersion: 'networking.istio.io/v1',
|
||||
kind: 'VirtualService',
|
||||
name: this.name,
|
||||
namespace: this.namespace,
|
||||
});
|
||||
|
||||
this.#virtualServiceCRDResource = resourceService.get({
|
||||
apiVersion: 'apiextensions.k8s.io/v1',
|
||||
kind: 'CustomResourceDefinition',
|
||||
name: 'virtualservices.networking.istio.io',
|
||||
});
|
||||
|
||||
this.#destinationRuleResource = resourceService.get({
|
||||
apiVersion: 'networking.istio.io/v1',
|
||||
kind: 'DestinationRule',
|
||||
name: this.name,
|
||||
namespace: this.namespace,
|
||||
});
|
||||
|
||||
this.#destinationRuleCRDResource = resourceService.get({
|
||||
apiVersion: 'apiextensions.k8s.io/v1',
|
||||
kind: 'CustomResourceDefinition',
|
||||
name: 'destinationrules.networking.istio.io',
|
||||
});
|
||||
|
||||
const gatewayNames = getWithNamespace(this.spec.domain);
|
||||
this.#domainResource = new ResourceReference(
|
||||
resourceService.get({
|
||||
apiVersion: `${GROUP}/v1`,
|
||||
kind: 'Domain',
|
||||
name: gatewayNames.name,
|
||||
namespace: gatewayNames.namespace,
|
||||
}),
|
||||
);
|
||||
|
||||
this.#virtualServiceResource.on('changed', this.queueReconcile);
|
||||
this.#virtualServiceCRDResource.on('changed', this.queueReconcile);
|
||||
this.#destinationRuleResource.on('changed', this.queueReconcile);
|
||||
this.#destinationRuleCRDResource.on('changed', this.queueReconcile);
|
||||
this.#domainResource.on('changed', this.queueReconcile);
|
||||
}
|
||||
|
||||
#reconcileVirtualService = async (): Promise<SubresourceResult> => {
|
||||
if (!this.#virtualServiceCRDResource.exists) {
|
||||
return {
|
||||
ready: false,
|
||||
failed: true,
|
||||
reason: 'MissingCRD',
|
||||
};
|
||||
}
|
||||
const domain = this.#domainResource.current;
|
||||
if (!domain?.exists || !domain.spec) {
|
||||
return {
|
||||
ready: false,
|
||||
failed: true,
|
||||
reason: 'MissingDomain',
|
||||
};
|
||||
}
|
||||
const manifest = createVirtualServiceManifest({
|
||||
name: this.name,
|
||||
namespace: this.namespace,
|
||||
gateway: `${domain.namespace}/${domain.name}`,
|
||||
owner: this.ref,
|
||||
host: `${this.spec.subdomain}.${domain.spec.hostname}`,
|
||||
destination: this.spec.destination,
|
||||
});
|
||||
|
||||
if (!deepEqual(this.#virtualServiceResource.spec, manifest.spec)) {
|
||||
await this.#virtualServiceResource.patch(manifest);
|
||||
return {
|
||||
ready: false,
|
||||
syncing: true,
|
||||
reason: 'ManifestNeedsPatching',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ready: true,
|
||||
};
|
||||
};
|
||||
|
||||
#reconcileDestinationRule = async (): Promise<SubresourceResult> => {
|
||||
if (!this.#destinationRuleCRDResource.exists) {
|
||||
return {
|
||||
ready: false,
|
||||
failed: true,
|
||||
reason: 'MissingCRD',
|
||||
};
|
||||
}
|
||||
const manifest = createDestinationRuleManifest({
|
||||
name: this.name,
|
||||
namespace: this.namespace,
|
||||
host: this.spec.destination.host,
|
||||
});
|
||||
|
||||
if (!deepEqual(this.#destinationRuleResource.spec, manifest.spec)) {
|
||||
await this.#destinationRuleResource.patch(manifest);
|
||||
return {
|
||||
ready: false,
|
||||
syncing: true,
|
||||
reason: 'ManifestNeedsPatching',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ready: true,
|
||||
};
|
||||
};
|
||||
|
||||
public reconcile = async () => {
|
||||
if (!this.exists || this.metadata.deletionTimestamp) {
|
||||
return;
|
||||
}
|
||||
const resourceService = this.services.get(ResourceService);
|
||||
const gatewayNames = getWithNamespace(this.spec.domain, this.namespace);
|
||||
|
||||
this.#domainResource.current = resourceService.get({
|
||||
apiVersion: `${GROUP}/v1`,
|
||||
kind: 'Domain',
|
||||
name: gatewayNames.name,
|
||||
namespace: gatewayNames.namespace,
|
||||
});
|
||||
|
||||
await this.reconcileSubresource(VIRTUAL_SERVICE_CONDITION, this.#reconcileVirtualService);
|
||||
await this.reconcileSubresource(DESTINAION_RULE_CONDITION, this.#reconcileDestinationRule);
|
||||
|
||||
const virtualServiceReady = this.conditions.get(VIRTUAL_SERVICE_CONDITION)?.status === 'True';
|
||||
const destinationruleReady = this.conditions.get(DESTINAION_RULE_CONDITION)?.status === 'True';
|
||||
|
||||
await this.conditions.set('Ready', {
|
||||
status: virtualServiceReady && destinationruleReady ? 'True' : 'False',
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export { DomainServiceResource };
|
||||
@@ -1,15 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const domainServiceSpecSchema = z.object({
|
||||
domain: z.string(),
|
||||
subdomain: z.string(),
|
||||
destination: z.object({
|
||||
host: z.string(),
|
||||
port: z.object({
|
||||
number: z.number().optional(),
|
||||
name: z.string().optional(),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export { domainServiceSpecSchema };
|
||||
@@ -1,19 +0,0 @@
|
||||
import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts';
|
||||
import { GROUP } from '../../utils/consts.ts';
|
||||
|
||||
import { DomainServiceResource } from './domain-service.resource.ts';
|
||||
import { domainServiceSpecSchema } from './domain-service.schemas.ts';
|
||||
|
||||
const domainServiceDefinition = createCustomResourceDefinition({
|
||||
group: GROUP,
|
||||
kind: 'DomainService',
|
||||
version: 'v1',
|
||||
spec: domainServiceSpecSchema,
|
||||
names: {
|
||||
plural: 'domainservices',
|
||||
singular: 'domainservice',
|
||||
},
|
||||
create: (options) => new DomainServiceResource(options),
|
||||
});
|
||||
|
||||
export { domainServiceDefinition };
|
||||
@@ -1,73 +0,0 @@
|
||||
type CreateGatewayManifestOptions = {
|
||||
name: string;
|
||||
namespace: string;
|
||||
ref: ExpectedAny;
|
||||
gateway: string;
|
||||
domain: string;
|
||||
secretName: string;
|
||||
};
|
||||
const createGatewayManifest = (options: CreateGatewayManifestOptions) => ({
|
||||
apiVersion: 'networking.istio.io/v1alpha3',
|
||||
kind: 'Gateway',
|
||||
metadata: {
|
||||
name: options.name,
|
||||
namespace: options.namespace,
|
||||
ownerReferences: [options.ref],
|
||||
},
|
||||
spec: {
|
||||
selector: {
|
||||
istio: options.gateway,
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
port: {
|
||||
number: 80,
|
||||
name: 'http',
|
||||
protocol: 'HTTP',
|
||||
},
|
||||
hosts: [`*.${options.domain}`],
|
||||
tls: {
|
||||
httpsRedirect: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
port: {
|
||||
number: 443,
|
||||
name: 'https',
|
||||
protocol: 'HTTPS',
|
||||
},
|
||||
hosts: [`*.${options.domain}`],
|
||||
tls: {
|
||||
mode: 'SIMPLE' as const,
|
||||
credentialName: options.secretName,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
type CreateCertificateManifestOptions = {
|
||||
name: string;
|
||||
namespace: string;
|
||||
domain: string;
|
||||
secretName: string;
|
||||
issuer: string;
|
||||
};
|
||||
const createCertificateManifest = (options: CreateCertificateManifestOptions) => ({
|
||||
apiVersion: 'cert-manager.io/v1',
|
||||
kind: 'Certificate',
|
||||
metadata: {
|
||||
name: options.name,
|
||||
namespace: 'homelab', // TODO: use namespace of gateway controller
|
||||
},
|
||||
spec: {
|
||||
secretName: options.secretName,
|
||||
dnsNames: [`*.${options.domain}`],
|
||||
issuerRef: {
|
||||
name: options.issuer,
|
||||
kind: 'ClusterIssuer',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export { createGatewayManifest, createCertificateManifest };
|
||||
@@ -1,174 +0,0 @@
|
||||
import type { KubernetesObject } from '@kubernetes/client-node';
|
||||
import deepEqual from 'deep-equal';
|
||||
|
||||
import type { K8SGatewayV1 } from '../../__generated__/resources/K8SGatewayV1.ts';
|
||||
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 { K8SCertificateV1 } from '../../__generated__/resources/K8SCertificateV1.ts';
|
||||
import { IstioService } from '../../services/istio/istio.ts';
|
||||
|
||||
import type { domainSpecSchema } from './domain.schemas.ts';
|
||||
import { createCertificateManifest, createGatewayManifest } from './domain.create-manifests.ts';
|
||||
|
||||
class DomainResource extends CustomResource<typeof domainSpecSchema> {
|
||||
#gatewayCrdResource = new ResourceReference();
|
||||
#gatewayResource = new ResourceReference<KubernetesObject & K8SGatewayV1>();
|
||||
#certificateCrdResource = new ResourceReference();
|
||||
#certificateResource = new ResourceReference<KubernetesObject & K8SCertificateV1>();
|
||||
|
||||
constructor(options: CustomResourceOptions<typeof domainSpecSchema>) {
|
||||
super(options);
|
||||
const resourceService = this.services.get(ResourceService);
|
||||
const istioService = this.services.get(IstioService);
|
||||
|
||||
this.#gatewayCrdResource.current = resourceService.get({
|
||||
apiVersion: 'apiextensions.k8s.io/v1',
|
||||
kind: 'CustomResourceDefinition',
|
||||
name: 'gateways.networking.istio.io',
|
||||
});
|
||||
this.#gatewayResource.current = resourceService.get({
|
||||
apiVersion: 'networking.istio.io/v1',
|
||||
kind: 'Gateway',
|
||||
name: this.name,
|
||||
namespace: this.namespace,
|
||||
});
|
||||
|
||||
this.#certificateCrdResource.current = resourceService.get({
|
||||
apiVersion: 'apiextensions.k8s.io/v1',
|
||||
kind: 'CustomResourceDefinition',
|
||||
name: 'certificates.cert-manager.io',
|
||||
});
|
||||
|
||||
this.#certificateResource.current = resourceService.get({
|
||||
apiVersion: 'cert-manager.io/v1',
|
||||
kind: 'Certificate',
|
||||
name: `domain-${this.name}`,
|
||||
namespace: 'homelab',
|
||||
});
|
||||
|
||||
this.#gatewayResource.on('changed', this.queueReconcile);
|
||||
this.#certificateResource.on('changed', this.queueReconcile);
|
||||
this.#gatewayCrdResource.on('changed', this.queueReconcile);
|
||||
this.#certificateCrdResource.on('changed', this.queueReconcile);
|
||||
|
||||
istioService.gateway.on('changed', this.queueReconcile);
|
||||
}
|
||||
|
||||
get #certSecret() {
|
||||
return `cert-secret-${this.namespace}-${this.name}`;
|
||||
}
|
||||
|
||||
#reconcileGateway = async (): Promise<SubresourceResult> => {
|
||||
if (!this.#gatewayCrdResource.current?.exists) {
|
||||
return {
|
||||
ready: false,
|
||||
failed: true,
|
||||
reason: 'MissingCRD',
|
||||
message: 'Missing Gateway CRD',
|
||||
};
|
||||
}
|
||||
const istioService = this.services.get(IstioService);
|
||||
if (!istioService.gateway.current) {
|
||||
return {
|
||||
ready: false,
|
||||
failed: true,
|
||||
reason: 'MissingGatewayController',
|
||||
message: 'No istio gateway controller could be found',
|
||||
};
|
||||
}
|
||||
const manifest = createGatewayManifest({
|
||||
name: this.name,
|
||||
namespace: this.name,
|
||||
domain: this.spec.hostname,
|
||||
ref: this.ref,
|
||||
gateway: istioService.gateway.current.metadata?.labels?.istio || 'gateway-controller',
|
||||
secretName: this.#certSecret,
|
||||
});
|
||||
if (!deepEqual(this.#gatewayResource.current?.spec, manifest.spec)) {
|
||||
await this.#gatewayResource.current?.patch(manifest);
|
||||
return {
|
||||
ready: false,
|
||||
syncing: true,
|
||||
reason: 'ChangingGateway',
|
||||
message: 'Gateway need changes',
|
||||
};
|
||||
}
|
||||
return {
|
||||
ready: true,
|
||||
};
|
||||
};
|
||||
|
||||
#reconcileCertificate = async (): Promise<SubresourceResult> => {
|
||||
if (!this.#certificateCrdResource.current?.exists) {
|
||||
return {
|
||||
ready: false,
|
||||
syncing: false,
|
||||
failed: true,
|
||||
reason: 'MissingCRD',
|
||||
message: 'Missing Certificate CRD',
|
||||
};
|
||||
}
|
||||
const current = this.#certificateResource.current;
|
||||
if (!current || !current.namespace) {
|
||||
throw new Error('Missing certificate resource');
|
||||
}
|
||||
const istioService = this.services.get(IstioService);
|
||||
if (!istioService.gateway.current) {
|
||||
return {
|
||||
ready: false,
|
||||
syncing: false,
|
||||
failed: true,
|
||||
reason: 'MissingGatewayController',
|
||||
message: 'No istio gateway controller could be found',
|
||||
};
|
||||
}
|
||||
const manifest = createCertificateManifest({
|
||||
name: current.name,
|
||||
namespace: istioService.gateway.current.namespace || 'default',
|
||||
domain: this.spec.hostname,
|
||||
secretName: this.#certSecret,
|
||||
issuer: this.spec.issuer,
|
||||
});
|
||||
if (!this.#certificateResource.current?.exists) {
|
||||
await current.patch(manifest);
|
||||
return {
|
||||
ready: false,
|
||||
syncing: true,
|
||||
reason: 'Creating',
|
||||
message: 'Creating certificate resource',
|
||||
};
|
||||
}
|
||||
if (!deepEqual(current.spec, manifest.spec)) {
|
||||
await this.conditions.set('CertificateReady', {
|
||||
status: 'False',
|
||||
reason: 'Changing',
|
||||
message: 'Certificate need changes',
|
||||
});
|
||||
await current.patch(manifest);
|
||||
}
|
||||
return {
|
||||
ready: true,
|
||||
};
|
||||
};
|
||||
|
||||
public reconcile = async () => {
|
||||
if (!this.exists || this.metadata.deletionTimestamp) {
|
||||
return;
|
||||
}
|
||||
await this.reconcileSubresource('Gateway', this.#reconcileGateway);
|
||||
await this.reconcileSubresource('Certificate', this.#reconcileCertificate);
|
||||
|
||||
const gatewayReady = this.conditions.get('Gateway')?.status === 'True';
|
||||
const certificateReady = this.conditions.get('Certificate')?.status === 'True';
|
||||
|
||||
await this.conditions.set('Ready', {
|
||||
status: gatewayReady && certificateReady ? 'True' : 'False',
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export { DomainResource };
|
||||
@@ -1,8 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const domainSpecSchema = z.object({
|
||||
hostname: z.string(),
|
||||
issuer: z.string(),
|
||||
});
|
||||
|
||||
export { domainSpecSchema };
|
||||
@@ -1,19 +0,0 @@
|
||||
import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts';
|
||||
import { GROUP } from '../../utils/consts.ts';
|
||||
|
||||
import { DomainResource } from './domain.resource.ts';
|
||||
import { domainSpecSchema } from './domain.schemas.ts';
|
||||
|
||||
const domainDefinition = createCustomResourceDefinition({
|
||||
version: 'v1',
|
||||
kind: 'Domain',
|
||||
group: GROUP,
|
||||
names: {
|
||||
plural: 'domains',
|
||||
singular: 'domain',
|
||||
},
|
||||
spec: domainSpecSchema,
|
||||
create: (options) => new DomainResource(options),
|
||||
});
|
||||
|
||||
export { domainDefinition };
|
||||
224
src/custom-resouces/environment/environment.controller.ts
Normal file
224
src/custom-resouces/environment/environment.controller.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { CertificateInstance } from '../../instances/certificate.ts';
|
||||
import { CustomDefinitionInstance } from '../../instances/custom-resource-definition.ts';
|
||||
import { NamespaceInstance } from '../../instances/namespace.ts';
|
||||
import {
|
||||
CustomResource,
|
||||
type CustomResourceOptions,
|
||||
} from '../../services/custom-resources/custom-resources.custom-resource.ts';
|
||||
import { ResourceService } from '../../services/resources/resources.ts';
|
||||
import { GatewayInstance } from '../../instances/gateway.ts';
|
||||
import { PostgresClusterInstance } from '../../instances/postgres-cluster.ts';
|
||||
import { API_VERSION } from '../../utils/consts.ts';
|
||||
import { AuthentikServerInstance } from '../../instances/authentik-server.ts';
|
||||
import { StorageClassInstance } from '../../instances/storageclass.ts';
|
||||
import { PROVISIONER } from '../../storage-provider/storage-provider.ts';
|
||||
import { RedisServerInstance } from '../../instances/redis-server.ts';
|
||||
import { NamespaceService } from '../../bootstrap/namespaces/namespaces.ts';
|
||||
|
||||
import type { environmentSpecSchema } from './environment.schemas.ts';
|
||||
|
||||
class EnvironmentController extends CustomResource<typeof environmentSpecSchema> {
|
||||
#namespace: NamespaceInstance;
|
||||
#certificateCrd: CustomDefinitionInstance;
|
||||
#certificate: CertificateInstance;
|
||||
#gatewayCrd: CustomDefinitionInstance;
|
||||
#gateway: GatewayInstance;
|
||||
#storageClass: StorageClassInstance;
|
||||
#postgresCluster: PostgresClusterInstance;
|
||||
#authentikServer: AuthentikServerInstance;
|
||||
#redisServer: RedisServerInstance;
|
||||
|
||||
constructor(options: CustomResourceOptions<typeof environmentSpecSchema>) {
|
||||
super(options);
|
||||
const resourceService = this.services.get(ResourceService);
|
||||
const namespaceService = this.services.get(NamespaceService);
|
||||
this.#namespace = resourceService.getInstance(
|
||||
{
|
||||
apiVersion: 'v1',
|
||||
kind: 'Namespace',
|
||||
name: this.namespace,
|
||||
},
|
||||
NamespaceInstance,
|
||||
);
|
||||
this.#certificateCrd = resourceService.getInstance(
|
||||
{
|
||||
apiVersion: 'apiextensions.k8s.io/v1',
|
||||
kind: 'CustomResourceDefinition',
|
||||
name: 'certificates.cert-manager.io',
|
||||
},
|
||||
CustomDefinitionInstance,
|
||||
);
|
||||
this.#certificate = resourceService.getInstance(
|
||||
{
|
||||
apiVersion: 'cert-manager.io/v1',
|
||||
kind: 'Certificate',
|
||||
name: `${this.name}-tls`,
|
||||
namespace: namespaceService.homelab.name,
|
||||
},
|
||||
CertificateInstance,
|
||||
);
|
||||
this.#gatewayCrd = resourceService.getInstance(
|
||||
{
|
||||
apiVersion: 'apiextensions.k8s.io/v1',
|
||||
kind: 'CustomResourceDefinition',
|
||||
name: 'gateways.networking.istio.io',
|
||||
},
|
||||
CustomDefinitionInstance,
|
||||
);
|
||||
this.#gateway = resourceService.getInstance(
|
||||
{
|
||||
apiVersion: 'networking.istio.io/v1',
|
||||
kind: 'Gateway',
|
||||
name: this.name,
|
||||
namespace: this.namespace,
|
||||
},
|
||||
GatewayInstance,
|
||||
);
|
||||
this.#storageClass = resourceService.getInstance(
|
||||
{
|
||||
apiVersion: 'storage.k8s.io/v1',
|
||||
kind: 'StorageClass',
|
||||
name: `${this.name}-retain`,
|
||||
},
|
||||
StorageClassInstance,
|
||||
);
|
||||
this.#postgresCluster = resourceService.getInstance(
|
||||
{
|
||||
apiVersion: API_VERSION,
|
||||
kind: 'PostgresCluster',
|
||||
name: `${this.name}-postgres-cluster`,
|
||||
namespace: this.namespace,
|
||||
},
|
||||
PostgresClusterInstance,
|
||||
);
|
||||
this.#authentikServer = resourceService.getInstance(
|
||||
{
|
||||
apiVersion: API_VERSION,
|
||||
kind: 'AuthentikServer',
|
||||
name: `${this.name}-authentik-server`,
|
||||
namespace: this.namespace,
|
||||
},
|
||||
AuthentikServerInstance,
|
||||
);
|
||||
this.#redisServer = resourceService.getInstance(
|
||||
{
|
||||
apiVersion: API_VERSION,
|
||||
kind: 'RedisServer',
|
||||
name: `${this.name}-redis-server`,
|
||||
namespace: this.namespace,
|
||||
},
|
||||
RedisServerInstance,
|
||||
);
|
||||
this.#gatewayCrd.on('changed', this.queueReconcile);
|
||||
this.#gateway.on('changed', this.queueReconcile);
|
||||
this.#certificateCrd.on('changed', this.queueReconcile);
|
||||
this.#namespace.on('changed', this.queueReconcile);
|
||||
this.#certificate.on('changed', this.queueReconcile);
|
||||
this.#postgresCluster.on('changed', this.queueReconcile);
|
||||
this.#authentikServer.on('changed', this.queueReconcile);
|
||||
this.#storageClass.on('changed', this.queueReconcile);
|
||||
this.#redisServer.on('changed', this.queueReconcile);
|
||||
}
|
||||
|
||||
public reconcile = async () => {
|
||||
if (!this.exists || this.metadata?.deletionTimestamp) {
|
||||
return;
|
||||
}
|
||||
await this.#namespace.ensure({
|
||||
metadata: {
|
||||
ownerReferences: [this.ref],
|
||||
labels: {
|
||||
'istio-injection': 'enabled',
|
||||
},
|
||||
},
|
||||
});
|
||||
if (this.#certificateCrd.ready) {
|
||||
await this.#certificate.ensure({
|
||||
spec: {
|
||||
secretName: `${this.name}-tls`,
|
||||
issuerRef: {
|
||||
name: this.spec.tls.issuer,
|
||||
kind: 'ClusterIssuer',
|
||||
},
|
||||
dnsNames: [`*.${this.spec.domain}`],
|
||||
privateKey: {
|
||||
rotationPolicy: 'Always',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
if (this.#gatewayCrd.ready) {
|
||||
await this.#gateway.ensure({
|
||||
metadata: {
|
||||
ownerReferences: [this.ref],
|
||||
},
|
||||
spec: {
|
||||
selector: {
|
||||
istio: 'homelab-istio-gateway',
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
hosts: [`*.${this.spec.domain}`],
|
||||
port: {
|
||||
name: 'http',
|
||||
number: 80,
|
||||
protocol: 'HTTP',
|
||||
},
|
||||
tls: {
|
||||
httpsRedirect: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
hosts: [`*.${this.spec.domain}`],
|
||||
port: {
|
||||
name: 'https',
|
||||
number: 443,
|
||||
protocol: 'HTTPS',
|
||||
},
|
||||
tls: {
|
||||
mode: 'SIMPLE',
|
||||
credentialName: `${this.name}-tls`,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
await this.#storageClass.ensure({
|
||||
provisioner: PROVISIONER,
|
||||
parameters: {
|
||||
storageLocation: this.spec.storage?.location || `/data/volumes/${this.name}`,
|
||||
reclaimPolicy: 'Retain',
|
||||
allowVolumeExpansion: 'true',
|
||||
volumeBindingMode: 'Immediate',
|
||||
},
|
||||
});
|
||||
await this.#postgresCluster.ensure({
|
||||
metadata: {
|
||||
ownerReferences: [this.ref],
|
||||
},
|
||||
spec: {
|
||||
environment: this.name,
|
||||
},
|
||||
});
|
||||
await this.#authentikServer.ensure({
|
||||
metadata: {
|
||||
ownerReferences: [this.ref],
|
||||
},
|
||||
spec: {
|
||||
environment: `${this.namespace}/${this.name}`,
|
||||
subdomain: 'authentik',
|
||||
postgresCluster: `${this.name}-postgres-cluster`,
|
||||
redisServer: `${this.name}-redis-server`,
|
||||
},
|
||||
});
|
||||
await this.#redisServer.ensure({
|
||||
metadata: {
|
||||
ownerReferences: [this.ref],
|
||||
},
|
||||
spec: {},
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export { EnvironmentController };
|
||||
17
src/custom-resouces/environment/environment.schemas.ts
Normal file
17
src/custom-resouces/environment/environment.schemas.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const environmentSpecSchema = z.object({
|
||||
domain: z.string(),
|
||||
tls: z.object({
|
||||
issuer: z.string(),
|
||||
}),
|
||||
storage: z
|
||||
.object({
|
||||
location: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
type EnvironmentSpec = z.infer<typeof environmentSpecSchema>;
|
||||
|
||||
export { environmentSpecSchema, type EnvironmentSpec };
|
||||
19
src/custom-resouces/environment/environment.ts
Normal file
19
src/custom-resouces/environment/environment.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts';
|
||||
import { GROUP } from '../../utils/consts.ts';
|
||||
|
||||
import { EnvironmentController } from './environment.controller.ts';
|
||||
import { environmentSpecSchema } from './environment.schemas.ts';
|
||||
|
||||
const environmentDefinition = createCustomResourceDefinition({
|
||||
group: GROUP,
|
||||
version: 'v1',
|
||||
kind: 'Environment',
|
||||
names: {
|
||||
plural: 'environments',
|
||||
singular: 'environment',
|
||||
},
|
||||
spec: environmentSpecSchema,
|
||||
create: (options) => new EnvironmentController(options),
|
||||
});
|
||||
|
||||
export { environmentDefinition };
|
||||
@@ -0,0 +1,61 @@
|
||||
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 };
|
||||
@@ -0,0 +1,17 @@
|
||||
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 };
|
||||
19
src/custom-resouces/generate-secret/generate-secret.ts
Normal file
19
src/custom-resouces/generate-secret/generate-secret.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
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 };
|
||||
69
src/custom-resouces/generate-secret/generate-secret.utils.ts
Normal file
69
src/custom-resouces/generate-secret/generate-secret.utils.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
import type { GenerateSecretField, GenerateSecretSpec } from './generate-secret.schemas.ts';
|
||||
|
||||
const generateRandomString = (length: number, encoding: GenerateSecretField['encoding']): string => {
|
||||
let byteLength = 0;
|
||||
switch (encoding) {
|
||||
case 'base64':
|
||||
case 'base64url':
|
||||
// Base64 uses 4 characters for every 3 bytes, so we'll generate slightly more bytes
|
||||
// than the final length to ensure we can get a string of at least the required length.
|
||||
byteLength = Math.ceil((length * 3) / 4);
|
||||
break;
|
||||
case 'hex':
|
||||
byteLength = Math.ceil(length / 2);
|
||||
break;
|
||||
case 'numeric':
|
||||
case 'utf8':
|
||||
byteLength = length;
|
||||
break;
|
||||
}
|
||||
|
||||
const randomBytes = crypto.randomBytes(byteLength);
|
||||
|
||||
let resultString = '';
|
||||
|
||||
switch (encoding) {
|
||||
case 'base64':
|
||||
resultString = randomBytes.toString('base64');
|
||||
break;
|
||||
case 'base64url':
|
||||
resultString = randomBytes.toString('base64url');
|
||||
break;
|
||||
case 'hex':
|
||||
resultString = randomBytes.toString('hex');
|
||||
break;
|
||||
case 'numeric':
|
||||
resultString = Array.from(randomBytes)
|
||||
.map((b) => (b % 10).toString()) // Get a single digit from each byte
|
||||
.join('');
|
||||
break;
|
||||
case 'utf8':
|
||||
resultString = randomBytes.toString('utf8');
|
||||
break;
|
||||
}
|
||||
|
||||
return resultString.slice(0, length);
|
||||
};
|
||||
|
||||
const generateSecrets = (spec: GenerateSecretSpec): Record<string, string> => {
|
||||
const secrets: Record<string, string> = {};
|
||||
|
||||
for (const field of spec.fields) {
|
||||
if (field.value !== undefined) {
|
||||
// If a value is provided, use it directly.
|
||||
secrets[field.name] = field.value;
|
||||
} else {
|
||||
// Generate a new secret based on the specification.
|
||||
// Use default values if encoding or length are not provided.
|
||||
const encoding = field.encoding || 'base64url';
|
||||
const length = field.length || 32;
|
||||
secrets[field.name] = generateRandomString(length, encoding);
|
||||
}
|
||||
}
|
||||
|
||||
return secrets;
|
||||
};
|
||||
|
||||
export { generateRandomString, generateSecrets };
|
||||
@@ -1,297 +0,0 @@
|
||||
import type { KubernetesObject } from '@kubernetes/client-node';
|
||||
|
||||
import type { K8SHelmRepositoryV1 } from '../../__generated__/resources/K8SHelmRepositoryV1.ts';
|
||||
import type { K8SHelmReleaseV2 } from '../../__generated__/resources/K8SHelmReleaseV2.ts';
|
||||
|
||||
type IstioRepoManifestOptions = {
|
||||
owner: ExpectedAny;
|
||||
};
|
||||
const istioRepoManifest = (options: IstioRepoManifestOptions): KubernetesObject & K8SHelmRepositoryV1 => {
|
||||
return {
|
||||
apiVersion: 'source.toolkit.fluxcd.io/v1beta1',
|
||||
kind: 'HelmRepository',
|
||||
metadata: {
|
||||
ownerReferences: [options.owner],
|
||||
},
|
||||
spec: {
|
||||
interval: '1h',
|
||||
url: 'https://istio-release.storage.googleapis.com/charts',
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
type CertManagerRepoManifestOptions = {
|
||||
owner: ExpectedAny;
|
||||
};
|
||||
const certManagerRepoManifest = (options: CertManagerRepoManifestOptions): KubernetesObject & K8SHelmRepositoryV1 => {
|
||||
return {
|
||||
apiVersion: 'source.toolkit.fluxcd.io/v1',
|
||||
kind: 'HelmRepository',
|
||||
metadata: {
|
||||
ownerReferences: [options.owner],
|
||||
},
|
||||
spec: {
|
||||
interval: '1h',
|
||||
url: 'https://charts.jetstack.io',
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
type RanchRepoManifestOptions = {
|
||||
owner: ExpectedAny;
|
||||
};
|
||||
const ranchRepoManifest = (options: RanchRepoManifestOptions): KubernetesObject & K8SHelmRepositoryV1 => {
|
||||
return {
|
||||
apiVersion: 'source.toolkit.fluxcd.io/v1',
|
||||
kind: 'HelmRepository',
|
||||
metadata: {
|
||||
ownerReferences: [options.owner],
|
||||
},
|
||||
spec: {
|
||||
interval: '1h',
|
||||
url: 'https://charts.containeroo.ch',
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
type IstioBaseManifestOptions = {
|
||||
owner: ExpectedAny;
|
||||
};
|
||||
const istioBaseManifest = (options: IstioBaseManifestOptions): KubernetesObject & K8SHelmReleaseV2 => {
|
||||
return {
|
||||
apiVersion: 'helm.toolkit.fluxcd.io/v2',
|
||||
kind: 'HelmRelease',
|
||||
metadata: {
|
||||
ownerReferences: [options.owner],
|
||||
},
|
||||
spec: {
|
||||
interval: '1h',
|
||||
targetNamespace: 'istio-system',
|
||||
install: {
|
||||
createNamespace: true,
|
||||
},
|
||||
values: {
|
||||
defaultRevision: 'default',
|
||||
},
|
||||
chart: {
|
||||
spec: {
|
||||
chart: 'base',
|
||||
sourceRef: {
|
||||
apiVersion: 'source.toolkit.fluxcd.io/v1',
|
||||
kind: 'HelmRepository',
|
||||
name: 'homelab-istio',
|
||||
},
|
||||
reconcileStrategy: 'ChartVersion',
|
||||
version: '1.24.3',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
type IstiodManifestOptions = {
|
||||
owner: ExpectedAny;
|
||||
namespace: string;
|
||||
};
|
||||
const istiodManifest = (options: IstiodManifestOptions): KubernetesObject & K8SHelmReleaseV2 => {
|
||||
return {
|
||||
apiVersion: 'helm.toolkit.fluxcd.io/v2',
|
||||
kind: 'HelmRelease',
|
||||
metadata: {
|
||||
ownerReferences: [options.owner],
|
||||
},
|
||||
spec: {
|
||||
targetNamespace: 'istio-system',
|
||||
interval: '1h',
|
||||
install: {
|
||||
createNamespace: true,
|
||||
},
|
||||
dependsOn: [
|
||||
{
|
||||
name: 'istio',
|
||||
namespace: options.namespace,
|
||||
},
|
||||
],
|
||||
chart: {
|
||||
spec: {
|
||||
chart: 'istiod',
|
||||
sourceRef: {
|
||||
apiVersion: 'source.toolkit.fluxcd.io/v1',
|
||||
kind: 'HelmRepository',
|
||||
name: 'homelab-istio',
|
||||
},
|
||||
reconcileStrategy: 'ChartVersion',
|
||||
version: '1.24.3',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
type IstioGatewayControllerManifestOptions = {
|
||||
owner: ExpectedAny;
|
||||
namespace: string;
|
||||
};
|
||||
const istioGatewayControllerManifest = (
|
||||
options: IstioGatewayControllerManifestOptions,
|
||||
): KubernetesObject & K8SHelmReleaseV2 => {
|
||||
return {
|
||||
apiVersion: 'helm.toolkit.fluxcd.io/v2',
|
||||
kind: 'HelmRelease',
|
||||
metadata: {
|
||||
ownerReferences: [options.owner],
|
||||
},
|
||||
spec: {
|
||||
interval: '1h',
|
||||
install: {
|
||||
createNamespace: true,
|
||||
},
|
||||
dependsOn: [
|
||||
{
|
||||
name: 'istio',
|
||||
namespace: options.namespace,
|
||||
},
|
||||
{
|
||||
name: 'istiod',
|
||||
namespace: options.namespace,
|
||||
},
|
||||
],
|
||||
values: {
|
||||
service: {
|
||||
ports: [
|
||||
{
|
||||
name: 'status-port',
|
||||
port: 15021,
|
||||
},
|
||||
{
|
||||
name: 'tls-istiod',
|
||||
port: 15012,
|
||||
},
|
||||
{
|
||||
name: 'tls',
|
||||
port: 15443,
|
||||
nodePort: 31371,
|
||||
},
|
||||
{
|
||||
name: 'http2',
|
||||
port: 80,
|
||||
nodePort: 31381,
|
||||
targetPort: 8280,
|
||||
},
|
||||
{
|
||||
name: 'https',
|
||||
port: 443,
|
||||
nodePort: 31391,
|
||||
targetPort: 8243,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
chart: {
|
||||
spec: {
|
||||
chart: 'gateway',
|
||||
sourceRef: {
|
||||
apiVersion: 'source.toolkit.fluxcd.io/v1',
|
||||
kind: 'HelmRepository',
|
||||
name: 'homelab-istio',
|
||||
},
|
||||
reconcileStrategy: 'ChartVersion',
|
||||
version: '1.24.3',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
type CertManagerManifestOptions = {
|
||||
owner: ExpectedAny;
|
||||
};
|
||||
const certManagerManifest = (options: CertManagerManifestOptions): KubernetesObject & K8SHelmReleaseV2 => {
|
||||
return {
|
||||
apiVersion: 'helm.toolkit.fluxcd.io/v2',
|
||||
kind: 'HelmRelease',
|
||||
metadata: {
|
||||
ownerReferences: [options.owner],
|
||||
},
|
||||
spec: {
|
||||
targetNamespace: 'cert-manager',
|
||||
interval: '1h',
|
||||
install: {
|
||||
createNamespace: true,
|
||||
},
|
||||
values: {
|
||||
installCRDs: true,
|
||||
},
|
||||
chart: {
|
||||
spec: {
|
||||
chart: 'cert-manager',
|
||||
sourceRef: {
|
||||
apiVersion: 'source.toolkit.fluxcd.io/v1',
|
||||
kind: 'HelmRepository',
|
||||
name: 'cert-manager',
|
||||
},
|
||||
version: 'v1.18.2',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
type LocalStorageManifestOptions = {
|
||||
owner: ExpectedAny;
|
||||
storagePath: string;
|
||||
};
|
||||
const localStorageManifest = (options: LocalStorageManifestOptions): KubernetesObject & K8SHelmReleaseV2 => {
|
||||
return {
|
||||
apiVersion: 'helm.toolkit.fluxcd.io/v2',
|
||||
kind: 'HelmRelease',
|
||||
metadata: {
|
||||
ownerReferences: [options.owner],
|
||||
},
|
||||
spec: {
|
||||
targetNamespace: 'local-path-storage',
|
||||
interval: '1h',
|
||||
install: {
|
||||
createNamespace: true,
|
||||
},
|
||||
values: {
|
||||
storageClass: {
|
||||
name: 'local-path',
|
||||
provisionerName: 'rancher.io/local-path',
|
||||
defaultClass: true,
|
||||
},
|
||||
nodePathMap: [
|
||||
{
|
||||
node: 'DEFAULT_PATH_FOR_NON_LISTED_NODES',
|
||||
paths: [options.storagePath],
|
||||
},
|
||||
],
|
||||
helper: {
|
||||
reclaimPolicy: 'Retain',
|
||||
},
|
||||
},
|
||||
chart: {
|
||||
spec: {
|
||||
chart: 'local-path-provisioner',
|
||||
sourceRef: {
|
||||
apiVersion: 'source.toolkit.fluxcd.io/v1',
|
||||
kind: 'HelmRepository',
|
||||
name: 'rancher',
|
||||
},
|
||||
version: '0.0.32',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export {
|
||||
istioRepoManifest,
|
||||
istioBaseManifest,
|
||||
istiodManifest,
|
||||
istioGatewayControllerManifest,
|
||||
certManagerRepoManifest,
|
||||
certManagerManifest,
|
||||
ranchRepoManifest,
|
||||
localStorageManifest,
|
||||
};
|
||||
@@ -1,263 +0,0 @@
|
||||
import { type KubernetesObject } from '@kubernetes/client-node';
|
||||
|
||||
import {
|
||||
CustomResource,
|
||||
type CustomResourceOptions,
|
||||
type SubresourceResult,
|
||||
} from '../../services/custom-resources/custom-resources.custom-resource.ts';
|
||||
import { Resource, ResourceService } from '../../services/resources/resources.ts';
|
||||
import type { K8SHelmRepositoryV1 } from '../../__generated__/resources/K8SHelmRepositoryV1.ts';
|
||||
import type { K8SHelmReleaseV2 } from '../../__generated__/resources/K8SHelmReleaseV2.ts';
|
||||
import { isDeepSubset } from '../../utils/objects.ts';
|
||||
|
||||
import type { homelabSpecSchema } from './homelab.schemas.ts';
|
||||
import {
|
||||
certManagerRepoManifest,
|
||||
istioBaseManifest,
|
||||
istiodManifest,
|
||||
istioGatewayControllerManifest,
|
||||
istioRepoManifest,
|
||||
certManagerManifest,
|
||||
ranchRepoManifest,
|
||||
localStorageManifest,
|
||||
} from './homelab.manifests.ts';
|
||||
|
||||
class HomelabResource extends CustomResource<typeof homelabSpecSchema> {
|
||||
#resources: {
|
||||
istioRepo: Resource<KubernetesObject & K8SHelmRepositoryV1>;
|
||||
istioBase: Resource<KubernetesObject & K8SHelmReleaseV2>;
|
||||
istiod: Resource<KubernetesObject & K8SHelmReleaseV2>;
|
||||
istioGatewayController: Resource<KubernetesObject & K8SHelmReleaseV2>;
|
||||
certManagerRepo: Resource<KubernetesObject & K8SHelmRepositoryV1>;
|
||||
certManager: Resource<KubernetesObject & K8SHelmReleaseV2>;
|
||||
ranchRepo: Resource<KubernetesObject & K8SHelmRepositoryV1>;
|
||||
localStorage: Resource<KubernetesObject & K8SHelmReleaseV2>;
|
||||
};
|
||||
|
||||
constructor(options: CustomResourceOptions<typeof homelabSpecSchema>) {
|
||||
super(options);
|
||||
const resourceService = this.services.get(ResourceService);
|
||||
this.#resources = {
|
||||
istioRepo: resourceService.get({
|
||||
apiVersion: 'source.toolkit.fluxcd.io/v1',
|
||||
kind: 'HelmRepository',
|
||||
name: 'homelab-istio',
|
||||
namespace: this.namespace,
|
||||
}),
|
||||
istioBase: resourceService.get({
|
||||
apiVersion: 'helm.toolkit.fluxcd.io/v2',
|
||||
kind: 'HelmRelease',
|
||||
name: 'istio',
|
||||
namespace: this.namespace,
|
||||
}),
|
||||
istiod: resourceService.get({
|
||||
apiVersion: 'helm.toolkit.fluxcd.io/v2',
|
||||
kind: 'HelmRelease',
|
||||
name: 'istiod',
|
||||
namespace: this.namespace,
|
||||
}),
|
||||
istioGatewayController: resourceService.get({
|
||||
apiVersion: 'helm.toolkit.fluxcd.io/v2',
|
||||
kind: 'HelmRelease',
|
||||
name: 'istio-gateway-controller',
|
||||
namespace: this.namespace,
|
||||
}),
|
||||
certManagerRepo: resourceService.get({
|
||||
apiVersion: 'source.toolkit.fluxcd.io/v1',
|
||||
kind: 'HelmRepository',
|
||||
name: 'cert-manager',
|
||||
namespace: this.namespace,
|
||||
}),
|
||||
certManager: resourceService.get({
|
||||
apiVersion: 'helm.toolkit.fluxcd.io/v2',
|
||||
kind: 'HelmRelease',
|
||||
name: 'cert-manager',
|
||||
namespace: this.namespace,
|
||||
}),
|
||||
ranchRepo: resourceService.get({
|
||||
apiVersion: 'source.toolkit.fluxcd.io/v1',
|
||||
kind: 'HelmRepository',
|
||||
name: 'rancher',
|
||||
namespace: this.namespace,
|
||||
}),
|
||||
localStorage: resourceService.get({
|
||||
apiVersion: 'helm.toolkit.fluxcd.io/v2',
|
||||
kind: 'HelmRelease',
|
||||
name: 'local-storage',
|
||||
namespace: this.namespace,
|
||||
}),
|
||||
};
|
||||
|
||||
for (const resource of Object.values(this.#resources)) {
|
||||
resource.on('changed', this.queueReconcile);
|
||||
}
|
||||
}
|
||||
|
||||
#reconcileIstioRepo = async (): Promise<SubresourceResult> => {
|
||||
const istioRepo = this.#resources.istioRepo;
|
||||
const manifest = istioRepoManifest({
|
||||
owner: this.ref,
|
||||
});
|
||||
if (!isDeepSubset(istioRepo.spec, manifest.spec)) {
|
||||
await istioRepo.patch(manifest);
|
||||
return {
|
||||
ready: false,
|
||||
syncing: true,
|
||||
reason: 'UpdatingManifest',
|
||||
};
|
||||
}
|
||||
return {
|
||||
ready: true,
|
||||
};
|
||||
};
|
||||
|
||||
#reconcileCertManagerRepo = async (): Promise<SubresourceResult> => {
|
||||
const certManagerRepo = this.#resources.certManagerRepo;
|
||||
const manifest = certManagerRepoManifest({
|
||||
owner: this.ref,
|
||||
});
|
||||
if (!isDeepSubset(certManagerRepo.spec, manifest.spec)) {
|
||||
await certManagerRepo.patch(manifest);
|
||||
return {
|
||||
ready: false,
|
||||
syncing: true,
|
||||
reason: 'UpdatingManifest',
|
||||
};
|
||||
}
|
||||
return {
|
||||
ready: true,
|
||||
};
|
||||
};
|
||||
|
||||
#reconcileRanchRepo = async (): Promise<SubresourceResult> => {
|
||||
const ranchRepo = this.#resources.ranchRepo;
|
||||
const manifest = ranchRepoManifest({
|
||||
owner: this.ref,
|
||||
});
|
||||
if (!isDeepSubset(ranchRepo.spec, manifest.spec)) {
|
||||
await ranchRepo.patch(manifest);
|
||||
return {
|
||||
ready: false,
|
||||
syncing: true,
|
||||
reason: 'UpdatingManifest',
|
||||
};
|
||||
}
|
||||
return {
|
||||
ready: true,
|
||||
};
|
||||
};
|
||||
|
||||
#reconcileIstioBase = async (): Promise<SubresourceResult> => {
|
||||
const istioBase = this.#resources.istioBase;
|
||||
const manifest = istioBaseManifest({
|
||||
owner: this.ref,
|
||||
});
|
||||
if (!isDeepSubset(istioBase.spec, manifest.spec)) {
|
||||
await istioBase.patch(manifest);
|
||||
return {
|
||||
ready: false,
|
||||
syncing: true,
|
||||
reason: 'UpdatingManifest',
|
||||
};
|
||||
}
|
||||
return {
|
||||
ready: true,
|
||||
};
|
||||
};
|
||||
|
||||
#reconcileIstiod = async (): Promise<SubresourceResult> => {
|
||||
const istiod = this.#resources.istiod;
|
||||
const manifest = istiodManifest({
|
||||
owner: this.ref,
|
||||
namespace: this.namespace,
|
||||
});
|
||||
if (!isDeepSubset(istiod.spec, manifest.spec)) {
|
||||
await istiod.patch(manifest);
|
||||
return {
|
||||
ready: false,
|
||||
syncing: true,
|
||||
reason: 'UpdatingManifest',
|
||||
};
|
||||
}
|
||||
return {
|
||||
ready: true,
|
||||
};
|
||||
};
|
||||
|
||||
#reconcileIstioGatewayController = async (): Promise<SubresourceResult> => {
|
||||
const istioGatewayController = this.#resources.istioGatewayController;
|
||||
const manifest = istioGatewayControllerManifest({
|
||||
owner: this.ref,
|
||||
namespace: this.namespace,
|
||||
});
|
||||
if (!isDeepSubset(istioGatewayController.spec, manifest.spec)) {
|
||||
await istioGatewayController.patch(manifest);
|
||||
return {
|
||||
ready: false,
|
||||
syncing: true,
|
||||
reason: 'UpdatingManifest',
|
||||
};
|
||||
}
|
||||
return {
|
||||
ready: true,
|
||||
};
|
||||
};
|
||||
|
||||
#reconcileCertManager = async (): Promise<SubresourceResult> => {
|
||||
const certManager = this.#resources.certManager;
|
||||
const manifest = certManagerManifest({
|
||||
owner: this.ref,
|
||||
});
|
||||
if (!isDeepSubset(certManager.spec, manifest.spec)) {
|
||||
await certManager.patch(manifest);
|
||||
return {
|
||||
ready: false,
|
||||
syncing: true,
|
||||
reason: 'UpdatingManifest',
|
||||
};
|
||||
}
|
||||
return {
|
||||
ready: true,
|
||||
};
|
||||
};
|
||||
|
||||
#reconcileLocalStorage = async (): Promise<SubresourceResult> => {
|
||||
const storage = this.spec.storage;
|
||||
if (!storage || !storage.enabled) {
|
||||
return {
|
||||
ready: true,
|
||||
};
|
||||
}
|
||||
const localStorage = this.#resources.localStorage;
|
||||
const manifest = localStorageManifest({
|
||||
owner: this.ref,
|
||||
storagePath: storage.path,
|
||||
});
|
||||
if (!isDeepSubset(localStorage.spec, manifest.spec)) {
|
||||
await localStorage.patch(manifest);
|
||||
return {
|
||||
ready: false,
|
||||
syncing: true,
|
||||
reason: 'UpdatingManifest',
|
||||
};
|
||||
}
|
||||
return {
|
||||
ready: true,
|
||||
};
|
||||
};
|
||||
|
||||
public reconcile = async () => {
|
||||
await Promise.allSettled([
|
||||
this.reconcileSubresource('IstioRepo', this.#reconcileIstioRepo),
|
||||
this.reconcileSubresource('CertManagerRepo', this.#reconcileCertManagerRepo),
|
||||
this.reconcileSubresource('IstioBase', this.#reconcileIstioBase),
|
||||
this.reconcileSubresource('Istiod', this.#reconcileIstiod),
|
||||
this.reconcileSubresource('IstioGatewayController', this.#reconcileIstioGatewayController),
|
||||
this.reconcileSubresource('CertManager', this.#reconcileCertManager),
|
||||
this.reconcileSubresource('RanchRepo', this.#reconcileRanchRepo),
|
||||
this.reconcileSubresource('LocalStorage', this.#reconcileLocalStorage),
|
||||
]);
|
||||
};
|
||||
}
|
||||
|
||||
export { HomelabResource };
|
||||
@@ -1,17 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const homelabSpecSchema = z.object({
|
||||
storage: z
|
||||
.object({
|
||||
enabled: z.boolean(),
|
||||
path: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const homelabSecretSchema = z.object({
|
||||
postgresPassword: z.string(),
|
||||
redisPassword: z.string(),
|
||||
});
|
||||
|
||||
export { homelabSpecSchema, homelabSecretSchema };
|
||||
@@ -1,19 +0,0 @@
|
||||
import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts';
|
||||
import { GROUP } from '../../utils/consts.ts';
|
||||
|
||||
import { HomelabResource } from './homelab.resource.ts';
|
||||
import { homelabSpecSchema } from './homelab.schemas.ts';
|
||||
|
||||
const homelabDefinition = createCustomResourceDefinition({
|
||||
group: GROUP,
|
||||
version: 'v1',
|
||||
kind: 'Homelab',
|
||||
names: {
|
||||
plural: 'homelabs',
|
||||
singular: 'homelab',
|
||||
},
|
||||
spec: homelabSpecSchema,
|
||||
create: (options) => new HomelabResource(options),
|
||||
});
|
||||
|
||||
export { homelabDefinition };
|
||||
100
src/custom-resouces/http-service/http-service.controller.ts
Normal file
100
src/custom-resouces/http-service/http-service.controller.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { DestinationRuleInstance } from '../../instances/destination-rule.ts';
|
||||
import { VirtualServiceInstance } from '../../instances/virtual-service.ts';
|
||||
import {
|
||||
CustomResource,
|
||||
type CustomResourceObject,
|
||||
type CustomResourceOptions,
|
||||
} from '../../services/custom-resources/custom-resources.custom-resource.ts';
|
||||
import { ResourceReference, ResourceService } from '../../services/resources/resources.ts';
|
||||
import { API_VERSION } from '../../utils/consts.ts';
|
||||
import { getWithNamespace } from '../../utils/naming.ts';
|
||||
import { environmentSpecSchema } from '../environment/environment.schemas.ts';
|
||||
|
||||
import { httpServiceSpecSchema } from './http-service.schemas.ts';
|
||||
|
||||
class HttpServiceController extends CustomResource<typeof httpServiceSpecSchema> {
|
||||
#environment: ResourceReference<CustomResourceObject<typeof environmentSpecSchema>>;
|
||||
#virtualService: VirtualServiceInstance;
|
||||
#destinationRule: DestinationRuleInstance;
|
||||
|
||||
constructor(options: CustomResourceOptions<typeof httpServiceSpecSchema>) {
|
||||
super(options);
|
||||
const resourceService = this.services.get(ResourceService);
|
||||
this.#environment = new ResourceReference();
|
||||
this.#virtualService = resourceService.getInstance(
|
||||
{
|
||||
apiVersion: 'networking.istio.io/v1beta1',
|
||||
kind: 'VirtualService',
|
||||
name: `${this.name}-virtual-service`,
|
||||
namespace: this.namespace,
|
||||
},
|
||||
VirtualServiceInstance,
|
||||
);
|
||||
this.#destinationRule = resourceService.getInstance(
|
||||
{
|
||||
apiVersion: 'networking.istio.io/v1beta1',
|
||||
kind: 'DestinationRule',
|
||||
name: `${this.name}-destination-rule`,
|
||||
namespace: this.namespace,
|
||||
},
|
||||
DestinationRuleInstance,
|
||||
);
|
||||
this.#destinationRule.on('changed', this.queueReconcile);
|
||||
this.#virtualService.on('changed', this.queueReconcile);
|
||||
this.#environment.on('changed', this.queueReconcile);
|
||||
}
|
||||
|
||||
public reconcile = async () => {
|
||||
if (!this.exists || this.metadata?.deletionTimestamp) {
|
||||
return;
|
||||
}
|
||||
const resourceService = this.services.get(ResourceService);
|
||||
const environmentNames = getWithNamespace(this.spec.environment, this.namespace);
|
||||
this.#environment.current = resourceService.get({
|
||||
apiVersion: API_VERSION,
|
||||
kind: 'Environment',
|
||||
name: environmentNames.name,
|
||||
namespace: environmentNames.namespace,
|
||||
});
|
||||
const environment = this.#environment.current;
|
||||
if (!environment?.exists) {
|
||||
return;
|
||||
}
|
||||
await this.#virtualService.ensure({
|
||||
metadata: {
|
||||
ownerReferences: [this.ref],
|
||||
},
|
||||
spec: {
|
||||
hosts: [`${this.spec.subdomain}.${environment.spec?.domain}`],
|
||||
gateways: [`${this.#environment.current.namespace}/${this.#environment.current.name}`],
|
||||
http: [
|
||||
{
|
||||
route: [
|
||||
{
|
||||
destination: {
|
||||
host: this.spec.destination.host,
|
||||
port: this.spec.destination.port,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
await this.#destinationRule.ensure({
|
||||
metadata: {
|
||||
ownerReferences: [this.ref],
|
||||
},
|
||||
spec: {
|
||||
host: this.spec.destination.host,
|
||||
trafficPolicy: {
|
||||
tls: {
|
||||
mode: 'DISABLE',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export { HttpServiceController };
|
||||
18
src/custom-resouces/http-service/http-service.schemas.ts
Normal file
18
src/custom-resouces/http-service/http-service.schemas.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const httpServiceSpecSchema = z.object({
|
||||
environment: z.string(),
|
||||
subdomain: z.string(),
|
||||
destination: z.object({
|
||||
host: z.string(),
|
||||
port: z
|
||||
.object({
|
||||
number: z.number().optional(),
|
||||
protocol: z.enum(['http', 'https']).optional(),
|
||||
name: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export { httpServiceSpecSchema };
|
||||
19
src/custom-resouces/http-service/http-service.ts
Normal file
19
src/custom-resouces/http-service/http-service.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts';
|
||||
import { GROUP } from '../../utils/consts.ts';
|
||||
|
||||
import { HttpServiceController } from './http-service.controller.ts';
|
||||
import { httpServiceSpecSchema } from './http-service.schemas.ts';
|
||||
|
||||
const httpServiceDefinition = createCustomResourceDefinition({
|
||||
group: GROUP,
|
||||
version: 'v1',
|
||||
kind: 'HttpService',
|
||||
names: {
|
||||
plural: 'httpservices',
|
||||
singular: 'httpservice',
|
||||
},
|
||||
spec: httpServiceSpecSchema,
|
||||
create: (options) => new HttpServiceController(options),
|
||||
});
|
||||
|
||||
export { httpServiceDefinition };
|
||||
@@ -0,0 +1,155 @@
|
||||
import { ServiceInstance } from '../../instances/service.ts';
|
||||
import { StatefulSetInstance } from '../../instances/stateful-set.ts';
|
||||
import { ResourceService } from '../../services/resources/resources.ts';
|
||||
import {
|
||||
CustomResource,
|
||||
type CustomResourceOptions,
|
||||
} from '../../services/custom-resources/custom-resources.custom-resource.ts';
|
||||
import type { EnsuredSecret } from '../../services/secrets/secrets.secret.ts';
|
||||
import { SecretService } from '../../services/secrets/secrets.ts';
|
||||
|
||||
import { postgresClusterSecretSchema, type postgresClusterSpecSchema } from './postgres-cluster.schemas.ts';
|
||||
|
||||
class PostgresClusterController extends CustomResource<typeof postgresClusterSpecSchema> {
|
||||
#statefulSet: StatefulSetInstance;
|
||||
#headlessService: ServiceInstance;
|
||||
#service: ServiceInstance;
|
||||
#secret: EnsuredSecret<typeof postgresClusterSecretSchema>;
|
||||
|
||||
constructor(options: CustomResourceOptions<typeof postgresClusterSpecSchema>) {
|
||||
super(options);
|
||||
const resourceService = this.services.get(ResourceService);
|
||||
const secretService = this.services.get(SecretService);
|
||||
this.#statefulSet = resourceService.getInstance(
|
||||
{
|
||||
apiVersion: 'apps/v1',
|
||||
kind: 'StatefulSet',
|
||||
name: this.name,
|
||||
namespace: this.namespace,
|
||||
},
|
||||
StatefulSetInstance,
|
||||
);
|
||||
this.#headlessService = resourceService.getInstance(
|
||||
{
|
||||
apiVersion: 'v1',
|
||||
kind: 'Service',
|
||||
name: `${this.name}-headless`,
|
||||
namespace: this.namespace,
|
||||
},
|
||||
ServiceInstance,
|
||||
);
|
||||
this.#service = resourceService.getInstance(
|
||||
{
|
||||
apiVersion: 'v1',
|
||||
kind: 'Service',
|
||||
name: this.name,
|
||||
namespace: this.namespace,
|
||||
},
|
||||
ServiceInstance,
|
||||
);
|
||||
this.#secret = secretService.ensure({
|
||||
name: this.name,
|
||||
namespace: this.namespace,
|
||||
schema: postgresClusterSecretSchema,
|
||||
generator: () => {
|
||||
return {
|
||||
database: 'postgres',
|
||||
host: `${this.name}.${this.namespace}.svc.cluster.local`,
|
||||
port: '5432',
|
||||
username: 'postgres',
|
||||
password: crypto.randomUUID(),
|
||||
};
|
||||
},
|
||||
});
|
||||
this.#statefulSet.on('changed', this.queueReconcile);
|
||||
this.#service.on('changed', this.queueReconcile);
|
||||
this.#headlessService.on('changed', this.queueReconcile);
|
||||
this.#secret.resource.on('changed', this.queueReconcile);
|
||||
}
|
||||
|
||||
public reconcile = async () => {
|
||||
if (!this.exists || this.metadata?.deletionTimestamp || !this.#secret.isValid) {
|
||||
return;
|
||||
}
|
||||
await this.#headlessService.ensure({
|
||||
metadata: {
|
||||
ownerReferences: [this.ref],
|
||||
},
|
||||
spec: {
|
||||
clusterIP: 'None',
|
||||
selector: {
|
||||
app: this.name,
|
||||
},
|
||||
ports: [{ name: 'postgres', port: 5432, targetPort: 5432 }],
|
||||
},
|
||||
});
|
||||
await this.#statefulSet.ensure({
|
||||
metadata: {
|
||||
ownerReferences: [this.ref],
|
||||
},
|
||||
spec: {
|
||||
replicas: 1,
|
||||
serviceName: this.name,
|
||||
selector: {
|
||||
matchLabels: {
|
||||
app: this.name,
|
||||
},
|
||||
},
|
||||
template: {
|
||||
metadata: {
|
||||
labels: {
|
||||
app: this.name,
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
containers: [
|
||||
{
|
||||
name: this.name,
|
||||
image: 'postgres:17',
|
||||
ports: [{ containerPort: 5432, name: 'postgres' }],
|
||||
env: [
|
||||
{ name: 'POSTGRES_PASSWORD', valueFrom: { secretKeyRef: { name: this.name, key: 'password' } } },
|
||||
{ name: 'POSTGRES_USER', valueFrom: { secretKeyRef: { name: this.name, key: 'username' } } },
|
||||
{ name: 'POSTGRES_DB', value: this.name },
|
||||
{ name: 'PGDATA', value: '/var/lib/postgresql/data/pgdata' },
|
||||
],
|
||||
volumeMounts: [{ name: this.name, mountPath: '/var/lib/postgresql/data' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
volumeClaimTemplates: [
|
||||
{
|
||||
metadata: {
|
||||
name: this.name,
|
||||
},
|
||||
spec: {
|
||||
accessModes: ['ReadWriteOnce'],
|
||||
storageClassName: `${this.spec.environment}-retain`,
|
||||
resources: {
|
||||
requests: {
|
||||
storage: this.spec.storage?.size || '1Gi',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await this.#service.ensure({
|
||||
metadata: {
|
||||
ownerReferences: [this.ref],
|
||||
},
|
||||
spec: {
|
||||
type: 'ClusterIP',
|
||||
selector: {
|
||||
app: this.name,
|
||||
},
|
||||
ports: [{ name: 'postgres', port: 5432, targetPort: 5432 }],
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export { PostgresClusterController };
|
||||
@@ -1,124 +0,0 @@
|
||||
import type { V1Deployment, V1PersistentVolumeClaim, V1Service } from '@kubernetes/client-node';
|
||||
|
||||
import type { CustomResourceObject } from '../../services/custom-resources/custom-resources.custom-resource.ts';
|
||||
import type { postgresConnectionSpecSchema } from '../postgres-connection/posgtres-connection.schemas.ts';
|
||||
import { API_VERSION } from '../../utils/consts.ts';
|
||||
|
||||
type PvcOptions = {
|
||||
name: string;
|
||||
owner: ExpectedAny;
|
||||
};
|
||||
const pvcManifest = (options: PvcOptions): V1PersistentVolumeClaim => {
|
||||
return {
|
||||
apiVersion: 'v1',
|
||||
kind: 'PersistentVolumeClaim',
|
||||
metadata: {
|
||||
ownerReferences: [options.owner],
|
||||
name: options.name,
|
||||
labels: {
|
||||
app: options.name,
|
||||
},
|
||||
annotations: {
|
||||
'volume.kubernetes.io/storage-class': 'local-path',
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
accessModes: ['ReadWriteOnce'],
|
||||
resources: {
|
||||
requests: {
|
||||
storage: '10Gi',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
type DeploymentManifetOptions = {
|
||||
name: string;
|
||||
owner: ExpectedAny;
|
||||
user: string;
|
||||
password: string;
|
||||
};
|
||||
const deploymentManifest = (options: DeploymentManifetOptions): V1Deployment => {
|
||||
return {
|
||||
apiVersion: 'apps/v1',
|
||||
kind: 'Deployment',
|
||||
metadata: {
|
||||
ownerReferences: [options.owner],
|
||||
},
|
||||
spec: {
|
||||
replicas: 1,
|
||||
selector: {
|
||||
matchLabels: {
|
||||
app: options.name,
|
||||
},
|
||||
},
|
||||
template: {
|
||||
metadata: {
|
||||
labels: {
|
||||
app: options.name,
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
volumes: [{ name: options.name, persistentVolumeClaim: { claimName: options.name } }],
|
||||
containers: [
|
||||
{
|
||||
name: options.name,
|
||||
image: 'postgres:17',
|
||||
ports: [{ containerPort: 5432 }],
|
||||
volumeMounts: [{ mountPath: '/var/lib/postgresql/data', name: options.name }],
|
||||
env: [
|
||||
{ name: 'POSTGRES_USER', value: options.user },
|
||||
{ name: 'POSTGRES_PASSWORD', value: options.password },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
type ServiceManifestOptions = {
|
||||
name: string;
|
||||
owner: ExpectedAny;
|
||||
};
|
||||
const serviceManifest = (options: ServiceManifestOptions): V1Service => {
|
||||
return {
|
||||
apiVersion: 'v1',
|
||||
kind: 'Service',
|
||||
metadata: {
|
||||
ownerReferences: [options.owner],
|
||||
name: options.name,
|
||||
labels: {
|
||||
app: options.name,
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
type: 'ClusterIP',
|
||||
ports: [{ port: 5432, targetPort: 5432 }],
|
||||
selector: {
|
||||
app: options.name,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
type ConnectionManifestOptions = {
|
||||
name: string;
|
||||
owner: ExpectedAny;
|
||||
};
|
||||
const connectionManifest = (
|
||||
options: ConnectionManifestOptions,
|
||||
): CustomResourceObject<typeof postgresConnectionSpecSchema> => ({
|
||||
apiVersion: API_VERSION,
|
||||
kind: 'PostgresConnection',
|
||||
metadata: {
|
||||
ownerReferences: [options.owner],
|
||||
},
|
||||
spec: {
|
||||
secret: `${options.name}-secret`,
|
||||
},
|
||||
});
|
||||
|
||||
export { pvcManifest, deploymentManifest, serviceManifest, connectionManifest };
|
||||
@@ -1,170 +0,0 @@
|
||||
import type { V1Deployment, V1PersistentVolumeClaim, V1Service } from '@kubernetes/client-node';
|
||||
|
||||
import {
|
||||
CustomResource,
|
||||
type CustomResourceObject,
|
||||
type CustomResourceOptions,
|
||||
type SubresourceResult,
|
||||
} from '../../services/custom-resources/custom-resources.custom-resource.ts';
|
||||
import { ResourceService, type Resource } from '../../services/resources/resources.ts';
|
||||
import {
|
||||
postgresConnectionSecretDataSchema,
|
||||
type postgresConnectionSpecSchema,
|
||||
} from '../postgres-connection/posgtres-connection.schemas.ts';
|
||||
import { API_VERSION } from '../../utils/consts.ts';
|
||||
import { isDeepSubset } from '../../utils/objects.ts';
|
||||
import type { EnsuredSecret } from '../../services/secrets/secrets.secret.ts';
|
||||
import { SecretService } from '../../services/secrets/secrets.ts';
|
||||
|
||||
import type { postgresClusterSpecSchema } from './postgres-cluster.schemas.ts';
|
||||
import { connectionManifest, deploymentManifest, pvcManifest, serviceManifest } from './postgres-cluster.manifests.ts';
|
||||
|
||||
class PostgresClusterResource extends CustomResource<typeof postgresClusterSpecSchema> {
|
||||
#resources: {
|
||||
pvc: Resource<V1PersistentVolumeClaim>;
|
||||
deployment: Resource<V1Deployment>;
|
||||
service: Resource<V1Service>;
|
||||
connection: Resource<CustomResourceObject<typeof postgresConnectionSpecSchema>>;
|
||||
secret: EnsuredSecret<typeof postgresConnectionSecretDataSchema>;
|
||||
};
|
||||
|
||||
constructor(options: CustomResourceOptions<typeof postgresClusterSpecSchema>) {
|
||||
super(options);
|
||||
const resourceService = this.services.get(ResourceService);
|
||||
const secretService = this.services.get(SecretService);
|
||||
this.#resources = {
|
||||
pvc: resourceService.get({
|
||||
apiVersion: 'v1',
|
||||
kind: 'PersistentVolumeClaim',
|
||||
name: this.name,
|
||||
namespace: this.namespace,
|
||||
}),
|
||||
deployment: resourceService.get({
|
||||
apiVersion: 'apps/v1',
|
||||
kind: 'Deployment',
|
||||
name: this.name,
|
||||
namespace: this.namespace,
|
||||
}),
|
||||
service: resourceService.get({
|
||||
apiVersion: 'v1',
|
||||
kind: 'Service',
|
||||
name: this.name,
|
||||
namespace: this.namespace,
|
||||
}),
|
||||
connection: resourceService.get({
|
||||
apiVersion: API_VERSION,
|
||||
kind: 'PostgresConnection',
|
||||
name: this.name,
|
||||
namespace: this.namespace,
|
||||
}),
|
||||
secret: secretService.ensure({
|
||||
name: `${this.name}-secret`,
|
||||
namespace: this.namespace,
|
||||
schema: postgresConnectionSecretDataSchema,
|
||||
generator: () => ({
|
||||
host: `${this.name}.${this.namespace}.svc.cluster.local`,
|
||||
port: '5432',
|
||||
user: 'postgres',
|
||||
password: Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString('hex'),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
#reconcilePvc = async (): Promise<SubresourceResult> => {
|
||||
const pvc = this.#resources.pvc;
|
||||
const manifest = pvcManifest({
|
||||
name: this.name,
|
||||
owner: this.ref,
|
||||
});
|
||||
if (!isDeepSubset(pvc.spec, manifest.spec)) {
|
||||
await pvc.patch(manifest);
|
||||
return {
|
||||
ready: false,
|
||||
syncing: true,
|
||||
reason: 'UpdatingManifest',
|
||||
};
|
||||
}
|
||||
return {
|
||||
ready: true,
|
||||
};
|
||||
};
|
||||
|
||||
#reconcileDeployment = async (): Promise<SubresourceResult> => {
|
||||
const secret = this.#resources.secret;
|
||||
if (!secret.isValid || !secret.value) {
|
||||
return {
|
||||
ready: false,
|
||||
syncing: true,
|
||||
reason: 'SecretNotReady',
|
||||
};
|
||||
}
|
||||
const deployment = this.#resources.deployment;
|
||||
const manifest = deploymentManifest({
|
||||
name: this.name,
|
||||
owner: this.ref,
|
||||
user: secret.value.user,
|
||||
password: secret.value.password,
|
||||
});
|
||||
if (!isDeepSubset(deployment.spec, manifest.spec)) {
|
||||
await deployment.patch(manifest);
|
||||
return {
|
||||
ready: false,
|
||||
syncing: true,
|
||||
reason: 'UpdatingManifest',
|
||||
};
|
||||
}
|
||||
return {
|
||||
ready: true,
|
||||
};
|
||||
};
|
||||
|
||||
#reconcileService = async (): Promise<SubresourceResult> => {
|
||||
const service = this.#resources.service;
|
||||
const manifest = serviceManifest({
|
||||
name: this.name,
|
||||
owner: this.ref,
|
||||
});
|
||||
if (!isDeepSubset(service.spec, manifest.spec)) {
|
||||
await service.patch(manifest);
|
||||
return {
|
||||
ready: false,
|
||||
syncing: true,
|
||||
reason: 'UpdatingManifest',
|
||||
};
|
||||
}
|
||||
return {
|
||||
ready: true,
|
||||
};
|
||||
};
|
||||
|
||||
#reconcileConnection = async (): Promise<SubresourceResult> => {
|
||||
const connection = this.#resources.connection;
|
||||
const manifest = connectionManifest({
|
||||
name: this.name,
|
||||
owner: this.ref,
|
||||
});
|
||||
if (!isDeepSubset(connection.spec, manifest.spec)) {
|
||||
await connection.patch(manifest);
|
||||
return {
|
||||
ready: false,
|
||||
syncing: true,
|
||||
reason: 'UpdatingManifest',
|
||||
};
|
||||
}
|
||||
return {
|
||||
ready: true,
|
||||
};
|
||||
};
|
||||
|
||||
public reconcile = async () => {
|
||||
await Promise.allSettled([
|
||||
this.reconcileSubresource('PVC', this.#reconcilePvc),
|
||||
this.reconcileSubresource('Deployment', this.#reconcileDeployment),
|
||||
this.reconcileSubresource('Service', this.#reconcileService),
|
||||
this.reconcileSubresource('Connection', this.#reconcileConnection),
|
||||
]);
|
||||
};
|
||||
}
|
||||
|
||||
export { PostgresClusterResource };
|
||||
@@ -1,5 +1,20 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const postgresClusterSpecSchema = z.object({});
|
||||
const postgresClusterSpecSchema = z.object({
|
||||
environment: z.string(),
|
||||
storage: z
|
||||
.object({
|
||||
size: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export { postgresClusterSpecSchema };
|
||||
const postgresClusterSecretSchema = z.object({
|
||||
database: z.string(),
|
||||
host: z.string(),
|
||||
port: z.string(),
|
||||
username: z.string(),
|
||||
password: z.string(),
|
||||
});
|
||||
|
||||
export { postgresClusterSpecSchema, postgresClusterSecretSchema };
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts';
|
||||
import { GROUP } from '../../utils/consts.ts';
|
||||
|
||||
import { PostgresClusterController } from './postgres-cluster.controller.ts';
|
||||
import { postgresClusterSpecSchema } from './postgres-cluster.schemas.ts';
|
||||
import { PostgresClusterResource } from './postgres-cluster.resource.ts';
|
||||
|
||||
const postgresClusterDefinition = createCustomResourceDefinition({
|
||||
group: GROUP,
|
||||
version: 'v1',
|
||||
kind: 'PostgresCluster',
|
||||
names: {
|
||||
plural: 'postgresclusters',
|
||||
singular: 'postgrescluster',
|
||||
plural: 'postgres-clusters',
|
||||
singular: 'postgres-cluster',
|
||||
},
|
||||
spec: postgresClusterSpecSchema,
|
||||
create: (options) => new PostgresClusterResource(options),
|
||||
create: (options) => new PostgresClusterController(options),
|
||||
});
|
||||
|
||||
export { postgresClusterDefinition };
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const postgresConnectionSpecSchema = z.object({
|
||||
secret: z.string(),
|
||||
});
|
||||
|
||||
const postgresConnectionSecretDataSchema = z.object({
|
||||
host: z.string(),
|
||||
port: z.string().optional(),
|
||||
user: z.string(),
|
||||
password: z.string(),
|
||||
});
|
||||
|
||||
export { postgresConnectionSpecSchema, postgresConnectionSecretDataSchema };
|
||||
@@ -1,94 +0,0 @@
|
||||
import type { V1Secret } from '@kubernetes/client-node';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import {
|
||||
CustomResource,
|
||||
type CustomResourceOptions,
|
||||
} from '../../services/custom-resources/custom-resources.custom-resource.ts';
|
||||
import { ResourceReference } from '../../services/resources/resources.ref.ts';
|
||||
import { ResourceService } from '../../services/resources/resources.ts';
|
||||
import { getWithNamespace } from '../../utils/naming.ts';
|
||||
import { PostgresService } from '../../services/postgres/postgres.service.ts';
|
||||
import { decodeSecret } from '../../utils/secrets.ts';
|
||||
|
||||
import type {
|
||||
postgresConnectionSecretDataSchema,
|
||||
postgresConnectionSpecSchema,
|
||||
} from './posgtres-connection.schemas.ts';
|
||||
|
||||
class PostgresConnectionResource extends CustomResource<typeof postgresConnectionSpecSchema> {
|
||||
#secret: ResourceReference<V1Secret>;
|
||||
|
||||
constructor(options: CustomResourceOptions<typeof postgresConnectionSpecSchema>) {
|
||||
super(options);
|
||||
const resourceService = this.services.get(ResourceService);
|
||||
const secretNames = getWithNamespace(this.spec.secret, this.namespace);
|
||||
this.#secret = new ResourceReference<V1Secret>(
|
||||
resourceService.get({
|
||||
apiVersion: 'v1',
|
||||
kind: 'Secret',
|
||||
name: secretNames.name,
|
||||
namespace: secretNames.namespace,
|
||||
}),
|
||||
);
|
||||
this.#secret.on('changed', this.queueReconcile);
|
||||
}
|
||||
|
||||
public reconcile = async () => {
|
||||
const resourceService = this.services.get(ResourceService);
|
||||
const secretNames = getWithNamespace(this.spec.secret, this.namespace);
|
||||
this.#secret.current = resourceService.get({
|
||||
apiVersion: 'v1',
|
||||
kind: 'Secret',
|
||||
name: secretNames.name,
|
||||
namespace: secretNames.namespace,
|
||||
});
|
||||
|
||||
const current = this.#secret.current;
|
||||
if (!current?.exists || !current.data) {
|
||||
return this.conditions.set('Ready', {
|
||||
status: 'False',
|
||||
reason: 'MissingSecret',
|
||||
});
|
||||
}
|
||||
const { host, user, password, port } = decodeSecret<z.infer<typeof postgresConnectionSecretDataSchema>>(
|
||||
current.data,
|
||||
)!;
|
||||
if (!host) {
|
||||
return this.conditions.set('Ready', {
|
||||
status: 'False',
|
||||
reason: 'MissingHost',
|
||||
});
|
||||
}
|
||||
if (!user) {
|
||||
return this.conditions.set('Ready', {
|
||||
status: 'False',
|
||||
reason: 'MissingUser',
|
||||
});
|
||||
}
|
||||
if (!password) {
|
||||
return this.conditions.set('Ready', {
|
||||
status: 'False',
|
||||
reason: 'MissingPassword',
|
||||
});
|
||||
}
|
||||
const postgresService = this.services.get(PostgresService);
|
||||
const database = postgresService.get({
|
||||
host,
|
||||
user,
|
||||
port: port ? Number(port) : 5432,
|
||||
password,
|
||||
});
|
||||
if (!(await database.ping())) {
|
||||
return this.conditions.set('Ready', {
|
||||
status: 'False',
|
||||
reason: 'CanNotConnectToDatabase',
|
||||
});
|
||||
}
|
||||
await this.conditions.set('Ready', {
|
||||
status: 'True',
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export { PostgresConnectionResource };
|
||||
@@ -1,19 +0,0 @@
|
||||
import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts';
|
||||
import { GROUP } from '../../utils/consts.ts';
|
||||
|
||||
import { postgresConnectionSpecSchema } from './posgtres-connection.schemas.ts';
|
||||
import { PostgresConnectionResource } from './postgres-connection.resource.ts';
|
||||
|
||||
const postgresConnectionDefinition = createCustomResourceDefinition({
|
||||
group: GROUP,
|
||||
version: 'v1',
|
||||
kind: 'PostgresConnection',
|
||||
names: {
|
||||
plural: 'postgresconnections',
|
||||
singular: 'postgresconnection',
|
||||
},
|
||||
spec: postgresConnectionSpecSchema,
|
||||
create: (options) => new PostgresConnectionResource(options),
|
||||
});
|
||||
|
||||
export { postgresConnectionDefinition };
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const postgresDatabaseSpecSchema = z.object({
|
||||
connection: z.string(),
|
||||
cluster: z.string(),
|
||||
});
|
||||
|
||||
export { postgresDatabaseSpecSchema };
|
||||
|
||||
@@ -1,62 +1,46 @@
|
||||
import { z } from 'zod';
|
||||
import type { V1Secret } from '@kubernetes/client-node';
|
||||
|
||||
import {
|
||||
CustomResource,
|
||||
type CustomResourceObject,
|
||||
type CustomResourceOptions,
|
||||
type SubresourceResult,
|
||||
} from '../../services/custom-resources/custom-resources.custom-resource.ts';
|
||||
import { PostgresService } from '../../services/postgres/postgres.service.ts';
|
||||
import {
|
||||
postgresConnectionSecretDataSchema,
|
||||
type postgresConnectionSpecSchema,
|
||||
} from '../postgres-connection/posgtres-connection.schemas.ts';
|
||||
import { ResourceReference } from '../../services/resources/resources.ref.ts';
|
||||
import { Resource, ResourceService } from '../../services/resources/resources.ts';
|
||||
import { ResourceService } from '../../services/resources/resources.ts';
|
||||
import { getWithNamespace } from '../../utils/naming.ts';
|
||||
import { API_VERSION } from '../../utils/consts.ts';
|
||||
import { decodeSecret } from '../../utils/secrets.ts';
|
||||
import { postgresClusterSecretSchema } from '../postgres-cluster/postgres-cluster.schemas.ts';
|
||||
import { SecretInstance } from '../../instances/secret.ts';
|
||||
|
||||
import type { postgresDatabaseSpecSchema } from './portgres-database.schemas.ts';
|
||||
import { 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> {
|
||||
#secret: Resource<V1Secret>;
|
||||
#secretName: string;
|
||||
#connection: ResourceReference<CustomResourceObject<typeof postgresConnectionSpecSchema>>;
|
||||
#connectionSecret: ResourceReference<V1Secret>;
|
||||
#clusterSecret: ResourceReference<V1Secret>;
|
||||
#databaseSecret: SecretInstance<typeof postgresClusterSecretSchema>;
|
||||
|
||||
constructor(options: CustomResourceOptions<typeof postgresDatabaseSpecSchema>) {
|
||||
super(options);
|
||||
const resouceService = this.services.get(ResourceService);
|
||||
const resourceService = this.services.get(ResourceService);
|
||||
|
||||
this.#secretName = `postgres-database-${this.name}`;
|
||||
this.#secret = resouceService.get({
|
||||
apiVersion: 'v1',
|
||||
kind: 'Secret',
|
||||
name: this.#secretName,
|
||||
namespace: this.namespace,
|
||||
});
|
||||
this.#clusterSecret = new ResourceReference();
|
||||
|
||||
this.#connection = new ResourceReference();
|
||||
this.#connectionSecret = new ResourceReference();
|
||||
this.#databaseSecret = resourceService.getInstance(
|
||||
{
|
||||
apiVersion: 'v1',
|
||||
kind: 'Secret',
|
||||
name: `${this.name}-postgres-database`,
|
||||
namespace: this.namespace,
|
||||
},
|
||||
SecretInstance<typeof postgresClusterSecretSchema>,
|
||||
);
|
||||
|
||||
this.#updateSecret();
|
||||
|
||||
this.#secret.on('changed', this.queueReconcile);
|
||||
this.#connection.on('changed', this.queueReconcile);
|
||||
this.#connectionSecret.on('changed', this.queueReconcile);
|
||||
this.#clusterSecret.on('changed', this.queueReconcile);
|
||||
this.#databaseSecret.on('changed', this.queueReconcile);
|
||||
}
|
||||
|
||||
get #dbName() {
|
||||
@@ -68,70 +52,46 @@ class PostgresDatabaseResource extends CustomResource<typeof postgresDatabaseSpe
|
||||
}
|
||||
|
||||
#updateSecret = () => {
|
||||
const resouceService = this.services.get(ResourceService);
|
||||
const connectionNames = getWithNamespace(this.spec.connection, this.namespace);
|
||||
this.#connection.current = resouceService.get({
|
||||
apiVersion: API_VERSION,
|
||||
kind: 'PostgresConnection',
|
||||
name: connectionNames.name,
|
||||
namespace: connectionNames.namespace,
|
||||
const resourceService = this.services.get(ResourceService);
|
||||
const secretNames = getWithNamespace(this.spec.cluster, this.namespace);
|
||||
this.#clusterSecret.current = resourceService.get({
|
||||
apiVersion: 'v1',
|
||||
kind: 'Secret',
|
||||
name: secretNames.name,
|
||||
namespace: secretNames.namespace,
|
||||
});
|
||||
if (this.#connection.current?.exists && this.#connection.current.spec) {
|
||||
const connectionSecretNames = getWithNamespace(
|
||||
this.#connection.current.spec.secret,
|
||||
this.#connection.current.namespace,
|
||||
);
|
||||
this.#connectionSecret.current = resouceService.get({
|
||||
apiVersion: 'v1',
|
||||
kind: 'Secret',
|
||||
name: connectionSecretNames.name,
|
||||
namespace: connectionSecretNames.namespace,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
#reconcileSecret = async (): Promise<SubresourceResult> => {
|
||||
const connectionSecret = this.#connectionSecret.current;
|
||||
if (!connectionSecret?.exists || !connectionSecret.data) {
|
||||
const serverSecret = this.#clusterSecret.current;
|
||||
const databaseSecret = this.#databaseSecret;
|
||||
|
||||
if (!serverSecret?.exists || !serverSecret.data) {
|
||||
return {
|
||||
ready: false,
|
||||
failed: true,
|
||||
reason: 'MissingConnectionSecret',
|
||||
};
|
||||
}
|
||||
|
||||
const connectionSecretData = decodeSecret(connectionSecret.data);
|
||||
|
||||
const secret = this.#secret;
|
||||
const parsed = secretDataSchema.safeParse(decodeSecret(secret.data));
|
||||
|
||||
if (!parsed.success) {
|
||||
this.#secret.patch({
|
||||
data: {
|
||||
host: Buffer.from(connectionSecretData?.host || '').toString('base64'),
|
||||
port: connectionSecretData?.port ? Buffer.from(connectionSecretData.port).toString('base64') : undefined,
|
||||
user: Buffer.from(this.#userName).toString('base64'),
|
||||
database: Buffer.from(this.#dbName).toString('base64'),
|
||||
password: Buffer.from(Buffer.from(crypto.randomUUID()).toString('hex')).toString('base64'),
|
||||
},
|
||||
});
|
||||
return {
|
||||
ready: false,
|
||||
syncing: true,
|
||||
};
|
||||
}
|
||||
if (parsed.data?.host !== connectionSecretData?.host || parsed.data?.port !== connectionSecretData?.port) {
|
||||
this.#secret.patch({
|
||||
data: {
|
||||
host: Buffer.from(connectionSecretData?.host || '').toString('base64'),
|
||||
port: connectionSecretData?.port ? Buffer.from(connectionSecretData.port).toString('base64') : undefined,
|
||||
},
|
||||
});
|
||||
const serverSecretData = postgresClusterSecretSchema.safeParse(decodeSecret(serverSecret.data));
|
||||
if (!serverSecretData.success || !serverSecretData.data) {
|
||||
return {
|
||||
ready: false,
|
||||
syncing: true,
|
||||
reason: 'SecretMissing',
|
||||
};
|
||||
}
|
||||
const databaseSecretData = postgresClusterSecretSchema.safeParse(decodeSecret(databaseSecret.data));
|
||||
const expectedSecret = {
|
||||
password: crypto.randomUUID(),
|
||||
host: serverSecretData.data.host,
|
||||
port: serverSecretData.data.port,
|
||||
username: this.#userName,
|
||||
database: this.#dbName,
|
||||
...databaseSecretData.data,
|
||||
};
|
||||
|
||||
await databaseSecret.ensureData(expectedSecret);
|
||||
|
||||
return {
|
||||
ready: true,
|
||||
@@ -139,8 +99,8 @@ class PostgresDatabaseResource extends CustomResource<typeof postgresDatabaseSpe
|
||||
};
|
||||
|
||||
#reconcileDatabase = async (): Promise<SubresourceResult> => {
|
||||
const connectionSecret = this.#connectionSecret.current;
|
||||
if (!connectionSecret?.exists || !connectionSecret.data) {
|
||||
const clusterSecret = this.#clusterSecret.current;
|
||||
if (!clusterSecret?.exists || !clusterSecret.data) {
|
||||
return {
|
||||
ready: false,
|
||||
failed: true,
|
||||
@@ -148,17 +108,8 @@ class PostgresDatabaseResource extends CustomResource<typeof postgresDatabaseSpe
|
||||
};
|
||||
}
|
||||
|
||||
const connectionSecretData = postgresConnectionSecretDataSchema.safeParse(decodeSecret(connectionSecret.data));
|
||||
const connectionSecretData = postgresClusterSecretSchema.safeParse(decodeSecret(clusterSecret.data));
|
||||
if (!connectionSecretData.success || !connectionSecretData.data) {
|
||||
return {
|
||||
ready: false,
|
||||
syncing: true,
|
||||
reason: 'ConnectionSecretMissing',
|
||||
};
|
||||
}
|
||||
|
||||
const secretData = secretDataSchema.safeParse(decodeSecret(this.#secret.data));
|
||||
if (!secretData.success || !secretData.data) {
|
||||
return {
|
||||
ready: false,
|
||||
syncing: true,
|
||||
@@ -166,18 +117,28 @@ class PostgresDatabaseResource extends CustomResource<typeof postgresDatabaseSpe
|
||||
};
|
||||
}
|
||||
|
||||
const secretData = postgresClusterSecretSchema.safeParse(decodeSecret(this.#databaseSecret.data));
|
||||
if (!secretData.success || !secretData.data) {
|
||||
return {
|
||||
ready: false,
|
||||
syncing: true,
|
||||
reason: 'ConnectionSecretMissing',
|
||||
};
|
||||
}
|
||||
|
||||
const postgresService = this.services.get(PostgresService);
|
||||
const database = postgresService.get({
|
||||
...connectionSecretData.data,
|
||||
port: connectionSecretData.data.port ? Number(connectionSecretData.data.port) : 5432,
|
||||
database: connectionSecretData.data.database,
|
||||
});
|
||||
await database.upsertRole({
|
||||
name: secretData.data.user,
|
||||
name: secretData.data.username,
|
||||
password: secretData.data.password,
|
||||
});
|
||||
await database.upsertDatabase({
|
||||
name: secretData.data.database,
|
||||
owner: secretData.data.user,
|
||||
owner: secretData.data.username,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -186,13 +147,13 @@ class PostgresDatabaseResource extends CustomResource<typeof postgresDatabaseSpe
|
||||
};
|
||||
|
||||
public reconcile = async () => {
|
||||
if (!this.exists || this.metadata.deletionTimestamp) {
|
||||
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),
|
||||
this.reconcileSubresource(DATABASE_READY_CONDITION, this.#reconcileDatabase),
|
||||
this.reconcileSubresource(SECRET_READY_CONDITION, this.#reconcileSecret),
|
||||
]);
|
||||
|
||||
const secretReady = this.conditions.get(SECRET_READY_CONDITION)?.status === 'True';
|
||||
@@ -203,4 +164,4 @@ class PostgresDatabaseResource extends CustomResource<typeof postgresDatabaseSpe
|
||||
};
|
||||
}
|
||||
|
||||
export { PostgresDatabaseResource, secretDataSchema as postgresDatabaseSecretSchema };
|
||||
export { PostgresDatabaseResource };
|
||||
|
||||
@@ -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 { ResourceReference } from '../../services/resources/resources.ref.ts';
|
||||
import { ResourceService } from '../../services/resources/resources.ts';
|
||||
import { getWithNamespace } from '../../utils/naming.ts';
|
||||
|
||||
import type { redisConnectionSpecSchema } from './redis-connection.schemas.ts';
|
||||
|
||||
class RedisConnectionResource extends CustomResource<typeof redisConnectionSpecSchema> {
|
||||
#secret: ResourceReference<V1Secret>;
|
||||
|
||||
constructor(options: CustomResourceOptions<typeof redisConnectionSpecSchema>) {
|
||||
super(options);
|
||||
const resourceService = this.services.get(ResourceService);
|
||||
const secretNames = getWithNamespace(this.spec.secret, this.namespace);
|
||||
this.#secret = new ResourceReference<V1Secret>(
|
||||
resourceService.get({
|
||||
apiVersion: 'v1',
|
||||
kind: 'Secret',
|
||||
name: secretNames.name,
|
||||
namespace: secretNames.namespace,
|
||||
}),
|
||||
);
|
||||
this.#secret.on('changed', this.queueReconcile);
|
||||
}
|
||||
|
||||
public reconcile = async () => {
|
||||
const resourceService = this.services.get(ResourceService);
|
||||
const secretNames = getWithNamespace(this.spec.secret, this.namespace);
|
||||
this.#secret.current = resourceService.get({
|
||||
apiVersion: 'v1',
|
||||
kind: 'Secret',
|
||||
name: secretNames.name,
|
||||
namespace: secretNames.namespace,
|
||||
});
|
||||
|
||||
const current = this.#secret.current;
|
||||
if (!current?.exists || !current.data) {
|
||||
return this.conditions.set('Ready', {
|
||||
status: 'False',
|
||||
reason: 'MissingSecret',
|
||||
});
|
||||
}
|
||||
const { host } = current.data;
|
||||
if (!host) {
|
||||
return this.conditions.set('Ready', {
|
||||
status: 'False',
|
||||
reason: 'MissingHost',
|
||||
});
|
||||
}
|
||||
await this.conditions.set('Ready', {
|
||||
status: 'True',
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export { RedisConnectionResource };
|
||||
@@ -1,14 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const redisConnectionSpecSchema = z.object({
|
||||
secret: z.string(),
|
||||
});
|
||||
|
||||
const redisConnectionSecretDataSchema = z.object({
|
||||
host: z.string(),
|
||||
port: z.string().optional(),
|
||||
user: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
});
|
||||
|
||||
export { redisConnectionSpecSchema, redisConnectionSecretDataSchema };
|
||||
@@ -1,19 +0,0 @@
|
||||
import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts';
|
||||
import { GROUP } from '../../utils/consts.ts';
|
||||
|
||||
import { redisConnectionSpecSchema } from './redis-connection.schemas.ts';
|
||||
import { RedisConnectionResource } from './redis-connection.resource.ts';
|
||||
|
||||
const redisConnectionDefinition = createCustomResourceDefinition({
|
||||
group: GROUP,
|
||||
version: 'v1',
|
||||
kind: 'RedisConnection',
|
||||
names: {
|
||||
plural: 'redisconnections',
|
||||
singular: 'redisconnection',
|
||||
},
|
||||
spec: redisConnectionSpecSchema,
|
||||
create: (options) => new RedisConnectionResource(options),
|
||||
});
|
||||
|
||||
export { redisConnectionDefinition };
|
||||
82
src/custom-resouces/redis-server/redis-server.controller.ts
Normal file
82
src/custom-resouces/redis-server/redis-server.controller.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { DeploymentInstance } from '../../instances/deployment.ts';
|
||||
import { ServiceInstance } from '../../instances/service.ts';
|
||||
import { CustomResource } from '../../services/custom-resources/custom-resources.custom-resource.ts';
|
||||
import type { CustomResourceOptions } from '../../services/custom-resources/custom-resources.custom-resource.ts';
|
||||
import { ResourceService } from '../../services/resources/resources.ts';
|
||||
|
||||
import type { redisServerSpecSchema } from './redis-server.schemas.ts';
|
||||
|
||||
class RedisServerController extends CustomResource<typeof redisServerSpecSchema> {
|
||||
#deployment: DeploymentInstance;
|
||||
#service: ServiceInstance;
|
||||
|
||||
constructor(options: CustomResourceOptions<typeof redisServerSpecSchema>) {
|
||||
super(options);
|
||||
const resourceService = this.services.get(ResourceService);
|
||||
this.#deployment = resourceService.getInstance(
|
||||
{
|
||||
apiVersion: 'apps/v1',
|
||||
kind: 'Deployment',
|
||||
name: this.name,
|
||||
namespace: this.namespace,
|
||||
},
|
||||
DeploymentInstance,
|
||||
);
|
||||
this.#service = resourceService.getInstance(
|
||||
{
|
||||
apiVersion: 'v1',
|
||||
kind: 'Service',
|
||||
name: this.name,
|
||||
namespace: this.namespace,
|
||||
},
|
||||
ServiceInstance,
|
||||
);
|
||||
this.#deployment.on('changed', this.queueReconcile);
|
||||
this.#service.on('changed', this.queueReconcile);
|
||||
}
|
||||
|
||||
public reconcile = async () => {
|
||||
await this.#deployment.ensure({
|
||||
metadata: {
|
||||
ownerReferences: [this.ref],
|
||||
},
|
||||
spec: {
|
||||
replicas: 1,
|
||||
selector: {
|
||||
matchLabels: {
|
||||
app: this.name,
|
||||
},
|
||||
},
|
||||
template: {
|
||||
metadata: {
|
||||
labels: {
|
||||
app: this.name,
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
containers: [
|
||||
{
|
||||
name: this.name,
|
||||
image: 'redis:latest',
|
||||
ports: [{ containerPort: 6379 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await this.#service.ensure({
|
||||
metadata: {
|
||||
ownerReferences: [this.ref],
|
||||
},
|
||||
spec: {
|
||||
selector: {
|
||||
app: this.name,
|
||||
},
|
||||
ports: [{ port: 6379, targetPort: 6379 }],
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export { RedisServerController };
|
||||
@@ -1,82 +0,0 @@
|
||||
import type { V1Deployment, V1Service } from '@kubernetes/client-node';
|
||||
|
||||
import type { CustomResourceObject } from '../../services/custom-resources/custom-resources.custom-resource.ts';
|
||||
import type { redisConnectionSpecSchema } from '../redis-connection/redis-connection.schemas.ts';
|
||||
import { API_VERSION, CONTROLLED_LABEL } from '../../utils/consts.ts';
|
||||
|
||||
const deploymentManifest = (): V1Deployment => ({
|
||||
apiVersion: 'apps/v1',
|
||||
kind: 'Deployment',
|
||||
metadata: {
|
||||
name: 'redis-server',
|
||||
namespace: 'homelab',
|
||||
},
|
||||
spec: {
|
||||
replicas: 1,
|
||||
selector: {
|
||||
matchLabels: {
|
||||
app: 'redis-server',
|
||||
},
|
||||
},
|
||||
template: {
|
||||
metadata: {
|
||||
labels: {
|
||||
app: 'redis-server',
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
containers: [
|
||||
{
|
||||
name: 'redis-server',
|
||||
image: 'redis:latest',
|
||||
ports: [
|
||||
{
|
||||
containerPort: 6379,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const serviceManifest = (): V1Service => ({
|
||||
apiVersion: 'v1',
|
||||
kind: 'Service',
|
||||
metadata: {
|
||||
name: 'redis-server',
|
||||
namespace: 'homelab',
|
||||
},
|
||||
spec: {
|
||||
selector: {
|
||||
app: 'redis-server',
|
||||
},
|
||||
ports: [
|
||||
{
|
||||
port: 6379,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
type RedisConnectionManifestOptions = {
|
||||
secretName: string;
|
||||
};
|
||||
|
||||
const connectionManifest = (
|
||||
options: RedisConnectionManifestOptions,
|
||||
): CustomResourceObject<typeof redisConnectionSpecSchema> => ({
|
||||
apiVersion: API_VERSION,
|
||||
kind: 'RedisConnection',
|
||||
metadata: {
|
||||
labels: {
|
||||
...CONTROLLED_LABEL,
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
secret: options.secretName,
|
||||
},
|
||||
});
|
||||
|
||||
export { deploymentManifest, serviceManifest, connectionManifest };
|
||||
@@ -1,138 +0,0 @@
|
||||
import type { V1Deployment, V1Service } from '@kubernetes/client-node';
|
||||
|
||||
import {
|
||||
type CustomResourceOptions,
|
||||
CustomResource,
|
||||
type CustomResourceObject,
|
||||
} from '../../services/custom-resources/custom-resources.custom-resource.ts';
|
||||
import {
|
||||
redisConnectionSecretDataSchema,
|
||||
redisConnectionSpecSchema,
|
||||
} from '../redis-connection/redis-connection.schemas.ts';
|
||||
import { Resource, ResourceService } from '../../services/resources/resources.ts';
|
||||
import { API_VERSION } from '../../utils/consts.ts';
|
||||
import type { EnsuredSecret } from '../../services/secrets/secrets.secret.ts';
|
||||
import { SecretService } from '../../services/secrets/secrets.ts';
|
||||
import { isDeepSubset } from '../../utils/objects.ts';
|
||||
|
||||
import { redisServerSpecSchema } from './redis-server.schemas.ts';
|
||||
import { connectionManifest, deploymentManifest, serviceManifest } from './redis-server.manifests.ts';
|
||||
|
||||
class RedisServerResource extends CustomResource<typeof redisServerSpecSchema> {
|
||||
#resources: {
|
||||
deployment: Resource<V1Deployment>;
|
||||
service: Resource<V1Service>;
|
||||
connection: Resource<CustomResourceObject<typeof redisConnectionSpecSchema>>;
|
||||
secret: EnsuredSecret<typeof redisConnectionSecretDataSchema>;
|
||||
};
|
||||
constructor(options: CustomResourceOptions<typeof redisServerSpecSchema>) {
|
||||
super(options);
|
||||
const resourceService = this.services.get(ResourceService);
|
||||
const secretService = this.services.get(SecretService);
|
||||
this.#resources = {
|
||||
deployment: resourceService.get({
|
||||
apiVersion: 'apps/v1',
|
||||
kind: 'Deployment',
|
||||
name: this.name,
|
||||
namespace: this.namespace,
|
||||
}),
|
||||
service: resourceService.get({
|
||||
apiVersion: 'v1',
|
||||
kind: 'Service',
|
||||
name: this.name,
|
||||
namespace: this.namespace,
|
||||
}),
|
||||
connection: resourceService.get({
|
||||
apiVersion: API_VERSION,
|
||||
kind: 'RedisConnection',
|
||||
name: this.name,
|
||||
namespace: this.namespace,
|
||||
}),
|
||||
secret: secretService.ensure({
|
||||
name: `${this.name}-connection`,
|
||||
namespace: this.namespace,
|
||||
schema: redisConnectionSecretDataSchema,
|
||||
generator: () => ({
|
||||
host: `${this.name}.${this.namespace}.svc.cluster.local`,
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
#reconcileDeployment = async () => {
|
||||
const { deployment } = this.#resources;
|
||||
const manifest = deploymentManifest();
|
||||
if (!isDeepSubset(deployment.spec, manifest.spec)) {
|
||||
await deployment.patch(manifest);
|
||||
return {
|
||||
ready: false,
|
||||
syncing: true,
|
||||
reason: 'ChangingDeployment',
|
||||
message: 'Deployment need changes',
|
||||
};
|
||||
}
|
||||
return {
|
||||
ready: true,
|
||||
reason: 'DeploymentReady',
|
||||
message: 'Deployment is ready',
|
||||
};
|
||||
};
|
||||
|
||||
#reconcileService = async () => {
|
||||
const { service } = this.#resources;
|
||||
const manifest = serviceManifest();
|
||||
if (!isDeepSubset(service.spec, manifest.spec)) {
|
||||
await service.patch(manifest);
|
||||
return {
|
||||
ready: false,
|
||||
syncing: true,
|
||||
reason: 'ChangingService',
|
||||
message: 'Service need changes',
|
||||
};
|
||||
}
|
||||
return {
|
||||
ready: true,
|
||||
reason: 'ServiceReady',
|
||||
message: 'Service is ready',
|
||||
};
|
||||
};
|
||||
|
||||
#reconcileConnection = async () => {
|
||||
const { connection, secret } = this.#resources;
|
||||
if (!secret.isValid || !secret.value) {
|
||||
return {
|
||||
ready: false,
|
||||
failed: true,
|
||||
reason: 'MissingSecret',
|
||||
message: 'Secret is missing',
|
||||
};
|
||||
}
|
||||
const manifest = connectionManifest({
|
||||
secretName: secret.name,
|
||||
});
|
||||
if (!isDeepSubset(connection.spec, manifest.spec)) {
|
||||
await connection.patch(manifest);
|
||||
return {
|
||||
ready: false,
|
||||
syncing: true,
|
||||
reason: 'ChangingConnection',
|
||||
message: 'Connection need changes',
|
||||
};
|
||||
}
|
||||
return {
|
||||
ready: true,
|
||||
reason: 'ConnectionReady',
|
||||
message: 'Connection is ready',
|
||||
};
|
||||
};
|
||||
|
||||
public reconcile = async () => {
|
||||
await Promise.allSettled([
|
||||
this.reconcileSubresource('Deployment', this.#reconcileDeployment),
|
||||
this.reconcileSubresource('Service', this.#reconcileService),
|
||||
this.reconcileSubresource('Connection', this.#reconcileConnection),
|
||||
]);
|
||||
};
|
||||
}
|
||||
|
||||
export { RedisServerResource };
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts';
|
||||
import { GROUP } from '../../utils/consts.ts';
|
||||
|
||||
import { RedisServerResource } from './redis-server.resource.ts';
|
||||
import { RedisServerController } from './redis-server.controller.ts';
|
||||
import { redisServerSpecSchema } from './redis-server.schemas.ts';
|
||||
|
||||
const redisServerDefinition = createCustomResourceDefinition({
|
||||
@@ -13,7 +13,7 @@ const redisServerDefinition = createCustomResourceDefinition({
|
||||
singular: 'redis-server',
|
||||
},
|
||||
spec: redisServerSpecSchema,
|
||||
create: (options) => new RedisServerResource(options),
|
||||
create: (options) => new RedisServerController(options),
|
||||
});
|
||||
|
||||
export { redisServerDefinition };
|
||||
|
||||
129
src/index.ts
129
src/index.ts
@@ -1,35 +1,78 @@
|
||||
import 'dotenv/config';
|
||||
import { ApiException } from '@kubernetes/client-node';
|
||||
|
||||
import { Services } from './utils/service.ts';
|
||||
import { BootstrapService } from './bootstrap/bootstrap.ts';
|
||||
import { customResources } from './custom-resouces/custom-resources.ts';
|
||||
import { CustomResourceService } from './services/custom-resources/custom-resources.ts';
|
||||
import { WatcherService } from './services/watchers/watchers.ts';
|
||||
import { IstioService } from './services/istio/istio.ts';
|
||||
import { customResources } from './custom-resouces/custom-resources.ts';
|
||||
|
||||
process.on('uncaughtException', (error) => {
|
||||
console.log('UNCAUGHT EXCEPTION');
|
||||
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);
|
||||
});
|
||||
import { StorageProvider } from './storage-provider/storage-provider.ts';
|
||||
import { Services } from './utils/service.ts';
|
||||
|
||||
const services = new Services();
|
||||
|
||||
const watcherService = services.get(WatcherService);
|
||||
await watcherService.watchCustomGroup('source.toolkit.fluxcd.io', 'v1', ['helmrepositories', 'gitrepositories']);
|
||||
await watcherService.watchCustomGroup('helm.toolkit.fluxcd.io', 'v2', ['helmreleases']);
|
||||
await watcherService.watchCustomGroup('cert-manager.io', 'v1', ['certificates']);
|
||||
await watcherService.watchCustomGroup('networking.k8s.io', 'v1', ['gateways', 'virtualservices']);
|
||||
|
||||
await watcherService
|
||||
.create({
|
||||
path: '/api/v1/namespaces',
|
||||
list: async (k8s) => {
|
||||
return await k8s.api.listNamespace();
|
||||
},
|
||||
verbs: ['add', 'update', 'delete'],
|
||||
transform: (manifest) => ({
|
||||
apiVersion: 'v1',
|
||||
kind: 'Namespace',
|
||||
...manifest,
|
||||
}),
|
||||
})
|
||||
.start();
|
||||
|
||||
await watcherService
|
||||
.create({
|
||||
path: '/api/v1/secrets',
|
||||
list: async (k8s) => {
|
||||
return await k8s.api.listSecretForAllNamespaces();
|
||||
},
|
||||
verbs: ['add', 'update', 'delete'],
|
||||
transform: (manifest) => ({
|
||||
apiVersion: 'v1',
|
||||
kind: 'Secret',
|
||||
...manifest,
|
||||
}),
|
||||
})
|
||||
.start();
|
||||
|
||||
await watcherService
|
||||
.create({
|
||||
path: '/apis/apps/v1/statefulsets',
|
||||
list: async (k8s) => {
|
||||
return await k8s.apps.listStatefulSetForAllNamespaces({});
|
||||
},
|
||||
verbs: ['add', 'update', 'delete'],
|
||||
transform: (manifest) => ({
|
||||
apiVersion: 'apps/v1',
|
||||
kind: 'StatefulSet',
|
||||
...manifest,
|
||||
}),
|
||||
})
|
||||
.start();
|
||||
|
||||
await watcherService
|
||||
.create({
|
||||
path: '/apis/apps/v1/deployments',
|
||||
list: async (k8s) => {
|
||||
return await k8s.apps.listDeploymentForAllNamespaces({});
|
||||
},
|
||||
verbs: ['add', 'update', 'delete'],
|
||||
transform: (manifest) => ({
|
||||
apiVersion: 'apps/v1',
|
||||
kind: 'Deployment',
|
||||
...manifest,
|
||||
}),
|
||||
})
|
||||
.start();
|
||||
|
||||
await watcherService
|
||||
.create({
|
||||
path: '/apis/apiextensions.k8s.io/v1/customresourcedefinitions',
|
||||
@@ -46,40 +89,24 @@ await watcherService
|
||||
.start();
|
||||
await watcherService
|
||||
.create({
|
||||
path: '/api/v1/secrets',
|
||||
path: '/apis/storage.k8s.io/v1/storageclasses',
|
||||
list: async (k8s) => {
|
||||
return await k8s.api.listSecretForAllNamespaces();
|
||||
return await k8s.storageApi.listStorageClass();
|
||||
},
|
||||
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',
|
||||
apiVersion: 'storage.k8s.io/v1',
|
||||
kind: 'StorageClass',
|
||||
...manifest,
|
||||
}),
|
||||
})
|
||||
.start();
|
||||
|
||||
await watcherService.watchCustomGroup('networking.istio.io', 'v1', ['gateways', 'virtualservices', 'destinationrules']);
|
||||
await watcherService.watchCustomGroup('source.toolkit.fluxcd.io', 'v1', ['helmrepositories', 'helmcharts']);
|
||||
await watcherService.watchCustomGroup('helm.toolkit.fluxcd.io', 'v2', ['helmreleases']);
|
||||
await watcherService.watchCustomGroup('cert-manager.io', 'v1', ['issuers', 'certificates', 'clusterissuers']);
|
||||
const storageProvider = services.get(StorageProvider);
|
||||
await storageProvider.start();
|
||||
|
||||
const istio = services.get(IstioService);
|
||||
await istio.start();
|
||||
const bootstrap = services.get(BootstrapService);
|
||||
await bootstrap.ensure();
|
||||
|
||||
const customResourceService = services.get(CustomResourceService);
|
||||
customResourceService.register(...customResources);
|
||||
|
||||
7
src/instances/authentik-server.ts
Normal file
7
src/instances/authentik-server.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { authentikServerSpecSchema } from '../custom-resouces/authentik-server/authentik-server.schemas.ts';
|
||||
import type { CustomResourceObject } from '../services/custom-resources/custom-resources.custom-resource.ts';
|
||||
import { ResourceInstance } from '../services/resources/resources.instance.ts';
|
||||
|
||||
class AuthentikServerInstance extends ResourceInstance<CustomResourceObject<typeof authentikServerSpecSchema>> {}
|
||||
|
||||
export { AuthentikServerInstance };
|
||||
8
src/instances/certificate.ts
Normal file
8
src/instances/certificate.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { KubernetesObject } from '@kubernetes/client-node';
|
||||
|
||||
import { ResourceInstance } from '../services/resources/resources.instance.ts';
|
||||
import type { K8SCertificateV1 } from '../__generated__/resources/K8SCertificateV1.ts';
|
||||
|
||||
class CertificateInstance extends ResourceInstance<KubernetesObject & K8SCertificateV1> {}
|
||||
|
||||
export { CertificateInstance };
|
||||
12
src/instances/cluster-issuer.ts
Normal file
12
src/instances/cluster-issuer.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { KubernetesObject } from '@kubernetes/client-node';
|
||||
|
||||
import type { K8SClusterIssuerV1 } from '../__generated__/resources/K8SClusterIssuerV1.ts';
|
||||
import { ResourceInstance } from '../services/resources/resources.instance.ts';
|
||||
|
||||
class ClusterIssuerInstance extends ResourceInstance<KubernetesObject & K8SClusterIssuerV1> {
|
||||
public get ready() {
|
||||
return this.exists;
|
||||
}
|
||||
}
|
||||
|
||||
export { ClusterIssuerInstance };
|
||||
7
src/instances/custom-resource-definition.ts
Normal file
7
src/instances/custom-resource-definition.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { V1CustomResourceDefinition } from '@kubernetes/client-node';
|
||||
|
||||
import { ResourceInstance } from '../services/resources/resources.instance.ts';
|
||||
|
||||
class CustomDefinitionInstance extends ResourceInstance<V1CustomResourceDefinition> {}
|
||||
|
||||
export { CustomDefinitionInstance };
|
||||
11
src/instances/deployment.ts
Normal file
11
src/instances/deployment.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { V1Deployment } from '@kubernetes/client-node';
|
||||
|
||||
import { ResourceInstance } from '../services/resources/resources.ts';
|
||||
|
||||
class DeploymentInstance extends ResourceInstance<V1Deployment> {
|
||||
public get ready() {
|
||||
return this.exists && this.status?.readyReplicas === this.status?.replicas;
|
||||
}
|
||||
}
|
||||
|
||||
export { DeploymentInstance };
|
||||
12
src/instances/destination-rule.ts
Normal file
12
src/instances/destination-rule.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { KubernetesObject } from '@kubernetes/client-node';
|
||||
|
||||
import { ResourceInstance } from '../services/resources/resources.instance.ts';
|
||||
import type { K8SDestinationRuleV1 } from '../__generated__/resources/K8SDestinationRuleV1.ts';
|
||||
|
||||
class DestinationRuleInstance extends ResourceInstance<KubernetesObject & K8SDestinationRuleV1> {
|
||||
public get ready() {
|
||||
return this.exists;
|
||||
}
|
||||
}
|
||||
|
||||
export { DestinationRuleInstance };
|
||||
7
src/instances/environment.ts
Normal file
7
src/instances/environment.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { environmentSpecSchema } from '../custom-resouces/environment/environment.schemas.ts';
|
||||
import type { CustomResourceObject } from '../services/custom-resources/custom-resources.custom-resource.ts';
|
||||
import { ResourceInstance } from '../services/resources/resources.instance.ts';
|
||||
|
||||
class EnvironmentInstance extends ResourceInstance<CustomResourceObject<typeof environmentSpecSchema>> {}
|
||||
|
||||
export { EnvironmentInstance };
|
||||
8
src/instances/gateway.ts
Normal file
8
src/instances/gateway.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { KubernetesObject } from '@kubernetes/client-node';
|
||||
|
||||
import { ResourceInstance } from '../services/resources/resources.instance.ts';
|
||||
import type { K8SGatewayV1 } from '../__generated__/resources/K8SGatewayV1.ts';
|
||||
|
||||
class GatewayInstance extends ResourceInstance<KubernetesObject & K8SGatewayV1> {}
|
||||
|
||||
export { GatewayInstance };
|
||||
12
src/instances/git-repo.ts
Normal file
12
src/instances/git-repo.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { KubernetesObject } from '@kubernetes/client-node';
|
||||
|
||||
import { ResourceInstance } from '../services/resources/resources.ts';
|
||||
import type { K8SGitRepositoryV1 } from '../__generated__/resources/K8SGitRepositoryV1.ts';
|
||||
|
||||
class GitRepoInstance extends ResourceInstance<KubernetesObject & K8SGitRepositoryV1> {
|
||||
public get ready() {
|
||||
return this.exists;
|
||||
}
|
||||
}
|
||||
|
||||
export { GitRepoInstance };
|
||||
12
src/instances/helm-release.ts
Normal file
12
src/instances/helm-release.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { KubernetesObject } from '@kubernetes/client-node';
|
||||
|
||||
import { ResourceInstance } from '../services/resources/resources.ts';
|
||||
import type { K8SHelmReleaseV2 } from '../__generated__/resources/K8SHelmReleaseV2.ts';
|
||||
|
||||
class HelmReleaseInstance extends ResourceInstance<KubernetesObject & K8SHelmReleaseV2> {
|
||||
public get ready() {
|
||||
return this.exists;
|
||||
}
|
||||
}
|
||||
|
||||
export { HelmReleaseInstance };
|
||||
16
src/instances/helm-repo.ts
Normal file
16
src/instances/helm-repo.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { KubernetesObject } from '@kubernetes/client-node';
|
||||
|
||||
import { ResourceInstance } from '../services/resources/resources.ts';
|
||||
import type { K8SHelmRepositoryV1 } from '../__generated__/resources/K8SHelmRepositoryV1.ts';
|
||||
|
||||
class HelmRepoInstance extends ResourceInstance<KubernetesObject & K8SHelmRepositoryV1> {
|
||||
public get ready() {
|
||||
if (!this.exists) {
|
||||
return false;
|
||||
}
|
||||
const condition = this.getCondition('Ready');
|
||||
return condition?.status === 'True';
|
||||
}
|
||||
}
|
||||
|
||||
export { HelmRepoInstance };
|
||||
7
src/instances/http-service.ts
Normal file
7
src/instances/http-service.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { httpServiceSpecSchema } from '../custom-resouces/http-service/http-service.schemas.ts';
|
||||
import type { CustomResourceObject } from '../services/custom-resources/custom-resources.custom-resource.ts';
|
||||
import { ResourceInstance } from '../services/resources/resources.instance.ts';
|
||||
|
||||
class HttpServiceInstance extends ResourceInstance<CustomResourceObject<typeof httpServiceSpecSchema>> {}
|
||||
|
||||
export { HttpServiceInstance };
|
||||
11
src/instances/namespace.ts
Normal file
11
src/instances/namespace.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { V1Namespace } from '@kubernetes/client-node';
|
||||
|
||||
import { ResourceInstance } from '../services/resources/resources.ts';
|
||||
|
||||
class NamespaceInstance extends ResourceInstance<V1Namespace> {
|
||||
public get ready() {
|
||||
return this.exists;
|
||||
}
|
||||
}
|
||||
|
||||
export { NamespaceInstance };
|
||||
7
src/instances/postgres-cluster.ts
Normal file
7
src/instances/postgres-cluster.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { postgresClusterSpecSchema } from '../custom-resouces/postgres-cluster/postgres-cluster.schemas.ts';
|
||||
import type { CustomResourceObject } from '../services/custom-resources/custom-resources.custom-resource.ts';
|
||||
import { ResourceInstance } from '../services/resources/resources.instance.ts';
|
||||
|
||||
class PostgresClusterInstance extends ResourceInstance<CustomResourceObject<typeof postgresClusterSpecSchema>> {}
|
||||
|
||||
export { PostgresClusterInstance };
|
||||
23
src/instances/postgres-database.ts
Normal file
23
src/instances/postgres-database.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { postgresDatabaseSpecSchema } from '../custom-resouces/postgres-database/portgres-database.schemas.ts';
|
||||
import type { CustomResourceObject } from '../services/custom-resources/custom-resources.custom-resource.ts';
|
||||
import { ResourceInstance } from '../services/resources/resources.instance.ts';
|
||||
import { ResourceService } from '../services/resources/resources.ts';
|
||||
|
||||
import { SecretInstance } from './secret.ts';
|
||||
|
||||
class PostgresDatabaseInstance extends ResourceInstance<CustomResourceObject<typeof postgresDatabaseSpecSchema>> {
|
||||
public get secret() {
|
||||
const resourceService = this.services.get(ResourceService);
|
||||
return resourceService.getInstance(
|
||||
{
|
||||
apiVersion: 'v1',
|
||||
kind: 'Secret',
|
||||
name: `${this.name}-postgres-database`,
|
||||
namespace: this.namespace,
|
||||
},
|
||||
SecretInstance,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export { PostgresDatabaseInstance };
|
||||
7
src/instances/redis-server.ts
Normal file
7
src/instances/redis-server.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { CustomResourceObject } from '../services/custom-resources/custom-resources.custom-resource.ts';
|
||||
import { ResourceInstance } from '../services/resources/resources.instance.ts';
|
||||
import type { redisServerSpecSchema } from '../custom-resouces/redis-server/redis-server.schemas.ts';
|
||||
|
||||
class RedisServerInstance extends ResourceInstance<CustomResourceObject<typeof redisServerSpecSchema>> {}
|
||||
|
||||
export { RedisServerInstance };
|
||||
23
src/instances/secret.ts
Normal file
23
src/instances/secret.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { V1Secret } from '@kubernetes/client-node';
|
||||
import type { z, ZodObject } from 'zod';
|
||||
|
||||
import { ResourceInstance } from '../services/resources/resources.instance.ts';
|
||||
import { decodeSecret, encodeSecret } from '../utils/secrets.ts';
|
||||
|
||||
class SecretInstance<T extends ZodObject = ExpectedAny> extends ResourceInstance<V1Secret> {
|
||||
public get values() {
|
||||
return decodeSecret(this.data) as z.infer<T>;
|
||||
}
|
||||
|
||||
public ensureData = async (values: z.infer<T>) => {
|
||||
await this.ensure({
|
||||
data: encodeSecret(values as Record<string, string>),
|
||||
});
|
||||
};
|
||||
|
||||
public get ready() {
|
||||
return this.exists;
|
||||
}
|
||||
}
|
||||
|
||||
export { SecretInstance };
|
||||
11
src/instances/service.ts
Normal file
11
src/instances/service.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { V1Service } from '@kubernetes/client-node';
|
||||
|
||||
import { ResourceInstance } from '../services/resources/resources.ts';
|
||||
|
||||
class ServiceInstance extends ResourceInstance<V1Service> {
|
||||
public get ready() {
|
||||
return this.exists;
|
||||
}
|
||||
}
|
||||
|
||||
export { ServiceInstance };
|
||||
11
src/instances/stateful-set.ts
Normal file
11
src/instances/stateful-set.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { V1StatefulSet } from '@kubernetes/client-node';
|
||||
|
||||
import { ResourceInstance } from '../services/resources/resources.instance.ts';
|
||||
|
||||
class StatefulSetInstance extends ResourceInstance<V1StatefulSet> {
|
||||
public get ready() {
|
||||
return this.exists && this.manifest?.status?.readyReplicas === this.manifest?.status?.replicas;
|
||||
}
|
||||
}
|
||||
|
||||
export { StatefulSetInstance };
|
||||
7
src/instances/storageclass.ts
Normal file
7
src/instances/storageclass.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { V1StorageClass } from '@kubernetes/client-node';
|
||||
|
||||
import { ResourceInstance } from '../services/resources/resources.instance.ts';
|
||||
|
||||
class StorageClassInstance extends ResourceInstance<V1StorageClass> {}
|
||||
|
||||
export { StorageClassInstance };
|
||||
12
src/instances/virtual-service.ts
Normal file
12
src/instances/virtual-service.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { KubernetesObject } from '@kubernetes/client-node';
|
||||
|
||||
import { ResourceInstance } from '../services/resources/resources.instance.ts';
|
||||
import type { K8SVirtualServiceV1 } from '../__generated__/resources/K8SVirtualServiceV1.ts';
|
||||
|
||||
class VirtualServiceInstance extends ResourceInstance<KubernetesObject & K8SVirtualServiceV1> {
|
||||
public get ready() {
|
||||
return this.exists;
|
||||
}
|
||||
}
|
||||
|
||||
export { VirtualServiceInstance };
|
||||
@@ -10,7 +10,7 @@ type AuthentikServerInfo = {
|
||||
|
||||
type UpsertClientRequest = {
|
||||
name: string;
|
||||
secret: string;
|
||||
secret?: string;
|
||||
scopes?: string[];
|
||||
flows?: {
|
||||
authorization: string;
|
||||
|
||||
@@ -179,6 +179,20 @@ abstract class CustomResource<TSpec extends ZodObject> extends EventEmitter<Cust
|
||||
}
|
||||
};
|
||||
|
||||
public markNotReady = async (reason?: string, message?: string) => {
|
||||
await this.conditions.set('Ready', {
|
||||
status: 'False',
|
||||
reason,
|
||||
message,
|
||||
});
|
||||
};
|
||||
|
||||
public markReady = async () => {
|
||||
await this.conditions.set('Ready', {
|
||||
status: 'True',
|
||||
});
|
||||
};
|
||||
|
||||
public patchStatus = async (status: Partial<CustomResourceStatus>) => {
|
||||
const k8s = this.services.get(K8sService);
|
||||
const [group, version] = this.apiVersion?.split('/') || [];
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import type { V1Deployment } from '@kubernetes/client-node';
|
||||
|
||||
import type { Services } from '../../utils/service.ts';
|
||||
import { ResourceReference } from '../resources/resources.ref.ts';
|
||||
import type { Watcher } from '../watchers/watchers.watcher.ts';
|
||||
import { WatcherService } from '../watchers/watchers.ts';
|
||||
import type { Resource } from '../resources/resources.ts';
|
||||
|
||||
const ISTIO_APP_SELECTOR = 'istio=gateway-controller';
|
||||
|
||||
class IstioService {
|
||||
#gatewayResource: ResourceReference<V1Deployment>;
|
||||
#gatewayWatcher: Watcher<V1Deployment>;
|
||||
|
||||
constructor(services: Services) {
|
||||
this.#gatewayResource = new ResourceReference<V1Deployment>();
|
||||
const watcherService = services.get(WatcherService);
|
||||
this.#gatewayWatcher = watcherService.create({
|
||||
path: '/apis/apps/v1/deployments',
|
||||
list: async (k8s) => {
|
||||
return await k8s.apps.listDeploymentForAllNamespaces({
|
||||
labelSelector: ISTIO_APP_SELECTOR,
|
||||
});
|
||||
},
|
||||
transform: (manifest) => ({
|
||||
apiVersion: 'apps/v1',
|
||||
kind: 'Deployment',
|
||||
...manifest,
|
||||
}),
|
||||
verbs: ['add', 'update', 'delete'],
|
||||
});
|
||||
this.#gatewayWatcher.on('changed', this.#handleChange);
|
||||
}
|
||||
|
||||
#handleChange = (resource: Resource<V1Deployment>) => {
|
||||
this.#gatewayResource.current = resource;
|
||||
};
|
||||
|
||||
public get gateway() {
|
||||
return this.#gatewayResource;
|
||||
}
|
||||
|
||||
public start = async () => {
|
||||
await this.#gatewayWatcher.start();
|
||||
};
|
||||
}
|
||||
|
||||
export { IstioService };
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user