Skip to content

Feat: Inline plugin-config templates in abctl edit#461

Merged
huang195 merged 14 commits into
kagenti:mainfrom
huang195:feat/abctl-edit-templates
Jun 1, 2026
Merged

Feat: Inline plugin-config templates in abctl edit#461
huang195 merged 14 commits into
kagenti:mainfrom
huang195:feat/abctl-edit-templates

Conversation

@huang195
Copy link
Copy Markdown
Contributor

@huang195 huang195 commented Jun 1, 2026

Summary

Adds an inline plugin-config template reference to abctl edit. When
operators open the pipeline editor, they now see — below a fence
marker in the same buffer — a commented YAML template for every
plugin in the catalog, annotated with required/optional field
markers, defaults, and prose descriptions. Templates are stripped on
save.

The structurally-foundational piece is per-field plugin schemas,
reflected from struct tags on each plugin's config struct. This
unlocks the templates feature here and any future config-aware
tooling (kagenti-UI forms, JSON-Schema publishing, schema-driven
linters).

Layered design

Layer Where
FieldSchema + SchemaOf reflection authlib/pipeline/schema.go
Per-plugin tags + ConfigSchema() authlib/plugins/{ibac,jwtvalidation,mcpparser,tokenexchange,tokenbroker}
Catalog wire format gains fields authlib/sessionapi/catalog_adapter.go
/v1/plugins exposes schema metadata authlib/sessionapi/server.go
abctl templates renderer cmd/abctl/edit/templates.go
abctl save path strips fence + below cmd/abctl/edit/templates.go (StripTemplates)

What the operator sees

pipeline:
  inbound:
    plugins:
    - config: { issuer: ... }
      name: jwt-validation
  outbound:
    plugins:
    - config: { default_policy: passthrough, ... }
      name: token-exchange


# === ABCTL TEMPLATES BELOW (stripped on save) ===
# ====================================================================
#
# Reference: every plugin in the catalog.
#
# To use a template, copy a plugin block from below, paste it above
# the fence into your inbound: or outbound: chain, strip the leading
# "# " from each line, then adjust indentation.
#
# This entire section -- from the fence line down -- is stripped
# before the edited buffer is written back to the ConfigMap.
#
# ====================================================================

# --- token-exchange ---
# RFC 8693 outbound token exchange against Keycloak per route.
# Required: identity.type
#       - name: token-exchange
#         config:
#           # [REQUIRED] Client credentials for token exchange.
#           identity:
#             # [REQUIRED] Identity scheme: spiffe or client-secret.
#             #   choices: spiffe | client-secret
#             type: ""
#             ...
#           # [optional, default=passthrough, enum=passthrough|exchange] ...
#           default_policy: passthrough
#           ...

Round-trip safety

The save path (StripTemplates) truncates at the fence marker and
trims trailing blank lines so opening + saving without edits produces
the original ConfigMap bytes byte-for-byte. No spurious + diff.

If the operator deletes the fence marker by accident, the whole
buffer applies — templates are comment-only YAML, so applying them
is a YAML-parser no-op.

Effective-required propagation

An object field whose own tag isn't required:"true" but which
contains a required descendant — e.g. tokenexchange.identity with
a required identity.type inside — is treated as "effectively
required": the operator can't legally omit the parent block. The
renderer annotates such parents as [REQUIRED] and promotes them to
the top of the parent's config block. The Required: header lists
every required leaf path (e.g. identity.type).

Tag vocabulary (additive)

Tag Meaning
description Single-line prose summary
required:"true" Required at boot
default:"X" Documented default
enum:"a,b,c" Allowed values

The struct-tag schema can't express OR-groups or conditional
requirements directly (one bool per field), so descriptions carry
those rules in prose — e.g. token-exchange.token_url's description
notes "Required unless keycloak_url + keycloak_realm are both set".

Commits

  1. feat(pipeline): FieldSchema reflection from struct tags
  2. feat(plugins): Annotate config structs with description / required / default / enum
  3. feat(sessionapi): Surface per-plugin field schemas in /v1/plugins
  4. feat(abctl): Render plugin templates below pipeline subtree on edit
  5. feat(abctl): Strip templates section on save
  6. fix(abctl): Fetch catalog on first edit when not yet cached
  7. fix(abctl): No spurious diff when saving without changes
  8. fix(abctl): Make required-vs-optional scan at the left margin
  9. fix(abctl): Surface nested + OR-group requirements in templates
  10. fix(abctl): Mark parents of required leaves as effectively required
  11. fix(abctl): Make the templates fence visually separate from config

Testing

