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
10 changes: 9 additions & 1 deletion config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,15 @@ config :logger, :default_formatter,
:node_count,
:relationship_count,
:auto_selected,
:verified
:verified,
:count,
:keys,
:retry,
:max_retries,
:type,
:endpoint,
:path,
:manifest_count
]

import_config "#{config_env()}.exs"
74 changes: 54 additions & 20 deletions lib/nopea/api/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -39,31 +39,65 @@ defmodule Nopea.API.Router do
handle_deploy(conn)
end

get "/api/status/:service" do
case Nopea.Surface.status(service) do
{:ok, state} -> json(conn, 200, state)
{:error, :not_found} -> json(conn, 404, %{error: "not_found"})
{:error, :unavailable} -> json(conn, 503, %{error: "unavailable"})
end
end

get "/api/context/:service" do
namespace = conn.params["namespace"] || "default"

context =
if Process.whereis(Nopea.Memory) do
Nopea.Memory.get_deploy_context(service, namespace)
else
%{known: false}
end

context = Nopea.Surface.context(service, namespace)
json(conn, 200, context)
end

get "/api/history/:service" do
history =
if Nopea.Cache.available?() do
case Nopea.Cache.get_service_state(service) do
{:ok, state} -> %{service: service, state: state}
{:error, :not_found} -> %{service: service, deployments: []}
end
else
%{service: service, deployments: []}
end

json(conn, 200, history)
case Nopea.Surface.history(service) do
{:ok, data} -> json(conn, 200, data)
{:error, :not_found} -> json(conn, 200, %{service: service, deployments: []})
{:error, :unavailable} -> json(conn, 200, %{service: service, deployments: []})
end
end

get "/api/explain/:service" do
namespace = conn.params["namespace"] || "default"
explanation = Nopea.Surface.explain(service, namespace)
json(conn, 200, %{service: service, explanation: explanation})
end

get "/api/services" do
services = Nopea.Surface.services()
json(conn, 200, %{services: services, count: length(services)})
end

post "/api/promote/:deploy_id" do
case Nopea.Surface.promote(deploy_id) do
{:ok, rollout} ->
json(conn, 200, Map.from_struct(rollout))

{:error, :not_found} ->
json(conn, 404, %{error: "not_found"})

{:error, reason} ->
Logger.error("Promote failed", deploy_id: deploy_id, error: inspect(reason))
json(conn, 500, %{error: "Internal server error"})
end
end

post "/api/rollback/:deploy_id" do
case Nopea.Surface.rollback(deploy_id) do
{:ok, rollout} ->
json(conn, 200, Map.from_struct(rollout))

{:error, :not_found} ->
json(conn, 404, %{error: "not_found"})

{:error, reason} ->
Logger.error("Rollback failed", deploy_id: deploy_id, error: inspect(reason))
json(conn, 500, %{error: "Internal server error"})
end
Comment on lines +75 to +100
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

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

These endpoints return 500 with inspect(reason) in the JSON body. This can leak internal details (atoms, exception messages, potentially upstream responses) to API clients.

Prefer logging reason server-side and returning a stable, client-safe error payload (similar to handle_deploy/1’s Internal server error).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 3d0ea1e — error responses now return a generic "Internal server error" message. The actual reason is logged server-side via Logger.error with the deploy_id for correlation.

Comment on lines +75 to +100
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

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

The POST /api/promote/:deploy_id and POST /api/rollback/:deploy_id routes perform privileged rollout operations without any authentication or authorization checks. If the Nopea HTTP API is reachable from an untrusted network, an attacker could arbitrarily promote or roll back progressive rollouts and thus modify the state of the Kubernetes cluster. Add strong authentication/authorization in front of these endpoints (or ensure they are only reachable behind an authenticated proxy) so that only authorized operators can trigger rollout changes.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed — auth is needed before production use. This PR focuses on the core progressive delivery mechanics. Auth middleware (API key or mTLS) is a follow-up concern that should apply uniformly to all mutating endpoints, not just promote/rollback. Will track separately.

end

match _ do
Expand All @@ -80,7 +114,7 @@ defmodule Nopea.API.Router do
strategy: Nopea.Helpers.parse_strategy(params["strategy"])
}

result = Nopea.Deploy.run(spec)
result = Nopea.Deploy.deploy(spec)
json(conn, 200, Nopea.Helpers.serialize_deploy_result(result))

_ ->
Expand Down
7 changes: 7 additions & 0 deletions lib/nopea/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ defmodule Nopea.Application do
|> add_cluster_child(cluster_enabled)
|> add_registry_child(cluster_enabled)
|> add_service_agent_child()
|> add_progressive_child()
|> add_router_child()

opts = [strategy: :one_for_one, name: Nopea.AppSupervisor]
Expand Down Expand Up @@ -87,6 +88,12 @@ defmodule Nopea.Application do
else: children
end

defp add_progressive_child(children) do
if Application.get_env(:nopea, :enable_deploy_supervisor, true),
do: children ++ [Nopea.Progressive.Supervisor],
else: children
end

defp add_router_child(children) do
if Application.get_env(:nopea, :enable_router, false),
do: children ++ [Nopea.API.Router],
Expand Down
122 changes: 97 additions & 25 deletions lib/nopea/cli.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,18 @@ defmodule Nopea.CLI do
Escript entry point for Nopea CLI.

