Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 98 additions & 52 deletions lib/nopea/deploy.ex
Original file line number Diff line number Diff line change
Expand Up @@ -145,15 +145,6 @@ defmodule Nopea.Deploy do
end
end

defp execute_strategy(other, spec) do
Logger.warning("Unrecognized strategy, falling back to direct",
service: spec.service,
strategy: inspect(other)
)

Nopea.Strategy.Direct.execute(spec)
end

defp k8s_module do
Application.get_env(:nopea, :k8s_module, Nopea.K8s)
end
Expand Down Expand Up @@ -233,30 +224,31 @@ defmodule Nopea.Deploy do

# Persist to .nopea/ directory
workdir = File.cwd!()
Nopea.Occurrence.persist(occurrence, workdir)

case Nopea.Occurrence.persist(occurrence, workdir) do
:ok ->
:ok

{:error, reason} ->
Logger.error("Failed to persist occurrence",
service: result.service,
deploy_id: result.deploy_id,
error: inspect(reason)
)
end
rescue
error ->
Logger.error("Failed to generate occurrence",
Logger.error("Failed to generate occurrence: #{Exception.message(error)}",
service: result.service,
deploy_id: result.deploy_id,
error: inspect(error),
stacktrace: __STACKTRACE__ |> Exception.format_stacktrace()
error: Exception.format(:error, error, __STACKTRACE__)
)
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}"
}
)

log_deploy_start(emitter, result)
emit_status_log(emitter, result)
Nopea.Occurrence.attach_log_ref(occurrence, emitter)

Expand All @@ -270,42 +262,96 @@ defmodule Nopea.Deploy do
end
end

defp log_deploy_start(emitter, result) do
case 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}"
}
) do
{:ok, _entry} ->
:ok

{:error, reason} ->
Logger.warning("Failed to emit deploy start log", reason: inspect(reason))
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}
}
)
case 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}
}
) do
{:ok, _entry} ->
:ok

{:error, reason} ->
Logger.warning("Failed to emit deploy complete log", reason: inspect(reason))
end
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"
}
)
case 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"
}
) do
{:ok, _entry} ->
:ok

{:error, reason} ->
Logger.warning("Failed to emit deploy failed log", reason: inspect(reason))
end
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"
}
)
case 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"
}
) do
{:ok, _entry} ->
:ok

{:error, reason} ->
Logger.warning("Failed to emit deploy rollback log", reason: inspect(reason))
end
end

defp emit_status_log(emitter, result) do
case FalseProtocol.LogEmitter.emit(
emitter,
:warning,
"deploy finished with status: #{inspect(result.status)}",
%FalseProtocol.Semantic{
event: "deploy.apply.unknown",
what_happened: "#{result.service} deployment ended with status #{result.status}"
}
) do
{:ok, _entry} ->
:ok

{:error, reason} ->
Logger.warning("Failed to emit deploy status log", reason: inspect(reason))
end
end

