5 changed files with 612 additions and 7 deletions
-
12weed/command/plugin_worker_test.go
-
5weed/command/worker.go
-
6weed/command/worker_runtime.go
-
4weed/command/worker_test.go
-
592weed/plugin/worker/admin_script_handler.go
@ -0,0 +1,592 @@ |
|||||
|
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 |
||||
|
) |
||||
|
|
||||
|
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_name", |
||||
|
Label: "Script Name", |
||||
|
Description: "Optional label used in job summaries.", |
||||
|
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING, |
||||
|
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT, |
||||
|
}, |
||||
|
{ |
||||
|
Name: "script", |
||||
|
Label: "Script", |
||||
|
Description: "Admin shell commands to execute (one per line).", |
||||
|
HelpText: "Lock/unlock are handled by the admin server; omit lock/unlock lines.", |
||||
|
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, |
||||
|
}, |
||||
|
}, |
||||
|
}, |
||||
|
}, |
||||
|
DefaultValues: map[string]*plugin_pb.ConfigValue{ |
||||
|
"script_name": { |
||||
|
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""}, |
||||
|
}, |
||||
|
"script": { |
||||
|
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""}, |
||||
|
}, |
||||
|
}, |
||||
|
}, |
||||
|
AdminRuntimeDefaults: &plugin_pb.AdminRuntimeDefaults{ |
||||
|
Enabled: false, |
||||
|
DetectionIntervalSeconds: 24 * 60 * 60, |
||||
|
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", "")) |
||||
|
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(), |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
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.SetNoLock(true) |
||||
|
|
||||
|
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() |
||||
|
} |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue