This commit is contained in:
Morten Olsen
2025-08-12 22:32:09 +02:00
parent d4b56007f1
commit b8bb16ccbb
59 changed files with 1855 additions and 290 deletions

View File

@@ -13,12 +13,9 @@ import { decodeSecret, encodeSecret } from '../../utils/secrets.ts';
import { CONTROLLED_LABEL } from '../../utils/consts.ts';
import { isDeepSubset } from '../../utils/objects.ts';
import { AuthentikService } from '../../services/authentik/authentik.service.ts';
import { authentikServerSecretSchema } from '../authentik-server/authentik-server.schemas.ts';
import {
authentikClientSecretSchema,
authentikClientServerSecretSchema,
type authentikClientSpecSchema,
} from './authentik-client.schemas.ts';
import { authentikClientSecretSchema, type authentikClientSpecSchema } from './authentik-client.schemas.ts';
class AuthentikClientResource extends CustomResource<typeof authentikClientSpecSchema> {
#serverSecret: ResourceReference<V1Secret>;
@@ -43,7 +40,7 @@ class AuthentikClientResource extends CustomResource<typeof authentikClientSpecS
}
#updateResouces = () => {
const serverSecretNames = getWithNamespace(this.spec.secretRef, this.namespace);
const serverSecretNames = getWithNamespace(`${this.spec.server}-server`, this.namespace);
const resourceService = this.services.get(ResourceService);
this.#serverSecret.current = resourceService.get({
apiVersion: 'v1',
@@ -62,7 +59,7 @@ class AuthentikClientResource extends CustomResource<typeof authentikClientSpecS
message: 'Server or server secret not found',
};
}
const serverSecretData = authentikClientServerSecretSchema.safeParse(decodeSecret(serverSecret.data));
const serverSecretData = authentikServerSecretSchema.safeParse(decodeSecret(serverSecret.data));
if (!serverSecretData.success || !serverSecretData.data) {
return {
ready: false,
@@ -118,7 +115,7 @@ class AuthentikClientResource extends CustomResource<typeof authentikClientSpecS
};
}
const serverSecretData = authentikClientServerSecretSchema.safeParse(decodeSecret(serverSecret.data));
const serverSecretData = authentikServerSecretSchema.safeParse(decodeSecret(serverSecret.data));
if (!serverSecretData.success || !serverSecretData.data) {
return {
ready: false,
@@ -139,7 +136,7 @@ class AuthentikClientResource extends CustomResource<typeof authentikClientSpecS
const authentikService = this.services.get(AuthentikService);
const authentikServer = authentikService.get({
url: {
internal: `${serverSecretData.data.name}.${serverSecret.namespace}.svc.cluster.local`,
internal: `http://${serverSecretData.data.host}`,
external: serverSecretData.data.url,
},
token: serverSecretData.data.token,

View File

@@ -2,7 +2,7 @@ import { ClientTypeEnum, SubModeEnum } from '@goauthentik/api';
import { z } from 'zod';
const authentikClientSpecSchema = z.object({
secretRef: z.string(),
server: z.string(),
subMode: z.enum(SubModeEnum).optional(),
clientType: z.enum(ClientTypeEnum).optional(),
redirectUris: z.array(
@@ -13,12 +13,6 @@ const authentikClientSpecSchema = z.object({
),
});
const authentikClientServerSecretSchema = z.object({
name: z.string(),
url: z.string(),
token: z.string(),
});
const authentikClientSecretSchema = z.object({
clientId: z.string(),
clientSecret: z.string().optional(),
@@ -31,4 +25,4 @@ const authentikClientSecretSchema = z.object({
jwks: z.string(),
});
export { authentikClientSpecSchema, authentikClientSecretSchema, authentikClientServerSecretSchema };
export { authentikClientSpecSchema, authentikClientSecretSchema };

View File

@@ -1,93 +0,0 @@
import type { V1Secret } from '@kubernetes/client-node';
import deepEqual from 'deep-equal';
import {
CustomResource,
type CustomResourceOptions,
} from '../../services/custom-resources/custom-resources.custom-resource.ts';
import { ResourceService, type Resource } from '../../services/resources/resources.ts';
import type { ValueReference } from '../../services/value-reference/value-reference.instance.ts';
import { ValueReferenceService } from '../../services/value-reference/value-reference.ts';
import { decodeSecret, encodeSecret } from '../../utils/secrets.ts';
import type { authentikConnectionSpecSchema } from './authentik-connection.schemas.ts';
class AuthentikConnectionResource extends CustomResource<typeof authentikConnectionSpecSchema> {
#name: ValueReference;
#url: ValueReference;
#token: ValueReference;
#secret: Resource<V1Secret>;
constructor(options: CustomResourceOptions<typeof authentikConnectionSpecSchema>) {
super(options);
const valueReferenceService = this.services.get(ValueReferenceService);
const resourceService = this.services.get(ResourceService);
this.#name = valueReferenceService.get(this.namespace);
this.#url = valueReferenceService.get(this.namespace);
this.#token = valueReferenceService.get(this.namespace);
this.#secret = resourceService.get({
apiVersion: 'v1',
kind: 'Secret',
name: `${this.name}-authentik-server`,
namespace: this.namespace,
});
this.#name.on('changed', this.queueReconcile);
this.#url.on('changed', this.queueReconcile);
this.#token.on('changed', this.queueReconcile);
this.#secret.on('changed', this.queueReconcile);
}
#updateResources = () => {
this.#name.ref = this.spec.name;
this.#url.ref = this.spec.url;
this.#token.ref = this.spec.token;
};
public reconcile = async () => {
this.#updateResources();
const name = this.#name.value;
const url = this.#url.value;
const token = this.#token.value;
if (!name) {
return await this.conditions.set('Ready', {
status: 'False',
reason: 'MissingName',
});
}
if (!url) {
return await this.conditions.set('Ready', {
status: 'False',
reason: 'MissingUrl',
});
}
if (!token) {
return await this.conditions.set('Ready', {
status: 'False',
reason: 'MissingToken',
});
}
const values = {
name,
url,
token,
};
const secretValue = decodeSecret(this.#secret.data);
if (!deepEqual(secretValue, values)) {
await this.#secret.patch({
data: encodeSecret(values),
});
return await this.conditions.set('Ready', {
status: 'False',
reason: 'UpdatingSecret',
});
}
return await this.conditions.set('Ready', {
status: 'True',
});
};
}
export { AuthentikConnectionResource };

View File

@@ -1,11 +0,0 @@
import { z } from 'zod';
import { valueReferenceInfoSchema } from '../../services/value-reference/value-reference.instance.ts';
const authentikConnectionSpecSchema = z.object({
name: valueReferenceInfoSchema,
url: valueReferenceInfoSchema,
token: valueReferenceInfoSchema,
});
export { authentikConnectionSpecSchema };

View File

@@ -1,19 +0,0 @@
import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts';
import { GROUP } from '../../utils/consts.ts';
import { AuthentikConnectionResource } from './authentik-connection.resource.ts';
import { authentikConnectionSpecSchema } from './authentik-connection.schemas.ts';
const authentikConnectionDefinition = createCustomResourceDefinition({
group: GROUP,
version: 'v1',
kind: 'AuthentikConnection',
names: {
plural: 'authentikconnections',
singular: 'authentikconnection',
},
spec: authentikConnectionSpecSchema,
create: (options) => new AuthentikConnectionResource(options),
});
export { authentikConnectionDefinition };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,23 +1,7 @@
import { z } from 'zod';
const postgresDatabaseSpecSchema = z.object({
secretRef: z.string(),
cluster: z.string(),
});
const postgresDatabaseSecretSchema = z.object({
host: z.string(),
port: z.string(),
user: z.string(),
password: z.string(),
database: z.string().optional(),
});
const postgresDatabaseConnectionSecretSchema = z.object({
host: z.string(),
port: z.string(),
user: z.string(),
password: z.string(),
database: z.string(),
});
export { postgresDatabaseSpecSchema, postgresDatabaseSecretSchema, postgresDatabaseConnectionSecretSchema };
export { postgresDatabaseSpecSchema };

View File

@@ -1,4 +1,3 @@
import { z } from 'zod';
import type { V1Secret } from '@kubernetes/client-node';
import {
@@ -12,42 +11,31 @@ import { Resource, ResourceService } from '../../services/resources/resources.ts
import { getWithNamespace } from '../../utils/naming.ts';
import { decodeSecret, encodeSecret } from '../../utils/secrets.ts';
import { isDeepSubset } from '../../utils/objects.ts';
import { postgresClusterSecretSchema } from '../postgres-cluster/postgres-cluster.schemas.ts';
import {
postgresDatabaseConnectionSecretSchema,
postgresDatabaseSecretSchema,
type postgresDatabaseSpecSchema,
} from './portgres-database.schemas.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> {
#serverSecret: ResourceReference<V1Secret>;
#clusterSecret: ResourceReference<V1Secret>;
#databaseSecret: Resource<V1Secret>;
constructor(options: CustomResourceOptions<typeof postgresDatabaseSpecSchema>) {
super(options);
this.#serverSecret = new ResourceReference();
this.#clusterSecret = new ResourceReference();
const resourceService = this.services.get(ResourceService);
this.#databaseSecret = resourceService.get({
apiVersion: 'v1',
kind: 'Secret',
name: `${this.name}-connection`,
name: `${this.name}-postgres-database`,
namespace: this.namespace,
});
this.#updateSecret();
this.#serverSecret.on('changed', this.queueReconcile);
this.#clusterSecret.on('changed', this.queueReconcile);
}
get #dbName() {
@@ -60,17 +48,17 @@ class PostgresDatabaseResource extends CustomResource<typeof postgresDatabaseSpe
#updateSecret = () => {
const resourceService = this.services.get(ResourceService);
const secretNames = getWithNamespace(this.spec.secretRef, this.namespace);
this.#serverSecret.current = resourceService.get({
const secretNames = getWithNamespace(this.spec.cluster, this.namespace);
this.#clusterSecret.current = resourceService.get({
apiVersion: 'v1',
kind: 'Secret',
name: secretNames.name,
name: `${secretNames.name}-postgres-cluster`,
namespace: secretNames.namespace,
});
};
#reconcileSecret = async (): Promise<SubresourceResult> => {
const serverSecret = this.#serverSecret.current;
const serverSecret = this.#clusterSecret.current;
const databaseSecret = this.#databaseSecret;
if (!serverSecret?.exists || !serverSecret.data) {
@@ -80,7 +68,7 @@ class PostgresDatabaseResource extends CustomResource<typeof postgresDatabaseSpe
reason: 'MissingConnectionSecret',
};
}
const serverSecretData = postgresDatabaseSecretSchema.safeParse(decodeSecret(serverSecret.data));
const serverSecretData = postgresClusterSecretSchema.safeParse(decodeSecret(serverSecret.data));
if (!serverSecretData.success || !serverSecretData.data) {
return {
ready: false,
@@ -88,7 +76,7 @@ class PostgresDatabaseResource extends CustomResource<typeof postgresDatabaseSpe
reason: 'SecretMissing',
};
}
const databaseSecretData = postgresDatabaseConnectionSecretSchema.safeParse(decodeSecret(databaseSecret.data));
const databaseSecretData = postgresClusterSecretSchema.safeParse(decodeSecret(databaseSecret.data));
const expectedSecret = {
password: crypto.randomUUID(),
host: serverSecretData.data.host,
@@ -115,8 +103,8 @@ class PostgresDatabaseResource extends CustomResource<typeof postgresDatabaseSpe
};
#reconcileDatabase = async (): Promise<SubresourceResult> => {
const connectionSecret = this.#serverSecret.current;
if (!connectionSecret?.exists || !connectionSecret.data) {
const clusterSecret = this.#clusterSecret.current;
if (!clusterSecret?.exists || !clusterSecret.data) {
return {
ready: false,
failed: true,
@@ -124,7 +112,7 @@ class PostgresDatabaseResource extends CustomResource<typeof postgresDatabaseSpe
};
}
const connectionSecretData = postgresDatabaseSecretSchema.safeParse(decodeSecret(connectionSecret.data));
const connectionSecretData = postgresClusterSecretSchema.safeParse(decodeSecret(clusterSecret.data));
if (!connectionSecretData.success || !connectionSecretData.data) {
return {
ready: false,
@@ -133,7 +121,7 @@ class PostgresDatabaseResource extends CustomResource<typeof postgresDatabaseSpe
};
}
const secretData = postgresDatabaseConnectionSecretSchema.safeParse(decodeSecret(this.#databaseSecret.data));
const secretData = postgresClusterSecretSchema.safeParse(decodeSecret(this.#databaseSecret.data));
if (!secretData.success || !secretData.data) {
return {
ready: false,
@@ -149,12 +137,12 @@ class PostgresDatabaseResource extends CustomResource<typeof postgresDatabaseSpe
database: connectionSecretData.data.database,
});
await database.upsertRole({
name: secretData.data.user,
name: secretData.data.username,
password: secretData.data.password,
});
await database.upsertDatabase({
name: secretData.data.database,
owner: secretData.data.user,
owner: secretData.data.username,
});
return {
@@ -180,4 +168,4 @@ class PostgresDatabaseResource extends CustomResource<typeof postgresDatabaseSpe
};
}
export { PostgresDatabaseResource, secretDataSchema as postgresDatabaseSecretSchema };
export { PostgresDatabaseResource };