github.com/jlmeeker/kismatic@v1.10.1-0.20180612190640-57f9005a1f1a/pkg/ansible/runner.go (about)

     1  package ansible
     2  
     3  import (
     4  	"fmt"
     5  	"io"
     6  	"io/ioutil"
     7  	"log"
     8  	"math/rand"
     9  	"os"
    10  	"os/exec"
    11  	"path/filepath"
    12  	"strings"
    13  	"syscall"
    14  	"time"
    15  )
    16  
    17  const (
    18  	// RawFormat is the raw Ansible output formatting
    19  	RawFormat = OutputFormat("raw")
    20  	// JSONLinesFormat is a JSON Lines representation of Ansible events
    21  	JSONLinesFormat = OutputFormat("json_lines")
    22  )
    23  
    24  // OutputFormat is used for controlling the STDOUT format of the Ansible runner
    25  type OutputFormat string
    26  
    27  // Runner for running Ansible playbooks
    28  type Runner interface {
    29  	// StartPlaybook runs the playbook asynchronously with the given inventory and extra vars.
    30  	// It returns a read-only channel that must be consumed for the playbook execution to proceed.
    31  	StartPlaybook(playbookFile string, inventory Inventory, cc ClusterCatalog) (<-chan Event, error)
    32  	// WaitPlaybook blocks until the execution of the playbook is complete. If an error occurred,
    33  	// it is returned. Otherwise, returns nil to signal the completion of the playbook.
    34  	WaitPlaybook() error
    35  	// StartPlaybookOnNode runs the playbook asynchronously with the given inventory and extra vars
    36  	// against the specific node.
    37  	// It returns a read-only channel that must be consumed for the playbook execution to proceed.
    38  	StartPlaybookOnNode(playbookFile string, inventory Inventory, cc ClusterCatalog, node ...string) (<-chan Event, error)
    39  }
    40  
    41  type runner struct {
    42  	// Out is the stdout writer for the Ansible process
    43  	out io.Writer
    44  	// ErrOut is the stderr writer for the Ansible process
    45  	errOut io.Writer
    46  
    47  	pythonPath   string
    48  	ansibleDir   string
    49  	runDir       string
    50  	waitPlaybook func() error
    51  	namedPipe    string
    52  }
    53  
    54  // NewRunner returns a new runner for running Ansible playbooks.
    55  func NewRunner(out, errOut io.Writer, ansibleDir string, runDir string) (Runner, error) {
    56  	// Ansible depends on python 2.7 being installed and on the path as "python".
    57  	// Validate that it is available
    58  	if _, err := exec.LookPath("python"); err != nil {
    59  		return nil, fmt.Errorf("Could not find 'python' in the PATH. Ensure that python 2.7 is installed and in the path as 'python'.")
    60  	}
    61  
    62  	ppath, err := getPythonPath()
    63  	if err != nil {
    64  		return nil, err
    65  	}
    66  
    67  	return &runner{
    68  		out:        out,
    69  		errOut:     errOut,
    70  		pythonPath: ppath,
    71  		ansibleDir: ansibleDir,
    72  		runDir:     runDir,
    73  	}, nil
    74  }
    75  
    76  // WaitPlaybook blocks until the ansible process running the playbook exits.
    77  // If the process exits with a non-zero status, it will return an error.
    78  func (r *runner) WaitPlaybook() error {
    79  	if r.waitPlaybook == nil {
    80  		return fmt.Errorf("wait called, but playbook not started")
    81  	}
    82  	execErr := r.waitPlaybook()
    83  	// Process exited, we can clean up named pipe
    84  	removeErr := os.Remove(r.namedPipe)
    85  	if removeErr != nil && execErr != nil {
    86  		return fmt.Errorf("an error occurred running ansible: %v. Removing named pipe at %q failed: %v", execErr, r.namedPipe, removeErr)
    87  	}
    88  	if removeErr != nil {
    89  		return fmt.Errorf("failed to clean up named pipe at %q: %v", r.namedPipe, removeErr)
    90  	}
    91  	if execErr != nil {
    92  		return fmt.Errorf("error running ansible: %v", execErr)
    93  	}
    94  	return nil
    95  }
    96  
    97  // RunPlaybook with the given inventory and extra vars
    98  func (r *runner) StartPlaybook(playbookFile string, inv Inventory, cc ClusterCatalog) (<-chan Event, error) {
    99  	return r.startPlaybook(playbookFile, inv, cc) // Don't set the --limit arg
   100  }
   101  
   102  // StartPlaybookOnNode runs the playbook asynchronously with the given inventory and extra vars
   103  // against the specific node.
   104  // It returns a read-only channel that must be consumed for the playbook execution to proceed.
   105  func (r *runner) StartPlaybookOnNode(playbookFile string, inv Inventory, cc ClusterCatalog, nodes ...string) (<-chan Event, error) {
   106  	// set the --limit arg to the node we want to target
   107  	return r.startPlaybook(playbookFile, inv, cc, nodes...)
   108  }
   109  
   110  func (r *runner) startPlaybook(playbookFile string, inv Inventory, cc ClusterCatalog, nodes ...string) (<-chan Event, error) {
   111  	playbook := filepath.Join(r.ansibleDir, "playbooks", playbookFile)
   112  	if _, err := os.Stat(playbook); os.IsNotExist(err) {
   113  		return nil, fmt.Errorf("playbook %q does not exist", playbook)
   114  	}
   115  
   116  	yamlBytes, err := cc.ToYAML()
   117  	if err != nil {
   118  		return nil, fmt.Errorf("error writing cluster catalog data to yaml: %v", err)
   119  	}
   120  	clusterCatalogFile := filepath.Join(r.ansibleDir, "clustercatalog.yaml")
   121  	if err = ioutil.WriteFile(clusterCatalogFile, yamlBytes, 0644); err != nil {
   122  		return nil, fmt.Errorf("error writing cluster catalog file to %q: %v", clusterCatalogFile, err)
   123  	}
   124  
   125  	inventoryFile := filepath.Join(r.ansibleDir, "inventory.ini")
   126  	if err := ioutil.WriteFile(inventoryFile, inv.ToINI(), 0644); err != nil {
   127  		return nil, fmt.Errorf("error writing inventory file to %q: %v", inventoryFile, err)
   128  	}
   129  
   130  	if err := copyFileContents(clusterCatalogFile, filepath.Join(r.runDir, "clustercatalog.yaml")); err != nil {
   131  		return nil, fmt.Errorf("error copying clustercatalog.yaml to %q: %v", r.runDir, err)
   132  	}
   133  	if err := copyFileContents(inventoryFile, filepath.Join(r.runDir, "inventory.ini")); err != nil {
   134  		return nil, fmt.Errorf("error copying inventory.ini to %q: %v", r.runDir, err)
   135  	}
   136  
   137  	cmd := exec.Command(filepath.Join(r.ansibleDir, "bin", "ansible-playbook"), "-i", inventoryFile, "-s", playbook, "--extra-vars", "@"+clusterCatalogFile)
   138  	cmd.Stdout = r.out
   139  	cmd.Stderr = r.errOut
   140  
   141  	log.SetOutput(r.out)
   142  
   143  	limitArg := strings.Join(nodes, ",")
   144  	if limitArg != "" {
   145  		cmd.Args = append(cmd.Args, "--limit", limitArg)
   146  	}
   147  
   148  	// We always want the most verbose output from Ansible. If it's not going to
   149  	// stdout, it's going to a log file.
   150  	cmd.Args = append(cmd.Args, "-vvvv")
   151  
   152  	// Create named pipe
   153  	np, err := createTempNamedPipe()
   154  	if err != nil {
   155  		return nil, err
   156  	}
   157  	r.namedPipe = np
   158  
   159  	os.Setenv("PYTHONPATH", r.pythonPath)
   160  	os.Setenv("ANSIBLE_CALLBACK_PLUGINS", filepath.Join(r.ansibleDir, "playbooks", "callback"))
   161  	os.Setenv("ANSIBLE_CALLBACK_WHITELIST", "json_lines")
   162  	os.Setenv("ANSIBLE_CONFIG", filepath.Join(r.ansibleDir, "playbooks", "ansible.cfg"))
   163  	os.Setenv("ANSIBLE_JSON_LINES_PIPE", r.namedPipe)
   164  
   165  	// Print Ansible command
   166  	fmt.Fprintf(r.out, "export PYTHONPATH=%v\n", os.Getenv("PYTHONPATH"))
   167  	fmt.Fprintf(r.out, "export ANSIBLE_CALLBACK_PLUGINS=%v\n", os.Getenv("ANSIBLE_CALLBACK_PLUGINS"))
   168  	fmt.Fprintf(r.out, "export ANSIBLE_CALLBACK_WHITELIST=%v\n", os.Getenv("ANSIBLE_CALLBACK_WHITELIST"))
   169  	fmt.Fprintf(r.out, "export ANSIBLE_CONFIG=%v\n", os.Getenv("ANSIBLE_CONFIG"))
   170  	fmt.Fprintf(r.out, "export ANSIBLE_JSON_LINES_PIPE=%v\n", os.Getenv("ANSIBLE_JSON_LINES_PIPE"))
   171  	fmt.Fprintln(r.out, strings.Join(cmd.Args, " "))
   172  
   173  	// Starts async execution of ansible, which will block until
   174  	// we start reading from the named pipe
   175  	err = cmd.Start()
   176  	if err != nil {
   177  		return nil, fmt.Errorf("error running playbook: %v", err)
   178  	}
   179  	r.waitPlaybook = cmd.Wait
   180  
   181  	// Create the event stream out of the named pipe
   182  	eventStreamFile, err := os.OpenFile(r.namedPipe, os.O_RDWR, os.ModeNamedPipe)
   183  	if err != nil {
   184  		return nil, fmt.Errorf("error openning event stream pipe: %v", err)
   185  	}
   186  	eventStream := EventStream(eventStreamFile)
   187  	return eventStream, nil
   188  }
   189  
   190  // create a named pipe for getting json events out of ansible.
   191  // add random int to file name to avoid collision.
   192  func createTempNamedPipe() (string, error) {
   193  	start := time.Now()
   194  	np := filepath.Join(os.TempDir(), fmt.Sprintf("ansible-pipe-%d-%s", rand.Int(), start.Format("2006-01-02-15-04-05.99999")))
   195  	if err := syscall.Mkfifo(np, 0644); err != nil {
   196  		return "", fmt.Errorf("error creating named pipe %q: %v", np, err)
   197  	}
   198  	return np, nil
   199  }
   200  
   201  func getPythonPath() (string, error) {
   202  	wd, err := os.Getwd()
   203  	if err != nil {
   204  		return "", fmt.Errorf("error getting working dir: %v", err)
   205  	}
   206  	lib := filepath.Join(wd, "ansible", "lib", "python2.7", "site-packages")
   207  	lib64 := filepath.Join(wd, "ansible", "lib64", "python2.7", "site-packages")
   208  	return fmt.Sprintf("%s:%s", lib, lib64), nil
   209  }
   210  
   211  func copyFileContents(src, dst string) (err error) {
   212  	in, err := os.Open(src)
   213  	if err != nil {
   214  		return err
   215  	}
   216  	defer in.Close()
   217  	out, err := os.Create(dst)
   218  	if err != nil {
   219  		return err
   220  	}
   221  	defer out.Close()
   222  	if _, err = io.Copy(out, in); err != nil {
   223  		return err
   224  	}
   225  	return out.Sync()
   226  }