This commit is contained in:
Morten Olsen
2025-08-18 08:02:48 +02:00
parent 295472a028
commit a27b563113
27 changed files with 499 additions and 64 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
environment: dev/dev
postgresCluster: dev/dev-postgres-cluster
authentikServer: dev/dev-authentik-server
storageClassName: dev-retain
subdomain: bytestash

View File

@@ -3,7 +3,7 @@
# Declare variables to be passed into your templates. # Declare variables to be passed into your templates.
image: image:
repository: ghcr.io/morten-olsen/homelab-operator repository: homelab-operator # ghcr.io/morten-olsen/homelab-operator
pullPolicy: Always pullPolicy: Always
# Overrides the image tag whose default is the chart appVersion. # Overrides the image tag whose default is the chart appVersion.
tag: main tag: main

View File

@@ -0,0 +1,9 @@
```yaml
kind: Environment
metadata:
name: dev
spec:
domain: one.dev.olsen.cloud
tls:
issuer: lets-encrypt-prod
```

View File

View File

@@ -0,0 +1,8 @@
```
kind: OidcClient
metadata:
name: demo
namespace: dev-demo
spec:
env: dev
```

View File

@@ -0,0 +1,8 @@
```yaml
kind: PostgresDatabase
metadata:
name: demo
namespace: dev-demo
spec:
env: dev
```

28
skaffold.yaml Normal file
View File

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

View File

@@ -17,7 +17,7 @@ import { authentikServerSecretSchema } from '../authentik-server/authentik-serve
import { authentikClientSecretSchema, type authentikClientSpecSchema } from './authentik-client.schemas.ts'; import { authentikClientSecretSchema, type authentikClientSpecSchema } from './authentik-client.schemas.ts';
class AuthentikClientResource extends CustomResource<typeof authentikClientSpecSchema> { class AuthentikClientController extends CustomResource<typeof authentikClientSpecSchema> {
#serverSecret: ResourceReference<V1Secret>; #serverSecret: ResourceReference<V1Secret>;
#clientSecretResource: Resource<V1Secret>; #clientSecretResource: Resource<V1Secret>;
@@ -172,4 +172,4 @@ class AuthentikClientResource extends CustomResource<typeof authentikClientSpecS
}; };
} }
export { AuthentikClientResource }; export { AuthentikClientController };

View File

@@ -1,7 +1,7 @@
import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts'; import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts';
import { GROUP } from '../../utils/consts.ts'; import { GROUP } from '../../utils/consts.ts';
import { AuthentikClientResource } from './authentik-client.resource.ts'; import { AuthentikClientController } from './authentik-client.controller.ts';
import { authentikClientSpecSchema } from './authentik-client.schemas.ts'; import { authentikClientSpecSchema } from './authentik-client.schemas.ts';
const authentikClientDefinition = createCustomResourceDefinition({ const authentikClientDefinition = createCustomResourceDefinition({
@@ -12,7 +12,7 @@ const authentikClientDefinition = createCustomResourceDefinition({
plural: 'authentikclients', plural: 'authentikclients',
singular: 'authentikclient', singular: 'authentikclient',
}, },
create: (options) => new AuthentikClientResource(options), create: (options) => new AuthentikClientController(options),
spec: authentikClientSpecSchema, spec: authentikClientSpecSchema,
}); });

View File

@@ -93,10 +93,11 @@ class AuthentikServerController extends CustomResource<typeof authentikServerSpe
this.#redisServer = new ResourceReference(); this.#redisServer = new ResourceReference();
this.#postgresSecret = new ResourceReference(); this.#postgresSecret = new ResourceReference();
this.#authentikSecret.on('changed', this.queueReconcile); this.#authentikSecret.on('changed', this.queueReconcile);
this.#authentikInitSecret.resource.on('deleted', this.queueReconcile); this.#authentikInitSecret.resource.on('changed', this.queueReconcile);
this.#environment.on('changed', this.queueReconcile); this.#environment.on('changed', this.queueReconcile);
this.#authentikRelease.on('changed', this.queueReconcile); this.#authentikRelease.on('changed', this.queueReconcile);
this.#postgresSecret.on('changed', this.queueReconcile); this.#postgresSecret.on('changed', this.queueReconcile);
this.#postgresDatabase.on('changed', this.queueReconcile);
this.#httpService.on('changed', this.queueReconcile); this.#httpService.on('changed', this.queueReconcile);
this.#redisServer.on('changed', this.queueReconcile); this.#redisServer.on('changed', this.queueReconcile);
} }

View File

