Compare commits

...

5 Commits

Author SHA1 Message Date
Morten Olsen
5782d59f71 add dotenv 2025-07-31 13:23:01 +02:00
Morten Olsen
34bba171ef Migrate to zod 2025-07-31 10:42:09 +02:00
Morten Olsen
85d043aec3 more 2025-07-31 08:51:50 +02:00
Morten Olsen
523637d40f add authentik 2025-07-30 13:42:25 +02:00
Morten Olsen
dd1e5a8124 add deployments 2025-07-28 23:23:34 +02:00
43 changed files with 60277 additions and 1617 deletions

5
.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
/node_modules/
/.github/
/.vscode/
/chart/
/.env

48
.github/release-drafter-config.yml vendored Normal file
View File

@@ -0,0 +1,48 @@
name-template: "$RESOLVED_VERSION 🌈"
tag-template: "v$RESOLVED_VERSION"
categories:
- title: "🚀 Features"
labels:
- "feature"
- "enhancement"
- title: "🐛 Bug Fixes"
labels:
- "fix"
- "bugfix"
- "bug"
- title: "🧰 Maintenance"
label: "chore"
change-template: "- $TITLE @$AUTHOR (#$NUMBER)"
change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks.
version-resolver:
major:
labels:
- "major"
minor:
labels:
- "minor"
patch:
labels:
- "patch"
default: patch
autolabeler:
- label: "chore"
files:
- "*.md"
branch:
- '/docs{0,1}\/.+/'
- label: "bug"
branch:
- '/fix\/.+/'
title:
- "/fix/i"
- label: "enhancement"
branch:
- '/feature\/.+/'
- '/feat\/.+/'
title:
- "/feat:.+/"
template: |
## Changes
$CHANGES

21
.github/workflows/auto-labeler.yml vendored Normal file
View File

@@ -0,0 +1,21 @@
name: Auto Labeler
on:
pull_request:
types: [opened, reopened, synchronize]
permissions:
contents: read
jobs:
auto-labeler:
permissions:
contents: write
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: release-drafter/release-drafter@v6
with:
config-name: release-drafter-config.yml
disable-releaser: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

79
.github/workflows/main.yml vendored Normal file
View File

@@ -0,0 +1,79 @@
name: Build, tag and release
on:
push:
branches:
- main
pull_request:
types:
- opened
- synchronize
env:
environment: test
release_channel: latest
DO_NOT_TRACK: "1"
NODE_VERSION: "23.x"
DOCKER_REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
PNPM_VERSION: 10.6.0
permissions:
contents: read
packages: read
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "${{ env.NODE_VERSION }}"
registry-url: "${{ env.NODE_REGISTRY }}"
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
version: ${{ env.PNPM_VERSION }}
run_install: false
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- uses: actions/cache@v4
name: Setup pnpm cache
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Run tests
run: pnpm test
update-release-draft:
name: Update release drafter
if: github.ref == 'refs/heads/main'
permissions:
contents: write
pull-requests: write
needs: build
environment: release
runs-on: ubuntu-latest
steps:
- uses: release-drafter/release-drafter@v6
with:
config-name: release-drafter-config.yml
publish: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

65
.github/workflows/publish-tag.yml vendored Normal file
View File

@@ -0,0 +1,65 @@
name: Publish tag
on:
push:
branches:
- 'main'
tags:
- "v*"
env:
environment: test
release_channel: latest
DO_NOT_TRACK: "1"
NODE_VERSION: "23.x"
DOCKER_REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
PNPM_VERSION: 10.6.0
permissions:
contents: read
packages: read
jobs:
release:
permissions:
contents: read
packages: write
attestations: write
id-token: write
pages: write
name: Release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Log in to the Container registry
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
with:
images: ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Build and push Docker image
id: push
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Generate artifact attestation
uses: actions/attest-build-provenance@v2
with:
subject-name: ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME}}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true

6
Dockerfile Normal file
View File

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

277
README.md
View File

