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 := ¬ification.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 }