# credo:disable-for-this-file
defmodule Rulestead.Admin.Lifecycle do
@moduledoc false
# Derives persisted admin lifecycle state from authored flag data.
alias Rulestead.Admin.LifecycleDefaults
@default_stale_after_seconds 30 * 24 * 60 * 60
@default_secondary_limit 2
@protected_flag_types [:kill_switch, :operational, :permission]
@type state :: :active | :potentially_stale | :stale | :archived
@spec classify(map() | struct(), map() | struct(), keyword()) :: map()
def classify(flag, flag_environment, opts \\ []) do
flag = Map.new(flag)
flag_environment = Map.new(flag_environment)
ownership = normalize_nested_map(flag[:ownership])
lifecycle = normalize_nested_map(flag[:lifecycle])
permanent = truthy?(flag[:permanent])
expected_expiration = flag[:expected_expiration]
last_evaluated_at = flag_environment[:last_evaluated_at]
mode = lifecycle[:mode] || if(permanent, do: :permanent, else: :expiring)
suggestion =
LifecycleDefaults.suggest(flag[:flag_type],
authored_mode: mode,
authored_review_by: lifecycle[:review_by] || expected_expiration
)
freshness =
freshness(flag, flag_environment,
now: Keyword.get(opts, :now, DateTime.utc_now()),
warning_after_seconds: warning_after_seconds(opts),
stale_after_seconds: stale_after_seconds(opts),
code_reference_count: Keyword.get(opts, :code_reference_count),
code_refs_scan: Keyword.get(opts, :code_refs_scan)
)
lifecycle_branch = %{
state: freshness.state,
mode: mode,
ownership: ownership,
review_by: lifecycle[:review_by] || expected_expiration,
expected_expiration: expected_expiration,
permanent: permanent,
default_source: lifecycle[:default_source],
default_overridden: lifecycle[:default_overridden] == true,
last_evaluated_at: last_evaluated_at
}
archive_readiness =
archive_readiness(flag, lifecycle_branch, freshness,
now: Keyword.get(opts, :now, DateTime.utc_now()),
secondary_limit: Keyword.get(opts, :secondary_limit, @default_secondary_limit)
)
%{
state: freshness.state,
mode: mode,
owner: owner_label(ownership, flag[:owner]),
owner_ref: ownership[:owner_ref],
owner_kind: ownership[:owner_kind],
owner_display: ownership[:owner_display],
ownership: ownership,
expected_expiration: expected_expiration,
permanent: permanent,
review_by: lifecycle[:review_by] || expected_expiration,
default_source: lifecycle[:default_source],
default_overridden: lifecycle[:default_overridden] == true,
suggestion: suggestion,
last_evaluated_at: last_evaluated_at,
lifecycle: lifecycle_branch,
freshness: freshness,
archive_readiness: archive_readiness
}
end
defp freshness(flag, flag_environment, opts) do
state = state(flag, flag_environment, opts)
evaluation = evaluation_freshness(flag_environment, state)
code_references = code_reference_freshness(flag, opts)
%{
state: state,
evaluation: evaluation,
code_references: code_references,
last_evaluated_at: flag_environment[:last_evaluated_at],
code_refs_scan: scan_summary(Keyword.get(opts, :code_refs_scan))
}
end
defp state(flag, flag_environment, opts) do
if not is_nil(flag[:archived_at]) or flag_environment[:status] == :archived do
:archived
else
state_from_freshness(flag_environment, opts)
end
end
defp state_from_freshness(%{last_evaluated_at: nil}, _opts), do: :potentially_stale
defp state_from_freshness(
%{last_evaluated_at: %DateTime{} = last_evaluated_at} = flag_environment,
opts
) do
now = Keyword.fetch!(opts, :now)
stale_after_seconds = Keyword.fetch!(opts, :stale_after_seconds)
warning_after_seconds = Keyword.fetch!(opts, :warning_after_seconds)
last_published_at = flag_environment[:last_published_at] || last_evaluated_at
variants_served = flag_environment[:variants_served] || %{}
eval_age_seconds = DateTime.diff(now, last_evaluated_at, :second)
pub_age_seconds = DateTime.diff(now, last_published_at, :second)
served_one_variant? = map_size(variants_served) <= 1
terminal_stale? = pub_age_seconds >= stale_after_seconds and served_one_variant?
terminal_warning? = pub_age_seconds >= warning_after_seconds and served_one_variant?
cond do
eval_age_seconds >= stale_after_seconds -> :stale
terminal_stale? -> :stale
eval_age_seconds >= warning_after_seconds -> :potentially_stale
terminal_warning? -> :potentially_stale
true -> :active
end
end
defp evaluation_freshness(_flag_environment, :archived), do: :not_applicable
defp evaluation_freshness(%{last_evaluated_at: nil}, _state), do: :never_evaluated
defp evaluation_freshness(_flag_environment, :potentially_stale), do: :recently_evaluated
defp evaluation_freshness(_flag_environment, :active), do: :recently_evaluated
defp evaluation_freshness(_flag_environment, _state), do: :not_evaluated_recently
defp code_reference_freshness(flag, opts) do
reference_count = Keyword.get(opts, :code_reference_count)
scan = scan_summary(Keyword.get(opts, :code_refs_scan))
now = Keyword.fetch!(opts, :now)
stale_after_seconds = Keyword.fetch!(opts, :stale_after_seconds)
cond do
reference_count_present?(reference_count) and reference_count > 0 ->
:refs_present
reference_count_present?(reference_count) and reference_count == 0 and
fresh_scan?(scan, now, stale_after_seconds) ->
:fresh_refs_absent
stale_scan?(scan, now, stale_after_seconds) ->
:scan_stale
is_nil(scan) and archived?(flag) ->
:not_applicable
true ->
:scan_unknown
end
end
defp archive_readiness(flag, lifecycle, freshness, opts) do
positive_reasons = positive_reasons(flag, lifecycle, freshness, opts)
blockers = blockers(flag, lifecycle, freshness)
unknowns = unknowns(freshness)
evidence_quality = evidence_quality(positive_reasons, blockers, unknowns)
readiness = readiness(flag, positive_reasons, blockers, unknowns, evidence_quality)
primary_action = recommended_next_action(readiness, blockers, unknowns, evidence_quality)
%{
readiness: readiness,
evidence_quality: evidence_quality,
reasons: positive_reasons,
unknowns: unknowns,
blockers: blockers,
recommended_next_action: primary_action,
secondary_actions:
secondary_actions(primary_action, blockers, unknowns)
|> Enum.take(Keyword.fetch!(opts, :secondary_limit))
}
end
defp positive_reasons(flag, lifecycle, freshness, opts) do
now = Keyword.fetch!(opts, :now)
review_by = lifecycle.review_by
[]
|> maybe_add_reason(lifecycle.mode == :expiring, :expiring_posture)
|> maybe_add_reason(review_due?(review_by, now), :review_horizon_passed)
|> maybe_add_reason(freshness.evaluation == :not_evaluated_recently, :stale_evaluation)
|> maybe_add_reason(freshness.evaluation == :never_evaluated, :never_evaluated)
|> maybe_add_reason(freshness.code_references == :fresh_refs_absent, :no_code_refs)
|> maybe_add_reason(archived?(flag), :already_archived)
end
defp blockers(flag, lifecycle, freshness) do
[]
|> maybe_add_reason(protected_flag_type?(flag[:flag_type]), :protected_flag_type)
|> maybe_add_reason(lifecycle.mode == :permanent, :permanent_posture)
|> maybe_add_reason(flag[:flag_type] == :remote_config, :remote_config_requires_review)
|> maybe_add_reason(freshness.code_references == :refs_present, :code_refs_present)
|> maybe_add_reason(archived?(flag), :already_archived)
end
defp unknowns(freshness) do
[]
|> maybe_add_reason(freshness.code_references == :scan_unknown, :code_refs_scan_missing)
|> maybe_add_reason(freshness.code_references == :scan_stale, :code_refs_scan_stale)
|> maybe_add_reason(freshness.evaluation == :never_evaluated, :evaluation_missing)
end
defp evidence_quality(reasons, blockers, unknowns) do
cond do
Enum.any?(unknowns, &(&1 in [:code_refs_scan_missing, :code_refs_scan_stale])) -> :weak
reasons != [] and blockers == [] and unknowns == [] -> :strong
reasons != [] and length(unknowns) <= 1 -> :partial
blockers != [] and reasons != [] -> :partial
true -> :weak
end
end
defp readiness(flag, reasons, blockers, unknowns, evidence_quality) do
cond do
archived?(flag) -> :keep_active
blockers != [] -> :keep_active
evidence_quality == :strong and archive_candidate_reasons?(reasons) -> :archive_candidate
unknowns != [] -> :needs_review
evidence_quality == :partial and archive_positive?(reasons) -> :needs_review
true -> :needs_review
end
end
defp recommended_next_action(:archive_candidate, _blockers, _unknowns, :strong),
do: :archive_ready
defp recommended_next_action(:keep_active, _blockers, _unknowns, _quality), do: :keep_active
defp recommended_next_action(_readiness, _blockers, _unknowns, :weak), do: nil
defp recommended_next_action(_readiness, _blockers, _unknowns, _quality), do: :review_manually
defp secondary_actions(primary_action, blockers, unknowns) do
[]
|> maybe_add_reason(:code_refs_scan_missing in unknowns, :refresh_code_refs)
|> maybe_add_reason(:code_refs_scan_stale in unknowns, :refresh_code_refs)
|> maybe_add_reason(:evaluation_missing in unknowns, :collect_eval_evidence)
|> maybe_add_reason(:code_refs_present in blockers, :remove_code_refs)
|> maybe_add_reason(:permanent_posture in blockers, :mark_permanent)
|> Enum.reject(&(&1 == primary_action))
|> Enum.uniq()
end
defp scan_summary(nil), do: nil
defp scan_summary(%_{} = scan),
do: scan |> Map.from_struct() |> scan_summary()
defp scan_summary(scan) when is_map(scan) do
%{
received_at: Map.get(scan, :received_at) || Map.get(scan, "received_at"),
reference_count: Map.get(scan, :reference_count) || Map.get(scan, "reference_count") || 0
}
end
defp scan_summary(_scan), do: nil
defp fresh_scan?(nil, _now, _threshold), do: false
defp fresh_scan?(%{received_at: %DateTime{} = received_at}, now, threshold) do
DateTime.diff(now, received_at, :second) < threshold
end
defp fresh_scan?(_scan, _now, _threshold), do: false
defp stale_scan?(nil, _now, _threshold), do: false
defp stale_scan?(%{received_at: %DateTime{} = received_at}, now, threshold) do
DateTime.diff(now, received_at, :second) >= threshold
end
defp stale_scan?(_scan, _now, _threshold), do: false
defp protected_flag_type?(flag_type), do: flag_type in @protected_flag_types
defp review_due?(nil, _now), do: false
defp review_due?(%Date{} = review_by, %DateTime{} = now),
do: Date.compare(review_by, DateTime.to_date(now)) != :gt
defp review_due?(_review_by, _now), do: false
defp archive_positive?(reasons) do
Enum.any?(
reasons,
&(&1 in [:review_horizon_passed, :stale_evaluation, :no_code_refs, :never_evaluated])
)
end
defp archive_candidate_reasons?(reasons) do
:no_code_refs in reasons and
Enum.any?(reasons, &(&1 in [:review_horizon_passed, :stale_evaluation, :never_evaluated]))
end
defp maybe_add_reason(list, true, reason), do: list ++ [reason]
defp maybe_add_reason(list, false, _reason), do: list
defp reference_count_present?(value), do: is_integer(value) and value >= 0
defp archived?(flag), do: not is_nil(flag[:archived_at])
defp stale_after_seconds(opts),
do: Keyword.get(opts, :stale_after_seconds, @default_stale_after_seconds)
defp warning_after_seconds(opts),
do: Keyword.get(opts, :warning_after_seconds, div(stale_after_seconds(opts), 2))
defp truthy?(value), do: value in [true, "true", 1, "1"]
defp normalize_nested_map(nil), do: %{}
defp normalize_nested_map(%_{} = struct),
do: struct |> Map.from_struct() |> normalize_nested_map()
defp normalize_nested_map(map) when is_map(map),
do: Map.new(map, fn {key, value} -> {normalize_key(key), value} end)
defp normalize_nested_map(_value), do: %{}
defp normalize_key(key) when is_binary(key) do
try do
String.to_existing_atom(key)
rescue
ArgumentError -> key
end
end
defp normalize_key(key) when is_atom(key), do: key
defp owner_label(ownership, owner) do
ownership[:owner_display] || ownership[:owner_ref] || owner
end
end