@@ -1,15 +1,282 @@
# homelab-operator
To install dependencies:
A Kubernetes operator designed for homelab environments that simplifies the
management of PostgreSQL databases and Kubernetes secrets. Built with TypeScript
and designed to run efficiently in resource-constrained environments.
## Features
- **PostgreSQL Database Management**: Automatically create and manage PostgreSQL
databases and roles
- **Secret Management**: Generate and manage Kubernetes secrets with
configurable data
- **Owner References**: Automatic cleanup when resources are deleted
- **Status Tracking**: Comprehensive status conditions and error reporting
- **Lightweight**: Minimal resource footprint suitable for homelab environments
## Architecture
The operator manages two main Custom Resource Definitions (CRDs):
### PostgresDatabase
Manages PostgreSQL databases and their associated roles:
- Creates a PostgreSQL role with a secure random password
- Creates a database owned by that role
- Generates a Kubernetes secret containing database credentials
- Ensures proper cleanup through owner references
### SecretRequest
Generates Kubernetes secrets with configurable data:
- Supports custom secret names
- Configurable data fields with various encodings
- Automatic secret lifecycle management
## Installation
### Prerequisites
- Kubernetes cluster (1.20+)
- PostgreSQL instance accessible from the cluster
- Helm 3.x (for chart-based installation)
### Using Helm Chart
1. Clone the repository:
```bash
bun install
git clone <repository-url>
cd homelab-operator
```
To run:
2. Install using Helm:
```bash
bun run index.ts
helm install homelab-operator ./chart \
--set-string env.POSTGRES_HOST=<your-postgres-host> \
--set-string env.POSTGRES_USER=<admin-user> \
--set-string env.POSTGRES_PASSWORD=<admin-password>
```
This project was created using `bun init` in bun v1.2.16. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.
### Using kubectl
1. Build and push the Docker image:
```bash
docker build -t your-registry/homelab-operator:latest .
docker push your-registry/homelab-operator:latest
```
2. Apply the Kubernetes manifests:
```bash
kubectl apply -f chart/templates/
```
## Configuration
The operator is configured through environment variables:
| Variable | Description | Required | Default |
| ------------------- | ---------------------------------------- | -------- | ------- |
| `POSTGRES_HOST` | PostgreSQL server hostname | Yes | - |
| `POSTGRES_USER` | PostgreSQL admin username | Yes | - |
| `POSTGRES_PASSWORD` | PostgreSQL admin password | Yes | - |
| `POSTGRES_PORT` | PostgreSQL server port | No | 5432 |
| `LOG_LEVEL` | Logging level (debug, info, warn, error) | No | info |
## Usage
### PostgreSQL Database
Create a PostgreSQL database with an associated role:
```yaml
apiVersion: homelab.mortenolsen.pro/v1
kind: PostgresDatabase
metadata:
name: my-app-db
namespace: my-namespace
spec: {}
```
This will create:
- A PostgreSQL role named `my-app-db`
- A PostgreSQL database named `my-namespace_my-app-db` owned by the role
- A Kubernetes secret `postgres-database-my-app-db` containing:
- `name`: Base64-encoded database name
- `user`: Base64-encoded username
- `password`: Base64-encoded password
### Secret Request
Generate a Kubernetes secret with custom data:
```yaml
apiVersion: homelab.mortenolsen.pro/v1
kind: SecretRequest
metadata:
name: my-secret
namespace: my-namespace
spec:
secretName: app-config
data:
- key: api-key
value: "my-api-key"
encoding: base64
- key: database-url
value: "postgresql://user:pass@host:5432/db"
- key: random-token
length: 32
chars: "abcdefghijklmnopqrstuvwxyz0123456789"
```
### Accessing Created Resources
To retrieve database credentials:
```bash
# Get the secret
kubectl get secret postgres-database-my-app-db -o jsonpath='{.data.user}' | base64 -d
kubectl get secret postgres-database-my-app-db -o jsonpath='{.data.password}' | base64 -d
kubectl get secret postgres-database-my-app-db -o jsonpath='{.data.name}' | base64 -d
```
## Development
### Prerequisites
- [Bun](https://bun.sh/) runtime
- [pnpm](https://pnpm.io/) package manager
- Docker (for building images)
- Access to a Kubernetes cluster for testing
### Setup
1. Clone the repository:
```bash
git clone <repository-url>
cd homelab-operator
```
2. Install dependencies:
```bash
pnpm install
```
3. Set up development environment:
```bash
cp .env.example .env
# Edit .env with your PostgreSQL connection details
```
### Running Locally
For development, you can run the operator locally against a remote cluster:
```bash
# Ensure kubectl is configured for your development cluster
export KUBECONFIG=~/.kube/config
# Set PostgreSQL connection environment variables
export POSTGRES_HOST=localhost
export POSTGRES_USER=postgres
export POSTGRES_PASSWORD=yourpassword
# Run the operator
bun run src/index.ts
```
### Development with Docker Compose
A development environment with PostgreSQL is provided:
```bash
docker-compose -f docker-compose.dev.yaml up -d
```
### Building
Build the Docker image:
```bash
docker build -t homelab-operator:latest .
```
### Testing
```bash
# Run linting
pnpm run test:lint
# Apply test resources
kubectl apply -f test.yaml
```
## Contributing
1. Fork the repository
2. Create a feature branch: `git checkout -b feature/new-feature`
3. Make your changes and add tests
4. Run linting: `pnpm run test:lint`
5. Commit your changes: `git commit -am 'Add new feature'`
6. Push to the branch: `git push origin feature/new-feature`
7. Submit a pull request
## Project Structure
```
├── chart/ # Helm chart for deployment
├── src/
│ ├── crds/ # Custom Resource Definitions
│ │ ├── postgres/ # PostgreSQL database management
│ │ └── secrets/ # Secret generation
│ ├── custom-resource/ # Base CRD framework
│ ├── database/ # Database migrations
│ ├── services/ # Core services
│ │ ├── config/ # Configuration management
│ │ ├── k8s.ts # Kubernetes API client
│ │ ├── log/ # Logging service
│ │ ├── postgres/ # PostgreSQL service
│ │ └── secrets/ # Secret management
│ └── utils/ # Utilities and constants
├── Dockerfile # Container build configuration
└── docker-compose.dev.yaml # Development environment
```
## License
This project is licensed under the MIT License - see the LICENSE file for
details.
## Support
For support and questions:
- Create an issue in the GitHub repository
- Check existing issues for similar problems
- Review the logs using `kubectl logs -l app=homelab-operator`
## Status Monitoring
Monitor the operator status:
```bash
# Check operator logs
kubectl logs -l app=homelab-operator -f
# Check CRD status
kubectl get postgresdatabases
kubectl get secretrequests
# Describe resources for detailed status
kubectl describe postgresdatabase my-app-db
kubectl describe secretrequest my-secret
```

6
chart/Chart.yaml Normal file
View File

@@ -0,0 +1,6 @@
apiVersion: v2
name: homelab-operator
description: A Helm chart for deploying the homelab-operator
type: application
version: 0.1.0
appVersion: "1.0.0" # This is the version of the app being deployed

View File

@@ -0,0 +1,55 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "homelab-operator.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
If release name contains chart name it will be used as a full name.
*/}}
{{- define "homelab-operator.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Create chart-level labels to be applied to every resource that comes from this chart.
*/}}
{{- define "homelab-operator.labels" -}}
helm.sh/chart: {{ include "homelab-operator.name" . }}
{{ include "homelab-operator.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels
*/}}
{{- define "homelab-operator.selectorLabels" -}}
app.kubernetes.io/name: {{ include "homelab-operator.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
Create the name of the service account to use
*/}}
{{- define "homelab-operator.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "homelab-operator.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,14 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: {{ include "homelab-operator.fullname" . }}
rules:
- apiGroups: [""]
resources: ["secrets"]
verbs: ["create", "get", "watch", "list"]
- apiGroups: ["*"]
resources: ["*"]
verbs: ["get", "watch", "list", "patch"]
- apiGroups: ["apiextensions.k8s.io"]
resources: ["customresourcedefinitions"]
verbs: ["get", "create", "replace"]

View File

@@ -0,0 +1,12 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: {{ include "homelab-operator.fullname" . }}
subjects:
- kind: ServiceAccount
name: {{ include "homelab-operator.serviceAccountName" . }}
namespace: {{ .Release.Namespace }}
roleRef:
kind: ClusterRole
name: {{ include "homelab-operator.fullname" . }}
apiGroup: rbac.authorization.k8s.io

View File

@@ -0,0 +1,47 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "homelab-operator.fullname" . }}
labels:
{{- include "homelab-operator.labels" . | nindent 4 }}
spec:
replicas: 1
selector:
matchLabels:
{{- include "homelab-operator.selectorLabels" . | nindent 6 }}
template:
metadata:
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "homelab-operator.selectorLabels" . | nindent 8 }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "homelab-operator.serviceAccountName" . }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: {{ .Chart.Name }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}

View File

@@ -0,0 +1,12 @@
{{- if .Values.serviceAccount.create -}}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "homelab-operator.serviceAccountName" . }}
labels:
{{- include "homelab-operator.labels" . | nindent 4 }}
{{- with .Values.serviceAccount.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}

53
chart/values.yaml Normal file
View File

@@ -0,0 +1,53 @@
# Default values for homelab-operator.
# This is a YAML-formatted file.
# Declare variables to be passed into your templates.
image:
repository: ghcr.io/morten-olsen/homelab-operator
pullPolicy: Always
# Overrides the image tag whose default is the chart appVersion.
tag: main
imagePullSecrets: []
nameOverride: ""
fullnameOverride: ""
serviceAccount:
# Specifies whether a service account should be created
create: true
# Annotations to add to the service account
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: ""
podAnnotations: {}
podSecurityContext: {}
# fsGroup: 2000
securityContext: {}
# capabilities:
# drop:
# - ALL
# readOnlyRootFilesystem: true
# runAsNonRoot: true
# runAsUser: 1000
resources: {}
# We usually recommend not to specify default resources and to leave this as a conscious
# choice for the user. This also increases chances charts run on environments with little
# resources, such as Minikube. If you do want to specify resources, uncomment the following
# lines, adjust them as necessary, and remove the curly braces after 'resources:'.
# limits:
# cpu: 100m
# memory: 128Mi
# requests:
# cpu: 100m
# memory: 128Mi
nodeSelector: {}
tolerations: []
affinity: {}

View File

@@ -46,6 +46,6 @@ export default tseslint.config(
},
...compat.extends('plugin:prettier/recommended'),
{
ignores: ['**/node_modules/', '**/dist/', '**/.turbo/', '**/generated/'],
ignores: ['**/node_modules/', '**/dist/', '**/.turbo/', '**/generated/', '**/clients/*.types.ts'],
},
);