End-to-end smoke against team1/email-agent:

  • go test ./... clean across authlib and cmd/abctl
  • /v1/plugins exposes per-plugin field metadata after image rebuild
  • /tmp/abctl shows the active pipeline + fence + per-plugin templates including token-broker (after rebase onto import tokenbroker #460)
  • Save without edits produces no diff (round-trip byte-identical)
  • Templates with nested object fields recurse and surface required leaves (identity.type) in both the per-plugin header and the inline [REQUIRED] bracket annotation
  • ASCII-only banner verified to open cleanly in vi / vim

Assisted-By: Claude (Anthropic AI) noreply@anthropic.com

Summary by CodeRabbit

  • New Features

    • Plugins now publish per-field configuration schemas (types, required, defaults, enums) surfaced in the plugin catalog and CLI/TUI.
    • CLI edit generates fenced, per-plugin configuration templates and strips them on save; TUI caches/fetchable catalog to render templates inline.
  • Tests

    • Added comprehensive tests for schema generation, catalog encoding/decoding, template rendering/stripping, and edit command behaviors.

huang195 added 11 commits June 1, 2026 14:58
Plugins decode their config from JSON via Configurable.Configure
but the framework has only known JSON field names — no per-field
metadata. Tools that want to render config UIs, generate templates,
or publish JSON Schema have had to read source.

Add a thin reflection layer:

  type FieldSchema struct {
      Name        string         // json key
      Type        string         // string|int|bool|[]string|object|unknown
      Required    bool           // from `required:"true"` tag
      Description string         // from `description:"..."` tag
      Default     string         // from `default:"..."` tag (cosmetic)
      Enum        []string       // from `enum:"a,b,c"` tag
      Fields      []FieldSchema  // populated when Type == "object"
  }

  func SchemaOf(configType any) []FieldSchema

SchemaOf accepts a zero-value struct (or *struct), walks the type
via reflect, and emits one FieldSchema per JSON-tagged exported
field. Tag vocabulary is additive — absence of any tag means "no
metadata," and the field still appears in the schema.

The four new tags (description, required, default, enum) are
hints for tooling. They do not change runtime behavior; the
plugin's existing applyDefaults / validate pipeline remains the
authoritative source of truth for runtime config semantics.

Type categorization is deliberately coarse:
- Primitive scalars (string/int/bool/pointers thereof)
- []string (the most common slice in plugin configs)
- Nested struct → recurse one level into its fields
- Anything else (slice-of-struct, maps, etc.) → "unknown"

Slice-of-struct shapes (e.g. tokenexchange.RoutesConfig.Rules)
don't fit the per-line YAML scalar template model abctl will use,
so they're surfaced as "unknown" rather than recursed. If a future
consumer needs deeper recursion the helper can be extended; today
no plugin's flat YAML template benefits from it.

Tests cover primitives, []string, nested structs, pointer
indirection, untagged-field skipping, dash-tag (`json:"-"`)
skipping, non-struct inputs, slice-of-struct → "unknown", and
enum tag whitespace/empty trimming.

This is foundational only — no consumer wires up to it yet. The
catalog adapter, abctl renderer, and per-plugin annotations land
in subsequent commits.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
Wires four plugin configs (ibac, jwt-validation, token-exchange,
token-broker) up to pipeline.SchemaOf. Each plugin's config struct
gains description / required / default / enum tags on its JSON-
tagged fields, and each plugin gains a one-line ConfigSchema()
method that returns SchemaOf of its config type.

Adds the SchemaProvider interface in pipeline/schema.go (one-method
contract) so the upcoming catalog adapter can type-assert against
it without needing to import every plugin package.

Tag conventions applied uniformly:

  - description: single-line summary suitable for inline comments
    in YAML templates. Long-form rationale stays in the field's Go
    doc comment and in docs/<plugin>-plugin.md.
  - required: "true" only on fields whose absence boot-fails. Most
    fields are optional with sensible defaults.
  - default: cosmetic mirror of what applyDefaults produces. Lets
    operator-facing tooling render the default visibly without
    invoking applyDefaults.
  - enum: comma-separated allowed values for enum-shaped fields.
    Whitespace around commas is trimmed by the schema parser.

Plugins without configs (a2a-parser, inference-parser, mcp-parser
on this branch) deliberately do NOT implement SchemaProvider —
the catalog adapter will surface them as "no fields" without any
boilerplate on those plugins.

The struct tag values must be on a single line per field; Go's
reflect.StructTag.Get parser requires "key:\"value\"" pairs to be
space-separated, not newline-separated. Tag lines in this commit
are intentionally long for that reason.

No behavior change at runtime — these tags are pure metadata for
tooling. Existing config decode (DisallowUnknownFields) keeps its
authoritative role for accepted/rejected fields; plugins'
applyDefaults / validate paths are untouched.

Tests: covered by pipeline/schema_test.go (commit 1). A smoke test
that ibac.ConfigSchema() returns the expected field list confirms
end-to-end wiring; deeper per-plugin assertions land in
sessionapi tests in the next commit.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
Wires the schema layer end-to-end: framework Catalog() captures
each plugin's ConfigSchema (when implemented), the sessionapi
adapter converts to a wire-shape FieldSchemaEntry slice, and the
abctl apiclient mirrors the same shape.

plugins.CatalogEntry gains a Fields slice populated by type-
asserting against pipeline.SchemaProvider during Catalog()'s
single-shot factory walk. Constraint already documented for
Capabilities() — the throwaway instance must be state-independent
and side-effect free — extends naturally to ConfigSchema(): in
practice it's `return SchemaOf(myConfig{})`, satisfying both.

cloneCatalog deep-copies the new Fields slice (including the Enum
sub-slices and recursively into nested-struct Fields) so callers
mutating the returned snapshot can't taint the cached copy that
seeds future /v1/plugins responses.

sessionapi gains FieldSchemaEntry as the wire-level mirror of
pipeline.FieldSchema, kept package-local so /v1/plugins consumers
(abctl, future kagenti-UI clients) don't transitively import
authlib/pipeline. The adapter's convertFieldSchemas helper recurses
for nested-struct schemas.

apiclient.PluginCatalogEntry gains Fields []PluginFieldEntry to
match. Existing consumers that only read the original fields
continue to work — JSON decoding ignores unknown wire fields, and
PluginFieldEntry uses omitempty throughout, so older catalog
responses without Fields decode cleanly.

Tests:
- TestHandlePluginCatalog_IncludesFieldSchemas confirms the new
  Fields field round-trips on the wire including required, default,
  and enum sub-fields.
- The existing TestHandlePluginCatalog_ListsRegisteredPlugins stays
  green (it doesn't reference Fields, and the omitempty wire format
  doesn't change its output).

No abctl-visible behavior change yet — the renderer arrives in the
next commit.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
Builds on the schema layer wired through in the previous commit:
when abctl opens $EDITOR on the pipeline subtree, the temp file
now also carries a commented templates section listing every
registered plugin and its config fields. Operators copy a block,
strip the leading "# " prefix, and adjust indentation to fit
their inbound: or outbound: chain - no more bouncing to docs to
remember a plugin's required fields.

Mechanism:

- New edit/templates.go renders a fence-marked YAML comment block
  per plugin in the catalog. Required fields render first inside
  each block, with placeholders typed to match the field
  (string -> "", int -> 0, bool -> false, []string -> []) plus an
  inline comment carrying required/default/enum/description. The
  documented Default substitutes for the typed placeholder when
  present, so operators see the actual fallback inline.

- FetchCmd gains a catalog parameter. nil produces today's behavior
  (no templates appended) - used by tests, --endpoint mode without
  a /v1/plugins fetch, and any future degraded-server path. tui/
  passes m.catalog through a small catalogPlugins helper that
  unwraps the *PluginCatalog pointer.

- The fence marker is exported as edit.FenceMarker so the save-side
  splice logic (commit 5) can match it without a duplicated string.

The templates section is pure comments - every emitted line starts
with "#". If the operator deletes the fence marker by accident,
applying the buffer is still a YAML no-op (comments are discarded
by the parser), preserving the safe-fallback the plan committed to.

Tests:

- TestRenderTemplates_EmptyCatalog (nil and empty cases)
- TestRenderTemplates_FenceMarkerPresent
- TestRenderTemplates_PluginWithFields covers required-first
  ordering, Required: header line, default/enum/description
  inline notes
- TestRenderTemplates_PluginNoFields confirms no spurious
  config: line for parsers without configurable fields
- TestRenderTemplates_PlaceholderTypes covers all five typed
  placeholders plus the documented-default substitution path
- TestFetchCmd_AppendsTemplatesWhenCatalogProvided is the
  end-to-end integration: real ConfigMap fixture + catalog ->
  tempfile contains both the pipeline subtree and the templates
- TestFetchCmd_NoTemplatesWhenCatalogNil pins the nil-catalog
  fallback so future refactors can't silently emit templates
  in --endpoint mode

Save-side stripping ships in the next commit; until then a
preserved fence marker would land in the ConfigMap as comments.
This commit is independently safe because the splice path
already preserves comments verbatim - nothing breaks if a user
saves with the templates section left in place.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
Closes the loop on the templates-in-buffer feature: the read path
appends the catalog reference below a fence marker, and the save
path now strips the fence and everything after it before any
downstream processing.

StripTemplates is a strict line-match: a line whose first non-
whitespace content is exactly FenceMarker. Whitespace before the
marker is tolerated (some editors auto-indent comment lines), and
truncation lands at the start of that line so the previous line's
trailing newline survives. Inline mentions of the marker text
(e.g. inside a comment that happens to discuss the marker) do not
trigger truncation.

The strip is invoked in tui/app.go immediately after the tempfile
read, before the empty-check, YAML parse, splice preview, validation,
and diff. Every downstream step sees only the active pipeline subtree
- the templates section never makes it to the kubectl apply call.

Safe-fallback contract preserved: when the fence marker is absent
(catalog wasn't fetched at edit time, --endpoint mode without
/v1/plugins, or operator deleted the marker by hand) StripTemplates
returns the input unchanged, and applying the buffer remains a
YAML no-op for the comment-only templates section even if it
somehow survives.

Tests:

- TestStripTemplates_RemovesFenceAndBelow - happy path: fence
  followed by template comments is truncated cleanly
- TestStripTemplates_NoFenceReturnsUnchanged - input without fence
  passes through byte-identical
- TestStripTemplates_ToleratesLeadingWhitespace - auto-indented
  fence still matches
- TestStripTemplates_FenceAtEOF - fence with no trailing newline
  still strips correctly
- TestStripTemplates_NotFooledBySimilarLine - inline mention of
  the marker text inside prose does not trigger truncation
- TestStripTemplates_IntegratesWithRender - round-trip: render
  templates after a real subtree, strip them, the subtree's
  structural content survives byte-for-byte (a renderer-emitted
  blank line separator may remain as harmless trailing whitespace,
  which the YAML parser tolerates and the existing trailing-newline
  normalization in the save path absorbs)

End-to-end behavior is now: operator presses 'e' on a pipeline pod,
sees the active config plus a commented reference for every
registered plugin, copies/edits/saves; abctl strips the reference
section, validates, splices, applies, and polls /reload/status.
The ConfigMap on disk only ever carries the active config - no
templates pollution.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
The previous commit assumed the operator had already opened the
catalog pane (P) before pressing 'e' to edit. In practice that
ordering is the exception, not the rule - operators going straight
to edit saw the active pipeline subtree but no templates section,
because m.catalog was nil and FetchCmd skipped template rendering.

FetchCmd now takes the *apiclient.Client directly. When the supplied
cachedCatalog is nil and client is non-nil, it issues GetPluginCatalog
inline before composing the tempfile - the catalog is small, the
endpoint is in-cluster, and one extra round-trip on the edit hot path
is invisible compared to the kubectl ConfigMap fetch already happening.

The fetched catalog is returned in FetchedMsg.Catalog so the TUI
caches it into m.catalog. Subsequent edits and the catalog pane
both reuse the same snapshot - no duplicate fetches.

Catalog-fetch failure is non-fatal: the edit still opens without
templates, mirroring the older "no catalog" path. An older server
(no /v1/plugins) or a transient network blip degrades gracefully
instead of blocking the edit.

Tests:

- TestFetchCmd_FetchesCatalogInlineWhenCacheNil uses httptest to
  serve /v1/plugins and confirms the rendered tempfile carries
  both the pipeline subtree and the inline-fetched catalog, plus
  FetchedMsg.Catalog is populated for TUI caching.
- TestFetchCmd_FetcherErrorIsNonFatal confirms a 500-returning
  catalog endpoint doesn't break the edit flow - tempfile lacks
  the fence marker, but msg.Err is nil and the path is set.

The function signature ended up taking *apiclient.Client concretely
rather than a CatalogFetcher interface. Go's nil-interface gotcha
(a typed nil pointer assigned to an interface compares != nil) made
the interface form silently panic in TUI tests that pass a nil
client; the concrete-pointer form lets `client != nil` work as
expected without a separate IsZero helper.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
The renderer prefixed a blank line before the fence marker for
visual separation. StripTemplates truncated at the fence start,
preserving that blank line - so opening edit and saving without
changes produced subtree + "\n" instead of subtree, surfacing a
spurious "+" line in the diff prompt.

Drop the leading newline from the renderer. The fence sits
directly against the last line of the active subtree; visual
separation is provided by the fence text itself plus the
templatesBanner immediately below. Round-trip is now byte-exact:
StripTemplates(subtree + RenderTemplates(catalog)) == subtree.

Tightened TestStripTemplates_IntegratesWithRender to assert
byte-exact equality instead of the loose "subtree is a prefix"
check that masked this regression.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
Previous template format hid required-ness in inline "# required;"
notes mixed with description text. Operators reading a token-exchange
block - which has no required fields - couldn't tell from the inline
format alone.

New per-field layout:

  # [REQUIRED] <description>
  judge_endpoint: ""

  # [optional, default=5000] <description>
  timeout_ms: 5000

The bracket prefix sits at the start of the comment so the operator
scans down a column to find required fields. Optional fields list
their default and enum constraints in the same bracket. Required
fields with enums get an extra "choices: a | b | c" line so the
constraint is still visible.

Per-plugin "Required: ..." header is now always emitted (even when
empty) - "Required: (none - every field is optional)" makes the
answer affirmative instead of inferred from absence.

Tests:
- Updated TestRenderTemplates_PluginWithFields for new line format
- TestRenderTemplates_AllOptionalShowsRequiredNoneHeader pins the
  "(none - every field is optional)" path
- TestRenderTemplates_RequiredEnumShowsChoices pins the choices
  line for required+enum fields

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
Two correctness gaps in the templates reference were hiding real
constraints from operators.

1. Nested object fields collapsed to "{}". token-exchange's
   identity sub-object includes a required `type` field, but the
   renderer emitted just `identity: {}` with no hint that anything
   inside was required.

   Fix: render object-typed fields with sub-fields as a recursive
   block (parent: <newline> sub-fields indented +2). Required and
   optional sub-fields are reordered the same way the top-level
   block does, so `[REQUIRED] type:` lands first.

2. The per-plugin "Required:" header only scanned top-level
   fields. token-exchange showed "(none)" even though identity.type
   is required.

   Fix: collectRequiredPaths recurses the whole schema and emits
   dotted paths (e.g. `identity.type`). Operators see the complete
   must-fill set in one place at the top of each plugin block.

3. tokenexchange struct tags now reflect what validate() enforces:
   - identity.type marked required:"true"
   - token_url description loudly notes the OR-group: "Required
     unless keycloak_url + keycloak_realm are both set"
   - keycloak_url / keycloak_realm descriptions: "Required (with
     <other>) when token_url is empty"
   - identity.jwt_audience description: "REQUIRED when type=spiffe"

   The struct-tag schema can't express OR-groups or conditional
   requirements directly (one bool per field), so the description
   text carries the rule. The header captures the unconditional
   case (identity.type), and the prose in [optional] entries
   carries the conditional ones.

Tests:
- TestRenderTemplates_NestedObjectRecurses pins the parent:\n
  sub-block layout and the +2 indentation contract
- TestRenderTemplates_HeaderListsNestedRequiredPaths confirms the
  dotted-path output (identity.type, not just type)

Image rebuild required for the schema-tag change to take effect
in /v1/plugins; abctl alone does not.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
Templates showed `[optional] Client credentials...` for token-exchange's
identity block even though identity.type below it is required. An
operator can't legally omit the parent block (validate() would reject
the empty Identity.Type), so [optional] was misleading.

