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