View File

@@ -4,11 +4,8 @@
"type": "module",
"private": true,
"devDependencies": {
"@types/bun": "latest",
"nodemon": "^3.1.10",
"@eslint/eslintrc": "3.3.1",
"@eslint/js": "9.32.0",
"@pnpm/find-workspace-packages": "6.0.9",
"eslint": "9.32.0",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-import": "2.32.0",
@@ -21,19 +18,26 @@
"typescript": "^5"
},
"dependencies": {
"@goauthentik/api": "2025.6.3-1751754396",
"@kubernetes/client-node": "^1.3.0",
"@sinclair/typebox": "^0.34.38",
"dotenv": "^17.2.1",
"knex": "^3.1.0",
"pg": "^8.16.3",
"sqlite3": "^5.1.7"
"sqlite3": "^5.1.7",
"yaml": "^2.8.0",
"zod": "^4.0.14"
},
"packageManager": "pnpm@10.6.0+sha512.df0136e797db0cfa7ec1084e77f3bdf81bacbae9066832fbf95cba4c2140ad05e64f316cde51ce3f99ea00a91ffc702d6aedd3c0f450f895e3e7c052fe573cd8",
"packageManager": "pnpm@10.6.0",
"pnpm": {
"onlyBuiltDependencies": [
"sqlite3"
]
],
"patchedDependencies": {
"@kubernetes/client-node": "patches/@kubernetes__client-node.patch"
}
},
"scripts": {
"test": "echo 'No tests'",
"test:lint": "eslint"
}
}

View File

