github.com/choria-io/go-choria@v0.28.1-0.20240416190746-b3bf9c7d5a45/internal/util/util.go (about)

     1  // Copyright (c) 2021-2022, R.I. Pienaar and the Choria Project contributors
     2  //
     3  // SPDX-License-Identifier: Apache-2.0
     4  
     5  package util
     6  
     7  import (
     8  	"bytes"
     9  	"context"
    10  	"encoding/base64"
    11  	"encoding/json"
    12  	"errors"
    13  	"fmt"
    14  	"math"
    15  	"math/rand"
    16  	"os"
    17  	"os/exec"
    18  	"path/filepath"
    19  	"regexp"
    20  	"runtime"
    21  	"sort"
    22  	"strconv"
    23  	"strings"
    24  	"text/template"
    25  	"time"
    26  	"unicode"
    27  
    28  	"github.com/santhosh-tekuri/jsonschema/v5"
    29  
    30  	"github.com/choria-io/go-choria/internal/fs"
    31  
    32  	"github.com/AlecAivazis/survey/v2"
    33  	"github.com/gofrs/uuid"
    34  	xtablewriter "github.com/xlab/tablewriter"
    35  	"golang.org/x/text/cases"
    36  	"golang.org/x/text/language"
    37  
    38  	"github.com/choria-io/go-choria/backoff"
    39  	"github.com/choria-io/go-choria/build"
    40  )
    41  
    42  var ConstantBackOffForTests = backoff.Policy{
    43  	Millis: []int{25},
    44  }
    45  
    46  // BuildInfo retrieves build information
    47  func BuildInfo() *build.Info {
    48  	return &build.Info{}
    49  }
    50  
    51  // GovernorSubject the subject to use for choria managed Governors within a collective
    52  func GovernorSubject(name string, collective string) string {
    53  	return fmt.Sprintf("%s.governor.%s", collective, name)
    54  }
    55  
    56  // UserConfig determines what is the active config file for a user
    57  func UserConfig() string {
    58  	home, _ := HomeDir()
    59  
    60  	if home != "" {
    61  		for _, n := range []string{".choriarc", ".mcollective"} {
    62  			homeCfg := filepath.Join(home, n)
    63  
    64  			if FileExist(homeCfg) {
    65  				return homeCfg
    66  			}
    67  		}
    68  	}
    69  
    70  	if runtime.GOOS == "windows" {
    71  		return filepath.Join("C:\\", "ProgramData", "choria", "etc", "client.conf")
    72  	}
    73  
    74  	if FileExist("/etc/choria/client.conf") {
    75  		return "/etc/choria/client.conf"
    76  	}
    77  
    78  	if FileExist("/usr/local/etc/choria/client.conf") {
    79  		return "/usr/local/etc/choria/client.conf"
    80  	}
    81  
    82  	return "/etc/puppetlabs/mcollective/client.cfg"
    83  }
    84  
    85  // FileExist checks if a file exist on disk
    86  func FileExist(path string) bool {
    87  	if path == "" {
    88  		return false
    89  	}
    90  
    91  	if _, err := os.Stat(path); os.IsNotExist(err) {
    92  		return false
    93  	}
    94  
    95  	return true
    96  }
    97  
    98  // HomeDir determines the home location without using the user package or requiring cgo
    99  //
   100  // On Unix it needs HOME set and on windows HOMEDRIVE and HOMEDIR
   101  func HomeDir() (string, error) {
   102  	if runtime.GOOS == "windows" {
   103  		drive := os.Getenv("HOMEDRIVE")
   104  		home := os.Getenv("HOMEDIR")
   105  
   106  		if home == "" || drive == "" {
   107  			return "", fmt.Errorf("cannot determine home dir, ensure HOMEDRIVE and HOMEDIR is set")
   108  		}
   109  
   110  		return filepath.Join(os.Getenv("HOMEDRIVE"), os.Getenv("HOMEDIR")), nil
   111  	}
   112  
   113  	home := os.Getenv("HOME")
   114  
   115  	if home == "" {
   116  		return "", fmt.Errorf("cannot determine home dir, ensure HOME is set")
   117  	}
   118  
   119  	return home, nil
   120  
   121  }
   122  
   123  // MatchAnyRegex checks str against a list of possible regex, if any match true is returned
   124  func MatchAnyRegex(str []byte, regex []string) bool {
   125  	for _, reg := range regex {
   126  		if matched, _ := regexp.Match(reg, str); matched {
   127  			return true
   128  		}
   129  	}
   130  
   131  	return false
   132  }
   133  
   134  // StringInList checks if match is in list
   135  func StringInList(list []string, match string) bool {
   136  	for _, i := range list {
   137  		if i == match {
   138  			return true
   139  		}
   140  	}
   141  
   142  	return false
   143  }
   144  
   145  // InterruptibleSleep sleep for the duration in a way that can be interrupted by the context.
   146  // An error is returned if the context cancels the sleep
   147  func InterruptibleSleep(ctx context.Context, d time.Duration) error {
   148  	timer := time.NewTimer(d)
   149  	select {
   150  	case <-timer.C:
   151  		return nil
   152  	case <-ctx.Done():
   153  		timer.Stop()
   154  		return fmt.Errorf("sleep interrupted by context")
   155  	}
   156  }
   157  
   158  // UniqueID creates a new unique ID, usually a v4 uuid, if that fails a random string based ID is made
   159  func UniqueID() (id string) {
   160  	uuid, err := uuid.NewV4()
   161  	if err == nil {
   162  		return uuid.String()
   163  	}
   164  
   165  	parts := []string{}
   166  	parts = append(parts, randStringRunes(8))
   167  	parts = append(parts, randStringRunes(4))
   168  	parts = append(parts, randStringRunes(4))
   169  	parts = append(parts, randStringRunes(12))
   170  
   171  	return strings.Join(parts, "-")
   172  }
   173  
   174  func randStringRunes(n int) string {
   175  	letterRunes := []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
   176  
   177  	b := make([]rune, n)
   178  	for i := range b {
   179  		b[i] = letterRunes[rand.Intn(len(letterRunes))]
   180  	}
   181  
   182  	return string(b)
   183  }
   184  
   185  // LongestString determines the length of the longest string in list, capped at max
   186  func LongestString(list []string, max int) int {
   187  	longest := 0
   188  	for _, i := range list {
   189  		if len(i) > longest {
   190  			longest = len(i)
   191  		}
   192  
   193  		if max != 0 && longest > max {
   194  			return max
   195  		}
   196  	}
   197  
   198  	return longest
   199  }
   200  
   201  // ParagraphPadding pads paragraph with padding spaces
   202  func ParagraphPadding(paragraph string, padding int) string {
   203  	parts := strings.Split(paragraph, "\n")
   204  	ps := fmt.Sprintf("%"+strconv.Itoa(padding)+"s", " ")
   205  
   206  	for i := range parts {
   207  		parts[i] = ps + parts[i]
   208  	}
   209  
   210  	return strings.Join(parts, "\n")
   211  }
   212  
   213  // SliceGroups takes a slice of words and make new chunks of given size
   214  // and call the function with the sub slice.  If there are not enough
   215  // items in the input slice empty strings will pad the last group
   216  func SliceGroups(input []string, size int, fn func(group []string)) {
   217  	// how many to add
   218  	padding := size - (len(input) % size)
   219  
   220  	if padding != size {
   221  		p := []string{}
   222  
   223  		for i := 0; i <= padding; i++ {
   224  			p = append(p, "")
   225  		}
   226  
   227  		input = append(input, p...)
   228  	}
   229  
   230  	// how many chunks we're making
   231  	count := len(input) / size
   232  
   233  	for i := 0; i < count; i++ {
   234  		chunk := input[i*size : i*size+size]
   235  		fn(chunk)
   236  	}
   237  }
   238  
   239  // SliceVerticalGroups takes a slice of words and make new chunks of given size
   240  // and call the function with the sub slice.  The results are ordered for
   241  // vertical alignment.  If there are not enough items in the input slice empty
   242  // strings will pad the last group
   243  func SliceVerticalGroups(input []string, size int, fn func(group []string)) {
   244  	// how many to add
   245  	padding := size - (len(input) % size)
   246  
   247  	if padding != size {
   248  		p := []string{}
   249  
   250  		for i := 0; i <= padding; i++ {
   251  			p = append(p, "")
   252  		}
   253  
   254  		input = append(input, p...)
   255  	}
   256  
   257  	// how many chunks we're making
   258  	count := len(input) / size
   259  
   260  	for i := 0; i < count; i++ {
   261  		chunk := []string{}
   262  		for s := 0; s < size; s++ {
   263  			chunk = append(chunk, input[i+s*count])
   264  		}
   265  		fn(chunk)
   266  	}
   267  }
   268  
   269  // StrToBool converts a typical mcollective boolianish string to bool
   270  func StrToBool(s string) (bool, error) {
   271  	clean := strings.TrimSpace(s)
   272  
   273  	if regexp.MustCompile(`(?i)^(1|yes|true|y|t)$`).MatchString(clean) {
   274  		return true, nil
   275  	}
   276  
   277  	if regexp.MustCompile(`(?i)^(0|no|false|n|f)$`).MatchString(clean) {
   278  		return false, nil
   279  	}
   280  
   281  	return false, fmt.Errorf("cannot convert string value '%s' into a boolean", clean)
   282  }
   283  
   284  func FileIsRegular(path string) bool {
   285  	stat, err := os.Stat(path)
   286  	if err != nil {
   287  		return false
   288  	}
   289  
   290  	return stat.Mode().IsRegular()
   291  }
   292  
   293  func FileIsDir(path string) bool {
   294  	if path == "" {
   295  		return false
   296  	}
   297  
   298  	stat, err := os.Stat(path)
   299  	if err != nil {
   300  		return false
   301  	}
   302  
   303  	return stat.IsDir()
   304  }
   305  
   306  func UniqueStrings(items []string, shouldSort bool) []string {
   307  	keys := make(map[string]struct{})
   308  	result := []string{}
   309  	for _, i := range items {
   310  		_, ok := keys[i]
   311  		if !ok {
   312  			keys[i] = struct{}{}
   313  			result = append(result, i)
   314  		}
   315  	}
   316  
   317  	if shouldSort {
   318  		sort.Strings(result)
   319  	}
   320  
   321  	return result
   322  }
   323  
   324  // ExpandPath expands a path that starts in ~ to the users homedir
   325  func ExpandPath(p string) (string, error) {
   326  	a := strings.TrimSpace(p)
   327  	if a[0] == '~' {
   328  		home, err := HomeDir()
   329  		if err != nil {
   330  			return "", err
   331  		}
   332  		a = strings.Replace(a, "~", home, 1)
   333  	}
   334  	return a, nil
   335  }
   336  
   337  // NewUTF8TableWithTitle creates a table formatted with UTF8 styling with a title set
   338  func NewUTF8TableWithTitle(title string, hdr ...any) *xtablewriter.Table {
   339  	table := xtablewriter.CreateTable()
   340  	table.UTF8Box()
   341  
   342  	if title != "" {
   343  		table.AddTitle(title)
   344  	}
   345  
   346  	table.AddHeaders(hdr...)
   347  
   348  	return table
   349  }
   350  
   351  // NewUTF8Table creates a table formatted with UTF8 styling
   352  func NewUTF8Table(hdr ...any) *xtablewriter.Table {
   353  	return NewUTF8TableWithTitle("", hdr...)
   354  }
   355  
   356  // StringsMapKeys returns the keys from a map[string]string in sorted order
   357  func StringsMapKeys(data map[string]string) []string {
   358  	keys := make([]string, len(data))
   359  	i := 0
   360  	for k := range data {
   361  		keys[i] = k
   362  		i++
   363  	}
   364  
   365  	sort.Strings(keys)
   366  
   367  	return keys
   368  }
   369  
   370  // IterateStringsMap iterates a map[string]string in key sorted order
   371  func IterateStringsMap(data map[string]string, cb func(k string, v string)) {
   372  	for _, k := range StringsMapKeys(data) {
   373  		cb(k, data[k])
   374  	}
   375  }
   376  
   377  // DumpMapStrings shows k: v of a map[string]string left padded by int, the k will be right aligned and value left aligned
   378  func DumpMapStrings(data map[string]string, leftPad int) {
   379  	longest := LongestString(StringsMapKeys(data), 0) + leftPad
   380  
   381  	IterateStringsMap(data, func(k, v string) {
   382  		fmt.Printf("%s: %s\n", strings.Repeat(" ", longest-len(k))+k, v)
   383  	})
   384  }
   385  
   386  // DumpJSONIndent dumps data to stdout as indented JSON
   387  func DumpJSONIndent(data any) error {
   388  	j, err := json.MarshalIndent(data, "", "  ")
   389  	if err != nil {
   390  		return err
   391  	}
   392  	fmt.Println(string(j))
   393  
   394  	return nil
   395  }
   396  
   397  func DumpJSONIndentedFormatted(data []byte, indent int) error {
   398  	var out bytes.Buffer
   399  	err := json.Indent(&out, data, "", "  ")
   400  	if err != nil {
   401  		return err
   402  	}
   403  
   404  	fmt.Println(ParagraphPadding(out.String(), indent))
   405  
   406  	return nil
   407  }
   408  
   409  // RenderDuration create a string similar to what %v on a duration would but it supports years, months, etc.
   410  // Being that days and years are influenced by leap years and such it will never be 100% accurate but for
   411  // feedback on the terminal its sufficient
   412  func RenderDuration(d time.Duration) string {
   413  	if d == math.MaxInt64 {
   414  		return "never"
   415  	}
   416  
   417  	if d == 0 {
   418  		return "forever"
   419  	}
   420  
   421  	tsecs := d / time.Second
   422  	tmins := tsecs / 60
   423  	thrs := tmins / 60
   424  	tdays := thrs / 24
   425  	tyrs := tdays / 365
   426  
   427  	if tyrs > 0 {
   428  		return fmt.Sprintf("%dy%dd%dh%dm%ds", tyrs, tdays%365, thrs%24, tmins%60, tsecs%60)
   429  	}
   430  
   431  	if tdays > 0 {
   432  		return fmt.Sprintf("%dd%dh%dm%ds", tdays, thrs%24, tmins%60, tsecs%60)
   433  	}
   434  
   435  	if thrs > 0 {
   436  		return fmt.Sprintf("%dh%dm%ds", thrs, tmins%60, tsecs%60)
   437  	}
   438  
   439  	if tmins > 0 {
   440  		return fmt.Sprintf("%dm%ds", tmins, tsecs%60)
   441  	}
   442  
   443  	return fmt.Sprintf("%.2fs", d.Seconds())
   444  }
   445  
   446  // ParseDuration is an extended version of go duration parsing that
   447  // also supports w,W,d,D,M,Y,y in addition to what go supports
   448  func ParseDuration(dstr string) (dur time.Duration, err error) {
   449  	dstr = strings.TrimSpace(dstr)
   450  
   451  	if len(dstr) <= 0 {
   452  		return dur, nil
   453  	}
   454  
   455  	ls := len(dstr)
   456  	di := ls - 1
   457  	unit := dstr[di:]
   458  
   459  	switch unit {
   460  	case "w", "W":
   461  		val, err := strconv.ParseFloat(dstr[:di], 32)
   462  		if err != nil {
   463  			return dur, err
   464  		}
   465  
   466  		dur = time.Duration(val*7*24) * time.Hour
   467  
   468  	case "d", "D":
   469  		val, err := strconv.ParseFloat(dstr[:di], 32)
   470  		if err != nil {
   471  			return dur, err
   472  		}
   473  
   474  		dur = time.Duration(val*24) * time.Hour
   475  	case "M":
   476  		val, err := strconv.ParseFloat(dstr[:di], 32)
   477  		if err != nil {
   478  			return dur, err
   479  		}
   480  
   481  		dur = time.Duration(val*24*30) * time.Hour
   482  	case "Y", "y":
   483  		val, err := strconv.ParseFloat(dstr[:di], 32)
   484  		if err != nil {
   485  			return dur, err
   486  		}
   487  
   488  		dur = time.Duration(val*24*365) * time.Hour
   489  	case "s", "S", "m", "h", "H":
   490  		dur, err = time.ParseDuration(dstr)
   491  		if err != nil {
   492  			return dur, err
   493  		}
   494  
   495  	default:
   496  		return dur, fmt.Errorf("invalid time unit %s", unit)
   497  	}
   498  
   499  	return dur, nil
   500  }
   501  
   502  // PromptForConfirmation asks for confirmation on the CLI
   503  func PromptForConfirmation(format string, a ...any) (bool, error) {
   504  	ans := false
   505  	err := survey.AskOne(&survey.Confirm{
   506  		Message: fmt.Sprintf(format, a...),
   507  		Default: ans,
   508  	}, &ans)
   509  
   510  	return ans, err
   511  }
   512  
   513  // IsPrintable determines if a string is printable
   514  func IsPrintable(s string) bool {
   515  	for _, r := range s {
   516  		if r > unicode.MaxASCII || !unicode.IsPrint(r) {
   517  			return false
   518  		}
   519  	}
   520  	return true
   521  }
   522  
   523  // Base64IfNotPrintable returns a string value that's either the given value or base64 encoded if not IsPrintable()
   524  func Base64IfNotPrintable(val []byte) string {
   525  	if IsPrintable(string(val)) {
   526  		return string(val)
   527  	}
   528  
   529  	return base64.StdEncoding.EncodeToString(val)
   530  }
   531  
   532  func tStringsJoin(s []string) string {
   533  	return strings.Join(s, ", ")
   534  }
   535  
   536  func tBase64Encode(v string) string {
   537  	return base64.StdEncoding.EncodeToString([]byte(v))
   538  }
   539  
   540  func tBase64Decode(v string) (string, error) {
   541  	r, err := base64.StdEncoding.DecodeString(v)
   542  	if err != nil {
   543  		return "", err
   544  	}
   545  
   546  	return string(r), nil
   547  }
   548  
   549  func FuncMap(f map[string]any) template.FuncMap {
   550  	fm := map[string]any{
   551  		"Title":        cases.Title(language.AmericanEnglish).String,
   552  		"Capitalize":   cases.Title(language.AmericanEnglish).String,
   553  		"ToLower":      strings.ToLower,
   554  		"ToUpper":      strings.ToUpper,
   555  		"StringsJoin":  tStringsJoin,
   556  		"Base64Encode": tBase64Encode,
   557  		"Base64Decode": tBase64Decode,
   558  	}
   559  
   560  	for k, v := range f {
   561  		fm[k] = v
   562  	}
   563  
   564  	return fm
   565  }
   566  
   567  func IsExecutableInPath(c string) bool {
   568  	p, err := exec.LookPath(c)
   569  	if err != nil {
   570  		return false
   571  	}
   572  
   573  	return p != ""
   574  }
   575  
   576  // HasPrefix checks if s has any one of prefixes
   577  func HasPrefix(s string, prefixes ...string) bool {
   578  	for _, p := range prefixes {
   579  		if strings.HasPrefix(s, p) {
   580  			return true
   581  		}
   582  	}
   583  
   584  	return false
   585  }
   586  
   587  var (
   588  	// ErrSchemaUnknown indicates the schema could not be found
   589  	ErrSchemaUnknown = errors.New("unknown schema")
   590  	// ErrSchemaValidationFailed indicates that the validator failed to perform validation, perhaps due to invalid schema
   591  	ErrSchemaValidationFailed = errors.New("validation failed")
   592  )
   593  
   594  func ValidateSchemaFromFS(schemaPath string, data any) (errors []string, err error) {
   595  	jschema, err := fs.FS.ReadFile(schemaPath)
   596  	if err != nil {
   597  		return nil, fmt.Errorf("%w: %v", ErrSchemaUnknown, err)
   598  	}
   599  
   600  	sch, err := jsonschema.CompileString(filepath.Base(schemaPath), string(jschema))
   601  	if err != nil {
   602  		return nil, fmt.Errorf("%w: compile error: %v", ErrSchemaValidationFailed, err)
   603  	}
   604  
   605  	err = sch.Validate(data)
   606  	if err != nil {
   607  		if verr, ok := err.(*jsonschema.ValidationError); ok {
   608  			for _, e := range verr.BasicOutput().Errors {
   609  				if e.KeywordLocation == "" || e.Error == "oneOf failed" || e.Error == "allOf failed" {
   610  					continue
   611  				}
   612  
   613  				if e.InstanceLocation == "" {
   614  					errors = append(errors, e.Error)
   615  				} else {
   616  					errors = append(errors, fmt.Sprintf("%s: %s", e.InstanceLocation, e.Error))
   617  				}
   618  			}
   619  			return errors, nil
   620  		} else {
   621  			return nil, fmt.Errorf("%s: validation failed: %v", ErrSchemaValidationFailed, err)
   622  		}
   623  	}
   624  
   625  	return nil, nil
   626  }