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
2 changes: 2 additions & 0 deletions cmd/harness/main-harness.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/harness/cli/modules/gitops"
"github.com/harness/cli/modules/iacm"
"github.com/harness/cli/modules/pipeline"
"github.com/harness/cli/modules/rt"
"github.com/harness/cli/pkg/console"
"github.com/harness/cli/pkg/hbase"
"github.com/harness/cli/pkg/registry"
Expand Down Expand Up @@ -49,6 +50,7 @@ func main() {
pipeline.ModuleInit(reg.Module("pipeline"))
// har is an external module (external_binary: harness-har) — ModuleInit is not loaded here.
iacm.ModuleInit(reg.Module("iacm"))
rt.ModuleInit(reg.Module("rt"))
rootcmd.MaybeCheckSpecs(reg)

root := &cobra.Command{
Expand Down
113 changes: 113 additions & 0 deletions modules/rt/apitest_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// Copyright © 2026 Harness Inc.
// SPDX-License-Identifier: Apache-2.0

package rt

import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"testing"

"github.com/harness/cli/pkg/auth"
"github.com/harness/cli/pkg/cmdctx"
"github.com/harness/cli/pkg/registry"
)

// The remaining handlers reach the API through client.New(ctx), so covering them
// means standing a server up in front of a Ctx and recording what was asked of it.

// One inbound request, so a test can assert on the route taken, not just the result.
type call struct {
method string
path string
query url.Values
body map[string]any
}

// Sent through as-is rather than JSON-encoded, for the one route that answers YAML.
type rawResponse string

// An unmapped path answers 404: what the real API does, and what the best-effort
// readers here have to survive.
func apiCtx(t *testing.T, routes map[string]any) (*cmdctx.Ctx, *[]call) {
t.Helper()

calls := &[]call{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c := call{method: r.Method, path: r.URL.Path, query: r.URL.Query()}
_ = json.NewDecoder(r.Body).Decode(&c.body)
*calls = append(*calls, c)

resp, ok := routes[r.URL.Path]
if !ok {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
fmt.Fprintf(w, `{"message":"no such route %s"}`, r.URL.Path)
return
}
if raw, isRaw := resp.(rawResponse); isRaw {
w.Header().Set("Content-Type", "application/yaml")
fmt.Fprint(w, string(raw))
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}))
t.Cleanup(srv.Close)

// A fresh registry per test doubles as a check that ModuleInit wires every id.
reg := registry.New()
ModuleInit(reg.Module("rt"))

ctx := &cmdctx.Ctx{
Context: context.Background(),
Auth: &auth.ResolvedAuth{
APIUrl: srv.URL,
AccountID: "acct",
OrgID: "eng",
ProjectID: "payments",
PATToken: "pat.test",
AuthType: auth.AuthTypePAT,
},
FlagValues: map[string]any{},
Resolver: reg,
}
// Render to a file so endpoint tests do not print over the test output.
ctx.FormatFlags.OutFile = filepath.Join(t.TempDir(), "out")
return ctx, calls
}

func api(format string, args ...any) string {
return basePath + fmt.Sprintf(format, args...)
}

func itemsPage(items ...map[string]any) map[string]any {
page := make([]any, 0, len(items))
for _, item := range items {
page = append(page, item)
}
return map[string]any{"items": page}
}

// The runs-of-a-load-test response; the identity picker reads only the identities.
func runList(identities ...string) map[string]any {
items := make([]map[string]any, 0, len(identities))
for _, id := range identities {
items = append(items, map[string]any{"identity": id})
}
return itemsPage(items...)
}

func findCall(calls *[]call, method, path string) (call, bool) {
for _, c := range *calls {
if c.method == method && c.path == path {
return c, true
}
}
return call{}, false
}
69 changes: 69 additions & 0 deletions modules/rt/composite.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// Copyright © 2026 Harness Inc.
// SPDX-License-Identifier: Apache-2.0

package rt

import (
"errors"
"fmt"
"regexp"
"strings"

"github.com/harness/cli/pkg/cmdctx"
)

const compositeBodyFnID = "composite_body"

