diff --git a/api/envtype/envtype.go b/api/envtype/envtype.go index b509bc80..918001a1 100644 --- a/api/envtype/envtype.go +++ b/api/envtype/envtype.go @@ -1,6 +1,20 @@ package envtype +type EnvType struct { + Id int64 `yaml:"id,omitempty" json:"id,omitempty"` + CreatedBy string `yaml:"createdBy,omitempty" json:"createdBy,omitempty"` + UpdatedBy string `yaml:"updatedBy,omitempty" json:"updatedBy,omitempty"` + CreatedAt string `yaml:"createdAt,omitempty" json:"createdAt,omitempty"` + UpdatedAt string `yaml:"updatedAt,omitempty" json:"updatedAt,omitempty"` + Name string `yaml:"name,omitempty" json:"name,omitempty"` + Strict bool `yaml:"strict,omitempty" json:"strict,omitempty"` +} + // ListResponse interface type ListTypeResponse struct { Response []string `yaml:"resp,omitempty" json:"resp,omitempty"` } + +type GetEnvTypeResponse struct { + Response EnvType `yaml:"resp,omitempty" json:"resp,omitempty"` +} diff --git a/api/service/service.go b/api/service/service.go index 23011f23..e00b3960 100644 --- a/api/service/service.go +++ b/api/service/service.go @@ -48,7 +48,31 @@ type MergedService struct { ProvisioningConfig map[string]interface{} } +type Operation struct { + Name string `yaml:"name,omitempty" json:"name,omitempty"` + Data interface{} `yaml:"data,omitempty" json:"data,omitempty"` +} + +type OperationConsent struct { + Name string `yaml:"name,omitempty" json:"name,omitempty"` + IsFeedbackRequired bool `yaml:"is_feedback_required,omitempty" json:"is_feedback_required,omitempty"` + Message string `yaml:"message,omitempty" json:"message,omitempty"` +} + +type OperationValidationResponseBody struct { + Operations []OperationConsent `yaml:"operations,omitempty" json:"operations,omitempty"` +} + // CompareResponse type CompareResponse struct { Response interface{} `yaml:"resp,omitempty" json:"resp,omitempty"` } + +type OperationRequest struct { + EnvName string `yaml:"env_name,omitempty" json:"env_name,omitempty"` + Operations []Operation `yaml:"operations,omitempty" json:"operations,omitempty"` +} + +type OperationValidationResponse struct { + Response OperationValidationResponseBody `yaml:"resp,omitempty" json:"resp,omitempty"` +} diff --git a/app/app.go b/app/app.go index be81dc44..0955d116 100644 --- a/app/app.go +++ b/app/app.go @@ -8,5 +8,5 @@ type application struct { // App (Application) interface var App application = application{ Name: "odin", - Version: "1.3.0-beta", + Version: "1.3.1-beta", } diff --git a/internal/backend/envtype.go b/internal/backend/envtype.go index 9cc4facc..2c8d5f90 100644 --- a/internal/backend/envtype.go +++ b/internal/backend/envtype.go @@ -2,6 +2,7 @@ package backend import ( "encoding/json" + "path" "github.com/dream11/odin/api/envtype" ) @@ -23,3 +24,14 @@ func (e *EnvType) ListEnvType() ([]string, error) { return envTypeResponse.Response, err } + +func (e *EnvType) GetEnvType(envName string) (envtype.EnvType, error) { + client := newApiClient() + response := client.actionWithRetry(path.Join(envEntityType, envName)+"/", "GET", nil) + response.Process(true) // process response and exit if error + + var envTypeResponse envtype.GetEnvTypeResponse + err := json.Unmarshal(response.Body, &envTypeResponse) + + return envTypeResponse.Response, err +} diff --git a/internal/backend/operation.go b/internal/backend/operation.go index 1729de1e..72d6504d 100644 --- a/internal/backend/operation.go +++ b/internal/backend/operation.go @@ -9,7 +9,7 @@ import ( type Operation struct{} -func (o *Operation) ListOperations(componentTypeName string) ([]operationapi.Operation, error) { +func (o *Operation) ListComponentTypeOperations(componentTypeName string) ([]operationapi.Operation, error) { client := newApiClient() response := client.actionWithRetry(path.Join("component", componentTypeName, "operate"), "GET", nil) response.Process(true) // process response and exit if error @@ -17,3 +17,12 @@ func (o *Operation) ListOperations(componentTypeName string) ([]operationapi.Ope err := json.Unmarshal(response.Body, &listResponse) return listResponse.Response, err } + +func (o *Operation) ListServiceOperations() ([]operationapi.Operation, error) { + client := newApiClient() + response := client.actionWithRetry(path.Join("services", "operations", "all"), "GET", nil) + response.Process(true) // process response and exit if error + var listResponse operationapi.ListOperation + err := json.Unmarshal(response.Body, &listResponse) + return listResponse.Response, err +} diff --git a/internal/backend/service.go b/internal/backend/service.go index 2729c5b3..1822e029 100644 --- a/internal/backend/service.go +++ b/internal/backend/service.go @@ -167,3 +167,22 @@ func (s *Service) StatusService(serviceName, version string) ([]service.Status, return serviceResponse.Response, err } + +func (s *Service) ValidateOperation(serviceName string, data service.OperationRequest) (service.OperationValidationResponse, error) { + client := newApiClient() + + response := client.actionWithRetry(path.Join(serviceEntity, serviceName)+"/operate/validate/", "GET", data) + response.Process(true) + + var validateOperationResponse service.OperationValidationResponse + err := json.Unmarshal(response.Body, &validateOperationResponse) + + return validateOperationResponse, err +} + +func (s *Service) OperateService(serviceName string, data service.OperationRequest) { + client := newStreamingApiClient() + client.Headers["Command-Verb"] = "operate" + response := client.streamWithRetry(path.Join(serviceEntity, serviceName)+"/operate/", "PUT", data) + response.Process(true) +} diff --git a/internal/command/command_catalog.go b/internal/command/command_catalog.go index 7bdb2922..e0ececd7 100644 --- a/internal/command/command_catalog.go +++ b/internal/command/command_catalog.go @@ -119,6 +119,9 @@ func CommandsCatalog() map[string]cli.CommandFactory { "status service": func() (cli.Command, error) { return &commands.Service{Status: true}, nil }, + "operate service": func() (cli.Command, error) { + return &commands.Service{Operate: true}, nil + }, // Verbs for `service-set` resource "create service-set": func() (cli.Command, error) { return &commands.ServiceSet{Create: true}, nil diff --git a/internal/command/commands/component.go b/internal/command/commands/component.go index e17d55f8..8afb96f6 100644 --- a/internal/command/commands/component.go +++ b/internal/command/commands/component.go @@ -42,6 +42,26 @@ func (c *Component) Run(args []string) int { return 1 } + envTypeResp, err := envTypeClient.GetEnvType(*envName) + if err != nil { + c.Logger.Error(err.Error()) + return 1 + } + if envTypeResp.Strict { + consentMessage := fmt.Sprintf("\nYou are executing the above command on a restricted environment. Are you sure? Enter \033[1m%s\033[0m to continue:", *envName) + val, err := c.Input.Ask(consentMessage) + + if err != nil { + c.Logger.Error(err.Error()) + return 1 + } + + if val != *envName { + c.Logger.Info("Aborting the operation") + return 1 + } + } + data := component.OperateComponentRequest{ Data: component.Data{ EnvName: *envName, diff --git a/internal/command/commands/operation.go b/internal/command/commands/operation.go index bbc66935..83a57d40 100644 --- a/internal/command/commands/operation.go +++ b/internal/command/commands/operation.go @@ -5,6 +5,7 @@ import ( "flag" "fmt" + operationapi "github.com/dream11/odin/api/operation" "github.com/dream11/odin/internal/backend" "github.com/dream11/odin/pkg/table" ) @@ -26,69 +27,105 @@ func (o *Operation) Run(args []string) int { } if o.List { - emptyParameters := emptyParameters(map[string]string{"--component-type": *componentType}) - if len(emptyParameters) == 0 { - operationList, err := operationClient.ListOperations(*componentType) - if err != nil { - o.Logger.Error(err.Error()) - return 1 - } + isComponentTypePresent := len(*componentType) > 0 - o.Logger.Info("Listing all operation(s)") - tableHeaders := []string{"Name", "Descrption"} - var tableData [][]interface{} + var operationList []operationapi.Operation + var err error - for _, operation := range operationList { - tableData = append(tableData, []interface{}{ - operation.Name, - operation.Description, - }) - } - table.Write(tableHeaders, tableData) + if isComponentTypePresent { + operationList, err = operationClient.ListComponentTypeOperations(*componentType) + } else { + operationList, err = operationClient.ListServiceOperations() + } - return 0 + if err != nil { + o.Logger.Error(err.Error()) + return 1 } - o.Logger.Error(fmt.Sprintf("%s cannot be blank", emptyParameters)) - return 1 + if isComponentTypePresent { + o.Logger.Info("Listing all operation(s)" + " on component " + *componentType) + } else { + o.Logger.Info("Listing all service operations") + } + + tableHeaders := []string{"Name", "Descrption"} + var tableData [][]interface{} + + for _, operation := range operationList { + tableData = append(tableData, []interface{}{ + operation.Name, + operation.Description, + }) + } + table.Write(tableHeaders, tableData) + + if isComponentTypePresent { + o.Logger.Output("\nCommand to describe component operation(s)") + o.Logger.ItalicEmphasize("odin describe operation --name --component-type ") + } else { + o.Logger.Output("\nCommand to describe service operations") + o.Logger.ItalicEmphasize("odin describe operation --name ") + } + + return 0 } if o.Describe { - emptyParameters := emptyParameters(map[string]string{"--name": *name, "--component-type": *componentType}) - if len(emptyParameters) == 0 { - operationList, err := operationClient.ListOperations(*componentType) - if err != nil { - o.Logger.Error(err.Error()) - return 1 - } + isNamePresent := len(*name) > 0 + isComponentTypePresent := len(*componentType) > 0 - o.Logger.Info("Describing operation: " + *name + " on component " + *componentType) - var operationKeys interface{} + if !isNamePresent { + o.Logger.Error("--name cannot be blank") + return 1 + } - for i := range operationList { - if operationList[i].Name == *name { - operationKeys = operationList[i].InputSchema - break - } + var operationList []operationapi.Operation + var err error + + if isComponentTypePresent { + operationList, err = operationClient.ListComponentTypeOperations(*componentType) + } else { + operationList, err = operationClient.ListServiceOperations() + } + + if err != nil { + o.Logger.Error(err.Error()) + return 1 + } + + var operationKeys interface{} + + for i := range operationList { + if operationList[i].Name == *name { + operationKeys = operationList[i].InputSchema + break } + } - if operationKeys == nil { + if operationKeys == nil { + if isComponentTypePresent { o.Logger.Error(fmt.Sprintf("operation: %s does not exist for the component: %s", *name, *componentType)) - return 1 + } else { + o.Logger.Error(fmt.Sprintf("operation: %s is not a valid service operation", *name)) } + return 1 + } - operationKeysJson, err := json.MarshalIndent(operationKeys, "", " ") - if err != nil { - o.Logger.Error(err.Error()) - return 1 - } + operationKeysJson, err := json.MarshalIndent(operationKeys, "", " ") + if err != nil { + o.Logger.Error(err.Error()) + return 1 + } - o.Logger.Output(fmt.Sprintf("\n%s", operationKeysJson)) - return 0 + if isComponentTypePresent { + o.Logger.Info("Describing operation: " + *name + " on component " + *componentType) + } else { + o.Logger.Info("Describing the service operation: " + *name) } - o.Logger.Error(fmt.Sprintf("%s cannot be blank", emptyParameters)) - return 1 + o.Logger.Output(fmt.Sprintf("\n%s", operationKeysJson)) + return 0 } o.Logger.Error("Not a valid command") return 127 @@ -113,10 +150,10 @@ func (o *Operation) Help() string { // Synopsis : returns a brief helper text for the command's verbs func (o *Operation) Synopsis() string { if o.List { - return "list all operations on a component-type" + return "list all operations on service or a component-type" } if o.Describe { - return "describe operation on a component-type" + return "describe a operation on service or a component-type" } return defaultHelper() } diff --git a/internal/command/commands/service.go b/internal/command/commands/service.go index 3262cbcb..f5321880 100644 --- a/internal/command/commands/service.go +++ b/internal/command/commands/service.go @@ -31,7 +31,7 @@ func (s *Service) Run(args []string) int { // Define flag set flagSet := flag.NewFlagSet("flagSet", flag.ContinueOnError) // create flags - filePath := flagSet.String("file", "", "file to read service config") + filePath := flagSet.String("file", "", "file to read service config or to provide options for service operations") serviceName := flagSet.String("name", "", "name of service to be used") serviceVersion := flagSet.String("version", "", "version of service to be used") envName := flagSet.String("env", "", "name of environment to deploy the service in") @@ -41,6 +41,8 @@ func (s *Service) Run(args []string) int { label := flagSet.String("label", "", "name of the label") provisioningConfigFile := flagSet.String("provisioning", "", "file to read provisioning config") directoryPath := flagSet.String("path", "", "path to directory containing service definition and provisioning config") + operation := flagSet.String("operation", "", "name of the operation to performed on the component") + options := flagSet.String("options", "", "options for service operations") err := flagSet.Parse(args) if err != nil { @@ -266,6 +268,10 @@ func (s *Service) Run(args []string) int { s.Logger.Error("Error while parsing service file " + *filePath + " : " + err.Error()) return 1 } + consent := s.askForConsent(envName) + if consent == 1 { + return 1 + } serviceDefinition := parsedConfig.(map[string]interface{}) return s.deployUnreleasedService(envName, serviceDefinition, provisioningConfigFile, configStoreNamespace) @@ -273,6 +279,10 @@ func (s *Service) Run(args []string) int { emptyReleasedParameters := emptyParameters(map[string]string{"--env": *envName, "--name": *serviceName, "--version": *serviceVersion}) if len(emptyReleasedParameters) == 0 { + consent := s.askForConsent(envName) + if consent == 1 { + return 1 + } return s.deployReleasedService(envName, serviceName, serviceVersion, provisioningConfigFile, configStoreNamespace) } @@ -325,10 +335,142 @@ func (s *Service) Run(args []string) int { return 1 } + if s.Operate { + if *envName == "" { + *envName = utils.FetchKey(ENV_NAME_KEY) + } + + isNamePresent := len(*serviceName) > 0 + isOptionsPresent := len(*options) > 0 + isFilePresent := len(*filePath) > 0 + isOperationPresnt := len(*operation) > 0 + isEnvNamePresent := len(*envName) > 0 + + if !isNamePresent { + s.Logger.Error("--name cannot be blank") + return 1 + } + + if !isOperationPresnt { + s.Logger.Error("--opertion cannot be blank") + return 1 + } + + if !isEnvNamePresent { + s.Logger.Error("--env cannot be blank") + return 1 + } + + if isOptionsPresent && isFilePresent { + s.Logger.Error("You can provide either --options or --file but not both") + return 1 + } + + if !isOptionsPresent && !isFilePresent { + s.Logger.Error("You should provide either --options or --file") + return 1 + } + + var optionsData map[string]interface{} + + if isFilePresent { + parsedConfig, err := parseFile(*filePath) + if err != nil { + s.Logger.Error("Error while parsing service file " + *filePath + " : " + err.Error()) + return 1 + } + optionsData = parsedConfig.(map[string]interface{}) + } else if isOptionsPresent { + err = json.Unmarshal([]byte(*options), &optionsData) + if err != nil { + s.Logger.Error("Unable to parse JSON data " + err.Error()) + return 1 + } + } + + if len(optionsData) == 0 { + s.Logger.Error("You can't send an empty JSON data") + return 1 + } + + consent := s.askForConsent(envName) + + if consent == 1 { + return 1 + } + + data := service.OperationRequest{ + EnvName: *envName, + Operations: []service.Operation{ + { + Name: *operation, + Data: optionsData, + }, + }, + } + + s.Logger.Info("Validating the operation: " + *operation + " on the service: " + *serviceName) + + validateOperateResponse, err := serviceClient.ValidateOperation(*serviceName, data) + + if err != nil { + s.Logger.Error(err.Error()) + return 1 + } + + for _, operation := range validateOperateResponse.Response.Operations { + isFeedbackRequired := operation.IsFeedbackRequired + message := operation.Message + + if isFeedbackRequired { + consentMessage := fmt.Sprintf("\n%s", message) + + allowedInputs := map[string]struct{}{"Y": {}, "n": {}} + val, err := s.Input.AskWithConstraints(consentMessage, allowedInputs) + + if err != nil { + s.Logger.Error(err.Error()) + return 1 + } + + if val != "Y" { + s.Logger.Info("Aborting the operation") + return 1 + } + } + } + + serviceClient.OperateService(*serviceName, data) + return 0 + } + s.Logger.Error("Not a valid command") return 127 } +func (s *Service) askForConsent(envName *string) int { + envTypeResp, err := envTypeClient.GetEnvType(*envName) + if err != nil { + s.Logger.Error(err.Error()) + return 1 + } + if envTypeResp.Strict { + consentMessage := fmt.Sprintf("\nYou are executing the above command on a restricted environment. Are you sure? Enter \033[1m%s\033[0m to continue:", *envName) + val, err := s.Input.Ask(consentMessage) + + if err != nil { + s.Logger.Error(err.Error()) + return 1 + } + + if val != *envName { + s.Logger.Info("Aborting the operation") + return 1 + } + } + return 0 +} + func (s *Service) deployUnreleasedService(envName *string, serviceDefinition map[string]interface{}, provisioningConfigFile *string, configStoreNamespace *string) int { if serviceDefinition["name"] == nil || len(serviceDefinition["name"].(string)) == 0 { @@ -523,6 +665,16 @@ func (s *Service) Help() string { }) } + if s.Operate { + return commandHelper("operate", "service", "", []Options{ + {Flag: "--name", Description: "name of service"}, + {Flag: "--env", Description: "name of environment"}, + {Flag: "--operate", Description: "name of the operation to be performed on the service"}, + {Flag: "--file", Description: "path of the file which contains the options for the operation in JSON format"}, + {Flag: "--options", Description: "options for the operation in JSON format"}, + }) + } + return defaultHelper() } @@ -560,5 +712,9 @@ func (s *Service) Synopsis() string { return "get status of a service version" } + if s.Operate { + return "perform operations on a service" + } + return defaultHelper() }