diff --git a/pkg/tasks/tasks.go b/pkg/tasks/tasks.go index c4b9c8b4..f430c632 100644 --- a/pkg/tasks/tasks.go +++ b/pkg/tasks/tasks.go @@ -45,6 +45,8 @@ import ( runtasks "github.com/ethpandaops/assertoor/pkg/tasks/run_tasks" runtasksconcurrent "github.com/ethpandaops/assertoor/pkg/tasks/run_tasks_concurrent" sleep "github.com/ethpandaops/assertoor/pkg/tasks/sleep" + tysmhookactivation "github.com/ethpandaops/assertoor/pkg/tasks/tysm_hook_activation" + tysmhookdeactivation "github.com/ethpandaops/assertoor/pkg/tasks/tysm_hook_deactivation" ) var AvailableTaskDescriptors = []*types.TaskDescriptor{ @@ -90,6 +92,8 @@ var AvailableTaskDescriptors = []*types.TaskDescriptor{ runtasks.TaskDescriptor, runtasksconcurrent.TaskDescriptor, sleep.TaskDescriptor, + tysmhookactivation.TaskDescriptor, + tysmhookdeactivation.TaskDescriptor, } func GetTaskDescriptor(name string) *types.TaskDescriptor { diff --git a/pkg/tasks/tysm_hook_activation/README.md b/pkg/tasks/tysm_hook_activation/README.md new file mode 100644 index 00000000..f9f18a47 --- /dev/null +++ b/pkg/tasks/tysm_hook_activation/README.md @@ -0,0 +1,111 @@ +## `tysm_hook_activation` Task + +### Description + +Creates a TTL-bound activation against a TYSM beacon node's hook-control API +(`POST /tysm/v1/activations`). + +An activation overlays `(enabled, configPatch)` on the hook's baseline state +for a bounded duration. When the TTL expires (or the activation is deleted +via `tysm_hook_deactivation`), the hook reverts to its baseline. Designed +for chaos-style flows where assertoor flips a hook to a non-default state, +exercises the network, and then cleans up. + +This task is a paired primitive: pair it with `tysm_hook_deactivation` +placed in the test's top-level `cleanupTasks:` block so the activation is +torn down even if the test fails. + +#### Task Behavior + +- POSTs the activation request and returns immediately on `201 Created`. +- Records `activation_id` and `expires_at` as task outputs for later use + (typically by a deactivation task in cleanup). +- Fails on any non-`201` response, surfacing the server's error message. +- Does not wait for or assert anything about hook side-effects — that is + the responsibility of subsequent tasks in the playbook. + +### Configuration Parameters + +- **`endpoint`**:\ + Base URL of the TYSM API, e.g. `http://beacon:8080`. Required. + +- **`auth_token`**:\ + Bearer token sent in the `Authorization` header. Required when the TYSM + API has auth enabled. Recommended to supply via `configVars` so the + secret is not hard-coded into the playbook. + +- **`hook`**:\ + Name of the hook to activate. Must be a hook implementing + `RuntimeReconfigurable` on the server side (currently `blob-mutator`, + `data-column-mutator`); other names are rejected with `400`. + +- **`enabled`**:\ + Optional boolean override of the hook's enabled flag while the activation + is in force. Either this or `configPatch` (or both) must be supplied. + +- **`configPatch`**:\ + Optional shallow patch over the hook's baseline configuration. Top-level + keys present here wholly replace the corresponding baseline keys; absent + keys keep their baseline value. + +- **`duration`**:\ + Activation TTL as a Go duration string (`10m`, `1h`, ...). Required. The + server enforces a hard cap (`api.max_activation_duration`); requests + exceeding it are rejected. + +- **`replace`**:\ + If `true`, replace any existing activation against the same hook instead + of returning `409 Conflict`. Default `false`. + +### Defaults + +```yaml +- name: tysm_hook_activation + config: + endpoint: "" + auth_token: "" + hook: "" + enabled: null + configPatch: {} + duration: "0s" + replace: false +``` + +### Outputs + +| Name | Type | Description | +|------------------|----------|------------------------------------------------------------------------------| +| `activation_id` | `string` | Server-assigned activation ID. Pass to `tysm_hook_deactivation`. | +| `expires_at` | `string` | RFC3339 timestamp at which the server-side TTL expires. | +| `hook` | `string` | Hook the activation targets (echoes the input). | + +### Example + +```yaml +tests: + - id: kzg_chaos_run + name: "blob-mutator KZG chaos" + cleanupTasks: + - name: tysm_hook_deactivation + config: + endpoint: "http://beacon:8080" + configVars: + auth_token: "tysmApiToken" + activation_id: "tasks.kzg_chaos.outputs.activation_id" + tasks: + - name: tysm_hook_activation + id: kzg_chaos + config: + endpoint: "http://beacon:8080" + hook: "blob-mutator" + enabled: true + configPatch: + mutationProbability: 1.0 + enabledStrategies: ["kzg-corruption"] + duration: "10m" + replace: false + configVars: + auth_token: "tysmApiToken" + + # ... assertions about network behaviour while activation is in force ... +``` diff --git a/pkg/tasks/tysm_hook_activation/config.go b/pkg/tasks/tysm_hook_activation/config.go new file mode 100644 index 00000000..2201803e --- /dev/null +++ b/pkg/tasks/tysm_hook_activation/config.go @@ -0,0 +1,53 @@ +package tysmhookactivation + +import ( + "errors" + "net/url" + "strings" + + "github.com/ethpandaops/assertoor/pkg/helper" +) + +// Config drives a single POST to the TYSM hook-control API. +// +// AuthToken is intentionally yaml-tagged with snake_case: it is expected +// to be supplied via the task's configVars (e.g. configVars: { auth_token: +// "tysmApiToken" }) so the secret never lives in the playbook source. +// Inline assignment under config: still works, it is just discouraged. +type Config struct { + Endpoint string `yaml:"endpoint" json:"endpoint" require:"A" desc:"Base URL of the TYSM API, e.g. http://beacon:8080"` + AuthToken string `yaml:"auth_token" json:"auth_token" desc:"Bearer token sent in the Authorization header. Prefer supplying via configVars."` + Hook string `yaml:"hook" json:"hook" require:"A" desc:"Name of the TYSM hook to activate (e.g. blob-mutator)."` + Enabled *bool `yaml:"enabled,omitempty" json:"enabled,omitempty" desc:"Override the hook's enabled flag while the activation is in force."` + ConfigPatch map[string]interface{} `yaml:"configPatch,omitempty" json:"configPatch,omitempty" desc:"Top-level config keys to overlay on top of the hook's baseline configuration."` + Duration helper.Duration `yaml:"duration" json:"duration" require:"A" desc:"Activation TTL (Go duration: 10m, 1h, ...). The server enforces a hard cap; values exceeding it are rejected."` + Replace bool `yaml:"replace" json:"replace" desc:"If true, replace any existing activation for the same hook instead of returning 409 Conflict."` +} + +func DefaultConfig() Config { + return Config{} +} + +func (c *Config) Validate() error { + if strings.TrimSpace(c.Endpoint) == "" { + return errors.New("endpoint is required") + } + + if _, err := url.Parse(c.Endpoint); err != nil { + return errors.New("endpoint must be a valid URL") + } + + if strings.TrimSpace(c.Hook) == "" { + return errors.New("hook is required") + } + + if c.Duration.Duration <= 0 { + return errors.New("duration must be greater than 0") + } + + if c.Enabled == nil && len(c.ConfigPatch) == 0 { + return errors.New("at least one of enabled or configPatch must be set") + } + + return nil +} diff --git a/pkg/tasks/tysm_hook_activation/task.go b/pkg/tasks/tysm_hook_activation/task.go new file mode 100644 index 00000000..f10e4633 --- /dev/null +++ b/pkg/tasks/tysm_hook_activation/task.go @@ -0,0 +1,223 @@ +package tysmhookactivation + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/ethpandaops/assertoor/pkg/types" + "github.com/sirupsen/logrus" +) + +const ( + apiPath = "/tysm/v1/activations" + requestTimeout = 30 * time.Second +) + +var ( + TaskName = "tysm_hook_activation" + TaskDescriptor = &types.TaskDescriptor{ + Name: TaskName, + Description: "Create a TTL-bound activation against a TYSM hook-control API.", + Category: "tysm", + Config: DefaultConfig(), + Outputs: []types.TaskOutputDefinition{ + { + Name: "activation_id", + Type: "string", + Description: "Server-assigned activation ID; pass to tysm_hook_deactivation as activation_id.", + }, + { + Name: "expires_at", + Type: "string", + Description: "RFC3339 timestamp at which the server-side TTL expires and the hook reverts to baseline.", + }, + { + Name: "hook", + Type: "string", + Description: "Name of the hook the activation targets (echoes the input).", + }, + }, + NewTask: NewTask, + } +) + +type Task struct { + ctx *types.TaskContext + options *types.TaskOptions + config Config + logger logrus.FieldLogger +} + +// activationRequest mirrors overlay/tysm/api.CreateActivationRequest. We +// declare it locally so this task has no dependency on the tysm overlay +// module — the two repos live in different module paths and the overlay +// only exists post-patch-apply inside a Prysm clone. +type activationRequest struct { + Hook string `json:"hook"` + Enabled *bool `json:"enabled,omitempty"` + ConfigPatch map[string]interface{} `json:"config_patch,omitempty"` + Duration string `json:"duration"` + Replace bool `json:"replace,omitempty"` +} + +// activationResponse mirrors overlay/tysm/api.ActivationView. Effective is +// intentionally decoded as a generic map: this task does not introspect it, +// it only forwards the activation handle (id, expires_at) to downstream +// tasks via outputs. +type activationResponse struct { + ID string `json:"id"` + Hook string `json:"hook"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` + Effective map[string]interface{} `json:"effective"` +} + +// errorResponse mirrors overlay/tysm/api.ErrorResponse. Used to surface +// the server-side reason in task error messages. +type errorResponse struct { + Error string `json:"error"` + Details string `json:"details,omitempty"` +} + +func NewTask(ctx *types.TaskContext, options *types.TaskOptions) (types.Task, error) { + return &Task{ + ctx: ctx, + options: options, + logger: ctx.Logger.GetLogger(), + }, nil +} + +func (t *Task) Config() interface{} { + return t.config +} + +func (t *Task) Timeout() time.Duration { + return t.options.Timeout.Duration +} + +func (t *Task) LoadConfig() error { + config := DefaultConfig() + + if t.options.Config != nil { + if err := t.options.Config.Unmarshal(&config); err != nil { + return fmt.Errorf("error parsing task config for %v: %w", TaskName, err) + } + } + + err := t.ctx.Vars.ConsumeVars(&config, t.options.ConfigVars) + if err != nil { + return err + } + + if err := config.Validate(); err != nil { + return err + } + + t.config = config + + return nil +} + +func (t *Task) Execute(ctx context.Context) error { + t.ctx.ReportProgress(0, fmt.Sprintf("Activating hook %q", t.config.Hook)) + + body, err := json.Marshal(activationRequest{ + Hook: t.config.Hook, + Enabled: t.config.Enabled, + ConfigPatch: t.config.ConfigPatch, + Duration: t.config.Duration.String(), + Replace: t.config.Replace, + }) + if err != nil { + return fmt.Errorf("encode activation request: %w", err) + } + + endpoint := strings.TrimRight(t.config.Endpoint, "/") + apiPath + + reqCtx, cancel := context.WithTimeout(ctx, requestTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + + if t.config.AuthToken != "" { + req.Header.Set("Authorization", "Bearer "+t.config.AuthToken) + } + + client := &http.Client{Timeout: requestTimeout} + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("POST %s: %w", endpoint, err) + } + + defer func() { + if closeErr := resp.Body.Close(); closeErr != nil { + t.logger.WithError(closeErr).Warn("failed to close response body") + } + }() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode != http.StatusCreated { + return fmt.Errorf("activation request failed: %s", formatHTTPError(resp.StatusCode, respBody)) + } + + var view activationResponse + if err := json.Unmarshal(respBody, &view); err != nil { + return fmt.Errorf("decode activation response: %w", err) + } + + if view.ID == "" { + return fmt.Errorf("server returned 201 but no activation id") + } + + t.ctx.Outputs.SetVar("activation_id", view.ID) + t.ctx.Outputs.SetVar("expires_at", view.ExpiresAt.Format(time.RFC3339)) + t.ctx.Outputs.SetVar("hook", view.Hook) + + t.logger.WithFields(logrus.Fields{ + "hook": view.Hook, + "activation_id": view.ID, + "expires_at": view.ExpiresAt.Format(time.RFC3339), + }).Info("TYSM hook activation created") + + t.ctx.SetResult(types.TaskResultSuccess) + t.ctx.ReportProgress(100, fmt.Sprintf("Activation %s expires at %s", view.ID, view.ExpiresAt.Format(time.RFC3339))) + + return nil +} + +// formatHTTPError flattens the server's JSON error envelope into one line +// suitable for an Execute error return. Falls back to the raw body when +// the response is not valid JSON or carries no Error field. +func formatHTTPError(status int, body []byte) string { + var errResp errorResponse + if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error != "" { + if errResp.Details != "" { + return fmt.Sprintf("HTTP %d: %s (%s)", status, errResp.Error, errResp.Details) + } + + return fmt.Sprintf("HTTP %d: %s", status, errResp.Error) + } + + trimmed := strings.TrimSpace(string(body)) + if trimmed == "" { + return fmt.Sprintf("HTTP %d", status) + } + + return fmt.Sprintf("HTTP %d: %s", status, trimmed) +} diff --git a/pkg/tasks/tysm_hook_activation/task_test.go b/pkg/tasks/tysm_hook_activation/task_test.go new file mode 100644 index 00000000..1e9c7624 --- /dev/null +++ b/pkg/tasks/tysm_hook_activation/task_test.go @@ -0,0 +1,391 @@ +package tysmhookactivation + +import ( + "context" + "encoding/json" + "io" + "math" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/ethpandaops/assertoor/pkg/helper" + "github.com/ethpandaops/assertoor/pkg/types" + "github.com/ethpandaops/assertoor/pkg/vars" + "github.com/sirupsen/logrus" +) + +const testToken = "test-token" + +func newTestTask(cfg *Config) *Task { + logger := logrus.New() + logger.SetOutput(io.Discard) + + return &Task{ + ctx: &types.TaskContext{ + Outputs: vars.NewVariables(nil), + SetResult: func(types.TaskResult) {}, + ReportProgress: func(float64, string) {}, + }, + config: *cfg, + logger: logger, + } +} + +func boolPtr(b bool) *bool { return &b } + +func validBaseConfig() Config { + return Config{ + Endpoint: "http://example.invalid", + Hook: "blob-mutator", + Enabled: boolPtr(true), + ConfigPatch: map[string]interface{}{"k": "v"}, + Duration: helper.Duration{Duration: time.Minute}, + } +} + +func TestConfig_Validate(t *testing.T) { + tests := []struct { + name string + mutate func(c *Config) + wantErr string + }{ + { + name: "valid", + mutate: func(*Config) {}, + }, + { + name: "missing endpoint", + mutate: func(c *Config) { c.Endpoint = "" }, + wantErr: "endpoint is required", + }, + { + name: "missing hook", + mutate: func(c *Config) { c.Hook = "" }, + wantErr: "hook is required", + }, + { + name: "zero duration", + mutate: func(c *Config) { c.Duration = helper.Duration{} }, + wantErr: "duration must be greater than 0", + }, + { + name: "neither enabled nor configPatch", + mutate: func(c *Config) { + c.Enabled = nil + c.ConfigPatch = nil + }, + wantErr: "at least one of enabled or configPatch must be set", + }, + { + name: "only enabled is sufficient", + mutate: func(c *Config) { + c.ConfigPatch = nil + }, + }, + { + name: "only configPatch is sufficient", + mutate: func(c *Config) { + c.Enabled = nil + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cfg := validBaseConfig() + tc.mutate(&cfg) + + err := cfg.Validate() + + switch { + case tc.wantErr == "" && err != nil: + t.Fatalf("unexpected error: %v", err) + case tc.wantErr != "" && err == nil: + t.Fatalf("expected error containing %q, got nil", tc.wantErr) + case tc.wantErr != "" && !strings.Contains(err.Error(), tc.wantErr): + t.Fatalf("error %q does not contain %q", err.Error(), tc.wantErr) + } + }) + } +} + +func TestExecute_HappyPath(t *testing.T) { + var ( + capturedAuth string + capturedBody activationRequest + capturedMethod string + capturedPath string + ) + + expiry := time.Now().Add(2 * time.Minute).UTC().Round(time.Second) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedAuth = r.Header.Get("Authorization") + capturedMethod = r.Method + capturedPath = r.URL.Path + + if err := json.NewDecoder(r.Body).Decode(&capturedBody); err != nil { + t.Errorf("decode request body: %v", err) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(activationResponse{ + ID: "act_abc", + Hook: "blob-mutator", + CreatedAt: time.Now().UTC(), + ExpiresAt: expiry, + }) + })) + defer srv.Close() + + task := newTestTask(&Config{ + Endpoint: srv.URL, + AuthToken: testToken, + Hook: "blob-mutator", + Enabled: boolPtr(true), + ConfigPatch: map[string]interface{}{"mutationProbability": 1.0}, + Duration: helper.Duration{Duration: 90 * time.Second}, + Replace: true, + }) + + if err := task.Execute(context.Background()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if capturedMethod != http.MethodPost { + t.Errorf("method: got %q, want %q", capturedMethod, http.MethodPost) + } + + if capturedPath != "/tysm/v1/activations" { + t.Errorf("path: got %q, want /tysm/v1/activations", capturedPath) + } + + if want := "Bearer " + testToken; capturedAuth != want { + t.Errorf("auth header: got %q, want %q", capturedAuth, want) + } + + if capturedBody.Hook != "blob-mutator" { + t.Errorf("body.hook: got %q, want blob-mutator", capturedBody.Hook) + } + + if capturedBody.Duration != "1m30s" { + t.Errorf("body.duration: got %q, want 1m30s", capturedBody.Duration) + } + + if !capturedBody.Replace { + t.Errorf("body.replace: got false, want true") + } + + if capturedBody.Enabled == nil || !*capturedBody.Enabled { + t.Errorf("body.enabled: got %v, want pointer to true", capturedBody.Enabled) + } + + probAny, ok := capturedBody.ConfigPatch["mutationProbability"] + if !ok { + t.Fatalf("body.config_patch missing mutationProbability key") + } + + prob, ok := probAny.(float64) + if !ok { + t.Fatalf("body.config_patch.mutationProbability: got %T, want float64", probAny) + } + + if math.Abs(prob-1.0) > 0 { + t.Errorf("body.config_patch.mutationProbability: got %v, want 1.0", prob) + } + + if got := task.ctx.Outputs.GetVar("activation_id"); got != "act_abc" { + t.Errorf("output activation_id: got %v, want act_abc", got) + } + + if got, want := task.ctx.Outputs.GetVar("expires_at"), expiry.Format(time.RFC3339); got != want { + t.Errorf("output expires_at: got %v, want %v", got, want) + } + + if got := task.ctx.Outputs.GetVar("hook"); got != "blob-mutator" { + t.Errorf("output hook: got %v, want blob-mutator", got) + } +} + +func TestExecute_NoAuthHeader_WhenTokenEmpty(t *testing.T) { + var capturedAuth string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedAuth = r.Header.Get("Authorization") + + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(activationResponse{ID: "id", ExpiresAt: time.Now()}) + })) + defer srv.Close() + + task := newTestTask(&Config{ + Endpoint: srv.URL, + Hook: "blob-mutator", + Enabled: boolPtr(true), + Duration: helper.Duration{Duration: time.Minute}, + }) + + if err := task.Execute(context.Background()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if capturedAuth != "" { + t.Errorf("expected no Authorization header when AuthToken is empty, got %q", capturedAuth) + } +} + +func TestExecute_ServerErrors(t *testing.T) { + tests := []struct { + name string + status int + body interface{} + wantSubstr string + }{ + { + name: "401 unauthorized", + status: http.StatusUnauthorized, + body: errorResponse{Error: "missing bearer"}, + wantSubstr: "HTTP 401: missing bearer", + }, + { + name: "400 with details", + status: http.StatusBadRequest, + body: errorResponse{Error: "hook is not runtime_reconfigurable", Details: "metrics"}, + wantSubstr: "HTTP 400: hook is not runtime_reconfigurable (metrics)", + }, + { + name: "409 conflict", + status: http.StatusConflict, + body: errorResponse{Error: "activation already exists for hook"}, + wantSubstr: "HTTP 409: activation already exists for hook", + }, + { + name: "500 with non-JSON body", + status: http.StatusInternalServerError, + body: "internal boom", + wantSubstr: "HTTP 500: internal boom", + }, + { + name: "503 with empty body", + status: http.StatusServiceUnavailable, + body: "", + wantSubstr: "HTTP 503", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(tc.status) + + switch v := tc.body.(type) { + case string: + _, _ = io.WriteString(w, v) + default: + _ = json.NewEncoder(w).Encode(tc.body) + } + })) + defer srv.Close() + + task := newTestTask(&Config{ + Endpoint: srv.URL, + Hook: "blob-mutator", + Enabled: boolPtr(true), + Duration: helper.Duration{Duration: time.Minute}, + }) + + err := task.Execute(context.Background()) + + switch { + case err == nil: + t.Fatalf("expected error containing %q, got nil", tc.wantSubstr) + case !strings.Contains(err.Error(), tc.wantSubstr): + t.Fatalf("error %q does not contain %q", err.Error(), tc.wantSubstr) + } + + if got := task.ctx.Outputs.GetVar("activation_id"); got != nil { + t.Errorf("expected no activation_id output on failure, got %v", got) + } + }) + } +} + +func TestExecute_201WithEmptyID(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(activationResponse{ID: "", ExpiresAt: time.Now()}) + })) + defer srv.Close() + + task := newTestTask(&Config{ + Endpoint: srv.URL, + Hook: "blob-mutator", + Enabled: boolPtr(true), + Duration: helper.Duration{Duration: time.Minute}, + }) + + err := task.Execute(context.Background()) + + switch { + case err == nil: + t.Fatalf("expected error, got nil") + case !strings.Contains(err.Error(), "no activation id"): + t.Fatalf("error %q does not contain 'no activation id'", err.Error()) + } +} + +func TestExecute_ConnectionRefused(t *testing.T) { + // Bind a server, capture its URL, then close it so the connection is + // guaranteed to be refused at the captured port. + srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + url := srv.URL + + srv.Close() + + task := newTestTask(&Config{ + Endpoint: url, + Hook: "blob-mutator", + Enabled: boolPtr(true), + Duration: helper.Duration{Duration: time.Minute}, + }) + + err := task.Execute(context.Background()) + + switch { + case err == nil: + t.Fatalf("expected error, got nil") + case !strings.Contains(err.Error(), "POST "+url+"/tysm/v1/activations"): + t.Fatalf("error %q missing expected POST URL", err.Error()) + } +} + +func TestExecute_TrimsTrailingSlash(t *testing.T) { + var capturedPath string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(activationResponse{ID: "act_x", ExpiresAt: time.Now()}) + })) + defer srv.Close() + + task := newTestTask(&Config{ + Endpoint: srv.URL + "/", + Hook: "blob-mutator", + Enabled: boolPtr(true), + Duration: helper.Duration{Duration: time.Minute}, + }) + + if err := task.Execute(context.Background()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if capturedPath != "/tysm/v1/activations" { + t.Errorf("path: got %q, want /tysm/v1/activations", capturedPath) + } +} diff --git a/pkg/tasks/tysm_hook_deactivation/README.md b/pkg/tasks/tysm_hook_deactivation/README.md new file mode 100644 index 00000000..1ab9f289 --- /dev/null +++ b/pkg/tasks/tysm_hook_deactivation/README.md @@ -0,0 +1,60 @@ +## `tysm_hook_deactivation` Task + +### Description + +Cancels a TYSM hook activation by issuing +`DELETE /tysm/v1/activations/{id}`. The hook reverts to its baseline +`(enabled, config)` state immediately on the server side. + +This task is the cleanup half of the `tysm_hook_activation` / +`tysm_hook_deactivation` pair. The intended placement is in a test's +top-level `cleanupTasks:` block, so the deactivation runs even if the test +fails. Because the server-side TTL on the activation will eventually fire +on its own, this task tolerates a `404 Not Found` (treats it as success by +default — see `ignoreNotFound`). + +### Configuration Parameters + +- **`endpoint`**:\ + Base URL of the TYSM API, e.g. `http://beacon:8080`. Required. + +- **`auth_token`**:\ + Bearer token sent in the `Authorization` header. Required when the TYSM + API has auth enabled. Recommended to supply via `configVars`. + +- **`activation_id`**:\ + ID of the activation to cancel. Required. Typically supplied via + `configVars` from the upstream `tysm_hook_activation` task's outputs: + `configVars: { activation_id: "tasks..outputs.activation_id" }`. + +- **`ignoreNotFound`**:\ + If `true` (default), treat HTTP `404` as success on the assumption the + server-side TTL already fired. Set to `false` if you want explicit + failure when the activation is no longer present. + +### Defaults + +```yaml +- name: tysm_hook_deactivation + config: + endpoint: "" + auth_token: "" + activation_id: "" + ignoreNotFound: true +``` + +### Outputs + +This task does not produce any outputs. + +### Example + +```yaml +cleanupTasks: + - name: tysm_hook_deactivation + config: + endpoint: "http://beacon:8080" + configVars: + auth_token: "tysmApiToken" + activation_id: "tasks.kzg_chaos.outputs.activation_id" +``` diff --git a/pkg/tasks/tysm_hook_deactivation/config.go b/pkg/tasks/tysm_hook_deactivation/config.go new file mode 100644 index 00000000..9e02b43f --- /dev/null +++ b/pkg/tasks/tysm_hook_deactivation/config.go @@ -0,0 +1,46 @@ +package tysmhookdeactivation + +import ( + "errors" + "net/url" + "strings" +) + +// Config drives a single DELETE against the TYSM hook-control API. +// +// ActivationID is yaml-tagged with snake_case so that playbooks can wire it +// up via configVars from a previous tysm_hook_activation task's outputs: +// +// configVars: +// activation_id: "tasks.kzg_chaos.outputs.activation_id" +// +// AuthToken follows the same convention so the bearer token can be +// supplied via a runtime variable rather than hard-coded in the playbook. +type Config struct { + Endpoint string `yaml:"endpoint" json:"endpoint" require:"A" desc:"Base URL of the TYSM API, e.g. http://beacon:8080"` + AuthToken string `yaml:"auth_token" json:"auth_token" desc:"Bearer token sent in the Authorization header. Prefer supplying via configVars."` + ActivationID string `yaml:"activation_id" json:"activation_id" require:"A" desc:"Activation ID returned by tysm_hook_activation. Typically supplied via configVars from the upstream task's outputs."` + IgnoreNotFound bool `yaml:"ignoreNotFound" json:"ignoreNotFound" desc:"If true (default), treat HTTP 404 as success — the activation may have already expired via TTL."` +} + +func DefaultConfig() Config { + return Config{ + IgnoreNotFound: true, + } +} + +func (c *Config) Validate() error { + if strings.TrimSpace(c.Endpoint) == "" { + return errors.New("endpoint is required") + } + + if _, err := url.Parse(c.Endpoint); err != nil { + return errors.New("endpoint must be a valid URL") + } + + if strings.TrimSpace(c.ActivationID) == "" { + return errors.New("activation_id is required") + } + + return nil +} diff --git a/pkg/tasks/tysm_hook_deactivation/task.go b/pkg/tasks/tysm_hook_deactivation/task.go new file mode 100644 index 00000000..ec9d2e0d --- /dev/null +++ b/pkg/tasks/tysm_hook_deactivation/task.go @@ -0,0 +1,165 @@ +package tysmhookdeactivation + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/ethpandaops/assertoor/pkg/types" + "github.com/sirupsen/logrus" +) + +const ( + apiPath = "/tysm/v1/activations/" + requestTimeout = 30 * time.Second +) + +var ( + TaskName = "tysm_hook_deactivation" + TaskDescriptor = &types.TaskDescriptor{ + Name: TaskName, + Description: "Cancel a TYSM hook activation. Designed to be placed in a test's cleanupTasks block.", + Category: "tysm", + Config: DefaultConfig(), + Outputs: []types.TaskOutputDefinition{}, + NewTask: NewTask, + } +) + +type Task struct { + ctx *types.TaskContext + options *types.TaskOptions + config Config + logger logrus.FieldLogger +} + +// errorResponse mirrors overlay/tysm/api.ErrorResponse — declared locally +// so this task does not depend on the tysm overlay module (different module +// path, only exists post-patch-apply). +type errorResponse struct { + Error string `json:"error"` + Details string `json:"details,omitempty"` +} + +func NewTask(ctx *types.TaskContext, options *types.TaskOptions) (types.Task, error) { + return &Task{ + ctx: ctx, + options: options, + logger: ctx.Logger.GetLogger(), + }, nil +} + +func (t *Task) Config() interface{} { + return t.config +} + +func (t *Task) Timeout() time.Duration { + return t.options.Timeout.Duration +} + +func (t *Task) LoadConfig() error { + config := DefaultConfig() + + if t.options.Config != nil { + if err := t.options.Config.Unmarshal(&config); err != nil { + return fmt.Errorf("error parsing task config for %v: %w", TaskName, err) + } + } + + err := t.ctx.Vars.ConsumeVars(&config, t.options.ConfigVars) + if err != nil { + return err + } + + if err := config.Validate(); err != nil { + return err + } + + t.config = config + + return nil +} + +func (t *Task) Execute(ctx context.Context) error { + t.ctx.ReportProgress(0, fmt.Sprintf("Deactivating activation %q", t.config.ActivationID)) + + endpoint := strings.TrimRight(t.config.Endpoint, "/") + apiPath + url.PathEscape(t.config.ActivationID) + + reqCtx, cancel := context.WithTimeout(ctx, requestTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(reqCtx, http.MethodDelete, endpoint, http.NoBody) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + + if t.config.AuthToken != "" { + req.Header.Set("Authorization", "Bearer "+t.config.AuthToken) + } + + client := &http.Client{Timeout: requestTimeout} + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("DELETE %s: %w", endpoint, err) + } + + defer func() { + if closeErr := resp.Body.Close(); closeErr != nil { + t.logger.WithError(closeErr).Warn("failed to close response body") + } + }() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read response: %w", err) + } + + switch { + case resp.StatusCode == http.StatusNoContent: + t.logger.WithField("activation_id", t.config.ActivationID).Info("TYSM hook activation cancelled") + t.ctx.SetResult(types.TaskResultSuccess) + t.ctx.ReportProgress(100, "Activation cancelled") + + return nil + + case resp.StatusCode == http.StatusNotFound && t.config.IgnoreNotFound: + // TTL may have already fired between the activation task running + // and the cleanup task running. Treat that as success — the + // invariant the caller wants (no activation in force) holds. + t.logger.WithField("activation_id", t.config.ActivationID).Info("TYSM hook activation already gone (404); treating as cancelled") + t.ctx.SetResult(types.TaskResultSuccess) + t.ctx.ReportProgress(100, "Activation already expired") + + return nil + + default: + return fmt.Errorf("deactivation request failed: %s", formatHTTPError(resp.StatusCode, respBody)) + } +} + +// formatHTTPError flattens the server's JSON error envelope into one line +// suitable for an Execute error return. Falls back to the raw body when +// the response is not valid JSON or carries no Error field. +func formatHTTPError(status int, body []byte) string { + var errResp errorResponse + if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error != "" { + if errResp.Details != "" { + return fmt.Sprintf("HTTP %d: %s (%s)", status, errResp.Error, errResp.Details) + } + + return fmt.Sprintf("HTTP %d: %s", status, errResp.Error) + } + + trimmed := strings.TrimSpace(string(body)) + if trimmed == "" { + return fmt.Sprintf("HTTP %d", status) + } + + return fmt.Sprintf("HTTP %d: %s", status, trimmed) +} diff --git a/pkg/tasks/tysm_hook_deactivation/task_test.go b/pkg/tasks/tysm_hook_deactivation/task_test.go new file mode 100644 index 00000000..84883ccd --- /dev/null +++ b/pkg/tasks/tysm_hook_deactivation/task_test.go @@ -0,0 +1,316 @@ +package tysmhookdeactivation + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/ethpandaops/assertoor/pkg/types" + "github.com/ethpandaops/assertoor/pkg/vars" + "github.com/sirupsen/logrus" +) + +const testToken = "test-token" + +func newTestTask(cfg *Config) *Task { + logger := logrus.New() + logger.SetOutput(io.Discard) + + return &Task{ + ctx: &types.TaskContext{ + Outputs: vars.NewVariables(nil), + SetResult: func(types.TaskResult) {}, + ReportProgress: func(float64, string) {}, + }, + config: *cfg, + logger: logger, + } +} + +func TestConfig_Validate(t *testing.T) { + tests := []struct { + name string + cfg Config + wantErr string + }{ + { + name: "valid", + cfg: Config{ + Endpoint: "http://example.invalid", + ActivationID: "act_1", + }, + }, + { + name: "missing endpoint", + cfg: Config{ + ActivationID: "act_1", + }, + wantErr: "endpoint is required", + }, + { + name: "missing activation_id", + cfg: Config{ + Endpoint: "http://example.invalid", + }, + wantErr: "activation_id is required", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := tc.cfg.Validate() + + switch { + case tc.wantErr == "" && err != nil: + t.Fatalf("unexpected error: %v", err) + case tc.wantErr != "" && err == nil: + t.Fatalf("expected error containing %q, got nil", tc.wantErr) + case tc.wantErr != "" && !strings.Contains(err.Error(), tc.wantErr): + t.Fatalf("error %q does not contain %q", err.Error(), tc.wantErr) + } + }) + } +} + +func TestExecute_HappyPath(t *testing.T) { + var ( + capturedAuth string + capturedMethod string + capturedPath string + ) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedAuth = r.Header.Get("Authorization") + capturedMethod = r.Method + capturedPath = r.URL.Path + + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + task := newTestTask(&Config{ + Endpoint: srv.URL, + AuthToken: testToken, + ActivationID: "act_abc", + }) + + if err := task.Execute(context.Background()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if capturedMethod != http.MethodDelete { + t.Errorf("method: got %q, want %q", capturedMethod, http.MethodDelete) + } + + if capturedPath != "/tysm/v1/activations/act_abc" { + t.Errorf("path: got %q, want /tysm/v1/activations/act_abc", capturedPath) + } + + if want := "Bearer " + testToken; capturedAuth != want { + t.Errorf("auth header: got %q, want %q", capturedAuth, want) + } +} + +func TestExecute_NoAuthHeader_WhenTokenEmpty(t *testing.T) { + var capturedAuth string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedAuth = r.Header.Get("Authorization") + + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + task := newTestTask(&Config{ + Endpoint: srv.URL, + ActivationID: "act_1", + }) + + if err := task.Execute(context.Background()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if capturedAuth != "" { + t.Errorf("expected no Authorization header when AuthToken is empty, got %q", capturedAuth) + } +} + +func TestExecute_404_IgnoreNotFound_True(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(errorResponse{Error: "unknown activation"}) + })) + defer srv.Close() + + task := newTestTask(&Config{ + Endpoint: srv.URL, + ActivationID: "act_gone", + IgnoreNotFound: true, + }) + + if err := task.Execute(context.Background()); err != nil { + t.Fatalf("404 should be treated as success when ignoreNotFound is true; got %v", err) + } +} + +func TestExecute_404_IgnoreNotFound_False(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(errorResponse{Error: "unknown activation"}) + })) + defer srv.Close() + + task := newTestTask(&Config{ + Endpoint: srv.URL, + ActivationID: "act_gone", + IgnoreNotFound: false, + }) + + err := task.Execute(context.Background()) + + switch { + case err == nil: + t.Fatalf("expected error, got nil") + case !strings.Contains(err.Error(), "HTTP 404"): + t.Errorf("error %q missing HTTP 404", err.Error()) + case !strings.Contains(err.Error(), "unknown activation"): + t.Errorf("error %q missing 'unknown activation'", err.Error()) + } +} + +func TestExecute_ServerErrors(t *testing.T) { + tests := []struct { + name string + status int + body interface{} + wantSubstr string + }{ + { + name: "401", + status: http.StatusUnauthorized, + body: errorResponse{Error: "missing bearer"}, + wantSubstr: "HTTP 401: missing bearer", + }, + { + name: "503 unavailable during shutdown", + status: http.StatusServiceUnavailable, + body: errorResponse{Error: "server is shutting down"}, + wantSubstr: "HTTP 503: server is shutting down", + }, + { + name: "500 with non-JSON body", + status: http.StatusInternalServerError, + body: "boom", + wantSubstr: "HTTP 500: boom", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(tc.status) + + switch v := tc.body.(type) { + case string: + _, _ = io.WriteString(w, v) + default: + _ = json.NewEncoder(w).Encode(tc.body) + } + })) + defer srv.Close() + + task := newTestTask(&Config{ + Endpoint: srv.URL, + ActivationID: "act_1", + }) + + err := task.Execute(context.Background()) + + switch { + case err == nil: + t.Fatalf("expected error containing %q, got nil", tc.wantSubstr) + case !strings.Contains(err.Error(), tc.wantSubstr): + t.Errorf("error %q does not contain %q", err.Error(), tc.wantSubstr) + } + }) + } +} + +func TestExecute_PathEscapesActivationID(t *testing.T) { + // RawPath preserves the on-the-wire encoded form whenever it differs + // from the decoded Path; that's what we want to assert against. + var capturedRawPath string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedRawPath = r.URL.RawPath + + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + // An activation id with characters that would otherwise corrupt the + // URL — confirms url.PathEscape is in play. Note PathEscape leaves + // '&' alone (a sub-delim valid in path segments). + task := newTestTask(&Config{ + Endpoint: srv.URL, + ActivationID: "act/with spaces?and&stuff", + }) + + if err := task.Execute(context.Background()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + want := "/tysm/v1/activations/act%2Fwith%20spaces%3Fand&stuff" + if capturedRawPath != want { + t.Errorf("raw path: got %q, want %q", capturedRawPath, want) + } +} + +func TestExecute_TrimsTrailingSlash(t *testing.T) { + var capturedPath string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + task := newTestTask(&Config{ + Endpoint: srv.URL + "/", + ActivationID: "act_x", + }) + + if err := task.Execute(context.Background()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if capturedPath != "/tysm/v1/activations/act_x" { + t.Errorf("path: got %q, want /tysm/v1/activations/act_x", capturedPath) + } +} + +func TestExecute_ConnectionRefused(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + url := srv.URL + + srv.Close() + + task := newTestTask(&Config{ + Endpoint: url, + ActivationID: "act_x", + }) + + err := task.Execute(context.Background()) + + switch { + case err == nil: + t.Fatalf("expected error, got nil") + case !strings.Contains(err.Error(), "DELETE "+url+"/tysm/v1/activations/act_x"): + t.Errorf("error %q missing expected DELETE URL", err.Error()) + } +}