This commit is contained in:
Morten Olsen
2025-08-19 22:05:41 +02:00
parent a27b563113
commit 3ab2b1969a
108 changed files with 1740 additions and 3350 deletions

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.13

View File

@@ -1,6 +1,6 @@
FROM node:23-alpine FROM node:23-slim
RUN corepack enable RUN corepack enable
COPY package.json pnpm-lock.yaml ./ COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod RUN pnpm install --frozen-lockfile --prod
COPY . . COPY . .
CMD ["node", "src/index.ts"] CMD ["node", "src/index.ts"]

0
README.md Normal file
View File

BIN
all-namespaces.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 798 KiB

View File

@@ -3,8 +3,8 @@
# Declare variables to be passed into your templates. # Declare variables to be passed into your templates.
image: image:
repository: homelab-operator # ghcr.io/morten-olsen/homelab-operator repository: ghcr.io/morten-olsen/homelab-operator
pullPolicy: Always pullPolicy: IfNotPresent
# 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

@@ -7,8 +7,7 @@ apiVersion: homelab.mortenolsen.pro/v1
kind: Environment kind: Environment
metadata: metadata:
name: dev name: dev
namespace: dev
spec: spec:
domain: one.dev.olsen.cloud domain: one.dev.olsen.cloud
tls: tls:
issuer: letsencrypt-prod issuer: lets-encrypt-prod

View File

@@ -35,6 +35,12 @@
"yaml": "^2.8.0", "yaml": "^2.8.0",
"zod": "^4.0.14" "zod": "^4.0.14"
}, },
"imports": {
"#services/*": "./src/services/*",
"#resources/*": "./src/resources/*",
"#bootstrap/*": "./src/bootstrap/*",
"#utils/*": "./src/utils/*"
},
"packageManager": "pnpm@10.6.0", "packageManager": "pnpm@10.6.0",
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [

9
pyproject.toml Normal file
View File

@@ -0,0 +1,9 @@
[project]
name = "homelab-operator"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"kubediagrams>=0.5.0",
]

View File

@@ -12,7 +12,7 @@ build:
# load: true # load: true
artifacts: artifacts:
# Defines the image to build. It matches the placeholder in deployment.yaml. # Defines the image to build. It matches the placeholder in deployment.yaml.
- image: homelab-operator - image: homelaboperator
context: . # The build context is the root directory context: . # The build context is the root directory
docker: docker:
dockerfile: Dockerfile dockerfile: Dockerfile
@@ -22,6 +22,9 @@ manifests:
releases: releases:
- name: homelab-operator - name: homelab-operator
chartPath: charts/operator chartPath: charts/operator
setValueTemplates:
image.repository: '{{.IMAGE_REPO_homelaboperator}}'
image.tag: '{{.IMAGE_TAG_homelaboperator}}'
deploy: deploy:
# Use kubectl to apply the manifests. # Use kubectl to apply the manifests.

View File

@@ -3,7 +3,6 @@ import type { Services } from '../utils/service.ts';
import { NamespaceService } from './namespaces/namespaces.ts'; import { NamespaceService } from './namespaces/namespaces.ts';
import { ReleaseService } from './releases/releases.ts'; import { ReleaseService } from './releases/releases.ts';
import { RepoService } from './repos/repos.ts'; import { RepoService } from './repos/repos.ts';
import { ClusterIssuerService } from './resources/issuer.ts';
class BootstrapService { class BootstrapService {
#services: Services; #services: Services;
@@ -23,15 +22,10 @@ class BootstrapService {
return this.#services.get(ReleaseService); return this.#services.get(ReleaseService);
} }
public get clusterIssuer() {
return this.#services.get(ClusterIssuerService);
}
public ensure = async () => { public ensure = async () => {
await this.namespaces.ensure(); await this.namespaces.ensure();
await this.repos.ensure(); await this.repos.ensure();
await this.releases.ensure(); await this.releases.ensure();
await this.clusterIssuer.ensure();
}; };
} }

View File

@@ -1,38 +1,19 @@
import { NamespaceInstance } from '../../instances/namespace.ts';
import type { Services } from '../../utils/service.ts'; import type { Services } from '../../utils/service.ts';
import { ResourceService } from '../../services/resources/resources.ts'; import { ResourceService } from '../../services/resources/resources.ts';
import { Namespace } from '#resources/core/namespace/namespace.ts';
class NamespaceService { class NamespaceService {
#homelab: NamespaceInstance; #homelab: Namespace;
#istioSystem: NamespaceInstance; #istioSystem: Namespace;
#certManager: NamespaceInstance; #certManager: Namespace;
constructor(services: Services) { constructor(services: Services) {
const resourceService = services.get(ResourceService); const resourceService = services.get(ResourceService);
this.#homelab = resourceService.getInstance( this.#homelab = resourceService.get(Namespace, 'homelab');
{ this.#istioSystem = resourceService.get(Namespace, 'istio-system');
apiVersion: 'v1', this.#certManager = resourceService.get(Namespace, 'cert-manager');
kind: 'Namespace',
name: 'homelab',
},
NamespaceInstance,
);
this.#istioSystem = resourceService.getInstance(
{
apiVersion: 'v1',
kind: 'Namespace',
name: 'istio-system',
},
NamespaceInstance,
);
this.#certManager = resourceService.getInstance(
{
apiVersion: 'v1',
kind: 'Namespace',
name: 'cert-manager',
},
NamespaceInstance,
);
this.#homelab.on('changed', this.ensure); this.#homelab.on('changed', this.ensure);
this.#istioSystem.on('changed', this.ensure); this.#istioSystem.on('changed', this.ensure);
this.#certManager.on('changed', this.ensure); this.#certManager.on('changed', this.ensure);

View File

@@ -1,56 +1,26 @@
import { HelmReleaseInstance } from '../../instances/helm-release.ts';
import { ResourceService } from '../../services/resources/resources.ts'; import { ResourceService } from '../../services/resources/resources.ts';
import { NAMESPACE } from '../../utils/consts.ts'; import { NAMESPACE } from '../../utils/consts.ts';
import { Services } from '../../utils/service.ts'; import { Services } from '../../utils/service.ts';
import { NamespaceService } from '../namespaces/namespaces.ts'; import { NamespaceService } from '../namespaces/namespaces.ts';
import { RepoService } from '../repos/repos.ts'; import { RepoService } from '../repos/repos.ts';
import { HelmRelease } from '#resources/flux/helm-release/helm-release.ts';
class ReleaseService { class ReleaseService {
#services: Services; #services: Services;
#certManager: HelmReleaseInstance; #certManager: HelmRelease;
#istioBase: HelmReleaseInstance; #istioBase: HelmRelease;
#istiod: HelmReleaseInstance; #istiod: HelmRelease;
#istioGateway: HelmReleaseInstance; #istioGateway: HelmRelease;
constructor(services: Services) { constructor(services: Services) {
this.#services = services; this.#services = services;
const resourceService = services.get(ResourceService); const resourceService = services.get(ResourceService);
this.#certManager = resourceService.getInstance( this.#certManager = resourceService.get(HelmRelease, 'cert-manager', NAMESPACE);
{ this.#istioBase = resourceService.get(HelmRelease, 'istio-base', NAMESPACE);
apiVersion: 'helm.toolkit.fluxcd.io/v2', this.#istiod = resourceService.get(HelmRelease, 'istiod', NAMESPACE);
kind: 'HelmRelease', this.#istioGateway = resourceService.get(HelmRelease, 'istio-gateway', NAMESPACE);
name: 'cert-manager',
namespace: NAMESPACE,
},
HelmReleaseInstance,
);
this.#istioBase = resourceService.getInstance(
{
apiVersion: 'helm.toolkit.fluxcd.io/v2',
kind: 'HelmRelease',
name: 'istio-base',
namespace: NAMESPACE,
},
HelmReleaseInstance,
);
this.#istiod = resourceService.getInstance(
{
apiVersion: 'helm.toolkit.fluxcd.io/v2',
kind: 'HelmRelease',
name: 'istiod',
namespace: NAMESPACE,
},
HelmReleaseInstance,
);
this.#istioGateway = resourceService.getInstance(
{
apiVersion: 'helm.toolkit.fluxcd.io/v2',
kind: 'HelmRelease',
name: 'istio-gateway',
namespace: NAMESPACE,
},
HelmReleaseInstance,
);
this.#certManager.on('changed', this.ensure); this.#certManager.on('changed', this.ensure);
this.#istioBase.on('changed', this.ensure); this.#istioBase.on('changed', this.ensure);
this.#istiod.on('changed', this.ensure); this.#istiod.on('changed', this.ensure);

View File

@@ -1,110 +1,48 @@
import type { Services } from '../../utils/service.ts'; import type { Services } from '../../utils/service.ts';
import { ResourceService } from '../../services/resources/resources.ts'; import { ResourceService } from '../../services/resources/resources.ts';
import { HelmRepoInstance } from '../../instances/helm-repo.ts';
import { NAMESPACE } from '../../utils/consts.ts'; import { NAMESPACE } from '../../utils/consts.ts';
import { HelmRepo } from '#resources/flux/helm-repo/helm-repo.ts';
class RepoService { class RepoService {
#jetstack: HelmRepoInstance; #jetstack: HelmRepo;
#istio: HelmRepoInstance; #istio: HelmRepo;
#authentik: HelmRepoInstance; #authentik: HelmRepo;
#containerro: HelmRepoInstance;
constructor(services: Services) { constructor(services: Services) {
const resourceService = services.get(ResourceService); const resourceService = services.get(ResourceService);
this.#jetstack = resourceService.getInstance( this.#jetstack = resourceService.get(HelmRepo, 'jetstack', NAMESPACE);
{ this.#istio = resourceService.get(HelmRepo, 'istio', NAMESPACE);
apiVersion: 'source.toolkit.fluxcd.io/v1', this.#authentik = resourceService.get(HelmRepo, 'authentik', NAMESPACE);
kind: 'HelmRepository',
name: 'jetstack',
namespace: NAMESPACE,
},
HelmRepoInstance,
);
this.#istio = resourceService.getInstance(
{
apiVersion: 'source.toolkit.fluxcd.io/v1',
kind: 'HelmRepository',
name: 'istio',
namespace: NAMESPACE,
},
HelmRepoInstance,
);
this.#authentik = resourceService.getInstance(
{
apiVersion: 'source.toolkit.fluxcd.io/v1',
kind: 'HelmRepository',
name: 'authentik',
namespace: NAMESPACE,
},
HelmRepoInstance,
);
this.#containerro = resourceService.getInstance(
{
apiVersion: 'source.toolkit.fluxcd.io/v1',
kind: 'HelmRepository',
name: 'containerro',
namespace: NAMESPACE,
},
HelmRepoInstance,
);
this.#jetstack.on('changed', this.ensure); this.#jetstack.on('changed', this.ensure);
this.#istio.on('changed', this.ensure); this.#istio.on('changed', this.ensure);
this.#authentik.on('changed', this.ensure); this.#authentik.on('changed', this.ensure);
this.#containerro.on('changed', this.ensure);
} }
public get jetstack() { public get jetstack() {
return this.#jetstack; return this.#jetstack;
} }
public get istio() { public get istio() {
return this.#istio; return this.#istio;
} }
public get authentik() { public get authentik() {
return this.#authentik; return this.#authentik;
} }
public get containerro() {
return this.#containerro;
}
public ensure = async () => { public ensure = async () => {
await this.#jetstack.ensure({ await this.#jetstack.set({
metadata: { url: 'https://charts.jetstack.io',
name: 'jetstack',
},
spec: {
interval: '1h',
url: 'https://charts.jetstack.io',
},
}); });
await this.#istio.ensure({ await this.#istio.set({
metadata: { url: 'https://istio-release.storage.googleapis.com/charts',
name: 'istio',
},
spec: {
interval: '1h',
url: 'https://istio-release.storage.googleapis.com/charts',
},
}); });
await this.#authentik.ensure({ await this.#authentik.set({
metadata: { url: 'https://charts.goauthentik.io',
name: 'authentik',
},
spec: {
interval: '1h',
url: 'https://charts.goauthentik.io',
},
});
await this.#containerro.ensure({
metadata: {
name: 'containerro',
},
spec: {
interval: '1h',
url: 'https://charts.containeroo.ch',
},
}); });
}; };
} }

View File

@@ -1,64 +0,0 @@
import { ClusterIssuerInstance } from '../../instances/cluster-issuer.ts';
import { CustomDefinitionInstance } from '../../instances/custom-resource-definition.ts';
import { ResourceService } from '../../services/resources/resources.ts';
import type { Services } from '../../utils/service.ts';
class ClusterIssuerService {
#clusterIssuerCrd: CustomDefinitionInstance;
#clusterIssuer: ClusterIssuerInstance;
constructor(services: Services) {
const resourceService = services.get(ResourceService);
this.#clusterIssuerCrd = resourceService.getInstance(
{
apiVersion: 'v1',
kind: 'CustomResourceDefinition',
name: 'clusterissuers.cert-manager.io',
},
CustomDefinitionInstance,
);
this.#clusterIssuer = resourceService.getInstance(
{
apiVersion: 'v1',
kind: 'ClusterIssuer',
name: 'cluster-issuer',
},
ClusterIssuerInstance,
);
this.#clusterIssuerCrd.on('changed', this.ensure);
this.#clusterIssuer.on('changed', this.ensure);
}
public ensure = async () => {
if (!this.#clusterIssuerCrd.ready) {
return;
}
await this.#clusterIssuer.ensure({
spec: {
acme: {
server: 'https://acme-v02.api.letsencrypt.org/directory',
email: 'admin@example.com',
privateKeySecretRef: {
name: 'cluster-issuer-key',
},
solvers: [
{
dns01: {
cloudflare: {
email: 'admin@example.com',
apiKeySecretRef: {
name: 'cloudflare-api-key',
key: 'api-key',
},
},
},
},
],
},
},
});
};
}
export { ClusterIssuerService };

View File

