Compare commits

..

1 Commits

Author SHA1 Message Date
Morten Olsen
83050d1eb1 add deployments 2025-07-28 22:57:43 +02:00
33 changed files with 1661 additions and 59910 deletions

View File

@@ -1,5 +1,5 @@
name-template: "$RESOLVED_VERSION 🌈"
tag-template: "v$RESOLVED_VERSION"
tag-template: "$RESOLVED_VERSION"
categories:
- title: "🚀 Features"
labels:

View File

@@ -1,4 +1,4 @@
name: Build, tag and release
name: Build and release
on:
push:
@@ -77,3 +77,49 @@ jobs:
publish: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
release:
permissions:
contents: read
packages: write
attestations: write
id-token: write
pages: write
name: Release
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
needs: update-release-draft
environment: release
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

View File

@@ -1,65 +0,0 @@
name: Publish tag
on:
push:
branches:
- 'main'
tags:
- "v*"
env:
environment: test
release_channel: latest
DO_NOT_TRACK: "1"
NODE_VERSION: "23.x"
DOCKER_REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
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

277
README.md
View File

@@ -1,282 +1,15 @@
# homelab-operator
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:
To install dependencies:
```bash
git clone <repository-url>
cd homelab-operator
bun install
```
2. Install using Helm:
To run:
```bash
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>
bun run index.ts
```
### 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
```
This project was created using `bun init` in bun v1.2.16. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.

View File

@@ -4,9 +4,9 @@
image:
repository: ghcr.io/morten-olsen/homelab-operator
pullPolicy: Always
pullPolicy: IfNotPresent
# Overrides the image tag whose default is the chart appVersion.
tag: main
tag: ""
imagePullSecrets: []
nameOverride: ""

View File

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

View File

@@ -4,8 +4,11 @@
"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",
@@ -18,22 +21,17 @@
"typescript": "^5"
},
"dependencies": {
"@goauthentik/api": "2025.6.3-1751754396",
"@kubernetes/client-node": "^1.3.0",
"@sinclair/typebox": "^0.34.38",
"knex": "^3.1.0",
"pg": "^8.16.3",
"sqlite3": "^5.1.7",
"yaml": "^2.8.0",
"zod": "^4.0.14"
"sqlite3": "^5.1.7"
},
"packageManager": "pnpm@10.6.0",
"pnpm": {
"onlyBuiltDependencies": [
"sqlite3"
],
"patchedDependencies": {
"@kubernetes/client-node": "patches/@kubernetes__client-node.patch"
}
]
},
"scripts": {
"test": "echo 'No tests'",

View File

@@ -1,14 +0,0 @@
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();

1307
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

8
postgres-database.ts Normal file
View File

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

View File

@@ -1,24 +0,0 @@
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'),
);

View File

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

View File

@@ -1,33 +0,0 @@
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 };

File diff suppressed because it is too large Load Diff

View File

@@ -1,58 +0,0 @@
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

@@ -1,26 +0,0 @@
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

@@ -1,9 +1,12 @@
import { z } from 'zod';
import { Type } from '@sinclair/typebox';
import { ApiException, type V1Secret } from '@kubernetes/client-node';
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 = z.object({});
const postgresDatabaseSpecSchema = Type.Object({});
class PostgresDatabase extends CustomResource<typeof postgresDatabaseSpecSchema> {
constructor() {
@@ -17,39 +20,99 @@ class PostgresDatabase extends CustomResource<typeof postgresDatabaseSpecSchema>
});
}
public update = async (options: CustomResourceHandlerOptions<typeof postgresDatabaseSpecSchema>) => {
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')}`,
}),
#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 postgresService = services.get(PostgresService);
await postgresService.upsertRole({
name: variables.user,
password: variables.password,
name: Buffer.from(variables.user!, 'base64').toString('utf-8'),
password: Buffer.from(variables.password!, 'base64').toString('utf-8'),
});
await postgresService.upsertDatabase({
name: variables.name,
owner: variables.user,
name: Buffer.from(variables.name!, 'base64').toString('utf-8'),
owner: Buffer.from(variables.user!, 'base64').toString('utf-8'),
});
await request.addEvent({
type: 'Normal',
reason: 'DatabaseUpserted',
message: 'Database has been upserted',
action: 'UPSERT',
status.setCondition('Ready', {
status: 'True',
reason: 'Ready',
message: 'Role created',
});
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,18 +1,24 @@
import { z } from 'zod';
import { Type } from '@sinclair/typebox';
import { ApiException, type V1Secret } from '@kubernetes/client-node';
import { CustomResource, type CustomResourceHandlerOptions } from '../../custom-resource/custom-resource.base.ts';
import { K8sService } from '../../services/k8s.ts';
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 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 secretRequestSpec = z.object({
secretName: z.string().optional(),
data: z.array(stringValueSchema),
const secretRequestSpec = Type.Object({
secretName: Type.Optional(Type.String()),
data: Type.Array(stringValueSchema),
});
class SecretRequest extends CustomResource<typeof secretRequestSpec> {
@@ -27,18 +33,71 @@ class SecretRequest extends CustomResource<typeof secretRequestSpec> {
});
}
public update = async (options: CustomResourceHandlerOptions<typeof secretRequestSpec>) => {
const { request, ensureSecret } = options;
const { secretName = request.metadata.name } = request.spec;
const { namespace = request.metadata.namespace ?? 'default' } = request.metadata;
await ensureSecret({
#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,
schema: z.object({}).passthrough(),
generator: async () => ({
hello: 'world',
}),
});
} catch (error) {
if (!(error instanceof ApiException && error.code === 404)) {
throw error;
}
}
if (current) {
services.log.debug('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',
});
return await status.save();
} catch {
status.setCondition('Ready', {
status: 'False',
reason: 'SecretNotCreated',
message: 'Secret not created',
});
}
};
}

