lot more stuff

This commit is contained in:
Morten Olsen
2025-08-04 23:44:14 +02:00
parent daf0ea21bb
commit 757b2fcfac
185 changed files with 115899 additions and 1874 deletions

View File

@@ -0,0 +1,176 @@
import type { CustomResourceObject } from '../../services/custom-resources/custom-resources.custom-resource.ts';
import { API_VERSION, CONTROLLED_LABEL } from '../../utils/consts.ts';
import type { domainServiceSpecSchema } from '../domain-service/domain-service.schemas.ts';
type CreateContainerManifestOptions = {
name: string;
namespace: string;
command: string;
owner: ExpectedAny;
secret: string;
bootstrap: {
email: string;
password: string;
token: string;
};
posgtres: {
host: string;
port: string;
name: string;
user: string;
password: string;
};
redis: {
host: string;
port: string;
};
};
const createManifest = (options: CreateContainerManifestOptions) => ({
apiVersion: 'apps/v1',
kind: 'Deployment',
metadata: {
name: options.name,
namespace: options.namespace,
labels: {
'app.kubernetes.io/name': options.name,
...CONTROLLED_LABEL,
},
ownerReferences: [options.owner],
},
spec: {
replicas: 1,
selector: {
matchLabels: {
'app.kubernetes.io/name': options.name,
},
},
template: {
metadata: {
labels: {
'app.kubernetes.io/name': options.name,
},
},
spec: {
containers: [
{
name: options.name,
image: 'ghcr.io/goauthentik/server:2025.6.4',
args: [options.command],
env: [
{ name: 'AUTHENTIK_SECRET_KEY', value: options.secret },
{ name: 'AUTHENTIK_POSTGRESQL__HOST', value: options.posgtres.host },
{
name: 'AUTHENTIK_POSTGRESQL__PORT',
value: '5432',
},
{
name: 'AUTHENTIK_POSTGRESQL__NAME',
value: options.posgtres.name,
},
{
name: 'AUTHENTIK_POSTGRESQL__USER',
value: options.posgtres.user,
},
{
name: 'AUTHENTIK_POSTGRESQL__PASSWORD',
value: options.posgtres.password,
},
{
name: 'AUTHENTIK_REDIS__HOST',
value: options.redis.host,
},
{
name: 'AUTHENTIK_REDIS__PORT',
value: options.redis.port,
},
{
name: 'AUTHENTIK_BOOTSTRAP_PASSWORD',
value: options.bootstrap.password,
},
{
name: 'AUTHENTIK_BOOTSTRAP_TOKEN',
value: options.bootstrap.token,
},
{
name: 'AUTHENTIK_BOOTSTRAP_EMAIL',
value: options.bootstrap.email,
},
],
ports: [
{
name: 'http',
containerPort: 9000,
protocol: 'TCP',
},
],
},
],
},
},
},
});
type CreateServiceManifestOptions = {
name: string;
namespace: string;
owner: ExpectedAny;
appName: string;
};
const createServiceManifest = (options: CreateServiceManifestOptions) => ({
apiVersion: 'v1',
kind: 'Service',
metadata: {
name: options.name,
namespace: options.namespace,
labels: {
...CONTROLLED_LABEL,
},
ownerReferences: [options.owner],
},
spec: {
type: 'ClusterIP',
ports: [
{
port: 9000,
targetPort: 9000,
protocol: 'TCP',
name: 'http',
},
],
selector: {
'app.kubernetes.io/name': options.appName,
},
},
});
type CreateDomainServiceOptions = {
name: string;
namespace: string;
owner: ExpectedAny;
subdomain: string;
host: string;
domain: string;
};
const createDomainService = (
options: CreateDomainServiceOptions,
): Omit<CustomResourceObject<typeof domainServiceSpecSchema>, 'status'> => ({
apiVersion: API_VERSION,
kind: 'DomainService',
metadata: {
name: options.name,
namespace: options.namespace,
ownerReferences: [options.owner],
},
spec: {
domain: options.domain,
subdomain: options.subdomain,
destination: {
host: options.host,
port: {
number: 9000,
},
},
},
});
export { createManifest, createServiceManifest, createDomainService };

View File

