mirror of
https://github.com/morten-olsen/homelab-operator.git
synced 2026-02-08 01:36:28 +01:00
lot more stuff
This commit is contained in:
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
19
src/custom-resouces/authentik-server/authentik-server.ts
Normal file
19
src/custom-resouces/authentik-server/authentik-server.ts
Normal 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 };
|
||||
17
src/custom-resouces/custom-resources.ts
Normal file
17
src/custom-resouces/custom-resources.ts
Normal 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 };
|
||||
@@ -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 };
|
||||
169
src/custom-resouces/domain-service/domain-service.resource.ts
Normal file
169
src/custom-resouces/domain-service/domain-service.resource.ts
Normal 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 };
|
||||
15
src/custom-resouces/domain-service/domain-service.schemas.ts
Normal file
15
src/custom-resouces/domain-service/domain-service.schemas.ts
Normal 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 };
|
||||
19
src/custom-resouces/domain-service/domain-service.ts
Normal file
19
src/custom-resouces/domain-service/domain-service.ts
Normal 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 };
|
||||
73
src/custom-resouces/domain/domain.create-manifests.ts
Normal file
73
src/custom-resouces/domain/domain.create-manifests.ts
Normal 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 };
|
||||
174
src/custom-resouces/domain/domain.resource.ts
Normal file
174
src/custom-resouces/domain/domain.resource.ts
Normal 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 };
|
||||
8
src/custom-resouces/domain/domain.schemas.ts
Normal file
8
src/custom-resouces/domain/domain.schemas.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const domainSpecSchema = z.object({
|
||||
hostname: z.string(),
|
||||
issuer: z.string(),
|
||||
});
|
||||
|
||||
export { domainSpecSchema };
|
||||
19
src/custom-resouces/domain/domain.ts
Normal file
19
src/custom-resouces/domain/domain.ts
Normal 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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const postgresDatabaseSpecSchema = z.object({
|
||||
connection: z.string(),
|
||||
});
|
||||
|
||||
export { postgresDatabaseSpecSchema };
|
||||
@@ -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 };
|
||||
19
src/custom-resouces/postgres-database/postgres-database.ts
Normal file
19
src/custom-resouces/postgres-database/postgres-database.ts
Normal 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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
19
src/custom-resouces/redis-connection/redis-connection.ts
Normal file
19
src/custom-resouces/redis-connection/redis-connection.ts
Normal 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 };
|
||||
Reference in New Issue
Block a user