init
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
/node_modules
|
||||
.turbo/
|
||||
/.env
|
||||
/coverage/
|
||||
18
.prettierrc.json
Normal file
18
.prettierrc.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"arrowParens": "always",
|
||||
"bracketSpacing": true,
|
||||
"htmlWhitespaceSensitivity": "css",
|
||||
"insertPragma": false,
|
||||
"bracketSameLine": false,
|
||||
"jsxSingleQuote": false,
|
||||
"printWidth": 120,
|
||||
"proseWrap": "preserve",
|
||||
"quoteProps": "as-needed",
|
||||
"requirePragma": false,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "all",
|
||||
"useTabs": false,
|
||||
"singleAttributePerLine": false
|
||||
}
|
||||
101
.u8.json
Normal file
101
.u8.json
Normal file
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"values": {
|
||||
"monoRepo": true,
|
||||
"packagePrefix": "@morten-olsen/box-",
|
||||
"packageVersion": "1.0.0"
|
||||
},
|
||||
"entries": [
|
||||
{
|
||||
"timestamp": "2025-10-23T09:53:12.641Z",
|
||||
"template": "monorepo",
|
||||
"values": {
|
||||
"monoRepo": true,
|
||||
"packagePrefix": "@morten-olsen/box-",
|
||||
"packageVersion": "1.0.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-10-23T09:53:27.897Z",
|
||||
"template": "eslint",
|
||||
"values": {
|
||||
"monoRepo": true,
|
||||
"packagePrefix": "@morten-olsen/box-",
|
||||
"packageVersion": "1.0.0",
|
||||
"target-pkg": {
|
||||
"name": "@morten-olsen/box-repo",
|
||||
"dir": "/Users/alice/Projects/private/homelab/box"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-10-23T09:53:42.721Z",
|
||||
"template": "pkg",
|
||||
"values": {
|
||||
"monoRepo": true,
|
||||
"packagePrefix": "@morten-olsen/box-",
|
||||
"packageVersion": "1.0.0",
|
||||
"packageName": "operator"
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-10-23T09:54:37.963Z",
|
||||
"template": "pkg",
|
||||
"values": {
|
||||
"monoRepo": true,
|
||||
"packagePrefix": "@morten-olsen/box-",
|
||||
"packageVersion": "1.0.0",
|
||||
"packageName": "k8s"
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-10-23T09:59:44.245Z",
|
||||
"template": "pkg",
|
||||
"values": {
|
||||
"monoRepo": true,
|
||||
"packagePrefix": "@morten-olsen/box-",
|
||||
"packageVersion": "1.0.0",
|
||||
"packageName": "utils"
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-10-23T10:27:13.812Z",
|
||||
"template": "pkg",
|
||||
"values": {
|
||||
"monoRepo": true,
|
||||
"packagePrefix": "@morten-olsen/box-",
|
||||
"packageVersion": "1.0.0",
|
||||
"packageName": "resource-authentik"
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-10-23T10:47:38.946Z",
|
||||
"template": "pkg",
|
||||
"values": {
|
||||
"monoRepo": true,
|
||||
"packagePrefix": "@morten-olsen/box-",
|
||||
"packageVersion": "1.0.0",
|
||||
"packageName": "resource-postgres"
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-10-23T10:47:58.677Z",
|
||||
"template": "pkg",
|
||||
"values": {
|
||||
"monoRepo": true,
|
||||
"packagePrefix": "@morten-olsen/box-",
|
||||
"packageVersion": "1.0.0",
|
||||
"packageName": "resource-redis"
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-10-23T10:49:51.870Z",
|
||||
"template": "pkg",
|
||||
"values": {
|
||||
"monoRepo": true,
|
||||
"packagePrefix": "@morten-olsen/box-",
|
||||
"packageVersion": "1.0.0",
|
||||
"packageName": "bootstrap"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
51
eslint.config.mjs
Normal file
51
eslint.config.mjs
Normal file
@@ -0,0 +1,51 @@
|
||||
import { FlatCompat } from '@eslint/eslintrc';
|
||||
import importPlugin from 'eslint-plugin-import';
|
||||
import eslint from '@eslint/js';
|
||||
import eslintConfigPrettier from 'eslint-config-prettier';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: import.meta.__dirname,
|
||||
resolvePluginsRelativeTo: import.meta.__dirname,
|
||||
});
|
||||
|
||||
export default tseslint.config(
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.strict,
|
||||
...tseslint.configs.stylistic,
|
||||
eslintConfigPrettier,
|
||||
{
|
||||
files: ['**/*.{ts,tsxx}'],
|
||||
extends: [importPlugin.flatConfigs.recommended, importPlugin.flatConfigs.typescript],
|
||||
rules: {
|
||||
'import/no-unresolved': 'off',
|
||||
'import/extensions': ['error', 'ignorePackages'],
|
||||
'import/exports-last': 'error',
|
||||
'import/no-default-export': 'error',
|
||||
'import/order': [
|
||||
'error',
|
||||
{
|
||||
groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'],
|
||||
'newlines-between': 'always',
|
||||
},
|
||||
],
|
||||
'import/no-duplicates': 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/consistent-type-definitions': ['error', 'type'],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**.d.ts'],
|
||||
rules: {
|
||||
'@typescript-eslint/triple-slash-reference': 'off',
|
||||
'@typescript-eslint/consistent-type-definitions': 'off',
|
||||
},
|
||||
},
|
||||
...compat.extends('plugin:prettier/recommended'),
|
||||
{
|
||||
ignores: ['**/node_modules/', '**/dist/', '**/.turbo/', '**/generated/'],
|
||||
},
|
||||
);
|
||||
33
package.json
Normal file
33
package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"name": "@morten-olsen/box-repo",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test:lint": "eslint",
|
||||
"build": "turbo build",
|
||||
"build:dev": "tsc --build --watch",
|
||||
"test:unit": "vitest --run --coverage --passWithNoTests",
|
||||
"test": "pnpm run \"/^test:.+/\""
|
||||
},
|
||||
"packageManager": "pnpm@10.6.0",
|
||||
"workspaces": [
|
||||
"packages/*",
|
||||
"apps/*"
|
||||
],
|
||||
"devDependencies": {
|
||||
"turbo": "2.5.8",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:",
|
||||
"@vitest/coverage-v8": "catalog:",
|
||||
"@eslint/eslintrc": "3.3.1",
|
||||
"@eslint/js": "9.38.0",
|
||||
"@pnpm/find-workspace-packages": "6.0.9",
|
||||
"eslint": "9.38.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"eslint-plugin-prettier": "5.5.4",
|
||||
"prettier": "3.6.2",
|
||||
"typescript-eslint": "8.46.2"
|
||||
}
|
||||
}
|
||||
4
packages/bootstrap/.gitignore
vendored
Normal file
4
packages/bootstrap/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
/node_modules/
|
||||
/dist/
|
||||
/coverage/
|
||||
/.env
|
||||
29
packages/bootstrap/package.json
Normal file
29
packages/bootstrap/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"type": "module",
|
||||
"main": "dist/exports.js",
|
||||
"scripts": {
|
||||
"build": "tsc --build",
|
||||
"test:unit": "vitest --run --passWithNoTests",
|
||||
"test": "pnpm run \"/^test:/\""
|
||||
},
|
||||
"packageManager": "pnpm@10.6.0",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
".": "./dist/exports.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-v8": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:",
|
||||
"@morten-olsen/box-configs": "workspace:*",
|
||||
"@morten-olsen/box-tests": "workspace:*"
|
||||
},
|
||||
"name": "@morten-olsen/box-bootstrap",
|
||||
"version": "1.0.0",
|
||||
"imports": {
|
||||
"#root/*": "./src/*"
|
||||
}
|
||||
}
|
||||
0
packages/bootstrap/src/exports.ts
Normal file
0
packages/bootstrap/src/exports.ts
Normal file
9
packages/bootstrap/tsconfig.json
Normal file
9
packages/bootstrap/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"extends": "@morten-olsen/box-configs/tsconfig.json"
|
||||
}
|
||||
12
packages/bootstrap/vitest.config.ts
Normal file
12
packages/bootstrap/vitest.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { getAliases } from '@morten-olsen/box-tests/vitest';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default defineConfig(async () => {
|
||||
const aliases = await getAliases();
|
||||
return {
|
||||
resolve: {
|
||||
alias: aliases,
|
||||
},
|
||||
};
|
||||
});
|
||||
4
packages/configs/package.json
Normal file
4
packages/configs/package.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "@morten-olsen/box-configs",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
19
packages/configs/tsconfig.json
Normal file
19
packages/configs/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"skipLibCheck": true,
|
||||
"jsx": "react-jsx",
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"allowImportingTsExtensions": false
|
||||
}
|
||||
}
|
||||
4
packages/k8s/.gitignore
vendored
Normal file
4
packages/k8s/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
/node_modules/
|
||||
/dist/
|
||||
/coverage/
|
||||
/.env
|
||||
292
packages/k8s/README.md
Normal file
292
packages/k8s/README.md
Normal file
@@ -0,0 +1,292 @@
|
||||
# @morten-olsen/k8s-operator
|
||||
|
||||
A TypeScript library for building Kubernetes operators with type-safe custom resource definitions (CRDs) and automatic reconciliation.
|
||||
|
||||
## Features
|
||||
|
||||
- **Type-safe CRDs** with Zod schema validation
|
||||
- **Automatic reconciliation** with configurable intervals
|
||||
- **Resource watching** with event-driven updates
|
||||
- **Built-in resource management** for both native and custom Kubernetes resources
|
||||
- **Status management** with conditions and ready states
|
||||
- **Derived resource handling** with automatic dependencies
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pnpm add @morten-olsen/k8s-operator @kubernetes/client-node zod
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
// start.ts
|
||||
|
||||
import { K8sOperator, Resource, CustomResource, type CustomResourceOptions } from '@morten-olsen/k8s-operator';
|
||||
import type { V1Secret } from '@kubernetes/client-node';
|
||||
import { z } from 'zod';
|
||||
|
||||
// We create a subscribable resouce for the built in secret type
|
||||
class Secret extends Resource<V1Secret> {
|
||||
public static readonly apiVersion = 'v1';
|
||||
public static readonly kind = 'Secret';
|
||||
}
|
||||
|
||||
// We create a schema for the spec of our custom Kubernetes definition
|
||||
const specSchema = z.object({
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
const SECRET_VALUE = Buffer.from('TEST').toString('base64');
|
||||
|
||||
class MyCustomResource extends CustomResource<typeof specSchema> {
|
||||
public static readonly apiVersion = 'myoperator.example.com/v1';
|
||||
public static readonly kind = 'MyCustomResource';
|
||||
public static readonly spec = specSchema;
|
||||
public static readonly scope = 'Namespaced';
|
||||
|
||||
// We create a resource dependency for a derived secret
|
||||
#secret: Resource<typeof Secret>;
|
||||
|
||||
constructor(options: CustomResourceOptions<typeof specSchema>) {
|
||||
super(options);
|
||||
|
||||
// We get a resource reference for our derived secret
|
||||
this.#secret = this.resources.get(Secret, `${this.name}-secret`, this.namespace);
|
||||
// And subscribe the reconciler to any changes to this resource
|
||||
this.#secret.on('changed', this.queueReconcile);
|
||||
}
|
||||
|
||||
// When the resource or #secret change we run a reconcile
|
||||
// to ensure the secret exists and has the expected value.
|
||||
public reconcile = async () => {
|
||||
// If the derived secret doesn't exist or has incorrect values we update it
|
||||
await this.#secret.ensure({
|
||||
data: {
|
||||
mysecret: SECRET_VALUE,
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
const operator = new K8sOperator();
|
||||
// We install our new CRD into the server
|
||||
await operator.resources.install(MyCustomResource);
|
||||
// We register our resources to ensure all changes are observed
|
||||
await operator.resources.register(Secret, MyCustomResource);
|
||||
```
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Resources
|
||||
|
||||
Resources represent Kubernetes objects. They can be built-in types (like `Secret`, `ConfigMap`) or custom resources.
|
||||
|
||||
```typescript
|
||||
import type { V1ConfigMap } from '@kubernetes/client-node';
|
||||
|
||||
class ConfigMap extends Resource<V1ConfigMap> {
|
||||
public static readonly apiVersion = 'v1';
|
||||
public static readonly kind = 'ConfigMap';
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Resources
|
||||
|
||||
Custom resources extend the `CustomResource` class and define their spec with Zod schemas:
|
||||
|
||||
```typescript
|
||||
const mySchema = z.object({
|
||||
replicas: z.number().min(1),
|
||||
image: z.string(),
|
||||
});
|
||||
|
||||
class MyResource extends CustomResource<typeof mySchema> {
|
||||
public static readonly apiVersion = 'example.com/v1';
|
||||
public static readonly kind = 'MyResource';
|
||||
public static readonly spec = mySchema;
|
||||
public static readonly scope = 'Namespaced'; // or 'Cluster'
|
||||
}
|
||||
```
|
||||
|
||||
### Reconciliation
|
||||
|
||||
The `reconcile` method is called automatically when:
|
||||
|
||||
- The resource changes
|
||||
- A watched dependency changes
|
||||
- The configured cron interval triggers (default: every 2 minutes)
|
||||
|
||||
```typescript
|
||||
public reconcile = async () => {
|
||||
// Your reconciliation logic here
|
||||
};
|
||||
```
|
||||
|
||||
### Resource Dependencies
|
||||
|
||||
Resources can depend on other resources and react to their changes:
|
||||
|
||||
```typescript
|
||||
constructor(options: CustomResourceOptions<typeof specSchema>) {
|
||||
super(options);
|
||||
|
||||
this.#deployment = this.resources.get(Deployment, this.name, this.namespace);
|
||||
this.#deployment.on('changed', this.queueReconcile);
|
||||
}
|
||||
```
|
||||
|
||||
### Status Management
|
||||
|
||||
Custom resources have built-in status management:
|
||||
|
||||
```typescript
|
||||
// Mark resource as ready
|
||||
await this.markReady();
|
||||
|
||||
// Mark resource as not ready with reason
|
||||
await this.markNotReady('ConfigError', 'Missing required configuration');
|
||||
|
||||
// Update custom status fields
|
||||
await this.patchStatus({
|
||||
observedGeneration: this.metadata?.generation,
|
||||
conditions: [
|
||||
{
|
||||
type: 'Ready',
|
||||
status: 'True',
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Resource Operations
|
||||
|
||||
```typescript
|
||||
// Create or update a resource
|
||||
await resource.patch({
|
||||
metadata: { labels: { app: 'myapp' } },
|
||||
spec: { replicas: 3 },
|
||||
});
|
||||
|
||||
// Ensure a resource matches desired state (only patches if different)
|
||||
const changed = await resource.ensure({
|
||||
data: { key: 'value' },
|
||||
});
|
||||
|
||||
// Check if resource exists
|
||||
if (resource.exists) {
|
||||
// Access resource properties
|
||||
const spec = resource.spec;
|
||||
const status = resource.status;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### K8sOperator
|
||||
|
||||
Main operator class that manages resources.
|
||||
|
||||
```typescript
|
||||
const operator = new K8sOperator();
|
||||
```
|
||||
|
||||
#### `operator.resources`
|
||||
|
||||
Access to the `ResourceService` for managing resources.
|
||||
|
||||
### ResourceService
|
||||
|
||||
```typescript
|
||||
// Install a CRD
|
||||
await operator.resources.install(MyCustomResource);
|
||||
|
||||
// Register resources for watching
|
||||
await operator.resources.register(MyResource, Secret, ConfigMap);
|
||||
|
||||
// Get a resource reference
|
||||
const resource = operator.resources.get(MyResource, 'my-name', 'my-namespace');
|
||||
```
|
||||
|
||||
### Resource
|
||||
|
||||
Base class for Kubernetes resources.
|
||||
|
||||
#### Properties
|
||||
|
||||
- `manifest`: The Kubernetes object
|
||||
- `exists`: Whether the resource exists
|
||||
- `ready`: Whether the resource is ready
|
||||
- `name`: Resource name
|
||||
- `namespace`: Resource namespace
|
||||
- `apiVersion`: Resource API version
|
||||
- `kind`: Resource kind
|
||||
- `spec`: Resource spec (if applicable)
|
||||
- `status`: Resource status (if applicable)
|
||||
- `metadata`: Resource metadata
|
||||
|
||||
#### Methods
|
||||
|
||||
- `patch(manifest)`: Create or update the resource
|
||||
- `ensure(manifest)`: Update only if different from current state
|
||||
- `on(event, handler)`: Listen to resource events
|
||||
|
||||
### CustomResource
|
||||
|
||||
Extends `Resource` with CRD-specific functionality.
|
||||
|
||||
#### Static Properties
|
||||
|
||||
- `apiVersion`: CRD API version (e.g., `'example.com/v1'`)
|
||||
- `kind`: CRD kind
|
||||
- `spec`: Zod schema for the spec
|
||||
- `scope`: `'Namespaced'` or `'Cluster'`
|
||||
- `labels`: Default labels for the resource
|
||||
|
||||
#### Properties
|
||||
|
||||
- `isSeen`: Whether the current generation has been observed
|
||||
- `reconcileTime`: Cron pattern for reconciliation
|
||||
|
||||
#### Methods
|
||||
|
||||
- `reconcile()`: Override this method with your reconciliation logic
|
||||
- `queueReconcile()`: Queue a reconciliation
|
||||
- `markReady()`: Mark resource as ready
|
||||
- `markNotReady(reason, message)`: Mark resource as not ready
|
||||
- `patchStatus(status)`: Update the status subresource
|
||||
|
||||
## Error Handling
|
||||
|
||||
Use `NotReadyError` to signal that a resource is not ready during reconciliation:
|
||||
|
||||
```typescript
|
||||
import { NotReadyError } from '@morten-olsen/k8s-operator';
|
||||
|
||||
public reconcile = async () => {
|
||||
if (!this.dependency.ready) {
|
||||
throw new NotReadyError('DependencyNotReady', 'Waiting for dependency');
|
||||
}
|
||||
// Continue reconciliation...
|
||||
};
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Build
|
||||
pnpm run build
|
||||
|
||||
# Run tests
|
||||
pnpm test
|
||||
|
||||
# Lint
|
||||
pnpm run test:lint
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0
|
||||
34
packages/k8s/package.json
Normal file
34
packages/k8s/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"type": "module",
|
||||
"main": "dist/exports.js",
|
||||
"scripts": {
|
||||
"build": "tsc --build",
|
||||
"test:unit": "vitest --run --passWithNoTests",
|
||||
"test": "pnpm run \"/^test:/\""
|
||||
},
|
||||
"packageManager": "pnpm@10.6.0",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
".": "./dist/exports.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@morten-olsen/box-configs": "workspace:*",
|
||||
"@morten-olsen/box-tests": "workspace:*",
|
||||
"@types/deep-equal": "^1.0.4",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-v8": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"@kubernetes/client-node": "^1.4.0",
|
||||
"@morten-olsen/box-utils": "workspace:*",
|
||||
"cron": "^4.3.3",
|
||||
"deep-equal": "^2.2.3",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"name": "@morten-olsen/box-k8s",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
10
packages/k8s/src/config/config.ts
Normal file
10
packages/k8s/src/config/config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { KubeConfig } from '@kubernetes/client-node';
|
||||
|
||||
class K8sConfig extends KubeConfig {
|
||||
constructor() {
|
||||
super();
|
||||
this.loadFromDefault();
|
||||
}
|
||||
}
|
||||
|
||||
export { K8sConfig };
|
||||
10
packages/k8s/src/core/core.crd.ts
Normal file
10
packages/k8s/src/core/core.crd.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Resource } from '../resources/resource/resource.js';
|
||||
import type { V1CustomResourceDefinition } from '@kubernetes/client-node';
|
||||
|
||||
|
||||
class CRD extends Resource<V1CustomResourceDefinition> {
|
||||
public static readonly apiVersion = 'apiextensions.k8s.io/v1';
|
||||
public static readonly kind = 'CustomResourceDefinition';
|
||||
}
|
||||
|
||||
export { CRD };
|
||||
9
packages/k8s/src/core/core.deployment.ts
Normal file
9
packages/k8s/src/core/core.deployment.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Resource } from '../resources/resource/resource.js';
|
||||
import type { V1Deployment } from '@kubernetes/client-node';
|
||||
|
||||
class Deployment extends Resource<V1Deployment> {
|
||||
public static readonly apiVersion = 'apps/v1';
|
||||
public static readonly kind = 'Deployment';
|
||||
}
|
||||
|
||||
export { Deployment };
|
||||
9
packages/k8s/src/core/core.namespace.ts
Normal file
9
packages/k8s/src/core/core.namespace.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Resource } from '../resources/resource/resource.js';
|
||||
import type { V1Namespace } from '@kubernetes/client-node';
|
||||
|
||||
class Namespace extends Resource<V1Namespace> {
|
||||
public static readonly apiVersion = 'v1';
|
||||
public static readonly kind = 'Namespace';
|
||||
}
|
||||
|
||||
export { Namespace };
|
||||
9
packages/k8s/src/core/core.pv.ts
Normal file
9
packages/k8s/src/core/core.pv.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Resource } from '../resources/resource/resource.js';
|
||||
import type { V1PersistentVolume } from '@kubernetes/client-node';
|
||||
|
||||
class PersistentVolume extends Resource<V1PersistentVolume> {
|
||||
public static readonly apiVersion = 'v1';
|
||||
public static readonly kind = 'PersistentVolume';
|
||||
}
|
||||
|
||||
export { PersistentVolume };
|
||||
29
packages/k8s/src/core/core.secret.ts
Normal file
29
packages/k8s/src/core/core.secret.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Resource, type ResourceOptions } from '../resources/resource/resource.js';
|
||||
import type { KubernetesObject, V1Secret } from '@kubernetes/client-node';
|
||||
import { decodeSecret, encodeSecret } from '../utils/utils.secrets.js';
|
||||
|
||||
|
||||
type SetOptions<T extends Record<string, string | undefined>> = T | ((current: T | undefined) => T | Promise<T>);
|
||||
|
||||
class Secret<T extends Record<string, string> = Record<string, string>> extends Resource<V1Secret> {
|
||||
public static readonly apiVersion = 'v1';
|
||||
public static readonly kind = 'Secret';
|
||||
|
||||
constructor(options: ResourceOptions<V1Secret>) {
|
||||
super(options);
|
||||
}
|
||||
|
||||
public get value() {
|
||||
return decodeSecret(this.data) as T | undefined;
|
||||
}
|
||||
|
||||
public set = async (options: SetOptions<T>, data?: KubernetesObject) => {
|
||||
const value = typeof options === 'function' ? await Promise.resolve(options(this.value)) : options;
|
||||
await this.ensure({
|
||||
...data,
|
||||
data: encodeSecret(value),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export { Secret };
|
||||
13
packages/k8s/src/core/core.service.ts
Normal file
13
packages/k8s/src/core/core.service.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Resource } from '../resources/resource/resource.js';
|
||||
import type { V1Service } from '@kubernetes/client-node';
|
||||
|
||||
class Service extends Resource<V1Service> {
|
||||
public static readonly apiVersion = 'v1';
|
||||
public static readonly kind = 'Service';
|
||||
|
||||
public get hostname() {
|
||||
return `${this.name}.${this.namespace}.svc.cluster.local`;
|
||||
}
|
||||
}
|
||||
|
||||
export { Service };
|
||||
9
packages/k8s/src/core/core.stateful-set.ts
Normal file
9
packages/k8s/src/core/core.stateful-set.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Resource } from '../resources/resource/resource.js';
|
||||
import type { V1StatefulSet } from '@kubernetes/client-node';
|
||||
|
||||
class StatefulSet extends Resource<V1StatefulSet> {
|
||||
public static readonly apiVersion = 'apps/v1';
|
||||
public static readonly kind = 'StatefulSet';
|
||||
}
|
||||
|
||||
export { StatefulSet };
|
||||
10
packages/k8s/src/core/core.storage-class.ts
Normal file
10
packages/k8s/src/core/core.storage-class.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Resource } from '../resources/resource/resource.js';
|
||||
import type { V1StorageClass } from '@kubernetes/client-node';
|
||||
|
||||
class StorageClass extends Resource<V1StorageClass> {
|
||||
public static readonly apiVersion = 'storage.k8s.io/v1';
|
||||
public static readonly kind = 'StorageClass';
|
||||
public static readonly plural = 'storageclasses';
|
||||
}
|
||||
|
||||
export { StorageClass };
|
||||
19
packages/k8s/src/core/core.ts
Normal file
19
packages/k8s/src/core/core.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { CRD } from "./core.crd.js";
|
||||
import { Deployment } from "./core.deployment.js";
|
||||
import { Namespace } from "./core.namespace.js";
|
||||
import { PersistentVolume } from "./core.pv.js";
|
||||
import { Secret } from "./core.secret.js";
|
||||
import { Service } from "./core.service.js";
|
||||
import { StatefulSet } from "./core.stateful-set.js";
|
||||
import { StorageClass } from "./core.storage-class.js";
|
||||
|
||||
export {
|
||||
CRD,
|
||||
Deployment,
|
||||
Namespace,
|
||||
PersistentVolume,
|
||||
Secret,
|
||||
Service,
|
||||
StatefulSet,
|
||||
StorageClass,
|
||||
}
|
||||
14
packages/k8s/src/errors/errors.ts
Normal file
14
packages/k8s/src/errors/errors.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
class NotReadyError extends Error {
|
||||
#reason?: string;
|
||||
|
||||
constructor(reason?: string, message?: string) {
|
||||
super(message || reason || 'Resource is not ready');
|
||||
this.#reason = reason;
|
||||
}
|
||||
|
||||
get reason() {
|
||||
return this.#reason;
|
||||
}
|
||||
}
|
||||
|
||||
export { NotReadyError };
|
||||
14
packages/k8s/src/exports.ts
Normal file
14
packages/k8s/src/exports.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export { K8sOperator } from './operator.js';
|
||||
export {
|
||||
ResourceService,
|
||||
Resource,
|
||||
type ResourceOptions,
|
||||
ResourceReference,
|
||||
CustomResource,
|
||||
type CustomResourceOptions,
|
||||
} from './resources/resources.js';
|
||||
export { Watcher, WatcherService } from './watchers/watchers.js';
|
||||
export { NotReadyError } from './errors/errors.js';
|
||||
export * as k8s from '@kubernetes/client-node';
|
||||
export { z } from 'zod';
|
||||
export * from './core/core.js';
|
||||
2
packages/k8s/src/global.d.ts
vendored
Normal file
2
packages/k8s/src/global.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
declare type ExplicitAny = any;
|
||||
16
packages/k8s/src/operator.ts
Normal file
16
packages/k8s/src/operator.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Services } from '@morten-olsen/box-utils/services';
|
||||
import { ResourceService } from './resources/resources.js';
|
||||
|
||||
class K8sOperator {
|
||||
#services: Services;
|
||||
|
||||
constructor(services: Services = new Services()) {
|
||||
this.#services = services;
|
||||
}
|
||||
|
||||
public get resources() {
|
||||
return this.#services.get(ResourceService);
|
||||
}
|
||||
}
|
||||
|
||||
export { K8sOperator };
|
||||
183
packages/k8s/src/resources/resource/resource.custom.ts
Normal file
183
packages/k8s/src/resources/resource/resource.custom.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { z, type ZodType } from 'zod';
|
||||
import { CustomObjectsApi, PatchStrategy, setHeaderOptions, type KubernetesObject } from '@kubernetes/client-node';
|
||||
import { CronJob, CronTime } from 'cron';
|
||||
|
||||
import { K8sConfig } from '../../config/config.js';
|
||||
|
||||
import { CoalescingQueue } from '@morten-olsen/box-utils/coalescing-queue';
|
||||
import { Resource, type ResourceOptions } from './resource.js';
|
||||
import { NotReadyError } from '../../errors/errors.js'
|
||||
|
||||
const customResourceStatusSchema = z.object({
|
||||
observedGeneration: z.number().optional(),
|
||||
conditions: z
|
||||
.array(
|
||||
z.object({
|
||||
observedGeneration: z.number().optional(),
|
||||
type: z.string(),
|
||||
status: z.enum(['True', 'False', 'Unknown']),
|
||||
lastTransitionTime: z.string().datetime().optional(),
|
||||
resource: z.boolean().optional(),
|
||||
failed: z.boolean().optional(),
|
||||
syncing: z.boolean().optional(),
|
||||
reason: z.string().optional().optional(),
|
||||
message: z.string().optional().optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
type CustomResourceOptions<TSpec extends ZodType> = ResourceOptions<KubernetesObject & { spec: z.infer<TSpec> }>;
|
||||
|
||||
class CustomResource<TSpec extends ZodType> extends Resource<
|
||||
KubernetesObject & { spec: z.infer<TSpec>; status?: z.infer<typeof customResourceStatusSchema> }
|
||||
> {
|
||||
public static readonly apiVersion: string;
|
||||
public static readonly status = customResourceStatusSchema;
|
||||
public static readonly labels: Record<string, string> = {};
|
||||
public static readonly dependsOn?: Resource<KubernetesObject>[];
|
||||
|
||||
#reconcileQueue: CoalescingQueue<void>;
|
||||
#cron: CronJob;
|
||||
|
||||
constructor(options: CustomResourceOptions<TSpec>) {
|
||||
super(options);
|
||||
this.#reconcileQueue = new CoalescingQueue({
|
||||
action: async () => {
|
||||
try {
|
||||
if (!this.exists || this.manifest?.metadata?.deletionTimestamp) {
|
||||
return;
|
||||
}
|
||||
await this.markSeen();
|
||||
await this.reconcile?.();
|
||||
await this.markReady();
|
||||
} catch (err) {
|
||||
if (err instanceof NotReadyError) {
|
||||
await this.markNotReady(err.reason, err.message);
|
||||
} else if (err instanceof Error) {
|
||||
await this.markNotReady('Failed', err.message);
|
||||
} else {
|
||||
await this.markNotReady('Failed', String(err));
|
||||
}
|
||||
console.error(err);
|
||||
}
|
||||
},
|
||||
});
|
||||
this.#cron = CronJob.from({
|
||||
cronTime: '*/2 * * * *',
|
||||
onTick: this.queueReconcile,
|
||||
start: true,
|
||||
runOnInit: true,
|
||||
});
|
||||
this.on('changed', this.#handleUpdate);
|
||||
}
|
||||
|
||||
public get reconcileTime() {
|
||||
return this.#cron.cronTime.toString();
|
||||
}
|
||||
|
||||
public set reconcileTime(pattern: string) {
|
||||
this.#cron.cronTime = new CronTime(pattern);
|
||||
}
|
||||
|
||||
public get isSeen() {
|
||||
return this.metadata?.generation === this.status?.observedGeneration;
|
||||
}
|
||||
|
||||
public get version() {
|
||||
const [, version] = this.apiVersion.split('/');
|
||||
return version;
|
||||
}
|
||||
|
||||
public get group() {
|
||||
const [group] = this.apiVersion.split('/');
|
||||
return group;
|
||||
}
|
||||
|
||||
public get scope() {
|
||||
if (!('scope' in this.constructor) || typeof this.constructor.scope !== 'string') {
|
||||
return;
|
||||
}
|
||||
return this.constructor.scope as 'Namespaced' | 'Cluster';
|
||||
}
|
||||
|
||||
#handleUpdate = async (
|
||||
previous?: KubernetesObject & { spec: z.infer<TSpec>; status?: z.infer<typeof customResourceStatusSchema> },
|
||||
) => {
|
||||
if (this.isSeen && previous) {
|
||||
return;
|
||||
}
|
||||
return await this.queueReconcile();
|
||||
};
|
||||
|
||||
public reconcile?: () => Promise<void>;
|
||||
public queueReconcile = () => {
|
||||
return this.#reconcileQueue.run();
|
||||
};
|
||||
|
||||
public markSeen = async () => {
|
||||
if (this.isSeen) {
|
||||
return;
|
||||
}
|
||||
await this.patchStatus({
|
||||
observedGeneration: this.metadata?.generation,
|
||||
});
|
||||
};
|
||||
|
||||
public markNotReady = async (reason?: string, message?: string) => {
|
||||
await this.patchStatus({
|
||||
conditions: [
|
||||
{
|
||||
type: 'Ready',
|
||||
status: 'False',
|
||||
reason,
|
||||
message,
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
public markReady = async () => {
|
||||
await this.patchStatus({
|
||||
conditions: [
|
||||
{
|
||||
type: 'Ready',
|
||||
status: 'True',
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
public patchStatus = (status: Partial<z.infer<typeof customResourceStatusSchema>>) =>
|
||||
this.queue.add(async () => {
|
||||
const config = this.services.get(K8sConfig);
|
||||
const customObjectsApi = config.makeApiClient(CustomObjectsApi);
|
||||
if (this.scope === 'Cluster') {
|
||||
await customObjectsApi.patchClusterCustomObjectStatus(
|
||||
{
|
||||
version: this.version,
|
||||
group: this.group,
|
||||
plural: this.plural,
|
||||
name: this.name,
|
||||
body: { status },
|
||||
},
|
||||
setHeaderOptions('Content-Type', PatchStrategy.MergePatch),
|
||||
);
|
||||
} else {
|
||||
await customObjectsApi.patchNamespacedCustomObjectStatus(
|
||||
{
|
||||
version: this.version,
|
||||
group: this.group,
|
||||
plural: this.plural,
|
||||
name: this.name,
|
||||
namespace: this.namespace || 'default',
|
||||
body: { status },
|
||||
},
|
||||
setHeaderOptions('Content-Type', PatchStrategy.MergePatch),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export { CustomResource, type CustomResourceOptions };
|
||||
|
||||
43
packages/k8s/src/resources/resource/resource.reference.ts
Normal file
43
packages/k8s/src/resources/resource/resource.reference.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { EventEmitter } from '@morten-olsen/box-utils/event-emitter';
|
||||
import type { ResourceClass } from '../resources.js';
|
||||
|
||||
import type { ResourceEvents } from './resource.js';
|
||||
|
||||
class ResourceReference<T extends ResourceClass<ExplicitAny>> extends EventEmitter<ResourceEvents<T>> {
|
||||
#current?: {
|
||||
instance: InstanceType<T>;
|
||||
unsubscribe: () => void;
|
||||
};
|
||||
|
||||
constructor(current?: InstanceType<T>) {
|
||||
super();
|
||||
this.current = current;
|
||||
}
|
||||
|
||||
public get current() {
|
||||
return this.#current?.instance;
|
||||
}
|
||||
|
||||
public set current(value: InstanceType<T> | undefined) {
|
||||
const previous = this.#current;
|
||||
this.#current?.unsubscribe();
|
||||
if (value) {
|
||||
const unsubscribe = value.on('changed', this.#handleChange);
|
||||
this.#current = {
|
||||
instance: value,
|
||||
unsubscribe,
|
||||
};
|
||||
} else {
|
||||
this.#current = undefined;
|
||||
}
|
||||
if (previous !== value) {
|
||||
this.emit('changed');
|
||||
}
|
||||
}
|
||||
|
||||
#handleChange = () => {
|
||||
this.emit('changed');
|
||||
};
|
||||
}
|
||||
|
||||
export { ResourceReference };
|
||||
193
packages/k8s/src/resources/resource/resource.ts
Normal file
193
packages/k8s/src/resources/resource/resource.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { ApiException, KubernetesObjectApi, PatchStrategy, type KubernetesObject } from '@kubernetes/client-node';
|
||||
import deepEqual from 'deep-equal';
|
||||
|
||||
import { ResourceService } from '../resources.service.js';
|
||||
import type { Services } from '@morten-olsen/box-utils/services';
|
||||
import { EventEmitter } from '@morten-olsen/box-utils/event-emitter';
|
||||
import { Queue } from '@morten-olsen/box-utils/queue';
|
||||
import { isDeepSubset } from '@morten-olsen/box-utils/objects';
|
||||
import { K8sConfig } from '../../config/config.js';
|
||||
|
||||
type ResourceSelector = {
|
||||
apiVersion: string;
|
||||
kind: string;
|
||||
name: string;
|
||||
namespace?: string;
|
||||
};
|
||||
|
||||
type ResourceOptions<T extends KubernetesObject> = {
|
||||
services: Services;
|
||||
selector: ResourceSelector;
|
||||
manifest?: T;
|
||||
};
|
||||
|
||||
type ResourceEvents<T extends KubernetesObject> = {
|
||||
changed: (from?: T) => void;
|
||||
};
|
||||
|
||||
class Resource<T extends KubernetesObject> extends EventEmitter<ResourceEvents<T>> {
|
||||
#manifest?: T;
|
||||
#queue: Queue;
|
||||
#options: ResourceOptions<T>;
|
||||
|
||||
constructor(options: ResourceOptions<T>) {
|
||||
super();
|
||||
this.#options = options;
|
||||
this.#manifest = options.manifest;
|
||||
this.#queue = new Queue({ concurrency: 1 });
|
||||
}
|
||||
|
||||
protected get queue() {
|
||||
return this.#queue;
|
||||
}
|
||||
|
||||
public get services() {
|
||||
return this.#options.services;
|
||||
}
|
||||
|
||||
public get resources() {
|
||||
return this.services.get(ResourceService);
|
||||
}
|
||||
|
||||
public get manifest() {
|
||||
return this.#manifest;
|
||||
}
|
||||
|
||||
public set manifest(value: T | undefined) {
|
||||
if (deepEqual(this.manifest, value)) {
|
||||
return;
|
||||
}
|
||||
const previous = this.#manifest;
|
||||
this.#manifest = value;
|
||||
this.emit('changed', previous);
|
||||
}
|
||||
|
||||
public get plural() {
|
||||
if ('plural' in this.constructor && typeof this.constructor.plural === 'string') {
|
||||
return this.constructor.plural;
|
||||
}
|
||||
if ('kind' in this.constructor && typeof this.constructor.kind === 'string') {
|
||||
return this.constructor.kind.toLowerCase() + 's';
|
||||
}
|
||||
throw new Error('Unknown kind');
|
||||
}
|
||||
|
||||
public get exists() {
|
||||
return !!this.#manifest;
|
||||
}
|
||||
|
||||
public get ready() {
|
||||
return this.exists;
|
||||
}
|
||||
|
||||
public get selector() {
|
||||
return this.#options.selector;
|
||||
}
|
||||
|
||||
public get apiVersion() {
|
||||
return this.selector.apiVersion;
|
||||
}
|
||||
|
||||
public get kind() {
|
||||
return this.selector.kind;
|
||||
}
|
||||
|
||||
public get name() {
|
||||
return this.selector.name;
|
||||
}
|
||||
|
||||
public get namespace() {
|
||||
return this.selector.namespace;
|
||||
}
|
||||
|
||||
public get metadata() {
|
||||
return this.manifest?.metadata;
|
||||
}
|
||||
|
||||
public get ref() {
|
||||
if (!this.metadata?.uid) {
|
||||
throw new Error('No uid for resource');
|
||||
}
|
||||
return {
|
||||
apiVersion: this.apiVersion,
|
||||
kind: this.kind,
|
||||
name: this.name,
|
||||
uid: this.metadata.uid,
|
||||
};
|
||||
}
|
||||
|
||||
public get spec(): (T extends { spec?: infer K } ? K : never) | undefined {
|
||||
const manifest = this.manifest;
|
||||
if (!manifest || !('spec' in manifest)) {
|
||||
return;
|
||||
}
|
||||
return manifest.spec as ExplicitAny;
|
||||
}
|
||||
|
||||
public get data(): (T extends { data?: infer K } ? K : never) | undefined {
|
||||
const manifest = this.manifest;
|
||||
if (!manifest || !('data' in manifest)) {
|
||||
return;
|
||||
}
|
||||
return manifest.data as ExplicitAny;
|
||||
}
|
||||
|
||||
public get status(): (T extends { status?: infer K } ? K : never) | undefined {
|
||||
const manifest = this.manifest;
|
||||
if (!manifest || !('status' in manifest)) {
|
||||
return;
|
||||
}
|
||||
return manifest.status as ExplicitAny;
|
||||
}
|
||||
|
||||
public patch = (patch: T) =>
|
||||
this.#queue.add(async () => {
|
||||
const { services } = this.#options;
|
||||
const config = services.get(K8sConfig);
|
||||
const objectsApi = config.makeApiClient(KubernetesObjectApi);
|
||||
const body = {
|
||||
...patch,
|
||||
apiVersion: this.selector.apiVersion,
|
||||
kind: this.selector.kind,
|
||||
metadata: {
|
||||
...patch.metadata,
|
||||
name: this.selector.name,
|
||||
namespace: this.selector.namespace,
|
||||
},
|
||||
};
|
||||
try {
|
||||
this.manifest = await objectsApi.patch(
|
||||
body,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
PatchStrategy.MergePatch,
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiException && err.code === 404) {
|
||||
this.manifest = await objectsApi.create(body);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
public getCondition = (
|
||||
condition: string,
|
||||
): T extends { status?: { conditions?: (infer U)[] } } ? U | undefined : undefined => {
|
||||
const status = this.status as ExplicitAny;
|
||||
return status?.conditions?.find((c: ExplicitAny) => c?.type === condition);
|
||||
};
|
||||
|
||||
public ensure = async (manifest: T) => {
|
||||
if (isDeepSubset(this.manifest, manifest)) {
|
||||
return false;
|
||||
}
|
||||
await this.patch(manifest);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
export { Resource, type ResourceOptions, type ResourceEvents };
|
||||
|
||||
139
packages/k8s/src/resources/resources.service.ts
Normal file
139
packages/k8s/src/resources/resources.service.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
|
||||
import { ApiException, ApiextensionsV1Api, type KubernetesObject } from '@kubernetes/client-node';
|
||||
import type { ZodType } from 'zod';
|
||||
|
||||
import { WatcherService } from '../watchers/watchers.js';
|
||||
import { K8sConfig } from '../config/config.js';
|
||||
|
||||
import { createManifest } from './resources.utils.js';
|
||||
import { Resource, type ResourceOptions } from './resource/resource.js';
|
||||
import { EventEmitter } from '@morten-olsen/box-utils/event-emitter';
|
||||
import type { Services } from '@morten-olsen/box-utils/services';
|
||||
|
||||
type ResourceClass<T extends KubernetesObject> = (new (options: ResourceOptions<T>) => InstanceType<typeof Resource<T>>) & {
|
||||
apiVersion: string;
|
||||
kind: string;
|
||||
plural?: string;
|
||||
};
|
||||
|
||||
type InstallableResourceClass<T extends KubernetesObject> = ResourceClass<T> & {
|
||||
spec: ZodType;
|
||||
status: ZodType;
|
||||
scope: 'Namespaced' | 'Cluster';
|
||||
labels: Record<string, string>;
|
||||
};
|
||||
|
||||
type ResourceServiceEvents = {
|
||||
changed: (resource: Resource<ExplicitAny>) => void;
|
||||
};
|
||||
|
||||
class ResourceService extends EventEmitter<ResourceServiceEvents> {
|
||||
#services: Services;
|
||||
#registry: Map<
|
||||
ResourceClass<ExplicitAny>,
|
||||
{
|
||||
apiVersion: string;
|
||||
kind: string;
|
||||
plural?: string;
|
||||
resources: Resource<ExplicitAny>[];
|
||||
}
|
||||
>;
|
||||
|
||||
constructor(services: Services) {
|
||||
super();
|
||||
this.#services = services;
|
||||
this.#registry = new Map();
|
||||
}
|
||||
|
||||
public register = async (...resources: ResourceClass<ExplicitAny>[]) => {
|
||||
for (const resource of resources) {
|
||||
if (!this.#registry.has(resource)) {
|
||||
this.#registry.set(resource, {
|
||||
apiVersion: resource.apiVersion,
|
||||
kind: resource.kind,
|
||||
plural: resource.plural,
|
||||
resources: [],
|
||||
});
|
||||
}
|
||||
const watcherService = this.#services.get(WatcherService);
|
||||
const watcher = watcherService.create({
|
||||
...resource,
|
||||
verbs: ['add', 'update', 'delete'],
|
||||
});
|
||||
watcher.on('changed', (manifest) => {
|
||||
const { name, namespace } = manifest.metadata || {};
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
const current = this.get(resource, name, namespace);
|
||||
current.manifest = manifest;
|
||||
});
|
||||
await watcher.start();
|
||||
}
|
||||
};
|
||||
|
||||
public getAllOfKind = <T extends ResourceClass<ExplicitAny>>(type: T) => {
|
||||
return (this.#registry.get(type)?.resources?.filter((r) => r.exists) as InstanceType<T>[]) || [];
|
||||
};
|
||||
|
||||
public get = <T extends ResourceClass<ExplicitAny>>(type: T, name: string, namespace?: string) => {
|
||||
let resourceRegistry = this.#registry.get(type);
|
||||
if (!resourceRegistry) {
|
||||
resourceRegistry = {
|
||||
apiVersion: type.apiVersion,
|
||||
kind: type.kind,
|
||||
plural: type.plural,
|
||||
resources: [],
|
||||
};
|
||||
this.#registry.set(type, resourceRegistry);
|
||||
}
|
||||
const { resources, apiVersion, kind } = resourceRegistry;
|
||||
let current = resources.find((resource) => resource.name === name && resource.namespace === namespace);
|
||||
if (!current) {
|
||||
current = new type({
|
||||
selector: {
|
||||
apiVersion,
|
||||
kind,
|
||||
name,
|
||||
namespace,
|
||||
},
|
||||
services: this.#services,
|
||||
});
|
||||
current.on('changed', this.emit.bind(this, 'changed', current));
|
||||
resources.push(current);
|
||||
}
|
||||
return current as InstanceType<T>;
|
||||
};
|
||||
|
||||
public install = async (...resources: InstallableResourceClass<ExplicitAny>[]) => {
|
||||
const config = this.#services.get(K8sConfig);
|
||||
const extensionsApi = config.makeApiClient(ApiextensionsV1Api);
|
||||
for (const resource of resources) {
|
||||
try {
|
||||
const manifest = createManifest(resource);
|
||||
try {
|
||||
await extensionsApi.createCustomResourceDefinition({
|
||||
body: manifest,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ApiException && error.code === 409) {
|
||||
await extensionsApi.patchCustomResourceDefinition({
|
||||
name: manifest.metadata.name,
|
||||
body: [{ op: 'replace', path: '/spec', value: manifest.spec }],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ApiException) {
|
||||
throw new Error(`Failed to install ${resource.kind}: ${error.body}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export { ResourceService, Resource, type ResourceOptions, type ResourceClass, type InstallableResourceClass };
|
||||
|
||||
4
packages/k8s/src/resources/resources.ts
Normal file
4
packages/k8s/src/resources/resources.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { CustomResource, type CustomResourceOptions } from './resource/resource.custom.js';
|
||||
export { ResourceReference } from './resource/resource.reference.js';
|
||||
export { ResourceService, Resource, type ResourceOptions, type ResourceClass, type InstallableResourceClass } from './resources.service.js';
|
||||
|
||||
55
packages/k8s/src/resources/resources.utils.ts
Normal file
55
packages/k8s/src/resources/resources.utils.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { InstallableResourceClass } from './resources.ts';
|
||||
|
||||
const createManifest = (defintion: InstallableResourceClass<ExplicitAny>) => {
|
||||
const plural = defintion.plural ?? defintion.kind.toLowerCase() + 's';
|
||||
const [version, group] = defintion.apiVersion.split('/').toReversed();
|
||||
return {
|
||||
apiVersion: 'apiextensions.k8s.io/v1',
|
||||
kind: 'CustomResourceDefinition',
|
||||
metadata: {
|
||||
name: `${plural}.${group}`,
|
||||
labels: defintion.labels,
|
||||
},
|
||||
spec: {
|
||||
group: group,
|
||||
names: {
|
||||
kind: defintion.kind,
|
||||
plural: plural,
|
||||
singular: defintion.kind.toLowerCase(),
|
||||
},
|
||||
scope: defintion.scope,
|
||||
versions: [
|
||||
{
|
||||
name: version,
|
||||
served: true,
|
||||
storage: true,
|
||||
schema: {
|
||||
openAPIV3Schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
spec: {
|
||||
...z.toJSONSchema(defintion.spec, { io: 'input' }),
|
||||
$schema: undefined,
|
||||
additionalProperties: undefined,
|
||||
} as ExplicitAny,
|
||||
status: {
|
||||
...z.toJSONSchema(defintion.status, { io: 'input' }),
|
||||
$schema: undefined,
|
||||
additionalProperties: undefined,
|
||||
} as ExplicitAny,
|
||||
},
|
||||
},
|
||||
},
|
||||
subresources: {
|
||||
status: {},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export { createManifest };
|
||||
|
||||
23
packages/k8s/src/utils/utils.secrets.ts
Normal file
23
packages/k8s/src/utils/utils.secrets.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
const decodeSecret = <T extends Record<string, string>>(
|
||||
data: Record<string, ExplicitAny> | undefined,
|
||||
): T | undefined => {
|
||||
if (!data) {
|
||||
return undefined;
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(data).map(([name, value]) => [name, Buffer.from(value, 'base64').toString('utf8')]),
|
||||
) as T;
|
||||
};
|
||||
|
||||
const encodeSecret = <T extends Record<string, string | undefined>>(
|
||||
data: T | undefined,
|
||||
): Record<string, string> | undefined => {
|
||||
if (!data) {
|
||||
return undefined;
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(data).map(([name, value]) => [name, Buffer.from(value || '', 'utf8').toString('base64')]),
|
||||
);
|
||||
};
|
||||
|
||||
export { decodeSecret, encodeSecret };
|
||||
70
packages/k8s/src/watchers/watcher/watcher.ts
Normal file
70
packages/k8s/src/watchers/watcher/watcher.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { KubernetesObjectApi, makeInformer, type Informer, type KubernetesObject } from '@kubernetes/client-node';
|
||||
import { EventEmitter } from '@morten-olsen/box-utils/event-emitter';
|
||||
import type { Services } from '@morten-olsen/box-utils/services';
|
||||
import { K8sConfig } from '../../config/config.js';
|
||||
|
||||
type ResourceChangedAction = 'add' | 'update' | 'delete';
|
||||
|
||||
type WatcherEvents<T extends KubernetesObject> = {
|
||||
changed: (manifest: T) => void;
|
||||
};
|
||||
|
||||
type WatcherOptions = {
|
||||
apiVersion: string;
|
||||
kind: string;
|
||||
plural?: string;
|
||||
selector?: string;
|
||||
services: Services;
|
||||
verbs: ResourceChangedAction[];
|
||||
};
|
||||
|
||||
class Watcher<T extends KubernetesObject> extends EventEmitter<WatcherEvents<T>> {
|
||||
#options: WatcherOptions;
|
||||
#informer: Informer<T>;
|
||||
|
||||
constructor(options: WatcherOptions) {
|
||||
super();
|
||||
this.#options = options;
|
||||
this.#informer = this.#setup();
|
||||
}
|
||||
|
||||
#setup = () => {
|
||||
const { services, apiVersion, kind, selector } = this.#options;
|
||||
const plural = this.#options.plural ?? kind.toLowerCase() + 's';
|
||||
const [version, group] = apiVersion.split('/').toReversed();
|
||||
const config = services.get(K8sConfig);
|
||||
const path = group ? `/apis/${group}/${version}/${plural}` : `/api/${version}/${plural}`;
|
||||
const objectsApi = config.makeApiClient(KubernetesObjectApi);
|
||||
const informer = makeInformer<T>(
|
||||
config,
|
||||
path,
|
||||
async () => {
|
||||
return objectsApi.list(apiVersion, kind);
|
||||
},
|
||||
selector,
|
||||
);
|
||||
informer.on('add', this.#handleResource.bind(this, 'add'));
|
||||
informer.on('update', this.#handleResource.bind(this, 'update'));
|
||||
informer.on('delete', this.#handleResource.bind(this, 'delete'));
|
||||
informer.on('error', (err) => {
|
||||
console.log('Watcher failed, will retry in 3 seconds', path, err);
|
||||
setTimeout(this.start, 3000);
|
||||
});
|
||||
return informer;
|
||||
};
|
||||
|
||||
#handleResource = (action: ResourceChangedAction, manifest: T) => {
|
||||
this.emit('changed', manifest);
|
||||
};
|
||||
|
||||
public stop = async () => {
|
||||
await this.#informer.stop();
|
||||
};
|
||||
|
||||
public start = async () => {
|
||||
await this.#informer.start();
|
||||
};
|
||||
}
|
||||
|
||||
export { Watcher, type WatcherOptions, type ResourceChangedAction };
|
||||
|
||||
27
packages/k8s/src/watchers/watchers.ts
Normal file
27
packages/k8s/src/watchers/watchers.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Services, destroy } from "@morten-olsen/box-utils/services";
|
||||
import { Watcher, type WatcherOptions } from "./watcher/watcher.js";
|
||||
|
||||
class WatcherService {
|
||||
#services: Services;
|
||||
#instances: Watcher<ExplicitAny>[];
|
||||
|
||||
constructor(services: Services) {
|
||||
this.#instances = [];
|
||||
this.#services = services;
|
||||
}
|
||||
|
||||
public create = (options: Omit<WatcherOptions, 'services'>) => {
|
||||
const instance = new Watcher({
|
||||
...options,
|
||||
services: this.#services,
|
||||
});
|
||||
this.#instances.push(instance);
|
||||
return instance;
|
||||
};
|
||||
|
||||
[destroy] = async () => {
|
||||
await Promise.all(this.#instances.map((instance) => instance.stop()));
|
||||
};
|
||||
}
|
||||
|
||||
export { WatcherService, Watcher };
|
||||
10
packages/k8s/tsconfig.json
Normal file
10
packages/k8s/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src/"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"extends": "@morten-olsen/box-configs/tsconfig.json"
|
||||
}
|
||||
12
packages/k8s/vitest.config.ts
Normal file
12
packages/k8s/vitest.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { getAliases } from '@morten-olsen/box-tests/vitest';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default defineConfig(async () => {
|
||||
const aliases = await getAliases();
|
||||
return {
|
||||
resolve: {
|
||||
alias: aliases,
|
||||
},
|
||||
};
|
||||
});
|
||||
4
packages/operator/.gitignore
vendored
Normal file
4
packages/operator/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
/node_modules/
|
||||
/dist/
|
||||
/coverage/
|
||||
/.env
|
||||
26
packages/operator/.u8.json
Normal file
26
packages/operator/.u8.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"values": {
|
||||
"monoRepo": false,
|
||||
"packageVersion": "1.0.0"
|
||||
},
|
||||
"entries": [
|
||||
{
|
||||
"timestamp": "2025-10-23T09:58:44.328Z",
|
||||
"template": "pkg",
|
||||
"values": {
|
||||
"monoRepo": false,
|
||||
"packageName": "utils",
|
||||
"packageVersion": "1.0.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-10-23T09:59:27.144Z",
|
||||
"template": "pkg",
|
||||
"values": {
|
||||
"monoRepo": false,
|
||||
"packageVersion": "1.0.0",
|
||||
"packageName": "operator"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
30
packages/operator/package.json
Normal file
30
packages/operator/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"type": "module",
|
||||
"main": "dist/exports.js",
|
||||
"scripts": {
|
||||
"build": "tsc --build",
|
||||
"test:unit": "vitest --run --passWithNoTests",
|
||||
"test": "pnpm run \"/^test:/\""
|
||||
},
|
||||
"packageManager": "pnpm@10.6.0",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
".": "./dist/exports.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-v8": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
},
|
||||
"name": "operator",
|
||||
"version": "1.0.0",
|
||||
"imports": {
|
||||
"#root/*": "./src/*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@kubernetes/client-node": "^1.4.0"
|
||||
}
|
||||
}
|
||||
10
packages/operator/src/config
/config.ts
Normal file
10
packages/operator/src/config
/config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { KubeConfig } from '@kubernetes/client-node';
|
||||
|
||||
class K8sConfig extends KubeConfig {
|
||||
constructor() {
|
||||
super();
|
||||
this.loadFromDefault();
|
||||
}
|
||||
}
|
||||
|
||||
export { K8sConfig };
|
||||
0
packages/operator/src/exports.ts
Normal file
0
packages/operator/src/exports.ts
Normal file
29
packages/operator/tsconfig.json
Normal file
29
packages/operator/tsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"target": "ESNext",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"paths": {
|
||||
"#root/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
]
|
||||
}
|
||||
12
packages/operator/vitest.config.ts
Normal file
12
packages/operator/vitest.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { getAliases } from '@morten-olsen/box-tests/vitest';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default defineConfig(async () => {
|
||||
const aliases = await getAliases();
|
||||
return {
|
||||
resolve: {
|
||||
alias: aliases,
|
||||
},
|
||||
};
|
||||
});
|
||||
4
packages/resource-authentik/.gitignore
vendored
Normal file
4
packages/resource-authentik/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
/node_modules/
|
||||
/dist/
|
||||
/coverage/
|
||||
/.env
|
||||
30
packages/resource-authentik/package.json
Normal file
30
packages/resource-authentik/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"type": "module",
|
||||
"main": "dist/exports.js",
|
||||
"scripts": {
|
||||
"build": "tsc --build",
|
||||
"test:unit": "vitest --run --passWithNoTests",
|
||||
"test": "pnpm run \"/^test:/\""
|
||||
},
|
||||
"packageManager": "pnpm@10.6.0",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
".": "./dist/exports.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-v8": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:",
|
||||
"@morten-olsen/box-configs": "workspace:*",
|
||||
"@morten-olsen/box-tests": "workspace:*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@morten-olsen/box-k8s": "workspace:*",
|
||||
"@morten-olsen/box-utils": "workspace:*"
|
||||
},
|
||||
"name": "@morten-olsen/box-resource-authentik",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
0
packages/resource-authentik/src/exports.ts
Normal file
0
packages/resource-authentik/src/exports.ts
Normal file
2
packages/resource-authentik/src/global.d.ts
vendored
Normal file
2
packages/resource-authentik/src/global.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
declare type ExplicitAny = any;
|
||||
@@ -0,0 +1,24 @@
|
||||
import { z } from "@morten-olsen/box-k8s";
|
||||
|
||||
const valueOrSecret = z.object({
|
||||
value: z.string().optional(),
|
||||
secret: z.string().optional(),
|
||||
key: z.string().optional(),
|
||||
});
|
||||
|
||||
const serverSpec = z.object({
|
||||
allowedNamespaces: z.array(z.string()).optional(),
|
||||
domain: z.string(),
|
||||
storageClass: z.string().optional(),
|
||||
redis: z.object({
|
||||
url: valueOrSecret,
|
||||
}),
|
||||
database: z.object({
|
||||
url: valueOrSecret,
|
||||
}),
|
||||
clients: z.object({
|
||||
substitutions: z.record(z.string(), z.string()).optional()
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
export { serverSpec };
|
||||
27
packages/resource-authentik/src/resources/server/server.ts
Normal file
27
packages/resource-authentik/src/resources/server/server.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { CustomResource, Secret, type CustomResourceOptions } from "@morten-olsen/box-k8s";
|
||||
import { serverSpec } from "./server.schemas.js";
|
||||
import { API_VERSION } from '@morten-olsen/box-utils/consts';
|
||||
|
||||
type SecretData = {
|
||||
secret: string;
|
||||
};
|
||||
|
||||
class AuthentikServer extends CustomResource<typeof serverSpec> {
|
||||
public static readonly apiVersion = API_VERSION;
|
||||
public static readonly kind = 'AuthentikServer';
|
||||
public static readonly spec = serverSpec;
|
||||
public static readonly scope = 'Namespaced';
|
||||
|
||||
#secret: Secret<SecretData>;
|
||||
|
||||
constructor(options: CustomResourceOptions<typeof serverSpec>) {
|
||||
super(options);
|
||||
this.#secret = this.resources.get(Secret<SecretData>, `${this.name}-secret`, this.namespace);
|
||||
this.#secret.on('changed', this.queueReconcile);
|
||||
}
|
||||
|
||||
public reconcile = async () => {
|
||||
};
|
||||
}
|
||||
|
||||
export { AuthentikServer }
|
||||
10
packages/resource-authentik/tsconfig.json
Normal file
10
packages/resource-authentik/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"extends": "@morten-olsen/box-configs/tsconfig.json"
|
||||
}
|
||||
12
packages/resource-authentik/vitest.config.ts
Normal file
12
packages/resource-authentik/vitest.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { getAliases } from '@morten-olsen/box-tests/vitest';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default defineConfig(async () => {
|
||||
const aliases = await getAliases();
|
||||
return {
|
||||
resolve: {
|
||||
alias: aliases,
|
||||
},
|
||||
};
|
||||
});
|
||||
4
packages/resource-postgres/.gitignore
vendored
Normal file
4
packages/resource-postgres/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
/node_modules/
|
||||
/dist/
|
||||
/coverage/
|
||||
/.env
|
||||
30
packages/resource-postgres/package.json
Normal file
30
packages/resource-postgres/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"type": "module",
|
||||
"main": "dist/exports.js",
|
||||
"scripts": {
|
||||
"build": "tsc --build",
|
||||
"test:unit": "vitest --run --passWithNoTests",
|
||||
"test": "pnpm run \"/^test:/\""
|
||||
},
|
||||
"packageManager": "pnpm@10.6.0",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
".": "./dist/exports.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-v8": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:",
|
||||
"@morten-olsen/box-configs": "workspace:*",
|
||||
"@morten-olsen/box-tests": "workspace:*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@morten-olsen/box-k8s": "workspace:*",
|
||||
"@morten-olsen/box-utils": "workspace:*"
|
||||
},
|
||||
"name": "@morten-olsen/box-resource-postgres",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
0
packages/resource-postgres/src/exports.ts
Normal file
0
packages/resource-postgres/src/exports.ts
Normal file
9
packages/resource-postgres/tsconfig.json
Normal file
9
packages/resource-postgres/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"extends": "@morten-olsen/box-configs/tsconfig.json"
|
||||
}
|
||||
12
packages/resource-postgres/vitest.config.ts
Normal file
12
packages/resource-postgres/vitest.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { getAliases } from '@morten-olsen/box-tests/vitest';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default defineConfig(async () => {
|
||||
const aliases = await getAliases();
|
||||
return {
|
||||
resolve: {
|
||||
alias: aliases,
|
||||
},
|
||||
};
|
||||
});
|
||||
4
packages/resource-redis/.gitignore
vendored
Normal file
4
packages/resource-redis/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
/node_modules/
|
||||
/dist/
|
||||
/coverage/
|
||||
/.env
|
||||
30
packages/resource-redis/package.json
Normal file
30
packages/resource-redis/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"type": "module",
|
||||
"main": "dist/exports.js",
|
||||
"scripts": {
|
||||
"build": "tsc --build",
|
||||
"test:unit": "vitest --run --passWithNoTests",
|
||||
"test": "pnpm run \"/^test:/\""
|
||||
},
|
||||
"packageManager": "pnpm@10.6.0",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
".": "./dist/exports.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-v8": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:",
|
||||
"@morten-olsen/box-configs": "workspace:*",
|
||||
"@morten-olsen/box-tests": "workspace:*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@morten-olsen/box-k8s": "workspace:*",
|
||||
"@morten-olsen/box-utils": "workspace:*"
|
||||
},
|
||||
"name": "@morten-olsen/box-resource-redis",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
0
packages/resource-redis/src/exports.ts
Normal file
0
packages/resource-redis/src/exports.ts
Normal file
10
packages/resource-redis/tsconfig.json
Normal file
10
packages/resource-redis/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"extends": "@morten-olsen/box-configs/tsconfig.json"
|
||||
}
|
||||
12
packages/resource-redis/vitest.config.ts
Normal file
12
packages/resource-redis/vitest.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { getAliases } from '@morten-olsen/box-tests/vitest';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default defineConfig(async () => {
|
||||
const aliases = await getAliases();
|
||||
return {
|
||||
resolve: {
|
||||
alias: aliases,
|
||||
},
|
||||
};
|
||||
});
|
||||
4
packages/tests/.gitignore
vendored
Normal file
4
packages/tests/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
/node_modules
|
||||
/dist
|
||||
/coverage
|
||||
/.env
|
||||
27
packages/tests/package.json
Normal file
27
packages/tests/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"type": "module",
|
||||
"main": "dist/exports.js",
|
||||
"scripts": {
|
||||
"build": "tsc --build"
|
||||
},
|
||||
"packageManager": "pnpm@10.6.0",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
".": "./dist/exports.js",
|
||||
"./vitest": "./dist/vitest.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-v8": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:",
|
||||
"@morten-olsen/box-configs": "workspace:*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pnpm/find-workspace-packages": "6.0.9"
|
||||
},
|
||||
"name": "@morten-olsen/box-tests",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
1
packages/tests/src/exports.ts
Normal file
1
packages/tests/src/exports.ts
Normal file
@@ -0,0 +1 @@
|
||||
console.log('Hello World');
|
||||
10
packages/tests/src/vitest.ts
Normal file
10
packages/tests/src/vitest.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { findWorkspacePackages } from '@pnpm/find-workspace-packages';
|
||||
|
||||
const getAliases = async () => {
|
||||
const packages = await findWorkspacePackages(process.cwd());
|
||||
return Object.fromEntries(packages.map((pkg) => [pkg.manifest.name, resolve(pkg.dir, 'src', 'exports.ts')]));
|
||||
};
|
||||
|
||||
export { getAliases };
|
||||
9
packages/tests/tsconfig.json
Normal file
9
packages/tests/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"extends": "@morten-olsen/box-configs/tsconfig.json"
|
||||
}
|
||||
4
packages/utils/.gitignore
vendored
Normal file
4
packages/utils/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
/node_modules/
|
||||
/dist/
|
||||
/coverage/
|
||||
/.env
|
||||
29
packages/utils/package.json
Normal file
29
packages/utils/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc --build",
|
||||
"test:unit": "vitest --run --passWithNoTests",
|
||||
"test": "pnpm run \"/^test:/\""
|
||||
},
|
||||
"packageManager": "pnpm@10.6.0",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
"./*": "./dist/*.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@morten-olsen/box-configs": "workspace:*",
|
||||
"@morten-olsen/box-tests": "workspace:*",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-v8": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
},
|
||||
"name": "@morten-olsen/box-utils",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"p-queue": "^9.0.0",
|
||||
"p-retry": "^7.1.0"
|
||||
}
|
||||
}
|
||||
56
packages/utils/src/coalescing-queue.ts
Normal file
56
packages/utils/src/coalescing-queue.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
type CoalescingQueueOptions<T> = {
|
||||
action: () => Promise<T>;
|
||||
};
|
||||
|
||||
const createResolvable = <T>() => {
|
||||
// eslint-disable-next-line
|
||||
let resolve: (item: T) => void = () => { };
|
||||
// eslint-disable-next-line
|
||||
let reject: (item: T) => void = () => { };
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { resolve, reject, promise };
|
||||
};
|
||||
|
||||
type Resolveable<T> = ReturnType<typeof createResolvable<T>>;
|
||||
|
||||
class CoalescingQueue<T> {
|
||||
#options: CoalescingQueueOptions<T>;
|
||||
#next?: Resolveable<T>;
|
||||
#current?: Promise<T>;
|
||||
|
||||
constructor(options: CoalescingQueueOptions<T>) {
|
||||
this.#options = options;
|
||||
}
|
||||
|
||||
#start = () => {
|
||||
if (this.#current) {
|
||||
return;
|
||||
}
|
||||
const next = this.#next;
|
||||
if (next) {
|
||||
const action = this.#options.action();
|
||||
this.#current = action;
|
||||
this.#next = undefined;
|
||||
action.then(next.resolve);
|
||||
action.catch(next.reject);
|
||||
action.finally(() => {
|
||||
this.#current = undefined;
|
||||
this.#start();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
public run = async () => {
|
||||
if (!this.#next) {
|
||||
this.#next = createResolvable<T>();
|
||||
}
|
||||
const next = this.#next;
|
||||
this.#start();
|
||||
return next.promise;
|
||||
};
|
||||
}
|
||||
|
||||
export { CoalescingQueue };
|
||||
4
packages/utils/src/consts.ts
Normal file
4
packages/utils/src/consts.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
const API_GROUP = 'playground.homelab.olsen.cloud';
|
||||
const API_VERSION = `${API_GROUP}/v1`;
|
||||
|
||||
export { API_VERSION, API_GROUP };
|
||||
65
packages/utils/src/event-emitter.ts
Normal file
65
packages/utils/src/event-emitter.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
type EventListener<T extends unknown[]> = (...args: T) => void | Promise<void>;
|
||||
|
||||
type OnOptions = {
|
||||
abortSignal?: AbortSignal;
|
||||
};
|
||||
|
||||
class EventEmitter<T extends Record<string, (...args: ExplicitAny[]) => void | Promise<void>>> {
|
||||
#listeners = new Map<keyof T, Set<EventListener<ExplicitAny>>>();
|
||||
|
||||
on = <K extends keyof T>(event: K, callback: EventListener<Parameters<T[K]>>, options: OnOptions = {}) => {
|
||||
const { abortSignal } = options;
|
||||
if (!this.#listeners.has(event)) {
|
||||
this.#listeners.set(event, new Set());
|
||||
}
|
||||
const callbackClone = (...args: Parameters<T[K]>) => callback(...args);
|
||||
const abortController = new AbortController();
|
||||
const listeners = this.#listeners.get(event);
|
||||
if (!listeners) {
|
||||
throw new Error('Event registration failed');
|
||||
}
|
||||
abortSignal?.addEventListener('abort', abortController.abort);
|
||||
listeners.add(callbackClone);
|
||||
abortController.signal.addEventListener('abort', () => {
|
||||
this.#listeners.set(event, listeners?.difference(new Set([callbackClone])));
|
||||
});
|
||||
return abortController.abort;
|
||||
};
|
||||
|
||||
once = <K extends keyof T>(event: K, callback: EventListener<Parameters<T[K]>>, options: OnOptions = {}) => {
|
||||
const abortController = new AbortController();
|
||||
options.abortSignal?.addEventListener('abort', abortController.abort);
|
||||
return this.on(
|
||||
event,
|
||||
async (...args) => {
|
||||
abortController.abort();
|
||||
await callback(...args);
|
||||
},
|
||||
{
|
||||
...options,
|
||||
abortSignal: abortController.signal,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
emit = <K extends keyof T>(event: K, ...args: Parameters<T[K]>) => {
|
||||
const listeners = this.#listeners.get(event);
|
||||
if (!listeners) {
|
||||
return;
|
||||
}
|
||||
for (const listener of listeners) {
|
||||
listener(...args);
|
||||
}
|
||||
};
|
||||
|
||||
emitAsync = async <K extends keyof T>(event: K, ...args: Parameters<T[K]>) => {
|
||||
const listeners = this.#listeners.get(event);
|
||||
if (!listeners) {
|
||||
return;
|
||||
}
|
||||
await Promise.all(listeners.values().map((listener) => listener(...args)));
|
||||
};
|
||||
}
|
||||
|
||||
export { EventEmitter };
|
||||
|
||||
2
packages/utils/src/global.d.ts
vendored
Normal file
2
packages/utils/src/global.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
declare type ExplicitAny = any;
|
||||
33
packages/utils/src/objects.ts
Normal file
33
packages/utils/src/objects.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
function isDeepSubset<T>(actual: ExplicitAny, expected: T): expected is T {
|
||||
if (typeof expected !== 'object' || expected === null) {
|
||||
return actual === expected;
|
||||
}
|
||||
|
||||
if (typeof actual !== 'object' || actual === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Array.isArray(expected)) {
|
||||
if (!Array.isArray(actual)) {
|
||||
return false;
|
||||
}
|
||||
return expected.every((expectedItem) => actual.some((actualItem) => isDeepSubset(actualItem, expectedItem)));
|
||||
}
|
||||
|
||||
// Iterate over the keys of the expected object
|
||||
for (const key in expected) {
|
||||
if (Object.prototype.hasOwnProperty.call(expected, key)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(actual, key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isDeepSubset(actual[key], expected[key])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export { isDeepSubset };
|
||||
41
packages/utils/src/queue.ts
Normal file
41
packages/utils/src/queue.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import PQueue from 'p-queue';
|
||||
import pRetry from 'p-retry';
|
||||
|
||||
type QueueCreateOptions = ConstructorParameters<typeof PQueue>[0];
|
||||
|
||||
type QueueAddOptions = Parameters<typeof pRetry>[1] & {
|
||||
retries?: number;
|
||||
};
|
||||
|
||||
type QueueOptions = QueueCreateOptions & {
|
||||
retries?: number;
|
||||
};
|
||||
|
||||
class Queue {
|
||||
#options: QueueOptions;
|
||||
#queue: PQueue;
|
||||
|
||||
constructor(options: QueueOptions = {}) {
|
||||
this.#options = options;
|
||||
this.#queue = new PQueue(options);
|
||||
}
|
||||
|
||||
public get concurrency() {
|
||||
return this.#queue.concurrency;
|
||||
}
|
||||
|
||||
public set concurrency(value: number) {
|
||||
this.#queue.concurrency = value;
|
||||
}
|
||||
|
||||
public add = async <T>(task: () => Promise<T>, options: QueueAddOptions = {}) => {
|
||||
const withRetry = () =>
|
||||
pRetry(task, {
|
||||
retries: options.retries || this.#options.retries || 1,
|
||||
});
|
||||
return this.#queue.add(withRetry);
|
||||
};
|
||||
}
|
||||
|
||||
export { Queue };
|
||||
|
||||
51
packages/utils/src/services.ts
Normal file
51
packages/utils/src/services.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
const destroy = Symbol('destroy');
|
||||
const instanceKey = Symbol('instances');
|
||||
|
||||
type ServiceDependency<T> = new (services: Services) => T & {
|
||||
[destroy]?: () => Promise<void> | void;
|
||||
};
|
||||
|
||||
class Services {
|
||||
[instanceKey]: Map<ServiceDependency<unknown>, unknown>;
|
||||
|
||||
constructor() {
|
||||
this[instanceKey] = new Map();
|
||||
}
|
||||
|
||||
public get = <T>(service: ServiceDependency<T>) => {
|
||||
if (!this[instanceKey].has(service)) {
|
||||
this[instanceKey].set(service, new service(this));
|
||||
}
|
||||
const instance = this[instanceKey].get(service);
|
||||
if (!instance) {
|
||||
throw new Error('Could not generate instance');
|
||||
}
|
||||
return instance as T;
|
||||
};
|
||||
|
||||
public set = <T>(service: ServiceDependency<T>, instance: Partial<T>) => {
|
||||
this[instanceKey].set(service, instance);
|
||||
};
|
||||
|
||||
public clone = () => {
|
||||
const services = new Services();
|
||||
services[instanceKey] = Object.fromEntries(this[instanceKey].entries());
|
||||
};
|
||||
|
||||
public destroy = async () => {
|
||||
await Promise.all(
|
||||
this[instanceKey].values().map(async (instance) => {
|
||||
if (
|
||||
typeof instance === 'object' &&
|
||||
instance &&
|
||||
destroy in instance &&
|
||||
typeof instance[destroy] === 'function'
|
||||
) {
|
||||
await instance[destroy]();
|
||||
}
|
||||
}),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export { Services, destroy };
|
||||
9
packages/utils/tsconfig.json
Normal file
9
packages/utils/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"extends": "@morten-olsen/box-configs/tsconfig.json"
|
||||
}
|
||||
12
packages/utils/vitest.config.ts
Normal file
12
packages/utils/vitest.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { getAliases } from '@morten-olsen/box-tests/vitest';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default defineConfig(async () => {
|
||||
const aliases = await getAliases();
|
||||
return {
|
||||
resolve: {
|
||||
alias: aliases,
|
||||
},
|
||||
};
|
||||
});
|
||||
5269
pnpm-lock.yaml
generated
Normal file
5269
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
8
pnpm-workspace.yaml
Normal file
8
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,8 @@
|
||||
packages:
|
||||
- ./packages/*
|
||||
- ./apps/*
|
||||
catalog:
|
||||
typescript: 5.9.3
|
||||
vitest: 4.0.1
|
||||
"@vitest/coverage-v8": 4.0.1
|
||||
"@types/node": 24.9.1
|
||||
16
scripts/set-version.mjs
Normal file
16
scripts/set-version.mjs
Normal file
@@ -0,0 +1,16 @@
|
||||
import { readFile, writeFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import process from 'process';
|
||||
|
||||
import { findWorkspacePackages } from '@pnpm/find-workspace-packages';
|
||||
|
||||
const packages = await findWorkspacePackages(process.cwd());
|
||||
|
||||
for (const pkg of packages) {
|
||||
const pkgPath = join(pkg.dir, 'package.json');
|
||||
const pkgJson = JSON.parse(await readFile(pkgPath, 'utf-8'));
|
||||
|
||||
pkgJson.version = process.argv[2];
|
||||
|
||||
await writeFile(pkgPath, JSON.stringify(pkgJson, null, 2) + '\n');
|
||||
}
|
||||
37
turbo.json
Normal file
37
turbo.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"$schema": "https://turbo.build/schema.json",
|
||||
"tasks": {
|
||||
"build": {
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
],
|
||||
"outputs": [
|
||||
"dist/**",
|
||||
"public/**"
|
||||
],
|
||||
"inputs": [
|
||||
"src/**/*.tsx",
|
||||
"src/**/*.ts",
|
||||
"./tsconfig.*",
|
||||
"../../pnpm-lock.yaml"
|
||||
]
|
||||
},
|
||||
"test": {
|
||||
"cache": false
|
||||
},
|
||||
"clean": {},
|
||||
"dev": {
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
],
|
||||
"cache": false,
|
||||
"persistent": true
|
||||
},
|
||||
"demo": {
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
],
|
||||
"persistent": true
|
||||
}
|
||||
}
|
||||
}
|
||||
14
vitest.config.ts
Normal file
14
vitest.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineConfig, type UserConfigExport } from 'vitest/config';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default defineConfig(async () => {
|
||||
const config: UserConfigExport = {
|
||||
test: {
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
include: ['packages/**/src/**/*.ts'],
|
||||
},
|
||||
},
|
||||
};
|
||||
return config;
|
||||
});
|
||||
Reference in New Issue
Block a user