@@ -0,0 +1,385 @@
import type { V1Service, V1Deployment, V1Secret } from '@kubernetes/client-node';
import { z } from 'zod';
import deepEqual from 'deep-equal';
import {
CustomResource,
type CustomResourceObject,
type CustomResourceOptions,
type SubresourceResult,
} from '../../services/custom-resources/custom-resources.custom-resource.ts';
import { ResourceReference } from '../../services/resources/resources.ref.ts';
import { ResourceService, type Resource } from '../../services/resources/resources.ts';
import type { domainSpecSchema } from '../domain/domain.schemas.ts';
import type { domainServiceSpecSchema } from '../domain-service/domain-service.schemas.ts';
import type { EnsuredSecret } from '../../services/secrets/secrets.secret.ts';
import { getWithNamespace } from '../../utils/naming.ts';
import { API_VERSION } from '../../utils/consts.ts';
import { SecretService } from '../../services/secrets/secrets.ts';
import { decodeSecret } from '../../utils/secrets.ts';
import type { postgresDatabaseSecretSchema } from '../postgres-database/postgres-database.resource.ts';
import type { redisConnectionSpecSchema } from '../redis-connection/redis-connection.schemas.ts';
import { authentikServerSecretSchema, type authentikServerSpecSchema } from './authentik-server.scemas.ts';
import { createDomainService, createManifest, createServiceManifest } from './authentik-server.create-manifests.ts';
class AuthentikServerResource extends CustomResource<typeof authentikServerSpecSchema> {
#domainResource: ResourceReference<CustomResourceObject<typeof domainSpecSchema>>;
#databaseSecretResource: ResourceReference<V1Secret>;
#redisResource: ResourceReference<CustomResourceObject<typeof redisConnectionSpecSchema>>;
#redisSecretResource: ResourceReference<V1Secret>;
#deploymentServerResource: Resource<V1Deployment>;
#deploymentWorkerResource: Resource<V1Deployment>;
#service: Resource<V1Service>;
#domainServiceResource: Resource<CustomResourceObject<typeof domainServiceSpecSchema>>;
#secret: EnsuredSecret<typeof authentikServerSecretSchema>;
constructor(options: CustomResourceOptions<typeof authentikServerSpecSchema>) {
super(options);
const domainNames = getWithNamespace(this.spec.domain, this.namespace);
const databaseNames = getWithNamespace(this.spec.database, this.namespace);
const resourceService = this.services.get(ResourceService);
const secretService = this.services.get(SecretService);
this.#domainResource = new ResourceReference();
this.#databaseSecretResource = new ResourceReference();
this.#redisResource = new ResourceReference();
this.#redisSecretResource = new ResourceReference();
this.#deploymentServerResource = resourceService.get({
apiVersion: 'apps/v1',
kind: 'Deployment',
name: this.#serverName,
namespace: this.namespace,
});
this.#deploymentWorkerResource = resourceService.get({
apiVersion: 'apps/v1',
kind: 'Deployment',
name: this.#workerName,
namespace: this.namespace,
});
this.#domainServiceResource = resourceService.get({
apiVersion: API_VERSION,
kind: 'DomainService',
name: this.name,
namespace: this.namespace,
});
this.#service = resourceService.get({
apiVersion: 'v1',
kind: 'Service',
name: this.name,
namespace: this.namespace,
});
this.#secret = secretService.ensure({
name: this.name,
namespace: this.namespace,
schema: authentikServerSecretSchema,
generator: () => ({
secret: crypto.randomUUID(),
token: crypto.randomUUID(),
password: crypto.randomUUID(),
}),
});
this.#domainServiceResource = resourceService.get({
apiVersion: API_VERSION,
kind: 'DomainService',
name: this.name,
namespace: this.namespace,
});
this.#updateResources();
this.#domainResource.on('changed', this.queueReconcile);
this.#databaseSecretResource.on('changed', this.queueReconcile);
this.#redisResource.on('changed', this.queueReconcile);
this.#redisSecretResource.on('changed', this.queueReconcile);
this.#deploymentServerResource.on('changed', this.queueReconcile);
this.#deploymentWorkerResource.on('changed', this.queueReconcile);
this.#domainServiceResource.on('changed', this.queueReconcile);
this.#service.on('changed', this.queueReconcile);
this.#secret.resouce.on('changed', this.queueReconcile);
}
get #databaseSecretName() {
const { name } = getWithNamespace(this.spec.database);
return `postgres-database-${name}`;
}
get #workerName() {
return `${this.name}-worker`;
}
get #serverName() {
return `${this.name}-server`;
}
#updateResources = () => {
if (!this.isValidSpec) {
return;
}
const resourceService = this.services.get(ResourceService);
const redisNames = getWithNamespace(this.spec.redis, this.namespace);
const redisResource = resourceService.get<CustomResourceObject<typeof redisConnectionSpecSchema>>({
apiVersion: API_VERSION,
kind: 'RedisConnection',
name: redisNames.name,
namespace: redisNames.namespace,
});
this.#redisResource.current = redisResource;
const redis = this.#redisResource.current;
if (redis.exists && redis.spec) {
const redisSecretNames = getWithNamespace(redis.spec.secret, redis.namespace);
this.#redisSecretResource.current = resourceService.get({
apiVersion: 'v1',
kind: 'Secret',
name: redisSecretNames.name,
namespace: redisSecretNames.namespace,
});
} else {
this.#redisSecretResource.current = undefined;
}
const domainNames = getWithNamespace(this.spec.domain, this.namespace);
const databaseNames = getWithNamespace(this.spec.database, this.namespace);
this.#domainResource.current = resourceService.get({
apiVersion: API_VERSION,
kind: 'Domain',
name: domainNames.name,
namespace: domainNames.namespace,
});
this.#databaseSecretResource.current = resourceService.get({
apiVersion: 'v1',
kind: 'Secret',
name: this.#databaseSecretName,
namespace: databaseNames.namespace,
});
};
#reconcileWorkerDeployment = async (): Promise<SubresourceResult> => {
const domainService = this.#domainResource.current;
if (!domainService?.exists || !domainService.spec) {
return {
ready: false,
failed: true,
reason: 'MissingDomain',
};
}
const databaseSecret = decodeSecret<z.infer<typeof postgresDatabaseSecretSchema>>(
this.#databaseSecretResource.current?.data,
);
if (!databaseSecret) {
return {
ready: false,
failed: true,
reason: 'MissingDatabase',
};
}
const secret = this.#secret.value;
if (!this.#secret.isValid || !secret) {
return {
ready: false,
syncing: true,
reason: 'WaitingForSecret',
};
}
const redisSecret = decodeSecret(this.#redisSecretResource.current?.data);
if (!redisSecret || !redisSecret.host) {
return {
ready: false,
failed: true,
reason: 'MissingRedisSecret',
};
}
const email = `admin@${domainService.spec.hostname}`;
const manifest = createManifest({
name: this.#workerName,
namespace: this.namespace,
secret: secret.secret,
command: 'worker',
owner: this.ref,
bootstrap: {
email,
token: secret.token,
password: secret.password,
},
redis: {
host: redisSecret.host,
port: redisSecret.port ?? '6379',
},
posgtres: {
host: databaseSecret.host,
port: databaseSecret.port || '5432',
name: databaseSecret.database,
user: databaseSecret.user,
password: databaseSecret.password,
},
});
if (!deepEqual(this.#deploymentWorkerResource.spec, manifest.spec)) {
await this.#deploymentWorkerResource.patch(manifest);
return {
ready: false,
syncing: true,
reason: 'ManifestNeedsPatching',
};
}
return {
ready: true,
};
};
#reconcileServerDeployment = async (): Promise<SubresourceResult> => {
const domainService = this.#domainResource.current;
if (!domainService?.exists || !domainService.spec) {
return {
ready: false,
failed: true,
reason: 'MissingDomain',
};
}
const databaseSecret = decodeSecret<z.infer<typeof postgresDatabaseSecretSchema>>(
this.#databaseSecretResource.current?.data,
);
if (!databaseSecret) {
return {
ready: false,
failed: true,
reason: 'MissingDatabase',
};
}
const secret = this.#secret.value;
if (!this.#secret.isValid || !secret) {
return {
ready: false,
syncing: true,
reason: 'WaitingForSecret',
};
}
const redisSecret = decodeSecret(this.#redisSecretResource.current?.data);
if (!redisSecret || !redisSecret.host) {
return {
ready: false,
failed: true,
reason: 'MissingRedisSecret',
};
}
const email = `admin@${domainService.spec.hostname}`;
const manifest = createManifest({
name: this.#serverName,
namespace: this.namespace,
secret: secret.secret,
command: 'server',
owner: this.ref,
bootstrap: {
email,
token: secret.token,
password: secret.password,
},
redis: {
host: redisSecret.host,
port: redisSecret.port ?? '6379',
},
posgtres: {
host: databaseSecret.host,
port: databaseSecret.port || '5432',
name: databaseSecret.database,
user: databaseSecret.user,
password: databaseSecret.password,
},
});
if (!deepEqual(this.#deploymentServerResource.spec, manifest.spec)) {
await this.#deploymentServerResource.patch(manifest);
return {
ready: false,
syncing: true,
reason: 'ManifestNeedsPatching',
};
}
return {
ready: true,
};
};
#reconcileService = async (): Promise<SubresourceResult> => {
const manifest = createServiceManifest({
name: this.name,
namespace: this.namespace,
owner: this.ref,
appName: this.#serverName,
});
if (!deepEqual(this.#service.manifest, manifest.spec)) {
await this.#service.patch(manifest);
return {
ready: false,
syncing: true,
reason: 'UpdatingManifest',
};
}
return {
ready: true,
};
};
#reconcileDomainService = async (): Promise<SubresourceResult> => {
const manifest = createDomainService({
name: this.name,
namespace: this.namespace,
owner: this.ref,
domain: this.spec.domain,
host: `${this.name}.${this.namespace}.svc.cluster.local`,
subdomain: 'authentik',
});
if (!deepEqual(manifest.spec, this.#domainServiceResource.spec)) {
await this.#domainServiceResource.patch(manifest);
return {
ready: false,
syncing: true,
reason: 'UpdatingManifest',
};
}
return {
ready: true,
};
};
public reconcile = async () => {
if (!this.isValidSpec) {
await this.conditions.set('Ready', {
status: 'False',
reason: 'Invalid spec',
});
}
this.#updateResources();
await Promise.allSettled([
this.reconcileSubresource('Worker', this.#reconcileWorkerDeployment),
this.reconcileSubresource('Server', this.#reconcileServerDeployment),
this.reconcileSubresource('Service', this.#reconcileService),
this.reconcileSubresource('DomainService', this.#reconcileDomainService),
]);
const workerReady = this.conditions.get('Worker')?.status === 'True';
const serverReady = this.conditions.get('Server')?.status === 'True';
const serviceReady = this.conditions.get('Service')?.status === 'True';
const domainServiceReady = this.conditions.get('DomainService')?.status === 'True';
await this.conditions.set('Ready', {
status: workerReady && serverReady && serviceReady && domainServiceReady ? 'True' : 'False',
});
};
}
export { AuthentikServerResource };

View File

@@ -0,0 +1,15 @@
import { z } from 'zod';
const authentikServerSpecSchema = z.object({
domain: z.string(),
database: z.string(),
redis: z.string(),
});
const authentikServerSecretSchema = z.object({
secret: z.string(),
password: z.string(),
token: z.string(),
});
export { authentikServerSpecSchema, authentikServerSecretSchema };

View File

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

View File

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

View File

@@ -0,0 +1,86 @@
import type { KubernetesObject } from '@kubernetes/client-node';
import type { K8SVirtualServiceV1 } from '../../__generated__/resources/K8SVirtualServiceV1.ts';
import type { K8SDestinationRuleV1 } from '../../__generated__/resources/K8SDestinationRuleV1.ts';
import { CONTROLLED_LABEL } from '../../utils/consts.ts';
type CreateVirtualServiceManifestOptions = {
name: string;
namespace: string;
owner: ExpectedAny;
host: string;
gateway: string;
destination: {
host: string;
port: {
number?: number;
name?: string;
};
};
};
const createVirtualServiceManifest = (
options: CreateVirtualServiceManifestOptions,
): KubernetesObject & K8SVirtualServiceV1 => ({
apiVersion: 'networking.istio.io/v1',
kind: 'VirtualService',
metadata: {
name: options.name,
namespace: options.namespace,
ownerReferences: [options.owner],
labels: {
...CONTROLLED_LABEL,
},
},
spec: {
hosts: [options.host],
gateways: [options.gateway],
http: [
{
match: [
{
uri: {
prefix: '/',
},
},
],
route: [
{
destination: {
host: options.destination.host,
port: options.destination.port,
},
},
],
},
],
},
});
type CreateDestinationRuleManifestOptions = {
name: string;
namespace: string;
host: string;
};
const createDestinationRuleManifest = (
options: CreateDestinationRuleManifestOptions,
): KubernetesObject & K8SDestinationRuleV1 => ({
apiVersion: 'networking.istio.io/v1',
kind: 'DestinationRule',
metadata: {
name: options.name,
namespace: options.namespace,
labels: {
...CONTROLLED_LABEL,
},
},
spec: {
host: options.host,
trafficPolicy: {
tls: {
mode: 'DISABLE',
},
},
},
});
export { createVirtualServiceManifest, createDestinationRuleManifest };

View File

@@ -0,0 +1,169 @@
import type { KubernetesObject } from '@kubernetes/client-node';
import deepEqual from 'deep-equal';
import type { K8SVirtualServiceV1 } from '../../__generated__/resources/K8SVirtualServiceV1.ts';
import {
CustomResource,
type CustomResourceObject,
type CustomResourceOptions,
type SubresourceResult,
} from '../../services/custom-resources/custom-resources.custom-resource.ts';
import { ResourceReference, ResourceService, type Resource } from '../../services/resources/resources.ts';
import type { K8SDestinationRuleV1 } from '../../__generated__/resources/K8SDestinationRuleV1.ts';
import type { domainSpecSchema } from '../domain/domain.schemas.ts';
import { getWithNamespace } from '../../utils/naming.ts';
import { GROUP } from '../../utils/consts.ts';
import type { domainServiceSpecSchema } from './domain-service.schemas.ts';
import { createDestinationRuleManifest, createVirtualServiceManifest } from './domain-service.create-manifests.ts';
const VIRTUAL_SERVICE_CONDITION = 'VirtualService';
const DESTINAION_RULE_CONDITION = 'DestinationRule';
class DomainServiceResource extends CustomResource<typeof domainServiceSpecSchema> {
#virtualServiceResource: Resource<KubernetesObject & K8SVirtualServiceV1>;
#virtualServiceCRDResource: Resource<KubernetesObject>;
#destinationRuleResource: Resource<KubernetesObject & K8SDestinationRuleV1>;
#destinationRuleCRDResource: Resource<KubernetesObject>;
#domainResource: ResourceReference<CustomResourceObject<typeof domainSpecSchema>>;
constructor(options: CustomResourceOptions<typeof domainServiceSpecSchema>) {
super(options);
const resourceService = this.services.get(ResourceService);
this.#virtualServiceResource = resourceService.get({
apiVersion: 'networking.istio.io/v1',
kind: 'VirtualService',
name: this.name,
namespace: this.namespace,
});
this.#virtualServiceCRDResource = resourceService.get({
apiVersion: 'apiextensions.k8s.io/v1',
kind: 'CustomResourceDefinition',
name: 'virtualservices.networking.istio.io',
});
this.#destinationRuleResource = resourceService.get({
apiVersion: 'networking.istio.io/v1',
kind: 'DestinationRule',
name: this.name,
namespace: this.namespace,
});
this.#destinationRuleCRDResource = resourceService.get({
apiVersion: 'apiextensions.k8s.io/v1',
kind: 'CustomResourceDefinition',
name: 'destinationrules.networking.istio.io',
});
const gatewayNames = getWithNamespace(this.spec.domain);
this.#domainResource = new ResourceReference(
resourceService.get({
apiVersion: `${GROUP}/v1`,
kind: 'Domain',
name: gatewayNames.name,
namespace: gatewayNames.namespace,
}),
);
this.#virtualServiceResource.on('changed', this.queueReconcile);
this.#virtualServiceCRDResource.on('changed', this.queueReconcile);
this.#destinationRuleResource.on('changed', this.queueReconcile);
this.#destinationRuleCRDResource.on('changed', this.queueReconcile);
this.#domainResource.on('changed', this.queueReconcile);
}
#reconcileVirtualService = async (): Promise<SubresourceResult> => {
if (!this.#virtualServiceCRDResource.exists) {
return {
ready: false,
failed: true,
reason: 'MissingCRD',
};
}
const domain = this.#domainResource.current;
if (!domain?.exists || !domain.spec) {
return {
ready: false,
failed: true,
reason: 'MissingDomain',
};
}
const manifest = createVirtualServiceManifest({
name: this.name,
namespace: this.namespace,
gateway: `${domain.namespace}/${domain.name}`,
owner: this.ref,
host: `${this.spec.subdomain}.${domain.spec.hostname}`,
destination: this.spec.destination,
});
if (!deepEqual(this.#virtualServiceResource.spec, manifest.spec)) {
await this.#virtualServiceResource.patch(manifest);
return {
ready: false,
syncing: true,
reason: 'ManifestNeedsPatching',
};
}
return {
ready: true,
};
};
#reconcileDestinationRule = async (): Promise<SubresourceResult> => {
if (!this.#destinationRuleCRDResource.exists) {
return {
ready: false,
failed: true,
reason: 'MissingCRD',
};
}
const manifest = createDestinationRuleManifest({
name: this.name,
namespace: this.namespace,
host: this.spec.destination.host,
});
if (!deepEqual(this.#destinationRuleResource.spec, manifest.spec)) {
await this.#destinationRuleResource.patch(manifest);
return {
ready: false,
syncing: true,
reason: 'ManifestNeedsPatching',
};
}
return {
ready: true,
};
};
public reconcile = async () => {
if (!this.exists || this.metadata.deletionTimestamp) {
return;
}
const resourceService = this.services.get(ResourceService);
const gatewayNames = getWithNamespace(this.spec.domain, this.namespace);
this.#domainResource.current = resourceService.get({
apiVersion: `${GROUP}/v1`,
kind: 'Domain',
name: gatewayNames.name,
namespace: gatewayNames.namespace,
});
await this.reconcileSubresource(VIRTUAL_SERVICE_CONDITION, this.#reconcileVirtualService);
await this.reconcileSubresource(DESTINAION_RULE_CONDITION, this.#reconcileDestinationRule);
const virtualServiceReady = this.conditions.get(VIRTUAL_SERVICE_CONDITION)?.status === 'True';
const destinationruleReady = this.conditions.get(DESTINAION_RULE_CONDITION)?.status === 'True';
await this.conditions.set('Ready', {
status: virtualServiceReady && destinationruleReady ? 'True' : 'False',
});
};
}
export { DomainServiceResource };