defp emit_start(spec, deploy_id, strategy) do
Expand Down
32 changes: 29 additions & 3 deletions lib/nopea/deploy/result.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ defmodule Nopea.Deploy.Result do
verification status, and any errors.
"""

@type strategy :: :direct | :canary | :blue_green

@type t :: %__MODULE__{
deploy_id: String.t(),
service: String.t(),
namespace: String.t(),
status: :completed | :failed | :rolledback,
strategy: atom(),
strategy: strategy(),
manifest_count: non_neg_integer(),
duration_ms: non_neg_integer(),
verified: boolean(),
Expand All @@ -20,6 +22,7 @@ defmodule Nopea.Deploy.Result do
timestamp: DateTime.t()
}

@enforce_keys [:deploy_id, :service, :namespace, :status, :strategy]
defstruct [
:deploy_id,
:service,
Expand All @@ -34,7 +37,14 @@ defmodule Nopea.Deploy.Result do
timestamp: nil
]

@spec success(String.t(), Nopea.Deploy.Spec.t(), atom(), [map()], non_neg_integer(), boolean()) ::
@spec success(
String.t(),
Nopea.Deploy.Spec.t(),
strategy(),
[map()],
non_neg_integer(),
boolean()
) ::
t()
def success(deploy_id, spec, strategy, applied, duration_ms, verified) do
%__MODULE__{
Expand All @@ -51,7 +61,7 @@ defmodule Nopea.Deploy.Result do
}
end

@spec failure(String.t(), Nopea.Deploy.Spec.t(), atom(), term(), non_neg_integer()) :: t()
@spec failure(String.t(), Nopea.Deploy.Spec.t(), strategy(), term(), non_neg_integer()) :: t()
def failure(deploy_id, spec, strategy, error, duration_ms) do
%__MODULE__{
deploy_id: deploy_id,
Expand All @@ -65,4 +75,20 @@ defmodule Nopea.Deploy.Result do
timestamp: DateTime.utc_now()
}
end

@spec rolledback(String.t(), Nopea.Deploy.Spec.t(), strategy(), term(), non_neg_integer()) ::
t()
def rolledback(deploy_id, spec, strategy, error, duration_ms) do
%__MODULE__{
deploy_id: deploy_id,
service: spec.service,
namespace: spec.namespace,
status: :rolledback,
strategy: strategy,
manifest_count: length(spec.manifests),
duration_ms: duration_ms,
error: error,
timestamp: DateTime.utc_now()
}
end
end
2 changes: 1 addition & 1 deletion lib/nopea/deploy/spec.ex
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ defmodule Nopea.Deploy.Spec do
service: String.t(),
namespace: String.t(),
manifests: [map()],
strategy: atom() | nil,
strategy: Nopea.Deploy.Result.strategy() | nil,
manifest_path: String.t() | nil,
timeout_ms: pos_integer()
}
Expand Down
1 change: 1 addition & 0 deletions lib/nopea/events.ex
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ defmodule Nopea.Events do
subject: subject()
}

@enforce_keys [:id, :type, :source, :specversion, :timestamp, :subject]
defstruct [:id, :type, :source, :specversion, :timestamp, :subject]

@event_type_map %{
Expand Down
2 changes: 1 addition & 1 deletion lib/nopea/events/emitter.ex
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ defmodule Nopea.Events.Emitter do

@impl true
def handle_cast({:emit, _event}, %{enabled: false} = state) do
# Silently ignore when disabled
Logger.debug("CDEvent ignored (emitter disabled)")
{:noreply, state}
end

Expand Down
4 changes: 2 additions & 2 deletions lib/nopea/graph/node.ex
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ defmodule Nopea.Graph.Node do
@type t :: %__MODULE__{
id: String.t(),
name: String.t(),
kind: atom(),
kind: NodeKind.t(),
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Node.t/0 now narrows kind to NodeKind.t(), but new/3 is still spec’d as taking atom() and is only guarded by is_atom(kind) (with a runtime NodeKind.valid?/1 check). To keep the public types consistent for Dialyzer/docs, update @spec new/3 (and ideally the guard) to accept NodeKind.t() rather than atom().

Copilot uses AI. Check for mistakes.
relevance: float(),
observations: non_neg_integer(),
first_seen: String.t(),
Expand All @@ -23,7 +23,7 @@ defmodule Nopea.Graph.Node do

@node_death_threshold 0.01

@spec new(atom(), String.t(), String.t()) :: t()
@spec new(NodeKind.t(), String.t(), String.t()) :: t()
def new(kind, name, ulid)
when is_atom(kind) and is_binary(name) and is_binary(ulid) do
true = NodeKind.valid?(kind)
Expand Down
2 changes: 2 additions & 0 deletions lib/nopea/graph/node_kind.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ defmodule Nopea.Graph.NodeKind do
Value object — immutable, equality by value.
"""

@type t :: :error | :concept

@kinds [:error, :concept]

@spec valid?(term()) :: boolean()
Expand Down
31 changes: 17 additions & 14 deletions lib/nopea/occurrence.ex
Original file line number Diff line number Diff line change
Expand Up @@ -28,27 +28,30 @@ defmodule Nopea.Occurrence do
def build(result, memory_context \\ nil) do
{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)
case Occurrence.new("nopea", "deploy.run.#{type_suffix}",
severity: severity,
outcome: outcome
) do
{:ok, occ} ->
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)

{:error, reason} ->
raise "FalseProtocol.Occurrence.new failed: #{inspect(reason)}"
end
end

@doc """
Starts a `FalseProtocol.LogEmitter` for the given occurrence.

Mode is `:both` — deploy logs are human-readable AND AI-structured.
"""
@spec start_log_emitter(Occurrence.t()) :: {:ok, pid()}
@spec start_log_emitter(Occurrence.t()) :: {:ok, pid()} | {:error, term()}
def start_log_emitter(%Occurrence{} = occ) do
FalseProtocol.LogEmitter.start_link(occ.id, "nopea", :both)
end
Expand Down
24 changes: 24 additions & 0 deletions test/nopea/deploy/result_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,28 @@ defmodule Nopea.Deploy.ResultTest do
assert result.verified == false
end
end

describe "rolledback/5" do
test "builds a rolledback result" do
spec = sample_spec()
result = Result.rolledback("01GHI", spec, :blue_green, {:apply_failed, "oom"}, 8000)

assert result.deploy_id == "01GHI"
assert result.status == :rolledback
assert result.strategy == :blue_green
assert result.error == {:apply_failed, "oom"}
assert result.duration_ms == 8000
assert result.verified == false
assert result.manifest_count == 2
assert %DateTime{} = result.timestamp
end
end

describe "enforce_keys" do
test "requires mandatory fields" do
assert_raise ArgumentError, ~r/keys must also be given/, fn ->
struct!(Result, %{})
end
end
end
end
Loading
Loading