* feat(metadata): add resilient match queue diagnostics * fix(metadata): harden match queue lifecycle --------- Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
364 lines
9.9 KiB
Go
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
|
|
}
|
|
}
|