Commands:
- deploy Deploy manifests to a cluster
- status Show deployment status
- context Show memory context for a service
- history Show deployment history
- memory Show memory graph stats
- serve Start daemon mode (HTTP API)
- deploy Deploy manifests to a cluster
- status Show deployment status
- context Show memory context for a service
- history Show deployment history
- explain Explain strategy selection for a service
- promote Promote an active progressive rollout
- rollback Rollback an active progressive rollout
- health Show system health
- services List known services
- memory Show memory graph stats
- serve Start daemon mode (HTTP API)
- mcp Start MCP server (JSON-RPC over stdin/stdout)
"""

require Logger
Expand All @@ -26,17 +32,23 @@ defmodule Nopea.CLI do
aliases: [f: :file, s: :service, n: :namespace, j: :json]
)

case args do
["deploy" | _] -> deploy(opts)
["status" | rest] -> status(rest, opts)
["context" | rest] -> context(rest, opts)
["history" | rest] -> history(rest, opts)
["memory" | _] -> memory(opts)
["serve" | _] -> serve(opts)
_ -> usage()
end
dispatch(args, opts)
end

defp dispatch(["deploy" | _], opts), do: deploy(opts)
defp dispatch(["status" | rest], opts), do: status(rest, opts)
defp dispatch(["context" | rest], opts), do: context(rest, opts)
defp dispatch(["history" | rest], opts), do: history(rest, opts)
defp dispatch(["explain" | rest], opts), do: explain(rest, opts)
defp dispatch(["health" | _], opts), do: health(opts)
defp dispatch(["services" | _], opts), do: services(opts)
defp dispatch(["memory" | _], opts), do: memory(opts)
defp dispatch(["promote" | rest], opts), do: promote(rest, opts)
defp dispatch(["rollback" | rest], opts), do: do_rollback(rest, opts)
defp dispatch(["serve" | _], opts), do: serve(opts)
defp dispatch(["mcp" | _], _opts), do: mcp()
defp dispatch(_, _opts), do: usage()

defp deploy(opts) do
path = Keyword.get(opts, :file) || "."
service = Keyword.get(opts, :service) || Path.basename(path)
Expand All @@ -60,9 +72,10 @@ defmodule Nopea.CLI do
error("Usage: nopea status <service>")
end

case Nopea.Cache.get_service_state(service) do
case Nopea.Surface.status(service) do
{:ok, state} -> output(state, opts)
{:error, :not_found} -> error("Service '#{service}' not found")
{:error, :unavailable} -> error("No status backend available")
end
end

Expand All @@ -74,7 +87,7 @@ defmodule Nopea.CLI do
error("Usage: nopea context <service>")
end

ctx = Nopea.Memory.get_deploy_context(service, namespace)
ctx = Nopea.Surface.context(service, namespace)
output(ctx, opts)
end

Expand All @@ -85,17 +98,66 @@ defmodule Nopea.CLI do
error("Usage: nopea history <service>")
end

deploys = Nopea.Cache.list_deployments(service)
output(deploys, opts)
case Nopea.Surface.history(service) do
{:ok, data} -> output(data, opts)
{:error, :not_found} -> error("No history found for '#{service}'")
{:error, :unavailable} -> error("Cache not available")
end
end

defp memory(opts) do
stats = %{
nodes: Nopea.Memory.node_count(),
relationships: Nopea.Memory.relationship_count()
}
defp explain(args, opts) do
service = List.first(args) || Keyword.get(opts, :service)
namespace = Keyword.get(opts, :namespace, "default")

unless service do
error("Usage: nopea explain <service>")
end

result = Nopea.Surface.explain(service, namespace)
output(result, opts)
end

defp health(opts) do
result = Nopea.Surface.health()
output(result, opts)
end

defp services(opts) do
result = Nopea.Surface.services()
output(result, opts)
end

output(stats, opts)
defp promote(args, opts) do
deploy_id = List.first(args)

unless deploy_id do
error("Usage: nopea promote <deploy_id>")
end

case Nopea.Surface.promote(deploy_id) do
{:ok, rollout} -> output(rollout, opts)
{:error, :not_found} -> error("No active rollout for deploy '#{deploy_id}'")
{:error, reason} -> error("Promote failed: #{inspect(reason)}")
end
end

defp do_rollback(args, opts) do
deploy_id = List.first(args)

unless deploy_id do
error("Usage: nopea rollback <deploy_id>")
end

case Nopea.Surface.rollback(deploy_id) do
{:ok, rollout} -> output(rollout, opts)
{:error, :not_found} -> error("No active rollout for deploy '#{deploy_id}'")
{:error, reason} -> error("Rollback failed: #{inspect(reason)}")
end
end

defp memory(opts) do
result = Nopea.Surface.health()
output(result.memory, opts)
end

defp serve(_opts) do
Expand All @@ -114,6 +176,10 @@ defmodule Nopea.CLI do
end
end

defp mcp do
Nopea.MCP.serve()
end

defp output(data, opts) do
if Keyword.get(opts, :json, false) do
IO.puts(Jason.encode!(data, pretty: true))
Expand All @@ -136,8 +202,14 @@ defmodule Nopea.CLI do
nopea status <service>
nopea context <service> [--json]
nopea history <service> [--json]
nopea explain <service> [--json]
nopea promote <deploy_id> [--json]
nopea rollback <deploy_id> [--json]
nopea health [--json]
nopea services [--json]
nopea memory [--json]
nopea serve
nopea mcp
""")
end
end
Loading
Loading