Compare commits

..

20 Commits

Author SHA1 Message Date
mortenolsenzn
683de402ff Merge pull request #1 from morten-olsen/rewrite2
Rewrite2
2025-09-03 12:24:40 +02:00
Morten Olsen
e8e939ad19 fixes 2025-08-22 11:44:53 +02:00
Morten Olsen
1b5b5145b0 stuff 2025-08-22 07:35:50 +02:00
Morten Olsen
cfd2d76873 more 2025-08-20 22:45:30 +02:00
Morten Olsen
9e5081ed9b updates 2025-08-20 14:58:34 +02:00
Morten Olsen
3ab2b1969a stuff 2025-08-19 22:05:41 +02:00
Morten Olsen
a27b563113 rewrite2 2025-08-18 08:02:48 +02:00
Morten Olsen
295472a028 update 2025-08-15 22:01:18 +02:00
Morten Olsen
91298b3cf7 update 2025-08-15 21:20:23 +02:00
Morten Olsen
638c288a5c update 2025-08-15 20:52:17 +02:00
Morten Olsen
2be6bdca84 update 2025-08-15 20:45:28 +02:00
Morten Olsen
f362f4afc4 fix: missing permissions 2025-08-13 09:01:30 +02:00
Morten Olsen
9fadbf75fb publish operator yaml 2025-08-13 08:50:17 +02:00
Morten Olsen
2add15d283 fix: authentik port 2025-08-12 23:25:03 +02:00
Morten Olsen
5426495be5 updates 2025-08-12 23:22:47 +02:00
Morten Olsen
b8bb16ccbb updates 2025-08-12 22:32:09 +02:00
Morten Olsen
d4b56007f1 add authentik connection crd 2025-08-12 08:36:29 +02:00
Morten Olsen
130bfec468 fix reconciliation of db 2025-08-11 20:00:01 +02:00
Morten Olsen
ddb3c79657 fix pg db 2025-08-11 15:00:06 +02:00
Morten Olsen
47cf43b44e Added storage provisioner 2025-08-11 12:07:36 +02:00
110 changed files with 3403 additions and 61387 deletions

View File

@@ -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

4
.gitignore vendored
View File

@@ -33,4 +33,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Finder (MacOS) folder config
.DS_Store
/data/
/data/
/cloudflare.yaml

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.13

View File

@@ -1,6 +1,6 @@
FROM node:23-alpine
FROM node:23-slim
RUN corepack enable
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod
COPY . .
CMD ["node", "src/index.ts"]
CMD ["node", "src/index.ts"]

View File

@@ -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 --docker" # --mount ${PWD}/data:/data:w
flux install --components="source-controller,helm-controller"
dev-recreate: dev-destroy dev-create setup
setup-flux:
flux install --components="source-controller,helm-controller"
server-install:
curl -sfL https://get.k3s.io | sh -s - --disable traefik,local-storage,helm-controller

View File

@@ -1,6 +0,0 @@
## Bootstrap repo
```
brew install fluxcd/tap/flux
make setup-server
```

View File

@@ -1 +0,0 @@
- Fix issue with incompatible spec breaking the server

View File

@@ -1,19 +0,0 @@
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
annotations:
argocd.argoproj.io/sync-options: SkipDryRunOnMissingResource=true
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: alice@alice.com
privateKeySecretRef:
name: letsencrypt-prod-account-key
solvers:
- dns01:
cloudflare:
email: alice@alice.com
apiTokenSecretRef:
name: cloudflare-api-token
key: api-token

View File

@@ -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"]

View File

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

View File

@@ -0,0 +1,9 @@
apiVersion: homelab.mortenolsen.pro/v1
kind: OidcClient
metadata:
name: '{{ .Release.Name }}'
spec:
environment: '{{ .Values.environment }}'
redirectUris:
- url: https://localhost:3000/api/v1/authentik/oauth2/callback
matchingMode: strict

View File

@@ -0,0 +1,11 @@
apiVersion: homelab.mortenolsen.pro/v1
kind: ExternalHttpService
metadata:
name: '{{ .Release.Name }}'
spec:
environment: '{{ .Values.environment }}'
subdomain: '{{ .Values.subdomain }}-external'
destination:
host: '{{ .Release.Name }}.{{ .Release.Namespace }}.svc.cluster.local'
port:
number: 80

View File

@@ -0,0 +1,13 @@
apiVersion: v1
kind: Service
metadata:
name: '{{ .Release.Name }}-headless'
labels:
app: '{{ .Release.Name }}'
spec:
clusterIP: None
ports:
- port: 5000
name: http
selector:
app: '{{ .Release.Name }}'

View File

@@ -0,0 +1,11 @@
apiVersion: homelab.mortenolsen.pro/v1
kind: HttpService
metadata:
name: '{{ .Release.Name }}'
spec:
environment: '{{ .Values.environment }}'
subdomain: '{{ .Values.subdomain }}'
destination:
host: '{{ .Release.Name }}'
port:
number: 80

View File

@@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: '{{ .Release.Name }}'
labels:
app: '{{ .Release.Name }}'
spec:
type: ClusterIP
ports:
- port: 80
targetPort: 5000
protocol: TCP
name: http
selector:
app: '{{ .Release.Name }}'

View File

@@ -0,0 +1,68 @@
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: '{{ .Release.Name }}'
labels:
app: '{{ .Release.Name }}'
spec:
serviceName: '{{ .Release.Name }}-headless'
replicas: 1
selector:
matchLabels:
app: '{{ .Release.Name }}'
template:
metadata:
labels:
app: '{{ .Release.Name }}'
spec:
containers:
- name: '{{ .Release.Name }}'
image: ghcr.io/jordan-dalby/bytestash:latest
ports:
- containerPort: 5000
name: http
env:
- name: OIDC_ENABLED
value: 'true'
- name: OIDC_DISPLAY_NAME
value: OIDC
- name: OIDC_CLIENT_ID
valueFrom:
secretKeyRef:
name: '{{ .Release.Name }}-client'
key: clientId
- name: OIDC_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: '{{ .Release.Name }}-client'
key: clientSecret
- name: OIDC_ISSUER_URL
valueFrom:
secretKeyRef:
name: '{{ .Release.Name }}-client'
key: configuration
# !! IMPORTANT !!
# You MUST update this Redirect URI to match your external URL.
# This URI must also be configured in your Authentik provider settings for this client.
#- name: BS_OIDC_REDIRECT_URI
#value: 'https://bytestash.your-domain.com/login/oauth2/code/oidc'
volumeMounts:
- mountPath: /data/snippets
name: bytestash-data
# Defines security context for the pod to avoid running as root.
# securityContext:
# runAsUser: 1000
# runAsGroup: 1000
# fsGroup: 1000
volumeClaimTemplates:
- metadata:
name: bytestash-data
spec:
accessModes: ['ReadWriteOnce']
storageClassName: '{{ .Values.environment }}'
resources:
requests:
storage: 5Gi

View File

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

View 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" }}

View 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"]

View File

@@ -33,6 +33,14 @@ spec:
imagePullPolicy: {{ .Values.image.pullPolicy }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
volumeMounts:
- name: data-volumes
mountPath: {{ .Values.storage.path }}
volumes:
- name: data-volumes
hostPath:
path: {{ .Values.storage.path }}
type: DirectoryOrCreate
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}

View File

@@ -4,13 +4,19 @@
image:
repository: ghcr.io/morten-olsen/homelab-operator
pullPolicy: Always
pullPolicy: IfNotPresent
# Overrides the image tag whose default is the chart appVersion.
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: {}
@@ -50,4 +56,4 @@ nodeSelector: {}
tolerations: []
affinity: {}
affinity: {}

View File

@@ -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

View File

@@ -1,901 +0,0 @@
# Writing Custom Resources
This guide explains how to create and implement custom resources in the
homelab-operator.
## Overview
Custom resources in this operator follow a structured pattern that includes:
- **Specification schemas** using Zod for runtime validation
- **Resource implementations** that extend the base `CustomResource` class
- **Manifest creation** helpers for generating Kubernetes resources
- **Reconciliation logic** to manage the desired state
## Project Structure
Each custom resource should be organized in its own directory under
`src/custom-resouces/` with the following structure:
```
src/custom-resouces/{resource-name}/
├── {resource-name}.ts # Main definition file
├── {resource-name}.schemas.ts # Zod validation schemas
├── {resource-name}.resource.ts # Resource implementation
└── {resource-name}.create-manifests.ts # Manifest generation helpers
```
## Quick Start
This section walks through creating a complete custom resource from scratch.
We'll build a `MyResource` that manages a web application with a deployment and
service.
### 1. Define Your Resource
The main definition file registers your custom resource with the operator
framework. This file serves as the entry point that ties together your schemas,
implementation, and Kubernetes CRD definition.
Create the main definition file (`{resource-name}.ts`):
```typescript
import { createCustomResourceDefinition } from "../../services/custom-resources/custom-resources.ts";
import { GROUP } from "../../utils/consts.ts";
import { MyResourceResource } from "./my-resource.resource.ts";
import { myResourceSpecSchema } from "./my-resource.schemas.ts";
const myResourceDefinition = createCustomResourceDefinition({
group: GROUP, // Uses your operator's API group (homelab.mortenolsen.pro)
version: "v1", // API version for this resource
kind: "MyResource", // The Kubernetes kind name (PascalCase)
names: {
plural: "myresources", // Plural name for kubectl (lowercase)
singular: "myresource", // Singular name for kubectl (lowercase)
},
spec: myResourceSpecSchema, // Zod schema for validation
create: (options) => new MyResourceResource(options), // Factory function
});
export { myResourceDefinition };
```
**Key Points:**
- The `group` should always use the `GROUP` constant to maintain consistency
- `kind` should be descriptive and follow Kubernetes naming conventions
(PascalCase)
- `names.plural` is used in kubectl commands (`kubectl get myresources`)
- The `create` function instantiates your resource implementation when a CR is
detected
### 2. Create Validation Schemas
Schemas define the structure and validation rules for your custom resource's
specification. Using Zod provides runtime type safety and automatic validation
of user input.
Define your spec schema (`{resource-name}.schemas.ts`):
```typescript
import { z } from "zod";
const myResourceSpecSchema = z.object({
// Required fields - these must be provided by users
hostname: z.string(), // Base hostname for the application
port: z.number().min(1).max(65535), // Container port (validated range)
// Optional fields with defaults - provide sensible fallbacks
replicas: z.number().min(1).default(1), // Number of pod replicas
// Enums - restrict to specific values with defaults
protocol: z.enum(["http", "https"]).default("https"),
// Nested objects - for complex configuration
database: z.object({
host: z.string(), // Database hostname
port: z.number(), // Database port
name: z.string(), // Database name
}).optional(), // Entire database config is optional
});
// Additional schemas for secrets, status, etc.
// Separate schemas help organize different data types
const myResourceSecretSchema = z.object({
apiKey: z.string(), // API key for external services
password: z.string(), // Database or service password
});
export { myResourceSecretSchema, myResourceSpecSchema };
```
**Schema Design Best Practices:**
- **Required vs Optional**: Make fields required only when absolutely necessary
- **Defaults**: Provide sensible defaults to reduce user configuration burden
- **Validation**: Use Zod's built-in validators (`.min()`, `.max()`, `.email()`,
etc.)
- **Enums**: Restrict values to prevent invalid configurations
- **Nested Objects**: Group related configuration together
- **Separate Schemas**: Create different schemas for different purposes (spec,
secrets, status)
### 3. Implement the Resource
The resource implementation is the core of your custom resource. It contains the
business logic for managing Kubernetes resources and maintains the desired
state. This class extends `CustomResource` and implements the reconciliation
logic.
Create the resource implementation (`{resource-name}.resource.ts`):
```typescript
import type { KubernetesObject } from "@kubernetes/client-node";
import deepEqual from "deep-equal";
import {
CustomResource,
type CustomResourceOptions,
type SubresourceResult,
} from "../../services/custom-resources/custom-resources.custom-resource.ts";
import {
ResourceReference,
ResourceService,
} from "../../services/resources/resources.ts";
import type { myResourceSpecSchema } from "./my-resource.schemas.ts";
import {
createDeploymentManifest,
createServiceManifest,
} from "./my-resource.create-manifests.ts";
class MyResourceResource extends CustomResource<typeof myResourceSpecSchema> {
#deploymentResource = new ResourceReference();
#serviceResource = new ResourceReference();
constructor(options: CustomResourceOptions<typeof myResourceSpecSchema>) {
super(options);
const resourceService = this.services.get(ResourceService);
// Initialize resource references
this.#deploymentResource.current = resourceService.get({
apiVersion: "apps/v1",
kind: "Deployment",
name: this.name,
namespace: this.namespace,
});
this.#serviceResource.current = resourceService.get({
apiVersion: "v1",
kind: "Service",
name: this.name,
namespace: this.namespace,
});
// Set up event handlers for reconciliation
this.#deploymentResource.on("changed", this.queueReconcile);
this.#serviceResource.on("changed", this.queueReconcile);
}
#reconcileDeployment = async (): Promise<SubresourceResult> => {
const manifest = createDeploymentManifest({
name: this.name,
namespace: this.namespace,
ref: this.ref,
spec: this.spec,
});
if (!this.#deploymentResource.current?.exists) {
await this.#deploymentResource.current?.patch(manifest);
return {
ready: false,
syncing: true,
reason: "Creating",
message: "Creating deployment",
};
}
if (!deepEqual(this.#deploymentResource.current.spec, manifest.spec)) {
await this.#deploymentResource.current.patch(manifest);
return {
ready: false,
syncing: true,
reason: "Updating",
message: "Deployment needs updates",
};
}
// Check if deployment is ready
const deployment = this.#deploymentResource.current;
const isReady =
deployment.status?.readyReplicas === deployment.status?.replicas;
return {
ready: isReady,
reason: isReady ? "Ready" : "Pending",
message: isReady ? "Deployment is ready" : "Waiting for pods to be ready",
};
};
#reconcileService = async (): Promise<SubresourceResult> => {
const manifest = createServiceManifest({
name: this.name,
namespace: this.namespace,
ref: this.ref,
spec: this.spec,
});
if (!deepEqual(this.#serviceResource.current?.spec, manifest.spec)) {
await this.#serviceResource.current?.patch(manifest);
return {
ready: false,
syncing: true,
reason: "Updating",
message: "Service needs updates",
};
}
return { ready: true };
};
public reconcile = async () => {
if (!this.exists || this.metadata.deletionTimestamp) {
return;
}
// Reconcile subresources
await this.reconcileSubresource("Deployment", this.#reconcileDeployment);
await this.reconcileSubresource("Service", this.#reconcileService);
// Update overall ready condition
const deploymentReady =
this.conditions.get("Deployment")?.status === "True";
const serviceReady = this.conditions.get("Service")?.status === "True";
await this.conditions.set("Ready", {
status: deploymentReady && serviceReady ? "True" : "False",
reason: deploymentReady && serviceReady ? "Ready" : "Pending",
message: deploymentReady && serviceReady
? "All resources are ready"
: "Waiting for resources to be ready",
});
};
}
export { MyResourceResource };
```
**Resource Implementation Breakdown:**
**Constructor Setup:**
- **Resource References**: Create `ResourceReference` objects to track managed
Kubernetes resources
- **Service Access**: Use dependency injection to access operator services
(`ResourceService`)
- **Event Handlers**: Listen for changes in managed resources to trigger
reconciliation
- **Resource Registration**: Register references for Deployment and Service that
will be managed
**Reconciliation Methods:**
- **`#reconcileDeployment`**: Manages the application's Deployment resource
- Creates manifests using helper functions
- Checks if resource exists and creates/updates as needed
- Uses `deepEqual` to avoid unnecessary updates
- Returns status indicating readiness state
- **`#reconcileService`**: Manages the Service resource for network access
- Similar pattern to deployment but typically simpler
- Services are usually ready immediately after creation
**Main Reconcile Loop:**
- **Deletion Check**: Early return if resource is being deleted
- **Subresource Management**: Calls individual reconciliation methods
- **Condition Updates**: Aggregates status from all subresources
- **Status Reporting**: Updates the overall "Ready" condition
**Key Design Patterns:**
- **Private Methods**: Use `#` for private reconciliation methods
- **Async/Await**: All reconciliation is asynchronous
- **Resource References**: Track external resources with type safety
- **Condition Management**: Provide clear status through Kubernetes conditions
- **Event-Driven**: React to changes in managed resources automatically
### 4. Create Manifest Helpers
Manifest helpers are pure functions that generate Kubernetes resource
definitions. They transform your custom resource's specification into standard
Kubernetes objects. This separation keeps your reconciliation logic clean and
makes manifests easy to test and modify.
Define manifest creation functions (`{resource-name}.create-manifests.ts`):
```typescript
type CreateDeploymentManifestOptions = {
name: string;
namespace: string;
ref: any; // Owner reference
spec: {
hostname: string;
port: number;
replicas: number;
};
};
const createDeploymentManifest = (
options: CreateDeploymentManifestOptions,
) => ({
apiVersion: "apps/v1",
kind: "Deployment",
metadata: {
name: options.name,
namespace: options.namespace,
ownerReferences: [options.ref],
},
spec: {
replicas: options.spec.replicas,
selector: {
matchLabels: {
app: options.name,
},
},
template: {
metadata: {
labels: {
app: options.name,
},
},
spec: {
containers: [
{
name: options.name,
image: "nginx:latest",
ports: [
{
containerPort: options.spec.port,
},
],
env: [
{
name: "HOSTNAME",
value: options.spec.hostname,
},
],
},
],
},
},
},
});
type CreateServiceManifestOptions = {
name: string;
namespace: string;
ref: any;
spec: {
port: number;
};
};
const createServiceManifest = (options: CreateServiceManifestOptions) => ({
apiVersion: "v1",
kind: "Service",
metadata: {
name: options.name,
namespace: options.namespace,
ownerReferences: [options.ref],
},
spec: {
selector: {
app: options.name,
},
ports: [
{
port: 80,
targetPort: options.spec.port,
},
],
},
});
export { createDeploymentManifest, createServiceManifest };
```
**Manifest Helper Patterns:**
**Type Definitions:**
- **Options Types**: Define clear interfaces for function parameters
- **Structured Input**: Group related parameters in nested objects
- **Type Safety**: Leverage TypeScript to catch configuration errors at compile
time
**Deployment Manifest:**
- **Owner References**: Ensures garbage collection when parent resource is
deleted
- **Labels & Selectors**: Consistent labeling for pod selection and organization
- **Container Configuration**: Maps custom resource spec to container settings
- **Environment Variables**: Passes configuration from spec to running
containers
- **Port Configuration**: Exposes application ports based on spec
**Service Manifest:**
- **Service Discovery**: Creates stable network endpoint for the deployment
- **Port Mapping**: Routes external traffic to container ports
- **Selector Matching**: Uses same labels as deployment for proper routing
- **Owner References**: Links service lifecycle to custom resource
**Best Practices for Manifest Helpers:**
- **Pure Functions**: No side effects, same input always produces same output
- **Immutable Objects**: Return new objects rather than modifying inputs
- **Validation**: Let TypeScript catch type mismatches
- **Consistent Naming**: Use predictable patterns for resource names
- **Owner References**: Always set for proper cleanup
- **Documentation**: Comment non-obvious configuration choices
### 5. Register Your Resource
Add your resource to `src/custom-resouces/custom-resources.ts`:
```typescript
import { myResourceDefinition } from "./my-resource/my-resource.ts";
const customResources = [
// ... existing resources
myResourceDefinition,
];
```
## Core Concepts
These fundamental patterns are used throughout the operator framework.
Understanding them is essential for building robust custom resources.
### Resource References
`ResourceReference` objects provide a strongly-typed way to track and manage
Kubernetes resources that your custom resource creates or depends on. They
automatically handle resource watching, caching, and change notifications.
Use `ResourceReference` to manage related Kubernetes resources:
```typescript
import {
ResourceReference,
ResourceService,
} from "../../services/resources/resources.ts";
class MyResource extends CustomResource<typeof myResourceSpecSchema> {
#deploymentResource = new ResourceReference();
constructor(options: CustomResourceOptions<typeof myResourceSpecSchema>) {
super(options);
const resourceService = this.services.get(ResourceService);
this.#deploymentResource.current = resourceService.get({
apiVersion: "apps/v1",
kind: "Deployment",
name: this.name,
namespace: this.namespace,
});
// Listen for changes
this.#deploymentResource.on("changed", this.queueReconcile);
}
}
```
**Why Resource References Matter:**
- **Automatic Watching**: Changes to referenced resources trigger reconciliation
- **Type Safety**: Get compile-time checking for resource properties
- **Lifecycle Management**: Easily check if resources exist and their current
state
- **Event Handling**: React to external changes without polling
- **Caching**: Avoid repeated API calls for the same resource data
### Conditions
Kubernetes conditions provide a standardized way to communicate resource status.
They follow the Kubernetes convention of expressing current state, reasons for
that state, and human-readable messages. Conditions are crucial for operators
and users to understand what's happening with resources.
Use conditions to track the status of your resource:
```typescript
// Set a condition
await this.conditions.set("Ready", {
status: "True",
reason: "AllResourcesReady",
message: "All subresources are ready",
});
// Get a condition
const isReady = this.conditions.get("Ready")?.status === "True";
```
**Condition Best Practices:**
- **Standard Names**: Use common condition types like "Ready", "Available",
"Progressing"
- **Clear Status**: Use "True", "False", or "Unknown" following Kubernetes
conventions
- **Descriptive Reasons**: Provide specific reason codes for troubleshooting
- **Helpful Messages**: Include actionable information for users
- **Consistent Updates**: Always update conditions during reconciliation
### Subresource Reconciliation
The `reconcileSubresource` method provides a standardized way to manage
individual components of your custom resource. It automatically handles
condition updates, error management, and status aggregation. This pattern keeps
your main reconciliation loop clean and ensures consistent error handling.
Use `reconcileSubresource` to manage individual components:
```typescript
public reconcile = async () => {
// This automatically manages conditions and error handling
await this.reconcileSubresource("Deployment", this.#reconcileDeployment);
await this.reconcileSubresource("Service", this.#reconcileService);
};
```
**Subresource Reconciliation Benefits:**
- **Automatic Condition Management**: Sets conditions based on reconciliation
results
- **Error Isolation**: Failures in one subresource don't stop others
- **Status Aggregation**: Combines individual component status into overall
status
- **Consistent Patterns**: Same error handling and retry logic across all
components
- **Observability**: Clear visibility into which components are having issues
### Deep Equality Checks
Deep equality checks prevent unnecessary API calls and resource churn.
Kubernetes resources should only be updated when their desired state actually
differs from their current state. This improves performance and reduces cluster
load.
Use `deepEqual` to avoid unnecessary updates:
```typescript
import deepEqual from "deep-equal";
if (!deepEqual(currentResource.spec, desiredManifest.spec)) {
await currentResource.patch(desiredManifest);
}
```
**Deep Equality Benefits:**
- **Performance**: Avoids unnecessary API calls to Kubernetes
- **Reduced Churn**: Prevents resource version conflicts and unnecessary events
- **Stability**: Reduces reconciliation loops and system noise
- **Efficiency**: Lets you focus compute on actual changes
- **Observability**: Cleaner audit logs with only meaningful changes
**When to Use Deep Equality:**
- **Spec Comparisons**: Before updating any Kubernetes resource
- **Status Updates**: Only update status when values actually change
- **Metadata Updates**: Check labels and annotations before patching
- **Complex Objects**: Especially useful for nested configuration objects
## Advanced Patterns
These patterns handle more complex scenarios like secret management, resource
dependencies, and sophisticated error handling. Use these when building
production-ready operators that need to handle real-world complexity.
### Working with Secrets
Many resources need to manage secrets. Here's a pattern for secret management:
```typescript
import { SecretService } from "../../services/secrets/secrets.ts";
class MyResource extends CustomResource<typeof myResourceSpecSchema> {
constructor(options: CustomResourceOptions<typeof myResourceSpecSchema>) {
super(options);
const secretService = this.services.get(SecretService);
// Get or create a secret
this.secretRef = secretService.get({
name: `${this.name}-secret`,
namespace: this.namespace,
});
}
#ensureSecret = async () => {
const secretData = {
apiKey: generateApiKey(),
password: generatePassword(),
};
if (!this.secretRef.current?.exists) {
await this.secretRef.current?.patch({
apiVersion: "v1",
kind: "Secret",
metadata: {
name: this.secretRef.current.name,
namespace: this.secretRef.current.namespace,
ownerReferences: [this.ref],
},
data: secretData,
});
}
};
}
```
### Cross-Resource Dependencies
When your resource depends on other custom resources:
```typescript
class MyResource extends CustomResource<typeof myResourceSpecSchema> {
#dependentResource = new ResourceReference();
constructor(options: CustomResourceOptions<typeof myResourceSpecSchema>) {
super(options);
const resourceService = this.services.get(ResourceService);
// Reference another custom resource
this.#dependentResource.current = resourceService.get({
apiVersion: "homelab.mortenolsen.pro/v1",
kind: "PostgresDatabase",
name: this.spec.database,
namespace: this.namespace,
});
this.#dependentResource.on("changed", this.queueReconcile);
}
#reconcileApp = async (): Promise<SubresourceResult> => {
// Check if dependency is ready
const dependency = this.#dependentResource.current;
if (!dependency?.exists) {
return {
ready: false,
failed: true,
reason: "MissingDependency",
message: `PostgresDatabase ${this.spec.database} not found`,
};
}
const dependencyReady = dependency.status?.conditions?.find(
(c) => c.type === "Ready" && c.status === "True",
);
if (!dependencyReady) {
return {
ready: false,
reason: "WaitingForDependency",
message:
`Waiting for PostgresDatabase ${this.spec.database} to be ready`,
};
}
// Continue with reconciliation...
};
}
```
### Error Handling
Proper error handling in reconciliation:
```typescript
#reconcileDeployment = async (): Promise<SubresourceResult> => {
try {
// Reconciliation logic...
return { ready: true };
} catch (error) {
return {
ready: false,
failed: true,
reason: 'ReconciliationError',
message: `Failed to reconcile deployment: ${error.message}`,
};
}
};
```
## Example Usage
Once your custom resource is implemented and registered, users can create
instances using standard Kubernetes manifests. The operator will automatically
detect new resources and begin reconciliation based on your implementation
logic.
```yaml
apiVersion: homelab.mortenolsen.pro/v1
kind: MyResource
metadata:
name: my-app
namespace: default
spec:
hostname: my-app.example.com
port: 8080
replicas: 3
protocol: https
database:
host: postgres.default.svc.cluster.local
port: 5432
name: myapp
```
**What happens when this resource is created:**
1. **Validation**: The operator validates the spec against your Zod schema
2. **Resource Creation**: Your `MyResourceResource` class is instantiated
3. **Reconciliation**: The operator creates a Deployment with 3 replicas and a
Service
4. **Status Updates**: Conditions are set to track deployment and service
readiness
5. **Event Handling**: The operator watches for changes and re-reconciles as
needed
Users can then monitor the resource status with:
```bash
kubectl get myresources my-app -o yaml
kubectl describe myresource my-app
```
## Real Examples
These examples show how the patterns described above are used in practice within
the homelab-operator.
### Simple Resource: Domain
The `Domain` resource demonstrates a straightforward custom resource that
manages external dependencies. It creates and manages TLS certificates through
cert-manager and configures Istio gateways for HTTPS traffic routing.
**What it does:**
- Creates a cert-manager Certificate for TLS termination
- Configures an Istio Gateway for traffic routing
- Manages the lifecycle of both resources through owner references
- Provides wildcard certificate support for subdomains
```yaml
apiVersion: homelab.mortenolsen.pro/v1
kind: Domain
metadata:
name: homelab
namespace: homelab
spec:
hostname: local.olsen.cloud # Domain for certificate and gateway
issuer: letsencrypt-prod # cert-manager ClusterIssuer to use
```
**Key Implementation Features:**
- **CRD Dependency Checking**: Validates that cert-manager and Istio CRDs exist
- **Cross-Namespace Resources**: Certificate is created in the istio-ingress
namespace
- **Status Aggregation**: Combines certificate and gateway readiness into
overall status
- **Wildcard Support**: Automatically configures `*.hostname` for subdomains
### Complex Resource: AuthentikServer
The `AuthentikServer` resource showcases a complex custom resource with multiple
dependencies and sophisticated reconciliation logic. It deploys a complete
identity provider solution with database and Redis dependencies.
**What it does:**
- Deploys Authentik identity provider with proper configuration
- Manages database schema and user creation
- Configures Redis connection for session storage
- Sets up domain integration for SSO endpoints
- Handles secret generation and rotation
```yaml
apiVersion: homelab.mortenolsen.pro/v1
kind: AuthentikServer
metadata:
name: homelab
namespace: homelab
spec:
domain: homelab # References a Domain resource
database: test2 # References a PostgresDatabase resource
redis: redis # References a Redis connection
```
**Key Implementation Features:**
- **Resource Dependencies**: Waits for Domain, PostgresDatabase, and Redis
resources
- **Secret Management**: Generates and manages API keys, passwords, and tokens
- **Service Configuration**: Creates comprehensive Kubernetes manifests
(Deployment, Service, Ingress)
- **Health Checking**: Monitors application readiness and database connectivity
- **Cross-Resource Communication**: Uses other custom resources' status and
outputs
### Database Resource: PostgresDatabase
The `PostgresDatabase` resource illustrates how to manage stateful resources and
external system integration. It creates databases within an existing PostgreSQL
instance and manages user permissions.
**What it does:**
- Creates a new database in an existing PostgreSQL server
- Generates dedicated database user with appropriate permissions
- Manages connection secrets for applications
- Handles database cleanup and user removal
```yaml
apiVersion: homelab.mortenolsen.pro/v1
kind: PostgresDatabase
metadata:
name: test2
namespace: homelab
spec:
connection: homelab/db # References PostgreSQL connection (namespace/name)
```
**Key Implementation Features:**
- **External System Integration**: Connects to existing PostgreSQL instances
- **User Management**: Creates database-specific users with minimal required
permissions
- **Secret Generation**: Provides connection details to consuming applications
- **Cleanup Handling**: Safely removes databases and users when resource is
deleted
- **Connection Validation**: Verifies connectivity before marking as ready
**Common Patterns Across Examples:**
- **Owner References**: All managed resources have proper ownership for garbage
collection
- **Condition Management**: Consistent status reporting through Kubernetes
conditions
- **Resource Dependencies**: Graceful handling of missing or unready
dependencies
- **Secret Management**: Secure generation and storage of credentials
- **Cross-Resource Integration**: Resources reference and depend on each other
appropriately
## Best Practices
1. **Validation**: Always use Zod schemas for comprehensive spec validation
2. **Idempotency**: Use `deepEqual` checks to avoid unnecessary updates
3. **Conditions**: Provide clear status information through conditions
4. **Owner References**: Always set owner references for created resources
5. **Error Handling**: Provide meaningful error messages and failure reasons
6. **Dependencies**: Handle missing dependencies gracefully
7. **Cleanup**: Leverage Kubernetes garbage collection through owner references
8. **Testing**: Create test manifests in `test-manifests/` for your resources
## Troubleshooting
- **Resource not reconciling**: Check if the resource is properly registered in
`custom-resources.ts`
- **Validation errors**: Ensure your Zod schema matches the expected spec
structure
- **Missing dependencies**: Verify that referenced resources exist and are ready
- **Owner reference issues**: Make sure `ownerReferences` are set correctly for
garbage collection
- **Condition not updating**: Ensure you're calling `this.conditions.set()` with
proper status values
For more examples, refer to the existing custom resources in
`src/custom-resouces/`.

