mirror of
https://github.com/morten-olsen/homelab-operator.git
synced 2026-02-08 01:36:28 +01:00
Migrate to zod
This commit is contained in:
@@ -6,14 +6,10 @@
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "3.3.1",
|
||||
"@eslint/js": "9.32.0",
|
||||
"@pnpm/find-workspace-packages": "6.0.9",
|
||||
"@types/bun": "latest",
|
||||
"eslint": "9.32.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"eslint-plugin-prettier": "5.5.3",
|
||||
"nodemon": "^3.1.10",
|
||||
"openapi-typescript": "^7.8.0",
|
||||
"prettier": "3.6.2",
|
||||
"typescript": "5.8.3",
|
||||
"typescript-eslint": "8.38.0"
|
||||
@@ -24,10 +20,7 @@
|
||||
"dependencies": {
|
||||
"@goauthentik/api": "2025.6.3-1751754396",
|
||||
"@kubernetes/client-node": "^1.3.0",
|
||||
"@sinclair/typebox": "^0.34.38",
|
||||
"dotenv": "^17.2.1",
|
||||
"knex": "^3.1.0",
|
||||
"openapi-fetch": "^0.14.0",
|
||||
"pg": "^8.16.3",
|
||||
"sqlite3": "^5.1.7",
|
||||
"yaml": "^2.8.0",
|
||||
|
||||
1447
pnpm-lock.yaml
generated
1447
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,31 +1,21 @@
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import { SubModeEnum } from '@goauthentik/api';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { CustomResource, type CustomResourceHandlerOptions } from '../../../custom-resource/custom-resource.base.ts';
|
||||
import { AuthentikService } from '../../../services/authentik/authentik.service.ts';
|
||||
|
||||
const authentikClientSpec = Type.Object({
|
||||
subMode: Type.Optional(Type.Unsafe<SubModeEnum>(Type.String())),
|
||||
clientType: Type.Optional(
|
||||
Type.Unsafe<'confidential' | 'public'>(
|
||||
Type.String({
|
||||
enum: ['confidential', 'public'],
|
||||
}),
|
||||
),
|
||||
),
|
||||
redirectUris: Type.Array(
|
||||
Type.Object({
|
||||
url: Type.String(),
|
||||
matchingMode: Type.Unsafe<'strict' | 'regex'>(
|
||||
Type.String({
|
||||
enum: ['strict', 'regex'],
|
||||
}),
|
||||
),
|
||||
const authentikClientSpec = z.object({
|
||||
subMode: z.enum(SubModeEnum).optional(),
|
||||
clientType: z.enum(['confidential', 'public']).optional(),
|
||||
redirectUris: z.array(
|
||||
z.object({
|
||||
url: z.string(),
|
||||
matchingMode: z.enum(['strict', 'regex']),
|
||||
}),
|
||||
),
|
||||
});
|
||||
const authentikClientSecret = Type.Object({
|
||||
clientSecret: Type.String(),
|
||||
const authentikClientSecret = z.object({
|
||||
clientSecret: z.string(),
|
||||
});
|
||||
|
||||
class AuthentikClient extends CustomResource<typeof authentikClientSpec> {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { CustomResource, type CustomResourceHandlerOptions } from '../../custom-resource/custom-resource.base.ts';
|
||||
import { PostgresService } from '../../services/postgres/postgres.service.ts';
|
||||
|
||||
const postgresDatabaseSpecSchema = Type.Object({});
|
||||
const postgresDatabaseSpecSchema = z.object({});
|
||||
|
||||
class PostgresDatabase extends CustomResource<typeof postgresDatabaseSpecSchema> {
|
||||
constructor() {
|
||||
@@ -22,10 +22,10 @@ class PostgresDatabase extends CustomResource<typeof postgresDatabaseSpecSchema>
|
||||
const variables = await ensureSecret({
|
||||
name: `postgres-database-${request.metadata.name}`,
|
||||
namespace: request.metadata.namespace ?? 'default',
|
||||
schema: Type.Object({
|
||||
name: Type.String(),
|
||||
user: Type.String(),
|
||||
password: Type.String(),
|
||||
schema: z.object({
|
||||
name: z.string(),
|
||||
user: z.string(),
|
||||
password: z.string(),
|
||||
}),
|
||||
generator: async () => ({
|
||||
name: `${request.metadata.namespace || 'default'}_${request.metadata.name}`,
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { CustomResource, type CustomResourceHandlerOptions } from '../../custom-resource/custom-resource.base.ts';
|
||||
|
||||
const stringValueSchema = Type.Object({
|
||||
key: Type.String(),
|
||||
chars: Type.Optional(Type.String()),
|
||||
length: Type.Optional(Type.Number()),
|
||||
encoding: Type.Optional(
|
||||
Type.String({
|
||||
enum: ['utf-8', 'base64', 'base64url', 'hex'],
|
||||
}),
|
||||
),
|
||||
value: Type.Optional(Type.String()),
|
||||
const stringValueSchema = z.object({
|
||||
key: z.string(),
|
||||
chars: z.string().optional(),
|
||||
length: z.number().optional(),
|
||||
encoding: z.enum(['utf-8', 'base64', 'base64url', 'hex']).optional(),
|
||||
value: z.string().optional(),
|
||||
});
|
||||
|
||||
const secretRequestSpec = Type.Object({
|
||||
secretName: Type.Optional(Type.String()),
|
||||
data: Type.Array(stringValueSchema),
|
||||
const secretRequestSpec = z.object({
|
||||
secretName: z.string().optional(),
|
||||
data: z.array(stringValueSchema),
|
||||
});
|
||||
|
||||
class SecretRequest extends CustomResource<typeof secretRequestSpec> {
|
||||
@@ -38,7 +34,7 @@ class SecretRequest extends CustomResource<typeof secretRequestSpec> {
|
||||
await ensureSecret({
|
||||
name: secretName,
|
||||
namespace,
|
||||
schema: Type.Object({}, { additionalProperties: true }),
|
||||
schema: z.object({}).passthrough(),
|
||||
generator: async () => ({
|
||||
hello: 'world',
|
||||
}),
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import { type Static, type TObject, type TSchema } from '@sinclair/typebox';
|
||||
import { z, type ZodObject } from 'zod';
|
||||
|
||||
import { GROUP } from '../utils/consts.ts';
|
||||
import type { Services } from '../utils/service.ts';
|
||||
import { noopAsync } from '../utils/types.js';
|
||||
import { noopAsync } from '../utils/types.ts';
|
||||
|
||||
import { customResourceStatusSchema, type CustomResourceRequest } from './custom-resource.request.ts';
|
||||
|
||||
type EnsureSecretOptions<T extends TObject> = {
|
||||
type EnsureSecretOptions<T extends ZodObject> = {
|
||||
schema: T;
|
||||
name: string;
|
||||
namespace: string;
|
||||
generator: () => Promise<Static<T>>;
|
||||
generator: () => Promise<z.infer<T>>;
|
||||
};
|
||||
|
||||
type CustomResourceHandlerOptions<TSpec extends TSchema> = {
|
||||
type CustomResourceHandlerOptions<TSpec extends ZodObject> = {
|
||||
request: CustomResourceRequest<TSpec>;
|
||||
ensureSecret: <T extends TObject>(options: EnsureSecretOptions<T>) => Promise<Static<T>>;
|
||||
ensureSecret: <T extends ZodObject>(options: EnsureSecretOptions<T>) => Promise<z.infer<T>>;
|
||||
services: Services;
|
||||
};
|
||||
|
||||
type CustomResourceConstructor<TSpec extends TSchema> = {
|
||||
type CustomResourceConstructor<TSpec extends ZodObject> = {
|
||||
kind: string;
|
||||
spec: TSpec;
|
||||
names: {
|
||||
@@ -28,7 +28,7 @@ type CustomResourceConstructor<TSpec extends TSchema> = {
|
||||
};
|
||||
};
|
||||
|
||||
abstract class CustomResource<TSpec extends TSchema> {
|
||||
abstract class CustomResource<TSpec extends ZodObject> {
|
||||
#options: CustomResourceConstructor<TSpec>;
|
||||
|
||||
constructor(options: CustomResourceConstructor<TSpec>) {
|
||||
@@ -89,8 +89,16 @@ abstract class CustomResource<TSpec extends TSchema> {
|
||||
openAPIV3Schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
spec: this.spec,
|
||||
status: customResourceStatusSchema as ExpectedAny,
|
||||
spec: {
|
||||
...z.toJSONSchema(this.spec.strict(), { io: 'input' }),
|
||||
$schema: undefined,
|
||||
additionalProperties: undefined,
|
||||
} as ExpectedAny,
|
||||
status: {
|
||||
...z.toJSONSchema(customResourceStatusSchema.strict(), { io: 'input' }),
|
||||
$schema: undefined,
|
||||
additionalProperties: undefined,
|
||||
} as ExpectedAny,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -104,7 +112,7 @@ abstract class CustomResource<TSpec extends TSchema> {
|
||||
};
|
||||
}
|
||||
|
||||
const createCustomResource = <TSpec extends TSchema>(
|
||||
const createCustomResource = <TSpec extends ZodObject>(
|
||||
options: CustomResourceConstructor<TSpec> & {
|
||||
update?: (options: CustomResourceHandlerOptions<TSpec>) => Promise<void>;
|
||||
create?: (options: CustomResourceHandlerOptions<TSpec>) => Promise<void>;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { ApiException, Watch } from '@kubernetes/client-node';
|
||||
import { type TObject } from '@sinclair/typebox';
|
||||
import type { ZodObject } from 'zod';
|
||||
|
||||
import { K8sService } from '../services/k8s.ts';
|
||||
import type { Services } from '../utils/service.ts';
|
||||
import { isSchemaValid } from '../utils/schemas.ts';
|
||||
|
||||
import { type CustomResource, type EnsureSecretOptions } from './custom-resource.base.ts';
|
||||
import { CustomResourceRequest } from './custom-resource.request.ts';
|
||||
|
||||
class CustomResourceRegistry {
|
||||
#services: Services;
|
||||
#resources = new Set<CustomResource<ExpectedAny>>();
|
||||
@@ -53,7 +53,7 @@ class CustomResourceRegistry {
|
||||
|
||||
#ensureSecret =
|
||||
(request: CustomResourceRequest<ExpectedAny>) =>
|
||||
async <T extends TObject>(options: EnsureSecretOptions<T>) => {
|
||||
async <T extends ZodObject>(options: EnsureSecretOptions<T>) => {
|
||||
const { schema, name, namespace, generator } = options;
|
||||
const { metadata } = request;
|
||||
const k8sService = this.#services.get(K8sService);
|
||||
@@ -69,7 +69,7 @@ class CustomResourceRegistry {
|
||||
const decoded = Object.fromEntries(
|
||||
Object.entries(secret.data).map(([key, value]) => [key, Buffer.from(value, 'base64').toString('utf-8')]),
|
||||
);
|
||||
if (isSchemaValid(schema, decoded)) {
|
||||
if (schema.safeParse(decoded).success) {
|
||||
return decoded;
|
||||
}
|
||||
}
|
||||
@@ -203,6 +203,7 @@ class CustomResourceRegistry {
|
||||
public install = async (replace = false) => {
|
||||
const k8sService = this.#services.get(K8sService);
|
||||
for (const crd of this.#resources) {
|
||||
this.#services.log.info('Installing CRD', { kind: crd.kind });
|
||||
try {
|
||||
const manifest = crd.toManifest();
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Type, type Static, type TSchema } from '@sinclair/typebox';
|
||||
import { ApiException, PatchStrategy, setHeaderOptions, V1MicroTime } from '@kubernetes/client-node';
|
||||
import { z, type ZodObject } from 'zod';
|
||||
|
||||
import type { Services } from '../utils/service.ts';
|
||||
import { K8sService } from '../services/k8s.ts';
|
||||
@@ -31,24 +31,22 @@ type CustomResourceEvent = {
|
||||
type: 'Normal' | 'Warning' | 'Error';
|
||||
};
|
||||
|
||||
const customResourceStatusSchema = Type.Object({
|
||||
observedGeneration: Type.Number(),
|
||||
conditions: Type.Array(
|
||||
Type.Object({
|
||||
type: Type.String(),
|
||||
status: Type.String({
|
||||
enum: ['True', 'False', 'Unknown'],
|
||||
}),
|
||||
lastTransitionTime: Type.String({ format: 'date-time' }),
|
||||
reason: Type.Optional(Type.String()),
|
||||
message: Type.Optional(Type.String()),
|
||||
const customResourceStatusSchema = z.object({
|
||||
observedGeneration: z.number(),
|
||||
conditions: z.array(
|
||||
z.object({
|
||||
type: z.string(),
|
||||
status: z.enum(['True', 'False', 'Unknown']),
|
||||
lastTransitionTime: z.string().datetime(),
|
||||
reason: z.string().optional(),
|
||||
message: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
type CustomResourceStatus = Static<typeof customResourceStatusSchema>;
|
||||
type CustomResourceStatus = z.infer<typeof customResourceStatusSchema>;
|
||||
|
||||
class CustomResourceRequest<TSpec extends TSchema> {
|
||||
class CustomResourceRequest<TSpec extends ZodObject> {
|
||||
#options: CustomResourceRequestOptions;
|
||||
|
||||
constructor(options: CustomResourceRequestOptions) {
|
||||
@@ -75,7 +73,7 @@ class CustomResourceRequest<TSpec extends TSchema> {
|
||||
return this.#options.manifest.apiVersion;
|
||||
}
|
||||
|
||||
public get spec(): Static<TSpec> {
|
||||
public get spec(): z.infer<TSpec> {
|
||||
return this.#options.manifest.spec;
|
||||
}
|
||||
|
||||
@@ -211,7 +209,7 @@ class CustomResourceRequest<TSpec extends TSchema> {
|
||||
apiVersion: string;
|
||||
kind: string;
|
||||
metadata: CustomResourceRequestMetadata;
|
||||
spec: Static<TSpec>;
|
||||
spec: z.infer<TSpec>;
|
||||
status: CustomResourceStatus;
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@@ -29,6 +29,7 @@ process.on('uncaughtException', (error) => {
|
||||
return console.error(error.body);
|
||||
}
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (error) => {
|
||||
@@ -41,4 +42,5 @@ process.on('unhandledRejection', (error) => {
|
||||
return console.error(error.body);
|
||||
}
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import type { Static, TSchema } from '@sinclair/typebox';
|
||||
import { TypeCompiler } from '@sinclair/typebox/compiler';
|
||||
|
||||
const isSchemaValid = <T extends TSchema>(schema: T, data: unknown): data is Static<T> => {
|
||||
const compiler = TypeCompiler.Compile(schema);
|
||||
return compiler.Check(data);
|
||||
};
|
||||
|
||||
export { isSchemaValid };
|
||||
Reference in New Issue
Block a user