Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

many: add support for user daemons in "snapctl services" #13806

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions client/clientutil/service_scope.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import (
"fmt"

"github.com/snapcore/snapd/client"
"github.com/snapcore/snapd/i18n"
"github.com/snapcore/snapd/snap"
"github.com/snapcore/snapd/strutil"
)

Expand Down Expand Up @@ -74,3 +76,24 @@ func (us *ServiceScopeOptions) Users() client.UserSelector {
Names: strutil.CommaSeparatedList(us.Usernames),
}
}

// FmtServiceStatus formats a given service application into the following string
// <snap.app> <enabled> <active> <notes>
// To keep output persistent between snapctl and snap cmd.
func FmtServiceStatus(svc *client.AppInfo, isGlobal bool) string {
startup := i18n.G("disabled")
if svc.Enabled {
startup = i18n.G("enabled")
}

// When requesting global service status, we don't have any active
// information available for user daemons.
current := i18n.G("inactive")
if svc.DaemonScope == snap.UserDaemon && isGlobal {
current = "-"
} else if svc.Active {
current = i18n.G("active")
}

return fmt.Sprintf("%s.%s\t%s\t%s\t%s", svc.Snap, svc.Name, startup, current, ClientAppInfoNotes(svc))
}
25 changes: 3 additions & 22 deletions cmd/snap/cmd_services.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ import (
"github.com/snapcore/snapd/client"
"github.com/snapcore/snapd/client/clientutil"
"github.com/snapcore/snapd/i18n"
"github.com/snapcore/snapd/snap"
)

