github.com/juju/juju@v0.0.0-20240430160146-1752b71fcf00/apiserver/tools.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 "bytes" 8 "context" 9 "crypto/sha256" 10 "fmt" 11 "io" 12 "net/http" 13 "net/url" 14 "os" 15 "strconv" 16 "strings" 17 18 "github.com/im7mortal/kmutex" 19 "github.com/juju/errors" 20 jujuhttp "github.com/juju/http/v2" 21 "github.com/juju/version/v2" 22 23 "github.com/juju/juju/apiserver/common" 24 "github.com/juju/juju/apiserver/httpcontext" 25 corebase "github.com/juju/juju/core/base" 26 "github.com/juju/juju/core/os/ostype" 27 "github.com/juju/juju/environs" 28 "github.com/juju/juju/environs/simplestreams" 29 envtools "github.com/juju/juju/environs/tools" 30 "github.com/juju/juju/rpc/params" 31 "github.com/juju/juju/state" 32 "github.com/juju/juju/state/binarystorage" 33 "github.com/juju/juju/state/stateenvirons" 34 "github.com/juju/juju/tools" 35 ) 36 37 // toolsReadCloser wraps the ReadCloser for the tools blob 38 // and the state StorageCloser. 39 // It allows us to stream the tools binary from state, 40 // closing them both at once when done. 41 type toolsReadCloser struct { 42 f io.ReadCloser 43 st binarystorage.StorageCloser 44 } 45 46 func (t *toolsReadCloser) Read(p []byte) (n int, err error) { 47 return t.f.Read(p) 48 } 49 50 func (t *toolsReadCloser) Close() error { 51 var err error 52 if err = t.f.Close(); err == nil { 53 return t.st.Close() 54 } 55 if err2 := t.st.Close(); err2 != nil { 56 err = errors.Wrap(err, err2) 57 } 58 return err 59 } 60 61 // toolsHandler handles tool upload through HTTPS in the API server. 62 type toolsUploadHandler struct { 63 ctxt httpContext 64 stateAuthFunc func(*http.Request) (*state.PooledState, error) 65 } 66 67 // toolsHandler handles tool download through HTTPS in the API server. 68 type toolsDownloadHandler struct { 69 ctxt httpContext 70 fetchMutex *kmutex.Kmutex 71 } 72 73 func newToolsDownloadHandler(httpCtxt httpContext) *toolsDownloadHandler { 74 return &toolsDownloadHandler{ 75 ctxt: httpCtxt, 76 fetchMutex: kmutex.New(), 77 } 78 } 79 80 func (h *toolsDownloadHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 81 st, err := h.ctxt.stateForRequestUnauthenticated(r) 82 if err != nil { 83 if err := sendError(w, err); err != nil { 84 logger.Errorf("%v", err) 85 } 86 return 87 } 88 defer st.Release() 89 90 switch r.Method { 91 case "GET": 92 reader, size, err := h.getToolsForRequest(r, st.State) 93 if err != nil { 94 logger.Errorf("GET(%s) failed: %v", r.URL, err) 95 if err := sendError(w, errors.NewBadRequest(err, "")); err != nil { 96 logger.Errorf("%v", err) 97 } 98 return 99 } 100 defer reader.Close() 101 if err := h.sendTools(w, reader, size); err != nil { 102 logger.Errorf("%v", err) 103 } 104 default: 105 if err := sendError(w, errors.MethodNotAllowedf("unsupported method: %q", r.Method)); err != nil { 106 logger.Errorf("%v", err) 107 } 108 } 109 } 110 111 func (h *toolsUploadHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 112 // Validate before authenticate because the authentication is dependent 113 // on the state connection that is determined during the validation. 114 st, err := h.stateAuthFunc(r) 115 if err != nil { 116 if err := sendError(w, err); err != nil { 117 logger.Errorf("%v", err) 118 } 119 return 120 } 121 defer st.Release() 122 123 switch r.Method { 124 case "POST": 125 // Add tools to storage. 126 agentTools, err := h.processPost(r, st.State) 127 if err != nil { 128 if err := sendError(w, err); err != nil { 129 logger.Errorf("%v", err) 130 } 131 return 132 } 133 if err := sendStatusAndJSON(w, http.StatusOK, ¶ms.ToolsResult{ 134 ToolsList: tools.List{agentTools}, 135 }); err != nil { 136 logger.Errorf("%v", err) 137 } 138 default: 139 if err := sendError(w, errors.MethodNotAllowedf("unsupported method: %q", r.Method)); err != nil { 140 logger.Errorf("%v", err) 141 } 142 } 143 } 144 145 // getToolsForRequest retrieves the compressed agent binaries tarball from state 146 // based on the input HTTP request. 147 // It is returned with the size of the file as recorded in the stored metadata. 148 func (h *toolsDownloadHandler) getToolsForRequest(r *http.Request, st *state.State) (_ io.ReadCloser, _ int64, err error) { 149 vers, err := version.ParseBinary(r.URL.Query().Get(":version")) 150 if err != nil { 151 return nil, 0, errors.Annotate(err, "error parsing version") 152 } 153 logger.Debugf("request for agent binaries: %s", vers) 154 155 storage, err := st.ToolsStorage() 156 if err != nil { 157 return nil, 0, errors.Annotate(err, "error getting storage for agent binaries") 158 } 159 defer func() { 160 if err != nil { 161 _ = storage.Close() 162 } 163 }() 164 165 // TODO(juju4) = remove this compatibility logic 166 // Looked for stored tools which are recorded for a series 167 // but which have the same os type as the wanted version. 168 // Alternatively, the request may have been for a specifc 169 // series and we need to use stored tools for the corresponding 170 // os type. 171 storageVers := vers 172 var osTypeName string 173 if vers.Number.Major == 2 && vers.Number.Minor <= 8 { 174 wantedOSType := vers.Release 175 if !ostype.IsValidOSTypeName(vers.Release) { 176 wantedOSType = corebase.DefaultOSTypeNameFromSeries(vers.Release) 177 } 178 vers.Release = wantedOSType 179 180 all, err := storage.AllMetadata() 181 if err != nil { 182 return nil, 0, errors.Trace(err) 183 } 184 var osMatchVersion *version.Binary 185 for _, m := range all { 186 metaVers, err := version.ParseBinary(m.Version) 187 if err != nil { 188 return nil, 0, errors.Annotate(err, "error parsing metadata version") 189 } 190 191 // Exact match so just use that with os type name substitution. 192 if m.Version == vers.String() { 193 osMatchVersion = &metaVers 194 break 195 } 196 if osMatchVersion != nil { 197 continue 198 } 199 metaOSType := metaVers.Release 200 if !ostype.IsValidOSTypeName(metaVers.Release) { 201 metaOSType = corebase.DefaultOSTypeNameFromSeries(metaVers.Release) 202 } 203 toCompare := metaVers 204 toCompare.Release = strings.ToLower(metaOSType) 205 if toCompare.String() == vers.String() { 206 logger.Debugf("using os based version %s for requested %s", toCompare, vers) 207 osMatchVersion = &metaVers 208 osTypeName = toCompare.Release 209 } 210 } 211 // Set the version to store to be the match we found 212 // for any compatible series. 213 if osMatchVersion != nil { 214 storageVers = *osMatchVersion 215 } 216 } 217 218 locker := h.fetchMutex.Locker(storageVers.String()) 219 locker.Lock() 220 defer locker.Unlock() 221 222 md, reader, err := storage.Open(storageVers.String()) 223 if errors.IsNotFound(err) { 224 // Tools could not be found in tools storage, 225 // so look for them in simplestreams, 226 // fetch them and cache in tools storage. 227 logger.Infof("%v agent binaries not found locally, fetching", vers) 228 if osTypeName != "" { 229 storageVers.Release = osTypeName 230 } 231 err = h.fetchAndCacheTools(vers, storageVers, st, storage) 232 if err != nil { 233 err = errors.Annotate(err, "error fetching agent binaries") 234 } else { 235 md, reader, err = storage.Open(storageVers.String()) 236 } 237 } 238 if err != nil { 239 return nil, 0, errors.Trace(err) 240 } 241 242 return &toolsReadCloser{f: reader, st: storage}, md.Size, nil 243 } 244 245 // fetchAndCacheTools fetches tools with the specified version by searching for a URL 246 // in simplestreams and GETting it, caching the result in tools storage before returning 247 // to the caller. 248 func (h *toolsDownloadHandler) fetchAndCacheTools( 249 v version.Binary, 250 storageVers version.Binary, 251 st *state.State, 252 modelStorage binarystorage.Storage, 253 ) error { 254 systemState, err := h.ctxt.statePool().SystemState() 255 if err != nil { 256 return errors.Trace(err) 257 } 258 259 controllerModel, err := systemState.Model() 260 if err != nil { 261 return err 262 } 263 264 var model *state.Model 265 var storage binarystorage.Storage 266 switch controllerModel.Type() { 267 case state.ModelTypeCAAS: 268 // TODO(caas): unify tool fetching 269 // Cache the tools against the model when the controller is CAAS. 270 model, err = st.Model() 271 if err != nil { 272 return err 273 } 274 storage = modelStorage 275 case state.ModelTypeIAAS: 276 // Cache the tools against the controller when the controller is IAAS. 277 model = controllerModel 278 controllerStorage, err := systemState.ToolsStorage() 279 if err != nil { 280 return err 281 } 282 defer controllerStorage.Close() 283 storage = controllerStorage 284 default: 285 return errors.NotValidf("model type %q", controllerModel.Type()) 286 } 287 288 newEnviron := stateenvirons.GetNewEnvironFunc(environs.New) 289 env, err := newEnviron(model) 290 if err != nil { 291 return err 292 } 293 294 ss := simplestreams.NewSimpleStreams(simplestreams.DefaultDataSourceFactory()) 295 exactTools, err := envtools.FindExactTools(ss, env, v.Number, v.Release, v.Arch) 296 if err != nil { 297 return err 298 } 299 300 // No need to verify the server's identity because we verify the SHA-256 hash. 301 logger.Infof("fetching %v agent binaries from %v", v, exactTools.URL) 302 client := jujuhttp.NewClient(jujuhttp.WithSkipHostnameVerification(true)) 303 resp, err := client.Get(context.TODO(), exactTools.URL) 304 if err != nil { 305 return err 306 } 307 defer func() { _ = resp.Body.Close() }() 308 if resp.StatusCode != http.StatusOK { 309 msg := fmt.Sprintf("bad HTTP response: %v", resp.Status) 310 if body, err := io.ReadAll(resp.Body); err == nil { 311 msg += fmt.Sprintf(" (%s)", bytes.TrimSpace(body)) 312 } 313 return errors.New(msg) 314 } 315 316 data, respSha256, size, err := tmpCacheAndHash(resp.Body) 317 if err != nil { 318 return err 319 } 320 defer data.Close() 321 if size != exactTools.Size { 322 return errors.Errorf("size mismatch for %s", exactTools.URL) 323 } 324 if respSha256 != exactTools.SHA256 { 325 return errors.Errorf("hash mismatch for %s", exactTools.URL) 326 } 327 328 md := binarystorage.Metadata{ 329 Version: storageVers.String(), 330 Size: exactTools.Size, 331 SHA256: exactTools.SHA256, 332 } 333 if err := storage.Add(data, md); err != nil { 334 return errors.Annotate(err, "error caching agent binaries") 335 } 336 337 return nil 338 } 339 340 // sendTools streams the tools tarball to the client. 341 func (h *toolsDownloadHandler) sendTools(w http.ResponseWriter, reader io.ReadCloser, size int64) error { 342 logger.Tracef("sending %d bytes", size) 343 344 w.Header().Set("Content-Type", "application/x-tar-gz") 345 w.Header().Set("Content-Length", strconv.FormatInt(size, 10)) 346 347 if _, err := io.Copy(w, reader); err != nil { 348 // Having begun writing, it is too late to send an error response here. 349 return errors.Annotatef(err, "failed to send agent binaries") 350 } 351 return nil 352 } 353 354 // processPost handles a tools upload POST request after authentication. 355 func (h *toolsUploadHandler) processPost(r *http.Request, st *state.State) (*tools.Tools, error) { 356 query := r.URL.Query() 357 358 binaryVersionParam := query.Get("binaryVersion") 359 if binaryVersionParam == "" { 360 return nil, errors.BadRequestf("expected binaryVersion argument") 361 } 362 toolsVersion, err := version.ParseBinary(binaryVersionParam) 363 if err != nil { 364 return nil, errors.NewBadRequest(err, fmt.Sprintf("invalid agent binaries version %q", binaryVersionParam)) 365 } 366 367 // Make sure the content type is x-tar-gz. 368 contentType := r.Header.Get("Content-Type") 369 if contentType != "application/x-tar-gz" { 370 return nil, errors.BadRequestf("expected Content-Type: application/x-tar-gz, got: %v", contentType) 371 } 372 373 logger.Debugf("request to upload agent binaries: %s", toolsVersion) 374 toolsVersions := []version.Binary{toolsVersion} 375 serverRoot := h.getServerRoot(r, query, st) 376 return h.handleUpload(r.Body, toolsVersions, serverRoot, st) 377 } 378 379 func (h *toolsUploadHandler) getServerRoot(r *http.Request, query url.Values, st *state.State) string { 380 modelUUID := httpcontext.RequestModelUUID(r) 381 return fmt.Sprintf("https://%s/model/%s", r.Host, modelUUID) 382 } 383 384 // handleUpload uploads the tools data from the reader to env storage as the specified version. 385 func (h *toolsUploadHandler) handleUpload(r io.Reader, toolsVersions []version.Binary, serverRoot string, st *state.State) (*tools.Tools, error) { 386 // Check if changes are allowed and the command may proceed. 387 blockChecker := common.NewBlockChecker(st) 388 if err := blockChecker.ChangeAllowed(); err != nil { 389 return nil, errors.Trace(err) 390 } 391 storage, err := st.ToolsStorage() 392 if err != nil { 393 return nil, err 394 } 395 defer storage.Close() 396 397 // Read the tools tarball from the request, calculating the sha256 along the way. 398 data, sha256, size, err := tmpCacheAndHash(r) 399 if err != nil { 400 return nil, err 401 } 402 defer data.Close() 403 404 if size == 0 { 405 return nil, errors.BadRequestf("no agent binaries uploaded") 406 } 407 408 // TODO(wallyworld): check integrity of tools tarball. 409 410 // Store tools and metadata in tools storage. 411 for _, v := range toolsVersions { 412 metadata := binarystorage.Metadata{ 413 Version: v.String(), 414 Size: size, 415 SHA256: sha256, 416 } 417 logger.Debugf("uploading agent binaries %+v to storage", metadata) 418 if err := storage.Add(data, metadata); err != nil { 419 return nil, err 420 } 421 } 422 423 tools := &tools.Tools{ 424 Version: toolsVersions[0], 425 Size: size, 426 SHA256: sha256, 427 URL: common.ToolsURL(serverRoot, toolsVersions[0]), 428 } 429 return tools, nil 430 } 431 432 type cleanupCloser struct { 433 io.ReadCloser 434 cleanup func() 435 } 436 437 func (c *cleanupCloser) Close() error { 438 if c.cleanup != nil { 439 c.cleanup() 440 } 441 return c.ReadCloser.Close() 442 } 443 444 func tmpCacheAndHash(r io.Reader) (data io.ReadCloser, sha256hex string, size int64, err error) { 445 tmpFile, err := os.CreateTemp("", "jujutools*") 446 tmpFilename := tmpFile.Name() 447 cleanup := func() { 448 _ = tmpFile.Close() 449 _ = os.Remove(tmpFilename) 450 } 451 defer func() { 452 if err != nil { 453 cleanup() 454 } 455 }() 456 tr := io.TeeReader(r, tmpFile) 457 hasher := sha256.New() 458 _, err = io.Copy(hasher, tr) 459 if err != nil { 460 return nil, "", 0, errors.Annotatef(err, "failed to hash agent tools and write to file %q", tmpFilename) 461 } 462 _, err = tmpFile.Seek(0, 0) 463 if err != nil { 464 return nil, "", 0, errors.Trace(err) 465 } 466 stat, err := tmpFile.Stat() 467 if err != nil { 468 return nil, "", 0, errors.Trace(err) 469 } 470 return &cleanupCloser{tmpFile, cleanup}, fmt.Sprintf("%x", hasher.Sum(nil)), stat.Size(), nil 471 }