@@ -1,272 +0,0 @@
import type { V1Secret } from '@kubernetes/client-node';
import { RepoService } from '../../bootstrap/repos/repos.ts';
import { HelmReleaseInstance } from '../../instances/helm-release.ts';
import { SecretInstance } from '../../instances/secret.ts';
import {
CustomResource,
type CustomResourceOptions,
type CustomResourceObject,
} from '../../services/custom-resources/custom-resources.custom-resource.ts';
import { ResourceReference } from '../../services/resources/resources.ref.ts';
import { ResourceService } from '../../services/resources/resources.ts';
import type { EnsuredSecret } from '../../services/secrets/secrets.secret.ts';
import { SecretService } from '../../services/secrets/secrets.ts';
import { API_VERSION } from '../../utils/consts.ts';
import { getWithNamespace } from '../../utils/naming.ts';
import { decodeSecret, encodeSecret } from '../../utils/secrets.ts';
import type { environmentSpecSchema } from '../environment/environment.schemas.ts';
import { HttpServiceInstance } from '../../instances/http-service.ts';
import type { redisServerSpecSchema } from '../redis-server/redis-server.schemas.ts';
import { PostgresDatabaseInstance } from '../../instances/postgres-database.ts';
import {
authentikServerInitSecretSchema,
authentikServerSecretSchema,
type authentikServerSpecSchema,
} from './authentik-server.schemas.ts';
class AuthentikServerController extends CustomResource<typeof authentikServerSpecSchema> {
#environment: ResourceReference<CustomResourceObject<typeof environmentSpecSchema>>;
#authentikInitSecret: EnsuredSecret<typeof authentikServerInitSecretSchema>;
#authentikSecret: SecretInstance;
#authentikRelease: HelmReleaseInstance;
#postgresSecret: ResourceReference<V1Secret>;
#httpService: HttpServiceInstance;
#redisServer: ResourceReference<CustomResourceObject<typeof redisServerSpecSchema>>;
#postgresDatabase: PostgresDatabaseInstance;
constructor(options: CustomResourceOptions<typeof authentikServerSpecSchema>) {
super(options);
const secretService = this.services.get(SecretService);
const resourceService = this.services.get(ResourceService);
this.#environment = new ResourceReference();
this.#authentikInitSecret = secretService.ensure({
owner: [this.ref],
name: `${this.name}-init`,
namespace: this.namespace,
schema: authentikServerInitSecretSchema,
generator: () => ({
AUTHENTIK_BOOTSTRAP_TOKEN: crypto.randomUUID(),
AUTHENTIK_BOOTSTRAP_PASSWORD: crypto.randomUUID(),
AUTHENTIK_BOOTSTRAP_EMAIL: 'admin@example.com',
AUTHENTIK_SECRET_KEY: crypto.randomUUID(),
}),
});
this.#authentikSecret = resourceService.getInstance(
{
apiVersion: 'v1',
kind: 'Secret',
name: `${this.name}-server`,
namespace: this.namespace,
},
SecretInstance<typeof authentikServerSecretSchema>,
);
this.#authentikRelease = resourceService.getInstance(
{
apiVersion: 'helm.toolkit.fluxcd.io/v2',
kind: 'HelmRelease',
name: this.name,
namespace: this.namespace,
},
HelmReleaseInstance,
);
this.#httpService = resourceService.getInstance(
{
apiVersion: API_VERSION,
kind: 'HttpService',
name: this.name,
namespace: this.namespace,
},
HttpServiceInstance,
);
this.#postgresDatabase = resourceService.getInstance(
{
apiVersion: API_VERSION,
kind: 'PostgresDatabase',
name: this.name,
namespace: this.namespace,
},
PostgresDatabaseInstance,
);
this.#redisServer = new ResourceReference();
this.#postgresSecret = new ResourceReference();
this.#authentikSecret.on('changed', this.queueReconcile);
this.#authentikInitSecret.resource.on('changed', this.queueReconcile);
this.#environment.on('changed', this.queueReconcile);
this.#authentikRelease.on('changed', this.queueReconcile);
this.#postgresSecret.on('changed', this.queueReconcile);
this.#postgresDatabase.on('changed', this.queueReconcile);
this.#httpService.on('changed', this.queueReconcile);
this.#redisServer.on('changed', this.queueReconcile);
}
public reconcile = async () => {
if (!this.exists || this.metadata?.deletionTimestamp) {
return;
}
if (!this.#authentikInitSecret.isValid) {
await this.markNotReady('MissingAuthentikInitSecret', 'The authentik init secret is not found');
return;
}
const resourceService = this.services.get(ResourceService);
const environmentNames = getWithNamespace(this.spec.environment, this.namespace);
this.#environment.current = resourceService.get({
apiVersion: API_VERSION,
kind: 'Environment',
name: environmentNames.name,
namespace: this.namespace,
});
await this.#postgresDatabase.ensure({
metadata: {
ownerReferences: [this.ref],
},
spec: {
cluster: this.spec.postgresCluster,
},
});
const postgresSecret = this.#postgresDatabase.secret;
if (!postgresSecret.exists) {
await this.markNotReady('MissingPostgresSecret', 'The postgres secret is not found');
return;
}
const postgresSecretData = decodeSecret(postgresSecret.data) || {};
if (!this.#environment.current?.exists) {
await this.markNotReady(
'MissingEnvironment',
`Environment ${this.#environment.current?.namespace}/${this.#environment.current?.name} not found`,
);
return;
}
const domain = this.#environment.current.spec?.domain;
if (!domain) {
await this.markNotReady('MissingDomain', 'The domain is not set');
return;
}
const secretData = {
url: `https://${this.spec.subdomain}.${domain}`,
host: `${this.name}-server.${this.namespace}.svc.cluster.local`,
token: this.#authentikInitSecret.value?.AUTHENTIK_BOOTSTRAP_TOKEN ?? '',
};
await this.#authentikSecret.ensure({
metadata: {
ownerReferences: [this.ref],
},
data: encodeSecret(secretData),
});
const repoService = this.services.get(RepoService);
const redisNames = getWithNamespace(this.spec.redisServer, this.namespace);
const redisHost = `${redisNames.name}.${redisNames.namespace}.svc.cluster.local`;
await this.#authentikRelease.ensure({
metadata: {
ownerReferences: [this.ref],
},
spec: {
interval: '60m',
chart: {
spec: {
chart: 'authentik',
version: '2025.6.4',
sourceRef: {
apiVersion: 'source.toolkit.fluxcd.io/v1',
kind: 'HelmRepository',
name: repoService.authentik.name,
namespace: repoService.authentik.namespace,
},
},
},
values: {
global: {
envFrom: [
{
secretRef: {
name: this.#authentikInitSecret.name,
},
},
],
},
authentik: {
error_reporting: {
enabled: false,
},
postgresql: {
host: postgresSecretData.host,
name: postgresSecretData.database,
user: postgresSecretData.username,
password: 'file:///postgres-creds/password',
},
redis: {
host: redisHost,
},
},
server: {
volumes: [
{
name: 'postgres-creds',
secret: {
secretName: postgresSecret.name,
},
},
],
volumeMounts: [
{
name: 'postgres-creds',
mountPath: '/postgres-creds',
readOnly: true,
},
],
},
worker: {
volumes: [
{
name: 'postgres-creds',
secret: {
secretName: postgresSecret.name,
},
},
],
volumeMounts: [
{
name: 'postgres-creds',
mountPath: '/postgres-creds',
readOnly: true,
},
],
},
},
},
});
await this.#httpService.ensure({
metadata: {
ownerReferences: [this.ref],
},
spec: {
environment: this.spec.environment,
subdomain: this.spec.subdomain,
destination: {
host: `${this.name}-server.${this.namespace}.svc.cluster.local`,
port: {
number: 80,
},
},
},
});
await this.markReady();
};
}
export { AuthentikServerController };

View File

@@ -1,23 +0,0 @@
import { z } from 'zod';
const authentikServerSpecSchema = z.object({
redisServer: z.string(),
postgresCluster: z.string(),
environment: z.string(),
subdomain: z.string(),
});
const authentikServerInitSecretSchema = z.object({
AUTHENTIK_BOOTSTRAP_TOKEN: z.string(),
AUTHENTIK_BOOTSTRAP_PASSWORD: z.string(),
AUTHENTIK_BOOTSTRAP_EMAIL: z.string(),
AUTHENTIK_SECRET_KEY: z.string(),
});
const authentikServerSecretSchema = z.object({
url: z.string(),
host: z.string(),
token: z.string(),
});
export { authentikServerSpecSchema, authentikServerInitSecretSchema, authentikServerSecretSchema };

View File

@@ -1,19 +0,0 @@
import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts';
import { GROUP } from '../../utils/consts.ts';
import { authentikServerSpecSchema } from './authentik-server.schemas.ts';
import { AuthentikServerController } from './authentik-server.controller.ts';
const authentikServerDefinition = createCustomResourceDefinition({
group: GROUP,
version: 'v1',
kind: 'AuthentikServer',
names: {
plural: 'authentikservers',
singular: 'authentikserver',
},
spec: authentikServerSpecSchema,
create: (options) => new AuthentikServerController(options),
});
export { authentikServerDefinition };

View File

@@ -1,21 +0,0 @@
import { authentikClientDefinition } from './authentik-client/authentik-client.ts';
import { authentikServerDefinition } from './authentik-server/authentik-server.ts';
import { environmentDefinition } from './environment/environment.ts';
import { generateSecretDefinition } from './generate-secret/generate-secret.ts';
import { httpServiceDefinition } from './http-service/http-service.ts';
import { postgresClusterDefinition } from './postgres-cluster/postgres-cluster.ts';
import { postgresDatabaseDefinition } from './postgres-database/postgres-database.ts';
import { redisServerDefinition } from './redis-server/redis-server.ts';
const customResources = [
postgresDatabaseDefinition,
authentikClientDefinition,
generateSecretDefinition,
environmentDefinition,
postgresClusterDefinition,
authentikServerDefinition,
httpServiceDefinition,
redisServerDefinition,
];
export { customResources };

View File

@@ -1,224 +0,0 @@
import { CertificateInstance } from '../../instances/certificate.ts';
import { CustomDefinitionInstance } from '../../instances/custom-resource-definition.ts';
import { NamespaceInstance } from '../../instances/namespace.ts';
import {
CustomResource,
type CustomResourceOptions,
} from '../../services/custom-resources/custom-resources.custom-resource.ts';
import { ResourceService } from '../../services/resources/resources.ts';
import { GatewayInstance } from '../../instances/gateway.ts';
import { PostgresClusterInstance } from '../../instances/postgres-cluster.ts';
import { API_VERSION } from '../../utils/consts.ts';
import { AuthentikServerInstance } from '../../instances/authentik-server.ts';
import { StorageClassInstance } from '../../instances/storageclass.ts';
import { PROVISIONER } from '../../storage-provider/storage-provider.ts';
import { RedisServerInstance } from '../../instances/redis-server.ts';
import { NamespaceService } from '../../bootstrap/namespaces/namespaces.ts';
import type { environmentSpecSchema } from './environment.schemas.ts';
class EnvironmentController extends CustomResource<typeof environmentSpecSchema> {
#namespace: NamespaceInstance;
#certificateCrd: CustomDefinitionInstance;
#certificate: CertificateInstance;
#gatewayCrd: CustomDefinitionInstance;
#gateway: GatewayInstance;
#storageClass: StorageClassInstance;
#postgresCluster: PostgresClusterInstance;
#authentikServer: AuthentikServerInstance;
#redisServer: RedisServerInstance;
constructor(options: CustomResourceOptions<typeof environmentSpecSchema>) {
super(options);
const resourceService = this.services.get(ResourceService);
const namespaceService = this.services.get(NamespaceService);
this.#namespace = resourceService.getInstance(
{
apiVersion: 'v1',
kind: 'Namespace',
name: this.namespace,
},
NamespaceInstance,
);
this.#certificateCrd = resourceService.getInstance(
{
apiVersion: 'apiextensions.k8s.io/v1',
kind: 'CustomResourceDefinition',
name: 'certificates.cert-manager.io',
},
CustomDefinitionInstance,
);
this.#certificate = resourceService.getInstance(
{
apiVersion: 'cert-manager.io/v1',
kind: 'Certificate',
name: `${this.name}-tls`,
namespace: namespaceService.homelab.name,
},
CertificateInstance,
);
this.#gatewayCrd = resourceService.getInstance(
{
apiVersion: 'apiextensions.k8s.io/v1',
kind: 'CustomResourceDefinition',
name: 'gateways.networking.istio.io',
},
CustomDefinitionInstance,
);
this.#gateway = resourceService.getInstance(
{
apiVersion: 'networking.istio.io/v1',
kind: 'Gateway',
name: this.name,
namespace: this.namespace,
},
GatewayInstance,
);
this.#storageClass = resourceService.getInstance(
{
apiVersion: 'storage.k8s.io/v1',
kind: 'StorageClass',
name: `${this.name}-retain`,
},
StorageClassInstance,
);
this.#postgresCluster = resourceService.getInstance(
{
apiVersion: API_VERSION,
kind: 'PostgresCluster',
name: `${this.name}-postgres-cluster`,
namespace: this.namespace,
},
PostgresClusterInstance,
);
this.#authentikServer = resourceService.getInstance(
{
apiVersion: API_VERSION,
kind: 'AuthentikServer',
name: `${this.name}-authentik-server`,
namespace: this.namespace,
},
AuthentikServerInstance,
);
this.#redisServer = resourceService.getInstance(
{
apiVersion: API_VERSION,
kind: 'RedisServer',
name: `${this.name}-redis-server`,
namespace: this.namespace,
},
RedisServerInstance,
);
this.#gatewayCrd.on('changed', this.queueReconcile);
this.#gateway.on('changed', this.queueReconcile);
this.#certificateCrd.on('changed', this.queueReconcile);
this.#namespace.on('changed', this.queueReconcile);
this.#certificate.on('changed', this.queueReconcile);
this.#postgresCluster.on('changed', this.queueReconcile);
this.#authentikServer.on('changed', this.queueReconcile);
this.#storageClass.on('changed', this.queueReconcile);
this.#redisServer.on('changed', this.queueReconcile);
}
public reconcile = async () => {
if (!this.exists || this.metadata?.deletionTimestamp) {
return;
}
await this.#namespace.ensure({
metadata: {
ownerReferences: [this.ref],
labels: {
'istio-injection': 'enabled',
},
},
});
if (this.#certificateCrd.ready) {
await this.#certificate.ensure({
spec: {
secretName: `${this.name}-tls`,
issuerRef: {
name: this.spec.tls.issuer,
kind: 'ClusterIssuer',
},
dnsNames: [`*.${this.spec.domain}`],
privateKey: {
rotationPolicy: 'Always',
},
},
});
}
if (this.#gatewayCrd.ready) {
await this.#gateway.ensure({
metadata: {
ownerReferences: [this.ref],
},
spec: {
selector: {
istio: 'homelab-istio-gateway',
},
servers: [
{
hosts: [`*.${this.spec.domain}`],
port: {
name: 'http',
number: 80,
protocol: 'HTTP',
},
tls: {
httpsRedirect: true,
},
},
{
hosts: [`*.${this.spec.domain}`],
port: {
name: 'https',
number: 443,
protocol: 'HTTPS',
},
tls: {
mode: 'SIMPLE',
credentialName: `${this.name}-tls`,
},
},
],
},
});
await this.#storageClass.ensure({
provisioner: PROVISIONER,
parameters: {
storageLocation: this.spec.storage?.location || `/data/volumes/${this.name}`,
reclaimPolicy: 'Retain',
allowVolumeExpansion: 'true',
volumeBindingMode: 'Immediate',
},
});
await this.#postgresCluster.ensure({
metadata: {
ownerReferences: [this.ref],
},
spec: {
environment: this.name,
},
});
await this.#authentikServer.ensure({
metadata: {
ownerReferences: [this.ref],
},
spec: {
environment: `${this.namespace}/${this.name}`,
subdomain: 'authentik',
postgresCluster: `${this.name}-postgres-cluster`,
redisServer: `${this.name}-redis-server`,
},
});
await this.#redisServer.ensure({
metadata: {
ownerReferences: [this.ref],
},
spec: {},
});
}
};
}
export { EnvironmentController };

View File

@@ -1,17 +0,0 @@
import { z } from 'zod';
const environmentSpecSchema = z.object({
domain: z.string(),
tls: z.object({
issuer: z.string(),
}),
storage: z
.object({
location: z.string().optional(),
})
.optional(),
});
type EnvironmentSpec = z.infer<typeof environmentSpecSchema>;
export { environmentSpecSchema, type EnvironmentSpec };

View File

@@ -1,19 +0,0 @@
import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts';
import { GROUP } from '../../utils/consts.ts';
import { EnvironmentController } from './environment.controller.ts';
import { environmentSpecSchema } from './environment.schemas.ts';
const environmentDefinition = createCustomResourceDefinition({
group: GROUP,
version: 'v1',
kind: 'Environment',
names: {
plural: 'environments',
singular: 'environment',
},
spec: environmentSpecSchema,
create: (options) => new EnvironmentController(options),
});
export { environmentDefinition };

View File

@@ -1,100 +0,0 @@
import { DestinationRuleInstance } from '../../instances/destination-rule.ts';
import { VirtualServiceInstance } from '../../instances/virtual-service.ts';
import {
CustomResource,
type CustomResourceObject,
type CustomResourceOptions,
} from '../../services/custom-resources/custom-resources.custom-resource.ts';
import { ResourceReference, ResourceService } from '../../services/resources/resources.ts';
import { API_VERSION } from '../../utils/consts.ts';
import { getWithNamespace } from '../../utils/naming.ts';
import { environmentSpecSchema } from '../environment/environment.schemas.ts';
import { httpServiceSpecSchema } from './http-service.schemas.ts';
class HttpServiceController extends CustomResource<typeof httpServiceSpecSchema> {
#environment: ResourceReference<CustomResourceObject<typeof environmentSpecSchema>>;
#virtualService: VirtualServiceInstance;
#destinationRule: DestinationRuleInstance;
constructor(options: CustomResourceOptions<typeof httpServiceSpecSchema>) {
super(options);
const resourceService = this.services.get(ResourceService);
this.#environment = new ResourceReference();
this.#virtualService = resourceService.getInstance(
{
apiVersion: 'networking.istio.io/v1beta1',
kind: 'VirtualService',
name: `${this.name}-virtual-service`,
namespace: this.namespace,
},
VirtualServiceInstance,
);
this.#destinationRule = resourceService.getInstance(
{
apiVersion: 'networking.istio.io/v1beta1',
kind: 'DestinationRule',
name: `${this.name}-destination-rule`,
namespace: this.namespace,
},
DestinationRuleInstance,
);
this.#destinationRule.on('changed', this.queueReconcile);
this.#virtualService.on('changed', this.queueReconcile);
this.#environment.on('changed', this.queueReconcile);
}
public reconcile = async () => {
if (!this.exists || this.metadata?.deletionTimestamp) {
return;
}
const resourceService = this.services.get(ResourceService);
const environmentNames = getWithNamespace(this.spec.environment, this.namespace);
this.#environment.current = resourceService.get({
apiVersion: API_VERSION,
kind: 'Environment',
name: environmentNames.name,
namespace: environmentNames.namespace,
});
const environment = this.#environment.current;
if (!environment?.exists) {
return;
}
await this.#virtualService.ensure({
metadata: {
ownerReferences: [this.ref],
},
spec: {
hosts: [`${this.spec.subdomain}.${environment.spec?.domain}`],
gateways: [`${this.#environment.current.namespace}/${this.#environment.current.name}`],
http: [
{
route: [
{
destination: {
host: this.spec.destination.host,
port: this.spec.destination.port,
},
},
],
},
],
},
});
await this.#destinationRule.ensure({
metadata: {
ownerReferences: [this.ref],
},
spec: {
host: this.spec.destination.host,
trafficPolicy: {
tls: {
mode: 'DISABLE',
},
},
},
});
};
}
export { HttpServiceController };

