From a2826a94738c83ee17c44cb6f656fbb623420a65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ey=C3=BE=C3=B3r=20Magn=C3=BAsson?= Date: Tue, 15 Oct 2024 12:58:52 +0200 Subject: [PATCH] fix(k8s): correctly set image pull secret on sync pod (#6533) Before this fix, we'd add image pull secrets with name and namespace to the Pod spec but the namespace field isn't allowed so applying the manifest fails. Now we only add the name. Also added test for the 'configureSyncMode' function. The compiler didn't catch this because it allows excess properties (except for object literals). --- .../kubernetes/container/deployment.ts | 1 + .../kubernetes/kubernetes-type/handlers.ts | 2 +- core/src/plugins/kubernetes/sync.ts | 7 +- .../integ/src/plugins/kubernetes/sync-mode.ts | 981 ++++++++++++++---- 4 files changed, 808 insertions(+), 183 deletions(-) diff --git a/core/src/plugins/kubernetes/container/deployment.ts b/core/src/plugins/kubernetes/container/deployment.ts index cd1a8cebd3..649fed8788 100644 --- a/core/src/plugins/kubernetes/container/deployment.ts +++ b/core/src/plugins/kubernetes/container/deployment.ts @@ -237,6 +237,7 @@ export async function createWorkloadManifest({ production, }: CreateDeploymentParams): Promise { const spec = action.getSpec() + const mode = action.mode() let configuredReplicas = spec.replicas || DEFAULT_MINIMUM_REPLICAS diff --git a/core/src/plugins/kubernetes/kubernetes-type/handlers.ts b/core/src/plugins/kubernetes/kubernetes-type/handlers.ts index bd0dd2eabb..8738d0c555 100644 --- a/core/src/plugins/kubernetes/kubernetes-type/handlers.ts +++ b/core/src/plugins/kubernetes/kubernetes-type/handlers.ts @@ -598,7 +598,7 @@ async function configureSpecialModesForManifests({ log, }) } else if (mode === "sync" && spec.sync && !isEmpty(spec.sync)) { - // The "sync-mode" annotation is set in `configureDevMode`. + // The "sync-mode" annotation is already set. return configureSyncMode({ ctx, log, diff --git a/core/src/plugins/kubernetes/sync.ts b/core/src/plugins/kubernetes/sync.ts index a376038fd0..b851f5cad9 100644 --- a/core/src/plugins/kubernetes/sync.ts +++ b/core/src/plugins/kubernetes/sync.ts @@ -303,7 +303,7 @@ export async function configureSyncMode({ spec, }: { ctx: PluginContext - log: Log + log: ActionLog provider: KubernetesProvider action: Resolved defaultTarget: KubernetesTargetResourceSpec | undefined @@ -461,10 +461,12 @@ export async function configureSyncMode({ if (!podSpec.initContainers) { podSpec.initContainers = [] } + if (!podSpec.imagePullSecrets) { podSpec.imagePullSecrets = [] } const k8sSyncUtilImageName = getK8sSyncUtilImageName() + if (!podSpec.initContainers.find((c) => c.image === k8sSyncUtilImageName)) { const initContainer: V1Container = { name: k8sSyncUtilContainerName, @@ -479,7 +481,8 @@ export async function configureSyncMode({ volumeMounts: [gardenVolumeMount], } podSpec.initContainers.push(initContainer) - podSpec.imagePullSecrets.push(...provider.config.imagePullSecrets) + + podSpec.imagePullSecrets.push(...provider.config.imagePullSecrets.map((s) => ({ name: s.name }))) } if (!targetContainer.volumeMounts) { diff --git a/core/test/integ/src/plugins/kubernetes/sync-mode.ts b/core/test/integ/src/plugins/kubernetes/sync-mode.ts index b7cb7a6b91..bdb91caad2 100644 --- a/core/test/integ/src/plugins/kubernetes/sync-mode.ts +++ b/core/test/integ/src/plugins/kubernetes/sync-mode.ts @@ -12,222 +12,240 @@ const { mkdirp, pathExists, readFile, remove, writeFile } = fsExtra import { join } from "path" import type { ConfigGraph } from "../../../../../src/graph/config-graph.js" import { k8sGetContainerDeployStatus } from "../../../../../src/plugins/kubernetes/container/status.js" -import type { Log } from "../../../../../src/logger/log-entry.js" +import type { ActionLog, Log } from "../../../../../src/logger/log-entry.js" import { createActionLog } from "../../../../../src/logger/log-entry.js" import type { KubernetesPluginContext, KubernetesProvider } from "../../../../../src/plugins/kubernetes/config.js" import { getMutagenMonitor, Mutagen } from "../../../../../src/mutagen.js" -import type { KubernetesWorkload } from "../../../../../src/plugins/kubernetes/types.js" +import type { KubernetesWorkload, SyncableRuntimeAction } from "../../../../../src/plugins/kubernetes/types.js" import { execInWorkload } from "../../../../../src/plugins/kubernetes/util.js" import { dedent } from "../../../../../src/util/string.js" import { sleep } from "../../../../../src/util/util.js" import { getContainerTestGarden } from "./container/container.js" import { + configureSyncMode, convertContainerSyncSpec, convertKubernetesModuleDevModeSpec, } from "../../../../../src/plugins/kubernetes/sync.js" import type { HelmModuleConfig } from "../../../../../src/plugins/kubernetes/helm/module-config.js" import type { KubernetesModuleConfig } from "../../../../../src/plugins/kubernetes/kubernetes-type/module-config.js" import type { TestGarden } from "../../../../helpers.js" -import { cleanProject } from "../../../../helpers.js" +import { cleanProject, expectError, getDataDir, makeTestGarden } from "../../../../helpers.js" import type { ContainerDeployActionConfig } from "../../../../../src/plugins/container/moduleConfig.js" import { resolveAction } from "../../../../../src/graph/actions.js" import { DeployTask } from "../../../../../src/tasks/deploy.js" import { MUTAGEN_DIR_NAME } from "../../../../../src/constants.js" +import { getK8sSyncUtilImageName } from "../../../../../src/plugins/kubernetes/constants.js" +import type { Action, Resolved } from "../../../../../src/actions/types.js" +import stripAnsi from "strip-ansi" describe("sync mode deployments and sync behavior", () => { - let garden: TestGarden - let cleanup: (() => void) | undefined - let graph: ConfigGraph - let ctx: KubernetesPluginContext - let provider: KubernetesProvider - - const execInPod = async (command: string[], log: Log, workload: KubernetesWorkload) => { - const execRes = await execInWorkload({ - command, - ctx, - provider, - log, - namespace: provider.config.namespace!.name!, - workload, - interactive: false, - }) - return execRes - } - - before(async () => { - await init("local") - }) + describe("sync mode deployments", () => { + const environmentName = "local" + let garden: TestGarden + let cleanup: (() => void) | undefined + let graph: ConfigGraph + let ctx: KubernetesPluginContext + let provider: KubernetesProvider - after(async () => { - if (cleanup) { - cleanup() - } - }) - - beforeEach(async () => { - graph = await garden.getConfigGraph({ log: garden.log, emit: false }) - }) - - afterEach(async () => { - if (garden) { - garden.close() - const dataDir = join(garden.gardenDirPath, MUTAGEN_DIR_NAME) - await getMutagenMonitor({ log: garden.log, dataDir }).stop() - await cleanProject(garden.gardenDirPath) + const execInPod = async (command: string[], log: Log, workload: KubernetesWorkload) => { + const execRes = await execInWorkload({ + command, + ctx, + provider, + log, + namespace: provider.config.namespace!.name!, + workload, + interactive: false, + }) + return execRes } - }) - const init = async (environmentName: string) => { - ;({ garden, cleanup } = await getContainerTestGarden(environmentName, { noTempDir: true })) - graph = await garden.getConfigGraph({ - log: garden.log, - emit: false, - noCache: true, - actionModes: { - sync: ["deploy.sync-mode"], - }, - }) - provider = await garden.resolveProvider({ log: garden.log, name: "local-kubernetes" }) - ctx = ( - await garden.getPluginContext({ provider, templateContext: undefined, events: undefined }) - ) - } - - // todo: fix this test, It works locally, fails on ci - it.skip("should deploy a service in sync mode and successfully set a two-way sync", async () => { - await init("local") - const action = graph.getDeploy("sync-mode") - const log = garden.log - const deployTask = new DeployTask({ - garden, - graph, - log, - action, - force: true, - startSync: true, + after(async () => { + if (cleanup) { + cleanup() + } }) - await garden.processTasks({ tasks: [deployTask], throwOnError: true }) - const resolvedAction = await garden.resolveAction({ - action, - log: garden.log, - graph: await garden.getConfigGraph({ log: garden.log, emit: false, actionModes: { sync: ["deploy.sync-mode"] } }), + beforeEach(async () => { + ;({ garden, cleanup } = await getContainerTestGarden(environmentName, { noTempDir: true })) + graph = await garden.getConfigGraph({ + log: garden.log, + emit: false, + noCache: true, + actionModes: { + sync: ["deploy.sync-mode"], + }, + }) + provider = await garden.resolveProvider({ log: garden.log, name: "local-kubernetes" }) + ctx = ( + await garden.getPluginContext({ provider, templateContext: undefined, events: undefined }) + ) }) - const actionLog = createActionLog({ log, actionName: action.name, actionKind: action.kind }) - const status = await k8sGetContainerDeployStatus({ - ctx, - action: resolvedAction, - log: actionLog, + afterEach(async () => { + if (garden) { + garden.close() + const dataDir = join(garden.gardenDirPath, MUTAGEN_DIR_NAME) + await getMutagenMonitor({ log: garden.log, dataDir }).stop() + await cleanProject(garden.gardenDirPath) + } }) - expect(status.detail?.mode).to.equal("sync") - - const workload = status.detail?.detail.workload! - - // First, we create a file locally and verify that it gets synced into the pod - const actionPath = action.sourcePath() - await writeFile(join(actionPath, "made_locally"), "foo") - await sleep(300) - const execRes = await execInPod(["/bin/sh", "-c", "cat /tmp/made_locally"], log, workload) - expect(execRes.output.trim()).to.eql("foo") - - // Then, we create a file in the pod and verify that it gets synced back - await execInPod(["/bin/sh", "-c", "echo bar > /tmp/made_in_pod"], log, workload) - await sleep(500) - const localPath = join(actionPath, "made_in_pod") - expect(await pathExists(localPath)).to.eql(true) - expect((await readFile(localPath)).toString().trim()).to.eql("bar") - - // This is to make sure that the two-way sync doesn't recreate the local files we're about to delete here. - const actions = await garden.getActionRouter() - await actions.deploy.delete({ graph, log: actionLog, action: resolvedAction }) - - // Clean up the files we created locally - for (const filename of ["made_locally", "made_in_pod"]) { - try { - await remove(join(actionPath, filename)) - } catch {} - } - }) - // todo: fix this test, It works locally, fails on ci. - it.skip("should apply ignore rules from the sync spec and the provider-level sync defaults", async () => { - await init("local") - const action = graph.getDeploy("sync-mode") - - // We want to ignore the following directories (all at module root) - // somedir - // prefix-a <--- matched by provider-level default excludes - // nested/prefix-b <--- matched by provider-level default excludes - - action["_config"].spec.sync!.paths[0].mode = "one-way-replica" - action["_config"].spec.sync!.paths[0].exclude = ["somedir"] - const log = garden.log - const deployTask = new DeployTask({ - garden, - graph, - log, - action, - force: true, - startSync: true, - }) + // todo: fix this test, It works locally, fails on ci + it("should deploy a service in sync mode and successfully set a two-way sync", async () => { + const action = graph.getDeploy("sync-mode") + const log = garden.log + const deployTask = new DeployTask({ + garden, + graph, + log, + action, + force: true, + startSync: true, + }) - await garden.processTasks({ tasks: [deployTask], throwOnError: true }) - const resolvedAction = await garden.resolveAction({ - action, - log: garden.log, - graph: await garden.getConfigGraph({ log: garden.log, emit: false, actionModes: { sync: ["deploy.sync-mode"] } }), - }) - const actionLog = createActionLog({ log, actionName: action.name, actionKind: action.kind }) - const status = await k8sGetContainerDeployStatus({ - ctx, - action: resolvedAction, - log: actionLog, + await garden.processTasks({ tasks: [deployTask], throwOnError: true }) + const resolvedAction = await garden.resolveAction({ + action, + log: garden.log, + graph: await garden.getConfigGraph({ + log: garden.log, + emit: false, + actionModes: { sync: ["deploy.sync-mode"] }, + }), + }) + const actionLog = createActionLog({ log, actionName: action.name, actionKind: action.kind }) + + const status = await k8sGetContainerDeployStatus({ + ctx, + action: resolvedAction, + log: actionLog, + }) + + expect(status.detail?.mode).to.equal("sync") + + // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain + const workload = status.detail?.detail.workload! + + // First, we create a file locally and verify that it gets synced into the pod + const actionPath = action.sourcePath() + await writeFile(join(actionPath, "made_locally"), "foo") + await sleep(300) + const execRes = await execInPod(["/bin/sh", "-c", "cat /tmp/made_locally"], log, workload) + expect(execRes.output.trim()).to.eql("foo") + + // Then, we create a file in the pod and verify that it gets synced back + await execInPod(["/bin/sh", "-c", "echo bar > /tmp/made_in_pod"], log, workload) + await sleep(500) + const localPath = join(actionPath, "made_in_pod") + expect(await pathExists(localPath)).to.eql(true) + expect((await readFile(localPath)).toString().trim()).to.eql("bar") + + // This is to make sure that the two-way sync doesn't recreate the local files we're about to delete here. + const actions = await garden.getActionRouter() + await actions.deploy.delete({ graph, log: actionLog, action: resolvedAction }) + + // Clean up the files we created locally + for (const filename of ["made_locally", "made_in_pod"]) { + try { + await remove(join(actionPath, filename)) + } catch {} + } }) - const workload = status.detail?.detail.workload! - const actionPath = action.sourcePath() - - // First, we create a non-ignored file locally - await writeFile(join(actionPath, "made_locally"), "foo") - - // Then, we create files in each of the directories we intended to ignore in the `exclude` spec above, and - // verify that they didn't get synced into the pod. - await mkdirp(join(actionPath, "somedir")) - await writeFile(join(actionPath, "somedir", "file"), "foo") - await mkdirp(join(actionPath, "prefix-a")) - await writeFile(join(actionPath, "prefix-a", "file"), "foo") - await mkdirp(join(actionPath, "nested", "prefix-b")) - await writeFile(join(actionPath, "nested", "prefix-b", "file"), "foo") - - await sleep(1000) - const mutagen = new Mutagen({ ctx, log }) - await mutagen.flushAllSyncs(log) - - const ignoreExecRes = await execInPod(["/bin/sh", "-c", "ls -a /tmp /tmp/nested"], log, workload) - // Clean up the files we created locally - for (const filename of ["made_locally", "somedir", "prefix-a", "nested"]) { - try { - await remove(join(actionPath, filename)) - } catch {} - } + // todo: fix this test, It works locally, fails on ci. + it("should apply ignore rules from the sync spec and the provider-level sync defaults", async () => { + const action = graph.getDeploy("sync-mode") + + // We want to ignore the following directories (all at module root) + // somedir + // prefix-a <--- matched by provider-level default excludes + // nested/prefix-b <--- matched by provider-level default excludes + + action["_config"].spec.sync!.paths[0].mode = "one-way-replica" + action["_config"].spec.sync!.paths[0].exclude = ["somedir"] + const log = garden.log + const deployTask = new DeployTask({ + garden, + graph, + log, + action, + force: true, + startSync: true, + }) - expect(ignoreExecRes.output.trim()).to.eql(dedent` - /tmp: - . - .. - Dockerfile - garden.yml - made_locally - nested - - /tmp/nested: - . - .. - `) + await garden.processTasks({ tasks: [deployTask], throwOnError: true }) + const resolvedAction = await garden.resolveAction({ + action, + log: garden.log, + graph: await garden.getConfigGraph({ + log: garden.log, + emit: false, + actionModes: { sync: ["deploy.sync-mode"] }, + }), + }) + const actionLog = createActionLog({ log, actionName: action.name, actionKind: action.kind }) + const status = await k8sGetContainerDeployStatus({ + ctx, + action: resolvedAction, + log: actionLog, + }) + + // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain + const workload = status.detail?.detail.workload! + const actionPath = action.sourcePath() + + // First, we create a non-ignored file locally + await writeFile(join(actionPath, "made_locally"), "foo") + + // Then, we create files in each of the directories we intended to ignore in the `exclude` spec above, and + // verify that they didn't get synced into the pod. + await mkdirp(join(actionPath, "somedir")) + await writeFile(join(actionPath, "somedir", "file"), "foo") + await mkdirp(join(actionPath, "prefix-a")) + await writeFile(join(actionPath, "prefix-a", "file"), "foo") + await mkdirp(join(actionPath, "nested", "prefix-b")) + await writeFile(join(actionPath, "nested", "prefix-b", "file"), "foo") + + await sleep(1000) + const mutagen = new Mutagen({ ctx, log }) + await mutagen.flushAllSyncs(log) + + const ignoreExecRes = await execInPod(["/bin/sh", "-c", "ls -a /tmp /tmp/nested"], log, workload) + // Clean up the files we created locally + for (const filename of ["made_locally", "somedir", "prefix-a", "nested"]) { + try { + await remove(join(actionPath, filename)) + } catch {} + } + + expect(ignoreExecRes.output.trim()).to.eql(dedent` + /tmp: + . + .. + Dockerfile + garden.yml + made_locally + nested + + /tmp/nested: + . + .. + `) + }) }) describe("convertKubernetesModuleDevModeSpec", () => { + const environmentName = "local" + const root = getDataDir("test-projects", "container") + let garden: TestGarden + let graph: ConfigGraph + + beforeEach(async () => { + garden = await makeTestGarden(root, { environmentString: environmentName, noTempDir: true }) + graph = await garden.getConfigGraph({ log: garden.log, emit: false }) + }) + it("should return a simple sync spec converted from a kubernetes or helm module", async () => { // Since the sync specs for both `kubernetes` and `helm` modules have the type // `KubernetesModuleDevModeSpec`, we don't need separate test cases for each of those two module types here. @@ -352,6 +370,22 @@ describe("sync mode deployments and sync behavior", () => { }) describe("convertContainerDevModeSpec", () => { + const environmentName = "local" + const root = getDataDir("test-projects", "container") + let garden: TestGarden + let graph: ConfigGraph + let ctx: KubernetesPluginContext + let provider: KubernetesProvider + + beforeEach(async () => { + garden = await makeTestGarden(root, { environmentString: environmentName, noTempDir: true }) + provider = await garden.resolveProvider({ log: garden.log, name: "local-kubernetes" }) + ctx = ( + await garden.getPluginContext({ provider, templateContext: undefined, events: undefined }) + ) + graph = await garden.getConfigGraph({ log: garden.log, emit: false }) + }) + it("converts a sync spec from a container Deploy action", async () => { garden.setModuleConfigs([]) garden.setActionConfigs([ @@ -404,4 +438,591 @@ describe("sync mode deployments and sync behavior", () => { }) }) }) + + describe("configureSyncMode", () => { + const environmentName = "local" + const root = getDataDir("test-projects", "container") + let garden: TestGarden + let graph: ConfigGraph + let ctx: KubernetesPluginContext + let provider: KubernetesProvider + let actionRaw: Action + let actionLog: ActionLog + let action: Resolved + + // It's enough to initialise this once for the tests in this block + before(async () => { + garden = await makeTestGarden(root, { environmentString: environmentName, noTempDir: true }) + provider = await garden.resolveProvider({ log: garden.log, name: "local-kubernetes" }) + ctx = ( + await garden.getPluginContext({ provider, templateContext: undefined, events: undefined }) + ) + graph = await garden.getConfigGraph({ log: garden.log, emit: false }) + actionRaw = graph.getDeploy("sync-mode") + actionLog = createActionLog({ log: garden.log, actionName: actionRaw.name, actionKind: actionRaw.kind }) + action = await garden.resolveAction({ + action: actionRaw, + log: garden.log, + graph: await garden.getConfigGraph({ + log: garden.log, + emit: false, + actionModes: { sync: ["deploy.sync-mode"] }, + }), + }) + }) + + it("should return all manifests with the target resources modified and the updated resource itself", async () => { + const manifests = [ + { + kind: "Deployment", + apiVersion: "apps/v1", + metadata: { + name: "sync-mode", + }, + spec: { + template: { + spec: { + containers: [{ name: "sync-mode" }], + }, + }, + }, + }, + { + kind: "ConfigMap", + apiVersion: "v1", + metadata: { + name: "my-configmap", + }, + }, + ] + + const res = await configureSyncMode({ + ctx, + log: actionLog, + provider, + action, + manifests, + defaultTarget: undefined, + spec: { + paths: [ + { + target: { + kind: "Deployment", + name: "sync-mode", + }, + sourcePath: join(action.sourcePath(), "src"), + containerPath: "/app/src", + }, + ], + }, + }) + + expect(res).to.eql({ + updated: [ + { + kind: "Deployment", + apiVersion: "apps/v1", + metadata: { + name: "sync-mode", + annotations: { + "garden.io/mode": "sync", + }, + }, + spec: { + template: { + spec: { + containers: [ + { + name: "sync-mode", + volumeMounts: [ + { + name: "garden", + mountPath: "/.garden", + }, + ], + }, + ], + volumes: [ + { + name: "garden", + emptyDir: {}, + }, + ], + initContainers: [ + { + name: "garden-sync-init", + image: getK8sSyncUtilImageName(), + command: ["/bin/sh", "-c", "'cp' '/usr/local/bin/mutagen-agent' '/.garden/mutagen-agent'"], + imagePullPolicy: "IfNotPresent", + volumeMounts: [ + { + name: "garden", + mountPath: "/.garden", + }, + ], + }, + ], + imagePullSecrets: [], + }, + }, + }, + }, + ], + manifests: [ + { + kind: "Deployment", + apiVersion: "apps/v1", + metadata: { + name: "sync-mode", + annotations: { + "garden.io/mode": "sync", + }, + }, + spec: { + template: { + spec: { + containers: [ + { + name: "sync-mode", + volumeMounts: [ + { + name: "garden", + mountPath: "/.garden", + }, + ], + }, + ], + volumes: [ + { + name: "garden", + emptyDir: {}, + }, + ], + initContainers: [ + { + name: "garden-sync-init", + image: getK8sSyncUtilImageName(), + command: ["/bin/sh", "-c", "'cp' '/usr/local/bin/mutagen-agent' '/.garden/mutagen-agent'"], + imagePullPolicy: "IfNotPresent", + volumeMounts: [ + { + name: "garden", + mountPath: "/.garden", + }, + ], + }, + ], + imagePullSecrets: [], + }, + }, + }, + }, + { + kind: "ConfigMap", + apiVersion: "v1", + metadata: { + name: "my-configmap", + }, + }, + ], + }) + }) + it("should correctly set image pull secrets from the provider config on the Pod spec", async () => { + const manifests = [ + { + kind: "Deployment", + apiVersion: "apps/v1", + metadata: { + name: "sync-mode", + }, + spec: { + template: { + spec: { + containers: [{ name: "sync-mode" }], + }, + }, + }, + }, + ] + + const res = await configureSyncMode({ + ctx, + log: actionLog, + provider: { + ...provider, + config: { + ...provider.config, + imagePullSecrets: [ + { + name: "secret-a", + namespace: "the-secret-namespace", + }, + { + name: "secret-b", + namespace: "the-secret-namespace", + }, + ], + }, + }, + action, + manifests, + defaultTarget: undefined, + spec: { + paths: [ + { + target: { + kind: "Deployment", + name: "sync-mode", + }, + sourcePath: join(action.sourcePath(), "src"), + containerPath: "/app/src", + }, + ], + }, + }) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((res.updated[0]).spec.template.spec.imagePullSecrets).to.eql([ + { + name: "secret-a", + }, + { + name: "secret-b", + }, + ]) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((res.manifests[0]).spec.template.spec.imagePullSecrets).to.eql([ + { + name: "secret-a", + }, + { + name: "secret-b", + }, + ]) + }) + it("should not overwrite existing image pull secrets", async () => { + const manifests = [ + { + kind: "Deployment", + apiVersion: "apps/v1", + metadata: { + name: "sync-mode", + }, + spec: { + template: { + spec: { + containers: [{ name: "sync-mode" }], + imagePullSecrets: [ + { + name: "deployment-secret", + }, + ], + }, + }, + }, + }, + ] + + const res = await configureSyncMode({ + ctx, + log: actionLog, + provider: { + ...provider, + config: { + ...provider.config, + imagePullSecrets: [ + { + name: "provider-secret", + namespace: "the-secret-namespace", + }, + ], + }, + }, + action, + manifests, + defaultTarget: undefined, + spec: { + paths: [ + { + target: { + kind: "Deployment", + name: "sync-mode", + }, + sourcePath: join(action.sourcePath(), "src"), + containerPath: "/app/src", + }, + ], + }, + }) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((res.updated[0]).spec.template.spec.imagePullSecrets).to.eql([ + { + name: "deployment-secret", + }, + { + name: "provider-secret", + }, + ]) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((res.manifests[0]).spec.template.spec.imagePullSecrets).to.eql([ + { + name: "deployment-secret", + }, + { + name: "provider-secret", + }, + ]) + }) + it("should handle overrides without a target when defaultTarget is provided", async () => { + const manifests = [ + { + kind: "Deployment", + apiVersion: "apps/v1", + metadata: { name: "sync-mode" }, + spec: { + template: { + spec: { + containers: [{ name: "sync-mode", image: "original-image" }], + }, + }, + }, + }, + ] + + const res = await configureSyncMode({ + ctx, + log: actionLog, + provider, + action, + manifests, + defaultTarget: { kind: "Deployment", name: "sync-mode" }, + spec: { + overrides: [ + { + image: "new-image", + }, + ], + }, + }) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((res.updated[0]).spec.template.spec.containers[0].image).to.equal("new-image") + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((res.manifests[0]).spec.template.spec.containers[0].image).to.equal("new-image") + }) + it("should throw an error when override has no target and no defaultTarget", async () => { + const manifests = [ + { + kind: "Deployment", + apiVersion: "apps/v1", + metadata: { name: "sync-mode" }, + spec: { + template: { + spec: { + containers: [{ name: "sync-mode" }], + }, + }, + }, + }, + ] + + await expectError( + async () => + await configureSyncMode({ + ctx, + log: actionLog, + provider, + action, + manifests, + defaultTarget: undefined, + spec: { + overrides: [ + { + image: "new-image", + }, + ], + }, + }), + (err) => + expect(stripAnsi(err.message)).to.contain( + stripAnsi(`Sync override configuration on ${action.longDescription()} doesn't specify a target`) + ) + ) + }) + it("should handle sync paths with podSelector", async () => { + const manifests = [ + { + kind: "Deployment", + apiVersion: "apps/v1", + metadata: { name: "sync-mode" }, + spec: { + selector: { matchLabels: { app: "sync-mode" } }, + template: { + metadata: { labels: { app: "sync-mode" } }, + spec: { + containers: [{ name: "sync-mode" }], + }, + }, + }, + }, + ] + + const res = await configureSyncMode({ + ctx, + log: actionLog, + provider, + action, + manifests, + defaultTarget: undefined, + spec: { + paths: [ + { + target: { podSelector: { app: "sync-mode" } }, + sourcePath: join(action.sourcePath(), "src"), + containerPath: "/app/src", + }, + ], + }, + }) + + // Verify that the manifest was not modified (podSelector doesn't modify manifests) + expect(res.manifests).to.deep.equal(manifests) + }) + it("should throw an error when sync path has no target and no defaultTarget", async () => { + const manifests = [ + { + kind: "Deployment", + apiVersion: "apps/v1", + metadata: { name: "sync-mode" }, + spec: { + template: { + spec: { + containers: [{ name: "sync-mode" }], + }, + }, + }, + }, + ] + + await expectError( + async () => + configureSyncMode({ + ctx, + log: actionLog, + provider, + action, + manifests, + defaultTarget: undefined, + spec: { + paths: [ + { + sourcePath: join(action.sourcePath(), "src"), + containerPath: "/app/src", + }, + ], + }, + }), + (err) => + expect(stripAnsi(err.message)).to.contain( + stripAnsi(`Sync configuration on ${action.longDescription()} doesn't specify a target`) + ) + ) + }) + it("should handle overrides with command and args", async () => { + const manifests = [ + { + kind: "Deployment", + apiVersion: "apps/v1", + metadata: { name: "sync-mode" }, + spec: { + template: { + spec: { + containers: [{ name: "sync-mode" }], + }, + }, + }, + }, + ] + + const res = await configureSyncMode({ + ctx, + log: actionLog, + provider, + action, + manifests, + defaultTarget: { kind: "Deployment", name: "sync-mode" }, + spec: { + overrides: [ + { + command: ["npm", "start"], + args: ["--port", "8080"], + }, + ], + }, + }) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const container = (res.updated[0]).spec.template.spec.containers[0] + expect(container.command).to.deep.equal(["npm", "start"]) + expect(container.args).to.deep.equal(["--port", "8080"]) + }) + it("should handle multiple targets and overrides", async () => { + const manifests = [ + { + kind: "Deployment", + apiVersion: "apps/v1", + metadata: { name: "sync-mode-1" }, + spec: { + template: { + spec: { + containers: [{ name: "sync-mode-1" }], + }, + }, + }, + }, + { + kind: "Deployment", + apiVersion: "apps/v1", + metadata: { name: "sync-mode-2" }, + spec: { + template: { + spec: { + containers: [{ name: "sync-mode-2" }], + }, + }, + }, + }, + ] + + const res = await configureSyncMode({ + ctx, + log: actionLog, + provider, + action, + manifests, + defaultTarget: undefined, + spec: { + overrides: [ + { + target: { kind: "Deployment", name: "sync-mode-1" }, + image: "new-image-1", + }, + { + target: { kind: "Deployment", name: "sync-mode-2" }, + image: "new-image-2", + }, + ], + }, + }) + + expect(res.updated).to.have.length(2) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((res.updated[0]).spec.template.spec.containers[0].image).to.equal("new-image-1") + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((res.updated[1]).spec.template.spec.containers[0].image).to.equal("new-image-2") + }) + }) })