diff --git a/lib/nopea/deploy.ex b/lib/nopea/deploy.ex index 2b911f9..79ab119 100644 --- a/lib/nopea/deploy.ex +++ b/lib/nopea/deploy.ex @@ -215,7 +215,8 @@ defmodule Nopea.Deploy do manifests_applied: result.manifest_count, duration_ms: result.duration_ms, verified: result.verified, - error: result.error + error: result.error, + applied_resources: result.applied_resources } memory_context = @@ -227,6 +228,9 @@ defmodule Nopea.Deploy do occurrence = Nopea.Occurrence.build(occurrence_input, memory_context) + # Start log emitter and emit key deploy events + occurrence = emit_deploy_logs(occurrence, result) + # Persist to .nopea/ directory workdir = File.cwd!() Nopea.Occurrence.persist(occurrence, workdir) @@ -240,6 +244,70 @@ defmodule Nopea.Deploy do ) end + defp emit_deploy_logs(occurrence, result) do + case Nopea.Occurrence.start_log_emitter(occurrence) do + {:ok, emitter} -> + FalseProtocol.LogEmitter.info_full( + emitter, + "deploy started for #{result.service}", + %FalseProtocol.Semantic{ + event: "deploy.apply.start", + what_happened: + "started applying #{result.manifest_count} manifests to #{result.namespace}" + } + ) + + emit_status_log(emitter, result) + Nopea.Occurrence.attach_log_ref(occurrence, emitter) + + {:error, reason} -> + Logger.warning("Failed to start deploy log emitter", + service: result.service, + reason: inspect(reason) + ) + + occurrence + end + end + + defp emit_status_log(emitter, %{status: :completed} = result) do + FalseProtocol.LogEmitter.info_full( + emitter, + "deploy completed in #{result.duration_ms}ms", + %FalseProtocol.Semantic{ + event: "deploy.apply.complete", + what_happened: "#{result.service} deployed successfully", + parameters: %{"verified" => result.verified, "duration_ms" => result.duration_ms} + } + ) + end + + defp emit_status_log(emitter, %{status: :failed} = result) do + FalseProtocol.LogEmitter.emit( + emitter, + :error, + "deploy failed: #{inspect(result.error)}", + %FalseProtocol.Semantic{ + event: "deploy.apply.failed", + what_happened: "#{result.service} deployment failed", + impact: "service in #{result.namespace} is not updated" + } + ) + end + + defp emit_status_log(emitter, %{status: :rolledback} = result) do + FalseProtocol.LogEmitter.emit( + emitter, + :warning, + "deploy rolledback: #{inspect(result.error)}", + %FalseProtocol.Semantic{ + event: "deploy.apply.rolledback", + what_happened: "#{result.service} deployment rolled back", + impact: "service in #{result.namespace} reverted to previous version" + } + ) + end + defp emit_start(spec, deploy_id, strategy) do Nopea.Metrics.emit_deploy_start(%{service: spec.service, strategy: strategy}) diff --git a/lib/nopea/occurrence.ex b/lib/nopea/occurrence.ex index a238bcf..27b3a93 100644 --- a/lib/nopea/occurrence.ex +++ b/lib/nopea/occurrence.ex @@ -2,10 +2,11 @@ defmodule Nopea.Occurrence do @moduledoc """ Generates FALSE Protocol occurrences for deployment events. - Adapts the SYKLI occurrence format for deployments: + Adapts `Nopea.Deploy.Result` into `FalseProtocol.Occurrence` structs with: - **Error block** — what_failed, why_it_matters, possible_causes - **Reasoning block** — summary + memory context from knowledge graph - - **History block** — deployment steps with outcomes + - **History block** — deployment steps with timestamps and outcomes + - **Context** — namespace + entities from applied K8s resources - **Deploy data** — service, namespace, strategy, manifests, duration ## FALSE Protocol Type Hierarchy @@ -15,7 +16,7 @@ defmodule Nopea.Occurrence do deploy.run.rolledback — deployment was rolled back """ - @occurrence_version "1.0" + alias FalseProtocol.{Occurrence, Error, Reasoning, History, HistoryStep, Entity, PatternMatch} @doc """ Builds a FALSE Protocol occurrence from a deploy result. @@ -23,39 +24,55 @@ defmodule Nopea.Occurrence do Optional second argument provides memory context from the knowledge graph to enrich the reasoning block. """ - @spec build(map(), map() | nil) :: map() + @spec build(map(), map() | nil) :: Occurrence.t() def build(result, memory_context \\ nil) do - {type_suffix, severity} = outcome_and_severity(result.status) - - occurrence = %{ - "version" => @occurrence_version, - "id" => Nopea.Helpers.generate_ulid(), - "timestamp" => DateTime.utc_now() |> DateTime.to_iso8601(), - "source" => "nopea", - "type" => "deploy.run.#{type_suffix}", - "severity" => severity, - "outcome" => type_suffix - } + {type_suffix, severity, outcome} = classify(result.status) + + {:ok, occ} = + Occurrence.new("nopea", "deploy.run.#{type_suffix}", + severity: severity, + outcome: outcome + ) + + occ + |> maybe_set_namespace(result) + |> maybe_add_entities(result) + |> Occurrence.with_data(build_deploy_data(result)) + |> Occurrence.with_history(build_history(result)) + |> maybe_add_error(result) + |> maybe_add_reasoning(result, memory_context) + end + + @doc """ + Starts a `FalseProtocol.LogEmitter` for the given occurrence. - occurrence - |> maybe_add("error", build_error_block(result)) - |> maybe_add("reasoning", build_reasoning_block(result, memory_context)) - |> Map.put("history", build_history_block(result)) - |> Map.put("deploy_data", build_deploy_data(result)) + Mode is `:both` — deploy logs are human-readable AND AI-structured. + """ + @spec start_log_emitter(Occurrence.t()) :: {:ok, pid()} + def start_log_emitter(%Occurrence{} = occ) do + FalseProtocol.LogEmitter.start_link(occ.id, "nopea", :both) end - @spec persist(map(), String.t()) :: :ok | {:error, term()} - def persist(occurrence, workdir) do + @doc """ + Attaches the log emitter's current ref to the occurrence. + """ + @spec attach_log_ref(Occurrence.t(), pid()) :: Occurrence.t() + def attach_log_ref(%Occurrence{} = occ, emitter) do + %{occ | log_ref: FalseProtocol.LogEmitter.log_ref(emitter)} + end + + @spec persist(Occurrence.t(), String.t()) :: :ok | {:error, term()} + def persist(%Occurrence{} = occurrence, workdir) do dir = Path.join(workdir, ".nopea") etf_dir = Path.join(dir, "occurrences") - with {:ok, json} <- Jason.encode(occurrence, pretty: true), + with {:ok, json} <- FalseProtocol.JSON.encode(occurrence), :ok <- File.mkdir_p(dir), :ok <- File.write(Path.join(dir, "occurrence.json"), json), :ok <- File.mkdir_p(etf_dir), :ok <- File.write( - Path.join(etf_dir, "#{occurrence["id"]}.etf"), + Path.join(etf_dir, "#{occurrence.id}.etf"), :erlang.term_to_binary(occurrence) ) do :ok @@ -63,47 +80,94 @@ defmodule Nopea.Occurrence do end # ───────────────────────────────────────────────────────────────────────────── - # FALSE PROTOCOL: ERROR BLOCK + # CLASSIFICATION + # ───────────────────────────────────────────────────────────────────────────── + + defp classify(:completed), do: {"completed", :info, :success} + defp classify(:failed), do: {"failed", :error, :failure} + defp classify(:rolledback), do: {"rolledback", :warning, :failure} + defp classify(_), do: {"failed", :error, :failure} + + # ───────────────────────────────────────────────────────────────────────────── + # CONTEXT: NAMESPACE + ENTITIES + # ───────────────────────────────────────────────────────────────────────────── + + defp maybe_set_namespace(occ, %{namespace: ns}) when is_binary(ns) do + Occurrence.in_namespace(occ, ns) + end + + defp maybe_set_namespace(occ, _), do: occ + + defp maybe_add_entities(occ, %{applied_resources: resources}) when is_list(resources) do + Enum.reduce(resources, occ, fn resource, acc -> + case build_entity(resource) do + nil -> acc + entity -> Occurrence.with_entity(acc, entity) + end + end) + end + + defp maybe_add_entities(occ, _), do: occ + + defp build_entity(%{"kind" => kind, "metadata" => meta}) do + uid = Map.get(meta, "uid", "unknown") + name = Map.get(meta, "name", "unknown") + namespace = Map.get(meta, "namespace") + resource_version = Map.get(meta, "resourceVersion", "0") + + entity = Entity.from_k8s(kind, uid, name, namespace || "", resource_version) + entity + end + + defp build_entity(_), do: nil + + # ───────────────────────────────────────────────────────────────────────────── + # ERROR BLOCK # ───────────────────────────────────────────────────────────────────────────── - defp build_error_block(%{status: :completed}), do: nil + defp maybe_add_error(occ, %{status: :completed}), do: occ - defp build_error_block(%{status: status, service: service, error: error} = result) + defp maybe_add_error(occ, %{status: status, service: service, error: error} = result) when status in [:failed, :rolledback] do - %{ - "code" => error_code(error), - "what_failed" => "deploy of #{service} (#{result.strategy})", - "why_it_matters" => + err = %Error{ + code: error_code(error), + message: error_message(error), + what_failed: "deploy of #{service} (#{result.strategy})", + why_it_matters: "#{service} in #{result.namespace} is not updated — " <> status_impact(status, result.strategy) } - |> maybe_add("message", error_message(error)) - |> reject_nils() + + Occurrence.with_error(occ, err) end - defp build_error_block(_), do: nil + defp maybe_add_error(occ, _), do: occ # ───────────────────────────────────────────────────────────────────────────── - # FALSE PROTOCOL: REASONING BLOCK + # REASONING BLOCK # ───────────────────────────────────────────────────────────────────────────── - defp build_reasoning_block(%{status: :completed}, _memory), do: nil + defp maybe_add_reasoning(occ, %{status: :completed}, _memory), do: occ - defp build_reasoning_block(%{status: status} = result, memory) + defp maybe_add_reasoning(occ, %{status: status} = result, memory) when status in [:failed, :rolledback] do summary = build_summary(result) + confidence = if memory && memory[:known], do: 0.8, else: 0.3 - reasoning = %{ - "summary" => summary, - "confidence" => if(memory && memory[:known], do: 0.8, else: 0.3) + explanation = build_explanation(result, memory) + patterns = build_patterns(memory) + + reasoning = %Reasoning{ + summary: summary, + explanation: explanation, + confidence: confidence, + patterns_matched: patterns } - reasoning - |> maybe_add("memory_context", build_memory_context(memory)) - |> maybe_add("recommendations", build_recommendations(memory)) + Occurrence.with_reasoning(occ, reasoning) end - defp build_reasoning_block(_result, _memory), do: nil + defp maybe_add_reasoning(occ, _result, _memory), do: occ defp build_summary(%{service: service, error: error}) do case error do @@ -113,111 +177,158 @@ defmodule Nopea.Occurrence do end end - defp build_memory_context(nil), do: nil - defp build_memory_context(%{failure_patterns: []}), do: nil + defp build_explanation(result, nil) do + "#{result.service} deployment #{result.status} after #{result.duration_ms}ms" + end - defp build_memory_context(%{failure_patterns: patterns}) when is_list(patterns) do - Enum.map(patterns, fn p -> - %{ - "error" => p.error, - "confidence" => p.confidence, - "observations" => p.observations - } - end) - |> non_empty() + defp build_explanation(result, %{recommendations: recs}) when is_list(recs) and recs != [] do + base = "#{result.service} deployment #{result.status} after #{result.duration_ms}ms." + base <> " " <> Enum.join(recs, " ") end - defp build_memory_context(_), do: nil + defp build_explanation(result, _memory) do + "#{result.service} deployment #{result.status} after #{result.duration_ms}ms" + end - defp build_recommendations(nil), do: nil + defp build_patterns(nil), do: [] + defp build_patterns(%{failure_patterns: []}), do: [] - defp build_recommendations(%{recommendations: recs}) when is_list(recs) do - non_empty(recs) + defp build_patterns(%{failure_patterns: patterns}) when is_list(patterns) do + Enum.map(patterns, fn p -> + %PatternMatch{ + pattern_name: to_string(p.error), + confidence: p.confidence, + times_seen: p.observations + } + end) end - defp build_recommendations(_), do: nil + defp build_patterns(_), do: [] # ───────────────────────────────────────────────────────────────────────────── - # FALSE PROTOCOL: HISTORY BLOCK + # HISTORY BLOCK # ───────────────────────────────────────────────────────────────────────────── - defp build_history_block(result) do + defp build_history(result) do steps = build_steps(result) - %{ - "steps" => steps, - "duration_ms" => result.duration_ms + %History{ + steps: steps, + duration_ms: result.duration_ms } end defp build_steps(%{status: :completed} = result) do - [ - %{ - "description" => "apply manifests", - "status" => "completed", - "duration_ms" => result.duration_ms + now = DateTime.utc_now() + + steps = [ + %HistoryStep{ + timestamp: now, + action: "apply", + description: "apply manifests", + outcome: :success, + duration_ms: result.duration_ms } ] - |> maybe_add_verification_step(result) + + maybe_add_verification_step(steps, result, now) end defp build_steps(%{status: :failed, error: error} = result) do [ - %{ - "description" => "apply manifests", - "status" => "failed", - "duration_ms" => result.duration_ms, - "error" => error_message(error) || "unknown error" + %HistoryStep{ + timestamp: DateTime.utc_now(), + action: "apply", + description: "apply manifests", + outcome: :failure, + duration_ms: result.duration_ms, + error: %Error{ + code: error_code(error), + message: error_message(error) || "unknown error", + what_failed: "manifest application" + } } ] end defp build_steps(%{status: :rolledback, error: error} = result) do + now = DateTime.utc_now() + [ - %{ - "description" => "apply manifests", - "status" => "failed", - "duration_ms" => result.duration_ms, - "error" => error_message(error) || "unknown error" + %HistoryStep{ + timestamp: now, + action: "apply", + description: "apply manifests", + outcome: :failure, + duration_ms: result.duration_ms, + error: %Error{ + code: error_code(error), + message: error_message(error) || "unknown error", + what_failed: "manifest application" + } }, - %{"description" => "rollback", "status" => "completed"} + %HistoryStep{ + timestamp: now, + action: "rollback", + description: "rollback to previous version", + outcome: :success + } ] end defp build_steps(result) do - [%{"description" => "deploy", "status" => Atom.to_string(result.status)}] + [ + %HistoryStep{ + timestamp: DateTime.utc_now(), + action: "deploy", + description: "deploy", + outcome: map_step_outcome(result.status) + } + ] end - defp maybe_add_verification_step(steps, %{verified: true}) do - steps ++ [%{"description" => "post-deploy verification", "status" => "passed"}] + defp maybe_add_verification_step(steps, %{verified: true}, now) do + steps ++ + [ + %HistoryStep{ + timestamp: now, + action: "verify", + description: "post-deploy verification", + outcome: :success + } + ] end - defp maybe_add_verification_step(steps, _result), do: steps + defp maybe_add_verification_step(steps, _result, _now), do: steps + + defp map_step_outcome(:completed), do: :success + defp map_step_outcome(:failed), do: :failure + defp map_step_outcome(:rolledback), do: :failure + defp map_step_outcome(_), do: :failure # ───────────────────────────────────────────────────────────────────────────── # DEPLOY DATA (Domain-Specific Payload) # ───────────────────────────────────────────────────────────────────────────── defp build_deploy_data(result) do - %{ + data = %{ "service" => result.service, "namespace" => result.namespace, - "strategy" => Atom.to_string(result.strategy), + "strategy" => to_string(result.strategy), "manifests_applied" => Map.get(result, :manifests_applied, 0), "verified" => Map.get(result, :verified, false) } - |> maybe_add("deploy_id", Map.get(result, :deploy_id)) + + case Map.get(result, :deploy_id) do + nil -> data + id -> Map.put(data, "deploy_id", id) + end end # ───────────────────────────────────────────────────────────────────────────── # HELPERS # ───────────────────────────────────────────────────────────────────────────── - defp outcome_and_severity(:completed), do: {"completed", "info"} - defp outcome_and_severity(:failed), do: {"failed", "error"} - defp outcome_and_severity(:rolledback), do: {"rolledback", "warning"} - defp outcome_and_severity(_), do: {"failed", "error"} - defp error_code({type, _msg}) when is_atom(type), do: Atom.to_string(type) defp error_code(msg) when is_binary(msg), do: "error" defp error_code(_), do: "unknown" @@ -230,17 +341,4 @@ defmodule Nopea.Occurrence do defp status_impact(:failed, _), do: "service may be partially updated" defp status_impact(:rolledback, _), do: "rolled back to previous version" defp status_impact(_, _), do: "deployment incomplete" - - defp maybe_add(map, _key, nil), do: map - defp maybe_add(map, key, value), do: Map.put(map, key, value) - - defp non_empty(nil), do: nil - defp non_empty([]), do: nil - defp non_empty(list), do: list - - defp reject_nils(map) do - map - |> Enum.reject(fn {_k, v} -> is_nil(v) end) - |> Map.new() - end end diff --git a/mix.exs b/mix.exs index fed3982..e4b58e0 100644 --- a/mix.exs +++ b/mix.exs @@ -43,6 +43,9 @@ defmodule Nopea.MixProject do # JSON {:jason, "~> 1.4"}, + # FALSE Protocol + {:false_protocol, path: "../false-protocol/elixir"}, + # Web server (for API) {:plug_cowboy, "~> 2.7"}, diff --git a/mix.lock b/mix.lock index faffadc..10f0f5b 100644 --- a/mix.lock +++ b/mix.lock @@ -39,6 +39,8 @@ "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, "telemetry_metrics_prometheus_core": {:hex, :telemetry_metrics_prometheus_core, "1.2.1", "c9755987d7b959b557084e6990990cb96a50d6482c683fb9622a63837f3cd3d8", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "5e2c599da4983c4f88a33e9571f1458bf98b0cf6ba930f1dc3a6e8cf45d5afb6"}, "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"}, + "ulid": {:hex, :ulid, "0.2.0", "1ef02026b7c8fa78a6ae6cb5e0d8f4ba92ed726b369849da328f93b7c0dab9cd", [:mix], [], "hexpm", "fadcc1d4cfa49028172f54bab9e464a69fb14f48f7652dad706d2bbb1ef76a6c"}, + "x509": {:hex, :x509, "0.9.2", "a75aa605348abd905990f3d2dc1b155fcde4e030fa2f90c4a91534405dce0f6e", [:mix], [], "hexpm", "4c5ede75697e565d4b0f5be04c3b71bb1fd3a090ea243af4bd7dae144e48cfc7"}, "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, "yaml_elixir": {:hex, :yaml_elixir, "2.12.0", "30343ff5018637a64b1b7de1ed2a3ca03bc641410c1f311a4dbdc1ffbbf449c7", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "ca6bacae7bac917a7155dca0ab6149088aa7bc800c94d0fe18c5238f53b313c6"}, } diff --git a/test/nopea/deploy_integration_test.exs b/test/nopea/deploy_integration_test.exs index b13018c..1423e86 100644 --- a/test/nopea/deploy_integration_test.exs +++ b/test/nopea/deploy_integration_test.exs @@ -120,8 +120,8 @@ defmodule Nopea.DeployIntegrationTest do assert is_binary(occurrence["id"]) assert String.starts_with?(occurrence["type"], "deploy.run.") assert occurrence["source"] == "nopea" - assert is_map(occurrence["deploy_data"]) - assert occurrence["deploy_data"]["service"] == "occ-test-svc" + assert is_map(occurrence["data"]) + assert occurrence["data"]["service"] == "occ-test-svc" end end end diff --git a/test/nopea/occurrence_log_test.exs b/test/nopea/occurrence_log_test.exs new file mode 100644 index 0000000..16b3ea7 --- /dev/null +++ b/test/nopea/occurrence_log_test.exs @@ -0,0 +1,97 @@ +defmodule Nopea.OccurrenceLogTest do + use ExUnit.Case, async: true + + alias Nopea.Occurrence, as: NopeaOccurrence + + @result %{ + service: "api-gateway", + namespace: "staging", + strategy: :direct, + status: :completed, + deploy_id: "01LOG", + manifests_applied: 2, + duration_ms: 800, + verified: true, + error: nil, + applied_resources: [] + } + + describe "start_log_emitter/1" do + test "starts a log emitter for the occurrence" do + occ = NopeaOccurrence.build(@result) + assert {:ok, emitter} = NopeaOccurrence.start_log_emitter(occ) + assert is_pid(emitter) + end + + test "emitter uses :both mode" do + occ = NopeaOccurrence.build(@result) + {:ok, emitter} = NopeaOccurrence.start_log_emitter(occ) + + # :both mode requires both message and semantic + semantic = %FalseProtocol.Semantic{ + event: "deploy.test.event", + what_happened: "test event" + } + + assert {:ok, entry} = FalseProtocol.LogEmitter.info_full(emitter, "test message", semantic) + assert entry.mode == :both + assert entry.message == "test message" + assert entry.semantic.event == "deploy.test.event" + end + + test "emitter sequences entries correctly" do + occ = NopeaOccurrence.build(@result) + {:ok, emitter} = NopeaOccurrence.start_log_emitter(occ) + + semantic = %FalseProtocol.Semantic{ + event: "deploy.test.first", + what_happened: "first" + } + + {:ok, entry1} = FalseProtocol.LogEmitter.info_full(emitter, "first", semantic) + + semantic2 = %FalseProtocol.Semantic{ + event: "deploy.test.second", + what_happened: "second" + } + + {:ok, entry2} = FalseProtocol.LogEmitter.info_full(emitter, "second", semantic2) + + assert entry1.seq == 1 + assert entry2.seq == 2 + end + + test "entries reference the parent occurrence" do + occ = NopeaOccurrence.build(@result) + {:ok, emitter} = NopeaOccurrence.start_log_emitter(occ) + + semantic = %FalseProtocol.Semantic{ + event: "deploy.test.ref", + what_happened: "ref test" + } + + {:ok, entry} = FalseProtocol.LogEmitter.info_full(emitter, "ref test", semantic) + assert entry.occurrence_id == occ.id + end + end + + describe "attach_log_ref/2" do + test "attaches log_ref with entry count" do + occ = NopeaOccurrence.build(@result) + {:ok, emitter} = NopeaOccurrence.start_log_emitter(occ) + + semantic = %FalseProtocol.Semantic{ + event: "deploy.test.count", + what_happened: "counting" + } + + FalseProtocol.LogEmitter.info_full(emitter, "one", semantic) + FalseProtocol.LogEmitter.info_full(emitter, "two", semantic) + + updated = NopeaOccurrence.attach_log_ref(occ, emitter) + + assert %FalseProtocol.LogRef{} = updated.log_ref + assert updated.log_ref.count == 2 + end + end +end diff --git a/test/nopea/occurrence_test.exs b/test/nopea/occurrence_test.exs index b3ebbd6..26d9391 100644 --- a/test/nopea/occurrence_test.exs +++ b/test/nopea/occurrence_test.exs @@ -1,8 +1,6 @@ defmodule Nopea.OccurrenceTest do use ExUnit.Case, async: true - alias Nopea.Occurrence - @successful_result %{ service: "auth-service", namespace: "production", @@ -12,7 +10,18 @@ defmodule Nopea.OccurrenceTest do manifests_applied: 3, duration_ms: 1500, verified: true, - error: nil + error: nil, + applied_resources: [ + %{ + "kind" => "Deployment", + "metadata" => %{ + "name" => "auth-service", + "namespace" => "production", + "uid" => "abc-123", + "resourceVersion" => "42" + } + } + ] } @failed_result %{ @@ -24,80 +33,128 @@ defmodule Nopea.OccurrenceTest do manifests_applied: 0, duration_ms: 30_000, verified: false, - error: {:timeout, "connection refused to api-server"} + error: {:timeout, "connection refused to api-server"}, + applied_resources: [] } describe "build/1 for successful deploys" do - test "produces valid FALSE Protocol envelope" do - occurrence = Occurrence.build(@successful_result) - - assert occurrence["version"] == "1.0" - assert occurrence["source"] == "nopea" - assert occurrence["type"] == "deploy.run.completed" - assert occurrence["severity"] == "info" - assert occurrence["outcome"] == "completed" - assert is_binary(occurrence["id"]) - assert is_binary(occurrence["timestamp"]) + test "produces valid FALSE Protocol occurrence struct" do + occ = Nopea.Occurrence.build(@successful_result) + + assert %FalseProtocol.Occurrence{} = occ + assert occ.protocol_version == "1.0" + assert occ.source == "nopea" + assert occ.type == "deploy.run.completed" + assert occ.severity == :info + assert occ.outcome == :success + assert is_binary(occ.id) + assert %DateTime{} = occ.timestamp end test "has no error block on success" do - occurrence = Occurrence.build(@successful_result) - refute Map.has_key?(occurrence, "error") + occ = Nopea.Occurrence.build(@successful_result) + assert occ.error == nil end test "has no reasoning block on success" do - occurrence = Occurrence.build(@successful_result) - refute Map.has_key?(occurrence, "reasoning") + occ = Nopea.Occurrence.build(@successful_result) + assert occ.reasoning == nil + end + + test "has history block with steps" do + occ = Nopea.Occurrence.build(@successful_result) + + assert %FalseProtocol.History{} = occ.history + assert is_list(occ.history.steps) + assert occ.history.duration_ms == 1500 + + [apply_step | _] = occ.history.steps + assert apply_step.action == "apply" + assert apply_step.outcome == :success + assert %DateTime{} = apply_step.timestamp end - test "has history block" do - occurrence = Occurrence.build(@successful_result) + test "includes verification step when verified" do + occ = Nopea.Occurrence.build(@successful_result) - assert history = occurrence["history"] - assert is_list(history["steps"]) - assert history["duration_ms"] == 1500 + actions = Enum.map(occ.history.steps, & &1.action) + assert "verify" in actions end - test "has deploy_data block" do - occurrence = Occurrence.build(@successful_result) + test "has deploy_data in data field" do + occ = Nopea.Occurrence.build(@successful_result) - assert data = occurrence["deploy_data"] + assert data = occ.data assert data["service"] == "auth-service" assert data["namespace"] == "production" assert data["strategy"] == "direct" assert data["manifests_applied"] == 3 assert data["verified"] == true end + + test "sets namespace in context" do + occ = Nopea.Occurrence.build(@successful_result) + assert occ.context.namespace == "production" + end + + test "builds entities from applied_resources" do + occ = Nopea.Occurrence.build(@successful_result) + + assert [entity] = occ.context.entities + assert entity.type == "Deployment" + assert entity.id == "abc-123" + assert entity.name == "auth-service" + assert entity.namespace == "production" + assert entity.version == "42" + assert entity.source_of_truth == "k8s-api" + end end describe "build/1 for failed deploys" do - test "produces failed envelope" do - occurrence = Occurrence.build(@failed_result) + test "produces failed occurrence" do + occ = Nopea.Occurrence.build(@failed_result) + + assert occ.type == "deploy.run.failed" + assert occ.severity == :error + assert occ.outcome == :failure + end + + test "has error struct with structured details" do + occ = Nopea.Occurrence.build(@failed_result) + + assert %FalseProtocol.Error{} = occ.error + assert occ.error.code == "timeout" + assert is_binary(occ.error.what_failed) + assert String.contains?(occ.error.what_failed, "payment-svc") + end + + test "error has why_it_matters" do + occ = Nopea.Occurrence.build(@failed_result) - assert occurrence["type"] == "deploy.run.failed" - assert occurrence["severity"] == "error" - assert occurrence["outcome"] == "failed" + assert is_binary(occ.error.why_it_matters) + assert String.contains?(occ.error.why_it_matters, "production") end - test "has error block with structured details" do - occurrence = Occurrence.build(@failed_result) + test "has reasoning block with low confidence without memory" do + occ = Nopea.Occurrence.build(@failed_result) - assert error = occurrence["error"] - assert error["code"] == "timeout" - assert is_binary(error["what_failed"]) - assert String.contains?(error["what_failed"], "payment-svc") + assert %FalseProtocol.Reasoning{} = occ.reasoning + assert occ.reasoning.confidence == 0.3 + assert is_binary(occ.reasoning.summary) end - test "error block has why_it_matters for canary" do - occurrence = Occurrence.build(@failed_result) + test "history steps have action and timestamp" do + occ = Nopea.Occurrence.build(@failed_result) - assert error = occurrence["error"] - assert is_binary(error["why_it_matters"]) + [step] = occ.history.steps + assert step.action == "apply" + assert step.outcome == :failure + assert %DateTime{} = step.timestamp end end describe "build/2 with memory context" do - test "includes reasoning block with memory context" do + test "includes reasoning block with patterns_matched" do memory_context = %{ known: true, failure_patterns: [ @@ -116,16 +173,20 @@ defmodule Nopea.OccurrenceTest do ] } - occurrence = Occurrence.build(@failed_result, memory_context) + occ = Nopea.Occurrence.build(@failed_result, memory_context) - assert reasoning = occurrence["reasoning"] - assert is_binary(reasoning["summary"]) - assert reasoning["confidence"] > 0 - assert is_list(reasoning["memory_context"]) - assert reasoning["memory_context"] != [] + assert %FalseProtocol.Reasoning{} = occ.reasoning + assert is_binary(occ.reasoning.summary) + assert occ.reasoning.confidence == 0.8 + + assert [pattern] = occ.reasoning.patterns_matched + assert %FalseProtocol.PatternMatch{} = pattern + assert pattern.pattern_name == "timeout" + assert pattern.confidence == 0.85 + assert pattern.times_seen == 4 end - test "reasoning includes recommendations from memory" do + test "reasoning explanation includes recommendations from memory" do memory_context = %{ known: true, failure_patterns: [], @@ -133,20 +194,36 @@ defmodule Nopea.OccurrenceTest do recommendations: ["Consider canary deployment."] } - occurrence = Occurrence.build(@failed_result, memory_context) + occ = Nopea.Occurrence.build(@failed_result, memory_context) - assert reasoning = occurrence["reasoning"] - assert "Consider canary deployment." in reasoning["recommendations"] + assert occ.reasoning.explanation =~ "Consider canary deployment." end end describe "build/2 with rolledback status" do - test "produces rolledback type" do + test "produces rolledback type with failure outcome" do + result = %{@failed_result | status: :rolledback} + occ = Nopea.Occurrence.build(result) + + assert occ.type == "deploy.run.rolledback" + assert occ.severity == :warning + assert occ.outcome == :failure + end + + test "history includes rollback step" do + result = %{@failed_result | status: :rolledback} + occ = Nopea.Occurrence.build(result) + + actions = Enum.map(occ.history.steps, & &1.action) + assert "apply" in actions + assert "rollback" in actions + end + + test "rollback indicated in deploy data" do result = %{@failed_result | status: :rolledback} - occurrence = Occurrence.build(result) + occ = Nopea.Occurrence.build(result) - assert occurrence["type"] == "deploy.run.rolledback" - assert occurrence["severity"] == "warning" + assert occ.data["service"] == "payment-svc" end end @@ -164,8 +241,8 @@ defmodule Nopea.OccurrenceTest do end test "writes occurrence.json to .nopea directory", %{workdir: workdir} do - occurrence = Occurrence.build(@successful_result) - assert :ok = Occurrence.persist(occurrence, workdir) + occ = Nopea.Occurrence.build(@successful_result) + assert :ok = Nopea.Occurrence.persist(occ, workdir) json_path = Path.join([workdir, ".nopea", "occurrence.json"]) assert File.exists?(json_path) @@ -173,11 +250,12 @@ defmodule Nopea.OccurrenceTest do {:ok, content} = File.read(json_path) {:ok, decoded} = Jason.decode(content) assert decoded["source"] == "nopea" + assert decoded["protocol_version"] == "1.0" end test "writes ETF to occurrences/ directory", %{workdir: workdir} do - occurrence = Occurrence.build(@successful_result) - :ok = Occurrence.persist(occurrence, workdir) + occ = Nopea.Occurrence.build(@successful_result) + :ok = Nopea.Occurrence.persist(occ, workdir) etf_dir = Path.join([workdir, ".nopea", "occurrences"]) assert File.exists?(etf_dir) @@ -186,5 +264,19 @@ defmodule Nopea.OccurrenceTest do assert length(files) == 1 assert hd(files) |> String.ends_with?(".etf") end + + test "ETF round-trips to same struct", %{workdir: workdir} do + occ = Nopea.Occurrence.build(@successful_result) + :ok = Nopea.Occurrence.persist(occ, workdir) + + etf_dir = Path.join([workdir, ".nopea", "occurrences"]) + [file] = File.ls!(etf_dir) + binary = File.read!(Path.join(etf_dir, file)) + restored = :erlang.binary_to_term(binary) + + assert restored.id == occ.id + assert restored.source == "nopea" + assert restored.type == occ.type + end end end