Skip to content

Commit

Permalink
Add "service context" support for exec and health checks (#246)
Browse files Browse the repository at this point in the history
  • Loading branch information
benhoyt authored Jul 11, 2023
1 parent 8e96c74 commit 00bcd1f
Show file tree
Hide file tree
Showing 9 changed files with 426 additions and 95 deletions.
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -688,6 +688,13 @@ checks:
# directly, not interpreted by a shell.
command: <commmand>
# (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: <service-name>
# (Optional) A list of key/value pairs defining environment
# variables that should be set when running the command.
environment:
Expand Down
68 changes: 38 additions & 30 deletions client/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
33 changes: 18 additions & 15 deletions internals/cli/cmd_exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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)",
Expand Down Expand Up @@ -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.,
Expand Down
51 changes: 35 additions & 16 deletions internals/daemon/api_exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand All @@ -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,
Expand Down
49 changes: 49 additions & 0 deletions internals/daemon/api_exec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{})
Expand Down Expand Up @@ -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)
Expand Down
29 changes: 21 additions & 8 deletions internals/overlord/checkstate/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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{
Expand All @@ -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:
Expand Down
Loading

0 comments on commit 00bcd1f

Please sign in to comment.