Skip to content
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
62 changes: 54 additions & 8 deletions cloudapi/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package cloudapi

import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"strconv"
Expand Down Expand Up @@ -70,6 +72,12 @@ type ValidateTokenResponse struct {
Token string `json:"token-info"`
}

// AuthenticationResponse represents the response from the /cloud/v6/auth endpoint.
type AuthenticationResponse struct {
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
type AuthenticationResponse struct {
type authenticationResponse struct {

do we really need to export it?

StackID int64 `json:"stack_id"`
DefaultProjectID int64 `json:"default_project_id"`
}

func (c *Client) handleLogEntriesFromCloud(ctrr CreateTestRunResponse) {
logger := c.logger.WithField("source", "grafana-k6-cloud")
for _, logEntry := range ctrr.Logs {
Expand All @@ -85,7 +93,7 @@ func (c *Client) handleLogEntriesFromCloud(ctrr CreateTestRunResponse) {
// CreateTestRun is used when a test run is being executed locally, while the
// results are streamed to the cloud, i.e. `k6 cloud run --local-execution` or `k6 run --out cloud script.js`.
func (c *Client) CreateTestRun(testRun *TestRun) (*CreateTestRunResponse, error) {
url := fmt.Sprintf("%s/tests", c.baseURL)
url := fmt.Sprintf("%s/tests", c.BaseURL(c.apiVersion))

// Because the kind of request we make can vary depending on the test run configuration, we delegate
// its creation to a helper.
Expand Down Expand Up @@ -186,7 +194,7 @@ func (c *Client) UploadTestOnly(name string, projectID int64, arc *lib.Archive)
}

func (c *Client) uploadArchive(fields [][2]string, arc *lib.Archive) (*CreateTestRunResponse, error) {
requestURL := fmt.Sprintf("%s/archive-upload", c.baseURL)
requestURL := fmt.Sprintf("%s/archive-upload", c.BaseURL(c.apiVersion))

var buf bytes.Buffer
mp := multipart.NewWriter(&buf)
Expand Down Expand Up @@ -228,7 +236,7 @@ func (c *Client) uploadArchive(fields [][2]string, arc *lib.Archive) (*CreateTes
// TestFinished sends the result and run status values to the cloud, along with
// information for the test thresholds, and marks the test run as finished.
func (c *Client) TestFinished(referenceID string, thresholds ThresholdResult, tained bool, runStatus RunStatus) error {
url := fmt.Sprintf("%s/tests/%s", c.baseURL, referenceID)
url := fmt.Sprintf("%s/tests/%s", c.BaseURL(c.apiVersion), referenceID)

resultStatus := ResultStatusPassed
if tained {
Expand All @@ -255,7 +263,7 @@ func (c *Client) TestFinished(referenceID string, thresholds ThresholdResult, ta

// GetTestProgress for the provided referenceID.
func (c *Client) GetTestProgress(referenceID string) (*TestProgressResponse, error) {
req, err := c.NewRequest(http.MethodGet, c.baseURL+"/test-progress/"+referenceID, nil)
req, err := c.NewRequest(http.MethodGet, c.BaseURL(c.apiVersion)+"/test-progress/"+referenceID, nil)
if err != nil {
return nil, err
}
Expand All @@ -271,7 +279,7 @@ func (c *Client) GetTestProgress(referenceID string) (*TestProgressResponse, err

// StopCloudTestRun tells the cloud to stop the test with the provided referenceID.
func (c *Client) StopCloudTestRun(referenceID string) error {
req, err := c.NewRequest("POST", c.baseURL+"/tests/"+referenceID+"/stop", nil)
req, err := c.NewRequest("POST", c.BaseURL(c.apiVersion)+"/tests/"+referenceID+"/stop", nil)
if err != nil {
return err
}
Expand All @@ -286,7 +294,7 @@ type validateOptionsRequest struct {
// ValidateOptions sends the provided options to the cloud for validation.
func (c *Client) ValidateOptions(options lib.Options) error {
data := validateOptionsRequest{Options: options}
req, err := c.NewRequest("POST", c.baseURL+"/validate-options", data)
req, err := c.NewRequest("POST", c.BaseURL(c.apiVersion)+"/validate-options", data)
if err != nil {
return err
}
Expand All @@ -302,7 +310,7 @@ type loginRequest struct {
// Login the user with the specified email and password.
func (c *Client) Login(email string, password string) (*LoginResponse, error) {
data := loginRequest{Email: email, Password: password}
req, err := c.NewRequest("POST", c.baseURL+"/login", data)
req, err := c.NewRequest("POST", c.BaseURL(c.apiVersion)+"/login", data)
if err != nil {
return nil, err
}
Expand All @@ -323,7 +331,7 @@ type validateTokenRequest struct {
// ValidateToken calls the endpoint to validate the Client's token and returns the result.
func (c *Client) ValidateToken() (*ValidateTokenResponse, error) {
data := validateTokenRequest{Token: c.token}
req, err := c.NewRequest("POST", c.baseURL+"/validate-token", data)
req, err := c.NewRequest("POST", c.BaseURL(c.apiVersion)+"/validate-token", data)
if err != nil {
return nil, err
}
Expand All @@ -336,3 +344,41 @@ func (c *Client) ValidateToken() (*ValidateTokenResponse, error) {

return &vtr, nil
}

// ValidateAuth validates a token and stack URL, returning the stack ID and default project ID.
// The stackURL must be a normalized full URL (e.g., https://my-team.grafana.net).
func (c *Client) ValidateAuth(stackURL string) (*AuthenticationResponse, error) {

Choose a reason for hiding this comment

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

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 same question actually. And more than that, should it be the goal for all v6 endpoints to be called via that library?

Copy link
Contributor

Choose a reason for hiding this comment

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

I think it would be great but would probably need a larger refactor, not directly related to what this PR does. I'm planning to use this library when working on #5009 and I can update this function then.

authURL := fmt.Sprintf("%s/auth", c.BaseURL(APIVersion6))
req, err := http.NewRequest(http.MethodGet, authURL, nil) //nolint:noctx
if err != nil {
return nil, err
}

req.Header.Set("X-Stack-Url", stackURL)
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.token))
req.Header.Set("User-Agent", "k6cloud/"+c.version)

resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer func() {
if resp != nil {
_, _ = io.Copy(io.Discard, resp.Body)
if cerr := resp.Body.Close(); cerr != nil && err == nil {
err = cerr
}
}
}()

if err := CheckResponse(resp); err != nil {

Choose a reason for hiding this comment

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

The error schema in v6 AP is different from the old one. Should the error handling code be updated too?

return nil, err
}

authResp := AuthenticationResponse{}
if err := json.NewDecoder(resp.Body).Decode(&authResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}

return &authResp, nil
}
41 changes: 33 additions & 8 deletions cloudapi/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,23 @@ const (
k6IdempotencyKeyHeader = "K6-Idempotency-Key"
)

// APIVersion represents the Cloud API version.
type APIVersion string

const (
// APIVersion1 represents the v1 API.
APIVersion1 APIVersion = "v1"
// APIVersion6 represents the v6 API.
APIVersion6 APIVersion = "v6"
)

// Client handles communication with the k6 Cloud API.
type Client struct {
client *http.Client
token string
baseURL string
version string
client *http.Client
token string
host string
apiVersion APIVersion
version string

logger logrus.FieldLogger

Expand All @@ -38,10 +49,16 @@ type Client struct {

// NewClient return a new client for the cloud API
func NewClient(logger logrus.FieldLogger, token, host, version string, timeout time.Duration) *Client {
return NewClientWithAPIVersion(logger, token, host, version, APIVersion1, timeout)
}

// NewClientWithAPIVersion returns a new client for the cloud API with a specific API version.
func NewClientWithAPIVersion(logger logrus.FieldLogger, token, host, version string, apiVersion APIVersion, timeout time.Duration) *Client {
c := &Client{
client: &http.Client{Timeout: timeout},
token: token,
baseURL: fmt.Sprintf("%s/v1", host),
host: host,
apiVersion: apiVersion,
version: version,
retries: MaxRetries,
retryInterval: RetryInterval,
Expand All @@ -50,9 +67,17 @@ func NewClient(logger logrus.FieldLogger, token, host, version string, timeout t
return c
}

// BaseURL returns configured host.
func (c *Client) BaseURL() string {
return c.baseURL
// BaseURL returns the fully qualified base URL for the specified API version.
// It returns:
// - "{client.host}/v1" for APIVersion1
// - "https://api.k6.io/cloud/v6" for APIVersion6
func (c *Client) BaseURL(apiVersion APIVersion) string {
switch apiVersion {
case APIVersion6:
return "https://api.k6.io/cloud/v6"
default:
return fmt.Sprintf("%s/v1", c.host)
}
Comment on lines +70 to +80
Copy link
Contributor

@yorugac yorugac Nov 18, 2025

Choose a reason for hiding this comment

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

This change might break k6-operator code if PLZ endpoints (a mix of v1 and v4 at api.k6.io) are not taken into account here. They don't seem to be ATM.

Copy link
Contributor

@AgnesToulet AgnesToulet Nov 24, 2025

Choose a reason for hiding this comment

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

@yorugac I'm having the same issue while working on the migration to the v6 API. If we don't want to migrate everything in one huge PR, it's not really possible to rely on c.host like before (as we'll work with both v1 and v6 APIs until everything is migrated and the host will likely be different for both).

@dgzlopes @grafana/k6-core Should this and the migration work be behind a feature flag so we can make the changes in different PRs until everything is ready?

}

// NewRequest creates new HTTP request.
Expand Down
25 changes: 21 additions & 4 deletions cloudapi/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,12 @@ const LegacyCloudConfigKey = "loadimpact"
//nolint:lll
type Config struct {
// TODO: refactor common stuff between cloud execution and output
Token null.String `json:"token" envconfig:"K6_CLOUD_TOKEN"`
ProjectID null.Int `json:"projectID" envconfig:"K6_CLOUD_PROJECT_ID"`
Name null.String `json:"name" envconfig:"K6_CLOUD_NAME"`
StackID null.Int `json:"stackID,omitempty" envconfig:"K6_CLOUD_STACK_ID"`
StackURL null.String `json:"stackURL,omitempty" envconfig:"K6_CLOUD_STACK"`
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
StackURL null.String `json:"stackURL,omitempty" envconfig:"K6_CLOUD_STACK"`
StackURL null.String `json:"stackURL,omitempty" envconfig:"K6_CLOUD_STACK_URL"`

I added URL because 2 out of 3 have it, but if the intention is to not have it then remove it from the other two.

DefaultProjectID null.Int `json:"defaultProjectID,omitempty"`
Token null.String `json:"token" envconfig:"K6_CLOUD_TOKEN"`
ProjectID null.Int `json:"projectID" envconfig:"K6_CLOUD_PROJECT_ID"`
Name null.String `json:"name" envconfig:"K6_CLOUD_NAME"`

Host null.String `json:"host" envconfig:"K6_CLOUD_HOST"`
Timeout types.NullDuration `json:"timeout" envconfig:"K6_CLOUD_TIMEOUT"`
Expand Down Expand Up @@ -103,6 +106,15 @@ func NewConfig() Config {
//
//nolint:cyclop
func (c Config) Apply(cfg Config) Config {
if cfg.StackID.Valid {
c.StackID = cfg.StackID
}
if cfg.StackURL.Valid && !c.StackURL.Valid {
c.StackURL = cfg.StackURL
}
if cfg.DefaultProjectID.Valid {
c.DefaultProjectID = cfg.DefaultProjectID
}
if cfg.Token.Valid {
c.Token = cfg.Token
}
Expand Down Expand Up @@ -240,7 +252,9 @@ func mergeFromCloudOptionAndExternal(
if err := json.Unmarshal(source, &tmpConfig); err != nil {
return err
}
// Only take out the ProjectID, Name and Token from the options.cloud (or legacy loadimpact struct) map:

// Only merge ProjectID, Name, Token, and StackID from options.
// StackURL and DefaultProjectID can only be set via login.
Comment on lines +255 to +257
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we get this check in a position near to the others, please?

if tmpConfig.ProjectID.Valid {
conf.ProjectID = tmpConfig.ProjectID
}
Expand All @@ -250,6 +264,9 @@ func mergeFromCloudOptionAndExternal(
if tmpConfig.Token.Valid {
conf.Token = tmpConfig.Token
}
if tmpConfig.StackID.Valid {
conf.StackID = tmpConfig.StackID
}

return nil
}
Expand Down
1 change: 1 addition & 0 deletions cloudapi/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ func TestConfigApply(t *testing.T) {

full := Config{
Token: null.NewString("Token", true),
StackID: null.NewInt(1, true),
ProjectID: null.NewInt(1, true),
Name: null.NewString("Name", true),
Host: null.NewString("Host", true),
Expand Down
37 changes: 37 additions & 0 deletions internal/cmd/cloud.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"go.k6.io/k6/internal/build"
"go.k6.io/k6/internal/ui/pb"
"go.k6.io/k6/lib"
"gopkg.in/guregu/null.v3"

"github.com/fatih/color"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -174,6 +175,12 @@ func (c *cmdCloud) run(cmd *cobra.Command, args []string) error {
return err
}

if cloudConfig.ProjectID.Int64 == 0 {
if err := resolveAndSetProjectID(c.gs, &cloudConfig, tmpCloudConfig, arc); err != nil {
return err
}
}

modifyAndPrintBar(c.gs, progressBar, pb.WithConstProgress(0, "Uploading archive"))

var cloudTestRun *cloudapi.CreateTestRunResponse
Expand Down Expand Up @@ -407,6 +414,36 @@ service. Be sure to run the "k6 cloud login" command prior to authenticate with
return cloudCmd
}

func resolveAndSetProjectID(
gs *state.GlobalState,
cloudConfig *cloudapi.Config,
tmpCloudConfig map[string]interface{},
arc *lib.Archive,
) error {
projectID, err := resolveDefaultProjectID(gs, cloudConfig)
if err != nil {
return err
}
if projectID > 0 {
tmpCloudConfig["projectID"] = projectID

b, err := json.Marshal(tmpCloudConfig)
if err != nil {
return err
}

arc.Options.Cloud = b
arc.Options.External[cloudapi.LegacyCloudConfigKey] = b

cloudConfig.ProjectID = null.IntFrom(projectID)
}
if projectID == 0 && (!cloudConfig.StackID.Valid || cloudConfig.StackID.Int64 == 0) {
gs.Logger.Warn("Warning: no projectID or default stack specified. Falling back to the first available stack.")
gs.Logger.Warn("Consider setting a default stack via the `k6 cloud login` command.")
}
return nil
}

func exactCloudArgs() cobra.PositionalArgs {
return func(_ *cobra.Command, args []string) error {
const baseErrMsg = `the "k6 cloud" command expects either a subcommand such as "run" or "login", or ` +
Expand Down
Loading
Loading