Add hasRequiredDescendant: an object field whose own tag is not
required:"true" but which contains any required field anywhere
beneath it is "effectively required" — the operator can't validly
leave it out. The renderer:

- Annotates effectively-required fields as [REQUIRED]
- Promotes them to the top of the parent's config: block (same
  ordering required leaves get)

The "Required:" header keeps emitting leaf paths only
(identity.type, not the redundant identity, identity.type) so
the operator sees the actionable name without double-counting.

Tests: TestRenderTemplates_NestedObjectRecurses now also asserts
the parent block carries [REQUIRED] (not [optional]) when its
sub-tree contains a required leaf.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
Operators reported the boundary between the active pipeline subtree
and the templates reference was hard to see — the fence comment line
sat directly under the last plugin's config, so the two blocks ran
together visually and operators couldn't tell where their editable
content ended.

Render two blank lines + the fence + a bordered banner block:

    ...token-exchange config above...

    # === ABCTL TEMPLATES BELOW (stripped on save) ===
    # ════════════════════════════════════════════════════════════════════
    #
    # Reference: every plugin in the catalog.
    #
    # To use a template, copy a plugin block from below ...
    #
    # ════════════════════════════════════════════════════════════════════

The visual padding ABOVE the fence has to be whitespace-only —
content lines there would survive strip and surface as a `+` diff
on save-without-changes. So all decoration (the ═══ borders, the
prose) lives BELOW the fence where it's stripped naturally.

