mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-05-23 18:21:28 +00:00
* shell: s3.* commands output JSON, connection messages to stderr
All s3.user.* and s3.policy.attach|detach commands now output structured
JSON to stdout instead of human-readable text:
- s3.user.create: {"name","access_key"} (secret key to stderr only)
- s3.user.list: [{name,status,policies,keys}]
- s3.user.show: {name,status,source,account,policies,credentials,...}
- s3.user.delete: {"name"}
- s3.user.enable/disable: {"name","status"}
- s3.policy.attach/detach: {"policy","user"}
Connection startup messages (master/filer) moved to stderr so they
don't pollute structured output when piping.
Closes #8962 (partial — covers merged s3.user/policy commands).
* shell: fix secret leak, duplicate JSON output, and non-interactive prompt
- s3.user.create: only echo secret key to stderr when auto-generated,
never echo caller-supplied secrets
- s3.user.enable/disable: fix duplicate JSON output — remove inner
write in early-return path, keep single write site after gRPC call
- shell_liner: use bufio.Scanner when stdin is not a terminal instead
of liner.Prompt, suppressing the "> " prompt in piped mode
* shell: check scanner error, idempotent enable output, history errors to stderr
- Check scanner.Err() after non-interactive input loop to surface read errors
- s3.user.enable: always emit JSON regardless of current state (idempotent)
- saveHistory: write error messages to stderr instead of stdout
259 lines
5.4 KiB
Go
259 lines
5.4 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"
|
|
)
|
|
|
|
var (
|
|
line *liner.State
|
|
historyPath = path.Join(os.TempDir(), "weed-shell")
|
|
)
|
|
|
|
func RunShell(options ShellOptions) {
|
|
slices.SortFunc(Commands, func(a, b command) int {
|
|
return strings.Compare(a.Name(), b.Name())
|
|
})
|
|
line = liner.NewLiner()
|
|
defer line.Close()
|
|
grace.OnInterrupt(func() {
|
|
line.Close()
|
|
})
|
|
|
|
line.SetCtrlCAborts(true)
|
|
line.SetTabCompletionStyle(liner.TabPrints)
|
|
|
|
setCompletionHandler()
|
|
loadHistory()
|
|
|
|
defer saveHistory()
|
|
|
|
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(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
|
|
})
|
|
fmt.Fprintf(os.Stderr, "master: %s ", *options.Masters)
|
|
if len(filers) > 0 {
|
|
fmt.Fprintf(os.Stderr, "filers: %v", filers)
|
|
commandEnv.option.FilerAddress = filers[rand.IntN(len(filers))]
|
|
}
|
|
fmt.Fprintln(os.Stderr)
|
|
}
|
|
|
|
if liner.TerminalSupported() {
|
|
for {
|
|
cmd, err := line.Prompt("> ")
|
|
if err != nil {
|
|
if err != io.EOF {
|
|
fmt.Fprintf(os.Stderr, "%v\n", err)
|
|
}
|
|
return
|
|
}
|
|
|
|
if strings.TrimSpace(cmd) != "" {
|
|
line.AppendHistory(cmd)
|
|
}
|
|
|
|
for _, c := range util.StringSplit(cmd, ";") {
|
|
if processEachCmd(c, commandEnv) {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
scanner := bufio.NewScanner(os.Stdin)
|
|
for scanner.Scan() {
|
|
cmd := scanner.Text()
|
|
for _, c := range util.StringSplit(cmd, ";") {
|
|
if processEachCmd(c, commandEnv) {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "error reading stdin: %v\n", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func processEachCmd(cmd string, commandEnv *CommandEnv) bool {
|
|
cmds := splitCommandLine(cmd)
|
|
|
|
if len(cmds) == 0 {
|
|
return false
|
|
} else {
|
|
|
|
args := cmds[1:]
|
|
|
|
cmd := cmds[0]
|
|
if cmd == "help" || cmd == "?" {
|
|
printHelp(cmds)
|
|
} else if cmd == "exit" || cmd == "quit" {
|
|
return true
|
|
} else {
|
|
foundCommand := false
|
|
for _, c := range Commands {
|
|
if c.Name() == cmd || c.Name() == "fs."+cmd {
|
|
if err := c.Do(args, commandEnv, os.Stdout); err != nil {
|
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
|
}
|
|
foundCommand = true
|
|
}
|
|
}
|
|
if !foundCommand {
|
|
fmt.Fprintf(os.Stderr, "unknown command: %v\n", cmd)
|
|
}
|
|
}
|
|
|
|
}
|
|
return false
|
|
}
|
|
|
|
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.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() {
|
|
if f, err := os.Open(historyPath); err == nil {
|
|
line.ReadHistory(f)
|
|
f.Close()
|
|
}
|
|
}
|
|
|
|
func saveHistory() {
|
|
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()
|
|
}
|
|
}
|