diff --git a/.gitignore b/.gitignore index e2060bb..c4a059b 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ node_modules /dist /nova.yaml +docs/README.md diff --git a/cmd/root.go b/cmd/root.go index a1ddfba..308fc65 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -49,6 +49,7 @@ func init() { findCmd, genConfigCmd, ) + rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "Config file to use. If empty, flags will be used instead") rootCmd.PersistentFlags().String("output-file", "", "Path on local filesystem to write file output to") err := viper.BindPFlag("output-file", rootCmd.PersistentFlags().Lookup("output-file")) @@ -92,6 +93,12 @@ func init() { klog.Exitf("Failed to bind context flag: %v", err) } + rootCmd.PersistentFlags().String("kubeconfig", "", "A path to a kubeconfig file.") + err = viper.BindPFlag("kubeconfig", rootCmd.PersistentFlags().Lookup("kubeconfig")) + if err != nil { + klog.Exitf("Failed to bind kubeconfig flag: %v", err) + } + rootCmd.PersistentFlags().Bool("wide", false, "Output chart name and namespace") err = viper.BindPFlag("wide", rootCmd.PersistentFlags().Lookup("wide")) if err != nil { @@ -247,6 +254,7 @@ var findCmd = &cobra.Command{ klog.V(5).Infof("All Keys: %v", viper.AllKeys()) kubeContext := viper.GetString("context") + kubeConfigPath := viper.GetString("kubeconfig") format := viper.GetString("format") if !(format == output.TableFormat || format == output.JSONFormat) { @@ -254,7 +262,7 @@ var findCmd = &cobra.Command{ } if viper.GetBool("helm") && viper.GetBool("containers") { - output, err := handleHelmAndContainers(kubeContext) + output, err := handleHelmAndContainers(kubeContext, kubeConfigPath) if err != nil { klog.Exit(err) } @@ -271,7 +279,7 @@ var findCmd = &cobra.Command{ } if viper.GetBool("containers") { - output, err := handleContainers(kubeContext) + output, err := handleContainers(kubeContext, kubeConfigPath) if err != nil { klog.Exit(err) } @@ -279,7 +287,7 @@ var findCmd = &cobra.Command{ return } - output, err := handleHelm(kubeContext) + output, err := handleHelm(kubeContext, kubeConfigPath) if err != nil { klog.Exit(err) } @@ -316,7 +324,7 @@ func Execute(VERSION, COMMIT string) { } } -func handleContainers(kubeContext string) (*output.ContainersOutput, error) { +func handleContainers(kubeContext, kubeConfigPath string) (*output.ContainersOutput, error) { // Set up a context we can use to cancel all operations to external container registries if we need to timeout := time.Duration(viper.GetUint16("timeout")) * time.Second ctx, cancel := context.WithTimeout(context.Background(), timeout) @@ -334,7 +342,7 @@ func handleContainers(kubeContext string) (*output.ContainersOutput, error) { case <-ctx.Done(): } }() - iClient := containers.NewClient(kubeContext) + iClient := containers.NewClient(kubeContext, kubeConfigPath) namespace := viper.GetString("namespace") if viper.IsSet("namespace") { klog.V(3).Infof("Scanning namespace %v", namespace) @@ -351,8 +359,8 @@ func handleContainers(kubeContext string) (*output.ContainersOutput, error) { return output.NewContainersOutput(containers.Images, containers.ErrImages, showNonSemver, showErrored, includeAll), nil } -func handleHelm(kubeContext string) (*output.Output, error) { - h := nova_helm.NewHelm(kubeContext) +func handleHelm(kubeContext, kubeConfigPath string) (*output.Output, error) { + h := nova_helm.NewHelm(kubeContext, kubeConfigPath) if viper.IsSet("desired-versions") { klog.V(3).Infof("desired-versions is set - attempting to load them") klog.V(8).Infof("raw desired-versions: %v", viper.Get("desired-versions")) @@ -414,12 +422,12 @@ func handleHelm(kubeContext string) (*output.Output, error) { return &out, nil } -func handleHelmAndContainers(kubeContext string) (*output.HelmAndContainersOutput, error) { - helmOutput, err := handleHelm(kubeContext) +func handleHelmAndContainers(kubeContext, kubeConfigPath string) (*output.HelmAndContainersOutput, error) { + helmOutput, err := handleHelm(kubeContext, kubeConfigPath) if err != nil { return nil, err } - containersOutput, err := handleContainers(kubeContext) + containersOutput, err := handleContainers(kubeContext, kubeConfigPath) if err != nil { return nil, err } diff --git a/pkg/containers/images.go b/pkg/containers/images.go index dbe32cf..0f38ca0 100644 --- a/pkg/containers/images.go +++ b/pkg/containers/images.go @@ -93,9 +93,9 @@ type Tag struct { } // NewClient is a constructor to create a new Client -func NewClient(kubeContext string) *Client { +func NewClient(kubeContext, kubeConfigPath string) *Client { return &Client{ - Kube: kube.GetConfigInstance(kubeContext), + Kube: kube.GetConfigInstance(kubeContext, kubeConfigPath), } } diff --git a/pkg/containers/images_test.go b/pkg/containers/images_test.go index c0c8168..e0338bc 100644 --- a/pkg/containers/images_test.go +++ b/pkg/containers/images_test.go @@ -98,7 +98,7 @@ func TestGetContainerImages(t *testing.T) { if err != nil { t.Error(err) } - var obj map[string]interface{} + var obj map[string]any err = json.Unmarshal(b, &obj) if err != nil { t.Error(err) @@ -106,7 +106,7 @@ func TestGetContainerImages(t *testing.T) { fakeTopControllerGetter := func(ns string) ([]controller.Workload, error) { return []controller.Workload{ { - TopController: unstructured.Unstructured{Object: map[string]interface{}{"kind": "Deployment", "metadata": map[string]interface{}{"name": "name", "namespace": "my-namespace"}}}, + TopController: unstructured.Unstructured{Object: map[string]any{"kind": "Deployment", "metadata": map[string]any{"name": "name", "namespace": "my-namespace"}}}, Pods: []unstructured.Unstructured{{Object: obj}}, }, }, nil diff --git a/pkg/helm/artifacthub.go b/pkg/helm/artifacthub.go index 6e3046f..75c30fa 100644 --- a/pkg/helm/artifacthub.go +++ b/pkg/helm/artifacthub.go @@ -110,6 +110,7 @@ type ArtifactHubHelmPackage struct { AvailableVersions []AvailableVersion `json:"available_versions"` Maintainers []Maintainer `json:"maintainers"` Links []Link `json:"links"` + Stars int `json:"stars"` } // AvailableVersion is a sub struct of ArtifactHubHelmPackage and provides a version that is available for a given helm chart. diff --git a/pkg/helm/artifacthub_cached.go b/pkg/helm/artifacthub_cached.go index 02c8160..e9803de 100644 --- a/pkg/helm/artifacthub_cached.go +++ b/pkg/helm/artifacthub_cached.go @@ -61,6 +61,7 @@ type ArtifactHubCachedPackage struct { Links []Link `json:"links"` Maintainers []Maintainer `json:"maintainers"` Deprecated bool `json:"deprecated"` + Stars int `json:"stars"` } // ArtifactHubCachedRepository is a sub-struct of the Package struct, and represents the repository containing the package. @@ -126,6 +127,7 @@ func (ac *ArtifactHubCachedPackageClient) List() ([]ArtifactHubHelmPackage, erro HomeURL: cachedPackage.HomeURL, Links: cachedPackage.Links, Official: cachedPackage.Official, + Stars: cachedPackage.Stars, Repository: ArtifactHubRepository{ Name: cachedPackage.Repository.Name, URL: cachedPackage.Repository.URL, diff --git a/pkg/helm/cluster.go b/pkg/helm/cluster.go index e68011c..5a657d3 100644 --- a/pkg/helm/cluster.go +++ b/pkg/helm/cluster.go @@ -16,6 +16,7 @@ package helm import ( "fmt" + "slices" "github.com/fairwindsops/nova/pkg/kube" "github.com/fairwindsops/nova/pkg/output" @@ -39,9 +40,9 @@ type DesiredVersion struct { } // NewHelm returns a basic helm struct with the version of helm requested -func NewHelm(kubeContext string) *Helm { +func NewHelm(kubeContext, kubeConfigPath string) *Helm { return &Helm{ - Kube: kube.GetConfigInstance(kubeContext), + Kube: kube.GetConfigInstance(kubeContext, kubeConfigPath), } } @@ -96,11 +97,8 @@ func filterIgnoredReleases(deployed []*release.Release, releaseIgnoreList []stri for _, release := range deployed { isIgnoredRelease := false isIgnoredChart := false - for _, ignoreListedRelease := range releaseIgnoreList { - if release.Name == ignoreListedRelease { - isIgnoredRelease = true - break - } + if slices.Contains(releaseIgnoreList, release.Name) { + isIgnoredRelease = true } for _, ignoreListedChart := range chartIgnoreList { // Check for nil to avoid a potential nil pointer exception diff --git a/pkg/helm/findscore.go b/pkg/helm/findscore.go index 30dd509..005199c 100644 --- a/pkg/helm/findscore.go +++ b/pkg/helm/findscore.go @@ -15,6 +15,7 @@ package helm import ( + "slices" "strings" "github.com/fairwindsops/nova/pkg/output" @@ -23,22 +24,56 @@ import ( "k8s.io/klog/v2" ) +const useStarCountThreshold = 10 + +type packageKey struct { + Name string + Repository string +} + // FindBestArtifactHubMatch takes the helm releases found in the cluster and attempts to match those to a package in artifacthub func FindBestArtifactHubMatch(clusterRelease *release.Release, ahubPackages []ArtifactHubHelmPackage) *output.ReleaseOutput { - var highScore int - var highScorePackage ArtifactHubHelmPackage + packagesByName := map[packageKey]ArtifactHubHelmPackage{} + packageScores := map[packageKey]float32{} + packageStars := map[packageKey]int{} + var useStars bool for _, p := range ahubPackages { - score := 0 if p.Name != clusterRelease.Chart.Metadata.Name { continue } - score = scoreChartSimilarity(clusterRelease, p) + + key := packageKey{Name: p.Name, Repository: p.Repository.Name} + packageScores[key] = scoreChartSimilarity(clusterRelease, p) + packagesByName[key] = p + packageStars[key] = p.Stars + + if p.Stars >= useStarCountThreshold { + useStars = true // If any package has more than 10 stars, we add a point to the highest star package + } + } + + var highestStarPackage packageKey + var highStars int + for p, stars := range packageStars { + if stars > highStars { + highStars = stars + highestStarPackage = p + } + } + + var highScore float32 + var highScorePackage ArtifactHubHelmPackage + for k, score := range packageScores { + if useStars && highStars > 0 && k == highestStarPackage { + klog.V(10).Infof("adding a point to the highest star package: %s:%s", k.Repository, k.Name) + score++ // Add a point to the highest star package + } if score > highScore { highScore = score - highScorePackage = p + highScorePackage = packagesByName[k] } } - klog.V(10).Infof("highScore for '%s': %d, highScorePackage Repo: %s", clusterRelease.Chart.Metadata.Name, highScore, highScorePackage.Repository.Name) + klog.V(10).Infof("highScore for '%s': %f, highScorePackage Repo: %s", clusterRelease.Chart.Metadata.Name, highScore, highScorePackage.Repository.Name) return prepareOutput(clusterRelease, highScorePackage) } @@ -98,14 +133,12 @@ func prepareOutput(release *release.Release, pkg ArtifactHubHelmPackage) *output } } -func scoreChartSimilarity(release *release.Release, pkg ArtifactHubHelmPackage) int { - ret := 0 - var preferredRepositories = []string{ - "bitnami", - "fairwinds-stable", - "ingress-nginx", - "cert-manager", - } +var preferredRepositories = []string{"bitnami", "fairwinds-stable", "fairwinds-incubator", "ingress-nginx", "cert-manager", "projectcalico", + "grafana", "prometheus-community", "elastic", "hashicorp", "argo", "metrics-server", "gitlab", "jenkins", "harbor", "minio", "cluster-autoscaler", + "aws-ebs-csi-driver", "coredns", "datadog", "deliveryhero", "falcosecurity", "kedacore", "kured", "oauth2-proxy", "rimusz"} + +func scoreChartSimilarity(release *release.Release, pkg ArtifactHubHelmPackage) float32 { + var ret float32 if release.Chart.Metadata.Home == pkg.HomeURL { klog.V(10).Infof("+1 score for %s Home URL (ahub package repo %s)", release.Chart.Metadata.Name, pkg.Repository.Name) ret++ @@ -153,10 +186,10 @@ func scoreChartSimilarity(release *release.Release, pkg ArtifactHubHelmPackage) ret++ } if containsString(preferredRepositories, pkg.Repository.Name) { - klog.V(10).Infof("+1 score for %s, preffered repo (ahub package repo %s)", release.Chart.Metadata.Name, pkg.Repository.Name) - ret++ + klog.V(10).Infof("+1.5 score for %s, preferred repo (ahub package repo %s)", release.Chart.Metadata.Name, pkg.Repository.Name) + ret += 1.5 } - klog.V(10).Infof("calculated score repo: %s, release: %s, score: %d\n\n", pkg.Repository.Name, release.Name, ret) + klog.V(10).Infof("calculated score repo: %s, release: %s, stars: %d, score: %f\n\n", pkg.Repository.Name, release.Name, pkg.Stars, ret) return ret } @@ -170,12 +203,7 @@ func clusterVersionExistsInPackage(clusterVersion string, pkg ArtifactHubHelmPac } func containsString(arr []string, val string) bool { - for _, item := range arr { - if item == val { - return true - } - } - return false + return slices.Contains(arr, val) } // IsValidRelease returns a bool indicating whether a version string is valid or not. diff --git a/pkg/helm/findscore_test.go b/pkg/helm/findscore_test.go index b35b87c..2644ff5 100644 --- a/pkg/helm/findscore_test.go +++ b/pkg/helm/findscore_test.go @@ -103,13 +103,13 @@ func Test_scoreChartSimilarity(t *testing.T) { name string release *release.Release pkg ArtifactHubHelmPackage - want int + want float32 }{ { name: "highest score", release: helmRelease, pkg: ahubPackage, - want: 7, + want: 7.5, }, { name: "empty pkg struct", diff --git a/pkg/kube/kube.go b/pkg/kube/kube.go index ed5bda9..0d685a5 100644 --- a/pkg/kube/kube.go +++ b/pkg/kube/kube.go @@ -15,6 +15,7 @@ package kube import ( + "flag" "sync" "k8s.io/apimachinery/pkg/api/meta" @@ -45,19 +46,38 @@ var ( ) // GetConfigInstance returns a Kubernetes interface based on the current configuration -func GetConfigInstance(context string) *Connection { +func GetConfigInstance(context, kubeConfigPath string) *Connection { once.Do(func() { kubeClient = &Connection{ - Client: getKubeClient(context), - DynamicClient: getDynamicKubeClient(context), - RESTMapper: getRESTMapper(context), + Client: getKubeClient(context, kubeConfigPath), + DynamicClient: getDynamicKubeClient(context, kubeConfigPath), + RESTMapper: getRESTMapper(context, kubeConfigPath), } }) return kubeClient } -func getKubeClient(context string) kubernetes.Interface { - kubeConf, err := config.GetConfigWithContext(context) +// GetConfig returns a *rest.Config based on the current configuration +func GetConfig(context, kubeConfigPath string) (*rest.Config, error) { + + if context != "" { + klog.V(3).Infof("using kube context: %s", context) + } + + fs := flag.NewFlagSet("fs", flag.ContinueOnError) + fs.String("kubeconfig", kubeConfigPath, "") + config.RegisterFlags(fs) + + kubeConfig, err := config.GetConfigWithContext(context) + if err != nil { + return nil, err + } + + return kubeConfig, nil +} + +func getKubeClient(context, kubeConfigPath string) kubernetes.Interface { + kubeConf, err := GetConfig(context, kubeConfigPath) if err != nil { klog.Fatalf("error getting config with context %s: %v", context, err) } @@ -69,8 +89,8 @@ func getKubeClient(context string) kubernetes.Interface { return clientset } -func getDynamicKubeClient(context string) dynamic.Interface { - kubeConf, err := config.GetConfigWithContext(context) +func getDynamicKubeClient(context, kubeConfigPath string) dynamic.Interface { + kubeConf, err := GetConfig(context, kubeConfigPath) if err != nil { klog.Fatalf("error getting config with context %s: %v", context, err) } @@ -81,8 +101,8 @@ func getDynamicKubeClient(context string) dynamic.Interface { return dynamicClient } -func getRESTMapper(context string) meta.RESTMapper { - kubeConf, err := config.GetConfigWithContext(context) +func getRESTMapper(context, kubeConfigPath string) meta.RESTMapper { + kubeConf, err := GetConfig(context, kubeConfigPath) if err != nil { klog.Fatalf("error getting config with context %s: %v", context, err) }