Migrate to zod

This commit is contained in:
Morten Olsen
2025-07-31 10:42:09 +02:00
parent 85d043aec3
commit 34bba171ef
11 changed files with 79 additions and 1523 deletions

View File

@@ -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> {

View File

@@ -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}`,

View File

@@ -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',
}),

View File

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

View File

@@ -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 {

View File

@@ -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) {

View File

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

View File

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