Files
silo-server/internal/plugins/service_admin_config.go
T
22fec4ed2d feat(metadata): add resilient match queue diagnostics (#463)
* feat(metadata): add resilient match queue diagnostics

* fix(metadata): harden match queue lifecycle

---------

Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
2026-07-24 12:18:49 -04:00

364 lines
9.9 KiB
Go

package plugins
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
"github.com/Silo-Server/silo-server/internal/pluginhost"
)
// ConfigValidationError indicates that the caller supplied an invalid key or
// a value that did not match the plugin's manifest global_config_schema.
// Callers (the admin HTTP handler) should map this to a 400-equivalent
// response.
type ConfigValidationError struct {
Message string
Cause error
}
func (e *ConfigValidationError) Error() string { return e.Message }
func (e *ConfigValidationError) Unwrap() error { return e.Cause }
// SetGlobalConfig validates a single global-config entry against the plugin's
// manifest schema, persists it, and stops the running plugin so the next
// invocation rebinds with the new configuration. Used by the admin HTTP
// handler at PUT /api/admin/plugins/installations/{id}/config.
func (s *Service) SetGlobalConfig(
ctx context.Context,
installationID int,
key string,
value map[string]any,
) error {
return s.SetGlobalConfigWithClears(ctx, installationID, key, value, nil)
}
// SetGlobalConfigWithClears saves one config entry while preserving redacted
// secret fields the browser left blank. A secret is removed only when its key
// appears in clearSecrets, making credential deletion an explicit action.
func (s *Service) SetGlobalConfigWithClears(
ctx context.Context,
installationID int,
key string,
value map[string]any,
clearSecrets []string,
) error {
key = strings.TrimSpace(key)
if key == "" {
return &ConfigValidationError{Message: "config key is required"}
}
if s.configs == nil {
return fmt.Errorf("plugin config store not configured")
}
_, manifest, err := s.ensureInstallationCache(ctx, installationID, false)
if err != nil {
return err
}
if value == nil {
value = map[string]any{}
}
secretFields := GlobalConfigSecretFields(manifest, key)
secretPaths := GlobalConfigSecretPaths(manifest, key)
clearSet, err := validatedSecretClearSet(key, secretFields, clearSecrets)
if err != nil {
return &ConfigValidationError{Message: err.Error(), Cause: err}
}
const maxConfigSaveAttempts = 5
saved := false
for attempt := 0; attempt < maxConfigSaveAttempts; attempt++ {
merged, expectedUpdatedAt, err := s.mergeStoredConfig(
ctx,
installationID,
key,
value,
secretPaths,
)
if err != nil {
return err
}
for field := range clearSet {
delete(merged, field)
}
projection := globalConfigValidationProjection(manifest, key, merged, value)
if err := ValidateGlobalConfigValue(manifest, key, projection); err != nil {
return &ConfigValidationError{Message: err.Error(), Cause: err}
}
swapped, err := s.configs.CompareAndSwapGlobalConfig(
ctx,
installationID,
key,
merged,
expectedUpdatedAt,
)
if err != nil {
return fmt.Errorf("persist plugin config: %w", err)
}
if swapped {
saved = true
break
}
}
if !saved {
return fmt.Errorf("persist plugin config: concurrent updates did not settle")
}
var stopErr error
if s.host != nil {
if err := s.host.Stop(installationID); err != nil && !errors.Is(err, pluginhost.ErrClientNotFound) {
stopErr = fmt.Errorf("reload plugin after config update: %w", err)
}
}
// Configuration is part of metadata match input identity. Notify hooks
// after the old process has been stopped so resolver reloads observe the new
// runtime. The durable config changed even when stopping failed, so hooks
// still run before returning that error and parked rows are not left asleep.
s.OnLifecycleChange(ctx)
return stopErr
}
func validatedSecretClearSet(
configKey string,
secretFields []string,
clearSecrets []string,
) (map[string]struct{}, error) {
clearSet := secretFieldSet(clearSecrets)
allowedSecrets := secretFieldSet(secretFields)
for field := range clearSet {
if _, ok := allowedSecrets[field]; !ok {
return nil, fmt.Errorf("%s is not a secret field in config %s", field, configKey)
}
}
return clearSet, nil
}
func (s *Service) preserveStoredSecrets(
ctx context.Context,
installationID int,
key string,
value map[string]any,
secretPaths [][]string,
) (map[string]any, error) {
merged, _, err := s.mergeStoredConfig(ctx, installationID, key, value, secretPaths)
return merged, err
}
func (s *Service) mergeStoredConfig(
ctx context.Context,
installationID int,
key string,
value map[string]any,
secretPaths [][]string,
) (map[string]any, *time.Time, error) {
if s.configs == nil {
if len(secretPaths) > 0 {
return nil, nil, fmt.Errorf("plugin config store not configured")
}
merged := make(map[string]any, len(value))
for field, incoming := range value {
merged[field] = cloneConfigValue(incoming)
}
return merged, nil, nil
}
configs, err := s.configs.ListGlobalConfigs(ctx, installationID)
if err != nil {
return nil, nil, fmt.Errorf("load existing plugin config: %w", err)
}
var existing map[string]any
var expectedUpdatedAt *time.Time
for _, config := range configs {
if config != nil && config.Key == key {
existing = config.Value
updatedAt := config.UpdatedAt
expectedUpdatedAt = &updatedAt
break
}
}
merged := make(map[string]any, len(existing)+len(value))
for field, saved := range existing {
merged[field] = cloneConfigValue(saved)
}
for field, incoming := range value {
merged[field] = cloneConfigValue(incoming)
}
if len(secretPaths) == 0 {
return merged, expectedUpdatedAt, nil
}
pathsByField := make(map[string][][]string)
for _, path := range secretPaths {
if len(path) == 0 {
continue
}
pathsByField[path[0]] = append(pathsByField[path[0]], path[1:])
}
for field, nestedSecretPaths := range pathsByField {
incoming, present := value[field]
saved, savedPresent := existing[field]
if !present {
continue
}
incomingObject, incomingIsObject := incoming.(map[string]any)
savedObject, savedIsObject := saved.(map[string]any)
if savedPresent && incomingIsObject && savedIsObject {
merged[field] = mergeConfigObjects(savedObject, incomingObject, nestedSecretPaths)
continue
}
incomingString, isString := incoming.(string)
if !isString || strings.TrimSpace(incomingString) != "" {
continue
}
if savedPresent {
merged[field] = cloneConfigValue(saved)
}
}
return merged, expectedUpdatedAt, nil
}
func globalConfigValidationProjection(
manifest *pluginv1.PluginManifest,
key string,
merged map[string]any,
submitted map[string]any,
) map[string]any {
projection := make(map[string]any)
schema := globalConfigSchema(manifest, key)
if schema != nil {
var document any
if json.Unmarshal([]byte(schema.GetJsonSchema()), &document) == nil {
projection = projectDeclaredConfigObject(
merged,
[]any{document},
document,
)
}
}
overlaySubmittedConfig(projection, submitted, merged)
return projection
}
func projectDeclaredConfigObject(
value map[string]any,
schemaNodes []any,
root any,
) map[string]any {
properties := make(map[string][]any)
for _, node := range schemaNodes {
collectTopLevelSchemaProperties(node, root, make(map[string]bool), properties)
}
projected := make(map[string]any, len(properties))
for field, fieldSchemas := range properties {
saved, present := value[field]
if !present {
continue
}
savedObject, isObject := saved.(map[string]any)
if isObject {
projected[field] = projectDeclaredConfigObject(savedObject, fieldSchemas, root)
continue
}
projected[field] = cloneConfigValue(saved)
}
return projected
}
func overlaySubmittedConfig(
projection map[string]any,
submitted map[string]any,
merged map[string]any,
) {
for field, submittedValue := range submitted {
mergedValue, present := merged[field]
if !present {
delete(projection, field)
continue
}
submittedObject, submittedIsObject := submittedValue.(map[string]any)
mergedObject, mergedIsObject := mergedValue.(map[string]any)
if submittedIsObject && mergedIsObject {
projectedObject, _ := projection[field].(map[string]any)
if projectedObject == nil {
projectedObject = make(map[string]any)
}
overlaySubmittedConfig(projectedObject, submittedObject, mergedObject)
projection[field] = projectedObject
continue
}
projection[field] = cloneConfigValue(mergedValue)
}
}
func mergeConfigObjects(
saved map[string]any,
incoming map[string]any,
secretPaths [][]string,
) map[string]any {
merged := make(map[string]any, len(saved)+len(incoming))
for key, value := range saved {
merged[key] = cloneConfigValue(value)
}
for key, value := range incoming {
nestedSecretPaths, wholeValueSecret := childSecretPaths(secretPaths, key)
if text, isString := value.(string); wholeValueSecret &&
isString && strings.TrimSpace(text) == "" {
if savedValue, present := saved[key]; present {
merged[key] = cloneConfigValue(savedValue)
continue
}
}
incomingObject, incomingIsObject := value.(map[string]any)
savedObject, savedIsObject := saved[key].(map[string]any)
if incomingIsObject && savedIsObject {
if wholeValueSecret {
nestedSecretPaths = append(nestedSecretPaths, nil)
}
merged[key] = mergeConfigObjects(savedObject, incomingObject, nestedSecretPaths)
continue
}
merged[key] = cloneConfigValue(value)
}
return merged
}
func childSecretPaths(paths [][]string, key string) (nested [][]string, wholeValue bool) {
for _, path := range paths {
if len(path) == 0 {
wholeValue = true
continue
}
if path[0] != key {
continue
}
if len(path) == 1 {
wholeValue = true
continue
}
nested = append(nested, path[1:])
}
return nested, wholeValue
}
func cloneConfigValue(value any) any {
switch typed := value.(type) {
case map[string]any:
cloned := make(map[string]any, len(typed))
for key, entry := range typed {
cloned[key] = cloneConfigValue(entry)
}
return cloned
case []any:
cloned := make([]any, len(typed))
for index, entry := range typed {
cloned[index] = cloneConfigValue(entry)
}
return cloned
default:
return value
}
}