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
332 changes: 169 additions & 163 deletions CLAUDE.md

Large diffs are not rendered by default.

20 changes: 20 additions & 0 deletions config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,24 @@ config :nopea,
enable_memory: true,
enable_cache: true

config :logger, :default_formatter,
format: "$time $metadata[$level] $message\n",
metadata: [
:service,
:deploy_id,
:namespace,
:strategy,
:error,
:reason,
:duration_ms,
:resource,
:stacktrace,
:cooldown_ms,
:queued,
:node_count,
:relationship_count,
:auto_selected,
:verified
]

import_config "#{config_env()}.exs"
23 changes: 11 additions & 12 deletions lib/nopea/cli.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ defmodule Nopea.CLI do
- status Show deployment status
- context Show memory context for a service
- history Show deployment history
- rollback Roll back a deployment
- memory Show memory graph stats
- serve Start daemon mode (HTTP API)
"""

require Logger

def main(args) do
{opts, args, _} =
OptionParser.parse(args,
Expand Down Expand Up @@ -44,7 +45,7 @@ defmodule Nopea.CLI do

case Nopea.Deploy.Spec.from_path(path, service, namespace, strategy: strategy) do
{:ok, spec} ->
result = Nopea.Deploy.run(spec)
result = Nopea.Deploy.deploy(spec)
output(result, opts)

{:error, reason} ->
Expand Down Expand Up @@ -98,21 +99,19 @@ defmodule Nopea.CLI do
end

defp serve(_opts) do
IO.puts("Starting Nopea daemon...")
Logger.info("Starting Nopea daemon...")
Application.put_env(:nopea, :enable_router, true)

case Supervisor.start_child(Nopea.AppSupervisor, Nopea.API.Router) do
{:ok, _pid} ->
case Application.ensure_all_started(:nopea) do
{:ok, _apps} ->
Comment on lines +102 to +106
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

serve/1 sets :enable_api, but the application supervision tree gates the HTTP server on :enable_router (see Nopea.Application.add_router_child/1). As written, nopea serve may never start Nopea.API.Router unless enable_router is already true in config. Use the same config key here (or update the application to read :enable_api consistently).

Copilot uses AI. Check for mistakes.
port = Application.get_env(:nopea, :api_port, 4000)
IO.puts("Nopea API listening on port #{port}")

{:error, {:already_started, _pid}} ->
IO.puts("Nopea API already running")
Logger.info("Nopea API listening on port #{port}")
Process.sleep(:infinity)

{:error, reason} ->
IO.puts(:stderr, "Failed to start API: #{inspect(reason)}")
Logger.error("Failed to start Nopea: #{inspect(reason)}")
System.halt(1)
end

Process.sleep(:infinity)
end

defp output(data, opts) do
Expand Down
46 changes: 35 additions & 11 deletions lib/nopea/deploy.ex
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,15 @@ defmodule Nopea.Deploy do
strategy
end

defp select_strategy(%Spec{strategy: nil}, %{known: true, failure_patterns: patterns})
when is_list(patterns) do
threshold = Application.get_env(:nopea, :canary_threshold, 0.15)

if Enum.any?(patterns, fn p -> p.confidence > threshold end),
do: :canary,
else: :direct
end

defp select_strategy(%Spec{strategy: nil}, _context), do: :direct

defp select_strategy(%Spec{strategy: other}, _context) do
Expand Down Expand Up @@ -151,21 +160,12 @@ defmodule Nopea.Deploy do

defp verify_deploy(spec, applied) when is_list(applied) do
Enum.all?(applied, fn manifest ->
case Nopea.Drift.verify_manifest(spec.service, manifest) do
case Nopea.Drift.verify_manifest(spec.service, manifest, k8s_module: k8s_module()) do
:no_drift -> true
:new_resource -> true
_ -> false
end
end)
rescue
error ->
Logger.warning("Post-deploy verification failed",
service: spec.service,
error: inspect(error),
stacktrace: __STACKTRACE__ |> Exception.format_stacktrace()
)

false
end

defp verify_deploy(_spec, _applied), do: false
Expand All @@ -178,7 +178,7 @@ defmodule Nopea.Deploy do
status: result.status,
error: result.error,
duration_ms: result.duration_ms,
concurrent_deploys: []
concurrent_deploys: get_concurrent_services(result.service)
})
end

Expand Down Expand Up @@ -408,6 +408,30 @@ defmodule Nopea.Deploy do

defp emitter_running?, do: Process.whereis(Nopea.Events.Emitter) != nil

defp get_concurrent_services(current_service) do
if Process.whereis(Nopea.Registry) do
Registry.select(Nopea.Registry, [
{{:"$1", :"$2", :_}, [], [{{:"$1", :"$2"}}]}
])
|> Enum.flat_map(fn
{{:service, name}, pid} when name != current_service ->
try do
case GenServer.call(pid, :status, 1_000) do
%{status: :deploying} -> [name]
_ -> []
end
catch
:exit, _ -> []
end

_ ->
[]
end)
else
Comment on lines +411 to +430
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

get_concurrent_services/1 currently returns all services registered in Nopea.Registry (i.e., all ServiceAgents), not the services that are actually deploying concurrently. This will over-report concurrent_deploys and can create misleading :deployed_together edges. Consider filtering to agents with status == :deploying (e.g., via Nopea.ServiceAgent.status/1/a registry value) or tracking deploying services separately.

Copilot uses AI. Check for mistakes.
[]
end
end

defp duration_ms(start_time) do
System.convert_time_unit(System.monotonic_time() - start_time, :native, :millisecond)
end
Expand Down
3 changes: 2 additions & 1 deletion lib/nopea/graph/relation_type.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ defmodule Nopea.Graph.RelationType do

@types [
:breaks,
:deployed_to
:deployed_to,
:deployed_together
]

@spec valid?(term()) :: boolean()
Expand Down
21 changes: 20 additions & 1 deletion lib/nopea/memory/ingestor.ex
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,28 @@ defmodule Nopea.Memory.Ingestor do

defp maybe_record_failure(graph, _result, _ulid), do: graph

defp maybe_record_dependencies(graph, %{concurrent_deploys: [_ | _] = deploys}, ulid) do
defp maybe_record_dependencies(
graph,
%{service: service, concurrent_deploys: [_ | _] = deploys},
ulid
) do
service_id = Nopea.Graph.Identity.compute_id(:concept, service)

Enum.reduce(deploys, graph, fn other_service, g ->
{g, _node} = Graph.upsert_node(g, :concept, other_service, 0.5, ulid)
other_id = Nopea.Graph.Identity.compute_id(:concept, other_service)

{g, _rel} =
Graph.upsert_relationship(
g,
service_id,
:deployed_together,
other_id,
0.5,
ulid,
"concurrent deploy at #{DateTime.utc_now() |> DateTime.to_iso8601()}"
)

g
end)
end
Expand Down
2 changes: 1 addition & 1 deletion lib/nopea/sykli/target.ex
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ defmodule Nopea.SYKLI.Target do
strategy: Map.get(task, :strategy)
}

result = Nopea.Deploy.run(spec)
result = Nopea.Deploy.deploy(spec)

case result.status do
:completed -> {:ok, result}
Expand Down
6 changes: 6 additions & 0 deletions test/nopea/deploy_integration_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ defmodule Nopea.DeployIntegrationTest do
setup do
start_supervised!(Nopea.Cache)
start_supervised!({Nopea.Memory, []})

# Stub get_resource — no real cluster in tests
Mox.stub(Nopea.K8sMock, :get_resource, fn _api, _kind, _name, _ns ->
{:error, :not_found}
end)

:ok
end

Expand Down
137 changes: 134 additions & 3 deletions test/nopea/deploy_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ defmodule Nopea.DeployTest do
setup do
# Stub K8s mock to fall through to real implementation (works for empty manifests)
Mox.stub_with(Nopea.K8sMock, Nopea.K8s)

# Stub get_resource to return not_found — no real cluster in tests
Mox.stub(Nopea.K8sMock, :get_resource, fn _api, _kind, _name, _ns ->
{:error, :not_found}
end)

# Start Memory for context tracking
start_supervised!({Nopea.Memory, []})
# Start Cache for state recording
Expand Down Expand Up @@ -48,8 +54,8 @@ defmodule Nopea.DeployTest do

Deploy.run(spec)

# Memory.record_deploy is a cast, give it time
Process.sleep(50)
# Flush Memory mailbox — node_count is a call, so all prior casts complete first
_ = Nopea.Memory.node_count()

ctx = Nopea.Memory.get_deploy_context("memory-test-svc", "default")
assert ctx.known == true
Expand Down Expand Up @@ -85,7 +91,7 @@ defmodule Nopea.DeployTest do
end

describe "strategy selection" do
test "always uses direct when no explicit strategy" do
test "unknown service (no memory) defaults to direct" do
spec = %Spec{
service: "clean-svc",
namespace: "default",
Expand All @@ -96,6 +102,131 @@ defmodule Nopea.DeployTest do
result = Deploy.run(spec)
assert result.strategy == :direct
end

test "known service with high failure confidence auto-selects canary" do
# First, create failure history so Memory knows about this service
Nopea.Memory.record_deploy(%{
service: "flaky-svc",
namespace: "default",
status: :failed,
error: {:timeout, "connection refused"},
concurrent_deploys: []
})

# Reinforce the failure pattern to push confidence above threshold
for _ <- 1..4 do
Nopea.Memory.record_deploy(%{
service: "flaky-svc",
namespace: "default",
status: :failed,
error: {:timeout, "connection refused"},
concurrent_deploys: []
})
end

# Flush Memory mailbox — node_count is a call, so all prior casts complete first
_ = Nopea.Memory.node_count()

# Verify memory has failure patterns above threshold
ctx = Nopea.Memory.get_deploy_context("flaky-svc", "default")
assert ctx.known == true
assert Enum.any?(ctx.failure_patterns, fn p -> p.confidence > 0.15 end)

# Now deploy with nil strategy — should auto-select canary
deployment = Nopea.Test.Factory.sample_deployment_manifest("flaky-svc", "default")

Nopea.K8sMock
|> expect(:apply_manifest, fn manifest, "default" ->
assert manifest["kind"] == "Rollout"
{:ok, manifest}
end)

spec = %Spec{
service: "flaky-svc",
namespace: "default",
manifests: [deployment],
strategy: nil
}

result = Deploy.run(spec)
assert result.strategy == :canary
end

test "known service with low failure confidence stays direct" do
# Single success — known but no failure patterns
Nopea.Memory.record_deploy(%{
service: "stable-svc",
namespace: "default",
status: :completed,
error: nil,
concurrent_deploys: []
})

_ = Nopea.Memory.node_count()

ctx = Nopea.Memory.get_deploy_context("stable-svc", "default")
assert ctx.known == true
assert ctx.failure_patterns == []

spec = %Spec{
service: "stable-svc",
namespace: "default",
manifests: [],
strategy: nil
}

result = Deploy.run(spec)
assert result.strategy == :direct
end

test "explicit strategy always overrides memory" do
# Create failure history
for _ <- 1..5 do
Nopea.Memory.record_deploy(%{
service: "override-svc",
namespace: "default",
status: :failed,
error: "crash",
concurrent_deploys: []
})
end

_ = Nopea.Memory.node_count()

spec = %Spec{
service: "override-svc",
namespace: "default",
manifests: [],
strategy: :direct
}

result = Deploy.run(spec)
assert result.strategy == :direct
end
end

describe "verify_deploy crash propagation" do
test "malformed manifest raises instead of returning false" do
# A manifest missing "apiVersion" and "kind" will cause Drift.verify_manifest
# to raise KeyError — this should propagate, not be silently caught
malformed = %{"metadata" => %{"name" => "bad"}}

Nopea.K8sMock
|> expect(:apply_manifests, fn _manifests, _ns ->
{:ok, [malformed]}
end)

spec = %Spec{
service: "crash-test-svc",
namespace: "default",
manifests: [malformed],
strategy: :direct
}

assert_raise KeyError, fn ->
Deploy.run(spec)
end
end
end

describe "Kulta strategies" do
Expand Down
Loading
Loading