252 lines
11 KiB
Go
252 lines
11 KiB
Go
package plugins
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/kandev/kandev/internal/plugins/store"
|
|
)
|
|
|
|
// UpdateConfig replaces the operator-editable config for id. Incoming
|
|
// secret fields carrying the mask placeholder keep their stored value
|
|
// (mergeMaskedSecrets), the result is validated against the manifest's
|
|
// config_schema (ErrConfigInvalid on mismatch, mapped to 400 by the HTTP
|
|
// layer), secret fields are moved into the encrypted vault
|
|
// (storeConfigSecrets — the config file persists only a vault reference),
|
|
// and a currently-running plugin is restarted so the new config takes
|
|
// effect — hostForPlugin rebuilds the Host per spawn, and plugins read
|
|
// config at startup via the Host GetConfig RPC.
|
|
func (s *Service) UpdateConfig(ctx context.Context, id string, config map[string]any) error {
|
|
lock := s.lifecycleLocks.lockFor(id)
|
|
lock.Lock()
|
|
defer lock.Unlock()
|
|
dispatchLock := s.dispatchLocks.lockFor(id)
|
|
dispatchLock.Lock()
|
|
defer dispatchLock.Unlock()
|
|
|
|
rec, err := s.Get(id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
existing, err := s.store.GetConfig(id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
merged := mergeMaskedSecrets(config, existing, rec.ConfigSchema)
|
|
if err := validateConfigSchema(rec.ID, merged, rec.ConfigSchema); err != nil {
|
|
return err
|
|
}
|
|
stored, removedSecrets, rollbackVault, err := s.storeConfigSecrets(ctx, rec, merged)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.store.SetConfig(id, stored); err != nil {
|
|
// The config commit failed, so the still-current config file is
|
|
// unchanged — restore the vault to match it, otherwise a field's
|
|
// unchanged ref would resolve to the new (uncommitted) value and a
|
|
// request reported as failed would have changed effective config.
|
|
if rbErr := rollbackVault(); rbErr != nil {
|
|
return errors.Join(fmt.Errorf("plugins: persist config: %w", err),
|
|
fmt.Errorf("plugins: vault rollback failed, effective config may be inconsistent: %w", rbErr))
|
|
}
|
|
return err
|
|
}
|
|
// Vault entries for removed secret fields are deleted only AFTER the
|
|
// config commit succeeds: a failed SetConfig must never leave the old
|
|
// (still-current) config referencing an already-deleted vault entry. The
|
|
// delete runs on a context detached from the request (like the rollback
|
|
// path), so a client disconnect right after the commit cannot cancel it
|
|
// and orphan the now-unreferenced vault entries.
|
|
s.cleanupRemovedConfigSecrets(context.WithoutCancel(ctx), rec.ID, removedSecrets, existing)
|
|
return s.restartForConfigChange(rec)
|
|
}
|
|
|
|
// errSecretVaultRequired is returned by storeConfigSecrets when a plugin
|
|
// declares secret config fields but no vault is wired. It fails closed
|
|
// rather than silently persisting the secret in cleartext — production
|
|
// always wires the vault (Provide), so this only guards a misconfigured or
|
|
// test setup.
|
|
var errSecretVaultRequired = errors.New("plugins: a secret vault is required to store secret config fields")
|
|
|
|
// storeConfigSecrets moves each secret config field's cleartext value into
|
|
// the encrypted vault (id pluginConfigSecretID) and replaces it with the
|
|
// configVaultRef marker, so <id>.config.yml never persists a cleartext
|
|
// secret (validateConfigSchema has already rejected non-string secret
|
|
// values, so nothing can slip past the string path here). A field already
|
|
// carrying its ref (the mask-merge round trip) is left alone. Secret fields
|
|
// absent from merged are returned as removedSecrets for the caller to
|
|
// delete from the vault AFTER the config commit — deleting here would leave
|
|
// the still-current config pointing at a missing entry if SetConfig then
|
|
// failed. When a plugin declares secret fields but no vault is wired, it
|
|
// fails closed (errSecretVaultRequired) rather than writing cleartext.
|
|
//
|
|
// The returned rollback restores every vault entry this call overwrote to
|
|
// its prior value (or deletes it if it did not exist before), so the whole
|
|
// operation is failure-atomic: a vault.Set failure mid-loop rolls back the
|
|
// earlier writes before returning, and the caller runs rollback if the
|
|
// subsequent config commit fails — in both cases the vault ends up matching
|
|
// the unchanged config file, so a failed request never changes the value a
|
|
// still-current ref resolves to. Rollback writes run on a context detached
|
|
// from the caller's (context.WithoutCancel), so a request cancelled mid-save
|
|
// cannot abort the rollback and leave the vault inconsistent with the
|
|
// unchanged config file.
|
|
func (s *Service) storeConfigSecrets(
|
|
ctx context.Context, rec *store.Record, merged map[string]any,
|
|
) (stored map[string]any, removedSecrets []string, rollback func() error, err error) {
|
|
noRollback := func() error { return nil }
|
|
secretFields := secretPropertyKeys(rec.ConfigSchema)
|
|
if len(secretFields) == 0 {
|
|
return merged, nil, noRollback, nil
|
|
}
|
|
if s.secrets == nil {
|
|
return nil, nil, noRollback, fmt.Errorf("%w (plugin %q)", errSecretVaultRequired, rec.ID)
|
|
}
|
|
|
|
out := make(map[string]any, len(merged))
|
|
for k, v := range merged {
|
|
out[k] = v
|
|
}
|
|
rollbackCtx := context.WithoutCancel(ctx)
|
|
var restores []func() error
|
|
runRollback := func() error {
|
|
var errs []error
|
|
for i := len(restores) - 1; i >= 0; i-- {
|
|
if e := restores[i](); e != nil {
|
|
errs = append(errs, e)
|
|
}
|
|
}
|
|
return errors.Join(errs...)
|
|
}
|
|
for field := range secretFields {
|
|
value, present := out[field]
|
|
if !present {
|
|
removedSecrets = append(removedSecrets, field)
|
|
continue
|
|
}
|
|
cleartext, ok := value.(string)
|
|
if !ok || cleartext == "" || isConfigVaultRef(rec.ID, field, value) {
|
|
continue
|
|
}
|
|
vaultID := pluginConfigSecretID(rec.ID, field)
|
|
restore, snapErr := s.vaultRestoreFunc(ctx, rollbackCtx, vaultID)
|
|
if snapErr != nil {
|
|
s.warnIfRollbackFailed(rec.ID, runRollback())
|
|
return nil, nil, noRollback, snapErr
|
|
}
|
|
if err := s.secrets.Set(ctx, vaultID, vaultID, cleartext); err != nil {
|
|
s.warnIfRollbackFailed(rec.ID, runRollback())
|
|
return nil, nil, noRollback, fmt.Errorf("plugins: store secret config field %q: %w", field, err)
|
|
}
|
|
restores = append(restores, restore)
|
|
out[field] = configVaultRef(rec.ID, field)
|
|
}
|
|
return out, removedSecrets, runRollback, nil
|
|
}
|
|
|
|
// warnIfRollbackFailed logs a mid-loop vault rollback failure. A double
|
|
// fault (a vault write succeeded, then its rollback also failed) can leave
|
|
// earlier fields' vault entries at their new values while the config file is
|
|
// unchanged — making a failed request silently change effective config for
|
|
// those fields. It is very unlikely (needs a transient vault failure on both
|
|
// the write and the compensating write) and uninstall's namespace purge is a
|
|
// backstop, but surfacing it makes the inconsistency observable rather than
|
|
// silent.
|
|
func (s *Service) warnIfRollbackFailed(pluginID string, err error) {
|
|
if err != nil {
|
|
s.log.Warn("plugins: vault rollback failed after a store error; config may be inconsistent",
|
|
zap.String("plugin_id", pluginID), zap.Error(err))
|
|
}
|
|
}
|
|
|
|
// vaultRestoreFunc snapshots vaultID's current value (read on readCtx) and
|
|
// returns a closure that restores it (writes on restoreCtx): reset to the
|
|
// prior cleartext if the entry existed, or delete it if it did not. Used to
|
|
// undo a config-secret write when the config commit that would reference it
|
|
// fails. A not-found snapshot means "absent" (rollback deletes what we
|
|
// create); any other Reveal error is a genuine backend fault where the prior
|
|
// value cannot be determined — it returns an error so the caller aborts
|
|
// before writing rather than risk a rollback that deletes a real secret.
|
|
// restoreCtx is detached from the request so a cancelled save cannot abort
|
|
// the rollback.
|
|
func (s *Service) vaultRestoreFunc(readCtx, restoreCtx context.Context, vaultID string) (func() error, error) {
|
|
prior, err := s.secrets.Reveal(readCtx, vaultID)
|
|
switch {
|
|
case err == nil:
|
|
return func() error { return s.secrets.Set(restoreCtx, vaultID, vaultID, prior) }, nil
|
|
case isSecretNotFound(err):
|
|
return func() error {
|
|
if delErr := s.secrets.Delete(restoreCtx, vaultID); delErr != nil && !isSecretNotFound(delErr) {
|
|
return delErr
|
|
}
|
|
return nil
|
|
}, nil
|
|
default:
|
|
return nil, fmt.Errorf("plugins: cannot snapshot secret config field %q for rollback: %w", vaultID, err)
|
|
}
|
|
}
|
|
|
|
// cleanupRemovedConfigSecrets best-effort deletes the vault entries backing
|
|
// secret config fields that the just-committed config no longer contains,
|
|
// when the previous config actually pointed at them. Runs only after a
|
|
// successful SetConfig (see UpdateConfig); a deletion failure leaves an
|
|
// orphaned vault entry, which uninstall's namespace purge also sweeps.
|
|
func (s *Service) cleanupRemovedConfigSecrets(
|
|
ctx context.Context, pluginID string, removed []string, existing map[string]any,
|
|
) {
|
|
for _, field := range removed {
|
|
if !isConfigVaultRef(pluginID, field, existing[field]) {
|
|
continue
|
|
}
|
|
if err := s.secrets.Delete(ctx, pluginConfigSecretID(pluginID, field)); err != nil {
|
|
s.log.Warn("plugins: failed to delete removed secret config field from vault",
|
|
zap.String("plugin_id", pluginID), zap.String("field", field), zap.Error(err))
|
|
}
|
|
}
|
|
}
|
|
|
|
// GetMaskedConfig returns id's stored config with secret values (per the
|
|
// manifest's config_schema) replaced by the mask placeholder — the shape
|
|
// the operator settings UI is allowed to see.
|
|
func (s *Service) GetMaskedConfig(id string) (map[string]any, error) {
|
|
rec, err := s.Get(id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
config, err := s.store.GetConfig(id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return maskSecrets(config, rec.ConfigSchema), nil
|
|
}
|
|
|
|
// restartForConfigChange bounces id's process after a config write so the
|
|
// plugin re-reads its config on the fresh spawn. A plugin that is not
|
|
// running (disabled, errored, or no runtime wired) is left alone — it will
|
|
// pick the config up on its next spawn anyway. The config is already
|
|
// persisted by the time this runs; a restart failure transitions the plugin
|
|
// to StatusError and is returned so the operator sees that the save
|
|
// succeeded but the plugin did not come back up.
|
|
func (s *Service) restartForConfigChange(rec *store.Record) error {
|
|
if s.runtime == nil || !s.runtime.Running(rec.ID) {
|
|
return nil
|
|
}
|
|
s.runtime.Stop(rec.ID)
|
|
ctx, cancel := context.WithTimeout(context.Background(), activateStartTimeout)
|
|
defer cancel()
|
|
if err := s.runtime.Start(ctx, rec, s.hostForPlugin); err != nil {
|
|
if setErr := s.setStatusAndDiagnostic(rec.ID, StatusError, err, true); setErr != nil {
|
|
// The restart error stays the returned error (it is the primary
|
|
// signal), but a failed status write means the registry may show
|
|
// StatusActive with no process running — don't lose that.
|
|
s.log.Warn("plugins: could not transition to error status after restart failure",
|
|
zap.String("plugin_id", rec.ID), zap.Error(setErr))
|
|
}
|
|
s.notifyDeliverer()
|
|
return fmt.Errorf("plugins: config saved but restart of %q failed: %w", rec.ID, err)
|
|
}
|
|
return nil
|
|
}
|