Go tool that compiles CF buildpack dependencies (ruby, node, python, httpd, …) for a
specific CF stack (cflinuxfs4, cflinuxfs5). Entry point: cmd/binary-builder/main.go.
go test ./... # all packages
go test -race ./... # with race detector (CI requirement)# By test name (regex) within a package:
go test ./internal/recipe/ -run TestRubyRecipeBuild
go test ./internal/runner/ -run TestFakeRunner
go test ./internal/stack/ -run TestLoad
# Run an entire package verbosely:
go test -v ./internal/recipe/
# Run with race detector for a single package:
go test -race ./internal/recipe/ -run TestHTTPDmake parity-test DEP=httpd [STACK=cflinuxfs4]
make parity-test-all [STACK=cflinuxfs4]make exerciser-test ARTIFACT=/tmp/ruby_3.3.6_...tgz STACK=cflinuxfs4go build ./cmd/binary-buildercmd/binary-builder/main.go ← CLI entry point
internal/
recipe/ ← one file per dep (ruby.go, node.go, httpd.go, …)
php/ ← PHP extension recipes (pecl.go, fake_pecl.go)
runner/ ← Runner interface + RealRunner + FakeRunner
stack/ ← Stack struct + YAML loader
fetch/ ← Fetcher interface + HTTPFetcher
archive/ ← StripTopLevelDir, StripFiles, InjectFile
source/ ← source.Input (data.json parser)
output/ ← OutData, BuildOutput, DepMetadataOutput
artifact/ ← Artifact naming, SHA256, S3 URL helpers
apt/ ← apt.New(runner).Install(ctx, pkgs...)
portile/ ← configure/make/install wrapper
stacks/ ← cflinuxfs4.yaml, cflinuxfs5.yaml ← ALL stack-specific data lives here
- Stack config is data, not code. Every Ubuntu-version-specific value
(apt packages, compiler paths, bootstrap URLs) lives in
stacks/{stack}.yaml. Recipes read from*stack.Stack; no stack names are hardcoded in Go source. - Runner interface — all
exec.Cmdusage goes throughrunner.Runner.RealRunnerexecutes;FakeRunnerrecords calls for tests. - Fetcher interface — all HTTP calls go through
fetch.Fetcher.HTTPFetcherdoes the real work;FakeFetcher(inrecipe_test.go) is used in tests. RunInDirWithEnvappends env vars — appended vars win over inherited env on Linux. SoGOTOOLCHAIN=localappended DOES override any existingGOTOOLCHAIN.- miniconda3-py39 is URL-passthrough:
Build()setsoutData.URL/outData.SHA256directly instead of writing a file.main.gochecksif outData.URL == ""before callinghandleArtifact.
- Go only. The Ruby binary-builder has been fully removed.
- Module:
github.com/cloudfoundry/binary-builder— use this import path. - Minimum Go version: see
go.mod.
| Kind | Convention | Example |
|---|---|---|
| Exported type / func | PascalCase | RubyRecipe, NewRegistry |
| Unexported func / var | camelCase | buildRegistry, mustCwd |
| Interface | noun (no I prefix) |
Runner, Fetcher, Recipe |
| Test helper | camelCase | newFakeFetcher, useTempWorkDir |
| Constants | PascalCase (exported) or camelCase | — |
Three groups, separated by blank lines:
import (
// 1. stdlib
"context"
"fmt"
"os"
// 2. third-party
"gopkg.in/yaml.v3"
"github.com/stretchr/testify/assert"
// 3. internal
"github.com/cloudfoundry/binary-builder/internal/runner"
"github.com/cloudfoundry/binary-builder/internal/stack"
)- Return errors explicitly; never panic in production code paths.
- Wrap with context using
fmt.Errorf("component: action: %w", err). - Pattern:
return fmt.Errorf("ruby: apt install ruby_build: %w", err) - Error strings are lowercase (Go convention).
- On fatal CLI errors:
fmt.Fprintf(os.Stderr, "binary-builder: %v\n", err); os.Exit(1).
- Package-level doc comment on every package:
// Package foo does X. - Exported types/funcs always have doc comments.
- Inline comments explain why, not what.
- Use
// nolint:errcheckonly when the error is genuinely ignorable (e.g., closing a writer in a test after all data is flushed).
- Define interfaces where the consumer lives, not where the implementation lives.
- Struct fields use PascalCase; YAML tags use snake_case:
InstallDir string \yaml:"install_dir"``. - Zero-value structs should be usable where practical.
Every recipe implements recipe.Recipe:
type Recipe interface {
Name() string
Build(ctx context.Context, s *stack.Stack, src *source.Input, r runner.Runner, outData *output.OutData) error
Artifact() ArtifactMeta // OS, Arch, Stack ("" = use build stack)
}Before writing a new recipe from scratch, check whether one of these abstractions fits:
| Abstraction | Location | Use when |
|---|---|---|
autoconf.Recipe |
internal/autoconf/ |
configure / make / make install cycle (libunwind, libgdiplus, openresty, nginx) |
RepackRecipe |
internal/recipe/repack.go |
Download an archive and optionally strip its top-level dir (bower, yarn, setuptools, rubygems) |
BundleRecipe |
internal/recipe/bundle.go |
pip3 download multiple packages into a tarball (pip, pipenv) |
GoToolRecipe |
internal/recipe/dep.go |
Download + build a Go tool with go get/go build (dep, glide, godep) |
PassthroughRecipe |
internal/recipe/passthrough.go |
No build step — just record the upstream URL and SHA256 |
autoconf.Recipe lives in internal/autoconf/ (separate package to avoid import cycles).
It is not a recipe.Recipe itself — wrap it in a thin struct in internal/recipe/:
type MyRecipe struct{ Fetcher fetch.Fetcher }
func (r *MyRecipe) Name() string { return "mylib" }
func (r *MyRecipe) Artifact() ArtifactMeta { return ArtifactMeta{OS: "linux", Arch: "x64"} }
func (r *MyRecipe) Build(ctx context.Context, s *stack.Stack, src *source.Input,
run runner.Runner, out *output.OutData) error {
return (&autoconf.Recipe{
DepName: "mylib",
Fetcher: r.Fetcher,
Hooks: autoconf.Hooks{
AptPackages: func(s *stack.Stack) []string { return s.AptPackages["mylib_build"] },
ConfigureArgs: func(_, prefix string) []string { return []string{"--prefix=" + prefix, "--enable-shared"} },
PackDirs: func() []string { return []string{"include", "lib"} },
},
}).Build(ctx, s, src, run, out)
}Available hooks (all optional — nil = default behaviour):
| Hook | Default | Typical override |
|---|---|---|
AptPackages |
s.AptPackages["{name}_build"] |
Custom package list |
BeforeDownload |
no-op | GPG verification (nginx) |
SourceProvider |
fetch tarball, extract to /tmp/{name}-{version} |
git clone (libgdiplus), read from source/ (libunwind) |
AfterExtract |
no-op | autoreconf -i, autogen.sh |
ConfigureArgs |
["--prefix={prefix}"] |
Full custom args |
ConfigureEnv |
nil | CFLAGS, CXXFLAGS |
MakeArgs |
nil | ["-j2"] |
InstallEnv |
nil (falls back to ConfigureEnv) |
DESTDIR (nginx) |
AfterInstall |
no-op | Remove runtime dirs, symlinks |
PackDirs |
["."] |
["include", "lib"], ["lib"] |
AfterPack |
no-op | archive.StripTopLevelDir (nginx) |
- Create
internal/recipe/{name}.gowith a struct implementingRecipe. - Register it in
buildRegistry()incmd/binary-builder/main.go. - Add a test in
internal/recipe/recipe_test.goor a new{name}_test.gofile. - If the dep is architecture-neutral, set
Arch: "noarch"inArtifactMeta. - For URL-passthrough deps (no build step), use
PassthroughRecipeor setoutData.URL/outData.SHA256directly. - For autoconf-based deps, use
autoconf.Recipewith hooks (see above). - For download-and-strip deps, use
RepackRecipe.
Recipes must not contain if s.Name == "cflinuxfs4" guards. Instead:
- Add the relevant value to both
stacks/cflinuxfs4.yamlandstacks/cflinuxfs5.yaml. - Read it from
s.AptPackages["key"],s.Python.UseForceYes, etc.
- Test package: always use
package recipe_test(external test package) forinternal/recipe/. Other packages follow the same_testsuffix convention. - Assertion library:
github.com/stretchr/testify/assert(non-fatal) andgithub.com/stretchr/testify/require(fatal / setup). - Use
require.NoErrorfor setup steps;assert.*for behaviour assertions. FakeRunnerininternal/runner/runner.go— inject instead ofRealRunnerin tests. InspectfakeRunner.Callsto verify command sequence, args, env, and dir.FakeFetcherdefined inrecipe_test.go— inject instead ofHTTPFetcher. Inspectf.DownloadedURLsand setf.ErrMap/f.BodyMapto control behaviour.useTempWorkDir(t)— helper inrecipe_helpers_test.go. Call it in tests that need a clean CWD (recipes write artifacts relative to CWD). NOT safe fort.Parallel().writeFakeArtifact(t, name)— creates a minimal valid.tgzin CWD so archive helpers don't fail when processing a fake build output.- Table-driven tests are preferred for multiple similar cases (see
TestCompiledRecipeArtifactMetaSanity). - Test names follow
Test{Type}{Behaviour}pattern:TestRubyRecipeBuild,TestNodeRecipeStripsVPrefix.
- Script:
test/parity/compare-builds.sh --dep <name> --data-json <path> [--stack <stack>] - Logs:
/tmp/parity-logs/<dep>-<version>-<stack>.log - The Go builder is compiled inside the container at runtime (
go build ./cmd/binary-builder), so source changes are always picked up on re-run without a separate image rebuild.
| File | Dep | Version |
|---|---|---|
/tmp/go-data.json |
go | 1.22.0 |
/tmp/node-data.json |
node | 20.11.0 |
/tmp/php-data.json |
php | 8.1.32 |
/tmp/r-data.json |
r | 4.2.3 |
/tmp/jruby-data.json |
jruby | 9.4.14.0 |
/tmp/appdynamics-data.json |
appdynamics | 23.11.0-839 |
/tmp/skywalking-data.json |
skywalking-agent | 9.5.0 |
R sub-dep data.json files live under /tmp/r-sub-deps/source-{pkg}-latest/data.json
(forecast, plumber, rserve, shiny).
| File | Purpose |
|---|---|
cmd/binary-builder/main.go |
CLI, findIntermediateArtifact, handleArtifact, buildRegistry |
internal/runner/runner.go |
Runner interface, RealRunner, FakeRunner + Call type |
internal/fetch/fetch.go |
Fetcher interface, HTTPFetcher |
internal/stack/stack.go |
Stack struct, Load(stacksDir, name) |
internal/recipe/recipe.go |
Recipe interface, Registry |
internal/archive/archive.go |
StripTopLevelDir, StripFiles, InjectFile |
internal/portile/ |
configure/make/install abstraction |
internal/apt/ |
apt-get install wrapper |
stacks/cflinuxfs4.yaml |
All cflinuxfs4-specific values |
stacks/cflinuxfs5.yaml |
All cflinuxfs5-specific values |
test/parity/compare-builds.sh |
Parity test harness |
Makefile |
unit-test, unit-test-race, parity-test, exerciser-test |
The cflinuxfs4/ports/ directory contains root-owned build artifacts from previous Ruby
parity test runs. These cannot be removed without sudo and are NOT tracked by git.
They do not affect builds or tests.