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
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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
})
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(¤tVersion, ¤tValue)
|
||||
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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user