mirror of
https://github.com/morten-olsen/homelab-operator.git
synced 2026-02-08 01:36:28 +01:00
init
This commit is contained in:
34
.gitignore
vendored
Normal file
34
.gitignore
vendored
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# dependencies (bun install)
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# output
|
||||||
|
out
|
||||||
|
dist
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# code coverage
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# logs
|
||||||
|
logs
|
||||||
|
_.log
|
||||||
|
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# caches
|
||||||
|
.eslintcache
|
||||||
|
.cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# IntelliJ based IDEs
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Finder (MacOS) folder config
|
||||||
|
.DS_Store
|
||||||
22
.vscode/launch.json
vendored
Normal file
22
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
// Use IntelliSense to learn about possible attributes.
|
||||||
|
// Hover to view descriptions of existing attributes.
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"console": "integratedTerminal",
|
||||||
|
"internalConsoleOptions": "neverOpen",
|
||||||
|
"name": "server",
|
||||||
|
"args": ["--watch", "${workspaceFolder}/src/index.ts"],
|
||||||
|
"request": "launch",
|
||||||
|
"restart": true,
|
||||||
|
"envFile": "${workspaceFolder}/.env",
|
||||||
|
"runtimeExecutable": "node",
|
||||||
|
"skipFiles": [
|
||||||
|
"<node_internals>/**"
|
||||||
|
],
|
||||||
|
"type": "node"
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
15
README.md
Normal file
15
README.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# homelab-operator
|
||||||
|
|
||||||
|
To install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
To run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
This project was created using `bun init` in bun v1.2.16. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.
|
||||||
12
docker-compose.dev.yaml
Normal file
12
docker-compose.dev.yaml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
name: homelab
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:17
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: $POSTGRES_USER
|
||||||
|
POSTGRES_PASSWORD: $POSTGRES_PASSWORD
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-postgres}
|
||||||
|
volumes:
|
||||||
|
- $PWD/.data/local/postgres:/var/lib/postgresql/data
|
||||||
26
package.json
Normal file
26
package.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "homelab-operator",
|
||||||
|
"module": "index.ts",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest",
|
||||||
|
"nodemon": "^3.1.10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@kubernetes/client-node": "^1.3.0",
|
||||||
|
"@sinclair/typebox": "^0.34.38",
|
||||||
|
"knex": "^3.1.0",
|
||||||
|
"pg": "^8.16.3",
|
||||||
|
"sqlite3": "^5.1.7"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@10.6.0+sha512.df0136e797db0cfa7ec1084e77f3bdf81bacbae9066832fbf95cba4c2140ad05e64f316cde51ce3f99ea00a91ffc702d6aedd3c0f450f895e3e7c052fe573cd8",
|
||||||
|
"pnpm": {
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"sqlite3"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
2054
pnpm-lock.yaml
generated
Normal file
2054
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
10
postgres-database.ts
Normal file
10
postgres-database.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
apiVersion: 'homelab.mortenolsen.pro/v1'
|
||||||
|
kind: 'PostgresDatabase'
|
||||||
|
metadata:
|
||||||
|
name: 'test2'
|
||||||
|
namespace: 'playground'
|
||||||
|
labels:
|
||||||
|
foo: 'bar'
|
||||||
|
annotations:
|
||||||
|
foo: 'bar'
|
||||||
|
spec: {}
|
||||||
119
src/crds/postgres/postgres.database.ts
Normal file
119
src/crds/postgres/postgres.database.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { Type } from "@sinclair/typebox";
|
||||||
|
import { CustomResource, type CustomResourceHandlerOptions } from "../../custom-resource/custom-resource.base.ts";
|
||||||
|
import { K8sService } from "../../services/k8s.ts";
|
||||||
|
import { ApiException, type V1Secret } from "@kubernetes/client-node";
|
||||||
|
import type { CustomResourceRequest } from "../../custom-resource/custom-resource.request.ts";
|
||||||
|
import { PostgresService } from "../../services/postgres/postgres.service.ts";
|
||||||
|
|
||||||
|
const postgresDatabaseSpecSchema = Type.Object({
|
||||||
|
});
|
||||||
|
|
||||||
|
class PostgresDatabase extends CustomResource<typeof postgresDatabaseSpecSchema> {
|
||||||
|
constructor() {
|
||||||
|
super({
|
||||||
|
kind: 'PostgresDatabase',
|
||||||
|
spec: postgresDatabaseSpecSchema,
|
||||||
|
names: {
|
||||||
|
plural: 'postgresdatabases',
|
||||||
|
singular: 'postgresdatabase',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#getVariables = async (request: CustomResourceRequest<typeof postgresDatabaseSpecSchema>) => {
|
||||||
|
const { metadata, services } = request;
|
||||||
|
const k8sService = services.get(K8sService);
|
||||||
|
|
||||||
|
const secretName = `postgres-database-${metadata.name}`;
|
||||||
|
let secret: V1Secret | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
secret = await k8sService.api.readNamespacedSecret({
|
||||||
|
name: secretName,
|
||||||
|
namespace: metadata.namespace ?? 'default',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof ApiException && error.code === 404)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (secret && request.isOwnerOf(secret) && secret.data) {
|
||||||
|
services.log.debug('PostgresRole secret found', { secret });
|
||||||
|
return secret.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (secret && !request.isOwnerOf(secret)) {
|
||||||
|
throw new Error('The secret is not owned by this resource');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
name: Buffer.from(`${metadata.namespace}_${metadata.name}`).toString('base64'),
|
||||||
|
user: Buffer.from(metadata.name).toString('base64'),
|
||||||
|
password: Buffer.from(crypto.randomUUID()).toString('base64'),
|
||||||
|
}
|
||||||
|
const namespace = metadata.namespace ?? 'default';
|
||||||
|
|
||||||
|
services.log.debug('Creating secret', { data });
|
||||||
|
const response = await k8sService.api.createNamespacedSecret({
|
||||||
|
namespace,
|
||||||
|
body: {
|
||||||
|
kind: 'Secret',
|
||||||
|
metadata: {
|
||||||
|
name: secretName,
|
||||||
|
namespace,
|
||||||
|
ownerReferences: [
|
||||||
|
{
|
||||||
|
apiVersion: request.apiVersion,
|
||||||
|
kind: request.kind,
|
||||||
|
name: metadata.name,
|
||||||
|
uid: metadata.uid,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
type: 'Opaque',
|
||||||
|
data,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
services.log.debug('Secret created', { response });
|
||||||
|
return response.data!;
|
||||||
|
}
|
||||||
|
|
||||||
|
public update = async (options: CustomResourceHandlerOptions<typeof postgresDatabaseSpecSchema>) => {
|
||||||
|
const { request, services } = options;
|
||||||
|
const status = await request.getStatus();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const variables = await this.#getVariables(request);
|
||||||
|
const postgresService = services.get(PostgresService);
|
||||||
|
await postgresService.upsertRole({
|
||||||
|
name: Buffer.from(variables.user!, 'base64').toString('utf-8'),
|
||||||
|
password: Buffer.from(variables.password!, 'base64').toString('utf-8'),
|
||||||
|
});
|
||||||
|
|
||||||
|
await postgresService.upsertDatabase({
|
||||||
|
name: Buffer.from(variables.name!, 'base64').toString('utf-8'),
|
||||||
|
owner: Buffer.from(variables.user!, 'base64').toString('utf-8'),
|
||||||
|
});
|
||||||
|
|
||||||
|
status.setCondition('Ready', {
|
||||||
|
status: 'True',
|
||||||
|
reason: 'Ready',
|
||||||
|
message: 'Role created',
|
||||||
|
});
|
||||||
|
services.log.info('PostgresRole updated', { status });
|
||||||
|
return await status.save();
|
||||||
|
} catch (error) {
|
||||||
|
const status = await request.getStatus();
|
||||||
|
status.setCondition('Ready', {
|
||||||
|
status: 'False',
|
||||||
|
reason: 'Error',
|
||||||
|
message: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
services.log.error('Error updating PostgresRole', { error });
|
||||||
|
return await status.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { PostgresDatabase };
|
||||||
101
src/crds/secrets/secrets.request.ts
Normal file
101
src/crds/secrets/secrets.request.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { Type } from "@sinclair/typebox";
|
||||||
|
import { CustomResource, type CustomResourceHandlerOptions } from "../../custom-resource/custom-resource.base.ts";
|
||||||
|
import { K8sService } from "../../services/k8s.ts";
|
||||||
|
import { ApiException, type V1Secret } from "@kubernetes/client-node";
|
||||||
|
|
||||||
|
const stringValueSchema = Type.String({
|
||||||
|
key: Type.String(),
|
||||||
|
chars: Type.Optional(Type.String()),
|
||||||
|
length: Type.Optional(Type.Number()),
|
||||||
|
encoding: Type.Optional(Type.String({
|
||||||
|
enum: ['utf-8', 'base64', 'base64url', 'hex'],
|
||||||
|
})),
|
||||||
|
value: Type.Optional(Type.String()),
|
||||||
|
});
|
||||||
|
|
||||||
|
const secretRequestSpec = Type.Object({
|
||||||
|
secretName: Type.Optional(Type.String()),
|
||||||
|
data: Type.Array(stringValueSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
class SecretRequest extends CustomResource<typeof secretRequestSpec> {
|
||||||
|
constructor() {
|
||||||
|
super({
|
||||||
|
kind: 'SecretRequest',
|
||||||
|
spec: secretRequestSpec,
|
||||||
|
names: {
|
||||||
|
plural: 'secretrequests',
|
||||||
|
singular: 'secretrequest',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#createSecret = async (options: CustomResourceHandlerOptions<typeof secretRequestSpec>) => {
|
||||||
|
const { request, services } = options;
|
||||||
|
const { apiVersion, kind, spec, metadata } = request;
|
||||||
|
const { secretName = metadata.name } = spec;
|
||||||
|
const { namespace = 'default' } = metadata;
|
||||||
|
const k8sService = services.get(K8sService);
|
||||||
|
let current: V1Secret | undefined;
|
||||||
|
try {
|
||||||
|
current = await k8sService.api.readNamespacedSecret({
|
||||||
|
name: secretName,
|
||||||
|
namespace,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof ApiException && error.code === 404)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current) {
|
||||||
|
console.log('secret already exists', current);
|
||||||
|
// TODO: Add update logic
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await k8sService.api.createNamespacedSecret({
|
||||||
|
namespace,
|
||||||
|
body: {
|
||||||
|
kind: 'Secret',
|
||||||
|
metadata: {
|
||||||
|
name: secretName,
|
||||||
|
namespace,
|
||||||
|
ownerReferences: [
|
||||||
|
{
|
||||||
|
apiVersion,
|
||||||
|
kind,
|
||||||
|
name: metadata.name,
|
||||||
|
uid: metadata.uid,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
type: 'Opaque',
|
||||||
|
data: {
|
||||||
|
// TODO: generate data from spec
|
||||||
|
'test': 'test',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public update = async (options: CustomResourceHandlerOptions<typeof secretRequestSpec>) => {
|
||||||
|
const { request } = options;
|
||||||
|
const status = await request.getStatus();
|
||||||
|
try {
|
||||||
|
await this.#createSecret(options);
|
||||||
|
status.setCondition('Ready', {
|
||||||
|
status: 'True',
|
||||||
|
reason: 'SecretCreated',
|
||||||
|
message: 'Secret created',
|
||||||
|
});
|
||||||
|
return await status.save();
|
||||||
|
} catch (error) {
|
||||||
|
status.setCondition('Ready', {
|
||||||
|
status: 'False',
|
||||||
|
reason: 'SecretNotCreated',
|
||||||
|
message: 'Secret not created',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { SecretRequest };
|
||||||
101
src/custom-resource/custom-resource.base.ts
Normal file
101
src/custom-resource/custom-resource.base.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { type Static, type TSchema } from "@sinclair/typebox";
|
||||||
|
import { GROUP } from "../utils/consts.ts";
|
||||||
|
import type { Services } from "../utils/service.ts";
|
||||||
|
import { statusSchema } from "./custom-resource.status.ts";
|
||||||
|
import type { CustomResourceRequest } from "./custom-resource.request.ts";
|
||||||
|
|
||||||
|
|
||||||
|
type CustomResourceHandlerOptions<TSpec extends TSchema> = {
|
||||||
|
request: CustomResourceRequest<TSpec>;
|
||||||
|
services: Services;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CustomResourceConstructor<TSpec extends TSchema> = {
|
||||||
|
kind: string;
|
||||||
|
spec: TSpec;
|
||||||
|
names: {
|
||||||
|
plural: string;
|
||||||
|
singular: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class CustomResource<
|
||||||
|
TSpec extends TSchema
|
||||||
|
> {
|
||||||
|
#options: CustomResourceConstructor<TSpec>;
|
||||||
|
|
||||||
|
constructor(options: CustomResourceConstructor<TSpec>) {
|
||||||
|
this.#options = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get name() {
|
||||||
|
return `${this.#options.names.plural}.${this.group}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get version() {
|
||||||
|
return 'v1';
|
||||||
|
}
|
||||||
|
|
||||||
|
public get group() {
|
||||||
|
return GROUP;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get path() {
|
||||||
|
return `/apis/${this.group}/v1/${this.#options.names.plural}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get kind() {
|
||||||
|
return this.#options.kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get spec() {
|
||||||
|
return this.#options.spec;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get names() {
|
||||||
|
return this.#options.names;
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract update(options: CustomResourceHandlerOptions<TSpec>): Promise<void>;
|
||||||
|
public create?(options: CustomResourceHandlerOptions<TSpec>): Promise<void>;
|
||||||
|
public delete?(options: CustomResourceHandlerOptions<TSpec>): Promise<void>;
|
||||||
|
|
||||||
|
public toManifest = () => {
|
||||||
|
return {
|
||||||
|
apiVersion: 'apiextensions.k8s.io/v1',
|
||||||
|
kind: 'CustomResourceDefinition',
|
||||||
|
metadata: {
|
||||||
|
name: this.name,
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
group: this.group,
|
||||||
|
names: {
|
||||||
|
kind: this.kind,
|
||||||
|
plural: this.#options.names.plural,
|
||||||
|
singular: this.#options.names.singular,
|
||||||
|
},
|
||||||
|
scope: 'Namespaced',
|
||||||
|
versions: [{
|
||||||
|
name: this.version,
|
||||||
|
served: true,
|
||||||
|
storage: true,
|
||||||
|
schema: {
|
||||||
|
openAPIV3Schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
spec: this.spec,
|
||||||
|
status: statusSchema as any,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
subresources: {
|
||||||
|
status: {}
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export { CustomResource, type CustomResourceConstructor, type CustomResourceHandlerOptions };
|
||||||
113
src/custom-resource/custom-resource.registry.ts
Normal file
113
src/custom-resource/custom-resource.registry.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { ApiException, Watch } from "@kubernetes/client-node";
|
||||||
|
import { K8sService } from "../services/k8s.ts";
|
||||||
|
import type { Services } from "../utils/service.ts";
|
||||||
|
import { type CustomResource } from "./custom-resource.base.ts";
|
||||||
|
import { CustomResourceRequest } from "./custom-resource.request.ts";
|
||||||
|
|
||||||
|
class CustomResourceRegistry {
|
||||||
|
#services: Services;
|
||||||
|
#resources: Set<CustomResource<any>> = new Set();
|
||||||
|
#watchers: Map<string, AbortController> = new Map();
|
||||||
|
|
||||||
|
constructor(services: Services) {
|
||||||
|
this.#services = services;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get kinds() {
|
||||||
|
return Array.from(this.#resources).map((r) => r.kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getByKind = (kind: string) => {
|
||||||
|
return Array.from(this.#resources).find((r) => r.kind === kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
public register = (resource: CustomResource<any>) => {
|
||||||
|
this.#resources.add(resource);
|
||||||
|
}
|
||||||
|
|
||||||
|
public unregister = (resource: CustomResource<any>) => {
|
||||||
|
this.#resources.delete(resource);
|
||||||
|
this.#watchers.forEach((controller, kind) => {
|
||||||
|
if (kind === resource.kind) {
|
||||||
|
controller.abort();
|
||||||
|
this.#watchers.delete(kind);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public watch = async () => {
|
||||||
|
const k8sService = this.#services.get(K8sService);
|
||||||
|
const watcher = new Watch(k8sService.config);
|
||||||
|
for (const resource of this.#resources) {
|
||||||
|
if (this.#watchers.has(resource.kind)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const path = resource.path;
|
||||||
|
const controller = await watcher.watch(path, {}, this.#onResourceEvent, this.#onError);
|
||||||
|
this.#watchers.set(resource.kind, controller);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#onResourceEvent = async (type: string, obj: any) => {
|
||||||
|
console.log(type, this.kinds);
|
||||||
|
const { kind } = obj;
|
||||||
|
const crd = this.getByKind(kind);
|
||||||
|
if (!crd) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let handler = type === 'DELETED' ? crd.delete : crd.update;
|
||||||
|
const request = new CustomResourceRequest({
|
||||||
|
type: type as 'ADDED' | 'DELETED' | 'MODIFIED',
|
||||||
|
manifest: obj,
|
||||||
|
services: this.#services,
|
||||||
|
});
|
||||||
|
|
||||||
|
const status = await request.getStatus();
|
||||||
|
if (status.observedGeneration === obj.metadata.generation) {
|
||||||
|
this.#services.log.debug('Skipping resource update', {
|
||||||
|
observedGeneration: status.observedGeneration,
|
||||||
|
generation: obj.metadata.generation,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'ADDED' && crd.create) {
|
||||||
|
handler = crd.create;
|
||||||
|
}
|
||||||
|
|
||||||
|
await handler?.({
|
||||||
|
request,
|
||||||
|
services: this.#services,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#onError = (error: any) => {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
public install = async (replace = false) => {
|
||||||
|
const k8sService = this.#services.get(K8sService);
|
||||||
|
for (const crd of this.#resources) {
|
||||||
|
const manifest = crd.toManifest();
|
||||||
|
try {
|
||||||
|
await k8sService.extensionsApi.createCustomResourceDefinition({
|
||||||
|
body: manifest,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiException && error.code === 409) {
|
||||||
|
if (replace) {
|
||||||
|
await k8sService.extensionsApi.patchCustomResourceDefinition({
|
||||||
|
name: crd.name,
|
||||||
|
body: [{ op: 'replace', path: '/spec', value: manifest.spec }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { CustomResourceRegistry };
|
||||||
147
src/custom-resource/custom-resource.request.ts
Normal file
147
src/custom-resource/custom-resource.request.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import type { Static, TSchema } from "@sinclair/typebox";
|
||||||
|
import type { Services } from "../utils/service.ts";
|
||||||
|
import { K8sService } from "../services/k8s.ts";
|
||||||
|
import { CustomResourceRegistry } from "./custom-resource.registry.ts";
|
||||||
|
import { CustomResourceStatus, type CustomResourceStatusType } from "./custom-resource.status.ts";
|
||||||
|
import { ApiException, PatchStrategy, setHeaderOptions } from "@kubernetes/client-node";
|
||||||
|
|
||||||
|
type CustomResourceRequestOptions = {
|
||||||
|
type: 'ADDED' | 'DELETED' | 'MODIFIED';
|
||||||
|
manifest: any;
|
||||||
|
services: Services;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CustomResourceRequestMetadata = Record<string, string> & {
|
||||||
|
name: string;
|
||||||
|
namespace?: string;
|
||||||
|
labels?: Record<string, string>;
|
||||||
|
annotations?: Record<string, string>;
|
||||||
|
uid: string;
|
||||||
|
resourceVersion: string;
|
||||||
|
creationTimestamp: string;
|
||||||
|
generation: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
class CustomResourceRequest<TSpec extends TSchema>{
|
||||||
|
#options: CustomResourceRequestOptions;
|
||||||
|
|
||||||
|
constructor(options: CustomResourceRequestOptions) {
|
||||||
|
this.#options = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get services(): Services {
|
||||||
|
return this.#options.services;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get type(): 'ADDED' | 'DELETED' | 'MODIFIED' {
|
||||||
|
return this.#options.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get manifest() {
|
||||||
|
return this.#options.manifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get kind(): string {
|
||||||
|
return this.#options.manifest.kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get apiVersion(): string {
|
||||||
|
return this.#options.manifest.apiVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get spec(): Static<TSpec> {
|
||||||
|
return this.#options.manifest.spec;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get metadata(): CustomResourceRequestMetadata {
|
||||||
|
return this.#options.manifest.metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
public isOwnerOf = (manifest: any) => {
|
||||||
|
const ownerRef = manifest?.metadata?.ownerReferences || [];
|
||||||
|
return ownerRef.some((ref: any) =>
|
||||||
|
ref.apiVersion === this.apiVersion &&
|
||||||
|
ref.kind === this.kind &&
|
||||||
|
ref.name === this.metadata.name &&
|
||||||
|
ref.uid === this.metadata.uid
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public setStatus = async (status: CustomResourceStatusType) => {
|
||||||
|
const { manifest, services } = this.#options;
|
||||||
|
const { kind, metadata } = manifest;
|
||||||
|
const registry = services.get(CustomResourceRegistry);
|
||||||
|
const crd = registry.getByKind(kind);
|
||||||
|
if (!crd) {
|
||||||
|
throw new Error(`Custom resource ${kind} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const k8sService = services.get(K8sService);
|
||||||
|
|
||||||
|
const { namespace = 'default', name } = metadata;
|
||||||
|
|
||||||
|
const response = await k8sService.customObjectsApi.patchNamespacedCustomObjectStatus({
|
||||||
|
group: crd.group,
|
||||||
|
version: crd.version,
|
||||||
|
namespace,
|
||||||
|
plural: crd.names.plural,
|
||||||
|
name,
|
||||||
|
body: { status },
|
||||||
|
fieldValidation: 'Strict',
|
||||||
|
}, setHeaderOptions('Content-Type', PatchStrategy.MergePatch))
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getCurrent = async () => {
|
||||||
|
const { manifest, services } = this.#options;
|
||||||
|
const k8sService = services.get(K8sService);
|
||||||
|
const registry = services.get(CustomResourceRegistry);
|
||||||
|
const crd = registry.getByKind(manifest.kind);
|
||||||
|
if (!crd) {
|
||||||
|
throw new Error(`Custom resource ${manifest.kind} not found`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resource = await k8sService.customObjectsApi.getNamespacedCustomObject({
|
||||||
|
group: crd.group,
|
||||||
|
version: crd.version,
|
||||||
|
plural: crd.names.plural,
|
||||||
|
namespace: manifest.metadata.namespace,
|
||||||
|
name: manifest.metadata.name,
|
||||||
|
});
|
||||||
|
return resource as {
|
||||||
|
apiVersion: string;
|
||||||
|
kind: string;
|
||||||
|
metadata: CustomResourceRequestMetadata;
|
||||||
|
spec: Static<TSpec>;
|
||||||
|
status: CustomResourceStatusType;
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiException && error.code === 404) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getStatus = async () => {
|
||||||
|
const resource = await this.getCurrent()
|
||||||
|
if (!resource || !resource.status) {
|
||||||
|
return new CustomResourceStatus({
|
||||||
|
status: {
|
||||||
|
observedGeneration: 0,
|
||||||
|
conditions: [],
|
||||||
|
},
|
||||||
|
generation: 0,
|
||||||
|
save: this.setStatus,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new CustomResourceStatus({
|
||||||
|
status: { ...resource.status, observedGeneration: resource.status.observedGeneration },
|
||||||
|
generation: resource.metadata.generation,
|
||||||
|
save: this.setStatus,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export { CustomResourceRequest };
|
||||||
80
src/custom-resource/custom-resource.status.ts
Normal file
80
src/custom-resource/custom-resource.status.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { Type, type Static } from "@sinclair/typebox";
|
||||||
|
|
||||||
|
type CustomResourceStatusType= Static<typeof statusSchema>;
|
||||||
|
|
||||||
|
const statusSchema = Type.Object({
|
||||||
|
observedGeneration: Type.Number(),
|
||||||
|
conditions: Type.Array(Type.Object({
|
||||||
|
type: Type.String(),
|
||||||
|
status: Type.String({
|
||||||
|
enum: ['True', 'False', 'Unknown']
|
||||||
|
}),
|
||||||
|
lastTransitionTime: Type.String(),
|
||||||
|
reason: Type.String(),
|
||||||
|
message: Type.String(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
type CustomResourceStatusOptions = {
|
||||||
|
status?: CustomResourceStatusType;
|
||||||
|
generation: number;
|
||||||
|
save: (status: CustomResourceStatusType) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CustomResourceStatus {
|
||||||
|
#status: CustomResourceStatusType;
|
||||||
|
#generation: number;
|
||||||
|
#save: (status: CustomResourceStatusType) => Promise<void>;
|
||||||
|
|
||||||
|
constructor(options: CustomResourceStatusOptions) {
|
||||||
|
this.#save = options.save;
|
||||||
|
this.#status = {
|
||||||
|
observedGeneration: options.status?.observedGeneration ?? 0,
|
||||||
|
conditions: options.status?.conditions ?? [],
|
||||||
|
};
|
||||||
|
this.#generation = options.generation;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get generation() {
|
||||||
|
return this.#generation;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get observedGeneration() {
|
||||||
|
return this.#status.observedGeneration;
|
||||||
|
}
|
||||||
|
|
||||||
|
public set observedGeneration(observedGeneration: number) {
|
||||||
|
this.#status.observedGeneration = observedGeneration;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getCondition = (type: string) => {
|
||||||
|
return this.#status.conditions?.find((condition) => condition.type === type)?.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setCondition = (type: string, condition: Omit<CustomResourceStatusType['conditions'][number], 'type' | 'lastTransitionTime'>) => {
|
||||||
|
const currentCondition = this.getCondition(type);
|
||||||
|
const newCondition = {
|
||||||
|
...condition,
|
||||||
|
type,
|
||||||
|
lastTransitionTime: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
if (currentCondition) {
|
||||||
|
this.#status.conditions = this.#status.conditions.map((c) => c.type === type ? newCondition : c);
|
||||||
|
} else {
|
||||||
|
this.#status.conditions.push(newCondition);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public save = async () => {
|
||||||
|
await this.#save({
|
||||||
|
...this.#status,
|
||||||
|
observedGeneration: this.#generation,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public toJSON = () => {
|
||||||
|
return this.#status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { CustomResourceStatus, statusSchema, type CustomResourceStatusType };
|
||||||
57
src/database/database.service.ts
Normal file
57
src/database/database.service.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import knex, { type Knex } from "knex";
|
||||||
|
import { migrationSource } from "./migrations/migrations.ts";
|
||||||
|
import { Services } from "../utils/service.ts";
|
||||||
|
import { PostgresService } from "../services/postgres/postgres.service.ts";
|
||||||
|
import { ConfigService } from "../services/config/config.ts";
|
||||||
|
|
||||||
|
const DATABASE_NAME = 'homelab';
|
||||||
|
|
||||||
|
class DatabaseService {
|
||||||
|
#services: Services;
|
||||||
|
#knex?: Promise<Knex>;
|
||||||
|
|
||||||
|
constructor(services: Services) {
|
||||||
|
this.#services = services;
|
||||||
|
}
|
||||||
|
|
||||||
|
#setup = async () => {
|
||||||
|
const password = crypto.randomUUID();
|
||||||
|
const postgresService = this.#services.get(PostgresService);
|
||||||
|
await postgresService.upsertRole({
|
||||||
|
name: DATABASE_NAME,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
await postgresService.upsertDatabase({
|
||||||
|
name: DATABASE_NAME,
|
||||||
|
owner: DATABASE_NAME,
|
||||||
|
});
|
||||||
|
const configService = this.#services.get(ConfigService);
|
||||||
|
const postgresConfig = configService.postgres;
|
||||||
|
const db = knex({
|
||||||
|
client: 'pg',
|
||||||
|
connection: {
|
||||||
|
host: postgresConfig.host,
|
||||||
|
user: postgresConfig.user,
|
||||||
|
password: postgresConfig.password,
|
||||||
|
database: DATABASE_NAME,
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
migrationSource,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.migrate.latest();
|
||||||
|
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getDb = async () => {
|
||||||
|
if (!this.#knex) {
|
||||||
|
this.#knex = this.#setup();
|
||||||
|
}
|
||||||
|
return this.#knex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { tableNames, type Table } from "./migrations/migrations.ts";
|
||||||
|
export { DatabaseService };
|
||||||
51
src/database/migrations/migrations-001.init.ts
Normal file
51
src/database/migrations/migrations-001.init.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import type { Migration } from "./migrations.types.ts";
|
||||||
|
|
||||||
|
const tableNames = {
|
||||||
|
secrets: 'secrets',
|
||||||
|
postgresRoles: 'postgres_roles',
|
||||||
|
}
|
||||||
|
|
||||||
|
const init: Migration = {
|
||||||
|
name: 'init',
|
||||||
|
up: async (db) => {
|
||||||
|
await db.schema.createTable(tableNames.secrets, (table) => {
|
||||||
|
table.string('name').primary();
|
||||||
|
table.string('namespace').notNullable();
|
||||||
|
table.string('secretName').notNullable();
|
||||||
|
table.json('template').notNullable();
|
||||||
|
table.json('data').notNullable();
|
||||||
|
table.primary(['name', 'namespace']);
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.schema.createTable(tableNames.postgresRoles, (table) => {
|
||||||
|
table.string('name').primary();
|
||||||
|
table.string('namespace').notNullable();
|
||||||
|
table.text('password').notNullable();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
down: async (db) => {
|
||||||
|
await db.schema.dropTable(tableNames.secrets);
|
||||||
|
await db.schema.dropTable(tableNames.postgresRoles);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
type PostgresRoleRow = {
|
||||||
|
name: string;
|
||||||
|
namespace: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SecretRow = {
|
||||||
|
name: string;
|
||||||
|
namespace: string;
|
||||||
|
secretName: string;
|
||||||
|
template: Record<string, unknown>;
|
||||||
|
data: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Table = {
|
||||||
|
secrets: SecretRow;
|
||||||
|
postgresRoles: PostgresRoleRow;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { init, tableNames, type PostgresRoleRow, type SecretRow, type Table };
|
||||||
16
src/database/migrations/migrations.ts
Normal file
16
src/database/migrations/migrations.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import type { Knex } from "knex";
|
||||||
|
import { init } from "./migrations-001.init.ts";
|
||||||
|
import type { Migration } from "./migrations.types.ts";
|
||||||
|
|
||||||
|
const migrations = [
|
||||||
|
init,
|
||||||
|
] satisfies Migration[];
|
||||||
|
|
||||||
|
const migrationSource: Knex.MigrationSource<Migration> = {
|
||||||
|
getMigrations: async () => migrations,
|
||||||
|
getMigrationName: (migration) => migration.name,
|
||||||
|
getMigration: async (migration) => migration,
|
||||||
|
}
|
||||||
|
|
||||||
|
export { tableNames, type Table } from "./migrations-001.init.ts";
|
||||||
|
export { migrationSource };
|
||||||
9
src/database/migrations/migrations.types.ts
Normal file
9
src/database/migrations/migrations.types.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import type { Knex } from "knex";
|
||||||
|
|
||||||
|
type Migration = {
|
||||||
|
name: string;
|
||||||
|
up: (db: Knex) => Promise<void>;
|
||||||
|
down: (db: Knex) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { Migration };
|
||||||
11
src/index.ts
Normal file
11
src/index.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { CustomResourceRegistry } from './custom-resource/custom-resource.registry.ts';
|
||||||
|
import { Services } from './utils/service.ts';
|
||||||
|
import { SecretRequest } from './crds/secrets/secrets.request.ts';
|
||||||
|
import { PostgresDatabase } from './crds/postgres/postgres.database.ts';
|
||||||
|
|
||||||
|
const services = new Services();
|
||||||
|
const registry = services.get(CustomResourceRegistry);
|
||||||
|
registry.register(new SecretRequest());
|
||||||
|
registry.register(new PostgresDatabase());
|
||||||
|
await registry.install(true);
|
||||||
|
await registry.watch();
|
||||||
16
src/services/config/config.ts
Normal file
16
src/services/config/config.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
class ConfigService {
|
||||||
|
public get postgres() {
|
||||||
|
const host = process.env.POSTGRES_HOST;
|
||||||
|
const user = process.env.POSTGRES_USER;
|
||||||
|
const password = process.env.POSTGRES_PASSWORD;
|
||||||
|
const port = process.env.POSTGRES_PORT ? parseInt(process.env.POSTGRES_PORT, 10) : 5432;
|
||||||
|
|
||||||
|
if (!host || !user || !password) {
|
||||||
|
throw new Error('POSTGRES_HOST, POSTGRES_USER, and POSTGRES_PASSWORD must be set');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { host, user, password, port };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ConfigService };
|
||||||
34
src/services/k8s.ts
Normal file
34
src/services/k8s.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { KubeConfig, CoreV1Api, ApiextensionsV1Api, CustomObjectsApi } from '@kubernetes/client-node';
|
||||||
|
|
||||||
|
class K8sService {
|
||||||
|
#kc: KubeConfig;
|
||||||
|
#k8sApi: CoreV1Api;
|
||||||
|
#k8sExtensionsApi: ApiextensionsV1Api;
|
||||||
|
#k8sCustomObjectsApi: CustomObjectsApi;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.#kc = new KubeConfig();
|
||||||
|
this.#kc.loadFromDefault();
|
||||||
|
this.#k8sApi = this.#kc.makeApiClient(CoreV1Api);
|
||||||
|
this.#k8sExtensionsApi = this.#kc.makeApiClient(ApiextensionsV1Api);
|
||||||
|
this.#k8sCustomObjectsApi = this.#kc.makeApiClient(CustomObjectsApi);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get config() {
|
||||||
|
return this.#kc;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get api() {
|
||||||
|
return this.#k8sApi;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get extensionsApi() {
|
||||||
|
return this.#k8sExtensionsApi;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get customObjectsApi() {
|
||||||
|
return this.#k8sCustomObjectsApi;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { K8sService };
|
||||||
19
src/services/log/log.ts
Normal file
19
src/services/log/log.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
class LogService {
|
||||||
|
public debug = (message: string, data?: Record<string, unknown>) => {
|
||||||
|
console.debug(message, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public info = (message: string, data?: Record<string, unknown>) => {
|
||||||
|
console.info(message, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public warn = (message: string, data?: Record<string, unknown>) => {
|
||||||
|
console.warn(message, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public error = (message: string, data?: Record<string, unknown>) => {
|
||||||
|
console.error(message, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { LogService };
|
||||||
59
src/services/postgres/postgres.service.ts
Normal file
59
src/services/postgres/postgres.service.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import knex, { type Knex } from "knex";
|
||||||
|
import type { PostgresDatabase, PostgresRole } from "./postgres.types.ts";
|
||||||
|
import { Services } from "../../utils/service.ts";
|
||||||
|
import { ConfigService } from "../config/config.ts";
|
||||||
|
|
||||||
|
class PostgresService {
|
||||||
|
#db: Knex;
|
||||||
|
|
||||||
|
constructor(services: Services) {
|
||||||
|
const configService = services.get(ConfigService);
|
||||||
|
const config = configService.postgres;
|
||||||
|
this.#db = knex({
|
||||||
|
client: 'pg',
|
||||||
|
connection: {
|
||||||
|
host: config.host,
|
||||||
|
user: config.user,
|
||||||
|
password: config.password,
|
||||||
|
port: config.port,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public upsertRole = async (role: PostgresRole) => {
|
||||||
|
const existingRole = await this.#db.raw(
|
||||||
|
'SELECT 1 FROM pg_roles WHERE rolname = ?',
|
||||||
|
[role.name]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingRole.rows.length === 0) {
|
||||||
|
await this.#db.raw(
|
||||||
|
`CREATE ROLE ${role.name} WITH LOGIN PASSWORD '${role.password}'`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await this.#db.raw(
|
||||||
|
`ALTER ROLE ${role.name} WITH PASSWORD '${role.password}'`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public upsertDatabase = async (database: PostgresDatabase) => {
|
||||||
|
const existingDatabase = await this.#db.raw(
|
||||||
|
'SELECT * FROM pg_database WHERE datname = ?',
|
||||||
|
[database.name]
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
if (existingDatabase.rows.length === 0) {
|
||||||
|
await this.#db.raw(
|
||||||
|
`CREATE DATABASE ${database.name} OWNER ${database.owner}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await this.#db.raw(
|
||||||
|
`ALTER DATABASE ${database.name} OWNER TO ${database.owner}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { PostgresService };
|
||||||
11
src/services/postgres/postgres.types.ts
Normal file
11
src/services/postgres/postgres.types.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
type PostgresRole = {
|
||||||
|
name: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PostgresDatabase = {
|
||||||
|
name: string;
|
||||||
|
owner: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type { PostgresRole, PostgresDatabase };
|
||||||
2
src/services/secrets/secrets.service.ts
Normal file
2
src/services/secrets/secrets.service.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
class SecretsService {
|
||||||
|
}
|
||||||
3
src/utils/consts.ts
Normal file
3
src/utils/consts.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
const GROUP = 'homelab.mortenolsen.pro';
|
||||||
|
|
||||||
|
export { GROUP };
|
||||||
23
src/utils/service.ts
Normal file
23
src/utils/service.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { LogService } from "../services/log/log.ts";
|
||||||
|
|
||||||
|
type Dependency<T> = new (services: Services) => T;
|
||||||
|
|
||||||
|
class Services {
|
||||||
|
#instances: Map<Dependency<unknown>, unknown> = new Map();
|
||||||
|
constructor() {
|
||||||
|
console.log('Constructor', 'bar');
|
||||||
|
}
|
||||||
|
|
||||||
|
public get log() {
|
||||||
|
return this.get(LogService);
|
||||||
|
}
|
||||||
|
|
||||||
|
get = <T>(dependency: Dependency<T>): T => {
|
||||||
|
if (!this.#instances.has(dependency)) {
|
||||||
|
this.#instances.set(dependency, new dependency(this));
|
||||||
|
}
|
||||||
|
return this.#instances.get(dependency) as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Services };
|
||||||
12
test.yaml
Normal file
12
test.yaml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
apiVersion: homelab.mortenolsen.pro/v1
|
||||||
|
kind: SecretRequest
|
||||||
|
metadata:
|
||||||
|
name: test
|
||||||
|
namespace: playground
|
||||||
|
spec:
|
||||||
|
secretName: testing
|
||||||
|
data:
|
||||||
|
- key: foo
|
||||||
|
value: bar
|
||||||
|
- key: baz
|
||||||
|
value: qux
|
||||||
29
tsconfig.json
Normal file
29
tsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
// Environment setup & latest features
|
||||||
|
"lib": ["ESNext"],
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"allowJs": true,
|
||||||
|
|
||||||
|
// Bundler mode
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
// Best practices
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
|
||||||
|
// Some stricter flags (disabled by default)
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noPropertyAccessFromIndexSignature": false
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user