github.com/facebookincubator/ttpforge@v1.0.13-0.20240405153150-5ae801628835/pkg/args/spec.go (about)

     1  /*
     2  Copyright © 2023-present, Meta Platforms, Inc. and affiliates
     3  Permission is hereby granted, free of charge, to any person obtaining a copy
     4  of this software and associated documentation files (the "Software"), to deal
     5  in the Software without restriction, including without limitation the rights
     6  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
     7  copies of the Software, and to permit persons to whom the Software is
     8  furnished to do so, subject to the following conditions:
     9  The above copyright notice and this permission notice shall be included in
    10  all copies or substantial portions of the Software.
    11  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    12  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    13  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    14  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    15  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    16  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
    17  THE SOFTWARE.
    18  */
    19  
    20  package args
    21  
    22  import (
    23  	"errors"
    24  	"fmt"
    25  	"regexp"
    26  	"strconv"
    27  	"strings"
    28  
    29  	"github.com/facebookincubator/ttpforge/pkg/fileutils"
    30  )
    31  
    32  // Spec defines a CLI argument for the TTP
    33  type Spec struct {
    34  	Name    string   `yaml:"name"`
    35  	Type    string   `yaml:"type,omitempty"`
    36  	Default string   `yaml:"default,omitempty"`
    37  	Choices []string `yaml:"choices,omitempty"`
    38  	Format  string   `yaml:"regexp,omitempty"`
    39  
    40  	formatReg *regexp.Regexp
    41  }
    42  
    43  // ParseAndValidate checks that the provided arguments
    44  // match the argument specifications for this TTP
    45  //
    46  // **Parameters:**
    47  //
    48  // specs: slice of argument Spec values loaded from the TTP yaml
    49  // argKvStrs: slice of arguments in "ARG_NAME=ARG_VALUE" format
    50  //
    51  // **Returns:**
    52  //
    53  // map[string]string: the parsed and validated argument key-value pairs
    54  // error: an error if there is a problem
    55  func ParseAndValidate(specs []Spec, argsKvStrs []string) (map[string]any, error) {
    56  
    57  	// validate the specs
    58  	processedArgs := make(map[string]any)
    59  	specsByName := make(map[string]Spec)
    60  	for _, spec := range specs {
    61  		if spec.Name == "" {
    62  			return nil, errors.New("argument name cannot be empty")
    63  		}
    64  
    65  		err := spec.validateChoiceTypes()
    66  		if err != nil {
    67  			return nil, fmt.Errorf("failed to validate types of choice values: %w", err)
    68  		}
    69  
    70  		// set the default value, will be overwritten by passed value
    71  		if spec.Default != "" {
    72  			if !spec.isValidChoice(spec.Default) {
    73  				return nil, fmt.Errorf("invalid default value: %v, allowed values: %v ", spec.Default, strings.Join(spec.Choices, ", "))
    74  			}
    75  
    76  			defaultVal, err := spec.convertArgToType(spec.Default)
    77  			if err != nil {
    78  				return nil, fmt.Errorf("default value type does not match spec: %w", err)
    79  			}
    80  			processedArgs[spec.Name] = defaultVal
    81  		}
    82  
    83  		// set Format to match whole string
    84  		// check if first and last character are ^ and $ respectively
    85  		// append and prepend if missing
    86  		// if Format string is missing ^$ then we are subject to partial matches
    87  		if spec.Format != "" {
    88  			if err := verifyCanUseWithRegexp(spec); err != nil {
    89  				return nil, err
    90  			}
    91  			spec.formatReg, err = regexp.Compile(spec.Format)
    92  			if err != nil {
    93  				return nil, fmt.Errorf("invalid regular expression supplied to arg spec format: %w", err)
    94  			}
    95  		}
    96  
    97  		if _, ok := specsByName[spec.Name]; ok {
    98  			return nil, fmt.Errorf("duplicate argument name: %v", spec.Name)
    99  		}
   100  		specsByName[spec.Name] = spec
   101  	}
   102  
   103  	// validate the inputs
   104  	for _, argKvStr := range argsKvStrs {
   105  		argKv := strings.SplitN(argKvStr, "=", 2)
   106  		if len(argKv) != 2 {
   107  			return nil, fmt.Errorf("invalid argument specification string: %v", argKvStr)
   108  		}
   109  		argName := argKv[0]
   110  		argVal := argKv[1]
   111  
   112  		// passed foo=bar with no argument foo defined in specs
   113  		spec, ok := specsByName[argName]
   114  		if !ok {
   115  			return nil, fmt.Errorf("received unexpected argument: %v ", argName)
   116  		}
   117  
   118  		if !spec.isValidChoice(argVal) {
   119  			return nil, fmt.Errorf("received unexpected value: %v, allowed values: %v ", argVal, strings.Join(spec.Choices, ", "))
   120  		}
   121  
   122  		if spec.formatReg != nil && !spec.formatReg.MatchString(argVal) {
   123  			return nil, fmt.Errorf("invalid value format: %v, expected regex format: %v ", argVal, spec.Format)
   124  		}
   125  
   126  		typedVal, err := spec.convertArgToType(argVal)
   127  		if err != nil {
   128  			return nil, fmt.Errorf(
   129  				"failed to process value '%v' specified for argument '%v': %v",
   130  				argVal,
   131  				argName,
   132  				err,
   133  			)
   134  		}
   135  
   136  		// valid arg value - save
   137  		processedArgs[argName] = typedVal
   138  	}
   139  
   140  	// error if argument was not provided and no default value was specified
   141  	for _, spec := range specs {
   142  		if _, ok := processedArgs[spec.Name]; !ok {
   143  			return nil, fmt.Errorf("value for required argument '%v' was not provided and no default value was specified", spec.Name)
   144  		}
   145  	}
   146  	return processedArgs, nil
   147  }
   148  
   149  func (spec Spec) convertArgToType(val string) (any, error) {
   150  	switch spec.Type {
   151  	case "", "string":
   152  		// string is the default - any string is valid
   153  		return val, nil
   154  	case "int":
   155  		asInt, err := strconv.Atoi(val)
   156  		if err != nil {
   157  			return nil, errors.New("non-integer value provided")
   158  		}
   159  		return asInt, nil
   160  	case "bool":
   161  		asBool, err := strconv.ParseBool(val)
   162  		if err != nil {
   163  			return nil, errors.New("no-boolean value provided")
   164  		}
   165  		return asBool, nil
   166  	case "path":
   167  		absPath, err := fileutils.AbsPath(val)
   168  		if err != nil {
   169  			return nil, fmt.Errorf("failed to process argument of type `path`: %w", err)
   170  		}
   171  		return absPath, nil
   172  	default:
   173  		return nil, fmt.Errorf("invalid type %v specified in configuration for argument %v", spec.Type, spec.Name)
   174  	}
   175  }