From 05fa5e919c18375f77a14688744ad584d2465355 Mon Sep 17 00:00:00 2001 From: Youngjin Jo Date: Tue, 12 Nov 2024 13:55:02 +0900 Subject: [PATCH 1/6] feat: add exec subcommand using grpc reflection Signed-off-by: Youngjin Jo --- cmd/exec.go | 258 +++++++++++++++++++++------------------------------- go.mod | 7 +- go.sum | 10 +- 3 files changed, 116 insertions(+), 159 deletions(-) diff --git a/cmd/exec.go b/cmd/exec.go index 931cfd4..d94a567 100644 --- a/cmd/exec.go +++ b/cmd/exec.go @@ -1,43 +1,49 @@ -/* -Copyright © 2024 NAME HERE -*/ package cmd import ( "context" "crypto/tls" "fmt" - "io/ioutil" "log" + "net/url" "os" "strings" - "github.com/golang/protobuf/ptypes/empty" - - "google.golang.org/protobuf/proto" - "google.golang.org/protobuf/types/descriptorpb" - - "google.golang.org/grpc/credentials" - "google.golang.org/grpc/reflection/grpc_reflection_v1alpha" - + "github.com/jhump/protoreflect/dynamic" + "github.com/jhump/protoreflect/grpcreflect" "github.com/spf13/cobra" "google.golang.org/grpc" + "google.golang.org/grpc/credentials" "google.golang.org/grpc/metadata" + "google.golang.org/grpc/reflection/grpc_reflection_v1alpha" "gopkg.in/yaml.v2" ) -var parameters []string - // Config structure to parse environment files type Config struct { Token string `yaml:"token"` Endpoints map[string]string `yaml:"endpoints"` } -// Load environment configuration +var execCmd = &cobra.Command{ + Use: "exec [rpc] [service].[resource]", + Short: "Execute a gRPC request to a specified service and message", + Long: `Executes a gRPC command to a given service and message based on environment configuration. + For example: cfctl exec list identity.User`, + Args: cobra.ExactArgs(2), + Run: runExecCommand, +} + +var parameters []string + +func init() { + rootCmd.AddCommand(execCmd) + execCmd.Flags().StringArrayVarP(¶meters, "parameter", "p", []string{}, "Input Parameter (-p = -p ...)") +} + func loadConfig(environment string) (*Config, error) { configPath := fmt.Sprintf("%s/.spaceone/environments/%s.yml", os.Getenv("HOME"), environment) - data, err := ioutil.ReadFile(configPath) + data, err := os.ReadFile(configPath) if err != nil { return nil, fmt.Errorf("could not read config file: %w", err) } @@ -50,11 +56,9 @@ func loadConfig(environment string) (*Config, error) { return &config, nil } -// Load current environment from environment.yml func fetchCurrentEnvironment() (string, error) { envPath := fmt.Sprintf("%s/.spaceone/environment.yml", os.Getenv("HOME")) - - data, err := ioutil.ReadFile(envPath) + data, err := os.ReadFile(envPath) if err != nil { return "", fmt.Errorf("could not read environment file: %w", err) } @@ -70,153 +74,99 @@ func fetchCurrentEnvironment() (string, error) { return envConfig.Environment, nil } -// execCmd represents the exec command -var execCmd = &cobra.Command{ - Use: "exec [verb] [service].[resource]", - Short: "Execute a gRPC request to a specified service and resource", - Long: `Executes a gRPC command to a given service and resource based on environment configuration. - For example: cfctl exec list identity.User`, - Args: cobra.ExactArgs(2), - Run: func(cmd *cobra.Command, args []string) { - // Verb and service resource extraction - serviceResource := args[1] - rpcVerb := args[0] - - // Load environment - environment, err := fetchCurrentEnvironment() - if err != nil { - log.Fatalf("Failed to get current environment: %v", err) - } +func runExecCommand(cmd *cobra.Command, args []string) { + environment, err := fetchCurrentEnvironment() + if err != nil { + log.Fatalf("Failed to get current environment: %v", err) + } - config, err := loadConfig(environment) - if err != nil { - log.Fatalf("Failed to load config for environment %s: %v", environment, err) - } + config, err := loadConfig(environment) + if err != nil { + log.Fatalf("Failed to load config for environment %s: %v", environment, err) + } - // Extract service name - parts := strings.Split(serviceResource, ".") - if len(parts) != 2 { - log.Fatalf("Invalid service format. Use [service].[resource]") - } - serviceName := parts[0] - resourceName := parts[1] + verbName := args[0] + serviceResource := args[1] + parts := strings.Split(serviceResource, ".") - // Modify endpoint format - endpoint := config.Endpoints[serviceName] - endpoint = strings.Replace(strings.Replace(endpoint, "grpc+ssl://", "", 1), "/v1", "", 1) + if len(parts) != 2 { + log.Fatalf("Invalid service and resource format. Use [service].[resource]") + } - // Set up secure connection - conn, err := grpc.Dial(endpoint, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{}))) - if err != nil { - log.Fatalf("Failed to connect to gRPC server: %v", err) - } - defer conn.Close() + serviceName := parts[0] + resourceName := parts[1] + fullServiceName := fmt.Sprintf("spaceone.api.%s.v2.%s", serviceName, resourceName) - // Set up gRPC reflection client - refClient := grpc_reflection_v1alpha.NewServerReflectionClient(conn) - ctx := metadata.AppendToOutgoingContext(context.Background(), "token", config.Token) + endpoint, ok := config.Endpoints[serviceName] + if !ok { + log.Fatalf("Service endpoint not found for service: %s", serviceName) + } - stream, err := refClient.ServerReflectionInfo(ctx) - if err != nil { - log.Fatalf("Failed to create reflection stream: %v", err) - } + parsedURL, err := url.Parse(endpoint) + if err != nil { + log.Fatalf("Invalid endpoint URL %s: %v", endpoint, err) + } - // Request service list - req := &grpc_reflection_v1alpha.ServerReflectionRequest{ - MessageRequest: &grpc_reflection_v1alpha.ServerReflectionRequest_ListServices{ - ListServices: "*", - }, - } + grpcEndpoint := fmt.Sprintf("%s:%s", parsedURL.Hostname(), parsedURL.Port()) - if err := stream.Send(req); err != nil { - log.Fatalf("Failed to send reflection request: %v", err) - } + // Set up secure connection + conn, err := grpc.Dial(grpcEndpoint, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{}))) + if err != nil { + log.Fatalf("Failed to connect to gRPC server: %v", err) + } + defer conn.Close() - // Receive and search for the specific service - resp, err := stream.Recv() - if err != nil { - log.Fatalf("Failed to receive reflection response: %v", err) - } + ctx := metadata.AppendToOutgoingContext(context.Background(), "token", config.Token) - serviceFound := false - for _, svc := range resp.GetListServicesResponse().Service { - if strings.Contains(svc.Name, resourceName) { - serviceFound = true - - // Request file descriptor for the specific service - fileDescriptorReq := &grpc_reflection_v1alpha.ServerReflectionRequest{ - MessageRequest: &grpc_reflection_v1alpha.ServerReflectionRequest_FileContainingSymbol{ - FileContainingSymbol: svc.Name, - }, - } - - if err := stream.Send(fileDescriptorReq); err != nil { - log.Fatalf("Failed to send file descriptor request: %v", err) - } - - fileResp, err := stream.Recv() - if err != nil { - log.Fatalf("Failed to receive file descriptor response: %v", err) - } - - // Parse the file descriptor response - fd := fileResp.GetFileDescriptorResponse() - if fd == nil { - log.Fatalf("No file descriptor found for service %s", svc.Name) - } - - // Extract methods from the file descriptor - fmt.Printf("Available methods for service %s:\n", svc.Name) - methodFound := false - for _, b := range fd.FileDescriptorProto { - protoDescriptor := &descriptorpb.FileDescriptorProto{} - if err := proto.Unmarshal(b, protoDescriptor); err != nil { - log.Fatalf("Failed to unmarshal file descriptor proto: %v", err) - } - - for _, service := range protoDescriptor.Service { - if service.GetName() == resourceName { - for _, method := range service.Method { - fmt.Printf("- %s\n", method.GetName()) - if method.GetName() == rpcVerb { - methodFound = true - // Call the method if it matches - fmt.Printf("Calling method %s on service %s...\n", rpcVerb, svc.Name) - - // Assuming the list method has no parameters - // Prepare the request message (in this case, an empty request) - req := &empty.Empty{} - response := new(empty.Empty) // Create a response placeholder - - // Make the RPC call using the client connection - err = conn.Invoke(ctx, fmt.Sprintf("/%s/%s", svc.Name, method.GetName()), req, response) - if err != nil { - log.Fatalf("Failed to call method %s: %v", rpcVerb, err) - } - - // Print the response - fmt.Printf("Response: %+v\n", response) - } - } - } - } - } - - if !methodFound { - log.Fatalf("Method %s not found in service %s", rpcVerb, resourceName) - } - - break - } - } + // Set up reflection client + refClient := grpcreflect.NewClient(ctx, grpc_reflection_v1alpha.NewServerReflectionClient(conn)) + + // Get the service descriptor + serviceDesc, err := refClient.ResolveService(fullServiceName) + if err != nil { + log.Fatalf("Failed to resolve service %s: %v", fullServiceName, err) + } - if !serviceFound { - log.Fatalf("Service %s not found", resourceName) + // Find the method descriptor + methodDesc := serviceDesc.FindMethodByName(verbName) + if methodDesc == nil { + log.Fatalf("Method %s not found in service %s", verbName, fullServiceName) + } + + // Create a dynamic message for the request + inputType := methodDesc.GetInputType() + reqMsg := dynamic.NewMessage(inputType) + + // Parse the input parameters into a map + inputParams := parseParameters(parameters) + for key, value := range inputParams { + if err := reqMsg.TrySetFieldByName(key, value); err != nil { + log.Fatalf("Failed to set field %s: %v", key, err) } - }, + } + + // Prepare response placeholder + outputType := methodDesc.GetOutputType() + respMsg := dynamic.NewMessage(outputType) + + // Make the RPC call using the client connection + err = conn.Invoke(ctx, fmt.Sprintf("/%s/%s", serviceDesc.GetFullyQualifiedName(), methodDesc.GetName()), reqMsg, respMsg) + if err != nil { + log.Fatalf("Failed to call method %s: %v", verbName, err) + } + + // Print the response + fmt.Printf("Response: %+v\n", respMsg) } -func init() { - rootCmd.AddCommand(execCmd) - execCmd.Flags().StringArrayVarP(¶meters, "parameter", "p", []string{}, "Input Parameter (-p = -p ...)") +func parseParameters(params []string) map[string]string { + parsed := make(map[string]string) + for _, param := range params { + parts := strings.SplitN(param, "=", 2) + if len(parts) != 2 { + log.Fatalf("Invalid parameter format. Use key=value") + } + parsed[parts[0]] = parts[1] + } + return parsed } diff --git a/go.mod b/go.mod index 7f853ea..bdbcc02 100644 --- a/go.mod +++ b/go.mod @@ -3,13 +3,12 @@ module github.com/cloudforet-io/cfctl go 1.23.1 require ( - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc - github.com/golang/protobuf v1.5.4 + github.com/jhump/protoreflect v1.17.0 github.com/pterm/pterm v0.12.79 github.com/spf13/cobra v1.8.1 github.com/spf13/viper v1.19.0 google.golang.org/grpc v1.62.1 - google.golang.org/protobuf v1.34.2 + google.golang.org/protobuf v1.35.1 gopkg.in/yaml.v2 v2.2.4 ) @@ -17,8 +16,10 @@ require ( atomicgo.dev/cursor v0.2.0 // indirect atomicgo.dev/keyboard v0.2.9 // indirect atomicgo.dev/schedule v0.1.0 // indirect + github.com/bufbuild/protocompile v0.14.1 // indirect github.com/containerd/console v1.0.3 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/golang/protobuf v1.5.4 // indirect github.com/gookit/color v1.5.4 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect diff --git a/go.sum b/go.sum index fc9213e..6c6e936 100644 --- a/go.sum +++ b/go.sum @@ -16,6 +16,8 @@ github.com/MarvinJWendt/testza v0.4.2/go.mod h1:mSdhXiKH8sg/gQehJ63bINcCKp7RtYew github.com/MarvinJWendt/testza v0.5.2 h1:53KDo64C1z/h/d/stCYCPY69bt/OSwjq5KpFNwi+zB4= github.com/MarvinJWendt/testza v0.5.2/go.mod h1:xu53QFE5sCdjtMCKk8YMQ2MnymimEctc4n3EjyIYvEY= github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk= +github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= +github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= @@ -39,6 +41,8 @@ github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94= +github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= @@ -136,6 +140,8 @@ golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -171,8 +177,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20240314234333-6e1732d8331c h1: google.golang.org/genproto/googleapis/rpc v0.0.0-20240314234333-6e1732d8331c/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY= google.golang.org/grpc v1.62.1 h1:B4n+nfKzOICUXMgyrNd19h/I9oH0L1pizfk1d4zSgTk= google.golang.org/grpc v1.62.1/go.mod h1:IWTG0VlJLCh1SkC58F7np9ka9mx/WNkjl4PGJaiq+QE= -google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= -google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= +google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From 38c0d95a185c25a2ac4e87b81834a604492dcce4 Mon Sep 17 00:00:00 2001 From: Youngjin Jo Date: Tue, 12 Nov 2024 14:03:25 +0900 Subject: [PATCH 2/6] feat: add exec subcommand - print as yaml Signed-off-by: Youngjin Jo --- cmd/exec.go | 68 +++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 64 insertions(+), 4 deletions(-) diff --git a/cmd/exec.go b/cmd/exec.go index d94a567..4b15174 100644 --- a/cmd/exec.go +++ b/cmd/exec.go @@ -3,6 +3,7 @@ package cmd import ( "context" "crypto/tls" + "encoding/json" "fmt" "log" "net/url" @@ -155,12 +156,36 @@ func runExecCommand(cmd *cobra.Command, args []string) { log.Fatalf("Failed to call method %s: %v", verbName, err) } - // Print the response - fmt.Printf("Response: %+v\n", respMsg) + // Convert the response to a map and format it as YAML + respMap, err := messageToMap(respMsg) + if err != nil { + log.Fatalf("Failed to convert response message to map: %v", err) + } + + // Convert response to JSON to properly decode UTF-8 characters + jsonData, err := json.Marshal(respMap) + if err != nil { + log.Fatalf("Failed to marshal response to JSON: %v", err) + } + + // Unmarshal JSON data back into a map to maintain UTF-8 decoding + var prettyMap map[string]interface{} + if err := json.Unmarshal(jsonData, &prettyMap); err != nil { + log.Fatalf("Failed to unmarshal JSON data: %v", err) + } + + // Convert response to YAML with proper formatting + yamlData, err := yaml.Marshal(prettyMap) + if err != nil { + log.Fatalf("Failed to marshal response to YAML: %v", err) + } + + // Print the response in YAML format + fmt.Printf("---\n%s\n", yamlData) } -func parseParameters(params []string) map[string]string { - parsed := make(map[string]string) +func parseParameters(params []string) map[string]interface{} { + parsed := make(map[string]interface{}) for _, param := range params { parts := strings.SplitN(param, "=", 2) if len(parts) != 2 { @@ -170,3 +195,38 @@ func parseParameters(params []string) map[string]string { } return parsed } + +func messageToMap(msg *dynamic.Message) (map[string]interface{}, error) { + result := make(map[string]interface{}) + fields := msg.GetKnownFields() + + for _, fd := range fields { + val := msg.GetField(fd) + + switch v := val.(type) { + case *dynamic.Message: + subMap, err := messageToMap(v) + if err != nil { + return nil, err + } + result[fd.GetName()] = subMap + case []*dynamic.Message: + var subList []map[string]interface{} + for _, subMsg := range v { + subMap, err := messageToMap(subMsg) + if err != nil { + return nil, err + } + subList = append(subList, subMap) + } + result[fd.GetName()] = subList + case string: + // Properly decode UTF-8 strings for human readability + result[fd.GetName()] = v + default: + result[fd.GetName()] = v + } + } + + return result, nil +} From 41e8e47618e97bdfccfe5980336c01fd64720ba3 Mon Sep 17 00:00:00 2001 From: Youngjin Jo Date: Tue, 12 Nov 2024 14:14:22 +0900 Subject: [PATCH 3/6] feat: add exec subcommand - add json option Signed-off-by: Youngjin Jo --- cmd/exec.go | 88 +++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 68 insertions(+), 20 deletions(-) diff --git a/cmd/exec.go b/cmd/exec.go index 4b15174..847c1d9 100644 --- a/cmd/exec.go +++ b/cmd/exec.go @@ -3,12 +3,14 @@ package cmd import ( "context" "crypto/tls" + "encoding/csv" "encoding/json" "fmt" "log" "net/url" "os" "strings" + "text/tabwriter" "github.com/jhump/protoreflect/dynamic" "github.com/jhump/protoreflect/grpcreflect" @@ -36,10 +38,18 @@ var execCmd = &cobra.Command{ } var parameters []string +var jsonParameter string +var fileParameter string +var apiVersion string +var outputFormat string func init() { rootCmd.AddCommand(execCmd) execCmd.Flags().StringArrayVarP(¶meters, "parameter", "p", []string{}, "Input Parameter (-p = -p ...)") + execCmd.Flags().StringVarP(&jsonParameter, "json-parameter", "j", "", "JSON type parameter") + execCmd.Flags().StringVarP(&fileParameter, "file-parameter", "f", "", "YAML file parameter") + execCmd.Flags().StringVarP(&apiVersion, "api-version", "v", "v1", "API Version") + execCmd.Flags().StringVarP(&outputFormat, "output", "o", "yaml", "Output format (yaml, json, table, csv)") } func loadConfig(environment string) (*Config, error) { @@ -139,7 +149,7 @@ func runExecCommand(cmd *cobra.Command, args []string) { reqMsg := dynamic.NewMessage(inputType) // Parse the input parameters into a map - inputParams := parseParameters(parameters) + inputParams := parseParameters(fileParameter, jsonParameter, parameters) for key, value := range inputParams { if err := reqMsg.TrySetFieldByName(key, value); err != nil { log.Fatalf("Failed to set field %s: %v", key, err) @@ -162,30 +172,32 @@ func runExecCommand(cmd *cobra.Command, args []string) { log.Fatalf("Failed to convert response message to map: %v", err) } - // Convert response to JSON to properly decode UTF-8 characters - jsonData, err := json.Marshal(respMap) - if err != nil { - log.Fatalf("Failed to marshal response to JSON: %v", err) - } + formatAndPrintResponse(respMap, outputFormat) +} - // Unmarshal JSON data back into a map to maintain UTF-8 decoding - var prettyMap map[string]interface{} - if err := json.Unmarshal(jsonData, &prettyMap); err != nil { - log.Fatalf("Failed to unmarshal JSON data: %v", err) - } +func parseParameters(fileParameter, jsonParameter string, params []string) map[string]interface{} { + parsed := make(map[string]interface{}) - // Convert response to YAML with proper formatting - yamlData, err := yaml.Marshal(prettyMap) - if err != nil { - log.Fatalf("Failed to marshal response to YAML: %v", err) + // Load from file parameter if provided + if fileParameter != "" { + data, err := os.ReadFile(fileParameter) + if err != nil { + log.Fatalf("Failed to read file parameter: %v", err) + } + + if err := yaml.Unmarshal(data, &parsed); err != nil { + log.Fatalf("Failed to unmarshal YAML file: %v", err) + } } - // Print the response in YAML format - fmt.Printf("---\n%s\n", yamlData) -} + // Load from JSON parameter if provided + if jsonParameter != "" { + if err := json.Unmarshal([]byte(jsonParameter), &parsed); err != nil { + log.Fatalf("Failed to unmarshal JSON parameter: %v", err) + } + } -func parseParameters(params []string) map[string]interface{} { - parsed := make(map[string]interface{}) + // Parse key=value parameters for _, param := range params { parts := strings.SplitN(param, "=", 2) if len(parts) != 2 { @@ -193,6 +205,7 @@ func parseParameters(params []string) map[string]interface{} { } parsed[parts[0]] = parts[1] } + return parsed } @@ -230,3 +243,38 @@ func messageToMap(msg *dynamic.Message) (map[string]interface{}, error) { return result, nil } + +func formatAndPrintResponse(respMap map[string]interface{}, format string) { + switch format { + case "json": + data, err := json.MarshalIndent(respMap, "", " ") + if err != nil { + log.Fatalf("Failed to marshal response to JSON: %v", err) + } + fmt.Println(string(data)) + + case "yaml": + data, err := yaml.Marshal(respMap) + if err != nil { + log.Fatalf("Failed to marshal response to YAML: %v", err) + } + fmt.Printf("---\n%s\n", data) + + case "table": + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', tabwriter.Debug) + for key, value := range respMap { + fmt.Fprintf(w, "%s:\t%v\n", key, value) + } + w.Flush() + + case "csv": + writer := csv.NewWriter(os.Stdout) + for key, value := range respMap { + writer.Write([]string{key, fmt.Sprintf("%v", value)}) + } + writer.Flush() + + default: + log.Fatalf("Unsupported output format: %s", format) + } +} From 9e2b242a46c5e4b41920a89bbf54cfbd04f28335 Mon Sep 17 00:00:00 2001 From: Youngjin Jo Date: Tue, 12 Nov 2024 14:54:51 +0900 Subject: [PATCH 4/6] feat: add exec subcommand - add json, csv, table option Signed-off-by: Youngjin Jo --- cmd/exec.go | 157 +++++++++++++++++++++++++++++++++++++++++++--------- go.mod | 3 +- go.sum | 2 + 3 files changed, 134 insertions(+), 28 deletions(-) diff --git a/cmd/exec.go b/cmd/exec.go index 847c1d9..85d7ada 100644 --- a/cmd/exec.go +++ b/cmd/exec.go @@ -1,6 +1,7 @@ package cmd import ( + "bytes" "context" "crypto/tls" "encoding/csv" @@ -10,18 +11,26 @@ import ( "net/url" "os" "strings" - "text/tabwriter" + "github.com/atotto/clipboard" "github.com/jhump/protoreflect/dynamic" "github.com/jhump/protoreflect/grpcreflect" + "github.com/pterm/pterm" "github.com/spf13/cobra" "google.golang.org/grpc" "google.golang.org/grpc/credentials" "google.golang.org/grpc/metadata" "google.golang.org/grpc/reflection/grpc_reflection_v1alpha" - "gopkg.in/yaml.v2" + "gopkg.in/yaml.v3" ) +var parameters []string +var jsonParameter string +var fileParameter string +var apiVersion string +var outputFormat string +var copyToClipboard bool + // Config structure to parse environment files type Config struct { Token string `yaml:"token"` @@ -32,17 +41,11 @@ var execCmd = &cobra.Command{ Use: "exec [rpc] [service].[resource]", Short: "Execute a gRPC request to a specified service and message", Long: `Executes a gRPC command to a given service and message based on environment configuration. - For example: cfctl exec list identity.User`, + For example: cfctl exec list identity.User`, Args: cobra.ExactArgs(2), Run: runExecCommand, } -var parameters []string -var jsonParameter string -var fileParameter string -var apiVersion string -var outputFormat string - func init() { rootCmd.AddCommand(execCmd) execCmd.Flags().StringArrayVarP(¶meters, "parameter", "p", []string{}, "Input Parameter (-p = -p ...)") @@ -50,6 +53,7 @@ func init() { execCmd.Flags().StringVarP(&fileParameter, "file-parameter", "f", "", "YAML file parameter") execCmd.Flags().StringVarP(&apiVersion, "api-version", "v", "v1", "API Version") execCmd.Flags().StringVarP(&outputFormat, "output", "o", "yaml", "Output format (yaml, json, table, csv)") + execCmd.Flags().BoolVar(©ToClipboard, "copy", false, "Copy the output to the clipboard") } func loadConfig(environment string) (*Config, error) { @@ -166,13 +170,26 @@ func runExecCommand(cmd *cobra.Command, args []string) { log.Fatalf("Failed to call method %s: %v", verbName, err) } - // Convert the response to a map and format it as YAML + // Convert the response to a map and maintain UTF-8 decoding respMap, err := messageToMap(respMsg) if err != nil { log.Fatalf("Failed to convert response message to map: %v", err) } - formatAndPrintResponse(respMap, outputFormat) + // Convert response to JSON to properly decode UTF-8 characters + jsonData, err := json.Marshal(respMap) + if err != nil { + log.Fatalf("Failed to marshal response to JSON: %v", err) + } + + // Unmarshal JSON data back into a map to maintain UTF-8 decoding + var prettyMap map[string]interface{} + if err := json.Unmarshal(jsonData, &prettyMap); err != nil { + log.Fatalf("Failed to unmarshal JSON data: %v", err) + } + + // Print the response + printData(prettyMap, outputFormat) } func parseParameters(fileParameter, jsonParameter string, params []string) map[string]interface{} { @@ -233,8 +250,14 @@ func messageToMap(msg *dynamic.Message) (map[string]interface{}, error) { subList = append(subList, subMap) } result[fd.GetName()] = subList + case map[interface{}]interface{}: + // Convert map[interface{}]interface{} to map[string]interface{} + formattedMap := make(map[string]interface{}) + for key, value := range v { + formattedMap[fmt.Sprintf("%v", key)] = value + } + result[fd.GetName()] = formattedMap case string: - // Properly decode UTF-8 strings for human readability result[fd.GetName()] = v default: result[fd.GetName()] = v @@ -244,37 +267,117 @@ func messageToMap(msg *dynamic.Message) (map[string]interface{}, error) { return result, nil } -func formatAndPrintResponse(respMap map[string]interface{}, format string) { +func printData(data map[string]interface{}, format string) { + var output string + switch format { case "json": - data, err := json.MarshalIndent(respMap, "", " ") + dataBytes, err := json.MarshalIndent(data, "", " ") if err != nil { log.Fatalf("Failed to marshal response to JSON: %v", err) } - fmt.Println(string(data)) + output = string(dataBytes) + fmt.Println(output) case "yaml": - data, err := yaml.Marshal(respMap) + var buf bytes.Buffer + encoder := yaml.NewEncoder(&buf) + encoder.SetIndent(2) + err := encoder.Encode(data) if err != nil { log.Fatalf("Failed to marshal response to YAML: %v", err) } - fmt.Printf("---\n%s\n", data) + output = buf.String() + fmt.Printf("---\n%s\n", output) case "table": - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', tabwriter.Debug) - for key, value := range respMap { - fmt.Fprintf(w, "%s:\t%v\n", key, value) - } - w.Flush() + printTable(data) + return case "csv": - writer := csv.NewWriter(os.Stdout) - for key, value := range respMap { - writer.Write([]string{key, fmt.Sprintf("%v", value)}) - } - writer.Flush() + printCSV(data) + return default: log.Fatalf("Unsupported output format: %s", format) } + + // Check if the copy flag is set + if copyToClipboard { + err := clipboard.WriteAll(output) + if err != nil { + log.Fatalf("Failed to copy to clipboard: %v", err) + } + // Use Pterm to notify the user + pterm.Success.Println("The output has been copied to your clipboard.") + } +} + +func printTable(data map[string]interface{}) { + if results, ok := data["results"].([]interface{}); ok { + tableData := pterm.TableData{} + + // Extract headers + headers := []string{} + if len(results) > 0 { + if row, ok := results[0].(map[string]interface{}); ok { + for key := range row { + headers = append(headers, key) + } + } + } + + // Append headers to table data + tableData = append(tableData, headers) + + // Extract rows + for _, result := range results { + if row, ok := result.(map[string]interface{}); ok { + rowData := []string{} + for _, key := range headers { + rowData = append(rowData, fmt.Sprintf("%v", row[key])) + } + tableData = append(tableData, rowData) + } + } + + pterm.DefaultTable.WithHasHeader(true).WithData(tableData).Render() + } +} + +func printCSV(data map[string]interface{}) { + if results, ok := data["results"].([]interface{}); ok { + writer := csv.NewWriter(os.Stdout) + var headers []string + var rows [][]string + + // Extract headers + for _, result := range results { + if row, ok := result.(map[string]interface{}); ok { + if headers == nil { + for key := range row { + headers = append(headers, key) + } + writer.Write(headers) + } + + // Extract row values + var rowValues []string + for _, key := range headers { + if val, ok := row[key]; ok { + rowValues = append(rowValues, fmt.Sprintf("%v", val)) + } else { + rowValues = append(rowValues, "") + } + } + rows = append(rows, rowValues) + } + } + + // Write rows + for _, row := range rows { + writer.Write(row) + } + writer.Flush() + } } diff --git a/go.mod b/go.mod index bdbcc02..65f0fd5 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/cloudforet-io/cfctl go 1.23.1 require ( + github.com/atotto/clipboard v0.1.4 github.com/jhump/protoreflect v1.17.0 github.com/pterm/pterm v0.12.79 github.com/spf13/cobra v1.8.1 @@ -10,6 +11,7 @@ require ( google.golang.org/grpc v1.62.1 google.golang.org/protobuf v1.35.1 gopkg.in/yaml.v2 v2.2.4 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -46,5 +48,4 @@ require ( golang.org/x/text v0.15.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240314234333-6e1732d8331c // 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 6c6e936..2ba4436 100644 --- a/go.sum +++ b/go.sum @@ -16,6 +16,8 @@ github.com/MarvinJWendt/testza v0.4.2/go.mod h1:mSdhXiKH8sg/gQehJ63bINcCKp7RtYew github.com/MarvinJWendt/testza v0.5.2 h1:53KDo64C1z/h/d/stCYCPY69bt/OSwjq5KpFNwi+zB4= github.com/MarvinJWendt/testza v0.5.2/go.mod h1:xu53QFE5sCdjtMCKk8YMQ2MnymimEctc4n3EjyIYvEY= github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= From f8ccfe0ef1af22f4ef1e41f8e8b478bf2c2ba3f7 Mon Sep 17 00:00:00 2001 From: Youngjin Jo Date: Tue, 12 Nov 2024 15:21:54 +0900 Subject: [PATCH 5/6] feat: add copy flag, which is copy any output format Signed-off-by: Youngjin Jo --- cmd/exec.go | 51 +++++++++++++++++++++++++++++++-------------------- 1 file changed, 31 insertions(+), 20 deletions(-) diff --git a/cmd/exec.go b/cmd/exec.go index 85d7ada..5467de7 100644 --- a/cmd/exec.go +++ b/cmd/exec.go @@ -53,7 +53,7 @@ func init() { execCmd.Flags().StringVarP(&fileParameter, "file-parameter", "f", "", "YAML file parameter") execCmd.Flags().StringVarP(&apiVersion, "api-version", "v", "v1", "API Version") execCmd.Flags().StringVarP(&outputFormat, "output", "o", "yaml", "Output format (yaml, json, table, csv)") - execCmd.Flags().BoolVar(©ToClipboard, "copy", false, "Copy the output to the clipboard") + execCmd.Flags().BoolVarP(©ToClipboard, "copy", "c", false, "Copy the output to the clipboard (copies any output format)") } func loadConfig(environment string) (*Config, error) { @@ -90,6 +90,11 @@ func fetchCurrentEnvironment() (string, error) { } func runExecCommand(cmd *cobra.Command, args []string) { + // Disable styling if copy to clipboard is enabled + //if copyToClipboard { + // pterm.DisableStyling() // Disable ANSI color codes + //} + environment, err := fetchCurrentEnvironment() if err != nil { log.Fatalf("Failed to get current environment: %v", err) @@ -291,29 +296,26 @@ func printData(data map[string]interface{}, format string) { fmt.Printf("---\n%s\n", output) case "table": - printTable(data) - return + output = printTable(data) case "csv": - printCSV(data) - return + output = printCSV(data) default: log.Fatalf("Unsupported output format: %s", format) } - // Check if the copy flag is set - if copyToClipboard { - err := clipboard.WriteAll(output) - if err != nil { + // Copy to clipboard if requested + if copyToClipboard && output != "" { + if err := clipboard.WriteAll(output); err != nil { log.Fatalf("Failed to copy to clipboard: %v", err) } - // Use Pterm to notify the user pterm.Success.Println("The output has been copied to your clipboard.") } } -func printTable(data map[string]interface{}) { +func printTable(data map[string]interface{}) string { + var output string if results, ok := data["results"].([]interface{}); ok { tableData := pterm.TableData{} @@ -341,15 +343,24 @@ func printTable(data map[string]interface{}) { } } - pterm.DefaultTable.WithHasHeader(true).WithData(tableData).Render() + // Disable styling only for the table output + pterm.DisableStyling() + renderedOutput, err := pterm.DefaultTable.WithHasHeader(true).WithData(tableData).Srender() + pterm.EnableStyling() // Re-enable styling for other outputs + if err != nil { + log.Fatalf("Failed to render table: %v", err) + } + output = renderedOutput + fmt.Println(output) // Print to console } + return output } -func printCSV(data map[string]interface{}) { +func printCSV(data map[string]interface{}) string { + var buf bytes.Buffer if results, ok := data["results"].([]interface{}); ok { - writer := csv.NewWriter(os.Stdout) + writer := csv.NewWriter(&buf) var headers []string - var rows [][]string // Extract headers for _, result := range results { @@ -370,14 +381,14 @@ func printCSV(data map[string]interface{}) { rowValues = append(rowValues, "") } } - rows = append(rows, rowValues) + writer.Write(rowValues) } } - // Write rows - for _, row := range rows { - writer.Write(row) - } writer.Flush() + output := buf.String() + fmt.Print(output) // Print to console + return output } + return "" } From 32a83f42183dbf500824afaf98de07c7b983e2f5 Mon Sep 17 00:00:00 2001 From: Youngjin Jo Date: Tue, 12 Nov 2024 15:28:16 +0900 Subject: [PATCH 6/6] feat: get package name by user input, not hard coding Signed-off-by: Youngjin Jo --- cmd/exec.go | 44 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/cmd/exec.go b/cmd/exec.go index 5467de7..2759ba1 100644 --- a/cmd/exec.go +++ b/cmd/exec.go @@ -90,11 +90,6 @@ func fetchCurrentEnvironment() (string, error) { } func runExecCommand(cmd *cobra.Command, args []string) { - // Disable styling if copy to clipboard is enabled - //if copyToClipboard { - // pterm.DisableStyling() // Disable ANSI color codes - //} - environment, err := fetchCurrentEnvironment() if err != nil { log.Fatalf("Failed to get current environment: %v", err) @@ -115,7 +110,6 @@ func runExecCommand(cmd *cobra.Command, args []string) { serviceName := parts[0] resourceName := parts[1] - fullServiceName := fmt.Sprintf("spaceone.api.%s.v2.%s", serviceName, resourceName) endpoint, ok := config.Endpoints[serviceName] if !ok { @@ -140,17 +134,49 @@ func runExecCommand(cmd *cobra.Command, args []string) { // Set up reflection client refClient := grpcreflect.NewClient(ctx, grpc_reflection_v1alpha.NewServerReflectionClient(conn)) + defer refClient.Reset() + + // Find the package name dynamically + packageName := "" + serviceList, err := refClient.ListServices() + if err != nil { + log.Fatalf("Failed to list services: %v", err) + } + + for _, fullServiceName := range serviceList { + serviceDesc, err := refClient.ResolveService(fullServiceName) + if err != nil { + log.Printf("Failed to resolve service %s: %v", fullServiceName, err) + continue + } + + if serviceDesc.GetName() == resourceName { + for _, method := range serviceDesc.GetMethods() { + if method.GetName() == verbName { + packageName = serviceDesc.GetFullyQualifiedName() + break + } + } + } + if packageName != "" { + break + } + } + + if packageName == "" { + log.Fatalf("Service and method not found for verb: %s", verbName) + } // Get the service descriptor - serviceDesc, err := refClient.ResolveService(fullServiceName) + serviceDesc, err := refClient.ResolveService(packageName) if err != nil { - log.Fatalf("Failed to resolve service %s: %v", fullServiceName, err) + log.Fatalf("Failed to resolve service %s: %v", packageName, err) } // Find the method descriptor methodDesc := serviceDesc.FindMethodByName(verbName) if methodDesc == nil { - log.Fatalf("Method %s not found in service %s", verbName, fullServiceName) + log.Fatalf("Method %s not found in service %s", verbName, packageName) } // Create a dynamic message for the request