github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/worker/exec/service.go (about)

     1  package exec
     2  
     3  import (
     4  	"encoding/json"
     5  	"errors"
     6  	"fmt"
     7  	"io"
     8  	"os"
     9  	"path"
    10  
    11  	"github.com/cozy/cozy-stack/model/app"
    12  	"github.com/cozy/cozy-stack/model/instance"
    13  	"github.com/cozy/cozy-stack/model/job"
    14  	"github.com/cozy/cozy-stack/pkg/appfs"
    15  	"github.com/cozy/cozy-stack/pkg/config/config"
    16  	"github.com/cozy/cozy-stack/pkg/consts"
    17  	"github.com/cozy/cozy-stack/pkg/couchdb"
    18  	"github.com/cozy/cozy-stack/pkg/logger"
    19  	"github.com/spf13/afero"
    20  )
    21  
    22  // ServiceOptions contains the options to execute a service.
    23  type ServiceOptions struct {
    24  	Slug   string          `json:"slug"`   // The application slug
    25  	Name   string          `json:"name"`   // The service name
    26  	Fields json.RawMessage `json:"fields"` // Custom fields
    27  	File   string          `json:"service_file"`
    28  
    29  	Message *ServiceOptions `json:"message"`
    30  }
    31  
    32  type serviceWorker struct {
    33  	man     *app.WebappManifest
    34  	slug    string
    35  	name    string
    36  	fields  json.RawMessage
    37  	workDir string
    38  }
    39  
    40  func (w *serviceWorker) PrepareWorkDir(ctx *job.TaskContext, i *instance.Instance) (workDir string, cleanDir func(), err error) {
    41  	cleanDir = func() {}
    42  	opts := &ServiceOptions{}
    43  	if err = ctx.UnmarshalMessage(&opts); err != nil {
    44  		return
    45  	}
    46  	if opts.Message != nil {
    47  		opts = opts.Message
    48  	}
    49  
    50  	slug := opts.Slug
    51  	name := opts.Name
    52  	fields := opts.Fields
    53  
    54  	man, err := app.GetWebappBySlugAndUpdate(i, slug,
    55  		app.Copier(consts.WebappType, i), i.Registries())
    56  	if err != nil {
    57  		if errors.Is(err, app.ErrNotFound) {
    58  			err = job.BadTriggerError{Err: err}
    59  		}
    60  		return
    61  	}
    62  
    63  	w.slug = slug
    64  	w.name = name
    65  	w.fields = fields
    66  
    67  	// Upgrade "installed" to "ready"
    68  	if err = app.UpgradeInstalledState(i, man); err != nil {
    69  		return
    70  	}
    71  
    72  	if man.State() != app.Ready {
    73  		err = errors.New("Application is not ready")
    74  		return
    75  	}
    76  
    77  	var service *app.Service
    78  	var ok bool
    79  	services := man.Services()
    80  	if name != "" {
    81  		service, ok = services[name]
    82  	} else {
    83  		for _, s := range services {
    84  			if s.File == opts.File {
    85  				service, ok = s, true
    86  				break
    87  			}
    88  		}
    89  	}
    90  	if !ok {
    91  		err = job.BadTriggerError{Err: fmt.Errorf("Service %q was not found", name)}
    92  		return
    93  	}
    94  	// Check if the trigger is orphan
    95  	if triggerID, ok := ctx.TriggerID(); ok && service.TriggerID != "" {
    96  		if triggerID != service.TriggerID {
    97  			// Check if this is another trigger for the same declared service.
    98  			// Note that the trigger may be not found if it was an @at.
    99  			var tInfos job.TriggerInfos
   100  			if err = couchdb.GetDoc(i, consts.Triggers, triggerID, &tInfos); err == nil {
   101  				var msg ServiceOptions
   102  				err = json.Unmarshal(tInfos.Message, &msg)
   103  				if err != nil {
   104  					err = job.BadTriggerError{Err: fmt.Errorf("Trigger %q has bad message structure", triggerID)}
   105  					return
   106  				}
   107  				if msg.Name != name {
   108  					err = job.BadTriggerError{Err: fmt.Errorf("Trigger %q is orphan", triggerID)}
   109  					return
   110  				}
   111  			}
   112  		}
   113  	}
   114  
   115  	w.man = man
   116  
   117  	osFS := afero.NewOsFs()
   118  	workDir, err = afero.TempDir(osFS, "", "service-"+slug)
   119  	if err != nil {
   120  		return
   121  	}
   122  	cleanDir = func() {
   123  		_ = os.RemoveAll(workDir)
   124  	}
   125  	w.workDir = workDir
   126  	workFS := afero.NewBasePathFs(osFS, workDir)
   127  
   128  	var fs appfs.FileServer
   129  	if man.FromAppsDir {
   130  		fs = app.FSForAppDir(man.Slug())
   131  	} else {
   132  		fs = app.AppsFileServer(i)
   133  	}
   134  	src, err := fs.Open(man.Slug(), man.Version(), man.Checksum(), path.Join("/", service.File))
   135  	if err != nil {
   136  		return
   137  	}
   138  	defer src.Close()
   139  
   140  	dst, err := workFS.OpenFile("index.js", os.O_CREATE|os.O_WRONLY, 0640)
   141  	if err != nil {
   142  		return
   143  	}
   144  	defer dst.Close()
   145  
   146  	_, err = io.Copy(dst, src)
   147  	if err != nil {
   148  		return
   149  	}
   150  
   151  	return workDir, cleanDir, nil
   152  }
   153  
   154  func (w *serviceWorker) Slug() string {
   155  	return w.slug
   156  }
   157  
   158  func (w *serviceWorker) PrepareCmdEnv(ctx *job.TaskContext, i *instance.Instance) (cmd string, env []string, err error) {
   159  	type serviceEvent struct {
   160  		Doc interface{} `json:"doc"`
   161  	}
   162  
   163  	var doc serviceEvent
   164  	marshaled := []byte{}
   165  	if err := ctx.UnmarshalEvent(&doc); err == nil {
   166  		marshaled, err = json.Marshal(doc.Doc)
   167  		if err != nil {
   168  			return "", nil, err
   169  		}
   170  	}
   171  
   172  	payload, err := preparePayload(ctx, w.workDir)
   173  	if err != nil {
   174  		return "", nil, err
   175  	}
   176  
   177  	token := i.BuildAppToken(w.man.Slug(), "")
   178  	cmd = config.GetConfig().Konnectors.Cmd
   179  	env = []string{
   180  		"COZY_URL=" + i.PageURL("/", nil),
   181  		"COZY_CREDENTIALS=" + token,
   182  		"COZY_LANGUAGE=node", // default to node language for services
   183  		"COZY_LOCALE=" + i.Locale,
   184  		"COZY_TIME_LIMIT=" + ctxToTimeLimit(ctx),
   185  		"COZY_JOB_ID=" + ctx.ID(),
   186  		"COZY_COUCH_DOC=" + string(marshaled),
   187  		"COZY_PAYLOAD=" + payload,
   188  		"COZY_FIELDS=" + string(w.fields),
   189  	}
   190  	if triggerID, ok := ctx.TriggerID(); ok {
   191  		env = append(env, "COZY_TRIGGER_ID="+triggerID)
   192  	}
   193  	return
   194  }
   195  
   196  func (w *serviceWorker) Logger(ctx *job.TaskContext) logger.Logger {
   197  	log := ctx.Logger().WithField("slug", w.Slug())
   198  	if w.name != "" {
   199  		log = log.WithField("name", w.name)
   200  	}
   201  	return log
   202  }
   203  
   204  func (w *serviceWorker) ScanOutput(ctx *job.TaskContext, i *instance.Instance, line []byte) error {
   205  	var msg struct {
   206  		Type    string `json:"type"`
   207  		Message string `json:"message"`
   208  	}
   209  	if err := json.Unmarshal(line, &msg); err != nil {
   210  		return fmt.Errorf("Could not parse stdout as JSON: %q", string(line))
   211  	}
   212  
   213  	// Truncate very long messages
   214  	if len(msg.Message) > 4000 {
   215  		msg.Message = msg.Message[:4000]
   216  	}
   217  
   218  	log := w.Logger(ctx)
   219  	switch msg.Type {
   220  	case konnectorMsgTypeDebug, konnectorMsgTypeInfo:
   221  		log.Debug(msg.Message)
   222  	case konnectorMsgTypeWarning, "warn":
   223  		log.Warn(msg.Message)
   224  	case konnectorMsgTypeError:
   225  		log.Error(msg.Message)
   226  	case konnectorMsgTypeCritical:
   227  		log.Error(msg.Message)
   228  	}
   229  	return nil
   230  }
   231  
   232  func (w *serviceWorker) Error(i *instance.Instance, err error) error {
   233  	return err
   234  }
   235  
   236  func (w *serviceWorker) Commit(ctx *job.TaskContext, errjob error) error {
   237  	log := w.Logger(ctx)
   238  	if errjob == nil {
   239  		log.Info("Service success")
   240  	} else {
   241  		log.Infof("Service failure: %s", errjob)
   242  	}
   243  	return nil
   244  }