From 9d4effab1c53af5b2d89e9f6146b58c535689a3b Mon Sep 17 00:00:00 2001 From: Anna Mager Date: Wed, 15 Jan 2025 15:17:11 +0100 Subject: [PATCH 1/4] feat(builds): send build report to backend v2 --- core/src/cloud/grow/trpc-schema.ts | 199 ++++++------------- core/src/cloud/grow/trpc.ts | 2 + core/src/plugins/container/build.ts | 218 +++++++++++++++++---- core/src/plugins/container/cloudbuilder.ts | 31 ++- 4 files changed, 278 insertions(+), 172 deletions(-) diff --git a/core/src/cloud/grow/trpc-schema.ts b/core/src/cloud/grow/trpc-schema.ts index 5f242a3d94..6f401e86ef 100644 --- a/core/src/cloud/grow/trpc-schema.ts +++ b/core/src/cloud/grow/trpc-schema.ts @@ -87,62 +87,6 @@ export declare const appRouter: import("@trpc/server/unstable-core-do-not-import transformer: true }, { - get: import("@trpc/server").TRPCQueryProcedure<{ - input: { - id: string - } - output: - | { - id: string - imageNameAndTag: string - builder: string - createdBy: string - author: { - id: string - name: string - } - createdAt: Date - status: "pending" | "completed" | "failed" - platform: "linux/amd64" | "linux/arm64" | "windows/amd64" - sha256: string - totalTiming: number - timing: { - startup: number - build: number - cleanup: number - } - } - | undefined - }> - list: import("@trpc/server").TRPCQueryProcedure<{ - input: { - cursor?: number | undefined - perPage?: number | undefined - } - output: { - items: { - id: string - imageNameAndTag: string - builder: string - createdBy: string - author: { - id: string - name: string - } - createdAt: Date - status: "pending" | "completed" | "failed" - platform: "linux/amd64" | "linux/arm64" | "windows/amd64" - sha256: string - totalTiming: number - timing: { - startup: number - build: number - cleanup: number - } - }[] - nextCursor: number | undefined - } - }> registerBuild: import("@trpc/server").TRPCMutationProcedure<{ input: { platforms: string[] @@ -152,20 +96,20 @@ export declare const appRouter: import("@trpc/server/unstable-core-do-not-import version: "v2" availability: | { - reason: string available: false + reason: string } | { - available: true buildx: { - clientCertificatePem: string endpoints: { platform: string - serverCaPem: string mtlsEndpoint: string + serverCaPem: string }[] + clientCertificatePem: string privateKeyPem?: string | undefined } + available: true } } }> @@ -181,30 +125,30 @@ export declare const appRouter: import("@trpc/server/unstable-core-do-not-import { create: import("@trpc/server").TRPCMutationProcedure<{ input: { - status: "success" | "unknown" | "error" | "active" | "cancelled" startedAt: Date completedAt: Date | null + status: "success" | "unknown" | "error" | "active" | "cancelled" clientVersion: string command: string - gitCommitHash: string | null gitRepositoryUrl: string | null gitBranchName: string | null + gitCommitHash: string | null gitIsDirty: boolean | null } output: { id: string - createdAt: Date - updatedAt: Date - status: "success" | "unknown" | "error" | "active" | "cancelled" accountId: string organizationId: string startedAt: Date completedAt: Date + status: "success" | "unknown" | "error" | "active" | "cancelled" + createdAt: Date + updatedAt: Date clientVersion: string command: string - gitCommitHash: string gitRepositoryUrl: string gitBranchName: string + gitCommitHash: string gitIsDirty: boolean } }> @@ -215,36 +159,36 @@ export declare const appRouter: import("@trpc/server/unstable-core-do-not-import output: { commandRun: { id: string - createdAt: Date - updatedAt: Date - status: "success" | "unknown" | "error" | "active" | "cancelled" accountId: string organizationId: string startedAt: Date completedAt: Date + status: "success" | "unknown" | "error" | "active" | "cancelled" + createdAt: Date + updatedAt: Date clientVersion: string command: string - gitCommitHash: string gitRepositoryUrl: string gitBranchName: string + gitCommitHash: string gitIsDirty: boolean } actionRuns: { id: string - createdAt: Date - updatedAt: Date startedAt: Date completedAt: Date | null - commandRunId: string + createdAt: Date + updatedAt: Date + durationMs: number | null actionUid: string actionName: string actionType: string actionVersion: string actionVersionResolved: string | null - actionState: "unknown" | "failed" | "getting-status" | "cached" | "not-ready" | "processing" | "ready" + actionState: "unknown" | "cached" | "getting-status" | "not-ready" | "processing" | "failed" | "ready" actionOutputs: Record force: boolean - durationMs: number | null + commandRunId: string }[] } }> @@ -263,18 +207,18 @@ export declare const appRouter: import("@trpc/server/unstable-core-do-not-import output: { items: { id: string - createdAt: Date - updatedAt: Date - status: "success" | "unknown" | "error" | "active" | "cancelled" accountId: string organizationId: string startedAt: Date completedAt: Date + status: "success" | "unknown" | "error" | "active" | "cancelled" + createdAt: Date + updatedAt: Date clientVersion: string command: string - gitCommitHash: string gitRepositoryUrl: string gitBranchName: string + gitCommitHash: string gitIsDirty: boolean }[] nextCursor: number | undefined @@ -311,9 +255,10 @@ export declare const appRouter: import("@trpc/server/unstable-core-do-not-import { create: import("@trpc/server").TRPCMutationProcedure<{ input: { - status: "success" | "failure" startedAt: Date completedAt: Date + status: "success" | "failure" + platforms: string[] runtime: { actual: "buildx" | "cloud-builder" | "garden-k8s-kaniko" | "garden-k8s-buildkit" preferred?: @@ -334,7 +279,7 @@ export declare const appRouter: import("@trpc/server/unstable-core-do-not-import driver: string } } - dockerLogs: Record[] | undefined>[] + dockerLogs?: unknown[] | undefined dockerMetadata?: | import("zod").objectInputType< { @@ -348,29 +293,7 @@ export declare const appRouter: import("@trpc/server/unstable-core-do-not-import | undefined } output: { - id: string - createdAt: Date - updatedAt: Date - status: "success" | "failure" - accountId: string - organizationId: string - startedAt: Date - completedAt: Date | null - dockerRawjsonLogs: { - [x: string]: unknown - } - dockerMetadata: { - [x: string]: unknown - } - tags: string[] | null - imageManifestDigest: string | null - buildxBuildRef: string | null - executedVertexDigests: string[] - cachedVertexDigests: string[] - secondsSaved: number | null - actualRuntime: string - preferredRuntime: string | null - fallbackReason: string | null + timeSaved: number | undefined } }> list: import("@trpc/server").TRPCQueryProcedure<{ @@ -382,23 +305,27 @@ export declare const appRouter: import("@trpc/server/unstable-core-do-not-import output: { items: { id: string - createdAt: Date - updatedAt: Date - status: "success" | "failure" accountId: string organizationId: string accountName: string startedAt: Date - dockerRawjsonLogs: Record + status: "success" | "failure" actualRuntime: string + createdAt: Date + updatedAt: Date completedAt?: Date | null | undefined - dockerMetadata?: Record | null | undefined + platforms?: string[] | null | undefined + dockerClientVersion?: string | null | undefined + dockerServerVersion?: string | null | undefined + builderImplicitName?: string | null | undefined + builderIsDefault?: boolean | null | undefined + builderDriver?: string | null | undefined tags?: string[] | null | undefined imageManifestDigest?: string | null | undefined buildxBuildRef?: string | null | undefined executedVertexDigests?: string[] | null | undefined cachedVertexDigests?: string[] | null | undefined - secondsSaved?: number | null | undefined + timeSavedMs?: number | null | undefined preferredRuntime?: string | null | undefined fallbackReason?: string | null | undefined }[] @@ -411,23 +338,27 @@ export declare const appRouter: import("@trpc/server/unstable-core-do-not-import } output: { id: string - createdAt: Date - updatedAt: Date - status: "success" | "failure" accountId: string organizationId: string accountName: string startedAt: Date - dockerRawjsonLogs: Record + status: "success" | "failure" actualRuntime: string + createdAt: Date + updatedAt: Date completedAt?: Date | null | undefined - dockerMetadata?: Record | null | undefined + platforms?: string[] | null | undefined + dockerClientVersion?: string | null | undefined + dockerServerVersion?: string | null | undefined + builderImplicitName?: string | null | undefined + builderIsDefault?: boolean | null | undefined + builderDriver?: string | null | undefined tags?: string[] | null | undefined imageManifestDigest?: string | null | undefined buildxBuildRef?: string | null | undefined executedVertexDigests?: string[] | null | undefined cachedVertexDigests?: string[] | null | undefined - secondsSaved?: number | null | undefined + timeSavedMs?: number | null | undefined preferredRuntime?: string | null | undefined fallbackReason?: string | null | undefined } @@ -490,8 +421,9 @@ export declare const appRouter: import("@trpc/server/unstable-core-do-not-import name: "deployStatus" timestamp: string payload: { + startedAt: Date status: { - state: "unknown" | "failed" | "getting-status" | "cached" | "not-ready" | "processing" | "ready" + state: "unknown" | "cached" | "getting-status" | "not-ready" | "processing" | "failed" | "ready" ingresses?: | { path: string @@ -502,18 +434,17 @@ export declare const appRouter: import("@trpc/server/unstable-core-do-not-import }[] | undefined } - startedAt: Date actionUid: string actionName: string actionType: string actionVersion: string actionState: | "unknown" - | "failed" - | "getting-status" | "cached" + | "getting-status" | "not-ready" | "processing" + | "failed" | "ready" actionOutputs: {} force: boolean @@ -528,21 +459,21 @@ export declare const appRouter: import("@trpc/server/unstable-core-do-not-import name: "runStatus" timestamp: string payload: { + startedAt: Date status: { state: "unknown" | "failed" | "outdated" | "running" | "succeeded" | "not-implemented" } - startedAt: Date actionUid: string actionName: string actionType: string actionVersion: string actionState: | "unknown" - | "failed" - | "getting-status" | "cached" + | "getting-status" | "not-ready" | "processing" + | "failed" | "ready" actionOutputs: {} force: boolean @@ -579,7 +510,7 @@ export declare const appRouter: import("@trpc/server/unstable-core-do-not-import rawMsg: string | null dataFormat: "json" | "yaml" | null } - level: "debug" | "info" | "warn" | "error" | "verbose" | "silly" + level: "error" | "debug" | "info" | "warn" | "verbose" | "silly" timestamp: string key: string actionUid: string | null @@ -594,7 +525,7 @@ export declare const appRouter: import("@trpc/server/unstable-core-do-not-import cursor?: number | undefined perPage?: number | undefined section?: string | undefined - logLevels?: ("debug" | "info" | "warn" | "error" | "verbose" | "silly")[] | undefined + logLevels?: ("error" | "debug" | "info" | "warn" | "verbose" | "silly")[] | undefined } output: { items: { @@ -606,7 +537,7 @@ export declare const appRouter: import("@trpc/server/unstable-core-do-not-import rawMsg: string | null dataFormat: "json" | "yaml" | null } - level: "debug" | "info" | "warn" | "error" | "verbose" | "silly" + level: "error" | "debug" | "info" | "warn" | "verbose" | "silly" timestamp: string key: string actionUid: string | null @@ -628,7 +559,7 @@ export declare const appRouter: import("@trpc/server/unstable-core-do-not-import rawMsg: string | null dataFormat: "json" | "yaml" | null } - level: "debug" | "info" | "warn" | "error" | "verbose" | "silly" + level: "error" | "debug" | "info" | "warn" | "verbose" | "silly" timestamp: string key: string actionUid: string | null @@ -659,8 +590,8 @@ export declare const appRouter: import("@trpc/server/unstable-core-do-not-import refreshToken: string } output: { - refreshToken: string accessToken: string + refreshToken: string tokenValidity: number } }> @@ -677,11 +608,11 @@ export declare const appRouter: import("@trpc/server/unstable-core-do-not-import label: string } output: { + accountId: string + value: string + type: "access" | "refresh" | "web" createdAt: Date updatedAt: Date - type: "access" | "refresh" | "web" - value: string - accountId: string expiresAt: Date label: string | null } @@ -700,11 +631,11 @@ export declare const appRouter: import("@trpc/server/unstable-core-do-not-import } output: { items: { + accountId: string + value: string + type: "access" | "refresh" | "web" createdAt: Date updatedAt: Date - type: "access" | "refresh" | "web" - value: string - accountId: string expiresAt: Date label: string | null }[] diff --git a/core/src/cloud/grow/trpc.ts b/core/src/cloud/grow/trpc.ts index 6fd3b489de..5b69a7d38a 100644 --- a/core/src/cloud/grow/trpc.ts +++ b/core/src/cloud/grow/trpc.ts @@ -46,6 +46,8 @@ class GrowCloudError extends GardenError implements TRPCClientErrorBase { export type RouterOutput = inferRouterOutputs export type RouterInput = inferRouterInputs +export type DockerBuildReport = RouterInput["dockerBuild"]["create"] + export const errorLogger: TRPCLink = () => { return ({ next, op }) => { return observable((observer) => { diff --git a/core/src/plugins/container/build.ts b/core/src/plugins/container/build.ts index c0be6732ef..6f616e26ad 100644 --- a/core/src/plugins/container/build.ts +++ b/core/src/plugins/container/build.ts @@ -25,11 +25,18 @@ import { import type { Writable } from "stream" import type { ActionLog } from "../../logger/log-entry.js" import type { PluginContext } from "../../plugin-context.js" -import { cloudBuilder } from "./cloudbuilder.js" +import { cloudBuilder, sendBuildReport } from "./cloudbuilder.js" import { styles } from "../../logger/styles.js" import type { CloudBuilderAvailableV2 } from "../../cloud/api.js" -import type { SpawnOutput } from "../../util/util.js" +import { spawn, type SpawnOutput } from "../../util/util.js" import { isSecret, type Secret } from "../../util/secrets.js" +import { tmpdir } from "os" +import { join } from "path" +import { mkdtemp, readFile } from "fs/promises" +import type { DockerBuildReport } from "../../cloud/grow/trpc.js" +import type { ActionRuntime } from "../../plugin/base.js" +import { formatDuration, intervalToDuration } from "date-fns" +import { gardenEnv } from "../../constants.js" export const validateContainerBuild: BuildActionHandler<"validate", ContainerBuildAction> = async ({ action }) => { // configure concurrency limit for build status task nodes. @@ -92,25 +99,30 @@ export const buildContainer: BuildActionHandler<"build", ContainerBuildAction> = level: "verbose" as const, } + // TODO: use go tool to transform rawjson logs into human readable logs + const dockerLogs: DockerBuildReport["dockerLogs"] = [] const outputStream = split2() outputStream.on("error", () => {}) outputStream.on("data", (line: Buffer) => { + dockerLogs.push(JSON.parse(line.toString())) ctx.events.emit("log", { timestamp: new Date().toISOString(), msg: line.toString(), ...logEventContext }) }) const timeout = action.getConfig("timeout") - let res: SpawnOutput - + let res: { buildResult: SpawnOutput; timeSaved: number } const availability = await cloudBuilder.getAvailability(ctx, action) + const runtime = cloudBuilder.getActionRuntime(ctx, availability) if (availability.available) { - res = await buildContainerInCloudBuilder({ action, availability, outputStream, timeout, log, ctx }) + res = await buildContainerInCloudBuilder({ action, availability, outputStream, timeout, log, ctx, dockerLogs }) } else { - res = await buildContainerLocally({ + res = await buildxBuildContainer({ action, outputStream, timeout, log, ctx, + runtime, + dockerLogs, }) } @@ -119,9 +131,9 @@ export const buildContainer: BuildActionHandler<"build", ContainerBuildAction> = outputs, detail: { fresh: true, - buildLog: res.all || "", + buildLog: res.buildResult.all || "", outputs, - runtime: cloudBuilder.getActionRuntime(ctx, availability), + runtime, details: { identifier, }, @@ -129,13 +141,15 @@ export const buildContainer: BuildActionHandler<"build", ContainerBuildAction> = } } -async function buildContainerLocally({ +async function buildxBuildContainer({ action, outputStream, timeout, log, ctx, extraDockerOpts = [], + runtime, + dockerLogs, }: { action: Resolved outputStream: Writable @@ -143,7 +157,9 @@ async function buildContainerLocally({ log: ActionLog ctx: PluginContext extraDockerOpts?: string[] -}) { + runtime: ActionRuntime + dockerLogs: DockerBuildReport["dockerLogs"] +}): Promise<{ buildResult: SpawnOutput; timeSaved: number }> { const spec = action.getSpec() const outputs = action.getOutputs() const buildPath = action.getBuildPath() @@ -152,10 +168,17 @@ async function buildContainerLocally({ const dockerfilePath = joinWithPosix(buildPath, spec.dockerfile) - const dockerFlags = [...getDockerBuildFlags(action, ctx.provider.config), ...extraDockerOpts] + const tmpDir = await mkdtemp(join(tmpdir(), `garden-build-${action.uid.slice(0, 5)}`)) + const metadataFile = join(tmpDir, "metadata-file.json") + + const internalDockerFlags = ["--progress", "rawjson", "--metadata-file", metadataFile] + + const dockerFlags = [...getDockerBuildFlags(action, ctx.provider.config), ...extraDockerOpts, ...internalDockerFlags] const { secretArgs, secretEnvVars } = getDockerSecrets(action.getSpec()) dockerFlags.push(...secretArgs) + const buildxEnvVars = { BUILDX_METADATA_PROVENANCE: "max", BUILDX_METADATA_WARNINGS: "1" } + const dockerEnvVars = { ...secretEnvVars, ...buildxEnvVars } // If there already is a --tag flag, another plugin like the Kubernetes plugin already decided how to tag the image. // In this case, we don't want to add another local tag. @@ -168,10 +191,10 @@ async function buildContainerLocally({ dockerFlags.push(...["--tag", outputs.deploymentImageId]) } } - + const startedAt = new Date() const cmdOpts = ["buildx", "build", ...dockerFlags, "--file", dockerfilePath] try { - return await containerHelpers.dockerCli({ + const res = await containerHelpers.dockerCli({ cwd: buildPath, args: [...cmdOpts, buildPath], log, @@ -179,8 +202,47 @@ async function buildContainerLocally({ stderr: outputStream, timeout, ctx, - env: secretEnvVars, + env: dockerEnvVars, }) + + if (gardenEnv.USE_GARDEN_CLOUD_V2) { + try { + const dockerMetadata = await getDockerMetadata(metadataFile) + const { client, server } = await containerHelpers.getDockerVersion() + + const builderName = getBuilderName(dockerMetadata) + const driver = await getBuildxDriver(builderName, log, ctx) + const output = await sendBuildReport( + { + runtime: cloudBuilder.transformRuntime(runtime), + status: res.code === 0 ? "success" : "failure", + startedAt, + completedAt: new Date(), + runtimeMetadata: { + docker: { + clientVersion: client || "unknown", + serverVersion: server || "unknown", + }, + builder: { + implicitName: builderName, + isDefault: false, //TODO + driver, + }, + }, + platforms: await getPlatforms(cmdOpts), + dockerLogs, + dockerMetadata, + }, + ctx + ) + return { buildResult: res, timeSaved: output?.timeSaved || 0 } + } catch (e: unknown) { + throw toGardenError({ + message: `Failed to send build report: ${e}`, + }) + } + } + return { buildResult: res, timeSaved: 0 } } catch (e) { const error = toGardenError(e) if (error.message.includes("docker exporter does not currently support exporting manifest lists")) { @@ -214,9 +276,6 @@ async function buildContainerLocally({ } } -const BUILDKIT_LAYER_REGEX = /^#[0-9]+ \[[^ ]+ +[0-9]+\/[0-9]+\] [^F][^R][^O][^M]/ -const BUILDKIT_LAYER_CACHED_REGEX = /^#[0-9]+ CACHED/ - async function buildContainerInCloudBuilder(params: { action: Resolved availability: CloudBuilderAvailableV2 @@ -224,21 +283,9 @@ async function buildContainerInCloudBuilder(params: { timeout: number log: ActionLog ctx: PluginContext + dockerLogs: DockerBuildReport["dockerLogs"] }) { - const cloudBuilderStats = { - totalLayers: 0, - layersCached: 0, - } - - // get basic buildkit stats - params.outputStream.on("data", (line: Buffer) => { - const logLine = line.toString() - if (BUILDKIT_LAYER_REGEX.test(logLine)) { - cloudBuilderStats.totalLayers++ - } else if (BUILDKIT_LAYER_CACHED_REGEX.test(logLine)) { - cloudBuilderStats.layersCached++ - } - }) + const runtime = cloudBuilder.getActionRuntime(params.ctx, params.availability) const res = await cloudBuilder.withBuilder(params.ctx, params.availability, async (builderName) => { const extraDockerOpts = ["--builder", builderName] @@ -250,19 +297,46 @@ async function buildContainerInCloudBuilder(params: { extraDockerOpts.push("--load") } - return await buildContainerLocally({ ...params, extraDockerOpts }) + return await buildxBuildContainer({ ...params, extraDockerOpts, runtime, dockerLogs: params.dockerLogs }) }) const log = params.ctx.log.createLog({ name: `build.${params.action.name}`, }) - log.success( - `${styles.bold("Accelerated by Garden Cloud Builder")} (${cloudBuilderStats.layersCached}/${cloudBuilderStats.totalLayers} layers cached)` - ) - + if (res.timeSaved > 0) { + log.success(`${styles.bold("Accelerated by Garden Cloud Builder - saved", formatDurationMs(res.timeSaved))}`) + } return res } +function formatDurationMs(durationMs: number) { + if (durationMs < 1000) { + return `${durationMs} ms` + } + const duration = intervalToDuration({ + start: new Date(0, 0, 0, 0, 0, 0, 0), + end: new Date(0, 0, 0, 0, 0, 0, durationMs), + }) + return formatDuration(duration, { format: ["hours", "minutes", "seconds"] }) +} + +async function getDockerMetadata(filePath: string) { + try { + return JSON.parse(await readFile(filePath, { encoding: "utf-8" })) + } catch (e: unknown) { + throw toGardenError({ + message: `Failed to read docker metadata file: ${e}`, + }) + } +} + +function getBuilderName(dockerMetadata: DockerBuildReport["dockerMetadata"]) { + if (typeof dockerMetadata?.["buildx.build.ref"] === "string") { + return dockerMetadata["buildx.build.ref"].split("/")[0] + } + return "unknown" +} + export function getContainerBuildActionOutputs(action: Resolved): ContainerBuildOutputs { return containerHelpers.getBuildActionOutputs(action, undefined) } @@ -304,6 +378,32 @@ export function getDockerSecrets(actionSpec: ContainerBuildActionSpec): { } } +async function getBuildxDriver(builderName: string, log: ActionLog, ctx: PluginContext): Promise { + if (builderName === "unknown") { + return "unknown" + } + if (builderName.startsWith("garden-cloud-builder")) { + return "remote" + } + const parsedBuilderInfo: { Name: string; Driver: string }[] = [] + const outputStream = split2() + outputStream.on("data", (line: Buffer) => { + try { + parsedBuilderInfo.push(JSON.parse(line.toString())) + } catch (e) { + log.debug(`Failed to parse buildx builder info: ${e}`) + } + }) + await containerHelpers.dockerCli({ + cwd: ".", + stdout: outputStream, + log, + ctx, + args: ["buildx", "ls", "--format", "json"], + }) + return parsedBuilderInfo.find((builder) => builder.Name === builderName)?.Driver || "unknown" +} + export function getDockerBuildFlags( action: Resolved, containerProviderConfig: ContainerProviderConfig @@ -354,3 +454,49 @@ export function getDockerBuildArgs(version: string, specBuildArgs: PrimitiveMap) }) .filter((x): x is string => !!x) } + +// Map of architecture names to Docker platform names +// see https://github.com/BretFisher/multi-platform-docker-build +const architectureMap: Record = { + "x86_64": "amd64", + "x86-64": "amd64", + "aarch64": "arm64", + "armhf": "arm", + "armel": "arm/v6", + "i386": "386", +} + +async function getPlatforms(cmdOpts: string[]): Promise { + const platforms: string[] = [] + for (let i = 0; i < cmdOpts.length; i++) { + if (cmdOpts[i] === "--platform") { + const platform = cmdOpts[i + 1] + if (platform === undefined) { + throw new ConfigurationError({ + message: "Missing platform after --platform flag", + }) + } + platforms.push(platform) + } + } + // no platforms specified, defaults to docker server's platform + if (platforms.length === 0) { + const osTypeResult = await spawn("docker", ["system", "info", "--format", "{{.OSType}}"]) + const osType = osTypeResult.stdout.trim() + const archResult = await spawn("docker", ["system", "info", "--format", "{{.Architecture}}"]) + let arch = archResult.stdout.trim() + + // docker system info does not always return the same architecure name as used for the platform flag + // see https://github.com/BretFisher/multi-platform-docker-build + if (!Object.values(architectureMap).includes(arch)) { + arch = architectureMap[arch] || "" + } + if (arch === "") { + throw new ConfigurationError({ + message: `Unsupported architecture ${arch}`, + }) + } + platforms.push(`${osType}/${arch}`) + } + return platforms +} diff --git a/core/src/plugins/container/cloudbuilder.ts b/core/src/plugins/container/cloudbuilder.ts index dfd0e4ebfd..7766f41ca5 100644 --- a/core/src/plugins/container/cloudbuilder.ts +++ b/core/src/plugins/container/cloudbuilder.ts @@ -8,7 +8,7 @@ import type { PluginContext } from "../../plugin-context.js" import type { Resolved } from "../../actions/types.js" import type { ContainerBuildAction } from "./config.js" -import { ConfigurationError, InternalError, isErrnoException } from "../../exceptions.js" +import { ConfigurationError, InternalError, isErrnoException, toGardenError } from "../../exceptions.js" import type { ContainerProvider, ContainerProviderConfig } from "./container.js" import dedent from "dedent" import { styles } from "../../logger/styles.js" @@ -35,7 +35,7 @@ import { stableStringify } from "../../util/string.js" import { homedir } from "os" import { getCloudDistributionName, isGardenCommunityEdition } from "../../cloud/util.js" import { TRPCClientError } from "@trpc/client" -import type { GrowCloudBuilderRegisterBuildResponse } from "../../cloud/grow/trpc.js" +import type { DockerBuildReport, GrowCloudBuilderRegisterBuildResponse } from "../../cloud/grow/trpc.js" import type { GrowCloudApi } from "../../cloud/grow/api.js" const { mkdirp, rm, writeFile, stat } = fsExtra @@ -110,6 +110,18 @@ function makeVersionMismatchWarning({ isInClusterBuildingConfigured }: CloudBuil Run ${styles.command("garden self-update")} to update Garden to the latest version.` } +export async function sendBuildReport(dockerBuildReport: DockerBuildReport, ctx: PluginContext) { + try { + const growCloudApi = ctx.cloudApiV2 as GrowCloudApi + return await growCloudApi.api.dockerBuild.create.mutate(dockerBuildReport) + } catch (err) { + throw toGardenError({ + message: `Failed to send Docker build report ${err instanceof TRPCClientError ? err.message : ""}`, + cause: err, + }) + } +} + type CloudApi = GardenCloudApi | GrowCloudApi type RegisterCloudBuildParams = { action: Resolved @@ -268,6 +280,21 @@ class CloudBuilder { return availability } + transformRuntime(runtime: ActionRuntime): DockerBuildReport["runtime"] { + const { actual, preferred, fallbackReason } = runtime + let actualNewFormat: DockerBuildReport["runtime"]["actual"] = "buildx" + if (actual.kind === "remote") { + actualNewFormat = actual.type === "garden-cloud" ? "cloud-builder" : "buildx" + } + if (preferred && preferred.kind === "remote" && preferred.type) { + return { + actual: actualNewFormat, + preferred: { runtime: preferred.type === "garden-cloud" ? "cloud-builder" : "buildx", fallbackReason }, + } + } + return { actual: actualNewFormat } + } + getActionRuntime(ctx: PluginContext, availability: CloudBuilderAvailabilityV2): ActionRuntime { const { isCloudBuilderEnabled, isInClusterBuildingConfigured } = getConfiguration(ctx) From 737ecffb4891bd2e7475f993cd7da51ccf5f7af0 Mon Sep 17 00:00:00 2001 From: Anna Mager Date: Wed, 15 Jan 2025 15:25:44 +0100 Subject: [PATCH 2/4] chore: error handling --- core/src/plugins/container/build.ts | 35 ++++++++++++++++------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/core/src/plugins/container/build.ts b/core/src/plugins/container/build.ts index 6f616e26ad..128645f6c3 100644 --- a/core/src/plugins/container/build.ts +++ b/core/src/plugins/container/build.ts @@ -385,23 +385,26 @@ async function getBuildxDriver(builderName: string, log: ActionLog, ctx: PluginC if (builderName.startsWith("garden-cloud-builder")) { return "remote" } - const parsedBuilderInfo: { Name: string; Driver: string }[] = [] - const outputStream = split2() - outputStream.on("data", (line: Buffer) => { - try { + + try { + const parsedBuilderInfo: { Name: string; Driver: string }[] = [] + const outputStream = split2() + outputStream.on("data", (line: Buffer) => { parsedBuilderInfo.push(JSON.parse(line.toString())) - } catch (e) { - log.debug(`Failed to parse buildx builder info: ${e}`) - } - }) - await containerHelpers.dockerCli({ - cwd: ".", - stdout: outputStream, - log, - ctx, - args: ["buildx", "ls", "--format", "json"], - }) - return parsedBuilderInfo.find((builder) => builder.Name === builderName)?.Driver || "unknown" + }) + await containerHelpers.dockerCli({ + cwd: ".", + stdout: outputStream, + log, + ctx, + args: ["buildx", "ls", "--format", "json"], + }) + return parsedBuilderInfo.find((builder) => builder.Name === builderName)?.Driver || "unknown" + } catch (e) { + throw toGardenError({ + message: `Failed to get buildx driver info: ${e}`, + }) + } } export function getDockerBuildFlags( From 88f942dccc4ecdb730e2527798831b7f5dde24b7 Mon Sep 17 00:00:00 2001 From: Anna Mager Date: Wed, 15 Jan 2025 16:14:52 +0100 Subject: [PATCH 3/4] chore: fix unit tests --- core/test/unit/src/plugins/container/build.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/core/test/unit/src/plugins/container/build.ts b/core/test/unit/src/plugins/container/build.ts index fc146efe63..8e30393558 100644 --- a/core/test/unit/src/plugins/container/build.ts +++ b/core/test/unit/src/plugins/container/build.ts @@ -148,6 +148,10 @@ context("build.ts", () => { `GARDEN_MODULE_VERSION=${action.versionString()}`, "--build-arg", `GARDEN_ACTION_VERSION=${action.versionString()}`, + "--progress", + "rawjson", + "--metadata-file", + "/tmp/a-unique-path/metadata.json", "--tag", "some/image", "--file", @@ -167,6 +171,9 @@ context("build.ts", () => { const cmdArgs = getCmdArgs(action, buildPath) sinon.replace(containerHelpers, "dockerCli", async ({ cwd, args, ctx: _ctx }) => { expect(cwd).to.equal(buildPath) + // metadata.json is always at a unique path - we need to replace the filename for the assertion + const idx = args.indexOf("--metadata-file") + 1 + args[idx] = "/tmp/a-unique-path/metadata.json" expect(args).to.eql(cmdArgs) expect(_ctx).to.exist return { all: "log", stdout: "", stderr: "", code: 0, proc: null } @@ -192,6 +199,9 @@ context("build.ts", () => { const cmdArgs = getCmdArgs(action, buildPath) sinon.replace(containerHelpers, "dockerCli", async ({ cwd, args, ctx: _ctx }) => { expect(cwd).to.equal(buildPath) + // metadata.json is always at a unique path - we need to replace the filename for the assertion + const idx = args.indexOf("--metadata-file") + 1 + args[idx] = "/tmp/a-unique-path/metadata.json" expect(args).to.eql(cmdArgs) expect(_ctx).to.exist return { all: "log", stdout: "", stderr: "", code: 0, proc: null } From 4d098b24faf9f719c8da671a7b5de102b4bebd3a Mon Sep 17 00:00:00 2001 From: Anna Mager Date: Wed, 22 Jan 2025 16:04:06 +0100 Subject: [PATCH 4/4] chore: send useful build reports on failed builds --- core/src/cloud/grow/trpc-schema.ts | 1 + core/src/constants.ts | 2 +- core/src/plugins/container/build.ts | 162 ++++++++++++++------- core/src/plugins/container/cloudbuilder.ts | 14 +- 4 files changed, 111 insertions(+), 68 deletions(-) diff --git a/core/src/cloud/grow/trpc-schema.ts b/core/src/cloud/grow/trpc-schema.ts index 6f401e86ef..91a2c55266 100644 --- a/core/src/cloud/grow/trpc-schema.ts +++ b/core/src/cloud/grow/trpc-schema.ts @@ -258,6 +258,7 @@ export declare const appRouter: import("@trpc/server/unstable-core-do-not-import startedAt: Date completedAt: Date status: "success" | "failure" + imageTags: string[] platforms: string[] runtime: { actual: "buildx" | "cloud-builder" | "garden-k8s-kaniko" | "garden-k8s-buildkit" diff --git a/core/src/constants.ts b/core/src/constants.ts index a3d6815757..7d4d1f78ab 100644 --- a/core/src/constants.ts +++ b/core/src/constants.ts @@ -53,7 +53,7 @@ export const SEGMENT_PROD_API_KEY = "b6ovUD9A0YjQqT3ZWetWUbuZ9OmGxKMa" // ggigno export const DOCS_BASE_URL = "https://docs.garden.io" export const DEFAULT_GARDEN_CLOUD_DOMAIN = "https://app.garden.io" -export const DEFAULT_GROW_CLOUD_DOMAIN = "https://grow.staging.sys.garden" +export const DEFAULT_GROW_CLOUD_DOMAIN = "https://grow-anna.dev.enterprise.garden.io" export const DEFAULT_BROWSER_DIVIDER_WIDTH = 80 diff --git a/core/src/plugins/container/build.ts b/core/src/plugins/container/build.ts index 128645f6c3..0d070b72da 100644 --- a/core/src/plugins/container/build.ts +++ b/core/src/plugins/container/build.ts @@ -7,6 +7,7 @@ */ import { containerHelpers } from "./helpers.js" +import type { GardenError } from "../../exceptions.js" import { ConfigurationError, InternalError, toGardenError } from "../../exceptions.js" import type { PrimitiveMap } from "../../config/common.js" import split2 from "split2" @@ -25,7 +26,7 @@ import { import type { Writable } from "stream" import type { ActionLog } from "../../logger/log-entry.js" import type { PluginContext } from "../../plugin-context.js" -import { cloudBuilder, sendBuildReport } from "./cloudbuilder.js" +import { cloudBuilder } from "./cloudbuilder.js" import { styles } from "../../logger/styles.js" import type { CloudBuilderAvailableV2 } from "../../cloud/api.js" import { spawn, type SpawnOutput } from "../../util/util.js" @@ -37,6 +38,7 @@ import type { DockerBuildReport } from "../../cloud/grow/trpc.js" import type { ActionRuntime } from "../../plugin/base.js" import { formatDuration, intervalToDuration } from "date-fns" import { gardenEnv } from "../../constants.js" +import type { GrowCloudApi } from "../../cloud/grow/api.js" export const validateContainerBuild: BuildActionHandler<"validate", ContainerBuildAction> = async ({ action }) => { // configure concurrency limit for build status task nodes. @@ -193,8 +195,10 @@ async function buildxBuildContainer({ } const startedAt = new Date() const cmdOpts = ["buildx", "build", ...dockerFlags, "--file", dockerfilePath] + let res: SpawnOutput = { all: "", stdout: "", stderr: "", code: 1, proc: null } + let buildError: GardenError | null = null try { - const res = await containerHelpers.dockerCli({ + res = await containerHelpers.dockerCli({ cwd: buildPath, args: [...cmdOpts, buildPath], log, @@ -204,57 +208,18 @@ async function buildxBuildContainer({ ctx, env: dockerEnvVars, }) - - if (gardenEnv.USE_GARDEN_CLOUD_V2) { - try { - const dockerMetadata = await getDockerMetadata(metadataFile) - const { client, server } = await containerHelpers.getDockerVersion() - - const builderName = getBuilderName(dockerMetadata) - const driver = await getBuildxDriver(builderName, log, ctx) - const output = await sendBuildReport( - { - runtime: cloudBuilder.transformRuntime(runtime), - status: res.code === 0 ? "success" : "failure", - startedAt, - completedAt: new Date(), - runtimeMetadata: { - docker: { - clientVersion: client || "unknown", - serverVersion: server || "unknown", - }, - builder: { - implicitName: builderName, - isDefault: false, //TODO - driver, - }, - }, - platforms: await getPlatforms(cmdOpts), - dockerLogs, - dockerMetadata, - }, - ctx - ) - return { buildResult: res, timeSaved: output?.timeSaved || 0 } - } catch (e: unknown) { - throw toGardenError({ - message: `Failed to send build report: ${e}`, - }) - } - } - return { buildResult: res, timeSaved: 0 } } catch (e) { - const error = toGardenError(e) - if (error.message.includes("docker exporter does not currently support exporting manifest lists")) { - throw new ConfigurationError({ + buildError = toGardenError(e) + if (buildError.message.includes("docker exporter does not currently support exporting manifest lists")) { + buildError = new ConfigurationError({ message: dedent` Your local docker image store does not support loading multi-platform images. If you are using Docker Desktop, you can turn on the experimental containerd image store. Learn more at https://docs.docker.com/go/build-multi-platform/ `, }) - } else if (error.message.includes("Multi-platform build is not supported for the docker driver")) { - throw new ConfigurationError({ + } else if (buildError.message.includes("Multi-platform build is not supported for the docker driver")) { + buildError = new ConfigurationError({ message: dedent` Your local docker daemon does not support building multi-platform images. If you are using Docker Desktop, you can turn on the experimental containerd image store. @@ -263,8 +228,8 @@ async function buildxBuildContainer({ Learn more at https://docs.docker.com/go/build-multi-platform/ `, }) - } else if (error.message.includes("failed to push")) { - throw new ConfigurationError({ + } else if (buildError.message.includes("failed to push")) { + buildError = new ConfigurationError({ message: dedent` The Docker daemon failed to push the image to the registry. Please make sure that you are logged in and that you @@ -272,7 +237,25 @@ async function buildxBuildContainer({ `, }) } - throw error + } finally { + let timeSaved = 0 + if (gardenEnv.USE_GARDEN_CLOUD_V2) { + const output = await sendBuildReport({ + metadataFile, + cmdOpts, + startedAt, + dockerLogs, + dockerCommandResult: res, + runtime, + ctx, + log, + }) + timeSaved = output?.timeSaved || 0 + } + if (buildError !== null) { + throw buildError + } + return { buildResult: res, timeSaved } } } @@ -320,18 +303,17 @@ function formatDurationMs(durationMs: number) { return formatDuration(duration, { format: ["hours", "minutes", "seconds"] }) } -async function getDockerMetadata(filePath: string) { +async function getDockerMetadata(filePath: string, log: ActionLog) { try { return JSON.parse(await readFile(filePath, { encoding: "utf-8" })) } catch (e: unknown) { - throw toGardenError({ - message: `Failed to read docker metadata file: ${e}`, - }) + log.debug(`Failed to read docker metadata file: ${e}`) + return undefined } } function getBuilderName(dockerMetadata: DockerBuildReport["dockerMetadata"]) { - if (typeof dockerMetadata?.["buildx.build.ref"] === "string") { + if (dockerMetadata && typeof dockerMetadata?.["buildx.build.ref"] === "string") { return dockerMetadata["buildx.build.ref"].split("/")[0] } return "unknown" @@ -341,6 +323,61 @@ export function getContainerBuildActionOutputs(action: Resolved @@ -407,6 +444,23 @@ async function getBuildxDriver(builderName: string, log: ActionLog, ctx: PluginC } } +export function getImageTags(dockerMetadata: DockerBuildReport["dockerMetadata"], cmdOpts: string[]) { + const tags: string[] = [] + if (dockerMetadata && dockerMetadata["image.name"]) { + // To be consistent we remove the prefix for the local docker image store from the image name + const localDockerStorePrefix = /^docker.io\/library\// + const parsedTags = dockerMetadata["image.name"].split(",").map((tag) => tag.replace(localDockerStorePrefix, "")) + tags.push(...parsedTags) + } else { + for (let i = 0; i < cmdOpts.length; i++) { + if (cmdOpts[i] === "--tag") { + tags.push(cmdOpts[i + 1]) + } + } + } + return tags +} + export function getDockerBuildFlags( action: Resolved, containerProviderConfig: ContainerProviderConfig diff --git a/core/src/plugins/container/cloudbuilder.ts b/core/src/plugins/container/cloudbuilder.ts index 7766f41ca5..447985a3fd 100644 --- a/core/src/plugins/container/cloudbuilder.ts +++ b/core/src/plugins/container/cloudbuilder.ts @@ -8,7 +8,7 @@ import type { PluginContext } from "../../plugin-context.js" import type { Resolved } from "../../actions/types.js" import type { ContainerBuildAction } from "./config.js" -import { ConfigurationError, InternalError, isErrnoException, toGardenError } from "../../exceptions.js" +import { ConfigurationError, InternalError, isErrnoException } from "../../exceptions.js" import type { ContainerProvider, ContainerProviderConfig } from "./container.js" import dedent from "dedent" import { styles } from "../../logger/styles.js" @@ -110,18 +110,6 @@ function makeVersionMismatchWarning({ isInClusterBuildingConfigured }: CloudBuil Run ${styles.command("garden self-update")} to update Garden to the latest version.` } -export async function sendBuildReport(dockerBuildReport: DockerBuildReport, ctx: PluginContext) { - try { - const growCloudApi = ctx.cloudApiV2 as GrowCloudApi - return await growCloudApi.api.dockerBuild.create.mutate(dockerBuildReport) - } catch (err) { - throw toGardenError({ - message: `Failed to send Docker build report ${err instanceof TRPCClientError ? err.message : ""}`, - cause: err, - }) - } -} - type CloudApi = GardenCloudApi | GrowCloudApi type RegisterCloudBuildParams = { action: Resolved