Skip to main content

Adding Custom Actions

ChaosPlane's executor system is designed to be extensible. Adding a new action type requires implementing the Executor interface, registering it, and writing tests.

The Executor interface

Every action implements three methods:

type Executor interface {
// Execute runs the chaos action against the target.
Execute(ctx context.Context, exp *v1alpha1.ChaosExperiment) error

// Rollback reverses the chaos action.
Rollback(ctx context.Context, exp *v1alpha1.ChaosExperiment) error

// Validate checks whether the experiment spec is valid for this executor.
Validate(exp *v1alpha1.ChaosExperiment) error
}

Step 1: Create the executor file

Create a new file in the appropriate package:

  • Pod-scoped actions: internal/executor/pod/
  • Network actions: internal/executor/network/
  • Node actions: internal/executor/node/
  • New category: internal/executor/mycategory/

Here's a minimal example — a pod-freeze action that pauses a container process:

// internal/executor/pod/freeze.go
package pod

import (
"context"
"fmt"
"log/slog"

"sigs.k8s.io/controller-runtime/pkg/client"

v1alpha1 "github.com/chaosplane-hq/chaosplane/api/v1alpha1"
daemonv1 "github.com/chaosplane-hq/chaosplane/gen/daemon/v1"
"github.com/chaosplane-hq/chaosplane/internal/executor"
)

var _ executor.Executor = (*FreezeExecutor)(nil)

type FreezeExecutor struct {
Logger *slog.Logger
Client client.Client
DaemonFactory DaemonClientFactory
}

func NewFreezeExecutor(logger *slog.Logger, c client.Client, df DaemonClientFactory) *FreezeExecutor {
return &FreezeExecutor{Logger: logger, Client: c, DaemonFactory: df}
}

func (e *FreezeExecutor) Execute(ctx context.Context, exp *v1alpha1.ChaosExperiment) error {
pods, err := ResolveTargetPods(ctx, e.Client, exp.Spec.Target)
if err != nil {
return fmt.Errorf("pod-freeze: resolve targets: %w", err)
}

params, err := ParseParameters(exp)
if err != nil {
return fmt.Errorf("pod-freeze: %w", err)
}

for _, p := range pods {
endpoint := ResolveDaemonEndpoint(p.Spec.NodeName)
dc, err := e.DaemonFactory(endpoint)
if err != nil {
return fmt.Errorf("pod-freeze: daemon client for node %s: %w", p.Spec.NodeName, err)
}

e.Logger.InfoContext(ctx, "freezing container", "pod", p.Name, "container", params["containerName"])
resp, err := dc.ExecStressChaos(ctx, &daemonv1.StressChaosRequest{
ExperimentId: string(exp.UID),
StressorType: "freeze",
Parameters: map[string]string{
"containerName": params["containerName"],
"podName": p.Name,
"podNamespace": p.Namespace,
},
})
if err != nil {
return fmt.Errorf("pod-freeze: exec on pod %s/%s: %w", p.Namespace, p.Name, err)
}
if !resp.Success {
return fmt.Errorf("pod-freeze: daemon failure for %s/%s: %s", p.Namespace, p.Name, resp.Message)
}
}
return nil
}

func (e *FreezeExecutor) Rollback(ctx context.Context, exp *v1alpha1.ChaosExperiment) error {
// Send SIGCONT to unfreeze — implement via daemon CancelChaos or a new RPC
return nil
}

func (e *FreezeExecutor) Validate(exp *v1alpha1.ChaosExperiment) error {
if exp.Spec.Target.Namespace == "" {
return fmt.Errorf("pod-freeze: target namespace is required")
}
if exp.Spec.Target.LabelSelector == nil && len(exp.Spec.Target.Names) == 0 {
return fmt.Errorf("pod-freeze: target must specify names or labelSelector")
}
params, err := ParseParameters(exp)
if err != nil {
return fmt.Errorf("pod-freeze: %w", err)
}
if params["containerName"] == "" {
return fmt.Errorf("pod-freeze: containerName parameter is required")
}
return nil
}

