github.com/wallyworld/juju@v0.0.0-20161013125918-6cf1bc9d917a/apiserver/gui.go (about) 1 // Copyright 2016 Canonical Ltd. 2 // Licensed under the AGPLv3, see LICENCE file for details. 3 4 package apiserver 5 6 import ( 7 "archive/tar" 8 "bytes" 9 "compress/bzip2" 10 "encoding/json" 11 "fmt" 12 "io" 13 "io/ioutil" 14 "mime" 15 "net/http" 16 "net/url" 17 "os" 18 "path" 19 "path/filepath" 20 "strings" 21 "text/template" 22 23 "github.com/juju/errors" 24 "github.com/juju/version" 25 26 agenttools "github.com/juju/juju/agent/tools" 27 "github.com/juju/juju/apiserver/common" 28 "github.com/juju/juju/apiserver/common/apihttp" 29 "github.com/juju/juju/apiserver/params" 30 "github.com/juju/juju/state" 31 "github.com/juju/juju/state/binarystorage" 32 jujuversion "github.com/juju/juju/version" 33 ) 34 35 const ( 36 bzMimeType = "application/x-tar-bzip2" 37 ) 38 39 var ( 40 jsMimeType = mime.TypeByExtension(".js") 41 spritePath = filepath.FromSlash("static/gui/build/app/assets/stack/svg/sprite.css.svg") 42 ) 43 44 // guiRouter serves the Juju GUI routes. 45 // Serving the Juju GUI is done with the following assumptions: 46 // - the archive is compressed in tar.bz2 format; 47 // - the archive includes a top directory named "jujugui-{version}" where 48 // version is semver (like "2.0.1"). This directory includes another 49 // "jujugui" directory where the actual Juju GUI files live; 50 // - the "jujugui" directory includes a "static" subdirectory with the Juju 51 // GUI assets to be served statically; 52 // - the "jujugui" directory specifically includes a 53 // "static/gui/build/app/assets/stack/svg/sprite.css.svg" file, which is 54 // required to render the Juju GUI index file; 55 // - the "jujugui" directory includes a "templates/index.html.go" file which is 56 // used to render the Juju GUI index. The template receives at least the 57 // following variables in its context: "staticURL", comboURL", "configURL", 58 // "debug" and "spriteContent". It might receive more variables but cannot 59 // assume them to be always provided; 60 // - the "jujugui" directory includes a "templates/config.js.go" file which is 61 // used to render the Juju GUI configuration file. The template receives at 62 // least the following variables in its context: "base", "host", "socket", 63 // "controllerSocket", "staticURL", "uuid" and "version". It might receive 64 // more variables but cannot assume them to be always provided. 65 type guiRouter struct { 66 dataDir string 67 ctxt httpContext 68 pattern string 69 } 70 71 func guiEndpoints(pattern, dataDir string, ctxt httpContext) []apihttp.Endpoint { 72 gr := &guiRouter{ 73 dataDir: dataDir, 74 ctxt: ctxt, 75 pattern: pattern, 76 } 77 var endpoints []apihttp.Endpoint 78 add := func(pattern string, h func(*guiHandler, http.ResponseWriter, *http.Request)) { 79 handler := gr.ensureFileHandler(h) 80 // TODO: We can switch from all methods to specific ones for entries 81 // where we only want to support specific request methods. However, our 82 // tests currently assert that errors come back as application/json and 83 // pat only does "text/plain" responses. 84 for _, method := range common.DefaultHTTPMethods { 85 endpoints = append(endpoints, apihttp.Endpoint{ 86 Pattern: pattern, 87 Method: method, 88 Handler: handler, 89 }) 90 } 91 } 92 hashedPattern := pattern + ":hash" 93 add(hashedPattern+"/config.js", (*guiHandler).serveConfig) 94 add(hashedPattern+"/combo", (*guiHandler).serveCombo) 95 add(hashedPattern+"/static/", (*guiHandler).serveStatic) 96 // The index is served when all remaining URLs are requested, so that 97 // the single page JavaScript application can properly handles its routes. 98 add(pattern, (*guiHandler).serveIndex) 99 return endpoints 100 } 101 102 // ensureFileHandler decorates the given function to ensure the Juju GUI files 103 // are available on disk. 104 func (gr *guiRouter) ensureFileHandler(h func(gh *guiHandler, w http.ResponseWriter, req *http.Request)) http.Handler { 105 return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 106 rootDir, hash, err := gr.ensureFiles(req) 107 if err != nil { 108 // Note that ensureFiles also checks that the model UUID is valid. 109 if err := sendError(w, err); err != nil { 110 logger.Errorf("%v", err) 111 } 112 return 113 } 114 qhash := req.URL.Query().Get(":hash") 115 if qhash != "" && qhash != hash { 116 if err := sendError(w, errors.NotFoundf("resource with %q hash", qhash)); err != nil { 117 logger.Errorf("%v", err) 118 } 119 return 120 } 121 uuid := req.URL.Query().Get(":modeluuid") 122 gh := &guiHandler{ 123 rootDir: rootDir, 124 baseURLPath: strings.Replace(gr.pattern, ":modeluuid", uuid, -1), 125 hash: hash, 126 uuid: uuid, 127 } 128 h(gh, w, req) 129 }) 130 } 131 132 // ensureFiles checks that the GUI files are available on disk. 133 // If they are not, it means this is the first time this Juju GUI version is 134 // accessed. In this case, retrieve the Juju GUI archive from the storage and 135 // uncompress it to disk. This function returns the current GUI root directory 136 // and archive hash. 137 func (gr *guiRouter) ensureFiles(req *http.Request) (rootDir string, hash string, err error) { 138 // Retrieve the Juju GUI info from the GUI storage. 139 st, err := gr.ctxt.stateForRequestUnauthenticated(req) 140 if err != nil { 141 return "", "", errors.Annotate(err, "cannot open state") 142 } 143 storage, err := st.GUIStorage() 144 if err != nil { 145 return "", "", errors.Annotate(err, "cannot open GUI storage") 146 } 147 defer storage.Close() 148 vers, hash, err := guiVersionAndHash(st, storage) 149 if err != nil { 150 return "", "", errors.Trace(err) 151 } 152 logger.Debugf("serving Juju GUI version %s", vers) 153 154 // Check if the current Juju GUI archive has been already expanded on disk. 155 baseDir := agenttools.SharedGUIDir(gr.dataDir) 156 // Note that we include the hash in the root directory so that when the GUI 157 // archive changes we can be sure that clients will not use files from 158 // mixed versions. 159 rootDir = filepath.Join(baseDir, hash) 160 info, err := os.Stat(rootDir) 161 if err == nil { 162 if info.IsDir() { 163 return rootDir, hash, nil 164 } 165 return "", "", errors.Errorf("cannot use Juju GUI root directory %q: not a directory", rootDir) 166 } 167 if !os.IsNotExist(err) { 168 return "", "", errors.Annotate(err, "cannot stat Juju GUI root directory") 169 } 170 171 // Fetch the Juju GUI archive from the GUI storage and expand it. 172 _, r, err := storage.Open(vers) 173 if err != nil { 174 return "", "", errors.Annotatef(err, "cannot find GUI archive version %q", vers) 175 } 176 defer r.Close() 177 if err := os.MkdirAll(baseDir, 0755); err != nil { 178 return "", "", errors.Annotate(err, "cannot create Juju GUI base directory") 179 } 180 guiDir := "jujugui-" + vers + "/jujugui" 181 if err := uncompressGUI(r, guiDir, rootDir); err != nil { 182 return "", "", errors.Annotate(err, "cannot uncompress Juju GUI archive") 183 } 184 return rootDir, hash, nil 185 } 186 187 // guiVersionAndHash returns the version and the SHA256 hash of the current 188 // Juju GUI archive. 189 func guiVersionAndHash(st *state.State, storage binarystorage.Storage) (vers, hash string, err error) { 190 currentVers, err := st.GUIVersion() 191 if errors.IsNotFound(err) { 192 return "", "", errors.NotFoundf("Juju GUI") 193 } 194 if err != nil { 195 return "", "", errors.Annotate(err, "cannot retrieve current GUI version") 196 } 197 metadata, err := storage.Metadata(currentVers.String()) 198 if err != nil { 199 return "", "", errors.Annotate(err, "cannot retrieve GUI metadata") 200 } 201 return metadata.Version, metadata.SHA256, nil 202 } 203 204 // uncompressGUI uncompresses the tar.bz2 Juju GUI archive provided in r. 205 // The sourceDir directory included in the tar archive is copied to targetDir. 206 func uncompressGUI(r io.Reader, sourceDir, targetDir string) error { 207 tempDir, err := ioutil.TempDir("", "gui") 208 if err != nil { 209 return errors.Annotate(err, "cannot create Juju GUI temporary directory") 210 } 211 defer os.Remove(tempDir) 212 tr := tar.NewReader(bzip2.NewReader(r)) 213 for { 214 hdr, err := tr.Next() 215 if err == io.EOF { 216 break 217 } 218 if err != nil { 219 return errors.Annotate(err, "cannot parse archive") 220 } 221 if hdr.Name != sourceDir && !strings.HasPrefix(hdr.Name, sourceDir+"/") { 222 continue 223 } 224 path := filepath.Join(tempDir, hdr.Name) 225 info := hdr.FileInfo() 226 if info.IsDir() { 227 if err := os.MkdirAll(path, info.Mode()); err != nil { 228 return errors.Annotate(err, "cannot create directory") 229 } 230 continue 231 } 232 f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, info.Mode()) 233 if err != nil { 234 return errors.Annotate(err, "cannot open file") 235 } 236 defer f.Close() 237 if _, err := io.Copy(f, tr); err != nil { 238 return errors.Annotate(err, "cannot copy file content") 239 } 240 } 241 if err := os.Rename(filepath.Join(tempDir, sourceDir), targetDir); err != nil { 242 return errors.Annotate(err, "cannot rename Juju GUI root directory") 243 } 244 return nil 245 } 246 247 // guiHandler serves the Juju GUI. 248 type guiHandler struct { 249 baseURLPath string 250 rootDir string 251 hash string 252 uuid string 253 } 254 255 // serveStatic serves the GUI static files. 256 func (h *guiHandler) serveStatic(w http.ResponseWriter, req *http.Request) { 257 staticDir := filepath.Join(h.rootDir, "static") 258 fs := http.FileServer(http.Dir(staticDir)) 259 http.StripPrefix(h.hashedPath("static/"), fs).ServeHTTP(w, req) 260 } 261 262 // serveCombo serves the GUI JavaScript and CSS files, dynamically combined. 263 func (h *guiHandler) serveCombo(w http.ResponseWriter, req *http.Request) { 264 ctype := "" 265 // The combo query is like /combo/?path/to/file1&path/to/file2 ... 266 parts := strings.Split(req.URL.RawQuery, "&") 267 paths := make([]string, 0, len(parts)) 268 for _, p := range parts { 269 fpath, err := getGUIComboPath(h.rootDir, p) 270 if err != nil { 271 if err := sendError(w, errors.Annotate(err, "cannot combine files")); err != nil { 272 logger.Errorf("%v", err) 273 } 274 return 275 } 276 if fpath == "" { 277 continue 278 } 279 paths = append(paths, fpath) 280 // Assume the Juju GUI does not mix different content types when 281 // combining contents. 282 if ctype == "" { 283 ctype = mime.TypeByExtension(filepath.Ext(fpath)) 284 } 285 } 286 w.Header().Set("Content-Type", ctype) 287 for _, fpath := range paths { 288 sendGUIComboFile(w, fpath) 289 } 290 } 291 292 func getGUIComboPath(rootDir, query string) (string, error) { 293 k := strings.SplitN(query, "=", 2)[0] 294 fname, err := url.QueryUnescape(k) 295 if err != nil { 296 return "", errors.NewBadRequest(err, fmt.Sprintf("invalid file name %q", k)) 297 } 298 // Ignore pat injected queries. 299 if strings.HasPrefix(fname, ":") { 300 return "", nil 301 } 302 // The Juju GUI references its combined files starting from the 303 // "static/gui/build" directory. 304 fname = filepath.Clean(fname) 305 if fname == ".." || strings.HasPrefix(fname, "../") { 306 return "", errors.BadRequestf("forbidden file path %q", k) 307 } 308 return filepath.Join(rootDir, "static", "gui", "build", fname), nil 309 } 310 311 func sendGUIComboFile(w io.Writer, fpath string) { 312 f, err := os.Open(fpath) 313 if err != nil { 314 logger.Infof("cannot send combo file %q: %s", fpath, err) 315 return 316 } 317 defer f.Close() 318 if _, err := io.Copy(w, f); err != nil { 319 return 320 } 321 fmt.Fprintf(w, "\n/* %s */\n", filepath.Base(fpath)) 322 } 323 324 // serveIndex serves the GUI index file. 325 func (h *guiHandler) serveIndex(w http.ResponseWriter, req *http.Request) { 326 spriteFile := filepath.Join(h.rootDir, spritePath) 327 spriteContent, err := ioutil.ReadFile(spriteFile) 328 if err != nil { 329 if err := sendError(w, errors.Annotate(err, "cannot read sprite file")); err != nil { 330 logger.Errorf("%v", err) 331 } 332 return 333 } 334 tmpl := filepath.Join(h.rootDir, "templates", "index.html.go") 335 if err := renderGUITemplate(w, tmpl, map[string]interface{}{ 336 // staticURL holds the root of the static hierarchy, hence why the 337 // empty string is used here. 338 "staticURL": h.hashedPath(""), 339 "comboURL": h.hashedPath("combo"), 340 "configURL": h.hashedPath("config.js"), 341 // TODO frankban: make it possible to enable debug. 342 "debug": false, 343 "spriteContent": string(spriteContent), 344 }); err != nil { 345 if err := sendError(w, err); err != nil { 346 logger.Errorf("%v", errors.Annotate(err, "cannot send error to client from rendering GUI template")) 347 } 348 } 349 } 350 351 // serveConfig serves the Juju GUI JavaScript configuration file. 352 func (h *guiHandler) serveConfig(w http.ResponseWriter, req *http.Request) { 353 w.Header().Set("Content-Type", jsMimeType) 354 tmpl := filepath.Join(h.rootDir, "templates", "config.js.go") 355 if err := renderGUITemplate(w, tmpl, map[string]interface{}{ 356 "base": h.baseURLPath, 357 "host": req.Host, 358 "controllerSocket": "/api", 359 "socket": "/model/$uuid/api", 360 // staticURL holds the root of the static hierarchy, hence why the 361 // empty string is used here. 362 "staticURL": h.hashedPath(""), 363 "uuid": h.uuid, 364 "version": jujuversion.Current.String(), 365 }); err != nil { 366 if err := sendError(w, err); err != nil { 367 logger.Errorf("%v", errors.Annotate(err, "cannot send error to client from rendering GUI template")) 368 } 369 } 370 } 371 372 // hashedPath returns the gull path (including the GUI archive hash) to the 373 // given path, that must not start with a slash. 374 func (h *guiHandler) hashedPath(p string) string { 375 return path.Join(h.baseURLPath, h.hash, p) 376 } 377 378 func renderGUITemplate(w http.ResponseWriter, tmpl string, ctx map[string]interface{}) error { 379 // TODO frankban: cache parsed template. 380 t, err := template.ParseFiles(tmpl) 381 if err != nil { 382 return errors.Annotate(err, "cannot parse template") 383 } 384 return errors.Annotate(t.Execute(w, ctx), "cannot render template") 385 } 386 387 // guiArchiveHandler serves the Juju GUI archive endpoints, used for uploading 388 // and retrieving information about GUI archives. 389 type guiArchiveHandler struct { 390 ctxt httpContext 391 } 392 393 // ServeHTTP implements http.Handler. 394 func (h *guiArchiveHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { 395 var handler func(http.ResponseWriter, *http.Request) error 396 switch req.Method { 397 case "GET": 398 handler = h.handleGet 399 case "POST": 400 handler = h.handlePost 401 default: 402 if err := sendError(w, errors.MethodNotAllowedf("unsupported method: %q", req.Method)); err != nil { 403 logger.Errorf("%v", err) 404 } 405 return 406 } 407 if err := handler(w, req); err != nil { 408 if err := sendError(w, errors.Trace(err)); err != nil { 409 logger.Errorf("%v", err) 410 } 411 } 412 } 413 414 // handleGet returns information on Juju GUI archives in the controller. 415 func (h *guiArchiveHandler) handleGet(w http.ResponseWriter, req *http.Request) error { 416 // Open the GUI archive storage. 417 st, err := h.ctxt.stateForRequestUnauthenticated(req) 418 if err != nil { 419 return errors.Annotate(err, "cannot open state") 420 } 421 storage, err := st.GUIStorage() 422 if err != nil { 423 return errors.Annotate(err, "cannot open GUI storage") 424 } 425 defer storage.Close() 426 427 // Retrieve metadata information. 428 allMeta, err := storage.AllMetadata() 429 if err != nil { 430 return errors.Annotate(err, "cannot retrieve GUI metadata") 431 } 432 433 // Prepare and send the response. 434 var currentVersion string 435 vers, err := st.GUIVersion() 436 if err == nil { 437 currentVersion = vers.String() 438 } else if !errors.IsNotFound(err) { 439 return errors.Annotate(err, "cannot retrieve current GUI version") 440 } 441 versions := make([]params.GUIArchiveVersion, len(allMeta)) 442 for i, m := range allMeta { 443 vers, err := version.Parse(m.Version) 444 if err != nil { 445 return errors.Annotate(err, "cannot parse GUI version") 446 } 447 versions[i] = params.GUIArchiveVersion{ 448 Version: vers, 449 SHA256: m.SHA256, 450 Current: m.Version == currentVersion, 451 } 452 } 453 return errors.Trace(sendStatusAndJSON(w, http.StatusOK, params.GUIArchiveResponse{ 454 Versions: versions, 455 })) 456 } 457 458 // handlePost is used to upload new Juju GUI archives to the controller. 459 func (h *guiArchiveHandler) handlePost(w http.ResponseWriter, req *http.Request) error { 460 // Validate the request. 461 if ctype := req.Header.Get("Content-Type"); ctype != bzMimeType { 462 return errors.BadRequestf("invalid content type %q: expected %q", ctype, bzMimeType) 463 } 464 if err := req.ParseForm(); err != nil { 465 return errors.Annotate(err, "cannot parse form") 466 } 467 versParam := req.Form.Get("version") 468 if versParam == "" { 469 return errors.BadRequestf("version parameter not provided") 470 } 471 vers, err := version.Parse(versParam) 472 if err != nil { 473 return errors.BadRequestf("invalid version parameter %q", versParam) 474 } 475 hashParam := req.Form.Get("hash") 476 if hashParam == "" { 477 return errors.BadRequestf("hash parameter not provided") 478 } 479 if req.ContentLength == -1 { 480 return errors.BadRequestf("content length not provided") 481 } 482 483 // Open the GUI archive storage. 484 st, _, err := h.ctxt.stateForRequestAuthenticatedUser(req) 485 if err != nil { 486 return errors.Annotate(err, "cannot open state") 487 } 488 storage, err := st.GUIStorage() 489 if err != nil { 490 return errors.Annotate(err, "cannot open GUI storage") 491 } 492 defer storage.Close() 493 494 // Read and validate the archive data. 495 data, hash, err := readAndHash(req.Body) 496 size := int64(len(data)) 497 if size != req.ContentLength { 498 return errors.BadRequestf("archive does not match provided content length") 499 } 500 if hash != hashParam { 501 return errors.BadRequestf("archive does not match provided hash") 502 } 503 504 // Add the archive to the GUI storage. 505 metadata := binarystorage.Metadata{ 506 Version: vers.String(), 507 Size: size, 508 SHA256: hash, 509 } 510 if err := storage.Add(bytes.NewReader(data), metadata); err != nil { 511 return errors.Annotate(err, "cannot add GUI archive to storage") 512 } 513 514 // Prepare and return the response. 515 resp := params.GUIArchiveVersion{ 516 Version: vers, 517 SHA256: hash, 518 } 519 if currentVers, err := st.GUIVersion(); err == nil { 520 if currentVers == vers { 521 resp.Current = true 522 } 523 } else if !errors.IsNotFound(err) { 524 return errors.Annotate(err, "cannot retrieve current GUI version") 525 526 } 527 return errors.Trace(sendStatusAndJSON(w, http.StatusOK, resp)) 528 } 529 530 // guiVersionHandler is used to select the Juju GUI version served by the 531 // controller. The specified version must be available in the controller. 532 type guiVersionHandler struct { 533 ctxt httpContext 534 } 535 536 // ServeHTTP implements http.Handler. 537 func (h *guiVersionHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { 538 if req.Method != "PUT" { 539 if err := sendError(w, errors.MethodNotAllowedf("unsupported method: %q", req.Method)); err != nil { 540 logger.Errorf("%v", err) 541 } 542 return 543 } 544 if err := h.handlePut(w, req); err != nil { 545 if err := sendError(w, errors.Trace(err)); err != nil { 546 logger.Errorf("%v", err) 547 } 548 } 549 } 550 551 // handlePut is used to switch to a specific Juju GUI version. 552 func (h *guiVersionHandler) handlePut(w http.ResponseWriter, req *http.Request) error { 553 // Validate the request. 554 if ctype := req.Header.Get("Content-Type"); ctype != params.ContentTypeJSON { 555 return errors.BadRequestf("invalid content type %q: expected %q", ctype, params.ContentTypeJSON) 556 } 557 558 // Authenticate the request and retrieve the Juju state. 559 st, _, err := h.ctxt.stateForRequestAuthenticatedUser(req) 560 if err != nil { 561 return errors.Annotate(err, "cannot open state") 562 } 563 564 var selected params.GUIVersionRequest 565 decoder := json.NewDecoder(req.Body) 566 if err := decoder.Decode(&selected); err != nil { 567 return errors.NewBadRequest(err, "invalid request body") 568 } 569 570 // Switch to the provided GUI version. 571 if err = st.GUISetVersion(selected.Version); err != nil { 572 return errors.Trace(err) 573 } 574 return nil 575 }