8000 Integrate binary provisioning into k6 root command by pablochacin · Pull Request #4801 · grafana/k6 · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

Integrate binary provisioning into k6 root command #4801

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 0 additions & 11 deletions cmd/execute.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,5 @@ import (
func Execute() {
gs := state.NewGlobalState(context.Background())

if gs.Flags.BinaryProvisioning {
internalcmd.NewLauncher(gs).Launch()
return
}

// If Binary Provisioning is not enabled, continue with the regular k6 execution path

// TODO: this is temporary defensive programming
// The Launcher has already the support for this specific execution path, but we decided to play safe here.
// After the v1.0 release, we want to fully delegate this control to the Launcher.
gs.Logger.Debug("Binary Provisioning feature is disabled.")
internalcmd.ExecuteWithGlobalState(gs)
}
209 changes: 83 additions & 126 deletions internal/cmd/launcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/Masterminds/semver/v3"
"github.com/grafana/k6deps"
"github.com/grafana/k6provider"
"github.com/spf13/cobra"
"go.k6.io/k6/cloudapi"
"go.k6.io/k6/cmd/state"
"go.k6.io/k6/internal/build"
Expand All @@ -25,63 +26,65 @@ var (
// commandExecutor executes the requested k6 command line command.
// It abstract the execution path from the concrete binary.
type commandExecutor interface {
run(*state.GlobalState)
run(*state.GlobalState) error
}

// Launcher is a k6 launcher. It analyses the requirements of a k6 execution,
// then if required, it provisions a binary executor to satisfy the requirements.
type Launcher struct {
// gs is the global state of k6.
gs *state.GlobalState
// customBinary runs the requested commands on a different binary on a subprocess passing the
// original arguments
type customBinary struct {
// path represents the local file path
// on the file system of the binary
path string
}

// provision generates a custom binary from the received list of dependencies
// with their constrains, and it returns an executor that satisfies them.
provision func(*state.GlobalState, k6deps.Dependencies) (commandExecutor, error)
// provisioner defines the interface for provisioning a commandExecutor for a set of dependencies
type provisioner interface {
provision(k6deps.Dependencies) (commandExecutor, error)
}

// commandExecutor executes the requested k6 command line command
// launcher is a k6 launcher. It analyses the requirements of a k6 execution,
// then if required, it provisions a binary executor to satisfy the requirements.
type launcher struct {
gs *state.GlobalState
provider provisioner
commandExecutor commandExecutor
}

// NewLauncher creates a new Launcher from a GlobalState using the default fallback and provision functions
func NewLauncher(gs *state.GlobalState) *Launcher {
defaultExecutor := &currentBinary{}
return &Launcher{
gs: gs,
provision: k6buildProvision,
commandExecutor: defaultExecutor,
// newLauncher creates a new Launcher from a GlobalState using the default provision function
func newLauncher(gs *state.GlobalState) *launcher {
return &launcher{
gs: gs,
provider: newK6BuildProvider(gs),
}
}

// Launch executes k6 either by launching a provisioned binary or defaulting to the
// current binary if this is not necessary.
// The commandExecutor can exit the process so don't assume it will return
func (l *Launcher) Launch() {
// If binary provisioning is not enabled, continue with the regular k6 execution path
if !l.gs.Flags.BinaryProvisioning {
l.gs.Logger.Debug("Binary Provisioning feature is disabled.")
l.commandExecutor.run(l.gs)
return
// launch analyzies the command to be executed and its input (e.g. script) to identify its dependencies.
// If it has dependencies that cannot be satisfied by the current binary, it obtains a custom commandExecutor
// usign the provision function and delegates the execution of the command to this commandExecutor.
// On the contrary, continues with the execution of the command in the current binary.
func (l *launcher) launch(cmd *cobra.Command, args []string) error {
if !isAnalysisRequired(cmd) {
l.gs.Logger.
WithField("command", cmd.Name()).
Debug("command does not require dependency analysis")
return nil
}

l.gs.Logger.
Debug("Binary Provisioning feature is enabled.")
deps, err := analyze(l.gs, l.gs.CmdArgs[1:])
deps, err := analyze(l.gs, args)
if err != nil {
l.gs.Logger.
WithError(err).
Error("Binary provisioning is enabled but it failed to analyze the dependencies." +
" Please, make sure to report this issue by opening a bug report.")
l.gs.OSExit(1)
return // this is required for testing
return err
}

// if the command does not have dependencies nor a custom build is required
if !customBuildRequired(build.Version, deps) {
if !isCustomBuildRequired(build.Version, deps) {
l.gs.Logger.
Debug("The current k6 binary already satisfies all the required dependencies," +
" it isn't required to provision a new binary.")
l.commandExecutor.run(l.gs)
return
return nil
}

l.gs.Logger.
Expand All @@ -90,29 +93,30 @@ func (l *Launcher) Launch() {
" The current k6 binary doesn't satisfy all dependencies, it's required to" +
" provision a custom binary.")

customBinary, err := l.provision(l.gs, deps)
customBinary, err := l.provider.provision(deps)
if err != nil {
l.gs.Logger.
WithError(err).
Error("Failed to provision a k6 binary with required dependencies." +
" Please, make sure to report this issue by opening a bug report.")
l.gs.OSExit(1)
return
return err
}

customBinary.run(l.gs)
l.commandExecutor = customBinary

// override command's RunE method to be processed by the command executor
cmd.RunE = l.runE

return nil
}

// customBinary runs the requested commands
// on a different binary on a subprocess passing the original arguments
type customBinary struct {
// path represents the local file path
// on the file system of the binary
path string
// runE executes the k6 command using a command executor
func (l *launcher) runE(_ *cobra.Command, _ []string) error {
return l.commandExecutor.run(l.gs)
}

//nolint:forbidigo
func (b *customBinary) run(gs *state.GlobalState) {
func (b *customBinary) run(gs *state.GlobalState) error {
cmd := exec.CommandContext(gs.Ctx, b.path, gs.CmdArgs[1:]...) //nolint:gosec

// we pass os stdout, err, in because passing them from GlobalState changes how
Expand All @@ -124,6 +128,8 @@ func (b *customBinary) run(gs *state.GlobalState) {
// Copy environment variables to the k6 process and skip binary provisioning feature flag to disable it.
// If not disabled, then the executed k6 binary would enter an infinite loop, where it continuously
// process the input script, detect dependencies, and retrigger provisioning.
// This can be avoided by checking if the current binary has already extensions that
// satisfies the dependencies. See comment in isCustomBuildRequired function.
env := []string{}
for k, v := range gs.Env {
if k == state.BinaryProvisioningFeatureFlag {
Expand All @@ -143,7 +149,7 @@ func (b *customBinary) run(gs *state.GlobalState) {
gs.Logger.
WithError(err).
Error("Failed to run the provisioned k6 binary")
gs.OSExit(1)
return err
}

// wait for the subprocess to end
Expand All @@ -155,15 +161,7 @@ func (b *customBinary) run(gs *state.GlobalState) {
for {
select {
case err := <-done:
rc := 0
if err != nil {
rc = 1
var eerr *exec.ExitError
if errors.As(err, &eerr) {
rc = eerr.ExitCode()
}
}
gs.OSExit(rc)
return err
case sig := <-sigC:
gs.Logger.
WithField("signal", sig.String()).
Expand All @@ -172,18 +170,12 @@ func (b *customBinary) run(gs *state.GlobalState) {
}
}

// currentBinary runs the requested commands on the current binary
type currentBinary struct{}

func (b *currentBinary) run(gs *state.GlobalState) {
ExecuteWithGlobalState(gs)
}

// customBuildRequired checks if the build is required
// isCustomBuildRequired checks if the build is required
// it's required if there is one or more dependencies other than k6 itself
// or if the required k6 version is not satisfied by the current binary's version
// TODO: get the version of any built-in extension and check if they satisfy the dependencies
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have the feeling we have an issue for this TODO. Please, mention here directly the issue so they are bidirectionally linked.

Copy link
Contributor Author
@pablochacin pablochacin Jun 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added. This isssue is addressed in this PR #4812

func customBuildRequired(baseK6Version string, deps k6deps.Dependencies) bool {
// (see https://github.com/grafana/k6/issues/4697)
func isCustomBuildRequired(baseK6Version string, deps k6deps.Dependencies) bool {
if len(deps) == 0 {
return false
}
Expand Down Expand Up @@ -215,11 +207,19 @@ func customBuildRequired(baseK6Version string, deps k6deps.Dependencies) bool {
return !k6Dependency.Constraints.Check(k6Ver)
}

// k6buildProvision returns the path to a k6 binary that satisfies the dependencies and the list of versions it provides
func k6buildProvision(gs *state.GlobalState, deps k6deps.Dependencies) (commandExecutor, error) {
token, err := extractToken(gs)
// k6buildProvider provides a k6 binary that satisfies the dependencies using the k6build service
type k6buildProvider struct {
gs *state.GlobalState
}

func newK6BuildProvider(gs *state.GlobalState) provisioner {
return &k6buildProvider{gs: gs}
}

func (p *k6buildProvider) provision(deps k6deps.Dependencies) (commandExecutor, error) {
token, err := extractToken(p.gs)
if err != nil {
gs.Logger.WithError(err).Debug("Failed to get a valid token")
p.gs.Logger.WithError(err).Debug("Failed to get a valid token")
}

if token == "" {
Expand All @@ -228,22 +228,22 @@ func k6buildProvision(gs *state.GlobalState, deps k6deps.Dependencies) (commandE
}

config := k6provider.Config{
BuildServiceURL: gs.Flags.BuildServiceURL,
BuildServiceURL: p.gs.Flags.BuildServiceURL,
BuildServiceAuth: token,
BinaryCacheDir: gs.Flags.BinaryCache,
BinaryCacheDir: p.gs.Flags.BinaryCache,
}

provider, err := k6provider.NewProvider(config)
if err != nil {
return nil, err
}

binary, err := provider.GetBinary(gs.Ctx, deps)
binary, err := provider.GetBinary(p.gs.Ctx, deps)
if err != nil {
return nil, err
}

gs.Logger.
p.gs.Logger.
Info("A new k6 binary has been provisioned with version(s): ", formatDependencies(binary.Dependencies))

return &customBinary{binary.Path}, nil
Expand Down Expand Up @@ -283,19 +283,13 @@ func analyze(gs *state.GlobalState, args []string) (k6deps.Dependencies, error)
Manifest: k6deps.Source{Ignore: true},
}

if !isAnalysisRequired(args) {
gs.Logger.
Debug("The command to execute does not require dependency analysis.")
return k6deps.Dependencies{}, nil
}

scriptname := scriptNameFromArgs(args)
if len(scriptname) == 0 {
if len(args) == 0 {
gs.Logger.
Debug("The command did not receive an input script.")
return nil, errScriptNotFound
}

scriptname := args[0]
if scriptname == "-" {
gs.Logger.
Debug("Test script provided by Stdin is not yet supported from Binary provisioning feature.")
Expand All @@ -319,56 +313,19 @@ func analyze(gs *state.GlobalState, args []string) (k6deps.Dependencies, error)
return k6deps.Analyze(dopts)
}

// isAnalysisRequired searches for the command and returns a boolean indicating if dependency analysis is required
func isAnalysisRequired(args []string) bool {
// return early if no arguments passed
if len(args) == 0 {
return false
}

// search for a command that requires binary provisioning and then get the target script or archive
// we handle cloud login subcommand as a special case because it does not require binary provisioning
for i, arg := range args {
switch arg {
case "--help", "-h":
return false
case "cloud":
for _, arg = range args[i+1:] {
if arg == "login" || arg == "--help" || arg == "-h" {
return false
}
}
return true
case "archive", "inspect":
// isAnalysisRequired returns a boolean indicating if dependency analysis is required for the command
func isAnalysisRequired(cmd *cobra.Command) bool {
switch cmd.Name() {
case "run":
// exclude `k6 cloud run` command
if cmd.Parent() != nil && cmd.Parent().Name() == "cloud" {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if cmd.Parent() != nil && cmd.Parent().Name() == "cloud" {
// exclude `k6 cloud run` command
if cmd.Parent() != nil && cmd.Parent().Name() == "cloud" {

return true
}
return false
case "archive", "inspect", "upload", "cloud":
return true
}

// not found
return false
}

// scriptNameFromArgs returns the file name passed as input and true if it's a valid script name
func scriptNameFromArgs(args []string) string {
// return early if no arguments passed
if len(args) == 0 {
return ""
}

for _, arg := range args {
if strings.HasPrefix(arg, "-") {
if arg == "-" { // we are running a script from stdin
return arg
}
continue
}
if strings.HasSuffix(arg, ".js") ||
strings.HasSuffix(arg, ".tar") ||
strings.HasSuffix(arg, ".ts") {
return arg
}
}

// not found
return ""
}
Loading
Loading
< 3048 div hidden> Add this suggestion to a batch that can be applied as a single commit. This suggestion is invalid because no changes were made to the code. Suggestions cannot be applied while the pull request is closed. Suggestions cannot be applied while viewing a subset of changes. Only one suggestion per line can be applied in a batch. Add this suggestion to a batch that can be applied as a single commit. Applying suggestions on deleted lines is not supported. You must change the existing code in this line in order to create a valid suggestion. Outdated suggestions cannot be applied. This suggestion has been applied or marked resolved. Suggestions cannot be applied from pending reviews. Suggestions cannot be applied on multi-line comments. Suggestions cannot be applied while the pull request is queued to merge. Suggestion cannot be applied right now. Please check back later.
0