9
manifests/client.yaml Normal file
View File

@@ -0,0 +1,9 @@
apiVersion: homelab.mortenolsen.pro/v1
kind: OidcClient
metadata:
name: test-client
spec:
environment: dev
redirectUris:
- url: https://localhost:3000/api/v1/authentik/oauth2/callback
matchingMode: strict

View File

@@ -0,0 +1,14 @@
apiVersion: v1
kind: Namespace
metadata:
name: dev
---
apiVersion: homelab.mortenolsen.pro/v1
kind: Environment
metadata:
name: dev
spec:
domain: one.dev.olsen.cloud
networkIp: 192.168.107.2
tls:
issuer: lets-encrypt-prod

View 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

View File

@@ -0,0 +1,14 @@
apiVersion: networking.istio.io/v1alpha3
kind: ServiceEntry
metadata:
name: test-example-com
namespace: dev
spec:
hosts:
- authentik.one.dev.olsen.cloud
# (the address field is optional if you use 'resolution: DNS')
ports:
- number: 80
name: https
protocol: HTTPS
resolution: DNS

35
operator.yaml Normal file
View 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

View File

@@ -22,6 +22,8 @@
"dependencies": {
"@goauthentik/api": "2025.6.3-1751754396",
"@kubernetes/client-node": "^1.3.0",
"cloudflare": "^4.5.0",
"cron": "^4.3.3",
"debounce": "^2.2.0",
"deep-equal": "^2.2.3",
"dotenv": "^17.2.1",
@@ -35,6 +37,12 @@
"yaml": "^2.8.0",
"zod": "^4.0.14"
},
"imports": {
"#services/*": "./src/services/*",
"#resources/*": "./src/resources/*",
"#bootstrap/*": "./src/bootstrap/*",
"#utils/*": "./src/utils/*"
},
"packageManager": "pnpm@10.6.0",
"pnpm": {
"onlyBuiltDependencies": [

96
pnpm-lock.yaml generated
View File

@@ -14,6 +14,12 @@ importers:
'@kubernetes/client-node':
specifier: ^1.3.0
version: 1.3.0(encoding@0.1.13)
cloudflare:
specifier: ^4.5.0
version: 4.5.0(encoding@0.1.13)
cron:
specifier: ^4.3.3
version: 4.3.3
debounce:
specifier: ^2.2.0
version: 2.2.0
@@ -229,9 +235,15 @@ packages:
'@types/lodash@4.17.20':
resolution: {integrity: sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==}
'@types/luxon@3.7.1':
resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==}
'@types/node-fetch@2.6.12':
resolution: {integrity: sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==}
'@types/node@18.19.123':
resolution: {integrity: sha512-K7DIaHnh0mzVxreCR9qwgNxp3MH9dltPNIEddW9MYUlcKAzm+3grKNSTe2vCJHI1FaLpvpL5JGJrz1UZDKYvDg==}
'@types/node@22.16.5':
resolution: {integrity: sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ==}
@@ -303,6 +315,10 @@ packages:
abbrev@1.1.1:
resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
abort-controller@3.0.0:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'}
acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
@@ -479,6 +495,9 @@ packages:
resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==}
engines: {node: '>=6'}
cloudflare@4.5.0:
resolution: {integrity: sha512-fPcbPKx4zF45jBvQ0z7PCdgejVAPBBCZxwqk1k7krQNfpM07Cfj97/Q6wBzvYqlWXx/zt1S9+m8vnfCe06umbQ==}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@@ -507,6 +526,10 @@ packages:
console-control-strings@1.1.0:
resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==}
cron@4.3.3:
resolution: {integrity: sha512-B/CJj5yL3sjtlun6RtYHvoSB26EmQ2NUmhq9ZiJSyKIM4K/fqfh9aelDFlIayD2YMeFZqWLi9hHV+c+pq2Djkw==}
engines: {node: '>=18.x'}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -754,6 +777,10 @@ packages:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
event-target-shim@5.0.1:
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
engines: {node: '>=6'}
eventemitter3@5.0.1:
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
@@ -825,10 +852,17 @@ packages:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'}
form-data-encoder@1.7.2:
resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==}
form-data@4.0.4:
resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
engines: {node: '>= 6'}
formdata-node@4.4.1:
resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==}
engines: {node: '>= 12.20'}
fs-constants@1.0.0:
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
@@ -1238,6 +1272,10 @@ packages:
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
engines: {node: '>=10'}
luxon@3.7.1:
resolution: {integrity: sha512-RkRWjA926cTvz5rAb1BqyWkKbbjzCGchDUIKMCUvNi17j6f6j8uHGDV82Aqcqtzd+icoYpELmG3ksgGiFNNcNg==}
engines: {node: '>=12'}
make-fetch-happen@9.1.0:
resolution: {integrity: sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==}
engines: {node: '>= 10'}
@@ -1339,6 +1377,11 @@ packages:
node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
node-domexception@1.0.0:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'}
deprecated: Use your platform's native DOMException instead
node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0}
@@ -1886,6 +1929,9 @@ packages:
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
engines: {node: '>= 0.4'}
undici-types@5.26.5:
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
@@ -1905,6 +1951,10 @@ packages:
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
web-streams-polyfill@4.0.0-beta.3:
resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==}
engines: {node: '>= 14'}
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
@@ -2129,11 +2179,17 @@ snapshots:
'@types/lodash@4.17.20': {}
'@types/luxon@3.7.1': {}
'@types/node-fetch@2.6.12':
dependencies:
'@types/node': 22.16.5
form-data: 4.0.4
'@types/node@18.19.123':
dependencies:
undici-types: 5.26.5
'@types/node@22.16.5':
dependencies:
undici-types: 6.21.0
@@ -2240,6 +2296,10 @@ snapshots:
abbrev@1.1.1:
optional: true
abort-controller@3.0.0:
dependencies:
event-target-shim: 5.0.1
acorn-jsx@5.3.2(acorn@8.15.0):
dependencies:
acorn: 8.15.0
@@ -2258,7 +2318,6 @@ snapshots:
agentkeepalive@4.6.0:
dependencies:
humanize-ms: 1.2.1
optional: true
aggregate-error@3.1.0:
dependencies:
@@ -2463,6 +2522,18 @@ snapshots:
clean-stack@2.2.0:
optional: true
cloudflare@4.5.0(encoding@0.1.13):
dependencies:
'@types/node': 18.19.123
'@types/node-fetch': 2.6.12
abort-controller: 3.0.0
agentkeepalive: 4.6.0
form-data-encoder: 1.7.2
formdata-node: 4.4.1
node-fetch: 2.7.0(encoding@0.1.13)
transitivePeerDependencies:
- encoding
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
@@ -2485,6 +2556,11 @@ snapshots:
console-control-strings@1.1.0:
optional: true
cron@4.3.3:
dependencies:
'@types/luxon': 3.7.1
luxon: 3.7.1
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -2828,6 +2904,8 @@ snapshots:
esutils@2.0.3: {}
event-target-shim@5.0.1: {}
eventemitter3@5.0.1: {}
execa@9.6.0:
@@ -2903,6 +2981,8 @@ snapshots:
dependencies:
is-callable: 1.2.7
form-data-encoder@1.7.2: {}
form-data@4.0.4:
dependencies:
asynckit: 0.4.0
@@ -2911,6 +2991,11 @@ snapshots:
hasown: 2.0.2
mime-types: 2.1.35
formdata-node@4.4.1:
dependencies:
node-domexception: 1.0.0
web-streams-polyfill: 4.0.0-beta.3
fs-constants@1.0.0: {}
fs-minipass@2.1.0:
@@ -3064,7 +3149,6 @@ snapshots:
humanize-ms@1.2.1:
dependencies:
ms: 2.1.3
optional: true
iconv-lite@0.6.3:
dependencies:
@@ -3329,6 +3413,8 @@ snapshots:
yallist: 4.0.0
optional: true
luxon@3.7.1: {}
make-fetch-happen@9.1.0:
dependencies:
agentkeepalive: 4.6.0
@@ -3440,6 +3526,8 @@ snapshots:
node-addon-api@7.1.1: {}
node-domexception@1.0.0: {}
node-fetch@2.7.0(encoding@0.1.13):
dependencies:
whatwg-url: 5.0.0
@@ -4098,6 +4186,8 @@ snapshots:
has-symbols: 1.1.0
which-boxed-primitive: 1.1.1
undici-types@5.26.5: {}
undici-types@6.21.0: {}
unicorn-magic@0.3.0: {}
@@ -4118,6 +4208,8 @@ snapshots:
util-deprecate@1.0.2: {}
web-streams-polyfill@4.0.0-beta.3: {}
webidl-conversions@3.0.1: {}
whatwg-url@5.0.0:

