Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(k8s): add support for patching manifests #5187

Merged
merged 3 commits into from
Oct 6, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 86 additions & 17 deletions core/src/plugins/kubernetes/kubernetes-type/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { glob } from "glob"
import isGlob from "is-glob"
import pFilter from "p-filter"
import { parseAllDocuments } from "yaml"
import { kubectl } from "../kubectl"

/**
* "DeployFile": Manifest has been read from one of the files declared in Garden Deploy `spec.files`
Expand Down Expand Up @@ -63,8 +64,58 @@ export async function getManifests({
action: Resolved<KubernetesDeployAction | KubernetesPodRunAction | KubernetesPodTestAction>
defaultNamespace: string
}): Promise<KubernetesResource[]> {
const actionSpec = action.getSpec()
const k8sCtx = <KubernetesPluginContext>ctx
const provider = k8sCtx.provider

// Local function that applies user defined patches to manifests
const patchManifest = async (declaredManifest: DeclaredManifest): Promise<DeclaredManifest> => {
const { manifest, declaration } = declaredManifest
const kind = manifest.kind
const name = manifest.metadata.name
const patchSpec = (actionSpec.patchResources || []).find((p) => p.kind === kind && p.name === name)
const namespace = manifest.metadata.namespace || defaultNamespace

if (patchSpec) {
const manifestDescription = renderManifestDescription(declaredManifest)
log.info(`Applying patch to ${manifestDescription}`)

try {
// Ideally we don't shell out to kubectl here but I couldn't find a way to use the Node SDK here.
// If this turns out to be a performance issue we can always implement our own patching
// using the kubectl code as reference.
const patchedManifestRaw = await kubectl(ctx, provider).stdout({
log,
args: [
"patch",
`--namespace=${namespace}`,
`--output=json`,
`--dry-run=client`,
`--patch=${JSON.stringify(patchSpec.patch)}`,
`--type=${patchSpec.strategy}`,
"-f",
"-",
],
input: JSON.stringify(manifest),
})
const patchedManifest = JSON.parse(patchedManifestRaw)

return {
declaration,
manifest: patchedManifest,
}
} catch (err) {
// It's not entirely clear what the failure modes are. In any case kubectl will
// happily apply an invalid patch.
log.error(`Applying patch to ${manifestDescription} failed with error: ${err}`)
throw err
}
}
return declaredManifest
}

// Local function to set some default values and Garden-specific annotations.
async function postProcessManifest({ manifest, declaration }: DeclaredManifest): Promise<DeclaredManifest> {
const postProcessManifest = async ({ manifest, declaration }: DeclaredManifest): Promise<DeclaredManifest> => {
// Ensure a namespace is set, if not already set, and if required by the resource type
if (!manifest.metadata?.namespace) {
if (!manifest.metadata) {
Expand Down Expand Up @@ -110,7 +161,23 @@ export async function getManifests({
declaredManifests.push(declaredMetadataManifest)
}

const postProcessedManifests: DeclaredManifest[] = await Promise.all(declaredManifests.map(postProcessManifest))
const patchedManifests = await Promise.all(declaredManifests.map(patchManifest))

const unmatchedPatches = (actionSpec.patchResources || []).filter((p) => {
const manifest = declaredManifests.find((m) => m.manifest.kind === p.kind && m.manifest.metadata.name === p.name)
if (manifest) {
return false
}
return true
})

for (const p of unmatchedPatches) {
log.warn(
`A patch is defined for a Kubernetes ${p.kind} with name ${p.name} but no Kubernetes resource with a corresponding kind and name found.`
)
}

const postProcessedManifests = await Promise.all(patchedManifests.map(postProcessManifest))

validateDeclaredManifests(postProcessedManifests)

Expand All @@ -125,25 +192,27 @@ export function gardenNamespaceAnnotationValue(namespaceName: string) {
return `garden-namespace--${namespaceName}`
}

function renderManifestDescription(declaredManifest: DeclaredManifest) {
switch (declaredManifest.declaration.type) {
case "file":
return `${declaredManifest.manifest.kind} ${declaredManifest.manifest.metadata.name} declared in the file ${declaredManifest.declaration.filename} (index: ${declaredManifest.declaration.index})`
case "inline":
return `${declaredManifest.manifest.kind} ${
declaredManifest.manifest.metadata.name
} declared inline in the Garden configuration (filename: ${
declaredManifest.declaration.filename || "unknown"
}, index: ${declaredManifest.declaration.index})`
case "kustomize":
return `${declaredManifest.manifest.kind} ${declaredManifest.manifest.metadata.name} generated by Kustomize at path ${declaredManifest.declaration.path} (index: ${declaredManifest.declaration.index})`
}
}

/**
* Verifies that there are no duplicates for every name, kind and namespace.
*
* This verification is important because otherwise this error would lead to several kinds of undefined behaviour.
*/
export function validateDeclaredManifests(declaredManifests: DeclaredManifest[]) {
const renderManifestDeclaration = (m: DeclaredManifest): string => {
switch (m.declaration.type) {
case "file":
return `${m.manifest.kind} ${m.manifest.metadata.name} declared in the file ${m.declaration.filename} (index: ${m.declaration.index})`
case "inline":
return `${m.manifest.kind} ${m.manifest.metadata.name} declared inline in the Garden configuration (filename: ${
m.declaration.filename || "unknown"
}, index: ${m.declaration.index})`
case "kustomize":
return `${m.manifest.kind} ${m.manifest.metadata.name} generated by Kustomize at path ${m.declaration.path} (index: ${m.declaration.index})`
}
}

for (const examinee of declaredManifests) {
const duplicate = declaredManifests.find(
(candidate) =>
Expand All @@ -160,8 +229,8 @@ export function validateDeclaredManifests(declaredManifests: DeclaredManifest[])
duplicate.manifest.metadata.name
} is declared more than once:

- ${renderManifestDeclaration(duplicate)}
- ${renderManifestDeclaration(examinee)}
- ${renderManifestDescription(duplicate)}
- ${renderManifestDescription(examinee)}
`,
})
}
Expand Down
41 changes: 40 additions & 1 deletion core/src/plugins/kubernetes/kubernetes-type/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
} from "../config"
import { kubernetesDeploySyncSchema, KubernetesDeploySyncSpec } from "../sync"
import { KubernetesKustomizeSpec, kustomizeSpecSchema } from "./kustomize"
import type { KubernetesResource } from "../types"
import type { KubernetesPatchResource, KubernetesResource } from "../types"
import type { DeployAction, DeployActionConfig } from "../../../actions/deploy"
import { defaultTargetSchema } from "../helm/config"
import type {
Expand All @@ -33,10 +33,12 @@ import {
KubernetesExecTestAction,
KubernetesExecTestActionConfig,
} from "./kubernetes-exec"
import { dedent } from "../../../util/string"

export interface KubernetesTypeCommonDeploySpec {
files: string[]
kustomize?: KubernetesKustomizeSpec
patchResources?: KubernetesPatchResource[]
manifests: KubernetesResource[]
namespace?: string
portForwards?: PortForwardSpec[]
Expand Down Expand Up @@ -68,6 +70,27 @@ const kubernetesResourceSchema = () =>
})
.unknown(true)

const kubernetesPatchResourceSchema = () =>
joi.object().keys({
kind: joi.string().required().description("The kind of the resource to patch."),
name: joi.string().required().description("The name of the resource to patch."),
strategy: joi
.string()
.allow("json", "merge", "strategic")
.required()
.description(
dedent`
The patch strategy to use. One of 'json', 'merge', or 'strategic'. Defaults to 'strategic'.

You can read more about the different strategies in the offical Kubernetes documentation at:
https://kubernetes.io/docs/tasks/manage-kubernetes-objects/update-api-object-kubectl-patch/
`
)
.default("strategic")
.optional(),
patch: joi.object().required().description("The patch to apply").unknown(true),
eysi09 marked this conversation as resolved.
Show resolved Hide resolved
})

export const kubernetesFilesSchema = () =>
joiSparseArray(joi.posixPath().subPathOnly().allowGlobs()).description(
"POSIX-style paths to YAML files to load manifests from. Each can contain multiple manifests, and can include any Garden template strings, which will be resolved before applying the manifests."
Expand All @@ -78,10 +101,26 @@ export const kubernetesManifestsSchema = () =>
"List of Kubernetes resource manifests to deploy. If `files` is also specified, this is combined with the manifests read from the files."
)

export const kubernetesPatchResourcesSchema = () =>
joiSparseArray(kubernetesPatchResourceSchema()).description(
dedent`
A list of resources to patch using Kubernetes' patch strategies. This is useful for e.g. overwriting a given container image name with an image built by Garden
without having to actually modify the underlying Kubernetes manifest in your source code. Another common example is to use this to change the number of replicas for a given
Kubernetes Deployment.

Under the hood, Garden just applies the \`kubectl patch\` command to the resource that matches the specified \`kind\` and \`name\`.

Patches are applied to file manifests, inline manifests, and kustomize files.

You can learn more about patching Kubernetes resources here: https://kubernetes.io/docs/tasks/manage-kubernetes-objects/update-api-object-kubectl-patch/
`
)

export const kubernetesCommonDeploySpecKeys = () => ({
files: kubernetesFilesSchema(),
kustomize: kustomizeSpecSchema(),
manifests: kubernetesManifestsSchema(),
patchResources: kubernetesPatchResourcesSchema(),
namespace: namespaceNameSchema(),
portForwards: portForwardsSchema(),
timeout: k8sDeploymentTimeoutSchema(),
Expand Down
8 changes: 6 additions & 2 deletions core/src/plugins/kubernetes/kubernetes-type/kubernetes-pod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,14 @@ import { runResultToActionState } from "../../../actions/base"
import {
kubernetesFilesSchema,
kubernetesManifestsSchema,
kubernetesPatchResourcesSchema,
KubernetesRunOutputs,
kubernetesRunOutputsSchema,
KubernetesTestOutputs,
kubernetesTestOutputsSchema,
} from "./config"
import { KubernetesResource } from "../types"
import { KubernetesKustomizeSpec } from "./kustomize"
import { KubernetesPatchResource, KubernetesResource } from "../types"
import { KubernetesKustomizeSpec, kustomizeSpecSchema } from "./kustomize"
import { ObjectSchema } from "@hapi/joi"
import { TestActionConfig, TestAction } from "../../../actions/test"
import { storeTestResult, k8sGetTestResult } from "../test-results"
Expand All @@ -43,6 +44,7 @@ export interface KubernetesPodRunActionSpec extends KubernetesCommonRunSpec {
files: string[]
kustomize?: KubernetesKustomizeSpec
manifests: KubernetesResource[]
patchResources?: KubernetesPatchResource[]
resource?: KubernetesTargetResourceSpec
podSpec?: V1PodSpec
}
Expand All @@ -61,6 +63,8 @@ export const kubernetesRunPodSchema = (kind: string) => {
name,
keys: () => ({
...kubernetesCommonRunSchemaKeys(),
kustomize: kustomizeSpecSchema(),
patchResources: kubernetesPatchResourcesSchema(),
manifests: kubernetesManifestsSchema().description(
`List of Kubernetes resource manifests to be searched (using \`resource\`e for the pod spec for the ${kind}. If \`files\` is also specified, this is combined with the manifests read from the files.`
),
Expand Down
7 changes: 7 additions & 0 deletions core/src/plugins/kubernetes/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ export type KubernetesResource<T extends BaseResource | KubernetesObject = BaseR
[P in Extract<keyof T, "spec">]: Exclude<T[P], undefined>
}

export interface KubernetesPatchResource {
name: string
kind: string
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just went for a string type here instead of listing all K8s object types.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that makes sense because a user could also patch a custom resource.

patch: Omit<KubernetesResource, "apiVersion" | "kind" | "metadata">
strategy: "json" | "merge" | "strategic"
}

// Server-side resources always have some fields set if they're in the schema, e.g. status
export type KubernetesServerResource<T extends BaseResource | KubernetesObject = BaseResource> =
KubernetesResource<T> & {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: busybox-deployment
labels:
app: busybox
spec:
replicas: 1
selector:
matchLabels:
app: busybox
template:
metadata:
labels:
app: busybox
spec:
containers:
- name: busybox
image: busybox:1.31.1
args: [sh, -c, "while :; do sleep 2073600; done"]
env:
- name: FOO
value: banana
- name: BAR
value: ""
- name: BAZ
value: null
ports:
- containerPort: 80

---

apiVersion: v1
kind: ConfigMap
metadata:
name: test-configmap
data:
hello: world
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
kind: Deploy
type: kubernetes
name: deploy-action
spec:
files: [ "*.yaml" ]
Loading