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