From e4808eaf4dc0223c1125766524422cc65986b53d Mon Sep 17 00:00:00 2001 From: Amber Date: Wed, 26 Aug 2026 17:34:57 -0600 Subject: [PATCH 1/4] chore: fix no-binding chart admission deployment --- src/lib/assets/assets.test.ts | 56 +++++++++++++++++++++++++++++++++-- src/lib/assets/assets.ts | 14 +++++++-- 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/src/lib/assets/assets.test.ts b/src/lib/assets/assets.test.ts index 4ae421932..80657022d 100644 --- a/src/lib/assets/assets.test.ts +++ b/src/lib/assets/assets.test.ts @@ -171,6 +171,10 @@ vi.mock("./index", () => ({ serviceAccountYaml: "/tmp/service-account.yaml", moduleSecretYaml: "/tmp/module-secret.yaml", valuesYaml: "/tmp/values.yaml", + admissionDeploymentYaml: "/tmp/admission-deployment.yaml", + admissionServiceMonitorYaml: "/tmp/admission-service-monitor.yaml", + mutationWebhookYaml: "/tmp/mutation-webhook.yaml", + validationWebhookYaml: "/tmp/validation-webhook.yaml", watcherDeploymentYaml: "/tmp/watcher-deployment.yaml", watcherServiceMonitorYaml: "/tmp/watcher-service-monitor.yaml", }, @@ -336,7 +340,53 @@ describe("Assets", () => { expect(createDirectoryIfNotExists).toHaveBeenCalledTimes(2); }); - it("should call generateHelmChart which should write file 40 times for built Kubernetes Manifests and helm chart generation", async () => { + it("should write admission Deployment for charts when capabilities have no admission or watcher bindings", async () => { + const webhookGeneratorFunction = vi + .fn< + ( + assets: Assets, + mutateOrValidate: WebhookType, + timeoutSeconds: number | undefined, + ) => Promise + >() + .mockResolvedValue(null); + const getWatcherFunction = vi.fn<() => kind.Deployment | null>().mockReturnValue(null); + const getModuleSecretFunction = createMockModuleSecret(); + assets.capabilities = [ + { + name: "capability-1", + description: "test", + namespaces: ["default"], + bindings: [] as unknown as Binding[], + hasSchedule: false, + }, + ]; + + (fs.writeFile as Mock).mockClear(); + + await assets.generateHelmChart( + webhookGeneratorFunction, + getWatcherFunction, + getModuleSecretFunction, + "/tmp", + ); + + expect(fs.writeFile).toHaveBeenCalledWith( + "/tmp/admission-deployment.yaml", + expect.stringContaining("kind: Deployment"), + ); + expect(fs.writeFile).toHaveBeenCalledWith( + "/tmp/admission-service-monitor.yaml", + expect.stringContaining("kind: ServiceMonitor"), + ); + expect(fs.writeFile).not.toHaveBeenCalledWith("/tmp/mutation-webhook.yaml", expect.any(String)); + expect(fs.writeFile).not.toHaveBeenCalledWith( + "/tmp/validation-webhook.yaml", + expect.any(String), + ); + }); + + it("should call generateHelmChart which should write expected chart files", async () => { const webhookGeneratorFunction = createMockWebhookGenerator(); const getWatcherFunction = createMockWatcher(); const getModuleSecretFunction = createMockModuleSecret(); @@ -349,13 +399,15 @@ describe("Assets", () => { hasSchedule: false, }, ]; + (fs.writeFile as Mock).mockClear(); + await assets.generateHelmChart( webhookGeneratorFunction, getWatcherFunction, getModuleSecretFunction, "/tmp", ); - expect(fs.writeFile).toHaveBeenCalledTimes(40); + expect(fs.writeFile).toHaveBeenCalledTimes(16); }); it("should call generateHelmChart and get no error", async () => { diff --git a/src/lib/assets/assets.ts b/src/lib/assets/assets.ts index 92d357480..fe928f853 100644 --- a/src/lib/assets/assets.ts +++ b/src/lib/assets/assets.ts @@ -181,8 +181,9 @@ export class Assets { validateWebhook: V1MutatingWebhookConfiguration | V1ValidatingWebhookConfiguration | null, mutateWebhook: V1MutatingWebhookConfiguration | V1ValidatingWebhookConfiguration | null, helm: Record>, + deployAdmissionController: boolean = Boolean(validateWebhook || mutateWebhook), ): Promise => { - if (validateWebhook || mutateWebhook) { + if (deployAdmissionController) { await fs.writeFile( helm.files.admissionDeploymentYaml, dedent(admissionDeployTemplate(this.buildTimestamp, "admission")), @@ -281,8 +282,10 @@ export class Assets { apiPath: this.apiPath, capabilities: this.capabilities, }; + const deployAdmissionController = + isAdmission(this.capabilities) || norWatchOrAdmission(this.capabilities); await overridesFile(overrideData, helm.files.valuesYaml, this.imagePullSecrets, { - admission: isAdmission(this.capabilities) || norWatchOrAdmission(this.capabilities), + admission: deployAdmissionController, watcher: isWatcher(this.capabilities), }); @@ -299,7 +302,12 @@ export class Assets { ), }; - await this.writeWebhookFiles(webhooks.validate, webhooks.mutate, helm); + await this.writeWebhookFiles( + webhooks.validate, + webhooks.mutate, + helm, + deployAdmissionController, + ); const watchDeployment = getWatcherFunction(this, moduleHash, this.buildTimestamp); if (watchDeployment) { From d7baa04205e84540af70fd34ce9318d864482acf Mon Sep 17 00:00:00 2001 From: Amber Date: Fri, 4 Sep 2026 11:53:38 -0600 Subject: [PATCH 2/4] chore: increased upgrade test timeout --- integration/cluster/upgrade.test.ts | 30 ++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/integration/cluster/upgrade.test.ts b/integration/cluster/upgrade.test.ts index e958c9db1..2e1f31be7 100644 --- a/integration/cluster/upgrade.test.ts +++ b/integration/cluster/upgrade.test.ts @@ -36,7 +36,7 @@ describe("build", () => { cwd: workdir.path(), stdio: "inherit", }); - }, ms("2m")); + }, ms("5m")); it( "should prepare, build, and deploy hello-pepr with pepr@latest", @@ -50,24 +50,24 @@ describe("build", () => { }, "installation"); }, ); - }); - it( - "should display the UUIDs of the deployed modules with a specific UUID", - { timeout: 1000 * 5 * 60 }, - async () => { - const uuidOut = spawnSync(`npx pepr@latest uuid ${id}`, { - shell: true, // Run command in a shell - encoding: "utf-8", // Encode result as string - }); + it( + "should display the UUIDs of the deployed modules with a specific UUID", + { timeout: 1000 * 5 * 60 }, + async () => { + const uuidOut = spawnSync(`npx pepr@latest uuid ${id}`, { + shell: true, // Run command in a shell + encoding: "utf-8", // Encode result as string + }); - const { stdout } = uuidOut; + const { stdout } = uuidOut; - const matches = stdout.match(/upgrade-test/g) || []; + const matches = stdout.match(/upgrade-test/g) || []; - expect(matches.length).toBe(2); - }, - ); + expect(matches.length).toBe(2); + }, + ); + }); it( "should prepare, build and deploy with pepr@pr-candidate", From 225d40d532698c76547acbe21a1ad0060698e38a Mon Sep 17 00:00:00 2001 From: Amber Date: Tue, 8 Sep 2026 12:04:42 -0600 Subject: [PATCH 3/4] chore: updated docs and removed boolean --- README.md | 7 +++-- docs/reference/best-practices.md | 2 +- docs/user-guide/capabilities.md | 2 +- src/lib/assets/assets.test.ts | 4 +-- src/lib/assets/assets.ts | 54 +++++++++++++++----------------- 5 files changed, 34 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index df1035e3c..644366c30 100644 --- a/README.md +++ b/README.md @@ -138,8 +138,9 @@ kubectl apply -f capabilities/hello-pepr.samples.yaml A module is the top-level collection of capabilities. It is a single, complete TypeScript project that includes an entry point to load all the configuration and capabilities, along with their actions. -During the Pepr build process, each module produces a unique Kubernetes MutatingWebhookConfiguration and ValidatingWebhookConfiguration, along with a secret containing the transpiled and compressed TypeScript code. -The webhooks and secret are deployed into the Kubernetes cluster with their own isolated controller. +During the Pepr build process, each module produces a secret containing the transpiled and compressed TypeScript code. +Modules with `Mutate()` or `Validate()` actions also produce the corresponding Kubernetes MutatingWebhookConfiguration or ValidatingWebhookConfiguration resources. +Modules without admission, watch, queue, finalize, or schedule actions still deploy their module code with an isolated admission controller, but do not produce webhook configuration resources unless admission actions are defined. See [Module](docs/user-guide/pepr-modules.md) for more details. @@ -147,7 +148,7 @@ See [Module](docs/user-guide/pepr-modules.md) for more details. A capability is set of related actions that work together to achieve a specific transformation or operation on Kubernetes resources. Capabilities are user-defined and can include one or more actions. -They are defined within a Pepr module and can be used in both MutatingWebhookConfigurations and ValidatingWebhookConfigurations. +They are defined within a Pepr module and can use MutatingWebhookConfigurations, ValidatingWebhookConfigurations, watchers, queues, or schedules depending on the actions they contain. A Capability can have a specific scope, such as mutating or validating, and can be reused in multiple Pepr modules. See [Capabilities](docs/user-guide/capabilities.md) for more details. diff --git a/docs/reference/best-practices.md b/docs/reference/best-practices.md index 7c6bd11ff..fc360fdb1 100644 --- a/docs/reference/best-practices.md +++ b/docs/reference/best-practices.md @@ -254,7 +254,7 @@ Pepr can monitor Mutations and Validations from Admission Controller the through ## Multiple Modules or Multiple Capabilities -Each module has it's own Mutating, Validating webhook configurations, Admission and Watch Controllers and Stores. This allows for each module to be deployed independently of each other. However, creating multiple modules creates overhead on the kube-apiserver, and the cluster. +Each module can have its own MutatingWebhookConfiguration, ValidatingWebhookConfiguration, Admission Controller, Watch Controller, and Store depending on the actions it contains. This allows for each module to be deployed independently of each other. However, creating multiple modules creates overhead on the kube-apiserver, and the cluster. Due to the overhead costs, it is recommended to deploy multiple capabilities that share the same resources (when possible). This will simplify analysis of which capabilities are responsible for changes on resources. diff --git a/docs/user-guide/capabilities.md b/docs/user-guide/capabilities.md index 6da2bfca3..40bd709bb 100644 --- a/docs/user-guide/capabilities.md +++ b/docs/user-guide/capabilities.md @@ -1,6 +1,6 @@ # Pepr Capabilities -A capability is set of related [actions](../actions/README.md) that work together to achieve a specific transformation or operation on Kubernetes resources. Capabilities are user-defined and can include one or more actions. They are defined within a Pepr module and can be used in both MutatingWebhookConfigurations and ValidatingWebhookConfigurations. A Capability can have a specific scope, such as mutating or validating, and can be reused in multiple Pepr modules. +A capability is set of related [actions](../actions/README.md) that work together to achieve a specific transformation or operation on Kubernetes resources. Capabilities are user-defined and can include one or more actions. They are defined within a Pepr module and can use MutatingWebhookConfigurations, ValidatingWebhookConfigurations, watchers, queues, or schedules depending on the actions they contain. A Capability can have a specific scope, such as mutating or validating, and can be reused in multiple Pepr modules. When you [`npx pepr init`](./pepr-cli.md#pepr-init), a `capabilities` directory is created for you. This directory is where you will define your capabilities. You can create as many capabilities as you need, and each capability can contain one or more actions. Pepr also automatically creates a `HelloPepr` capability with a number of example actions to help you get started. diff --git a/src/lib/assets/assets.test.ts b/src/lib/assets/assets.test.ts index 80657022d..763b004de 100644 --- a/src/lib/assets/assets.test.ts +++ b/src/lib/assets/assets.test.ts @@ -299,7 +299,7 @@ describe("Assets", () => { ); }); - it("should call writeWebhookFiles and write admissionController Deployment, ServiceMonitor, and WebhookConfigs", async () => { + it("should call writeWebhookFiles and write WebhookConfigs", async () => { const mockHelm = { files: { admissionDeploymentYaml: "/tmp/admission-deployment.yaml", @@ -313,7 +313,7 @@ describe("Assets", () => { const mutateWebhook: V1MutatingWebhookConfiguration = new kind.MutatingWebhookConfiguration(); await assets.writeWebhookFiles(validateWebhook, mutateWebhook, mockHelm); - expect(fs.writeFile).toHaveBeenCalledTimes(4); + expect(fs.writeFile).toHaveBeenCalledTimes(2); }); it("should call generateHelmChart which should call createDirectoryIfNotExists twice for templates and charts", async () => { diff --git a/src/lib/assets/assets.ts b/src/lib/assets/assets.ts index fe928f853..65defdbdb 100644 --- a/src/lib/assets/assets.ts +++ b/src/lib/assets/assets.ts @@ -181,26 +181,7 @@ export class Assets { validateWebhook: V1MutatingWebhookConfiguration | V1ValidatingWebhookConfiguration | null, mutateWebhook: V1MutatingWebhookConfiguration | V1ValidatingWebhookConfiguration | null, helm: Record>, - deployAdmissionController: boolean = Boolean(validateWebhook || mutateWebhook), ): Promise => { - if (deployAdmissionController) { - await fs.writeFile( - helm.files.admissionDeploymentYaml, - dedent(admissionDeployTemplate(this.buildTimestamp, "admission")), - ); - await fs.writeFile( - helm.files.admissionServiceMonitorYaml, - dedent( - serviceMonitorTemplate( - process.env.PEPR_CUSTOM_BUILD_NAME - ? `admission-${process.env.PEPR_CUSTOM_BUILD_NAME}` - : "admission", - `admission`, - ), - ), - ); - } - if (mutateWebhook) { await fs.writeFile( helm.files.mutationWebhookYaml, @@ -216,6 +197,26 @@ export class Assets { } }; + writeAdmissionControllerFiles = async ( + helm: Record>, + ): Promise => { + await fs.writeFile( + helm.files.admissionDeploymentYaml, + dedent(admissionDeployTemplate(this.buildTimestamp, "admission")), + ); + await fs.writeFile( + helm.files.admissionServiceMonitorYaml, + dedent( + serviceMonitorTemplate( + process.env.PEPR_CUSTOM_BUILD_NAME + ? `admission-${process.env.PEPR_CUSTOM_BUILD_NAME}` + : "admission", + `admission`, + ), + ), + ); + }; + generateHelmChart = async ( webhookGeneratorFunction: ( assets: Assets, @@ -282,10 +283,8 @@ export class Assets { apiPath: this.apiPath, capabilities: this.capabilities, }; - const deployAdmissionController = - isAdmission(this.capabilities) || norWatchOrAdmission(this.capabilities); await overridesFile(overrideData, helm.files.valuesYaml, this.imagePullSecrets, { - admission: deployAdmissionController, + admission: isAdmission(this.capabilities) || norWatchOrAdmission(this.capabilities), watcher: isWatcher(this.capabilities), }); @@ -302,12 +301,11 @@ export class Assets { ), }; - await this.writeWebhookFiles( - webhooks.validate, - webhooks.mutate, - helm, - deployAdmissionController, - ); + if (isAdmission(this.capabilities) || norWatchOrAdmission(this.capabilities)) { + await this.writeAdmissionControllerFiles(helm); + } + + await this.writeWebhookFiles(webhooks.validate, webhooks.mutate, helm); const watchDeployment = getWatcherFunction(this, moduleHash, this.buildTimestamp); if (watchDeployment) { From 10a8319c296f7fc6c7c7b90e271aebb3a4453326 Mon Sep 17 00:00:00 2001 From: Amber Date: Tue, 8 Sep 2026 12:59:02 -0600 Subject: [PATCH 4/4] chore: correct test --- src/lib/assets/assets.test.ts | 49 +++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/lib/assets/assets.test.ts b/src/lib/assets/assets.test.ts index 763b004de..3f3f257fa 100644 --- a/src/lib/assets/assets.test.ts +++ b/src/lib/assets/assets.test.ts @@ -311,9 +311,21 @@ describe("Assets", () => { const validateWebhook: V1ValidatingWebhookConfiguration = new kind.ValidatingWebhookConfiguration(); const mutateWebhook: V1MutatingWebhookConfiguration = new kind.MutatingWebhookConfiguration(); + (fs.writeFile as Mock).mockClear(); + await assets.writeWebhookFiles(validateWebhook, mutateWebhook, mockHelm); expect(fs.writeFile).toHaveBeenCalledTimes(2); + expect(fs.writeFile).toHaveBeenCalledWith("/tmp/mutation-webhook.yaml", expect.any(String)); + expect(fs.writeFile).toHaveBeenCalledWith("/tmp/validation-webhook.yaml", expect.any(String)); + expect(fs.writeFile).not.toHaveBeenCalledWith( + "/tmp/admission-deployment.yaml", + expect.any(String), + ); + expect(fs.writeFile).not.toHaveBeenCalledWith( + "/tmp/admission-service-monitor.yaml", + expect.any(String), + ); }); it("should call generateHelmChart which should call createDirectoryIfNotExists twice for templates and charts", async () => { @@ -340,6 +352,43 @@ describe("Assets", () => { expect(createDirectoryIfNotExists).toHaveBeenCalledTimes(2); }); + it("should write admission controller files and WebhookConfigs for admission chart capabilities", async () => { + const webhookGeneratorFunction = createMockWebhookGenerator(); + const getWatcherFunction = vi.fn<() => kind.Deployment | null>().mockReturnValue(null); + const getModuleSecretFunction = createMockModuleSecret(); + assets.capabilities = [ + { + name: "capability-1", + description: "test", + namespaces: ["default"], + bindings: [{ isMutate: true }] as unknown as Binding[], + hasSchedule: false, + }, + ]; + (fs.writeFile as Mock).mockClear(); + + await assets.generateHelmChart( + webhookGeneratorFunction, + getWatcherFunction, + getModuleSecretFunction, + "/tmp", + ); + + const admissionAndWebhookFiles = [ + "/tmp/admission-deployment.yaml", + "/tmp/admission-service-monitor.yaml", + "/tmp/mutation-webhook.yaml", + "/tmp/validation-webhook.yaml", + ]; + const admissionAndWebhookWrites = (fs.writeFile as Mock).mock.calls.filter(([file]) => + admissionAndWebhookFiles.includes(file), + ); + + expect(admissionAndWebhookWrites.map(([file]) => file).sort()).toEqual( + admissionAndWebhookFiles.sort(), + ); + }); + it("should write admission Deployment for charts when capabilities have no admission or watcher bindings", async () => { const webhookGeneratorFunction = vi .fn<