Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@
- Exposes tools across workflows, executions, artifacts, and metadata via `testkube mcp serve` (CLI), Docker image (`testkube/mcp-server`), or Control Plane's `/mcp` endpoint per environment.
- Uses interface-based tool design; new tools need registration in both `pkg/mcp/server.go` and control plane's `mcp_handler.go`.
- See `pkg/mcp/README.md` for architecture, tool patterns, and usage examples.
- Insights board tools (`pkg/mcp/tools/boards.go`) keep their rules in `pkg/mcp/boards/`: report param validation and defaults, the report-to-`/insights/*` query translation, and the layout. It is a port of the dashboard's TypeScript (`utils/insights.ts`, `DynamicFilters/types.ts`, `reports/*/type.ts` in `testkube-cloud-api/js/packages/web`), since the Control Plane stores report params opaquely. **The Control Plane's `HandlerClient` must use this package rather than reimplement it**, and a dashboard change to those files needs a matching change here; `testdata/translation_cases.json` pins the translation.
- Boards are organization-scoped and the Control Plane refuses API tokens on every board endpoint, so the board tools need a user session. `APIClient` refuses a `tkcapi_` token before sending anything and returns `tools.ErrBoardsRequireUser`. Every board write reads the board first and resends its description. Current Control Planes keep a description an update omits, but older ones clear it, so resending is what keeps it on those.
- **Board updates are optimistic-concurrency writes.** Resending a value read earlier (the description, a recomputed layout) would overwrite a concurrent edit, so every update - `update_board` and the three report tools - goes through `writeBoard` in `pkg/mcp/tools/boards.go`: it sends `expectedVersion` (the board `version` it read; every write to a board increments it), the Control Plane refuses a stale write with 409, both clients turn that into `tools.ErrBoardChanged`, and the write is rebuilt from a fresh read, up to `boardWriteAttempts` times. A write's builder must derive everything from the board it is handed, never from an earlier read. The token is a counter, not `updatedAt`: two writes can share a timestamp, and a reused token would let a stale write through. A Control Plane that predates versions returns none, and the write is then unconditional. `delete_board` is deliberately not conditional: it resends nothing it read (the read only resolves a slug to the ID it deletes by), and the Control Plane checks visibility and delete rights against the board as it is at delete time, so deleting removes the board whatever changed since the read, as deleting in the dashboard does.
- **Relative report ranges are anchored in a time zone.** The dashboard ends a `day`/`week`/`month`/`quarter` range at the viewer's local midnight, so `render_board` takes an IANA `timeZone` (default UTC) and passes it as `boards.QueryOptions.Location`; `boards` embeds `time/tzdata` because the MCP also runs from images without a zoneinfo database.

## GitOps resource sync

Expand Down
8 changes: 8 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,14 @@ The Testkube CLI (`kubectl-testkube`, typically invoked as `testkube`) is a kube
- Authentication tokens
- Contexts (for multi-environment setups)

### MCP Server

**Location**: [`pkg/mcp/`](pkg/mcp/) (see its [README](pkg/mcp/README.md))

`testkube mcp serve` and the `testkube/mcp-server` image expose Testkube to AI assistants over the Model Context Protocol. The tools in [`pkg/mcp/tools/`](pkg/mcp/tools/) depend on small client interfaces, implemented over HTTP by `APIClient` ([`pkg/mcp/api.go`](pkg/mcp/api.go)) and in-process by the Control Plane's `HandlerClient`, which registers the same tools on its per-environment `/mcp` endpoint.

The Insights board tools keep their shared rules in [`pkg/mcp/boards/`](pkg/mcp/boards/): the report param validation and defaults, the translation of a report into the org-scoped `/insights/*` query that renders it, and the board layout. It is a port of the dashboard's rules, because the Control Plane stores report params opaquely, and both clients use it so that what the MCP writes renders in the dashboard and `render_board` returns the numbers the dashboard shows. Boards are organization-scoped and served only to user sessions, never to API tokens. Board updates are conditional on the board version the tool read (a counter every write increments), so an update built from a stale read is refused and rebuilt rather than overwriting a concurrent edit. Deleting a board is not conditional: it removes the board whatever changed since it was read.

