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 }