View File

@@ -1,18 +0,0 @@
import { z } from 'zod';
const httpServiceSpecSchema = z.object({
environment: z.string(),
subdomain: z.string(),
destination: z.object({
host: z.string(),
port: z
.object({
number: z.number().optional(),
protocol: z.enum(['http', 'https']).optional(),
name: z.string().optional(),
})
.optional(),
}),
});
export { httpServiceSpecSchema };

View File

@@ -1,19 +0,0 @@
import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts';
import { GROUP } from '../../utils/consts.ts';
import { HttpServiceController } from './http-service.controller.ts';
import { httpServiceSpecSchema } from './http-service.schemas.ts';
const httpServiceDefinition = createCustomResourceDefinition({
group: GROUP,
version: 'v1',
kind: 'HttpService',
names: {
plural: 'httpservices',
singular: 'httpservice',
},
spec: httpServiceSpecSchema,
create: (options) => new HttpServiceController(options),
});
export { httpServiceDefinition };

View File

@@ -1,155 +0,0 @@
import { ServiceInstance } from '../../instances/service.ts';
import { StatefulSetInstance } from '../../instances/stateful-set.ts';
import { ResourceService } from '../../services/resources/resources.ts';
import {
CustomResource,
type CustomResourceOptions,
} from '../../services/custom-resources/custom-resources.custom-resource.ts';
import type { EnsuredSecret } from '../../services/secrets/secrets.secret.ts';
import { SecretService } from '../../services/secrets/secrets.ts';
import { postgresClusterSecretSchema, type postgresClusterSpecSchema } from './postgres-cluster.schemas.ts';
class PostgresClusterController extends CustomResource<typeof postgresClusterSpecSchema> {
#statefulSet: StatefulSetInstance;
#headlessService: ServiceInstance;
#service: ServiceInstance;
#secret: EnsuredSecret<typeof postgresClusterSecretSchema>;
constructor(options: CustomResourceOptions<typeof postgresClusterSpecSchema>) {
super(options);
const resourceService = this.services.get(ResourceService);
const secretService = this.services.get(SecretService);
this.#statefulSet = resourceService.getInstance(
{
apiVersion: 'apps/v1',
kind: 'StatefulSet',
name: this.name,
namespace: this.namespace,
},
StatefulSetInstance,
);
this.#headlessService = resourceService.getInstance(
{
apiVersion: 'v1',
kind: 'Service',
name: `${this.name}-headless`,
namespace: this.namespace,
},
ServiceInstance,
);
this.#service = resourceService.getInstance(
{
apiVersion: 'v1',
kind: 'Service',
name: this.name,
namespace: this.namespace,
},
ServiceInstance,
);
this.#secret = secretService.ensure({
name: this.name,
namespace: this.namespace,
schema: postgresClusterSecretSchema,
generator: () => {
return {
database: 'postgres',
host: `${this.name}.${this.namespace}.svc.cluster.local`,
port: '5432',
username: 'postgres',
password: crypto.randomUUID(),
};
},
});
this.#statefulSet.on('changed', this.queueReconcile);
this.#service.on('changed', this.queueReconcile);
this.#headlessService.on('changed', this.queueReconcile);
this.#secret.resource.on('changed', this.queueReconcile);
}
public reconcile = async () => {
if (!this.exists || this.metadata?.deletionTimestamp || !this.#secret.isValid) {
return;
}
await this.#headlessService.ensure({
metadata: {
ownerReferences: [this.ref],
},
spec: {
clusterIP: 'None',
selector: {
app: this.name,
},
ports: [{ name: 'postgres', port: 5432, targetPort: 5432 }],
},
});
await this.#statefulSet.ensure({
metadata: {
ownerReferences: [this.ref],
},
spec: {
replicas: 1,
serviceName: this.name,
selector: {
matchLabels: {
app: this.name,
},
},
template: {
metadata: {
labels: {
app: this.name,
},
},
spec: {
containers: [
{
name: this.name,
image: 'postgres:17',
ports: [{ containerPort: 5432, name: 'postgres' }],
env: [
{ name: 'POSTGRES_PASSWORD', valueFrom: { secretKeyRef: { name: this.name, key: 'password' } } },
{ name: 'POSTGRES_USER', valueFrom: { secretKeyRef: { name: this.name, key: 'username' } } },
{ name: 'POSTGRES_DB', value: this.name },
{ name: 'PGDATA', value: '/var/lib/postgresql/data/pgdata' },
],
volumeMounts: [{ name: this.name, mountPath: '/var/lib/postgresql/data' }],
},
],
},
},
volumeClaimTemplates: [
{
metadata: {
name: this.name,
},
spec: {
accessModes: ['ReadWriteOnce'],
storageClassName: `${this.spec.environment}-retain`,
resources: {
requests: {
storage: this.spec.storage?.size || '1Gi',
},
},
},
},
],
},
});
await this.#service.ensure({
metadata: {
ownerReferences: [this.ref],
},
spec: {
type: 'ClusterIP',
selector: {
app: this.name,
},
ports: [{ name: 'postgres', port: 5432, targetPort: 5432 }],
},
});
};
}
export { PostgresClusterController };

View File

@@ -1,20 +0,0 @@
import { z } from 'zod';
const postgresClusterSpecSchema = z.object({
environment: z.string(),
storage: z
.object({
size: z.string().optional(),
})
.optional(),
});
const postgresClusterSecretSchema = z.object({
database: z.string(),
host: z.string(),
port: z.string(),
username: z.string(),
password: z.string(),
});
export { postgresClusterSpecSchema, postgresClusterSecretSchema };

View File

@@ -1,19 +0,0 @@
import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts';
import { GROUP } from '../../utils/consts.ts';
import { PostgresClusterController } from './postgres-cluster.controller.ts';
import { postgresClusterSpecSchema } from './postgres-cluster.schemas.ts';
const postgresClusterDefinition = createCustomResourceDefinition({
group: GROUP,
version: 'v1',
kind: 'PostgresCluster',
names: {
plural: 'postgres-clusters',
singular: 'postgres-cluster',
},
spec: postgresClusterSpecSchema,
create: (options) => new PostgresClusterController(options),
});
export { postgresClusterDefinition };

View File

@@ -1,7 +0,0 @@
import { z } from 'zod';
const postgresDatabaseSpecSchema = z.object({
cluster: z.string(),
});
export { postgresDatabaseSpecSchema };

View File

@@ -1,167 +0,0 @@
import type { V1Secret } from '@kubernetes/client-node';
import {
CustomResource,
type CustomResourceOptions,
type SubresourceResult,
} from '../../services/custom-resources/custom-resources.custom-resource.ts';
import { PostgresService } from '../../services/postgres/postgres.service.ts';
import { ResourceReference } from '../../services/resources/resources.ref.ts';
import { ResourceService } from '../../services/resources/resources.ts';
import { getWithNamespace } from '../../utils/naming.ts';
import { decodeSecret } from '../../utils/secrets.ts';
import { postgresClusterSecretSchema } from '../postgres-cluster/postgres-cluster.schemas.ts';
import { SecretInstance } from '../../instances/secret.ts';
import { type postgresDatabaseSpecSchema } from './portgres-database.schemas.ts';
const SECRET_READY_CONDITION = 'Secret';
const DATABASE_READY_CONDITION = 'Database';
class PostgresDatabaseResource extends CustomResource<typeof postgresDatabaseSpecSchema> {
#clusterSecret: ResourceReference<V1Secret>;
#databaseSecret: SecretInstance<typeof postgresClusterSecretSchema>;
constructor(options: CustomResourceOptions<typeof postgresDatabaseSpecSchema>) {
super(options);
const resourceService = this.services.get(ResourceService);
this.#clusterSecret = new ResourceReference();
this.#databaseSecret = resourceService.getInstance(
{
apiVersion: 'v1',
kind: 'Secret',
name: `${this.name}-postgres-database`,
namespace: this.namespace,
},
SecretInstance<typeof postgresClusterSecretSchema>,
);
this.#updateSecret();
this.#clusterSecret.on('changed', this.queueReconcile);
this.#databaseSecret.on('changed', this.queueReconcile);
}
get #dbName() {
return `${this.namespace}_${this.name}`;
}
get #userName() {
return `${this.namespace}_${this.name}`;
}
#updateSecret = () => {
const resourceService = this.services.get(ResourceService);
const secretNames = getWithNamespace(this.spec.cluster, this.namespace);
this.#clusterSecret.current = resourceService.get({
apiVersion: 'v1',
kind: 'Secret',
name: secretNames.name,
namespace: secretNames.namespace,
});
};
#reconcileSecret = async (): Promise<SubresourceResult> => {
const serverSecret = this.#clusterSecret.current;
const databaseSecret = this.#databaseSecret;
if (!serverSecret?.exists || !serverSecret.data) {
return {
ready: false,
failed: true,
reason: 'MissingConnectionSecret',
};
}
const serverSecretData = postgresClusterSecretSchema.safeParse(decodeSecret(serverSecret.data));
if (!serverSecretData.success || !serverSecretData.data) {
return {
ready: false,
syncing: true,
reason: 'SecretMissing',
};
}
const databaseSecretData = postgresClusterSecretSchema.safeParse(decodeSecret(databaseSecret.data));
const expectedSecret = {
password: crypto.randomUUID(),
host: serverSecretData.data.host,
port: serverSecretData.data.port,
username: this.#userName,
database: this.#dbName,
...databaseSecretData.data,
};
await databaseSecret.ensureData(expectedSecret);
return {
ready: true,
};
};
#reconcileDatabase = async (): Promise<SubresourceResult> => {
const clusterSecret = this.#clusterSecret.current;
if (!clusterSecret?.exists || !clusterSecret.data) {
return {
ready: false,
failed: true,
reason: 'MissingConnectionSecret',
};
}
const connectionSecretData = postgresClusterSecretSchema.safeParse(decodeSecret(clusterSecret.data));
if (!connectionSecretData.success || !connectionSecretData.data) {
return {
ready: false,
syncing: true,
reason: 'SecretMissing',
};
}
const secretData = postgresClusterSecretSchema.safeParse(decodeSecret(this.#databaseSecret.data));
if (!secretData.success || !secretData.data) {
return {
ready: false,
syncing: true,
reason: 'ConnectionSecretMissing',
};
}
const postgresService = this.services.get(PostgresService);
const database = postgresService.get({
...connectionSecretData.data,
port: connectionSecretData.data.port ? Number(connectionSecretData.data.port) : 5432,
database: connectionSecretData.data.database,
});
await database.upsertRole({
name: secretData.data.username,
password: secretData.data.password,
});
await database.upsertDatabase({
name: secretData.data.database,
owner: secretData.data.username,
});
return {
ready: true,
};
};
public reconcile = async () => {
if (!this.exists || this.metadata?.deletionTimestamp) {
return;
}
this.#updateSecret();
await Promise.allSettled([
this.reconcileSubresource(DATABASE_READY_CONDITION, this.#reconcileDatabase),
this.reconcileSubresource(SECRET_READY_CONDITION, this.#reconcileSecret),
]);
const secretReady = this.conditions.get(SECRET_READY_CONDITION)?.status === 'True';
const databaseReady = this.conditions.get(DATABASE_READY_CONDITION)?.status === 'True';
await this.conditions.set('Ready', {
status: secretReady && databaseReady ? 'True' : 'False',
});
};
}
export { PostgresDatabaseResource };

View File

@@ -1,19 +0,0 @@
import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts';
import { GROUP } from '../../utils/consts.ts';
import { postgresDatabaseSpecSchema } from './portgres-database.schemas.ts';
import { PostgresDatabaseResource } from './postgres-database.resource.ts';
const postgresDatabaseDefinition = createCustomResourceDefinition({
group: GROUP,
version: 'v1',
kind: 'PostgresDatabase',
names: {
plural: 'postgresdatabases',
singular: 'postgresdatabase',
},
spec: postgresDatabaseSpecSchema,
create: (options) => new PostgresDatabaseResource(options),
});
export { postgresDatabaseDefinition };

View File

@@ -1,82 +0,0 @@
import { DeploymentInstance } from '../../instances/deployment.ts';
import { ServiceInstance } from '../../instances/service.ts';
import { CustomResource } from '../../services/custom-resources/custom-resources.custom-resource.ts';
import type { CustomResourceOptions } from '../../services/custom-resources/custom-resources.custom-resource.ts';
import { ResourceService } from '../../services/resources/resources.ts';
import type { redisServerSpecSchema } from './redis-server.schemas.ts';
class RedisServerController extends CustomResource<typeof redisServerSpecSchema> {
#deployment: DeploymentInstance;
#service: ServiceInstance;
constructor(options: CustomResourceOptions<typeof redisServerSpecSchema>) {
super(options);
const resourceService = this.services.get(ResourceService);
this.#deployment = resourceService.getInstance(
{
apiVersion: 'apps/v1',
kind: 'Deployment',
name: this.name,
namespace: this.namespace,
},
DeploymentInstance,
);
this.#service = resourceService.getInstance(
{
apiVersion: 'v1',
kind: 'Service',
name: this.name,
namespace: this.namespace,
},
ServiceInstance,
);
this.#deployment.on('changed', this.queueReconcile);
this.#service.on('changed', this.queueReconcile);
}
public reconcile = async () => {
await this.#deployment.ensure({
metadata: {
ownerReferences: [this.ref],
},
spec: {
replicas: 1,
selector: {
matchLabels: {
app: this.name,
},
},
template: {
metadata: {
labels: {
app: this.name,
},
},
spec: {
containers: [
{
name: this.name,
image: 'redis:latest',
ports: [{ containerPort: 6379 }],
},
],
},
},
},
});
await this.#service.ensure({
metadata: {
ownerReferences: [this.ref],
},
spec: {
selector: {
app: this.name,
},
ports: [{ port: 6379, targetPort: 6379 }],
},
});
};
}
export { RedisServerController };

View File

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

View File

@@ -1,19 +0,0 @@
import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts';
import { GROUP } from '../../utils/consts.ts';
import { RedisServerController } from './redis-server.controller.ts';
import { redisServerSpecSchema } from './redis-server.schemas.ts';
const redisServerDefinition = createCustomResourceDefinition({
group: GROUP,
version: 'v1',
kind: 'RedisServer',
names: {
plural: 'redis-servers',
singular: 'redis-server',
},
spec: redisServerSpecSchema,
create: (options) => new RedisServerController(options),
});
export { redisServerDefinition };

View File

@@ -1,115 +1,17 @@
import { BootstrapService } from './bootstrap/bootstrap.ts'; import { ResourceService } from './services/resources/resources.ts';
import { customResources } from './custom-resouces/custom-resources.ts';
import { CustomResourceService } from './services/custom-resources/custom-resources.ts';
import { WatcherService } from './services/watchers/watchers.ts';
import { StorageProvider } from './storage-provider/storage-provider.ts';
import { Services } from './utils/service.ts'; import { Services } from './utils/service.ts';
import { BootstrapService } from './bootstrap/bootstrap.ts';
import { resources } from '#resources/resources.ts';
import { homelab } from '#resources/homelab/homelab.ts';
const services = new Services(); const services = new Services();
const resourceService = services.get(ResourceService);
const watcherService = services.get(WatcherService); await resourceService.install(...Object.values(homelab));
await watcherService.watchCustomGroup('source.toolkit.fluxcd.io', 'v1', ['helmrepositories', 'gitrepositories']); await resourceService.register(...Object.values(resources));
await watcherService.watchCustomGroup('helm.toolkit.fluxcd.io', 'v2', ['helmreleases']);
await watcherService.watchCustomGroup('cert-manager.io', 'v1', ['certificates']);
await watcherService.watchCustomGroup('networking.k8s.io', 'v1', ['gateways', 'virtualservices']);
await watcherService const bootstrapService = services.get(BootstrapService);
.create({ await bootstrapService.ensure();
path: '/api/v1/namespaces',
list: async (k8s) => {
return await k8s.api.listNamespace();
},
verbs: ['add', 'update', 'delete'],
transform: (manifest) => ({
apiVersion: 'v1',
kind: 'Namespace',
...manifest,
}),
})
.start();
await watcherService console.log('Started');
.create({
path: '/api/v1/secrets',
list: async (k8s) => {
return await k8s.api.listSecretForAllNamespaces();
},
verbs: ['add', 'update', 'delete'],
transform: (manifest) => ({
apiVersion: 'v1',
kind: 'Secret',
...manifest,
}),
})
.start();
await watcherService
.create({
path: '/apis/apps/v1/statefulsets',
list: async (k8s) => {
return await k8s.apps.listStatefulSetForAllNamespaces({});
},
verbs: ['add', 'update', 'delete'],
transform: (manifest) => ({
apiVersion: 'apps/v1',
kind: 'StatefulSet',
...manifest,
}),
})
.start();
await watcherService
.create({
path: '/apis/apps/v1/deployments',
list: async (k8s) => {
return await k8s.apps.listDeploymentForAllNamespaces({});
},
verbs: ['add', 'update', 'delete'],
transform: (manifest) => ({
apiVersion: 'apps/v1',
kind: 'Deployment',
...manifest,
}),
})
.start();
await watcherService
.create({
path: '/apis/apiextensions.k8s.io/v1/customresourcedefinitions',
list: async (k8s) => {
return await k8s.extensionsApi.listCustomResourceDefinition();
},
verbs: ['add', 'update', 'delete'],
transform: (manifest) => ({
apiVersion: 'apiextensions.k8s.io/v1',
kind: 'CustomResourceDefinition',
...manifest,
}),
})
.start();
await watcherService
.create({
path: '/apis/storage.k8s.io/v1/storageclasses',
list: async (k8s) => {
return await k8s.storageApi.listStorageClass();
},
verbs: ['add', 'update', 'delete'],
transform: (manifest) => ({
apiVersion: 'storage.k8s.io/v1',
kind: 'StorageClass',
...manifest,
}),
})
.start();
const storageProvider = services.get(StorageProvider);
await storageProvider.start();
const bootstrap = services.get(BootstrapService);
await bootstrap.ensure();
const customResourceService = services.get(CustomResourceService);
customResourceService.register(...customResources);
await customResourceService.install(true);
await customResourceService.watch();