### External Integration: License Event Reporting

The CLI reports installation lifecycle events to the Testkube license service so the
Expand Down
2 changes: 1 addition & 1 deletion api/executor/v1/webhook_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ type SecretRef struct {
Key string `json:"key"`
}

// +kubebuilder:validation:Enum=start-test;end-test-success;end-test-failed;end-test-aborted;end-test-timeout;become-test-up;become-test-down;become-test-failed;become-test-aborted;become-test-timeout;start-testsuite;end-testsuite-success;end-testsuite-failed;end-testsuite-aborted;end-testsuite-timeout;become-testsuite-up;become-testsuite-down;become-testsuite-failed;become-testsuite-aborted;become-testsuite-timeout;start-testworkflow;queue-testworkflow;end-testworkflow-success;end-testworkflow-failed;end-testworkflow-aborted;end-testworkflow-canceled;end-testworkflow-not-passed;become-testworkflow-up;become-testworkflow-down;become-testworkflow-failed;become-testworkflow-aborted;become-testworkflow-canceled;become-testworkflow-not-passed
// +kubebuilder:validation:Enum=start-test;end-test-success;end-test-failed;end-test-aborted;end-test-timeout;become-test-up;become-test-down;become-test-failed;become-test-aborted;become-test-timeout;start-testsuite;end-testsuite-success;end-testsuite-failed;end-testsuite-aborted;end-testsuite-timeout;become-testsuite-up;become-testsuite-down;become-testsuite-failed;become-testsuite-aborted;become-testsuite-timeout;start-testworkflow;queue-testworkflow;end-testworkflow-success;end-testworkflow-failed;end-testworkflow-aborted;end-testworkflow-canceled;end-testworkflow-not-passed;end-testworkflow-test-failure;end-testworkflow-infrastructure-failure;end-testworkflow-configuration-error;become-testworkflow-up;become-testworkflow-down;become-testworkflow-failed;become-testworkflow-aborted;become-testworkflow-canceled;become-testworkflow-not-passed
type EventType string

// List of EventType
Expand Down
6 changes: 6 additions & 0 deletions api/v1/testkube.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8029,6 +8029,9 @@ components:

