build

package
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Sep 9, 2026 License: Apache-2.0 Imports: 22 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.

This package is the Phase 0 spike only: it proves the client works end to end against the BuildKit instance embedded in a local Docker Engine, and loads the result into the local image store. Remote build cache, SSE log streaming to the frontend, and app-spec-driven config are Phase 1 work and are not implemented here. See docs-local/research/buildkit-spike.md for what was learned building this and what Phase 1 needs to add.

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.

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 TASKS.md 3.5, 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.

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 and TASKS.md 3.5 both call 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).

See docs-local/research/buildkit-spike.md for the connection methods that were tried and why this one is the one that actually works in this environment.

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.

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 (TASKS.md 3.1/3.2), 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 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 TASKS.md 3.5 added WithCacheRegistry: 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 TASKS.md 3.5 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 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 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