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
Executorinterface (Execute,Rollback,Validate) - Register in
internal/executor/registry.go - Write unit tests for
ValidateandExecute - Add daemon support if needed (proto + handler)
- Write documentation page
- Update the sidebar in
docs-site/sidebars.ts - Run
make testandmake lint
Tips
- Use
ParseParameters(exp)frominternal/executor/pod/common.goto extract action parameters - Use
ResolveTargetPodsorResolveTargetNodesto 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
ValidatebeforeExecuteis called - Return wrapped errors with the action type prefix:
fmt.Errorf("pod-freeze: %w", err)