Step 2: Register the executor

Open internal/executor/registry.go and add your executor:

func NewRegistry(logger *slog.Logger, c client.Client, cs kubernetes.Interface, df pod.DaemonClientFactory) *Registry {
r := &Registry{executors: make(map[string]Executor)}

// ... existing registrations ...

// Register your new action
r.Register("pod-freeze", pod.NewFreezeExecutor(logger, c, df))

return r
}

Step 3: Write tests

Create a test file alongside your executor:

// internal/executor/pod/freeze_test.go
package pod_test

import (
"context"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

v1alpha1 "github.com/chaosplane-hq/chaosplane/api/v1alpha1"
"github.com/chaosplane-hq/chaosplane/internal/executor/pod"
)

func TestFreezeExecutor_Validate(t *testing.T) {
tests := []struct {
name string
exp *v1alpha1.ChaosExperiment
wantErr bool
}{
{
name: "valid",
exp: makeExperiment("pod-freeze", map[string]string{
"containerName": "app",
}),
wantErr: false,
},
{
name: "missing containerName",
exp: makeExperiment("pod-freeze", map[string]string{}),
wantErr: true,
},
{
name: "missing namespace",
exp: makeExperimentNoNamespace("pod-freeze", map[string]string{
"containerName": "app",
}),
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
e := pod.NewFreezeExecutor(testLogger(), nil, nil)
err := e.Validate(tt.exp)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}

func TestFreezeExecutor_Execute(t *testing.T) {
// Use the mock daemon client from mock_test.go
mockDaemon := &mockDaemonClient{
execStressResponse: &daemonv1.ChaosResponse{
Success: true,
ExecutionId: "exec-123",
},
}

e := pod.NewFreezeExecutor(testLogger(), fakePodClient(), func(endpoint string) (pod.DaemonClient, error) {
return mockDaemon, nil
})

exp := makeExperiment("pod-freeze", map[string]string{"containerName": "app"})
err := e.Execute(context.Background(), exp)
require.NoError(t, err)
assert.Equal(t, "freeze", mockDaemon.lastStressorType)
}

Step 4: Add daemon support (if needed)

If your action requires new daemon functionality, add a new RPC to the protobuf definition:

// proto/daemon/v1/daemon.proto
service DaemonService {
// ... existing RPCs ...
rpc ExecFreezeChaos(FreezeChaosRequest) returns (ChaosResponse);
}

message FreezeChaosRequest {
string experiment_id = 1;
map<string, string> parameters = 2;
}

Regenerate the gRPC code:

make proto

Then implement the handler in the daemon:

// cmd/daemon/server/freeze.go
func (s *Server) ExecFreezeChaos(ctx context.Context, req *daemonv1.FreezeChaosRequest) (*daemonv1.ChaosResponse, error) {
// Implement SIGSTOP/SIGCONT logic
}

Step 5: Document the action

Add a reference page at docs/reference/actions/pod-freeze.md following the same structure as existing action pages:

  • Description
  • Parameters table
  • Example YAML
  • Rollback behavior
  • Implementation notes

Checklist

  • Implement Executor interface (Execute, Rollback, Validate)
  • Register in internal/executor/registry.go
  • Write unit tests for Validate and Execute
  • Add daemon support if needed (proto + handler)
  • Write documentation page
  • Update the sidebar in docs-site/sidebars.ts
  • Run make test and make lint

Tips

  • Use ParseParameters(exp) from internal/executor/pod/common.go to extract action parameters
  • Use ResolveTargetPods or ResolveTargetNodes to resolve targets
  • Use ResolveDaemonEndpoint(nodeName) to get the daemon gRPC address for a node
  • Store execution IDs in a sync.Mutex-protected map if you need rollback support
  • Always validate required parameters in Validate before Execute is called
  • Return wrapped errors with the action type prefix: fmt.Errorf("pod-freeze: %w", err)