Skip to content

Commit

Permalink
fix(k8s): correctly resolve manifests when build is set (#4846)
Browse files Browse the repository at this point in the history
* refactor: extract named function for manifest post-processing

* refactor: split manifest reading and post-processing

* fix(k8s): correctly resolve manifests when `build` is set

* fix(k8s): quickfix to avoid resetting `outdated` state to `ready`

* test: fix tests

* refactor(test): move helper to the upper-level scope

It will be used by other test contexts.

* refactor(test): introduce one more convenience helper

* refactor(test): introduce and rename some local vars

* refactor(test): use helpers to avoid code duplication

* chore: re-arranged code

Keep all helpers close to each other for simpler navigation.

* chore(test): rename some tests and variables

To avoid usage of the old glossary.

* refactor(test): helper function to deploy in namespaces

* DRY to avoid repetition and too complex local state
* Less shared global state between different test contexts
* Avoid dependencies between tests and reliance on the execution order
* Ability to run individual tests locally

* refactor(test): convert lambdas to functions

* test: ensure all resources are deleted by `deleteKubernetesDeploy`

A metadata `ConfigMap` describing what was last deployed must be deleted too.

* test: restore initial module config state after each test

To avoid unexpected pollution of the Garden instance's state.
Multiple test can define temporary custom module config.
Such config changes should not affect the other tests.

* refactor(k8s): helper to compose k8s deploy status

* refactor(k8s): return deploy status immediately on "missing" state

* refactor: unwrap unnecessary else-block

* chore: variable scoping

* refactor: extract helper to check if deploy is outdated

* refactor: move input args check into `getForwardablePorts`

* chore: remove unnecessary code and comments

Local mode checks were moved to `getForwardablePorts` in #5022.

* chore: remove unnecessary try/catch

Function `getForwardablePorts` does not call any potentially unsafe operations.
It's not wrapped into try/catch in the other code places.

* chore: remove unused local var

* refactor: use SRP in status calculation

Split individual resource status retrieval and overall state calculation.

* refactor: simplified code

Minimized mutability of the local vars.
More straightforward and linear processing flow.

* test: fix test setup to cover the original bug

* chore: post-merge corrections

* refactor: remove unnecessary function call

The value has already been calculated as an immutable value.

* chore: typos and wording

Got rid of old-fashioned term "service". Replaced it with "deploy".

---------

Co-authored-by: Jon Edvald <[email protected]>
  • Loading branch information
vvagaytsev and edvald authored Sep 21, 2023
1 parent 230ed41 commit 6c737a9
Show file tree
Hide file tree
Showing 9 changed files with 668 additions and 341 deletions.
5 changes: 2 additions & 3 deletions core/src/plugins/kubernetes/helm/deployment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export const helmDeploy: DeployActionHandler<"deploy", HelmDeployAction> = async
await helm({ ctx: k8sCtx, namespace, log, args: [...upgradeArgs], emitLogEvents: true })

// If ctx.cloudApi is defined, the user is logged in and they might be trying to deploy to an environment
// that could have been paused by by Garden Cloud's AEC functionality. We therefore make sure to clean up any
// that could have been paused by Garden Cloud's AEC functionality. We therefore make sure to clean up any
// dangling annotations created by Garden Cloud.
if (ctx.cloudApi) {
try {
Expand All @@ -92,7 +92,7 @@ export const helmDeploy: DeployActionHandler<"deploy", HelmDeployAction> = async
})
)
} catch (error) {
const errorMsg = `Failed to remove Garden Cloud AEC annotations for service: ${action.name}.`
const errorMsg = `Failed to remove Garden Cloud AEC annotations for deploy: ${action.name}.`
log.warn(errorMsg)
log.debug({ error: toGardenError(error) })
}
Expand Down Expand Up @@ -148,7 +148,6 @@ export const helmDeploy: DeployActionHandler<"deploy", HelmDeployAction> = async
timeoutSec: timeout,
})

// Local mode has its own port-forwarding configuration
const forwardablePorts = getForwardablePorts({ resources: manifests, parentAction: action, mode })

// Make sure port forwards work after redeployment
Expand Down
175 changes: 122 additions & 53 deletions core/src/plugins/kubernetes/kubernetes-type/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@

import { join, resolve } from "path"
import { pathExists, readFile } from "fs-extra"
import { flatten, set } from "lodash"
import { flatten, keyBy, set } from "lodash"
import { loadAll } from "js-yaml"

