From a8aa8af3d332669560bcc9e55652424b61d34df2 Mon Sep 17 00:00:00 2001 From: Ryan D McGuire Date: Tue, 5 Dec 2023 16:56:47 -0500 Subject: [PATCH] Begin caching, implement commands --- cmd/cache.go | 67 +++++++---------- cmd/config.go | 31 -------- cmd/init.go | 76 +++++++++++++++++++ cmd/load.go | 33 +------- cmd/root.go | 96 ++++++++++++++++++++---- go.mod | 10 +++ go.sum | 33 ++++++++ internal/config/config.go | 16 ++++ internal/gitlab/gitlab.go | 32 ++++++++ internal/projects/cache.go | 137 ++++++++++++++++++++++++++++++++++ main.go | 21 ------ pkg/gitlab/gitlab.go | 18 +++++ pkg/manager/manager.go | 31 -------- pkg/manager/manager_config.go | 29 ------- 14 files changed, 432 insertions(+), 198 deletions(-) create mode 100644 cmd/init.go create mode 100644 internal/config/config.go create mode 100644 internal/gitlab/gitlab.go create mode 100644 internal/projects/cache.go create mode 100644 pkg/gitlab/gitlab.go delete mode 100644 pkg/manager/manager.go delete mode 100644 pkg/manager/manager_config.go diff --git a/cmd/cache.go b/cmd/cache.go index 4ad29fa..bdcee8b 100644 --- a/cmd/cache.go +++ b/cmd/cache.go @@ -1,57 +1,40 @@ -/* -Copyright © 2023 Ryan McGuire - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. -*/ package cmd import ( - "fmt" + "time" "github.com/spf13/cobra" + "github.com/spf13/viper" + "gitlab.sweetwater.com/it/devops/tools/gitlab-project-manager/internal/projects" ) -// cacheCmd represents the cache command -var cacheCmd = &cobra.Command{ - Use: "cache", - Short: "A brief description of your command", - Long: `A longer description that spans multiple lines and likely contains examples -and usage of using your command. For example: +const desc = `Contains sub-commands for managing project cache. +The project cache keeps this speedy, without smashing against the GitLab +API every time a new project is added / searched for` -Cobra is a CLI library for Go that empowers applications. -This application is a tool to generate the needed files -to quickly create a Cobra application.`, - Run: func(cmd *cobra.Command, args []string) { - fmt.Println("cache called") - }, +var cache *projects.Cache + +var cacheCmd = &cobra.Command{ + Use: "cache", + Short: "Manage GitLab project cache", + Long: desc, + PersistentPreRun: initCacheCmd, + PersistentPostRun: postCacheCmd, +} + +func initCacheCmd(cmd *cobra.Command, args []string) { + initProjectCache(cmd, args) +} + +func postCacheCmd(cmd *cobra.Command, args []string) { + postProjectCache(cmd, args) } func init() { rootCmd.AddCommand(cacheCmd) - // Here you will define your flags and configuration settings. + cacheCmd.PersistentFlags().Duration("ttl", 48*time.Hour, + "Duration before cache is re-built in go time.Duration format") - // Cobra supports Persistent Flags which will work for this command - // and all subcommands, e.g.: - // cacheCmd.PersistentFlags().String("foo", "", "A help for foo") - - // Cobra supports local flags which will only run when this command - // is called directly, e.g.: - // cacheCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") + viper.BindPFlags(cacheCmd.Flags()) } diff --git a/cmd/config.go b/cmd/config.go index 1423357..503f194 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -1,24 +1,3 @@ -/* -Copyright © 2023 Ryan McGuire - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. -*/ package cmd import ( @@ -44,14 +23,4 @@ to quickly create a Cobra application.`, func init() { rootCmd.AddCommand(configCmd) - - // Here you will define your flags and configuration settings. - - // Cobra supports Persistent Flags which will work for this command - // and all subcommands, e.g.: - // configCmd.PersistentFlags().String("foo", "", "A help for foo") - - // Cobra supports local flags which will only run when this command - // is called directly, e.g.: - // configCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") } diff --git a/cmd/init.go b/cmd/init.go new file mode 100644 index 0000000..642256f --- /dev/null +++ b/cmd/init.go @@ -0,0 +1,76 @@ +package cmd + +import ( + "os" + "os/user" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + "gitlab.sweetwater.com/it/devops/tools/gitlab-project-manager/internal/projects" + "golang.org/x/exp/slog" + "golang.org/x/sys/unix" +) + +// This file contains init methods that may be used by +// multiple sub-commands. For instance, the cach and projects +// sub-commands both depend on a cache and may both call the initProjectCache +// func from their PersistentPreRun commands + +func initProjectCache(cmd *cobra.Command, args []string) { + slog.Debug("Running persistent pre-run for cacheCmd") + conf.Cache.File = conf.ProjectPath + "/.cache.json" + + var err error + cacheOpts := &projects.CacheOpts{ + Path: conf.Cache.File, + TTL: conf.Cache.Ttl, + Logger: slog.Default(), + } + if cache, err = projects.NewProjectCache(cacheOpts); err != nil { + slog.Error("Failed to prepare project cache", "error", err) + os.Exit(1) + } + + if err := cache.Load(); err != nil { + slog.Error("Cache load failed", "error", err) + os.Exit(1) + } +} + +func postProjectCache(cmd *cobra.Command, args []string) { + cache.Write() +} + +func initProjectPath(cmd *cobra.Command, args []string) { + slog.Debug("Running persistent pre-run for rootCmd") + var err error + if conf.ProjectPath, err = resolvePath(conf.ProjectPath); err != nil { + slog.Error("Failed to determine project path", "error", err) + os.Exit(1) + } + _, err = os.Stat(conf.ProjectPath) + if err != nil { + slog.Error("Failed to stat project path, trying to create", "error", err) + if err := os.Mkdir(conf.ProjectPath, 0750); err != nil { + slog.Error("Failed to create project path", "error", err) + os.Exit(1) + } + slog.Info("Project path created", "path", conf.ProjectPath) + } else { + if err = unix.Access(conf.ProjectPath, unix.W_OK); err != nil { + slog.Error("Unable to write to project path", + "path", conf.ProjectPath, + "error", err) + os.Exit(1) + } + } +} + +func resolvePath(path string) (string, error) { + if strings.HasPrefix(path, "~/") { + usr, _ := user.Current() + path = filepath.Join(usr.HomeDir, path[2:]) + } + return filepath.Abs(path) +} diff --git a/cmd/load.go b/cmd/load.go index 1cbf966..d0d2992 100644 --- a/cmd/load.go +++ b/cmd/load.go @@ -1,24 +1,3 @@ -/* -Copyright © 2023 Ryan McGuire - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. -*/ package cmd import ( @@ -30,13 +9,10 @@ import ( // loadCmd represents the load command var loadCmd = &cobra.Command{ Use: "load", - Short: "A brief description of your command", - Long: `A longer description that spans multiple lines and likely contains examples -and usage of using your command. For example: - -Cobra is a CLI library for Go that empowers applications. -This application is a tool to generate the needed files -to quickly create a Cobra application.`, + Short: "Load GitLab Project Cache", + Long: `Used to initialize or update a new GitLab cache. With thousands +of projects, it would be too much work to hit the API every time a user +wants to find a new project.`, Run: func(cmd *cobra.Command, args []string) { fmt.Println("load called") }, @@ -44,7 +20,6 @@ to quickly create a Cobra application.`, func init() { cacheCmd.AddCommand(loadCmd) - // Here you will define your flags and configuration settings. // Cobra supports Persistent Flags which will work for this command diff --git a/cmd/root.go b/cmd/root.go index e565bf4..0838afe 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,43 +1,69 @@ package cmd import ( + "context" "os" + "os/signal" + "strings" "github.com/spf13/cobra" "github.com/spf13/viper" + "gitlab.sweetwater.com/it/devops/tools/gitlab-project-manager/internal/config" "golang.org/x/exp/slog" ) const ( - defGitlabHost = "gitlab.sweetwater.com" + defGitlabHost = "gitlab.sweetwater.com" + defProjectsPath = "~/work/projects" + defLogLevel = "info" ) -// rootCmd represents the base command when called without any subcommands +var conf config.Config + var rootCmd = &cobra.Command{ Use: "gitlab-project-manager", Short: "Find and use GitLab projects locally", Long: `Finds GitLab projects using fuzzy-find, remembering your chosen term for the project as an alias, and offers helpful shortcuts for moving around in projects and opening your code`, - // Uncomment the following line if your bare application - // has an action associated with it: - // Run: func(cmd *cobra.Command, args []string) { }, + PersistentPreRun: initRootCmd, +} + +// Hook traversal is enabled, so this will be run for all +// sub-commands regardless of their registered pre-hooks +func initRootCmd(cmd *cobra.Command, args []string) { + initProjectPath(cmd, args) } // Execute adds all child commands to the root command and sets flags appropriately. // This is called by main.main(). It only needs to happen once to the rootCmd. func Execute() { - err := rootCmd.Execute() + ctx, cncl := signal.NotifyContext(context.Background(), os.Kill, os.Interrupt) + defer cncl() + + err := rootCmd.ExecuteContext(ctx) + if err != nil { + slog.Error("Failed to execute command", "err", err) os.Exit(1) } } func init() { + cobra.EnableTraverseRunHooks = true cobra.OnInitialize(initConfig) - rootCmd.PersistentFlags().String("config", "", "config file (default is $HOME/.gitlab-project-manager.yaml)") - rootCmd.PersistentFlags().String("gitlabHost", defGitlabHost, "GitLab Hostname (e.g. gitlab.com)") - rootCmd.PersistentFlags().String("gitlabToken", "", "GitLab Tokenname (e.g. gitlab.com)") + + rootCmd.PersistentFlags().String("config", "", + "config file (default is $HOME/.gitlab-project-manager.yaml)") + rootCmd.PersistentFlags().String("gitlabHost", defGitlabHost, + "GitLab Hostname (e.g. gitlab.com)") + rootCmd.PersistentFlags().String("gitlabToken", "", + "GitLab Tokenname (e.g. gitlab.com)") + rootCmd.PersistentFlags().String("projectPath", defProjectsPath, + "Sets a path for local clones of projects") + rootCmd.PersistentFlags().String("logLevel", defLogLevel, + "Default log level -- info, warn, error, debug") + viper.BindPFlags(rootCmd.PersistentFlags()) } @@ -58,12 +84,52 @@ func initConfig() { viper.SetConfigName(".gitlab-project-manager") } - viper.AutomaticEnv() // read in environment variables that match + viper.AutomaticEnv() + viper.ReadInConfig() - // If a config file is found, read it in. - if err := viper.ReadInConfig(); err == nil { - slog.Info("Using config file:", viper.ConfigFileUsed()) - } else { - slog.Error("Error reading viper config", err) + checkConfigPerms(viper.ConfigFileUsed()) // Abort on world-readable config + + // Configure default logger + logger := slog.New(slog.NewTextHandler(os.Stdout, + &slog.HandlerOptions{Level: getSlogLevel(viper.GetString("logLevel"))})) + slog.SetDefault(logger) + + // Load into struct to not be so darn pythonic, retrieving + // settings by untyped string "name" + if err := viper.Unmarshal(&conf); err != nil { + slog.Error("Failed loading config", "err", err) + } + + slog.Debug("Configuration loaded", "conf", conf) +} + +func getSlogLevel(level string) slog.Level { + var slogLevel slog.Level + switch strings.ToLower(level) { + case "error": + slogLevel = slog.LevelError + case "warn": + slogLevel = slog.LevelWarn + case "info": + slogLevel = slog.LevelInfo + case "debug": + slogLevel = slog.LevelDebug + default: + slogLevel = slog.LevelInfo + } + return slogLevel +} + +// Don't allow world-readable configuration +func checkConfigPerms(file string) { + stat, err := os.Stat(file) + if err != nil { + slog.Error("Failure reading configuration", "err", err) + return + } + if stat.Mode().Perm()&0004 == 0004 { + slog.Error("Configuration is world-readable. Recomment 0400.", + "mode", stat.Mode().String()) + os.Exit(1) } } diff --git a/go.mod b/go.mod index ea83f3d..6951613 100644 --- a/go.mod +++ b/go.mod @@ -5,11 +5,16 @@ go 1.21.2 require ( github.com/spf13/cobra v1.8.0 github.com/spf13/viper v1.17.0 + github.com/xanzy/go-gitlab v0.94.0 golang.org/x/exp v0.0.0-20231127185646-65229373498e ) require ( github.com/fsnotify/fsnotify v1.6.0 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-retryablehttp v0.7.2 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/magiconair/properties v1.8.7 // indirect @@ -24,8 +29,13 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect + golang.org/x/net v0.15.0 // indirect + golang.org/x/oauth2 v0.12.0 // indirect golang.org/x/sys v0.12.0 // indirect golang.org/x/text v0.13.0 // indirect + golang.org/x/time v0.3.0 // indirect + google.golang.org/appengine v1.6.7 // indirect + google.golang.org/protobuf v1.31.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 3b0c5b0..3442b86 100644 --- a/go.sum +++ b/go.sum @@ -57,6 +57,8 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/fatih/color v1.14.1 h1:qfhVLaG5s+nCROl1zJsZRxFeYrHLqWroPOQ8BWiNb4w= +github.com/fatih/color v1.14.1/go.mod h1:2oHN61fhTpgcxD3TSWCgKDiH1+x4OiDVVGH8WlgGZGg= github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY= github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= @@ -89,6 +91,9 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= @@ -100,8 +105,11 @@ github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +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/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= @@ -120,6 +128,13 @@ github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+ github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= +github.com/hashicorp/go-hclog v1.5.0 h1:bI2ocEMgcVlz55Oj1xZNBsVi900c7II+fWDyV9o+13c= +github.com/hashicorp/go-hclog v1.5.0/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-retryablehttp v0.7.2 h1:AcYqCvkpalPnPF2pn0KamgwamS42TqUDDYFRKq/RAd0= +github.com/hashicorp/go-retryablehttp v0.7.2/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= @@ -141,6 +156,10 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= @@ -174,6 +193,7 @@ github.com/spf13/viper v1.17.0/go.mod h1:BmMMMLQXSbcHK6KAOiFLz0l5JHrU89OdIRHvsk0 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= @@ -184,6 +204,8 @@ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcU github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/xanzy/go-gitlab v0.94.0 h1:GmBl2T5zqUHqyjkxFSvsT7CbelGdAH/dmBqUBqS+4BE= +github.com/xanzy/go-gitlab v0.94.0/go.mod h1:ETg8tcj4OhrB84UEgeE8dSuV/0h4BBL1uOV/qK0vlyI= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -272,6 +294,8 @@ golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -281,6 +305,8 @@ golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.12.0 h1:smVPGxink+n1ZI5pkQa8y6fZT0RW0MgCO5bFpepy4B4= +golang.org/x/oauth2 v0.12.0/go.mod h1:A74bZ3aGXgCY0qaIC9Ahg6Lglin4AMAco8cIv9baba4= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -343,6 +369,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= @@ -419,6 +447,7 @@ google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7 google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= @@ -482,6 +511,10 @@ google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2 google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..70072ad --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,16 @@ +package config + +import "time" + +type Config struct { + GitlabHost string `yaml:"gitlabHost" json:"gitlabHost"` + GitlabToken string `yaml:"gitlabToken" json:"gitlabToken"` + LogLevel string `yaml:"logLevel" json:"logLevel" enum:"info,warn,debug,error"` + ProjectPath string `yaml:"projectPath" json:"projectPath"` + Cache cacheConfig `yaml:"cache" json:"cache"` +} + +type cacheConfig struct { + Ttl time.Duration `yaml:"ttl" json:"ttl"` + File string +} diff --git a/internal/gitlab/gitlab.go b/internal/gitlab/gitlab.go new file mode 100644 index 0000000..fd92db1 --- /dev/null +++ b/internal/gitlab/gitlab.go @@ -0,0 +1,32 @@ +package gitlab + +import "github.com/xanzy/go-gitlab" + +type Client struct { + gitlab *gitlab.Client +} + +type ProjectInfo struct { + Name string `yaml:"name" json:"name"` + Id int `yaml:"id" json:"id"` + Path string `yaml:"path" json:"path"` + URI string `yaml:"uri" json:"uri"` + Description string `yaml:"description" json:"description"` +} + +type ProjectAlias struct { + Alias string + ProjectID string + Project *ProjectInfo +} + +func NewGitlabClient(host, token string) (*Client, error) { + client, err := gitlab.NewClient(token, gitlab.WithBaseURL(host)) + if err != nil { + return nil, err + } + gitlabClient := &Client{ + gitlab: client, + } + return gitlabClient, nil +} diff --git a/internal/projects/cache.go b/internal/projects/cache.go new file mode 100644 index 0000000..1455fc6 --- /dev/null +++ b/internal/projects/cache.go @@ -0,0 +1,137 @@ +package projects + +import ( + "fmt" + "io/fs" + "os" + "sync" + "time" + + "gitlab.sweetwater.com/it/devops/tools/gitlab-project-manager/internal/gitlab" + "golang.org/x/exp/slog" + "gopkg.in/yaml.v3" +) + +type Cache struct { + Projects []*gitlab.ProjectInfo + Aliases []*gitlab.ProjectAlias + Updated time.Time + readFromFile bool + lock *sync.Mutex + ttl time.Duration + file string + log *slog.Logger +} + +type CacheOpts struct { + Path string + TTL time.Duration + Logger *slog.Logger +} + +// Load cache, if already loaded and +// up to date, +func (c *Cache) Load() error { + var err error + if !c.readFromFile { + c.Read() + } + if time.Since(c.Updated) > c.ttl { + c.log.Info("Project cache stale, updating.") + c.Refresh() + } + return err +} + +func (c *Cache) Write() { + file, err := os.OpenFile(c.file, os.O_RDWR, fs.ModeAppend) + if err != nil { + c.log.Error("Failed to write cache to disk", "error", err) + } + d := yaml.NewEncoder(file) + if err = d.Encode(*c); err != nil { + c.log.Error("Failed to Marshal cache to yaml", "error", err) + } else { + c.log.Info("Cache saved to disk") + } +} + +func (c *Cache) Read() { + c.lock.Lock() + defer c.lock.Unlock() + c.log.Info("Reading project cache from disk", "file", c.file) + + file, err := os.Open(c.file) + if err != nil { + c.log.Error("Failed to read project cache", "error", err) + return + } + + d := yaml.NewDecoder(file) + d.Decode(c) + + if time.Since(c.Updated) > c.ttl { + c.refresh() + } + + c.readFromFile = true + c.log.Debug(c.String()) +} + +func (c *Cache) refresh() { + c.log.Info("Refreshing project cache, this may take a while") + defer c.setUpdated() +} + +func (c *Cache) Refresh() { + c.lock.Lock() + defer c.lock.Unlock() + c.refresh() +} + +func (c *Cache) String() string { + return fmt.Sprintf("Cache Updated %s: Projects %d, Aliases %d", + c.Updated.String(), + len(c.Projects), + len(c.Aliases)) +} + +func (c *Cache) setUpdated() { + c.Updated = time.Now() +} + +func (c *Cache) SetUpdated() { + c.lock.Lock() + defer c.lock.Unlock() + c.setUpdated() +} + +func (c *Cache) GetUpdated() time.Time { + return c.Updated +} + +// Returns a new project cache ready to load from +// cache file. +// Cache.Load() must be called manually +func NewProjectCache(opts *CacheOpts) (*Cache, error) { + var err error + + if _, err = os.Stat(opts.Path); err != nil { + err = createProjectCache(opts.Path) + } + + cache := &Cache{ + Projects: make([]*gitlab.ProjectInfo, 0), + Aliases: make([]*gitlab.ProjectAlias, 0), + file: opts.Path, + ttl: opts.TTL, + lock: &sync.Mutex{}, + log: opts.Logger, + } + + return cache, err +} + +func createProjectCache(path string) error { + return os.WriteFile(path, nil, 0640) +} diff --git a/main.go b/main.go index 4c9a079..756d8fe 100644 --- a/main.go +++ b/main.go @@ -1,24 +1,3 @@ -/* -Copyright © 2023 Ryan McGuire - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. -*/ package main import "gitlab.sweetwater.com/it/devops/tools/gitlab-project-manager/cmd" diff --git a/pkg/gitlab/gitlab.go b/pkg/gitlab/gitlab.go new file mode 100644 index 0000000..0474ce0 --- /dev/null +++ b/pkg/gitlab/gitlab.go @@ -0,0 +1,18 @@ +package gitlab + +import "github.com/xanzy/go-gitlab" + +type Client struct { + gitlab *gitlab.Client +} + +func NewGitlabClient(host, token string) (*Client, error) { + client, err := gitlab.NewClient(token, gitlab.WithBaseURL(host)) + if err != nil { + return nil, err + } + gitlabClient := &Client{ + gitlab: client, + } + return gitlabClient, nil +} diff --git a/pkg/manager/manager.go b/pkg/manager/manager.go deleted file mode 100644 index 2a15cbe..0000000 --- a/pkg/manager/manager.go +++ /dev/null @@ -1,31 +0,0 @@ -package manager - -import "fmt" - -type Manager struct { - Config *ManagerConfig - GitLabHost string - gitLabToken string -} - -type ManagerParams struct { - GitLabHost string - GitLabToken string - ConfigFile string -} - -// Returns a new instance of project manager -// with optional overrides for common settings -// More complex configuration is provided in the -// configuration file -func NewManager(p *ManagerParams) *Manager { - return &Manager{ - Config: NewConfigFromFile(p.ConfigFile), - GitLabHost: p.GitLabHost, - gitLabToken: p.GitLabToken, - } -} - -func (m *Manager) String() string { - return fmt.Sprintf("%+v", *m) -} diff --git a/pkg/manager/manager_config.go b/pkg/manager/manager_config.go deleted file mode 100644 index a6c99f5..0000000 --- a/pkg/manager/manager_config.go +++ /dev/null @@ -1,29 +0,0 @@ -package manager - -import ( - "os" - "time" - - "golang.org/x/exp/slog" -) - -const ( - managerDefCacheFile = "/tmp/gitlab_project-manager_cache" - managerDefConfigFile = "~/.config/project-manager/config.yaml" - managerDefProjectsPath = "~/work/projects" -) - -type ManagerConfig struct { - CacheTTL time.Duration `yaml:"cacheTTL" json:"cacheTTL" default:"48h"` - CacheFile string `yaml:"cacheFile" json:"cacheFile"` - ConfigFile string `yaml:"configFile" json:"configFile"` - ProjectsPath string `yaml:"projectsPath" json:"projectsPath"` -} - -func NewConfigFromFile(file string) *ManagerConfig { - _, err := os.Open(file) - if err != nil { - slog.Warn("Skipping config file, can't read", "err", err) - } - return &ManagerConfig{} -}