diff --git a/README.md b/README.md index 315e9d08..ab9a0cc2 100644 --- a/README.md +++ b/README.md @@ -688,6 +688,13 @@ checks: # directly, not interpreted by a shell. command: + # (Optional) Run the command in the context of this service. + # Specifically, inherit its environment variables, user/group + # settings, and working directory. The check's context (the + # settings below) will override the service's; the check's + # environment map will be merged on top of the service's. + service-context: + # (Optional) A list of key/value pairs defining environment # variables that should be set when running the command. environment: diff --git a/client/exec.go b/client/exec.go index 4b74242d..88d405bd 100644 --- a/client/exec.go +++ b/client/exec.go @@ -30,22 +30,28 @@ type ExecOptions struct { // Required: command and arguments (first element is the executable). Command []string + // Optional: run the command in the context of this service. Specifically, + // inherit its environment variables, user/group settings, and working + // and working directory. The other options in this struct will override + // the service context; Environment will be merged on top of the service's. + ServiceContext string + // Optional environment variables. Environment map[string]string // Optional working directory (default is $HOME or "/" if $HOME not set). WorkingDir string - // Optional timeout for the command execution, after which the process - // will be terminated. If zero, no timeout applies. - Timeout time.Duration - // Optional user ID and group ID for the process to run as. UserID *int User string GroupID *int Group string + // Optional timeout for the command execution, after which the process + // will be terminated. If zero, no timeout applies. + Timeout time.Duration + // True to ask the server to set up a pseudo-terminal (PTY) for stdout // (this also allows window resizing). The default is no PTY, and just // to use pipes for stdout/stderr. @@ -74,19 +80,20 @@ type ExecOptions struct { } type execPayload struct { - Command []string `json:"command"` - Environment map[string]string `json:"environment,omitempty"` - WorkingDir string `json:"working-dir,omitempty"` - Timeout string `json:"timeout,omitempty"` - UserID *int `json:"user-id,omitempty"` - User string `json:"user,omitempty"` - GroupID *int `json:"group-id,omitempty"` - Group string `json:"group,omitempty"` - Terminal bool `json:"terminal,omitempty"` - Interactive bool `json:"interactive,omitempty"` - SplitStderr bool `json:"split-stderr,omitempty"` - Width int `json:"width,omitempty"` - Height int `json:"height,omitempty"` + Command []string `json:"command"` + ServiceContext string `json:"service-context,omitempty"` + Environment map[string]string `json:"environment,omitempty"` + WorkingDir string `json:"working-dir,omitempty"` + Timeout string `json:"timeout,omitempty"` + UserID *int `json:"user-id,omitempty"` + User string `json:"user,omitempty"` + GroupID *int `json:"group-id,omitempty"` + Group string `json:"group,omitempty"` + Terminal bool `json:"terminal,omitempty"` + Interactive bool `json:"interactive,omitempty"` + SplitStderr bool `json:"split-stderr,omitempty"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` } type execResult struct { @@ -122,19 +129,20 @@ func (client *Client) Exec(opts *ExecOptions) (*ExecProcess, error) { timeoutStr = opts.Timeout.String() } payload := execPayload{ - Command: opts.Command, - Environment: opts.Environment, - WorkingDir: opts.WorkingDir, - Timeout: timeoutStr, - UserID: opts.UserID, - User: opts.User, - GroupID: opts.GroupID, - Group: opts.Group, - Terminal: opts.Terminal, - Interactive: opts.Interactive, - SplitStderr: opts.Stderr != nil, - Width: opts.Width, - Height: opts.Height, + Command: opts.Command, + ServiceContext: opts.ServiceContext, + Environment: opts.Environment, + WorkingDir: opts.WorkingDir, + Timeout: timeoutStr, + UserID: opts.UserID, + User: opts.User, + GroupID: opts.GroupID, + Group: opts.Group, + Terminal: opts.Terminal, + Interactive: opts.Interactive, + SplitStderr: opts.Stderr != nil, + Width: opts.Width, + Height: opts.Height, } var body bytes.Buffer err := json.NewEncoder(&body).Encode(&payload) diff --git a/internals/cli/cmd_exec.go b/internals/cli/cmd_exec.go index 0298f2b2..1dae5300 100644 --- a/internals/cli/cmd_exec.go +++ b/internals/cli/cmd_exec.go @@ -39,6 +39,7 @@ type cmdExec struct { GroupID *int `long:"gid"` Group string `long:"group"` Timeout time.Duration `long:"timeout"` + Context string `long:"context"` Terminal bool `short:"t"` NoTerminal bool `short:"T"` Interactive bool `short:"i"` @@ -56,6 +57,7 @@ var execDescs = map[string]string{ "gid": "Group ID to run command as", "group": "Group name to run command as (group's GID must match gid if both present)", "timeout": "Timeout after which to terminate command", + "context": "Inherit the context of the named service (overridden by -w, --env, --uid/user, --gid/group)", "t": "Allocate remote pseudo-terminal and connect stdout to it (default if stdout is a TTY)", "T": "Disable remote pseudo-terminal allocation", "i": "Interactive mode: connect stdin to the pseudo-terminal (default if stdin and stdout are TTYs)", @@ -143,21 +145,22 @@ func (cmd *cmdExec) Execute(args []string) error { } opts := &client.ExecOptions{ - Command: command, - Environment: env, - WorkingDir: cmd.WorkingDir, - Timeout: cmd.Timeout, - UserID: cmd.UserID, - User: cmd.User, - GroupID: cmd.GroupID, - Group: cmd.Group, - Terminal: terminal, - Interactive: interactive, - Width: width, - Height: height, - Stdin: Stdin, - Stdout: Stdout, - Stderr: Stderr, + Command: command, + ServiceContext: cmd.Context, + Environment: env, + WorkingDir: cmd.WorkingDir, + Timeout: cmd.Timeout, + UserID: cmd.UserID, + User: cmd.User, + GroupID: cmd.GroupID, + Group: cmd.Group, + Terminal: terminal, + Interactive: interactive, + Width: width, + Height: height, + Stdin: Stdin, + Stdout: Stdout, + Stderr: Stderr, } // If stdout and stderr both refer to the same file or device (e.g., diff --git a/internals/daemon/api_exec.go b/internals/daemon/api_exec.go index cac58462..eab7bd43 100644 --- a/internals/daemon/api_exec.go +++ b/internals/daemon/api_exec.go @@ -24,22 +24,24 @@ import ( "github.com/canonical/pebble/internals/osutil" "github.com/canonical/pebble/internals/overlord/cmdstate" "github.com/canonical/pebble/internals/overlord/state" + "github.com/canonical/pebble/internals/plan" ) type execPayload struct { - Command []string `json:"command"` - Environment map[string]string `json:"environment"` - WorkingDir string `json:"working-dir"` - Timeout string `json:"timeout"` - UserID *int `json:"user-id"` - User string `json:"user"` - GroupID *int `json:"group-id"` - Group string `json:"group"` - Terminal bool `json:"terminal"` - Interactive bool `json:"interactive"` - SplitStderr bool `json:"split-stderr"` - Width int `json:"width"` - Height int `json:"height"` + Command []string `json:"command"` + ServiceContext string `json:"service-context"` + Environment map[string]string `json:"environment"` + WorkingDir string `json:"working-dir"` + Timeout string `json:"timeout"` + UserID *int `json:"user-id"` + User string `json:"user"` + GroupID *int `json:"group-id"` + Group string `json:"group"` + Terminal bool `json:"terminal"` + Interactive bool `json:"interactive"` + SplitStderr bool `json:"split-stderr"` + Width int `json:"width"` + Height int `json:"height"` } func v1PostExec(c *Command, req *http.Request, _ *userState) Response { @@ -80,8 +82,25 @@ func v1PostExec(c *Command, req *http.Request, _ *userState) Response { } } + p, err := c.d.overlord.ServiceManager().Plan() + if err != nil { + return statusBadRequest("%v", err) + } + overrides := plan.ContextOptions{ + Environment: payload.Environment, + UserID: payload.UserID, + User: payload.User, + GroupID: payload.GroupID, + Group: payload.Group, + WorkingDir: payload.WorkingDir, + } + merged, err := plan.MergeServiceContext(p, payload.ServiceContext, overrides) + if err != nil { + return statusBadRequest("%v", err) + } + // Convert User/UserID and Group/GroupID combinations into raw uid/gid. - uid, gid, err := osutil.NormalizeUidGid(payload.UserID, payload.GroupID, payload.User, payload.Group) + uid, gid, err := osutil.NormalizeUidGid(merged.UserID, merged.GroupID, merged.User, merged.Group) if err != nil { return statusBadRequest("%v", err) } @@ -92,8 +111,8 @@ func v1PostExec(c *Command, req *http.Request, _ *userState) Response { args := &cmdstate.ExecArgs{ Command: payload.Command, - Environment: payload.Environment, - WorkingDir: payload.WorkingDir, + Environment: merged.Environment, + WorkingDir: merged.WorkingDir, Timeout: timeout, UserID: uid, GroupID: gid, diff --git a/internals/daemon/api_exec_test.go b/internals/daemon/api_exec_test.go index 9ef9a6a7..db38ed72 100644 --- a/internals/daemon/api_exec_test.go +++ b/internals/daemon/api_exec_test.go @@ -31,6 +31,7 @@ import ( "github.com/canonical/pebble/client" "github.com/canonical/pebble/internals/logger" + "github.com/canonical/pebble/internals/plan" ) var _ = Suite(&execSuite{}) @@ -164,6 +165,54 @@ func (s *execSuite) TestTimeout(c *C) { c.Check(stderr, Equals, "") } +func (s *execSuite) TestContextNoOverrides(c *C) { + dir := c.MkDir() + err := s.daemon.overlord.ServiceManager().AppendLayer(&plan.Layer{ + Label: "layer1", + Services: map[string]*plan.Service{"svc1": { + Name: "svc1", + Override: "replace", + Command: "dummy", + Environment: map[string]string{"FOO": "foo", "BAR": "bar"}, + WorkingDir: dir, + }}, + }) + c.Assert(err, IsNil) + + stdout, stderr, err := s.exec(c, "", &client.ExecOptions{ + Command: []string{"/bin/sh", "-c", "echo FOO=$FOO BAR=$BAR; pwd"}, + ServiceContext: "svc1", + }) + c.Assert(err, IsNil) + c.Check(stdout, Equals, "FOO=foo BAR=bar\n"+dir+"\n") + c.Check(stderr, Equals, "") +} + +func (s *execSuite) TestContextOverrides(c *C) { + err := s.daemon.overlord.ServiceManager().AppendLayer(&plan.Layer{ + Label: "layer1", + Services: map[string]*plan.Service{"svc1": { + Name: "svc1", + Override: "replace", + Command: "dummy", + Environment: map[string]string{"FOO": "foo", "BAR": "bar"}, + WorkingDir: c.MkDir(), + }}, + }) + c.Assert(err, IsNil) + + overrideDir := c.MkDir() + stdout, stderr, err := s.exec(c, "", &client.ExecOptions{ + Command: []string{"/bin/sh", "-c", "echo FOO=$FOO BAR=$BAR; pwd"}, + ServiceContext: "svc1", + Environment: map[string]string{"FOO": "oof"}, + WorkingDir: overrideDir, + }) + c.Assert(err, IsNil) + c.Check(stdout, Equals, "FOO=oof BAR=bar\n"+overrideDir+"\n") + c.Check(stderr, Equals, "") +} + func (s *execSuite) TestCurrentUserGroup(c *C) { current, err := user.Current() c.Assert(err, IsNil) diff --git a/internals/overlord/checkstate/manager.go b/internals/overlord/checkstate/manager.go index 6cba21ca..3708559c 100644 --- a/internals/overlord/checkstate/manager.go +++ b/internals/overlord/checkstate/manager.go @@ -72,7 +72,7 @@ func (m *CheckManager) PlanChanged(p *plan.Plan) { ctx, cancel := context.WithCancel(context.Background()) check := &checkData{ config: config, - checker: newChecker(config), + checker: newChecker(config, p), ctx: ctx, cancel: cancel, action: m.callFailureHandlers, @@ -93,7 +93,7 @@ func (m *CheckManager) callFailureHandlers(name string) { } // newChecker creates a new checker of the configured type. -func newChecker(config *plan.Check) checker { +func newChecker(config *plan.Check, p *plan.Plan) checker { switch { case config.HTTP != nil: return &httpChecker{ @@ -110,15 +110,28 @@ func newChecker(config *plan.Check) checker { } case config.Exec != nil: + overrides := plan.ContextOptions{ + Environment: config.Exec.Environment, + UserID: config.Exec.UserID, + User: config.Exec.User, + GroupID: config.Exec.GroupID, + Group: config.Exec.Group, + WorkingDir: config.Exec.WorkingDir, + } + merged, err := plan.MergeServiceContext(p, config.Exec.ServiceContext, overrides) + if err != nil { + // Context service name has already been checked when plan was loaded. + panic("internal error: " + err.Error()) + } return &execChecker{ name: config.Name, command: config.Exec.Command, - environment: config.Exec.Environment, - userID: config.Exec.UserID, - user: config.Exec.User, - groupID: config.Exec.GroupID, - group: config.Exec.Group, - workingDir: config.Exec.WorkingDir, + environment: merged.Environment, + userID: merged.UserID, + user: merged.User, + groupID: merged.GroupID, + group: merged.Group, + workingDir: merged.WorkingDir, } default: diff --git a/internals/overlord/checkstate/manager_test.go b/internals/overlord/checkstate/manager_test.go index 12b418b1..8e5cf589 100644 --- a/internals/overlord/checkstate/manager_test.go +++ b/internals/overlord/checkstate/manager_test.go @@ -302,7 +302,7 @@ func (s *CheckersSuite) TestNewChecker(c *C) { URL: "https://example.com/foo", Headers: map[string]string{"k": "v"}, }, - }) + }, nil) http, ok := chk.(*httpChecker) c.Assert(ok, Equals, true) c.Check(http.name, Equals, "http") @@ -315,7 +315,7 @@ func (s *CheckersSuite) TestNewChecker(c *C) { Port: 80, Host: "localhost", }, - }) + }, nil) tcp, ok := chk.(*tcpChecker) c.Assert(ok, Equals, true) c.Check(tcp.name, Equals, "tcp") @@ -334,7 +334,7 @@ func (s *CheckersSuite) TestNewChecker(c *C) { Group: "group", WorkingDir: "/working/dir", }, - }) + }, nil) exec, ok := chk.(*execChecker) c.Assert(ok, Equals, true) c.Assert(exec.name, Equals, "exec") @@ -345,3 +345,70 @@ func (s *CheckersSuite) TestNewChecker(c *C) { c.Assert(exec.groupID, Equals, &groupID) c.Assert(exec.workingDir, Equals, "/working/dir") } + +func (s *CheckersSuite) TestExecContextNoOverride(c *C) { + svcUserID, svcGroupID := 10, 20 + chk := newChecker(&plan.Check{ + Name: "exec", + Exec: &plan.ExecCheck{ + Command: "sleep 1", + ServiceContext: "svc1", + }, + }, &plan.Plan{Services: map[string]*plan.Service{ + "svc1": { + Name: "svc1", + Environment: map[string]string{"k": "x", "a": "1"}, + UserID: &svcUserID, + User: "svcuser", + GroupID: &svcGroupID, + Group: "svcgroup", + WorkingDir: "/working/svc", + }, + }}) + exec, ok := chk.(*execChecker) + c.Assert(ok, Equals, true) + c.Check(exec.name, Equals, "exec") + c.Check(exec.command, Equals, "sleep 1") + c.Check(exec.environment, DeepEquals, map[string]string{"k": "x", "a": "1"}) + c.Check(exec.userID, DeepEquals, &svcUserID) + c.Check(exec.user, Equals, "svcuser") + c.Check(exec.groupID, DeepEquals, &svcGroupID) + c.Check(exec.workingDir, Equals, "/working/svc") +} + +func (s *CheckersSuite) TestExecContextOverride(c *C) { + userID, groupID := 100, 200 + svcUserID, svcGroupID := 10, 20 + chk := newChecker(&plan.Check{ + Name: "exec", + Exec: &plan.ExecCheck{ + Command: "sleep 1", + ServiceContext: "svc1", + Environment: map[string]string{"k": "v"}, + UserID: &userID, + User: "user", + GroupID: &groupID, + Group: "group", + WorkingDir: "/working/dir", + }, + }, &plan.Plan{Services: map[string]*plan.Service{ + "svc1": { + Name: "svc1", + Environment: map[string]string{"k": "x", "a": "1"}, + UserID: &svcUserID, + User: "svcuser", + GroupID: &svcGroupID, + Group: "svcgroup", + WorkingDir: "/working/svc", + }, + }}) + exec, ok := chk.(*execChecker) + c.Assert(ok, Equals, true) + c.Check(exec.name, Equals, "exec") + c.Check(exec.command, Equals, "sleep 1") + c.Check(exec.environment, DeepEquals, map[string]string{"k": "v", "a": "1"}) + c.Check(exec.userID, DeepEquals, &userID) + c.Check(exec.user, Equals, "user") + c.Check(exec.groupID, DeepEquals, &groupID) + c.Check(exec.workingDir, Equals, "/working/dir") +} diff --git a/internals/plan/plan.go b/internals/plan/plan.go index 22d403c9..eccf42a9 100644 --- a/internals/plan/plan.go +++ b/internals/plan/plan.go @@ -105,12 +105,10 @@ func (s *Service) Copy() *Service { } } if s.UserID != nil { - userID := *s.UserID - copied.UserID = &userID + copied.UserID = copyIntPtr(s.UserID) } if s.GroupID != nil { - groupID := *s.GroupID - copied.GroupID = &groupID + copied.GroupID = copyIntPtr(s.GroupID) } if s.OnCheckFailure != nil { copied.OnCheckFailure = make(map[string]ServiceAction) @@ -139,15 +137,13 @@ func (s *Service) Merge(other *Service) { s.KillDelay = other.KillDelay } if other.UserID != nil { - userID := *other.UserID - s.UserID = &userID + s.UserID = copyIntPtr(other.UserID) } if other.User != "" { s.User = other.User } if other.GroupID != nil { - groupID := *other.GroupID - s.GroupID = &groupID + s.GroupID = copyIntPtr(other.GroupID) } if other.Group != "" { s.Group = other.Group @@ -431,13 +427,14 @@ func (c *TCPCheck) Merge(other *TCPCheck) { // ExecCheck holds the configuration for an exec health check. type ExecCheck struct { - Command string `yaml:"command,omitempty"` - Environment map[string]string `yaml:"environment,omitempty"` - UserID *int `yaml:"user-id,omitempty"` - User string `yaml:"user,omitempty"` - GroupID *int `yaml:"group-id,omitempty"` - Group string `yaml:"group,omitempty"` - WorkingDir string `yaml:"working-dir,omitempty"` + Command string `yaml:"command,omitempty"` + ServiceContext string `yaml:"service-context,omitempty"` + Environment map[string]string `yaml:"environment,omitempty"` + UserID *int `yaml:"user-id,omitempty"` + User string `yaml:"user,omitempty"` + GroupID *int `yaml:"group-id,omitempty"` + Group string `yaml:"group,omitempty"` + WorkingDir string `yaml:"working-dir,omitempty"` } // Copy returns a deep copy of the exec check configuration. @@ -450,12 +447,10 @@ func (c *ExecCheck) Copy() *ExecCheck { } } if c.UserID != nil { - userID := *c.UserID - copied.UserID = &userID + copied.UserID = copyIntPtr(c.UserID) } if c.GroupID != nil { - groupID := *c.GroupID - copied.GroupID = &groupID + copied.GroupID = copyIntPtr(c.GroupID) } return &copied } @@ -465,6 +460,9 @@ func (c *ExecCheck) Merge(other *ExecCheck) { if other.Command != "" { c.Command = other.Command } + if other.ServiceContext != "" { + c.ServiceContext = other.ServiceContext + } for k, v := range other.Environment { if c.Environment == nil { c.Environment = make(map[string]string) @@ -472,15 +470,13 @@ func (c *ExecCheck) Merge(other *ExecCheck) { c.Environment[k] = v } if other.UserID != nil { - userID := *other.UserID - c.UserID = &userID + c.UserID = copyIntPtr(other.UserID) } if other.User != "" { c.User = other.User } if other.GroupID != nil { - groupID := *other.GroupID - c.GroupID = &groupID + c.GroupID = copyIntPtr(other.GroupID) } if other.Group != "" { c.Group = other.Group @@ -733,6 +729,13 @@ func CombineLayers(layers ...*Layer) (*Layer, error) { Message: fmt.Sprintf("plan check %q command invalid: %v", name, err), } } + _, contextExists := combined.Services[check.Exec.ServiceContext] + if check.Exec.ServiceContext != "" && !contextExists { + return nil, &FormatError{ + Message: fmt.Sprintf("plan check %q service context specifies non-existent service %q", + name, check.Exec.ServiceContext), + } + } _, _, err = osutil.NormalizeUidGid(check.Exec.UserID, check.Exec.GroupID, check.Exec.User, check.Exec.Group) if err != nil { return nil, &FormatError{ @@ -1075,3 +1078,79 @@ func ReadDir(dir string) (*Plan, error) { } return plan, err } + +// MergeServiceContext merges the overrides on top of the service context +// specified by serviceName, returning a new ContextOptions value. If +// serviceName is "" (context not specified), return overrides directly. +func MergeServiceContext(p *Plan, serviceName string, overrides ContextOptions) (ContextOptions, error) { + if serviceName == "" { + return overrides, nil + } + var service *Service + for _, s := range p.Services { + if s.Name == serviceName { + service = s + break + } + } + if service == nil { + return ContextOptions{}, fmt.Errorf("context service %q not found", serviceName) + } + + // Start with the config values from the context service. + merged := ContextOptions{ + Environment: make(map[string]string), + } + for k, v := range service.Environment { + merged.Environment[k] = v + } + if service.UserID != nil { + merged.UserID = copyIntPtr(service.UserID) + } + merged.User = service.User + if service.GroupID != nil { + merged.GroupID = copyIntPtr(service.GroupID) + } + merged.Group = service.Group + merged.WorkingDir = service.WorkingDir + + // Merge in fields from the overrides, if set. + for k, v := range overrides.Environment { + merged.Environment[k] = v + } + if overrides.UserID != nil { + merged.UserID = copyIntPtr(overrides.UserID) + } + if overrides.User != "" { + merged.User = overrides.User + } + if overrides.GroupID != nil { + merged.GroupID = copyIntPtr(overrides.GroupID) + } + if overrides.Group != "" { + merged.Group = overrides.Group + } + if overrides.WorkingDir != "" { + merged.WorkingDir = overrides.WorkingDir + } + + return merged, nil +} + +// ContextOptions holds service context config fields. +type ContextOptions struct { + Environment map[string]string + UserID *int + User string + GroupID *int + Group string + WorkingDir string +} + +func copyIntPtr(p *int) *int { + if p == nil { + return nil + } + copied := *p + return &copied +} diff --git a/internals/plan/plan_test.go b/internals/plan/plan_test.go index 1883ece4..c9f999d0 100644 --- a/internals/plan/plan_test.go +++ b/internals/plan/plan_test.go @@ -823,6 +823,17 @@ var planTests = []planTest{{ exec: command: foo ' `}, +}, { + summary: `Invalid exec check service context`, + error: `plan check "chk1" service context specifies non-existent service "nosvc"`, + input: []string{` + checks: + chk1: + override: replace + exec: + command: foo + service-context: nosvc + `}, }, { summary: "Simple layer with log targets", input: []string{` @@ -1545,3 +1556,78 @@ func (s *S) TestLogsTo(c *C) { } } } + +func (s *S) TestMergeServiceContextNoContext(c *C) { + userID, groupID := 10, 20 + overrides := plan.ContextOptions{ + Environment: map[string]string{"x": "y"}, + UserID: &userID, + User: "usr", + GroupID: &groupID, + Group: "grp", + WorkingDir: "/working/dir", + } + merged, err := plan.MergeServiceContext(nil, "", overrides) + c.Assert(err, IsNil) + c.Check(merged, DeepEquals, overrides) +} + +func (s *S) TestMergeServiceContextBadService(c *C) { + _, err := plan.MergeServiceContext(&plan.Plan{}, "nosvc", plan.ContextOptions{}) + c.Assert(err, ErrorMatches, `context service "nosvc" not found`) +} + +func (s *S) TestMergeServiceContextNoOverrides(c *C) { + userID, groupID := 11, 22 + p := &plan.Plan{Services: map[string]*plan.Service{"svc1": { + Name: "svc1", + Environment: map[string]string{"x": "y"}, + UserID: &userID, + User: "svcuser", + GroupID: &groupID, + Group: "svcgroup", + WorkingDir: "/working/svc", + }}} + merged, err := plan.MergeServiceContext(p, "svc1", plan.ContextOptions{}) + c.Assert(err, IsNil) + c.Check(merged, DeepEquals, plan.ContextOptions{ + Environment: map[string]string{"x": "y"}, + UserID: &userID, + User: "svcuser", + GroupID: &groupID, + Group: "svcgroup", + WorkingDir: "/working/svc", + }) +} + +func (s *S) TestMergeServiceContextOverrides(c *C) { + svcUserID, svcGroupID := 10, 20 + p := &plan.Plan{Services: map[string]*plan.Service{"svc1": { + Name: "svc1", + Environment: map[string]string{"x": "y", "w": "z"}, + UserID: &svcUserID, + User: "svcuser", + GroupID: &svcGroupID, + Group: "svcgroup", + WorkingDir: "/working/svc", + }}} + userID, groupID := 11, 22 + overrides := plan.ContextOptions{ + Environment: map[string]string{"x": "a"}, + UserID: &userID, + User: "usr", + GroupID: &groupID, + Group: "grp", + WorkingDir: "/working/dir", + } + merged, err := plan.MergeServiceContext(p, "svc1", overrides) + c.Assert(err, IsNil) + c.Check(merged, DeepEquals, plan.ContextOptions{ + Environment: map[string]string{"x": "a", "w": "z"}, + UserID: &userID, + User: "usr", + GroupID: &groupID, + Group: "grp", + WorkingDir: "/working/dir", + }) +}