gopkg.in/ubuntu-core/snappy.v0@v0.0.0-20210902073436-25a8614f10a6/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 validateJSONRequest(r *http.Request) (valid bool, errResp Response) {
   183  	contentType := r.Header.Get("Content-Type")
   184  	mediaType, params, err := mime.ParseMediaType(contentType)
   185  	if err != nil {
   186  		return false, BadRequest("cannot parse content type: %v", err)
   187  	}
   188  
   189  	if mediaType != "application/json" {
   190  		return false, BadRequest("unknown content type: %s", contentType)
   191  	}
   192  
   193  	charset := strings.ToUpper(params["charset"])
   194  	if charset != "" && charset != "UTF-8" {
   195  		return false, BadRequest("unknown charset in content type: %s", contentType)
   196  	}
   197  
   198  	return true, nil
   199  }
   200  
   201  func postServiceControl(c *Command, r *http.Request) Response {
   202  	if ok, resp := validateJSONRequest(r); !ok {
   203  		return resp
   204  	}
   205  
   206  	decoder := json.NewDecoder(r.Body)
   207  	var inst serviceInstruction
   208  	if err := decoder.Decode(&inst); err != nil {
   209  		return BadRequest("cannot decode request body into service instruction: %v", err)
   210  	}
   211  	impl := serviceInstructionDispTable[inst.Action]
   212  	if impl == nil {
   213  		return BadRequest("unknown action %s", inst.Action)
   214  	}
   215  	// Prevent multiple systemd actions from being carried out simultaneously
   216  	systemdLock.Lock()
   217  	defer systemdLock.Unlock()
   218  	sysd := systemd.New(systemd.UserMode, dummyReporter{})
   219  	return impl(&inst, sysd)
   220  }
   221  
   222  func postPendingRefreshNotification(c *Command, r *http.Request) Response {
   223  	if ok, resp := validateJSONRequest(r); !ok {
   224  		return resp
   225  	}
   226  
   227  	decoder := json.NewDecoder(r.Body)
   228  
   229  	// pendingSnapRefreshInfo holds information about pending snap refresh provided by snapd.
   230  	type pendingSnapRefreshInfo struct {
   231  		InstanceName        string        `json:"instance-name"`
   232  		TimeRemaining       time.Duration `json:"time-remaining,omitempty"`
   233  		BusyAppName         string        `json:"busy-app-name,omitempty"`
   234  		BusyAppDesktopEntry string        `json:"busy-app-desktop-entry,omitempty"`
   235  	}
   236  	var refreshInfo pendingSnapRefreshInfo
   237  	if err := decoder.Decode(&refreshInfo); err != nil {
   238  		return BadRequest("cannot decode request body into pending snap refresh info: %v", err)
   239  	}
   240  
   241  	// Note that since the connection is shared, we are not closing it.
   242  	if c.s.bus == nil {
   243  		return SyncResponse(&resp{
   244  			Type:   ResponseTypeError,
   245  			Status: 500,
   246  			Result: &errorResult{
   247  				Message: fmt.Sprintf("cannot connect to the session bus"),
   248  			},
   249  		})
   250  	}
   251  
   252  	// TODO: support desktop-specific notification APIs if they provide a better
   253  	// experience. For example, the GNOME notification API.
   254  	notifySrv := notification.New(c.s.bus)
   255  
   256  	// TODO: this message needs to be crafted better as it's the only thing guaranteed to be delivered.
   257  	summary := fmt.Sprintf(i18n.G("Pending update of %q snap"), refreshInfo.InstanceName)
   258  	var urgencyLevel notification.Urgency
   259  	var body, icon string
   260  	var hints []notification.Hint
   261  
   262  	plzClose := i18n.G("Close the app to avoid disruptions")
   263  	if daysLeft := int(refreshInfo.TimeRemaining.Truncate(time.Hour).Hours() / 24); daysLeft > 0 {
   264  		urgencyLevel = notification.LowUrgency
   265  		body = fmt.Sprintf("%s (%s)", plzClose, fmt.Sprintf(
   266  			i18n.NG("%d day left", "%d days left", daysLeft), daysLeft))
   267  	} else if hoursLeft := int(refreshInfo.TimeRemaining.Truncate(time.Minute).Minutes() / 60); hoursLeft > 0 {
   268  		urgencyLevel = notification.NormalUrgency
   269  		body = fmt.Sprintf("%s (%s)", plzClose, fmt.Sprintf(
   270  			i18n.NG("%d hour left", "%d hours left", hoursLeft), hoursLeft))
   271  	} else if minutesLeft := int(refreshInfo.TimeRemaining.Truncate(time.Minute).Minutes()); minutesLeft > 0 {
   272  		urgencyLevel = notification.CriticalUrgency
   273  		body = fmt.Sprintf("%s (%s)", plzClose, fmt.Sprintf(
   274  			i18n.NG("%d minute left", "%d minutes left", minutesLeft), minutesLeft))
   275  	} else {
   276  		summary = fmt.Sprintf(i18n.G("Snap %q is refreshing now!"), refreshInfo.InstanceName)
   277  		urgencyLevel = notification.CriticalUrgency
   278  	}
   279  	hints = append(hints, notification.WithUrgency(urgencyLevel))
   280  	// The notification is provided by snapd session agent.
   281  	hints = append(hints, notification.WithDesktopEntry("io.snapcraft.SessionAgent"))
   282  	// But if we have a desktop file of the busy application, use that apps's icon.
   283  	if refreshInfo.BusyAppDesktopEntry != "" {
   284  		parser := goconfigparser.New()
   285  		desktopFilePath := filepath.Join(dirs.SnapDesktopFilesDir, refreshInfo.BusyAppDesktopEntry+".desktop")
   286  		if err := parser.ReadFile(desktopFilePath); err == nil {
   287  			icon, _ = parser.Get("Desktop Entry", "Icon")
   288  		}
   289  	}
   290  
   291  	msg := &notification.Message{
   292  		AppName: refreshInfo.BusyAppName,
   293  		Summary: summary,
   294  		Icon:    icon,
   295  		Body:    body,
   296  		Hints:   hints,
   297  	}
   298  
   299  	// TODO: silently ignore error returned when the notification server does not exist.
   300  	// TODO: track returned notification ID and respond to actions, if supported.
   301  	if _, err := notifySrv.SendNotification(msg); err != nil {
   302  		return SyncResponse(&resp{
   303  			Type:   ResponseTypeError,
   304  			Status: 500,
   305  			Result: &errorResult{
   306  				Message: fmt.Sprintf("cannot send notification message: %v", err),
   307  			},
   308  		})
   309  	}
   310  	return SyncResponse(nil)
   311  }