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