Browse Source
add admin script worker (#8491)
add admin script worker (#8491)
* admin: add plugin lock coordination
* shell: allow bypassing lock checks
* plugin worker: add admin script handler
* mini: include admin_script in plugin defaults
* admin script UI: drop name and enlarge text
* admin script: add default script
* admin_script: make run interval configurable
* plugin: gate other jobs during admin_script runs
* plugin: use last completed admin_script run
* admin: backfill plugin config defaults
* templ
Co-Authored-By: Copilot <223556219+Copilot@users.noreply.github.com>
* comparable to default version
Co-Authored-By: Copilot <223556219+Copilot@users.noreply.github.com>
* default to run
Co-Authored-By: Copilot <223556219+Copilot@users.noreply.github.com>
* format
Co-Authored-By: Copilot <223556219+Copilot@users.noreply.github.com>
* shell: respect pre-set noLock for fix.replication
* shell: add force no-lock mode for admin scripts
* volume balance worker already exists
Co-Authored-By: Copilot <223556219+Copilot@users.noreply.github.com>
* admin: expose scheduler status JSON
* shell: add sleep command
* shell: restrict sleep syntax
* Revert "shell: respect pre-set noLock for fix.replication"
This reverts commit 2b14e8b826.
* templ
Co-Authored-By: Copilot <223556219+Copilot@users.noreply.github.com>
* fix import
Co-Authored-By: Copilot <223556219+Copilot@users.noreply.github.com>
* less logs
Co-Authored-By: Copilot <223556219+Copilot@users.noreply.github.com>
* Reduce master client logs on canceled contexts
* Update mini default job type count
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
pull/8500/head
committed by
GitHub
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 1888 additions and 27 deletions
-
148weed/admin/dash/admin_lock_manager.go
-
38weed/admin/dash/admin_server.go
-
116weed/admin/dash/plugin_api.go
-
80weed/admin/dash/plugin_api_test.go
-
1weed/admin/handlers/admin_handlers.go
-
7weed/admin/plugin/lock_manager.go
-
84weed/admin/plugin/plugin.go
-
164weed/admin/plugin/plugin_cancel_test.go
-
61weed/admin/plugin/plugin_detection_test.go
-
20weed/admin/plugin/plugin_scheduler.go
-
234weed/admin/plugin/scheduler_status.go
-
64weed/admin/plugin/scheduler_status_test.go
-
3weed/admin/view/app/plugin.templ
-
2weed/admin/view/app/plugin_templ.go
-
2weed/command/mini.go
-
4weed/command/mini_plugin_test.go
-
12weed/command/plugin_worker_test.go
-
5weed/command/worker.go
-
6weed/command/worker_runtime.go
-
4weed/command/worker_test.go
-
635weed/plugin/worker/admin_script_handler.go
-
100weed/plugin/worker/admin_script_handler_test.go
-
4weed/shell/command_ec_balance.go
-
6weed/shell/command_ec_common.go
-
43weed/shell/command_sleep.go
-
20weed/shell/commands.go
-
52weed/wdclient/masterclient.go
@ -0,0 +1,148 @@ |
|||||
|
package dash |
||||
|
|
||||
|
import ( |
||||
|
"sync" |
||||
|
"time" |
||||
|
|
||||
|
"github.com/seaweedfs/seaweedfs/weed/wdclient" |
||||
|
"github.com/seaweedfs/seaweedfs/weed/wdclient/exclusive_locks" |
||||
|
) |
||||
|
|
||||
|
const ( |
||||
|
adminLockName = "shell" |
||||
|
adminLockClientName = "admin-plugin" |
||||
|
) |
||||
|
|
||||
|
// AdminLockManager coordinates exclusive admin locks with reference counting.
|
||||
|
// It is safe for concurrent use.
|
||||
|
type AdminLockManager struct { |
||||
|
locker *exclusive_locks.ExclusiveLocker |
||||
|
clientName string |
||||
|
|
||||
|
mu sync.Mutex |
||||
|
cond *sync.Cond |
||||
|
acquiring bool |
||||
|
holdCount int |
||||
|
|
||||
|
lastAcquiredAt time.Time |
||||
|
lastReleasedAt time.Time |
||||
|
waitingSince time.Time |
||||
|
waitingReason string |
||||
|
currentReason string |
||||
|
} |
||||
|
|
||||
|
func NewAdminLockManager(masterClient *wdclient.MasterClient, clientName string) *AdminLockManager { |
||||
|
if masterClient == nil { |
||||
|
return nil |
||||
|
} |
||||
|
if clientName == "" { |
||||
|
clientName = adminLockClientName |
||||
|
} |
||||
|
manager := &AdminLockManager{ |
||||
|
locker: exclusive_locks.NewExclusiveLocker(masterClient, adminLockName), |
||||
|
clientName: clientName, |
||||
|
} |
||||
|
manager.cond = sync.NewCond(&manager.mu) |
||||
|
return manager |
||||
|
} |
||||
|
|
||||
|
func (m *AdminLockManager) Acquire(reason string) (func(), error) { |
||||
|
if m == nil || m.locker == nil { |
||||
|
return func() {}, nil |
||||
|
} |
||||
|
|
||||
|
m.mu.Lock() |
||||
|
if reason != "" { |
||||
|
m.locker.SetMessage(reason) |
||||
|
m.currentReason = reason |
||||
|
} |
||||
|
for m.acquiring { |
||||
|
m.cond.Wait() |
||||
|
} |
||||
|
if m.holdCount == 0 { |
||||
|
m.acquiring = true |
||||
|
m.waitingSince = time.Now().UTC() |
||||
|
m.waitingReason = reason |
||||
|
m.mu.Unlock() |
||||
|
m.locker.RequestLock(m.clientName) |
||||
|
m.mu.Lock() |
||||
|
m.acquiring = false |
||||
|
m.holdCount = 1 |
||||
|
m.lastAcquiredAt = time.Now().UTC() |
||||
|
m.waitingSince = time.Time{} |
||||
|
m.waitingReason = "" |
||||
|
m.cond.Broadcast() |
||||
|
m.mu.Unlock() |
||||
|
return m.Release, nil |
||||
|
} |
||||
|
m.holdCount++ |
||||
|
if reason != "" { |
||||
|
m.currentReason = reason |
||||
|
} |
||||
|
m.mu.Unlock() |
||||
|
return m.Release, nil |
||||
|
} |
||||
|
|
||||
|
func (m *AdminLockManager) Release() { |
||||
|
if m == nil || m.locker == nil { |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
m.mu.Lock() |
||||
|
if m.holdCount <= 0 { |
||||
|
m.mu.Unlock() |
||||
|
return |
||||
|
} |
||||
|
m.holdCount-- |
||||
|
shouldRelease := m.holdCount == 0 |
||||
|
m.mu.Unlock() |
||||
|
|
||||
|
if shouldRelease { |
||||
|
m.mu.Lock() |
||||
|
m.lastReleasedAt = time.Now().UTC() |
||||
|
m.currentReason = "" |
||||
|
m.mu.Unlock() |
||||
|
m.locker.ReleaseLock() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
type LockStatus struct { |
||||
|
Held bool `json:"held"` |
||||
|
HoldCount int `json:"hold_count"` |
||||
|
Acquiring bool `json:"acquiring"` |
||||
|
Message string `json:"message,omitempty"` |
||||
|
WaitingReason string `json:"waiting_reason,omitempty"` |
||||
|
LastAcquiredAt *time.Time `json:"last_acquired_at,omitempty"` |
||||
|
LastReleasedAt *time.Time `json:"last_released_at,omitempty"` |
||||
|
WaitingSince *time.Time `json:"waiting_since,omitempty"` |
||||
|
} |
||||
|
|
||||
|
func (m *AdminLockManager) Status() LockStatus { |
||||
|
if m == nil { |
||||
|
return LockStatus{} |
||||
|
} |
||||
|
|
||||
|
m.mu.Lock() |
||||
|
defer m.mu.Unlock() |
||||
|
|
||||
|
status := LockStatus{ |
||||
|
Held: m.holdCount > 0, |
||||
|
HoldCount: m.holdCount, |
||||
|
Acquiring: m.acquiring, |
||||
|
Message: m.currentReason, |
||||
|
WaitingReason: m.waitingReason, |
||||
|
} |
||||
|
if !m.lastAcquiredAt.IsZero() { |
||||
|
at := m.lastAcquiredAt |
||||
|
status.LastAcquiredAt = &at |
||||
|
} |
||||
|
if !m.lastReleasedAt.IsZero() { |
||||
|
at := m.lastReleasedAt |
||||
|
status.LastReleasedAt = &at |
||||
|
} |
||||
|
if !m.waitingSince.IsZero() { |
||||
|
at := m.waitingSince |
||||
|
status.WaitingSince = &at |
||||
|
} |
||||
|
return status |
||||
|
} |
||||
@ -0,0 +1,7 @@ |
|||||
|
package plugin |
||||
|
|
||||
|
// LockManager provides a shared exclusive lock for admin-managed detection/execution.
|
||||
|
// Acquire returns a release function that must be called when the protected work finishes.
|
||||
|
type LockManager interface { |
||||
|
Acquire(reason string) (release func(), err error) |
||||
|
} |
||||
@ -0,0 +1,234 @@ |
|||||
|
package plugin |
||||
|
|
||||
|
import ( |
||||
|
"sort" |
||||
|
"strings" |
||||
|
"time" |
||||
|
) |
||||
|
|
||||
|
type SchedulerStatus struct { |
||||
|
Now time.Time `json:"now"` |
||||
|
SchedulerTickSeconds int `json:"scheduler_tick_seconds"` |
||||
|
Waiting []SchedulerWaitingStatus `json:"waiting,omitempty"` |
||||
|
InProcessJobs []SchedulerJobStatus `json:"in_process_jobs,omitempty"` |
||||
|
JobTypes []SchedulerJobTypeStatus `json:"job_types,omitempty"` |
||||
|
} |
||||
|
|
||||
|
type SchedulerWaitingStatus struct { |
||||
|
Reason string `json:"reason"` |
||||
|
JobType string `json:"job_type,omitempty"` |
||||
|
Since *time.Time `json:"since,omitempty"` |
||||
|
Until *time.Time `json:"until,omitempty"` |
||||
|
Details map[string]interface{} `json:"details,omitempty"` |
||||
|
} |
||||
|
|
||||
|
type SchedulerJobStatus struct { |
||||
|
JobID string `json:"job_id"` |
||||
|
JobType string `json:"job_type"` |
||||
|
State string `json:"state"` |
||||
|
Stage string `json:"stage,omitempty"` |
||||
|
WorkerID string `json:"worker_id,omitempty"` |
||||
|
Message string `json:"message,omitempty"` |
||||
|
Progress float64 `json:"progress,omitempty"` |
||||
|
CreatedAt *time.Time `json:"created_at,omitempty"` |
||||
|
UpdatedAt *time.Time `json:"updated_at,omitempty"` |
||||
|
DurationSeconds float64 `json:"duration_seconds,omitempty"` |
||||
|
} |
||||
|
|
||||
|
type SchedulerJobTypeStatus struct { |
||||
|
JobType string `json:"job_type"` |
||||
|
Enabled bool `json:"enabled"` |
||||
|
DetectionInFlight bool `json:"detection_in_flight"` |
||||
|
NextDetectionAt *time.Time `json:"next_detection_at,omitempty"` |
||||
|
DetectionIntervalSeconds int32 `json:"detection_interval_seconds,omitempty"` |
||||
|
LastDetectedAt *time.Time `json:"last_detected_at,omitempty"` |
||||
|
LastDetectedCount int `json:"last_detected_count,omitempty"` |
||||
|
LastDetectionError string `json:"last_detection_error,omitempty"` |
||||
|
LastDetectionSkipped string `json:"last_detection_skipped,omitempty"` |
||||
|
} |
||||
|
|
||||
|
type schedulerDetectionInfo struct { |
||||
|
lastDetectedAt time.Time |
||||
|
lastDetectedCount int |
||||
|
lastErrorAt time.Time |
||||
|
lastError string |
||||
|
lastSkippedAt time.Time |
||||
|
lastSkippedReason string |
||||
|
} |
||||
|
|
||||
|
func (r *Plugin) recordSchedulerDetectionSuccess(jobType string, count int) { |
||||
|
if r == nil { |
||||
|
return |
||||
|
} |
||||
|
r.schedulerDetectionMu.Lock() |
||||
|
defer r.schedulerDetectionMu.Unlock() |
||||
|
info := r.schedulerDetection[jobType] |
||||
|
if info == nil { |
||||
|
info = &schedulerDetectionInfo{} |
||||
|
r.schedulerDetection[jobType] = info |
||||
|
} |
||||
|
info.lastDetectedAt = time.Now().UTC() |
||||
|
info.lastDetectedCount = count |
||||
|
info.lastError = "" |
||||
|
info.lastSkippedReason = "" |
||||
|
} |
||||
|
|
||||
|
func (r *Plugin) recordSchedulerDetectionError(jobType string, err error) { |
||||
|
if r == nil { |
||||
|
return |
||||
|
} |
||||
|
if err == nil { |
||||
|
return |
||||
|
} |
||||
|
r.schedulerDetectionMu.Lock() |
||||
|
defer r.schedulerDetectionMu.Unlock() |
||||
|
info := r.schedulerDetection[jobType] |
||||
|
if info == nil { |
||||
|
info = &schedulerDetectionInfo{} |
||||
|
r.schedulerDetection[jobType] = info |
||||
|
} |
||||
|
info.lastErrorAt = time.Now().UTC() |
||||
|
info.lastError = err.Error() |
||||
|
} |
||||
|
|
||||
|
func (r *Plugin) recordSchedulerDetectionSkip(jobType string, reason string) { |
||||
|
if r == nil { |
||||
|
return |
||||
|
} |
||||
|
if strings.TrimSpace(reason) == "" { |
||||
|
return |
||||
|
} |
||||
|
r.schedulerDetectionMu.Lock() |
||||
|
defer r.schedulerDetectionMu.Unlock() |
||||
|
info := r.schedulerDetection[jobType] |
||||
|
if info == nil { |
||||
|
info = &schedulerDetectionInfo{} |
||||
|
r.schedulerDetection[jobType] = info |
||||
|
} |
||||
|
info.lastSkippedAt = time.Now().UTC() |
||||
|
info.lastSkippedReason = reason |
||||
|
} |
||||
|
|
||||
|
func (r *Plugin) snapshotSchedulerDetection(jobType string) schedulerDetectionInfo { |
||||
|
if r == nil { |
||||
|
return schedulerDetectionInfo{} |
||||
|
} |
||||
|
r.schedulerDetectionMu.Lock() |
||||
|
defer r.schedulerDetectionMu.Unlock() |
||||
|
info := r.schedulerDetection[jobType] |
||||
|
if info == nil { |
||||
|
return schedulerDetectionInfo{} |
||||
|
} |
||||
|
return *info |
||||
|
} |
||||
|
|
||||
|
func (r *Plugin) GetSchedulerStatus() SchedulerStatus { |
||||
|
now := time.Now().UTC() |
||||
|
status := SchedulerStatus{ |
||||
|
Now: now, |
||||
|
SchedulerTickSeconds: int(secondsFromDuration(r.schedulerTick)), |
||||
|
InProcessJobs: r.listInProcessJobs(now), |
||||
|
} |
||||
|
|
||||
|
states, err := r.ListSchedulerStates() |
||||
|
if err != nil { |
||||
|
return status |
||||
|
} |
||||
|
|
||||
|
waiting := make([]SchedulerWaitingStatus, 0) |
||||
|
jobTypes := make([]SchedulerJobTypeStatus, 0, len(states)) |
||||
|
|
||||
|
for _, state := range states { |
||||
|
jobType := state.JobType |
||||
|
info := r.snapshotSchedulerDetection(jobType) |
||||
|
|
||||
|
jobStatus := SchedulerJobTypeStatus{ |
||||
|
JobType: jobType, |
||||
|
Enabled: state.Enabled, |
||||
|
DetectionInFlight: state.DetectionInFlight, |
||||
|
NextDetectionAt: state.NextDetectionAt, |
||||
|
DetectionIntervalSeconds: state.DetectionIntervalSeconds, |
||||
|
} |
||||
|
if !info.lastDetectedAt.IsZero() { |
||||
|
jobStatus.LastDetectedAt = timeToPtr(info.lastDetectedAt) |
||||
|
jobStatus.LastDetectedCount = info.lastDetectedCount |
||||
|
} |
||||
|
if info.lastError != "" { |
||||
|
jobStatus.LastDetectionError = info.lastError |
||||
|
} |
||||
|
if info.lastSkippedReason != "" { |
||||
|
jobStatus.LastDetectionSkipped = info.lastSkippedReason |
||||
|
} |
||||
|
jobTypes = append(jobTypes, jobStatus) |
||||
|
|
||||
|
if state.DetectionInFlight { |
||||
|
waiting = append(waiting, SchedulerWaitingStatus{ |
||||
|
Reason: "detection_in_flight", |
||||
|
JobType: jobType, |
||||
|
}) |
||||
|
} else if state.Enabled && state.NextDetectionAt != nil && now.Before(*state.NextDetectionAt) { |
||||
|
waiting = append(waiting, SchedulerWaitingStatus{ |
||||
|
Reason: "next_detection_at", |
||||
|
JobType: jobType, |
||||
|
Until: state.NextDetectionAt, |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
sort.Slice(jobTypes, func(i, j int) bool { |
||||
|
return jobTypes[i].JobType < jobTypes[j].JobType |
||||
|
}) |
||||
|
|
||||
|
status.Waiting = waiting |
||||
|
status.JobTypes = jobTypes |
||||
|
return status |
||||
|
} |
||||
|
|
||||
|
func (r *Plugin) listInProcessJobs(now time.Time) []SchedulerJobStatus { |
||||
|
active := make([]SchedulerJobStatus, 0) |
||||
|
if r == nil { |
||||
|
return active |
||||
|
} |
||||
|
|
||||
|
r.jobsMu.RLock() |
||||
|
for _, job := range r.jobs { |
||||
|
if job == nil { |
||||
|
continue |
||||
|
} |
||||
|
if !isActiveTrackedJobState(job.State) { |
||||
|
continue |
||||
|
} |
||||
|
start := timeToPtr(now) |
||||
|
if job.CreatedAt != nil && !job.CreatedAt.IsZero() { |
||||
|
start = job.CreatedAt |
||||
|
} else if job.UpdatedAt != nil && !job.UpdatedAt.IsZero() { |
||||
|
start = job.UpdatedAt |
||||
|
} |
||||
|
durationSeconds := 0.0 |
||||
|
if start != nil { |
||||
|
durationSeconds = now.Sub(*start).Seconds() |
||||
|
} |
||||
|
active = append(active, SchedulerJobStatus{ |
||||
|
JobID: job.JobID, |
||||
|
JobType: job.JobType, |
||||
|
State: strings.ToLower(job.State), |
||||
|
Stage: job.Stage, |
||||
|
WorkerID: job.WorkerID, |
||||
|
Message: job.Message, |
||||
|
Progress: job.Progress, |
||||
|
CreatedAt: job.CreatedAt, |
||||
|
UpdatedAt: job.UpdatedAt, |
||||
|
DurationSeconds: durationSeconds, |
||||
|
}) |
||||
|
} |
||||
|
r.jobsMu.RUnlock() |
||||
|
|
||||
|
sort.Slice(active, func(i, j int) bool { |
||||
|
if active[i].DurationSeconds != active[j].DurationSeconds { |
||||
|
return active[i].DurationSeconds > active[j].DurationSeconds |
||||
|
} |
||||
|
return active[i].JobID < active[j].JobID |
||||
|
}) |
||||
|
|
||||
|
return active |
||||
|
} |
||||
@ -0,0 +1,64 @@ |
|||||
|
package plugin |
||||
|
|
||||
|
import ( |
||||
|
"testing" |
||||
|
|
||||
|
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb" |
||||
|
) |
||||
|
|
||||
|
func TestGetSchedulerStatusIncludesInProcessJobs(t *testing.T) { |
||||
|
pluginSvc, err := New(Options{}) |
||||
|
if err != nil { |
||||
|
t.Fatalf("New: %v", err) |
||||
|
} |
||||
|
defer pluginSvc.Shutdown() |
||||
|
|
||||
|
pluginSvc.trackExecutionStart("req-1", "worker-a", &plugin_pb.JobSpec{ |
||||
|
JobId: "job-1", |
||||
|
JobType: "vacuum", |
||||
|
}, 1) |
||||
|
|
||||
|
status := pluginSvc.GetSchedulerStatus() |
||||
|
if len(status.InProcessJobs) != 1 { |
||||
|
t.Fatalf("expected one in-process job, got %d", len(status.InProcessJobs)) |
||||
|
} |
||||
|
if status.InProcessJobs[0].JobID != "job-1" { |
||||
|
t.Fatalf("unexpected job id: %s", status.InProcessJobs[0].JobID) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func TestGetSchedulerStatusIncludesLastDetectionCount(t *testing.T) { |
||||
|
pluginSvc, err := New(Options{}) |
||||
|
if err != nil { |
||||
|
t.Fatalf("New: %v", err) |
||||
|
} |
||||
|
defer pluginSvc.Shutdown() |
||||
|
|
||||
|
const jobType = "vacuum" |
||||
|
pluginSvc.registry.UpsertFromHello(&plugin_pb.WorkerHello{ |
||||
|
WorkerId: "worker-a", |
||||
|
Capabilities: []*plugin_pb.JobTypeCapability{ |
||||
|
{JobType: jobType, CanDetect: true}, |
||||
|
}, |
||||
|
}) |
||||
|
|
||||
|
pluginSvc.recordSchedulerDetectionSuccess(jobType, 3) |
||||
|
|
||||
|
status := pluginSvc.GetSchedulerStatus() |
||||
|
found := false |
||||
|
for _, jt := range status.JobTypes { |
||||
|
if jt.JobType != jobType { |
||||
|
continue |
||||
|
} |
||||
|
found = true |
||||
|
if jt.LastDetectedCount != 3 { |
||||
|
t.Fatalf("unexpected last detected count: got=%d want=3", jt.LastDetectedCount) |
||||
|
} |
||||
|
if jt.LastDetectedAt == nil { |
||||
|
t.Fatalf("expected last detected at to be set") |
||||
|
} |
||||
|
} |
||||
|
if !found { |
||||
|
t.Fatalf("expected job type status for %s", jobType) |
||||
|
} |
||||
|
} |
||||
2
weed/admin/view/app/plugin_templ.go
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -0,0 +1,635 @@ |
|||||
|
package pluginworker |
||||
|
|
||||
|
import ( |
||||
|
"bytes" |
||||
|
"context" |
||||
|
"crypto/sha256" |
||||
|
"encoding/hex" |
||||
|
"fmt" |
||||
|
"regexp" |
||||
|
"strings" |
||||
|
"time" |
||||
|
|
||||
|
"github.com/seaweedfs/seaweedfs/weed/glog" |
||||
|
"github.com/seaweedfs/seaweedfs/weed/pb" |
||||
|
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb" |
||||
|
"github.com/seaweedfs/seaweedfs/weed/shell" |
||||
|
"google.golang.org/grpc" |
||||
|
"google.golang.org/protobuf/types/known/timestamppb" |
||||
|
) |
||||
|
|
||||
|
const ( |
||||
|
adminScriptJobType = "admin_script" |
||||
|
maxAdminScriptOutputBytes = 16 * 1024 |
||||
|
defaultAdminScriptRunMins = 17 |
||||
|
adminScriptDetectTickSecs = 60 |
||||
|
) |
||||
|
|
||||
|
const defaultAdminScript = `ec.balance -apply |
||||
|
fs.log.purge -daysAgo=7 |
||||
|
volume.deleteEmpty -quietFor=24h -apply |
||||
|
volume.fix.replication -apply |
||||
|
s3.clean.uploads -timeAgo=24h` |
||||
|
|
||||
|
var adminScriptTokenRegex = regexp.MustCompile(`'.*?'|".*?"|\S+`) |
||||
|
|
||||
|
type AdminScriptHandler struct { |
||||
|
grpcDialOption grpc.DialOption |
||||
|
} |
||||
|
|
||||
|
func NewAdminScriptHandler(grpcDialOption grpc.DialOption) *AdminScriptHandler { |
||||
|
return &AdminScriptHandler{grpcDialOption: grpcDialOption} |
||||
|
} |
||||
|
|
||||
|
func (h *AdminScriptHandler) Capability() *plugin_pb.JobTypeCapability { |
||||
|
return &plugin_pb.JobTypeCapability{ |
||||
|
JobType: adminScriptJobType, |
||||
|
CanDetect: true, |
||||
|
CanExecute: true, |
||||
|
MaxDetectionConcurrency: 1, |
||||
|
MaxExecutionConcurrency: 1, |
||||
|
DisplayName: "Admin Script", |
||||
|
Description: "Execute custom admin shell scripts", |
||||
|
Weight: 20, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func (h *AdminScriptHandler) Descriptor() *plugin_pb.JobTypeDescriptor { |
||||
|
return &plugin_pb.JobTypeDescriptor{ |
||||
|
JobType: adminScriptJobType, |
||||
|
DisplayName: "Admin Script", |
||||
|
Description: "Run custom admin shell scripts not covered by built-in job types", |
||||
|
Icon: "fas fa-terminal", |
||||
|
DescriptorVersion: 1, |
||||
|
AdminConfigForm: &plugin_pb.ConfigForm{ |
||||
|
FormId: "admin-script-admin", |
||||
|
Title: "Admin Script Configuration", |
||||
|
Description: "Define the admin shell script to execute.", |
||||
|
Sections: []*plugin_pb.ConfigSection{ |
||||
|
{ |
||||
|
SectionId: "script", |
||||
|
Title: "Script", |
||||
|
Description: "Commands run sequentially by the admin script worker.", |
||||
|
Fields: []*plugin_pb.ConfigField{ |
||||
|
{ |
||||
|
Name: "script", |
||||
|
Label: "Script", |
||||
|
Description: "Admin shell commands to execute (one per line).", |
||||
|
HelpText: "Lock/unlock are handled by the admin server; omit explicit lock/unlock commands.", |
||||
|
Placeholder: "volume.balance -apply\nvolume.fix.replication -apply", |
||||
|
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING, |
||||
|
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXTAREA, |
||||
|
Required: true, |
||||
|
}, |
||||
|
{ |
||||
|
Name: "run_interval_minutes", |
||||
|
Label: "Run Interval (minutes)", |
||||
|
Description: "Minimum interval between successful admin script runs.", |
||||
|
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64, |
||||
|
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER, |
||||
|
Required: true, |
||||
|
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 1}}, |
||||
|
}, |
||||
|
}, |
||||
|
}, |
||||
|
}, |
||||
|
DefaultValues: map[string]*plugin_pb.ConfigValue{ |
||||
|
"script": { |
||||
|
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: defaultAdminScript}, |
||||
|
}, |
||||
|
"run_interval_minutes": { |
||||
|
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultAdminScriptRunMins}, |
||||
|
}, |
||||
|
}, |
||||
|
}, |
||||
|
AdminRuntimeDefaults: &plugin_pb.AdminRuntimeDefaults{ |
||||
|
Enabled: true, |
||||
|
DetectionIntervalSeconds: adminScriptDetectTickSecs, |
||||
|
DetectionTimeoutSeconds: 300, |
||||
|
MaxJobsPerDetection: 1, |
||||
|
GlobalExecutionConcurrency: 1, |
||||
|
PerWorkerExecutionConcurrency: 1, |
||||
|
RetryLimit: 0, |
||||
|
RetryBackoffSeconds: 30, |
||||
|
}, |
||||
|
WorkerDefaultValues: map[string]*plugin_pb.ConfigValue{}, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func (h *AdminScriptHandler) Detect(ctx context.Context, request *plugin_pb.RunDetectionRequest, sender DetectionSender) error { |
||||
|
if request == nil { |
||||
|
return fmt.Errorf("run detection request is nil") |
||||
|
} |
||||
|
if sender == nil { |
||||
|
return fmt.Errorf("detection sender is nil") |
||||
|
} |
||||
|
if request.JobType != "" && request.JobType != adminScriptJobType { |
||||
|
return fmt.Errorf("job type %q is not handled by admin_script worker", request.JobType) |
||||
|
} |
||||
|
|
||||
|
script := normalizeAdminScript(readStringConfig(request.GetAdminConfigValues(), "script", "")) |
||||
|
scriptName := strings.TrimSpace(readStringConfig(request.GetAdminConfigValues(), "script_name", "")) |
||||
|
runIntervalMinutes := readAdminScriptRunIntervalMinutes(request.GetAdminConfigValues()) |
||||
|
if shouldSkipDetectionByInterval(request.GetLastSuccessfulRun(), runIntervalMinutes*60) { |
||||
|
_ = sender.SendActivity(buildDetectorActivity( |
||||
|
"skipped_by_interval", |
||||
|
fmt.Sprintf("ADMIN SCRIPT: Detection skipped due to run interval (%dm)", runIntervalMinutes), |
||||
|
map[string]*plugin_pb.ConfigValue{ |
||||
|
"run_interval_minutes": { |
||||
|
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(runIntervalMinutes)}, |
||||
|
}, |
||||
|
}, |
||||
|
)) |
||||
|
if err := sender.SendProposals(&plugin_pb.DetectionProposals{ |
||||
|
JobType: adminScriptJobType, |
||||
|
Proposals: []*plugin_pb.JobProposal{}, |
||||
|
HasMore: false, |
||||
|
}); err != nil { |
||||
|
return err |
||||
|
} |
||||
|
return sender.SendComplete(&plugin_pb.DetectionComplete{ |
||||
|
JobType: adminScriptJobType, |
||||
|
Success: true, |
||||
|
TotalProposals: 0, |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
commands := parseAdminScriptCommands(script) |
||||
|
execCount := countExecutableCommands(commands) |
||||
|
if execCount == 0 { |
||||
|
_ = sender.SendActivity(buildDetectorActivity( |
||||
|
"no_script", |
||||
|
"ADMIN SCRIPT: No executable commands configured", |
||||
|
map[string]*plugin_pb.ConfigValue{ |
||||
|
"command_count": { |
||||
|
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(execCount)}, |
||||
|
}, |
||||
|
}, |
||||
|
)) |
||||
|
if err := sender.SendProposals(&plugin_pb.DetectionProposals{ |
||||
|
JobType: adminScriptJobType, |
||||
|
Proposals: []*plugin_pb.JobProposal{}, |
||||
|
HasMore: false, |
||||
|
}); err != nil { |
||||
|
return err |
||||
|
} |
||||
|
return sender.SendComplete(&plugin_pb.DetectionComplete{ |
||||
|
JobType: adminScriptJobType, |
||||
|
Success: true, |
||||
|
TotalProposals: 0, |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
proposal := buildAdminScriptProposal(script, scriptName, execCount) |
||||
|
proposals := []*plugin_pb.JobProposal{proposal} |
||||
|
hasMore := false |
||||
|
maxResults := int(request.MaxResults) |
||||
|
if maxResults > 0 && len(proposals) > maxResults { |
||||
|
proposals = proposals[:maxResults] |
||||
|
hasMore = true |
||||
|
} |
||||
|
|
||||
|
if err := sender.SendProposals(&plugin_pb.DetectionProposals{ |
||||
|
JobType: adminScriptJobType, |
||||
|
Proposals: proposals, |
||||
|
HasMore: hasMore, |
||||
|
}); err != nil { |
||||
|
return err |
||||
|
} |
||||
|
|
||||
|
return sender.SendComplete(&plugin_pb.DetectionComplete{ |
||||
|
JobType: adminScriptJobType, |
||||
|
Success: true, |
||||
|
TotalProposals: 1, |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
func (h *AdminScriptHandler) Execute(ctx context.Context, request *plugin_pb.ExecuteJobRequest, sender ExecutionSender) error { |
||||
|
if request == nil || request.Job == nil { |
||||
|
return fmt.Errorf("execute job request is nil") |
||||
|
} |
||||
|
if sender == nil { |
||||
|
return fmt.Errorf("execution sender is nil") |
||||
|
} |
||||
|
if request.Job.JobType != "" && request.Job.JobType != adminScriptJobType { |
||||
|
return fmt.Errorf("job type %q is not handled by admin_script worker", request.Job.JobType) |
||||
|
} |
||||
|
|
||||
|
script := normalizeAdminScript(readStringConfig(request.Job.Parameters, "script", "")) |
||||
|
scriptName := strings.TrimSpace(readStringConfig(request.Job.Parameters, "script_name", "")) |
||||
|
if script == "" { |
||||
|
script = normalizeAdminScript(readStringConfig(request.GetAdminConfigValues(), "script", "")) |
||||
|
} |
||||
|
if scriptName == "" { |
||||
|
scriptName = strings.TrimSpace(readStringConfig(request.GetAdminConfigValues(), "script_name", "")) |
||||
|
} |
||||
|
|
||||
|
commands := parseAdminScriptCommands(script) |
||||
|
execCommands := filterExecutableCommands(commands) |
||||
|
if len(execCommands) == 0 { |
||||
|
return sender.SendCompleted(&plugin_pb.JobCompleted{ |
||||
|
Success: false, |
||||
|
ErrorMessage: "no executable admin script commands configured", |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
commandEnv, cancel, err := h.buildAdminScriptCommandEnv(ctx, request.ClusterContext) |
||||
|
if err != nil { |
||||
|
return sender.SendCompleted(&plugin_pb.JobCompleted{ |
||||
|
Success: false, |
||||
|
ErrorMessage: err.Error(), |
||||
|
}) |
||||
|
} |
||||
|
defer cancel() |
||||
|
|
||||
|
if err := sender.SendProgress(&plugin_pb.JobProgressUpdate{ |
||||
|
JobId: request.Job.JobId, |
||||
|
JobType: request.Job.JobType, |
||||
|
State: plugin_pb.JobState_JOB_STATE_ASSIGNED, |
||||
|
ProgressPercent: 0, |
||||
|
Stage: "assigned", |
||||
|
Message: "admin script job accepted", |
||||
|
Activities: []*plugin_pb.ActivityEvent{ |
||||
|
buildExecutorActivity("assigned", "admin script job accepted"), |
||||
|
}, |
||||
|
}); err != nil { |
||||
|
return err |
||||
|
} |
||||
|
|
||||
|
output := &limitedBuffer{maxBytes: maxAdminScriptOutputBytes} |
||||
|
executed := 0 |
||||
|
errorMessages := make([]string, 0) |
||||
|
executedCommands := make([]string, 0, len(execCommands)) |
||||
|
|
||||
|
for _, cmd := range execCommands { |
||||
|
if ctx.Err() != nil { |
||||
|
errorMessages = append(errorMessages, ctx.Err().Error()) |
||||
|
break |
||||
|
} |
||||
|
|
||||
|
commandLine := formatAdminScriptCommand(cmd) |
||||
|
executedCommands = append(executedCommands, commandLine) |
||||
|
_, _ = fmt.Fprintf(output, "$ %s\n", commandLine) |
||||
|
|
||||
|
found := false |
||||
|
for _, command := range shell.Commands { |
||||
|
if command.Name() != cmd.Name { |
||||
|
continue |
||||
|
} |
||||
|
found = true |
||||
|
if err := command.Do(cmd.Args, commandEnv, output); err != nil { |
||||
|
msg := fmt.Sprintf("%s: %v", cmd.Name, err) |
||||
|
errorMessages = append(errorMessages, msg) |
||||
|
_ = sender.SendProgress(&plugin_pb.JobProgressUpdate{ |
||||
|
State: plugin_pb.JobState_JOB_STATE_RUNNING, |
||||
|
ProgressPercent: percentProgress(executed+1, len(execCommands)), |
||||
|
Stage: "error", |
||||
|
Message: msg, |
||||
|
Activities: []*plugin_pb.ActivityEvent{ |
||||
|
buildExecutorActivity("error", msg), |
||||
|
}, |
||||
|
}) |
||||
|
} |
||||
|
break |
||||
|
} |
||||
|
|
||||
|
if !found { |
||||
|
msg := fmt.Sprintf("unknown admin command: %s", cmd.Name) |
||||
|
errorMessages = append(errorMessages, msg) |
||||
|
_ = sender.SendProgress(&plugin_pb.JobProgressUpdate{ |
||||
|
State: plugin_pb.JobState_JOB_STATE_RUNNING, |
||||
|
ProgressPercent: percentProgress(executed+1, len(execCommands)), |
||||
|
Stage: "error", |
||||
|
Message: msg, |
||||
|
Activities: []*plugin_pb.ActivityEvent{ |
||||
|
buildExecutorActivity("error", msg), |
||||
|
}, |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
executed++ |
||||
|
progress := percentProgress(executed, len(execCommands)) |
||||
|
_ = sender.SendProgress(&plugin_pb.JobProgressUpdate{ |
||||
|
State: plugin_pb.JobState_JOB_STATE_RUNNING, |
||||
|
ProgressPercent: progress, |
||||
|
Stage: "running", |
||||
|
Message: fmt.Sprintf("executed %d/%d command(s)", executed, len(execCommands)), |
||||
|
Activities: []*plugin_pb.ActivityEvent{ |
||||
|
buildExecutorActivity("running", commandLine), |
||||
|
}, |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
scriptHash := hashAdminScript(script) |
||||
|
resultSummary := fmt.Sprintf("admin script executed (%d command(s))", executed) |
||||
|
if scriptName != "" { |
||||
|
resultSummary = fmt.Sprintf("admin script %q executed (%d command(s))", scriptName, executed) |
||||
|
} |
||||
|
|
||||
|
outputValues := map[string]*plugin_pb.ConfigValue{ |
||||
|
"command_count": { |
||||
|
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(executed)}, |
||||
|
}, |
||||
|
"error_count": { |
||||
|
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(len(errorMessages))}, |
||||
|
}, |
||||
|
"script_hash": { |
||||
|
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: scriptHash}, |
||||
|
}, |
||||
|
} |
||||
|
if scriptName != "" { |
||||
|
outputValues["script_name"] = &plugin_pb.ConfigValue{ |
||||
|
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: scriptName}, |
||||
|
} |
||||
|
} |
||||
|
if len(executedCommands) > 0 { |
||||
|
outputValues["commands"] = &plugin_pb.ConfigValue{ |
||||
|
Kind: &plugin_pb.ConfigValue_StringList{ |
||||
|
StringList: &plugin_pb.StringList{Values: executedCommands}, |
||||
|
}, |
||||
|
} |
||||
|
} |
||||
|
if out := strings.TrimSpace(output.String()); out != "" { |
||||
|
outputValues["output"] = &plugin_pb.ConfigValue{ |
||||
|
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: out}, |
||||
|
} |
||||
|
} |
||||
|
if output.truncated { |
||||
|
outputValues["output_truncated"] = &plugin_pb.ConfigValue{ |
||||
|
Kind: &plugin_pb.ConfigValue_BoolValue{BoolValue: true}, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
success := len(errorMessages) == 0 && ctx.Err() == nil |
||||
|
errorMessage := "" |
||||
|
if !success { |
||||
|
errorMessage = strings.Join(errorMessages, "; ") |
||||
|
if ctx.Err() != nil { |
||||
|
if errorMessage == "" { |
||||
|
errorMessage = ctx.Err().Error() |
||||
|
} else { |
||||
|
errorMessage = fmt.Sprintf("%s; %s", errorMessage, ctx.Err().Error()) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return sender.SendCompleted(&plugin_pb.JobCompleted{ |
||||
|
Success: success, |
||||
|
ErrorMessage: errorMessage, |
||||
|
Result: &plugin_pb.JobResult{ |
||||
|
Summary: resultSummary, |
||||
|
OutputValues: outputValues, |
||||
|
}, |
||||
|
Activities: []*plugin_pb.ActivityEvent{ |
||||
|
buildExecutorActivity("completed", resultSummary), |
||||
|
}, |
||||
|
CompletedAt: timestamppb.Now(), |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
func readAdminScriptRunIntervalMinutes(values map[string]*plugin_pb.ConfigValue) int { |
||||
|
runIntervalMinutes := int(readInt64Config(values, "run_interval_minutes", defaultAdminScriptRunMins)) |
||||
|
if runIntervalMinutes <= 0 { |
||||
|
return defaultAdminScriptRunMins |
||||
|
} |
||||
|
return runIntervalMinutes |
||||
|
} |
||||
|
|
||||
|
type adminScriptCommand struct { |
||||
|
Name string |
||||
|
Args []string |
||||
|
Raw string |
||||
|
} |
||||
|
|
||||
|
func normalizeAdminScript(script string) string { |
||||
|
script = strings.ReplaceAll(script, "\r\n", "\n") |
||||
|
return strings.TrimSpace(script) |
||||
|
} |
||||
|
|
||||
|
func parseAdminScriptCommands(script string) []adminScriptCommand { |
||||
|
script = normalizeAdminScript(script) |
||||
|
if script == "" { |
||||
|
return nil |
||||
|
} |
||||
|
lines := strings.Split(script, "\n") |
||||
|
commands := make([]adminScriptCommand, 0) |
||||
|
for _, line := range lines { |
||||
|
line = strings.TrimSpace(line) |
||||
|
if line == "" || strings.HasPrefix(line, "#") { |
||||
|
continue |
||||
|
} |
||||
|
for _, chunk := range strings.Split(line, ";") { |
||||
|
chunk = strings.TrimSpace(chunk) |
||||
|
if chunk == "" { |
||||
|
continue |
||||
|
} |
||||
|
parts := adminScriptTokenRegex.FindAllString(chunk, -1) |
||||
|
if len(parts) == 0 { |
||||
|
continue |
||||
|
} |
||||
|
args := make([]string, 0, len(parts)-1) |
||||
|
for _, arg := range parts[1:] { |
||||
|
args = append(args, strings.Trim(arg, "\"'")) |
||||
|
} |
||||
|
commands = append(commands, adminScriptCommand{ |
||||
|
Name: strings.TrimSpace(parts[0]), |
||||
|
Args: args, |
||||
|
Raw: chunk, |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
|
return commands |
||||
|
} |
||||
|
|
||||
|
func filterExecutableCommands(commands []adminScriptCommand) []adminScriptCommand { |
||||
|
exec := make([]adminScriptCommand, 0, len(commands)) |
||||
|
for _, cmd := range commands { |
||||
|
if cmd.Name == "" { |
||||
|
continue |
||||
|
} |
||||
|
if isAdminScriptLockCommand(cmd.Name) { |
||||
|
continue |
||||
|
} |
||||
|
exec = append(exec, cmd) |
||||
|
} |
||||
|
return exec |
||||
|
} |
||||
|
|
||||
|
func countExecutableCommands(commands []adminScriptCommand) int { |
||||
|
count := 0 |
||||
|
for _, cmd := range commands { |
||||
|
if cmd.Name == "" { |
||||
|
continue |
||||
|
} |
||||
|
if isAdminScriptLockCommand(cmd.Name) { |
||||
|
continue |
||||
|
} |
||||
|
count++ |
||||
|
} |
||||
|
return count |
||||
|
} |
||||
|
|
||||
|
func isAdminScriptLockCommand(name string) bool { |
||||
|
switch strings.ToLower(strings.TrimSpace(name)) { |
||||
|
case "lock", "unlock": |
||||
|
return true |
||||
|
default: |
||||
|
return false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func buildAdminScriptProposal(script, scriptName string, commandCount int) *plugin_pb.JobProposal { |
||||
|
scriptHash := hashAdminScript(script) |
||||
|
summary := "Run admin script" |
||||
|
if scriptName != "" { |
||||
|
summary = fmt.Sprintf("Run admin script: %s", scriptName) |
||||
|
} |
||||
|
detail := fmt.Sprintf("Admin script with %d command(s)", commandCount) |
||||
|
proposalID := fmt.Sprintf("admin-script-%s-%d", scriptHash[:8], time.Now().UnixNano()) |
||||
|
|
||||
|
labels := map[string]string{ |
||||
|
"script_hash": scriptHash, |
||||
|
} |
||||
|
if scriptName != "" { |
||||
|
labels["script_name"] = scriptName |
||||
|
} |
||||
|
|
||||
|
return &plugin_pb.JobProposal{ |
||||
|
ProposalId: proposalID, |
||||
|
DedupeKey: "admin-script:" + scriptHash, |
||||
|
JobType: adminScriptJobType, |
||||
|
Priority: plugin_pb.JobPriority_JOB_PRIORITY_NORMAL, |
||||
|
Summary: summary, |
||||
|
Detail: detail, |
||||
|
Parameters: map[string]*plugin_pb.ConfigValue{ |
||||
|
"script": { |
||||
|
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: script}, |
||||
|
}, |
||||
|
"script_name": { |
||||
|
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: scriptName}, |
||||
|
}, |
||||
|
"script_hash": { |
||||
|
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: scriptHash}, |
||||
|
}, |
||||
|
"command_count": { |
||||
|
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(commandCount)}, |
||||
|
}, |
||||
|
}, |
||||
|
Labels: labels, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func (h *AdminScriptHandler) buildAdminScriptCommandEnv( |
||||
|
ctx context.Context, |
||||
|
clusterContext *plugin_pb.ClusterContext, |
||||
|
) (*shell.CommandEnv, context.CancelFunc, error) { |
||||
|
if clusterContext == nil { |
||||
|
return nil, nil, fmt.Errorf("cluster context is required") |
||||
|
} |
||||
|
|
||||
|
masters := normalizeAddressList(clusterContext.MasterGrpcAddresses) |
||||
|
if len(masters) == 0 { |
||||
|
return nil, nil, fmt.Errorf("missing master addresses for admin script") |
||||
|
} |
||||
|
|
||||
|
filerGroup := "" |
||||
|
mastersValue := strings.Join(masters, ",") |
||||
|
options := shell.ShellOptions{ |
||||
|
Masters: &mastersValue, |
||||
|
GrpcDialOption: h.grpcDialOption, |
||||
|
FilerGroup: &filerGroup, |
||||
|
Directory: "/", |
||||
|
} |
||||
|
|
||||
|
filers := normalizeAddressList(clusterContext.FilerGrpcAddresses) |
||||
|
if len(filers) > 0 { |
||||
|
options.FilerAddress = pb.ServerAddress(filers[0]) |
||||
|
} else { |
||||
|
glog.V(1).Infof("admin script worker missing filer address; filer-dependent commands may fail") |
||||
|
} |
||||
|
|
||||
|
commandEnv := shell.NewCommandEnv(&options) |
||||
|
commandEnv.ForceNoLock() |
||||
|
|
||||
|
ctx, cancel := context.WithCancel(ctx) |
||||
|
go commandEnv.MasterClient.KeepConnectedToMaster(ctx) |
||||
|
|
||||
|
return commandEnv, cancel, nil |
||||
|
} |
||||
|
|
||||
|
func normalizeAddressList(addresses []string) []string { |
||||
|
normalized := make([]string, 0, len(addresses)) |
||||
|
seen := make(map[string]struct{}, len(addresses)) |
||||
|
for _, address := range addresses { |
||||
|
address = strings.TrimSpace(address) |
||||
|
if address == "" { |
||||
|
continue |
||||
|
} |
||||
|
if _, exists := seen[address]; exists { |
||||
|
continue |
||||
|
} |
||||
|
seen[address] = struct{}{} |
||||
|
normalized = append(normalized, address) |
||||
|
} |
||||
|
return normalized |
||||
|
} |
||||
|
|
||||
|
func hashAdminScript(script string) string { |
||||
|
sum := sha256.Sum256([]byte(script)) |
||||
|
return hex.EncodeToString(sum[:]) |
||||
|
} |
||||
|
|
||||
|
func formatAdminScriptCommand(cmd adminScriptCommand) string { |
||||
|
if len(cmd.Args) == 0 { |
||||
|
return cmd.Name |
||||
|
} |
||||
|
return fmt.Sprintf("%s %s", cmd.Name, strings.Join(cmd.Args, " ")) |
||||
|
} |
||||
|
|
||||
|
func percentProgress(done, total int) float64 { |
||||
|
if total <= 0 { |
||||
|
return 0 |
||||
|
} |
||||
|
if done < 0 { |
||||
|
done = 0 |
||||
|
} |
||||
|
if done > total { |
||||
|
done = total |
||||
|
} |
||||
|
return float64(done) / float64(total) * 100 |
||||
|
} |
||||
|
|
||||
|
type limitedBuffer struct { |
||||
|
buf bytes.Buffer |
||||
|
maxBytes int |
||||
|
truncated bool |
||||
|
} |
||||
|
|
||||
|
func (b *limitedBuffer) Write(p []byte) (int, error) { |
||||
|
if b == nil { |
||||
|
return len(p), nil |
||||
|
} |
||||
|
if b.maxBytes <= 0 { |
||||
|
b.truncated = true |
||||
|
return len(p), nil |
||||
|
} |
||||
|
remaining := b.maxBytes - b.buf.Len() |
||||
|
if remaining <= 0 { |
||||
|
b.truncated = true |
||||
|
return len(p), nil |
||||
|
} |
||||
|
if len(p) > remaining { |
||||
|
_, _ = b.buf.Write(p[:remaining]) |
||||
|
b.truncated = true |
||||
|
return len(p), nil |
||||
|
} |
||||
|
_, _ = b.buf.Write(p) |
||||
|
return len(p), nil |
||||
|
} |
||||
|
|
||||
|
func (b *limitedBuffer) String() string { |
||||
|
if b == nil { |
||||
|
return "" |
||||
|
} |
||||
|
return b.buf.String() |
||||
|
} |
||||
@ -0,0 +1,100 @@ |
|||||
|
package pluginworker |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"strings" |
||||
|
"testing" |
||||
|
"time" |
||||
|
|
||||
|
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb" |
||||
|
"google.golang.org/protobuf/types/known/timestamppb" |
||||
|
) |
||||
|
|
||||
|
func TestAdminScriptDescriptorDefaults(t *testing.T) { |
||||
|
descriptor := NewAdminScriptHandler(nil).Descriptor() |
||||
|
if descriptor == nil { |
||||
|
t.Fatalf("expected descriptor") |
||||
|
} |
||||
|
if descriptor.AdminRuntimeDefaults == nil { |
||||
|
t.Fatalf("expected admin runtime defaults") |
||||
|
} |
||||
|
if descriptor.AdminRuntimeDefaults.DetectionIntervalSeconds != adminScriptDetectTickSecs { |
||||
|
t.Fatalf("unexpected detection interval seconds: got=%d want=%d", |
||||
|
descriptor.AdminRuntimeDefaults.DetectionIntervalSeconds, adminScriptDetectTickSecs) |
||||
|
} |
||||
|
if descriptor.AdminConfigForm == nil { |
||||
|
t.Fatalf("expected admin config form") |
||||
|
} |
||||
|
runInterval := readInt64Config(descriptor.AdminConfigForm.DefaultValues, "run_interval_minutes", 0) |
||||
|
if runInterval != defaultAdminScriptRunMins { |
||||
|
t.Fatalf("unexpected run_interval_minutes default: got=%d want=%d", runInterval, defaultAdminScriptRunMins) |
||||
|
} |
||||
|
script := readStringConfig(descriptor.AdminConfigForm.DefaultValues, "script", "") |
||||
|
if strings.TrimSpace(script) == "" { |
||||
|
t.Fatalf("expected non-empty default script") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func TestAdminScriptDetectSkipsByRunInterval(t *testing.T) { |
||||
|
handler := NewAdminScriptHandler(nil) |
||||
|
sender := &recordingDetectionSender{} |
||||
|
err := handler.Detect(context.Background(), &plugin_pb.RunDetectionRequest{ |
||||
|
JobType: adminScriptJobType, |
||||
|
LastSuccessfulRun: timestamppb.New(time.Now().Add(-2 * time.Minute)), |
||||
|
AdminConfigValues: map[string]*plugin_pb.ConfigValue{ |
||||
|
"script": { |
||||
|
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: defaultAdminScript}, |
||||
|
}, |
||||
|
"run_interval_minutes": { |
||||
|
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 17}, |
||||
|
}, |
||||
|
}, |
||||
|
}, sender) |
||||
|
if err != nil { |
||||
|
t.Fatalf("detect returned err = %v", err) |
||||
|
} |
||||
|
if sender.proposals == nil { |
||||
|
t.Fatalf("expected proposals message") |
||||
|
} |
||||
|
if len(sender.proposals.Proposals) != 0 { |
||||
|
t.Fatalf("expected zero proposals, got %d", len(sender.proposals.Proposals)) |
||||
|
} |
||||
|
if sender.complete == nil || !sender.complete.Success { |
||||
|
t.Fatalf("expected successful completion message") |
||||
|
} |
||||
|
if len(sender.events) == 0 { |
||||
|
t.Fatalf("expected detector activity events") |
||||
|
} |
||||
|
if !strings.Contains(sender.events[0].Message, "run interval") { |
||||
|
t.Fatalf("unexpected skip message: %q", sender.events[0].Message) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func TestAdminScriptDetectCreatesProposalWhenIntervalElapsed(t *testing.T) { |
||||
|
handler := NewAdminScriptHandler(nil) |
||||
|
sender := &recordingDetectionSender{} |
||||
|
err := handler.Detect(context.Background(), &plugin_pb.RunDetectionRequest{ |
||||
|
JobType: adminScriptJobType, |
||||
|
LastSuccessfulRun: timestamppb.New(time.Now().Add(-20 * time.Minute)), |
||||
|
AdminConfigValues: map[string]*plugin_pb.ConfigValue{ |
||||
|
"script": { |
||||
|
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: defaultAdminScript}, |
||||
|
}, |
||||
|
"run_interval_minutes": { |
||||
|
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 17}, |
||||
|
}, |
||||
|
}, |
||||
|
}, sender) |
||||
|
if err != nil { |
||||
|
t.Fatalf("detect returned err = %v", err) |
||||
|
} |
||||
|
if sender.proposals == nil { |
||||
|
t.Fatalf("expected proposals message") |
||||
|
} |
||||
|
if len(sender.proposals.Proposals) != 1 { |
||||
|
t.Fatalf("expected one proposal, got %d", len(sender.proposals.Proposals)) |
||||
|
} |
||||
|
if sender.complete == nil || !sender.complete.Success || sender.complete.TotalProposals != 1 { |
||||
|
t.Fatalf("unexpected completion message: %+v", sender.complete) |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,43 @@ |
|||||
|
package shell |
||||
|
|
||||
|
import ( |
||||
|
"fmt" |
||||
|
"io" |
||||
|
"strconv" |
||||
|
"time" |
||||
|
) |
||||
|
|
||||
|
func init() { |
||||
|
Commands = append(Commands, &commandSleep{}) |
||||
|
} |
||||
|
|
||||
|
// =========== Sleep ==============
|
||||
|
type commandSleep struct { |
||||
|
} |
||||
|
|
||||
|
func (c *commandSleep) Name() string { |
||||
|
return "sleep" |
||||
|
} |
||||
|
|
||||
|
func (c *commandSleep) Help() string { |
||||
|
return `sleep for N seconds (useful to simulate long running jobs) |
||||
|
|
||||
|
sleep 5 |
||||
|
` |
||||
|
} |
||||
|
|
||||
|
func (c *commandSleep) HasTag(CommandTag) bool { |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
func (c *commandSleep) Do(args []string, _ *CommandEnv, _ io.Writer) error { |
||||
|
if len(args) == 0 { |
||||
|
return fmt.Errorf("sleep requires a seconds argument") |
||||
|
} |
||||
|
seconds, err := strconv.Atoi(args[0]) |
||||
|
if err != nil || seconds <= 0 { |
||||
|
return fmt.Errorf("sleep duration must be a positive integer, got %q", args[0]) |
||||
|
} |
||||
|
time.Sleep(time.Duration(seconds) * time.Second) |
||||
|
return nil |
||||
|
} |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue