Skip to content

Commit

Permalink
feat(container): experimental cloudbuilder support (#5928)
Browse files Browse the repository at this point in the history
* feat(container): experimental cloudbuilder support

Experimental cloudbuilder support for test customers.

* fix(tools): only throw exception for unsupported platform when actually using / downloading the tool

* docs(cloudbuilder): add docs in container provider

* fix: tags and add success message

* fix: typo

* docs: update docs

* fix: integ test failure

* fix: cloud builder cleanup should not throw if tmp dir does not exist

* test: add test for namespaceCliSpec

* fix: cloudbuilder stats layer regex

* improvement: use gardenEnv as suggested in code review

* improvement: apply suggestion from code review

* chore: regenerate docs
  • Loading branch information
stefreak committed Apr 16, 2024
1 parent 9ade619 commit 3f28841
Show file tree
Hide file tree
Showing 16 changed files with 728 additions and 102 deletions.
3 changes: 2 additions & 1 deletion core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
"linewrap": "^0.2.1",
"lodash-es": "^4.17.21",
"log-symbols": "^6.0.0",
"lru-cache": "^10.2.0",
"micromatch": "^4.0.5",
"mimic-function": "^5.0.0",
"minimatch": "^7.1.1",
Expand Down Expand Up @@ -275,4 +276,4 @@
"fsevents": "^2.3.3"
},
"gitHead": "b0647221a4d2ff06952bae58000b104215aed922"
}
}
50 changes: 50 additions & 0 deletions core/src/cloud/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -827,6 +827,28 @@ export class CloudApi {
return secrets
}

async registerCloudBuilderBuild(body: {
actionName: string
actionUid: string
coreSessionId: string
}): Promise<RegisterCloudBuilderBuildResponse> {
try {
return await this.post<RegisterCloudBuilderBuildResponse>(`/cloudbuilder/builds/`, {
body,
})
} catch (err) {
return {
data: {
version: "v1",
availability: {
available: false,
reason: `Failed to determine Garden Cloud Builder availability: ${extractErrorMessageBodyFromGotError(err) ?? err}`,
},
},
}
}
}

async createEphemeralCluster(): Promise<EphemeralClusterWithRegistry> {
try {
const response = await this.post<CreateEphemeralClusterResponse>(`/ephemeral-clusters/`)
Expand All @@ -851,3 +873,31 @@ export class CloudApi {
}
}
}

// TODO(cloudbuilder): import these from api-types
type V1RegisterCloudBuilderBuildResponse = {
data: {
version: "v1"
availability: CloudBuilderAvailability
}
}
type UnsupportedRegisterCloudBuilderBuildResponse = {
data: {
version: "unsupported" // using unknown here overpowers the compund type
}
}
type RegisterCloudBuilderBuildResponse =
| V1RegisterCloudBuilderBuildResponse
| UnsupportedRegisterCloudBuilderBuildResponse

type CloudBuilderAvailable = {
available: true
builder: string
token: string
region: "eu" // location of the builder. Currently only eu is supported
}
type CloudBuilderNotAvailable = {
available: false
reason: string
}
export type CloudBuilderAvailability = CloudBuilderAvailable | CloudBuilderNotAvailable
3 changes: 3 additions & 0 deletions core/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,4 +102,7 @@ export const gardenEnv = {
.default("https://get.garden.io/releases")
.asUrlString(),
GARDEN_ENABLE_NEW_SYNC: env.get("GARDEN_ENABLE_NEW_SYNC").required(false).default("false").asBool(),
// GARDEN_CLOUD_BUILDER will always override the config; That's why it doesn't have a default.
// FIXME: If the environment variable is not set, asBool returns undefined, unlike the type suggests. That's why we cast to `boolean | undefined`.
GARDEN_CLOUD_BUILDER: env.get("GARDEN_CLOUD_BUILDER").required(false).asBool() as boolean | undefined,
}
150 changes: 118 additions & 32 deletions core/src/plugins/container/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ import type { Resolved } from "../../actions/types.js"
import dedent from "dedent"
import { splitFirst } from "../../util/string.js"
import type { ContainerProviderConfig } from "./container.js"
import type { Writable } from "stream"
import type { ActionLog } from "../../logger/log-entry.js"
import type { PluginContext } from "../../plugin-context.js"
import type { SpawnOutput } from "../../util/util.js"
import { cloudbuilder } from "./cloudbuilder.js"
import { styles } from "../../logger/styles.js"

