pepr 0.48.1-nightly.10 → 0.48.1-nightly.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -16,7 +16,7 @@
16
16
  "!src/fixtures/**",
17
17
  "!dist/**/*.test.d.ts*"
18
18
  ],
19
- "version": "0.48.1-nightly.10",
19
+ "version": "0.48.1-nightly.12",
20
20
  "main": "dist/lib.js",
21
21
  "types": "dist/lib.d.ts",
22
22
  "scripts": {
@@ -59,7 +59,8 @@
59
59
  "pino-pretty": "13.0.0",
60
60
  "prom-client": "15.1.3",
61
61
  "ramda": "0.30.1",
62
- "sigstore": "3.1.0"
62
+ "sigstore": "3.1.0",
63
+ "ts-morph": "^25.0.1"
63
64
  },
64
65
  "devDependencies": {
65
66
  "@commitlint/cli": "19.8.0",
@@ -0,0 +1,134 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // SPDX-FileCopyrightText: 2023-Present The Pepr Authors
3
+
4
+ import { Command } from "commander";
5
+ import { createDirectoryIfNotExists } from "../../lib/filesystemService";
6
+ import { promises as fs } from "fs";
7
+ import path from "path";
8
+
9
+ // Scaffolds a new CRD TypeScript definition
10
+ const create = new Command("create")
11
+ .description("Create a new CRD TypeScript definition")
12
+ .requiredOption("--group <group>", "API group (e.g. cache)")
13
+ .requiredOption("--version <version>", "API version (e.g. v1alpha1)")
14
+ .requiredOption("--kind <kind>", "Kind name (e.g. Memcached)")
15
+ .option("--domain <domain>", "Optional domain (e.g. pepr.dev)", "pepr.dev")
16
+ .option(
17
+ "--scope <Namespaced | Cluster>",
18
+ "Whether the resulting custom resource is cluster- or namespace-scoped",
19
+ validateScope,
20
+ "Namespaced",
21
+ )
22
+ .option("--plural <plural>", "Plural name (e.g. memcacheds)", "")
23
+ .option("--shortName <shortName>", "Short name (e.g. mc)", "")
24
+ .action(async ({ group, version, kind, domain, scope, plural, shortName }) => {
25
+ console.log("This feature is currently in alpha.\n");
26
+ const outputDir = path.resolve(`./api/${version}`);
27
+ await createDirectoryIfNotExists(outputDir);
28
+
29
+ // create file in directory with kind
30
+ await fs.writeFile(
31
+ `./api/${version}/${kind.toLowerCase()}_types.ts`,
32
+ generateCRDScaffold(group, version, kind, { domain, scope, plural, shortName }),
33
+ );
34
+ console.log(`✔ Created ${kind} TypeScript definition in ${outputDir}`);
35
+ });
36
+
37
+ export default create;
38
+
39
+ export const generateCRDScaffold = (
40
+ group: string,
41
+ version: string,
42
+ kind: string,
43
+ data: {
44
+ domain: string;
45
+ plural: string;
46
+ scope: string;
47
+ shortName: string;
48
+ },
49
+ ): string => {
50
+ return `// Auto-generated CRD TypeScript definition
51
+ // Kind: ${kind}
52
+ // Group: ${group}
53
+ // Version: ${version}
54
+ // Domain: ${data.domain}
55
+
56
+ export interface ${kind}Spec {
57
+ // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster
58
+ // Important: Run "npx pepr crd generate" to regenerate code after modifying this file
59
+
60
+ /**
61
+ * Size defines the number of Memcache instances
62
+ */
63
+ Size?: number[];
64
+
65
+ /**
66
+ * Port defines the port that will be used to init the container with the image
67
+ */
68
+ ContainerPort: number;
69
+
70
+ /**
71
+ * Application specific configuration
72
+ */
73
+ Config?: {
74
+ language: string[];
75
+ timezone: number;
76
+ zone: {
77
+ state: string;
78
+ areaCode: string[];
79
+ };
80
+ };
81
+ }
82
+
83
+ export interface ${kind}Status {
84
+ conditions: ${kind}StatusCondition[];
85
+ }
86
+
87
+ export const details = {
88
+ plural: "${data.plural}",
89
+ scope: "${data.scope}",
90
+ shortName: "${data.shortName}",
91
+ };
92
+
93
+ type ${kind}StatusCondition = {
94
+ /**
95
+ * lastTransitionTime is the last time the condition transitioned from one status to another. This is not guaranteed to be set in happensBefore order across different conditions for a given object. It may be unset in some circumstances.
96
+ */
97
+ lastTransitionTime: Date;
98
+ /**
99
+ * message is a human readable message indicating details about the transition. This may be an empty string.
100
+ */
101
+ message: string;
102
+ /**
103
+ * observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance.
104
+ */
105
+ observedGeneration?: number;
106
+ /**
107
+ * reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty.
108
+ */
109
+ reason: string;
110
+ /**
111
+ * status of the condition, one of True, False, Unknown.
112
+ */
113
+ status: string;
114
+ /**
115
+ * VM location.
116
+ */
117
+ vm: {
118
+ name: string;
119
+ region: string;
120
+ status: string;
121
+ message: string;
122
+ }
123
+ };
124
+
125
+
126
+ `;
127
+ };
128
+
129
+ export function validateScope(value: string): "Cluster" | "Namespaced" {
130
+ if (value !== "Cluster" && value !== "Namespaced") {
131
+ throw new Error("Scope must be either 'Cluster' or 'Namespaced'");
132
+ }
133
+ return value;
134
+ }
@@ -0,0 +1,317 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // SPDX-FileCopyrightText: 2023-Present The Pepr Authors
3
+
4
+ import { Command } from "commander";
5
+ import fs from "fs";
6
+ import path from "path";
7
+ import { stringify as toYAML } from "yaml";
8
+ import {
9
+ Project,
10
+ InterfaceDeclaration,
11
+ TypeAliasDeclaration,
12
+ SyntaxKind,
13
+ Node,
14
+ SourceFile,
15
+ Type,
16
+ } from "ts-morph";
17
+ import { createDirectoryIfNotExists } from "../../lib/filesystemService";
18
+ import { kind as k } from "kubernetes-fluent-client";
19
+ import { V1JSONSchemaProps } from "@kubernetes/client-node";
20
+
21
+ export default new Command("generate")
22
+ .description("Generate CRD manifests from TypeScript definitions")
23
+ .option("--output <output>", "Output directory for generated CRDs", "./crds")
24
+ .action(generateCRDs);
25
+
26
+ export function extractCRDDetails(
27
+ content: string,
28
+ sourceFile: SourceFile,
29
+ ): {
30
+ kind: string | undefined;
31
+ fqdn: string;
32
+ scope: "Cluster" | "Namespaced";
33
+ plural: string;
34
+ shortNames?: string[];
35
+ } {
36
+ const kind = extractSingleLineComment(content, "Kind");
37
+ const group = extractSingleLineComment(content, "Group") ?? "example";
38
+ const domain = extractSingleLineComment(content, "Domain") ?? "pepr.dev";
39
+ const details = extractDetails(sourceFile);
40
+
41
+ const fqdn = `${group}.${domain}`;
42
+
43
+ const { plural, scope } = details;
44
+
45
+ const shortNames = details.shortName ? [details.shortName] : undefined;
46
+ return { kind, plural, scope, shortNames, fqdn };
47
+ }
48
+
49
+ export async function generateCRDs(options: { output: string }): Promise<void> {
50
+ console.log("This feature is currently in alpha.\n");
51
+ const outputDir = path.resolve(options.output);
52
+ await createDirectoryIfNotExists(outputDir);
53
+
54
+ const project = new Project();
55
+ const apiRoot = path.resolve("api");
56
+ const versions = getAPIVersions(apiRoot);
57
+
58
+ for (const version of versions) {
59
+ const sourceFiles = loadVersionFiles(project, path.join(apiRoot, version));
60
+ for (const sourceFile of sourceFiles) {
61
+ processSourceFile(sourceFile, version, outputDir);
62
+ }
63
+ }
64
+ }
65
+
66
+ export function getAPIVersions(apiRoot: string): string[] {
67
+ return fs.readdirSync(apiRoot).filter(v => fs.statSync(path.join(apiRoot, v)).isDirectory());
68
+ }
69
+
70
+ export function loadVersionFiles(project: Project, versionDir: string): SourceFile[] {
71
+ const files = fs.readdirSync(versionDir).filter(f => f.endsWith(".ts"));
72
+ const filePaths = files.map(f => path.join(versionDir, f));
73
+ return project.addSourceFilesAtPaths(filePaths);
74
+ }
75
+
76
+ export function processSourceFile(
77
+ sourceFile: SourceFile,
78
+ version: string,
79
+ outputDir: string,
80
+ ): void {
81
+ const content = sourceFile.getFullText();
82
+ const { kind, fqdn, scope, plural, shortNames } = extractCRDDetails(content, sourceFile);
83
+
84
+ if (!kind) {
85
+ console.warn(`Skipping ${sourceFile.getBaseName()}: missing '// Kind: <KindName>' comment`);
86
+ return;
87
+ }
88
+
89
+ const spec = sourceFile.getInterface(`${kind}Spec`);
90
+ if (!spec) {
91
+ console.warn(`Skipping ${sourceFile.getBaseName()}: missing interface ${kind}Spec`);
92
+ return;
93
+ }
94
+
95
+ const condition = sourceFile.getTypeAlias(`${kind}StatusCondition`);
96
+ const specSchema = getSchemaFromType(spec);
97
+ const conditionSchema = condition ? getSchemaFromType(condition) : emptySchema();
98
+
99
+ const crd = buildCRD({
100
+ kind,
101
+ fqdn,
102
+ version,
103
+ plural,
104
+ scope,
105
+ shortNames,
106
+ specSchema,
107
+ conditionSchema,
108
+ });
109
+
110
+ const outPath = path.join(outputDir, `${kind.toLowerCase()}.yaml`);
111
+ fs.writeFileSync(outPath, toYAML(crd), "utf8");
112
+ console.log(`✔ Created ${outPath}`);
113
+ }
114
+
115
+ // Extracts a comment from the content of a file based on a label.
116
+ export function extractSingleLineComment(content: string, label: string): string | undefined {
117
+ // https://regex101.com/r/oLFaHP/1
118
+ const match = content.match(new RegExp(`//\\s+${label}:\\s+(.*)`));
119
+ return match?.[1].trim();
120
+ }
121
+
122
+ export function extractDetails(sourceFile: SourceFile): {
123
+ plural: string;
124
+ scope: "Cluster" | "Namespaced";
125
+ shortName: string;
126
+ } {
127
+ const decl = sourceFile.getVariableDeclaration("details");
128
+ if (!decl) {
129
+ throw new Error(`Missing 'details' variable declaration.`);
130
+ }
131
+
132
+ const init = decl.getInitializerIfKindOrThrow(SyntaxKind.ObjectLiteralExpression);
133
+
134
+ const getStr = (key: string): string => {
135
+ const prop = init.getProperty(key);
136
+ const value = prop?.getFirstChildByKind(SyntaxKind.StringLiteral)?.getLiteralText();
137
+ if (!value) {
138
+ throw new Error(`Missing or invalid value for required key: '${key}'`);
139
+ }
140
+ return value;
141
+ };
142
+
143
+ const scope = getStr("scope");
144
+ if (scope === "Cluster" || scope === "Namespaced") {
145
+ return {
146
+ plural: getStr("plural"),
147
+ scope,
148
+ shortName: getStr("shortName"),
149
+ };
150
+ }
151
+
152
+ throw new Error(`'scope' must be either "Cluster" or "Namespaced", got "${scope}"`);
153
+ }
154
+
155
+ export function getJsDocDescription(node: Node): string {
156
+ if (!Node.isPropertySignature(node) && !Node.isPropertyDeclaration(node)) return "";
157
+ return node
158
+ .getJsDocs()
159
+ .map(doc => doc.getComment())
160
+ .filter(Boolean)
161
+ .join(" ")
162
+ .trim();
163
+ }
164
+
165
+ export function getSchemaFromType(decl: InterfaceDeclaration | TypeAliasDeclaration): {
166
+ properties: Record<string, V1JSONSchemaProps>;
167
+ required: string[];
168
+ } {
169
+ const type = decl.getType();
170
+ const properties: Record<string, V1JSONSchemaProps> = {};
171
+ const required: string[] = [];
172
+
173
+ for (const prop of type.getProperties()) {
174
+ const name = uncapitalize(prop.getName());
175
+ const declarations = prop.getDeclarations();
176
+ if (!declarations.length) continue;
177
+
178
+ const declaration = declarations[0];
179
+ const description = getJsDocDescription(declaration);
180
+ const valueType = declaration.getType();
181
+
182
+ properties[name] = {
183
+ ...mapTypeToSchema(valueType),
184
+ ...(description ? { description } : {}),
185
+ };
186
+
187
+ if (!prop.isOptional()) required.push(name);
188
+ }
189
+
190
+ return { properties, required };
191
+ }
192
+
193
+ export function mapTypeToSchema(type: Type): V1JSONSchemaProps {
194
+ if (type.getText() === "Date") return { type: "string", format: "date-time" };
195
+ if (type.isString()) return { type: "string" };
196
+ if (type.isNumber()) return { type: "number" };
197
+ if (type.isBoolean()) return { type: "boolean" };
198
+ if (type.isArray()) {
199
+ return {
200
+ type: "array",
201
+ items: mapTypeToSchema(type.getArrayElementTypeOrThrow()),
202
+ };
203
+ }
204
+
205
+ if (type.isObject()) return buildObjectSchema(type);
206
+ return { type: "string" };
207
+ }
208
+
209
+ export function buildObjectSchema(type: Type): V1JSONSchemaProps {
210
+ const props: Record<string, V1JSONSchemaProps> = {};
211
+ const required: string[] = [];
212
+
213
+ for (const prop of type.getProperties()) {
214
+ const name = uncapitalize(prop.getName());
215
+ const declarations = prop.getDeclarations();
216
+ if (!declarations.length) continue;
217
+
218
+ const decl = declarations[0];
219
+ const description = getJsDocDescription(decl);
220
+ const subType = decl.getType();
221
+
222
+ props[name] = {
223
+ ...mapTypeToSchema(subType),
224
+ ...(description ? { description } : {}),
225
+ };
226
+
227
+ if (!prop.isOptional()) required.push(name);
228
+ }
229
+
230
+ return {
231
+ type: "object",
232
+ properties: props,
233
+ ...(required.length > 0 ? { required } : {}),
234
+ };
235
+ }
236
+
237
+ export function uncapitalize(str: string): string {
238
+ return str.charAt(0).toLowerCase() + str.slice(1);
239
+ }
240
+
241
+ export function emptySchema(): {
242
+ properties: Record<string, V1JSONSchemaProps>;
243
+ required: string[];
244
+ } {
245
+ return { properties: {}, required: [] };
246
+ }
247
+
248
+ interface CRDConfig {
249
+ kind: string;
250
+ fqdn: string;
251
+ version: string;
252
+ plural: string;
253
+ scope: "Cluster" | "Namespaced";
254
+ shortNames?: string[];
255
+ specSchema: ReturnType<typeof getSchemaFromType>;
256
+ conditionSchema: ReturnType<typeof getSchemaFromType>;
257
+ }
258
+
259
+ export function buildCRD(config: CRDConfig): k.CustomResourceDefinition {
260
+ return {
261
+ apiVersion: "apiextensions.k8s.io/v1",
262
+ kind: "CustomResourceDefinition",
263
+ metadata: {
264
+ name: `${config.plural}.${config.fqdn}`,
265
+ },
266
+ spec: {
267
+ group: config.fqdn,
268
+ names: {
269
+ kind: config.kind,
270
+ plural: config.plural,
271
+ singular: config.kind.toLowerCase(),
272
+ ...(config.shortNames ? { shortNames: config.shortNames } : {}),
273
+ },
274
+ scope: config.scope,
275
+ versions: [
276
+ {
277
+ name: config.version,
278
+ served: true,
279
+ storage: true,
280
+ schema: {
281
+ openAPIV3Schema: {
282
+ type: "object",
283
+ properties: {
284
+ spec: {
285
+ type: "object",
286
+ description: `${config.kind}Spec defines the desired state of ${config.kind}`,
287
+ properties: config.specSchema.properties,
288
+ required: config.specSchema.required,
289
+ },
290
+ status: {
291
+ type: "object",
292
+ description: `${config.kind}Status defines the observed state of ${config.kind}`,
293
+ properties: {
294
+ conditions: {
295
+ type: "array",
296
+ description: "Conditions describing the current state",
297
+ items: {
298
+ type: "object",
299
+ description:
300
+ "Condition contains details for one aspect of the current state of this API Resource.",
301
+ properties: config.conditionSchema.properties,
302
+ required: config.conditionSchema.required,
303
+ },
304
+ },
305
+ },
306
+ },
307
+ },
308
+ },
309
+ },
310
+ subresources: {
311
+ status: {},
312
+ },
313
+ },
314
+ ],
315
+ },
316
+ };
317
+ }
@@ -0,0 +1,15 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // SPDX-FileCopyrightText: 2023-Present The Pepr Authors
3
+
4
+ import { RootCmd } from "../root";
5
+ import createCmd from "./create";
6
+ import generateCmd from "./generate";
7
+
8
+ export default function (program: RootCmd): void {
9
+ const crd = program
10
+ .command("crd")
11
+ .description("Scaffold and generate Kubernetes CRDs from structured TypeScript definitions");
12
+
13
+ crd.addCommand(createCmd);
14
+ crd.addCommand(generateCmd);
15
+ }
package/src/cli.ts CHANGED
@@ -15,6 +15,7 @@ import { version } from "./cli/init/templates";
15
15
  import { RootCmd } from "./cli/root";
16
16
  import update from "./cli/update";
17
17
  import kfc from "./cli/kfc";
18
+ import crd from "./cli/crd";
18
19
 
19
20
  if (process.env.npm_lifecycle_event !== "npx") {
20
21
  console.info("Pepr should be run via `npx pepr <command>` instead of `pepr <command>`.");
@@ -25,6 +26,7 @@ if (!process.env.PEPR_NODE_WARNINGS) {
25
26
  process.removeAllListeners("warning");
26
27
  }
27
28
  program
29
+ .enablePositionalOptions()
28
30
  .version(version)
29
31
  .description(`Pepr (v${version}) - Type safe K8s middleware for humans`)
30
32
  .action(() => {
@@ -47,4 +49,5 @@ format(program);
47
49
  monitor(program);
48
50
  uuid(program);
49
51
  kfc(program);
52
+ crd(program);
50
53
  program.parse();
@@ -0,0 +1,34 @@
1
+ import { ControllerHooks } from ".";
2
+ import { resolveIgnoreNamespaces } from "../assets/webhooks";
3
+ import { Capability } from "../core/capability";
4
+ import { isWatchMode, isDevMode } from "../core/envChecks";
5
+ import { setupWatch } from "../processors/watch-processor";
6
+ import { PeprModuleOptions } from "../types";
7
+
8
+ /**
9
+ * Creates controller hooks with proper handling of watch setup
10
+ *
11
+ * @param opts Module options including hooks
12
+ * @param capabilities List of capabilities
13
+ * @param ignoreNamespaces Namespaces to ignore
14
+ * @returns Controller hooks configuration
15
+ */
16
+ export function createControllerHooks(
17
+ opts: PeprModuleOptions,
18
+ capabilities: Capability[],
19
+ ignoreNamespaces: string[] = [],
20
+ ): ControllerHooks {
21
+ return {
22
+ beforeHook: opts.beforeHook,
23
+ afterHook: opts.afterHook,
24
+ onReady: async (): Promise<void> => {
25
+ if (isWatchMode() || isDevMode()) {
26
+ try {
27
+ setupWatch(capabilities, resolveIgnoreNamespaces(ignoreNamespaces));
28
+ } catch (error) {
29
+ throw new Error(`WatchError: Could not set up watch.`, { cause: error });
30
+ }
31
+ }
32
+ },
33
+ };
34
+ }
@@ -2,14 +2,12 @@
2
2
  // SPDX-FileCopyrightText: 2023-Present The Pepr Authors
3
3
  import { clone } from "ramda";
4
4
  import { Capability } from "./capability";
5
- import { Controller, ControllerHooks } from "../controller";
5
+ import { Controller } from "../controller";
6
6
  import { ValidateError } from "../errors";
7
7
  import { CapabilityExport } from "../types";
8
- import { setupWatch } from "../processors/watch-processor";
9
- import Log from "../../lib/telemetry/logger";
10
- import { resolveIgnoreNamespaces } from "../assets/webhooks";
11
- import { isBuildMode, isDevMode, isWatchMode } from "./envChecks";
8
+ import { isBuildMode } from "./envChecks";
12
9
  import { PackageJSON, PeprModuleOptions, ModuleConfig } from "../types";
10
+ import { createControllerHooks } from "../controller/createHooks";
13
11
 
14
12
  export class PeprModule {
15
13
  #controller!: Controller;
@@ -59,21 +57,11 @@ export class PeprModule {
59
57
  return;
60
58
  }
61
59
 
62
- const controllerHooks: ControllerHooks = {
63
- beforeHook: opts.beforeHook,
64
- afterHook: opts.afterHook,
65
- onReady: async (): Promise<void> => {
66
- // Wait for the controller to be ready before setting up watches
67
- if (isWatchMode() || isDevMode()) {
68
- try {
69
- setupWatch(capabilities, resolveIgnoreNamespaces(pepr?.alwaysIgnore?.namespaces));
70
- } catch (e) {
71
- Log.error(e, "Error setting up watch");
72
- process.exit(1);
73
- }
74
- }
75
- },
76
- };
60
+ const controllerHooks = createControllerHooks(
61
+ opts,
62
+ capabilities,
63
+ pepr?.alwaysIgnore?.namespaces,
64
+ );
77
65
 
78
66
  this.#controller = new Controller(config, capabilities, controllerHooks);
79
67