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 }