github.com/grafana/synthetic-monitoring-agent@v0.64.2/internal/k6runner/version/repository.go (about)

     1  package version
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"encoding/json"
     7  	"errors"
     8  	"fmt"
     9  	"io/fs"
    10  	"os"
    11  	"os/exec"
    12  	"path/filepath"
    13  	"slices"
    14  	"strings"
    15  	"sync"
    16  	"time"
    17  
    18  	"github.com/Masterminds/semver/v3"
    19  	"github.com/rs/zerolog"
    20  )
    21  
    22  // Repository points to a collection of k6 binaries.
    23  type Repository struct {
    24  	// Root points to a folder in the local filesystem that will be scanned for k6 binaries.
    25  	// All executable files in said folder should be k6 binaries, as they will all be executed with `--version` to map
    26  	// their actual versions.
    27  	Root string
    28  	// Override is the path to a specific k6 binary. If set, all Repository operations will return this path.
    29  	Override string
    30  
    31  	// Logger.
    32  	Logger zerolog.Logger
    33  
    34  	mtx     sync.Mutex
    35  	entries []Entry
    36  }
    37  
    38  // NewRepository creates a new Repository object.
    39  func NewRepository(root, override string) (*Repository, error) {
    40  	if root == "" && override == "" {
    41  		return nil, fmt.Errorf("either root or override should be provided")
    42  	}
    43  
    44  	if root != "" {
    45  		rootStat, err := os.Stat(root)
    46  		if err != nil {
    47  			return nil, fmt.Errorf("could not stat repository root: %w", err)
    48  		}
    49  
    50  		if !rootStat.IsDir() {
    51  			return nil, fmt.Errorf("repository root is not a directory")
    52  		}
    53  	}
    54  
    55  	if override != "" { //nolint:nestif // Complexity is driven by if-err-nil rather than actual logic.
    56  		// If override is not an absolute path, attempt to resolve it using $PATH. If that fails, then the override is
    57  		// useless.
    58  		if !filepath.IsAbs(override) {
    59  			absolute, err := exec.LookPath(override)
    60  			if err != nil {
    61  				return nil, fmt.Errorf("resolving %q using PATH: %w", override, err)
    62  			}
    63  
    64  			override = absolute
    65  		}
    66  
    67  		overrideStat, err := os.Stat(override)
    68  		if err != nil {
    69  			return nil, fmt.Errorf("could not stat repository override: %w", err)
    70  		}
    71  
    72  		if overrideStat.IsDir() || overrideStat.Mode()&0o100 == 0 {
    73  			return nil, fmt.Errorf("repository override should be a single executable file")
    74  		}
    75  	}
    76  
    77  	return &Repository{
    78  		Root:     root,
    79  		Override: override,
    80  	}, nil
    81  }
    82  
    83  const binaryMustContain = "k6"
    84  
    85  // Entry stores the path to a k6 binary, and the parsed semantic version that binary returns.
    86  type Entry struct {
    87  	Path    string          `json:"path"`
    88  	Version *semver.Version `json:"version"`
    89  }
    90  
    91  type k6Version struct {
    92  	Commit    string `json:"commit"`
    93  	GoArch    string `json:"go_arch"`
    94  	GoOs      string `json:"go_os"`
    95  	GoVersion string `json:"go_version"`
    96  	Version   string `json:"version"`
    97  }
    98  
    99  var (
   100  	// ErrUnsatisfiable is returned when no binary matching a constraint is found.
   101  	ErrUnsatisfiable = errors.New("no compatible k6 version found")
   102  	// ErrInvalidConstraint is joined with errors returned by the semver package when attempting to parse a constraint.
   103  	// This allows callers to tell a parsing error apart from other errors.
   104  	ErrInvalidConstraint = errors.New("invalid constraint")
   105  )
   106  
   107  // Resolve walks the repository and returns the entry with the highest version that matches
   108  // constraintStr. If no binary matching the constraint is found, ErrUnsatisfiable is returned.
   109  // Resolve scans the root folder if needed.
   110  func (r *Repository) Resolve(constraintStr string) (*Entry, error) {
   111  	err := r.scan(false)
   112  	if err != nil {
   113  		return nil, fmt.Errorf("scanning for binaries: %w", err)
   114  	}
   115  
   116  	r.mtx.Lock()
   117  	defer r.mtx.Unlock()
   118  
   119  	constraint, err := semver.NewConstraint(constraintStr)
   120  	if err != nil {
   121  		return nil, errors.Join(fmt.Errorf("invalid version constraint %q: %w", constraintStr, err), ErrInvalidConstraint)
   122  	}
   123  
   124  	// r.entries is sorted newest version first, so we can return the first that matches.
   125  	for _, entry := range r.entries {
   126  		if constraint.Check(entry.Version) {
   127  			e := entry // Copy variable to avoid returning pointer to a slice element.
   128  			return &e, nil
   129  		}
   130  
   131  		r.Logger.Debug().
   132  			Str("binary", entry.Path).
   133  			Str("version", entry.Version.String()).
   134  			Str("constraint", constraint.String()).
   135  			Msg("k6 binary does not match constraint")
   136  	}
   137  
   138  	return nil, ErrUnsatisfiable
   139  }
   140  
   141  // Entries returns the list of binaries and their versions, scanning them if needed. Callers are allowed to modify the
   142  // returned slice, but not the individual Entry objects.
   143  func (r *Repository) Entries() ([]Entry, error) {
   144  	err := r.scan(false)
   145  	if err != nil {
   146  		return nil, fmt.Errorf("scanning for binaries: %w", err)
   147  	}
   148  
   149  	r.mtx.Lock()
   150  	defer r.mtx.Unlock()
   151  
   152  	if len(r.entries) == 0 {
   153  		return nil, nil
   154  	}
   155  
   156  	entries := make([]Entry, len(r.entries))
   157  	copy(entries, r.entries)
   158  
   159  	return entries, nil
   160  }
   161  
   162  // scan is an internal method that walks the repository root looking for k6 binaries, and runs them to obtain their
   163  // version. If force is set to false and the Repository is not empty, scan will return immediately with no error, acting
   164  // as a cache.
   165  func (r *Repository) scan(force bool) error {
   166  	r.mtx.Lock()
   167  	defer r.mtx.Unlock()
   168  
   169  	if len(r.entries) > 0 && !force {
   170  		return nil
   171  	}
   172  
   173  	// All binaries found in the repository folder, which must also match a naming pattern, are assumed to be indeed k6
   174  	// binaries. The code will try to execute them with `$0 version --json` and error out entirely if any of them
   175  	// misbehave.
   176  	binaries, err := r.binaries()
   177  	if err != nil {
   178  		return err
   179  	}
   180  
   181  	for _, bin := range binaries {
   182  		k6Version, err := runK6Version(bin)
   183  		if err != nil {
   184  			return fmt.Errorf("finding version for %q: %w", bin, err)
   185  		}
   186  
   187  		version, err := semver.NewVersion(k6Version.Version)
   188  		if err != nil {
   189  			return fmt.Errorf("version %q returned by %q is invalid: %w", k6Version.Version, bin, err)
   190  		}
   191  
   192  		r.entries = append(r.entries, Entry{
   193  			Path:    bin,
   194  			Version: version,
   195  		})
   196  	}
   197  
   198  	// Sort versions, highest first. That way we can scan this slice and return the first matching version, which will
   199  	// be the newest.
   200  	slices.SortFunc(r.entries, func(a, b Entry) int {
   201  		return b.Version.Compare(a.Version)
   202  	})
   203  
   204  	r.Logger.Debug().Str("root", r.Root).Any("versions", r.entries).Msg("k6 version scan complete")
   205  
   206  	return nil
   207  }
   208  
   209  // binaries returns the paths to all binaries assumed to be k6 inside the repository root. All files in the repository
   210  // root should be k6 binaries, but as a sanity check, files are filtered based on them being executable and matching a
   211  // naming pattern.
   212  // If the repository's Override is set, then binaries returns only that path blindly.
   213  func (r *Repository) binaries() ([]string, error) {
   214  	if r.Override != "" {
   215  		r.Logger.Warn().Str("k6", r.Override).Msg("Overriding k6 binary autoselection")
   216  
   217  		return []string{r.Override}, nil
   218  	}
   219  
   220  	var binaries []string
   221  
   222  	files, err := fs.ReadDir(os.DirFS(r.Root), ".")
   223  	if err != nil {
   224  		return nil, fmt.Errorf("reading k6 repository root: %w", err)
   225  	}
   226  
   227  	for _, file := range files {
   228  		path := filepath.Join(r.Root, file.Name())
   229  
   230  		if file.IsDir() {
   231  			r.Logger.Warn().Str("root", r.Root).Str("directory", path).Msg("Foreign directory found inside k6 repository root")
   232  			continue
   233  		}
   234  
   235  		info, err := file.Info()
   236  		if err != nil {
   237  			return nil, fmt.Errorf("reading file info: %w", err)
   238  		}
   239  
   240  		if info.Mode().Perm()&0o111 == 0 {
   241  			// This is not an exhaustive check: It is possible that the file is executable, but not by the user running
   242  			// this code, in which case the error will be thrown later. This is a best-effort pass to detect stray
   243  			// files.
   244  			r.Logger.Warn().Str("root", r.Root).Str("path", path).Msg("Found non-executable file inside k6 repository root")
   245  			continue
   246  		}
   247  
   248  		if !strings.Contains(file.Name(), binaryMustContain) {
   249  			// Ignore binaries that do not contain a specific substring in the name. In the next step we will execute
   250  			// every found binary with `--version --json`, so as a safety check to avoid executing unknown binaries if
   251  			// we're pointed to the wrong directory (like /usr/bin) we look for a specific name here.
   252  			r.Logger.Warn().Str("root", r.Root).Str("path", path).Msg("Foreign binary found inside k6 repository root")
   253  			continue
   254  		}
   255  
   256  		binaries = append(binaries, path)
   257  	}
   258  
   259  	return binaries, nil
   260  }
   261  
   262  // runK6Version executes the given binary with `version --json` as arguments, and attempts to parse the output as if it
   263  // were a k6 version.
   264  func runK6Version(k6Path string) (*k6Version, error) {
   265  	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
   266  	defer cancel()
   267  
   268  	cmd := exec.CommandContext(ctx, k6Path, "version", "--json")
   269  	cmd.Env = []string{
   270  		"K6_AUTO_EXTENSION_RESOLUTION=false",
   271  		// By not explicitly appending os.Env, all other env vars are cleared here.
   272  	}
   273  
   274  	stdout := &bytes.Buffer{}
   275  	stderr := &bytes.Buffer{}
   276  	cmd.Stdout = stdout
   277  	cmd.Stderr = stderr
   278  
   279  	err := cmd.Run()
   280  	if err != nil {
   281  		return nil, fmt.Errorf("running k6: %w\n%s", err, stderr.String())
   282  	}
   283  
   284  	k6v := k6Version{}
   285  
   286  	err = json.Unmarshal(stdout.Bytes(), &k6v)
   287  	if err != nil {
   288  		return nil, fmt.Errorf("parsing json: %w", err)
   289  	}
   290  
   291  	return &k6v, nil
   292  }