build

package
v0.2.0-beta Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 12, 2026 License: Apache-2.0 Imports: 24 Imported by: 0

Documentation

Overview

Package build drives container image builds through BuildKit's Go client (github.com/moby/buildkit/client), never by shelling out to `docker build` or the `docker` CLI, matching the same "no CLI shelling" rule the node communication layer follows. It accepts plain Dockerfiles via the dockerfile.v0 frontend, per the declarative app spec's build config, and Railpack-detected sources via their own solve path (railpack.go).

A build runs against the BuildKit instance embedded in whichever Docker Engine is local to the process running it, and Router (router.go) decides which process that is: this one, or a node an operator marked build-capable, reached over the agent transport.

Index

Constants

This section is empty.

Variables

View Source
var (
	ErrRailpackSourceDirRequired = errors.New("build: railpack: source dir is required")
	ErrRailpackTagRequired       = errors.New("build: railpack: tag is required")
)

ErrRailpackSourceDirRequired and ErrRailpackTagRequired are returned by Validate for the two required fields, matching Request.Validate's own "distinguishable sentinel error" shape for the Dockerfile path.

View Source
var (
	ErrContextDirRequired = errors.New("build: context dir is required")
	ErrTagRequired        = errors.New("build: tag is required")
)

ErrContextDirRequired and ErrTagRequired are returned by Validate for the two required fields, so callers can match on them if they need to distinguish the failure reason without parsing the error string.

View Source
var ErrCacheRegistryRefRequired = errors.New("build: cache registry insecure flag set without a registry ref")

