diff --git a/README.md b/README.md index 73ebfd585..3b7d70ada 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![MCP Toplist](https://mcptoplist.com/badge/glama%2Fharness%2Fmcp-server.svg)](https://mcptoplist.com/server/glama%2Fharness%2Fmcp-server) -An MCP (Model Context Protocol) server that gives AI agents full access to the Harness.io platform through 11 consolidated tools and 224 resource types. +An MCP (Model Context Protocol) server that gives AI agents full access to the Harness.io platform through 11 consolidated tools and 226 resource types. ## Why Use This MCP Server @@ -10,7 +10,7 @@ Most MCP servers map one tool per API endpoint. For a platform as broad as Harne This server is built differently: -- **11 tools, 224 resource types.** A registry-based dispatch system routes `harness_list`, `harness_get`, `harness_create`, etc. to any Harness resource — pipelines, services, environments, orgs, projects, feature flags, cost data, and more. The LLM picks from 11 tools instead of hundreds. +- **11 tools, 226 resource types.** A registry-based dispatch system routes `harness_list`, `harness_get`, `harness_create`, etc. to any Harness resource — pipelines, services, environments, orgs, projects, feature flags, cost data, and more. The LLM picks from 11 tools instead of hundreds. - **Full platform coverage.** 38 default toolsets spanning CI/CD, GitOps, Feature Flags, Cloud Cost Management, Security Testing, Chaos Engineering, Database DevOps, Internal Developer Portal, Software Supply Chain, Infrastructure as Code Management, Governance, Service Overrides, Knowledge Graph, and more. Opt-in Ansible coverage is available when you need inventory and playbook data. - **Multi-project workflows out of the box.** Agents discover organizations and projects dynamically — no hardcoded env vars needed. Ask "show failed executions across all projects" and the agent can navigate the full account hierarchy. - **34 prompt templates.** Pre-built prompts for common workflows: build & deploy apps end-to-end, debug failed pipelines, review DORA metrics, triage vulnerabilities, optimize cloud costs, audit access control, plan feature flag rollouts, review pull requests, approve pending pipelines, and more. @@ -1202,7 +1202,7 @@ Harness pipelines can be stored in three ways: ## Resource Types -224 resource types organized across 38 toolsets. Each resource type supports a subset of CRUD operations and optional execute actions. +226 resource types organized across 38 toolsets. Each resource type supports a subset of CRUD operations and optional execute actions. ### Platform @@ -1523,40 +1523,43 @@ Use `harness_execute(resource_type="pull_request", action="close", ...)` for an ### Chaos Engineering -| Resource Type | List | Get | Create | Update | Delete | Execute Actions | -|------------------------------| ---- | --- | ------ | ------ | ------ |----------------------------------------------------------| -| `chaos_experiment` | x | x | x | | x | `run`, `stop` | -| `chaos_experiment_run` | | x | | | | | -| `chaos_experiment_variable` | x | | | | | | -| `chaos_component_variable` | | x | | | | | -| `chaos_input_set` | x | x | x | x | x | | -| `chaos_experiment_template` | x | x | | | x | `create_from_template` | -| `chaos_probe` | x | x | x | | x | `enable`, `verify` | -| `chaos_probe_in_run` | x | | | | | | -| `chaos_probe_template` | x | x | | | x | | -| `chaos_infrastructure` | x | | | | | | -| `chaos_k8s_infrastructure` | x | x | | | | `check_health` | -| `chaos_environment` | x | | | | | | -| `chaos_hub` | x | x | x | x | x | | -| `chaos_hub_fault` | x | | | | | | -| `chaos_fault` | x | x | | | x | | -| `chaos_fault_template` | x | x | | | x | | -| `chaos_fault_experiment_run` | x | | | | | | -| `chaos_action` | x | x | | | x | | -| `chaos_action_template` | x | x | | | x | | -| `chaos_loadtest` | x | x | x | | x | `run`, `stop` | -| `chaos_application_map` | x | x | | | | | -| `discovered_namespace` | x | | | | | | -| `discovered_service` | x | | | | | | -| `discovered_network_map` | x | | | | | | -| `chaos_guard_condition` | x | x | | | x | | -| `chaos_guard_rule` | x | x | | | x | `enable` | -| `chaos_recommendation` | x | x | | | | | -| `chaos_risk` | x | x | | | | | -| `chaos_dr_test` | x | | x | | | | -| `scanned_risk` | x | x | | | | `occurrences`, `summary_by_service` | -| `chaos_risk_rule` | x | x | | | | | -| `chaos_risk_scan` | x | x | x | x | x | `retry`, `abort`, `report`, `report_download`, `heatmap` | +| Resource Type | List | Get | Create | Update | Delete | Execute Actions | +|----------------------------------| ---- | --- | ------ | ------ | ------ |------------------------------------------------------------------------------| +| `chaos_experiment` | x | x | x | | x | `run`, `stop` | +| `chaos_experiment_run` | | x | | | | | +| `chaos_experiment_variable` | x | | | | | | +| `chaos_component_variable` | | x | | | | | +| `chaos_input_set` | x | x | x | x | x | | +| `chaos_experiment_template` | x | x | | | x | `create_from_template`, `list_revisions`, `get_variables`, `get_yaml`, `compare_revisions` | +| `chaos_probe` | x | x | x | | x | `enable`, `verify`, `get_manifest` | +| `chaos_probe_in_run` | x | | | | | | +| `chaos_probe_template` | x | x | | | x | `get_variables` | +| `chaos_infrastructure` | x | | | | | | +| `chaos_k8s_infrastructure` | x | x | x | | | `check_health` | +| `chaos_enabled_infrastructure` | x | | | | | | +| `chaos_environment` | x | | | | | | +| `chaos_hub` | x | x | x | x | x | | +| `chaos_hub_fault` | x | | | | | | +| `chaos_fault` | x | x | | | x | `get_variables`, `get_yaml` | +| `chaos_fault_template` | x | x | | | x | `list_revisions`, `get_variables`, `get_yaml`, `compare_revisions` | +| `chaos_fault_experiment_run` | x | | | | | | +| `chaos_action` | x | x | x | | x | `get_manifest` | +| `chaos_action_template` | x | x | | | x | `list_revisions`, `get_variables`, `compare_revisions` | +| `chaos_loadtest` | x | x | x | x | x | `run`, `stop` | +| `chaos_service` | x | x | x | x | x | `list_experiment_runs`, `list_load_tests` | +| `chaos_application_map` | x | x | | | | | +| `discovered_agent` | x | | | | | | +| `discovered_namespace` | x | | | | | | +| `discovered_service` | x | | | | | | +| `discovered_network_map` | x | | | | | | +| `chaos_guard_condition` | x | x | | | x | | +| `chaos_guard_rule` | x | x | | | x | `enable` | +| `chaos_recommendation` | x | x | | | | | +| `chaos_risk` | x | x | | | | | +| `chaos_dr_test` | x | | x | | | | +| `scanned_risk` | x | x | | | | `occurrences`, `summary_by_service` | +| `chaos_risk_rule` | x | x | | | | | +| `chaos_risk_scan` | x | x | x | x | x | `retry`, `abort`, `report`, `report_download`, `heatmap` | ### Cloud Cost Management (CCM) @@ -1833,7 +1836,7 @@ Available toolset names: | `pull-requests` | pull_request, pr_reviewer, pr_comment, pr_check, pr_activity | | `feature-flags` | fme_workspace, fme_environment, fme_feature_flag, fme_feature_flag_definition, fme_rollout_status, fme_rule_based_segment, fme_rule_based_segment_definition, fme_traffic_type, fme_identity, fme_standard_segment, fme_segment_keys | | `gitops` | gitops_agent, gitops_application, gitops_cluster, gitops_repository, gitops_applicationset, gitops_repo_credential, gitops_app_event, gitops_pod_log, gitops_managed_resource, gitops_resource_action, gitops_dashboard, gitops_app_resource_tree | -| `chaos` | chaos_experiment, chaos_experiment_run, chaos_experiment_variable, chaos_component_variable, chaos_input_set, chaos_experiment_template, chaos_probe, chaos_probe_in_run, chaos_probe_template, chaos_infrastructure, chaos_k8s_infrastructure, chaos_environment, chaos_hub, chaos_hub_fault, chaos_fault, chaos_fault_template, chaos_fault_experiment_run, chaos_action, chaos_action_template, chaos_loadtest, chaos_application_map, discovered_namespace, discovered_service, discovered_network_map, chaos_guard_condition, chaos_guard_rule, chaos_recommendation, chaos_risk, chaos_dr_test, scanned_risk, chaos_risk_rule, chaos_risk_scan | +| `chaos` | chaos_experiment, chaos_experiment_run, chaos_experiment_variable, chaos_component_variable, chaos_input_set, chaos_experiment_template, chaos_probe, chaos_probe_in_run, chaos_probe_template, chaos_infrastructure, chaos_k8s_infrastructure, chaos_enabled_infrastructure, chaos_environment, chaos_hub, chaos_hub_fault, chaos_fault, chaos_fault_template, chaos_fault_experiment_run, chaos_action, chaos_action_template, chaos_loadtest, chaos_service, chaos_application_map, discovered_agent, discovered_namespace, discovered_service, discovered_network_map, chaos_guard_condition, chaos_guard_rule, chaos_recommendation, chaos_risk, chaos_dr_test, scanned_risk, chaos_risk_rule, chaos_risk_scan | | `ccm` | cost_perspective, cost_breakdown, cost_timeseries, cost_summary, cost_recommendation, cost_anomaly, cost_anomaly_summary, cost_category, cost_account_overview, cost_filter_value, cost_recommendation_stats, cost_recommendation_detail, cost_commitment | | `sei` | sei_metric, sei_productivity_metric, sei_dora_metric, sei_team, sei_team_detail, sei_org_tree, sei_org_tree_detail, sei_business_alignment, sei_ai_usage, sei_ai_adoption, sei_ai_impact, sei_ai_raw_metric | | `scs` | scs_artifact_source, artifact_security, scs_artifact_component, scs_artifact_remediation, scs_chain_of_custody, scs_compliance_result, code_repo_security, scs_sbom | @@ -1868,7 +1871,7 @@ Available toolset names: +--------v---------+ | Registry | <-- Declarative resource definitions | 38 Toolsets | (data files, not code) - | 224 Resource Types| + | 226 Resource Types| +--------+---------+ | +--------v---------+ diff --git a/scripts/generate-docs.js b/scripts/generate-docs.js index ec32327cc..c267a3a3f 100644 --- a/scripts/generate-docs.js +++ b/scripts/generate-docs.js @@ -19,6 +19,7 @@ const README_PATH = join(ROOT, "README.md"); const README_COVERAGE_TOOLSETS = new Map([ ["feature-flags", { sectionTitle: "Feature Flags" }], ["file_store", { sectionTitle: "File Store" }], + ["chaos", { sectionTitle: "Chaos Engineering" }], ]); const CRUD_OPERATIONS = ["list", "get", "create", "update", "delete"]; const CRUD_COLUMN_INDEX = { list: 1, get: 2, create: 3, update: 4, delete: 5 }; diff --git a/src/registry/extractors.ts b/src/registry/extractors.ts index f0c4351e5..582ff7048 100644 --- a/src/registry/extractors.ts +++ b/src/registry/extractors.ts @@ -897,57 +897,125 @@ export const chaosK8sInfraListExtract = (raw: unknown): { items: unknown[]; tota }; }; -type LoadtestInputLike = { name?: unknown; value?: unknown; type?: unknown; required?: unknown }; +// Pick the tool-specific sub-object out of toolConfig; loadTestManager also +// accepts flat toolConfig at the root for forward-compat, so fall back to that. +const pickToolBlock = ( + toolType: unknown, + toolConfig: Record | undefined, +): Record | undefined => { + if (!toolConfig) return undefined; + const key = typeof toolType === "string" ? toolType.toLowerCase() : ""; + const nested = key + ? (toolConfig[key] as Record | undefined) + : undefined; + return nested ?? toolConfig; +}; + +const readTunable = ( + tc: Record | undefined, + name: string, +): unknown => { + if (!tc) return undefined; + const tunables = tc.tunables as Record | undefined; + return tunables?.[name]; +}; -// Read a well-known input value from the inputs[] slice by name. -const readLoadtestInput = (inputs: LoadtestInputLike[] | undefined, name: string): unknown => - (inputs ?? []).find((i) => i?.name === name)?.value; +const readScriptField = ( + tc: Record | undefined, + name: string, +): unknown => { + if (!tc) return undefined; + const script = tc.script as Record | undefined; + return script?.[name]; +}; + +// K6 rpsLimit has two historical storage locations: tunables.rpsLimit (current +// authoring surface) and options.rpsLimit (legacy/UI-authored path — see +// loadTestManager k6.go LoadOptions.RPSLimit). Mirror the backend's own +// dispatch-time fallback precedence (loadtest_handlers.go) so read-side scalars +// stay truthful regardless of which shape a given record was persisted with. +const readK6RpsLimit = (tc: Record | undefined): unknown => { + const fromTunables = readTunable(tc, "rpsLimit"); + if (fromTunables != null && fromTunables !== 0) return fromTunables; + const options = tc?.options as Record | undefined; + const fromOptions = options?.rpsLimit; + if (fromOptions != null && fromOptions !== 0) return fromOptions; + return undefined; +}; /** - * Project a single load test (InternalApiLoadTestResponse) to a stable shape. + * Project a single load test (LoadTestResponse) to a stable shape. * - * The new wire format carries every recognised tunable inside `inputs[]` - * (TemplateInput) and user-defined non-secrets inside `variables[]` - * (TemplateVariable). We pass both arrays through as-is for fidelity AND - * surface derived convenience scalars (target_url / users / duration_sec / ...) - * that mirror the create-side LLM surface so round-trips are intuitive. + * Since the variables migration, all tunables and custom env vars live under + * toolConfig..tunables / toolConfig..variables (see LocustSpec / + * K6Spec / JMeterSpec in loadTestManager/internal/domain). We pass toolConfig + * through as-is for fidelity AND surface derived convenience scalars mirroring + * the create-side LLM surface so round-trips are intuitive. * - * Drops the large base64 scriptContent and backend user-detail envelopes. * Mirrors `identity` into `loadtestId` so the deep-link resolver fills * {loadtestId} instead of falling back to the name. */ export const chaosLoadTestExtract = (raw: unknown): unknown => { if (!raw || typeof raw !== "object" || Array.isArray(raw)) return raw; const t = raw as Record; - const inputs = Array.isArray(t.inputs) ? (t.inputs as LoadtestInputLike[]) : undefined; - const variables = Array.isArray(t.variables) ? (t.variables as unknown[]) : undefined; + const toolConfig = t.toolConfig as Record | undefined; + const toolBlock = pickToolBlock(t.toolType, toolConfig); + const variables = toolBlock?.variables as unknown[] | undefined; + const envVars = toolBlock?.envVars as unknown[] | undefined; return { loadtestId: t.identity, + uniqueId: t.uniqueId, + parentUniqueId: t.parentUniqueId, identity: t.identity, name: t.name, description: t.description, tags: t.tags, + serviceReferences: t.serviceReferences, environmentIdentifier: t.environmentIdentifier, infraIdentifier: t.infraIdentifier, + infraType: t.infraType, targetType: t.targetType, toolType: t.toolType, scriptSource: t.scriptSource, - // Canonical slices — passed through as-is for fidelity. - inputs, + importType: t.importType, + templateReference: t.templateReference, + templateUpdateAvailable: t.templateUpdateAvailable, + latestRevisionIdentifier: t.latestRevisionIdentifier, + maxDurationSec: t.maxDurationSec, + cleanupPolicy: t.cleanupPolicy, + resources: t.resources, + // Canonical tool config — passed through as-is for fidelity. + toolConfig, variables, - yaml: t.yaml, - // Derived convenience scalars (read-side mirror of the create-side LLM surface). - target_url: readLoadtestInput(inputs, "targetUrl"), - users: readLoadtestInput(inputs, "targetUsers"), - duration_sec: readLoadtestInput(inputs, "durationSeconds"), - ramp_up_sec: readLoadtestInput(inputs, "rampUpTimeSec"), - worker_count: readLoadtestInput(inputs, "workerCount"), - script_image: readLoadtestInput(inputs, "scriptImage"), - script_entrypoint: readLoadtestInput(inputs, "scriptEntrypoint"), - load_args: readLoadtestInput(inputs, "loadArgs"), + envVars, + // Denormalised display strings the backend emits for table renders. + targetUsersDisplay: t.targetUsers, + durationSecondsDisplay: t.durationSeconds, + // Derived convenience scalars (typed tunables under toolConfig..tunables). + target_url: readTunable(toolBlock, "targetUrl"), + users: readTunable(toolBlock, "targetUsers"), + duration_sec: readTunable(toolBlock, "durationSeconds"), + ramp_up_sec: readTunable(toolBlock, "rampUpTimeSec"), + worker_count: readTunable(toolBlock, "workerCount"), + spawn_rate: readTunable(toolBlock, "spawnRate"), + host_url: readTunable(toolBlock, "hostUrl"), + iterations: readTunable(toolBlock, "iterations"), + rps_limit: readK6RpsLimit(toolBlock), + // Derived convenience scalars from toolConfig..script (image mode). + script_image: readScriptField(toolBlock, "image"), + script_entrypoint: readScriptField(toolBlock, "entrypoint"), + load_args: readScriptField(toolBlock, "loadArgs"), + image_pull_secret: readScriptField(toolBlock, "imagePullSecret"), + recentRuns: t.recentRuns, + isSampleTest: t.isSampleTest, + isApiGenerated: t.isApiGenerated, + isAIUsed: t.isAIUsed, lastExecuted: t.lastExecuted, createdAt: t.createdAt, + createdBy: t.createdBy, updatedAt: t.updatedAt, + updatedBy: t.updatedBy, + yaml: t.yaml, }; }; @@ -961,6 +1029,17 @@ export const chaosLoadTestListExtract = (raw: unknown): { items: unknown[]; tota return { items, total: r.pagination?.totalItems ?? items.length }; }; +/** + * Chaos Service (v3) list response: { data: [...], correlationID, pagination: { index, limit, totalPages, totalItems } }. + * Distinct from chaos_loadtest's { items, pagination } envelope — the v3 chaos-services API + * wraps the array under `data` per shared PaginationResponse contract. + */ +export const chaosServiceListExtract = (raw: unknown): { items: unknown[]; total: number } => { + const r = raw as { data?: unknown[]; pagination?: { totalItems?: number } }; + const items = r.data ?? []; + return { items, total: r.pagination?.totalItems ?? items.length }; +}; + /** * Extract chaos hub list response: { items: [...], pagination: { totalItems } } */ diff --git a/src/registry/toolsets/chaos-descriptions.ts b/src/registry/toolsets/chaos-descriptions.ts index eae53759e..5298bb2d3 100644 --- a/src/registry/toolsets/chaos-descriptions.ts +++ b/src/registry/toolsets/chaos-descriptions.ts @@ -103,45 +103,151 @@ export const descChaosExperimentVariable = `Variables for a chaos experiment. Li export const descChaosInfrastructure = `Linux/machine infrastructure registered for chaos experiments and load testing. For Kubernetes infrastructure, use chaos_k8s_infrastructure. Supports list.`; +export const descChaosService = `Chaos Service (Service Management) — a logical service onboarded into Harness Chaos Engineering with an associated Service Discovery agent, environment, infrastructure, and probe associations. Distinct from harness_service (CD service) and from chaos_loadtest. Supports list, get, create, update, and delete.`; + +export const descChaosServiceEnvironmentIds = `Filter by one or more environment identifiers. Comma-separated string for multiple values (e.g. "prodEnv,stagingEnv"). Matches the chaos service's environmentId exactly.`; +export const descChaosServiceInfrastructureIds = `Filter by one or more chaos infrastructure identifiers, each in "/" form. Comma-separated string for multiple values (e.g. "env1/infra1,env2/infra2"). IMPORTANT: the 'infrastructureId' field returned by chaos_service list/get is the BARE infra ID only (the environment prefix is stripped server-side before it is returned) — do NOT pass that value alone. Build the filter value by combining that same response's 'environmentId' + "/" + 'infrastructureId'.`; +export const descChaosServiceTags = `Filter by tags. Comma-separated string; matches services that carry ALL of the listed tags.`; +export const descChaosServiceIncludeAllScope = `When true, include chaos services from all descendant scopes (org + all projects when scoped at org, or account + all orgs/projects when scoped at account). Defaults to false (strict scope match).`; +export const descChaosServiceProbeIds = `Filter by one or more probe identities associated with the chaos service. Comma-separated string for multiple values.`; +export const descChaosServiceOnboardingIdFilter = `Filter by the onboarding batch identifier the chaos service was created under (set during bulk/discovery onboarding).`; +export const descChaosServiceIdentity = `Chaos service identity (slug). Use the 'identity' field returned by chaos_service list.`; +export const descChaosServiceSearch = `Search chaos services by name or identity (case-insensitive regex).`; + +export const descListChaosServices = `List chaos services in the given account/org/project scope. Supports pagination (page, limit; default limit 15, max 100), search, sort (sortField: name|lastUpdated|experimentName|lastExecuted with sortAscending), tag filter, and filters by environmentIds / infrastructureIds. Set include_all_scope=true to include descendant scopes.`; +export const descGetChaosService = `Get a single chaos service by its identity slug within the given account/org/project scope.`; +export const descDeleteChaosService = `Delete a chaos service by its identity slug within the given account/org/project scope. Soft-deletes the service record and purges its probe mapping associations in a single transaction. Destructive and not reversible via API. Returns { success, correlationID } on 200; does not return the deleted resource.`; +export const descListChaosServiceExperimentRuns = `List the experiment runs in which this chaos service was executed (service→run linkage resolved via execution nodes). Supports pagination (page, limit), search, sort (sortField: name|lastUpdated with sortAscending), and filters by infra_ids / statuses / step_types. Set include_all_scope=true to include descendant scopes.`; +export const descListChaosServiceLoadTests = `List the load tests whose serviceReferences include this chaos service. Supports pagination (page, limit), search, sort, and filters by tool_type / environment_ids / infra_ids / tags. Set include_all_scope=true to include descendant scopes.`; +export const descCreateChaosService = `Onboard (create) a chaos service. This is a GUIDED, ORDERED workflow — do NOT skip ahead or invent identifiers; each step depends on the selection made in the previous one. Do NOT advance to the next step until the current step's selection is made, and NEVER call harness_create(chaos_service) until every step below is resolved and the user has confirmed. + +STEP 1 — Select a Discovery Agent (REQUIRED FIRST; gate: do not continue without a chosen agent). + Call harness_list(resource_type='discovered_agent', org_id, project_id). Show each agent's name, identity, serviceCount, and installationType. Ask the user to pick one. + From the chosen agent capture: agent_id = agent.identity; environment_id = agent.environmentIdentifier. For SD Kubernetes agents set infrastructure_id = agent.identity (BARE, not env-prefixed) and infrastructure_type = "KubernetesV2". + +STEP 2 — Select the Service to onboard (REQUIRED, only after STEP 1; gate: do not continue without a chosen service). + 2a. (Optional namespace narrowing) harness_list(resource_type='discovered_namespace', agent_identity=, environment_id=, all=true). Ask the user to pick a namespace, or skip to list all services. + 2b. harness_list(resource_type='discovered_service', agent_identity=, environment_id=, compact=false[, namespace=]). For each service show: name; namespace = spec.kubernetes.namespace; IP = spec.kubernetes.service.clusterIP (fallback spec.ip[0]); port = spec.kubernetes.service.ports[0].port (fallback spec.port[0]). Ask the user to pick a service. + Capture external_service_id = .id. + +STEP 3 — Configure Metadata (REQUIRED, only after STEP 2). + Ask the user for name (REQUIRED), description (optional), and tags (optional). Derive identity as a slug of name unless the user supplies one. identity must be unique in scope — create fails on a duplicate identity or a duplicate external_service_id. + +STEP 4 — Associate Probes (REQUIRED DECISION, only after STEP 3). + Explicitly ask the user: attach one or more health-check probes now, or create the service with no probes? Do not assume. + - If none: probes = []. + - If attaching: harness_list(resource_type='chaos_probe', infra_type='KubernetesV2'[, entity_type='httpProbe'|'cmdProbe'|'datadogProbe'|... to filter by probe type]). For EACH probe the user selects, read its inputs[] array (from the list item, or harness_get chaos_probe). For every input, prompt the user for a value — MANDATORY when required=true — and REPLACE the placeholder value "<+input>" with the user's value while keeping the rest of the input object (name, path, category, type, reference, ...) unchanged. Build probes = [{ probeId: , inputs: [] }, ...]. + +STEP 5 — Create. Only now call harness_create(resource_type='chaos_service', org_id, project_id, body={ identity, name, description?, tags?, external_service_id, agent_id, environment_id, infrastructure_id, infrastructure_type, probes }). The server re-resolves serviceType and namespace from external_service_id and stores infrastructureId as "/". Returns the full ChaosServiceResponse on 200. + +Never fabricate external_service_id, agent_id, or probe inputs — always source them from the discovered_agent / discovered_service / chaos_probe responses above.`; + +export const descBodyChaosServiceCreate = `Chaos service create body. Required: identity, name, external_service_id, agent_id, environment_id, infrastructure_id. Optional: description, tags, infrastructure_type, onboarding_id, probes.`; +export const descChaosServiceName = `Human-readable name for the chaos service. Required.`; +export const descChaosServiceDescription = `Optional free-form description for the chaos service.`; +export const descChaosServiceTagsBody = `Optional tags for the chaos service. Accepts a JSON array of strings or a comma-separated string; normalised to string[] before sending.`; +export const descChaosServiceExternalServiceId = `External service ID (maps to externalServiceId). Required. This is the 'id' field of the chosen record from discovered_service list (STEP 2 of create) — NOT the service name. The server treats it as authoritative and re-resolves the service type and namespace from it, so it must reference a real discovered service under the same agent + environment.`; +export const descChaosServiceAgentId = `Service Discovery agent identity (maps to agentId). Required. Use the 'identity' field of the agent chosen from discovered_agent list (STEP 1 of create). The agent's environmentIdentifier becomes environment_id, and for SD Kubernetes agents its identity is also the bare infrastructure_id.`; +export const descChaosServiceEnvironmentId = `Harness environment identifier the service lives in (maps to environmentId). Required.`; +export const descChaosServiceInfrastructureId = `Chaos infrastructure identifier (maps to infrastructureId). Required. Pass the BARE infra id (NOT env-prefixed) — for SD-onboarded Kubernetes this equals the Discovery agent's identity. The server stores it as "/", so do not pre-prefix it yourself.`; +export const descChaosServiceInfrastructureType = `Infrastructure type of the underlying chaos infra. Enum: Kubernetes | KubernetesV2 | Windows | Linux | CloudFoundry | Container. Use KubernetesV2 for Service Discovery agents (the modern SD-based Kubernetes chaos infrastructure) — this is the value for services onboarded via the discovered_agent/discovered_service flow. Optional; values are the canonical PascalCase forms.`; +export const descChaosServiceOnboardingId = `Optional onboarding batch identifier — set when this service is created as part of a multi-service onboarding flow so downstream tools can correlate the batch.`; +export const descChaosServiceProbes = `Optional probe associations. Array of { probeId, inputs? }. probeId is the identity of an existing chaos probe (from chaos_probe list, filter infra_type='KubernetesV2'). inputs is the probe's own inputs[] array (read it from the chaos_probe list item or harness_get chaos_probe): pass each input object back intact (name, path, category, type, reference, required, ...) but REPLACE its placeholder value "<+input>" with the user-supplied value. Values for inputs marked required=true are mandatory. Do not fabricate or drop input objects — the backend uses each input's path/category to place the value.`; + +export const descChaosServiceProbesUpdate = `Required — full replacement of every probe association (desired-state reconcile; any existing probe omitted here is removed). Array of { probeId, inputs? }, same shape as create's 'probes'. To keep current probes, call harness_get first and resupply its 'probes' array unchanged; pass [] to explicitly detach all probes.`; + +export const descUpdateChaosService = `Update an existing chaos service, identified by the path 'identity' (identity is not renameable — do not put it in the body). This is a FULL REPLACE of the mutable fields (name, description, tags, external_service_id, agent_id, environment_id, infrastructure_id), not a partial patch: description and tags are overwritten with exactly what you send, including being cleared to empty if you omit them — there is no "leave unchanged" behavior for these two fields. To preserve the current description/tags, fetch the service first (harness_get) and re-supply its current values. Probes is a REQUIRED desired-state array: it is reconciled atomically with the field update, and any probe present on the service but not in the array is removed. To keep the current probes, harness_get first and resupply the 'probes' array unchanged; pass [] to intentionally detach every probe. Returns the full ChaosServiceResponse on 200.`; +export const descBodyChaosServiceUpdate = `Chaos service update body. Required: name, external_service_id, agent_id, environment_id, infrastructure_id (the last four are re-validated server-side via a Service Discovery lookup), and probes (a full desired-state array — see below). Optional (schema-wise): description, tags — but "optional" only means the request validates without them. Omitting description or tags CLEARS them server-side (full replace, no partial patch); re-supply the current value from a prior harness_get if you want it preserved. probes is required precisely because it is a full replace: supply the complete desired set (harness_get first to keep existing ones), or [] to detach all probes. Do NOT include 'identity' — pass it at the top level so it goes on the URL.`; + export const descChaosLoadtest = `Load test (Resilience Testing) instance. Supports list, get, create, delete; run/stop via execute actions. -Locust is supported on Linux VM and Kubernetes (script + image modes). K6 is supported on Kubernetes only (script + image modes; UI mode deferred) — LinuxVM K6 is not supported. JMeter is coming soon. -To create one, first pick a load-runner infrastructure: for Linux VM use chaos_infrastructure (loadEnabled infras), for Kubernetes use chaos_enabled_infrastructure. Then pass its environment_id + infra_id with target_url and the Python (locust) script. +Locust is supported on Linux VM and Kubernetes (script + image modes). K6 is genuinely Kubernetes-only at the backend. JMeter is restricted to Kubernetes by MCP for parity with the Harness UI (the backend itself does not enforce this for JMeter) — Linux VM supports Locust only; MCP rejects K6/JMeter with a Linux target_type at create time. JMeter accepts script/script_image scalars like Locust/K6 (plus properties/env_vars/thresholds/worker_count); 'tool_config' remains available as an advanced escape hatch (e.g. .zip bundles). +To create one, follow the prerequisite chain declared in relatedResources: (1) pick a load-runner infra via chaos_infrastructure (Linux VM) or chaos_enabled_infrastructure (Kubernetes), then (2) list chaos_service with infrastructure_ids='/' and pick one — or create a chaos_service if the list is empty — feeding its identity into service_references. When picking a Kubernetes infra, remember to set target_type='kubernetes' explicitly; it is not derived from the infra you chose. Response / read schema (what list/get returns — agents NEVER need to construct these on create; MCP builds them automatically): -- inputs: array of {name, value, type ("Integer" | "String"), required?: true}. Well-known names the backend recognises: - targetUsers (Integer, required), durationSeconds (Integer), rampUpTimeSec (Integer), - workerCount (Integer; Kubernetes only), targetUrl (String), - scriptImage (String, required in image mode), scriptEntrypoint (String, required in image mode), - loadArgs (String; image mode only). -- variables: array of {name, value, type ("String" | "Number")} — user-defined non-secret env vars (currently always empty; custom variables are a deferred feature). +- toolConfig. (canonical, per loadTestManager LocustSpec / K6Spec / JMeterSpec): + mode: "script" | "image" + script: { content? (base64), image?, entrypoint?, loadArgs?, imagePullSecret? } + tunables: { targetUrl?, targetUsers?, spawnRate? (Locust), rampUpTimeSec? (Locust), durationSeconds? (Locust/K6), workerCount?, hostUrl? (K6), iterations? (K6), rpsLimit? (K6) } + envVars?: [{key, value, secret?}] (K6/JMeter) + variables?: template.VariableList (currently always empty; custom variables are a deferred feature) + properties?: [{key, value, sendToEngines?}] (JMeter) + thresholds?: [{metric, stat?, operator, value, abortOnFail?}] (JMeter) - yaml: base64-encoded LoadTest manifest. Decoded shape: kind: LoadTest apiVersion: v1alpha1 - name: - description: - tags: [] + metadata: + name: + description?: + tags?: [] + serviceReferences?: [] spec: identity: - toolType: Locust + toolType: Locust | K6 | JMeter infraType: kubernetes | linux # derived from targetType targetType: kubernetes | machine-chaos-linux | linux-chaos - scriptSource: inline | image - scriptContent: + cleanupPolicy: delete | retain infraId: envId: - inputs: [] -- scriptContent: base64-encoded Python script (inline mode). Large — dropped from list responses. -- Derived convenience scalars (read-side projection of inputs[] by MCP, matching the create-side scalars): - target_url, users, duration_sec, ramp_up_sec, worker_count, script_image, script_entrypoint, load_args.`; + toolConfig: { : { mode, script, tunables, ... } } +- Derived convenience scalars (read-side projection of toolConfig. by MCP, matching the create-side scalars): + target_url, users, duration_sec, ramp_up_sec, worker_count, spawn_rate, host_url, iterations, rps_limit, + script_image, script_entrypoint, load_args, image_pull_secret.`; -export const descChaosK8sInfrastructure = `Kubernetes chaos infrastructure available for running experiments. +export const descChaosK8sInfrastructure = `Kubernetes chaos infrastructure (chaos server) available for running experiments. Use chaos_environment list first to get an environmentId, then pass it here to filter infrastructures for that environment. Returns infrastructure details including identity, infraID, name, environmentID, status, infraType, infraScope, and isChaosEnabled. The infraID is used as the infra_ref parameter in create_from_template. IMPORTANT: Only infrastructures with status=ACTIVE AND isChaosEnabled=true can be used to create chaos experiments. Always check both fields before selecting an infrastructure. +Supports list, get, create, plus check_health execute action. Supports filtering by status (ACTIVE, INACTIVE, PENDING), search, and optional inclusion of legacy V1 infrastructures. To list ONLY infrastructures that are ready to run experiments (chaos-enabled AND ACTIVE) with correct totals/pagination, use chaos_enabled_infrastructure instead.`; +export const descCreateK8sInfra = `Register a new Kubernetes chaos infrastructure (chaos server) in the given Harness project. + +This registers the chaos agent/delegate metadata in Harness and returns an access token plus Helm/manifest instructions for installing the chaos server in your cluster. + +GUIDED workflow — resolve each step before calling harness_create: + +STEP 1 — Select environment (REQUIRED): + harness_list resource_type=chaos_environment (or resource_type=environment). Ask the user to pick one. + Capture environment_id = chosen environment identifier. + +STEP 2 — Select backing CD infrastructure (REQUIRED for standard onboarding): + harness_list resource_type=infrastructure with environment_id=. + Ask the user which Kubernetes infrastructure definition to attach. Capture infra_id = infrastructure identifier. + The backend validates this Harness CD infra exists in the selected environment. + +STEP 3 — Configure chaos server metadata (REQUIRED): + Ask for name and identity (slug). identity must be unique in scope; infra_id defaults to identity when omitted. + Defaults (override only if user asks): infra_namespace=hce, service_account=litmus, infra_scope=CLUSTER, infra_type=KUBERNETES. + +STEP 4 — Create: + harness_create(resource_type='chaos_k8s_infrastructure', org_id, project_id, body={ identity, name, environment_id, infra_id?, k8s_connector_id?, infra_namespace?, service_account?, infra_scope?, infra_type?, description?, tags?, ai_enabled? }) + +Returns identity, name, token (for agent install), and uniqueId. After create, apply the returned manifest/Helm chart in the target cluster and wait for status=ACTIVE + isChaosEnabled=true before running experiments.`; + +export const descBodyK8sInfraCreate = `Chaos K8s infrastructure (chaos server) registration body. Required: identity, name, environment_id. infra_id defaults to identity when omitted.`; + +export const descK8sInfraIdentityCreate = `Unique slug for the chaos infrastructure. Also used as infraID when infra_id is omitted. Must be unique within the project scope.`; + +export const descK8sInfraNameCreate = `Human-readable display name for the chaos server.`; + +export const descK8sInfraEnvironmentIdCreate = `Harness environment identifier where the chaos server will run. Use chaos_environment or environment list to find valid IDs.`; + +export const descK8sInfraInfraIdCreate = `Harness CD infrastructure definition identifier in the selected environment. Defaults to identity when omitted. The backend verifies this infra exists.`; + +export const descK8sInfraConnectorIdCreate = `Optional Kubernetes connector identifier when required by your onboarding flow.`; + +export const descK8sInfraNamespaceCreate = `Kubernetes namespace where chaos components are installed. Default: hce.`; + +export const descK8sInfraServiceAccountCreate = `Kubernetes service account for the chaos server. Default: litmus.`; + +export const descK8sInfraScopeCreate = `Installation scope. CLUSTER (default) or NAMESPACE.`; + +export const descK8sInfraTypeCreate = `Infrastructure type. KUBERNETES (default) or KUBERNETESV2.`; + +export const descK8sInfraAiEnabledCreate = `Enable AI recommendations for this infrastructure. Default: false.`; + export const descChaosHub = `ChaosHub — a Git-backed repository that provides version-controlled chaos fault, experiment, probe, and action templates. Every project includes a default Enterprise ChaosHub with pre-built templates; custom hubs can be created to bring in organization-specific chaos artifacts. Supports list, get, create, update, and delete. @@ -394,112 +500,309 @@ export const descListExperimentVariables = `List variables for a chaos experimen export const descListLinuxInfra = `List chaos Linux infrastructures (load runners)`; -export const descListLoadtests = `List load test instances`; +export const descListLoadtests = `List load test instances. + +Pagination: use the top-level harness_list 'page' / 'size' arguments (0-indexed page, size 1-100) -- NOT 'limit'; a top-level 'limit' argument is silently dropped and the default size (20) applies instead. +Filters (tool_type, environment_id, tags, sort_field, sort_ascending, search) must be passed inside harness_list's 'filters' object, e.g. filters: { tool_type: "Locust" }. +KNOWN LIMITATION: 'search' matches only the load test's display name (case-insensitive substring) -- it does NOT match identity/slug, and is not fuzzy or prefix-tolerant. Use an exact or partial name substring, not an identity-style term. +compact:true (the harness_list default) strips toolType/targetType/scriptSource/toolConfig/recentRuns from each item to keep responses lean -- pass compact:false, or call harness_get on a specific loadtest_id, for full tool config and run history.`; export const descGetLoadtest = `Get load test instance details`; -export const descCreateLoadtest = `Creates a Locust load test (Resilience Testing) on a Linux VM or Kubernetes load-runner infrastructure. +// Shared, tool-agnostic map of scalar body fields -> toolConfig..* wire paths. +// Referenced verbatim by both descCreateLoadtest (Step 6 taxonomy) and +// descUpdateLoadtest (scalar-edit path 1). The mapping is identical on create +// and update -- callers pass the same scalars either way; MCP builds +// toolConfig. from them via buildLocustToolConfig / buildK6ToolConfig / +// buildJMeterToolConfig. +export const descLoadtestFieldTaxonomy = ` (a) Script file / Custom image -> script | script_image (+script_entrypoint, load_args, image_pull_secret) -> toolConfig..script.{content|image|entrypoint|loadArgs|imagePullSecret}. ALL tools; script_image is the only mandatory field in Custom Image mode; script_entrypoint, load_args and image_pull_secret are optional (image_pull_secret only for Private registries; the backend falls back to a tool-specific default entrypoint when omitted). + (b) Host / Target URL -> target_url (fixed|<+input>) -> toolConfig..tunables.targetUrl. LOCUST + K6 ONLY (not exposed for JMeter -- see JMeter step notes), optional. K6 additionally derives tunables.hostUrl from the URL's origin (feeds the script as __ENV.HOST_URL); leave blank if the script/locustfile declares its own host. + (c) Users -> users (fixed|<+input>) -> toolConfig..tunables.targetUsers. LOCUST + K6 ONLY (not exposed for JMeter -- see JMeter step notes). + (d) Duration (seconds) -> duration_sec (fixed|<+input>) -> toolConfig..tunables.durationSeconds. LOCUST + K6 ONLY (not exposed for JMeter -- see JMeter step notes). + (e) Ramp Up Duration (seconds) -> ramp_up_sec (fixed|<+input>) -> toolConfig..tunables.rampUpTimeSec. LOCUST ONLY (no Ramp Up field in the K6 UI form or for JMeter). Must not exceed Duration when both are fixed values. + (f) Iterations (cap) -> iterations (fixed|<+input>) -> toolConfig.k6.tunables.iterations. K6 ONLY. Duration takes precedence when both are set. + (g) RPS Limit -> rps_limit (fixed|<+input>) -> toolConfig.k6.tunables.rpsLimit. K6 ONLY. Global cap, split evenly across replicas; always applies (unlike (c)/(d)/(f), which k6 ignores once the script declares its own scenarios/stages). + (h) Worker Count / Replicas -> worker_count (fixed|<+input>) -> toolConfig..tunables.workerCount. LOCUST + K6 + JMETER, Kubernetes only (VM always runs a single worker/pod -- skip entirely for target_type='machine-chaos-linux'). Locust: Users are DISTRIBUTED across workers, not multiplied. K6: <=1 = single pod; higher values fan out to multiple runner pods via --execution-segment. JMeter: workers MULTIPLY load -- each injector runs the FULL uploaded plan unchanged; thread counts inside the file are NOT sliced across workers. + (i) Environment Variables -> env_vars -> toolConfig..envVars[]. K6 + JMETER. Structured array; each entry is a literal {key, value}, a runtime {key, value: "<+input>"}, or a secret reference {key, secret_id, secret_scope?}. See the env_vars field's own description (descLoadtestEnvVars) for the full validator, reserved-name list, and secret_scope discovery flow (harness_list resource_type=secret type=SecretText) -- not repeated here. K6 additionally rejects HOST_URL/K6_VUS/K6_DURATION/K6_ITERATIONS/K6_STAGES/K6_RPS (tool-specific reserved names on top of the shared list). + (j) Property Overrides -> properties -> toolConfig.jmeter.properties[]. JMETER ONLY. Array of {key, value, send_to_engines?}; injects/overrides JMeter properties at run time without editing the uploaded plan -- reference as \${__P(NAME)} inside the plan. send_to_engines (-G) controls whether the property also reaches remote workers in Distributed Execution; omit for controller-only properties. + (k) Thresholds -> thresholds -> toolConfig.jmeter.thresholds[]. JMETER ONLY. Array of {metric, stat?, operator, value, abort_on_fail?}; pass/fail criteria evaluated against the run's .jtl results AFTER the test (the uploaded plan's own Assertions still apply per-request during the run). metric one of response_time_ms|error_rate_pct|throughput_rps|latency_ms; operator one of < <= > >= == !=; stat (e.g. p95/p99/avg/median/max) is REQUIRED when metric is response_time_ms or latency_ms.`; + +export const descCreateLoadtest = `Creates a Locust load test (Resilience Testing) on a Linux VM or Kubernetes load-runner infrastructure. IMPORTANT: You MUST NOT auto-select, assume, or pre-fill any value on behalf of the user. At every step below, present the options / ask for the value and wait for explicit user confirmation before proceeding. Do NOT assume org/project/infrastructure/script/URL based on previous activity — the user may choose differently each time. -Locust (Python) and K6 (JavaScript) are supported. JMeter is coming soon. K6 requires Kubernetes (LinuxVM K6 is not supported). For K6, "Upload K6 script" and "Custom Image" modes are supported via MCP; "Define test via UI" is deferred. +Locust (Python), K6 (JavaScript), and JMeter (Java) are supported. K6 and JMeter are Kubernetes-only; Linux VM supports Locust only. All three tools support "Upload script" and "Using Custom Image" via scalar inputs (script, script_image, script_entrypoint, load_args, image_pull_secret) plus tool-specific extras (properties/thresholds for JMeter; env_vars for K6/JMeter). The 'tool_config' body field remains available as an advanced escape hatch (e.g. JMeter .zip bundles pre-base64'd into tool_config.jmeter.script.content). For K6, "Define test via UI" is deferred. +Required workflow -- follow in order, pausing for user input after each step. +COMMON STEPS (identical for Locust, K6, and JMeter): +Each step below shows the slice of the canonical stored LoadTest YAML that its inputs populate (what the user sees in the Harness UI YAML view). You NEVER hand-write this YAML -- MCP synthesizes it from the scalars/objects you pass to harness_create; the slices exist only so you can reason about which of your inputs affects which YAML path and echo an accurate preview back to the user. +Two paths of the stored YAML are set from top-level body fields rather than the MCP-built manifest itself and are merged by the backend: metadata.serviceReferences (from body.service_references) and spec.cleanupPolicy (from body.cleanup_policy). All other YAML paths below correspond 1:1 to what MCP writes into body.yaml. + +Step 1 -- Name / description / tags: + Ask for a name (any non-empty string) -> name. identity is auto-derived from name by stripping non-alphanumerics unless the user supplies one (letters / numbers / underscores). + description and tags are optional -- ask, but do not require them. + YAML populated after this step: + kind: LoadTest + apiVersion: v1alpha1 + metadata: + name: + description: # omitted when empty + tags: # omitted when empty + - + spec: + identity: # auto-derived slug from name unless user supplies one -Required workflow — follow in order, pausing for user input after each step: +Step 2 -- Execution environment target type: + Ask "Linux VM" (target_type=machine-chaos-linux) or "Kubernetes" (target_type=kubernetes). Wait for the user to choose. + Linux VM supports Locust only. Kubernetes supports Locust, K6, or JMeter. + YAML populated after this step (delta only): + spec: + targetType: kubernetes | machine-chaos-linux + infraType: kubernetes | linux # derived from targetType, not user-supplied + +Step 3 -- Load-runner infrastructure (see chaos_loadtest.relatedResources for canonical guidance): + Linux VM -> harness_list resource_type=chaos_infrastructure (loadEnabled + ACTIVE only). + Kubernetes -> harness_list resource_type=chaos_enabled_infrastructure (already ACTIVE + chaos-enabled). + From the chosen row: environmentID -> environment_id, infraID -> infra_id. Do NOT choose an infra yourself. + If NO eligible infra is returned (empty list, or every row fails the eligibility filter above), STOP and tell the user no eligible load-runner infra exists in the current scope. Ask them to (a) switch org/project scope, (b) pick a different target type at Step 2 (Linux VM <-> Kubernetes), or (c) exit/stop. Do NOT fall back to an ineligible infra and do NOT proceed to Step 4 -- a load test cannot be created without an eligible infra. + YAML populated after this step (delta only): + spec: + infraId: + envId: + +Step 4 -- Resilience Testing Services (chaos_service): + Call harness_list resource_type=chaos_service with infrastructure_ids='/'. Show the list to the user. + The user may (a) pick one or more identities from the list, OR (b) onboard a brand-new service via chaos_service's create flow -- offer BOTH options regardless of whether the list is empty. Pass the chosen identity/identities as service_references. + YAML populated after this step (delta only): + metadata: + serviceReferences: # merged in by backend from the top-level service_references body field + - + - ... + Passed on the wire as body.service_references (snake_case scalar), NOT as part of the yaml manifest MCP builds -- the backend writes it into the stored YAML at metadata.serviceReferences. + +Step 5 -- Tool type: + Linux VM: Locust only. Kubernetes: ask Locust / K6 / JMeter. + Pass tool_type='Locust' | 'K6' | 'JMeter'. K6 and JMeter with a non-Kubernetes target_type will be rejected by MCP. + YAML populated after this step (delta only): + spec: + toolType: Locust | K6 | JMeter + cleanupPolicy: delete # default; see Step 7a to override or add spec.resources (Step 7b) + toolConfig: + : # e.g. locust / k6 / jmeter + mode: script | image # tool_config detail filled in Step 6 + script: + content: "" # base64 on the wire, decoded plain-text in the YAML view; filled in Step 6 + tunables: # filled in Step 8 (load configuration) + targetUsers: + durationSeconds: + rampUpTimeSec: + workerCount: # Kubernetes only + Notes: + - All three tools (Locust / K6 / JMeter) populate toolConfig. from Step-6 scalars -- no hand-built tool_config needed for standard flows. JMeter's tool_config remains available only as an advanced escape hatch (e.g. pre-base64'd .zip bundles). + - JMeter has no target_url / users / duration / ramp_up scalar surface -- load shape is baked into the uploaded plan (script mode) or the container image (custom image mode). Server defaults 100/600/30 for missing tunables on save. + +Step 6 -- Tool-specific configuration (branch on tool_type from Step 5): +Every tool type supports two authoring modes: + (a) Upload script (script_source='inline', mode='script') -- paste the raw script; MCP base64-encodes it into toolConfig..script.content. + (b) Using Custom Image (script_source='image', mode='image') -- point at a prebuilt container image; no script is uploaded. +Mode lives at toolConfig..mode; script_source on the wire body mirrors it ('inline' <-> 'script', 'image' <-> 'image'). +Fields marked (*) in a branch below are MANDATORY for that branch -- the UI (and MCP validation) reject creation with any '*' field empty. Unmarked fields are OPTIONAL. +Fixed vs runtime input: fields noted "(fixed|<+input>)" below accept EITHER a concrete value NOW, OR the literal string '<+input>' meaning "prompt for this value at run time". Pass the placeholder verbatim -- MCP forwards it as-is into the tunables and into the base64 YAML manifest. +Shared field taxonomy -- each branch below references these by letter instead of restating them: +${descLoadtestFieldTaxonomy} + +Step 6.LocustScript -- Locust + Upload Python script (mode='script'): + + Ask, in order: (*)(a) Script file, (b) Host URL, (*)(c) Users, (*)(d) Duration, (*)(e) Ramp Up, (h) Worker Count [Kubernetes only]. + YAML populated after this step (delta only): + spec: + toolConfig: + locust: + mode: script + script: + content: | # plain text in YAML view; base64 on wire + + tunables: # omit keys the user left blank + targetUrl: http://www.example.com + targetUsers: 100 + durationSeconds: 600 + rampUpTimeSec: 120 + workerCount: 1 # Kubernetes only + +Step 6.LocustImage -- Locust + Using Custom Image (mode='image'): + Kubernetes only (script_source=image, or inferred when script_image is set). VM is always inline. + Ask, in order: + (b) Host URL -> target_url (optional; Locust --host; leave blank if the locustfile sets host in code) + (a) Load Test Image* -> script_image (required; container image, e.g. my-registry/my-load-test:latest) + (a) Entrypoint -> script_entrypoint (optional; path inside the image, e.g. /scripts/locustfile.py; passed to locust -f; backend falls back to a default when omitted) + (a) Load args -> load_args (optional; semicolon-separated k=v pairs, e.g. "tags=smoke,fast;headless=true" -- NOT bare CLI flags) + (a) Image Registry Type -> Public: omit image_pull_secret + Private: image_pull_secret (name of the Kubernetes image-pull secret) + (c) Users* -> users + (d) Duration* -> duration_sec + (e) Ramp Up* -> ramp_up_sec + (h) Worker Count -> worker_count (K8s; 0/omit = standalone) + YAML populated after this step (delta only): + spec: + toolConfig: + locust: + mode: image + script: + image: my-registry/my-load-test:latest + entrypoint: /scripts/locustfile.py + loadArgs: tags=smoke # omit when blank + imagePullSecret: my-pull-secret # omit when Public + tunables: # omit keys the user left blank + targetUrl: https://www.google.com + targetUsers: 100 + durationSeconds: 600 + rampUpTimeSec: 120 + workerCount: 1 + +Step 6.K6Script -- K6 + Upload K6 script (mode='script'): + Kubernetes-only: MCP rejects K6 with target_type='machine-chaos-linux' at create time. + Ask, in order: (*)(a) Script file [MANDATORY: script MUST contain 'export default function ...' -- reject before calling MCP if missing, matching Harness UI validateScriptContent], (b) Host URL, (c) Users, (d) Duration, (f) Iterations, (g) RPS Limit, (i) Environment Variables, (h) Replicas. + Load-profile semantics reminder: (c)/(d)/(f) are ignored by k6 once the script declares its own scenarios/stages -- leave them blank for scripted scenarios. (g) RPS Limit always applies. + YAML populated after this step (delta only): + spec: + toolConfig: + k6: + mode: script + script: + content: | # plain text in YAML view; base64 on wire + + tunables: # omit keys the user left blank + targetUrl: https://www.examplek6.com + targetUsers: <+input> + durationSeconds: <+input> + iterations: <+input> + workerCount: 1 + rpsLimit: 100 + envVars: # omit entirely when no env vars are set + - { key: env_var_1, value: some_val_1, secret: false } + - { key: env_var_2, value: <+input>, secret: false } + - { key: somekey, value: secrets.getValue("account.gcp-ca-cert"), secret: true } # scope prefix per env_vars docs: account./org./none for project + +Step 6.K6Image -- K6 + Using Custom Image (mode='image'): + Kubernetes only (script_source=image, or inferred when script_image is set). + Ask, in order: + (b) Host URL -> target_url (optional; MCP derives toolConfig.k6.tunables.hostUrl from its origin) + (a) Load Test Image* -> script_image (required; container image, e.g. my-registry/my-load-test:latest) + (a) Entrypoint -> script_entrypoint (optional; path inside the image, e.g. /script.js; backend falls back to a default when omitted) + (a) Load args -> load_args (optional; semicolon-separated k=v pairs, e.g. "tags=smoke,fast;headless=true" -- NOT bare CLI flags) + (a) Image Registry Type -> Public: omit image_pull_secret + Private: image_pull_secret (name of the Kubernetes image-pull secret) + (h) Worker Count -> worker_count (K8s; 0/omit = standalone) + (g) RPS Limit -> rps_limit (optional; toolConfig.k6.tunables.rpsLimit when > 0) + (i) Environment Variables -> env_vars (see Step 4c) + Note: Users/Duration/Iterations tunables are meaningless for image mode -- the bundled script controls its own scenarios/stages. Do not ask for them here. + YAML populated after this step (delta only): + spec: + toolConfig: + k6: + mode: image + script: + image: my-registry/my-load-test:latest # or <+input> + entrypoint: /script.js # or <+input> + loadArgs: tags=smoke # omit when blank + imagePullSecret: my-pull-secret # omit when Public + tunables: + workerCount: 1 + rpsLimit: 100 # omit when rps_limit is unset + envVars: # omit entirely when no env vars are set + - { key: someKey, value: someValue, secret: false } + +Step 6.JMeterScript -- JMeter + Upload JMX / XML / ZIP (mode='script'): + Kubernetes-only: MCP rejects JMeter with target_type='machine-chaos-linux' at create time. + Ask, in order: (*)(a) Test Plan file [.jmx/.xml -- raw plan text, verbatim; do NOT parse, modify, or pre-encode. A .zip bundle (plan + CSV data/dependencies) is also accepted but must be supplied pre-base64'd via tool_config.jmeter.script.content instead of the scalar 'script' field], (j) Property Overrides, (i) Environment Variables, (k) Thresholds, (h) Workers. + Notes: (b)/(c)/(d)/(e)/(f)/(g) do not apply to JMeter -- thread count, ramp-up, and duration live inside the uploaded plan's ThreadGroup elements, not as separate MCP inputs. If the user wants to change load shape, tell them to edit the .jmx and re-upload (or use tool_config.jmeter.tunables.{targetUsers,durationSeconds,rampUpTimeSec} as an advanced override -- backend-accepted but not exposed as a scalar here since the Harness UI does not expose it either). + YAML populated after this step (delta only): + spec: + toolConfig: + jmeter: + mode: script + script: + content: | # plain text in YAML view; base64 on wire + + tunables: + workerCount: 1 # Kubernetes only + properties: # omit entirely when no property overrides are set + - { key: someProperty, value: someValue, sendToEngines: true } + envVars: # omit entirely when no env vars are set + - { key: secretKeyAtProject, value: secrets.getValue("datat-api"), secret: true } + - { key: fixedKey, value: fixedValue } + - { key: RuntimeKey, value: <+input> } + thresholds: # omit entirely when no thresholds are set + - { metric: response_time_ms, stat: p95, operator: <, value: 5000, abortOnFail: false } + +Step 6.JMeterImage -- JMeter + Using Custom Image (mode='image'): + Kubernetes-only (script_source=image, or inferred when script_image is set). Custom Image does NOT slice load -- each worker runs the full command independently, so total load ~= plan threads * injectors. Users/Duration/Ramp-up are baked into the image's plan and are NOT asked here (server defaults 100/600/30 when omitted; do not surface them). + Ask, in order: + (a) Load Test Image* -> script_image (required; cluster-reachable container image, e.g. my-registry/jmeter:5.6; may be '<+input>') + (a) Entrypoint -> script_entrypoint (optional; path to the .jmx/.xml test plan inside the image, e.g. /test/plan.jmx; may be '<+input>'; backend falls back to a default plan-file name when omitted) + (a) Load args -> load_args (optional; JMeter GNU-style long options as semicolon-separated key=value pairs OR bare flags, e.g. "proxyHost=10.0.0.1;forceDeleteResultFile". Each becomes --key=value. Do NOT put JMeter properties (jmeter.save.saveservice.* or \${__P(...)} vars) here -- those go in Property Overrides / -J / -G. Keys must be non-empty, contain no whitespace, and must NOT start with '-') + (a) Image Registry Type -> Public: omit image_pull_secret + Private: image_pull_secret (name of the Kubernetes image-pull secret) + (j) Property Overrides -> properties (optional; array of {key, value, send_to_engines?}. send_to_engines=true adds -G so the property reaches remote workers in Distributed Execution) + (i) Environment Variables -> env_vars (optional; fixed / '<+input>' runtime / secret via {secret_id, secret_scope?}. See descLoadtestEnvVars for the full reserved-name list and secret discovery flow) + (k) Thresholds -> thresholds (optional; array of {metric, stat?, operator, value, abort_on_fail?}. metric one of response_time_ms|error_rate_pct|throughput_rps|latency_ms; operator one of < <= > >= == !=; stat REQUIRED for response_time_ms / latency_ms. value is a number -- runtime '<+input>' is NOT supported for thresholds) + (h) Workers -> worker_count (optional; number of load injectors. Each injector runs the FULL plan unchanged -- workers MULTIPLY load. 0/omit = single standalone injector) + YAML populated after this step (delta only): + spec: + toolConfig: + jmeter: + mode: image + script: + image: my-registry/jmeter:5.6 # or <+input> + entrypoint: /test/plan.jmx # or <+input> + loadArgs: proxyHost=10.0.0.1 # omit when blank + imagePullSecret: my-pull-secret # omit when Public + tunables: + workerCount: 1 # omit when 0/unset + properties: # omit entirely when no property overrides are set + - { key: threads1, value: "100", sendToEngines: true } + - { key: someProperty, value: "200", sendToEngines: false } + envVars: # omit entirely when no env vars are set + - { key: somekey, value: secrets.getValue("datat-api"), secret: true } + - { key: runtime, value: <+input> } + - { key: fixed, value: somefixed } + thresholds: # omit entirely when no thresholds are set + - { metric: response_time_ms, stat: p95, operator: <, value: 5000, abortOnFail: false } + - { metric: error_rate_pct, stat: avg, operator: <=, value: 50, abortOnFail: true } + - { metric: throughput_rps, stat: p99, operator: '>', value: 5000, abortOnFail: false } + +Step 7 -- Advanced Options (all tool types; identical for Locust/K6/JMeter): + +Step 7a -- Cleanup Policy: + Ask whether to keep the default -> cleanup_policy='delete' (removes run pods/configmaps/secrets after each execution), or retain them for debugging -> cleanup_policy='retain'. Maps to spec.cleanupPolicy. Mirrors the UI's Advanced Options "Clean-up Load Resources" toggle (ON = delete, OFF = retain). + +Step 7b -- Resource Requirements (Kubernetes only; OFF by default -- skip unless the user opts in): + If the user wants to set CPU/memory limits and requests, ask for any of: limits.cpu, limits.memory, requests.cpu, requests.memory (all optional individually). Pass resources={ limits?: {cpu?, memory?}, requests?: {cpu?, memory?} }. cpu is cores/millicores (e.g. "100m", "0.5", "2"); memory is a whole-byte quantity (e.g. "128Mi", "1Gi", "134217728"). Per resource, requests must not exceed limits. Maps to spec.resources. If the user declines, omit 'resources' entirely. + YAML populated after Step 7 (delta only): + spec: + cleanupPolicy: retain # 'delete' when omitted/unset + resources: # omitted entirely when the user declines Step 7b + limits: + cpu: "0.5" + memory: 1Gi + requests: + cpu: 100m + memory: 128Mi +Only after the user confirms all of the above (across Steps 1-7) should you call harness_create resource_type=chaos_loadtest. The full input-field reference lives on each field's own description (bodySchema.fields) -- do NOT restate it here. +Returns the created load test, including the full toolConfig echo (script/tunables/options/envVars as stored), serviceReferences, cleanupPolicy, resources, the base64 canonical yaml, createdAt/updatedAt, and an openInHarness deep link.`; +export const descDeleteLoadtest = `Delete a load test instance`; -Step 1 — Select the execution environment target type: - Ask whether the load test runs on "Linux VM" (target_type=machine-chaos-linux) or "Kubernetes" (target_type=kubernetes). Wait for the user to choose. - -Step 1b — Select the tool type: - Linux VM only supports Locust. Kubernetes supports Locust or K6 — ask which. Pass tool_type='Locust' (default) or 'K6'. - K6 is JavaScript; Locust is Python. K6 currently supports script-mode only (Custom Image and UI are deferred). - -Step 2 — Select a load-runner infrastructure: - Linux VM: call harness_list resource_type=chaos_infrastructure, show the list, wait for the user to pick one. Only infras with loadEnabled=true and status ACTIVE can run load tests. - Kubernetes: call harness_list resource_type=chaos_enabled_infrastructure, show the list, wait for the user to pick one (results are always ACTIVE and chaos-enabled). - From the chosen row take environmentID -> environment_id and infraID -> infra_id. Do NOT choose an infrastructure yourself. - -Step 3 — Collect the target (host) URL: - Ask for the base URL of the application under test (e.g. https://www.google.com) -> target_url. Do NOT assume it. - -Step 4 — Test configuration (branch on tool_type from Step 1b): - Locust (Linux VM or Kubernetes): - (a) Upload Python script (script_source=inline): ask for the raw Python locust script -> script (verbatim; do NOT generate or pre-encode). MCP base64-encodes it into top-level scriptContent. - (b) Custom Image (script_source=image, Kubernetes only): ask for script_image (+ optional script_entrypoint, load_args). - K6 (Kubernetes only): - (a) Upload K6 script (script_source=inline): ask the user to paste the raw K6 JavaScript source. Pass it verbatim in 'script'. MANDATORY rule: the script MUST contain 'export default function ...'. If it does not, REJECT before calling MCP — MCP will throw and no load test will be created. MCP base64-encodes the script into toolConfig.scriptContent; do NOT pre-encode. - (b) Custom Image (script_source=image): ask for the container image -> script_image (e.g. my-registry/my-load-test:latest); optionally an entrypoint inside the image -> script_entrypoint (e.g. /script.js); optional container args -> load_args. MCP routes script_image and script_entrypoint into BOTH toolConfig.customImage (image, entrypoint) AND inputs[] (scriptImage required:true, scriptEntrypoint required:true); load_args rides ONLY in inputs[name=loadArgs]. No script is needed. - (c) Define test via UI: NOT YET SUPPORTED via MCP. Inform the user it is deferred and stop. - Optional K6-only knobs to ask the user about (skip if user says no): - - host_url: origin of the target system (protocol+host, no path). Defaults to the origin of target_url. - - rps_limit: requests-per-second cap (toolConfig.options.rpsLimit). Optional. - - iterations: total iteration cap (toolConfig.iterations). Optional. - - env_vars: see Step 4c. - -Step 4c — Optional K6 environment variables (env_vars): - The env_vars param is a structured array. Each entry sets EITHER a literal value OR a secret reference: - - Literal: {key: "FOO", value: "bar"} - - Secret reference: {key: "TOKEN", secret_id: "", secret_scope: "account"|"org"|"project"} - To discover available secrets, call harness_list resource_type=secret type=SecretText. Each list item has - a nested secret.{identifier, orgIdentifier?, projectIdentifier?}. Derive secret_scope from the response: - - no orgIdentifier AND no projectIdentifier → secret_scope: "account" - - orgIdentifier only → secret_scope: "org" - - both orgIdentifier AND projectIdentifier → secret_scope: "project" - MCP builds the wire string 'secrets.getValue("")' (account. / org. / ) and sets - secret: true automatically — do NOT construct the secrets.getValue(...) string yourself. - Disallowed keys (reserved, case-insensitive): RUN_ID, LOAD_TEST_ID, TARGET_USERS, SPAWN_RATE, - SCRIPT_CONTENT_BASE64, TARGET_URL, ACCOUNT_ID, ORG_ID, PROJECT_ID, ENV_ID, DURATION_SECONDS, - CONTROL_PLANE_URL, CONTROL_PLANE_TOKEN, HARNESS_CUSTOM_VAR_NAMES, METRICS_PUSH_INTERVAL, INFRA_ID, - ACCESS_KEY, TENANT_ID, PYTHONPATH, PATH, HOME, USER, SHELL, LANG, TERM, HOSTNAME, PWD, - LD_LIBRARY_PATH, LD_PRELOAD, TMPDIR, TMP, TEMP. - Key pattern: /^[A-Za-z_][A-Za-z0-9_]*$/. - -Step 5 — Load configuration: - Ask users, duration_sec, ramp_up_sec (defaults 100 / 600 / 120 only if user accepts). - K8s: also ask worker_count — do NOT skip (default 0). VM: skip worker_count. - -Step 6 — Collect name and optional metadata: - Ask for a name (any non-empty string). identity is auto-derived from name by stripping non-alphanumerics unless the user provides one (must be letters/numbers/underscores). description and tags are optional — ask, but do not require them. - -Only after the user confirms all of the above should you call harness_create resource_type=chaos_loadtest. - -HOW THE BODY IS BUILT (you do NOT construct any of this — MCP handles it): -- Your scalars (users / duration_sec / ramp_up_sec / worker_count / target_url / script_image / script_entrypoint / load_args) - are translated into a canonical inputs[] array with backend-wire names - (targetUsers / durationSeconds / rampUpTimeSec / workerCount / targetUrl / scriptImage / scriptEntrypoint / loadArgs). -- Inline mode (Locust): your raw 'script' is base64-encoded into top-level scriptContent. -- Image mode (Locust): scriptContent is omitted; script_image / script_entrypoint / load_args ride in inputs[]. -- K6 script mode: top-level scriptContent is OMITTED. The base64 script lives in toolConfig.scriptContent only. - toolConfig also carries hostUrl, optional options.rpsLimit, optional iterations, and optional envVars. -- env_vars (K6): structured only. {key, value} for literals; {key, secret_id, secret_scope?} for secrets — - MCP builds the wire 'secrets.getValue("")' string and sets secret: true automatically. -- A canonical LoadTest YAML manifest is synthesized and base64-encoded into the 'yaml' request field. -- description defaults to "", tags defaults to []. tool_type is fixed to "Locust" server-side (K6/JMeter coming). +export const descUpdateLoadtest = `Update fields on an existing load test (PUT -- partial by field, full-replace within a field). Omit a field entirely to leave it unchanged. -INPUT FIELDS: - name (string, required): Display name for the load test. Any non-empty string (e.g. "My Load Test", "locust-1"). - environment_id (string, required): environmentID of the chosen infrastructure (Step 2). - infra_id (string, required): infraID of the chosen infrastructure (Step 2). - target_url (string, required): Base URL of the application under test (Step 3). Goes into inputs[name=targetUrl]. - script (string): Raw Python locust script. VM always requires this; K8s when inline. MCP base64-encodes it into the top-level scriptContent field — do NOT pre-encode. - script_source (string): "inline" (default) or "image". "image" is K8s-only; VM is always "inline". Inferred as "image" when script_image is set. - script_image (string): K8s + image mode only — prebuilt container image. Required when script_source=image. Goes into inputs[name=scriptImage, required:true]. - script_entrypoint (string): Custom Image mode — entrypoint file inside the image. Goes into inputs[name=scriptEntrypoint, required:true] when provided. - load_args (string): Custom Image mode — container args as "k=v,v2;k2=v" pairs. Goes into inputs[name=loadArgs] when provided. - target_type (string): machine-chaos-linux (Linux VM, default), linux-chaos, or kubernetes. - users (number, default 100): Number of simulated users. Goes into inputs[name=targetUsers, required:true]. - duration_sec (number, default 600): Total test duration in seconds. Goes into inputs[name=durationSeconds]. - ramp_up_sec (number, default 120): Ramp-up duration in seconds. Goes into inputs[name=rampUpTimeSec]. - worker_count (number, default 0): K8s — agent must ask; default 0. VM: N/A. Goes into inputs[name=workerCount]. - tool_type (string, default "Locust"): "Locust" or "K6". K6 requires target_type=kubernetes. - host_url (string, K6 only): K6 host origin (protocol://host, no path). Defaults to origin of target_url. Goes into toolConfig.hostUrl. - rps_limit (number, K6 only): requests-per-second cap. Goes into toolConfig.options.rpsLimit when > 0. - iterations (number, K6 only): total iteration cap. Goes into toolConfig.iterations when > 0. - env_vars (array, K6 only): environment variables. Array of {key, value} (literal) or {key, secret_id, secret_scope?} (secret reference). See Step 4c. - identity (string): Stable slug-safe identifier (letters / numbers / underscores). Auto-derived from name by stripping non-alphanumerics when omitted. - description (string): Optional human-readable description. Defaults to "". - tags (string[]): Optional tags. Defaults to []. - -Returns the created load test (identity, name, environment/infra, targetType, toolType, scriptSource, inputs[], plus derived target_url/users/duration_sec/ramp_up_sec/worker_count convenience scalars) and an openInHarness deep link.`; -export const descDeleteLoadtest = `Delete a load test instance`; +- name / description / tags / environment_id / infra_id / target_type / max_duration_sec: simple scalar replace when supplied. +- service_references / variables: replace the ENTIRE list when non-null (a non-null service_references must still be non-empty); there is no per-item merge. +- cleanup_policy / resources: identical top-level spec fields to create -- see their own field descriptions (descLoadtestCleanupPolicy / descLoadtestResources). Not repeated here. +- Editing the script, image, tunables, properties, thresholds, or env vars (toolConfig.) requires resupplying the FULL desired sub-object -- the backend overwrites toolConfig. wholesale on any non-null write (no per-field merge server-side, except variables and JMeter's masked-.zip workspace preservation). You have two ways to do this: + 1. Scalar fields (recommended, mirrors create): supply 'tool_type' (Locust/K6/JMeter -- required, NOT itself sent to the API since tool_type is immutable after creation; used only so MCP picks the right builder) plus any of the same scalar fields create accepts (target_url, script_source, script, script_image, script_entrypoint, load_args, image_pull_secret, users, spawn_rate, duration_sec, ramp_up_sec, worker_count, host_url, rps_limit, iterations, env_vars, properties, thresholds). MCP rebuilds the complete toolConfig. object from these exactly as it does on create -- you must resupply every scalar that should remain set, not just the ones changing. Field-to-path mapping: + +${descLoadtestFieldTaxonomy} + 2. 'tool_config' escape hatch: hand-construct the object yourself (e.g. to preserve a JMeter .zip workspace or other advanced shape not covered by scalars). Pass 'tool_type' alongside it so MCP wraps/unwraps consistently with create; omitting 'tool_type' falls back to legacy behavior where 'tool_config' is sent to the API completely as-is (must already be the full \`{ : {...} }\` wire shape). +- No 'yaml' manifest is sent on update (unlike create) -- the typed JSON fields above (cleanupPolicy, resources, toolConfig) are authoritative since the backend falls back to them whenever no yaml body is supplied. + +Returns the updated load test (same shape as harness_get resource_type=chaos_loadtest).`; export const descListK8sInfra = `List Kubernetes chaos infrastructures available for running experiments. Use chaos_environment list first to get an environmentId, then pass it here to filter infrastructures for that environment. @@ -889,15 +1192,18 @@ Requires the template identity, hub identity, and two revision numbers.`; export const descBodyExperimentRun = `Optional runtime inputs for the chaos experiment. Use chaos_experiment_variable list to discover required variables first.`; export const descBodyNoBody = `No body required. Resource identified by path parameter.`; export const descBodyCreateFromTemplate = `Chaos experiment from template`; -export const descBodyLoadtestDefinition = `Locust load test definition. Required: name, environment_id, infra_id, target_url. Mode: either 'script' (inline Python, script_source=inline, default) or 'script_image' (Custom Image, script_source=image). -MCP translates your scalars into the wire shape automatically: - - users / duration_sec / ramp_up_sec → inputs[targetUsers / durationSeconds / rampUpTimeSec] (Integer) - - target_url → inputs[targetUrl] (String) - - worker_count → inputs[workerCount] (Integer; Kubernetes only) - - script_image / script_entrypoint / load_args → inputs[scriptImage / scriptEntrypoint / loadArgs] (String; image mode) - - script (raw Python) → top-level scriptContent (base64) +export const descBodyLoadtestDefinition = `Load test definition (Locust / K6 / JMeter). Required: name, environment_id, infra_id, target_url. Mode: 'inline' (upload raw script, default) or 'image' (Custom Image via script_image; Kubernetes only). +MCP translates your scalars into the canonical toolConfig. wire shape automatically: + - users / duration_sec / ramp_up_sec / worker_count / target_url + → toolConfig..tunables.{targetUsers, durationSeconds, rampUpTimeSec, workerCount, targetUrl} + - script (raw Python/JS/JMX) → toolConfig..script.content (base64) + - script_image / script_entrypoint / load_args / image_pull_secret (image mode) + → toolConfig..script.{image, entrypoint, loadArgs, imagePullSecret} + - K6 host_url / iterations → toolConfig.k6.tunables.{hostUrl, iterations} + - K6 rps_limit → toolConfig.k6.tunables.rpsLimit + - env_vars (K6/JMeter) → toolConfig..envVars - A canonical LoadTest YAML manifest is built and base64-encoded into a 'yaml' field. -You never construct inputs[], scriptContent base64, or the yaml field — pass scalars only.`; +You never construct toolConfig, script.content base64, or the yaml field — pass scalars only.`; // ── Field Descriptions ─────────────────────────────────────────────── @@ -910,27 +1216,34 @@ export const descInfraRef = `Infrastructure reference in format: environmentId/i export const descExperimentId = `Chaos experiment identifier. Accepts either the internal UUID (default, with is_identity=false) or the human-readable identity slug (set is_identity=true). Use harness_list with resource_type=chaos_experiment to find experiment IDs.`; export const descInfraStatus = `Filter by infra status: Active (default) or All`; export const descLoadtestName = `Display name for the load test. Any non-empty string (e.g. "My Load Test", "locust-1"). The slug-safe identifier rule applies to 'identity' (auto-derived from name) — not to name.`; -export const descLoadtestType = `Load test tool type: "Locust" (default; Python on Linux VM or Kubernetes) or "K6" (JavaScript on Kubernetes only). JMeter is coming soon. K6 supports script mode and Custom Image mode; "Define test via UI" is deferred.`; +export const descLoadtestType = `Load test tool type: "Locust" (default; Python on Linux VM or Kubernetes), "K6" (JavaScript, Kubernetes only), or "JMeter" (Java, Kubernetes only in MCP/UI; not a backend restriction). All three support "Upload script" (script mode) and "Using Custom Image" (image mode) as first-class scalar flows; "Define test via UI" is deferred for K6/Locust and does not apply to JMeter.`; export const descLoadtestIdentity = `Stable identifier (letters, numbers and underscores). Auto-derived from name by stripping non-alphanumerics when omitted.`; export const descLoadtestDescription = `Optional human-readable description. Defaults to "".`; export const descLoadtestTags = `Optional tags (array of strings, or comma-separated string). Defaults to [].`; export const descLoadtestEnvId = `Environment identifier of the load-runner infrastructure. Use the 'environmentID' from chaos_infrastructure (Linux VM) or chaos_enabled_infrastructure (Kubernetes).`; export const descLoadtestInfraId = `Load-runner infrastructure identifier. Use the 'infraID' from chaos_infrastructure (Linux VM) or chaos_enabled_infrastructure (Kubernetes).`; -export const descLoadtestTargetType = `Execution environment target type: "machine-chaos-linux" (Linux VM, default), "linux-chaos", or "kubernetes". "workerCount" is only emitted into inputs[] for "kubernetes".`; -export const descLoadtestTargetUrl = `Base URL of the application under test (e.g. https://www.google.com). Sent as an entry in inputs[] with name="targetUrl", type="String".`; -export const descLoadtestScript = `Raw script contents for inline mode (script_source=inline). Locust expects Python (locustfile.py); K6 expects JavaScript and MUST contain 'export default function ...' (mandatory — MCP rejects scripts without it). MCP base64-encodes onto the wire — do NOT pre-encode. For Locust this lands at top-level scriptContent; for K6 it lands at toolConfig.scriptContent (and top-level scriptContent is omitted). Required for inline mode; omit for Custom Image mode.`; -export const descLoadtestScriptSource = `Test definition source: "inline" (default — upload a raw Python locust script via 'script') or "image" (use a prebuilt container image via 'script_image'). "image" is K8s-only; VM is always "inline". Inferred as "image" when script_image is set, otherwise "inline".`; -export const descLoadtestScriptImage = `Custom Image mode (both Locust and K6, Kubernetes only): prebuilt container image used as the load test source, e.g. "my-registry/my-load-test:latest". Required when script_source=image. Sent as inputs[name=scriptImage, type=String, required:true]. For K6 also wired into toolConfig.customImage.image.`; -export const descLoadtestScriptEntrypoint = `Custom Image mode (optional, both Locust and K6): entrypoint file inside the image, e.g. "locustfile.py" (Locust) or "/script.js" (K6). Sent as inputs[name=scriptEntrypoint, type=String, required:true] when provided. For K6 also wired into toolConfig.customImage.entrypoint.`; -export const descLoadtestLoadArgs = `Custom Image mode (optional, both Locust and K6): arguments passed to the container as key=value pairs — comma-separated for multiple values per key, separated by ';'. Example: "tags=smoke,random;headless=true". Sent as inputs[name=loadArgs, type=String] only — for K6 this does NOT go into toolConfig.customImage.`; -export const descLoadtestUsers = `Number of simulated users. Default: 100. Sent as inputs[name=targetUsers, type=Integer, required:true].`; -export const descLoadtestDurationSec = `Total test duration in seconds. Default: 600. Sent as inputs[name=durationSeconds, type=Integer].`; -export const descLoadtestRampUpSec = `Ramp-up duration in seconds. Default: 120. Sent as inputs[name=rampUpTimeSec, type=Integer].`; -export const descLoadtestWorkerCount = `K8s: agent must ask; default 0 (0 = standalone). VM: N/A. Sent as inputs[name=workerCount, type=Integer]. Ignored for Linux target types.`; +export const descLoadtestTargetType = `Execution environment target type: "machine-chaos-linux" (Linux VM, default), "linux-chaos", or "kubernetes". "workerCount" is only honoured for "kubernetes".`; +export const descLoadtestTargetUrl = `Base URL of the application under test (e.g. https://www.google.com). Maps to toolConfig..tunables.targetUrl. For Locust it is passed as --host; leave blank if the locustfile sets host in code.`; +export const descLoadtestScript = `Raw script contents for inline mode (script_source=inline). Locust expects Python (locustfile.py); K6 expects JavaScript and MUST contain 'export default function ...' (mandatory — MCP rejects scripts without it); JMeter expects the raw .jmx/.xml test plan text (verbatim; for a .zip bundle use tool_config.jmeter.script.content with a pre-computed base64 string instead). MCP base64-encodes onto the wire — do NOT pre-encode. Lands at toolConfig..script.content for all three tools. Required for inline mode; omit for Custom Image mode.`; +export const descLoadtestScriptSource = `Test definition source: "inline" (default — upload a raw script via 'script') or "image" (use a prebuilt container image via 'script_image'). "image" is K8s-only; VM is always "inline". Inferred as "image" when script_image is set, otherwise "inline".`; +export const descLoadtestScriptImage = `Custom Image mode (Locust/K6/JMeter, Kubernetes only): prebuilt container image used as the load test source, e.g. "my-registry/my-load-test:latest". Required when script_source=image. Maps to toolConfig..script.image.`; +export const descLoadtestScriptEntrypoint = `Custom Image mode: entrypoint file inside the image. Locust: locustfile path (optional; passed to locust -f), e.g. "locustfile.py". K6: script path (optional; passed to k6 run), e.g. "/script.js". JMeter: path to the .jmx/.xml test plan (optional; backend uses a default plan-file name when omitted), e.g. "/test/plan.jmx". Runtime placeholder '<+input>' is accepted verbatim. Maps to toolConfig..script.entrypoint.`; +export const descLoadtestLoadArgs = `Custom Image mode (optional): container args as semicolon-separated key=value pairs (commas allowed inside a value). Keys must be non-empty, contain no whitespace, and must NOT start with '-' (bare CLI flags like "--headless" are rejected -- mirrors loadTestManager ValidateLoadArgs). Locust/K6: passed to the tool as CLI args (e.g. "tags=smoke,fast;headless=true"). JMeter: GNU-style long options -- each pair becomes --key=value (e.g. "proxyHost=10.0.0.1;forceDeleteResultFile"). Do NOT put JMeter properties (jmeter.save.saveservice.* or \${__P(...)} plan variables) here -- use 'properties' (which become -J/-G). Maps to toolConfig..script.loadArgs.`; +export const descLoadtestImagePullSecret = `Custom Image mode (Kubernetes, private registry only): name of the Kubernetes image-pull secret used to pull script_image. Maps to toolConfig..script.imagePullSecret. Omit for public images (UI "Image Registry Type = Public"); there is no separate Public/Private field on the wire — presence of this scalar is the private signal.`; +export const descLoadtestUsers = `Number of simulated users. Default: 100. Maps to toolConfig..tunables.targetUsers.`; +export const descLoadtestDurationSec = `Total test duration in seconds. Default: 600. Maps to toolConfig..tunables.durationSeconds.`; +export const descLoadtestRampUpSec = `Ramp-up duration in seconds. Default: 120. Maps to toolConfig..tunables.rampUpTimeSec.`; +export const descLoadtestWorkerCount = `K8s: agent must ask; default 0 (0 = standalone). VM: N/A (ignored for Linux target types). Goes to toolConfig..tunables.workerCount. Semantics differ per tool: Locust DISTRIBUTES users across workers (load not multiplied); K6 <=1 is a single pod and higher values fan out via --execution-segment; JMeter MULTIPLIES load -- each worker runs the full uploaded plan unchanged.`; export const descLoadtestHostUrl = `K6 only: host origin (protocol+host with no path, e.g. https://api.example.com). Defaults to the origin parsed from target_url when omitted. Goes into toolConfig.hostUrl.`; -export const descLoadtestRpsLimit = `K6 only: requests-per-second cap. Goes into toolConfig.options.rpsLimit when > 0. Omitted from the wire when unset or 0.`; +export const descLoadtestRpsLimit = `K6 only: requests-per-second cap. Goes into toolConfig.k6.tunables.rpsLimit when > 0. Omitted from the wire when unset or 0.`; export const descLoadtestIterations = `K6 only: total iteration cap. Goes into toolConfig.iterations when > 0. Omitted from the wire when unset or 0.`; -export const descLoadtestEnvVars = `K6 only: environment variables for the K6 runner. Array of entries — each sets EITHER {key, value} for a literal OR {key, secret_id, secret_scope?: "account"|"org"|"project"} for a Harness secret (default secret_scope = "project"). MCP builds the wire 'secrets.getValue("")' string and sets secret: true automatically — do NOT construct that string yourself. Discover secrets via harness_list resource_type=secret type=SecretText and read each item's secret.{identifier, orgIdentifier?, projectIdentifier?} to derive scope. Key pattern: /^[A-Za-z_][A-Za-z0-9_]*$/. Reserved names (case-insensitive) are rejected: RUN_ID, LOAD_TEST_ID, TARGET_USERS, SPAWN_RATE, SCRIPT_CONTENT_BASE64, TARGET_URL, ACCOUNT_ID, ORG_ID, PROJECT_ID, ENV_ID, DURATION_SECONDS, CONTROL_PLANE_URL, CONTROL_PLANE_TOKEN, HARNESS_CUSTOM_VAR_NAMES, METRICS_PUSH_INTERVAL, INFRA_ID, ACCESS_KEY, TENANT_ID, PYTHONPATH, PATH, HOME, USER, SHELL, LANG, TERM, HOSTNAME, PWD, LD_LIBRARY_PATH, LD_PRELOAD, TMPDIR, TMP, TEMP.`; +export const descLoadtestEnvVars = `K6 and JMeter only: environment variables for the runner. Array of entries — each sets EITHER {key, value} for a literal OR {key, secret_id, secret_scope?: "account"|"org"|"project"} for a Harness secret (default secret_scope = "project"). MCP builds the wire 'secrets.getValue("")' string and sets secret: true automatically — do NOT construct that string yourself. Discover secrets via harness_list resource_type=secret type=SecretText and read each item's secret.{identifier, orgIdentifier?, projectIdentifier?} to derive scope. Key pattern: /^[A-Za-z_][A-Za-z0-9_]*$/. Reserved names (case-insensitive), rejected for BOTH tools: RUN_ID, LOAD_TEST_ID, TARGET_USERS, SPAWN_RATE, SCRIPT_CONTENT_BASE64, TARGET_URL, ACCOUNT_ID, ORG_ID, PROJECT_ID, ENV_ID, DURATION_SECONDS, CONTROL_PLANE_URL, CONTROL_PLANE_TOKEN, HARNESS_CUSTOM_VAR_NAMES, METRICS_PUSH_INTERVAL, INFRA_ID, ACCESS_KEY, TENANT_ID, PYTHONPATH, PATH, HOME, USER, SHELL, LANG, TERM, HOSTNAME, PWD, LD_LIBRARY_PATH, LD_PRELOAD, TMPDIR, TMP, TEMP. K6 ALSO rejects: HOST_URL, K6_VUS, K6_DURATION, K6_ITERATIONS, K6_STAGES, K6_RPS.`; + +export const descLoadtestCleanupPolicy = `'delete' (default) or 'retain' -- controls whether run resources (pods, configmaps, secrets) are removed after each execution. Choose 'retain' to keep them around for debugging. Maps to spec.cleanupPolicy. Identical field for Locust/K6/JMeter. Mirrors the UI's Advanced Options "Clean-up Load Resources" toggle (ON = delete, OFF = retain).`; +export const descLoadtestResources = `Kubernetes-only per-pod resource limits and requests (UI: Advanced Options > Resource Requirements; OFF by default -- omit entirely unless the user opts in). Object shape: { limits?: { cpu?, memory? }, requests?: { cpu?, memory? } }. cpu is cores/millicores (e.g. "100m", "0.5", "2"); memory is a whole-byte quantity (e.g. "128Mi", "1Gi", "134217728"). Only 'cpu' and 'memory' keys are allowed in each section. Per resource, requests must not exceed limits. Maps to spec.resources. Identical field for Locust/K6/JMeter.`; + +export const descLoadtestProperties = "JMeter only: runtime property overrides injected into the plan without editing the uploaded file -- reference as ${__P(NAME)} in the .jmx. Array of {key, value, send_to_engines?}. send_to_engines (maps to sendToEngines / the JMeter -G flag) controls whether the property also reaches remote Distributed Execution workers; omit (false) for controller-only properties."; +export const descLoadtestThresholds = "JMeter only: pass/fail criteria evaluated against the run's .jtl results after the test completes (the uploaded plan's own Assertions still apply per-request during the run). Array of {metric, stat?, operator, value, abort_on_fail?}. metric: one of response_time_ms | error_rate_pct | throughput_rps | latency_ms. operator: one of < | <= | > | >= | == | !=. stat (e.g. p95, p99, avg, median, max) is REQUIRED when metric is response_time_ms or latency_ms. abort_on_fail (maps to abortOnFail) marks the threshold as run-aborting on failure."; export const descHubIdentityExact = `The unique identity of the ChaosHub. Use harness_list with resource_type=chaos_hub to find hub identities.`; export const descHubName = `Display name for the ChaosHub.`; @@ -1461,9 +1774,18 @@ export const descSDAgentIdentity = `Service Discovery agent identity — the pat export const descSDEnvironmentId = `Harness environment identifier the SD agent is bound to (e.g. 'dev'). Required by SD's AgentAccessCheck middleware to resolve the agent — the same agent identity may exist in multiple environments.`; +export const descSDAgentListEnvironmentId = `Optional narrowing filter — the LIST agents endpoint does not require it (unlike discovered_namespace/discovered_service/discovered_network_map, which use AgentAccessCheck and require both agent_identity + environment_id). Omit to list agents across every environment in scope.`; + export const descSDFetchAll = `When true, fetch every result and ignore page/limit (the API returns the full unpaginated list). Useful for small/medium clusters; avoid on very large clusters.`; -export const descSDAgentDiagnostic = `404 from SD endpoints almost always means agent_identity or environment_id is wrong — both are required for AgentAccessCheck to resolve the agent. Both can be confirmed from the SD UI URL or (when added) the discovered_agent list.`; +export const descSDAgentDiagnostic = `404 from SD endpoints almost always means agent_identity or environment_id is wrong — both are required for AgentAccessCheck to resolve the agent. Both can be confirmed from the SD UI URL or via discovered_agent list.`; + +// discovered_agent +export const descDiscoveredAgent = `Service Discovery agent — one SD deployment running inside a customer's Kubernetes cluster, bound to a single Harness environment. Its 'identity' field is the value other resources take as 'agent_identity' (discovered_namespace / discovered_service / discovered_network_map) and as 'agent_id' (chaos_service create/update). Soft-deleted agents are hidden from list results. The token field is never returned.`; + +export const descListDiscoveredAgents = `List Service Discovery agents in the given account/org/project scope. Use this to look up the 'identity' value required by other Service Discovery resources and by chaos_service create/update. Optional environment_id narrows to one environment; omit to list agents across all environments in scope. Optional search does a case-insensitive regex match on the agent name. Supports page/limit pagination or all=true to fetch everything (all=true also skips per-agent installation/service/network-map count enrichment, so responses are lighter).`; + +export const descDiscoveredAgentSearch = `Case-insensitive substring match against the SD agent name field.`; // discovered_namespace export const descDiscoveredNamespace = `Read-only snapshot of a Kubernetes Namespace recorded by a Service Discovery agent — includes the namespace name, UID, resource version, labels, annotations, owner references, and the full corev1.NamespaceSpec/Status from the cluster's last sync. Use namespaces as the scope boundary when listing discovered_service or future discovered_workload/discovered_connection resources for the same agent.`; diff --git a/src/registry/toolsets/chaos.ts b/src/registry/toolsets/chaos.ts index b04800bf5..252a39e05 100644 --- a/src/registry/toolsets/chaos.ts +++ b/src/registry/toolsets/chaos.ts @@ -15,6 +15,7 @@ import { chaosK8sInfraListExtract, chaosLoadTestListExtract, chaosLoadTestExtract, + chaosServiceListExtract, chaosHubListExtract, chaosDRTestListExtract, sdPageExtract, @@ -26,7 +27,7 @@ import { // Resource descriptions descChaosExperiment, descChaosExperimentRun, descChaosProbe, descChaosExperimentTemplate, descChaosExperimentVariable, - descChaosInfrastructure, descChaosLoadtest, descChaosK8sInfrastructure, descChaosEnabledInfrastructure, + descChaosInfrastructure, descChaosLoadtest, descChaosService, descChaosK8sInfrastructure, descChaosEnabledInfrastructure, descChaosHub, descChaosFault, descChaosFaultExperimentRun, descChaosFaultTemplate, descChaosProbeTemplate, descChaosActionTemplate, descChaosHubFault, descChaosEnvironment, @@ -43,8 +44,26 @@ import { descListExperimentTemplates, descGetExperimentTemplate, descDeleteExperimentTemplate, descListExperimentVariables, descGetComponentVariable, descCreateExperiment, descListLinuxInfra, - descListLoadtests, descGetLoadtest, descCreateLoadtest, descDeleteLoadtest, - descListK8sInfra, descGetK8sInfra, descListChaosEnabledInfra, + descListLoadtests, descGetLoadtest, descCreateLoadtest, descUpdateLoadtest, descDeleteLoadtest, + descListChaosServices, descGetChaosService, descDeleteChaosService, descCreateChaosService, descUpdateChaosService, + descChaosServiceEnvironmentIds, descChaosServiceInfrastructureIds, + descChaosServiceTags, descChaosServiceIncludeAllScope, + descChaosServiceProbeIds, descChaosServiceOnboardingIdFilter, + descListChaosServiceExperimentRuns, descListChaosServiceLoadTests, + descChaosServiceSearch, + descChaosServiceIdentity, + descBodyChaosServiceCreate, descBodyChaosServiceUpdate, + descChaosServiceName, descChaosServiceDescription, descChaosServiceTagsBody, + descChaosServiceExternalServiceId, descChaosServiceAgentId, + descChaosServiceEnvironmentId, descChaosServiceInfrastructureId, + descChaosServiceInfrastructureType, descChaosServiceOnboardingId, + descChaosServiceProbes, + descChaosServiceProbesUpdate, + descListK8sInfra, descGetK8sInfra, descCreateK8sInfra, descListChaosEnabledInfra, + descBodyK8sInfraCreate, descK8sInfraIdentityCreate, descK8sInfraNameCreate, + descK8sInfraEnvironmentIdCreate, descK8sInfraInfraIdCreate, descK8sInfraConnectorIdCreate, + descK8sInfraNamespaceCreate, descK8sInfraServiceAccountCreate, descK8sInfraScopeCreate, + descK8sInfraTypeCreate, descK8sInfraAiEnabledCreate, descListHubs, descGetHub, descCreateHub, descUpdateHub, descDeleteHub, descListFaults, descGetFault, descListFaultTemplates, descGetFaultTemplate, descDeleteFaultTemplate, @@ -92,8 +111,10 @@ import { descLoadtestTargetUrl, descLoadtestScript, descLoadtestUsers, descLoadtestDurationSec, descLoadtestRampUpSec, descLoadtestWorkerCount, descLoadtestScriptSource, descLoadtestScriptImage, - descLoadtestScriptEntrypoint, descLoadtestLoadArgs, + descLoadtestScriptEntrypoint, descLoadtestLoadArgs, descLoadtestImagePullSecret, descLoadtestHostUrl, descLoadtestRpsLimit, descLoadtestIterations, descLoadtestEnvVars, + descLoadtestProperties, descLoadtestThresholds, + descLoadtestCleanupPolicy, descLoadtestResources, descHubIdentityExact, descHubName, descHubNameUpdate, descHubDescription, descHubDescriptionUpdate, descHubTags, descHubTagsReplace, @@ -145,7 +166,8 @@ import { descExperimentManifest, descExperimentInfraType, descExperimentInfraIdCreate, descExperimentCronSyntax, descExperimentIdUUID, // Service Discovery - descSDAgentIdentity, descSDEnvironmentId, descSDFetchAll, descSDAgentDiagnostic, + descSDAgentIdentity, descSDEnvironmentId, descSDAgentListEnvironmentId, descSDFetchAll, descSDAgentDiagnostic, + descDiscoveredAgent, descListDiscoveredAgents, descDiscoveredAgentSearch, descDiscoveredNamespace, descListDiscoveredNamespaces, descSDNamespaceNameFilter, descDiscoveredService, descListDiscoveredServices, descSDNamespaceFilter, descSDSearchFilter, // Scanned Risks / Risk Rules / Risk Scans (chaos-manager v3) @@ -193,6 +215,39 @@ const chaosComponentVarExtract = (raw: unknown): unknown => { return r.items?.[0] ?? raw; }; +/** Compact projection for chaos_loadtest list items — keeps cheap discriminator + * scalars (toolType/targetType/scriptSource/infraType/cleanupPolicy) that the + * generic compactItems() whitelist in utils/compact.ts would otherwise drop + * (it only matches the literal key "type", not "toolType"/"targetType"), while + * still excluding heavy fields (toolConfig, yaml, variables, envVars, recentRuns). */ +function compactLoadTest(item: Record): Record { + const slim: Record = {}; + for (const key of [ + "loadtestId", "uniqueId", "identity", "name", "description", "tags", + "environmentIdentifier", "infraIdentifier", "infraType", "targetType", + "toolType", "scriptSource", "cleanupPolicy", "latestRevisionIdentifier", + "createdAt", "updatedAt", "openInHarness", + ]) { + if (item[key] !== undefined) slim[key] = item[key]; + } + return slim; +} + +/** Compact projection for discovered_agent list items — keeps serviceCount/ + * networkMapCount (STEP 1 of chaos_service create shows these) which the + * generic compactItems() whitelist in utils/compact.ts would otherwise drop. */ +function compactDiscoveredAgent(item: Record): Record { + const slim: Record = {}; + for (const key of [ + "identity", "name", "description", "tags", "environmentIdentifier", + "serviceCount", "networkMapCount", "installationType", + "createdAt", "updatedAt", "openInHarness", + ]) { + if (item[key] !== undefined) slim[key] = item[key]; + } + return slim; +} + /** * Parse input.body when LLMs double-serialize it as a JSON string instead of an object. * Fails loudly on malformed JSON so callers' defaults can never silently produce a @@ -214,79 +269,20 @@ function coerceBody(input: Record): Record { } // ── Load test helpers ──────────────────────────────────────────────── -// The load-test backend now carries every recognised tunable (run params, target -// URL, image fields, worker count) inside a flat `inputs: TemplateInput[]` array -// instead of the legacy top-level `defaultUsers/...` fields. The MCP keeps its -// LLM surface as ergonomic snake_case scalars and translates them into the wire -// shape here, so agents never construct the array, base64, or YAML themselves. - -type LoadtestInput = { - name: string; - value: number | string; - type: "Integer" | "String"; - required?: true; -}; +// Since the loadTestManager variables migration, all tunables and custom env +// vars live under toolConfig..tunables / toolConfig..variables. The +// MCP keeps its LLM surface as ergonomic snake_case scalars and translates +// them into the nested toolConfig shape here, so agents never construct the +// nested map, base64, or YAML themselves. -/** - * Map ergonomic snake_case scalars to the canonical TemplateInput[] array the - * load-test backend expects. Only emits entries for values the caller provided - * (plus the three always-present run params and the K8s-only workerCount). - */ -function buildLoadtestInputs( - b: Record, - opts: { targetType: string; scriptSource: string }, -): LoadtestInput[] { - const inputs: LoadtestInput[] = []; - - // Run params: always emitted (backend treats targetUsers as required). - const users = b.users != null ? (b.users as number) : 100; - const duration = b.duration_sec != null ? (b.duration_sec as number) : 600; - const rampUp = b.ramp_up_sec != null ? (b.ramp_up_sec as number) : 120; - inputs.push({ name: "targetUsers", value: users, type: "Integer", required: true }); - inputs.push({ name: "durationSeconds", value: duration, type: "Integer" }); - inputs.push({ name: "rampUpTimeSec", value: rampUp, type: "Integer" }); - - // workerCount is Kubernetes-only (0 = standalone, N > 0 = distributed). - if (opts.targetType === "kubernetes") { - const workerCount = b.worker_count != null ? (b.worker_count as number) : 0; - inputs.push({ name: "workerCount", value: workerCount, type: "Integer" }); - } +// ── Shared env-var helper (K6 + JMeter) ────────────────────────────── +// Env vars and secret references live in `toolConfig..envVars`. - // Target URL: present on every load test (backend treats it as the host under test). - const targetUrl = (b.target_url ?? b.targetUrl) as string | undefined; - if (targetUrl != null) { - inputs.push({ name: "targetUrl", value: targetUrl, type: "String" }); - } - - // Image-mode tunables (Custom Image): scriptImage/scriptEntrypoint are - // mandatory in image mode (marked required), loadArgs is optional. - if (opts.scriptSource === "image") { - const scriptImage = (b.script_image ?? b.scriptImage) as string | undefined; - if (scriptImage != null) { - inputs.push({ name: "scriptImage", value: scriptImage, type: "String", required: true }); - } - const entrypoint = (b.script_entrypoint ?? b.scriptEntrypoint) as string | undefined; - if (entrypoint != null) { - inputs.push({ name: "scriptEntrypoint", value: entrypoint, type: "String", required: true }); - } - const loadArgs = (b.load_args ?? b.loadArgs) as string | undefined; - if (loadArgs != null) { - inputs.push({ name: "loadArgs", value: loadArgs, type: "String" }); - } - } - - return inputs; -} - -// ── K6-specific helpers ────────────────────────────────────────────── -// K6 is a Kubernetes-only load test runner. Script mode wraps the K6 JS source -// in a `toolConfig` object (rather than top-level `scriptContent` like Locust). -// Env vars and secret references live exclusively in `toolConfig.envVars`. - -// Reserved K6 env-var names (case-insensitive). Mirrors -// loadTestManager/internal/domain/ReservedEnvVarNames; the frontend list is at +// Reserved env-var names (case-insensitive). Mirrors +// loadTestManager/internal/domain/ReservedEnvVarNames (shared base set, all +// tools) + K6ReservedEnvVarNames (K6-only extras). The frontend list is at // hce-saas/web/src/services/loadTest/loadTestVariables.ts:337-372. -const RESERVED_K6_ENV_VAR_NAMES = new Set([ +const BASE_RESERVED_ENV_VAR_NAMES = new Set([ "RUN_ID", "LOAD_TEST_ID", "TARGET_USERS", "SPAWN_RATE", "SCRIPT_CONTENT_BASE64", "TARGET_URL", "ACCOUNT_ID", "ORG_ID", "PROJECT_ID", "ENV_ID", "DURATION_SECONDS", "CONTROL_PLANE_URL", "CONTROL_PLANE_TOKEN", "HARNESS_CUSTOM_VAR_NAMES", @@ -294,9 +290,12 @@ const RESERVED_K6_ENV_VAR_NAMES = new Set([ "PYTHONPATH", "PATH", "HOME", "USER", "SHELL", "LANG", "TERM", "HOSTNAME", "PWD", "LD_LIBRARY_PATH", "LD_PRELOAD", "TMPDIR", "TMP", "TEMP", ]); -const K6_ENV_VAR_KEY_REGEX = /^[A-Za-z_][A-Za-z0-9_]*$/; +const K6_EXTRA_RESERVED_ENV_VAR_NAMES = new Set([ + "HOST_URL", "K6_VUS", "K6_DURATION", "K6_ITERATIONS", "K6_STAGES", "K6_RPS", +]); +const ENV_VAR_KEY_REGEX = /^[A-Za-z_][A-Za-z0-9_]*$/; -type K6EnvVarWire = { key: string; value: string; secret?: true }; +type EnvVarWire = { key: string; value: string; secret?: true }; // Build the HSM secret reference: account → "account.", org → "org.", project → "". // Matches the Harness UI convention (scope prefix encodes which secret manager the value lives in). @@ -305,11 +304,11 @@ function buildSecretReference(secretId: string, scope: "account" | "org" | "proj return `secrets.getValue("${prefix}${secretId}")`; } -// Validate + project the structured env_vars input into the wire shape K6 expects. +// Validate + project the structured env_vars input into the wire shape K6/JMeter expect. // Each entry sets exactly one of: // - { key, value } → literal env var // - { key, secret_id, secret_scope?: "..." } → MCP builds the secrets.getValue(...) string -function buildK6EnvVars(raw: unknown): K6EnvVarWire[] { +function buildEnvVars(raw: unknown, tool: "K6" | "JMeter"): EnvVarWire[] { if (raw == null) return []; if (!Array.isArray(raw)) { throw new Error("env_vars must be an array of {key, value | secret_id, secret_scope?} entries."); @@ -321,12 +320,13 @@ function buildK6EnvVars(raw: unknown): K6EnvVarWire[] { if (!key || typeof key !== "string") { throw new Error(`env_vars[${idx}].key is required.`); } - if (!K6_ENV_VAR_KEY_REGEX.test(key)) { + if (!ENV_VAR_KEY_REGEX.test(key)) { throw new Error( `env_vars[${idx}].key '${key}' is invalid: must match /^[A-Za-z_][A-Za-z0-9_]*$/.`, ); } - if (RESERVED_K6_ENV_VAR_NAMES.has(key.toUpperCase())) { + const upper = key.toUpperCase(); + if (BASE_RESERVED_ENV_VAR_NAMES.has(upper) || (tool === "K6" && K6_EXTRA_RESERVED_ENV_VAR_NAMES.has(upper))) { throw new Error( `env_vars[${idx}].key '${key}' is a reserved name and cannot be used as a custom env var.`, ); @@ -368,10 +368,11 @@ function parseHostOrigin(rawUrl: string | undefined): string | undefined { } } -// Build the K6 `toolConfig` object for script or image mode (UI mode is deferred). -// Script mode: enforces the mandatory `export default function` rule client-side so the -// agent gets a clear error before the API trip. Image mode: wires the prebuilt container -// image (+ optional entrypoint) into customImage. hostUrl / options / envVars are shared. +// Build the K6 `toolConfig.k6` block for script or image mode (UI mode deferred). +// Matches K6Spec + ScriptSpec in loadTestManager: mode + +// script{content|image|entrypoint|loadArgs|imagePullSecret} + +// tunables{targetUrl, targetUsers, durationSeconds, rampUpTimeSec, workerCount, hostUrl, +// iterations, rpsLimit} + envVars. function buildK6ToolConfig( b: Record, args: { scriptSource: string; script?: string; targetUrl?: string }, @@ -381,112 +382,329 @@ function buildK6ToolConfig( (b.hostUrl as string | undefined) ?? parseHostOrigin(args.targetUrl); const rpsLimit = b.rps_limit != null ? (b.rps_limit as number) : undefined; - const envVars = buildK6EnvVars(b.env_vars); + const iterations = b.iterations != null ? (b.iterations as number) : undefined; + const envVars = buildEnvVars(b.env_vars, "K6"); - const options: Record = {}; - if (rpsLimit != null && rpsLimit > 0) options.rpsLimit = rpsLimit; - - // Build the mode-specific payload first; shared keys (hostUrl, options, envVars, - // iterations) are appended after so we get a stable key order matching the studio. - let toolConfig: Record; + // Script/image artifact lives under `script`, not `scriptContent`/`customImage`. + let mode: "script" | "image"; + const script: Record = {}; if (args.scriptSource === "image") { - // Custom Image: image + optional entrypoint live in toolConfig.customImage. - // Note: load_args is NOT part of customImage for K6 — it rides only in inputs[] - // (matches K6LoadTestService.ts:487-495 where customImage only has image/entrypoint). const scriptImage = (b.script_image ?? b.scriptImage) as string | undefined; if (scriptImage == null) { - // Defensive — the early image-mode gate in bodyBuilder already throws this. throw new Error("K6 image mode requires 'script_image'."); } - const customImage: Record = { image: scriptImage }; + mode = "image"; + script.image = scriptImage; const entrypoint = (b.script_entrypoint ?? b.scriptEntrypoint) as string | undefined; - if (entrypoint != null) customImage.entrypoint = entrypoint; - toolConfig = { mode: "image", customImage }; + if (entrypoint != null) script.entrypoint = entrypoint; + + const loadArgs = (b.load_args ?? b.loadArgs) as string | undefined; + if (loadArgs != null && loadArgs !== "") { + validateLoadArgs(loadArgs); + script.loadArgs = loadArgs; + } + + const imagePullSecret = (b.image_pull_secret ?? b.imagePullSecret) as string | undefined; + if (imagePullSecret != null && imagePullSecret !== "") { + script.imagePullSecret = imagePullSecret; + } } else { - // Script mode: base64 JS source. Mandatory client-side rule (matches Harness UI: - // validateScriptContent at K6LoadTestService.ts:767-774). Substring check (not - // full AST) matches the UI exactly. if (args.script == null) { throw new Error("K6 script mode requires 'script' (the raw JavaScript K6 source)."); } + // Mandatory client-side rule (matches Harness UI validateScriptContent). if (!args.script.includes("export default")) { throw new Error( "K6 script must export a default function (export default function ...).", ); } - toolConfig = { - mode: "script", - scriptContent: Buffer.from(args.script, "utf8").toString("base64"), - }; - // iterations is K6 script-mode-only (matches K6LoadTestService.ts:483-484). - const iterations = b.iterations != null ? (b.iterations as number) : undefined; - if (iterations != null && iterations > 0) toolConfig.iterations = iterations; + mode = "script"; + script.content = Buffer.from(args.script, "utf8").toString("base64"); } - if (hostUrl) toolConfig.hostUrl = hostUrl; - if (Object.keys(options).length > 0) toolConfig.options = options; + // Tunables live under `tunables`, not flat on the toolConfig object. + const tunables: Record = {}; + if (args.targetUrl != null) tunables.targetUrl = args.targetUrl; + if (b.users != null) tunables.targetUsers = b.users; + if (b.duration_sec != null) tunables.durationSeconds = b.duration_sec; + if (b.ramp_up_sec != null) tunables.rampUpTimeSec = b.ramp_up_sec; + if (b.worker_count != null) tunables.workerCount = b.worker_count; + if (hostUrl) tunables.hostUrl = hostUrl; + if (iterations != null && iterations > 0) tunables.iterations = iterations; + // rpsLimit's sole authoring surface is tunables (loadTestManager k6.go); + // options.rpsLimit is a legacy run-dispatch fallback only, never written here. + if (rpsLimit != null && rpsLimit > 0) tunables.rpsLimit = rpsLimit; + + const toolConfig: Record = { mode, script }; + if (Object.keys(tunables).length > 0) toolConfig.tunables = tunables; if (envVars.length > 0) toolConfig.envVars = envVars; return toolConfig; } +// Reject known legacy/mistaken field names on chaos_loadtest create/update instead +// of silently ignoring them (Fail Loudly — see coerceBody above). 'scriptContent' / +// 'customImage' are old wire-shape names superseded by 'script' / 'script_image'; +// 'inputs' belongs to chaos_probe, not chaos_loadtest. +const LEGACY_LOADTEST_KEYS = ["scriptContent", "customImage", "inputs"] as const; +function rejectLegacyLoadtestFields(b: Record): void { + for (const key of LEGACY_LOADTEST_KEYS) { + if (b[key] !== undefined) { + throw new Error( + `'${key}' is not a supported chaos_loadtest field. Use 'script'/'script_image' instead of 'scriptContent'/'customImage'; 'inputs' is a chaos_probe field, not chaos_loadtest.`, + ); + } + } +} + +// ── JMeter-specific helpers ────────────────────────────────────────── +// Matches JMeterSpec in loadTestManager/internal/domain/jmeter.go. + +type JMeterProperty = { key: string; value: string; sendToEngines?: boolean }; +type JMeterThreshold = { metric: string; stat?: string; operator: string; value: number; abortOnFail?: boolean }; + +const JMETER_THRESHOLD_METRICS = new Set(["response_time_ms", "error_rate_pct", "throughput_rps", "latency_ms"]); +const JMETER_THRESHOLD_OPERATORS = new Set(["<", "<=", ">", ">=", "==", "!="]); +const JMETER_STAT_REQUIRED_METRICS = new Set(["response_time_ms", "latency_ms"]); + +// Validate + project the structured properties input into toolConfig.jmeter.properties[]. +function buildJMeterProperties(raw: unknown): JMeterProperty[] { + if (raw == null) return []; + if (!Array.isArray(raw)) { + throw new Error("properties must be an array of {key, value, send_to_engines?} entries."); + } + return raw.map((entry, idx) => { + const p = entry as Record; + const key = p.key as string | undefined; + if (!key) { + throw new Error(`properties[${idx}].key is required.`); + } + const out: JMeterProperty = { key, value: String(p.value ?? "") }; + if (typeof p.send_to_engines === "boolean") out.sendToEngines = p.send_to_engines; + return out; + }); +} + +// Validate + project the structured thresholds input into toolConfig.jmeter.thresholds[]. +// Mirrors JMeterThreshold.validate() in loadTestManager/internal/domain/jmeter.go:245-256. +function buildJMeterThresholds(raw: unknown): JMeterThreshold[] { + if (raw == null) return []; + if (!Array.isArray(raw)) { + throw new Error("thresholds must be an array of {metric, stat?, operator, value, abort_on_fail?} entries."); + } + return raw.map((entry, idx) => { + const t = entry as Record; + const metric = t.metric as string | undefined; + if (!metric || !JMETER_THRESHOLD_METRICS.has(metric)) { + throw new Error(`thresholds[${idx}].metric must be one of ${[...JMETER_THRESHOLD_METRICS].join(", ")}.`); + } + const operator = t.operator as string | undefined; + if (!operator || !JMETER_THRESHOLD_OPERATORS.has(operator)) { + throw new Error(`thresholds[${idx}].operator must be one of ${[...JMETER_THRESHOLD_OPERATORS].join(", ")}.`); + } + const stat = t.stat as string | undefined; + if (JMETER_STAT_REQUIRED_METRICS.has(metric) && !stat) { + throw new Error(`thresholds[${idx}]: metric '${metric}' requires 'stat' (e.g. p95, p99, avg, median, max).`); + } + if (t.value == null) { + throw new Error(`thresholds[${idx}].value is required.`); + } + const out: JMeterThreshold = { metric, operator, value: t.value as number }; + if (stat) out.stat = stat; + if (typeof t.abort_on_fail === "boolean") out.abortOnFail = t.abort_on_fail; + return out; + }); +} + +// Build the JMeter `toolConfig.jmeter` block for script or image mode. +// Matches JMeterSpec in loadTestManager/internal/domain/jmeter.go: mode + script{content|image|entrypoint} +// + tunables{workerCount} + properties[] + envVars[] + thresholds[]. +function buildJMeterToolConfig( + b: Record, + args: { scriptSource: string; script?: string }, +): Record { + let mode: "script" | "image"; + const script: Record = {}; + if (args.scriptSource === "image") { + const scriptImage = (b.script_image ?? b.scriptImage) as string | undefined; + if (scriptImage == null) { + throw new Error("JMeter image mode requires 'script_image'."); + } + mode = "image"; + script.image = scriptImage; + const entrypoint = (b.script_entrypoint ?? b.scriptEntrypoint) as string | undefined; + if (entrypoint != null) script.entrypoint = entrypoint; + + const loadArgs = (b.load_args ?? b.loadArgs) as string | undefined; + if (loadArgs != null && loadArgs !== "") { + validateLoadArgs(loadArgs); + script.loadArgs = loadArgs; + } + + const imagePullSecret = (b.image_pull_secret ?? b.imagePullSecret) as string | undefined; + if (imagePullSecret != null && imagePullSecret !== "") { + script.imagePullSecret = imagePullSecret; + } + } else { + if (args.script == null) { + throw new Error("JMeter script mode requires 'script' (the raw .jmx/.xml plan text)."); + } + mode = "script"; + script.content = Buffer.from(args.script, "utf8").toString("base64"); + } + + const toolConfig: Record = { mode, script }; + + if (b.worker_count != null) toolConfig.tunables = { workerCount: b.worker_count }; + + const properties = buildJMeterProperties(b.properties); + if (properties.length > 0) toolConfig.properties = properties; + + const envVars = buildEnvVars(b.env_vars, "JMeter"); + if (envVars.length > 0) toolConfig.envVars = envVars; + + const thresholds = buildJMeterThresholds(b.thresholds); + if (thresholds.length > 0) toolConfig.thresholds = thresholds; + + return toolConfig; +} + +// Validate the Locust/K6/JMeter load_args string. Mirrors ValidateLoadArgs in +// loadTestManager/internal/api/dto.go: semicolon-separated k=v pairs; keys +// must be non-empty, contain no whitespace, and not start with '-'. +function validateLoadArgs(loadArgs: string): void { + for (const raw of loadArgs.split(";")) { + const pair = raw.trim(); + if (!pair) continue; + const eq = pair.indexOf("="); + const key = (eq >= 0 ? pair.slice(0, eq) : pair).trim(); + if (!key) throw new Error(`load_args: empty key in pair ${JSON.stringify(pair)}`); + if (/[ \t]/.test(key)) { + throw new Error(`load_args: key ${JSON.stringify(key)} must not contain spaces`); + } + if (key.startsWith("-")) { + throw new Error(`load_args: key ${JSON.stringify(key)} must not start with '-'`); + } + } +} + +// Build the Locust `toolConfig.locust` block for script or image mode. Matches +// LocustSpec + ScriptSpec in loadTestManager: mode + +// script{content|image|entrypoint|loadArgs|imagePullSecret} + +// tunables{targetUrl, targetUsers, spawnRate, rampUpTimeSec, durationSeconds, workerCount}. +// NOTE: Locust intentionally has no target_type guard (unlike K6/JMeter) — +// loadTestManager supports Locust script AND image mode on both Linux VM and +// Kubernetes infra (loadtest_handlers.go has no Locust kubernetes-only check). +// Do not add one here. +function buildLocustToolConfig( + b: Record, + args: { scriptSource: string; script?: string; targetUrl?: string }, +): Record { + let mode: "script" | "image"; + const script: Record = {}; + if (args.scriptSource === "image") { + const scriptImage = (b.script_image ?? b.scriptImage) as string | undefined; + if (scriptImage == null) { + throw new Error("Locust image mode requires 'script_image'."); + } + mode = "image"; + script.image = scriptImage; + const entrypoint = (b.script_entrypoint ?? b.scriptEntrypoint) as string | undefined; + if (entrypoint != null) script.entrypoint = entrypoint; + + const loadArgs = (b.load_args ?? b.loadArgs) as string | undefined; + if (loadArgs != null && loadArgs !== "") { + validateLoadArgs(loadArgs); + script.loadArgs = loadArgs; + } + + const imagePullSecret = (b.image_pull_secret ?? b.imagePullSecret) as string | undefined; + if (imagePullSecret != null && imagePullSecret !== "") { + script.imagePullSecret = imagePullSecret; + } + } else { + if (args.script == null) { + throw new Error("Locust script mode requires 'script' (the raw Python locustfile)."); + } + mode = "script"; + script.content = Buffer.from(args.script, "utf8").toString("base64"); + } + + const tunables: Record = {}; + if (args.targetUrl != null) tunables.targetUrl = args.targetUrl; + if (b.users != null) tunables.targetUsers = b.users; + if (b.spawn_rate != null) tunables.spawnRate = b.spawn_rate; + if (b.duration_sec != null) tunables.durationSeconds = b.duration_sec; + if (b.ramp_up_sec != null) tunables.rampUpTimeSec = b.ramp_up_sec; + if (b.worker_count != null) tunables.workerCount = b.worker_count; + + const toolConfig: Record = { mode, script }; + if (Object.keys(tunables).length > 0) toolConfig.tunables = tunables; + return toolConfig; +} + /** * Build the canonical LoadTest YAML manifest. Mirrors the studio's * formDataToManifest shape (kind: LoadTest, apiVersion: v1alpha1, spec.{...}). * - * scriptContent inside the YAML is PLAIN TEXT (so the manifest is human- - * readable); only the JSON request body's top-level scriptContent is base64. - * Caller base64-encodes the returned string into the `yaml` request field. + * The wire toolBlock carries base64 script.content; for readability the YAML + * view carries plain-text script.content instead. Caller base64-encodes the + * returned string into the `yaml` request field. */ function buildLoadtestYamlManifest(args: { name: string; description?: string; tags?: string[]; + serviceReferences?: string[]; identity: string; - toolType: "Locust" | "K6"; + toolType: "Locust" | "K6" | "JMeter"; targetType: string; - scriptSource: string; - script?: string; // Locust inline source OR K6 source (plain text) - k6ToolConfig?: Record; // wire-shape K6 toolConfig (with base64 scriptContent) + toolBlock: Record; environmentIdentifier: string; infraIdentifier: string; - inputs: LoadtestInput[]; + cleanupPolicy?: string; + resources?: Record; }): string { const infraType = args.targetType === "kubernetes" ? "kubernetes" : "linux"; + + const metadata: Record = { name: args.name }; + if (args.description) metadata.description = args.description; + if (args.tags && args.tags.length) metadata.tags = args.tags; + if (args.serviceReferences && args.serviceReferences.length) { + metadata.serviceReferences = args.serviceReferences; + } + const manifest: Record = { kind: "LoadTest", apiVersion: "v1alpha1", - name: args.name, + metadata, }; - if (args.description) manifest.description = args.description; - if (args.tags && args.tags.length) manifest.tags = args.tags; + + // In the YAML view we want plain-text script content for readability; the + // wire toolConfig carries it base64. Decode a copy for the manifest. + const yamlToolBlock: Record = { ...args.toolBlock }; + const s = yamlToolBlock.script as Record | undefined; + if (s?.content && typeof s.content === "string") { + try { + yamlToolBlock.script = { + ...s, + content: Buffer.from(s.content, "base64").toString("utf8"), + }; + } catch { + // If the content isn't valid base64 for any reason, keep as-is. + } + } const spec: Record = { identity: args.identity, toolType: args.toolType, infraType, targetType: args.targetType, - scriptSource: args.scriptSource, + cleanupPolicy: args.cleanupPolicy ?? "delete", }; - // Locust inline only: readable scriptContent at the manifest root (plain text). - // K6 keeps the script inside spec.toolConfig instead. - if (args.toolType === "Locust" && args.scriptSource === "inline" && args.script) { - spec.scriptContent = args.script; - } + if (args.resources != null) spec.resources = args.resources; spec.infraId = args.infraIdentifier; spec.envId = args.environmentIdentifier; - spec.inputs = args.inputs; - - // K6: emit spec.toolConfig with PLAIN TEXT scriptContent (decode the base64 from - // the wire-shape toolConfig so the YAML view is human-readable). Other - // toolConfig keys (mode, hostUrl, options, envVars, iterations) pass through. - if (args.toolType === "K6" && args.k6ToolConfig) { - const tc: Record = { ...args.k6ToolConfig }; - if (typeof tc.scriptContent === "string" && args.script) { - tc.scriptContent = args.script; // plain text for YAML readability - } - spec.toolConfig = tc; - } - + spec.toolConfig = { [args.toolType.toLowerCase()]: yamlToolBlock }; manifest.spec = spec; return YAML.stringify(manifest); @@ -1735,7 +1953,37 @@ export const chaosToolset: ToolsetDefinition = { scope: "project", scopeParams: CHAOS_SCOPE, identifierFields: ["loadtest_id"], + compactItem: compactLoadTest, deepLinkTemplate: "/ng/account/{accountId}/module/chaos/orgs/{orgIdentifier}/projects/{projectIdentifier}/load-tests/{loadtestId}", + listFilterFields: [ + { name: "environment_id", description: descEnvironmentId }, + { name: "tool_type", description: descLoadtestType, enum: ["Locust", "K6", "JMeter"] }, + { name: "tags", description: descLoadtestTags }, + { name: "search", description: "Free-text search. NOTE: backend currently matches only the load test's display name (substring, case-insensitive) -- it does not match identity/slug and is not fuzzy or prefix-tolerant." }, + { name: "sort_field", description: "Field to sort by (e.g. createdAt, updatedAt, name)." }, + { name: "sort_ascending", description: "Sort ascending when true, descending when false.", type: "boolean" }, + ], + diagnosticHint: "Load tests are looked up by their identity slug (e.g. 'mcplocustscript001'), not the uniqueId UUID. If you only have a uniqueId, call harness_list resource_type=chaos_loadtest (optionally with search_term) and use the matching item's 'identity'/'loadtestId' field instead.", + relatedResources: [ + { + resourceType: "chaos_infrastructure", + relationship: "prerequisite", + description: + "Linux VM load-runner infrastructure. When the load test targets Linux VM (target_type='machine-chaos-linux', the default), its 'environmentID' supplies environment_id and its 'infraID' supplies infra_id. Only loadEnabled + ACTIVE infras are usable. In MCP, Linux VM supports tool_type Locust only -- K6 (a genuine backend restriction) and JMeter (an MCP/UI-parity restriction; the backend itself allows Linux JMeter) with a Linux target_type are rejected by MCP.", + }, + { + resourceType: "chaos_enabled_infrastructure", + relationship: "prerequisite", + description: + "Kubernetes load-runner infrastructure. When the load test targets Kubernetes, filter this list by infra_type='KubernetesV2'; its 'environmentID' supplies environment_id and its 'infraID' supplies infra_id. Kubernetes infra supports tool_type Locust, K6, or JMeter. IMPORTANT: target_type MUST be explicitly set to 'kubernetes' -- it is not auto-derived from which infra list you picked from; omitting it defaults to Linux naming and dispatches wrong.", + }, + { + resourceType: "chaos_service", + relationship: "prerequisite", + description: + "Resilience Testing Services attached to a load test. List with infrastructure_ids='/' to see services already onboarded on the chosen load-runner infra. A user may pick one or more identities from the list, OR onboard a brand-new service via chaos_service's create flow (see chaos_service.relatedResources for the discovered_agent/discovered_service chain) regardless of whether the list is empty -- creating a new service is a user-intent decision, not gated on list length. The chosen identity/identities are passed on the load test as service_references (required when the account has CHAOS_RISK_SERVICES_ENABLED on).", + }, + ], operations: { list: { method: "GET", @@ -1748,6 +1996,10 @@ export const chaosToolset: ToolsetDefinition = { search: "search", search_term: "search", environment_id: "environmentIdentifier", + tool_type: "toolType", + tags: "tags", + sort_field: "sortField", + sort_ascending: "sortAscending", }, responseExtractor: chaosLoadTestListExtract, description: descListLoadtests, @@ -1767,15 +2019,13 @@ export const chaosToolset: ToolsetDefinition = { skipScopeBodyInjection: true, bodyBuilder: (input) => { const b = coerceBody(input); + rejectLegacyLoadtestFields(b); const name = (b.name as string) ?? ""; if (!name) { throw new Error("name is required."); } // identity is the slug-constrained key; auto-derive from name when omitted. - // The display name is permissive (any non-empty string) — only the identity - // must be a slug. We strip non-alphanumerics from name as a sensible default; - // a fully-blank slug falls back to a UUID so we never write an empty id. const slug = (s: string) => s.replace(/[^a-zA-Z0-9]/g, ""); const identity = (b.identity as string) || slug(name) || randomUUID(); if (!/^[a-zA-Z0-9_]+$/.test(identity)) { @@ -1784,7 +2034,21 @@ export const chaosToolset: ToolsetDefinition = { ); } + // Tool type: Locust (default), K6, or JMeter. Gatling/Custom are deprecated + // and the backend now rejects them with a 400. + const toolType = ((b.tool_type as string) ?? "Locust") as + | "Locust" + | "K6" + | "JMeter"; + if (toolType !== "Locust" && toolType !== "K6" && toolType !== "JMeter") { + throw new Error(`tool_type '${toolType}' must be 'Locust', 'K6', or 'JMeter'.`); + } + const targetType = (b.target_type as string) ?? "machine-chaos-linux"; + if ((toolType === "K6" || toolType === "JMeter") && targetType !== "kubernetes") { + throw new Error(`${toolType} load tests require target_type='kubernetes'.`); + } + const environmentIdentifier = (b.environment_id ?? b.environmentIdentifier) as | string | undefined; @@ -1792,32 +2056,9 @@ export const chaosToolset: ToolsetDefinition = { const targetUrl = (b.target_url ?? b.targetUrl) as string | undefined; const script = b.script as string | undefined; const scriptImage = (b.script_image ?? b.scriptImage) as string | undefined; - // Explicit script_source wins, else infer "image" when an image is supplied. const scriptSource = (b.script_source as string) ?? (scriptImage != null ? "image" : "inline"); - // Mode-specific required fields (fail loudly before we touch the wire). - // Tool-agnostic message: Locust = Python locust source, K6 = JavaScript with 'export default'. - // The K6-specific 'export default' rule is enforced later in buildK6ToolConfig. - if (scriptSource === "inline" && script == null) { - throw new Error("script is required when script_source='inline'."); - } - if (scriptSource === "image" && scriptImage == null) { - throw new Error("script_image is required when script_source='image'."); - } - - // Tool type: Locust (default) or K6. K6 is Kubernetes-only and supports - // script and image modes (UI mode is deferred). - const toolType = ((b.tool_type as string) ?? "Locust") as "Locust" | "K6"; - if (toolType !== "Locust" && toolType !== "K6") { - throw new Error(`tool_type '${toolType}' must be 'Locust' or 'K6'.`); - } - if (toolType === "K6" && targetType !== "kubernetes") { - throw new Error( - "K6 load tests require target_type='kubernetes' (LinuxVM is not supported).", - ); - } - // Normalise tags (accept array or comma-separated string; default []). const rawTags = b.tags; const tags: string[] = Array.isArray(rawTags) @@ -1826,35 +2067,27 @@ export const chaosToolset: ToolsetDefinition = { ? (rawTags as string).split(",").map((t) => t.trim()).filter(Boolean) : []; - // Build the canonical inputs[] array (well-known tunables → TemplateInput[]). - // inputs[] is identical between Locust and K6 script/image modes. - const inputs = buildLoadtestInputs(b, { targetType, scriptSource }); - - // K6 wraps the script in a toolConfig object (no top-level scriptContent). - // Built up-front so the same value drives both the JSON body and the YAML view. - let k6ToolConfig: Record | undefined; + // Build toolConfig.. JMeter builds from scalars by default; + // 'tool_config' remains an escape hatch for advanced/back-compat callers + // (e.g. .zip bundles) who want to hand-construct the object. + let toolBlock: Record; if (toolType === "K6") { - k6ToolConfig = buildK6ToolConfig(b, { scriptSource, script, targetUrl }); + toolBlock = buildK6ToolConfig(b, { scriptSource, script, targetUrl }); + } else if (toolType === "Locust") { + toolBlock = buildLocustToolConfig(b, { scriptSource, script, targetUrl }); + } else { + const supplied = b.tool_config as Record | undefined; + toolBlock = supplied != null + ? ((supplied.jmeter as Record) ?? supplied) + : buildJMeterToolConfig(b, { scriptSource, script }); } + const toolKey = toolType.toLowerCase(); + const toolConfig: Record = { [toolKey]: toolBlock }; - // Build the canonical LoadTest YAML manifest, then base64-encode for the wire. - // The manifest carries plain-text scriptContent (Locust inline) OR plain-text - // toolConfig.scriptContent (K6), so the YAML view is human-readable; the JSON - // body's base64 lives only on the wire. - const yamlManifest = buildLoadtestYamlManifest({ - name, - description: b.description as string | undefined, - tags, - identity, - toolType, - targetType, - scriptSource, - script, - k6ToolConfig, - environmentIdentifier: environmentIdentifier as string, - infraIdentifier: infraIdentifier as string, - inputs, - }); + // Variables live under toolConfig..variables. + if (Array.isArray(b.variables)) { + (toolBlock as Record).variables = b.variables; + } const body: Record = { identity, @@ -1863,34 +2096,39 @@ export const chaosToolset: ToolsetDefinition = { tags, environmentIdentifier, infraIdentifier, - scriptSource, targetType, toolType, - inputs, - yaml: Buffer.from(yamlManifest, "utf8").toString("base64"), + toolConfig, }; - // Top-level scriptContent rules: - // - Locust inline: base64 of the raw Python script. - // - Locust image: omitted (script_image rides in inputs[]). - // - K6 script: OMITTED at top level — the K6 script lives in - // toolConfig.scriptContent only (top-level would be - // treated as a Locust-style script and double-encoded). - // - K6 image: not yet supported (rejected earlier). - if (toolType === "Locust" && scriptSource === "inline") { - body.scriptContent = Buffer.from(script as string, "utf8").toString("base64"); - } - if (toolType === "K6" && k6ToolConfig) { - body.toolConfig = k6ToolConfig; - } + // Optional pass-through fields. + if (b.service_references != null) body.serviceReferences = b.service_references; + if (b.cleanup_policy != null) body.cleanupPolicy = b.cleanup_policy; + if (b.max_duration_sec != null) body.maxDurationSec = b.max_duration_sec; + if (b.resources != null) body.resources = b.resources; + + // Optional canonical YAML manifest (backend accepts it as an alternative + // source; MCP still sends toolConfig authoritatively). + const yamlManifest = buildLoadtestYamlManifest({ + name, + description: b.description as string | undefined, + tags, + serviceReferences: body.serviceReferences as string[] | undefined, + identity, + toolType, + targetType, + toolBlock, + environmentIdentifier: environmentIdentifier as string, + infraIdentifier: infraIdentifier as string, + cleanupPolicy: body.cleanupPolicy as string | undefined, + resources: body.resources as Record | undefined, + }); + body.yaml = Buffer.from(yamlManifest, "utf8").toString("base64"); - // Emit snake_case aliases for the registry's required-field validator (which - // checks the built body against the snake_case bodySchema field names). The - // load-test backend reads the camelCase / inputs[] keys and ignores extras - // (same pattern as chaos_action create). + // Snake-case aliases for the registry's required-field validator (which + // checks the built body against the snake_case bodySchema field names). if (environmentIdentifier != null) body.environment_id = environmentIdentifier; if (infraIdentifier != null) body.infra_id = infraIdentifier; - if (targetUrl != null) body.target_url = targetUrl; return body; }, @@ -1902,18 +2140,151 @@ export const chaosToolset: ToolsetDefinition = { { name: "name", type: "string", required: true, description: descLoadtestName }, { name: "environment_id", type: "string", required: true, description: descLoadtestEnvId }, { name: "infra_id", type: "string", required: true, description: descLoadtestInfraId }, - { name: "target_url", type: "string", required: true, description: descLoadtestTargetUrl }, - { name: "script", type: "string", required: false, description: descLoadtestScript }, + { name: "identity", type: "string", required: false, description: descLoadtestIdentity }, + { name: "description", type: "string", required: false, description: descLoadtestDescription }, + { name: "tags", type: "array", required: false, description: descLoadtestTags }, + { name: "target_type", type: "string", required: false, description: descLoadtestTargetType }, + { name: "tool_type", type: "string", required: false, description: descLoadtestType }, + { name: "target_url", type: "string", required: false, description: descLoadtestTargetUrl }, { name: "script_source", type: "string", required: false, description: descLoadtestScriptSource }, + { name: "script", type: "string", required: false, description: descLoadtestScript }, { name: "script_image", type: "string", required: false, description: descLoadtestScriptImage }, { name: "script_entrypoint", type: "string", required: false, description: descLoadtestScriptEntrypoint }, { name: "load_args", type: "string", required: false, description: descLoadtestLoadArgs }, - { name: "identity", type: "string", required: false, description: descLoadtestIdentity }, + { name: "image_pull_secret", type: "string", required: false, description: descLoadtestImagePullSecret }, + { name: "users", type: "number", required: false, description: descLoadtestUsers }, + { name: "spawn_rate", type: "number", required: false, description: "Locust spawn rate (users/sec)." }, + { name: "duration_sec", type: "number", required: false, description: descLoadtestDurationSec }, + { name: "ramp_up_sec", type: "number", required: false, description: descLoadtestRampUpSec }, + { name: "worker_count", type: "number", required: false, description: descLoadtestWorkerCount }, + { name: "host_url", type: "string", required: false, description: descLoadtestHostUrl }, + { name: "rps_limit", type: "number", required: false, description: descLoadtestRpsLimit }, + { name: "iterations", type: "number", required: false, description: descLoadtestIterations }, + { name: "env_vars", type: "array", required: false, description: descLoadtestEnvVars }, + { name: "properties", type: "array", required: false, description: descLoadtestProperties }, + { name: "thresholds", type: "array", required: false, description: descLoadtestThresholds }, + { name: "variables", type: "array", required: false, description: "Custom template.Variable entries stored under toolConfig..variables." }, + { name: "tool_config", type: "object", required: false, description: "JMeter-only pass-through toolConfig.jmeter object -- escape hatch for advanced/back-compat use (e.g. .zip test-plan bundles). Ignored for K6/Locust on create (use the scalar fields for those); not required for JMeter either -- prefer script/script_image/properties/env_vars/thresholds/worker_count unless you need this override." }, + { name: "service_references", type: "array", required: false, description: "chaosService identity strings; required when CHAOS_RISK_SERVICES_ENABLED is on." }, + { name: "cleanup_policy", type: "string", required: false, description: descLoadtestCleanupPolicy }, + { name: "max_duration_sec", type: "number", required: false, description: "Per-load-test hard cap on any run." }, + { name: "resources", type: "object", required: false, description: descLoadtestResources }, + ], + }, + }, + update: { + method: "PUT", + path: `${CHAOS_LOADTEST}/v1/load-tests/{loadtestId}`, + operationPolicy: { risk: "low_write", retryPolicy: "do_not_retry" }, + pathParams: { loadtest_id: "loadtestId" }, + skipScopeBodyInjection: true, + bodyBuilder: (input) => { + const b = coerceBody(input); + rejectLegacyLoadtestFields(b); + const body: Record = {}; + if (b.name != null) body.name = b.name; + if (b.description != null) body.description = b.description; + if (b.tags != null) body.tags = b.tags; + if (b.service_references != null) body.serviceReferences = b.service_references; + if (b.environment_id != null) body.environmentIdentifier = b.environment_id; + if (b.infra_id != null) body.infraIdentifier = b.infra_id; + if (b.target_type != null) body.targetType = b.target_type; + if (b.max_duration_sec != null) body.maxDurationSec = b.max_duration_sec; + if (b.cleanup_policy != null) body.cleanupPolicy = b.cleanup_policy; + if (b.resources != null) body.resources = b.resources; + + // tool_type is never sent to the API (immutable after creation) -- + // used only internally to pick the right scalar builder / wrap key. + const toolType = b.tool_type as "Locust" | "K6" | "JMeter" | undefined; + const toolConfigEscapeHatch = b.tool_config as Record | undefined; + + // Same scalar surface as create -- if any of these are present, + // toolConfig. must be rebuilt wholesale (server does a full + // overwrite; see UpdateLoadTestRequest.ToolConfig in loadTestManager). + const SCALAR_TOOL_FIELDS = [ + "target_url", "script_source", "script", "script_image", "script_entrypoint", + "load_args", "image_pull_secret", "users", "spawn_rate", "duration_sec", + "ramp_up_sec", "worker_count", "host_url", "rps_limit", "iterations", + "env_vars", "properties", "thresholds", + ] as const; + const hasScalarToolField = SCALAR_TOOL_FIELDS.some((f) => b[f] != null); + + if (hasScalarToolField) { + if (toolType == null) { + throw new Error( + "tool_type is required to edit script/tunables/properties/thresholds/env_vars on update -- call harness_get first if you don't know the existing tool_type.", + ); + } + if (toolType !== "Locust" && toolType !== "K6" && toolType !== "JMeter") { + throw new Error(`tool_type '${toolType}' must be 'Locust', 'K6', or 'JMeter'.`); + } + const targetUrl = (b.target_url ?? b.targetUrl) as string | undefined; + const script = b.script as string | undefined; + const scriptImage = (b.script_image ?? b.scriptImage) as string | undefined; + const scriptSource = + (b.script_source as string) ?? (scriptImage != null ? "image" : "inline"); + + let toolBlock: Record; + if (toolType === "K6") { + toolBlock = buildK6ToolConfig(b, { scriptSource, script, targetUrl }); + } else if (toolType === "Locust") { + toolBlock = buildLocustToolConfig(b, { scriptSource, script, targetUrl }); + } else { + toolBlock = buildJMeterToolConfig(b, { scriptSource, script }); + } + // Mirrors create: variables live under toolConfig..variables, + // which ReconcileToolConfigVariables prefers over the top-level + // field -- do not also set body.variables to avoid two sources. + if (Array.isArray(b.variables)) { + toolBlock.variables = b.variables; + } + body.toolConfig = { [toolType.toLowerCase()]: toolBlock }; + } else if (toolConfigEscapeHatch != null) { + if (toolType != null) { + const toolKey = toolType.toLowerCase(); + const inner = + (toolConfigEscapeHatch[toolKey] as Record | undefined) ?? + toolConfigEscapeHatch; + body.toolConfig = { [toolKey]: inner }; + } else { + // Back-compat: caller already sends the fully-wired { : {...} } shape. + body.toolConfig = toolConfigEscapeHatch; + } + if (b.variables != null) body.variables = b.variables; + } else if (b.variables != null) { + // No toolConfig rebuild in this call -- variables-only update, + // top-level field (server falls back to it when + // toolConfig..variables is empty). + body.variables = b.variables; + } + + return body; + }, + responseExtractor: chaosLoadTestExtract, + description: descUpdateLoadtest, + bodySchema: { + description: descUpdateLoadtest, + fields: [ + { name: "name", type: "string", required: false, description: descLoadtestName }, { name: "description", type: "string", required: false, description: descLoadtestDescription }, { name: "tags", type: "array", required: false, description: descLoadtestTags }, + { name: "service_references", type: "array", required: false, description: "chaosService identity strings; a non-null slice must be non-empty." }, + { name: "environment_id", type: "string", required: false, description: descLoadtestEnvId }, + { name: "infra_id", type: "string", required: false, description: descLoadtestInfraId }, { name: "target_type", type: "string", required: false, description: descLoadtestTargetType }, + { name: "max_duration_sec", type: "number", required: false, description: "Per-load-test hard cap on any run." }, + { name: "cleanup_policy", type: "string", required: false, description: descLoadtestCleanupPolicy }, + { name: "resources", type: "object", required: false, description: descLoadtestResources }, { name: "tool_type", type: "string", required: false, description: descLoadtestType }, + { name: "target_url", type: "string", required: false, description: descLoadtestTargetUrl }, + { name: "script_source", type: "string", required: false, description: descLoadtestScriptSource }, + { name: "script", type: "string", required: false, description: descLoadtestScript }, + { name: "script_image", type: "string", required: false, description: descLoadtestScriptImage }, + { name: "script_entrypoint", type: "string", required: false, description: descLoadtestScriptEntrypoint }, + { name: "load_args", type: "string", required: false, description: descLoadtestLoadArgs }, + { name: "image_pull_secret", type: "string", required: false, description: descLoadtestImagePullSecret }, { name: "users", type: "number", required: false, description: descLoadtestUsers }, + { name: "spawn_rate", type: "number", required: false, description: "Locust spawn rate (users/sec)." }, { name: "duration_sec", type: "number", required: false, description: descLoadtestDurationSec }, { name: "ramp_up_sec", type: "number", required: false, description: descLoadtestRampUpSec }, { name: "worker_count", type: "number", required: false, description: descLoadtestWorkerCount }, @@ -1921,6 +2292,10 @@ export const chaosToolset: ToolsetDefinition = { { name: "rps_limit", type: "number", required: false, description: descLoadtestRpsLimit }, { name: "iterations", type: "number", required: false, description: descLoadtestIterations }, { name: "env_vars", type: "array", required: false, description: descLoadtestEnvVars }, + { name: "properties", type: "array", required: false, description: descLoadtestProperties }, + { name: "thresholds", type: "array", required: false, description: descLoadtestThresholds }, + { name: "tool_config", type: "object", required: false, description: "Advanced escape hatch to hand-construct toolConfig. directly (e.g. JMeter .zip bundles) instead of the scalar fields above. Pass 'tool_type' alongside it so MCP wraps/unwraps it consistently with create; without 'tool_type' it is sent to the API exactly as given (must already be the full '{ : {...} }' wire shape). Full replacement of toolConfig. either way -- null/omit to keep existing." }, + { name: "variables", type: "array", required: false, description: "Full replacement variables list; null to keep existing. When also editing script/tunables via the scalar fields above, this is nested into toolConfig..variables instead of sent top-level (mirrors create)." }, ], }, }, @@ -1939,13 +2314,29 @@ export const chaosToolset: ToolsetDefinition = { path: `${CHAOS_LOADTEST}/v1/load-tests/{loadtestId}/runs`, operationPolicy: { risk: "high_write", retryPolicy: "do_not_retry" }, pathParams: { loadtest_id: "loadtestId" }, - // TODO(runtime-inputs): accept { values: TemplateInputMinimum[] } when runtime - // inputs ('<+input>' sentinel) are added (see deferred follow-up plan). The empty - // body is correct while runtime inputs are unsupported on the create side. - bodyBuilder: () => ({}), + // CreateLoadTestRunRequest.Identity is binding:"required" — send a UUID + // when the caller doesn't supply one so ShouldBindJSON succeeds. + bodyBuilder: (input) => { + const b = coerceBody(input); + const body: Record = { + identity: (b.run_identity as string) ?? randomUUID(), + }; + if (b.run_name != null) body.name = b.run_name; + if (b.values != null) body.values = b.values; + if (b.runtime_values != null) body.runtimeValues = b.runtime_values; + return body; + }, responseExtractor: passthrough, actionDescription: descRunLoadtest, - bodySchema: { description: descBodyNoBody, fields: [] }, + bodySchema: { + description: "Run parameters (all optional).", + fields: [ + { name: "run_identity", type: "string", required: false, description: "Client-supplied identity for the run; a UUID is generated when omitted." }, + { name: "run_name", type: "string", required: false, description: "Optional display name for the run." }, + { name: "values", type: "array", required: false, description: "Runtime override values for load-test Variables (template.InputMinimum entries)." }, + { name: "runtime_values", type: "object", required: false, description: "Path-keyed runtime overrides for toolConfig '<+input>' leaves." }, + ], + }, }, stop: { method: "POST", @@ -1960,6 +2351,260 @@ export const chaosToolset: ToolsetDefinition = { }, }, + // ── Chaos Service (Service Management) ──────────────────────────── + // v3 REST endpoints under the chaos manager: + // GET /v3/chaos-services + // GET /v3/chaos-services/{identity} + // POST /v3/chaos-services + // PUT /v3/chaos-services/{identity} + // DELETE /v3/chaos-services/{identity} + // List response envelope is + // { data: [...], correlationID, pagination: { totalItems, ... } } + // (distinct from the load-test v1 envelope of { items, pagination }). + // Create and update return the full ChaosServiceResponse. Update is a + // full-replace on mutable fields plus desired-state reconcile of probes. + // Delete soft-deletes the service and purges its probe mappings, returning + // { success, correlationID }. + { + resourceType: "chaos_service", + displayName: "Chaos Service", + description: descChaosService, + toolset: "chaos", + scope: "project", + scopeParams: CHAOS_SCOPE, + identifierFields: ["identity"], + deepLinkTemplate: "/ng/account/{accountId}/module/chaos/orgs/{orgIdentifier}/projects/{projectIdentifier}/risks/services/{identity}", + listFilterFields: [ + { name: "environment_ids", description: descChaosServiceEnvironmentIds }, + { name: "infrastructure_ids", description: descChaosServiceInfrastructureIds }, + { name: "tags", description: descChaosServiceTags }, + { name: "include_all_scope", description: descChaosServiceIncludeAllScope, type: "boolean" }, + { name: "search", description: descChaosServiceSearch }, + { name: "probe_ids", description: descChaosServiceProbeIds }, + { name: "onboarding_id", description: descChaosServiceOnboardingIdFilter }, + ], + relatedResources: [ + { resourceType: "discovered_agent", relationship: "prerequisite", description: "STEP 1 of create: pick the Service Discovery agent. Its 'identity' becomes agent_id, its environmentIdentifier becomes environment_id, and (for SD K8s) its identity is also the bare infrastructure_id." }, + { resourceType: "discovered_namespace", relationship: "filters", description: "Optional STEP 2a: list namespaces for the chosen agent to narrow the discovered_service list before picking a service." }, + { resourceType: "discovered_service", relationship: "prerequisite", description: "STEP 2b of create: pick the service to onboard. Its 'id' becomes external_service_id (the server derives serviceType/namespace from it)." }, + { resourceType: "chaos_probe", relationship: "associates", description: "STEP 4 of create: optional health-check probes to attach. Source probe identities and their inputs[] schema here; fill each input value before adding to the create body's probes array." }, + ], + operations: { + list: { + method: "GET", + path: `${CHAOS}/v3/chaos-services`, + operationPolicy: { risk: "read", retryPolicy: "safe" }, + queryParams: { + page: "page", + limit: "limit", + size: "limit", + search: "search", + search_term: "search", + environment_ids: "environmentIds", + infrastructure_ids: "infrastructureIds", + tags: "tags", + include_all_scope: "includeAllScope", + sort_field: "sortField", + sort_ascending: "sortAscending", + probe_ids: "probeIds", + onboarding_id: "onboardingId", + }, + responseExtractor: chaosServiceListExtract, + description: descListChaosServices, + }, + get: { + method: "GET", + path: `${CHAOS}/v3/chaos-services/{identity}`, + operationPolicy: { risk: "read", retryPolicy: "safe" }, + pathParams: { identity: "identity" }, + responseExtractor: passthrough, + description: descGetChaosService, + }, + delete: { + method: "DELETE", + path: `${CHAOS}/v3/chaos-services/{identity}`, + operationPolicy: { risk: "destructive", retryPolicy: "do_not_retry" }, + pathParams: { identity: "identity" }, + responseExtractor: passthrough, + description: descDeleteChaosService, + }, + create: { + method: "POST", + path: `${CHAOS}/v3/chaos-services`, + operationPolicy: { risk: "medium_write", retryPolicy: "do_not_retry" }, + bodyBuilder: (input) => { + const b = coerceBody(input); + const externalServiceId = b.external_service_id ?? b.externalServiceId; + const agentId = b.agent_id ?? b.agentId; + const environmentId = b.environment_id ?? b.environmentId; + const infrastructureId = b.infrastructure_id ?? b.infrastructureId; + const infrastructureType = b.infrastructure_type ?? b.infrastructureType; + const onboardingId = b.onboarding_id ?? b.onboardingId; + const tags = b.tags; + const probes = (b.probes as Array> | undefined)?.map((p) => ({ + probeId: p.probeId ?? p.probe_id, + ...(p.inputs ? { inputs: p.inputs } : {}), + })); + return { + identity: b.identity, + name: b.name, + ...(b.description ? { description: b.description } : {}), + ...(tags + ? { + tags: Array.isArray(tags) + ? tags + : String(tags) + .split(",") + .map((t) => t.trim()) + .filter(Boolean), + } + : {}), + externalServiceId, + agentId, + environmentId, + infrastructureId, + // Dual-write snake_case keys so registry required-field validation + // (which checks bodySchema.fields names against this transformed + // payload) sees the fields it expects. See getBodySchemaValidationPayload + // in registry/index.ts. + external_service_id: externalServiceId, + agent_id: agentId, + environment_id: environmentId, + infrastructure_id: infrastructureId, + ...(infrastructureType ? { infrastructureType } : {}), + ...(onboardingId ? { onboardingId } : {}), + ...(probes && probes.length > 0 ? { probes } : {}), + }; + }, + responseExtractor: passthrough, + description: descCreateChaosService, + bodySchema: { + description: descBodyChaosServiceCreate, + fields: [ + { name: "identity", type: "string", required: true, description: descChaosServiceIdentity }, + { name: "name", type: "string", required: true, description: descChaosServiceName }, + { name: "external_service_id", type: "string", required: true, description: descChaosServiceExternalServiceId }, + { name: "agent_id", type: "string", required: true, description: descChaosServiceAgentId }, + { name: "environment_id", type: "string", required: true, description: descChaosServiceEnvironmentId }, + { name: "infrastructure_id", type: "string", required: true, description: descChaosServiceInfrastructureId }, + { name: "infrastructure_type", type: "string", required: false, description: descChaosServiceInfrastructureType }, + { name: "description", type: "string", required: false, description: descChaosServiceDescription }, + { name: "tags", type: "array", required: false, description: descChaosServiceTagsBody }, + { name: "onboarding_id", type: "string", required: false, description: descChaosServiceOnboardingId }, + { name: "probes", type: "array", required: false, description: descChaosServiceProbes }, + ], + }, + }, + update: { + method: "PUT", + path: `${CHAOS}/v3/chaos-services/{identity}`, + operationPolicy: { risk: "medium_write", retryPolicy: "do_not_retry" }, + pathParams: { identity: "identity" }, + bodyBuilder: (input) => { + const b = coerceBody(input); + const externalServiceId = b.external_service_id ?? b.externalServiceId; + const agentId = b.agent_id ?? b.agentId; + const environmentId = b.environment_id ?? b.environmentId; + const infrastructureId = b.infrastructure_id ?? b.infrastructureId; + const tags = b.tags; + const probes = (b.probes as Array> | undefined)?.map((p) => ({ + probeId: p.probeId ?? p.probe_id, + ...(p.inputs ? { inputs: p.inputs } : {}), + })); + return { + name: b.name, + ...(b.description !== undefined ? { description: b.description } : {}), + ...(tags + ? { + tags: Array.isArray(tags) + ? tags + : String(tags) + .split(",") + .map((t) => t.trim()) + .filter(Boolean), + } + : {}), + externalServiceId, + agentId, + environmentId, + infrastructureId, + // Dual-write snake_case keys — see comment in create bodyBuilder above. + external_service_id: externalServiceId, + agent_id: agentId, + environment_id: environmentId, + infrastructure_id: infrastructureId, + // Full desired-state replace (hce-saas reconcileProbeMappings): + // pass an explicit [] straight through (detach all) rather than + // dropping it, but leave the key absent when the caller omitted it + // so the required-field validation below rejects the request + // instead of silently clearing every probe. + ...(probes !== undefined ? { probes } : {}), + }; + }, + responseExtractor: passthrough, + description: descUpdateChaosService, + bodySchema: { + description: descBodyChaosServiceUpdate, + fields: [ + { name: "name", type: "string", required: true, description: descChaosServiceName }, + { name: "external_service_id", type: "string", required: true, description: descChaosServiceExternalServiceId }, + { name: "agent_id", type: "string", required: true, description: descChaosServiceAgentId }, + { name: "environment_id", type: "string", required: true, description: descChaosServiceEnvironmentId }, + { name: "infrastructure_id", type: "string", required: true, description: descChaosServiceInfrastructureId }, + { name: "description", type: "string", required: false, description: descChaosServiceDescription }, + { name: "tags", type: "array", required: false, description: descChaosServiceTagsBody }, + { name: "probes", type: "array", required: true, description: descChaosServiceProbesUpdate }, + ], + }, + }, + }, + executeActions: { + list_experiment_runs: { + method: "GET", + path: `${CHAOS}/v3/chaos-services/{identity}/experiment-runs`, + operationPolicy: { risk: "read", retryPolicy: "safe" }, + pathParams: { identity: "identity" }, + queryParams: { + page: "page", + limit: "limit", + size: "limit", + search: "search", + sort_field: "sortField", + sort_ascending: "sortAscending", + include_all_scope: "includeAllScope", + infra_ids: "infraIds", + statuses: "statuses", + step_types: "stepTypes", + }, + responseExtractor: passthrough, + actionDescription: descListChaosServiceExperimentRuns, + bodySchema: { description: descBodyNoBody, fields: [] }, + }, + list_load_tests: { + method: "GET", + path: `${CHAOS}/v3/chaos-services/{identity}/load-tests`, + operationPolicy: { risk: "read", retryPolicy: "safe" }, + pathParams: { identity: "identity" }, + queryParams: { + page: "page", + limit: "limit", + size: "limit", + search: "search", + sort_field: "sortField", + sort_ascending: "sortAscending", + include_all_scope: "includeAllScope", + tool_type: "toolType", + environment_ids: "environmentIds", + infra_ids: "infraIds", + tags: "tags", + }, + responseExtractor: passthrough, + actionDescription: descListChaosServiceLoadTests, + bodySchema: { description: descBodyNoBody, fields: [] }, + }, + }, + }, + // ── Chaos Kubernetes Infrastructure ────────────────────────────── { resourceType: "chaos_k8s_infrastructure", @@ -1969,7 +2614,13 @@ export const chaosToolset: ToolsetDefinition = { scope: "project", scopeParams: CHAOS_SCOPE, identifierFields: ["infra_id"], + deepLinkTemplate: "/ng/account/{accountId}/module/chaos/orgs/{orgIdentifier}/projects/{projectIdentifier}/settings/chaos/infrastructures/{infraId}", diagnosticHint: "An infrastructure can only create chaos experiments when status is ACTIVE and isChaosEnabled is true. Filter out any infrastructure that does not meet both conditions.", + relatedResources: [ + { resourceType: "chaos_environment", relationship: "scoped_by", description: "STEP 1 of create: pick the Harness environment. Its identifier becomes environment_id." }, + { resourceType: "infrastructure", relationship: "prerequisite", description: "STEP 2 of create: pick the backing CD Kubernetes infrastructure definition in that environment. Its identifier becomes infra_id (defaults to chaos identity when omitted)." }, + { resourceType: "chaos_enabled_infrastructure", relationship: "child", description: "After install completes, the registered infra appears here once status=ACTIVE and isChaosEnabled=true." }, + ], listFilterFields: [ { name: "environment_id", description: descEnvironmentId }, { name: "status", description: descK8sInfraStatus, enum: ["ACTIVE", "INACTIVE", "PENDING", "All"] }, @@ -2012,6 +2663,71 @@ export const chaosToolset: ToolsetDefinition = { responseExtractor: passthrough, description: descGetK8sInfra, }, + create: { + method: "POST", + path: `${CHAOS}/rest/v2/infrastructure`, + operationPolicy: { risk: "medium_write", retryPolicy: "do_not_retry" }, + bodyBuilder: (input) => { + const b = coerceBody(input); + const identity = b.identity as string | undefined; + const name = b.name as string | undefined; + const environmentId = (b.environment_id ?? b.environmentId) as string | undefined; + if (!identity) throw new Error("identity is required."); + if (!name) throw new Error("name is required."); + if (!environmentId) throw new Error("environment_id is required."); + + const infraId = (b.infra_id ?? b.infraId ?? identity) as string; + const infraNamespace = (b.infra_namespace ?? b.infraNamespace ?? "hce") as string; + const serviceAccount = (b.service_account ?? b.serviceAccount ?? "litmus") as string; + const infraScope = ((b.infra_scope ?? b.infraScope ?? "CLUSTER") as string).toUpperCase(); + const infraType = ((b.infra_type ?? b.infraType ?? "KUBERNETES") as string).toUpperCase(); + const k8sConnectorId = (b.k8s_connector_id ?? b.k8sConnectorID ?? b.k8sConnectorId) as string | undefined; + const discoveryAgentId = (b.discovery_agent_id ?? b.discoveryAgentID ?? b.discoveryAgentId) as string | undefined; + const aiEnabled = Boolean(b.ai_enabled ?? b.aiEnabled ?? false); + const rawTags = b.tags; + const tags: string[] = Array.isArray(rawTags) + ? (rawTags as string[]) + : typeof rawTags === "string" + ? rawTags.split(",").map((t) => t.trim()).filter(Boolean) + : []; + + return { + identity, + name, + environmentID: environmentId, + infraID: infraId, + infraNamespace, + serviceAccount, + infraScope, + infraType, + ...(b.description ? { description: b.description } : {}), + ...(tags.length > 0 ? { tags } : {}), + ...(k8sConnectorId ? { k8sConnectorID: k8sConnectorId } : {}), + ...(discoveryAgentId ? { discoveryAgentID: discoveryAgentId } : {}), + aiEnabled, + insecureSkipVerify: Boolean(b.insecure_skip_verify ?? b.insecureSkipVerify ?? false), + }; + }, + responseExtractor: passthrough, + description: descCreateK8sInfra, + bodySchema: { + description: descBodyK8sInfraCreate, + fields: [ + { name: "identity", type: "string", required: true, description: descK8sInfraIdentityCreate }, + { name: "name", type: "string", required: true, description: descK8sInfraNameCreate }, + { name: "environment_id", type: "string", required: true, description: descK8sInfraEnvironmentIdCreate }, + { name: "infra_id", type: "string", required: false, description: descK8sInfraInfraIdCreate }, + { name: "k8s_connector_id", type: "string", required: false, description: descK8sInfraConnectorIdCreate }, + { name: "infra_namespace", type: "string", required: false, description: descK8sInfraNamespaceCreate }, + { name: "service_account", type: "string", required: false, description: descK8sInfraServiceAccountCreate }, + { name: "infra_scope", type: "string", required: false, description: descK8sInfraScopeCreate }, + { name: "infra_type", type: "string", required: false, description: descK8sInfraTypeCreate }, + { name: "description", type: "string", required: false, description: "Optional description for the chaos server." }, + { name: "tags", type: "array", required: false, description: "Optional tags (array of strings or comma-separated string)." }, + { name: "ai_enabled", type: "boolean", required: false, description: descK8sInfraAiEnabledCreate }, + ], + }, + }, }, executeActions: { check_health: { @@ -3288,6 +4004,61 @@ export const chaosToolset: ToolsetDefinition = { }, }, + // ── Service Discovery: Agents ────────────────────────────────────── + // GET /gateway/servicediscovery/api/v1/agents (service-discovery repo). + // Envelope matches the other SD list endpoints: + // { items, page: { totalItems, ... }, correlationID } + // so we reuse sdPageExtract. The Go handler treats environmentIdentifier + // as an optional narrowing filter even though swagger marks it required — + // omit to enumerate agents across every environment in the current scope. + { + resourceType: "discovered_agent", + displayName: "Discovered Agent", + description: descDiscoveredAgent, + toolset: "chaos", + scope: "project", + scopeParams: CHAOS_SCOPE, + identifierFields: ["identity"], + compactItem: compactDiscoveredAgent, + searchAliases: [ + "service discovery agent", "sd agent", "discovery agent", + "chaos discovery agent", + ], + listFilterFields: [ + { name: "environment_id", description: descSDAgentListEnvironmentId }, + { name: "search", description: descDiscoveredAgentSearch }, + { name: "all", type: "boolean", description: descSDFetchAll }, + ], + relatedResources: [ + { + resourceType: "discovered_namespace", + relationship: "produces", + description: "The 'identity' field returned here is the value discovered_namespace / discovered_service / discovered_network_map take as agent_identity.", + }, + { + resourceType: "chaos_service", + relationship: "referenced_by", + description: "Use an agent's 'identity' as the agent_id field when creating or updating a chaos_service.", + }, + ], + operations: { + list: { + method: "GET", + path: `${SD}/agents`, + operationPolicy: { risk: "read", retryPolicy: "safe" }, + queryParams: { + environment_id: "environmentIdentifier", + search: "search", + page: "page", + size: "limit", // SD uses `limit`, not `size` + all: "all", + }, + responseExtractor: sdPageExtract, + description: descListDiscoveredAgents, + }, + }, + }, + // ── Service Discovery: Namespaces ────────────────────────────────── { resourceType: "discovered_namespace", diff --git a/src/utils/url-parser.ts b/src/utils/url-parser.ts index e72785a9e..031c0685c 100644 --- a/src/utils/url-parser.ts +++ b/src/utils/url-parser.ts @@ -69,6 +69,7 @@ const RESOURCE_SEGMENTS: Record.tunables / toolConfig..variables. The + * MCP surface stays ergonomic (snake_case scalars) but the wire body is now a + * nested toolConfig map — never top-level `inputs[]` or `scriptContent`. K6 is + * Kubernetes-only; JMeter is passthrough via `tool_config`. */ import { describe, it, expect, vi, beforeEach } from "vitest"; import YAML from "yaml"; @@ -42,13 +41,6 @@ function makeClient(requestFn?: (...args: unknown[]) => unknown): HarnessClient } as unknown as HarnessClient; } -type LoadtestInput = { - name: string; - value: number | string; - type: "Integer" | "String"; - required?: true; -}; - const SCRIPT = `from locust import HttpUser, task, between @@ -61,34 +53,22 @@ class GoogleUser(HttpUser): `; const K6_SCRIPT = `/** - * main.js - Sample JavaScript file with an entry function + * main.js */ - -function greet(name) { - return \`Hello, \${name}!\`; -} - -function add(a, b) { - return a + b; -} - -// Entry point export default function main() { - console.log(greet("World")); - console.log("2 + 3 =", add(2, 3)); + console.log("hi"); } - -main(); `; -describe("chaos_loadtest create", () => { +// ── Locust create ──────────────────────────────────────────────────── +describe("chaos_loadtest create (Locust)", () => { let registry: Registry; beforeEach(() => { registry = new Registry(makeConfig()); }); - it("create (Linux VM, inline script): mirrors verified curl 3 — inputs[], base64 scriptContent, base64 yaml manifest", async () => { - const mockRequest = vi.fn().mockResolvedValue({ identity: "locust3", name: "locust-3" }); + it("Linux VM inline: nests script + tunables under toolConfig.locust, no top-level scriptContent/inputs", async () => { + const mockRequest = vi.fn().mockResolvedValue({ identity: "locust3" }); const client = makeClient(mockRequest); await registry.dispatch(client, "chaos_loadtest", "create", { @@ -97,856 +77,1131 @@ describe("chaos_loadtest create", () => { name: "locust-3", identity: "locust3", environment_id: "env91x", - infra_id: "79608c02-f1ed-46c0-8551-483509d68111", + infra_id: "infra-1", target_url: "http://www.example.com", script: SCRIPT, target_type: "machine-chaos-linux", + users: 50, + duration_sec: 300, + ramp_up_sec: 30, + spawn_rate: 5, }); - expect(mockRequest).toHaveBeenCalledOnce(); const call = mockRequest.mock.calls[0][0]; expect(call.method).toBe("POST"); expect(call.path).toBe("/loadTest/manager/api/v1/load-tests"); - - // Scope goes in query params as organizationIdentifier (NOT orgIdentifier). + // Scope stays in query params as organizationIdentifier (not orgIdentifier). expect(call.params.organizationIdentifier).toBe("templatescopetest"); - expect(call.params.projectIdentifier).toBe("templatescopetest"); expect(call.params.orgIdentifier).toBeUndefined(); - // Top-level wire shape — NEW canonical form. - expect(call.body).toMatchObject({ + const body = call.body; + expect(body).toMatchObject({ identity: "locust3", name: "locust-3", - description: "", - tags: [], environmentIdentifier: "env91x", - infraIdentifier: "79608c02-f1ed-46c0-8551-483509d68111", - scriptSource: "inline", + infraIdentifier: "infra-1", targetType: "machine-chaos-linux", toolType: "Locust", }); - expect(call.body.scriptContent).toBe(Buffer.from(SCRIPT, "utf8").toString("base64")); - - // Legacy fields are gone. - expect(call.body.defaultUsers).toBeUndefined(); - expect(call.body.defaultDurationSec).toBeUndefined(); - expect(call.body.defaultRampUpTimeSec).toBeUndefined(); - expect(call.body.variables).toBeUndefined(); - expect(call.body.targetUrl).toBeUndefined(); // moved into inputs[] - - // inputs[] in canonical order (no workerCount on Linux), exact shape per curl 3. - expect(call.body.inputs).toEqual([ - { name: "targetUsers", value: 100, type: "Integer", required: true }, - { name: "durationSeconds", value: 600, type: "Integer" }, - { name: "rampUpTimeSec", value: 120, type: "Integer" }, - { name: "targetUrl", value: "http://www.example.com", type: "String" }, - ]); + // Legacy top-level fields must be gone. + expect(body.scriptContent).toBeUndefined(); + expect(body.scriptSource).toBeUndefined(); + expect(body.inputs).toBeUndefined(); + + // toolConfig.locust: script.content is base64, tunables mirrors the request. + const tc = body.toolConfig as Record; + expect(Object.keys(tc)).toEqual(["locust"]); + const locust = tc.locust as Record; + expect(locust.mode).toBe("script"); + expect(locust.script).toEqual({ + content: Buffer.from(SCRIPT, "utf8").toString("base64"), + }); + expect(locust.tunables).toEqual({ + targetUrl: "http://www.example.com", + targetUsers: 50, + spawnRate: 5, + durationSeconds: 300, + rampUpTimeSec: 30, + }); - // yaml field is base64; decode and assert canonical manifest shape. - const yamlText = Buffer.from(call.body.yaml as string, "base64").toString("utf8"); - const manifest = YAML.parse(yamlText); - expect(manifest.kind).toBe("LoadTest"); - expect(manifest.apiVersion).toBe("v1alpha1"); - expect(manifest.name).toBe("locust-3"); + // YAML manifest is base64; decoded shape carries plain-text script content. + const manifest = YAML.parse(Buffer.from(body.yaml as string, "base64").toString("utf8")); expect(manifest.spec).toMatchObject({ identity: "locust3", toolType: "Locust", infraType: "linux", targetType: "machine-chaos-linux", - scriptSource: "inline", - infraId: "79608c02-f1ed-46c0-8551-483509d68111", envId: "env91x", + infraId: "infra-1", }); - // YAML scriptContent is PLAIN TEXT (not base64). - expect(manifest.spec.scriptContent).toBe(SCRIPT); - expect(manifest.spec.inputs).toEqual(call.body.inputs); - - // skipScopeBodyInjection: scope must NOT be injected into the JSON body. - expect(call.body.orgIdentifier).toBeUndefined(); - expect(call.body.organizationIdentifier).toBeUndefined(); - expect(call.body.projectIdentifier).toBeUndefined(); + expect(manifest.spec.scriptContent).toBeUndefined(); + expect(manifest.spec.inputs).toBeUndefined(); + expect(manifest.spec.toolConfig.locust.script.content).toBe(SCRIPT); + expect(manifest.spec.toolConfig.locust.tunables.targetUsers).toBe(50); + + // skipScopeBodyInjection: no scope in body. + expect(body.orgIdentifier).toBeUndefined(); + expect(body.organizationIdentifier).toBeUndefined(); + expect(body.projectIdentifier).toBeUndefined(); }); - it("create (Kubernetes inline): mirrors verified curl 1 — inputs[workerCount=1], no variables map", async () => { - const mockRequest = vi.fn().mockResolvedValue({ identity: "locust1" }); + it("Kubernetes image (private registry): nests script.{image,entrypoint,loadArgs,imagePullSecret} under toolConfig.locust", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); const client = makeClient(mockRequest); - await registry.dispatch(client, "chaos_loadtest", "create", { - org_id: "templatescopetest", - project_id: "templatescopetest", - name: "locust-1", - identity: "locust1", - description: "op desc", - tags: ["op:1", "op:2"], - environment_id: "ashloadtest", - infra_id: "deletelater1", + org_id: "o", project_id: "p", + name: "img-lt", environment_id: "e", infra_id: "i", target_url: "http://www.example.com", - script: SCRIPT, target_type: "kubernetes", + script_source: "image", + script_image: "my-registry/locust:latest", + script_entrypoint: "/script/xyz", + load_args: "tags=smoke", + image_pull_secret: "Some secret name", worker_count: 1, }); - const body = mockRequest.mock.calls[0][0].body; - expect(body.targetType).toBe("kubernetes"); - expect(body.scriptSource).toBe("inline"); - expect(body.description).toBe("op desc"); - expect(body.tags).toEqual(["op:1", "op:2"]); - expect(body.variables).toBeUndefined(); // legacy {workerCount: {...}} map is gone - - expect(body.inputs).toEqual([ - { name: "targetUsers", value: 100, type: "Integer", required: true }, - { name: "durationSeconds", value: 600, type: "Integer" }, - { name: "rampUpTimeSec", value: 120, type: "Integer" }, - { name: "workerCount", value: 1, type: "Integer" }, - { name: "targetUrl", value: "http://www.example.com", type: "String" }, - ]); + expect(body.scriptContent).toBeUndefined(); + expect(body.inputs).toBeUndefined(); + const locust = (body.toolConfig as Record).locust as Record; + expect(locust.mode).toBe("image"); + expect(locust.script).toEqual({ + image: "my-registry/locust:latest", + entrypoint: "/script/xyz", + loadArgs: "tags=smoke", + imagePullSecret: "Some secret name", + }); + expect(locust.tunables).toEqual({ + targetUrl: "http://www.example.com", + workerCount: 1, + }); - const manifest = YAML.parse(Buffer.from(body.yaml as string, "base64").toString("utf8")); - expect(manifest.description).toBe("op desc"); - expect(manifest.tags).toEqual(["op:1", "op:2"]); - expect(manifest.spec.infraType).toBe("kubernetes"); - expect(manifest.spec.targetType).toBe("kubernetes"); - expect(manifest.spec.inputs).toEqual(body.inputs); + // YAML manifest carries the same script scalars (plain text on the YAML side). + const yamlB64 = body.yaml as string; + const manifest = YAML.parse(Buffer.from(yamlB64, "base64").toString("utf8")); + expect(manifest.spec.toolConfig.locust.script).toEqual({ + image: "my-registry/locust:latest", + entrypoint: "/script/xyz", + loadArgs: "tags=smoke", + imagePullSecret: "Some secret name", + }); }); - it("create (Kubernetes Custom Image): mirrors verified curl 2 — image inputs[] entries, no scriptContent", async () => { - const mockRequest = vi.fn().mockResolvedValue({ identity: "locust2" }); + it("Kubernetes image (public registry): omits imagePullSecret and loadArgs when not supplied", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); const client = makeClient(mockRequest); - await registry.dispatch(client, "chaos_loadtest", "create", { - org_id: "templatescopetest", - project_id: "templatescopetest", - name: "locust-2", - identity: "locust2", - environment_id: "ashloadtest", - infra_id: "expertdatest1", - target_url: "http://www.example.com", + org_id: "o", project_id: "p", + name: "img-lt-public", environment_id: "e", infra_id: "i", target_type: "kubernetes", script_source: "image", - script_image: "imageregistry", - script_entrypoint: "/script/xyz", - load_args: "tags=smoke", - worker_count: 1, + script_image: "my-registry/locust:latest", }); - const body = mockRequest.mock.calls[0][0].body; - expect(body.scriptSource).toBe("image"); - expect(body.scriptContent).toBeUndefined(); - // Legacy top-level image fields gone. - expect(body.scriptImage).toBeUndefined(); - expect(body.scriptEntrypoint).toBeUndefined(); - expect(body.loadArgs).toBeUndefined(); - - expect(body.inputs).toEqual([ - { name: "targetUsers", value: 100, type: "Integer", required: true }, - { name: "durationSeconds", value: 600, type: "Integer" }, - { name: "rampUpTimeSec", value: 120, type: "Integer" }, - { name: "workerCount", value: 1, type: "Integer" }, - { name: "targetUrl", value: "http://www.example.com", type: "String" }, - { name: "scriptImage", value: "imageregistry", type: "String", required: true }, - { name: "scriptEntrypoint", value: "/script/xyz", type: "String", required: true }, - { name: "loadArgs", value: "tags=smoke", type: "String" }, - ]); - - const manifest = YAML.parse(Buffer.from(body.yaml as string, "base64").toString("utf8")); - expect(manifest.spec.scriptSource).toBe("image"); - // Image mode → no scriptContent in YAML (the script lives in the container image). - expect(manifest.spec.scriptContent).toBeUndefined(); - expect(manifest.spec.inputs).toEqual(body.inputs); + const locust = (body.toolConfig as Record).locust as Record; + expect(locust.mode).toBe("image"); + expect(locust.script).toEqual({ image: "my-registry/locust:latest" }); + const script = locust.script as Record; + expect(script.imagePullSecret).toBeUndefined(); + expect(script.loadArgs).toBeUndefined(); }); - it("create: derives identity from name (alphanumeric) when omitted", async () => { - const mockRequest = vi.fn().mockResolvedValue({ identity: "testloadone" }); + it("Kubernetes image: rejects load_args whose keys start with '-'", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); const client = makeClient(mockRequest); + await expect( + registry.dispatch(client, "chaos_loadtest", "create", { + org_id: "o", project_id: "p", + name: "img-lt-bad", environment_id: "e", infra_id: "i", + target_type: "kubernetes", + script_source: "image", + script_image: "my-registry/locust:latest", + load_args: "--headless", + }), + ).rejects.toThrow(/must not start with '-'/); + expect(mockRequest).not.toHaveBeenCalled(); + }); + it("passes optional service_references / cleanup_policy / max_duration_sec / resources through", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); await registry.dispatch(client, "chaos_loadtest", "create", { org_id: "o", project_id: "p", - name: "test-load-one", - environment_id: "env90x", - infra_id: "infra-1", - target_url: "https://example.com", - script: SCRIPT, + name: "lt", environment_id: "e", infra_id: "i", + target_url: "https://example.com", script: SCRIPT, + service_references: ["svc-1", "svc-2"], + cleanup_policy: "retain", + max_duration_sec: 900, + resources: { + requests: { cpu: "100m", memory: "256Mi" }, + limits: { cpu: "500m", memory: "512Mi" }, + }, + }); + const body = mockRequest.mock.calls[0][0].body; + expect(body.serviceReferences).toEqual(["svc-1", "svc-2"]); + expect(body.cleanupPolicy).toBe("retain"); + expect(body.maxDurationSec).toBe(900); + expect(body.resources).toMatchObject({ + requests: { cpu: "100m", memory: "256Mi" }, }); - expect(mockRequest.mock.calls[0][0].body.identity).toBe("testloadone"); + const manifest = YAML.parse(Buffer.from(body.yaml as string, "base64").toString("utf8")); + expect(manifest.spec.cleanupPolicy).toBe("retain"); + expect(manifest.spec.resources).toEqual({ + requests: { cpu: "100m", memory: "256Mi" }, + limits: { cpu: "500m", memory: "512Mi" }, + }); }); - it("create: applies load-config defaults (100/600/120) in inputs[] when omitted", async () => { + it("stores variables under toolConfig..variables", async () => { const mockRequest = vi.fn().mockResolvedValue({}); const client = makeClient(mockRequest); - await registry.dispatch(client, "chaos_loadtest", "create", { org_id: "o", project_id: "p", name: "lt", environment_id: "e", infra_id: "i", target_url: "https://example.com", script: SCRIPT, + variables: [{ name: "API_KEY", type: "String", value: "abc" }], }); - - const body = mockRequest.mock.calls[0][0].body; - const byName = Object.fromEntries( - (body.inputs as LoadtestInput[]).map((i) => [i.name, i.value]), - ); - expect(byName.targetUsers).toBe(100); - expect(byName.durationSeconds).toBe(600); - expect(byName.rampUpTimeSec).toBe(120); + const locust = (mockRequest.mock.calls[0][0].body.toolConfig as Record) + .locust as Record; + expect(locust.variables).toEqual([{ name: "API_KEY", type: "String", value: "abc" }]); }); - it("create: zero-valued load config survives in inputs[] (!= null, not truthiness)", async () => { + it("derives identity from name when omitted", async () => { const mockRequest = vi.fn().mockResolvedValue({}); const client = makeClient(mockRequest); - await registry.dispatch(client, "chaos_loadtest", "create", { org_id: "o", project_id: "p", - name: "lt", environment_id: "e", infra_id: "i", + name: "My Load Test", + environment_id: "e", infra_id: "i", target_url: "https://example.com", script: SCRIPT, - users: 0, duration_sec: 0, ramp_up_sec: 0, }); + expect(mockRequest.mock.calls[0][0].body.identity).toBe("MyLoadTest"); + }); - const body = mockRequest.mock.calls[0][0].body; - const byName = Object.fromEntries( - (body.inputs as LoadtestInput[]).map((i) => [i.name, i.value]), - ); - expect(byName.targetUsers).toBe(0); - expect(byName.durationSeconds).toBe(0); - expect(byName.rampUpTimeSec).toBe(0); + it("rejects deprecated tool_type='Gatling' / 'Custom'", async () => { + const client = makeClient(vi.fn()); + for (const t of ["Gatling", "Custom"]) { + await expect( + registry.dispatch(client, "chaos_loadtest", "create", { + org_id: "o", project_id: "p", + name: "lt", tool_type: t, environment_id: "e", infra_id: "i", + target_url: "https://example.com", script: SCRIPT, + }), + ).rejects.toThrow(/Locust.*K6.*JMeter/); + } + }); +}); + +// ── K6 create ───────────────────────────────────────────────────────── +describe("chaos_loadtest create (K6)", () => { + let registry: Registry; + beforeEach(() => { + registry = new Registry(makeConfig()); }); - it("create (Kubernetes): worker_count emitted as inputs[name=workerCount]", async () => { + it("script mode: nests script.content + tunables under toolConfig.k6, envVars carry literal + secret refs", async () => { const mockRequest = vi.fn().mockResolvedValue({}); const client = makeClient(mockRequest); - await registry.dispatch(client, "chaos_loadtest", "create", { org_id: "o", project_id: "p", - name: "k8s-lt", environment_id: "e", infra_id: "i", - target_url: "https://example.com", script: SCRIPT, - target_type: "kubernetes", worker_count: 3, + tool_type: "K6", + name: "k6-1", environment_id: "e", infra_id: "i", + target_url: "http://www.google.com", + script: K6_SCRIPT, + target_type: "kubernetes", + worker_count: 3, + rps_limit: 98, + iterations: 42, + env_vars: [ + { key: "var1", value: "static" }, + { key: "SECRET_VAR", secret_id: "vcenter", secret_scope: "project" }, + ], }); const body = mockRequest.mock.calls[0][0].body; - expect(body.targetType).toBe("kubernetes"); - const workerEntry = (body.inputs as LoadtestInput[]).find((i) => i.name === "workerCount"); - expect(workerEntry).toEqual({ name: "workerCount", value: 3, type: "Integer" }); - // No legacy variables map. - expect(body.variables).toBeUndefined(); + expect(body.toolType).toBe("K6"); + expect(body.scriptContent).toBeUndefined(); + expect(body.inputs).toBeUndefined(); + + const k6 = (body.toolConfig as Record).k6 as Record; + expect(k6.mode).toBe("script"); + expect(k6.script).toEqual({ + content: Buffer.from(K6_SCRIPT, "utf8").toString("base64"), + }); + expect(k6.tunables).toMatchObject({ + targetUrl: "http://www.google.com", + workerCount: 3, + hostUrl: "http://www.google.com", + iterations: 42, + rpsLimit: 98, + }); + expect(k6.options).toBeUndefined(); + expect(k6.envVars).toEqual([ + { key: "var1", value: "static" }, + { key: "SECRET_VAR", value: 'secrets.getValue("vcenter")', secret: true }, + ]); + // Legacy flat keys must NOT appear on the toolConfig.k6 block. + expect(k6.scriptContent).toBeUndefined(); + expect(k6.customImage).toBeUndefined(); + expect(k6.hostUrl).toBeUndefined(); + + // Decoded YAML mirrors the wire toolConfig but with plain-text script content. + const manifest = YAML.parse(Buffer.from(body.yaml as string, "base64").toString("utf8")); + expect(manifest.spec.toolConfig.k6.mode).toBe("script"); + expect(manifest.spec.toolConfig.k6.script.content).toBe(K6_SCRIPT); + expect(manifest.spec.toolConfig.k6.tunables.workerCount).toBe(3); + expect(manifest.spec.toolConfig.k6.tunables.rpsLimit).toBe(98); }); - it("create: Linux VM does NOT emit workerCount in inputs[]", async () => { + it("image mode: nests script.image + entrypoint under toolConfig.k6.script", async () => { const mockRequest = vi.fn().mockResolvedValue({}); const client = makeClient(mockRequest); - await registry.dispatch(client, "chaos_loadtest", "create", { org_id: "o", project_id: "p", - name: "lt", environment_id: "e", infra_id: "i", - target_url: "https://example.com", script: SCRIPT, - worker_count: 3, // even when explicitly set on Linux, it should NOT show up. + tool_type: "K6", + name: "k6-2", environment_id: "e", infra_id: "i", + target_url: "http://www.example.com", + target_type: "kubernetes", + script_source: "image", + script_image: "my-image", + script_entrypoint: "/entrypoint.sh", }); - - const body = mockRequest.mock.calls[0][0].body; - const workerEntry = (body.inputs as LoadtestInput[]).find((i) => i.name === "workerCount"); - expect(workerEntry).toBeUndefined(); + const k6 = (mockRequest.mock.calls[0][0].body.toolConfig as Record) + .k6 as Record; + expect(k6.mode).toBe("image"); + expect(k6.script).toEqual({ image: "my-image", entrypoint: "/entrypoint.sh" }); + expect(k6.scriptContent).toBeUndefined(); + expect(k6.customImage).toBeUndefined(); }); - it("create (Kubernetes): worker_count defaults to 0 in inputs[] when omitted", async () => { + it("image mode (private registry): nests script.{image,entrypoint,loadArgs,imagePullSecret} under toolConfig.k6", async () => { const mockRequest = vi.fn().mockResolvedValue({}); const client = makeClient(mockRequest); - await registry.dispatch(client, "chaos_loadtest", "create", { org_id: "o", project_id: "p", - name: "k8s-lt", environment_id: "e", infra_id: "i", - target_url: "https://example.com", script: SCRIPT, + tool_type: "K6", + name: "k6-img-priv", environment_id: "e", infra_id: "i", target_type: "kubernetes", + script_source: "image", + script_image: "my-registry/k6:latest", + script_entrypoint: "/script.js", + load_args: "tags=smoke", + image_pull_secret: "Some secret name", + worker_count: 1, }); - const body = mockRequest.mock.calls[0][0].body; - const workerEntry = (body.inputs as LoadtestInput[]).find((i) => i.name === "workerCount"); - expect(workerEntry).toEqual({ name: "workerCount", value: 0, type: "Integer" }); + const k6 = (body.toolConfig as Record).k6 as Record; + expect(k6.mode).toBe("image"); + expect(k6.script).toEqual({ + image: "my-registry/k6:latest", + entrypoint: "/script.js", + loadArgs: "tags=smoke", + imagePullSecret: "Some secret name", + }); + + const manifest = YAML.parse(Buffer.from(body.yaml as string, "base64").toString("utf8")); + expect(manifest.spec.toolConfig.k6.script).toEqual({ + image: "my-registry/k6:latest", + entrypoint: "/script.js", + loadArgs: "tags=smoke", + imagePullSecret: "Some secret name", + }); }); - it("create: rejects when target_url is missing", async () => { + it("image mode (public registry): omits imagePullSecret and loadArgs when not supplied", async () => { const mockRequest = vi.fn().mockResolvedValue({}); const client = makeClient(mockRequest); + await registry.dispatch(client, "chaos_loadtest", "create", { + org_id: "o", project_id: "p", + tool_type: "K6", + name: "k6-img-public", environment_id: "e", infra_id: "i", + target_type: "kubernetes", + script_source: "image", + script_image: "my-registry/k6:latest", + }); + const k6 = (mockRequest.mock.calls[0][0].body.toolConfig as Record) + .k6 as Record; + const script = k6.script as Record; + expect(script.image).toBe("my-registry/k6:latest"); + expect(script.imagePullSecret).toBeUndefined(); + expect(script.loadArgs).toBeUndefined(); + }); + it("image mode: rejects load_args whose keys start with '-'", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); await expect( registry.dispatch(client, "chaos_loadtest", "create", { org_id: "o", project_id: "p", - name: "lt", environment_id: "e", infra_id: "i", script: SCRIPT, + tool_type: "K6", + name: "k6-img-bad", environment_id: "e", infra_id: "i", + target_type: "kubernetes", + script_source: "image", + script_image: "my-registry/k6:latest", + load_args: "--headless", }), - ).rejects.toThrow(/target_url/); + ).rejects.toThrow(/must not start with '-'/); expect(mockRequest).not.toHaveBeenCalled(); }); - it("create: rejects when the inline script is missing", async () => { - const mockRequest = vi.fn().mockResolvedValue({}); - const client = makeClient(mockRequest); - + it("rejects target_type='machine-chaos-linux'", async () => { + const client = makeClient(vi.fn()); await expect( registry.dispatch(client, "chaos_loadtest", "create", { - org_id: "o", project_id: "p", - name: "lt", environment_id: "e", infra_id: "i", - target_url: "https://example.com", + org_id: "o", project_id: "p", tool_type: "K6", + name: "k6-lt", environment_id: "e", infra_id: "i", + target_url: "https://example.com", script: K6_SCRIPT, + target_type: "machine-chaos-linux", }), - ).rejects.toThrow(/script/); - expect(mockRequest).not.toHaveBeenCalled(); + ).rejects.toThrow(/K6.*kubernetes/); }); - it("create: image mode is inferred from script_image when script_source is omitted", async () => { + it("passes cleanup_policy / resources through and emits them into the built YAML (tool-agnostic)", async () => { const mockRequest = vi.fn().mockResolvedValue({}); const client = makeClient(mockRequest); - await registry.dispatch(client, "chaos_loadtest", "create", { org_id: "o", project_id: "p", - name: "img-lt", environment_id: "e", infra_id: "i", - target_url: "https://example.com", + tool_type: "K6", + name: "k6-resources", environment_id: "e", infra_id: "i", target_type: "kubernetes", - script_image: "my-registry/my-load-test:latest", + script: K6_SCRIPT, + cleanup_policy: "retain", + resources: { + limits: { cpu: "0.5", memory: "1Gi" }, + requests: { cpu: "100m", memory: "128Mi" }, + }, }); - const body = mockRequest.mock.calls[0][0].body; - expect(body.scriptSource).toBe("image"); - expect(body.scriptContent).toBeUndefined(); - const imageEntry = (body.inputs as LoadtestInput[]).find((i) => i.name === "scriptImage"); - expect(imageEntry).toEqual({ - name: "scriptImage", - value: "my-registry/my-load-test:latest", - type: "String", - required: true, + expect(body.cleanupPolicy).toBe("retain"); + expect(body.resources).toEqual({ + limits: { cpu: "0.5", memory: "1Gi" }, + requests: { cpu: "100m", memory: "128Mi" }, }); + const manifest = YAML.parse(Buffer.from(body.yaml as string, "base64").toString("utf8")); + expect(manifest.spec.cleanupPolicy).toBe("retain"); + expect(manifest.spec.resources).toEqual(body.resources); }); - it("create: rejects image mode when script_image is missing", async () => { - const mockRequest = vi.fn().mockResolvedValue({}); - const client = makeClient(mockRequest); - + it("rejects K6 script missing 'export default'", async () => { + const client = makeClient(vi.fn()); await expect( registry.dispatch(client, "chaos_loadtest", "create", { - org_id: "o", project_id: "p", - name: "lt", environment_id: "e", infra_id: "i", + org_id: "o", project_id: "p", tool_type: "K6", + name: "k6-lt", environment_id: "e", infra_id: "i", target_url: "https://example.com", - target_type: "kubernetes", script_source: "image", + script: "function main() { /* no default export */ }", + target_type: "kubernetes", }), - ).rejects.toThrow(/script_image/); - expect(mockRequest).not.toHaveBeenCalled(); + ).rejects.toThrow(/export default function/); + }); +}); + +// ── JMeter create ───────────────────────────────────────────────────── +describe("chaos_loadtest create (JMeter)", () => { + let registry: Registry; + beforeEach(() => { + registry = new Registry(makeConfig()); }); - it("create: display name is permissive; identity is auto-slugged when omitted", async () => { + it("passes tool_config through verbatim under toolConfig.jmeter (advanced escape hatch)", async () => { const mockRequest = vi.fn().mockResolvedValue({}); const client = makeClient(mockRequest); + const jmeter = { + mode: "script", + script: { content: "PD94bWwgdmVyc2lvbj0iMS4wIj8+" }, + tunables: { targetUsers: 10, durationSeconds: 60 }, + }; + await registry.dispatch(client, "chaos_loadtest", "create", { + org_id: "o", project_id: "p", + tool_type: "JMeter", + name: "jm-1", environment_id: "e", infra_id: "i", + target_type: "kubernetes", + tool_config: { jmeter }, + }); + const tc = mockRequest.mock.calls[0][0].body.toolConfig as Record; + expect(tc.jmeter).toEqual(jmeter); + }); + it("script mode: base64-encodes plan into toolConfig.jmeter.script.content, wires properties/env_vars/thresholds/worker_count", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + const plan = ``; await registry.dispatch(client, "chaos_loadtest", "create", { org_id: "o", project_id: "p", - name: "My Load Test", - environment_id: "e", infra_id: "i", - target_url: "https://example.com", script: SCRIPT, + tool_type: "JMeter", + name: "jm-script", environment_id: "e", infra_id: "i", + target_type: "kubernetes", + script: plan, + worker_count: 2, + properties: [ + { key: "threads1", value: 100, send_to_engines: true }, + { key: "someProperty", value: "200" }, + ], + env_vars: [ + { key: "fixed", value: "somefixed" }, + { key: "runtime", value: "<+input>" }, + { key: "somekey", secret_id: "datat-api", secret_scope: "project" }, + ], + thresholds: [ + { metric: "response_time_ms", stat: "p95", operator: "<", value: 5000 }, + { metric: "error_rate_pct", operator: "<=", value: 50, abort_on_fail: true }, + { metric: "throughput_rps", stat: "p99", operator: ">", value: 5000 }, + ], }); const body = mockRequest.mock.calls[0][0].body; - expect(body.name).toBe("My Load Test"); - expect(body.identity).toBe("MyLoadTest"); - }); -}); + expect(body.toolType).toBe("JMeter"); + expect(body.scriptContent).toBeUndefined(); + const jm = (body.toolConfig as Record).jmeter as Record; + expect(jm.mode).toBe("script"); + expect(jm.script).toEqual({ + content: Buffer.from(plan, "utf8").toString("base64"), + }); + expect(jm.tunables).toEqual({ workerCount: 2 }); + expect(jm.properties).toEqual([ + { key: "threads1", value: "100", sendToEngines: true }, + { key: "someProperty", value: "200" }, + ]); + expect(jm.envVars).toEqual([ + { key: "fixed", value: "somefixed" }, + { key: "runtime", value: "<+input>" }, + { key: "somekey", value: 'secrets.getValue("datat-api")', secret: true }, + ]); + expect(jm.thresholds).toEqual([ + { metric: "response_time_ms", stat: "p95", operator: "<", value: 5000 }, + { metric: "error_rate_pct", operator: "<=", value: 50, abortOnFail: true }, + { metric: "throughput_rps", stat: "p99", operator: ">", value: 5000 }, + ]); -describe("chaos_loadtest list/get", () => { - let registry: Registry; - beforeEach(() => { - registry = new Registry(makeConfig()); + const manifest = YAML.parse(Buffer.from(body.yaml as string, "base64").toString("utf8")); + expect(manifest.spec.toolConfig.jmeter.mode).toBe("script"); + expect(manifest.spec.toolConfig.jmeter.script.content).toBe(plan); + expect(manifest.spec.toolConfig.jmeter.tunables.workerCount).toBe(2); }); - it("list: extracts { items, total } and projects a stable shape (drops user-details + scriptContent)", async () => { - const mockRequest = vi.fn().mockResolvedValue({ - items: [ - { - identity: "testloadone", - name: "test-load-one", - environmentIdentifier: "env90x", - infraIdentifier: "infra-1", - targetType: "machine-chaos-linux", - toolType: "Locust", - scriptSource: "inline", - scriptContent: "BIG_BASE64_BLOB", - createdByUserDetails: { name: "someone", email: "a@b.com" }, - inputs: [ - { name: "targetUsers", value: 100, type: "Integer", required: true }, - { name: "targetUrl", value: "http://www.example.com", type: "String" }, - ], - variables: [], - }, + it("image mode (private registry): nests script.{image,entrypoint,loadArgs,imagePullSecret} + extras under toolConfig.jmeter", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + await registry.dispatch(client, "chaos_loadtest", "create", { + org_id: "o", project_id: "p", + tool_type: "JMeter", + name: "jm-update-3", identity: "jmupdate3", + environment_id: "env91x", infra_id: "test", + target_type: "kubernetes", + script_source: "image", + script_image: "<+input>", + script_entrypoint: "<+input>", + load_args: "proxyHost=8080", + image_pull_secret: "someSecretName", + worker_count: 1, + properties: [ + { key: "threads1", value: "100", send_to_engines: true }, + { key: "someProperty", value: "200" }, + ], + env_vars: [ + { key: "somekey", secret_id: "datat-api", secret_scope: "project" }, + { key: "runtime", value: "<+input>" }, + { key: "fixed", value: "somefixed" }, + ], + thresholds: [ + { metric: "response_time_ms", stat: "p95", operator: "<", value: 5000 }, + { metric: "error_rate_pct", operator: "<=", value: 50, abort_on_fail: true }, + { metric: "throughput_rps", stat: "p99", operator: ">", value: 5000 }, ], - pagination: { totalItems: 5 }, }); - const client = makeClient(mockRequest); - - const result = (await registry.dispatch(client, "chaos_loadtest", "list", { - org_id: "templatescopetest", - project_id: "templatescopetest", - })) as { items: Array>; total: number }; - const call = mockRequest.mock.calls[0][0]; - expect(call.method).toBe("GET"); - expect(call.path).toBe("/loadTest/manager/api/v1/load-tests"); - expect(call.params.organizationIdentifier).toBe("templatescopetest"); + const body = mockRequest.mock.calls[0][0].body; + const jm = (body.toolConfig as Record).jmeter as Record; + expect(jm.mode).toBe("image"); + expect(jm.script).toEqual({ + image: "<+input>", + entrypoint: "<+input>", + loadArgs: "proxyHost=8080", + imagePullSecret: "someSecretName", + }); + expect(jm.tunables).toEqual({ workerCount: 1 }); + expect(jm.properties).toEqual([ + { key: "threads1", value: "100", sendToEngines: true }, + { key: "someProperty", value: "200" }, + ]); + expect(jm.envVars).toEqual([ + { key: "somekey", value: 'secrets.getValue("datat-api")', secret: true }, + { key: "runtime", value: "<+input>" }, + { key: "fixed", value: "somefixed" }, + ]); + expect(jm.thresholds).toEqual([ + { metric: "response_time_ms", stat: "p95", operator: "<", value: 5000 }, + { metric: "error_rate_pct", operator: "<=", value: 50, abortOnFail: true }, + { metric: "throughput_rps", stat: "p99", operator: ">", value: 5000 }, + ]); - expect(result.total).toBe(5); - expect(result.items).toHaveLength(1); - const item = result.items[0]; - expect(item.loadtestId).toBe("testloadone"); - expect(item.identity).toBe("testloadone"); - expect(item.name).toBe("test-load-one"); - // Large / opaque fields are dropped from the projected shape. - expect(item.scriptContent).toBeUndefined(); - expect(item.createdByUserDetails).toBeUndefined(); - // Canonical inputs[] passes through; derived target_url + users are surfaced. - expect(item.inputs).toHaveLength(2); - expect(item.users).toBe(100); - expect(item.target_url).toBe("http://www.example.com"); + const manifest = YAML.parse(Buffer.from(body.yaml as string, "base64").toString("utf8")); + expect(manifest.spec.toolConfig.jmeter.script).toEqual({ + image: "<+input>", + entrypoint: "<+input>", + loadArgs: "proxyHost=8080", + imagePullSecret: "someSecretName", + }); }); - it("get: extracts canonical inputs[] AND derives convenience scalars from inputs[]", async () => { - const mockRequest = vi.fn().mockResolvedValue({ - identity: "locust1", - name: "locust-1", - environmentIdentifier: "ashloadtest", - infraIdentifier: "deletelater1", - targetType: "kubernetes", - toolType: "Locust", - scriptSource: "inline", - inputs: [ - { name: "targetUsers", value: 100, type: "Integer", required: true }, - { name: "durationSeconds", value: 600, type: "Integer" }, - { name: "rampUpTimeSec", value: 120, type: "Integer" }, - { name: "workerCount", value: 1, type: "Integer" }, - { name: "targetUrl", value: "http://www.example.com", type: "String" }, - ], - variables: [], - yaml: "BASE64_YAML_BLOB", - scriptContent: "BIG_BASE64_BLOB", - updatedByUserDetails: { name: "someone" }, - }); + it("image mode (public registry): omits imagePullSecret / loadArgs / entrypoint when not supplied", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); const client = makeClient(mockRequest); + await registry.dispatch(client, "chaos_loadtest", "create", { + org_id: "o", project_id: "p", + tool_type: "JMeter", + name: "jm-img-public", environment_id: "e", infra_id: "i", + target_type: "kubernetes", + script_source: "image", + script_image: "my-registry/jmeter:5.6", + }); + const jm = (mockRequest.mock.calls[0][0].body.toolConfig as Record) + .jmeter as Record; + expect(jm.mode).toBe("image"); + expect(jm.script).toEqual({ image: "my-registry/jmeter:5.6" }); + const script = jm.script as Record; + expect(script.entrypoint).toBeUndefined(); + expect(script.loadArgs).toBeUndefined(); + expect(script.imagePullSecret).toBeUndefined(); + }); - const result = (await registry.dispatch(client, "chaos_loadtest", "get", { - loadtest_id: "locust1", - org_id: "templatescopetest", - project_id: "templatescopetest", - })) as Record; - - const call = mockRequest.mock.calls[0][0]; - expect(call.path).toBe("/loadTest/manager/api/v1/load-tests/locust1"); - - // Canonical arrays preserved. - expect(result.inputs).toHaveLength(5); - expect(result.variables).toEqual([]); - expect(result.yaml).toBe("BASE64_YAML_BLOB"); - - // Derived convenience scalars match the create-side LLM surface. - expect(result.target_url).toBe("http://www.example.com"); - expect(result.users).toBe(100); - expect(result.duration_sec).toBe(600); - expect(result.ramp_up_sec).toBe(120); - expect(result.worker_count).toBe(1); + it("image mode: rejects load_args whose keys start with '-'", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + await expect( + registry.dispatch(client, "chaos_loadtest", "create", { + org_id: "o", project_id: "p", + tool_type: "JMeter", + name: "jm-img-bad", environment_id: "e", infra_id: "i", + target_type: "kubernetes", + script_source: "image", + script_image: "my-registry/jmeter:5.6", + load_args: "--headless", + }), + ).rejects.toThrow(/must not start with '-'/); + expect(mockRequest).not.toHaveBeenCalled(); + }); - // Legacy top-level projections are not surfaced anymore. - expect((result as Record).defaultUsers).toBeUndefined(); - expect((result as Record).defaultDurationSec).toBeUndefined(); - expect((result as Record).defaultRampUpTimeSec).toBeUndefined(); - expect((result as Record).defaultWorkerCount).toBeUndefined(); - expect((result as Record).targetUrl).toBeUndefined(); + it("rejects JMeter without script or script_image (scalar path)", async () => { + const client = makeClient(vi.fn()); + await expect( + registry.dispatch(client, "chaos_loadtest", "create", { + org_id: "o", project_id: "p", + tool_type: "JMeter", name: "jm-1", + environment_id: "e", infra_id: "i", + target_type: "kubernetes", + }), + ).rejects.toThrow(/JMeter.*script/); + }); - // Large blobs still dropped. - expect(result.scriptContent).toBeUndefined(); - expect(result.updatedByUserDetails).toBeUndefined(); + it("rejects JMeter when target_type is not kubernetes", async () => { + const client = makeClient(vi.fn()); + await expect( + registry.dispatch(client, "chaos_loadtest", "create", { + org_id: "o", project_id: "p", + tool_type: "JMeter", name: "jm-1", + environment_id: "e", infra_id: "i", + target_type: "machine-chaos-linux", + tool_config: { jmeter: {} }, + }), + ).rejects.toThrow(/JMeter.*kubernetes/); + }); - // Deep link is attached. - expect(result.openInHarness).toBe( - "https://app.harness.io/ng/account/test-account/module/chaos/orgs/templatescopetest/projects/templatescopetest/load-tests/locust1", - ); + it("preserves explicit false for send_to_engines / abort_on_fail instead of dropping them", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + const plan = ``; + await registry.dispatch(client, "chaos_loadtest", "create", { + org_id: "o", project_id: "p", + tool_type: "JMeter", + name: "jm-falsy", environment_id: "e", infra_id: "i", + target_type: "kubernetes", + script: plan, + properties: [ + { key: "explicitFalse", value: "1", send_to_engines: false }, + { key: "omitted", value: "2" }, + ], + thresholds: [ + { metric: "response_time_ms", stat: "p95", operator: "<", value: 5000, abort_on_fail: false }, + ], + }); + const jm = (mockRequest.mock.calls[0][0].body.toolConfig as Record) + .jmeter as Record; + expect(jm.properties).toEqual([ + { key: "explicitFalse", value: "1", sendToEngines: false }, + { key: "omitted", value: "2" }, + ]); + expect(jm.thresholds).toEqual([ + { metric: "response_time_ms", stat: "p95", operator: "<", value: 5000, abortOnFail: false }, + ]); }); + + it.each(["scriptContent", "customImage", "inputs"])( + "fails loudly when legacy field '%s' is passed on create", + async (legacyKey) => { + const client = makeClient(vi.fn()); + await expect( + registry.dispatch(client, "chaos_loadtest", "create", { + org_id: "o", project_id: "p", + tool_type: "JMeter", name: "jm-legacy", + environment_id: "e", infra_id: "i", + target_type: "kubernetes", + script: ``, + [legacyKey]: legacyKey === "inputs" ? [] : "whatever", + }), + ).rejects.toThrow(new RegExp(`'${legacyKey}' is not a supported chaos_loadtest field`)); + }, + ); }); -describe("chaos_loadtest create — K6", () => { +// ── list ────────────────────────────────────────────────────────────── +describe("chaos_loadtest list", () => { let registry: Registry; beforeEach(() => { registry = new Registry(makeConfig()); }); - it("create (K6 Kubernetes, Upload K6 script): mirrors verified curl — inputs[], toolConfig with base64 script, env-var literal + secret reference, base64 yaml manifest", async () => { - const mockRequest = vi.fn().mockResolvedValue({ identity: "k61", name: "k6-1" }); + it("maps the new query params (tool_type, tags, sort_field, sort_ascending)", async () => { + const mockRequest = vi.fn().mockResolvedValue({ items: [], pagination: { totalItems: 0 } }); const client = makeClient(mockRequest); - - await registry.dispatch(client, "chaos_loadtest", "create", { - org_id: "templatescopetest", - project_id: "templatescopetest", + await registry.dispatch(client, "chaos_loadtest", "list", { + org_id: "o", project_id: "p", tool_type: "K6", - name: "k6-1", - identity: "k61", - description: "op desc", - tags: ["tag:1"], - environment_id: "ashloadtest", - infra_id: "deletelater1", - target_url: "http://www.google.com", - script: K6_SCRIPT, - target_type: "kubernetes", - worker_count: 3, - rps_limit: 98, - env_vars: [ - { key: "var1", value: "staticValue_withoutSecret" }, - { key: "secretKeyVar2", secret_id: "vcenter-admin-username", secret_scope: "project" }, - ], + tags: ["a", "b"], + sort_field: "lastUpdated", + sort_ascending: false, + environment_id: "env-1", + search: "foo", + limit: 25, + page: 2, }); + const params = mockRequest.mock.calls[0][0].params; + expect(params.toolType).toBe("K6"); + expect(params.tags).toEqual(["a", "b"]); + expect(params.sortField).toBe("lastUpdated"); + expect(params.sortAscending).toBe(false); + expect(params.environmentIdentifier).toBe("env-1"); + expect(params.search).toBe("foo"); + expect(params.limit).toBe(25); + expect(params.page).toBe(2); + }); - const call = mockRequest.mock.calls[0][0]; - expect(call.method).toBe("POST"); - expect(call.path).toBe("/loadTest/manager/api/v1/load-tests"); + it("projects list items via chaosLoadTestExtract (toolConfig-nested tunables → scalar mirrors)", async () => { + const mockRequest = vi.fn().mockResolvedValue({ + items: [ + { + uniqueId: "u-1", + identity: "k61", + name: "k6-1", + environmentIdentifier: "env-1", + infraIdentifier: "infra-1", + infraType: "kubernetes", + targetType: "kubernetes", + toolType: "K6", + scriptSource: "inline", + maxDurationSec: 900, + cleanupPolicy: "delete", + serviceReferences: ["svc-1"], + targetUsers: "100", + durationSeconds: "600", + toolConfig: { + k6: { + mode: "script", + script: { content: "BASE64==" }, + tunables: { + targetUrl: "https://api.example.com", + targetUsers: 100, + durationSeconds: 600, + rampUpTimeSec: 60, + hostUrl: "https://api.example.com", + rpsLimit: 50, + }, + variables: [{ name: "X" }], + envVars: [{ key: "K", value: "V" }], + }, + }, + createdByUserDetails: { name: "someone" }, // must not surface + }, + ], + pagination: { totalItems: 3 }, + }); + const client = makeClient(mockRequest); + const result = (await registry.dispatch(client, "chaos_loadtest", "list", { + org_id: "o", project_id: "p", + })) as { items: Array>; total: number }; - const body = call.body; + expect(result.total).toBe(3); + const item = result.items[0]; + expect(item.loadtestId).toBe("k61"); + expect(item.uniqueId).toBe("u-1"); + expect(item.infraType).toBe("kubernetes"); + expect(item.maxDurationSec).toBe(900); + expect(item.serviceReferences).toEqual(["svc-1"]); + // Convenience scalars pulled from toolConfig.k6.tunables. + expect(item.target_url).toBe("https://api.example.com"); + expect(item.users).toBe(100); + expect(item.duration_sec).toBe(600); + expect(item.ramp_up_sec).toBe(60); + expect(item.host_url).toBe("https://api.example.com"); + expect(item.rps_limit).toBe(50); + // Full toolConfig is passed through. + expect((item.toolConfig as Record).k6).toBeDefined(); + // Variables/envVars surfaced from toolBlock. + expect((item.variables as unknown[]).length).toBe(1); + expect((item.envVars as unknown[]).length).toBe(1); + // Denormalised display strings surface separately from scalar mirrors. + expect(item.targetUsersDisplay).toBe("100"); + expect(item.durationSecondsDisplay).toBe("600"); + // Legacy fields are gone. + expect(item.inputs).toBeUndefined(); + expect(item.createdByUserDetails).toBeUndefined(); + }); - // Top-level wire shape matches the verified curl exactly. - expect(body).toMatchObject({ - identity: "k61", - name: "k6-1", - description: "op desc", - tags: ["tag:1"], - environmentIdentifier: "ashloadtest", - infraIdentifier: "deletelater1", - scriptSource: "inline", - targetType: "kubernetes", - toolType: "K6", + it("surfaces rps_limit from toolConfig.k6.options.rpsLimit when tunables.rpsLimit is absent (real QA payload shape)", async () => { + const mockRequest = vi.fn().mockResolvedValue({ + items: [ + { + uniqueId: "u-2", + identity: "k62", + name: "k6-2", + toolType: "K6", + toolConfig: { + k6: { + mode: "script", + script: { content: "BASE64==" }, + options: { rpsLimit: 50 }, + tunables: { + targetUsers: 10, + durationSeconds: 45, + }, + }, + }, + }, + ], + pagination: { totalItems: 1 }, }); - // K6 does NOT send top-level scriptContent. - expect(body.scriptContent).toBeUndefined(); + const client = makeClient(mockRequest); + const result = (await registry.dispatch(client, "chaos_loadtest", "list", { + org_id: "o", project_id: "p", + })) as { items: Array>; total: number }; - // inputs[] in canonical order (mirrors curl exactly). - expect(body.inputs).toEqual([ - { name: "targetUsers", value: 100, type: "Integer", required: true }, - { name: "durationSeconds", value: 600, type: "Integer" }, - { name: "rampUpTimeSec", value: 120, type: "Integer" }, - { name: "workerCount", value: 3, type: "Integer" }, - { name: "targetUrl", value: "http://www.google.com", type: "String" }, - ]); + const item = result.items[0]; + expect(item.rps_limit).toBe(50); + expect(item.users).toBe(10); + expect(item.duration_sec).toBe(45); + }); - // toolConfig: K6 wire shape. - expect(body.toolConfig).toMatchObject({ - mode: "script", - hostUrl: "http://www.google.com", - options: { rpsLimit: 98 }, - envVars: [ - { key: "var1", value: "staticValue_withoutSecret" }, + it("prefers tunables.rpsLimit over options.rpsLimit when both are set (matches backend dispatch precedence)", async () => { + const mockRequest = vi.fn().mockResolvedValue({ + items: [ { - key: "secretKeyVar2", - value: 'secrets.getValue("vcenter-admin-username")', - secret: true, + uniqueId: "u-3", + identity: "k63", + name: "k6-3", + toolType: "K6", + toolConfig: { + k6: { + mode: "script", + script: { content: "BASE64==" }, + options: { rpsLimit: 50 }, + tunables: { rpsLimit: 30 }, + }, + }, }, ], + pagination: { totalItems: 1 }, }); - expect((body.toolConfig as Record).scriptContent).toBe( - Buffer.from(K6_SCRIPT, "utf8").toString("base64"), - ); + const client = makeClient(mockRequest); + const result = (await registry.dispatch(client, "chaos_loadtest", "list", { + org_id: "o", project_id: "p", + })) as { items: Array>; total: number }; - // Decoded YAML manifest. - const manifest = YAML.parse(Buffer.from(body.yaml as string, "base64").toString("utf8")); - expect(manifest.spec).toMatchObject({ - identity: "k61", - toolType: "K6", - infraType: "kubernetes", - targetType: "kubernetes", - scriptSource: "inline", - infraId: "deletelater1", - envId: "ashloadtest", - }); - // YAML must NOT have spec.scriptContent for K6. - expect(manifest.spec.scriptContent).toBeUndefined(); - // YAML toolConfig.scriptContent is PLAIN TEXT (not base64). - expect(manifest.spec.toolConfig.scriptContent).toBe(K6_SCRIPT); - expect(manifest.spec.toolConfig.mode).toBe("script"); - expect(manifest.spec.toolConfig.hostUrl).toBe("http://www.google.com"); - expect(manifest.spec.toolConfig.options).toEqual({ rpsLimit: 98 }); - expect(manifest.spec.toolConfig.envVars).toEqual(body.toolConfig.envVars); - expect(manifest.spec.inputs).toEqual(body.inputs); + expect(result.items[0].rps_limit).toBe(30); }); - it("create (K6): rejects target_type='machine-chaos-linux'", async () => { - const client = makeClient(vi.fn()); - await expect( - registry.dispatch(client, "chaos_loadtest", "create", { - org_id: "o", project_id: "p", - tool_type: "K6", - name: "k6-lt", environment_id: "e", infra_id: "i", - target_url: "https://example.com", - script: K6_SCRIPT, - target_type: "machine-chaos-linux", - }), - ).rejects.toThrow(/K6.*kubernetes/); + it("treats a zero rpsLimit in either location as unset", async () => { + const mockRequest = vi.fn().mockResolvedValue({ + items: [ + { + uniqueId: "u-4", + identity: "k64", + name: "k6-4", + toolType: "K6", + toolConfig: { + k6: { + mode: "script", + script: { content: "BASE64==" }, + options: { rpsLimit: 0 }, + tunables: { rpsLimit: 0 }, + }, + }, + }, + ], + pagination: { totalItems: 1 }, + }); + const client = makeClient(mockRequest); + const result = (await registry.dispatch(client, "chaos_loadtest", "list", { + org_id: "o", project_id: "p", + })) as { items: Array>; total: number }; + + expect(result.items[0].rps_limit).toBeUndefined(); }); +}); - it("create (K6): rejects script missing 'export default'", async () => { - const client = makeClient(vi.fn()); - await expect( - registry.dispatch(client, "chaos_loadtest", "create", { - org_id: "o", project_id: "p", - tool_type: "K6", - name: "k6-lt", environment_id: "e", infra_id: "i", - target_url: "https://example.com", - script: "function main() { /* no default export */ }", - target_type: "kubernetes", - }), - ).rejects.toThrow(/export default function/); +// ── get / delete ────────────────────────────────────────────────────── +describe("chaos_loadtest get / delete", () => { + let registry: Registry; + beforeEach(() => { + registry = new Registry(makeConfig()); }); - it.each([ - { caseLabel: "reserved name", env: [{ key: "PATH", value: "x" }], match: /reserved/ }, - { caseLabel: "invalid key pattern", env: [{ key: "2foo", value: "x" }], match: /must match/ }, - { caseLabel: "duplicate keys", env: [{ key: "A", value: "1" }, { key: "A", value: "2" }], match: /duplicated/ }, - { caseLabel: "both value and secret_id", env: [{ key: "A", value: "x", secret_id: "y" }], match: /exactly one of/ }, - { caseLabel: "neither value nor secret_id", env: [{ key: "A" }], match: /exactly one of/ }, - { caseLabel: "invalid secret_scope", env: [{ key: "A", secret_id: "x", secret_scope: "global" }], match: /account.*org.*project/ }, - ])("create (K6): env_vars validation rejects $caseLabel", async ({ env, match }) => { - const client = makeClient(vi.fn()); - await expect( - registry.dispatch(client, "chaos_loadtest", "create", { - org_id: "o", project_id: "p", - tool_type: "K6", - name: "k6-lt", environment_id: "e", infra_id: "i", - target_url: "https://example.com", - script: K6_SCRIPT, - target_type: "kubernetes", - env_vars: env, - }), - ).rejects.toThrow(match); + it("get: extractor projects toolConfig.locust.tunables → scalars and mirrors identity", async () => { + const mockRequest = vi.fn().mockResolvedValue({ + identity: "locust1", + name: "locust-1", + environmentIdentifier: "e", + infraIdentifier: "i", + targetType: "kubernetes", + toolType: "Locust", + toolConfig: { + locust: { + mode: "script", + script: { content: "BASE64==" }, + tunables: { + targetUrl: "http://www.example.com", + targetUsers: 100, + durationSeconds: 600, + rampUpTimeSec: 120, + spawnRate: 5, + workerCount: 1, + }, + }, + }, + yaml: "BASE64_YAML_BLOB", + }); + const client = makeClient(mockRequest); + const result = (await registry.dispatch(client, "chaos_loadtest", "get", { + loadtest_id: "locust1", + org_id: "templatescopetest", + project_id: "templatescopetest", + })) as Record; + + expect(mockRequest.mock.calls[0][0].path).toBe( + "/loadTest/manager/api/v1/load-tests/locust1", + ); + expect(result.loadtestId).toBe("locust1"); + expect(result.target_url).toBe("http://www.example.com"); + expect(result.users).toBe(100); + expect(result.duration_sec).toBe(600); + expect(result.ramp_up_sec).toBe(120); + expect(result.worker_count).toBe(1); + expect(result.spawn_rate).toBe(5); + expect(result.yaml).toBe("BASE64_YAML_BLOB"); + expect(result.openInHarness).toBe( + "https://app.harness.io/ng/account/test-account/module/chaos/orgs/templatescopetest/projects/templatescopetest/load-tests/locust1", + ); }); - it.each([ - { scope: "account", id: "gcp-ca-cert", wire: 'secrets.getValue("account.gcp-ca-cert")' }, - { scope: "org", id: "org-level-secret", wire: 'secrets.getValue("org.org-level-secret")' }, - { scope: "project", id: "vcenter-admin-username", wire: 'secrets.getValue("vcenter-admin-username")' }, - ])("create (K6): secret_scope='$scope' produces $wire", async ({ scope, id, wire }) => { - const mockRequest = vi.fn().mockResolvedValue({}); + it("delete: DELETE /v1/load-tests/{identity}", async () => { + const mockRequest = vi.fn().mockResolvedValue({ success: true, message: "deleted" }); const client = makeClient(mockRequest); - await registry.dispatch(client, "chaos_loadtest", "create", { + await registry.dispatch(client, "chaos_loadtest", "delete", { + loadtest_id: "locust1", org_id: "o", project_id: "p", - tool_type: "K6", - name: "k6-lt", environment_id: "e", infra_id: "i", - target_url: "https://example.com", - script: K6_SCRIPT, - target_type: "kubernetes", - env_vars: [{ key: "SECRET", secret_id: id, secret_scope: scope }], }); - const envVars = (mockRequest.mock.calls[0][0].body.toolConfig as Record) - .envVars; - expect(envVars).toEqual([{ key: "SECRET", value: wire, secret: true }]); + const call = mockRequest.mock.calls[0][0]; + expect(call.method).toBe("DELETE"); + expect(call.path).toBe("/loadTest/manager/api/v1/load-tests/locust1"); + }); +}); + +// ── update ──────────────────────────────────────────────────────────── +describe("chaos_loadtest update", () => { + let registry: Registry; + beforeEach(() => { + registry = new Registry(makeConfig()); }); - it("create (K6): secret_scope defaults to 'project' when omitted", async () => { + it("PUT with only the fields the caller supplied (partial update)", async () => { const mockRequest = vi.fn().mockResolvedValue({}); const client = makeClient(mockRequest); - await registry.dispatch(client, "chaos_loadtest", "create", { + await registry.dispatch(client, "chaos_loadtest", "update", { + loadtest_id: "locust1", org_id: "o", project_id: "p", - tool_type: "K6", - name: "k6-lt", environment_id: "e", infra_id: "i", - target_url: "https://example.com", - script: K6_SCRIPT, - target_type: "kubernetes", - env_vars: [{ key: "SECRET", secret_id: "my-secret" }], + name: "renamed", + cleanup_policy: "retain", }); - const envVars = (mockRequest.mock.calls[0][0].body.toolConfig as Record) - .envVars; - expect(envVars).toEqual([ - { key: "SECRET", value: 'secrets.getValue("my-secret")', secret: true }, - ]); + const call = mockRequest.mock.calls[0][0]; + expect(call.method).toBe("PUT"); + expect(call.path).toBe("/loadTest/manager/api/v1/load-tests/locust1"); + expect(call.body).toEqual({ name: "renamed", cleanupPolicy: "retain" }); }); - it("create (K6): host_url defaults to the origin of target_url when omitted", async () => { + it("passes tool_config through as a full replacement", async () => { const mockRequest = vi.fn().mockResolvedValue({}); const client = makeClient(mockRequest); - await registry.dispatch(client, "chaos_loadtest", "create", { + const tool_config = { locust: { mode: "script", script: { content: "AAA=" }, tunables: { targetUsers: 5 } } }; + await registry.dispatch(client, "chaos_loadtest", "update", { + loadtest_id: "lt-1", org_id: "o", project_id: "p", - tool_type: "K6", - name: "k6-lt", environment_id: "e", infra_id: "i", - target_url: "https://api.example.com/foo/bar?q=1", - script: K6_SCRIPT, - target_type: "kubernetes", + tool_config, }); - const toolConfig = mockRequest.mock.calls[0][0].body.toolConfig as Record; - expect(toolConfig.hostUrl).toBe("https://api.example.com"); + expect(mockRequest.mock.calls[0][0].body.toolConfig).toEqual(tool_config); }); - it("create (K6): omits options/envVars/iterations from toolConfig when empty", async () => { + it("rebuilds toolConfig.jmeter from scalars when tool_type + a scalar field are supplied", async () => { const mockRequest = vi.fn().mockResolvedValue({}); const client = makeClient(mockRequest); - await registry.dispatch(client, "chaos_loadtest", "create", { - org_id: "o", project_id: "p", - tool_type: "K6", - name: "k6-lt", environment_id: "e", infra_id: "i", - target_url: "https://example.com", - script: K6_SCRIPT, - target_type: "kubernetes", + const plan = ``; + await registry.dispatch(client, "chaos_loadtest", "update", { + loadtest_id: "jm-1", org_id: "o", project_id: "p", + tool_type: "JMeter", + script: plan, + worker_count: 3, + properties: [{ key: "threads1", value: "100", send_to_engines: true }], + thresholds: [{ metric: "response_time_ms", stat: "p95", operator: "<", value: 5000 }], }); - const tc = mockRequest.mock.calls[0][0].body.toolConfig as Record; - expect(Object.keys(tc).sort()).toEqual(["hostUrl", "mode", "scriptContent"]); + const body = mockRequest.mock.calls[0][0].body; + expect(body.toolConfig).toEqual({ + jmeter: { + mode: "script", + script: { content: Buffer.from(plan, "utf8").toString("base64") }, + tunables: { workerCount: 3 }, + properties: [{ key: "threads1", value: "100", sendToEngines: true }], + thresholds: [{ metric: "response_time_ms", stat: "p95", operator: "<", value: 5000 }], + }, + }); + // tool_type is immutable server-side; MCP must not forward it in the body. + expect(body.toolType).toBeUndefined(); }); - it("create (Locust): existing behaviour unchanged — no toolConfig emitted, top-level scriptContent still set", async () => { + it("rebuilds toolConfig.locust from scalars (image mode)", async () => { const mockRequest = vi.fn().mockResolvedValue({}); const client = makeClient(mockRequest); - await registry.dispatch(client, "chaos_loadtest", "create", { - org_id: "o", project_id: "p", - name: "lt", environment_id: "e", infra_id: "i", - target_url: "https://example.com", script: SCRIPT, - target_type: "kubernetes", + await registry.dispatch(client, "chaos_loadtest", "update", { + loadtest_id: "locust-1", org_id: "o", project_id: "p", + tool_type: "Locust", + script_image: "my-registry/locust:latest", + script_entrypoint: "/scripts/locustfile.py", + users: 50, }); const body = mockRequest.mock.calls[0][0].body; - expect(body.toolType).toBe("Locust"); // default when tool_type omitted - expect(body.toolConfig).toBeUndefined(); - expect(body.scriptContent).toBe(Buffer.from(SCRIPT, "utf8").toString("base64")); + expect(body.toolConfig).toEqual({ + locust: { + mode: "image", + script: { image: "my-registry/locust:latest", entrypoint: "/scripts/locustfile.py" }, + tunables: { targetUsers: 50 }, + }, + }); }); - it("create (K6 Kubernetes, Using Custom Image): mirrors verified curl k6-2 — toolConfig.customImage, inputs[] with image fields, base64 yaml manifest", async () => { - const mockRequest = vi.fn().mockResolvedValue({ identity: "k62", name: "k6-2" }); + it("throws when a scalar tool field is supplied without tool_type", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); const client = makeClient(mockRequest); + await expect( + registry.dispatch(client, "chaos_loadtest", "update", { + loadtest_id: "lt-1", org_id: "o", project_id: "p", + worker_count: 3, + }), + ).rejects.toThrow(/tool_type is required/); + }); - await registry.dispatch(client, "chaos_loadtest", "create", { - org_id: "templatescopetest", - project_id: "templatescopetest", - tool_type: "K6", - name: "k6-2", - identity: "k62", - description: "optional desc", - tags: ["tag:op1"], - environment_id: "ashloadtest", - infra_id: "discoverytestallsettingsset", - target_url: "http://www.example.com", - target_type: "kubernetes", - script_source: "image", - script_image: " my-image", // preserve leading space per the verified curl - script_entrypoint: "/script.json", - load_args: "tags=smoke,random;headless=true", - worker_count: 1, - rps_limit: 29, - env_vars: [ - { key: "var1", value: "val1" }, - { key: "secretKeyVar2", secret_id: "gcp-ca-cert", secret_scope: "account" }, - ], + it("normalizes tool_config + tool_type (wraps a bare inner object)", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + await registry.dispatch(client, "chaos_loadtest", "update", { + loadtest_id: "lt-1", org_id: "o", project_id: "p", + tool_type: "Locust", + tool_config: { mode: "script", script: { content: "AAA=" }, tunables: { targetUsers: 5 } }, }); - - const body = mockRequest.mock.calls[0][0].body; - - // Top-level wire shape matches the verified curl exactly. - expect(body).toMatchObject({ - identity: "k62", - name: "k6-2", - description: "optional desc", - tags: ["tag:op1"], - environmentIdentifier: "ashloadtest", - infraIdentifier: "discoverytestallsettingsset", - scriptSource: "image", - targetType: "kubernetes", - toolType: "K6", + expect(mockRequest.mock.calls[0][0].body.toolConfig).toEqual({ + locust: { mode: "script", script: { content: "AAA=" }, tunables: { targetUsers: 5 } }, }); - // K6 (any mode) does NOT send top-level scriptContent. - expect(body.scriptContent).toBeUndefined(); - - // inputs[] order matches the curl (5 standard + 3 image entries). - expect(body.inputs).toEqual([ - { name: "targetUsers", value: 100, type: "Integer", required: true }, - { name: "durationSeconds", value: 600, type: "Integer" }, - { name: "rampUpTimeSec", value: 120, type: "Integer" }, - { name: "workerCount", value: 1, type: "Integer" }, - { name: "targetUrl", value: "http://www.example.com", type: "String" }, - { name: "scriptImage", value: " my-image", type: "String", required: true }, - { name: "scriptEntrypoint", value: "/script.json", type: "String", required: true }, - { name: "loadArgs", value: "tags=smoke,random;headless=true", type: "String" }, - ]); + }); - // toolConfig: K6 image-mode wire shape. - expect(body.toolConfig).toEqual({ - mode: "image", - customImage: { image: " my-image", entrypoint: "/script.json" }, - hostUrl: "http://www.example.com", - options: { rpsLimit: 29 }, - envVars: [ - { key: "var1", value: "val1" }, - { - key: "secretKeyVar2", - value: 'secrets.getValue("account.gcp-ca-cert")', - secret: true, - }, - ], + it("normalizes tool_config + tool_type (passes an already-wrapped object through)", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + const wrapped = { locust: { mode: "script", script: { content: "AAA=" }, tunables: { targetUsers: 5 } } }; + await registry.dispatch(client, "chaos_loadtest", "update", { + loadtest_id: "lt-1", org_id: "o", project_id: "p", + tool_type: "Locust", + tool_config: wrapped, }); - // toolConfig.customImage MUST NOT carry loadArgs (load_args rides only in inputs[]). - const ci = (body.toolConfig as Record).customImage as Record; - expect(ci.runArgs).toBeUndefined(); - expect(ci.loadArgs).toBeUndefined(); + expect(mockRequest.mock.calls[0][0].body.toolConfig).toEqual(wrapped); + }); - // Decoded YAML manifest. - const manifest = YAML.parse(Buffer.from(body.yaml as string, "base64").toString("utf8")); - expect(manifest.spec).toMatchObject({ - identity: "k62", - toolType: "K6", - infraType: "kubernetes", - targetType: "kubernetes", - scriptSource: "image", - infraId: "discoverytestallsettingsset", - envId: "ashloadtest", - }); - expect(manifest.spec.scriptContent).toBeUndefined(); - expect(manifest.spec.toolConfig.mode).toBe("image"); - expect(manifest.spec.toolConfig.customImage).toEqual({ - image: " my-image", - entrypoint: "/script.json", - }); - expect(manifest.spec.toolConfig.scriptContent).toBeUndefined(); - expect(manifest.spec.inputs).toEqual(body.inputs); + it.each(["scriptContent", "customImage", "inputs"])( + "fails loudly when legacy field '%s' is passed on update", + async (legacyKey) => { + const client = makeClient(vi.fn()); + await expect( + registry.dispatch(client, "chaos_loadtest", "update", { + loadtest_id: "lt-1", org_id: "o", project_id: "p", + [legacyKey]: legacyKey === "inputs" ? [] : "whatever", + }), + ).rejects.toThrow(new RegExp(`'${legacyKey}' is not a supported chaos_loadtest field`)); + }, + ); +}); + +// ── run / stop actions ──────────────────────────────────────────────── +describe("chaos_loadtest execute actions", () => { + let registry: Registry; + beforeEach(() => { + registry = new Registry(makeConfig()); }); - it("create (K6 image): script_entrypoint is optional — customImage carries only image", async () => { + it("run: auto-fills required `identity` with a UUID and hits /v1/load-tests/{id}/runs", async () => { const mockRequest = vi.fn().mockResolvedValue({}); const client = makeClient(mockRequest); - await registry.dispatch(client, "chaos_loadtest", "create", { + await registry.dispatchExecute(client, "chaos_loadtest", "run", { + loadtest_id: "lt-1", org_id: "o", project_id: "p", - tool_type: "K6", - name: "k6-lt", environment_id: "e", infra_id: "i", - target_url: "https://example.com", - target_type: "kubernetes", - script_source: "image", - script_image: "my-registry/k6:latest", }); - const tc = mockRequest.mock.calls[0][0].body.toolConfig as Record; - expect(tc.mode).toBe("image"); - expect(tc.customImage).toEqual({ image: "my-registry/k6:latest" }); + const call = mockRequest.mock.calls[0][0]; + expect(call.method).toBe("POST"); + expect(call.path).toBe("/loadTest/manager/api/v1/load-tests/lt-1/runs"); + expect(typeof call.body.identity).toBe("string"); + expect(call.body.identity.length).toBeGreaterThan(0); + expect(call.body.values).toBeUndefined(); + expect(call.body.runtimeValues).toBeUndefined(); }); - it("create (K6 image): rejects when script_image is missing", async () => { - const client = makeClient(vi.fn()); - await expect( - registry.dispatch(client, "chaos_loadtest", "create", { - org_id: "o", project_id: "p", - tool_type: "K6", - name: "k6-lt", environment_id: "e", infra_id: "i", - target_url: "https://example.com", - target_type: "kubernetes", - script_source: "image", - }), - ).rejects.toThrow(/script_image/); + it("run: honours caller-supplied run_identity / run_name / values / runtime_values", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + await registry.dispatchExecute(client, "chaos_loadtest", "run", { + loadtest_id: "lt-1", + org_id: "o", project_id: "p", + run_identity: "my-run-1", + run_name: "Nightly Load", + values: [{ name: "TARGET_USERS", value: "500" }], + runtime_values: { "toolConfig.locust.tunables.targetUsers": 500 }, + }); + const body = mockRequest.mock.calls[0][0].body; + expect(body.identity).toBe("my-run-1"); + expect(body.name).toBe("Nightly Load"); + expect(body.values).toEqual([{ name: "TARGET_USERS", value: "500" }]); + expect(body.runtimeValues).toEqual({ + "toolConfig.locust.tunables.targetUsers": 500, + }); }); - it("create (K6 image): load_args rides only in inputs[], not toolConfig.customImage", async () => { + it("stop: POSTs to /v1/runs/{run_id}/stop with empty body", async () => { const mockRequest = vi.fn().mockResolvedValue({}); const client = makeClient(mockRequest); - await registry.dispatch(client, "chaos_loadtest", "create", { + await registry.dispatchExecute(client, "chaos_loadtest", "stop", { + run_id: "run-42", org_id: "o", project_id: "p", - tool_type: "K6", - name: "k6-lt", environment_id: "e", infra_id: "i", - target_url: "https://example.com", - target_type: "kubernetes", - script_source: "image", - script_image: "my-image", - load_args: "tags=smoke", }); - const body = mockRequest.mock.calls[0][0].body; - // inputs[] has loadArgs. - expect( - (body.inputs as Array>).find((i) => i.name === "loadArgs"), - ).toEqual({ name: "loadArgs", value: "tags=smoke", type: "String" }); - // toolConfig.customImage MUST NOT carry it. - const ci = (body.toolConfig as Record).customImage as Record; - expect(ci.loadArgs).toBeUndefined(); - expect(ci.runArgs).toBeUndefined(); + const call = mockRequest.mock.calls[0][0]; + expect(call.method).toBe("POST"); + expect(call.path).toBe("/loadTest/manager/api/v1/runs/run-42/stop"); + // Body carries no run-specific fields; scope injection is registry behaviour we don't assert here. + expect(call.body.identity).toBeUndefined(); + expect(call.body.values).toBeUndefined(); + expect(call.body.runtimeValues).toBeUndefined(); }); }); diff --git a/tests/registry/chaos-service-write.test.ts b/tests/registry/chaos-service-write.test.ts new file mode 100644 index 000000000..5829e7455 --- /dev/null +++ b/tests/registry/chaos-service-write.test.ts @@ -0,0 +1,233 @@ +/** + * Request-shape coverage for chaos_service create/update body builders and the + * discovered_agent list — the pieces the earlier chaos-service.test.ts (list + + * execute only) did not cover. + * + * Backed by hce-saas graphql/server/{handlers,services}/chaosservices/v3 and + * service-discovery GET /api/v1/agents. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Registry } from "../../src/registry/index.js"; +import { compactItems } from "../../src/utils/compact.js"; +import type { Config } from "../../src/config.js"; +import type { HarnessClient } from "../../src/client/harness-client.js"; + +function makeConfig(overrides: Partial = {}): Config { + return { + HARNESS_API_KEY: "pat.test", + HARNESS_ACCOUNT_ID: "test-account", + HARNESS_BASE_URL: "https://app.harness.io", + HARNESS_ORG: "default", + HARNESS_PROJECT: "test-project", + HARNESS_API_TIMEOUT_MS: 30000, + HARNESS_MAX_RETRIES: 3, + LOG_LEVEL: "info", + HARNESS_MAX_BODY_SIZE_MB: 10, + HARNESS_RATE_LIMIT_RPS: 10, + HARNESS_READ_ONLY: false, + HARNESS_SKIP_ELICITATION: false, + HARNESS_ALLOW_HTTP: false, + HARNESS_FME_BASE_URL: "https://api.split.io", + ...overrides, + }; +} + +function makeClient(requestFn?: (...args: unknown[]) => unknown): HarnessClient { + return { + request: requestFn ?? vi.fn().mockResolvedValue({}), + account: "test-account", + } as unknown as HarnessClient; +} + +// ── create ────────────────────────────────────────────────────────────── +describe("chaos_service create", () => { + let registry: Registry; + beforeEach(() => { + registry = new Registry(makeConfig()); + }); + + it("builds the v3 body: probe {probeId,inputs} mapping, dual-write ids, tag normalization", async () => { + const mockRequest = vi.fn().mockResolvedValue({ identity: "svc-1" }); + const client = makeClient(mockRequest); + + await registry.dispatch(client, "chaos_service", "create", { + org_id: "o", + project_id: "p", + identity: "svc1", + name: "svc-1", + external_service_id: "ext-1", + agent_id: "agent-1", + environment_id: "env-1", + infrastructure_id: "infra-1", + infrastructure_type: "KubernetesV2", + tags: "a, b ,c", + probes: [{ probe_id: "p-1", inputs: [{ name: "url", value: "http://x" }] }], + }); + + const call = mockRequest.mock.calls[0][0]; + expect(call.method).toBe("POST"); + expect(call.path).toBe("/chaos/manager/api/v3/chaos-services"); + + const body = call.body as Record; + expect(body).toMatchObject({ + identity: "svc1", + name: "svc-1", + externalServiceId: "ext-1", + agentId: "agent-1", + environmentId: "env-1", + infrastructureId: "infra-1", + infrastructureType: "KubernetesV2", + // Dual-write snake_case keys for the registry required-field validator. + external_service_id: "ext-1", + agent_id: "agent-1", + environment_id: "env-1", + infrastructure_id: "infra-1", + }); + expect(body.tags).toEqual(["a", "b", "c"]); + expect(body.probes).toEqual([ + { probeId: "p-1", inputs: [{ name: "url", value: "http://x" }] }, + ]); + }); + + it("omits probes when none are supplied (new service starts with no probes)", async () => { + const mockRequest = vi.fn().mockResolvedValue({ identity: "svc-2" }); + const client = makeClient(mockRequest); + + await registry.dispatch(client, "chaos_service", "create", { + org_id: "o", + project_id: "p", + identity: "svc2", + name: "svc-2", + external_service_id: "ext-2", + agent_id: "agent-2", + environment_id: "env-2", + infrastructure_id: "infra-2", + }); + + const body = mockRequest.mock.calls[0][0].body as Record; + expect(body.probes).toBeUndefined(); + }); +}); + +// ── update ────────────────────────────────────────────────────────────── +describe("chaos_service update", () => { + let registry: Registry; + beforeEach(() => { + registry = new Registry(makeConfig()); + }); + + const REQUIRED = { + identity: "svc-1", + org_id: "o", + project_id: "p", + name: "svc-1", + external_service_id: "ext-1", + agent_id: "agent-1", + environment_id: "env-1", + infrastructure_id: "infra-1", + }; + + it("sends an explicit probes:[] through (detach all) rather than dropping it", async () => { + const mockRequest = vi.fn().mockResolvedValue({ identity: "svc-1" }); + const client = makeClient(mockRequest); + + await registry.dispatch(client, "chaos_service", "update", { + ...REQUIRED, + probes: [], + }); + + const call = mockRequest.mock.calls[0][0]; + expect(call.method).toBe("PUT"); + expect(call.path).toBe("/chaos/manager/api/v3/chaos-services/svc-1"); + expect((call.body as Record).probes).toEqual([]); + }); + + it("maps a supplied probe list to {probeId,inputs}", async () => { + const mockRequest = vi.fn().mockResolvedValue({ identity: "svc-1" }); + const client = makeClient(mockRequest); + + await registry.dispatch(client, "chaos_service", "update", { + ...REQUIRED, + probes: [{ probe_id: "p-9", inputs: [{ name: "n", value: "v" }] }], + }); + + const body = mockRequest.mock.calls[0][0].body as Record; + expect(body.probes).toEqual([ + { probeId: "p-9", inputs: [{ name: "n", value: "v" }] }, + ]); + }); + + it("rejects an update that omits probes (required full desired-state replace)", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await expect( + registry.dispatch(client, "chaos_service", "update", { ...REQUIRED }), + ).rejects.toThrow(/probes/); + // Validation must fire before any API call. + expect(mockRequest).not.toHaveBeenCalled(); + }); +}); + +// ── discovered_agent list ──────────────────────────────────────────────── +describe("discovered_agent list", () => { + let registry: Registry; + beforeEach(() => { + registry = new Registry(makeConfig()); + }); + + it("lists without environment_id (optional filter, not sent when omitted)", async () => { + const mockRequest = vi.fn().mockResolvedValue({ items: [], page: { totalItems: 0 } }); + const client = makeClient(mockRequest); + + await registry.dispatch(client, "discovered_agent", "list", { + org_id: "o", + project_id: "p", + }); + + const call = mockRequest.mock.calls[0][0]; + expect(call.method).toBe("GET"); + expect(call.path).toBe("/gateway/servicediscovery/api/v1/agents"); + expect(call.params.environmentIdentifier).toBeUndefined(); + }); + + it("passes environment_id through as environmentIdentifier when supplied", async () => { + const mockRequest = vi.fn().mockResolvedValue({ items: [], page: { totalItems: 0 } }); + const client = makeClient(mockRequest); + + await registry.dispatch(client, "discovered_agent", "list", { + org_id: "o", + project_id: "p", + environment_id: "env-1", + }); + + expect(mockRequest.mock.calls[0][0].params.environmentIdentifier).toBe("env-1"); + }); + + it("keeps serviceCount/networkMapCount through the compact projection", () => { + const compactFn = registry.getResource("discovered_agent").compactItem; + expect(compactFn).toBeDefined(); + + const [slim] = compactItems( + [ + { + identity: "agent-1", + name: "my-agent", + environmentIdentifier: "env-1", + serviceCount: 12, + networkMapCount: 3, + token: "SECRET", + config: { some: "verbose blob" }, + }, + ], + compactFn, + ) as Record[]; + + expect(slim.identity).toBe("agent-1"); + expect(slim.serviceCount).toBe(12); + expect(slim.networkMapCount).toBe(3); + // Verbose/secret fields are dropped. + expect(slim.token).toBeUndefined(); + expect(slim.config).toBeUndefined(); + }); +}); diff --git a/tests/registry/chaos-service.test.ts b/tests/registry/chaos-service.test.ts new file mode 100644 index 000000000..abfaade6b --- /dev/null +++ b/tests/registry/chaos-service.test.ts @@ -0,0 +1,213 @@ +/** + * Verifies chaos_service list filters and the list_experiment_runs / + * list_load_tests execute actions against the v3 chaos-services REST surface + * (/chaos/manager/api/v3/chaos-services), per hce-saas + * graphql/server/handlers/chaosservices/v3/*.go. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Registry } from "../../src/registry/index.js"; +import type { Config } from "../../src/config.js"; +import type { HarnessClient } from "../../src/client/harness-client.js"; + +function makeConfig(overrides: Partial = {}): Config { + return { + HARNESS_API_KEY: "pat.test", + HARNESS_ACCOUNT_ID: "test-account", + HARNESS_BASE_URL: "https://app.harness.io", + HARNESS_ORG: "default", + HARNESS_PROJECT: "test-project", + HARNESS_API_TIMEOUT_MS: 30000, + HARNESS_MAX_RETRIES: 3, + LOG_LEVEL: "info", + HARNESS_MAX_BODY_SIZE_MB: 10, + HARNESS_RATE_LIMIT_RPS: 10, + HARNESS_READ_ONLY: false, + HARNESS_SKIP_ELICITATION: false, + HARNESS_ALLOW_HTTP: false, + HARNESS_FME_BASE_URL: "https://api.split.io", + ...overrides, + }; +} + +function makeClient(requestFn?: (...args: unknown[]) => unknown): HarnessClient { + return { + request: requestFn ?? vi.fn().mockResolvedValue({}), + account: "test-account", + } as unknown as HarnessClient; +} + +// ── list ────────────────────────────────────────────────────────────── +describe("chaos_service list", () => { + let registry: Registry; + beforeEach(() => { + registry = new Registry(makeConfig()); + }); + + it("maps probe_ids and onboarding_id to probeIds / onboardingId query params", async () => { + const mockRequest = vi.fn().mockResolvedValue({ data: [], pagination: { totalItems: 0 } }); + const client = makeClient(mockRequest); + await registry.dispatch(client, "chaos_service", "list", { + org_id: "o", + project_id: "p", + probe_ids: "probe-1,probe-2", + onboarding_id: "batch-1", + }); + const call = mockRequest.mock.calls[0][0]; + expect(call.method).toBe("GET"); + expect(call.path).toBe("/chaos/manager/api/v3/chaos-services"); + expect(call.params.probeIds).toBe("probe-1,probe-2"); + expect(call.params.onboardingId).toBe("batch-1"); + }); + + it("still maps the existing filters (environment_ids, infrastructure_ids, tags, include_all_scope)", async () => { + const mockRequest = vi.fn().mockResolvedValue({ data: [], pagination: { totalItems: 0 } }); + const client = makeClient(mockRequest); + await registry.dispatch(client, "chaos_service", "list", { + org_id: "o", + project_id: "p", + environment_ids: "env-1", + infrastructure_ids: "infra-1", + tags: "a,b", + include_all_scope: true, + search: "svc", + sort_field: "name", + sort_ascending: true, + limit: 25, + page: 2, + }); + const params = mockRequest.mock.calls[0][0].params; + expect(params.environmentIds).toBe("env-1"); + expect(params.infrastructureIds).toBe("infra-1"); + expect(params.tags).toBe("a,b"); + expect(params.includeAllScope).toBe(true); + expect(params.search).toBe("svc"); + expect(params.sortField).toBe("name"); + expect(params.sortAscending).toBe(true); + expect(params.limit).toBe(25); + expect(params.page).toBe(2); + }); +}); + +// ── list_experiment_runs execute action ───────────────────────────────── +describe("chaos_service execute: list_experiment_runs", () => { + let registry: Registry; + beforeEach(() => { + registry = new Registry(makeConfig()); + }); + + it("GETs /v3/chaos-services/{identity}/experiment-runs with the identity path-substituted", async () => { + const mockRequest = vi.fn().mockResolvedValue({ data: [], correlationID: "c-1", pagination: { totalItems: 0 } }); + const client = makeClient(mockRequest); + await registry.dispatchExecute(client, "chaos_service", "list_experiment_runs", { + identity: "svc-1", + org_id: "o", + project_id: "p", + }); + const call = mockRequest.mock.calls[0][0]; + expect(call.method).toBe("GET"); + expect(call.path).toBe("/chaos/manager/api/v3/chaos-services/svc-1/experiment-runs"); + }); + + it("maps page/limit/search/sort/include_all_scope/infra_ids/statuses/step_types query params", async () => { + const mockRequest = vi.fn().mockResolvedValue({ data: [], pagination: { totalItems: 0 } }); + const client = makeClient(mockRequest); + await registry.dispatchExecute(client, "chaos_service", "list_experiment_runs", { + identity: "svc-1", + org_id: "o", + project_id: "p", + page: 1, + limit: 10, + search: "run", + sort_field: "lastUpdated", + sort_ascending: false, + include_all_scope: true, + infra_ids: "infra-1,infra-2", + statuses: "Completed,Running", + step_types: "PodDelete", + }); + const params = mockRequest.mock.calls[0][0].params; + expect(params.page).toBe(1); + expect(params.limit).toBe(10); + expect(params.search).toBe("run"); + expect(params.sortField).toBe("lastUpdated"); + expect(params.sortAscending).toBe(false); + expect(params.includeAllScope).toBe(true); + expect(params.infraIds).toBe("infra-1,infra-2"); + expect(params.statuses).toBe("Completed,Running"); + expect(params.stepTypes).toBe("PodDelete"); + }); + + it("passes the raw { data, pagination } envelope through unchanged", async () => { + const backendResponse = { + data: [{ experimentRunID: "run-1", experimentName: "exp-1", phase: "Completed" }], + correlationID: "c-1", + pagination: { totalItems: 1 }, + }; + const mockRequest = vi.fn().mockResolvedValue(backendResponse); + const client = makeClient(mockRequest); + const result = await registry.dispatchExecute(client, "chaos_service", "list_experiment_runs", { + identity: "svc-1", + org_id: "o", + project_id: "p", + }); + expect(result).toEqual(backendResponse); + }); +}); + +// ── list_load_tests execute action ────────────────────────────────────── +describe("chaos_service execute: list_load_tests", () => { + let registry: Registry; + beforeEach(() => { + registry = new Registry(makeConfig()); + }); + + it("GETs /v3/chaos-services/{identity}/load-tests with the identity path-substituted", async () => { + const mockRequest = vi.fn().mockResolvedValue({ data: [], correlationID: "c-1", pagination: { totalItems: 0 } }); + const client = makeClient(mockRequest); + await registry.dispatchExecute(client, "chaos_service", "list_load_tests", { + identity: "svc-1", + org_id: "o", + project_id: "p", + }); + const call = mockRequest.mock.calls[0][0]; + expect(call.method).toBe("GET"); + expect(call.path).toBe("/chaos/manager/api/v3/chaos-services/svc-1/load-tests"); + }); + + it("maps tool_type/environment_ids/infra_ids/tags query params", async () => { + const mockRequest = vi.fn().mockResolvedValue({ data: [], pagination: { totalItems: 0 } }); + const client = makeClient(mockRequest); + await registry.dispatchExecute(client, "chaos_service", "list_load_tests", { + identity: "svc-1", + org_id: "o", + project_id: "p", + tool_type: "K6", + environment_ids: "env-1", + infra_ids: "infra-1", + tags: "a,b", + include_all_scope: true, + }); + const params = mockRequest.mock.calls[0][0].params; + expect(params.toolType).toBe("K6"); + expect(params.environmentIds).toBe("env-1"); + expect(params.infraIds).toBe("infra-1"); + expect(params.tags).toBe("a,b"); + expect(params.includeAllScope).toBe(true); + }); + + it("passes the raw { data, pagination } envelope through unchanged", async () => { + const backendResponse = { + data: [{ identity: "lt-1", name: "load-test-1", toolType: "Locust" }], + correlationID: "c-1", + pagination: { totalItems: 1 }, + }; + const mockRequest = vi.fn().mockResolvedValue(backendResponse); + const client = makeClient(mockRequest); + const result = await registry.dispatchExecute(client, "chaos_service", "list_load_tests", { + identity: "svc-1", + org_id: "o", + project_id: "p", + }); + expect(result).toEqual(backendResponse); + }); +});