9
pyproject.toml Normal file
View File

@@ -0,0 +1,9 @@
[project]
name = "homelab-operator"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"kubediagrams>=0.5.0",
]

View File

@@ -1,4 +0,0 @@
for f in "./test-manifests/"*; do
echo "Applying $f"
kubectl apply -f "$f"
done

View File

@@ -1,20 +0,0 @@
#!/bin/bash
# Load environment variables from .env file
if [ -f .env ]; then
export $(cat .env | grep -v '#' | awk '/=/ {print $1}')
fi
# Check if CLOUDFLARE_API_KEY is set
if [ -z "${CLOUDFLARE_API_KEY}" ]; then
echo "Error: CLOUDFLARE_API_KEY is not set. Please add it to your .env file."
exit 1
fi
# Create the postgres namespace if it doesn't exist
kubectl get namespace postgres > /dev/null 2>&1 || kubectl create namespace postgres
# Create the secret
kubectl create secret generic cloudflare-api-token \
--namespace cert-manager \
--from-literal=api-token="${CLOUDFLARE_API_KEY}"

View File

@@ -1,3 +0,0 @@
#!/bin/bash
flux install --components="source-controller,helm-controller"
kubectl create namespace homelab

31
skaffold.yaml Normal file
View File

@@ -0,0 +1,31 @@
apiVersion: skaffold/v4beta7
kind: Config
metadata:
name: homelab-operator
build:
# This tells Skaffold to build the image locally using your Docker daemon.
local:
push: false
# This is the crucial part for your workflow. Instead of pushing to a
# registry, it loads the built image directly into your cluster's nodes.
# load: true
artifacts:
# Defines the image to build. It matches the placeholder in deployment.yaml.
- image: homelaboperator
context: . # The build context is the root directory
docker:
dockerfile: Dockerfile
manifests:
helm:
releases:
- name: homelab-operator
chartPath: charts/operator
setValueTemplates:
image.repository: '{{.IMAGE_REPO_homelaboperator}}'
image.tag: '{{.IMAGE_TAG_homelaboperator}}'
deploy:
# Use kubectl to apply the manifests.
kubectl: {}

View File

@@ -0,0 +1,42 @@
import { CloudflareTunnel } from '#resources/homelab/cloudflare-tunnel/cloudflare-tunnel.ts';
import { ResourceService } from '#services/resources/resources.ts';
import type { Services } from '../utils/service.ts';
import { NamespaceService } from './namespaces/namespaces.ts';
import { ReleaseService } from './releases/releases.ts';
import { RepoService } from './repos/repos.ts';
class BootstrapService {
#services: Services;
constructor(services: Services) {
this.#services = services;
}
public get namespaces() {
return this.#services.get(NamespaceService);
}
public get repos() {
return this.#services.get(RepoService);
}
public get releases() {
return this.#services.get(ReleaseService);
}
public get cloudflareTunnel() {
const resourceService = this.#services.get(ResourceService);
return resourceService.get(CloudflareTunnel, 'cloudflare-tunnel', this.namespaces.homelab.name);
}
public ensure = async () => {
await this.namespaces.ensure();
await this.repos.ensure();
await this.releases.ensure();
await this.cloudflareTunnel.ensure({
spec: {},
});
};
}
export { BootstrapService };

View File

@@ -0,0 +1,45 @@
import type { Services } from '../../utils/service.ts';
import { ResourceService } from '../../services/resources/resources.ts';
import { Namespace } from '#resources/core/namespace/namespace.ts';
class NamespaceService {
#homelab: Namespace;
#istioSystem: Namespace;
#certManager: Namespace;
constructor(services: Services) {
const resourceService = services.get(ResourceService);
this.#homelab = resourceService.get(Namespace, 'homelab');
this.#istioSystem = resourceService.get(Namespace, 'istio-system');
this.#certManager = resourceService.get(Namespace, 'cert-manager');
this.#homelab.on('changed', this.ensure);
this.#istioSystem.on('changed', this.ensure);
this.#certManager.on('changed', this.ensure);
}
public get homelab() {
return this.#homelab;
}
public get istioSystem() {
return this.#istioSystem;
}
public get certManager() {
return this.#certManager;
}
public ensure = async () => {
await this.#homelab.ensure({
metadata: {
labels: {
'istio-injection': 'enabled',
},
},
});
await this.#istioSystem.ensure({});
await this.#certManager.ensure({});
};
}
export { NamespaceService };

View File

@@ -0,0 +1,141 @@
import { ResourceService } from '../../services/resources/resources.ts';
import { NAMESPACE } from '../../utils/consts.ts';
import { Services } from '../../utils/service.ts';
import { NamespaceService } from '../namespaces/namespaces.ts';
import { RepoService } from '../repos/repos.ts';
import { HelmRelease } from '#resources/flux/helm-release/helm-release.ts';
class ReleaseService {
#services: Services;
#certManager: HelmRelease;
#istioBase: HelmRelease;
#istiod: HelmRelease;
#istioGateway: HelmRelease;
constructor(services: Services) {
this.#services = services;
const resourceService = services.get(ResourceService);
this.#certManager = resourceService.get(HelmRelease, 'cert-manager', NAMESPACE);
this.#istioBase = resourceService.get(HelmRelease, 'istio-base', NAMESPACE);
this.#istiod = resourceService.get(HelmRelease, 'istiod', NAMESPACE);
this.#istioGateway = resourceService.get(HelmRelease, 'istio-gateway', NAMESPACE);
this.#certManager.on('changed', this.ensure);
this.#istioBase.on('changed', this.ensure);
this.#istiod.on('changed', this.ensure);
this.#istioGateway.on('changed', this.ensure);
}
public get certManager() {
return this.#certManager;
}
public get istioBase() {
return this.#istioBase;
}
public get istiod() {
return this.#istiod;
}
public ensure = async () => {
const namespaceService = this.#services.get(NamespaceService);
const repoService = this.#services.get(RepoService);
await this.#certManager.ensure({
spec: {
targetNamespace: namespaceService.certManager.name,
interval: '1h',
values: {
installCRDs: true,
},
chart: {
spec: {
chart: 'cert-manager',
version: 'v1.18.2',
sourceRef: {
apiVersion: 'source.toolkit.fluxcd.io/v1',
kind: 'HelmRepository',
name: repoService.jetstack.name,
namespace: repoService.jetstack.namespace,
},
},
},
},
});
await this.#istioBase.ensure({
spec: {
targetNamespace: namespaceService.istioSystem.name,
interval: '1h',
values: {
defaultRevision: 'default',
profile: 'ambient',
},
chart: {
spec: {
chart: 'base',
version: '1.24.3',
sourceRef: {
apiVersion: 'source.toolkit.fluxcd.io/v1',
kind: 'HelmRepository',
name: repoService.istio.name,
namespace: repoService.istio.namespace,
},
},
},
},
});
await this.#istiod.ensure({
spec: {
targetNamespace: namespaceService.istioSystem.name,
interval: '1h',
dependsOn: [
{
name: this.#istioBase.name,
namespace: this.#istioBase.namespace,
},
],
chart: {
spec: {
chart: 'istiod',
version: '1.24.3',
sourceRef: {
apiVersion: 'source.toolkit.fluxcd.io/v1',
kind: 'HelmRepository',
name: repoService.istio.name,
namespace: repoService.istio.namespace,
},
},
},
},
});
await this.#istioGateway.ensure({
spec: {
targetNamespace: NAMESPACE,
interval: '1h',
dependsOn: [
{
name: this.#istioBase.name,
namespace: this.#istioBase.namespace,
},
{
name: this.#istiod.name,
namespace: this.#istiod.namespace,
},
],
chart: {
spec: {
chart: 'gateway',
version: '1.24.3',
sourceRef: {
apiVersion: 'source.toolkit.fluxcd.io/v1',
kind: 'HelmRepository',
name: repoService.istio.name,
namespace: repoService.istio.namespace,
},
},
},
},
});
};
}
export { ReleaseService };

View File

@@ -0,0 +1,61 @@
import type { Services } from '../../utils/service.ts';
import { ResourceService } from '../../services/resources/resources.ts';
import { NAMESPACE } from '../../utils/consts.ts';
import { HelmRepo } from '#resources/flux/helm-repo/helm-repo.ts';
class RepoService {
#jetstack: HelmRepo;
#istio: HelmRepo;
#authentik: HelmRepo;
#cloudflare: HelmRepo;
constructor(services: Services) {
const resourceService = services.get(ResourceService);
this.#jetstack = resourceService.get(HelmRepo, 'jetstack', NAMESPACE);
this.#istio = resourceService.get(HelmRepo, 'istio', NAMESPACE);
this.#authentik = resourceService.get(HelmRepo, 'authentik', NAMESPACE);
this.#cloudflare = resourceService.get(HelmRepo, 'cloudflare', NAMESPACE);
this.#jetstack.on('changed', this.ensure);
this.#istio.on('changed', this.ensure);
this.#authentik.on('changed', this.ensure);
this.#cloudflare.on('changed', this.ensure);
}
public get jetstack() {
return this.#jetstack;
}
public get istio() {
return this.#istio;
}
public get authentik() {
return this.#authentik;
}
public get cloudflare() {
return this.#cloudflare;
}
public ensure = async () => {
await this.#jetstack.set({
url: 'https://charts.jetstack.io',
});
await this.#istio.set({
url: 'https://istio-release.storage.googleapis.com/charts',
});
await this.#authentik.set({
url: 'https://charts.goauthentik.io',
});
await this.#cloudflare.set({
url: 'https://cloudflare.github.io/helm-charts',
});
};
}
export { RepoService };

File diff suppressed because it is too large Load Diff

View File

@@ -1,178 +0,0 @@
import type { V1Secret } from '@kubernetes/client-node';
import type { z } from 'zod';
import {
CustomResource,
type CustomResourceOptions,
type SubresourceResult,
} from '../../services/custom-resources/custom-resources.custom-resource.ts';
import { ResourceReference } from '../../services/resources/resources.ref.ts';
import { ResourceService, type Resource } from '../../services/resources/resources.ts';
import { getWithNamespace } from '../../utils/naming.ts';
import { decodeSecret, encodeSecret } from '../../utils/secrets.ts';
import { CONTROLLED_LABEL } from '../../utils/consts.ts';
import { isDeepSubset } from '../../utils/objects.ts';
import { AuthentikService } from '../../services/authentik/authentik.service.ts';
import {
authentikClientSecretSchema,
authentikClientServerSecretSchema,
type authentikClientSpecSchema,
} from './authentik-client.schemas.ts';
class AuthentikClientResource extends CustomResource<typeof authentikClientSpecSchema> {
#serverSecret: ResourceReference<V1Secret>;
#clientSecretResource: Resource<V1Secret>;
constructor(options: CustomResourceOptions<typeof authentikClientSpecSchema>) {
super(options);
const resourceService = this.services.get(ResourceService);
this.#serverSecret = new ResourceReference();
this.#clientSecretResource = resourceService.get({
apiVersion: 'v1',
kind: 'Secret',
name: `authentik-client-${this.name}`,
namespace: this.namespace,
});
this.#updateResouces();
this.#serverSecret.on('changed', this.queueReconcile);
this.#clientSecretResource.on('changed', this.queueReconcile);
}
#updateResouces = () => {
const serverSecretNames = getWithNamespace(this.spec.secretRef, this.namespace);
const resourceService = this.services.get(ResourceService);
this.#serverSecret.current = resourceService.get({
apiVersion: 'v1',
kind: 'Secret',
name: serverSecretNames.name,
namespace: serverSecretNames.namespace,
});
};
#reconcileClientSecret = async (): Promise<SubresourceResult> => {
const serverSecret = this.#serverSecret.current;
if (!serverSecret?.exists || !serverSecret.data) {
return {
ready: false,
failed: true,
message: 'Server or server secret not found',
};
}
const serverSecretData = authentikClientServerSecretSchema.safeParse(decodeSecret(serverSecret.data));
if (!serverSecretData.success || !serverSecretData.data) {
return {
ready: false,
failed: true,
message: 'Server secret not found',
};
}
const url = serverSecretData.data.external_url;
const appName = this.name;
const clientSecretData = authentikClientSecretSchema.safeParse(decodeSecret(this.#clientSecretResource.data));
const expectedValues: z.infer<typeof authentikClientSecretSchema> = {
clientId: this.name,
clientSecret: clientSecretData.data?.clientSecret || crypto.randomUUID(),
configuration: new URL(`/application/o/${appName}/.well-known/openid-configuration`, url).toString(),
configurationIssuer: new URL(`/application/o/${appName}/`, url).toString(),
authorization: new URL(`/application/o/${appName}/authorize/`, url).toString(),
token: new URL(`/application/o/${appName}/token/`, url).toString(),
userinfo: new URL(`/application/o/${appName}/userinfo/`, url).toString(),
endSession: new URL(`/application/o/${appName}/end-session/`, url).toString(),
jwks: new URL(`/application/o/${appName}/jwks/`, url).toString(),
};
if (!isDeepSubset(clientSecretData.data, expectedValues)) {
await this.#clientSecretResource.patch({
metadata: {
ownerReferences: [this.ref],
labels: {
...CONTROLLED_LABEL,
},
},
data: encodeSecret(expectedValues),
});
return {
ready: false,
syncing: true,
message: 'UpdatingManifest',
};
}
return {
ready: true,
};
};
#reconcileServer = async (): Promise<SubresourceResult> => {
const serverSecret = this.#serverSecret.current;
const clientSecret = this.#clientSecretResource;
if (!serverSecret?.exists || !serverSecret.data) {
return {
ready: false,
failed: true,
message: 'Server secret not found',
};
}
const serverSecretData = authentikClientServerSecretSchema.safeParse(decodeSecret(serverSecret.data));
if (!serverSecretData.success || !serverSecretData.data) {
return {
ready: false,
failed: true,
message: 'Server secret not found',
};
}
const clientSecretData = authentikClientSecretSchema.safeParse(decodeSecret(clientSecret.data));
if (!clientSecretData.success || !clientSecretData.data) {
return {
ready: false,
failed: true,
message: 'Client secret not found',
};
}
const authentikService = this.services.get(AuthentikService);
const authentikServer = authentikService.get({
url: {
internal: serverSecretData.data.internal_url,
external: serverSecretData.data.external_url,
},
token: serverSecretData.data.token,
});
(await authentikServer).upsertClient({
...this.spec,
name: this.name,
secret: clientSecretData.data.clientSecret,
});
return {
ready: true,
};
};
public reconcile = async () => {
if (!this.exists || this.metadata?.deletionTimestamp) {
return;
}
this.#updateResouces();
await Promise.all([
this.reconcileSubresource('Secret', this.#reconcileClientSecret),
this.reconcileSubresource('Server', this.#reconcileServer),
]);
const secretReady = this.conditions.get('Secret')?.status === 'True';
const serverReady = this.conditions.get('Server')?.status === 'True';
await this.conditions.set('Ready', {
status: secretReady && serverReady ? 'True' : 'False',
});
};
}
export { AuthentikClientResource };

View File

@@ -1,34 +0,0 @@
import { ClientTypeEnum, SubModeEnum } from '@goauthentik/api';
import { z } from 'zod';
const authentikClientSpecSchema = z.object({
secretRef: z.string(),
subMode: z.enum(SubModeEnum).optional(),
clientType: z.enum(ClientTypeEnum).optional(),
redirectUris: z.array(
z.object({
url: z.string(),
matchingMode: z.enum(['strict', 'regex']),
}),
),
});
const authentikClientServerSecretSchema = z.object({
internal_url: z.string(),
external_url: z.string(),
token: z.string(),
});
const authentikClientSecretSchema = z.object({
clientId: z.string(),
clientSecret: z.string().optional(),
configuration: z.string(),
configurationIssuer: z.string(),
authorization: z.string(),
token: z.string(),
userinfo: z.string(),
endSession: z.string(),
jwks: z.string(),
});
export { authentikClientSpecSchema, authentikClientSecretSchema, authentikClientServerSecretSchema };

View File

@@ -1,19 +0,0 @@
import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts';
import { GROUP } from '../../utils/consts.ts';
import { AuthentikClientResource } from './authentik-client.resource.ts';
import { authentikClientSpecSchema } from './authentik-client.schemas.ts';
const authentikClientDefinition = createCustomResourceDefinition({
group: GROUP,
version: 'v1',
kind: 'AuthentikClient',
names: {
plural: 'authentikclients',
singular: 'authentikclient',
},
create: (options) => new AuthentikClientResource(options),
spec: authentikClientSpecSchema,
});
export { authentikClientDefinition };

View File

@@ -1,7 +0,0 @@
import { authentikClientDefinition } from './authentik-client/authentik-client.ts';
import { generateSecretDefinition } from './generate-secret/generate-secret.ts';
import { postgresDatabaseDefinition } from './postgres-database/postgres-database.ts';
const customResources = [postgresDatabaseDefinition, authentikClientDefinition, generateSecretDefinition];
export { customResources };

View File

@@ -1,61 +0,0 @@
import type { V1Secret } from '@kubernetes/client-node';
import {
CustomResource,
type CustomResourceOptions,
} from '../../services/custom-resources/custom-resources.custom-resource.ts';
import { Resource, ResourceService } from '../../services/resources/resources.ts';
import { decodeSecret, encodeSecret } from '../../utils/secrets.ts';
import { isDeepSubset } from '../../utils/objects.ts';
import { generateSecrets } from './generate-secret.utils.ts';
import { generateSecretSpecSchema } from './generate-secret.schemas.ts';
class GenerateSecretResource extends CustomResource<typeof generateSecretSpecSchema> {
#secretResource: Resource<V1Secret>;
constructor(options: CustomResourceOptions<typeof generateSecretSpecSchema>) {
super(options);
const resourceService = this.services.get(ResourceService);
this.#secretResource = resourceService.get({
apiVersion: 'v1',
kind: 'Secret',
name: this.name,
namespace: this.namespace,
});
this.#secretResource.on('changed', this.queueReconcile);
}
public reconcile = async () => {
if (!this.exists || this.metadata?.deletionTimestamp) {
return;
}
const secrets = generateSecrets(this.spec);
const current = decodeSecret(this.#secretResource.data) || {};
const expected = {
...current,
...secrets,
};
if (!isDeepSubset(current, expected)) {
this.#secretResource.patch({
data: encodeSecret(expected),
});
this.conditions.set('SecretUpdated', {
status: 'False',
reason: 'SecretUpdated',
});
}
this.conditions.set('Ready', {
status: 'True',
reason: 'Ready',
});
};
}
export { GenerateSecretResource };

View File

@@ -1,17 +0,0 @@
import { z } from 'zod';
const generateSecretFieldSchema = z.object({
name: z.string(),
value: z.string().optional(),
encoding: z.enum(['base64', 'base64url', 'hex', 'utf8', 'numeric']).optional(),
length: z.number().optional(),
});
const generateSecretSpecSchema = z.object({
fields: z.array(generateSecretFieldSchema),
});
type GenerateSecretField = z.infer<typeof generateSecretFieldSchema>;
type GenerateSecretSpec = z.infer<typeof generateSecretSpecSchema>;
export { generateSecretSpecSchema, type GenerateSecretField, type GenerateSecretSpec };

View File

