github.com/grafana/synthetic-monitoring-agent@v0.64.2/internal/k6runner/local.go (about)

     1  package k6runner
     2  
     3  import (
     4  	"bufio"
     5  	"bytes"
     6  	"context"
     7  	"encoding/json"
     8  	"errors"
     9  	"fmt"
    10  	"io"
    11  	"os"
    12  	"os/exec"
    13  	"time"
    14  
    15  	"github.com/grafana/synthetic-monitoring-agent/internal/k6runner/version"
    16  	"github.com/grafana/synthetic-monitoring-agent/pkg/pb/synthetic_monitoring"
    17  	"github.com/rs/zerolog"
    18  	"github.com/spf13/afero"
    19  )
    20  
    21  // k6CloudPushRefIDEnvVar is the name of the k6 runtime environment variable used to
    22  // tag browser tests as originated from Synthetic Monitoring. It contains a reference
    23  // to the check's execution ID. k6 will propagate this data to the window.k6 property,
    24  // which is parsed by FE O11y as a means to correlate between a specific browser session
    25  // and a check execution.
    26  const k6CloudPushRefIDEnvVar = "K6_CLOUD_PUSH_REF_ID"
    27  
    28  // secretSourceConfig represents the configuration for the secrets store
    29  type secretSourceConfig struct {
    30  	URL   string `json:"url"`
    31  	Token string `json:"token"`
    32  }
    33  
    34  type Local struct {
    35  	repository    *version.Repository
    36  	logger        *zerolog.Logger
    37  	fs            afero.Fs
    38  	blacklistedIP string
    39  }
    40  
    41  func (r Local) WithLogger(logger *zerolog.Logger) Runner {
    42  	r.logger = logger
    43  	return r
    44  }
    45  
    46  func (r Local) Run(ctx context.Context, script Script, secretStore SecretStore, executionID string) (*RunResponse, error) {
    47  	logger := r.logger.With().
    48  		Object("checkInfo", &script.CheckInfo).
    49  		Str("k6ChannelManifest", script.K6ChannelManifest).
    50  		Logger()
    51  
    52  	if script.K6ChannelManifest == "" {
    53  		script.K6ChannelManifest = "*"
    54  
    55  		logger.Warn().Msg("Script does not have a k6 channel assigned. Latest available version will be used.")
    56  	}
    57  
    58  	k6Version, err := r.repository.Resolve(script.K6ChannelManifest)
    59  	if err != nil {
    60  		return nil, fmt.Errorf("cannot resolve k6 version: %w", err)
    61  	}
    62  
    63  	logger = logger.With().Str("k6Version", k6Version.Version.String()).Str("k6Path", k6Version.Path).Logger()
    64  
    65  	afs := afero.Afero{Fs: r.fs}
    66  
    67  	checkTimeout := time.Duration(script.Settings.Timeout) * time.Millisecond
    68  	if checkTimeout == 0 {
    69  		return nil, ErrNoTimeout
    70  	}
    71  
    72  	workdir, err := afs.TempDir("", "k6-runner")
    73  	if err != nil {
    74  		return nil, fmt.Errorf("cannot create temporary directory: %w", err)
    75  	}
    76  
    77  	defer func() {
    78  		if err := r.fs.RemoveAll(workdir); err != nil {
    79  			logger.Error().Err(err).Str("severity", "critical").Msg("cannot remove temporary directory")
    80  		}
    81  	}()
    82  
    83  	metricsFn, err := mktemp(r.fs, workdir, "*.json")
    84  	if err != nil {
    85  		return nil, fmt.Errorf("cannot obtain temporary metrics filename: %w", err)
    86  	}
    87  
    88  	logsFn, err := mktemp(r.fs, workdir, "*.log")
    89  	if err != nil {
    90  		return nil, fmt.Errorf("cannot obtain temporary logs filename: %w", err)
    91  	}
    92  
    93  	scriptFn, err := mktemp(r.fs, workdir, "*.js")
    94  	if err != nil {
    95  		return nil, fmt.Errorf("cannot obtain temporary script filename: %w", err)
    96  	}
    97  
    98  	if err := afs.WriteFile(scriptFn, script.Script, 0o644); err != nil {
    99  		return nil, fmt.Errorf("cannot write temporary script file: %w", err)
   100  	}
   101  
   102  	var cancel context.CancelFunc
   103  
   104  	ctx, cancel = context.WithTimeout(ctx, checkTimeout)
   105  	defer cancel()
   106  
   107  	var configFile string
   108  
   109  	logger.Debug().
   110  		Bool("secretStoreIsConfigured", secretStore.IsConfigured()).
   111  		Str("secretStoreUrl", secretStore.Url).
   112  		Bool("hasSecretStoreToken", secretStore.Token != "").
   113  		Msg("checking secret store configuration")
   114  
   115  	if secretStore.IsConfigured() {
   116  		var cleanup func()
   117  
   118  		configFile, cleanup, err = createSecretConfigFile(secretStore.Url, secretStore.Token)
   119  		if err != nil {
   120  			return nil, fmt.Errorf("cannot create secret config file: %w", err)
   121  		}
   122  		defer cleanup()
   123  
   124  		logger.Debug().
   125  			Str("secret_config_file", configFile).
   126  			Str("secrets_url", secretStore.Url).
   127  			Msg("Using secret config file")
   128  	} else {
   129  		logger.Warn().Msg("No secret store configuration available")
   130  	}
   131  
   132  	args, err := r.buildK6Args(script, metricsFn, logsFn, scriptFn, configFile, executionID)
   133  	if err != nil {
   134  		return nil, fmt.Errorf("building k6 arguments: %w", err)
   135  	}
   136  
   137  	cmd := exec.CommandContext(
   138  		ctx,
   139  		k6Version.Path,
   140  		args...,
   141  	)
   142  
   143  	var stdout, stderr bytes.Buffer
   144  
   145  	cmd.Dir = workdir
   146  	cmd.Stdin = nil
   147  	cmd.Stdout = &stdout
   148  	cmd.Stderr = &stderr
   149  	cmd.Env = k6Env(os.Environ())
   150  
   151  	start := time.Now()
   152  
   153  	logger.Info().Str("command", cmd.String()).Msg("running k6 script")
   154  	err = cmd.Run()
   155  
   156  	duration := time.Since(start)
   157  
   158  	// If context error is non-nil, incorporate it into err.
   159  	// This brings context to log lines and plays well with both errors.Is and errors.As.
   160  	err = errors.Join(err, ctx.Err())
   161  
   162  	if err != nil && !isUserError(err) {
   163  		// A non-user error occurred. This is usually something like k6 not being found, or other os-level errors trying
   164  		// to run the binary. In this case, we don't bother to read k6 outputs and report the error immediately.
   165  		logger.Error().
   166  			Err(err).
   167  			Dur("duration", duration).
   168  			Msg("cannot run k6")
   169  
   170  		dumpK6OutputStream(r.logger, zerolog.ErrorLevel, &stdout, "stream", "stdout")
   171  		dumpK6OutputStream(r.logger, zerolog.ErrorLevel, &stderr, "stream", "stderr")
   172  
   173  		logs, _ := afs.ReadFile(logsFn)
   174  		dumpK6OutputStream(r.logger, zerolog.InfoLevel, bytes.NewReader(logs), "stream", "logs")
   175  
   176  		return nil, fmt.Errorf("executing k6 script: %w", err)
   177  	}
   178  
   179  	// 256KiB is the maximum payload size for Loki. Set our limit slightly below that to avoid tripping the limit in
   180  	// case we inject some messages down the line.
   181  	const maxLogsSizeBytes = 255 * 1024
   182  
   183  	// Mimir can also ingest up to 256KiB, but that's JSON-encoded, not promhttp encoded.
   184  	// To be safe, we limit it to 100KiB promhttp-encoded, hoping than the more verbose json encoding overhead is less
   185  	// than 2.5x.
   186  	const maxMetricsSizeBytes = 100 * 1024
   187  
   188  	// Pre-fill logs buffer with k6 versioning info.
   189  	logsBuf := &bytes.Buffer{}
   190  	_, _ = fmt.Fprintf(
   191  		logsBuf,
   192  		`time=%q level=info k6ChannelManifest=%q k6version=%q k6Path=%q msg="Check will be run with resolved k6 version"`+"\n",
   193  		start.Format(time.RFC3339Nano),
   194  		script.K6ChannelManifest, k6Version.Version.String(), k6Version.Path,
   195  	)
   196  
   197  	logs, truncated, logsErr := readFileLimit(afs.Fs, logsFn, maxLogsSizeBytes, logsBuf)
   198  	if logsErr != nil {
   199  		return nil, fmt.Errorf("reading k6 logs: %w", err)
   200  	}
   201  
   202  	if truncated {
   203  		logger.Warn().
   204  			Str("filename", logsFn).
   205  			Int("limitBytes", maxLogsSizeBytes).
   206  			Msg("Logs output larger than limit, truncating")
   207  
   208  		// Leave a truncation notice at the end.
   209  		fmt.Fprintf(logs, `level=error msg="Log output truncated at %d bytes"`+"\n", maxLogsSizeBytes)
   210  	}
   211  
   212  	metrics, truncated, metricsErr := readFileLimit(afs.Fs, metricsFn, maxMetricsSizeBytes, &bytes.Buffer{})
   213  	if metricsErr != nil {
   214  		return nil, fmt.Errorf("reading k6 metrics: %w", err)
   215  	}
   216  
   217  	if truncated {
   218  		logger.Warn().
   219  			Str("filename", metricsFn).
   220  			Int("limitBytes", maxMetricsSizeBytes).
   221  			Msg("Metrics output larger than limit, truncating")
   222  
   223  		// If we truncate metrics, also leave a truncation notice at the end of the logs.
   224  		fmt.Fprintf(logs, `level=error msg="Metrics output truncated at %d bytes"`+"\n", maxMetricsSizeBytes)
   225  	}
   226  
   227  	rr := &RunResponse{Metrics: metrics.Bytes(), Logs: logs.Bytes()}
   228  	if err := errors.Join(err, errorFromLogs(logs.Bytes())); err != nil {
   229  		// A user-error occurred: Either a context error, a k6 exit code we recognize as such, or an error was inferred
   230  		// from the logs. In this case, absorb the error into the RunResponse so it can be reported back to the user.
   231  		rr.ErrorCode = errorType(err)
   232  		rr.Error = err.Error()
   233  	}
   234  
   235  	return rr, nil
   236  }
   237  
   238  func (r Local) Versions(ctx context.Context) <-chan []string {
   239  	ch := make(chan []string)
   240  
   241  	go func() {
   242  		// The list of k6 versions in the local repository is static: We will push to the channel at most once.
   243  		defer close(ch)
   244  
   245  		entries, err := r.repository.Entries()
   246  		if err != nil {
   247  			r.logger.Error().
   248  				Err(err).
   249  				Str("k6Repository", r.repository.Root).
   250  				Str("k6RepositoryOverride", r.repository.Override).
   251  				Msg("Could not retrieve k6 versions from repository")
   252  
   253  			return
   254  		}
   255  
   256  		versions := make([]string, 0, len(entries))
   257  		for _, e := range entries {
   258  			versions = append(versions, e.Version.String())
   259  		}
   260  
   261  		select {
   262  		case <-ctx.Done():
   263  			r.logger.Error().Err(ctx.Err()).Msg("Aborting k6 version reporting")
   264  
   265  		case ch <- versions:
   266  		}
   267  	}()
   268  
   269  	return ch
   270  }
   271  
   272  func (r Local) buildK6Args(script Script, metricsFn, logsFn, scriptFn, configFile, executionID string) ([]string, error) {
   273  	var (
   274  		logger    *zerolog.Logger
   275  		nopLogger = zerolog.Nop()
   276  	)
   277  	if r.logger != nil {
   278  		logger = r.logger
   279  	} else {
   280  		logger = &nopLogger
   281  	}
   282  
   283  	args := []string{
   284  		"run",
   285  		"--out", "sm=" + metricsFn,
   286  		"--log-format", "logfmt",
   287  		"--log-output", "file=" + logsFn,
   288  		"--max-redirects", "10",
   289  		"--batch", "10",
   290  		"--batch-per-host", "4",
   291  		"--no-connection-reuse",
   292  		"--blacklist-ip", r.blacklistedIP,
   293  		"--block-hostnames", "*.cluster.local", // TODO(mem): make this configurable
   294  		"--summary-time-unit", "s",
   295  		// "--discard-response-bodies",                        // TODO(mem): make this configurable
   296  		"--dns", "ttl=30s,select=random,policy=preferIPv4", // TODO(mem): this needs fixing, preferIPv4 is probably not what we want
   297  		"--address", "", // Disable REST API server
   298  		"--no-thresholds",
   299  		"--no-usage-report",
   300  		"--no-color",
   301  		"--summary-mode", "disabled",
   302  		"--verbose",
   303  		"--throw", // Abort with an exception on certain abnormal cases: https://grafana.com/docs/k6/latest/using-k6/k6-options/reference/#throw
   304  	}
   305  
   306  	// Add secretStore configuration if available
   307  	if configFile != "" {
   308  		args = append(args, "--secret-source", "grafanasecrets=config="+configFile)
   309  		logger.Debug().Str("configFile", configFile).Msg(
   310  			"Adding secret source configuration to k6",
   311  		)
   312  	} else {
   313  		logger.Debug().Msg("No secret source configuration to add to k6")
   314  	}
   315  
   316  	if script.CheckInfo.Type == synthetic_monitoring.CheckTypeBrowser.String() {
   317  		if k6RefID, err := buildK6RefID(executionID); err != nil {
   318  			logger.Warn().Err(err).Msg("error building k6RefID")
   319  		} else {
   320  			args = append(args, "-e", k6CloudPushRefIDEnvVar+"="+k6RefID)
   321  		}
   322  	} else {
   323  		args = append(args,
   324  			"--vus", "1",
   325  			"--iterations", "1",
   326  		)
   327  	}
   328  
   329  	args = append(args, scriptFn)
   330  
   331  	return args, nil
   332  }
   333  
   334  // buildK6RefID builds the K6 Cloud Ref. ID by prefixing the
   335  // Synthetic Monitoring check executionID with "sm:".
   336  func buildK6RefID(executionID string) (string, error) {
   337  	if executionID == "" {
   338  		return "", fmt.Errorf("executionID is empty")
   339  	}
   340  
   341  	return "sm:" + executionID, nil
   342  }
   343  
   344  func mktemp(fs afero.Fs, dir, pattern string) (string, error) {
   345  	f, err := afero.TempFile(fs, dir, pattern)
   346  	if err != nil {
   347  		return "", fmt.Errorf("cannot create temporary file: %w", err)
   348  	}
   349  
   350  	if err := f.Close(); err != nil {
   351  		return "", fmt.Errorf("cannot close temporary file: %w", err)
   352  	}
   353  
   354  	return f.Name(), nil
   355  }
   356  
   357  func dumpK6OutputStream(logger *zerolog.Logger, lvl zerolog.Level, stream io.Reader, fields ...any) {
   358  	scanner := bufio.NewScanner(stream)
   359  
   360  	for scanner.Scan() {
   361  		logger.WithLevel(lvl).Fields(fields).Str("line", scanner.Text()).Msg("k6 output")
   362  	}
   363  
   364  	if err := scanner.Err(); err != nil {
   365  		logger.Error().Fields(fields).Err(err).Msg("reading k6 output")
   366  	}
   367  }
   368  
   369  // readFileLimit reads up to limit bytes from the specified file using the specified FS. The limit respects newline
   370  // boundaries: If the limit is reached, the portion between the last '\n' character and the limit will not be returned.
   371  // A boolean is returned indicating whether the limit was reached.
   372  // existing must point to a bytes.Buffer instance, potentially with some data already in. This data is not accounted for
   373  // in terms of limit.
   374  func readFileLimit(f afero.Fs, name string, limit int64, existing *bytes.Buffer) (*bytes.Buffer, bool, error) {
   375  	file, err := f.Open(name)
   376  	if err != nil {
   377  		return nil, false, fmt.Errorf("opening file: %w", err)
   378  	}
   379  	defer file.Close()
   380  
   381  	copied, err := io.Copy(existing, io.LimitReader(file, limit))
   382  	if err != nil && !errors.Is(err, io.EOF) {
   383  		return nil, false, fmt.Errorf("reading file: %w", err)
   384  	}
   385  
   386  	if copied < limit {
   387  		// Copied less than budget, we haven't truncated anything.
   388  		return existing, false, nil
   389  	}
   390  
   391  	peek := make([]byte, 1)
   392  
   393  	_, err = file.Read(peek)
   394  	if errors.Is(err, io.EOF) {
   395  		// Jackpot, file fit exactly within the limit.
   396  		return existing, false, nil
   397  	}
   398  
   399  	// Rewind until last newline
   400  	lastNewline := bytes.LastIndexByte(existing.Bytes(), '\n')
   401  	if lastNewline != -1 {
   402  		existing.Truncate(lastNewline + 1)
   403  	}
   404  
   405  	return existing, true, nil
   406  }
   407  
   408  // createSecretConfigFile creates a JSON config file with the given secret store URL and token
   409  func createSecretConfigFile(url, token string) (filename string, cleanup func(), err error) {
   410  	tmpFile, err := os.CreateTemp("", "k6-secrets-*.json")
   411  	if err != nil {
   412  		return "", nil, fmt.Errorf("creating temp file: %w", err)
   413  	}
   414  
   415  	if err := os.Chmod(tmpFile.Name(), 0o600); err != nil {
   416  		os.Remove(tmpFile.Name())
   417  		return "", nil, fmt.Errorf("setting file permissions: %w", err)
   418  	}
   419  
   420  	config := secretSourceConfig{
   421  		URL:   url,
   422  		Token: token,
   423  	}
   424  
   425  	configData, err := json.Marshal(config)
   426  	if err != nil {
   427  		os.Remove(tmpFile.Name())
   428  		return "", nil, fmt.Errorf("marshaling config to JSON: %w", err)
   429  	}
   430  
   431  	if _, err := tmpFile.Write(configData); err != nil {
   432  		os.Remove(tmpFile.Name())
   433  		return "", nil, fmt.Errorf("writing config file: %w", err)
   434  	}
   435  
   436  	if err := tmpFile.Close(); err != nil {
   437  		os.Remove(tmpFile.Name())
   438  		return "", nil, fmt.Errorf("closing config file: %w", err)
   439  	}
   440  
   441  	return tmpFile.Name(), func() { os.Remove(tmpFile.Name()) }, nil
   442  }