github.com/juju/juju@v0.0.0-20240430160146-1752b71fcf00/core/charm/repository/charmhub.go (about) 1 // Copyright 2021 Canonical Ltd. 2 // Licensed under the AGPLv3, see LICENCE file for details. 3 4 package repository 5 6 import ( 7 "context" 8 "fmt" 9 "net/url" 10 "strings" 11 12 "github.com/juju/charm/v12" 13 charmresource "github.com/juju/charm/v12/resource" 14 "github.com/juju/collections/set" 15 "github.com/juju/errors" 16 17 "github.com/juju/juju/charmhub" 18 "github.com/juju/juju/charmhub/transport" 19 corebase "github.com/juju/juju/core/base" 20 corecharm "github.com/juju/juju/core/charm" 21 ) 22 23 // CharmHubClient describes the API exposed by the charmhub client. 24 type CharmHubClient interface { 25 DownloadAndRead(ctx context.Context, resourceURL *url.URL, archivePath string, options ...charmhub.DownloadOption) (*charm.CharmArchive, error) 26 ListResourceRevisions(ctx context.Context, charm, resource string) ([]transport.ResourceRevision, error) 27 Refresh(ctx context.Context, config charmhub.RefreshConfig) ([]transport.RefreshResponse, error) 28 } 29 30 // CharmHubRepository provides an API for charm-related operations using charmhub. 31 type CharmHubRepository struct { 32 logger Logger 33 client CharmHubClient 34 } 35 36 // NewCharmHubRepository returns a new repository instance using the provided 37 // charmhub client. 38 func NewCharmHubRepository(logger Logger, chClient CharmHubClient) *CharmHubRepository { 39 return &CharmHubRepository{ 40 logger: logger, 41 client: chClient, 42 } 43 } 44 45 // ResolveWithPreferredChannel defines a way using the given charm name and 46 // charm origin (platform and channel) to locate a matching charm against the 47 // Charmhub API. 48 func (c *CharmHubRepository) ResolveWithPreferredChannel(charmName string, argOrigin corecharm.Origin) (*charm.URL, corecharm.Origin, []corecharm.Platform, error) { 49 c.logger.Tracef("Resolving CharmHub charm %q with origin %+v", charmName, argOrigin) 50 51 requestedOrigin, err := c.validateOrigin(argOrigin) 52 if err != nil { 53 return nil, corecharm.Origin{}, nil, err 54 } 55 resCurl, outputOrigin, resolvedBases, _, err := c.resolveWithPreferredChannel(charmName, requestedOrigin) 56 return resCurl, outputOrigin, resolvedBases, err 57 } 58 59 // ResolveForDeploy combines ResolveWithPreferredChannel, GetEssentialMetadata 60 // and best effort for repositoryResources into 1 call for server side charm deployment. 61 // Reducing the number of required calls to a repository. 62 func (c *CharmHubRepository) ResolveForDeploy(arg corecharm.CharmID) (corecharm.ResolvedDataForDeploy, error) { 63 c.logger.Tracef("Resolving CharmHub charm %q with origin %+v", arg.URL, arg.Origin) 64 65 resultURL, resolvedOrigin, _, resp, resolveErr := c.resolveWithPreferredChannel(arg.URL.Name, arg.Origin) 66 if resolveErr != nil { 67 return corecharm.ResolvedDataForDeploy{}, errors.Trace(resolveErr) 68 } 69 70 essMeta, err := transformRefreshResult(resultURL.Name, resp) 71 if err != nil { 72 return corecharm.ResolvedDataForDeploy{}, errors.Trace(err) 73 } 74 essMeta.ResolvedOrigin = resolvedOrigin 75 76 // Get ID and Hash for the origin. Needed in the case where this 77 // charm has been downloaded before. 78 resolvedOrigin.ID = resp.Entity.ID 79 resolvedOrigin.Hash = resp.Entity.Download.HashSHA256 80 81 // Resources are best attempt here. If we were able to resolve the charm 82 // via a channel, the resource data will be here. If using a revision, 83 // then not. However, that does not mean that the charm has no resources. 84 resourceResults, err := transformResourceRevision(resp.Entity.Resources) 85 if err != nil { 86 return corecharm.ResolvedDataForDeploy{}, errors.Trace(err) 87 } 88 89 thing := corecharm.ResolvedDataForDeploy{ 90 URL: resultURL, 91 EssentialMetadata: essMeta, 92 Resources: resourceResults, 93 } 94 return thing, nil 95 } 96 97 // There are a few things to note in the attempt to resolve the charm and it's 98 // supporting series. 99 // 100 // 1. The algorithm for this is terrible. For Charmhub the worst case for this 101 // will be 2. 102 // Most of the initial requests from the client will hit this first time 103 // around (think `juju deploy foo`) without a series (client can then 104 // determine what to call the real request with) will be default of 2 105 // requests. 106 // 2. Attempting to find the default series will require 2 requests so that 107 // we can find the correct charm ID ensure that the default series exists 108 // along with the revision. 109 // 3. In theory we could just return most of this information without the 110 // re-request, but we end up with missing data and potential incorrect 111 // charm downloads later. 112 func (c *CharmHubRepository) resolveWithPreferredChannel(charmName string, requestedOrigin corecharm.Origin) (*charm.URL, corecharm.Origin, []corecharm.Platform, transport.RefreshResponse, error) { 113 c.logger.Tracef("Resolving CharmHub charm %q with origin %v", charmName, requestedOrigin) 114 115 // First attempt to find the charm based on the only input provided. 116 response, err := c.refreshOne(charmName, requestedOrigin) 117 if err != nil { 118 return nil, corecharm.Origin{}, nil, transport.RefreshResponse{}, errors.Annotatef(err, "resolving with preferred channel") 119 } 120 121 // resolvedBases holds a slice of supported bases from the subsequent 122 // refresh API call. The bases can inform the consumer of the API about what 123 // they can also install *IF* the retry resolution uses a base that doesn't 124 // match their requirements. This can happen in the client if the series 125 // selection also wants to consider model-config default-base after the 126 // call. 127 var ( 128 effectiveChannel string 129 resolvedBases []corecharm.Platform 130 chSuggestedOrigin = requestedOrigin 131 ) 132 switch { 133 case response.Error != nil: 134 retryResult, err := c.retryResolveWithPreferredChannel(charmName, requestedOrigin, response.Error) 135 if err != nil { 136 return nil, corecharm.Origin{}, nil, transport.RefreshResponse{}, errors.Trace(err) 137 } 138 139 response = retryResult.refreshResponse 140 resolvedBases = retryResult.bases 141 chSuggestedOrigin = retryResult.origin 142 143 // Fill these back on the origin, so that we can fix the issue of 144 // bundles passing back "all" on the response type. 145 // Note: we can be sure these have at least one, because of the 146 // validation logic in retry method. 147 requestedOrigin.Platform.OS = resolvedBases[0].OS 148 requestedOrigin.Platform.Channel = resolvedBases[0].Channel 149 150 effectiveChannel = response.EffectiveChannel 151 case requestedOrigin.Revision != nil && *requestedOrigin.Revision != -1: 152 if len(response.Entity.Bases) > 0 { 153 for _, v := range response.Entity.Bases { 154 resolvedBases = append(resolvedBases, corecharm.Platform{ 155 Architecture: v.Architecture, 156 OS: v.Name, 157 Channel: v.Channel, 158 }) 159 } 160 } 161 // Entities installed by revision do not have an effective channel in the data. 162 // A charm with revision X can be in multiple different channels. The user 163 // specified channel for future refresh calls, is located in the origin. 164 if response.Entity.Type == transport.CharmType && requestedOrigin.Channel != nil { 165 effectiveChannel = requestedOrigin.Channel.String() 166 } else if response.Entity.Type == transport.BundleType { 167 // This is a hack. A bundle does not require a channel moving forward as refresh 168 // by bundle is not implemented, however the following code expects a channel. 169 effectiveChannel = "stable" 170 } 171 default: 172 effectiveChannel = response.EffectiveChannel 173 } 174 175 // Use the channel that was actually picked by the API. This should 176 // account for the closed tracks in a given channel. 177 channel, err := charm.ParseChannelNormalize(effectiveChannel) 178 if err != nil { 179 return nil, corecharm.Origin{}, nil, transport.RefreshResponse{}, errors.Annotatef(err, "invalid channel for %q", charmName) 180 } 181 182 // Ensure we send the updated charmURL back, with all the correct segments. 183 revision := response.Entity.Revision 184 // TODO(wallyworld) - does charm url still need a series? 185 var series string 186 if chSuggestedOrigin.Platform.Channel != "" { 187 series, err = corebase.GetSeriesFromChannel(chSuggestedOrigin.Platform.OS, chSuggestedOrigin.Platform.Channel) 188 if err != nil { 189 return nil, corecharm.Origin{}, nil, transport.RefreshResponse{}, errors.Trace(err) 190 } 191 } 192 resCurl := &charm.URL{ 193 Schema: "ch", 194 Name: charmName, 195 Revision: revision, 196 Series: series, 197 Architecture: chSuggestedOrigin.Platform.Architecture, 198 } 199 200 // Create a resolved origin. Keep the original values for ID and Hash, if 201 // any were passed in. ResolveWithPreferredChannel is called for both 202 // charms to be deployed, and charms which are being upgraded. 203 // Only charms being upgraded will have an ID and Hash. Those values should 204 // only ever be updated in DownloadURL. 205 resOrigin := corecharm.Origin{ 206 Source: requestedOrigin.Source, 207 ID: requestedOrigin.ID, 208 Hash: requestedOrigin.Hash, 209 Type: string(response.Entity.Type), 210 Channel: &channel, 211 Revision: &revision, 212 Platform: chSuggestedOrigin.Platform, 213 InstanceKey: requestedOrigin.InstanceKey, 214 } 215 216 outputOrigin, err := sanitiseCharmOrigin(resOrigin, requestedOrigin) 217 if err != nil { 218 return nil, corecharm.Origin{}, nil, transport.RefreshResponse{}, errors.Trace(err) 219 } 220 c.logger.Tracef("Resolved CharmHub charm %q with origin %v", resCurl, outputOrigin) 221 222 // If the callee of the API defines a base and that base is pick and 223 // identified as being selected (think `juju deploy --base`) then we will 224 // never have to retry. The API will never give us back any other supported 225 // base, so we can just pass back what the callee requested. 226 // This is the happy path for resolving a charm. 227 // 228 // Unfortunately, most deployments will not pass a base flag, so we will 229 // have to ask the API to give us back a potential base. The supported 230 // bases can be passed back. The callee can then determine which base they 231 // want to use and deploy that accordingly without another API request. 232 if len(resolvedBases) == 0 && outputOrigin.Platform.Channel != "" { 233 resolvedBases = []corecharm.Platform{outputOrigin.Platform} 234 } 235 return resCurl, outputOrigin, resolvedBases, response, nil 236 } 237 238 // validateOrigin, validate the origin and maybe fix as follows: 239 // 240 // Platform must have an architecture. 241 // Platform can have both an empty Channel AND os. 242 // Platform must have channel if os defined. 243 // Platform must have os if channel defined. 244 func (c *CharmHubRepository) validateOrigin(origin corecharm.Origin) (corecharm.Origin, error) { 245 p := origin.Platform 246 247 if p.Architecture == "" { 248 return corecharm.Origin{}, errors.BadRequestf("origin.Platform requires an Architecture") 249 } 250 251 if p.OS != "" && p.Channel == "" { 252 return corecharm.Origin{}, errors.BadRequestf("origin.Platform requires a Channel, if OS set") 253 } 254 255 if p.OS == "" && p.Channel != "" { 256 return corecharm.Origin{}, errors.BadRequestf("origin.Platform requires an OS, if channel set") 257 } 258 return origin, nil 259 } 260 261 type retryResolveResult struct { 262 refreshResponse transport.RefreshResponse 263 origin corecharm.Origin 264 bases []corecharm.Platform 265 } 266 267 // retryResolveWithPreferredChannel will attempt to inspect the transport 268 // APIError and determine if a retry is possible with the information gathered 269 // from the error. 270 func (c *CharmHubRepository) retryResolveWithPreferredChannel(charmName string, origin corecharm.Origin, resErr *transport.APIError) (*retryResolveResult, error) { 271 var ( 272 err error 273 bases []corecharm.Platform 274 ) 275 switch resErr.Code { 276 case transport.ErrorCodeInvalidCharmPlatform, transport.ErrorCodeInvalidCharmBase: 277 c.logger.Tracef("Invalid charm base %q %v - Default Base: %v", charmName, origin, resErr.Extra.DefaultBases) 278 279 if bases, err = c.selectNextBases(resErr.Extra.DefaultBases, origin); err != nil { 280 return nil, errors.Annotatef(err, "selecting next bases") 281 } 282 283 case transport.ErrorCodeRevisionNotFound: 284 c.logger.Tracef("Revision not found %q %v - Releases: %v", charmName, origin, resErr.Extra.Releases) 285 286 return nil, errors.Annotatef(c.handleRevisionNotFound(resErr.Extra.Releases, origin), "selecting releases") 287 288 default: 289 return nil, errors.Errorf("resolving error: %s", resErr.Message) 290 } 291 292 if len(bases) == 0 { 293 ch := origin.Channel.String() 294 if ch == "" { 295 ch = "stable" 296 } 297 return nil, errors.Wrap(resErr, errors.Errorf("no releases found for channel %q", ch)) 298 } 299 base := bases[0] 300 301 origin.Platform.OS = base.OS 302 origin.Platform.Channel = base.Channel 303 304 if origin.Platform.Channel == "" { 305 return nil, errors.NotValidf("channel for %s", charmName) 306 } 307 308 c.logger.Tracef("Refresh again with %q %v", charmName, origin) 309 res, err := c.refreshOne(charmName, origin) 310 if err != nil { 311 return nil, errors.Annotatef(err, "retrying") 312 } 313 if resErr := res.Error; resErr != nil { 314 return nil, errors.Errorf("resolving retry error: %s", resErr.Message) 315 } 316 return &retryResolveResult{ 317 refreshResponse: res, 318 origin: origin, 319 bases: bases, 320 }, nil 321 } 322 323 func transformRefreshResult(charmName string, refreshResult transport.RefreshResponse) (corecharm.EssentialMetadata, error) { 324 if refreshResult.Entity.MetadataYAML == "" { 325 return corecharm.EssentialMetadata{}, errors.NotValidf("charmhub refresh response for %q does not include the contents of metadata.yaml", charmName) 326 } 327 chMeta, err := charm.ReadMeta(strings.NewReader(refreshResult.Entity.MetadataYAML)) 328 if err != nil { 329 return corecharm.EssentialMetadata{}, errors.Annotatef(err, "parsing metadata.yaml for %q", charmName) 330 } 331 332 configYAML := refreshResult.Entity.ConfigYAML 333 var chConfig *charm.Config 334 // NOTE: Charmhub returns a "{}\n" when no config.yaml exists for 335 // the charm, e.g. postgreql. However, this will fail the charm 336 // config validation which happens in ReadConfig. Valid config 337 // are nil and "Options: {}" 338 if configYAML == "" || strings.TrimSpace(configYAML) == "{}" { 339 chConfig = charm.NewConfig() 340 } else { 341 chConfig, err = charm.ReadConfig(strings.NewReader(configYAML)) 342 if err != nil { 343 return corecharm.EssentialMetadata{}, errors.Annotatef(err, "parsing config.yaml for %q", charmName) 344 } 345 } 346 347 chManifest := new(charm.Manifest) 348 for _, base := range refreshResult.Entity.Bases { 349 baseCh, err := charm.ParseChannelNormalize(base.Channel) 350 if err != nil { 351 return corecharm.EssentialMetadata{}, errors.Annotatef(err, "parsing base channel for %q", charmName) 352 } 353 354 chManifest.Bases = append(chManifest.Bases, charm.Base{ 355 Name: base.Name, 356 Channel: baseCh, 357 Architectures: []string{base.Architecture}, 358 }) 359 } 360 return corecharm.EssentialMetadata{Meta: chMeta, Config: chConfig, Manifest: chManifest}, nil 361 } 362 363 // DownloadCharm retrieves specified charm from the store and saves its 364 // contents to the specified path. 365 func (c *CharmHubRepository) DownloadCharm(charmName string, requestedOrigin corecharm.Origin, archivePath string) (corecharm.CharmArchive, corecharm.Origin, error) { 366 c.logger.Tracef("DownloadCharm %q, origin: %q", charmName, requestedOrigin) 367 368 // Resolve charm URL to a link to the charm blob and keep track of the 369 // actual resolved origin which may be different from the requested one. 370 resURL, actualOrigin, err := c.GetDownloadURL(charmName, requestedOrigin) 371 if err != nil { 372 return nil, corecharm.Origin{}, errors.Trace(err) 373 } 374 375 charmArchive, err := c.client.DownloadAndRead(context.TODO(), resURL, archivePath) 376 if err != nil { 377 return nil, corecharm.Origin{}, errors.Trace(err) 378 } 379 380 return charmArchive, actualOrigin, nil 381 } 382 383 // GetDownloadURL returns the url from which to download the CharmHub charm/bundle 384 // defined by the provided charm name and origin. An updated charm origin is 385 // also returned with the ID and hash for the charm to be downloaded. If the 386 // provided charm origin has no ID, it is assumed that the charm is being 387 // installed, not refreshed. 388 func (c *CharmHubRepository) GetDownloadURL(charmName string, requestedOrigin corecharm.Origin) (*url.URL, corecharm.Origin, error) { 389 c.logger.Tracef("GetDownloadURL %q, origin: %q", charmName, requestedOrigin) 390 391 refreshRes, err := c.refreshOne(charmName, requestedOrigin) 392 if err != nil { 393 return nil, corecharm.Origin{}, errors.Trace(err) 394 } 395 if refreshRes.Error != nil { 396 return nil, corecharm.Origin{}, errors.Errorf("%s: %s", refreshRes.Error.Code, refreshRes.Error.Message) 397 } 398 399 resOrigin := requestedOrigin 400 401 // We've called Refresh with the install action. Now update the 402 // charm ID and Hash values saved. This is the only place where 403 // they should be saved. 404 resOrigin.ID = refreshRes.Entity.ID 405 resOrigin.Hash = refreshRes.Entity.Download.HashSHA256 406 407 durl, err := url.Parse(refreshRes.Entity.Download.URL) 408 if err != nil { 409 return nil, corecharm.Origin{}, errors.Trace(err) 410 } 411 outputOrigin, err := sanitiseCharmOrigin(resOrigin, requestedOrigin) 412 return durl, outputOrigin, errors.Trace(err) 413 } 414 415 // ListResources returns the resources for a given charm and origin. 416 func (c *CharmHubRepository) ListResources(charmName string, origin corecharm.Origin) ([]charmresource.Resource, error) { 417 c.logger.Tracef("ListResources %q", charmName) 418 419 resCurl, resOrigin, _, err := c.ResolveWithPreferredChannel(charmName, origin) 420 if isErrSelection(err) { 421 var channel string 422 if origin.Channel != nil { 423 channel = origin.Channel.String() 424 } 425 return nil, errors.Errorf("unable to locate charm %q with matching channel %q", charmName, channel) 426 } else if err != nil { 427 return nil, errors.Trace(err) 428 } 429 430 // If a revision is included with an install action, no resources will be 431 // returned. Resources are dependent on a channel, a specific revision can 432 // be in multiple channels. refreshOne gives priority to a revision if 433 // specified. ListResources is used by the "charm-resources" cli cmd, 434 // therefore specific charm revisions are less important. 435 resOrigin.Revision = nil 436 resp, err := c.refreshOne(resCurl.Name, resOrigin) 437 if err != nil { 438 return nil, errors.Trace(err) 439 } 440 441 results := make([]charmresource.Resource, len(resp.Entity.Resources)) 442 for i, resource := range resp.Entity.Resources { 443 results[i], err = resourceFromRevision(resource) 444 if err != nil { 445 return nil, errors.Trace(err) 446 } 447 } 448 449 return results, nil 450 } 451 452 // TODO 30-Nov-2022 453 // ResolveResources can be made more efficient, some choices left over from 454 // integration with charmstore style of working. 455 456 // ResolveResources looks at the provided charmhub and backend (already 457 // downloaded) resources to determine which to use. Provided (uploaded) take 458 // precedence. If charmhub has a newer resource than the back end, use that. 459 func (c *CharmHubRepository) ResolveResources(resources []charmresource.Resource, id corecharm.CharmID) ([]charmresource.Resource, error) { 460 revisionResources, err := c.listResourcesIfRevisions(resources, id.URL.Name) 461 if err != nil { 462 return nil, errors.Trace(err) 463 } 464 storeResources, err := c.repositoryResources(id) 465 if err != nil { 466 return nil, errors.Trace(err) 467 } 468 storeResourcesMap, err := transformResourceRevision(storeResources) 469 if err != nil { 470 return nil, errors.Trace(err) 471 } 472 for k, v := range revisionResources { 473 storeResourcesMap[k] = v 474 } 475 resolved, err := c.resolveResources(resources, storeResourcesMap, id) 476 if err != nil { 477 return nil, errors.Trace(err) 478 } 479 return resolved, nil 480 } 481 482 func (c *CharmHubRepository) listResourcesIfRevisions(resources []charmresource.Resource, charmName string) (map[string]charmresource.Resource, error) { 483 results := make(map[string]charmresource.Resource, 0) 484 for _, resource := range resources { 485 // If not revision is specified, or the resource has already been 486 // uploaded, no need to attempt to find it here. 487 if resource.Revision == -1 || resource.Origin == charmresource.OriginUpload { 488 continue 489 } 490 refreshResp, err := c.client.ListResourceRevisions(context.TODO(), charmName, resource.Name) 491 if err != nil { 492 return nil, errors.Annotatef(err, "refreshing charm %q", charmName) 493 } 494 if len(refreshResp) == 0 { 495 return nil, errors.Errorf("no download refresh responses received") 496 } 497 for _, res := range refreshResp { 498 if res.Revision == resource.Revision { 499 results[resource.Name], err = resourceFromRevision(refreshResp[0]) 500 if err != nil { 501 return nil, errors.Trace(err) 502 } 503 } 504 } 505 } 506 return results, nil 507 } 508 509 // repositoryResources composes, a map of details for each of the charm's 510 // resources. Those details are those associated with the specific 511 // charm channel. They include the resource's metadata and revision. 512 // Found via the CharmHub api. ListResources requires charm resolution, 513 // this method does not. 514 func (c *CharmHubRepository) repositoryResources(id corecharm.CharmID) ([]transport.ResourceRevision, error) { 515 curl := id.URL 516 origin := id.Origin 517 refBase := charmhub.RefreshBase{ 518 Architecture: origin.Platform.Architecture, 519 Name: origin.Platform.OS, 520 Channel: origin.Platform.Channel, 521 } 522 var cfg charmhub.RefreshConfig 523 var err error 524 switch { 525 // Do not get resource data via revision here, it is only provided if explicitly 526 // asked for by resource revision. The purpose here is to find a resource revision 527 // in the channel, if one was not provided on the cli. 528 case origin.ID != "": 529 cfg, err = charmhub.DownloadOneFromChannel(origin.ID, origin.Channel.String(), refBase) 530 if err != nil { 531 c.logger.Errorf("creating resources config for charm (%q, %q): %s", origin.ID, origin.Channel.String(), err) 532 return nil, errors.Annotatef(err, "creating resources config for charm %q", curl.String()) 533 } 534 case origin.ID == "": 535 cfg, err = charmhub.DownloadOneFromChannelByName(curl.Name, origin.Channel.String(), refBase) 536 if err != nil { 537 c.logger.Errorf("creating resources config for charm (%q, %q): %s", curl.Name, origin.Channel.String(), err) 538 return nil, errors.Annotatef(err, "creating resources config for charm %q", curl.String()) 539 } 540 } 541 refreshResp, err := c.client.Refresh(context.TODO(), cfg) 542 if err != nil { 543 return nil, errors.Annotatef(err, "refreshing charm %q", curl.String()) 544 } 545 if len(refreshResp) == 0 { 546 return nil, errors.Errorf("no download refresh responses received") 547 } 548 resp := refreshResp[0] 549 if resp.Error != nil { 550 return nil, errors.Annotatef(errors.New(resp.Error.Message), "listing resources for charm %q", curl.String()) 551 } 552 return resp.Entity.Resources, nil 553 } 554 555 // transformResourceRevision transforms resource revision structs in charmhub format into 556 // charmresource format for use within juju. 557 func transformResourceRevision(resources []transport.ResourceRevision) (map[string]charmresource.Resource, error) { 558 if len(resources) == 0 { 559 return nil, nil 560 } 561 results := make(map[string]charmresource.Resource, len(resources)) 562 for _, v := range resources { 563 var err error 564 results[v.Name], err = resourceFromRevision(v) 565 if err != nil { 566 return nil, errors.Trace(err) 567 } 568 } 569 return results, nil 570 } 571 572 // resolveResources determines the resource info that should actually 573 // be stored on the controller. That decision is based on the provided 574 // resources along with those in the charm backend (if any). 575 func (c *CharmHubRepository) resolveResources(resources []charmresource.Resource, 576 storeResources map[string]charmresource.Resource, 577 id corecharm.CharmID, 578 ) ([]charmresource.Resource, error) { 579 allResolved := make([]charmresource.Resource, len(resources)) 580 copy(allResolved, resources) 581 for i, res := range resources { 582 // Note that incoming "upload" resources take precedence over 583 // ones already known to the controller, regardless of their 584 // origin. 585 if res.Origin != charmresource.OriginStore { 586 continue 587 } 588 589 resolved, err := c.resolveRepositoryResources(res, storeResources, id) 590 if err != nil { 591 return nil, errors.Trace(err) 592 } 593 allResolved[i] = resolved 594 } 595 return allResolved, nil 596 } 597 598 // resolveRepositoryResources selects the resource info to use. It decides 599 // between the provided and latest info based on the revision. 600 func (c *CharmHubRepository) resolveRepositoryResources(res charmresource.Resource, 601 storeResources map[string]charmresource.Resource, 602 id corecharm.CharmID, 603 ) (charmresource.Resource, error) { 604 storeRes, ok := storeResources[res.Name] 605 if !ok { 606 // This indicates that AddPendingResources() was called for 607 // a resource the charm backend doesn't know about (for the 608 // relevant charm revision). 609 return res, nil 610 } 611 612 if res.Revision < 0 { 613 // The caller wants to use the charm backend info. 614 return storeRes, nil 615 } 616 if res.Revision == storeRes.Revision { 617 // We don't worry about if they otherwise match. Only the 618 // revision is significant here. So we use the info from the 619 // charm backend since it is authoritative. 620 return storeRes, nil 621 } 622 if res.Fingerprint.IsZero() { 623 // The caller wants resource info from the charm backend, but with 624 // a different resource revision than the one associated with 625 // the charm in the backend. 626 return c.resourceInfo(id.URL, id.Origin, res.Name, res.Revision) 627 } 628 // The caller fully-specified a resource with a different resource 629 // revision than the one associated with the charm in the backend. So 630 // we use the provided info as-is. 631 return res, nil 632 } 633 634 func (c *CharmHubRepository) resourceInfo(curl *charm.URL, origin corecharm.Origin, name string, revision int) (charmresource.Resource, error) { 635 var configs []charmhub.RefreshConfig 636 var err error 637 638 // Due to async charm downloading we may not always have a charm ID to 639 // use for getting resource info, however it is preferred. A charm name 640 // is second best due to anticipation of charms being renamed in the 641 // future. The charm url may not change, but the ID will reference the 642 // new name. 643 if origin.ID != "" { 644 configs, err = configsByID(curl, origin, name, revision) 645 } else { 646 configs, err = configsByName(curl, origin, name, revision) 647 } 648 if err != nil { 649 return charmresource.Resource{}, err 650 } 651 652 refreshResp, err := c.client.Refresh(context.TODO(), charmhub.RefreshMany(configs...)) 653 if err != nil { 654 return charmresource.Resource{}, errors.Trace(err) 655 } 656 if len(refreshResp) == 0 { 657 return charmresource.Resource{}, errors.Errorf("no download refresh responses received") 658 } 659 660 for _, resp := range refreshResp { 661 if resp.Error != nil { 662 return charmresource.Resource{}, errors.Trace(errors.New(resp.Error.Message)) 663 } 664 665 for _, entity := range resp.Entity.Resources { 666 if entity.Name == name && entity.Revision == revision { 667 rfr, err := resourceFromRevision(entity) 668 return rfr, err 669 } 670 } 671 } 672 return charmresource.Resource{}, errors.NotFoundf("charm resource %q at revision %d", name, revision) 673 } 674 675 func configsByID(curl *charm.URL, origin corecharm.Origin, name string, revision int) ([]charmhub.RefreshConfig, error) { 676 var ( 677 configs []charmhub.RefreshConfig 678 refBase = charmhub.RefreshBase{ 679 Architecture: origin.Platform.Architecture, 680 Name: origin.Platform.OS, 681 Channel: origin.Platform.Channel, 682 } 683 ) 684 // Get all the resources for everything and just find out which one matches. 685 // The order is expected to be kept so when the response is looped through 686 // we get channel, then revision. 687 if sChan := origin.Channel.String(); sChan != "" { 688 cfg, err := charmhub.DownloadOneFromChannel(origin.ID, sChan, refBase) 689 if err != nil { 690 return configs, errors.Trace(err) 691 } 692 configs = append(configs, cfg) 693 } 694 if rev := origin.Revision; rev != nil { 695 cfg, err := charmhub.DownloadOneFromRevision(origin.ID, *rev) 696 if err != nil { 697 return configs, errors.Trace(err) 698 } 699 if newCfg, ok := charmhub.AddResource(cfg, name, revision); ok { 700 cfg = newCfg 701 } 702 configs = append(configs, cfg) 703 } 704 if rev := curl.Revision; rev >= 0 { 705 cfg, err := charmhub.DownloadOneFromRevision(origin.ID, rev) 706 if err != nil { 707 return configs, errors.Trace(err) 708 } 709 if newCfg, ok := charmhub.AddResource(cfg, name, revision); ok { 710 cfg = newCfg 711 } 712 configs = append(configs, cfg) 713 } 714 return configs, nil 715 } 716 717 func configsByName(curl *charm.URL, origin corecharm.Origin, name string, revision int) ([]charmhub.RefreshConfig, error) { 718 charmName := curl.Name 719 var configs []charmhub.RefreshConfig 720 // Get all the resource for everything and just find out which one matches. 721 // The order is expected to be kept so when the response is looped through 722 // we get channel, then revision. 723 if sChan := origin.Channel.String(); sChan != "" { 724 refBase := charmhub.RefreshBase{ 725 Architecture: origin.Platform.Architecture, 726 Name: origin.Platform.OS, 727 Channel: origin.Platform.Channel, 728 } 729 cfg, err := charmhub.DownloadOneFromChannelByName(charmName, sChan, refBase) 730 if err != nil { 731 return configs, errors.Trace(err) 732 } 733 configs = append(configs, cfg) 734 } 735 if rev := origin.Revision; rev != nil { 736 cfg, err := charmhub.DownloadOneFromRevisionByName(charmName, *rev) 737 if err != nil { 738 return configs, errors.Trace(err) 739 } 740 if newCfg, ok := charmhub.AddResource(cfg, name, revision); ok { 741 cfg = newCfg 742 } 743 configs = append(configs, cfg) 744 } 745 if rev := curl.Revision; rev >= 0 { 746 cfg, err := charmhub.DownloadOneFromRevisionByName(charmName, rev) 747 if err != nil { 748 return configs, errors.Trace(err) 749 } 750 if newCfg, ok := charmhub.AddResource(cfg, name, revision); ok { 751 cfg = newCfg 752 } 753 configs = append(configs, cfg) 754 } 755 return configs, nil 756 } 757 758 // GetEssentialMetadata resolves each provided MetadataRequest and returns back 759 // a slice with the results. The results include the minimum set of metadata 760 // that is required for deploying each charm. 761 func (c *CharmHubRepository) GetEssentialMetadata(reqs ...corecharm.MetadataRequest) ([]corecharm.EssentialMetadata, error) { 762 if len(reqs) == 0 { 763 return nil, nil 764 } 765 766 resolvedOrigins := make([]corecharm.Origin, len(reqs)) 767 refreshCfgs := make([]charmhub.RefreshConfig, len(reqs)) 768 for reqIdx, req := range reqs { 769 // TODO(achilleasa): We should add support for resolving origin 770 // batches and move this outside the loop. 771 _, resolvedOrigin, _, err := c.ResolveWithPreferredChannel(req.CharmName, req.Origin) 772 if err != nil { 773 return nil, errors.Annotatef(err, "resolving origin for %q", req.CharmName) 774 } 775 776 refreshCfg, err := refreshConfig(req.CharmName, resolvedOrigin) 777 if err != nil { 778 return nil, errors.Trace(err) 779 } 780 781 resolvedOrigins[reqIdx] = resolvedOrigin 782 refreshCfgs[reqIdx] = refreshCfg 783 } 784 785 refreshResults, err := c.client.Refresh(context.TODO(), charmhub.RefreshMany(refreshCfgs...)) 786 if err != nil { 787 return nil, errors.Trace(err) 788 } 789 790 var metaRes = make([]corecharm.EssentialMetadata, len(reqs)) 791 for resIdx, refreshResult := range refreshResults { 792 essMeta, err := transformRefreshResult(reqs[resIdx].CharmName, refreshResult) 793 if err != nil { 794 return nil, err 795 } 796 essMeta.ResolvedOrigin = resolvedOrigins[resIdx] 797 metaRes[resIdx] = essMeta 798 } 799 800 return metaRes, nil 801 } 802 803 func (c *CharmHubRepository) refreshOne(charmName string, origin corecharm.Origin) (transport.RefreshResponse, error) { 804 cfg, err := refreshConfig(charmName, origin) 805 if err != nil { 806 return transport.RefreshResponse{}, errors.Trace(err) 807 } 808 c.logger.Tracef("Locate charm using: %v", cfg) 809 result, err := c.client.Refresh(context.TODO(), cfg) 810 if err != nil { 811 return transport.RefreshResponse{}, errors.Trace(err) 812 } 813 if len(result) != 1 { 814 return transport.RefreshResponse{}, errors.Errorf("more than 1 result found") 815 } 816 817 return result[0], nil 818 } 819 820 func (c *CharmHubRepository) selectNextBases(bases []transport.Base, origin corecharm.Origin) ([]corecharm.Platform, error) { 821 if len(bases) == 0 { 822 return nil, errors.Errorf("no bases available") 823 } 824 // We've got a invalid charm platform error, the error should contain 825 // a valid platform to query again to get the right information. If 826 // the platform is empty, consider it a failure. 827 var compatible []transport.Base 828 for _, base := range bases { 829 if base.Architecture != origin.Platform.Architecture { 830 continue 831 } 832 compatible = append(compatible, base) 833 } 834 if len(compatible) == 0 { 835 return nil, errors.NotFoundf("bases matching architecture %q", origin.Platform.Architecture) 836 } 837 838 // Serialize all the platforms into core entities. 839 var results []corecharm.Platform 840 seen := set.NewStrings() 841 for _, base := range compatible { 842 platform, err := corecharm.ParsePlatform(fmt.Sprintf("%s/%s/%s", base.Architecture, base.Name, base.Channel)) 843 if err != nil { 844 return nil, errors.Annotate(err, "base") 845 } 846 if !seen.Contains(platform.String()) { 847 seen.Add(platform.String()) 848 results = append(results, platform) 849 } 850 } 851 852 return results, nil 853 } 854 855 func (c *CharmHubRepository) handleRevisionNotFound(releases []transport.Release, origin corecharm.Origin) error { 856 if len(releases) == 0 { 857 return errors.Errorf("no releases available") 858 } 859 // If the user passed in a branch, but not enough information about the 860 // arch and channel, then we can help by giving a better error message. 861 if origin.Channel != nil && origin.Channel.Branch != "" { 862 return errors.Errorf("ambiguous arch and series with channel %q, specify both arch and series along with channel", origin.Channel.String()) 863 } 864 // Help the user out by creating a list of channel/base suggestions to try. 865 suggestions := c.composeSuggestions(releases, origin) 866 var s string 867 if len(suggestions) > 0 { 868 s = fmt.Sprintf("\navailable releases are:\n %v", strings.Join(suggestions, "\n ")) 869 } 870 // If the origin's channel is nil, one wasn't specified by the user, 871 // so we requested "stable", which indicates the charm's default channel. 872 // However, at the time we're writing this message, we do not know what 873 // the charm's default channel is. 874 var channelString string 875 if origin.Channel != nil { 876 channelString = fmt.Sprintf("for channel %q", origin.Channel.String()) 877 } else { 878 channelString = "in the charm's default channel" 879 } 880 881 return errSelection{ 882 err: errors.Errorf( 883 "charm or bundle not found %s, base %q%s", 884 channelString, origin.Platform.String(), s), 885 } 886 } 887 888 type errSelection struct { 889 err error 890 } 891 892 func (e errSelection) Error() string { 893 return e.err.Error() 894 } 895 896 func isErrSelection(err error) bool { 897 _, ok := errors.Cause(err).(errSelection) 898 return ok 899 } 900 901 // Method describes the method for requesting the charm using the RefreshAPI. 902 type Method string 903 904 const ( 905 // MethodRevision utilizes an install action by the revision only. A 906 // channel must be in the origin, however it's not used in this request, 907 // but saved in the origin for future use. 908 MethodRevision Method = "revision" 909 // MethodChannel utilizes an install action by the channel only. 910 MethodChannel Method = "channel" 911 // MethodID utilizes an refresh action by the id, revision and 912 // channel (falls back to latest/stable if channel is not found). 913 MethodID Method = "id" 914 ) 915 916 // refreshConfig creates a RefreshConfig for the given input. 917 // If the origin.ID is not set, a install refresh config is returned. For 918 // install. Channel and Revision are mutually exclusive in the api, only 919 // one will be used. 920 // 921 // If the origin.ID is set, a refresh config is returned. 922 // 923 // NOTE: There is one idiosyncrasy of this method. The charm URL and and 924 // origin have a revision number in them when called by GetDownloadURL 925 // to install a charm. Potentially causing an unexpected install by revision. 926 // This is okay as all of the data is ready and correct in the origin. 927 func refreshConfig(charmName string, origin corecharm.Origin) (charmhub.RefreshConfig, error) { 928 // Work out the correct install method. 929 rev := -1 930 var method Method 931 if origin.Revision != nil && *origin.Revision >= 0 { 932 rev = *origin.Revision 933 } 934 if origin.ID == "" && rev != -1 { 935 method = MethodRevision 936 } 937 938 var ( 939 channel string 940 nonEmptyChannel = origin.Channel != nil && !origin.Channel.Empty() 941 ) 942 943 // Select the appropriate channel based on the supplied origin. 944 // We need to ensure that we always, always normalize the incoming channel 945 // before we hit the refresh API. 946 if nonEmptyChannel { 947 channel = origin.Channel.Normalize().String() 948 } else if method != MethodRevision { 949 channel = corecharm.DefaultChannel.Normalize().String() 950 } 951 952 if method != MethodRevision && channel != "" { 953 method = MethodChannel 954 } 955 956 // Bundles can not use method IDs, which in turn forces a refresh. 957 if !transport.BundleType.Matches(origin.Type) && origin.ID != "" { 958 method = MethodID 959 } 960 961 var ( 962 cfg charmhub.RefreshConfig 963 err error 964 965 base = charmhub.RefreshBase{ 966 Architecture: origin.Platform.Architecture, 967 Name: origin.Platform.OS, 968 Channel: origin.Platform.Channel, 969 } 970 ) 971 switch method { 972 case MethodChannel: 973 // Install from just the name and the channel. If there is no origin ID, 974 // we haven't downloaded this charm before. 975 // Try channel first. 976 cfg, err = charmhub.InstallOneFromChannel(charmName, channel, base) 977 case MethodRevision: 978 // If there is a revision, install it using that. If there is no origin 979 // ID, we haven't downloaded this charm before. 980 cfg, err = charmhub.InstallOneFromRevision(charmName, rev) 981 case MethodID: 982 // This must be a charm upgrade if we have an ID. Use the refresh 983 // action for metric keeping on the CharmHub side. 984 cfg, err = charmhub.RefreshOne(origin.InstanceKey, origin.ID, rev, channel, base) 985 default: 986 return nil, errors.NotValidf("origin %v", origin) 987 } 988 return cfg, err 989 } 990 991 func (c *CharmHubRepository) composeSuggestions(releases []transport.Release, origin corecharm.Origin) []string { 992 charmRisks := set.NewStrings() 993 for _, v := range charm.Risks { 994 charmRisks.Add(string(v)) 995 } 996 channelSeries := make(map[string][]string) 997 for _, release := range releases { 998 arch := release.Base.Architecture 999 if arch == "all" { 1000 arch = origin.Platform.Architecture 1001 } 1002 if arch != origin.Platform.Architecture { 1003 continue 1004 } 1005 var ( 1006 base corebase.Base 1007 err error 1008 ) 1009 1010 channel, err := corebase.ParseChannel(release.Base.Channel) 1011 if err != nil { 1012 c.logger.Errorf("invalid base channel %v: %s", release.Base.Channel, err) 1013 continue 1014 } 1015 if channel.Track == "all" || release.Base.Name == "all" { 1016 base, err = corebase.ParseBase(origin.Platform.OS, origin.Platform.Channel) 1017 } else { 1018 base, err = corebase.ParseBase(release.Base.Name, release.Base.Channel) 1019 } 1020 if err != nil { 1021 c.logger.Errorf("converting version to base: %s", err) 1022 continue 1023 } 1024 // Now that we have default tracks other than latest: 1025 // If a channel is risk only, add latest as the track 1026 // to be more clear for the user facing error message. 1027 // At this point, we do not know the default channel, 1028 // or if the charm has one, therefore risk only output 1029 // is ambiguous. 1030 charmChannel := release.Channel 1031 if charmRisks.Contains(charmChannel) { 1032 charmChannel = "latest/" + charmChannel 1033 } 1034 channelSeries[charmChannel] = append(channelSeries[charmChannel], base.DisplayString()) 1035 } 1036 1037 var suggestions []string 1038 for channel, values := range channelSeries { 1039 suggestions = append(suggestions, fmt.Sprintf("channel %q: available bases are: %s", channel, strings.Join(values, ", "))) 1040 } 1041 return suggestions 1042 } 1043 1044 func resourceFromRevision(rev transport.ResourceRevision) (charmresource.Resource, error) { 1045 resType, err := charmresource.ParseType(rev.Type) 1046 if err != nil { 1047 return charmresource.Resource{}, errors.Trace(err) 1048 } 1049 fp, err := charmresource.ParseFingerprint(rev.Download.HashSHA384) 1050 if err != nil { 1051 return charmresource.Resource{}, errors.Trace(err) 1052 } 1053 r := charmresource.Resource{ 1054 Meta: charmresource.Meta{ 1055 Name: rev.Name, 1056 Type: resType, 1057 Path: rev.Filename, 1058 Description: rev.Description, 1059 }, 1060 Origin: charmresource.OriginStore, 1061 Revision: rev.Revision, 1062 Fingerprint: fp, 1063 Size: int64(rev.Download.Size), 1064 } 1065 return r, nil 1066 }