Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(container): experimental cloudbuilder support #5928

Merged
merged 13 commits into from
Apr 16, 2024
Merged
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