@@ -0,0 +1,14 @@
diff --git a/dist/gen/models/ObjectSerializer.js b/dist/gen/models/ObjectSerializer.js
index 1d798b6a2d7c059165d1df9fbb77b89a8317ebca..c8bacfdc95be0f0146c6505f89a9372e013afea4 100644
--- a/dist/gen/models/ObjectSerializer.js
+++ b/dist/gen/models/ObjectSerializer.js
@@ -2216,6 +2216,9 @@ export class ObjectSerializer {
return transformedData;
}
else if (type === "Date") {
+ if (typeof data === "string") {
+ return data;
+ }
if (format == "date") {
let month = data.getMonth() + 1;
month = month < 10 ? "0" + month.toString() : month.toString();

1316
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +0,0 @@
apiVersion: 'homelab.mortenolsen.pro/v1';
kind: 'PostgresDatabase';
name: 'test2';
namespace: 'playground';
foo: 'bar';
foo: 'bar';
{
}

24
scripts/create-clients.ts Normal file
View File

@@ -0,0 +1,24 @@
import fs from 'node:fs';
import { mkdir } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import YAML from 'yaml';
import openapiTS, { astToString } from 'openapi-typescript';
const schemaRequest = await fetch('https://authentik.olsen.cloud/api/v3/schema/');
if (!schemaRequest.ok) {
console.error(schemaRequest.status, schemaRequest.statusText);
throw new Error('Failed to fetch schema');
}
const schemaYaml = await schemaRequest.text();
const schema = YAML.parse(schemaYaml);
const ast = await openapiTS(schema);
const contents = astToString(ast);
const targetLocation = resolve(import.meta.dirname, '..', 'src', 'clients', 'authentik', 'authentik.types.d.ts');
await mkdir(dirname(targetLocation), { recursive: true });
fs.writeFileSync(
targetLocation,
['// This file is generated by scripts/create-clients.ts', '/* eslint-disable */', contents].join('\n'),
);

2
scripts/recreate.bash Executable file
View File

@@ -0,0 +1,2 @@
kubectl delete -f "$1"
kubectl apply -f "$1"

View File

@@ -0,0 +1,33 @@
import {
Configuration,
CoreApi,
FlowsApi,
PropertymappingsApi,
ProvidersApi,
instanceOfErrorDetail,
} from '@goauthentik/api';
type CreateAuthentikClientOptions = {
baseUrl: string;
token: string;
};
const createAuthentikClient = ({ baseUrl, token }: CreateAuthentikClientOptions) => {
const config = new Configuration({
basePath: baseUrl,
headers: {
Authorization: `Bearer ${token}`,
},
});
const client = {
core: new CoreApi(config),
providers: new ProvidersApi(config),
propertymappings: new PropertymappingsApi(config),
flows: new FlowsApi(config),
};
return client;
};
type AuthentikClient = ReturnType<typeof createAuthentikClient>;
export { createAuthentikClient, type AuthentikClient, instanceOfErrorDetail };

58661
src/clients/authentik/authentik.types.d.ts vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,58 @@
import { SubModeEnum } from '@goauthentik/api';
import { z } from 'zod';
import { CustomResource, type CustomResourceHandlerOptions } from '../../../custom-resource/custom-resource.base.ts';
import { AuthentikService } from '../../../services/authentik/authentik.service.ts';
const authentikClientSpec = z.object({
subMode: z.enum(SubModeEnum).optional(),
clientType: z.enum(['confidential', 'public']).optional(),
redirectUris: z.array(
z.object({
url: z.string(),
matchingMode: z.enum(['strict', 'regex']),
}),
),
});
const authentikClientSecret = z.object({
clientSecret: z.string(),
});
class AuthentikClient extends CustomResource<typeof authentikClientSpec> {
constructor() {
super({
kind: 'AuthentikClient',
names: {
singular: 'authentikclient',
plural: 'authentikclients',
},
spec: authentikClientSpec,
});
}
public update = async (options: CustomResourceHandlerOptions<typeof authentikClientSpec>) => {
const { request, services, ensureSecret } = options;
const authentikService = services.get(AuthentikService);
const { clientSecret } = await ensureSecret({
name: `authentik-client-${request.metadata.name}`,
namespace: request.metadata.namespace ?? 'default',
schema: authentikClientSecret,
generator: async () => ({
clientSecret: crypto.randomUUID(),
}),
});
const client = await authentikService.upsertClient({
name: request.metadata.name,
secret: clientSecret,
subMode: request.spec.subMode,
clientType: request.spec.clientType,
redirectUris: request.spec.redirectUris.map((rule) => ({
url: rule.url,
matchingMode: rule.matchingMode ?? 'strict',
})),
});
console.log(client.config);
};
}
export { AuthentikClient };

View File

@@ -0,0 +1,26 @@
import { createCustomResource } from '../../../custom-resource/custom-resource.base.ts';
const backupReportSchema = z.object({
spec: z.object({
startedAt: z.string({
format: 'date-time',
}),
finishedAt: z.string({
format: 'date-time',
}),
status: z.enum(['success', 'failed']),
error: z.string().optional(),
message: z.string().optional(),
}),
});
const BackupReport = createCustomResource({
kind: 'BackupReport',
spec: backupReportSchema,
names: {
plural: 'backupreports',
singular: 'backupreport',
},
});
export { BackupReport };

View File

View File

@@ -1,12 +1,9 @@
import { Type } from '@sinclair/typebox';
import { ApiException, type V1Secret } from '@kubernetes/client-node';
import { z } from 'zod';
import { CustomResource, type CustomResourceHandlerOptions } from '../../custom-resource/custom-resource.base.ts';
import { K8sService } from '../../services/k8s.ts';
import type { CustomResourceRequest } from '../../custom-resource/custom-resource.request.ts';
import { PostgresService } from '../../services/postgres/postgres.service.ts';
const postgresDatabaseSpecSchema = Type.Object({});
const postgresDatabaseSpecSchema = z.object({});
class PostgresDatabase extends CustomResource<typeof postgresDatabaseSpecSchema> {
constructor() {
@@ -20,99 +17,39 @@ class PostgresDatabase extends CustomResource<typeof postgresDatabaseSpecSchema>
});
}
#getVariables = async (request: CustomResourceRequest<typeof postgresDatabaseSpecSchema>) => {
const { metadata, services } = request;
const k8sService = services.get(K8sService);
const secretName = `postgres-database-${metadata.name}`;
let secret: V1Secret | undefined;
try {
secret = await k8sService.api.readNamespacedSecret({
name: secretName,
namespace: metadata.namespace ?? 'default',
});
} catch (error) {
if (!(error instanceof ApiException && error.code === 404)) {
throw error;
}
}
if (secret && request.isOwnerOf(secret) && secret.data) {
services.log.debug('PostgresRole secret found', { secret });
return secret.data;
}
if (secret && !request.isOwnerOf(secret)) {
throw new Error('The secret is not owned by this resource');
}
const data = {
name: Buffer.from(`${metadata.namespace}_${metadata.name}`).toString('base64'),
user: Buffer.from(metadata.name).toString('base64'),
password: Buffer.from(crypto.randomUUID()).toString('base64'),
};
const namespace = metadata.namespace ?? 'default';
services.log.debug('Creating secret', { data });
const response = await k8sService.api.createNamespacedSecret({
namespace,
body: {
kind: 'Secret',
metadata: {
name: secretName,
namespace,
ownerReferences: [
{
apiVersion: request.apiVersion,
kind: request.kind,
name: metadata.name,
uid: metadata.uid,
},
],
},
type: 'Opaque',
data,
},
});
services.log.debug('Secret created', { response });
return response.data!;
};
public update = async (options: CustomResourceHandlerOptions<typeof postgresDatabaseSpecSchema>) => {
const { request, services } = options;
const status = await request.getStatus();
try {
const variables = await this.#getVariables(request);
const { request, services, ensureSecret } = options;
const variables = await ensureSecret({
name: `postgres-database-${request.metadata.name}`,
namespace: request.metadata.namespace ?? 'default',
schema: z.object({
name: z.string(),
user: z.string(),
password: z.string(),
}),
generator: async () => ({
name: `${request.metadata.namespace || 'default'}_${request.metadata.name}`,
user: `${request.metadata.namespace || 'default'}_${request.metadata.name}`,
password: `password_${Buffer.from(crypto.getRandomValues(new Uint8Array(12))).toString('hex')}`,
}),
});
const postgresService = services.get(PostgresService);
await postgresService.upsertRole({
name: Buffer.from(variables.user!, 'base64').toString('utf-8'),
password: Buffer.from(variables.password!, 'base64').toString('utf-8'),
name: variables.user,
password: variables.password,
});
await postgresService.upsertDatabase({
name: Buffer.from(variables.name!, 'base64').toString('utf-8'),
owner: Buffer.from(variables.user!, 'base64').toString('utf-8'),
name: variables.name,
owner: variables.user,
});
status.setCondition('Ready', {
status: 'True',
reason: 'Ready',
message: 'Role created',
await request.addEvent({
type: 'Normal',
reason: 'DatabaseUpserted',
message: 'Database has been upserted',
action: 'UPSERT',
});
services.log.info('PostgresRole updated', { status });
return await status.save();
} catch (error) {
const status = await request.getStatus();
status.setCondition('Ready', {
status: 'False',
reason: 'Error',
message: error instanceof Error ? error.message : 'Unknown error',
});
services.log.error('Error updating PostgresRole', { error });
return await status.save();
}
};
}

View File

@@ -1,24 +1,18 @@
import { Type } from '@sinclair/typebox';
import { ApiException, type V1Secret } from '@kubernetes/client-node';
import { z } from 'zod';
import { CustomResource, type CustomResourceHandlerOptions } from '../../custom-resource/custom-resource.base.ts';
import { K8sService } from '../../services/k8s.ts';
const stringValueSchema = Type.String({
key: Type.String(),
chars: Type.Optional(Type.String()),
length: Type.Optional(Type.Number()),
encoding: Type.Optional(
Type.String({
enum: ['utf-8', 'base64', 'base64url', 'hex'],
}),
),
value: Type.Optional(Type.String()),
const stringValueSchema = z.object({
key: z.string(),
chars: z.string().optional(),
length: z.number().optional(),
encoding: z.enum(['utf-8', 'base64', 'base64url', 'hex']).optional(),
value: z.string().optional(),
});
const secretRequestSpec = Type.Object({
secretName: Type.Optional(Type.String()),
data: Type.Array(stringValueSchema),
const secretRequestSpec = z.object({
secretName: z.string().optional(),
data: z.array(stringValueSchema),
});
class SecretRequest extends CustomResource<typeof secretRequestSpec> {
@@ -33,71 +27,18 @@ class SecretRequest extends CustomResource<typeof secretRequestSpec> {
});
}
#createSecret = async (options: CustomResourceHandlerOptions<typeof secretRequestSpec>) => {
const { request, services } = options;
const { apiVersion, kind, spec, metadata } = request;
const { secretName = metadata.name } = spec;
const { namespace = 'default' } = metadata;
const k8sService = services.get(K8sService);
let current: V1Secret | undefined;
try {
current = await k8sService.api.readNamespacedSecret({
name: secretName,
namespace,
});
} catch (error) {
if (!(error instanceof ApiException && error.code === 404)) {
throw error;
}
}
if (current) {
console.log('secret already exists', current);
// TODO: Add update logic
return;
}
await k8sService.api.createNamespacedSecret({
namespace,
body: {
kind: 'Secret',
metadata: {
name: secretName,
namespace,
ownerReferences: [
{
apiVersion,
kind,
name: metadata.name,
uid: metadata.uid,
},
],
},
type: 'Opaque',
data: {
// TODO: generate data from spec
test: 'test',
},
},
});
};
public update = async (options: CustomResourceHandlerOptions<typeof secretRequestSpec>) => {
const { request } = options;
const status = await request.getStatus();
try {
await this.#createSecret(options);
status.setCondition('Ready', {
status: 'True',
reason: 'SecretCreated',
message: 'Secret created',
const { request, ensureSecret } = options;
const { secretName = request.metadata.name } = request.spec;
const { namespace = request.metadata.namespace ?? 'default' } = request.metadata;
await ensureSecret({
name: secretName,
namespace,
schema: z.object({}).passthrough(),
generator: async () => ({
hello: 'world',
}),
});
return await status.save();
} catch {
status.setCondition('Ready', {
status: 'False',
reason: 'SecretNotCreated',
message: 'Secret not created',
});
}
};
}

View File

@@ -1,17 +1,25 @@
import { type TSchema } from '@sinclair/typebox';
import { z, type ZodObject } from 'zod';
import { GROUP } from '../utils/consts.ts';
import type { Services } from '../utils/service.ts';
import { noopAsync } from '../utils/types.ts';
import { statusSchema } from './custom-resource.status.ts';
import type { CustomResourceRequest } from './custom-resource.request.ts';
import { customResourceStatusSchema, type CustomResourceRequest } from './custom-resource.request.ts';
type CustomResourceHandlerOptions<TSpec extends TSchema> = {
type EnsureSecretOptions<T extends ZodObject> = {
schema: T;
name: string;
namespace: string;
generator: () => Promise<z.infer<T>>;
};
type CustomResourceHandlerOptions<TSpec extends ZodObject> = {
request: CustomResourceRequest<TSpec>;
ensureSecret: <T extends ZodObject>(options: EnsureSecretOptions<T>) => Promise<z.infer<T>>;
services: Services;
};
type CustomResourceConstructor<TSpec extends TSchema> = {
type CustomResourceConstructor<TSpec extends ZodObject> = {
kind: string;
spec: TSpec;
names: {
@@ -20,7 +28,7 @@ type CustomResourceConstructor<TSpec extends TSchema> = {
};
};
abstract class CustomResource<TSpec extends TSchema> {
abstract class CustomResource<TSpec extends ZodObject> {
#options: CustomResourceConstructor<TSpec>;
constructor(options: CustomResourceConstructor<TSpec>) {
@@ -81,8 +89,16 @@ abstract class CustomResource<TSpec extends TSchema> {
openAPIV3Schema: {
type: 'object',
properties: {
spec: this.spec,
status: statusSchema,
spec: {
...z.toJSONSchema(this.spec.strict(), { io: 'input' }),
$schema: undefined,
additionalProperties: undefined,
} as ExpectedAny,
status: {
...z.toJSONSchema(customResourceStatusSchema.strict(), { io: 'input' }),
$schema: undefined,
additionalProperties: undefined,
} as ExpectedAny,
},
},
},
@@ -96,4 +112,28 @@ abstract class CustomResource<TSpec extends TSchema> {
};
}
export { CustomResource, type CustomResourceConstructor, type CustomResourceHandlerOptions };
const createCustomResource = <TSpec extends ZodObject>(
options: CustomResourceConstructor<TSpec> & {
update?: (options: CustomResourceHandlerOptions<TSpec>) => Promise<void>;
create?: (options: CustomResourceHandlerOptions<TSpec>) => Promise<void>;
delete?: (options: CustomResourceHandlerOptions<TSpec>) => Promise<void>;
},
) => {
return class extends CustomResource<TSpec> {
constructor() {
super(options);
}
public update = options.update ?? noopAsync;
public create = options.create;
public delete = options.delete;
};
};
export {
CustomResource,
type CustomResourceConstructor,
type CustomResourceHandlerOptions,
type EnsureSecretOptions,
createCustomResource,
};

View File

@@ -1,14 +1,15 @@
import { ApiException, Watch } from '@kubernetes/client-node';
import type { ZodObject } from 'zod';
import { K8sService } from '../services/k8s.ts';
import type { Services } from '../utils/service.ts';
import { type CustomResource } from './custom-resource.base.ts';
import { type CustomResource, type EnsureSecretOptions } from './custom-resource.base.ts';
import { CustomResourceRequest } from './custom-resource.request.ts';
class CustomResourceRegistry {
#services: Services;
#resources = new Set<CustomResource<any>>();
#resources = new Set<CustomResource<ExpectedAny>>();
#watchers = new Map<string, AbortController>();
constructor(services: Services) {
@@ -23,11 +24,11 @@ class CustomResourceRegistry {
return Array.from(this.#resources).find((r) => r.kind === kind);
};
public register = (resource: CustomResource<any>) => {
public register = (resource: CustomResource<ExpectedAny>) => {
this.#resources.add(resource);
};
public unregister = (resource: CustomResource<any>) => {
public unregister = (resource: CustomResource<ExpectedAny>) => {
this.#resources.delete(resource);
this.#watchers.forEach((controller, kind) => {
if (kind === resource.kind) {
@@ -50,8 +51,70 @@ class CustomResourceRegistry {
}
};
#onResourceEvent = async (type: string, obj: any) => {
console.log(type, this.kinds);
#ensureSecret =
(request: CustomResourceRequest<ExpectedAny>) =>
async <T extends ZodObject>(options: EnsureSecretOptions<T>) => {
const { schema, name, namespace, generator } = options;
const { metadata } = request;
const k8sService = this.#services.get(K8sService);
let exists = false;
try {
const secret = await k8sService.api.readNamespacedSecret({
name,
namespace,
});
exists = true;
if (secret?.data) {
const decoded = Object.fromEntries(
Object.entries(secret.data).map(([key, value]) => [key, Buffer.from(value, 'base64').toString('utf-8')]),
);
if (schema.safeParse(decoded).success) {
return decoded;
}
}
} catch (error) {
if (!(error instanceof ApiException && error.code === 404)) {
throw error;
}
}
const value = await generator();
const data = Object.fromEntries(
Object.entries(value).map(([key, value]) => [key, Buffer.from(value as string).toString('base64')]),
);
const body = {
kind: 'Secret',
metadata: {
name,
namespace,
ownerReferences: [
{
apiVersion: request.apiVersion,
kind: request.kind,
name: metadata.name,
uid: metadata.uid,
},
],
},
type: 'Opaque',
data,
};
if (exists) {
await k8sService.api.replaceNamespacedSecret({
name,
namespace,
body,
});
} else {
const response = await k8sService.api.createNamespacedSecret({
namespace,
body,
});
return response.data;
}
};
#onResourceEvent = async (type: string, obj: ExpectedAny) => {
const { kind } = obj;
const crd = this.getByKind(kind);
if (!crd) {
@@ -66,31 +129,82 @@ class CustomResourceRegistry {
});
const status = await request.getStatus();
if (status && (type === 'ADDED' || type === 'MODIFIED')) {
if (status.observedGeneration === obj.metadata.generation) {
this.#services.log.debug('Skipping resource update', {
kind,
name: obj.metadata.name,
namespace: obj.metadata.namespace,
observedGeneration: status.observedGeneration,
generation: obj.metadata.generation,
});
return;
}
}
this.#services.log.debug('Updating resource', {
type,
kind,
name: obj.metadata.name,
namespace: obj.metadata.namespace,
observedGeneration: status?.observedGeneration,
generation: obj.metadata.generation,
});
if (type === 'ADDED' || type === 'MODIFIED') {
await request.markSeen();
}
if (type === 'ADDED' && crd.create) {
handler = crd.create;
}
try {
await handler?.({
request,
services: this.#services,
ensureSecret: this.#ensureSecret(request) as ExpectedAny,
});
if (type === 'ADDED' || type === 'MODIFIED') {
await request.setCondition({
type: 'Ready',
status: 'True',
message: 'Resource created',
});
}
} catch (error) {
let message = 'Unknown error';
if (error instanceof ApiException) {
message = error.body;
this.#services.log.error('Error handling resource', { reason: error.body });
} else if (error instanceof Error) {
message = error.message;
this.#services.log.error('Error handling resource', { reason: error.message });
} else {
message = String(error);
this.#services.log.error('Error handling resource', { reason: String(error) });
}
if (type === 'ADDED' || type === 'MODIFIED') {
await request.setCondition({
type: 'Ready',
status: 'False',
reason: 'Error',
message,
});
}
}
};
#onError = (error: any) => {
console.error(error);
#onError = (error: ExpectedAny) => {
this.#services.log.error('Error watching resource', { error });
};
public install = async (replace = false) => {
const k8sService = this.#services.get(K8sService);
for (const crd of this.#resources) {
this.#services.log.info('Installing CRD', { kind: crd.kind });
try {
const manifest = crd.toManifest();
try {
await k8sService.extensionsApi.createCustomResourceDefinition({
@@ -108,6 +222,12 @@ class CustomResourceRegistry {
}
throw error;
}
} catch (error) {
if (error instanceof ApiException) {
throw new Error(`Failed to install ${crd.kind}: ${error.body}`);
}
throw error;
}
}
};
}

View File

@@ -1,15 +1,15 @@
import type { Static, TSchema } from '@sinclair/typebox';
import { ApiException, PatchStrategy, setHeaderOptions } from '@kubernetes/client-node';
import { ApiException, PatchStrategy, setHeaderOptions, V1MicroTime } from '@kubernetes/client-node';
import { z, type ZodObject } from 'zod';
import type { Services } from '../utils/service.ts';
import { K8sService } from '../services/k8s.ts';
import { GROUP } from '../utils/consts.ts';
import { CustomResourceRegistry } from './custom-resource.registry.ts';
import { CustomResourceStatus, type CustomResourceStatusType } from './custom-resource.status.ts';
type CustomResourceRequestOptions = {
type: 'ADDED' | 'DELETED' | 'MODIFIED';
manifest: any;
manifest: ExpectedAny;
services: Services;
};
@@ -24,7 +24,29 @@ type CustomResourceRequestMetadata = Record<string, string> & {
generation: number;
};
class CustomResourceRequest<TSpec extends TSchema> {
type CustomResourceEvent = {
reason: string;
message: string;
action: string;
type: 'Normal' | 'Warning' | 'Error';
};
const customResourceStatusSchema = z.object({
observedGeneration: z.number(),
conditions: z.array(
z.object({
type: z.string(),
status: z.enum(['True', 'False', 'Unknown']),
lastTransitionTime: z.string().datetime(),
reason: z.string().optional(),
message: z.string().optional(),
}),
),
});
type CustomResourceStatus = z.infer<typeof customResourceStatusSchema>;
class CustomResourceRequest<TSpec extends ZodObject> {
#options: CustomResourceRequestOptions;
constructor(options: CustomResourceRequestOptions) {
@@ -51,7 +73,7 @@ class CustomResourceRequest<TSpec extends TSchema> {
return this.#options.manifest.apiVersion;
}
public get spec(): Static<TSpec> {
public get spec(): z.infer<TSpec> {
return this.#options.manifest.spec;
}
@@ -59,10 +81,10 @@ class CustomResourceRequest<TSpec extends TSchema> {
return this.#options.manifest.metadata;
}
public isOwnerOf = (manifest: any) => {
public isOwnerOf = (manifest: ExpectedAny) => {
const ownerRef = manifest?.metadata?.ownerReferences || [];
return ownerRef.some(
(ref: any) =>
(ref: ExpectedAny) =>
ref.apiVersion === this.apiVersion &&
ref.kind === this.kind &&
ref.name === this.metadata.name &&
@@ -70,11 +92,73 @@ class CustomResourceRequest<TSpec extends TSchema> {
);
};
public setStatus = async (status: CustomResourceStatusType) => {
public markSeen = async () => {
const { manifest } = this.#options;
await this.setStatus({
observedGeneration: manifest.metadata.generation,
});
};
public setCondition = async (condition: Omit<CustomResourceStatus['conditions'][number], 'lastTransitionTime'>) => {
const fullCondition = {
...condition,
lastTransitionTime: new Date().toISOString(),
};
const current = await this.getCurrent();
const conditions: CustomResourceStatus['conditions'] = current?.status?.conditions || [];
const index = conditions.findIndex((c) => c.type === condition.type);
if (index === -1) {
conditions.push(fullCondition);
} else {
conditions[index] = fullCondition;
}
await this.setStatus({
conditions,
});
};
public getStatus = async () => {
const current = await this.getCurrent();
return current?.status as CustomResourceStatus | undefined;
};
public addEvent = async (event: CustomResourceEvent) => {
const { manifest, services } = this.#options;
const k8sService = services.get(K8sService);
await k8sService.eventsApi.createNamespacedEvent({
namespace: manifest.metadata.namespace,
body: {
kind: 'Event',
metadata: {
name: `${manifest.metadata.name}-${Date.now()}`,
namespace: manifest.metadata.namespace,
},
eventTime: new V1MicroTime(),
note: event.message,
action: event.action,
reason: event.reason,
type: event.type,
reportingController: GROUP,
reportingInstance: manifest.metadata.name,
regarding: {
apiVersion: manifest.apiVersion,
resourceVersion: manifest.metadata.resourceVersion,
kind: manifest.kind,
name: manifest.metadata.name,
namespace: manifest.metadata.namespace,
uid: manifest.metadata.uid,
},
},
});
};
public setStatus = async (status: Partial<CustomResourceStatus>) => {
const { manifest, services } = this.#options;
const { kind, metadata } = manifest;
const registry = services.get(CustomResourceRegistry);
const crd = registry.getByKind(kind);
const current = await this.getCurrent();
if (!crd) {
throw new Error(`Custom resource ${kind} not found`);
}
@@ -90,7 +174,14 @@ class CustomResourceRequest<TSpec extends TSchema> {
namespace,
plural: crd.names.plural,
name,
body: { status },
body: {
status: {
observedGeneration: manifest.metadata.generation,
conditions: current?.status?.conditions || [],
...current?.status,
...status,
},
},
fieldValidation: 'Strict',
},
setHeaderOptions('Content-Type', PatchStrategy.MergePatch),
@@ -118,8 +209,8 @@ class CustomResourceRequest<TSpec extends TSchema> {
apiVersion: string;
kind: string;
metadata: CustomResourceRequestMetadata;
spec: Static<TSpec>;
status: CustomResourceStatusType;
spec: z.infer<TSpec>;
status: CustomResourceStatus;
};
} catch (error) {
if (error instanceof ApiException && error.code === 404) {
@@ -128,25 +219,6 @@ class CustomResourceRequest<TSpec extends TSchema> {
throw error;
}
};
public getStatus = async () => {
const resource = await this.getCurrent();
if (!resource || !resource.status) {
return new CustomResourceStatus({
status: {
observedGeneration: 0,
conditions: [],
},
generation: 0,
save: this.setStatus,
});
}
return new CustomResourceStatus({
status: { ...resource.status, observedGeneration: resource.status.observedGeneration },
generation: resource.metadata.generation,
save: this.setStatus,
});
};
}
export { CustomResourceRequest };
export { CustomResourceRequest, customResourceStatusSchema };

View File

@@ -1,85 +0,0 @@
import { Type, type Static } from '@sinclair/typebox';
type CustomResourceStatusType = Static<typeof statusSchema>;
const statusSchema = Type.Object({
observedGeneration: Type.Number(),
conditions: Type.Array(
Type.Object({
type: Type.String(),
status: Type.String({
enum: ['True', 'False', 'Unknown'],
}),
lastTransitionTime: Type.String(),
reason: Type.String(),
message: Type.String(),
}),
),
});
type CustomResourceStatusOptions = {
status?: CustomResourceStatusType;
generation: number;
save: (status: CustomResourceStatusType) => Promise<void>;
};
class CustomResourceStatus {
#status: CustomResourceStatusType;
#generation: number;
#save: (status: CustomResourceStatusType) => Promise<void>;
constructor(options: CustomResourceStatusOptions) {
this.#save = options.save;
this.#status = {
observedGeneration: options.status?.observedGeneration ?? 0,
conditions: options.status?.conditions ?? [],
};
this.#generation = options.generation;
}
public get generation() {
return this.#generation;
}
public get observedGeneration() {
return this.#status.observedGeneration;
}
public set observedGeneration(observedGeneration: number) {
this.#status.observedGeneration = observedGeneration;
}
public getCondition = (type: string) => {
return this.#status.conditions?.find((condition) => condition.type === type)?.status;
};
public setCondition = (
type: string,
condition: Omit<CustomResourceStatusType['conditions'][number], 'type' | 'lastTransitionTime'>,
) => {
const currentCondition = this.getCondition(type);
const newCondition = {
...condition,
type,
lastTransitionTime: new Date().toISOString(),
};
if (currentCondition) {
this.#status.conditions = this.#status.conditions.map((c) => (c.type === type ? newCondition : c));
} else {
this.#status.conditions.push(newCondition);
}
};
public save = async () => {
await this.#save({
...this.#status,
observedGeneration: this.#generation,
});
};
public toJSON = () => {
return this.#status;
};
}
export { CustomResourceStatus, statusSchema, type CustomResourceStatusType };

View File

@@ -1,11 +1,46 @@
import 'dotenv/config';
import { ApiException } from '@kubernetes/client-node';
import { CustomResourceRegistry } from './custom-resource/custom-resource.registry.ts';
import { Services } from './utils/service.ts';
import { SecretRequest } from './crds/secrets/secrets.request.ts';
import { PostgresDatabase } from './crds/postgres/postgres.database.ts';
import { AuthentikService } from './services/authentik/authentik.service.ts';
import { AuthentikClient } from './crds/authentik/client/client.ts';
const services = new Services();
const registry = services.get(CustomResourceRegistry);
registry.register(new SecretRequest());
registry.register(new PostgresDatabase());
registry.register(new AuthentikClient());
await registry.install(true);
await registry.watch();
const authentikService = services.get(AuthentikService);
await authentikService.upsertClient({
name: 'foo',
secret: 'foo',
redirectUris: [{ url: 'http://localhost:3000/api/auth/callback', matchingMode: 'strict' }],
});
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) {
// show stack trace
console.error(error.stack);
}
if (error instanceof ApiException) {
return console.error(error.body);
}
console.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,216 @@
import type { Services } from '../../utils/service.ts';
import { ConfigService } from '../config/config.ts';
import { createAuthentikClient, type AuthentikClient } from '../../clients/authentik/authentik.ts';
import type { UpsertClientRequest, UpsertGroupRequest } from './authentik.types.ts';
const DEFAULT_AUTHORIZATION_FLOW = 'default-provider-authorization-implicit-consent';
const DEFAULT_INVALIDATION_FLOW = 'default-invalidation-flow';
const DEFAULT_SCOPES = ['openid', 'email', 'profile', 'offline_access'];
class AuthentikService {
#client: AuthentikClient;
#services: Services;
constructor(services: Services) {
const config = services.get(ConfigService);
this.#client = createAuthentikClient({
baseUrl: new URL('api/v3', config.authentik.url).toString(),
token: config.authentik.token,
});
this.#services = services;
}
public get url() {
const config = this.#services.get(ConfigService);
return config.authentik.url;
}
#upsertApplication = async (request: UpsertClientRequest, provider: number, pk?: string) => {
if (!pk) {
return await this.#client.core.coreApplicationsCreate({
applicationRequest: {
name: request.name,
slug: request.name,
provider,
},
});
}
return await this.#client.core.coreApplicationsUpdate({
slug: request.name,
applicationRequest: {
name: request.name,
slug: request.name,
provider,
},
});
};
#upsertProvider = async (request: UpsertClientRequest, pk?: number) => {
const flows = await this.getFlows();
const authorizationFlow = flows.results.find(
(flow) => flow.slug === (request.flows?.authorization ?? DEFAULT_AUTHORIZATION_FLOW),
);
const invalidationFlow = flows.results.find(
(flow) => flow.slug === (request.flows?.invalidation ?? DEFAULT_INVALIDATION_FLOW),
);
if (!authorizationFlow || !invalidationFlow) {
throw new Error('Authorization and invalidation flows not found');
}
const scopes = await this.getScopePropertyMappings();
const scopePropertyMapping = (request.scopes ?? DEFAULT_SCOPES)
.map((scope) => scopes.results.find((mapping) => mapping.scopeName === scope)?.pk)
.filter(Boolean) as string[];
if (!pk) {
return await this.#client.providers.providersOauth2Create({
oAuth2ProviderRequest: {
name: request.name,
clientId: request.name,
clientSecret: request.secret,
redirectUris: request.redirectUris,
authorizationFlow: authorizationFlow.pk,
invalidationFlow: invalidationFlow.pk,
propertyMappings: scopePropertyMapping,
clientType: request.clientType,
subMode: request.subMode,
accessCodeValidity: request.timing?.accessCodeValidity,
accessTokenValidity: request.timing?.accessTokenValidity,
refreshTokenValidity: request.timing?.refreshTokenValidity,
},
});
}
return await this.#client.providers.providersOauth2Update({
id: pk,
oAuth2ProviderRequest: {
name: request.name,
clientId: request.name,
clientSecret: request.secret,
redirectUris: request.redirectUris,
authorizationFlow: authorizationFlow.pk,
invalidationFlow: invalidationFlow.pk,
propertyMappings: scopePropertyMapping,
clientType: request.clientType,
subMode: request.subMode,
accessCodeValidity: request.timing?.accessCodeValidity,
accessTokenValidity: request.timing?.accessTokenValidity,
refreshTokenValidity: request.timing?.refreshTokenValidity,
},
});
};
public getGroupFromName = async (name: string) => {
const groups = await this.#client.core.coreGroupsList({
search: name,
});
return groups.results.find((group) => group.name === name);
};
public getScopePropertyMappings = async () => {
const mappings = await this.#client.propertymappings.propertymappingsProviderScopeList({});
return mappings;
};
public getApplicationFromSlug = async (slug: string) => {
const applications = await this.#client.core.coreApplicationsList({
search: slug,
});
const application = applications.results.find((app) => app.slug === slug);
return application;
};
public getProviderFromClientId = async (clientId: string) => {
const providers = await this.#client.providers.providersOauth2List({
clientId,
});
return providers.results.find((provider) => provider.clientId === clientId);
};
public getFlows = async () => {
const flows = await this.#client.flows.flowsInstancesList();
return flows;
};
public upsertClient = async (request: UpsertClientRequest) => {
try {
let provider = await this.getProviderFromClientId(request.name);
provider = await this.#upsertProvider(request, provider?.pk);
let application = await this.getApplicationFromSlug(request.name);
application = await this.#upsertApplication(request, provider.pk, application?.pk);
const config = {
provider: {
id: provider.pk,
name: provider.name,
clientId: provider.clientId,
clientSecret: provider.clientSecret,
clientType: provider.clientType,
subMode: provider.subMode,
redirectUris: provider.redirectUris,
scopes: provider.propertyMappings,
timing: {
accessCodeValidity: provider.accessCodeValidity,
accessTokenValidity: provider.accessTokenValidity,
refreshTokenValidity: provider.refreshTokenValidity,
},
},
application: {
id: application.pk,
name: application.name,
slug: application.slug,
provider: provider.pk,
},
urls: {
configuration: new URL(
`/application/o/${provider.name}/.well-known/openid-configuration`,
this.url,
).toString(),
configurationIssuer: new URL(`/application/o/${provider.name}/`, this.url).toString(),
authorization: new URL(`/application/o/${provider.name}/authorize/`, this.url).toString(),
token: new URL(`/application/o/${provider.name}/token/`, this.url).toString(),
userinfo: new URL(`/application/o/${provider.name}/userinfo/`, this.url).toString(),
endSession: new URL(`/application/o/${provider.name}/end-session/`, this.url).toString(),
jwks: new URL(`/application/o/${provider.name}/jwks/`, this.url).toString(),
},
};
return { provider, application, config };
} catch (error: ExpectedAny) {
if ('response' in error) {
throw new Error(await error.response.text());
}
throw error;
}
};
public deleteClient = async (name: string) => {
const provider = await this.getProviderFromClientId(name);
if (provider) {
await this.#client.providers.providersOauth2Destroy({ id: provider.pk });
}
const application = await this.getApplicationFromSlug(name);
if (application) {
await this.#client.core.coreApplicationsDestroy({ slug: application.name });
}
};
public upsertGroup = async (request: UpsertGroupRequest) => {
const group = await this.getGroupFromName(request.name);
if (!group) {
await this.#client.core.coreGroupsCreate({
groupRequest: {
name: request.name,
attributes: request.attributes,
},
});
} else {
await this.#client.core.coreGroupsUpdate({
groupUuid: group.pk,
groupRequest: {
name: request.name,
attributes: request.attributes,
},
});
}
};
}
export { AuthentikService };

