Skip to content

devconfig,shellgen: option to patch ELF binaries with newer glibc #1574

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Oct 31, 2023
4 changes: 4 additions & 0 deletions internal/devconfig/packages.go
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,10 @@ type Package struct {

Platforms []string `json:"platforms,omitempty"`
ExcludedPlatforms []string `json:"excluded_platforms,omitempty"`

// PatchGlibc applies a function to the package's derivation that
// patches any ELF binaries to use the latest version of nixpkgs#glibc.
PatchGlibc bool `json:"patch_glibc,omitempty"`
}

func NewVersionOnlyPackage(name, version string) Package {
Expand Down
10 changes: 8 additions & 2 deletions internal/devpkg/package.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ type Package struct {
// example: github:nixos/nixpkgs/5233fd2ba76a3accb5aaa999c00509a11fd0793c#hello
Raw string

// PatchGlibc applies a function to the package's derivation that
// patches any ELF binaries to use the latest version of nixpkgs#glibc.
PatchGlibc bool

// isInstallable is true if the package may be enabled on the current platform.
isInstallable bool

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

func PackagesFromConfig(config *devconfig.Config, l lock.Locker) []*Package {
result := []*Package{}
for _, pkg := range config.Packages.Collection {
result = append(result, newPackage(pkg.VersionedName(), pkg.IsEnabledOnPlatform(), l))
for _, cfgPkg := range config.Packages.Collection {
pkg := newPackage(cfgPkg.VersionedName(), cfgPkg.IsEnabledOnPlatform(), l)
pkg.PatchGlibc = cfgPkg.PatchGlibc
result = append(result, pkg)
}
return result
}
Expand Down
45 changes: 25 additions & 20 deletions internal/shellgen/flake_input.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,28 +47,33 @@ func (f *flakeInput) PkgImportName() string {
return f.Name + "-pkgs"
}

func (f *flakeInput) BuildInputs() ([]string, error) {
var err error
attributePaths := lo.Map(f.Packages, func(pkg *devpkg.Package, _ int) string {
attributePath, attributePathErr := pkg.FullPackageAttributePath()
if attributePathErr != nil {
err = attributePathErr
}
return attributePath
})
if err != nil {
return nil, err
type buildInput struct {
AttrPath string
PatchGlibc bool
}

func (f *flakeInput) BuildInputs() ([]buildInput, error) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should be able to revert this as well once you do the individual flake approach since PatchGlibc is not needed in the top level flake.

inputs := make([]buildInput, len(f.Packages))
prefix := f.Name
if f.IsNixpkgs() {
prefix = f.PkgImportName()
}
if !f.IsNixpkgs() {
return lo.Map(attributePaths, func(pkg string, _ int) string {
return f.Name + "." + pkg
}), nil
prefix += "."
for i, pkg := range f.Packages {
attrPath, err := pkg.FullPackageAttributePath()
if err != nil {
return nil, err
}
if f.IsNixpkgs() {
// Remove the legacyPackages.<system> prefix.
attrPath = strings.SplitN(attrPath, ".", 3)[2]
}
inputs[i] = buildInput{
AttrPath: prefix + attrPath,
PatchGlibc: pkg.PatchGlibc,
}
}
return lo.Map(attributePaths, func(pkg string, _ int) string {
parts := strings.Split(pkg, ".")
// Ugh, not sure if this is reliable?
return f.PkgImportName() + "." + strings.Join(parts[2:], ".")
}), nil
return inputs, nil
}

// flakeInputs returns a list of flake inputs for the top level flake.nix
Expand Down
7 changes: 7 additions & 0 deletions internal/shellgen/flake_plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package shellgen
import (
"context"
"runtime/trace"
"slices"

"go.jetpack.io/devbox/internal/devpkg"
"go.jetpack.io/devbox/internal/nix"
Expand Down Expand Up @@ -65,3 +66,9 @@ func newFlakePlan(ctx context.Context, devbox devboxer) (*flakePlan, error) {
System: nix.System(),
}, nil
}

func (f flakePlan) PatchGlibc() bool {
return slices.ContainsFunc(f.Packages, func(p *devpkg.Package) bool {
return p.PatchGlibc
})
}
119 changes: 78 additions & 41 deletions internal/shellgen/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@
package shellgen

import (
"bufio"
"bytes"
"context"
"embed"
"io"
"io/fs"
"os"
"os/exec"
Expand All @@ -20,6 +20,7 @@ import (
"go.jetpack.io/devbox/internal/boxcli/featureflag"
"go.jetpack.io/devbox/internal/cuecfg"
"go.jetpack.io/devbox/internal/debug"
"go.jetpack.io/devbox/internal/redact"
)

//go:embed tmpl/*
Expand Down Expand Up @@ -61,10 +62,7 @@ func GenerateForPrintEnv(ctx context.Context, devbox devboxer) error {
// Cache and buffers for generating templated files.
var (
tmplCache = map[string]*template.Template{}

// Most generated files are < 4KiB.
tmplNewBuf = bytes.NewBuffer(make([]byte, 0, 4096))
tmplOldBuf = bytes.NewBuffer(make([]byte, 0, 4096))
tmplBuf bytes.Buffer
)

func writeFromTemplate(path string, plan any, tmplName, generatedName string) error {
Expand All @@ -75,61 +73,92 @@ func writeFromTemplate(path string, plan any, tmplName, generatedName string) er
tmpl.Funcs(templateFuncs)

var err error
tmpl, err = tmpl.ParseFS(tmplFS, "tmpl/"+tmplKey)
glob := "tmpl/" + tmplKey
tmpl, err = tmpl.ParseFS(tmplFS, glob)
if err != nil {
return errors.WithStack(err)
return redact.Errorf("parse embedded tmplFS glob %q: %v", redact.Safe(glob), redact.Safe(err))
}
tmplCache[tmplKey] = tmpl
}
tmplNewBuf.Reset()
if err := tmpl.Execute(tmplNewBuf, plan); err != nil {
return errors.WithStack(err)
tmplBuf.Reset()
if err := tmpl.Execute(&tmplBuf, plan); err != nil {
return redact.Errorf("execute template %s: %v", redact.Safe(tmplKey), err)
}

// In some circumstances, Nix looks at the mod time of a file when
// caching, so we only want to update the file if something has
// changed. Blindly overwriting the file could invalidate Nix's cache
// every time, slowing down evaluation considerably.
var (
outPath = filepath.Join(path, generatedName)
flag = os.O_RDWR | os.O_CREATE
perm = fs.FileMode(0o644)
)
outFile, err := os.OpenFile(outPath, flag, perm)
if errors.Is(err, fs.ErrNotExist) {
if err := os.MkdirAll(path, 0o755); err != nil {
return errors.WithStack(err)
}
outFile, err = os.OpenFile(outPath, flag, perm)
err := overwriteFileIfChanged(filepath.Join(path, generatedName), tmplBuf.Bytes(), 0o644)
if err != nil {
return redact.Errorf("write %s to file: %v", redact.Safe(tmplName), err)
}
return nil
}

// writeGlibcPatchScript writes the embedded glibc patching script to disk so
// that a generated flake can use it.
func writeGlibcPatchScript(path string) error {
script, err := fs.ReadFile(tmplFS, "tmpl/glibc-patch.bash")
if err != nil {
return errors.WithStack(err)
return redact.Errorf("read embedded glibc-patch.bash: %v", redact.Safe(err))
}
err = overwriteFileIfChanged(path, script, 0o755)
if err != nil {
return redact.Errorf("write glibc-patch.bash to file: %v", err)
}
defer outFile.Close()
return nil
}

// overwriteFileIfChanged checks that the contents of f == data, and overwrites
// f if they differ. It also ensures that f's permissions are set to perm.
func overwriteFileIfChanged(path string, data []byte, perm os.FileMode) error {
flag := os.O_RDWR | os.O_CREATE
file, err := os.OpenFile(path, flag, perm)
if errors.Is(err, os.ErrNotExist) {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}

// Only read len(tmplWriteBuf) + 1 from the existing file so we can
// check if the lengths are different without reading the whole thing.
tmplOldBuf.Reset()
tmplOldBuf.Grow(tmplNewBuf.Len() + 1)
_, err = io.Copy(tmplOldBuf, io.LimitReader(outFile, int64(tmplNewBuf.Len())+1))
// Definitely a new file if we had to make the directory.
return os.WriteFile(path, data, perm)
}
if err != nil {
return errors.WithStack(err)
return err
}
if bytes.Equal(tmplNewBuf.Bytes(), tmplOldBuf.Bytes()) {
return nil
defer file.Close()

fi, err := file.Stat()
if err != nil || fi.Mode().Perm() != perm {
if err := file.Chmod(perm); err != nil {
return err
}
}

// Replace the existing file contents.
if _, err := outFile.Seek(0, io.SeekStart); err != nil {
return errors.WithStack(err)
// Fast path - check if the lengths differ.
if err == nil && fi.Size() != int64(len(data)) {
return overwriteFile(file, data, 0)
}
if err := outFile.Truncate(int64(tmplNewBuf.Len())); err != nil {
return errors.WithStack(err)

r := bufio.NewReader(file)
for offset := range data {
b, err := r.ReadByte()
if err != nil || b != data[offset] {
return overwriteFile(file, data, offset)
}
}
if _, err := io.Copy(outFile, tmplNewBuf); err != nil {
return errors.WithStack(err)
return nil
}

// overwriteFile truncates f to len(data) and writes data[offset:] beginning at
// the same offset in f.
func overwriteFile(f *os.File, data []byte, offset int) error {
err := f.Truncate(int64(len(data)))
if err != nil {
return err
}
return errors.WithStack(outFile.Close())
_, err = f.WriteAt(data[offset:], int64(offset))
return err
}

func toJSON(a any) string {
Expand Down Expand Up @@ -157,6 +186,13 @@ func makeFlakeFile(d devboxer, outPath string, plan *flakePlan) error {
return errors.WithStack(err)
}

if plan.PatchGlibc() {
err := writeGlibcPatchScript(filepath.Join(flakeDir, "glibc-patch.bash"))
if err != nil {
return err
}
}

if !isProjectInGitRepo(outPath) {
// if we are not in a git repository, then carry on
return nil
Expand All @@ -178,8 +214,9 @@ func makeFlakeFile(d devboxer, outPath string, plan *flakePlan) error {
return errors.WithStack(err)
}

// add the flake.nix file to git
cmd = exec.Command("git", "-C", flakeDir, "add", "flake.nix")
// Any files that flake.nix needs at build time must be in git.
// Otherwise, Nix won't copy it into the flake's build environment.
cmd = exec.Command("git", "-C", flakeDir, "add", ".")
if debug.IsEnabled() {
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
Expand Down
48 changes: 45 additions & 3 deletions internal/shellgen/tmpl/flake_remove_nixpkgs.nix.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,14 @@

inputs = {
nixpkgs.url = "{{ .NixpkgsInfo.URL }}";

{{- range .FlakeInputs }}
{{.Name}}.url = "{{.URLWithCaching}}";
{{- end }}

{{- if .PatchGlibc }}
nixpkgs-unstable.url = "nixpkgs";
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are the semantics for when the concrete resolution for this changes:

  • is it everytime we use the generated flake.nix?

How can we ensure it is locked and reproducible for users?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should be locked by the flake.lock file. I'm not as familiar with the updating logic, but I think it should only change when devbox update is run.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, generally speaking, devbox update doesn't affect the flake.lock directly. It does affect how the flake.nix's inputs are defined, and in that manner will affect the flake.lock file.

I think this could be problematic. My understanding is:

  1. In general, in a flake, nixpkgs will resolve to the current nix channel for the user.
  2. The flake.lock will be generated from that. For users on a team, this could pin nixpkgs to different commit-hashes in the generated flake.lock, assuming they are even on the same nix channel.

Let me think some more (have to go to a meeting, jsut sharing my prelim. thoughts)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make this a hard coded reference.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As discussed:

avoid creating nixpkgs-unstable and just use nixpkgs from above. Just set nixpkgs to be the hard coded commit you want in go code.

{{- end }}
};

outputs = {
Expand All @@ -14,6 +19,9 @@
{{- range .FlakeInputs }}
{{.Name}},
{{- end }}
{{- if .PatchGlibc }}
nixpkgs-unstable,
{{- end }}
}:
let
pkgs = nixpkgs.legacyPackages.{{ .System }};
Expand All @@ -29,25 +37,59 @@
{{- end }}
{{- end }}
];

overlays = [
{{- range $flake.Packages }}
{{- if .PatchGlibc }}
(final: prev: {
{{.PackageAttributePath}} = prev.{{.PackageAttributePath}};
})
{{- end }}
{{- end }}
];
});
{{- end }}
{{- end }}

{{ if .PatchGlibc -}}
patchGlibc = pkg: derivation {
name = "devbox-patched-glibc";
system = "{{.System}}";

# Set these attributes so that glibc-patch.bash has access to their
# paths via environment variables of the same name.
inherit pkg;
glibc = nixpkgs.legacyPackages."{{.System}}".glibc;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest using stdenv.cc.libc_lib. I don't know if glibc nix package is always reliably updated. For example, glibc 2.36 versions are missing https://www.nixhub.io/packages/glibc

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I specified glibc because the patch would break if the stdenv libc was different (for example, it could be musl).

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good point

coreutils = nixpkgs.legacyPackages."{{.System}}".coreutils;
file = nixpkgs.legacyPackages."{{.System}}".file;
findutils = nixpkgs.legacyPackages."{{.System}}".findutils;
patchelf = nixpkgs.legacyPackages."{{.System}}".patchelf;
ripgrep = nixpkgs.legacyPackages."{{.System}}".ripgrep;

builder = "${nixpkgs-unstable.legacyPackages.{{.System}}.bash}/bin/bash";
args = [ ./glibc-patch.bash ];
};
{{ end }}
in
{
devShells.{{ .System }}.default = pkgs.mkShell {
buildInputs = [
{{- range .Packages }}
{{- if .IsInBinaryCache }}
(builtins.fetchClosure{
{{ if .IsInBinaryCache -}}
{{ if .PatchGlibc -}} (patchGlibc {{ end -}}
(builtins.fetchClosure {
fromStore = "{{ $.BinaryCache }}";
fromPath = "{{ .InputAddressedPath }}";
inputAddressed = true;
})
{{- if .PatchGlibc -}} ) {{- end -}}
{{- end }}
{{- end }}
{{- range .FlakeInputs }}
{{- range .BuildInputs }}
{{.}}
{{ if .PatchGlibc -}} (patchGlibc {{ end -}}
{{.AttrPath}}
{{- if .PatchGlibc -}} ) {{- end -}}
{{- end }}
{{- end }}
];
Expand Down
Loading