package cliservice import ( "context" "errors" "fmt" "os/exec" "strings" "time" "github.com/google/uuid" "edge-infra.dev/pkg/lib/fog" "edge-infra.dev/pkg/sds/emergencyaccess/msgdata" "edge-infra.dev/pkg/sds/emergencyaccess/remotecli" ) const ( responseTopic = "topic.dsds-ea-response" perSessionSubscription = "sub.session.%s.dsds-ea-response" ) // type uerr is a simple type that implements both error and UserError, returning // the same string for both interfaces type uerr string func (err uerr) Error() string { return string(err) } func (err uerr) UserError() []string { return []string{string(err)} } // type uuerr is a type which implements error and UserError, it allows setting // different messages for both interfaces type uuerr struct { error user string } func (err uuerr) UserError() []string { return []string{err.user} } // This file defines the public APIs to connect, send to and end // a session. type remoteCLI interface { Send(ctx context.Context, userID string, sessionID string, commandID string, request msgdata.Request, opts ...remotecli.RCLIOption) error StartSession(ctx context.Context, sessionID string, displayChan chan<- msgdata.CommandResponse, target remotecli.Target, opts ...remotecli.RCLIOption) error EndSession(ctx context.Context, sessionID string) error } type subscriptionCreator interface { CreateSubscription(ctx context.Context, sessionID string, subscriptionID string, projectID string, topicID string) error DeleteSubscription(ctx context.Context, subscriptionID string, projectID string) error } type target struct { projectID string bannerID string storeID string terminalID string } func (t target) ProjectID() string { return t.projectID } func (t target) BannerID() string { return t.bannerID } func (t target) StoreID() string { return t.storeID } func (t target) TerminalID() string { return t.terminalID } type CLIService struct { dispChan chan msgdata.CommandResponse rcli remoteCLI ms remotecli.MsgSvc seshCtx context.Context seshCancel context.CancelFunc target remotecli.Target sessionID string userID string idleTime time.Time // remotecli optionaltemplates topicTemplate string subscriptionTemplate string perSessionSubscription bool } // initilaises a cls with an rcli // takes a parent context (from main idealy) func NewCLIService(ctx context.Context, ms remotecli.MsgSvc) CLIService { ctx = fog.IntoContext(ctx, fog.FromContext(ctx).WithName("remotecli")) rcli := remotecli.New(ctx, ms) return CLIService{ rcli: rcli, ms: ms, // Default to creating a new subscription per session perSessionSubscription: true, } } // Connects to the ms. takes a context from main to generate the session context func (cls *CLIService) Connect(ctx context.Context, projectID string, bannerID string, storeID string, terminalID string) error { cls.seshCtx, cls.seshCancel = context.WithCancel(ctx) if projectID == "" { return uerr("Project ID is a required field") } cls.target = target{projectID: projectID, bannerID: bannerID, storeID: storeID, terminalID: terminalID} opts := []remotecli.RCLIOption{} cls.sessionID = uuid.NewString() cls.dispChan = make(chan msgdata.CommandResponse, 10) os, err := cls.createSubscription(cls.seshCtx, projectID) if err != nil { return err } opts = append(opts, os...) if cls.subscriptionTemplate != "" { opts = append(opts, remotecli.WithOptionalTemplate(cls.subscriptionTemplate)) } err = cls.rcli.StartSession(ctx, cls.sessionID, cls.dispChan, cls.target, opts...) if err != nil { close(cls.dispChan) } cls.idleTime = time.Now() return err } // Creates a new subscription in the given projectID with the details stored in cls. // If sussessful returns an remotecli.RCLIOption with the template set to a valid // value for the created subscription. // Does not create a subscription if [cls.DisablePerSessionSubscription] has been // called. func (cls *CLIService) createSubscription(ctx context.Context, projectID string) ([]remotecli.RCLIOption, error) { if !cls.perSessionSubscription { return nil, nil } subscriptionID := fmt.Sprintf(perSessionSubscription, cls.sessionID) if m, ok := cls.ms.(subscriptionCreator); ok { // TODO: what if not ok? err := m.CreateSubscription(ctx, cls.sessionID, subscriptionID, projectID, responseTopic) if err != nil { err := uuerr{ error: fmt.Errorf("error creating subscription: %w", err), user: "Error creating subscription. Consider running `rcliconfig disablePerSessionSubscription` or see https://docs.edge-infra.dev/edge/sds/remoteaccess-tools/emergency-access/#multiple-users-connected-to-the-same-store-or-terminals", } return nil, err } } return []remotecli.RCLIOption{ remotecli.WithOptionalTemplate(subscriptionID), }, nil } func (cls *CLIService) Send(command string) (string, error) { if cls.userID == "" { return "", fmt.Errorf("no user id provided") } commandID := uuid.NewString() log := fog.FromContext( cls.seshCtx, "sessionID", cls.sessionID, "commandID", commandID, ) ctx := fog.IntoContext(cls.seshCtx, log) request, err := msgdata.NewV1_0Request(command) if err != nil { return "", fmt.Errorf("error creating command: %w", err) } opts := []remotecli.RCLIOption{} if cls.topicTemplate != "" { opts = append(opts, remotecli.WithOptionalTemplate(cls.topicTemplate)) } cls.idleTime = time.Now() return commandID, cls.rcli.Send(ctx, cls.userID, cls.sessionID, commandID, request, opts...) } func (cls *CLIService) End() error { deleteErr := cls.deleteSubscription(cls.seshCtx) // always end the session regardless of the delete subscription error cls.seshCancel() endErr := cls.rcli.EndSession(cls.seshCtx, cls.sessionID) return errors.Join(deleteErr, endErr) } // Deletes the per session subscription if required func (cls *CLIService) deleteSubscription(ctx context.Context) error { if !cls.perSessionSubscription { return nil } if m, ok := cls.ms.(subscriptionCreator); ok { // TODO: what if not ok? subscriptionID := fmt.Sprintf(perSessionSubscription, cls.sessionID) err := m.DeleteSubscription(ctx, subscriptionID, cls.target.ProjectID()) if err != nil { // This error is only logged in the log file, there is no screen output // indicating there has been an error return fmt.Errorf("error deleting per session subscription: %w", err) } } return nil } func (cls *CLIService) RetrieveIdentity(_ context.Context) error { cmd := exec.Command("gcloud", "config", "get-value", "account") out, err := cmd.Output() if err != nil { return err } res := strings.TrimSpace(string(out)) if res == "" { return fmt.Errorf("no gcloud userid found") } cls.userID = res return nil } // Optionally modify the topic messages are sent to func (cls *CLIService) SetTopicTemplate(topicTemplate string) { cls.topicTemplate = topicTemplate } // Optionally modify the subscription name messages are listened to func (cls *CLIService) SetSubscriptionTemplate(subscriptionTemplate string) { cls.subscriptionTemplate = subscriptionTemplate } // returns a read only version of the channel. // feed will be blocked when the session prompt is active. func (cls CLIService) GetDisplayChannel() <-chan msgdata.CommandResponse { return cls.dispChan } func (cls CLIService) GetSessionContext() context.Context { return cls.seshCtx } func (cls *CLIService) UserID() string { return cls.userID } func (cls CLIService) IdleTime() time.Duration { return time.Since(cls.idleTime) } // Setting which, when enabled, causes a new subscription scoped to a single // sessionID to be created on Connect, and used in place of the default // response subscription to listen to response messages. // This is the default behaviour. func (cls *CLIService) EnablePerSessionSubscription() { cls.perSessionSubscription = true } // Disables creating a new subscription scoped to a single sessionID func (cls *CLIService) DisablePerSessionSubscription() { cls.perSessionSubscription = false } func (cls *CLIService) Env() []string { return nil }