github.com/makyo/juju@v0.0.0-20160425123129-2608902037e9/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 // "staticURL", "uuid" and "version". It might receive more variables but 64 // 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 sendError(w, err) 110 return 111 } 112 qhash := req.URL.Query().Get(":hash") 113 if qhash != "" && qhash != hash { 114 sendError(w, errors.NotFoundf("resource with %q hash", qhash)) 115 return 116 } 117 uuid := req.URL.Query().Get(":modeluuid") 118 gh := &guiHandler{ 119 rootDir: rootDir, 120 baseURLPath: strings.Replace(gr.pattern, ":modeluuid", uuid, -1), 121 hash: hash, 122 uuid: uuid, 123 } 124 h(gh, w, req) 125 }) 126 } 127 128 // ensureFiles checks that the GUI files are available on disk. 129 // If they are not, it means this is the first time this Juju GUI version is 130 // accessed. In this case, retrieve the Juju GUI archive from the storage and 131 // uncompress it to disk. This function returns the current GUI root directory 132 // and archive hash. 133 func (gr *guiRouter) ensureFiles(req *http.Request) (rootDir string, hash string, err error) { 134 // Retrieve the Juju GUI info from the GUI storage. 135 st, err := gr.ctxt.stateForRequestUnauthenticated(req) 136 if err != nil { 137 return "", "", errors.Annotate(err, "cannot open state") 138 } 139 storage, err := st.GUIStorage() 140 if err != nil { 141 return "", "", errors.Annotate(err, "cannot open GUI storage") 142 } 143 defer storage.Close() 144 vers, hash, err := guiVersionAndHash(st, storage) 145 if err != nil { 146 return "", "", errors.Trace(err) 147 } 148 logger.Debugf("serving Juju GUI version %s", vers) 149 150 // Check if the current Juju GUI archive has been already expanded on disk. 151 baseDir := agenttools.SharedGUIDir(gr.dataDir) 152 // Note that we include the hash in the root directory so that when the GUI 153 // archive changes we can be sure that clients will not use files from 154 // mixed versions. 155 rootDir = filepath.Join(baseDir, hash) 156 info, err := os.Stat(rootDir) 157 if err == nil { 158 if info.IsDir() { 159 return rootDir, hash, nil 160 } 161 return "", "", errors.Errorf("cannot use Juju GUI root directory %q: not a directory", rootDir) 162 } 163 if !os.IsNotExist(err) { 164 return "", "", errors.Annotate(err, "cannot stat Juju GUI root directory") 165 } 166 167 // Fetch the Juju GUI archive from the GUI storage and expand it. 168 _, r, err := storage.Open(vers) 169 if err != nil { 170 return "", "", errors.Annotatef(err, "cannot find GUI archive version %q", vers) 171 } 172 defer r.Close() 173 if err := os.MkdirAll(baseDir, 0755); err != nil { 174 return "", "", errors.Annotate(err, "cannot create Juju GUI base directory") 175 } 176 guiDir := "jujugui-" + vers + "/jujugui" 177 if err := uncompressGUI(r, guiDir, rootDir); err != nil { 178 return "", "", errors.Annotate(err, "cannot uncompress Juju GUI archive") 179 } 180 return rootDir, hash, nil 181 } 182 183 // guiVersionAndHash returns the version and the SHA256 hash of the current 184 // Juju GUI archive. 185 func guiVersionAndHash(st *state.State, storage binarystorage.Storage) (vers, hash string, err error) { 186 currentVers, err := st.GUIVersion() 187 if errors.IsNotFound(err) { 188 return "", "", errors.NotFoundf("Juju GUI") 189 } 190 if err != nil { 191 return "", "", errors.Annotate(err, "cannot retrieve current GUI version") 192 } 193 metadata, err := storage.Metadata(currentVers.String()) 194 if err != nil { 195 return "", "", errors.Annotate(err, "cannot retrieve GUI metadata") 196 } 197 return metadata.Version, metadata.SHA256, nil 198 } 199 200 // uncompressGUI uncompresses the tar.bz2 Juju GUI archive provided in r. 201 // The sourceDir directory included in the tar archive is copied to targetDir. 202 func uncompressGUI(r io.Reader, sourceDir, targetDir string) error { 203 tempDir, err := ioutil.TempDir("", "gui") 204 if err != nil { 205 return errors.Annotate(err, "cannot create Juju GUI temporary directory") 206 } 207 defer os.Remove(tempDir) 208 tr := tar.NewReader(bzip2.NewReader(r)) 209 for { 210 hdr, err := tr.Next() 211 if err == io.EOF { 212 break 213 } 214 if err != nil { 215 return errors.Annotate(err, "cannot parse archive") 216 } 217 if hdr.Name != sourceDir && !strings.HasPrefix(hdr.Name, sourceDir+"/") { 218 continue 219 } 220 path := filepath.Join(tempDir, hdr.Name) 221 info := hdr.FileInfo() 222 if info.IsDir() { 223 if err := os.MkdirAll(path, info.Mode()); err != nil { 224 return errors.Annotate(err, "cannot create directory") 225 } 226 continue 227 } 228 f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, info.Mode()) 229 if err != nil { 230 return errors.Annotate(err, "cannot open file") 231 } 232 defer f.Close() 233 if _, err := io.Copy(f, tr); err != nil { 234 return errors.Annotate(err, "cannot copy file content") 235 } 236 } 237 if err := os.Rename(filepath.Join(tempDir, sourceDir), targetDir); err != nil { 238 return errors.Annotate(err, "cannot rename Juju GUI root directory") 239 } 240 return nil 241 } 242 243 // guiHandler serves the Juju GUI. 244 type guiHandler struct { 245 baseURLPath string 246 rootDir string 247 hash string 248 uuid string 249 } 250 251 // serveStatic serves the GUI static files. 252 func (h *guiHandler) serveStatic(w http.ResponseWriter, req *http.Request) { 253 staticDir := filepath.Join(h.rootDir, "static") 254 fs := http.FileServer(http.Dir(staticDir)) 255 http.StripPrefix(h.hashedPath("static/"), fs).ServeHTTP(w, req) 256 } 257 258 // serveCombo serves the GUI JavaScript and CSS files, dynamically combined. 259 func (h *guiHandler) serveCombo(w http.ResponseWriter, req *http.Request) { 260 ctype := "" 261 // The combo query is like /combo/?path/to/file1&path/to/file2 ... 262 parts := strings.Split(req.URL.RawQuery, "&") 263 paths := make([]string, 0, len(parts)) 264 for _, p := range parts { 265 fpath, err := getGUIComboPath(h.rootDir, p) 266 if err != nil { 267 sendError(w, errors.Annotate(err, "cannot combine files")) 268 return 269 } 270 if fpath == "" { 271 continue 272 } 273 paths = append(paths, fpath) 274 // Assume the Juju GUI does not mix different content types when 275 // combining contents. 276 if ctype == "" { 277 ctype = mime.TypeByExtension(filepath.Ext(fpath)) 278 } 279 } 280 w.Header().Set("Content-Type", ctype) 281 for _, fpath := range paths { 282 sendGUIComboFile(w, fpath) 283 } 284 } 285 286 func getGUIComboPath(rootDir, query string) (string, error) { 287 k := strings.SplitN(query, "=", 2)[0] 288 fname, err := url.QueryUnescape(k) 289 if err != nil { 290 return "", errors.NewBadRequest(err, fmt.Sprintf("invalid file name %q", k)) 291 } 292 // Ignore pat injected queries. 293 if strings.HasPrefix(fname, ":") { 294 return "", nil 295 } 296 // The Juju GUI references its combined files starting from the 297 // "static/gui/build" directory. 298 fname = filepath.Clean(fname) 299 if fname == ".." || strings.HasPrefix(fname, "../") { 300 return "", errors.BadRequestf("forbidden file path %q", k) 301 } 302 return filepath.Join(rootDir, "static", "gui", "build", fname), nil 303 } 304 305 func sendGUIComboFile(w io.Writer, fpath string) { 306 f, err := os.Open(fpath) 307 if err != nil { 308 logger.Infof("cannot send combo file %q: %s", fpath, err) 309 return 310 } 311 defer f.Close() 312 if _, err := io.Copy(w, f); err != nil { 313 return 314 } 315 fmt.Fprintf(w, "\n/* %s */\n", filepath.Base(fpath)) 316 } 317 318 // serveIndex serves the GUI index file. 319 func (h *guiHandler) serveIndex(w http.ResponseWriter, req *http.Request) { 320 spriteFile := filepath.Join(h.rootDir, spritePath) 321 spriteContent, err := ioutil.ReadFile(spriteFile) 322 if err != nil { 323 sendError(w, errors.Annotate(err, "cannot read sprite file")) 324 return 325 } 326 tmpl := filepath.Join(h.rootDir, "templates", "index.html.go") 327 renderGUITemplate(w, tmpl, map[string]interface{}{ 328 // staticURL holds the root of the static hierarchy, hence why the 329 // empty string is used here. 330 "staticURL": h.hashedPath(""), 331 "comboURL": h.hashedPath("combo"), 332 "configURL": h.hashedPath("config.js"), 333 // TODO frankban: make it possible to enable debug. 334 "debug": false, 335 "spriteContent": string(spriteContent), 336 }) 337 } 338 339 // serveConfig serves the Juju GUI JavaScript configuration file. 340 func (h *guiHandler) serveConfig(w http.ResponseWriter, req *http.Request) { 341 w.Header().Set("Content-Type", jsMimeType) 342 tmpl := filepath.Join(h.rootDir, "templates", "config.js.go") 343 renderGUITemplate(w, tmpl, map[string]interface{}{ 344 "base": h.baseURLPath, 345 "host": req.Host, 346 "socket": "/model/$uuid/api", 347 // staticURL holds the root of the static hierarchy, hence why the 348 // empty string is used here. 349 "staticURL": h.hashedPath(""), 350 "uuid": h.uuid, 351 "version": jujuversion.Current.String(), 352 }) 353 } 354 355 // hashedPath returns the gull path (including the GUI archive hash) to the 356 // given path, that must not start with a slash. 357 func (h *guiHandler) hashedPath(p string) string { 358 return path.Join(h.baseURLPath, h.hash, p) 359 } 360 361 func renderGUITemplate(w http.ResponseWriter, tmpl string, ctx map[string]interface{}) { 362 // TODO frankban: cache parsed template. 363 t, err := template.ParseFiles(tmpl) 364 if err != nil { 365 sendError(w, errors.Annotate(err, "cannot parse template")) 366 return 367 } 368 if err := t.Execute(w, ctx); err != nil { 369 sendError(w, errors.Annotate(err, "cannot render template")) 370 } 371 } 372 373 // guiArchiveHandler serves the Juju GUI archive endpoints, used for uploading 374 // and retrieving information about GUI archives. 375 type guiArchiveHandler struct { 376 ctxt httpContext 377 } 378 379 // ServeHTTP implements http.Handler. 380 func (h *guiArchiveHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { 381 var handler func(http.ResponseWriter, *http.Request) error 382 switch req.Method { 383 case "GET": 384 handler = h.handleGet 385 case "POST": 386 handler = h.handlePost 387 default: 388 sendError(w, errors.MethodNotAllowedf("unsupported method: %q", req.Method)) 389 return 390 } 391 if err := handler(w, req); err != nil { 392 sendError(w, errors.Trace(err)) 393 } 394 } 395 396 // handleGet returns information on Juju GUI archives in the controller. 397 func (h *guiArchiveHandler) handleGet(w http.ResponseWriter, req *http.Request) error { 398 // Open the GUI archive storage. 399 st, err := h.ctxt.stateForRequestUnauthenticated(req) 400 if err != nil { 401 return errors.Annotate(err, "cannot open state") 402 } 403 storage, err := st.GUIStorage() 404 if err != nil { 405 return errors.Annotate(err, "cannot open GUI storage") 406 } 407 defer storage.Close() 408 409 // Retrieve metadata information. 410 allMeta, err := storage.AllMetadata() 411 if err != nil { 412 return errors.Annotate(err, "cannot retrieve GUI metadata") 413 } 414 415 // Prepare and send the response. 416 var currentVersion string 417 vers, err := st.GUIVersion() 418 if err == nil { 419 currentVersion = vers.String() 420 } else if !errors.IsNotFound(err) { 421 return errors.Annotate(err, "cannot retrieve current GUI version") 422 } 423 versions := make([]params.GUIArchiveVersion, len(allMeta)) 424 for i, m := range allMeta { 425 vers, err := version.Parse(m.Version) 426 if err != nil { 427 return errors.Annotate(err, "cannot parse GUI version") 428 } 429 versions[i] = params.GUIArchiveVersion{ 430 Version: vers, 431 SHA256: m.SHA256, 432 Current: m.Version == currentVersion, 433 } 434 } 435 sendStatusAndJSON(w, http.StatusOK, params.GUIArchiveResponse{ 436 Versions: versions, 437 }) 438 return nil 439 } 440 441 // handlePost is used to upload new Juju GUI archives to the controller. 442 func (h *guiArchiveHandler) handlePost(w http.ResponseWriter, req *http.Request) error { 443 // Validate the request. 444 if ctype := req.Header.Get("Content-Type"); ctype != bzMimeType { 445 return errors.BadRequestf("invalid content type %q: expected %q", ctype, bzMimeType) 446 } 447 if err := req.ParseForm(); err != nil { 448 return errors.Annotate(err, "cannot parse form") 449 } 450 versParam := req.Form.Get("version") 451 if versParam == "" { 452 return errors.BadRequestf("version parameter not provided") 453 } 454 vers, err := version.Parse(versParam) 455 if err != nil { 456 return errors.BadRequestf("invalid version parameter %q", versParam) 457 } 458 hashParam := req.Form.Get("hash") 459 if hashParam == "" { 460 return errors.BadRequestf("hash parameter not provided") 461 } 462 if req.ContentLength == -1 { 463 return errors.BadRequestf("content length not provided") 464 } 465 466 // Open the GUI archive storage. 467 st, _, err := h.ctxt.stateForRequestAuthenticatedUser(req) 468 if err != nil { 469 return errors.Annotate(err, "cannot open state") 470 } 471 storage, err := st.GUIStorage() 472 if err != nil { 473 return errors.Annotate(err, "cannot open GUI storage") 474 } 475 defer storage.Close() 476 477 // Read and validate the archive data. 478 data, hash, err := readAndHash(req.Body) 479 size := int64(len(data)) 480 if size != req.ContentLength { 481 return errors.BadRequestf("archive does not match provided content length") 482 } 483 if hash != hashParam { 484 return errors.BadRequestf("archive does not match provided hash") 485 } 486 487 // Add the archive to the GUI storage. 488 metadata := binarystorage.Metadata{ 489 Version: vers.String(), 490 Size: size, 491 SHA256: hash, 492 } 493 if err := storage.Add(bytes.NewReader(data), metadata); err != nil { 494 return errors.Annotate(err, "cannot add GUI archive to storage") 495 } 496 497 // Prepare and return the response. 498 resp := params.GUIArchiveVersion{ 499 Version: vers, 500 SHA256: hash, 501 } 502 if currentVers, err := st.GUIVersion(); err == nil { 503 if currentVers == vers { 504 resp.Current = true 505 } 506 } else if !errors.IsNotFound(err) { 507 return errors.Annotate(err, "cannot retrieve current GUI version") 508 } 509 sendStatusAndJSON(w, http.StatusOK, resp) 510 return nil 511 } 512 513 // guiVersionHandler is used to select the Juju GUI version served by the 514 // controller. The specified version must be available in the controller. 515 type guiVersionHandler struct { 516 ctxt httpContext 517 } 518 519 // ServeHTTP implements http.Handler. 520 func (h *guiVersionHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { 521 if req.Method != "PUT" { 522 sendError(w, errors.MethodNotAllowedf("unsupported method: %q", req.Method)) 523 return 524 } 525 if err := h.handlePut(w, req); err != nil { 526 sendError(w, errors.Trace(err)) 527 } 528 } 529 530 // handlePut is used to switch to a specific Juju GUI version. 531 func (h *guiVersionHandler) handlePut(w http.ResponseWriter, req *http.Request) error { 532 // Validate the request. 533 if ctype := req.Header.Get("Content-Type"); ctype != params.ContentTypeJSON { 534 return errors.BadRequestf("invalid content type %q: expected %q", ctype, params.ContentTypeJSON) 535 } 536 537 // Authenticate the request and retrieve the Juju state. 538 st, _, err := h.ctxt.stateForRequestAuthenticatedUser(req) 539 if err != nil { 540 return errors.Annotate(err, "cannot open state") 541 } 542 543 var selected params.GUIVersionRequest 544 decoder := json.NewDecoder(req.Body) 545 if err := decoder.Decode(&selected); err != nil { 546 return errors.NewBadRequest(err, "invalid request body") 547 } 548 549 // Switch to the provided GUI version. 550 if err = st.GUISetVersion(selected.Version); err != nil { 551 return errors.Trace(err) 552 } 553 return nil 554 }