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
6 changes: 5 additions & 1 deletion authbridge/cmd/abctl/tui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,11 @@ type model struct {
catalogTbl table.Model
detailVp viewport.Model
detailEvent *pipeline.SessionEvent
detailPlugin *apiclient.PipelinePlugin
// detailInvocation is the plugin invocation selected in the events pane
// when the detail view was opened. Used to re-scope the event to that
// plugin on resize/re-render. nil means "whole event" (no invocation).
detailInvocation *pipeline.Invocation
detailPlugin *apiclient.PipelinePlugin
filterInput textinput.Model

// visibleRows holds the invocationRow spec for each rendered row in
Expand Down
50 changes: 48 additions & 2 deletions authbridge/cmd/abctl/tui/detail_pane.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,43 @@ import (
"github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline"
)

// eventScopedToPlugin returns a shallow copy of e whose Invocations and
// per-plugin Plugins map are limited to the given plugin. Event-level context
// (protocol slot, identity) is preserved; filterForDetail still trims those by
// phase. Returns e unchanged when plugin is empty.
func eventScopedToPlugin(e *pipeline.SessionEvent, plugin string) *pipeline.SessionEvent {
if e == nil || plugin == "" {
return e
}
scoped := *e // shallow copy
if e.Invocations != nil {
scoped.Invocations = &pipeline.Invocations{
Inbound: filterInvocationsByPlugin(e.Invocations.Inbound, plugin),
Outbound: filterInvocationsByPlugin(e.Invocations.Outbound, plugin),
}
}
if e.Plugins != nil {
if raw, ok := e.Plugins[plugin]; ok {
scoped.Plugins = map[string]json.RawMessage{plugin: raw}
} else {
scoped.Plugins = nil
}
}
return &scoped
}

// filterInvocationsByPlugin returns the invocations in invs whose Plugin
// matches plugin, preserving order. Returns nil when none match.
func filterInvocationsByPlugin(invs []pipeline.Invocation, plugin string) []pipeline.Invocation {
var out []pipeline.Invocation
for _, iv := range invs {
if iv.Plugin == plugin {
out = append(out, iv)
}
}
return out
}

// showDetail loads e into the detail viewport as colorized JSON and
// remembers the focused event so yank (y) can find it.
//
Expand All @@ -21,9 +58,18 @@ import (
// When the event arrived over TLS (SessionEvent.TLS non-nil), a small
// header block is prepended to the JSON so operators can see the
// connection-level identity at a glance. Absent for plaintext events.
func (m *model) showDetail(e *pipeline.SessionEvent) {
//
// The events list is one row per plugin invocation, so showDetail takes the
// selected invocation and renders a copy of the event scoped to that plugin
// (see eventScopedToPlugin). A nil invocation renders the whole event.
func (m *model) showDetail(e *pipeline.SessionEvent, inv *pipeline.Invocation) {
m.detailEvent = e
data, err := json.Marshal(e)
m.detailInvocation = inv
ev := e
if inv != nil {
ev = eventScopedToPlugin(e, inv.Plugin)
}
data, err := json.Marshal(ev)
if err != nil {
m.detailVp.SetContent("error marshaling event: " + err.Error())
return
Expand Down
60 changes: 60 additions & 0 deletions authbridge/cmd/abctl/tui/detail_pane_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package tui

import (
"encoding/json"
"strings"
"testing"

Expand Down Expand Up @@ -65,3 +66,62 @@ func TestTLSHeader_PeerOnly(t *testing.T) {
t.Errorf("tlsHeader unexpectedly included version/cipher on peer-only state\ngot:\n%s", got)
}
}

// eventScopedToPlugin must restrict both the Invocations slices and the
// per-plugin Plugins map to the selected plugin, and must NOT mutate the
// original event (the shallow copy aliases the slices/map otherwise).
func TestEventScopedToPlugin_FiltersToSelectedPlugin(t *testing.T) {
ev := &pipeline.SessionEvent{
Invocations: &pipeline.Invocations{
Inbound: []pipeline.Invocation{
{Plugin: "jwt-validation", Action: pipeline.ActionAllow},
{Plugin: "a2a-parser", Action: pipeline.ActionObserve},
},
},
Plugins: map[string]json.RawMessage{
"jwt-validation": json.RawMessage("{}"),
"a2a-parser": json.RawMessage("{}"),
},
}

scoped := eventScopedToPlugin(ev, "jwt-validation")

if got := len(scoped.Invocations.Inbound); got != 1 {
t.Fatalf("scoped inbound invocations = %d, want 1", got)
}
if got := scoped.Invocations.Inbound[0].Plugin; got != "jwt-validation" {
t.Errorf("scoped invocation plugin = %q, want %q", got, "jwt-validation")
}
if got := len(scoped.Plugins); got != 1 {
t.Fatalf("scoped Plugins entries = %d, want 1", got)
}
if _, ok := scoped.Plugins["jwt-validation"]; !ok {
t.Errorf("scoped Plugins missing jwt-validation key: %v", scoped.Plugins)
}
if _, ok := scoped.Plugins["a2a-parser"]; ok {
t.Errorf("scoped Plugins unexpectedly retained a2a-parser")
}

// Original event must be untouched (no aliasing of slice/map).
if got := len(ev.Invocations.Inbound); got != 2 {
t.Errorf("original inbound invocations mutated: = %d, want 2", got)
}
if got := len(ev.Plugins); got != 2 {
t.Errorf("original Plugins map mutated: = %d, want 2", got)
}
}

// An empty plugin string means "no specific invocation" — the helper returns
// the event unchanged (same pointer) so old whole-event behavior is preserved.
func TestEventScopedToPlugin_EmptyPluginReturnsUnchanged(t *testing.T) {
ev := &pipeline.SessionEvent{
Invocations: &pipeline.Invocations{
Inbound: []pipeline.Invocation{
{Plugin: "jwt-validation", Action: pipeline.ActionAllow},
},
},
}
if got := eventScopedToPlugin(ev, ""); got != ev {
t.Errorf("eventScopedToPlugin(ev, \"\") = %p, want original %p", got, ev)
}
}
13 changes: 13 additions & 0 deletions authbridge/cmd/abctl/tui/events_pane.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,19 @@ func (m *model) selectedEvent() *pipeline.SessionEvent {
return m.visibleRows[cur].event
}

// selectedInvocation returns the plugin invocation for the highlighted events
// row, or nil (pseudo-row for an event with no invocations, or no rows).
func (m *model) selectedInvocation() *pipeline.Invocation {
if len(m.visibleRows) == 0 {
return nil
}
cur := m.eventsTbl.Cursor()
if cur < 0 || cur >= len(m.visibleRows) {
return nil
}
return m.visibleRows[cur].inv
}

// invocationRow is one table row — the cartesian product of SessionEvent
// × Invocation. An event with N plugin invocations produces N rows; an
// event with no invocations produces one row with an empty invocation.
Expand Down
4 changes: 2 additions & 2 deletions authbridge/cmd/abctl/tui/keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ func (m *model) handleKey(msg tea.KeyMsg) tea.Cmd {
if ev == nil {
return nil
}
m.showDetail(ev)
m.showDetail(ev, m.selectedInvocation())
m.pane = paneDetail
return nil
case panePipeline:
Expand Down Expand Up @@ -476,7 +476,7 @@ func (m *model) layout() {
// Re-wrap the detail viewport to the new width so long JSON values
// continue to fit after a terminal resize.
if m.detailEvent != nil {
m.showDetail(m.detailEvent)
m.showDetail(m.detailEvent, m.detailInvocation)
}
}

Expand Down
7 changes: 5 additions & 2 deletions authbridge/cmd/abctl/tui/pipeline_pane.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,11 @@ func (m *model) rebuildPipelineTable() {
for _, p := range m.pipeline.Inbound {
rows = append(rows, pipelineRow(p, counts[p.Name], m.pipeline.Inbound))
}
// Divider between inbound and outbound.
rows = append(rows, table.Row{"", "", "── (app) ──", "", "", "", ""})
// Divider between inbound and outbound. Cell count MUST match the 6
// columns defined in newPipelineTable (#, DIRECTION, PLUGIN, DEPS, BODY,
// EVENTS) — bubbles' table.renderRow indexes columns by cell position and
// panics on a mismatch.
rows = append(rows, table.Row{"", "", "── (app) ──", "", "", ""})
for _, p := range m.pipeline.Outbound {
rows = append(rows, pipelineRow(p, counts[p.Name], m.pipeline.Outbound))
}
Expand Down
38 changes: 38 additions & 0 deletions authbridge/cmd/abctl/tui/pipeline_pane_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package tui

import (
"testing"

"github.com/kagenti/kagenti-extensions/authbridge/cmd/abctl/apiclient"
)

// TestRebuildPipelineTable_AllRowsMatchColumnCount is a regression guard:
// every row added to the pipeline table — including the inbound/outbound
// "(app)" divider — must have exactly as many cells as the table has columns.
// The divider previously carried 7 cells against 6 columns, which made
// bubbles' table.renderRow panic ("index out of range [6] with length 6")
// the moment abctl rendered any pipeline.
func TestRebuildPipelineTable_AllRowsMatchColumnCount(t *testing.T) {
m := &model{
pipeline: &apiclient.PipelineView{
Inbound: []apiclient.PipelinePlugin{{Name: "jwt-validation", Direction: "inbound", Position: 1}},
Outbound: []apiclient.PipelinePlugin{{Name: "token-exchange", Direction: "outbound", Position: 1}},
},
pipelineTbl: newPipelineTable(),
}

// Must not panic: the buggy 7-cell divider panicked here via
// SetRows → UpdateViewport → renderRow.
m.rebuildPipelineTable()

rows := m.pipelineTbl.Rows()
if len(rows) != 3 { // inbound plugin + divider + outbound plugin
t.Fatalf("rows = %d, want 3 (inbound + divider + outbound)", len(rows))
}
const wantCells = 6 // #, DIRECTION, PLUGIN, DEPS, BODY, EVENTS
for i, r := range rows {
if len(r) != wantCells {
t.Errorf("row %d has %d cells, want %d (must match column count)", i, len(r), wantCells)
}
}
}
13 changes: 10 additions & 3 deletions authbridge/demos/echo/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
# Prerequisites:
# - kagenti installed on a kind cluster (this is the operator-flow demo)
# - kubectl, kind, podman/docker, python3 + PyYAML
# - python-keycloak (for setup-keycloak; pip install python-keycloak)
# - python-keycloak (for setup-keycloak; pip3 install --user python-keycloak)

.PHONY: help preflight build-sidecar load-sidecar override-sidecar-image \
build-images load-images deploy wait-pods setup-keycloak \
Expand Down Expand Up @@ -94,6 +94,13 @@ preflight: ## Verify kagenti is installed + tools are available
echo " Install: pip3 install --user pyyaml"; \
exit 1; \
}
@python3 -c 'import keycloak' 2>/dev/null || { \
echo "ERROR: python-keycloak is required (used later by setup-keycloak)."; \
echo " Install: pip3 install --user python-keycloak"; \
echo " Checked here in preflight so demo-echo fails before the"; \
echo " build/deploy steps rather than after them."; \
exit 1; \
}

# ---------- Sidecar (authbridge proxy) ----------

Expand Down Expand Up @@ -225,11 +232,11 @@ wait-pods: ## Wait for pods + operator-rendered authbridge ConfigMap
# can't reach that, port-forward in another terminal:
# kubectl port-forward service/keycloak-service -n keycloak 8080:8080
# and set KEYCLOAK_URL=http://localhost:8080.
# Requires the python-keycloak package: pip install python-keycloak
# Requires the python-keycloak package: pip3 install --user python-keycloak
setup-keycloak: ## Configure Keycloak (echo-upstream client, scopes, demo users)
@python3 -c 'import keycloak' 2>/dev/null || { \
echo "ERROR: python-keycloak is required for setup-keycloak."; \
echo " Install: pip install python-keycloak"; \
echo " Install: pip3 install --user python-keycloak"; \
exit 1; \
}
python3 scripts/setup_keycloak.py --namespace $(NAMESPACE) --service-account $(AGENT_NAME)
Expand Down
Loading