feat(plugin): add admin plugin management UI
Some checks failed
Go / build (.exe, 386, windows, windows-386) (push) Has been cancelled
Go / build (.exe, amd64, windows, windows-amd64) (push) Has been cancelled
Go / build (.exe, arm64, windows, windows-arm64) (push) Has been cancelled
Go / build (386, freebsd, freebsd-386) (push) Has been cancelled
Go / build (386, linux, linux-386) (push) Has been cancelled
Go / build (386, netbsd, netbsd-386) (push) Has been cancelled
Go / build (386, openbsd, openbsd-386) (push) Has been cancelled
Go / build (386, plan9, plan9-386) (push) Has been cancelled
Go / build (amd64, darwin, darwin-amd64) (push) Has been cancelled
Go / build (amd64, dragonfly, dragonfly-amd64) (push) Has been cancelled
Go / build (amd64, freebsd, freebsd-amd64) (push) Has been cancelled
Go / build (amd64, illumos, illumos-amd64) (push) Has been cancelled
Go / build (amd64, linux, linux-amd64) (push) Has been cancelled
Go / build (amd64, netbsd, netbsd-amd64) (push) Has been cancelled
Go / build (amd64, openbsd, openbsd-amd64) (push) Has been cancelled
Go / build (amd64, plan9, plan9-amd64) (push) Has been cancelled
Go / build (amd64, solaris, solaris-amd64) (push) Has been cancelled
Go / build (arm, 6, linux, linux-armv6) (push) Has been cancelled
Go / build (arm, 7, linux, linux-armv7) (push) Has been cancelled
Go / build (arm, freebsd, freebsd-arm) (push) Has been cancelled
Go / build (arm, netbsd, netbsd-arm) (push) Has been cancelled
Go / build (arm, openbsd, openbsd-arm) (push) Has been cancelled
Go / build (arm, plan9, plan9-arm) (push) Has been cancelled
Go / build (arm64, darwin, darwin-arm64) (push) Has been cancelled
Go / build (arm64, freebsd, freebsd-arm64) (push) Has been cancelled
Go / build (arm64, linux, linux-arm64) (push) Has been cancelled
Go / build (arm64, netbsd, netbsd-arm64) (push) Has been cancelled
Go / build (arm64, openbsd, openbsd-arm64) (push) Has been cancelled
Go / build (loong64, linux, linux-loong64) (push) Has been cancelled
Go / build (mips, linux, linux-mips) (push) Has been cancelled
Go / build (mips64, linux, linux-mips64) (push) Has been cancelled
Go / build (mips64le, linux, linux-mips64le) (push) Has been cancelled
Go / build (mipsle, linux, linux-mipsle) (push) Has been cancelled
Go / build (ppc64, aix, aix-ppc64) (push) Has been cancelled
Go / build (ppc64, linux, linux-ppc64) (push) Has been cancelled
Go / build (ppc64, openbsd, openbsd-ppc64) (push) Has been cancelled
Go / build (ppc64le, linux, linux-ppc64le) (push) Has been cancelled
Go / build (riscv64, freebsd, freebsd-riscv64) (push) Has been cancelled
Go / build (riscv64, linux, linux-riscv64) (push) Has been cancelled
Go / build (riscv64, openbsd, openbsd-riscv64) (push) Has been cancelled
Go / build (s390x, linux, linux-s390x) (push) Has been cancelled
Go / merge-artifacts (push) Has been cancelled
Docker Image / docker (push) Has been cancelled

This commit is contained in:
2026-06-26 11:38:19 +08:00
parent eae2319328
commit b822993489
24 changed files with 2834 additions and 88 deletions

View File

