lib/runbox/scenario/output_action.ex

defmodule Runbox.Scenario.OutputAction do
  @moduledoc group: :output_actions
  @moduledoc """
  Output action represents a side effect produced by a run.

  ## Creating output actions

  Creating output action typically happens from scenario. It is done by
  creating one of the appropriate struct:

    * `t:Runbox.Scenario.OutputAction.UpsertAssetAttributes.t/0`
    * `t:Runbox.Scenario.OutputAction.DeleteAssetAttributes.t/0`
    * `t:Runbox.Scenario.OutputAction.DeleteAllAssetAttributes.t/0`
    * `t:Runbox.Scenario.OutputAction.UpsertEdge.t/0`
    * `t:Runbox.Scenario.OutputAction.DeleteEdge.t/0`
    * `t:Runbox.Scenario.OutputAction.Notification.t/0`
    * `t:Runbox.Scenario.OutputAction.Event.t/0`
    * `t:Runbox.Scenario.OutputAction.ExecuteSQL.t/0`
    * `t:Runbox.Scenario.OutputAction.Incident.t/0`
    * `t:Runbox.Scenario.OutputAction.IncidentPatch.t/0`

  For example:

      iex> %OutputAction.UpsertAssetAttributes{
      ...>   type: "/asset/camera",
      ...>   id: "one",
      ...>   attributes: %{"foo" => "bar"}
      ...> }

  ## Asset and edge output actions

  Changes done by output actions to assets and edges are scoped for each
  scenario run. The global result is computed from the changes done by
  individual runs.

  A single run is forbidden to do multiple changes to the same object (asset
  attribute or edge) at the same time. However distinct runs can perform such
  changes, because they are scoped to each run.

  The rules to compute the global state apply in the following order:

    1. Change with the higher timestamp has precedence.

    1. Updating an asset attribute or an edge has precedence to deleting it.

    1. Change with the higher value has precedence (applies only for asset attributes).
  """

  alias __MODULE__, as: OA

  @typedoc """
  Asset type.

  Assets are grouped by their asset types. Assets of the same type should be of similar nature
  (e.g. servers or buildings) and should share the same set of attributes.

  Asset types must start with a slash and must not end with a slash. Slashes can be used
  within the asset type to denote structure. For example, these are valid asset types:
  `/assets/servers`, `/servers`, and `/assets/network/routers`.

  Although asset types like `/servers` are permitted, it is recommended to prefix asset types
  with the `/assets` prefix to enable consistent configuration of common attributes (e.g., `name`)
  via the root `/assets` asset type in the UI.
  """
  @type asset_type :: String.t()

  @typedoc """
  Asset ID.

  Asset ID together with an asset type uniquely identifies an asset.

  Asset ID must not contain any slashes. They can contain any other character though.
  For example, these are valid asset IDs: `fire_alarm`, `192.168.0.142`, `Meeting room`, and
  `Příliš žluťoučký kůň`.
  """
  @type asset_id :: String.t()

  @type edge_type :: String.t()

  defmodule UpsertAssetAttributes do
    @moduledoc group: :output_actions
    @moduledoc """
    Parameters for output action Upsert Asset Attributes.

    The resulting output action will create or update attributes of the given
    asset.
    """

    @enforce_keys [:type, :id, :attributes]
    defstruct [:type, :id, :attributes]

    @typedoc """
    Upsert Asset Attributes

      * `:type` - asset type (for more information, see `t:Runbox.Scenario.OutputAction.asset_type/0`)
      * `:id` - asset ID (for more information, see `t:Runbox.Scenario.OutputAction.asset_id/0`)
      * `:attributes` - a map of attributes in format `%{"name" => value}` to be
        inserted or updated
    """
    @type t :: %__MODULE__{
            type: OA.asset_type(),
            id: OA.asset_id(),
            attributes: map()
          }
  end

  defmodule DeleteAssetAttributes do
    @moduledoc group: :output_actions

    @moduledoc """
    Parameters for output action Delete Asset Attributes.

    The resulting output action will delete attributes of the given asset. This
    action succeeds even if the attributes do not exist.
    """

    @enforce_keys [:type, :id, :attributes]
    defstruct [:type, :id, :attributes]

    @typedoc """
    Delete Asset Attributes

      * `:type` - asset type
      * `:id` - asset ID
      * `:attributes` - a map of attributes to be deleted

    The leaf attributes of the `:attributes` map are deleted.
    E.g., if asset has the following attributes
    ```elixir
    %{
      "a": 1,
      "b": %{"c" => 2}
    }
    ```
    and the following `:attributes` are provided in the output action
    ```elixir
    %{
      "b": %{"c" => true}
    }
    ```
    then `b.c` attribute will be deleted, resulting in
    ```elixir
    %{
      "a": 1
      "b": %{}
    }
    ```

    """
    @type t :: %__MODULE__{
            type: OA.asset_type(),
            id: OA.asset_id(),
            attributes: map()
          }
  end

  defmodule DeleteAllAssetAttributes do
    @moduledoc group: :output_actions
    @moduledoc """
    Parameters for output action Delete All Asset Attributes.

    The resulting output action will delete all existing attributes of the
    given asset. This is scoped to the attributes created by the same run.
    """

    @enforce_keys [:type, :id]
    defstruct [:type, :id]

    @typedoc """
    Delete All Asset Attributes

      * `:type` - asset type
      * `:id` - asset ID
    """
    @type t :: %__MODULE__{
            type: OA.asset_type(),
            id: OA.asset_id()
          }
  end

  defmodule UpsertEdge do
    @moduledoc group: :output_actions
    @moduledoc """
    Parameters for output action Upsert Edge.

    The resulting output action will create an edge between two assets. The
    assets do not have to exist at the time of the edge creation.
    """

    @enforce_keys [:from_type, :from_id, :to_type, :to_id, :type]
    defstruct [:from_type, :from_id, :to_type, :to_id, :type]

    @typedoc """
    Upsert Edge

      * `:from_type` - type of the asset which this edge goes out from
      * `:from_id` - id of the asset which this edge goes out from
      * `:to_type` - type of the asset which this edge goes to
      * `:to_id` - id of the asset which this edge goes to
      * `:type` - type of the edge
    """
    @type t :: %__MODULE__{
            from_type: OA.asset_type(),
            from_id: OA.asset_id(),
            to_type: OA.asset_type(),
            to_id: OA.asset_id(),
            type: OA.edge_type()
          }
  end

  defmodule DeleteEdge do
    @moduledoc group: :output_actions
    @moduledoc """
    Parameters for output action Delete Edge.

    The resulting output action will delete an edge between two assets. This
    action succeeds even if the edge does not exist.
    """

    @enforce_keys [:from_type, :from_id, :to_type, :to_id, :type]
    defstruct [:from_type, :from_id, :to_type, :to_id, :type]

    @typedoc """
    Delete Edge

      * `:from_type` - type of the asset which this edge goes out from
      * `:from_id` - id of the asset which this edge goes out from
      * `:to_type` - type of the asset which this edge goes to
      * `:to_id` - id of the asset which this edge goes to
      * `:type` - type of the edge
    """
    @type t :: %__MODULE__{
            from_type: OA.asset_type(),
            from_id: OA.asset_id(),
            to_type: OA.asset_type(),
            to_id: OA.asset_id(),
            type: OA.edge_type()
          }
  end

  defmodule Notification do
    @moduledoc group: :output_actions
    @moduledoc """
    Parameters for output action Notification.

    The resulting output action sends a notification.
    """

    @enforce_keys [:type, :data]
    defstruct type: nil,
              data: nil,
              primary_asset: nil,
              ids: [],
              aqls: %{},
              priority: :medium,
              metadata: %{},
              direct_subscriptions: nil,
              attachments: nil

    @typedoc """
    Notification

      * `:type` - string, type of the notification
      * `:data` - map, data for the template evaluation
      * `:primary_asset` - string, optional, id of an asset that corresponds to this notification
      * `:ids` - list of asset ids, optional, to filter recipients, each recipient must have
        sufficient privileges to see all listed IDs
      * `:aqls` - map, AQLs which are evaluated, provide further context to the templates, and also can
        serve the same purpose as `ids` - filter recipients to only those who can see the whole
        result of the query. Key is the name of the AQL which can be used in the template. Value
        is a map with `aql` (the AQL query) and `primary` (whether the AQL should also be used
        to filter recipients) keys.
      * `:priority` - optional, defaults to `:medium`, can be `:low`, `:medium`, `:high`, only
        applicable to some channels
      * `:metadata` - map, optional, additional metadata which can be used in notification group
        subscriptions. Should adhere to the format specified in `Runbox.Scenario.Manifest.notifications`.
      * `:direct_subscriptions` - list of maps, subscriptions to be used additionally to the usual
        routing. Each map should have the following.
          * `:user` - map, user object to be used instead of Altworx object, should have
            `notification.email` and optionally even `notification.language`.
          * `:templates` - list of ids, which templates should be used
          * `:channels` - list of ids, which channels should be used
      * `:attachments` - list of maps, optional, attachments that should be sent with the notification.
        Each map has the following keys.
          * `:data` - binary, content of the attachment
          * `:filename` - string, name of the file
          * `:content_type` - string, optional, MIME type of the data
          * `:type` - whether the attachment is to inlined in the template (`:inline`) or not
            (`:attachment`, this is the default)
    """
    @type t :: %__MODULE__{
            type: String.t() | atom(),
            data: map(),
            primary_asset: String.t() | nil,
            ids: [String.t()],
            aqls: map(),
            priority: :low | :medium | :high | String.t(),
            metadata: map(),
            direct_subscriptions: [map()] | nil,
            attachments: [map()] | nil
          }
  end

  defmodule Event do
    @moduledoc group: :output_actions
    @moduledoc """
    Parameters for output action Event.

    The resulting output action creates an event.

    See `t:t/0` for more information.

    > #### Note {: .warning}
    >
    > Beware that creating incident history manually via event's `incident_actors` property, instead
    > of relying on Incident output actions, is low-level and prone to errors. You must always ensure
    > that you create the event **and** you accordingly update the incident itself. Failing to do
    > either will result in inconsistent state.
    """

    @enforce_keys [:type, :template, :actors]
    defstruct type: nil,
              template: nil,
              actors: nil,
              incident_actors: nil,
              params: %{},
              origin_messages: []

    @typedoc """
    Event

      * `:type` - type of the event. It's declared in Scenario's Manifest.
      * `:template` - event's template that allows to interpolate actors and parameters
        (see the *Interpolation* section below).
      * `:actors` - map of asset actors that are to be linked with the event. They may also be
        interpolated in the template.
      * `:incident_actors` - map of incident actors that are to be linked with the event. This means
        the event is either part of the incident's history, or is just related. They may also be
        interpolated in the template. See `t:incident_actor/0` for more information.
      * `:params` - map of template parameters that are interpolated in the template
        (optional, defaults to `%{}`).
      * `:origin_messages` - list of references to raw messages linked with the event itself
        (optional, defaults to `[]`).

    ## Interpolation

    The template may reference actors using placeholders like `${actors.actor_key}`, where
    `actor_key` corresponds to a key in the `actors` map within this struct. In the UI,
    these placeholders are transformed into links. Each link points to the corresponding
    asset and displays the asset's name as the link text.

    Incident actors can be interpolated in a very similar manner using `incidents` keyword -
    `${incidents.fire}`.

    The template may also contain placeholders like `${params.param_key}`, where `param_key`
    is a key under the `params` map. These placeholders are simply replaced with the
    corresponding values from the `params` map. These parameters are useful for dynamic
    content other than actors.

    ## Example

        %Event{
          type: "server_login",
          template: "${actors.person} logged into ${actors.server} using OpenSSH ${params.openssh_version}",
          actors: %{
            "person" => %{
              asset_type: "/assets/person",
              asset_id: "joe"
            },
            "server" => %{
              asset_type: "/assets/server",
              asset_id: "192.168.142.18"
            }
          },
          params: %{"openssh_version" => "9.8"},
          origin_messages: [normalized_message.origin]
        }
    """
    @type t :: %__MODULE__{
            type: String.t() | atom(),
            template: String.t(),
            actors: actors(),
            incident_actors: incident_actors(),
            params: %{String.t() => String.t()},
            origin_messages: [Runbox.Message.origin()]
          }

    @type actors :: %{(actor_key :: String.t()) => actor()}
    @type actor :: %{
            asset_type: String.t(),
            asset_id: String.t()
          }
    @type incident_actors :: %{(actor_key :: String.t()) => incident_actor()}
    @typedoc """
    Incident actor of the event.

    Can mean two things depending on if `status` and `severity` is set. If either is empty, then the
    event is considered related to the incident, similar to `actors`. If both are set, then the
    event is considered a part of the incident's history. `status` and `severity` then represent the
    change of these incident properties at the time of the event. `attributes` is fully optional
    and contains further context about the change of the incident.
    """
    @type incident_actor :: %{
            :type => String.t(),
            :id => String.t(),
            optional(:status) => String.t() | nil,
            optional(:severity) => 1 | 2 | 3 | 4 | nil,
            optional(:attributes) => map() | nil
          }
  end

  defmodule ExecuteSQL do
    @moduledoc group: :output_actions
    @moduledoc """
    Parameters for output action Execute SQL.

    The resulting output action executes an SQL statement.
    """

    @enforce_keys [:db_connection, :sql_query, :data, :type]
    defstruct db_connection: nil,
              sql_query: nil,
              data: nil,
              type: nil

    @typedoc """
    Execute SQL

      * `:db_connection` - map with database connection info (`hostname`, `username`, `password`, `database`)
      * `:sql_query` - SQL query.
      * `:data` - list of parameterized data.
      * `:type` - database type, right now only PostgreSQL is supported. It's checked in `OutputProcessor` and
        the value must be `:postgresql`.

    If `:run_id` is present in `data`, it's replaced with actual run_id in `AssetMap.Api.SqlExecutor`.
    """
    @type t :: %__MODULE__{
            db_connection: map(),
            sql_query: String.t(),
            data: list(),
            type: :postgresql | String.t()
          }
  end

  @typedoc """
  String with interpolation support.

  Supports interpolating asset references to a format which is useful for
  displaying assets in the Altworx UI. The string can contain any number of
  placeholders in format

      ${assets.["/assets/camera", "one"]}

  Where `"/assets/camera"` is the asset type and `"one"` is the asset ID. The resulting interpolation is

      [Camera One[/assets/camera/one]]

  Where `Camera One` is the asset's `name` attribute and `/assets/camera/one`
  is the asset's full ID. When the asset `name` is not present, the asset's ID
  is used instead

      [one[/assets/camera/one]]

  When the asset cannot be found, the resulting interpolation is

      unknown

  The interpolation is done upon "read" operations, therefore the result can
  vary based on the asset visibility for the identity of the user performing
  the operation.
  """
  @type interpolable :: String.t()

  defmodule IncidentFuture do
    @moduledoc group: :output_actions
    @moduledoc "Incident Future"
    @enforce_keys [
      :status,
      :severity,
      :timestamp,
      :description
    ]
    defstruct [
      :status,
      :severity,
      :timestamp,
      :description
    ]

    @type t :: %IncidentFuture{
            status: String.t(),
            severity: OA.Incident.severity(),
            timestamp: integer(),
            description: OA.interpolable()
          }
  end

  defmodule IncidentHistory do
    @moduledoc group: :output_actions
    @moduledoc """
    Incident history.

    The struct represents a change of an Incident in time. It carries information about how the
    Incident changed, all describe how the Incident looks after the change. The struct contains all
    necessary parameters to create an Incident history record.

    Since Incident history is now synonymous to Events, `event_*` fields of this struct can be used
    to control how the final Event looks like.

    Available fields

    - `:status` - status of the Incident
    - `:severity` - severity of the Incident
    - `:timestamp` - time of the change
    - `:description` - description of the change - what has happened, will be used as Event template
    - `:attributes` - (optional) significant additional attributes that changed with the Incident
      (usually metrics, like temperature, server load, distance). Can be empty.
    - `:event_type` - (optional) type of the underlying Event, if empty a default type
      `incident_updated` will be used
    - `:event_actors` - (optional) additional actors of the underlying Event. If empty no actors are
      defined.
    - `:event_params` - (optional) additional parameters of the Event. If empty no params are
      defined.
    - `:event_origin_messages` - (optional) list of references to raw messages linked with the
      underlying Event. If empty no raw messages are linked with the Event.
    """
    @enforce_keys [
      :status,
      :severity,
      :timestamp,
      :description
    ]
    defstruct [
      :status,
      :severity,
      :timestamp,
      :description,
      :attributes,
      :event_type,
      :event_actors,
      :event_params,
      :event_origin_messages
    ]

    @typedoc """
    Incident history.

    Note Incident history is synonymous to Events and thus carries similar parameters.

    - `:status` - status of the Incident
    - `:severity` - severity of the Incident
    - `:timestamp` - time of the change
    - `:description` - description of the change - what has happened, will be used as Event template
    - `:attributes` - (optional) significant additional attributes that changed with the Incident
      (usually metrics, like temperature, server load, distance). Can be empty.
    - `:event_type` - (optional) type of the underlying Event, if empty a default type
      `incident_updated` will be used
    - `:event_actors` - (optional) additional actors of the underlying Event. If empty no actors are
      defined.
    - `:event_params` - (optional) additional parameters of the Event. If empty no params are
      defined.
    - `:event_origin_messages` - (optional) list of references to raw messages linked with the
      underlying Event. If empty no raw messages are linked with the Event.
    """
    @type t :: %IncidentHistory{
            status: String.t(),
            severity: OA.Incident.severity(),
            timestamp: integer(),
            description: OA.interpolable(),
            attributes: map() | nil,
            event_type: String.t() | nil,
            event_actors: Event.actors() | nil,
            event_params: %{String.t() => String.t()} | nil,
            event_origin_messages: [Runbox.Message.origin()] | nil
          }
  end

  defmodule IncidentActor do
    @moduledoc group: :output_actions
    @moduledoc "Incident Actor"
    @enforce_keys [
      :type,
      :id
    ]
    defstruct [
      :type,
      :id
    ]

    @type t :: %IncidentActor{
            type: String.t(),
            id: String.t()
          }
  end

  defmodule Incident do
    @moduledoc group: :output_actions
    @moduledoc """
    Parameters for output action Incident.

    The resulting output action creates an incident.
    """
    @enforce_keys [
      :type,
      :id,
      :subject,
      :status,
      :resolved,
      :severity,
      :future,
      :history
    ]
    defstruct [
      :type,
      :id,
      :subject,
      :status,
      :resolved,
      :severity,
      :future,
      :history,
      :actors,
      :user_actions,
      :additional_attributes
    ]

    @type severity :: 1..4

    @typedoc """
    Incident
    """
    @type t :: %Incident{
            type: String.t(),
            id: String.t(),
            subject: OA.interpolable(),
            status: String.t(),
            resolved: boolean(),
            severity: severity(),
            future: [IncidentFuture.t()],
            history: [IncidentHistory.t()],
            actors: [IncidentActor.t()] | nil,
            user_actions: map() | nil,
            additional_attributes: map() | nil
          }
  end

  defmodule IncidentPatch do
    @moduledoc group: :output_actions
    @moduledoc """
    Parameters for output action Incident Patch.

    The resulting output action updates an incident.
    """
    @enforce_keys [
      :type,
      :id
    ]
    defstruct [
      :type,
      :id,
      :subject,
      :status,
      :resolved,
      :severity,
      :future,
      :history,
      :actors,
      :user_actions,
      :additional_attributes
    ]

    @typedoc """
    Incident Patch
    """
    @type t :: %IncidentPatch{
            type: String.t(),
            id: String.t(),
            subject: OA.interpolable() | nil,
            status: String.t() | nil,
            resolved: boolean() | nil,
            severity: OA.Incident.severity() | nil,
            future: [IncidentFuture.t()] | nil,
            history: [IncidentHistory.t()] | nil,
            actors:
              %{
                optional(:add) => [IncidentActor.t()],
                optional(:remove) => [IncidentActor.t()]
              }
              | nil,
            user_actions:
              %{
                optional(:upsert) => map(),
                optional(:remove) => list()
              }
              | nil,
            additional_attributes:
              %{
                optional(:upsert) => map(),
                optional(:remove) => list()
              }
              | nil
          }
  end

  defmodule BadOutputAction do
    @moduledoc group: :output_actions
    @moduledoc """
    Body of invalid output action.
    """

    @enforce_keys [:params]
    defstruct [:params]

    @typedoc """
    Bad Output Action

      * `:params` - holds the original parameters
    """
    @type t :: %__MODULE__{
            params: any()
          }
  end

  @type oa_params ::
          UpsertAssetAttributes.t()
          | DeleteAssetAttributes.t()
          | DeleteAllAssetAttributes.t()
          | UpsertEdge.t()
          | DeleteEdge.t()
          | Notification.t()
          | Event.t()
          | ExecuteSQL.t()
          | Incident.t()
          | IncidentPatch.t()

  defstruct [:body, :timestamp, :scenario_id, :run_id]

  @typedoc """
  Output Action

  This struct is produced by `Runbox.Runtime.Stage.Sandbox.execute_run/3`.
  """
  @type t :: %__MODULE__{
          body: oa_params() | BadOutputAction.t(),
          timestamp: integer()
        }
end