ErrCacheRegistryRefRequired is returned by CacheConfig.entries when RegistryInsecure is set but RegistryRef is empty: an insecure flag with nothing to apply it to is almost certainly a caller mistake (e.g. a typo'd env var name that leaves RegistryRef unset), not a meaningful configuration, so this fails loudly at construction time rather than silently doing nothing with the flag.

View Source
var ErrNoBuildNodeAvailable = errors.New("build: no build-capable node is currently online")

ErrNoBuildNodeAvailable is returned by SelectBuildNode when at least one node is marked AcceptsBuildWorkloads but none of them is currently Online. This is distinct from "no node was ever configured as build-capable at all", which is not an error (SelectBuildNode returns "" for the control plane's own local node in that case): an operator who deliberately dedicated capacity to builds almost certainly does not want a deploy to silently fall back onto the control plane's own resources the moment that capacity blips offline. That surprise is worse than a clear, loud failure a caller can retry or alert on.

View Source
var ErrRemoteDockerfileOutsideContext = errors.New("build: a dispatched build's dockerfile must live inside its build context")

ErrRemoteDockerfileOutsideContext is returned when a build's Dockerfile sits outside its own build context. A local build can read it anyway, since both are paths on one filesystem; a dispatched one cannot, because only the context is shipped to the build node.

View Source
var ErrUnsafeContextPath = errors.New("build: build context entry escapes the destination directory")

ErrUnsafeContextPath is returned by UntarContext for an entry whose name escapes the destination directory. A build context arrives over the agent transport from an authenticated control plane, but an extractor that trusts entry names is a file-overwrite primitive regardless of who is on the other end.

Functions

func SelectBuildNode

func SelectBuildNode(nodes []NodeInfo) (string, error)

SelectBuildNode picks which node a build should run on, given every known node.

  • No node in nodes has AcceptsBuildWorkloads set: returns "" (this control plane's own local node) and a nil error. This is the default, zero-configuration behavior every deployment already had before dedicated build nodes, matching the "" == local convention migrations/0009_node_placement.sql established for service and database placement: an operator who has never configured a dedicated build node keeps building locally, unchanged.
  • At least one node has AcceptsBuildWorkloads set: the Online one with the lexicographically smallest ID is selected, a deterministic, easy-to-reason-about tie-break rather than anything load-aware; real load-based scheduling is explicitly out of scope, this project's stated non-goal against building a scheduler with bin-packing, affinity rules, or autoscaling in v1, and this is the build-node equivalent of that same non-goal.
  • At least one node has AcceptsBuildWorkloads set but none is Online: returns ErrNoBuildNodeAvailable rather than silently falling back to the local node; see the sentinel's own doc comment for why.

func SlogProgress

func SlogProgress(log *slog.Logger) func(ProgressEvent)

SlogProgress adapts a *slog.Logger into a progress func, for callers that just want build progress logged rather than consumed some other way (an SSE stream, a test assertion). log defaults to slog.Default() if nil.

func TarContext

func TarContext(ctx context.Context, dir string, w io.Writer) error

TarContext writes dir's contents to w as a tar stream: regular files, directories, and symlinks, with their permission bits. Anything else (sockets, devices, named pipes) is skipped, since no build context meaningfully needs one and Docker's own context upload skips them too.

func UntarContext

func UntarContext(ctx context.Context, r io.Reader, dir string) error

UntarContext extracts a TarContext stream into dir, which must already exist. Entry names that escape dir fail the whole extraction with ErrUnsafeContextPath rather than being skipped: a context that cannot be reproduced faithfully must not be built.

Types

type CacheConfig

type CacheConfig struct {
	// Dir enables BuildKit's "local" cache backend: a directory on this
	// process's own disk. Only ever useful to the one build node that
	// owns that disk, which is exactly Phase 1's scoped-down cache
	// (client.go's WithCacheDir).
	Dir string

	// RegistryRef enables BuildKit's "registry" cache backend: an image
	// reference (e.g. "registry.example.com/levelrail/build-cache:app")
	// that cache blobs are pushed to and pulled from using the same
	// registry credentials Docker itself already has configured. This
	// is the actual "remote cache... shared across dedicated build
	// nodes" the build design calls for: any build
	// node with network access to the registry gets the same cache,
	// unlike Dir.
	RegistryRef string

	// RegistryInsecure allows the registry backend to talk plain HTTP
	// or accept a self-signed certificate, matching BuildKit's own
	// "insecure" cache attribute. Only meaningful when RegistryRef is
	// set; ignored otherwise.
	RegistryInsecure bool
}

CacheConfig describes every BuildKit cache import/export backend a Client should use. The zero value disables caching entirely, matching WithCacheDir's pre-existing "empty disables cache import/export entirely" behavior.

func (CacheConfig) String

func (c CacheConfig) String() string

String renders c for logging, never including credentials (this struct never holds any: registry auth comes from Docker's own configured credential store, the same as every other registry operation in this codebase, per ensureImage in internal/docker).

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client drives builds against the BuildKit instance embedded in a local Docker Engine daemon.

Docker Desktop (and any dockerd using the default "docker" buildx driver, as opposed to the "docker-container" driver) does not expose a bare buildkitd gRPC endpoint of its own. BuildKit runs inside dockerd and is only reachable by hijacking the daemon's HTTP connection at the /grpc endpoint, exactly the way `docker buildx build` reaches it when using the default driver. That is what dockerbuildkit.ClientOpts wires up below, using the same *dockerclient.Client already used to talk to the Engine API elsewhere in this codebase (see internal/docker).

This hijacking approach is the one that actually works in this environment; a standalone buildkitd endpoint is not an option here.

func NewClient

func NewClient(ctx context.Context, docker *dockerclient.Client, opts ...Option) (*Client, error)

NewClient builds a Client that reaches BuildKit through docker's hijacked connection, and reuses the same Docker Engine API client to load the finished image back into the local image store.

docker must already be a working *dockerclient.Client (e.g. from dockerclient.NewClientWithOpts(dockerclient.FromEnv, dockerclient.WithAPIVersionNegotiation())). NewClient does not create one itself so callers control the daemon connection lifecycle.

func (*Client) Build

func (c *Client) Build(ctx context.Context, req Request, progress func(ProgressEvent)) (*Result, error)

Build runs req end to end: solves the Dockerfile with BuildKit, streams the resulting image as a tar into the local Docker Engine's /images/load endpoint, and waits for both to finish. It never shells out to the docker CLI.

progress, if non-nil, receives every build progress update as it happens, including the docker-image-load phase after BuildKit's own solve finishes (that phase isn't part of BuildKit's SolveStatus stream, but is relayed through the same callback for one unified progress feed). Pass SlogProgress(logger) to just log them, or nil to discard them entirely.

func (*Client) BuildRailpack

func (c *Client) BuildRailpack(ctx context.Context, req RailpackRequest, progress func(ProgressEvent)) (*Result, error)

BuildRailpack runs req end to end: generates a Railpack build plan for req.SourceDir, rejects it unless Railpack detected a supported provider (supportedRailpackProviders), converts the plan to a BuildKit LLB definition, solves it, and streams the resulting image into the local Docker Engine's /images/load endpoint, exactly like Build does for a Dockerfile. It never shells out to the railpack or docker CLI.

progress behaves exactly as it does for Build: pass SlogProgress(logger) to log every update, or nil to discard them.

func (*Client) Close

func (c *Client) Close() error

Close releases the underlying BuildKit connection. It does not close the Docker Engine API client passed to NewClient, since the caller owns that client's lifecycle.

func (*Client) SolveRemote

func (c *Client) SolveRemote(ctx context.Context, req RemoteRequest, out io.Writer, progress func(ProgressEvent)) (*Result, error)

SolveRemote runs a build dispatched from a control plane against this node's own BuildKit and writes the resulting docker-save tar to out, rather than loading it into this node's image store: the image is wanted by whoever asked for the build, not by the node that ran it.

req.ContextDir must already hold the unpacked build context.

type NodeBuilder

type NodeBuilder interface {
	BuildOnNode(ctx context.Context, nodeID string, req RemoteRequest, image io.Writer, progress func(ProgressEvent)) (*Result, error)
}

NodeBuilder runs a build on one specific node and streams the result back: progress as it happens, and the docker-save image tar into image. internal/agent's BuildDispatcher satisfies this over the agent transport; nothing in this package knows how a node is reached.

type NodeInfo

type NodeInfo struct {
	// ID is the node's identifier, matching store.Node.ID.
	ID string
	// AcceptsBuildWorkloads mirrors store.Node.AcceptsBuildWorkloads
	// (migrations/0010_node_workloads.sql): whether this node has opted
	// in to running build work at all.
	AcceptsBuildWorkloads bool
	// Online reports whether this node is currently reachable through
	// the agent transport, i.e. whether
	// agent.Registry.Get for this node's ID currently succeeds. A
	// build-capable node the control plane cannot currently reach is
	// not a usable candidate, the same reasoning resolveNodeTransport
	// (cmd/levelrail/main.go) already applies to service/database
	// placement.
	Online bool
}

NodeInfo is the minimal node shape SelectBuildNode needs. Deliberately not store.Node: internal/build has no dependency on internal/store today, and build routing only ever cares about three of a node's fields, the same narrow, consumer-defined interface convention every other package boundary in this codebase already follows (e.g. internal/deploy's ImageBuilder/ServiceStore/SecretChecker).

type NodeSource

type NodeSource func(ctx context.Context) ([]NodeInfo, error)

NodeSource reports every node the control plane currently knows about, with whichever of them are reachable right now marked Online. Consulted per build rather than once at startup, so marking a node build-capable, or a build node dropping offline, takes effect on the next build instead of the next control-plane restart.

type Option

type Option func(*Client)

Option configures optional Client behavior.

func WithCacheDir

func WithCacheDir(dir string) Option

WithCacheDir enables BuildKit's local cache backend, importing from and exporting to dir on every build. Empty (the default) disables cache import/export entirely, not an empty-but-present cache.

This was the build design's "remote cache" goal scoped honestly for Phase 1's single-node target, before WithCacheRegistry was added: a cache genuinely shared across dedicated build nodes needs a registry or object-store backend and dedicated build nodes to share it with. WithCacheDir is still useful on its own (fast incremental rebuilds on one node with no registry round trip) and composes with WithCacheRegistry rather than being replaced by it: both may be set on the same Client, see CacheConfig.

func WithCacheRegistry

func WithCacheRegistry(ref string) Option

WithCacheRegistry enables BuildKit's registry cache backend (CacheConfig.RegistryRef): this is the actual remote cache the build design asks for, shared by every build node with network access to ref's registry, unlike WithCacheDir's per-machine directory. Empty ref disables the registry backend, the same "empty disables this backend" convention WithCacheDir already establishes.

func WithCacheRegistryInsecure

func WithCacheRegistryInsecure() Option

WithCacheRegistryInsecure allows the registry cache backend (WithCacheRegistry) to talk plain HTTP or accept a self-signed certificate. Only meaningful combined with WithCacheRegistry; NewClient returns ErrCacheRegistryRefRequired if this is set without a registry ref configured, rather than silently ignoring it.

type ProgressEvent

type ProgressEvent struct {
	// Step is the build step's name, e.g. "[2/4] RUN go build".
	Step string
	// Cached is true when this step was skipped because BuildKit's cache
	// (see WithCacheDir) already had the result.
	Cached bool
	// Completed is true once Step finished, successfully or not.
	Completed bool
	// Error is non-empty when Step failed.
	Error string
	// Log is a raw output line from the step (stdout/stderr from the
	// build), empty for step-lifecycle events.
	Log string
	// Stream is "stdout" or "stderr" when Log is non-empty, and empty
	// for a step-lifecycle event (Log == ""), since those never came
	// from either stream. Added for internal/deploylog.Recorder, so a
	// persisted/live-streamed build log line can carry the same
	// stdout/stderr distinction web/src/hooks/useDeployLogStream.ts's
	// contract already expects (its own doc comment: "distinguishing
	// stdout/stderr is useful for highlighting failed build steps").
	// Populated from BuildKit's own VertexLog.Stream (see
	// relayProgress), 1/2 being BuildKit's stdout/stderr convention; the
	// docker-image-load phase (loadImage) has no equivalent stream
	// signal from the Engine API, so it always reports "stdout".
	Stream string
}

ProgressEvent is one structured build progress update, deliberately decoupled from BuildKit's own SolveStatus wire type so callers, a future SSE handler in particular (the build design's build log streaming requirement), don't need to import moby/buildkit/client just to format a build log line for a browser.

type RailpackRequest

type RailpackRequest struct {
	// SourceDir is the app's source root Railpack inspects to detect its
	// provider and generate a build plan, and the build context BuildKit
	// reads from. Required.
	SourceDir string

	// Tag is the image reference given to the built image, e.g.
	// "levelrail/thesvg:abc1234". Required.
	Tag string
}

RailpackRequest describes a single Railpack-detected build: no Dockerfile, no user-authored build steps, just a source directory Railpack inspects itself to determine how to build it.

func (RailpackRequest) Validate

func (r RailpackRequest) Validate() error

Validate checks the request is well-formed enough to attempt a build. Like Request.Validate, it does not check the filesystem: a missing source directory surfaces later, from Railpack's own app.NewApp call, which already produces a clear error for that case.

type RemoteKind

type RemoteKind string

RemoteKind selects which solve path a dispatched build runs, since a Dockerfile build and a Railpack build are genuinely different solves (see railpack.go) rather than one solve with a flag.

const (
	RemoteKindDockerfile RemoteKind = "dockerfile"
	RemoteKindRailpack   RemoteKind = "railpack"
)

RemoteKindDockerfile and RemoteKindRailpack are the two dispatchable build kinds, matching internal/deploy's own two building build types.

type RemoteRequest

type RemoteRequest struct {
	Kind RemoteKind

	// ContextDir is the build context root on whichever side currently
	// holds it: the control plane's checkout when dispatching, the build
	// node's unpacked copy when running.
	ContextDir string

	// DockerfilePath is relative to ContextDir, since the control plane's
	// own absolute paths mean nothing on the build node. Empty means the
	// context root's own Dockerfile. Ignored for RemoteKindRailpack.
	DockerfilePath string

	Tag       string
	Target    string
	BuildArgs map[string]string
	NoCache   bool

	// Cache is the registry cache backend the build node should import
	// from and export to. CacheConfig.Dir is never carried here: it names
	// a directory on the dispatching side's own disk.
	Cache CacheConfig
}

RemoteRequest is one build dispatched to another node: everything the build node needs that is not the context's bytes.

func NewRemoteRailpackRequest

func NewRemoteRailpackRequest(req RailpackRequest, cache CacheConfig) (RemoteRequest, error)

NewRemoteRailpackRequest turns a local Railpack build into a dispatchable one.

func NewRemoteRequest

func NewRemoteRequest(req Request, cache CacheConfig) (RemoteRequest, error)

NewRemoteRequest turns a local Dockerfile build into a dispatchable one, re-rooting DockerfilePath relative to the context.

type Request

type Request struct {
	// ContextDir is the build context root, matching `docker build <dir>`.
	// Required.
	ContextDir string

	// DockerfilePath is the path to the Dockerfile. It may live outside
	// ContextDir. Defaults to "<ContextDir>/Dockerfile" when empty.
	DockerfilePath string

	// Tag is the image reference given to the built image, e.g.
	// "levelrail-spike:latest". Required.
	Tag string

	// Target is an optional multi-stage build target name.
	Target string

	// BuildArgs are passed through as build-time --build-arg equivalents.
	BuildArgs map[string]string

	// NoCache disables BuildKit's cache for this build.
	NoCache bool
}

Request describes a single Dockerfile build. It is deliberately the pure, unit-testable input to newSolveOpt: nothing in this file touches a filesystem, a network, or a daemon, so Validate can be exercised with plain table-driven tests that need no live BuildKit connection.

func (Request) Validate

func (r Request) Validate() error

Validate checks the request is well-formed enough to attempt a build. It does not check the filesystem; newSolveOpt does that, because a missing directory is an environment problem, not a malformed request.

type Result

type Result struct {
	Tag      string
	Duration time.Duration

	// ExporterResponse is BuildKit's raw exporter metadata (e.g. the
	// resulting image ID), passed through for callers that need it.
	ExporterResponse map[string]string
}

Result is what a successful build produced.

type Router

type Router struct {
	// contains filtered or unexported fields
}

Router is the build entry point the deploy pipeline calls: it picks a build node (SelectBuildNode, node.go) and either builds locally or dispatches, with the same method set and the same results either way.

func NewRouter

func NewRouter(local *Client, nodes NodeSource, remote NodeBuilder, opts ...RouterOption) *Router

NewRouter builds a Router over local. nodes and remote may both be nil, which pins every build to local: that is the single-node default, not a degraded mode.

func (*Router) Build

func (r *Router) Build(ctx context.Context, req Request, progress func(ProgressEvent)) (*Result, error)

Build implements internal/deploy.ImageBuilder.

func (*Router) BuildRailpack

func (r *Router) BuildRailpack(ctx context.Context, req RailpackRequest, progress func(ProgressEvent)) (*Result, error)

BuildRailpack implements internal/deploy.ImageBuilder.

type RouterOption

type RouterOption func(*Router)

RouterOption configures optional Router behavior.

func WithRouterLogger

func WithRouterLogger(logger *slog.Logger) RouterOption

WithRouterLogger overrides the logger a dispatch decision is reported on. Defaults to slog.Default().

type UnsupportedProviderError

type UnsupportedProviderError struct {
	// Provider is the provider name Railpack detected, or "" if it
	// detected none at all.
	Provider string
}

UnsupportedProviderError is returned when Railpack's own detection (core.BuildResult.DetectedProviders) resolved to a provider outside this slice's scope (supportedRailpackProviders), including the empty string for "no provider matched at all". It is a distinct type, not a plain fmt.Errorf, so callers (internal/deploy in particular) can errors.As it to produce a caller-appropriate message rather than a generic build failure, the same "fail loudly, not silently" pattern deploy.go's validateEnv already establishes for unsupported env resolution.

func (*UnsupportedProviderError) Error

func (e *UnsupportedProviderError) Error() string

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL