diff --git a/ingredients/cmd/cmd.go b/ingredients/cmd/cmd.go index 847075a..5efe7f5 100644 --- a/ingredients/cmd/cmd.go +++ b/ingredients/cmd/cmd.go @@ -3,75 +3,114 @@ package cmd import ( "context" "encoding/json" + "errors" "fmt" - nats "github.com/nats-io/nats.go" - "github.com/gogrlx/grlx/ingredients" "github.com/gogrlx/grlx/types" ) -var ec *nats.EncodedConn - -func init() { - baseCMD := Cmd{} - ingredients.RegisterAllMethods(baseCMD) -} +var ErrCmdMethodUndefined = fmt.Errorf("cmd method undefined") type Cmd struct { - ID string - Method string - Name string - RunAs string - Env []string - Path string - WorkingDir string - Async bool + id string + method string + params map[string]interface{} } -func RegisterEC(n *nats.EncodedConn) { - ec = n +// TODO parse out the map here +func (c Cmd) Parse(id, method string, params map[string]interface{}) (types.RecipeCooker, error) { + if params == nil { + params = map[string]interface{}{} + } + return Cmd{ + id: id, method: method, + params: params, + }, nil } -func New(id, method string, params map[string]interface{}) Cmd { - return Cmd{ID: id, Method: method} +func (c Cmd) validate() error { + set, err := c.PropertiesForMethod(c.method) + if err != nil { + return err + } + propSet, err := ingredients.PropMapToPropSet(set) + if err != nil { + return err + } + for _, v := range propSet { + if v.IsReq { + if v.Key == "name" { + name, ok := c.params[v.Key].(string) + if !ok { + return types.ErrMissingName + } + if name == "" { + return types.ErrMissingName + } + + } else { + if _, ok := c.params[v.Key]; !ok { + return fmt.Errorf("missing required property %s", v.Key) + } + } + } + } + return nil } func (c Cmd) Test(ctx context.Context) (types.Result, error) { - return types.Result{}, nil + return types.Result{ + Succeeded: true, + Failed: false, + Changed: false, + Notes: []fmt.Stringer{types.SimpleNote("cmd would have been executed")}, + }, nil } func (c Cmd) Apply(ctx context.Context) (types.Result, error) { - switch c.Method { + switch c.method { case "run": fallthrough default: - // TODO define error type - return types.Result{Succeeded: false, Failed: true, Changed: false, Notes: nil}, fmt.Errorf("method %s undefined", c.Method) + return types.Result{Succeeded: false, Failed: true, Changed: false, Notes: nil}, + errors.Join(ErrCmdMethodUndefined, fmt.Errorf("method %s undefined", c.method)) } } -func (c Cmd) Methods() (string, []string) { - return "cmd", []string{"run"} -} - // TODO create map for method: type func (c Cmd) PropertiesForMethod(method string) (map[string]string, error) { - return nil, nil + switch method { + case "run": + return ingredients.MethodPropsSet{ + ingredients.MethodProps{Key: "name", Type: "string", IsReq: true}, + ingredients.MethodProps{Key: "args", Type: "string", IsReq: false}, + ingredients.MethodProps{Key: "env", Type: "[]string", IsReq: false}, + ingredients.MethodProps{Key: "cwd", Type: "string", IsReq: false}, + ingredients.MethodProps{Key: "runas", Type: "string", IsReq: false}, + ingredients.MethodProps{Key: "path", Type: "string", IsReq: false}, + ingredients.MethodProps{Key: "timeout", Type: "string", IsReq: false}, + }.ToMap(), nil + default: + return nil, fmt.Errorf("method %s undefined", method) + } } -// TODO parse out the map here -func (c Cmd) Parse(id, method string, params map[string]interface{}) (types.RecipeCooker, error) { - return New(id, method, params), nil +func (c Cmd) Methods() (string, []string) { + return "cmd", []string{"run"} } func (c Cmd) Properties() (map[string]interface{}, error) { m := map[string]interface{}{} - b, err := json.Marshal(c) + b, err := json.Marshal(c.params) if err != nil { return m, err } err = json.Unmarshal(b, &m) return m, err } + +func init() { + ingredients.RegisterAllMethods(Cmd{}) +} diff --git a/ingredients/cmd/cmdRun.go b/ingredients/cmd/cmdRun.go new file mode 100644 index 0000000..efbfda6 --- /dev/null +++ b/ingredients/cmd/cmdRun.go @@ -0,0 +1,120 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "math" + "os/exec" + "os/user" + "runtime" + "strconv" + "strings" + "syscall" + "time" + + "github.com/gogrlx/grlx/types" +) + +func (c Cmd) run(ctx context.Context, test bool) (types.Result, error) { + var result types.Result + var err error + + cmd, ok := c.params["name"].(string) + if !ok { + return result, errors.New("invalid command; name must be a string") + } + splitCmd := strings.Split(cmd, " ") + if len(splitCmd) == 0 { + return result, errors.New("invalid command; name must not be empty") + } + args := splitCmd[1:] + + runas := "" + path := "" + cwd := "" + env := []string{} + timeout := "" + if runasInter, ok := c.params["runas"]; ok { + runas, ok = runasInter.(string) + } + if pathInter, ok := c.params["path"]; ok { + path, ok = pathInter.(string) + } + if cwdInter, ok := c.params["cwd"]; ok { + cwd, ok = cwdInter.(string) + } + if envInter, ok := c.params["env"]; ok { + env, ok = envInter.([]string) + } + if timeoutInter, ok := c.params["timeout"]; ok { + timeout, ok = timeoutInter.(string) + } + // sanity check env vars + envVars := map[string]string{} + for _, envVar := range env { + sp := strings.Split(envVar, "=") + if len(sp) != 2 { + return result, fmt.Errorf("invalid env var %s; vars must be key=value pairs", envVar) + } + envVars[sp[0]] = sp[1] + } + ttimeout, err := time.ParseDuration(timeout) + if err != nil { + return result, errors.Join(err, fmt.Errorf("invalid timeout %s; must be a valid duration", timeout)) + } + timeoutCTX, cancel := context.WithTimeout(ctx, ttimeout) + defer cancel() + command := exec.CommandContext(timeoutCTX, splitCmd[0], args...) + if runas != "" && runtime.GOOS != "windows" { + u, err := user.Lookup(runas) + if err != nil { + return result, errors.Join(err, fmt.Errorf("invalid user %s; user must exist", runas)) + } + uid64, err := strconv.Atoi(u.Uid) + if err != nil { + return result, errors.Join(err, fmt.Errorf("invalid user %s; user must exist", runas)) + } + if uid64 > math.MaxInt32 { + return result, fmt.Errorf("UID %d is invalid", uid64) + } + uid := uint32(uid64) + command.SysProcAttr = &syscall.SysProcAttr{} + command.SysProcAttr.Credential = &syscall.Credential{Uid: uid} + } + if path != "" { + command.Path = path + } + if cwd != "" { + command.Dir = cwd + } + if len(envVars) > 0 { + command.Env = []string{} + for _, v := range env { + command.Env = append(command.Env, v) + } + } + if test { + result.Notes = append(result.Notes, + types.SimpleNote("Command would have been run")) + return result, nil + } + + out, err := command.CombinedOutput() + result.Notes = append(result.Notes, + types.SimpleNote(fmt.Sprintf("Command output: %s", string(out))), + ) + + if err != nil { + result.Notes = append(result.Notes, + types.SimpleNote(fmt.Sprintf("Command failed: %s", err.Error()))) + } + if command.ProcessState.ExitCode() != 0 { + result.Succeeded = false + result.Failed = true + } else { + result.Succeeded = true + result.Failed = false + } + return result, nil +} diff --git a/ingredients/cmd/run.go b/ingredients/cmd/interactive.go similarity index 94% rename from ingredients/cmd/run.go rename to ingredients/cmd/interactive.go index 53cebdb..d28dd80 100644 --- a/ingredients/cmd/run.go +++ b/ingredients/cmd/interactive.go @@ -15,14 +15,19 @@ import ( "syscall" "time" - "github.com/taigrr/log-socket/log" - "github.com/gogrlx/grlx/types" + nats "github.com/nats-io/nats.go" + "github.com/taigrr/log-socket/log" ) +var ec *nats.EncodedConn + +func RegisterEC(encodedConn *nats.EncodedConn) { + ec = encodedConn +} + var envMutex sync.Mutex -// TODO allow selector to be more than an ID func FRun(target types.KeyManager, cmdRun types.CmdRun) (types.CmdRun, error) { topic := "grlx.sprouts." + target.SproutID + ".cmd.run" var results types.CmdRun diff --git a/ingredients/file/file.go b/ingredients/file/file.go index 8bb6872..08194c6 100644 --- a/ingredients/file/file.go +++ b/ingredients/file/file.go @@ -13,6 +13,8 @@ import ( "github.com/gogrlx/grlx/types" ) +var ErrFileMethodUndefined = errors.New("file method undefined") + type File struct { id string method string @@ -68,7 +70,7 @@ func (f File) undef() (types.Result, error) { return types.Result{ Succeeded: false, Failed: true, Changed: false, Notes: nil, - }, fmt.Errorf("method %s undefined", f.method) + }, errors.Join(ErrFileMethodUndefined, fmt.Errorf("method %s undefined", f.method)) } func (f File) Test(ctx context.Context) (types.Result, error) { @@ -419,6 +421,7 @@ func (f File) Apply(ctx context.Context) (types.Result, error) { func (f File) PropertiesForMethod(method string) (map[string]string, error) { switch f.method { + // TODO use ingredients.MethodPropsSet for remaining methods case "absent": return ingredients.MethodPropsSet{ ingredients.MethodProps{Key: "name", Type: "string", IsReq: true}, @@ -490,7 +493,7 @@ func (f File) PropertiesForMethod(method string) (map[string]string, error) { }, nil default: // TODO define error type - return nil, fmt.Errorf("method %s undefined", f.method) + return nil, errors.Join(ErrDuplicateProtocol, fmt.Errorf("method %s undefined", f.method)) } } diff --git a/ingredients/file/fileCached_test.go b/ingredients/file/fileCached_test.go index 374f9d3..bb97483 100644 --- a/ingredients/file/fileCached_test.go +++ b/ingredients/file/fileCached_test.go @@ -157,9 +157,6 @@ func TestCachedSkipVerify(t *testing.T) { "skip_verify": true, }, } - if err != nil { - t.Fatalf("failed to register local file provider: %v", err) - } _, err = f.cached(context.Background(), false) if err != nil { t.Errorf("expected no error, got %v", err)