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