mirror of
https://github.com/morten-olsen/homelab-operator.git
synced 2026-02-08 01:36:28 +01:00
Compare commits
1 Commits
feat/rewri
...
v0.1.14
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32576ad37d |
@@ -1,36 +1,34 @@
|
|||||||
import type { V1Secret } from '@kubernetes/client-node';
|
import type { V1Secret } from '@kubernetes/client-node';
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
import deepEqual from 'deep-equal';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CustomResource,
|
CustomResource,
|
||||||
type CustomResourceObject,
|
|
||||||
type CustomResourceOptions,
|
type CustomResourceOptions,
|
||||||
type SubresourceResult,
|
type SubresourceResult,
|
||||||
} from '../../services/custom-resources/custom-resources.custom-resource.ts';
|
} from '../../services/custom-resources/custom-resources.custom-resource.ts';
|
||||||
import { ResourceReference } from '../../services/resources/resources.ref.ts';
|
import { ResourceReference } from '../../services/resources/resources.ref.ts';
|
||||||
import { ResourceService, type Resource } from '../../services/resources/resources.ts';
|
import { ResourceService, type Resource } from '../../services/resources/resources.ts';
|
||||||
import { getWithNamespace } from '../../utils/naming.ts';
|
import { getWithNamespace } from '../../utils/naming.ts';
|
||||||
import type { authentikServerSpecSchema } from '../authentik-server/authentik-server.scemas.ts';
|
|
||||||
import type { domainSpecSchema } from '../domain/domain.schemas.ts';
|
|
||||||
import { decodeSecret, encodeSecret } from '../../utils/secrets.ts';
|
import { decodeSecret, encodeSecret } from '../../utils/secrets.ts';
|
||||||
import { API_VERSION, CONTROLLED_LABEL } from '../../utils/consts.ts';
|
import { CONTROLLED_LABEL } from '../../utils/consts.ts';
|
||||||
|
import { isDeepSubset } from '../../utils/objects.ts';
|
||||||
|
import { AuthentikService } from '../../services/authentik/authentik.service.ts';
|
||||||
|
|
||||||
import { authentikClientSecretSchema, type authentikClientSpecSchema } from './authentik-client.schemas.ts';
|
import {
|
||||||
|
authentikClientSecretSchema,
|
||||||
|
authentikClientServerSecretSchema,
|
||||||
|
type authentikClientSpecSchema,
|
||||||
|
} from './authentik-client.schemas.ts';
|
||||||
|
|
||||||
class AuthentikClientResource extends CustomResource<typeof authentikClientSpecSchema> {
|
class AuthentikClientResource extends CustomResource<typeof authentikClientSpecSchema> {
|
||||||
#serverResource: ResourceReference<CustomResourceObject<typeof authentikServerSpecSchema>>;
|
#serverSecret: ResourceReference<V1Secret>;
|
||||||
#serverSecretResource: ResourceReference<V1Secret>;
|
|
||||||
#domainResource: ResourceReference<CustomResourceObject<typeof domainSpecSchema>>;
|
|
||||||
#clientSecretResource: Resource<V1Secret>;
|
#clientSecretResource: Resource<V1Secret>;
|
||||||
|
|
||||||
constructor(options: CustomResourceOptions<typeof authentikClientSpecSchema>) {
|
constructor(options: CustomResourceOptions<typeof authentikClientSpecSchema>) {
|
||||||
super(options);
|
super(options);
|
||||||
const resourceService = this.services.get(ResourceService);
|
const resourceService = this.services.get(ResourceService);
|
||||||
|
|
||||||
this.#serverResource = new ResourceReference();
|
this.#serverSecret = new ResourceReference();
|
||||||
this.#serverSecretResource = new ResourceReference();
|
|
||||||
this.#domainResource = new ResourceReference();
|
|
||||||
this.#clientSecretResource = resourceService.get({
|
this.#clientSecretResource = resourceService.get({
|
||||||
apiVersion: 'v1',
|
apiVersion: 'v1',
|
||||||
kind: 'Secret',
|
kind: 'Secret',
|
||||||
@@ -40,93 +38,45 @@ class AuthentikClientResource extends CustomResource<typeof authentikClientSpecS
|
|||||||
|
|
||||||
this.#updateResouces();
|
this.#updateResouces();
|
||||||
|
|
||||||
this.#serverResource.on('changed', this.queueReconcile);
|
this.#serverSecret.on('changed', this.queueReconcile);
|
||||||
this.#serverSecretResource.on('changed', this.queueReconcile);
|
|
||||||
this.#domainResource.on('changed', this.queueReconcile);
|
|
||||||
this.#clientSecretResource.on('changed', this.queueReconcile);
|
this.#clientSecretResource.on('changed', this.queueReconcile);
|
||||||
}
|
}
|
||||||
|
|
||||||
get server() {
|
|
||||||
return this.#serverResource.current;
|
|
||||||
}
|
|
||||||
|
|
||||||
get serverSecret() {
|
|
||||||
return this.#serverSecretResource.current;
|
|
||||||
}
|
|
||||||
|
|
||||||
get serverSecretValue() {
|
|
||||||
return decodeSecret(this.#serverSecretResource.current?.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
get domain() {
|
|
||||||
return this.#domainResource.current;
|
|
||||||
}
|
|
||||||
|
|
||||||
get clientSecret() {
|
|
||||||
return this.#clientSecretResource;
|
|
||||||
}
|
|
||||||
|
|
||||||
get clientSecretValue() {
|
|
||||||
const values = decodeSecret(this.#clientSecretResource.data);
|
|
||||||
const parsed = authentikClientSecretSchema.safeParse(values);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return parsed.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
#updateResouces = () => {
|
#updateResouces = () => {
|
||||||
const serverNames = getWithNamespace(this.spec.server, this.namespace);
|
const serverSecretNames = getWithNamespace(this.spec.secretRef, this.namespace);
|
||||||
const resourceService = this.services.get(ResourceService);
|
const resourceService = this.services.get(ResourceService);
|
||||||
this.#serverResource.current = resourceService.get({
|
this.#serverSecret.current = resourceService.get({
|
||||||
apiVersion: API_VERSION,
|
|
||||||
kind: 'AuthentikServer',
|
|
||||||
name: serverNames.name,
|
|
||||||
namespace: serverNames.namespace,
|
|
||||||
});
|
|
||||||
this.#serverSecretResource.current = resourceService.get({
|
|
||||||
apiVersion: 'v1',
|
apiVersion: 'v1',
|
||||||
kind: 'Secret',
|
kind: 'Secret',
|
||||||
name: `authentik-server-${serverNames.name}`,
|
name: serverSecretNames.name,
|
||||||
namespace: serverNames.namespace,
|
namespace: serverSecretNames.namespace,
|
||||||
});
|
});
|
||||||
const server = this.#serverResource.current;
|
|
||||||
if (server && server.spec) {
|
|
||||||
const domainNames = getWithNamespace(server.spec.domain, server.namespace);
|
|
||||||
this.#domainResource.current = resourceService.get({
|
|
||||||
apiVersion: API_VERSION,
|
|
||||||
kind: 'Domain',
|
|
||||||
name: domainNames.name,
|
|
||||||
namespace: domainNames.namespace,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.#domainResource.current = undefined;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#reconcileClientSecret = async (): Promise<SubresourceResult> => {
|
#reconcileClientSecret = async (): Promise<SubresourceResult> => {
|
||||||
const domain = this.domain;
|
const serverSecret = this.#serverSecret.current;
|
||||||
const server = this.server;
|
if (!serverSecret?.exists || !serverSecret.data) {
|
||||||
const serverSecret = this.serverSecret;
|
|
||||||
if (!server?.exists || !server?.spec || !serverSecret?.exists || !serverSecret.data) {
|
|
||||||
return {
|
return {
|
||||||
ready: false,
|
ready: false,
|
||||||
failed: true,
|
failed: true,
|
||||||
message: 'Server or server secret not found',
|
message: 'Server or server secret not found',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (!domain?.exists || !domain?.spec) {
|
const serverSecretData = authentikClientServerSecretSchema.safeParse(decodeSecret(serverSecret.data));
|
||||||
|
if (!serverSecretData.success || !serverSecretData.data) {
|
||||||
return {
|
return {
|
||||||
ready: false,
|
ready: false,
|
||||||
failed: true,
|
failed: true,
|
||||||
message: 'Domain not found',
|
message: 'Server secret not found',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const url = `https://authentik.${domain.spec?.hostname}`;
|
const url = serverSecretData.data.external_url;
|
||||||
const appName = this.name;
|
const appName = this.name;
|
||||||
const values = this.clientSecretValue;
|
const clientSecretData = authentikClientSecretSchema.safeParse(decodeSecret(this.#clientSecretResource.data));
|
||||||
const expectedValues: Omit<z.infer<typeof authentikClientSecretSchema>, 'clientSecret'> = {
|
|
||||||
|
const expectedValues: z.infer<typeof authentikClientSecretSchema> = {
|
||||||
clientId: this.name,
|
clientId: this.name,
|
||||||
|
clientSecret: clientSecretData.data?.clientSecret || crypto.randomUUID(),
|
||||||
configuration: new URL(`/application/o/${appName}/.well-known/openid-configuration`, url).toString(),
|
configuration: new URL(`/application/o/${appName}/.well-known/openid-configuration`, url).toString(),
|
||||||
configurationIssuer: new URL(`/application/o/${appName}/`, url).toString(),
|
configurationIssuer: new URL(`/application/o/${appName}/`, url).toString(),
|
||||||
authorization: new URL(`/application/o/${appName}/authorize/`, url).toString(),
|
authorization: new URL(`/application/o/${appName}/authorize/`, url).toString(),
|
||||||
@@ -135,31 +85,8 @@ class AuthentikClientResource extends CustomResource<typeof authentikClientSpecS
|
|||||||
endSession: new URL(`/application/o/${appName}/end-session/`, url).toString(),
|
endSession: new URL(`/application/o/${appName}/end-session/`, url).toString(),
|
||||||
jwks: new URL(`/application/o/${appName}/jwks/`, url).toString(),
|
jwks: new URL(`/application/o/${appName}/jwks/`, url).toString(),
|
||||||
};
|
};
|
||||||
if (!values) {
|
if (!isDeepSubset(clientSecretData.data, expectedValues)) {
|
||||||
await this.clientSecret.patch({
|
await this.#clientSecretResource.patch({
|
||||||
metadata: {
|
|
||||||
ownerReferences: [this.ref],
|
|
||||||
labels: {
|
|
||||||
...CONTROLLED_LABEL,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
data: encodeSecret({
|
|
||||||
...expectedValues,
|
|
||||||
clientSecret: crypto.randomUUID(),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
ready: false,
|
|
||||||
syncing: true,
|
|
||||||
message: 'UpdatingManifest',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const compareData = {
|
|
||||||
...values,
|
|
||||||
clientSecret: undefined,
|
|
||||||
};
|
|
||||||
if (!deepEqual(compareData, expectedValues)) {
|
|
||||||
await this.clientSecret.patch({
|
|
||||||
metadata: {
|
metadata: {
|
||||||
ownerReferences: [this.ref],
|
ownerReferences: [this.ref],
|
||||||
labels: {
|
labels: {
|
||||||
@@ -168,23 +95,82 @@ class AuthentikClientResource extends CustomResource<typeof authentikClientSpecS
|
|||||||
},
|
},
|
||||||
data: encodeSecret(expectedValues),
|
data: encodeSecret(expectedValues),
|
||||||
});
|
});
|
||||||
|
return {
|
||||||
|
ready: false,
|
||||||
|
syncing: true,
|
||||||
|
message: 'UpdatingManifest',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
ready: true,
|
ready: true,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#reconcileServer = async (): Promise<SubresourceResult> => {
|
||||||
|
const serverSecret = this.#serverSecret.current;
|
||||||
|
const clientSecret = this.#clientSecretResource;
|
||||||
|
|
||||||
|
if (!serverSecret?.exists || !serverSecret.data) {
|
||||||
|
return {
|
||||||
|
ready: false,
|
||||||
|
failed: true,
|
||||||
|
message: 'Server secret not found',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverSecretData = authentikClientServerSecretSchema.safeParse(decodeSecret(serverSecret.data));
|
||||||
|
if (!serverSecretData.success || !serverSecretData.data) {
|
||||||
|
return {
|
||||||
|
ready: false,
|
||||||
|
failed: true,
|
||||||
|
message: 'Server secret not found',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientSecretData = authentikClientSecretSchema.safeParse(decodeSecret(clientSecret.data));
|
||||||
|
if (!clientSecretData.success || !clientSecretData.data) {
|
||||||
|
return {
|
||||||
|
ready: false,
|
||||||
|
failed: true,
|
||||||
|
message: 'Client secret not found',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const authentikService = this.services.get(AuthentikService);
|
||||||
|
const authentikServer = authentikService.get({
|
||||||
|
url: {
|
||||||
|
internal: serverSecretData.data.internal_url,
|
||||||
|
external: serverSecretData.data.external_url,
|
||||||
|
},
|
||||||
|
token: serverSecretData.data.token,
|
||||||
|
});
|
||||||
|
|
||||||
|
(await authentikServer).upsertClient({
|
||||||
|
...this.spec,
|
||||||
|
name: this.name,
|
||||||
|
secret: clientSecretData.data.clientSecret,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
ready: true,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
public reconcile = async () => {
|
public reconcile = async () => {
|
||||||
if (!this.exists || this.metadata.deletionTimestamp) {
|
if (!this.exists || this.metadata?.deletionTimestamp) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.#updateResouces();
|
this.#updateResouces();
|
||||||
await Promise.all([this.reconcileSubresource('Secret', this.#reconcileClientSecret)]);
|
await Promise.all([
|
||||||
|
this.reconcileSubresource('Secret', this.#reconcileClientSecret),
|
||||||
|
this.reconcileSubresource('Server', this.#reconcileServer),
|
||||||
|
]);
|
||||||
|
|
||||||
const secretReady = this.conditions.get('Secret')?.status === 'True';
|
const secretReady = this.conditions.get('Secret')?.status === 'True';
|
||||||
|
const serverReady = this.conditions.get('Server')?.status === 'True';
|
||||||
|
|
||||||
await this.conditions.set('Ready', {
|
await this.conditions.set('Ready', {
|
||||||
status: secretReady ? 'True' : 'False',
|
status: secretReady && serverReady ? 'True' : 'False',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
import { ClientTypeEnum, MatchingModeEnum, SubModeEnum } from '@goauthentik/api';
|
import { ClientTypeEnum, SubModeEnum } from '@goauthentik/api';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
const authentikClientSpecSchema = z.object({
|
const authentikClientSpecSchema = z.object({
|
||||||
server: z.string(),
|
secretRef: z.string(),
|
||||||
subMode: z.enum(SubModeEnum).optional(),
|
subMode: z.enum(SubModeEnum).optional(),
|
||||||
clientType: z.enum(ClientTypeEnum).optional(),
|
clientType: z.enum(ClientTypeEnum).optional(),
|
||||||
redirectUris: z.array(
|
redirectUris: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
url: z.string(),
|
url: z.string(),
|
||||||
matchingMode: z.enum(MatchingModeEnum).optional(),
|
matchingMode: z.enum(['strict', 'regex']),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const authentikClientServerSecretSchema = z.object({
|
||||||
|
internal_url: z.string(),
|
||||||
|
external_url: z.string(),
|
||||||
|
token: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
const authentikClientSecretSchema = z.object({
|
const authentikClientSecretSchema = z.object({
|
||||||
clientId: z.string(),
|
clientId: z.string(),
|
||||||
clientSecret: z.string().optional(),
|
clientSecret: z.string().optional(),
|
||||||
@@ -25,4 +31,4 @@ const authentikClientSecretSchema = z.object({
|
|||||||
jwks: z.string(),
|
jwks: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export { authentikClientSpecSchema, authentikClientSecretSchema };
|
export { authentikClientSpecSchema, authentikClientSecretSchema, authentikClientServerSecretSchema };
|
||||||
|
|||||||
@@ -1,176 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,382 +0,0 @@
|
|||||||
import type { V1Service, V1Deployment, V1Secret } from '@kubernetes/client-node';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
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 { isDeepSubset } from '../../utils/objects.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 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: `authentik-server-${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 (!isDeepSubset(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 (!isDeepSubset(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 (!isDeepSubset(manifest.spec, this.#service.manifest)) {
|
|
||||||
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: this.spec.subdomain,
|
|
||||||
});
|
|
||||||
if (!isDeepSubset(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 };
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
const authentikServerSpecSchema = z.object({
|
|
||||||
domain: z.string(),
|
|
||||||
subdomain: z.string(),
|
|
||||||
database: z.string(),
|
|
||||||
redis: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const authentikServerSecretSchema = z.object({
|
|
||||||
secret: z.string(),
|
|
||||||
password: z.string(),
|
|
||||||
token: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export { authentikServerSpecSchema, authentikServerSecretSchema };
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,25 +1,7 @@
|
|||||||
import { authentikServerDefinition } from './authentik-server/authentik-server.ts';
|
|
||||||
import { authentikClientDefinition } from './authentik-client/authentik-client.ts';
|
import { authentikClientDefinition } from './authentik-client/authentik-client.ts';
|
||||||
import { domainServiceDefinition } from './domain-service/domain-service.ts';
|
import { generateSecretDefinition } from './generate-secret/generate-secret.ts';
|
||||||
import { domainDefinition } from './domain/domain.ts';
|
|
||||||
import { postgresConnectionDefinition } from './postgres-connection/postgres-connection.ts';
|
|
||||||
import { postgresDatabaseDefinition } from './postgres-database/postgres-database.ts';
|
import { postgresDatabaseDefinition } from './postgres-database/postgres-database.ts';
|
||||||
import { redisConnectionDefinition } from './redis-connection/redis-connection.ts';
|
|
||||||
import { homelabDefinition } from './homelab/homelab.ts';
|
|
||||||
import { postgresClusterDefinition } from './postgres-cluster/postgres-cluster.ts';
|
|
||||||
import { redisServerDefinition } from './redis-server/redis-server.ts';
|
|
||||||
|
|
||||||
const customResources = [
|
const customResources = [postgresDatabaseDefinition, authentikClientDefinition, generateSecretDefinition];
|
||||||
homelabDefinition,
|
|
||||||
domainDefinition,
|
|
||||||
domainServiceDefinition,
|
|
||||||
postgresClusterDefinition,
|
|
||||||
postgresConnectionDefinition,
|
|
||||||
postgresDatabaseDefinition,
|
|
||||||
redisServerDefinition,
|
|
||||||
redisConnectionDefinition,
|
|
||||||
authentikServerDefinition,
|
|
||||||
authentikClientDefinition,
|
|
||||||
];
|
|
||||||
|
|
||||||
export { customResources };
|
export { customResources };
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
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: 'homelab', // TODO: use namespace of gateway controller
|
|
||||||
},
|
|
||||||
spec: {
|
|
||||||
secretName: options.secretName,
|
|
||||||
dnsNames: [`*.${options.domain}`],
|
|
||||||
issuerRef: {
|
|
||||||
name: options.issuer,
|
|
||||||
kind: 'ClusterIssuer',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export { createGatewayManifest, createCertificateManifest };
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
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: 'homelab',
|
|
||||||
});
|
|
||||||
|
|
||||||
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 || 'gateway-controller',
|
|
||||||
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 };
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
const domainSpecSchema = z.object({
|
|
||||||
hostname: z.string(),
|
|
||||||
issuer: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export { domainSpecSchema };
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
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,61 @@
|
|||||||
|
import type { V1Secret } from '@kubernetes/client-node';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CustomResource,
|
||||||
|
type CustomResourceOptions,
|
||||||
|
} from '../../services/custom-resources/custom-resources.custom-resource.ts';
|
||||||
|
import { Resource, ResourceService } from '../../services/resources/resources.ts';
|
||||||
|
import { decodeSecret, encodeSecret } from '../../utils/secrets.ts';
|
||||||
|
import { isDeepSubset } from '../../utils/objects.ts';
|
||||||
|
|
||||||
|
import { generateSecrets } from './generate-secret.utils.ts';
|
||||||
|
import { generateSecretSpecSchema } from './generate-secret.schemas.ts';
|
||||||
|
|
||||||
|
class GenerateSecretResource extends CustomResource<typeof generateSecretSpecSchema> {
|
||||||
|
#secretResource: Resource<V1Secret>;
|
||||||
|
|
||||||
|
constructor(options: CustomResourceOptions<typeof generateSecretSpecSchema>) {
|
||||||
|
super(options);
|
||||||
|
const resourceService = this.services.get(ResourceService);
|
||||||
|
|
||||||
|
this.#secretResource = resourceService.get({
|
||||||
|
apiVersion: 'v1',
|
||||||
|
kind: 'Secret',
|
||||||
|
name: this.name,
|
||||||
|
namespace: this.namespace,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.#secretResource.on('changed', this.queueReconcile);
|
||||||
|
}
|
||||||
|
|
||||||
|
public reconcile = async () => {
|
||||||
|
if (!this.exists || this.metadata?.deletionTimestamp) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const secrets = generateSecrets(this.spec);
|
||||||
|
const current = decodeSecret(this.#secretResource.data) || {};
|
||||||
|
|
||||||
|
const expected = {
|
||||||
|
...current,
|
||||||
|
...secrets,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isDeepSubset(current, expected)) {
|
||||||
|
this.#secretResource.patch({
|
||||||
|
data: encodeSecret(expected),
|
||||||
|
});
|
||||||
|
this.conditions.set('SecretUpdated', {
|
||||||
|
status: 'False',
|
||||||
|
reason: 'SecretUpdated',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.conditions.set('Ready', {
|
||||||
|
status: 'True',
|
||||||
|
reason: 'Ready',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { GenerateSecretResource };
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const generateSecretFieldSchema = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
value: z.string().optional(),
|
||||||
|
encoding: z.enum(['base64', 'base64url', 'hex', 'utf8', 'numeric']).optional(),
|
||||||
|
length: z.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const generateSecretSpecSchema = z.object({
|
||||||
|
fields: z.array(generateSecretFieldSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
type GenerateSecretField = z.infer<typeof generateSecretFieldSchema>;
|
||||||
|
type GenerateSecretSpec = z.infer<typeof generateSecretSpecSchema>;
|
||||||
|
|
||||||
|
export { generateSecretSpecSchema, type GenerateSecretField, type GenerateSecretSpec };
|
||||||
19
src/custom-resouces/generate-secret/generate-secret.ts
Normal file
19
src/custom-resouces/generate-secret/generate-secret.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts';
|
||||||
|
import { GROUP } from '../../utils/consts.ts';
|
||||||
|
|
||||||
|
import { GenerateSecretResource } from './generate-secret.resource.ts';
|
||||||
|
import { generateSecretSpecSchema } from './generate-secret.schemas.ts';
|
||||||
|
|
||||||
|
const generateSecretDefinition = createCustomResourceDefinition({
|
||||||
|
group: GROUP,
|
||||||
|
version: 'v1',
|
||||||
|
kind: 'GenerateSecret',
|
||||||
|
names: {
|
||||||
|
plural: 'generate-secrets',
|
||||||
|
singular: 'generate-secret',
|
||||||
|
},
|
||||||
|
spec: generateSecretSpecSchema,
|
||||||
|
create: (options) => new GenerateSecretResource(options),
|
||||||
|
});
|
||||||
|
|
||||||
|
export { generateSecretDefinition };
|
||||||
69
src/custom-resouces/generate-secret/generate-secret.utils.ts
Normal file
69
src/custom-resouces/generate-secret/generate-secret.utils.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import * as crypto from 'crypto';
|
||||||
|
|
||||||
|
import type { GenerateSecretField, GenerateSecretSpec } from './generate-secret.schemas.ts';
|
||||||
|
|
||||||
|
const generateRandomString = (length: number, encoding: GenerateSecretField['encoding']): string => {
|
||||||
|
let byteLength = 0;
|
||||||
|
switch (encoding) {
|
||||||
|
case 'base64':
|
||||||
|
case 'base64url':
|
||||||
|
// Base64 uses 4 characters for every 3 bytes, so we'll generate slightly more bytes
|
||||||
|
// than the final length to ensure we can get a string of at least the required length.
|
||||||
|
byteLength = Math.ceil((length * 3) / 4);
|
||||||
|
break;
|
||||||
|
case 'hex':
|
||||||
|
byteLength = Math.ceil(length / 2);
|
||||||
|
break;
|
||||||
|
case 'numeric':
|
||||||
|
case 'utf8':
|
||||||
|
byteLength = length;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const randomBytes = crypto.randomBytes(byteLength);
|
||||||
|
|
||||||
|
let resultString = '';
|
||||||
|
|
||||||
|
switch (encoding) {
|
||||||
|
case 'base64':
|
||||||
|
resultString = randomBytes.toString('base64');
|
||||||
|
break;
|
||||||
|
case 'base64url':
|
||||||
|
resultString = randomBytes.toString('base64url');
|
||||||
|
break;
|
||||||
|
case 'hex':
|
||||||
|
resultString = randomBytes.toString('hex');
|
||||||
|
break;
|
||||||
|
case 'numeric':
|
||||||
|
resultString = Array.from(randomBytes)
|
||||||
|
.map((b) => (b % 10).toString()) // Get a single digit from each byte
|
||||||
|
.join('');
|
||||||
|
break;
|
||||||
|
case 'utf8':
|
||||||
|
resultString = randomBytes.toString('utf8');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return resultString.slice(0, length);
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateSecrets = (spec: GenerateSecretSpec): Record<string, string> => {
|
||||||
|
const secrets: Record<string, string> = {};
|
||||||
|
|
||||||
|
for (const field of spec.fields) {
|
||||||
|
if (field.value !== undefined) {
|
||||||
|
// If a value is provided, use it directly.
|
||||||
|
secrets[field.name] = field.value;
|
||||||
|
} else {
|
||||||
|
// Generate a new secret based on the specification.
|
||||||
|
// Use default values if encoding or length are not provided.
|
||||||
|
const encoding = field.encoding || 'base64url';
|
||||||
|
const length = field.length || 32;
|
||||||
|
secrets[field.name] = generateRandomString(length, encoding);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return secrets;
|
||||||
|
};
|
||||||
|
|
||||||
|
export { generateRandomString, generateSecrets };
|
||||||
@@ -1,297 +0,0 @@
|
|||||||
import type { KubernetesObject } from '@kubernetes/client-node';
|
|
||||||
|
|
||||||
import type { K8SHelmRepositoryV1 } from '../../__generated__/resources/K8SHelmRepositoryV1.ts';
|
|
||||||
import type { K8SHelmReleaseV2 } from '../../__generated__/resources/K8SHelmReleaseV2.ts';
|
|
||||||
|
|
||||||
type IstioRepoManifestOptions = {
|
|
||||||
owner: ExpectedAny;
|
|
||||||
};
|
|
||||||
const istioRepoManifest = (options: IstioRepoManifestOptions): KubernetesObject & K8SHelmRepositoryV1 => {
|
|
||||||
return {
|
|
||||||
apiVersion: 'source.toolkit.fluxcd.io/v1beta1',
|
|
||||||
kind: 'HelmRepository',
|
|
||||||
metadata: {
|
|
||||||
ownerReferences: [options.owner],
|
|
||||||
},
|
|
||||||
spec: {
|
|
||||||
interval: '1h',
|
|
||||||
url: 'https://istio-release.storage.googleapis.com/charts',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type CertManagerRepoManifestOptions = {
|
|
||||||
owner: ExpectedAny;
|
|
||||||
};
|
|
||||||
const certManagerRepoManifest = (options: CertManagerRepoManifestOptions): KubernetesObject & K8SHelmRepositoryV1 => {
|
|
||||||
return {
|
|
||||||
apiVersion: 'source.toolkit.fluxcd.io/v1',
|
|
||||||
kind: 'HelmRepository',
|
|
||||||
metadata: {
|
|
||||||
ownerReferences: [options.owner],
|
|
||||||
},
|
|
||||||
spec: {
|
|
||||||
interval: '1h',
|
|
||||||
url: 'https://charts.jetstack.io',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type RanchRepoManifestOptions = {
|
|
||||||
owner: ExpectedAny;
|
|
||||||
};
|
|
||||||
const ranchRepoManifest = (options: RanchRepoManifestOptions): KubernetesObject & K8SHelmRepositoryV1 => {
|
|
||||||
return {
|
|
||||||
apiVersion: 'source.toolkit.fluxcd.io/v1',
|
|
||||||
kind: 'HelmRepository',
|
|
||||||
metadata: {
|
|
||||||
ownerReferences: [options.owner],
|
|
||||||
},
|
|
||||||
spec: {
|
|
||||||
interval: '1h',
|
|
||||||
url: 'https://charts.containeroo.ch',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type IstioBaseManifestOptions = {
|
|
||||||
owner: ExpectedAny;
|
|
||||||
};
|
|
||||||
const istioBaseManifest = (options: IstioBaseManifestOptions): KubernetesObject & K8SHelmReleaseV2 => {
|
|
||||||
return {
|
|
||||||
apiVersion: 'helm.toolkit.fluxcd.io/v2',
|
|
||||||
kind: 'HelmRelease',
|
|
||||||
metadata: {
|
|
||||||
ownerReferences: [options.owner],
|
|
||||||
},
|
|
||||||
spec: {
|
|
||||||
interval: '1h',
|
|
||||||
targetNamespace: 'istio-system',
|
|
||||||
install: {
|
|
||||||
createNamespace: true,
|
|
||||||
},
|
|
||||||
values: {
|
|
||||||
defaultRevision: 'default',
|
|
||||||
},
|
|
||||||
chart: {
|
|
||||||
spec: {
|
|
||||||
chart: 'base',
|
|
||||||
sourceRef: {
|
|
||||||
apiVersion: 'source.toolkit.fluxcd.io/v1',
|
|
||||||
kind: 'HelmRepository',
|
|
||||||
name: 'homelab-istio',
|
|
||||||
},
|
|
||||||
reconcileStrategy: 'ChartVersion',
|
|
||||||
version: '1.24.3',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type IstiodManifestOptions = {
|
|
||||||
owner: ExpectedAny;
|
|
||||||
namespace: string;
|
|
||||||
};
|
|
||||||
const istiodManifest = (options: IstiodManifestOptions): KubernetesObject & K8SHelmReleaseV2 => {
|
|
||||||
return {
|
|
||||||
apiVersion: 'helm.toolkit.fluxcd.io/v2',
|
|
||||||
kind: 'HelmRelease',
|
|
||||||
metadata: {
|
|
||||||
ownerReferences: [options.owner],
|
|
||||||
},
|
|
||||||
spec: {
|
|
||||||
targetNamespace: 'istio-system',
|
|
||||||
interval: '1h',
|
|
||||||
install: {
|
|
||||||
createNamespace: true,
|
|
||||||
},
|
|
||||||
dependsOn: [
|
|
||||||
{
|
|
||||||
name: 'istio',
|
|
||||||
namespace: options.namespace,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
chart: {
|
|
||||||
spec: {
|
|
||||||
chart: 'istiod',
|
|
||||||
sourceRef: {
|
|
||||||
apiVersion: 'source.toolkit.fluxcd.io/v1',
|
|
||||||
kind: 'HelmRepository',
|
|
||||||
name: 'homelab-istio',
|
|
||||||
},
|
|
||||||
reconcileStrategy: 'ChartVersion',
|
|
||||||
version: '1.24.3',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type IstioGatewayControllerManifestOptions = {
|
|
||||||
owner: ExpectedAny;
|
|
||||||
namespace: string;
|
|
||||||
};
|
|
||||||
const istioGatewayControllerManifest = (
|
|
||||||
options: IstioGatewayControllerManifestOptions,
|
|
||||||
): KubernetesObject & K8SHelmReleaseV2 => {
|
|
||||||
return {
|
|
||||||
apiVersion: 'helm.toolkit.fluxcd.io/v2',
|
|
||||||
kind: 'HelmRelease',
|
|
||||||
metadata: {
|
|
||||||
ownerReferences: [options.owner],
|
|
||||||
},
|
|
||||||
spec: {
|
|
||||||
interval: '1h',
|
|
||||||
install: {
|
|
||||||
createNamespace: true,
|
|
||||||
},
|
|
||||||
dependsOn: [
|
|
||||||
{
|
|
||||||
name: 'istio',
|
|
||||||
namespace: options.namespace,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'istiod',
|
|
||||||
namespace: options.namespace,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
values: {
|
|
||||||
service: {
|
|
||||||
ports: [
|
|
||||||
{
|
|
||||||
name: 'status-port',
|
|
||||||
port: 15021,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'tls-istiod',
|
|
||||||
port: 15012,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'tls',
|
|
||||||
port: 15443,
|
|
||||||
nodePort: 31371,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'http2',
|
|
||||||
port: 80,
|
|
||||||
nodePort: 31381,
|
|
||||||
targetPort: 8280,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'https',
|
|
||||||
port: 443,
|
|
||||||
nodePort: 31391,
|
|
||||||
targetPort: 8243,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
chart: {
|
|
||||||
spec: {
|
|
||||||
chart: 'gateway',
|
|
||||||
sourceRef: {
|
|
||||||
apiVersion: 'source.toolkit.fluxcd.io/v1',
|
|
||||||
kind: 'HelmRepository',
|
|
||||||
name: 'homelab-istio',
|
|
||||||
},
|
|
||||||
reconcileStrategy: 'ChartVersion',
|
|
||||||
version: '1.24.3',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type CertManagerManifestOptions = {
|
|
||||||
owner: ExpectedAny;
|
|
||||||
};
|
|
||||||
const certManagerManifest = (options: CertManagerManifestOptions): KubernetesObject & K8SHelmReleaseV2 => {
|
|
||||||
return {
|
|
||||||
apiVersion: 'helm.toolkit.fluxcd.io/v2',
|
|
||||||
kind: 'HelmRelease',
|
|
||||||
metadata: {
|
|
||||||
ownerReferences: [options.owner],
|
|
||||||
},
|
|
||||||
spec: {
|
|
||||||
targetNamespace: 'cert-manager',
|
|
||||||
interval: '1h',
|
|
||||||
install: {
|
|
||||||
createNamespace: true,
|
|
||||||
},
|
|
||||||
values: {
|
|
||||||
installCRDs: true,
|
|
||||||
},
|
|
||||||
chart: {
|
|
||||||
spec: {
|
|
||||||
chart: 'cert-manager',
|
|
||||||
sourceRef: {
|
|
||||||
apiVersion: 'source.toolkit.fluxcd.io/v1',
|
|
||||||
kind: 'HelmRepository',
|
|
||||||
name: 'cert-manager',
|
|
||||||
},
|
|
||||||
version: 'v1.18.2',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type LocalStorageManifestOptions = {
|
|
||||||
owner: ExpectedAny;
|
|
||||||
storagePath: string;
|
|
||||||
};
|
|
||||||
const localStorageManifest = (options: LocalStorageManifestOptions): KubernetesObject & K8SHelmReleaseV2 => {
|
|
||||||
return {
|
|
||||||
apiVersion: 'helm.toolkit.fluxcd.io/v2',
|
|
||||||
kind: 'HelmRelease',
|
|
||||||
metadata: {
|
|
||||||
ownerReferences: [options.owner],
|
|
||||||
},
|
|
||||||
spec: {
|
|
||||||
targetNamespace: 'local-path-storage',
|
|
||||||
interval: '1h',
|
|
||||||
install: {
|
|
||||||
createNamespace: true,
|
|
||||||
},
|
|
||||||
values: {
|
|
||||||
storageClass: {
|
|
||||||
name: 'local-path',
|
|
||||||
provisionerName: 'rancher.io/local-path',
|
|
||||||
defaultClass: true,
|
|
||||||
},
|
|
||||||
nodePathMap: [
|
|
||||||
{
|
|
||||||
node: 'DEFAULT_PATH_FOR_NON_LISTED_NODES',
|
|
||||||
paths: [options.storagePath],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
helper: {
|
|
||||||
reclaimPolicy: 'Retain',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
chart: {
|
|
||||||
spec: {
|
|
||||||
chart: 'local-path-provisioner',
|
|
||||||
sourceRef: {
|
|
||||||
apiVersion: 'source.toolkit.fluxcd.io/v1',
|
|
||||||
kind: 'HelmRepository',
|
|
||||||
name: 'rancher',
|
|
||||||
},
|
|
||||||
version: '0.0.32',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export {
|
|
||||||
istioRepoManifest,
|
|
||||||
istioBaseManifest,
|
|
||||||
istiodManifest,
|
|
||||||
istioGatewayControllerManifest,
|
|
||||||
certManagerRepoManifest,
|
|
||||||
certManagerManifest,
|
|
||||||
ranchRepoManifest,
|
|
||||||
localStorageManifest,
|
|
||||||
};
|
|
||||||
@@ -1,263 +0,0 @@
|
|||||||
import { type KubernetesObject } from '@kubernetes/client-node';
|
|
||||||
|
|
||||||
import {
|
|
||||||
CustomResource,
|
|
||||||
type CustomResourceOptions,
|
|
||||||
type SubresourceResult,
|
|
||||||
} from '../../services/custom-resources/custom-resources.custom-resource.ts';
|
|
||||||
import { Resource, ResourceService } from '../../services/resources/resources.ts';
|
|
||||||
import type { K8SHelmRepositoryV1 } from '../../__generated__/resources/K8SHelmRepositoryV1.ts';
|
|
||||||
import type { K8SHelmReleaseV2 } from '../../__generated__/resources/K8SHelmReleaseV2.ts';
|
|
||||||
import { isDeepSubset } from '../../utils/objects.ts';
|
|
||||||
|
|
||||||
import type { homelabSpecSchema } from './homelab.schemas.ts';
|
|
||||||
import {
|
|
||||||
certManagerRepoManifest,
|
|
||||||
istioBaseManifest,
|
|
||||||
istiodManifest,
|
|
||||||
istioGatewayControllerManifest,
|
|
||||||
istioRepoManifest,
|
|
||||||
certManagerManifest,
|
|
||||||
ranchRepoManifest,
|
|
||||||
localStorageManifest,
|
|
||||||
} from './homelab.manifests.ts';
|
|
||||||
|
|
||||||
class HomelabResource extends CustomResource<typeof homelabSpecSchema> {
|
|
||||||
#resources: {
|
|
||||||
istioRepo: Resource<KubernetesObject & K8SHelmRepositoryV1>;
|
|
||||||
istioBase: Resource<KubernetesObject & K8SHelmReleaseV2>;
|
|
||||||
istiod: Resource<KubernetesObject & K8SHelmReleaseV2>;
|
|
||||||
istioGatewayController: Resource<KubernetesObject & K8SHelmReleaseV2>;
|
|
||||||
certManagerRepo: Resource<KubernetesObject & K8SHelmRepositoryV1>;
|
|
||||||
certManager: Resource<KubernetesObject & K8SHelmReleaseV2>;
|
|
||||||
ranchRepo: Resource<KubernetesObject & K8SHelmRepositoryV1>;
|
|
||||||
localStorage: Resource<KubernetesObject & K8SHelmReleaseV2>;
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor(options: CustomResourceOptions<typeof homelabSpecSchema>) {
|
|
||||||
super(options);
|
|
||||||
const resourceService = this.services.get(ResourceService);
|
|
||||||
this.#resources = {
|
|
||||||
istioRepo: resourceService.get({
|
|
||||||
apiVersion: 'source.toolkit.fluxcd.io/v1',
|
|
||||||
kind: 'HelmRepository',
|
|
||||||
name: 'homelab-istio',
|
|
||||||
namespace: this.namespace,
|
|
||||||
}),
|
|
||||||
istioBase: resourceService.get({
|
|
||||||
apiVersion: 'helm.toolkit.fluxcd.io/v2',
|
|
||||||
kind: 'HelmRelease',
|
|
||||||
name: 'istio',
|
|
||||||
namespace: this.namespace,
|
|
||||||
}),
|
|
||||||
istiod: resourceService.get({
|
|
||||||
apiVersion: 'helm.toolkit.fluxcd.io/v2',
|
|
||||||
kind: 'HelmRelease',
|
|
||||||
name: 'istiod',
|
|
||||||
namespace: this.namespace,
|
|
||||||
}),
|
|
||||||
istioGatewayController: resourceService.get({
|
|
||||||
apiVersion: 'helm.toolkit.fluxcd.io/v2',
|
|
||||||
kind: 'HelmRelease',
|
|
||||||
name: 'istio-gateway-controller',
|
|
||||||
namespace: this.namespace,
|
|
||||||
}),
|
|
||||||
certManagerRepo: resourceService.get({
|
|
||||||
apiVersion: 'source.toolkit.fluxcd.io/v1',
|
|
||||||
kind: 'HelmRepository',
|
|
||||||
name: 'cert-manager',
|
|
||||||
namespace: this.namespace,
|
|
||||||
}),
|
|
||||||
certManager: resourceService.get({
|
|
||||||
apiVersion: 'helm.toolkit.fluxcd.io/v2',
|
|
||||||
kind: 'HelmRelease',
|
|
||||||
name: 'cert-manager',
|
|
||||||
namespace: this.namespace,
|
|
||||||
}),
|
|
||||||
ranchRepo: resourceService.get({
|
|
||||||
apiVersion: 'source.toolkit.fluxcd.io/v1',
|
|
||||||
kind: 'HelmRepository',
|
|
||||||
name: 'rancher',
|
|
||||||
namespace: this.namespace,
|
|
||||||
}),
|
|
||||||
localStorage: resourceService.get({
|
|
||||||
apiVersion: 'helm.toolkit.fluxcd.io/v2',
|
|
||||||
kind: 'HelmRelease',
|
|
||||||
name: 'local-storage',
|
|
||||||
namespace: this.namespace,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const resource of Object.values(this.#resources)) {
|
|
||||||
resource.on('changed', this.queueReconcile);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#reconcileIstioRepo = async (): Promise<SubresourceResult> => {
|
|
||||||
const istioRepo = this.#resources.istioRepo;
|
|
||||||
const manifest = istioRepoManifest({
|
|
||||||
owner: this.ref,
|
|
||||||
});
|
|
||||||
if (!isDeepSubset(istioRepo.spec, manifest.spec)) {
|
|
||||||
await istioRepo.patch(manifest);
|
|
||||||
return {
|
|
||||||
ready: false,
|
|
||||||
syncing: true,
|
|
||||||
reason: 'UpdatingManifest',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
ready: true,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
#reconcileCertManagerRepo = async (): Promise<SubresourceResult> => {
|
|
||||||
const certManagerRepo = this.#resources.certManagerRepo;
|
|
||||||
const manifest = certManagerRepoManifest({
|
|
||||||
owner: this.ref,
|
|
||||||
});
|
|
||||||
if (!isDeepSubset(certManagerRepo.spec, manifest.spec)) {
|
|
||||||
await certManagerRepo.patch(manifest);
|
|
||||||
return {
|
|
||||||
ready: false,
|
|
||||||
syncing: true,
|
|
||||||
reason: 'UpdatingManifest',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
ready: true,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
#reconcileRanchRepo = async (): Promise<SubresourceResult> => {
|
|
||||||
const ranchRepo = this.#resources.ranchRepo;
|
|
||||||
const manifest = ranchRepoManifest({
|
|
||||||
owner: this.ref,
|
|
||||||
});
|
|
||||||
if (!isDeepSubset(ranchRepo.spec, manifest.spec)) {
|
|
||||||
await ranchRepo.patch(manifest);
|
|
||||||
return {
|
|
||||||
ready: false,
|
|
||||||
syncing: true,
|
|
||||||
reason: 'UpdatingManifest',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
ready: true,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
#reconcileIstioBase = async (): Promise<SubresourceResult> => {
|
|
||||||
const istioBase = this.#resources.istioBase;
|
|
||||||
const manifest = istioBaseManifest({
|
|
||||||
owner: this.ref,
|
|
||||||
});
|
|
||||||
if (!isDeepSubset(istioBase.spec, manifest.spec)) {
|
|
||||||
await istioBase.patch(manifest);
|
|
||||||
return {
|
|
||||||
ready: false,
|
|
||||||
syncing: true,
|
|
||||||
reason: 'UpdatingManifest',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
ready: true,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
#reconcileIstiod = async (): Promise<SubresourceResult> => {
|
|
||||||
const istiod = this.#resources.istiod;
|
|
||||||
const manifest = istiodManifest({
|
|
||||||
owner: this.ref,
|
|
||||||
namespace: this.namespace,
|
|
||||||
});
|
|
||||||
if (!isDeepSubset(istiod.spec, manifest.spec)) {
|
|
||||||
await istiod.patch(manifest);
|
|
||||||
return {
|
|
||||||
ready: false,
|
|
||||||
syncing: true,
|
|
||||||
reason: 'UpdatingManifest',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
ready: true,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
#reconcileIstioGatewayController = async (): Promise<SubresourceResult> => {
|
|
||||||
const istioGatewayController = this.#resources.istioGatewayController;
|
|
||||||
const manifest = istioGatewayControllerManifest({
|
|
||||||
owner: this.ref,
|
|
||||||
namespace: this.namespace,
|
|
||||||
});
|
|
||||||
if (!isDeepSubset(istioGatewayController.spec, manifest.spec)) {
|
|
||||||
await istioGatewayController.patch(manifest);
|
|
||||||
return {
|
|
||||||
ready: false,
|
|
||||||
syncing: true,
|
|
||||||
reason: 'UpdatingManifest',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
ready: true,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
#reconcileCertManager = async (): Promise<SubresourceResult> => {
|
|
||||||
const certManager = this.#resources.certManager;
|
|
||||||
const manifest = certManagerManifest({
|
|
||||||
owner: this.ref,
|
|
||||||
});
|
|
||||||
if (!isDeepSubset(certManager.spec, manifest.spec)) {
|
|
||||||
await certManager.patch(manifest);
|
|
||||||
return {
|
|
||||||
ready: false,
|
|
||||||
syncing: true,
|
|
||||||
reason: 'UpdatingManifest',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
ready: true,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
#reconcileLocalStorage = async (): Promise<SubresourceResult> => {
|
|
||||||
const storage = this.spec.storage;
|
|
||||||
if (!storage || !storage.enabled) {
|
|
||||||
return {
|
|
||||||
ready: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const localStorage = this.#resources.localStorage;
|
|
||||||
const manifest = localStorageManifest({
|
|
||||||
owner: this.ref,
|
|
||||||
storagePath: storage.path,
|
|
||||||
});
|
|
||||||
if (!isDeepSubset(localStorage.spec, manifest.spec)) {
|
|
||||||
await localStorage.patch(manifest);
|
|
||||||
return {
|
|
||||||
ready: false,
|
|
||||||
syncing: true,
|
|
||||||
reason: 'UpdatingManifest',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
ready: true,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
public reconcile = async () => {
|
|
||||||
await Promise.allSettled([
|
|
||||||
this.reconcileSubresource('IstioRepo', this.#reconcileIstioRepo),
|
|
||||||
this.reconcileSubresource('CertManagerRepo', this.#reconcileCertManagerRepo),
|
|
||||||
this.reconcileSubresource('IstioBase', this.#reconcileIstioBase),
|
|
||||||
this.reconcileSubresource('Istiod', this.#reconcileIstiod),
|
|
||||||
this.reconcileSubresource('IstioGatewayController', this.#reconcileIstioGatewayController),
|
|
||||||
this.reconcileSubresource('CertManager', this.#reconcileCertManager),
|
|
||||||
this.reconcileSubresource('RanchRepo', this.#reconcileRanchRepo),
|
|
||||||
this.reconcileSubresource('LocalStorage', this.#reconcileLocalStorage),
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export { HomelabResource };
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
const homelabSpecSchema = z.object({
|
|
||||||
storage: z
|
|
||||||
.object({
|
|
||||||
enabled: z.boolean(),
|
|
||||||
path: z.string(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const homelabSecretSchema = z.object({
|
|
||||||
postgresPassword: z.string(),
|
|
||||||
redisPassword: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export { homelabSpecSchema, homelabSecretSchema };
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts';
|
|
||||||
import { GROUP } from '../../utils/consts.ts';
|
|
||||||
|
|
||||||
import { HomelabResource } from './homelab.resource.ts';
|
|
||||||
import { homelabSpecSchema } from './homelab.schemas.ts';
|
|
||||||
|
|
||||||
const homelabDefinition = createCustomResourceDefinition({
|
|
||||||
group: GROUP,
|
|
||||||
version: 'v1',
|
|
||||||
kind: 'Homelab',
|
|
||||||
names: {
|
|
||||||
plural: 'homelabs',
|
|
||||||
singular: 'homelab',
|
|
||||||
},
|
|
||||||
spec: homelabSpecSchema,
|
|
||||||
create: (options) => new HomelabResource(options),
|
|
||||||
});
|
|
||||||
|
|
||||||
export { homelabDefinition };
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
import type { V1Deployment, V1PersistentVolumeClaim, V1Service } from '@kubernetes/client-node';
|
|
||||||
|
|
||||||
import type { CustomResourceObject } from '../../services/custom-resources/custom-resources.custom-resource.ts';
|
|
||||||
import type { postgresConnectionSpecSchema } from '../postgres-connection/posgtres-connection.schemas.ts';
|
|
||||||
import { API_VERSION } from '../../utils/consts.ts';
|
|
||||||
|
|
||||||
type PvcOptions = {
|
|
||||||
name: string;
|
|
||||||
owner: ExpectedAny;
|
|
||||||
};
|
|
||||||
const pvcManifest = (options: PvcOptions): V1PersistentVolumeClaim => {
|
|
||||||
return {
|
|
||||||
apiVersion: 'v1',
|
|
||||||
kind: 'PersistentVolumeClaim',
|
|
||||||
metadata: {
|
|
||||||
ownerReferences: [options.owner],
|
|
||||||
name: options.name,
|
|
||||||
labels: {
|
|
||||||
app: options.name,
|
|
||||||
},
|
|
||||||
annotations: {
|
|
||||||
'volume.kubernetes.io/storage-class': 'local-path',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
spec: {
|
|
||||||
accessModes: ['ReadWriteOnce'],
|
|
||||||
resources: {
|
|
||||||
requests: {
|
|
||||||
storage: '10Gi',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type DeploymentManifetOptions = {
|
|
||||||
name: string;
|
|
||||||
owner: ExpectedAny;
|
|
||||||
user: string;
|
|
||||||
password: string;
|
|
||||||
};
|
|
||||||
const deploymentManifest = (options: DeploymentManifetOptions): V1Deployment => {
|
|
||||||
return {
|
|
||||||
apiVersion: 'apps/v1',
|
|
||||||
kind: 'Deployment',
|
|
||||||
metadata: {
|
|
||||||
ownerReferences: [options.owner],
|
|
||||||
},
|
|
||||||
spec: {
|
|
||||||
replicas: 1,
|
|
||||||
selector: {
|
|
||||||
matchLabels: {
|
|
||||||
app: options.name,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
template: {
|
|
||||||
metadata: {
|
|
||||||
labels: {
|
|
||||||
app: options.name,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
spec: {
|
|
||||||
volumes: [{ name: options.name, persistentVolumeClaim: { claimName: options.name } }],
|
|
||||||
containers: [
|
|
||||||
{
|
|
||||||
name: options.name,
|
|
||||||
image: 'postgres:17',
|
|
||||||
ports: [{ containerPort: 5432 }],
|
|
||||||
volumeMounts: [{ mountPath: '/var/lib/postgresql/data', name: options.name }],
|
|
||||||
env: [
|
|
||||||
{ name: 'POSTGRES_USER', value: options.user },
|
|
||||||
{ name: 'POSTGRES_PASSWORD', value: options.password },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type ServiceManifestOptions = {
|
|
||||||
name: string;
|
|
||||||
owner: ExpectedAny;
|
|
||||||
};
|
|
||||||
const serviceManifest = (options: ServiceManifestOptions): V1Service => {
|
|
||||||
return {
|
|
||||||
apiVersion: 'v1',
|
|
||||||
kind: 'Service',
|
|
||||||
metadata: {
|
|
||||||
ownerReferences: [options.owner],
|
|
||||||
name: options.name,
|
|
||||||
labels: {
|
|
||||||
app: options.name,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
spec: {
|
|
||||||
type: 'ClusterIP',
|
|
||||||
ports: [{ port: 5432, targetPort: 5432 }],
|
|
||||||
selector: {
|
|
||||||
app: options.name,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type ConnectionManifestOptions = {
|
|
||||||
name: string;
|
|
||||||
owner: ExpectedAny;
|
|
||||||
};
|
|
||||||
const connectionManifest = (
|
|
||||||
options: ConnectionManifestOptions,
|
|
||||||
): CustomResourceObject<typeof postgresConnectionSpecSchema> => ({
|
|
||||||
apiVersion: API_VERSION,
|
|
||||||
kind: 'PostgresConnection',
|
|
||||||
metadata: {
|
|
||||||
ownerReferences: [options.owner],
|
|
||||||
},
|
|
||||||
spec: {
|
|
||||||
secret: `${options.name}-secret`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export { pvcManifest, deploymentManifest, serviceManifest, connectionManifest };
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
import type { V1Deployment, V1PersistentVolumeClaim, V1Service } from '@kubernetes/client-node';
|
|
||||||
|
|
||||||
import {
|
|
||||||
CustomResource,
|
|
||||||
type CustomResourceObject,
|
|
||||||
type CustomResourceOptions,
|
|
||||||
type SubresourceResult,
|
|
||||||
} from '../../services/custom-resources/custom-resources.custom-resource.ts';
|
|
||||||
import { ResourceService, type Resource } from '../../services/resources/resources.ts';
|
|
||||||
import {
|
|
||||||
postgresConnectionSecretDataSchema,
|
|
||||||
type postgresConnectionSpecSchema,
|
|
||||||
} from '../postgres-connection/posgtres-connection.schemas.ts';
|
|
||||||
import { API_VERSION } from '../../utils/consts.ts';
|
|
||||||
import { isDeepSubset } from '../../utils/objects.ts';
|
|
||||||
import type { EnsuredSecret } from '../../services/secrets/secrets.secret.ts';
|
|
||||||
import { SecretService } from '../../services/secrets/secrets.ts';
|
|
||||||
|
|
||||||
import type { postgresClusterSpecSchema } from './postgres-cluster.schemas.ts';
|
|
||||||
import { connectionManifest, deploymentManifest, pvcManifest, serviceManifest } from './postgres-cluster.manifests.ts';
|
|
||||||
|
|
||||||
class PostgresClusterResource extends CustomResource<typeof postgresClusterSpecSchema> {
|
|
||||||
#resources: {
|
|
||||||
pvc: Resource<V1PersistentVolumeClaim>;
|
|
||||||
deployment: Resource<V1Deployment>;
|
|
||||||
service: Resource<V1Service>;
|
|
||||||
connection: Resource<CustomResourceObject<typeof postgresConnectionSpecSchema>>;
|
|
||||||
secret: EnsuredSecret<typeof postgresConnectionSecretDataSchema>;
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor(options: CustomResourceOptions<typeof postgresClusterSpecSchema>) {
|
|
||||||
super(options);
|
|
||||||
const resourceService = this.services.get(ResourceService);
|
|
||||||
const secretService = this.services.get(SecretService);
|
|
||||||
this.#resources = {
|
|
||||||
pvc: resourceService.get({
|
|
||||||
apiVersion: 'v1',
|
|
||||||
kind: 'PersistentVolumeClaim',
|
|
||||||
name: this.name,
|
|
||||||
namespace: this.namespace,
|
|
||||||
}),
|
|
||||||
deployment: resourceService.get({
|
|
||||||
apiVersion: 'apps/v1',
|
|
||||||
kind: 'Deployment',
|
|
||||||
name: this.name,
|
|
||||||
namespace: this.namespace,
|
|
||||||
}),
|
|
||||||
service: resourceService.get({
|
|
||||||
apiVersion: 'v1',
|
|
||||||
kind: 'Service',
|
|
||||||
name: this.name,
|
|
||||||
namespace: this.namespace,
|
|
||||||
}),
|
|
||||||
connection: resourceService.get({
|
|
||||||
apiVersion: API_VERSION,
|
|
||||||
kind: 'PostgresConnection',
|
|
||||||
name: this.name,
|
|
||||||
namespace: this.namespace,
|
|
||||||
}),
|
|
||||||
secret: secretService.ensure({
|
|
||||||
name: `${this.name}-secret`,
|
|
||||||
namespace: this.namespace,
|
|
||||||
schema: postgresConnectionSecretDataSchema,
|
|
||||||
generator: () => ({
|
|
||||||
host: `${this.name}.${this.namespace}.svc.cluster.local`,
|
|
||||||
port: '5432',
|
|
||||||
user: 'postgres',
|
|
||||||
password: Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString('hex'),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
#reconcilePvc = async (): Promise<SubresourceResult> => {
|
|
||||||
const pvc = this.#resources.pvc;
|
|
||||||
const manifest = pvcManifest({
|
|
||||||
name: this.name,
|
|
||||||
owner: this.ref,
|
|
||||||
});
|
|
||||||
if (!isDeepSubset(pvc.spec, manifest.spec)) {
|
|
||||||
await pvc.patch(manifest);
|
|
||||||
return {
|
|
||||||
ready: false,
|
|
||||||
syncing: true,
|
|
||||||
reason: 'UpdatingManifest',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
ready: true,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
#reconcileDeployment = async (): Promise<SubresourceResult> => {
|
|
||||||
const secret = this.#resources.secret;
|
|
||||||
if (!secret.isValid || !secret.value) {
|
|
||||||
return {
|
|
||||||
ready: false,
|
|
||||||
syncing: true,
|
|
||||||
reason: 'SecretNotReady',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const deployment = this.#resources.deployment;
|
|
||||||
const manifest = deploymentManifest({
|
|
||||||
name: this.name,
|
|
||||||
owner: this.ref,
|
|
||||||
user: secret.value.user,
|
|
||||||
password: secret.value.password,
|
|
||||||
});
|
|
||||||
if (!isDeepSubset(deployment.spec, manifest.spec)) {
|
|
||||||
await deployment.patch(manifest);
|
|
||||||
return {
|
|
||||||
ready: false,
|
|
||||||
syncing: true,
|
|
||||||
reason: 'UpdatingManifest',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
ready: true,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
#reconcileService = async (): Promise<SubresourceResult> => {
|
|
||||||
const service = this.#resources.service;
|
|
||||||
const manifest = serviceManifest({
|
|
||||||
name: this.name,
|
|
||||||
owner: this.ref,
|
|
||||||
});
|
|
||||||
if (!isDeepSubset(service.spec, manifest.spec)) {
|
|
||||||
await service.patch(manifest);
|
|
||||||
return {
|
|
||||||
ready: false,
|
|
||||||
syncing: true,
|
|
||||||
reason: 'UpdatingManifest',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
ready: true,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
#reconcileConnection = async (): Promise<SubresourceResult> => {
|
|
||||||
const connection = this.#resources.connection;
|
|
||||||
const manifest = connectionManifest({
|
|
||||||
name: this.name,
|
|
||||||
owner: this.ref,
|
|
||||||
});
|
|
||||||
if (!isDeepSubset(connection.spec, manifest.spec)) {
|
|
||||||
await connection.patch(manifest);
|
|
||||||
return {
|
|
||||||
ready: false,
|
|
||||||
syncing: true,
|
|
||||||
reason: 'UpdatingManifest',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
ready: true,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
public reconcile = async () => {
|
|
||||||
await Promise.allSettled([
|
|
||||||
this.reconcileSubresource('PVC', this.#reconcilePvc),
|
|
||||||
this.reconcileSubresource('Deployment', this.#reconcileDeployment),
|
|
||||||
this.reconcileSubresource('Service', this.#reconcileService),
|
|
||||||
this.reconcileSubresource('Connection', this.#reconcileConnection),
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export { PostgresClusterResource };
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
const postgresClusterSpecSchema = z.object({});
|
|
||||||
|
|
||||||
export { postgresClusterSpecSchema };
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts';
|
|
||||||
import { GROUP } from '../../utils/consts.ts';
|
|
||||||
|
|
||||||
import { postgresClusterSpecSchema } from './postgres-cluster.schemas.ts';
|
|
||||||
import { PostgresClusterResource } from './postgres-cluster.resource.ts';
|
|
||||||
|
|
||||||
const postgresClusterDefinition = createCustomResourceDefinition({
|
|
||||||
group: GROUP,
|
|
||||||
version: 'v1',
|
|
||||||
kind: 'PostgresCluster',
|
|
||||||
names: {
|
|
||||||
plural: 'postgresclusters',
|
|
||||||
singular: 'postgrescluster',
|
|
||||||
},
|
|
||||||
spec: postgresClusterSpecSchema,
|
|
||||||
create: (options) => new PostgresClusterResource(options),
|
|
||||||
});
|
|
||||||
|
|
||||||
export { postgresClusterDefinition };
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
import type { V1Secret } from '@kubernetes/client-node';
|
|
||||||
import type { z } from 'zod';
|
|
||||||
|
|
||||||
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 { decodeSecret } from '../../utils/secrets.ts';
|
|
||||||
|
|
||||||
import type {
|
|
||||||
postgresConnectionSecretDataSchema,
|
|
||||||
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 } = decodeSecret<z.infer<typeof postgresConnectionSecretDataSchema>>(
|
|
||||||
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 };
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,7 +1,22 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
const postgresDatabaseSpecSchema = z.object({
|
const postgresDatabaseSpecSchema = z.object({
|
||||||
connection: z.string(),
|
secretRef: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export { postgresDatabaseSpecSchema };
|
const postgresDatabaseSecretSchema = z.object({
|
||||||
|
host: z.string(),
|
||||||
|
port: z.string(),
|
||||||
|
user: z.string(),
|
||||||
|
password: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const postgresDatabaseConnectionSecretSchema = z.object({
|
||||||
|
host: z.string(),
|
||||||
|
port: z.string(),
|
||||||
|
user: z.string(),
|
||||||
|
password: z.string(),
|
||||||
|
database: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export { postgresDatabaseSpecSchema, postgresDatabaseSecretSchema, postgresDatabaseConnectionSecretSchema };
|
||||||
|
|||||||
@@ -3,22 +3,21 @@ import type { V1Secret } from '@kubernetes/client-node';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
CustomResource,
|
CustomResource,
|
||||||
type CustomResourceObject,
|
|
||||||
type CustomResourceOptions,
|
type CustomResourceOptions,
|
||||||
type SubresourceResult,
|
type SubresourceResult,
|
||||||
} from '../../services/custom-resources/custom-resources.custom-resource.ts';
|
} from '../../services/custom-resources/custom-resources.custom-resource.ts';
|
||||||
import { PostgresService } from '../../services/postgres/postgres.service.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 { ResourceReference } from '../../services/resources/resources.ref.ts';
|
||||||
import { Resource, ResourceService } from '../../services/resources/resources.ts';
|
import { Resource, ResourceService } from '../../services/resources/resources.ts';
|
||||||
import { getWithNamespace } from '../../utils/naming.ts';
|
import { getWithNamespace } from '../../utils/naming.ts';
|
||||||
import { API_VERSION } from '../../utils/consts.ts';
|
import { decodeSecret, encodeSecret } from '../../utils/secrets.ts';
|
||||||
import { decodeSecret } from '../../utils/secrets.ts';
|
import { isDeepSubset } from '../../utils/objects.ts';
|
||||||
|
|
||||||
import type { postgresDatabaseSpecSchema } from './portgres-database.schemas.ts';
|
import {
|
||||||
|
postgresDatabaseConnectionSecretSchema,
|
||||||
|
postgresDatabaseSecretSchema,
|
||||||
|
type postgresDatabaseSpecSchema,
|
||||||
|
} from './portgres-database.schemas.ts';
|
||||||
|
|
||||||
const SECRET_READY_CONDITION = 'Secret';
|
const SECRET_READY_CONDITION = 'Secret';
|
||||||
const DATABASE_READY_CONDITION = 'Database';
|
const DATABASE_READY_CONDITION = 'Database';
|
||||||
@@ -32,31 +31,23 @@ const secretDataSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
class PostgresDatabaseResource extends CustomResource<typeof postgresDatabaseSpecSchema> {
|
class PostgresDatabaseResource extends CustomResource<typeof postgresDatabaseSpecSchema> {
|
||||||
#secret: Resource<V1Secret>;
|
#serverSecret: ResourceReference<V1Secret>;
|
||||||
#secretName: string;
|
#databaseSecret: Resource<V1Secret>;
|
||||||
#connection: ResourceReference<CustomResourceObject<typeof postgresConnectionSpecSchema>>;
|
|
||||||
#connectionSecret: ResourceReference<V1Secret>;
|
|
||||||
|
|
||||||
constructor(options: CustomResourceOptions<typeof postgresDatabaseSpecSchema>) {
|
constructor(options: CustomResourceOptions<typeof postgresDatabaseSpecSchema>) {
|
||||||
super(options);
|
super(options);
|
||||||
const resouceService = this.services.get(ResourceService);
|
this.#serverSecret = new ResourceReference();
|
||||||
|
|
||||||
this.#secretName = `postgres-database-${this.name}`;
|
const resourceService = this.services.get(ResourceService);
|
||||||
this.#secret = resouceService.get({
|
this.#databaseSecret = resourceService.get({
|
||||||
apiVersion: 'v1',
|
apiVersion: 'v1',
|
||||||
kind: 'Secret',
|
kind: 'Secret',
|
||||||
name: this.#secretName,
|
name: `${this.name}-connection`,
|
||||||
namespace: this.namespace,
|
namespace: this.namespace,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.#connection = new ResourceReference();
|
|
||||||
this.#connectionSecret = new ResourceReference();
|
|
||||||
|
|
||||||
this.#updateSecret();
|
this.#updateSecret();
|
||||||
|
this.#serverSecret.on('changed', this.queueReconcile);
|
||||||
this.#secret.on('changed', this.queueReconcile);
|
|
||||||
this.#connection.on('changed', this.queueReconcile);
|
|
||||||
this.#connectionSecret.on('changed', this.queueReconcile);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get #dbName() {
|
get #dbName() {
|
||||||
@@ -68,68 +59,52 @@ class PostgresDatabaseResource extends CustomResource<typeof postgresDatabaseSpe
|
|||||||
}
|
}
|
||||||
|
|
||||||
#updateSecret = () => {
|
#updateSecret = () => {
|
||||||
const resouceService = this.services.get(ResourceService);
|
const resourceService = this.services.get(ResourceService);
|
||||||
const connectionNames = getWithNamespace(this.spec.connection, this.namespace);
|
const secretNames = getWithNamespace(this.spec.secretRef, this.namespace);
|
||||||
this.#connection.current = resouceService.get({
|
this.#serverSecret.current = resourceService.get({
|
||||||
apiVersion: API_VERSION,
|
apiVersion: 'v1',
|
||||||
kind: 'PostgresConnection',
|
kind: 'Secret',
|
||||||
name: connectionNames.name,
|
name: secretNames.name,
|
||||||
namespace: connectionNames.namespace,
|
namespace: secretNames.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> => {
|
#reconcileSecret = async (): Promise<SubresourceResult> => {
|
||||||
const connectionSecret = this.#connectionSecret.current;
|
const serverSecret = this.#serverSecret.current;
|
||||||
if (!connectionSecret?.exists || !connectionSecret.data) {
|
const databaseSecret = this.#databaseSecret;
|
||||||
|
|
||||||
|
if (!serverSecret?.exists || !serverSecret.data) {
|
||||||
return {
|
return {
|
||||||
ready: false,
|
ready: false,
|
||||||
failed: true,
|
failed: true,
|
||||||
reason: 'MissingConnectionSecret',
|
reason: 'MissingConnectionSecret',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
const serverSecretData = postgresDatabaseSecretSchema.safeParse(decodeSecret(serverSecret.data));
|
||||||
const connectionSecretData = decodeSecret(connectionSecret.data);
|
if (!serverSecretData.success || !serverSecretData.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 {
|
return {
|
||||||
ready: false,
|
ready: false,
|
||||||
syncing: true,
|
syncing: true,
|
||||||
|
reason: 'SecretMissing',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (parsed.data?.host !== connectionSecretData?.host || parsed.data?.port !== connectionSecretData?.port) {
|
const databaseSecretData = postgresDatabaseConnectionSecretSchema.safeParse(decodeSecret(databaseSecret.data));
|
||||||
this.#secret.patch({
|
const expectedSecret = {
|
||||||
data: {
|
password: crypto.randomUUID(),
|
||||||
host: Buffer.from(connectionSecretData?.host || '').toString('base64'),
|
host: serverSecretData.data.host,
|
||||||
port: connectionSecretData?.port ? Buffer.from(connectionSecretData.port).toString('base64') : undefined,
|
port: serverSecretData.data.port,
|
||||||
},
|
user: this.#userName,
|
||||||
|
database: this.#dbName,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isDeepSubset(databaseSecretData.data, expectedSecret)) {
|
||||||
|
databaseSecret.patch({
|
||||||
|
data: encodeSecret(expectedSecret),
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
ready: false,
|
ready: false,
|
||||||
syncing: true,
|
syncing: true,
|
||||||
|
reason: 'SecretNotReady',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,7 +114,7 @@ class PostgresDatabaseResource extends CustomResource<typeof postgresDatabaseSpe
|
|||||||
};
|
};
|
||||||
|
|
||||||
#reconcileDatabase = async (): Promise<SubresourceResult> => {
|
#reconcileDatabase = async (): Promise<SubresourceResult> => {
|
||||||
const connectionSecret = this.#connectionSecret.current;
|
const connectionSecret = this.#serverSecret.current;
|
||||||
if (!connectionSecret?.exists || !connectionSecret.data) {
|
if (!connectionSecret?.exists || !connectionSecret.data) {
|
||||||
return {
|
return {
|
||||||
ready: false,
|
ready: false,
|
||||||
@@ -148,21 +123,21 @@ class PostgresDatabaseResource extends CustomResource<typeof postgresDatabaseSpe
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const connectionSecretData = postgresConnectionSecretDataSchema.safeParse(decodeSecret(connectionSecret.data));
|
const connectionSecretData = postgresDatabaseSecretSchema.safeParse(decodeSecret(connectionSecret.data));
|
||||||
if (!connectionSecretData.success || !connectionSecretData.data) {
|
if (!connectionSecretData.success || !connectionSecretData.data) {
|
||||||
return {
|
return {
|
||||||
ready: false,
|
ready: false,
|
||||||
syncing: true,
|
syncing: true,
|
||||||
reason: 'ConnectionSecretMissing',
|
reason: 'SecretMissing',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const secretData = secretDataSchema.safeParse(decodeSecret(this.#secret.data));
|
const secretData = postgresDatabaseConnectionSecretSchema.safeParse(decodeSecret(this.#serverSecret.current?.data));
|
||||||
if (!secretData.success || !secretData.data) {
|
if (!secretData.success || !secretData.data) {
|
||||||
return {
|
return {
|
||||||
ready: false,
|
ready: false,
|
||||||
syncing: true,
|
syncing: true,
|
||||||
reason: 'SecretMissing',
|
reason: 'ConnectionSecretMissing',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,7 +161,7 @@ class PostgresDatabaseResource extends CustomResource<typeof postgresDatabaseSpe
|
|||||||
};
|
};
|
||||||
|
|
||||||
public reconcile = async () => {
|
public reconcile = async () => {
|
||||||
if (!this.exists || this.metadata.deletionTimestamp) {
|
if (!this.exists || this.metadata?.deletionTimestamp) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.#updateSecret();
|
this.#updateSecret();
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import type { V1Deployment, V1Service } from '@kubernetes/client-node';
|
|
||||||
|
|
||||||
import type { CustomResourceObject } from '../../services/custom-resources/custom-resources.custom-resource.ts';
|
|
||||||
import type { redisConnectionSpecSchema } from '../redis-connection/redis-connection.schemas.ts';
|
|
||||||
import { API_VERSION, CONTROLLED_LABEL } from '../../utils/consts.ts';
|
|
||||||
|
|
||||||
const deploymentManifest = (): V1Deployment => ({
|
|
||||||
apiVersion: 'apps/v1',
|
|
||||||
kind: 'Deployment',
|
|
||||||
metadata: {
|
|
||||||
name: 'redis-server',
|
|
||||||
namespace: 'homelab',
|
|
||||||
},
|
|
||||||
spec: {
|
|
||||||
replicas: 1,
|
|
||||||
selector: {
|
|
||||||
matchLabels: {
|
|
||||||
app: 'redis-server',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
template: {
|
|
||||||
metadata: {
|
|
||||||
labels: {
|
|
||||||
app: 'redis-server',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
spec: {
|
|
||||||
containers: [
|
|
||||||
{
|
|
||||||
name: 'redis-server',
|
|
||||||
image: 'redis:latest',
|
|
||||||
ports: [
|
|
||||||
{
|
|
||||||
containerPort: 6379,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const serviceManifest = (): V1Service => ({
|
|
||||||
apiVersion: 'v1',
|
|
||||||
kind: 'Service',
|
|
||||||
metadata: {
|
|
||||||
name: 'redis-server',
|
|
||||||
namespace: 'homelab',
|
|
||||||
},
|
|
||||||
spec: {
|
|
||||||
selector: {
|
|
||||||
app: 'redis-server',
|
|
||||||
},
|
|
||||||
ports: [
|
|
||||||
{
|
|
||||||
port: 6379,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
type RedisConnectionManifestOptions = {
|
|
||||||
secretName: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const connectionManifest = (
|
|
||||||
options: RedisConnectionManifestOptions,
|
|
||||||
): CustomResourceObject<typeof redisConnectionSpecSchema> => ({
|
|
||||||
apiVersion: API_VERSION,
|
|
||||||
kind: 'RedisConnection',
|
|
||||||
metadata: {
|
|
||||||
labels: {
|
|
||||||
...CONTROLLED_LABEL,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
spec: {
|
|
||||||
secret: options.secretName,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export { deploymentManifest, serviceManifest, connectionManifest };
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
import type { V1Deployment, V1Service } from '@kubernetes/client-node';
|
|
||||||
|
|
||||||
import {
|
|
||||||
type CustomResourceOptions,
|
|
||||||
CustomResource,
|
|
||||||
type CustomResourceObject,
|
|
||||||
} from '../../services/custom-resources/custom-resources.custom-resource.ts';
|
|
||||||
import {
|
|
||||||
redisConnectionSecretDataSchema,
|
|
||||||
redisConnectionSpecSchema,
|
|
||||||
} from '../redis-connection/redis-connection.schemas.ts';
|
|
||||||
import { Resource, ResourceService } from '../../services/resources/resources.ts';
|
|
||||||
import { API_VERSION } from '../../utils/consts.ts';
|
|
||||||
import type { EnsuredSecret } from '../../services/secrets/secrets.secret.ts';
|
|
||||||
import { SecretService } from '../../services/secrets/secrets.ts';
|
|
||||||
import { isDeepSubset } from '../../utils/objects.ts';
|
|
||||||
|
|
||||||
import { redisServerSpecSchema } from './redis-server.schemas.ts';
|
|
||||||
import { connectionManifest, deploymentManifest, serviceManifest } from './redis-server.manifests.ts';
|
|
||||||
|
|
||||||
class RedisServerResource extends CustomResource<typeof redisServerSpecSchema> {
|
|
||||||
#resources: {
|
|
||||||
deployment: Resource<V1Deployment>;
|
|
||||||
service: Resource<V1Service>;
|
|
||||||
connection: Resource<CustomResourceObject<typeof redisConnectionSpecSchema>>;
|
|
||||||
secret: EnsuredSecret<typeof redisConnectionSecretDataSchema>;
|
|
||||||
};
|
|
||||||
constructor(options: CustomResourceOptions<typeof redisServerSpecSchema>) {
|
|
||||||
super(options);
|
|
||||||
const resourceService = this.services.get(ResourceService);
|
|
||||||
const secretService = this.services.get(SecretService);
|
|
||||||
this.#resources = {
|
|
||||||
deployment: resourceService.get({
|
|
||||||
apiVersion: 'apps/v1',
|
|
||||||
kind: 'Deployment',
|
|
||||||
name: this.name,
|
|
||||||
namespace: this.namespace,
|
|
||||||
}),
|
|
||||||
service: resourceService.get({
|
|
||||||
apiVersion: 'v1',
|
|
||||||
kind: 'Service',
|
|
||||||
name: this.name,
|
|
||||||
namespace: this.namespace,
|
|
||||||
}),
|
|
||||||
connection: resourceService.get({
|
|
||||||
apiVersion: API_VERSION,
|
|
||||||
kind: 'RedisConnection',
|
|
||||||
name: this.name,
|
|
||||||
namespace: this.namespace,
|
|
||||||
}),
|
|
||||||
secret: secretService.ensure({
|
|
||||||
name: `${this.name}-connection`,
|
|
||||||
namespace: this.namespace,
|
|
||||||
schema: redisConnectionSecretDataSchema,
|
|
||||||
generator: () => ({
|
|
||||||
host: `${this.name}.${this.namespace}.svc.cluster.local`,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
#reconcileDeployment = async () => {
|
|
||||||
const { deployment } = this.#resources;
|
|
||||||
const manifest = deploymentManifest();
|
|
||||||
if (!isDeepSubset(deployment.spec, manifest.spec)) {
|
|
||||||
await deployment.patch(manifest);
|
|
||||||
return {
|
|
||||||
ready: false,
|
|
||||||
syncing: true,
|
|
||||||
reason: 'ChangingDeployment',
|
|
||||||
message: 'Deployment need changes',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
ready: true,
|
|
||||||
reason: 'DeploymentReady',
|
|
||||||
message: 'Deployment is ready',
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
#reconcileService = async () => {
|
|
||||||
const { service } = this.#resources;
|
|
||||||
const manifest = serviceManifest();
|
|
||||||
if (!isDeepSubset(service.spec, manifest.spec)) {
|
|
||||||
await service.patch(manifest);
|
|
||||||
return {
|
|
||||||
ready: false,
|
|
||||||
syncing: true,
|
|
||||||
reason: 'ChangingService',
|
|
||||||
message: 'Service need changes',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
ready: true,
|
|
||||||
reason: 'ServiceReady',
|
|
||||||
message: 'Service is ready',
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
#reconcileConnection = async () => {
|
|
||||||
const { connection, secret } = this.#resources;
|
|
||||||
if (!secret.isValid || !secret.value) {
|
|
||||||
return {
|
|
||||||
ready: false,
|
|
||||||
failed: true,
|
|
||||||
reason: 'MissingSecret',
|
|
||||||
message: 'Secret is missing',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const manifest = connectionManifest({
|
|
||||||
secretName: secret.name,
|
|
||||||
});
|
|
||||||
if (!isDeepSubset(connection.spec, manifest.spec)) {
|
|
||||||
await connection.patch(manifest);
|
|
||||||
return {
|
|
||||||
ready: false,
|
|
||||||
syncing: true,
|
|
||||||
reason: 'ChangingConnection',
|
|
||||||
message: 'Connection need changes',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
ready: true,
|
|
||||||
reason: 'ConnectionReady',
|
|
||||||
message: 'Connection is ready',
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
public reconcile = async () => {
|
|
||||||
await Promise.allSettled([
|
|
||||||
this.reconcileSubresource('Deployment', this.#reconcileDeployment),
|
|
||||||
this.reconcileSubresource('Service', this.#reconcileService),
|
|
||||||
this.reconcileSubresource('Connection', this.#reconcileConnection),
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export { RedisServerResource };
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
const redisServerSpecSchema = z.object({});
|
|
||||||
|
|
||||||
export { redisServerSpecSchema };
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts';
|
|
||||||
import { GROUP } from '../../utils/consts.ts';
|
|
||||||
|
|
||||||
import { RedisServerResource } from './redis-server.resource.ts';
|
|
||||||
import { redisServerSpecSchema } from './redis-server.schemas.ts';
|
|
||||||
|
|
||||||
const redisServerDefinition = createCustomResourceDefinition({
|
|
||||||
group: GROUP,
|
|
||||||
version: 'v1',
|
|
||||||
kind: 'RedisServer',
|
|
||||||
names: {
|
|
||||||
plural: 'redis-servers',
|
|
||||||
singular: 'redis-server',
|
|
||||||
},
|
|
||||||
spec: redisServerSpecSchema,
|
|
||||||
create: (options) => new RedisServerResource(options),
|
|
||||||
});
|
|
||||||
|
|
||||||
export { redisServerDefinition };
|
|
||||||
@@ -4,7 +4,6 @@ import { ApiException } from '@kubernetes/client-node';
|
|||||||
import { Services } from './utils/service.ts';
|
import { Services } from './utils/service.ts';
|
||||||
import { CustomResourceService } from './services/custom-resources/custom-resources.ts';
|
import { CustomResourceService } from './services/custom-resources/custom-resources.ts';
|
||||||
import { WatcherService } from './services/watchers/watchers.ts';
|
import { WatcherService } from './services/watchers/watchers.ts';
|
||||||
import { IstioService } from './services/istio/istio.ts';
|
|
||||||
import { customResources } from './custom-resouces/custom-resources.ts';
|
import { customResources } from './custom-resouces/custom-resources.ts';
|
||||||
|
|
||||||
process.on('uncaughtException', (error) => {
|
process.on('uncaughtException', (error) => {
|
||||||
@@ -73,14 +72,6 @@ await watcherService
|
|||||||
})
|
})
|
||||||
.start();
|
.start();
|
||||||
|
|
||||||
await watcherService.watchCustomGroup('networking.istio.io', 'v1', ['gateways', 'virtualservices', 'destinationrules']);
|
|
||||||
await watcherService.watchCustomGroup('source.toolkit.fluxcd.io', 'v1', ['helmrepositories', 'helmcharts']);
|
|
||||||
await watcherService.watchCustomGroup('helm.toolkit.fluxcd.io', 'v2', ['helmreleases']);
|
|
||||||
await watcherService.watchCustomGroup('cert-manager.io', 'v1', ['issuers', 'certificates', 'clusterissuers']);
|
|
||||||
|
|
||||||
const istio = services.get(IstioService);
|
|
||||||
await istio.start();
|
|
||||||
|
|
||||||
const customResourceService = services.get(CustomResourceService);
|
const customResourceService = services.get(CustomResourceService);
|
||||||
customResourceService.register(...customResources);
|
customResourceService.register(...customResources);
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ type AuthentikServerInfo = {
|
|||||||
|
|
||||||
type UpsertClientRequest = {
|
type UpsertClientRequest = {
|
||||||
name: string;
|
name: string;
|
||||||
secret: string;
|
secret?: string;
|
||||||
scopes?: string[];
|
scopes?: string[];
|
||||||
flows?: {
|
flows?: {
|
||||||
authorization: string;
|
authorization: string;
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
import type { V1Deployment } from '@kubernetes/client-node';
|
|
||||||
|
|
||||||
import type { Services } from '../../utils/service.ts';
|
|
||||||
import { ResourceReference } from '../resources/resources.ref.ts';
|
|
||||||
import type { Watcher } from '../watchers/watchers.watcher.ts';
|
|
||||||
import { WatcherService } from '../watchers/watchers.ts';
|
|
||||||
import type { Resource } from '../resources/resources.ts';
|
|
||||||
|
|
||||||
const ISTIO_APP_SELECTOR = 'istio=gateway-controller';
|
|
||||||
|
|
||||||
class IstioService {
|
|
||||||
#gatewayResource: ResourceReference<V1Deployment>;
|
|
||||||
#gatewayWatcher: Watcher<V1Deployment>;
|
|
||||||
|
|
||||||
constructor(services: Services) {
|
|
||||||
this.#gatewayResource = new ResourceReference<V1Deployment>();
|
|
||||||
const watcherService = services.get(WatcherService);
|
|
||||||
this.#gatewayWatcher = watcherService.create({
|
|
||||||
path: '/apis/apps/v1/deployments',
|
|
||||||
list: async (k8s) => {
|
|
||||||
return await k8s.apps.listDeploymentForAllNamespaces({
|
|
||||||
labelSelector: ISTIO_APP_SELECTOR,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
transform: (manifest) => ({
|
|
||||||
apiVersion: 'apps/v1',
|
|
||||||
kind: 'Deployment',
|
|
||||||
...manifest,
|
|
||||||
}),
|
|
||||||
verbs: ['add', 'update', 'delete'],
|
|
||||||
});
|
|
||||||
this.#gatewayWatcher.on('changed', this.#handleChange);
|
|
||||||
}
|
|
||||||
|
|
||||||
#handleChange = (resource: Resource<V1Deployment>) => {
|
|
||||||
this.#gatewayResource.current = resource;
|
|
||||||
};
|
|
||||||
|
|
||||||
public get gateway() {
|
|
||||||
return this.#gatewayResource;
|
|
||||||
}
|
|
||||||
|
|
||||||
public start = async () => {
|
|
||||||
await this.#gatewayWatcher.start();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export { IstioService };
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
apiVersion: 'homelab.mortenolsen.pro/v1'
|
|
||||||
kind: 'AuthentikClient'
|
|
||||||
metadata:
|
|
||||||
name: homelab
|
|
||||||
namespace: homelab
|
|
||||||
spec:
|
|
||||||
server: homelab
|
|
||||||
redirectUris:
|
|
||||||
- url: http://localhost:3000/api/v1/authentik/oauth2/callback
|
|
||||||
matchingMode: strict
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
apiVersion: 'homelab.mortenolsen.pro/v1'
|
|
||||||
kind: 'AuthentikServer'
|
|
||||||
metadata:
|
|
||||||
name: authentik
|
|
||||||
namespace: homelab
|
|
||||||
spec:
|
|
||||||
domain: homelab
|
|
||||||
subdomain: authentik
|
|
||||||
database: postgres
|
|
||||||
redis: redis
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
apiVersion: homelab.mortenolsen.pro/v1
|
|
||||||
kind: DomainService
|
|
||||||
metadata:
|
|
||||||
name: homelab
|
|
||||||
namespace: homelab
|
|
||||||
spec:
|
|
||||||
domain: homelab/homelab
|
|
||||||
subdomain: test
|
|
||||||
destination:
|
|
||||||
host: authentik.svc.cluster.local
|
|
||||||
port:
|
|
||||||
number: 80
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
apiVersion: homelab.mortenolsen.pro/v1
|
|
||||||
kind: Domain
|
|
||||||
metadata:
|
|
||||||
name: homelab
|
|
||||||
namespace: homelab
|
|
||||||
spec:
|
|
||||||
hostname: local.olsen.cloud
|
|
||||||
issuer: letsencrypt-prod
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
apiVersion: homelab.mortenolsen.pro/v1
|
|
||||||
kind: Homelab
|
|
||||||
metadata:
|
|
||||||
name: homelab
|
|
||||||
namespace: homelab
|
|
||||||
spec:
|
|
||||||
storage:
|
|
||||||
enabled: true
|
|
||||||
path: /data/homelab
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
apiVersion: 'homelab.mortenolsen.pro/v1'
|
|
||||||
kind: 'PostgresCluster'
|
|
||||||
metadata:
|
|
||||||
name: 'postgres'
|
|
||||||
namespace: 'homelab'
|
|
||||||
spec: {}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
apiVersion: 'homelab.mortenolsen.pro/v1'
|
|
||||||
kind: 'PostgresDatabase'
|
|
||||||
metadata:
|
|
||||||
name: postgres
|
|
||||||
namespace: 'homelab'
|
|
||||||
spec:
|
|
||||||
connection: homelab/postgres
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
apiVersion: 'homelab.mortenolsen.pro/v1'
|
|
||||||
kind: 'RedisServer'
|
|
||||||
metadata:
|
|
||||||
name: redis
|
|
||||||
namespace: 'homelab'
|
|
||||||
spec: {}
|
|
||||||
Reference in New Issue
Block a user