From a0d6cf803c86be4968acb2c5ba044ce54d685286 Mon Sep 17 00:00:00 2001 From: Jon Andrew Date: Mon, 21 Oct 2024 11:01:47 -0600 Subject: [PATCH 1/4] Fresh commits --- src/pepr/operator/index.ts | 18 ++- src/pepr/operator/secrets.spec.ts | 181 +++++++++++++++++++++++++ src/pepr/operator/secrets.ts | 215 ++++++++++++++++++++++++++++++ tasks.yaml | 10 +- 4 files changed, 420 insertions(+), 4 deletions(-) create mode 100644 src/pepr/operator/secrets.spec.ts create mode 100644 src/pepr/operator/secrets.ts diff --git a/src/pepr/operator/index.ts b/src/pepr/operator/index.ts index 23c7510ee..a65277ed1 100644 --- a/src/pepr/operator/index.ts +++ b/src/pepr/operator/index.ts @@ -27,6 +27,9 @@ import { purgeAuthserviceClients } from "./controllers/keycloak/authservice/auth import { exemptValidator } from "./crd/validators/exempt-validator"; import { packageReconciler } from "./reconcilers/package-reconciler"; +// Secret imports +import { copySecret, labelCopySecret, validateSecret } from "./secrets"; + // Export the operator capability for registration in the root pepr.ts export { operator } from "./common"; @@ -80,7 +83,9 @@ When(UDSPackage) .WithName("keycloak") .Watch(() => { // todo: wait for keycloak and authservice to be running? - log.info("Identity and Authorization layer deployed, operator configured to handle SSO."); + log.info( + "Identity and Authorization layer deployed, operator configured to handle SSO.", + ); UDSConfig.isIdentityDeployed = true; }); When(UDSPackage) @@ -88,6 +93,15 @@ When(UDSPackage) .InNamespace("keycloak") .WithName("keycloak") .Watch(() => { - log.info("Identity and Authorization layer removed, operator will NOT handle SSO."); + log.info( + "Identity and Authorization layer removed, operator will NOT handle SSO.", + ); UDSConfig.isIdentityDeployed = false; }); + +// Watch for secrets w/ the UDS secret label and copy as necessary +When(a.Secret) + .IsCreatedOrUpdated() + .WithLabel(labelCopySecret, "true") + .Mutate(request => copySecret(request)) + .Validate(request => validateSecret(request)); diff --git a/src/pepr/operator/secrets.spec.ts b/src/pepr/operator/secrets.spec.ts new file mode 100644 index 000000000..9843c99f2 --- /dev/null +++ b/src/pepr/operator/secrets.spec.ts @@ -0,0 +1,181 @@ +/** + * Copyright 2024 Defense Unicorns + * SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial + */ + +import { afterAll, beforeAll, describe, expect, it } from "@jest/globals"; +import { K8s, kind } from "pepr"; + +const failIfReached = () => expect(true).toBe(false); + +describe("test secret copy", () => { + const sourceSecret = { + metadata: { + name: "source-secret", + namespace: "source-namespace", + }, + data: { key: "TESTCASE" }, + }; + + beforeAll(async () => { + // Setup test namespaces + await K8s(kind.Namespace).Apply({ + metadata: { name: "source-namespace" }, + }); + await K8s(kind.Namespace).Apply({ + metadata: { name: "destination-namespace" }, + }); + await K8s(kind.Namespace).Apply({ + metadata: { name: "destination-namespace2" }, + }); + await K8s(kind.Namespace).Apply({ + metadata: { name: "destination-namespace3" }, + }); + + // Create source secret + await K8s(kind.Secret).Apply(sourceSecret); + }); + + afterAll(async () => { + // Cleanup test namespaces + await K8s(kind.Namespace).Delete("source-namespace"); + await K8s(kind.Namespace).Delete("destination-namespace"); + await K8s(kind.Namespace).Delete("destination-namespace2"); + // await K8s(kind.Namespace).Delete("destination-namespace3"); + }); + + it("should copy a secret with the secrets.uds.dev/copy label", async () => { + // Apply destination secret + const destinationSecret = { + metadata: { + name: "destination-secret", + namespace: "destination-namespace", + labels: { "secrets.uds.dev/copy": "true" }, + annotations: { + "secrets.uds.dev/fromNamespace": "source-namespace", + "secrets.uds.dev/fromName": "source-secret", + }, + }, + }; + + // Check if destination secret has the same data as the source secret + const destSecret = await K8s(kind.Secret).Apply(destinationSecret); + expect(destSecret.data).toEqual({ key: "VEVTVENBU0U=" }); // base64 encoded "TESTCASE" + + // Confirm that label has changed from copy to copied + expect(destSecret.metadata?.labels).toEqual({ + "secrets.uds.dev/copied": "true", + }); + }); + + it("should not copy a secret without the secrets.uds.dev/copy=true label", async () => { + // Apply destination secret + const destinationSecret1 = { + metadata: { + name: "destination-secret-tc2a", + namespace: "destination-namespace2", + labels: { "secrets.uds.dev/copy": "false" }, + annotations: { + "secrets.uds.dev/fromNamespace": "source-namespace", + "secrets.uds.dev/fromName": "source-secret", + }, + }, + }; + + const destinationSecret2 = { + metadata: { + name: "destination-secret-tc2b", + namespace: "destination-namespace2", + labels: { asdf: "true" }, + annotations: { + "secrets.uds.dev/fromNamespace": "source-namespace", + "secrets.uds.dev/fromName": "source-secret", + }, + }, + }; + + const destSecret1 = await K8s(kind.Secret).Apply(destinationSecret1); + const destSecret2 = await K8s(kind.Secret).Apply(destinationSecret2); + + // Confirm destination secrets are created "as is" + expect(destSecret1.data).toEqual(undefined); + expect(destSecret1.metadata?.labels).toEqual({ + "secrets.uds.dev/copy": "false", + }); + + expect(destSecret2.data).toEqual(undefined); + expect(destSecret2.metadata?.labels).toEqual({ asdf: "true" }); + }); + + it("should error when copy label is present but missing annotations", async () => { + const destinationSecret = { + metadata: { + name: "destination-secret-tc3", + namespace: "destination-namespace3", + labels: { "secrets.uds.dev/copy": "true" }, + }, + }; + + const expected = (e: Error) => { + expect(e).toMatchObject({ + ok: false, + data: { + message: expect.stringContaining("denied the request"), + }, + }); + }; + + return K8s(kind.Secret) + .Apply(destinationSecret) + .then(failIfReached) + .catch(expected); + }); + + it("should error when missing source secret and onMissingSource=Deny", async () => { + const destinationSecret = { + metadata: { + name: "destination-secret", + namespace: "destination-namespace", + labels: { "secrets.uds.dev/copy": "true" }, + annotations: { + "secrets.uds.dev/fromNamespace": "missing-namespace", + "secrets.uds.dev/fromName": "missing-secret", + "secrets.uds.dev/onMissingSource": "Deny", + }, + }, + }; + + const expected = (e: Error) => { + expect(e).toMatchObject({ + ok: false, + data: { + message: expect.stringContaining("denied the request"), + }, + }); + }; + + return K8s(kind.Secret) + .Apply(destinationSecret) + .then(failIfReached) + .catch(expected); + }); + + it("should create empty secret when missing source secret and onMissingSource=LeaveEmpty", async () => { + const destinationSecret = { + metadata: { + name: "destination-secret-tc4a", + namespace: "destination-namespace", + labels: { "secrets.uds.dev/copy": "true" }, + annotations: { + "secrets.uds.dev/fromNamespace": "source-namespace", + "secrets.uds.dev/fromName": "missing-secret", + "secrets.uds.dev/onMissingSource": "LeaveEmpty", + }, + }, + }; + + const destSecret = await K8s(kind.Secret).Apply(destinationSecret); + + expect(destSecret.data).toEqual(undefined); + }); +}); diff --git a/src/pepr/operator/secrets.ts b/src/pepr/operator/secrets.ts new file mode 100644 index 000000000..7f2c90543 --- /dev/null +++ b/src/pepr/operator/secrets.ts @@ -0,0 +1,215 @@ +/** + * Copyright 2024 Defense Unicorns + * SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial + */ + +import { + a, + K8s, + kind, + Log, + PeprMutateRequest, + PeprValidateRequest, +} from "pepr"; +import { ValidateActionResponse } from "pepr/dist/lib/types"; + +export const labelCopySecret = "secrets.uds.dev/copy"; +const labelCopiedSecret = "secrets.uds.dev/copied"; + +const annotationFromNS = "secrets.uds.dev/fromNamespace"; +const annotationFromName = "secrets.uds.dev/fromName"; +const annotationOnFailure = "secrets.uds.dev/onMissingSource"; + +/** + * Enum for handling what to do when the source secret is missing + * + * This can be one of the following: + * - Deny: Fail to create the destination secret + * - LeaveEmpty: Create the destination secret with no data + */ +enum OnFailure { + DENY, + LEAVEEMPTY, +} + +/** + * Enum for reasons a secret copy may fail + */ +enum FailReason { + NO_ANNOTATIONS, + MISSING_ANNOTATIONS, + MISSING_METADATA, + SOURCE_NOT_FOUND, + OK, +} + +/** + * Check required annotations and metadata for a secret copy + */ +export function checkAnnotationsAndMetadata(data: a.Secret): FailReason { + const annotations = data.metadata?.annotations; + + if (!annotations) { + return FailReason.NO_ANNOTATIONS; + } + + if (!annotations[annotationFromNS] || !annotations[annotationFromName]) { + return FailReason.MISSING_ANNOTATIONS; + } + + if (!data.metadata?.namespace || !data.metadata?.name) { + return FailReason.MISSING_METADATA; + } + + return FailReason.OK; +} + +/** + * Copy a secret from one namespace to another + * + * @remarks + * This function is a PeprMutateRequest handler that copies a secret from one + * namespace to another. It looks for a set of annotations on this _destination_ + * secret, with the source namespace and name from which to copy the secret + * data. + * + * If the source secret does not exist, the behavior is determined by the + * `uds.dev/secrets/onMissingSource` annotation. If this annotation is not + * present, the default behavior is "Deny", which will log an error and return + * without creating a destination secret. The other option is "LeaveEmpty" which + * will create the destination secret with desired name in the desired + * namespace, but with no data. This may be useful if a secret is expected to + * exist, but is not critical to the operation of the application. + * + * This function will remove the `secrets.uds.dev/copy` label from the secret + * and replace it with `secrets.uds.dev/copied` with the value "true" if the + * secret was successfully copied, or "empty" if the secret was created with no + * data. If the source secret was not found and the behavior is "Deny", the + * label will be set to "deny", and the secret will be denied in the validation + * step after this. + * + * @param request The PeprMutateRequest on a Secret. This should have been + * triggered by a secret with the appropriate label. + * @returns Promise + * + **/ +export async function copySecret(request: PeprMutateRequest) { + const annotations = request.Raw.metadata?.annotations; + + if (checkAnnotationsAndMetadata(request.Raw) !== FailReason.OK) { + // if there are no annotations, we can't do anything, we'll deny. + // in the validation step, the missing annotations will be noticed and an + // appropriate error message generated. + request.RemoveLabel(labelCopySecret); + request.SetLabel(labelCopiedSecret, "deny"); + throw "Missing annotations"; + } + + const fromNS = annotations[annotationFromNS]; + const fromName = annotations[annotationFromName]; + const toNS = request.Raw.metadata?.namespace; + const toName = request.Raw.metadata?.name; + + let failBehavior: OnFailure; + + if (!annotations[annotationOnFailure]) { + failBehavior = OnFailure.DENY; + } else { + switch (annotations[annotationOnFailure].toUpperCase()) { + case "LEAVEEMPTY": + failBehavior = OnFailure.LEAVEEMPTY; + break; + case "DENY": + default: + failBehavior = OnFailure.DENY; + break; + } + } + + if (!fromNS || !fromName || !toNS || !toName) { + // if any of the required annotations or metadata are blank, deny. + request.RemoveLabel(labelCopySecret); + request.SetLabel(labelCopiedSecret, "deny"); + throw "Missing annotations"; + return; + } + + Log.info( + "Attempting to copy secret %s from namespace %s to %s", + fromName, + fromNS, + toNS, + ); + + let sourceSecret = null; + + try { + sourceSecret = await K8s(kind.Secret).InNamespace(fromNS).Get(fromName); + } catch (error) { + sourceSecret = null; + Log.info( + "Source secret %s/%s not found when copy to %s/%s: %s", + fromNS, + fromName, + toNS, + toName, + error.message, + ); + } + + if (!sourceSecret) { + // if the source secret does not exist, handle according to the failure behavior + switch (failBehavior) { + case OnFailure.LEAVEEMPTY: + Log.info("Creating empty secret %s/%s", toNS, toName); + + request.RemoveLabel(labelCopySecret); + request.SetLabel(labelCopiedSecret, "empty"); + request.Raw.data = {}; + return; + case OnFailure.DENY: + request.SetLabel(labelCopiedSecret, "deny"); + throw `Source secret ${fromNS}/${fromName} not found`; + } + } else { + // fill in destination secret with source data + request.RemoveLabel(labelCopySecret); + request.SetLabel(labelCopiedSecret, "true"); + request.Raw.data = sourceSecret.data; + } +} + +/** + * Provide final validation for a secret copy request. In some cases we don't + * want to copy secrets, so we can deny the request here. + * + * @param request + * @returns Approve, or Deny w/ message + */ +export function validateSecret( + request: PeprValidateRequest, +): ValidateActionResponse { + const result = checkAnnotationsAndMetadata(request.Raw); + + console.log("**** VALIDATE REQUEST *****: ", JSON.stringify(request.Raw)); + + if (result === FailReason.NO_ANNOTATIONS) { + return request.Deny( + "Missing all annotations (requires secrets.uds.dev/fromNamespace and fromName)", + ); + } + + if (result === FailReason.MISSING_ANNOTATIONS) { + return request.Deny( + "Missing secrets.uds.dev/fromNamespace and/or secrets.uds.dev/fromName annotations", + ); + } + + if ( + checkAnnotationsAndMetadata(request.Raw) === FailReason.MISSING_METADATA + ) { + return request.Deny("Source secret not found, denying the request"); + } + + return request.Approve(); +} diff --git a/tasks.yaml b/tasks.yaml index 5e9b388eb..cb9e0280c 100644 --- a/tasks.yaml +++ b/tasks.yaml @@ -26,11 +26,17 @@ tasks: - description: "Create the dev cluster" task: setup:create-k3d-cluster - # Note: This currently is broken until https://github.com/zarf-dev/zarf/issues/2713 is resolved - # As a workaround you can edit the `src/istio/values/upstream-values.yaml` file to change ###ZARF_REGISTRY### to docker.io before running + # Workaround for https://github.com/zarf-dev/zarf/issues/2713 + - description: "Modify Istio values w/ upstream registry" + cmd: "uds zarf tools yq -i '.global.proxy_init.image |= sub(\"###ZARF_REGISTRY###\", \"docker.io\") | .global.proxy.image |= sub(\"###ZARF_REGISTRY###\", \"docker.io\")' src/istio/values/upstream-values.yaml" + - description: "Deploy the Istio source package with Zarf Dev" cmd: "uds zarf dev deploy src/istio --flavor upstream --no-progress" + # Undo previous registry workaround + - description: "Restore Istio registry values" + cmd: "uds zarf tools yq -i '.global.proxy_init.image |= sub(\"docker.io\", \"###ZARF_REGISTRY###\") | .global.proxy.image |= sub(\"docker.io\", \"###ZARF_REGISTRY###\")' src/istio/values/upstream-values.yaml" + # Note, this abuses the --flavor flag to only install the CRDs from this package - the "crds-only" flavor is not an explicit flavor of the package - description: "Deploy the Prometheus-Stack source package with Zarf Dev to only install the CRDs" cmd: "uds zarf dev deploy src/prometheus-stack --flavor crds-only --no-progress" From 62d81fa37d57a2f345c337b74c684c4a6c82cfe1 Mon Sep 17 00:00:00 2001 From: Jon Andrew Date: Mon, 21 Oct 2024 11:16:30 -0600 Subject: [PATCH 2/4] Reformat --- src/pepr/operator/index.ts | 8 ++------ src/pepr/operator/secrets.spec.ts | 10 ++-------- src/pepr/operator/secrets.ts | 24 ++++-------------------- 3 files changed, 8 insertions(+), 34 deletions(-) diff --git a/src/pepr/operator/index.ts b/src/pepr/operator/index.ts index a65277ed1..3e621172c 100644 --- a/src/pepr/operator/index.ts +++ b/src/pepr/operator/index.ts @@ -83,9 +83,7 @@ When(UDSPackage) .WithName("keycloak") .Watch(() => { // todo: wait for keycloak and authservice to be running? - log.info( - "Identity and Authorization layer deployed, operator configured to handle SSO.", - ); + log.info("Identity and Authorization layer deployed, operator configured to handle SSO."); UDSConfig.isIdentityDeployed = true; }); When(UDSPackage) @@ -93,9 +91,7 @@ When(UDSPackage) .InNamespace("keycloak") .WithName("keycloak") .Watch(() => { - log.info( - "Identity and Authorization layer removed, operator will NOT handle SSO.", - ); + log.info("Identity and Authorization layer removed, operator will NOT handle SSO."); UDSConfig.isIdentityDeployed = false; }); diff --git a/src/pepr/operator/secrets.spec.ts b/src/pepr/operator/secrets.spec.ts index 9843c99f2..a75f6c8f5 100644 --- a/src/pepr/operator/secrets.spec.ts +++ b/src/pepr/operator/secrets.spec.ts @@ -125,10 +125,7 @@ describe("test secret copy", () => { }); }; - return K8s(kind.Secret) - .Apply(destinationSecret) - .then(failIfReached) - .catch(expected); + return K8s(kind.Secret).Apply(destinationSecret).then(failIfReached).catch(expected); }); it("should error when missing source secret and onMissingSource=Deny", async () => { @@ -154,10 +151,7 @@ describe("test secret copy", () => { }); }; - return K8s(kind.Secret) - .Apply(destinationSecret) - .then(failIfReached) - .catch(expected); + return K8s(kind.Secret).Apply(destinationSecret).then(failIfReached).catch(expected); }); it("should create empty secret when missing source secret and onMissingSource=LeaveEmpty", async () => { diff --git a/src/pepr/operator/secrets.ts b/src/pepr/operator/secrets.ts index 7f2c90543..ef13b7693 100644 --- a/src/pepr/operator/secrets.ts +++ b/src/pepr/operator/secrets.ts @@ -3,14 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial */ -import { - a, - K8s, - kind, - Log, - PeprMutateRequest, - PeprValidateRequest, -} from "pepr"; +import { a, K8s, kind, Log, PeprMutateRequest, PeprValidateRequest } from "pepr"; import { ValidateActionResponse } from "pepr/dist/lib/types"; export const labelCopySecret = "secrets.uds.dev/copy"; @@ -134,12 +127,7 @@ export async function copySecret(request: PeprMutateRequest) { return; } - Log.info( - "Attempting to copy secret %s from namespace %s to %s", - fromName, - fromNS, - toNS, - ); + Log.info("Attempting to copy secret %s from namespace %s to %s", fromName, fromNS, toNS); let sourceSecret = null; @@ -186,9 +174,7 @@ export async function copySecret(request: PeprMutateRequest) { * @param request * @returns Approve, or Deny w/ message */ -export function validateSecret( - request: PeprValidateRequest, -): ValidateActionResponse { +export function validateSecret(request: PeprValidateRequest): ValidateActionResponse { const result = checkAnnotationsAndMetadata(request.Raw); console.log("**** VALIDATE REQUEST *****: ", JSON.stringify(request.Raw)); @@ -205,9 +191,7 @@ export function validateSecret( ); } - if ( - checkAnnotationsAndMetadata(request.Raw) === FailReason.MISSING_METADATA - ) { + if (checkAnnotationsAndMetadata(request.Raw) === FailReason.MISSING_METADATA) { return request.Deny("Source secret not found, denying the request"); } From dd2924068f1b822364302f5a01ab013009741fc2 Mon Sep 17 00:00:00 2001 From: Jon Andrew Date: Mon, 21 Oct 2024 11:56:40 -0600 Subject: [PATCH 3/4] Make typechecker great again --- src/pepr/operator/secrets.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pepr/operator/secrets.ts b/src/pepr/operator/secrets.ts index ef13b7693..284b2ab80 100644 --- a/src/pepr/operator/secrets.ts +++ b/src/pepr/operator/secrets.ts @@ -89,7 +89,7 @@ export function checkAnnotationsAndMetadata(data: a.Secret): FailReason { export async function copySecret(request: PeprMutateRequest) { const annotations = request.Raw.metadata?.annotations; - if (checkAnnotationsAndMetadata(request.Raw) !== FailReason.OK) { + if (!annotations) { // if there are no annotations, we can't do anything, we'll deny. // in the validation step, the missing annotations will be noticed and an // appropriate error message generated. From b9c558a33cffacd86a2af7ce19ec41ce84abc2a9 Mon Sep 17 00:00:00 2001 From: Jon Andrew Date: Mon, 21 Oct 2024 12:06:44 -0600 Subject: [PATCH 4/4] Add afterAll to codespell ignore list, part of jest --- .codespellrc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.codespellrc b/.codespellrc index 538846652..47175b9ef 100644 --- a/.codespellrc +++ b/.codespellrc @@ -1,6 +1,6 @@ # Lint Codespell configurations [codespell] skip = .codespellrc,.git,node_modules,build,dist,*.zst,CHANGELOG.md,.playwright,.terraform -ignore-words-list = NotIn,AKS,LICENS,aks +ignore-words-list = NotIn,AKS,LICENS,aks,afterAll enable-colors = check-hidden =