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

@@ -6,14 +6,10 @@
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "3.3.1", "@eslint/eslintrc": "3.3.1",
"@eslint/js": "9.32.0", "@eslint/js": "9.32.0",
"@pnpm/find-workspace-packages": "6.0.9",
"@types/bun": "latest",
"eslint": "9.32.0", "eslint": "9.32.0",
"eslint-config-prettier": "10.1.8", "eslint-config-prettier": "10.1.8",
"eslint-plugin-import": "2.32.0", "eslint-plugin-import": "2.32.0",
"eslint-plugin-prettier": "5.5.3", "eslint-plugin-prettier": "5.5.3",
"nodemon": "^3.1.10",
"openapi-typescript": "^7.8.0",
"prettier": "3.6.2", "prettier": "3.6.2",
"typescript": "5.8.3", "typescript": "5.8.3",
"typescript-eslint": "8.38.0" "typescript-eslint": "8.38.0"
@@ -24,10 +20,7 @@
"dependencies": { "dependencies": {
"@goauthentik/api": "2025.6.3-1751754396", "@goauthentik/api": "2025.6.3-1751754396",
"@kubernetes/client-node": "^1.3.0", "@kubernetes/client-node": "^1.3.0",
"@sinclair/typebox": "^0.34.38",
"dotenv": "^17.2.1",
"knex": "^3.1.0", "knex": "^3.1.0",
"openapi-fetch": "^0.14.0",
"pg": "^8.16.3", "pg": "^8.16.3",
"sqlite3": "^5.1.7", "sqlite3": "^5.1.7",
"yaml": "^2.8.0", "yaml": "^2.8.0",

1447
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,31 +1,21 @@
import { Type } from '@sinclair/typebox';
import { SubModeEnum } from '@goauthentik/api'; import { SubModeEnum } from '@goauthentik/api';
import { z } from 'zod';
import { CustomResource, type CustomResourceHandlerOptions } from '../../../custom-resource/custom-resource.base.ts'; import { CustomResource, type CustomResourceHandlerOptions } from '../../../custom-resource/custom-resource.base.ts';
import { AuthentikService } from '../../../services/authentik/authentik.service.ts'; import { AuthentikService } from '../../../services/authentik/authentik.service.ts';
const authentikClientSpec = Type.Object({ const authentikClientSpec = z.object({
subMode: Type.Optional(Type.Unsafe<SubModeEnum>(Type.String())), subMode: z.enum(SubModeEnum).optional(),
clientType: Type.Optional( clientType: z.enum(['confidential', 'public']).optional(),
Type.Unsafe<'confidential' | 'public'>( redirectUris: z.array(
Type.String({ z.object({
enum: ['confidential', 'public'], url: z.string(),
}), matchingMode: z.enum(['strict', 'regex']),
),
),
redirectUris: Type.Array(
Type.Object({
url: Type.String(),
matchingMode: Type.Unsafe<'strict' | 'regex'>(
Type.String({
enum: ['strict', 'regex'],
}),
),
}), }),
), ),
}); });
const authentikClientSecret = Type.Object({ const authentikClientSecret = z.object({
clientSecret: Type.String(), clientSecret: z.string(),
}); });
class AuthentikClient extends CustomResource<typeof authentikClientSpec> { 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 { CustomResource, type CustomResourceHandlerOptions } from '../../custom-resource/custom-resource.base.ts';
import { PostgresService } from '../../services/postgres/postgres.service.ts'; import { PostgresService } from '../../services/postgres/postgres.service.ts';
const postgresDatabaseSpecSchema = Type.Object({}); const postgresDatabaseSpecSchema = z.object({});
class PostgresDatabase extends CustomResource<typeof postgresDatabaseSpecSchema> { class PostgresDatabase extends CustomResource<typeof postgresDatabaseSpecSchema> {
constructor() { constructor() {
@@ -22,10 +22,10 @@ class PostgresDatabase extends CustomResource<typeof postgresDatabaseSpecSchema>
const variables = await ensureSecret({ const variables = await ensureSecret({
name: `postgres-database-${request.metadata.name}`, name: `postgres-database-${request.metadata.name}`,
namespace: request.metadata.namespace ?? 'default', namespace: request.metadata.namespace ?? 'default',
schema: Type.Object({ schema: z.object({
name: Type.String(), name: z.string(),
user: Type.String(), user: z.string(),
password: Type.String(), password: z.string(),
}), }),
generator: async () => ({ generator: async () => ({
name: `${request.metadata.namespace || 'default'}_${request.metadata.name}`, 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'; import { CustomResource, type CustomResourceHandlerOptions } from '../../custom-resource/custom-resource.base.ts';
const stringValueSchema = Type.Object({ const stringValueSchema = z.object({
key: Type.String(), key: z.string(),
chars: Type.Optional(Type.String()), chars: z.string().optional(),
length: Type.Optional(Type.Number()), length: z.number().optional(),
encoding: Type.Optional( encoding: z.enum(['utf-8', 'base64', 'base64url', 'hex']).optional(),
Type.String({ value: z.string().optional(),
enum: ['utf-8', 'base64', 'base64url', 'hex'],
}),
),
value: Type.Optional(Type.String()),
}); });
const secretRequestSpec = Type.Object({ const secretRequestSpec = z.object({
secretName: Type.Optional(Type.String()), secretName: z.string().optional(),
data: Type.Array(stringValueSchema), data: z.array(stringValueSchema),
}); });
class SecretRequest extends CustomResource<typeof secretRequestSpec> { class SecretRequest extends CustomResource<typeof secretRequestSpec> {
@@ -38,7 +34,7 @@ class SecretRequest extends CustomResource<typeof secretRequestSpec> {
await ensureSecret({ await ensureSecret({
name: secretName, name: secretName,
namespace, namespace,
schema: Type.Object({}, { additionalProperties: true }), schema: z.object({}).passthrough(),
generator: async () => ({ generator: async () => ({
hello: 'world', 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 { GROUP } from '../utils/consts.ts';
import type { Services } from '../utils/service.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'; import { customResourceStatusSchema, type CustomResourceRequest } from './custom-resource.request.ts';
type EnsureSecretOptions<T extends TObject> = { type EnsureSecretOptions<T extends ZodObject> = {
schema: T; schema: T;
name: string; name: string;
namespace: 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>; 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; services: Services;
}; };
type CustomResourceConstructor<TSpec extends TSchema> = { type CustomResourceConstructor<TSpec extends ZodObject> = {
kind: string; kind: string;
spec: TSpec; spec: TSpec;
names: { 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>; #options: CustomResourceConstructor<TSpec>;
constructor(options: CustomResourceConstructor<TSpec>) { constructor(options: CustomResourceConstructor<TSpec>) {
@@ -89,8 +89,16 @@ abstract class CustomResource<TSpec extends TSchema> {
openAPIV3Schema: { openAPIV3Schema: {
type: 'object', type: 'object',
properties: { properties: {
spec: this.spec, spec: {
status: customResourceStatusSchema as ExpectedAny, ...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> & { options: CustomResourceConstructor<TSpec> & {
update?: (options: CustomResourceHandlerOptions<TSpec>) => Promise<void>; update?: (options: CustomResourceHandlerOptions<TSpec>) => Promise<void>;
create?: (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 { ApiException, Watch } from '@kubernetes/client-node';
import { type TObject } from '@sinclair/typebox'; import type { ZodObject } from 'zod';
import { K8sService } from '../services/k8s.ts'; import { K8sService } from '../services/k8s.ts';
import type { Services } from '../utils/service.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 { type CustomResource, type EnsureSecretOptions } from './custom-resource.base.ts';
import { CustomResourceRequest } from './custom-resource.request.ts'; import { CustomResourceRequest } from './custom-resource.request.ts';
class CustomResourceRegistry { class CustomResourceRegistry {
#services: Services; #services: Services;
#resources = new Set<CustomResource<ExpectedAny>>(); #resources = new Set<CustomResource<ExpectedAny>>();
@@ -53,7 +53,7 @@ class CustomResourceRegistry {
#ensureSecret = #ensureSecret =
(request: CustomResourceRequest<ExpectedAny>) => (request: CustomResourceRequest<ExpectedAny>) =>
async <T extends TObject>(options: EnsureSecretOptions<T>) => { async <T extends ZodObject>(options: EnsureSecretOptions<T>) => {
const { schema, name, namespace, generator } = options; const { schema, name, namespace, generator } = options;
const { metadata } = request; const { metadata } = request;
const k8sService = this.#services.get(K8sService); const k8sService = this.#services.get(K8sService);
@@ -69,7 +69,7 @@ class CustomResourceRegistry {
const decoded = Object.fromEntries( const decoded = Object.fromEntries(
Object.entries(secret.data).map(([key, value]) => [key, Buffer.from(value, 'base64').toString('utf-8')]), 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; return decoded;
} }
} }
@@ -203,6 +203,7 @@ class CustomResourceRegistry {
public install = async (replace = false) => { public install = async (replace = false) => {
const k8sService = this.#services.get(K8sService); const k8sService = this.#services.get(K8sService);
for (const crd of this.#resources) { for (const crd of this.#resources) {
this.#services.log.info('Installing CRD', { kind: crd.kind });
try { try {
const manifest = crd.toManifest(); const manifest = crd.toManifest();
try { 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 { ApiException, PatchStrategy, setHeaderOptions, V1MicroTime } from '@kubernetes/client-node';
import { z, type ZodObject } from 'zod';
import type { Services } from '../utils/service.ts'; import type { Services } from '../utils/service.ts';
import { K8sService } from '../services/k8s.ts'; import { K8sService } from '../services/k8s.ts';
@@ -31,24 +31,22 @@ type CustomResourceEvent = {
type: 'Normal' | 'Warning' | 'Error'; type: 'Normal' | 'Warning' | 'Error';
}; };
const customResourceStatusSchema = Type.Object({ const customResourceStatusSchema = z.object({
observedGeneration: Type.Number(), observedGeneration: z.number(),
conditions: Type.Array( conditions: z.array(
Type.Object({ z.object({
type: Type.String(), type: z.string(),
status: Type.String({ status: z.enum(['True', 'False', 'Unknown']),
enum: ['True', 'False', 'Unknown'], lastTransitionTime: z.string().datetime(),
}), reason: z.string().optional(),
lastTransitionTime: Type.String({ format: 'date-time' }), message: z.string().optional(),
reason: Type.Optional(Type.String()),
message: Type.Optional(Type.String()),
}), }),
), ),
}); });
type CustomResourceStatus = Static<typeof customResourceStatusSchema>; type CustomResourceStatus = z.infer<typeof customResourceStatusSchema>;
class CustomResourceRequest<TSpec extends TSchema> { class CustomResourceRequest<TSpec extends ZodObject> {
#options: CustomResourceRequestOptions; #options: CustomResourceRequestOptions;
constructor(options: CustomResourceRequestOptions) { constructor(options: CustomResourceRequestOptions) {
@@ -75,7 +73,7 @@ class CustomResourceRequest<TSpec extends TSchema> {
return this.#options.manifest.apiVersion; return this.#options.manifest.apiVersion;
} }
public get spec(): Static<TSpec> { public get spec(): z.infer<TSpec> {
return this.#options.manifest.spec; return this.#options.manifest.spec;
} }
@@ -211,7 +209,7 @@ class CustomResourceRequest<TSpec extends TSchema> {
apiVersion: string; apiVersion: string;
kind: string; kind: string;
metadata: CustomResourceRequestMetadata; metadata: CustomResourceRequestMetadata;
spec: Static<TSpec>; spec: z.infer<TSpec>;
status: CustomResourceStatus; status: CustomResourceStatus;
}; };
} catch (error) { } catch (error) {

View File

@@ -29,6 +29,7 @@ process.on('uncaughtException', (error) => {
return console.error(error.body); return console.error(error.body);
} }
console.error(error); console.error(error);
process.exit(1);
}); });
process.on('unhandledRejection', (error) => { process.on('unhandledRejection', (error) => {
@@ -41,4 +42,5 @@ process.on('unhandledRejection', (error) => {
return console.error(error.body); return console.error(error.body);
} }
console.error(error); 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 };