Skip to content
43 changes: 43 additions & 0 deletions internal/cloudapi/v6/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,20 @@ type LoadTest struct {
Updated time.Time `json:"updated"`
}

// LoadZone is a Grafana Cloud k6 load zone.
type LoadZone struct {
ID int64 `json:"id"`
// K6LoadZoneID is the identifier used to reference the load zone from
// k6 scripts (e.g. "amazon:us:ashburn").
K6LoadZoneID string `json:"k6_load_zone_id"`
Name string `json:"name"`
// Public reports whether the load zone is a public (Grafana-managed)
// zone as opposed to a private/custom one.
Public bool `json:"public"`
// Available reports whether the load zone is currently usable.
Available bool `json:"available"`
}

// ListProjects retrieves the list of projects for the configured stack.
func (c *Client) ListProjects(ctx context.Context) ([]Project, error) {
const pageSize int32 = 1000
Expand Down Expand Up @@ -141,6 +155,35 @@ func (c *Client) listLoadTestsPage(
return res, nil
}

// ListLoadZones retrieves the list of load zones available to the configured stack.
func (c *Client) ListLoadZones(ctx context.Context) (_ []LoadZone, err error) {
res, hr, err := c.apiClient.LoadZonesAPI.
LoadZonesList(c.authCtx(ctx)).
XStackId(c.stackID).
Execute()
defer closeResponse(hr, &err)

if err := CheckResponse(hr, err); err != nil {
return nil, err
}
if res == nil {
return nil, errUnknown
}

zones := make([]LoadZone, 0, len(res.Value))
for _, zone := range res.Value {
zones = append(zones, LoadZone{
ID: zone.Id,
K6LoadZoneID: zone.K6LoadZoneId,
Name: zone.Name,
Public: zone.Public,
Available: zone.Available,
})
}

return zones, nil
}

// ValidateToken validates the cloud authentication token.
func (c *Client) ValidateToken(ctx context.Context, stackURL string) (_ *k6cloud.AuthenticationResponse, err error) {
if stackURL == "" {
Expand Down
35 changes: 35 additions & 0 deletions internal/cloudapi/v6/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,41 @@ func TestListLoadTests(t *testing.T) {
assert.Equal(t, int64(2), tests[1].ID)
}

func TestListLoadZones(t *testing.T) {
t.Parallel()

loadZone := func(id int64, k6LoadZoneID, name string, public, available bool) map[string]any {
return map[string]any{
"id": id,
"k6_load_zone_id": k6LoadZoneID,
"name": name,
"public": public,
"available": available,
"custom_load_runner_image": nil,
}
}

client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/cloud/v6/load_zones", r.URL.Path)
writeJSON(t, w, http.StatusOK, map[string]any{
"value": []any{
loadZone(1, "amazon:us:ashburn", "US East (Ashburn)", true, true),
loadZone(2, "my-cluster", "My private cluster", false, false),
},
})
}))

zones, err := client.ListLoadZones(t.Context())
require.NoError(t, err)
require.Len(t, zones, 2)
assert.Equal(t,
LoadZone{ID: 1, K6LoadZoneID: "amazon:us:ashburn", Name: "US East (Ashburn)", Public: true, Available: true},
zones[0])
assert.Equal(t,
LoadZone{ID: 2, K6LoadZoneID: "my-cluster", Name: "My private cluster", Public: false, Available: false},
zones[1])
}

