diff --git a/README.md b/README.md index ecd2690c9..87b2dd46f 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,28 @@ are marked as `startup: enabled` (if you don't want that, use `--hold`). Then other Pebble commands may be used to interact with the running daemon, for example, in another terminal window. +To provide additional arguments to a service, use `--args ...`. +If the `command` field in the service's plan has a `[ ]` +list, the `--args` arguments will replace the defaults. If not, they will be +appended to the command. + +To indicate the end of an `--args` list, use a `;` (semicolon) terminator, +which must be backslash-escaped if used in the shell. The terminator +may be omitted if there are no other Pebble options that follow. + +For example: + +``` +# Start the daemon and pass additional arguments to "myservice". +$ pebble run --args myservice --verbose --foo "multi str arg" + +# Use args terminator to pass --hold to Pebble at the end of the line. +$ pebble run --args myservice --verbose \; --hold + +# Start the daemon and pass arguments to multiple services. +$ pebble run --args myservice1 --arg1 \; --args myservice2 --arg2 +``` + To override the default configuration directory, set the `PEBBLE` environment variable when running: ``` @@ -423,10 +445,10 @@ services: # override the existing service spec in the plan with the same name. override: merge | replace - # (Required in combined layer) The command to run the service. The - # command is executed directly, not interpreted by a shell. - # - # Example: /usr/bin/somecommand -b -t 30 + # (Required in combined layer) The command to run the service. It is executed + # directly, not interpreted by a shell, and may be optionally suffixed by default + # arguments within "[" and "]" which may be overriden via --args. + # Example: /usr/bin/somedaemon --db=/db/path [ --port 8080 ] command: # (Optional) A short summary of the service. diff --git a/cmd/pebble/cmd_run.go b/cmd/pebble/cmd_run.go index 0a5571521..4b56113c5 100644 --- a/cmd/pebble/cmd_run.go +++ b/cmd/pebble/cmd_run.go @@ -34,13 +34,21 @@ import ( var shortRunHelp = "Run the pebble environment" var longRunHelp = ` The run command starts pebble and runs the configured environment. + +Additional arguments may be provided to the service command with the --args option, which +must be terminated with ";" unless there are no further Pebble options. These arguments +are appended to the end of the service command, and replace any default arguments defined +in the service plan. For example: + + $ pebble run --args myservice --port 8080 \; --hold ` type sharedRunEnterOpts struct { - CreateDirs bool `long:"create-dirs"` - Hold bool `long:"hold"` - HTTP string `long:"http"` - Verbose bool `short:"v" long:"verbose"` + CreateDirs bool `long:"create-dirs"` + Hold bool `long:"hold"` + HTTP string `long:"http"` + Verbose bool `short:"v" long:"verbose"` + Args [][]string `long:"args" terminator:";"` } var sharedRunEnterOptsHelp = map[string]string{ @@ -48,6 +56,7 @@ var sharedRunEnterOptsHelp = map[string]string{ "hold": "Do not start default services automatically", "http": `Start HTTP API listening on this address (e.g., ":4000")`, "verbose": "Log all output from services to stdout", + "args": `Provide additional arguments to a service`, } type cmdRun struct { @@ -149,6 +158,16 @@ func runDaemon(rcmd *cmdRun, ch chan os.Signal, ready chan<- func()) error { return err } + if rcmd.Args != nil { + mappedArgs, err := convertArgs(rcmd.Args) + if err != nil { + return err + } + if err := d.SetServiceArgs(mappedArgs); err != nil { + return err + } + } + // Run sanity check now, if anything goes wrong with the // check we go into "degraded" mode where we always report // the given error to any client. @@ -217,3 +236,22 @@ out: return d.Stop(ch) } + +// convert args from [][]string type to map[string][]string +// and check for empty or duplicated --args usage +func convertArgs(args [][]string) (map[string][]string, error) { + mappedArgs := make(map[string][]string) + + for _, arg := range args { + if len(arg) < 1 { + return nil, fmt.Errorf("--args requires a service name") + } + name := arg[0] + if _, ok := mappedArgs[name]; ok { + return nil, fmt.Errorf("--args provided more than once for %q service", name) + } + mappedArgs[name] = arg[1:] + } + + return mappedArgs, nil +} diff --git a/go.mod b/go.mod index 7ea999110..9782aed6c 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.14 require ( github.com/canonical/go-flags v0.0.0-20230403090104-105d09a091b8 - github.com/canonical/x-go v0.0.0-20230113154138-0ccdb0b57a43 + github.com/canonical/x-go v0.0.0-20230522092633-7947a7587f5b github.com/gorilla/mux v1.8.0 github.com/gorilla/websocket v1.4.2 github.com/pkg/term v1.1.0 diff --git a/go.sum b/go.sum index 5ab5b5583..f335a74dc 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ github.com/canonical/go-flags v0.0.0-20230403090104-105d09a091b8 h1:zGaJEJI9qPVyM+QKFJagiyrM91Ke5S9htoL1D470g6E= github.com/canonical/go-flags v0.0.0-20230403090104-105d09a091b8/go.mod h1:ZZFeR9K9iGgpwOaLYF9PdT44/+lfSJ9sQz3B+SsGsYU= -github.com/canonical/x-go v0.0.0-20230113154138-0ccdb0b57a43 h1:bey1JgA3D2EBabr2a7kWKj+JlEPxX1akv8rcRromotA= -github.com/canonical/x-go v0.0.0-20230113154138-0ccdb0b57a43/go.mod h1:A0/Jvt7qKuCDss37TYRNXSscVyS+tLWM5kBYipQOpWQ= +github.com/canonical/x-go v0.0.0-20230522092633-7947a7587f5b h1:Da2fardddn+JDlVEYtrzBLTtyzoyU3nIS0Cf0GvjmwU= +github.com/canonical/x-go v0.0.0-20230522092633-7947a7587f5b/go.mod h1:upTK9n6rlqITN9rCN69hdreI37dRDFUk2thlGGD5Cg8= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= @@ -31,7 +31,7 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/tomb.v2 v2.0.0-20161208151619-d5d1b5820637 h1:yiW+nvdHb9LVqSHQBXfZCieqV4fzYhNBql77zY0ykqs= gopkg.in/tomb.v2 v2.0.0-20161208151619-d5d1b5820637/go.mod h1:BHsqpu/nsuzkT5BpiH1EMZPLyqSMM8JbIavyFACoFNk= -gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= -gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 0210d27c4..c755f19de 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -776,6 +776,12 @@ func (d *Daemon) RebootIsMissing(st *state.State) error { return errExpectedReboot } +// SetServiceArgs sets the provided service arguments to their respective services, +// by passing the arguments to the service manager responsible under daemon overlord. +func (d *Daemon) SetServiceArgs(serviceArgs map[string][]string) error { + return d.overlord.ServiceManager().SetServiceArgs(serviceArgs) +} + func New(opts *Options) (*Daemon, error) { d := &Daemon{ pebbleDir: opts.Dir, diff --git a/internal/overlord/servstate/handlers.go b/internal/overlord/servstate/handlers.go index bc1ff6ac3..d0b7a66f3 100644 --- a/internal/overlord/servstate/handlers.go +++ b/internal/overlord/servstate/handlers.go @@ -10,7 +10,6 @@ import ( "syscall" "time" - "github.com/canonical/x-go/strutil/shlex" "golang.org/x/sys/unix" "gopkg.in/tomb.v2" @@ -334,12 +333,11 @@ func logError(err error) { // command. It assumes the caller has ensures the service is in a valid state, // and it sets s.cmd and other relevant fields. func (s *serviceData) startInternal() error { - args, err := shlex.Split(s.config.Command) + base, extra, err := s.config.ParseCommand() if err != nil { - // Shouldn't happen as it should have failed on parsing, but - // it does not hurt to double check and report. - return fmt.Errorf("cannot parse service command: %s", err) + return err } + args := append(base, extra...) s.cmd = exec.Command(args[0], args[1:]...) s.cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} diff --git a/internal/overlord/servstate/manager.go b/internal/overlord/servstate/manager.go index 159ca1f19..691f0a546 100644 --- a/internal/overlord/servstate/manager.go +++ b/internal/overlord/servstate/manager.go @@ -513,6 +513,41 @@ func (m *ServiceManager) CheckFailed(name string) { } } +// SetServiceArgs sets the service arguments provided by "pebble run --args" +// to their respective services. It adds a new layer in the plan, the layer +// consisting of services with commands having their arguments changed. +func (m *ServiceManager) SetServiceArgs(serviceArgs map[string][]string) error { + releasePlan, err := m.acquirePlan() + if err != nil { + return err + } + defer releasePlan() + + newLayer := &plan.Layer{ + // TODO: Consider making any labels starting with + // the "pebble-" prefix reserved. + Label: "pebble-service-args", + Services: make(map[string]*plan.Service), + } + + for name, args := range serviceArgs { + service, ok := m.plan.Services[name] + if !ok { + return fmt.Errorf("service %q not found in plan", name) + } + base, _, err := service.ParseCommand() + if err != nil { + return err + } + newLayer.Services[name] = &plan.Service{ + Override: plan.MergeOverride, + Command: plan.CommandString(base, args), + } + } + + return m.appendLayer(newLayer) +} + // servicesToStop returns a slice of service names to stop, in dependency order. func servicesToStop(m *ServiceManager) ([]string, error) { releasePlan, err := m.acquirePlan() diff --git a/internal/overlord/servstate/manager_test.go b/internal/overlord/servstate/manager_test.go index 570bdb54c..6a07c1560 100644 --- a/internal/overlord/servstate/manager_test.go +++ b/internal/overlord/servstate/manager_test.go @@ -851,6 +851,54 @@ services: s.planLayersHasLen(c, manager, 3) } +func (s *S) TestSetServiceArgs(c *C) { + dir := c.MkDir() + os.Mkdir(filepath.Join(dir, "layers"), 0755) + runner := state.NewTaskRunner(s.st) + manager, err := servstate.NewManager(s.st, runner, dir, nil, nil, fakeLogManager{}) + c.Assert(err, IsNil) + defer manager.Stop() + + // Append a layer with a few services having default args. + layer := parseLayer(c, 0, "base-layer", ` +services: + svc1: + override: replace + command: foo [ --bar ] + svc2: + override: replace + command: foo + svc3: + override: replace + command: foo +`) + err = manager.AppendLayer(layer) + c.Assert(err, IsNil) + c.Assert(layer.Order, Equals, 1) + s.planLayersHasLen(c, manager, 1) + + // Set arguments to services. + serviceArgs := map[string][]string{ + "svc1": {"-abc", "--xyz"}, + "svc2": {"--bar"}, + } + err = manager.SetServiceArgs(serviceArgs) + c.Assert(err, IsNil) + c.Assert(planYAML(c, manager), Equals, ` +services: + svc1: + override: replace + command: foo [ -abc --xyz ] + svc2: + override: replace + command: foo [ --bar ] + svc3: + override: replace + command: foo +`[1:]) + s.planLayersHasLen(c, manager, 2) +} + func (s *S) TestServices(c *C) { started := time.Now() services, err := s.manager.Services(nil) diff --git a/internal/plan/plan.go b/internal/plan/plan.go index 9c640e431..e12389c81 100644 --- a/internal/plan/plan.go +++ b/internal/plan/plan.go @@ -211,6 +211,65 @@ func (s *Service) Equal(other *Service) bool { return reflect.DeepEqual(s, other) } +// ParseCommand returns a service command as two stream of strings. +// The base command is returned as a stream and the default arguments +// in [ ... ] group is returned as another stream. +func (s *Service) ParseCommand() (base, extra []string, err error) { + defer func() { + if err != nil { + err = fmt.Errorf("cannot parse service %q command: %w", s.Name, err) + } + }() + + args, err := shlex.Split(s.Command) + if err != nil { + return nil, nil, err + } + + var inBrackets, gotBrackets bool + + for idx, arg := range args { + if inBrackets { + if arg == "[" { + return nil, nil, fmt.Errorf("cannot nest [ ... ] groups") + } + if arg == "]" { + inBrackets = false + continue + } + extra = append(extra, arg) + continue + } + if gotBrackets { + return nil, nil, fmt.Errorf("cannot have any arguments after [ ... ] group") + } + if arg == "[" { + if idx == 0 { + return nil, nil, fmt.Errorf("cannot start command with [ ... ] group") + } + inBrackets = true + gotBrackets = true + continue + } + if arg == "]" { + return nil, nil, fmt.Errorf("cannot have ] outside of [ ... ] group") + } + base = append(base, arg) + } + + return base, extra, nil +} + +// CommandString returns a service command as a string after +// appending the arguments in "extra" to the command in "base" +func CommandString(base, extra []string) string { + output := shlex.Join(base) + if len(extra) > 0 { + output = output + " [ " + shlex.Join(extra) + " ]" + } + return output +} + // LogsTo returns true if the logs from s should be forwarded to target t. // This happens if: // - t.Selection is "opt-out" or empty, and s.LogTargets is empty; or @@ -603,7 +662,7 @@ func CombineLayers(layers ...*Layer) (*Layer, error) { Message: fmt.Sprintf(`plan must define "command" for service %q`, name), } } - _, err := shlex.Split(service.Command) + _, _, err := service.ParseCommand() if err != nil { return nil, &FormatError{ Message: fmt.Sprintf("plan service %q command invalid: %v", name, err), diff --git a/internal/plan/plan_test.go b/internal/plan/plan_test.go index a97c047eb..f44dc0035 100644 --- a/internal/plan/plan_test.go +++ b/internal/plan/plan_test.go @@ -475,13 +475,61 @@ var planTests = []planTest{{ `}, }, { summary: `Invalid service command`, - error: `plan service "svc1" command invalid: EOF found when expecting closing quote`, + error: `plan service "svc1" command invalid: cannot parse service "svc1" command: EOF found when expecting closing quote`, input: []string{` services: svc1: override: replace command: foo ' `}, +}, { + summary: `Optional/overridable arguments in service command`, + input: []string{` + services: + "svc1": + override: replace + command: cmd -v [ --foo bar -e "x [ y ] z" ] + `}, + layers: []*plan.Layer{{ + Order: 0, + Label: "layer-0", + Services: map[string]*plan.Service{ + "svc1": { + Name: "svc1", + Override: "replace", + Command: `cmd -v [ --foo bar -e "x [ y ] z" ]`, + }, + }, + Checks: map[string]*plan.Check{}, + LogTargets: map[string]*plan.LogTarget{}, + }}, +}, { + summary: `Invalid service command: cannot have any arguments after [ ... ] group`, + error: `plan service "svc1" command invalid: cannot parse service "svc1" command: cannot have any arguments after \[ ... \] group`, + input: []string{` + services: + "svc1": + override: replace + command: cmd -v [ --foo ] bar + `}, +}, { + summary: `Invalid service command: cannot have ] outside of [ ... ] group`, + error: `plan service "svc1" command invalid: cannot parse service "svc1" command: cannot have \] outside of \[ ... \] group`, + input: []string{` + services: + "svc1": + override: replace + command: cmd -v ] foo + `}, +}, { + summary: `Invalid service command: cannot nest [ ... ] groups`, + error: `plan service "svc1" command invalid: cannot parse service "svc1" command: cannot nest \[ ... \] groups`, + input: []string{` + services: + "svc1": + override: replace + command: cmd -v [ foo [ --bar ] ] + `}, }, { summary: "Checks fields parse correctly and defaults are correct", input: []string{` @@ -1323,6 +1371,90 @@ func (s *S) TestMarshalLayer(c *C) { c.Assert(string(out), Equals, string(layerBytes)) } +var cmdTests = []struct { + summary string + command string + cmdArgs []string + expectedBase []string + expectedExtra []string + expectedNewCommand string + error string +}{{ + summary: "No default arguments, no additional cmdArgs", + command: "cmd --foo bar", + expectedBase: []string{"cmd", "--foo", "bar"}, + expectedNewCommand: "cmd --foo bar", +}, { + summary: "No default arguments, add cmdArgs only", + command: "cmd --foo bar", + cmdArgs: []string{"-v", "--opt"}, + expectedBase: []string{"cmd", "--foo", "bar"}, + expectedNewCommand: "cmd --foo bar [ -v --opt ]", +}, { + summary: "Override default arguments with empty cmdArgs", + command: "cmd [ --foo bar ]", + expectedBase: []string{"cmd"}, + expectedExtra: []string{"--foo", "bar"}, + expectedNewCommand: "cmd", +}, { + summary: "Override default arguments with cmdArgs", + command: "cmd [ --foo bar ]", + cmdArgs: []string{"--bar", "foo"}, + expectedBase: []string{"cmd"}, + expectedExtra: []string{"--foo", "bar"}, + expectedNewCommand: "cmd [ --bar foo ]", +}, { + summary: "Empty [ ... ], no cmdArgs", + command: "cmd --foo bar [ ]", + expectedBase: []string{"cmd", "--foo", "bar"}, + expectedNewCommand: "cmd --foo bar", +}, { + summary: "Empty [ ... ], override with cmdArgs", + command: "cmd --foo bar [ ]", + cmdArgs: []string{"-v", "--opt"}, + expectedBase: []string{"cmd", "--foo", "bar"}, + expectedNewCommand: "cmd --foo bar [ -v --opt ]", +}, { + summary: "[ ... ] should be a suffix", + command: "cmd [ --foo ] --bar", + error: `cannot parse service "svc" command: cannot have any arguments after \[ ... \] group`, +}, { + summary: "[ ... ] should not be prefix", + command: "[ cmd --foo ]", + error: `cannot parse service "svc" command: cannot start command with \[ ... \] group`, +}} + +func (s *S) TestParseCommand(c *C) { + for _, test := range cmdTests { + service := plan.Service{Name: "svc", Command: test.command} + + // parse base and the default arguments in [ ... ] + base, extra, err := service.ParseCommand() + if err != nil || test.error != "" { + if test.error != "" { + c.Assert(err, ErrorMatches, test.error) + } else { + c.Assert(err, IsNil) + } + continue + } + c.Assert(base, DeepEquals, test.expectedBase) + c.Assert(extra, DeepEquals, test.expectedExtra) + + // add cmdArgs to base and produce a new command string + newCommand := plan.CommandString(base, test.cmdArgs) + c.Assert(newCommand, DeepEquals, test.expectedNewCommand) + + // parse the new command string again and check if base is + // the same and cmdArgs is the new default arguments in [ ... ] + service.Command = newCommand + base, extra, err = service.ParseCommand() + c.Assert(err, IsNil) + c.Assert(base, DeepEquals, test.expectedBase) + c.Assert(extra, DeepEquals, test.cmdArgs) + } +} + func (s *S) TestSelectTargets(c *C) { logTargets := []*plan.LogTarget{ {Name: "unset", Selection: plan.UnsetSelection},