diff --git a/charts/apps/bytestash/Chart.yaml b/charts/apps/bytestash/Chart.yaml new file mode 100644 index 0000000..8bc957b --- /dev/null +++ b/charts/apps/bytestash/Chart.yaml @@ -0,0 +1,3 @@ +apiVersion: v2 +version: 1.0.0 +name: ByteStash diff --git a/charts/apps/bytestash/templates/client.yaml b/charts/apps/bytestash/templates/client.yaml new file mode 100644 index 0000000..09ecb3b --- /dev/null +++ b/charts/apps/bytestash/templates/client.yaml @@ -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 diff --git a/charts/apps/bytestash/templates/headless-service.yaml b/charts/apps/bytestash/templates/headless-service.yaml new file mode 100644 index 0000000..5a253d1 --- /dev/null +++ b/charts/apps/bytestash/templates/headless-service.yaml @@ -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 }}' diff --git a/charts/apps/bytestash/templates/http-service.yaml b/charts/apps/bytestash/templates/http-service.yaml new file mode 100644 index 0000000..a57b37d --- /dev/null +++ b/charts/apps/bytestash/templates/http-service.yaml @@ -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 diff --git a/charts/apps/bytestash/templates/service.yaml b/charts/apps/bytestash/templates/service.yaml new file mode 100644 index 0000000..b8bedd6 --- /dev/null +++ b/charts/apps/bytestash/templates/service.yaml @@ -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 }}' diff --git a/charts/apps/bytestash/templates/stateful-set.yaml b/charts/apps/bytestash/templates/stateful-set.yaml new file mode 100644 index 0000000..2c812cd --- /dev/null +++ b/charts/apps/bytestash/templates/stateful-set.yaml @@ -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 diff --git a/charts/apps/bytestash/values.yaml b/charts/apps/bytestash/values.yaml new file mode 100644 index 0000000..65f3462 --- /dev/null +++ b/charts/apps/bytestash/values.yaml @@ -0,0 +1,5 @@ +environment: dev/dev +postgresCluster: dev/dev-postgres-cluster +authentikServer: dev/dev-authentik-server +storageClassName: dev-retain +subdomain: bytestash diff --git a/charts/operator/values.yaml b/charts/operator/values.yaml index 7de2c4d..9128485 100644 --- a/charts/operator/values.yaml +++ b/charts/operator/values.yaml @@ -3,7 +3,7 @@ # Declare variables to be passed into your templates. image: - repository: ghcr.io/morten-olsen/homelab-operator + repository: homelab-operator # ghcr.io/morten-olsen/homelab-operator pullPolicy: Always # Overrides the image tag whose default is the chart appVersion. tag: main diff --git a/docs/resources/environment.md b/docs/resources/environment.md new file mode 100644 index 0000000..1a302ee --- /dev/null +++ b/docs/resources/environment.md @@ -0,0 +1,9 @@ +```yaml +kind: Environment +metadata: + name: dev +spec: + domain: one.dev.olsen.cloud + tls: + issuer: lets-encrypt-prod +``` diff --git a/docs/resources/http-service.md b/docs/resources/http-service.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/resources/oidc-client.md b/docs/resources/oidc-client.md new file mode 100644 index 0000000..9eed270 --- /dev/null +++ b/docs/resources/oidc-client.md @@ -0,0 +1,8 @@ +``` +kind: OidcClient +metadata: + name: demo + namespace: dev-demo +spec: + env: dev +``` diff --git a/docs/resources/postgres-database.md b/docs/resources/postgres-database.md new file mode 100644 index 0000000..e9e8ae6 --- /dev/null +++ b/docs/resources/postgres-database.md @@ -0,0 +1,8 @@ +```yaml +kind: PostgresDatabase +metadata: + name: demo + namespace: dev-demo +spec: + env: dev +``` diff --git a/skaffold.yaml b/skaffold.yaml new file mode 100644 index 0000000..fd3683a --- /dev/null +++ b/skaffold.yaml @@ -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: {} diff --git a/src/custom-resouces/authentik-client/authentik-client.resource.ts b/src/custom-resouces/authentik-client/authentik-client.controller.ts similarity index 97% rename from src/custom-resouces/authentik-client/authentik-client.resource.ts rename to src/custom-resouces/authentik-client/authentik-client.controller.ts index d7c9bec..927ba44 100644 --- a/src/custom-resouces/authentik-client/authentik-client.resource.ts +++ b/src/custom-resouces/authentik-client/authentik-client.controller.ts @@ -17,7 +17,7 @@ import { authentikServerSecretSchema } from '../authentik-server/authentik-serve import { authentikClientSecretSchema, type authentikClientSpecSchema } from './authentik-client.schemas.ts'; -class AuthentikClientResource extends CustomResource { +class AuthentikClientController extends CustomResource { #serverSecret: ResourceReference; #clientSecretResource: Resource; @@ -172,4 +172,4 @@ class AuthentikClientResource extends CustomResource new AuthentikClientResource(options), + create: (options) => new AuthentikClientController(options), spec: authentikClientSpecSchema, }); diff --git a/src/custom-resouces/authentik-server/authentik-server.controller.ts b/src/custom-resouces/authentik-server/authentik-server.controller.ts index 353615f..32f46b0 100644 --- a/src/custom-resouces/authentik-server/authentik-server.controller.ts +++ b/src/custom-resouces/authentik-server/authentik-server.controller.ts @@ -93,10 +93,11 @@ class AuthentikServerController extends CustomResource> {} +import { SecretInstance } from './secret.ts'; + +class AuthentikServerInstance extends ResourceInstance> { + public get secret() { + const resourceService = this.services.get(ResourceService); + return resourceService.getInstance( + { + apiVersion: 'v1', + kind: 'Secret', + name: `${this.name}-server`, + namespace: this.namespace, + }, + SecretInstance, + ); + } +} export { AuthentikServerInstance }; diff --git a/src/instances/postgres-database.ts b/src/instances/postgres-database.ts index 63e8089..737777e 100644 --- a/src/instances/postgres-database.ts +++ b/src/instances/postgres-database.ts @@ -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 { CustomResourceObject } from '../services/custom-resources/custom-resources.custom-resource.ts'; import { ResourceInstance } from '../services/resources/resources.instance.ts'; @@ -15,7 +16,7 @@ class PostgresDatabaseInstance extends ResourceInstance, ); } } diff --git a/src/services/controllers/controllers.dependencies.ts b/src/services/controllers/controllers.dependencies.ts new file mode 100644 index 0000000..5f8d818 --- /dev/null +++ b/src/services/controllers/controllers.dependencies.ts @@ -0,0 +1,15 @@ +import type { ResourceInstance } from '../resources/resources.instance.ts'; + +type DependencyRef> = { + apiVersion: string; + kind: string; + name: string; + namespace?: string; + instance: T; +}; + +class CustomResourceControllerDependencies { + public get = >(name: string, ref: DependencyRef) => { }; +} + +export { CustomResourceControllerDependencies }; diff --git a/src/services/controllers/controllers.types.ts b/src/services/controllers/controllers.types.ts new file mode 100644 index 0000000..087293c --- /dev/null +++ b/src/services/controllers/controllers.types.ts @@ -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 = { + resource: Resource }>; + dependencies: CustomResourceControllerDependencies; +}; + +type CustomResourceController = (options: CustomResourceControllerOptions) => { + reconcile: () => Promise; +}; + +type CustomResource = { + group: string; + version: string; + spec: TSpec; + scope: 'namespace' | 'cluster'; + controller: CustomResourceController; +}; + +export type { CustomResource, CustomResourceController }; diff --git a/src/services/resources/resource/resource.ts b/src/services/resources/resource/resource.ts new file mode 100644 index 0000000..a036ac9 --- /dev/null +++ b/src/services/resources/resource/resource.ts @@ -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 = { + services: Services; + selector: ResourceSelector; + manifest?: T; +}; + +type ResourceEvents = { + changed: () => void; +}; + +class Resource extends EventEmitter { + #manifest?: T; + #queue: Queue; + #options: ResourceOptions; + + constructor(options: ResourceOptions) { + 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 }; diff --git a/src/services/resources/resources.ts b/src/services/resources/resources.ts index f6189d8..a331b5a 100644 --- a/src/services/resources/resources.ts +++ b/src/services/resources/resources.ts @@ -1,54 +1,43 @@ import type { KubernetesObject } from '@kubernetes/client-node'; import type { Services } from '../../utils/service.ts'; +import { WatcherService } from '../watchers/watchers.ts'; -import { Resource } from './resources.resource.ts'; -import type { ResourceInstance } from './resources.instance.ts'; +import type { Resource, ResourceOptions } from './resource/resource.ts'; -type ResourceGetOptions = { +type ResourceClass = new (options: ResourceOptions) => Resource; + +type RegisterOptions = { apiVersion: string; kind: string; - name: string; - namespace?: string; + plural?: string; + type: ResourceClass; }; class ResourceService { - #cache: Resource[] = []; #services: Services; + #registry: Map, Resource[]>; constructor(services: Services) { this.#services = services; + this.#registry = new Map(); } - public getInstance = >( - options: ResourceGetOptions, - instance: new (resource: Resource) => I, - ) => { - const resource = this.get(options); - return new instance(resource); + public register = async (options: RegisterOptions) => { + const watcherService = this.#services.get(WatcherService); + const watcher = watcherService.create({}); + watcher.on('changed', (manifest) => { + const { name, namespace } = manifest.metadata || {}; + if (!name) { + return; + } + const current = this.get(options.type, name, namespace); + current.manifest = manifest; + }); + await watcher.start(); }; - public get = (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; - } - resource = new Resource({ - data: options, - services: this.#services, - }); - this.#cache.push(resource); - return resource as Resource; - }; + public get = (type: ResourceClass, name: string, namespace?: string) => {}; } -export { ResourceInstance } from './resources.instance.ts'; -export { ResourceReference } from './resources.ref.ts'; -export { ResourceService, Resource }; +export { ResourceService }; diff --git a/src/services/resources/resources.instance.ts b/src/services/resources_old/resources.instance.ts similarity index 100% rename from src/services/resources/resources.instance.ts rename to src/services/resources_old/resources.instance.ts diff --git a/src/services/resources/resources.ref.ts b/src/services/resources_old/resources.ref.ts similarity index 100% rename from src/services/resources/resources.ref.ts rename to src/services/resources_old/resources.ref.ts diff --git a/src/services/resources/resources.resource.ts b/src/services/resources_old/resources.resource.ts similarity index 100% rename from src/services/resources/resources.resource.ts rename to src/services/resources_old/resources.resource.ts diff --git a/src/services/resources_old/resources.ts b/src/services/resources_old/resources.ts new file mode 100644 index 0000000..f6189d8 --- /dev/null +++ b/src/services/resources_old/resources.ts @@ -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[] = []; + #services: Services; + + constructor(services: Services) { + this.#services = services; + } + + public getInstance = >( + options: ResourceGetOptions, + instance: new (resource: Resource) => I, + ) => { + const resource = this.get(options); + return new instance(resource); + }; + + public get = (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; + } + resource = new Resource({ + data: options, + services: this.#services, + }); + this.#cache.push(resource); + return resource as Resource; + }; +} + +export { ResourceInstance } from './resources.instance.ts'; +export { ResourceReference } from './resources.ref.ts'; +export { ResourceService, Resource }; diff --git a/src/services/watchers/watchers.watcher.ts b/src/services/watchers/watchers.watcher.ts index 16b3872..299317b 100644 --- a/src/services/watchers/watchers.watcher.ts +++ b/src/services/watchers/watchers.watcher.ts @@ -9,12 +9,11 @@ import { EventEmitter } from 'eventemitter3'; import { K8sService } from '../k8s/k8s.ts'; import type { Services } from '../../utils/service.ts'; -import { ResourceService, type Resource } from '../resources/resources.ts'; type ResourceChangedAction = 'add' | 'update' | 'delete'; type WatcherEvents = { - changed: (resource: Resource) => void; + changed: (manifest: T) => void; }; type WatcherOptions = { @@ -53,27 +52,10 @@ class Watcher extends EventEmitter> }; #handleResource = (action: ResourceChangedAction, originalManifest: T) => { - const { services, transform } = this.#options; + const { transform } = this.#options; 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({ - apiVersion, - kind, - name, - namespace, - }); - if (action === 'delete') { - resource.manifest = undefined; - } else { - resource.manifest = manifest; - } - this.emit('changed', resource); + this.emit('changed', manifest); }; public stop = async () => {