diff --git a/cmd/state-exec/cmd.go b/cmd/state-exec/cmd.go index 2b7595384f..e7a40a8720 100644 --- a/cmd/state-exec/cmd.go +++ b/cmd/state-exec/cmd.go @@ -6,7 +6,7 @@ import ( "os/exec" ) -func runCmd(meta *executorMeta) error { +func runCmd(meta *executorMeta) (int, error) { userArgs := os.Args[1:] cmd := exec.Command(meta.MatchingBin, userArgs...) cmd.Stdin = os.Stdin @@ -15,8 +15,8 @@ func runCmd(meta *executorMeta) error { cmd.Env = meta.TransformedEnv if err := cmd.Run(); err != nil { - return fmt.Errorf("command %q failed: %w", meta.MatchingBin, err) + return -1, fmt.Errorf("command %q failed: %w", meta.MatchingBin, err) } - return nil + return cmd.ProcessState.ExitCode(), nil } diff --git a/cmd/state-exec/comm.go b/cmd/state-exec/comm.go index 49ee0a974d..8e5a5a3584 100644 --- a/cmd/state-exec/comm.go +++ b/cmd/state-exec/comm.go @@ -12,14 +12,14 @@ const ( msgWidth = 1024 ) -func sendMsgToService(sockPath string, hb *svcmsg.Heartbeat) error { +func sendMsgToService(sockPath string, msg svcmsg.Messager) error { conn, err := net.Dial(network, sockPath) if err != nil { return fmt.Errorf("dial failed: %w", err) } defer conn.Close() - _, err = conn.Write([]byte(hb.SvcMsg())) + _, err = conn.Write([]byte(msg.SvcMsg())) if err != nil { return fmt.Errorf("write to connection failed: %w", err) } diff --git a/cmd/state-exec/exitcode.go b/cmd/state-exec/exitcode.go new file mode 100644 index 0000000000..f0b2d0586c --- /dev/null +++ b/cmd/state-exec/exitcode.go @@ -0,0 +1,17 @@ +package main + +import ( + "fmt" + "os" + "strconv" + + "github.com/ActiveState/cli/internal/svcctl/svcmsg" +) + +func newExitCodeMessage(exitCode int) (*svcmsg.ExitCode, error) { + execPath, err := os.Executable() + if err != nil { + return nil, fmt.Errorf("cannot get executable info: %w", err) + } + return &svcmsg.ExitCode{execPath, strconv.Itoa(exitCode)}, nil +} diff --git a/cmd/state-exec/main.go b/cmd/state-exec/main.go index 47c375daa3..5f1842f9c9 100644 --- a/cmd/state-exec/main.go +++ b/cmd/state-exec/main.go @@ -93,10 +93,25 @@ func run() error { } logr.Debug("cmd - running: %s", meta.MatchingBin) - if err := runCmd(meta); err != nil { + exitCode, err := runCmd(meta) + if err != nil { logr.Debug(" running - failed: bins (%v)", meta.ExecMeta.Bins) return fmt.Errorf("cannot run command: %w", err) } + msg, err := newExitCodeMessage(exitCode) + if err != nil { + return fmt.Errorf("cannot create new exit code message: %w", err) + } + logr.Debug("message data - exec: %s, exit code: %s", msg.ExecPath, msg.ExitCode) + + if err := sendMsgToService(meta.SockPath, msg); err != nil { + logr.Debug(" sock - error: %v", err) + + if onCI() { // halt control flow on CI only + return fmt.Errorf("cannot send message to service (this error is handled in CI only): %w", err) + } + } + return nil } diff --git a/cmd/state-svc/service.go b/cmd/state-svc/service.go index b290cd44a7..fe52a5f883 100644 --- a/cmd/state-svc/service.go +++ b/cmd/state-svc/service.go @@ -55,6 +55,7 @@ func (s *service) Start() error { svcctl.HTTPAddrHandler(portText(s.server)), svcctl.LogFileHandler(s.logFile), svcctl.HeartbeatHandler(s.cfg, s.server.Resolver(), s.an), + svcctl.ExitCodeHandler(s.cfg, s.server.Resolver(), s.an), } s.ipcSrv = ipc.NewServer(s.ctx, spath, reqHandlers...) err = s.ipcSrv.Start() diff --git a/internal/analytics/client/sync/client.go b/internal/analytics/client/sync/client.go index 751c377149..5ab2ae1ea8 100644 --- a/internal/analytics/client/sync/client.go +++ b/internal/analytics/client/sync/client.go @@ -46,6 +46,7 @@ type Client struct { sequence int auth *authentication.Auth source string + mutex sync.Mutex } var _ analytics.Dispatcher = &Client{} @@ -196,6 +197,11 @@ func (a *Client) EventWithSource(category, action, source string, dims ...*dimen // source (for example, state-svc forwarding events on behalf of State Tool or an executor). // Otherwise, use EventWithLabel(). func (a *Client) EventWithSourceAndLabel(category, action, source, label string, dims ...*dimensions.Values) { + // Note: svcctl runs event handlers as goroutines, so calling this function twice in goroutines + // can cause synchronization issues. + a.mutex.Lock() + defer a.mutex.Unlock() + if a.customDimensions == nil { if condition.InUnitTest() { return diff --git a/internal/analytics/constants/constants.go b/internal/analytics/constants/constants.go index 72ca08144e..3fde7bd722 100644 --- a/internal/analytics/constants/constants.go +++ b/internal/analytics/constants/constants.go @@ -154,6 +154,9 @@ const ActCommandError = "command-error" // ActCommandInputError is the event action used for command input errors const ActCommandInputError = "command-input-error" +// ActExecutorExit is the event action used for executor exit codes +const ActExecutorExit = "executor-exit" + // UpdateLabelSuccess is the sent if an auto-update was successful const UpdateLabelSuccess = "success" diff --git a/internal/svcctl/comm.go b/internal/svcctl/comm.go index 7ada81cdcf..6d07662837 100644 --- a/internal/svcctl/comm.go +++ b/internal/svcctl/comm.go @@ -29,6 +29,7 @@ var ( KeyHTTPAddr = "http-addr" KeyLogFile = "log-file" KeyHeartbeat = "heart<" + KeyExitCode = "exitcode<" ) type Requester interface { @@ -79,6 +80,7 @@ type Resolver interface { type AnalyticsReporter interface { EventWithSource(category, action, source string, dims ...*dimensions.Values) + EventWithSourceAndLabel(category, action, source, label string, dims ...*dimensions.Values) } func HeartbeatHandler(cfg *config.Instance, resolver Resolver, analyticsReporter AnalyticsReporter) ipc.RequestHandler { @@ -150,6 +152,24 @@ func HeartbeatHandler(cfg *config.Instance, resolver Resolver, analyticsReporter } } -func (c *Comm) SendHeartbeat(ctx context.Context, pid string) (string, error) { - return c.req.Request(ctx, KeyHeartbeat+pid) +func ExitCodeHandler(cfg *config.Instance, resolver Resolver, analyticsReporter AnalyticsReporter) ipc.RequestHandler { + return func(input string) (string, bool) { + defer func() { panics.HandlePanics(recover(), debug.Stack()) }() + + if !strings.HasPrefix(input, KeyExitCode) { + return "", false + } + + logging.Debug("Exit Code: Received exit code through ipc") + + data := input[len(KeyExitCode):] + exitCode := svcmsg.NewExitCodeFromSvcMsg(data) + + logging.Debug("Firing exit code event for %s", exitCode.ExecPath) + analyticsReporter.EventWithSourceAndLabel(constants.CatDebug, constants.ActExecutorExit, constants.SrcExecutor, exitCode.ExitCode, &dimensions.Values{ + Command: ptr.To(exitCode.ExecPath), + }) + + return "ok", true + } } diff --git a/internal/svcctl/svcmsg/exitcode.go b/internal/svcctl/svcmsg/exitcode.go new file mode 100644 index 0000000000..8c0e03fb1a --- /dev/null +++ b/internal/svcctl/svcmsg/exitcode.go @@ -0,0 +1,42 @@ +// Package svcmsg models the Exit Code data that the executor must communicate +// to the service. +// +// IMPORTANT: This package should have minimal dependencies as it will be +// imported by cmd/state-exec. The resulting compiled executable must remain as +// small as possible. +package svcmsg + +import ( + "fmt" + "strings" +) + +type ExitCode struct { + ExecPath string + ExitCode string +} + +func NewExitCodeFromSvcMsg(data string) *ExitCode { + var execPath, exitCode string + + ss := strings.SplitN(data, "<", 2) + if len(ss) > 0 { + execPath = ss[0] + } + if len(ss) > 1 { + exitCode = ss[1] + } + + return NewExitCode(execPath, exitCode) +} + +func NewExitCode(execPath, exitCode string) *ExitCode { + return &ExitCode{ + ExecPath: execPath, + ExitCode: exitCode, + } +} + +func (e *ExitCode) SvcMsg() string { + return fmt.Sprintf("exitcode<%s<%s", e.ExecPath, e.ExitCode) +} diff --git a/internal/svcctl/svcmsg/svcmsg.go b/internal/svcctl/svcmsg/svcmsg.go new file mode 100644 index 0000000000..24ac38dc4f --- /dev/null +++ b/internal/svcctl/svcmsg/svcmsg.go @@ -0,0 +1,5 @@ +package svcmsg + +type Messager interface { + SvcMsg() string +} diff --git a/test/integration/analytics_int_test.go b/test/integration/analytics_int_test.go index c862b64e1d..9f27c3339e 100644 --- a/test/integration/analytics_int_test.go +++ b/test/integration/analytics_int_test.go @@ -148,6 +148,8 @@ func (suite *AnalyticsIntegrationTestSuite) TestHeartbeats() { }) suite.Require().Equal(1, countEvents(executorEvents, anaConst.CatRuntimeUsage, anaConst.ActRuntimeAttempt, anaConst.SrcExecutor), ts.DebugMessage("Should have a runtime attempt, events:\n"+suite.summarizeEvents(executorEvents))) + suite.Require().Equal(1, countEvents(eventsAfterExecutor, anaConst.CatDebug, anaConst.ActExecutorExit, anaConst.SrcExecutor), + ts.DebugMessage("Should have an executor exit event, events:\n"+suite.summarizeEvents(executorEvents))) // It's possible due to the timing of the heartbeats and the fact that they are async that we have gotten either // one or two by this point. Technically more is possible, just very unlikely.