diff --git a/.github/workflows/crosscompile-e2e.yml b/.github/workflows/crosscompile-e2e.yml new file mode 100644 index 0000000..4307d45 --- /dev/null +++ b/.github/workflows/crosscompile-e2e.yml @@ -0,0 +1,415 @@ +name: Cross Compile E2E (Linux hosts to arm64 targets) + +on: + push: + pull_request: + +permissions: + contents: read + +concurrency: + group: crosscompile-e2e-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + build-linux-arm64: + name: "Build C libs: Linux amd64 -> Linux arm64" + runs-on: ubuntu-24.04 + timeout-minutes: 25 + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: 1.24.x + + - name: Install LLVM + run: | + sudo apt-get update + sudo apt-get install --yes clang-18 lld-18 llvm-18 pkg-config + echo "/usr/lib/llvm-18/bin" >> "$GITHUB_PATH" + + - name: Download Bootlin glibc and musl arm64 toolchains + run: | + default_archive="$RUNNER_TEMP/aarch64--glibc--stable-2017.05-toolchains-1-1.tar.bz2" + custom_archive="$RUNNER_TEMP/aarch64--glibc--stable-2018.11-1.tar.bz2" + musl_archive="$RUNNER_TEMP/aarch64--musl--stable-2018.11-1.tar.bz2" + + curl --fail --location --retry 3 \ + "https://toolchains.bootlin.com/downloads/releases/toolchains/aarch64/tarballs/aarch64--glibc--stable-2017.05-toolchains-1-1.tar.bz2" \ + --output "$default_archive" + curl --fail --location --retry 3 \ + "https://toolchains.bootlin.com/downloads/releases/toolchains/aarch64/tarballs/aarch64--glibc--stable-2018.11-1.tar.bz2" \ + --output "$custom_archive" + curl --fail --location --retry 3 \ + "https://toolchains.bootlin.com/downloads/releases/toolchains/aarch64/tarballs/aarch64--musl--stable-2018.11-1.tar.bz2" \ + --output "$musl_archive" + echo "530137589c4588599ebd115c12630c75e79c935ac4f34b0941404036f5e25026 $default_archive" | sha256sum --check + echo "abae0522480b9f37ff6cee4249e147e7cb78e1997cc6f76dba7e0fb8ec04221d $custom_archive" | sha256sum --check + echo "ce2d60c4f33cdc1c09ab8e10316b76639da71c4af1ab1948f3547fc05b814796 $musl_archive" | sha256sum --check + + - name: Prepare formula and source repositories + run: | + formula_repo="$RUNNER_TEMP/llarhub" + source_repo="$RUNNER_TEMP/glibc-source" + musl_source_repo="$RUNNER_TEMP/musl-source" + consumer_source_repo="$RUNNER_TEMP/pkgconfig-consumer-source" + + mkdir -p "$formula_repo" "$source_repo" "$musl_source_repo" "$consumer_source_repo" + cp -R testdata/kodo-e2e/formulas/. "$formula_repo/" + cp -R testdata/crosscompile-e2e/formulas/. "$formula_repo/" + git -C "$formula_repo" init --initial-branch=main + git -C "$formula_repo" config user.email crosscompile-e2e@example.com + git -C "$formula_repo" config user.name "Cross Compile E2E" + git -C "$formula_repo" add . + git -C "$formula_repo" commit --quiet -m "Cross compile E2E formulas" + + cp testdata/crosscompile-e2e/install-bootlin-sysroot "$source_repo/install-sysroot" + cp "$RUNNER_TEMP/aarch64--glibc--stable-2017.05-toolchains-1-1.tar.bz2" "$source_repo/" + chmod +x "$source_repo/install-sysroot" + git -C "$source_repo" init --initial-branch=main + git -C "$source_repo" config user.email crosscompile-e2e@example.com + git -C "$source_repo" config user.name "Cross Compile E2E" + git -C "$source_repo" add . + git -C "$source_repo" commit --quiet -m "Add glibc 2.24 toolchain" + git -C "$source_repo" tag glibc-2.24 + + git -C "$source_repo" rm --quiet aarch64--glibc--stable-2017.05-toolchains-1-1.tar.bz2 + cp "$RUNNER_TEMP/aarch64--glibc--stable-2018.11-1.tar.bz2" "$source_repo/" + git -C "$source_repo" add aarch64--glibc--stable-2018.11-1.tar.bz2 + git -C "$source_repo" commit --quiet -m "Update toolchain to glibc 2.27" + git -C "$source_repo" tag glibc-2.27 + + cp testdata/crosscompile-e2e/install-bootlin-sysroot "$musl_source_repo/install-sysroot" + cp "$RUNNER_TEMP/aarch64--musl--stable-2018.11-1.tar.bz2" "$musl_source_repo/" + chmod +x "$musl_source_repo/install-sysroot" + git -C "$musl_source_repo" init --initial-branch=main + git -C "$musl_source_repo" config user.email crosscompile-e2e@example.com + git -C "$musl_source_repo" config user.name "Cross Compile E2E" + git -C "$musl_source_repo" add . + git -C "$musl_source_repo" commit --quiet -m "Add musl 1.1.19 toolchain" + git -C "$musl_source_repo" tag v1.1.19 + + cp testdata/crosscompile-e2e/pkgconfig-consumer/project.cmake "$consumer_source_repo/CMakeLists.txt" + cp testdata/crosscompile-e2e/consumer.c "$consumer_source_repo/main.c" + git -C "$consumer_source_repo" init --initial-branch=main + git -C "$consumer_source_repo" config user.email crosscompile-e2e@example.com + git -C "$consumer_source_repo" config user.name "Cross Compile E2E" + git -C "$consumer_source_repo" add . + git -C "$consumer_source_repo" commit --quiet -m "Add pkg-config dependency consumer" + git -C "$consumer_source_repo" tag v1.0.0 + + git config --global protocol.file.allow always + git config --global url."file://$formula_repo".insteadOf "https://github.com/goplus/llarhub.git" + git config --global url."file://$source_repo".insteadOf "https://github.com/bminor/glibc.git" + git config --global url."file://$musl_source_repo".insteadOf "https://github.com/ifduyue/musl.git" + git config --global url."file://$consumer_source_repo".insteadOf "https://github.com/llar/pkgconfig-consumer.git" + + - name: Cross compile zlib with default sysroot + run: | + go build -ldflags="-checklinkname=0" -o "$RUNNER_TEMP/llar" ./cmd/llar + "$RUNNER_TEMP/llar" make madler/zlib@v1.3.1 \ + --os linux \ + --arch arm64 \ + --output "$RUNNER_TEMP/zlib-linux-arm64" \ + --verbose + + - name: Cross compile zlib with Formula-managed glibc + run: | + custom_log="$RUNNER_TEMP/zlib-linux-arm64-custom-libc.log" + "$RUNNER_TEMP/llar" make madler/zlib@v1.3.1 \ + --os linux \ + --arch arm64 \ + --require libc=glibc-2.27 \ + --output "$RUNNER_TEMP/zlib-linux-arm64-custom-libc" \ + --json \ + --verbose \ + > "$RUNNER_TEMP/zlib-linux-arm64-custom-libc.json" \ + 2> >(tee "$custom_log" >&2) + grep -F '"path":"bminor/glibc","version":"glibc-2.27"' \ + "$RUNNER_TEMP/zlib-linux-arm64-custom-libc.json" + grep -E -- '--sysroot=[^ ]*/bminor/glibc@glibc-2\.27-' "$custom_log" + if grep -F -- 'bminor/glibc@glibc-2.24-' "$custom_log"; then + echo "custom libc build used the default glibc 2.24 sysroot" >&2 + exit 1 + fi + + - name: Cross compile pkg-config dependency consumer + run: | + "$RUNNER_TEMP/llar" make llar/pkgconfig-consumer@v1.0.0 \ + --os linux \ + --arch arm64 \ + --output "$RUNNER_TEMP/pkgconfig-consumer-linux-arm64" \ + --verbose + + - name: Cross compile libuv with Formula-managed musl + run: | + musl_log="$RUNNER_TEMP/libuv-linux-arm64-musl.log" + "$RUNNER_TEMP/llar" make libuv/libuv@v1.49.2 \ + --os linux \ + --arch arm64 \ + --require libc=musl-1.1.19 \ + --output "$RUNNER_TEMP/libuv-linux-arm64-musl" \ + --json \ + --verbose \ + > "$RUNNER_TEMP/libuv-linux-arm64-musl.json" \ + 2> >(tee "$musl_log" >&2) + grep -F '"path":"ifduyue/musl","version":"v1.1.19"' \ + "$RUNNER_TEMP/libuv-linux-arm64-musl.json" + grep -E -- '--sysroot=[^ ]*/ifduyue/musl@v1\.1\.19-' "$musl_log" + grep -F -- '--target=aarch64-linux-musl' "$musl_log" + + - name: Upload default-sysroot zlib artifact + uses: actions/upload-artifact@v4 + with: + name: zlib-linux-arm64 + path: ${{ runner.temp }}/zlib-linux-arm64 + if-no-files-found: error + + - name: Upload custom-libc zlib artifact + uses: actions/upload-artifact@v4 + with: + name: zlib-linux-arm64-custom-libc + path: ${{ runner.temp }}/zlib-linux-arm64-custom-libc + if-no-files-found: error + + - name: Upload pkg-config consumer artifact + uses: actions/upload-artifact@v4 + with: + name: pkgconfig-consumer-linux-arm64 + path: ${{ runner.temp }}/pkgconfig-consumer-linux-arm64 + if-no-files-found: error + + - name: Upload musl libuv artifact + uses: actions/upload-artifact@v4 + with: + name: libuv-linux-arm64-musl + path: ${{ runner.temp }}/libuv-linux-arm64-musl + if-no-files-found: error + + run-linux-arm64: + name: "Run C libs: Linux arm64" + needs: build-linux-arm64 + runs-on: ubuntu-24.04-arm + timeout-minutes: 10 + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Install musl tools + run: | + sudo apt-get update + sudo apt-get install --yes musl-tools + + - name: Download default-sysroot zlib artifact + uses: actions/download-artifact@v4 + with: + name: zlib-linux-arm64 + path: ${{ runner.temp }}/zlib-linux-arm64 + + - name: Download custom-libc zlib artifact + uses: actions/download-artifact@v4 + with: + name: zlib-linux-arm64-custom-libc + path: ${{ runner.temp }}/zlib-linux-arm64-custom-libc + + - name: Download pkg-config consumer artifact + uses: actions/download-artifact@v4 + with: + name: pkgconfig-consumer-linux-arm64 + path: ${{ runner.temp }}/pkgconfig-consumer-linux-arm64 + + - name: Download musl libuv artifact + uses: actions/download-artifact@v4 + with: + name: libuv-linux-arm64-musl + path: ${{ runner.temp }}/libuv-linux-arm64-musl + + - name: Inspect, link, and run default-sysroot consumer + run: | + lib="$RUNNER_TEMP/zlib-linux-arm64/lib/libz.a" + member_dir="$RUNNER_TEMP/zlib-members" + consumer="$RUNNER_TEMP/zlib-consumer" + + mkdir -p "$member_dir" + ( + cd "$member_dir" + ar x "$lib" adler32.o + ) + file "$member_dir/adler32.o" | tee "$RUNNER_TEMP/zlib-format.txt" + grep -F "ELF 64-bit LSB relocatable, ARM aarch64" "$RUNNER_TEMP/zlib-format.txt" + + cc \ + -I"$RUNNER_TEMP/zlib-linux-arm64/include" \ + testdata/crosscompile-e2e/consumer.c \ + "$lib" \ + -o "$consumer" + ldd "$consumer" | tee "$RUNNER_TEMP/dynamic-libraries.txt" + grep -E "libc\\.so\\.6 => /" "$RUNNER_TEMP/dynamic-libraries.txt" + "$consumer" + + - name: Inspect, link, and run custom-libc consumer + run: | + lib="$RUNNER_TEMP/zlib-linux-arm64-custom-libc/lib/libz.a" + member_dir="$RUNNER_TEMP/zlib-custom-libc-members" + consumer="$RUNNER_TEMP/zlib-custom-libc-consumer" + + mkdir -p "$member_dir" + ( + cd "$member_dir" + ar x "$lib" adler32.o + ) + file "$member_dir/adler32.o" | tee "$RUNNER_TEMP/zlib-custom-libc-format.txt" + grep -F "ELF 64-bit LSB relocatable, ARM aarch64" "$RUNNER_TEMP/zlib-custom-libc-format.txt" + + cc \ + -I"$RUNNER_TEMP/zlib-linux-arm64-custom-libc/include" \ + testdata/crosscompile-e2e/consumer.c \ + "$lib" \ + -o "$consumer" + ldd "$consumer" | tee "$RUNNER_TEMP/custom-libc-dynamic-libraries.txt" + grep -E "libc\\.so\\.6 => /" "$RUNNER_TEMP/custom-libc-dynamic-libraries.txt" + "$consumer" + + - name: Inspect and run pkg-config dependency consumer + run: | + consumer="$RUNNER_TEMP/pkgconfig-consumer-linux-arm64/bin/llar-pkgconfig-consumer" + + chmod +x "$consumer" + file "$consumer" | tee "$RUNNER_TEMP/pkgconfig-consumer-format.txt" + grep -E "ELF 64-bit LSB.*ARM aarch64" "$RUNNER_TEMP/pkgconfig-consumer-format.txt" + ldd "$consumer" | tee "$RUNNER_TEMP/pkgconfig-consumer-dynamic-libraries.txt" + grep -E "libc\\.so\\.6 => /" "$RUNNER_TEMP/pkgconfig-consumer-dynamic-libraries.txt" + "$consumer" + + - name: Compare glibc and musl libuv ABIs + run: | + root="$RUNNER_TEMP/libuv-linux-arm64-musl" + lib="$root/lib/libuv.a" + glibc_consumer="$RUNNER_TEMP/libuv-glibc-consumer" + musl_consumer="$RUNNER_TEMP/libuv-musl-consumer" + + cc \ + -DEXPECT_ABI_MATCH=0 \ + -I"$root/include" \ + testdata/crosscompile-e2e/libuv-consumer.c \ + "$lib" \ + -pthread -ldl -lrt \ + -o "$glibc_consumer" + "$glibc_consumer" + + musl-gcc \ + -DEXPECT_ABI_MATCH=1 \ + -I"$root/include" \ + testdata/crosscompile-e2e/libuv-consumer.c \ + "$lib" \ + -pthread -ldl -lrt \ + -o "$musl_consumer" + readelf --program-headers "$musl_consumer" | tee "$RUNNER_TEMP/musl-program-headers.txt" + grep -F "/lib/ld-musl-aarch64.so.1" "$RUNNER_TEMP/musl-program-headers.txt" + "$musl_consumer" + + build-darwin-arm64: + name: "Build zlib: Linux amd64 host to Darwin arm64 target" + runs-on: ubuntu-24.04 + timeout-minutes: 25 + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: 1.24.x + + - name: Install LLVM + run: | + sudo apt-get update + sudo apt-get install --yes clang-18 lld-18 llvm-18 + echo "/usr/lib/llvm-18/bin" >> "$GITHUB_PATH" + + - name: Prepare macOS 14.5 SDK + run: | + archive="$RUNNER_TEMP/MacOSX14.5.sdk.tar.xz" + curl --fail --location --retry 3 \ + "https://github.com/joseluisq/macosx-sdks/releases/download/14.5/MacOSX14.5.sdk.tar.xz" \ + --output "$archive" + echo "6e146275d19f027faa2e8354da5e0267513abf013b8f16ad65a231653a2b1c5d $archive" | sha256sum --check + + - name: Prepare formula and source repositories + run: | + formula_repo="$RUNNER_TEMP/llarhub" + source_repo="$RUNNER_TEMP/macosx-sdks-source" + + mkdir -p "$formula_repo" "$source_repo" + cp -R testdata/kodo-e2e/formulas/. "$formula_repo/" + cp -R testdata/crosscompile-e2e/formulas/. "$formula_repo/" + git -C "$formula_repo" init --initial-branch=main + git -C "$formula_repo" config user.email crosscompile-e2e@example.com + git -C "$formula_repo" config user.name "Cross Compile E2E" + git -C "$formula_repo" add . + git -C "$formula_repo" commit --quiet -m "Cross compile E2E formulas" + + cp testdata/crosscompile-e2e/install-sysroot "$source_repo/" + cp "$RUNNER_TEMP/MacOSX14.5.sdk.tar.xz" "$source_repo/sysroot.tar.xz" + chmod +x "$source_repo/install-sysroot" + git -C "$source_repo" init --initial-branch=main + git -C "$source_repo" config user.email crosscompile-e2e@example.com + git -C "$source_repo" config user.name "Cross Compile E2E" + git -C "$source_repo" add . + git -C "$source_repo" commit --quiet -m "Add sysroot installer" + git -C "$source_repo" tag 14.5 + + git config --global protocol.file.allow always + git config --global url."file://$formula_repo".insteadOf "https://github.com/goplus/llarhub.git" + git config --global url."file://$source_repo".insteadOf "https://github.com/joseluisq/macosx-sdks.git" + + - name: Cross compile zlib + run: | + go build -ldflags="-checklinkname=0" -o "$RUNNER_TEMP/llar" ./cmd/llar + "$RUNNER_TEMP/llar" make madler/zlib@v1.3.1 \ + --os darwin \ + --arch arm64 \ + --output "$RUNNER_TEMP/zlib-darwin-arm64" \ + --verbose + + - name: Upload zlib artifact + uses: actions/upload-artifact@v4 + with: + name: zlib-darwin-arm64 + path: ${{ runner.temp }}/zlib-darwin-arm64 + if-no-files-found: error + + run-darwin-arm64: + name: "Run zlib: macOS arm64 runner consumes Darwin arm64 artifact" + needs: build-darwin-arm64 + runs-on: macos-14 + timeout-minutes: 10 + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Download zlib artifact + uses: actions/download-artifact@v4 + with: + name: zlib-darwin-arm64 + path: ${{ runner.temp }}/zlib-darwin-arm64 + + - name: Inspect, link, and run consumer + run: | + lib="$RUNNER_TEMP/zlib-darwin-arm64/lib/libz.a" + consumer="$RUNNER_TEMP/zlib-consumer" + + lipo -info "$lib" | tee "$RUNNER_TEMP/zlib-format.txt" + grep -F "architecture: arm64" "$RUNNER_TEMP/zlib-format.txt" + + cc \ + -I"$RUNNER_TEMP/zlib-darwin-arm64/include" \ + testdata/crosscompile-e2e/consumer.c \ + "$lib" \ + -o "$consumer" + otool -L "$consumer" | tee "$RUNNER_TEMP/dynamic-libraries.txt" + grep -F "/usr/lib/libSystem.B.dylib" "$RUNNER_TEMP/dynamic-libraries.txt" + "$consumer" diff --git a/.github/workflows/llard-cluster-e2e.yml b/.github/workflows/llard-cluster-e2e.yml index 88e3c23..8f69d92 100644 --- a/.github/workflows/llard-cluster-e2e.yml +++ b/.github/workflows/llard-cluster-e2e.yml @@ -34,6 +34,12 @@ jobs: with: go-version: 1.24.x + - name: Install LLVM + run: | + sudo apt-get update + sudo apt-get install --yes clang-18 lld-18 llvm-18 + echo "/usr/lib/llvm-18/bin" >> "$GITHUB_PATH" + - name: Join private network uses: tailscale/github-action@v4 with: @@ -47,18 +53,40 @@ jobs: go build -ldflags="-checklinkname=0" -o "$RUNNER_TEMP/llard" ./cmd/llard go build -o "$RUNNER_TEMP/llard-e2e-control" ./testdata/llard-e2e/control - - name: Prepare formula repository + - name: Download Bootlin glibc arm64 toolchain + run: | + archive="$RUNNER_TEMP/aarch64--glibc--stable-2017.05-toolchains-1-1.tar.bz2" + curl --fail --location --retry 3 \ + "https://toolchains.bootlin.com/downloads/releases/toolchains/aarch64/tarballs/aarch64--glibc--stable-2017.05-toolchains-1-1.tar.bz2" \ + --output "$archive" + echo "530137589c4588599ebd115c12630c75e79c935ac4f34b0941404036f5e25026 $archive" | sha256sum --check + + - name: Prepare formula and source repositories run: | formula_repo="$RUNNER_TEMP/llarhub" - mkdir -p "$formula_repo" + source_repo="$RUNNER_TEMP/glibc-source" + mkdir -p "$formula_repo" "$source_repo" cp -R testdata/kodo-e2e/formulas/. "$formula_repo/" + cp -R testdata/crosscompile-e2e/formulas/bminor "$formula_repo/" git -C "$formula_repo" init --initial-branch=main git -C "$formula_repo" config user.email llard-e2e@example.com git -C "$formula_repo" config user.name "LLARD E2E" git -C "$formula_repo" add . git -C "$formula_repo" commit -m "LLARD E2E formulas" + + cp testdata/crosscompile-e2e/install-bootlin-sysroot "$source_repo/install-sysroot" + cp "$RUNNER_TEMP/aarch64--glibc--stable-2017.05-toolchains-1-1.tar.bz2" "$source_repo/" + chmod +x "$source_repo/install-sysroot" + git -C "$source_repo" init --initial-branch=main + git -C "$source_repo" config user.email llard-e2e@example.com + git -C "$source_repo" config user.name "LLARD E2E" + git -C "$source_repo" add . + git -C "$source_repo" commit --quiet -m "Add glibc 2.24 toolchain" + git -C "$source_repo" tag glibc-2.24 + git config --global protocol.file.allow always git config --global url."file://$formula_repo".insteadOf "https://github.com/goplus/llarhub.git" + git config --global url."file://$source_repo".insteadOf "https://github.com/bminor/glibc.git" - name: Run llard env: diff --git a/cmd/llar/internal/make.go b/cmd/llar/internal/make.go index c16f343..39771f0 100644 --- a/cmd/llar/internal/make.go +++ b/cmd/llar/internal/make.go @@ -12,6 +12,7 @@ import ( "github.com/goplus/llar/formula" "github.com/goplus/llar/internal/build" + "github.com/goplus/llar/internal/crosscompile" "github.com/goplus/llar/internal/formula/repo" "github.com/goplus/llar/internal/modules" "github.com/goplus/llar/internal/modules/modlocal" @@ -129,10 +130,23 @@ func hostMatrix() formula.Matrix { // hooks triggered — each dependency is verified by its own // `llar test ` invocation. func buildModule(ctx context.Context, store repo.Store, modPath, version string, matrix formula.Matrix, runTest bool) error { - mods, err := modules.Load(ctx, module.Version{Path: modPath, Version: version}, modules.Options{ + root := module.Version{Path: modPath, Version: version} + var targetOS, targetArch string + if values := matrix.Require["os"]; len(values) > 0 { + targetOS = values[0] + } + if values := matrix.Require["arch"]; len(values) > 0 { + targetArch = values[0] + } + crossCompile := targetOS != runtime.GOOS || targetArch != runtime.GOARCH + if runTest && crossCompile { + return fmt.Errorf("llar test cannot run %s/%s target on %s/%s host", targetOS, targetArch, runtime.GOOS, runtime.GOARCH) + } + loadOpts := modules.Options{ FormulaStore: store, Matrix: matrix, - }) + } + mods, err := modules.Load(ctx, root, loadOpts) if err != nil { return fmt.Errorf("failed to load modules: %w", err) } @@ -158,7 +172,23 @@ func buildModule(ctx context.Context, store repo.Store, modPath, version string, defer os.RemoveAll(tmpDir) buildOpts.WorkspaceDir = tmpDir } - + target, err := crosscompile.Load(ctx, root, crosscompile.Config{ + Store: store, + Matrix: matrix, + Stdout: buildOpts.Stdout, + Stderr: buildOpts.Stderr, + WorkspaceDir: buildOpts.WorkspaceDir, + Cache: buildOpts.Cache, + }) + if err != nil { + return err + } + if target != nil { + if closer, ok := target.(io.Closer); ok { + defer closer.Close() + } + buildOpts.Target = target + } builder, err := build.NewBuilder(buildOpts) if err != nil { return fmt.Errorf("failed to create builder: %w", err) diff --git a/cmd/llar/internal/make_test.go b/cmd/llar/internal/make_test.go index 8b330cb..34a338e 100644 --- a/cmd/llar/internal/make_test.go +++ b/cmd/llar/internal/make_test.go @@ -167,6 +167,23 @@ func TestRunMakeReturnsMatrixErrorBeforeStore(t *testing.T) { } } +func TestBuildModuleRejectsCrossTargetTest(t *testing.T) { + targetOS, targetArch := "linux", "amd64" + if runtime.GOOS == targetOS && runtime.GOARCH == targetArch { + targetArch = "arm64" + } + matrix := formula.Matrix{Require: map[string][]string{ + "os": {targetOS}, + "arch": {targetArch}, + }} + + err := buildModule(context.Background(), nil, "owner/repo", "v1.0.0", matrix, true) + want := fmt.Sprintf("llar test cannot run %s/%s target on %s/%s host", targetOS, targetArch, runtime.GOOS, runtime.GOARCH) + if err == nil || err.Error() != want { + t.Fatalf("buildModule error = %v, want %q", err, want) + } +} + func TestNewRemoteStore(t *testing.T) { isolatedWorkspaceDir(t) store, err := newRemoteStore() diff --git a/internal/build/build.go b/internal/build/build.go index 47268a3..ee5e0c3 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -28,6 +28,7 @@ type Builder struct { stderr io.Writer workspaceDir string cache cache.Cache + target Target newRepo func(repoPath string) (vcs.Repo, error) // defaults to vcs.NewRepo } @@ -51,6 +52,7 @@ type Options struct { Stderr io.Writer WorkspaceDir string Cache cache.Cache + Target Target } func runFormulaHook(fn func()) (err error) { @@ -110,6 +112,7 @@ func NewBuilder(opts Options) (*Builder, error) { stderr: stderr, workspaceDir: workspaceDir, cache: c, + target: opts.Target, newRepo: vcs.NewRepo, }, nil } @@ -243,6 +246,11 @@ func (b *Builder) Build(ctx context.Context, targets []*modules.Module) ([]Resul rootID = module.Version{Path: targets[0].Path, Version: targets[0].Version} } + var commandMiddleware execbroker.Middleware + if b.target != nil { + commandMiddleware = targetMiddleware(b.target) + } + build := func(mod *modules.Module) (Result, error) { isRoot := mod.Path == rootID.Path && mod.Version == rootID.Version testThisMod := b.runTest && isRoot && mod.OnTest != nil @@ -306,10 +314,11 @@ func (b *Builder) Build(ctx context.Context, targets []*modules.Module) ([]Resul var metadata string if err := execbroker.Do(execbroker.Scope{ - Dir: tmpSourceDir, - Stdin: os.Stdin, - Stdout: b.stdout, - Stderr: b.stderr, + Dir: tmpSourceDir, + Stdin: os.Stdin, + Stdout: b.stdout, + Stderr: b.stderr, + Middleware: commandMiddleware, }, func() error { // Run OnBuild only on cache miss; reuse cached metadata otherwise. if cacheHit { diff --git a/internal/build/http/http.go b/internal/build/http/http.go index 39740e7..ac9a395 100644 --- a/internal/build/http/http.go +++ b/internal/build/http/http.go @@ -19,6 +19,7 @@ import ( "github.com/goplus/llar/internal/artifact" "github.com/goplus/llar/internal/build" "github.com/goplus/llar/internal/build/cache" + "github.com/goplus/llar/internal/crosscompile" "github.com/goplus/llar/internal/formula/repo" "github.com/goplus/llar/internal/modules" "github.com/goplus/llar/mod/module" @@ -120,7 +121,8 @@ func (h *handler) do(ctx context.Context, req request, info io.Writer) (result, } func (h *handler) build(ctx context.Context, req request, info io.Writer) (result, error) { - mods, err := modules.Load(ctx, module.Version{Path: req.module, Version: req.version}, modules.Options{ + root := module.Version{Path: req.module, Version: req.version} + mods, err := modules.Load(ctx, root, modules.Options{ FormulaStore: h.formulaStore, Matrix: req.matrix, }) @@ -128,17 +130,35 @@ func (h *handler) build(ctx context.Context, req request, info io.Writer) (resul return result{}, err } - builder, err := build.NewBuilder(build.Options{ + buildOpts := build.Options{ Store: h.formulaStore, MatrixStr: req.matrixStr, Stdout: info, Stderr: info, WorkspaceDir: h.workspaceDir, Cache: h.cache, + } + target, err := crosscompile.Load(ctx, root, crosscompile.Config{ + Store: h.formulaStore, + Matrix: req.matrix, + Stdout: info, + Stderr: info, + WorkspaceDir: h.workspaceDir, + Cache: h.cache, }) if err != nil { return result{}, err } + if target != nil { + if closer, ok := target.(io.Closer); ok { + defer closer.Close() + } + buildOpts.Target = target + } + builder, err := build.NewBuilder(buildOpts) + if err != nil { + return result{}, err + } if _, err := builder.Build(ctx, mods); err != nil { return result{}, err } @@ -200,9 +220,9 @@ func parseRequest(r *http.Request) (request, error) { if len(values) != 1 || values[0] == "" { return request{}, fmt.Errorf("matrix %q requires exactly one value", key) } - // Requests carry a flat matrix. Platform dimensions propagate to + // Requests carry a flat matrix. Target requirements propagate to // dependencies; all other dimensions are package build options. - if key == "os" || key == "arch" { + if key == "os" || key == "arch" || key == "libc" { if require == nil { require = make(map[string][]string) } diff --git a/internal/build/http/http_test.go b/internal/build/http/http_test.go index b0b430a..f00f02e 100644 --- a/internal/build/http/http_test.go +++ b/internal/build/http/http_test.go @@ -12,6 +12,7 @@ import ( "io/fs" "net/http" "net/http/httptest" + "os" "path/filepath" "reflect" "runtime" @@ -53,11 +54,11 @@ func TestParseRequest(t *testing.T) { }, { name: "require and options", - target: "/v1/artifacts/madler/zlib@v1.3.1?os=linux&arch=amd64&debug=OFF&shared=ON", + target: "/v1/artifacts/madler/zlib@v1.3.1?os=linux&arch=amd64&libc=glibc-2.13&debug=OFF&shared=ON", wantModule: "madler/zlib", wantVer: "v1.3.1", - wantMatrix: "amd64-linux|OFF-ON", - wantRequire: map[string][]string{"arch": {"amd64"}, "os": {"linux"}}, + wantMatrix: "amd64-glibc-2.13-linux|OFF-ON", + wantRequire: map[string][]string{"arch": {"amd64"}, "libc": {"glibc-2.13"}, "os": {"linux"}}, wantOptions: map[string][]string{"debug": {"OFF"}, "shared": {"ON"}}, }, {name: "wrong path", target: "/v1/modules/madler/zlib?os=linux", wantErr: "artifact path not found"}, @@ -94,7 +95,7 @@ func TestParseRequest(t *testing.T) { for key, values := range req.query { values[0] = "changed" matrixValues := req.matrix.Options[key] - if key == "os" || key == "arch" { + if key == "os" || key == "arch" || key == "libc" { matrixValues = req.matrix.Require[key] } if matrixValues[0] == "changed" { @@ -163,7 +164,8 @@ func TestServeHTTPSingleflight(t *testing.T) { Artifacts: artifacts, WorkspaceDir: t.TempDir(), }).(*handler) - const target = "/v1/artifacts/DaveGamble/cJSON@v1.7.18?os=linux&arch=amd64" + query := "arch=" + runtime.GOARCH + "&os=" + runtime.GOOS + target := "/v1/artifacts/DaveGamble/cJSON@v1.7.18?" + query var recorders [2]*httptest.ResponseRecorder var wg sync.WaitGroup @@ -182,7 +184,7 @@ func TestServeHTTPSingleflight(t *testing.T) { wg.Add(1) go serve(1) - key := "DaveGamble/cJSON@v1.7.18?arch=amd64&os=linux" + key := "DaveGamble/cJSON@v1.7.18?" + query waitFor(t, func() bool { stream, ok := h.infos.Load(key) if !ok { @@ -206,10 +208,11 @@ func TestServeHTTPSingleflight(t *testing.T) { t.Fatalf("artifact Get calls = %d, want 2", got) } + jsonQuery := strings.ReplaceAll(query, "&", `\u0026`) wantLines := []string{ - `info "resolving DaveGamble/cJSON@v1.7.18?arch=amd64\u0026os=linux"`, - `artifact {"id":"madler/zlib@v1.3.1?arch=amd64\u0026os=linux","type":"tar.gz","url":"https://artifacts.example/madler/zlib"}`, - `artifact {"id":"DaveGamble/cJSON@v1.7.18?arch=amd64\u0026os=linux","type":"tar.gz","url":"https://artifacts.example/DaveGamble/cJSON","deps":["madler/zlib@v1.3.1?arch=amd64\u0026os=linux"]}`, + `info "resolving DaveGamble/cJSON@v1.7.18?` + jsonQuery + `"`, + `artifact {"id":"madler/zlib@v1.3.1?` + jsonQuery + `","type":"tar.gz","url":"https://artifacts.example/madler/zlib"}`, + `artifact {"id":"DaveGamble/cJSON@v1.7.18?` + jsonQuery + `","type":"tar.gz","url":"https://artifacts.example/DaveGamble/cJSON","deps":["madler/zlib@v1.3.1?` + jsonQuery + `"]}`, } gotLines := strings.Split(strings.TrimSpace(recorders[0].Body.String()), "\n") if len(gotLines) != len(wantLines) { @@ -262,6 +265,54 @@ func TestServeHTTPBuildErrors(t *testing.T) { } } +func TestBuildCrossCompileTarget(t *testing.T) { + targetArch := "amd64" + if runtime.GOOS == "linux" && runtime.GOARCH == targetArch { + targetArch = "arm64" + } + target := "/v1/artifacts/madler/zlib@v1.3.1?arch=" + targetArch + "&libc=custom&os=linux" + req, err := parseRequest(httptest.NewRequest(http.MethodGet, target, nil)) + if err != nil { + t.Fatal(err) + } + + t.Run("success", func(t *testing.T) { + dir := t.TempDir() + for _, name := range []string{"clang", "clang++", "ld.lld", "llvm-ar", "llvm-ranlib", "llvm-nm", "llvm-strip"} { + if err := os.WriteFile(filepath.Join(dir, name), []byte("tool"), 0o755); err != nil { + t.Fatal(err) + } + } + t.Setenv("PATH", dir) + h := New(Options{ + FormulaStore: localFormulas(t), + Cache: &testCache{}, + Artifacts: &testArtifactStore{}, + WorkspaceDir: t.TempDir(), + }).(*handler) + result, err := h.build(context.Background(), req, io.Discard) + if err != nil { + t.Fatal(err) + } + if len(result.artifacts) != 1 || !strings.HasPrefix(result.artifacts[0].ID, "madler/zlib@v1.3.1?") { + t.Fatalf("build artifacts = %+v", result.artifacts) + } + }) + + t.Run("toolchain error", func(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + h := New(Options{ + FormulaStore: localFormulas(t), + Cache: &testCache{}, + WorkspaceDir: t.TempDir(), + }).(*handler) + _, err := h.build(context.Background(), req, io.Discard) + if err == nil || !strings.Contains(err.Error(), "prepare C toolchain") { + t.Fatalf("build error = %v, want toolchain error", err) + } + }) +} + func TestDoReturnsCanceledContext(t *testing.T) { store := &blockingFormulaStore{ started: make(chan struct{}), diff --git a/internal/build/target.go b/internal/build/target.go new file mode 100644 index 0000000..8e3182f --- /dev/null +++ b/internal/build/target.go @@ -0,0 +1,56 @@ +package build + +import ( + "os" + + "github.com/goplus/llar/internal/execbroker" +) + +// Command describes a command before target defaults are applied. +type Command struct { + Name string + Args []string + Env []string + Dir string +} + +// Patch contains target-specific changes for one command. +type Patch struct { + Name string + PrependArg []string + AppendArg []string + Env []string +} + +// Target applies language-specific target defaults to build commands. +type Target interface { + Use(Command) Patch +} + +func targetMiddleware(target Target) execbroker.Middleware { + return func(req execbroker.Request) execbroker.Request { + env := req.Env + if env == nil { + env = os.Environ() + } + patch := target.Use(Command{ + Name: req.Name, + Args: req.Args, + Env: env, + Dir: req.Dir, + }) + if patch.Name != "" { + req.Name = patch.Name + } + if len(patch.PrependArg) > 0 { + req.Args = append(append([]string(nil), patch.PrependArg...), req.Args...) + } + if len(patch.AppendArg) > 0 { + req.Args = append(req.Args, patch.AppendArg...) + } + if patch.Env != nil { + req.Env = append([]string(nil), patch.Env...) + } + return req + } +} diff --git a/internal/build/target_test.go b/internal/build/target_test.go new file mode 100644 index 0000000..1591704 --- /dev/null +++ b/internal/build/target_test.go @@ -0,0 +1,78 @@ +package build + +import ( + "context" + "reflect" + "testing" + "testing/fstest" + + classfile "github.com/goplus/llar/formula" + "github.com/goplus/llar/internal/execbroker" + internalformula "github.com/goplus/llar/internal/formula" + "github.com/goplus/llar/internal/modules" +) + +type testTarget struct { + command Command +} + +func (t *testTarget) Use(command Command) Patch { + t.command = command + return Patch{ + Name: "/toolchain/cc", + PrependArg: []string{"--target=aarch64-linux-gnu"}, + AppendArg: []string{"--sysroot=/sdk"}, + Env: []string{"CC=/toolchain/cc"}, + } +} + +func TestTargetMiddleware(t *testing.T) { + target := new(testTarget) + got := targetMiddleware(target)(execbroker.Request{ + Name: "cc", + Args: []string{"-c", "a.c"}, + Env: []string{"CFLAGS=-O2"}, + Dir: "/src", + }) + if got.Name != "/toolchain/cc" { + t.Fatalf("Name = %q", got.Name) + } + if want := []string{"--target=aarch64-linux-gnu", "-c", "a.c", "--sysroot=/sdk"}; !reflect.DeepEqual(got.Args, want) { + t.Fatalf("Args = %q, want %q", got.Args, want) + } + if want := []string{"CC=/toolchain/cc"}; !reflect.DeepEqual(got.Env, want) { + t.Fatalf("Env = %q, want %q", got.Env, want) + } + if target.command.Name != "cc" || target.command.Dir != "/src" { + t.Fatalf("Command = %+v", target.command) + } + if want := []string{"CFLAGS=-O2"}; !reflect.DeepEqual(target.command.Env, want) { + t.Fatalf("Command.Env = %q, want %q", target.command.Env, want) + } +} + +func TestBuildAppliesTargetToFormulaCommands(t *testing.T) { + store := setupTestStore(t) + builder := setupBuilder(t, store, "arm64-linux") + target := new(testTarget) + builder.target = target + + var commandName string + root := &modules.Module{ + Formula: &internalformula.Formula{OnBuild: func(*classfile.Context) { + commandName = execbroker.Command("cc", "-c", "a.c").Args[0] + }}, + FS: fstest.MapFS{"README": {Data: []byte("test")}}, + Path: "test/liba", + Version: "1.0.0", + } + if _, err := builder.Build(context.Background(), []*modules.Module{root}); err != nil { + t.Fatal(err) + } + if commandName != "/toolchain/cc" { + t.Fatalf("command name = %q, want /toolchain/cc", commandName) + } + if target.command.Name != "cc" { + t.Fatalf("target command = %+v", target.command) + } +} diff --git a/internal/crosscompile/c/llvm/toolchain.go b/internal/crosscompile/c/llvm/toolchain.go new file mode 100644 index 0000000..3c3b901 --- /dev/null +++ b/internal/crosscompile/c/llvm/toolchain.go @@ -0,0 +1,132 @@ +package llvm + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/goplus/llar/internal/crosscompile/c" +) + +// Toolchain is a prepared LLVM C-family toolchain. +type Toolchain struct { + c.Toolchain +} + +// Config contains the target facts required to prepare LLVM commands. +type Config struct { + OS string + Arch string + Sysroot string +} + +// New prepares an LLVM Toolchain from commands available in PATH. +func New(config Config) (*Toolchain, error) { + var triple, linkerName string + switch config.Arch + "-" + config.OS { + case "amd64-linux": + triple = "x86_64-linux-" + linkerName = "ld.lld" + case "arm64-linux": + triple = "aarch64-linux-" + linkerName = "ld.lld" + case "amd64-darwin": + triple = "x86_64-apple-macos10.13" + linkerName = "ld64.lld" + case "arm64-darwin": + triple = "arm64-apple-macos11.0" + linkerName = "ld64.lld" + default: + return nil, fmt.Errorf("unsupported LLVM target %s/%s", config.OS, config.Arch) + } + if config.OS == "linux" { + environment := "gnu" + if config.Sysroot != "" { + // The loader family is the sysroot's target-environment fact. For + // example, any ld-musl* loader requires a Linux musl Clang triple. + hasLoader := func(prefix string) (bool, error) { + for _, dir := range []string{"lib", "lib64"} { + entries, err := os.ReadDir(filepath.Join(config.Sysroot, dir)) + if os.IsNotExist(err) { + continue + } + if err != nil { + return false, err + } + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), prefix) { + return true, nil + } + } + } + return false, nil + } + environment = "" + if ok, err := hasLoader("ld-linux"); err != nil { + return nil, fmt.Errorf("inspect GNU loader in %s: %w", config.Sysroot, err) + } else if ok { + environment = "gnu" + } + if environment == "" { + if ok, err := hasLoader("ld-musl"); err != nil { + return nil, fmt.Errorf("inspect musl loader in %s: %w", config.Sysroot, err) + } else if ok { + environment = "musl" + } + } + if environment == "" { + return nil, fmt.Errorf("cannot determine LLVM target environment from sysroot %s: neither ld-linux* nor ld-musl* exists", config.Sysroot) + } + } + triple += environment + } + find := func(name string) (string, error) { + path, err := exec.LookPath(name) + if err != nil { + return "", fmt.Errorf("find prepared LLVM command %q: %w", name, err) + } + return path, nil + } + ccPath, err := find("clang") + if err != nil { + return nil, err + } + cxxPath, err := find("clang++") + if err != nil { + return nil, err + } + linker, err := find(linkerName) + if err != nil { + return nil, err + } + archiver, err := find("llvm-ar") + if err != nil { + return nil, err + } + ranlib, err := find("llvm-ranlib") + if err != nil { + return nil, err + } + nm, err := find("llvm-nm") + if err != nil { + return nil, err + } + strip, err := find("llvm-strip") + if err != nil { + return nil, err + } + cc := []string{ccPath, "--target=" + triple, "-fuse-ld=lld"} + cxx := []string{cxxPath, "--target=" + triple, "-fuse-ld=lld"} + if config.Sysroot != "" { + flag := "--sysroot=" + config.Sysroot + if config.OS == "darwin" { + flag = "-isysroot" + config.Sysroot + } + cc = append(cc, flag) + cxx = append(cxx, flag) + } + toolchain := c.NewToolchain(cc, cxx, []string{linker}, archiver, ranlib, nm, strip) + return &Toolchain{Toolchain: toolchain}, nil +} diff --git a/internal/crosscompile/c/llvm/toolchain_test.go b/internal/crosscompile/c/llvm/toolchain_test.go new file mode 100644 index 0000000..901a2d2 --- /dev/null +++ b/internal/crosscompile/c/llvm/toolchain_test.go @@ -0,0 +1,130 @@ +package llvm + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestNewUsesPreparedPath(t *testing.T) { + dir := fakeToolDir(t) + t.Setenv("PATH", dir) + gnuSysroot := fakeLinuxSysroot(t, "gnu") + + toolchain, err := New(Config{OS: "linux", Arch: "arm64", Sysroot: gnuSysroot}) + if err != nil { + t.Fatal(err) + } + if got, want := toolchain.CC(), []string{filepath.Join(dir, "clang"), "--target=aarch64-linux-gnu", "-fuse-ld=lld", "--sysroot=" + gnuSysroot}; !reflect.DeepEqual(got, want) { + t.Fatalf("CC = %q, want %q", got, want) + } + if got, want := toolchain.Linker(), []string{filepath.Join(dir, "ld.lld")}; !reflect.DeepEqual(got, want) { + t.Fatalf("Linker = %q, want %q", got, want) + } + + toolchain, err = New(Config{OS: "darwin", Arch: "amd64", Sysroot: "/sdk"}) + if err != nil { + t.Fatal(err) + } + if got, want := toolchain.CC(), []string{filepath.Join(dir, "clang"), "--target=x86_64-apple-macos10.13", "-fuse-ld=lld", "-isysroot/sdk"}; !reflect.DeepEqual(got, want) { + t.Fatalf("Darwin CC = %q, want %q", got, want) + } + if got, want := toolchain.Linker(), []string{filepath.Join(dir, "ld64.lld")}; !reflect.DeepEqual(got, want) { + t.Fatalf("Darwin Linker = %q, want %q", got, want) + } + + toolchain, err = New(Config{OS: "linux", Arch: "amd64"}) + if err != nil { + t.Fatal(err) + } + if got, want := toolchain.CC(), []string{filepath.Join(dir, "clang"), "--target=x86_64-linux-gnu", "-fuse-ld=lld"}; !reflect.DeepEqual(got, want) { + t.Fatalf("Linux amd64 CC = %q, want %q", got, want) + } + + toolchain, err = New(Config{OS: "darwin", Arch: "arm64"}) + if err != nil { + t.Fatal(err) + } + if got, want := toolchain.CC(), []string{filepath.Join(dir, "clang"), "--target=arm64-apple-macos11.0", "-fuse-ld=lld"}; !reflect.DeepEqual(got, want) { + t.Fatalf("Darwin arm64 CC = %q, want %q", got, want) + } +} + +func TestNewSelectsLinuxEnvironmentFromSysroot(t *testing.T) { + dir := fakeToolDir(t) + t.Setenv("PATH", dir) + + for _, environment := range []string{"gnu", "musl"} { + t.Run(environment, func(t *testing.T) { + sysroot := fakeLinuxSysroot(t, environment) + toolchain, err := New(Config{OS: "linux", Arch: "arm64", Sysroot: sysroot}) + if err != nil { + t.Fatal(err) + } + args := strings.Join(toolchain.CC(), " ") + if !strings.Contains(args, "--target=aarch64-linux-"+environment) { + t.Fatalf("CC = %q, want %s target", args, environment) + } + }) + } + + unknown := t.TempDir() + if _, err := New(Config{OS: "linux", Arch: "arm64", Sysroot: unknown}); err == nil || !strings.Contains(err.Error(), "cannot determine LLVM target environment") { + t.Fatalf("New unknown sysroot error = %v", err) + } +} + +func TestNewErrors(t *testing.T) { + if _, err := New(Config{OS: "plan9", Arch: "amd64"}); err == nil || !strings.Contains(err.Error(), "unsupported LLVM target") { + t.Fatalf("New unsupported target error = %v", err) + } + + for _, missing := range []string{"clang", "clang++", "ld.lld", "llvm-ar", "llvm-ranlib", "llvm-nm", "llvm-strip"} { + t.Run(missing, func(t *testing.T) { + dir := fakeToolDir(t) + if err := os.Remove(filepath.Join(dir, missing)); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir) + _, err := New(Config{OS: "linux", Arch: "arm64"}) + if err == nil || !strings.Contains(err.Error(), `find prepared LLVM command "`+missing+`"`) { + t.Fatalf("New missing %s error = %v", missing, err) + } + }) + } +} + +func fakeToolDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + for _, name := range []string{"clang", "clang++", "ld.lld", "ld64.lld", "llvm-ar", "llvm-ranlib", "llvm-nm", "llvm-strip"} { + if err := os.WriteFile(filepath.Join(dir, name), []byte("tool"), 0o755); err != nil { + t.Fatal(err) + } + } + return dir +} + +func fakeLinuxSysroot(t *testing.T, environment string) string { + t.Helper() + root := t.TempDir() + var loader string + switch environment { + case "gnu": + loader = "lib64/ld-linux-test.so.37" + case "musl": + loader = "lib/ld-musl-test.so.42" + default: + t.Fatalf("unsupported fake Linux sysroot %s", environment) + } + path := filepath.Join(root, filepath.FromSlash(loader)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, nil, 0o755); err != nil { + t.Fatal(err) + } + return root +} diff --git a/internal/crosscompile/c/target.go b/internal/crosscompile/c/target.go new file mode 100644 index 0000000..757cd1b --- /dev/null +++ b/internal/crosscompile/c/target.go @@ -0,0 +1,321 @@ +package c + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/goplus/llar/internal/build" + "github.com/goplus/llar/mod/module" + "github.com/kballard/go-shellquote" +) + +const ( + linuxSysrootPath = "bminor/glibc" + linuxSysrootVersion = "glibc-2.24" + darwinSysrootPath = "joseluisq/macosx-sdks" + darwinSysrootVersion = "14.5" +) + +// Config contains the facts required to prepare a C target. +type Config struct { + Matrix string + Toolchain Toolchain + Sysroot string +} + +// Target contains prepared C command defaults for one build target. +type Target struct { + target string + systemName string + systemProcessor string + autotoolsHost string + sysroot string + toolchain Toolchain + toolchainFile string + tempDir string +} + +// Sysroot returns the fixed compatibility sysroot Formula for a built-in C +// target. +func Sysroot(targetOS, targetArch string) (module.Version, bool) { + switch targetOS { + case "linux": + switch targetArch { + case "amd64", "arm64": + return module.Version{Path: linuxSysrootPath, Version: linuxSysrootVersion}, true + } + case "darwin": + switch targetArch { + case "amd64", "arm64": + return module.Version{Path: darwinSysrootPath, Version: darwinSysrootVersion}, true + } + } + return module.Version{}, false +} + +// NewTarget prepares C command defaults from config. +func NewTarget(config Config) (*Target, error) { + target := config.Matrix + if value, _, ok := strings.Cut(config.Matrix, "|"); ok { + target = value + } + var systemName, systemProcessor, autotoolsHost string + switch target { + case "amd64-linux": + systemName = "Linux" + systemProcessor = "x86_64" + autotoolsHost = "x86_64-linux-gnu" + case "arm64-linux": + systemName = "Linux" + systemProcessor = "aarch64" + autotoolsHost = "aarch64-linux-gnu" + case "amd64-darwin": + systemName = "Darwin" + systemProcessor = "x86_64" + autotoolsHost = "x86_64-apple-darwin" + case "arm64-darwin": + systemName = "Darwin" + systemProcessor = "arm64" + autotoolsHost = "aarch64-apple-darwin" + default: + return nil, fmt.Errorf("unsupported C target %s", target) + } + if err := validateToolchain(config.Toolchain); err != nil { + return nil, fmt.Errorf("prepare C target %s: %w", target, err) + } + return &Target{ + target: target, + systemName: systemName, + systemProcessor: systemProcessor, + autotoolsHost: autotoolsHost, + sysroot: config.Sysroot, + toolchain: config.Toolchain, + }, nil +} + +// Close removes generated build-system configuration. +func (c *Target) Close() error { + if c.tempDir == "" { + return nil + } + return os.RemoveAll(c.tempDir) +} + +// Use returns C target defaults for cmd. Explicit Formula settings are +// preserved. +func (c *Target) Use(cmd build.Command) build.Patch { + base := filepath.Base(cmd.Name) + if base == "configure" { + return c.autotoolsPatch(cmd) + } + if filepath.Base(cmd.Name) != cmd.Name { + return build.Patch{} + } + + switch base { + case "cmake": + if isCMakeConfigure(cmd.Args) && !hasCMakeToolchain(cmd.Args) { + if c.toolchainFile == "" { + tempDir, err := os.MkdirTemp("", "llar-c-target-*") + if err != nil { + panic(fmt.Errorf("prepare CMake toolchain for %s: %w", c.target, err)) + } + toolchainFile := filepath.Join(tempDir, "toolchain.cmake") + if err := os.WriteFile(toolchainFile, []byte(c.cmakeToolchain()), 0o600); err != nil { + _ = os.RemoveAll(tempDir) + panic(fmt.Errorf("prepare CMake toolchain for %s: %w", c.target, err)) + } + c.tempDir = tempDir + c.toolchainFile = toolchainFile + } + patch := c.pkgConfigPatch(cmd.Env) + patch.AppendArg = []string{"-DCMAKE_TOOLCHAIN_FILE:FILEPATH=" + c.toolchainFile} + return patch + } + case "pkg-config": + return c.pkgConfigPatch(cmd.Env) + case "cc", "gcc", "clang": + return commandPatch(c.toolchain.CC()) + case "c++", "g++", "clang++": + return commandPatch(c.toolchain.CXX()) + case "ld", "ld.lld", "ld64.lld": + return commandPatch(c.toolchain.Linker()) + case "ar", "llvm-ar": + return build.Patch{Name: c.toolchain.Archiver()} + case "ranlib", "llvm-ranlib": + return build.Patch{Name: c.toolchain.Ranlib()} + case "nm", "llvm-nm": + return build.Patch{Name: c.toolchain.NM()} + case "strip", "llvm-strip": + return build.Patch{Name: c.toolchain.Strip()} + } + return build.Patch{} +} + +func commandPatch(command []string) build.Patch { + return build.Patch{Name: command[0], PrependArg: command[1:]} +} + +func (c *Target) autotoolsPatch(cmd build.Command) build.Patch { + env := append([]string(nil), cmd.Env...) + env = setMissingEnv(env, "CC", shellquote.Join(c.toolchain.CC()...)) + env = setMissingEnv(env, "CXX", shellquote.Join(c.toolchain.CXX()...)) + env = setMissingEnv(env, "LD", shellquote.Join(c.toolchain.Linker()...)) + env = setMissingEnv(env, "AR", c.toolchain.Archiver()) + env = setMissingEnv(env, "RANLIB", c.toolchain.Ranlib()) + env = setMissingEnv(env, "NM", c.toolchain.NM()) + env = setMissingEnv(env, "STRIP", c.toolchain.Strip()) + if c.sysroot != "" { + env = c.pkgConfigPatch(env).Env + } + + for _, arg := range cmd.Args { + if arg == "--host" || strings.HasPrefix(arg, "--host=") { + return build.Patch{Env: env} + } + } + + configurePath := cmd.Name + if !filepath.IsAbs(configurePath) { + configurePath = filepath.Join(cmd.Dir, configurePath) + } + data, err := os.ReadFile(configurePath) + if err != nil { + panic(fmt.Errorf("inspect configure options for %s: %w", c.target, err)) + } + // Only scripts that declare --host receive the Autoconf target tuple. + // For example, zlib's custom configure declares CHOST but rejects --host. + patch := build.Patch{Env: env} + if bytes.Contains(data, []byte("--host")) { + patch.AppendArg = []string{"--host=" + c.autotoolsHost} + } + return patch +} + +func (c *Target) pkgConfigPatch(commandEnv []string) build.Patch { + if c.sysroot == "" { + return build.Patch{} + } + env := append([]string(nil), commandEnv...) + libDirs, _ := envValue(env, "PKG_CONFIG_PATH") + // Use stores LLAR dependency .pc directories in PKG_CONFIG_PATH. Restrict + // lookup to them without rewriting their absolute installation prefixes. + env = setMissingEnv(env, "PKG_CONFIG_LIBDIR", libDirs) + return build.Patch{Env: env} +} + +func (c *Target) cmakeToolchain() string { + values := [][2]string{ + {"CMAKE_SYSTEM_NAME", c.systemName}, + {"CMAKE_SYSTEM_PROCESSOR", c.systemProcessor}, + } + if c.systemName == "Darwin" { + values = append(values, + [2]string{"CMAKE_OSX_ARCHITECTURES", c.systemProcessor}, + ) + if c.sysroot != "" { + values = append(values, [2]string{"CMAKE_OSX_SYSROOT", c.sysroot}) + } + } else if c.sysroot != "" { + values = append(values, [2]string{"CMAKE_SYSROOT", c.sysroot}) + } + values = append(values, + [2]string{"CMAKE_C_COMPILER", strings.Join(c.toolchain.CC(), ";")}, + [2]string{"CMAKE_CXX_COMPILER", strings.Join(c.toolchain.CXX(), ";")}, + [2]string{"CMAKE_LINKER", strings.Join(c.toolchain.Linker(), ";")}, + [2]string{"CMAKE_AR", c.toolchain.Archiver()}, + [2]string{"CMAKE_RANLIB", c.toolchain.Ranlib()}, + [2]string{"CMAKE_NM", c.toolchain.NM()}, + [2]string{"CMAKE_STRIP", c.toolchain.Strip()}, + ) + if c.sysroot != "" { + values = append(values, + [2]string{"CMAKE_FIND_ROOT_PATH_MODE_PROGRAM", "NEVER"}, + [2]string{"CMAKE_FIND_ROOT_PATH_MODE_LIBRARY", "ONLY"}, + [2]string{"CMAKE_FIND_ROOT_PATH_MODE_INCLUDE", "ONLY"}, + [2]string{"CMAKE_FIND_ROOT_PATH_MODE_PACKAGE", "ONLY"}, + ) + } + var out strings.Builder + for _, value := range values { + fmt.Fprintf(&out, "if(NOT DEFINED %s)\n set(%s \"%s\")\nendif()\n", value[0], value[0], cmakeEscape(value[1])) + } + return out.String() +} + +func validateToolchain(toolchain Toolchain) error { + commands := []struct { + name string + command []string + }{ + {"CC", toolchain.CC()}, + {"CXX", toolchain.CXX()}, + {"linker", toolchain.Linker()}, + } + for _, command := range commands { + if len(command.command) == 0 || command.command[0] == "" { + return fmt.Errorf("%s is required", command.name) + } + } + tools := []struct { + name string + path string + }{ + {"archiver", toolchain.Archiver()}, + {"ranlib", toolchain.Ranlib()}, + {"nm", toolchain.NM()}, + {"strip", toolchain.Strip()}, + } + for _, tool := range tools { + if tool.path == "" { + return fmt.Errorf("%s is required", tool.name) + } + } + return nil +} + +func isCMakeConfigure(args []string) bool { + if len(args) == 0 { + return true + } + switch args[0] { + case "--build", "--install", "--open", "--workflow": + return false + default: + return true + } +} + +func hasCMakeToolchain(args []string) bool { + for _, arg := range args { + if strings.HasPrefix(arg, "-DCMAKE_TOOLCHAIN_FILE") || arg == "--toolchain" || strings.HasPrefix(arg, "--toolchain=") { + return true + } + } + return false +} + +func envValue(env []string, key string) (string, bool) { + prefix := key + "=" + for i := len(env) - 1; i >= 0; i-- { + if strings.HasPrefix(env[i], prefix) { + return strings.TrimPrefix(env[i], prefix), true + } + } + return "", false +} + +func setMissingEnv(env []string, key, value string) []string { + if _, ok := envValue(env, key); ok { + return env + } + return append(env, key+"="+value) +} + +func cmakeEscape(value string) string { + value = strings.ReplaceAll(value, "\\", "/") + return strings.ReplaceAll(value, "\"", "\\\"") +} diff --git a/internal/crosscompile/c/target_test.go b/internal/crosscompile/c/target_test.go new file mode 100644 index 0000000..0f70541 --- /dev/null +++ b/internal/crosscompile/c/target_test.go @@ -0,0 +1,302 @@ +package c + +import ( + "os" + "path/filepath" + "reflect" + "slices" + "strings" + "testing" + + "github.com/goplus/llar/internal/build" + "github.com/goplus/llar/mod/module" +) + +func fakeToolchain(t *testing.T, targetOS string, compilerArgs ...string) Toolchain { + t.Helper() + cc := append([]string{"/llvm/bin/clang"}, compilerArgs...) + cxx := append([]string{"/llvm/bin/clang++"}, compilerArgs...) + linker := "/llvm/bin/ld.lld" + if targetOS == "darwin" { + linker = "/llvm/bin/ld64.lld" + } + return NewToolchain( + cc, + cxx, + []string{linker}, + "/llvm/bin/llvm-ar", + "/llvm/bin/llvm-ranlib", + "/llvm/bin/llvm-nm", + "/llvm/bin/llvm-strip", + ) +} + +func TestSysroot(t *testing.T) { + linux := module.Version{Path: "bminor/glibc", Version: "glibc-2.24"} + darwin := module.Version{Path: "joseluisq/macosx-sdks", Version: "14.5"} + for _, arch := range []string{"amd64", "arm64"} { + got, ok := Sysroot("linux", arch) + if !ok || got != linux { + t.Fatalf("Sysroot(linux, %s) = %+v, %v; want %+v, true", arch, got, ok, linux) + } + got, ok = Sysroot("darwin", arch) + if !ok || got != darwin { + t.Fatalf("Sysroot(darwin, %s) = %+v, %v; want %+v, true", arch, got, ok, darwin) + } + } + for _, target := range [][2]string{{"darwin", "riscv64"}, {"linux", "riscv64"}, {"", "esp32"}} { + if got, ok := Sysroot(target[0], target[1]); ok { + t.Fatalf("Sysroot(%q, %q) = %+v, true; want unsupported", target[0], target[1], got) + } + } +} + +func TestDarwinTarget(t *testing.T) { + toolchain := fakeToolchain(t, "darwin", "--target=x86_64-apple-macos10.13", "-fuse-ld=lld", "-isysroot/sdk") + target, err := NewTarget(Config{Matrix: "amd64-darwin|shared", Toolchain: toolchain, Sysroot: "/sdk"}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = target.Close() }) + + patch := target.Use(build.Command{Name: "cc", Args: []string{"-c", "a.c"}}) + want := []string{"--target=x86_64-apple-macos10.13", "-fuse-ld=lld", "-isysroot/sdk"} + if !reflect.DeepEqual(patch.PrependArg, want) { + t.Fatalf("PrependArg = %q, want %q", patch.PrependArg, want) + } + patch = target.Use(build.Command{Name: "cc", Args: []string{"a.o", "-shared"}}) + if !reflect.DeepEqual(patch.PrependArg, want) { + t.Fatalf("link PrependArg = %q, want %q", patch.PrependArg, want) + } + + patch = target.Use(build.Command{ + Name: "cc", + Args: []string{"--target=custom", "-isysroot/custom", "-fuse-ld=custom"}, + }) + want = []string{"--target=x86_64-apple-macos10.13", "-fuse-ld=lld", "-isysroot/sdk"} + if !reflect.DeepEqual(patch.PrependArg, want) { + t.Fatalf("PrependArg = %q, want prepared defaults %q", patch.PrependArg, want) + } + + configure := filepath.Join(t.TempDir(), "configure") + if err := os.WriteFile(configure, []byte("#!/bin/sh\nCHOST=${CHOST-}\n"), 0o755); err != nil { + t.Fatal(err) + } + patch = target.Use(build.Command{Name: configure}) + if len(patch.AppendArg) != 0 { + t.Fatalf("configure AppendArg = %q, want no unsupported arguments", patch.AppendArg) + } + for key, values := range map[string][]string{ + "CC": {"/llvm/bin/clang", "--target=x86_64-apple-macos10.13", "-fuse-ld=lld", "-isysroot/sdk"}, + "LD": {"/llvm/bin/ld64.lld"}, + } { + got, _ := envValue(patch.Env, key) + for _, value := range values { + if !slices.Contains(strings.Fields(got), value) { + t.Fatalf("%s = %q, want %q", key, got, value) + } + } + } + + patch = target.Use(build.Command{Name: "cmake", Args: []string{"-S", ".", "-B", "build"}}) + data, err := os.ReadFile(strings.TrimPrefix(patch.AppendArg[0], "-DCMAKE_TOOLCHAIN_FILE:FILEPATH=")) + if err != nil { + t.Fatal(err) + } + content := string(data) + for _, want := range []string{ + "set(CMAKE_SYSTEM_NAME \"Darwin\")", + "CMAKE_OSX_ARCHITECTURES", + "CMAKE_OSX_SYSROOT", + "CMAKE_LINKER", + "/llvm/bin/ld64.lld", + "x86_64-apple-macos10.13", + "-fuse-ld=lld", + } { + if !strings.Contains(content, want) { + t.Fatalf("toolchain file does not contain %q:\n%s", want, content) + } + } +} + +func TestDarwinArm64Compiler(t *testing.T) { + toolchain := fakeToolchain(t, "darwin", "--target=arm64-apple-macos11.0", "-fuse-ld=lld", "-isysroot/sdk") + target, err := NewTarget(Config{Matrix: "arm64-darwin", Toolchain: toolchain, Sysroot: "/sdk"}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = target.Close() }) + + patch := target.Use(build.Command{Name: "cc"}) + if !slices.Contains(patch.PrependArg, "--target=arm64-apple-macos11.0") { + t.Fatalf("PrependArg = %q, want prepared arm64 compiler target", patch.PrependArg) + } +} + +func TestBootstrapTargetOmitsSysroot(t *testing.T) { + c, err := NewTarget(Config{Matrix: "arm64-linux", Toolchain: fakeToolchain(t, "linux", "--target=aarch64-linux-gnu", "-fuse-ld=lld")}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = c.Close() }) + + patch := c.Use(build.Command{Name: "cc", Args: []string{"-c", "a.c"}}) + if got, want := patch.PrependArg, []string{"--target=aarch64-linux-gnu", "-fuse-ld=lld"}; !reflect.DeepEqual(got, want) { + t.Fatalf("PrependArg = %q, want %q", got, want) + } + if patch := c.Use(build.Command{Name: "pkg-config"}); patch.Env != nil { + t.Fatalf("pkg-config Patch = %+v, want no sysroot environment", patch) + } + + patch = c.Use(build.Command{Name: "cmake", Args: []string{"-S", ".", "-B", "build"}}) + data, err := os.ReadFile(strings.TrimPrefix(patch.AppendArg[0], "-DCMAKE_TOOLCHAIN_FILE:FILEPATH=")) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), "CMAKE_SYSROOT") { + t.Fatalf("bootstrap CMake toolchain contains CMAKE_SYSROOT:\n%s", data) + } +} + +func TestUseCMakeWritesToolchainLazily(t *testing.T) { + c := newTestTarget(t) + if c.toolchainFile != "" || c.tempDir != "" { + t.Fatalf("New created CMake files: toolchainFile=%q tempDir=%q", c.toolchainFile, c.tempDir) + } + + c.Use(build.Command{Name: "cmake", Args: []string{"-S", ".", "-B", "build"}}) + path := c.toolchainFile + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + content := string(data) + for _, want := range []string{"CMAKE_SYSTEM_NAME", "CMAKE_LINKER", "aarch64", "/llvm/bin/clang", "/llvm/bin/ld.lld", "aarch64-linux-gnu", "/sdk", "-fuse-ld=lld"} { + if !strings.Contains(content, want) { + t.Fatalf("toolchain file does not contain %q:\n%s", want, content) + } + } + if err := c.Close(); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("toolchain file still exists after Close: %v", err) + } +} + +func TestUseCMake(t *testing.T) { + c := newTestTarget(t) + patch := c.Use(build.Command{ + Name: "cmake", + Args: []string{"-S", ".", "-B", "build"}, + Env: []string{"PKG_CONFIG_PATH=/deps/lib/pkgconfig"}, + }) + if got, want := patch.AppendArg, []string{"-DCMAKE_TOOLCHAIN_FILE:FILEPATH=" + c.toolchainFile}; !reflect.DeepEqual(got, want) { + t.Fatalf("AppendArg = %q, want %q", got, want) + } + if got, ok := envValue(patch.Env, "PKG_CONFIG_SYSROOT_DIR"); ok { + t.Fatalf("PKG_CONFIG_SYSROOT_DIR = %q, want unset", got) + } + if got, _ := envValue(patch.Env, "PKG_CONFIG_LIBDIR"); got != "/deps/lib/pkgconfig" { + t.Fatalf("PKG_CONFIG_LIBDIR = %q, want /deps/lib/pkgconfig", got) + } + patch = c.Use(build.Command{Name: "cmake", Args: []string{"--build", "build"}}) + if len(patch.AppendArg) != 0 { + t.Fatalf("build Patch = %+v, want no toolchain argument", patch) + } + patch = c.Use(build.Command{Name: "cmake", Args: []string{"-S", ".", "--toolchain", "/custom.cmake"}}) + if len(patch.AppendArg) != 0 { + t.Fatalf("explicit toolchain Patch = %+v", patch) + } +} + +func TestUseDirectCommands(t *testing.T) { + c := newTestTarget(t) + patch := c.Use(build.Command{Name: "cc", Args: []string{"-c", "a.c"}}) + if patch.Name != "/llvm/bin/clang" { + t.Fatalf("Name = %q", patch.Name) + } + want := []string{"--target=aarch64-linux-gnu", "-fuse-ld=lld", "--sysroot=/sdk"} + if !reflect.DeepEqual(patch.PrependArg, want) { + t.Fatalf("PrependArg = %q, want %q", patch.PrependArg, want) + } + patch = c.Use(build.Command{Name: "cc", Args: []string{"a.o", "-o", "a"}}) + if !reflect.DeepEqual(patch.PrependArg, want) { + t.Fatalf("link PrependArg = %q, want %q", patch.PrependArg, want) + } + patch = c.Use(build.Command{Name: "cc", Args: []string{"-c", "a.c", "--target=custom", "--sysroot=/custom"}}) + if !reflect.DeepEqual(patch.PrependArg, want) { + t.Fatalf("PrependArg = %q, want prepared defaults %q", patch.PrependArg, want) + } + if patch := c.Use(build.Command{Name: filepath.Join("custom", "cc")}); patch.Name != "" { + t.Fatalf("explicit compiler path was rewritten: %+v", patch) + } + patch = c.Use(build.Command{Name: "ld"}) + if patch.Name != "/llvm/bin/ld.lld" { + t.Fatalf("linker Name = %q, want /llvm/bin/ld.lld", patch.Name) + } +} + +func TestUseAutotools(t *testing.T) { + c := newTestTarget(t) + configure := filepath.Join(t.TempDir(), "configure") + if err := os.WriteFile(configure, []byte("#!/bin/sh\n# options: --build=BUILD --host=HOST\n"), 0o755); err != nil { + t.Fatal(err) + } + patch := c.Use(build.Command{ + Name: configure, + Args: []string{"--build=x86_64-apple-darwin"}, + Env: []string{"CC=/custom/cc", "CFLAGS=-O2 --target=custom", "PKG_CONFIG_PATH=/deps/lib/pkgconfig"}, + }) + if got, _ := envValue(patch.Env, "CC"); got != "/custom/cc" { + t.Fatalf("CC override = %q, want /custom/cc", got) + } + if got, _ := envValue(patch.Env, "CFLAGS"); got != "-O2 --target=custom" { + t.Fatalf("CFLAGS = %q", got) + } + if got, _ := envValue(patch.Env, "LD"); got != "/llvm/bin/ld.lld" { + t.Fatalf("LD = %q, want /llvm/bin/ld.lld", got) + } + if got, want := patch.AppendArg, []string{"--host=aarch64-linux-gnu"}; !reflect.DeepEqual(got, want) { + t.Fatalf("AppendArg = %q, want %q", got, want) + } + if got, ok := envValue(patch.Env, "PKG_CONFIG_SYSROOT_DIR"); ok { + t.Fatalf("PKG_CONFIG_SYSROOT_DIR = %q, want unset", got) + } + if got, _ := envValue(patch.Env, "PKG_CONFIG_LIBDIR"); got != "/deps/lib/pkgconfig" { + t.Fatalf("PKG_CONFIG_LIBDIR = %q, want /deps/lib/pkgconfig", got) + } + + patch = c.Use(build.Command{Name: configure, Args: []string{"--host=custom-linux"}}) + if len(patch.AppendArg) != 0 { + t.Fatalf("explicit host AppendArg = %q, want no duplicate host", patch.AppendArg) + } +} + +func TestUsePkgConfig(t *testing.T) { + c := newTestTarget(t) + depPaths := strings.Join([]string{"/deps/a/lib/pkgconfig", "/deps/b/lib/pkgconfig"}, string(os.PathListSeparator)) + patch := c.Use(build.Command{Name: "pkg-config", Env: []string{"PKG_CONFIG_PATH=" + depPaths}}) + if got, ok := envValue(patch.Env, "PKG_CONFIG_SYSROOT_DIR"); ok { + t.Fatalf("PKG_CONFIG_SYSROOT_DIR = %q, want unset", got) + } + got, _ := envValue(patch.Env, "PKG_CONFIG_LIBDIR") + if got != depPaths { + t.Fatalf("PKG_CONFIG_LIBDIR = %q, want %q", got, depPaths) + } + patch = c.Use(build.Command{Name: "pkg-config", Env: []string{"PKG_CONFIG_LIBDIR=/custom"}}) + if got, _ := envValue(patch.Env, "PKG_CONFIG_LIBDIR"); got != "/custom" { + t.Fatalf("PKG_CONFIG_LIBDIR override = %q, want /custom", got) + } +} + +func newTestTarget(t *testing.T) *Target { + t.Helper() + toolchain := fakeToolchain(t, "linux", "--target=aarch64-linux-gnu", "-fuse-ld=lld", "--sysroot=/sdk") + c, err := NewTarget(Config{Matrix: "arm64-linux|shared", Toolchain: toolchain, Sysroot: "/sdk"}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = c.Close() }) + return c +} diff --git a/internal/crosscompile/c/toolchain.go b/internal/crosscompile/c/toolchain.go new file mode 100644 index 0000000..5181035 --- /dev/null +++ b/internal/crosscompile/c/toolchain.go @@ -0,0 +1,33 @@ +package c + +// Toolchain contains prepared C-family commands. +type Toolchain struct { + cc []string + cxx []string + linker []string + archiver string + ranlib string + nm string + strip string +} + +// NewToolchain creates a Toolchain from prepared target commands. +func NewToolchain(cc, cxx, linker []string, archiver, ranlib, nm, strip string) Toolchain { + return Toolchain{ + cc: append([]string(nil), cc...), + cxx: append([]string(nil), cxx...), + linker: append([]string(nil), linker...), + archiver: archiver, + ranlib: ranlib, + nm: nm, + strip: strip, + } +} + +func (t Toolchain) CC() []string { return append([]string(nil), t.cc...) } +func (t Toolchain) CXX() []string { return append([]string(nil), t.cxx...) } +func (t Toolchain) Linker() []string { return append([]string(nil), t.linker...) } +func (t Toolchain) Archiver() string { return t.archiver } +func (t Toolchain) Ranlib() string { return t.ranlib } +func (t Toolchain) NM() string { return t.nm } +func (t Toolchain) Strip() string { return t.strip } diff --git a/internal/crosscompile/c/toolchain_test.go b/internal/crosscompile/c/toolchain_test.go new file mode 100644 index 0000000..a911635 --- /dev/null +++ b/internal/crosscompile/c/toolchain_test.go @@ -0,0 +1,37 @@ +package c + +import ( + "reflect" + "testing" +) + +func TestToolchain(t *testing.T) { + toolchain := NewToolchain([]string{"cc", "--target=aarch64-linux-gnu"}, []string{"c++", "--target=aarch64-linux-gnu"}, []string{"ld.lld"}, "ar", "ranlib", "nm", "strip") + for _, test := range []struct { + name string + got []string + want []string + }{ + {name: "CC", got: toolchain.CC(), want: []string{"cc", "--target=aarch64-linux-gnu"}}, + {name: "CXX", got: toolchain.CXX(), want: []string{"c++", "--target=aarch64-linux-gnu"}}, + {name: "Linker", got: toolchain.Linker(), want: []string{"ld.lld"}}, + } { + if !reflect.DeepEqual(test.got, test.want) { + t.Fatalf("%s = %q, want %q", test.name, test.got, test.want) + } + } + for _, test := range []struct { + name string + got string + want string + }{ + {name: "Archiver", got: toolchain.Archiver(), want: "ar"}, + {name: "Ranlib", got: toolchain.Ranlib(), want: "ranlib"}, + {name: "NM", got: toolchain.NM(), want: "nm"}, + {name: "Strip", got: toolchain.Strip(), want: "strip"}, + } { + if test.got != test.want { + t.Fatalf("%s = %q, want %q", test.name, test.got, test.want) + } + } +} diff --git a/internal/crosscompile/crosscompile.go b/internal/crosscompile/crosscompile.go new file mode 100644 index 0000000..894a736 --- /dev/null +++ b/internal/crosscompile/crosscompile.go @@ -0,0 +1,207 @@ +package crosscompile + +import ( + "context" + "errors" + "fmt" + "io" + "runtime" + "strings" + + "github.com/goplus/llar/formula" + "github.com/goplus/llar/internal/build" + "github.com/goplus/llar/internal/build/cache" + "github.com/goplus/llar/internal/crosscompile/c" + "github.com/goplus/llar/internal/crosscompile/c/llvm" + "github.com/goplus/llar/internal/formula/repo" + "github.com/goplus/llar/internal/modules" + "github.com/goplus/llar/mod/module" + ccmetadata "github.com/goplus/llar/x/metadata/cc" + "github.com/kballard/go-shellquote" +) + +// Config contains the build resources used to prepare a cross-compile target. +type Config struct { + Store repo.Store + Matrix formula.Matrix + Stdout io.Writer + Stderr io.Writer + WorkspaceDir string + Cache cache.Cache +} + +// customTarget uses the bootstrap toolchain to build libc itself. When a +// consumer such as zlib supplies cmake.Sysroot or autotools.Sysroot, it switches +// to the toolchain selected from that sysroot. +type customTarget struct { + targetOS string + targetArch string + targetMatrix string + bootstrap *c.Target + configured *c.Target +} + +func (t *customTarget) Use(command build.Command) build.Patch { + root, err := commandSysroot(command) + if err != nil { + panic(err) + } + if root == "" && t.configured == nil { + return t.bootstrap.Use(command) + } + if t.configured == nil { + toolchain, err := llvm.New(llvm.Config{OS: t.targetOS, Arch: t.targetArch, Sysroot: root}) + if err != nil { + panic(fmt.Errorf("prepare C toolchain for %s: %w", t.targetMatrix, err)) + } + target, err := c.NewTarget(c.Config{ + Matrix: t.targetMatrix, + Toolchain: toolchain.Toolchain, + Sysroot: root, + }) + if err != nil { + panic(err) + } + t.configured = target + } + return t.configured.Use(command) +} + +func (t *customTarget) Close() error { + var configuredErr error + if t.configured != nil { + configuredErr = t.configured.Close() + } + return errors.Join(t.bootstrap.Close(), configuredErr) +} + +func commandSysroot(command build.Command) (string, error) { + var root string + for _, entry := range command.Env { + key, value, ok := strings.Cut(entry, "=") + if !ok { + continue + } + switch key { + case "CPPFLAGS", "CFLAGS", "CXXFLAGS", "LDFLAGS": + metadata, err := ccmetadata.Parse(value) + if err != nil { + return "", fmt.Errorf("parse %s for cross compile target: %w", key, err) + } + if metadata.Sysroot() != "" { + root = metadata.Sysroot() + } + } + } + metadata, err := ccmetadata.Parse(shellquote.Join(command.Args...)) + if err != nil { + return "", fmt.Errorf("parse command flags for cross compile target: %w", err) + } + if metadata.Sysroot() != "" { + root = metadata.Sysroot() + } + for _, arg := range command.Args { + key, value, ok := strings.Cut(arg, "=") + if !ok { + continue + } + key = strings.TrimPrefix(key, "-D") + key, _, _ = strings.Cut(key, ":") + if key == "CMAKE_SYSROOT" || key == "CMAKE_OSX_SYSROOT" { + root = value + } + } + return root, nil +} + +// Load returns the target used to cross-compile root. A nil target means the +// requested matrix is native or has no built-in C target policy. +func Load(ctx context.Context, root module.Version, config Config) (build.Target, error) { + var targetOS, targetArch string + if values := config.Matrix.Require["os"]; len(values) > 0 { + targetOS = values[0] + } + if values := config.Matrix.Require["arch"]; len(values) > 0 { + targetArch = values[0] + } + if targetOS == runtime.GOOS && targetArch == runtime.GOARCH { + return nil, nil + } + + // TODO: Add other language target policies alongside this C case when + // they provide build.Target implementations. + cSysroot, ok := c.Sysroot(targetOS, targetArch) + if !ok { + return nil, nil + } + targetMatrix := targetArch + "-" + targetOS + bootstrapToolchain, err := llvm.New(llvm.Config{OS: targetOS, Arch: targetArch}) + if err != nil { + return nil, fmt.Errorf("prepare C toolchain for %s: %w", targetMatrix, err) + } + bootstrapTarget, err := c.NewTarget(c.Config{ + Matrix: targetMatrix, + Toolchain: bootstrapToolchain.Toolchain, + }) + if err != nil { + return nil, err + } + + _, customLibc := config.Matrix.Require["libc"] + if root.Path == cSysroot.Path { + return bootstrapTarget, nil + } + if customLibc { + return &customTarget{ + targetOS: targetOS, + targetArch: targetArch, + targetMatrix: targetMatrix, + bootstrap: bootstrapTarget, + }, nil + } + defer bootstrapTarget.Close() + + // The default sysroot has no dependencies, but modules.Load still owns + // selecting the Formula whose fromVer applies to cSysroot.Version. + sysrootMods, err := modules.Load(ctx, cSysroot, modules.Options{ + FormulaStore: config.Store, + Matrix: config.Matrix, + }) + if err != nil { + return nil, fmt.Errorf("failed to load sysroot %s@%s: %w", cSysroot.Path, cSysroot.Version, err) + } + selectedSysroot := sysrootMods[0] + sysrootBuilder, err := build.NewBuilder(build.Options{ + Store: config.Store, + MatrixStr: config.Matrix.Combinations()[0], + Stdout: config.Stdout, + Stderr: config.Stderr, + WorkspaceDir: config.WorkspaceDir, + Cache: config.Cache, + Target: bootstrapTarget, + }) + if err != nil { + return nil, fmt.Errorf("failed to create sysroot builder: %w", err) + } + sysrootResults, err := sysrootBuilder.Build(ctx, sysrootMods) + if err != nil { + return nil, fmt.Errorf("failed to build sysroot %s@%s: %w", selectedSysroot.Path, selectedSysroot.Version, err) + } + metadata, err := ccmetadata.Parse(sysrootResults[len(sysrootResults)-1].Metadata) + if err != nil { + return nil, fmt.Errorf("failed to parse sysroot metadata for %s@%s: %w", selectedSysroot.Path, selectedSysroot.Version, err) + } + if metadata.Sysroot() == "" { + return nil, fmt.Errorf("sysroot metadata for %s@%s has no sysroot", selectedSysroot.Path, selectedSysroot.Version) + } + + configuredToolchain, err := llvm.New(llvm.Config{OS: targetOS, Arch: targetArch, Sysroot: metadata.Sysroot()}) + if err != nil { + return nil, fmt.Errorf("prepare C toolchain for %s: %w", targetMatrix, err) + } + return c.NewTarget(c.Config{ + Matrix: targetMatrix, + Toolchain: configuredToolchain.Toolchain, + Sysroot: metadata.Sysroot(), + }) +} diff --git a/internal/crosscompile/crosscompile_test.go b/internal/crosscompile/crosscompile_test.go new file mode 100644 index 0000000..527f740 --- /dev/null +++ b/internal/crosscompile/crosscompile_test.go @@ -0,0 +1,350 @@ +package crosscompile + +import ( + "context" + "errors" + "io" + "io/fs" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/goplus/llar/formula" + "github.com/goplus/llar/internal/build" + "github.com/goplus/llar/internal/build/cache" + "github.com/goplus/llar/mod/module" +) + +func TestLoadWithoutCrossCompileTarget(t *testing.T) { + tests := []struct { + name string + matrix formula.Matrix + }{ + { + name: "native", + matrix: formula.Matrix{Require: map[string][]string{ + "os": {runtime.GOOS}, + "arch": {runtime.GOARCH}, + }}, + }, + { + name: "unsupported", + matrix: formula.Matrix{Require: map[string][]string{ + "os": {"unsupported"}, + "arch": {"unsupported"}, + }}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + target, err := Load(context.Background(), module.Version{Path: "owner/repo", Version: "v1.0.0"}, Config{Matrix: tt.matrix}) + if err != nil { + t.Fatal(err) + } + if target != nil { + t.Fatalf("Load target = %T, want nil", target) + } + }) + } +} + +func TestLoadBootstrapTarget(t *testing.T) { + installFakeLLVM(t) + _, triple := linuxCrossMatrix() + tests := []struct { + name string + root module.Version + libc bool + }{ + { + name: "custom libc", + root: module.Version{Path: "owner/repo", Version: "v1.0.0"}, + libc: true, + }, + { + name: "sysroot formula", + root: module.Version{Path: "bminor/glibc", Version: "glibc-2.24"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + selected, _ := linuxCrossMatrix() + if tt.libc { + selected.Require["libc"] = []string{"custom"} + } + target, err := Load(context.Background(), tt.root, Config{Matrix: selected}) + if err != nil { + t.Fatal(err) + } + if target == nil { + t.Fatal("Load target = nil") + } + if closer, ok := target.(io.Closer); ok { + t.Cleanup(func() { _ = closer.Close() }) + } + patch := target.Use(build.Command{Name: "cc"}) + if filepath.Base(patch.Name) != "clang" { + t.Fatalf("compiler = %q, want fake clang", patch.Name) + } + args := strings.Join(patch.PrependArg, " ") + if !strings.Contains(args, "--target="+triple) { + t.Fatalf("compiler args = %q, want target %q", args, triple) + } + if strings.Contains(args, "sysroot") { + t.Fatalf("bootstrap compiler args = %q, want no sysroot", args) + } + }) + } +} + +func TestLoadDefaultSysroot(t *testing.T) { + installFakeLLVM(t) + matrix, _ := linuxCrossMatrix() + arch := matrix.Require["arch"][0] + sysroot := fakeCrossSysroot(t, arch, "gnu") + target, err := Load(context.Background(), module.Version{Path: "owner/repo", Version: "v1.0.0"}, Config{ + Store: localSysrootFormulas(t), + Matrix: matrix, + Stdout: io.Discard, + Stderr: io.Discard, + WorkspaceDir: t.TempDir(), + Cache: metadataCache{metadata: "--sysroot=" + sysroot}, + }) + if err != nil { + t.Fatal(err) + } + if target == nil { + t.Fatal("Load target = nil") + } + if closer, ok := target.(io.Closer); ok { + t.Cleanup(func() { _ = closer.Close() }) + } + patch := target.Use(build.Command{Name: "cc"}) + if args := strings.Join(patch.PrependArg, " "); !strings.Contains(args, "--sysroot="+sysroot) { + t.Fatalf("compiler args = %q, want configured sysroot", args) + } +} + +func TestLoadCustomLibcUsesCommandSysroot(t *testing.T) { + installFakeLLVM(t) + matrix, _ := linuxCrossMatrix() + arch := matrix.Require["arch"][0] + matrix.Require["libc"] = []string{"custom"} + target, err := Load(context.Background(), module.Version{Path: "owner/repo", Version: "v1.0.0"}, Config{Matrix: matrix}) + if err != nil { + t.Fatal(err) + } + if closer, ok := target.(io.Closer); ok { + t.Cleanup(func() { _ = closer.Close() }) + } + + sysroot := fakeCrossSysroot(t, arch, "musl") + patch := target.Use(build.Command{Name: "cc", Args: []string{"--sysroot=" + sysroot}}) + args := strings.Join(patch.PrependArg, " ") + wantTriple := "x86_64-linux-musl" + if arch == "arm64" { + wantTriple = "aarch64-linux-musl" + } + if !strings.Contains(args, "--target="+wantTriple) { + t.Fatalf("compiler args = %q, want target %q", args, wantTriple) + } +} + +func TestCommandSysroot(t *testing.T) { + tests := []struct { + name string + command build.Command + want string + }{ + { + name: "autotools environment", + command: build.Command{Env: []string{"CFLAGS=-O2 --sysroot=/autotools-sdk -isysroot/autotools-sdk"}}, + want: "/autotools-sdk", + }, + { + name: "cmake definition", + command: build.Command{Args: []string{"-DCMAKE_SYSROOT:STRING=/cmake-sdk"}}, + want: "/cmake-sdk", + }, + { + name: "compiler option", + command: build.Command{Args: []string{"-c", "source.c", "-isysroot/compiler-sdk"}}, + want: "/compiler-sdk", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := commandSysroot(tt.command) + if err != nil { + t.Fatal(err) + } + if got != tt.want { + t.Fatalf("commandSysroot = %q, want %q", got, tt.want) + } + }) + } +} + +func TestLoadErrors(t *testing.T) { + matrix, _ := linuxCrossMatrix() + root := module.Version{Path: "owner/repo", Version: "v1.0.0"} + cacheErr := errors.New("cache unavailable") + + t.Run("toolchain", func(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + selected, _ := linuxCrossMatrix() + selected.Require["libc"] = []string{"custom"} + _, err := Load(context.Background(), root, Config{Matrix: selected}) + if err == nil || !strings.Contains(err.Error(), "prepare C toolchain") { + t.Fatalf("Load error = %v, want toolchain error", err) + } + }) + + t.Run("formula", func(t *testing.T) { + installFakeLLVM(t) + formulaErr := errors.New("formula unavailable") + _, err := Load(context.Background(), root, Config{ + Store: formulaStore{err: formulaErr}, + Matrix: matrix, + }) + if !errors.Is(err, formulaErr) || !strings.Contains(err.Error(), "failed to load sysroot") { + t.Fatalf("Load error = %v, want formula error", err) + } + }) + + tests := []struct { + name string + cache cache.Cache + wantText string + wantErr error + }{ + { + name: "cache", + cache: metadataCache{err: cacheErr}, + wantText: "failed to build sysroot", + wantErr: cacheErr, + }, + { + name: "metadata", + cache: metadataCache{metadata: "--sysroot"}, + wantText: "failed to parse sysroot metadata", + }, + { + name: "missing sysroot", + cache: metadataCache{metadata: "-I/include"}, + wantText: "has no sysroot", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + installFakeLLVM(t) + _, err := Load(context.Background(), root, Config{ + Store: localSysrootFormulas(t), + Matrix: matrix, + Stdout: io.Discard, + Stderr: io.Discard, + WorkspaceDir: t.TempDir(), + Cache: tt.cache, + }) + if err == nil || !strings.Contains(err.Error(), tt.wantText) { + t.Fatalf("Load error = %v, want %q", err, tt.wantText) + } + if tt.wantErr != nil && !errors.Is(err, tt.wantErr) { + t.Fatalf("Load error = %v, want wrapped %v", err, tt.wantErr) + } + }) + } +} + +func linuxCrossMatrix() (formula.Matrix, string) { + arch := "amd64" + triple := "x86_64-linux-gnu" + if runtime.GOOS == "linux" && runtime.GOARCH == arch { + arch = "arm64" + triple = "aarch64-linux-gnu" + } + return formula.Matrix{Require: map[string][]string{ + "os": {"linux"}, + "arch": {arch}, + }}, triple +} + +func installFakeLLVM(t *testing.T) { + t.Helper() + dir := t.TempDir() + for _, name := range []string{"clang", "clang++", "ld.lld", "llvm-ar", "llvm-ranlib", "llvm-nm", "llvm-strip"} { + if err := os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + } + t.Setenv("PATH", dir) +} + +func fakeCrossSysroot(t *testing.T, arch, environment string) string { + t.Helper() + root := t.TempDir() + var loader string + switch arch + "-" + environment { + case "amd64-gnu": + loader = "lib64/ld-linux-x86-64.so.2" + case "amd64-musl": + loader = "lib/ld-musl-x86_64.so.1" + case "arm64-gnu": + loader = "lib/ld-linux-aarch64.so.1" + case "arm64-musl": + loader = "lib/ld-musl-aarch64.so.1" + default: + t.Fatalf("unsupported fake Linux sysroot %s/%s", arch, environment) + } + path := filepath.Join(root, filepath.FromSlash(loader)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, nil, 0o755); err != nil { + t.Fatal(err) + } + return root +} + +func localSysrootFormulas(t *testing.T) formulaStore { + t.Helper() + _, filename, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + return formulaStore{root: filepath.Join(filepath.Dir(filename), "..", "..", "testdata", "crosscompile-e2e", "formulas")} +} + +type formulaStore struct { + root string + err error +} + +func (s formulaStore) ModuleFS(_ context.Context, modPath string) (fs.FS, error) { + if s.err != nil { + return nil, s.err + } + return os.DirFS(filepath.Join(s.root, filepath.FromSlash(modPath))), nil +} + +func (formulaStore) LockModule(string) (func(), error) { + return func() {}, nil +} + +type metadataCache struct { + metadata string + err error +} + +func (c metadataCache) Get(context.Context, cache.Key) (cache.Entry, bool, error) { + if c.err != nil { + return cache.Entry{}, false, c.err + } + return cache.Entry{Metadata: c.metadata}, true, nil +} + +func (metadataCache) Put(context.Context, cache.Key, fs.FS, cache.Entry) (cache.Entry, error) { + return cache.Entry{}, errors.New("unexpected cache Put") +} diff --git a/testdata/crosscompile-e2e/consumer.c b/testdata/crosscompile-e2e/consumer.c new file mode 100644 index 0000000..d3ccb9a --- /dev/null +++ b/testdata/crosscompile-e2e/consumer.c @@ -0,0 +1,60 @@ +#include +#include +#include + +int main(void) { + static const Bytef input[] = "LLAR cross-compiled zlib"; + static const char gzip_path[] = "llar-zlib-e2e.gz"; + Bytef compressed[128]; + Bytef restored[128]; + uLongf compressed_len = sizeof(compressed); + uLongf restored_len = sizeof(restored); + gzFile gzip; + int gzip_len; + + if (compress(compressed, &compressed_len, input, sizeof(input)) != Z_OK) { + return 1; + } + if (uncompress(restored, &restored_len, compressed, compressed_len) != Z_OK) { + return 2; + } + if (restored_len != sizeof(input) || memcmp(restored, input, sizeof(input)) != 0) { + return 3; + } + if (strcmp(zlibVersion(), ZLIB_VERSION) != 0) { + return 4; + } + + gzip = gzopen(gzip_path, "wb"); + if (gzip == NULL) { + return 5; + } + if (gzwrite(gzip, input, (unsigned)sizeof(input)) != (int)sizeof(input)) { + return 6; + } + if (gzclose(gzip) != Z_OK) { + return 7; + } + + gzip = gzopen(gzip_path, "rb"); + if (gzip == NULL) { + return 8; + } + gzip_len = gzread(gzip, restored, (unsigned)sizeof(restored)); + if (gzip_len != (int)sizeof(input)) { + return 9; + } + if (gzclose(gzip) != Z_OK) { + return 10; + } + if (memcmp(restored, input, sizeof(input)) != 0) { + return 11; + } + if (remove(gzip_path) != 0) { + return 12; + } + + printf("zlib %s: compressed %lu bytes to %lu bytes and verified gzip I/O\n", + zlibVersion(), (unsigned long)sizeof(input), (unsigned long)compressed_len); + return 0; +} diff --git a/testdata/crosscompile-e2e/formulas/bminor/glibc/glibc-2.24/Glibc_llar.gox b/testdata/crosscompile-e2e/formulas/bminor/glibc/glibc-2.24/Glibc_llar.gox new file mode 100644 index 0000000..ff7ef81 --- /dev/null +++ b/testdata/crosscompile-e2e/formulas/bminor/glibc/glibc-2.24/Glibc_llar.gox @@ -0,0 +1,8 @@ +id "bminor/glibc" + +fromVer "glibc-2.24" + +onBuild ctx => { + exec! "./install-sysroot", ctx.outputDir, "0" + ctx.setMetadata "--sysroot="+ctx.outputDir +} diff --git a/testdata/crosscompile-e2e/formulas/bminor/glibc/versions.json b/testdata/crosscompile-e2e/formulas/bminor/glibc/versions.json new file mode 100644 index 0000000..4b06491 --- /dev/null +++ b/testdata/crosscompile-e2e/formulas/bminor/glibc/versions.json @@ -0,0 +1,4 @@ +{ + "path": "bminor/glibc", + "deps": {} +} diff --git a/testdata/crosscompile-e2e/formulas/ifduyue/musl/v1.1.19/Musl_llar.gox b/testdata/crosscompile-e2e/formulas/ifduyue/musl/v1.1.19/Musl_llar.gox new file mode 100644 index 0000000..1f10c74 --- /dev/null +++ b/testdata/crosscompile-e2e/formulas/ifduyue/musl/v1.1.19/Musl_llar.gox @@ -0,0 +1,8 @@ +id "ifduyue/musl" + +fromVer "v1.1.19" + +onBuild ctx => { + exec! "./install-sysroot", ctx.outputDir + ctx.setMetadata "--sysroot="+ctx.outputDir +} diff --git a/testdata/crosscompile-e2e/formulas/ifduyue/musl/versions.json b/testdata/crosscompile-e2e/formulas/ifduyue/musl/versions.json new file mode 100644 index 0000000..97b6460 --- /dev/null +++ b/testdata/crosscompile-e2e/formulas/ifduyue/musl/versions.json @@ -0,0 +1,4 @@ +{ + "path": "ifduyue/musl", + "deps": {} +} diff --git a/testdata/crosscompile-e2e/formulas/joseluisq/macosx-sdks/sdk-14_5/MacosxSdks_llar.gox b/testdata/crosscompile-e2e/formulas/joseluisq/macosx-sdks/sdk-14_5/MacosxSdks_llar.gox new file mode 100644 index 0000000..e1adeaf --- /dev/null +++ b/testdata/crosscompile-e2e/formulas/joseluisq/macosx-sdks/sdk-14_5/MacosxSdks_llar.gox @@ -0,0 +1,8 @@ +id "joseluisq/macosx-sdks" + +fromVer "14.5" + +onBuild ctx => { + exec! "./install-sysroot", ctx.outputDir, "1" + ctx.setMetadata "--sysroot="+ctx.outputDir +} diff --git a/testdata/crosscompile-e2e/formulas/joseluisq/macosx-sdks/versions.json b/testdata/crosscompile-e2e/formulas/joseluisq/macosx-sdks/versions.json new file mode 100644 index 0000000..67d412f --- /dev/null +++ b/testdata/crosscompile-e2e/formulas/joseluisq/macosx-sdks/versions.json @@ -0,0 +1,4 @@ +{ + "path": "joseluisq/macosx-sdks", + "deps": {} +} diff --git a/testdata/crosscompile-e2e/formulas/llar/pkgconfig-consumer/v1.0.0/Consumer_llar.gox b/testdata/crosscompile-e2e/formulas/llar/pkgconfig-consumer/v1.0.0/Consumer_llar.gox new file mode 100644 index 0000000..6d4838c --- /dev/null +++ b/testdata/crosscompile-e2e/formulas/llar/pkgconfig-consumer/v1.0.0/Consumer_llar.gox @@ -0,0 +1,18 @@ +id "llar/pkgconfig-consumer" + +fromVer "v1.0.0" + +onBuild ctx => { + installDir := ctx.outputDir + + c := cmake.new(ctx.SourceDir, ctx.SourceDir+"/_build", installDir) + c.buildType "Release" + for _, dep := range ctx.Proj.Deps { + c.use ctx.outputDir(dep) + } + c.configure + c.build + c.install + + ctx.setMetadata "" +} diff --git a/testdata/crosscompile-e2e/formulas/llar/pkgconfig-consumer/versions.json b/testdata/crosscompile-e2e/formulas/llar/pkgconfig-consumer/versions.json new file mode 100644 index 0000000..66467b3 --- /dev/null +++ b/testdata/crosscompile-e2e/formulas/llar/pkgconfig-consumer/versions.json @@ -0,0 +1,8 @@ +{ + "path": "llar/pkgconfig-consumer", + "deps": { + "v1.0.0": [ + {"path": "madler/zlib", "version": "v1.3.1"} + ] + } +} diff --git a/testdata/crosscompile-e2e/install-bootlin-sysroot b/testdata/crosscompile-e2e/install-bootlin-sysroot new file mode 100755 index 0000000..10cf77d --- /dev/null +++ b/testdata/crosscompile-e2e/install-bootlin-sysroot @@ -0,0 +1,46 @@ +#!/bin/sh +set -eu + +output_dir=$1 +source_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) + +set -- "$source_dir"/aarch64--*--stable-*.tar.bz2 +if [ "$#" -ne 1 ] || [ ! -f "$1" ]; then + echo "expected one Bootlin toolchain archive" >&2 + exit 1 +fi +archive=$1 + +case $(basename -- "$archive") in + aarch64--glibc--stable-2017.05-toolchains-1-1.tar.bz2) + root=aarch64--glibc--stable + tuple=aarch64-buildroot-linux-gnu + gcc_version=5.4.0 + ;; + aarch64--glibc--stable-2018.11-1.tar.bz2) + root=aarch64--glibc--stable-2018.11-1 + tuple=aarch64-buildroot-linux-gnu + gcc_version=7.3.0 + ;; + aarch64--musl--stable-2018.11-1.tar.bz2) + root=aarch64--musl--stable-2018.11-1 + tuple=aarch64-buildroot-linux-musl + gcc_version=7.3.0 + ;; + *) + echo "unsupported Bootlin toolchain archive: $(basename -- "$archive")" >&2 + exit 1 + ;; +esac + +mkdir -p "$output_dir" +LC_ALL=C tar -xjf "$archive" -C "$output_dir" --strip-components=3 \ + "$root/$tuple/sysroot" \ + "$root/lib/gcc/$tuple/$gcc_version" + +# Clang needs the target GCC runtime, for example crtbeginS.o and libgcc.a, +# beside the target libc when configure links compiler probes. +runtime_dir="$output_dir/$tuple/$gcc_version" +mkdir -p "$output_dir/usr/lib" +cp -a "$runtime_dir/." "$output_dir/usr/lib/" +rm -r "$output_dir/$tuple" diff --git a/testdata/crosscompile-e2e/install-sysroot b/testdata/crosscompile-e2e/install-sysroot new file mode 100644 index 0000000..12d411f --- /dev/null +++ b/testdata/crosscompile-e2e/install-sysroot @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu + +source_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +tar -xJf "$source_dir/sysroot.tar.xz" -C "$1" --strip-components="$2" diff --git a/testdata/crosscompile-e2e/libuv-consumer.c b/testdata/crosscompile-e2e/libuv-consumer.c new file mode 100644 index 0000000..49cfabd --- /dev/null +++ b/testdata/crosscompile-e2e/libuv-consumer.c @@ -0,0 +1,29 @@ +#include + +#include + +#ifndef EXPECT_ABI_MATCH +#error EXPECT_ABI_MATCH must be defined +#endif + +int main(void) { + size_t built_size = uv_loop_size(); + size_t consumer_size = sizeof(uv_loop_t); + + printf("libuv loop size: library=%zu consumer=%zu\n", + built_size, consumer_size); + +#if EXPECT_ABI_MATCH + // The musl-built library and musl consumer must use the same pthread layout. + if (built_size != consumer_size) { + return 1; + } +#else + // The glibc consumer is the negative control: uv_loop_t contains libc-owned + // pthread types, so matching sizes would fail to distinguish the two ABIs. + if (built_size == consumer_size) { + return 1; + } +#endif + return 0; +} diff --git a/testdata/crosscompile-e2e/pkgconfig-consumer/project.cmake b/testdata/crosscompile-e2e/pkgconfig-consumer/project.cmake new file mode 100644 index 0000000..01d6ab1 --- /dev/null +++ b/testdata/crosscompile-e2e/pkgconfig-consumer/project.cmake @@ -0,0 +1,23 @@ +cmake_minimum_required(VERSION 3.16) +project(llar_pkgconfig_consumer C) + +find_package(PkgConfig REQUIRED) +pkg_check_modules(ZLIB REQUIRED IMPORTED_TARGET zlib) + +# zlib.pc already names its LLAR output root. A target sysroot prepended to +# that absolute path would make both directories below nonexistent. +foreach(include_dir IN LISTS ZLIB_INCLUDE_DIRS) + if(NOT EXISTS "${include_dir}/zlib.h") + message(FATAL_ERROR "pkg-config returned missing zlib include directory: ${include_dir}") + endif() +endforeach() +foreach(library_dir IN LISTS ZLIB_LIBRARY_DIRS) + if(NOT EXISTS "${library_dir}/libz.a") + message(FATAL_ERROR "pkg-config returned missing zlib library directory: ${library_dir}") + endif() +endforeach() + +add_executable(llar-pkgconfig-consumer main.c) +target_link_libraries(llar-pkgconfig-consumer PRIVATE PkgConfig::ZLIB) + +install(TARGETS llar-pkgconfig-consumer RUNTIME DESTINATION bin) diff --git a/testdata/kodo-e2e/formulas/libuv/libuv/v1.49.2/Libuv_llar.gox b/testdata/kodo-e2e/formulas/libuv/libuv/v1.49.2/Libuv_llar.gox new file mode 100644 index 0000000..eda93e3 --- /dev/null +++ b/testdata/kodo-e2e/formulas/libuv/libuv/v1.49.2/Libuv_llar.gox @@ -0,0 +1,29 @@ +id "libuv/libuv" + +fromVer "v1.49.2" + +onRequire (proj, deps) => { + if libc := target.require["libc"]; len(libc) > 0 && libc[0] == "musl-1.1.19" { + deps.require "ifduyue/musl", "v1.1.19" + } +} + +onBuild ctx => { + installDir := ctx.outputDir + + c := cmake.new(ctx.SourceDir, ctx.SourceDir+"/_build", installDir) + for _, dep := range ctx.Proj.Deps { + if dep.Path == "ifduyue/musl" { + c.sysroot ctx.outputDir(dep) + } + } + c.buildType "Release" + c.defineBool "CMAKE_VERBOSE_MAKEFILE", true + c.defineBool "LIBUV_BUILD_SHARED", false + c.defineBool "BUILD_TESTING", false + c.configure + c.build + c.install + + ctx.setMetadata "-luv -pthread -ldl -lrt" +} diff --git a/testdata/kodo-e2e/formulas/libuv/libuv/versions.json b/testdata/kodo-e2e/formulas/libuv/libuv/versions.json new file mode 100644 index 0000000..275b882 --- /dev/null +++ b/testdata/kodo-e2e/formulas/libuv/libuv/versions.json @@ -0,0 +1,4 @@ +{ + "path": "libuv/libuv", + "deps": {} +} diff --git a/testdata/kodo-e2e/formulas/madler/zlib/v1.3.1/Zlib_llar.gox b/testdata/kodo-e2e/formulas/madler/zlib/v1.3.1/Zlib_llar.gox index 11a9297..d081ecd 100644 --- a/testdata/kodo-e2e/formulas/madler/zlib/v1.3.1/Zlib_llar.gox +++ b/testdata/kodo-e2e/formulas/madler/zlib/v1.3.1/Zlib_llar.gox @@ -2,10 +2,23 @@ id "madler/zlib" fromVer "v1.3.1" +onRequire (proj, deps) => { + if libc := target.require["libc"]; len(libc) > 0 { + if libc[0] == "glibc-2.27" { + deps.require "bminor/glibc", libc[0] + } + } +} + onBuild ctx => { installDir := ctx.outputDir a := autotools.new(ctx.SourceDir, ctx.SourceDir+"/_build", installDir) + for _, dep := range ctx.Proj.Deps { + if dep.Path == "bminor/glibc" { + a.sysroot ctx.outputDir(dep) + } + } a.configure "--static" a.build diff --git a/testdata/llard-e2e/e2e_test.go b/testdata/llard-e2e/e2e_test.go index 5378322..575a3df 100644 --- a/testdata/llard-e2e/e2e_test.go +++ b/testdata/llard-e2e/e2e_test.go @@ -133,15 +133,32 @@ func TestLLARDE2E(t *testing.T) { if err != nil { t.Fatal(err) } + crossMatrix := classfile.Matrix{Require: map[string][]string{ + "arch": {"arm64"}, + "os": {"linux"}, + }} + cross := *s + cross.matrix = crossMatrix + cross.matrixStr = crossMatrix.Combinations()[0] + cross.query = url.Values{ + "arch": {"arm64"}, + "os": {"linux"}, + }.Encode() if err := s.deleteArtifacts(ctx, zlibTarget, libpngTarget, cjsonTarget); err != nil { t.Fatal(err) } + if err := cross.deleteArtifacts(ctx, zlibTarget); err != nil { + t.Fatal(err) + } t.Cleanup(func() { ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() if err := s.deleteArtifacts(ctx, zlibTarget, libpngTarget, cjsonTarget); err != nil { t.Errorf("cleanup artifacts: %v", err) } + if err := cross.deleteArtifacts(ctx, zlibTarget); err != nil { + t.Errorf("cleanup cross-compile artifact: %v", err) + } }) for _, step := range []struct { @@ -152,6 +169,7 @@ func TestLLARDE2E(t *testing.T) { {"warm zlib request reuses stored artifact", s.warmZlibBuild}, {"concurrent zlib requests run one build", s.concurrentZlibBuild}, {"concurrent roots share canonical zlib artifact", s.concurrentSharedDependency}, + {"cross-compile zlib for Linux arm64", cross.crossCompileZlib}, {"protocol errors use command JSON lines", s.protocolErrors}, } { start := time.Now() @@ -439,6 +457,70 @@ func (s *suite) concurrentSharedDependency(ctx context.Context) error { return nil } +func (s *suite) crossCompileZlib(ctx context.Context) error { + before, err := s.buildCounts(ctx) + if err != nil { + return err + } + got, err := s.get(ctx, s.cfg.baseURL, zlibTarget) + if err != nil { + return err + } + if err := s.requireSuccess(got, zlibTarget); err != nil { + return err + } + after, err := s.buildCounts(ctx) + if err != nil { + return err + } + if delta := after[zlibTarget.key()] - before[zlibTarget.key()]; delta != 1 { + return fmt.Errorf("cross-compile zlib build count = %d, want 1", delta) + } + if got.upstream == "" { + return errors.New("cross-compile nginx response has no X-Upstream-Addr") + } + if _, ok := s.upstreams[got.upstream]; !ok { + return fmt.Errorf("cross-compile nginx upstream = %q, workers = %v", got.upstream, s.upstreams) + } + + hasTarget := false + hasSysroot := false + for _, line := range got.infos { + if strings.Contains(line, "--target=aarch64-linux-gnu") { + hasTarget = true + } + if strings.Contains(line, "bminor/glibc@glibc-2.24-") { + hasSysroot = true + } + } + if !hasTarget { + return fmt.Errorf("cross-compile info has no ARM64 GNU target:\n%s", strings.Join(got.infos, "\n")) + } + if !hasSysroot { + return fmt.Errorf("cross-compile info has no default glibc sysroot:\n%s", strings.Join(got.infos, "\n")) + } + + message := got.artifacts[0] + if len(message.Deps) != 0 { + return fmt.Errorf("cross-compile zlib deps = %q, want hidden sysroot", message.Deps) + } + info, checksum, err := s.downloadArtifact(ctx, message) + if err != nil { + return err + } + if info.Metadata != zlibTarget.metadata { + return fmt.Errorf("cross-compile zlib metadata = %q, want %q", info.Metadata, zlibTarget.metadata) + } + stored, err := s.artifacts.Get(ctx, s.artifactKey(zlibTarget)) + if err != nil { + return err + } + if stored.Source.URL != message.URL || stored.Checksum != checksum { + return fmt.Errorf("stored cross-compile zlib artifact = %+v, response URL %q checksum %q", stored, message.URL, checksum) + } + return nil +} + func (s *suite) protocolErrors(ctx context.Context) error { missingMatrix, err := s.do(ctx, http.MethodGet, s.cfg.baseURL+"/v1/artifacts/"+zlibTarget.path+"@"+zlibTarget.version) if err != nil { diff --git a/x/autotools/autotools.go b/x/autotools/autotools.go index bfa59b4..8bc45bd 100644 --- a/x/autotools/autotools.go +++ b/x/autotools/autotools.go @@ -29,6 +29,16 @@ func New(sourceDir, buildDir, installDir string) *AutoTools { // Source overrides the source directory. func (a *AutoTools) Source(dir string) { a.sourceDir = dir } +// Sysroot sets the target system root for the compiler and linker. Both +// compiler spellings are supplied so the same Formula works for generic and +// Apple targets. +func (a *AutoTools) Sysroot(root string) { + for _, key := range []string{"CPPFLAGS", "CFLAGS", "CXXFLAGS", "LDFLAGS"} { + appendFlag(key, "--sysroot="+root) + appendFlag(key, "-isysroot"+root) + } +} + // Use configures the process environment so that Autotools, compilers, and // pkg-config find a non-system dependency installed at root. func (a *AutoTools) Use(root string) { diff --git a/x/autotools/autotools_test.go b/x/autotools/autotools_test.go index 2f24a47..0d275b5 100644 --- a/x/autotools/autotools_test.go +++ b/x/autotools/autotools_test.go @@ -115,6 +115,38 @@ func TestUsePartialDirs(t *testing.T) { } } +func TestSysroot(t *testing.T) { + for _, key := range []string{"CPPFLAGS", "CFLAGS", "CXXFLAGS", "LDFLAGS"} { + t.Setenv(key, "-existing") + } + for _, key := range []string{"PKG_CONFIG_SYSROOT_DIR", "PKG_CONFIG_LIBDIR"} { + value, ok := os.LookupEnv(key) + if err := os.Unsetenv(key); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if ok { + _ = os.Setenv(key, value) + } else { + _ = os.Unsetenv(key) + } + }) + } + a := New("", "", "") + a.Sysroot("/sdk") + + for _, key := range []string{"CPPFLAGS", "CFLAGS", "CXXFLAGS", "LDFLAGS"} { + if got, want := os.Getenv(key), "-existing --sysroot=/sdk -isysroot/sdk"; got != want { + t.Errorf("%s = %q, want %q", key, got, want) + } + } + for _, key := range []string{"PKG_CONFIG_SYSROOT_DIR", "PKG_CONFIG_LIBDIR"} { + if got, ok := os.LookupEnv(key); ok { + t.Errorf("%s = %q, want unset", key, got) + } + } +} + func TestSource(t *testing.T) { a := New("original", "", "") a.Source("/new/src") diff --git a/x/cmake/cmake.go b/x/cmake/cmake.go index 1c1625e..e7c2865 100644 --- a/x/cmake/cmake.go +++ b/x/cmake/cmake.go @@ -6,6 +6,7 @@ import ( "path/filepath" "runtime" "sort" + "strings" "github.com/goplus/llar/internal/execbroker" ) @@ -48,6 +49,16 @@ func (c *CMake) BuildType(name string) { c.buildType = name } // Toolchain sets CMAKE_TOOLCHAIN_FILE. func (c *CMake) Toolchain(path string) { c.toolchain = path } +// Sysroot sets the target system root for both generic and Apple targets. +func (c *CMake) Sysroot(root string) { + c.Define("CMAKE_SYSROOT", root) + c.Define("CMAKE_OSX_SYSROOT", root) + c.Define("CMAKE_FIND_ROOT_PATH_MODE_PROGRAM", "NEVER") + c.Define("CMAKE_FIND_ROOT_PATH_MODE_LIBRARY", "ONLY") + c.Define("CMAKE_FIND_ROOT_PATH_MODE_INCLUDE", "ONLY") + c.Define("CMAKE_FIND_ROOT_PATH_MODE_PACKAGE", "ONLY") +} + // Define adds a -D:STRING= definition. func (c *CMake) Define(key, value string) { c.defines[key] = defineValue{value: value, typeName: "STRING"} @@ -81,6 +92,7 @@ func (c *CMake) Use(root string) { } } prependPath("CMAKE_PREFIX_PATH", root) + prependPath("CMAKE_FIND_ROOT_PATH", root) if hasInclude { prependPath("CMAKE_INCLUDE_PATH", includeDir) } @@ -124,6 +136,9 @@ func (c *CMake) Configure(args ...string) { if c.buildType != "" { cmakeArgs = append(cmakeArgs, "-DCMAKE_BUILD_TYPE:STRING="+c.buildType) } + if roots := os.Getenv("CMAKE_FIND_ROOT_PATH"); roots != "" { + cmakeArgs = append(cmakeArgs, "-DCMAKE_FIND_ROOT_PATH:STRING="+strings.Join(filepath.SplitList(roots), ";")) + } cmakeArgs = append(cmakeArgs, c.definesArgs()...) cmakeArgs = append(cmakeArgs, args...) c.run("cmake", cmakeArgs) diff --git a/x/cmake/cmake_test.go b/x/cmake/cmake_test.go index 1b87ece..e56fc34 100644 --- a/x/cmake/cmake_test.go +++ b/x/cmake/cmake_test.go @@ -21,7 +21,7 @@ func TestUseSetsEnv(t *testing.T) { } for _, key := range []string{ - "PKG_CONFIG_PATH", "CMAKE_PREFIX_PATH", "CMAKE_INCLUDE_PATH", + "PKG_CONFIG_PATH", "CMAKE_PREFIX_PATH", "CMAKE_FIND_ROOT_PATH", "CMAKE_INCLUDE_PATH", "CMAKE_LIBRARY_PATH", "INCLUDE", "LIB", "CPPFLAGS", "LDFLAGS", } { t.Setenv(key, "") @@ -31,10 +31,11 @@ func TestUseSetsEnv(t *testing.T) { c.Use(root) for key, want := range map[string]string{ - "PKG_CONFIG_PATH": pkgconfigDir, - "CMAKE_PREFIX_PATH": root, - "CMAKE_INCLUDE_PATH": includeDir, - "CMAKE_LIBRARY_PATH": libDir, + "PKG_CONFIG_PATH": pkgconfigDir, + "CMAKE_PREFIX_PATH": root, + "CMAKE_FIND_ROOT_PATH": root, + "CMAKE_INCLUDE_PATH": includeDir, + "CMAKE_LIBRARY_PATH": libDir, } { if got := os.Getenv(key); got != want { t.Errorf("%s = %q, want %q", key, got, want) @@ -79,6 +80,18 @@ func TestUsePartialDirs(t *testing.T) { } } +func TestUseMultipleRoots(t *testing.T) { + t.Setenv("CMAKE_FIND_ROOT_PATH", "") + c := New("", "", "") + c.Use("/deps/one") + c.Use("/deps/two") + + sep := string(os.PathListSeparator) + if got, want := os.Getenv("CMAKE_FIND_ROOT_PATH"), "/deps/two"+sep+"/deps/one"; got != want { + t.Fatalf("CMAKE_FIND_ROOT_PATH = %q, want %q", got, want) + } +} + func TestOutputDir(t *testing.T) { if got := New("", "build", "").OutputDir(); got != "build" { t.Errorf("OutputDir = %q, want %q", got, "build") @@ -113,6 +126,25 @@ func TestDefinesArgs(t *testing.T) { } } +func TestSysroot(t *testing.T) { + c := New("", "", "") + c.Sysroot("/sdk") + + joined := strings.Join(c.definesArgs(), " ") + for _, want := range []string{ + "-DCMAKE_SYSROOT:STRING=/sdk", + "-DCMAKE_OSX_SYSROOT:STRING=/sdk", + "-DCMAKE_FIND_ROOT_PATH_MODE_PROGRAM:STRING=NEVER", + "-DCMAKE_FIND_ROOT_PATH_MODE_LIBRARY:STRING=ONLY", + "-DCMAKE_FIND_ROOT_PATH_MODE_INCLUDE:STRING=ONLY", + "-DCMAKE_FIND_ROOT_PATH_MODE_PACKAGE:STRING=ONLY", + } { + if !strings.Contains(joined, want) { + t.Errorf("definesArgs missing %q, got %q", want, joined) + } + } +} + func TestDefinesArgsEmpty(t *testing.T) { c := New("", "", "") if args := c.definesArgs(); args != nil {