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  }