diff --git a/internal/boxcli/patch.go b/internal/boxcli/patch.go new file mode 100644 index 00000000000..ae69c2c4bb5 --- /dev/null +++ b/internal/boxcli/patch.go @@ -0,0 +1,22 @@ +package boxcli + +import ( + "github.com/spf13/cobra" + "go.jetpack.io/devbox/internal/patchpkg" +) + +func patchCmd() *cobra.Command { + return &cobra.Command{ + Use: "patch ", + Short: "Apply Devbox patches to a package to fix common linker errors", + Args: cobra.ExactArgs(1), + Hidden: true, + RunE: func(cmd *cobra.Command, args []string) error { + builder, err := patchpkg.NewDerivationBuilder() + if err != nil { + return err + } + return builder.Build(cmd.Context(), args[0]) + }, + } +} diff --git a/internal/boxcli/root.go b/internal/boxcli/root.go index e3910b52ddd..e30ba6a363b 100644 --- a/internal/boxcli/root.go +++ b/internal/boxcli/root.go @@ -70,6 +70,7 @@ func RootCmd() *cobra.Command { command.AddCommand(integrateCmd()) command.AddCommand(listCmd()) command.AddCommand(logCmd()) + command.AddCommand(patchCmd()) command.AddCommand(removeCmd()) command.AddCommand(runCmd(runFlagDefaults{})) command.AddCommand(searchCmd()) diff --git a/internal/patchpkg/builder.go b/internal/patchpkg/builder.go new file mode 100644 index 00000000000..32190124141 --- /dev/null +++ b/internal/patchpkg/builder.go @@ -0,0 +1,156 @@ +// patchpkg patches packages to fix common linker errors. +package patchpkg + +import ( + "bytes" + "context" + _ "embed" + "fmt" + "io" + "io/fs" + "iter" + "log/slog" + "os" + "os/exec" + "path/filepath" +) + +//go:embed glibc-patch.bash +var glibcPatchScript []byte + +// DerivationBuilder patches an existing package. +type DerivationBuilder struct { + // Out is the output directory that will contain the built derivation. + // If empty it defaults to $out, which is typically set by Nix. + Out string +} + +// NewDerivationBuilder initializes a new DerivationBuilder from the current +// Nix build environment. +func NewDerivationBuilder() (*DerivationBuilder, error) { + d := &DerivationBuilder{} + if err := d.init(); err != nil { + return nil, err + } + return d, nil +} + +func (d *DerivationBuilder) init() error { + if d.Out == "" { + d.Out = os.Getenv("out") + if d.Out == "" { + return fmt.Errorf("patchpkg: $out is empty (is this being run from a nix build?)") + } + } + return nil +} + +// Build applies patches to a package store path and puts the result in the +// d.Out directory. +func (d *DerivationBuilder) Build(ctx context.Context, pkgStorePath string) error { + slog.DebugContext(ctx, "starting build of patched package", "pkg", pkgStorePath, "out", d.Out) + + var err error + pkgFS := os.DirFS(pkgStorePath) + for path, entry := range allFiles(pkgFS, ".") { + switch { + case entry.IsDir(): + err = d.copyDir(path) + case isSymlink(entry.Type()): + err = d.copySymlink(pkgStorePath, path) + default: + err = d.copyFile(pkgFS, path) + } + + if err != nil { + return err + } + } + + bash := filepath.Join(os.Getenv("bash"), "bin/bash") + cmd := exec.CommandContext(ctx, bash, "-s") + cmd.Stdin = bytes.NewReader(glibcPatchScript) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func (d *DerivationBuilder) copyDir(path string) error { + osPath, err := filepath.Localize(path) + if err != nil { + return err + } + return os.Mkdir(filepath.Join(d.Out, osPath), 0o777) +} + +func (d *DerivationBuilder) copyFile(pkgFS fs.FS, path string) error { + src, err := pkgFS.Open(path) + if err != nil { + return err + } + defer src.Close() + + stat, err := src.Stat() + if err != nil { + return err + } + + // We only need to copy the executable permissions of a file. + // Nix ends up making everything in the store read-only after + // the build is done. + perm := fs.FileMode(0o666) + if isExecutable(stat.Mode()) { + perm = fs.FileMode(0o777) + } + + osPath, err := filepath.Localize(path) + if err != nil { + return err + } + dstPath := filepath.Join(d.Out, osPath) + + dst, err := os.OpenFile(dstPath, os.O_CREATE|os.O_WRONLY|os.O_EXCL, perm) + if err != nil { + return err + } + defer dst.Close() + + _, err = io.Copy(dst, src) + if err != nil { + return err + } + return dst.Close() +} + +func (d *DerivationBuilder) copySymlink(pkgStorePath, path string) error { + // The fs package doesn't support symlinks, so we need to convert the + // path back to an OS path to see what it points to. + osPath, err := filepath.Localize(path) + if err != nil { + return err + } + target, err := os.Readlink(filepath.Join(pkgStorePath, osPath)) + if err != nil { + return err + } + // TODO(gcurtis): translate absolute symlink targets to relative paths. + return os.Symlink(target, filepath.Join(d.Out, osPath)) +} + +// RegularFiles iterates over all files in fsys starting at root. It silently +// ignores errors. +func allFiles(fsys fs.FS, root string) iter.Seq2[string, fs.DirEntry] { + return func(yield func(string, fs.DirEntry) bool) { + _ = fs.WalkDir(fsys, root, func(path string, d fs.DirEntry, err error) error { + if err == nil { + if !yield(path, d) { + return filepath.SkipAll + } + } + return nil + }) + } +} + +func isExecutable(mode fs.FileMode) bool { return mode&0o111 != 0 } +func isSymlink(mode fs.FileMode) bool { return mode&fs.ModeSymlink != 0 } diff --git a/internal/shellgen/tmpl/glibc-patch.bash b/internal/patchpkg/glibc-patch.bash similarity index 91% rename from internal/shellgen/tmpl/glibc-patch.bash rename to internal/patchpkg/glibc-patch.bash index d8fe9ba9699..2539135345c 100644 --- a/internal/shellgen/tmpl/glibc-patch.bash +++ b/internal/patchpkg/glibc-patch.bash @@ -23,14 +23,6 @@ hash -p "$gnused/bin/sed" sed hash -p "$patchelf/bin/patchelf" patchelf hash -p "$ripgrep/bin/rg" rg -# Copy the contents of the original package so we can patch them. -cp -R "$pkg" "$out" - -# Because we copied an existing store path, our new $out directory might be -# read-only. This might've caused issues with some versions of Nix, so make it -# writable again just to be safe. -chmod u+rwx "$out" - # Find the new linker that we'll patch into all of the package's executables as # the interpreter. interp="$(find "$glibc/lib" -type f -maxdepth 1 -executable -name 'ld-linux-*.so*' | head -n1)" diff --git a/internal/shellgen/flake_plan.go b/internal/shellgen/flake_plan.go index bb0c6529d4a..7262a57a0c7 100644 --- a/internal/shellgen/flake_plan.go +++ b/internal/shellgen/flake_plan.go @@ -3,6 +3,7 @@ package shellgen import ( "context" "fmt" + "os" "path/filepath" "runtime/trace" "strings" @@ -68,6 +69,10 @@ func (f *flakePlan) needsGlibcPatch() bool { } type glibcPatchFlake struct { + // DevboxExecutable is the absolute path to the Devbox binary to use as + // the flake's builder. It must not be the wrapper script. + DevboxExecutable string + // NixpkgsGlibcFlakeRef is a flake reference to the nixpkgs flake // containing the new glibc package. NixpkgsGlibcFlakeRef string @@ -85,7 +90,21 @@ type glibcPatchFlake struct { } func newGlibcPatchFlake(nixpkgsGlibcRev string, packages []*devpkg.Package) (glibcPatchFlake, error) { - flake := glibcPatchFlake{NixpkgsGlibcFlakeRef: "flake:nixpkgs/" + nixpkgsGlibcRev} + // Get the path to the actual devbox binary (not the /usr/bin/devbox + // wrapper) so the flake build can use it. + exe, err := os.Executable() + if err != nil { + return glibcPatchFlake{}, err + } + exe, err = filepath.EvalSymlinks(exe) + if err != nil { + return glibcPatchFlake{}, err + } + + flake := glibcPatchFlake{ + DevboxExecutable: exe, + NixpkgsGlibcFlakeRef: "flake:nixpkgs/" + nixpkgsGlibcRev, + } for _, pkg := range packages { if !pkg.PatchGlibc() { continue @@ -143,9 +162,5 @@ func (g *glibcPatchFlake) fetchClosureExpr(pkg *devpkg.Package) (string, error) } func (g *glibcPatchFlake) writeTo(dir string) error { - err := writeFromTemplate(dir, g, "glibc-patch.nix", "flake.nix") - if err != nil { - return err - } - return writeGlibcPatchScript(filepath.Join(dir, "glibc-patch.bash")) + return writeFromTemplate(dir, g, "glibc-patch.nix", "flake.nix") } diff --git a/internal/shellgen/generate.go b/internal/shellgen/generate.go index 65699fe1886..57d8e37acc9 100644 --- a/internal/shellgen/generate.go +++ b/internal/shellgen/generate.go @@ -8,7 +8,6 @@ import ( "bytes" "context" "embed" - "io/fs" "os" "path/filepath" "runtime/trace" @@ -102,20 +101,6 @@ func writeFromTemplate(path string, plan any, tmplName, generatedName string) er 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 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) - } - 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 { diff --git a/internal/shellgen/tmpl/glibc-patch.nix.tmpl b/internal/shellgen/tmpl/glibc-patch.nix.tmpl index fdb7a2408ff..f9bc2c35e42 100644 --- a/internal/shellgen/tmpl/glibc-patch.nix.tmpl +++ b/internal/shellgen/tmpl/glibc-patch.nix.tmpl @@ -2,6 +2,11 @@ description = "Patches packages to use a newer version of glibc"; inputs = { + local-devbox = { + url = "path://{{ .DevboxExecutable }}"; + flake = false; + }; + nixpkgs-glibc.url = "{{ .NixpkgsGlibcFlakeRef }}"; {{- range $name, $flakeref := .Inputs }} @@ -9,7 +14,7 @@ {{- end }} }; - outputs = args@{ self, nixpkgs-glibc {{- range $name, $_ := .Inputs -}}, {{ $name }} {{- end }} }: + outputs = args@{ self, local-devbox, nixpkgs-glibc {{- range $name, $_ := .Inputs -}}, {{ $name }} {{- end }} }: {{ with .Outputs -}} let # Initialize each nixpkgs input into a new attribute set with the @@ -30,17 +35,35 @@ else null) args; patchGlibc = pkg: derivation rec { - name = "devbox-patched-glibc"; - system = pkg.system; - # The package we're patching. inherit pkg; + # Keep the name the same as the package we're patching so that the + # length of the store path doesn't change. Otherwise patching binaries + # becomes trickier. + name = pkg.name; + system = pkg.system; + # Programs needed by glibc-patch.bash. inherit (nixpkgs-glibc.legacyPackages."${system}") bash coreutils file findutils glibc gnused patchelf ripgrep; - builder = "${bash}/bin/bash"; - args = [ ./glibc-patch.bash ]; + # Create a package that puts the local devbox binary in the conventional + # bin subdirectory. This also ensures that the executable is named + # "devbox" and not "-source" (which is how Nix names the flake + # input). Invoking it as anything other than "devbox" will break + # testscripts which look at os.Args[0] to decide to run the real + # entrypoint or the test entrypoint. + devbox = derivation { + name = "devbox"; + system = pkg.system; + builder = "${bash}/bin/bash"; + + # exit 0 to work around https://github.com/NixOS/nix/issues/2176 + args = [ "-c" "${coreutils}/bin/mkdir -p $out/bin && ${coreutils}/bin/cp ${local-devbox} $out/bin/devbox && exit 0" ]; + }; + + builder = "${devbox}/bin/devbox"; + args = [ "patch" pkg ]; }; in {