github.imxd.top/operator-framework/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 }