Skip to content

Commit 7f5f1be

Browse files
feat: add middleware / plugin system (Phase 11.3)
Add composable, reusable middleware entities that can be attached to services in an ordered chain. Middleware coexists with existing inline fields — inline blocks render first, middleware chain blocks append after in position order. - Middleware schema (12 types: rate_limit, cache, cors, compression, headers, access_control, security, path_rewrite, request_transform, response_transform, auth, custom) with project-scoped slugs - ServiceMiddleware join table with position ordering, per-service config_override, and enable toggle - KDL generator produces middleware chain blocks with merged config (auth type generates structured auth blocks, custom type uses configurable kdl_block_name) - Middleware library LiveView pages (index, new, show, edit) - Service show page middleware chain section (attach/detach) - REST API for middleware CRUD and service chain management - 32 new tests (713 total, 0 failures)
1 parent b023205 commit 7f5f1be

16 files changed

Lines changed: 1931 additions & 7 deletions

File tree

lib/sentinel_cp/services.ex

Lines changed: 136 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ defmodule SentinelCp.Services do
88

99
import Ecto.Query, warn: false
1010
alias SentinelCp.Repo
11-
alias SentinelCp.Services.{Service, ServiceTemplate, ProjectConfig, UpstreamGroup, UpstreamTarget, Certificate, AuthPolicy, OpenApiSpec, DiscoverySource, DiscoverySync}
11+
alias SentinelCp.Services.{Service, ServiceTemplate, ProjectConfig, UpstreamGroup, UpstreamTarget, Certificate, AuthPolicy, OpenApiSpec, DiscoverySource, DiscoverySync, Middleware, ServiceMiddleware}
1212

1313
## Services
1414

@@ -613,6 +613,141 @@ defmodule SentinelCp.Services do
613613
end
614614
end
615615

616+
## Middlewares
617+
618+
@doc """
619+
Lists middlewares for a project, ordered by name.
620+
"""
621+
def list_middlewares(project_id) do
622+
from(m in Middleware,
623+
where: m.project_id == ^project_id,
624+
order_by: [asc: m.name]
625+
)
626+
|> Repo.all()
627+
end
628+
629+
@doc """
630+
Lists middlewares for a project filtered by type.
631+
"""
632+
def list_middlewares_by_type(project_id, type) do
633+
from(m in Middleware,
634+
where: m.project_id == ^project_id and m.middleware_type == ^type,
635+
order_by: [asc: m.name]
636+
)
637+
|> Repo.all()
638+
end
639+
640+
@doc """
641+
Gets a single middleware by ID.
642+
"""
643+
def get_middleware(id), do: Repo.get(Middleware, id)
644+
645+
@doc """
646+
Gets a single middleware by ID, raises if not found.
647+
"""
648+
def get_middleware!(id), do: Repo.get!(Middleware, id)
649+
650+
@doc """
651+
Creates a middleware.
652+
"""
653+
def create_middleware(attrs) do
654+
%Middleware{}
655+
|> Middleware.create_changeset(attrs)
656+
|> Repo.insert()
657+
end
658+
659+
@doc """
660+
Updates a middleware.
661+
"""
662+
def update_middleware(%Middleware{} = middleware, attrs) do
663+
middleware
664+
|> Middleware.update_changeset(attrs)
665+
|> Repo.update()
666+
end
667+
668+
@doc """
669+
Deletes a middleware. Cascades to service_middlewares.
670+
"""
671+
def delete_middleware(%Middleware{} = middleware) do
672+
Repo.delete(middleware)
673+
end
674+
675+
## Service Middleware Chain
676+
677+
@doc """
678+
Lists service middlewares for a service, ordered by position, preloading middleware.
679+
"""
680+
def list_service_middlewares(service_id) do
681+
from(sm in ServiceMiddleware,
682+
where: sm.service_id == ^service_id,
683+
order_by: [asc: sm.position],
684+
preload: [:middleware]
685+
)
686+
|> Repo.all()
687+
end
688+
689+
@doc """
690+
Attaches a middleware to a service.
691+
"""
692+
def attach_middleware(attrs) do
693+
%ServiceMiddleware{}
694+
|> ServiceMiddleware.changeset(attrs)
695+
|> Repo.insert()
696+
end
697+
698+
@doc """
699+
Detaches a middleware from a service.
700+
"""
701+
def detach_middleware(%ServiceMiddleware{} = sm) do
702+
Repo.delete(sm)
703+
end
704+
705+
@doc """
706+
Gets a service middleware by ID.
707+
"""
708+
def get_service_middleware(id) do
709+
ServiceMiddleware
710+
|> Repo.get(id)
711+
|> Repo.preload(:middleware)
712+
end
713+
714+
@doc """
715+
Gets a service middleware by service_id and middleware_id.
716+
"""
717+
def get_service_middleware_by(service_id, middleware_id) do
718+
from(sm in ServiceMiddleware,
719+
where: sm.service_id == ^service_id and sm.middleware_id == ^middleware_id
720+
)
721+
|> Repo.one()
722+
end
723+
724+
@doc """
725+
Updates a service middleware (position, enabled, config_override).
726+
"""
727+
def update_service_middleware(%ServiceMiddleware{} = sm, attrs) do
728+
sm
729+
|> ServiceMiddleware.changeset(attrs)
730+
|> Repo.update()
731+
end
732+
733+
@doc """
734+
Batch updates service middleware positions.
735+
736+
Accepts a list of `{service_middleware_id, position}` tuples.
737+
"""
738+
def reorder_service_middlewares(service_id, id_position_pairs) do
739+
Repo.transaction(fn ->
740+
for {id, position} <- id_position_pairs do
741+
from(sm in ServiceMiddleware,
742+
where: sm.id == ^id and sm.service_id == ^service_id
743+
)
744+
|> Repo.update_all(set: [position: position])
745+
end
746+
747+
:ok
748+
end)
749+
end
750+
616751
defp dns_resolver do
617752
Application.get_env(:sentinel_cp, :dns_resolver, SentinelCp.Services.DnsResolver.Inet)
618753
end

