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  }