Skip to content

Commit

Permalink
refactor local build service
Browse files Browse the repository at this point in the history
Signed-off-by: Pablo Chacin <[email protected]>
  • Loading branch information
pablochacin committed Dec 13, 2024
1 parent 4f2b954 commit 03acbc0
Show file tree
Hide file tree
Showing 6 changed files with 338 additions and 306 deletions.
6 changes: 3 additions & 3 deletions cmd/local/local.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
61 changes: 46 additions & 15 deletions cmd/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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)
}
Expand All @@ -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).",
Expand Down
251 changes: 251 additions & 0 deletions pkg/builder/builder.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading

0 comments on commit 03acbc0

Please sign in to comment.