This commit is contained in:
Morten Olsen
2025-08-07 22:21:33 +02:00
parent cfb90f7c9f
commit 9cdbaf7929
25 changed files with 618 additions and 43 deletions

View File

@@ -15,6 +15,6 @@ fi
kubectl get namespace postgres > /dev/null 2>&1 || kubectl create namespace postgres
# Create the secret
kubectl create secret generic cloudflare-api-token-secret \
kubectl create secret generic cloudflare-api-token \
--namespace cert-manager \
--from-literal=api-token="${CLOUDFLARE_API_KEY}"

View File

@@ -1,6 +1,5 @@
import type { V1Service, V1Deployment, V1Secret } from '@kubernetes/client-node';
import { z } from 'zod';
import deepEqual from 'deep-equal';
import {
CustomResource,
@@ -19,6 +18,7 @@ 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';
@@ -222,7 +222,7 @@ class AuthentikServerResource extends CustomResource<typeof authentikServerSpecS
password: databaseSecret.password,
},
});
if (!deepEqual(this.#deploymentWorkerResource.spec, manifest.spec)) {
if (!isDeepSubset(this.#deploymentWorkerResource.spec, manifest.spec)) {
await this.#deploymentWorkerResource.patch(manifest);
return {
ready: false,
@@ -296,7 +296,7 @@ class AuthentikServerResource extends CustomResource<typeof authentikServerSpecS
password: databaseSecret.password,
},
});
if (!deepEqual(this.#deploymentServerResource.spec, manifest.spec)) {
if (!isDeepSubset(this.#deploymentServerResource.spec, manifest.spec)) {
await this.#deploymentServerResource.patch(manifest);
return {
ready: false,
@@ -317,7 +317,7 @@ class AuthentikServerResource extends CustomResource<typeof authentikServerSpecS
appName: this.#serverName,
});
if (!deepEqual(this.#service.manifest, manifest.spec)) {
if (!isDeepSubset(manifest.spec, this.#service.manifest)) {
await this.#service.patch(manifest);
return {
ready: false,
@@ -337,9 +337,9 @@ class AuthentikServerResource extends CustomResource<typeof authentikServerSpecS
owner: this.ref,
domain: this.spec.domain,
host: `${this.name}.${this.namespace}.svc.cluster.local`,
subdomain: 'authentik',
subdomain: this.spec.subdomain,
});
if (!deepEqual(manifest.spec, this.#domainServiceResource.spec)) {
if (!isDeepSubset(manifest.spec, this.#domainServiceResource.spec)) {
await this.#domainServiceResource.patch(manifest);
return {
ready: false,

View File

@@ -2,6 +2,7 @@ import { z } from 'zod';
const authentikServerSpecSchema = z.object({
domain: z.string(),
subdomain: z.string(),
database: z.string(),
redis: z.string(),
});

View File

@@ -6,13 +6,17 @@ import { postgresConnectionDefinition } from './postgres-connection/postgres-con
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 = [
homelabDefinition,
domainDefinition,
domainServiceDefinition,
postgresClusterDefinition,
postgresConnectionDefinition,
postgresDatabaseDefinition,
redisServerDefinition,
redisConnectionDefinition,
authentikServerDefinition,
authentikClientDefinition,

View File

@@ -58,7 +58,7 @@ const createCertificateManifest = (options: CreateCertificateManifestOptions) =>
kind: 'Certificate',
metadata: {
name: options.name,
namespace: 'istio-ingress',
namespace: 'homelab', // TODO: use namespace of gateway controller
},
spec: {
secretName: options.secretName,

View File

@@ -47,7 +47,7 @@ class DomainResource extends CustomResource<typeof domainSpecSchema> {
apiVersion: 'cert-manager.io/v1',
kind: 'Certificate',
name: `domain-${this.name}`,
namespace: 'istio-ingress',
namespace: 'homelab',
});
this.#gatewayResource.on('changed', this.queueReconcile);
@@ -85,7 +85,7 @@ class DomainResource extends CustomResource<typeof domainSpecSchema> {
namespace: this.name,
domain: this.spec.hostname,
ref: this.ref,
gateway: istioService.gateway.current.metadata?.labels?.istio || 'ingress',
gateway: istioService.gateway.current.metadata?.labels?.istio || 'gateway-controller',
secretName: this.#certSecret,
});
if (!deepEqual(this.#gatewayResource.current?.spec, manifest.spec)) {

View File

@@ -257,12 +257,13 @@ const localStorageManifest = (options: LocalStorageManifestOptions): KubernetesO
values: {
storageClass: {
name: 'local-path',
provisionerName: 'rancher.io/local-path',
defaultClass: true,
},
nodePathMap: [
{
node: 'DEFAULT_PATH_FOR_NON_LISTED_NODES',
path: options.storagePath,
paths: [options.storagePath],
},
],
helper: {

View File

@@ -0,0 +1,124 @@
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 };

View File

@@ -0,0 +1,170 @@
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 };

View File

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

View File

@@ -1,4 +1,5 @@
import type { V1Secret } from '@kubernetes/client-node';
import type { z } from 'zod';
import {
CustomResource,
@@ -8,8 +9,12 @@ 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 { postgresConnectionSpecSchema } from './posgtres-connection.schemas.ts';
import type {
postgresConnectionSecretDataSchema,
postgresConnectionSpecSchema,
} from './posgtres-connection.schemas.ts';
class PostgresConnectionResource extends CustomResource<typeof postgresConnectionSpecSchema> {
#secret: ResourceReference<V1Secret>;
@@ -46,7 +51,9 @@ class PostgresConnectionResource extends CustomResource<typeof postgresConnectio
reason: 'MissingSecret',
});
}
const { host, user, password, port } = current.data;
const { host, user, password, port } = decodeSecret<z.infer<typeof postgresConnectionSecretDataSchema>>(
current.data,
)!;
if (!host) {
return this.conditions.set('Ready', {
status: 'False',

View File

@@ -186,10 +186,10 @@ class PostgresDatabaseResource extends CustomResource<typeof postgresDatabaseSpe
};
public reconcile = async () => {
this.#updateSecret();
if (!this.exists || this.metadata.deletionTimestamp) {
return;
}
this.#updateSecret();
await Promise.allSettled([
await this.reconcileSubresource(DATABASE_READY_CONDITION, this.#reconcileDatabase),
await this.reconcileSubresource(SECRET_READY_CONDITION, this.#reconcileSecret),

View File

@@ -0,0 +1,82 @@
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 };

View File

@@ -0,0 +1,138 @@
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 };

View File

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

View File

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

View File

@@ -89,20 +89,18 @@ abstract class CustomResource<TSpec extends ZodObject> extends EventEmitter<Cust
return this.resource.kind;
}
public get metadata() {
public get metadata(): KubernetesObject['metadata'] {
const metadata = this.resource.metadata;
if (!metadata) {
throw new Error('Custom resources needs metadata');
}
return metadata;
return (
metadata || {
name: this.name,
namespace: this.namespace,
}
);
}
public get name() {
const name = this.metadata.name;
if (!name) {
throw new Error('Custom resources needs a name');
}
return name;
return this.resource.specifier.name;
}
public get namespace() {
@@ -130,7 +128,7 @@ abstract class CustomResource<TSpec extends ZodObject> extends EventEmitter<Cust
}
public get isSeen() {
return this.metadata.generation === this.status?.observedGeneration;
return this.metadata?.generation === this.status?.observedGeneration;
}
public get isValidSpec() {
@@ -146,7 +144,7 @@ abstract class CustomResource<TSpec extends ZodObject> extends EventEmitter<Cust
return;
}
await this.patchStatus({
observedGeneration: this.metadata.generation,
observedGeneration: this.metadata?.generation,
});
};

View File

@@ -6,7 +6,7 @@ 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=ingress';
const ISTIO_APP_SELECTOR = 'istio=gateway-controller';
class IstioService {
#gatewayResource: ResourceReference<V1Deployment>;

View File

@@ -33,6 +33,14 @@ class EnsuredSecret<T extends ZodObject> {
this.#handleChanged();
}
public get name() {
return this.#options.name;
}
public get namespace() {
return this.#options.namespace;
}
public get resouce() {
return this.#resource;
}

View File

@@ -1,9 +1,10 @@
apiVersion: 'homelab.mortenolsen.pro/v1'
kind: 'AuthentikServer'
metadata:
name: homelab
name: authentik
namespace: homelab
spec:
domain: homelab
database: test2
subdomain: authentik
database: postgres
redis: redis

View File

@@ -7,6 +7,6 @@ spec:
domain: homelab/homelab
subdomain: test
destination:
host: foo.svc.cluster.local
host: authentik.svc.cluster.local
port:
number: 80

View File

@@ -1,7 +1,6 @@
apiVersion: 'homelab.mortenolsen.pro/v1'
kind: 'RedisConnection'
kind: 'PostgresCluster'
metadata:
name: 'redis'
name: 'postgres'
namespace: 'homelab'
spec:
secret: redis/connection
spec: {}

View File

@@ -1,7 +0,0 @@
apiVersion: 'homelab.mortenolsen.pro/v1'
kind: 'PostgresConnection'
metadata:
name: 'db'
namespace: 'homelab'
spec:
secret: postgres/postgres-secret

View File

@@ -1,7 +1,7 @@
apiVersion: 'homelab.mortenolsen.pro/v1'
kind: 'PostgresDatabase'
metadata:
name: 'test2'
name: postgres
namespace: 'homelab'
spec:
connection: homelab/db
connection: homelab/postgres

View File

@@ -0,0 +1,6 @@
apiVersion: 'homelab.mortenolsen.pro/v1'
kind: 'RedisServer'
metadata:
name: redis
namespace: 'homelab'
spec: {}