View File

@@ -1,26 +0,0 @@
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 { ResourceInstance } from '../services/resources/resources.instance.ts';
import { ResourceService } from '../services/resources/resources.ts';
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 };

View File

@@ -1,8 +0,0 @@
import type { KubernetesObject } from '@kubernetes/client-node';
import { ResourceInstance } from '../services/resources/resources.instance.ts';
import type { K8SCertificateV1 } from '../__generated__/resources/K8SCertificateV1.ts';
class CertificateInstance extends ResourceInstance<KubernetesObject & K8SCertificateV1> {}
export { CertificateInstance };

View File

@@ -1,12 +0,0 @@
import type { KubernetesObject } from '@kubernetes/client-node';
import type { K8SClusterIssuerV1 } from '../__generated__/resources/K8SClusterIssuerV1.ts';
import { ResourceInstance } from '../services/resources/resources.instance.ts';
class ClusterIssuerInstance extends ResourceInstance<KubernetesObject & K8SClusterIssuerV1> {
public get ready() {
return this.exists;
}
}
export { ClusterIssuerInstance };

View File

@@ -1,7 +0,0 @@
import type { V1CustomResourceDefinition } from '@kubernetes/client-node';
import { ResourceInstance } from '../services/resources/resources.instance.ts';
class CustomDefinitionInstance extends ResourceInstance<V1CustomResourceDefinition> {}
export { CustomDefinitionInstance };

View File

@@ -1,11 +0,0 @@
import type { V1Deployment } from '@kubernetes/client-node';
import { ResourceInstance } from '../services/resources/resources.ts';
class DeploymentInstance extends ResourceInstance<V1Deployment> {
public get ready() {
return this.exists && this.status?.readyReplicas === this.status?.replicas;
}
}
export { DeploymentInstance };

View File

@@ -1,12 +0,0 @@
import type { KubernetesObject } from '@kubernetes/client-node';
import { ResourceInstance } from '../services/resources/resources.instance.ts';
import type { K8SDestinationRuleV1 } from '../__generated__/resources/K8SDestinationRuleV1.ts';
class DestinationRuleInstance extends ResourceInstance<KubernetesObject & K8SDestinationRuleV1> {
public get ready() {
return this.exists;
}
}
export { DestinationRuleInstance };

View File

@@ -1,7 +0,0 @@
import type { environmentSpecSchema } from '../custom-resouces/environment/environment.schemas.ts';
import type { CustomResourceObject } from '../services/custom-resources/custom-resources.custom-resource.ts';
import { ResourceInstance } from '../services/resources/resources.instance.ts';
class EnvironmentInstance extends ResourceInstance<CustomResourceObject<typeof environmentSpecSchema>> {}
export { EnvironmentInstance };

View File

@@ -1,8 +0,0 @@
import type { KubernetesObject } from '@kubernetes/client-node';
import { ResourceInstance } from '../services/resources/resources.instance.ts';
import type { K8SGatewayV1 } from '../__generated__/resources/K8SGatewayV1.ts';
class GatewayInstance extends ResourceInstance<KubernetesObject & K8SGatewayV1> {}
export { GatewayInstance };

View File

@@ -1,12 +0,0 @@
import type { KubernetesObject } from '@kubernetes/client-node';
import { ResourceInstance } from '../services/resources/resources.ts';
import type { K8SGitRepositoryV1 } from '../__generated__/resources/K8SGitRepositoryV1.ts';
class GitRepoInstance extends ResourceInstance<KubernetesObject & K8SGitRepositoryV1> {
public get ready() {
return this.exists;
}
}
export { GitRepoInstance };

View File

@@ -1,12 +0,0 @@
import type { KubernetesObject } from '@kubernetes/client-node';
import { ResourceInstance } from '../services/resources/resources.ts';
import type { K8SHelmReleaseV2 } from '../__generated__/resources/K8SHelmReleaseV2.ts';
class HelmReleaseInstance extends ResourceInstance<KubernetesObject & K8SHelmReleaseV2> {
public get ready() {
return this.exists;
}
}
export { HelmReleaseInstance };

View File

@@ -1,16 +0,0 @@
import type { KubernetesObject } from '@kubernetes/client-node';
import { ResourceInstance } from '../services/resources/resources.ts';
import type { K8SHelmRepositoryV1 } from '../__generated__/resources/K8SHelmRepositoryV1.ts';
class HelmRepoInstance extends ResourceInstance<KubernetesObject & K8SHelmRepositoryV1> {
public get ready() {
if (!this.exists) {
return false;
}
const condition = this.getCondition('Ready');
return condition?.status === 'True';
}
}
export { HelmRepoInstance };

View File

@@ -1,7 +0,0 @@
import type { httpServiceSpecSchema } from '../custom-resouces/http-service/http-service.schemas.ts';
import type { CustomResourceObject } from '../services/custom-resources/custom-resources.custom-resource.ts';
import { ResourceInstance } from '../services/resources/resources.instance.ts';
class HttpServiceInstance extends ResourceInstance<CustomResourceObject<typeof httpServiceSpecSchema>> {}
export { HttpServiceInstance };

View File

@@ -1,11 +0,0 @@
import type { V1Namespace } from '@kubernetes/client-node';
import { ResourceInstance } from '../services/resources/resources.ts';
class NamespaceInstance extends ResourceInstance<V1Namespace> {
public get ready() {
return this.exists;
}
}
export { NamespaceInstance };

View File

@@ -1,7 +0,0 @@
import type { postgresClusterSpecSchema } from '../custom-resouces/postgres-cluster/postgres-cluster.schemas.ts';
import type { CustomResourceObject } from '../services/custom-resources/custom-resources.custom-resource.ts';
import { ResourceInstance } from '../services/resources/resources.instance.ts';
class PostgresClusterInstance extends ResourceInstance<CustomResourceObject<typeof postgresClusterSpecSchema>> {}
export { PostgresClusterInstance };

View File

@@ -1,24 +0,0 @@
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';
import { ResourceService } from '../services/resources/resources.ts';
import { SecretInstance } from './secret.ts';
class PostgresDatabaseInstance extends ResourceInstance<CustomResourceObject<typeof postgresDatabaseSpecSchema>> {
public get secret() {
const resourceService = this.services.get(ResourceService);
return resourceService.getInstance(
{
apiVersion: 'v1',
kind: 'Secret',
name: `${this.name}-postgres-database`,
namespace: this.namespace,
},
SecretInstance<typeof postgresClusterSecretSchema>,
);
}
}
export { PostgresDatabaseInstance };

View File

@@ -1,7 +0,0 @@
import type { CustomResourceObject } from '../services/custom-resources/custom-resources.custom-resource.ts';
import { ResourceInstance } from '../services/resources/resources.instance.ts';
import type { redisServerSpecSchema } from '../custom-resouces/redis-server/redis-server.schemas.ts';
class RedisServerInstance extends ResourceInstance<CustomResourceObject<typeof redisServerSpecSchema>> {}
export { RedisServerInstance };

View File

@@ -1,23 +0,0 @@
import type { V1Secret } from '@kubernetes/client-node';
import type { z, ZodObject } from 'zod';
import { ResourceInstance } from '../services/resources/resources.instance.ts';
import { decodeSecret, encodeSecret } from '../utils/secrets.ts';
class SecretInstance<T extends ZodObject = ExpectedAny> extends ResourceInstance<V1Secret> {
public get values() {
return decodeSecret(this.data) as z.infer<T>;
}
public ensureData = async (values: z.infer<T>) => {
await this.ensure({
data: encodeSecret(values as Record<string, string>),
});
};
public get ready() {
return this.exists;
}
}
export { SecretInstance };

View File

@@ -1,11 +0,0 @@
import type { V1Service } from '@kubernetes/client-node';
import { ResourceInstance } from '../services/resources/resources.ts';
class ServiceInstance extends ResourceInstance<V1Service> {
public get ready() {
return this.exists;
}
}
export { ServiceInstance };

View File

@@ -1,11 +0,0 @@
import type { V1StatefulSet } from '@kubernetes/client-node';
import { ResourceInstance } from '../services/resources/resources.instance.ts';
class StatefulSetInstance extends ResourceInstance<V1StatefulSet> {
public get ready() {
return this.exists && this.manifest?.status?.readyReplicas === this.manifest?.status?.replicas;
}
}
export { StatefulSetInstance };

View File

@@ -1,7 +0,0 @@
import type { V1StorageClass } from '@kubernetes/client-node';
import { ResourceInstance } from '../services/resources/resources.instance.ts';
class StorageClassInstance extends ResourceInstance<V1StorageClass> {}
export { StorageClassInstance };

View File

@@ -1,12 +0,0 @@
import type { KubernetesObject } from '@kubernetes/client-node';
import { ResourceInstance } from '../services/resources/resources.instance.ts';
import type { K8SVirtualServiceV1 } from '../__generated__/resources/K8SVirtualServiceV1.ts';
class VirtualServiceInstance extends ResourceInstance<KubernetesObject & K8SVirtualServiceV1> {
public get ready() {
return this.exists;
}
}
export { VirtualServiceInstance };

View File

@@ -0,0 +1,9 @@
import { Certificate } from './certificate/certificate.ts';
import type { ResourceClass } from '#services/resources/resources.ts';
const certManager = {
certificate: Certificate,
} satisfies Record<string, ResourceClass<ExpectedAny>>;
export { certManager };

View File

@@ -0,0 +1,29 @@
import type { KubernetesObject } from '@kubernetes/client-node';
import type { K8SCertificateV1 } from 'src/__generated__/resources/K8SCertificateV1.ts';
import { CRD } from '#resources/core/crd/crd.ts';
import { Resource, ResourceService, type ResourceOptions } from '#services/resources/resources.ts';
class Certificate extends Resource<KubernetesObject & K8SCertificateV1> {
public static readonly apiVersion = 'cert-manager.io/v1';
public static readonly kind = 'Certificate';
#crd: CRD;
constructor(options: ResourceOptions<KubernetesObject & K8SCertificateV1>) {
super(options);
const resourceService = this.services.get(ResourceService);
this.#crd = resourceService.get(CRD, 'certificates.cert-manager.io');
this.#crd.on('changed', this.#handleCrdChanged);
}
#handleCrdChanged = () => {
this.emit('changed');
};
public get hasCRD() {
return this.#crd.exists;
}
}
export { Certificate };

View File

@@ -0,0 +1,23 @@
import { CRD } from './crd/crd.ts';
import { Deployment } from './deployment/deployment.ts';
import { Namespace } from './namespace/namespace.ts';
import { PersistentVolume } from './pv/pv.ts';
import { PVC } from './pvc/pvc.ts';
import { Secret } from './secret/secret.ts';
import { Service } from './service/service.ts';
import { StatefulSet } from './stateful-set/stateful-set.ts';
import { StorageClass } from './storage-class/storage-class.ts';
const core = {
namespace: Namespace,
storageClass: StorageClass,
pvc: PVC,
pv: PersistentVolume,
secret: Secret,
crd: CRD,
service: Service,
deployment: Deployment,
statefulSet: StatefulSet,
};
export { core };

View File

@@ -0,0 +1,10 @@
import type { V1CustomResourceDefinition } from '@kubernetes/client-node';
import { Resource } from '#services/resources/resources.ts';
class CRD extends Resource<V1CustomResourceDefinition> {
public static readonly apiVersion = 'apiextensions.k8s.io/v1';
public static readonly kind = 'CustomResourceDefinition';
}
export { CRD };

View File

@@ -0,0 +1,10 @@
import type { V1Deployment } from '@kubernetes/client-node';
import { Resource } from '#services/resources/resources.ts';
class Deployment extends Resource<V1Deployment> {
public static readonly apiVersion = 'apps/v1';
public static readonly kind = 'Deployment';
}
export { Deployment };

View File

@@ -0,0 +1,10 @@
import type { V1Namespace } from '@kubernetes/client-node';
import { Resource } from '#services/resources/resources.ts';
class Namespace extends Resource<V1Namespace> {
public static readonly apiVersion = 'v1';
public static readonly kind = 'Namespace';
}
export { Namespace };

View File

@@ -0,0 +1,10 @@
import type { V1PersistentVolume } from '@kubernetes/client-node';
import { Resource } from '#services/resources/resources.ts';
class PersistentVolume extends Resource<V1PersistentVolume> {
public static readonly apiVersion = 'v1';
public static readonly kind = 'PersistentVolume';
}
export { PersistentVolume };

View File

@@ -0,0 +1,74 @@
import type { V1PersistentVolumeClaim } from '@kubernetes/client-node';
import { StorageClass } from '../storage-class/storage-class.ts';
import { PersistentVolume } from '../pv/pv.ts';
import { Resource, ResourceService } from '#services/resources/resources.ts';
const PROVISIONER = 'homelab-operator';
class PVC extends Resource<V1PersistentVolumeClaim> {
public static readonly apiVersion = 'v1';
public static readonly kind = 'PersistentVolumeClaim';
public reconcile = async () => {
const storageClassName = this.spec?.storageClassName;
if (!storageClassName) {
return;
}
const resourceService = this.services.get(ResourceService);
const storageClass = resourceService.get(StorageClass, storageClassName);
if (!storageClass.exists || storageClass.manifest?.provisioner !== PROVISIONER) {
return;
}
if (this.status?.phase === 'Pending' && !this.spec?.volumeName) {
await this.#provisionVolume(storageClass);
}
};
#provisionVolume = async (storageClass: StorageClass) => {
const pvName = `pv-${this.namespace}-${this.name}`;
const storageLocation = storageClass.manifest?.parameters?.storageLocation || '/data/volumes';
const target = `${storageLocation}/${this.namespace}/${this.name}`;
const resourceService = this.services.get(ResourceService);
const pv = resourceService.get(PersistentVolume, pvName);
await pv.ensure({
metadata: {
name: pvName,
labels: {
provisioner: PROVISIONER,
'pvc-namespace': this.namespace || 'default',
'pvc-name': this.name || 'unknown',
},
annotations: {
'pv.kubernetes.io/provisioned-by': PROVISIONER,
},
},
spec: {
hostPath: {
path: target,
type: 'DirectoryOrCreate',
},
capacity: {
storage: this.spec?.resources?.requests?.storage ?? '1Gi',
},
persistentVolumeReclaimPolicy: 'Retain',
accessModes: this.spec?.accessModes ?? ['ReadWriteOnce'],
storageClassName: this.spec?.storageClassName,
claimRef: {
uid: this.metadata?.uid,
resourceVersion: this.metadata?.resourceVersion,
apiVersion: this.apiVersion,
kind: 'PersistentVolumeClaim',
name: this.name,
namespace: this.namespace,
},
},
});
};
}
export { PVC, PROVISIONER };

View File

