github.com/spinnaker/spin@v1.30.0/cmd/canary/canary-config/retro.go (about)

     1  // Copyright (c) 2019, Waze, Inc.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //   http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  //   Unless required by applicable law or agreed to in writing, software
    10  //   distributed under the License is distributed on an "AS IS" BASIS,
    11  //   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  //   See the License for the specific language governing permissions and
    13  //   limitations under the License.
    14  
    15  package canary_config
    16  
    17  import (
    18  	"errors"
    19  	"fmt"
    20  	"net/http"
    21  	"strings"
    22  	"time"
    23  
    24  	"github.com/antihax/optional"
    25  	"github.com/spf13/cobra"
    26  
    27  	gate "github.com/spinnaker/spin/gateapi"
    28  	"github.com/spinnaker/spin/util"
    29  )
    30  
    31  type retroOptions struct {
    32  	*canaryConfigOptions
    33  	output              string
    34  	canaryConfigFile    string
    35  	controlGroup        string
    36  	controlLocation     string
    37  	experimentGroup     string
    38  	experimentLocation  string
    39  	startInstant        string
    40  	endInstant          string
    41  	extendedScopeParams map[string]string
    42  	metricsAccount      string
    43  	storageAccount      string
    44  	stepSize            int
    45  	marginalScore       int
    46  	passScore           int
    47  	fullResult          bool
    48  }
    49  
    50  const (
    51  	retroTemplateShort = "Retro the provided canary config"
    52  	retroTemplateLong  = "Retro the provided canary config"
    53  )
    54  
    55  var retrySleepCycle = 6 * time.Second
    56  
    57  func NewRetroCmd(canaryConfigOptions *canaryConfigOptions) *cobra.Command {
    58  	options := &retroOptions{
    59  		canaryConfigOptions: canaryConfigOptions,
    60  	}
    61  	cmd := &cobra.Command{
    62  		Use:     "retro",
    63  		Aliases: []string{},
    64  		Short:   retroTemplateShort,
    65  		Long:    retroTemplateLong,
    66  		RunE: func(cmd *cobra.Command, args []string) error {
    67  			return retroCanaryConfig(cmd, options)
    68  		},
    69  	}
    70  
    71  	cmd.PersistentFlags().StringVarP(&options.canaryConfigFile, "file",
    72  		"f", "", "path to the canary config file")
    73  	cmd.PersistentFlags().StringVar(&options.controlGroup, "control-group", "", "Control server group name (required)")
    74  	cmd.PersistentFlags().StringVar(&options.controlLocation, "control-location", "", "Control server group location (required)")
    75  	cmd.PersistentFlags().StringVar(&options.experimentGroup, "experiment-group", "", "Experiment server group name (required)")
    76  	cmd.PersistentFlags().StringVar(&options.experimentLocation, "experiment-location", "", "Experiment server group location (required)")
    77  	cmd.PersistentFlags().StringVar(&options.startInstant, "start", "", "Start of canary window, in ISO Instant format (required)")
    78  	cmd.PersistentFlags().StringVar(&options.endInstant, "end", "", "End of canary window, in ISO Instant format (required)")
    79  
    80  	cmd.PersistentFlags().IntVar(&options.stepSize, "step", 10, "Canary sampling step size in seconds")
    81  	cmd.PersistentFlags().IntVar(&options.marginalScore, "marginal-score", 75, "Canary marginal score threshold")
    82  	cmd.PersistentFlags().IntVar(&options.passScore, "pass-score", 95, "Canary pass score threshold")
    83  	cmd.PersistentFlags().StringToStringVar(&options.extendedScopeParams, "extended-scope-params", nil, "Extended scope params for retrospective")
    84  	cmd.PersistentFlags().StringVar(&options.metricsAccount, "metrics-account", "", "Metrics account to use in the retrospective")
    85  	cmd.PersistentFlags().StringVar(&options.storageAccount, "storage-account", "", "Storage account to use in the retrospective")
    86  
    87  	cmd.PersistentFlags().BoolVar(&options.fullResult, "full-result", false, "Whether to print the full canary result")
    88  
    89  	return cmd
    90  }
    91  
    92  func retroCanaryConfig(cmd *cobra.Command, options *retroOptions) error {
    93  	canaryConfigJson, err := util.ParseJsonFromFileOrStdin(options.canaryConfigFile, false)
    94  	if err != nil {
    95  		return err
    96  	}
    97  
    98  	validateErr := validateOptions(options)
    99  	if validateErr != nil {
   100  		return validateErr
   101  	}
   102  
   103  	startTime, tErr := time.Parse(time.RFC3339, options.startInstant)
   104  	if tErr != nil {
   105  		return tErr
   106  	}
   107  	endTime, tErr := time.Parse(time.RFC3339, options.endInstant)
   108  	if tErr != nil {
   109  		return tErr
   110  	}
   111  
   112  	scopes := map[string]interface{}{
   113  		"default": map[string]interface{}{
   114  			"controlScope": map[string]interface{}{
   115  				"scope":               options.controlGroup,
   116  				"location":            options.controlLocation,
   117  				"start":               startTime,
   118  				"end":                 endTime,
   119  				"step":                options.stepSize,
   120  				"extendedScopeParams": options.extendedScopeParams,
   121  			},
   122  			"experimentScope": map[string]interface{}{
   123  				"scope":               options.experimentGroup,
   124  				"location":            options.experimentLocation,
   125  				"start":               startTime,
   126  				"end":                 endTime,
   127  				"step":                options.stepSize,
   128  				"extendedScopeParams": options.extendedScopeParams,
   129  			},
   130  		},
   131  	}
   132  
   133  	executionRequest := map[string]interface{}{
   134  		"scopes": scopes,
   135  		"thresholds": map[string]int{
   136  			"pass":     options.passScore,
   137  			"marginal": options.marginalScore,
   138  		},
   139  	}
   140  
   141  	adhocRequest := map[string]interface{}{
   142  		"canaryConfig":     canaryConfigJson,
   143  		"executionRequest": executionRequest,
   144  	}
   145  
   146  	initiateOptionalParams := &gate.V2CanaryControllerApiInitiateCanaryWithConfigUsingPOSTOpts{}
   147  	if options.metricsAccount != "" {
   148  		initiateOptionalParams.MetricsAccountName = optional.NewString(options.metricsAccount)
   149  	}
   150  	if options.storageAccount != "" {
   151  		initiateOptionalParams.StorageAccountName = optional.NewString(options.storageAccount)
   152  	}
   153  
   154  	options.Ui.Info("Initiating canary execution for supplied canary config")
   155  	canaryExecutionResp, initiateResp, initiateErr := options.GateClient.V2CanaryControllerApi.InitiateCanaryWithConfigUsingPOST(options.GateClient.Context, adhocRequest, initiateOptionalParams)
   156  
   157  	if initiateErr != nil {
   158  		return initiateErr
   159  	}
   160  
   161  	if initiateResp.StatusCode != http.StatusOK {
   162  		return fmt.Errorf(
   163  			"Encountered an unexpected status code %d initiating execution for canary config\n",
   164  			initiateResp.StatusCode)
   165  	}
   166  
   167  	canaryExecutionId := canaryExecutionResp.(map[string]interface{})["canaryExecutionId"].(string)
   168  	options.Ui.Info(fmt.Sprintf("Spawned canary execution with id %s, polling for completion...", canaryExecutionId))
   169  
   170  	queryOptionalParams := &gate.V2CanaryControllerApiGetCanaryResultUsingGET1Opts{}
   171  	if options.storageAccount != "" {
   172  		queryOptionalParams.StorageAccountName = optional.NewString(options.storageAccount)
   173  	}
   174  
   175  	canaryResult, canaryResultResp, canaryResultErr := options.GateClient.V2CanaryControllerApi.GetCanaryResultUsingGET1(options.GateClient.Context, canaryExecutionId, queryOptionalParams)
   176  	if canaryResultErr != nil {
   177  		return canaryResultErr
   178  	}
   179  
   180  	if canaryResultResp.StatusCode != http.StatusOK {
   181  		return fmt.Errorf(
   182  			"Encountered an unexpected status code %d querying canary execution with id: %s\n",
   183  			canaryResultResp.StatusCode, canaryExecutionId)
   184  	}
   185  
   186  	complete := canaryResult.(map[string]interface{})["complete"].(bool)
   187  
   188  	retries := 0
   189  	for retries < 10 && !complete && canaryResultErr == nil {
   190  		canaryResult, canaryResultResp, canaryResultErr = options.GateClient.V2CanaryControllerApi.GetCanaryResultUsingGET1(options.GateClient.Context, canaryExecutionId, queryOptionalParams)
   191  		complete = canaryResult.(map[string]interface{})["complete"].(bool)
   192  		time.Sleep(retrySleepCycle)
   193  		retries += 1
   194  	}
   195  
   196  	if canaryResultErr != nil {
   197  		return canaryResultErr
   198  	}
   199  	if !complete {
   200  		return fmt.Errorf(
   201  			"Canary execution %s incomplete after 60 seconds, aborting", canaryExecutionId)
   202  	}
   203  
   204  	judgement := canaryResult.(map[string]interface{})["result"].(map[string]interface{})["judgeResult"].(map[string]interface{})["score"].(map[string]interface{})["classification"].(string)
   205  
   206  	options.Ui.Info(fmt.Sprintf("Retrospective canary execution finished, judgement = %s", strings.ToUpper(judgement)))
   207  	if options.fullResult {
   208  		options.Ui.JsonOutput(canaryResult)
   209  	}
   210  	return nil
   211  }
   212  
   213  func validateOptions(options *retroOptions) error {
   214  	if options.controlGroup == "" || options.controlLocation == "" {
   215  		return errors.New("Required control group flags not supplied")
   216  	}
   217  
   218  	if options.experimentGroup == "" || options.experimentLocation == "" {
   219  		return errors.New("Required experiment group flags not supplied")
   220  	}
   221  
   222  	if options.startInstant == "" || options.endInstant == "" {
   223  		return errors.New("Required time interval flags not supplied")
   224  	}
   225  	return nil
   226  }