ops.go (307 lines of code) (raw):

// Licensed to the Apache Software Foundation (ASF) under one // or more contributor license agreements. See the NOTICE file // distributed with this work for additional information // regarding copyright ownership. The ASF licenses this file // to you under the Apache License, Version 2.0 (the // "License"); you may not use this file except in compliance // with the License. You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, // software distributed under the License is distributed on an // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY // KIND, either express or implied. See the License for the // specific language governing permissions and limitations // under the License. package openserverless import ( "bufio" "fmt" "os" "regexp" "sort" "strings" "github.com/a8m/envsubst/parse" "github.com/apache/openserverless-cli/tools" docopt "github.com/docopt/docopt-go" "github.com/mitchellh/go-homedir" "golang.org/x/exp/slices" "gopkg.in/yaml.v3" ) type TaskNotFoundErr struct { input string } func (e *TaskNotFoundErr) Error() string { return fmt.Sprintf("no command named %s found", e.input) } func help(helpMessage string) error { if helpMessage != "" { fmt.Println(helpMessage) return nil } // In case of syntax error, Task will return an error list := "-l" if os.Getenv("OPS_NO_DOCOPTS") != "" { list = "--list-all" } _, err := Task("-t", OPSFILE, list) return err } // parseArgs parse the arguments acording the docopt // it returns a sequence suitable to be feed as arguments for task. // note that it will change hyphens for flags ('-c', '--count') to '_' ('_c' '__count') // and '<' and '>' for parameters '_' (<hosts> => _hosts_) // boolean are "true" or "false" and arrays in the form ('first' 'second') // suitable to be used as arrays // Examples: // if "Usage: nettool ping [--count=<max>] <hosts>..." // with "ping --count=3 google apple" returns // ping=true _count=3 _hosts_=('google' 'apple') func parseArgs(usage string, args []string) []string { res := []string{} // parse args parser := docopt.Parser{} opts, err := parser.ParseArgs(usage, args, OpsVersion) if err != nil { warn(err) return res } for k, v := range opts { kk := strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(k, "-", "_"), "<", "_"), ">", "_") vv := "" //fmt.Println(v, reflect.TypeOf(v)) switch o := v.(type) { case bool: vv = "false" if o { vv = "true" } case string: vv = o case []string: a := []string{} for _, i := range o { a = append(a, fmt.Sprintf("'%v'", i)) } vv = "(" + strings.Join(a, " ") + ")" case nil: vv = "" } res = append(res, fmt.Sprintf("%s=%s", kk, vv)) } sort.Strings(res) return res } // sets up a tmp folder and OPS_TMP envvar func setupTmp() error { var err error tmp := os.Getenv("OPS_TMP") if tmp == "" { tmp, err = homedir.Expand("~/.ops/tmp") if err != nil { return err } os.Setenv("OPS_TMP", tmp) } return os.MkdirAll(tmp, 0755) } // load saved args in files names _*_ in current directory func loadSavedArgs() []string { res := []string{} files, err := os.ReadDir(".") if err != nil { return res } r := regexp.MustCompile(`^_.+_$`) // regex to match file names that start and end with '_' for _, f := range files { if !f.IsDir() && r.MatchString(f.Name()) { debug("reading vars from " + f.Name()) file, err := os.Open(f.Name()) if err != nil { warn("cannot read " + f.Name()) continue } scanner := bufio.NewScanner(file) r := regexp.MustCompile(`^[a-zA-Z0-9]+=`) // regex to match lines that start with an alphanumeric sequence followed by '=' for scanner.Scan() { line := scanner.Text() if r.MatchString(line) { debug("found var " + line) res = append(res, line) } } err = scanner.Err() //nolint:errcheck file.Close() if err != nil { warn(err) continue } } } return res } // read docpts // return the doppts and the help text // expand the environment variables // if the file is markdown expand the markdown func readDocOpts() (string, string) { if os.Getenv("OPS_NO_DOCOPTS") != "" { if exists(".", DOCOPTS_TXT) { fmt.Printf("Warning: ignoring %s - you have to provide all the options.\n", DOCOPTS_TXT) } if exists(".", DOCOPTS_MD) { fmt.Printf("Warning: ignoring %s - you have to provide all the options.\n", DOCOPTS_MD) } return "", "" } if exists(".", DOCOPTS_MD) { text := readfile(DOCOPTS_MD) // parse embedded variables restrictions := &parse.Restrictions{false, false, true} result, err := (&parse.Parser{Name: "string", Env: os.Environ(), Restrict: restrictions, Mode: parse.AllErrors}).Parse(text) if err != nil { return "", err.Error() } // convert to markdown help := tools.MarkdownToText(result) // extract the embedded usage opts := tools.ExtractUsage(result) // warn if there is a docopts.txt if exists(".", DOCOPTS_TXT) { help = fmt.Sprintf("%s\nWarning: both %s and %s are present, %s ignored.", help, DOCOPTS_TXT, DOCOPTS_MD, DOCOPTS_TXT) } return opts, help } if exists(".", DOCOPTS_TXT) { text := readfile(DOCOPTS_TXT) restrictions := &parse.Restrictions{false, false, true} help, err := (&parse.Parser{Name: "string", Env: os.Environ(), Restrict: restrictions, Mode: parse.AllErrors}).Parse(text) if err != nil { help = err.Error() } return help, help } return "", "" } // Ops parses args moving into the folder corresponding to args // then parses them with docopts and invokes the task func Ops(base string, args []string) error { trace("Ops run in", base, "with", args) // go down using args as subcommands err := os.Chdir(base) debug("Ops chdir", base) if err != nil { return err } rest := args isSubCmd := false err = ensurePrereq(base) debug("Ops ensurePrereq", err) if err != nil { fmt.Println("ERROR: cannot ensure prerequisites: " + err.Error()) os.Exit(1) } for _, task := range args { trace("task name", task) // skip flags if strings.HasPrefix(task, "-") { continue } // try to correct name if it's not a flag pwd, _ := os.Getwd() taskName, err := validateTaskName(pwd, task) if err != nil { return err } // if valid, check if it's a folder and move to it if isDir(taskName) && exists(taskName, OPSFILE) { if err := os.Chdir(taskName); err != nil { return err } err = ensurePrereq(joinpath(pwd, taskName)) if err != nil { fmt.Println("ERROR: cannot ensure prerequisites" + err.Error()) os.Exit(1) } //remove it from the args rest = rest[1:] isSubCmd = true } else { // stop when non folder reached //substitute it with the validated task name if len(rest) > 0 { rest[0] = taskName } break } } // read docopts and help text opts, helpMessage := readDocOpts() // print help if len(rest) == 0 || rest[0] == "help" { err := help(helpMessage) if !isSubCmd { fmt.Println() return printPluginsHelp() } return err } // load saved args savedArgs := loadSavedArgs() // parse options if an opts file is available trace("DOCOPTS:", opts) if opts != "" { debug("PREPARSE:", rest) // parse args parsedArgs := parseArgs(opts, rest) trace("DOCOPTS: parsedargs=", parsedArgs) // append -t optfile.yml to the Task cli prefix := []string{"-t", OPSFILE} if len(rest) > 0 && rest[0][0] != '-' { prefix = append(prefix, rest[0]) } // parse args parsedArgs = append(savedArgs, parsedArgs...) parsedArgs = append(prefix, parsedArgs...) extra := os.Getenv("EXTRA") if extra != "" { trace("EXTRA:", extra) parsedArgs = append(parsedArgs, strings.Split(extra, " ")...) } trace("POSTPARSE:", parsedArgs) _, err := Task(parsedArgs...) return err } mainTask := rest[0] // unparsed args - separate variable assignments from extra args pre := []string{"-t", OPSFILE, mainTask} pre = append(pre, savedArgs...) post := []string{"--"} args1 := rest[1:] extra := os.Getenv("EXTRA") if extra != "" { trace("EXTRA:", extra) args1 = append(args1, strings.Split(extra, " ")...) } for _, s := range args1 { if strings.Contains(s, "=") { pre = append(pre, s) } else { post = append(post, s) } } taskArgs := append(pre, post...) debug("task args: ", taskArgs) _, err = Task(taskArgs...) return err } // validateTaskName does the following: // 1. Check that the given task name is found in the opsfile.yaml and return it // 2. If not found, check if the input is a prefix of any task name, if it is for only one return the proper task name // 3. If the prefix is valid for more than one task, return an error // 4. If the prefix is not valid for any task, return an error func validateTaskName(dir string, name string) (string, error) { if name == "" { return "", fmt.Errorf("command name is empty") } candidates := []string{} tasks := getTaskNamesList(dir) if !slices.Contains(tasks, "help") { tasks = append(tasks, "help") } for _, t := range tasks { if t == name { return name, nil } if strings.HasPrefix(t, name) { candidates = append(candidates, t) } } if len(candidates) == 0 { return "", &TaskNotFoundErr{input: name} } if len(candidates) == 1 { return candidates[0], nil } return "", fmt.Errorf("ambiguous command: %s. Possible matches: %v", name, candidates) } // obtains the task names from the opsfile.yaml inside the given directory func getTaskNamesList(dir string) []string { m := make(map[interface{}]interface{}) var taskNames []string if exists(dir, OPSFILE) { dat, err := os.ReadFile(joinpath(dir, OPSFILE)) if err != nil { return make([]string, 0) } err = yaml.Unmarshal(dat, &m) if err != nil { warn("error reading opsfile.yml") return make([]string, 0) } tasksMap, ok := m["tasks"].(map[string]interface{}) if !ok { // warn("error checking task list, perhaps no tasks defined?") return make([]string, 0) } for k := range tasksMap { taskNames = append(taskNames, k) } } // for each subfolder, check if it has a opsfile.yaml // if it does, add it to the list of tasks // get subfolders subfolders, err := os.ReadDir(dir) if err != nil { warn("error reading subfolders of", dir) return taskNames } for _, f := range subfolders { if f.IsDir() { subfolder := joinpath(dir, f.Name()) if exists(subfolder, OPSFILE) { // check if not contained name := f.Name() if !slices.Contains(taskNames, name) { taskNames = append(taskNames, name) } } } } return taskNames }