From 6d5532113f4e2b76580b1c787f03d7e83aaf96ce Mon Sep 17 00:00:00 2001 From: Nadia Santalla Date: Tue, 4 Nov 2025 16:32:42 +0100 Subject: [PATCH] feat: wip: add k6 version repository A Repository can list k6 binaries present on disk, and run them to find their versions --- .../version/testdata/other-file-noexec | 1 + .../version/testdata/override/sm-k6-custom | 3 + internal/k6runner/version/testdata/sm-k6-v1 | 3 + internal/k6runner/version/testdata/sm-k6-v2 | 3 + .../k6runner/version/testdata/subdir/sm-k6-v0 | 3 + internal/k6runner/version/version.go | 171 ++++++++++++++++++ internal/k6runner/version/version_test.go | 58 ++++++ 7 files changed, 242 insertions(+) create mode 100644 internal/k6runner/version/testdata/other-file-noexec create mode 100755 internal/k6runner/version/testdata/override/sm-k6-custom create mode 100755 internal/k6runner/version/testdata/sm-k6-v1 create mode 100755 internal/k6runner/version/testdata/sm-k6-v2 create mode 100755 internal/k6runner/version/testdata/subdir/sm-k6-v0 create mode 100644 internal/k6runner/version/version.go create mode 100644 internal/k6runner/version/version_test.go diff --git a/internal/k6runner/version/testdata/other-file-noexec b/internal/k6runner/version/testdata/other-file-noexec new file mode 100644 index 000000000..753a67111 --- /dev/null +++ b/internal/k6runner/version/testdata/other-file-noexec @@ -0,0 +1 @@ +I'm just a regular text file. Don't stare at me, I'm shy. diff --git a/internal/k6runner/version/testdata/override/sm-k6-custom b/internal/k6runner/version/testdata/override/sm-k6-custom new file mode 100755 index 000000000..5ccb0e667 --- /dev/null +++ b/internal/k6runner/version/testdata/override/sm-k6-custom @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +echo '{"commit":"9.9.9","go_arch":"amd64","go_os":"linux","go_version":"go1.24.6","version":"v9.9.9"}' diff --git a/internal/k6runner/version/testdata/sm-k6-v1 b/internal/k6runner/version/testdata/sm-k6-v1 new file mode 100755 index 000000000..7a2cb1d7b --- /dev/null +++ b/internal/k6runner/version/testdata/sm-k6-v1 @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +echo '{"commit":"1111111111","go_arch":"amd64","go_os":"linux","go_version":"go1.24.6","version":"v1.2.3"}' diff --git a/internal/k6runner/version/testdata/sm-k6-v2 b/internal/k6runner/version/testdata/sm-k6-v2 new file mode 100755 index 000000000..a063f1a1b --- /dev/null +++ b/internal/k6runner/version/testdata/sm-k6-v2 @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +echo '{"commit":"2222222222","go_arch":"amd64","go_os":"linux","go_version":"go1.24.6","version":"v2.0.0"}' diff --git a/internal/k6runner/version/testdata/subdir/sm-k6-v0 b/internal/k6runner/version/testdata/subdir/sm-k6-v0 new file mode 100755 index 000000000..3f2ab0de4 --- /dev/null +++ b/internal/k6runner/version/testdata/subdir/sm-k6-v0 @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +echo '{"commit":"00000000","go_arch":"amd64","go_os":"linux","go_version":"go1.24.6","version":"v0.0.0"}' diff --git a/internal/k6runner/version/version.go b/internal/k6runner/version/version.go new file mode 100644 index 000000000..f84edac54 --- /dev/null +++ b/internal/k6runner/version/version.go @@ -0,0 +1,171 @@ +package version + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/rs/zerolog" +) + +// Repository points to a collection of k6 binaries. +type Repository struct { + // Root points to a folder in the local filesystem that will be scanned for k6 binaries. + // All executable files in said folder should be k6 binaries, as they will all be executed with `--version` to map + // their actual versions. + Root string + // Override is the path to a specific k6 binary. If set, all Repository operations will return this path. + Override string + + // Logger. + Logger zerolog.Logger + + mtx sync.Mutex + entries []Entry +} + +const binaryMustContain = "k6" + +type Entry struct { + Path string + Version string +} + +type k6Version struct { + Commit string `json:"commit"` + GoArch string `json:"go_arch"` + GoOs string `json:"go_os"` + GoVersion string `json:"go_version"` + Version string `json:"version"` +} + +func (r *Repository) Entries() ([]Entry, error) { + err := r.scan(false) + if err != nil { + return nil, fmt.Errorf("scanning for binaries: %w", err) + } + + r.mtx.Lock() + defer r.mtx.Unlock() + + if len(r.entries) == 0 { + return nil, nil + } + + entries := make([]Entry, len(r.entries)) + copy(entries, r.entries) + + return entries, nil +} + +func (r *Repository) scan(force bool) error { + r.mtx.Lock() + defer r.mtx.Unlock() + + if len(r.entries) > 0 && !force { + return nil + } + + binaries, err := r.binaries() + if err != nil { + return err + } + + for _, bin := range binaries { + version, err := runK6Version(bin) + if err != nil { + return fmt.Errorf("finding version for %q: %w", bin, err) + } + + r.entries = append(r.entries, Entry{ + Path: bin, + Version: version.Version, + }) + } + + return nil +} + +func (r *Repository) binaries() ([]string, error) { + if r.Override != "" { + r.Logger.Warn().Str("k6", r.Override).Msg("Overriding k6 binary autoselection") + + return []string{r.Override}, nil + } + + var binaries []string + + files, err := fs.ReadDir(os.DirFS(r.Root), ".") + if err != nil { + return nil, fmt.Errorf("reading k6 repository root: %w", err) + } + + for _, file := range files { + path := filepath.Join(r.Root, file.Name()) + + if file.IsDir() { + r.Logger.Warn().Str("root", r.Root).Str("directory", path).Msg("Foreign directory found inside k6 repository root") + continue + } + + info, err := file.Info() + if err != nil { + return nil, fmt.Errorf("reading file info: %w", err) + } + + if info.Mode().Perm()&0o111 == 0 { + // This is not an exhaustive check: It is possible that the file is executable, but not by the user running + // this code, in which case the error will be thrown later. This is a best-effort pass to detect stray + // files. + r.Logger.Warn().Str("root", r.Root).Str("path", path).Msg("Found non-executable file inside k6 repository root") + continue + } + + if !strings.Contains(file.Name(), binaryMustContain) { + // Ignore binaries that do not contain a specific substring in the name. In the next step we will execute + // every found binary with `--version --json`, so as a safety check to avoid executing unknown binaries if + // we're pointed to the wrong directory (like /usr/bin) we look for a specific name here. + r.Logger.Warn().Str("root", r.Root).Str("path", path).Msg("Foreign binary found inside k6 repository root") + continue + } + + binaries = append(binaries, path) + } + + return binaries, nil +} + +func runK6Version(k6Path string) (*k6Version, error) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, k6Path, "--version", "--json") + cmd.Env = []string{ + "K6_AUTO_EXTENSION_RESOLUTION=false", + // By not explicitly appending os.Env, all other env vars are cleared here. + } + + stdout := &bytes.Buffer{} + cmd.Stdout = stdout + + err := cmd.Run() + if err != nil { + return nil, fmt.Errorf("running k6: %w", err) + } + + k6v := k6Version{} + err = json.Unmarshal(stdout.Bytes(), &k6v) + if err != nil { + return nil, fmt.Errorf("parsing json: %w", err) + } + + return &k6v, nil +} diff --git a/internal/k6runner/version/version_test.go b/internal/k6runner/version/version_test.go new file mode 100644 index 000000000..55063894d --- /dev/null +++ b/internal/k6runner/version/version_test.go @@ -0,0 +1,58 @@ +package version_test + +import ( + "slices" + "testing" + + "github.com/grafana/synthetic-monitoring-agent/internal/k6runner/version" +) + +func TestOverride(t *testing.T) { + t.Parallel() + + repo := version.Repository{ + Root: "./testdata/", + Override: "./testdata/override/sm-k6-custom", + } + + versions, err := repo.Entries() + if err != nil { + t.Fatalf("retrieving entries: %v", err) + } + + if len(versions) != 1 { + t.Fatalf("Expected just the overridden version, got %d", len(versions)) + } + + if v := versions[0].Version; v != "v9.9.9" { + t.Fatalf("Unexpected version %q", v) + } +} + +func TestVersions(t *testing.T) { + t.Parallel() + + repo := version.Repository{ + Root: "./testdata/", + } + + versions, err := repo.Entries() + if err != nil { + t.Fatalf("retrieving entries: %v", err) + } + + expected := []string{ + "v1.2.3", + "v2.0.0", + } + + if len(versions) != len(expected) { + t.Fatalf("Expected to find 2 versions, got %d", len(versions)) + } + + for _, ev := range expected { + if !slices.ContainsFunc(versions, func(v version.Entry) bool { return ev == v.Version }) { + t.Fatalf("Expected version %q not found in %v", ev, versions) + } + } +}