@@ -1,19 +0,0 @@
import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts';
import { GROUP } from '../../utils/consts.ts';
import { GenerateSecretResource } from './generate-secret.resource.ts';
import { generateSecretSpecSchema } from './generate-secret.schemas.ts';
const generateSecretDefinition = createCustomResourceDefinition({
group: GROUP,
version: 'v1',
kind: 'GenerateSecret',
names: {
plural: 'generate-secrets',
singular: 'generate-secret',
},
spec: generateSecretSpecSchema,
create: (options) => new GenerateSecretResource(options),
});
export { generateSecretDefinition };

View File

@@ -1,22 +0,0 @@
import { z } from 'zod';
const postgresDatabaseSpecSchema = z.object({
secretRef: z.string(),
});
const postgresDatabaseSecretSchema = z.object({
host: z.string(),
port: z.string(),
user: z.string(),
password: z.string(),
});
const postgresDatabaseConnectionSecretSchema = z.object({
host: z.string(),
port: z.string(),
user: z.string(),
password: z.string(),
database: z.string(),
});
export { postgresDatabaseSpecSchema, postgresDatabaseSecretSchema, postgresDatabaseConnectionSecretSchema };

View File

@@ -1,181 +0,0 @@
import { z } from 'zod';
import type { V1Secret } from '@kubernetes/client-node';
import {
CustomResource,
type CustomResourceOptions,
type SubresourceResult,
} from '../../services/custom-resources/custom-resources.custom-resource.ts';
import { PostgresService } from '../../services/postgres/postgres.service.ts';
import { ResourceReference } from '../../services/resources/resources.ref.ts';
import { Resource, ResourceService } from '../../services/resources/resources.ts';
import { getWithNamespace } from '../../utils/naming.ts';
import { decodeSecret, encodeSecret } from '../../utils/secrets.ts';
import { isDeepSubset } from '../../utils/objects.ts';
import {
postgresDatabaseConnectionSecretSchema,
postgresDatabaseSecretSchema,
type postgresDatabaseSpecSchema,
} from './portgres-database.schemas.ts';
const SECRET_READY_CONDITION = 'Secret';
const DATABASE_READY_CONDITION = 'Database';
const secretDataSchema = z.object({
host: z.string(),
port: z.string().optional(),
database: z.string(),
user: z.string(),
password: z.string(),
});
class PostgresDatabaseResource extends CustomResource<typeof postgresDatabaseSpecSchema> {
#serverSecret: ResourceReference<V1Secret>;
#databaseSecret: Resource<V1Secret>;
constructor(options: CustomResourceOptions<typeof postgresDatabaseSpecSchema>) {
super(options);
this.#serverSecret = new ResourceReference();
const resourceService = this.services.get(ResourceService);
this.#databaseSecret = resourceService.get({
apiVersion: 'v1',
kind: 'Secret',
name: `${this.name}-connection`,
namespace: this.namespace,
});
this.#updateSecret();
this.#serverSecret.on('changed', this.queueReconcile);
}
get #dbName() {
return `${this.namespace}_${this.name}`;
}
get #userName() {
return `${this.namespace}_${this.name}`;
}
#updateSecret = () => {
const resourceService = this.services.get(ResourceService);
const secretNames = getWithNamespace(this.spec.secretRef, this.namespace);
this.#serverSecret.current = resourceService.get({
apiVersion: 'v1',
kind: 'Secret',
name: secretNames.name,
namespace: secretNames.namespace,
});
};
#reconcileSecret = async (): Promise<SubresourceResult> => {
const serverSecret = this.#serverSecret.current;
const databaseSecret = this.#databaseSecret;
if (!serverSecret?.exists || !serverSecret.data) {
return {
ready: false,
failed: true,
reason: 'MissingConnectionSecret',
};
}
const serverSecretData = postgresDatabaseSecretSchema.safeParse(decodeSecret(serverSecret.data));
if (!serverSecretData.success || !serverSecretData.data) {
return {
ready: false,
syncing: true,
reason: 'SecretMissing',
};
}
const databaseSecretData = postgresDatabaseConnectionSecretSchema.safeParse(decodeSecret(databaseSecret.data));
const expectedSecret = {
password: crypto.randomUUID(),
host: serverSecretData.data.host,
port: serverSecretData.data.port,
user: this.#userName,
database: this.#dbName,
};
if (!isDeepSubset(databaseSecretData.data, expectedSecret)) {
databaseSecret.patch({
data: encodeSecret(expectedSecret),
});
return {
ready: false,
syncing: true,
reason: 'SecretNotReady',
};
}
return {
ready: true,
};
};
#reconcileDatabase = async (): Promise<SubresourceResult> => {
const connectionSecret = this.#serverSecret.current;
if (!connectionSecret?.exists || !connectionSecret.data) {
return {
ready: false,
failed: true,
reason: 'MissingConnectionSecret',
};
}
const connectionSecretData = postgresDatabaseSecretSchema.safeParse(decodeSecret(connectionSecret.data));
if (!connectionSecretData.success || !connectionSecretData.data) {
return {
ready: false,
syncing: true,
reason: 'SecretMissing',
};
}
const secretData = postgresDatabaseConnectionSecretSchema.safeParse(decodeSecret(this.#serverSecret.current?.data));
if (!secretData.success || !secretData.data) {
return {
ready: false,
syncing: true,
reason: 'ConnectionSecretMissing',
};
}
const postgresService = this.services.get(PostgresService);
const database = postgresService.get({
...connectionSecretData.data,
port: connectionSecretData.data.port ? Number(connectionSecretData.data.port) : 5432,
});
await database.upsertRole({
name: secretData.data.user,
password: secretData.data.password,
});
await database.upsertDatabase({
name: secretData.data.database,
owner: secretData.data.user,
});
return {
ready: true,
};
};
public reconcile = async () => {
if (!this.exists || this.metadata?.deletionTimestamp) {
return;
}
this.#updateSecret();
await Promise.allSettled([
await this.reconcileSubresource(DATABASE_READY_CONDITION, this.#reconcileDatabase),
await this.reconcileSubresource(SECRET_READY_CONDITION, this.#reconcileSecret),
]);
const secretReady = this.conditions.get(SECRET_READY_CONDITION)?.status === 'True';
const databaseReady = this.conditions.get(DATABASE_READY_CONDITION)?.status === 'True';
await this.conditions.set('Ready', {
status: secretReady && databaseReady ? 'True' : 'False',
});
};
}
export { PostgresDatabaseResource, secretDataSchema as postgresDatabaseSecretSchema };

View File

@@ -1,19 +0,0 @@
import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts';
import { GROUP } from '../../utils/consts.ts';
import { postgresDatabaseSpecSchema } from './portgres-database.schemas.ts';
import { PostgresDatabaseResource } from './postgres-database.resource.ts';
const postgresDatabaseDefinition = createCustomResourceDefinition({
group: GROUP,
version: 'v1',
kind: 'PostgresDatabase',
names: {
plural: 'postgresdatabases',
singular: 'postgresdatabase',
},
spec: postgresDatabaseSpecSchema,
create: (options) => new PostgresDatabaseResource(options),
});
export { postgresDatabaseDefinition };

View File

@@ -1,79 +1,17 @@
import 'dotenv/config';
import { ApiException } from '@kubernetes/client-node';
import { ResourceService } from './services/resources/resources.ts';
import { Services } from './utils/service.ts';
import { CustomResourceService } from './services/custom-resources/custom-resources.ts';
import { WatcherService } from './services/watchers/watchers.ts';
import { customResources } from './custom-resouces/custom-resources.ts';
import { BootstrapService } from './bootstrap/bootstrap.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 { resources } from '#resources/resources.ts';
import { homelab } from '#resources/homelab/homelab.ts';
const services = new Services();
const watcherService = services.get(WatcherService);
await watcherService
.create({
path: '/apis/apiextensions.k8s.io/v1/customresourcedefinitions',
list: async (k8s) => {
return await k8s.extensionsApi.listCustomResourceDefinition();
},
verbs: ['add', 'update', 'delete'],
transform: (manifest) => ({
apiVersion: 'apiextensions.k8s.io/v1',
kind: 'CustomResourceDefinition',
...manifest,
}),
})
.start();
await watcherService
.create({
path: '/api/v1/secrets',
list: async (k8s) => {
return await k8s.api.listSecretForAllNamespaces();
},
verbs: ['add', 'update', 'delete'],
transform: (manifest) => ({
apiVersion: 'v1',
kind: 'Secret',
...manifest,
}),
})
.start();
await watcherService
.create({
path: '/apis/apps/v1/deployments',
list: async (k8s) => {
return await k8s.apps.listDeploymentForAllNamespaces({});
},
verbs: ['add', 'update', 'delete'],
transform: (manifest) => ({
apiVersion: 'apps/v1',
kind: 'Deployment',
...manifest,
}),
})
.start();
const resourceService = services.get(ResourceService);
const customResourceService = services.get(CustomResourceService);
customResourceService.register(...customResources);
await resourceService.install(...Object.values(homelab));
await resourceService.register(...Object.values(resources));
await customResourceService.install(true);
await customResourceService.watch();
const bootstrapService = services.get(BootstrapService);
await bootstrapService.ensure();
console.log('Started');

View File

@@ -0,0 +1,9 @@
import { Certificate } from './certificate/certificate.ts';
import type { ResourceClass } from '#services/resources/resources.ts';
const certManager = {
certificate: Certificate,
} satisfies Record<string, ResourceClass<ExpectedAny>>;
export { certManager };

View File

@@ -0,0 +1,37 @@
import type { KubernetesObject } from '@kubernetes/client-node';
import type { K8SCertificateV1 } from 'src/__generated__/resources/K8SCertificateV1.ts';
import { CRD } from '#resources/core/crd/crd.ts';
import { Resource, ResourceService, type ResourceOptions } from '#services/resources/resources.ts';
import { NotReadyError } from '#utils/errors.ts';
class Certificate extends Resource<KubernetesObject & K8SCertificateV1> {
public static readonly apiVersion = 'cert-manager.io/v1';
public static readonly kind = 'Certificate';
#crd: CRD;
constructor(options: ResourceOptions<KubernetesObject & K8SCertificateV1>) {
super(options);
const resourceService = this.services.get(ResourceService);
this.#crd = resourceService.get(CRD, 'certificates.cert-manager.io');
this.#crd.on('changed', this.#handleCrdChanged);
}
#handleCrdChanged = () => {
this.emit('changed', this.manifest);
};
public get hasCRD() {
return this.#crd.exists;
}
public set = async (manifest: KubernetesObject & K8SCertificateV1) => {
if (!this.hasCRD) {
throw new NotReadyError('MissingCRD', 'certificates.cert-manager.io does not exist');
}
return this.ensure(manifest);
};
}
export { Certificate };

View File

@@ -0,0 +1,23 @@
import { CRD } from './crd/crd.ts';
import { Deployment } from './deployment/deployment.ts';
import { Namespace } from './namespace/namespace.ts';
import { PersistentVolume } from './pv/pv.ts';
import { PVC } from './pvc/pvc.ts';
import { Secret } from './secret/secret.ts';
import { Service } from './service/service.ts';
import { StatefulSet } from './stateful-set/stateful-set.ts';
import { StorageClass } from './storage-class/storage-class.ts';
const core = {
namespace: Namespace,
storageClass: StorageClass,
pvc: PVC,
pv: PersistentVolume,
secret: Secret,
crd: CRD,
service: Service,
deployment: Deployment,
statefulSet: StatefulSet,
};
export { core };

View File

@@ -0,0 +1,10 @@
import type { V1CustomResourceDefinition } from '@kubernetes/client-node';
import { Resource } from '#services/resources/resources.ts';
class CRD extends Resource<V1CustomResourceDefinition> {
public static readonly apiVersion = 'apiextensions.k8s.io/v1';
public static readonly kind = 'CustomResourceDefinition';
}
export { CRD };

View File

@@ -0,0 +1,10 @@
import type { V1Deployment } from '@kubernetes/client-node';
import { Resource } from '#services/resources/resources.ts';
class Deployment extends Resource<V1Deployment> {
public static readonly apiVersion = 'apps/v1';
public static readonly kind = 'Deployment';
}
export { Deployment };

View File

@@ -0,0 +1,10 @@
import type { V1Namespace } from '@kubernetes/client-node';
import { Resource } from '#services/resources/resources.ts';
class Namespace extends Resource<V1Namespace> {
public static readonly apiVersion = 'v1';
public static readonly kind = 'Namespace';
}
export { Namespace };

View File

@@ -0,0 +1,10 @@
import type { V1PersistentVolume } from '@kubernetes/client-node';
import { Resource } from '#services/resources/resources.ts';
class PersistentVolume extends Resource<V1PersistentVolume> {
public static readonly apiVersion = 'v1';
public static readonly kind = 'PersistentVolume';
}
export { PersistentVolume };

View File

@@ -0,0 +1,80 @@
import type { V1PersistentVolumeClaim } from '@kubernetes/client-node';
import { StorageClass } from '../storage-class/storage-class.ts';
import { PersistentVolume } from '../pv/pv.ts';
import { Resource, ResourceService, type ResourceOptions } from '#services/resources/resources.ts';
const PROVISIONER = 'homelab-operator';
class PVC extends Resource<V1PersistentVolumeClaim> {
public static readonly apiVersion = 'v1';
public static readonly kind = 'PersistentVolumeClaim';
constructor(options: ResourceOptions<V1PersistentVolumeClaim>) {
super(options);
this.on('changed', this.reconcile);
}
public reconcile = async () => {
const storageClassName = this.spec?.storageClassName;
console.log('PVC', this.name, storageClassName);
if (!storageClassName) {
return;
}
const resourceService = this.services.get(ResourceService);
const storageClass = resourceService.get(StorageClass, storageClassName);
if (!storageClass.exists || storageClass.manifest?.provisioner !== PROVISIONER) {
return;
}
if (this.status?.phase === 'Pending' && !this.spec?.volumeName) {
await this.#provisionVolume(storageClass);
}
};
#provisionVolume = async (storageClass: StorageClass) => {
const pvName = `pv-${this.namespace}-${this.name}`;
const storageLocation = storageClass.manifest?.parameters?.storageLocation || '/data/volumes';
const target = `${storageLocation}/${this.namespace}/${this.name}`;
const resourceService = this.services.get(ResourceService);
const pv = resourceService.get(PersistentVolume, pvName);
await pv.ensure({
metadata: {
name: pvName,
labels: {
provisioner: PROVISIONER,
'pvc-namespace': this.namespace || 'default',
'pvc-name': this.name || 'unknown',
},
annotations: {
'pv.kubernetes.io/provisioned-by': PROVISIONER,
},
},
spec: {
hostPath: {
path: target,
type: 'DirectoryOrCreate',
},
capacity: {
storage: this.spec?.resources?.requests?.storage ?? '1Gi',
},
persistentVolumeReclaimPolicy: 'Retain',
accessModes: this.spec?.accessModes ?? ['ReadWriteOnce'],
storageClassName: this.spec?.storageClassName,
claimRef: {
uid: this.metadata?.uid,
resourceVersion: this.metadata?.resourceVersion,
apiVersion: this.apiVersion,
kind: 'PersistentVolumeClaim',
name: this.name,
namespace: this.namespace,
},
},
});
};
}
export { PVC, PROVISIONER };

View File

@@ -0,0 +1,25 @@
import type { KubernetesObject, V1Secret } from '@kubernetes/client-node';
import { Resource } from '#services/resources/resources.ts';
import { decodeSecret, encodeSecret } from '#utils/secrets.ts';
type SetOptions<T extends Record<string, string | undefined>> = T | ((current: T | undefined) => T | Promise<T>);
class Secret<T extends Record<string, string> = Record<string, string>> extends Resource<V1Secret> {
public static readonly apiVersion = 'v1';
public static readonly kind = 'Secret';
public get value() {
return decodeSecret(this.data) as T | undefined;
}
public set = async (options: SetOptions<T>, data?: KubernetesObject) => {
const value = typeof options === 'function' ? await Promise.resolve(options(this.value)) : options;
await this.ensure({
...data,
data: encodeSecret(value),
});
};
}
export { Secret };

View File

@@ -0,0 +1,14 @@
import type { V1Service } from '@kubernetes/client-node';
import { Resource } from '#services/resources/resources.ts';
class Service extends Resource<V1Service> {
public static readonly apiVersion = 'v1';
public static readonly kind = 'Service';
public get hostname() {
return `${this.name}.${this.namespace}.svc.cluster.local`;
}
}
export { Service };

View File

@@ -0,0 +1,10 @@
import type { V1StatefulSet } from '@kubernetes/client-node';
import { Resource } from '#services/resources/resources.ts';
class StatefulSet extends Resource<V1StatefulSet> {
public static readonly apiVersion = 'apps/v1';
public static readonly kind = 'StatefulSet';
}
export { StatefulSet };

View File

@@ -0,0 +1,11 @@
import type { V1StorageClass } from '@kubernetes/client-node';
import { Resource } from '#services/resources/resources.ts';
class StorageClass extends Resource<V1StorageClass> {
public static readonly apiVersion = 'storage.k8s.io/v1';
public static readonly kind = 'StorageClass';
public static readonly plural = 'storageclasses';
}
export { StorageClass };

View File

@@ -0,0 +1,11 @@
import { HelmRelease } from './helm-release/helm-release.ts';
import { HelmRepo } from './helm-repo/helm-repo.ts';
import type { ResourceClass } from '#services/resources/resources.ts';
const flux = {
helmRelease: HelmRelease,
helmRepo: HelmRepo,
} satisfies Record<string, ResourceClass<ExpectedAny>>;
export { flux };

View File

@@ -0,0 +1,42 @@
import type { KubernetesObject } from '@kubernetes/client-node';
import type { K8SHelmReleaseV2 } from 'src/__generated__/resources/K8SHelmReleaseV2.ts';
import { Resource } from '#services/resources/resources.ts';
type SetOptions = {
namespace?: string;
values?: Record<string, unknown>;
chart: {
name: string;
namespace?: string;
};
};
class HelmRelease extends Resource<KubernetesObject & K8SHelmReleaseV2> {
public static readonly apiVersion = 'helm.toolkit.fluxcd.io/v2';
public static readonly kind = 'HelmRelease';
public set = async (options: SetOptions) => {
return await this.ensure({
spec: {
targetNamespace: options.namespace,
interval: '1h',
values: options.values,
chart: {
spec: {
chart: 'cert-manager',
version: 'v1.18.2',
sourceRef: {
apiVersion: 'source.toolkit.fluxcd.io/v1',
kind: 'HelmRepository',
name: options.chart.name,
namespace: options.chart.namespace,
},
},
},
},
});
};
}
export { HelmRelease };

View File

@@ -0,0 +1,24 @@
import type { KubernetesObject } from '@kubernetes/client-node';
import type { K8SHelmRepositoryV1 } from 'src/__generated__/resources/K8SHelmRepositoryV1.ts';
import { Resource } from '#services/resources/resources.ts';
type SetOptions = {
url: string;
};
class HelmRepo extends Resource<KubernetesObject & K8SHelmRepositoryV1> {
public static readonly apiVersion = 'source.toolkit.fluxcd.io/v1';
public static readonly kind = 'HelmRepository';
public static readonly plural = 'helmrepositories';
public set = async ({ url }: SetOptions) => {
await this.ensure({
spec: {
interval: '1h',
url,
},
});
};
}
export { HelmRepo };

View File