type svcStatus struct {
Expand Down Expand Up @@ -96,9 +95,9 @@ func init() {
}}
addCommand("services", shortServicesHelp, longServicesHelp, func() flags.Commander { return &svcStatus{} }, map[string]string{
// TRANSLATORS: This should not start with a lowercase letter.
"global": i18n.G("Show the global enable status for user-services instead of the status for the current user."),
"global": i18n.G("Show the global enable status for user services instead of the status for the current user."),
// TRANSLATORS: This should not start with a lowercase letter.
"user": i18n.G("Show the current status of the user-services instead of the global enable status."),
"user": i18n.G("Show the current status of the user services instead of the global enable status."),
}, argdescs)
addCommand("logs", shortLogsHelp, longLogsHelp, func() flags.Commander { return &svcLogs{} },
timeDescs.also(map[string]string{
Expand Down Expand Up @@ -133,24 +132,6 @@ func svcNames(s []serviceName) []string {
return svcNames
}

func fmtServiceStatus(svc *client.AppInfo, isGlobal bool) string {
startup := i18n.G("disabled")
if svc.Enabled {
startup = i18n.G("enabled")
}

// When requesting global service status, we don't have any active
// information available for user daemons.
current := i18n.G("inactive")
if svc.DaemonScope == snap.UserDaemon && isGlobal {
current = "-"
} else if svc.Active {
current = i18n.G("active")
}

return fmt.Sprintf("%s.%s\t%s\t%s\t%s", svc.Snap, svc.Name, startup, current, clientutil.ClientAppInfoNotes(svc))
}

func (s *svcStatus) showGlobalEnablement(u *user.User) bool {
if u.Uid == "0" && !s.User {
return true
Expand Down Expand Up @@ -201,7 +182,7 @@ func (s *svcStatus) Execute(args []string) error {

fmt.Fprintln(w, i18n.G("Service\tStartup\tCurrent\tNotes"))
for _, svc := range services {
fmt.Fprintln(w, fmtServiceStatus(svc, isGlobal))
fmt.Fprintln(w, clientutil.FmtServiceStatus(svc, isGlobal))
}
return nil
}
Expand Down
8 changes: 8 additions & 0 deletions overlord/hookstate/ctlcmd/ctlcmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"bytes"
"fmt"
"io"
"strconv"

"github.com/jessevdk/go-flags"

Expand All @@ -45,12 +46,17 @@ type baseCommand struct {
stderr io.Writer
c *hookstate.Context
name string
uid string
}

func (c *baseCommand) setName(name string) {
c.name = name
}

func (c *baseCommand) setUid(uid uint32) {
c.uid = strconv.FormatUint(uint64(uid), 10)
}

func (c *baseCommand) setStdout(w io.Writer) {
c.stdout = w
}
Expand Down Expand Up @@ -88,6 +94,7 @@ func (c *baseCommand) ensureContext() (context *hookstate.Context, err error) {

type command interface {
setName(name string)
setUid(uid uint32)

setStdout(w io.Writer)
setStderr(w io.Writer)
Expand Down Expand Up @@ -157,6 +164,7 @@ func Run(context *hookstate.Context, args []string, uid uint32) (stdout, stderr
for name, cmdInfo := range commands {
cmd := cmdInfo.generator()
cmd.setName(name)
cmd.setUid(uid)
cmd.setStdout(&stdoutBuffer)
cmd.setStderr(&stderrBuffer)
cmd.setContext(context)
Expand Down
8 changes: 8 additions & 0 deletions overlord/hookstate/ctlcmd/export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@
package ctlcmd

import (
"context"
"fmt"
"os/user"

"github.com/snapcore/snapd/asserts"
"github.com/snapcore/snapd/client/clientutil"
"github.com/snapcore/snapd/overlord/devicestate"
"github.com/snapcore/snapd/overlord/hookstate"
"github.com/snapcore/snapd/overlord/servicestate"
Expand Down Expand Up @@ -156,3 +158,9 @@ func MockAutoRefreshForGatingSnap(f func(st *state.State, gatingSnap string) err
autoRefreshForGatingSnap = old
}
}

func MockNewStatusDecorator(f func(ctx context.Context, isGlobal bool, uid string) clientutil.StatusDecorator) (restore func()) {
restore = testutil.Backup(&newStatusDecorator)
newStatusDecorator = f
return restore
}
57 changes: 40 additions & 17 deletions overlord/hookstate/ctlcmd/services.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
package ctlcmd

import (
"context"
"fmt"
"sort"
"text/tabwriter"
Expand Down Expand Up @@ -47,6 +48,8 @@ type servicesCommand struct {
Positional struct {
ServiceNames []string `positional-arg-name:"<service>"`
} `positional-args:"yes"`
Global bool `long:"global" short:"g" description:"Show the global enable status for user services instead of the status for the current user"`
User bool `long:"user" short:"u" description:"Show the current status of the user services instead of the global enable status"`
}

type byApp []*snap.AppInfo
Expand All @@ -57,21 +60,52 @@ func (a byApp) Less(i, j int) bool {
return a[i].Name < a[j].Name
}

var newStatusDecorator = func(ctx context.Context, isGlobal bool, uid string) clientutil.StatusDecorator {
if isGlobal {
return servicestate.NewStatusDecorator(progress.Null)
} else {
return servicestate.NewStatusDecoratorForUid(progress.Null, ctx, uid)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This route is not covered in testing.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function is mocked intentionally - if we use NewStatusDecoratorForUid then it will start to contact the snapd user client session which is unfortunate to start spinning up in all tests that use this. Instead the mock verifies that newStatusDecorator is invoked with the expected arguments, and fakes the responses from from the status decorator.

The NewStatusDecoratorForUid functionality, along with the user-session agent is tested independently.

}
}

func (c *servicesCommand) showGlobalEnablement() bool {
if c.uid == "0" && !c.User {
return true
} else if c.uid != "0" && c.Global {
return true
}
return false
}

func (c *servicesCommand) validateArguments() error {
// can't use --global and --user together
if c.Global && c.User {
return fmt.Errorf(i18n.G("cannot combine --global and --user switches."))
}
return nil
}

// The 'snapctl services' command is one of the few commands that can run as
// non-root through snapctl.
func (c *servicesCommand) Execute([]string) error {
context, err := c.ensureContext()
ctx, err := c.ensureContext()
if err != nil {
return err
}

st := context.State()
svcInfos, err := getServiceInfos(st, context.InstanceName(), c.Positional.ServiceNames)
if err := c.validateArguments(); err != nil {
return err
}

st := ctx.State()
svcInfos, err := getServiceInfos(st, ctx.InstanceName(), c.Positional.ServiceNames)
if err != nil {
return err
}
sort.Sort(byApp(svcInfos))

sd := servicestate.NewStatusDecorator(progress.Null)

isGlobal := c.showGlobalEnablement()
sd := newStatusDecorator(context.TODO(), isGlobal, c.uid)
services, err := clientutil.ClientAppInfosFromSnapAppInfos(svcInfos, sd)
if err != nil || len(services) == 0 {
return err
Expand All @@ -81,19 +115,8 @@ func (c *servicesCommand) Execute([]string) error {
defer w.Flush()

fmt.Fprintln(w, i18n.G("Service\tStartup\tCurrent\tNotes"))

for _, svc := range services {
startup := i18n.G("disabled")
if svc.Enabled {
startup = i18n.G("enabled")
}
current := i18n.G("inactive")
if svc.DaemonScope == snap.UserDaemon {
current = "-"
} else if svc.Active {
current = i18n.G("active")
}
fmt.Fprintf(w, "%s.%s\t%s\t%s\t%s\n", svc.Snap, svc.Name, startup, current, clientutil.ClientAppInfoNotes(&svc))
fmt.Fprintln(w, clientutil.FmtServiceStatus(&svc, isGlobal))
}

return nil
Expand Down
106 changes: 102 additions & 4 deletions overlord/hookstate/ctlcmd/services_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import (
"github.com/snapcore/snapd/asserts"
"github.com/snapcore/snapd/asserts/snapasserts"
"github.com/snapcore/snapd/client"
"github.com/snapcore/snapd/client/clientutil"
"github.com/snapcore/snapd/dirs"
"github.com/snapcore/snapd/interfaces"
"github.com/snapcore/snapd/overlord/auth"
Expand Down Expand Up @@ -98,12 +99,19 @@ func (f *fakeStore) SnapAction(_ context.Context, currentSnaps []*store.CurrentS
return snaps, nil, nil
}

type appsSuiteDecoratorResult struct {
daemonType string
active bool
enabled bool
}

type servicectlSuite struct {
testutil.BaseTest
st *state.State
fakeStore fakeStore
mockContext *hookstate.Context
mockHandler *hooktest.MockHandler
st *state.State
fakeStore fakeStore
mockContext *hookstate.Context
mockHandler *hooktest.MockHandler
decoratorResults map[string]appsSuiteDecoratorResult
}

var _ = Suite(&servicectlSuite{})
Expand Down Expand Up @@ -746,6 +754,96 @@ test-snap.test-service enabled active -
c.Check(string(stderr), Equals, "")
}

func (s *servicectlSuite) TestServicesAsUserWithGlobal(c *C) {
restore := systemd.MockSystemctl(func(args ...string) (buf []byte, err error) {
c.Assert(args[0], Equals, "show")
c.Check(args[2], Equals, "snap.test-snap.test-service.service")
return []byte(`Id=snap.test-snap.test-service.service
Names=snap.test-snap.test-service.service
Type=simple
ActiveState=active
UnitFileState=enabled
NeedDaemonReload=no
`), nil
})
defer restore()

stdout, stderr, err := ctlcmd.Run(s.mockContext, []string{"services", "--global", "test-snap.test-service"}, 1337)
c.Assert(err, IsNil)
c.Check(string(stdout), Equals, `
Service Startup Current Notes
test-snap.test-service enabled active -
`[1:])
c.Check(string(stderr), Equals, "")
}

func (s *servicectlSuite) DecorateWithStatus(appInfo *client.AppInfo, snapApp *snap.AppInfo) error {
name := snapApp.Snap.RealName + "." + appInfo.Name
dec, ok := s.decoratorResults[name]
if !ok {
return fmt.Errorf("%s not found in expected test decorator results", name)
}
appInfo.Daemon = dec.daemonType
appInfo.Enabled = dec.enabled
appInfo.Active = dec.active
return nil
}

func (s *servicectlSuite) TestServicesUserSwitch(c *C) {
restore := ctlcmd.MockNewStatusDecorator(func(ctx context.Context, isGlobal bool, uid string) clientutil.StatusDecorator {
c.Check(isGlobal, Equals, false)
c.Check(uid, Equals, "0")
return s
})
defer restore()

s.decoratorResults = map[string]appsSuiteDecoratorResult{
"test-snap.user-service": {
daemonType: "simple",
active: true,
enabled: true,
},
}

stdout, stderr, err := ctlcmd.Run(s.mockContext, []string{"services", "--user", "test-snap.user-service"}, 0)
c.Assert(err, IsNil)
c.Check(string(stdout), Equals, `
Service Startup Current Notes
test-snap.user-service enabled active user
`[1:])
c.Check(string(stderr), Equals, "")
}

func (s *servicectlSuite) TestServicesAsUser(c *C) {
restore := ctlcmd.MockNewStatusDecorator(func(ctx context.Context, isGlobal bool, uid string) clientutil.StatusDecorator {
c.Check(isGlobal, Equals, false)
c.Check(uid, Equals, "1337")
return s
})
defer restore()

s.decoratorResults = map[string]appsSuiteDecoratorResult{
"test-snap.user-service": {
daemonType: "simple",
active: true,
enabled: true,
},
}

stdout, stderr, err := ctlcmd.Run(s.mockContext, []string{"services", "test-snap.user-service"}, 1337)
c.Assert(err, IsNil)
c.Check(string(stdout), Equals, `
Service Startup Current Notes
test-snap.user-service enabled active user
`[1:])
c.Check(string(stderr), Equals, "")
}

func (s *servicectlSuite) TestAppStatusInvalidUserGlobalSwitches(c *C) {
_, _, err := ctlcmd.Run(s.mockContext, []string{"services", "--global", "--user"}, 0)
c.Assert(err, ErrorMatches, "cannot combine --global and --user switches.")
}

func (s *servicectlSuite) TestServicesWithoutContext(c *C) {
actions := []string{
"start",
Expand Down
Loading