Files
pack/recipe.go
zeekay b2bd2df30e feat: zero-config BuildKit gateway frontend for the build fabric
pack is the one packer for the fabric: detect a Go/Node/Python/static
context and emit a runnable OCI image with no Dockerfile. Detection and
LLB generation are pure functions (Detect + Recipe), unit-tested by
marshalling the graph so no buildkitd is needed; build.go is the only
impure code and drives BuildKit through dockerui, mirroring the reference
dockerfile frontend. Pinned to moby/buildkit v0.16.0 to match the fabric
daemon. A Dockerfile (dockerfile.v0) remains the escape hatch.
2026-07-10 09:44:06 -07:00

113 lines
3.5 KiB
Go

package main
import (
"github.com/moby/buildkit/client/llb"
dockerspec "github.com/moby/docker-image-spec/specs-go/v1"
)
// Base images, in one place. Go compiles to a static binary on distroless;
// Node and Python run on their official slim/alpine bases; static HTML is
// served by busybox httpd (no nginx/caddy).
const (
goBuilder = "docker.io/library/golang:1.23-alpine"
goRuntime = "gcr.io/distroless/static-debian12"
nodeImage = "docker.io/library/node:22-alpine"
pythonImage = "docker.io/library/python:3.12-slim"
staticImage = "docker.io/library/busybox:stable"
)
// Recipe is the concrete build for a Plan: the final rootfs graph plus the
// runtime config that turns it into a runnable image.
type Recipe struct {
State llb.State // final rootfs
Base string // runtime image whose config is inherited
Entrypoint []string
WorkDir string
Ports []string
}
// Recipe turns a Plan and the build context into a build. It is pure: the
// returned State marshals to a complete LLB graph without a running buildkitd.
func (p Plan) Recipe(src llb.State) Recipe {
switch p.Ecosystem {
case Go:
built := llb.Image(goBuilder).
With(copyDir(src, "/src")).
Dir("/src").
Run(llb.Shlex("go build -o /out/app ."), llb.AddEnv("CGO_ENABLED", "0")).Root()
return Recipe{
State: llb.Image(goRuntime).With(copyFile(built, "/out/app", "/app")),
Base: goRuntime,
Entrypoint: []string{"/app"},
}
case Node:
install := "npm install"
if p.Lockfile {
install = "npm ci"
}
app := llb.Image(nodeImage).
With(copyDir(src, "/app")).
Dir("/app").
Run(llb.Shlex(install)).Root().
Run(llb.Shlex("npm run build --if-present")).Root()
return Recipe{State: app, Base: nodeImage, Entrypoint: []string{"npm", "start"}, WorkDir: "/app"}
case Python:
install := "pip install --no-cache-dir ."
if p.Requirements {
install = "pip install --no-cache-dir -r requirements.txt"
}
app := llb.Image(pythonImage).
With(copyDir(src, "/app")).
Dir("/app").
Run(llb.Shlex(install)).Root()
return Recipe{State: app, Base: pythonImage, Entrypoint: []string{"python", "main.py"}, WorkDir: "/app"}
case Static:
return Recipe{
State: llb.Image(staticImage).With(copyDir(src, "/site")),
Base: staticImage,
Entrypoint: []string{"httpd", "-f", "-v", "-p", "8080", "-h", "/site"},
Ports: []string{"8080/tcp"},
}
}
panic("pack: Recipe called with unknown ecosystem " + string(p.Ecosystem))
}
// image overlays the recipe's runtime config onto the inherited base config,
// preserving the base env (PATH, so npm/python/httpd resolve) and never
// mutating the caller's base.
func (r Recipe) image(base *dockerspec.DockerOCIImage) *dockerspec.DockerOCIImage {
img := *base
img.Config.Entrypoint = r.Entrypoint
img.Config.Cmd = nil
if r.WorkDir != "" {
img.Config.WorkingDir = r.WorkDir
}
img.Config.Env = append([]string{}, base.Config.Env...)
if len(r.Ports) > 0 {
ports := map[string]struct{}{}
for k, v := range base.Config.ExposedPorts {
ports[k] = v
}
for _, p := range r.Ports {
ports[p] = struct{}{}
}
img.Config.ExposedPorts = ports
}
return &img
}
func copyDir(src llb.State, dst string) llb.StateOption {
return func(s llb.State) llb.State {
return s.File(llb.Copy(src, "/", dst, &llb.CopyInfo{CopyDirContentsOnly: true, CreateDestPath: true}))
}
}
func copyFile(src llb.State, from, to string) llb.StateOption {
return func(s llb.State) llb.State {
return s.File(llb.Copy(src, from, to, &llb.CopyInfo{CreateDestPath: true}))
}
}