@@ -0,0 +1,25 @@
import type { KubernetesObject, V1Secret } from '@kubernetes/client-node';
import { Resource } from '#services/resources/resources.ts';
import { decodeSecret, encodeSecret } from '#utils/secrets.ts';
type SetOptions<T extends Record<string, string | undefined>> = T | ((current: T | undefined) => T | Promise<T>);
class Secret<T extends Record<string, string> = Record<string, string>> extends Resource<V1Secret> {
public static readonly apiVersion = 'v1';
public static readonly kind = 'Secret';
public get value() {
return decodeSecret(this.data) as T | undefined;
}
public set = async (options: SetOptions<T>, data?: KubernetesObject) => {
const value = typeof options === 'function' ? await Promise.resolve(options(this.value)) : options;
await this.ensure({
...data,
data: encodeSecret(value),
});
};
}
export { Secret };

View File

@@ -0,0 +1,14 @@
import type { V1Service } from '@kubernetes/client-node';
import { Resource } from '#services/resources/resources.ts';
class Service extends Resource<V1Service> {
public static readonly apiVersion = 'v1';
public static readonly kind = 'Service';
public get hostname() {
return `${this.name}.${this.namespace}.svc.cluster.local`;
}
}
export { Service };

View File

@@ -0,0 +1,10 @@
import type { V1StatefulSet } from '@kubernetes/client-node';
import { Resource } from '#services/resources/resources.ts';
class StatefulSet extends Resource<V1StatefulSet> {
public static readonly apiVersion = 'apps/v1';
public static readonly kind = 'StatefulSet';
}
export { StatefulSet };

View File

@@ -0,0 +1,11 @@
import type { V1StorageClass } from '@kubernetes/client-node';
import { Resource } from '#services/resources/resources.ts';
class StorageClass extends Resource<V1StorageClass> {
public static readonly apiVersion = 'storage.k8s.io/v1';
public static readonly kind = 'StorageClass';
public static readonly plural = 'storageclasses';
}
export { StorageClass };

View File

@@ -0,0 +1,11 @@
import { HelmRelease } from './helm-release/helm-release.ts';
import { HelmRepo } from './helm-repo/helm-repo.ts';
import type { ResourceClass } from '#services/resources/resources.ts';
const flux = {
helmRelease: HelmRelease,
helmRepo: HelmRepo,
} satisfies Record<string, ResourceClass<ExpectedAny>>;
export { flux };

View File

@@ -0,0 +1,42 @@
import type { KubernetesObject } from '@kubernetes/client-node';
import type { K8SHelmReleaseV2 } from 'src/__generated__/resources/K8SHelmReleaseV2.ts';
import { Resource } from '#services/resources/resources.ts';
type SetOptions = {
namespace?: string;
values?: Record<string, unknown>;
chart: {
name: string;
namespace?: string;
};
};
class HelmRelease extends Resource<KubernetesObject & K8SHelmReleaseV2> {
public static readonly apiVersion = 'helm.toolkit.fluxcd.io/v2';
public static readonly kind = 'HelmRelease';
public set = async (options: SetOptions) => {
return await this.ensure({
spec: {
targetNamespace: options.namespace,
interval: '1h',
values: options.values,
chart: {
spec: {
chart: 'cert-manager',
version: 'v1.18.2',
sourceRef: {
apiVersion: 'source.toolkit.fluxcd.io/v1',
kind: 'HelmRepository',
name: options.chart.name,
namespace: options.chart.namespace,
},
},
},
},
});
};
}
export { HelmRelease };

View File

@@ -0,0 +1,24 @@
import type { KubernetesObject } from '@kubernetes/client-node';
import type { K8SHelmRepositoryV1 } from 'src/__generated__/resources/K8SHelmRepositoryV1.ts';
import { Resource } from '#services/resources/resources.ts';
type SetOptions = {
url: string;
};
class HelmRepo extends Resource<KubernetesObject & K8SHelmRepositoryV1> {
public static readonly apiVersion = 'source.toolkit.fluxcd.io/v1';
public static readonly kind = 'HelmRepository';
public static readonly plural = 'helmrepositories';
public set = async ({ url }: SetOptions) => {
await this.ensure({
spec: {
interval: '1h',
url,
},
});
};
}
export { HelmRepo };

View File

@@ -0,0 +1,244 @@
import { z } from 'zod';
import { PostgresDatabase } from '../postgres-database/postgres-database.ts';
import { Environment } from '../environment/environment.ts';
import {
CustomResource,
ResourceReference,
ResourceService,
type CustomResourceOptions,
} from '#services/resources/resources.ts';
import { API_VERSION } from '#utils/consts.ts';
import { Secret } from '#resources/core/secret/secret.ts';
import { generateRandomHexPass } from '#utils/secrets.ts';
import { Service } from '#resources/core/service/service.ts';
import { HelmRelease } from '#resources/flux/helm-release/helm-release.ts';
import { RepoService } from '#bootstrap/repos/repos.ts';
import { VirtualService } from '#resources/istio/virtual-service/virtual-service.ts';
const specSchema = z.object({
environment: z.string(),
subdomain: z.string().optional(),
});
type SecretData = { url: string; host: string; token: string };
type InitSecretData = {
AUTHENTIK_BOOTSTRAP_TOKEN: string;
AUTHENTIK_BOOTSTRAP_PASSWORD: string;
AUTHENTIK_BOOTSTRAP_EMAIL: string;
AUTHENTIK_SECRET_KEY: string;
};
class AuthentikServer extends CustomResource<typeof specSchema> {
public static readonly apiVersion = API_VERSION;
public static readonly kind = 'AuthentikServer';
public static readonly spec = specSchema;
public static readonly scope = 'Namespaced';
#environment: ResourceReference<typeof Environment>;
#database: PostgresDatabase;
#secret: Secret<SecretData>;
#initSecret: Secret<InitSecretData>;
#service: Service;
#helmRelease: HelmRelease;
#virtualService: VirtualService;
constructor(options: CustomResourceOptions<typeof specSchema>) {
super(options);
const resourceService = this.services.get(ResourceService);
this.#environment = new ResourceReference();
this.#environment.on('changed', this.queueReconcile);
this.#database = resourceService.get(PostgresDatabase, this.name, this.namespace);
this.#database.on('changed', this.queueReconcile);
this.#secret = resourceService.get(Secret<SecretData>, this.name, this.namespace);
this.#secret.on('changed', this.queueReconcile);
this.#initSecret = resourceService.get(Secret<InitSecretData>, `${this.name}-init`, this.namespace);
this.#service = resourceService.get(Service, `${this.name}-server`, this.namespace);
// this.#service.on('changed', this.queueReconcile);
this.#helmRelease = resourceService.get(HelmRelease, this.name, this.namespace);
this.#helmRelease.on('changed', this.queueReconcile);
this.#virtualService = resourceService.get(VirtualService, this.name, this.namespace);
this.#virtualService.on('changed', this.queueReconcile);
}
public reconcile = async () => {
if (!this.spec) {
return;
}
const resourceService = this.services.get(ResourceService);
this.#environment.current = resourceService.get(Environment, this.spec.environment);
if (!this.#environment.current.spec) {
return;
}
await this.#database.ensure({
metadata: {
ownerReferences: [this.ref],
},
spec: {
environment: this.#environment.current.name,
},
});
const databaseSecret = this.#database.secret.value;
if (!databaseSecret) {
return;
}
await this.#initSecret.set(
(current) => ({
AUTHENTIK_BOOTSTRAP_EMAIL: 'admin@example.com',
AUTHENTIK_BOOTSTRAP_PASSWORD: generateRandomHexPass(24),
AUTHENTIK_BOOTSTRAP_TOKEN: generateRandomHexPass(32),
AUTHENTIK_SECRET_KEY: generateRandomHexPass(32),
...current,
}),
{
metadata: {
ownerReferences: [this.ref],
},
},
);
const initSecret = this.#initSecret.value;
if (!initSecret) {
return;
}
const domain = `${this.spec?.subdomain || 'authentik'}.${this.#environment.current.spec.domain}`;
await this.#secret.set(
{
url: `https://${domain}`,
host: this.#service.hostname,
token: initSecret.AUTHENTIK_BOOTSTRAP_TOKEN,
},
{
metadata: {
ownerReferences: [this.ref],
},
},
);
const secret = this.#secret.value;
if (!secret) {
return;
}
const repoService = this.services.get(RepoService);
await this.#helmRelease.ensure({
metadata: {
ownerReferences: [this.ref],
},
spec: {
interval: '60m',
chart: {
spec: {
chart: 'authentik',
version: '2025.6.4',
sourceRef: {
apiVersion: 'source.toolkit.fluxcd.io/v1',
kind: 'HelmRepository',
name: repoService.authentik.name,
namespace: repoService.authentik.namespace,
},
},
},
values: {
global: {
envFrom: [
{
secretRef: {
name: this.#initSecret.name,
},
},
],
},
authentik: {
error_reporting: {
enabled: false,
},
postgresql: {
host: databaseSecret.host,
name: databaseSecret.database,
user: databaseSecret.user,
password: 'file:///postgres-creds/password',
},
redis: {
host: this.#environment.current.redisServer.service.hostname,
},
},
server: {
volumes: [
{
name: 'postgres-creds',
secret: {
secretName: this.#database.secret.name,
},
},
],
volumeMounts: [
{
name: 'postgres-creds',
mountPath: '/postgres-creds',
readOnly: true,
},
],
},
worker: {
volumes: [
{
name: 'postgres-creds',
secret: {
secretName: this.#database.secret.name,
},
},
],
volumeMounts: [
{
name: 'postgres-creds',
mountPath: '/postgres-creds',
readOnly: true,
},
],
},
},
},
});
const gateway = this.#environment.current.gateway;
await this.#virtualService.ensure({
metadata: {
ownerReferences: [this.ref],
},
spec: {
gateways: [`${gateway.namespace}/${gateway.name}`],
hosts: [domain],
http: [
{
route: [
{
destination: {
host: this.#service.hostname,
port: {
number: 80,
},
},
},
],
},
],
},
});
};
}
export { AuthentikServer };

View File

@@ -0,0 +1,187 @@
import { z } from 'zod';
import { PostgresCluster } from '../postgres-cluster/postgres-cluster.ts';
import { RedisServer } from '../redis-server/redis-server.ts';
import { AuthentikServer } from '../authentik-server/authentik-server.ts';
import { CustomResource, ResourceService, type CustomResourceOptions } from '#services/resources/resources.ts';
import { API_VERSION } from '#utils/consts.ts';
import { Namespace } from '#resources/core/namespace/namespace.ts';
import { Certificate } from '#resources/cert-manager/certificate/certificate.ts';
import { StorageClass } from '#resources/core/storage-class/storage-class.ts';
import { PROVISIONER } from '#resources/core/pvc/pvc.ts';
import { Gateway } from '#resources/istio/gateway/gateway.ts';
const specSchema = z.object({
domain: z.string(),
tls: z.object({
issuer: z.string(),
}),
});
class Environment extends CustomResource<typeof specSchema> {
public static readonly apiVersion = API_VERSION;
public static readonly kind = 'Environment';
public static readonly spec = specSchema;
public static readonly scope = 'Cluster';
#namespace: Namespace;
#certificate: Certificate;
#storageClass: StorageClass;
#gateway: Gateway;
#postgresCluster: PostgresCluster;
#redisServer: RedisServer;
#authentikServer: AuthentikServer;
constructor(options: CustomResourceOptions<typeof specSchema>) {
super(options);
const resourceService = this.services.get(ResourceService);
this.#namespace = resourceService.get(Namespace, this.name);
this.#namespace.on('changed', this.queueReconcile);
this.#certificate = resourceService.get(Certificate, this.name, this.name);
this.#certificate.on('changed', this.queueReconcile);
this.#storageClass = resourceService.get(StorageClass, this.name);
this.#storageClass.on('changed', this.queueReconcile);
this.#postgresCluster = resourceService.get(PostgresCluster, `${this.name}-postgres-cluster`, this.name);
this.#postgresCluster.on('changed', this.queueReconcile);
this.#redisServer = resourceService.get(RedisServer, `${this.name}-redis-server`, this.name);
this.#redisServer.on('changed', this.queueReconcile);
this.#gateway = resourceService.get(Gateway, this.name, this.name);
this.#gateway.on('changed', this.queueReconcile);
this.#authentikServer = resourceService.get(AuthentikServer, `${this.name}-authentik`, this.name);
this.#authentikServer.on('changed', this.queueReconcile);
}
public get certificate() {
return this.#certificate;
}
public get storageClass() {
return this.#storageClass;
}
public get postgresCluster() {
return this.#postgresCluster;
}
public get redisServer() {
return this.#redisServer;
}
public get gateway() {
return this.#gateway;
}
public get authentikServer() {
return this.#authentikServer;
}
public reconcile = async () => {
const { data: spec, success } = specSchema.safeParse(this.spec);
if (!success || !spec) {
return;
}
await this.#namespace.ensure({
metadata: {
labels: {
'istio-injection': 'enabled',
},
},
});
if (this.#certificate.hasCRD) {
await this.#certificate.ensure({
metadata: {
ownerReferences: [this.ref],
},
spec: {
secretName: `${this.name}-tls`,
issuerRef: {
name: spec.tls.issuer,
kind: 'ClusterIssuer',
},
dnsNames: [`*.${spec.domain}`],
privateKey: {
rotationPolicy: 'Always',
},
},
});
}
await this.#storageClass.ensure({
metadata: {
ownerReferences: [this.ref],
},
provisioner: PROVISIONER,
reclaimPolicy: 'Retain',
});
await this.#postgresCluster.ensure({
metadata: {
ownerReferences: [this.ref],
},
spec: {
storageClass: this.name,
},
});
await this.#redisServer.ensure({
metadata: {
ownerReferences: [this.ref],
},
spec: {},
});
await this.#authentikServer.ensure({
metadata: {
ownerReferences: [this.ref],
},
spec: {
environment: this.name,
},
});
await this.#gateway.ensure({
metadata: {
ownerReferences: [this.ref],
},
spec: {
selector: {
istio: 'homelab-istio-gateway',
},
servers: [
{
hosts: [`*.${spec.domain}`],
port: {
name: 'http',
number: 80,
protocol: 'HTTP',
},
tls: {
httpsRedirect: true,
},
},
{
hosts: [`*.${spec.domain}`],
port: {
name: 'https',
number: 443,
protocol: 'HTTPS',
},
tls: {
mode: 'SIMPLE',
credentialName: `${this.name}-tls`,
},
},
],
},
});
};
}
export { Environment };

View File

@@ -0,0 +1,17 @@
import { Environment } from './environment/environment.ts';
import { PostgresCluster } from './postgres-cluster/postgres-cluster.ts';
import { RedisServer } from './redis-server/redis-server.ts';
import { PostgresDatabase } from './postgres-database/postgres-database.ts';
import { AuthentikServer } from './authentik-server/authentik-server.ts';
import type { InstallableResourceClass } from '#services/resources/resources.ts';
const homelab = {
PostgresCluster,
RedisServer,
Environment,
AuthentikServer,
PostgresDatabase,
} satisfies Record<string, InstallableResourceClass<ExpectedAny>>;
export { homelab };

View File

