This commit is contained in:
Morten Olsen
2025-08-06 21:18:02 +02:00
parent 757b2fcfac
commit cfb90f7c9f
72 changed files with 16675 additions and 823 deletions

View File

@@ -0,0 +1,192 @@
import type { V1Secret } from '@kubernetes/client-node';
import type { z } from 'zod';
import deepEqual from 'deep-equal';
import {
CustomResource,
type CustomResourceObject,
type CustomResourceOptions,
type SubresourceResult,
} from '../../services/custom-resources/custom-resources.custom-resource.ts';
import { ResourceReference } from '../../services/resources/resources.ref.ts';
import { ResourceService, type Resource } from '../../services/resources/resources.ts';
import { getWithNamespace } from '../../utils/naming.ts';
import type { authentikServerSpecSchema } from '../authentik-server/authentik-server.scemas.ts';
import type { domainSpecSchema } from '../domain/domain.schemas.ts';
import { decodeSecret, encodeSecret } from '../../utils/secrets.ts';
import { API_VERSION, CONTROLLED_LABEL } from '../../utils/consts.ts';
import { authentikClientSecretSchema, type authentikClientSpecSchema } from './authentik-client.schemas.ts';
class AuthentikClientResource extends CustomResource<typeof authentikClientSpecSchema> {
#serverResource: ResourceReference<CustomResourceObject<typeof authentikServerSpecSchema>>;
#serverSecretResource: ResourceReference<V1Secret>;
#domainResource: ResourceReference<CustomResourceObject<typeof domainSpecSchema>>;
#clientSecretResource: Resource<V1Secret>;
constructor(options: CustomResourceOptions<typeof authentikClientSpecSchema>) {
super(options);
const resourceService = this.services.get(ResourceService);
this.#serverResource = new ResourceReference();
this.#serverSecretResource = new ResourceReference();
this.#domainResource = new ResourceReference();
this.#clientSecretResource = resourceService.get({
apiVersion: 'v1',
kind: 'Secret',
name: `authentik-client-${this.name}`,
namespace: this.namespace,
});
this.#updateResouces();
this.#serverResource.on('changed', this.queueReconcile);
this.#serverSecretResource.on('changed', this.queueReconcile);
this.#domainResource.on('changed', this.queueReconcile);
this.#clientSecretResource.on('changed', this.queueReconcile);
}
get server() {
return this.#serverResource.current;
}
get serverSecret() {
return this.#serverSecretResource.current;
}
get serverSecretValue() {
return decodeSecret(this.#serverSecretResource.current?.data);
}
get domain() {
return this.#domainResource.current;
}
get clientSecret() {
return this.#clientSecretResource;
}
get clientSecretValue() {
const values = decodeSecret(this.#clientSecretResource.data);
const parsed = authentikClientSecretSchema.safeParse(values);
if (!parsed.success) {
return undefined;
}
return parsed.data;
}
#updateResouces = () => {
const serverNames = getWithNamespace(this.spec.server, this.namespace);
const resourceService = this.services.get(ResourceService);
this.#serverResource.current = resourceService.get({
apiVersion: API_VERSION,
kind: 'AuthentikServer',
name: serverNames.name,
namespace: serverNames.namespace,
});
this.#serverSecretResource.current = resourceService.get({
apiVersion: 'v1',
kind: 'Secret',
name: `authentik-server-${serverNames.name}`,
namespace: serverNames.namespace,
});
const server = this.#serverResource.current;
if (server && server.spec) {
const domainNames = getWithNamespace(server.spec.domain, server.namespace);
this.#domainResource.current = resourceService.get({
apiVersion: API_VERSION,
kind: 'Domain',
name: domainNames.name,
namespace: domainNames.namespace,
});
} else {
this.#domainResource.current = undefined;
}
};
#reconcileClientSecret = async (): Promise<SubresourceResult> => {
const domain = this.domain;
const server = this.server;
const serverSecret = this.serverSecret;
if (!server?.exists || !server?.spec || !serverSecret?.exists || !serverSecret.data) {
return {
ready: false,
failed: true,
message: 'Server or server secret not found',
};
}
if (!domain?.exists || !domain?.spec) {
return {
ready: false,
failed: true,
message: 'Domain not found',
};
}
const url = `https://authentik.${domain.spec?.hostname}`;
const appName = this.name;
const values = this.clientSecretValue;
const expectedValues: Omit<z.infer<typeof authentikClientSecretSchema>, 'clientSecret'> = {
clientId: this.name,
configuration: new URL(`/application/o/${appName}/.well-known/openid-configuration`, url).toString(),
configurationIssuer: new URL(`/application/o/${appName}/`, url).toString(),
authorization: new URL(`/application/o/${appName}/authorize/`, url).toString(),
token: new URL(`/application/o/${appName}/token/`, url).toString(),
userinfo: new URL(`/application/o/${appName}/userinfo/`, url).toString(),
endSession: new URL(`/application/o/${appName}/end-session/`, url).toString(),
jwks: new URL(`/application/o/${appName}/jwks/`, url).toString(),
};
if (!values) {
await this.clientSecret.patch({
metadata: {
ownerReferences: [this.ref],
labels: {
...CONTROLLED_LABEL,
},
},
data: encodeSecret({
...expectedValues,
clientSecret: crypto.randomUUID(),
}),
});
return {
ready: false,
syncing: true,
message: 'UpdatingManifest',
};
}
const compareData = {
...values,
clientSecret: undefined,
};
if (!deepEqual(compareData, expectedValues)) {
await this.clientSecret.patch({
metadata: {
ownerReferences: [this.ref],
labels: {
...CONTROLLED_LABEL,
},
},
data: encodeSecret(expectedValues),
});
}
return {
ready: true,
};
};
public reconcile = async () => {
if (!this.exists || this.metadata.deletionTimestamp) {
return;
}
this.#updateResouces();
await Promise.all([this.reconcileSubresource('Secret', this.#reconcileClientSecret)]);
const secretReady = this.conditions.get('Secret')?.status === 'True';
await this.conditions.set('Ready', {
status: secretReady ? 'True' : 'False',
});
};
}
export { AuthentikClientResource };

View File

@@ -0,0 +1,28 @@
import { ClientTypeEnum, MatchingModeEnum, SubModeEnum } from '@goauthentik/api';
import { z } from 'zod';
const authentikClientSpecSchema = z.object({
server: z.string(),
subMode: z.enum(SubModeEnum).optional(),
clientType: z.enum(ClientTypeEnum).optional(),
redirectUris: z.array(
z.object({
url: z.string(),
matchingMode: z.enum(MatchingModeEnum).optional(),
}),
),
});
const authentikClientSecretSchema = z.object({
clientId: z.string(),
clientSecret: z.string().optional(),
configuration: z.string(),
configurationIssuer: z.string(),
authorization: z.string(),
token: z.string(),
userinfo: z.string(),
endSession: z.string(),
jwks: z.string(),
});
export { authentikClientSpecSchema, authentikClientSecretSchema };

View File

@@ -0,0 +1,19 @@
import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts';
import { GROUP } from '../../utils/consts.ts';
import { AuthentikClientResource } from './authentik-client.resource.ts';
import { authentikClientSpecSchema } from './authentik-client.schemas.ts';
const authentikClientDefinition = createCustomResourceDefinition({
group: GROUP,
version: 'v1',
kind: 'AuthentikClient',
names: {
plural: 'authentikclients',
singular: 'authentikclient',
},
create: (options) => new AuthentikClientResource(options),
spec: authentikClientSpecSchema,
});
export { authentikClientDefinition };

View File

@@ -36,9 +36,6 @@ class AuthentikServerResource extends CustomResource<typeof authentikServerSpecS
constructor(options: CustomResourceOptions<typeof authentikServerSpecSchema>) {
super(options);
const domainNames = getWithNamespace(this.spec.domain, this.namespace);
const databaseNames = getWithNamespace(this.spec.database, this.namespace);
const resourceService = this.services.get(ResourceService);
const secretService = this.services.get(SecretService);
@@ -76,7 +73,7 @@ class AuthentikServerResource extends CustomResource<typeof authentikServerSpecS
});
this.#secret = secretService.ensure({
name: this.name,
name: `authentik-server-${this.name}`,
namespace: this.namespace,
schema: authentikServerSecretSchema,
generator: () => ({

View File

@@ -1,17 +1,21 @@
import { authentikServerDefinition } from './authentik-server/authentik-server.ts';
import { authentikClientDefinition } from './authentik-client/authentik-client.ts';
import { domainServiceDefinition } from './domain-service/domain-service.ts';
import { domainDefinition } from './domain/domain.ts';
import { postgresConnectionDefinition } from './postgres-connection/postgres-connection.ts';
import { postgresDatabaseDefinition } from './postgres-database/postgres-database.ts';
import { redisConnectionDefinition } from './redis-connection/redis-connection.ts';
import { homelabDefinition } from './homelab/homelab.ts';
const customResources = [
homelabDefinition,
domainDefinition,
domainServiceDefinition,
postgresConnectionDefinition,
postgresDatabaseDefinition,
redisConnectionDefinition,
authentikServerDefinition,
authentikClientDefinition,
];
export { customResources };

View File

@@ -0,0 +1,296 @@
import type { KubernetesObject } from '@kubernetes/client-node';
import type { K8SHelmRepositoryV1 } from '../../__generated__/resources/K8SHelmRepositoryV1.ts';
import type { K8SHelmReleaseV2 } from '../../__generated__/resources/K8SHelmReleaseV2.ts';
type IstioRepoManifestOptions = {
owner: ExpectedAny;
};
const istioRepoManifest = (options: IstioRepoManifestOptions): KubernetesObject & K8SHelmRepositoryV1 => {
return {
apiVersion: 'source.toolkit.fluxcd.io/v1beta1',
kind: 'HelmRepository',
metadata: {
ownerReferences: [options.owner],
},
spec: {
interval: '1h',
url: 'https://istio-release.storage.googleapis.com/charts',
},
};
};
type CertManagerRepoManifestOptions = {
owner: ExpectedAny;
};
const certManagerRepoManifest = (options: CertManagerRepoManifestOptions): KubernetesObject & K8SHelmRepositoryV1 => {
return {
apiVersion: 'source.toolkit.fluxcd.io/v1',
kind: 'HelmRepository',
metadata: {
ownerReferences: [options.owner],
},
spec: {
interval: '1h',
url: 'https://charts.jetstack.io',
},
};
};
type RanchRepoManifestOptions = {
owner: ExpectedAny;
};
const ranchRepoManifest = (options: RanchRepoManifestOptions): KubernetesObject & K8SHelmRepositoryV1 => {
return {
apiVersion: 'source.toolkit.fluxcd.io/v1',
kind: 'HelmRepository',
metadata: {
ownerReferences: [options.owner],
},
spec: {
interval: '1h',
url: 'https://charts.containeroo.ch',
},
};
};
type IstioBaseManifestOptions = {
owner: ExpectedAny;
};
const istioBaseManifest = (options: IstioBaseManifestOptions): KubernetesObject & K8SHelmReleaseV2 => {
return {
apiVersion: 'helm.toolkit.fluxcd.io/v2',
kind: 'HelmRelease',
metadata: {
ownerReferences: [options.owner],
},
spec: {
interval: '1h',
targetNamespace: 'istio-system',
install: {
createNamespace: true,
},
values: {
defaultRevision: 'default',
},
chart: {
spec: {
chart: 'base',
sourceRef: {
apiVersion: 'source.toolkit.fluxcd.io/v1',
kind: 'HelmRepository',
name: 'homelab-istio',
},
reconcileStrategy: 'ChartVersion',
version: '1.24.3',
},
},
},
};
};
type IstiodManifestOptions = {
owner: ExpectedAny;
namespace: string;
};
const istiodManifest = (options: IstiodManifestOptions): KubernetesObject & K8SHelmReleaseV2 => {
return {
apiVersion: 'helm.toolkit.fluxcd.io/v2',
kind: 'HelmRelease',
metadata: {
ownerReferences: [options.owner],
},
spec: {
targetNamespace: 'istio-system',
interval: '1h',
install: {
createNamespace: true,
},
dependsOn: [
{
name: 'istio',
namespace: options.namespace,
},
],
chart: {
spec: {
chart: 'istiod',
sourceRef: {
apiVersion: 'source.toolkit.fluxcd.io/v1',
kind: 'HelmRepository',
name: 'homelab-istio',
},
reconcileStrategy: 'ChartVersion',
version: '1.24.3',
},
},
},
};
};
type IstioGatewayControllerManifestOptions = {
owner: ExpectedAny;
namespace: string;
};
const istioGatewayControllerManifest = (
options: IstioGatewayControllerManifestOptions,
): KubernetesObject & K8SHelmReleaseV2 => {
return {
apiVersion: 'helm.toolkit.fluxcd.io/v2',
kind: 'HelmRelease',
metadata: {
ownerReferences: [options.owner],
},
spec: {
interval: '1h',
install: {
createNamespace: true,
},
dependsOn: [
{
name: 'istio',
namespace: options.namespace,
},
{
name: 'istiod',
namespace: options.namespace,
},
],
values: {
service: {
ports: [
{
name: 'status-port',
port: 15021,
},
{
name: 'tls-istiod',
port: 15012,
},
{
name: 'tls',
port: 15443,
nodePort: 31371,
},
{
name: 'http2',
port: 80,
nodePort: 31381,
targetPort: 8280,
},
{
name: 'https',
port: 443,
nodePort: 31391,
targetPort: 8243,
},
],
},
},
chart: {
spec: {
chart: 'gateway',
sourceRef: {
apiVersion: 'source.toolkit.fluxcd.io/v1',
kind: 'HelmRepository',
name: 'homelab-istio',
},
reconcileStrategy: 'ChartVersion',
version: '1.24.3',
},
},
},
};
};
type CertManagerManifestOptions = {
owner: ExpectedAny;
};
const certManagerManifest = (options: CertManagerManifestOptions): KubernetesObject & K8SHelmReleaseV2 => {
return {
apiVersion: 'helm.toolkit.fluxcd.io/v2',
kind: 'HelmRelease',
metadata: {
ownerReferences: [options.owner],
},
spec: {
targetNamespace: 'cert-manager',
interval: '1h',
install: {
createNamespace: true,
},
values: {
installCRDs: true,
},
chart: {
spec: {
chart: 'cert-manager',
sourceRef: {
apiVersion: 'source.toolkit.fluxcd.io/v1',
kind: 'HelmRepository',
name: 'cert-manager',
},
version: 'v1.18.2',
},
},
},
};
};
type LocalStorageManifestOptions = {
owner: ExpectedAny;
storagePath: string;
};
const localStorageManifest = (options: LocalStorageManifestOptions): KubernetesObject & K8SHelmReleaseV2 => {
return {
apiVersion: 'helm.toolkit.fluxcd.io/v2',
kind: 'HelmRelease',
metadata: {
ownerReferences: [options.owner],
},
spec: {
targetNamespace: 'local-path-storage',
interval: '1h',
install: {
createNamespace: true,
},
values: {
storageClass: {
name: 'local-path',
defaultClass: true,
},
nodePathMap: [
{
node: 'DEFAULT_PATH_FOR_NON_LISTED_NODES',
path: options.storagePath,
},
],
helper: {
reclaimPolicy: 'Retain',
},
},
chart: {
spec: {
chart: 'local-path-provisioner',
sourceRef: {
apiVersion: 'source.toolkit.fluxcd.io/v1',
kind: 'HelmRepository',
name: 'rancher',
},
version: '0.0.32',
},
},
},
};
};
export {
istioRepoManifest,
istioBaseManifest,
istiodManifest,
istioGatewayControllerManifest,
certManagerRepoManifest,
certManagerManifest,
ranchRepoManifest,
localStorageManifest,
};

View File

@@ -0,0 +1,263 @@
import { type KubernetesObject } from '@kubernetes/client-node';
import {
CustomResource,
type CustomResourceOptions,
type SubresourceResult,
} from '../../services/custom-resources/custom-resources.custom-resource.ts';
import { Resource, ResourceService } from '../../services/resources/resources.ts';
import type { K8SHelmRepositoryV1 } from '../../__generated__/resources/K8SHelmRepositoryV1.ts';
import type { K8SHelmReleaseV2 } from '../../__generated__/resources/K8SHelmReleaseV2.ts';
import { isDeepSubset } from '../../utils/objects.ts';
import type { homelabSpecSchema } from './homelab.schemas.ts';
import {
certManagerRepoManifest,
istioBaseManifest,
istiodManifest,
istioGatewayControllerManifest,
istioRepoManifest,
certManagerManifest,
ranchRepoManifest,
localStorageManifest,
} from './homelab.manifests.ts';
class HomelabResource extends CustomResource<typeof homelabSpecSchema> {
#resources: {
istioRepo: Resource<KubernetesObject & K8SHelmRepositoryV1>;
istioBase: Resource<KubernetesObject & K8SHelmReleaseV2>;
istiod: Resource<KubernetesObject & K8SHelmReleaseV2>;
istioGatewayController: Resource<KubernetesObject & K8SHelmReleaseV2>;
certManagerRepo: Resource<KubernetesObject & K8SHelmRepositoryV1>;
certManager: Resource<KubernetesObject & K8SHelmReleaseV2>;
ranchRepo: Resource<KubernetesObject & K8SHelmRepositoryV1>;
localStorage: Resource<KubernetesObject & K8SHelmReleaseV2>;
};
constructor(options: CustomResourceOptions<typeof homelabSpecSchema>) {
super(options);
const resourceService = this.services.get(ResourceService);
this.#resources = {
istioRepo: resourceService.get({
apiVersion: 'source.toolkit.fluxcd.io/v1',
kind: 'HelmRepository',
name: 'homelab-istio',
namespace: this.namespace,
}),
istioBase: resourceService.get({
apiVersion: 'helm.toolkit.fluxcd.io/v2',
kind: 'HelmRelease',
name: 'istio',
namespace: this.namespace,
}),
istiod: resourceService.get({
apiVersion: 'helm.toolkit.fluxcd.io/v2',
kind: 'HelmRelease',
name: 'istiod',
namespace: this.namespace,
}),
istioGatewayController: resourceService.get({
apiVersion: 'helm.toolkit.fluxcd.io/v2',
kind: 'HelmRelease',
name: 'istio-gateway-controller',
namespace: this.namespace,
}),
certManagerRepo: resourceService.get({
apiVersion: 'source.toolkit.fluxcd.io/v1',
kind: 'HelmRepository',
name: 'cert-manager',
namespace: this.namespace,
}),
certManager: resourceService.get({
apiVersion: 'helm.toolkit.fluxcd.io/v2',
kind: 'HelmRelease',
name: 'cert-manager',
namespace: this.namespace,
}),
ranchRepo: resourceService.get({
apiVersion: 'source.toolkit.fluxcd.io/v1',
kind: 'HelmRepository',
name: 'rancher',
namespace: this.namespace,
}),
localStorage: resourceService.get({
apiVersion: 'helm.toolkit.fluxcd.io/v2',
kind: 'HelmRelease',
name: 'local-storage',
namespace: this.namespace,
}),
};
for (const resource of Object.values(this.#resources)) {
resource.on('changed', this.queueReconcile);
}
}
#reconcileIstioRepo = async (): Promise<SubresourceResult> => {
const istioRepo = this.#resources.istioRepo;
const manifest = istioRepoManifest({
owner: this.ref,
});
if (!isDeepSubset(istioRepo.spec, manifest.spec)) {
await istioRepo.patch(manifest);
return {
ready: false,
syncing: true,
reason: 'UpdatingManifest',
};
}
return {
ready: true,
};
};
#reconcileCertManagerRepo = async (): Promise<SubresourceResult> => {
const certManagerRepo = this.#resources.certManagerRepo;
const manifest = certManagerRepoManifest({
owner: this.ref,
});
if (!isDeepSubset(certManagerRepo.spec, manifest.spec)) {
await certManagerRepo.patch(manifest);
return {
ready: false,
syncing: true,
reason: 'UpdatingManifest',
};
}
return {
ready: true,
};
};
#reconcileRanchRepo = async (): Promise<SubresourceResult> => {
const ranchRepo = this.#resources.ranchRepo;
const manifest = ranchRepoManifest({
owner: this.ref,
});
if (!isDeepSubset(ranchRepo.spec, manifest.spec)) {
await ranchRepo.patch(manifest);
return {
ready: false,
syncing: true,
reason: 'UpdatingManifest',
};
}
return {
ready: true,
};
};
#reconcileIstioBase = async (): Promise<SubresourceResult> => {
const istioBase = this.#resources.istioBase;
const manifest = istioBaseManifest({
owner: this.ref,
});
if (!isDeepSubset(istioBase.spec, manifest.spec)) {
await istioBase.patch(manifest);
return {
ready: false,
syncing: true,
reason: 'UpdatingManifest',
};
}
return {
ready: true,
};
};
#reconcileIstiod = async (): Promise<SubresourceResult> => {
const istiod = this.#resources.istiod;
const manifest = istiodManifest({
owner: this.ref,
namespace: this.namespace,
});
if (!isDeepSubset(istiod.spec, manifest.spec)) {
await istiod.patch(manifest);
return {
ready: false,
syncing: true,
reason: 'UpdatingManifest',
};
}
return {
ready: true,
};
};
#reconcileIstioGatewayController = async (): Promise<SubresourceResult> => {
const istioGatewayController = this.#resources.istioGatewayController;
const manifest = istioGatewayControllerManifest({
owner: this.ref,
namespace: this.namespace,
});
if (!isDeepSubset(istioGatewayController.spec, manifest.spec)) {
await istioGatewayController.patch(manifest);
return {
ready: false,
syncing: true,
reason: 'UpdatingManifest',
};
}
return {
ready: true,
};
};
#reconcileCertManager = async (): Promise<SubresourceResult> => {
const certManager = this.#resources.certManager;
const manifest = certManagerManifest({
owner: this.ref,
});
if (!isDeepSubset(certManager.spec, manifest.spec)) {
await certManager.patch(manifest);
return {
ready: false,
syncing: true,
reason: 'UpdatingManifest',
};
}
return {
ready: true,
};
};
#reconcileLocalStorage = async (): Promise<SubresourceResult> => {
const storage = this.spec.storage;
if (!storage || !storage.enabled) {
return {
ready: true,
};
}
const localStorage = this.#resources.localStorage;
const manifest = localStorageManifest({
owner: this.ref,
storagePath: storage.path,
});
if (!isDeepSubset(localStorage.spec, manifest.spec)) {
await localStorage.patch(manifest);
return {
ready: false,
syncing: true,
reason: 'UpdatingManifest',
};
}
return {
ready: true,
};
};
public reconcile = async () => {
await Promise.allSettled([
this.reconcileSubresource('IstioRepo', this.#reconcileIstioRepo),
this.reconcileSubresource('CertManagerRepo', this.#reconcileCertManagerRepo),
this.reconcileSubresource('IstioBase', this.#reconcileIstioBase),
this.reconcileSubresource('Istiod', this.#reconcileIstiod),
this.reconcileSubresource('IstioGatewayController', this.#reconcileIstioGatewayController),
this.reconcileSubresource('CertManager', this.#reconcileCertManager),
this.reconcileSubresource('RanchRepo', this.#reconcileRanchRepo),
this.reconcileSubresource('LocalStorage', this.#reconcileLocalStorage),
]);
};
}
export { HomelabResource };

View File

@@ -0,0 +1,17 @@
import { z } from 'zod';
const homelabSpecSchema = z.object({
storage: z
.object({
enabled: z.boolean(),
path: z.string(),
})
.optional(),
});
const homelabSecretSchema = z.object({
postgresPassword: z.string(),
redisPassword: z.string(),
});
export { homelabSpecSchema, homelabSecretSchema };

View File

@@ -0,0 +1,19 @@
import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts';
import { GROUP } from '../../utils/consts.ts';
import { HomelabResource } from './homelab.resource.ts';
import { homelabSpecSchema } from './homelab.schemas.ts';
const homelabDefinition = createCustomResourceDefinition({
group: GROUP,
version: 'v1',
kind: 'Homelab',
names: {
plural: 'homelabs',
singular: 'homelab',
},
spec: homelabSpecSchema,
create: (options) => new HomelabResource(options),
});
export { homelabDefinition };

View File

@@ -0,0 +1,5 @@
import { z } from 'zod';
const postgresClusterSpecSchema = z.object({});
export { postgresClusterSpecSchema };