View File

@@ -0,0 +1,15 @@
import { z } from 'zod';
const domainServiceSpecSchema = z.object({
domain: z.string(),
subdomain: z.string(),
destination: z.object({
host: z.string(),
port: z.object({
number: z.number().optional(),
name: z.string().optional(),
}),
}),
});
export { domainServiceSpecSchema };

View File

@@ -0,0 +1,19 @@
import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts';
import { GROUP } from '../../utils/consts.ts';
import { DomainServiceResource } from './domain-service.resource.ts';
import { domainServiceSpecSchema } from './domain-service.schemas.ts';
const domainServiceDefinition = createCustomResourceDefinition({
group: GROUP,
kind: 'DomainService',
version: 'v1',
spec: domainServiceSpecSchema,
names: {
plural: 'domainservices',
singular: 'domainservice',
},
create: (options) => new DomainServiceResource(options),
});
export { domainServiceDefinition };

View File

@@ -0,0 +1,73 @@
type CreateGatewayManifestOptions = {
name: string;
namespace: string;
ref: ExpectedAny;
gateway: string;
domain: string;
secretName: string;
};
const createGatewayManifest = (options: CreateGatewayManifestOptions) => ({
apiVersion: 'networking.istio.io/v1alpha3',
kind: 'Gateway',
metadata: {
name: options.name,
namespace: options.namespace,
ownerReferences: [options.ref],
},
spec: {
selector: {
istio: options.gateway,
},
servers: [
{
port: {
number: 80,
name: 'http',
protocol: 'HTTP',
},
hosts: [`*.${options.domain}`],
tls: {
httpsRedirect: true,
},
},
{
port: {
number: 443,
name: 'https',
protocol: 'HTTPS',
},
hosts: [`*.${options.domain}`],
tls: {
mode: 'SIMPLE' as const,
credentialName: options.secretName,
},
},
],
},
});
type CreateCertificateManifestOptions = {
name: string;
namespace: string;
domain: string;
secretName: string;
issuer: string;
};
const createCertificateManifest = (options: CreateCertificateManifestOptions) => ({
apiVersion: 'cert-manager.io/v1',
kind: 'Certificate',
metadata: {
name: options.name,
namespace: 'istio-ingress',
},
spec: {
secretName: options.secretName,
dnsNames: [`*.${options.domain}`],
issuerRef: {
name: options.issuer,
kind: 'ClusterIssuer',
},
},
});
export { createGatewayManifest, createCertificateManifest };