func TestRetryWithConnectionClose(t *testing.T) {
t.Parallel()

Expand Down
17 changes: 17 additions & 0 deletions internal/cloudapi/v6/v6test/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ type Config struct {

// LoadTests is the load test list returned by the load-tests endpoint.
LoadTests []cloudapi.LoadTest

// LoadZones is the load zone list returned by the load-zones endpoint.
LoadZones []cloudapi.LoadZone
}

// NewServer creates a test server that serves v6 API endpoints.
Expand All @@ -70,6 +73,7 @@ func NewServer(t *testing.T, cfg Config) *Server {
mux := http.NewServeMux()
mux.HandleFunc("GET /cloud/v6/projects", s.handleListProjects)
mux.HandleFunc("GET /cloud/v6/projects/{projectID}/load_tests", s.handleListLoadTests)
mux.HandleFunc("GET /cloud/v6/load_zones", s.handleListLoadZones)
mux.HandleFunc("POST /cloud/v6/validate_options", s.handleValidateOptions)
mux.HandleFunc("POST /cloud/v6/projects/{projectID}/load_tests", func(w http.ResponseWriter, r *http.Request) {
if s.cfg.InspectArchive != nil {
Expand Down Expand Up @@ -139,6 +143,19 @@ func (s *Server) handleListLoadTests(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, res)
}

func (s *Server) handleListLoadZones(w http.ResponseWriter, _ *http.Request) {
zones := make([]k6cloud.LoadZoneApiModel, len(s.cfg.LoadZones))
for i, zone := range s.cfg.LoadZones {
m := k6cloud.NewLoadZoneApiModel(
zone.ID, zone.Name, zone.K6LoadZoneID,
zone.Available, *k6cloud.NewNullableString(nil), zone.Public,
)
zones[i] = *m
}
res := k6cloud.NewLoadZonesListApiModel(zones)
writeJSON(w, http.StatusOK, res)
}

func (s *Server) handleValidateOptions(w http.ResponseWriter, _ *http.Request) {
vuh, zero := float32(0.5), float32(0)
res := k6cloud.NewValidateOptionsResponse(
Expand Down
55 changes: 55 additions & 0 deletions internal/cmd/cloud.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,58 @@ func checkCloudLoginFor(conf cloudapi.Config, prefix string) error {
return nil
}

// newCloudV6ClientFromConfig builds a v6 cloud API client from the on-disk and
// environment configuration. It verifies that a complete login is configured
// (using authPrefix in the error message) and wires the resolved stack ID into
// the client. It returns the client alongside the consolidated cloud config.
func newCloudV6ClientFromConfig(
Comment thread
oleiade marked this conversation as resolved.
gs *state.GlobalState, authPrefix string,
) (*cloudapiv6.Client, cloudapi.Config, error) {
currentDiskConf, err := readDiskConfig(gs)
if err != nil {
return nil, cloudapi.Config{}, err
}

cloudConfig, warn, err := cloudapi.GetConsolidatedConfig(
currentDiskConf.Collectors["cloud"], gs.Env, "", nil)
if err != nil {
return nil, cloudapi.Config{}, err
}
if warn != "" {
gs.Logger.Warn(warn)
}

if err := checkCloudLoginFor(cloudConfig, authPrefix); err != nil {
return nil, cloudapi.Config{}, err
}

client, err := cloudapiv6.NewClient(
gs.Logger,
cloudConfig.Token.String,
cloudConfig.Hostv6.String,
build.Version,
cloudConfig.Timeout.TimeDuration(),
)
if err != nil {
return nil, cloudapi.Config{}, err
}

if err := client.SetStackID(cloudConfig.StackID.Int64); err != nil {
return nil, cloudapi.Config{}, err
}

return client, cloudConfig, nil
}

// cloudStackName returns a human-readable name for the configured stack,
// falling back to "stack-<id>" when the stack URL is not available.
func cloudStackName(conf cloudapi.Config) string {
if conf.StackURL.Valid {
return conf.StackURL.String
}
return fmt.Sprintf("stack-%d", conf.StackID.Int64)
}

// cmdCloud handles the `k6 cloud` sub-command
type cmdCloud struct {
gs *state.GlobalState
Expand Down Expand Up @@ -441,6 +493,9 @@ func getCmdCloud(gs *state.GlobalState) *cobra.Command {
projectCmd := getCmdCloudProject(c)
cloudCmd.AddCommand(projectCmd)

loadZoneCmd := getCmdCloudLoadZone(c)
cloudCmd.AddCommand(loadZoneCmd)

testCmd := getCmdCloudTest(c)
cloudCmd.AddCommand(testCmd)

Expand Down
42 changes: 42 additions & 0 deletions internal/cmd/cloud_load_zone.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package cmd

import (
"github.com/spf13/cobra"
"go.k6.io/k6/v2/cmd/state"
)

type cmdCloudLoadZone struct {
globalState *state.GlobalState
}

func getCmdCloudLoadZone(cloudCmd *cmdCloud) *cobra.Command {
c := &cmdCloudLoadZone{
globalState: cloudCmd.gs,
}

exampleText := getExampleText(cloudCmd.gs, `
# List all load zones available in the configured stack
Comment thread
joanlopez marked this conversation as resolved.
Outdated
$ {{.}} cloud load-zone list

# List load zones in JSON format
$ {{.}} cloud load-zone list --json`[1:])

cloudLoadZoneCommand := &cobra.Command{
Use: "load-zone",
Short: "Work with Grafana Cloud k6 load zones",
Long: `Work with Grafana Cloud k6 load zones.`,

Example: exampleText,
}

cloudUsageTemplate := getCloudUsageTemplate()

listCmd := getCmdCloudLoadZoneList(c)
listCmd.SetUsageTemplate(cloudUsageTemplate)
listCmd.SetHelpTemplate(cloudUsageTemplate)
cloudLoadZoneCommand.AddCommand(listCmd)

cloudLoadZoneCommand.SetUsageTemplate(cloudUsageTemplate)

return cloudLoadZoneCommand
}
101 changes: 101 additions & 0 deletions internal/cmd/cloud_load_zone_list.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package cmd

import (
"bytes"
"encoding/json"
"fmt"
"strings"
"text/tabwriter"

"github.com/spf13/cobra"

"go.k6.io/k6/v2/cmd/state"
cloudapiv6 "go.k6.io/k6/v2/internal/cloudapi/v6"
)

type cmdCloudLoadZoneList struct {
globalState *state.GlobalState
isJSON bool
}

func getCmdCloudLoadZoneList(loadZoneCmd *cmdCloudLoadZone) *cobra.Command {
c := &cmdCloudLoadZoneList{
globalState: loadZoneCmd.globalState,
}

exampleText := getExampleText(loadZoneCmd.globalState, `
# List all load zones available in the configured stack
Comment thread
dgzlopes marked this conversation as resolved.
Outdated
$ {{.}} cloud load-zone list`[1:])

listCmd := &cobra.Command{
Use: "list",
Short: "List Grafana Cloud k6 load zones",
Long: `List all load zones available in the configured Grafana Cloud k6 stack.`,
Comment thread
dgzlopes marked this conversation as resolved.
Outdated
Example: exampleText,
Args: cobra.NoArgs,
RunE: c.run,
}

listCmd.Flags().BoolVar(&c.isJSON, "json", false, "output load zone list in JSON format")

return listCmd
}

func (c *cmdCloudLoadZoneList) run(_ *cobra.Command, _ []string) error {
client, cloudConfig, err := newCloudV6ClientFromConfig(
c.globalState, "Listing cloud load zones requires auth settings")
if err != nil {
return err
}

loadZones, err := client.ListLoadZones(c.globalState.Ctx)
if err != nil {
return err
}

if c.isJSON {
return c.outputJSON(loadZones)
}

stackHeader := fmt.Sprintf("Load zones for %s:\n\n", cloudStackName(cloudConfig))

if len(loadZones) == 0 {
printToStdout(c.globalState, stackHeader+"No load zones found.\n")
return nil
}

printToStdout(c.globalState, stackHeader+formatLoadZoneTable(loadZones))
return nil
}

func (c *cmdCloudLoadZoneList) outputJSON(loadZones []cloudapiv6.LoadZone) error {
buf := &bytes.Buffer{}
enc := json.NewEncoder(buf)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
if err := enc.Encode(loadZones); err != nil {
return fmt.Errorf("failed to encode load zone list: %w", err)
}

printToStdout(c.globalState, buf.String())
return nil
}

func formatLoadZoneTable(loadZones []cloudapiv6.LoadZone) string {
var buf strings.Builder
w := tabwriter.NewWriter(&buf, 0, 0, 3, ' ', 0)
_, _ = fmt.Fprintln(w, "ID\tNAME\tTYPE\tAVAILABLE")
for _, z := range loadZones {
zoneType := "private"
if z.Public {
zoneType = "public"
}
available := "no"
if z.Available {
available = "yes"
}
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", z.K6LoadZoneID, z.Name, zoneType, available)
}
_ = w.Flush()
return buf.String()
}
Loading
Loading