From a7d0c22db146ab6b0942596f067cb6df2d21aa6f Mon Sep 17 00:00:00 2001 From: Aurora Gaffney Date: Sun, 25 Feb 2024 17:01:22 -0600 Subject: [PATCH] feat: dependency resolver Fixes #9 --- cmd/cardano-up/install.go | 18 +-- go.mod | 1 + go.sum | 2 + pkgmgr/error.go | 28 ++++- pkgmgr/package.go | 1 + pkgmgr/pkgmgr.go | 43 ++++++- pkgmgr/registry.go | 17 +++ pkgmgr/resolver.go | 248 ++++++++++++++++++++++++++++++++++++++ 8 files changed, 337 insertions(+), 21 deletions(-) create mode 100644 pkgmgr/resolver.go diff --git a/cmd/cardano-up/install.go b/cmd/cardano-up/install.go index 4b457c0..0dfd89f 100644 --- a/cmd/cardano-up/install.go +++ b/cmd/cardano-up/install.go @@ -82,23 +82,11 @@ func installCommand() *cobra.Command { ) os.Exit(1) } - packages := pm.AvailablePackages() - foundPackage := false - for _, tmpPackage := range packages { - if tmpPackage.Name == args[0] { - foundPackage = true - if err := pm.Install(tmpPackage); err != nil { - slog.Error(err.Error()) - os.Exit(1) - } - break - } - } - if !foundPackage { - slog.Error(fmt.Sprintf("no such package: %s", args[0])) + // Install requested package + if err := pm.Install(args[0]); err != nil { + slog.Error(err.Error()) os.Exit(1) } - slog.Info(fmt.Sprintf("Successfully installed package %s", args[0])) }, } installCmd.Flags().StringVarP(&installFlags.network, "network", "n", "", "specifies network for package") diff --git a/go.mod b/go.mod index b44f3b6..d68cda9 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.21 require ( github.com/docker/docker v25.0.3+incompatible github.com/docker/go-connections v0.5.0 + github.com/hashicorp/go-version v1.6.0 github.com/spf13/cobra v1.8.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index 00060bc..eb7156a 100644 --- a/go.sum +++ b/go.sum @@ -32,6 +32,8 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0 h1:Wqo399gCIufwto+VfwCSvsnfGpF/w5E9CNxSwbpD6No= github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0/go.mod h1:qmOFXW2epJhM0qSnUUYpldc7gVz2KMQwJ/QYCDIa7XU= +github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek= +github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= diff --git a/pkgmgr/error.go b/pkgmgr/error.go index 589fc90..09cd1f5 100644 --- a/pkgmgr/error.go +++ b/pkgmgr/error.go @@ -14,7 +14,10 @@ package pkgmgr -import "errors" +import ( + "errors" + "fmt" +) // ErrOperationFailed is a placeholder error for operations that directly log errors. // It's used to signify when an operation has failed when the actual error message is @@ -37,3 +40,26 @@ var ErrContextAlreadyExists = errors.New("specified context already exists") // ErrContainerAlreadyExists is returned when creating a new container with a name that is already in use var ErrContainerAlreadyExists = errors.New("specified container already exists") + +func NewResolverPackageAlreadyInstalledError(pkgName string) error { + return fmt.Errorf( + "the package %q is already installed in the current context\n\nYou can use 'cardano-up context create' to create an empty context to install another instance of the package", + pkgName, + ) +} + +func NewResolverNoAvailablePackageDependencyError(depSpec string) error { + return fmt.Errorf( + "no available package found for dependency: %s", + depSpec, + ) +} + +func NewResolverInstalledPackageNoMatchVersionSpecError(pkgName string, pkgVersion string, depSpec string) error { + return fmt.Errorf( + "installed package \"%s = %s\" does not match dependency: %s", + pkgName, + pkgVersion, + depSpec, + ) +} diff --git a/pkgmgr/package.go b/pkgmgr/package.go index fd84775..31f5774 100644 --- a/pkgmgr/package.go +++ b/pkgmgr/package.go @@ -26,6 +26,7 @@ type Package struct { Version string `yaml:"version"` Description string `yaml:"description"` InstallSteps []PackageInstallStep `yaml:"installSteps"` + Dependencies []string `yaml:"dependencies"` } func (p Package) install(cfg Config, context string) error { diff --git a/pkgmgr/pkgmgr.go b/pkgmgr/pkgmgr.go index 10fe130..a10df9e 100644 --- a/pkgmgr/pkgmgr.go +++ b/pkgmgr/pkgmgr.go @@ -83,22 +83,55 @@ func (p *PackageManager) AvailablePackages() []Package { } func (p *PackageManager) InstalledPackages() []InstalledPackage { + var ret []InstalledPackage + for _, pkg := range p.state.InstalledPackages { + if pkg.Context == p.state.ActiveContext { + ret = append(ret, pkg) + } + } + return ret +} + +func (p *PackageManager) InstalledPackagesAllContexts() []InstalledPackage { return p.state.InstalledPackages } -func (p *PackageManager) Install(pkg Package) error { - if err := pkg.install(p.config, p.state.ActiveContext); err != nil { +func (p *PackageManager) Install(pkgs ...string) error { + resolver, err := NewResolver( + p.InstalledPackages(), + p.AvailablePackages(), + p.config.Logger, + ) + if err != nil { return err } - installedPkg := NewInstalledPackage(pkg, p.state.ActiveContext) - p.state.InstalledPackages = append(p.state.InstalledPackages, installedPkg) - if err := p.state.Save(); err != nil { + installPkgs, err := resolver.Install(pkgs...) + if err != nil { return err } + for _, installPkg := range installPkgs { + if err := installPkg.install(p.config, p.state.ActiveContext); err != nil { + return err + } + installedPkg := NewInstalledPackage(installPkg, p.state.ActiveContext) + p.state.InstalledPackages = append(p.state.InstalledPackages, installedPkg) + if err := p.state.Save(); err != nil { + return err + } + p.config.Logger.Info( + fmt.Sprintf( + "Successfully installed package %s (= %s) in context %q", + installPkg.Name, + installPkg.Version, + p.state.ActiveContext, + ), + ) + } return nil } func (p *PackageManager) Uninstall(installedPkg InstalledPackage) error { + // TODO: resolve dependencies if err := installedPkg.Package.uninstall(p.config, installedPkg.Context); err != nil { return err } diff --git a/pkgmgr/registry.go b/pkgmgr/registry.go index 201e0c9..75dfbbf 100644 --- a/pkgmgr/registry.go +++ b/pkgmgr/registry.go @@ -65,4 +65,21 @@ docker run --rm -ti ghcr.io/blinklabs-io/mithril-client:0.5.17 $@ }, }, }, + + // Test packages + { + Name: "test-packageA", + Version: "1.0.2", + }, + { + Name: "test-packageA", + Version: "2.1.3", + }, + { + Name: "test-packageB", + Version: "0.1.0", + Dependencies: []string{ + "test-packageA < 2.0.0, >= 1.0.2", + }, + }, } diff --git a/pkgmgr/resolver.go b/pkgmgr/resolver.go new file mode 100644 index 0000000..e2df64b --- /dev/null +++ b/pkgmgr/resolver.go @@ -0,0 +1,248 @@ +// Copyright 2024 Blink Labs Software +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pkgmgr + +import ( + "fmt" + "log/slog" + "strings" + + "github.com/hashicorp/go-version" +) + +type Resolver struct { + logger *slog.Logger + installedPkgs []InstalledPackage + availablePkgs []Package + installedConstraints map[string]version.Constraints +} + +func NewResolver(installedPkgs []InstalledPackage, availablePkgs []Package, logger *slog.Logger) (*Resolver, error) { + r := &Resolver{ + logger: logger, + installedPkgs: installedPkgs[:], + availablePkgs: availablePkgs[:], + installedConstraints: make(map[string]version.Constraints), + } + // Calculate package constraints from installed packages + for _, installedPkg := range installedPkgs { + pkgName := installedPkg.Package.Name + // Add implicit constraint for other versions of the same package + tmpConstraints, err := version.NewConstraint( + fmt.Sprintf("= %s", installedPkg.Package.Version), + ) + if err != nil { + return nil, err + } + if _, ok := r.installedConstraints[pkgName]; !ok { + r.installedConstraints[pkgName] = make(version.Constraints, 0) + } + r.installedConstraints[pkgName] = append( + r.installedConstraints[pkgName], + tmpConstraints..., + ) + logger.Debug( + fmt.Sprintf( + "added constraint for installed package %q: %s", + pkgName, + tmpConstraints.String(), + ), + ) + // Add constraint for each explicit dependency + for _, dep := range installedPkg.Package.Dependencies { + depPkgName, depPkgVersionSpec := r.splitPackage(dep) + tmpConstraints, err := version.NewConstraint(depPkgVersionSpec) + if err != nil { + return nil, err + } + r.installedConstraints[depPkgName] = append( + r.installedConstraints[depPkgName], + tmpConstraints..., + ) + logger.Debug( + fmt.Sprintf( + "added constraint for installed package %q dependency: %q: %s", + pkgName, + depPkgName, + tmpConstraints.String(), + ), + ) + } + } + return r, nil +} + +func (r *Resolver) Install(pkgs ...string) ([]Package, error) { + var ret []Package + for _, pkg := range pkgs { + pkgName, pkgVersion := r.splitPackage(pkg) + if pkg, err := r.findInstalled(pkgName, ""); err != nil { + return nil, err + } else if !pkg.InstalledTime.IsZero() { + return nil, NewResolverPackageAlreadyInstalledError(pkgName) + } + availablePkgs, err := r.findAvailable(pkgName, pkgVersion) + if err != nil { + return nil, err + } + latestPkg, err := r.latestPackage(availablePkgs...) + if err != nil { + return nil, err + } + // Calculate dependencies + neededPkgs, err := r.getNeededDeps(latestPkg) + if err != nil { + return nil, err + } + ret = append(ret, neededPkgs...) + // Add selected package + ret = append(ret, latestPkg) + } + return ret, nil +} + +func (r *Resolver) getNeededDeps(pkg Package) ([]Package, error) { + // NOTE: this function is very naive and only works for a single level of dependencies + var ret []Package + for _, dep := range pkg.Dependencies { + depPkgName, depPkgVersionSpec := r.splitPackage(dep) + // Check if we already have an installed package that satisfies the dependency + if pkg, err := r.findInstalled(depPkgName, depPkgVersionSpec); err != nil { + return nil, err + } else if !pkg.InstalledTime.IsZero() { + continue + } + // Check if we already have any installed version of the package + if pkg, err := r.findInstalled(depPkgName, depPkgVersionSpec); err != nil { + return nil, err + } else if !pkg.InstalledTime.IsZero() { + return nil, NewResolverInstalledPackageNoMatchVersionSpecError(pkg.Package.Name, pkg.Package.Version, dep) + } + availablePkgs, err := r.findAvailable(depPkgName, depPkgVersionSpec) + if err != nil { + return nil, err + } + if len(availablePkgs) == 0 { + return nil, NewResolverNoAvailablePackageDependencyError(dep) + } + latestPkg, err := r.latestPackage(availablePkgs...) + if err != nil { + return nil, err + } + ret = append(ret, latestPkg) + } + return ret, nil +} + +func (r *Resolver) splitPackage(pkg string) (string, string) { + versionSpecIdx := strings.IndexAny(pkg, ` <>=~!`) + var pkgName, pkgVersionSpec string + if versionSpecIdx == -1 { + pkgName = pkg + } else { + pkgName = pkg[:versionSpecIdx] + pkgVersionSpec = strings.TrimSpace(pkg[versionSpecIdx:]) + } + return pkgName, pkgVersionSpec +} + +func (r *Resolver) findInstalled(pkgName string, pkgVersionSpec string) (InstalledPackage, error) { + var constraints version.Constraints + if pkgVersionSpec != "" { + tmpConstraints, err := version.NewConstraint(pkgVersionSpec) + if err != nil { + return InstalledPackage{}, err + } + constraints = tmpConstraints + } + for _, installedPkg := range r.installedPkgs { + if installedPkg.Package.Name != pkgName { + continue + } + if pkgVersionSpec != "" { + installedPkgVer, err := version.NewVersion(installedPkg.Package.Version) + if err != nil { + return InstalledPackage{}, err + } + if !constraints.Check(installedPkgVer) { + continue + } + } + return installedPkg, nil + } + return InstalledPackage{}, nil +} + +func (r *Resolver) findAvailable(pkgName string, pkgVersionSpec string) ([]Package, error) { + var constraints version.Constraints + // Filter to versions matching our version spec + if pkgVersionSpec != "" { + tmpConstraints, err := version.NewConstraint(pkgVersionSpec) + if err != nil { + return nil, err + } + constraints = tmpConstraints + } + // Filter to versions matching constraints from installed packages + if r.installedConstraints != nil { + if pkgConstraints, ok := r.installedConstraints[pkgName]; ok { + constraints = append( + constraints, + pkgConstraints..., + ) + } + } + var ret []Package + for _, availablePkg := range r.availablePkgs { + if availablePkg.Name != pkgName { + continue + } + if constraints != nil { + availablePkgVer, err := version.NewVersion(availablePkg.Version) + if err != nil { + return nil, err + } + if !constraints.Check(availablePkgVer) { + r.logger.Debug( + fmt.Sprintf( + "excluding available package \"%s = %s\" due to constraint: %s", + availablePkg.Name, + availablePkg.Version, + constraints.String(), + ), + ) + continue + } + } + ret = append(ret, availablePkg) + } + return ret, nil +} + +func (r *Resolver) latestPackage(pkgs ...Package) (Package, error) { + var ret Package + var retVer *version.Version + for _, pkg := range pkgs { + pkgVer, err := version.NewVersion(pkg.Version) + if err != nil { + return ret, err + } + if retVer == nil || pkgVer.GreaterThan(retVer) { + ret = pkg + retVer = pkgVer + } + } + return ret, nil +}