From cff02efbcaee61f80c27713878fd95d8a2e507d8 Mon Sep 17 00:00:00 2001 From: Yair Etziony Date: Fri, 27 Feb 2026 01:18:14 +0100 Subject: [PATCH 1/2] feat: migrate occurrence module to FalseProtocol library Replace hand-rolled FALSE Protocol maps with the official FalseProtocol Elixir library, gaining validated persistence, entity traceability from applied K8s resources, typed reasoning with PatternMatch structs, and LogEmitter integration for structured deploy logs. Co-Authored-By: Claude Opus 4.6 --- lib/nopea/deploy.ex | 47 +++- lib/nopea/occurrence.ex | 312 ++++++++++++++++--------- mix.exs | 3 + mix.lock | 2 + test/nopea/deploy_integration_test.exs | 4 +- test/nopea/occurrence_log_test.exs | 97 ++++++++ test/nopea/occurrence_test.exs | 212 ++++++++++++----- 7 files changed, 507 insertions(+), 170 deletions(-) create mode 100644 test/nopea/occurrence_log_test.exs diff --git a/lib/nopea/deploy.ex b/lib/nopea/deploy.ex index 2b911f9..4464e57 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,47 @@ defmodule Nopea.Deploy do ) end + defp emit_deploy_logs(occurrence, result) do + {:ok, emitter} = Nopea.Occurrence.start_log_emitter(occurrence) + + 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}" + } + ) + + case result.status do + :completed -> + 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} + } + ) + + status when status in [:failed, :rolledback] -> + FalseProtocol.LogEmitter.emit( + emitter, + :error, + "deploy #{status}: #{inspect(result.error)}", + %FalseProtocol.Semantic{ + event: "deploy.apply.#{status}", + what_happened: "#{result.service} deployment #{status}", + impact: "service in #{result.namespace} is not updated" + } + ) + end + + Nopea.Occurrence.attach_log_ref(occurrence, emitter) + 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 From d4c86c5abf3e7dfc7b4e2b73c172c676fb2a8196 Mon Sep 17 00:00:00 2001 From: Yair Etziony Date: Sun, 1 Mar 2026 00:35:24 +0100 Subject: [PATCH 2/2] fix: handle log emitter failure gracefully, fix rolledback log level Address PR review feedback: - emit_deploy_logs now handles {:error, reason} from start_log_emitter instead of crashing, returning the occurrence without log_ref - Rolledback deploys emit :warning (not :error) to match occurrence severity classification Co-Authored-By: Claude Opus 4.6 --- lib/nopea/deploy.ex | 81 +++++++++++++++++++++++++++++---------------- 1 file changed, 52 insertions(+), 29 deletions(-) diff --git a/lib/nopea/deploy.ex b/lib/nopea/deploy.ex index 4464e57..79ab119 100644 --- a/lib/nopea/deploy.ex +++ b/lib/nopea/deploy.ex @@ -245,44 +245,67 @@ defmodule Nopea.Deploy do end defp emit_deploy_logs(occurrence, result) do - {:ok, emitter} = Nopea.Occurrence.start_log_emitter(occurrence) - - 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}" - } - ) - - case result.status do - :completed -> + case Nopea.Occurrence.start_log_emitter(occurrence) do + {:ok, emitter} -> FalseProtocol.LogEmitter.info_full( emitter, - "deploy completed in #{result.duration_ms}ms", + "deploy started for #{result.service}", %FalseProtocol.Semantic{ - event: "deploy.apply.complete", - what_happened: "#{result.service} deployed successfully", - parameters: %{"verified" => result.verified, "duration_ms" => result.duration_ms} + event: "deploy.apply.start", + what_happened: + "started applying #{result.manifest_count} manifests to #{result.namespace}" } ) - status when status in [:failed, :rolledback] -> - FalseProtocol.LogEmitter.emit( - emitter, - :error, - "deploy #{status}: #{inspect(result.error)}", - %FalseProtocol.Semantic{ - event: "deploy.apply.#{status}", - what_happened: "#{result.service} deployment #{status}", - impact: "service in #{result.namespace} is not updated" - } + 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 - Nopea.Occurrence.attach_log_ref(occurrence, emitter) + 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