View File

@@ -0,0 +1,174 @@
import type { KubernetesObject } from '@kubernetes/client-node';
import deepEqual from 'deep-equal';
import type { K8SGatewayV1 } from '../../__generated__/resources/K8SGatewayV1.ts';
import {
CustomResource,
type CustomResourceOptions,
type SubresourceResult,
} from '../../services/custom-resources/custom-resources.custom-resource.ts';
import { ResourceReference, ResourceService } from '../../services/resources/resources.ts';
import type { K8SCertificateV1 } from '../../__generated__/resources/K8SCertificateV1.ts';
import { IstioService } from '../../services/istio/istio.ts';
import type { domainSpecSchema } from './domain.schemas.ts';
import { createCertificateManifest, createGatewayManifest } from './domain.create-manifests.ts';
class DomainResource extends CustomResource<typeof domainSpecSchema> {
#gatewayCrdResource = new ResourceReference();
#gatewayResource = new ResourceReference<KubernetesObject & K8SGatewayV1>();
#certificateCrdResource = new ResourceReference();
#certificateResource = new ResourceReference<KubernetesObject & K8SCertificateV1>();
constructor(options: CustomResourceOptions<typeof domainSpecSchema>) {
super(options);
const resourceService = this.services.get(ResourceService);
const istioService = this.services.get(IstioService);
this.#gatewayCrdResource.current = resourceService.get({
apiVersion: 'apiextensions.k8s.io/v1',
kind: 'CustomResourceDefinition',
name: 'gateways.networking.istio.io',
});
this.#gatewayResource.current = resourceService.get({
apiVersion: 'networking.istio.io/v1',
kind: 'Gateway',
name: this.name,
namespace: this.namespace,
});
this.#certificateCrdResource.current = resourceService.get({
apiVersion: 'apiextensions.k8s.io/v1',
kind: 'CustomResourceDefinition',
name: 'certificates.cert-manager.io',
});
this.#certificateResource.current = resourceService.get({
apiVersion: 'cert-manager.io/v1',
kind: 'Certificate',
name: `domain-${this.name}`,
namespace: 'istio-ingress',
});
this.#gatewayResource.on('changed', this.queueReconcile);
this.#certificateResource.on('changed', this.queueReconcile);
this.#gatewayCrdResource.on('changed', this.queueReconcile);
this.#certificateCrdResource.on('changed', this.queueReconcile);
istioService.gateway.on('changed', this.queueReconcile);
}
get #certSecret() {
return `cert-secret-${this.namespace}-${this.name}`;
}
#reconcileGateway = async (): Promise<SubresourceResult> => {
if (!this.#gatewayCrdResource.current?.exists) {
return {
ready: false,
failed: true,
reason: 'MissingCRD',
message: 'Missing Gateway CRD',
};
}
const istioService = this.services.get(IstioService);
if (!istioService.gateway.current) {
return {
ready: false,
failed: true,
reason: 'MissingGatewayController',
message: 'No istio gateway controller could be found',
};
}
const manifest = createGatewayManifest({
name: this.name,
namespace: this.name,
domain: this.spec.hostname,
ref: this.ref,
gateway: istioService.gateway.current.metadata?.labels?.istio || 'ingress',
secretName: this.#certSecret,
});
if (!deepEqual(this.#gatewayResource.current?.spec, manifest.spec)) {
await this.#gatewayResource.current?.patch(manifest);
return {
ready: false,
syncing: true,
reason: 'ChangingGateway',
message: 'Gateway need changes',
};
}
return {
ready: true,
};
};
#reconcileCertificate = async (): Promise<SubresourceResult> => {
if (!this.#certificateCrdResource.current?.exists) {
return {
ready: false,
syncing: false,
failed: true,
reason: 'MissingCRD',
message: 'Missing Certificate CRD',
};
}
const current = this.#certificateResource.current;
if (!current || !current.namespace) {
throw new Error('Missing certificate resource');
}
const istioService = this.services.get(IstioService);
if (!istioService.gateway.current) {
return {
ready: false,
syncing: false,
failed: true,
reason: 'MissingGatewayController',
message: 'No istio gateway controller could be found',
};
}
const manifest = createCertificateManifest({
name: current.name,
namespace: istioService.gateway.current.namespace || 'default',
domain: this.spec.hostname,
secretName: this.#certSecret,
issuer: this.spec.issuer,
});
if (!this.#certificateResource.current?.exists) {
await current.patch(manifest);
return {
ready: false,
syncing: true,
reason: 'Creating',
message: 'Creating certificate resource',
};
}
if (!deepEqual(current.spec, manifest.spec)) {
await this.conditions.set('CertificateReady', {
status: 'False',
reason: 'Changing',
message: 'Certificate need changes',
});
await current.patch(manifest);
}
return {
ready: true,
};
};
public reconcile = async () => {
if (!this.exists || this.metadata.deletionTimestamp) {
return;
}
await this.reconcileSubresource('Gateway', this.#reconcileGateway);
await this.reconcileSubresource('Certificate', this.#reconcileCertificate);
const gatewayReady = this.conditions.get('Gateway')?.status === 'True';
const certificateReady = this.conditions.get('Certificate')?.status === 'True';
await this.conditions.set('Ready', {
status: gatewayReady && certificateReady ? 'True' : 'False',
});
};
}
export { DomainResource };

View File

@@ -0,0 +1,8 @@
import { z } from 'zod';
const domainSpecSchema = z.object({
hostname: z.string(),
issuer: z.string(),
});
export { domainSpecSchema };

View File

@@ -0,0 +1,19 @@
import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts';
import { GROUP } from '../../utils/consts.ts';
import { DomainResource } from './domain.resource.ts';
import { domainSpecSchema } from './domain.schemas.ts';
const domainDefinition = createCustomResourceDefinition({
version: 'v1',
kind: 'Domain',
group: GROUP,
names: {
plural: 'domains',
singular: 'domain',
},
spec: domainSpecSchema,
create: (options) => new DomainResource(options),
});
export { domainDefinition };

View File

@@ -0,0 +1,14 @@
import { z } from 'zod';
const postgresConnectionSpecSchema = z.object({
secret: z.string(),
});
const postgresConnectionSecretDataSchema = z.object({
host: z.string(),
port: z.string().optional(),
user: z.string(),
password: z.string(),
});
export { postgresConnectionSpecSchema, postgresConnectionSecretDataSchema };

View File

@@ -0,0 +1,87 @@
import type { V1Secret } from '@kubernetes/client-node';
import {
CustomResource,
type CustomResourceOptions,
} 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 { getWithNamespace } from '../../utils/naming.ts';
import { PostgresService } from '../../services/postgres/postgres.service.ts';
import type { postgresConnectionSpecSchema } from './posgtres-connection.schemas.ts';
class PostgresConnectionResource extends CustomResource<typeof postgresConnectionSpecSchema> {
#secret: ResourceReference<V1Secret>;
constructor(options: CustomResourceOptions<typeof postgresConnectionSpecSchema>) {
super(options);
const resourceService = this.services.get(ResourceService);
const secretNames = getWithNamespace(this.spec.secret, this.namespace);
this.#secret = new ResourceReference<V1Secret>(
resourceService.get({
apiVersion: 'v1',
kind: 'Secret',
name: secretNames.name,
namespace: secretNames.namespace,
}),
);
this.#secret.on('changed', this.queueReconcile);
}
public reconcile = async () => {
const resourceService = this.services.get(ResourceService);
const secretNames = getWithNamespace(this.spec.secret, this.namespace);
this.#secret.current = resourceService.get({
apiVersion: 'v1',
kind: 'Secret',
name: secretNames.name,
namespace: secretNames.namespace,
});
const current = this.#secret.current;
if (!current?.exists || !current.data) {
return this.conditions.set('Ready', {
status: 'False',
reason: 'MissingSecret',
});
}
const { host, user, password, port } = current.data;
if (!host) {
return this.conditions.set('Ready', {
status: 'False',
reason: 'MissingHost',
});
}
if (!user) {
return this.conditions.set('Ready', {
status: 'False',
reason: 'MissingUser',
});
}
if (!password) {
return this.conditions.set('Ready', {
status: 'False',
reason: 'MissingPassword',
});
}
const postgresService = this.services.get(PostgresService);
const database = postgresService.get({
host,
user,
port: port ? Number(port) : 5432,
password,
});
if (!(await database.ping())) {
return this.conditions.set('Ready', {
status: 'False',
reason: 'CanNotConnectToDatabase',
});
}
await this.conditions.set('Ready', {
status: 'True',
});
};
}
export { PostgresConnectionResource };