View File

@@ -0,0 +1,29 @@
import type { ClientTypeEnum, SubModeEnum } from '@goauthentik/api';
type UpsertClientRequest = {
name: string;
secret: string;
scopes?: string[];
flows?: {
authorization: string;
invalidation: string;
};
clientType?: ClientTypeEnum;
subMode?: SubModeEnum;
redirectUris: {
url: string;
matchingMode: 'strict' | 'regex';
}[];
timing?: {
accessCodeValidity?: string;
accessTokenValidity?: string;
refreshTokenValidity?: string;
};
};
type UpsertGroupRequest = {
name: string;
attributes?: Record<string, string[]>;
};
export type { UpsertClientRequest, UpsertGroupRequest };

View File

@@ -11,6 +11,17 @@ class ConfigService {
return { host, user, password, port };
}
public get authentik() {
const url = process.env.AUTHENTIK_URL;
const token = process.env.AUTHENTIK_TOKEN;
if (!url || !token) {
throw new Error('AUTHENTIK_URL and AUTHENTIK_TOKEN must be set');
}
return { url, token };
}
}
export { ConfigService };

View File

@@ -1,10 +1,19 @@
import { KubeConfig, CoreV1Api, ApiextensionsV1Api, CustomObjectsApi } from '@kubernetes/client-node';
import {
KubeConfig,
CoreV1Api,
ApiextensionsV1Api,
CustomObjectsApi,
EventsV1Api,
KubernetesObjectApi,
} from '@kubernetes/client-node';
class K8sService {
#kc: KubeConfig;
#k8sApi: CoreV1Api;
#k8sExtensionsApi: ApiextensionsV1Api;
#k8sCustomObjectsApi: CustomObjectsApi;
#k8sEventsApi: EventsV1Api;
#k8sObjectsApi: KubernetesObjectApi;
constructor() {
this.#kc = new KubeConfig();
@@ -12,6 +21,8 @@ class K8sService {
this.#k8sApi = this.#kc.makeApiClient(CoreV1Api);
this.#k8sExtensionsApi = this.#kc.makeApiClient(ApiextensionsV1Api);
this.#k8sCustomObjectsApi = this.#kc.makeApiClient(CustomObjectsApi);
this.#k8sEventsApi = this.#kc.makeApiClient(EventsV1Api);
this.#k8sObjectsApi = this.#kc.makeApiClient(KubernetesObjectApi);
}
public get config() {
@@ -29,6 +40,14 @@ class K8sService {
public get customObjectsApi() {
return this.#k8sCustomObjectsApi;
}
public get eventsApi() {
return this.#k8sEventsApi;
}
public get objectsApi() {
return this.#k8sObjectsApi;
}
}
export { K8sService };

View File

@@ -4,9 +4,6 @@ type Dependency<T> = new (services: Services) => T;
class Services {
#instances = new Map<Dependency<unknown>, unknown>();
constructor() {
console.log('Constructor', 'bar');
}
public get log() {
return this.get(LogService);

12
src/utils/types.ts Normal file
View File

@@ -0,0 +1,12 @@
declare global {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type ExpectedAny = any;
}
// eslint-disable-next-line @typescript-eslint/no-empty-function
const noop = () => {};
// eslint-disable-next-line @typescript-eslint/no-empty-function
const noopAsync = async () => {};
export { noop, noopAsync };

View File

@@ -0,0 +1,8 @@
apiVersion: homelab.mortenolsen.pro/v1
kind: AuthentikClient
metadata:
name: foobas
spec:
redirectUris:
- url: http://localhost:3000/api/auth/callback
matchingMode: strict

View File

@@ -0,0 +1,5 @@
apiVersion: 'homelab.mortenolsen.pro/v1'
kind: 'PostgresDatabase'
metadata:
name: 'test2'
namespace: 'playground'

View File

@@ -16,9 +16,8 @@
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// "noUncheckedIndexedAccess": true,
// "noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,