From 5f7d584a3217a9937b26831e1880eee824601917 Mon Sep 17 00:00:00 2001 From: Zeyad Yasser Date: Thu, 25 Jul 2024 13:41:09 +0300 Subject: [PATCH] many: support killing running snap apps (#14160) * 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 ".*.scope". Signed-off-by: Zeyad Gouda * 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 * 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 snap: move kill reason to snap package Signed-off-by: Zeyad Gouda usersession: use syscall.Signal type for AppInstruction.Signal Thanks @zyga Signed-off-by: Zeyad Gouda --------- Signed-off-by: Zeyad Gouda --- snap/info.go | 13 ++ snap/info_test.go | 32 +++++ snap/types.go | 9 ++ systemd/emulation.go | 4 + systemd/systemd.go | 24 ++++ systemd/systemd_test.go | 47 +++++++ usersession/agent/export_test.go | 1 + usersession/agent/response.go | 1 + usersession/agent/rest_api.go | 85 +++++++++++ usersession/agent/rest_api_test.go | 217 +++++++++++++++++++++++++++++ usersession/client/client.go | 87 +++++++++++- usersession/client/client_test.go | 138 ++++++++++++++++++ 12 files changed, 655 insertions(+), 3 deletions(-) diff --git a/snap/info.go b/snap/info.go index e22d8539b74..c7009e82dfc 100644 --- a/snap/info.go +++ b/snap/info.go @@ -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" ) @@ -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) diff --git a/snap/info_test.go b/snap/info_test.go index 32825da2175..d3a8c91d9a9 100644 --- a/snap/info_test.go +++ b/snap/info_test.go @@ -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")) diff --git a/snap/types.go b/snap/types.go index e8d09571562..bb831c8aeb7 100644 --- a/snap/types.go +++ b/snap/types.go @@ -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 diff --git a/systemd/emulation.go b/systemd/emulation.go index db451dd87ce..28f442a8082 100644 --- a/systemd/emulation.go +++ b/systemd/emulation.go @@ -217,6 +217,10 @@ func (s *emulation) ListMountUnits(snapName, origin string) ([]string, error) { return nil, ¬ImplementedError{"ListMountUnits"} } +func (s *emulation) ListUnits(pattern string) ([]string, error) { + return nil, ¬ImplementedError{"ListUnits"} +} + func (s *emulation) Mask(service string) error { _, err := systemctlCmd("--root", s.rootDir, "mask", service) return err diff --git a/systemd/systemd.go b/systemd/systemd.go index 96993166791..80e7c80b5cf 100644 --- a/systemd/systemd.go +++ b/systemd/systemd.go @@ -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. @@ -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") diff --git a/systemd/systemd_test.go b/systemd/systemd_test.go index 3ab205f91ae..53f677658cd 100644 --- a/systemd/systemd_test.go +++ b/systemd/systemd_test.go @@ -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) diff --git a/usersession/agent/export_test.go b/usersession/agent/export_test.go index 85675a0eb43..3b8cf02fa2d 100644 --- a/usersession/agent/export_test.go +++ b/usersession/agent/export_test.go @@ -28,6 +28,7 @@ import ( var ( SessionInfoCmd = sessionInfoCmd ServiceControlCmd = serviceControlCmd + AppControlCmd = appControlCmd ServiceStatusCmd = serviceStatusCmd PendingRefreshNotificationCmd = pendingRefreshNotificationCmd FinishRefreshNotificationCmd = finishRefreshNotificationCmd diff --git a/usersession/agent/response.go b/usersession/agent/response.go index 21a0b7a12a8..23f5d4f02d5 100644 --- a/usersession/agent/response.go +++ b/usersession/agent/response.go @@ -96,6 +96,7 @@ const ( errorKindLoginRequired = errorKind("login-required") errorKindServiceControl = errorKind("service-control") errorKindServiceStatus = errorKind("service-status") + errorKindAppControl = errorKind("app-control") ) type errorValue interface{} diff --git a/usersession/agent/rest_api.go b/usersession/agent/rest_api.go index e936dcf402f..da9e4cd412e 100644 --- a/usersession/agent/rest_api.go +++ b/usersession/agent/rest_api.go @@ -25,8 +25,10 @@ import ( "mime" "net/http" "path/filepath" + "strconv" "strings" "sync" + "syscall" "time" "github.com/mvo5/goconfigparser" @@ -46,6 +48,7 @@ var restApi = []*Command{ sessionInfoCmd, serviceControlCmd, serviceStatusCmd, + appControlCmd, pendingRefreshNotificationCmd, finishRefreshNotificationCmd, } @@ -71,6 +74,11 @@ var ( GET: serviceStatus, } + appControlCmd = &Command{ + Path: "/v1/app-control", + POST: postAppControl, + } + pendingRefreshNotificationCmd = &Command{ Path: "/v1/notifications/pending-refresh", POST: postPendingRefreshNotification, @@ -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) +} diff --git a/usersession/agent/rest_api_test.go b/usersession/agent/rest_api_test.go index 037895138b5..1d9905b4563 100644 --- a/usersession/agent/rest_api_test.go +++ b/usersession/agent/rest_api_test.go @@ -28,6 +28,7 @@ import ( "os" "path" "path/filepath" + "strings" "time" "github.com/godbus/dbus" @@ -794,6 +795,222 @@ func (s *restSuite) TestServicesStatusReportsError(c *C) { }) } +func (s *restSuite) TestAppControl(c *C) { + // the agent.Apps end point only supports POST requests + c.Assert(agent.AppControlCmd.GET, IsNil) + c.Check(agent.AppControlCmd.PUT, IsNil) + c.Check(agent.AppControlCmd.POST, NotNil) + c.Check(agent.AppControlCmd.DELETE, IsNil) + + c.Check(agent.AppControlCmd.Path, Equals, "/v1/app-control") +} + +func (s *restSuite) testAppControlBadRequest(c *C, inst string, contentType string, expectedErr string) { + req := httptest.NewRequest("POST", "/v1/app-control", bytes.NewBufferString(inst)) + req.Header.Set("Content-Type", contentType) + rec := httptest.NewRecorder() + agent.AppControlCmd.POST(agent.AppControlCmd, req).ServeHTTP(rec, req) + c.Check(rec.Code, Equals, 400) + c.Check(rec.Header().Get("Content-Type"), Equals, "application/json") + + var rsp resp + c.Assert(json.Unmarshal(rec.Body.Bytes(), &rsp), IsNil) + c.Check(rsp.Type, Equals, agent.ResponseTypeError) + c.Check(rsp.Result, DeepEquals, map[string]interface{}{"message": expectedErr}) +} + +func (s *restSuite) TestAppControlBadContentType(c *C) { + const inst = `{"action":"kill","snaps":["foo"],"signal":9}` + const contentType = "text/html" + const expectedErr = "unknown content type: text/html" + s.testAppControlBadRequest(c, inst, contentType, expectedErr) +} + +func (s *restSuite) TestAppControlBadAction(c *C) { + const inst = `{"action":"bad-action","snaps":["foo"],"signal":9}` + const contentType = "application/json" + const expectedErr = "unknown action bad-action" + s.testAppControlBadRequest(c, inst, contentType, expectedErr) +} + +func (s *restSuite) TestAppControlBadJsonFormat(c *C) { + const inst = `{"action":}` + const contentType = "application/json" + const expectedErr = "cannot decode request body into service instruction: invalid character '}' looking for beginning of value" + s.testAppControlBadRequest(c, inst, contentType, expectedErr) +} + +func mockSystemctlUnitJsonOutput(units []string, c *C) []byte { + type rawUnit struct { + UnitName string `json:"unit"` + } + var fakeUnits = make([]rawUnit, len(units)) + for i, unit := range units { + fakeUnits[i].UnitName = unit + } + systemctlOutput, err := json.Marshal(fakeUnits) + c.Assert(err, IsNil) + return systemctlOutput +} + +func (s *restSuite) TestAppControlKill(c *C) { + var sysdLog [][]string + unitsForPattern := map[string][]string{ + "snap.foo.*.scope": { + "snap.foo.some-app-7414e1a3-6d08-43ff-a81c-6547242a78b0.scope", + "snap.foo.some-app-ff81c9d9-cabb-494b-84b5-494ba945a458.scope", + }, + "snap.bar.*.scope": { + "snap.bar.some-app-f3a1d6fa-c660-4b7d-a450-aaa8849614c7.scope", + }, + // no running apps for foobar + "snap.foobar.*.scope": nil, + } + restore := systemd.MockSystemctl(func(cmd ...string) ([]byte, error) { + sysdLog = append(sysdLog, cmd) + switch cmd[1] { + case "list-units": + units, exists := unitsForPattern[cmd[len(cmd)-1]] + c.Assert(exists, Equals, true) + return mockSystemctlUnitJsonOutput(units, c), nil + case "kill": + return []byte{}, nil + default: + return nil, fmt.Errorf("unexpected systemctl cmd %q", cmd[1]) + } + }) + defer restore() + + req := httptest.NewRequest("POST", "/v1/app-control", bytes.NewBufferString(`{"action":"kill","snaps":["foo", "bar", "foobar"],"signal":9}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + agent.AppControlCmd.POST(agent.AppControlCmd, req).ServeHTTP(rec, req) + c.Check(rec.Code, Equals, 200) + c.Check(rec.Header().Get("Content-Type"), Equals, "application/json") + + var rsp resp + c.Assert(json.Unmarshal(rec.Body.Bytes(), &rsp), IsNil) + c.Check(rsp.Type, Equals, agent.ResponseTypeSync) + c.Check(rsp.Result, Equals, nil) + + c.Check(sysdLog, DeepEquals, [][]string{ + {"--user", "list-units", "--output=json", "snap.foo.*.scope"}, + {"--user", "list-units", "--output=json", "snap.bar.*.scope"}, + {"--user", "list-units", "--output=json", "snap.foobar.*.scope"}, + {"--user", "kill", "snap.foo.some-app-7414e1a3-6d08-43ff-a81c-6547242a78b0.scope", "-s", "9", "--kill-who=all"}, + {"--user", "kill", "snap.foo.some-app-ff81c9d9-cabb-494b-84b5-494ba945a458.scope", "-s", "9", "--kill-who=all"}, + {"--user", "kill", "snap.bar.some-app-f3a1d6fa-c660-4b7d-a450-aaa8849614c7.scope", "-s", "9", "--kill-who=all"}, + }) +} + +func (s *restSuite) TestAppControlKillBadSignal(c *C) { + const inst = `{"action":"kill","snaps":["foo"],"signal":15}` + const contentType = "application/json" + const expectedErr = "only signal SIGKILL is supported" + s.testAppControlBadRequest(c, inst, contentType, expectedErr) +} + +func (s *restSuite) TestAppControlKillBadInstanceName(c *C) { + const inst = `{"action":"kill","snaps":["foo_bad-instance-name"],"signal":9}` + const contentType = "application/json" + const expectedErr = `invalid snap instance name "foo_bad-instance-name": invalid instance key: "bad-instance-name"` + s.testAppControlBadRequest(c, inst, contentType, expectedErr) +} + +func (s *restSuite) TestAppControlKillBadSnapName(c *C) { + const inst = `{"action":"kill","snaps":["Bad"],"signal":9}` + const contentType = "application/json" + const expectedErr = `invalid snap instance name "Bad": invalid snap name: "Bad"` + s.testAppControlBadRequest(c, inst, contentType, expectedErr) +} + +func (s *restSuite) TestAppControlKillListUnitsError(c *C) { + var sysdLog [][]string + restore := systemd.MockSystemctl(func(cmd ...string) ([]byte, error) { + sysdLog = append(sysdLog, cmd) + switch cmd[1] { + case "list-units": + return nil, errors.New("mock systemctl error") + default: + return nil, fmt.Errorf("unexpected systemctl cmd %q", cmd[1]) + } + }) + defer restore() + + req := httptest.NewRequest("POST", "/v1/app-control", bytes.NewBufferString(`{"action":"kill","snaps":["foo", "bar", "foobar"],"signal":9}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + agent.AppControlCmd.POST(agent.AppControlCmd, req).ServeHTTP(rec, req) + c.Check(rec.Code, Equals, 500) + c.Check(rec.Header().Get("Content-Type"), Equals, "application/json") + + var rsp resp + c.Assert(json.Unmarshal(rec.Body.Bytes(), &rsp), IsNil) + c.Check(rsp.Type, Equals, agent.ResponseTypeError) + c.Check(rsp.Result, DeepEquals, map[string]interface{}{"message": `cannot collect snap app units for "foo": mock systemctl error`}) +} + +func (s *restSuite) TestAppControlKillReportsError(c *C) { + var sysdLog [][]string + unitsForPattern := map[string][]string{ + "snap.foo.*.scope": { + "snap.foo.some-app-7414e1a3-6d08-43ff-a81c-6547242a78b0.scope", + }, + "snap.bad.*.scope": { + "snap.bad.app-ff81c9d9-cabb-494b-84b5-494ba945a458.scope", + }, + "snap.bar.*.scope": { + "snap.bar.some-app-f3a1d6fa-c660-4b7d-a450-aaa8849614c7.scope", + }, + } + restore := systemd.MockSystemctl(func(cmd ...string) ([]byte, error) { + sysdLog = append(sysdLog, cmd) + switch cmd[1] { + case "list-units": + units, exists := unitsForPattern[cmd[len(cmd)-1]] + c.Assert(exists, Equals, true) + return mockSystemctlUnitJsonOutput(units, c), nil + case "kill": + if strings.HasPrefix(cmd[2], "snap.bad.") { + return []byte{}, errors.New("mock systemctl error") + } + return []byte{}, nil + default: + return nil, fmt.Errorf("unexpected systemctl cmd %q", cmd[1]) + } + }) + defer restore() + + req := httptest.NewRequest("POST", "/v1/app-control", bytes.NewBufferString(`{"action":"kill","snaps":["foo", "bad", "bar"],"signal":9}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + agent.AppControlCmd.POST(agent.AppControlCmd, req).ServeHTTP(rec, req) + c.Check(rec.Code, Equals, 500) + c.Check(rec.Header().Get("Content-Type"), Equals, "application/json") + + var rsp resp + c.Assert(json.Unmarshal(rec.Body.Bytes(), &rsp), IsNil) + c.Check(rsp.Type, Equals, agent.ResponseTypeError) + c.Check(rsp.Result, DeepEquals, map[string]interface{}{ + "message": "some transient units failed to be killed", + "kind": "app-control", + "value": map[string]interface{}{ + "kill-errors": map[string]interface{}{ + "snap.bad.app-ff81c9d9-cabb-494b-84b5-494ba945a458.scope": "mock systemctl error", + }, + }, + }) + + c.Check(sysdLog, DeepEquals, [][]string{ + {"--user", "list-units", "--output=json", "snap.foo.*.scope"}, + {"--user", "list-units", "--output=json", "snap.bad.*.scope"}, + {"--user", "list-units", "--output=json", "snap.bar.*.scope"}, + {"--user", "kill", "snap.foo.some-app-7414e1a3-6d08-43ff-a81c-6547242a78b0.scope", "-s", "9", "--kill-who=all"}, + {"--user", "kill", "snap.bad.app-ff81c9d9-cabb-494b-84b5-494ba945a458.scope", "-s", "9", "--kill-who=all"}, + {"--user", "kill", "snap.bar.some-app-f3a1d6fa-c660-4b7d-a450-aaa8849614c7.scope", "-s", "9", "--kill-who=all"}, + }) +} + func (s *restSuite) TestPostPendingRefreshNotificationMalformedContentType(c *C) { req := httptest.NewRequest("POST", "/v1/notifications/pending-refresh", bytes.NewBufferString("")) req.Header.Set("Content-Type", "text/plain/joke") diff --git a/usersession/client/client.go b/usersession/client/client.go index ad5eedda145..623cb0a6a7d 100644 --- a/usersession/client/client.go +++ b/usersession/client/client.go @@ -32,9 +32,11 @@ import ( "strconv" "strings" "sync" + "syscall" "time" "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/snap" "github.com/snapcore/snapd/systemd" ) @@ -292,7 +294,7 @@ type ServiceInstruction struct { Reload bool `json:"reload,omitempty"` } -func (client *Client) decodeControlResponses(responses []*response) (startFailures, stopFailures []ServiceFailure, err error) { +func (client *Client) decodeServiceControlResponses(responses []*response) (startFailures, stopFailures []ServiceFailure, err error) { for _, resp := range responses { if agentErr, ok := resp.err.(*Error); ok && agentErr.Kind == "service-control" { if errorValue, ok := agentErr.Value.(map[string]interface{}); ok { @@ -323,7 +325,7 @@ func (client *Client) serviceControlCall(ctx context.Context, inst *ServiceInstr if err != nil { return nil, nil, err } - return client.decodeControlResponses(responses) + return client.decodeServiceControlResponses(responses) } func (client *Client) ServicesDaemonReload(ctx context.Context) error { @@ -411,7 +413,7 @@ func (client *Client) ServicesStart(ctx context.Context, services []string, opts }(uid) } wg.Wait() - return client.decodeControlResponses(responses) + return client.decodeServiceControlResponses(responses) } // ServicesStop attempts to stop the services in `services`. @@ -534,3 +536,82 @@ func (client *Client) FinishRefreshNotification(ctx context.Context, closeInfo * _, err = client.doMany(ctx, "POST", "/v1/notifications/finish-refresh", nil, headers, reqBody) return err } + +// AppInstruction is the json representation of possible arguments +// for the user session rest api to control apps. +type AppInstruction struct { + Action string `json:"action"` + Snaps []string `json:"snaps,omitempty"` + + // Kill options + Signal syscall.Signal `json:"signal,omitempty"` + Reason snap.AppKillReason `json:"reason,omitempty"` +} + +type AppFailure struct { + Uid int + Unit string + Error string +} + +func decodeAppErrors(uid int, errorValue map[string]interface{}, kind string) []AppFailure { + if errorValue[kind] == nil { + return nil + } + errors, ok := errorValue[kind].(map[string]interface{}) + if !ok { + return nil + } + var failures []AppFailure + for unit, reason := range errors { + if reasonString, ok := reason.(string); ok { + failures = append(failures, AppFailure{ + Uid: uid, + Unit: unit, + Error: reasonString, + }) + } + } + return failures +} + +func (client *Client) decodeAppControlResponses(responses []*response) (killFailures []AppFailure, err error) { + for _, resp := range responses { + // Parse kill errors which were a result of failure to kill running snap apps + if agentErr, ok := resp.err.(*Error); ok && agentErr.Kind == "app-control" { + if errorValue, ok := agentErr.Value.(map[string]interface{}); ok { + failures := decodeAppErrors(resp.uid, errorValue, "kill-errors") + killFailures = append(killFailures, failures...) + } + } + // The response was an error, store the first error + if resp.err != nil && err == nil { + err = resp.err + } + } + return killFailures, err +} + +func (client *Client) appControlCall(ctx context.Context, inst *AppInstruction) (killFailures []AppFailure, err error) { + headers := map[string]string{"Content-Type": "application/json"} + reqBody, err := json.Marshal(inst) + if err != nil { + return nil, err + } + responses, err := client.doMany(ctx, "POST", "/v1/app-control", nil, headers, reqBody) + if err != nil { + return nil, err + } + return client.decodeAppControlResponses(responses) +} + +// AppsKill attempts to send a signal to running snap apps. +func (client *Client) AppsKill(ctx context.Context, snaps []string, signal syscall.Signal, reason snap.AppKillReason) (killFailures []AppFailure, err error) { + killFailures, err = client.appControlCall(ctx, &AppInstruction{ + Action: "kill", + Snaps: snaps, + Signal: signal, + Reason: reason, + }) + return killFailures, err +} diff --git a/usersession/client/client_test.go b/usersession/client/client_test.go index ffb9c5909f4..8be4720bde7 100644 --- a/usersession/client/client_test.go +++ b/usersession/client/client_test.go @@ -29,12 +29,14 @@ import ( "os" "path/filepath" "sync/atomic" + "syscall" "testing" "time" . "gopkg.in/check.v1" "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/snap" "github.com/snapcore/snapd/testutil" "github.com/snapcore/snapd/usersession/client" ) @@ -744,3 +746,139 @@ func (s *clientSuite) TestPendingRefreshNotificationOneClient(c *C) { c.Assert(err, IsNil) c.Check(atomic.LoadInt32(&n), Equals, int32(1)) } + +func (s *clientSuite) TestAppsKill(c *C) { + s.handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.URL.Path, Equals, "/v1/app-control") + w.Header().Set("Content-Type", "application/json") + + decoder := json.NewDecoder(r.Body) + var inst client.AppInstruction + c.Assert(decoder.Decode(&inst), IsNil) + c.Check(inst.Action, Equals, "kill") + c.Check(inst.Snaps, DeepEquals, []string{"foo", "foo_bar"}) + c.Check(inst.Signal, Equals, syscall.SIGKILL) + c.Check(inst.Reason, Equals, snap.KillReasonForceRemove) + + w.WriteHeader(200) + w.Write([]byte(`{ + "type": "sync", + "result": null +}`)) + }) + failures, err := s.cli.AppsKill(context.Background(), []string{"foo", "foo_bar"}, 9, snap.KillReasonForceRemove) + c.Assert(err, IsNil) + c.Check(failures, HasLen, 0) +} + +func (s *clientSuite) TestAppsKillFailure(c *C) { + s.handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.URL.Path, Equals, "/v1/app-control") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + w.Write([]byte(`{ + "type": "error", + "result": { + "kind": "app-control", + "message": "failed to kill running apps", + "value": { + "kill-errors": { + "snap.foo.some-app-7414e1a3-6d08-43ff-a81c-6547242a78b0.scope": "failed to kill running app" + } + } + } +}`)) + }) + failures, err := s.cli.AppsKill(context.Background(), []string{"foo", "foo_bar"}, 9, snap.KillReasonForceRemove) + c.Assert(err, ErrorMatches, "failed to kill running apps") + c.Check(failures, HasLen, 2) + failure0 := failures[0] + failure1 := failures[1] + if failure0.Uid == 1000 { + failure0, failure1 = failure1, failure0 + } + c.Check(failure0, DeepEquals, client.AppFailure{ + Uid: 42, + Unit: "snap.foo.some-app-7414e1a3-6d08-43ff-a81c-6547242a78b0.scope", + Error: "failed to kill running app", + }) + c.Check(failure1, DeepEquals, client.AppFailure{ + Uid: 1000, + Unit: "snap.foo.some-app-7414e1a3-6d08-43ff-a81c-6547242a78b0.scope", + Error: "failed to kill running app", + }) +} + +func (s *clientSuite) TestAppsKillBadErrors(c *C) { + errorValue := "null" + s.handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + // Only produce failure from one agent + if r.Host != "42" { + w.WriteHeader(200) + w.Write([]byte(`{"type": "sync","result": null}`)) + return + } + + w.WriteHeader(500) + w.Write([]byte(fmt.Sprintf(`{ + "type": "error", + "result": { + "kind": "app-control", + "message": "failed to kill running apps", + "value": %s + } +}`, errorValue))) + }) + + // Error value is not a map + errorValue = "[]" + failures, err := s.cli.AppsKill(context.Background(), []string{"foo"}, 9, snap.KillReasonForceRemove) + c.Check(err, ErrorMatches, "failed to kill running apps") + c.Check(failures, HasLen, 0) + + // Error value is a map, but missing kill-errors key + errorValue = "{}" + failures, err = s.cli.AppsKill(context.Background(), []string{"foo"}, 9, snap.KillReasonForceRemove) + c.Check(err, ErrorMatches, "failed to kill running apps") + c.Check(failures, HasLen, 0) + + // kill-errors is a map + errorValue = `{ + "kill-errors": [] +}` + failures, err = s.cli.AppsKill(context.Background(), []string{"foo"}, 9, snap.KillReasonForceRemove) + c.Check(err, ErrorMatches, "failed to kill running apps") + c.Check(failures, HasLen, 0) + + // kill-error values are not strings + errorValue = `{ + "kill-errors": { + "snap.foo.some-app-7414e1a3-6d08-43ff-a81c-6547242a78b0.scope": 42 + } +}` + failures, err = s.cli.AppsKill(context.Background(), []string{"foo"}, 9, snap.KillReasonForceRemove) + c.Check(err, ErrorMatches, "failed to kill running apps") + c.Check(failures, HasLen, 0) + + // When some valid app failures are mixed in with bad + // ones, report the valid failure along with the error + // message. + errorValue = `{ + "kill-errors": { + "snap.foo.some-app-7414e1a3-6d08-43ff-a81c-6547242a78b0.scope": "failure one", + "snap.foo.some-other-app-f3a1d6fa-c660-4b7d-a450-aaa8849614c7.scope": 42 + } +}` + failures, err = s.cli.AppsKill(context.Background(), []string{"foo"}, 9, snap.KillReasonForceRemove) + c.Check(err, ErrorMatches, "failed to kill running apps") + c.Check(failures, HasLen, 1) + c.Check(failures, DeepEquals, []client.AppFailure{ + { + Uid: 42, + Unit: "snap.foo.some-app-7414e1a3-6d08-43ff-a81c-6547242a78b0.scope", + Error: "failure one", + }, + }) +}