diff --git a/.gitignore b/.gitignore index d639354..ccbb5c5 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ go.work.sum /build .idea/ .DS_Store +tmp/ diff --git a/README.md b/README.md index c807560..6cfde12 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,10 @@ The buildpack is published for consumption at This buildpack participates if one of the following detection succeeds: - (miniconda)[installers/pkg/minconda/README.md] -> Always -- (pip)[installers/ppkg/pip/README.md] -> Always -- (pipenv)[installers/ppkg/pipenv/README.md] -> Always -- (poetry)[installers/ppkg/poetry/README.md] -> `pyproject.toml` is present in the root folder +- (pip)[installers/pkg/pip/README.md] -> Always +- (pipenv)[installers/pkg/pipenv/README.md] -> Always +- (poetry)[installers/pkg/poetry/README.md] -> `pyproject.toml` is present in the root folder +- (uv)[installers/pkg/uv/README.md] -> `uv.lock` is present in the root folder The buildpack will do the following: * At build time: diff --git a/REUSE.toml b/REUSE.toml index 0b874b3..6690bf2 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -12,7 +12,7 @@ SPDX-FileCopyrightText = "© 2025 Idiap Research Institute " SPDX-License-Identifier = "Apache-2.0" [[annotations]] -path = "scripts/.util/tools.json" +path = "scripts/**" precedence = "override" SPDX-FileCopyrightText = "Copyright (c) 2013-Present CloudFoundry.org Foundation, Inc. All Rights Reserved." SPDX-License-Identifier = "Apache-2.0" diff --git a/build.go b/build.go index c74545c..ac09b1e 100644 --- a/build.go +++ b/build.go @@ -13,6 +13,7 @@ import ( pip "github.com/paketo-buildpacks/python-installers/pkg/installers/pip" pipenv "github.com/paketo-buildpacks/python-installers/pkg/installers/pipenv" poetry "github.com/paketo-buildpacks/python-installers/pkg/installers/poetry" + uv "github.com/paketo-buildpacks/python-installers/pkg/installers/uv" pythoninstallers "github.com/paketo-buildpacks/python-installers/pkg/installers/common" ) @@ -75,6 +76,14 @@ func Build( return validateResult(result, err) + case uv.Uv: + result, err := uv.Build( + parameters.(uv.UvBuildParameters), + commonBuildParameters, + )(context) + + return validateResult(result, err) + default: return packit.BuildResult{}, packit.Fail.WithMessage("unknown plan: %s", entry.Name) } diff --git a/build_test.go b/build_test.go index d5a8346..3ac7ec1 100644 --- a/build_test.go +++ b/build_test.go @@ -31,6 +31,8 @@ import ( pipenvfakes "github.com/paketo-buildpacks/python-installers/pkg/installers/pipenv/fakes" poetry "github.com/paketo-buildpacks/python-installers/pkg/installers/poetry" poetryfakes "github.com/paketo-buildpacks/python-installers/pkg/installers/poetry/fakes" + uv "github.com/paketo-buildpacks/python-installers/pkg/installers/uv" + uvfakes "github.com/paketo-buildpacks/python-installers/pkg/installers/uv/fakes" . "github.com/onsi/gomega" "github.com/sclevine/spec" @@ -77,6 +79,10 @@ func testBuild(t *testing.T, context spec.G, it spec.S) { poetryProcess *poetryfakes.InstallProcess poetrySitePackageProcess *poetryfakes.SitePackageProcess + // uv + uvDependencyManager *uvfakes.DependencyManager + uvInstallProcess *uvfakes.InstallProcess + buildParameters pkgcommon.CommonBuildParameters testPlans []TestPlan @@ -217,6 +223,34 @@ func testBuild(t *testing.T, context spec.G, it spec.S) { poetrySitePackageProcess = &poetryfakes.SitePackageProcess{} poetrySitePackageProcess.ExecuteCall.Returns.String = filepath.Join(layersDir, "poetry", "lib", "python3.8", "site-packages") + // uv + uvDependencyManager = &uvfakes.DependencyManager{} + uvDependencyManager.ResolveCall.Returns.Dependency = postal.Dependency{ + ID: "uv", + Name: "uv-dependency-name", + Checksum: "uv-dependency-sha", + Stacks: []string{"some-stack"}, + URI: "uv-dependency-uri", + Version: "uv-dependency-version", + } + + // Legacy SBOM + uvDependencyManager.GenerateBillOfMaterialsCall.Returns.BOMEntrySlice = []packit.BOMEntry{ + { + Name: "uv", + Metadata: paketosbom.BOMMetadata{ + Checksum: paketosbom.BOMChecksum{ + Algorithm: paketosbom.SHA256, + Hash: "uv-dependency-sha", + }, + URI: "uv-dependency-uri", + Version: "uv-dependency-version", + }, + }, + } + + uvInstallProcess = &uvfakes.InstallProcess{} + buildParameters = pkgcommon.CommonBuildParameters{ SbomGenerator: pkgcommon.Generator{}, Clock: chronos.DefaultClock, @@ -243,6 +277,10 @@ func testBuild(t *testing.T, context spec.G, it spec.S) { InstallProcess: poetryProcess, SitePackageProcess: poetrySitePackageProcess, }, + uv.Uv: uv.UvBuildParameters{ + DependencyManager: uvDependencyManager, + InstallProcess: uvInstallProcess, + }, } build = pythoninstallers.Build(logger, buildParameters, packagerParameters) @@ -302,9 +340,20 @@ func testBuild(t *testing.T, context spec.G, it spec.S) { }, 1, }, + { + packit.BuildpackPlan{ + Entries: []packit.BuildpackPlanEntry{ + { + Name: uv.Uv, + }, + }, + }, + 1, + }, } Expect(os.WriteFile(filepath.Join(workingDir, "x.py"), []byte{}, os.ModePerm)).To(Succeed()) Expect(os.WriteFile(filepath.Join(workingDir, "pyproject.toml"), []byte(""), 0755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(workingDir, "uv.lock"), []byte(`python-requires = "3.13.0"`), 0755)).To(Succeed()) }) it("runs the build process and returns expected layers", func() { diff --git a/buildpack.toml b/buildpack.toml index d13c468..f61e6c9 100644 --- a/buildpack.toml +++ b/buildpack.toml @@ -266,6 +266,64 @@ api = "0.8" id = "poetry" patches = 2 + # uv + [[metadata.dependencies]] + arch = "amd64" + os = "linux" + cpe = "cpe:2.3:a:uv:uv:0.9.21:*:*:*:*:python:*:*" + id = "uv" + name = "uv" + uri = "https://github.com/astral-sh/uv/releases/download/0.9.21/uv-x86_64-unknown-linux-gnu.tar.gz" + sha256 = "0a1ab27383c28ef1c041f85cbbc609d8e3752dfb4b238d2ad97b208a52232baf" + source = "https://github.com/astral-sh/uv/releases/download/0.9.21/source.tar.gz" + sha256_source = "4b0a5505280424dbde3e1c55203c4106f802d5c031a899a9d87e2256bc783cf0" + stacks = ["*"] + version = "0.9.21" + + [[metadata.dependencies]] + arch = "arm64" + os = "linux" + cpe = "cpe:2.3:a:uv:uv:0.9.21:*:*:*:*:python:*:*" + id = "uv" + name = "uv" + uri = "https://github.com/astral-sh/uv/releases/download/0.9.21/uv-aarch64-unknown-linux-gnu.tar.gz" + sha256 = "416984484783a357170c43f98e7d2d203f1fb595d6b3b95131513c53e50986ef" + source = "https://github.com/astral-sh/uv/releases/download/0.9.21/source.tar.gz" + sha256_source = "4b0a5505280424dbde3e1c55203c4106f802d5c031a899a9d87e2256bc783cf0" + stacks = ["*"] + version = "0.9.21" + + [[metadata.dependencies]] + arch = "amd64" + os = "linux" + cpe = "cpe:2.3:a:uv:uv:0.9.22:*:*:*:*:python:*:*" + id = "uv" + name = "uv" + uri = "https://github.com/astral-sh/uv/releases/download/0.9.22/uv-x86_64-unknown-linux-gnu.tar.gz" + sha256 = "e170aed70ac0225feee612e855d3a57ae73c61ffb22c7e52c3fd33b87c286508" + source = "https://github.com/astral-sh/uv/releases/download/0.9.22/source.tar.gz" + sha256_source = "0c683fac23fc414505adce589adf642d710798b691321672de554443b6938c6e" + stacks = ["*"] + version = "0.9.22" + + [[metadata.dependencies]] + arch = "arm64" + os = "linux" + cpe = "cpe:2.3:a:uv:uv:0.9.22:*:*:*:*:python:*:*" + id = "uv" + name = "uv" + uri = "https://github.com/astral-sh/uv/releases/download/0.9.22/uv-aarch64-unknown-linux-gnu.tar.gz" + sha256 = "2f8716c407d5da21b8a3e8609ed358147216aaab28b96b1d6d7f48e9bcc6254e" + source = "https://github.com/astral-sh/uv/releases/download/0.9.22/source.tar.gz" + sha256_source = "0c683fac23fc414505adce589adf642d710798b691321672de554443b6938c6e" + stacks = ["*"] + version = "0.9.22" + + [[metadata.dependency-constraints]] + constraint = "*" + id = "python-uv" + patches = 2 + [[stacks]] id = "*" diff --git a/dependency/retrieval/go.mod b/dependency/retrieval/go.mod index ef71f82..df4aa63 100644 --- a/dependency/retrieval/go.mod +++ b/dependency/retrieval/go.mod @@ -7,6 +7,7 @@ replace github.com/ekzhu/minhash-lsh => github.com/ekzhu/minhash-lsh v0.0.0-2017 require ( github.com/Masterminds/semver/v3 v3.4.0 + github.com/google/go-github/v81 v81.0.0 github.com/joshuatcasey/libdependency v0.22.0 github.com/nfx/go-htmltable v0.4.0 github.com/paketo-buildpacks/packit/v2 v2.25.0 @@ -29,6 +30,7 @@ require ( github.com/go-git/go-billy/v5 v5.6.2 // indirect github.com/go-git/go-git/v5 v5.16.2 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/google/go-querystring v1.1.0 // indirect github.com/hhatto/gorst v0.0.0-20181029133204-ca9f730cac5b // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/jdkato/prose v1.2.1 // indirect diff --git a/dependency/retrieval/go.sum b/dependency/retrieval/go.sum index 36d25cc..a4bf4f4 100644 --- a/dependency/retrieval/go.sum +++ b/dependency/retrieval/go.sum @@ -101,8 +101,13 @@ github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGw github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-github/v81 v81.0.0 h1:hTLugQRxSLD1Yei18fk4A5eYjOGLUBKAl/VCqOfFkZc= +github.com/google/go-github/v81 v81.0.0/go.mod h1:upyjaybucIbBIuxgJS7YLOZGziyvvJ92WX6WEBNE3sM= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hhatto/gorst v0.0.0-20181029133204-ca9f730cac5b h1:Jdu2tbAxkRouSILp2EbposIb8h4gO+2QuZEn3d9sKAc= @@ -266,6 +271,7 @@ golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190206041539-40960b6deb8e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.0.0-20180816165407-929014505bf4/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo= gonum.org/v1/gonum v0.7.0/go.mod h1:L02bwd0sqlsvRv41G7wGWFCsVNZFv/k1xzGIxeANHGM= gonum.org/v1/gonum v0.12.0 h1:xKuo6hzt+gMav00meVPUlXwSdoEJP46BR+wdxQEFK2o= diff --git a/dependency/retrieval/main.go b/dependency/retrieval/main.go index f3db9d0..fbe7db3 100644 --- a/dependency/retrieval/main.go +++ b/dependency/retrieval/main.go @@ -9,6 +9,7 @@ package main import ( "bytes" + "context" "crypto/sha256" "encoding/json" "errors" @@ -23,6 +24,7 @@ import ( "time" "github.com/Masterminds/semver/v3" + "github.com/google/go-github/v81/github" "github.com/joshuatcasey/libdependency/buildpack_config" "github.com/joshuatcasey/libdependency/retrieve" "github.com/joshuatcasey/libdependency/upstream" @@ -57,6 +59,10 @@ func getAllVersionsForInstaller(installer string) retrieve.GetAllVersionsFunc { return getAllMinicondaVersions } + if installer == "uv" { + return getAllUvVersions + } + return func() (versionology.VersionFetcherArray, error) { var pypiMetadata PyPiProductMetadataRaw @@ -343,6 +349,107 @@ func generateMinicondaMetadata(versionFetcher versionology.VersionFetcher) ([]ve }}, nil } +type UvRelease struct { + version *semver.Version + Arch string + SourceURL string + UploadTime time.Time + SourceSHA256 string + BinaryURL string + BinarySHA256 string +} + +func (release UvRelease) Version() *semver.Version { + return release.version +} + +func getAllUvVersions() (versionology.VersionFetcherArray, error) { + client := github.NewClient(nil) + + opt := &github.ListOptions{Page: 1, PerPage: 2} + releases, _, err := client.Repositories.ListReleases(context.Background(), "astral-sh", "uv", opt) + + if err != nil { + return nil, err + } + + var result versionology.VersionFetcherArray + + for _, release := range releases { + version, err := semver.NewVersion(*release.Name) + if err != nil { + return nil, err + } + + var sourceURL *string + var sourceSHA256 *string + for _, asset := range release.Assets { + if *asset.Name == "source.tar.gz" { + sourceURL = asset.BrowserDownloadURL + sourceSHA256 = asset.Digest + break + } + } + if sourceURL == nil || sourceSHA256 == nil { + return nil, errors.New("Failed to find source asset") + } + + archAsset := "uv-%s-unknown-linux-gnu.tar.gz" + + for inArch, outArch := range ArchMap { + assetName := fmt.Sprintf(archAsset, inArch) + for _, asset := range release.Assets { + if *asset.Name == assetName { + result = append(result, + UvRelease{ + version: version, + Arch: outArch, + BinaryURL: *asset.BrowserDownloadURL, + BinarySHA256: *asset.Digest, + SourceURL: *sourceURL, + SourceSHA256: *sourceSHA256, + UploadTime: *asset.UpdatedAt.GetTime(), + }) + break + } + } + } + } + + return result, nil +} + +func generateUvMetadata(versionFetcher versionology.VersionFetcher) ([]versionology.Dependency, error) { + version := versionFetcher.Version().String() + uvRelease, ok := versionFetcher.(UvRelease) + if !ok { + return nil, errors.New("expected a UvRelease") + } + + var licenseIDsAsInterface []interface{} + licenseIDsAsInterface = append(licenseIDsAsInterface, "Apache-2.0", "MIT") + configMetadataDependency := cargo.ConfigMetadataDependency{ + CPE: fmt.Sprintf("cpe:2.3:a:uv:uv:%s:*:*:*:*:python:*:*", version), + Checksum: uvRelease.BinarySHA256, + ID: "uv", + Licenses: licenseIDsAsInterface, + Name: "uv", + OS: "linux", + Arch: uvRelease.Arch, + PURL: retrieve.GeneratePURL("uv", version, uvRelease.SourceSHA256, uvRelease.SourceURL), + Source: uvRelease.SourceURL, + SourceChecksum: uvRelease.SourceSHA256, + Stacks: []string{"*"}, + URI: uvRelease.BinaryURL, + Version: version, + } + + return []versionology.Dependency{{ + ConfigMetadataDependency: configMetadataDependency, + SemverVersion: versionFetcher.Version(), + }}, nil +} + // Taken from libdependency.retrieve.retrieval // https://github.com/joshuatcasey/libdependency/blob/main/retrieve/retrieval.go func toWorkflowJson(item any) (string, error) { @@ -384,6 +491,7 @@ func main() { "pipenv": generatePipenvMetadata, "poetry": generatePoetryMetadata, "miniconda3": generateMinicondaMetadata, + "uv": generateUvMetadata, } var dependencies []versionology.Dependency diff --git a/detect.go b/detect.go index 0290346..5515ae2 100644 --- a/detect.go +++ b/detect.go @@ -13,6 +13,7 @@ import ( pip "github.com/paketo-buildpacks/python-installers/pkg/installers/pip" pipenv "github.com/paketo-buildpacks/python-installers/pkg/installers/pipenv" poetry "github.com/paketo-buildpacks/python-installers/pkg/installers/poetry" + uv "github.com/paketo-buildpacks/python-installers/pkg/installers/uv" ) // Detect will return a packit.DetectFunc that will be invoked during the @@ -56,6 +57,14 @@ func Detect(logger scribe.Emitter, pyprojectVersionParser poetry.PyProjectPython logger.Detail("%s", err) } + uvResult, err := uv.Detect()(context) + + if err == nil { + plans = append(plans, uvResult.Plan) + } else { + logger.Detail("%s", err) + } + if len(plans) == 0 { return packit.DetectResult{}, packit.Fail.WithMessage("No python packager manager related files found") } diff --git a/detect_test.go b/detect_test.go index 07bfd06..704f439 100644 --- a/detect_test.go +++ b/detect_test.go @@ -20,6 +20,7 @@ import ( pipenv "github.com/paketo-buildpacks/python-installers/pkg/installers/pipenv" poetry "github.com/paketo-buildpacks/python-installers/pkg/installers/poetry" poetryfakes "github.com/paketo-buildpacks/python-installers/pkg/installers/poetry/fakes" + uv "github.com/paketo-buildpacks/python-installers/pkg/installers/uv" "github.com/sclevine/spec" @@ -126,7 +127,7 @@ func testDetect(t *testing.T, context spec.G, it spec.S) { WorkingDir: workingDir, }) - plans = append(plans, + withPoetry := append(plans, packit.BuildPlan{ Provides: []packit.BuildPlanProvision{ {Name: "poetry"}, @@ -151,7 +152,31 @@ func testDetect(t *testing.T, context spec.G, it spec.S) { ) Expect(err).NotTo(HaveOccurred()) - Expect(result.Plan).To(Equal(pythoninstallers.Or(plans...))) + Expect(result.Plan).To(Equal(pythoninstallers.Or(withPoetry...))) + }) + }) + + context("with uv.lock", func() { + it.Before(func() { + Expect(os.WriteFile(filepath.Join(workingDir, uv.LockfileName), []byte{}, os.ModePerm)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(workingDir, uv.LockfileName), []byte(`requires-python = "3.13.0"`), 0755)).To(Succeed()) + }) + + it("passes detection", func() { + result, err := detect(packit.DetectContext{ + WorkingDir: workingDir, + }) + + withUv := append(plans, + packit.BuildPlan{ + Provides: []packit.BuildPlanProvision{ + {Name: uv.Uv}, + }, + }, + ) + + Expect(err).NotTo(HaveOccurred()) + Expect(result.Plan).To(Equal(pythoninstallers.Or(withUv...))) }) }) }) diff --git a/integration/installers/init_test.go b/integration/installers/init_test.go index 74d06e7..0d79c63 100644 --- a/integration/installers/init_test.go +++ b/integration/installers/init_test.go @@ -118,5 +118,10 @@ func TestIntegration(t *testing.T) { suite("Poetry LayerReuse", poetryTestLayerReuse, spec.Parallel()) suite("Poetry Versions", poetryTestVersions, spec.Parallel()) + // uv + suite("uv Default", uvTestDefault, spec.Parallel()) + suite("uv LayerReuse", uvTestLayerReuse, spec.Parallel()) + suite("uv Offline", uvTestOffline, spec.Parallel()) + suite.Run(t) } diff --git a/integration/installers/pip_default_test.go b/integration/installers/pip_default_test.go index 1035eaa..56ec6d8 100644 --- a/integration/installers/pip_default_test.go +++ b/integration/installers/pip_default_test.go @@ -86,7 +86,7 @@ func pipTestDefault(t *testing.T, context spec.G, it spec.S) { Expect(logs).To(ContainLines( " Executing build process", MatchRegexp(` Installing Pip \d+\.\d+\.\d+`), - MatchRegexp(` Completed in \d+\.\d+`), + MatchRegexp(` Completed in \d+(\.?\d+)*`), )) Expect(logs).To(ContainLines( " Configuring build environment", diff --git a/integration/installers/testdata/uv_app/REUSE.toml b/integration/installers/testdata/uv_app/REUSE.toml new file mode 100644 index 0000000..4a5fb8f --- /dev/null +++ b/integration/installers/testdata/uv_app/REUSE.toml @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: 2025 Idiap Research Institute +# +# SPDX-License-Identifier: CC0-1.0 + +version = 1 + +[[annotations]] +path = [ + "uv.lock", +] +precedence = "override" +SPDX-FileCopyrightText = "Copyright (c) 2013-Present CloudFoundry.org Foundation, Inc. All Rights Reserved." +SPDX-License-Identifier = "Apache-2.0" diff --git a/integration/installers/testdata/uv_app/plan.toml b/integration/installers/testdata/uv_app/plan.toml new file mode 100644 index 0000000..8fa7398 --- /dev/null +++ b/integration/installers/testdata/uv_app/plan.toml @@ -0,0 +1,9 @@ +# Copyright (c) 2013-Present CloudFoundry.org Foundation, Inc. All Rights Reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +[[requires]] +name = "uv" + +[requires.metadata] +launch = true diff --git a/integration/installers/testdata/uv_app/uv.lock b/integration/installers/testdata/uv_app/uv.lock new file mode 100644 index 0000000..a46b1d6 --- /dev/null +++ b/integration/installers/testdata/uv_app/uv.lock @@ -0,0 +1,8 @@ +version = 1 +revision = 3 +requires-python = "==3.13.*" + +[[package]] +name = "integration-test" +version = "0.0.0" +source = { virtual = "." } diff --git a/integration/installers/testdata/uv_app_offline/REUSE.toml b/integration/installers/testdata/uv_app_offline/REUSE.toml new file mode 100644 index 0000000..4a5fb8f --- /dev/null +++ b/integration/installers/testdata/uv_app_offline/REUSE.toml @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: 2025 Idiap Research Institute +# +# SPDX-License-Identifier: CC0-1.0 + +version = 1 + +[[annotations]] +path = [ + "uv.lock", +] +precedence = "override" +SPDX-FileCopyrightText = "Copyright (c) 2013-Present CloudFoundry.org Foundation, Inc. All Rights Reserved." +SPDX-License-Identifier = "Apache-2.0" diff --git a/integration/installers/testdata/uv_app_offline/plan.toml b/integration/installers/testdata/uv_app_offline/plan.toml new file mode 100644 index 0000000..a07862e --- /dev/null +++ b/integration/installers/testdata/uv_app_offline/plan.toml @@ -0,0 +1,15 @@ +# Copyright (c) 2013-Present CloudFoundry.org Foundation, Inc. All Rights Reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +[[requires]] +name = "uv" + +[requires.metadata] +launch = true + +[[requires]] +name = "cpython" + +[requires.metadata] +launch = true diff --git a/integration/installers/testdata/uv_app_offline/uv.lock b/integration/installers/testdata/uv_app_offline/uv.lock new file mode 100644 index 0000000..a46b1d6 --- /dev/null +++ b/integration/installers/testdata/uv_app_offline/uv.lock @@ -0,0 +1,8 @@ +version = 1 +revision = 3 +requires-python = "==3.13.*" + +[[package]] +name = "integration-test" +version = "0.0.0" +source = { virtual = "." } diff --git a/integration/installers/uv_default_test.go b/integration/installers/uv_default_test.go new file mode 100644 index 0000000..c286915 --- /dev/null +++ b/integration/installers/uv_default_test.go @@ -0,0 +1,183 @@ +// SPDX-FileCopyrightText: Copyright (c) 2013-Present CloudFoundry.org Foundation, Inc. All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 + +package integration_test + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/paketo-buildpacks/occam" + "github.com/sclevine/spec" + + . "github.com/onsi/gomega" + . "github.com/paketo-buildpacks/occam/matchers" +) + +func uvTestDefault(t *testing.T, context spec.G, it spec.S) { + var ( + Expect = NewWithT(t).Expect + Eventually = NewWithT(t).Eventually + + pack occam.Pack + docker occam.Docker + source string + ) + + it.Before(func() { + pack = occam.NewPack().WithVerbose() + docker = occam.NewDocker() + + var err error + source, err = occam.Source(filepath.Join("testdata", "uv_app")) + Expect(err).NotTo(HaveOccurred()) + }) + + it.After(func() { + Expect(os.RemoveAll(source)).To(Succeed()) + }) + + context("when the buildpack is run with pack build", func() { + var ( + image occam.Image + container occam.Container + name string + ) + + it.Before(func() { + var err error + name, err = occam.RandomName() + Expect(err).NotTo(HaveOccurred()) + }) + + it.After(func() { + Expect(docker.Container.Remove.Execute(container.ID)).To(Succeed()) + Expect(docker.Image.Remove.Execute(image.ID)).To(Succeed()) + Expect(docker.Volume.Remove.Execute(occam.CacheVolumeNames(name))).To(Succeed()) + }) + + it("builds with the defaults", func() { + var err error + + var logs fmt.Stringer + image, logs, err = pack.WithNoColor().Build. + WithPullPolicy("never"). + WithBuildpacks( + settings.Buildpacks.PythonInstallers.Online, + settings.Buildpacks.BuildPlan.Online, + ). + Execute(name, source) + Expect(err).ToNot(HaveOccurred(), logs.String) + + Expect(logs).To(ContainLines( + MatchRegexp(fmt.Sprintf(`%s \d+\.\d+\.\d+`, buildpackInfo.Buildpack.Name)), + " Resolving uv version", + " Candidate version sources (in priority order):", + " -> \"\"", + )) + Expect(logs).To(ContainLines( + MatchRegexp(` Selected uv version \(using \): \d+\.\d+\.\d+`), + )) + Expect(logs).To(ContainLines( + " Executing build process", + MatchRegexp(` Installing uv \d+\.\d+\.\d+`), + MatchRegexp(` Completed in \d+(\.?\d+)*`), + )) + + container, err = docker.Container.Run. + WithCommand("uv --version"). + Execute(image.ID) + Expect(err).ToNot(HaveOccurred()) + + Eventually(func() string { + cLogs, err := docker.Container.Logs.Execute(container.ID) + Expect(err).NotTo(HaveOccurred()) + return cLogs.String() + }).Should(MatchRegexp(`uv \d+\.\d+(\.\d+)?.*`)) + }) + + context("validating SBOM", func() { + var ( + container2 occam.Container + sbomDir string + ) + + it.Before(func() { + var err error + sbomDir, err = os.MkdirTemp("", "sbom") + Expect(err).NotTo(HaveOccurred()) + Expect(os.Chmod(sbomDir, os.ModePerm)).To(Succeed()) + }) + + it.After(func() { + Expect(docker.Container.Remove.Execute(container2.ID)).To(Succeed()) + Expect(os.RemoveAll(sbomDir)).To(Succeed()) + }) + + it("writes SBOM files to the layer and label metadata", func() { + var err error + var logs fmt.Stringer + image, logs, err = pack.WithNoColor().Build. + WithPullPolicy("never"). + WithBuildpacks( + settings.Buildpacks.PythonInstallers.Online, + settings.Buildpacks.BuildPlan.Online, + ). + WithEnv(map[string]string{ + "BP_LOG_LEVEL": "DEBUG", + }). + WithSBOMOutputDir(sbomDir). + Execute(name, source) + Expect(err).ToNot(HaveOccurred(), logs.String) + + container, err = docker.Container.Run. + WithCommand("uv --version"). + Execute(image.ID) + Expect(err).ToNot(HaveOccurred()) + + Eventually(func() string { + cLogs, err := docker.Container.Logs.Execute(container.ID) + Expect(err).NotTo(HaveOccurred()) + return cLogs.String() + }).Should(MatchRegexp(`uv \d+\.\d+(\.\d+)?.*`)) + + Expect(logs).To(ContainLines( + fmt.Sprintf(" Generating SBOM for /layers/%s/uv", strings.ReplaceAll(buildpackInfo.Buildpack.ID, "/", "_")), + MatchRegexp(` Completed in \d+(\.?\d+)*`), + )) + Expect(logs).To(ContainLines( + " Writing SBOM in the following format(s):", + " application/vnd.cyclonedx+json", + " application/spdx+json", + " application/vnd.syft+json", + )) + + // check that legacy SBOM is included via metadata + container2, err = docker.Container.Run. + WithCommand("cat /layers/sbom/launch/sbom.legacy.json"). + Execute(image.ID) + Expect(err).NotTo(HaveOccurred()) + + Eventually(func() string { + cLogs, err := docker.Container.Logs.Execute(container2.ID) + Expect(err).NotTo(HaveOccurred()) + return cLogs.String() + }).Should(ContainSubstring(`"name":"uv"`)) + + // check that all required SBOM files are present + Expect(filepath.Join(sbomDir, "sbom", "launch", strings.ReplaceAll(buildpackInfo.Buildpack.ID, "/", "_"), "uv", "sbom.cdx.json")).To(BeARegularFile()) + Expect(filepath.Join(sbomDir, "sbom", "launch", strings.ReplaceAll(buildpackInfo.Buildpack.ID, "/", "_"), "uv", "sbom.spdx.json")).To(BeARegularFile()) + Expect(filepath.Join(sbomDir, "sbom", "launch", strings.ReplaceAll(buildpackInfo.Buildpack.ID, "/", "_"), "uv", "sbom.syft.json")).To(BeARegularFile()) + + // check an SBOM file to make sure it has an entry for cpython + contents, err := os.ReadFile(filepath.Join(sbomDir, "sbom", "launch", strings.ReplaceAll(buildpackInfo.Buildpack.ID, "/", "_"), "uv", "sbom.cdx.json")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(contents)).To(ContainSubstring(`"name": "uv"`)) + }) + }) + }) +} diff --git a/integration/installers/uv_layer_reuse_test.go b/integration/installers/uv_layer_reuse_test.go new file mode 100644 index 0000000..95080a9 --- /dev/null +++ b/integration/installers/uv_layer_reuse_test.go @@ -0,0 +1,217 @@ +// SPDX-FileCopyrightText: Copyright (c) 2013-Present CloudFoundry.org Foundation, Inc. All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 + +package integration_test + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/paketo-buildpacks/occam" + "github.com/sclevine/spec" + + . "github.com/onsi/gomega" + . "github.com/paketo-buildpacks/occam/matchers" + + integration_helpers "github.com/paketo-buildpacks/python-installers/integration" +) + +func uvTestLayerReuse(t *testing.T, context spec.G, it spec.S) { + var ( + Expect = NewWithT(t).Expect + Eventually = NewWithT(t).Eventually + + pack occam.Pack + docker occam.Docker + + imageIDs map[string]struct{} + containerIDs map[string]struct{} + + name string + source string + ) + + it.Before(func() { + var err error + name, err = occam.RandomName() + Expect(err).NotTo(HaveOccurred()) + + pack = occam.NewPack() + docker = occam.NewDocker() + + imageIDs = map[string]struct{}{} + containerIDs = map[string]struct{}{} + + source, err = occam.Source(filepath.Join("testdata", "uv_app")) + Expect(err).NotTo(HaveOccurred()) + }) + + it.After(func() { + for id := range containerIDs { + Expect(docker.Container.Remove.Execute(id)).To(Succeed()) + } + + for id := range imageIDs { + Expect(docker.Image.Remove.Execute(id)).To(Succeed()) + } + + Expect(docker.Volume.Remove.Execute(occam.CacheVolumeNames(name))).To(Succeed()) + + Expect(os.RemoveAll(source)).To(Succeed()) + }) + + context("when the app is rebuilt and the same pip version is required", func() { + it("reuses the cached pip layer", func() { + var ( + err error + logs fmt.Stringer + + firstImage occam.Image + secondImage occam.Image + + firstContainer occam.Container + secondContainer occam.Container + ) + + firstImage, logs, err = pack.WithNoColor().Build. + WithPullPolicy("never"). + WithBuildpacks( + settings.Buildpacks.PythonInstallers.Online, + settings.Buildpacks.BuildPlan.Online, + ). + Execute(name, source) + Expect(err).ToNot(HaveOccurred(), logs.String) + + imageIDs[firstImage.ID] = struct{}{} + firstContainer, err = docker.Container.Run. + WithCommand("uv --version"). + Execute(firstImage.ID) + Expect(err).ToNot(HaveOccurred()) + + containerIDs[firstContainer.ID] = struct{}{} + + secondImage, logs, err = pack.WithNoColor().Build. + WithPullPolicy("never"). + WithBuildpacks( + settings.Buildpacks.PythonInstallers.Online, + settings.Buildpacks.BuildPlan.Online, + ). + Execute(name, source) + Expect(err).ToNot(HaveOccurred(), logs.String) + + imageIDs[secondImage.ID] = struct{}{} + + Expect(logs).To(ContainLines( + MatchRegexp(fmt.Sprintf(`%s \d+\.\d+\.\d+`, buildpackInfo.Buildpack.Name)), + " Resolving uv version", + " Candidate version sources (in priority order):", + " -> \"\"", + )) + Expect(logs).To(ContainLines( + MatchRegexp(` Selected uv version \(using \): \d+\.\d+\.\d+`), + )) + Expect(logs).To(ContainLines( + fmt.Sprintf(" Reusing cached layer /layers/%s/uv", strings.ReplaceAll(buildpackInfo.Buildpack.ID, "/", "_")), + )) + + secondContainer, err = docker.Container.Run. + WithCommand("uv --version"). + Execute(secondImage.ID) + Expect(err).ToNot(HaveOccurred()) + + containerIDs[secondContainer.ID] = struct{}{} + + Eventually(func() string { + cLogs, err := docker.Container.Logs.Execute(secondContainer.ID) + Expect(err).NotTo(HaveOccurred()) + return cLogs.String() + }).Should(MatchRegexp(`uv \d+\.\d+(\.\d+)?.*`)) + + Expect(secondImage.Buildpacks[0].Key).To(Equal(buildpackInfo.Buildpack.ID)) + Expect(secondImage.Buildpacks[0].Layers["uv"].SHA).To(Equal(firstImage.Buildpacks[0].Layers["uv"].SHA)) + }) + }) + + context("when the app is rebuilt and a different uv version is required", func() { + it("rebuilds", func() { + var ( + err error + logs fmt.Stringer + + firstImage occam.Image + secondImage occam.Image + + firstContainer occam.Container + secondContainer occam.Container + ) + + dependencies := integration_helpers.DependenciesForId(buildpackInfo.Metadata.Dependencies, "uv") + + firstImage, logs, err = pack.WithNoColor().Build. + WithPullPolicy("never"). + WithBuildpacks( + settings.Buildpacks.PythonInstallers.Online, + settings.Buildpacks.BuildPlan.Online, + ). + WithEnv(map[string]string{"BP_UV_VERSION": dependencies[0].Version}). + Execute(name, source) + Expect(err).ToNot(HaveOccurred(), logs.String) + + imageIDs[firstImage.ID] = struct{}{} + firstContainer, err = docker.Container.Run. + WithCommand("uv --version"). + Execute(firstImage.ID) + Expect(err).ToNot(HaveOccurred()) + + containerIDs[firstContainer.ID] = struct{}{} + + secondImage, logs, err = pack.WithNoColor().Build. + WithPullPolicy("never"). + WithBuildpacks( + settings.Buildpacks.PythonInstallers.Online, + settings.Buildpacks.BuildPlan.Online, + ). + WithEnv(map[string]string{"BP_UV_VERSION": dependencies[2].Version}). + Execute(name, source) + Expect(err).ToNot(HaveOccurred(), logs.String) + + imageIDs[secondImage.ID] = struct{}{} + + Expect(logs).To(ContainLines( + MatchRegexp(fmt.Sprintf(`%s \d+\.\d+\.\d+`, buildpackInfo.Buildpack.Name)), + " Resolving uv version", + " Candidate version sources (in priority order):", + MatchRegexp(` BP_UV_VERSION -> "\d+\.\d+\.\d+"`), + " -> \"\"", + )) + Expect(logs).To(ContainLines( + MatchRegexp(` Selected uv version \(using BP_UV_VERSION\): \d+\.\d+\.\d+`), + )) + Expect(logs).To(ContainLines( + " Executing build process", + MatchRegexp(` Installing uv \d+\.\d+\.\d+`), + MatchRegexp(` Completed in \d+(\.?\d+)*`), + )) + + secondContainer, err = docker.Container.Run. + WithCommand("uv --version"). + Execute(secondImage.ID) + Expect(err).ToNot(HaveOccurred()) + + containerIDs[secondContainer.ID] = struct{}{} + + Eventually(func() string { + cLogs, err := docker.Container.Logs.Execute(secondContainer.ID) + Expect(err).NotTo(HaveOccurred()) + return cLogs.String() + }).Should(MatchRegexp(`uv \d+\.\d+(\.\d+)?.*`)) + + Expect(secondImage.Buildpacks[0].Key).To(Equal(buildpackInfo.Buildpack.ID)) + Expect(secondImage.Buildpacks[0].Layers["uv"].SHA).ToNot(Equal(firstImage.Buildpacks[0].Layers["uv"].SHA)) + }) + }) +} diff --git a/integration/installers/uv_offline_test.go b/integration/installers/uv_offline_test.go new file mode 100644 index 0000000..cea8546 --- /dev/null +++ b/integration/installers/uv_offline_test.go @@ -0,0 +1,83 @@ +// SPDX-FileCopyrightText: Copyright (c) 2013-Present CloudFoundry.org Foundation, Inc. All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 + +package integration_test + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/paketo-buildpacks/occam" + "github.com/sclevine/spec" + + . "github.com/onsi/gomega" +) + +func uvTestOffline(t *testing.T, context spec.G, it spec.S) { + var ( + Expect = NewWithT(t).Expect + Eventually = NewWithT(t).Eventually + + pack occam.Pack + docker occam.Docker + ) + + it.Before(func() { + pack = occam.NewPack() + docker = occam.NewDocker() + }) + + context("when the buildpack is run with pack build in offline mode", func() { + var ( + image occam.Image + container occam.Container + name string + source string + ) + + it.Before(func() { + var err error + name, err = occam.RandomName() + Expect(err).NotTo(HaveOccurred()) + + source, err = occam.Source(filepath.Join("testdata", "uv_app_offline")) + Expect(err).NotTo(HaveOccurred()) + }) + + it.After(func() { + Expect(docker.Container.Remove.Execute(container.ID)).To(Succeed()) + Expect(docker.Image.Remove.Execute(image.ID)).To(Succeed()) + Expect(docker.Volume.Remove.Execute(occam.CacheVolumeNames(name))).To(Succeed()) + Expect(os.RemoveAll(source)).To(Succeed()) + }) + + it("builds successfully", func() { + var err error + var logs fmt.Stringer + image, logs, err = pack.WithNoColor().Build. + WithPullPolicy("never"). + WithNetwork("none"). + WithBuildpacks( + settings.Buildpacks.CPython.Offline, + settings.Buildpacks.PythonInstallers.Offline, + settings.Buildpacks.BuildPlan.Online, + ). + Execute(name, source) + Expect(err).ToNot(HaveOccurred(), logs.String) + + container, err = docker.Container.Run. + WithCommand("uv --version"). + Execute(image.ID) + Expect(err).ToNot(HaveOccurred()) + + Eventually(func() string { + cLogs, err := docker.Container.Logs.Execute(container.ID) + Expect(err).NotTo(HaveOccurred()) + return cLogs.String() + }).Should(MatchRegexp(`uv \d+\.\d+(\.\d+)?.*`)) + }) + }) +} diff --git a/pkg/installers/uv/README.md b/pkg/installers/uv/README.md new file mode 100644 index 0000000..78f3f28 --- /dev/null +++ b/pkg/installers/uv/README.md @@ -0,0 +1,50 @@ + + +# Sub-package for uv installation + +This sub-package installs uv into a layer and makes it available on the +PATH. + +## Integration + +The uv CNB provides uv as a dependency. Downstream buildpacks can +require the uv dependency by generating a [Build Plan +TOML](https://github.com/buildpacks/spec/blob/master/buildpack.md#build-plan-toml) +file that looks like the following: + +```toml +[[requires]] + + # The name of the Uv dependency is "uv". This value is considered + # part of the public API for the buildpack and will not change without a plan + # for deprecation. + name = "uv" + + # The version of the uv dependency is not required. In the case it + # is not specified, the buildpack will provide the default version, which can + # be seen in the buildpack.toml file. + # If you wish to request a specific version, the buildpack supports + # specifying a semver constraint in the form of "0.*", "0.9.*", or even + # "0.9.22". + version = "0.9.22" + + # The Miniconda buildpack supports some non-required metadata options. + [requires.metadata] + + # Setting the build flag to true will ensure that the uv + # dependency is available on the $PATH for subsequent buildpacks during + # their build phase. If you are writing a buildpack that needs to run + # uv during its build process, this flag should be set to true. + build = true + + # Setting the launch flag to true will ensure that the uv + # dependency is available on the $PATH for the running application. If you are + # writing an application that needs to run uv at runtime, this flag + # should be set to true. + launch = true +``` diff --git a/pkg/installers/uv/build.go b/pkg/installers/uv/build.go new file mode 100644 index 0000000..fde3856 --- /dev/null +++ b/pkg/installers/uv/build.go @@ -0,0 +1,191 @@ +// SPDX-FileCopyrightText: © 2025 Idiap Research Institute +// SPDX-FileContributor: Samuel Gaist +// +// SPDX-License-Identifier: Apache-2.0 + +package uv + +import ( + "path/filepath" + "time" + + "github.com/paketo-buildpacks/packit/v2" + "github.com/paketo-buildpacks/packit/v2/cargo" + "github.com/paketo-buildpacks/packit/v2/draft" + "github.com/paketo-buildpacks/packit/v2/postal" + "github.com/paketo-buildpacks/packit/v2/sbom" + + pythoninstallers "github.com/paketo-buildpacks/python-installers/pkg/installers/common" +) + +//go:generate faux --interface DependencyManager --output fakes/dependency_manager.go +//go:generate faux --interface Runner --output fakes/runner.go +//go:generate faux --interface SBOMGenerator --output fakes/sbom_generator.go + +// DependencyManager defines the interface for picking the best matching +// dependency and installing it. +type DependencyManager interface { + Resolve(path, id, version, stack string) (postal.Dependency, error) + Deliver(dependency postal.Dependency, cnbPath, destinationPath, platformPath string) error + GenerateBillOfMaterials(dependencies ...postal.Dependency) []packit.BOMEntry +} + +// InstallProcess defines the interface for installing the poetry dependency into a layer. +type InstallProcess interface { + Execute(sourcePath, targetLayerPath, dependencyName string) error +} + +type SBOMGenerator interface { + GenerateFromDependency(dependency postal.Dependency, dir string) (sbom.SBOM, error) +} + +// UvBuildParameters encapsulates the uv specific parameters for the +// Build function +type UvBuildParameters struct { + DependencyManager DependencyManager + InstallProcess InstallProcess +} + +// Build will return a packit.BuildFunc that will be invoked during the build +// phase of the buildpack lifecycle. +// +// Build will find the right uv dependency to download, download it +// into a layer, run the uv-install script to install uv into a separate +// layer and generate Bill-of-Materials. It also makes use of the checksum of +// the dependency to reuse the layer when possible. +func Build( + buildParameters UvBuildParameters, + parameters pythoninstallers.CommonBuildParameters, +) packit.BuildFunc { + return func(context packit.BuildContext) (packit.BuildResult, error) { + dependencyManager := buildParameters.DependencyManager + installProcess := buildParameters.InstallProcess + sbomGenerator := parameters.SbomGenerator + clock := parameters.Clock + logger := parameters.Logger + + logger.Title("%s %s", context.BuildpackInfo.Name, context.BuildpackInfo.Version) + + planner := draft.NewPlanner() + + logger.Process("Resolving uv version") + entry, sortedEntries := planner.Resolve(Uv, context.Plan.Entries, Priorities) + logger.Candidates(sortedEntries) + + version, _ := entry.Metadata["version"].(string) + + dependency, err := dependencyManager.Resolve(filepath.Join(context.CNBPath, "buildpack.toml"), entry.Name, version, context.Stack) + if err != nil { + return packit.BuildResult{}, err + } + + logger.SelectedDependency(entry, dependency, clock.Now()) + + legacySBOM := dependencyManager.GenerateBillOfMaterials(dependency) + + uvLayer, err := context.Layers.Get("uv") + if err != nil { + return packit.BuildResult{}, err + } + + launch, build := planner.MergeLayerTypes("uv", context.Plan.Entries) + + var buildMetadata = packit.BuildMetadata{} + var launchMetadata = packit.LaunchMetadata{} + if build { + buildMetadata = packit.BuildMetadata{BOM: legacySBOM} + } + + if launch { + launchMetadata = packit.LaunchMetadata{BOM: legacySBOM} + } + + cachedChecksum, ok := uvLayer.Metadata[DepKey].(string) + dependencyChecksum := dependency.Checksum + if dependencyChecksum == "" { + //nolint:staticcheck // SHA256 is only a fallback in case Checksum is not present + dependencyChecksum = dependency.SHA256 + } + + if ok && cachedChecksum != "" && cargo.Checksum(cachedChecksum).MatchString(dependencyChecksum) { + logger.Process("Reusing cached layer %s", uvLayer.Path) + logger.Break() + + uvLayer.Launch, uvLayer.Build, uvLayer.Cache = launch, build, build + + return packit.BuildResult{ + Layers: []packit.Layer{uvLayer}, + Build: buildMetadata, + Launch: launchMetadata, + }, nil + } + + uvLayer, err = uvLayer.Reset() + if err != nil { + return packit.BuildResult{}, err + } + + uvLayer.Launch, uvLayer.Build, uvLayer.Cache = launch, build, build + + // This temporary layer is created because the path to a deterministic and + // easier to make assertions about during testing. Because this layer has + // no type set to true the lifecycle will ensure that this layer is + // removed. + uvScriptTempLayer, err := context.Layers.Get("uv-temp-layer") + if err != nil { + return packit.BuildResult{}, err + } + + uvScriptTempLayer, err = uvScriptTempLayer.Reset() + if err != nil { + return packit.BuildResult{}, err + } + + logger.Process("Executing build process") + logger.Subprocess("Installing uv %s", dependency.Version) + + duration, err := clock.Measure(func() error { + err := dependencyManager.Deliver(dependency, context.CNBPath, uvScriptTempLayer.Path, context.Platform.Path) + if err != nil { + return err + } + + return installProcess.Execute(uvLayer.Path, uvScriptTempLayer.Path, dependency.Arch) + }) + if err != nil { + return packit.BuildResult{}, err + } + + logger.Action("Completed in %s", duration.Round(time.Millisecond)) + logger.Break() + + uvLayer.Metadata = map[string]interface{}{ + DepKey: dependencyChecksum, + } + + logger.GeneratingSBOM(uvLayer.Path) + var sbomContent sbom.SBOM + duration, err = clock.Measure(func() error { + sbomContent, err = sbomGenerator.GenerateFromDependency(dependency, uvLayer.Path) + return err + }) + if err != nil { + return packit.BuildResult{}, err + } + + logger.Action("Completed in %s", duration.Round(time.Millisecond)) + logger.Break() + + logger.FormattingSBOM(context.BuildpackInfo.SBOMFormats...) + uvLayer.SBOM, err = sbomContent.InFormats(context.BuildpackInfo.SBOMFormats...) + if err != nil { + return packit.BuildResult{}, err + } + + return packit.BuildResult{ + Layers: []packit.Layer{uvLayer}, + Build: buildMetadata, + Launch: launchMetadata, + }, nil + } +} diff --git a/pkg/installers/uv/build_test.go b/pkg/installers/uv/build_test.go new file mode 100644 index 0000000..b44f346 --- /dev/null +++ b/pkg/installers/uv/build_test.go @@ -0,0 +1,337 @@ +// SPDX-FileCopyrightText: © 2025 Idiap Research Institute +// SPDX-FileContributor: Samuel Gaist +// +// SPDX-License-Identifier: Apache-2.0 + +package uv_test + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/paketo-buildpacks/packit/v2" + "github.com/paketo-buildpacks/packit/v2/chronos" + pythoninstallers "github.com/paketo-buildpacks/python-installers/pkg/installers/common" + "github.com/paketo-buildpacks/python-installers/pkg/installers/uv" + "github.com/paketo-buildpacks/python-installers/pkg/installers/uv/fakes" + + //nolint Ignore SA1019, informed usage of deprecated package + "github.com/paketo-buildpacks/packit/v2/paketosbom" + "github.com/paketo-buildpacks/packit/v2/postal" + "github.com/paketo-buildpacks/packit/v2/sbom" + "github.com/paketo-buildpacks/packit/v2/scribe" + "github.com/sclevine/spec" + + . "github.com/onsi/gomega" +) + +func testBuild(t *testing.T, context spec.G, it spec.S) { + var ( + Expect = NewWithT(t).Expect + + layersDir string + cnbDir string + + buffer *bytes.Buffer + + dependencyManager *fakes.DependencyManager + installProcess *fakes.InstallProcess + sbomGenerator *fakes.SBOMGenerator + + build packit.BuildFunc + buildContext packit.BuildContext + ) + + it.Before(func() { + var err error + layersDir, err = os.MkdirTemp("", "layers") + Expect(err).NotTo(HaveOccurred()) + + cnbDir, err = os.MkdirTemp("", "cnb") + Expect(err).NotTo(HaveOccurred()) + + dependencyManager = &fakes.DependencyManager{} + dependencyManager.ResolveCall.Returns.Dependency = postal.Dependency{ + ID: "uv", + Name: "uv-dependency-name", + Checksum: "uv-dependency-sha", + Stacks: []string{"some-stack"}, + URI: "uv-dependency-uri", + Version: "uv-dependency-version", + } + + // Legacy SBOM + dependencyManager.GenerateBillOfMaterialsCall.Returns.BOMEntrySlice = []packit.BOMEntry{ + { + Name: "uv", + Metadata: paketosbom.BOMMetadata{ + Checksum: paketosbom.BOMChecksum{ + Algorithm: paketosbom.SHA256, + Hash: "uv-dependency-sha", + }, + URI: "uv-dependency-uri", + Version: "uv-dependency-version", + }, + }, + } + + // Syft SBOM + sbomGenerator = &fakes.SBOMGenerator{} + sbomGenerator.GenerateFromDependencyCall.Returns.SBOM = sbom.SBOM{} + + installProcess = &fakes.InstallProcess{} + + buffer = bytes.NewBuffer(nil) + logger := scribe.NewEmitter(buffer) + + build = uv.Build( + uv.UvBuildParameters{ + DependencyManager: dependencyManager, + InstallProcess: installProcess, + }, + pythoninstallers.CommonBuildParameters{ + SbomGenerator: sbomGenerator, + Clock: chronos.DefaultClock, + Logger: logger, + }, + ) + buildContext = packit.BuildContext{ + BuildpackInfo: packit.BuildpackInfo{ + Name: "Some Buildpack", + Version: "some-version", + SBOMFormats: []string{sbom.CycloneDXFormat, sbom.SPDXFormat}, + }, + CNBPath: cnbDir, + Plan: packit.BuildpackPlan{ + Entries: []packit.BuildpackPlanEntry{ + {Name: uv.Uv}, + }, + }, + Platform: packit.Platform{Path: "some-platform-path"}, + Layers: packit.Layers{Path: layersDir}, + Stack: "some-stack", + } + }) + + it.After(func() { + Expect(os.RemoveAll(layersDir)).To(Succeed()) + Expect(os.RemoveAll(cnbDir)).To(Succeed()) + }) + + it("returns a result that installs uv", func() { + result, err := build(buildContext) + Expect(err).NotTo(HaveOccurred()) + + Expect(result.Layers).To(HaveLen(1)) + layer := result.Layers[0] + + Expect(layer.Name).To(Equal("uv")) + Expect(layer.Path).To(Equal(filepath.Join(layersDir, "uv"))) + + Expect(layer.SharedEnv).To(BeEmpty()) + Expect(layer.BuildEnv).To(BeEmpty()) + Expect(layer.LaunchEnv).To(BeEmpty()) + Expect(layer.ProcessLaunchEnv).To(BeEmpty()) + + Expect(layer.Build).To(BeFalse()) + Expect(layer.Launch).To(BeFalse()) + Expect(layer.Cache).To(BeFalse()) + + Expect(layer.Metadata).To(HaveLen(1)) + Expect(layer.Metadata["dependency-sha"]).To(Equal("uv-dependency-sha")) + + Expect(layer.SBOM.Formats()).To(HaveLen(2)) + var actualExtensions []string + for _, format := range layer.SBOM.Formats() { + actualExtensions = append(actualExtensions, format.Extension) + } + Expect(actualExtensions).To(ConsistOf("cdx.json", "spdx.json")) + + Expect(dependencyManager.ResolveCall.Receives.Path).To(Equal(filepath.Join(cnbDir, "buildpack.toml"))) + Expect(dependencyManager.ResolveCall.Receives.Id).To(Equal("uv")) + Expect(dependencyManager.ResolveCall.Receives.Version).To(Equal("")) + Expect(dependencyManager.ResolveCall.Receives.Stack).To(Equal("some-stack")) + + Expect(dependencyManager.GenerateBillOfMaterialsCall.Receives.Dependencies).To(Equal([]postal.Dependency{ + { + ID: "uv", + Name: "uv-dependency-name", + Checksum: "uv-dependency-sha", + Stacks: []string{"some-stack"}, + URI: "uv-dependency-uri", + Version: "uv-dependency-version", + }, + })) + + Expect(dependencyManager.DeliverCall.Receives.Dependency).To(Equal( + postal.Dependency{ + ID: "uv", + Name: "uv-dependency-name", + Checksum: "uv-dependency-sha", + Stacks: []string{"some-stack"}, + URI: "uv-dependency-uri", + Version: "uv-dependency-version", + })) + Expect(dependencyManager.DeliverCall.Receives.CnbPath).To(Equal(cnbDir)) + Expect(dependencyManager.DeliverCall.Receives.DestinationPath).To(Equal(filepath.Join(layersDir, "uv-temp-layer"))) + Expect(dependencyManager.DeliverCall.Receives.PlatformPath).To(Equal("some-platform-path")) + + Expect(sbomGenerator.GenerateFromDependencyCall.Receives.Dir).To(Equal(filepath.Join(layersDir, "uv"))) + + Expect(buffer.String()).To(ContainSubstring("Some Buildpack some-version")) + Expect(buffer.String()).To(ContainSubstring("Executing build process")) + Expect(buffer.String()).To(ContainSubstring("Installing uv")) + }) + + context("when the uv layer is required at build and launch", func() { + it.Before(func() { + buildContext.Plan.Entries[0].Metadata = make(map[string]interface{}) + buildContext.Plan.Entries[0].Metadata["launch"] = true + buildContext.Plan.Entries[0].Metadata["build"] = true + }) + + it("returns a layer with build and launch set true and the BOM is set for build and launch", func() { + result, err := build(buildContext) + Expect(err).NotTo(HaveOccurred()) + + Expect(result.Layers).To(HaveLen(1)) + layer := result.Layers[0] + + Expect(layer.Name).To(Equal("uv")) + + Expect(layer.Build).To(BeTrue()) + Expect(layer.Launch).To(BeTrue()) + Expect(layer.Cache).To(BeTrue()) + + Expect(result.Build.BOM).To(Equal( + []packit.BOMEntry{ + { + Name: "uv", + Metadata: paketosbom.BOMMetadata{ + Checksum: paketosbom.BOMChecksum{ + Algorithm: paketosbom.SHA256, + Hash: "uv-dependency-sha", + }, + URI: "uv-dependency-uri", + Version: "uv-dependency-version", + }, + }, + }, + )) + + Expect(result.Launch.BOM).To(Equal( + []packit.BOMEntry{ + { + Name: "uv", + Metadata: paketosbom.BOMMetadata{ + Checksum: paketosbom.BOMChecksum{ + Algorithm: paketosbom.SHA256, + Hash: "uv-dependency-sha", + }, + URI: "uv-dependency-uri", + Version: "uv-dependency-version", + }, + }, + }, + )) + }) + }) + + context("failure cases", func() { + context("when the dependency manager resolution fails", func() { + it.Before(func() { + dependencyManager.ResolveCall.Returns.Error = errors.New("resolve call failed") + }) + + it("returns an error", func() { + _, err := build(buildContext) + + Expect(err).To(MatchError("resolve call failed")) + }) + }) + + context("when the layer dir cannot be accessed", func() { + it.Before(func() { + Expect(os.Chmod(layersDir, 0000)).To(Succeed()) + }) + + it.After(func() { + Expect(os.Chmod(layersDir, os.ModePerm)).To(Succeed()) + }) + + it("returns an error", func() { + _, err := build(buildContext) + + Expect(err).To(MatchError(ContainSubstring("permission denied"))) + }) + }) + + context("when the layer dir cannot be reset", func() { + it.Before(func() { + Expect(os.MkdirAll(filepath.Join(layersDir, "uv", "bin"), os.ModePerm)).To(Succeed()) + Expect(os.Chmod(filepath.Join(layersDir, "uv"), 0500)).To(Succeed()) + }) + + it.After(func() { + Expect(os.Chmod(filepath.Join(layersDir, "uv"), os.ModePerm)).To(Succeed()) + }) + + it("returns an error", func() { + _, err := build(buildContext) + + Expect(err).To(MatchError(ContainSubstring("permission denied"))) + }) + }) + + context("when the dependency manager delivery fails", func() { + it.Before(func() { + dependencyManager.DeliverCall.Returns.Error = errors.New("deliver call failed") + }) + + it("returns an error", func() { + _, err := build(buildContext) + + Expect(err).To(MatchError("deliver call failed")) + }) + }) + + context("when generating the SBOM returns an error", func() { + it.Before(func() { + buildContext.BuildpackInfo.SBOMFormats = []string{"random-format"} + }) + + it("returns an error", func() { + _, err := build(buildContext) + + Expect(err).To(MatchError(`unsupported SBOM format: 'random-format'`)) + }) + }) + + context("when formatting the SBOM returns an error", func() { + it.Before(func() { + sbomGenerator.GenerateFromDependencyCall.Returns.Error = errors.New("failed to generate SBOM") + }) + + it("returns an error", func() { + _, err := build(buildContext) + + Expect(err).To(MatchError(ContainSubstring("failed to generate SBOM"))) + }) + }) + + context("when the install process returns an error", func() { + it.Before(func() { + installProcess.ExecuteCall.Returns.Error = errors.New("failed to copy files") + }) + + it("returns an error", func() { + _, err := build(buildContext) + + Expect(err).To(MatchError(ContainSubstring("failed to copy files"))) + }) + }) + }) +} diff --git a/pkg/installers/uv/constants.go b/pkg/installers/uv/constants.go new file mode 100644 index 0000000..b8e37c6 --- /dev/null +++ b/pkg/installers/uv/constants.go @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: © 2025 Idiap Research Institute +// SPDX-FileContributor: Samuel Gaist +// +// SPDX-License-Identifier: Apache-2.0 + +package uv + +const ( + // uv is the name of the layer into which uv dependency is installed. + Uv = "uv" + // LockfileName is the name of the uv lock file + LockfileName = "uv.lock" + + // CPython is the name of the python runtime dependency provided by the CPython buildpack: https://github.com/paketo-buildpacks/cpython + CPython = "cpython" + + // This is the key name that we use to store the sha of the script we + // download in the layer metadata, which is used to determine if the uvs + // layer can be reused on during a rebuild. + DepKey = "dependency-sha" + + UvArchiveTemplateName = "uv-%s-unknown-linux-gnu" +) + +var Priorities = []interface{}{ + "BP_UV_VERSION", +} diff --git a/pkg/installers/uv/detect.go b/pkg/installers/uv/detect.go new file mode 100644 index 0000000..dc9001f --- /dev/null +++ b/pkg/installers/uv/detect.go @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: © 2025 Idiap Research Institute +// SPDX-FileContributor: Samuel Gaist +// +// SPDX-License-Identifier: Apache-2.0 + +package uv + +import ( + "os" + "path/filepath" + + "github.com/paketo-buildpacks/packit/v2" + "github.com/paketo-buildpacks/packit/v2/fs" +) + +type BuildPlanMetadata struct { + VersionSource string `toml:"version-source"` + Build bool `toml:"build"` + Version string `toml:"version"` +} + +// Detect will return a packit.DetectFunc that will be invoked during the +// detect phase of the buildpack lifecycle. +// +// Detection always passes, and will contribute a Build Plan that provides uv. +func Detect() packit.DetectFunc { + return func(context packit.DetectContext) (packit.DetectResult, error) { + lockfile := filepath.Join(context.WorkingDir, LockfileName) + + if exists, err := fs.Exists(lockfile); err != nil { + return packit.DetectResult{}, err + } else if !exists { + return packit.DetectResult{}, packit.Fail.WithMessage("%s is not present", LockfileName) + } + + parser := NewLockfileParser() + pythonVersion, err := parser.ParsePythonVersion(lockfile) + if err != nil { + return packit.DetectResult{}, err + } + + if pythonVersion == "" { + return packit.DetectResult{}, packit.Fail.WithMessage("%s must include requires-python", LockfileName) + } + + plan := packit.BuildPlan{ + Provides: []packit.BuildPlanProvision{ + {Name: Uv}, + }, + } + + if version, ok := os.LookupEnv("BP_UV_VERSION"); ok { + plan.Requires = []packit.BuildPlanRequirement{ + { + Name: Uv, + Metadata: BuildPlanMetadata{ + VersionSource: "BP_UV_VERSION", + Version: version, + }, + }, + } + } + + return packit.DetectResult{ + Plan: plan, + }, nil + } +} diff --git a/pkg/installers/uv/detect_test.go b/pkg/installers/uv/detect_test.go new file mode 100644 index 0000000..bf0d4e6 --- /dev/null +++ b/pkg/installers/uv/detect_test.go @@ -0,0 +1,168 @@ +// SPDX-FileCopyrightText: © 2025 Idiap Research Institute +// SPDX-FileContributor: Samuel Gaist +// +// SPDX-License-Identifier: Apache-2.0 + +package uv_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/paketo-buildpacks/packit/v2" + "github.com/paketo-buildpacks/python-installers/pkg/installers/uv" + "github.com/sclevine/spec" + + . "github.com/onsi/gomega" +) + +func testDetect(t *testing.T, context spec.G, it spec.S) { + var ( + Expect = NewWithT(t).Expect + + workingDir string + + detect packit.DetectFunc + ) + + it.Before(func() { + var err error + workingDir, err = os.MkdirTemp("", "working-dir") + Expect(err).NotTo(HaveOccurred()) + + Expect(os.WriteFile(filepath.Join(workingDir, uv.LockfileName), []byte(`requires-python = "==3.13.0"`), 0755)).To(Succeed()) + + detect = uv.Detect() + }) + + it.After(func() { + Expect(os.RemoveAll(workingDir)).To(Succeed()) + }) + + context("when the BP_UV_VERSION is NOT set", func() { + it.Before(func() { + Expect(os.Unsetenv("BP_UV_VERSION")).To(Succeed()) + }) + it("returns a plan that provides uv", func() { + result, err := detect(packit.DetectContext{ + WorkingDir: workingDir, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(packit.DetectResult{ + Plan: packit.BuildPlan{ + Provides: []packit.BuildPlanProvision{ + {Name: uv.Uv}, + }, + }, + })) + }) + }) + + context("when the BP_UV_VERSION is set", func() { + it.Before(func() { + Expect(os.Setenv("BP_UV_VERSION", "some-version")).To(Succeed()) + }) + + it.After(func() { + Expect(os.Unsetenv("BP_UV_VERSION")).To(Succeed()) + }) + + it("returns a plan that requires that version of poetry", func() { + result, err := detect(packit.DetectContext{ + WorkingDir: workingDir, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(packit.DetectResult{ + Plan: packit.BuildPlan{ + Provides: []packit.BuildPlanProvision{ + {Name: uv.Uv}, + }, + Requires: []packit.BuildPlanRequirement{ + { + Name: uv.Uv, + Metadata: uv.BuildPlanMetadata{ + VersionSource: "BP_UV_VERSION", + Version: "some-version", + }, + }, + }, + }, + })) + }) + }) + + context("when uv.lock is not present", func() { + it.Before(func() { + Expect(os.RemoveAll(filepath.Join(workingDir, uv.LockfileName))).To(Succeed()) + }) + + it("fails detection", func() { + _, err := detect(packit.DetectContext{ + WorkingDir: workingDir, + }) + Expect(err).To(MatchError(packit.Fail.WithMessage("uv.lock is not present"))) + }) + }) + + context("when no python version is returned from the parser", func() { + it.Before(func() { + var err error + workingDir, err = os.MkdirTemp("", "working-dir") + Expect(err).NotTo(HaveOccurred()) + + Expect(os.WriteFile(filepath.Join(workingDir, uv.LockfileName), []byte(""), 0755)).To(Succeed()) + }) + + it.After(func() { + Expect(os.RemoveAll(workingDir)).To(Succeed()) + }) + + it("fails detection", func() { + _, err := detect(packit.DetectContext{ + WorkingDir: workingDir, + }) + Expect(err).To(MatchError(packit.Fail.WithMessage("uv.lock must include requires-python"))) + }) + }) + + context("error handling", func() { + context("when there is an error determining if the uv.lock file exists", func() { + it.Before(func() { + Expect(os.Chmod(workingDir, 0000)).To(Succeed()) + }) + + it.After(func() { + Expect(os.Chmod(workingDir, os.ModePerm)).To(Succeed()) + }) + + it("returns the error", func() { + _, err := detect(packit.DetectContext{ + WorkingDir: workingDir, + }) + Expect(err).To(MatchError(ContainSubstring("permission denied"))) + }) + }) + + context("when the uv lock file parser returns an error", func() { + it.Before(func() { + var err error + workingDir, err = os.MkdirTemp("", "working-dir") + Expect(err).NotTo(HaveOccurred()) + + Expect(os.WriteFile(filepath.Join(workingDir, uv.LockfileName), []byte("error"), 0755)).To(Succeed()) + }) + + it.After(func() { + Expect(os.RemoveAll(workingDir)).To(Succeed()) + }) + + it("returns the error", func() { + _, err := detect(packit.DetectContext{ + WorkingDir: workingDir, + }) + Expect(err).To(MatchError(ContainSubstring("toml: line 1: expected '.' or '=', but got '<' instead"))) + }) + }) + }) +} diff --git a/pkg/installers/uv/fakes/dependency_manager.go b/pkg/installers/uv/fakes/dependency_manager.go new file mode 100644 index 0000000..f321df1 --- /dev/null +++ b/pkg/installers/uv/fakes/dependency_manager.go @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: © 2025 Idiap Research Institute +// SPDX-FileContributor: Samuel Gaist +// +// SPDX-License-Identifier: Apache-2.0 + +package fakes + +import ( + "sync" + + "github.com/paketo-buildpacks/packit/v2" + "github.com/paketo-buildpacks/packit/v2/postal" +) + +type DependencyManager struct { + DeliverCall struct { + mutex sync.Mutex + CallCount int + Receives struct { + Dependency postal.Dependency + CnbPath string + DestinationPath string + PlatformPath string + } + Returns struct { + Error error + } + Stub func(postal.Dependency, string, string, string) error + } + GenerateBillOfMaterialsCall struct { + mutex sync.Mutex + CallCount int + Receives struct { + Dependencies []postal.Dependency + } + Returns struct { + BOMEntrySlice []packit.BOMEntry + } + Stub func(...postal.Dependency) []packit.BOMEntry + } + ResolveCall struct { + mutex sync.Mutex + CallCount int + Receives struct { + Path string + Id string + Version string + Stack string + } + Returns struct { + Dependency postal.Dependency + Error error + } + Stub func(string, string, string, string) (postal.Dependency, error) + } +} + +func (f *DependencyManager) Deliver(param1 postal.Dependency, param2 string, param3 string, param4 string) error { + f.DeliverCall.mutex.Lock() + defer f.DeliverCall.mutex.Unlock() + f.DeliverCall.CallCount++ + f.DeliverCall.Receives.Dependency = param1 + f.DeliverCall.Receives.CnbPath = param2 + f.DeliverCall.Receives.DestinationPath = param3 + f.DeliverCall.Receives.PlatformPath = param4 + if f.DeliverCall.Stub != nil { + return f.DeliverCall.Stub(param1, param2, param3, param4) + } + return f.DeliverCall.Returns.Error +} +func (f *DependencyManager) GenerateBillOfMaterials(param1 ...postal.Dependency) []packit.BOMEntry { + f.GenerateBillOfMaterialsCall.mutex.Lock() + defer f.GenerateBillOfMaterialsCall.mutex.Unlock() + f.GenerateBillOfMaterialsCall.CallCount++ + f.GenerateBillOfMaterialsCall.Receives.Dependencies = param1 + if f.GenerateBillOfMaterialsCall.Stub != nil { + return f.GenerateBillOfMaterialsCall.Stub(param1...) + } + return f.GenerateBillOfMaterialsCall.Returns.BOMEntrySlice +} +func (f *DependencyManager) Resolve(param1 string, param2 string, param3 string, param4 string) (postal.Dependency, error) { + f.ResolveCall.mutex.Lock() + defer f.ResolveCall.mutex.Unlock() + f.ResolveCall.CallCount++ + f.ResolveCall.Receives.Path = param1 + f.ResolveCall.Receives.Id = param2 + f.ResolveCall.Receives.Version = param3 + f.ResolveCall.Receives.Stack = param4 + if f.ResolveCall.Stub != nil { + return f.ResolveCall.Stub(param1, param2, param3, param4) + } + return f.ResolveCall.Returns.Dependency, f.ResolveCall.Returns.Error +} diff --git a/pkg/installers/uv/fakes/install_process.go b/pkg/installers/uv/fakes/install_process.go new file mode 100644 index 0000000..e3270bc --- /dev/null +++ b/pkg/installers/uv/fakes/install_process.go @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: © 2025 Idiap Research Institute +// SPDX-FileContributor: Samuel Gaist +// +// SPDX-License-Identifier: Apache-2.0 + +package fakes + +import "sync" + +type InstallProcess struct { + TranslateArchitectureCall struct { + mutex sync.Mutex + CallCount int + Receives struct { + DependencyArch string + } + Returns struct { + Arch string + } + Stub func(string) string + } + + ExecuteCall struct { + mutex sync.Mutex + CallCount int + Receives struct { + DestLayerPath string + SrcLayerPath string + DependencyArch string + } + Returns struct { + Error error + } + Stub func(string, string, string) error + } +} + +func (f *InstallProcess) TranslateArchitecture(param1 string) string { + f.TranslateArchitectureCall.mutex.Lock() + defer f.TranslateArchitectureCall.mutex.Unlock() + f.TranslateArchitectureCall.CallCount++ + f.TranslateArchitectureCall.Receives.DependencyArch = param1 + if f.TranslateArchitectureCall.Stub != nil { + return f.TranslateArchitectureCall.Stub(param1) + } + return f.TranslateArchitectureCall.Returns.Arch +} + +func (f *InstallProcess) Execute(param1 string, param2 string, param3 string) error { + f.ExecuteCall.mutex.Lock() + defer f.ExecuteCall.mutex.Unlock() + f.ExecuteCall.CallCount++ + f.ExecuteCall.Receives.DestLayerPath = param1 + f.ExecuteCall.Receives.SrcLayerPath = param2 + f.ExecuteCall.Receives.DependencyArch = param3 + if f.ExecuteCall.Stub != nil { + return f.ExecuteCall.Stub(param1, param2, param3) + } + return f.ExecuteCall.Returns.Error +} diff --git a/pkg/installers/uv/fakes/sbom_generator.go b/pkg/installers/uv/fakes/sbom_generator.go new file mode 100644 index 0000000..a548333 --- /dev/null +++ b/pkg/installers/uv/fakes/sbom_generator.go @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: © 2025 Idiap Research Institute +// SPDX-FileContributor: Samuel Gaist +// +// SPDX-License-Identifier: Apache-2.0 + +package fakes + +import ( + "sync" + + "github.com/paketo-buildpacks/packit/v2/postal" + "github.com/paketo-buildpacks/packit/v2/sbom" +) + +type SBOMGenerator struct { + GenerateFromDependencyCall struct { + mutex sync.Mutex + CallCount int + Receives struct { + Dependency postal.Dependency + Dir string + } + Returns struct { + SBOM sbom.SBOM + Error error + } + Stub func(postal.Dependency, string) (sbom.SBOM, error) + } +} + +func (f *SBOMGenerator) GenerateFromDependency(param1 postal.Dependency, param2 string) (sbom.SBOM, error) { + f.GenerateFromDependencyCall.mutex.Lock() + defer f.GenerateFromDependencyCall.mutex.Unlock() + f.GenerateFromDependencyCall.CallCount++ + f.GenerateFromDependencyCall.Receives.Dependency = param1 + f.GenerateFromDependencyCall.Receives.Dir = param2 + if f.GenerateFromDependencyCall.Stub != nil { + return f.GenerateFromDependencyCall.Stub(param1, param2) + } + return f.GenerateFromDependencyCall.Returns.SBOM, f.GenerateFromDependencyCall.Returns.Error +} diff --git a/pkg/installers/uv/init_test.go b/pkg/installers/uv/init_test.go new file mode 100644 index 0000000..2e03065 --- /dev/null +++ b/pkg/installers/uv/init_test.go @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: © 2025 Idiap Research Institute +// SPDX-FileContributor: Samuel Gaist +// +// SPDX-License-Identifier: Apache-2.0 + +package uv_test + +import ( + "testing" + + "github.com/sclevine/spec" + "github.com/sclevine/spec/report" +) + +func TestUnit(t *testing.T) { + suite := spec.New("uv", spec.Report(report.Terminal{}), spec.Parallel()) + suite("Build", testBuild) + suite("Detect", testDetect) + suite("InstallProcess", testUvInstallProcess) + suite("Parser", testUvLockParser) + suite.Run(t) +} diff --git a/pkg/installers/uv/install_process.go b/pkg/installers/uv/install_process.go new file mode 100644 index 0000000..b0fc712 --- /dev/null +++ b/pkg/installers/uv/install_process.go @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: © 2025 Idiap Research Institute +// SPDX-FileContributor: Samuel Gaist +// +// SPDX-License-Identifier: Apache-2.0 + +package uv + +import ( + "fmt" + "os" + "path/filepath" +) + +type UvInstallProcess struct { +} + +// NewUvInstallProcess creates a UvInstallProcess instance. +func NewUvInstallProcess() UvInstallProcess { + return UvInstallProcess{} +} + +func (p UvInstallProcess) TranslateArchitecture(arch string) string { + switch arch { + case "amd64": + return "x86_64" + case "arm64": + return "aarch64" + default: + return "" + } +} + +// Copy files from uv archive +func (p UvInstallProcess) Execute(targetLayerPath, sourcePath, dependencyArch string) error { + arch := p.TranslateArchitecture(dependencyArch) + + if arch == "" { + return fmt.Errorf("arch %s is not supported", dependencyArch) + } + + folder := fmt.Sprintf(UvArchiveTemplateName, arch) + return os.CopyFS(filepath.Join(targetLayerPath, "bin"), os.DirFS(filepath.Join(sourcePath, folder))) +} diff --git a/pkg/installers/uv/install_process_test.go b/pkg/installers/uv/install_process_test.go new file mode 100644 index 0000000..5e7e4c5 --- /dev/null +++ b/pkg/installers/uv/install_process_test.go @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: © 2025 Idiap Research Institute +// SPDX-FileContributor: Samuel Gaist +// +// SPDX-License-Identifier: Apache-2.0 + +package uv_test + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + . "github.com/onsi/gomega" + "github.com/paketo-buildpacks/python-installers/pkg/installers/uv" + "github.com/sclevine/spec" +) + +func testUvInstallProcess(t *testing.T, context spec.G, it spec.S) { + var ( + Expect = NewWithT(t).Expect + + workingDir string + srcDir string + dstDir string + + installProcess uv.UvInstallProcess + ) + + it.Before(func() { + var err error + workingDir, err = os.MkdirTemp("", "working-dir") + Expect(err).NotTo(HaveOccurred()) + + srcDir = filepath.Join(workingDir, "src") + Expect(os.MkdirAll(srcDir, os.ModePerm)).To(Succeed()) + + dstDir = filepath.Join(workingDir, "dst") + Expect(os.MkdirAll(dstDir, os.ModePerm)).To(Succeed()) + + installProcess = uv.NewUvInstallProcess() + }) + + it.After(func() { + Expect(os.RemoveAll(workingDir)).To(Succeed()) + }) + + context("Calling Execute", func() { + archs := []string{"amd64", "arm64"} + for _, arch := range archs { + context(fmt.Sprintf("Test for %s", arch), func() { + it.Before(func() { + archFolder := filepath.Join(srcDir, fmt.Sprintf(uv.UvArchiveTemplateName, installProcess.TranslateArchitecture(arch))) + Expect(os.MkdirAll(archFolder, os.ModePerm)).To(Succeed()) + }) + it(fmt.Sprintf("copies file for %s", arch), func() { + err := installProcess.Execute(dstDir, srcDir, arch) + Expect(err).NotTo(HaveOccurred()) + }) + }) + } + + context("error handling", func() { + it("fails if arch is unknown", func() { + err := installProcess.Execute("dummy", "dummy", "invalid") + Expect(err).To(HaveOccurred()) + }) + }) + }) +} diff --git a/pkg/installers/uv/uvlock_parser.go b/pkg/installers/uv/uvlock_parser.go new file mode 100644 index 0000000..83cb08c --- /dev/null +++ b/pkg/installers/uv/uvlock_parser.go @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: © 2025 Idiap Research Institute +// SPDX-FileContributor: Samuel Gaist +// +// SPDX-License-Identifier: Apache-2.0 + +package uv + +import ( + "strings" + + "github.com/BurntSushi/toml" +) + +type Lockfile struct { + RequiresPython string `toml:"requires-python"` +} + +type LockfileParser struct { +} + +func NewLockfileParser() LockfileParser { + return LockfileParser{} +} + +func (p LockfileParser) ParsePythonVersion(lockfilePath string) (string, error) { + var lockfile Lockfile + + _, err := toml.DecodeFile(lockfilePath, &lockfile) + if err != nil { + return "", err + } + + if lockfile.RequiresPython != "" { + return strings.Trim(lockfile.RequiresPython, "="), nil + } + return lockfile.RequiresPython, nil +} diff --git a/pkg/installers/uv/uvlock_parser_test.go b/pkg/installers/uv/uvlock_parser_test.go new file mode 100644 index 0000000..bb5c0bc --- /dev/null +++ b/pkg/installers/uv/uvlock_parser_test.go @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: © 2025 Idiap Research Institute +// SPDX-FileContributor: Samuel Gaist +// +// SPDX-License-Identifier: Apache-2.0 + +package uv_test + +import ( + "os" + "path/filepath" + "testing" + + . "github.com/onsi/gomega" + "github.com/paketo-buildpacks/python-installers/pkg/installers/uv" + "github.com/sclevine/spec" +) + +func testUvLockParser(t *testing.T, context spec.G, it spec.S) { + var ( + Expect = NewWithT(t).Expect + + workingDir string + lockfile string + + parser uv.LockfileParser + ) + + const ( + version = `requires-python = "==1.2.3"` + ) + + it.Before(func() { + var err error + workingDir, err = os.MkdirTemp("", "working-dir") + Expect(err).NotTo(HaveOccurred()) + + lockfile = filepath.Join(workingDir, uv.LockfileName) + + parser = uv.NewLockfileParser() + }) + + it.After(func() { + Expect(os.RemoveAll(workingDir)).To(Succeed()) + }) + + context("Calling ParsePythonVersion", func() { + it("parses version", func() { + Expect(os.WriteFile(lockfile, []byte(version), 0644)).To(Succeed()) + + version, err := parser.ParsePythonVersion(lockfile) + Expect(err).NotTo(HaveOccurred()) + Expect(version).To(Equal("1.2.3")) + }) + + it("returns empty string if file does not contain requires-python", func() { + Expect(os.WriteFile(lockfile, []byte(""), 0644)).To(Succeed()) + + version, err := parser.ParsePythonVersion(lockfile) + Expect(err).NotTo(HaveOccurred()) + Expect(version).To(Equal("")) + }) + + context("error handling", func() { + it("fails if file does not exist", func() { + _, err := parser.ParsePythonVersion("not-a-valid-dir") + Expect(err).To(HaveOccurred()) + }) + }) + }) +} diff --git a/run/main.go b/run/main.go index 15b504d..51e0f38 100644 --- a/run/main.go +++ b/run/main.go @@ -20,6 +20,7 @@ import ( pip "github.com/paketo-buildpacks/python-installers/pkg/installers/pip" pipenv "github.com/paketo-buildpacks/python-installers/pkg/installers/pipenv" poetry "github.com/paketo-buildpacks/python-installers/pkg/installers/poetry" + uv "github.com/paketo-buildpacks/python-installers/pkg/installers/uv" ) func main() { @@ -51,6 +52,10 @@ func main() { InstallProcess: poetry.NewPoetryInstallProcess(pexec.NewExecutable("python")), SitePackageProcess: poetry.NewSiteProcess(pexec.NewExecutable("python")), }, + uv.Uv: uv.UvBuildParameters{ + DependencyManager: postal.NewService(cargo.NewTransport()), + InstallProcess: uv.NewUvInstallProcess(), + }, } packit.Run(