github.com/juju/juju@v0.0.0-20240430160146-1752b71fcf00/apiserver/charms.go (about) 1 // Copyright 2013 Canonical Ltd. 2 // Licensed under the AGPLv3, see LICENCE file for details. 3 4 package apiserver 5 6 import ( 7 "archive/zip" 8 "bytes" 9 "crypto/sha256" 10 "encoding/hex" 11 "fmt" 12 "io" 13 "mime" 14 "net/http" 15 "os" 16 "path" 17 "path/filepath" 18 "sort" 19 "strconv" 20 "strings" 21 22 "github.com/juju/charm/v12" 23 "github.com/juju/errors" 24 ziputil "github.com/juju/utils/v3/zip" 25 26 "github.com/juju/juju/apiserver/common" 27 apiservererrors "github.com/juju/juju/apiserver/errors" 28 "github.com/juju/juju/apiserver/facades/client/charms/services" 29 "github.com/juju/juju/core/charm/downloader" 30 "github.com/juju/juju/rpc/params" 31 "github.com/juju/juju/state" 32 "github.com/juju/juju/state/storage" 33 ) 34 35 type FailableHandlerFunc func(http.ResponseWriter, *http.Request) error 36 37 // CharmsHTTPHandler creates is a http.Handler which serves POST 38 // requests to a PostHandler and GET requests to a GetHandler. 39 // 40 // TODO(katco): This is the beginning of inverting the dependencies in 41 // this callstack by splitting out the serving mechanism from the 42 // modules that are processing the requests. The next step is to 43 // publically expose construction of a suitable PostHandler and 44 // GetHandler whose goals should be clearly called out in their names, 45 // (e.g. charmPersitAPI for POSTs). 46 // 47 // To accomplish this, we'll have to make the httpContext type public 48 // so that we can pass it into these public functions. 49 // 50 // After we do this, we can then test the individual funcs/structs 51 // without standing up an entire HTTP server. I.e. actual unit 52 // tests. If you're in this area and can, please chisel away at this 53 // problem and update this TODO as needed! Many thanks, hacker! 54 // 55 // TODO(stickupkid): This handler is terrible, we could implement a middleware 56 // pattern to handle discreet logic and then pass it on to the next in the 57 // pipeline. 58 // 59 // As usual big methods lead to untestable code and it causes testing pain. 60 type CharmsHTTPHandler struct { 61 PostHandler FailableHandlerFunc 62 GetHandler FailableHandlerFunc 63 } 64 65 func (h *CharmsHTTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 66 var err error 67 switch r.Method { 68 case "POST": 69 err = errors.Annotate(h.PostHandler(w, r), "cannot upload charm") 70 case "GET": 71 err = errors.Annotate(h.GetHandler(w, r), "cannot retrieve charm") 72 default: 73 err = emitUnsupportedMethodErr(r.Method) 74 } 75 76 if err != nil { 77 if err := sendJSONError(w, r, errors.Trace(err)); err != nil { 78 logger.Errorf("%v", errors.Annotate(err, "cannot return error to user")) 79 } 80 } 81 } 82 83 // charmsHandler handles charm upload through HTTPS in the API server. 84 type charmsHandler struct { 85 ctxt httpContext 86 dataDir string 87 stateAuthFunc func(*http.Request) (*state.PooledState, error) 88 } 89 90 // bundleContentSenderFunc functions are responsible for sending a 91 // response related to a charm bundle. 92 type bundleContentSenderFunc func(w http.ResponseWriter, r *http.Request, bundle *charm.CharmArchive) error 93 94 func (h *charmsHandler) ServeUnsupported(w http.ResponseWriter, r *http.Request) error { 95 return errors.Trace(emitUnsupportedMethodErr(r.Method)) 96 } 97 98 func (h *charmsHandler) ServePost(w http.ResponseWriter, r *http.Request) error { 99 logger.Child("charmsHandler").Tracef("ServePost(%s)", r.URL) 100 if r.Method != "POST" { 101 return errors.Trace(emitUnsupportedMethodErr(r.Method)) 102 } 103 104 // Make sure the content type is zip. 105 contentType := r.Header.Get("Content-Type") 106 if contentType != "application/zip" { 107 return errors.BadRequestf("expected Content-Type: application/zip, got: %v", contentType) 108 } 109 110 st, err := h.stateAuthFunc(r) 111 if err != nil { 112 return errors.Trace(err) 113 } 114 defer st.Release() 115 116 // Add a charm to the store provider. 117 charmURL, err := h.processPost(r, st.State) 118 if err != nil { 119 return errors.NewBadRequest(err, "") 120 } 121 return errors.Trace(sendStatusAndHeadersAndJSON(w, http.StatusOK, map[string]string{"Juju-Curl": charmURL}, ¶ms.CharmsResponse{CharmURL: charmURL})) 122 } 123 124 func (h *charmsHandler) ServeGet(w http.ResponseWriter, r *http.Request) error { 125 logger.Child("charmsHandler").Tracef("ServeGet(%s)", r.URL) 126 if r.Method != "GET" { 127 return errors.Trace(emitUnsupportedMethodErr(r.Method)) 128 } 129 130 st, _, err := h.ctxt.stateForRequestAuthenticated(r) 131 if err != nil { 132 return errors.Trace(err) 133 } 134 defer st.Release() 135 136 // Retrieve or list charm files. 137 // Requires "url" (charm URL) and an optional "file" (the path to the 138 // charm file) to be included in the query. Optionally also receives an 139 // "icon" query for returning the charm icon or a default one in case the 140 // charm has no icon. 141 charmArchivePath, fileArg, serveIcon, err := h.processGet(r, st.State) 142 if err != nil { 143 // An error occurred retrieving the charm bundle. 144 if errors.IsNotFound(err) || errors.IsNotYetAvailable(err) { 145 return errors.Trace(err) 146 } 147 148 return errors.NewBadRequest(err, "") 149 } 150 defer os.Remove(charmArchivePath) 151 152 var sender bundleContentSenderFunc 153 switch fileArg { 154 case "": 155 // The client requested the list of charm files. 156 sender = h.manifestSender 157 case "*": 158 // The client requested the archive. 159 sender = h.archiveSender 160 default: 161 // The client requested a specific file. 162 sender = h.archiveEntrySender(fileArg, serveIcon) 163 } 164 165 return errors.Trace(sendBundleContent(w, r, charmArchivePath, sender)) 166 } 167 168 // manifestSender sends a JSON-encoded response to the client including the 169 // list of files contained in the charm bundle. 170 func (h *charmsHandler) manifestSender(w http.ResponseWriter, r *http.Request, bundle *charm.CharmArchive) error { 171 manifest, err := bundle.ArchiveMembers() 172 if err != nil { 173 return errors.Annotatef(err, "unable to read manifest in %q", bundle.Path) 174 } 175 return errors.Trace(sendStatusAndJSON(w, http.StatusOK, ¶ms.CharmsResponse{ 176 Files: manifest.SortedValues(), 177 })) 178 } 179 180 // archiveEntrySender returns a bundleContentSenderFunc which is responsible 181 // for sending the contents of filePath included in the given charm bundle. If 182 // filePath does not identify a file or a symlink, a 403 forbidden error is 183 // returned. If serveIcon is true, then the charm icon.svg file is sent, or a 184 // default icon if that file is not included in the charm. 185 func (h *charmsHandler) archiveEntrySender(filePath string, serveIcon bool) bundleContentSenderFunc { 186 return func(w http.ResponseWriter, r *http.Request, bundle *charm.CharmArchive) error { 187 contents, err := common.CharmArchiveEntry(bundle.Path, filePath, serveIcon) 188 if err != nil { 189 return errors.Trace(err) 190 } 191 ctype := mime.TypeByExtension(filepath.Ext(filePath)) 192 if ctype != "" { 193 // Older mime.types may map .js to x-javascript. 194 // Map it to javascript for consistency. 195 if ctype == params.ContentTypeXJS { 196 ctype = params.ContentTypeJS 197 } 198 w.Header().Set("Content-Type", ctype) 199 } 200 w.Header().Set("Content-Length", strconv.Itoa(len(contents))) 201 w.WriteHeader(http.StatusOK) 202 _, _ = io.Copy(w, bytes.NewReader(contents)) 203 return nil 204 } 205 } 206 207 // archiveSender is a bundleContentSenderFunc which is responsible for sending 208 // the contents of the given charm bundle. 209 func (h *charmsHandler) archiveSender(w http.ResponseWriter, r *http.Request, bundle *charm.CharmArchive) error { 210 // Note that http.ServeFile's error responses are not our standard JSON 211 // responses (they are the usual textual error messages as produced 212 // by http.Error), but there's not a great deal we can do about that, 213 // except accept non-JSON error responses in the client, because 214 // http.ServeFile does not provide a way of customizing its 215 // error responses. 216 http.ServeFile(w, r, bundle.Path) 217 return nil 218 } 219 220 // processPost handles a charm upload POST request after authentication. 221 func (h *charmsHandler) processPost(r *http.Request, st *state.State) (string, error) { 222 query := r.URL.Query() 223 schema := query.Get("schema") 224 if schema == "" { 225 schema = "local" 226 } 227 if schema != "local" { 228 // charmhub charms may only be uploaded into models 229 // which are being imported during model migrations. 230 // There's currently no other time where it makes sense 231 // to accept repository charms through this endpoint. 232 if isImporting, err := modelIsImporting(st); err != nil { 233 return "", errors.Trace(err) 234 } else if !isImporting { 235 return "", errors.New("charms may only be uploaded during model migration import") 236 } 237 } 238 239 charmFileName, err := writeCharmToTempFile(r.Body) 240 if err != nil { 241 return "", errors.Trace(err) 242 } 243 defer os.Remove(charmFileName) 244 245 err = h.processUploadedArchive(charmFileName) 246 if err != nil { 247 return "", err 248 } 249 archive, err := charm.ReadCharmArchive(charmFileName) 250 if err != nil { 251 return "", errors.BadRequestf("invalid charm archive: %v", err) 252 } 253 254 // Use the name from the query string. If we're dealing with an older client 255 // then this won't be sent, instead fallback to the archive metadata name. 256 name := query.Get("name") 257 if name == "" { 258 name = archive.Meta().Name 259 } 260 if err := charm.ValidateName(name); err != nil { 261 return "", errors.NewBadRequest(err, "") 262 } 263 264 var revision int 265 if revisionStr := query.Get("revision"); revisionStr != "" { 266 revision, err = strconv.Atoi(revisionStr) 267 if err != nil { 268 return "", errors.NewBadRequest(errors.NewNotValid(err, "revision"), "") 269 } 270 } else { 271 revision = archive.Revision() 272 } 273 274 // We got it, now let's reserve a charm URL for it in state. 275 curlStr := curlString(schema, query.Get("arch"), name, query.Get("series"), revision) 276 277 switch charm.Schema(schema) { 278 case charm.Local: 279 curl, err := st.PrepareLocalCharmUpload(curlStr) 280 if err != nil { 281 return "", errors.Trace(err) 282 } 283 curlStr = curl.String() 284 285 case charm.CharmHub: 286 if _, err := st.PrepareCharmUpload(curlStr); err != nil { 287 return "", errors.Trace(err) 288 } 289 290 default: 291 return "", errors.Errorf("unsupported schema %q", schema) 292 } 293 294 err = RepackageAndUploadCharm(st, archive, curlStr, revision) 295 if err != nil { 296 return "", errors.Trace(err) 297 } 298 return curlStr, nil 299 } 300 301 // curlString takes the constituent parts of a charm url and renders the url as a string. 302 // This is required since, to support migrations from legacy controllers, we need to support 303 // charm urls with series since controllers do not allow migrations to mutate charm urls during 304 // migration. 305 // 306 // This is the only place in Juju 4 where series in a charm url needs to be processed. As such, 307 // instead of dragging support for series with us into 4.0, in this one place we string-hack the 308 // url 309 func curlString(schema, arch, name, series string, revision int) string { 310 if series == "" { 311 curl := &charm.URL{ 312 Schema: schema, 313 Architecture: arch, 314 Name: name, 315 Revision: revision, 316 } 317 return curl.String() 318 } 319 var curl string 320 if arch == "" { 321 curl = fmt.Sprintf("%s:%s/%s", schema, series, name) 322 } else { 323 curl = fmt.Sprintf("%s:%s/%s/%s", schema, arch, series, name) 324 } 325 if revision != -1 { 326 curl = fmt.Sprintf("%s-%d", curl, revision) 327 } 328 return curl 329 } 330 331 // processUploadedArchive opens the given charm archive from path, 332 // inspects it to see if it has all files at the root of the archive 333 // or it has subdirs. It repackages the archive so it has all the 334 // files at the root dir, if necessary, replacing the original archive 335 // at path. 336 func (h *charmsHandler) processUploadedArchive(path string) error { 337 // Open the archive as a zip. 338 f, err := os.OpenFile(path, os.O_RDWR, 0644) 339 if err != nil { 340 return err 341 } 342 defer f.Close() 343 fi, err := f.Stat() 344 if err != nil { 345 return err 346 } 347 zipr, err := zip.NewReader(f, fi.Size()) 348 if err != nil { 349 return errors.Annotate(err, "cannot open charm archive") 350 } 351 352 // Find out the root dir prefix from the archive. 353 rootDir, err := h.findArchiveRootDir(zipr) 354 if err != nil { 355 return errors.Annotate(err, "cannot read charm archive") 356 } 357 if rootDir == "." { 358 // Normal charm, just use charm.ReadCharmArchive). 359 return nil 360 } 361 362 // There is one or more subdirs, so we need extract it to a temp 363 // dir and then read it as a charm dir. 364 tempDir, err := os.MkdirTemp("", "charm-extract") 365 if err != nil { 366 return errors.Annotate(err, "cannot create temp directory") 367 } 368 defer os.RemoveAll(tempDir) 369 if err := ziputil.Extract(zipr, tempDir, rootDir); err != nil { 370 return errors.Annotate(err, "cannot extract charm archive") 371 } 372 dir, err := charm.ReadCharmDir(tempDir) 373 if err != nil { 374 return errors.Annotate(err, "cannot read extracted archive") 375 } 376 377 // Now repackage the dir as a bundle at the original path. 378 if err := f.Truncate(0); err != nil { 379 return err 380 } 381 if err := dir.ArchiveTo(f); err != nil { 382 return err 383 } 384 return nil 385 } 386 387 // findArchiveRootDir scans a zip archive and returns the rootDir of 388 // the archive, the one containing metadata.yaml, config.yaml and 389 // revision files, or an error if the archive appears invalid. 390 func (h *charmsHandler) findArchiveRootDir(zipr *zip.Reader) (string, error) { 391 paths, err := ziputil.Find(zipr, "metadata.yaml") 392 if err != nil { 393 return "", err 394 } 395 switch len(paths) { 396 case 0: 397 return "", errors.Errorf("invalid charm archive: missing metadata.yaml") 398 case 1: 399 default: 400 sort.Sort(byDepth(paths)) 401 if depth(paths[0]) == depth(paths[1]) { 402 return "", errors.Errorf("invalid charm archive: ambiguous root directory") 403 } 404 } 405 return filepath.Dir(paths[0]), nil 406 } 407 408 func depth(path string) int { 409 return strings.Count(path, "/") 410 } 411 412 type byDepth []string 413 414 func (d byDepth) Len() int { return len(d) } 415 func (d byDepth) Swap(i, j int) { d[i], d[j] = d[j], d[i] } 416 func (d byDepth) Less(i, j int) bool { return depth(d[i]) < depth(d[j]) } 417 418 // RepackageAndUploadCharm expands the given charm archive to a 419 // temporary directory, repackages it with the given curl's revision, 420 // then uploads it to storage, and finally updates the state. 421 func RepackageAndUploadCharm(st *state.State, archive *charm.CharmArchive, curl string, charmRevision int) error { 422 // Create a temp dir to contain the extracted charm dir. 423 tempDir, err := os.MkdirTemp("", "charm-download") 424 if err != nil { 425 return errors.Annotate(err, "cannot create temp directory") 426 } 427 defer os.RemoveAll(tempDir) 428 extractPath := filepath.Join(tempDir, "extracted") 429 430 // Expand and repack it with the specified revision 431 archive.SetRevision(charmRevision) 432 if err := archive.ExpandTo(extractPath); err != nil { 433 return errors.Annotate(err, "cannot extract uploaded charm") 434 } 435 436 charmDir, err := charm.ReadCharmDir(extractPath) 437 if err != nil { 438 return errors.Annotate(err, "cannot read extracted charm") 439 } 440 441 // Try to get the version details here. 442 // read just the first line of the file. 443 var version string 444 versionPath := filepath.Join(extractPath, "version") 445 if file, err := os.Open(versionPath); err == nil { 446 version, err = charm.ReadVersion(file) 447 _ = file.Close() 448 if err != nil { 449 return errors.Trace(err) 450 } 451 } else if !os.IsNotExist(err) { 452 return errors.Annotate(err, "cannot open version file") 453 } 454 455 // Bundle the charm and calculate its sha256 hash at the same time. 456 var repackagedArchive bytes.Buffer 457 hash := sha256.New() 458 err = charmDir.ArchiveTo(io.MultiWriter(hash, &repackagedArchive)) 459 if err != nil { 460 return errors.Annotate(err, "cannot repackage uploaded charm") 461 } 462 bundleSHA256 := hex.EncodeToString(hash.Sum(nil)) 463 464 // Now we need to repackage it with the reserved URL, upload it to 465 // provider storage and update the state. 466 charmStorage := services.NewCharmStorage(services.CharmStorageConfig{ 467 Logger: logger, 468 StateBackend: storageStateShim{st}, 469 StorageFactory: func(modelUUID string) services.Storage { 470 return storage.NewStorage(modelUUID, st.MongoSession()) 471 }, 472 }) 473 474 return charmStorage.Store(curl, downloader.DownloadedCharm{ 475 Charm: archive, 476 CharmData: &repackagedArchive, 477 CharmVersion: version, 478 Size: int64(repackagedArchive.Len()), 479 SHA256: bundleSHA256, 480 LXDProfile: charmDir.LXDProfile(), 481 }) 482 } 483 484 type storageStateShim struct { 485 *state.State 486 } 487 488 func (s storageStateShim) UpdateUploadedCharm(info state.CharmInfo) (services.UploadedCharm, error) { 489 ch, err := s.State.UpdateUploadedCharm(info) 490 return ch, err 491 } 492 493 func (s storageStateShim) PrepareCharmUpload(curl string) (services.UploadedCharm, error) { 494 ch, err := s.State.PrepareCharmUpload(curl) 495 return ch, err 496 } 497 498 // processGet handles a charm file GET request after authentication. 499 // It returns the bundle path, the requested file path (if any), whether the 500 // default charm icon has been requested and an error. 501 func (h *charmsHandler) processGet(r *http.Request, st *state.State) ( 502 archivePath string, 503 fileArg string, 504 serveIcon bool, 505 err error, 506 ) { 507 errRet := func(err error) (string, string, bool, error) { 508 return "", "", false, err 509 } 510 511 query := r.URL.Query() 512 513 // Retrieve and validate query parameters. 514 curl := query.Get("url") 515 if curl == "" { 516 return errRet(errors.Errorf("expected url=CharmURL query argument")) 517 } 518 fileArg = query.Get("file") 519 if fileArg != "" { 520 fileArg = path.Clean(fileArg) 521 } else if query.Get("icon") == "1" { 522 serveIcon = true 523 fileArg = "icon.svg" 524 } 525 526 store := storage.NewStorage(st.ModelUUID(), st.MongoSession()) 527 // Use the storage to retrieve and save the charm archive. 528 ch, err := st.Charm(curl) 529 if err != nil { 530 return errRet(errors.Annotate(err, "cannot get charm from state")) 531 } 532 // Check if the charm is still pending to be downloaded and return back 533 // a suitable error. 534 if !ch.IsUploaded() { 535 return errRet(errors.NewNotYetAvailable(nil, curl)) 536 } 537 538 archivePath, err = common.ReadCharmFromStorage(store, h.dataDir, ch.StoragePath()) 539 if err != nil { 540 return errRet(errors.Annotatef(err, "cannot read charm %q from storage", curl)) 541 } 542 return archivePath, fileArg, serveIcon, nil 543 } 544 545 // sendJSONError sends a JSON-encoded error response. Note the 546 // difference from the error response sent by the sendError function - 547 // the error is encoded in the Error field as a string, not an Error 548 // object. 549 func sendJSONError(w http.ResponseWriter, req *http.Request, err error) error { 550 if errors.IsNotYetAvailable(err) { 551 // This error is typically raised when trying to fetch the blob 552 // contents for a charm which is still pending to be downloaded. 553 // 554 // We should log this at debug level to avoid unnecessary noise 555 // in the logs. 556 logger.Debugf("returning error from %s %s: %s", req.Method, req.URL, errors.Details(err)) 557 } else { 558 logger.Errorf("returning error from %s %s: %s", req.Method, req.URL, errors.Details(err)) 559 } 560 561 perr, status := apiservererrors.ServerErrorAndStatus(err) 562 return errors.Trace(sendStatusAndJSON(w, status, ¶ms.CharmsResponse{ 563 Error: perr.Message, 564 ErrorCode: perr.Code, 565 ErrorInfo: perr.Info, 566 })) 567 } 568 569 // sendBundleContent uses the given bundleContentSenderFunc to send a 570 // response related to the charm archive located in the given 571 // archivePath. 572 func sendBundleContent( 573 w http.ResponseWriter, 574 r *http.Request, 575 archivePath string, 576 sender bundleContentSenderFunc, 577 ) error { 578 logger.Child("charmhttp").Tracef("sendBundleContent %q", archivePath) 579 bundle, err := charm.ReadCharmArchive(archivePath) 580 if err != nil { 581 return errors.Annotatef(err, "unable to read archive in %q", archivePath) 582 } 583 // The bundleContentSenderFunc will set up and send an appropriate response. 584 if err := sender(w, r, bundle); err != nil { 585 return errors.Trace(err) 586 } 587 return nil 588 } 589 590 func writeCharmToTempFile(r io.Reader) (string, error) { 591 tempFile, err := os.CreateTemp("", "charm") 592 if err != nil { 593 return "", errors.Annotate(err, "creating temp file") 594 } 595 defer tempFile.Close() 596 597 if _, err := io.Copy(tempFile, r); err != nil { 598 return "", errors.Annotate(err, "processing upload") 599 } 600 return tempFile.Name(), nil 601 } 602 603 func modelIsImporting(st *state.State) (bool, error) { 604 model, err := st.Model() 605 if err != nil { 606 return false, errors.Trace(err) 607 } 608 return model.MigrationMode() == state.MigrationModeImporting, nil 609 } 610 611 func emitUnsupportedMethodErr(method string) error { 612 return errors.MethodNotAllowedf("unsupported method: %q", method) 613 }