github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/worker/exec/konnector.go (about) 1 // Package exec is for the exec worker, which covers both konnector and service 2 // execution. 3 package exec 4 5 import ( 6 "archive/tar" 7 "encoding/json" 8 "errors" 9 "fmt" 10 "io" 11 "os" 12 "path" 13 "strconv" 14 "strings" 15 16 "github.com/cozy/cozy-stack/model/account" 17 "github.com/cozy/cozy-stack/model/app" 18 "github.com/cozy/cozy-stack/model/feature" 19 "github.com/cozy/cozy-stack/model/instance" 20 "github.com/cozy/cozy-stack/model/instance/lifecycle" 21 "github.com/cozy/cozy-stack/model/job" 22 "github.com/cozy/cozy-stack/model/permission" 23 "github.com/cozy/cozy-stack/model/vfs" 24 "github.com/cozy/cozy-stack/pkg/appfs" 25 "github.com/cozy/cozy-stack/pkg/config/config" 26 "github.com/cozy/cozy-stack/pkg/consts" 27 "github.com/cozy/cozy-stack/pkg/couchdb" 28 "github.com/cozy/cozy-stack/pkg/logger" 29 "github.com/cozy/cozy-stack/pkg/metadata" 30 "github.com/cozy/cozy-stack/pkg/realtime" 31 "github.com/cozy/cozy-stack/pkg/registry" 32 "github.com/spf13/afero" 33 "golang.org/x/text/cases" 34 "golang.org/x/text/language" 35 ) 36 37 const ( 38 konnErrorLoginFailed = "LOGIN_FAILED" 39 konnErrorUserActionNeeded = "USER_ACTION_NEEDED" 40 konnErrorUserActionNeededCgu = "USER_ACTION_NEEDED.CGU_FORM" 41 ) 42 43 type konnectorWorker struct { 44 slug string 45 msg *KonnectorMessage 46 man *app.KonnManifest 47 workDir string 48 49 err error 50 lastErr error 51 } 52 53 const ( 54 konnectorMsgTypeDebug = "debug" 55 konnectorMsgTypeInfo = "info" 56 konnectorMsgTypeWarning = "warning" 57 konnectorMsgTypeError = "error" 58 konnectorMsgTypeCritical = "critical" 59 ) 60 61 // KonnectorMessage is the message structure sent to the konnector worker. 62 type KonnectorMessage struct { 63 Account string `json:"account"` // Account is the identifier of the account 64 Konnector string `json:"konnector"` // Konnector is the slug of the konnector 65 FolderToSave string `json:"folder_to_save"` // FolderToSave is the identifier of the folder 66 BIWebhook bool `json:"bi_webhook,omitempty"` 67 AccountDeleted bool `json:"account_deleted,omitempty"` 68 69 // Data contains the original value of the message, even fields that are not 70 // part of our message definition. 71 data json.RawMessage 72 } 73 74 // ToJSON returns a JSON reprensation of the KonnectorMessage 75 func (m *KonnectorMessage) ToJSON() string { 76 return string(m.data) 77 } 78 79 // updateFolderToSave updates the message with the new dirID, and also the trigger 80 func (m *KonnectorMessage) updateFolderToSave(inst *instance.Instance, dir string) { 81 m.FolderToSave = dir 82 var d map[string]interface{} 83 _ = json.Unmarshal(m.data, &d) 84 d["folder_to_save"] = dir 85 m.data, _ = json.Marshal(d) 86 87 _ = couchdb.ForeachDocs(inst, consts.Triggers, func(_ string, data json.RawMessage) error { 88 var infos *job.TriggerInfos 89 if err := json.Unmarshal(data, &infos); err != nil { 90 return err 91 } 92 var msg map[string]interface{} 93 if err := json.Unmarshal(infos.Message, &msg); err != nil { 94 return err 95 } 96 if msg["account"] != m.Account || msg["konnector"] != m.Konnector { 97 return nil 98 } 99 msg["folder_to_save"] = dir 100 var err error 101 if infos.Message, err = json.Marshal(msg); err != nil { 102 return err 103 } 104 return couchdb.UpdateDoc(inst, infos) 105 }) 106 } 107 108 func jobHookErrorCheckerKonnector(err error) bool { 109 // If there was no previous error, we are fine to go on 110 if err == nil { 111 return true 112 } 113 114 lastError := err.Error() 115 if strings.HasPrefix(lastError, konnErrorLoginFailed) || 116 strings.HasPrefix(lastError, konnErrorUserActionNeeded) { 117 return false 118 } 119 return true 120 } 121 122 // beforeHookKonnector skips jobs from trigger that are failing on certain 123 // errors. 124 func beforeHookKonnector(j *job.Job) (bool, error) { 125 var msg KonnectorMessage 126 var slug string 127 128 if err := json.Unmarshal(j.Message, &msg); err == nil { 129 slug = msg.Konnector 130 131 inst, err := lifecycle.GetInstance(j.DomainName()) 132 if err != nil { 133 return false, err 134 } 135 136 flags, err := feature.GetFlags(inst) 137 if err != nil { 138 return false, err 139 } 140 skipMaintenance, err := flags.HasListItem("harvest.skip-maintenance-for", slug) 141 if err != nil { 142 return false, err 143 } 144 145 doc, err := app.GetMaintenanceOptions(slug) 146 if err != nil { 147 j.Logger().Warnf("konnector %q could not get local maintenance status", slug) 148 } else if doc != nil { 149 if j.Manual { 150 opts, ok := doc["maintenance_options"].(map[string]interface{}) 151 if ok && opts["flag_disallow_manual_exec"] != true { 152 return true, nil 153 } 154 } 155 156 if skipMaintenance { 157 j.Logger().Infof("skipping konnector %q's maintenance", slug) 158 return true, nil 159 } else { 160 j.Logger().Infof("konnector %q has not been triggered because of its maintenance status", slug) 161 return false, nil 162 } 163 } 164 165 app, err := registry.GetApplication(slug, inst.Registries()) 166 if err != nil { 167 j.Logger().Warnf("konnector %q could not get application to fetch maintenance status", slug) 168 } else if app.MaintenanceActivated { 169 if j.Manual && !app.MaintenanceOptions.FlagDisallowManualExec { 170 return true, nil 171 } 172 173 if skipMaintenance { 174 j.Logger().Infof("skipping konnector %q's maintenance", slug) 175 return true, nil 176 } else { 177 j.Logger().Infof("konnector %q has not been triggered because of its maintenance status", slug) 178 return false, nil 179 } 180 } 181 182 if msg.BIWebhook { 183 return true, nil 184 } 185 } 186 187 if j.Manual || j.TriggerID == "" { 188 return true, nil 189 } 190 191 state, err := job.GetTriggerState(j, j.TriggerID) 192 if err != nil { 193 return false, err 194 } 195 if state.Status == job.Errored { 196 ignore := 197 strings.HasPrefix(state.LastError, konnErrorUserActionNeeded) && 198 state.LastError != konnErrorUserActionNeededCgu 199 if strings.HasPrefix(state.LastError, konnErrorLoginFailed) { 200 ignore = true 201 } 202 if ignore { 203 j.Logger(). 204 WithField("account_id", msg.Account). 205 WithField("slug", slug). 206 Infof("Konnector ignore: %s", state.LastError) 207 return false, nil 208 } 209 } 210 return true, nil 211 } 212 213 func (w *konnectorWorker) PrepareWorkDir(ctx *job.TaskContext, i *instance.Instance) (string, func(), error) { 214 cleanDir := func() {} 215 216 // Reset the errors from previous runs on retries 217 w.err = nil 218 w.lastErr = nil 219 220 var err error 221 var data json.RawMessage 222 var msg KonnectorMessage 223 if err = ctx.UnmarshalMessage(&data); err != nil { 224 return "", cleanDir, err 225 } 226 if err = json.Unmarshal(data, &msg); err != nil { 227 return "", cleanDir, err 228 } 229 msg.data = data 230 231 slug := msg.Konnector 232 w.slug = slug 233 w.msg = &msg 234 235 w.man, err = app.GetKonnectorBySlugAndUpdate(i, slug, 236 app.Copier(consts.KonnectorType, i), i.Registries()) 237 if errors.Is(err, app.ErrNotFound) { 238 return "", cleanDir, job.BadTriggerError{Err: err} 239 } else if err != nil { 240 return "", cleanDir, err 241 } 242 243 // Check that the associated account is present. 244 var acc *account.Account 245 if msg.Account != "" && !msg.AccountDeleted { 246 acc = &account.Account{} 247 err = couchdb.GetDoc(i, consts.Accounts, msg.Account, acc) 248 if couchdb.IsNotFoundError(err) { 249 return "", cleanDir, job.BadTriggerError{Err: err} 250 } 251 } 252 253 man := w.man 254 // Upgrade "installed" to "ready" 255 if err := app.UpgradeInstalledState(i, man); err != nil { 256 return "", cleanDir, err 257 } 258 259 if man.State() != app.Ready { 260 return "", cleanDir, errors.New("Konnector is not ready") 261 } 262 263 var workDir string 264 osFS := afero.NewOsFs() 265 workDir, err = afero.TempDir(osFS, "", "konnector-"+slug) 266 if err != nil { 267 return "", cleanDir, err 268 } 269 cleanDir = func() { 270 _ = os.RemoveAll(workDir) 271 } 272 w.workDir = workDir 273 workFS := afero.NewBasePathFs(osFS, workDir) 274 275 fileServer := app.KonnectorsFileServer(i) 276 err = copyFiles(workFS, fileServer, slug, man.Version(), man.Checksum()) 277 if err != nil { 278 return "", cleanDir, err 279 } 280 281 // Create the folder in which the konnector has the right to write. 282 if err = w.ensureFolderToSave(ctx, i, acc); err != nil { 283 return "", cleanDir, err 284 } 285 286 // Make sure the konnector can write to this folder 287 if err = w.ensurePermissions(i); err != nil { 288 return "", cleanDir, err 289 } 290 291 // If we get the AccountDeleted flag on, we check if the konnector manifest 292 // has defined an "on_delete_account" field, containing the path of the file 293 // to execute on account deletation. If no such field is present, the job is 294 // aborted. 295 if w.msg.AccountDeleted { 296 // make sure we are not executing a path outside of the konnector's 297 // directory 298 fileExecPath := path.Join("/", path.Clean(w.man.OnDeleteAccount())) 299 fileExecPath = fileExecPath[1:] 300 if fileExecPath == "" { 301 return "", cleanDir, job.ErrAbort 302 } 303 return path.Join(workDir, fileExecPath), cleanDir, nil 304 } 305 306 return workDir, cleanDir, nil 307 } 308 309 // ensureFolderToSave tries hard to give a folder to the konnector where it can 310 // write its files if it needs to do so. 311 func (w *konnectorWorker) ensureFolderToSave(ctx *job.TaskContext, inst *instance.Instance, acc *account.Account) error { 312 fs := inst.VFS() 313 msg := w.msg 314 315 if msg.FolderToSave == "" { 316 return nil 317 } 318 319 // 1. Check if the folder identified by its ID exists 320 dir, err := fs.DirByID(msg.FolderToSave) 321 if err == nil { 322 if !strings.HasPrefix(dir.Fullpath, vfs.TrashDirName) { 323 return nil 324 } 325 } else if !os.IsNotExist(err) { 326 return err 327 } 328 329 var sourceAccountIdentifier string 330 if acc != nil && acc.Metadata != nil { 331 sourceAccountIdentifier = acc.Metadata.SourceIdentifier 332 } 333 334 // 2. Check if the konnector has a reference to a folder 335 start := []string{consts.Konnectors, consts.Konnectors + "/" + w.slug} 336 end := []string{start[0], start[1], couchdb.MaxString} 337 req := &couchdb.ViewRequest{ 338 StartKey: start, 339 EndKey: end, 340 IncludeDocs: true, 341 } 342 var res couchdb.ViewResponse 343 if err := couchdb.ExecView(inst, couchdb.FilesReferencedByView, req, &res); err == nil { 344 count := 0 345 dirID := "" 346 for _, row := range res.Rows { 347 dir := &vfs.DirDoc{} 348 if err := couchdb.GetDoc(inst, consts.Files, row.ID, dir); err == nil { 349 if strings.HasPrefix(dir.Fullpath, vfs.TrashDirName) { 350 continue 351 } 352 if !hasCompatibleSourceAccountIdentifier(dir, sourceAccountIdentifier) { 353 continue 354 } 355 count++ 356 dirID = row.ID 357 } 358 } 359 if count == 1 { 360 msg.updateFolderToSave(inst, dirID) 361 return nil 362 } 363 } 364 365 // 3 Check if a folder should be created 366 if acc == nil { 367 return nil 368 } 369 370 // 4. Find a path for the folder 371 folderPath := acc.DefaultFolderPath 372 if folderPath == "" { 373 folderPath = acc.FolderPath // For legacy purposes 374 } 375 if folderPath == "" { 376 folderPath = computeFolderPath(inst, w.man.Name(), acc) 377 } 378 379 // 5. Try to recreate the folder 380 dir, err = vfs.MkdirAll(fs, folderPath) 381 if err != nil { 382 dir, err = fs.DirByPath(folderPath) 383 if err != nil { 384 log := inst.Logger().WithNamespace("konnector") 385 log.Warnf("Can't create the default folder %s: %s", folderPath, err) 386 return err 387 } 388 } 389 msg.updateFolderToSave(inst, dir.ID()) 390 if len(dir.ReferencedBy) == 0 { 391 dir.AddReferencedBy(couchdb.DocReference{ 392 Type: consts.Konnectors, 393 ID: consts.Konnectors + "/" + w.slug, 394 }) 395 if sourceAccountIdentifier != "" { 396 dir.AddReferencedBy(couchdb.DocReference{ 397 Type: consts.SourceAccountIdentifier, 398 ID: sourceAccountIdentifier, 399 }) 400 } 401 instanceURL := inst.PageURL("/", nil) 402 if dir.CozyMetadata == nil { 403 dir.CozyMetadata = vfs.NewCozyMetadata(instanceURL) 404 } else { 405 dir.CozyMetadata.CreatedOn = instanceURL 406 } 407 dir.CozyMetadata.CreatedByApp = w.slug 408 dir.CozyMetadata.UpdatedByApp(&metadata.UpdatedByAppEntry{ 409 Slug: w.slug, 410 Date: dir.CozyMetadata.UpdatedAt, 411 Instance: instanceURL, 412 }) 413 dir.CozyMetadata.SourceAccount = acc.ID() 414 _ = couchdb.UpdateDoc(inst, dir) 415 } 416 417 // 6. Ensure that the account knows the folder path 418 if acc.DefaultFolderPath == "" { 419 acc.DefaultFolderPath = folderPath 420 _ = couchdb.UpdateDoc(inst, acc) 421 } 422 423 return nil 424 } 425 426 func computeFolderPath(inst *instance.Instance, slug string, acc *account.Account) string { 427 admin := inst.Translate("Tree Administrative") 428 r := strings.NewReplacer("&", "_", "/", "_", "\\", "_", "#", "_", 429 ",", "_", "+", "_", "(", "_", ")", "_", "$", "_", "@", "_", "~", 430 "_", "%", "_", ".", "_", "'", "_", "\"", "_", ":", "_", "*", "_", 431 "?", "_", "<", "_", ">", "_", "{", "_", "}", "_") 432 433 accountName := r.Replace(acc.Name) 434 if accountName == "" { 435 accountName = acc.ID() 436 } 437 438 title := cases.Title(language.Make(inst.Locale)).String(slug) 439 return fmt.Sprintf("/%s/%s/%s", admin, title, accountName) 440 } 441 442 func hasCompatibleSourceAccountIdentifier(dir *vfs.DirDoc, sourceAccountIdentifier string) bool { 443 if sourceAccountIdentifier == "" { 444 return true 445 } 446 nb := 0 447 for _, ref := range dir.ReferencedBy { 448 if ref.Type == consts.SourceAccountIdentifier { 449 if ref.ID == sourceAccountIdentifier { 450 return true 451 } 452 nb++ 453 } 454 } 455 return nb == 0 456 } 457 458 // ensurePermissions checks that the konnector has the permissions to write 459 // files in the folder referenced by the konnector, and adds the permission if 460 // needed. 461 func (w *konnectorWorker) ensurePermissions(inst *instance.Instance) error { 462 for { 463 perms, err := permission.GetForKonnector(inst, w.slug) 464 if err != nil { 465 return err 466 } 467 value := consts.Konnectors + "/" + w.slug 468 for _, rule := range perms.Permissions { 469 if rule.Type == consts.Files && rule.Selector == couchdb.SelectorReferencedBy { 470 for _, val := range rule.Values { 471 if val == value { 472 return nil 473 } 474 } 475 } 476 } 477 rule := permission.Rule{ 478 Type: consts.Files, 479 Title: "referenced folders", 480 Description: "folders referenced by the konnector", 481 Selector: couchdb.SelectorReferencedBy, 482 Values: []string{value}, 483 } 484 perms.Permissions = append(perms.Permissions, rule) 485 err = couchdb.UpdateDoc(inst, perms) 486 if !couchdb.IsConflictError(err) { 487 return err 488 } 489 } 490 } 491 492 func copyFiles(workFS afero.Fs, fileServer appfs.FileServer, slug, version, shasum string) error { 493 files, err := fileServer.FilesList(slug, version, shasum) 494 if err != nil { 495 return err 496 } 497 for _, file := range files { 498 switch file { 499 // The following files are completely useless for running a konnector, so we skip them 500 // in order to lower pressure on underlying file storage backend during high konnector execution rate 501 case 502 "README.md", 503 "package.json", 504 ".travis.yml", 505 "LICENSE": 506 continue 507 // Backward compatibility with older konnector storage pattern 508 // in unique tar file which has ben removed in #1332 509 case app.KonnectorArchiveName: 510 tarFile, err := fileServer.Open(slug, version, shasum, file) 511 if err != nil { 512 return err 513 } 514 err = extractTar(workFS, tarFile) 515 if errc := tarFile.Close(); err == nil { 516 err = errc 517 } 518 if err != nil { 519 return err 520 } 521 continue 522 } 523 var src io.ReadCloser 524 var dst io.WriteCloser 525 src, err = fileServer.Open(slug, version, shasum, file) 526 if err != nil { 527 return err 528 } 529 dirname := path.Dir(file) 530 if dirname != "." { 531 if err = workFS.MkdirAll(dirname, 0755); err != nil { 532 return err 533 } 534 } 535 dst, err = workFS.OpenFile(file, os.O_CREATE|os.O_WRONLY, 0640) 536 if err != nil { 537 return err 538 } 539 _, err = io.Copy(dst, src) 540 errc1 := dst.Close() 541 errc2 := src.Close() 542 if err != nil { 543 return err 544 } 545 if errc1 != nil { 546 return errc1 547 } 548 if errc2 != nil { 549 return errc2 550 } 551 } 552 return nil 553 } 554 555 func extractTar(workFS afero.Fs, tarFile io.ReadCloser) error { 556 tr := tar.NewReader(tarFile) 557 for { 558 var hdr *tar.Header 559 hdr, err := tr.Next() 560 if errors.Is(err, io.EOF) { 561 return nil 562 } 563 if err != nil { 564 return err 565 } 566 dirname := path.Dir(hdr.Name) 567 if dirname != "." { 568 if err = workFS.MkdirAll(dirname, 0755); err != nil { 569 return err 570 } 571 } 572 var f afero.File 573 f, err = workFS.OpenFile(hdr.Name, os.O_CREATE|os.O_WRONLY, 0640) 574 if err != nil { 575 return err 576 } 577 _, err = io.Copy(f, tr) 578 errc := f.Close() 579 if err != nil { 580 return err 581 } 582 if errc != nil { 583 return errc 584 } 585 } 586 } 587 588 func (w *konnectorWorker) Slug() string { 589 return w.slug 590 } 591 592 func (w *konnectorWorker) PrepareCmdEnv(ctx *job.TaskContext, i *instance.Instance) (cmd string, env []string, err error) { 593 parameters := w.man.Parameters() 594 595 accountTypes, err := account.FindAccountTypesBySlug(w.slug, i.ContextName) 596 if err == nil && len(accountTypes) == 1 && accountTypes[0].HasSecretGrant() { 597 secret := accountTypes[0].Secret 598 if parameters == nil { 599 parameters = map[string]interface{}{"secret": secret} 600 } else { 601 params := map[string]interface{}{} 602 for k, v := range parameters { 603 params[k] = v 604 } 605 params["secret"] = secret 606 parameters = params 607 } 608 } 609 610 paramsJSON, err := json.Marshal(parameters) 611 if err != nil { 612 return 613 } 614 615 language := w.man.Language() 616 if language == "" { 617 language = "node" 618 } 619 620 // Directly pass the job message as fields parameters 621 fieldsJSON := w.msg.ToJSON() 622 token := i.BuildKonnectorToken(w.man.Slug()) 623 624 payload, err := preparePayload(ctx, w.workDir) 625 if err != nil { 626 return "", nil, err 627 } 628 629 cmd = config.GetConfig().Konnectors.Cmd 630 env = []string{ 631 "COZY_URL=" + i.PageURL("/", nil), 632 "COZY_CREDENTIALS=" + token, 633 "COZY_FIELDS=" + fieldsJSON, 634 "COZY_PARAMETERS=" + string(paramsJSON), 635 "COZY_PAYLOAD=" + payload, 636 "COZY_LANGUAGE=" + language, 637 "COZY_LOCALE=" + i.Locale, 638 "COZY_TIME_LIMIT=" + ctxToTimeLimit(ctx), 639 "COZY_JOB_ID=" + ctx.ID(), 640 "COZY_JOB_MANUAL_EXECUTION=" + strconv.FormatBool(ctx.Manual()), 641 } 642 if triggerID, ok := ctx.TriggerID(); ok { 643 env = append(env, "COZY_TRIGGER_ID="+triggerID) 644 } 645 return 646 } 647 648 func (w *konnectorWorker) Logger(ctx *job.TaskContext) logger.Logger { 649 return ctx.Logger().WithField("slug", w.slug) 650 } 651 652 func (w *konnectorWorker) ScanOutput(ctx *job.TaskContext, i *instance.Instance, line []byte) error { 653 var msg struct { 654 Type string `json:"type"` 655 Message string `json:"message"` 656 NoRetry bool `json:"no_retry"` 657 } 658 if err := json.Unmarshal(line, &msg); err != nil { 659 return fmt.Errorf("Could not parse stdout as JSON: %q", string(line)) 660 } 661 662 // Truncate very long messages 663 if len(msg.Message) > 4000 { 664 msg.Message = msg.Message[:4000] 665 } 666 667 log := w.Logger(ctx) 668 switch msg.Type { 669 case konnectorMsgTypeDebug, konnectorMsgTypeInfo: 670 log.Debug(msg.Message) 671 case konnectorMsgTypeWarning, "warn": 672 log.Warn(msg.Message) 673 case konnectorMsgTypeError: 674 // For retro-compatibility, we still use "error" logs as returned error, 675 // only in the case that no "critical" message are actually returned. In 676 // such case, We use the last "error" log as the returned error. 677 w.lastErr = errors.New(msg.Message) 678 log.Error(msg.Message) 679 case konnectorMsgTypeCritical: 680 w.err = errors.New(msg.Message) 681 if msg.NoRetry { 682 ctx.SetNoRetry() 683 } 684 log.Error(msg.Message) 685 } 686 687 realtime.GetHub().Publish(i, 688 realtime.EventCreate, 689 &couchdb.JSONDoc{Type: consts.JobEvents, M: map[string]interface{}{ 690 "type": msg.Type, 691 "message": msg.Message, 692 }}, 693 nil) 694 return nil 695 } 696 697 func (w *konnectorWorker) Error(i *instance.Instance, err error) error { 698 if w.err != nil { 699 return w.err 700 } 701 if w.lastErr != nil { 702 return w.lastErr 703 } 704 return err 705 } 706 707 func (w *konnectorWorker) Commit(ctx *job.TaskContext, errjob error) error { 708 log := w.Logger(ctx) 709 if w.msg != nil { 710 log = log.WithField("account_id", w.msg.Account) 711 if w.msg.BIWebhook { 712 log = log.WithField("bi_webhook", w.msg.BIWebhook) 713 } 714 } 715 if w.man != nil { 716 log = log.WithField("version", w.man.Version()) 717 } 718 if errjob == nil { 719 log.Info("Konnector success") 720 // Clean the soft-deleted account 721 msg := &KonnectorMessage{} 722 if err := ctx.UnmarshalMessage(&msg); err == nil && msg.AccountDeleted { 723 var doc couchdb.JSONDoc 724 err := couchdb.GetDoc(ctx.Instance, consts.SoftDeletedAccounts, msg.Account, &doc) 725 if err == nil { 726 doc.Type = consts.SoftDeletedAccounts 727 err = couchdb.DeleteDoc(ctx.Instance, &doc) 728 } 729 if err != nil { 730 log.Warnf("Cannot clean soft-deleted account: %s", err) 731 } 732 } 733 } else { 734 log.Infof("Konnector failure: %s", errjob) 735 } 736 return nil 737 }