Skip to content

Commit 02f05b7

Browse files
feat: add structured Services UI for proxy config generation
Add a Services feature that lets users create/manage proxy services through forms instead of writing raw KDL. Services are converted to KDL configuration and can generate bundles for compilation. - Service schema with route path, upstream/static response, retry, cache, rate limit, health check, and header policies - ProjectConfig schema for global proxy settings (log level, metrics) - KDL generator that produces valid config from service definitions - Bundle integration to create bundles from services - Full CRUD LiveViews (index, new, show, edit) with DaisyUI styling - Sidebar navigation, dual routing (legacy + org-scoped) - 53 new tests covering context, KDL generation, and LiveViews
1 parent d82be77 commit 02f05b7

17 files changed

Lines changed: 2763 additions & 3 deletions

File tree

lib/sentinel_cp/services.ex

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
defmodule SentinelCp.Services do
2+
@moduledoc """
3+
The Services context manages proxy service definitions.
4+
5+
Services are structured representations of proxy routes that can be
6+
used to generate KDL configuration for bundles.
7+
"""
8+
9+
import Ecto.Query, warn: false
10+
alias SentinelCp.Repo
11+
alias SentinelCp.Services.{Service, ProjectConfig}
12+
13+
## Services
14+
15+
@doc """
16+
Lists services for a project, ordered by position.
17+
"""
18+
def list_services(project_id, opts \\ []) do
19+
query =
20+
from(s in Service,
21+
where: s.project_id == ^project_id,
22+
order_by: [asc: s.position, asc: s.inserted_at]
23+
)
24+
25+
query =
26+
Enum.reduce(opts, query, fn
27+
{:enabled, enabled}, q -> where(q, [s], s.enabled == ^enabled)
28+
_, q -> q
29+
end)
30+
31+
Repo.all(query)
32+
end
33+
34+
@doc """
35+
Gets a single service by ID.
36+
"""
37+
def get_service(id), do: Repo.get(Service, id)
38+
39+
@doc """
40+
Gets a single service by ID, raises if not found.
41+
"""
42+
def get_service!(id), do: Repo.get!(Service, id)
43+
44+
@doc """
45+
Creates a service.
46+
"""
47+
def create_service(attrs) do
48+
%Service{}
49+
|> Service.create_changeset(attrs)
50+
|> Repo.insert()
51+
end
52+
53+
@doc """
54+
Updates a service.
55+
"""
56+
def update_service(%Service{} = service, attrs) do
57+
service
58+
|> Service.update_changeset(attrs)
59+
|> Repo.update()
60+
end
61+
62+
@doc """
63+
Deletes a service.
64+
"""
65+
def delete_service(%Service{} = service) do
66+
Repo.delete(service)
67+
end
68+
69+
@doc """
70+
Batch updates service positions.
71+
72+
Accepts a list of `{service_id, position}` tuples.
73+
"""
74+
def reorder_services(project_id, id_position_pairs) do
75+
Repo.transaction(fn ->
76+
for {id, position} <- id_position_pairs do
77+
from(s in Service,
78+
where: s.id == ^id and s.project_id == ^project_id
79+
)
80+
|> Repo.update_all(set: [position: position])
81+
end
82+
83+
:ok
84+
end)
85+
end
86+
87+
@doc """
88+
Returns an `%Ecto.Changeset{}` for tracking service changes.
89+
"""
90+
def change_service(%Service{} = service, attrs \\ %{}) do
91+
Service.update_changeset(service, attrs)
92+
end
93+
94+
## Project Config
95+
96+
@doc """
97+
Gets or creates the project config for a project.
98+
"""
99+
def get_or_create_project_config(project_id) do
100+
case Repo.get_by(ProjectConfig, project_id: project_id) do
101+
nil ->
102+
%ProjectConfig{}
103+
|> ProjectConfig.changeset(%{project_id: project_id})
104+
|> Repo.insert()
105+
106+
config ->
107+
{:ok, config}
108+
end
109+
end
110+
111+
@doc """
112+
Updates the project config.
113+
"""
114+
def update_project_config(%ProjectConfig{} = config, attrs) do
115+
config
116+
|> ProjectConfig.changeset(attrs)
117+
|> Repo.update()
118+
end
119+
end
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
defmodule SentinelCp.Services.BundleIntegration do
2+
@moduledoc """
3+
Integrates structured services with the bundle compilation pipeline.
4+
5+
Generates KDL from services and creates bundles for compilation.
6+
"""
7+
8+
alias SentinelCp.Bundles
9+
alias SentinelCp.Services.KdlGenerator
10+
11+
@doc """
12+
Creates a bundle from the project's service definitions.
13+
14+
Generates KDL configuration from enabled services, then creates a bundle
15+
and enqueues compilation.
16+
17+
Returns `{:ok, bundle}` or `{:error, reason}`.
18+
"""
19+
def create_bundle_from_services(project_id, version, opts \\ []) do
20+
case KdlGenerator.generate(project_id) do
21+
{:ok, kdl} ->
22+
attrs = %{
23+
project_id: project_id,
24+
version: version,
25+
config_source: kdl
26+
}
27+
28+
attrs =
29+
if created_by_id = Keyword.get(opts, :created_by_id) do
30+
Map.put(attrs, :created_by_id, created_by_id)
31+
else
32+
attrs
33+
end
34+
35+
Bundles.create_bundle(attrs)
36+
37+
{:error, :no_services} ->
38+
{:error, :no_services}
39+
end
40+
end
41+
42+
@doc """
43+
Generates a KDL preview without creating a bundle.
44+
45+
Returns `{:ok, kdl_string}` or `{:error, :no_services}`.
46+
"""
47+
def preview_kdl(project_id) do
48+
KdlGenerator.generate(project_id)
49+
end
50+
end
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
defmodule SentinelCp.Services.KdlGenerator do
2+
@moduledoc """
3+
Generates KDL configuration from structured service definitions.
4+
5+
Pure function module — reads services and project config, produces KDL text.
6+
"""
7+
8+
alias SentinelCp.Services
9+
alias SentinelCp.Services.{Service, ProjectConfig}
10+
11+
@doc """
12+
Generates KDL configuration for a project from its services and config.
13+
14+
Returns `{:ok, kdl_string}` or `{:error, :no_services}`.
15+
"""
16+
def generate(project_id) do
17+
services = Services.list_services(project_id, enabled: true)
18+
19+
if services == [] do
20+
{:error, :no_services}
21+
else
22+
{:ok, config} = Services.get_or_create_project_config(project_id)
23+
kdl = build_kdl(services, config)
24+
{:ok, kdl}
25+
end
26+
end
27+
28+
@doc """
29+
Generates KDL from provided services and config (no DB access).
30+
Useful for testing.
31+
"""
32+
def build_kdl(services, %ProjectConfig{} = config) do
33+
parts = [
34+
"// Generated by Sentinel Control Plane",
35+
"// Do not edit manually — managed via Services UI",
36+
"",
37+
build_settings(config),
38+
"",
39+
build_routes(services),
40+
build_rate_limits(services)
41+
]
42+
43+
parts
44+
|> List.flatten()
45+
|> Enum.join("\n")
46+
|> String.trim_trailing()
47+
|> Kernel.<>("\n")
48+
end
49+
50+
defp build_settings(%ProjectConfig{} = config) do
51+
lines = [
52+
"settings {",
53+
" log_level #{inspect(config.log_level)}",
54+
" metrics_port #{config.metrics_port}"
55+
]
56+
57+
custom_lines =
58+
(config.custom_settings || %{})
59+
|> Enum.sort_by(fn {k, _} -> k end)
60+
|> Enum.map(fn {key, value} -> " #{key} #{format_value(value)}" end)
61+
62+
lines ++ custom_lines ++ ["}"]
63+
end
64+
65+
defp build_routes(services) do
66+
route_blocks =
67+
services
68+
|> Enum.map(&build_route/1)
69+
|> Enum.intersperse([""])
70+
71+
["routes {"] ++ List.flatten(route_blocks) ++ ["}"]
72+
end
73+
74+
defp build_route(%Service{} = service) do
75+
lines = [" route #{inspect(service.route_path)} {"]
76+
77+
lines =
78+
if service.upstream_url do
79+
lines ++ [" upstream #{inspect(service.upstream_url)}"]
80+
else
81+
lines ++
82+
[" respond #{service.respond_status} #{inspect(service.respond_body || "")}"]
83+
end
84+
85+
lines =
86+
if service.timeout_seconds do
87+
lines ++ [" timeout #{service.timeout_seconds}s"]
88+
else
89+
lines
90+
end
91+
92+
lines = lines ++ build_retry_block(service.retry)
93+
lines = lines ++ build_cache_block(service.cache)
94+
lines = lines ++ build_health_check_block(service.health_check)
95+
lines = lines ++ build_headers_block(service.headers)
96+
97+
lines ++ [" }"]
98+
end
99+
100+
defp build_retry_block(retry) when retry == %{} or retry == nil, do: []
101+
102+
defp build_retry_block(retry) do
103+
inner =
104+
retry
105+
|> Enum.sort_by(fn {k, _} -> k end)
106+
|> Enum.map(fn {key, value} -> " #{key} #{format_value(value)}" end)
107+
108+
if inner == [] do
109+
[]
110+
else
111+
[" retry {"] ++ inner ++ [" }"]
112+
end
113+
end
114+
115+
defp build_cache_block(cache) when cache == %{} or cache == nil, do: []
116+
117+
defp build_cache_block(cache) do
118+
inner =
119+
cache
120+
|> Enum.sort_by(fn {k, _} -> k end)
121+
|> Enum.map(fn {key, value} -> " #{key} #{format_value(value)}" end)
122+
123+
if inner == [] do
124+
[]
125+
else
126+
[" cache {"] ++ inner ++ [" }"]
127+
end
128+
end
129+
130+
defp build_health_check_block(hc) when hc == %{} or hc == nil, do: []
131+
132+
defp build_health_check_block(hc) do
133+
inner =
134+
hc
135+
|> Enum.sort_by(fn {k, _} -> k end)
136+
|> Enum.map(fn {key, value} -> " #{key} #{format_value(value)}" end)
137+
138+
if inner == [] do
139+
[]
140+
else
141+
[" health_check {"] ++ inner ++ [" }"]
142+
end
143+
end
144+
145+
defp build_headers_block(headers) when headers == %{} or headers == nil, do: []
146+
147+
defp build_headers_block(headers) do
148+
inner =
149+
headers
150+
|> Enum.sort_by(fn {k, _} -> k end)
151+
|> Enum.map(fn {key, value} -> " #{key} #{format_value(value)}" end)
152+
153+
if inner == [] do
154+
[]
155+
else
156+
[" headers {"] ++ inner ++ [" }"]
157+
end
158+
end
159+
160+
defp build_rate_limits(services) do
161+
rate_limited =
162+
services
163+
|> Enum.filter(fn s -> s.rate_limit && s.rate_limit != %{} end)
164+
165+
if rate_limited == [] do
166+
[]
167+
else
168+
blocks =
169+
rate_limited
170+
|> Enum.map(&build_rate_limit_entry/1)
171+
|> Enum.intersperse([""])
172+
173+
["", "rate_limits {"] ++ List.flatten(blocks) ++ ["}"]
174+
end
175+
end
176+
177+
defp build_rate_limit_entry(%Service{} = service) do
178+
inner =
179+
service.rate_limit
180+
|> Enum.sort_by(fn {k, _} -> k end)
181+
|> Enum.map(fn {key, value} -> " #{key} #{format_value(value)}" end)
182+
183+
[" limit #{inspect(service.slug)} {"] ++ inner ++ [" }"]
184+
end
185+
186+
defp format_value(value) when is_binary(value), do: inspect(value)
187+
defp format_value(value) when is_integer(value), do: Integer.to_string(value)
188+
defp format_value(value) when is_float(value), do: Float.to_string(value)
189+
defp format_value(true), do: "true"
190+
defp format_value(false), do: "false"
191+
defp format_value(value), do: inspect(value)
192+
end
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
defmodule SentinelCp.Services.ProjectConfig do
2+
@moduledoc """
3+
ProjectConfig schema storing global proxy settings for a project.
4+
5+
Maps to the `settings {}` block in generated KDL configuration.
6+
"""
7+
use Ecto.Schema
8+
import Ecto.Changeset
9+
10+
@primary_key {:id, :binary_id, autogenerate: true}
11+
@foreign_key_type :binary_id
12+
13+
@log_levels ~w(trace debug info warn error)
14+
15+
schema "project_configs" do
16+
field :log_level, :string, default: "info"
17+
field :metrics_port, :integer, default: 9090
18+
field :custom_settings, :map, default: %{}
19+
20+
belongs_to :project, SentinelCp.Projects.Project
21+
22+
timestamps(type: :utc_datetime)
23+
end
24+
25+
def changeset(config, attrs) do
26+
config
27+
|> cast(attrs, [:log_level, :metrics_port, :custom_settings, :project_id])
28+
|> validate_required([:project_id])
29+
|> validate_inclusion(:log_level, @log_levels)
30+
|> validate_number(:metrics_port, greater_than: 0, less_than: 65536)
31+
|> unique_constraint(:project_id)
32+
|> foreign_key_constraint(:project_id)
33+
end
34+
end

0 commit comments

Comments
 (0)