EventType:
type: string
description: >-
The type of an event. The events end-testworkflow-test-failure, end-testworkflow-infrastructure-failure
and end-testworkflow-configuration-error select executions by the cause of the failure.
enum:
- queue-testworkflow
- start-testworkflow
Expand All @@ -8037,6 +8040,9 @@ components:
- end-testworkflow-aborted
- end-testworkflow-canceled
- end-testworkflow-not-passed
- end-testworkflow-test-failure
- end-testworkflow-infrastructure-failure
- end-testworkflow-configuration-error
- become-testworkflow-up
- become-testworkflow-down
- become-testworkflow-failed
Expand Down
2 changes: 1 addition & 1 deletion cmd/api-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -794,7 +794,7 @@ func main() {
// Push a cluster-resources snapshot to the CP on startup, on CRD informer
// events, and as an hourly safety net. The CP caches it to render the
// TestTrigger resourceRef picker (see AgentInventoryService).
if intconfig.ShouldPushClusterInventory(proContext) {
if intconfig.ShouldPushClusterInventory(proContext, cfg.DisableTestTriggers) {
crdNotifier := inventorycontroller.StartCRDChangeNotifier(ctx, apiextClient, log.DefaultLogger)
clusterResourcesController := &inventorycontroller.ClusterResourcesController{
Discoverer: api.ClusterDiscoverer,
Expand Down
28 changes: 15 additions & 13 deletions cmd/api-server/superagentmigration.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,17 +43,19 @@ type superAgentMigrationKubernetesResourceLister interface {
List(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error
}

// skipUnownedResource reports whether a failure to sync a resource during migration is an ownership
// conflict. Those cannot be cleared by retrying, so the resource is skipped and reported; blocking
// the migration on it would wedge the agent indefinitely with no way out.
func skipUnownedResource(log superAgentMigrationLogger, kind, name string, err error) bool {
if !errors.Is(err, syncagent.ErrOwnershipConflict) {
// skipRejectedResource reports whether the migration must skip a resource that failed to sync.
// It skips and logs a rejection that no retry clears (see syncagent.IsRejection). Without the skip,
// the migration retries that resource forever and never completes.
func skipRejectedResource(log superAgentMigrationLogger, kind, name string, err error) bool {
if !syncagent.IsRejection(err) {
return false
}

log.Errorw("resource is owned by another GitOps agent, skipping it during SuperAgent migration. It will not be present in the Control Plane until its ownership is resolved.",
kind, name,
"error", err.Error())
msg := "resource is owned by another GitOps agent, skipping it during SuperAgent migration. It will not be present in the Control Plane until its ownership is resolved."
if errors.Is(err, syncagent.ErrInvalidResource) {
msg = "the Control Plane rejected the resource as invalid, skipping it during SuperAgent migration. It will not be present in the Control Plane until the resource is fixed."
}
log.Errorw(msg, kind, name, "error", err.Error())

return true
}
Expand Down Expand Up @@ -181,7 +183,7 @@ func migrateSuperAgent(ctx context.Context, log superAgentMigrationLogger, cfg s
for _, t := range testTriggerList.Items {
for {
if err := syncStore.UpdateOrCreateTestTrigger(ctx, t); err != nil {
if skipUnownedResource(log, "TestTrigger", t.Name, err) {
if skipRejectedResource(log, "TestTrigger", t.Name, err) {
break
}
retryAfter := b.Duration()
Expand All @@ -201,7 +203,7 @@ func migrateSuperAgent(ctx context.Context, log superAgentMigrationLogger, cfg s
for _, t := range testWorkflowTemplateList.Items {
for {
if err := syncStore.UpdateOrCreateTestWorkflowTemplate(ctx, t); err != nil {
if skipUnownedResource(log, "TestWorkflowTemplate", t.Name, err) {
if skipRejectedResource(log, "TestWorkflowTemplate", t.Name, err) {
break
}
retryAfter := b.Duration()
Expand All @@ -219,7 +221,7 @@ func migrateSuperAgent(ctx context.Context, log superAgentMigrationLogger, cfg s
for _, t := range testWorkflowList.Items {
for {
if err := syncStore.UpdateOrCreateTestWorkflow(ctx, t); err != nil {
if skipUnownedResource(log, "TestWorkflow", t.Name, err) {
if skipRejectedResource(log, "TestWorkflow", t.Name, err) {
break
}
retryAfter := b.Duration()
Expand All @@ -237,7 +239,7 @@ func migrateSuperAgent(ctx context.Context, log superAgentMigrationLogger, cfg s
for _, t := range webhookList.Items {
for {
if err := syncStore.UpdateOrCreateWebhook(ctx, t); err != nil {
if skipUnownedResource(log, "Webhook", t.Name, err) {
if skipRejectedResource(log, "Webhook", t.Name, err) {
break
}
retryAfter := b.Duration()
Expand All @@ -255,7 +257,7 @@ func migrateSuperAgent(ctx context.Context, log superAgentMigrationLogger, cfg s
for _, t := range webhookTemplateList.Items {
for {
if err := syncStore.UpdateOrCreateWebhookTemplate(ctx, t); err != nil {
if skipUnownedResource(log, "WebhookTemplate", t.Name, err) {
if skipRejectedResource(log, "WebhookTemplate", t.Name, err) {
break
}
retryAfter := b.Duration()
Expand Down
5 changes: 3 additions & 2 deletions cmd/kubectl-testkube/commands/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ import (

func NewAgentCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "agent",
Short: "Testkube Pro Agent related commands",
Use: "runner",
Aliases: []string{"agent"},
Short: "Testkube Pro Runner related commands",
Run: func(cmd *cobra.Command, args []string) {
client, _, err := common.GetClient(cmd)
ui.ExitOnError("getting client", err)
Expand Down
4 changes: 2 additions & 2 deletions cmd/kubectl-testkube/commands/agent/debug.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import (
func NewDebugAgentCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "debug",
Short: "Debug Agent info",
Deprecated: "use `testkube debug agent` instead",
Short: "Debug Runner info",
Deprecated: "use `testkube debug runner` instead",
}

return cmd
Expand Down
7 changes: 4 additions & 3 deletions cmd/kubectl-testkube/commands/agent/migrate.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ import (

func NewMigrateAgentCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "agent",
Short: "manual migrate agent command",
Long: `migrate agent command will run agent migrations greater or equals current version`,
Use: "runner",
Aliases: []string{"agent"},
Short: "manual migrate runner command",
Long: `migrate runner command will run runner migrations greater or equals current version`,
Run: func(cmd *cobra.Command, args []string) {
// TODO: Delete, as we don't have any migrations
},
Expand Down
19 changes: 10 additions & 9 deletions cmd/kubectl-testkube/commands/agents/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@ func NewCreateAgentCommand() *cobra.Command {
agentType string
)
cmd := &cobra.Command{
Use: "agent",
Args: cobra.ExactArgs(1),
Use: "runner",
Aliases: []string{"agent"},
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
// Check for deprecated --type flag usage
if cmd.Flags().Changed("type") {
Expand Down Expand Up @@ -62,8 +63,8 @@ func NewCreateAgentCommand() *cobra.Command {
enableWebhooks,
)
ui.NL()
ui.Hint("Install the agent with command:")
installCmd := fmt.Sprintf("testkube install agent %s --secret %s", agent.Name, agent.SecretKey)
ui.Hint("Install the runner with command:")
installCmd := fmt.Sprintf("testkube install runner %s --secret %s", agent.Name, agent.SecretKey)
if enableExecution {
installCmd += " --execution"
}
Expand All @@ -80,11 +81,11 @@ func NewCreateAgentCommand() *cobra.Command {
},
}

cmd.Flags().StringSliceVarP(&environmentIds, "env", "e", nil, "environment ID or slug that the agent have access to")
cmd.Flags().StringSliceVarP(&environmentIds, "env", "e", nil, "environment ID or slug that the runner have access to")
cmd.Flags().StringSliceVarP(&labelPairs, "label", "l", nil, "label key value pair: --label key1=value1")
cmd.Flags().BoolVar(&global, "global", false, "make it global agent")
cmd.Flags().StringVar(&group, "group", "", "make it grouped agent")
cmd.Flags().BoolVar(&floating, "floating", false, "create as a floating agent")
cmd.Flags().BoolVar(&global, "global", false, "make it global runner")
cmd.Flags().StringVar(&group, "group", "", "make it grouped runner")
cmd.Flags().BoolVar(&floating, "floating", false, "create as a floating runner")

// Components selection
common.AddExecutionCapabilityFlags(cmd)
Expand All @@ -93,7 +94,7 @@ func NewCreateAgentCommand() *cobra.Command {
cmd.Flags().Bool("webhooks", false, "enable webhooks capability")

// Deprecated flag
cmd.Flags().StringVarP(&agentType, "type", "t", "", "[DEPRECATED] agent type - use capability flags instead")
cmd.Flags().StringVarP(&agentType, "type", "t", "", "[DEPRECATED] runner type - use capability flags instead")
cmd.Flags().MarkDeprecated("type", "use --execution, --listener, --gitops, and/or --webhooks instead")

return cmd
Expand Down
Loading