import { KubernetesModule } from "./module-config"
import { KubernetesResource } from "../types"
import { KubeApi } from "../api"
import { dedent, gardenAnnotationKey, naturalList } from "../../../util/string"
import { dedent, gardenAnnotationKey, naturalList, stableStringify } from "../../../util/string"
import { Log } from "../../../logger/log-entry"
import { PluginContext } from "../../../plugin-context"
import { ConfigurationError, GardenError, PluginError } from "../../../exceptions"
Expand All @@ -23,9 +23,10 @@ import { HelmModule } from "../helm/module-config"
import { KubernetesDeployAction } from "./config"
import { CommonRunParams } from "../../../plugin/handlers/Run/run"
import { runAndCopy } from "../run"
import { getResourceContainer, getResourcePodSpec, getTargetResource, makePodName } from "../util"
import { Resolved } from "../../../actions/types"
import { getResourceContainer, getResourceKey, getResourcePodSpec, getTargetResource, makePodName } from "../util"
import { ActionMode, Resolved } from "../../../actions/types"
import { KubernetesPodRunAction, KubernetesPodTestAction } from "./kubernetes-pod"
import { V1ConfigMap } from "@kubernetes/client-node"
import { glob } from "glob"
import isGlob from "is-glob"
import pFilter from "p-filter"
Expand Down Expand Up @@ -55,54 +56,65 @@ export async function getManifests({
log,
action,
defaultNamespace,
readFromSrcDir = false,
}: {
ctx: PluginContext
api: KubeApi
log: Log
action: Resolved<KubernetesDeployAction | KubernetesPodRunAction | KubernetesPodTestAction>
defaultNamespace: string
readFromSrcDir?: boolean
}): Promise<KubernetesResource[]> {
const declaredManifests: DeclaredManifest[] = await Promise.all(
(await readManifests(ctx, action, log, readFromSrcDir)).map(async ({ manifest, declaration }) => {
// Ensure a namespace is set, if not already set, and if required by the resource type
if (!manifest.metadata?.namespace) {
if (!manifest.metadata) {
// TODO: Type system complains that name is missing
;(manifest as any).metadata = {}
}

const info = await api.getApiResourceInfo(log, manifest.apiVersion, manifest.kind)

if (info?.namespaced) {
manifest.metadata.namespace = defaultNamespace
}
// Local function to set some default values and Garden-specific annotations.
async function postProcessManifest({ 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) {
// TODO: Type system complains that name is missing
;(manifest as any).metadata = {}
}

/**
* Set Garden annotations.
*
* For namespace resources, we use the namespace's name as the annotation value, to ensure that namespace resources
* with different names aren't considered by Garden to be the same resource.
*
* This is relevant e.g. in the context of a shared dev cluster, where several users might create their own
* copies of a namespace resource (each named e.g. "${username}-some-namespace") through deploying a `kubernetes`
* module that includes a namespace resource in its manifests.
*/
const annotationValue =
manifest.kind === "Namespace" ? gardenNamespaceAnnotationValue(manifest.metadata.name) : action.name
set(manifest, ["metadata", "annotations", gardenAnnotationKey("service")], annotationValue)
set(manifest, ["metadata", "annotations", gardenAnnotationKey("mode")], action.mode())
set(manifest, ["metadata", "labels", gardenAnnotationKey("service")], annotationValue)

return { manifest, declaration }
})
)
const info = await api.getApiResourceInfo(log, manifest.apiVersion, manifest.kind)

if (info?.namespaced) {
manifest.metadata.namespace = defaultNamespace
}
}

validateDeclaredManifests(declaredManifests)
/**
* Set Garden annotations.
*
* For namespace resources, we use the namespace's name as the annotation value, to ensure that namespace resources
* with different names aren't considered by Garden to be the same resource.
*
* This is relevant e.g. in the context of a shared dev cluster, where several users might create their own
* copies of a namespace resource (each named e.g. "${username}-some-namespace") through deploying a `kubernetes`
* module that includes a namespace resource in its manifests.
*/
const annotationValue =
manifest.kind === "Namespace" ? gardenNamespaceAnnotationValue(manifest.metadata.name) : action.name
set(manifest, ["metadata", "annotations", gardenAnnotationKey("service")], annotationValue)
set(manifest, ["metadata", "annotations", gardenAnnotationKey("mode")], action.mode())
set(manifest, ["metadata", "labels", gardenAnnotationKey("service")], annotationValue)

return { manifest, declaration }
}

const declaredManifests = await readManifests(ctx, action, log)

if (action.kind === "Deploy") {
// Add metadata ConfigMap to aid quick status check
const metadataManifest = getMetadataManifest(action, defaultNamespace, declaredManifests)
const declaredMetadataManifest: DeclaredManifest = {
declaration: { type: "inline", index: declaredManifests.length },
manifest: metadataManifest,
}
declaredManifests.push(declaredMetadataManifest)
}

return declaredManifests.map((m) => m.manifest)
const postProcessedManifests: DeclaredManifest[] = await Promise.all(declaredManifests.map(postProcessManifest))

validateDeclaredManifests(postProcessedManifests)

return postProcessedManifests.map((m) => m.manifest)
}