To make the leading blank lines safe, StripTemplates now also trims
trailing whitespace-only lines after truncating at the fence. The
previous real line's terminator stays put. trimTrailingBlankLines
+ isBlankLine are the small helpers that do this.

Tests:
- TestStripTemplates_RemovesFenceAndBelow updated: a blank-line
  separator no longer survives strip
- TestStripTemplates_TrimsMultipleBlankLinesBeforeFence pins the
  multi-blank-line case
- TestStripTemplates_TrimsWhitespaceOnlyLineBeforeFence covers
  spaces / tabs as "blank"
- The existing TestStripTemplates_IntegratesWithRender (round-trip)
  keeps passing — render-then-strip is still byte-identical to the
  original subtree

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jun 1, 2026

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5fc68f8c-9edc-4050-a332-f441350df15b

📥 Commits

Reviewing files that changed from the base of the PR and between 17bc1f3 and c051be0.

📒 Files selected for processing (3)
  • authbridge/authlib/pipeline/schema.go
  • authbridge/authlib/pipeline/schema_test.go
  • authbridge/cmd/abctl/apiclient/client_test.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • authbridge/authlib/pipeline/schema.go
  • authbridge/authlib/pipeline/schema_test.go

📝 Walkthrough

Walkthrough

Adds reflection-based plugin config schema extraction (SchemaProvider, FieldSchema, SchemaOf), updates plugins to publish schemas, includes schemas in the registry and /v1/plugins wire model, and renders/removes fenced template blocks in abctl edit; tests cover schemas, templates, and round-trips.