lib/sentinel_cp/services/kdl_generator.ex

Lines changed: 72 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,15 @@ defmodule SentinelCp.Services.KdlGenerator do
2323
upstream_groups = Services.list_upstream_groups(project_id)
2424
certificates = Services.list_certificates(project_id)
2525
auth_policies = Services.list_auth_policies(project_id)
26-
kdl = build_kdl(services, config, upstream_groups, certificates, auth_policies)
26+
27+
# Build middleware chain map: service_id -> [service_middlewares]
28+
middleware_chains =
29+
services
30+
|> Enum.into(%{}, fn s ->
31+
{s.id, Services.list_service_middlewares(s.id)}
32+
end)
33+
34+
kdl = build_kdl(services, config, upstream_groups, certificates, auth_policies, middleware_chains)
2735
{:ok, kdl}
2836
end
2937
end
@@ -32,7 +40,7 @@ defmodule SentinelCp.Services.KdlGenerator do
3240
Generates KDL from provided services, config, and upstream groups (no DB access).
3341
Useful for testing.
3442
"""
35-
def build_kdl(services, %ProjectConfig{} = config, upstream_groups \\ [], certificates \\ [], auth_policies \\ []) do
43+
def build_kdl(services, %ProjectConfig{} = config, upstream_groups \\ [], certificates \\ [], auth_policies \\ [], middleware_chains \\ %{}) do
3644
# Build lookup maps
3745
group_map =
3846
upstream_groups
@@ -63,7 +71,7 @@ defmodule SentinelCp.Services.KdlGenerator do
6371
"",
6472
build_tls_certificates(used_certs),
6573
build_upstream_groups(upstream_groups),
66-
build_routes(services, group_map, cert_map, auth_policy_map),
74+
build_routes(services, group_map, cert_map, auth_policy_map, middleware_chains),
6775
build_rate_limits(services)
6876
]
6977

@@ -146,16 +154,19 @@ defmodule SentinelCp.Services.KdlGenerator do
146154
lines ++ [" }"]
147155
end
148156

149-
defp build_routes(services, group_map, cert_map, auth_policy_map) do
157+
defp build_routes(services, group_map, cert_map, auth_policy_map, middleware_chains) do
150158
route_blocks =
151159
services
152-
|> Enum.map(&build_route(&1, group_map, cert_map, auth_policy_map))
160+
|> Enum.map(fn service ->
161+
chain = Map.get(middleware_chains, service.id, [])
162+
build_route(service, group_map, cert_map, auth_policy_map, chain)
163+
end)
153164
|> Enum.intersperse([""])
154165

155166
["routes {"] ++ List.flatten(route_blocks) ++ ["}"]
156167
end
157168

158-
defp build_route(%Service{} = service, group_map, cert_map, auth_policy_map) do
169+
defp build_route(%Service{} = service, group_map, cert_map, auth_policy_map, middleware_chain) do
159170
lines = [" route #{inspect(service.route_path)} {"]
160171

161172
lines =
@@ -198,6 +209,9 @@ defmodule SentinelCp.Services.KdlGenerator do
198209
lines = lines ++ build_response_transform_block(service.response_transform)
199210
lines = lines ++ build_traffic_split_block(service.traffic_split, group_map)
200211

212+
# Append middleware chain blocks (after inline fields)
213+
lines = lines ++ build_middleware_chain(middleware_chain)
214+
201215
lines ++ [" }"]
202216
end
203217

@@ -401,6 +415,58 @@ defmodule SentinelCp.Services.KdlGenerator do
401415
end
402416
end
403417

418+
defp build_middleware_chain([]), do: []
419+
420+
defp build_middleware_chain(service_middlewares) do
421+
service_middlewares
422+
|> Enum.filter(fn sm -> sm.enabled end)
423+
|> Enum.sort_by(fn sm -> sm.position end)
424+
|> Enum.flat_map(&build_middleware_block/1)
425+
end
426+
427+
defp build_middleware_block(service_middleware) do
428+
middleware = service_middleware.middleware
429+
430+
# Skip disabled middleware definitions
431+
unless middleware.enabled do
432+
[]
433+
else
434+
merged_config = Map.merge(middleware.config || %{}, service_middleware.config_override || %{})
435+
436+
case middleware.middleware_type do
437+
"auth" ->
438+
build_middleware_auth_block(merged_config)
439+
440+
"custom" ->
441+
block_name = Map.get(merged_config, "kdl_block_name", "custom")
442+
config = Map.drop(merged_config, ["kdl_block_name"])
443+
build_nested_map_block(config, block_name, " ")
444+
445+
type ->
446+
build_nested_map_block(merged_config, type, " ")
447+
end
448+
end
449+
end
450+
451+
defp build_middleware_auth_block(config) do
452+
auth_type = Map.get(config, "type", Map.get(config, "auth_type"))
453+
rest = Map.drop(config, ["type", "auth_type"])
454+
455+
if auth_type do
456+
lines = [" auth {"]
457+
lines = lines ++ [" type #{inspect(auth_type)}"]
458+
459+
config_lines =
460+
rest
461+
|> Enum.sort_by(fn {k, _} -> k end)
462+
|> Enum.map(fn {key, value} -> " #{key} #{format_value(value)}" end)
463+
464+
lines ++ config_lines ++ [" }"]
465+
else
466+
build_nested_map_block(config, "auth", " ")
467+
end
468+
end
469+
404470
defp build_rate_limits(services) do
405471
rate_limited =
406472
services
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
defmodule SentinelCp.Services.Middleware do
2+
@moduledoc """
3+
Middleware schema for reusable proxy middleware definitions.
4+
5+
Middleware entities are project-scoped building blocks (rate limiting, CORS,
6+
compression, etc.) that can be attached to services in an ordered chain via
7+
ServiceMiddleware join records.
8+
"""
9+
use Ecto.Schema
10+
import Ecto.Changeset
11+
12+
@primary_key {:id, :binary_id, autogenerate: true}
13+
@foreign_key_type :binary_id
14+
15+
@middleware_types ~w(rate_limit cache cors compression headers access_control security path_rewrite request_transform response_transform auth custom)
16+
17+
schema "middlewares" do
18+
field :name, :string
19+
field :slug, :string
20+
field :description, :string
21+
field :middleware_type, :string
22+
field :config, :map, default: %{}
23+
field :enabled, :boolean, default: true
24+
25+
belongs_to :project, SentinelCp.Projects.Project
26+
has_many :service_middlewares, SentinelCp.Services.ServiceMiddleware
27+
28+
timestamps(type: :utc_datetime)
29+
end
30+
31+
def middleware_types, do: @middleware_types
32+
33+
def create_changeset(middleware, attrs) do
34+
middleware
35+
|> cast(attrs, [:name, :description, :middleware_type, :config, :enabled, :project_id])
36+
|> validate_required([:name, :middleware_type, :project_id])
37+
|> validate_length(:name, min: 1, max: 100)
38+
|> validate_inclusion(:middleware_type, @middleware_types)
39+
|> generate_slug()
40+
|> validate_slug()
41+
|> unique_constraint([:project_id, :slug], error_key: :slug)
42+
|> foreign_key_constraint(:project_id)
43+
end
44+
45+
def update_changeset(middleware, attrs) do
46+
middleware
47+
|> cast(attrs, [:name, :description, :config, :enabled])
48+
|> validate_required([:name])
49+
|> validate_length(:name, min: 1, max: 100)
50+
end
51+
52+
defp generate_slug(changeset) do
53+
case get_change(changeset, :name) do
54+
nil ->
55+
changeset
56+
57+
name ->
58+
slug =
59+
name
60+
|> String.downcase()
61+
|> String.replace(~r/[^a-z0-9]+/, "-")
62+
|> String.replace(~r/^-+|-+$/, "")
63+
|> String.slice(0, 50)
64+
65+
put_change(changeset, :slug, slug)
66+
end
67+
end
68+
69+
defp validate_slug(changeset) do
70+
changeset
71+
|> validate_required([:slug])
72+
|> validate_format(:slug, ~r/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/,
73+
message: "must contain only lowercase letters, numbers, and hyphens"
74+
)
75+
|> validate_length(:slug, min: 1, max: 50)
76+
end
77+
end

lib/sentinel_cp/services/service.ex

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ defmodule SentinelCp.Services.Service do
4242
belongs_to :auth_policy, SentinelCp.Services.AuthPolicy
4343
belongs_to :openapi_spec, SentinelCp.Services.OpenApiSpec
4444

45+
has_many :service_middlewares, SentinelCp.Services.ServiceMiddleware
46+
4547
field :openapi_path, :string
4648

4749
timestamps(type: :utc_datetime)

0 commit comments

Comments
 (0)