Skip to content

Commit f5660df

Browse files
feat: add plugin system for WASM/Lua extension support
Plugin management with versioning, S3 storage, service plugin chains, config schemas with per-service overrides, marketplace support, KDL generation, and bundle integration. Includes LiveView UI, REST API, and full test coverage.
1 parent 4677a38 commit f5660df

17 files changed

Lines changed: 2728 additions & 0 deletions

File tree

lib/sentinel_cp/plugins.ex

Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
defmodule SentinelCp.Plugins do
2+
@moduledoc """
3+
Context for plugin management: CRUD, versioning, service attachment,
4+
marketplace listing, and bundle integration.
5+
"""
6+
7+
import Ecto.Query
8+
alias SentinelCp.Repo
9+
alias SentinelCp.Plugins.{Plugin, PluginVersion, ServicePlugin}
10+
alias SentinelCp.Bundles.{Compiler, Storage}
11+
12+
## Plugin CRUD
13+
14+
@doc """
15+
Lists plugins for a project plus public marketplace plugins, ordered by name.
16+
"""
17+
def list_plugins(project_id) do
18+
from(p in Plugin,
19+
where: p.project_id == ^project_id or (is_nil(p.project_id) and p.public == true),
20+
order_by: [asc: p.name]
21+
)
22+
|> Repo.all()
23+
end
24+
25+
@doc """
26+
Gets a single plugin by ID, preloading latest version.
27+
"""
28+
def get_plugin(id) do
29+
Plugin
30+
|> Repo.get(id)
31+
|> maybe_preload_latest_version()
32+
end
33+
34+
@doc """
35+
Gets a single plugin by ID, raises if not found.
36+
"""
37+
def get_plugin!(id) do
38+
Plugin
39+
|> Repo.get!(id)
40+
|> maybe_preload_latest_version()
41+
end
42+
43+
defp maybe_preload_latest_version(nil), do: nil
44+
45+
defp maybe_preload_latest_version(%Plugin{} = plugin) do
46+
latest = get_latest_version(plugin.id)
47+
Map.put(plugin, :plugin_versions, if(latest, do: [latest], else: []))
48+
end
49+
50+
@doc """
51+
Creates a plugin.
52+
"""
53+
def create_plugin(attrs) do
54+
%Plugin{}
55+
|> Plugin.create_changeset(attrs)
56+
|> Repo.insert()
57+
end
58+
59+
@doc """
60+
Updates a plugin.
61+
"""
62+
def update_plugin(%Plugin{} = plugin, attrs) do
63+
plugin
64+
|> Plugin.update_changeset(attrs)
65+
|> Repo.update()
66+
end
67+
68+
@doc """
69+
Deletes a plugin. Cascades to versions and service_plugins via DB.
70+
Cleans up S3 storage for all versions.
71+
"""
72+
def delete_plugin(%Plugin{} = plugin) do
73+
versions = list_plugin_versions(plugin.id)
74+
75+
for v <- versions do
76+
Storage.delete(v.storage_key)
77+
end
78+
79+
Repo.delete(plugin)
80+
end
81+
82+
## Plugin Versions
83+
84+
@doc """
85+
Lists versions for a plugin, ordered by inserted_at desc.
86+
"""
87+
def list_plugin_versions(plugin_id) do
88+
from(v in PluginVersion,
89+
where: v.plugin_id == ^plugin_id,
90+
order_by: [desc: v.inserted_at, desc: v.id]
91+
)
92+
|> Repo.all()
93+
end
94+
95+
@doc """
96+
Gets a plugin version by ID.
97+
"""
98+
def get_plugin_version(id), do: Repo.get(PluginVersion, id)
99+
100+
@doc """
101+
Gets the latest version for a plugin (most recent by inserted_at).
102+
"""
103+
def get_latest_version(plugin_id) do
104+
from(v in PluginVersion,
105+
where: v.plugin_id == ^plugin_id,
106+
order_by: [desc: v.inserted_at, desc: v.id],
107+
limit: 1
108+
)
109+
|> Repo.one()
110+
end
111+
112+
@doc """
113+
Creates a plugin version by uploading binary content to S3.
114+
115+
Accepts base64-encoded binary, computes SHA256 checksum, uploads to S3,
116+
and creates the DB record.
117+
"""
118+
def create_plugin_version(%Plugin{} = plugin, binary_content, attrs)
119+
when is_binary(binary_content) do
120+
checksum = Compiler.checksum(binary_content)
121+
file_size = byte_size(binary_content)
122+
version = attrs[:version] || attrs["version"]
123+
ext = plugin_extension(plugin.plugin_type)
124+
storage_key = "plugins/#{plugin.id}/#{version}.#{ext}"
125+
126+
case Storage.upload(storage_key, binary_content) do
127+
:ok ->
128+
version_attrs =
129+
Map.merge(
130+
%{
131+
plugin_id: plugin.id,
132+
storage_key: storage_key,
133+
checksum: checksum,
134+
file_size: file_size
135+
},
136+
normalize_attrs(attrs)
137+
)
138+
139+
%PluginVersion{}
140+
|> PluginVersion.changeset(version_attrs)
141+
|> Repo.insert()
142+
143+
{:error, _} = error ->
144+
error
145+
end
146+
end
147+
148+
@doc """
149+
Deletes a plugin version from S3 and DB.
150+
"""
151+
def delete_plugin_version(%PluginVersion{} = version) do
152+
Storage.delete(version.storage_key)
153+
Repo.delete(version)
154+
end
155+
156+
## Service Plugin Chain
157+
158+
@doc """
159+
Lists service plugins for a service, ordered by position, preloading plugin and version.
160+
"""
161+
def list_service_plugins(service_id) do
162+
from(sp in ServicePlugin,
163+
where: sp.service_id == ^service_id,
164+
order_by: [asc: sp.position],
165+
preload: [:plugin, :plugin_version]
166+
)
167+
|> Repo.all()
168+
end
169+
170+
@doc """
171+
Attaches a plugin to a service.
172+
"""
173+
def attach_plugin(attrs) do
174+
%ServicePlugin{}
175+
|> ServicePlugin.changeset(attrs)
176+
|> Repo.insert()
177+
end
178+
179+
@doc """
180+
Detaches a plugin from a service.
181+
"""
182+
def detach_plugin(%ServicePlugin{} = sp) do
183+
Repo.delete(sp)
184+
end
185+
186+
@doc """
187+
Gets a service plugin by ID with preloads.
188+
"""
189+
def get_service_plugin(id) do
190+
ServicePlugin
191+
|> Repo.get(id)
192+
|> Repo.preload([:plugin, :plugin_version])
193+
end
194+
195+
@doc """
196+
Gets a service plugin by service_id and plugin_id.
197+
"""
198+
def get_service_plugin_by(service_id, plugin_id) do
199+
from(sp in ServicePlugin,
200+
where: sp.service_id == ^service_id and sp.plugin_id == ^plugin_id
201+
)
202+
|> Repo.one()
203+
end
204+
205+
@doc """
206+
Updates a service plugin (position, enabled, config_override, plugin_version_id).
207+
"""
208+
def update_service_plugin(%ServicePlugin{} = sp, attrs) do
209+
sp
210+
|> ServicePlugin.changeset(attrs)
211+
|> Repo.update()
212+
end
213+
214+
@doc """
215+
Batch updates service plugin positions.
216+
217+
Accepts a list of `{service_plugin_id, position}` tuples.
218+
"""
219+
def reorder_service_plugins(service_id, id_position_pairs) do
220+
Repo.transaction(fn ->
221+
for {id, position} <- id_position_pairs do
222+
from(sp in ServicePlugin,
223+
where: sp.id == ^id and sp.service_id == ^service_id
224+
)
225+
|> Repo.update_all(set: [position: position])
226+
end
227+
228+
:ok
229+
end)
230+
end
231+
232+
## Marketplace
233+
234+
@doc """
235+
Lists public marketplace plugins, optionally filtered by type.
236+
"""
237+
def list_marketplace_plugins(opts \\ []) do
238+
query =
239+
from(p in Plugin,
240+
where: p.public == true,
241+
order_by: [asc: p.name]
242+
)
243+
244+
query =
245+
case opts[:plugin_type] do
246+
nil -> query
247+
type -> from(p in query, where: p.plugin_type == ^type)
248+
end
249+
250+
Repo.all(query)
251+
end
252+
253+
## Bundle Integration
254+
255+
@doc """
256+
Collects plugin files for bundle compilation.
257+
258+
Returns `[{path, binary_content}]` for all enabled plugins attached
259+
to services in the given project. Deduplicates by plugin+version.
260+
"""
261+
def collect_plugin_files(project_id) do
262+
# Get all services for this project
263+
service_ids =
264+
from(s in SentinelCp.Services.Service,
265+
where: s.project_id == ^project_id and s.enabled == true,
266+
select: s.id
267+
)
268+
|> Repo.all()
269+
270+
if service_ids == [] do
271+
[]
272+
else
273+
# Get all enabled service_plugins for these services
274+
service_plugins =
275+
from(sp in ServicePlugin,
276+
where: sp.service_id in ^service_ids and sp.enabled == true,
277+
preload: [:plugin, :plugin_version]
278+
)
279+
|> Repo.all()
280+
281+
# Deduplicate and collect files
282+
service_plugins
283+
|> Enum.map(fn sp ->
284+
version = sp.plugin_version || get_latest_version(sp.plugin_id)
285+
{sp.plugin, version}
286+
end)
287+
|> Enum.reject(fn {_plugin, version} -> is_nil(version) end)
288+
|> Enum.uniq_by(fn {plugin, version} -> {plugin.id, version.id} end)
289+
|> Enum.flat_map(fn {plugin, version} ->
290+
ext = plugin_extension(plugin.plugin_type)
291+
path = "plugins/#{plugin.slug}/#{version.version}.#{ext}"
292+
293+
case Storage.download(version.storage_key) do
294+
{:ok, binary} -> [{path, binary}]
295+
_ -> []
296+
end
297+
end)
298+
end
299+
end
300+
301+
## Private helpers
302+
303+
defp plugin_extension("wasm"), do: "wasm"
304+
defp plugin_extension("lua"), do: "lua"
305+
defp plugin_extension(_), do: "bin"
306+
307+
defp normalize_attrs(attrs) when is_map(attrs) do
308+
attrs
309+
|> Enum.into(%{}, fn
310+
{k, v} when is_atom(k) -> {k, v}
311+
{k, v} when is_binary(k) -> {String.to_existing_atom(k), v}
312+
end)
313+
end
314+
end

0 commit comments

Comments
 (0)