github.com/mutagen-io/mutagen@v0.18.0-rc1/pkg/agent/bundle.go (about)

     1  package agent
     2  
     3  import (
     4  	"archive/tar"
     5  	"errors"
     6  	"fmt"
     7  	"io"
     8  	"os"
     9  	"path/filepath"
    10  	"runtime"
    11  
    12  	"github.com/klauspost/compress/gzip"
    13  
    14  	"github.com/mutagen-io/mutagen/pkg/filesystem"
    15  	"github.com/mutagen-io/mutagen/pkg/mutagen"
    16  	"github.com/mutagen-io/mutagen/pkg/platform"
    17  )
    18  
    19  const (
    20  	// BundleName is the base name of the agent bundle.
    21  	BundleName = "mutagen-agents.tar.gz"
    22  )
    23  
    24  // BundleLocation encodes an expected location for the agent bundle.
    25  type BundleLocation uint8
    26  
    27  const (
    28  	// BundleLocationDefault indicates that the ExecutableForPlatform function
    29  	// should expect to find the agent bundle in the same directory as the
    30  	// current executable or (if the current executable resides in the "bin"
    31  	// directory of a Filesystem Hierarchy Standard layout) in the libexec
    32  	// directory.
    33  	BundleLocationDefault BundleLocation = iota
    34  	// BundleLocationBuildDirectory indicates that the ExecutableForPlatform
    35  	// function should expect to find the agent bundle in the Mutagen build
    36  	// directory. This mode is only used during integration testing. It is
    37  	// required because test executables will be built in temporary directories.
    38  	BundleLocationBuildDirectory
    39  )
    40  
    41  // ExpectedBundleLocation specifies the expected agent bundle location. It is
    42  // set by the time that init functions have completed. After that, it should
    43  // only be set at the process entry point, before any code calls into the agent
    44  // package.
    45  var ExpectedBundleLocation BundleLocation
    46  
    47  // ExecutableForPlatform attempts to locate the agent bundle and extract an
    48  // agent executable for the specified target platform. If no output path is
    49  // specified, then the extracted file will be in a temporary location accessible
    50  // to only the user, will have an appropriate extension for the target platform,
    51  // and will have the executability bit set if it makes sense. The path to the
    52  // extracted file will be returned, and the caller is responsible for cleaning
    53  // up the file if this function returns a nil error.
    54  func ExecutableForPlatform(goos, goarch, outputPath string) (string, error) {
    55  	// Compute the path to the location in which we expect to find the agent
    56  	// bundle.
    57  	var bundleSearchPaths []string
    58  	if ExpectedBundleLocation == BundleLocationDefault {
    59  		// Add the executable directory as a search path.
    60  		if executablePath, err := os.Executable(); err != nil {
    61  			return "", fmt.Errorf("unable to determine executable path: %w", err)
    62  		} else {
    63  			bundleSearchPaths = append(bundleSearchPaths, filepath.Dir(executablePath))
    64  		}
    65  
    66  		// If the executable is in what appears to be a Filesystem Hierarchy
    67  		// Standard layout, then add the libexec directory as a search path.
    68  		if libexecPath, err := filesystem.LibexecPath(); err == nil {
    69  			bundleSearchPaths = append(bundleSearchPaths, libexecPath)
    70  		}
    71  	} else if ExpectedBundleLocation == BundleLocationBuildDirectory {
    72  		if sourceTreePath, err := mutagen.SourceTreePath(); err != nil {
    73  			return "", fmt.Errorf("unable to determine Mutagen source tree path: %w", err)
    74  		} else {
    75  			bundleSearchPaths = append(bundleSearchPaths, filepath.Join(sourceTreePath, mutagen.BuildDirectoryName))
    76  		}
    77  	} else {
    78  		panic("invalid bundle location specification")
    79  	}
    80  
    81  	// Loop until we find a bundle file. If we fail to locate a bundle, then
    82  	// abort. If we succeed, then defer its closure.
    83  	var bundle *os.File
    84  	for _, path := range bundleSearchPaths {
    85  		bundlePath := filepath.Join(path, BundleName)
    86  		if file, err := os.Open(bundlePath); err != nil {
    87  			if os.IsNotExist(err) {
    88  				continue
    89  			}
    90  			return "", fmt.Errorf("unable to open agent bundle (%s): %w", bundlePath, err)
    91  		} else if metadata, err := file.Stat(); err != nil {
    92  			file.Close()
    93  			return "", fmt.Errorf("unable to access agent bundle (%s) file metadata: %w", bundlePath, err)
    94  		} else if metadata.Mode()&os.ModeType != 0 {
    95  			file.Close()
    96  			return "", fmt.Errorf("agent bundle (%s) is not a file", bundlePath)
    97  		} else {
    98  			bundle = file
    99  			defer bundle.Close()
   100  		}
   101  	}
   102  	if bundle == nil {
   103  		return "", fmt.Errorf("unable to locate agent bundle (search paths: %v)", bundleSearchPaths)
   104  	}
   105  
   106  	// Create a decompressor and defer its closure.
   107  	bundleDecompressor, err := gzip.NewReader(bundle)
   108  	if err != nil {
   109  		return "", fmt.Errorf("unable to decompress agent bundle: %w", err)
   110  	}
   111  	defer bundleDecompressor.Close()
   112  
   113  	// Create an archive reader.
   114  	bundleArchive := tar.NewReader(bundleDecompressor)
   115  
   116  	// Scan until we find a matching header.
   117  	var header *tar.Header
   118  	for {
   119  		if h, err := bundleArchive.Next(); err != nil {
   120  			if err == io.EOF {
   121  				break
   122  			}
   123  			return "", fmt.Errorf("unable to read archive header: %w", err)
   124  		} else if h.Name == fmt.Sprintf("%s_%s", goos, goarch) {
   125  			header = h
   126  			break
   127  		}
   128  	}
   129  
   130  	// Check if we have a valid header. If not, there was no match.
   131  	if header == nil {
   132  		return "", errors.New("unsupported platform")
   133  	}
   134  
   135  	// If an output path has been specified, then open the path for writing,
   136  	// otherwise create a temporary file.
   137  	var file *os.File
   138  	if outputPath != "" {
   139  		file, err = os.OpenFile(outputPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
   140  	} else {
   141  		file, err = os.CreateTemp("", platform.ExecutableName(BaseName+".*", goos))
   142  	}
   143  	if err != nil {
   144  		return "", fmt.Errorf("unable to create output file: %w", err)
   145  	}
   146  
   147  	// Copy data into the file.
   148  	if _, err := io.CopyN(file, bundleArchive, header.Size); err != nil {
   149  		file.Close()
   150  		os.Remove(file.Name())
   151  		return "", fmt.Errorf("unable to copy agent data: %w", err)
   152  	}
   153  
   154  	// If we're not on Windows and our target system is not Windows, mark the
   155  	// file as executable. This will save us an additional "chmod +x" command
   156  	// during agent installation. Note that the mechanism we use here
   157  	// (os.File.Chmod) does not work on Windows (only the path-based os.Chmod is
   158  	// supported there), but this is fine because this code wouldn't make sense
   159  	// to use on Windows in any scenario (where executability bits don't exist).
   160  	if runtime.GOOS != "windows" && goos != "windows" {
   161  		if err := file.Chmod(0700); err != nil {
   162  			file.Close()
   163  			os.Remove(file.Name())
   164  			return "", fmt.Errorf("unable to make agent executable: %w", err)
   165  		}
   166  	}
   167  
   168  	// Close the file.
   169  	if err := file.Close(); err != nil {
   170  		os.Remove(file.Name())
   171  		return "", fmt.Errorf("unable to close temporary file: %w", err)
   172  	}
   173  
   174  	// Success.
   175  	return file.Name(), nil
   176  }