@@ -1,7 +1,26 @@
import type { authentikServerSpecSchema } from '../custom-resouces/authentik-server/authentik-server.schemas.ts'; import {
authentikServerSecretSchema,
type authentikServerSpecSchema,
} from '../custom-resouces/authentik-server/authentik-server.schemas.ts';
import type { CustomResourceObject } from '../services/custom-resources/custom-resources.custom-resource.ts'; import type { CustomResourceObject } from '../services/custom-resources/custom-resources.custom-resource.ts';
import { ResourceInstance } from '../services/resources/resources.instance.ts'; import { ResourceInstance } from '../services/resources/resources.instance.ts';
import { ResourceService } from '../services/resources/resources.ts';
class AuthentikServerInstance extends ResourceInstance<CustomResourceObject<typeof authentikServerSpecSchema>> {} import { SecretInstance } from './secret.ts';
class AuthentikServerInstance extends ResourceInstance<CustomResourceObject<typeof authentikServerSpecSchema>> {
public get secret() {
const resourceService = this.services.get(ResourceService);
return resourceService.getInstance(
{
apiVersion: 'v1',
kind: 'Secret',
name: `${this.name}-server`,
namespace: this.namespace,
},
SecretInstance<typeof authentikServerSecretSchema>,
);
}
}
export { AuthentikServerInstance }; export { AuthentikServerInstance };

View File

@@ -1,3 +1,4 @@
import { postgresClusterSecretSchema } from '../custom-resouces/postgres-cluster/postgres-cluster.schemas.ts';
import type { postgresDatabaseSpecSchema } from '../custom-resouces/postgres-database/portgres-database.schemas.ts'; import type { postgresDatabaseSpecSchema } from '../custom-resouces/postgres-database/portgres-database.schemas.ts';
import type { CustomResourceObject } from '../services/custom-resources/custom-resources.custom-resource.ts'; import type { CustomResourceObject } from '../services/custom-resources/custom-resources.custom-resource.ts';
import { ResourceInstance } from '../services/resources/resources.instance.ts'; import { ResourceInstance } from '../services/resources/resources.instance.ts';
@@ -15,7 +16,7 @@ class PostgresDatabaseInstance extends ResourceInstance<CustomResourceObject<typ
name: `${this.name}-postgres-database`, name: `${this.name}-postgres-database`,
namespace: this.namespace, namespace: this.namespace,
}, },
SecretInstance, SecretInstance<typeof postgresClusterSecretSchema>,
); );
} }
} }

View File

@@ -0,0 +1,15 @@
import type { ResourceInstance } from '../resources/resources.instance.ts';
type DependencyRef<T extends ResourceInstance<ExpectedAny>> = {
apiVersion: string;
kind: string;
name: string;
namespace?: string;
instance: T;
};
class CustomResourceControllerDependencies {
public get = <T extends ResourceInstance<ExpectedAny>>(name: string, ref: DependencyRef<T>) => { };
}
export { CustomResourceControllerDependencies };

View File

@@ -0,0 +1,25 @@
import type { z, ZodAny, ZodType } from 'zod';
import type { KubernetesObject } from '@kubernetes/client-node';
import type { Resource } from '../resources/resources.resource.ts';
import type { CustomResourceControllerDependencies } from './controllers.dependencies.ts';
type CustomResourceControllerOptions<TSpec extends ZodType> = {
resource: Resource<KubernetesObject & { spec: z.infer<TSpec> }>;
dependencies: CustomResourceControllerDependencies;
};
type CustomResourceController<TSpec extends ZodType> = (options: CustomResourceControllerOptions<TSpec>) => {
reconcile: () => Promise<void>;
};
type CustomResource<TSpec extends ZodAny> = {
group: string;
version: string;
spec: TSpec;
scope: 'namespace' | 'cluster';
controller: CustomResourceController<TSpec>;
};
export type { CustomResource, CustomResourceController };

View File

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

View File

