github.com/juju/juju@v0.0.0-20240430160146-1752b71fcf00/charmhub/refresh.go (about) 1 // Copyright 2020 Canonical Ltd. 2 // Licensed under the AGPLv3, see LICENCE file for details. 3 4 package charmhub 5 6 import ( 7 "context" 8 "crypto/sha512" 9 "encoding/base64" 10 "fmt" 11 "net/http" 12 "strings" 13 14 "github.com/juju/collections/set" 15 "github.com/juju/errors" 16 "github.com/juju/loggo" 17 "github.com/juju/names/v5" 18 "github.com/juju/utils/v3" 19 "github.com/kr/pretty" 20 "golang.org/x/crypto/pbkdf2" 21 22 "github.com/juju/juju/charmhub/path" 23 "github.com/juju/juju/charmhub/transport" 24 corebase "github.com/juju/juju/core/base" 25 charmmetrics "github.com/juju/juju/core/charm/metrics" 26 corelogger "github.com/juju/juju/core/logger" 27 "github.com/juju/juju/version" 28 ) 29 30 // action represents the type of refresh is performed. 31 type action string 32 33 const ( 34 // installAction defines a install action. 35 installAction action = "install" 36 37 // downloadAction defines a download action. 38 downloadAction action = "download" 39 40 // refreshAction defines a refresh action. 41 refreshAction action = "refresh" 42 ) 43 44 var ( 45 // A set of fields that are always requested when performing refresh calls 46 requiredRefreshFields = set.NewStrings( 47 "download", "id", "license", "name", "publisher", "resources", 48 "revision", "summary", "type", "version", "bases", "config-yaml", 49 "metadata-yaml", 50 ).SortedValues() 51 ) 52 53 const ( 54 // notAvailable is used a placeholder for Name and Channel for a refresh 55 // base request, if the Name and Channel is not known. 56 notAvailable = "NA" 57 ) 58 59 // RefreshBase defines a base for selecting a specific charm. 60 // Continues to exist to allow for incoming bases to be converted 61 // to bases inside this package. 62 type RefreshBase struct { 63 Architecture string 64 Name string 65 Channel string 66 } 67 68 func (p RefreshBase) String() string { 69 path := p.Architecture 70 if p.Channel != "" { 71 if p.Name != "" { 72 path = fmt.Sprintf("%s/%s", path, p.Name) 73 } 74 path = fmt.Sprintf("%s/%s", path, p.Channel) 75 } 76 return path 77 } 78 79 // refreshClient defines a client for refresh requests. 80 type refreshClient struct { 81 path path.Path 82 client RESTClient 83 logger Logger 84 } 85 86 // newRefreshClient creates a refreshClient for requesting 87 func newRefreshClient(path path.Path, client RESTClient, logger Logger) *refreshClient { 88 return &refreshClient{ 89 path: path, 90 client: client, 91 logger: logger, 92 } 93 } 94 95 // Refresh is used to refresh installed charms to a more suitable revision. 96 func (c *refreshClient) Refresh(ctx context.Context, config RefreshConfig) ([]transport.RefreshResponse, error) { 97 if c.logger.IsTraceEnabled() { 98 c.logger.Tracef("Refresh(%s)", pretty.Sprint(config)) 99 } 100 req, err := config.Build() 101 if err != nil { 102 return nil, errors.Trace(err) 103 } 104 return c.refresh(ctx, config.Ensure, req) 105 } 106 107 // RefreshWithRequestMetrics is to get refreshed charm data and provide metrics 108 // at the same time. Used as part of the charm revision updater facade. 109 func (c *refreshClient) RefreshWithRequestMetrics(ctx context.Context, config RefreshConfig, metrics map[charmmetrics.MetricKey]map[charmmetrics.MetricKey]string) ([]transport.RefreshResponse, error) { 110 if c.logger.IsTraceEnabled() { 111 c.logger.Tracef("RefreshWithRequestMetrics(%s, %+v)", pretty.Sprint(config), metrics) 112 } 113 req, err := config.Build() 114 if err != nil { 115 return nil, errors.Trace(err) 116 } 117 m, err := contextMetrics(metrics) 118 if err != nil { 119 return nil, errors.Trace(err) 120 } 121 req.Metrics = m 122 return c.refresh(ctx, config.Ensure, req) 123 } 124 125 // RefreshWithMetricsOnly is to provide metrics without context or actions. Used 126 // as part of the charm revision updater facade. 127 func (c *refreshClient) RefreshWithMetricsOnly(ctx context.Context, metrics map[charmmetrics.MetricKey]map[charmmetrics.MetricKey]string) error { 128 c.logger.Tracef("RefreshWithMetricsOnly(%+v)", metrics) 129 m, err := contextMetrics(metrics) 130 if err != nil { 131 return errors.Trace(err) 132 } 133 req := transport.RefreshRequest{ 134 Context: []transport.RefreshRequestContext{}, 135 Actions: []transport.RefreshRequestAction{}, 136 Metrics: m, 137 } 138 139 // No need to ensure data which is not expected. 140 ensure := func(responses []transport.RefreshResponse) error { return nil } 141 142 _, err = c.refresh(ctx, ensure, req) 143 return err 144 } 145 146 func contextMetrics(metrics map[charmmetrics.MetricKey]map[charmmetrics.MetricKey]string) (transport.RequestMetrics, error) { 147 m := make(transport.RequestMetrics) 148 for k, v := range metrics { 149 // verify top level "model" and "controller" keys 150 if k != charmmetrics.Controller && k != charmmetrics.Model { 151 return nil, errors.Trace(errors.NotValidf("highlevel metrics label %q", k)) 152 } 153 ctxM := make(map[string]string, len(v)) 154 for k2, v2 := range v { 155 ctxM[k2.String()] = v2 156 } 157 m[k.String()] = ctxM 158 } 159 return m, nil 160 } 161 162 func (c *refreshClient) refresh(ctx context.Context, ensure func(responses []transport.RefreshResponse) error, req transport.RefreshRequest) ([]transport.RefreshResponse, error) { 163 httpHeaders := make(http.Header) 164 165 var resp transport.RefreshResponses 166 restResp, err := c.client.Post(ctx, c.path, httpHeaders, req, &resp) 167 if err != nil { 168 return nil, errors.Trace(err) 169 } 170 if restResp.StatusCode == http.StatusNotFound { 171 return nil, logAndReturnError(errors.NotFoundf("refresh")) 172 } 173 if err := handleBasicAPIErrors(resp.ErrorList, c.logger); err != nil { 174 return nil, errors.Trace(err) 175 } 176 // Ensure that all the results contain the correct instance keys. 177 if err := ensure(resp.Results); err != nil { 178 return nil, errors.Trace(err) 179 } 180 // Exit early. 181 if len(resp.Results) <= 1 { 182 return resp.Results, nil 183 } 184 185 // As the results are not expected to be in the correct order, sort them 186 // to prevent others falling into not RTFM! 187 indexes := make(map[string]int, len(req.Actions)) 188 for i, action := range req.Actions { 189 indexes[action.InstanceKey] = i 190 } 191 results := make([]transport.RefreshResponse, len(resp.Results)) 192 for _, result := range resp.Results { 193 results[indexes[result.InstanceKey]] = result 194 } 195 196 if c.logger.IsTraceEnabled() { 197 c.logger.Tracef("Refresh() unmarshalled: %s", pretty.Sprint(results)) 198 } 199 return results, nil 200 } 201 202 // RefreshOne creates a request config for requesting only one charm. 203 func RefreshOne(key, id string, revision int, channel string, base RefreshBase) (RefreshConfig, error) { 204 if id == "" { 205 return nil, logAndReturnError(errors.NotValidf("empty id")) 206 } 207 if key == "" { 208 // This is for compatibility reasons. With older clients, the 209 // key created in GetCharmURLOrigin will be lost to and from 210 // the client. Since a key is required, ensure we have one. 211 uuid, err := utils.NewUUID() 212 if err != nil { 213 return nil, logAndReturnError(err) 214 } 215 key = uuid.String() 216 } 217 if err := validateBase(base); err != nil { 218 return nil, logAndReturnError(err) 219 } 220 return refreshOne{ 221 instanceKey: key, 222 ID: id, 223 Revision: revision, 224 Channel: channel, 225 Base: base, 226 fields: requiredRefreshFields, 227 }, nil 228 } 229 230 // CreateInstanceKey creates an InstanceKey which can be unique and stable 231 // from Refresh action to Refresh action. Required for KPI collection 232 // on the charmhub side, see LP:1944582. Rather than saving in 233 // state, use the model uuid + the app name, which are unique. Modeled 234 // after the applicationDoc DocID and globalKey in state. 235 func CreateInstanceKey(app names.ApplicationTag, model names.ModelTag) string { 236 h := pbkdf2.Key([]byte(app.Id()), []byte(model.Id()), 8192, 32, sha512.New) 237 return base64.RawURLEncoding.EncodeToString(h) 238 } 239 240 // InstallOneFromRevision creates a request config using the revision and not 241 // the channel for requesting only one charm. 242 func InstallOneFromRevision(name string, revision int) (RefreshConfig, error) { 243 if name == "" { 244 return nil, logAndReturnError(errors.NotValidf("empty name")) 245 } 246 uuid, err := utils.NewUUID() 247 if err != nil { 248 return nil, logAndReturnError(err) 249 } 250 return executeOneByRevision{ 251 action: installAction, 252 instanceKey: uuid.String(), 253 Name: name, 254 Revision: &revision, 255 fields: requiredRefreshFields, 256 }, nil 257 } 258 259 // AddResource adds resource revision data to a executeOne config. 260 // Used for install by revision. 261 func AddResource(config RefreshConfig, name string, revision int) (RefreshConfig, bool) { 262 c, ok := config.(executeOneByRevision) 263 if !ok { 264 return config, false 265 } 266 if len(c.resourceRevisions) == 0 { 267 c.resourceRevisions = make([]transport.RefreshResourceRevision, 0) 268 } 269 c.resourceRevisions = append(c.resourceRevisions, transport.RefreshResourceRevision{ 270 Name: name, 271 Revision: revision, 272 }) 273 return c, true 274 } 275 276 // AddConfigMetrics adds metrics to a refreshOne config. All values are 277 // applied at once, subsequent calls, replace all values. 278 func AddConfigMetrics(config RefreshConfig, metrics map[charmmetrics.MetricKey]string) (RefreshConfig, error) { 279 c, ok := config.(refreshOne) 280 if !ok { 281 return config, nil // error? 282 } 283 if len(metrics) < 1 { 284 return c, nil 285 } 286 c.metrics = make(transport.ContextMetrics) 287 for k, v := range metrics { 288 c.metrics[k.String()] = v 289 } 290 return c, nil 291 } 292 293 // InstallOneFromChannel creates a request config using the channel and not the 294 // revision for requesting only one charm. 295 func InstallOneFromChannel(name string, channel string, base RefreshBase) (RefreshConfig, error) { 296 if name == "" { 297 return nil, logAndReturnError(errors.NotValidf("empty name")) 298 } 299 if err := validateBase(base); err != nil { 300 return nil, logAndReturnError(err) 301 } 302 uuid, err := utils.NewUUID() 303 if err != nil { 304 return nil, logAndReturnError(err) 305 } 306 return executeOne{ 307 action: installAction, 308 instanceKey: uuid.String(), 309 Name: name, 310 Channel: &channel, 311 Base: base, 312 fields: requiredRefreshFields, 313 }, nil 314 } 315 316 // DownloadOneFromRevision creates a request config using the revision and not 317 // the channel for requesting only one charm. 318 func DownloadOneFromRevision(id string, revision int) (RefreshConfig, error) { 319 if id == "" { 320 return nil, logAndReturnError(errors.NotValidf("empty id")) 321 } 322 uuid, err := utils.NewUUID() 323 if err != nil { 324 return nil, logAndReturnError(err) 325 } 326 return executeOneByRevision{ 327 action: downloadAction, 328 instanceKey: uuid.String(), 329 ID: id, 330 Revision: &revision, 331 fields: requiredRefreshFields, 332 }, nil 333 } 334 335 // DownloadOneFromRevisionByName creates a request config using the revision and not 336 // the channel for requesting only one charm. 337 func DownloadOneFromRevisionByName(name string, revision int) (RefreshConfig, error) { 338 if name == "" { 339 return nil, logAndReturnError(errors.NotValidf("empty name")) 340 } 341 uuid, err := utils.NewUUID() 342 if err != nil { 343 return nil, logAndReturnError(err) 344 } 345 return executeOneByRevision{ 346 action: downloadAction, 347 instanceKey: uuid.String(), 348 Name: name, 349 Revision: &revision, 350 fields: requiredRefreshFields, 351 }, nil 352 } 353 354 // DownloadOneFromChannel creates a request config using the channel and not the 355 // revision for requesting only one charm. 356 func DownloadOneFromChannel(id string, channel string, base RefreshBase) (RefreshConfig, error) { 357 if id == "" { 358 return nil, logAndReturnError(errors.NotValidf("empty id")) 359 } 360 if err := validateBase(base); err != nil { 361 return nil, logAndReturnError(err) 362 } 363 uuid, err := utils.NewUUID() 364 if err != nil { 365 return nil, logAndReturnError(err) 366 } 367 return executeOne{ 368 action: downloadAction, 369 instanceKey: uuid.String(), 370 ID: id, 371 Channel: &channel, 372 Base: base, 373 fields: requiredRefreshFields, 374 }, nil 375 } 376 377 // DownloadOneFromChannelByName creates a request config using the channel and not the 378 // revision for requesting only one charm. 379 func DownloadOneFromChannelByName(name string, channel string, base RefreshBase) (RefreshConfig, error) { 380 if name == "" { 381 return nil, logAndReturnError(errors.NotValidf("empty name")) 382 } 383 if err := validateBase(base); err != nil { 384 return nil, logAndReturnError(err) 385 } 386 uuid, err := utils.NewUUID() 387 if err != nil { 388 return nil, logAndReturnError(err) 389 } 390 return executeOne{ 391 action: downloadAction, 392 instanceKey: uuid.String(), 393 Name: name, 394 Channel: &channel, 395 Base: base, 396 fields: requiredRefreshFields, 397 }, nil 398 } 399 400 // constructRefreshBase creates a refresh request base that allows for 401 // partial base queries. 402 func constructRefreshBase(base RefreshBase) (transport.Base, error) { 403 if base.Architecture == "" { 404 return transport.Base{}, logAndReturnError(errors.NotValidf("refresh arch")) 405 } 406 407 name := base.Name 408 if name == "" { 409 name = notAvailable 410 } 411 412 var channel string 413 switch base.Channel { 414 case "": 415 channel = notAvailable 416 case "kubernetes": 417 // Kubernetes is not a valid channel for a base. 418 // Instead use the latest LTS version of ubuntu. 419 b := version.DefaultSupportedLTSBase() 420 name = b.OS 421 channel = b.Channel.Track 422 default: 423 var err error 424 channel, err = sanitiseChannel(base.Channel) 425 if err != nil { 426 return transport.Base{}, logAndReturnError(errors.Trace(err)) 427 } 428 } 429 430 return transport.Base{ 431 Architecture: base.Architecture, 432 Name: name, 433 Channel: channel, 434 }, nil 435 } 436 437 // sanitiseChannel returns a channel, sanitised for charmhub 438 // 439 // Sometimes channels we receive include a risk, which charmhub 440 // cannot understand. So ensure any risk is dropped. 441 func sanitiseChannel(channel string) (string, error) { 442 if channel == "" { 443 return channel, nil 444 } 445 ch, err := corebase.ParseChannel(channel) 446 if err != nil { 447 return "", errors.Trace(err) 448 } 449 return ch.Track, nil 450 } 451 452 // validateBase ensures that we do not pass "all" as part of base. 453 // This function is to help find programming related failures. 454 func validateBase(rp RefreshBase) error { 455 var msg []string 456 if rp.Architecture == "all" { 457 msg = append(msg, fmt.Sprintf("Architecture %q", rp.Architecture)) 458 } 459 if rp.Name == "all" { 460 msg = append(msg, fmt.Sprintf("Name %q", rp.Name)) 461 } 462 if rp.Channel == "all" { 463 msg = append(msg, fmt.Sprintf("Channel %q", rp.Channel)) 464 } 465 if len(msg) > 0 { 466 return errors.Trace(errors.NotValidf(strings.Join(msg, ", "))) 467 } 468 return nil 469 } 470 471 type instanceKey interface { 472 InstanceKey() string 473 } 474 475 // ExtractConfigInstanceKey is used to get the instance key from a refresh 476 // config. 477 func ExtractConfigInstanceKey(cfg RefreshConfig) string { 478 key, ok := cfg.(instanceKey) 479 if ok { 480 return key.InstanceKey() 481 } 482 return "" 483 } 484 485 // Ideally we'd avoid the package-level logger and use the Client's one, but 486 // the functions that create a RefreshConfig like RefreshOne don't take 487 // loggers. This logging can sometimes be quite useful to avoid error sources 488 // getting lost across the wire, so leave as is for now. 489 var logger = loggo.GetLoggerWithLabels("juju.charmhub", corelogger.CHARMHUB) 490 491 func logAndReturnError(err error) error { 492 err = errors.Trace(err) 493 logger.Errorf(err.Error()) 494 return err 495 }