defmodule Toolbox.Incident do @moduledoc """ Module extends Toolbox.Workflow and abstracts how regular incident behaves. Wraps around `Toolbox.Workflow` and adds some additional callbacks to manage incident in asset map. This module works very much like regular workflow, but some additional properties can be specified. Contrary to a regular workflow, this automatically generates OAs to manage the incident in asset map and therefore syncs the general state of this workflow with the incident asset. Start by creating a definition (see `new/0`, `add_transition/2` and `build/1`) which describes the workflow of the incident. Then, you can create a new instance based on that definition with `new_instance/6`. """ alias Toolbox.Message, as: Msg alias Toolbox.Scenario.OutputAction, as: OA alias Toolbox.Utils.Map, as: UtilsMap alias Toolbox.Workflow, as: WF alias Toolbox.Workflow.Instance, as: WFI alias Toolbox.Workflow.Transition, as: WFT @placeholder_re ~r/{{([a-zA-Z\_\.0-9]*)}}/ @user_actions_state_field "_user_actions" @prev_user_actions_state_field "_prev_user_actions" @spec new :: WF.t() @doc "Creates new blank incident workflow definition" def new do %WF{} end @spec new_instance(WF.t(), WF.status(), String.t(), map, Msg.t(), Keyword.t()) :: {:ok, [OA.t()], WFI.t()} | {:terminated, [OA.t()], WFI.t()} | {:error, :unknown_status} | {:error, {:user_actions_invalid | :upsert_attributes_invalid, reason :: String.t()}} @doc """ Creates new incident instance for given workflow. `params` can be used to specify additional transition-like parameters. For available options see `add_transition/2`. """ def new_instance(%WF{} = wf, status, id, state, %Msg{} = msg, params) do with {:user_actions_invalid, :ok} <- {:user_actions_invalid, validate_user_actions_def(params[:user_actions])}, {:upsert_attributes_invalid, :ok} <- {:upsert_attributes_invalid, validate_upsert_attributes(params[:upsert_attributes])} do subject = Keyword.get(params, :subject, "") name = Keyword.get(params, :name, "") severity = Keyword.get(params, :severity, 1) description = Keyword.get(params, :description, "") edges = Keyword.get(params, :edges, []) then_fn = construct_callback_list(params, :then, [ {__MODULE__, :update_incident_state}, {__MODULE__, :update_user_actions} ]) side_effects_fn = construct_callback_list(params, :side_effects, [ {__MODULE__, :append_create_incident_output_actions} ]) update_history_entry_fn = construct_callback_list(params, :update_history_entry, [ {__MODULE__, :update_incident_history_entry} ]) update_possible_transition_fn = construct_callback_list(params, :update_possible_transition, [ {__MODULE__, :update_incident_possible_transition} ]) wf_params = Keyword.merge(params, subject: subject, name: name, severity: severity, description_after: description, edges: edges, then: then_fn, side_effects: side_effects_fn, update_history_entry: update_history_entry_fn, update_possible_transition: update_possible_transition_fn ) WF.new_instance(wf, status, id, state, msg, wf_params) else {:user_actions_invalid, {:error, reason}} -> {:error, {:user_actions_invalid, reason}} {:upsert_attributes_invalid, {:error, reason}} -> {:error, {:upsert_attributes_invalid, reason}} end end defp construct_callback_list(params, key, defaults) when is_list(defaults) do case Keyword.get(params, key) do nil -> defaults {mod, fun} = callback_def when is_atom(mod) and is_atom(fun) -> defaults ++ [callback_def] callback_defs when is_list(callback_defs) -> defaults ++ callback_defs end end @spec add_transition(WF.t(), Keyword.t()) :: WF.t() @doc """ Adds a new transition to incident workflow definition. Incident workflow transition can be defined by: - from (source status) - to (target status) - when (predicate used to select transition which will be executed) - there can be multiple when definitions in list, all definitions are connected with && relation - possible when definitions: - `{Module, function}`, where function accepts transition, instance and message as args, returns boolean - `{:timeout, timeout}`, where timeout is defined in milliseconds (transition is then automatically executed when time elapses the specified value) - `{:=, [path, to, state, key], value}` (transition is executed if the specified field of state reaches the specified value) - then (callback used to update workflow instance state during transition execution) - there can be multiple then definitions in list, all definitions are executed in given order - possible then definitions: - `{Module, function}`, where function accepts transition, instance and message as args, and returns `{:ok, state()}` to update the instance state - side_effects (callback used to generate output actions during transition execution) - there can be multiple definitions in list, all definitions are executed in given order - possible definitions: - `{Module, function}`, where function accepts transition, instance and message as args, and returns `{:ok, [OA | Msg | OtherSideEffect]}` - update_history_entry (callback used to modify transition execution history entry stored in asset map) - there can be multiple definitions in list, all definitions are executed in given order - this is usually used to interpolate description texts, or to add additional attributes to history - possible definitions: - `{Module, function}`, where function accepts history entry, transition, instance and message as args and returns `{:ok, history_entry}` - update_possible_transition (callback used to modify possible future transitions stored in asset map) - there can be multiple definitions in list, all definitions are executed in given order - the callback is executed for each possible future transition - note this only modifies the items of `future` attribute of the incident asset, this has no effect on definition transitions - possible definitions: - `{Module, function}`, where function accepts possible transition, transition, instance and message as args, and returns `{:ok, future_transition}` - user_actions (specifies all possible user actions from the target state) - a map of user actions that should be enabled once the transition is executed and incident is in the target state - user actions are automatically deleted if not present in the next transition - keys are strings - values are `{module, function}`, this specifies the function to be called to generate the user action token (since tokens are not known in advance, they are generated by the specified function) - the function takes `transition, instance, message` as arguments and is expected to return `{:ok, binary_token}` to register the user action token - upsert_attributes (specifies additional attributes to be added to the incident asset) - list of callbacks to compute the additional attributes - each callback produces a map of additional attributes and this is merged into a single map where the latter has priority over the former - attributes cannot override attributes handled by this workflow incl. user actions, only other attributes can be added - possible definitions: - `{Module, function}`, where function accepts transition, instance and message as args, and should return `{:ok, attribute_map}` When a message is evaluated the callbacks above are run in the following order. 1. `when` callbacks are evaluated to see if the current transition is ready to be executed. If not the next transition is tried. 2. `then` callbacks are evaluated to update the instance state. 3. `user_actions` is evaluated, all callbacks specified inside are executed and all user action tokens are calculated. 4. `update_history_entry` callbacks are evaluated to update the new history entry 5. `update_possible_transition` callbacks are evaluated to update the new possible future transitions 6. `upsert_attributes` callbacks are evaluated to gather additional attributes 7. `side_effects` callbacks are evaluated to acquire the list of all additional output actions All text bearing attributes (such as `subject`, `name`, `description_before`, `description_after`) has access to incident metadata dictionary. This dictionary contains these, keys: - `id`, which contains incident id - `transition`, transition attributes dictionary containing `from`, `to`, `severity` keys - `state`, dictionary containing user defined state - `message`, altworx message which triggered given transition Metadata can be accessed via interpolation defined as `{{}}`, e.g. `{{state.foo.bar}}`, `{{message.body.foo}}`. """ def add_transition(%WF{} = wf, params) do then_fn = construct_callback_list(params, :then, [ {__MODULE__, :update_incident_state}, {__MODULE__, :update_user_actions} ]) side_effects_fn = construct_callback_list(params, :side_effects, [ {__MODULE__, :append_update_incident_output_actions} ]) update_history_entry_fn = construct_callback_list(params, :update_history_entry, [ {__MODULE__, :update_incident_history_entry} ]) update_possible_transition_fn = construct_callback_list(params, :update_possible_transition, [ {__MODULE__, :update_incident_possible_transition} ]) incident_tran_params = [ then: then_fn, side_effects: side_effects_fn, update_history_entry: update_history_entry_fn, update_possible_transition: update_possible_transition_fn ] WF.add_transition(wf, Keyword.merge(params, incident_tran_params)) end @spec handle_message(WF.t(), WFI.t(), Msg.t()) :: {:ok, [OA.t()], WFI.t()} | {:terminated, [OA.t()], WFI.t()} | {:error, :not_built_yet} | {:error, :status_mismatch} @doc """ Uses given incident workflow definition and message to update state of given instance. If no configured workflow transition matches, nothing will happen = instance state will remain the same. Order of callback execution: 1. when definitions of transitions in definition order 2. then definitions of matching transition 3. update history entry definitions of matching transition 3. update possible transition definitions of matching transition 5. side effects definitions of matching transition """ def handle_message(%WF{} = wf, %WFI{} = inc_inst, %Msg{} = msg) do WF.handle_message(wf, inc_inst, msg) end @spec append_create_incident_output_actions(WFT.t(), WFI.t(), Msg.t()) :: {:ok, [OA.t()]} def append_create_incident_output_actions(%WFT{} = tran, %WFI{} = inc_inst, %Msg{} = msg) do create_edge_actions = Enum.map(tran.attributes.edges, fn %{actor_id: actor_id, type: edge_type} -> OA.new_create_edge_action( msg.timestamp, actor_id, inc_inst.id, edge_type ) end) {resolution, closed_at} = if inc_inst.terminated? do {"closed", msg.timestamp} else {"open", nil} end metadata = format_transition_metadata(tran, inc_inst, msg) user_actions_attrs = make_user_actions_upsert(inc_inst.state[@user_actions_state_field]) additional_attributes = tran.attributes |> Map.get(:upsert_attributes, []) |> evaluate_upsert_attributes(tran, inc_inst, msg) base_attributes = %{ "status" => inc_inst.status, "resolution" => resolution, "subject" => apply_metadata(tran.attributes.subject, metadata), "name" => apply_metadata(tran.attributes.name, metadata), "severity" => tran.attributes.severity, "history" => inc_inst.history, "future" => inc_inst.possible_transitions, "created_at" => msg.timestamp, "modified_at" => msg.timestamp, "closed_at" => closed_at, "next_possible_transition_timestamp" => inc_inst.next_possible_transition_timestamp } create_incident_action = OA.new_create_asset_action( inc_inst.id, msg.timestamp, merge_maps([additional_attributes, base_attributes, user_actions_attrs]) ) {:ok, [create_incident_action | create_edge_actions]} end @spec append_update_incident_output_actions(WFT.t(), WFI.t(), Msg.t()) :: {:ok, [OA.t()]} def append_update_incident_output_actions(%WFT{} = tran, %WFI{} = inc_inst, %Msg{} = msg) do severity = Map.get(tran.attributes, :severity, Map.get(inc_inst.state, "severity")) {resolution, closed_at} = if inc_inst.terminated? do {"closed", msg.timestamp} else {"open", nil} end subject = Map.get(tran.attributes, :subject) metadata = format_transition_metadata(tran, inc_inst, msg) {update_actions, delete_actions} = prepare_user_actions_for_update( inc_inst.state[@user_actions_state_field], inc_inst.state[@prev_user_actions_state_field] ) update_action_attrs = make_user_actions_upsert(update_actions) delete_action_attrs = make_user_actions_upsert(delete_actions) additional_attributes = tran.attributes |> Map.get(:upsert_attributes, []) |> evaluate_upsert_attributes(tran, inc_inst, msg) base_update = %{ "status" => inc_inst.status, "resolution" => resolution, "severity" => severity, "history" => inc_inst.history, "future" => inc_inst.possible_transitions, "modified_at" => msg.timestamp, "closed_at" => closed_at, "next_possible_transition_timestamp" => inc_inst.next_possible_transition_timestamp } subject_update = if subject != nil, do: %{"subject" => apply_metadata(subject, metadata)}, else: %{} update = merge_maps([additional_attributes, base_update, update_action_attrs, subject_update]) delete = if delete_action_attrs != %{}, do: delete_action_attrs update_incident_oa = OA.new_update_asset_action( inc_inst.id, msg.timestamp, update, delete ) {:ok, [update_incident_oa]} end @spec update_incident_state(WFT.t(), WFI.t(), Msg.t()) :: {:ok, map} def update_incident_state(%WFT{attributes: %{severity: severity}}, %WFI{} = inc_inst, %Msg{}) do new_state = Map.put(inc_inst.state, "severity", severity) {:ok, new_state} end def update_incident_state(_tran, inc_inst, _msg) do {:ok, inc_inst.state} end @spec update_user_actions(WFT.t(), WFI.t(), Msg.t()) :: {:ok, map} def update_user_actions(%WFT{attributes: attrs} = transition, %WFI{} = instance, %Msg{} = msg) do current_actions = case attrs[:user_actions] do %{} = actions_def -> evaluate_user_actions(actions_def, transition, instance, msg) _ -> %{} end new_state = instance.state |> Map.put(@prev_user_actions_state_field, instance.state[@user_actions_state_field]) |> Map.put(@user_actions_state_field, current_actions) {:ok, new_state} end @spec update_incident_history_entry(map, WFT.t(), WFI.t(), Msg.t()) :: {:ok, map} def update_incident_history_entry(history_entry, tran, inc_inst, msg) do severity = Map.get(tran.attributes, :severity, Map.get(inc_inst.state, "severity")) metadata = format_transition_metadata(tran, inc_inst, msg) description = apply_metadata(tran.attributes.description_after, metadata) new_history_entry = history_entry |> Map.put("severity", severity) |> Map.put("description", description) |> Map.put("attributes", %{}) {:ok, new_history_entry} end defp format_transition_metadata(%WFT{} = tran, %WFI{} = inc_inst, %Msg{} = msg) do tran_attrs = tran.attributes |> Enum.map(fn {k, v} -> {Atom.to_string(k), v} end) |> Map.new() |> Map.put("from", tran.from) |> Map.put("to", tran.to) update_metadata_keys(%{ "id" => inc_inst.id, "transition" => tran_attrs, "state" => inc_inst.state, "message" => msg }) end @spec update_incident_possible_transition(map, WFT.t(), WFI.t(), Msg.t()) :: {:ok, map} def update_incident_possible_transition( pos_tran, %WFT{} = tran, %WFI{} = inc_inst, %Msg{} = msg ) do severity = Map.get(tran.attributes, :severity, Map.get(inc_inst.state, "severity")) metadata = format_transition_metadata(tran, inc_inst, msg) description = apply_metadata(tran.attributes.description_before, metadata) new_pos_tran = pos_tran |> Map.put("severity", severity) |> Map.put("description", description) {:ok, new_pos_tran} end @spec build(WF.t()) :: {:ok, WF.t()} | {:error, :transition_from_required} | {:error, :transition_to_required} | {:error, :description_after_required} | {:error, :description_before_required} | {:error, {:bad_callback, {atom, atom}}} | {:error, :multiple_init_statuses} | {:error, {:user_actions_invalid | :upsert_attributes_invalid, reason :: String.t()}} def build(%WF{} = wf) do with :ok <- validate_incident_transitions(wf) do WF.build(wf) end end @spec validate_incident_transitions(WF.t()) :: :ok | {:error, :description_after_required} | {:error, :description_before_required} defp validate_incident_transitions(%WF{} = wf) do wf.transitions |> Map.values() |> List.flatten() |> Enum.reduce_while(:ok, fn tran, acc -> cond do !Map.has_key?(tran.attributes, :description_after) -> {:halt, {:error, :description_after_required}} !Map.has_key?(tran.attributes, :description_before) -> {:halt, {:error, :description_before_required}} (error = validate_user_actions_def(tran.attributes[:user_actions])) != :ok -> {:error, reason} = error {:halt, {:error, {:user_actions_invalid, reason}}} (error = validate_upsert_attributes(tran.attributes[:upsert_attributes])) != :ok -> {:error, reason} = error {:halt, {:error, {:upsert_attributes_invalid, reason}}} true -> {:cont, acc} end end) end defp validate_user_actions_def(nil), do: :ok defp validate_user_actions_def(user_actions_def) do with {:map, true} <- {:map, is_map(user_actions_def)}, {:unknown_def, :none} <- {:unknown_def, Enum.find(user_actions_def, :none, &(!match?({_name, {_mod, _fun}}, &1)))}, {:exported_fun, :none} <- {:exported_fun, Enum.find(user_actions_def, :none, fn {_name, {mod, fun}} -> Code.ensure_loaded?(mod) !function_exported?(mod, fun, 3) end)} do :ok else {:map, _} -> {:error, "user actions definition is not map"} {:unknown_def, {name, _}} -> {:error, "user action #{name}: unknown definition"} {:exported_fun, {name, {mod, fun}}} -> {:error, "user action #{name}: function #{mod}.#{fun}/3 is not exported"} end end def validate_upsert_attributes(nil), do: :ok def validate_upsert_attributes(upsert_attributes_def) do with {:list, true} <- {:list, is_list(upsert_attributes_def)}, {:unknown_def, :none} <- {:unknown_def, Enum.find(upsert_attributes_def, :none, &(!match?({_mod, _fun}, &1)))}, {:exported_fun, :none} <- {:exported_fun, Enum.find(upsert_attributes_def, :none, fn {mod, fun} -> Code.ensure_loaded?(mod) !function_exported?(mod, fun, 3) end)} do :ok else {:list, _} -> {:error, "not a list"} {:unknown_def, def} -> {:error, "invalid element: #{inspect(def)}"} {:exported_fun, {mod, fun}} -> {:error, "function #{mod}.#{fun}/3 is not exported"} end end @spec apply_metadata(String.t(), map) :: String.t() defp apply_metadata(text, metadata) do Regex.replace(@placeholder_re, text, fn _, key -> metadata |> UtilsMap.get_path(String.split(key, "."), "") |> Kernel.to_string() end) end defp update_metadata_keys(metadata) when is_map(metadata) do metadata |> Enum.map(fn {k, %_{} = v} when is_map(v) -> {to_string(k), update_metadata_keys(Map.from_struct(v))} {k, %{} = v} when is_map(v) -> {to_string(k), update_metadata_keys(v)} {k, v} -> {to_string(k), v} end) |> Map.new() end defp prepare_user_actions_for_update(nil, _), do: {%{}, %{}} defp prepare_user_actions_for_update(current, nil) do prepare_user_actions_for_update(current, %{}) end defp prepare_user_actions_for_update(current, previous) do delete_actions = (Map.keys(previous) -- Map.keys(current)) |> Enum.into(%{}, fn item -> {item, true} end) update_actions = current |> Enum.filter(fn {action, token} -> previous[action] != token end) |> Enum.into(%{}) {update_actions, delete_actions} end defp evaluate_user_actions(actions_def, transition, instance, msg) do actions_def |> Enum.map(fn {action_name, token_def} -> {action_name, evaluate_user_action_token(token_def, transition, instance, msg)} end) |> Enum.filter(fn {_, token} -> is_binary(token) end) |> Enum.into(%{}) end defp evaluate_user_action_token({mod, fun}, transition, instance, msg) do case apply(mod, fun, [transition, instance, msg]) do {:ok, token} when is_binary(token) -> token _ -> nil end end defp evaluate_upsert_attributes(defs, transition, instance, msg) do Enum.reduce(defs, %{}, fn {mod, fun}, attrs -> case apply(mod, fun, [transition, instance, msg]) do {:ok, new_attrs} -> Map.merge(attrs, new_attrs) _ -> attrs end end) end defp make_user_actions_upsert(empty) when empty == %{} do %{} end defp make_user_actions_upsert(data) do %{"user_actions" => data} end defp merge_maps(list_of_maps) do Enum.reduce(list_of_maps, &Map.merge(&2, &1)) end end