322 lines
11 KiB
Go
322 lines
11 KiB
Go
package plugins
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/kandev/kandev/internal/mentions"
|
|
"github.com/kandev/kandev/internal/plugins/manifest"
|
|
"github.com/kandev/kandev/internal/plugins/store"
|
|
apiv1 "github.com/kandev/kandev/pkg/api/v1"
|
|
"github.com/kandev/kandev/pkg/pluginsdk"
|
|
)
|
|
|
|
const (
|
|
referenceAuthorizationTimeout = 1500 * time.Millisecond
|
|
entityReferenceProviderField = "provider"
|
|
)
|
|
|
|
// MentionSourceClient is the live plugin-service surface needed to materialize
|
|
// manifest-declared composer sources.
|
|
type MentionSourceClient interface {
|
|
List() []*store.Record
|
|
SearchEntityReferences(
|
|
context.Context, string, pluginDispatchGeneration, *pluginsdk.SearchEntityReferencesRequest,
|
|
) (*pluginsdk.SearchEntityReferencesResponse, error)
|
|
AuthorizeEntityReference(
|
|
context.Context, string, pluginDispatchGeneration, *pluginsdk.AuthorizeEntityReferenceRequest,
|
|
) (*pluginsdk.AuthorizeEntityReferenceResponse, error)
|
|
}
|
|
|
|
// MentionSourceBridge materializes active manifest-owned reference sources
|
|
// into mentions.Registry. It never accepts plugin-provided descriptor identity.
|
|
type MentionSourceBridge struct {
|
|
mu sync.Mutex
|
|
client MentionSourceClient
|
|
registry *mentions.Registry
|
|
owners map[string]struct{}
|
|
fingerprints map[string]string
|
|
}
|
|
|
|
func NewMentionSourceBridge(client MentionSourceClient) *MentionSourceBridge {
|
|
return &MentionSourceBridge{
|
|
client: client, owners: make(map[string]struct{}), fingerprints: make(map[string]string),
|
|
}
|
|
}
|
|
|
|
// Descriptor and Search make the bridge transport-compatible with the
|
|
// existing bootstrap list. backendapp recognizes SourceRegistrar first, so
|
|
// this placeholder is never itself a searchable source.
|
|
func (*MentionSourceBridge) Descriptor() mentions.ProviderDescriptor {
|
|
return mentions.ProviderDescriptor{}
|
|
}
|
|
|
|
func (*MentionSourceBridge) Search(context.Context, mentions.SearchRequest) ([]mentions.Candidate, error) {
|
|
return nil, errors.New("plugin mention source bridge is not directly searchable")
|
|
}
|
|
|
|
// RegisterMentionSources installs this bridge as a live registry refresher
|
|
// and materializes the current active-plugin snapshot.
|
|
func (b *MentionSourceBridge) RegisterMentionSources(registry *mentions.Registry) error {
|
|
if b == nil || registry == nil {
|
|
return errors.New("plugin mention source registry is unavailable")
|
|
}
|
|
b.mu.Lock()
|
|
b.registry = registry
|
|
b.mu.Unlock()
|
|
registry.RegisterSourceRefresher(b)
|
|
return b.Sync()
|
|
}
|
|
|
|
// RefreshMentionSources satisfies mentions.SourceRefresher. Registry reads
|
|
// cannot surface refresh errors, so Sync keeps the prior owner set intact on
|
|
// a collision and authorization remains fail-closed.
|
|
func (b *MentionSourceBridge) RefreshMentionSources() {
|
|
_ = b.Sync()
|
|
}
|
|
|
|
// Sync replaces every plugin-owned source from the current active manifest
|
|
// snapshot. Sources disappear when their plugin is disabled or uninstalled.
|
|
func (b *MentionSourceBridge) Sync() error {
|
|
if b == nil || b.client == nil {
|
|
return errors.New("plugin mention source client is unavailable")
|
|
}
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
if b.registry == nil {
|
|
return errors.New("plugin mention source registry is unavailable")
|
|
}
|
|
return b.syncLocked(activeMentionSourceProviders(b.client, b.client.List()))
|
|
}
|
|
|
|
func (b *MentionSourceBridge) syncLocked(next map[string][]mentions.MentionProvider) error {
|
|
var errs []error
|
|
// Release departed owners first. Otherwise a successor whose ID sorts
|
|
// before the disabled predecessor collides on the first refresh and the
|
|
// newly active source stays invisible until a second request.
|
|
for _, owner := range departedMentionSourceOwners(b.owners, next) {
|
|
if err := b.registry.ReplaceOwner(owner); err != nil {
|
|
errs = append(errs, fmt.Errorf("plugin %q reference sources: %w", owner, err))
|
|
continue
|
|
}
|
|
delete(b.owners, owner)
|
|
delete(b.fingerprints, owner)
|
|
}
|
|
for _, owner := range sortedMentionSourceOwners(next) {
|
|
providers := next[owner]
|
|
fingerprint := mentionSourceFingerprint(providers)
|
|
if _, exists := b.owners[owner]; exists && b.fingerprints[owner] == fingerprint {
|
|
continue
|
|
}
|
|
if err := b.registry.ReplaceOwner(owner, providers...); err != nil {
|
|
errs = append(errs, fmt.Errorf("plugin %q reference sources: %w", owner, err))
|
|
continue
|
|
}
|
|
b.owners[owner] = struct{}{}
|
|
b.fingerprints[owner] = fingerprint
|
|
}
|
|
return errors.Join(errs...)
|
|
}
|
|
|
|
func activeMentionSourceProviders(client MentionSourceClient, records []*store.Record) map[string][]mentions.MentionProvider {
|
|
providers := make(map[string][]mentions.MentionProvider)
|
|
for _, record := range records {
|
|
if record == nil || record.Status != StatusActive || len(record.ReferenceSources) == 0 {
|
|
continue
|
|
}
|
|
for _, source := range record.ReferenceSources {
|
|
providers[record.ID] = append(providers[record.ID], pluginMentionProvider{
|
|
client: client,
|
|
pluginID: record.ID,
|
|
generation: dispatchGeneration(record),
|
|
descriptor: source,
|
|
})
|
|
}
|
|
}
|
|
return providers
|
|
}
|
|
|
|
func departedMentionSourceOwners(
|
|
current map[string]struct{}, next map[string][]mentions.MentionProvider,
|
|
) []string {
|
|
owners := make([]string, 0, len(current))
|
|
for owner := range current {
|
|
if _, retained := next[owner]; retained {
|
|
continue
|
|
}
|
|
owners = append(owners, owner)
|
|
}
|
|
sort.Strings(owners)
|
|
return owners
|
|
}
|
|
|
|
func sortedMentionSourceOwners(next map[string][]mentions.MentionProvider) []string {
|
|
owners := make([]string, 0, len(next))
|
|
for owner := range next {
|
|
owners = append(owners, owner)
|
|
}
|
|
sort.Strings(owners)
|
|
return owners
|
|
}
|
|
|
|
func mentionSourceFingerprint(providers []mentions.MentionProvider) string {
|
|
var result strings.Builder
|
|
for _, provider := range providers {
|
|
descriptor := provider.Descriptor()
|
|
generation := pluginDispatchGeneration{}
|
|
if pluginProvider, ok := provider.(pluginMentionProvider); ok {
|
|
generation = pluginProvider.generation
|
|
}
|
|
_, _ = fmt.Fprintf(
|
|
&result,
|
|
"%q\x00%q\x00%q\x00%q\x00%q\x00%d\x00%q\x00%q\x00%d\x00",
|
|
descriptor.Source, descriptor.Provider, descriptor.Kind,
|
|
descriptor.DisplayName, descriptor.KindLabel, descriptor.Order,
|
|
generation.version, generation.installPath, generation.installedAt.UnixNano(),
|
|
)
|
|
}
|
|
return result.String()
|
|
}
|
|
|
|
type pluginMentionProvider struct {
|
|
client MentionSourceClient
|
|
pluginID string
|
|
generation pluginDispatchGeneration
|
|
descriptor manifest.ReferenceSource
|
|
}
|
|
|
|
func (p pluginMentionProvider) Descriptor() mentions.ProviderDescriptor {
|
|
return mentions.ProviderDescriptor{
|
|
Source: p.descriptor.Source, Provider: p.descriptor.Provider, Kind: p.descriptor.Kind,
|
|
DisplayName: p.descriptor.DisplayName, KindLabel: p.descriptor.KindLabel, Order: p.descriptor.Order,
|
|
}
|
|
}
|
|
|
|
func (p pluginMentionProvider) Search(
|
|
ctx context.Context, request mentions.SearchRequest,
|
|
) ([]mentions.Candidate, error) {
|
|
response, err := p.client.SearchEntityReferences(ctx, p.pluginID, p.generation, &pluginsdk.SearchEntityReferencesRequest{
|
|
Source: p.descriptor.Source, WorkspaceID: request.WorkspaceID, Query: request.Query, Limit: int32(request.Limit),
|
|
})
|
|
if err != nil || response == nil {
|
|
return nil, err
|
|
}
|
|
return hostCandidates(response.Candidates, request.WorkspaceID), nil
|
|
}
|
|
|
|
func hostCandidates(candidates []pluginsdk.EntityReferenceCandidate, workspaceID string) []mentions.Candidate {
|
|
result := make([]mentions.Candidate, 0, len(candidates))
|
|
for _, candidate := range candidates {
|
|
result = append(result, mentions.Candidate{
|
|
ID: candidate.ProviderLocalID, Key: candidate.ProviderLocalID, Title: candidate.Title,
|
|
URL: candidate.URL, Scope: workspaceID,
|
|
})
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (p pluginMentionProvider) AuthorizeReference(
|
|
ctx context.Context, request mentions.ReferenceAuthorizationRequest,
|
|
) error {
|
|
if !p.matchesReference(request) {
|
|
return mentions.ErrReferenceUnauthorized
|
|
}
|
|
ctx, cancel := context.WithTimeout(ctx, referenceAuthorizationTimeout)
|
|
defer cancel()
|
|
response, err := p.client.AuthorizeEntityReference(ctx, p.pluginID, p.generation, &pluginsdk.AuthorizeEntityReferenceRequest{
|
|
Source: p.descriptor.Source, WorkspaceID: request.WorkspaceID, Purpose: string(request.Purpose),
|
|
Reference: pluginEntityReference(request.Reference),
|
|
})
|
|
if err != nil || response == nil || !response.Allowed {
|
|
return mentions.ErrReferenceUnauthorized
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p pluginMentionProvider) matchesReference(request mentions.ReferenceAuthorizationRequest) bool {
|
|
return request.Reference.Provider == p.descriptor.Provider &&
|
|
request.Reference.Kind == p.descriptor.Kind && request.Reference.Scope == request.WorkspaceID
|
|
}
|
|
|
|
func pluginEntityReference(reference apiv1.EntityReference) map[string]any {
|
|
return map[string]any{
|
|
"version": reference.Version,
|
|
"ref": reference.Ref,
|
|
entityReferenceProviderField: reference.Provider,
|
|
"kind": reference.Kind,
|
|
"id": reference.ID,
|
|
"key": reference.Key,
|
|
"title": reference.Title,
|
|
"url": reference.URL,
|
|
"scope": reference.Scope,
|
|
}
|
|
}
|
|
|
|
// SearchEntityReferences routes one validated manifest source to its live
|
|
// plugin process. The bridge also checks active status before every RPC so a
|
|
// disable race cannot return or authorize a reference.
|
|
func (s *Service) SearchEntityReferences(
|
|
ctx context.Context, id string, expected pluginDispatchGeneration, request *pluginsdk.SearchEntityReferencesRequest,
|
|
) (*pluginsdk.SearchEntityReferencesResponse, error) {
|
|
if request == nil {
|
|
return nil, errors.New("plugins: missing entity reference request")
|
|
}
|
|
_, release, err := s.beginPluginDispatch(id, expected)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer release()
|
|
if err := s.requireActiveReferenceSource(id, request.Source); err != nil {
|
|
return nil, err
|
|
}
|
|
remote, ok := s.pluginRemote(id)
|
|
if !ok || remote == nil {
|
|
return nil, fmt.Errorf("plugins: plugin %q is not running", id)
|
|
}
|
|
return remote.SearchEntityReferences(ctx, request)
|
|
}
|
|
|
|
// AuthorizeEntityReference routes submit-time authorization for one validated
|
|
// manifest source to its live plugin process.
|
|
func (s *Service) AuthorizeEntityReference(
|
|
ctx context.Context, id string, expected pluginDispatchGeneration, request *pluginsdk.AuthorizeEntityReferenceRequest,
|
|
) (*pluginsdk.AuthorizeEntityReferenceResponse, error) {
|
|
if request == nil {
|
|
return nil, errors.New("plugins: missing entity reference request")
|
|
}
|
|
_, release, err := s.beginPluginDispatch(id, expected)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer release()
|
|
if err := s.requireActiveReferenceSource(id, request.Source); err != nil {
|
|
return nil, err
|
|
}
|
|
remote, ok := s.pluginRemote(id)
|
|
if !ok || remote == nil {
|
|
return nil, fmt.Errorf("plugins: plugin %q is not running", id)
|
|
}
|
|
return remote.AuthorizeEntityReference(ctx, request)
|
|
}
|
|
|
|
func (s *Service) requireActiveReferenceSource(id, sourceName string) error {
|
|
record, err := s.Get(id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if record.Status != StatusActive {
|
|
return fmt.Errorf("plugins: plugin %q is not active", id)
|
|
}
|
|
for _, source := range record.ReferenceSources {
|
|
if source.Source == sourceName {
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("plugins: plugin %q does not declare reference source %q", id, sourceName)
|
|
}
|