You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

739 lines
19 KiB

package plugin
import (
"encoding/json"
"fmt"
"net/url"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
)
const (
pluginDirName = "plugin"
jobTypesDirName = "job_types"
jobsDirName = "jobs"
jobDetailsDirName = "job_details"
activitiesDirName = "activities"
descriptorPBFileName = "descriptor.pb"
descriptorJSONFileName = "descriptor.json"
configPBFileName = "config.pb"
configJSONFileName = "config.json"
runsJSONFileName = "runs.json"
trackedJobsJSONFileName = "tracked_jobs.json"
activitiesJSONFileName = "activities.json"
defaultDirPerm = 0o755
defaultFilePerm = 0o644
)
// validJobTypePattern is the canonical pattern for safe job type names.
// Only letters, digits, underscore, dash, and dot are allowed, which prevents
// path traversal because '/', '\\', and whitespace are rejected.
var validJobTypePattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`)
// ConfigStore persists plugin configuration and bounded run history.
// If admin data dir is empty, it transparently falls back to in-memory mode.
type ConfigStore struct {
configured bool
baseDir string
mu sync.RWMutex
memDescriptors map[string]*plugin_pb.JobTypeDescriptor
memConfigs map[string]*plugin_pb.PersistedJobTypeConfig
memRunHistory map[string]*JobTypeRunHistory
memTrackedJobs []TrackedJob
memActivities []JobActivity
memJobDetails map[string]TrackedJob
}
func NewConfigStore(adminDataDir string) (*ConfigStore, error) {
store := &ConfigStore{
configured: adminDataDir != "",
memDescriptors: make(map[string]*plugin_pb.JobTypeDescriptor),
memConfigs: make(map[string]*plugin_pb.PersistedJobTypeConfig),
memRunHistory: make(map[string]*JobTypeRunHistory),
memJobDetails: make(map[string]TrackedJob),
}
if adminDataDir == "" {
return store, nil
}
store.baseDir = filepath.Join(adminDataDir, pluginDirName)
if err := os.MkdirAll(filepath.Join(store.baseDir, jobTypesDirName), defaultDirPerm); err != nil {
return nil, fmt.Errorf("create plugin job_types dir: %w", err)
}
if err := os.MkdirAll(filepath.Join(store.baseDir, jobsDirName), defaultDirPerm); err != nil {
return nil, fmt.Errorf("create plugin jobs dir: %w", err)
}
if err := os.MkdirAll(filepath.Join(store.baseDir, jobsDirName, jobDetailsDirName), defaultDirPerm); err != nil {
return nil, fmt.Errorf("create plugin job_details dir: %w", err)
}
if err := os.MkdirAll(filepath.Join(store.baseDir, activitiesDirName), defaultDirPerm); err != nil {
return nil, fmt.Errorf("create plugin activities dir: %w", err)
}
return store, nil
}
func (s *ConfigStore) IsConfigured() bool {
return s.configured
}
func (s *ConfigStore) BaseDir() string {
return s.baseDir
}
func (s *ConfigStore) SaveDescriptor(jobType string, descriptor *plugin_pb.JobTypeDescriptor) error {
if descriptor == nil {
return fmt.Errorf("descriptor is nil")
}
if _, err := sanitizeJobType(jobType); err != nil {
return err
}
clone := proto.Clone(descriptor).(*plugin_pb.JobTypeDescriptor)
if clone.JobType == "" {
clone.JobType = jobType
}
s.mu.Lock()
defer s.mu.Unlock()
if !s.configured {
s.memDescriptors[jobType] = clone
return nil
}
jobTypeDir, err := s.ensureJobTypeDir(jobType)
if err != nil {
return err
}
pbPath := filepath.Join(jobTypeDir, descriptorPBFileName)
jsonPath := filepath.Join(jobTypeDir, descriptorJSONFileName)
if err := writeProtoFiles(clone, pbPath, jsonPath); err != nil {
return fmt.Errorf("save descriptor for %s: %w", jobType, err)
}
return nil
}
func (s *ConfigStore) LoadDescriptor(jobType string) (*plugin_pb.JobTypeDescriptor, error) {
if _, err := sanitizeJobType(jobType); err != nil {
return nil, err
}
s.mu.RLock()
if !s.configured {
d := s.memDescriptors[jobType]
s.mu.RUnlock()
if d == nil {
return nil, nil
}
return proto.Clone(d).(*plugin_pb.JobTypeDescriptor), nil
}
s.mu.RUnlock()
pbPath := filepath.Join(s.baseDir, jobTypesDirName, jobType, descriptorPBFileName)
data, err := os.ReadFile(pbPath)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read descriptor for %s: %w", jobType, err)
}
var descriptor plugin_pb.JobTypeDescriptor
if err := proto.Unmarshal(data, &descriptor); err != nil {
return nil, fmt.Errorf("unmarshal descriptor for %s: %w", jobType, err)
}
return &descriptor, nil
}
func (s *ConfigStore) SaveJobTypeConfig(config *plugin_pb.PersistedJobTypeConfig) error {
if config == nil {
return fmt.Errorf("job type config is nil")
}
if config.JobType == "" {
return fmt.Errorf("job type config has empty job_type")
}
sanitizedJobType, err := sanitizeJobType(config.JobType)
if err != nil {
return err
}
// Use the sanitized job type going forward to ensure it is safe for filesystem paths.
config.JobType = sanitizedJobType
clone := proto.Clone(config).(*plugin_pb.PersistedJobTypeConfig)
s.mu.Lock()
defer s.mu.Unlock()
if !s.configured {
s.memConfigs[config.JobType] = clone
return nil
}
jobTypeDir, err := s.ensureJobTypeDir(config.JobType)
if err != nil {
return err
}
pbPath := filepath.Join(jobTypeDir, configPBFileName)
jsonPath := filepath.Join(jobTypeDir, configJSONFileName)
if err := writeProtoFiles(clone, pbPath, jsonPath); err != nil {
return fmt.Errorf("save job type config for %s: %w", config.JobType, err)
}
return nil
}
func (s *ConfigStore) LoadJobTypeConfig(jobType string) (*plugin_pb.PersistedJobTypeConfig, error) {
if _, err := sanitizeJobType(jobType); err != nil {
return nil, err
}
s.mu.RLock()
if !s.configured {
cfg := s.memConfigs[jobType]
s.mu.RUnlock()
if cfg == nil {
return nil, nil
}
return proto.Clone(cfg).(*plugin_pb.PersistedJobTypeConfig), nil
}
s.mu.RUnlock()
pbPath := filepath.Join(s.baseDir, jobTypesDirName, jobType, configPBFileName)
data, err := os.ReadFile(pbPath)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read job type config for %s: %w", jobType, err)
}
var config plugin_pb.PersistedJobTypeConfig
if err := proto.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("unmarshal job type config for %s: %w", jobType, err)
}
return &config, nil
}
func (s *ConfigStore) AppendRunRecord(jobType string, record *JobRunRecord) error {
if record == nil {
return fmt.Errorf("run record is nil")
}
if _, err := sanitizeJobType(jobType); err != nil {
return err
}
safeRecord := *record
if safeRecord.JobType == "" {
safeRecord.JobType = jobType
}
if safeRecord.CompletedAt == nil || safeRecord.CompletedAt.IsZero() {
safeRecord.CompletedAt = timeToPtr(time.Now().UTC())
}
s.mu.Lock()
defer s.mu.Unlock()
history, err := s.loadRunHistoryLocked(jobType)
if err != nil {
return err
}
if safeRecord.Outcome == RunOutcomeSuccess {
history.SuccessfulRuns = append(history.SuccessfulRuns, safeRecord)
} else {
safeRecord.Outcome = RunOutcomeError
history.ErrorRuns = append(history.ErrorRuns, safeRecord)
}
history.SuccessfulRuns = trimRuns(history.SuccessfulRuns, MaxSuccessfulRunHistory)
history.ErrorRuns = trimRuns(history.ErrorRuns, MaxErrorRunHistory)
history.LastUpdatedTime = timeToPtr(time.Now().UTC())
return s.saveRunHistoryLocked(jobType, history)
}
func (s *ConfigStore) LoadRunHistory(jobType string) (*JobTypeRunHistory, error) {
if _, err := sanitizeJobType(jobType); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
history, err := s.loadRunHistoryLocked(jobType)
if err != nil {
return nil, err
}
return cloneRunHistory(history), nil
}
func (s *ConfigStore) SaveTrackedJobs(jobs []TrackedJob) error {
s.mu.Lock()
defer s.mu.Unlock()
clone := cloneTrackedJobs(jobs)
if !s.configured {
s.memTrackedJobs = clone
return nil
}
encoded, err := json.MarshalIndent(clone, "", " ")
if err != nil {
return fmt.Errorf("encode tracked jobs: %w", err)
}
path := filepath.Join(s.baseDir, jobsDirName, trackedJobsJSONFileName)
if err := atomicWriteFile(path, encoded, defaultFilePerm); err != nil {
return fmt.Errorf("write tracked jobs: %w", err)
}
return nil
}
func (s *ConfigStore) LoadTrackedJobs() ([]TrackedJob, error) {
s.mu.RLock()
if !s.configured {
out := cloneTrackedJobs(s.memTrackedJobs)
s.mu.RUnlock()
return out, nil
}
s.mu.RUnlock()
path := filepath.Join(s.baseDir, jobsDirName, trackedJobsJSONFileName)
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read tracked jobs: %w", err)
}
var jobs []TrackedJob
if err := json.Unmarshal(data, &jobs); err != nil {
return nil, fmt.Errorf("parse tracked jobs: %w", err)
}
return cloneTrackedJobs(jobs), nil
}
func (s *ConfigStore) SaveJobDetail(job TrackedJob) error {
jobID, err := sanitizeJobID(job.JobID)
if err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
clone := cloneTrackedJob(job)
clone.JobID = jobID
if !s.configured {
s.memJobDetails[jobID] = clone
return nil
}
encoded, err := json.MarshalIndent(clone, "", " ")
if err != nil {
return fmt.Errorf("encode job detail: %w", err)
}
path := filepath.Join(s.baseDir, jobsDirName, jobDetailsDirName, jobDetailFileName(jobID))
if err := atomicWriteFile(path, encoded, defaultFilePerm); err != nil {
return fmt.Errorf("write job detail: %w", err)
}
return nil
}
func (s *ConfigStore) LoadJobDetail(jobID string) (*TrackedJob, error) {
jobID, err := sanitizeJobID(jobID)
if err != nil {
return nil, err
}
s.mu.RLock()
if !s.configured {
job, ok := s.memJobDetails[jobID]
s.mu.RUnlock()
if !ok {
return nil, nil
}
clone := cloneTrackedJob(job)
return &clone, nil
}
s.mu.RUnlock()
path := filepath.Join(s.baseDir, jobsDirName, jobDetailsDirName, jobDetailFileName(jobID))
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read job detail: %w", err)
}
var job TrackedJob
if err := json.Unmarshal(data, &job); err != nil {
return nil, fmt.Errorf("parse job detail: %w", err)
}
clone := cloneTrackedJob(job)
return &clone, nil
}
func (s *ConfigStore) SaveActivities(activities []JobActivity) error {
s.mu.Lock()
defer s.mu.Unlock()
clone := cloneActivities(activities)
if !s.configured {
s.memActivities = clone
return nil
}
encoded, err := json.MarshalIndent(clone, "", " ")
if err != nil {
return fmt.Errorf("encode activities: %w", err)
}
path := filepath.Join(s.baseDir, activitiesDirName, activitiesJSONFileName)
if err := atomicWriteFile(path, encoded, defaultFilePerm); err != nil {
return fmt.Errorf("write activities: %w", err)
}
return nil
}
func (s *ConfigStore) LoadActivities() ([]JobActivity, error) {
s.mu.RLock()
if !s.configured {
out := cloneActivities(s.memActivities)
s.mu.RUnlock()
return out, nil
}
s.mu.RUnlock()
path := filepath.Join(s.baseDir, activitiesDirName, activitiesJSONFileName)
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read activities: %w", err)
}
var activities []JobActivity
if err := json.Unmarshal(data, &activities); err != nil {
return nil, fmt.Errorf("parse activities: %w", err)
}
return cloneActivities(activities), nil
}
func (s *ConfigStore) ListJobTypes() ([]string, error) {
s.mu.RLock()
defer s.mu.RUnlock()
jobTypeSet := make(map[string]struct{})
if !s.configured {
for jobType := range s.memDescriptors {
jobTypeSet[jobType] = struct{}{}
}
for jobType := range s.memConfigs {
jobTypeSet[jobType] = struct{}{}
}
for jobType := range s.memRunHistory {
jobTypeSet[jobType] = struct{}{}
}
} else {
jobTypesPath := filepath.Join(s.baseDir, jobTypesDirName)
entries, err := os.ReadDir(jobTypesPath)
if err != nil {
if os.IsNotExist(err) {
return []string{}, nil
}
return nil, fmt.Errorf("list job types: %w", err)
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
jobType := strings.TrimSpace(entry.Name())
if _, err := sanitizeJobType(jobType); err != nil {
continue
}
jobTypeSet[jobType] = struct{}{}
}
}
jobTypes := make([]string, 0, len(jobTypeSet))
for jobType := range jobTypeSet {
jobTypes = append(jobTypes, jobType)
}
sort.Strings(jobTypes)
return jobTypes, nil
}
func (s *ConfigStore) loadRunHistoryLocked(jobType string) (*JobTypeRunHistory, error) {
if !s.configured {
history, ok := s.memRunHistory[jobType]
if !ok {
history = &JobTypeRunHistory{JobType: jobType}
s.memRunHistory[jobType] = history
}
return cloneRunHistory(history), nil
}
runsPath := filepath.Join(s.baseDir, jobTypesDirName, jobType, runsJSONFileName)
data, err := os.ReadFile(runsPath)
if err != nil {
if os.IsNotExist(err) {
return &JobTypeRunHistory{JobType: jobType}, nil
}
return nil, fmt.Errorf("read run history for %s: %w", jobType, err)
}
var history JobTypeRunHistory
if err := json.Unmarshal(data, &history); err != nil {
return nil, fmt.Errorf("parse run history for %s: %w", jobType, err)
}
if history.JobType == "" {
history.JobType = jobType
}
return &history, nil
}
func (s *ConfigStore) saveRunHistoryLocked(jobType string, history *JobTypeRunHistory) error {
if !s.configured {
s.memRunHistory[jobType] = cloneRunHistory(history)
return nil
}
jobTypeDir, err := s.ensureJobTypeDir(jobType)
if err != nil {
return err
}
encoded, err := json.MarshalIndent(history, "", " ")
if err != nil {
return fmt.Errorf("encode run history for %s: %w", jobType, err)
}
runsPath := filepath.Join(jobTypeDir, runsJSONFileName)
if err := atomicWriteFile(runsPath, encoded, defaultFilePerm); err != nil {
return fmt.Errorf("write run history for %s: %w", jobType, err)
}
return nil
}
func (s *ConfigStore) ensureJobTypeDir(jobType string) (string, error) {
if !s.configured {
return "", nil
}
jobTypeDir := filepath.Join(s.baseDir, jobTypesDirName, jobType)
if err := os.MkdirAll(jobTypeDir, defaultDirPerm); err != nil {
return "", fmt.Errorf("create job type dir for %s: %w", jobType, err)
}
return jobTypeDir, nil
}
func sanitizeJobType(jobType string) (string, error) {
jobType = strings.TrimSpace(jobType)
if jobType == "" {
return "", fmt.Errorf("job type is empty")
}
// Enforce a strict, path-safe pattern for job types: only letters, digits, underscore, dash and dot.
// This prevents path traversal because '/', '\\' and whitespace are rejected.
if !validJobTypePattern.MatchString(jobType) {
return "", fmt.Errorf("invalid job type %q: must match %s", jobType, validJobTypePattern.String())
}
return jobType, nil
}
// validJobIDPattern allows letters, digits, dash, underscore, and dot.
// url.PathEscape in jobDetailFileName provides a second layer of defense.
var validJobIDPattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`)
func sanitizeJobID(jobID string) (string, error) {
jobID = strings.TrimSpace(jobID)
if jobID == "" {
return "", fmt.Errorf("job id is empty")
}
if !validJobIDPattern.MatchString(jobID) {
return "", fmt.Errorf("invalid job id %q: must match %s", jobID, validJobIDPattern.String())
}
return jobID, nil
}
func jobDetailFileName(jobID string) string {
return url.PathEscape(jobID) + ".json"
}
func trimRuns(runs []JobRunRecord, maxKeep int) []JobRunRecord {
if len(runs) == 0 {
return runs
}
sort.Slice(runs, func(i, j int) bool {
ti := time.Time{}
if runs[i].CompletedAt != nil {
ti = *runs[i].CompletedAt
}
tj := time.Time{}
if runs[j].CompletedAt != nil {
tj = *runs[j].CompletedAt
}
return ti.After(tj)
})
if len(runs) > maxKeep {
runs = runs[:maxKeep]
}
return runs
}
func cloneRunHistory(in *JobTypeRunHistory) *JobTypeRunHistory {
if in == nil {
return nil
}
out := *in
if in.SuccessfulRuns != nil {
out.SuccessfulRuns = append([]JobRunRecord(nil), in.SuccessfulRuns...)
}
if in.ErrorRuns != nil {
out.ErrorRuns = append([]JobRunRecord(nil), in.ErrorRuns...)
}
return &out
}
func cloneTrackedJobs(in []TrackedJob) []TrackedJob {
if len(in) == 0 {
return nil
}
out := make([]TrackedJob, len(in))
for i := range in {
out[i] = cloneTrackedJob(in[i])
}
return out
}
func cloneTrackedJob(in TrackedJob) TrackedJob {
out := in
if in.Parameters != nil {
out.Parameters = make(map[string]interface{}, len(in.Parameters))
for key, value := range in.Parameters {
out.Parameters[key] = deepCopyGenericValue(value)
}
}
if in.Labels != nil {
out.Labels = make(map[string]string, len(in.Labels))
for key, value := range in.Labels {
out.Labels[key] = value
}
}
if in.ResultOutputValues != nil {
out.ResultOutputValues = make(map[string]interface{}, len(in.ResultOutputValues))
for key, value := range in.ResultOutputValues {
out.ResultOutputValues[key] = deepCopyGenericValue(value)
}
}
return out
}
func deepCopyGenericValue(val interface{}) interface{} {
switch v := val.(type) {
case map[string]interface{}:
res := make(map[string]interface{}, len(v))
for k, val := range v {
res[k] = deepCopyGenericValue(val)
}
return res
case []interface{}:
res := make([]interface{}, len(v))
for i, val := range v {
res[i] = deepCopyGenericValue(val)
}
return res
default:
return v
}
}
func cloneActivities(in []JobActivity) []JobActivity {
if len(in) == 0 {
return nil
}
out := make([]JobActivity, len(in))
for i := range in {
out[i] = in[i]
if in[i].Details != nil {
out[i].Details = make(map[string]interface{}, len(in[i].Details))
for key, value := range in[i].Details {
out[i].Details[key] = deepCopyGenericValue(value)
}
}
}
return out
}
// writeProtoFiles writes message to both a binary protobuf file (pbPath) and a
// human-readable JSON file (jsonPath) using atomicWriteFile for each.
// The .pb file is the authoritative source of truth: all reads use proto.Unmarshal
// on the .pb file. The .json file is for human inspection only, so a partial
// failure where .pb succeeds but .json fails leaves the store in a consistent state.
func writeProtoFiles(message proto.Message, pbPath string, jsonPath string) error {
pbData, err := proto.Marshal(message)
if err != nil {
return fmt.Errorf("marshal protobuf: %w", err)
}
if err := atomicWriteFile(pbPath, pbData, defaultFilePerm); err != nil {
return fmt.Errorf("write protobuf file: %w", err)
}
jsonData, err := protojson.MarshalOptions{
Multiline: true,
Indent: " ",
EmitUnpopulated: true,
}.Marshal(message)
if err != nil {
return fmt.Errorf("marshal json: %w", err)
}
if err := atomicWriteFile(jsonPath, jsonData, defaultFilePerm); err != nil {
return fmt.Errorf("write json file: %w", err)
}
return nil
}
func atomicWriteFile(filename string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(filename)
if err := os.MkdirAll(dir, defaultDirPerm); err != nil {
return fmt.Errorf("create directory %s: %w", dir, err)
}
tmpFile := filename + ".tmp"
if err := os.WriteFile(tmpFile, data, perm); err != nil {
return err
}
if err := os.Rename(tmpFile, filename); err != nil {
_ = os.Remove(tmpFile)
return err
}
return nil
}