Writing a custom Action¶
A custom action is one struct that implements api.Action (internal/api/action.go) plus an init() that calls api.RegisterAction. The dispatcher (internal/actions/engine) picks it up from the registry, applies rate-limiting and idempotency, and routes violations to its Execute() method.
The interface¶
// internal/api/action.go
type Action interface {
Type() string
Execute(ctx context.Context, v Violation, params map[string]any) error
Idempotent() bool
DefaultRateLimit() time.Duration
}
Type()— the string a rule writes underactions[].type. Globally unique. The five built-ins arelabel,annotate,evict,patch-networkpolicy,revoke-sa-token(plusalertmanager).Execute()— the work. Receives theViolation, the rule'sactions[].paramsbag, and a context. Return a non-nil error to mark the dispatch asresult="error".Idempotent()—trueif re-runningExecuteis safe. The dispatcher uses this to decide whether theIdempotencyStoresuppresses repeats.DefaultRateLimit()— fallback when the rule'sactions[].rateLimitis empty.
The Configure-on-client pattern¶
Actions that need a Kubernetes client cannot get it at init() time — the kubeconfig isn't loaded yet. The pattern (look at internal/actions/label/action.go for a full reference) is:
init()registers a placeholder factory that returns an action whoseExecute()immediately returnsErrNotConfigured.- The composition root (
cmd/portal/wire.go) calls a package-levelConfigure(client)once the client exists. This swaps the placeholder for the real action.
package myaction
import (
"context"
"errors"
"time"
"github.com/vilaca/portal/internal/api"
)
const actionType = "myaction"
var ErrNotConfigured = errors.New("myaction action not configured")
func init() {
api.RegisterAction(actionType, func() api.Action { return &action{} })
}
type action struct {
client SomeClient
}
func Configure(c SomeClient) { defaultAction.client = c }
var defaultAction = &action{}
func (a *action) Type() string { return actionType }
func (a *action) Idempotent() bool { return true }
func (a *action) DefaultRateLimit() time.Duration { return time.Minute }
func (a *action) Execute(ctx context.Context, v api.Violation, params map[string]any) error {
if a.client == nil {
return ErrNotConfigured
}
// ...real work...
return nil
}
Example — a hypothetical "slack" action¶
40 lines for a webhook-style notifier:
// internal/actions/slack/action.go
package slack
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"github.com/vilaca/portal/internal/api"
)
const actionType = "slack"
var ErrNotConfigured = errors.New("slack action not configured")
func init() {
api.RegisterAction(actionType, func() api.Action { return defaultAction })
}
type action struct {
webhookURL string
httpClient *http.Client
}
var defaultAction = &action{httpClient: &http.Client{Timeout: 5 * time.Second}}
// Configure is called from cmd/portal/wire.go once the URL is known.
func Configure(url string) { defaultAction.webhookURL = url }
func (a *action) Type() string { return actionType }
func (a *action) Idempotent() bool { return false } // each call is a fresh notification
func (a *action) DefaultRateLimit() time.Duration { return 5 * time.Minute }
func (a *action) Execute(ctx context.Context, v api.Violation, params map[string]any) error {
if a.webhookURL == "" {
return ErrNotConfigured
}
channel, _ := params["channel"].(string)
body, _ := json.Marshal(map[string]any{
"channel": channel,
"text": fmt.Sprintf(":warning: *%s* on %s/%s: %s", v.Rule, v.GVK.Kind, v.Name, v.Message),
})
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, a.webhookURL, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := a.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("slack webhook returned %d", resp.StatusCode)
}
return nil
}
Wire-up:
// cmd/portal/wire.go
import _ "github.com/vilaca/portal/internal/actions/slack"
// later, in runPortal:
if opts.slackURL != "" {
slack.Configure(opts.slackURL)
}
Rule that uses it:
RBAC implications¶
If your action talks to the Kubernetes API, you must also update the chart's templates/clusterrole.yaml to add the matching verbs, and gate the rule under a rbac.actions.<name> Helm value (see how the existing actions do it). Without RBAC the action returns errors and portal_actions_total{action=...,result="error"} increments — visible but not catastrophic.
Actions that only talk to external services (Slack, PagerDuty, a custom webhook) need no Kubernetes RBAC.
Testing¶
Each built-in action has a small unit test under internal/actions/<name>/action_test.go — copy that pattern:
- Construct the action via the package factory.
- Stub the client interface.
- Call
Executedirectly and assert side effects. - Cover
ErrNotConfigured(action used beforeConfigure()). - Cover
Idempotent()andDefaultRateLimit()return values.
End-to-end coverage lives in deploy/test/; new actions land integration tests there too.