View File

@@ -0,0 +1,19 @@
import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts';
import { GROUP } from '../../utils/consts.ts';
import { postgresConnectionSpecSchema } from './posgtres-connection.schemas.ts';
import { PostgresConnectionResource } from './postgres-connection.resource.ts';
const postgresConnectionDefinition = createCustomResourceDefinition({
group: GROUP,
version: 'v1',
kind: 'PostgresConnection',
names: {
plural: 'postgresconnections',
singular: 'postgresconnection',
},
spec: postgresConnectionSpecSchema,
create: (options) => new PostgresConnectionResource(options),
});
export { postgresConnectionDefinition };

View File

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

View File

@@ -0,0 +1,206 @@
import { z } from 'zod';
import type { V1Secret } from '@kubernetes/client-node';
import {
CustomResource,
type CustomResourceObject,
type CustomResourceOptions,
type SubresourceResult,
} from '../../services/custom-resources/custom-resources.custom-resource.ts';
import { PostgresService } from '../../services/postgres/postgres.service.ts';
import {
postgresConnectionSecretDataSchema,
type postgresConnectionSpecSchema,
} from '../postgres-connection/posgtres-connection.schemas.ts';
import { ResourceReference } from '../../services/resources/resources.ref.ts';
import { Resource, ResourceService } from '../../services/resources/resources.ts';
import { getWithNamespace } from '../../utils/naming.ts';
import { API_VERSION } from '../../utils/consts.ts';
import { decodeSecret } from '../../utils/secrets.ts';
import type { postgresDatabaseSpecSchema } from './portgres-database.schemas.ts';
const SECRET_READY_CONDITION = 'Secret';
const DATABASE_READY_CONDITION = 'Database';
const secretDataSchema = z.object({
host: z.string(),
port: z.string().optional(),
database: z.string(),
user: z.string(),
password: z.string(),
});
class PostgresDatabaseResource extends CustomResource<typeof postgresDatabaseSpecSchema> {
#secret: Resource<V1Secret>;
#secretName: string;
#connection: ResourceReference<CustomResourceObject<typeof postgresConnectionSpecSchema>>;
#connectionSecret: ResourceReference<V1Secret>;
constructor(options: CustomResourceOptions<typeof postgresDatabaseSpecSchema>) {
super(options);
const resouceService = this.services.get(ResourceService);
this.#secretName = `postgres-database-${this.name}`;
this.#secret = resouceService.get({
apiVersion: 'v1',
kind: 'Secret',
name: this.#secretName,
namespace: this.namespace,
});
this.#connection = new ResourceReference();
this.#connectionSecret = new ResourceReference();
this.#updateSecret();
this.#secret.on('changed', this.queueReconcile);
this.#connection.on('changed', this.queueReconcile);
this.#connectionSecret.on('changed', this.queueReconcile);
}
get #dbName() {
return `${this.namespace}_${this.name}`;
}
get #userName() {
return `${this.namespace}_${this.name}`;
}
#updateSecret = () => {
const resouceService = this.services.get(ResourceService);
const connectionNames = getWithNamespace(this.spec.connection, this.namespace);
this.#connection.current = resouceService.get({
apiVersion: API_VERSION,
kind: 'PostgresConnection',
name: connectionNames.name,
namespace: connectionNames.namespace,
});
if (this.#connection.current?.exists && this.#connection.current.spec) {
const connectionSecretNames = getWithNamespace(
this.#connection.current.spec.secret,
this.#connection.current.namespace,
);
this.#connectionSecret.current = resouceService.get({
apiVersion: 'v1',
kind: 'Secret',
name: connectionSecretNames.name,
namespace: connectionSecretNames.namespace,
});
}
};
#reconcileSecret = async (): Promise<SubresourceResult> => {
const connectionSecret = this.#connectionSecret.current;
if (!connectionSecret?.exists || !connectionSecret.data) {
return {
ready: false,
failed: true,
reason: 'MissingConnectionSecret',
};
}
const connectionSecretData = decodeSecret(connectionSecret.data);
const secret = this.#secret;
const parsed = secretDataSchema.safeParse(decodeSecret(secret.data));
if (!parsed.success) {
this.#secret.patch({
data: {
host: Buffer.from(connectionSecretData?.host || '').toString('base64'),
port: connectionSecretData?.port ? Buffer.from(connectionSecretData.port).toString('base64') : undefined,
user: Buffer.from(this.#userName).toString('base64'),
database: Buffer.from(this.#dbName).toString('base64'),
password: Buffer.from(Buffer.from(crypto.randomUUID()).toString('hex')).toString('base64'),
},
});
return {
ready: false,
syncing: true,
};
}
if (parsed.data?.host !== connectionSecretData?.host || parsed.data?.port !== connectionSecretData?.port) {
this.#secret.patch({
data: {
host: Buffer.from(connectionSecretData?.host || '').toString('base64'),
port: connectionSecretData?.port ? Buffer.from(connectionSecretData.port).toString('base64') : undefined,
},
});
return {
ready: false,
syncing: true,
};
}
return {
ready: true,
};
};
#reconcileDatabase = async (): Promise<SubresourceResult> => {
const connectionSecret = this.#connectionSecret.current;
if (!connectionSecret?.exists || !connectionSecret.data) {
return {
ready: false,
failed: true,
reason: 'MissingConnectionSecret',
};
}
const connectionSecretData = postgresConnectionSecretDataSchema.safeParse(decodeSecret(connectionSecret.data));
if (!connectionSecretData.success || !connectionSecretData.data) {
return {
ready: false,
syncing: true,
reason: 'ConnectionSecretMissing',
};
}
const secretData = secretDataSchema.safeParse(decodeSecret(this.#secret.data));
if (!secretData.success || !secretData.data) {
return {
ready: false,
syncing: true,
reason: 'SecretMissing',
};
}
const postgresService = this.services.get(PostgresService);
const database = postgresService.get({
...connectionSecretData.data,
port: connectionSecretData.data.port ? Number(connectionSecretData.data.port) : 5432,
});
await database.upsertRole({
name: secretData.data.user,
password: secretData.data.password,
});
await database.upsertDatabase({
name: secretData.data.database,
owner: secretData.data.user,
});
return {
ready: true,
};
};
public reconcile = async () => {
this.#updateSecret();
if (!this.exists || this.metadata.deletionTimestamp) {
return;
}
await Promise.allSettled([
await this.reconcileSubresource(DATABASE_READY_CONDITION, this.#reconcileDatabase),
await 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, secretDataSchema as postgresDatabaseSecretSchema };

View File

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

@@ -0,0 +1,61 @@
import type { V1Secret } from '@kubernetes/client-node';
import {
CustomResource,
type CustomResourceOptions,
} 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 { getWithNamespace } from '../../utils/naming.ts';
import type { redisConnectionSpecSchema } from './redis-connection.schemas.ts';
class RedisConnectionResource extends CustomResource<typeof redisConnectionSpecSchema> {
#secret: ResourceReference<V1Secret>;
constructor(options: CustomResourceOptions<typeof redisConnectionSpecSchema>) {
super(options);
const resourceService = this.services.get(ResourceService);
const secretNames = getWithNamespace(this.spec.secret, this.namespace);
this.#secret = new ResourceReference<V1Secret>(
resourceService.get({
apiVersion: 'v1',
kind: 'Secret',
name: secretNames.name,
namespace: secretNames.namespace,
}),
);
this.#secret.on('changed', this.queueReconcile);
}
public reconcile = async () => {
const resourceService = this.services.get(ResourceService);
const secretNames = getWithNamespace(this.spec.secret, this.namespace);
this.#secret.current = resourceService.get({
apiVersion: 'v1',
kind: 'Secret',
name: secretNames.name,
namespace: secretNames.namespace,
});
const current = this.#secret.current;
if (!current?.exists || !current.data) {
return this.conditions.set('Ready', {
status: 'False',
reason: 'MissingSecret',
});
}
const { host } = current.data;
if (!host) {
return this.conditions.set('Ready', {
status: 'False',
reason: 'MissingHost',
});
}
await this.conditions.set('Ready', {
status: 'True',
});
};
}
export { RedisConnectionResource };

View File

@@ -0,0 +1,14 @@
import { z } from 'zod';
const redisConnectionSpecSchema = z.object({
secret: z.string(),
});
const redisConnectionSecretDataSchema = z.object({
host: z.string(),
port: z.string().optional(),
user: z.string().optional(),
password: z.string().optional(),
});
export { redisConnectionSpecSchema, redisConnectionSecretDataSchema };

View File

@@ -0,0 +1,19 @@
import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts';
import { GROUP } from '../../utils/consts.ts';
import { redisConnectionSpecSchema } from './redis-connection.schemas.ts';
import { RedisConnectionResource } from './redis-connection.resource.ts';
const redisConnectionDefinition = createCustomResourceDefinition({
group: GROUP,
version: 'v1',
kind: 'RedisConnection',
names: {
plural: 'redisconnections',
singular: 'redisconnection',
},
spec: redisConnectionSpecSchema,
create: (options) => new RedisConnectionResource(options),
});
export { redisConnectionDefinition };