This commit is contained in:
Morten Olsen
2025-10-23 13:47:07 +02:00
commit b851dc3006
91 changed files with 7578 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
/node_modules
.turbo/
/.env
/coverage/

18
.prettierrc.json Normal file
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,4 @@
/node_modules/
/dist/
/coverage/
/.env

View 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/*"
}
}

View File

View File

@@ -0,0 +1,9 @@
{
"compilerOptions": {
"outDir": "./dist"
},
"include": [
"src/**/*.ts"
],
"extends": "@morten-olsen/box-configs/tsconfig.json"
}

View 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,
},
};
});

View File

@@ -0,0 +1,4 @@
{
"name": "@morten-olsen/box-configs",
"version": "1.0.0"
}

View 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
View File

@@ -0,0 +1,4 @@
/node_modules/
/dist/
/coverage/
/.env

292
packages/k8s/README.md Normal file
View 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
View 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"
}

View File

@@ -0,0 +1,10 @@
import { KubeConfig } from '@kubernetes/client-node';
class K8sConfig extends KubeConfig {
constructor() {
super();
this.loadFromDefault();
}
}
export { K8sConfig };

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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,
}

View 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 };

View 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
View File

@@ -0,0 +1,2 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any
declare type ExplicitAny = any;

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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';

View 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 };

View 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 };

View 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 };

View 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 };

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src/"
},
"include": [
"src/**/*.ts"
],
"extends": "@morten-olsen/box-configs/tsconfig.json"
}

View 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
View File

@@ -0,0 +1,4 @@
/node_modules/
/dist/
/coverage/
/.env

View 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"
}
}
]
}

View 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"
}
}

View File

@@ -0,0 +1,10 @@
import { KubeConfig } from '@kubernetes/client-node';
class K8sConfig extends KubeConfig {
constructor() {
super();
this.loadFromDefault();
}
}
export { K8sConfig };

View File

View 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"
]
}

View 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,
},
};
});

View File

@@ -0,0 +1,4 @@
/node_modules/
/dist/
/coverage/
/.env

View 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"
}

View File

@@ -0,0 +1,2 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any
declare type ExplicitAny = any;

View File

@@ -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 };

View 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 }

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": [
"src/**/*.ts"
],
"extends": "@morten-olsen/box-configs/tsconfig.json"
}

View 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
View File

@@ -0,0 +1,4 @@
/node_modules/
/dist/
/coverage/
/.env

View 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"
}

View File

@@ -0,0 +1,9 @@
{
"compilerOptions": {
"outDir": "./dist"
},
"include": [
"src/**/*.ts"
],
"extends": "@morten-olsen/box-configs/tsconfig.json"
}

View 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
View File

@@ -0,0 +1,4 @@
/node_modules/
/dist/
/coverage/
/.env

View 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"
}

View File

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": [
"src/**/*.ts"
],
"extends": "@morten-olsen/box-configs/tsconfig.json"
}

View 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
View File

@@ -0,0 +1,4 @@
/node_modules
/dist
/coverage
/.env

View 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"
}

View File

@@ -0,0 +1 @@
console.log('Hello World');

View 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 };

View 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
View File

@@ -0,0 +1,4 @@
/node_modules/
/dist/
/coverage/
/.env

View 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"
}
}

View 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 };

View File

@@ -0,0 +1,4 @@
const API_GROUP = 'playground.homelab.olsen.cloud';
const API_VERSION = `${API_GROUP}/v1`;
export { API_VERSION, API_GROUP };

View 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
View File

@@ -0,0 +1,2 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any
declare type ExplicitAny = any;

View 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 };

View 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 };

View 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 };

View File

@@ -0,0 +1,9 @@
{
"compilerOptions": {
"outDir": "./dist"
},
"include": [
"src/**/*.ts"
],
"extends": "@morten-olsen/box-configs/tsconfig.json"
}

View 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

File diff suppressed because it is too large Load Diff

8
pnpm-workspace.yaml Normal file
View 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
View 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
View 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
View 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;
});