// Copyright © 2023 tsuru-client authors
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package config
import (
"os"
"path/filepath"
"github.com/spf13/cobra"
)
var ConfigPath string
func init() {
// Find home directory.
home, err := os.UserHomeDir()
cobra.CheckErr(err)
ConfigPath = filepath.Join(home, ".tsuru")
}
// Copyright © 2023 tsuru-client authors
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package config
import (
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"github.com/spf13/afero"
)
var (
errUndefinedTarget = fmt.Errorf(`no target defined. Please use target-add/target-set to define a target.
For more details, please run "tsuru help target"`)
)
// getSavedTargets returns a map of label->target
func getSavedTargets(fsys afero.Fs) (map[string]string, error) {
var targets = map[string]string{} // label->target
// legacyTargetsPath := JoinWithUserDir(".tsuru_targets") // XXX: remove legacy file
targetsPath := filepath.Join(ConfigPath, "targets")
f, err := fsys.Open(targetsPath)
if os.IsNotExist(err) {
return targets, nil
}
if err != nil {
return nil, err
}
defer f.Close()
if b, err := io.ReadAll(f); err == nil {
var targetLines = strings.Split(strings.TrimSpace(string(b)), "\n")
for i := range targetLines {
var targetSplit = strings.Fields(targetLines[i])
if len(targetSplit) == 2 {
targets[targetSplit[0]] = targetSplit[1]
}
}
}
return targets, nil
}
// getTargetLabel finds the saved label of a target (os self if already a label).
// If target is unknown, the original target is returned with an error.
func getTargetLabel(fsys afero.Fs, target string) (string, error) {
targets, err := getSavedTargets(fsys)
if err != nil {
return "", err
}
targetKeys := make([]string, len(targets))
for k := range targets {
if k == target {
return k, nil
}
targetKeys = append(targetKeys, k)
}
sort.Strings(targetKeys)
for _, k := range targetKeys {
if targets[k] == target {
return k, nil
}
}
return target, fmt.Errorf("label for target %q not found ", target)
}
// GetCurrentTargetFromFs returns the current target (from filesystem .tsuru/target)
func GetCurrentTargetFromFs(fsys afero.Fs) (target string, err error) {
targetPath := filepath.Join(ConfigPath, "target")
if f, err := fsys.Open(targetPath); err == nil {
defer f.Close()
if b, err := io.ReadAll(f); err == nil {
target = strings.TrimSpace(string(b))
}
}
if target == "" {
return "", errUndefinedTarget
}
return target, nil
}
// GetTargetURL returns the target URL from a given alias. If the alias is not
// found, it returns the alias itself as a NormalizedTargetURL.
func GetTargetURL(fsys afero.Fs, alias string) (string, error) {
targets, err := getSavedTargets(fsys)
if err != nil {
return "", err
}
targetURL := NormalizeTargetURL(alias)
if val, ok := targets[alias]; ok {
targetURL = val
}
return targetURL, nil
}
// IsCurrentTarget checks if the target in the same from ~/.tsuru/target
func IsCurrentTarget(fsys afero.Fs, target string) bool {
target, _ = GetTargetURL(fsys, target)
if file, err := fsys.Open(filepath.Join(ConfigPath, "target")); err == nil {
defer file.Close()
defaultTarget, _ := io.ReadAll(file)
if target == string(defaultTarget) {
return true
}
}
return false
}
// SaveTarget saves the label->target in ~/.tsuru/targets list
func SaveTarget(fsys afero.Fs, label, target string) error {
allTargets, err := getSavedTargets(fsys)
if err != nil {
return err
}
allTargets[label] = NormalizeTargetURL(target)
// sorting by label
labels := make([]string, 0, len(allTargets))
for l := range allTargets {
labels = append(labels, l)
}
sort.Slice(labels, func(i, j int) bool { return labels[i] < labels[j] })
// writing all targets to temp file for atomicy
file, err := fsys.Create(filepath.Join(ConfigPath, "targets.tmp"))
if err != nil {
return err
}
for _, l := range labels {
_, err1 := fmt.Fprintf(file, "%s\t%s\n", l, allTargets[l])
if err1 != nil {
return fmt.Errorf("something went wrong when writing to targets.tmp: %w", err1)
}
}
// replace targets file
return fsys.Rename(filepath.Join(ConfigPath, "targets.tmp"), filepath.Join(ConfigPath, "targets"))
}
// SaveTargetAsCurrent saves the target in ~/.tsuru/target
func SaveTargetAsCurrent(fsys afero.Fs, target string) error {
target = NormalizeTargetURL(target)
file, err := fsys.Create(filepath.Join(ConfigPath, "target"))
if err != nil {
return err
}
defer file.Close()
_, err = fmt.Fprintf(file, "%s\n", target)
if err != nil {
return err
}
return nil
}
// NormalizeTargetURL adds an https:// if it has no protocol
func NormalizeTargetURL(target string) string {
if m, _ := regexp.MatchString("^https?://", target); !m {
target = "https://" + target
}
return target
}
// Copyright © 2023 tsuru-client authors
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package config
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/spf13/afero"
)
// GetTokenFromFs returns the token for the target.
func GetTokenFromFs(fsys afero.Fs, target string) (string, error) {
tokenPaths := []string{}
if targetLabel, err := getTargetLabel(fsys, target); err == nil {
tokenPaths = append(tokenPaths, filepath.Join(ConfigPath, "token.d", targetLabel))
}
tokenPaths = append(tokenPaths, filepath.Join(ConfigPath, "token")) // always defaults to current token
var err error
for _, tokenPath := range tokenPaths {
var tkFile afero.File
if tkFile, err = fsys.Open(tokenPath); err == nil {
defer tkFile.Close()
token, err1 := io.ReadAll(tkFile)
if err1 != nil {
return "", err1
}
tokenStr := strings.TrimSpace(string(token))
return tokenStr, nil
}
}
if os.IsNotExist(err) {
return "", nil
}
return "", err
}
// SaveTokenToFs saves the token on the filesystem for future use.
func SaveTokenToFs(fsys afero.Fs, target, token string) error {
err := fsys.MkdirAll(filepath.Join(ConfigPath, "token.d"), 0700)
if err != nil {
return err
}
tokenPaths := []string{}
if IsCurrentTarget(fsys, target) {
tokenPaths = append(tokenPaths, filepath.Join(ConfigPath, "token"))
} else if _, fErr := fsys.Stat(filepath.Join(ConfigPath, "token")); os.IsNotExist(fErr) {
tokenPaths = append(tokenPaths, filepath.Join(ConfigPath, "token"))
SaveTargetAsCurrent(fsys, target)
}
targetLabel, _ := getTargetLabel(fsys, target) // ignore err, and consider label=host
tokenPaths = append(tokenPaths, filepath.Join(ConfigPath, "token.d", hostFromURL(targetLabel)))
for _, tokenPath := range tokenPaths {
file, err := fsys.Create(tokenPath)
if err != nil {
return err
}
defer file.Close()
n, err := file.WriteString(token)
if err != nil {
return err
}
if n != len(token) {
return fmt.Errorf("failed to write token file")
}
}
return nil
}
// RemoveTokensFromFs removes the token for target.
func RemoveTokensFromFs(fsys afero.Fs, target string) error {
tokenPaths := []string{}
if IsCurrentTarget(fsys, target) {
tokenPaths = append(tokenPaths, filepath.Join(ConfigPath, "token"))
}
if targetLabel, err := getTargetLabel(fsys, target); err == nil {
tokenPaths = append(tokenPaths, filepath.Join(ConfigPath, "token.d", targetLabel))
}
errs := []error{}
for _, tokenPath := range tokenPaths {
if err := fsys.Remove(tokenPath); err != nil && !os.IsNotExist(err) {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
func hostFromURL(url string) string {
return regexp.MustCompile("^(https?://)?([0-9a-zA-Z_.-]+).*").ReplaceAllString(url, "$2")
}
// Copyright 2013 tsuru authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Package exec provides a interface to run external commands as an
// abstraction layer.
package exec
import (
"fmt"
"io"
"os/exec"
)
// ExecuteOptions specify parameters to the Execute method.
type ExecuteOptions struct {
Cmd string
Args []string
Envs []string
Dir string
Stdin io.Reader
Stdout io.Writer
Stderr io.Writer
}
var _ Executor = &OsExec{}
type Executor interface {
// Command executes the specified command.
Command(opts ExecuteOptions) error
}
type OsExec struct{}
func (*OsExec) Command(opts ExecuteOptions) error {
c := exec.Command(opts.Cmd, opts.Args...)
c.Stdin = opts.Stdin
c.Stdout = opts.Stdout
c.Stderr = opts.Stderr
c.Env = opts.Envs
c.Dir = opts.Dir
return c.Run()
}
var _ Executor = &FakeExec{}
type FakeExec struct {
OutStderr string
OutStdout string
OutErr error
CalledOpts ExecuteOptions
}
func (e *FakeExec) Command(opts ExecuteOptions) error {
if opts.Stdout != nil {
fmt.Fprint(opts.Stdout, e.OutStdout)
}
if opts.Stderr != nil {
fmt.Fprint(opts.Stderr, e.OutStderr)
}
e.CalledOpts = opts
return e.OutErr
}
// Copyright © 2023 tsuru-client authors
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//go:build !windows && !darwin
// +build !windows,!darwin
package exec
import (
"fmt"
"strings"
"golang.org/x/sys/unix"
)
func isWSL() bool {
var u unix.Utsname
err := unix.Uname(&u)
if err != nil {
fmt.Println(err)
return false
}
release := strings.ToLower(string(u.Release[:]))
return strings.Contains(release, "microsoft")
}
func Open(ex Executor, url string) error {
cmd := "xdg-open"
args := []string{url}
if isWSL() {
cmd = "cmd"
args = []string{"-c", "start", "'" + url + "'"}
}
opts := ExecuteOptions{
Cmd: cmd,
Args: args,
}
return ex.Command(opts)
}
// Copyright © 2023 tsuru-client authors
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package auth
import (
"encoding/json"
"fmt"
"strings"
"github.com/spf13/cobra"
"github.com/tsuru/tsuru-client/v2/internal/tsuructx"
)
type loginScheme struct {
Name string
Data map[string]string
}
func NewLoginCmd(tsuruCtx *tsuructx.TsuruContext) *cobra.Command {
loginCmd := &cobra.Command{
Use: "login [EMAIL]",
Short: "initiates a new tsuru session for a user",
Long: `Initiates a new tsuru session for a user. If using tsuru native authentication
scheme, it will ask for the email and the password and check if the user is
successfully authenticated. If using OAuth, it will open a web browser for the
user to complete the login.
After that, the token generated by the tsuru server will be stored in
[[${HOME}/.tsuru/token]].
All tsuru actions require the user to be authenticated (except [[tsuru login]]
and [[tsuru version]]).
`,
Example: `$ tsuru login
$ tsuru login example@tsuru.local`,
RunE: func(cmd *cobra.Command, args []string) error {
return loginCmdRun(tsuruCtx, cmd, args)
},
Args: cobra.RangeArgs(0, 1),
}
return loginCmd
}
func loginCmdRun(tsuruCtx *tsuructx.TsuruContext, cmd *cobra.Command, args []string) error {
if tsuruCtx.Token() != "" && !tsuruCtx.TokenSetFromFS {
return fmt.Errorf("this command can't run with $TSURU_TOKEN environment variable set. Did you forget to unset?")
}
cmd.SilenceUsage = true
authScheme := &loginScheme{Name: tsuruCtx.AuthScheme}
if authScheme.Name == "" {
var err error
authScheme, err = getAuthScheme(tsuruCtx)
if err != nil {
return err
}
}
switch strings.ToLower(authScheme.Name) {
case "oauth":
return oauthLogin(tsuruCtx, authScheme)
case "saml":
return fmt.Errorf("login is not implemented for saml auth. Please contact the tsuru team")
default:
return nativeLogin(tsuruCtx, cmd, args)
}
}
func getAuthScheme(tsuruCtx *tsuructx.TsuruContext) (*loginScheme, error) {
request, err := tsuruCtx.NewRequest("GET", "/auth/scheme", nil)
if err != nil {
return nil, err
}
httpResponse, err := tsuruCtx.RawHTTPClient().Do(request)
if err != nil {
return nil, err
}
defer httpResponse.Body.Close()
info := loginScheme{}
err = json.NewDecoder(httpResponse.Body).Decode(&info)
if err != nil {
return nil, err
}
return &info, nil
}
// Copyright © 2023 tsuru-client authors
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package auth
import (
"errors"
"fmt"
"github.com/spf13/cobra"
"github.com/tsuru/tsuru-client/v2/internal/config"
"github.com/tsuru/tsuru-client/v2/internal/tsuructx"
)
func NewLogoutCmd(tsuruCtx *tsuructx.TsuruContext) *cobra.Command {
loginCmd := &cobra.Command{
Use: "logout",
Short: "logout will terminate the session with the tsuru server",
Long: `logout will terminate the session with the tsuru server
and cleanup the token from the local machine.
`,
Example: `$ tsuru logout`,
RunE: func(cmd *cobra.Command, args []string) error {
return logoutCmdRun(tsuruCtx, cmd, args)
},
Args: cobra.ExactArgs(0),
}
return loginCmd
}
func logoutCmdRun(tsuruCtx *tsuructx.TsuruContext, cmd *cobra.Command, args []string) error {
cmd.SilenceUsage = true
errs := []error{}
if tsuruCtx.Token() != "" {
func() {
request, err := tsuruCtx.NewRequest("DELETE", "/users/tokens", nil)
if err != nil {
errs = append(errs, err)
return
}
httpResponse, err := tsuruCtx.RawHTTPClient().Do(request)
if err != nil {
errs = append(errs, err)
return
}
if httpResponse.StatusCode != 200 {
errs = append(errs, fmt.Errorf("unexpected response from server: %d: %s", httpResponse.StatusCode, httpResponse.Status))
}
defer httpResponse.Body.Close()
}()
}
if err := config.RemoveTokensFromFs(tsuruCtx.Fs, tsuruCtx.TargetURL()); err != nil {
errs = append(errs, err)
return errors.Join(errs...)
}
if len(errs) == 0 {
fmt.Fprintln(tsuruCtx.Stdout, "Successfully logged out!")
} else {
fmt.Fprintln(tsuruCtx.Stdout, "Logged out, but some errors occurred:")
}
return errors.Join(errs...)
}
// Copyright © 2023 tsuru-client authors
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package auth
import (
"encoding/json"
"fmt"
"io"
"net/url"
"strings"
"github.com/spf13/cobra"
"github.com/tsuru/tsuru-client/v2/internal/config"
"github.com/tsuru/tsuru-client/v2/internal/tsuructx"
"golang.org/x/term"
)
func nativeLogin(tsuruCtx *tsuructx.TsuruContext, cmd *cobra.Command, args []string) error {
var email string
if len(args) > 0 {
email = args[0]
} else {
fmt.Fprint(tsuruCtx.Stdout, "Email: ")
fmt.Fscanf(tsuruCtx.Stdin, "%s\n", &email)
}
fmt.Fprint(tsuruCtx.Stdout, "Password: ")
password, err := PasswordFromReader(tsuruCtx.Stdin)
if err != nil {
return err
}
fmt.Fprintln(tsuruCtx.Stdout)
v := url.Values{}
v.Set("password", password)
b := strings.NewReader(v.Encode())
request, err := tsuruCtx.NewRequest("POST", "/users/"+email+"/tokens", b)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
httpResponse, err := tsuruCtx.RawHTTPClient().Do(request)
if err != nil {
return err
}
defer httpResponse.Body.Close()
result, err := io.ReadAll(httpResponse.Body)
if err != nil {
return err
}
out := make(map[string]interface{})
err = json.Unmarshal(result, &out)
if err != nil {
return err
}
if _, ok := out["token"]; !ok {
return fmt.Errorf("something went wrong. No 'token' in response")
}
fmt.Fprintln(tsuruCtx.Stdout, "Successfully logged in!")
return config.SaveTokenToFs(tsuruCtx.Fs, tsuruCtx.TargetURL(), out["token"].(string))
}
func PasswordFromReader(reader io.Reader) (string, error) {
var (
password []byte
err error
)
if desc, ok := reader.(tsuructx.DescriptorReader); ok && term.IsTerminal(int(desc.Fd())) {
password, err = term.ReadPassword(int(desc.Fd()))
if err != nil {
return "", err
}
} else {
fmt.Fscanf(reader, "%s\n", &password)
}
if len(password) == 0 {
return "", fmt.Errorf("empty password. You must provide the password")
}
return string(password), err
}
// Copyright © 2023 tsuru-client authors
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package auth
import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"time"
"github.com/pkg/errors"
"github.com/tsuru/tsuru-client/v2/internal/config"
"github.com/tsuru/tsuru-client/v2/internal/exec"
"github.com/tsuru/tsuru-client/v2/internal/tsuructx"
)
const callbackPage = `<!DOCTYPE html>
<html>
<head>
<style>
body {
text-align: center;
}
</style>
</head>
<body>
%s
</body>
</html>
`
const successMarkup = `
<script>window.close();</script>
<h1>Login Successful!</h1>
<p>You can close this window now.</p>
`
const errorMarkup = `
<h1>Login Failed!</h1>
<p>%s</p>
`
func port(schemeData map[string]string) string {
p := schemeData["port"]
if p != "" {
return fmt.Sprintf(":%s", p)
}
return ":0"
}
func getToken(tsuruCtx *tsuructx.TsuruContext, code, redirectURL string) (token string, err error) {
v := url.Values{}
v.Set("code", code)
v.Set("redirectUrl", redirectURL)
b := strings.NewReader(v.Encode())
request, err := tsuruCtx.NewRequest("POST", "/auth/login", b)
if err != nil {
return
}
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
httpResponse, err := tsuruCtx.RawHTTPClient().Do(request)
if err != nil {
return token, errors.Wrap(err, "error during login post")
}
defer httpResponse.Body.Close()
result, err := io.ReadAll(httpResponse.Body)
if err != nil {
return token, errors.Wrap(err, "error reading body")
}
data := make(map[string]interface{})
err = json.Unmarshal(result, &data)
if err != nil {
return token, errors.Wrapf(err, "error parsing response: %s", result)
}
return data["token"].(string), nil
}
func callback(tsuruCtx *tsuructx.TsuruContext, redirectURL string, finish chan bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
defer func() {
finish <- true
}()
var page string
token, err := getToken(tsuruCtx, r.URL.Query().Get("code"), redirectURL)
if err == nil {
config.SaveTokenToFs(tsuruCtx.Fs, tsuruCtx.TargetURL(), token)
page = fmt.Sprintf(callbackPage, successMarkup)
} else {
msg := fmt.Sprintf(errorMarkup, err.Error())
page = fmt.Sprintf(callbackPage, msg)
}
w.Header().Add("Content-Type", "text/html")
w.Write([]byte(page))
}
}
func oauthLogin(tsuruCtx *tsuructx.TsuruContext, scheme *loginScheme) error {
if _, ok := scheme.Data["authorizeUrl"]; !ok {
return fmt.Errorf("missing authorizeUrl in scheme data")
}
l, err := net.Listen("tcp", port(scheme.Data)) // use low level net.Listen for random port with :0
if err != nil {
return err
}
_, port, err := net.SplitHostPort(l.Addr().String())
if err != nil {
return err
}
redirectURL := fmt.Sprintf("http://localhost:%s", port)
authURL := strings.Replace(scheme.Data["authorizeUrl"], "__redirect_url__", redirectURL, 1)
finish := make(chan bool, 1)
mux := http.NewServeMux()
mux.HandleFunc("/", callback(tsuruCtx, redirectURL, finish))
server := &http.Server{}
server.Handler = mux
go server.Serve(l)
err = exec.Open(tsuruCtx.Executor, authURL)
if err != nil {
fmt.Fprintln(tsuruCtx.Stdout, "Failed to start your browser.")
fmt.Fprintf(tsuruCtx.Stdout, "Please open the following URL in your browser: %s\n", authURL)
}
<-finish
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
server.Shutdown(ctx)
fmt.Fprintln(tsuruCtx.Stdout, "Successfully logged in!")
return nil
}
// Copyright © 2023 tsuru-client authors
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package cmd
import (
"sort"
"strings"
"github.com/spf13/cobra"
tsuruV1Config "github.com/tsuru/tsuru-client/tsuru/config"
tsuruCmd "github.com/tsuru/tsuru/cmd"
)
var ignoredLegacyCommands = map[string]bool{
"change-password": true,
"cluster-add": true,
"cluster-list": true,
"cluster-remove": true,
"cluster-update": true,
"help": true,
"reset-password": true,
}
func newV1LegacyCmdManager() *tsuruCmd.Manager {
versionForLegacy := strings.TrimLeft(version.Version, "v") + "-legacy-plugin"
if version.Version == "dev" {
versionForLegacy = "dev"
}
return tsuruV1Config.BuildManager("tsuru", versionForLegacy)
}
func newLegacyCommand(v1CmdManager *tsuruCmd.Manager) *cobra.Command {
legacyCmd := &cobra.Command{
Use: "legacy",
Short: "legacy is the previous version of tsuru cli",
RunE: func(cmd *cobra.Command, args []string) error {
return runLegacyCommand(v1CmdManager, args)
},
Args: cobra.MinimumNArgs(0),
DisableFlagParsing: true,
}
return legacyCmd
}
func runLegacyCommand(v1CmdManager *tsuruCmd.Manager, args []string) error {
var err error
defer recoverCmdPanicExitError(&err)
v1CmdManager.Run(args)
return err
}
func recoverCmdPanicExitError(err *error) {
if r := recover(); r != nil {
if e, ok := r.(*tsuruCmd.PanicExitError); ok {
if e.Code > 0 {
*err = e
}
return
}
panic(r)
}
}
type cmdNode struct {
command *cobra.Command
children map[string]*cmdNode
}
func (n *cmdNode) addChild(c *cobra.Command) {
if n.children == nil {
n.children = make(map[string]*cmdNode)
}
n.children[c.Name()] = &cmdNode{command: c}
for _, sub := range c.Commands() {
n.children[c.Name()].addChild(sub)
}
}
func addMissingV1LegacyCommands(rootCmd *cobra.Command, v1CmdManager *tsuruCmd.Manager) {
// build current commands tree (without legacy commands)
tree := &cmdNode{command: rootCmd}
for _, c := range rootCmd.Commands() {
tree.addChild(c)
}
// sort legacy commands by less specific ones first (create "deploy" before "deploy list" )
v1Commands := make([]v1Command, 0, len(v1CmdManager.Commands))
for cmdName, v1Cmd := range v1CmdManager.Commands {
v1Commands = append(v1Commands, v1Command{cmdName, v1Cmd})
}
sort.Sort(ByPriority(v1Commands))
// add missing legacy commands
for _, v1Cmd := range v1Commands {
// ignore this legacy commands
if ignoredLegacyCommands[v1Cmd.name] {
continue
}
addMissingV1LegacyCommand(tree, v1CmdManager, v1Cmd)
}
}
func addMissingV1LegacyCommand(tree *cmdNode, v1CmdManager *tsuruCmd.Manager, v1Cmd v1Command) {
curr := tree
parts := strings.Split(strings.ReplaceAll(v1Cmd.name, "-", " "), " ")
for i, part := range parts {
found := false
if _, found = curr.children[part]; !found {
newCmd := &cobra.Command{
Use: part,
Short: "manage " + strings.Join(parts[:i+1], " ") + "s",
DisableFlagParsing: true,
}
curr.addChild(newCmd)
curr.command.AddCommand(newCmd)
}
curr = curr.children[part]
if i == len(parts)-1 && !found {
curr.command.Short = strings.TrimSpace(strings.Split(v1Cmd.cmd.Info().Desc, "\n")[0]) + " ¹"
curr.command.Long = v1Cmd.cmd.Info().Usage
curr.command.SilenceUsage = true
curr.command.Args = cobra.MinimumNArgs(0)
curr.command.RunE = func(cmd *cobra.Command, args []string) error {
return runLegacyCommand(v1CmdManager, append(parts, args...))
}
}
}
}
type v1Command struct {
name string
cmd tsuruCmd.Command
}
type ByPriority []v1Command
func (a ByPriority) Len() int { return len(a) }
func (a ByPriority) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
func (a ByPriority) Less(i, j int) bool {
Li := len(strings.Split(a[i].name, " "))
Lj := len(strings.Split(a[j].name, " "))
if Li == Lj {
return a[i].name < a[j].name
}
return Li < Lj
}
// Copyright © 2023 tsuru-client authors
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package cmd
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"github.com/tsuru/tsuru-client/v2/internal/config"
"github.com/tsuru/tsuru-client/v2/internal/exec"
"github.com/tsuru/tsuru-client/v2/internal/tsuructx"
)
func runTsuruPlugin(tsuruCtx *tsuructx.TsuruContext, args []string) error {
pluginName := args[0]
if tsuruCtx.Viper.GetString("plugin-name") == pluginName {
return fmt.Errorf("failing trying to run recursive plugin")
}
pluginPath := findExecutablePlugin(tsuruCtx, pluginName)
if pluginPath == "" {
return fmt.Errorf("unknown command %q", pluginName)
}
envs := os.Environ()
tsuruEnvs := []string{
"TSURU_TARGET=" + tsuruCtx.TargetURL(),
"TSURU_TOKEN=" + tsuruCtx.Token(),
"TSURU_VERBOSITY=" + fmt.Sprintf("%d", tsuruCtx.Verbosity()),
"TSURU_FORMAT=" + tsuruCtx.OutputFormat().ToString(),
"TSURU_PLUGIN_NAME=" + pluginName,
}
envs = append(envs, tsuruEnvs...)
opts := exec.ExecuteOptions{
Cmd: pluginPath,
Args: args[1:],
Stdout: tsuruCtx.Stdout,
Stderr: tsuruCtx.Stderr,
Stdin: tsuruCtx.Stdin,
Envs: envs,
}
return tsuruCtx.Executor.Command(opts)
}
func findExecutablePlugin(tsuruCtx *tsuructx.TsuruContext, pluginName string) (execPath string) {
basePath := filepath.Join(config.ConfigPath, "plugins")
testPathGlobs := []string{
filepath.Join(basePath, pluginName),
filepath.Join(basePath, pluginName, pluginName),
filepath.Join(basePath, pluginName, pluginName+".*"),
filepath.Join(basePath, pluginName+".*"),
}
for _, pathGlob := range testPathGlobs {
var fStat fs.FileInfo
var err error
execPath = pathGlob
if fStat, err = tsuruCtx.Fs.Stat(pathGlob); err != nil {
files, _ := filepath.Glob(pathGlob)
if len(files) != 1 {
continue
}
execPath = files[0]
fStat, err = tsuruCtx.Fs.Stat(execPath)
}
if err != nil || fStat.IsDir() || !fStat.Mode().IsRegular() {
continue
}
return execPath
}
return ""
}
// Copyright © 2023 tsuru-client authors
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package cmd
import (
"fmt"
"os"
"strings"
"time"
"github.com/spf13/afero"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/tsuru/tsuru-client/v2/internal/config"
"github.com/tsuru/tsuru-client/v2/internal/exec"
"github.com/tsuru/tsuru-client/v2/internal/tsuructx"
"github.com/tsuru/tsuru-client/v2/pkg/cmd/auth"
"github.com/tsuru/tsuru-client/v2/pkg/printer"
)
var (
version cmdVersion
commands = []func(*tsuructx.TsuruContext) *cobra.Command{
auth.NewLoginCmd,
auth.NewLogoutCmd,
}
)
type cmdVersion struct {
Version string
Commit string
Date string
}
func (v *cmdVersion) String() string {
if v.Version == "" {
v.Version = "dev"
}
if v.Commit == "" && v.Date == "" {
return v.Version
}
return fmt.Sprintf("%s (%s - %s)", v.Version, v.Commit, v.Date)
}
// Execute will create the cli with all subcommands and run it
func Execute(_version, _commit, _dateStr string) {
version = cmdVersion{_version, _commit, _dateStr}
rootCmd := NewRootCmd(viper.GetViper(), nil)
err := rootCmd.Execute()
if err != nil {
os.Exit(1)
}
}
func NewRootCmd(vip *viper.Viper, tsuruCtx *tsuructx.TsuruContext) *cobra.Command {
vip = preSetupViper(vip)
if tsuruCtx == nil {
tsuruCtx = NewProductionTsuruContext(vip, afero.NewOsFs())
}
rootCmd := newBareRootCmd(tsuruCtx)
setupPFlagsAndCommands(rootCmd, tsuruCtx)
return rootCmd
}
func newBareRootCmd(tsuruCtx *tsuructx.TsuruContext) *cobra.Command {
rootCmd := &cobra.Command{
Version: version.String(),
Use: "tsuru",
Short: "A command-line interface for interacting with tsuru",
PersistentPreRun: rootPersistentPreRun(tsuruCtx),
RunE: func(cmd *cobra.Command, args []string) error {
return runRootCmd(tsuruCtx, cmd, args)
},
Args: cobra.MinimumNArgs(0),
FParseErrWhitelist: cobra.FParseErrWhitelist{
UnknownFlags: true,
},
DisableFlagParsing: true,
}
rootCmd.SetVersionTemplate(`{{printf "tsuru-client version: %s" .Version}}` + "\n")
return rootCmd
}
func runRootCmd(tsuruCtx *tsuructx.TsuruContext, cmd *cobra.Command, args []string) error {
cmd.SilenceUsage = true
args = parseFirstFlagsOnly(cmd, args)
versionVal, _ := cmd.Flags().GetBool("version")
helpVal, _ := cmd.Flags().GetBool("help")
if len(args) == 0 || versionVal || helpVal {
cmd.RunE = nil
cmd.Run = nil
return cmd.Execute()
}
return runTsuruPlugin(tsuruCtx, args)
}
// parseFirstFlagsOnly handles only the first flags with cmd.ParseFlags()
// before a non-flag element
func parseFirstFlagsOnly(cmd *cobra.Command, args []string) []string {
if cmd == nil {
return args
}
cmd.DisableFlagParsing = false
for len(args) > 0 {
s := args[0]
if len(s) == 0 || s[0] != '-' || len(s) == 1 {
return args // any non-flag means we're done
}
args = args[1:]
flagName := s[1:]
if s[1] == '-' {
if len(s) == 2 { // "--" terminates the flags
return args
}
flagName = s[2:]
}
if strings.Contains(flagName, "=") {
flagArgPair := strings.SplitN(flagName, "=", 2)
flagName = flagArgPair[0]
args = append([]string{flagArgPair[1]}, args...)
}
flag := cmd.Flags().Lookup(flagName)
if flag == nil && len(flagName) == 1 {
flag = cmd.Flags().ShorthandLookup(flagName)
}
if flag != nil && flag.Value.Type() == "bool" {
cmd.ParseFlags([]string{s})
} else {
if len(args) == 0 {
return args
}
cmd.ParseFlags([]string{s, args[0]})
args = args[1:]
}
}
return args
}
func rootPersistentPreRun(tsuruCtx *tsuructx.TsuruContext) func(cmd *cobra.Command, args []string) {
return func(cmd *cobra.Command, args []string) {
if l := cmd.Flags().Lookup("target"); l != nil && l.Value.String() != "" {
target, err := config.GetTargetURL(tsuruCtx.Fs, l.Value.String())
cobra.CheckErr(err)
tsuruCtx.SetTargetURL(target)
if tsuruCtx.TokenSetFromFS { // do not update if set from ENV
token, err1 := config.GetTokenFromFs(tsuruCtx.Fs, target)
cobra.CheckErr(err1)
tsuruCtx.SetToken(token)
}
}
if v, err := cmd.Flags().GetInt("verbosity"); err != nil {
tsuruCtx.SetVerbosity(v)
}
if v, err := cmd.Flags().GetBool("json"); err == nil && v {
tsuruCtx.SetOutputFormat("json")
} else {
tsuruCtx.SetOutputFormat(cmd.Flags().Lookup("format").Value.String())
}
if v, err := cmd.Flags().GetBool("api-data"); err == nil && v {
tsuruCtx.SetOutputAPIData(true)
}
}
}
// preSetupViper prepares viper for being used by NewProductionTsuruContext()
func preSetupViper(vip *viper.Viper) *viper.Viper {
if vip == nil {
vip = viper.New()
}
vip.SetEnvPrefix("tsuru")
vip.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
vip.AutomaticEnv() // read in environment variables that match
return vip
}
// setupPFlagsAndCommands reads in config file and ENV variables if set.
func setupPFlagsAndCommands(rootCmd *cobra.Command, tsuruCtx *tsuructx.TsuruContext) {
// Persistent Flags.
// !!! Double bind them inside PersistentPreRun() !!!
rootCmd.PersistentFlags().String("target", "", "Tsuru server endpoint")
tsuruCtx.Viper.BindPFlag("target", rootCmd.PersistentFlags().Lookup("target"))
rootCmd.PersistentFlags().IntP("verbosity", "v", 0, "Verbosity level: 1 => print HTTP requests; 2 => print HTTP requests/responses")
tsuruCtx.Viper.BindPFlag("verbosity", rootCmd.PersistentFlags().Lookup("verbosity"))
rootCmd.PersistentFlags().Bool("json", false, "Output format as json")
rootCmd.PersistentFlags().MarkHidden("json")
tsuruCtx.Viper.BindPFlag("json", rootCmd.PersistentFlags().Lookup("json"))
format := printer.OutputFormat("string")
rootCmd.PersistentFlags().Var(&format, "format", "Output format. Supports json, compact-json, yaml and table")
rootCmd.RegisterFlagCompletionFunc("format",
func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return printer.OutputFormatCompletionHelp(), cobra.ShellCompDirectiveNoFileComp
},
)
tsuruCtx.Viper.BindPFlag("format", rootCmd.PersistentFlags().Lookup("format"))
rootCmd.PersistentFlags().Bool("api-data", false, "Output API response data instead of a parsed data (more useful with --format=json)")
tsuruCtx.Viper.BindPFlag("api-data", rootCmd.PersistentFlags().Lookup("api-data"))
// Search config in home directory with name ".tsuru-client" (without extension).
tsuruCtx.Viper.AddConfigPath(config.ConfigPath)
tsuruCtx.Viper.SetConfigType("yaml")
tsuruCtx.Viper.SetConfigName(".tsuru-client")
// If a config file is found, read it in.
if err := tsuruCtx.Viper.ReadInConfig(); err == nil {
fmt.Fprintln(os.Stderr, "Using config file:", tsuruCtx.Viper.ConfigFileUsed()) // TODO: handle this better
}
// Add subcommands
for _, cmd := range commands {
rootCmd.AddCommand(cmd(tsuruCtx))
}
v1LegacyCmdManager := newV1LegacyCmdManager()
addMissingV1LegacyCommands(rootCmd, v1LegacyCmdManager)
rootCmd.AddCommand(newLegacyCommand(v1LegacyCmdManager))
}
func NewProductionTsuruContext(vip *viper.Viper, fs afero.Fs) *tsuructx.TsuruContext {
var err error
var tokenSetFromFS bool
// Get target
target := vip.GetString("target")
if target == "" {
target, err = config.GetCurrentTargetFromFs(fs)
cobra.CheckErr(err)
}
target, err = config.GetTargetURL(fs, target)
cobra.CheckErr(err)
vip.Set("target", target)
// Get token
token := vip.GetString("token")
if token == "" {
token, err = config.GetTokenFromFs(fs, target)
cobra.CheckErr(err)
tokenSetFromFS = true
vip.Set("token", token)
}
tsuruCtx := tsuructx.TsuruContextWithConfig(productionOpts(fs, vip))
tsuruCtx.TokenSetFromFS = tokenSetFromFS
return tsuruCtx
}
func productionOpts(fs afero.Fs, vip *viper.Viper) *tsuructx.TsuruContextOpts {
return &tsuructx.TsuruContextOpts{
InsecureSkipVerify: vip.GetBool("insecure-skip-verify"),
LocalTZ: time.Local,
AuthScheme: vip.GetString("auth-scheme"),
Executor: &exec.OsExec{},
Fs: fs,
Viper: vip,
UserAgent: "tsuru-client:" + version.Version,
Stdout: os.Stdout,
Stderr: os.Stderr,
Stdin: os.Stdin,
}
}
// Copyright © 2023 tsuru-client authors
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package printer
import (
"encoding/json"
"fmt"
"io"
"strings"
"github.com/spf13/pflag"
"gopkg.in/yaml.v3"
)
type OutputFormat string
const (
// every OutputType should be mapped inside PrintInfo()
CompactJSON OutputFormat = "compatc-json"
PrettyJSON OutputFormat = "json"
YAML OutputFormat = "yaml"
Table OutputFormat = "table"
)
var _ pflag.Value = (*OutputFormat)(nil)
func OutputFormatCompletionHelp() []string {
return []string{
CompactJSON.ToString() + "\toutput as compact JSON format (no newlines)",
PrettyJSON.ToString() + "\toutput as JSON (PrettyJSON)",
YAML.ToString() + "\toutput as YAML",
Table.ToString() + "\toutput as Human readable table",
}
}
func (o OutputFormat) ToString() string {
return string(o)
}
func (o *OutputFormat) String() string {
return string(*o)
}
func (o *OutputFormat) Set(v string) error {
var err error
*o, err = FormatAs(v)
return err
}
func (e *OutputFormat) Type() string {
return "OutputFormat"
}
func FormatAs(s string) (OutputFormat, error) {
switch strings.ToLower(s) {
case "compact-json", "compactjson":
return CompactJSON, nil
case "json", "pretty-json", "prettyjson":
return PrettyJSON, nil
case "yaml":
return YAML, nil
case "table":
return Table, nil
default:
return Table, fmt.Errorf("must be one of: json, compact-json, yaml, table")
}
}
// Print will print the data in the given format.
// If the format is not supported, it will return an error.
// If the format is Table, it will try to convert the data to a human readable format. (see pkg/converter)
func Print(out io.Writer, data any, format OutputFormat) error {
switch format {
case CompactJSON:
return PrintJSON(out, data)
case PrettyJSON:
return PrintPrettyJSON(out, data)
case YAML:
return PrintYAML(out, data)
case Table:
return PrintTable(out, data)
default:
return fmt.Errorf("unknown format: %q", format)
}
}
func PrintJSON(out io.Writer, data any) error {
if data == nil {
return nil
}
dataByte, err := json.Marshal(data)
if err != nil {
return fmt.Errorf("error converting to json: %w", err)
}
fmt.Fprintln(out, string(dataByte))
return nil
}
func PrintPrettyJSON(out io.Writer, data any) error {
if data == nil {
return nil
}
dataByte, err := json.MarshalIndent(data, "", " ")
if err != nil {
return fmt.Errorf("error converting to json: %w", err)
}
fmt.Fprintln(out, string(dataByte))
return nil
}
func PrintYAML(out io.Writer, data any) (err error) {
defer func() {
if r := recover(); r != nil {
// yaml.v3 panics a lot: https://github.com/go-yaml/yaml/issues/954
err = fmt.Errorf("error converting to yaml (panic): %v", r)
}
}()
if data == nil {
return nil
}
dataByte, err := yaml.Marshal(data)
if err != nil {
return fmt.Errorf("error converting to yaml: %w", err)
}
_, err = out.Write(dataByte)
return err
}
// Copyright © 2023 tsuru-client authors
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package printer
import (
"fmt"
"io"
"reflect"
"regexp"
"sort"
"strconv"
"strings"
"text/tabwriter"
)
// PrintTable prints the data to out in a table format.
// If data is a simple type (bool, int, string, etc), it will print it as-is.
// If data is a slice/map, it will print a summary table.
// If data is a struct, it will print simple fields as 'key: value' table and complex fields as sub-tables.
// Non-printable types will return an error.
//
// For structs, some field tags are supported:
// - if "name" tag exists, that will be used instead of the field name.
// - if "priority" tag exists, it will be used to sort the fields. Higher priority will be printed first.
func PrintTable(out io.Writer, data any) (err error) {
w := tabwriter.NewWriter(out, 2, 2, 2, ' ', 0)
defer w.Flush()
return printTable(w, data)
}
func printTable(out io.Writer, data any) (err error) {
if data == nil {
return nil
}
kind := reflect.TypeOf(data).Kind()
switch kind {
case reflect.Pointer: // just dereference it:
return printTable(out, reflect.ValueOf(data).Elem().Interface())
case reflect.Bool,
reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr,
reflect.Float32, reflect.Float64, reflect.Complex64, reflect.Complex128,
reflect.String:
_, err = fmt.Fprintln(out, data)
case reflect.Array, reflect.Slice,
reflect.Map:
err = printTableList(out, data)
case reflect.Struct:
printTableStruct(out, data)
case reflect.Invalid, reflect.Chan, reflect.Func, reflect.UnsafePointer:
err = fmt.Errorf("cannot print type %T (kind: %s)", data, kind.String())
default:
err = fmt.Errorf("unknown type for printing: %T (kind: %s)", data, kind.String())
}
return err
}
func printTableStruct(out io.Writer, data any) {
o := &StructuredOutput{}
keys := GetSortedStructFields(reflect.TypeOf(data))
for _, key := range keys {
o.ProcessStructField(key.printName, reflect.ValueOf(data).FieldByName(key.fieldName))
}
o.PrintTo(out)
}
func printTableList(out io.Writer, data any) (err error) {
value := reflect.ValueOf(data)
switch value.Kind() {
case reflect.Slice, reflect.Array:
return printTableListOfSlice(out, data)
case reflect.Map:
if isMapOfSimple(data) {
mapKeys := reflect.ValueOf(data).MapKeys()
sort.Slice(mapKeys, func(i, j int) bool { return fmt.Sprint(mapKeys[i].Interface()) < fmt.Sprint(mapKeys[j].Interface()) })
for i, k := range mapKeys {
if i > 0 {
fmt.Fprint(out, ", ")
}
fmt.Fprintf(out, "%v: %v", k.Interface(), reflect.ValueOf(data).MapIndex(k).Interface())
}
fmt.Fprintln(out, "")
return nil
}
return printTableListOfMap(out, data)
default:
return fmt.Errorf("cannot print type as list: %T (%s)", data, value.Kind().String())
}
}
type OutputField struct {
name string
value string
}
type StructuredOutput struct {
simpleData []OutputField
complexData []OutputField
}
// PrintTo will output the structured output to the given io.Writer.
// eg: PrintTo(os.Stdout) will output:
// simpleField1:\tvalue1
// simpleField2:\tvalue2
//
// complexField1:
// complexValue1...
//
// complexField2:
// complexValue2...
func (o *StructuredOutput) PrintTo(output io.Writer) {
for _, f := range o.simpleData {
fmt.Fprintf(output, "%s:\t%s\n", normalizeName(f.name), f.value)
}
for _, f := range o.complexData {
fmt.Fprintf(output, "\n%s:\n%s", normalizeName(f.name), f.value)
if !strings.HasSuffix(f.value, "\n") {
fmt.Fprintln(output)
}
}
}
// ProcessStructField will process a single field of a struct.
// It will differentiate between simple and complex fields and process them accordingly.
// Use PrintTo() to output the processed data.
func (o *StructuredOutput) ProcessStructField(name string, value reflect.Value) error {
if o.simpleData == nil {
o.simpleData = []OutputField{}
}
if o.complexData == nil {
o.complexData = []OutputField{}
}
kind := value.Kind()
switch kind {
case reflect.Pointer:
if value.IsNil() {
return nil
}
return o.ProcessStructField(name, value.Elem())
case reflect.Bool,
reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr,
reflect.Float32, reflect.Float64, reflect.Complex64, reflect.Complex128,
reflect.String:
o.simpleData = append(o.simpleData, OutputField{name: name, value: ParseField(value)})
case reflect.Array, reflect.Slice,
reflect.Map:
if value.Len() == 0 {
return nil
}
if isCollectionOfSimple(value.Interface()) {
o.simpleData = append(o.simpleData, OutputField{name: name, value: ParseField(value)})
} else {
o.complexData = append(o.complexData, OutputField{name: name, value: ParseField(value)})
}
case reflect.Struct:
o.complexData = append(o.complexData, OutputField{name: name, value: ParseField(value)})
default:
return fmt.Errorf("cannot process field %q of type %T (kind: %s)", name, value.Interface(), kind.String())
}
return nil
}
// ParseField will parse a single value into a string.
// If value is a simple type (bool, int, string, etc), it will return it as-is.
// If value is a slice/map of simple types, it will return a comma-separated list.
// If value is a slice/map of complex types, it will return a summary table.
// If value is a struct, it will return a summary table.
func ParseField(value reflect.Value) string {
kind := value.Kind()
switch kind {
case reflect.Pointer:
return ParseField(value.Elem())
case reflect.Bool,
reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr,
reflect.Float32, reflect.Float64, reflect.Complex64, reflect.Complex128,
reflect.String:
return fmt.Sprint(value.Interface())
case reflect.Array, reflect.Slice:
return parseStructFieldAsSubList(value)
case reflect.Map:
if isCollectionOfSimple(value.Interface()) {
mapKeys := value.MapKeys()
sort.Slice(mapKeys, func(i, j int) bool { return fmt.Sprint(mapKeys[i].Interface()) < fmt.Sprint(mapKeys[j].Interface()) })
buf := &strings.Builder{}
for i, k := range mapKeys {
if i > 0 {
fmt.Fprint(buf, ", ")
}
fmt.Fprintf(buf, "%v: %v", k.Interface(), value.MapIndex(k).Interface())
}
return buf.String()
}
return parseStructFieldAsSubList(value)
case reflect.Struct:
return parseStructFieldAsSubStruct(value)
default:
return fmt.Sprintf("<not parseable (kind: %s)>", kind.String())
}
}
func parseStructFieldAsSubList(value reflect.Value) string {
if value.Kind() != reflect.Slice && value.Kind() != reflect.Array {
return ""
}
if value.Len() == 0 {
return ""
}
if isCollectionOfSimple(value.Interface()) {
buf := &strings.Builder{}
for i := 0; i < value.Len(); i++ {
if i > 0 {
fmt.Fprint(buf, ", ")
}
fmt.Fprintf(buf, "%v", value.Index(i).Interface())
}
return buf.String()
}
sliceElement := value.Type().Elem()
if sliceElement.Kind() == reflect.Pointer {
sliceElement = sliceElement.Elem()
}
switch sliceElement.Kind() {
case reflect.Array, reflect.Slice:
if isCollectionOfSimple(value.Index(0).Interface()) {
buf := &strings.Builder{}
for i := 0; i < value.Len(); i++ {
if i > 0 {
fmt.Fprint(buf, ", ")
}
fmt.Fprintf(buf, "%v", value.Index(i).Interface())
}
return buf.String()
}
return fmt.Sprintf("[]%s{...}", sliceElement.String())
case reflect.Map:
buf := &strings.Builder{}
for i := 0; i < value.Len(); i++ {
if i > 0 {
fmt.Fprint(buf, "\n")
}
item := value.Index(i)
if item.Kind() == reflect.Pointer {
item = item.Elem()
}
keys := item.MapKeys()
sort.Slice(keys, func(i, j int) bool { return fmt.Sprint(keys[i].Interface()) < fmt.Sprint(keys[j].Interface()) })
for j, k := range keys {
if j == 0 {
fmt.Fprintf(buf, "map{")
} else {
fmt.Fprint(buf, ", ")
}
fmt.Fprintf(buf, "%v", k.Interface())
}
fmt.Fprint(buf, "}")
}
return buf.String()
case reflect.Struct:
keys := GetSortedStructFields(sliceElement)
buf := &strings.Builder{}
for i, k := range keys {
if i > 0 {
fmt.Fprint(buf, "\t")
}
fmt.Fprintf(buf, "%s", strings.ToUpper(normalizeName(k.printName)))
}
for i := 0; i < value.Len(); i++ {
fmt.Fprint(buf, "\n")
for j, k := range keys {
if j > 0 {
fmt.Fprint(buf, "\t")
}
item := value.Index(i)
if item.Kind() == reflect.Pointer {
item = item.Elem()
}
field := item.FieldByName(k.fieldName)
if field.Kind() == reflect.Pointer {
field = field.Elem()
}
fmt.Fprintf(buf, "%v", field.Interface())
}
}
fmt.Fprintln(buf)
return buf.String()
default:
return fmt.Sprintf("[]%s{...}", sliceElement.String())
}
}
func parseStructFieldAsSubStruct(value reflect.Value) string {
if value.Kind() != reflect.Struct {
return ""
}
keys := GetSortedStructFields(value.Type())
buf := &strings.Builder{}
for i, k := range keys {
if i > 0 {
fmt.Fprint(buf, "\t")
}
fmt.Fprintf(buf, "%s", strings.ToUpper(normalizeName(k.printName)))
}
fmt.Fprintln(buf)
for i, k := range keys {
if i > 0 {
fmt.Fprint(buf, "\t")
}
item := value.FieldByName(k.fieldName).Interface()
fmt.Fprintf(buf, "%v", item)
}
fmt.Fprintln(buf)
return buf.String()
}
func printTableListOfSlice(out io.Writer, data any) (err error) {
value := reflect.ValueOf(data)
if value.Len() == 0 {
return nil
}
subKind := value.Type().Elem().Kind()
if subKind == reflect.Pointer {
subKind = value.Type().Elem().Elem().Kind()
}
if subKind == reflect.Struct {
fmt.Fprintln(out, ParseField(value))
return nil
}
for i := 0; i < value.Len(); i++ {
fmt.Fprintln(out, ParseField(value.Index(i)))
}
return nil
}
func printTableListOfMap(out io.Writer, data any) (err error) {
return fmt.Errorf("printTableListOfMap not implemented")
}
func isCollectionOfSimple(data any) bool {
return isSliceOfSimple(data) || isMapOfSimple(data)
}
func isSliceOfSimple(data any) bool {
kind := reflect.TypeOf(data).Kind()
if kind != reflect.Slice && kind != reflect.Array {
return false
}
sliceKind := reflect.TypeOf(data).Elem().Kind()
switch sliceKind {
case reflect.Bool,
reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr,
reflect.Float32, reflect.Float64, reflect.Complex64, reflect.Complex128,
reflect.String:
return true
default:
return false
}
}
func isMapOfSimple(data any) bool {
if reflect.TypeOf(data).Kind() != reflect.Map {
return false
}
valueKind := reflect.TypeOf(data).Elem().Kind()
keyKind := reflect.TypeOf(data).Key().Kind()
isKeyKindSimple, isValueKindSimple := false, false
switch keyKind {
case reflect.Bool,
reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr,
reflect.Float32, reflect.Float64, reflect.Complex64, reflect.Complex128,
reflect.String:
isKeyKindSimple = true
}
switch valueKind {
case reflect.Bool,
reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr,
reflect.Float32, reflect.Float64, reflect.Complex64, reflect.Complex128,
reflect.String:
isValueKindSimple = true
}
return isKeyKindSimple && isValueKindSimple
}
type structFieldsSortable struct {
fieldName string
printName string
priority int
}
func GetSortedStructFields(structType reflect.Type) []structFieldsSortable {
if structType.Kind() != reflect.Struct {
return nil
}
fields := []structFieldsSortable{}
for _, field := range reflect.VisibleFields(structType) {
if !field.IsExported() {
continue
}
priorityStr := field.Tag.Get("priority")
priority, _ := strconv.Atoi(priorityStr)
printName := field.Name
if tag := field.Tag.Get("name"); tag != "" {
printName = tag
}
fields = append(fields, structFieldsSortable{priority: priority, fieldName: field.Name, printName: printName})
}
sort.Slice(fields, func(i, j int) bool {
if fields[i].priority == fields[j].priority {
return fields[i].printName < fields[j].printName
}
return fields[i].priority > fields[j].priority
})
return fields
}
var matchFirstCap = regexp.MustCompile("(.)([A-Z][a-z]+)")
var matchAllCap = regexp.MustCompile("([a-z0-9])([A-Z])")
// normalizeName will normalize a string to be used as a field name.
// Meaning, it will convert CamelCase to Title Case.
func normalizeName(s string) string {
ret := matchFirstCap.ReplaceAllString(s, "${1} ${2}")
ret = matchAllCap.ReplaceAllString(ret, "${1} ${2}")
return strings.Title(ret) //lint:ignore SA1019 // structure fields are safe
}