@@ -0,0 +1,284 @@
import { z } from 'zod';
import { PostgresDatabase } from '../postgres-database/postgres-database.ts';
import { Environment } from '../environment/environment.ts';
import {
CustomResource,
ResourceReference,
ResourceService,
type CustomResourceOptions,
} from '#services/resources/resources.ts';
import { API_VERSION } from '#utils/consts.ts';
import { Secret } from '#resources/core/secret/secret.ts';
import { generateRandomHexPass } from '#utils/secrets.ts';
import { Service } from '#resources/core/service/service.ts';
import { HelmRelease } from '#resources/flux/helm-release/helm-release.ts';
import { RepoService } from '#bootstrap/repos/repos.ts';
import { VirtualService } from '#resources/istio/virtual-service/virtual-service.ts';
import { DestinationRule } from '#resources/istio/destination-rule/destination-rule.ts';
import { NotReadyError } from '#utils/errors.ts';
const specSchema = z.object({
environment: z.string(),
subdomain: z.string().optional(),
});
type SecretData = { url: string; host: string; token: string };
type InitSecretData = {
AUTHENTIK_BOOTSTRAP_TOKEN: string;
AUTHENTIK_BOOTSTRAP_PASSWORD: string;
AUTHENTIK_BOOTSTRAP_EMAIL: string;
AUTHENTIK_SECRET_KEY: string;
};
class AuthentikServer extends CustomResource<typeof specSchema> {
public static readonly apiVersion = API_VERSION;
public static readonly kind = 'AuthentikServer';
public static readonly spec = specSchema;
public static readonly scope = 'Namespaced';
#environment: ResourceReference<typeof Environment>;
#database: PostgresDatabase;
#secret: Secret<SecretData>;
#initSecret: Secret<InitSecretData>;
#service: Service;
#helmRelease: HelmRelease;
#virtualService: VirtualService;
#destinationRule: DestinationRule;
constructor(options: CustomResourceOptions<typeof specSchema>) {
super(options);
const resourceService = this.services.get(ResourceService);
this.#environment = new ResourceReference();
this.#environment.on('changed', this.queueReconcile);
this.#database = resourceService.get(PostgresDatabase, this.name, this.namespace);
this.#database.on('changed', this.queueReconcile);
this.#secret = resourceService.get(Secret<SecretData>, this.name, this.namespace);
this.#secret.on('changed', this.queueReconcile);
this.#initSecret = resourceService.get(Secret<InitSecretData>, `${this.name}-init`, this.namespace);
this.#service = resourceService.get(Service, `${this.name}-server`, this.namespace);
// this.#service.on('changed', this.queueReconcile);
this.#helmRelease = resourceService.get(HelmRelease, this.name, this.namespace);
this.#helmRelease.on('changed', this.queueReconcile);
this.#virtualService = resourceService.get(VirtualService, this.name, this.namespace);
this.#virtualService.on('changed', this.queueReconcile);
this.#destinationRule = resourceService.get(DestinationRule, this.name, this.namespace);
this.#destinationRule.on('changed', this.queueReconcile);
}
public get service() {
return this.#service;
}
public get secret() {
return this.#secret;
}
public get subdomain() {
return this.spec?.subdomain || 'authentik';
}
public get domain() {
return `${this.subdomain}.${this.#environment.current?.spec?.domain}`;
}
public get url() {
return `https://${this.domain}`;
}
public reconcile = async () => {
if (!this.spec) {
throw new NotReadyError('MissingSpec');
}
const resourceService = this.services.get(ResourceService);
this.#environment.current = resourceService.get(Environment, this.spec.environment);
if (!this.#environment.current.spec) {
throw new NotReadyError('MissingEnvSpev');
}
await this.#database.ensure({
metadata: {
ownerReferences: [this.ref],
},
spec: {
environment: this.#environment.current.name,
},
});
const databaseSecret = this.#database.secret.value;
if (!databaseSecret) {
throw new NotReadyError('MissingDatabaseSecret');
}
await this.#initSecret.set(
(current) => ({
AUTHENTIK_BOOTSTRAP_EMAIL: 'admin@example.com',
AUTHENTIK_BOOTSTRAP_PASSWORD: generateRandomHexPass(24),
AUTHENTIK_BOOTSTRAP_TOKEN: generateRandomHexPass(32),
AUTHENTIK_SECRET_KEY: generateRandomHexPass(32),
...current,
}),
{
metadata: {
ownerReferences: [this.ref],
},
},
);
const initSecret = this.#initSecret.value;
if (!initSecret) {
throw new NotReadyError('MissingInitSecret');
}
const domain = `${this.spec?.subdomain || 'authentik'}.${this.#environment.current.spec.domain}`;
await this.#secret.set(
{
url: `https://${domain}`,
host: this.#service.hostname,
token: initSecret.AUTHENTIK_BOOTSTRAP_TOKEN,
},
{
metadata: {
ownerReferences: [this.ref],
},
},
);
const secret = this.#secret.value;
if (!secret) {
throw new NotReadyError('MissingSecret');
}
const repoService = this.services.get(RepoService);
await this.#helmRelease.ensure({
metadata: {
ownerReferences: [this.ref],
},
spec: {
interval: '60m',
chart: {
spec: {
chart: 'authentik',
version: '2025.6.4',
sourceRef: {
apiVersion: 'source.toolkit.fluxcd.io/v1',
kind: 'HelmRepository',
name: repoService.authentik.name,
namespace: repoService.authentik.namespace,
},
},
},
values: {
global: {
envFrom: [
{
secretRef: {
name: this.#initSecret.name,
},
},
],
},
authentik: {
error_reporting: {
enabled: false,
},
postgresql: {
host: databaseSecret.host,
name: databaseSecret.database,
user: databaseSecret.user,
password: 'file:///postgres-creds/password',
},
redis: {
host: this.#environment.current.redisServer.service.hostname,
},
},
server: {
volumes: [
{
name: 'postgres-creds',
secret: {
secretName: this.#database.secret.name,
},
},
],
volumeMounts: [
{
name: 'postgres-creds',
mountPath: '/postgres-creds',
readOnly: true,
},
],
},
worker: {
volumes: [
{
name: 'postgres-creds',
secret: {
secretName: this.#database.secret.name,
},
},
],
volumeMounts: [
{
name: 'postgres-creds',
mountPath: '/postgres-creds',
readOnly: true,
},
],
},
},
},
});
await this.#destinationRule.ensure({
metadata: {
ownerReferences: [this.ref],
},
spec: {
host: this.#service.hostname,
trafficPolicy: {
tls: {
mode: 'DISABLE',
},
},
},
});
const gateway = this.#environment.current.gateway;
await this.#virtualService.set({
metadata: {
ownerReferences: [this.ref],
},
spec: {
gateways: [`${gateway.namespace}/${gateway.name}`, 'mesh'],
hosts: [domain],
http: [
{
route: [
{
destination: {
host: this.#service.hostname,
port: {
number: 80,
},
},
},
],
},
],
},
});
};
}
export { AuthentikServer };

View File

@@ -0,0 +1,94 @@
import {
CustomResource,
Resource,
ResourceService,
type CustomResourceOptions,
} from '#services/resources/resources.ts';
import z from 'zod';
import { ExternalHttpService } from '../external-http-service.ts/external-http-service.ts';
import { API_VERSION } from '#utils/consts.ts';
import { HelmRelease } from '#resources/flux/helm-release/helm-release.ts';
import { RepoService } from '#bootstrap/repos/repos.ts';
import { Secret } from '#resources/core/secret/secret.ts';
import { NotReadyError } from '#utils/errors.ts';
import { NamespaceService } from '#bootstrap/namespaces/namespaces.ts';
const specSchema = z.object({});
type SecretData = {
account: string;
tunnelName: string;
tunnelId: string;
secret: string;
};
class CloudflareTunnel extends CustomResource<typeof specSchema> {
public static readonly apiVersion = API_VERSION;
public static readonly kind = 'CloudflareTunnel';
public static readonly spec = specSchema;
public static readonly scope = 'Cluster';
#helmRelease: HelmRelease;
#secret: Secret<SecretData>;
constructor(options: CustomResourceOptions<typeof specSchema>) {
super(options);
const resourceService = this.services.get(ResourceService);
const namespaceService = this.services.get(NamespaceService);
const namespace = namespaceService.homelab.name;
resourceService.on('changed', this.#handleResourceChanged);
this.#helmRelease = resourceService.get(HelmRelease, this.name, namespace);
this.#secret = resourceService.get(Secret<SecretData>, 'cloudflare', namespace);
this.#secret.on('changed', this.queueReconcile);
}
#handleResourceChanged = (resource: Resource<ExpectedAny>) => {
if (resource instanceof CloudflareTunnel) {
this.queueReconcile();
}
};
public reconcile = async () => {
const secret = this.#secret.value;
if (!secret) {
throw new NotReadyError('MissingSecret', `Secret ${this.#secret.namespace}/${this.#secret.name} does not exist`);
}
const resourceService = this.services.get(ResourceService);
const repoService = this.services.get(RepoService);
const routes = resourceService.getAllOfKind(ExternalHttpService);
const ingress = routes.map(({ rule }) => ({
hostname: rule?.hostname,
service: `http://${rule?.destination.host}:${rule?.destination.port.number}`,
}));
await this.#helmRelease.ensure({
metadata: {
ownerReferences: [this.ref],
},
spec: {
interval: '1h',
values: {
cloudflare: {
account: secret.account,
tunnelName: secret.tunnelName,
tunnelId: secret.tunnelId,
secret: secret.secret,
ingress,
},
},
chart: {
spec: {
chart: 'cloudflare-tunnel',
sourceRef: {
apiVersion: 'source.toolkit.fluxcd.io/v1',
kind: 'HelmRepository',
name: repoService.cloudflare.name,
namespace: repoService.cloudflare.namespace,
},
},
},
},
});
};
}
export { CloudflareTunnel };

View File

@@ -0,0 +1,214 @@
import { z } from 'zod';
import { PostgresCluster } from '../postgres-cluster/postgres-cluster.ts';
import { RedisServer } from '../redis-server/redis-server.ts';
import { AuthentikServer } from '../authentik-server/authentik-server.ts';
import { CustomResource, ResourceService, type CustomResourceOptions } from '#services/resources/resources.ts';
import { API_VERSION } from '#utils/consts.ts';
import { Namespace } from '#resources/core/namespace/namespace.ts';
import { Certificate } from '#resources/cert-manager/certificate/certificate.ts';
import { StorageClass } from '#resources/core/storage-class/storage-class.ts';
import { PROVISIONER } from '#resources/core/pvc/pvc.ts';
import { Gateway } from '#resources/istio/gateway/gateway.ts';
import { NotReadyError } from '#utils/errors.ts';
import { NamespaceService } from '#bootstrap/namespaces/namespaces.ts';
import { CloudflareService } from '#services/cloudflare/cloudflare.ts';
const specSchema = z.object({
domain: z.string(),
networkIp: z.string().optional(),
tls: z.object({
issuer: z.string(),
}),
});
class Environment extends CustomResource<typeof specSchema> {
public static readonly apiVersion = API_VERSION;
public static readonly kind = 'Environment';
public static readonly spec = specSchema;
public static readonly scope = 'Cluster';
#namespace: Namespace;
#certificate: Certificate;
#storageClass: StorageClass;
#gateway: Gateway;
#postgresCluster: PostgresCluster;
#redisServer: RedisServer;
#authentikServer: AuthentikServer;
#cloudflareService: CloudflareService;
constructor(options: CustomResourceOptions<typeof specSchema>) {
super(options);
const resourceService = this.services.get(ResourceService);
const namespaceService = this.services.get(NamespaceService);
const homelabNamespace = namespaceService.homelab.name;
this.#cloudflareService = this.services.get(CloudflareService);
this.#cloudflareService.on('changed', this.queueReconcile);
this.#namespace = resourceService.get(Namespace, this.name);
this.#namespace.on('changed', this.queueReconcile);
this.#certificate = resourceService.get(Certificate, this.name, homelabNamespace);
this.#certificate.on('changed', this.queueReconcile);
this.#storageClass = resourceService.get(StorageClass, this.name);
this.#storageClass.on('changed', this.queueReconcile);
this.#postgresCluster = resourceService.get(PostgresCluster, `${this.name}-postgres-cluster`, homelabNamespace);
this.#postgresCluster.on('changed', this.queueReconcile);
this.#redisServer = resourceService.get(RedisServer, `${this.name}-redis-server`, homelabNamespace);
this.#redisServer.on('changed', this.queueReconcile);
this.#gateway = resourceService.get(Gateway, this.name, homelabNamespace);
this.#gateway.on('changed', this.queueReconcile);
this.#authentikServer = resourceService.get(AuthentikServer, `${this.name}-authentik`, homelabNamespace);
this.#authentikServer.on('changed', this.queueReconcile);
}
public get certificate() {
return this.#certificate;
}
public get storageClass() {
return this.#storageClass;
}
public get postgresCluster() {
return this.#postgresCluster;
}
public get redisServer() {
return this.#redisServer;
}
public get gateway() {
return this.#gateway;
}
public get authentikServer() {
return this.#authentikServer;
}
public reconcile = async () => {
const { data: spec, success } = specSchema.safeParse(this.spec);
if (!success || !spec) {
throw new NotReadyError('InvalidSpec');
}
if (this.#cloudflareService.ready && spec.networkIp) {
const client = this.#cloudflareService.client;
const zones = await client.zones.list({
name: spec.domain,
});
const [zone] = zones.result;
if (!zone) {
throw new NotReadyError('NoZoneFound');
}
const existingRecords = await client.dns.records.list({
zone_id: zone.id,
name: {
exact: `*.${spec.domain}`,
},
});
console.log('Cloudflare records', existingRecords);
// zones.result[0].
}
await this.#namespace.ensure({
metadata: {
labels: {
'istio-injection': 'enabled',
},
},
});
await this.#certificate.ensure({
spec: {
secretName: `${this.name}-tls`,
issuerRef: {
name: spec.tls.issuer,
kind: 'ClusterIssuer',
},
dnsNames: [`*.${spec.domain}`],
privateKey: {
rotationPolicy: 'Always',
},
},
});
await this.#storageClass.ensure({
metadata: {
ownerReferences: [this.ref],
},
provisioner: PROVISIONER,
reclaimPolicy: 'Retain',
});
await this.#postgresCluster.ensure({
metadata: {
ownerReferences: [this.ref],
},
spec: {
storageClass: this.name,
},
});
await this.#redisServer.ensure({
metadata: {
ownerReferences: [this.ref],
},
spec: {},
});
await this.#authentikServer.ensure({
metadata: {
ownerReferences: [this.ref],
},
spec: {
environment: this.name,
},
});
await this.#gateway.set({
metadata: {
ownerReferences: [this.ref],
},
spec: {
selector: {
istio: 'homelab-istio-gateway',
},
servers: [
{
hosts: [`*.${spec.domain}`],
port: {
name: 'http',
number: 80,
protocol: 'HTTP',
},
tls: {
httpsRedirect: true,
},
},
{
hosts: [`*.${spec.domain}`],
port: {
name: 'https',
number: 443,
protocol: 'HTTPS',
},
tls: {
mode: 'SIMPLE',
credentialName: `${this.name}-tls`,
},
},
],
},
});
};
}
export { Environment };

View File

@@ -0,0 +1,43 @@
import { CustomResource, ResourceService, type CustomResourceOptions } from '#services/resources/resources.ts';
import { z } from 'zod';
import { Environment } from '../environment/environment.ts';
import { API_VERSION } from '#utils/consts.ts';
const specSchema = z.object({
environment: z.string(),
subdomain: z.string(),
destination: z.object({
host: z.string(),
port: z.object({
number: z.number(),
}),
}),
});
class ExternalHttpService extends CustomResource<typeof specSchema> {
public static readonly apiVersion = API_VERSION;
public static readonly kind = 'ExternalHttpService';
public static readonly spec = specSchema;
public static readonly scope = 'Namespaced';
constructor(options: CustomResourceOptions<typeof specSchema>) {
super(options);
}
public get rule() {
if (!this.spec) {
return undefined;
}
const resourceService = this.services.get(ResourceService);
const env = resourceService.get(Environment, this.spec.environment);
const hostname = `${this.spec.subdomain}.${env.spec?.domain}`;
return {
domain: env.spec?.domain,
subdomain: this.spec.subdomain,
hostname,
destination: this.spec.destination,
};
}
}
export { ExternalHttpService };

View File

@@ -0,0 +1,47 @@
import { Secret } from '#resources/core/secret/secret.ts';
import { CustomResource, ResourceService, type CustomResourceOptions } from '#services/resources/resources.ts';
import { z } from 'zod';
import { generateSecrets } from './generate-secret.utils.ts';
import { API_VERSION } from '#utils/consts.ts';
const generateSecretFieldSchema = z.object({
name: z.string(),
value: z.string().optional(),
encoding: z.enum(['base64', 'base64url', 'hex', 'utf8', 'numeric']).optional(),
length: z.number().optional(),
});
const specSchema = z.object({
fields: z.array(generateSecretFieldSchema),
});
class GenerateSecret extends CustomResource<typeof specSchema> {
public static readonly apiVersion = API_VERSION;
public static readonly kind = 'GenerateSecret';
public static readonly spec = specSchema;
public static readonly scope = 'Namespaced';
#secret: Secret;
constructor(options: CustomResourceOptions<typeof specSchema>) {
super(options);
const resourceService = this.services.get(ResourceService);
this.#secret = resourceService.get(Secret, this.name, this.namespace);
}
public reconcile = async () => {
const secrets = generateSecrets(this.spec);
const current = this.#secret.value;
const expected = {
...secrets,
...current,
};
await this.#secret.ensure(expected);
};
}
export { GenerateSecret };

View File

@@ -0,0 +1,27 @@
import { Environment } from './environment/environment.ts';
import { PostgresCluster } from './postgres-cluster/postgres-cluster.ts';
import { RedisServer } from './redis-server/redis-server.ts';
import { PostgresDatabase } from './postgres-database/postgres-database.ts';
import { AuthentikServer } from './authentik-server/authentik-server.ts';
import type { InstallableResourceClass } from '#services/resources/resources.ts';
import { OIDCClient } from './oidc-client/oidc-client.ts';
import { HttpService } from './http-service/http-service.ts';
import { GenerateSecret } from './generate-secret/generate-secret.ts';
import { ExternalHttpService } from './external-http-service.ts/external-http-service.ts';
import { CloudflareTunnel } from './cloudflare-tunnel/cloudflare-tunnel.ts';
const homelab = {
PostgresCluster,
RedisServer,
Environment,
ExternalHttpService,
CloudflareTunnel,
AuthentikServer,
PostgresDatabase,
OIDCClient,
HttpService,
GenerateSecret,
} satisfies Record<string, InstallableResourceClass<ExpectedAny>>;
export { homelab };

View File

@@ -0,0 +1,83 @@
import { VirtualService } from '#resources/istio/virtual-service/virtual-service.ts';
import {
CustomResource,
ResourceReference,
ResourceService,
type CustomResourceOptions,
} from '#services/resources/resources.ts';
import { z } from 'zod';
import { Environment } from '../environment/environment.ts';
import { NotReadyError } from '#utils/errors.ts';
import { API_VERSION } from '#utils/consts.ts';
const specSchema = z.object({
environment: z.string(),
subdomain: z.string(),
destination: z.object({
host: z.string(),
port: z.object({
number: z.number().optional(),
name: z.string().optional(),
}),
}),
});
class HttpService extends CustomResource<typeof specSchema> {
public static readonly apiVersion = API_VERSION;
public static readonly kind = 'HttpService';
public static readonly spec = specSchema;
public static readonly scope = 'Namespaced';
#virtualService: VirtualService;
#environment: ResourceReference<typeof Environment>;
constructor(options: CustomResourceOptions<typeof specSchema>) {
super(options);
const resourceService = this.services.get(ResourceService);
this.#virtualService = resourceService.get(VirtualService, this.name, this.namespace);
this.#virtualService.on('changed', this.queueReconcile);
this.#environment = new ResourceReference();
this.#environment.on('changed', this.queueReconcile);
}
public reconcile = async () => {
if (!this.spec) {
throw new NotReadyError('MissingSpec');
}
const resourceService = this.services.get(ResourceService);
this.#environment.current = resourceService.get(Environment, this.spec.environment);
const env = this.#environment.current;
if (!env.exists) {
throw new NotReadyError('MissingEnvironment');
}
const gateway = env.gateway;
const domain = env.spec?.domain;
if (!domain) {
throw new NotReadyError('MissingDomain');
}
const host = `${this.spec.subdomain}.${domain}`;
this.#virtualService.ensure({
metadata: {
ownerReferences: [this.ref],
},
spec: {
hosts: [host, 'mesh'],
gateways: [`${gateway.namespace}/${gateway.name}`, 'mesh'],
http: [
{
route: [
{
destination: this.spec.destination,
},
],
},
],
},
});
};
}
export { HttpService };

