github.com/mkimuram/operator-sdk@v0.7.1-0.20190410172100-52ad33a4bda0/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  		// link the current run to the `latest` directory under artifacts
   302  		currentRun := filepath.Join(inputDir.Path, "artifacts", ident)
   303  		latestArtifacts := filepath.Join(inputDir.Path, "artifacts", "latest")
   304  		if _, err = os.Lstat(latestArtifacts); err == nil {
   305  			if err = os.Remove(latestArtifacts); err != nil {
   306  				logger.Error(err, "Error removing the latest artifacts symlink")
   307  			}
   308  		}
   309  		if err = os.Symlink(currentRun, latestArtifacts); err != nil {
   310  			logger.Error(err, "Error symlinking latest artifacts")
   311  		}
   312  
   313  	}()
   314  
   315  	return &runResult{
   316  		events:   receiver.Events,
   317  		inputDir: &inputDir,
   318  		ident:    ident,
   319  	}, nil
   320  }
   321  
   322  // GetReconcilePeriod - new reconcile period.
   323  func (r *runner) GetReconcilePeriod() (time.Duration, bool) {
   324  	if r.reconcilePeriod == nil {
   325  		return time.Duration(0), false
   326  	}
   327  	return *r.reconcilePeriod, true
   328  }
   329  
   330  // GetManageStatus - get the manage status
   331  func (r *runner) GetManageStatus() bool {
   332  	return r.manageStatus
   333  }
   334  
   335  // GetWatchDependentResources - get the watch dependent resources value
   336  func (r *runner) GetWatchDependentResources() bool {
   337  	return r.watchDependentResources
   338  }
   339  
   340  // GetWatchClusterScopedResources - get the watch cluster scoped resources value
   341  func (r *runner) GetWatchClusterScopedResources() bool {
   342  	return r.watchClusterScopedResources
   343  }
   344  
   345  func (r *runner) GetFinalizer() (string, bool) {
   346  	if r.Finalizer != nil {
   347  		return r.Finalizer.Name, true
   348  	}
   349  	return "", false
   350  }
   351  
   352  func (r *runner) isFinalizerRun(u *unstructured.Unstructured) bool {
   353  	finalizersSet := r.Finalizer != nil && u.GetFinalizers() != nil
   354  	// The resource is deleted and our finalizer is present, we need to run the finalizer
   355  	if finalizersSet && u.GetDeletionTimestamp() != nil {
   356  		for _, f := range u.GetFinalizers() {
   357  			if f == r.Finalizer.Name {
   358  				return true
   359  			}
   360  		}
   361  	}
   362  	return false
   363  }
   364  
   365  func (r *runner) addFinalizer(finalizer *Finalizer) error {
   366  	r.Finalizer = finalizer
   367  	switch {
   368  	case finalizer == nil:
   369  		return nil
   370  	case finalizer.Playbook != "":
   371  		if !filepath.IsAbs(finalizer.Playbook) {
   372  			return fmt.Errorf("finalizer playbook path must be absolute for %v", r.GVK)
   373  		}
   374  		r.finalizerCmdFunc = func(ident, inputDirPath string, maxArtifacts int) *exec.Cmd {
   375  			return exec.Command("ansible-runner", "-vv", "--rotate-artifacts", fmt.Sprintf("%v", maxArtifacts), "-p", finalizer.Playbook, "-i", ident, "run", inputDirPath)
   376  		}
   377  	case finalizer.Role != "":
   378  		if !filepath.IsAbs(finalizer.Role) {
   379  			return fmt.Errorf("finalizer role path must be absolute for %v", r.GVK)
   380  		}
   381  		r.finalizerCmdFunc = func(ident, inputDirPath string, maxArtifacts int) *exec.Cmd {
   382  			path := strings.TrimRight(finalizer.Role, "/")
   383  			rolePath, roleName := filepath.Split(path)
   384  			return exec.Command("ansible-runner", "-vv", "--rotate-artifacts", fmt.Sprintf("%v", maxArtifacts), "--role", roleName, "--roles-path", rolePath, "--hosts", "localhost", "-i", ident, "run", inputDirPath)
   385  		}
   386  	case len(finalizer.Vars) != 0:
   387  		r.finalizerCmdFunc = r.cmdFunc
   388  	}
   389  	return nil
   390  }
   391  
   392  // makeParameters - creates the extravars parameters for ansible
   393  // The resulting structure in json is:
   394  // { "meta": {
   395  //      "name": <object_name>,
   396  //      "namespace": <object_namespace>,
   397  //   },
   398  //   <cr_spec_fields_as_snake_case>,
   399  //   ...
   400  //   _<group_as_snake>_<kind>: {
   401  //       <cr_object as is
   402  //   }
   403  // }
   404  func (r *runner) makeParameters(u *unstructured.Unstructured) map[string]interface{} {
   405  	s := u.Object["spec"]
   406  	spec, ok := s.(map[string]interface{})
   407  	if !ok {
   408  		log.Info("Spec was not found for CR", "GroupVersionKind", u.GroupVersionKind(), "Namespace", u.GetNamespace(), "Name", u.GetName())
   409  		spec = map[string]interface{}{}
   410  	}
   411  	parameters := paramconv.MapToSnake(spec)
   412  	parameters["meta"] = map[string]string{"namespace": u.GetNamespace(), "name": u.GetName()}
   413  	objectKey := fmt.Sprintf("_%v_%v", strings.Replace(r.GVK.Group, ".", "_", -1), strings.ToLower(r.GVK.Kind))
   414  	parameters[objectKey] = u.Object
   415  	if r.isFinalizerRun(u) {
   416  		for k, v := range r.Finalizer.Vars {
   417  			parameters[k] = v
   418  		}
   419  	}
   420  	return parameters
   421  }
   422  
   423  // RunResult - result of a ansible run
   424  type RunResult interface {
   425  	// Stdout returns the stdout from ansible-runner if it is available, else an error.
   426  	Stdout() (string, error)
   427  	// Events returns the events from ansible-runner if it is available, else an error.
   428  	Events() <-chan eventapi.JobEvent
   429  }
   430  
   431  // RunResult facilitates access to information about a run of ansible.
   432  type runResult struct {
   433  	// Events is a channel of events from ansible that contain state related
   434  	// to a run of ansible.
   435  	events <-chan eventapi.JobEvent
   436  
   437  	ident    string
   438  	inputDir *inputdir.InputDir
   439  }
   440  
   441  // Stdout returns the stdout from ansible-runner if it is available, else an error.
   442  func (r *runResult) Stdout() (string, error) {
   443  	return r.inputDir.Stdout(r.ident)
   444  }
   445  
   446  // Events returns the events from ansible-runner if it is available, else an error.
   447  func (r *runResult) Events() <-chan eventapi.JobEvent {
   448  	return r.events
   449  }