@@ -0,0 +1,172 @@
import { z } from 'zod';
import { Secret } from '#resources/core/secret/secret.ts';
import { StatefulSet } from '#resources/core/stateful-set/stateful-set.ts';
import { CustomResource, ResourceService, type CustomResourceOptions } from '#services/resources/resources.ts';
import { API_VERSION } from '#utils/consts.ts';
import { Service } from '#resources/core/service/service.ts';
import { generateRandomHexPass } from '#utils/secrets.ts';
const specSchema = z.object({
storageClass: z.string(),
storage: z
.object({
size: z.string().optional(),
})
.optional(),
});
type SecretData = {
host: string;
port: string;
user: string;
password: string;
database: string;
};
class PostgresCluster extends CustomResource<typeof specSchema> {
public static readonly apiVersion = API_VERSION;
public static readonly kind = 'PostgresCluster';
public static readonly spec = specSchema;
public static readonly scope = 'Namespaced';
#secret: Secret<SecretData>;
#statefulSet: StatefulSet;
#headlessService: Service;
#service: Service;
constructor(options: CustomResourceOptions<typeof specSchema>) {
super(options);
const resourceService = this.services.get(ResourceService);
this.#secret = resourceService.get(Secret<SecretData>, this.name, this.namespace);
this.#secret.on('changed', this.queueReconcile);
this.#statefulSet = resourceService.get(StatefulSet, this.name, this.namespace);
this.#statefulSet.on('changed', this.queueReconcile);
this.#service = resourceService.get(Service, this.name, this.namespace);
this.#service.on('changed', this.queueReconcile);
this.#headlessService = resourceService.get(Service, `${this.name}-headless`, this.namespace);
this.#headlessService.on('changed', this.queueReconcile);
}
public get secret() {
return this.#secret;
}
public get statefulSet() {
return this.#statefulSet;
}
public get headlessService() {
return this.#headlessService;
}
public get service() {
return this.#service;
}
public reconcile = async () => {
await this.#secret.set(
(current) => ({
password: generateRandomHexPass(16),
user: 'homelab',
database: 'homelab',
...current,
host: `${this.#service.name}.${this.#service.namespace}.svc.cluster.local`,
port: '5432',
}),
{
metadata: {
ownerReferences: [this.ref],
},
},
);
const secretName = this.#secret.name;
await this.#statefulSet.ensure({
metadata: {
ownerReferences: [this.ref],
},
spec: {
replicas: 1,
selector: {
matchLabels: {
app: this.name,
},
},
template: {
metadata: {
labels: {
app: this.name,
},
},
spec: {
containers: [
{
name: this.name,
image: 'postgres:17',
ports: [{ containerPort: 5432, name: 'postgres' }],
env: [
{ name: 'POSTGRES_PASSWORD', valueFrom: { secretKeyRef: { name: secretName, key: 'password' } } },
{ name: 'POSTGRES_USER', valueFrom: { secretKeyRef: { name: secretName, key: 'user' } } },
{ name: 'POSTGRES_DB', valueFrom: { secretKeyRef: { name: secretName, key: 'database' } } },
{ name: 'PGDATA', value: '/var/lib/postgresql/data/pgdata' },
],
volumeMounts: [{ name: this.name, mountPath: '/var/lib/postgresql/data' }],
},
],
},
},
volumeClaimTemplates: [
{
metadata: {
name: this.name,
ownerReferences: [this.ref],
},
spec: {
accessModes: ['ReadWriteOnce'],
storageClassName: this.spec?.storageClass,
resources: {
requests: {
storage: this.spec?.storage?.size || '1Gi',
},
},
},
},
],
},
});
await this.#headlessService.ensure({
metadata: {
ownerReferences: [this.ref],
},
spec: {
clusterIP: 'None',
selector: {
app: this.name,
},
ports: [{ name: 'postgres', port: 5432, targetPort: 5432 }],
},
});
await this.#service.ensure({
metadata: {
ownerReferences: [this.ref],
},
spec: {
type: 'ClusterIP',
selector: {
app: this.name,
},
ports: [{ name: 'postgres', port: 5432, targetPort: 5432 }],
},
});
};
}
export { PostgresCluster };

View File

@@ -0,0 +1,133 @@
import { z } from 'zod';
import { PostgresCluster } from '../postgres-cluster/postgres-cluster.ts';
import {
CustomResource,
ResourceReference,
ResourceService,
type CustomResourceOptions,
} from '#services/resources/resources.ts';
import { Secret } from '#resources/core/secret/secret.ts';
import { API_VERSION } from '#utils/consts.ts';
import { getWithNamespace } from '#utils/naming.ts';
import { PostgresService } from '#services/postgres/postgres.service.ts';
const specSchema = z.object({
environment: z.string().optional(),
cluster: z.string().optional(),
});
type SecretData = {
password: string;
user: string;
database: string;
host: string;
port: string;
};
const sanitizeName = (input: string) => {
return input.replace(/[^a-zA-Z0-9_]+/g, '_').toLowerCase();
};
class PostgresDatabase extends CustomResource<typeof specSchema> {
public static readonly apiVersion = API_VERSION;
public static readonly kind = 'PostgresDatabase';
public static readonly spec = specSchema;
public static readonly scope = 'Namespaced';
#cluster: ResourceReference<typeof PostgresCluster>;
#secret: Secret<SecretData>;
constructor(options: CustomResourceOptions<typeof specSchema>) {
super(options);
const resourceService = this.services.get(ResourceService);
this.#cluster = new ResourceReference();
this.#cluster.on('changed', this.queueReconcile);
this.#secret = resourceService.get(Secret<SecretData>, `${this.name}-pg-connection`, this.namespace);
this.#secret.on('changed', this.queueReconcile);
}
public get username() {
return sanitizeName(`${this.namespace}_${this.name}`);
}
public get database() {
return sanitizeName(`${this.namespace}_${this.name}`);
}
public get cluster() {
return this.#cluster;
}
public get secret() {
return this.#secret;
}
public reconcile = async () => {
const resourceService = this.services.get(ResourceService);
if (this.spec?.cluster) {
const clusterNames = getWithNamespace(this.spec.cluster, this.namespace);
this.#cluster.current = resourceService.get(PostgresCluster, clusterNames.name, clusterNames.namespace);
} else if (this.spec?.environment) {
const { Environment } = await import('../environment/environment.ts');
const environment = resourceService.get(Environment, this.spec.environment);
this.#cluster.current = environment.postgresCluster;
} else {
this.#cluster.current = undefined;
return;
}
const clusterSecret = this.#cluster.current.secret.value;
if (!clusterSecret) {
return;
}
await this.#secret.set(
(current) => ({
password: crypto.randomUUID(),
user: this.username,
database: this.database,
...current,
host: clusterSecret.host,
port: clusterSecret.port,
}),
{
metadata: {
ownerReferences: [this.ref],
},
},
);
const secret = this.#secret.value;
if (!secret) {
return;
}
const postgresService = this.services.get(PostgresService);
const database = postgresService.get({
host: clusterSecret.host,
port: clusterSecret.port ? Number(clusterSecret.port) : 5432,
database: clusterSecret.database,
user: clusterSecret.user,
password: clusterSecret.password,
});
const connectionError = await database.ping();
if (connectionError) {
console.error('Failed to connect', connectionError);
return;
}
await database.upsertRole({
name: secret.user,
password: secret.password,
});
await database.upsertDatabase({
name: secret.database,
owner: secret.user,
});
};
}
export { PostgresDatabase };

View File

@@ -0,0 +1,79 @@
import { z } from 'zod';
import { Deployment } from '#resources/core/deployment/deployment.ts';
import { Service } from '#resources/core/service/service.ts';
import { CustomResource, ResourceService, type CustomResourceOptions } from '#services/resources/resources.ts';
import { API_VERSION } from '#utils/consts.ts';
const specSchema = z.object({});
class RedisServer extends CustomResource<typeof specSchema> {
public static readonly apiVersion = API_VERSION;
public static readonly kind = 'RedisServer';
public static readonly spec = specSchema;
public static readonly scope = 'Namespaced';
#deployment: Deployment;
#service: Service;
constructor(options: CustomResourceOptions<typeof specSchema>) {
super(options);
const resourceService = this.services.get(ResourceService);
this.#deployment = resourceService.get(Deployment, this.name, this.namespace);
this.#service = resourceService.get(Service, this.name, this.namespace);
}
public get deployment() {
return this.#deployment;
}
public get service() {
return this.#service;
}
public reconcile = async () => {
await this.#deployment.ensure({
metadata: {
ownerReferences: [this.ref],
},
spec: {
replicas: 1,
selector: {
matchLabels: {
app: this.name,
},
},
template: {
metadata: {
labels: {
app: this.name,
},
},
spec: {
containers: [
{
name: this.name,
image: 'redis:latest',
ports: [{ containerPort: 6379 }],
},
],
},
},
},
});
await this.#service.ensure({
metadata: {
ownerReferences: [this.ref],
},
spec: {
selector: {
app: this.name,
},
ports: [{ port: 6379, targetPort: 6379 }],
},
});
};
}
export { RedisServer };

View File

@@ -0,0 +1,11 @@
import type { KubernetesObject } from '@kubernetes/client-node';
import type { K8SDestinationRuleV1 } from 'src/__generated__/resources/K8SDestinationRuleV1.ts';
import { Resource } from '#services/resources/resources.ts';
class DestinationRule extends Resource<KubernetesObject & K8SDestinationRuleV1> {
public static readonly apiVersion = 'networking.istio.io/v1';
public static readonly kind = 'DestinationRule';
}
export { DestinationRule };

View File

@@ -0,0 +1,11 @@
import type { KubernetesObject } from '@kubernetes/client-node';
import type { K8SGatewayV1 } from 'src/__generated__/resources/K8SGatewayV1.ts';
import { Resource } from '#services/resources/resources.ts';
class Gateway extends Resource<KubernetesObject & K8SGatewayV1> {
public static readonly apiVersion = 'networking.istio.io/v1';
public static readonly kind = 'Gateway';
}
export { Gateway };

View File

@@ -0,0 +1,11 @@
import { DestinationRule } from './destination-rule/destination-rule.ts';
import { Gateway } from './gateway/gateway.ts';
import { VirtualService } from './virtual-service/virtual-service.ts';
const istio = {
gateway: Gateway,
destinationRule: DestinationRule,
virtualService: VirtualService,
};
export { istio };

View File

@@ -0,0 +1,11 @@
import type { KubernetesObject } from '@kubernetes/client-node';
import type { K8SVirtualServiceV1 } from 'src/__generated__/resources/K8SVirtualServiceV1.ts';
import { Resource } from '#services/resources/resources.ts';
class VirtualService extends Resource<KubernetesObject & K8SVirtualServiceV1> {
public static readonly apiVersion = 'networking.istio.io/v1';
public static readonly kind = 'VirtualService';
}
export { VirtualService };

View File

@@ -0,0 +1,17 @@
import { core } from './core/core.ts';
import { flux } from './flux/flux.ts';
import { homelab } from './homelab/homelab.ts';
import { certManager } from './cert-manager/cert-manager.ts';
import { istio } from './istio/istio.ts';
import type { ResourceClass } from '#services/resources/resources.ts';
const resources = {
...core,
...flux,
...certManager,
...istio,
...homelab,
} satisfies Record<string, ResourceClass<ExpectedAny>>;
export { resources };

View File

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

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

@@ -1,109 +0,0 @@
import { EventEmitter } from 'eventemitter3';
import equal from 'deep-equal';
import type { CustomResource } from './custom-resources.custom-resource.ts';
import type { CustomResourceStatus } from './custom-resources.types.ts';
type CustomResourceStatusOptions = {
resource: CustomResource<ExpectedAny>;
};
type CustomResourceConditionsEvents = {
changed: (type: string, condition: Condition) => void;
};
type Condition = {
lastTransitionTime: Date;
status: 'True' | 'False' | 'Unknown';
syncing?: boolean;
failed?: boolean;
resource?: boolean;
reason?: string;
message?: string;
observedGeneration?: number;
};
class CustomResourceConditions extends EventEmitter<CustomResourceConditionsEvents> {
#options: CustomResourceStatusOptions;
#conditions: Record<string, Condition>;
#changed: boolean;
constructor(options: CustomResourceStatusOptions) {
super();
this.#options = options;
this.#conditions = Object.fromEntries(
(options.resource.status?.conditions || []).map(({ type, lastTransitionTime, ...condition }) => [
type,
{
...condition,
lastTransitionTime: new Date(lastTransitionTime),
},
]),
);
options.resource.on('changed', this.#handleChange);
this.#changed = false;
}
#handleChange = () => {
const { resource } = this.#options;
for (const { type, ...condition } of resource.status?.conditions || []) {
const next = {
...condition,
lastTransitionTime: new Date(condition.lastTransitionTime),
};
const current = this.#conditions[type];
const isEqual = equal(current, next);
const isNewer = !current || next.lastTransitionTime > current.lastTransitionTime;
if (isEqual || !isNewer) {
return;
}
this.#conditions[type] = next;
this.emit('changed', type, next);
}
};
public get = (type: string): Condition | undefined => {
return this.#conditions[type];
};
public set = async (type: string, condition: Omit<Condition, 'lastTransitionTime'>) => {
const current = this.#conditions[type];
const isEqual = equal(
{ ...current, lastTransitionTime: undefined },
{ ...condition, lastTransitionTime: undefined },
);
if (isEqual) {
return;
}
this.#changed = true;
this.#conditions[type] = {
...condition,
lastTransitionTime: current && current.status === condition.status ? current.lastTransitionTime : new Date(),
observedGeneration: this.#options.resource.metadata?.generation,
};
await this.save();
};
public save = async () => {
if (!this.#changed) {
return;
}
try {
this.#changed = false;
const { resource } = this.#options;
const status: CustomResourceStatus = {
conditions: Object.entries(this.#conditions).map(([type, condition]) => ({
...condition,
type,
lastTransitionTime: condition.lastTransitionTime.toISOString(),
})),
};
await resource.patchStatus(status);
} catch (error) {
this.#changed = true;
throw error;
}
};
}
export { CustomResourceConditions };

View File

@@ -1,222 +0,0 @@
import type { z, ZodObject } from 'zod';
import { ApiException, PatchStrategy, setHeaderOptions, type KubernetesObject } from '@kubernetes/client-node';
import { EventEmitter } from 'eventemitter3';
import type { Resource } from '../resources/resources.resource.ts';
import type { Services } from '../../utils/service.ts';
import { K8sService } from '../k8s/k8s.ts';
import { CoalescingQueued } from '../../utils/queues.ts';
import type { CustomResourceDefinition, CustomResourceStatus } from './custom-resources.types.ts';
import { CustomResourceConditions } from './custom-resources.conditions.ts';
type CustomResourceObject<TSpec extends ZodObject> = KubernetesObject & {
spec: z.infer<TSpec>;
status?: CustomResourceStatus;
};
type CustomResourceOptions<TSpec extends ZodObject> = {
resource: Resource<CustomResourceObject<TSpec>>;
services: Services;
definition: CustomResourceDefinition<TSpec>;
};
type CustomResourceEvents<TSpec extends ZodObject> = {
changed: () => void;
changedStatus: (options: { previous: CustomResourceStatus; next: CustomResourceStatus }) => void;
changedMetadate: (options: { previous: KubernetesObject['metadata']; next: KubernetesObject['metadata'] }) => void;
changedSpec: (options: { previous: z.infer<TSpec>; next: z.infer<TSpec> }) => void;
};
type SubresourceResult = {
ready: boolean;
syncing?: boolean;
failed?: boolean;
reason?: string;
message?: string;
};
abstract class CustomResource<TSpec extends ZodObject> extends EventEmitter<CustomResourceEvents<TSpec>> {
#options: CustomResourceOptions<TSpec>;
#conditions: CustomResourceConditions;
#queue: CoalescingQueued<void>;
constructor(options: CustomResourceOptions<TSpec>) {
super();
this.#options = options;
this.#conditions = new CustomResourceConditions({
resource: this,
});
options.resource.on('changed', this.#handleChanged);
this.#queue = new CoalescingQueued({
action: async () => {
if (this.exists && !this.isValidSpec) {
this.services.log.error(
`Invalid spec for ${this.apiVersion}/${this.kind}/${this.namespace}/${this.name}`,
this.spec,
);
return;
}
console.log('Reconcileing', this.apiVersion, this.kind, this.namespace, this.name);
await this.reconcile?.();
},
});
}
public get conditions() {
return this.#conditions;
}
public get names() {
return this.#options.definition.names;
}
public get services() {
const { services } = this.#options;
return services;
}
public get resource() {
const { resource } = this.#options;
return resource;
}
public get apiVersion() {
return this.resource.apiVersion;
}
public get kind() {
return this.resource.kind;
}
public get metadata(): KubernetesObject['metadata'] {
const metadata = this.resource.metadata;
return (
metadata || {
name: this.name,
namespace: this.namespace,
}
);
}
public get name() {
return this.resource.specifier.name;
}
public get namespace() {
const namespace = this.resource.specifier.namespace;
if (!namespace) {
throw new Error('Custom resources needs a namespace');
}
return namespace;
}
public get exists() {
return this.resource.exists;
}
public get ref() {
return this.resource.ref;
}
public get spec(): z.infer<TSpec> {
return this.resource.spec as ExpectedAny;
}
public get status() {
return this.resource.manifest?.status;
}
public get isSeen() {
return this.metadata?.generation === this.status?.observedGeneration;
}
public get isValidSpec() {
const { success } = this.#options.definition.spec.safeParse(this.spec);
return success;
}
public setup?: () => Promise<void>;
public reconcile?: () => Promise<void>;
public markSeen = async () => {
if (this.isSeen) {
return;
}
await this.patchStatus({
observedGeneration: this.metadata?.generation,
});
};
public queueReconcile = async () => {
return this.#queue.run();
};
#handleChanged = () => {
this.emit('changed');
};
public reconcileSubresource = async (name: string, action: () => Promise<SubresourceResult>) => {
try {
const result = await action();
await this.conditions.set(name, {
status: result.ready ? 'True' : 'False',
syncing: result.syncing,
failed: result.failed ?? false,
resource: true,
reason: result.reason,
message: result.message,
});
} catch (err) {
console.error(err);
await this.conditions.set(name, {
status: 'False',
failed: true,
reason: 'Failed',
resource: true,
message: err instanceof Error ? err.message : String(err),
});
}
};
public markNotReady = async (reason?: string, message?: string) => {
await this.conditions.set('Ready', {
status: 'False',
reason,
message,
});
};
public markReady = async () => {
await this.conditions.set('Ready', {
status: 'True',
});
};
public patchStatus = async (status: Partial<CustomResourceStatus>) => {
const k8s = this.services.get(K8sService);
const [group, version] = this.apiVersion?.split('/') || [];
try {
await k8s.customObjectsApi.patchNamespacedCustomObjectStatus(
{
group,
version,
plural: this.names.plural,
name: this.name,
namespace: this.namespace,
body: {
status,
},
},
setHeaderOptions('Content-Type', PatchStrategy.MergePatch),
);
} catch (err) {
if (err instanceof ApiException && err.code === 404) {
return;
}
throw err;
}
};
}
export { CustomResource, type CustomResourceOptions, type CustomResourceObject, type SubresourceResult };