View File

@@ -0,0 +1,109 @@
import {
CustomResource,
ResourceReference,
ResourceService,
type CustomResourceOptions,
} from '#services/resources/resources.ts';
import { ClientTypeEnum, SubModeEnum } from '@goauthentik/api';
import { z } from 'zod';
import { Environment } from '../environment/environment.ts';
import { API_VERSION } from '#utils/consts.ts';
import { NotReadyError } from '#utils/errors.ts';
import { Secret } from '#resources/core/secret/secret.ts';
import { generateRandomHexPass } from '#utils/secrets.ts';
import { AuthentikService } from '#services/authentik/authentik.service.ts';
const specSchema = z.object({
environment: z.string(),
subMode: z.enum(SubModeEnum).optional(),
clientType: z.enum(ClientTypeEnum).optional(),
redirectUris: z.array(
z.object({
url: z.string(),
matchingMode: z.enum(['strict', 'regex']),
}),
),
});
type SecretData = {
clientId: string;
clientSecret?: string;
configuration: string;
configurationIssuer: string;
authorization: string;
token: string;
userinfo: string;
endSession: string;
jwks: string;
};
class OIDCClient extends CustomResource<typeof specSchema> {
public static readonly apiVersion = API_VERSION;
public static readonly kind = 'OidcClient';
public static readonly spec = specSchema;
public static readonly scope = 'Namespaced';
#environment = new ResourceReference<typeof Environment>();
#secret: Secret<SecretData>;
constructor(options: CustomResourceOptions<typeof specSchema>) {
super(options);
const resourceService = this.services.get(ResourceService);
this.#secret = resourceService.get(Secret<SecretData>, `${this.name}-client`, this.namespace);
}
public get appName() {
return this.name;
}
public reconcile = async () => {
if (!this.spec) {
throw new NotReadyError('MissingSpec');
}
const resourceService = this.services.get(ResourceService);
this.#environment.current = resourceService.get(Environment, this.spec.environment);
if (!this.#environment.current.exists) {
throw new NotReadyError('EnvironmentNotFound');
}
const authentik = this.#environment.current.authentikServer;
const authentikSecret = authentik.secret.value;
if (!authentikSecret) {
throw new Error('MissingAuthentikSecret');
}
const url = authentik.url;
await this.#secret.set((current) => ({
clientSecret: generateRandomHexPass(),
...current,
clientId: this.name,
configuration: new URL(`/application/o/${this.appName}/.well-known/openid-configuration`, url).toString(),
configurationIssuer: new URL(`/application/o/${this.appName}/`, url).toString(),
authorization: new URL(`/application/o/${this.appName}/authorize/`, url).toString(),
token: new URL(`/application/o/${this.appName}/token/`, url).toString(),
userinfo: new URL(`/application/o/${this.appName}/userinfo/`, url).toString(),
endSession: new URL(`/application/o/${this.appName}/end-session/`, url).toString(),
jwks: new URL(`/application/o/${this.appName}/jwks/`, url).toString(),
}));
const secret = this.#secret.value;
if (!secret) {
throw new NotReadyError('MissingSecret');
}
const authentikService = this.services.get(AuthentikService);
const authentikServer = await authentikService.get({
url: {
internal: `http://${authentikSecret.host}`,
external: authentikSecret.url,
},
token: authentikSecret.token,
});
await authentikServer.upsertClient({
...this.spec,
name: this.name,
secret: secret.clientSecret,
});
};
}
export { OIDCClient };

View File

@@ -0,0 +1,172 @@
import { z } from 'zod';
import { Secret } from '#resources/core/secret/secret.ts';
import { StatefulSet } from '#resources/core/stateful-set/stateful-set.ts';
import { CustomResource, ResourceService, type CustomResourceOptions } from '#services/resources/resources.ts';
import { API_VERSION } from '#utils/consts.ts';
import { Service } from '#resources/core/service/service.ts';
import { generateRandomHexPass } from '#utils/secrets.ts';
const specSchema = z.object({
storageClass: z.string(),
storage: z
.object({
size: z.string().optional(),
})
.optional(),
});
type SecretData = {
host: string;
port: string;
user: string;
password: string;
database: string;
};
class PostgresCluster extends CustomResource<typeof specSchema> {
public static readonly apiVersion = API_VERSION;
public static readonly kind = 'PostgresCluster';
public static readonly spec = specSchema;
public static readonly scope = 'Namespaced';
#secret: Secret<SecretData>;
#statefulSet: StatefulSet;
#headlessService: Service;
#service: Service;
constructor(options: CustomResourceOptions<typeof specSchema>) {
super(options);
const resourceService = this.services.get(ResourceService);
this.#secret = resourceService.get(Secret<SecretData>, this.name, this.namespace);
this.#secret.on('changed', this.queueReconcile);
this.#statefulSet = resourceService.get(StatefulSet, this.name, this.namespace);
this.#statefulSet.on('changed', this.queueReconcile);
this.#service = resourceService.get(Service, this.name, this.namespace);
this.#service.on('changed', this.queueReconcile);
this.#headlessService = resourceService.get(Service, `${this.name}-headless`, this.namespace);
this.#headlessService.on('changed', this.queueReconcile);
}
public get secret() {
return this.#secret;
}
public get statefulSet() {
return this.#statefulSet;
}
public get headlessService() {
return this.#headlessService;
}
public get service() {
return this.#service;
}
public reconcile = async () => {
await this.#secret.set(
(current) => ({
password: generateRandomHexPass(16),
user: 'homelab',
database: 'homelab',
...current,
host: `${this.#service.name}.${this.#service.namespace}.svc.cluster.local`,
port: '5432',
}),
{
metadata: {
ownerReferences: [this.ref],
},
},
);
const secretName = this.#secret.name;
await this.#statefulSet.ensure({
metadata: {
ownerReferences: [this.ref],
},
spec: {
replicas: 1,
selector: {
matchLabels: {
app: this.name,
},
},
template: {
metadata: {
labels: {
app: this.name,
},
},
spec: {
containers: [
{
name: this.name,
image: 'postgres:17',
ports: [{ containerPort: 5432, name: 'postgres' }],
env: [
{ name: 'POSTGRES_PASSWORD', valueFrom: { secretKeyRef: { name: secretName, key: 'password' } } },
{ name: 'POSTGRES_USER', valueFrom: { secretKeyRef: { name: secretName, key: 'user' } } },
{ name: 'POSTGRES_DB', valueFrom: { secretKeyRef: { name: secretName, key: 'database' } } },
{ name: 'PGDATA', value: '/var/lib/postgresql/data/pgdata' },
],
volumeMounts: [{ name: this.name, mountPath: '/var/lib/postgresql/data' }],
},
],
},
},
volumeClaimTemplates: [
{
metadata: {
name: this.name,
ownerReferences: [this.ref],
},
spec: {
accessModes: ['ReadWriteOnce'],
storageClassName: this.spec?.storageClass,
resources: {
requests: {
storage: this.spec?.storage?.size || '1Gi',
},
},
},
},
],
},
});
await this.#headlessService.ensure({
metadata: {
ownerReferences: [this.ref],
},
spec: {
clusterIP: 'None',
selector: {
app: this.name,
},
ports: [{ name: 'postgres', port: 5432, targetPort: 5432 }],
},
});
await this.#service.ensure({
metadata: {
ownerReferences: [this.ref],
},
spec: {
type: 'ClusterIP',
selector: {
app: this.name,
},
ports: [{ name: 'postgres', port: 5432, targetPort: 5432 }],
},
});
};
}
export { PostgresCluster };

View File

@@ -0,0 +1,135 @@
import { z } from 'zod';
import { PostgresCluster } from '../postgres-cluster/postgres-cluster.ts';
import {
CustomResource,
ResourceReference,
ResourceService,
type CustomResourceOptions,
} from '#services/resources/resources.ts';
import { Secret } from '#resources/core/secret/secret.ts';
import { API_VERSION } from '#utils/consts.ts';
import { getWithNamespace } from '#utils/naming.ts';
import { PostgresService } from '#services/postgres/postgres.service.ts';
import { NotReadyError } from '#utils/errors.ts';
import { generateRandomHexPass } from '#utils/secrets.ts';
const specSchema = z.object({
environment: z.string().optional(),
cluster: z.string().optional(),
});
type SecretData = {
password: string;
user: string;
database: string;
host: string;
port: string;
};
const sanitizeName = (input: string) => {
return input.replace(/[^a-zA-Z0-9_]+/g, '_').toLowerCase();
};
class PostgresDatabase extends CustomResource<typeof specSchema> {
public static readonly apiVersion = API_VERSION;
public static readonly kind = 'PostgresDatabase';
public static readonly spec = specSchema;
public static readonly scope = 'Namespaced';
#cluster: ResourceReference<typeof PostgresCluster>;
#secret: Secret<SecretData>;
constructor(options: CustomResourceOptions<typeof specSchema>) {
super(options);
const resourceService = this.services.get(ResourceService);
this.#cluster = new ResourceReference();
this.#cluster.on('changed', this.queueReconcile);
this.#secret = resourceService.get(Secret<SecretData>, `${this.name}-pg-connection`, this.namespace);
this.#secret.on('changed', this.queueReconcile);
}
public get username() {
return sanitizeName(`${this.namespace}_${this.name}`);
}
public get database() {
return sanitizeName(`${this.namespace}_${this.name}`);
}
public get cluster() {
return this.#cluster;
}
public get secret() {
return this.#secret;
}
public reconcile = async () => {
const resourceService = this.services.get(ResourceService);
if (this.spec?.cluster) {
const clusterNames = getWithNamespace(this.spec.cluster, this.namespace);
this.#cluster.current = resourceService.get(PostgresCluster, clusterNames.name, clusterNames.namespace);
} else if (this.spec?.environment) {
const { Environment } = await import('../environment/environment.ts');
const environment = resourceService.get(Environment, this.spec.environment);
this.#cluster.current = environment.postgresCluster;
} else {
this.#cluster.current = undefined;
throw new NotReadyError('MissingEnvOrClusterSpec');
}
const clusterSecret = this.#cluster.current.secret.value;
if (!clusterSecret) {
throw new NotReadyError('MissingClusterSecret');
}
await this.#secret.set(
(current) => ({
password: generateRandomHexPass(),
user: this.username,
database: this.database,
...current,
host: clusterSecret.host,
port: clusterSecret.port,
}),
{
metadata: {
ownerReferences: [this.ref],
},
},
);
const secret = this.#secret.value;
if (!secret) {
throw new NotReadyError('MissingSecret');
}
const postgresService = this.services.get(PostgresService);
const database = postgresService.get({
host: clusterSecret.host,
port: clusterSecret.port ? Number(clusterSecret.port) : 5432,
database: clusterSecret.database,
user: clusterSecret.user,
password: clusterSecret.password,
});
const connectionError = await database.ping();
if (connectionError) {
console.error('Failed to connect', connectionError);
throw new NotReadyError('FailedToConnectToDatabase');
}
await database.upsertRole({
name: secret.user,
password: secret.password,
});
await database.upsertDatabase({
name: secret.database,
owner: secret.user,
});
};
}
export { PostgresDatabase };

View File

@@ -0,0 +1,79 @@
import { z } from 'zod';
import { Deployment } from '#resources/core/deployment/deployment.ts';
import { Service } from '#resources/core/service/service.ts';
import { CustomResource, ResourceService, type CustomResourceOptions } from '#services/resources/resources.ts';
import { API_VERSION } from '#utils/consts.ts';
const specSchema = z.object({});
class RedisServer extends CustomResource<typeof specSchema> {
public static readonly apiVersion = API_VERSION;
public static readonly kind = 'RedisServer';
public static readonly spec = specSchema;
public static readonly scope = 'Namespaced';
#deployment: Deployment;
#service: Service;
constructor(options: CustomResourceOptions<typeof specSchema>) {
super(options);
const resourceService = this.services.get(ResourceService);
this.#deployment = resourceService.get(Deployment, this.name, this.namespace);
this.#service = resourceService.get(Service, this.name, this.namespace);
}
public get deployment() {
return this.#deployment;
}
public get service() {
return this.#service;
}
public reconcile = async () => {
await this.#deployment.ensure({
metadata: {
ownerReferences: [this.ref],
},
spec: {
replicas: 1,
selector: {
matchLabels: {
app: this.name,
},
},
template: {
metadata: {
labels: {
app: this.name,
},
},
spec: {
containers: [
{
name: this.name,
image: 'redis:latest',
ports: [{ containerPort: 6379 }],
},
],
},
},
},
});
await this.#service.ensure({
metadata: {
ownerReferences: [this.ref],
},
spec: {
selector: {
app: this.name,
},
ports: [{ port: 6379, targetPort: 6379 }],
},
});
};
}
export { RedisServer };

View File

@@ -0,0 +1,37 @@
import type { KubernetesObject } from '@kubernetes/client-node';
import type { K8SDestinationRuleV1 } from 'src/__generated__/resources/K8SDestinationRuleV1.ts';
import { Resource, ResourceService, type ResourceOptions } from '#services/resources/resources.ts';
import { CRD } from '#resources/core/crd/crd.ts';
import { NotReadyError } from '#utils/errors.ts';
class DestinationRule extends Resource<KubernetesObject & K8SDestinationRuleV1> {
public static readonly apiVersion = 'networking.istio.io/v1';
public static readonly kind = 'DestinationRule';
#crd: CRD;
constructor(options: ResourceOptions<KubernetesObject & K8SDestinationRuleV1>) {
super(options);
const resourceService = this.services.get(ResourceService);
this.#crd = resourceService.get(CRD, 'destinationrules.networking.istio.io');
this.#crd.on('changed', this.#handleChange);
}
public get hasCRD() {
return this.#crd.exists;
}
#handleChange = () => {
this.emit('changed', this.manifest);
};
public set = async (manifest: KubernetesObject & K8SDestinationRuleV1) => {
if (!this.hasCRD) {
throw new NotReadyError('CRD is not installed');
}
await this.ensure(manifest);
};
}
export { DestinationRule };

View File

@@ -0,0 +1,37 @@
import type { KubernetesObject } from '@kubernetes/client-node';
import type { K8SGatewayV1 } from 'src/__generated__/resources/K8SGatewayV1.ts';
import { Resource, ResourceService, type ResourceOptions } from '#services/resources/resources.ts';
import { CRD } from '#resources/core/crd/crd.ts';
import { NotReadyError } from '#utils/errors.ts';
class Gateway extends Resource<KubernetesObject & K8SGatewayV1> {
public static readonly apiVersion = 'networking.istio.io/v1';
public static readonly kind = 'Gateway';
#crd: CRD;
constructor(options: ResourceOptions<KubernetesObject & K8SGatewayV1>) {
super(options);
const resourceService = this.services.get(ResourceService);
this.#crd = resourceService.get(CRD, 'gateways.networking.istio.io');
this.#crd.on('changed', this.#handleUpdate);
}
#handleUpdate = async () => {
this.emit('changed', this.manifest);
};
public get hasCRD() {
return this.#crd.exists;
}
public set = async (manifest: KubernetesObject & K8SGatewayV1) => {
if (!this.hasCRD) {
throw new NotReadyError('CRD is not installed');
}
await this.ensure(manifest);
};
}
export { Gateway };

View File

@@ -0,0 +1,11 @@
import { DestinationRule } from './destination-rule/destination-rule.ts';
import { Gateway } from './gateway/gateway.ts';
import { VirtualService } from './virtual-service/virtual-service.ts';
const istio = {
gateway: Gateway,
destinationRule: DestinationRule,
virtualService: VirtualService,
};
export { istio };

View File

@@ -0,0 +1,37 @@
import type { KubernetesObject } from '@kubernetes/client-node';
import type { K8SVirtualServiceV1 } from 'src/__generated__/resources/K8SVirtualServiceV1.ts';
import { Resource, ResourceService, type ResourceOptions } from '#services/resources/resources.ts';
import { CRD } from '#resources/core/crd/crd.ts';
import { NotReadyError } from '#utils/errors.ts';
class VirtualService extends Resource<KubernetesObject & K8SVirtualServiceV1> {
public static readonly apiVersion = 'networking.istio.io/v1';
public static readonly kind = 'VirtualService';
#crd: CRD;
constructor(options: ResourceOptions<KubernetesObject & K8SVirtualServiceV1>) {
super(options);
const resourceService = this.services.get(ResourceService);
this.#crd = resourceService.get(CRD, 'virtualservices.networking.istio.io');
this.#crd.on('changed', this.#handleChange);
}
public get hasCRD() {
return this.#crd.exists;
}
#handleChange = () => {
this.emit('changed', this.manifest);
};
public set = async (manifest: KubernetesObject & K8SVirtualServiceV1) => {
if (!this.hasCRD) {
throw new NotReadyError('CRD is not installed');
}
await this.ensure(manifest);
};
}
export { VirtualService };

View File

@@ -0,0 +1,17 @@
import { core } from './core/core.ts';
import { flux } from './flux/flux.ts';
import { homelab } from './homelab/homelab.ts';
import { certManager } from './cert-manager/cert-manager.ts';
import { istio } from './istio/istio.ts';
import type { ResourceClass } from '#services/resources/resources.ts';
const resources = {
...core,
...flux,
...certManager,
...istio,
...homelab,
} satisfies Record<string, ResourceClass<ExpectedAny>>;
export { resources };

View File

@@ -0,0 +1,57 @@
import { Cloudflare } from 'cloudflare';
import { EventEmitter } from 'eventemitter3';
import { NamespaceService } from '#bootstrap/namespaces/namespaces.ts';
import { Secret } from '#resources/core/secret/secret.ts';
import { ResourceService } from '#services/resources/resources.ts';
import type { Services } from '#utils/service.ts';
type SecretData = {
account: string;
tunnelName: string;
tunnelId: string;
secret: string;
token: string;
};
type CloudflareServiceEvents = {
changed: () => void;
};
class CloudflareService extends EventEmitter<CloudflareServiceEvents> {
#services: Services;
#secret: Secret<SecretData>;
constructor(services: Services) {
super();
this.#services = services;
const resourceService = this.#services.get(ResourceService);
const namespaceService = this.#services.get(NamespaceService);
this.#secret = resourceService.get(Secret<SecretData>, 'cloudflare', namespaceService.homelab.name);
this.#secret.on('changed', this.emit.bind(this, 'changed'));
}
public get secret() {
return this.#secret.value;
}
public get ready() {
return !!this.secret;
}
public get client() {
const token = this.#secret.value?.token;
if (!token) {
throw new Error('Cloudflare API token is not set');
}
const client = new Cloudflare({
apiToken: token,
});
return client;
}
}
export { CloudflareService };

View File