// A composite becomes a pipeline, and pipeline identifiers are stricter than load test
// ones — notably hyphens are fine in a load test identity and rejected here.
var (
compositeIdentifierPattern = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_$]*$`)
compositeIdentifierFix = strings.NewReplacer("-", "_", ".", "_", " ", "_", "/", "_")
)

const compositeIdentifierMaxLen = 128

// Not in the spec so the identifier can be checked here: the server's own rejection
// arrives as an HTTP 500 quoting a pipeline rule, naming no argument.
func compositeBody(ctx *cmdctx.Ctx) (any, error) {
if err := checkCompositeIdentifier(ctx.Id); err != nil {
return nil, err
}
name := cmdctx.GetString(ctx.FlagValues, "name")
if name == "" {
name = ctx.Id
}
return map[string]any{
"identifier": ctx.Id,
"name": name,
"description": cmdctx.GetString(ctx.FlagValues, "description"),
"objective": cmdctx.GetString(ctx.FlagValues, "objective"),
"loadTest": map[string]any{
"loadTestRef": cmdctx.GetString(ctx.FlagValues, "loadtest"),
},
"probe": map[string]any{
"identity": cmdctx.GetString(ctx.FlagValues, "probe"),
"infraReference": cmdctx.GetString(ctx.FlagValues, "probe-infra"),
"duration": cmdctx.GetString(ctx.FlagValues, "probe-duration"),
},
}, nil
}

func checkCompositeIdentifier(identifier string) error {
if identifier == "" {
return errors.New("create composite_loadtest requires an <id>")
}
if len(identifier) > compositeIdentifierMaxLen {
return fmt.Errorf("%q is %d characters: a pipeline identifier is capped at %d",
identifier, len(identifier), compositeIdentifierMaxLen)
}
if compositeIdentifierPattern.MatchString(identifier) {
return nil
}
const problem = "%q is not a valid pipeline identifier: it has to start with a letter and hold only letters, digits, underscores or dollar signs. A composite load test is a pipeline, so it will not take the hyphens a load test identity does"
// Only suggest a rewrite that passes: swapping separators cannot fix a leading digit.
if fixed := compositeIdentifierFix.Replace(identifier); compositeIdentifierPattern.MatchString(fixed) {
return fmt.Errorf(problem+"; try %q", identifier, fixed)
}
return fmt.Errorf(problem, identifier)
}
140 changes: 140 additions & 0 deletions modules/rt/composite_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// Copyright © 2026 Harness Inc.
// SPDX-License-Identifier: Apache-2.0

package rt

import (
"reflect"
"strings"
"testing"

"github.com/harness/cli/pkg/cmdctx"
)

func TestCheckCompositeIdentifier(t *testing.T) {
cases := []struct {
name string
identifier string
wantErr string // substring; empty means the identifier is accepted
}{
{name: "plain", identifier: "checkout_load"},
{name: "digits and dollar", identifier: "svc$2_peak"},
{name: "single letter", identifier: "a"},
{name: "mixed case", identifier: "CheckoutLoad"},

{name: "empty", identifier: "", wantErr: "requires an <id>"},
{name: "too long", identifier: strings.Repeat("a", 129), wantErr: "capped at 128"},

// The defect: load test identities take hyphens, pipeline identifiers do not.
{name: "hyphen suggests underscore", identifier: "checkout-load", wantErr: `try "checkout_load"`},
{name: "dot suggests underscore", identifier: "checkout.load", wantErr: `try "checkout_load"`},
{name: "space suggests underscore", identifier: "checkout load", wantErr: `try "checkout_load"`},
{name: "slash suggests underscore", identifier: "checkout/load", wantErr: `try "checkout_load"`},

// A rewrite is only offered when it would actually be accepted.
{name: "leading digit", identifier: "2checkout", wantErr: "has to start with a letter"},
{name: "leading underscore", identifier: "_checkout", wantErr: "has to start with a letter"},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := checkCompositeIdentifier(tc.identifier)

if tc.wantErr == "" {
if err != nil {
t.Fatalf("identifier %q: unexpected error: %v", tc.identifier, err)
}
return
}
if err == nil {
t.Fatalf("identifier %q: expected an error containing %q, got none", tc.identifier, tc.wantErr)
}
if !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("identifier %q: error %q does not contain %q", tc.identifier, err, tc.wantErr)
}
})
}
}

func TestCheckCompositeIdentifierOnlySuggestsValidRewrites(t *testing.T) {
for _, identifier := range []string{"2-checkout", "-checkout", "...", "$checkout"} {
err := checkCompositeIdentifier(identifier)
if err == nil {
t.Fatalf("identifier %q: expected rejection", identifier)
}
if strings.Contains(err.Error(), "try ") {
t.Fatalf("identifier %q: offered a rewrite that would also be rejected: %v", identifier, err)
}
}
}

// The identifier is checked before the body is built, so a bad one never reaches the server.
func TestCompositeBodyRejectsBadIdentifier(t *testing.T) {
for _, identifier := range []string{"", "checkout-load", "2checkout"} {
if _, err := compositeBody(&cmdctx.Ctx{Id: identifier, FlagValues: map[string]any{}}); err == nil {
t.Fatalf("identifier %q: expected rejection", identifier)
}
}
}

func TestCompositeBody(t *testing.T) {
body, err := compositeBody(&cmdctx.Ctx{
Id: "checkout_load",
FlagValues: map[string]any{
"name": "Checkout peak",
"description": "Black Friday rehearsal",
"objective": "hold p95 under 400ms",
"loadtest": "checkout-peak",
"probe": "cart_latency",
"probe-infra": "prod_k8s",
"probe-duration": "10m",
},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

want := map[string]any{
"identifier": "checkout_load",
"name": "Checkout peak",
"description": "Black Friday rehearsal",
"objective": "hold p95 under 400ms",
"loadTest": map[string]any{"loadTestRef": "checkout-peak"},
"probe": map[string]any{
"identity": "cart_latency",
"infraReference": "prod_k8s",
"duration": "10m",
},
}
if !reflect.DeepEqual(body, want) {
t.Fatalf("body mismatch:\n got %#v\nwant %#v", body, want)
}
}

// Every key stays present when its flag is unset — the route reads an absent probe
// block as no probe at all, rather than as one waiting on a runtime input.
func TestCompositeBodyDefaultsNameToIdentifierAndKeepsEmptyKeys(t *testing.T) {
body, err := compositeBody(&cmdctx.Ctx{
Id: "checkout_load",
FlagValues: map[string]any{"loadtest": "checkout-peak", "probe": "cart_latency"},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

want := map[string]any{
"identifier": "checkout_load",
"name": "checkout_load",
"description": "",
"objective": "",
"loadTest": map[string]any{"loadTestRef": "checkout-peak"},
"probe": map[string]any{
"identity": "cart_latency",
"infraReference": "",
"duration": "",
},
}
if !reflect.DeepEqual(body, want) {
t.Fatalf("body mismatch:\n got %#v\nwant %#v", body, want)
}
}
31 changes: 31 additions & 0 deletions modules/rt/filters.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Copyright © 2026 Harness Inc.
// SPDX-License-Identifier: Apache-2.0

package rt

import (
"strings"

"github.com/harness/cli/pkg/cmdctx"
)

const loadTestFilterParamsID = "loadtest_filters"

// Not a query_params expression: a list flag formats as the literal "[]" when unset, which
// the route reads as a tag nothing carries and answers with an empty page.
func loadTestFilterParams(ctx *cmdctx.Ctx) (map[string]string, error) {
tags := make([]string, 0, 4)
for _, tag := range cmdctx.GetStringSlice(ctx.FlagValues, "tag") {
// Splitting here keeps --tag a,b and --tag a --tag b the same request.
for _, part := range strings.Split(tag, ",") {
if part = strings.TrimSpace(part); part != "" {
tags = append(tags, part)
}
}
}
if len(tags) == 0 {
// Absent rather than empty: an empty filter is not a filter.
return nil, nil
}
return map[string]string{"tags": strings.Join(tags, ",")}, nil
}
Loading