Changes

Plugin Configuration Schema Introspection and Edit Template Rendering

Layer / File(s) Summary
Schema Reflection Framework
authbridge/authlib/pipeline/schema.go, authbridge/authlib/pipeline/schema_test.go
Implements SchemaProvider, FieldSchema, and SchemaOf() using reflection over json and semantic tags; includes tests for primitives, string slices, nested structs, pointer handling, non-struct inputs, slice-of-struct behavior, enum parsing, and recursion bounds.
Plugin Configuration Metadata
authbridge/authlib/plugins/{ibac,jwtvalidation,tokenbroker,tokenexchange}/plugin.go
Plugins updated with richer struct tags (description, required, default, enum) and implement ConfigSchema() returning pipeline.SchemaOf(...) to expose config schemas.
Catalog Registry and API Integration
authbridge/authlib/plugins/registry.go, authbridge/authlib/sessionapi/{catalog_adapter.go,server.go,server_test.go}, authbridge/cmd/abctl/apiclient/{client.go,client_test.go}
CatalogEntry gains optional Fields; Catalog() populates Fields for SchemaProvider plugins and deep-copies schemas; adapter converts to wire FieldSchemaEntry; HTTP handler, client models, and tests updated to include field schemas.
Edit Fetch and Template Rendering
authbridge/cmd/abctl/edit/{edit.go,templates.go,templates_test.go}, authbridge/cmd/abctl/edit/edit_test.go
FetchCmd accepts API client and optional cached catalog, resolves/fetches catalog, appends fenced annotated YAML templates (required-first, dotted required paths, enum choices, placeholders) via RenderTemplates, and StripTemplates removes the fence and trailing blanks for round-trips; tests validate rendering, placeholders, stripping, and FetchCmd behavior.
TUI Application Wiring
authbridge/cmd/abctl/tui/{app.go,keys.go}
TUI caches inline-fetched catalog from FetchCmd for reuse; editor-exit flow strips templates via StripTemplates before parsing/validation; keymap wiring passes cached catalog into FetchCmd for initial and retry edit flows.