@@ -1,109 +0,0 @@
import { EventEmitter } from 'eventemitter3';
import equal from 'deep-equal';
import type { CustomResource } from './custom-resources.custom-resource.ts';
import type { CustomResourceStatus } from './custom-resources.types.ts';
type CustomResourceStatusOptions = {
resource: CustomResource<ExpectedAny>;
};
type CustomResourceConditionsEvents = {
changed: (type: string, condition: Condition) => void;
};
type Condition = {
lastTransitionTime: Date;
status: 'True' | 'False' | 'Unknown';
syncing?: boolean;
failed?: boolean;
resource?: boolean;
reason?: string;
message?: string;
observedGeneration?: number;
};
class CustomResourceConditions extends EventEmitter<CustomResourceConditionsEvents> {
#options: CustomResourceStatusOptions;
#conditions: Record<string, Condition>;
#changed: boolean;
constructor(options: CustomResourceStatusOptions) {
super();
this.#options = options;
this.#conditions = Object.fromEntries(
(options.resource.status?.conditions || []).map(({ type, lastTransitionTime, ...condition }) => [
type,
{
...condition,
lastTransitionTime: new Date(lastTransitionTime),
},
]),
);
options.resource.on('changed', this.#handleChange);
this.#changed = false;
}
#handleChange = () => {
const { resource } = this.#options;
for (const { type, ...condition } of resource.status?.conditions || []) {
const next = {
...condition,
lastTransitionTime: new Date(condition.lastTransitionTime),
};
const current = this.#conditions[type];
const isEqual = equal(current, next);
const isNewer = !current || next.lastTransitionTime > current.lastTransitionTime;
if (isEqual || !isNewer) {
return;
}
this.#conditions[type] = next;
this.emit('changed', type, next);
}
};
public get = (type: string): Condition | undefined => {
return this.#conditions[type];
};
public set = async (type: string, condition: Omit<Condition, 'lastTransitionTime'>) => {
const current = this.#conditions[type];
const isEqual = equal(
{ ...current, lastTransitionTime: undefined },
{ ...condition, lastTransitionTime: undefined },
);
if (isEqual) {
return;
}
this.#changed = true;
this.#conditions[type] = {
...condition,
lastTransitionTime: current && current.status === condition.status ? current.lastTransitionTime : new Date(),
observedGeneration: this.#options.resource.metadata?.generation,
};
await this.save();
};
public save = async () => {
if (!this.#changed) {
return;
}
try {
this.#changed = false;
const { resource } = this.#options;
const status: CustomResourceStatus = {
conditions: Object.entries(this.#conditions).map(([type, condition]) => ({
...condition,
type,
lastTransitionTime: condition.lastTransitionTime.toISOString(),
})),
};
await resource.patchStatus(status);
} catch (error) {
this.#changed = true;
throw error;
}
};
}
export { CustomResourceConditions };

View File

@@ -1,208 +0,0 @@
import type { z, ZodObject } from 'zod';
import { ApiException, PatchStrategy, setHeaderOptions, type KubernetesObject } from '@kubernetes/client-node';
import { EventEmitter } from 'eventemitter3';
import type { Resource } from '../resources/resources.resource.ts';
import type { Services } from '../../utils/service.ts';
import { K8sService } from '../k8s/k8s.ts';
import { CoalescingQueued } from '../../utils/queues.ts';
import type { CustomResourceDefinition, CustomResourceStatus } from './custom-resources.types.ts';
import { CustomResourceConditions } from './custom-resources.conditions.ts';
type CustomResourceObject<TSpec extends ZodObject> = KubernetesObject & {
spec: z.infer<TSpec>;
status?: CustomResourceStatus;
};
type CustomResourceOptions<TSpec extends ZodObject> = {
resource: Resource<CustomResourceObject<TSpec>>;
services: Services;
definition: CustomResourceDefinition<TSpec>;
};
type CustomResourceEvents<TSpec extends ZodObject> = {
changed: () => void;
changedStatus: (options: { previous: CustomResourceStatus; next: CustomResourceStatus }) => void;
changedMetadate: (options: { previous: KubernetesObject['metadata']; next: KubernetesObject['metadata'] }) => void;
changedSpec: (options: { previous: z.infer<TSpec>; next: z.infer<TSpec> }) => void;
};
type SubresourceResult = {
ready: boolean;
syncing?: boolean;
failed?: boolean;
reason?: string;
message?: string;
};
abstract class CustomResource<TSpec extends ZodObject> extends EventEmitter<CustomResourceEvents<TSpec>> {
#options: CustomResourceOptions<TSpec>;
#conditions: CustomResourceConditions;
#queue: CoalescingQueued<void>;
constructor(options: CustomResourceOptions<TSpec>) {
super();
this.#options = options;
this.#conditions = new CustomResourceConditions({
resource: this,
});
options.resource.on('changed', this.#handleChanged);
this.#queue = new CoalescingQueued({
action: async () => {
if (this.exists && !this.isValidSpec) {
this.services.log.error(
`Invalid spec for ${this.apiVersion}/${this.kind}/${this.namespace}/${this.name}`,
this.spec,
);
return;
}
console.log('Reconcileing', this.apiVersion, this.kind, this.namespace, this.name);
await this.reconcile?.();
},
});
}
public get conditions() {
return this.#conditions;
}
public get names() {
return this.#options.definition.names;
}
public get services() {
const { services } = this.#options;
return services;
}
public get resource() {
const { resource } = this.#options;
return resource;
}
public get apiVersion() {
return this.resource.apiVersion;
}
public get kind() {
return this.resource.kind;
}
public get metadata(): KubernetesObject['metadata'] {
const metadata = this.resource.metadata;
return (
metadata || {
name: this.name,
namespace: this.namespace,
}
);
}
public get name() {
return this.resource.specifier.name;
}
public get namespace() {
const namespace = this.resource.specifier.namespace;
if (!namespace) {
throw new Error('Custom resources needs a namespace');
}
return namespace;
}
public get exists() {
return this.resource.exists;
}
public get ref() {
return this.resource.ref;
}
public get spec(): z.infer<TSpec> {
return this.resource.spec as ExpectedAny;
}
public get status() {
return this.resource.manifest?.status;
}
public get isSeen() {
return this.metadata?.generation === this.status?.observedGeneration;
}
public get isValidSpec() {
const { success } = this.#options.definition.spec.safeParse(this.spec);
return success;
}
public setup?: () => Promise<void>;
public reconcile?: () => Promise<void>;
public markSeen = async () => {
if (this.isSeen) {
return;
}
await this.patchStatus({
observedGeneration: this.metadata?.generation,
});
};
public queueReconcile = async () => {
return this.#queue.run();
};
#handleChanged = () => {
this.emit('changed');
};
public reconcileSubresource = async (name: string, action: () => Promise<SubresourceResult>) => {
try {
const result = await action();
await this.conditions.set(name, {
status: result.ready ? 'True' : 'False',
syncing: result.syncing,
failed: result.failed ?? false,
resource: true,
reason: result.reason,
message: result.message,
});
} catch (err) {
console.error(err);
await this.conditions.set(name, {
status: 'False',
failed: true,
reason: 'Failed',
resource: true,
message: err instanceof Error ? err.message : String(err),
});
}
};
public patchStatus = async (status: Partial<CustomResourceStatus>) => {
const k8s = this.services.get(K8sService);
const [group, version] = this.apiVersion?.split('/') || [];
try {
await k8s.customObjectsApi.patchNamespacedCustomObjectStatus(
{
group,
version,
plural: this.names.plural,
name: this.name,
namespace: this.namespace,
body: {
status,
},
},
setHeaderOptions('Content-Type', PatchStrategy.MergePatch),
);
} catch (err) {
if (err instanceof ApiException && err.code === 404) {
return;
}
throw err;
}
};
}
export { CustomResource, type CustomResourceOptions, type CustomResourceObject, type SubresourceResult };

View File

@@ -1,128 +0,0 @@
import { ApiException, type KubernetesObject } from '@kubernetes/client-node';
import type { ZodObject } from 'zod';
import type { Services } from '../../utils/service.ts';
import type { Resource } from '../resources/resources.resource.ts';
import { WatcherService } from '../watchers/watchers.ts';
import { K8sService } from '../k8s/k8s.ts';
import { Queue } from '../queue/queue.ts';
import type { CustomResourceDefinition } from './custom-resources.types.ts';
import type { CustomResource } from './custom-resources.custom-resource.ts';
import { createManifest } from './custom-resources.utils.ts';
type DefinitionItem = {
definition: CustomResourceDefinition<ExpectedAny>;
queue: Queue;
};
class CustomResourceService {
#services: Services;
#definitions: DefinitionItem[];
#resources: Map<string, CustomResource<ExpectedAny>>;
constructor(services: Services) {
this.#definitions = [];
this.#resources = new Map();
this.#services = services;
}
#handleChanged = async (resource: Resource<KubernetesObject>) => {
const uid = resource.metadata?.uid;
if (!uid) {
return;
}
let current = this.#resources.get(uid);
if (!current) {
const entry = this.#definitions.find(
({ definition: r }) =>
r.version === resource.version &&
r.group === resource.group &&
r.version === resource.version &&
r.kind === resource.kind,
);
if (!entry) {
return;
}
const { definition } = entry;
current = definition.create({
resource: resource as Resource<ExpectedAny>,
services: this.#services,
definition,
});
this.#resources.set(uid, current);
await current.setup?.();
if (!current.isSeen) {
await current.markSeen();
}
await current.queueReconcile();
} else if (!current.isSeen) {
await current.markSeen();
await current.queueReconcile();
}
};
public register = (...resources: CustomResourceDefinition<ExpectedAny>[]) => {
this.#definitions.push(
...resources.map((definition) => ({
definition,
queue: new Queue(),
})),
);
};
public install = async (replace = false) => {
const k8sService = this.#services.get(K8sService);
for (const { definition: crd } of this.#definitions) {
this.#services.log.info('Installing CRD', { kind: crd.kind });
try {
const manifest = createManifest(crd);
try {
await k8sService.extensionsApi.createCustomResourceDefinition({
body: manifest,
});
} catch (error) {
if (error instanceof ApiException && error.code === 409) {
if (replace) {
await k8sService.extensionsApi.patchCustomResourceDefinition({
name: manifest.metadata.name,
body: [{ op: 'replace', path: '/spec', value: manifest.spec }],
});
}
continue;
}
throw error;
}
} catch (error) {
if (error instanceof ApiException) {
throw new Error(`Failed to install ${crd.kind}: ${error.body}`);
}
throw error;
}
}
};
public watch = async () => {
const watcherService = this.#services.get(WatcherService);
for (const { definition, queue } of this.#definitions) {
const watcher = watcherService.create({
path: `/apis/${definition.group}/${definition.version}/${definition.names.plural}`,
list: (k8s) =>
k8s.customObjectsApi.listCustomObjectForAllNamespaces({
version: definition.version,
group: definition.group,
plural: definition.names.plural,
}),
verbs: ['add', 'update', 'delete'],
});
watcher.on('changed', (resource) => {
queue.add(() => this.#handleChanged(resource));
});
await watcher.start();
}
};
}
const createCustomResourceDefinition = <TSpec extends ZodObject>(options: CustomResourceDefinition<TSpec>) => options;
export { CustomResourceService, createCustomResourceDefinition };

View File

@@ -1,38 +0,0 @@
import { z, type ZodObject } from 'zod';
import type { CustomResource, CustomResourceOptions } from './custom-resources.custom-resource.ts';
type CustomResourceDefinition<TSpec extends ZodObject> = {
group: string;
version: string;
kind: string;
names: {
plural: string;
singular: string;
};
spec: TSpec;
create: (options: CustomResourceOptions<TSpec>) => CustomResource<TSpec>;
};
const customResourceStatusSchema = z.object({
observedGeneration: z.number().optional(),
conditions: z
.array(
z.object({
observedGeneration: z.number().optional(),
type: z.string(),
status: z.enum(['True', 'False', 'Unknown']),
lastTransitionTime: z.string().datetime(),
resource: z.boolean().optional(),
failed: z.boolean().optional(),
syncing: z.boolean().optional(),
reason: z.string().optional().optional(),
message: z.string().optional().optional(),
}),
)
.optional(),
});
type CustomResourceStatus = z.infer<typeof customResourceStatusSchema>;
export { customResourceStatusSchema, type CustomResourceDefinition, type CustomResourceStatus };

View File

@@ -5,8 +5,8 @@ import {
CustomObjectsApi,
EventsV1Api,
KubernetesObjectApi,
ApiException,
AppsV1Api,
StorageV1Api,
} from '@kubernetes/client-node';
class K8sService {
@@ -17,6 +17,7 @@ class K8sService {
#k8sEventsApi: EventsV1Api;
#k8sObjectsApi: KubernetesObjectApi;
#k8sAppsApi: AppsV1Api;
#k8sStorageApi: StorageV1Api;
constructor() {
this.#kc = new KubeConfig();
@@ -27,6 +28,7 @@ class K8sService {
this.#k8sEventsApi = this.#kc.makeApiClient(EventsV1Api);
this.#k8sObjectsApi = this.#kc.makeApiClient(KubernetesObjectApi);
this.#k8sAppsApi = this.#kc.makeApiClient(AppsV1Api);
this.#k8sStorageApi = this.#kc.makeApiClient(StorageV1Api);
}
public get config() {
@@ -56,6 +58,10 @@ class K8sService {
public get apps() {
return this.#k8sAppsApi;
}
public get storageApi() {
return this.#k8sStorageApi;
}
}
export { K8sService };

View File

@@ -10,6 +10,7 @@ type PostgresInstanceOptions = {
port?: number;
user: string;
password: string;
database?: string;
};
class PostgresInstance {
@@ -19,10 +20,11 @@ class PostgresInstance {
this.#db = knex({
client: 'pg',
connection: {
host: process.env.FORCE_PG_HOST ?? options.host,
user: process.env.FORCE_PG_USER ?? options.user,
password: process.env.FORCE_PG_PASSWORD ?? options.password,
port: process.env.FORCE_PG_PORT ? parseInt(process.env.FORCE_PG_PORT) : options.port,
host: options.host,
user: options.user,
password: options.password,
port: options.port,
database: options.database,
},
});
}
@@ -30,29 +32,32 @@ class PostgresInstance {
public ping = async () => {
try {
await this.#db.raw('SELECT 1');
return true;
} catch {
return false;
return;
} catch (err) {
return err;
}
};
public upsertRole = async (role: PostgresRole) => {
const existingRole = await this.#db.raw('SELECT 1 FROM pg_roles WHERE rolname = ?', [role.name]);
const name = role.name;
const existingRole = await this.#db.raw('SELECT 1 FROM pg_roles WHERE rolname = ?', [name]);
if (existingRole.rows.length === 0) {
await this.#db.raw(`CREATE ROLE ${role.name} WITH LOGIN PASSWORD '${role.password}'`);
await this.#db.raw(`CREATE ROLE "${name}" WITH LOGIN PASSWORD '${role.password}'`);
} else {
await this.#db.raw(`ALTER ROLE ${role.name} WITH PASSWORD '${role.password}'`);
await this.#db.raw(`ALTER ROLE "${name}" WITH PASSWORD '${role.password}'`);
}
};
public upsertDatabase = async (database: PostgresDatabase) => {
const existingDatabase = await this.#db.raw('SELECT * FROM pg_database WHERE datname = ?', [database.name]);
const owner = database.owner;
const name = database.name;
const existingDatabase = await this.#db.raw('SELECT * FROM pg_database WHERE datname = ?', [name]);
if (existingDatabase.rows.length === 0) {
await this.#db.raw(`CREATE DATABASE ${database.name} OWNER ${database.owner}`);
await this.#db.raw(`CREATE DATABASE "${name}" OWNER "${owner}"`);
} else {
await this.#db.raw(`ALTER DATABASE ${database.name} OWNER TO ${database.owner}`);
await this.#db.raw(`ALTER DATABASE "${name}" OWNER TO "${owner}"`);
}
};
}

View File

@@ -0,0 +1,186 @@
import { z, type ZodType } from 'zod';
import { PatchStrategy, setHeaderOptions, type KubernetesObject } from '@kubernetes/client-node';
import { Resource, type ResourceOptions } from './resource.ts';
import { API_VERSION } from '#utils/consts.ts';
import { CoalescingQueued } from '#utils/queues.ts';
import { NotReadyError } from '#utils/errors.ts';
import { K8sService } from '#services/k8s/k8s.ts';
import { CronJob, CronTime } from 'cron';
const customResourceStatusSchema = z.object({
observedGeneration: z.number().optional(),
conditions: z
.array(
z.object({
observedGeneration: z.number().optional(),
type: z.string(),
status: z.enum(['True', 'False', 'Unknown']),
lastTransitionTime: z.string().datetime().optional(),
resource: z.boolean().optional(),
failed: z.boolean().optional(),
syncing: z.boolean().optional(),
reason: z.string().optional().optional(),
message: z.string().optional().optional(),
}),
)
.optional(),
});
type CustomResourceOptions<TSpec extends ZodType> = ResourceOptions<KubernetesObject & { spec: z.infer<TSpec> }>;
class CustomResource<TSpec extends ZodType> extends Resource<
KubernetesObject & { spec: z.infer<TSpec>; status?: z.infer<typeof customResourceStatusSchema> }
> {
public static readonly apiVersion = API_VERSION;
public static readonly status = customResourceStatusSchema;
#reconcileQueue: CoalescingQueued<void>;
#cron: CronJob;
constructor(options: CustomResourceOptions<TSpec>) {
super(options);
this.#reconcileQueue = new CoalescingQueued({
action: async () => {
try {
if (!this.exists || this.manifest?.metadata?.deletionTimestamp) {
return;
}
this.services.log.debug('Reconciling', {
apiVersion: this.apiVersion,
kind: this.kind,
namespace: this.namespace,
name: this.name,
});
await this.markSeen();
await this.reconcile?.();
await this.markReady();
} catch (err) {
if (err instanceof NotReadyError) {
await this.markNotReady(err.reason, err.message);
} else if (err instanceof Error) {
await this.markNotReady('Failed', err.message);
} else {
await this.markNotReady('Failed', String(err));
}
console.error(err);
}
},
});
this.#cron = CronJob.from({
cronTime: '*/2 * * * *',
onTick: this.queueReconcile,
start: true,
runOnInit: true,
});
this.on('changed', this.#handleUpdate);
}
public get reconcileTime() {
return this.#cron.cronTime.toString();
}
public set reconcileTime(pattern: string) {
this.#cron.cronTime = new CronTime(pattern);
}
public get isSeen() {
return this.metadata?.generation === this.status?.observedGeneration;
}
public get version() {
const [, version] = this.apiVersion.split('/');
return version;
}
public get group() {
const [group] = this.apiVersion.split('/');
return group;
}
public get scope() {
if (!('scope' in this.constructor) || typeof this.constructor.scope !== 'string') {
return;
}
return this.constructor.scope as 'Namespaced' | 'Cluster';
}
#handleUpdate = async (
previous?: KubernetesObject & { spec: z.infer<TSpec>; status?: z.infer<typeof customResourceStatusSchema> },
) => {
if (this.isSeen && previous) {
return;
}
return await this.queueReconcile();
};
public reconcile?: () => Promise<void>;
public queueReconcile = () => {
return this.#reconcileQueue.run();
};
public markSeen = async () => {
if (this.isSeen) {
return;
}
await this.patchStatus({
observedGeneration: this.metadata?.generation,
});
};
public markNotReady = async (reason?: string, message?: string) => {
await this.patchStatus({
conditions: [
{
type: 'Ready',
status: 'False',
reason,
message,
},
],
});
};
public markReady = async () => {
await this.patchStatus({
conditions: [
{
type: 'Ready',
status: 'True',
},
],
});
};
public patchStatus = (status: Partial<z.infer<typeof customResourceStatusSchema>>) =>
this.queue.add(async () => {
const k8sService = this.services.get(K8sService);
if (this.scope === 'Cluster') {
await k8sService.customObjectsApi.patchClusterCustomObjectStatus(
{
version: this.version,
group: this.group,
plural: this.plural,
name: this.name,
body: { status },
},
setHeaderOptions('Content-Type', PatchStrategy.MergePatch),
);
} else {
await k8sService.customObjectsApi.patchNamespacedCustomObjectStatus(
{
version: this.version,
group: this.group,
plural: this.plural,
name: this.name,
namespace: this.namespace || 'default',
body: { status },
},
setHeaderOptions('Content-Type', PatchStrategy.MergePatch),
);
}
});
}
export { CustomResource, type CustomResourceOptions };

View File

@@ -0,0 +1,38 @@
import { EventEmitter } from 'eventemitter3';
import type { ResourceClass } from '../resources.ts';
import type { ResourceEvents } from './resource.ts';
class ResourceReference<T extends ResourceClass<ExpectedAny>> extends EventEmitter<ResourceEvents> {
#current?: InstanceType<T>;
constructor(current?: InstanceType<T>) {
super();
this.#current = current;
}
public get current() {
return this.#current;
}
public set current(value: InstanceType<T> | undefined) {
const previous = this.#current;
if (this.#current) {
this.#current.off('changed', this.#handleChange);
}
if (value) {
value.on('changed', this.#handleChange);
}
this.#current = value;
if (previous !== value) {
this.emit('changed');
}
}
#handleChange = () => {
this.emit('changed');
};
}
export { ResourceReference };