/**
Expand Down Expand Up @@ -156,24 +168,81 @@ export function validateDeclaredManifests(declaredManifests: DeclaredManifest[])
}
}

export interface ManifestMetadata {
key: string
apiVersion: string
kind: string
name: string
namespace: string
}

export interface ParsedMetadataManifestData {
resolvedVersion: string
mode: ActionMode
manifestMetadata: { [key: string]: ManifestMetadata }
}

export function getMetadataManifest(
action: Resolved<KubernetesDeployAction>,
defaultNamespace: string,
declaredManifests: DeclaredManifest[]
): KubernetesResource<V1ConfigMap> {
const manifestMetadata: ManifestMetadata[] = declaredManifests.map((declaredManifest) => {
const m = declaredManifest.manifest
return {
key: getResourceKey(m),
apiVersion: m.apiVersion,
kind: m.kind,
name: m.metadata.name,
namespace: m.metadata.namespace || defaultNamespace,
}
})

return {
apiVersion: "v1",
kind: "ConfigMap",
metadata: {
name: `garden-meta-${action.kind.toLowerCase()}-${action.name}`,
},
data: {
resolvedVersion: action.versionString(),
mode: action.mode(),
manifestMetadata: stableStringify(keyBy(manifestMetadata, "key")),
},
}
}

export function parseMetadataResource(log: Log, resource: KubernetesResource<V1ConfigMap>): ParsedMetadataManifestData {
// TODO: validate schema here
const output: ParsedMetadataManifestData = {
resolvedVersion: resource.data?.resolvedVersion || "",
mode: (resource.data?.mode || "default") as ActionMode,
manifestMetadata: {},
}

const manifestMetadata = resource.data?.manifestMetadata

if (manifestMetadata) {
try {
// TODO: validate by schema
output.manifestMetadata = JSON.parse(manifestMetadata)
} catch (error) {
log.debug({ msg: `Failed querying for remote resources: ${error}` })

This comment has been minimized.

Copy link
@stefreak

stefreak Sep 21, 2023

Member

@vvagaytsev if we can't read the metadata, shouldn't we assume that everything is outdated, instead of assuming everything is up-to-date?
If we only log the error, we assume everything is up-to-date instead of verifying every single resource in getKubernetesDeployStatus. I think it would be better to throw an error, and catch it where we call the function and do the appropriate thing (e.g. assume that the action is outdated)

This comment has been minimized.

Copy link
@vvagaytsev

vvagaytsev Sep 25, 2023

Author Collaborator

@stefreak, that's a great point, thanks! I think we should.

Here we catch any parsing error that can occur. Ideally, this should never happen because the manifest metadata is always created by us. But, metadata config in the cluster can be modified manually. So, if someone stores an invalid JSON string there, we'll get a repeating error.

I'll change this in the follow-up PR #5122.

This comment has been minimized.

Copy link
@vvagaytsev

vvagaytsev Sep 25, 2023

Author Collaborator

Ah, that case is already resolved as outdated state. IF JSON parsing fails, the output.manifestMetadata will be an empty object {}. Later, when the deploy state is resolved in resolveResourceStatuses(log, resourceStatuses), it will be outdated because of the underlying call to the function combineStates. That function returns outdated for an empty input array.

}
}

return output
}

/**
* Read the manifests from the module config, as well as any referenced files in the config.
*
* @param module The kubernetes module to read manifests for.
* @param readFromSrcDir Whether or not to read the manifests from the module build dir or from the module source dir.
* In general we want to read from the build dir to ensure that manifests added via the `build.dependencies[].copy`
* field will be included. However, in some cases, e.g. when getting the service status, we can't be certain that
* the build has been staged and we therefore read the manifests from the source.
*
* TODO: Remove this once we're checking for kubernetes module service statuses with version hashes.
*/
async function readManifests(
export async function readManifests(
ctx: PluginContext,
action: Resolved<KubernetesDeployAction | KubernetesPodRunAction | KubernetesPodTestAction>,
log: Log,
readFromSrcDir = false
log: Log
): Promise<DeclaredManifest[]> {
const manifestPath = readFromSrcDir ? action.basePath() : action.getBuildPath()
const manifestPath = action.getBuildPath()

const inlineManifests = readInlineManifests(action)
const fileManifests = await readFileManifests(ctx, action, log, manifestPath)
Expand Down
Loading

0 comments on commit 6c737a9

Please sign in to comment.