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 }