export const getContainerBuildStatus: BuildActionHandler<"getStatus", ContainerBuildAction> = async ({
ctx,
Expand All @@ -39,43 +45,21 @@ export const getContainerBuildStatus: BuildActionHandler<"getStatus", ContainerB
export const buildContainer: BuildActionHandler<"build", ContainerBuildAction> = async ({ ctx, action, log }) => {
containerHelpers.checkDockerServerVersion(await containerHelpers.getDockerVersion(), log)

const buildPath = action.getBuildPath()
const spec = action.getSpec()
const outputs = action.getOutputs()
const identifier = outputs.localImageId

const hasDockerfile = await containerHelpers.actionHasDockerfile(action)

// make sure we can build the thing
if (!hasDockerfile) {
throw new ConfigurationError({
message: dedent`
Dockerfile not found at ${spec.dockerfile || defaultDockerfileName} for build ${action.name}.
Dockerfile not found at ${action.getSpec().dockerfile || defaultDockerfileName} for build ${action.name}.
Please make sure the file exists, and is not excluded by include/exclude fields or .gardenignore files.
`,
})
}

const outputs = action.getOutputs()

const identifier = outputs.localImageId

// build doesn't exist, so we create it
log.info(`Building ${identifier}...`)

const dockerfilePath = joinWithPosix(action.getBuildPath(), spec.dockerfile)

const cmdOpts = [
"build",
"-t",
identifier,
...getDockerBuildFlags(action, ctx.provider.config),
"--file",
dockerfilePath,
]

// if deploymentImageId is different from localImageId, tag the image with deploymentImageId as well.
if (outputs.deploymentImageId && identifier !== outputs.deploymentImageId) {
cmdOpts.push(...["-t", outputs.deploymentImageId])
}

const logEventContext = {
origin: "docker build",
level: "verbose" as const,
Expand All @@ -87,21 +71,123 @@ export const buildContainer: BuildActionHandler<"build", ContainerBuildAction> =
ctx.events.emit("log", { timestamp: new Date().toISOString(), msg: line.toString(), ...logEventContext })
})
const timeout = action.getConfig("timeout")
const res = await containerHelpers.dockerCli({
cwd: action.getBuildPath(),

let res: SpawnOutput
if (await cloudbuilder.isConfiguredAndAvailable(ctx, action)) {
res = await buildContainerInCloudBuilder({ action, outputStream, timeout, log, ctx })
} else {
res = await buildContainerLocally({
action,
outputStream,
timeout,
log,
ctx,
})
}

return {
state: "ready",
outputs,
detail: { fresh: true, buildLog: res.all || "", outputs, details: { identifier } },
}
}

async function buildContainerLocally({
action,
outputStream,
timeout,
log,
ctx,
extraDockerOpts = [],
}: {
action: Resolved<ContainerBuildAction>
outputStream: Writable
timeout: number
log: ActionLog
ctx: PluginContext<ContainerProviderConfig>
extraDockerOpts?: string[]
}) {
const spec = action.getSpec()
const outputs = action.getOutputs()
const buildPath = action.getBuildPath()

log.info(`Building ${outputs.localImageId}...`)

const dockerfilePath = joinWithPosix(buildPath, spec.dockerfile)

const dockerFlags = [...getDockerBuildFlags(action, ctx.provider.config), ...extraDockerOpts]

// 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.
// TODO: it would be nice to find a better way to become aware of the parent plugin's concerns in the container plugin.
if (!dockerFlags.includes("--tag")) {
dockerFlags.push(...["--tag", outputs.localImageId])

// if deploymentImageId is different from localImageId, tag the image with deploymentImageId as well.
if (outputs.deploymentImageId && outputs.localImageId !== outputs.deploymentImageId) {
dockerFlags.push(...["--tag", outputs.deploymentImageId])
}
}

const cmdOpts = ["build", ...dockerFlags, "--file", dockerfilePath]

return await containerHelpers.dockerCli({
cwd: buildPath,
args: [...cmdOpts, buildPath],
log,
stdout: outputStream,
stderr: outputStream,
timeout,
ctx,
})
}

return {
state: "ready",
outputs,
detail: { fresh: true, buildLog: res.all || "", outputs, details: { identifier } },
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<ContainerBuildAction>
outputStream: Writable
timeout: number
log: ActionLog
ctx: PluginContext<ContainerProviderConfig>
}) {
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 res = await cloudbuilder.withBuilder(params.ctx, params.action, async (builderName) => {
const extraDockerOpts = ["--builder", builderName]

// we add --push in the Kubernetes local-docker handler when using the Kubernetes plugin with a deploymentRegistry setting.
// If we have --push, no need to --load.
if (!getDockerBuildFlags(params.action, params.ctx.provider.config).includes("--push")) {
// This action makes sure to download the image from the cloud builder, and make it available locally.
extraDockerOpts.push("--load")
}

return await buildContainerLocally({ ...params, extraDockerOpts })
})

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)`
)

return res
}

export function getContainerBuildActionOutputs(action: Resolved<ContainerBuildAction>): ContainerBuildOutputs {
Expand Down
Loading

0 comments on commit 3f28841

Please sign in to comment.