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 }