Skip to content

Commit

Permalink
feat(k8s): add support for patching manifests (#5187)
Browse files Browse the repository at this point in the history
* feat(k8s): enable patching manifests

This change introduces a \`patchResources\` field to the \`kubernetes\`
Deploy action that allows users to overwrite the values of manifests in
Garden config without modifying the manifest itself.

This enables users to drop Garden into existing K8s projects without
making any other changes to code or config.

Under the hood, Garden uses the `kubectl patch` command. The behaviour
is documented here:

https://kubernetes.io/docs/tasks/manage-kubernetes-objects/update-api-object-kubectl-patch/

* chore(k8s): fix typo in description

Co-authored-by: Anna Mager <78752267+twelvemo@users.noreply.github.com>

---------

Co-authored-by: Anna Mager <78752267+twelvemo@users.noreply.github.com>
  • Loading branch information
eysi09 and twelvemo committed Oct 6, 2023
1 parent 37b4e4a commit 5f7f533
Show file tree
Hide file tree
Showing 63 changed files with 32,462 additions and 437 deletions.
103 changes: 86 additions & 17 deletions core/src/plugins/kubernetes/kubernetes-type/common.ts
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
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),
})

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
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
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
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
@@ -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
@@ -0,0 +1,5 @@
kind: Deploy
type: kubernetes
name: deploy-action
spec:
files: [ "*.yaml" ]

0 comments on commit 5f7f533

Please sign in to comment.