mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-09 16:10:44 +02:00
* shell: non-interactive mode exits non-zero when a command fails A failed command in a piped weed shell run printed 'error: ...' but the process still exited 0, so a CronJob wrapping e.g. echo 's3.lifecycle.run-shard -shards 0-15' | weed shell -master=... reported green while the run aborted partway (shards N+1..15 unwalked). An unknown command likewise exited 0. RunShell now returns the last command failure from the non-interactive stdin path (unknown commands included), and the shell command exits 2 on it. Interactive sessions are unchanged: errors are shown to the operator and the session continues, exiting 0 as before. * shell: route the piped-failure exit through main's shutdown path Review follow-up: os.Exit(2) inside the shell command skipped main's shutdown work. The command now records the status (SetCommandExitStatus) and returns normally; main applies it via setExitStatus before exit(). exit() itself now flushes sentry before os.Exit -- main's deferred sentry.Flush never ran on this path (os.Exit skips defers), so the existing 'flush buffered events before the program terminates' intent only worked for the autocomplete early-return. Exit status 2 on a failed piped run is preserved (verified: piped success exits 0, piped failing command exits 2). * shell: test the registered-command failure path Review follow-up: the error-propagation test only covered unknown commands. A fake registered command now drives processEachCmd's real dispatch path: a failing Do surfaces its exact error (errors.Is) and a succeeding one returns nil. The non-interactive exit status itself is main-level plumbing, verified end to end against the reproduction (piped failure exits 2). * shell: trim the comments added with the exit status Keep the non-obvious why -- why a piped run has to fail its wrapper, why the status is recorded instead of os.Exit'ed -- and drop the narration. Claude-Session: https://claude.ai/code/session_018DWwctzD4T2DmnczPRM47t * shell: fail a piped run with the status weed already uses for that weed.go spends 1 on a command that failed and 2 on a usage or syntax error, and runShell returns true precisely so the usage dump is skipped. Exiting 2 there told a wrapper the command line was wrong. Claude-Session: https://claude.ai/code/session_018DWwctzD4T2DmnczPRM47t --------- Co-authored-by: Carlos Leyva <carlos.leyva@idener.es>
290 lines
6.5 KiB
Go
290 lines
6.5 KiB
Go
package shell
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"math/rand/v2"
|
|
"os"
|
|
"path"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/cluster"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/util"
|
|
"github.com/seaweedfs/seaweedfs/weed/util/grace"
|
|
|
|
"github.com/peterh/liner"
|
|
flag "github.com/seaweedfs/seaweedfs/weed/util/fla9"
|
|
"golang.org/x/term"
|
|
)
|
|
|
|
var historyPath = path.Join(os.TempDir(), "weed-shell")
|
|
|
|
// Piped stdin returns the last command failure; an interactive session shows
|
|
// the error to the operator and keeps going.
|
|
func RunShell(options ShellOptions) error {
|
|
slices.SortFunc(Commands, func(a, b command) int {
|
|
return strings.Compare(a.Name(), b.Name())
|
|
})
|
|
|
|
if !options.Debug {
|
|
flag.Set("alsologtostderr", "false")
|
|
flag.Set("logtostderr", "false")
|
|
}
|
|
|
|
interactive := liner.TerminalSupported() && term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd()))
|
|
|
|
var line *liner.State
|
|
if interactive {
|
|
line = liner.NewLiner()
|
|
defer line.Close()
|
|
grace.OnInterrupt(func() {
|
|
line.Close()
|
|
})
|
|
|
|
line.SetCtrlCAborts(true)
|
|
line.SetTabCompletionStyle(liner.TabPrints)
|
|
|
|
setCompletionHandler(line)
|
|
loadHistory(line)
|
|
|
|
defer saveHistory(line)
|
|
}
|
|
|
|
commandEnv := NewCommandEnv(&options)
|
|
|
|
ctx := context.Background()
|
|
go commandEnv.MasterClient.KeepConnectedToMaster(ctx)
|
|
commandEnv.MasterClient.WaitUntilConnected(ctx)
|
|
|
|
if commandEnv.option.FilerAddress == "" {
|
|
var filers []pb.ServerAddress
|
|
commandEnv.MasterClient.WithClient(ctx, false, func(client master_pb.SeaweedClient) error {
|
|
resp, err := client.ListClusterNodes(context.Background(), &master_pb.ListClusterNodesRequest{
|
|
ClientType: cluster.FilerType,
|
|
FilerGroup: *options.FilerGroup,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, clusterNode := range resp.ClusterNodes {
|
|
filers = append(filers, pb.ServerAddress(clusterNode.Address))
|
|
}
|
|
return nil
|
|
})
|
|
if len(filers) > 0 {
|
|
commandEnv.option.FilerAddress = filers[rand.IntN(len(filers))]
|
|
}
|
|
if options.Debug {
|
|
if len(filers) > 0 {
|
|
fmt.Fprintf(os.Stderr, "master: %s filers: %v\n", *options.Masters, filers)
|
|
} else {
|
|
fmt.Fprintf(os.Stderr, "master: %s\n", *options.Masters)
|
|
}
|
|
}
|
|
}
|
|
|
|
if interactive {
|
|
for {
|
|
cmd, err := line.Prompt("> ")
|
|
if err != nil {
|
|
if err != io.EOF {
|
|
fmt.Fprintf(os.Stderr, "%v\n", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if strings.TrimSpace(cmd) != "" {
|
|
line.AppendHistory(cmd)
|
|
}
|
|
|
|
for _, c := range util.StringSplit(cmd, ";") {
|
|
if exit, _ := processEachCmd(c, commandEnv); exit {
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
var lastErr error
|
|
scanner := bufio.NewScanner(os.Stdin)
|
|
for scanner.Scan() {
|
|
cmd := scanner.Text()
|
|
for _, c := range util.StringSplit(cmd, ";") {
|
|
exit, err := processEachCmd(c, commandEnv)
|
|
if err != nil {
|
|
lastErr = err
|
|
}
|
|
if exit {
|
|
return lastErr
|
|
}
|
|
}
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "error reading stdin: %v\n", err)
|
|
lastErr = err
|
|
}
|
|
return lastErr
|
|
}
|
|
}
|
|
|
|
func processEachCmd(cmd string, commandEnv *CommandEnv) (exit bool, cmdErr error) {
|
|
cmds := splitCommandLine(cmd)
|
|
|
|
if len(cmds) == 0 {
|
|
return false, nil
|
|
} else {
|
|
|
|
args := cmds[1:]
|
|
|
|
cmd := cmds[0]
|
|
if cmd == "help" || cmd == "?" {
|
|
printHelp(cmds)
|
|
} else if cmd == "exit" || cmd == "quit" {
|
|
return true, nil
|
|
} else {
|
|
foundCommand := false
|
|
for _, c := range Commands {
|
|
if c.Name() == cmd || c.Name() == "fs."+cmd {
|
|
// noLock says "this invocation changes nothing", which is a
|
|
// property of the invocation and not of the session. A
|
|
// command that set it for a dry run would otherwise leave
|
|
// every later command in the session unlocked, so a real
|
|
// mutation right after a simulation would skip its lock.
|
|
commandEnv.SetNoLock(false)
|
|
if err := c.Do(args, commandEnv, os.Stdout); err != nil {
|
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
|
cmdErr = err
|
|
}
|
|
foundCommand = true
|
|
}
|
|
}
|
|
if !foundCommand {
|
|
fmt.Fprintf(os.Stderr, "unknown command: %v\n", cmd)
|
|
cmdErr = fmt.Errorf("unknown command: %v", cmd)
|
|
}
|
|
}
|
|
|
|
}
|
|
return false, cmdErr
|
|
}
|
|
|
|
func splitCommandLine(line string) []string {
|
|
tokens, _ := parseShellInput(line, true)
|
|
return tokens
|
|
}
|
|
|
|
func parseShellInput(line string, split bool) (args []string, unbalanced bool) {
|
|
var current strings.Builder
|
|
inDoubleQuotes := false
|
|
inSingleQuotes := false
|
|
escaped := false
|
|
|
|
for i := 0; i < len(line); i++ {
|
|
c := line[i]
|
|
|
|
if escaped {
|
|
current.WriteByte(c)
|
|
escaped = false
|
|
continue
|
|
}
|
|
|
|
if c == '\\' && !inSingleQuotes {
|
|
escaped = true
|
|
continue
|
|
}
|
|
|
|
if c == '"' && !inSingleQuotes {
|
|
inDoubleQuotes = !inDoubleQuotes
|
|
continue
|
|
}
|
|
|
|
if c == '\'' && !inDoubleQuotes {
|
|
inSingleQuotes = !inSingleQuotes
|
|
continue
|
|
}
|
|
|
|
if split && (c == ' ' || c == '\t' || c == '\n' || c == '\r') && !inDoubleQuotes && !inSingleQuotes {
|
|
if current.Len() > 0 {
|
|
args = append(args, current.String())
|
|
current.Reset()
|
|
}
|
|
continue
|
|
}
|
|
|
|
current.WriteByte(c)
|
|
}
|
|
|
|
if current.Len() > 0 {
|
|
args = append(args, current.String())
|
|
}
|
|
|
|
return args, inDoubleQuotes || inSingleQuotes || escaped
|
|
}
|
|
|
|
func printGenericHelp() {
|
|
msg :=
|
|
`Type: "help <command>" for help on <command>. Most commands support "<command> -h" also for options.
|
|
`
|
|
fmt.Print(msg)
|
|
|
|
for _, c := range Commands {
|
|
if c.HasTag(Hidden) {
|
|
continue
|
|
}
|
|
helpTexts := strings.SplitN(c.Help(), "\n", 2)
|
|
fmt.Printf(" %-30s\t# %s \n", c.Name(), helpTexts[0])
|
|
}
|
|
}
|
|
|
|
func printHelp(cmds []string) {
|
|
args := cmds[1:]
|
|
if len(args) == 0 {
|
|
printGenericHelp()
|
|
} else if len(args) > 1 {
|
|
fmt.Println()
|
|
} else {
|
|
cmd := strings.ToLower(args[0])
|
|
|
|
for _, c := range Commands {
|
|
if strings.ToLower(c.Name()) == cmd {
|
|
fmt.Printf(" %s\t# %s\n", c.Name(), c.Help())
|
|
fmt.Printf("use \"%s -h\" for more details\n", c.Name())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func setCompletionHandler(line *liner.State) {
|
|
line.SetCompleter(func(line string) (c []string) {
|
|
for _, i := range Commands {
|
|
if strings.HasPrefix(i.Name(), strings.ToLower(line)) {
|
|
c = append(c, i.Name())
|
|
}
|
|
}
|
|
return
|
|
})
|
|
}
|
|
|
|
func loadHistory(line *liner.State) {
|
|
if f, err := os.Open(historyPath); err == nil {
|
|
line.ReadHistory(f)
|
|
f.Close()
|
|
}
|
|
}
|
|
|
|
func saveHistory(line *liner.State) {
|
|
if f, err := os.Create(historyPath); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error creating history file: %v\n", err)
|
|
} else {
|
|
if _, err = line.WriteHistory(f); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error writing history file: %v\n", err)
|
|
}
|
|
f.Close()
|
|
}
|
|
}
|