diff --git a/internal/cloudapi/v2/depsolve.go b/internal/cloudapi/v2/depsolve.go index b1b8122a35..a25c183705 100644 --- a/internal/cloudapi/v2/depsolve.go +++ b/internal/cloudapi/v2/depsolve.go @@ -80,3 +80,78 @@ func (request *DepsolveRequest) DepsolveBlueprint(df *distrofactory.Factory, rr return solved.Packages, nil } + +// PackageSearch uses the solver to search for the details of one or more packages +// It will search for exact package name matches, and for searches using globs on the name +// or version. +// If a specific distro and/or arch are included in the blueprint it will use that for the +// search, otherwise it will use the host's distro and arch. +// If separate repositories are included in the request they are used instead. +func (request *PackageSearchRequest) PackageSearch(df *distrofactory.Factory, rr *reporegistry.RepoRegistry, solver *dnfjson.BaseSolver) ([]rpmmd.PackageInfo, error) { + bp, err := ConvertRequestBP(request.Blueprint) + if err != nil { + return nil, err + } + + // Distro name, in order of priority + // bp.Distro + // host distro + var originalDistroName string + if len(bp.Distro) > 0 { + originalDistroName = bp.Distro + } else { + originalDistroName, err = distro.GetHostDistroName() + if err != nil { + return nil, HTTPErrorWithInternal(ErrorUnsupportedDistribution, err) + } + } + + distribution := df.GetDistro(originalDistroName) + if distribution == nil { + return nil, HTTPError(ErrorUnsupportedDistribution) + } + + var originalArchName string + if len(bp.Arch) > 0 { + originalArchName = bp.Arch + } else { + originalArchName = arch.Current().String() + } + distroArch, err := distribution.GetArch(originalArchName) + if err != nil { + return nil, HTTPErrorWithInternal(ErrorUnsupportedArchitecture, err) + } + + // Get the repositories to use for depsolving + // Either the list passed in with the request, or the defaults for the distro+arch + var repos []rpmmd.RepoConfig + if request.Repositories != nil { + repos, err = convertRepos(*request.Repositories, []Repository{}, []string{}) + if err != nil { + // Error comes from genRepoConfig and is already an HTTPError + return nil, err + } + } else { + repos, err = rr.ReposByArchName(originalDistroName, distroArch.Name(), false) + if err != nil { + return nil, HTTPErrorWithInternal(ErrorInvalidRepository, err) + } + } + + s := solver.NewWithConfig( + distribution.ModulePlatformID(), + distribution.Releasever(), + distroArch.Name(), + distribution.Name()) + pkgs, err := s.SearchMetadata(repos, bp.GetPackagesEx(false)) + if err != nil { + return nil, HTTPErrorWithInternal(ErrorFailedToDepsolve, err) + } + + if err := solver.CleanCache(); err != nil { + // log and ignore + log.Printf("Error during rpm repo cache cleanup: %s", err.Error()) + } + + return pkgs.ToPackageInfos(), nil +} diff --git a/internal/cloudapi/v2/handler.go b/internal/cloudapi/v2/handler.go index 2e3641e368..7d963aad95 100644 --- a/internal/cloudapi/v2/handler.go +++ b/internal/cloudapi/v2/handler.go @@ -1397,3 +1397,67 @@ func packageSpecToPackageMetadata(pkgspecs []rpmmd.PackageSpec) []PackageMetadat } return packages } + +// PostPackageSearch searches for one or more packages and returns their details +// as a list of PackageDetails +func (h *apiHandlers) PostPackageSearch(ctx echo.Context) error { + var request PackageSearchRequest + err := ctx.Bind(&request) + if err != nil { + return err + } + + pkgs, err := request.PackageSearch(h.server.distros, h.server.repos, h.server.solver) + if err != nil { + return err + } + + return ctx.JSON(http.StatusOK, + PackageSearchResponse{ + Packages: packageInfoToPackageInfo(pkgs), + }) +} + +// packageInfoToPackageInfo converts []rpmmd.PackageInfo to []PackageInfo +func packageInfoToPackageInfo(pkgs []rpmmd.PackageInfo) []PackageInfo { + info := []PackageInfo{} + for _, p := range pkgs { + i := PackageInfo{ + Name: p.Name, + Summary: p.Summary, + } + if len(p.Description) > 0 { + i.Description = common.ToPtr(p.Description) + } + if len(p.Homepage) > 0 { + i.Homepage = common.ToPtr(p.Homepage) + } + var builds []PackageBuild + if len(p.Builds) > 0 { + for _, b := range p.Builds { + build := PackageBuild{ + Arch: b.Arch, + Release: b.Release, + Source: PackageSource{ + License: b.Source.License, + Version: b.Source.Version, + }, + } + if b.Epoch > 0 { + build.Epoch = common.ToPtr(strconv.FormatUint(uint64(b.Epoch), 10)) + } + if len(b.BuildTime) > 0 { + build.BuildTime = common.ToPtr(b.BuildTime) + } + + builds = append(builds, build) + } + + i.Builds = &builds + } + + info = append(info, i) + } + + return info +} diff --git a/internal/cloudapi/v2/v2_internal_test.go b/internal/cloudapi/v2/v2_internal_test.go index 7eafedf828..b0e59588c0 100644 --- a/internal/cloudapi/v2/v2_internal_test.go +++ b/internal/cloudapi/v2/v2_internal_test.go @@ -405,3 +405,59 @@ func TestPackageSpecToPackageMetadata(t *testing.T) { assert.Equal(tc.pkgs, packageSpecToPackageMetadata(tc.specs), "mismatch in test case %d", idx) } } + +func TestPackageInfoToPackageInfo(t *testing.T) { + assert := assert.New(t) + type testCase struct { + pkgs []rpmmd.PackageInfo + info []PackageInfo + } + testCases := []testCase{ + { + pkgs: []rpmmd.PackageInfo{ + { + Name: "vim-enhanced", + Summary: "A version of the VIM editor which includes recent enhancements", + Description: "VIM (VIsual editor iMproved) is an updated and improved ...", + Homepage: "http://www.vim.org/", + Builds: []rpmmd.PackageBuild{ + { + Arch: "x86_64", + BuildTime: "2024-09-06T16:14:20", + Epoch: 2, + Release: "1.fc40", + Source: rpmmd.PackageSource{ + Version: "9.1.719", + License: "Vim AND LGPL-2.1-or-later AND ...", + }, + }, + }, + }, + }, + info: []PackageInfo{ + { + Name: "vim-enhanced", + Summary: "A version of the VIM editor which includes recent enhancements", + Description: common.ToPtr("VIM (VIsual editor iMproved) is an updated and improved ..."), + Homepage: common.ToPtr("http://www.vim.org/"), + Builds: &[]PackageBuild{ + { + Arch: "x86_64", + BuildTime: common.ToPtr("2024-09-06T16:14:20"), + Epoch: common.ToPtr("2"), + Release: "1.fc40", + Source: PackageSource{ + Version: "9.1.719", + License: "Vim AND LGPL-2.1-or-later AND ...", + }, + }, + }, + }, + }, + }, + } + + for idx, tc := range testCases { + assert.Equal(tc.info, packageInfoToPackageInfo(tc.pkgs), "mismatch in test case %d", idx) + } +} diff --git a/internal/cloudapi/v2/v2_test.go b/internal/cloudapi/v2/v2_test.go index c35b2acdb7..f66cbf8f43 100644 --- a/internal/cloudapi/v2/v2_test.go +++ b/internal/cloudapi/v2/v2_test.go @@ -1623,6 +1623,73 @@ func TestDepsolveBlueprint(t *testing.T) { }`) } +func TestPackagesSearch(t *testing.T) { + srv, _, _, cancel := newV2Server(t, t.TempDir(), []string{""}, false, false) + defer cancel() + + test.TestRoute(t, srv.Handler("/api/image-builder-composer/v2"), false, "POST", + "/api/image-builder-composer/v2/packages/search", fmt.Sprintf(` + {"blueprint": { + "name": "searchtest1", + "version": "0.0.1", + "distro": "%s", + "architecture": "%s", + "packages": [ + { "name": "package1" } + ]} + }`, test_distro.TestDistro1Name, test_distro.TestArchName), + http.StatusOK, + `{ + "packages": [ + { + "description": "pkg1 desc", + "homepage": "https://pkg1.example.com", + "name": "package1", + "summary": "pkg1 sum", + "builds": [ + { + "arch": "x86_64", + "build_time": "2006-02-02T15:04:05", + "release": "1.fc30", + "source": { + "license": "MIT", + "version": "1.0" + } + }, + { + "arch": "x86_64", + "build_time": "2006-02-03T15:04:05", + "release": "1.fc30", + "source": { + "license": "MIT", + "version": "1.1" + } + }] + }] + }`) +} + +func TestPackagesSearchError(t *testing.T) { + srv, _, _, cancel := newV2Server(t, t.TempDir(), []string{""}, false, false) + defer cancel() + + test.TestRoute(t, srv.Handler("/api/image-builder-composer/v2"), false, "POST", + "/api/image-builder-composer/v2/packages/search", fmt.Sprintf(` + {"blueprint": { + "name": "searchtest2", + "version": "0.0.1", + "distro": "%s", + "architecture": "%s", + "packages": [ + { "name": "nonexistingpkg" } + ]} + }`, test_distro.TestDistro1Name, test_distro.TestArchName), + http.StatusOK, + `{ + "packages": [] + }`) +} + // TestMain builds the mock dnf json binary and cleans it up on exit func TestMain(m *testing.M) { setupDNFJSON()