View File

@@ -1,25 +1,17 @@
import { z, type ZodObject } from 'zod';
import { type TSchema } from '@sinclair/typebox';
import { GROUP } from '../utils/consts.ts';
import type { Services } from '../utils/service.ts';
import { noopAsync } from '../utils/types.ts';
import { customResourceStatusSchema, type CustomResourceRequest } from './custom-resource.request.ts';
import { statusSchema } from './custom-resource.status.ts';
import type { CustomResourceRequest } from './custom-resource.request.ts';
type EnsureSecretOptions<T extends ZodObject> = {
schema: T;
name: string;
namespace: string;
generator: () => Promise<z.infer<T>>;
};
type CustomResourceHandlerOptions<TSpec extends ZodObject> = {
type CustomResourceHandlerOptions<TSpec extends TSchema> = {
request: CustomResourceRequest<TSpec>;
ensureSecret: <T extends ZodObject>(options: EnsureSecretOptions<T>) => Promise<z.infer<T>>;
services: Services;
};
type CustomResourceConstructor<TSpec extends ZodObject> = {
type CustomResourceConstructor<TSpec extends TSchema> = {
kind: string;
spec: TSpec;
names: {
@@ -28,7 +20,7 @@ type CustomResourceConstructor<TSpec extends ZodObject> = {
};
};
abstract class CustomResource<TSpec extends ZodObject> {
abstract class CustomResource<TSpec extends TSchema> {
#options: CustomResourceConstructor<TSpec>;
constructor(options: CustomResourceConstructor<TSpec>) {
@@ -89,16 +81,8 @@ abstract class CustomResource<TSpec extends ZodObject> {
openAPIV3Schema: {
type: 'object',
properties: {
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,
spec: this.spec,
status: statusSchema,
},
},
},
@@ -112,28 +96,4 @@ abstract class CustomResource<TSpec extends ZodObject> {
};
}
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,
};
export { CustomResource, type CustomResourceConstructor, type CustomResourceHandlerOptions };

View File

@@ -1,15 +1,14 @@
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, type EnsureSecretOptions } from './custom-resource.base.ts';
import { type CustomResource } from './custom-resource.base.ts';
import { CustomResourceRequest } from './custom-resource.request.ts';
class CustomResourceRegistry {
#services: Services;
#resources = new Set<CustomResource<ExpectedAny>>();
#resources = new Set<CustomResource<any>>();
#watchers = new Map<string, AbortController>();
constructor(services: Services) {
@@ -24,11 +23,11 @@ class CustomResourceRegistry {
return Array.from(this.#resources).find((r) => r.kind === kind);
};
public register = (resource: CustomResource<ExpectedAny>) => {
public register = (resource: CustomResource<any>) => {
this.#resources.add(resource);
};
public unregister = (resource: CustomResource<ExpectedAny>) => {
public unregister = (resource: CustomResource<any>) => {
this.#resources.delete(resource);
this.#watchers.forEach((controller, kind) => {
if (kind === resource.kind) {
@@ -51,70 +50,7 @@ class CustomResourceRegistry {
}
};
#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) => {
#onResourceEvent = async (type: string, obj: any) => {
const { kind } = obj;
const crd = this.getByKind(kind);
if (!crd) {
@@ -129,82 +65,31 @@ 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: ExpectedAny) => {
this.#services.log.error('Error watching resource', { error });
#onError = (error: any) => {
console.error(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({
@@ -222,12 +107,6 @@ 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 { ApiException, PatchStrategy, setHeaderOptions, V1MicroTime } from '@kubernetes/client-node';
import { z, type ZodObject } from 'zod';
import type { Static, TSchema } from '@sinclair/typebox';
import { ApiException, PatchStrategy, setHeaderOptions } from '@kubernetes/client-node';
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: ExpectedAny;
manifest: any;
services: Services;
};
@@ -24,29 +24,7 @@ type CustomResourceRequestMetadata = Record<string, string> & {
generation: number;
};
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> {
class CustomResourceRequest<TSpec extends TSchema> {
#options: CustomResourceRequestOptions;
constructor(options: CustomResourceRequestOptions) {
@@ -73,7 +51,7 @@ class CustomResourceRequest<TSpec extends ZodObject> {
return this.#options.manifest.apiVersion;
}
public get spec(): z.infer<TSpec> {
public get spec(): Static<TSpec> {
return this.#options.manifest.spec;
}
@@ -81,10 +59,10 @@ class CustomResourceRequest<TSpec extends ZodObject> {
return this.#options.manifest.metadata;
}
public isOwnerOf = (manifest: ExpectedAny) => {
public isOwnerOf = (manifest: any) => {
const ownerRef = manifest?.metadata?.ownerReferences || [];
return ownerRef.some(
(ref: ExpectedAny) =>
(ref: any) =>
ref.apiVersion === this.apiVersion &&
ref.kind === this.kind &&
ref.name === this.metadata.name &&
@@ -92,73 +70,11 @@ class CustomResourceRequest<TSpec extends ZodObject> {
);
};
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>) => {
public setStatus = async (status: CustomResourceStatusType) => {
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`);
}
@@ -174,14 +90,7 @@ class CustomResourceRequest<TSpec extends ZodObject> {
namespace,
plural: crd.names.plural,
name,
body: {
status: {
observedGeneration: manifest.metadata.generation,
conditions: current?.status?.conditions || [],
...current?.status,
...status,
},
},
body: { status },
fieldValidation: 'Strict',
},
setHeaderOptions('Content-Type', PatchStrategy.MergePatch),
@@ -209,8 +118,8 @@ class CustomResourceRequest<TSpec extends ZodObject> {
apiVersion: string;
kind: string;
metadata: CustomResourceRequestMetadata;
spec: z.infer<TSpec>;
status: CustomResourceStatus;
spec: Static<TSpec>;
status: CustomResourceStatusType;
};
} catch (error) {
if (error instanceof ApiException && error.code === 404) {
@@ -219,6 +128,25 @@ class CustomResourceRequest<TSpec extends ZodObject> {
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, customResourceStatusSchema };
export { CustomResourceRequest };

View File

@@ -0,0 +1,85 @@
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,46 +1,11 @@
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

@@ -1,216 +0,0 @@
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

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

View File

@@ -1,12 +0,0 @@
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

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

View File

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

View File

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