Skip to content

Commit 3b15ea0

Browse files
Populate __ENV.K6_CLOUDRUN_TEST_RUN_ID on local executions of k6 cloud run (#4092)
* Create test run before starting output.Cloud * Create the cloud test run earlier * Cloud local execution creates the test run before delegating * Move the cloud test run creation into the test configuration * Keep the compatibility w/old behavior in Cloud output * Remove the Cloud-specific handling code of the cloud local execution * Apply suggestions from code review Co-authored-by: Oleg Bespalov <[email protected]> --------- Co-authored-by: Oleg Bespalov <[email protected]>
1 parent 419ea09 commit 3b15ea0

File tree

6 files changed

+241
-15
lines changed

6 files changed

+241
-15
lines changed

cloudapi/config.go

+4-4
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,10 @@ type Config struct {
3232
StopOnError null.Bool `json:"stopOnError" envconfig:"K6_CLOUD_STOP_ON_ERROR"`
3333
APIVersion null.Int `json:"apiVersion" envconfig:"K6_CLOUD_API_VERSION"`
3434

35-
// PushRefID represents the test run id.
36-
// Note: It is a legacy name used by the backend, the code in k6 open-source
37-
// references it as test run id.
38-
// Currently, a renaming is not planned.
35+
// PushRefID is the identifier used by k6 Cloud to correlate all the things that
36+
// belong to the same test run/execution. Currently, it is equivalent to the test run id.
37+
// But, in the future, or in future solutions (e.g. Synthetic Monitoring), there might be
38+
// no test run id, and we may still need an identifier to correlate all the things.
3939
PushRefID null.String `json:"pushRefID" envconfig:"K6_CLOUD_PUSH_REF_ID"`
4040

4141
// Defines the max allowed number of time series in a single batch.

cmd/cloud_run.go

+12
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,18 @@ func (c *cmdCloudRun) preRun(cmd *cobra.Command, args []string) error {
131131

132132
func (c *cmdCloudRun) run(cmd *cobra.Command, args []string) error {
133133
if c.localExecution {
134+
c.runCmd.loadConfiguredTest = func(*cobra.Command, []string) (*loadedAndConfiguredTest, execution.Controller, error) {
135+
test, err := loadAndConfigureLocalTest(c.runCmd.gs, cmd, args, getCloudRunLocalExecutionConfig)
136+
if err != nil {
137+
return nil, nil, fmt.Errorf("could not load and configure the test: %w", err)
138+
}
139+
140+
if err := createCloudTest(c.runCmd.gs, test); err != nil {
141+
return nil, nil, fmt.Errorf("could not create the cloud test run: %w", err)
142+
}
143+
144+
return test, local.NewController(), nil
145+
}
134146
return c.runCmd.run(cmd, args)
135147
}
136148

cmd/outputs_cloud.go

+178
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
package cmd
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"errors"
7+
"fmt"
8+
"path/filepath"
9+
"strings"
10+
"time"
11+
12+
"github.com/sirupsen/logrus"
13+
"gopkg.in/guregu/null.v3"
14+
15+
"go.k6.io/k6/cloudapi"
16+
"go.k6.io/k6/cmd/state"
17+
"go.k6.io/k6/lib"
18+
"go.k6.io/k6/lib/consts"
19+
"go.k6.io/k6/metrics"
20+
)
21+
22+
const (
23+
defaultTestName = "k6 test"
24+
testRunIDKey = "K6_CLOUDRUN_TEST_RUN_ID"
25+
)
26+
27+
// createCloudTest performs some test and Cloud configuration validations and if everything
28+
// looks good, then it creates a test run in the k6 Cloud, using the Cloud API, meant to be used
29+
// for streaming test results.
30+
//
31+
// This method is also responsible for filling the test run id on the test environment, so it can be used later,
32+
// and to populate the Cloud configuration back in case the Cloud API returned some overrides,
33+
// as expected by the Cloud output.
34+
//
35+
//nolint:funlen
36+
func createCloudTest(gs *state.GlobalState, test *loadedAndConfiguredTest) error {
37+
// Otherwise, we continue normally with the creation of the test run in the k6 Cloud backend services.
38+
conf, warn, err := cloudapi.GetConsolidatedConfig(
39+
test.derivedConfig.Collectors[builtinOutputCloud.String()],
40+
gs.Env,
41+
"", // Historically used for -o cloud=..., no longer used (deprecated).
42+
test.derivedConfig.Options.Cloud,
43+
test.derivedConfig.Options.External,
44+
)
45+
if err != nil {
46+
return err
47+
}
48+
49+
if warn != "" {
50+
gs.Logger.Warn(warn)
51+
}
52+
53+
// If not, we continue with some validations and the creation of the test run.
54+
if err := validateRequiredSystemTags(test.derivedConfig.Options.SystemTags); err != nil {
55+
return err
56+
}
57+
58+
if !conf.Name.Valid || conf.Name.String == "" {
59+
scriptPath := test.source.URL.String()
60+
if scriptPath == "" {
61+
// Script from stdin without a name, likely from stdin
62+
return errors.New("script name not set, please specify K6_CLOUD_NAME or options.cloud.name")
63+
}
64+
65+
conf.Name = null.StringFrom(filepath.Base(scriptPath))
66+
}
67+
if conf.Name.String == "-" {
68+
conf.Name = null.StringFrom(defaultTestName)
69+
}
70+
71+
thresholds := make(map[string][]string)
72+
for name, t := range test.derivedConfig.Thresholds {
73+
for _, threshold := range t.Thresholds {
74+
thresholds[name] = append(thresholds[name], threshold.Source)
75+
}
76+
}
77+
78+
et, err := lib.NewExecutionTuple(
79+
test.derivedConfig.Options.ExecutionSegment,
80+
test.derivedConfig.Options.ExecutionSegmentSequence,
81+
)
82+
if err != nil {
83+
return err
84+
}
85+
executionPlan := test.derivedConfig.Options.Scenarios.GetFullExecutionRequirements(et)
86+
87+
duration, testEnds := lib.GetEndOffset(executionPlan)
88+
if !testEnds {
89+
return errors.New("tests with unspecified duration are not allowed when outputting data to k6 cloud")
90+
}
91+
92+
if conf.MetricPushConcurrency.Int64 < 1 {
93+
return fmt.Errorf("metrics push concurrency must be a positive number but is %d",
94+
conf.MetricPushConcurrency.Int64)
95+
}
96+
97+
if conf.MaxTimeSeriesInBatch.Int64 < 1 {
98+
return fmt.Errorf("max allowed number of time series in a single batch must be a positive number but is %d",
99+
conf.MaxTimeSeriesInBatch.Int64)
100+
}
101+
102+
var testArchive *lib.Archive
103+
if !test.derivedConfig.NoArchiveUpload.Bool {
104+
testArchive = test.initRunner.MakeArchive()
105+
}
106+
107+
testRun := &cloudapi.TestRun{
108+
Name: conf.Name.String,
109+
ProjectID: conf.ProjectID.Int64,
110+
VUsMax: int64(lib.GetMaxPossibleVUs(executionPlan)), //nolint:gosec
111+
Thresholds: thresholds,
112+
Duration: int64(duration / time.Second),
113+
Archive: testArchive,
114+
}
115+
116+
logger := gs.Logger.WithFields(logrus.Fields{"output": builtinOutputCloud.String()})
117+
118+
apiClient := cloudapi.NewClient(
119+
logger, conf.Token.String, conf.Host.String, consts.Version, conf.Timeout.TimeDuration())
120+
121+
response, err := apiClient.CreateTestRun(testRun)
122+
if err != nil {
123+
return err
124+
}
125+
126+
// We store the test run id in the environment, so it can be used later.
127+
test.preInitState.RuntimeOptions.Env[testRunIDKey] = response.ReferenceID
128+
129+
// If the Cloud API returned configuration overrides, we apply them to the current configuration.
130+
// Then, we serialize the overridden configuration back, so it can be used by the Cloud output.
131+
if response.ConfigOverride != nil {
132+
logger.WithFields(logrus.Fields{"override": response.ConfigOverride}).Debug("overriding config options")
133+
134+
raw, err := cloudConfToRawMessage(conf.Apply(*response.ConfigOverride))
135+
if err != nil {
136+
return fmt.Errorf("could not serialize overridden cloud configuration: %w", err)
137+
}
138+
139+
if test.derivedConfig.Collectors == nil {
140+
test.derivedConfig.Collectors = make(map[string]json.RawMessage)
141+
}
142+
test.derivedConfig.Collectors[builtinOutputCloud.String()] = raw
143+
}
144+
145+
return nil
146+
}
147+
148+
// validateRequiredSystemTags checks if all required tags are present.
149+
func validateRequiredSystemTags(scriptTags *metrics.SystemTagSet) error {
150+
var missingRequiredTags []string
151+
requiredTags := metrics.SystemTagSet(metrics.TagName |
152+
metrics.TagMethod |
153+
metrics.TagStatus |
154+
metrics.TagError |
155+
metrics.TagCheck |
156+
metrics.TagGroup)
157+
for _, tag := range metrics.SystemTagValues() {
158+
if requiredTags.Has(tag) && !scriptTags.Has(tag) {
159+
missingRequiredTags = append(missingRequiredTags, tag.String())
160+
}
161+
}
162+
if len(missingRequiredTags) > 0 {
163+
return fmt.Errorf(
164+
"the cloud output needs the following system tags enabled: %s",
165+
strings.Join(missingRequiredTags, ", "),
166+
)
167+
}
168+
return nil
169+
}
170+
171+
func cloudConfToRawMessage(conf cloudapi.Config) (json.RawMessage, error) {
172+
var buff bytes.Buffer
173+
enc := json.NewEncoder(&buff)
174+
if err := enc.Encode(conf); err != nil {
175+
return nil, err
176+
}
177+
return buff.Bytes(), nil
178+
}

cmd/tests/cmd_cloud_run_test.go

+35-5
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,16 @@ import (
77
"io"
88
"net/http"
99
"path/filepath"
10+
"strconv"
1011
"testing"
1112

12-
"go.k6.io/k6/errext/exitcodes"
13-
"go.k6.io/k6/lib/fsext"
14-
13+
"github.com/stretchr/testify/assert"
1514
"github.com/stretchr/testify/require"
16-
"go.k6.io/k6/cloudapi"
1715

18-
"github.com/stretchr/testify/assert"
16+
"go.k6.io/k6/cloudapi"
1917
"go.k6.io/k6/cmd"
18+
"go.k6.io/k6/errext/exitcodes"
19+
"go.k6.io/k6/lib/fsext"
2020
)
2121

2222
func TestK6CloudRun(t *testing.T) {
@@ -169,6 +169,36 @@ export default function() {};`
169169
assert.Contains(t, stdout, "execution: local")
170170
assert.Contains(t, stdout, "output: cloud (https://some.other.url/foo/tests/org/1337?bar=baz)")
171171
})
172+
173+
t.Run("the script can read the test run id to the environment", func(t *testing.T) {
174+
t.Parallel()
175+
176+
script := `
177+
export const options = {
178+
cloud: {
179+
name: 'Hello k6 Cloud!',
180+
projectID: 123456,
181+
},
182+
};
183+
184+
export default function() {
185+
` + "console.log(`The test run id is ${__ENV.K6_CLOUDRUN_TEST_RUN_ID}`);" + `
186+
};`
187+
188+
ts := makeTestState(t, script, []string{"--local-execution", "--log-output=stdout"}, 0)
189+
190+
const testRunID = 1337
191+
srv := getCloudTestEndChecker(t, testRunID, nil, cloudapi.RunStatusFinished, cloudapi.ResultStatusPassed)
192+
ts.Env["K6_CLOUD_HOST"] = srv.URL
193+
194+
cmd.ExecuteWithGlobalState(ts.GlobalState)
195+
196+
stdout := ts.Stdout.String()
197+
t.Log(stdout)
198+
assert.Contains(t, stdout, "execution: local")
199+
assert.Contains(t, stdout, "output: cloud (https://app.k6.io/runs/1337)")
200+
assert.Contains(t, stdout, "The test run id is "+strconv.Itoa(testRunID))
201+
})
172202
}
173203

174204
func makeTestState(tb testing.TB, script string, cliFlags []string, expExitCode exitcodes.ExitCode) *GlobalTestState {

lib/options.go

+1-2
Original file line numberDiff line numberDiff line change
@@ -308,8 +308,7 @@ type Options struct {
308308
// iteration is shorter than the specified value.
309309
MinIterationDuration types.NullDuration `json:"minIterationDuration" envconfig:"K6_MIN_ITERATION_DURATION"`
310310

311-
// Cloud is the config for the cloud
312-
// formally known as ext.loadimpact
311+
// Cloud is the configuration for the k6 Cloud, formerly known as ext.loadimpact.
313312
Cloud json.RawMessage `json:"cloud,omitempty"`
314313

315314
// These values are for third party collectors' benefit.

output/cloud/output.go

+11-4
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import (
99
"time"
1010

1111
"github.com/sirupsen/logrus"
12+
"gopkg.in/guregu/null.v3"
13+
1214
"go.k6.io/k6/cloudapi"
1315
"go.k6.io/k6/errext"
1416
"go.k6.io/k6/lib"
@@ -17,11 +19,12 @@ import (
1719
"go.k6.io/k6/output"
1820
cloudv2 "go.k6.io/k6/output/cloud/expv2"
1921
"go.k6.io/k6/usage"
20-
"gopkg.in/guregu/null.v3"
2122
)
2223

23-
// TestName is the default k6 Cloud test name
24-
const TestName = "k6 test"
24+
const (
25+
defaultTestName = "k6 test"
26+
testRunIDKey = "K6_CLOUDRUN_TEST_RUN_ID"
27+
)
2528

2629
// versionedOutput represents an output implementing
2730
// metrics samples aggregation and flushing to the
@@ -119,7 +122,7 @@ func newOutput(params output.Params) (*Output, error) {
119122
conf.Name = null.StringFrom(filepath.Base(scriptPath))
120123
}
121124
if conf.Name.String == "-" {
122-
conf.Name = null.StringFrom(TestName)
125+
conf.Name = null.StringFrom(defaultTestName)
123126
}
124127

125128
duration, testEnds := lib.GetEndOffset(params.ExecutionPlan)
@@ -147,6 +150,7 @@ func newOutput(params output.Params) (*Output, error) {
147150
duration: int64(duration / time.Second),
148151
logger: logger,
149152
usage: params.Usage,
153+
testRunID: params.RuntimeOptions.Env[testRunIDKey],
150154
}, nil
151155
}
152156

@@ -178,6 +182,9 @@ func validateRequiredSystemTags(scriptTags *metrics.SystemTagSet) error {
178182
func (out *Output) Start() error {
179183
if out.config.PushRefID.Valid {
180184
out.testRunID = out.config.PushRefID.String
185+
}
186+
187+
if out.testRunID != "" {
181188
out.logger.WithField("testRunId", out.testRunID).Debug("Directly pushing metrics without init")
182189
return out.startVersionedOutput()
183190
}

0 commit comments

Comments
 (0)