Skip to content

Commit 42af76d

Browse files
authored
FEATURE: Flow Plugin (#75)
1 parent 888b2eb commit 42af76d

22 files changed

Lines changed: 3215 additions & 52 deletions

internal/api/handlers/entities.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,9 @@ func (h *Handlers) CreateEntity(w http.ResponseWriter, r *http.Request) {
174174
func (h *Handlers) UpdateEntity(w http.ResponseWriter, r *http.Request) {
175175
kind := chi.URLParam(r, "kind")
176176
name := chi.URLParam(r, "name")
177+
if kind == "Flow" && !h.ensureFlowWriteAccess(w, r) {
178+
return
179+
}
177180

178181
var e entity.Entity
179182
if err := json.NewDecoder(r.Body).Decode(&e); err != nil {
@@ -251,6 +254,9 @@ func (h *Handlers) UpdateEntity(w http.ResponseWriter, r *http.Request) {
251254
func (h *Handlers) DeleteEntity(w http.ResponseWriter, r *http.Request) {
252255
kind := chi.URLParam(r, "kind")
253256
name := chi.URLParam(r, "name")
257+
if kind == "Flow" && !h.ensureFlowWriteAccess(w, r) {
258+
return
259+
}
254260
namespace := r.URL.Query().Get("namespace")
255261
if namespace == "" {
256262
namespace = entity.DefaultNamespace

internal/api/handlers/flow.go

Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
package handlers
2+
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"net/http"
7+
"strings"
8+
9+
"github.com/go-chi/chi/v5"
10+
"github.com/go2engle/gantry/internal/api/middleware"
11+
"github.com/go2engle/gantry/internal/auth"
12+
"github.com/go2engle/gantry/internal/db"
13+
"github.com/go2engle/gantry/internal/entity"
14+
"github.com/go2engle/gantry/internal/events"
15+
"github.com/go2engle/gantry/internal/plugins"
16+
)
17+
18+
type flowSettingsResponse struct {
19+
ShowInSidebar bool `json:"showInSidebar"`
20+
EditorRole string `json:"editorRole"`
21+
CanEdit bool `json:"canEdit"`
22+
}
23+
24+
const maxFlowEntityRequestBytes int64 = 1 << 20
25+
26+
func flowEditorRole(config map[string]any) string {
27+
role, _ := config["editorRole"].(string)
28+
role = strings.TrimSpace(role)
29+
if role == "" || !auth.IsValidRole(role) {
30+
return "developer"
31+
}
32+
return role
33+
}
34+
35+
func flowShowInSidebar(config map[string]any) bool {
36+
show, ok := config["showInSidebar"].(bool)
37+
if !ok {
38+
return true
39+
}
40+
return show
41+
}
42+
43+
func (h *Handlers) getFlowPlugin(r *http.Request) (*plugins.Plugin, error) {
44+
plugin, err := h.DB.GetPlugin(r.Context(), "flow")
45+
if err != nil {
46+
return nil, err
47+
}
48+
if plugin == nil {
49+
return nil, plugins.ErrPluginNotInstalled
50+
}
51+
return plugin, nil
52+
}
53+
54+
func (h *Handlers) getFlowSettings(r *http.Request) (*plugins.Plugin, flowSettingsResponse, error) {
55+
plugin, err := h.getFlowPlugin(r)
56+
if err != nil {
57+
return nil, flowSettingsResponse{}, err
58+
}
59+
60+
editorRole := flowEditorRole(plugin.Config)
61+
effectiveRole := middleware.GetEffectiveRole(r.Context())
62+
canEdit := auth.RoleLevel(effectiveRole) >= auth.RoleLevel(editorRole)
63+
64+
return plugin, flowSettingsResponse{
65+
ShowInSidebar: flowShowInSidebar(plugin.Config),
66+
EditorRole: editorRole,
67+
CanEdit: canEdit,
68+
}, nil
69+
}
70+
71+
func (h *Handlers) ensureFlowWriteAccess(w http.ResponseWriter, r *http.Request) bool {
72+
plugin, settings, err := h.getFlowSettings(r)
73+
if err != nil {
74+
if errors.Is(err, plugins.ErrPluginNotInstalled) {
75+
writeError(w, http.StatusNotFound, "flow plugin not installed")
76+
return false
77+
}
78+
writeError(w, http.StatusInternalServerError, "failed to load flow plugin")
79+
return false
80+
}
81+
if !plugin.Enabled {
82+
writeError(w, http.StatusBadRequest, "flow plugin is not enabled")
83+
return false
84+
}
85+
if !settings.CanEdit {
86+
writeError(w, http.StatusForbidden, "insufficient permissions to edit flows")
87+
return false
88+
}
89+
return true
90+
}
91+
92+
// GetFlowSettings returns the non-sensitive Flow plugin settings needed by the UI.
93+
func (h *Handlers) GetFlowSettings(w http.ResponseWriter, r *http.Request) {
94+
plugin, settings, err := h.getFlowSettings(r)
95+
if err != nil {
96+
if errors.Is(err, plugins.ErrPluginNotInstalled) {
97+
writeError(w, http.StatusNotFound, "flow plugin not installed")
98+
return
99+
}
100+
writeError(w, http.StatusInternalServerError, "failed to load flow settings")
101+
return
102+
}
103+
if !plugin.Enabled {
104+
writeError(w, http.StatusBadRequest, "flow plugin is not enabled")
105+
return
106+
}
107+
108+
writeJSON(w, http.StatusOK, settings)
109+
}
110+
111+
// CreateFlowEntity handles POST /plugins/flow/entities.
112+
func (h *Handlers) CreateFlowEntity(w http.ResponseWriter, r *http.Request) {
113+
if !h.ensureFlowWriteAccess(w, r) {
114+
return
115+
}
116+
117+
var e entity.Entity
118+
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxFlowEntityRequestBytes)).Decode(&e); err != nil {
119+
var maxErr *http.MaxBytesError
120+
if errors.As(err, &maxErr) {
121+
writeError(w, http.StatusRequestEntityTooLarge, "request body too large")
122+
return
123+
}
124+
writeError(w, http.StatusBadRequest, "invalid request body")
125+
return
126+
}
127+
if e.Kind != "" && e.Kind != "Flow" {
128+
writeError(w, http.StatusBadRequest, "flow endpoint only accepts Flow entities")
129+
return
130+
}
131+
e.Kind = "Flow"
132+
e.SetDefaults()
133+
134+
claims := middleware.GetClaims(r.Context())
135+
if claims != nil {
136+
e.Metadata.CreatedBy = claims.Username
137+
}
138+
139+
if err := h.Validator.Validate(&e); err != nil {
140+
writeError(w, http.StatusBadRequest, err.Error())
141+
return
142+
}
143+
144+
if err := h.DB.CreateEntity(r.Context(), &e); err != nil {
145+
if errors.Is(err, entity.ErrEntityAlreadyExists) {
146+
writeError(w, http.StatusConflict, "entity already exists")
147+
return
148+
}
149+
writeError(w, http.StatusInternalServerError, "failed to create flow")
150+
return
151+
}
152+
153+
h.Events.Publish(events.Event{
154+
Type: events.EntityCreated,
155+
Data: map[string]any{
156+
"kind": e.Kind,
157+
"name": e.Metadata.Name,
158+
"namespace": e.Metadata.Namespace,
159+
},
160+
})
161+
162+
userName := ""
163+
userID := ""
164+
if claims != nil {
165+
userName = claims.Username
166+
userID = claims.UserID
167+
}
168+
h.DB.CreateAuditEntry(r.Context(), &db.AuditEntry{
169+
UserID: userID,
170+
UserName: userName,
171+
Action: "entity.created",
172+
ResourceType: e.Kind,
173+
ResourceName: e.Metadata.Name,
174+
AfterState: marshalEntityState(&e),
175+
Source: "api",
176+
IPAddress: clientIP(r),
177+
})
178+
179+
writeJSON(w, http.StatusCreated, e)
180+
}
181+
182+
// UpdateFlowEntity handles PUT /plugins/flow/entities/{name}.
183+
func (h *Handlers) UpdateFlowEntity(w http.ResponseWriter, r *http.Request) {
184+
if !h.ensureFlowWriteAccess(w, r) {
185+
return
186+
}
187+
188+
name := chi.URLParam(r, "name")
189+
var e entity.Entity
190+
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxFlowEntityRequestBytes)).Decode(&e); err != nil {
191+
var maxErr *http.MaxBytesError
192+
if errors.As(err, &maxErr) {
193+
writeError(w, http.StatusRequestEntityTooLarge, "request body too large")
194+
return
195+
}
196+
writeError(w, http.StatusBadRequest, "invalid request body")
197+
return
198+
}
199+
200+
if e.Kind != "" && e.Kind != "Flow" {
201+
writeError(w, http.StatusBadRequest, "flow endpoint only accepts Flow entities")
202+
return
203+
}
204+
e.Kind = "Flow"
205+
e.Metadata.Name = name
206+
e.SetDefaults()
207+
208+
if err := h.Validator.Validate(&e); err != nil {
209+
writeError(w, http.StatusBadRequest, err.Error())
210+
return
211+
}
212+
213+
ns := e.Metadata.Namespace
214+
if ns == "" {
215+
ns = entity.DefaultNamespace
216+
}
217+
218+
var beforeState string
219+
if prev, err := h.DB.GetEntity(r.Context(), e.Kind, ns, e.Metadata.Name); err == nil {
220+
beforeState = marshalEntityState(prev)
221+
}
222+
223+
if err := h.DB.UpdateEntity(r.Context(), &e); err != nil {
224+
if errors.Is(err, entity.ErrEntityNotFound) {
225+
writeError(w, http.StatusNotFound, "entity not found")
226+
return
227+
}
228+
writeError(w, http.StatusInternalServerError, "failed to update flow")
229+
return
230+
}
231+
232+
h.Events.Publish(events.Event{
233+
Type: events.EntityUpdated,
234+
Data: map[string]any{
235+
"kind": e.Kind,
236+
"name": e.Metadata.Name,
237+
"namespace": e.Metadata.Namespace,
238+
},
239+
})
240+
241+
claims := middleware.GetClaims(r.Context())
242+
userName := ""
243+
userID := ""
244+
if claims != nil {
245+
userName = claims.Username
246+
userID = claims.UserID
247+
}
248+
h.DB.CreateAuditEntry(r.Context(), &db.AuditEntry{
249+
UserID: userID,
250+
UserName: userName,
251+
Action: "entity.updated",
252+
ResourceType: e.Kind,
253+
ResourceName: e.Metadata.Name,
254+
BeforeState: beforeState,
255+
AfterState: marshalEntityState(&e),
256+
Source: "api",
257+
IPAddress: clientIP(r),
258+
})
259+
260+
writeJSON(w, http.StatusOK, e)
261+
}
262+
263+
// DeleteFlowEntity handles DELETE /plugins/flow/entities/{name}.
264+
func (h *Handlers) DeleteFlowEntity(w http.ResponseWriter, r *http.Request) {
265+
if !h.ensureFlowWriteAccess(w, r) {
266+
return
267+
}
268+
269+
name := chi.URLParam(r, "name")
270+
namespace := r.URL.Query().Get("namespace")
271+
if namespace == "" {
272+
namespace = entity.DefaultNamespace
273+
}
274+
275+
var beforeState string
276+
if prev, err := h.DB.GetEntity(r.Context(), "Flow", namespace, name); err == nil {
277+
beforeState = marshalEntityState(prev)
278+
}
279+
280+
if err := h.DB.DeleteEntity(r.Context(), "Flow", namespace, name); err != nil {
281+
if errors.Is(err, entity.ErrEntityNotFound) {
282+
writeError(w, http.StatusNotFound, "entity not found")
283+
return
284+
}
285+
writeError(w, http.StatusInternalServerError, "failed to delete flow")
286+
return
287+
}
288+
289+
h.Events.Publish(events.Event{
290+
Type: events.EntityDeleted,
291+
Data: map[string]any{
292+
"kind": "Flow",
293+
"name": name,
294+
"namespace": namespace,
295+
},
296+
})
297+
298+
claims := middleware.GetClaims(r.Context())
299+
userName := ""
300+
userID := ""
301+
if claims != nil {
302+
userName = claims.Username
303+
userID = claims.UserID
304+
}
305+
h.DB.CreateAuditEntry(r.Context(), &db.AuditEntry{
306+
UserID: userID,
307+
UserName: userName,
308+
Action: "entity.deleted",
309+
ResourceType: "Flow",
310+
ResourceName: name,
311+
BeforeState: beforeState,
312+
Source: "api",
313+
IPAddress: clientIP(r),
314+
})
315+
316+
w.WriteHeader(http.StatusNoContent)
317+
}

internal/api/handlers/plugins.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"strings"
99

1010
"github.com/go-chi/chi/v5"
11+
"github.com/go2engle/gantry/internal/auth"
1112
"github.com/go2engle/gantry/internal/gitops"
1213
"github.com/go2engle/gantry/internal/plugins"
1314
argocd "github.com/go2engle/gantry/internal/plugins/argocd"
@@ -190,6 +191,18 @@ func (h *Handlers) UpdatePluginConfig(w http.ResponseWriter, r *http.Request) {
190191
}
191192

192193
merged, _ := preserveSecretValues(existing.Config, config).(map[string]any)
194+
if name == "flow" {
195+
role, _ := merged["editorRole"].(string)
196+
role = strings.TrimSpace(role)
197+
if role == "" {
198+
merged["editorRole"] = "developer"
199+
} else if !auth.IsValidRole(role) {
200+
writeError(w, http.StatusBadRequest, "invalid flow editor role")
201+
return
202+
} else {
203+
merged["editorRole"] = role
204+
}
205+
}
193206
if err := h.DB.UpdatePluginConfig(r.Context(), name, merged); err != nil {
194207
writeError(w, http.StatusInternalServerError, err.Error())
195208
return

internal/api/handlers/topology.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,10 @@ func (h *Handlers) GetTopologyData(w http.ResponseWriter, r *http.Request) {
135135

136136
// Second pass: collect all non-Environment entities and build edges.
137137
for _, e := range all {
138+
if e.Kind == "Flow" {
139+
continue
140+
}
141+
138142
nodeID := e.Kind + "/" + e.Metadata.Name
139143
spec := e.Spec
140144
if spec == nil {

internal/api/server.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,11 @@ func NewServer(cfg *config.Config, database *db.DB, authSvc *auth.Service, event
220220
// Topology Explorer plugin endpoints.
221221
protected.Get("/plugins/topology-explorer/data", h.GetTopologyData)
222222
protected.Get("/plugins/topology-explorer/status", h.GetTopologyStatus)
223+
// Flow plugin endpoints.
224+
protected.Get("/plugins/flow/settings", h.GetFlowSettings)
225+
protected.Post("/plugins/flow/entities", h.CreateFlowEntity)
226+
protected.Put("/plugins/flow/entities/{name}", h.UpdateFlowEntity)
227+
protected.Delete("/plugins/flow/entities/{name}", h.DeleteFlowEntity)
223228
// Nexus Repository Manager plugin endpoints.
224229
protected.Get("/plugins/nexus-repository-manager/repositories", h.GetNexusRepositories)
225230
protected.Get("/plugins/nexus-repository-manager/components", h.GetNexusComponents)

internal/entity/kinds.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ var BuiltinKinds = []KindDefinition{
2222
{Name: "Team", Plural: "teams", Description: "Engineering team or group"},
2323
{Name: "Environment", Plural: "environments", Description: "Deployment target or cloud account"},
2424
{Name: "Documentation", Plural: "documentation", Description: "Link to external documentation or runbook"},
25+
{Name: "Flow", Plural: "flows", Description: "Interactive system flow diagram backed by catalog entities"},
2526
{Name: "Action", Plural: "actions", Description: "Self-service workflow definition"},
2627
}
2728

0 commit comments

Comments
 (0)