View File

@@ -1,128 +0,0 @@
import { ApiException, type KubernetesObject } from '@kubernetes/client-node';
import type { ZodObject } from 'zod';
import type { Services } from '../../utils/service.ts';
import type { Resource } from '../resources/resources.resource.ts';
import { WatcherService } from '../watchers/watchers.ts';
import { K8sService } from '../k8s/k8s.ts';
import { Queue } from '../queue/queue.ts';
import type { CustomResourceDefinition } from './custom-resources.types.ts';
import type { CustomResource } from './custom-resources.custom-resource.ts';
import { createManifest } from './custom-resources.utils.ts';
type DefinitionItem = {
definition: CustomResourceDefinition<ExpectedAny>;
queue: Queue;
};
class CustomResourceService {
#services: Services;
#definitions: DefinitionItem[];
#resources: Map<string, CustomResource<ExpectedAny>>;
constructor(services: Services) {
this.#definitions = [];
this.#resources = new Map();
this.#services = services;
}
#handleChanged = async (resource: Resource<KubernetesObject>) => {
const uid = resource.metadata?.uid;
if (!uid) {
return;
}
let current = this.#resources.get(uid);
if (!current) {
const entry = this.#definitions.find(
({ definition: r }) =>
r.version === resource.version &&
r.group === resource.group &&
r.version === resource.version &&
r.kind === resource.kind,
);
if (!entry) {
return;
}
const { definition } = entry;
current = definition.create({
resource: resource as Resource<ExpectedAny>,
services: this.#services,
definition,
});
this.#resources.set(uid, current);
await current.setup?.();
if (!current.isSeen) {
await current.markSeen();
}
await current.queueReconcile();
} else if (!current.isSeen) {
await current.markSeen();
await current.queueReconcile();
}
};
public register = (...resources: CustomResourceDefinition<ExpectedAny>[]) => {
this.#definitions.push(
...resources.map((definition) => ({
definition,
queue: new Queue(),
})),
);
};
public install = async (replace = false) => {
const k8sService = this.#services.get(K8sService);
for (const { definition: crd } of this.#definitions) {
this.#services.log.info('Installing CRD', { kind: crd.kind });
try {
const manifest = createManifest(crd);
try {
await k8sService.extensionsApi.createCustomResourceDefinition({
body: manifest,
});
} catch (error) {
if (error instanceof ApiException && error.code === 409) {
if (replace) {
await k8sService.extensionsApi.patchCustomResourceDefinition({
name: manifest.metadata.name,
body: [{ op: 'replace', path: '/spec', value: manifest.spec }],
});
}
continue;
}
throw error;
}
} catch (error) {
if (error instanceof ApiException) {
throw new Error(`Failed to install ${crd.kind}: ${error.body}`);
}
throw error;
}
}
};
public watch = async () => {
const watcherService = this.#services.get(WatcherService);
for (const { definition, queue } of this.#definitions) {
const watcher = watcherService.create({
path: `/apis/${definition.group}/${definition.version}/${definition.names.plural}`,
list: (k8s) =>
k8s.customObjectsApi.listCustomObjectForAllNamespaces({
version: definition.version,
group: definition.group,
plural: definition.names.plural,
}),
verbs: ['add', 'update', 'delete'],
});
watcher.on('changed', (resource) => {
queue.add(() => this.#handleChanged(resource));
});
await watcher.start();
}
};
}
const createCustomResourceDefinition = <TSpec extends ZodObject>(options: CustomResourceDefinition<TSpec>) => options;
export { CustomResourceService, createCustomResourceDefinition };

View File

@@ -1,38 +0,0 @@
import { z, type ZodObject } from 'zod';
import type { CustomResource, CustomResourceOptions } from './custom-resources.custom-resource.ts';
type CustomResourceDefinition<TSpec extends ZodObject> = {
group: string;
version: string;
kind: string;
names: {
plural: string;
singular: string;
};
spec: TSpec;
create: (options: CustomResourceOptions<TSpec>) => CustomResource<TSpec>;
};
const customResourceStatusSchema = z.object({
observedGeneration: z.number().optional(),
conditions: z
.array(
z.object({
observedGeneration: z.number().optional(),
type: z.string(),
status: z.enum(['True', 'False', 'Unknown']),
lastTransitionTime: z.string().datetime(),
resource: z.boolean().optional(),
failed: z.boolean().optional(),
syncing: z.boolean().optional(),
reason: z.string().optional().optional(),
message: z.string().optional().optional(),
}),
)
.optional(),
});
type CustomResourceStatus = z.infer<typeof customResourceStatusSchema>;
export { customResourceStatusSchema, type CustomResourceDefinition, type CustomResourceStatus };

View File

@@ -8,7 +8,7 @@ type PostgresInstanceOptions = {
services: Services; services: Services;
host: string; host: string;
port?: number; port?: number;
username: string; user: string;
password: string; password: string;
database?: string; database?: string;
}; };
@@ -20,10 +20,10 @@ class PostgresInstance {
this.#db = knex({ this.#db = knex({
client: 'pg', client: 'pg',
connection: { connection: {
host: process.env.FORCE_PG_HOST ?? options.host, host: options.host,
user: process.env.FORCE_PG_USER ?? options.username, user: options.user,
password: process.env.FORCE_PG_PASSWORD ?? options.password, password: options.password,
port: process.env.FORCE_PG_PORT ? parseInt(process.env.FORCE_PG_PORT) : options.port, port: options.port,
database: options.database, database: options.database,
}, },
}); });
@@ -32,29 +32,32 @@ class PostgresInstance {
public ping = async () => { public ping = async () => {
try { try {
await this.#db.raw('SELECT 1'); await this.#db.raw('SELECT 1');
return true; return;
} catch { } catch (err) {
return false; return err;
} }
}; };
public upsertRole = async (role: PostgresRole) => { public upsertRole = async (role: PostgresRole) => {
const existingRole = await this.#db.raw('SELECT 1 FROM pg_roles WHERE rolname = ?', [role.name]); const name = role.name;
const existingRole = await this.#db.raw('SELECT 1 FROM pg_roles WHERE rolname = ?', [name]);
if (existingRole.rows.length === 0) { if (existingRole.rows.length === 0) {
await this.#db.raw(`CREATE ROLE "${role.name}" WITH LOGIN PASSWORD '${role.password}'`); await this.#db.raw(`CREATE ROLE "${name}" WITH LOGIN PASSWORD '${role.password}'`);
} else { } else {
await this.#db.raw(`ALTER ROLE "${role.name}" WITH PASSWORD '${role.password}'`); await this.#db.raw(`ALTER ROLE "${name}" WITH PASSWORD '${role.password}'`);
} }
}; };
public upsertDatabase = async (database: PostgresDatabase) => { public upsertDatabase = async (database: PostgresDatabase) => {
const existingDatabase = await this.#db.raw('SELECT * FROM pg_database WHERE datname = ?', [database.name]); const owner = database.owner;
const name = database.name;
const existingDatabase = await this.#db.raw('SELECT * FROM pg_database WHERE datname = ?', [name]);
if (existingDatabase.rows.length === 0) { if (existingDatabase.rows.length === 0) {
await this.#db.raw(`CREATE DATABASE "${database.name}" OWNER "${database.owner}"`); await this.#db.raw(`CREATE DATABASE "${name}" OWNER "${owner}"`);
} else { } else {
await this.#db.raw(`ALTER DATABASE "${database.name}" OWNER TO "${database.owner}"`); await this.#db.raw(`ALTER DATABASE "${name}" OWNER TO "${owner}"`);
} }
}; };
} }

View File

@@ -0,0 +1,34 @@
import { z, type ZodType } from 'zod';
import type { KubernetesObject } from '@kubernetes/client-node';
import { Resource, type ResourceOptions } from './resource.ts';
import { API_VERSION } from '#utils/consts.ts';
const customResourceStatusSchema = z.object({
observedGeneration: z.number().optional(),
conditions: z
.array(
z.object({
observedGeneration: z.number().optional(),
type: z.string(),
status: z.enum(['True', 'False', 'Unknown']),
lastTransitionTime: z.string().datetime(),
resource: z.boolean().optional(),
failed: z.boolean().optional(),
syncing: z.boolean().optional(),
reason: z.string().optional().optional(),
message: z.string().optional().optional(),
}),
)
.optional(),
});
type CustomResourceOptions<TSpec extends ZodType> = ResourceOptions<KubernetesObject & { spec: z.infer<TSpec> }>;
class CustomResource<TSpec extends ZodType> extends Resource<KubernetesObject & { spec: z.infer<TSpec> }> {
public static readonly apiVersion = API_VERSION;
public static readonly status = customResourceStatusSchema;
}
export { CustomResource, type CustomResourceOptions };

View File

@@ -0,0 +1,38 @@
import { EventEmitter } from 'eventemitter3';
import type { ResourceClass } from '../resources.ts';
import type { ResourceEvents } from './resource.ts';
class ResourceReference<T extends ResourceClass<ExpectedAny>> extends EventEmitter<ResourceEvents> {
#current?: InstanceType<T>;
constructor(current?: InstanceType<T>) {
super();
this.#current = current;
}
public get current() {
return this.#current;
}
public set current(value: InstanceType<T> | undefined) {
const previous = this.#current;
if (this.#current) {
this.#current.off('changed', this.#handleChange);
}
if (value) {
value.on('changed', this.#handleChange);
}
this.#current = value;
if (previous !== value) {
this.emit('changed');
}
}
#handleChange = () => {
this.emit('changed');
};
}
export { ResourceReference };

View File

@@ -7,6 +7,8 @@ import { Queue } from '../../queue/queue.ts';
import { K8sService } from '../../k8s/k8s.ts'; import { K8sService } from '../../k8s/k8s.ts';
import { isDeepSubset } from '../../../utils/objects.ts'; import { isDeepSubset } from '../../../utils/objects.ts';
import { CoalescingQueued } from '#utils/queues.ts';
type ResourceSelector = { type ResourceSelector = {
apiVersion: string; apiVersion: string;
kind: string; kind: string;
@@ -27,6 +29,7 @@ type ResourceEvents = {
class Resource<T extends KubernetesObject> extends EventEmitter<ResourceEvents> { class Resource<T extends KubernetesObject> extends EventEmitter<ResourceEvents> {
#manifest?: T; #manifest?: T;
#queue: Queue; #queue: Queue;
#reconcileQueue: CoalescingQueued<void>;
#options: ResourceOptions<T>; #options: ResourceOptions<T>;
constructor(options: ResourceOptions<T>) { constructor(options: ResourceOptions<T>) {
@@ -34,6 +37,29 @@ class Resource<T extends KubernetesObject> extends EventEmitter<ResourceEvents>
this.#options = options; this.#options = options;
this.#manifest = options.manifest; this.#manifest = options.manifest;
this.#queue = new Queue({ concurrency: 1 }); this.#queue = new Queue({ concurrency: 1 });
this.#reconcileQueue = new CoalescingQueued({
action: async () => {
try {
if (!this.exists || !this.reconcile) {
return;
}
console.log('Reconcileing', this.apiVersion, this.kind, this.namespace, this.name);
await this.reconcile?.();
} catch (err) {
console.error(err);
}
},
});
this.on('changed', this.queueReconcile);
}
public reconcile?: () => Promise<void>;
public queueReconcile = () => {
return this.#reconcileQueue.run();
};
public get services() {
return this.#options.services;
} }
public get manifest() { public get manifest() {
@@ -169,4 +195,4 @@ class Resource<T extends KubernetesObject> extends EventEmitter<ResourceEvents>
}; };
} }
export { Resource, type ResourceOptions }; export { Resource, type ResourceOptions, type ResourceEvents };

View File

@@ -1,43 +1,128 @@
import type { KubernetesObject } from '@kubernetes/client-node'; import { ApiException, type KubernetesObject } from '@kubernetes/client-node';
import type { ZodType } from 'zod';
import type { Services } from '../../utils/service.ts'; import type { Services } from '../../utils/service.ts';
import { WatcherService } from '../watchers/watchers.ts'; import { WatcherService } from '../watchers/watchers.ts';
import type { Resource, ResourceOptions } from './resource/resource.ts'; import { Resource, type ResourceOptions } from './resource/resource.ts';
import { createManifest } from './resources.utils.ts';
type ResourceClass<T extends KubernetesObject> = new (options: ResourceOptions<T>) => Resource<T>; import { K8sService } from '#services/k8s/k8s.ts';
type RegisterOptions<T extends KubernetesObject> = { type ResourceClass<T extends KubernetesObject> = (new (options: ResourceOptions<T>) => Resource<T>) & {
apiVersion: string; apiVersion: string;
kind: string; kind: string;
plural?: string; plural?: string;
type: ResourceClass<T>; };
type InstallableResourceClass<T extends KubernetesObject> = ResourceClass<T> & {
spec: ZodType;
status: ZodType;
scope: 'Namespaced' | 'Cluster';
}; };
class ResourceService { class ResourceService {
#services: Services; #services: Services;
#registry: Map<Resource<ExpectedAny>, Resource<ExpectedAny>[]>; #registry: Map<
ResourceClass<ExpectedAny>,
{
apiVersion: string;
kind: string;
plural?: string;
resources: Resource<ExpectedAny>[];
}
>;
constructor(services: Services) { constructor(services: Services) {
this.#services = services; this.#services = services;
this.#registry = new Map(); this.#registry = new Map();
} }
public register = async <T extends KubernetesObject>(options: RegisterOptions<T>) => { public register = async (...resources: ResourceClass<ExpectedAny>[]) => {
const watcherService = this.#services.get(WatcherService); for (const resource of resources) {
const watcher = watcherService.create({}); if (!this.#registry.has(resource)) {
watcher.on('changed', (manifest) => { this.#registry.set(resource, {
const { name, namespace } = manifest.metadata || {}; apiVersion: resource.apiVersion,
if (!name) { kind: resource.kind,
return; plural: resource.plural,
resources: [],
});
} }
const current = this.get(options.type, name, namespace); const watcherService = this.#services.get(WatcherService);
current.manifest = manifest; const watcher = watcherService.create({
}); ...resource,
await watcher.start(); verbs: ['add', 'update', 'delete'],
});
watcher.on('changed', (manifest) => {
const { name, namespace } = manifest.metadata || {};
if (!name) {
return;
}
const current = this.get(resource, name, namespace);
current.manifest = manifest;
});
await watcher.start();
}
}; };
public get = <T extends KubernetesObject>(type: ResourceClass<T>, name: string, namespace?: string) => {}; public get = <T extends ResourceClass<ExpectedAny>>(type: T, name: string, namespace?: string) => {
let resourceRegistry = this.#registry.get(type);
if (!resourceRegistry) {
resourceRegistry = {
apiVersion: type.apiVersion,
kind: type.kind,
plural: type.plural,
resources: [],
};
this.#registry.set(type, resourceRegistry);
}
const { resources, apiVersion, kind } = resourceRegistry;
let current = resources.find((resource) => resource.name === name && resource.namespace === namespace);
if (!current) {
current = new type({
selector: {
apiVersion,
kind,
name,
namespace,
},
services: this.#services,
});
resources.push(current);
}
return current as InstanceType<T>;
};
public install = async (...resources: InstallableResourceClass<ExpectedAny>[]) => {
const k8sService = this.#services.get(K8sService);
for (const resource of resources) {
this.#services.log.info('Installing CRD', { kind: resource.kind });
try {
const manifest = createManifest(resource);
try {
await k8sService.extensionsApi.createCustomResourceDefinition({
body: manifest,
});
} catch (error) {
if (error instanceof ApiException && error.code === 409) {
await k8sService.extensionsApi.patchCustomResourceDefinition({
name: manifest.metadata.name,
body: [{ op: 'replace', path: '/spec', value: manifest.spec }],
});
continue;
}
throw error;
}
} catch (error) {
if (error instanceof ApiException) {
throw new Error(`Failed to install ${resource.kind}: ${error.body}`);
}
throw error;
}
}
};
} }
export { ResourceService }; export { CustomResource, type CustomResourceOptions } from './resource/resource.custom.ts';
export { ResourceReference } from './resource/resource.reference.ts';
export { ResourceService, Resource, type ResourceOptions, type ResourceClass, type InstallableResourceClass };

