diff --git a/core/src/plugin/handlers/base/base.ts b/core/src/plugin/handlers/base/base.ts index 09eb43521b..33b9b789f4 100644 --- a/core/src/plugin/handlers/base/base.ts +++ b/core/src/plugin/handlers/base/base.ts @@ -16,6 +16,7 @@ export type ParamsBase<_ = any> = {} export type ActionTypeHandlerParamsType = Handler extends ActionTypeHandlerSpec ? Params : never + export abstract class ActionTypeHandlerSpec< Kind extends ActionKind, Params extends ParamsBase, @@ -63,10 +64,3 @@ export const actionOutputsSchema = memoize(() => "Structured outputs from the execution, as defined by individual action/module types, to be made available for dependencies and in templating." ) ) - -export interface BaseRunParams { - command?: string[] - args: string[] - interactive: boolean - timeout: number -} diff --git a/core/src/plugins/kubernetes/run.ts b/core/src/plugins/kubernetes/run.ts index dc40dd335a..e634bc8951 100644 --- a/core/src/plugins/kubernetes/run.ts +++ b/core/src/plugins/kubernetes/run.ts @@ -34,11 +34,11 @@ import { waitForResources, DeploymentResourceStatusError } from "./status/status import { getResourceRequirements, getSecurityContext } from "./container/util.js" import { KUBECTL_DEFAULT_TIMEOUT } from "./kubectl.js" import fsExtra from "fs-extra" + const { copy } = fsExtra import type { PodLogEntryConverter, PodLogEntryConverterParams } from "./logs.js" import { K8sLogFollower } from "./logs.js" import { Stream } from "ts-stream" -import type { BaseRunParams } from "../../plugin/handlers/base/base.js" import type { V1PodSpec, V1Container, V1Pod, V1ContainerStatus, V1PodStatus } from "@kubernetes/client-node" import type { RunResult } from "../../plugin/base.js" import { LogLevel } from "../../logger/logger.js" @@ -105,6 +105,13 @@ export const makeRunLogEntry: PodLogEntryConverter = ({ timestamp, export const runContainerExcludeFields: (keyof V1Container)[] = ["readinessProbe", "livenessProbe", "startupProbe"] +interface BaseRunAndCopyParams { + command?: string[] + args: string[] + interactive: boolean + timeout: number +} + // TODO: jfc this function signature stinks like all hell - JE export async function runAndCopy({ ctx, @@ -128,7 +135,7 @@ export async function runAndCopy({ privileged, addCapabilities, dropCapabilities, -}: BaseRunParams & { +}: BaseRunAndCopyParams & { ctx: PluginContext log: Log action: SupportedRuntimeAction @@ -137,7 +144,7 @@ export async function runAndCopy({ podName?: string podSpec?: V1PodSpec artifacts?: ArtifactSpec[] - artifactsPath?: string + artifactsPath: string envVars?: ContainerEnvVars resources?: ContainerResourcesSpec description?: string @@ -150,7 +157,7 @@ export async function runAndCopy({ const provider = ctx.provider const api = await KubeApi.factory(log, ctx, provider) - const getArtifacts = !!(!interactive && artifacts && artifacts.length > 0 && artifactsPath) + const getArtifacts = artifacts.length > 0 const mainContainerName = "main" if (!description) { @@ -223,7 +230,7 @@ export async function runAndCopy({ ...runParams, mainContainerName, artifacts, - artifactsPath: artifactsPath!, + artifactsPath, description, stdout: outputStream, stderr: outputStream, @@ -394,7 +401,7 @@ async function runWithoutArtifacts({ api: KubeApi provider: KubernetesProvider podData: PodData - run: BaseRunParams + run: BaseRunAndCopyParams }): Promise { const { timeout: timeoutSec, interactive } = run @@ -499,7 +506,7 @@ async function runWithArtifacts({ stdout: Writable stderr: Writable podData: PodData - run: BaseRunParams + run: BaseRunAndCopyParams }): Promise { const { args, command, timeout: timeoutSec } = run diff --git a/core/test/integ/src/plugins/kubernetes/run.ts b/core/test/integ/src/plugins/kubernetes/run.ts index c3f3efe583..bb4bec7982 100644 --- a/core/test/integ/src/plugins/kubernetes/run.ts +++ b/core/test/integ/src/plugins/kubernetes/run.ts @@ -10,6 +10,7 @@ import * as td from "testdouble" import tmp from "tmp-promise" import { expectError, pruneEmpty } from "../../../../helpers.js" import fsExtra from "fs-extra" + const { pathExists } = fsExtra import { expect } from "chai" import { join } from "path" @@ -1052,6 +1053,7 @@ describe("kubernetes Pod runner functions", () => { log: garden.log, command: ["sh", "-c", "echo ok"], args: [], + artifactsPath: "./", interactive: false, action, namespace, @@ -1071,6 +1073,7 @@ describe("kubernetes Pod runner functions", () => { log: garden.log, command: ["sh", "-c", "echo ok"], args: [], + artifactsPath: "./", interactive: false, action, namespace: provider.config.namespace!.name!, @@ -1097,6 +1100,7 @@ describe("kubernetes Pod runner functions", () => { log: garden.log, command: ["sh", "-c", "echo banana && sleep 10"], args: [], + artifactsPath: "./", interactive: false, action, namespace, @@ -1110,228 +1114,236 @@ describe("kubernetes Pod runner functions", () => { }) context("artifacts are specified", () => { - it("should copy artifacts out of the container", async () => { - const action = await garden.resolveAction({ action: graph.getRun("artifacts-task"), log, graph }) - const spec = action.getSpec() as KubernetesPodRunActionSpec - - const result = await runAndCopy({ - ctx: await garden.getPluginContext({ provider, templateContext: undefined, events: undefined }), - log: garden.log, - command: spec.command, - args: [], - interactive: false, - namespace, - artifacts: spec.artifacts, - artifactsPath: tmpDir.path, - image, - action, - timeout: DEFAULT_RUN_TIMEOUT_SEC, - }) + const interactiveModeContexts = [{ interactive: false }, { interactive: true }] - expect(result.log.trim()).to.equal("ok") - expect(await pathExists(join(tmpDir.path, "task.txt"))).to.be.true - expect(await pathExists(join(tmpDir.path, "subdir", "task.txt"))).to.be.true - }) + for (const interactiveModeContext of interactiveModeContexts) { + const interactive = interactiveModeContext.interactive - it("should clean up the created Pod", async () => { - const action = await garden.resolveAction({ action: graph.getRun("artifacts-task"), log, graph }) - const spec = action.getSpec() as KubernetesPodRunActionSpec - const podName = makePodName("test", action.name) - - await runAndCopy({ - ctx: await garden.getPluginContext({ provider, templateContext: undefined, events: undefined }), - log: garden.log, - command: spec.command, - args: [], - interactive: false, - namespace, - podName, - action, - artifacts: spec.artifacts, - artifactsPath: tmpDir.path, - image, - timeout: DEFAULT_RUN_TIMEOUT_SEC, - }) + context(`when --interactive=${interactive}`, () => { + it("should copy artifacts out of the container", async () => { + const action = await garden.resolveAction({ action: graph.getRun("artifacts-task"), log, graph }) + const spec = action.getSpec() as KubernetesPodRunActionSpec - await expectError( - () => api.core.readNamespacedPod({ name: podName, namespace }), - (err) => { - expect(err).to.be.instanceOf(KubernetesError) - expect(err.responseStatusCode).to.equal(404) - } - ) - }) + const result = await runAndCopy({ + ctx: await garden.getPluginContext({ provider, templateContext: undefined, events: undefined }), + log: garden.log, + command: spec.command, + args: [], + interactive, + namespace, + artifacts: spec.artifacts, + artifactsPath: tmpDir.path, + image, + action, + timeout: DEFAULT_RUN_TIMEOUT_SEC, + }) - it("should handle globs when copying artifacts out of the container", async () => { - const action = await garden.resolveAction({ action: graph.getRun("globs-task"), log, graph }) - const spec = action.getSpec() as KubernetesPodRunActionSpec + expect(result.log.trim()).to.equal("ok") + expect(await pathExists(join(tmpDir.path, "task.txt"))).to.be.true + expect(await pathExists(join(tmpDir.path, "subdir", "task.txt"))).to.be.true + }) - await runAndCopy({ - ctx: await garden.getPluginContext({ provider, templateContext: undefined, events: undefined }), - log: garden.log, - command: spec.command, - args: [], - interactive: false, - namespace, - action, - artifacts: spec.artifacts, - artifactsPath: tmpDir.path, - image, - timeout: DEFAULT_RUN_TIMEOUT_SEC, - }) + it("should clean up the created Pod", async () => { + const action = await garden.resolveAction({ action: graph.getRun("artifacts-task"), log, graph }) + const spec = action.getSpec() as KubernetesPodRunActionSpec + const podName = makePodName("test", action.name) - expect(await pathExists(join(tmpDir.path, "subdir", "task.txt"))).to.be.true - expect(await pathExists(join(tmpDir.path, "output.txt"))).to.be.true - }) + await runAndCopy({ + ctx: await garden.getPluginContext({ provider, templateContext: undefined, events: undefined }), + log: garden.log, + command: spec.command, + args: [], + interactive, + namespace, + podName, + action, + artifacts: spec.artifacts, + artifactsPath: tmpDir.path, + image, + timeout: DEFAULT_RUN_TIMEOUT_SEC, + }) + + await expectError( + () => api.core.readNamespacedPod({ name: podName, namespace }), + (err) => { + expect(err).to.be.instanceOf(KubernetesError) + expect(err.responseStatusCode).to.equal(404) + } + ) + }) - it("should not throw when an artifact is missing", async () => { - const action = await garden.resolveAction({ action: graph.getRun("artifacts-task"), log, graph }) - const spec = action.getSpec() as KubernetesPodRunActionSpec - - await runAndCopy({ - ctx: await garden.getPluginContext({ provider, templateContext: undefined, events: undefined }), - log: garden.log, - command: ["sh", "-c", "echo ok"], - args: [], - interactive: false, - action, - namespace, - artifacts: spec.artifacts, - artifactsPath: tmpDir.path, - image, - timeout: DEFAULT_RUN_TIMEOUT_SEC, - }) - }) + it("should handle globs when copying artifacts out of the container", async () => { + const action = await garden.resolveAction({ action: graph.getRun("globs-task"), log, graph }) + const spec = action.getSpec() as KubernetesPodRunActionSpec - it("should correctly copy a whole directory", async () => { - const action = await garden.resolveAction({ action: graph.getRun("artifacts-task"), log, graph }) + await runAndCopy({ + ctx: await garden.getPluginContext({ provider, templateContext: undefined, events: undefined }), + log: garden.log, + command: spec.command, + args: [], + interactive, + namespace, + action, + artifacts: spec.artifacts, + artifactsPath: tmpDir.path, + image, + timeout: DEFAULT_RUN_TIMEOUT_SEC, + }) - await runAndCopy({ - ctx: await garden.getPluginContext({ provider, templateContext: undefined, events: undefined }), - log: garden.log, - command: ["sh", "-c", "mkdir -p /report && touch /report/output.txt && echo ok"], - args: [], - interactive: false, - action, - namespace, - artifacts: [ - { - source: "/report/*", - target: "my-task-report", - }, - ], - artifactsPath: tmpDir.path, - image, - timeout: DEFAULT_RUN_TIMEOUT_SEC, - }) + expect(await pathExists(join(tmpDir.path, "subdir", "task.txt"))).to.be.true + expect(await pathExists(join(tmpDir.path, "output.txt"))).to.be.true + }) - expect(await pathExists(join(tmpDir.path, "my-task-report"))).to.be.true - expect(await pathExists(join(tmpDir.path, "my-task-report", "output.txt"))).to.be.true - }) + it("should not throw when an artifact is missing", async () => { + const action = await garden.resolveAction({ action: graph.getRun("artifacts-task"), log, graph }) + const spec = action.getSpec() as KubernetesPodRunActionSpec + + await runAndCopy({ + ctx: await garden.getPluginContext({ provider, templateContext: undefined, events: undefined }), + log: garden.log, + command: ["sh", "-c", "echo ok"], + args: [], + interactive, + action, + namespace, + artifacts: spec.artifacts, + artifactsPath: tmpDir.path, + image, + timeout: DEFAULT_RUN_TIMEOUT_SEC, + }) + }) - it("should correctly copy a whole directory without setting a wildcard or target", async () => { - const action = await garden.resolveAction({ action: graph.getRun("artifacts-task"), log, graph }) + it("should correctly copy a whole directory", async () => { + const action = await garden.resolveAction({ action: graph.getRun("artifacts-task"), log, graph }) - await runAndCopy({ - ctx: await garden.getPluginContext({ provider, templateContext: undefined, events: undefined }), - log: garden.log, - command: ["sh", "-c", "mkdir -p /report && touch /report/output.txt && echo ok"], - args: [], - interactive: false, - action, - namespace, - artifacts: [ - { - source: "/report", - target: ".", - }, - ], - artifactsPath: tmpDir.path, - image, - timeout: DEFAULT_RUN_TIMEOUT_SEC, - }) + await runAndCopy({ + ctx: await garden.getPluginContext({ provider, templateContext: undefined, events: undefined }), + log: garden.log, + command: ["sh", "-c", "mkdir -p /report && touch /report/output.txt && echo ok"], + args: [], + interactive, + action, + namespace, + artifacts: [ + { + source: "/report/*", + target: "my-task-report", + }, + ], + artifactsPath: tmpDir.path, + image, + timeout: DEFAULT_RUN_TIMEOUT_SEC, + }) - expect(await pathExists(join(tmpDir.path, "report"))).to.be.true - expect(await pathExists(join(tmpDir.path, "report", "output.txt"))).to.be.true - }) + expect(await pathExists(join(tmpDir.path, "my-task-report"))).to.be.true + expect(await pathExists(join(tmpDir.path, "my-task-report", "output.txt"))).to.be.true + }) - it("should return with logs and success=false when command exceeds timeout", async () => { - const action = await garden.resolveAction({ action: graph.getRun("artifacts-task"), log, graph }) - const spec = action.getSpec() as KubernetesPodRunActionSpec - - const timeout = 3 - const result = await runAndCopy({ - ctx: await garden.getPluginContext({ provider, templateContext: undefined, events: undefined }), - log: garden.log, - command: ["sh", "-c", "echo banana && sleep 10"], - args: [], - interactive: false, - action, - namespace, - artifacts: spec.artifacts, - artifactsPath: tmpDir.path, - image, - timeout, - }) + it("should correctly copy a whole directory without setting a wildcard or target", async () => { + const action = await garden.resolveAction({ action: graph.getRun("artifacts-task"), log, graph }) - expect(result.log.trim()).to.equal( - `Command timed out after ${timeout} seconds. Here are the logs until the timeout occurred:\n\nbanana` - ) - expect(result.success).to.be.false - }) + await runAndCopy({ + ctx: await garden.getPluginContext({ provider, templateContext: undefined, events: undefined }), + log: garden.log, + command: ["sh", "-c", "mkdir -p /report && touch /report/output.txt && echo ok"], + args: [], + interactive, + action, + namespace, + artifacts: [ + { + source: "/report", + target: ".", + }, + ], + artifactsPath: tmpDir.path, + image, + timeout: DEFAULT_RUN_TIMEOUT_SEC, + }) - it("should copy artifacts out of the container even when task times out", async () => { - const action = await garden.resolveAction({ action: graph.getRun("artifacts-task"), log, graph }) - const spec = action.getSpec() as KubernetesPodRunActionSpec - - const timeout = 3 - const result = await runAndCopy({ - ctx: await garden.getPluginContext({ provider, templateContext: undefined, events: undefined }), - log: garden.log, - command: ["sh", "-c", "touch /task.txt && sleep 10"], - args: [], - interactive: false, - action, - namespace, - artifacts: spec.artifacts, - artifactsPath: tmpDir.path, - image, - timeout, - }) + expect(await pathExists(join(tmpDir.path, "report"))).to.be.true + expect(await pathExists(join(tmpDir.path, "report", "output.txt"))).to.be.true + }) - expect(result.log.trim()).to.equal(`Command timed out after ${timeout} seconds.`) - expect(await pathExists(join(tmpDir.path, "task.txt"))).to.be.true - expect(result.success).to.be.false - }) + it("should return with logs and success=false when command exceeds timeout", async () => { + const action = await garden.resolveAction({ action: graph.getRun("artifacts-task"), log, graph }) + const spec = action.getSpec() as KubernetesPodRunActionSpec - it("should throw when no command is specified", async () => { - const action = await garden.resolveAction({ action: graph.getRun("missing-tar-task"), log, graph }) - const spec = action.getSpec() as KubernetesPodRunActionSpec + const timeout = 3 + const result = await runAndCopy({ + ctx: await garden.getPluginContext({ provider, templateContext: undefined, events: undefined }), + log: garden.log, + command: ["sh", "-c", "echo banana && sleep 10"], + args: [], + interactive, + action, + namespace, + artifacts: spec.artifacts, + artifactsPath: tmpDir.path, + image, + timeout, + }) - await expectError( - async () => - runAndCopy({ + expect(result.log.trim()).to.equal( + `Command timed out after ${timeout} seconds. Here are the logs until the timeout occurred:\n\nbanana` + ) + expect(result.success).to.be.false + }) + + it("should copy artifacts out of the container even when task times out", async () => { + const action = await garden.resolveAction({ action: graph.getRun("artifacts-task"), log, graph }) + const spec = action.getSpec() as KubernetesPodRunActionSpec + + const timeout = 3 + const result = await runAndCopy({ ctx: await garden.getPluginContext({ provider, templateContext: undefined, events: undefined }), log: garden.log, + command: ["sh", "-c", "touch /task.txt && sleep 10"], args: [], - interactive: false, + interactive, action, namespace, artifacts: spec.artifacts, artifactsPath: tmpDir.path, - description: "Foo", image, - timeout: DEFAULT_RUN_TIMEOUT_SEC, - }), - (err) => - expect(err.message).to.include(deline` + timeout, + }) + + expect(result.log.trim()).to.equal(`Command timed out after ${timeout} seconds.`) + expect(await pathExists(join(tmpDir.path, "task.txt"))).to.be.true + expect(result.success).to.be.false + }) + + it("should throw when no command is specified", async () => { + const action = await garden.resolveAction({ action: graph.getRun("missing-tar-task"), log, graph }) + const spec = action.getSpec() as KubernetesPodRunActionSpec + + await expectError( + async () => + runAndCopy({ + ctx: await garden.getPluginContext({ provider, templateContext: undefined, events: undefined }), + log: garden.log, + args: [], + interactive, + action, + namespace, + artifacts: spec.artifacts, + artifactsPath: tmpDir.path, + description: "Foo", + image, + timeout: DEFAULT_RUN_TIMEOUT_SEC, + }), + (err) => + expect(err.message).to.include(deline` Foo specifies artifacts to export, but doesn't explicitly set a \`command\`. The kubernetes provider currently requires an explicit command to be set for tests and tasks that export artifacts, because the image's entrypoint cannot be inferred in that execution mode. Please set the \`command\` field and try again. `) - ) - }) + ) + }) + }) + } }) }) })