From 03acbc07ccb47b00b46d51a5db41c06be33deffa Mon Sep 17 00:00:00 2001
From: Pablo Chacin <pablochacin@gmail.com>
Date: Fri, 13 Dec 2024 11:12:34 +0100
Subject: [PATCH] refactor local build service

Signed-off-by: Pablo Chacin <pablochacin@gmail.com>
---
 cmd/local/local.go     |   6 +-
 cmd/server/server.go   |  61 ++++++---
 pkg/builder/builder.go | 251 ++++++++++++++++++++++++++++++++++++
 pkg/local/local.go     | 279 +++--------------------------------------
 pkg/local/testutils.go |  42 +++----
 pkg/store/file/file.go |   5 +-
 6 files changed, 338 insertions(+), 306 deletions(-)
 create mode 100644 pkg/builder/builder.go

diff --git a/cmd/local/local.go b/cmd/local/local.go
index 7f8dc23..a5dc1b4 100644
--- a/cmd/local/local.go
+++ b/cmd/local/local.go
@@ -54,7 +54,7 @@ k6build local -k v0.50.0 -e GOPROXY=http://localhost:80 -q
 // New creates new cobra command for local build command.
 func New() *cobra.Command { //nolint:funlen
 	var (
-		config   local.BuildServiceConfig
+		config   local.Config
 		deps     []string
 		k6       string
 		output   string
@@ -127,9 +127,9 @@ func New() *cobra.Command { //nolint:funlen
 	_ = cmd.MarkFlagRequired("platform")
 	cmd.Flags().StringVarP(&config.Catalog, "catalog", "c", k6catalog.DefaultCatalogURL, "dependencies catalog")
 	cmd.Flags().StringVarP(&config.StoreDir, "store-dir", "f", "/tmp/k6build/store", "object store dir")
-	cmd.Flags().BoolVarP(&config.Verbose, "verbose", "v", false, "print build process output")
+	cmd.Flags().BoolVarP(&config.Opts.Verbose, "verbose", "v", false, "print build process output")
 	cmd.Flags().BoolVarP(&config.CopyGoEnv, "copy-go-env", "g", true, "copy go environment")
-	cmd.Flags().StringToStringVarP(&config.BuildEnv, "env", "e", nil, "build environment variables")
+	cmd.Flags().StringToStringVarP(&config.Opts.Env, "env", "e", nil, "build environment variables")
 	cmd.Flags().StringVarP(&output, "output", "o", "k6", "path to put the binary as an executable.")
 	cmd.Flags().BoolVarP(&quiet, "quiet", "q", false, "don't print artifact's details")
 	cmd.Flags().BoolVar(
diff --git a/cmd/server/server.go b/cmd/server/server.go
index 68cdf49..d5ff496 100644
--- a/cmd/server/server.go
+++ b/cmd/server/server.go
@@ -8,8 +8,9 @@ import (
 	"os"
 
 	"github.com/grafana/k6build"
-	"github.com/grafana/k6build/pkg/local"
+	"github.com/grafana/k6build/pkg/builder"
 	server "github.com/grafana/k6build/pkg/server"
+	store "github.com/grafana/k6build/pkg/store/client"
 	"github.com/grafana/k6catalog"
 
 	"github.com/spf13/cobra"
@@ -68,10 +69,15 @@ k6build server -e GOPROXY=http://localhost:80
 // New creates new cobra command for the server command.
 func New() *cobra.Command { //nolint:funlen
 	var (
-		config    local.BuildServiceConfig
-		logLevel  string
-		port      int
-		enableCgo bool
+		logLevel          string
+		port              int
+		enableCgo         bool
+		verbose           bool
+		goEnv             map[string]string
+		copyGoEnv         bool
+		catalogURL        string
+		storeURL          string
+		allowBuildSemvers bool
 	)
 
 	cmd := &cobra.Command{
@@ -99,16 +105,41 @@ func New() *cobra.Command { //nolint:funlen
 				),
 			)
 
+			catalog, err := k6catalog.NewCatalog(cmd.Context(), catalogURL)
+			if err != nil {
+				return fmt.Errorf("creating catalog %w", err)
+			}
+
+			store, err := store.NewStoreClient(store.StoreClientConfig{
+				Server: storeURL,
+			})
+			if err != nil {
+				return fmt.Errorf("creating store %w", err)
+			}
+
+			// TODO: check this logic
 			if enableCgo {
 				log.Warn("enabling CGO for build service")
 			} else {
-				if config.BuildEnv == nil {
-					config.BuildEnv = make(map[string]string)
+				if goEnv == nil {
+					goEnv = make(map[string]string)
 				}
-				config.BuildEnv["CGO_ENABLED"] = "0"
+				goEnv["CGO_ENABLED"] = "0"
 			}
 
-			buildSrv, err := local.NewBuildService(cmd.Context(), config)
+			config := builder.Config{
+				Opts: builder.Opts{
+					GoOpts: builder.GoOpts{
+						Env:       goEnv,
+						CopyGoEnv: copyGoEnv,
+					},
+					Verbose:           verbose,
+					AllowBuildSemvers: allowBuildSemvers,
+				},
+				Catalog: catalog,
+				Store:   store,
+			}
+			buildSrv, err := builder.New(cmd.Context(), config)
 			if err != nil {
 				return fmt.Errorf("creating local build service  %w", err)
 			}
@@ -135,22 +166,22 @@ func New() *cobra.Command { //nolint:funlen
 	}
 
 	cmd.Flags().StringVarP(
-		&config.Catalog,
+		&catalogURL,
 		"catalog",
 		"c",
 		k6catalog.DefaultCatalogURL,
 		"dependencies catalog. Can be path to a local file or an URL."+
 			"\n",
 	)
-	cmd.Flags().StringVar(&config.StoreURL, "store-url", "http://localhost:9000/store", "store server url")
-	cmd.Flags().BoolVarP(&config.Verbose, "verbose", "v", false, "print build process output")
-	cmd.Flags().BoolVarP(&config.CopyGoEnv, "copy-go-env", "g", true, "copy go environment")
-	cmd.Flags().StringToStringVarP(&config.BuildEnv, "env", "e", nil, "build environment variables")
+	cmd.Flags().StringVar(&storeURL, "store-url", "http://localhost:9000/store", "store server url")
+	cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "print build process output")
+	cmd.Flags().BoolVarP(&copyGoEnv, "copy-go-env", "g", true, "copy go environment")
+	cmd.Flags().StringToStringVarP(&goEnv, "env", "e", nil, "build environment variables")
 	cmd.Flags().IntVarP(&port, "port", "p", 8000, "port server will listen")
 	cmd.Flags().StringVarP(&logLevel, "log-level", "l", "INFO", "log level")
 	cmd.Flags().BoolVar(&enableCgo, "enable-cgo", false, "enable CGO for building binaries.")
 	cmd.Flags().BoolVar(
-		&config.AllowBuildSemvers,
+		&allowBuildSemvers,
 		"allow-build-semvers",
 		false,
 		"allow building versions with build metadata (e.g v0.0.0+build).",
diff --git a/pkg/builder/builder.go b/pkg/builder/builder.go
new file mode 100644
index 0000000..c359e27
--- /dev/null
+++ b/pkg/builder/builder.go
@@ -0,0 +1,251 @@
+// Package builder implements a build service
+package builder
+
+import (
+	"bytes"
+	"context"
+	"crypto/sha1" //nolint:gosec
+	"errors"
+	"fmt"
+	"os"
+	"regexp"
+	"sort"
+	"sync"
+
+	"github.com/grafana/k6build"
+	"github.com/grafana/k6build/pkg/store"
+	"github.com/grafana/k6catalog"
+	"github.com/grafana/k6foundry"
+)
+
+const (
+	k6Dep  = "k6"
+	k6Path = "go.k6.io/k6"
+
+	opRe    = `(?<operator>[=|~|>|<|\^|>=|<=|!=]){0,1}(?:\s*)`
+	verRe   = `(?P<version>[v|V](?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*))`
+	buildRe = `(?:[+|-|])(?P<build>(?:[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))`
+)
+
+var (
+	ErrAccessingArtifact     = errors.New("accessing artifact")                      //nolint:revive
+	ErrBuildingArtifact      = errors.New("building artifact")                       //nolint:revive
+	ErrInitializingBuilder   = errors.New("initializing builder")                    //nolint:revive
+	ErrInvalidParameters     = errors.New("invalid build parameters")                //nolint:revive
+	ErrBuildSemverNotAllowed = errors.New("semvers with build metadata not allowed") //nolint:revive
+
+	constrainRe = regexp.MustCompile(opRe + verRe + buildRe)
+)
+
+// GoOpts defines the options for the go build environment
+type GoOpts = k6foundry.GoOpts
+
+// Opts defines the options for configuring the builder
+type Opts struct {
+	// Allow semvers with build metadata
+	AllowBuildSemvers bool
+	// Generate build output
+	Verbose bool
+	// Build environment options
+	GoOpts
+}
+
+// Config defines the configuration for a Builder
+type Config struct {
+	Opts    Opts
+	Catalog k6catalog.Catalog
+	Store   store.ObjectStore
+}
+
+// Builder implements the BuildService interface
+type Builder struct {
+	allowBuildSemvers bool
+	catalog           k6catalog.Catalog
+	builder           k6foundry.Builder
+	store             store.ObjectStore
+	mutexes           sync.Map
+}
+
+// New returns a new instance of Builder given a BuilderConfig
+func New(ctx context.Context, config Config) (*Builder, error) {
+	if config.Catalog == nil {
+		return nil, k6build.NewWrappedError(ErrInitializingBuilder, errors.New("catalog cannot be nil"))
+	}
+
+	if config.Store == nil {
+		return nil, k6build.NewWrappedError(ErrInitializingBuilder, errors.New("store cannot be nil"))
+	}
+
+	builderOpts := k6foundry.NativeBuilderOpts{
+		GoOpts: k6foundry.GoOpts{
+			Env:       config.Opts.Env,
+			CopyGoEnv: config.Opts.CopyGoEnv,
+		},
+	}
+	if config.Opts.Verbose {
+		builderOpts.Stdout = os.Stdout
+		builderOpts.Stderr = os.Stderr
+	}
+
+	builder, err := k6foundry.NewNativeBuilder(ctx, builderOpts)
+	if err != nil {
+		return nil, k6build.NewWrappedError(ErrInitializingBuilder, err)
+	}
+
+	return &Builder{
+		allowBuildSemvers: config.Opts.AllowBuildSemvers,
+		catalog:           config.Catalog,
+		builder:           builder,
+		store:             config.Store,
+	}, nil
+}
+
+// Build builds a custom k6 binary with dependencies
+func (b *Builder) Build( //nolint:funlen
+	ctx context.Context,
+	platform string,
+	k6Constrains string,
+	deps []k6build.Dependency,
+) (k6build.Artifact, error) {
+	buildPlatform, err := k6foundry.ParsePlatform(platform)
+	if err != nil {
+		return k6build.Artifact{}, k6build.NewWrappedError(ErrInvalidParameters, err)
+	}
+
+	// sort dependencies to ensure idempotence of build
+	sort.Slice(deps, func(i, j int) bool { return deps[i].Name < deps[j].Name })
+	resolved := map[string]string{}
+
+	// check if it is a semver of the form v0.0.0+<build>
+	// if it is, we don't check with the catalog, but instead we use
+	// the build metadata as version when building this module
+	// the build process will return the actual version built in the build info
+	// and we can check that version with the catalog
+	var k6Mod k6catalog.Module
+	buildMetadata, err := hasBuildMetadata(k6Constrains)
+	if err != nil {
+		return k6build.Artifact{}, err
+	}
+	if buildMetadata != "" {
+		if !b.allowBuildSemvers {
+			return k6build.Artifact{}, k6build.NewWrappedError(ErrInvalidParameters, ErrBuildSemverNotAllowed)
+		}
+		k6Mod = k6catalog.Module{Path: k6Path, Version: buildMetadata}
+	} else {
+		k6Mod, err = b.catalog.Resolve(ctx, k6catalog.Dependency{Name: k6Dep, Constrains: k6Constrains})
+		if err != nil {
+			return k6build.Artifact{}, k6build.NewWrappedError(ErrInvalidParameters, err)
+		}
+	}
+	resolved[k6Dep] = k6Mod.Version
+
+	mods := []k6foundry.Module{}
+	for _, d := range deps {
+		m, modErr := b.catalog.Resolve(ctx, k6catalog.Dependency{Name: d.Name, Constrains: d.Constraints})
+		if modErr != nil {
+			return k6build.Artifact{}, k6build.NewWrappedError(ErrInvalidParameters, modErr)
+		}
+		mods = append(mods, k6foundry.Module{Path: m.Path, Version: m.Version})
+		resolved[d.Name] = m.Version
+	}
+
+	// generate id form sorted list of dependencies
+	hashData := bytes.Buffer{}
+	hashData.WriteString(platform)
+	hashData.WriteString(fmt.Sprintf(":k6%s", k6Mod.Version))
+	for _, d := range deps {
+		hashData.WriteString(fmt.Sprintf(":%s%s", d, resolved[d.Name]))
+	}
+	id := fmt.Sprintf("%x", sha1.Sum(hashData.Bytes())) //nolint:gosec
+
+	unlock := b.lockArtifact(id)
+	defer unlock()
+
+	artifactObject, err := b.store.Get(ctx, id)
+	if err == nil {
+		return k6build.Artifact{
+			ID:           id,
+			Checksum:     artifactObject.Checksum,
+			URL:          artifactObject.URL,
+			Dependencies: resolved,
+			Platform:     platform,
+		}, nil
+	}
+
+	if !errors.Is(err, store.ErrObjectNotFound) {
+		return k6build.Artifact{}, k6build.NewWrappedError(ErrAccessingArtifact, err)
+	}
+
+	artifactBuffer := &bytes.Buffer{}
+	buildInfo, err := b.builder.Build(ctx, buildPlatform, k6Mod.Version, mods, []string{}, artifactBuffer)
+	if err != nil {
+		return k6build.Artifact{}, k6build.NewWrappedError(ErrAccessingArtifact, err)
+	}
+
+	// if the version has a build metadata, we must use the actual version built
+	// TODO: check this version is supported
+	if buildMetadata != "" {
+		resolved[k6Dep] = buildInfo.ModVersions[k6Mod.Path]
+	}
+
+	artifactObject, err = b.store.Put(ctx, id, artifactBuffer)
+	if err != nil {
+		return k6build.Artifact{}, k6build.NewWrappedError(ErrAccessingArtifact, err)
+	}
+
+	return k6build.Artifact{
+		ID:           id,
+		Checksum:     artifactObject.Checksum,
+		URL:          artifactObject.URL,
+		Dependencies: resolved,
+		Platform:     platform,
+	}, nil
+}
+
+// lockArtifact obtains a mutex used to prevent concurrent builds of the same artifact and
+// returns a function that will unlock the mutex associated to the given id in the object store.
+// The lock is also removed from the map. Subsequent calls will get another lock on the same
+// id but this is safe as the object should already be in the object strore and no further
+// builds are needed.
+func (b *Builder) lockArtifact(id string) func() {
+	value, _ := b.mutexes.LoadOrStore(id, &sync.Mutex{})
+	mtx, _ := value.(*sync.Mutex)
+	mtx.Lock()
+
+	return func() {
+		b.mutexes.Delete(id)
+		mtx.Unlock()
+	}
+}
+
+// hasBuildMetadata checks if the constrain references a version with a build metadata.
+// E.g.  v0.1.0+build-effa45f
+func hasBuildMetadata(constrain string) (string, error) {
+	opInx := constrainRe.SubexpIndex("operator")
+	verIdx := constrainRe.SubexpIndex("version")
+	preIdx := constrainRe.SubexpIndex("build")
+	matches := constrainRe.FindStringSubmatch(constrain)
+
+	if matches == nil {
+		return "", nil
+	}
+
+	op := matches[opInx]
+	ver := matches[verIdx]
+	build := matches[preIdx]
+
+	if op != "" && op != "=" {
+		return "", k6build.NewWrappedError(
+			ErrInvalidParameters,
+			fmt.Errorf("only exact match is allowed for versions with build metadata"),
+		)
+	}
+
+	if ver != "v0.0.0" {
+		return "", k6build.NewWrappedError(
+			ErrInvalidParameters,
+			fmt.Errorf("version with build metadata must start with v0.0.0"),
+		)
+	}
+	return build, nil
+}
diff --git a/pkg/local/local.go b/pkg/local/local.go
index bfdac34..b90b5ef 100644
--- a/pkg/local/local.go
+++ b/pkg/local/local.go
@@ -2,289 +2,44 @@
 package local
 
 import (
-	"bytes"
 	"context"
-	"crypto/sha1" //nolint:gosec
-	"errors"
-	"fmt"
-	"os"
-	"regexp"
-	"sort"
-	"sync"
 
 	"github.com/grafana/k6build"
-	"github.com/grafana/k6build/pkg/store"
-	"github.com/grafana/k6build/pkg/store/client"
+	"github.com/grafana/k6build/pkg/builder"
 	"github.com/grafana/k6build/pkg/store/file"
 	"github.com/grafana/k6catalog"
-	"github.com/grafana/k6foundry"
 )
 
-const (
-	k6Dep  = "k6"
-	k6Path = "go.k6.io/k6"
+// Opts local builder options
+type Opts = builder.Opts
 
-	opRe    = `(?<operator>[=|~|>|<|\^|>=|<=|!=]){0,1}(?:\s*)`
-	verRe   = `(?P<version>[v|V](?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*))`
-	buildRe = `(?:[+|-|])(?P<build>(?:[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))`
-)
-
-var (
-	ErrAccessingArtifact     = errors.New("accessing artifact")                      //nolint:revive
-	ErrBuildingArtifact      = errors.New("building artifact")                       //nolint:revive
-	ErrInitializingBuilder   = errors.New("initializing builder")                    //nolint:revive
-	ErrInvalidParameters     = errors.New("invalid build parameters")                //nolint:revive
-	ErrBuildSemverNotAllowed = errors.New("semvers with build metadata not allowed") //nolint:revive
-
-	constrainRe = regexp.MustCompile(opRe + verRe + buildRe)
-)
+// GoOpts Go build options
+type GoOpts = builder.GoOpts
 
-// BuildServiceConfig defines the configuration for a Local build service
-type BuildServiceConfig struct {
-	// Set build environment variables
-	// Can be used for setting (or overriding, if CopyGoEnv is true) go environment variables
-	BuildEnv map[string]string
+// Config defines the configuration for a Local build service
+type Config struct {
+	Opts
 	// path to catalog's json file. Can be a file path or a URL
 	Catalog string
-	// url to remote object store service
-	StoreURL string
 	// path to object store dir
 	StoreDir string
-	// Copy go environment. BuildEnv can override the variables copied from go environment.
-	CopyGoEnv bool
-	// set verbose build mode
-	Verbose bool
-	// Allow semvers with build metadata
-	AllowBuildSemvers bool
-}
-
-// buildSrv implements the BuildService interface
-type localBuildSrv struct {
-	allowBuildSemvers bool
-	catalog           k6catalog.Catalog
-	builder           k6foundry.Builder
-	store             store.ObjectStore
-	mutexes           sync.Map
 }
 
 // NewBuildService creates a local build service using the given configuration
-func NewBuildService(ctx context.Context, config BuildServiceConfig) (k6build.BuildService, error) {
+func NewBuildService(ctx context.Context, config Config) (k6build.BuildService, error) {
 	catalog, err := k6catalog.NewCatalog(ctx, config.Catalog)
 	if err != nil {
-		return nil, k6build.NewWrappedError(ErrInitializingBuilder, err)
-	}
-
-	builderOpts := k6foundry.NativeBuilderOpts{
-		GoOpts: k6foundry.GoOpts{
-			Env:       config.BuildEnv,
-			CopyGoEnv: config.CopyGoEnv,
-		},
-	}
-	if config.Verbose {
-		builderOpts.Stdout = os.Stdout
-		builderOpts.Stderr = os.Stderr
-	}
-
-	builder, err := k6foundry.NewNativeBuilder(ctx, builderOpts)
-	if err != nil {
-		return nil, k6build.NewWrappedError(ErrInitializingBuilder, err)
-	}
-
-	var store store.ObjectStore
-
-	if config.StoreURL != "" {
-		store, err = client.NewStoreClient(
-			client.StoreClientConfig{
-				Server: config.StoreURL,
-			},
-		)
-		if err != nil {
-			return nil, k6build.NewWrappedError(ErrInitializingBuilder, err)
-		}
-	} else {
-		store, err = file.NewFileStore(config.StoreDir)
-		if err != nil {
-			return nil, k6build.NewWrappedError(ErrInitializingBuilder, err)
-		}
-	}
-
-	return &localBuildSrv{
-		allowBuildSemvers: config.AllowBuildSemvers,
-		catalog:           catalog,
-		builder:           builder,
-		store:             store,
-	}, nil
-}
-
-// DefaultLocalBuildService creates a local build service with default configuration
-func DefaultLocalBuildService() (k6build.BuildService, error) {
-	catalog, err := k6catalog.DefaultCatalog()
-	if err != nil {
-		return nil, k6build.NewWrappedError(ErrInitializingBuilder, err)
-	}
-
-	builder, err := k6foundry.NewDefaultNativeBuilder()
-	if err != nil {
-		return nil, k6build.NewWrappedError(ErrInitializingBuilder, err)
-	}
-
-	store, err := file.NewTempFileStore()
-	if err != nil {
-		return nil, k6build.NewWrappedError(ErrInitializingBuilder, err)
+		return nil, k6build.NewWrappedError(builder.ErrInitializingBuilder, err)
 	}
 
-	return &localBuildSrv{
-		catalog: catalog,
-		builder: builder,
-		store:   store,
-	}, nil
-}
-
-func (b *localBuildSrv) Build( //nolint:funlen
-	ctx context.Context,
-	platform string,
-	k6Constrains string,
-	deps []k6build.Dependency,
-) (k6build.Artifact, error) {
-	buildPlatform, err := k6foundry.ParsePlatform(platform)
+	store, err := file.NewFileStore(config.StoreDir)
 	if err != nil {
-		return k6build.Artifact{}, k6build.NewWrappedError(ErrInvalidParameters, err)
+		return nil, k6build.NewWrappedError(builder.ErrInitializingBuilder, err)
 	}
 
-	// sort dependencies to ensure idempotence of build
-	sort.Slice(deps, func(i, j int) bool { return deps[i].Name < deps[j].Name })
-	resolved := map[string]string{}
-
-	// check if it is a semver of the form v0.0.0+<build>
-	// if it is, we don't check with the catalog, but instead we use
-	// the build metadata as version when building this module
-	// the build process will return the actual version built in the build info
-	// and we can check that version with the catalog
-	var k6Mod k6catalog.Module
-	buildMetadata, err := hasBuildMetadata(k6Constrains)
-	if err != nil {
-		return k6build.Artifact{}, err
-	}
-	if buildMetadata != "" {
-		if !b.allowBuildSemvers {
-			return k6build.Artifact{}, k6build.NewWrappedError(ErrInvalidParameters, ErrBuildSemverNotAllowed)
-		}
-		k6Mod = k6catalog.Module{Path: k6Path, Version: buildMetadata}
-	} else {
-		k6Mod, err = b.catalog.Resolve(ctx, k6catalog.Dependency{Name: k6Dep, Constrains: k6Constrains})
-		if err != nil {
-			return k6build.Artifact{}, k6build.NewWrappedError(ErrInvalidParameters, err)
-		}
-	}
-	resolved[k6Dep] = k6Mod.Version
-
-	mods := []k6foundry.Module{}
-	for _, d := range deps {
-		m, modErr := b.catalog.Resolve(ctx, k6catalog.Dependency{Name: d.Name, Constrains: d.Constraints})
-		if modErr != nil {
-			return k6build.Artifact{}, k6build.NewWrappedError(ErrInvalidParameters, modErr)
-		}
-		mods = append(mods, k6foundry.Module{Path: m.Path, Version: m.Version})
-		resolved[d.Name] = m.Version
-	}
-
-	// generate id form sorted list of dependencies
-	hashData := bytes.Buffer{}
-	hashData.WriteString(platform)
-	hashData.WriteString(fmt.Sprintf(":k6%s", k6Mod.Version))
-	for _, d := range deps {
-		hashData.WriteString(fmt.Sprintf(":%s%s", d, resolved[d.Name]))
-	}
-	id := fmt.Sprintf("%x", sha1.Sum(hashData.Bytes())) //nolint:gosec
-
-	unlock := b.lockArtifact(id)
-	defer unlock()
-
-	artifactObject, err := b.store.Get(ctx, id)
-	if err == nil {
-		return k6build.Artifact{
-			ID:           id,
-			Checksum:     artifactObject.Checksum,
-			URL:          artifactObject.URL,
-			Dependencies: resolved,
-			Platform:     platform,
-		}, nil
-	}
-
-	if !errors.Is(err, store.ErrObjectNotFound) {
-		return k6build.Artifact{}, k6build.NewWrappedError(ErrAccessingArtifact, err)
-	}
-
-	artifactBuffer := &bytes.Buffer{}
-	buildInfo, err := b.builder.Build(ctx, buildPlatform, k6Mod.Version, mods, []string{}, artifactBuffer)
-	if err != nil {
-		return k6build.Artifact{}, k6build.NewWrappedError(ErrAccessingArtifact, err)
-	}
-
-	// if the version has a build metadata, we must use the actual version built
-	// TODO: check this version is supported
-	if buildMetadata != "" {
-		resolved[k6Dep] = buildInfo.ModVersions[k6Mod.Path]
-	}
-
-	artifactObject, err = b.store.Put(ctx, id, artifactBuffer)
-	if err != nil {
-		return k6build.Artifact{}, k6build.NewWrappedError(ErrAccessingArtifact, err)
-	}
-
-	return k6build.Artifact{
-		ID:           id,
-		Checksum:     artifactObject.Checksum,
-		URL:          artifactObject.URL,
-		Dependencies: resolved,
-		Platform:     platform,
-	}, nil
-}
-
-// lockArtifact obtains a mutex used to prevent concurrent builds of the same artifact and
-// returns a function that will unlock the mutex associated to the given id in the object store.
-// The lock is also removed from the map. Subsequent calls will get another lock on the same
-// id but this is safe as the object should already be in the object strore and no further
-// builds are needed.
-func (b *localBuildSrv) lockArtifact(id string) func() {
-	value, _ := b.mutexes.LoadOrStore(id, &sync.Mutex{})
-	mtx, _ := value.(*sync.Mutex)
-	mtx.Lock()
-
-	return func() {
-		b.mutexes.Delete(id)
-		mtx.Unlock()
-	}
-}
-
-// hasBuildMetadata checks if the constrain references a version with a build metadata.
-// E.g.  v0.1.0+build-effa45f
-func hasBuildMetadata(constrain string) (string, error) {
-	opInx := constrainRe.SubexpIndex("operator")
-	verIdx := constrainRe.SubexpIndex("version")
-	preIdx := constrainRe.SubexpIndex("build")
-	matches := constrainRe.FindStringSubmatch(constrain)
-
-	if matches == nil {
-		return "", nil
-	}
-
-	op := matches[opInx]
-	ver := matches[verIdx]
-	build := matches[preIdx]
-
-	if op != "" && op != "=" {
-		return "", k6build.NewWrappedError(
-			ErrInvalidParameters,
-			fmt.Errorf("only exact match is allowed for versions with build metadata"),
-		)
-	}
-
-	if ver != "v0.0.0" {
-		return "", k6build.NewWrappedError(
-			ErrInvalidParameters,
-			fmt.Errorf("version with build metadata must start with v0.0.0"),
-		)
-	}
-	return build, nil
+	return builder.New(ctx, builder.Config{
+		Opts:    config.Opts,
+		Catalog: catalog,
+		Store:   store,
+	})
 }
diff --git a/pkg/local/testutils.go b/pkg/local/testutils.go
index 88d27ca..2f8a342 100644
--- a/pkg/local/testutils.go
+++ b/pkg/local/testutils.go
@@ -7,6 +7,7 @@ import (
 	"testing"
 
 	"github.com/grafana/k6build"
+	"github.com/grafana/k6build/pkg/builder"
 	"github.com/grafana/k6build/pkg/store/file"
 	"github.com/grafana/k6catalog"
 	"github.com/grafana/k6foundry"
@@ -61,24 +62,6 @@ func SetupTestLocalBuildService(t *testing.T) (k6build.BuildService, error) {
 
 	goproxySrv := httptest.NewServer(proxy)
 
-	opts := k6foundry.NativeBuilderOpts{
-		GoOpts: k6foundry.GoOpts{
-			CopyGoEnv: true,
-			Env: map[string]string{
-				"GOPROXY":   goproxySrv.URL,
-				"GONOPROXY": "none",
-				"GOPRIVATE": "go.k6.io",
-				"GONOSUMDB": "go.k6.io",
-			},
-			TmpCache: true,
-		},
-	}
-
-	builder, err := k6foundry.NewNativeBuilder(context.Background(), opts)
-	if err != nil {
-		return nil, fmt.Errorf("setting up test builder %w", err)
-	}
-
 	catalog, err := k6catalog.NewCatalogFromFile("testdata/catalog.json")
 	if err != nil {
 		return nil, fmt.Errorf("setting up test builder %w", err)
@@ -89,11 +72,20 @@ func SetupTestLocalBuildService(t *testing.T) (k6build.BuildService, error) {
 		return nil, fmt.Errorf("creating temporary object store %w", err)
 	}
 
-	buildsrv := &localBuildSrv{
-		builder: builder,
-		catalog: catalog,
-		store:   store,
-	}
-
-	return buildsrv, nil
+	return builder.New(context.Background(), builder.Config{
+		Opts: builder.Opts{
+			GoOpts: k6foundry.GoOpts{
+				CopyGoEnv: true,
+				Env: map[string]string{
+					"GOPROXY":   goproxySrv.URL,
+					"GONOPROXY": "none",
+					"GOPRIVATE": "go.k6.io",
+					"GONOSUMDB": "go.k6.io",
+				},
+				TmpCache: true,
+			},
+		},
+		Catalog: catalog,
+		Store:   store,
+	})
 }
diff --git a/pkg/store/file/file.go b/pkg/store/file/file.go
index e49424e..7e5bf69 100644
--- a/pkg/store/file/file.go
+++ b/pkg/store/file/file.go
@@ -118,7 +118,10 @@ func (f *Store) Get(_ context.Context, id string) (store.Object, error) {
 		return store.Object{}, k6build.NewWrappedError(store.ErrAccessingObject, err)
 	}
 
-	objectURL, _ := util.URLFromFilePath(filepath.Join(objectDir, "data"))
+	objectURL, err := util.URLFromFilePath(filepath.Join(objectDir, "data"))
+	if err != nil {
+		return store.Object{}, k6build.NewWrappedError(store.ErrAccessingObject, err)
+	}
 	return store.Object{
 		ID:       id,
 		Checksum: string(checksum),