Sequence Diagram

sequenceDiagram
  participant TUI as TUI App
  participant FetchCmd as edit.FetchCmd
  participant APIClient as API Client
  participant Catalog as /v1/plugins
  participant RenderT as RenderTemplates
  participant TempFile as Temp YAML
  participant Editor as User Editor
  participant StripT as StripTemplates

  TUI->>FetchCmd: Call with client & namespace/pod
  FetchCmd->>APIClient: Fetch plugin catalog (if needed)
  APIClient->>Catalog: GET /v1/plugins
  Catalog-->>APIClient: CatalogEntry[].Fields
  APIClient-->>FetchCmd: PluginCatalog
  FetchCmd->>RenderT: Render templates from plugins
  RenderT-->>FetchCmd: Fenced YAML blocks
  FetchCmd->>TempFile: Write pipeline subtree + templates
  TempFile-->>TUI: Return FetchedMsg with catalog
  TUI->>Editor: Open tempfile
  Editor->>Editor: User edits
  Editor-->>TUI: Edited buffer
  TUI->>StripT: Remove templates & fence
  StripT-->>TUI: Clean pipeline YAML
  TUI->>TUI: Parse & validate clean subtree
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • cwiklik
  • pdettori

Poem

I nibble tags and peek inside,
I map the fields where secrets hide,
I fence the templates, soft and neat,
Then strip the crumbs when edits meet. 🐇✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 39.06% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Feat: Inline plugin-config templates in abctl edit' clearly and concisely describes the main feature being added: inline plugin-config templates in the abctl edit workflow.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 golangci-lint (2.12.2)

level=error msg="[linters_context] typechecking error: pattern ./...: directory prefix . does not contain main module or its selected dependencies"


Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
authbridge/authlib/sessionapi/server.go (1)

68-80: ⚡ Quick win