View File

@@ -0,0 +1,187 @@
import { ApiException, PatchStrategy, type KubernetesObject } from '@kubernetes/client-node';
import { EventEmitter } from 'eventemitter3';
import deepEqual from 'deep-equal';
import type { Services } from '../../../utils/service.ts';
import { Queue } from '../../queue/queue.ts';
import { K8sService } from '../../k8s/k8s.ts';
import { isDeepSubset } from '../../../utils/objects.ts';
type ResourceSelector = {
apiVersion: string;
kind: string;
name: string;
namespace?: string;
};
type ResourceOptions<T extends KubernetesObject> = {
services: Services;
selector: ResourceSelector;
manifest?: T;
};
type ResourceEvents<T extends KubernetesObject> = {
changed: (from?: T) => void;
};
class Resource<T extends KubernetesObject> extends EventEmitter<ResourceEvents<T>> {
#manifest?: T;
#queue: Queue;
#options: ResourceOptions<T>;
constructor(options: ResourceOptions<T>) {
super();
this.#options = options;
this.#manifest = options.manifest;
this.#queue = new Queue({ concurrency: 1 });
}
protected get queue() {
return this.#queue;
}
public get services() {
return this.#options.services;
}
public get manifest() {
return this.#manifest;
}
public set manifest(value: T | undefined) {
if (deepEqual(this.manifest, value)) {
return;
}
const previous = this.#manifest;
this.#manifest = value;
this.emit('changed', previous);
}
public get plural() {
if ('plural' in this.constructor && typeof this.constructor.plural === 'string') {
return this.constructor.plural;
}
if ('kind' in this.constructor && typeof this.constructor.kind === 'string') {
return this.constructor.kind.toLowerCase() + 's';
}
throw new Error('Unknown kind');
}
public get exists() {
return !!this.#manifest;
}
public get ready() {
return this.exists;
}
public get selector() {
return this.#options.selector;
}
public get apiVersion() {
return this.selector.apiVersion;
}
public get kind() {
return this.selector.kind;
}
public get name() {
return this.selector.name;
}
public get namespace() {
return this.selector.namespace;
}
public get metadata() {
return this.manifest?.metadata;
}
public get ref() {
if (!this.metadata?.uid) {
throw new Error('No uid for resource');
}
return {
apiVersion: this.apiVersion,
kind: this.kind,
name: this.name,
uid: this.metadata.uid,
};
}
public get spec(): (T extends { spec?: infer K } ? K : never) | undefined {
const manifest = this.manifest;
if (!manifest || !('spec' in manifest)) {
return;
}
return manifest.spec as ExpectedAny;
}
public get data(): (T extends { data?: infer K } ? K : never) | undefined {
const manifest = this.manifest;
if (!manifest || !('data' in manifest)) {
return;
}
return manifest.data as ExpectedAny;
}
public get status(): (T extends { status?: infer K } ? K : never) | undefined {
const manifest = this.manifest;
if (!manifest || !('status' in manifest)) {
return;
}
return manifest.status as ExpectedAny;
}
public patch = (patch: T) =>
this.#queue.add(async () => {
const { services } = this.#options;
services.log.debug(`Patching ${this.apiVersion}/${this.kind}/${this.namespace}/${this.name}`);
const k8s = services.get(K8sService);
const body = {
...patch,
apiVersion: this.selector.apiVersion,
kind: this.selector.kind,
metadata: {
...patch.metadata,
name: this.selector.name,
namespace: this.selector.namespace,
},
};
try {
this.manifest = await k8s.objectsApi.patch(
body,
undefined,
undefined,
undefined,
undefined,
PatchStrategy.MergePatch,
);
} catch (err) {
if (err instanceof ApiException && err.code === 404) {
this.manifest = await k8s.objectsApi.create(body);
return;
}
throw err;
}
});
public getCondition = (
condition: string,
): T extends { status?: { conditions?: (infer U)[] } } ? U | undefined : undefined => {
const status = this.status as ExpectedAny;
return status?.conditions?.find((c: ExpectedAny) => c?.type === condition);
};
public ensure = async (manifest: T) => {
if (isDeepSubset(this.manifest, manifest)) {
return false;
}
await this.patch(manifest);
return true;
};
}
export { Resource, type ResourceOptions, type ResourceEvents };

View File

@@ -1,81 +0,0 @@
import type { KubernetesObject } from '@kubernetes/client-node';
import { EventEmitter } from 'eventemitter3';
import type { Resource } from './resources.ts';
import type { ResourceEvents } from './resources.resource.ts';
type ResourceReferenceEvents<T extends KubernetesObject> = ResourceEvents<T> & {
replaced: (options: { previous: Resource<T> | undefined; next: Resource<T> | undefined }) => void;
};
class ResourceReference<T extends KubernetesObject = KubernetesObject> extends EventEmitter<
ResourceReferenceEvents<T>
> {
#current?: Resource<T>;
#updatedEvent: ResourceEvents<T>['updated'];
#changedEvent: ResourceEvents<T>['changed'];
#changedMetadateEvent: ResourceEvents<T>['changedMetadate'];
#changedSpecEvent: ResourceEvents<T>['changedSpec'];
#changedStatusEvent: ResourceEvents<T>['changedStatus'];
#deletedEvent: ResourceEvents<T>['deleted'];
constructor(current?: Resource<T>) {
super();
this.#updatedEvent = this.emit.bind(this, 'updated');
this.#changedEvent = this.emit.bind(this, 'changed');
this.#changedMetadateEvent = this.emit.bind(this, 'changedMetadate');
this.#changedSpecEvent = this.emit.bind(this, 'changedSpec');
this.#changedStatusEvent = this.emit.bind(this, 'changedStatus');
this.#deletedEvent = this.emit.bind(this, 'deleted');
this.current = current;
}
public get current() {
return this.#current;
}
public set current(next: Resource<T> | undefined) {
const previous = this.#current;
if (next === previous) {
return;
}
if (this.#current) {
this.#current.off('updated', this.#updatedEvent);
this.#current.off('changed', this.#changedEvent);
this.#current.off('changedMetadate', this.#changedMetadateEvent);
this.#current.off('changedSpec', this.#changedSpecEvent);
this.#current.off('changedStatus', this.#changedStatusEvent);
this.#current.off('deleted', this.#deletedEvent);
}
if (next) {
next.on('updated', this.#updatedEvent);
next.on('changed', this.#changedEvent);
next.on('changedMetadate', this.#changedMetadateEvent);
next.on('changedSpec', this.#changedSpecEvent);
next.on('changedStatus', this.#changedStatusEvent);
next.on('deleted', this.#deletedEvent);
}
this.#current = next;
this.emit('replaced', {
previous,
next,
});
this.emit('changedStatus', {
previous: previous && 'status' in previous ? (previous.status as ExpectedAny) : undefined,
next: next && 'status' in next ? (next.status as ExpectedAny) : undefined,
});
this.emit('changedMetadate', {
previous: previous && 'metadata' in previous ? (previous.metadata as ExpectedAny) : undefined,
next: next && 'metadata' in next ? (next.metadata as ExpectedAny) : undefined,
});
this.emit('changedSpec', {
previous: previous && 'spec' in previous ? (previous.spec as ExpectedAny) : undefined,
next: next && 'spec' in next ? (next.spec as ExpectedAny) : undefined,
});
this.emit('changed');
this.emit('updated');
}
}
export { ResourceReference };

View File

@@ -1,298 +0,0 @@
import { ApiException, PatchStrategy, V1MicroTime, type KubernetesObject } from '@kubernetes/client-node';
import { EventEmitter } from 'eventemitter3';
import equal from 'deep-equal';
import { Services } from '../../utils/service.ts';
import { K8sService } from '../k8s/k8s.ts';
import { Queue } from '../queue/queue.ts';
import { GROUP } from '../../utils/consts.ts';
import { ResourceService } from './resources.ts';
type ResourceOptions<T extends KubernetesObject> = {
services: Services;
manifest?: T;
data: {
apiVersion: string;
kind: string;
name: string;
namespace?: string;
};
};
type UnknownResource = KubernetesObject & {
spec: ExpectedAny;
data: ExpectedAny;
};
type EventOptions = {
reason: string;
message: string;
action: string;
type: 'Normal' | 'Warning' | 'Error';
};
type ResourceEvents<T extends KubernetesObject> = {
updated: () => void;
deleted: () => void;
changed: () => void;
changedStatus: (options: {
previous: T extends { status: infer K } ? K | undefined : never;
next: T extends { status: infer K } ? K | undefined : never;
}) => void;
changedMetadate: (options: { previous: T['metadata'] | undefined; next: T['metadata'] | undefined }) => void;
changedSpec: (options: {
previous: T extends { spec: infer K } ? K | undefined : never;
next: T extends { spec: infer K } ? K | undefined : never;
}) => void;
};
class Resource<T extends KubernetesObject = UnknownResource> extends EventEmitter<ResourceEvents<T>> {
#options: ResourceOptions<T>;
#queue: Queue;
constructor(options: ResourceOptions<T>) {
super();
this.#options = options;
this.#queue = new Queue({ concurrency: 1 });
}
public get specifier() {
return this.#options.data;
}
public get manifest() {
return this.#options?.manifest;
}
public set manifest(obj: T | undefined) {
if (equal(obj, this.manifest)) {
return;
}
this.#options.manifest = obj;
const nextManifest = obj || {};
const currentManifest = this.manifest || {};
const nextStatus = 'status' in nextManifest ? nextManifest.status : undefined;
const currentStatus = 'status' in currentManifest ? currentManifest.status : undefined;
if (!equal(nextStatus, currentStatus)) {
this.emit('changedStatus', {
previous: currentStatus as ExpectedAny,
next: nextStatus as ExpectedAny,
});
}
const nextSpec = 'spec' in nextManifest ? nextManifest.spec : undefined;
const currentSpec = 'spec' in currentManifest ? currentManifest.spec : undefined;
if (!equal(nextSpec, currentSpec)) {
this.emit('changedSpec', {
next: nextSpec as ExpectedAny,
previous: currentSpec as ExpectedAny,
});
}
const nextMetadata = 'metadata' in nextManifest ? nextManifest.metadata : undefined;
const currentMetadata = 'metadata' in currentManifest ? currentManifest.metadata : undefined;
if (!equal(nextMetadata, currentMetadata)) {
this.emit('changedMetadate', {
next: nextMetadata as ExpectedAny,
previous: currentMetadata as ExpectedAny,
});
}
this.emit('updated');
this.emit('changed');
}
public get ref() {
if (!this.metadata?.uid) {
throw new Error('No uid for resource');
}
return {
apiVersion: this.apiVersion,
kind: this.kind,
name: this.name,
uid: this.metadata.uid,
};
}
public get exists() {
return !!this.manifest;
}
public get apiVersion() {
return this.#options.data.apiVersion;
}
public get group() {
const [group] = this.apiVersion?.split('/') || [];
return group;
}
public get version() {
const [, version] = this.apiVersion?.split('/') || [];
return version;
}
public get kind() {
return this.#options.data.kind;
}
public get metadata() {
return this.manifest?.metadata;
}
public get name() {
return this.#options.data.name;
}
public get namespace() {
return this.#options.data.namespace;
}
public get spec(): T extends { spec?: infer K } ? K | undefined : never {
if (this.manifest && 'spec' in this.manifest) {
return this.manifest.spec as ExpectedAny;
}
return undefined as ExpectedAny;
}
public get data(): T extends { data?: infer K } ? K | undefined : never {
if (this.manifest && 'data' in this.manifest) {
return this.manifest.data as ExpectedAny;
}
return undefined as ExpectedAny;
}
public get owners() {
const { services } = this.#options;
const references = this.metadata?.ownerReferences || [];
const resourceService = services.get(ResourceService);
return references.map((ref) =>
resourceService.get({
apiVersion: ref.apiVersion,
kind: ref.kind,
name: ref.name,
namespace: this.namespace,
}),
);
}
public patch = (patch: T) =>
this.#queue.add(async () => {
const { services } = this.#options;
services.log.debug(`Patching ${this.apiVersion}/${this.kind}/${this.namespace}/${this.name}`, {
specifier: this.specifier,
current: this.manifest,
patch,
});
const k8s = services.get(K8sService);
const body = {
...patch,
apiVersion: this.specifier.apiVersion,
kind: this.specifier.kind,
metadata: {
...patch.metadata,
name: this.specifier.name,
namespace: this.specifier.namespace,
},
};
try {
this.manifest = await k8s.objectsApi.patch(
body,
undefined,
undefined,
undefined,
undefined,
PatchStrategy.MergePatch,
);
} catch (err) {
if (err instanceof ApiException && err.code === 404) {
this.manifest = await k8s.objectsApi.create(body);
return;
}
throw err;
}
});
public delete = () =>
this.#queue.add(async () => {
try {
const { services } = this.#options;
services.log.debug(`Deleting ${this.apiVersion}/${this.kind}/${this.namespace}/${this.name}`);
const k8s = services.get(K8sService);
await k8s.objectsApi.delete({
apiVersion: this.specifier.apiVersion,
kind: this.specifier.kind,
metadata: {
name: this.specifier.name,
namespace: this.specifier.namespace,
},
});
this.manifest = undefined;
} catch (err) {
if (err instanceof ApiException && err.code === 404) {
return;
}
throw err;
}
});
public load = () =>
this.#queue.add(async () => {
const { services } = this.#options;
const k8s = services.get(K8sService);
try {
const manifest = await k8s.objectsApi.read({
apiVersion: this.specifier.apiVersion,
kind: this.specifier.kind,
metadata: {
name: this.specifier.name,
namespace: this.specifier.namespace,
},
});
this.manifest = manifest as T;
} catch (err) {
if (err instanceof ApiException && err.code === 404) {
this.manifest = undefined;
} else {
throw err;
}
}
});
public addEvent = (event: EventOptions) =>
this.#queue.add(async () => {
const { services } = this.#options;
const k8sService = services.get(K8sService);
services.log.debug(`Adding event ${this.apiVersion}/${this.kind}/${this.namespace}/${this.name}`, event);
await k8sService.eventsApi.createNamespacedEvent({
namespace: this.specifier.namespace || 'default',
body: {
kind: 'Event',
metadata: {
name: `${this.specifier.name}-${Date.now()}-${Buffer.from(crypto.getRandomValues(new Uint8Array(8))).toString('hex')}`,
namespace: this.specifier.namespace,
},
eventTime: new V1MicroTime(),
note: event.message,
action: event.action,
reason: event.reason,
type: event.type,
reportingController: GROUP,
reportingInstance: this.name,
regarding: {
apiVersion: this.specifier.apiVersion,
resourceVersion: this.metadata?.resourceVersion,
kind: this.specifier.kind,
name: this.specifier.name,
namespace: this.specifier.namespace,
uid: this.metadata?.uid,
},
},
});
});
}
export { Resource, type UnknownResource, type ResourceEvents };

View File

@@ -1,44 +1,139 @@
import type { KubernetesObject } from '@kubernetes/client-node';
import { ApiException, type KubernetesObject } from '@kubernetes/client-node';
import type { ZodType } from 'zod';
import type { Services } from '../../utils/service.ts';
import { WatcherService } from '../watchers/watchers.ts';
import { Resource } from './resources.resource.ts';
import { Resource, type ResourceOptions } from './resource/resource.ts';
import { createManifest } from './resources.utils.ts';
type ResourceGetOptions = {
import { K8sService } from '#services/k8s/k8s.ts';
import { EventEmitter } from 'eventemitter3';
type ResourceClass<T extends KubernetesObject> = (new (options: ResourceOptions<T>) => Resource<T>) & {
apiVersion: string;
kind: string;
name: string;
namespace?: string;
plural?: string;
};
class ResourceService {
#cache: Resource<ExpectedAny>[] = [];
type InstallableResourceClass<T extends KubernetesObject> = ResourceClass<T> & {
spec: ZodType;
status: ZodType;
scope: 'Namespaced' | 'Cluster';
};
type ResourceServiceEvents = {
changed: (resource: Resource<ExpectedAny>) => void;
};
class ResourceService extends EventEmitter<ResourceServiceEvents> {
#services: Services;
#registry: Map<
ResourceClass<ExpectedAny>,
{
apiVersion: string;
kind: string;
plural?: string;
resources: Resource<ExpectedAny>[];
}
>;
constructor(services: Services) {
super();
this.#services = services;
this.#registry = new Map();
}
public get = <T extends KubernetesObject>(options: ResourceGetOptions) => {
const { apiVersion, kind, name, namespace } = options;
let resource = this.#cache.find(
(resource) =>
resource.specifier.kind === kind &&
resource.specifier.apiVersion === apiVersion &&
resource.specifier.name === name &&
resource.specifier.namespace === namespace,
);
if (resource) {
return resource as Resource<T>;
public register = async (...resources: ResourceClass<ExpectedAny>[]) => {
for (const resource of resources) {
if (!this.#registry.has(resource)) {
this.#registry.set(resource, {
apiVersion: resource.apiVersion,
kind: resource.kind,
plural: resource.plural,
resources: [],
});
}
const watcherService = this.#services.get(WatcherService);
const watcher = watcherService.create({
...resource,
verbs: ['add', 'update', 'delete'],
});
watcher.on('changed', (manifest) => {
const { name, namespace } = manifest.metadata || {};
if (!name) {
return;
}
const current = this.get(resource, name, namespace);
current.manifest = manifest;
});
await watcher.start();
}
};
public getAllOfKind = <T extends ResourceClass<ExpectedAny>>(type: T) => {
return (this.#registry.get(type)?.resources?.filter((r) => r.exists) as InstanceType<T>[]) || [];
};
public get = <T extends ResourceClass<ExpectedAny>>(type: T, name: string, namespace?: string) => {
let resourceRegistry = this.#registry.get(type);
if (!resourceRegistry) {
resourceRegistry = {
apiVersion: type.apiVersion,
kind: type.kind,
plural: type.plural,
resources: [],
};
this.#registry.set(type, resourceRegistry);
}
const { resources, apiVersion, kind } = resourceRegistry;
let current = resources.find((resource) => resource.name === name && resource.namespace === namespace);
if (!current) {
current = new type({
selector: {
apiVersion,
kind,
name,
namespace,
},
services: this.#services,
});
current.on('changed', this.emit.bind(this, 'changed', current));
resources.push(current);
}
return current as InstanceType<T>;
};
public install = async (...resources: InstallableResourceClass<ExpectedAny>[]) => {
const k8sService = this.#services.get(K8sService);
for (const resource of resources) {
this.#services.log.info('Installing CRD', { kind: resource.kind });
try {
const manifest = createManifest(resource);
try {
await k8sService.extensionsApi.createCustomResourceDefinition({
body: manifest,
});
} catch (error) {
if (error instanceof ApiException && error.code === 409) {
await k8sService.extensionsApi.patchCustomResourceDefinition({
name: manifest.metadata.name,
body: [{ op: 'replace', path: '/spec', value: manifest.spec }],
});
continue;
}
throw error;
}
} catch (error) {
if (error instanceof ApiException) {
throw new Error(`Failed to install ${resource.kind}: ${error.body}`);
}
throw error;
}
}
resource = new Resource({
data: options,
services: this.#services,
});
this.#cache.push(resource);
return resource as Resource<T>;
};
}
export { ResourceReference } from './resources.ref.ts';
export { ResourceService, Resource };
export { CustomResource, type CustomResourceOptions } from './resource/resource.custom.ts';
export { ResourceReference } from './resource/resource.reference.ts';
export { ResourceService, Resource, type ResourceOptions, type ResourceClass, type InstallableResourceClass };

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