diff --git a/internal/cmd/cloud.go b/internal/cmd/cloud.go index 235a60fb243..07b64102c5f 100644 --- a/internal/cmd/cloud.go +++ b/internal/cmd/cloud.go @@ -85,14 +85,6 @@ func (c *cmdCloud) preRun(cmd *cobra.Command, _ []string) error { // //nolint:funlen,gocognit,cyclop func (c *cmdCloud) run(cmd *cobra.Command, args []string) error { - printBanner(c.gs) - - progressBar := pb.New( - pb.WithConstLeft("Init"), - pb.WithConstProgress(0, "Loading test script..."), - ) - printBar(c.gs, progressBar) - test, err := loadAndConfigureLocalTest(c.gs, cmd, args, getPartialConfig) if err != nil { return err @@ -111,6 +103,13 @@ func (c *cmdCloud) run(cmd *cobra.Command, args []string) error { // TODO: validate for usage of execution segment // TODO: validate for externally controlled executor (i.e. executors that aren't distributable) // TODO: move those validations to a separate function and reuse validateConfig()? + printBanner(c.gs) + + progressBar := pb.New( + pb.WithConstLeft("Init"), + pb.WithConstProgress(0, "Loading test script..."), + ) + printBar(c.gs, progressBar) modifyAndPrintBar(c.gs, progressBar, pb.WithConstProgress(0, "Building the archive...")) arc := testRunState.Runner.MakeArchive() diff --git a/internal/cmd/launcher.go b/internal/cmd/launcher.go index 6c518923888..b252e43b436 100644 --- a/internal/cmd/launcher.go +++ b/internal/cmd/launcher.go @@ -2,6 +2,7 @@ package cmd import ( "bytes" + "errors" "fmt" "io/fs" "maps" @@ -12,6 +13,8 @@ import ( "slices" "strings" "syscall" + "unicode" + "unicode/utf8" "github.com/Masterminds/semver/v3" "github.com/grafana/k6deps" @@ -20,6 +23,8 @@ import ( "go.k6.io/k6/cloudapi" "go.k6.io/k6/cmd/state" + "go.k6.io/k6/errext" + "go.k6.io/k6/errext/exitcodes" "go.k6.io/k6/ext" "go.k6.io/k6/internal/build" "go.k6.io/k6/lib/fsext" @@ -218,6 +223,10 @@ func (b *customBinary) run(gs *state.GlobalState) error { for { select { case err := <-done: + var exitError *exec.ExitError + if errors.As(err, &exitError) { + return errext.WithExitCodeIfNone(errAlreadyReported, exitcodes.ExitCode(exitError.ExitCode())) //nolint:gosec + } return err case sig := <-sigC: gs.Logger. @@ -227,10 +236,12 @@ func (b *customBinary) run(gs *state.GlobalState) error { } } +// used just to signal we shouldn't print error again +var errAlreadyReported = fmt.Errorf("already reported error") + // isCustomBuildRequired checks if there is at least one dependency that are not satisfied by the binary // considering the version of k6 and any built-in extension -func isCustomBuildRequired(deps map[string]*semver.Constraints, k6Version string, exts []*ext.Extension) bool { - // return early if there are no dependencies +func isCustomBuildRequired(deps dependencies, k6Version string, exts []*ext.Extension) bool { if len(deps) == 0 { return false } @@ -328,7 +339,7 @@ func formatDependencies(deps map[string]string) string { // Presently, only the k6 input script or archive (if any) is passed to k6deps for scanning. // TODO: if k6 receives the input from stdin, it is not used for scanning because we don't know // if it is a script or an archive -func analyze(gs *state.GlobalState, args []string) (map[string]*semver.Constraints, error) { +func analyze(gs *state.GlobalState, args []string) (dependencies, error) { dopts := &k6deps.Options{ LookupEnv: func(key string) (string, bool) { v, ok := gs.Env[key]; return v, ok }, Manifest: k6deps.Source{Ignore: true}, @@ -363,7 +374,7 @@ func analyze(gs *state.GlobalState, args []string) (map[string]*semver.Constrain if err != nil { return nil, err } - result := make(map[string]*semver.Constraints, len(deps)) + result := make(dependencies, len(deps)) for n, dep := range deps { result[n] = dep.Constraints } @@ -408,3 +419,70 @@ func extractToken(gs *state.GlobalState) (string, error) { return config.Token.String, nil } + +func processUseDirectives(name string, text []byte, deps dependencies) error { + directives := findDirectives(text) + + for _, directive := range directives { + // normalize spaces + directive = strings.ReplaceAll(directive, " ", " ") + if !strings.HasPrefix(directive, "use k6") { + continue + } + directive = strings.TrimSpace(strings.TrimPrefix(directive, "use k6")) + if !strings.HasPrefix(directive, "with k6/x/") { + err := deps.update("k6", directive) + if err != nil { + return fmt.Errorf("error while parsing use directives in %q: %w", name, err) + } + continue + } + directive = strings.TrimSpace(strings.TrimPrefix(directive, "with ")) + dep, constraint, _ := strings.Cut(directive, " ") + err := deps.update(dep, constraint) + if err != nil { + return fmt.Errorf("error while parsing use directives in %q: %w", name, err) + } + } + + return nil +} + +func findDirectives(text []byte) []string { + // parse #! at beginning of file + if bytes.HasPrefix(text, []byte("#!")) { + _, text, _ = bytes.Cut(text, []byte("\n")) + } + + var result []string + + for i := 0; i < len(text); { + r, width := utf8.DecodeRune(text[i:]) + switch { + case unicode.IsSpace(r) || r == rune(';'): // skip all spaces and ; + i += width + case r == '"' || r == '\'': // string literals + idx := bytes.IndexRune(text[i+width:], r) + if idx < 0 { + return result + } + result = append(result, string(text[i+width:i+width+idx])) + i += width + idx + 1 + case bytes.HasPrefix(text[i:], []byte("//")): + idx := bytes.IndexRune(text[i+width:], '\n') + if idx < 0 { + return result + } + i += width + idx + 1 + case bytes.HasPrefix(text[i:], []byte("/*")): + idx := bytes.Index(text[i+width:], []byte("*/")) + if idx < 0 { + return result + } + i += width + idx + 2 + default: + return result + } + } + return result +} diff --git a/internal/cmd/launcher_test.go b/internal/cmd/launcher_test.go index efa8ad27e9c..319512bbf80 100644 --- a/internal/cmd/launcher_test.go +++ b/internal/cmd/launcher_test.go @@ -210,6 +210,7 @@ func TestLauncherLaunch(t *testing.T) { t.Parallel() ts := tests.NewGlobalTestState(t) + ts.Env["K6_OLD_RESOLUTION"] = "true" k6Args := append([]string{"k6"}, tc.k6Cmd) k6Args = append(k6Args, tc.k6Args...) @@ -277,6 +278,7 @@ func TestLauncherViaStdin(t *testing.T) { k6Args := []string{"k6", "archive", "-"} ts := tests.NewGlobalTestState(t) + ts.Env["K6_OLD_RESOLUTION"] = "true" ts.CmdArgs = k6Args // k6deps uses os package to access files. So we need to use it in the global state @@ -567,3 +569,171 @@ func TestGetProviderConfig(t *testing.T) { }) } } + +func TestProcessUseDirectives(t *testing.T) { + t.Parallel() + tests := map[string]struct { + input string + expectedOutput map[string]string + expectedError string + }{ + "nothing": { + input: "export default function() {}", + }, + "nothing really": { + input: `"use k6"`, + expectedOutput: map[string]string{ + "k6": "", + }, + }, + "k6 pinning": { + input: `"use k6 > 1.4.0"`, + expectedOutput: map[string]string{ + "k6": "> 1.4.0", + }, + }, + "a extension": { + input: `"use k6 with k6/x/sql"`, + expectedOutput: map[string]string{ + "k6/x/sql": "", + }, + }, + "an extension with constraint": { + input: `"use k6 with k6/x/sql > 1.4.0"`, + expectedOutput: map[string]string{ + "k6/x/sql": "> 1.4.0", + }, + }, + "complex": { + input: ` + // something here + "use k6 with k6/x/A" + function a (){ + "use k6 with k6/x/B" + let s = JSON.stringify( "use k6 with k6/x/C") + "use k6 with k6/x/D" + + return s + } + + export const b = "use k6 with k6/x/E" + "use k6 with k6/x/F" + + // Here for esbuild and k6 warnings + a() + export default function(){} + `, + expectedOutput: map[string]string{ + "k6/x/A": "", + }, + }, + + "repeat": { + input: ` + "use k6 with k6/x/A" + "use k6 with k6/x/A" + `, + expectedOutput: map[string]string{ + "k6/x/A": "", + }, + }, + "repeat with constraint first": { + input: ` + "use k6 with k6/x/A > 1.4.0" + "use k6 with k6/x/A" + `, + expectedOutput: map[string]string{ + "k6/x/A": "> 1.4.0", + }, + }, + "constraint difference": { + input: ` + "use k6 > 1.4.0" + "use k6 = 1.2.3" + `, + expectedError: `error while parsing use directives in "name.js": already have constraint for "k6", when parsing "=1.2.3"`, + }, + "constraint difference for extensions": { + input: ` + "use k6 with k6/x/A > 1.4.0" + "use k6 with k6/x/A = 1.2.3" + `, + expectedError: `error while parsing use directives in "name.js": already have constraint for "k6/x/A", when parsing "=1.2.3"`, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + deps := make(dependencies) + for k, v := range test.expectedOutput { + require.NoError(t, deps.update(k, v)) + } + if len(test.expectedError) > 0 { + deps = nil + } + + m := make(dependencies) + err := processUseDirectives("name.js", []byte(test.input), m) + if len(test.expectedError) > 0 { + require.ErrorContains(t, err, test.expectedError) + } else { + require.EqualValues(t, deps, m) + } + }) + } +} + +func TestFindDirectives(t *testing.T) { + t.Parallel() + tests := map[string]struct { + input string + expectedOutput []string + }{ + "nothing": { + input: "export default function() {}", + expectedOutput: nil, + }, + "nothing really": { + input: `"use k6"`, + expectedOutput: []string{"use k6"}, + }, + "multiline": { + input: ` + "use k6 with k6/x/sql" + "something" + `, + expectedOutput: []string{"use k6 with k6/x/sql", "something"}, + }, + "multiline start at beginning": { + input: ` +"use k6 with k6/x/sql" +"something" + `, + expectedOutput: []string{"use k6 with k6/x/sql", "something"}, + }, + "multiline comments": { + input: `#!/bin/sh + // here comment "hello" +"use k6 with k6/x/sql"; + /* + "something else here as well" + */ + ; +"something"; +const l = 5 +"more" + `, + expectedOutput: []string{"use k6 with k6/x/sql", "something"}, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + m := findDirectives([]byte(test.input)) + assert.EqualValues(t, test.expectedOutput, m) + }) + } +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 320b14f4fcb..a11b6f1ac74 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -106,6 +106,10 @@ func (c *rootCommand) persistentPreRunE(cmd *cobra.Command, args []string) error } c.globalState.Logger.Debugf("k6 version: v%s", fullVersion()) + if c.globalState.Env["K6_OLD_RESOLUTION"] != "true" { + // do not use the old resolution, let k6 handle it all + return nil + } // If automatic extension resolution is not enabled, continue with the regular k6 execution path if !c.globalState.Flags.AutoExtensionResolution { @@ -147,11 +151,22 @@ func (c *rootCommand) execute() { return } + newExitCode, err := handleNotSatisfiedDependancies(err, c) + + if err == nil { + exitCode = int(newExitCode) + return + } + var ecerr errext.HasExitCode if errors.As(err, &ecerr) { exitCode = int(ecerr.ExitCode()) } + if errors.Is(err, errAlreadyReported) { + return + } + errText, fields := errext.Format(err) c.globalState.Logger.WithFields(fields).Error(errText) if c.loggerIsRemote { @@ -159,6 +174,39 @@ func (c *rootCommand) execute() { } } +func handleNotSatisfiedDependancies(err error, c *rootCommand) (exitcodes.ExitCode, error) { + var unsatisfiedDependenciesErr binaryIsNotSatisfyingDependenciesError + + if !errors.As(err, &unsatisfiedDependenciesErr) { + return 0, err + } + deps := unsatisfiedDependenciesErr.deps + c.globalState.Logger. + WithField("deps", deps). + Info("Automatic extension resolution is enabled. The current k6 binary doesn't satisfy all dependencies," + + " it's required to provision a custom binary.") + provisioner := newK6BuildProvisioner(c.globalState) + var customBinary commandExecutor + customBinary, err = provisioner.provision(constraintsMapToProvisionDependency(deps)) + if err != nil { + err = errext.WithExitCodeIfNone(err, exitcodes.ScriptException) + c.globalState.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.") + return 0, err + } + + err = customBinary.run(c.globalState) + // this only happens if we actually ran the binary and it exited afterwads, in which case we propagate the exit code + var ecerr errext.HasExitCode + if errors.As(err, &ecerr) { + return ecerr.ExitCode(), err + } + + return 0, err +} + func (c *rootCommand) stopLoggers() { done := make(chan struct{}) go func() { diff --git a/internal/cmd/run.go b/internal/cmd/run.go index 764019d2bc8..acc27f78e6d 100644 --- a/internal/cmd/run.go +++ b/internal/cmd/run.go @@ -67,7 +67,6 @@ func (c *cmdRun) run(cmd *cobra.Command, args []string) (err error) { logger.WithError(err).Debug("Everything has finished, exiting k6 with an error!") } }() - printBanner(c.gs) globalCtx, globalCancel := context.WithCancel(c.gs.Ctx) defer globalCancel() @@ -106,6 +105,7 @@ func (c *cmdRun) run(cmd *cobra.Command, args []string) (err error) { if err != nil { return err } + printBanner(c.gs) if test.keyLogger != nil { defer func() { if klErr := test.keyLogger.Close(); klErr != nil { diff --git a/internal/cmd/test_load.go b/internal/cmd/test_load.go index 42828d8fc92..87a8a0cd117 100644 --- a/internal/cmd/test_load.go +++ b/internal/cmd/test_load.go @@ -4,12 +4,18 @@ import ( "archive/tar" "bytes" "crypto/x509" + "errors" "fmt" "io" + "maps" + "net/url" "path/filepath" + "slices" + "strings" "sync" "syscall" + "github.com/Masterminds/semver/v3" "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/pflag" @@ -17,6 +23,8 @@ import ( "go.k6.io/k6/cmd/state" "go.k6.io/k6/errext" "go.k6.io/k6/errext/exitcodes" + "go.k6.io/k6/ext" + "go.k6.io/k6/internal/build" "go.k6.io/k6/internal/js" "go.k6.io/k6/internal/loader" "go.k6.io/k6/js/modules" @@ -142,6 +150,7 @@ func (lt *loadedTest) initializeFirstRunner(gs *state.GlobalState) error { err := errext.WithExitCodeIfNone( moduleResolver.LoadMainModule(pwd, specifier, lt.source.Data), exitcodes.ScriptException) + err = tryResolveModulesExtensions(err, moduleResolver.Imported(), logger, lt.fileSystems, lt.source, gs) if err != nil { return fmt.Errorf("could not load JS test '%s': %w", testPath, err) } @@ -173,6 +182,7 @@ func (lt *loadedTest) initializeFirstRunner(gs *state.GlobalState) error { err := errext.WithExitCodeIfNone( moduleResolver.LoadMainModule(pwd, specifier, arc.Data), exitcodes.ScriptException) + err = tryResolveModulesExtensions(err, moduleResolver.Imported(), logger, arc.Filesystems, lt.source, gs) if err != nil { return fmt.Errorf("could not load JS test '%s': %w", testPath, err) } @@ -191,6 +201,135 @@ func (lt *loadedTest) initializeFirstRunner(gs *state.GlobalState) error { } } +func tryResolveModulesExtensions( + originalError error, imports []string, logger logrus.FieldLogger, + fileSystems map[string]fsext.Fs, source *loader.SourceData, gs *state.GlobalState, +) error { + if !gs.Flags.AutoExtensionResolution { + return originalError + } + + deps, err := extractUnknownModules(originalError) + if err != nil { + return err + } + err = analyseUseContraints(imports, fileSystems, deps) + if err != nil { + return err + } + if len(deps) == 0 { + return nil + } + if !isCustomBuildRequired(deps, build.Version, ext.GetAll()) { + logger. + Debug("The current k6 binary already satisfies all the required dependencies," + + " it isn't required to provision a new binary.") + return nil + } + + if source.URL.Path == "/-" { + gs.Stdin = bytes.NewBuffer(source.Data) + } + + return binaryIsNotSatisfyingDependenciesError{ + deps: deps, + } +} + +func analyseUseContraints(imports []string, fileSystems map[string]fsext.Fs, deps dependencies) error { + for _, imported := range imports { + if strings.HasPrefix(imported, "k6") { + continue + } + u, err := url.Parse(imported) + if err != nil { + panic(err) + } + // We always have URLs here with scheme and everything + _, path, _ := strings.Cut(imported, "://") + if u.Scheme == "https" { + path = "/" + path + } + data, err := fsext.ReadFile(fileSystems[u.Scheme], path) + if err != nil { + panic(err) + } + err = processUseDirectives(imported, data, deps) + if err != nil { + panic(err) + } + } + return nil +} + +type dependencies map[string]*semver.Constraints + +func (d dependencies) update(dep, constraintStr string) error { + var constraint *semver.Constraints + var err error + if len(constraintStr) > 0 { + constraint, err = semver.NewConstraint(constraintStr) + if err != nil { + return fmt.Errorf("unparsable constraint %q for %q", constraintStr, dep) + } + } + // TODO: We could actually do constraint comparison here and get the more specific one + oldConstraint, ok := d[dep] + if !ok || oldConstraint == nil { // either nothing or it didn't have constraint + d[dep] = constraint + return nil + } + if constraint == oldConstraint || constraint == nil { + return nil + } + return fmt.Errorf("already have constraint for %q, when parsing %q", dep, constraint) +} + +func (d dependencies) String() string { + var buf bytes.Buffer + + for idx, depName := range slices.Sorted(maps.Keys(d)) { + if idx > 0 { + _ = buf.WriteByte(';') + } + + buf.WriteString(depName) + constraint := d[depName] + if constraint != nil { + buf.WriteString(constraint.String()) + } + } + return buf.String() +} + +func extractUnknownModules(err error) (map[string]*semver.Constraints, error) { + deps := make(map[string]*semver.Constraints) + if err == nil { + return deps, nil + } + + var u modules.UnknownModulesError + + if errors.As(err, &u) { + for _, name := range u.List() { + deps[name] = nil + } + return deps, nil + } + + return nil, err +} + +// TODO(@mstoykov) potentially figure out some less "exceptionl workflow" solution +type binaryIsNotSatisfyingDependenciesError struct { + deps dependencies +} + +func (r binaryIsNotSatisfyingDependenciesError) Error() string { + // TODO(@mstoykov) fix this for #5327 before merge + return fmt.Sprintf("binary does not satisfy dependencies %q", r.deps) +} + // readSource is a small wrapper around loader.ReadSource returning // result of the load and filesystems map func readSource(gs *state.GlobalState, filename string) (*loader.SourceData, map[string]fsext.Fs, string, error) { diff --git a/internal/cmd/test_load_test.go b/internal/cmd/test_load_test.go new file mode 100644 index 00000000000..41b0c1b3556 --- /dev/null +++ b/internal/cmd/test_load_test.go @@ -0,0 +1,48 @@ +package cmd + +import ( + "testing" + + "github.com/Masterminds/semver/v3" + "github.com/stretchr/testify/require" + + "go.k6.io/k6/internal/lib/testutils" + "go.k6.io/k6/lib/fsext" +) + +const ( + fakerJs = ` +import { Faker } from "k6/x/faker"; + +const faker = new Faker(11); + +export default function () { + console.log(faker.person.firstName()); +} +` + + scriptJS = ` +"use k6 with k6/x/faker > 0.4.0"; +import faker from "./faker.js"; + +export default () => { + faker(); +}; +` +) + +func TestAnalyseUseConstraints(t *testing.T) { + t.Parallel() + + fs := testutils.MakeMemMapFs(t, map[string][]byte{ + "/script.js": []byte(scriptJS), + "/faker.js": []byte(fakerJs), + }) + deps := make(map[string]*semver.Constraints) + + err := analyseUseContraints([]string{"file:///script.js", "file:///faker.js"}, map[string]fsext.Fs{"file": fs}, deps) + + require.NoError(t, err) + require.Len(t, deps, 1) + require.Equal(t, deps["k6/x/faker"].String(), ">0.4.0") +} diff --git a/internal/cmd/tests/cmd_run_test.go b/internal/cmd/tests/cmd_run_test.go index bebc95fd691..9c1a57ab9d1 100644 --- a/internal/cmd/tests/cmd_run_test.go +++ b/internal/cmd/tests/cmd_run_test.go @@ -1060,6 +1060,61 @@ func TestAbortedByUnknownModules(t *testing.T) { assert.Contains(t, stdout, `unknown modules [\"k6/x/anotherone\", \"k6/x/somethinghere\"] were tried to be loaded,`) } +func TestRunFromNotBaseDirectory(t *testing.T) { + t.Parallel() + depScript := ` + export const p = 5; + ` + mainScript := ` + import { p } from "../../../b/dep.js"; + export default function() { + console.log("p = " + p); + }; + ` + + ts := NewGlobalTestState(t) + require.NoError(t, fsext.WriteFile(ts.FS, filepath.Join(ts.Cwd, "a/b/c/test.js"), []byte(mainScript), 0o644)) + require.NoError(t, fsext.WriteFile(ts.FS, filepath.Join(ts.Cwd, "b/dep.js"), []byte(depScript), 0o644)) + + ts.Cwd = filepath.Join(ts.Cwd, "./a/") + ts.CmdArgs = []string{"k6", "run", "-v", "--log-output=stdout", "b/c/test.js"} + + cmd.ExecuteWithGlobalState(ts.GlobalState) + + stdout := ts.Stdout.String() + t.Log(stdout) + require.Contains(t, stdout, `p = 5`) +} + +func TestRunFromSeparateDriveWindows(t *testing.T) { + t.Parallel() + if runtime.GOOS != "windows" { + t.Skip("test only for windows") + } + depScript := ` + export const p = 5; + ` + mainScript := ` + import { p } from "../../../b/dep.js"; + export default function() { + console.log("p = " + p); + }; + ` + + ts := NewGlobalTestState(t) + require.NoError(t, fsext.WriteFile(ts.FS, filepath.Join(ts.Cwd, "a/b/c/test.js"), []byte(mainScript), 0o644)) + require.NoError(t, fsext.WriteFile(ts.FS, filepath.Join(ts.Cwd, "b/dep.js"), []byte(depScript), 0o644)) + + ts.Cwd = "f:\\something somewhere\\and another\\" + ts.CmdArgs = []string{"k6", "run", "-v", "--log-output=stdout", "c:\\test\\a\\b\\c\\test.js"} + + cmd.ExecuteWithGlobalState(ts.GlobalState) + + stdout := ts.Stdout.String() + t.Log(stdout) + require.Contains(t, stdout, `p = 5`) +} + func runTestWithNoLinger(_ *testing.T, ts *GlobalTestState) { cmd.ExecuteWithGlobalState(ts.GlobalState) }