@@ -1,54 +1,43 @@
import type { KubernetesObject } from '@kubernetes/client-node'; import type { KubernetesObject } from '@kubernetes/client-node';
import type { Services } from '../../utils/service.ts'; import type { Services } from '../../utils/service.ts';
import { WatcherService } from '../watchers/watchers.ts';
import { Resource } from './resources.resource.ts'; import type { Resource, ResourceOptions } from './resource/resource.ts';
import type { ResourceInstance } from './resources.instance.ts';
type ResourceGetOptions = { type ResourceClass<T extends KubernetesObject> = new (options: ResourceOptions<T>) => Resource<T>;
type RegisterOptions<T extends KubernetesObject> = {
apiVersion: string; apiVersion: string;
kind: string; kind: string;
name: string; plural?: string;
namespace?: string; type: ResourceClass<T>;
}; };
class ResourceService { class ResourceService {
#cache: Resource<ExpectedAny>[] = [];
#services: Services; #services: Services;
#registry: Map<Resource<ExpectedAny>, Resource<ExpectedAny>[]>;
constructor(services: Services) { constructor(services: Services) {
this.#services = services; this.#services = services;
this.#registry = new Map();
} }
public getInstance = <T extends KubernetesObject, I extends ResourceInstance<T>>( public register = async <T extends KubernetesObject>(options: RegisterOptions<T>) => {
options: ResourceGetOptions, const watcherService = this.#services.get(WatcherService);
instance: new (resource: Resource<T>) => I, const watcher = watcherService.create({});
) => { watcher.on('changed', (manifest) => {
const resource = this.get<T>(options); const { name, namespace } = manifest.metadata || {};
return new instance(resource); if (!name) {
}; return;
public get = <T extends KubernetesObject>(options: ResourceGetOptions) => {
const { apiVersion, kind, name, namespace } = options;
let resource = this.#cache.find(
(resource) =>
resource.specifier.kind === kind &&
resource.specifier.apiVersion === apiVersion &&
resource.specifier.name === name &&
resource.specifier.namespace === namespace,
);
if (resource) {
return resource as Resource<T>;
} }
resource = new Resource({ const current = this.get(options.type, name, namespace);
data: options, current.manifest = manifest;
services: this.#services,
}); });
this.#cache.push(resource); await watcher.start();
return resource as Resource<T>;
}; };
public get = <T extends KubernetesObject>(type: ResourceClass<T>, name: string, namespace?: string) => {};
} }
export { ResourceInstance } from './resources.instance.ts'; export { ResourceService };
export { ResourceReference } from './resources.ref.ts';
export { ResourceService, Resource };

View File

@@ -0,0 +1,54 @@
import type { KubernetesObject } from '@kubernetes/client-node';
import type { Services } from '../../utils/service.ts';
import { Resource } from './resources.resource.ts';
import type { ResourceInstance } from './resources.instance.ts';
type ResourceGetOptions = {
apiVersion: string;
kind: string;
name: string;
namespace?: string;
};
class ResourceService {
#cache: Resource<ExpectedAny>[] = [];
#services: Services;
constructor(services: Services) {
this.#services = services;
}
public getInstance = <T extends KubernetesObject, I extends ResourceInstance<T>>(
options: ResourceGetOptions,
instance: new (resource: Resource<T>) => I,
) => {
const resource = this.get<T>(options);
return new instance(resource);
};
public get = <T extends KubernetesObject>(options: ResourceGetOptions) => {
const { apiVersion, kind, name, namespace } = options;
let resource = this.#cache.find(
(resource) =>
resource.specifier.kind === kind &&
resource.specifier.apiVersion === apiVersion &&
resource.specifier.name === name &&
resource.specifier.namespace === namespace,
);
if (resource) {
return resource as Resource<T>;
}
resource = new Resource({
data: options,
services: this.#services,
});
this.#cache.push(resource);
return resource as Resource<T>;
};
}
export { ResourceInstance } from './resources.instance.ts';
export { ResourceReference } from './resources.ref.ts';
export { ResourceService, Resource };

View File

@@ -9,12 +9,11 @@ import { EventEmitter } from 'eventemitter3';
import { K8sService } from '../k8s/k8s.ts'; import { K8sService } from '../k8s/k8s.ts';
import type { Services } from '../../utils/service.ts'; import type { Services } from '../../utils/service.ts';
import { ResourceService, type Resource } from '../resources/resources.ts';
type ResourceChangedAction = 'add' | 'update' | 'delete'; type ResourceChangedAction = 'add' | 'update' | 'delete';
type WatcherEvents<T extends KubernetesObject> = { type WatcherEvents<T extends KubernetesObject> = {
changed: (resource: Resource<T>) => void; changed: (manifest: T) => void;
}; };
type WatcherOptions<T extends KubernetesObject = KubernetesObject> = { type WatcherOptions<T extends KubernetesObject = KubernetesObject> = {
@@ -53,27 +52,10 @@ class Watcher<T extends KubernetesObject> extends EventEmitter<WatcherEvents<T>>
}; };
#handleResource = (action: ResourceChangedAction, originalManifest: T) => { #handleResource = (action: ResourceChangedAction, originalManifest: T) => {
const { services, transform } = this.#options; const { transform } = this.#options;
const manifest = transform ? transform(originalManifest) : originalManifest; const manifest = transform ? transform(originalManifest) : originalManifest;
const resourceService = services.get(ResourceService);
const { apiVersion, kind, metadata = {} } = manifest;
const { name, namespace } = metadata;
if (!name || !apiVersion || !kind) {
return;
}
const resource = resourceService.get<T>({
apiVersion,
kind,
name,
namespace,
});
if (action === 'delete') { this.emit('changed', manifest);
resource.manifest = undefined;
} else {
resource.manifest = manifest;
}
this.emit('changed', resource);
}; };
public stop = async () => { public stop = async () => {