View File

@@ -1,25 +1,27 @@
import { z } from 'zod'; import { z } from 'zod';
import { customResourceStatusSchema, type CustomResourceDefinition } from './custom-resources.types.ts'; import type { InstallableResourceClass } from './resources.ts';
const createManifest = (defintion: CustomResourceDefinition<ExpectedAny>) => { const createManifest = (defintion: InstallableResourceClass<ExpectedAny>) => {
const plural = defintion.plural ?? defintion.kind.toLowerCase() + 's';
const [version, group] = defintion.apiVersion.split('/').toReversed();
return { return {
apiVersion: 'apiextensions.k8s.io/v1', apiVersion: 'apiextensions.k8s.io/v1',
kind: 'CustomResourceDefinition', kind: 'CustomResourceDefinition',
metadata: { metadata: {
name: `${defintion.names.plural}.${defintion.group}`, name: `${plural}.${group}`,
}, },
spec: { spec: {
group: defintion.group, group: group,
names: { names: {
kind: defintion.kind, kind: defintion.kind,
plural: defintion.names.plural, plural: plural,
singular: defintion.names.singular, singular: defintion.kind.toLowerCase(),
}, },
scope: 'Namespaced', scope: defintion.scope,
versions: [ versions: [
{ {
name: defintion.version, name: version,
served: true, served: true,
storage: true, storage: true,
schema: { schema: {
@@ -27,12 +29,12 @@ const createManifest = (defintion: CustomResourceDefinition<ExpectedAny>) => {
type: 'object', type: 'object',
properties: { properties: {
spec: { spec: {
...z.toJSONSchema(defintion.spec.strict(), { io: 'input' }), ...z.toJSONSchema(defintion.spec, { io: 'input' }),
$schema: undefined, $schema: undefined,
additionalProperties: undefined, additionalProperties: undefined,
} as ExpectedAny, } as ExpectedAny,
status: { status: {
...z.toJSONSchema(customResourceStatusSchema.strict(), { io: 'input' }), ...z.toJSONSchema(defintion.status, { io: 'input' }),
$schema: undefined, $schema: undefined,
additionalProperties: undefined, additionalProperties: undefined,
} as ExpectedAny, } as ExpectedAny,

View File

@@ -1,83 +0,0 @@
import type { KubernetesObject } from '@kubernetes/client-node';
import { isDeepSubset } from '../../utils/objects.ts';
import { ResourceReference } from './resources.ref.ts';
abstract class ResourceInstance<T extends KubernetesObject> extends ResourceReference<T> {
public get resource() {
if (!this.current) {
throw new Error('Instance needs a resource');
}
return this.current;
}
public get services() {
return this.resource.services;
}
public get exists() {
return this.resource.exists;
}
public get manifest() {
return this.resource.manifest;
}
public get apiVersion() {
return this.resource.apiVersion;
}
public get kind() {
return this.resource.kind;
}
public get name() {
return this.resource.name;
}
public get namespace() {
return this.resource.namespace;
}
public get metadata() {
return this.resource.metadata;
}
public get spec() {
return this.resource.spec;
}
public get data() {
return this.resource.data;
}
public get status() {
return this.resource.status;
}
public patch = this.resource.patch;
public reload = this.resource.load;
public delete = this.resource.delete;
public ensure = async (manifest: T) => {
if (isDeepSubset(this.manifest, manifest)) {
return false;
}
await this.patch(manifest);
return true;
};
public get ready() {
return this.exists;
}
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);
};
}
export { ResourceInstance };

View File

@@ -1,85 +0,0 @@
import type { KubernetesObject } from '@kubernetes/client-node';
import { EventEmitter } from 'eventemitter3';
import type { Resource } from './resources.ts';
import type { ResourceEvents } from './resources.resource.ts';
type ResourceReferenceEvents<T extends KubernetesObject> = ResourceEvents<T> & {
replaced: (options: { previous: Resource<T> | undefined; next: Resource<T> | undefined }) => void;
};
class ResourceReference<T extends KubernetesObject = KubernetesObject> extends EventEmitter<
ResourceReferenceEvents<T>
> {
#current?: Resource<T>;
#updatedEvent: ResourceEvents<T>['updated'];
#changedEvent: ResourceEvents<T>['changed'];
#changedMetadateEvent: ResourceEvents<T>['changedMetadate'];
#changedSpecEvent: ResourceEvents<T>['changedSpec'];
#changedStatusEvent: ResourceEvents<T>['changedStatus'];
#deletedEvent: ResourceEvents<T>['deleted'];
constructor(current?: Resource<T>) {
super();
this.#updatedEvent = this.emit.bind(this, 'updated');
this.#changedEvent = this.emit.bind(this, 'changed');
this.#changedMetadateEvent = this.emit.bind(this, 'changedMetadate');
this.#changedSpecEvent = this.emit.bind(this, 'changedSpec');
this.#changedStatusEvent = this.emit.bind(this, 'changedStatus');
this.#deletedEvent = this.emit.bind(this, 'deleted');
this.current = current;
}
public get services() {
return this.#current?.services;
}
public get current() {
return this.#current;
}
public set current(next: Resource<T> | undefined) {
const previous = this.#current;
if (next === previous) {
return;
}
if (this.#current) {
this.#current.off('updated', this.#updatedEvent);
this.#current.off('changed', this.#changedEvent);
this.#current.off('changedMetadate', this.#changedMetadateEvent);
this.#current.off('changedSpec', this.#changedSpecEvent);
this.#current.off('changedStatus', this.#changedStatusEvent);
this.#current.off('deleted', this.#deletedEvent);
}
if (next) {
next.on('updated', this.#updatedEvent);
next.on('changed', this.#changedEvent);
next.on('changedMetadate', this.#changedMetadateEvent);
next.on('changedSpec', this.#changedSpecEvent);
next.on('changedStatus', this.#changedStatusEvent);
next.on('deleted', this.#deletedEvent);
}
this.#current = next;
this.emit('replaced', {
previous,
next,
});
this.emit('changedStatus', {
previous: previous && 'status' in previous ? (previous.status as ExpectedAny) : undefined,
next: next && 'status' in next ? (next.status as ExpectedAny) : undefined,
});
this.emit('changedMetadate', {
previous: previous && 'metadata' in previous ? (previous.metadata as ExpectedAny) : undefined,
next: next && 'metadata' in next ? (next.metadata as ExpectedAny) : undefined,
});
this.emit('changedSpec', {
previous: previous && 'spec' in previous ? (previous.spec as ExpectedAny) : undefined,
next: next && 'spec' in next ? (next.spec as ExpectedAny) : undefined,
});
this.emit('changed');
this.emit('updated');
}
}
export { ResourceReference };

View File

@@ -1,309 +0,0 @@
import { ApiException, PatchStrategy, V1MicroTime, type KubernetesObject } from '@kubernetes/client-node';
import { EventEmitter } from 'eventemitter3';
import equal from 'deep-equal';
import { Services } from '../../utils/service.ts';
import { K8sService } from '../k8s/k8s.ts';
import { Queue } from '../queue/queue.ts';
import { GROUP } from '../../utils/consts.ts';
import { ResourceService } from './resources.ts';
type ResourceOptions<T extends KubernetesObject> = {
services: Services;
manifest?: T;
data: {
apiVersion: string;
kind: string;
name: string;
namespace?: string;
};
};
type UnknownResource = KubernetesObject & {
spec: ExpectedAny;
data: ExpectedAny;
};
type EventOptions = {
reason: string;
message: string;
action: string;
type: 'Normal' | 'Warning' | 'Error';
};
type ResourceEvents<T extends KubernetesObject> = {
updated: () => void;
deleted: () => void;
changed: () => void;
changedStatus: (options: {
previous: T extends { status: infer K } ? K | undefined : never;
next: T extends { status: infer K } ? K | undefined : never;
}) => void;
changedMetadate: (options: { previous: T['metadata'] | undefined; next: T['metadata'] | undefined }) => void;
changedSpec: (options: {
previous: T extends { spec: infer K } ? K | undefined : never;
next: T extends { spec: infer K } ? K | undefined : never;
}) => void;
};
class Resource<T extends KubernetesObject = UnknownResource> extends EventEmitter<ResourceEvents<T>> {
#options: ResourceOptions<T>;
#queue: Queue;
constructor(options: ResourceOptions<T>) {
super();
this.#options = options;
this.#queue = new Queue({ concurrency: 1 });
}
public get services() {
return this.#options.services;
}
public get specifier() {
return this.#options.data;
}
public get manifest() {
return this.#options?.manifest;
}
public set manifest(obj: T | undefined) {
if (equal(obj, this.manifest)) {
return;
}
this.#options.manifest = obj;
const nextManifest = obj || {};
const currentManifest = this.manifest || {};
const nextStatus = 'status' in nextManifest ? nextManifest.status : undefined;
const currentStatus = 'status' in currentManifest ? currentManifest.status : undefined;
if (!equal(nextStatus, currentStatus)) {
this.emit('changedStatus', {
previous: currentStatus as ExpectedAny,
next: nextStatus as ExpectedAny,
});
}
const nextSpec = 'spec' in nextManifest ? nextManifest.spec : undefined;
const currentSpec = 'spec' in currentManifest ? currentManifest.spec : undefined;
if (!equal(nextSpec, currentSpec)) {
this.emit('changedSpec', {
next: nextSpec as ExpectedAny,
previous: currentSpec as ExpectedAny,
});
}
const nextMetadata = 'metadata' in nextManifest ? nextManifest.metadata : undefined;
const currentMetadata = 'metadata' in currentManifest ? currentManifest.metadata : undefined;
if (!equal(nextMetadata, currentMetadata)) {
this.emit('changedMetadate', {
next: nextMetadata as ExpectedAny,
previous: currentMetadata as ExpectedAny,
});
}
this.emit('updated');
this.emit('changed');
}
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 exists() {
return !!this.manifest;
}
public get apiVersion() {
return this.#options.data.apiVersion;
}
public get group() {
const [group] = this.apiVersion?.split('/') || [];
return group;
}
public get version() {
const [, version] = this.apiVersion?.split('/') || [];
return version;
}
public get kind() {
return this.#options.data.kind;
}
public get metadata() {
return this.manifest?.metadata;
}
public get name() {
return this.#options.data.name;
}
public get namespace() {
return this.#options.data.namespace;
}
public get spec(): T extends { spec?: infer K } ? K | undefined : never {
if (this.manifest && 'spec' in this.manifest) {
return this.manifest.spec as ExpectedAny;
}
return undefined as ExpectedAny;
}
public get data(): T extends { data?: infer K } ? K | undefined : never {
if (this.manifest && 'data' in this.manifest) {
return this.manifest.data as ExpectedAny;
}
return undefined as ExpectedAny;
}
public get status(): T extends { status?: infer K } ? K | undefined : never {
if (this.manifest && 'status' in this.manifest) {
return this.manifest.status as ExpectedAny;
}
return undefined as ExpectedAny;
}
public get owners() {
const { services } = this.#options;
const references = this.metadata?.ownerReferences || [];
const resourceService = services.get(ResourceService);
return references.map((ref) =>
resourceService.get({
apiVersion: ref.apiVersion,
kind: ref.kind,
name: ref.name,
namespace: this.namespace,
}),
);
}
public patch = (patch: T) =>
this.#queue.add(async () => {
const { services } = this.#options;
services.log.debug(`Patching ${this.apiVersion}/${this.kind}/${this.namespace}/${this.name}`, {
specifier: this.specifier,
current: this.manifest,
patch,
});
const k8s = services.get(K8sService);
const body = {
...patch,
apiVersion: this.specifier.apiVersion,
kind: this.specifier.kind,
metadata: {
...patch.metadata,
name: this.specifier.name,
namespace: this.specifier.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 delete = () =>
this.#queue.add(async () => {
try {
const { services } = this.#options;
services.log.debug(`Deleting ${this.apiVersion}/${this.kind}/${this.namespace}/${this.name}`);
const k8s = services.get(K8sService);
await k8s.objectsApi.delete({
apiVersion: this.specifier.apiVersion,
kind: this.specifier.kind,
metadata: {
name: this.specifier.name,
namespace: this.specifier.namespace,
},
});
this.manifest = undefined;
} catch (err) {
if (err instanceof ApiException && err.code === 404) {
return;
}
throw err;
}
});
public load = () =>
this.#queue.add(async () => {
const { services } = this.#options;
const k8s = services.get(K8sService);
try {
const manifest = await k8s.objectsApi.read({
apiVersion: this.specifier.apiVersion,
kind: this.specifier.kind,
metadata: {
name: this.specifier.name,
namespace: this.specifier.namespace,
},
});
this.manifest = manifest as T;
} catch (err) {
if (err instanceof ApiException && err.code === 404) {
this.manifest = undefined;
} else {
throw err;
}
}
});
public addEvent = (event: EventOptions) =>
this.#queue.add(async () => {
const { services } = this.#options;
const k8sService = services.get(K8sService);
services.log.debug(`Adding event ${this.apiVersion}/${this.kind}/${this.namespace}/${this.name}`, event);
await k8sService.eventsApi.createNamespacedEvent({
namespace: this.specifier.namespace || 'default',
body: {
kind: 'Event',
metadata: {
name: `${this.specifier.name}-${Date.now()}-${Buffer.from(crypto.getRandomValues(new Uint8Array(8))).toString('hex')}`,
namespace: this.specifier.namespace,
},
eventTime: new V1MicroTime(),
note: event.message,
action: event.action,
reason: event.reason,
type: event.type,
reportingController: GROUP,
reportingInstance: this.name,
regarding: {
apiVersion: this.specifier.apiVersion,
resourceVersion: this.metadata?.resourceVersion,
kind: this.specifier.kind,
name: this.specifier.name,
namespace: this.specifier.namespace,
uid: this.metadata?.uid,
},
},
});
});
}
export { Resource, type UnknownResource, type ResourceEvents };

View File

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

@@ -1,100 +0,0 @@
import type { V1Secret } from '@kubernetes/client-node';
import type { z, ZodObject } from 'zod';
import deepEqual from 'deep-equal';
import { ResourceService, type Resource } from '../resources/resources.ts';
import type { Services } from '../../utils/service.ts';
type EnsuredSecretOptions<T extends ZodObject> = {
services: Services;
name: string;
namespace: string;
schema: T;
owner?: ExpectedAny[];
generator: (previous?: unknown) => z.infer<T>;
validate?: (value: T) => boolean;
};
class EnsuredSecret<T extends ZodObject> {
#options: EnsuredSecretOptions<T>;
#resource: Resource<V1Secret>;
constructor(options: EnsuredSecretOptions<T>) {
this.#options = options;
const { services, name, namespace } = options;
const resourceService = services.get(ResourceService);
this.#resource = resourceService.get({
apiVersion: 'v1',
kind: 'Secret',
name,
namespace,
});
this.#resource.on('changed', this.#handleChanged);
this.#handleChanged();
}
public get name() {
return this.#options.name;
}
public get namespace() {
return this.#options.namespace;
}
public get resource() {
return this.#resource;
}
public get value(): z.infer<T> | undefined {
if (!this.#resource.data) {
return undefined;
}
return Object.fromEntries(
Object.entries(this.#resource.data).map(([name, value]) => [name, Buffer.from(value, 'base64').toString('utf8')]),
) as ExpectedAny;
}
public patch = async (value: ExpectedAny) => {
const patched = {
...this.value,
...value,
};
if (deepEqual(patched, this.value)) {
return;
}
await this.resource.patch({
data: patched,
});
};
public get isValid() {
const { schema, validate } = this.#options;
const { success } = schema.safeParse(this.value);
if (!success) {
return false;
}
if (validate) {
return validate(this.value as unknown as T);
}
return true;
}
#handleChanged = () => {
const { generator, owner } = this.#options;
if (this.isValid && deepEqual(this.#resource.metadata?.ownerReferences, owner)) {
return;
}
const data = generator();
const encodedValues = Object.fromEntries(
Object.entries(data).map(([name, value]) => [name, Buffer.from(String(value)).toString('base64')]),
);
this.#resource.patch({
metadata: {
ownerReferences: owner,
},
data: encodedValues,
});
};
}
export { EnsuredSecret, type EnsuredSecretOptions };

View File

@@ -1,22 +0,0 @@
import type { ZodObject } from 'zod';
import type { Services } from '../../utils/service.ts';
import { EnsuredSecret, type EnsuredSecretOptions } from './secrets.secret.ts';
class SecretService {
#services: Services;
constructor(services: Services) {
this.#services = services;
}
public ensure = <T extends ZodObject>(options: Omit<EnsuredSecretOptions<T>, 'services'>) => {
return new EnsuredSecret({
...options,
services: this.#services,
});
};
}
export { SecretService };

Some files were not shown because too many files have changed in this diff Show More