Skip to content

Commit df1982d

Browse files
authored
devconfig,shellgen: option to patch ELF binaries with newer glibc (#1574)
Add an optional `patch_glibc` boolean field to packages in devbox.json. When true, devbox will patch any ELF binaries in a package to link against the latest `nixpkgs#glibc` at runtime. This is a workaround for dynamic linking issues that can arise when using older packages. For example, an old Python interpreter that's linked against glibc 2.35 might run a script that loads a native library that requires glibc 2.37. Because the linker only loads a library into a process once (regardless of version), linking will fail when the script references a 2.37 symbol. The following devbox.json will reproduce a glibc version bug when the `virtenv` and `crash` scripts are run on x86-64-linux (aarch64 will not work). Setting the `patch_glibc` field to true will fix it: ```json { "packages": { "binutils": "latest", "libpqxx": "latest", "libxcrypt": "latest", "libz": "latest", "python37Packages.pip": "latest", "python": { "version": "3.7", "patch_glibc": false } }, "shell": { "scripts": { "crash": "python3 -c 'import psycopg2'", "virtenv": [ "rm -rf \"$VENV_DIR\"", "echo \"python3 path: $(readlink -f $(command -v python3))\"", "python3 -m venv \"$VENV_DIR\"", ". \"$VENV_DIR/bin/activate\"", "echo \"pip3 path: $(readlink -f $(command -v pip3))\"", "pip3 install psycopg2==2.9.5" ] } } } ``` This field is intended to be a "last resort" for when a package cannot be updated to a newer version. Upgrading to a newer version of Python in the example above is preferable.
1 parent 4d0694b commit df1982d

File tree

13 files changed

+420
-87
lines changed

13 files changed

+420
-87
lines changed

internal/devconfig/config.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,8 @@ func (c *Config) Equals(other *Config) bool {
103103
}
104104

105105
func (c *Config) NixPkgsCommitHash() string {
106-
// The commit hash for nixpkgs-unstable on 2023-01-25 from status.nixos.org
107-
const DefaultNixpkgsCommit = "f80ac848e3d6f0c12c52758c0f25c10c97ca3b62"
106+
// The commit hash for nixpkgs-unstable on 2023-10-25 from status.nixos.org
107+
const DefaultNixpkgsCommit = "75a52265bda7fd25e06e3a67dee3f0354e73243c"
108108

109109
if c == nil || c.Nixpkgs == nil || c.Nixpkgs.Commit == "" {
110110
return DefaultNixpkgsCommit

internal/devconfig/packages.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,10 @@ type Package struct {
189189

190190
Platforms []string `json:"platforms,omitempty"`
191191
ExcludedPlatforms []string `json:"excluded_platforms,omitempty"`
192+
193+
// PatchGlibc applies a function to the package's derivation that
194+
// patches any ELF binaries to use the latest version of nixpkgs#glibc.
195+
PatchGlibc bool `json:"patch_glibc,omitempty"`
192196
}
193197

194198
func NewVersionOnlyPackage(name, version string) Package {

internal/devpkg/package.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ type Package struct {
4747
// example: github:nixos/nixpkgs/5233fd2ba76a3accb5aaa999c00509a11fd0793c#hello
4848
Raw string
4949

50+
// PatchGlibc applies a function to the package's derivation that
51+
// patches any ELF binaries to use the latest version of nixpkgs#glibc.
52+
PatchGlibc bool
53+
5054
// isInstallable is true if the package may be enabled on the current platform.
5155
isInstallable bool
5256

@@ -65,8 +69,10 @@ func PackageFromStrings(rawNames []string, l lock.Locker) []*Package {
6569

6670
func PackagesFromConfig(config *devconfig.Config, l lock.Locker) []*Package {
6771
result := []*Package{}
68-
for _, pkg := range config.Packages.Collection {
69-
result = append(result, newPackage(pkg.VersionedName(), pkg.IsEnabledOnPlatform(), l))
72+
for _, cfgPkg := range config.Packages.Collection {
73+
pkg := newPackage(cfgPkg.VersionedName(), cfgPkg.IsEnabledOnPlatform(), l)
74+
pkg.PatchGlibc = cfgPkg.PatchGlibc
75+
result = append(result, pkg)
7076
}
7177
return result
7278
}

internal/impl/devbox.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -891,7 +891,14 @@ func (d *Devbox) computeNixEnv(ctx context.Context, usePrintDevEnvCache bool) (m
891891
// Motivation: if a user removes a package from their devbox it should no longer
892892
// be available in their environment.
893893
buildInputs := strings.Split(env["buildInputs"], " ")
894+
glibcPatchPath := ""
894895
nixEnvPath = filterPathList(nixEnvPath, func(path string) bool {
896+
// TODO(gcurtis): this is a massive hack. Please get rid
897+
// of this and install the package to the profile.
898+
if strings.Contains(path, "patched-glibc") {
899+
glibcPatchPath = path
900+
return true
901+
}
895902
for _, input := range buildInputs {
896903
// input is of the form: /nix/store/<hash>-<package-name>-<version>
897904
// path is of the form: /nix/store/<hash>-<package-name>-<version>/bin
@@ -904,6 +911,13 @@ func (d *Devbox) computeNixEnv(ctx context.Context, usePrintDevEnvCache bool) (m
904911
})
905912
debug.Log("PATH after filtering with buildInputs (%v) is: %s", buildInputs, nixEnvPath)
906913

914+
// TODO(gcurtis): this is a massive hack. Please get rid
915+
// of this and install the package to the profile.
916+
if glibcPatchPath != "" {
917+
nixEnvPath = glibcPatchPath + ":" + nixEnvPath
918+
debug.Log("PATH after glibc-patch hack is: %s", nixEnvPath)
919+
}
920+
907921
runXPaths, err := d.RunXPaths(ctx)
908922
if err != nil {
909923
return nil, err

internal/shellgen/flake_input.go

Lines changed: 67 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,18 @@ package shellgen
33
import (
44
"context"
55
"runtime/trace"
6+
"slices"
67
"strings"
78

89
"github.com/samber/lo"
910
"go.jetpack.io/devbox/internal/boxcli/featureflag"
11+
"go.jetpack.io/devbox/internal/debug"
1012
"go.jetpack.io/devbox/internal/devpkg"
11-
"go.jetpack.io/devbox/internal/goutil"
1213
"go.jetpack.io/devbox/internal/nix"
1314
)
1415

16+
const glibcPatchFlakeRef = "path:./glibc-patch"
17+
1518
type flakeInput struct {
1619
Name string
1720
Packages []*devpkg.Package
@@ -54,6 +57,13 @@ func (f *flakeInput) BuildInputs() ([]string, error) {
5457
if attributePathErr != nil {
5558
err = attributePathErr
5659
}
60+
if pkg.PatchGlibc {
61+
// When the package comes from the glibc flake, the
62+
// "legacyPackages" portion of the attribute path
63+
// becomes just "packages" (matching the standard flake
64+
// output schema).
65+
return strings.Replace(attributePath, "legacyPackages", "packages", 1)
66+
}
5767
return attributePath
5868
})
5969
if err != nil {
@@ -77,48 +87,71 @@ func (f *flakeInput) BuildInputs() ([]string, error) {
7787
// i.e. have a commit hash and always resolve to the same package/version.
7888
// Note: inputs returned by this function include plugin packages. (php only for now)
7989
// It's not entirely clear we always want to add plugin packages to the top level
80-
func flakeInputs(ctx context.Context, packages []*devpkg.Package) []*flakeInput {
90+
func flakeInputs(ctx context.Context, packages []*devpkg.Package) []flakeInput {
8191
defer trace.StartRegion(ctx, "flakeInputs").End()
8292

83-
// Use the verbose name flakeInputs to distinguish from `inputs`
84-
// which refer to `nix.Input` in most of the codebase.
85-
flakeInputs := map[string]*flakeInput{}
86-
87-
packages = lo.Filter(packages, func(item *devpkg.Package, _ int) bool {
88-
// Non nix packages (e.g. runx) don't belong in the flake
89-
if !item.IsNix() {
90-
return false
93+
var flakeInputs keyedSlice
94+
for _, pkg := range packages {
95+
// Non-nix packages (e.g. runx) don't belong in the flake
96+
if !pkg.IsNix() {
97+
continue
9198
}
9299

93-
// Include packages (like local or remote flakes) that cannot be
94-
// fetched from a Binary Cache Store.
95-
if !featureflag.RemoveNixpkgs.Enabled() {
96-
return true
100+
// Don't include cached packages (like local or remote flakes)
101+
// that can be fetched from a Binary Cache Store.
102+
if featureflag.RemoveNixpkgs.Enabled() {
103+
// TODO(savil): return error?
104+
cached, err := pkg.IsInBinaryCache()
105+
if err != nil {
106+
debug.Log("error checking if package is in binary cache: %v", err)
107+
}
108+
if err == nil && cached {
109+
continue
110+
}
97111
}
98112

99-
inCache, err := item.IsInBinaryCache()
100-
if err != nil {
101-
// Ignore this error for now. TODO savil: return error?
102-
return true
113+
// Packages that need a glibc patch are assigned to the special
114+
// glibc-patched flake input. This input refers to the
115+
// glibc-patch.nix flake.
116+
if pkg.PatchGlibc {
117+
nixpkgsGlibc := flakeInputs.getOrAppend(glibcPatchFlakeRef)
118+
nixpkgsGlibc.Name = "glibc-patch"
119+
nixpkgsGlibc.URL = glibcPatchFlakeRef
120+
nixpkgsGlibc.Packages = append(nixpkgsGlibc.Packages, pkg)
121+
continue
103122
}
104-
return !inCache
105-
})
106123

107-
order := []string{}
108-
for _, pkg := range packages {
109-
if flkInput, ok := flakeInputs[pkg.URLForFlakeInput()]; !ok {
110-
order = append(order, pkg.URLForFlakeInput())
111-
flakeInputs[pkg.URLForFlakeInput()] = &flakeInput{
112-
Name: pkg.FlakeInputName(),
113-
URL: pkg.URLForFlakeInput(),
114-
Packages: []*devpkg.Package{pkg},
115-
}
116-
} else {
117-
flkInput.Packages = lo.Uniq(
118-
append(flakeInputs[pkg.URLForFlakeInput()].Packages, pkg),
119-
)
124+
pkgURL := pkg.URLForFlakeInput()
125+
flake := flakeInputs.getOrAppend(pkgURL)
126+
flake.Name = pkg.FlakeInputName()
127+
flake.URL = pkgURL
128+
129+
// TODO(gcurtis): is the uniqueness check necessary? We're
130+
// comparing pointers.
131+
if !slices.Contains(flake.Packages, pkg) {
132+
flake.Packages = append(flake.Packages, pkg)
120133
}
121134
}
135+
return flakeInputs.slice
136+
}
122137

123-
return goutil.PickByKeysSorted(flakeInputs, order)
138+
// keyedSlice keys the elements of an append-only slice for fast lookups.
139+
type keyedSlice struct {
140+
slice []flakeInput
141+
lookup map[string]int
142+
}
143+
144+
// getOrAppend returns a pointer to the slice element with a given key. If the
145+
// key doesn't exist, a new element is automatically appended to the slice. The
146+
// pointer is valid until the next append.
147+
func (k *keyedSlice) getOrAppend(key string) *flakeInput {
148+
if k.lookup == nil {
149+
k.lookup = make(map[string]int)
150+
}
151+
if i, ok := k.lookup[key]; ok {
152+
return &k.slice[i]
153+
}
154+
k.slice = append(k.slice, flakeInput{})
155+
k.lookup[key] = len(k.slice) - 1
156+
return &k.slice[len(k.slice)-1]
124157
}

internal/shellgen/flake_plan.go

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ package shellgen
22

33
import (
44
"context"
5+
"fmt"
6+
"path/filepath"
57
"runtime/trace"
8+
"strings"
69

710
"go.jetpack.io/devbox/internal/devpkg"
811
"go.jetpack.io/devbox/internal/nix"
@@ -13,8 +16,8 @@ import (
1316
type flakePlan struct {
1417
BinaryCache string
1518
NixpkgsInfo *NixpkgsInfo
16-
FlakeInputs []*flakeInput
1719
Packages []*devpkg.Package
20+
FlakeInputs []flakeInput
1821
System string
1922
}
2023

@@ -65,3 +68,91 @@ func newFlakePlan(ctx context.Context, devbox devboxer) (*flakePlan, error) {
6568
System: nix.System(),
6669
}, nil
6770
}
71+
72+
func (f *flakePlan) needsGlibcPatch() bool {
73+
for _, in := range f.FlakeInputs {
74+
if in.URL == glibcPatchFlakeRef {
75+
return true
76+
}
77+
}
78+
return false
79+
}
80+
81+
type glibcPatchFlake struct {
82+
// NixpkgsGlibcFlakeRef is a flake reference to the nixpkgs flake
83+
// containing the new glibc package.
84+
NixpkgsGlibcFlakeRef string
85+
86+
// Inputs is the attribute set of flake inputs. The key is the input
87+
// name and the value is a flake reference.
88+
Inputs map[string]string
89+
90+
// Outputs is the attribute set of flake outputs. It follows the
91+
// standard flake output schema of system.name = derivation. The
92+
// derivation can be any valid Nix expression.
93+
Outputs struct {
94+
Packages map[string]map[string]string
95+
}
96+
}
97+
98+
func newGlibcPatchFlake(nixpkgsGlibcRev string, packages []*devpkg.Package) (glibcPatchFlake, error) {
99+
flake := glibcPatchFlake{NixpkgsGlibcFlakeRef: "flake:nixpkgs/" + nixpkgsGlibcRev}
100+
for _, pkg := range packages {
101+
if !pkg.PatchGlibc {
102+
continue
103+
}
104+
105+
err := flake.addPackageOutput(pkg)
106+
if err != nil {
107+
return glibcPatchFlake{}, err
108+
}
109+
}
110+
return flake, nil
111+
}
112+
113+
func (g *glibcPatchFlake) addPackageOutput(pkg *devpkg.Package) error {
114+
if g.Inputs == nil {
115+
g.Inputs = make(map[string]string)
116+
}
117+
inputName := pkg.FlakeInputName()
118+
g.Inputs[inputName] = pkg.URLForFlakeInput()
119+
120+
attrPath, err := pkg.FullPackageAttributePath()
121+
if err != nil {
122+
return err
123+
}
124+
// Remove the legacyPackages.<system> prefix.
125+
outputName := strings.SplitN(attrPath, ".", 3)[2]
126+
127+
if g.Outputs.Packages == nil {
128+
g.Outputs.Packages = map[string]map[string]string{nix.System(): {}}
129+
}
130+
if cached, err := pkg.IsInBinaryCache(); err == nil && cached {
131+
if expr, err := g.fetchClosureExpr(pkg); err == nil {
132+
g.Outputs.Packages[nix.System()][outputName] = expr
133+
return nil
134+
}
135+
}
136+
g.Outputs.Packages[nix.System()][outputName] = strings.Join([]string{"pkgs", inputName, nix.System(), outputName}, ".")
137+
return nil
138+
}
139+
140+
func (g *glibcPatchFlake) fetchClosureExpr(pkg *devpkg.Package) (string, error) {
141+
storePath, err := pkg.InputAddressedPath()
142+
if err != nil {
143+
return "", err
144+
}
145+
return fmt.Sprintf(`builtins.fetchClosure {
146+
fromStore = "%s";
147+
fromPath = "%s";
148+
inputAddressed = true;
149+
}`, devpkg.BinaryCache, storePath), nil
150+
}
151+
152+
func (g *glibcPatchFlake) writeTo(dir string) error {
153+
err := writeFromTemplate(dir, g, "glibc-patch.nix", "flake.nix")
154+
if err != nil {
155+
return err
156+
}
157+
return writeGlibcPatchScript(filepath.Join(dir, "glibc-patch.bash"))
158+
}

0 commit comments

Comments
 (0)