Files
silo-server/internal/metadata/chain.go
T
203a18ae83 feat(observability): OpenTelemetry logs+traces with secret redaction and slog standardization (#290)
* feat(observability): OpenTelemetry logs+traces with secret redaction

Part of #265. Adds opt-in OpenTelemetry (logs + traces) alongside the existing
stderr + opslog pipeline, plus secret redaction on all sinks. Default-off: with
no OTEL_* / SILO_OTEL_ENABLED config, behavior is unchanged.

Bootstrap (internal/telemetry):
- Setup() builds one shared resource, a TracerProvider (parent-based trace-id
  ratio sampler), a LoggerProvider, and the W3C TraceContext+Baggage propagator
  from env. It installs NO MeterProvider — metrics stay on Prometheus, and the
  built-in no-op global MeterProvider keeps the trace instrumentation libs from
  double-emitting. Shutdown is deferred with a flush timeout.
- Logs are bridged via otelslog fan-out (slog.MultiHandler), level-gated by the
  shared LevelVar and best-effort so a failing collector can't break the console
  or DB branches. stderr + opslog stay untouched.

Secret redaction (internal/logredact):
- A slog.Handler masks secret-keyed attributes (password, token, api_key,
  authorization, cookie, ...) — including .With-bound attrs, nested groups,
  secret-keyed group subtrees, and values behind a LogValuer — on the console
  and OTLP sinks, with a no-op fast path when a record has no secret keys.
  opslog.shouldRedact delegates to logredact.SecretKey so all sinks share one
  marker list.

Rotation is infra-managed (no custom file sink): container runtime for stderr,
collector/backend for OTLP, opslog partition-pruning for the DB. Documented in
docs/architecture/observability.md.

Verification: go build ./..., go vet, gofmt -l — clean; go test
./internal/telemetry/ ./internal/logredact/ -race pass.

AI-use disclosure: implemented with AI assistance (Claude Code), including
adversarial reviews that hardened the bootstrap and fixed two redaction leak
paths; reviewed by the author.

* refactor(observability): slog context+component sweep, sloglint gate (phase 3)

Part of #265. Builds on the OTel bootstrap + redaction commit.

Standardizes every log call site onto the context-carrying slog variants so
records correlate with the active OpenTelemetry trace, and locks the standard
in with a machine gate so future code (human- or AI-authored) can't drift back.

- Call-site sweep: converted the remaining slog.<Level>(...) calls to the
  slog.<Level>Context(ctx, ...) form wherever a context.Context is in scope
  (background/init calls with no ctx are left as-is), across 183 files. Applied
  via a type-aware AST codemod. Log levels and message strings are preserved
  verbatim; a component attr (canonical per-package name) is added to direct
  package-level slog calls. Bound-logger calls keep their existing .With
  bindings. The main.go and telemetry package conversions rode with their file
  in the previous commit to keep each file within a single commit.
- Enforcement (.golangci.yml): enable sloglint with context=scope, static-msg,
  key-naming-case=snake, no-mixed-args. After the sweep all four report zero
  violations repo-wide (tests included), so make lint / CI now blocks any
  regression to the non-context form. The gate ships with the sweep because it
  cannot be green until the legacy sites are converted.

Metrics remain on Prometheus; no behavior change to /metrics or Grafana.

Verification: go build ./..., go vet ./..., gofmt -l — clean; sloglint (all 4
rules) 0 violations repo-wide; log levels verified unchanged.

AI-use disclosure: implemented with AI assistance (Claude Code), including the
codemod; reviewed by the author.

* fix(observability): honor per-signal OTLP protocol and secret WithGroup names

Two Codex review findings on PR #290:

- telemetry: OTEL_EXPORTER_OTLP_{TRACES,LOGS}_PROTOCOL now override the
  generic OTEL_EXPORTER_OTLP_PROTOCOL per signal, so mixed collector
  setups (e.g. HTTP logs + gRPC traces) build the right exporter.
- logredact: entering a group whose name is secret-bearing (e.g.
  WithGroup("authorization")) now masks every leaf in that subtree,
  matching how slog.Group("authorization", ...) is masked as a whole.

* fix(observability): address review feedback on telemetry bootstrap

- Telemetry setup failure no longer kills boot: Setup returns usable
  no-op providers alongside the error and main logs and continues with
  telemetry disabled, honoring the best-effort contract.
- Honor OTEL_TRACES_SAMPLER (always_on/off, traceidratio, parentbased_*
  variants); unsupported values fall back to parentbased_traceidratio.
- Attach node identity as semconv service.instance.id instead of the
  non-semconv node.name.
- Rename opslog retention-scope log attrs to target_component/target_level
  so they no longer collide with the canonical component routing key, and
  tag those lines with component=opslog.
- Fix stale levelGated comment casing; use WarnContext in the telemetry
  shutdown defer; document the LogValuer double-resolve on the redaction
  slow path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 08:53:52 -04:00

636 lines
22 KiB
Go

package metadata
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"sort"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// InstallationEnabledChecker answers whether a plugin installation is enabled.
// It mirrors the structural-dependency style of pluginMetadataResolver /
// PluginResolverAdapter so the metadata package can consult the plugins
// service's in-memory installation cache without importing (and creating an
// import cycle with) the plugins package. *plugins.Service satisfies it via
// IsInstallationEnabled.
type InstallationEnabledChecker interface {
IsInstallationEnabled(ctx context.Context, installationID int) (bool, error)
}
// installationEnabled reports whether a plugin installation is enabled. When a
// checker is supplied it is served from the plugins service's cache (no DB
// read); when nil it falls back to a direct pool query so tests and non-plugin
// builds keep working unchanged.
func installationEnabled(
ctx context.Context,
checker InstallationEnabledChecker,
pool *pgxpool.Pool,
installationID int,
) (bool, error) {
if checker != nil {
return checker.IsInstallationEnabled(ctx, installationID)
}
var enabled bool
err := pool.QueryRow(ctx,
"SELECT enabled FROM plugin_installations WHERE id = $1",
installationID,
).Scan(&enabled)
return enabled, err
}
// ChainEntry represents a single entry in a library's provider chain.
type ChainEntry struct {
PluginInstallationID int
CapabilityID string
CapabilityType string // always "metadata_provider.v1" for now
ContentLevel string // "movie", "series", "season", "episode", or "" (legacy)
Priority int
Enabled bool
}
// ChainRepository provides operations for the library_provider_chains table.
type ChainRepository struct {
pool *pgxpool.Pool
}
// NewChainRepository creates a new ChainRepository.
func NewChainRepository(pool *pgxpool.Pool) *ChainRepository {
return &ChainRepository{pool: pool}
}
// Pool returns the underlying connection pool.
func (r *ChainRepository) Pool() *pgxpool.Pool {
return r.pool
}
// SetChain replaces the entire provider chain for a given media folder.
func (r *ChainRepository) SetChain(ctx context.Context, folderID int, entries []ChainEntry) error {
tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return fmt.Errorf("beginning chain transaction: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
_, err = tx.Exec(ctx, "DELETE FROM library_provider_chains WHERE media_folder_id = $1", folderID)
if err != nil {
return fmt.Errorf("deleting existing chain: %w", err)
}
for _, entry := range entries {
capType := entry.CapabilityType
if capType == "" {
capType = "metadata_provider.v1"
}
_, err = tx.Exec(ctx,
`INSERT INTO library_provider_chains (media_folder_id, plugin_installation_id, capability_id, capability_type, content_level, priority, enabled)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
folderID, entry.PluginInstallationID, entry.CapabilityID, capType, entry.ContentLevel, entry.Priority, entry.Enabled,
)
if err != nil {
return fmt.Errorf("inserting chain entry (install=%d, cap=%s, level=%s, priority=%d): %w",
entry.PluginInstallationID, entry.CapabilityID, entry.ContentLevel, entry.Priority, err)
}
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("committing chain transaction: %w", err)
}
return nil
}
// GetChain returns entries for a specific content level. If no per-level entries
// exist, falls back to legacy flat entries (content_level = ”).
func (r *ChainRepository) GetChain(ctx context.Context, folderID int, contentLevel string) ([]ChainEntry, error) {
rows, err := r.pool.Query(ctx,
`SELECT plugin_installation_id, capability_id, capability_type, content_level, priority, enabled
FROM library_provider_chains
WHERE media_folder_id = $1 AND content_level = $2
ORDER BY priority ASC`,
folderID, contentLevel,
)
if err != nil {
return nil, fmt.Errorf("querying chain: %w", err)
}
defer rows.Close()
var entries []ChainEntry
for rows.Next() {
var e ChainEntry
if err := rows.Scan(&e.PluginInstallationID, &e.CapabilityID, &e.CapabilityType, &e.ContentLevel, &e.Priority, &e.Enabled); err != nil {
return nil, fmt.Errorf("scanning chain entry: %w", err)
}
entries = append(entries, e)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating chain rows: %w", err)
}
// Fall back to legacy flat chain if no per-level entries exist.
if len(entries) == 0 && contentLevel != "" {
return r.GetChain(ctx, folderID, "")
}
if entries == nil {
entries = []ChainEntry{}
}
return entries, nil
}
// GetAllChainEntries returns every chain entry for a folder, across all content levels.
func (r *ChainRepository) GetAllChainEntries(ctx context.Context, folderID int) ([]ChainEntry, error) {
rows, err := r.pool.Query(ctx,
`SELECT plugin_installation_id, capability_id, capability_type, content_level, priority, enabled
FROM library_provider_chains
WHERE media_folder_id = $1
ORDER BY content_level, priority ASC`,
folderID,
)
if err != nil {
return nil, fmt.Errorf("querying all chain entries: %w", err)
}
defer rows.Close()
var entries []ChainEntry
for rows.Next() {
var e ChainEntry
if err := rows.Scan(&e.PluginInstallationID, &e.CapabilityID, &e.CapabilityType, &e.ContentLevel, &e.Priority, &e.Enabled); err != nil {
return nil, fmt.Errorf("scanning chain entry: %w", err)
}
entries = append(entries, e)
}
return entries, rows.Err()
}
// DeleteChain removes all provider chain entries for a given media folder.
func (r *ChainRepository) DeleteChain(ctx context.Context, folderID int) error {
_, err := r.pool.Exec(ctx, "DELETE FROM library_provider_chains WHERE media_folder_id = $1", folderID)
if err != nil {
return fmt.Errorf("deleting chain: %w", err)
}
return nil
}
// AppendProviderToAllChains adds a provider to every existing library chain
// (per content level) that it serves and doesn't already include it. The
// placement callback resolves the plugin's manifest intent for a content level:
// levels it does not support are skipped entirely, and it is appended enabled
// only when it declares the level and opts into default_enabled.
func (r *ChainRepository) AppendProviderToAllChains(
ctx context.Context,
pluginInstallationID int,
capabilityID string,
placement func(contentLevel string) SeedPlacement,
) error {
// Find every distinct (folder, level) pair that has chain entries.
rows, err := r.pool.Query(ctx,
`SELECT DISTINCT media_folder_id, content_level
FROM library_provider_chains`)
if err != nil {
return fmt.Errorf("listing chain groups: %w", err)
}
defer rows.Close()
type chainGroup struct {
folderID int
contentLevel string
}
var groups []chainGroup
for rows.Next() {
var g chainGroup
if err := rows.Scan(&g.folderID, &g.contentLevel); err != nil {
return fmt.Errorf("scanning chain group: %w", err)
}
groups = append(groups, g)
}
if err := rows.Err(); err != nil {
return fmt.Errorf("iterating chain groups: %w", err)
}
for _, g := range groups {
// Only attach the provider to levels it actually serves. A single-purpose
// provider (e.g. audiobook/ebook/manga metadata, or a sports provider that
// only declares series levels) must not clutter every library's chain with
// a disabled row for content it cannot handle.
p := placement(g.contentLevel)
if !p.SupportsLevel {
continue
}
// Check if the provider is already in this chain.
var exists bool
err := r.pool.QueryRow(ctx,
`SELECT EXISTS(
SELECT 1 FROM library_provider_chains
WHERE media_folder_id = $1 AND content_level = $2
AND plugin_installation_id = $3 AND capability_id = $4
)`, g.folderID, g.contentLevel, pluginInstallationID, capabilityID,
).Scan(&exists)
if err != nil {
return fmt.Errorf("checking chain membership: %w", err)
}
if exists {
continue
}
// Determine priority position (append after the last entry).
var maxPriority int
err = r.pool.QueryRow(ctx,
`SELECT COALESCE(MAX(priority), -1)
FROM library_provider_chains
WHERE media_folder_id = $1 AND content_level = $2`,
g.folderID, g.contentLevel,
).Scan(&maxPriority)
if err != nil {
return fmt.Errorf("getting max priority: %w", err)
}
// A provider joins an existing library disabled unless it declares this
// level and opts into being enabled by default. This keeps a specialist
// (default_enabled=false) one click away instead of silently taking over
// established libraries the moment it is installed.
enabled := p.DefaultPriority > 0 && p.DefaultEnabled
_, err = r.pool.Exec(ctx,
`INSERT INTO library_provider_chains (media_folder_id, plugin_installation_id, capability_id, capability_type, content_level, priority, enabled)
VALUES ($1, $2, $3, 'metadata_provider.v1', $4, $5, $6)
ON CONFLICT DO NOTHING`,
g.folderID, pluginInstallationID, capabilityID, g.contentLevel, maxPriority+1, enabled,
)
if err != nil {
return fmt.Errorf("appending provider (install=%d, cap=%s) to folder %d level %q: %w",
pluginInstallationID, capabilityID, g.folderID, g.contentLevel, err)
}
}
return nil
}
// ResolveChain builds the ordered list of Provider implementations for a given
// media folder and content level. If the folder has custom chain entries, those
// are used. Otherwise all enabled metadata provider capabilities are used,
// ordered by their plugin manifest default_priority for the given content level.
//
// Providers whose underlying plugin installation is disabled are silently skipped.
//
// The enabled-check falls back to a direct pool query. Callers on the hot path
// (MetadataService.resolveChainCached) use ResolveChainWithChecker to serve it
// from the plugins service's in-memory installation cache instead.
func ResolveChain(
ctx context.Context,
folderID int,
contentLevel string,
chainRepo *ChainRepository,
resolver pluginMetadataResolver,
) ([]Provider, error) {
return ResolveChainWithChecker(ctx, folderID, contentLevel, chainRepo, resolver, nil)
}
// ResolveChainWithChecker is ResolveChain with an optional
// InstallationEnabledChecker threaded through the enabled-check. A nil checker
// preserves the direct pool-query behavior, so existing callers and tests are
// unaffected.
func ResolveChainWithChecker(
ctx context.Context,
folderID int,
contentLevel string,
chainRepo *ChainRepository,
resolver pluginMetadataResolver,
checker InstallationEnabledChecker,
) ([]Provider, error) {
chainEntries, err := chainRepo.GetChain(ctx, folderID, contentLevel)
if err != nil {
return nil, fmt.Errorf("getting chain for folder %d: %w", folderID, err)
}
// Filter to only enabled entries.
var enabledEntries []ChainEntry
for _, e := range chainEntries {
if e.Enabled {
enabledEntries = append(enabledEntries, e)
}
}
chainEntries = enabledEntries
if len(chainEntries) > 0 {
return resolveChainEntries(ctx, chainEntries, resolver, chainRepo.pool, checker), nil
}
providers, err := resolveEnabledProvidersByPriority(ctx, contentLevel, resolver, chainRepo.pool, checker)
if err != nil {
return nil, err
}
return providers, nil
}
// CapabilityInfo holds the fields needed to construct a provider from plugin tables.
type CapabilityInfo struct {
PluginInstallationID int
CapabilityID string
DisplayName string
}
// resolveEnabledProviders returns all enabled providers in installation ID order.
// Used by callers that don't have a content-level context (e.g. person refresh).
func resolveEnabledProviders(
ctx context.Context,
resolver pluginMetadataResolver,
pool *pgxpool.Pool,
checker InstallationEnabledChecker,
) ([]Provider, error) {
caps, err := ListEnabledMetadataCapabilities(ctx, pool)
if err != nil {
return nil, err
}
return buildProviders(ctx, caps, resolver, pool, checker), nil
}
// resolveEnabledProvidersByPriority returns all enabled providers sorted by
// their plugin manifest default_priority for the given content level. Providers
// without a declared priority are placed last (sorted by installation ID as a tiebreaker).
func resolveEnabledProvidersByPriority(
ctx context.Context,
contentLevel string,
resolver pluginMetadataResolver,
pool *pgxpool.Pool,
checker InstallationEnabledChecker,
) ([]Provider, error) {
caps, err := ListEnabledMetadataCapabilities(ctx, pool)
if err != nil {
return nil, err
}
type ranked struct {
cap CapabilityInfo
priority int
}
// Only providers that support contentLevel participate in the chain-less
// fallback. A provider declaring a non-empty default_priority map that omits
// this level is excluded outright (not merely ranked last), so a
// single-purpose provider is never invoked for content it does not handle.
items := make([]ranked, 0, len(caps))
for _, c := range caps {
metadataJSON := lookupCapabilityMetadata(ctx, pool, c.PluginInstallationID, c.CapabilityID)
if !providerSupportsLevel(metadataJSON, contentLevel) {
slog.DebugContext(ctx, "skipping metadata provider: does not declare support for content level", "component", "metadata",
"installation_id", c.PluginInstallationID,
"capability_id", c.CapabilityID,
"content_level", contentLevel)
continue
}
items = append(items, ranked{cap: c, priority: extractDefaultPriority(metadataJSON, contentLevel)})
}
sort.SliceStable(items, func(i, j int) bool {
pi, pj := items[i].priority, items[j].priority
if (pi == 0) != (pj == 0) {
return pi != 0
}
if pi != pj {
return pi < pj
}
return items[i].cap.PluginInstallationID < items[j].cap.PluginInstallationID
})
sorted := make([]CapabilityInfo, len(items))
for i, item := range items {
sorted[i] = item.cap
}
return buildProviders(ctx, sorted, resolver, pool, checker), nil
}
// providerSupportsLevel reports whether a metadata provider should participate
// in the chain-less global fallback for contentLevel. A provider that declares
// a non-empty default_priority map is treated as enumerating the content levels
// it supports: it is eligible only for levels present in that map with a
// positive priority. A provider that declares no default_priority makes no
// claim and stays eligible for every level (legacy behavior), ranked last.
//
// This is what keeps a single-purpose provider (e.g. an audiobook metadata
// provider declaring only {"audiobook": N}) from being pulled into video
// content levels when a library has no enabled chain entry for that level.
func providerSupportsLevel(metadataJSON []byte, contentLevel string) bool {
levels, declared := declaredPriorityLevels(metadataJSON)
if !declared {
return true
}
return levels[contentLevel] > 0
}
// lookupCapabilityMetadata returns the raw plugin_capabilities.metadata JSON for
// a provider's exact metadata_provider.v1 capability, or nil if absent.
func lookupCapabilityMetadata(ctx context.Context, pool *pgxpool.Pool, pluginInstallationID int, capabilityID string) []byte {
var metadataJSON []byte
err := pool.QueryRow(ctx,
`SELECT metadata FROM plugin_capabilities
WHERE plugin_installation_id = $1
AND capability_id = $2
AND capability_type = 'metadata_provider.v1'`,
pluginInstallationID,
capabilityID,
).Scan(&metadataJSON)
if err != nil {
return nil
}
return metadataJSON
}
// LookupDefaultPriority queries plugin_capabilities for a provider's declared
// default_priority at the given content level. Returns 0 if not found.
func LookupDefaultPriority(ctx context.Context, pool *pgxpool.Pool, pluginInstallationID int, capabilityID, contentLevel string) int {
return extractDefaultPriority(lookupCapabilityMetadata(ctx, pool, pluginInstallationID, capabilityID), contentLevel)
}
// extractDefaultPriority parses the default_priority for a content level from
// capability metadata JSON.
func extractDefaultPriority(metadataJSON []byte, contentLevel string) int {
levels, _ := declaredPriorityLevels(metadataJSON)
if v, ok := levels[contentLevel]; ok && v > 0 {
return int(v)
}
return 0
}
// extractDefaultEnabled reports whether a provider should be enabled by default
// when a chain is first seeded for a new library. It defaults to true when the
// flag is absent or unparseable, so every plugin predating this flag keeps its
// original seeded-enabled behavior. A specialist provider (e.g. a sports
// metadata source that should not compete with general providers on every
// library) sets metadata.default_enabled=false to be seeded installed-but-off,
// leaving it one click away for users who want it. The flag lives in the same
// "metadata" envelope as default_priority.
func extractDefaultEnabled(metadataJSON []byte) bool {
var meta map[string]json.RawMessage
if err := json.Unmarshal(metadataJSON, &meta); err != nil {
return true
}
raw, ok := meta["default_enabled"]
if !ok {
if innerRaw, innerOK := meta["metadata"]; innerOK {
var inner map[string]json.RawMessage
if err := json.Unmarshal(innerRaw, &inner); err == nil {
raw, ok = inner["default_enabled"]
}
}
}
if !ok {
return true
}
var enabled bool
if err := json.Unmarshal(raw, &enabled); err != nil {
return true
}
return enabled
}
// SeedPlacement captures how a provider's manifest wants it placed when a chain
// is first seeded for a content level: whether it handles the level at all, its
// declared priority, and whether it should be enabled by default.
type SeedPlacement struct {
SupportsLevel bool
DefaultPriority int
DefaultEnabled bool
}
// LookupSeedPlacement resolves a provider's seed placement for one content level
// from its capability manifest with a single metadata fetch. SupportsLevel uses
// the same rule as the chain-less fallback (providerSupportsLevel): a provider
// that enumerates levels is eligible only for the ones it lists; a provider that
// declares none stays eligible everywhere.
func LookupSeedPlacement(ctx context.Context, pool *pgxpool.Pool, pluginInstallationID int, capabilityID, contentLevel string) SeedPlacement {
md := lookupCapabilityMetadata(ctx, pool, pluginInstallationID, capabilityID)
return SeedPlacement{
SupportsLevel: providerSupportsLevel(md, contentLevel),
DefaultPriority: extractDefaultPriority(md, contentLevel),
DefaultEnabled: extractDefaultEnabled(md),
}
}
// declaredPriorityLevels parses a capability's default_priority map. The map may
// sit at the top level or inside a "metadata" envelope (plugin capability
// metadata wraps plugin-declared fields in a "metadata" sub-object). The second
// return value is true only when a non-empty map was found, i.e. the provider
// explicitly enumerates the content levels it supports.
func declaredPriorityLevels(metadataJSON []byte) (map[string]float64, bool) {
var meta map[string]json.RawMessage
if err := json.Unmarshal(metadataJSON, &meta); err != nil {
return nil, false
}
dpRaw, ok := meta["default_priority"]
if !ok {
if innerRaw, innerOK := meta["metadata"]; innerOK {
var inner map[string]json.RawMessage
if err := json.Unmarshal(innerRaw, &inner); err == nil {
dpRaw, ok = inner["default_priority"]
}
}
}
if !ok {
return nil, false
}
var dpMap map[string]float64
if err := json.Unmarshal(dpRaw, &dpMap); err != nil {
return nil, false
}
return dpMap, len(dpMap) > 0
}
// ListEnabledMetadataCapabilities returns all metadata_provider.v1 capabilities
// whose plugin installation is enabled.
func ListEnabledMetadataCapabilities(ctx context.Context, pool *pgxpool.Pool) ([]CapabilityInfo, error) {
rows, err := pool.Query(ctx,
`SELECT pc.plugin_installation_id, pc.capability_id,
COALESCE(pc.metadata->>'display_name', pc.capability_id)
FROM plugin_capabilities pc
JOIN plugin_installations pi ON pi.id = pc.plugin_installation_id
WHERE pc.capability_type = 'metadata_provider.v1'
AND pi.enabled = true
ORDER BY pc.plugin_installation_id`)
if err != nil {
return nil, fmt.Errorf("listing enabled metadata capabilities: %w", err)
}
defer rows.Close()
var caps []CapabilityInfo
for rows.Next() {
var c CapabilityInfo
if err := rows.Scan(&c.PluginInstallationID, &c.CapabilityID, &c.DisplayName); err != nil {
return nil, fmt.Errorf("scanning capability: %w", err)
}
caps = append(caps, c)
}
return caps, rows.Err()
}
// resolveChainEntries builds Provider instances from explicit chain entries,
// skipping providers whose plugin installation is disabled.
func resolveChainEntries(
ctx context.Context,
entries []ChainEntry,
resolver pluginMetadataResolver,
pool *pgxpool.Pool,
checker InstallationEnabledChecker,
) []Provider {
caps := make([]CapabilityInfo, 0, len(entries))
for _, e := range entries {
displayName := lookupCapabilityDisplayName(ctx, pool, e.PluginInstallationID, e.CapabilityID)
caps = append(caps, CapabilityInfo{
PluginInstallationID: e.PluginInstallationID,
CapabilityID: e.CapabilityID,
DisplayName: displayName,
})
}
return buildProviders(ctx, caps, resolver, pool, checker)
}
// buildProviders constructs Provider instances from capability info, skipping
// providers whose plugin installation is disabled.
func buildProviders(
ctx context.Context,
caps []CapabilityInfo,
resolver pluginMetadataResolver,
pool *pgxpool.Pool,
checker InstallationEnabledChecker,
) []Provider {
providers := make([]Provider, 0, len(caps))
for _, c := range caps {
enabled, err := installationEnabled(ctx, checker, pool, c.PluginInstallationID)
if err != nil || !enabled {
slog.DebugContext(ctx, "skipping metadata provider: plugin installation disabled", "component", "metadata",
"installation_id", c.PluginInstallationID, "capability_id", c.CapabilityID)
continue
}
provider, err := NewPluginProviderFromCapability(c.PluginInstallationID, c.CapabilityID, c.DisplayName, resolver)
if err != nil {
slog.WarnContext(ctx, "skipping metadata provider during chain resolution", "component", "metadata",
"installation_id", c.PluginInstallationID,
"capability_id", c.CapabilityID,
"error", err,
)
continue
}
providers = append(providers, provider)
}
return providers
}
// lookupCapabilityDisplayName retrieves the display name from plugin capability metadata.
func lookupCapabilityDisplayName(ctx context.Context, pool *pgxpool.Pool, installationID int, capabilityID string) string {
var displayName string
err := pool.QueryRow(ctx,
`SELECT COALESCE(metadata->>'display_name', $2)
FROM plugin_capabilities
WHERE plugin_installation_id = $1 AND capability_id = $2 AND capability_type = 'metadata_provider.v1'`,
installationID, capabilityID,
).Scan(&displayName)
if err != nil {
return capabilityID
}
return displayName
}