internal/pkg/fleetapi/action.go (450 lines of code) (raw):
// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
// or more contributor license agreements. Licensed under the Elastic License 2.0;
// you may not use this file except in compliance with the Elastic License 2.0.
package fleetapi
import (
"encoding/json"
"fmt"
"strings"
"time"
"github.com/go-viper/mapstructure/v2"
"github.com/elastic/elastic-agent/internal/pkg/agent/errors"
)
const (
// ActionTypeUnknown is used to indicate that the elastic-agent does not know how to handle the action
ActionTypeUnknown = "UNKNOWN"
// ActionTypeUpgrade specifies upgrade action.
ActionTypeUpgrade = "UPGRADE"
// ActionTypeUnenroll specifies unenroll action.
ActionTypeUnenroll = "UNENROLL"
// ActionTypePolicyChange specifies policy change action.
ActionTypePolicyChange = "POLICY_CHANGE"
// ActionTypePolicyReassign specifies policy reassign action.
ActionTypePolicyReassign = "POLICY_REASSIGN"
// ActionTypeSettings specifies change of agent settings.
ActionTypeSettings = "SETTINGS"
// ActionTypeInputAction specifies agent action.
ActionTypeInputAction = "INPUT_ACTION"
// ActionTypeCancel specifies a cancel action.
ActionTypeCancel = "CANCEL"
// ActionTypeDiagnostics specifies a diagnostics action.
ActionTypeDiagnostics = "REQUEST_DIAGNOSTICS"
)
// Error values that the Action interface can return
var (
ErrNoStartTime = fmt.Errorf("action has no start time")
ErrNoExpiration = fmt.Errorf("action has no expiration")
)
// Action base interface for all the implemented action from the fleet API.
type Action interface {
fmt.Stringer
Type() string
ID() string
AckEvent() AckEvent
}
// Actions is a slice of Actions to executes and allow to unmarshal
// heterogeneous action types.
type Actions []Action
// ScheduledAction is an Action that may be executed at a later date
// Only ActionUpgrade implements this at the moment
type ScheduledAction interface {
Action
// StartTime returns the earliest time an action should start.
StartTime() (time.Time, error)
// Expiration returns the time where an action is expired and should not be ran.
Expiration() (time.Time, error)
}
// RetryableAction is an Action that may be scheduled for a retry.
type RetryableAction interface {
ScheduledAction
// RetryAttempt returns the retry-attempt number of the action
// the retry_attempt number is meant to be an internal counter for the elastic-agent and not communicated to fleet-server or ES.
// If RetryAttempt returns > 1, and GetError is not nil the acker should signal that the action is being retried.
// If RetryAttempt returns < 1, and GetError is not nil the acker should signal that the action has failed.
RetryAttempt() int
// SetRetryAttempt sets the retry-attempt number of the action
// the retry_attempt number is meant to be an internal counter for the elastic-agent and not communicated to fleet-server or ES.
SetRetryAttempt(int)
// SetStartTime sets the start_time of the action to the specified value.
// this is used by the action-retry mechanism.
SetStartTime(t time.Time)
// GetError returns the error that is associated with the retry.
// If it is a retryable action fleet-server should mark it as such.
// Otherwise, fleet-server should mark the action as failed.
GetError() error
// SetError sets the retryable action error
SetError(error)
}
type Signed struct {
Data string `json:"data" yaml:"data" mapstructure:"data"`
Signature string `json:"signature" yaml:"signature" mapstructure:"signature"`
}
// NewAction returns a new, zero-value, action of the type defined by 'actionType'
// or an ActionUnknown with the 'OriginalType' field set to 'actionType' if the
// type is not valid.
func NewAction(actionType string) Action {
var action Action
// keep the case statements alphabetically sorted
switch actionType {
case ActionTypeCancel:
action = &ActionCancel{}
case ActionTypeDiagnostics:
action = &ActionDiagnostics{}
case ActionTypeInputAction:
action = &ActionApp{}
case ActionTypePolicyChange:
action = &ActionPolicyChange{}
case ActionTypePolicyReassign:
action = &ActionPolicyReassign{}
case ActionTypeSettings:
action = &ActionSettings{}
case ActionTypeUnenroll:
action = &ActionUnenroll{}
case ActionTypeUpgrade:
action = &ActionUpgrade{}
default:
action = &ActionUnknown{OriginalType: actionType}
}
return action
}
func newAckEvent(id, aType string) AckEvent {
return AckEvent{
EventType: "ACTION_RESULT",
SubType: "ACKNOWLEDGED",
ActionID: id,
Message: fmt.Sprintf("Action %q of type %q acknowledged.", id, aType),
}
}
// ActionUnknown is an action that is not know by the current version of the Agent and we don't want
// to return an error at parsing time but at execution time we can report or ignore.
//
// NOTE: We only keep the original type and the action id, the payload of the event is dropped, we
// do this to make sure we do not leak any unwanted information.
type ActionUnknown struct {
ActionID string `json:"id" yaml:"id" mapstructure:"id"`
ActionType string `json:"type,omitempty" yaml:"type,omitempty" mapstructure:"type"`
// OriginalType is the original type of the action as returned by the API.
OriginalType string `json:"original_type,omitempty" yaml:"original_type,omitempty" mapstructure:"original_type"`
}
// Type returns the type of the Action.
func (a *ActionUnknown) Type() string {
return ActionTypeUnknown
}
// ID returns the ID of the Action.
func (a *ActionUnknown) ID() string {
return a.ActionID
}
func (a *ActionUnknown) String() string {
var s strings.Builder
s.WriteString("id: ")
s.WriteString(a.ActionID)
s.WriteString(", type: ")
s.WriteString(a.ActionType)
s.WriteString(" (original type: ")
s.WriteString(a.OriginalType)
s.WriteString(")")
return s.String()
}
func (a *ActionUnknown) AckEvent() AckEvent {
return AckEvent{
EventType: "ACTION_RESULT", // TODO Discuss EventType/SubType needed - by default only ACTION_RESULT was used - what is (or was) the intended purpose of these attributes? Are they documented? Can we change them to better support acking an error or a retry?
SubType: "ACKNOWLEDGED",
ActionID: a.ActionID,
Message: fmt.Sprintf("Action %q of type %q acknowledged.", a.ActionID, a.ActionType),
Error: fmt.Sprintf("Action %q of type %q is unknown to the elastic-agent", a.ActionID, a.OriginalType),
}
}
// ActionPolicyReassign is a request to apply a new policy
type ActionPolicyReassign struct {
ActionID string `json:"id" yaml:"id"`
ActionType string `json:"type" yaml:"type"`
Data ActionPolicyReassignData `json:"data,omitempty"`
}
type ActionPolicyReassignData struct {
PolicyID string `json:"policy_id"`
}
func (a *ActionPolicyReassign) String() string {
var s strings.Builder
s.WriteString("id: ")
s.WriteString(a.ActionID)
s.WriteString(", type: ")
s.WriteString(a.ActionType)
return s.String()
}
// Type returns the type of the Action.
func (a *ActionPolicyReassign) Type() string {
return a.ActionType
}
// ID returns the ID of the Action.
func (a *ActionPolicyReassign) ID() string {
return a.ActionID
}
func (a *ActionPolicyReassign) AckEvent() AckEvent {
return newAckEvent(a.ActionID, a.ActionType)
}
// ActionPolicyChange is a request to apply a new
type ActionPolicyChange struct {
ActionID string `json:"id" yaml:"id"`
ActionType string `json:"type" yaml:"type"`
Data ActionPolicyChangeData `json:"data,omitempty" yaml:"data,omitempty"`
}
type ActionPolicyChangeData struct {
Policy map[string]interface{} `json:"policy" yaml:"policy,omitempty"`
}
func (a *ActionPolicyChange) String() string {
var s strings.Builder
s.WriteString("id: ")
s.WriteString(a.ActionID)
s.WriteString(", type: ")
s.WriteString(a.ActionType)
return s.String()
}
// Type returns the type of the Action.
func (a *ActionPolicyChange) Type() string {
return a.ActionType
}
// ID returns the ID of the Action.
func (a *ActionPolicyChange) ID() string {
return a.ActionID
}
func (a *ActionPolicyChange) AckEvent() AckEvent {
return newAckEvent(a.ActionID, a.ActionType)
}
// ActionUpgrade is a request for agent to upgrade.
type ActionUpgrade struct {
ActionID string `json:"id" yaml:"id" mapstructure:"id"`
ActionType string `json:"type" yaml:"type" mapstructure:"type"`
ActionStartTime string `json:"start_time" yaml:"start_time,omitempty" mapstructure:"-"` // TODO change to time.Time in unmarshal
ActionExpiration string `json:"expiration" yaml:"expiration,omitempty" mapstructure:"-"`
// does anyone know why those aren't mapped to mapstructure?
Data ActionUpgradeData `json:"data,omitempty" mapstructure:"-"`
Signed *Signed `json:"signed,omitempty" yaml:"signed,omitempty" mapstructure:"signed,omitempty"`
Err error `json:"-" yaml:"-" mapstructure:"-"`
}
type ActionUpgradeData struct {
Version string `json:"version" yaml:"version,omitempty" mapstructure:"-"`
SourceURI string `json:"source_uri,omitempty" yaml:"source_uri,omitempty" mapstructure:"-"`
// TODO: update fleet open api schema
Retry int `json:"retry_attempt,omitempty" yaml:"retry_attempt,omitempty" mapstructure:"-"`
}
func (a *ActionUpgrade) String() string {
var s strings.Builder
s.WriteString("id: ")
s.WriteString(a.ActionID)
s.WriteString(", type: ")
s.WriteString(a.ActionType)
return s.String()
}
func (a *ActionUpgrade) AckEvent() AckEvent {
event := newAckEvent(a.ActionID, a.ActionType)
if a.Err != nil {
// FIXME Do we want to change EventType/SubType here?
event.Error = a.Err.Error()
var payload struct {
Retry bool `json:"retry"`
Attempt int `json:"retry_attempt,omitempty"`
}
payload.Retry = true
payload.Attempt = a.Data.Retry
if a.Data.Retry < 1 { // retry is set to -1 if it will not re attempt
payload.Retry = false
}
p, _ := json.Marshal(payload)
event.Payload = p
}
return event
}
// Type returns the type of the Action.
func (a *ActionUpgrade) Type() string {
return a.ActionType
}
// ID returns the ID of the Action.
func (a *ActionUpgrade) ID() string {
return a.ActionID
}
// StartTime returns the start_time as a UTC time.Time or ErrNoStartTime if there is no start time
func (a *ActionUpgrade) StartTime() (time.Time, error) {
if a.ActionStartTime == "" {
return time.Time{}, ErrNoStartTime
}
ts, err := time.Parse(time.RFC3339, a.ActionStartTime)
if err != nil {
return time.Time{}, err
}
return ts.UTC(), nil
}
// Expiration returns the expiration as a UTC time.Time or ErrExpiration if there is no expiration
func (a *ActionUpgrade) Expiration() (time.Time, error) {
if a.ActionExpiration == "" {
return time.Time{}, ErrNoExpiration
}
ts, err := time.Parse(time.RFC3339, a.ActionExpiration)
if err != nil {
return time.Time{}, err
}
return ts.UTC(), nil
}
// RetryAttempt will return the retry_attempt of the action
func (a *ActionUpgrade) RetryAttempt() int {
return a.Data.Retry
}
// SetRetryAttempt sets the retry_attempt of the action
func (a *ActionUpgrade) SetRetryAttempt(n int) {
a.Data.Retry = n
}
// GetError returns the error associated with the attempt to run the action.
func (a *ActionUpgrade) GetError() error {
return a.Err
}
// SetError sets the error associated with the attempt to run the action.
func (a *ActionUpgrade) SetError(err error) {
a.Err = err
}
// SetStartTime sets the start time of the action.
func (a *ActionUpgrade) SetStartTime(t time.Time) {
a.ActionStartTime = t.Format(time.RFC3339)
}
// MarshalMap marshals ActionUpgrade into a corresponding map
func (a *ActionUpgrade) MarshalMap() (map[string]interface{}, error) {
var res map[string]interface{}
err := mapstructure.Decode(a, &res)
return res, err
}
// ActionUnenroll is a request for agent to unhook from fleet.
type ActionUnenroll struct {
ActionID string `json:"id" yaml:"id" mapstructure:"id"`
ActionType string `json:"type" yaml:"type" mapstructure:"type"`
IsDetected bool `json:"is_detected,omitempty" yaml:"is_detected,omitempty" mapstructure:"-"`
Signed *Signed `json:"signed,omitempty" mapstructure:"signed,omitempty"`
}
func (a *ActionUnenroll) String() string {
var s strings.Builder
s.WriteString("id: ")
s.WriteString(a.ActionID)
s.WriteString(", type: ")
s.WriteString(a.ActionType)
return s.String()
}
// Type returns the type of the Action.
func (a *ActionUnenroll) Type() string {
return a.ActionType
}
// ID returns the ID of the Action.
func (a *ActionUnenroll) ID() string {
return a.ActionID
}
func (a *ActionUnenroll) AckEvent() AckEvent {
return newAckEvent(a.ActionID, a.ActionType)
}
// MarshalMap marshals ActionUnenroll into a corresponding map
func (a *ActionUnenroll) MarshalMap() (map[string]interface{}, error) {
var res map[string]interface{}
err := mapstructure.Decode(a, &res)
return res, err
}
// ActionSettings is a request to change agent settings.
type ActionSettings struct {
ActionID string `json:"id" yaml:"id"`
ActionType string `json:"type" yaml:"type"`
Data ActionSettingsData `json:"data,omitempty"`
}
type ActionSettingsData struct {
// LogLevel can only be one of "debug", "info", "warning", "error"
// TODO: add validation
LogLevel string `json:"log_level" yaml:"log_level,omitempty"`
}
// ID returns the ID of the Action.
func (a *ActionSettings) ID() string {
return a.ActionID
}
// Type returns the type of the Action.
func (a *ActionSettings) Type() string {
return a.ActionType
}
func (a *ActionSettings) String() string {
var s strings.Builder
s.WriteString("id: ")
s.WriteString(a.ActionID)
s.WriteString(", type: ")
s.WriteString(a.ActionType)
s.WriteString(", log_level: ")
s.WriteString(a.Data.LogLevel)
return s.String()
}
func (a *ActionSettings) AckEvent() AckEvent {
return newAckEvent(a.ActionID, a.ActionType)
}
// ActionCancel is a request to cancel an action.
type ActionCancel struct {
ActionID string `json:"id" yaml:"id"`
ActionType string `json:"type" yaml:"type"`
Data ActionCancelData `json:"data,omitempty"`
}
type ActionCancelData struct {
TargetID string `json:"target_id" yaml:"target_id,omitempty"`
}
// ID returns the ID of the Action.
func (a *ActionCancel) ID() string {
return a.ActionID
}
// Type returns the type of the Action.
func (a *ActionCancel) Type() string {
return a.ActionType
}
func (a *ActionCancel) String() string {
var s strings.Builder
s.WriteString("id: ")
s.WriteString(a.ActionID)
s.WriteString(", type: ")
s.WriteString(a.ActionType)
s.WriteString(", target_id: ")
s.WriteString(a.Data.TargetID)
return s.String()
}
func (a *ActionCancel) AckEvent() AckEvent {
return newAckEvent(a.ActionID, a.ActionType)
}
// ActionDiagnostics is a request to gather and upload a diagnostics bundle.
type ActionDiagnostics struct {
ActionID string `json:"id"`
ActionType string `json:"type"`
Data ActionDiagnosticsData `json:"data"`
UploadID string `json:"-"`
Err error `json:"-"`
}
type ActionDiagnosticsData struct {
AdditionalMetrics []string `json:"additional_metrics"`
ExcludeEventsLog bool `json:"exclude_events_log"`
}
// ID returns the ID of the action.
func (a *ActionDiagnostics) ID() string {
return a.ActionID
}
// Type returns the type of the action.
func (a *ActionDiagnostics) Type() string {
return a.ActionType
}
func (a *ActionDiagnostics) String() string {
var s strings.Builder
s.WriteString("id: ")
s.WriteString(a.ActionID)
s.WriteString(", type: ")
s.WriteString(a.ActionType)
return s.String()
}
func (a *ActionDiagnostics) AckEvent() AckEvent {
event := newAckEvent(a.ActionID, a.ActionType)
if a.Err != nil {
event.Error = a.Err.Error()
}
if a.UploadID != "" {
var data struct {
UploadID string `json:"upload_id"`
}
data.UploadID = a.UploadID
p, _ := json.Marshal(data)
event.Data = p
}
return event
}
// ActionApp is the application action request.
type ActionApp struct {
ActionID string `json:"id" mapstructure:"id"`
ActionType string `json:"type" mapstructure:"type"`
InputType string `json:"input_type" mapstructure:"input_type"`
Timeout int64 `json:"timeout,omitempty" mapstructure:"timeout,omitempty"`
Data json.RawMessage `json:"data" mapstructure:"data"`
Response map[string]interface{} `json:"response,omitempty" mapstructure:"response,omitempty"`
StartedAt string `json:"started_at,omitempty" mapstructure:"started_at,omitempty"`
CompletedAt string `json:"completed_at,omitempty" mapstructure:"completed_at,omitempty"`
Signed *Signed `json:"signed,omitempty" mapstructure:"signed,omitempty"`
Error string `json:"error,omitempty" mapstructure:"error,omitempty"`
}
func (a *ActionApp) String() string {
var s strings.Builder
s.WriteString("id: ")
s.WriteString(a.ActionID)
s.WriteString(", type: ")
s.WriteString(a.ActionType)
s.WriteString(", input_type: ")
s.WriteString(a.InputType)
return s.String()
}
// ID returns the ID of the Action.
func (a *ActionApp) ID() string {
return a.ActionID
}
// Type returns the type of the Action.
func (a *ActionApp) Type() string {
return a.ActionType
}
func (a *ActionApp) AckEvent() AckEvent {
return AckEvent{
EventType: "ACTION_RESULT",
SubType: "ACKNOWLEDGED",
ActionID: a.ActionID,
Message: fmt.Sprintf("Action %q of type %q acknowledged.", a.ActionID, a.ActionType),
ActionInputType: a.InputType,
ActionData: a.Data,
ActionResponse: a.Response,
StartedAt: a.StartedAt,
CompletedAt: a.CompletedAt,
Error: a.Error,
}
}
// MarshalMap marshals ActionApp into a corresponding map
func (a *ActionApp) MarshalMap() (map[string]interface{}, error) {
var res map[string]interface{}
err := mapstructure.Decode(a, &res)
return res, err
}
// UnmarshalJSON takes every raw representation of an action and try to decode them.
func (a *Actions) UnmarshalJSON(data []byte) error {
var typeUnmarshaler []struct {
ActionType string `json:"type,omitempty" yaml:"type,omitempty"`
}
if err := json.Unmarshal(data, &typeUnmarshaler); err != nil {
return errors.New(err,
"fail to decode actions to read their types",
errors.TypeConfig)
}
rawActions := make([]json.RawMessage, len(typeUnmarshaler))
if err := json.Unmarshal(data, &rawActions); err != nil {
return errors.New(err,
"fail to decode actions",
errors.TypeConfig)
}
actions := make([]Action, 0, len(typeUnmarshaler))
for i, response := range typeUnmarshaler {
action := NewAction(response.ActionType)
if err := json.Unmarshal(rawActions[i], action); err != nil {
return errors.New(err,
fmt.Sprintf("fail to decode %s action", action.Type()),
errors.TypeConfig)
}
actions = append(actions, action)
}
*a = actions
return nil
}
// UnmarshalYAML prevents to unmarshal actions from YAML.
func (a *Actions) UnmarshalYAML(_ func(interface{}) error) error {
return errors.New("Actions cannot be Unmarshalled from YAML")
}
// MarshalYAML prevents to marshal actions from YAML.
func (a *Actions) MarshalYAML() (interface{}, error) {
return nil, errors.New("Actions cannot be Marshaled into YAML")
}