github.com/juju/juju@v0.0.0-20240430160146-1752b71fcf00/environs/sync/sync.go (about)

     1  // Copyright 2012, 2013 Canonical Ltd.
     2  // Licensed under the AGPLv3, see LICENCE file for details.
     3  
     4  package sync
     5  
     6  import (
     7  	"bytes"
     8  	"context"
     9  	"io"
    10  	"os"
    11  	"path/filepath"
    12  
    13  	"github.com/juju/errors"
    14  	"github.com/juju/http/v2"
    15  	"github.com/juju/loggo"
    16  	"github.com/juju/utils/v3"
    17  	"github.com/juju/version/v2"
    18  
    19  	"github.com/juju/juju/environs/filestorage"
    20  	"github.com/juju/juju/environs/simplestreams"
    21  	"github.com/juju/juju/environs/storage"
    22  	envtools "github.com/juju/juju/environs/tools"
    23  	"github.com/juju/juju/juju/keys"
    24  	coretools "github.com/juju/juju/tools"
    25  	jujuversion "github.com/juju/juju/version"
    26  )
    27  
    28  var logger = loggo.GetLogger("juju.environs.sync")
    29  
    30  // SyncContext describes the context for tool synchronization.
    31  type SyncContext struct {
    32  	// TargetToolsFinder is a ToolsFinder provided to find existing
    33  	// tools in the target destination.
    34  	TargetToolsFinder ToolsFinder
    35  
    36  	// TargetToolsUploader is a ToolsUploader provided to upload
    37  	// tools to the target destination.
    38  	TargetToolsUploader ToolsUploader
    39  
    40  	// AllVersions controls the copy of all versions, not only the latest.
    41  	// TODO: remove here because it's only used for tests!
    42  	AllVersions bool
    43  
    44  	// DryRun controls that nothing is copied. Instead it's logged
    45  	// what would be coppied.
    46  	DryRun bool
    47  
    48  	// Stream specifies the simplestreams stream to use (defaults to "Released").
    49  	Stream string
    50  
    51  	// Source, if non-empty, specifies a directory in the local file system
    52  	// to use as a source.
    53  	Source string
    54  
    55  	// ChosenVersion is the requested version to upload.
    56  	ChosenVersion version.Number
    57  }
    58  
    59  // ToolsFinder provides an interface for finding tools of a specified version.
    60  type ToolsFinder interface {
    61  	// FindTools returns a list of tools with the specified major version in the specified stream.
    62  	FindTools(major int, stream string) (coretools.List, error)
    63  }
    64  
    65  // ToolsUploader provides an interface for uploading tools and associated
    66  // metadata.
    67  type ToolsUploader interface {
    68  	// UploadTools uploads the tools with the specified version and tarball contents.
    69  	UploadTools(toolsDir, stream string, tools *coretools.Tools, data []byte) error
    70  }
    71  
    72  // SyncTools copies the Juju tools tarball from the official bucket
    73  // or a specified source directory into the user's environment.
    74  func SyncTools(syncContext *SyncContext) error {
    75  	sourceDataSource, err := selectSourceDatasource(syncContext)
    76  	if err != nil {
    77  		return errors.Trace(err)
    78  	}
    79  
    80  	logger.Infof("listing available agent binaries")
    81  	if syncContext.ChosenVersion.Major == 0 && syncContext.ChosenVersion.Minor == 0 {
    82  		syncContext.ChosenVersion.Major = jujuversion.Current.Major
    83  		syncContext.ChosenVersion.Minor = -1
    84  		if !syncContext.AllVersions {
    85  			syncContext.ChosenVersion.Minor = jujuversion.Current.Minor
    86  		}
    87  	}
    88  
    89  	toolsDir := syncContext.Stream
    90  	// If no stream has been specified, assume "released" for non-devel versions of Juju.
    91  	if syncContext.Stream == "" {
    92  		// We now store the tools in a directory named after their stream, but the
    93  		// legacy behaviour is to store all tools in a single "releases" directory.
    94  		toolsDir = envtools.ReleasedStream
    95  		// Always use the primary stream here - the user can specify
    96  		// to override that decision.
    97  		syncContext.Stream = envtools.PreferredStreams(&jujuversion.Current, false, "")[0]
    98  	}
    99  	// TODO (stickupkid): We should lift this simplestreams constructor out of
   100  	// this function.
   101  	ss := simplestreams.NewSimpleStreams(simplestreams.DefaultDataSourceFactory())
   102  	// For backwards compatibility with cloud storage, if there are no tools in the specified stream,
   103  	// double check the release stream.
   104  	// TODO - remove this when we no longer need to support cloud storage upgrades.
   105  	streams := []string{syncContext.Stream, envtools.ReleasedStream}
   106  	sourceTools, err := envtools.FindToolsForCloud(
   107  		ss,
   108  		[]simplestreams.DataSource{sourceDataSource}, simplestreams.CloudSpec{},
   109  		streams, syncContext.ChosenVersion.Major, syncContext.ChosenVersion.Minor, coretools.Filter{})
   110  	if err != nil {
   111  		return errors.Trace(err)
   112  	}
   113  
   114  	logger.Infof("found %d agent binaries", len(sourceTools))
   115  	for _, tool := range sourceTools {
   116  		logger.Debugf("found source agent binary: %v", tool)
   117  	}
   118  
   119  	logger.Infof("listing target agent binaries storage")
   120  
   121  	result, err := sourceTools.Match(coretools.Filter{Number: syncContext.ChosenVersion})
   122  	logger.Tracef("syncContext.ChosenVersion %s, result %s", syncContext.ChosenVersion, result)
   123  	if err != nil {
   124  		return errors.Wrap(err, errors.NotFoundf("%q", syncContext.ChosenVersion))
   125  	}
   126  	_, chosenList := result.Newest()
   127  	logger.Tracef("syncContext.ChosenVersion %s, chosenList %s", syncContext.ChosenVersion, chosenList)
   128  	if syncContext.TargetToolsFinder != nil {
   129  		targetTools, err := syncContext.TargetToolsFinder.FindTools(
   130  			syncContext.ChosenVersion.Major, syncContext.Stream,
   131  		)
   132  		switch err {
   133  		case nil, coretools.ErrNoMatches, envtools.ErrNoTools:
   134  		default:
   135  			return errors.Trace(err)
   136  		}
   137  		for _, tool := range targetTools {
   138  			logger.Debugf("found target agent binary: %v", tool)
   139  		}
   140  		if targetTools.Exclude(chosenList).Len() != targetTools.Len() {
   141  			// already in target.
   142  			return nil
   143  		}
   144  	}
   145  
   146  	if syncContext.DryRun {
   147  		for _, tools := range chosenList {
   148  			logger.Infof("copying %s from %s", tools.Version, tools.URL)
   149  		}
   150  		return nil
   151  	}
   152  
   153  	err = copyTools(toolsDir, syncContext.Stream, chosenList, syncContext.TargetToolsUploader)
   154  	if err != nil {
   155  		return err
   156  	}
   157  	logger.Infof("copied %d agent binaries", len(chosenList))
   158  	return nil
   159  }
   160  
   161  // selectSourceDatasource returns a storage reader based on the source setting.
   162  func selectSourceDatasource(syncContext *SyncContext) (simplestreams.DataSource, error) {
   163  	source := syncContext.Source
   164  	if source == "" {
   165  		source = envtools.DefaultBaseURL
   166  	}
   167  	sourceURL, err := envtools.ToolsURL(source)
   168  	if err != nil {
   169  		return nil, err
   170  	}
   171  	logger.Infof("source for sync of agent binaries: %v", sourceURL)
   172  	config := simplestreams.Config{
   173  		Description:          "sync agent binaries source",
   174  		BaseURL:              sourceURL,
   175  		PublicSigningKey:     keys.JujuPublicKey,
   176  		HostnameVerification: true,
   177  		Priority:             simplestreams.CUSTOM_CLOUD_DATA,
   178  	}
   179  	if err := config.Validate(); err != nil {
   180  		return nil, errors.Annotate(err, "simplestreams config validation failed")
   181  	}
   182  	return simplestreams.NewDataSource(config), nil
   183  }
   184  
   185  // copyTools copies a set of tools from the source to the target.
   186  func copyTools(toolsDir, stream string, tools []*coretools.Tools, u ToolsUploader) error {
   187  	for _, tool := range tools {
   188  		logger.Infof("copying %s from %s", tool.Version, tool.URL)
   189  		if err := copyOneToolsPackage(toolsDir, stream, tool, u); err != nil {
   190  			return err
   191  		}
   192  	}
   193  	return nil
   194  }
   195  
   196  // copyOneToolsPackage copies one tool from the source to the target.
   197  func copyOneToolsPackage(toolsDir, stream string, tools *coretools.Tools, u ToolsUploader) error {
   198  	toolsName := envtools.StorageName(tools.Version, toolsDir)
   199  	logger.Infof("downloading %q %v (%v)", stream, toolsName, tools.URL)
   200  	client := http.NewClient()
   201  	resp, err := client.Get(context.TODO(), tools.URL)
   202  	if err != nil {
   203  		return err
   204  	}
   205  	defer func() { _ = resp.Body.Close() }()
   206  	// Verify SHA-256 hash.
   207  	var buf bytes.Buffer
   208  	sha256, size, err := utils.ReadSHA256(io.TeeReader(resp.Body, &buf))
   209  	if err != nil {
   210  		return err
   211  	}
   212  	if tools.SHA256 == "" {
   213  		logger.Errorf("no SHA-256 hash for %v", tools.SHA256) // TODO(dfc) can you spot the bug ?
   214  	} else if sha256 != tools.SHA256 {
   215  		return errors.Errorf("SHA-256 hash mismatch (%v/%v)", sha256, tools.SHA256)
   216  	}
   217  	sizeInKB := (size + 512) / 1024
   218  	logger.Infof("uploading %v (%dkB) to model", toolsName, sizeInKB)
   219  	return u.UploadTools(toolsDir, stream, tools, buf.Bytes())
   220  }
   221  
   222  // UploadFunc is the type of Upload, which may be
   223  // reassigned to control the behaviour of tools
   224  // uploading.
   225  type UploadFunc func(
   226  	envtools.SimplestreamsFetcher, storage.Storage, string,
   227  	func(vers version.Number) version.Number,
   228  ) (*coretools.Tools, error)
   229  
   230  // Upload is exported for testing.
   231  var Upload UploadFunc = upload
   232  
   233  // upload builds whatever version of github.com/juju/juju is in $GOPATH,
   234  // uploads it to the given storage, and returns a Tools instance describing
   235  // them. If forceVersion is not nil, the uploaded tools bundle will report
   236  // the given version number.
   237  func upload(
   238  	ss envtools.SimplestreamsFetcher, store storage.Storage, stream string,
   239  	f func(vers version.Number) version.Number,
   240  ) (*coretools.Tools, error) {
   241  	if f == nil {
   242  		f = func(vers version.Number) version.Number { return vers }
   243  	}
   244  	builtTools, err := BuildAgentTarball(true, stream, f)
   245  	if err != nil {
   246  		return nil, err
   247  	}
   248  	defer os.RemoveAll(builtTools.Dir)
   249  	return syncBuiltTools(ss, store, stream, builtTools)
   250  }
   251  
   252  // generateAgentMetadata copies the built tools tarball into a tarball for the specified
   253  // stream and series and generates corresponding metadata.
   254  func generateAgentMetadata(ss envtools.SimplestreamsFetcher, toolsInfo *BuiltAgent, stream string) error {
   255  	// Copy the tools to the target storage, recording a Tools struct for each one.
   256  	var targetTools coretools.List
   257  	targetTools = append(targetTools, &coretools.Tools{
   258  		Version: toolsInfo.Version,
   259  		Size:    toolsInfo.Size,
   260  		SHA256:  toolsInfo.Sha256Hash,
   261  	})
   262  	// The tools have been copied to a temp location from which they will be uploaded,
   263  	// now write out the matching simplestreams metadata so that SyncTools can find them.
   264  	metadataStore, err := filestorage.NewFileStorageWriter(toolsInfo.Dir)
   265  	if err != nil {
   266  		return err
   267  	}
   268  	logger.Debugf("generating agent metadata")
   269  	return envtools.MergeAndWriteMetadata(ss, metadataStore, stream, stream, targetTools, false)
   270  }
   271  
   272  // BuiltAgent contains metadata for a tools tarball resulting from
   273  // a call to BundleTools.
   274  type BuiltAgent struct {
   275  	Version     version.Binary
   276  	Official    bool
   277  	Dir         string
   278  	StorageName string
   279  	Sha256Hash  string
   280  	Size        int64
   281  }
   282  
   283  // BuildAgentTarballFunc is a function which can build an agent tarball.
   284  type BuildAgentTarballFunc func(
   285  	build bool, stream string, getForceVersion func(version.Number) version.Number,
   286  ) (*BuiltAgent, error)
   287  
   288  // Override for testing.
   289  var BuildAgentTarball BuildAgentTarballFunc = buildAgentTarball
   290  
   291  // BuildAgentTarball bundles an agent tarball and places it in a temp directory in
   292  // the expected agent path.
   293  func buildAgentTarball(
   294  	build bool, stream string,
   295  	getForceVersion func(version.Number) version.Number,
   296  ) (_ *BuiltAgent, err error) {
   297  	// TODO(rog) find binaries from $PATH when not using a development
   298  	// version of juju within a $GOPATH.
   299  
   300  	logger.Debugf("Making agent binary tarball")
   301  	// We create the entire archive before asking the environment to
   302  	// start uploading so that we can be sure we have archived
   303  	// correctly.
   304  	f, err := os.CreateTemp("", "juju-tgz")
   305  	if err != nil {
   306  		return nil, err
   307  	}
   308  	defer func() {
   309  		_ = f.Close()
   310  		_ = os.Remove(f.Name())
   311  	}()
   312  
   313  	toolsVersion, forceVersion, official, sha256Hash, err := envtools.BundleTools(build, f, getForceVersion)
   314  	if err != nil {
   315  		return nil, err
   316  	}
   317  	// Built agent version needs to match the client used to bootstrap.
   318  	builtVersion := toolsVersion
   319  	builtVersion.Build = 0
   320  	clientVersion := jujuversion.Current
   321  	clientVersion.Build = 0
   322  	if builtVersion.Number.Compare(clientVersion) != 0 {
   323  		return nil, errors.Errorf(
   324  			"agent binary %v not compatible with bootstrap client %v",
   325  			toolsVersion.Number, jujuversion.Current,
   326  		)
   327  	}
   328  	fileInfo, err := f.Stat()
   329  	if err != nil {
   330  		return nil, errors.Errorf("cannot stat newly made agent binary archive: %v", err)
   331  	}
   332  	size := fileInfo.Size()
   333  	agentBinary := "agent binary"
   334  	if official {
   335  		agentBinary = "official agent binary"
   336  	}
   337  	logger.Infof("using %s %v aliased to %v (%dkB)", agentBinary, toolsVersion, forceVersion, (size+512)/1024)
   338  
   339  	baseToolsDir, err := os.MkdirTemp("", "juju-tools")
   340  	if err != nil {
   341  		return nil, err
   342  	}
   343  
   344  	// If we exit with an error, clean up the built tools directory.
   345  	defer func() {
   346  		if err != nil {
   347  			os.RemoveAll(baseToolsDir)
   348  		}
   349  	}()
   350  
   351  	err = os.MkdirAll(filepath.Join(baseToolsDir, storage.BaseToolsPath, stream), 0755)
   352  	if err != nil {
   353  		return nil, err
   354  	}
   355  	storageName := envtools.StorageName(toolsVersion, stream)
   356  	err = utils.CopyFile(filepath.Join(baseToolsDir, storageName), f.Name())
   357  	if err != nil {
   358  		return nil, err
   359  	}
   360  	return &BuiltAgent{
   361  		Version:     toolsVersion,
   362  		Official:    official,
   363  		Dir:         baseToolsDir,
   364  		StorageName: storageName,
   365  		Size:        size,
   366  		Sha256Hash:  sha256Hash,
   367  	}, nil
   368  }
   369  
   370  // syncBuiltTools copies to storage a tools tarball and cloned copies for each series.
   371  func syncBuiltTools(ss envtools.SimplestreamsFetcher, store storage.Storage, stream string, builtTools *BuiltAgent) (*coretools.Tools, error) {
   372  	if err := generateAgentMetadata(ss, builtTools, stream); err != nil {
   373  		return nil, err
   374  	}
   375  	syncContext := &SyncContext{
   376  		Source:            builtTools.Dir,
   377  		TargetToolsFinder: StorageToolsFinder{store},
   378  		TargetToolsUploader: StorageToolsUploader{
   379  			Fetcher:       ss,
   380  			Storage:       store,
   381  			WriteMetadata: false,
   382  			WriteMirrors:  false,
   383  		},
   384  		AllVersions:   true,
   385  		Stream:        stream,
   386  		ChosenVersion: builtTools.Version.Number,
   387  	}
   388  	logger.Debugf("uploading agent binaries to cloud storage")
   389  	err := SyncTools(syncContext)
   390  	if err != nil {
   391  		return nil, err
   392  	}
   393  	url, err := store.URL(builtTools.StorageName)
   394  	if err != nil {
   395  		return nil, err
   396  	}
   397  	return &coretools.Tools{
   398  		Version: builtTools.Version,
   399  		URL:     url,
   400  		Size:    builtTools.Size,
   401  		SHA256:  builtTools.Sha256Hash,
   402  	}, nil
   403  }
   404  
   405  // StorageToolsFinder is an implementation of ToolsFinder
   406  // that searches for tools in the specified storage.
   407  type StorageToolsFinder struct {
   408  	Storage storage.StorageReader
   409  }
   410  
   411  func (f StorageToolsFinder) FindTools(major int, stream string) (coretools.List, error) {
   412  	return envtools.ReadList(f.Storage, stream, major, -1)
   413  }
   414  
   415  // StorageToolsUploader is an implementation of ToolsUploader that
   416  // writes tools to the provided storage and then writes merged
   417  // metadata, optionally with mirrors.
   418  type StorageToolsUploader struct {
   419  	Fetcher       envtools.SimplestreamsFetcher
   420  	Storage       storage.Storage
   421  	WriteMetadata bool
   422  	WriteMirrors  envtools.ShouldWriteMirrors
   423  }
   424  
   425  func (u StorageToolsUploader) UploadTools(toolsDir, stream string, tools *coretools.Tools, data []byte) error {
   426  	toolsName := envtools.StorageName(tools.Version, toolsDir)
   427  	if err := u.Storage.Put(toolsName, bytes.NewReader(data), int64(len(data))); err != nil {
   428  		return err
   429  	}
   430  	if !u.WriteMetadata {
   431  		return nil
   432  	}
   433  	err := envtools.MergeAndWriteMetadata(u.Fetcher, u.Storage, toolsDir, stream, coretools.List{tools}, u.WriteMirrors)
   434  	if err != nil {
   435  		logger.Errorf("error writing agent metadata: %v", err)
   436  		return err
   437  	}
   438  	return nil
   439  }