Wire JSON tags for FieldSchemaEntry match apiclient.PluginFieldEntry (server-side contract is aligned).
FieldSchemaEntry and PluginFieldEntry use identical json tags for name, type, required, description, default, enum, and fields (including omitempty), so schema fields should decode correctly. (Optional: extend the inline-fetch test to assert schema fields to guard against future tag drift.)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@authbridge/authlib/sessionapi/server.go` around lines 68 - 80,
FieldSchemaEntry's JSON tags must exactly match the server-side contract
represented by apiclient.PluginFieldEntry so schema fields decode correctly;
verify that the struct FieldSchemaEntry (fields Name, Type, Required,
Description, Default, Enum, Fields) uses the same json tags and omitempty
behavior as PluginFieldEntry and, if missing, update the tags on
FieldSchemaEntry to match PluginFieldEntry exactly; optionally add/extend the
inline-fetch test to assert decoded schema fields for FieldSchemaEntry to detect
future tag drift.
authbridge/cmd/abctl/edit/edit_test.go (1)

17-18: ⚡ Quick win

Add coverage for the new catalog-backed fetch paths.

These tests still only hit the legacy client == nil branch, so regressions in inline /v1/plugins fetch, template append, and FetchedMsg.Catalog propagation will slip through. Please add one case with a cached catalog and one case with an httptest.Server, then assert the tempfile contains FenceMarker and the fresh catalog is returned when fetched inline.

Also applies to: 34-35

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@authbridge/cmd/abctl/edit/edit_test.go` around lines 17 - 18, Add test cases
exercising the catalog-backed fetch paths: create one case where FetchCmd is
invoked with a cached catalog (populate a temporary catalog file/structure and
ensure FetchCmd uses that cache) and one case using an httptest.Server that
serves the /v1/plugins template/catalog response; call
FetchCmd(context.Background(), stub, <clientOrURL>, "team1", "email-agent", nil)
and cast the result to FetchedMsg, then assert that the tempfile produced
contains the FenceMarker string and that FetchedMsg.Catalog contains the fresh
catalog returned by the server (or cached catalog in the cached case). Ensure
tests hit the branch where client != nil so template append and inline
/v1/plugins handling in FetchCmd and the FetchedMsg.Catalog propagation are
validated.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@authbridge/cmd/abctl/edit/templates.go`:
- Around line 336-342: The isBlankLine function currently only treats spaces and
tabs as blank, so CRLF-terminated blank lines (containing '\r') aren't
recognized; update isBlankLine to also consider '\r' as whitespace (i.e., treat
byte value '\r' as blank alongside ' ' and '\t') so trimTrailingBlankLines will
remove CRLF blank lines; locate and change the isBlankLine function accordingly.
- Around line 299-308: The current truncation logic allows any line that starts
with the fence (variable target) to match, stripping lines like "FenceMarker ...
extra"; change the condition in the function containing variables edited, start,
end, and target so it only truncates when the fence is an exact line (allowing
only optional trailing spaces/tabs). Concretely, replace the end-start >=
len(target) check with logic that (1) ensures edited[start:start+len(target)] ==
target and (2) that either start+len(target) == end or every character in
edited[start+len(target):end] is a space or tab; only then perform the
trimTrailingBlankLines(edited[:i]) return.

---

Nitpick comments:
In `@authbridge/authlib/sessionapi/server.go`:
- Around line 68-80: FieldSchemaEntry's JSON tags must exactly match the
server-side contract represented by apiclient.PluginFieldEntry so schema fields
decode correctly; verify that the struct FieldSchemaEntry (fields Name, Type,
Required, Description, Default, Enum, Fields) uses the same json tags and
omitempty behavior as PluginFieldEntry and, if missing, update the tags on
FieldSchemaEntry to match PluginFieldEntry exactly; optionally add/extend the
inline-fetch test to assert decoded schema fields for FieldSchemaEntry to detect
future tag drift.

In `@authbridge/cmd/abctl/edit/edit_test.go`:
- Around line 17-18: Add test cases exercising the catalog-backed fetch paths:
create one case where FetchCmd is invoked with a cached catalog (populate a
temporary catalog file/structure and ensure FetchCmd uses that cache) and one
case using an httptest.Server that serves the /v1/plugins template/catalog
response; call FetchCmd(context.Background(), stub, <clientOrURL>, "team1",
"email-agent", nil) and cast the result to FetchedMsg, then assert that the
tempfile produced contains the FenceMarker string and that FetchedMsg.Catalog
contains the fresh catalog returned by the server (or cached catalog in the
cached case). Ensure tests hit the branch where client != nil so template append
and inline /v1/plugins handling in FetchCmd and the FetchedMsg.Catalog
propagation are validated.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e41edc41-2c2d-456d-bf93-16cf25061b41

📥 Commits

Reviewing files that changed from the base of the PR and between bd1bb3b and 003b4fa.

📒 Files selected for processing (17)
  • authbridge/authlib/pipeline/schema.go
  • authbridge/authlib/pipeline/schema_test.go
  • authbridge/authlib/plugins/ibac/plugin.go
  • authbridge/authlib/plugins/jwtvalidation/plugin.go
  • authbridge/authlib/plugins/registry.go
  • authbridge/authlib/plugins/tokenbroker/plugin.go
  • authbridge/authlib/plugins/tokenexchange/plugin.go
  • authbridge/authlib/sessionapi/catalog_adapter.go
  • authbridge/authlib/sessionapi/server.go
  • authbridge/authlib/sessionapi/server_test.go
  • authbridge/cmd/abctl/apiclient/client.go
  • authbridge/cmd/abctl/edit/edit.go
  • authbridge/cmd/abctl/edit/edit_test.go
  • authbridge/cmd/abctl/edit/templates.go
  • authbridge/cmd/abctl/edit/templates_test.go
  • authbridge/cmd/abctl/tui/app.go
  • authbridge/cmd/abctl/tui/keys.go

Comment thread authbridge/cmd/abctl/edit/templates.go
Comment thread authbridge/cmd/abctl/edit/templates.go
huang195 added 2 commits June 1, 2026 15:57
Three small follow-ups from the review:

1. isBlankLine now treats \r as horizontal whitespace. An editor that
   saves with CRLF would otherwise leave a stray "\r\n" line surviving
   trimTrailingBlankLines and break the byte-identical round-trip on
   save-without-changes. Adds TestStripTemplates_TrimsCRLFBlankLine-
   BeforeFence to pin the behavior.

2. schemaOfType comment updated to reflect actual unbounded recursion
   (not "one level"). Plugin configs in practice nest at most one or
   two levels; a self-referential config would loop, but none exist
   today. Comment now points future readers at where to add a depth
   guard if it ever becomes necessary.

3. SchemaOf doc updated to clarify that untagged anonymous/embedded
   structs are skipped, NOT flattened — the opposite of encoding/json.
   No current plugin uses untagged embedding, so this is a doc
   correctness fix rather than a behavior change.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
CodeRabbit caught that the fence detection was a prefix match: a line
starting with FenceMarker followed by extra content would still
trigger truncation. The doc explicitly promised "marker must be
intact", so the looser match was a contract violation — and an
operator who happened to type past the marker on that line would
silently lose their edits below.

Replace the prefix compare with an exact length+content compare,
tolerating only a trailing \r so CRLF line endings on the fence line
itself still match. Anything else after the marker (a stray comment,
a typo, an accidental keystroke) now fails the match and the strip
is skipped — the operator gets the safe fallback path.

Tests:
- TestStripTemplates_RequiresExactFenceMatch — line with trailing
  content stays intact
- TestStripTemplates_AcceptsCRLFFenceLine — CRLF on the marker line
  itself still matches (the tolerated suffix)

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
Copy link
Copy Markdown
Collaborator

@cwiklik cwiklik left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well-structured PR — the layered design (reflection → plugin annotations → wire format → template renderer → strip-on-save) is clean and the test coverage is thorough (~583 lines of tests for ~1,128 added). All 13 commits signed-off, CI green.

Two minor observations inline (suggestion + nit), neither blocking.

Areas reviewed: Go (schema infra, plugin annotations, sessionapi wire, abctl edit/templates, TUI integration), Tests
Commits: 13 commits, all signed-off
CI status: all passing

return out
}

// kindOf returns the coarse-grained Type tag for a field.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: The unbounded recursion note is well-documented, but consider adding a simple depth counter (e.g., max 10) as a safety net. A future plugin config struct with an inadvertent self-referential pointer field would stack overflow silently. Low risk today but cheap insurance.

func schemaOfType(t reflect.Type) []FieldSchema {
    return schemaOfTypeDepth(t, 0)
}

func schemaOfTypeDepth(t reflect.Type, depth int) []FieldSchema {
    if depth > 10 {
        return nil
    }
    // ... existing logic, recurse with depth+1
}

func trimTrailingBlankLines(out []byte) []byte {
for len(out) > 0 && out[len(out)-1] == '\n' {
// Find the start of the line that ends at len(out)-1.
prevNL := bytes.LastIndexByte(out[:len(out)-1], '\n')
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: The fence-match check end-start >= len(target) means a line like # === ABCTL TEMPLATES BELOW (stripped on save) ===EXTRA would match. TestStripTemplates_NotFooledBySimilarLine covers inline mentions but not trailing garbage after the marker. Consider tightening to start+len(target) == end for an exact match (after stripping leading whitespace).



Two follow-ups from the review pass:

1. Bound schemaOfType recursion at depth 10 (cwiklik's defensive
   suggestion). Plugin configs nest one or two levels in practice,
   so the cap is generous; its job is to keep an inadvertent
   self-referential field (e.g. *Self) from silently
   stack-overflowing. New TestSchemaOf_SelfReferentialIsBounded
   pins the behavior with a linked-list-style node{Next *node}.

2. New TestGetPluginCatalog_DecodesFieldSchemas in apiclient
   (CodeRabbit tag-drift-guard suggestion). Server-side
   FieldSchemaEntry and client-side PluginFieldEntry have aligned
   JSON tags today; this test would catch a future drift by
   decoding a realistic /v1/plugins payload (including a nested
   object field with required + enum + default) and asserting
   every metadata key round-trips. The existing TestGetPluginCatalog
   stays focused on basic plugin metadata.

Skipped: CodeRabbit nit about edit_test.go lacking catalog-path
coverage. Those paths ARE covered, just in templates_test.go:
TestFetchCmd_AppendsTemplatesWhenCatalogProvided (cached catalog +
FenceMarker assertion) and TestFetchCmd_FetchesCatalogInlineWhenCacheNil
(httptest.Server + Catalog propagation assertion). The reviewer
appears to have looked at edit_test.go in isolation.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
@huang195
Copy link
Copy Markdown
Contributor Author

huang195 commented Jun 1, 2026

Pushed c051be0 addressing the review pass.

Item Source Status
\r in isBlankLine CodeRabbit (inline) Fixed in b9aced4
Exact fence-line match CodeRabbit (inline) + cwiklik (nit) Fixed in 17bc1f3
Depth bound on schemaOfType cwiklik (suggestion) Fixed in c051be0 (cap at 10 + TestSchemaOf_SelfReferentialIsBounded)
Tag-drift guard on FieldSchemaEntry CodeRabbit (nit) Fixed in c051be0 (TestGetPluginCatalog_DecodesFieldSchemas round-trips a realistic payload incl. nested + enum + required)
edit_test.go lacks catalog-path coverage CodeRabbit (nit) Skipped — covered in templates_test.go: TestFetchCmd_AppendsTemplatesWhenCatalogProvided (cached catalog + FenceMarker assertion) and TestFetchCmd_FetchesCatalogInlineWhenCacheNil (httptest.Server + Catalog propagation). The CodeRabbit nit looked at edit_test.go in isolation.

All packages green: authlib/pipeline, authlib/sessionapi, cmd/abctl/apiclient, cmd/abctl/edit.

@huang195 huang195 merged commit 5d6c9b1 into kagenti:main Jun 1, 2026
19 checks passed
@github-project-automation github-project-automation Bot moved this from New /:ToDo to Done in Kagenti Issue Prioritization Jun 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

3 participants