asteris-llc/converge

View on GitHub
resource/shell/preparer.go

Summary

Maintainability
A
50 mins
Test Coverage
// Copyright © 2016 Asteris, LLC
//
// Licensed 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 shell

import (
    "bytes"

    "fmt"
    "io"
    "io/ioutil"
    "os/exec"
    "time"

    "github.com/pkg/errors"

    log "github.com/Sirupsen/logrus"
    "github.com/asteris-llc/converge/helpers/transform"
    "github.com/asteris-llc/converge/load/registry"
    "github.com/asteris-llc/converge/resource"
    "golang.org/x/net/context"
)

var (
    defaultInterpreter = "/bin/sh"
    defaultCheckFlags  = []string{"-n"}
    defaultExecFlags   = []string{}
)

// Preparer for shell tasks
//
// Task allows you to run arbitrary shell commands on your system, first
// checking if the command should be run.
type Preparer struct {
    // the shell interpreter that will be used for your scripts. `/bin/sh` is
    // used by default.
    Interpreter string `hcl:"interpreter"`

    // flags to pass to the `interpreter` binary to check validity. For
    // `/bin/sh` this is `-n`
    CheckFlags []string `hcl:"check_flags"`

    // flags to pass to the interpreter at execution time
    ExecFlags []string `hcl:"exec_flags"`

    // the script to run to check if a resource needs to be changed. It should
    // exit with exit code 0 if the resource does not need to be changed, and
    // 1 (or above) otherwise.
    Check string `hcl:"check" nonempty:"true"`

    // the script to run to apply the resource. Normal shell exit code
    // expectations apply (that is, exit code 0 for success, 1 or above for
    // failure.)
    Apply string `hcl:"apply" nonempty:"true"`

    // the amount of time the command will wait before halting forcefully.
    Timeout *time.Duration `hcl:"timeout"`

    // the working directory this command should be run in
    Dir string `hcl:"dir"`

    // any environment variables that should be passed to the command
    Env map[string]string `hcl:"env"`
}

// Prepare a new shell task
func (p *Preparer) Prepare(ctx context.Context, render resource.Renderer) (resource.Task, error) {
    env := transform.StringsMapToStringSlice(
        p.Env,
        func(k, v string) string {
            return fmt.Sprintf("%s=%s", k, v)
        },
    )

    generator := &CommandGenerator{
        Interpreter: p.Interpreter,
        Flags:       p.ExecFlags,
        Dir:         p.Dir,
        Env:         env,
        Timeout:     p.Timeout,
    }

    shell := &Shell{
        CmdGenerator: generator,
        CheckStmt:    p.Check,
        ApplyStmt:    p.Apply,
        Dir:          p.Dir,
        Env:          env,
    }

    return shell, checkSyntax(p.Interpreter, p.CheckFlags, p.Check)
}

func checkSyntax(interpreter string, flags []string, script string) error {
    if interpreter == "" {
        interpreter = defaultInterpreter
        if len(flags) > 0 {
            return errors.New("custom syntax check_flags given without an interpreter")
        }
        flags = defaultCheckFlags
    } else {
        if len(flags) == 0 {
            // TODO: add ID in here somehow
            log.Debug("no check_flags specified for interpeter, skipping syntax validation")
            return nil
        }
    }
    command := exec.Command(interpreter, flags...)
    cmdStdin, cmdStdout, cmdStderr, err := cmdGetPipes(command)
    if err != nil {
        return errors.Wrap(err, "unable to communicate with subprocess")
    }
    if err := command.Start(); err != nil {
        return errors.Wrap(err, "unable to start subprocess")
    }
    if _, err := cmdStdin.Write([]byte(script)); err != nil {
        return errors.Wrap(err, "unable to write to interpreter")
    }

    if err := cmdStdin.Close(); err != nil {
        return errors.Wrap(err, "failed to close stdin")
    }

    var buffer bytes.Buffer
    if data, err := ioutil.ReadAll(cmdStdout); err == nil {
        if len(data) > 0 {
            buffer.WriteString("Command Stdout:\n")
            buffer.Write(data)
        }
    }
    if data, err := ioutil.ReadAll(cmdStderr); err == nil {
        if len(data) > 0 {
            buffer.WriteString("Command Stderr:\n")
            buffer.Write(data)
        }
    }

    if err := command.Wait(); err != nil {
        return errors.Wrap(err, "syntax error: "+buffer.String())
    }

    return nil
}

func cmdGetPipes(command *exec.Cmd) (io.WriteCloser, io.ReadCloser, io.ReadCloser, error) {
    var err error
    cmdStdin, err := command.StdinPipe()
    if err != nil {
        return nil, nil, nil, errors.Wrap(err, "failed to get stdin pipe")
    }
    cmdStderr, err := command.StderrPipe()
    if err != nil {
        return nil, nil, nil, errors.Wrap(err, "failed to get stderr pipe")
    }
    cmdStdout, err := command.StdoutPipe()
    if err != nil {
        return nil, nil, nil, errors.Wrap(err, "failed to get stdout pipe")
    }
    return cmdStdin, cmdStdout, cmdStderr, nil
}

func init() {
    registry.Register("task", (*Preparer)(nil), (*Shell)(nil))
    registry.Register("healthcheck.task", (*Preparer)(nil), (*Shell)(nil))
}