From a25e0b9ffb83d28faf903720d7c87db48baf9278 Mon Sep 17 00:00:00 2001 From: Morten Olsen Date: Fri, 1 Aug 2025 07:52:09 +0200 Subject: [PATCH] updates --- src/crds/authentik/server/server.ts | 0 .../backup-report.ts | 12 +- src/crds/domain/domain/domain.ts | 133 +++++++++++ src/crds/domain/endpoint/endpoint.ts | 79 +++++++ .../custom-resource.registry.ts | 210 +++++++++++------- .../custom-resource.request.ts | 155 ++----------- src/index.ts | 15 +- src/services/authentik/authentik.service.ts | 89 +++++--- src/services/authentik/authentik.setup.ts | 135 +++++++++++ src/services/config/config.ts | 29 ++- src/services/k8s.ts | 88 +++++++- src/services/k8s/k8s.manifest.ts | 179 +++++++++++++++ src/services/log/log.ts | 5 +- src/services/postgres/postgres.service.ts | 7 + src/types/kubernetes.ts | 8 + src/utils/consts.ts | 3 +- src/utils/naming.ts | 13 ++ 17 files changed, 880 insertions(+), 280 deletions(-) create mode 100644 src/crds/authentik/server/server.ts rename src/crds/backup/{backup-report.ts => backup-report}/backup-report.ts (72%) create mode 100644 src/crds/domain/domain/domain.ts create mode 100644 src/crds/domain/endpoint/endpoint.ts create mode 100644 src/services/authentik/authentik.setup.ts create mode 100644 src/services/k8s/k8s.manifest.ts create mode 100644 src/types/kubernetes.ts create mode 100644 src/utils/naming.ts diff --git a/src/crds/authentik/server/server.ts b/src/crds/authentik/server/server.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/crds/backup/backup-report.ts/backup-report.ts b/src/crds/backup/backup-report/backup-report.ts similarity index 72% rename from src/crds/backup/backup-report.ts/backup-report.ts rename to src/crds/backup/backup-report/backup-report.ts index 39f3f09..11f22ef 100644 --- a/src/crds/backup/backup-report.ts/backup-report.ts +++ b/src/crds/backup/backup-report/backup-report.ts @@ -1,14 +1,12 @@ +import { z } from 'zod'; + 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']), + startedAt: z.string().datetime(), + finishedAt: z.string().datetime(), + status: z.enum(['success', 'failed', 'in-progress']), error: z.string().optional(), message: z.string().optional(), }), diff --git a/src/crds/domain/domain/domain.ts b/src/crds/domain/domain/domain.ts new file mode 100644 index 0000000..ddc2863 --- /dev/null +++ b/src/crds/domain/domain/domain.ts @@ -0,0 +1,133 @@ +import z from 'zod'; + +import { createCustomResource } from '../../../custom-resource/custom-resource.base.ts'; +import { K8sService } from '../../../services/k8s.ts'; +import { ConfigService } from '../../../services/config/config.ts'; +import { CustomResourceRegistry } from '../../../custom-resource/custom-resource.registry.ts'; +import { GROUP } from '../../../utils/consts.ts'; + +const Domain = createCustomResource({ + kind: 'Domain', + names: { + singular: 'domain', + plural: 'domains', + }, + spec: z.object({ + domain: z.string(), + }), + update: async ({ request, services }) => { + const k8s = services.get(K8sService); + const config = services.get(ConfigService); + const secretName = `certificate-${request.metadata.name}`; + + request.addEvent({ + type: 'Normal', + message: 'Creating certificate', + reason: 'CreateCertificate', + action: 'Create', + }); + await k8s.upsert({ + apiVersion: 'cert-manager.io/v1', + kind: 'Certificate', + metadata: { + name: request.metadata.name, + namespace: 'istio-ingress', + }, + spec: { + secretName, + dnsNames: [`*.${request.spec.domain}`], + issuerRef: { + name: config.certManager, + kind: 'ClusterIssuer', + }, + }, + }); + request.addEvent({ + type: 'Normal', + message: 'Created certificate', + reason: 'CreatedCertificate', + action: 'Create', + }); + + request.addEvent({ + type: 'Normal', + message: 'Creating gateway', + reason: 'CreateGateway', + action: 'Create', + }); + await k8s.upsert({ + apiVersion: 'networking.istio.io/v1alpha3', + kind: 'Gateway', + metadata: { + name: request.metadata.name, + namespace: request.metadata.namespace, + ownerReferences: [request.objectRef], + }, + spec: { + selector: { + app: config.istio.gateway, + }, + servers: [ + { + port: { + number: 80, + name: 'http', + protocol: 'HTTP', + }, + hosts: [`*.${request.spec.domain}`], + tls: { + httpsRedirect: true, + }, + }, + { + port: { + number: 443, + name: 'https', + protocol: 'HTTPS', + }, + hosts: [`*.${request.spec.domain}`], + tls: { + mode: 'SIMPLE', + credentialName: secretName, + }, + }, + ], + }, + }); + request.addEvent({ + type: 'Normal', + message: 'Created gateway', + reason: 'CreatedGateway', + action: 'Create', + }); + const registryService = services.get(CustomResourceRegistry); + const endpoints = registryService.objects.filter( + (obj) => + obj.manifest.kind === 'DomainEndpoint' && + obj.manifest.apiVersion === `${GROUP}/v1` && + obj.manifest.spec.domain === `${request.metadata.namespace}/${request.metadata.name}`, + ); + const expectedDomainId = [request.metadata.uid, request.metadata.generation].join('.'); + for (const endpoint of endpoints) { + const domainId = endpoint.manifest.metadata[`${GROUP}/domain-id`]; + if (domainId === expectedDomainId) { + continue; + } + request.addEvent({ + type: 'Normal', + message: `Updating dependent endpoint: ${endpoint.manifest.metadata.namespace}/${endpoint.manifest.metadata.name}`, + reason: 'UpdateDependant', + action: 'Update', + }); + await endpoint.manifest.patch({ + metadata: { + annotations: { + [`${GROUP}/generation`]: expectedDomainId, + }, + }, + }); + } + }, +}); + +export { Domain }; diff --git a/src/crds/domain/endpoint/endpoint.ts b/src/crds/domain/endpoint/endpoint.ts new file mode 100644 index 0000000..9809a49 --- /dev/null +++ b/src/crds/domain/endpoint/endpoint.ts @@ -0,0 +1,79 @@ +import z from 'zod'; + +import { createCustomResource } from '../../../custom-resource/custom-resource.base.ts'; +import { K8sService } from '../../../services/k8s.ts'; +import { getWithNamespace } from '../../../utils/naming.ts'; +import { GROUP } from '../../../utils/consts.ts'; + +const DomainEndpoint = createCustomResource({ + kind: 'DomainEndpoint', + names: { + plural: 'domainendpoints', + singular: 'domainendpoint', + }, + spec: z.object({ + domain: z.string(), + subdomain: z.string(), + destination: z.object({ + name: z.string(), + namespace: z.string().optional(), + port: z.object({ + number: z.number(), + }), + }), + }), + update: async ({ request, services }) => { + const k8s = services.get(K8sService); + const domainName = getWithNamespace(request.spec.domain); + const domain = await k8s.get({ + apiVersion: `${GROUP}/v1`, + kind: 'Domain', + name: domainName.name, + namespace: domainName.namespace, + }); + if (!domain) { + throw new Error(`Domain ${request.spec.domain} could not be found`); + } + const host = `${request.spec.subdomain}.${domain.spec.domain}`; + await k8s.upsert({ + apiVersion: 'networking.istio.io/v1alpha3', + kind: 'VirtualService', + metadata: { + name: request.metadata.name, + namespace: request.metadata.namespace, + ownerReferences: [request.objectRef], + labels: { + app: request.spec.destination.name, + }, + annotations: { + [`${GROUP}/domain-id`]: [domain.metadata.uid, domain.metadata.generation].join('.'), + }, + }, + spec: { + hosts: [host], + gateways: [`${domain.metadata.namespace}/${domain.metadata.name}`], + http: [ + { + match: [ + { + uri: { + prefix: '/', + }, + }, + ], + route: [ + { + destination: { + host: `${request.spec.destination.name}.${request.spec.destination.namespace || request.metadata.namespace || 'homelab'}.svc.cluster.local`, + port: request.spec.destination.port, + }, + }, + ], + }, + ], + }, + }); + }, +}); + +export { DomainEndpoint }; diff --git a/src/custom-resource/custom-resource.registry.ts b/src/custom-resource/custom-resource.registry.ts index 4a7820b..fa6e234 100644 --- a/src/custom-resource/custom-resource.registry.ts +++ b/src/custom-resource/custom-resource.registry.ts @@ -7,10 +7,24 @@ import type { Services } from '../utils/service.ts'; import { type CustomResource, type EnsureSecretOptions } from './custom-resource.base.ts'; import { CustomResourceRequest } from './custom-resource.request.ts'; +type ManifestCacheItem = { + kind: string; + namespace?: string; + name?: string; + manifest: CustomResourceRequest; +}; + +type ManifestChangeOptions = { + crd: CustomResource; + cacheKey: string; + manifest: ExpectedAny; +}; + class CustomResourceRegistry { #services: Services; #resources = new Set>(); #watchers = new Map(); + #cache = new Map(); constructor(services: Services) { this.#services = services; @@ -53,112 +67,106 @@ class CustomResourceRegistry { #ensureSecret = (request: CustomResourceRequest) => - async (options: EnsureSecretOptions) => { - 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, - }); + async (options: EnsureSecretOptions) => { + 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; + 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; } } - } 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; } - } - 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) { - return; - } + public get objects() { + return Array.from(this.#cache.values()); + } - let handler = type === 'DELETED' ? crd.delete : crd.update; + #onResourceUpdated = async (type: string, options: ManifestChangeOptions) => { + const { cacheKey, manifest, crd } = options; + const { kind, metadata } = manifest; const request = new CustomResourceRequest({ - type: type as 'ADDED' | 'DELETED' | 'MODIFIED', - manifest: obj, + type: type as 'ADDED' | 'MODIFIED', + manifest: manifest, services: this.#services, }); - + this.#cache.set(cacheKey, { + kind, + manifest: request, + }); const status = await request.getStatus(); if (status && (type === 'ADDED' || type === 'MODIFIED')) { - if (status.observedGeneration === obj.metadata.generation) { + if (status.observedGeneration === metadata.generation) { this.#services.log.debug('Skipping resource update', { kind, - name: obj.metadata.name, - namespace: obj.metadata.namespace, + name: metadata.name, + namespace: metadata.namespace, observedGeneration: status.observedGeneration, - generation: obj.metadata.generation, + generation: metadata.generation, }); return; } } - this.#services.log.debug('Updating resource', { type, kind, - name: obj.metadata.name, - namespace: obj.metadata.namespace, + name: metadata.name, + namespace: metadata.namespace, observedGeneration: status?.observedGeneration, - generation: obj.metadata.generation, + generation: metadata.generation, }); - - if (type === 'ADDED' || type === 'MODIFIED') { - await request.markSeen(); - } - - if (type === 'ADDED' && crd.create) { - handler = crd.create; - } - + await request.markSeen(); + const handler = type === 'ADDED' && crd.create ? crd.create : crd.update; try { await handler?.({ request, @@ -177,13 +185,13 @@ class CustomResourceRegistry { if (error instanceof ApiException) { message = error.body; - this.#services.log.error('Error handling resource', { reason: error.body }); + this.#services.log.error('Error handling resource', { reason: error.body }, error); } else if (error instanceof Error) { message = error.message; - this.#services.log.error('Error handling resource', { reason: error.message }); + this.#services.log.error('Error handling resource', { reason: error.message }, error); } else { message = String(error); - this.#services.log.error('Error handling resource', { reason: String(error) }); + this.#services.log.error('Error handling resource', { reason: String(error) }, error); } if (type === 'ADDED' || type === 'MODIFIED') { await request.setCondition({ @@ -196,6 +204,38 @@ class CustomResourceRegistry { } }; + #onDelete = async (options: ManifestChangeOptions) => { + const { manifest, cacheKey } = options; + const { kind, metadata } = manifest; + + this.#services.log.debug('Deleting resource', { + kind, + name: metadata.name, + namespace: metadata.namespace, + observedGeneration: manifest.status?.observedGeneration, + generation: metadata.generation, + }); + this.#cache.delete(cacheKey); + }; + + #onResourceEvent = async (type: string, manifest: ExpectedAny) => { + const { kind, metadata } = manifest; + const { name, namespace } = metadata; + const cacheKey = [kind, name, namespace].join('___'); + const crd = this.getByKind(kind); + if (!crd) { + return; + } + + const input = { cacheKey, manifest, crd }; + + if (type === 'DELETE') { + await this.#onDelete(input); + } else { + await this.#onResourceUpdated(type, input); + } + }; + #onError = (error: ExpectedAny) => { this.#services.log.error('Error watching resource', { error }); }; diff --git a/src/custom-resource/custom-resource.request.ts b/src/custom-resource/custom-resource.request.ts index 9ad3a63..0a344a2 100644 --- a/src/custom-resource/custom-resource.request.ts +++ b/src/custom-resource/custom-resource.request.ts @@ -1,9 +1,9 @@ -import { ApiException, PatchStrategy, setHeaderOptions, V1MicroTime } from '@kubernetes/client-node'; import { z, type ZodObject } from 'zod'; +import { setHeaderOptions } from '@kubernetes/client-node'; import type { Services } from '../utils/service.ts'; +import { Manifest } from '../services/k8s/k8s.manifest.ts'; import { K8sService } from '../services/k8s.ts'; -import { GROUP } from '../utils/consts.ts'; import { CustomResourceRegistry } from './custom-resource.registry.ts'; @@ -13,24 +13,6 @@ type CustomResourceRequestOptions = { services: Services; }; -type CustomResourceRequestMetadata = Record & { - name: string; - namespace?: string; - labels?: Record; - annotations?: Record; - uid: string; - resourceVersion: string; - creationTimestamp: 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( @@ -46,56 +28,25 @@ const customResourceStatusSchema = z.object({ type CustomResourceStatus = z.infer; -class CustomResourceRequest { - #options: CustomResourceRequestOptions; +class CustomResourceRequest extends Manifest> { + #type: 'ADDED' | 'DELETED' | 'MODIFIED'; - constructor(options: CustomResourceRequestOptions) { - this.#options = options; + constructor({ type, ...options }: CustomResourceRequestOptions) { + super(options); + this.#type = type; } - public get services(): Services { - return this.#options.services; + public get schema() { + return undefined as unknown as z.infer; } public get type(): 'ADDED' | 'DELETED' | 'MODIFIED' { - return this.#options.type; + return this.#type; } - public get manifest() { - return this.#options.manifest; - } - - public get kind(): string { - return this.#options.manifest.kind; - } - - public get apiVersion(): string { - return this.#options.manifest.apiVersion; - } - - public get spec(): z.infer { - return this.#options.manifest.spec; - } - - public get metadata(): CustomResourceRequestMetadata { - return this.#options.manifest.metadata; - } - - public isOwnerOf = (manifest: ExpectedAny) => { - const ownerRef = manifest?.metadata?.ownerReferences || []; - return ownerRef.some( - (ref: ExpectedAny) => - ref.apiVersion === this.apiVersion && - ref.kind === this.kind && - ref.name === this.metadata.name && - ref.uid === this.metadata.uid, - ); - }; - public markSeen = async () => { - const { manifest } = this.#options; await this.setStatus({ - observedGeneration: manifest.metadata.generation, + observedGeneration: this.manifest.metadata.generation, }); }; @@ -104,8 +55,7 @@ class CustomResourceRequest { ...condition, lastTransitionTime: new Date().toISOString(), }; - const current = await this.getCurrent(); - const conditions: CustomResourceStatus['conditions'] = current?.status?.conditions || []; + const conditions: CustomResourceStatus['conditions'] = this.manifest?.status?.conditions || []; const index = conditions.findIndex((c) => c.type === condition.type); if (index === -1) { conditions.push(fullCondition); @@ -118,52 +68,19 @@ class CustomResourceRequest { }; 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, - }, - }, - }); + return this.manifest?.status as CustomResourceStatus | undefined; }; public setStatus = async (status: Partial) => { - const { manifest, services } = this.#options; - const { kind, metadata } = manifest; - const registry = services.get(CustomResourceRegistry); + const { kind, metadata } = this.manifest; + const registry = this.services.get(CustomResourceRegistry); const crd = registry.getByKind(kind); - const current = await this.getCurrent(); if (!crd) { throw new Error(`Custom resource ${kind} not found`); } - const k8sService = services.get(K8sService); + const current = await this.manifest; + const k8sService = this.services.get(K8sService); const { namespace = 'default', name } = metadata; @@ -176,7 +93,7 @@ class CustomResourceRequest { name, body: { status: { - observedGeneration: manifest.metadata.generation, + observedGeneration: this.manifest.metadata.generation, conditions: current?.status?.conditions || [], ...current?.status, ...status, @@ -184,41 +101,13 @@ class CustomResourceRequest { }, fieldValidation: 'Strict', }, - setHeaderOptions('Content-Type', PatchStrategy.MergePatch), + { + ...setHeaderOptions('Content-Type', 'application/merge-patch+json'), + }, ); + this.manifest = response; return response; }; - - public getCurrent = async () => { - const { manifest, services } = this.#options; - const k8sService = services.get(K8sService); - const registry = services.get(CustomResourceRegistry); - const crd = registry.getByKind(manifest.kind); - if (!crd) { - throw new Error(`Custom resource ${manifest.kind} not found`); - } - try { - const resource = await k8sService.customObjectsApi.getNamespacedCustomObject({ - group: crd.group, - version: crd.version, - plural: crd.names.plural, - namespace: manifest.metadata.namespace, - name: manifest.metadata.name, - }); - return resource as { - apiVersion: string; - kind: string; - metadata: CustomResourceRequestMetadata; - spec: z.infer; - status: CustomResourceStatus; - }; - } catch (error) { - if (error instanceof ApiException && error.code === 404) { - return undefined; - } - throw error; - } - }; } export { CustomResourceRequest, customResourceStatusSchema }; diff --git a/src/index.ts b/src/index.ts index 89f0e13..17252ec 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,22 +7,23 @@ 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'; +import { Domain } from './crds/domain/domain/domain.ts'; +import { DomainEndpoint } from './crds/domain/endpoint/endpoint.ts'; const services = new Services(); + +//const authentikService = services.get(AuthentikService); +//await authentikService.ready(); + const registry = services.get(CustomResourceRegistry); registry.register(new SecretRequest()); registry.register(new PostgresDatabase()); registry.register(new AuthentikClient()); +registry.register(new Domain()); +registry.register(new DomainEndpoint()); 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) { diff --git a/src/services/authentik/authentik.service.ts b/src/services/authentik/authentik.service.ts index f45ffa1..e0dcc01 100644 --- a/src/services/authentik/authentik.service.ts +++ b/src/services/authentik/authentik.service.ts @@ -1,34 +1,36 @@ 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'; +import { setupAuthentik } from './authentik.setup.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; + #init?: Promise; 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; - } + public getPublicUrl = async () => { + return ''; + }; + + #getClient = () => { + if (!this.#init) { + this.#init = this.#create(); + } + return this.#init; + }; #upsertApplication = async (request: UpsertClientRequest, provider: number, pk?: string) => { + const client = await this.#getClient(); if (!pk) { - return await this.#client.core.coreApplicationsCreate({ + return await client.core.coreApplicationsCreate({ applicationRequest: { name: request.name, slug: request.name, @@ -36,7 +38,7 @@ class AuthentikService { }, }); } - return await this.#client.core.coreApplicationsUpdate({ + return await client.core.coreApplicationsUpdate({ slug: request.name, applicationRequest: { name: request.name, @@ -62,8 +64,10 @@ class AuthentikService { .map((scope) => scopes.results.find((mapping) => mapping.scopeName === scope)?.pk) .filter(Boolean) as string[]; + const client = await this.#getClient(); + if (!pk) { - return await this.#client.providers.providersOauth2Create({ + return await client.providers.providersOauth2Create({ oAuth2ProviderRequest: { name: request.name, clientId: request.name, @@ -80,7 +84,7 @@ class AuthentikService { }, }); } - return await this.#client.providers.providersOauth2Update({ + return await client.providers.providersOauth2Update({ id: pk, oAuth2ProviderRequest: { name: request.name, @@ -99,20 +103,31 @@ class AuthentikService { }); }; + #create = async () => { + const { url, token } = await setupAuthentik(this.#services); + return createAuthentikClient({ + baseUrl: new URL('api/v3', url).toString(), + token: token, + }); + }; + public getGroupFromName = async (name: string) => { - const groups = await this.#client.core.coreGroupsList({ + const client = await this.#getClient(); + const groups = await client.core.coreGroupsList({ search: name, }); return groups.results.find((group) => group.name === name); }; public getScopePropertyMappings = async () => { - const mappings = await this.#client.propertymappings.propertymappingsProviderScopeList({}); + const client = await this.#getClient(); + const mappings = await client.propertymappings.propertymappingsProviderScopeList({}); return mappings; }; public getApplicationFromSlug = async (slug: string) => { - const applications = await this.#client.core.coreApplicationsList({ + const client = await this.#getClient(); + const applications = await client.core.coreApplicationsList({ search: slug, }); const application = applications.results.find((app) => app.slug === slug); @@ -120,18 +135,21 @@ class AuthentikService { }; public getProviderFromClientId = async (clientId: string) => { - const providers = await this.#client.providers.providersOauth2List({ + const client = await this.#getClient(); + const providers = await client.providers.providersOauth2List({ clientId, }); return providers.results.find((provider) => provider.clientId === clientId); }; public getFlows = async () => { - const flows = await this.#client.flows.flowsInstancesList(); + const client = await this.#getClient(); + const flows = await client.flows.flowsInstancesList(); return flows; }; public upsertClient = async (request: UpsertClientRequest) => { + const url = await this.getPublicUrl(); try { let provider = await this.getProviderFromClientId(request.name); provider = await this.#upsertProvider(request, provider?.pk); @@ -160,16 +178,13 @@ class AuthentikService { 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(), + configuration: new URL(`/application/o/${provider.name}/.well-known/openid-configuration`, url).toString(), + configurationIssuer: new URL(`/application/o/${provider.name}/`, url).toString(), + authorization: new URL(`/application/o/${provider.name}/authorize/`, url).toString(), + token: new URL(`/application/o/${provider.name}/token/`, url).toString(), + userinfo: new URL(`/application/o/${provider.name}/userinfo/`, url).toString(), + endSession: new URL(`/application/o/${provider.name}/end-session/`, url).toString(), + jwks: new URL(`/application/o/${provider.name}/jwks/`, url).toString(), }, }; return { provider, application, config }; @@ -183,26 +198,28 @@ class AuthentikService { public deleteClient = async (name: string) => { const provider = await this.getProviderFromClientId(name); + const client = await this.#getClient(); if (provider) { - await this.#client.providers.providersOauth2Destroy({ id: provider.pk }); + await client.providers.providersOauth2Destroy({ id: provider.pk }); } const application = await this.getApplicationFromSlug(name); if (application) { - await this.#client.core.coreApplicationsDestroy({ slug: application.name }); + await client.core.coreApplicationsDestroy({ slug: application.name }); } }; public upsertGroup = async (request: UpsertGroupRequest) => { const group = await this.getGroupFromName(request.name); + const client = await this.#getClient(); if (!group) { - await this.#client.core.coreGroupsCreate({ + await client.core.coreGroupsCreate({ groupRequest: { name: request.name, attributes: request.attributes, }, }); } else { - await this.#client.core.coreGroupsUpdate({ + await client.core.coreGroupsUpdate({ groupUuid: group.pk, groupRequest: { name: request.name, @@ -211,6 +228,10 @@ class AuthentikService { }); } }; + + public ready = async () => { + await this.#getClient(); + }; } export { AuthentikService }; diff --git a/src/services/authentik/authentik.setup.ts b/src/services/authentik/authentik.setup.ts new file mode 100644 index 0000000..1a062cd --- /dev/null +++ b/src/services/authentik/authentik.setup.ts @@ -0,0 +1,135 @@ +import { NAMESPACE } from '../../utils/consts.ts'; +import type { Services } from '../../utils/service.ts'; +import { K8sService } from '../k8s.ts'; +import { PostgresService } from '../postgres/postgres.service.ts'; + +const SECRET = 'WkE/MDsSCe7TyIPtx/16/rwQ3XyyY9QsM450mXZklhR545PZPFoXcfrBhnxYB5jzlIwTmkg7Opgm0FDl'; // TODO: Generate and store +const setupAuthentik = async (services: Services) => { + const namespace = NAMESPACE; + const db = { + name: 'homelab_authentik', + user: 'homelab_authentik', + password: 'sdf908sad0sdf7g98', + }; + + const k8sService = services.get(K8sService); + const postgresService = services.get(PostgresService); + + await postgresService.upsertRole({ + name: db.user, + password: db.password, + }); + + await postgresService.upsertDatabase({ + name: db.name, + owner: db.user, + }); + + const createManifest = (command: string) => ({ + apiVersion: 'apps/v1', + kind: 'Deployment', + metadata: { + name: `authentik-${command}`, + namespace: namespace, + labels: { + 'app.kubernetes.io/name': `authentik-${command}`, + 'argocd.argoproj.io/instance': 'homelab', + }, + }, + spec: { + replicas: 1, + selector: { + matchLabels: { + 'app.kubernetes.io/name': `authentik-${command}`, + }, + }, + template: { + metadata: { + labels: { + 'app.kubernetes.io/name': `authentik-${command}`, + }, + }, + spec: { + containers: [ + { + name: `authentik-${command}`, + image: 'ghcr.io/goauthentik/server:2025.6.4', + // imagePullPolicy: 'ifNot' + args: [command], + env: [ + { name: 'AUTHENTIK_SECRET_KEY', value: SECRET }, + { name: 'AUTHENTIK_POSTGRESQL__HOST', value: 'postgres-postgresql.postgres.svc.cluster.local' }, + { + name: 'AUTHENTIK_POSTGRESQL__PORT', + value: '5432', + }, + { + name: 'AUTHENTIK_POSTGRESQL__NAME', + value: db.name, + }, + { + name: 'AUTHENTIK_POSTGRESQL__USER', + value: db.user, + }, + { + name: 'AUTHENTIK_POSTGRESQL__PASSWORD', + value: db.password, + }, + { + name: 'AUTHENTIK_REDIS__HOST', + value: 'redis.redis.svc.cluster.local', + }, + // { + // name: 'AUTHENTIK_REDIS__PORT', + // value: '' + // } + ], + ports: [ + { + name: 'http', + containerPort: 9000, + protocol: 'TCP', + }, + ], + }, + ], + }, + }, + }, + }); + + await k8sService.upsert(createManifest('server')); + await k8sService.upsert(createManifest('worker')); + await k8sService.upsert({ + apiVersion: 'v1', + kind: 'Service', + metadata: { + name: 'authentik', + namespace, + labels: { + 'app.kubernetes.io/name': 'authentik-server', + }, + }, + spec: { + type: 'ClusterIP', + ports: [ + { + port: 9000, + targetPort: 9000, + protocol: 'TCP', + name: 'http', + }, + ], + selector: { + 'app.kubernetes.io/name': 'authentik-server', + }, + }, + }); + + return { + url: '', + token: '', + }; +}; + +export { setupAuthentik }; diff --git a/src/services/config/config.ts b/src/services/config/config.ts index 06c51fe..6d902b1 100644 --- a/src/services/config/config.ts +++ b/src/services/config/config.ts @@ -1,4 +1,22 @@ class ConfigService { + public get istio() { + const gateway = process.env.ISTIO_GATEWAY; + if (!gateway) { + throw new Error('ISTIO_GATEWAY must be set'); + } + return { + gateway: process.env.ISTIO_GATEWAY, + }; + } + + public get certManager() { + const certManager = process.env.CERT_MANAGER; + if (!certManager) { + throw new Error('CERT_MANAGER must be set'); + } + return certManager; + } + public get postgres() { const host = process.env.POSTGRES_HOST; const user = process.env.POSTGRES_USER; @@ -11,17 +29,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 }; diff --git a/src/services/k8s.ts b/src/services/k8s.ts index abe5059..990516b 100644 --- a/src/services/k8s.ts +++ b/src/services/k8s.ts @@ -5,9 +5,16 @@ import { CustomObjectsApi, EventsV1Api, KubernetesObjectApi, + ApiException, + PatchStrategy, } from '@kubernetes/client-node'; +import type { Services } from '../utils/service.ts'; + +import { Manifest } from './k8s/k8s.manifest.ts'; + class K8sService { + #services: Services; #kc: KubeConfig; #k8sApi: CoreV1Api; #k8sExtensionsApi: ApiextensionsV1Api; @@ -15,7 +22,8 @@ class K8sService { #k8sEventsApi: EventsV1Api; #k8sObjectsApi: KubernetesObjectApi; - constructor() { + constructor(services: Services) { + this.#services = services; this.#kc = new KubeConfig(); this.#kc.loadFromDefault(); this.#k8sApi = this.#kc.makeApiClient(CoreV1Api); @@ -48,6 +56,84 @@ class K8sService { public get objectsApi() { return this.#k8sObjectsApi; } + + public exists = async (options: { apiVersion: string; kind: string; name: string; namespace?: string }) => { + try { + await this.objectsApi.read({ + apiVersion: options.apiVersion, + kind: options.kind, + metadata: { + name: options.name, + namespace: options.namespace, + }, + }); + return true; + } catch (err) { + if (!(err instanceof ApiException && err.code === 404)) { + throw err; + } + return false; + } + }; + + public get = async (options: { apiVersion: string; kind: string; name: string; namespace?: string }) => { + try { + const manifest = await this.objectsApi.read({ + apiVersion: options.apiVersion, + kind: options.kind, + metadata: { + name: options.name, + namespace: options.namespace, + }, + }); + return new Manifest({ + manifest, + services: this.#services, + }); + } catch (err) { + if (!(err instanceof ApiException && err.code === 404)) { + throw err; + } + return undefined; + } + }; + + public upsert = async (obj: ExpectedAny) => { + let current: unknown; + try { + current = await this.objectsApi.read({ + apiVersion: obj.apiVersion, + kind: obj.kind, + metadata: { + name: obj.metadata.name, + namespace: obj.metadata.namespace, + }, + }); + } catch (error) { + if (!(error instanceof ApiException && error.code === 404)) { + throw error; + } + } + + if (current) { + return new Manifest({ + manifest: await this.objectsApi.patch( + obj, + undefined, + undefined, + undefined, + undefined, + PatchStrategy.MergePatch, + ), + services: this.#services, + }); + } else { + return new Manifest({ + manifest: await this.objectsApi.create(obj), + services: this.#services, + }); + } + }; } export { K8sService }; diff --git a/src/services/k8s/k8s.manifest.ts b/src/services/k8s/k8s.manifest.ts new file mode 100644 index 0000000..965f0aa --- /dev/null +++ b/src/services/k8s/k8s.manifest.ts @@ -0,0 +1,179 @@ +import { ApiException, PatchStrategy, V1MicroTime } from '@kubernetes/client-node'; + +import type { Services } from '../../utils/service.ts'; +import { K8sService } from '../k8s.ts'; +import { GROUP } from '../../utils/consts.ts'; +import { CustomResourceRegistry } from '../../custom-resource/custom-resource.registry.ts'; + +type ManifestOptions = { + manifest: ExpectedAny; + services: Services; +}; + +type ManifestMetadata = Record & { + name: string; + namespace?: string; + labels?: Record; + annotations?: Record; + uid: string; + resourceVersion: string; + creationTimestamp: string; + generation: number; +}; + +type EventOptions = { + reason: string; + message: string; + action: string; + type: 'Normal' | 'Warning' | 'Error'; +}; + +class Manifest { + #options: ManifestOptions; + + constructor(options: ManifestOptions) { + this.#options = { + ...options, + manifest: options.manifest, + }; + } + + public get objectRef() { + return { + apiVersion: this.apiVersion, + kind: this.kind, + name: this.metadata.name, + uid: this.metadata.uid, + namespace: this.metadata.namespace, + }; + } + + public get services(): Services { + return this.#options.services; + } + + public get manifest() { + return this.#options.manifest; + } + + protected set manifest(obj: ExpectedAny) { + this.#options.manifest = obj; + } + + public get kind(): string { + return this.#options.manifest.kind; + } + + public get apiVersion(): string { + return this.#options.manifest.apiVersion; + } + + public get spec(): TSpec { + return this.#options.manifest.spec; + } + + public get metadata(): ManifestMetadata { + return this.#options.manifest.metadata; + } + + public isOwnerOf = (manifest: ExpectedAny) => { + const ownerRef = manifest?.metadata?.ownerReferences || []; + return ownerRef.some( + (ref: ExpectedAny) => + ref.apiVersion === this.apiVersion && + ref.kind === this.kind && + ref.name === this.metadata.name && + ref.uid === this.metadata.uid, + ); + }; + + public addEvent = async (event: EventOptions) => { + 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()}-${Buffer.from(crypto.getRandomValues(new Uint8Array(8))).toString('hex')}`, + 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 patch = async (manifest: ExpectedAny) => { + const { services } = this.#options; + const k8sService = services.get(K8sService); + this.manifest = await k8sService.objectsApi.patch( + { + apiVersion: this.apiVersion, + kind: this.kind, + metadata: { + name: this.metadata.name, + namespace: this.metadata.namespace, + ownerReferences: this.metadata.ownerReferences, + ...manifest.metadata, + labels: { + ...this.metadata.labels, + ...(manifest.metadata?.label || {}), + }, + annotations: { + ...this.metadata.annotations, + ...(manifest.metadata?.annotations || {}), + }, + }, + spec: manifest.spec || this.spec, + }, + undefined, + undefined, + undefined, + undefined, + PatchStrategy.MergePatch, + ); + }; + + public update = async () => { + const { manifest, services } = this.#options; + const k8sService = services.get(K8sService); + const registry = services.get(CustomResourceRegistry); + const crd = registry.getByKind(manifest.kind); + if (!crd) { + throw new Error(`Custom resource ${manifest.kind} not found`); + } + try { + const resource = await k8sService.objectsApi.read({ + apiVersion: this.apiVersion, + kind: this.kind, + metadata: { + name: this.metadata.name, + namespace: this.metadata.namespace, + }, + }); + this.#options.manifest = resource; + } catch (error) { + if (error instanceof ApiException && error.code === 404) { + return undefined; + } + throw error; + } + }; +} + +export { Manifest }; diff --git a/src/services/log/log.ts b/src/services/log/log.ts index 756bb52..1acae1f 100644 --- a/src/services/log/log.ts +++ b/src/services/log/log.ts @@ -11,7 +11,10 @@ class LogService { console.warn(message, data); }; - public error = (message: string, data?: Record) => { + public error = (message: string, data?: Record, root?: unknown) => { + if (root instanceof Error) { + console.log(root.stack); + } console.error(message, data); }; } diff --git a/src/services/postgres/postgres.service.ts b/src/services/postgres/postgres.service.ts index f1e1271..e915046 100644 --- a/src/services/postgres/postgres.service.ts +++ b/src/services/postgres/postgres.service.ts @@ -7,8 +7,10 @@ import type { PostgresDatabase, PostgresRole } from './postgres.types.ts'; class PostgresService { #db: Knex; + #services: Services; constructor(services: Services) { + this.#services = services; const configService = services.get(ConfigService); const config = configService.postgres; this.#db = knex({ @@ -22,6 +24,11 @@ class PostgresService { }); } + public get config() { + const configService = this.#services.get(ConfigService); + return configService.postgres; + } + public upsertRole = async (role: PostgresRole) => { const existingRole = await this.#db.raw('SELECT 1 FROM pg_roles WHERE rolname = ?', [role.name]); diff --git a/src/types/kubernetes.ts b/src/types/kubernetes.ts new file mode 100644 index 0000000..4e4841d --- /dev/null +++ b/src/types/kubernetes.ts @@ -0,0 +1,8 @@ +type ResourceRef = { + apiVersion: string; + kind: string; + name: string; + uid: string; +}; + +export type { ResourceRef }; diff --git a/src/utils/consts.ts b/src/utils/consts.ts index a1c770c..c0d6734 100644 --- a/src/utils/consts.ts +++ b/src/utils/consts.ts @@ -1,3 +1,4 @@ const GROUP = 'homelab.mortenolsen.pro'; +const NAMESPACE = 'homelab'; -export { GROUP }; +export { GROUP, NAMESPACE }; diff --git a/src/utils/naming.ts b/src/utils/naming.ts new file mode 100644 index 0000000..61750ce --- /dev/null +++ b/src/utils/naming.ts @@ -0,0 +1,13 @@ +const getWithNamespace = (input: string) => { + const result = input.split('/'); + const first = result.pop(); + if (!first) { + throw new Error(`${input} could not be parsed as a namespace`); + } + return { + name: first, + namespace: result.join('/'), + }; +}; + +export { getWithNamespace };