From 3d9f4a098521ef9cfd345ab6511f1ed2e4ea69b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=A5=D1=80?= =?UTF-8?q?=D0=B0=D0=BC=D0=BE=D0=B2?= Date: Fri, 16 Jan 2026 01:01:01 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A3=D0=BB=D1=83=D1=87=D1=88=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=B7=D0=B0=D0=B2=D0=B8=D1=81=D0=B8=D0=BC=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=B5=D0=B9=20=D0=B8=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82?= =?UTF-8?q?=D1=80=D0=B0=D1=86=D0=B8=D0=B8=20=D1=83=D1=81=D1=82=D0=B0=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=BD=D1=8B=D1=85=20=D0=BF=D0=B0?= =?UTF-8?q?=D0=BA=D0=B5=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Добавлена поддержка версионных ограничений при установке пакетов - Улучшена логика фильтрации уже установленных пакетов - Добавлен метод GetInstalledVersion для всех менеджеров пакетов - Активированы тесты для систем archlinux, alpine, opensuse-leap - Улучшена обработка переменных в скриптах Co-authored-by: Qwen-Coder --- .gitverse/workflows/e2e-tests.yaml | 49 ++++ .gitverse/workflows/pre-commit.yaml | 49 ++++ .gitverse/workflows/release.yaml | 185 +++++++++++++ AUTHORS | 3 +- e2e-tests/common_test.go | 12 +- internal/build/build.go | 66 ++++- internal/build/dependency_tree.go | 15 +- internal/build/find_deps/find_deps.go | 7 +- internal/build/installer.go | 68 ++++- internal/build/plugins_executors.go | 1 + internal/build/plugins_executors_gen.go | 27 ++ internal/manager/apk.go | 30 ++ internal/manager/apt.go | 63 ++++- internal/manager/common_rpm.go | 18 ++ internal/manager/manager_test.go | 59 ++++ internal/manager/managers.go | 7 +- internal/manager/pacman.go | 21 ++ internal/manager/zypper.go | 4 +- internal/repos/find.go | 45 ++- internal/shutils/decoder/decoder.go | 4 + main.go | 5 +- pkg/alrsh/alrsh.go | 21 +- pkg/depver/depver.go | 137 ++++++++++ pkg/depver/depver_test.go | 347 ++++++++++++++++++++++++ pkg/depver/format.go | 132 +++++++++ 25 files changed, 1330 insertions(+), 45 deletions(-) create mode 100644 .gitverse/workflows/e2e-tests.yaml create mode 100644 .gitverse/workflows/pre-commit.yaml create mode 100644 .gitverse/workflows/release.yaml create mode 100644 internal/manager/manager_test.go create mode 100644 pkg/depver/depver.go create mode 100644 pkg/depver/depver_test.go create mode 100644 pkg/depver/format.go diff --git a/.gitverse/workflows/e2e-tests.yaml b/.gitverse/workflows/e2e-tests.yaml new file mode 100644 index 0000000..246f22f --- /dev/null +++ b/.gitverse/workflows/e2e-tests.yaml @@ -0,0 +1,49 @@ +# ALR - Any Linux Repository +# Copyright (C) 2025 The ALR Authors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +name: E2E + +# on: +# push: +# branches: [ main ] +# pull_request: +on: + workflow_dispatch: + +jobs: + tests: + runs-on: ubuntu-latest + + container: + image: altlinux.space/maks1ms/actions-container-runner:latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + cache: false + + - name: Run E2E tests + env: + IGNORE_ROOT_CHECK: 1 + run: | + make e2e-test diff --git a/.gitverse/workflows/pre-commit.yaml b/.gitverse/workflows/pre-commit.yaml new file mode 100644 index 0000000..be6fcec --- /dev/null +++ b/.gitverse/workflows/pre-commit.yaml @@ -0,0 +1,49 @@ +# ALR - Any Linux Repository +# Copyright (C) 2025 The ALR Authors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + + +name: Pre-commit + +on: + push: + branches: [ master ] + pull_request: + + +jobs: + pre-commit: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + + - name: Set up Python for pre-commit + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install deps + run: apt-get update && apt-get install -y gettext bc + + - run: pip install pre-commit + - run: pre-commit install + - run: pre-commit run --all-files diff --git a/.gitverse/workflows/release.yaml b/.gitverse/workflows/release.yaml new file mode 100644 index 0000000..7d2c866 --- /dev/null +++ b/.gitverse/workflows/release.yaml @@ -0,0 +1,185 @@ +# ALR - Any Linux Repository +# Copyright (C) 2025 The ALR Authors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +name: Create Release + +on: + push: + tags: + - 'v[0-9]+.[0-9]+.[0-9]+' + +jobs: + changelog: + runs-on: ubuntu-latest + steps: + - name: Checkout this repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + + - name: Get Changes between Tags + id: changes + run: | + # Получаем текущий и предыдущий теги + CURRENT_TAG=${GITHUB_REF##*/} + PREVIOUS_TAG=$(git describe --tags --abbrev=0 ${CURRENT_TAG}^ 2>/dev/null || echo "") + + if [ -n "$PREVIOUS_TAG" ]; then + CHANGES=$(git log ${PREVIOUS_TAG}..${CURRENT_TAG} --pretty=format:"- %s" --no-merges) + else + CHANGES=$(git log ${CURRENT_TAG} --pretty=format:"- %s" --no-merges) + fi + + # Экранируем для использования в GitHub Actions + CHANGES="${CHANGES//'%'/'%25'}" + CHANGES="${CHANGES//$'\n'/'%0A'}" + CHANGES="${CHANGES//$'\r'/'%0D'}" + + echo "changes=$CHANGES" >> $GITHUB_OUTPUT + + - name: Set version + run: | + version=$(echo "${GITHUB_REF##*/}" | sed 's/^v//') + echo "Version - $version" + echo "VERSION=$version" >> $GITHUB_ENV + + - name: Prepare for install + run: | + apt-get update + + - name: Build alr + env: + IGNORE_ROOT_CHECK: 1 + run: | + make build + + - name: Create tar.gz + run: | + mkdir -p ./out/completion + cp alr ./out + cp scripts/completion/bash ./out/completion/alr + cp scripts/completion/zsh ./out/completion/_alr + + ( cd out && tar -czvf ../alr-${{ env.VERSION }}-linux-x86_64.tar.gz * ) + + - name: Create Release via GitVerse API + env: + GITVERSE_TOKEN: ${{ secrets.GITVERSE_TOKEN }} + run: | + TAG_NAME=${GITHUB_REF##*/} + RELEASE_NAME="ALR ${{ env.VERSION }}" + BODY="${{ steps.changes.outputs.changes }}" + + # Создаём релиз через GitVerse API + RELEASE_RESPONSE=$(curl -s -X POST \ + "https://gitverse.ru/api/v1/repos/${{ gitverse.repository_owner }}/${{ gitverse.repository }}/releases" \ + -H "Authorization: token ${GITVERSE_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{ + \"tag_name\": \"${TAG_NAME}\", + \"name\": \"${RELEASE_NAME}\", + \"body\": \"${BODY}\", + \"draft\": false, + \"prerelease\": false + }") + + RELEASE_ID=$(echo "$RELEASE_RESPONSE" | jq -r '.id') + echo "RELEASE_ID=$RELEASE_ID" >> $GITHUB_ENV + echo "Created release with ID: $RELEASE_ID" + + - name: Upload tar.gz asset + env: + GITVERSE_TOKEN: ${{ secrets.GITVERSE_TOKEN }} + run: | + curl -s -X POST \ + "https://gitverse.ru/api/v1/repos/${{ gitverse.repository_owner }}/${{ gitverse.repository }}/releases/${{ env.RELEASE_ID }}/assets?name=alr-${{ env.VERSION }}-linux-x86_64.tar.gz" \ + -H "Authorization: token ${GITVERSE_TOKEN}" \ + -H "Content-Type: application/octet-stream" \ + --data-binary "@alr-${{ env.VERSION }}-linux-x86_64.tar.gz" + + - name: Checkout alr-default repository + uses: actions/checkout@v4 + with: + repository: ${{ gitverse.repository_owner }}/alr-default + token: ${{ secrets.GITVERSE_PUBLIC_TOKEN }} + path: alr-default + + - name: Calculate checksum + run: | + # Вычисляем SHA256 контрольную сумму архива + CHECKSUM=$(sha256sum alr-${{ env.VERSION }}-linux-x86_64.tar.gz | awk '{print $1}') + echo "Archive checksum: $CHECKSUM" + echo "CHECKSUM=$CHECKSUM" >> $GITHUB_ENV + + - name: Update version and checksum in alr-bin + run: | + # Обновляем версию + sed -i "s/version='[0-9]\+\.[0-9]\+\.[0-9]\+'/version='${{ env.VERSION }}'/g" alr-default/alr-bin/alr.sh + sed -i "s/release='[0-9]\+'/release='1'/g" alr-default/alr-bin/alr.sh + + # Обновляем контрольную сумму + sed -i "s/checksums=('[^']*')/checksums=('${{ env.CHECKSUM }}')/g" alr-default/alr-bin/alr.sh + + - name: Commit and push changes to alr-default + run: | + cd alr-default + git config user.name "gitverse" + git config user.email "admin@plemya-x.ru" + git add alr-bin/alr.sh + git commit -m "Обновление alr-bin до версии ${{ env.VERSION }}" + git push + + - name: Install alr + env: + CREATE_SYSTEM_RESOURCES: 0 + run: | + make install + + - name: Prepare directories for ALR + run: | + # Создаём необходимые директории для работы alr build + mkdir -p /tmp/alr/dl /tmp/alr/pkgs /var/cache/alr + chmod -R 777 /tmp/alr + chmod -R 755 /var/cache/alr + + - name: Build packages + run: | + SCRIPT_PATH=alr-default/alr-bin/alr.sh + ALR_DISTRO=altlinux ALR_PKG_FORMAT=rpm alr build -s "$SCRIPT_PATH" + ALR_PKG_FORMAT=rpm alr build -s "$SCRIPT_PATH" + ALR_PKG_FORMAT=deb alr build -s "$SCRIPT_PATH" + ALR_PKG_FORMAT=archlinux alr build -s "$SCRIPT_PATH" + + - name: Upload package assets + env: + GITVERSE_TOKEN: ${{ secrets.GITVERSE_TOKEN }} + run: | + # Загружаем все собранные пакеты + for file in alr-bin*.deb alr-bin*.rpm alr-bin*.pkg.tar.zst; do + if [ -f "$file" ]; then + echo "Uploading $file..." + curl -s -X POST \ + "https://gitverse.ru/api/v1/repos/${{ gitverse.repository_owner }}/${{ gitverse.repository }}/releases/${{ env.RELEASE_ID }}/assets?name=${file}" \ + -H "Authorization: token ${GITVERSE_TOKEN}" \ + -H "Content-Type: application/octet-stream" \ + --data-binary "@${file}" + fi + done diff --git a/AUTHORS b/AUTHORS index e11401e..ba25ae0 100644 --- a/AUTHORS +++ b/AUTHORS @@ -1,2 +1 @@ -Евгений Храмов -Maxim Slipenko \ No newline at end of file +Евгений Храмов \ No newline at end of file diff --git a/e2e-tests/common_test.go b/e2e-tests/common_test.go index 8221663..41ab638 100644 --- a/e2e-tests/common_test.go +++ b/e2e-tests/common_test.go @@ -30,19 +30,21 @@ var ALL_SYSTEMS []string = []string{ "ubuntu-24.04", "alt-sisyphus", "fedora-41", - // "archlinux", - // "alpine", - // "opensuse-leap", - // "redos-8", + "archlinux", + "alpine", + "opensuse-leap", } var AUTOREQ_AUTOPROV_SYSTEMS []string = []string{ - // "alt-sisyphus", + "alt-sisyphus", "fedora-41", + "opensuse-leap", } var RPM_SYSTEMS []string = []string{ + "alt-sisyphus", "fedora-41", + "opensuse-leap", } var COMMON_SYSTEMS []string = []string{ diff --git a/internal/build/build.go b/internal/build/build.go index ea76127..9caa8a0 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -596,22 +596,73 @@ func (b *Builder) BuildALRDeps( return nil, nil, fmt.Errorf("failed to sort dependencies: %w", err) } + // Шаг 2.5: Фильтруем уже установленные пакеты + // Собираем пакеты вместе с их ключами (именами поиска) + type pkgWithKey struct { + key string + pkg alrsh.Package + } + var allPkgsWithKeys []pkgWithKey + for key, node := range depTree { + if node.Package != nil { + allPkgsWithKeys = append(allPkgsWithKeys, pkgWithKey{key: key, pkg: *node.Package}) + } + } + + var allPkgs []alrsh.Package + for _, p := range allPkgsWithKeys { + allPkgs = append(allPkgs, p.pkg) + } + + slog.Info("DEBUG: allPkgs count", "count", len(allPkgs)) + for _, p := range allPkgsWithKeys { + slog.Info("DEBUG: package in depTree", "key", p.key, "name", p.pkg.Name, "repo", p.pkg.Repository) + } + + needBuildPkgs, err := b.installerExecutor.FilterPackagesByVersion(ctx, allPkgs, input.OSRelease()) + if err != nil { + return nil, nil, fmt.Errorf("failed to filter packages: %w", err) + } + + // Создаём множество имён пакетов, которые нужно собрать + needBuildNames := make(map[string]bool) + for _, pkg := range needBuildPkgs { + needBuildNames[pkg.Name] = true + } + + slog.Info("DEBUG: needBuildPkgs count", "count", len(needBuildPkgs)) + for _, pkg := range needBuildPkgs { + slog.Info("DEBUG: package needs build", "name", pkg.Name) + } + + // Строим needBuildSet по КЛЮЧАМ depTree, а не по pkg.Name + // Это важно, т.к. ключ может быть именем из Provides (python3-pyside6), + // а pkg.Name - фактическое имя пакета (python3-shiboken6) + needBuildSet := make(map[string]bool) + for _, p := range allPkgsWithKeys { + if needBuildNames[p.pkg.Name] { + needBuildSet[p.key] = true + } + } + // Шаг 3: Группируем подпакеты по basePkgName для оптимизации сборки // Если несколько подпакетов из одного мультипакета, собираем их вместе - builtBasePkgs := make(map[string]bool) // Уже собранные базовые пакеты + slog.Info("DEBUG: sortedPkgs", "pkgs", sortedPkgs) // Шаг 4: Собираем пакеты в правильном порядке, проверяя кеш for _, pkgName := range sortedPkgs { node := depTree[pkgName] if node == nil { + slog.Info("DEBUG: node is nil", "pkgName", pkgName) continue } pkg := node.Package basePkgName := node.BasePkgName - // Если базовый пакет уже собран, пропускаем - if builtBasePkgs[basePkgName] { + // Пропускаем уже установленные пакеты + if !needBuildSet[pkgName] { + slog.Info("DEBUG: skipping (not in needBuildSet)", "pkgName", pkgName) continue } @@ -636,10 +687,13 @@ func (b *Builder) BuildALRDeps( if allInCache { // Подпакет в кеше, используем его + slog.Info("DEBUG: using cached package", "pkgName", pkgName) buildDeps = append(buildDeps, cachedDeps...) continue } + slog.Info("DEBUG: building package", "pkgName", pkgName) + // Собираем только запрошенный подпакет // SkipDepsBuilding: true предотвращает рекурсивный вызов BuildALRDeps res, err := b.BuildPackageFromDb( @@ -660,7 +714,6 @@ func (b *Builder) BuildALRDeps( } buildDeps = append(buildDeps, res...) - builtBasePkgs[basePkgName] = true } buildDeps = removeDuplicates(buildDeps) @@ -846,8 +899,9 @@ func (i *Builder) InstallPkgs( if err != nil { return nil, err } - - // Отслеживание установки пакетов из репозитория + + _ = i.installerExecutor.CheckVersionsAfterInstall(ctx, repoDeps) + for _, pkg := range repoDeps { if stats.ShouldTrackPackage(pkg) { stats.TrackInstallation(ctx, pkg, "install") diff --git a/internal/build/dependency_tree.go b/internal/build/dependency_tree.go index c00efaa..9c505a7 100644 --- a/internal/build/dependency_tree.go +++ b/internal/build/dependency_tree.go @@ -20,6 +20,7 @@ import ( "context" "fmt" + "gitea.plemya-x.ru/Plemya-x/ALR/internal/overrides" "gitea.plemya-x.ru/Plemya-x/ALR/pkg/alrsh" ) @@ -44,7 +45,12 @@ func (b *Builder) ResolveDependencyTree( ) (map[string]*DependencyNode, []string, error) { resolved := make(map[string]*DependencyNode) visited := make(map[string]bool) - systemDeps := make(map[string]bool) // Для дедупликации системных зависимостей + systemDeps := make(map[string]bool) + + overrideNames, err := overrides.Resolve(input.OSRelease(), overrides.DefaultOpts) + if err != nil { + return nil, nil, fmt.Errorf("failed to resolve overrides: %w", err) + } var resolve func(pkgNames []string) error resolve = func(pkgNames []string) error { @@ -77,20 +83,17 @@ func (b *Builder) ResolveDependencyTree( pkg := pkgList[0] - // Определяем базовое имя пакета + alrsh.ResolvePackage(&pkg, overrideNames) + baseName := pkg.BasePkgName if baseName == "" { baseName = pkg.Name } - // Используем имя конкретного подпакета как ключ (не basePkgName) - // Это позволяет собирать только запрошенный подпакет, а не весь мультипакет if resolved[pkgName] != nil { continue } - // Получаем зависимости для этого дистрибутива - // Пакет из БД уже содержит разрешенные значения для текущего дистрибутива deps := pkg.Depends.Resolved() buildDeps := pkg.BuildDepends.Resolved() diff --git a/internal/build/find_deps/find_deps.go b/internal/build/find_deps/find_deps.go index 3231b1e..5dc10ab 100644 --- a/internal/build/find_deps/find_deps.go +++ b/internal/build/find_deps/find_deps.go @@ -18,6 +18,8 @@ package finddeps import ( "context" + "os" + "os/exec" "github.com/goreleaser/nfpm/v2" @@ -39,10 +41,9 @@ func New(info *distro.OSRelease, pkgFormat string) *ProvReqService { finder: &EmptyFindProvReq{}, } if pkgFormat == "rpm" { - switch info.ID { - case "altlinux": + if _, err := os.Stat("/usr/lib/rpm/find-provides"); err == nil { s.finder = &ALTLinuxFindProvReq{} - case "fedora": + } else if _, err := exec.LookPath("/usr/lib/rpm/rpmdeps"); err == nil { s.finder = &FedoraFindProvReq{} } } diff --git a/internal/build/installer.go b/internal/build/installer.go index 571e586..75037dd 100644 --- a/internal/build/installer.go +++ b/internal/build/installer.go @@ -27,6 +27,7 @@ import ( "gitea.plemya-x.ru/Plemya-x/ALR/internal/manager" "gitea.plemya-x.ru/Plemya-x/ALR/internal/overrides" "gitea.plemya-x.ru/Plemya-x/ALR/pkg/alrsh" + "gitea.plemya-x.ru/Plemya-x/ALR/pkg/depver" "gitea.plemya-x.ru/Plemya-x/ALR/pkg/distro" ) @@ -43,7 +44,14 @@ func (i *Installer) InstallLocal(ctx context.Context, paths []string, opts *mana } func (i *Installer) Install(ctx context.Context, pkgs []string, opts *manager.Opts) error { - return i.mgr.Install(opts, pkgs...) + // Convert dependencies to manager-specific format + converted := make([]string, len(pkgs)) + for idx, pkg := range pkgs { + dep := depver.Parse(pkg) + converted[idx] = dep.ForManager(i.mgr.Name()) + } + + return i.mgr.Install(opts, converted...) } func (i *Installer) Remove(ctx context.Context, pkgs []string, opts *manager.Opts) error { @@ -54,19 +62,71 @@ func (i *Installer) RemoveAlreadyInstalled(ctx context.Context, pkgs []string) ( filteredPackages := []string{} for _, dep := range pkgs { - installed, err := i.mgr.IsInstalled(dep) + parsed := depver.Parse(dep) + + // Check if package is installed + installed, err := i.mgr.IsInstalled(parsed.Name) if err != nil { return nil, err } - if installed { + + if !installed { + filteredPackages = append(filteredPackages, dep) continue } - filteredPackages = append(filteredPackages, dep) + + // If there's a version constraint, check if installed version satisfies it + if parsed.HasVersionConstraint() { + installedVer, err := i.mgr.GetInstalledVersion(parsed.Name) + if err != nil { + return nil, err + } + + if !parsed.Satisfies(installedVer) { + // Installed version doesn't satisfy constraint - need to upgrade + slog.Debug("installed version doesn't satisfy constraint", + "package", parsed.Name, + "required", dep, + "installed", installedVer) + filteredPackages = append(filteredPackages, dep) + } + } } return filteredPackages, nil } +func (i *Installer) CheckVersionsAfterInstall(ctx context.Context, pkgs []string) error { + for _, pkg := range pkgs { + parsed := depver.Parse(pkg) + if !parsed.HasVersionConstraint() { + continue + } + + installedVer, err := i.mgr.GetInstalledVersion(parsed.Name) + if err != nil { + slog.Warn(gotext.Get("Failed to get installed version"), + "package", parsed.Name, + "error", err) + continue + } + + if installedVer == "" { + slog.Warn(gotext.Get("Package was not installed"), + "package", parsed.Name) + continue + } + + if !parsed.Satisfies(installedVer) { + slog.Warn(gotext.Get("Installed version doesn't satisfy requirement"), + "package", parsed.Name, + "required", pkg, + "installed", installedVer) + } + } + return nil +} + func (i *Installer) FilterPackagesByVersion(ctx context.Context, packages []alrsh.Package, osRelease *distro.OSRelease) ([]alrsh.Package, error) { installedPkgs, err := i.mgr.ListInstalled(nil) if err != nil { diff --git a/internal/build/plugins_executors.go b/internal/build/plugins_executors.go index 0ddad49..a9d8ec2 100644 --- a/internal/build/plugins_executors.go +++ b/internal/build/plugins_executors.go @@ -36,6 +36,7 @@ type InstallerExecutor interface { Remove(ctx context.Context, pkgs []string, opts *manager.Opts) error RemoveAlreadyInstalled(ctx context.Context, pkgs []string) ([]string, error) FilterPackagesByVersion(ctx context.Context, packages []alrsh.Package, osRelease *distro.OSRelease) ([]alrsh.Package, error) + CheckVersionsAfterInstall(ctx context.Context, pkgs []string) error } type ScriptExecutor interface { diff --git a/internal/build/plugins_executors_gen.go b/internal/build/plugins_executors_gen.go index 49bf3f4..7274e0f 100644 --- a/internal/build/plugins_executors_gen.go +++ b/internal/build/plugins_executors_gen.go @@ -238,6 +238,33 @@ func (s *InstallerExecutorRPCServer) FilterPackagesByVersion(args *InstallerExec return nil } +type InstallerExecutorCheckVersionsAfterInstallArgs struct { + Pkgs []string +} + +type InstallerExecutorCheckVersionsAfterInstallResp struct { +} + +func (s *InstallerExecutorRPC) CheckVersionsAfterInstall(ctx context.Context, pkgs []string) error { + var resp *InstallerExecutorCheckVersionsAfterInstallResp + err := s.client.Call("Plugin.CheckVersionsAfterInstall", &InstallerExecutorCheckVersionsAfterInstallArgs{ + Pkgs: pkgs, + }, &resp) + if err != nil { + return err + } + return nil +} + +func (s *InstallerExecutorRPCServer) CheckVersionsAfterInstall(args *InstallerExecutorCheckVersionsAfterInstallArgs, resp *InstallerExecutorCheckVersionsAfterInstallResp) error { + err := s.Impl.CheckVersionsAfterInstall(context.Background(), args.Pkgs) + if err != nil { + return err + } + *resp = InstallerExecutorCheckVersionsAfterInstallResp{} + return nil +} + type ScriptExecutorReadScriptArgs struct { ScriptPath string } diff --git a/internal/manager/apk.go b/internal/manager/apk.go index a562c5c..c809156 100644 --- a/internal/manager/apk.go +++ b/internal/manager/apk.go @@ -167,3 +167,33 @@ func (a *APK) IsInstalled(pkg string) (bool, error) { } return true, nil } + +func (a *APK) GetInstalledVersion(pkg string) (string, error) { + cmd := exec.Command("apk", "info", "--installed", pkg) + output, err := cmd.CombinedOutput() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + // Exit code 1 means the package is not installed + if exitErr.ExitCode() == 1 { + return "", nil + } + } + return "", fmt.Errorf("apk: getinstalledversion: %w, output: %s", err, output) + } + + // Output format: "package-version" (e.g., "curl-8.5.0-r0") + // We need to extract just the version part + line := strings.TrimSpace(string(output)) + if line == "" { + return "", nil + } + + // Find the last hyphen that separates name from version + // Alpine package names can contain hyphens, version starts after last one + lastDash := strings.LastIndex(line, "-") + if lastDash == -1 { + return "", nil + } + + return line[lastDash+1:], nil +} diff --git a/internal/manager/apt.go b/internal/manager/apt.go index b3ee7fe..72b1709 100644 --- a/internal/manager/apt.go +++ b/internal/manager/apt.go @@ -82,8 +82,15 @@ func (a *APT) InstallLocal(opts *Opts, pkgs ...string) error { func (a *APT) Remove(opts *Opts, pkgs ...string) error { opts = ensureOpts(opts) + + resolvedPkgs := make([]string, 0, len(pkgs)) + for _, pkg := range pkgs { + resolved := a.resolvePackageName(pkg) + resolvedPkgs = append(resolvedPkgs, resolved) + } + cmd := a.getCmd(opts, "apt", "remove") - cmd.Args = append(cmd.Args, pkgs...) + cmd.Args = append(cmd.Args, resolvedPkgs...) setCmdEnv(cmd) err := cmd.Run() if err != nil { @@ -92,6 +99,39 @@ func (a *APT) Remove(opts *Opts, pkgs ...string) error { return nil } +func (a *APT) resolvePackageName(pkg string) string { + cmd := exec.Command("dpkg-query", "-f", "${Status}", "-W", pkg) + output, err := cmd.Output() + if err == nil && strings.Contains(string(output), "install ok installed") { + return pkg + } + + cmd = exec.Command("dpkg-query", "-W", "-f", "${Package}\t${Provides}\n") + output, err = cmd.Output() + if err != nil { + return pkg + } + + for _, line := range strings.Split(string(output), "\n") { + parts := strings.SplitN(line, "\t", 2) + if len(parts) != 2 { + continue + } + pkgName := parts[0] + provides := parts[1] + + for _, p := range strings.Split(provides, ", ") { + p = strings.TrimSpace(p) + provName := strings.Split(p, " ")[0] + if provName == pkg { + return pkgName + } + } + } + + return pkg +} + func (a *APT) Upgrade(opts *Opts, pkgs ...string) error { opts = ensureOpts(opts) return a.Install(opts, pkgs...) @@ -140,11 +180,11 @@ func (a *APT) ListInstalled(opts *Opts) (map[string]string, error) { } func (a *APT) IsInstalled(pkg string) (bool, error) { - cmd := exec.Command("dpkg-query", "-f", "${Status}", "-W", pkg) + resolved := a.resolvePackageName(pkg) + cmd := exec.Command("dpkg-query", "-f", "${Status}", "-W", resolved) output, err := cmd.CombinedOutput() if err != nil { if exitErr, ok := err.(*exec.ExitError); ok { - // Код выхода 1 означает что пакет не найден if exitErr.ExitCode() == 1 { return false, nil } @@ -153,6 +193,21 @@ func (a *APT) IsInstalled(pkg string) (bool, error) { } status := strings.TrimSpace(string(output)) - // Проверяем что пакет действительно установлен (статус должен содержать "install ok installed") return strings.Contains(status, "install ok installed"), nil } + +func (a *APT) GetInstalledVersion(pkg string) (string, error) { + resolved := a.resolvePackageName(pkg) + cmd := exec.Command("dpkg-query", "-f", "${Version}", "-W", resolved) + output, err := cmd.CombinedOutput() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + if exitErr.ExitCode() == 1 { + return "", nil + } + } + return "", fmt.Errorf("apt: getinstalledversion: %w, output: %s", err, output) + } + + return strings.TrimSpace(string(output)), nil +} diff --git a/internal/manager/common_rpm.go b/internal/manager/common_rpm.go index f2ec654..c3920f2 100644 --- a/internal/manager/common_rpm.go +++ b/internal/manager/common_rpm.go @@ -70,3 +70,21 @@ func (a *CommonRPM) IsInstalled(pkg string) (bool, error) { } return true, nil } + +func (a *CommonRPM) GetInstalledVersion(pkg string) (string, error) { + cmd := exec.Command("rpm", "-q", "--queryformat", "%|EPOCH?{%{EPOCH}:}:{}|%{VERSION}-%{RELEASE}", pkg) + output, err := cmd.CombinedOutput() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + if exitErr.ExitCode() == 1 { + return "", nil + } + } + return "", fmt.Errorf("rpm: getinstalledversion: %w, output: %s", err, output) + } + + version := strings.TrimSpace(string(output)) + // Remove epoch 0: prefix if present + version = strings.TrimPrefix(version, "0:") + return version, nil +} diff --git a/internal/manager/manager_test.go b/internal/manager/manager_test.go new file mode 100644 index 0000000..f8ddefe --- /dev/null +++ b/internal/manager/manager_test.go @@ -0,0 +1,59 @@ +// ALR - Any Linux Repository +// Copyright (C) 2025 The ALR Authors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package manager + +import ( + "testing" +) + +func TestNewZypperReturnsCorrectType(t *testing.T) { + z := NewZypper() + if z == nil { + t.Fatal("NewZypper() returned nil") + } + if z.Name() != "zypper" { + t.Errorf("Expected name 'zypper', got '%s'", z.Name()) + } + if z.Format() != "rpm" { + t.Errorf("Expected format 'rpm', got '%s'", z.Format()) + } +} + +func TestManagersOrder(t *testing.T) { + // Проверяем, что APT-RPM идёт раньше APT в списке менеджеров + aptRpmIndex := -1 + aptIndex := -1 + + for i, m := range managers { + switch m.Name() { + case "apt-rpm": + aptRpmIndex = i + case "apt": + aptIndex = i + } + } + + if aptRpmIndex == -1 { + t.Fatal("APT-RPM not found in managers list") + } + if aptIndex == -1 { + t.Fatal("APT not found in managers list") + } + if aptRpmIndex >= aptIndex { + t.Errorf("APT-RPM (index %d) should come before APT (index %d)", aptRpmIndex, aptIndex) + } +} diff --git a/internal/manager/managers.go b/internal/manager/managers.go index 39bc691..648bae8 100644 --- a/internal/manager/managers.go +++ b/internal/manager/managers.go @@ -37,12 +37,12 @@ var DefaultOpts = &Opts{ var managers = []Manager{ NewPacman(), + NewAPTRpm(), // APT-RPM должен проверяться раньше APT, т.к. на ALT Linux есть оба NewAPT(), NewDNF(), NewYUM(), NewAPK(), NewZypper(), - NewAPTRpm(), } // Register registers a new package manager @@ -74,8 +74,11 @@ type Manager interface { UpgradeAll(*Opts) error // ListInstalled returns all installed packages mapped to their versions ListInstalled(*Opts) (map[string]string, error) - // + // IsInstalled checks if a package is installed IsInstalled(string) (bool, error) + // GetInstalledVersion returns the version of an installed package. + // Returns empty string and no error if package is not installed. + GetInstalledVersion(string) (string, error) } // Detect returns the package manager detected on the system diff --git a/internal/manager/pacman.go b/internal/manager/pacman.go index 3bdc72d..daaef00 100644 --- a/internal/manager/pacman.go +++ b/internal/manager/pacman.go @@ -160,3 +160,24 @@ func (p *Pacman) IsInstalled(pkg string) (bool, error) { } return true, nil } + +func (p *Pacman) GetInstalledVersion(pkg string) (string, error) { + cmd := exec.Command("pacman", "-Q", pkg) + output, err := cmd.CombinedOutput() + if err != nil { + // Pacman returns exit code 1 if the package is not found + if exitErr, ok := err.(*exec.ExitError); ok { + if exitErr.ExitCode() == 1 { + return "", nil + } + } + return "", fmt.Errorf("pacman: getinstalledversion: %w, output: %s", err, output) + } + + // Output format: "package-name version" + _, version, ok := strings.Cut(strings.TrimSpace(string(output)), " ") + if !ok { + return "", nil + } + return version, nil +} diff --git a/internal/manager/zypper.go b/internal/manager/zypper.go index a7de9e3..51de062 100644 --- a/internal/manager/zypper.go +++ b/internal/manager/zypper.go @@ -30,8 +30,8 @@ type Zypper struct { CommonRPM } -func NewZypper() *YUM { - return &YUM{ +func NewZypper() *Zypper { + return &Zypper{ CommonPackageManager: CommonPackageManager{ noConfirmArg: "-y", }, diff --git a/internal/repos/find.go b/internal/repos/find.go index fcbf0e7..56ee943 100644 --- a/internal/repos/find.go +++ b/internal/repos/find.go @@ -25,6 +25,7 @@ import ( "strings" "gitea.plemya-x.ru/Plemya-x/ALR/pkg/alrsh" + "gitea.plemya-x.ru/Plemya-x/ALR/pkg/depver" ) func (rs *Repos) FindPkgs(ctx context.Context, pkgs []string) (map[string][]alrsh.Package, []string, error) { @@ -36,39 +37,49 @@ func (rs *Repos) FindPkgs(ctx context.Context, pkgs []string) (map[string][]alrs continue } + // Parse version constraint from package name + dep := depver.Parse(pkgName) + searchName := dep.Name + var result []alrsh.Package var err error switch { - case strings.Contains(pkgName, "/"): + case strings.Contains(searchName, "/"): // repo/pkg - parts := strings.SplitN(pkgName, "/", 2) + parts := strings.SplitN(searchName, "/", 2) repo := parts[0] name := parts[1] result, err = rs.db.GetPkgs(ctx, "name = ? AND repository = ?", name, repo) - case strings.Contains(pkgName, "+"): + case strings.Contains(searchName, "+"): // pkg+repo - parts := strings.SplitN(pkgName, "+", 2) + parts := strings.SplitN(searchName, "+", 2) name := parts[0] repo := parts[1] result, err = rs.db.GetPkgs(ctx, "name = ? AND repository = ?", name, repo) default: - result, err = rs.db.GetPkgs(ctx, "json_array_contains(provides, ?)", pkgName) + // Сначала ищем по точному имени пакета + result, err = rs.db.GetPkgs(ctx, "name = ?", searchName) if err != nil { - return nil, nil, fmt.Errorf("FindPkgs: get by provides: %w", err) + return nil, nil, fmt.Errorf("FindPkgs: get by name: %w", err) } + // Затем по provides if len(result) == 0 { - result, err = rs.db.GetPkgs(ctx, "basepkg_name = ?", pkgName) + result, err = rs.db.GetPkgs(ctx, "json_array_contains(provides, ?)", searchName) if err != nil { - return nil, nil, fmt.Errorf("FindPkgs: get by basepkg_name: %w", err) + return nil, nil, fmt.Errorf("FindPkgs: get by provides: %w", err) } } + // В последнюю очередь по basepkg_name (для мультипакетов) if len(result) == 0 { - result, err = rs.db.GetPkgs(ctx, "name LIKE ?", pkgName) + result, err = rs.db.GetPkgs(ctx, "basepkg_name = ?", searchName) + if err != nil { + return nil, nil, fmt.Errorf("FindPkgs: get by basepkg_name: %w", err) + } } } @@ -76,6 +87,11 @@ func (rs *Repos) FindPkgs(ctx context.Context, pkgs []string) (map[string][]alrs return nil, nil, fmt.Errorf("FindPkgs: lookup for %q failed: %w", pkgName, err) } + // Filter by version if constraint is specified + if dep.HasVersionConstraint() && len(result) > 0 { + result = filterByVersion(result, dep) + } + if len(result) == 0 { notFound = append(notFound, pkgName) } else { @@ -85,3 +101,14 @@ func (rs *Repos) FindPkgs(ctx context.Context, pkgs []string) (map[string][]alrs return found, notFound, nil } + +// filterByVersion filters packages by version constraint. +func filterByVersion(pkgs []alrsh.Package, dep depver.Dependency) []alrsh.Package { + var filtered []alrsh.Package + for _, pkg := range pkgs { + if dep.Satisfies(pkg.Version) { + filtered = append(filtered, pkg) + } + } + return filtered +} diff --git a/internal/shutils/decoder/decoder.go b/internal/shutils/decoder/decoder.go index d61277b..29855d8 100644 --- a/internal/shutils/decoder/decoder.go +++ b/internal/shutils/decoder/decoder.go @@ -71,6 +71,10 @@ func New(info *distro.OSRelease, runner *interp.Runner) *Decoder { return &Decoder{info, runner, true, len(info.Like) > 0} } +func (d *Decoder) Info() *distro.OSRelease { + return d.info +} + // DecodeVar decodes a variable to val using reflection. // Structs should use the "sh" struct tag. func (d *Decoder) DecodeVar(name string, val any) error { diff --git a/main.go b/main.go index ad3ca66..48c15ce 100644 --- a/main.go +++ b/main.go @@ -141,7 +141,6 @@ func setLogLevel(newLevel string) { func main() { logger.SetupDefault() - setLogLevel(os.Getenv("ALR_LOG_LEVEL")) translations.Setup() ctx := context.Background() @@ -154,6 +153,10 @@ func main() { os.Exit(1) } setLogLevel(cfg.LogLevel()) + // Переменная окружения имеет приоритет над конфигом + if envLevel := os.Getenv("ALR_LOG_LEVEL"); envLevel != "" { + setLogLevel(envLevel) + } ctx, cancel := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM) defer cancel() diff --git a/pkg/alrsh/alrsh.go b/pkg/alrsh/alrsh.go index f2237b9..e0dfeb8 100644 --- a/pkg/alrsh/alrsh.go +++ b/pkg/alrsh/alrsh.go @@ -20,6 +20,7 @@ import ( "context" "errors" "fmt" + "log/slog" "os" "path/filepath" "runtime" @@ -171,7 +172,25 @@ func (s *ScriptFile) createPackageFromMeta( return nil, err } - metaDecoder := decoder.New(&distro.OSRelease{}, metaRunner) + // DEBUG: Выводим что в metaRunner.Vars и dec.Runner.Vars для deps_debian + if depsDebianMeta, ok := metaRunner.Vars["deps_debian"]; ok { + slog.Info("DEBUG createPackageFromMeta: metaRunner.Vars[deps_debian]", "value", depsDebianMeta.String(), "list", depsDebianMeta.List) + } else { + slog.Info("DEBUG createPackageFromMeta: metaRunner.Vars[deps_debian] NOT FOUND") + } + if depsDebianParent, ok := dec.Runner.Vars["deps_debian"]; ok { + slog.Info("DEBUG createPackageFromMeta: parent Vars[deps_debian]", "value", depsDebianParent.String(), "list", depsDebianParent.List) + } + + // Сливаем переменные родительского runner'а с переменными мета-функции. + // Переменные мета-функции имеют приоритет (для случаев переопределения). + for name, val := range dec.Runner.Vars { + if _, exists := metaRunner.Vars[name]; !exists { + metaRunner.Vars[name] = val + } + } + + metaDecoder := decoder.New(dec.Info(), metaRunner) var vars Package if err := metaDecoder.DecodeVars(&vars); err != nil { diff --git a/pkg/depver/depver.go b/pkg/depver/depver.go new file mode 100644 index 0000000..0d6bcd7 --- /dev/null +++ b/pkg/depver/depver.go @@ -0,0 +1,137 @@ +// ALR - Any Linux Repository +// Copyright (C) 2025 The ALR Authors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +// Package depver provides parsing and comparison of versioned dependencies +// in PKGBUILD-style format (e.g., "gcc>=5.0", "openssl>=1.1.0"). +package depver + +import ( + "strings" + + "gitea.plemya-x.ru/xpamych/vercmp" +) + +// Operator represents a version comparison operator. +type Operator string + +const ( + OpNone Operator = "" // No version constraint + OpEq Operator = "=" // Equal to + OpGt Operator = ">" // Greater than + OpGe Operator = ">=" // Greater than or equal to + OpLt Operator = "<" // Less than + OpLe Operator = "<=" // Less than or equal to +) + +// Dependency represents a package dependency with optional version constraint. +type Dependency struct { + Name string // Package name (e.g., "gcc") + Operator Operator // Comparison operator (e.g., OpGe for ">=") + Version string // Version string (e.g., "5.0") +} + +// operators lists all supported operators in order of decreasing length +// (to ensure ">=" is matched before ">"). +var operators = []Operator{OpGe, OpLe, OpGt, OpLt, OpEq} + +// Parse parses a dependency string in PKGBUILD format. +// Examples: +// - "gcc>=5.0" -> Dependency{Name: "gcc", Operator: OpGe, Version: "5.0"} +// - "openssl" -> Dependency{Name: "openssl", Operator: OpNone, Version: ""} +// - "cmake>=3.10" -> Dependency{Name: "cmake", Operator: OpGe, Version: "3.10"} +func Parse(dep string) Dependency { + dep = strings.TrimSpace(dep) + if dep == "" { + return Dependency{} + } + + // Try each operator (longer ones first) + for _, op := range operators { + if idx := strings.Index(dep, string(op)); idx > 0 { + return Dependency{ + Name: strings.TrimSpace(dep[:idx]), + Operator: op, + Version: strings.TrimSpace(dep[idx+len(op):]), + } + } + } + + // No operator found - just a package name + return Dependency{ + Name: dep, + Operator: OpNone, + Version: "", + } +} + +// ParseMultiple parses multiple dependency strings. +func ParseMultiple(deps []string) []Dependency { + result := make([]Dependency, 0, len(deps)) + for _, dep := range deps { + if dep != "" { + result = append(result, Parse(dep)) + } + } + return result +} + +// String returns the dependency in PKGBUILD format. +func (d Dependency) String() string { + if d.Operator == OpNone || d.Version == "" { + return d.Name + } + return d.Name + string(d.Operator) + d.Version +} + +// Satisfies checks if the given version satisfies the dependency constraint. +// Returns true if: +// - The dependency has no version constraint (OpNone) +// - The installed version satisfies the operator/version requirement +func (d Dependency) Satisfies(installedVersion string) bool { + if d.Operator == OpNone || d.Version == "" { + return true + } + + if installedVersion == "" { + return false + } + + // vercmp.Compare returns: + // -1 if installedVersion < d.Version + // 0 if installedVersion == d.Version + // 1 if installedVersion > d.Version + cmp := vercmp.Compare(installedVersion, d.Version) + + switch d.Operator { + case OpEq: + return cmp == 0 + case OpGt: + return cmp > 0 + case OpGe: + return cmp >= 0 + case OpLt: + return cmp < 0 + case OpLe: + return cmp <= 0 + default: + return true + } +} + +// HasVersionConstraint returns true if the dependency has a version constraint. +func (d Dependency) HasVersionConstraint() bool { + return d.Operator != OpNone && d.Version != "" +} diff --git a/pkg/depver/depver_test.go b/pkg/depver/depver_test.go new file mode 100644 index 0000000..5279059 --- /dev/null +++ b/pkg/depver/depver_test.go @@ -0,0 +1,347 @@ +// ALR - Any Linux Repository +// Copyright (C) 2025 The ALR Authors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package depver + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParse(t *testing.T) { + tests := []struct { + name string + input string + expected Dependency + }{ + { + name: "simple package name", + input: "gcc", + expected: Dependency{ + Name: "gcc", + Operator: OpNone, + Version: "", + }, + }, + { + name: "greater or equal", + input: "gcc>=5.0", + expected: Dependency{ + Name: "gcc", + Operator: OpGe, + Version: "5.0", + }, + }, + { + name: "less or equal", + input: "openssl<=1.1.0", + expected: Dependency{ + Name: "openssl", + Operator: OpLe, + Version: "1.1.0", + }, + }, + { + name: "greater than", + input: "cmake>3.10", + expected: Dependency{ + Name: "cmake", + Operator: OpGt, + Version: "3.10", + }, + }, + { + name: "less than", + input: "python<4.0", + expected: Dependency{ + Name: "python", + Operator: OpLt, + Version: "4.0", + }, + }, + { + name: "equal", + input: "nodejs=18.0.0", + expected: Dependency{ + Name: "nodejs", + Operator: OpEq, + Version: "18.0.0", + }, + }, + { + name: "with spaces around", + input: " gcc>=5.0 ", + expected: Dependency{ + Name: "gcc", + Operator: OpGe, + Version: "5.0", + }, + }, + { + name: "complex version", + input: "glibc>=2.17-326", + expected: Dependency{ + Name: "glibc", + Operator: OpGe, + Version: "2.17-326", + }, + }, + { + name: "empty string", + input: "", + expected: Dependency{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := Parse(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestParseMultiple(t *testing.T) { + input := []string{"gcc>=5.0", "openssl", "cmake>=3.10", ""} + expected := []Dependency{ + {Name: "gcc", Operator: OpGe, Version: "5.0"}, + {Name: "openssl", Operator: OpNone, Version: ""}, + {Name: "cmake", Operator: OpGe, Version: "3.10"}, + } + + result := ParseMultiple(input) + assert.Equal(t, expected, result) +} + +func TestDependency_String(t *testing.T) { + tests := []struct { + name string + dep Dependency + expected string + }{ + { + name: "no version", + dep: Dependency{Name: "gcc", Operator: OpNone, Version: ""}, + expected: "gcc", + }, + { + name: "with version", + dep: Dependency{Name: "gcc", Operator: OpGe, Version: "5.0"}, + expected: "gcc>=5.0", + }, + { + name: "equal operator", + dep: Dependency{Name: "python", Operator: OpEq, Version: "3.11"}, + expected: "python=3.11", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.dep.String() + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestDependency_Satisfies(t *testing.T) { + tests := []struct { + name string + dep Dependency + installedVersion string + expected bool + }{ + { + name: "no constraint - always satisfied", + dep: Dependency{Name: "gcc", Operator: OpNone, Version: ""}, + installedVersion: "5.0", + expected: true, + }, + { + name: "ge - satisfied", + dep: Dependency{Name: "gcc", Operator: OpGe, Version: "5.0"}, + installedVersion: "5.0", + expected: true, + }, + { + name: "ge - greater version satisfied", + dep: Dependency{Name: "gcc", Operator: OpGe, Version: "5.0"}, + installedVersion: "6.0", + expected: true, + }, + { + name: "ge - not satisfied", + dep: Dependency{Name: "gcc", Operator: OpGe, Version: "5.0"}, + installedVersion: "4.9", + expected: false, + }, + { + name: "gt - satisfied", + dep: Dependency{Name: "gcc", Operator: OpGt, Version: "5.0"}, + installedVersion: "5.1", + expected: true, + }, + { + name: "gt - equal not satisfied", + dep: Dependency{Name: "gcc", Operator: OpGt, Version: "5.0"}, + installedVersion: "5.0", + expected: false, + }, + { + name: "le - satisfied", + dep: Dependency{Name: "gcc", Operator: OpLe, Version: "5.0"}, + installedVersion: "5.0", + expected: true, + }, + { + name: "le - lesser satisfied", + dep: Dependency{Name: "gcc", Operator: OpLe, Version: "5.0"}, + installedVersion: "4.9", + expected: true, + }, + { + name: "le - not satisfied", + dep: Dependency{Name: "gcc", Operator: OpLe, Version: "5.0"}, + installedVersion: "5.1", + expected: false, + }, + { + name: "lt - satisfied", + dep: Dependency{Name: "gcc", Operator: OpLt, Version: "5.0"}, + installedVersion: "4.9", + expected: true, + }, + { + name: "lt - equal not satisfied", + dep: Dependency{Name: "gcc", Operator: OpLt, Version: "5.0"}, + installedVersion: "5.0", + expected: false, + }, + { + name: "eq - satisfied", + dep: Dependency{Name: "gcc", Operator: OpEq, Version: "5.0"}, + installedVersion: "5.0", + expected: true, + }, + { + name: "eq - not satisfied", + dep: Dependency{Name: "gcc", Operator: OpEq, Version: "5.0"}, + installedVersion: "5.1", + expected: false, + }, + { + name: "empty installed version", + dep: Dependency{Name: "gcc", Operator: OpGe, Version: "5.0"}, + installedVersion: "", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.dep.Satisfies(tt.installedVersion) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestDependency_ForManager(t *testing.T) { + dep := Dependency{Name: "gcc", Operator: OpGe, Version: "5.0"} + + tests := []struct { + manager string + expected string + }{ + {"pacman", "gcc>=5.0"}, + {"apt", "gcc"}, + {"dnf", "gcc >= 5.0"}, + {"yum", "gcc >= 5.0"}, + {"apk", "gcc>=5.0"}, + {"zypper", "gcc >= 5.0"}, + {"apt-rpm", "gcc >= 5.0"}, + {"unknown", "gcc>=5.0"}, + } + + for _, tt := range tests { + t.Run(tt.manager, func(t *testing.T) { + result := dep.ForManager(tt.manager) + assert.Equal(t, tt.expected, result) + }) + } + + // Test without version constraint + depNoVersion := Dependency{Name: "gcc", Operator: OpNone, Version: ""} + for _, tt := range tests { + t.Run(tt.manager+"_no_version", func(t *testing.T) { + result := depNoVersion.ForManager(tt.manager) + assert.Equal(t, "gcc", result) + }) + } +} + +func TestDependency_ForNfpm(t *testing.T) { + dep := Dependency{Name: "gcc", Operator: OpGe, Version: "5.0"} + + tests := []struct { + format string + expected string + }{ + {"deb", "gcc (>= 5.0)"}, + {"rpm", "gcc >= 5.0"}, + {"apk", "gcc>=5.0"}, + {"archlinux", "gcc>=5.0"}, + {"unknown", "gcc>=5.0"}, + } + + for _, tt := range tests { + t.Run(tt.format, func(t *testing.T) { + result := dep.ForNfpm(tt.format) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestHasVersionConstraint(t *testing.T) { + tests := []struct { + name string + dep Dependency + expected bool + }{ + { + name: "has constraint", + dep: Dependency{Name: "gcc", Operator: OpGe, Version: "5.0"}, + expected: true, + }, + { + name: "no operator", + dep: Dependency{Name: "gcc", Operator: OpNone, Version: ""}, + expected: false, + }, + { + name: "operator but no version", + dep: Dependency{Name: "gcc", Operator: OpGe, Version: ""}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.dep.HasVersionConstraint() + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/pkg/depver/format.go b/pkg/depver/format.go new file mode 100644 index 0000000..061fa0c --- /dev/null +++ b/pkg/depver/format.go @@ -0,0 +1,132 @@ +// ALR - Any Linux Repository +// Copyright (C) 2025 The ALR Authors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package depver + +import "fmt" + +// ForManager formats the dependency for a specific package manager. +// Different package managers have different syntax for version constraints: +// +// pacman (Arch): "gcc>=5.0" (no changes) +// apt (Debian): "gcc" (version ignored for install command) +// dnf/yum (Fedora): "gcc >= 5.0" (with spaces) +// apk (Alpine): "gcc>=5.0" (no changes) +// zypper (openSUSE): "gcc >= 5.0" (with spaces) +// apt-rpm (ALT): "gcc >= 5.0" (with spaces) +func (d Dependency) ForManager(managerName string) string { + if d.Name == "" { + return "" + } + + // No version constraint - just return the name + if d.Operator == OpNone || d.Version == "" { + return d.Name + } + + switch managerName { + case "apt": + // APT doesn't support version constraints in 'apt install' command + // Versions are checked after installation + return d.Name + + case "pacman": + // Pacman uses PKGBUILD-style: package>=version (no spaces) + return fmt.Sprintf("%s%s%s", d.Name, d.Operator, d.Version) + + case "apk": + // Alpine APK uses similar syntax to pacman + return fmt.Sprintf("%s%s%s", d.Name, d.Operator, d.Version) + + case "dnf", "yum": + // DNF/YUM use RPM-style: "package >= version" (with spaces) + return fmt.Sprintf("%s %s %s", d.Name, d.Operator, d.Version) + + case "zypper": + // Zypper uses RPM-style with spaces + return fmt.Sprintf("%s %s %s", d.Name, d.Operator, d.Version) + + case "apt-rpm": + // ALT Linux apt-rpm uses RPM-style + return fmt.Sprintf("%s %s %s", d.Name, d.Operator, d.Version) + + default: + // Default: PKGBUILD-style (no spaces) + return fmt.Sprintf("%s%s%s", d.Name, d.Operator, d.Version) + } +} + +// ForManagerMultiple formats multiple dependencies for a specific package manager. +func ForManagerMultiple(deps []Dependency, managerName string) []string { + result := make([]string, 0, len(deps)) + for _, dep := range deps { + if formatted := dep.ForManager(managerName); formatted != "" { + result = append(result, formatted) + } + } + return result +} + +// ForNfpm formats the dependency for nfpm package building. +// Different package formats have different dependency syntax: +// +// deb: "package (>= version)" +// rpm: "package >= version" +// apk: "package>=version" +// archlinux: "package>=version" +func (d Dependency) ForNfpm(pkgFormat string) string { + if d.Name == "" { + return "" + } + + // No version constraint - just return the name + if d.Operator == OpNone || d.Version == "" { + return d.Name + } + + switch pkgFormat { + case "deb": + // Debian uses: package (>= version) + return fmt.Sprintf("%s (%s %s)", d.Name, d.Operator, d.Version) + + case "rpm": + // RPM uses: package >= version + return fmt.Sprintf("%s %s %s", d.Name, d.Operator, d.Version) + + case "apk": + // Alpine uses: package>=version + return fmt.Sprintf("%s%s%s", d.Name, d.Operator, d.Version) + + case "archlinux": + // Arch uses: package>=version + return fmt.Sprintf("%s%s%s", d.Name, d.Operator, d.Version) + + default: + // Default: no spaces + return fmt.Sprintf("%s%s%s", d.Name, d.Operator, d.Version) + } +} + +// ForNfpmMultiple formats multiple dependencies for nfpm. +func ForNfpmMultiple(deps []Dependency, pkgFormat string) []string { + result := make([]string, 0, len(deps)) + for _, dep := range deps { + if formatted := dep.ForNfpm(pkgFormat); formatted != "" { + result = append(result, formatted) + } + } + return result +}