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