github.com/jmrodri/operator-sdk@v0.5.0/pkg/ansible/runner/runner.go (about)

     1  // Copyright 2018 The Operator-SDK Authors
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //     http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package runner
    16  
    17  import (
    18  	"errors"
    19  	"fmt"
    20  	"io/ioutil"
    21  	"net/http"
    22  	"os"
    23  	"os/exec"
    24  	"path/filepath"
    25  	"strconv"
    26  	"strings"
    27  	"time"
    28  
    29  	"github.com/operator-framework/operator-sdk/pkg/ansible/paramconv"
    30  	"github.com/operator-framework/operator-sdk/pkg/ansible/runner/eventapi"
    31  	"github.com/operator-framework/operator-sdk/pkg/ansible/runner/internal/inputdir"
    32  
    33  	yaml "gopkg.in/yaml.v2"
    34  	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
    35  	"k8s.io/apimachinery/pkg/runtime/schema"
    36  	logf "sigs.k8s.io/controller-runtime/pkg/runtime/log"
    37  )
    38  
    39  var log = logf.Log.WithName("runner")
    40  
    41  const (
    42  	// MaxRunnerArtifactsAnnotation - annotation used by a user to specify the max artifacts to keep
    43  	// in the runner directory. This will override the value provided by the watches file for a
    44  	// particular CR. Setting this to zero will cause all artifact directories to be kept.
    45  	// Example usage "ansible.operator-sdk/max-runner-artifacts: 100"
    46  	MaxRunnerArtifactsAnnotation = "ansible.operator-sdk/max-runner-artifacts"
    47  )
    48  
    49  // Runner - a runnable that should take the parameters and name and namespace
    50  // and run the correct code.
    51  type Runner interface {
    52  	Run(string, *unstructured.Unstructured, string) (RunResult, error)
    53  	GetFinalizer() (string, bool)
    54  	GetReconcilePeriod() (time.Duration, bool)
    55  	GetManageStatus() bool
    56  	GetWatchDependentResources() bool
    57  	GetWatchClusterScopedResources() bool
    58  }
    59  
    60  // watch holds data used to create a mapping of GVK to ansible playbook or role.
    61  // The mapping is used to compose an ansible operator.
    62  type watch struct {
    63  	MaxRunnerArtifacts          int        `yaml:"maxRunnerArtifacts"`
    64  	Version                     string     `yaml:"version"`
    65  	Group                       string     `yaml:"group"`
    66  	Kind                        string     `yaml:"kind"`
    67  	Playbook                    string     `yaml:"playbook"`
    68  	Role                        string     `yaml:"role"`
    69  	ReconcilePeriod             string     `yaml:"reconcilePeriod"`
    70  	ManageStatus                bool       `yaml:"manageStatus"`
    71  	WatchDependentResources     bool       `yaml:"watchDependentResources"`
    72  	WatchClusterScopedResources bool       `yaml:"watchClusterScopedResources"`
    73  	Finalizer                   *Finalizer `yaml:"finalizer"`
    74  }
    75  
    76  // Finalizer - Expose finalizer to be used by a user.
    77  type Finalizer struct {
    78  	Name     string                 `yaml:"name"`
    79  	Playbook string                 `yaml:"playbook"`
    80  	Role     string                 `yaml:"role"`
    81  	Vars     map[string]interface{} `yaml:"vars"`
    82  }
    83  
    84  // UnmarshalYaml - implements the yaml.Unmarshaler interface
    85  func (w *watch) UnmarshalYAML(unmarshal func(interface{}) error) error {
    86  	// by default, the operator will manage status and watch dependent resources
    87  	// The operator will not manage cluster scoped resources by default.
    88  	w.ManageStatus = true
    89  	w.WatchDependentResources = true
    90  	w.MaxRunnerArtifacts = 20
    91  	w.WatchClusterScopedResources = false
    92  
    93  	// hide watch data in plain struct to prevent unmarshal from calling
    94  	// UnmarshalYAML again
    95  	type plain watch
    96  
    97  	return unmarshal((*plain)(w))
    98  }
    99  
   100  // NewFromWatches reads the operator's config file at the provided path.
   101  func NewFromWatches(path string) (map[schema.GroupVersionKind]Runner, error) {
   102  	b, err := ioutil.ReadFile(path)
   103  	if err != nil {
   104  		log.Error(err, "Failed to get config file")
   105  		return nil, err
   106  	}
   107  	watches := []watch{}
   108  	err = yaml.Unmarshal(b, &watches)
   109  	if err != nil {
   110  		log.Error(err, "Failed to unmarshal config")
   111  		return nil, err
   112  	}
   113  
   114  	m := map[schema.GroupVersionKind]Runner{}
   115  	for _, w := range watches {
   116  		s := schema.GroupVersionKind{
   117  			Group:   w.Group,
   118  			Version: w.Version,
   119  			Kind:    w.Kind,
   120  		}
   121  		var reconcilePeriod *time.Duration
   122  		if w.ReconcilePeriod != "" {
   123  			d, err := time.ParseDuration(w.ReconcilePeriod)
   124  			if err != nil {
   125  				return nil, fmt.Errorf("unable to parse duration: %v - %v, setting to default", w.ReconcilePeriod, err)
   126  			}
   127  			reconcilePeriod = &d
   128  		}
   129  
   130  		// Check if schema is a duplicate
   131  		if _, ok := m[s]; ok {
   132  			return nil, fmt.Errorf("duplicate GVK: %v", s.String())
   133  		}
   134  		switch {
   135  		case w.Playbook != "":
   136  			r, err := NewForPlaybook(w.Playbook, s, w.Finalizer, reconcilePeriod, w.ManageStatus, w.WatchDependentResources, w.WatchClusterScopedResources, w.MaxRunnerArtifacts)
   137  			if err != nil {
   138  				return nil, err
   139  			}
   140  			m[s] = r
   141  		case w.Role != "":
   142  			r, err := NewForRole(w.Role, s, w.Finalizer, reconcilePeriod, w.ManageStatus, w.WatchDependentResources, w.WatchClusterScopedResources, w.MaxRunnerArtifacts)
   143  			if err != nil {
   144  				return nil, err
   145  			}
   146  			m[s] = r
   147  		default:
   148  			return nil, fmt.Errorf("either playbook or role must be defined for %v", s)
   149  		}
   150  	}
   151  	return m, nil
   152  }
   153  
   154  // NewForPlaybook returns a new Runner based on the path to an ansible playbook.
   155  func NewForPlaybook(path string, gvk schema.GroupVersionKind, finalizer *Finalizer, reconcilePeriod *time.Duration, manageStatus, dependentResources, clusterScopedResources bool, maxArtifacts int) (Runner, error) {
   156  	if !filepath.IsAbs(path) {
   157  		return nil, fmt.Errorf("playbook path must be absolute for %v", gvk)
   158  	}
   159  	if _, err := os.Stat(path); err != nil {
   160  		return nil, fmt.Errorf("playbook: %v was not found for %v", path, gvk)
   161  	}
   162  	r := &runner{
   163  		Path: path,
   164  		GVK:  gvk,
   165  		cmdFunc: func(ident, inputDirPath string, maxArtifacts int) *exec.Cmd {
   166  			return exec.Command("ansible-runner", "-vv", "--rotate-artifacts", fmt.Sprintf("%v", maxArtifacts), "-p", path, "-i", ident, "run", inputDirPath)
   167  		},
   168  		maxRunnerArtifacts:          maxArtifacts,
   169  		reconcilePeriod:             reconcilePeriod,
   170  		manageStatus:                manageStatus,
   171  		watchDependentResources:     dependentResources,
   172  		watchClusterScopedResources: clusterScopedResources,
   173  	}
   174  	err := r.addFinalizer(finalizer)
   175  	if err != nil {
   176  		return nil, err
   177  	}
   178  	return r, nil
   179  }
   180  
   181  // NewForRole returns a new Runner based on the path to an ansible role.
   182  func NewForRole(path string, gvk schema.GroupVersionKind, finalizer *Finalizer, reconcilePeriod *time.Duration, manageStatus, dependentResources, clusterScopedResources bool, maxArtifacts int) (Runner, error) {
   183  	if !filepath.IsAbs(path) {
   184  		return nil, fmt.Errorf("role path must be absolute for %v", gvk)
   185  	}
   186  	if _, err := os.Stat(path); err != nil {
   187  		return nil, fmt.Errorf("role path: %v was not found for %v", path, gvk)
   188  	}
   189  	path = strings.TrimRight(path, "/")
   190  	r := &runner{
   191  		Path: path,
   192  		GVK:  gvk,
   193  		cmdFunc: func(ident, inputDirPath string, maxArtifacts int) *exec.Cmd {
   194  			rolePath, roleName := filepath.Split(path)
   195  			return exec.Command("ansible-runner", "-vv", "--rotate-artifacts", fmt.Sprintf("%v", maxArtifacts), "--role", roleName, "--roles-path", rolePath, "--hosts", "localhost", "-i", ident, "run", inputDirPath)
   196  		},
   197  		maxRunnerArtifacts:          maxArtifacts,
   198  		reconcilePeriod:             reconcilePeriod,
   199  		manageStatus:                manageStatus,
   200  		watchDependentResources:     dependentResources,
   201  		watchClusterScopedResources: clusterScopedResources,
   202  	}
   203  	err := r.addFinalizer(finalizer)
   204  	if err != nil {
   205  		return nil, err
   206  	}
   207  	return r, nil
   208  }
   209  
   210  // runner - implements the Runner interface for a GVK that's being watched.
   211  type runner struct {
   212  	maxRunnerArtifacts          int
   213  	Path                        string                  // path on disk to a playbook or role depending on what cmdFunc expects
   214  	GVK                         schema.GroupVersionKind // GVK being watched that corresponds to the Path
   215  	Finalizer                   *Finalizer
   216  	cmdFunc                     func(ident, inputDirPath string, maxArtifacts int) *exec.Cmd // returns a Cmd that runs ansible-runner
   217  	finalizerCmdFunc            func(ident, inputDirPath string, maxArtifacts int) *exec.Cmd
   218  	reconcilePeriod             *time.Duration
   219  	manageStatus                bool
   220  	watchDependentResources     bool
   221  	watchClusterScopedResources bool
   222  }
   223  
   224  func (r *runner) Run(ident string, u *unstructured.Unstructured, kubeconfig string) (RunResult, error) {
   225  	if u.GetDeletionTimestamp() != nil && !r.isFinalizerRun(u) {
   226  		return nil, errors.New("resource has been deleted, but no finalizer was matched, skipping reconciliation")
   227  	}
   228  	logger := log.WithValues(
   229  		"job", ident,
   230  		"name", u.GetName(),
   231  		"namespace", u.GetNamespace(),
   232  	)
   233  
   234  	// start the event receiver. We'll check errChan for an error after
   235  	// ansible-runner exits.
   236  	errChan := make(chan error, 1)
   237  	receiver, err := eventapi.New(ident, errChan)
   238  	if err != nil {
   239  		return nil, err
   240  	}
   241  	inputDir := inputdir.InputDir{
   242  		Path:       filepath.Join("/tmp/ansible-operator/runner/", r.GVK.Group, r.GVK.Version, r.GVK.Kind, u.GetNamespace(), u.GetName()),
   243  		Parameters: r.makeParameters(u),
   244  		EnvVars: map[string]string{
   245  			"K8S_AUTH_KUBECONFIG": kubeconfig,
   246  			"KUBECONFIG":          kubeconfig,
   247  		},
   248  		Settings: map[string]string{
   249  			"runner_http_url":  receiver.SocketPath,
   250  			"runner_http_path": receiver.URLPath,
   251  		},
   252  	}
   253  	// If Path is a dir, assume it is a role path. Otherwise assume it's a
   254  	// playbook path
   255  	fi, err := os.Lstat(r.Path)
   256  	if err != nil {
   257  		return nil, err
   258  	}
   259  	if !fi.IsDir() {
   260  		inputDir.PlaybookPath = r.Path
   261  	}
   262  	err = inputDir.Write()
   263  	if err != nil {
   264  		return nil, err
   265  	}
   266  	maxArtifacts := r.maxRunnerArtifacts
   267  	if ma, ok := u.GetAnnotations()[MaxRunnerArtifactsAnnotation]; ok {
   268  		i, err := strconv.Atoi(ma)
   269  		if err != nil {
   270  			log.Info("Invalid max runner artifact annotation", "err", err, "value", ma)
   271  		}
   272  		maxArtifacts = i
   273  	}
   274  
   275  	go func() {
   276  		var dc *exec.Cmd
   277  		if r.isFinalizerRun(u) {
   278  			logger.V(1).Info("Resource is marked for deletion, running finalizer", "Finalizer", r.Finalizer.Name)
   279  			dc = r.finalizerCmdFunc(ident, inputDir.Path, maxArtifacts)
   280  		} else {
   281  			dc = r.cmdFunc(ident, inputDir.Path, maxArtifacts)
   282  		}
   283  		// Append current environment since setting dc.Env to anything other than nil overwrites current env
   284  		dc.Env = append(dc.Env, os.Environ()...)
   285  		dc.Env = append(dc.Env, fmt.Sprintf("K8S_AUTH_KUBECONFIG=%s", kubeconfig), fmt.Sprintf("KUBECONFIG=%s", kubeconfig))
   286  
   287  		output, err := dc.CombinedOutput()
   288  		if err != nil {
   289  			logger.Error(err, string(output))
   290  		} else {
   291  			logger.Info("Ansible-runner exited successfully")
   292  		}
   293  
   294  		receiver.Close()
   295  		err = <-errChan
   296  		// http.Server returns this in the case of being closed cleanly
   297  		if err != nil && err != http.ErrServerClosed {
   298  			logger.Error(err, "Error from event API")
   299  		}
   300  	}()
   301  	return &runResult{
   302  		events:   receiver.Events,
   303  		inputDir: &inputDir,
   304  		ident:    ident,
   305  	}, nil
   306  }
   307  
   308  // GetReconcilePeriod - new reconcile period.
   309  func (r *runner) GetReconcilePeriod() (time.Duration, bool) {
   310  	if r.reconcilePeriod == nil {
   311  		return time.Duration(0), false
   312  	}
   313  	return *r.reconcilePeriod, true
   314  }
   315  
   316  // GetManageStatus - get the manage status
   317  func (r *runner) GetManageStatus() bool {
   318  	return r.manageStatus
   319  }
   320  
   321  // GetWatchDependentResources - get the watch dependent resources value
   322  func (r *runner) GetWatchDependentResources() bool {
   323  	return r.watchDependentResources
   324  }
   325  
   326  // GetWatchClusterScopedResources - get the watch cluster scoped resources value
   327  func (r *runner) GetWatchClusterScopedResources() bool {
   328  	return r.watchClusterScopedResources
   329  }
   330  
   331  func (r *runner) GetFinalizer() (string, bool) {
   332  	if r.Finalizer != nil {
   333  		return r.Finalizer.Name, true
   334  	}
   335  	return "", false
   336  }
   337  
   338  func (r *runner) isFinalizerRun(u *unstructured.Unstructured) bool {
   339  	finalizersSet := r.Finalizer != nil && u.GetFinalizers() != nil
   340  	// The resource is deleted and our finalizer is present, we need to run the finalizer
   341  	if finalizersSet && u.GetDeletionTimestamp() != nil {
   342  		for _, f := range u.GetFinalizers() {
   343  			if f == r.Finalizer.Name {
   344  				return true
   345  			}
   346  		}
   347  	}
   348  	return false
   349  }
   350  
   351  func (r *runner) addFinalizer(finalizer *Finalizer) error {
   352  	r.Finalizer = finalizer
   353  	switch {
   354  	case finalizer == nil:
   355  		return nil
   356  	case finalizer.Playbook != "":
   357  		if !filepath.IsAbs(finalizer.Playbook) {
   358  			return fmt.Errorf("finalizer playbook path must be absolute for %v", r.GVK)
   359  		}
   360  		r.finalizerCmdFunc = func(ident, inputDirPath string, maxArtifacts int) *exec.Cmd {
   361  			return exec.Command("ansible-runner", "-vv", "--rotate-artifacts", fmt.Sprintf("%v", maxArtifacts), "-p", finalizer.Playbook, "-i", ident, "run", inputDirPath)
   362  		}
   363  	case finalizer.Role != "":
   364  		if !filepath.IsAbs(finalizer.Role) {
   365  			return fmt.Errorf("finalizer role path must be absolute for %v", r.GVK)
   366  		}
   367  		r.finalizerCmdFunc = func(ident, inputDirPath string, maxArtifacts int) *exec.Cmd {
   368  			path := strings.TrimRight(finalizer.Role, "/")
   369  			rolePath, roleName := filepath.Split(path)
   370  			return exec.Command("ansible-runner", "-vv", "--rotate-artifacts", fmt.Sprintf("%v", maxArtifacts), "--role", roleName, "--roles-path", rolePath, "--hosts", "localhost", "-i", ident, "run", inputDirPath)
   371  		}
   372  	case len(finalizer.Vars) != 0:
   373  		r.finalizerCmdFunc = r.cmdFunc
   374  	}
   375  	return nil
   376  }
   377  
   378  // makeParameters - creates the extravars parameters for ansible
   379  // The resulting structure in json is:
   380  // { "meta": {
   381  //      "name": <object_name>,
   382  //      "namespace": <object_namespace>,
   383  //   },
   384  //   <cr_spec_fields_as_snake_case>,
   385  //   ...
   386  //   _<group_as_snake>_<kind>: {
   387  //       <cr_object as is
   388  //   }
   389  // }
   390  func (r *runner) makeParameters(u *unstructured.Unstructured) map[string]interface{} {
   391  	s := u.Object["spec"]
   392  	spec, ok := s.(map[string]interface{})
   393  	if !ok {
   394  		log.Info("Spec was not found for CR", "GroupVersionKind", u.GroupVersionKind(), "Namespace", u.GetNamespace(), "Name", u.GetName())
   395  		spec = map[string]interface{}{}
   396  	}
   397  	parameters := paramconv.MapToSnake(spec)
   398  	parameters["meta"] = map[string]string{"namespace": u.GetNamespace(), "name": u.GetName()}
   399  	objectKey := fmt.Sprintf("_%v_%v", strings.Replace(r.GVK.Group, ".", "_", -1), strings.ToLower(r.GVK.Kind))
   400  	parameters[objectKey] = u.Object
   401  	if r.isFinalizerRun(u) {
   402  		for k, v := range r.Finalizer.Vars {
   403  			parameters[k] = v
   404  		}
   405  	}
   406  	return parameters
   407  }
   408  
   409  // RunResult - result of a ansible run
   410  type RunResult interface {
   411  	// Stdout returns the stdout from ansible-runner if it is available, else an error.
   412  	Stdout() (string, error)
   413  	// Events returns the events from ansible-runner if it is available, else an error.
   414  	Events() <-chan eventapi.JobEvent
   415  }
   416  
   417  // RunResult facilitates access to information about a run of ansible.
   418  type runResult struct {
   419  	// Events is a channel of events from ansible that contain state related
   420  	// to a run of ansible.
   421  	events <-chan eventapi.JobEvent
   422  
   423  	ident    string
   424  	inputDir *inputdir.InputDir
   425  }
   426  
   427  // Stdout returns the stdout from ansible-runner if it is available, else an error.
   428  func (r *runResult) Stdout() (string, error) {
   429  	return r.inputDir.Stdout(r.ident)
   430  }
   431  
   432  // Events returns the events from ansible-runner if it is available, else an error.
   433  func (r *runResult) Events() <-chan eventapi.JobEvent {
   434  	return r.events
   435  }