@@ -194,6 +194,21 @@ CREATE TABLE IF NOT EXISTS plugin_config_snapshots (
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS plugin_secrets (
plugin_id TEXT NOT NULL,
name TEXT NOT NULL,
current_version INTEGER NOT NULL DEFAULT 1,
previous_version INTEGER NOT NULL DEFAULT 0,
current_value TEXT NOT NULL DEFAULT '',
previous_value TEXT NOT NULL DEFAULT '',
reload_required INTEGER NOT NULL DEFAULT 0,
hot_reload INTEGER NOT NULL DEFAULT 0,
updated_by TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY(plugin_id, name)
);
CREATE INDEX IF NOT EXISTS idx_routes_enabled ON routes(enabled);
CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at);
CREATE INDEX IF NOT EXISTS idx_plugin_artifacts_plugin_id ON plugin_artifacts(plugin_id, created_at);
@@ -202,12 +217,22 @@ CREATE INDEX IF NOT EXISTS idx_plugin_operations_plugin_id ON plugin_operations(
CREATE INDEX IF NOT EXISTS idx_plugin_builds_plugin_id ON plugin_builds(plugin_id, created_at);
CREATE INDEX IF NOT EXISTS idx_plugin_builds_source_id ON plugin_builds(source_id, created_at);
CREATE INDEX IF NOT EXISTS idx_plugin_config_snapshots_plugin_id ON plugin_config_snapshots(plugin_id, created_at);
CREATE INDEX IF NOT EXISTS idx_plugin_secrets_plugin_id ON plugin_secrets(plugin_id, updated_at);
INSERT OR IGNORE INTO schema_migrations(version, applied_at) VALUES (1, strftime('%s','now'));
`
if _, err := db.Exec(schema); err != nil {
return err
}
return ensureColumn(db, "audit_logs", "metadata_json", "TEXT NOT NULL DEFAULT '{}'")
if err := ensureColumn(db, "audit_logs", "metadata_json", "TEXT NOT NULL DEFAULT '{}'"); err != nil {
return err
}
if err := ensureColumn(db, "plugin_secrets", "reload_required", "INTEGER NOT NULL DEFAULT 0"); err != nil {
return err
}
if err := ensureColumn(db, "plugin_secrets", "hot_reload", "INTEGER NOT NULL DEFAULT 0"); err != nil {
return err
}
return nil
}
func ensureColumn(db *sql.DB, table, column, definition string) error {

View File

@@ -38,6 +38,9 @@ type APIHandlers struct {
PluginsList http.HandlerFunc
PluginItem SegmentHandlerFunc
PluginAction SegmentHandlerFunc
PluginConfig SegmentHandlerFunc
PluginSecrets SegmentHandlerFunc
PluginRollback SegmentHandlerFunc
PluginDraining SegmentHandlerFunc
PluginDispatch http.HandlerFunc
}
@@ -103,6 +106,22 @@ func NewAPIHandler(prefix string, handlers APIHandlers) http.HandlerFunc {
callSegmentHandler(w, r, handlers.PluginDraining, strings.TrimSuffix(pluginPath, "/draining/force-close"))
return
}
if strings.Contains(pluginPath, "/rollback/") || strings.HasSuffix(pluginPath, "/rollback") {
callSegmentHandler(w, r, handlers.PluginRollback, pluginPath)
return
}
if strings.Contains(pluginPath, "/config/") || strings.HasSuffix(pluginPath, "/config") {
callSegmentHandler(w, r, handlers.PluginConfig, pluginPath)
return
}
if strings.HasSuffix(pluginPath, "/proxy-connections") {
callSegmentHandler(w, r, handlers.PluginItem, pluginPath)
return
}
if strings.Contains(pluginPath, "/secrets/") || strings.HasSuffix(pluginPath, "/secrets") {
callSegmentHandler(w, r, handlers.PluginSecrets, pluginPath)
return
}
if strings.Count(pluginPath, "/") == 1 {
callSegmentHandler(w, r, handlers.PluginAction, pluginPath)
return

View File

@@ -40,6 +40,10 @@ func TestNewAPIHandlerRoutesRequests(t *testing.T) {
{name: "plugins list", method: http.MethodGet, path: "/admin/api/plugins", wantCall: "plugins_list"},
{name: "plugin item", method: http.MethodPut, path: "/admin/api/plugins/upstream-rewrite", wantCall: "plugin_item", wantSegment: "upstream-rewrite"},
{name: "plugin action", method: http.MethodPost, path: "/admin/api/plugins/upstream-rewrite/enable", wantCall: "plugin_action", wantSegment: "upstream-rewrite/enable"},
{name: "plugin config", method: http.MethodPut, path: "/admin/api/plugins/upstream-rewrite/config", wantCall: "plugin_config", wantSegment: "upstream-rewrite/config"},
{name: "plugin config dry-run", method: http.MethodPost, path: "/admin/api/plugins/upstream-rewrite/config/dry-run", wantCall: "plugin_config", wantSegment: "upstream-rewrite/config/dry-run"},
{name: "plugin secrets", method: http.MethodGet, path: "/admin/api/plugins/upstream-rewrite/secrets", wantCall: "plugin_secrets", wantSegment: "upstream-rewrite/secrets"},
{name: "plugin rollback artifact", method: http.MethodPost, path: "/admin/api/plugins/upstream-rewrite/rollback/artifact", wantCall: "plugin_rollback", wantSegment: "upstream-rewrite/rollback/artifact"},
{name: "plugin draining force close", method: http.MethodPost, path: "/admin/api/plugins/mc-auth-proxy/draining/force-close", wantCall: "plugin_draining", wantSegment: "mc-auth-proxy"},
{name: "plugin dispatch", method: http.MethodGet, path: "/admin/api/plugins/dispatch-plan", wantCall: "plugin_dispatch"},
}
@@ -78,6 +82,9 @@ func TestNewAPIHandlerRoutesRequests(t *testing.T) {
PluginsList: recordCall(&gotCall, "plugins_list"),
PluginItem: recordSegmentCall(&gotCall, &gotSegment, "plugin_item"),
PluginAction: recordSegmentCall(&gotCall, &gotSegment, "plugin_action"),
PluginConfig: recordSegmentCall(&gotCall, &gotSegment, "plugin_config"),
PluginSecrets: recordSegmentCall(&gotCall, &gotSegment, "plugin_secrets"),
PluginRollback: recordSegmentCall(&gotCall, &gotSegment, "plugin_rollback"),
PluginDraining: recordSegmentCall(&gotCall, &gotSegment, "plugin_draining"),
PluginDispatch: recordCall(&gotCall, "plugin_dispatch"),
})

View File

@@ -42,3 +42,25 @@ type PluginDesiredRequest struct {
Config map[string]any `json:"config"`
ConfigJSON string `json:"config_json"`
}
type PluginConfigRequest struct {
ArtifactID string `json:"artifact_id"`
DesiredState string `json:"desired_state"`
Priority int `json:"priority"`
Config map[string]any `json:"config"`
ConfigJSON string `json:"config_json"`
}
type PluginSecretRequest struct {
ArtifactID string `json:"artifact_id"`
Name string `json:"name"`
Value string `json:"value"`
ReloadRequired bool `json:"reload_required"`
HotReload bool `json:"hot_reload"`
}
type PluginRollbackRequest struct {
ArtifactID string `json:"artifact_id"`
SnapshotID int64 `json:"snapshot_id"`
FullDesired bool `json:"full_desired"`
}

View File

@@ -68,6 +68,8 @@ func Permissions(role string) map[string]bool {
"read_routes": HasRole(role, RoleGuest),
"write_routes": HasRole(role, RoleMember),
"read_status": HasRole(role, RoleMember),
"read_plugins": HasRole(role, RoleMember),
"manage_plugins": HasRole(role, RoleAdmin),
"manage_users": HasRole(role, RoleAdmin),
"manage_services": HasRole(role, RoleAdmin),
}

View File

@@ -82,6 +82,8 @@ func TestPermissions(t *testing.T) {
"read_routes": true,
"write_routes": false,
"read_status": false,
"read_plugins": false,
"manage_plugins": false,
"manage_users": false,
"manage_services": false,
}
@@ -93,6 +95,8 @@ func TestPermissions(t *testing.T) {
"read_routes": true,
"write_routes": true,
"read_status": true,
"read_plugins": true,
"manage_plugins": true,
"manage_users": true,
"manage_services": true,
}

View File

@@ -18,7 +18,10 @@ import (
"time"
)
var pluginIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,127}$`)
var (
pluginIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,127}$`)
secretNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`)
)
type ArtifactStore struct {
Root string
@@ -464,6 +467,16 @@ func validateManifest(manifest Manifest) error {
if !found {
return fmt.Errorf("extension point %q is required", ExtensionUpstreamConnect)
}
seenSecrets := make(map[string]bool, len(manifest.Secrets))
for _, secret := range manifest.Secrets {
if !secretNamePattern.MatchString(secret.Name) {
return fmt.Errorf("invalid secret name %q", secret.Name)
}
if seenSecrets[secret.Name] {
return fmt.Errorf("duplicate secret name %q", secret.Name)
}
seenSecrets[secret.Name] = true
}
return nil
}

View File

@@ -12,6 +12,7 @@ import (
"reflect"
"runtime"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
@@ -23,6 +24,10 @@ type RuntimeAdapter interface {
Load(ctx context.Context, artifact ArtifactRecord, pluginRecord PluginRecord, gateway *Gateway) (api.Plugin, error)
}
type ConfigDryRunAdapter interface {
DryRunConfig(ctx context.Context, artifact ArtifactRecord, pluginRecord PluginRecord) error
}
type GoPluginAdapter struct{}
func (a GoPluginAdapter) Load(ctx context.Context, artifact ArtifactRecord, pluginRecord PluginRecord, gateway *Gateway) (api.Plugin, error) {
@@ -60,6 +65,38 @@ func (a GoPluginAdapter) Load(ctx context.Context, artifact ArtifactRecord, plug
return instance, nil
}
func (a GoPluginAdapter) DryRunConfig(ctx context.Context, artifact ArtifactRecord, pluginRecord PluginRecord) error {
_ = ctx
opened, err := stdplugin.Open(artifact.FilePath)
if err != nil {
return err
}
symbolName := "Plugin"
var manifest Manifest
if err := json.Unmarshal([]byte(artifact.MetadataJSON), &manifest); err == nil && manifest.Runtime.EntrySymbol != "" {
symbolName = manifest.Runtime.EntrySymbol
}
symbol, err := opened.Lookup(symbolName)
if err != nil {
return err
}
factory, ok := symbol.(func() api.Plugin)
if !ok {
return fmt.Errorf("plugin symbol %q has invalid signature", symbolName)
}
instance := factory()
cfg := instance.NewConfigObj()
if cfg != nil && pluginRecord.ConfigJSON != "" && canUnmarshalInto(cfg) {
if err := json.Unmarshal([]byte(pluginRecord.ConfigJSON), cfg); err != nil {
return fmt.Errorf("decode plugin config: %w", err)
}
}
if err := instance.ReloadConfig(cfg); err != nil {
return err
}
return nil
}
func canUnmarshalInto(value any) bool {
if value == nil {
return false
@@ -397,6 +434,17 @@ func (m *Manager) RunBuild(ctx context.Context, actor string, buildID int64) (Bu
}
func (m *Manager) SetDesired(ctx context.Context, actor, pluginID, artifactID, desiredState, configJSON string, priority int) (PluginRecord, error) {
if desiredState == "" {
desiredState = DesiredDisabled
}
if desiredState != DesiredDeleted {
if _, err := m.DryRunConfig(ctx, pluginID, artifactID, configJSON); err != nil {
_ = m.repo.RecordOperation(ctx, pluginID, artifactID, "config_dry_run", "failed", actor, err.Error(), map[string]any{
"active_changed": false,
})
return PluginRecord{}, err
}
}
pluginRecord, err := m.repo.UpsertDesired(ctx, actor, pluginID, artifactID, desiredState, configJSON, priority)
if err != nil {
_ = m.repo.RecordOperation(ctx, pluginID, artifactID, "desired_update", "failed", actor, err.Error(), nil)
@@ -410,6 +458,150 @@ func (m *Manager) SetDesired(ctx context.Context, actor, pluginID, artifactID, d
return pluginRecord, nil
}
func (m *Manager) DryRunConfig(ctx context.Context, pluginID, artifactID, configJSON string) (ConfigDryRunResult, error) {
result := ConfigDryRunResult{
OK: false,
PluginID: pluginID,
ArtifactID: artifactID,
}
if configJSON == "" {
configJSON = "{}"
}
if !json.Valid([]byte(configJSON)) {
err := errors.New("config_json must be valid JSON")
result.Error = err.Error()
return result, err
}
artifact, err := m.repo.Artifact(ctx, artifactID)
if err != nil {
result.Error = err.Error()
return result, err
}
if artifact.PluginID != pluginID {
err := errors.New("artifact plugin_id does not match")
result.Error = err.Error()
return result, err
}
if err := m.validateArtifactGate(artifact); err != nil {
result.Error = err.Error()
return result, err
}
var manifest Manifest
if err := json.Unmarshal([]byte(artifact.MetadataJSON), &manifest); err != nil {
result.Error = err.Error()
return result, err
}
if err := validateConfigSchema(manifest.ConfigSchema, configJSON); err != nil {
result.Error = err.Error()
return result, err
}
if err := m.validateSecretRefs(ctx, manifest, configJSON); err != nil {
result.Error = err.Error()
return result, err
}
pluginRecord, err := m.pluginRecordForDryRun(ctx, pluginID, artifactID, configJSON)
if err != nil {
result.Error = err.Error()
return result, err
}
if dryRunner, ok := m.adapter.(ConfigDryRunAdapter); ok {
if err := dryRunner.DryRunConfig(ctx, artifact, pluginRecord); err != nil {
result.Error = err.Error()
return result, err
}
}
currentConfig := "{}"
if current, err := m.repo.Plugin(ctx, pluginID); err == nil {
currentConfig = current.ConfigJSON
}
sensitivePaths := sensitiveConfigPaths(manifest.ConfigSchema, configJSON)
redactedConfig, err := redactJSON(configJSON, sensitivePaths)
if err != nil {
result.Error = err.Error()
return result, err
}
diff, err := redactedDiffJSON(currentConfig, configJSON, sensitivePaths)
if err != nil {
result.Error = err.Error()
return result, err
}
result.OK = true
result.RestartRequired = m.restartRequired(pluginID, artifactID)
result.HotReload = !result.RestartRequired
result.SensitivePaths = sensitivePaths
result.RedactedConfigJSON = redactedConfig
result.RedactedDiffJSON = diff
return result, nil
}
func (m *Manager) RollbackArtifact(ctx context.Context, actor, pluginID, artifactID string) (PluginRecord, error) {
current, err := m.repo.Plugin(ctx, pluginID)
if err != nil {
return PluginRecord{}, err
}
if _, err := m.DryRunConfig(ctx, pluginID, artifactID, current.ConfigJSON); err != nil {
_ = m.repo.RecordOperation(ctx, pluginID, artifactID, "artifact_rollback", "failed", actor, err.Error(), map[string]any{
"active_changed": false,
})
return PluginRecord{}, err
}
plugin, err := m.repo.UpsertDesired(ctx, actor, pluginID, artifactID, current.DesiredState, current.ConfigJSON, current.Priority)
if err != nil {
_ = m.repo.RecordOperation(ctx, pluginID, artifactID, "artifact_rollback", "failed", actor, err.Error(), map[string]any{
"active_changed": false,
})
return PluginRecord{}, err
}
_ = m.repo.RecordOperation(ctx, pluginID, artifactID, "artifact_rollback", "succeeded", actor, "artifact rollback desired state updated", map[string]any{
"desired_generation": plugin.DesiredGeneration,
"active_changed": false,
})
return plugin, nil
}
func (m *Manager) RollbackConfigSnapshot(ctx context.Context, actor string, snapshotID int64, fullDesired bool) (PluginRecord, error) {
snapshot, err := m.repo.ConfigSnapshot(ctx, snapshotID)
if err != nil {
return PluginRecord{}, err
}
plugin, err := m.repo.Plugin(ctx, snapshot.PluginID)
if err != nil {
return PluginRecord{}, err
}
artifactID := plugin.DesiredArtifactID
desiredState := plugin.DesiredState
priority := plugin.Priority
if fullDesired {
artifactID = snapshot.ArtifactID
desiredState = snapshot.DesiredState
priority = snapshot.Priority
}
if _, err := m.DryRunConfig(ctx, snapshot.PluginID, artifactID, snapshot.ConfigJSON); err != nil {
_ = m.repo.RecordOperation(ctx, snapshot.PluginID, artifactID, "config_rollback", "failed", actor, err.Error(), map[string]any{
"snapshot_id": snapshot.ID,
"full_desired": fullDesired,
"active_changed": false,
})
return PluginRecord{}, err
}
next, err := m.repo.UpsertDesired(ctx, actor, snapshot.PluginID, artifactID, desiredState, snapshot.ConfigJSON, priority)
if err != nil {
_ = m.repo.RecordOperation(ctx, snapshot.PluginID, artifactID, "config_rollback", "failed", actor, err.Error(), map[string]any{
"snapshot_id": snapshot.ID,
"full_desired": fullDesired,
"active_changed": false,
})
return PluginRecord{}, err
}
_ = m.repo.RecordOperation(ctx, snapshot.PluginID, artifactID, "config_rollback", "succeeded", actor, "config snapshot rollback desired state updated", map[string]any{
"snapshot_id": snapshot.ID,
"full_desired": fullDesired,
"desired_generation": next.DesiredGeneration,
"active_changed": false,
})
return next, nil
}
func (m *Manager) Load(ctx context.Context, actor, pluginID string) (PluginRecord, error) {
m.mu.Lock()
defer m.mu.Unlock()
@@ -428,19 +620,20 @@ func (m *Manager) Load(ctx context.Context, actor, pluginID string) (PluginRecor
}
func (m *Manager) Enable(ctx context.Context, actor, pluginID string) (PluginRecord, error) {
m.mu.Lock()
defer m.mu.Unlock()
pluginRecord, err := m.repo.Plugin(ctx, pluginID)
if err != nil {
return PluginRecord{}, err
}
if pluginRecord.DesiredState != DesiredEnabled {
pluginRecord, err = m.repo.UpsertDesired(ctx, actor, pluginRecord.ID, pluginRecord.DesiredArtifactID, DesiredEnabled, pluginRecord.ConfigJSON, pluginRecord.Priority)
pluginRecord, err = m.SetDesired(ctx, actor, pluginRecord.ID, pluginRecord.DesiredArtifactID, DesiredEnabled, pluginRecord.ConfigJSON, pluginRecord.Priority)
if err != nil {
return PluginRecord{}, err
}
}
m.mu.Lock()
defer m.mu.Unlock()
loaded, err := m.loadLocked(ctx, pluginRecord)
if err != nil {
_ = m.repo.RecordOperation(ctx, pluginID, pluginRecord.DesiredArtifactID, "enable", "failed", actor, err.Error(), nil)
@@ -658,6 +851,139 @@ func (m *Manager) Build(ctx context.Context, id int64) (BuildRecord, error) {
return m.repo.Build(ctx, id)
}
func (m *Manager) ListConfigSnapshots(ctx context.Context, pluginID string) ([]ConfigSnapshotRecord, error) {
return m.repo.ListConfigSnapshots(ctx, pluginID)
}
func (m *Manager) ConfigSnapshot(ctx context.Context, id int64) (ConfigSnapshotRecord, error) {
return m.repo.ConfigSnapshot(ctx, id)
}
func (m *Manager) ConfigSnapshotDiff(ctx context.Context, snapshotID int64) (ConfigSnapshotDiff, error) {
snapshot, err := m.repo.ConfigSnapshot(ctx, snapshotID)
if err != nil {
return ConfigSnapshotDiff{}, err
}
plugin, err := m.repo.Plugin(ctx, snapshot.PluginID)
if err != nil {
return ConfigSnapshotDiff{}, err
}
artifactID := snapshot.ArtifactID
if artifactID == "" {
artifactID = plugin.DesiredArtifactID
}
artifact, err := m.repo.Artifact(ctx, artifactID)
if err != nil {
return ConfigSnapshotDiff{}, err
}
var manifest Manifest
if err := json.Unmarshal([]byte(artifact.MetadataJSON), &manifest); err != nil {
return ConfigSnapshotDiff{}, err
}
paths := sensitiveConfigPaths(manifest.ConfigSchema, snapshot.ConfigJSON)
diff, err := redactedDiffJSON(plugin.ConfigJSON, snapshot.ConfigJSON, paths)
if err != nil {
return ConfigSnapshotDiff{}, err
}
return ConfigSnapshotDiff{
SnapshotID: snapshot.ID,
PluginID: snapshot.PluginID,
ArtifactID: artifactID,
SensitivePaths: paths,
RedactedDiffJSON: diff,
RestartRequired: m.restartRequired(snapshot.PluginID, artifactID),
CurrentGeneration: plugin.DesiredGeneration,
SnapshotGeneration: snapshot.DesiredGeneration,
}, nil
}
func (m *Manager) ListSecrets(ctx context.Context, pluginID string) ([]SecretRecord, error) {
return m.repo.ListSecrets(ctx, pluginID)
}
func (m *Manager) UpsertSecret(ctx context.Context, actor, pluginID, artifactID, name, value string, reloadRequired, hotReload bool) (SecretRecord, error) {
if artifactID == "" {
plugin, err := m.repo.Plugin(ctx, pluginID)
if err != nil {
return SecretRecord{}, err
}
artifactID = plugin.DesiredArtifactID
}
artifact, err := m.repo.Artifact(ctx, artifactID)
if err != nil {
return SecretRecord{}, err
}
if artifact.PluginID != pluginID {
return SecretRecord{}, errors.New("artifact plugin_id does not match")
}
var manifest Manifest
if err := json.Unmarshal([]byte(artifact.MetadataJSON), &manifest); err != nil {
return SecretRecord{}, err
}
if len(manifest.Secrets) > 0 {
declared := false
for _, spec := range manifest.Secrets {
if spec.Name == name {
declared = true
if !reloadRequired && !hotReload {
switch spec.Rotation.Reload {
case "hot":
hotReload = true
case "reload_required", "restart_required", "manual":
reloadRequired = true
}
}
break
}
}
if !declared {
return SecretRecord{}, fmt.Errorf("secret %q is not declared by manifest", name)
}
}
secret, err := m.repo.UpsertSecret(ctx, actor, pluginID, name, value, reloadRequired, hotReload)
if err != nil {
_ = m.repo.RecordOperation(ctx, pluginID, "", "secret_update", "failed", actor, "secret update failed", map[string]any{
"secret_ref": "plugin://" + pluginID + "/" + name,
"error": redactSecretText(err.Error()),
})
return SecretRecord{}, err
}
_ = m.repo.RecordOperation(ctx, pluginID, "", "secret_update", "succeeded", actor, "secret updated", map[string]any{
"secret_ref": "plugin://" + pluginID + "/" + name,
"current_version": secret.CurrentVersion,
"previous_version": secret.PreviousVersion,
"reload_required": secret.ReloadRequired,
"hot_reload": secret.HotReload,
})
return secret, nil
}
func (m *Manager) ActiveProxyConnections(ctx context.Context, pluginID string) ([]ProxyConnectionSummary, error) {
_ = ctx
now := time.Now()
var summaries []ProxyConnectionSummary
m.proxyMu.Lock()
defer m.proxyMu.Unlock()
for _, conn := range m.proxyConns {
if pluginID != "" && conn.pluginID != pluginID {
continue
}
summaries = append(summaries, ProxyConnectionSummary{
ID: conn.id,
PluginID: conn.pluginID,
ArtifactID: conn.artifactID,
HandlerID: conn.handlerID,
StartedAt: conn.startedAt.Unix(),
DurationMS: now.Sub(conn.startedAt).Milliseconds(),
Draining: conn.draining,
})
}
sort.Slice(summaries, func(i, j int) bool {
return summaries[i].StartedAt < summaries[j].StartedAt
})
return summaries, nil
}
func (m *Manager) CancelBuild(ctx context.Context, actor string, id int64) (BuildRecord, error) {
build, err := m.repo.CancelBuild(ctx, id, actor)
if err != nil {
@@ -830,17 +1156,8 @@ func (m *Manager) loadLocked(ctx context.Context, pluginRecord PluginRecord) (*l
if err != nil {
return nil, err
}
if artifact.Status == ArtifactStatusDeleted || artifact.Status == ArtifactStatusRejected {
return nil, fmt.Errorf("artifact status %q is not loadable", artifact.Status)
}
if artifact.ArtifactType != ArtifactTypeBinary {
return nil, fmt.Errorf("artifact type %q is not loadable", artifact.ArtifactType)
}
if artifact.GoVersion != runtime.Version() {
return nil, fmt.Errorf("artifact go_version %q does not match gateway %q", artifact.GoVersion, runtime.Version())
}
if artifact.GOOS != runtime.GOOS || artifact.GOARCH != runtime.GOARCH {
return nil, fmt.Errorf("artifact target %s/%s does not match gateway %s/%s", artifact.GOOS, artifact.GOARCH, runtime.GOOS, runtime.GOARCH)
if err := m.validateArtifactGate(artifact); err != nil {
return nil, err
}
gateway := NewGateway(pluginRecord.ID, m.handleConn, m.wg)
@@ -866,6 +1183,50 @@ func (m *Manager) loadLocked(ctx context.Context, pluginRecord PluginRecord) (*l
return loaded, nil
}
func (m *Manager) validateArtifactGate(artifact ArtifactRecord) error {
if artifact.Status == ArtifactStatusDeleted || artifact.Status == ArtifactStatusRejected {
return fmt.Errorf("artifact status %q is not loadable", artifact.Status)
}
if artifact.ArtifactType != ArtifactTypeBinary {
return errors.New("desired artifact must be a binary artifact")
}
if artifact.GoVersion != runtime.Version() {
return fmt.Errorf("artifact go_version %q does not match gateway %q", artifact.GoVersion, runtime.Version())
}
if artifact.GOOS != runtime.GOOS || artifact.GOARCH != runtime.GOARCH {
return fmt.Errorf("artifact target %s/%s does not match gateway %s/%s", artifact.GOOS, artifact.GOARCH, runtime.GOOS, runtime.GOARCH)
}
return nil
}
func (m *Manager) pluginRecordForDryRun(ctx context.Context, pluginID, artifactID, configJSON string) (PluginRecord, error) {
pluginRecord, err := m.repo.Plugin(ctx, pluginID)
if err != nil {
if !errors.Is(err, ErrPluginNotFound) {
return PluginRecord{}, err
}
return PluginRecord{
ID: pluginID,
DesiredArtifactID: artifactID,
DesiredState: DesiredDisabled,
RuntimeState: RuntimeDisabled,
Priority: DefaultPriority,
ConfigJSON: configJSON,
DesiredGeneration: 1,
}, nil
}
pluginRecord.DesiredArtifactID = artifactID
pluginRecord.ConfigJSON = configJSON
return pluginRecord, nil
}
func (m *Manager) restartRequired(pluginID, artifactID string) bool {
m.mu.Lock()
defer m.mu.Unlock()
loaded := m.loaded[pluginID]
return loaded != nil && loaded.artifact.ID != artifactID
}
func (m *Manager) markEnabled(ctx context.Context, loaded *loadedPlugin) error {
return m.repo.MarkRuntime(ctx, loaded.record.ID, RuntimeEnabled, loaded.artifact.ID, loaded.artifact.ID, loaded.record.DesiredGeneration, "", map[string]any{
"handler_count": len(loaded.handlers),
@@ -1156,6 +1517,326 @@ func closeRead(conn any) {
}
}
func validateConfigSchema(schema json.RawMessage, configJSON string) error {
if len(strings.TrimSpace(string(schema))) == 0 || string(schema) == "null" {
return nil
}
var root map[string]any
if err := json.Unmarshal(schema, &root); err != nil {
return fmt.Errorf("invalid config_schema: %w", err)
}
var config any
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
return err
}
return validateSchemaValue(root, config, "$")
}
func validateSchemaValue(schema map[string]any, value any, path string) error {
if typ, _ := schema["type"].(string); typ != "" {
if !jsonTypeMatches(typ, value) {
return fmt.Errorf("%s must be %s", path, typ)
}
}
if enumValues, ok := schema["enum"].([]any); ok && len(enumValues) > 0 {
found := false
for _, allowed := range enumValues {
if reflect.DeepEqual(allowed, value) {
found = true
break
}
}
if !found {
return fmt.Errorf("%s must match enum", path)
}
}
props, _ := schema["properties"].(map[string]any)
obj, _ := value.(map[string]any)
if required, ok := schema["required"].([]any); ok {
for _, raw := range required {
name, _ := raw.(string)
if name == "" {
continue
}
if obj == nil {
return fmt.Errorf("%s must be object for required %q", path, name)
}
if _, exists := obj[name]; !exists {
return fmt.Errorf("%s.%s is required", path, name)
}
}
}
if obj == nil || len(props) == 0 {
return nil
}
for name, propSchema := range props {
childSchema, ok := propSchema.(map[string]any)
if !ok {
continue
}
childValue, exists := obj[name]
if !exists {
continue
}
if err := validateSchemaValue(childSchema, childValue, path+"."+name); err != nil {
return err
}
}
return nil
}
func jsonTypeMatches(typ string, value any) bool {
switch typ {
case "object":
_, ok := value.(map[string]any)
return ok
case "array":
_, ok := value.([]any)
return ok
case "string":
_, ok := value.(string)
return ok
case "number":
_, ok := value.(float64)
return ok
case "integer":
n, ok := value.(float64)
return ok && n == float64(int64(n))
case "boolean":
_, ok := value.(bool)
return ok
case "null":
return value == nil
default:
return true
}
}
func (m *Manager) validateSecretRefs(ctx context.Context, manifest Manifest, configJSON string) error {
declared := make(map[string]SecretSpec, len(manifest.Secrets))
for _, spec := range manifest.Secrets {
if spec.Name != "" {
declared[spec.Name] = spec
}
}
configRefs, err := collectConfigSecretRefs(configJSON)
if err != nil {
return err
}
for ref := range configRefs {
if len(declared) > 0 {
if _, ok := declared[ref]; !ok {
return fmt.Errorf("secret ref %q is not declared by manifest", ref)
}
}
}
for name, spec := range declared {
if spec.Required {
configRefs[name] = true
}
}
if len(configRefs) == 0 {
return nil
}
secrets, err := m.repo.ListSecrets(ctx, manifest.ID)
if err != nil {
return err
}
configured := make(map[string]bool, len(secrets))
for _, secret := range secrets {
configured[secret.Name] = secret.CurrentVersion > 0
}
var missing []string
for ref := range configRefs {
if !configured[ref] {
missing = append(missing, ref)
}
}
if len(missing) > 0 {
sort.Strings(missing)
return fmt.Errorf("missing configured secret ref(s): %s", strings.Join(missing, ", "))
}
return nil
}
func collectConfigSecretRefs(configJSON string) (map[string]bool, error) {
var value any
if err := json.Unmarshal([]byte(defaultJSONObject(configJSON)), &value); err != nil {
return nil, err
}
refs := make(map[string]bool)
collectSecretRefs(value, refs)
return refs, nil
}
func collectSecretRefs(value any, refs map[string]bool) {
switch typed := value.(type) {
case map[string]any:
for name, child := range typed {
if strings.HasSuffix(strings.ToLower(name), "_secret_ref") {
if ref, ok := child.(string); ok && ref != "" {
refs[ref] = true
}
}
collectSecretRefs(child, refs)
}
case []any:
for _, child := range typed {
collectSecretRefs(child, refs)
}
}
}
func sensitiveConfigPaths(schema json.RawMessage, configJSON string) []string {
paths := map[string]bool{}
var root map[string]any
if len(schema) > 0 {
_ = json.Unmarshal(schema, &root)
}
collectSensitiveSchemaPaths(root, "$", paths)
var config any
if err := json.Unmarshal([]byte(configJSON), &config); err == nil {
collectSensitiveNamePaths(config, "$", paths)
}
result := make([]string, 0, len(paths))
for path := range paths {
result = append(result, path)
}
sort.Strings(result)
return result
}
func collectSensitiveSchemaPaths(schema map[string]any, path string, paths map[string]bool) {
if len(schema) == 0 {
return
}
if isSensitiveSchema(schema) {
paths[path] = true
}
props, _ := schema["properties"].(map[string]any)
for name, raw := range props {
child, ok := raw.(map[string]any)
if !ok {
continue
}
collectSensitiveSchemaPaths(child, path+"."+name, paths)
}
}
func isSensitiveSchema(schema map[string]any) bool {
for _, key := range []string{"sensitive", "secret", "writeOnly"} {
if value, ok := schema[key].(bool); ok && value {
return true
}
}
if format, _ := schema["format"].(string); isSensitiveName(format) {
return true
}
return false
}
func collectSensitiveNamePaths(value any, path string, paths map[string]bool) {
switch typed := value.(type) {
case map[string]any:
for name, child := range typed {
childPath := path + "." + name
if isSensitiveName(name) {
paths[childPath] = true
}
collectSensitiveNamePaths(child, childPath, paths)
}
case []any:
for idx, child := range typed {
collectSensitiveNamePaths(child, fmt.Sprintf("%s[%d]", path, idx), paths)
}
}
}
func isSensitiveName(name string) bool {
lower := strings.ToLower(name)
for _, marker := range []string{"secret", "password", "token", "key", "credential"} {
if strings.Contains(lower, marker) {
return true
}
}
return false
}
func redactJSON(configJSON string, sensitivePaths []string) (string, error) {
var value any
if err := json.Unmarshal([]byte(configJSON), &value); err != nil {
return "", err
}
pathSet := make(map[string]bool, len(sensitivePaths))
for _, path := range sensitivePaths {
pathSet[path] = true
}
value = redactValue(value, "$", pathSet)
data, err := json.Marshal(value)
if err != nil {
return "", err
}
return string(data), nil
}
func redactValue(value any, path string, sensitive map[string]bool) any {
if sensitive[path] {
return "[REDACTED]"
}
switch typed := value.(type) {
case map[string]any:
next := make(map[string]any, len(typed))
for name, child := range typed {
next[name] = redactValue(child, path+"."+name, sensitive)
}
return next
case []any:
next := make([]any, len(typed))
for idx, child := range typed {
next[idx] = redactValue(child, fmt.Sprintf("%s[%d]", path, idx), sensitive)
}
return next
default:
return value
}
}
func redactedDiffJSON(oldConfig, newConfig string, sensitivePaths []string) (string, error) {
oldRedacted, err := redactJSON(defaultJSONObject(oldConfig), sensitivePaths)
if err != nil {
return "", err
}
newRedacted, err := redactJSON(defaultJSONObject(newConfig), sensitivePaths)
if err != nil {
return "", err
}
var oldValue any
var newValue any
if err := json.Unmarshal([]byte(oldRedacted), &oldValue); err != nil {
return "", err
}
if err := json.Unmarshal([]byte(newRedacted), &newValue); err != nil {
return "", err
}
diff := map[string]any{
"changed": !reflect.DeepEqual(oldValue, newValue),
"before": oldValue,
"after": newValue,
}
data, err := json.Marshal(diff)
if err != nil {
return "", err
}
return string(data), nil
}
func redactSecretText(text string) string {
if text == "" {
return ""
}
return "[REDACTED]"
}
func flattenHandlers(byPlugin map[string][]*upstreamHandler) []*upstreamHandler {
var handlers []*upstreamHandler
for _, pluginHandlers := range byPlugin {

View File

@@ -129,6 +129,110 @@ func TestManagerRejectsSourceArtifactLoad(t *testing.T) {
}
}
func TestManagerDryRunRejectsBadConfigWithoutGenerationChange(t *testing.T) {
adapter := &fakeAdapter{}
manager := newManagerForTest(t, adapter)
artifact := uploadTestArtifact(t, manager, "plugin-a")
plugin, err := manager.SetDesired(context.Background(), "admin", "plugin-a", artifact.ID, DesiredEnabled, `{"ok":true}`, 10)
if err != nil {
t.Fatalf("SetDesired() error = %v", err)
}
adapter.dryRunErrs = map[string]error{"plugin-a": errors.New("bad config")}
if _, err := manager.SetDesired(context.Background(), "admin", "plugin-a", artifact.ID, DesiredEnabled, `{"ok":false}`, 10); err == nil || !strings.Contains(err.Error(), "bad config") {
t.Fatalf("SetDesired(bad config) error = %v, want bad config", err)
}
after, err := manager.Plugin(context.Background(), "plugin-a")
if err != nil {
t.Fatalf("Plugin() error = %v", err)
}
if after.DesiredGeneration != plugin.DesiredGeneration || after.ConfigJSON != `{"ok":true}` {
t.Fatalf("plugin after bad config = %+v, want generation/config unchanged from %+v", after, plugin)
}
}
func TestManagerDryRunRedactsSensitiveDiff(t *testing.T) {
manager := newManagerForTest(t, &fakeAdapter{})
artifact := uploadTestArtifactWithManifest(t, manager, "plugin-a", func(manifest *Manifest) {
manifest.ConfigSchema = json.RawMessage(`{"type":"object","properties":{"token":{"type":"string","sensitive":true},"host":{"type":"string"}}}`)
})
if _, err := manager.SetDesired(context.Background(), "admin", "plugin-a", artifact.ID, DesiredDisabled, `{"token":"old","host":"a"}`, 10); err != nil {
t.Fatalf("SetDesired() error = %v", err)
}
result, err := manager.DryRunConfig(context.Background(), "plugin-a", artifact.ID, `{"token":"new","host":"b"}`)
if err != nil {
t.Fatalf("DryRunConfig() error = %v", err)
}
if strings.Contains(result.RedactedConfigJSON, "new") || strings.Contains(result.RedactedDiffJSON, "old") || strings.Contains(result.RedactedDiffJSON, "new") {
t.Fatalf("dry-run leaked secret: %+v", result)
}
if !strings.Contains(result.RedactedDiffJSON, "[REDACTED]") {
t.Fatalf("redacted diff = %s, want redaction marker", result.RedactedDiffJSON)
}
}
func TestManagerSecretVersionsAreSummariesOnly(t *testing.T) {
manager := newManagerForTest(t, &fakeAdapter{})
artifact := uploadTestArtifactWithManifest(t, manager, "plugin-a", func(manifest *Manifest) {
manifest.Secrets = []SecretSpec{{
Name: "api_token",
Required: true,
Type: "api_token",
Rotation: SecretRotation{Reload: "reload_required"},
}}
})
first, err := manager.UpsertSecret(context.Background(), "admin", "plugin-a", artifact.ID, "api_token", "secret-one", true, false)
if err != nil {
t.Fatalf("UpsertSecret(first) error = %v", err)
}
second, err := manager.UpsertSecret(context.Background(), "admin", "plugin-a", artifact.ID, "api_token", "secret-two", false, true)
if err != nil {
t.Fatalf("UpsertSecret(second) error = %v", err)
}
if first.CurrentVersion != 1 || second.CurrentVersion != 2 || second.PreviousVersion != 1 || !second.HotReload || second.ReloadRequired {
t.Fatalf("secret versions = first %+v second %+v", first, second)
}
data, _ := json.Marshal(second)
if strings.Contains(string(data), "secret-one") || strings.Contains(string(data), "secret-two") {
t.Fatalf("secret summary leaked value: %s", data)
}
}
func TestManagerRollbackConfigSnapshotRunsDryRunBeforeChangingDesired(t *testing.T) {
adapter := &fakeAdapter{}
manager := newManagerForTest(t, adapter)
artifact := uploadTestArtifact(t, manager, "plugin-a")
if _, err := manager.SetDesired(context.Background(), "admin", "plugin-a", artifact.ID, DesiredEnabled, `{"version":1}`, 10); err != nil {
t.Fatalf("SetDesired(v1) error = %v", err)
}
updated, err := manager.SetDesired(context.Background(), "admin", "plugin-a", artifact.ID, DesiredEnabled, `{"version":2}`, 20)
if err != nil {
t.Fatalf("SetDesired(v2) error = %v", err)
}
snapshots, err := manager.ListConfigSnapshots(context.Background(), "plugin-a")
if err != nil {
t.Fatalf("ListConfigSnapshots() error = %v", err)
}
if len(snapshots) != 1 {
t.Fatalf("snapshots = %d, want 1", len(snapshots))
}
adapter.dryRunErrs = map[string]error{"plugin-a": errors.New("rollback rejected")}
if _, err := manager.RollbackConfigSnapshot(context.Background(), "admin", snapshots[0].ID, false); err == nil {
t.Fatal("RollbackConfigSnapshot() error = nil, want dry-run rejection")
}
afterFail, _ := manager.Plugin(context.Background(), "plugin-a")
if afterFail.DesiredGeneration != updated.DesiredGeneration || afterFail.ConfigJSON != `{"version":2}` {
t.Fatalf("plugin after failed rollback = %+v, want unchanged %+v", afterFail, updated)
}
adapter.dryRunErrs = nil
rolledBack, err := manager.RollbackConfigSnapshot(context.Background(), "admin", snapshots[0].ID, false)
if err != nil {
t.Fatalf("RollbackConfigSnapshot(success) error = %v", err)
}
if rolledBack.ConfigJSON != `{"version":1}` || rolledBack.Priority != 20 {
t.Fatalf("rolled back plugin = %+v, want config v1 and current priority 20", rolledBack)
}
}
func TestManagerEnableDisableAndDispatch(t *testing.T) {
adapter := &fakeAdapter{}
manager := newManagerForTest(t, adapter)
@@ -590,8 +694,26 @@ func uploadTestArtifact(t *testing.T, manager *Manager, pluginID string) Artifac
func uploadTestArtifactWithCapabilities(t *testing.T, manager *Manager, pluginID string, capabilities json.RawMessage) ArtifactRecord {
t.Helper()
return uploadTestArtifactWithManifest(t, manager, pluginID, func(manifest *Manifest) {
manifest.Capabilities = capabilities
})
}
func uploadTestArtifactWithManifest(t *testing.T, manager *Manager, pluginID string, mutate func(*Manifest)) ArtifactRecord {
t.Helper()
var manifest Manifest
if err := json.Unmarshal(testManifestBytesWithCapabilities(t, pluginID, nil), &manifest); err != nil {
t.Fatalf("Unmarshal manifest error = %v", err)
}
if mutate != nil {
mutate(&manifest)
}
manifestBytes, err := json.Marshal(manifest)
if err != nil {
t.Fatalf("Marshal manifest error = %v", err)
}
packagePath := writeTestMCGP(t, map[string][]byte{
"manifest.json": testManifestBytesWithCapabilities(t, pluginID, capabilities),
"manifest.json": manifestBytes,
"plugin.so": []byte("fake plugin bytes " + pluginID),
})
artifact, err := manager.UploadArtifact(context.Background(), ArtifactUpload{
@@ -754,10 +876,12 @@ func waitForPluginManagerTest(t *testing.T, done func() bool) {
}
type fakeAdapter struct {
loads int
handlers map[string]api.UpstreamConnectHandler
loadErr error
loadErrs map[string]error
loads int
handlers map[string]api.UpstreamConnectHandler
loadErr error
loadErrs map[string]error
dryRunErr error
dryRunErrs map[string]error
}
func (a *fakeAdapter) Load(_ context.Context, artifact ArtifactRecord, _ PluginRecord, gateway *Gateway) (api.Plugin, error) {
@@ -785,6 +909,16 @@ func (a *fakeAdapter) Load(_ context.Context, artifact ArtifactRecord, _ PluginR
return &fakePlugin{}, nil
}
func (a *fakeAdapter) DryRunConfig(_ context.Context, artifact ArtifactRecord, _ PluginRecord) error {
if a.dryRunErr != nil {
return a.dryRunErr
}
if a.dryRunErrs != nil && a.dryRunErrs[artifact.PluginID] != nil {
return a.dryRunErrs[artifact.PluginID]
}
return nil
}
type fakePlugin struct {
api.AbstractPlugin
}

View File

@@ -180,6 +180,13 @@ ON CONFLICT(id) DO UPDATE SET
return r.Plugin(ctx, pluginID)
}
func (r Repository) RestoreSnapshot(ctx context.Context, actor string, snapshot ConfigSnapshotRecord) (PluginRecord, error) {
if snapshot.PluginID == "" {
return PluginRecord{}, errors.New("snapshot plugin_id is required")
}
return r.UpsertDesired(ctx, actor, snapshot.PluginID, snapshot.ArtifactID, snapshot.DesiredState, snapshot.ConfigJSON, snapshot.Priority)
}
func (r Repository) Plugin(ctx context.Context, id string) (PluginRecord, error) {
row := r.db.QueryRowContext(ctx, `
SELECT id, desired_artifact_id, active_artifact_id, loaded_artifact_id, desired_state, runtime_state,
@@ -221,6 +228,40 @@ ORDER BY priority ASC, id ASC`)
return plugins, rows.Err()
}
func (r Repository) ListConfigSnapshots(ctx context.Context, pluginID string) ([]ConfigSnapshotRecord, error) {
query := `
SELECT id, plugin_id, artifact_id, config_json, desired_state, priority, desired_generation, created_by, created_at
FROM plugin_config_snapshots`
var args []any
if pluginID != "" {
query += ` WHERE plugin_id = ?`
args = append(args, pluginID)
}
query += ` ORDER BY created_at DESC, id DESC`
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var snapshots []ConfigSnapshotRecord
for rows.Next() {
snapshot, err := scanConfigSnapshot(rows)
if err != nil {
return nil, err
}
snapshots = append(snapshots, snapshot)
}
return snapshots, rows.Err()
}
func (r Repository) ConfigSnapshot(ctx context.Context, id int64) (ConfigSnapshotRecord, error) {
row := r.db.QueryRowContext(ctx, `
SELECT id, plugin_id, artifact_id, config_json, desired_state, priority, desired_generation, created_by, created_at
FROM plugin_config_snapshots
WHERE id = ?`, id)
return scanConfigSnapshot(row)
}
func (r Repository) DesiredEnabled(ctx context.Context) ([]PluginRecord, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, desired_artifact_id, active_artifact_id, loaded_artifact_id, desired_state, runtime_state,
@@ -471,6 +512,93 @@ WHERE desired_state <> 'deleted'`)
return refs, nil
}
func (r Repository) UpsertSecret(ctx context.Context, actor, pluginID, name, value string, reloadRequired, hotReload bool) (SecretRecord, error) {
if pluginID == "" {
return SecretRecord{}, errors.New("plugin_id is required")
}
if !pluginIDPattern.MatchString(pluginID) {
return SecretRecord{}, fmt.Errorf("invalid plugin id %q", pluginID)
}
if !secretNamePattern.MatchString(name) {
return SecretRecord{}, fmt.Errorf("invalid secret name %q", name)
}
if value == "" {
return SecretRecord{}, errors.New("secret value is required")
}
now := r.now().Unix()
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return SecretRecord{}, err
}
defer tx.Rollback()
var currentVersion int64
var currentValue string
err = tx.QueryRowContext(ctx, `SELECT current_version, current_value FROM plugin_secrets WHERE plugin_id = ? AND name = ?`, pluginID, name).Scan(&currentVersion, &currentValue)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return SecretRecord{}, err
}
if errors.Is(err, sql.ErrNoRows) {
_, err = tx.ExecContext(ctx, `
INSERT INTO plugin_secrets(plugin_id, name, current_version, previous_version, current_value, previous_value, reload_required, hot_reload, updated_by, created_at, updated_at)
VALUES (?, ?, 1, 0, ?, '', ?, ?, ?, ?, ?)`,
pluginID, name, value, boolInt(reloadRequired), boolInt(hotReload), actor, now, now)
} else {
_, err = tx.ExecContext(ctx, `
UPDATE plugin_secrets
SET previous_version = current_version,
previous_value = current_value,
current_version = current_version + 1,
current_value = ?,
reload_required = ?,
hot_reload = ?,
updated_by = ?,
updated_at = ?
WHERE plugin_id = ? AND name = ?`,
value, boolInt(reloadRequired), boolInt(hotReload), actor, now, pluginID, name)
}
if err != nil {
return SecretRecord{}, err
}
if err := tx.Commit(); err != nil {
return SecretRecord{}, err
}
return r.Secret(ctx, pluginID, name)
}
func (r Repository) Secret(ctx context.Context, pluginID, name string) (SecretRecord, error) {
row := r.db.QueryRowContext(ctx, `
SELECT plugin_id, name, current_version, previous_version, reload_required, hot_reload, updated_by, created_at, updated_at
FROM plugin_secrets
WHERE plugin_id = ? AND name = ?`, pluginID, name)
return scanSecret(row)
}
func (r Repository) ListSecrets(ctx context.Context, pluginID string) ([]SecretRecord, error) {
query := `
SELECT plugin_id, name, current_version, previous_version, reload_required, hot_reload, updated_by, created_at, updated_at
FROM plugin_secrets`
var args []any
if pluginID != "" {
query += ` WHERE plugin_id = ?`
args = append(args, pluginID)
}
query += ` ORDER BY plugin_id ASC, name ASC`
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var secrets []SecretRecord
for rows.Next() {
secret, err := scanSecret(rows)
if err != nil {
return nil, err
}
secrets = append(secrets, secret)
}
return secrets, rows.Err()
}
func (r Repository) RecordOperation(ctx context.Context, pluginID, artifactID, operation, status, actor, message string, metadata any) error {
metadataJSON, err := marshalDefaultObject(metadata)
if err != nil {
@@ -527,6 +655,28 @@ func scanPluginRow(row rowScanner, plugin *PluginRecord) error {
)
}
func scanConfigSnapshot(row rowScanner) (ConfigSnapshotRecord, error) {
var snapshot ConfigSnapshotRecord
err := row.Scan(
&snapshot.ID, &snapshot.PluginID, &snapshot.ArtifactID, &snapshot.ConfigJSON, &snapshot.DesiredState,
&snapshot.Priority, &snapshot.DesiredGeneration, &snapshot.CreatedBy, &snapshot.CreatedAt,
)
return snapshot, err
}
func scanSecret(row rowScanner) (SecretRecord, error) {
var secret SecretRecord
var reloadRequired int
var hotReload int
err := row.Scan(
&secret.PluginID, &secret.Name, &secret.CurrentVersion, &secret.PreviousVersion,
&reloadRequired, &hotReload, &secret.UpdatedBy, &secret.CreatedAt, &secret.UpdatedAt,
)
secret.ReloadRequired = reloadRequired != 0
secret.HotReload = hotReload != 0
return secret, err
}
func scanBuild(row rowScanner) (BuildRecord, error) {
var build BuildRecord
var vendorRequired int

View File

@@ -88,6 +88,7 @@ type Manifest struct {
Capabilities json.RawMessage `json:"capabilities"`
RuntimeLimits RuntimeLimits `json:"runtime_limits"`
ConfigSchema json.RawMessage `json:"config_schema"`
Secrets []SecretSpec `json:"secrets,omitempty"`
SupplyChain json.RawMessage `json:"supply_chain"`
}
@@ -119,6 +120,20 @@ type RuntimeLimits struct {
InitialWriteTimeoutMS int `json:"initial_write_timeout_ms"`
}
type SecretSpec struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Required bool `json:"required"`
Type string `json:"type,omitempty"`
Rotation SecretRotation `json:"rotation,omitempty"`
}
type SecretRotation struct {
Strategy string `json:"strategy,omitempty"`
GracePeriod string `json:"grace_period,omitempty"`
Reload string `json:"reload,omitempty"`
}
type CapabilitySummary struct {
UpstreamConnect UpstreamConnectCapability `json:"upstream_connect,omitempty"`
Minecraft *MinecraftCapability `json:"minecraft,omitempty"`
@@ -196,6 +211,63 @@ type PluginRecord struct {
UpdatedBy string `json:"updated_by"`
}
type ConfigSnapshotRecord struct {
ID int64 `json:"id"`
PluginID string `json:"plugin_id"`
ArtifactID string `json:"artifact_id"`
ConfigJSON string `json:"config_json"`
DesiredState string `json:"desired_state"`
Priority int `json:"priority"`
DesiredGeneration int64 `json:"desired_generation"`
CreatedBy string `json:"created_by"`
CreatedAt int64 `json:"created_at"`
}
type ConfigDryRunResult struct {
OK bool `json:"ok"`
PluginID string `json:"plugin_id"`
ArtifactID string `json:"artifact_id"`
RestartRequired bool `json:"restart_required"`
HotReload bool `json:"hot_reload"`
SensitivePaths []string `json:"sensitive_paths"`
RedactedConfigJSON string `json:"redacted_config_json"`
RedactedDiffJSON string `json:"redacted_diff_json"`
Error string `json:"error,omitempty"`
}
type ConfigSnapshotDiff struct {
SnapshotID int64 `json:"snapshot_id"`
PluginID string `json:"plugin_id"`
ArtifactID string `json:"artifact_id"`
SensitivePaths []string `json:"sensitive_paths"`
RedactedDiffJSON string `json:"redacted_diff_json"`
RestartRequired bool `json:"restart_required"`
CurrentGeneration int64 `json:"current_generation"`
SnapshotGeneration int64 `json:"snapshot_generation"`
}
type SecretRecord struct {
PluginID string `json:"plugin_id"`
Name string `json:"name"`
CurrentVersion int64 `json:"current_version"`
PreviousVersion int64 `json:"previous_version"`
ReloadRequired bool `json:"reload_required"`
HotReload bool `json:"hot_reload"`
UpdatedBy string `json:"updated_by"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
type ProxyConnectionSummary struct {
ID uint64 `json:"id"`
PluginID string `json:"plugin_id"`
ArtifactID string `json:"artifact_id"`
HandlerID string `json:"handler_id"`
StartedAt int64 `json:"started_at"`
DurationMS int64 `json:"duration_ms"`
Draining bool `json:"draining"`
}
type OperationRecord struct {
ID int64 `json:"id"`
PluginID string `json:"plugin_id"`