diff --git a/.gitignore b/.gitignore index 26a5873..c7ab30c 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,7 @@ build/dist # .DS_Store file of MacOS -.DS_Store \ No newline at end of file +.DS_Store + +# test data files +**/testdata/** \ No newline at end of file diff --git a/cmd/cmd.go b/cmd/cmd.go index f174726..3a92caf 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -23,10 +23,9 @@ import ( ) var rootCmd = &cobra.Command{ - Use: "microcks", - Short: "A CLI tool for Microcks", - Long: `microcks-cli is a CLI for interacting with Microcks server APIs. - It allows to launch tests or import API artifacts with minimal dependencies.`, + Use: "microcks", + Short: "A CLI tool for Microcks", + SilenceUsage: true, Run: func(cmd *cobra.Command, args []string) { cmd.HelpFunc()(cmd, args) }, @@ -48,4 +47,5 @@ func init() { rootCmd.AddCommand(NewImportURLCommand()) rootCmd.AddCommand(NewStartCommand()) rootCmd.AddCommand(NewStopCommand()) + rootCmd.AddCommand(NewContextCommand()) } diff --git a/cmd/context.go b/cmd/context.go new file mode 100644 index 0000000..597cbe9 --- /dev/null +++ b/cmd/context.go @@ -0,0 +1,126 @@ +package cmd + +import ( + "fmt" + "log" + "os" + "strings" + "text/tabwriter" + + "github.com/microcks/microcks-cli/pkg/config" + "github.com/microcks/microcks-cli/pkg/errors" + "github.com/spf13/cobra" +) + +func NewContextCommand() *cobra.Command { + var delete bool + ctxCmd := &cobra.Command{ + Use: "context [CONTEXT]", + Aliases: []string{"ctx"}, + Short: "switch between contexts", + Example: `# List Microcks context +microcks context/ctx + +#Switch Microcks context +microcks context httP://localhost:8080 + +# Delete Microcks context +microcks context httP://localhost:8080 --delete`, + Run: func(cmd *cobra.Command, args []string) { + var cfgFile string + configPath, err := config.DefaultLocalConfigPath() + errors.CheckError(err) + cfgFile = configPath + localCfg, err := config.ReadLocalConfig(cfgFile) + errors.CheckError(err) + if delete { + if len(args) == 0 { + cmd.HelpFunc()(cmd, args) + os.Exit(1) + } + err := deleteContext(args[0], cfgFile) + errors.CheckError(err) + return + } + + if len(args) == 0 { + printMicrocksContexts(cfgFile) + return + } + + ctxName := args[0] + if localCfg.CurrentContext == ctxName { + fmt.Printf("Already at context '%s'\n", localCfg.CurrentContext) + return + } + if _, err = localCfg.ResolveContext(ctxName); err != nil { + log.Fatal(err) + } + localCfg.CurrentContext = ctxName + err = config.WriteLocalConfig(*localCfg, configPath) + errors.CheckError(err) + fmt.Printf("Switched to context '%s'\n", localCfg.CurrentContext) + }, + } + + ctxCmd.Flags().BoolVar(&delete, "delete", false, "Delete a context") + + return ctxCmd +} + +func deleteContext(context, configPath string) error { + localCfg, err := config.ReadLocalConfig(configPath) + errors.CheckError(err) + if localCfg == nil { + return fmt.Errorf("Nothing to logout from") + } + serverName, ok := localCfg.RemoveContext(context) + if !ok { + return fmt.Errorf("Context %s does not exist", context) + } + _ = localCfg.RemoveUser(context) + _ = localCfg.RemoveServer(serverName) + + if localCfg.IsEmpty() { + err := localCfg.DeleteLocalConfig(configPath) + errors.CheckError(err) + } else { + if localCfg.CurrentContext == context { + localCfg.CurrentContext = "" + } + err = config.ValidateLocalConfig(*localCfg) + if err != nil { + return fmt.Errorf("Error in logging out") + } + err = config.WriteLocalConfig(*localCfg, configPath) + errors.CheckError(err) + } + fmt.Printf("Context '%s' deleted\n", context) + return nil +} + +func printMicrocksContexts(configPath string) { + localCfg, err := config.ReadLocalConfig(configPath) + errors.CheckError(err) + if localCfg == nil { + log.Fatalf("No contexts defined in %s", configPath) + } + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + defer func() { _ = w.Flush() }() + columnNames := []string{"CURRENT", "NAME", "SERVER"} + _, err = fmt.Fprintf(w, "%s\n", strings.Join(columnNames, "\t")) + errors.CheckError(err) + + for _, contextRef := range localCfg.Contexts { + context, err := localCfg.ResolveContext(contextRef.Name) + if err != nil { + log.Printf("Context '%s' had error: %v", contextRef.Name, err) + } + prefix := " " + if localCfg.CurrentContext == context.Name { + prefix = "*" + } + _, err = fmt.Fprintf(w, "%s\t%s\t%s\n", prefix, context.Name, context.Server.Server) + errors.CheckError(err) + } +} diff --git a/cmd/context_test.go b/cmd/context_test.go new file mode 100644 index 0000000..8de7935 --- /dev/null +++ b/cmd/context_test.go @@ -0,0 +1,66 @@ +package cmd + +import ( + "os" + "testing" + + "github.com/microcks/microcks-cli/pkg/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testConfig = `current-context: http://localhost:8083 +contexts: +- name: http://localhost:8080 + server: http://localhost:8080 + user: http://localhost:8080 + instance: "" +- name: http://localhost:8083 + server: http://localhost:8083 + user: http://localhost:8083 + instance: "" +servers: +- name: "" + server: http://localhost:8080 + insecureTLS: true + keycloakEnable: true +- name: "" + server: http://localhost:8083 + insecureTLS: true + keycloakEnable: true +users: +- name: http://localhost:8080 + auth-token: vErrYS3c3tReFRe$hToken + refresh-token: vErrYS3c3tReFRe$hToken +- name: http://localhost:8083 + auth-token: "" + refresh-token: ""` + +const testConfigFilePath = "./testdata/local.config" + +func TestDeleteContext(t *testing.T) { + //write the test config file + err := os.WriteFile(testConfigFilePath, []byte(testConfig), os.ModePerm) + require.NoError(t, err) + + err = os.Chmod(testConfigFilePath, 0o600) + require.NoError(t, err, "Could not change the file permission to 0600 %v", err) + localCfg, err := config.ReadLocalConfig(testConfigFilePath) + require.NoError(t, err) + assert.Equal(t, "http://localhost:8083", localCfg.CurrentContext) + assert.Contains(t, localCfg.Contexts, config.ContextRef{Name: "http://localhost:8083", Server: "http://localhost:8083", User: "http://localhost:8083", Instance: ""}) + + //Delete non-existing context + err = deleteContext("microcks.io", testConfigFilePath) + require.EqualError(t, err, "Context microcks.io does not exist") + + //Delete non-current context + err = deleteContext("http://localhost:8080", testConfigFilePath) + require.NoError(t, err) + + //Delete current context + err = deleteContext("http://localhost:8083", testConfigFilePath) + require.NoError(t, err) + _, err = config.ReadLocalConfig(testConfigFilePath) + require.NoError(t, err) +} diff --git a/go.mod b/go.mod index d366b66..74297de 100644 --- a/go.mod +++ b/go.mod @@ -8,12 +8,14 @@ require ( github.com/docker/docker v28.0.4+incompatible github.com/docker/go-connections v0.5.0 github.com/spf13/cobra v1.9.1 + github.com/stretchr/testify v1.10.0 gopkg.in/yaml.v2 v2.4.0 ) require ( github.com/Microsoft/go-winio v0.4.14 // indirect github.com/containerd/log v0.1.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect @@ -27,6 +29,7 @@ require ( github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/spf13/pflag v1.0.6 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect @@ -36,5 +39,6 @@ require ( go.opentelemetry.io/otel/trace v1.35.0 // indirect golang.org/x/sys v0.30.0 // indirect golang.org/x/time v0.11.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect gotest.tools/v3 v3.5.2 // indirect ) diff --git a/pkg/config/file_permission_unix.go b/pkg/config/file_permission_unix.go new file mode 100644 index 0000000..b22031d --- /dev/null +++ b/pkg/config/file_permission_unix.go @@ -0,0 +1,16 @@ +//go:build !windows + +package config + +import ( + "fmt" + "os" +) + +func getFilePermission(fi os.FileInfo) error { + if fi.Mode().Perm() == 0o600 || fi.Mode().Perm() == 0o400 { + return nil + } + return fmt.Errorf("config file has incorrect permission flags:%s."+ + "change the file permission either to 0400 or 0600.", fi.Mode().Perm().String()) +} diff --git a/pkg/config/file_permission_windows.go b/pkg/config/file_permission_windows.go new file mode 100644 index 0000000..e92c374 --- /dev/null +++ b/pkg/config/file_permission_windows.go @@ -0,0 +1,16 @@ +//go:build windows + +package config + +import ( + "fmt" + "os" +) + +func getFilePermission(fi os.FileInfo) error { + if fi.Mode().Perm() == 0666 || fi.Mode().Perm() == 0444 { + return nil + } + return fmt.Errorf("config file has incorrect permission flags:%s."+ + "change the file permission either to 0444 or 0666.", fi.Mode().Perm().String()) +} diff --git a/pkg/config/localconfig.go b/pkg/config/localconfig.go new file mode 100644 index 0000000..1e21f9a --- /dev/null +++ b/pkg/config/localconfig.go @@ -0,0 +1,295 @@ +package config + +import ( + "fmt" + "os" + "path" + + configUtil "github.com/microcks/microcks-cli/pkg/util" +) + +type LocalConfig struct { + CurrentContext string `yaml:"current-context"` + Contexts []ContextRef `yaml:"contexts"` + Servers []Server `yaml:"servers"` + Users []User `yaml:"users"` + Instances []Instance `yaml:"instances"` +} + +type ContextRef struct { + Name string `yaml:"name"` + Server string `yaml:"server"` + User string `yaml:"user"` + Instance string `yaml:"instance"` +} + +type Context struct { + Name string + User User + Server Server + Instance Instance +} + +type User struct { + Name string `yaml:"name"` + AuthToken string `yaml:"auth-token"` + RefreshToken string `yaml:"refresh-token"` +} + +type Server struct { + Name string `yaml:"name"` + Server string `yaml:"server"` + InsecureTLS bool `yaml:"insecureTLS"` + KeycloackEnable bool `yaml:"keycloakEnable"` +} + +type Instance struct { + Name string `yaml:"name"` + Image string `yaml:"image"` + Status string `yaml:"status"` + Port string `yaml:"port"` + ContainerID string `yaml:"containerID"` + AutoRemove bool `yaml:"autoRemove"` + Driver string `yaml:"driver"` +} + +// ReadLocalConfig loads up the local configuration file. Returns nil if config does not exist +func ReadLocalConfig(path string) (*LocalConfig, error) { + var err error + var config LocalConfig + + // check file permission only when microcks config exists + if fi, err := os.Stat(path); err == nil { + err = getFilePermission(fi) + if err != nil { + return nil, err + } + } + + err = configUtil.UnmarshalLocalFile(path, &config) + if os.IsNotExist(err) { + return nil, nil + } + + err = ValidateLocalConfig(config) + if err != nil { + return nil, err + } + return &config, nil +} + +// DefaultConfigDir returns the local configuration path for settings such as cached authentication tokens. +func DefaultConfigDir() (string, error) { + + configDir := os.Getenv("MICROCKS_CONFIG_DIR") + + if configDir != "" { + return configDir, nil + } + + homeDir, err := getHomeDir() + if err != nil { + return "", nil + } + + configDir = path.Join(homeDir, ".config", "microcks") + + return configDir, nil +} + +func getHomeDir() (string, error) { + homedir, err := os.UserHomeDir() + + if err != nil { + return "", err + } + + return homedir, nil +} + +// DefaultLocalConfigPath returns the local configuration path for settings such as cached authentication tokens. +func DefaultLocalConfigPath() (string, error) { + dir, err := DefaultConfigDir() + if err != nil { + return "", err + } + return path.Join(dir, "config"), nil +} + +func ValidateLocalConfig(config LocalConfig) error { + if config.CurrentContext == "" { + return nil + } + if _, err := config.ResolveContext(config.CurrentContext); err != nil { + return fmt.Errorf("local config invalid: %w", err) + } + return nil +} + +// WriteLocalConfig writes a new local configuration file. +func WriteLocalConfig(config LocalConfig, configPath string) error { + err := os.MkdirAll(path.Dir(configPath), os.ModePerm) + if err != nil { + return err + } + return configUtil.MarshalLocalYAMLFile(configPath, &config) +} + +func (l *LocalConfig) DeleteLocalConfig(configPath string) error { + _, err := os.Stat(configPath) + if os.IsNotExist(err) { + return err + } + return os.Remove(configPath) +} + +// ResolveContext resolves the specified context. If unspecified, resolves the current context +func (l *LocalConfig) ResolveContext(name string) (*Context, error) { + if name == "" { + if l.CurrentContext == "" { + return nil, fmt.Errorf("local config: current-context unset") + } + name = l.CurrentContext + } + for _, ctx := range l.Contexts { + if ctx.Name == name { + server, err := l.GetServer(ctx.Server) + if err != nil { + return nil, err + } + user, err := l.GetUser(ctx.User) + if err != nil { + return nil, err + } + instance, err := l.GetInstance(ctx.Instance) + if err != nil { + instance = &Instance{} + } + return &Context{ + Name: ctx.Name, + Server: *server, + User: *user, + Instance: *instance, + }, nil + } + } + return nil, fmt.Errorf("Context '%s' undefined", name) +} + +func (l *LocalConfig) UpserContext(context ContextRef) { + for i, c := range l.Contexts { + if c.Name == context.Name { + l.Contexts[i] = context + return + } + } + l.Contexts = append(l.Contexts, context) +} + +func (l *LocalConfig) RemoveContext(serverName string) (string, bool) { + for i, c := range l.Contexts { + if c.Name == serverName { + l.Contexts = append(l.Contexts[:i], l.Contexts[i+1:]...) + return c.Server, true + } + } + return "", false +} + +func (l *LocalConfig) GetUser(name string) (*User, error) { + for _, u := range l.Users { + if u.Name == name { + return &u, nil + } + } + return nil, fmt.Errorf("User '%s' undefined", name) +} + +func (l *LocalConfig) UpsertUser(user User) { + for i, u := range l.Users { + if u.Name == user.Name { + l.Users[i] = user + return + } + } + + l.Users = append(l.Users, user) +} + +// Returns true if user was removed successfully +func (l *LocalConfig) RemoveUser(serverName string) bool { + for i, u := range l.Users { + if u.Name == serverName { + l.Users = append(l.Users[:i], l.Users[i+1:]...) + return true + } + } + return false +} + +func (l *LocalConfig) GetServer(name string) (*Server, error) { + for _, s := range l.Servers { + if s.Server == name { + return &s, nil + } + } + return nil, fmt.Errorf("Server '%s' undefined", name) +} + +func (l *LocalConfig) UpsertServer(server Server) { + for i, s := range l.Servers { + if s.Server == server.Server { + l.Servers[i] = server + return + } + } + l.Servers = append(l.Servers, server) +} + +// Returns true if server was removed successfully +func (l *LocalConfig) RemoveServer(serverName string) bool { + for i, s := range l.Servers { + if s.Server == serverName { + l.Servers = append(l.Servers[:i], l.Servers[i+1:]...) + return true + } + } + return false +} + +func (l *LocalConfig) GetInstance(name string) (*Instance, error) { + for _, i := range l.Instances { + if i.Name == name { + return &i, nil + } + } + return nil, fmt.Errorf("Instance '%s' undefined", name) +} + +func (l *LocalConfig) UpsertInstance(instance Instance) { + for a, i := range l.Instances { + if i.ContainerID == instance.ContainerID { + l.Instances[a] = instance + return + } + } + l.Instances = append(l.Instances, instance) +} + +// Returns true if server was removed successfully +func (l *LocalConfig) RemoveInstance(instanceName string) bool { + if instanceName == "" { + return true + } + for a, i := range l.Instances { + if i.Name == instanceName { + l.Instances = append(l.Instances[:a], l.Instances[a+1:]...) + return true + } + } + return false +} + +func (l *LocalConfig) IsEmpty() bool { + return len(l.Servers) == 0 +} diff --git a/pkg/errors/error.go b/pkg/errors/error.go new file mode 100644 index 0000000..70f3b4e --- /dev/null +++ b/pkg/errors/error.go @@ -0,0 +1,31 @@ +package errors + +import ( + "log" + "os" +) + +const ( + // ErrorCommandSpecific is reserved for command specific indications + ErrorCommandSpecific = 1 + // ErrorConnectionFailure is returned on connection failure to API endpoint + ErrorConnectionFailure = 11 + // ErrorAPIResponse is returned on unexpected API response, i.e. authorization failure + ErrorAPIResponse = 12 + // ErrorResourceDoesNotExist is returned when the requested resource does not exist + ErrorResourceDoesNotExist = 13 + // ErrorGeneric is returned for generic error + ErrorGeneric = 20 +) + +func CheckError(err error) { + if err != nil { + Fatal(ErrorGeneric, err) + } +} + +// Fatal is a wrapper for log.Fatal() to exit with custom code +func Fatal(exitcode int, args ...interface{}) { + log.Fatal(args...) + os.Exit(exitcode) +} diff --git a/pkg/util/util.go b/pkg/util/util.go new file mode 100644 index 0000000..5c3b663 --- /dev/null +++ b/pkg/util/util.go @@ -0,0 +1,35 @@ +package util + +import ( + "os" + + "gopkg.in/yaml.v2" +) + +// UnmarshalLocalFile retrieves JSON or YAML from a file on disk. +// The caller is responsible for checking error return values. +func UnmarshalLocalFile(path string, obj interface{}) error { + data, err := os.ReadFile(path) + if err == nil { + err = unmarshalObject(data, obj) + } + return err +} + +func unmarshalObject(data []byte, obj interface{}) error { + return yaml.Unmarshal(data, obj) +} + +func Unmarshal(data []byte, obj interface{}) error { + return unmarshalObject(data, obj) +} + +// MarshalLocalYAMLFile writes JSON or YAML to a file on disk. +// The caller is responsible for checking error return values. +func MarshalLocalYAMLFile(path string, obj interface{}) error { + yamlData, err := yaml.Marshal(obj) + if err == nil { + err = os.WriteFile(path, yamlData, 0o600) + } + return err +}