github.com/chipaca/snappy@v0.0.0-20210104084008-1f06296fe8ad/usersession/agent/rest_api.go (about)

     1  // -*- Mode: Go; indent-tabs-mode: t -*-
     2  
     3  /*
     4   * Copyright (C) 2019 Canonical Ltd
     5   *
     6   * This program is free software: you can redistribute it and/or modify
     7   * it under the terms of the GNU General Public License version 3 as
     8   * published by the Free Software Foundation.
     9   *
    10   * This program is distributed in the hope that it will be useful,
    11   * but WITHOUT ANY WARRANTY; without even the implied warranty of
    12   * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    13   * GNU General Public License for more details.
    14   *
    15   * You should have received a copy of the GNU General Public License
    16   * along with this program.  If not, see <http://www.gnu.org/licenses/>.
    17   *
    18   */
    19  
    20  package agent
    21  
    22  import (
    23  	"encoding/json"
    24  	"fmt"
    25  	"mime"
    26  	"net/http"
    27  	"path/filepath"
    28  	"strings"
    29  	"sync"
    30  	"time"
    31  
    32  	"github.com/mvo5/goconfigparser"
    33  
    34  	"github.com/snapcore/snapd/dbusutil"
    35  	"github.com/snapcore/snapd/desktop/notification"
    36  	"github.com/snapcore/snapd/dirs"
    37  	"github.com/snapcore/snapd/i18n"
    38  	"github.com/snapcore/snapd/systemd"
    39  	"github.com/snapcore/snapd/timeout"
    40  )
    41  
    42  var restApi = []*Command{
    43  	rootCmd,
    44  	sessionInfoCmd,
    45  	serviceControlCmd,
    46  	pendingRefreshNotificationCmd,
    47  }
    48  
    49  var (
    50  	rootCmd = &Command{
    51  		Path: "/",
    52  		GET:  nil,
    53  	}
    54  
    55  	sessionInfoCmd = &Command{
    56  		Path: "/v1/session-info",
    57  		GET:  sessionInfo,
    58  	}
    59  
    60  	serviceControlCmd = &Command{
    61  		Path: "/v1/service-control",
    62  		POST: postServiceControl,
    63  	}
    64  
    65  	pendingRefreshNotificationCmd = &Command{
    66  		Path: "/v1/notifications/pending-refresh",
    67  		POST: postPendingRefreshNotification,
    68  	}
    69  )
    70  
    71  func sessionInfo(c *Command, r *http.Request) Response {
    72  	m := map[string]interface{}{
    73  		"version": c.s.Version,
    74  	}
    75  	return SyncResponse(m)
    76  }
    77  
    78  type serviceInstruction struct {
    79  	Action   string   `json:"action"`
    80  	Services []string `json:"services"`
    81  }
    82  
    83  var (
    84  	stopTimeout = time.Duration(timeout.DefaultTimeout)
    85  	killWait    = 5 * time.Second
    86  )
    87  
    88  func serviceStart(inst *serviceInstruction, sysd systemd.Systemd) Response {
    89  	// Refuse to start non-snap services
    90  	for _, service := range inst.Services {
    91  		if !strings.HasPrefix(service, "snap.") {
    92  			return InternalError("cannot start non-snap service %v", service)
    93  		}
    94  	}
    95  
    96  	startErrors := make(map[string]string)
    97  	var started []string
    98  	for _, service := range inst.Services {
    99  		if err := sysd.Start(service); err != nil {
   100  			startErrors[service] = err.Error()
   101  			break
   102  		}
   103  		started = append(started, service)
   104  	}
   105  	// If we got any failures, attempt to stop the services we started.
   106  	stopErrors := make(map[string]string)
   107  	if len(startErrors) != 0 {
   108  		for _, service := range started {
   109  			if err := sysd.Stop(service, stopTimeout); err != nil {
   110  				stopErrors[service] = err.Error()
   111  			}
   112  		}
   113  	}
   114  	if len(startErrors) == 0 {
   115  		return SyncResponse(nil)
   116  	}
   117  	return SyncResponse(&resp{
   118  		Type:   ResponseTypeError,
   119  		Status: 500,
   120  		Result: &errorResult{
   121  			Message: "some user services failed to start",
   122  			Kind:    errorKindServiceControl,
   123  			Value: map[string]interface{}{
   124  				"start-errors": startErrors,
   125  				"stop-errors":  stopErrors,
   126  			},
   127  		},
   128  	})
   129  }
   130  
   131  func serviceStop(inst *serviceInstruction, sysd systemd.Systemd) Response {
   132  	// Refuse to stop non-snap services
   133  	for _, service := range inst.Services {
   134  		if !strings.HasPrefix(service, "snap.") {
   135  			return InternalError("cannot stop non-snap service %v", service)
   136  		}
   137  	}
   138  
   139  	stopErrors := make(map[string]string)
   140  	for _, service := range inst.Services {
   141  		if err := sysd.Stop(service, stopTimeout); err != nil {
   142  			stopErrors[service] = err.Error()
   143  		}
   144  	}
   145  	if len(stopErrors) == 0 {
   146  		return SyncResponse(nil)
   147  	}
   148  	return SyncResponse(&resp{
   149  		Type:   ResponseTypeError,
   150  		Status: 500,
   151  		Result: &errorResult{
   152  			Message: "some user services failed to stop",
   153  			Kind:    errorKindServiceControl,
   154  			Value: map[string]interface{}{
   155  				"stop-errors": stopErrors,
   156  			},
   157  		},
   158  	})
   159  }
   160  
   161  func serviceDaemonReload(inst *serviceInstruction, sysd systemd.Systemd) Response {
   162  	if len(inst.Services) != 0 {
   163  		return InternalError("daemon-reload should not be called with any services")
   164  	}
   165  	if err := sysd.DaemonReload(); err != nil {
   166  		return InternalError("cannot reload daemon: %v", err)
   167  	}
   168  	return SyncResponse(nil)
   169  }
   170  
   171  var serviceInstructionDispTable = map[string]func(*serviceInstruction, systemd.Systemd) Response{
   172  	"start":         serviceStart,
   173  	"stop":          serviceStop,
   174  	"daemon-reload": serviceDaemonReload,
   175  }
   176  
   177  var systemdLock sync.Mutex
   178  
   179  type dummyReporter struct{}
   180  
   181  func (dummyReporter) Notify(string) {}
   182  
   183  func postServiceControl(c *Command, r *http.Request) Response {
   184  	contentType := r.Header.Get("Content-Type")
   185  	mediaType, params, err := mime.ParseMediaType(contentType)
   186  	if err != nil {
   187  		return BadRequest("cannot parse content type: %v", err)
   188  	}
   189  
   190  	if mediaType != "application/json" {
   191  		return BadRequest("unknown content type: %s", contentType)
   192  	}
   193  
   194  	charset := strings.ToUpper(params["charset"])
   195  	if charset != "" && charset != "UTF-8" {
   196  		return BadRequest("unknown charset in content type: %s", contentType)
   197  	}
   198  
   199  	decoder := json.NewDecoder(r.Body)
   200  	var inst serviceInstruction
   201  	if err := decoder.Decode(&inst); err != nil {
   202  		return BadRequest("cannot decode request body into service instruction: %v", err)
   203  	}
   204  	impl := serviceInstructionDispTable[inst.Action]
   205  	if impl == nil {
   206  		return BadRequest("unknown action %s", inst.Action)
   207  	}
   208  	// Prevent multiple systemd actions from being carried out simultaneously
   209  	systemdLock.Lock()
   210  	defer systemdLock.Unlock()
   211  	sysd := systemd.New(systemd.UserMode, dummyReporter{})
   212  	return impl(&inst, sysd)
   213  }
   214  
   215  func postPendingRefreshNotification(c *Command, r *http.Request) Response {
   216  	contentType := r.Header.Get("Content-Type")
   217  	mediaType, params, err := mime.ParseMediaType(contentType)
   218  	if err != nil {
   219  		return BadRequest("cannot parse content type: %v", err)
   220  	}
   221  
   222  	if mediaType != "application/json" {
   223  		return BadRequest("unknown content type: %s", contentType)
   224  	}
   225  
   226  	charset := strings.ToUpper(params["charset"])
   227  	if charset != "" && charset != "UTF-8" {
   228  		return BadRequest("unknown charset in content type: %s", contentType)
   229  	}
   230  
   231  	decoder := json.NewDecoder(r.Body)
   232  
   233  	// pendingSnapRefreshInfo holds information about pending snap refresh provided by snapd.
   234  	type pendingSnapRefreshInfo struct {
   235  		InstanceName        string        `json:"instance-name"`
   236  		TimeRemaining       time.Duration `json:"time-remaining,omitempty"`
   237  		BusyAppName         string        `json:"busy-app-name,omitempty"`
   238  		BusyAppDesktopEntry string        `json:"busy-app-desktop-entry,omitempty"`
   239  	}
   240  	var refreshInfo pendingSnapRefreshInfo
   241  	if err := decoder.Decode(&refreshInfo); err != nil {
   242  		return BadRequest("cannot decode request body into pending snap refresh info: %v", err)
   243  	}
   244  
   245  	// TODO: use c.a.bus once https://github.com/snapcore/snapd/pull/9497 is merged.
   246  	conn, err := dbusutil.SessionBus()
   247  	// Note that since the connection is shared, we are not closing it.
   248  	if err != nil {
   249  		return SyncResponse(&resp{
   250  			Type:   ResponseTypeError,
   251  			Status: 500,
   252  			Result: &errorResult{
   253  				Message: fmt.Sprintf("cannot connect to the session bus: %v", err),
   254  			},
   255  		})
   256  	}
   257  
   258  	// TODO: support desktop-specific notification APIs if they provide a better
   259  	// experience. For example, the GNOME notification API.
   260  	notifySrv := notification.New(conn)
   261  
   262  	// TODO: this message needs to be crafted better as it's the only thing guaranteed to be delivered.
   263  	summary := fmt.Sprintf(i18n.G("Pending update of %q snap"), refreshInfo.InstanceName)
   264  	var urgencyLevel notification.Urgency
   265  	var body, icon string
   266  	var hints []notification.Hint
   267  
   268  	plzClose := i18n.G("Close the app to avoid disruptions")
   269  	if daysLeft := int(refreshInfo.TimeRemaining.Truncate(time.Hour).Hours() / 24); daysLeft > 0 {
   270  		urgencyLevel = notification.LowUrgency
   271  		body = fmt.Sprintf("%s (%s)", plzClose, fmt.Sprintf(
   272  			i18n.NG("%d day left", "%d days left", daysLeft), daysLeft))
   273  	} else if hoursLeft := int(refreshInfo.TimeRemaining.Truncate(time.Minute).Minutes() / 60); hoursLeft > 0 {
   274  		urgencyLevel = notification.NormalUrgency
   275  		body = fmt.Sprintf("%s (%s)", plzClose, fmt.Sprintf(
   276  			i18n.NG("%d hour left", "%d hours left", hoursLeft), hoursLeft))
   277  	} else if minutesLeft := int(refreshInfo.TimeRemaining.Truncate(time.Minute).Minutes()); minutesLeft > 0 {
   278  		urgencyLevel = notification.CriticalUrgency
   279  		body = fmt.Sprintf("%s (%s)", plzClose, fmt.Sprintf(
   280  			i18n.NG("%d minute left", "%d minutes left", minutesLeft), minutesLeft))
   281  	} else {
   282  		summary = fmt.Sprintf(i18n.G("Snap %q is refreshing now!"), refreshInfo.InstanceName)
   283  		urgencyLevel = notification.CriticalUrgency
   284  	}
   285  	hints = append(hints, notification.WithUrgency(urgencyLevel))
   286  	// The notification is provided by snapd session agent.
   287  	hints = append(hints, notification.WithDesktopEntry("io.snapcraft.SessionAgent"))
   288  	// But if we have a desktop file of the busy application, use that apps's icon.
   289  	if refreshInfo.BusyAppDesktopEntry != "" {
   290  		parser := goconfigparser.New()
   291  		desktopFilePath := filepath.Join(dirs.SnapDesktopFilesDir, refreshInfo.BusyAppDesktopEntry+".desktop")
   292  		if err := parser.ReadFile(desktopFilePath); err == nil {
   293  			icon, _ = parser.Get("Desktop Entry", "Icon")
   294  		}
   295  	}
   296  
   297  	msg := &notification.Message{
   298  		AppName: refreshInfo.BusyAppName,
   299  		Summary: summary,
   300  		Icon:    icon,
   301  		Body:    body,
   302  		Hints:   hints,
   303  	}
   304  
   305  	// TODO: silently ignore error returned when the notification server does not exist.
   306  	// TODO: track returned notification ID and respond to actions, if supported.
   307  	if _, err := notifySrv.SendNotification(msg); err != nil {
   308  		return SyncResponse(&resp{
   309  			Type:   ResponseTypeError,
   310  			Status: 500,
   311  			Result: &errorResult{
   312  				Message: fmt.Sprintf("cannot send notification message: %v", err),
   313  			},
   314  		})
   315  	}
   316  	return SyncResponse(nil)
   317  }