Skip to content

Commit

Permalink
many: support killing running snap apps (canonical#14160)
Browse files Browse the repository at this point in the history
* systemd: add a method to list units by pattern

This method will be used later to list all snap apps'
transient units using the pattern "<snap-name>.*.scope".

Signed-off-by: Zeyad Gouda <[email protected]>

* snap: add helper to match snap transient scope units

This helper will be used to find running snap apps
that need to be force killed.

Signed-off-by: Zeyad Gouda <[email protected]>

* usersession: support killing running snap apps

The new /v1/app-control endpoint currently only supports the "kill"
action. This action finds and kills all running apps for the
requested snaps.

This endpoint will be invoked by snapd during removal of snaps
when the --terminate flag is used.

Signed-off-by: Zeyad Gouda <[email protected]>

snap: move kill reason to snap package

Signed-off-by: Zeyad Gouda <[email protected]>

usersession: use syscall.Signal type for AppInstruction.Signal

Thanks @zyga

Signed-off-by: Zeyad Gouda <[email protected]>

---------

Signed-off-by: Zeyad Gouda <[email protected]>
  • Loading branch information
ZeyadYasser authored Jul 25, 2024
1 parent 3a71035 commit 5f7d584
Show file tree
Hide file tree
Showing 12 changed files with 655 additions and 3 deletions.
13 changes: 13 additions & 0 deletions snap/info.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import (
"github.com/snapcore/snapd/snap/naming"
"github.com/snapcore/snapd/snapdtool"
"github.com/snapcore/snapd/strutil"
"github.com/snapcore/snapd/systemd"
"github.com/snapcore/snapd/timeout"
)

Expand Down Expand Up @@ -234,6 +235,18 @@ func NoneSecurityTag(snapName, uniqueName string) string {
return ScopedSecurityTag(snapName, "none", uniqueName)
}

// TransientScopeGlob returns the glob pattern matching
// snap's transient scope units.
//
// e.g. snap.hello-world.sh-4706fe54-7802-4808-aa7e-ae8b567239e0.scope
func TransientScopeGlob(snapName string) (string, error) {
snapSecurityTag := SecurityTag(snapName)
unitPrefix, err := systemd.SecurityTagToUnitName(snapSecurityTag)
// XXX: Should we also match snap components glob pattern (i.e. snap.name+*.*.scope?
// snap.name.*.scope
return unitPrefix + ".*.scope", err
}

// BaseDataDir returns the base directory for snap data locations.
func BaseDataDir(name string) string {
return filepath.Join(dirs.SnapDataDir, name)
Expand Down
32 changes: 32 additions & 0 deletions snap/info_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2367,6 +2367,38 @@ hooks:
c.Check(hook.SecurityTag(), Equals, "snap.test-snap_instance.hook.install")
}

func (s *infoSuite) TestTransientScopeGlob(c *C) {
pattern, err := snap.TransientScopeGlob("some-snap")
c.Assert(err, IsNil)
c.Check(pattern, Equals, "snap.some-snap.*.scope")
matched, err := filepath.Match(pattern, "snap.some-snap.some-app-4706fe54-7802-4808-aa7e-ae8b567239e0.scope")
c.Assert(err, IsNil)
c.Check(matched, Equals, true)
}

func (s *infoSuite) TestTransientScopeGlobInstance(c *C) {
pattern, err := snap.TransientScopeGlob("some-snap_instance-1")
c.Assert(err, IsNil)
c.Check(pattern, Equals, "snap.some-snap_instance-1.*.scope")
// matches instance
matched, err := filepath.Match(pattern, "snap.some-snap_instance-1.some-app-4706fe54-7802-4808-aa7e-ae8b567239e0.scope")
c.Assert(err, IsNil)
c.Check(matched, Equals, true)
// but not other instances
matched, err = filepath.Match(pattern, "snap.some-snap_instance-2.some-app-4706fe54-7802-4808-aa7e-ae8b567239e0.scope")
c.Assert(err, IsNil)
c.Check(matched, Equals, false)
// or the main snap
matched, err = filepath.Match(pattern, "snap.some-snap.some-app-4706fe54-7802-4808-aa7e-ae8b567239e0.scope")
c.Assert(err, IsNil)
c.Check(matched, Equals, false)
}

func (s *infoSuite) TestTransientScopeError(c *C) {
_, err := snap.TransientScopeGlob("invalid?name")
c.Assert(err.Error(), Equals, "invalid character in security tag: '?'")
}

func (s *infoSuite) TestComponentMountDir(c *C) {
dir := snap.ComponentMountDir("comp", snap.R(1), "snap")
c.Check(dir, Equals, filepath.Join(dirs.SnapMountDir, "snap", "components", "mnt", "comp", "1"))
Expand Down
9 changes: 9 additions & 0 deletions snap/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,15 @@ const (
StopReasonOther ServiceStopReason = ""
)

// TODO: merge ServiceStopReason, AppKillReason and removeAliasesReason
type AppKillReason string

const (
KillReasonRemove AppKillReason = "remove"
KillReasonForceRemove AppKillReason = "force-remove"
KillReasonOther AppKillReason = ""
)

// DaemonScope represents the scope of the daemon running under systemd
type DaemonScope string

Expand Down
4 changes: 4 additions & 0 deletions systemd/emulation.go
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,10 @@ func (s *emulation) ListMountUnits(snapName, origin string) ([]string, error) {
return nil, &notImplementedError{"ListMountUnits"}
}

func (s *emulation) ListUnits(pattern string) ([]string, error) {
return nil, &notImplementedError{"ListUnits"}
}

func (s *emulation) Mask(service string) error {
_, err := systemctlCmd("--root", s.rootDir, "mask", service)
return err
Expand Down
24 changes: 24 additions & 0 deletions systemd/systemd.go
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,8 @@ type Systemd interface {
// ListMountUnits gets the list of targets of the mount units created by
// the `origin` module for the given snap
ListMountUnits(snapName, origin string) ([]string, error)
// ListUnits get the list of units currently in memory including transient units.
ListUnits(pattern string) ([]string, error)
// Mask the given service.
Mask(service string) error
// Unmask the given service.
Expand Down Expand Up @@ -1694,6 +1696,28 @@ func (s *systemd) ListMountUnits(snapName, origin string) ([]string, error) {
return mountPoints, nil
}

func (s *systemd) ListUnits(pattern string) ([]string, error) {
out, err := s.systemctl("list-units", "--output=json", pattern)
if err != nil {
return nil, err
}

type rawUnit struct {
UnitName string `json:"unit"`
}
var parsedUnits []rawUnit
err = json.Unmarshal(out, &parsedUnits)
if err != nil {
return nil, fmt.Errorf("cannot parse systemctl output: %w", err)
}

units := make([]string, len(parsedUnits))
for i, parsedUnit := range parsedUnits {
units[i] = parsedUnit.UnitName
}
return units, nil
}

func (s *systemd) ReloadOrRestart(serviceNames []string) error {
if s.mode == GlobalUserMode {
panic("cannot call restart with GlobalUserMode")
Expand Down
47 changes: 47 additions & 0 deletions systemd/systemd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2311,6 +2311,53 @@ X-SnapdOrigin=%s
c.Check(err, IsNil)
}

func (s *SystemdTestSuite) TestListUnitsEmpty(c *C) {
s.outs = [][]byte{
[]byte("[]"),
}

sysd := New(UserMode, nil)
units, err := sysd.ListUnits("snap.some-snap.*.scope")
c.Check(units, HasLen, 0)
c.Check(err, IsNil)
}

func (s *SystemdTestSuite) TestListUnitsMalformed(c *C) {
s.outs = [][]byte{
[]byte(`[{"unit":}]
`),
}

sysd := New(UserMode, nil)
units, err := sysd.ListUnits("snap.some-snap.*.scope")
c.Check(units, HasLen, 0)
c.Check(err, ErrorMatches, "cannot parse systemctl output:.*")
}

func (s *SystemdTestSuite) TestListUnitsHappy(c *C) {
type rawUnit struct {
UnitName string `json:"unit"`
}
var fakeUnits = []rawUnit{
{UnitName: "snap.some-snap.some-app-7414e1a3-6d08-43ff-a81c-6547242a78b0.scope"},
{UnitName: "snap.some-snap.some-app-ff81c9d9-cabb-494b-84b5-494ba945a458.scope"},
{UnitName: "snap.some-snap.some-other-app-f3a1d6fa-c660-4b7d-a450-aaa8849614c7.scope"},
}
systemctlOutput, err := json.Marshal(fakeUnits)
c.Assert(err, IsNil)

s.outs = [][]byte{systemctlOutput}

sysd := New(UserMode, nil)
units, err := sysd.ListUnits("some-snap.*.scope")
c.Assert(err, IsNil)
c.Check(units, DeepEquals, []string{
"snap.some-snap.some-app-7414e1a3-6d08-43ff-a81c-6547242a78b0.scope",
"snap.some-snap.some-app-ff81c9d9-cabb-494b-84b5-494ba945a458.scope",
"snap.some-snap.some-other-app-f3a1d6fa-c660-4b7d-a450-aaa8849614c7.scope",
})
}

func (s *SystemdTestSuite) TestMountHappy(c *C) {
sysd := New(SystemMode, nil)

Expand Down
1 change: 1 addition & 0 deletions usersession/agent/export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (
var (
SessionInfoCmd = sessionInfoCmd
ServiceControlCmd = serviceControlCmd
AppControlCmd = appControlCmd
ServiceStatusCmd = serviceStatusCmd
PendingRefreshNotificationCmd = pendingRefreshNotificationCmd
FinishRefreshNotificationCmd = finishRefreshNotificationCmd
Expand Down
1 change: 1 addition & 0 deletions usersession/agent/response.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ const (
errorKindLoginRequired = errorKind("login-required")
errorKindServiceControl = errorKind("service-control")
errorKindServiceStatus = errorKind("service-status")
errorKindAppControl = errorKind("app-control")
)

type errorValue interface{}
Expand Down
85 changes: 85 additions & 0 deletions usersession/agent/rest_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@ import (
"mime"
"net/http"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"time"

"github.com/mvo5/goconfigparser"
Expand All @@ -46,6 +48,7 @@ var restApi = []*Command{
sessionInfoCmd,
serviceControlCmd,
serviceStatusCmd,
appControlCmd,
pendingRefreshNotificationCmd,
finishRefreshNotificationCmd,
}
Expand All @@ -71,6 +74,11 @@ var (
GET: serviceStatus,
}

appControlCmd = &Command{
Path: "/v1/app-control",
POST: postAppControl,
}

pendingRefreshNotificationCmd = &Command{
Path: "/v1/notifications/pending-refresh",
POST: postPendingRefreshNotification,
Expand Down Expand Up @@ -572,3 +580,80 @@ func postRefreshFinishedNotification(c *Command, r *http.Request) Response {
}
return SyncResponse(nil)
}

func collectSnapAppUnits(snapName string, sysd systemd.Systemd) (units []string, err error) {
pattern, err := snap.TransientScopeGlob(snapName)
if err != nil {
return nil, err
}

return sysd.ListUnits(pattern)
}

func appKill(inst *client.AppInstruction, sysd systemd.Systemd) Response {
// TODO: Use inst.Reason as a hint to explain to users what is happening
if inst.Signal != syscall.SIGKILL {
return BadRequest("only signal SIGKILL is supported")
}

var units []string
for _, snapName := range inst.Snaps {
if err := snap.ValidateInstanceName(snapName); err != nil {
return BadRequest("invalid snap instance name %q: %v", snapName, err)
}
snapAppUnits, err := collectSnapAppUnits(snapName, sysd)
if err != nil {
return InternalError("cannot collect snap app units for %q: %v", snapName, err)
}
units = append(units, snapAppUnits...)
}

killErrors := make(map[string]string)
for _, unit := range units {
signal := strconv.Itoa(int(inst.Signal))
if err := sysd.Kill(unit, signal, "all"); err != nil {
killErrors[unit] = err.Error()
}
}

if len(killErrors) != 0 {
return SyncResponse(&resp{
Type: ResponseTypeError,
Status: 500,
Result: &errorResult{
Message: "some transient units failed to be killed",
Kind: errorKindAppControl,
Value: map[string]interface{}{
"kill-errors": killErrors,
},
},
})
}

return SyncResponse(nil)
}

var appInstructionDispTable = map[string]func(*client.AppInstruction, systemd.Systemd) Response{
"kill": appKill,
}

func postAppControl(c *Command, r *http.Request) Response {
if ok, resp := validateJSONRequest(r); !ok {
return resp
}

decoder := json.NewDecoder(r.Body)
var inst client.AppInstruction
if err := decoder.Decode(&inst); err != nil {
return BadRequest("cannot decode request body into service instruction: %v", err)
}
impl := appInstructionDispTable[inst.Action]
if impl == nil {
return BadRequest("unknown action %s", inst.Action)
}
// Prevent multiple systemd actions from being carried out simultaneously
systemdLock.Lock()
defer systemdLock.Unlock()
sysd := systemd.New(systemd.UserMode, noopReporter{})
return impl(&inst, sysd)
}
Loading

0 comments on commit 5f7d584

Please sign in to comment.