├── main ├── internal └── xbar │ ├── screenshot.png │ ├── Status_Available.png │ ├── Temporal_Favicon.png │ ├── Status_Unavailable.png │ ├── render_other.go │ ├── README.md │ ├── xbar.go │ ├── tctx.1m.sh │ └── render_darwin.go ├── .gitignore ├── go.mod ├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── LICENSE ├── go.sum ├── config ├── config.go └── manager.go ├── README.md ├── main_test.go └── main.go /main: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jlegrone/tctx/HEAD/main -------------------------------------------------------------------------------- /internal/xbar/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jlegrone/tctx/HEAD/internal/xbar/screenshot.png -------------------------------------------------------------------------------- /internal/xbar/Status_Available.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jlegrone/tctx/HEAD/internal/xbar/Status_Available.png -------------------------------------------------------------------------------- /internal/xbar/Temporal_Favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jlegrone/tctx/HEAD/internal/xbar/Temporal_Favicon.png -------------------------------------------------------------------------------- /internal/xbar/Status_Unavailable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jlegrone/tctx/HEAD/internal/xbar/Status_Unavailable.png -------------------------------------------------------------------------------- /internal/xbar/render_other.go: -------------------------------------------------------------------------------- 1 | //go:build !darwin 2 | 3 | package xbar 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | ) 9 | 10 | func Render(ctx context.Context, opts *Options) error { 11 | return fmt.Errorf("xbar not supported on current platform") 12 | } 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | 17 | # Editor Config 18 | .idea 19 | 20 | # Output binary 21 | tctx 22 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jlegrone/tctx 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/jlegrone/xbargo v0.0.0-20220128073828-b95b21d50723 7 | github.com/urfave/cli/v2 v2.3.0 8 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a 9 | ) 10 | 11 | require ( 12 | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d // indirect 13 | github.com/russross/blackfriday/v2 v2.0.1 // indirect 14 | github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect 15 | ) 16 | -------------------------------------------------------------------------------- /internal/xbar/README.md: -------------------------------------------------------------------------------- 1 | # Graphical User Interface 2 | 3 | An [xbar](https://github.com/matryer/xbar) plugin is available which provides a graphical interface for visualizing and switching between Temporal contexts on macOS. 4 | 5 | tctx xbar integration 6 | 7 | ## Installation 8 | 9 | > Note: This process may be streamlined by contributing the plugin to https://github.com/matryer/xbar-plugins in the future. 10 | 11 | 1. Install xbar: https://github.com/matryer/xbar#install 12 | 2. Copy `tctx.1m.sh` into your xbar plugins directory: 13 | ```bash 14 | cp ./internal/xbar/tctx.1m.sh $HOME/Library/Application\ Support/xbar/plugins/tctx.1m.sh 15 | ``` 16 | 3. Reload xbar 17 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | 16 | - name: Set up Go 17 | uses: actions/setup-go@v3 18 | with: 19 | go-version-file: 'go.mod' 20 | check-latest: true 21 | 22 | - name: Build 23 | run: go build -v ./... 24 | 25 | - name: Test 26 | run: go test -v -race -coverpkg=./... -covermode=atomic -coverprofile=coverage.out ./... 27 | 28 | - name: Coverage 29 | uses: codecov/codecov-action@v2.0.2 30 | with: 31 | token: ${{ secrets.CODECOV_TOKEN }} 32 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | goreleaser: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - 16 | name: Checkout 17 | uses: actions/checkout@v2 18 | with: 19 | fetch-depth: 0 20 | 21 | - 22 | name: Set up Go 23 | uses: actions/setup-go@v3 24 | with: 25 | go-version-file: 'go.mod' 26 | check-latest: true 27 | 28 | - 29 | name: Run GoReleaser 30 | uses: goreleaser/goreleaser-action@v2 31 | with: 32 | distribution: goreleaser 33 | version: latest 34 | args: release --rm-dist 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | -------------------------------------------------------------------------------- /internal/xbar/xbar.go: -------------------------------------------------------------------------------- 1 | package xbar 2 | 3 | import ( 4 | _ "embed" 5 | 6 | "github.com/urfave/cli/v2" 7 | 8 | "github.com/jlegrone/tctx/config" 9 | ) 10 | 11 | var ( 12 | ShowClusterFlag = cli.BoolFlag{ 13 | Name: "show-cluster", 14 | Usage: "display Temporal cluster name in menu bar", 15 | EnvVars: []string{"SHOW_CLUSTER"}, 16 | } 17 | ShowNamespaceFlag = cli.BoolFlag{ 18 | Name: "show-namespace", 19 | Usage: "display Temporal namespace in menu bar", 20 | EnvVars: []string{"SHOW_NAMESPACE"}, 21 | } 22 | //go:embed Temporal_Favicon.png 23 | temporalIcon []byte 24 | //go:embed Status_Available.png 25 | statusAvailable []byte 26 | //go:embed Status_Unavailable.png 27 | statusUnavailable []byte 28 | ) 29 | 30 | type Options struct { 31 | *config.Config 32 | TctxPath, TctlPath string 33 | ShowCluster, ShowNamespace bool 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Jacob LeGrone 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /internal/xbar/tctx.1m.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Temporal 4 | # v1.0 5 | # Jacob LeGrone 6 | # jlegrone 7 | # Switch Temporal cluster and namespace contexts. 8 | # https://github.com/jlegrone/tctx 9 | # https://github.com/jlegrone/tctx/raw/jlegrone/xbar/internal/xbar/screenshot.png 10 | # tctx,tctl 11 | # boolean(SHOW_CLUSTER=""): Display Temporal cluster name in menu bar. 12 | # boolean(SHOW_NAMESPACE=""): Display Temporal namespace in menu bar. 13 | # string(TCTX_BIN="tctx"): Path to tctx executable. 14 | # string(TCTL_BIN="tctl"): Path to tctl executable. 15 | 16 | export PATH="/usr/local/bin:/usr/bin:$PATH"; 17 | 18 | # Set defaults again just in case they were deleted in plugin settings: 19 | export TCTX_BIN="${TCTX_BIN:-tctx}"; 20 | export TCTL_BIN="${TCTL_BIN:-tctl}"; 21 | 22 | # Render menu items 23 | "$TCTX_BIN" tctxbar 24 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 2 | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= 3 | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 4 | github.com/jlegrone/xbargo v0.0.0-20220128073828-b95b21d50723 h1:lU24GwOuNc6fyEDjQCAGEVMAw0XAVZO/DceYGBYnGmc= 5 | github.com/jlegrone/xbargo v0.0.0-20220128073828-b95b21d50723/go.mod h1:CsRLEcW0IfRKQQ2XZvuGu0J9GFdMpVMGqzxngOXTxlE= 6 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 7 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 8 | github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= 9 | github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 10 | github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= 11 | github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 12 | github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M= 13 | github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= 14 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a h1:dGzPydgVsqGcTRVwiLJ1jVbufYwmzD3LfVPLKsKg+0k= 15 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 16 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 17 | gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 18 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | ) 8 | 9 | type TLSConfig struct { 10 | // Path to x509 certificate 11 | CertPath string `json:"certPath"` 12 | // Path to private key 13 | KeyPath string `json:"keyPath"` 14 | // Path to server CA certificate 15 | CACertPath string `json:"caPath"` 16 | // Disable tls host name verification (tls must be enabled) 17 | DisableHostVerification bool `json:"disableHostVerification"` 18 | // Override for target server name 19 | ServerName string `json:"serverName"` 20 | } 21 | 22 | type ClusterConfig struct { 23 | // host:port for Temporal frontend service 24 | Address string `json:"address"` 25 | // Web UI Link 26 | WebAddress string `json:"webAddress"` 27 | // Temporal workflow namespace (default: "default") 28 | Namespace string `json:"namespace"` 29 | // Headers provider plugin executable name 30 | HeadersProvider string `json:"headersProvider"` 31 | // Data converter plugin executable name 32 | DataConverter string `json:"dataConverter"` 33 | TLS *TLSConfig `json:"tls,omitempty"` 34 | // Any additional environment variables that are needed 35 | Environment map[string]string `json:"additional,omitempty"` 36 | } 37 | 38 | type Config struct { 39 | ActiveContext string `json:"active"` 40 | // Map of context names to cluster configuration 41 | Contexts map[string]*ClusterConfig `json:"contexts"` 42 | } 43 | 44 | func (c ClusterConfig) GetTLS() TLSConfig { 45 | if c.TLS == nil { 46 | return TLSConfig{} 47 | } 48 | return *c.TLS 49 | } 50 | 51 | // GetDefaultConfigPath returns the path to the current user's default tctx config file. 52 | // On unix systems, this will be `$XDG_CONFIG_HOME/tctx/config.json`. 53 | func GetDefaultConfigPath() (string, error) { 54 | userConfigDir, err := os.UserConfigDir() 55 | if err != nil { 56 | return "", fmt.Errorf("error getting default config file path: %s", err) 57 | } 58 | return getConfigPath(userConfigDir), nil 59 | } 60 | 61 | func getConfigPath(userConfigDir string) string { 62 | return filepath.Join(userConfigDir, "tctx", "config.json") 63 | } 64 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tctx 2 | 3 | [![ci](https://github.com/jlegrone/tctx/actions/workflows/ci.yml/badge.svg)](https://github.com/jlegrone/tctx/actions/workflows/ci.yml) 4 | [![codecov](https://codecov.io/gh/jlegrone/tctx/branch/main/graph/badge.svg?token=jClJfwNTKI)](https://codecov.io/gh/jlegrone/tctx) 5 | 6 | `tctx` makes it fast and easy to switch between Temporal clusters when running `tctl` commands. 7 | 8 | ## Installation 9 | 10 | Build from source using [go install](https://golang.org/ref/mod#go-install): 11 | 12 | ```bash 13 | go install github.com/jlegrone/tctx@latest 14 | ``` 15 | 16 | ## Usage 17 | 18 | ### Add a context 19 | 20 | ```bash 21 | $ tctx add -c localhost --namespace default --address localhost:7233 22 | Context "localhost" modified. 23 | Active namespace is "default". 24 | ``` 25 | 26 | ### Execute a `tctl` command 27 | 28 | ```bash 29 | $ tctx exec -- tctl cluster health 30 | temporal.api.workflowservice.v1.WorkflowService: SERVING 31 | ``` 32 | 33 | ### List contexts 34 | 35 | ```bash 36 | $ tctx list 37 | NAME ADDRESS NAMESPACE STATUS 38 | localhost localhost:7233 default active 39 | production temporal-production.example.com:443 myapp 40 | staging temporal-staging.example.com:443 myapp 41 | ``` 42 | 43 | ### Switch contexts 44 | 45 | ```bash 46 | $ tctx use -c production 47 | Context "production" modified. 48 | Active namespace is "myapp". 49 | ``` 50 | 51 | ## Tips 52 | 53 | ### How it works 54 | 55 | `tctx` sets standard Temporal CLI environment variables before executing a subcommand with `tctx exec`. 56 | 57 | Any CLI tool (not just `tctl`) can be used in conjunction with `tctx` if it leverages these environment variables. 58 | 59 | To view all environment variables set for the current context, run 60 | 61 | ```bash 62 | tctx exec -- printenv | grep TEMPORAL_CLI 63 | ``` 64 | 65 | By default `tctx exec` uses the active context. The active context is set by the last `tctx use` or `tctx add` command. 66 | You can override the active context by adding a context flag 67 | 68 | ```bash 69 | tctx exec -c -- 70 | ``` 71 | 72 | ### Define an alias 73 | 74 | Typing `tctx exec -- tctl` is a lot of effort. It's possible to define an alias to make this easier. 75 | 76 | ```bash 77 | alias tctl="tctx exec -- tctl" 78 | ``` 79 | -------------------------------------------------------------------------------- /internal/xbar/render_darwin.go: -------------------------------------------------------------------------------- 1 | //go:build darwin 2 | 3 | package xbar 4 | 5 | import ( 6 | "bufio" 7 | "bytes" 8 | "context" 9 | "fmt" 10 | "os" 11 | "os/exec" 12 | "path" 13 | "sort" 14 | "strings" 15 | "syscall" 16 | "time" 17 | 18 | "github.com/jlegrone/tctx/config" 19 | "github.com/jlegrone/xbargo" 20 | "golang.org/x/sys/unix" 21 | ) 22 | 23 | func Render(ctx context.Context, opts *Options) error { 24 | activeContext := opts.Contexts[opts.ActiveContext] 25 | // Avoid nil pointer exceptions when there is no active context 26 | if activeContext == nil { 27 | activeContext = &config.ClusterConfig{} 28 | } 29 | 30 | // Compute menu title based on user settings 31 | var titleMeta []string 32 | if activeContext.Address != "" { 33 | if opts.ShowCluster { 34 | titleMeta = append(titleMeta, opts.ActiveContext) 35 | } 36 | if opts.ShowNamespace { 37 | titleMeta = append(titleMeta, activeContext.Namespace) 38 | } 39 | } 40 | 41 | plugin := xbargo.NewPlugin().WithText(strings.Join(titleMeta, ":")).WithIcon(bytes.NewReader(temporalIcon)) 42 | 43 | activeContextStatus := xbargo.NewMenuItem(activeContext.Address). 44 | WithStyle(xbargo.Style{MaxLength: 60}). 45 | WithShortcut("o", xbargo.CommandKey) 46 | if activeContext.WebAddress != "" { 47 | activeContextStatus = activeContextStatus.WithHref(path.Join(activeContext.WebAddress, "namespaces", activeContext.Namespace)) 48 | } 49 | 50 | // Get list of namespaces in active cluster 51 | var namespaces []string 52 | if activeContext.Address != "" { 53 | combinedOutput, err := execContext(ctx, opts.TctxPath, "exec", "--", opts.TctlPath, 54 | "--context_timeout", "1", 55 | "namespace", 56 | "list", 57 | ) 58 | if err != nil { 59 | activeContextStatus.Icon = bytes.NewReader(statusUnavailable) 60 | // Let the user know if we can't find a binary in PATH 61 | if errMessage := combinedOutput.String(); strings.Contains(errMessage, "not found in $PATH") { 62 | panic(errMessage) 63 | } 64 | // Print error for debugging 65 | _, _ = fmt.Fprintln(os.Stderr, combinedOutput) 66 | } else { 67 | activeContextStatus.Icon = bytes.NewReader(statusAvailable) 68 | scanner := bufio.NewScanner(combinedOutput) 69 | for scanner.Scan() { 70 | line := scanner.Text() 71 | if strings.HasPrefix(line, "Name: ") { 72 | namespaces = append(namespaces, strings.TrimPrefix(line, "Name: ")) 73 | } 74 | } 75 | sort.Strings(namespaces) 76 | } 77 | } else { 78 | activeContextStatus.Title = "No active context" 79 | } 80 | 81 | plugin = plugin.WithElements(activeContextStatus, xbargo.Separator{}) 82 | 83 | // Get sorted list of context names 84 | var contextNames []string 85 | for k := range opts.Contexts { 86 | contextNames = append(contextNames, k) 87 | } 88 | sort.Strings(contextNames) 89 | 90 | var clusterOptions []*xbargo.MenuItem 91 | for i, k := range contextNames { 92 | prefix := " " 93 | if k == opts.ActiveContext { 94 | prefix = "✓ " 95 | } 96 | clusterOptions = append(clusterOptions, xbargo.NewMenuItem(prefix+k). 97 | WithShell(opts.TctxPath, "use", "-c", k). 98 | WithShortcut(fmt.Sprintf("%d", i), xbargo.ControlKey). 99 | WithRefresh(), 100 | ) 101 | } 102 | plugin = plugin.WithElements(xbargo.NewMenuItem("Clusters").WithSubMenu(clusterOptions...)) 103 | 104 | var namespaceOptions []*xbargo.MenuItem 105 | var hasActiveNamespace bool 106 | for i, ns := range namespaces { 107 | prefix := " " 108 | if ns == activeContext.Namespace { 109 | prefix = "✓ " 110 | hasActiveNamespace = true 111 | } 112 | namespaceOptions = append(namespaceOptions, xbargo.NewMenuItem(prefix+ns). 113 | WithShell(opts.TctxPath, "use", "-c", opts.ActiveContext, "--ns", ns). 114 | WithShortcut(fmt.Sprintf("%d", i), xbargo.ShiftKey). 115 | WithRefresh(), 116 | ) 117 | } 118 | if !hasActiveNamespace && activeContext.Namespace != "" { 119 | // The namespace currently set in tctx doesn't exist in the cluster 120 | namespaceOptions = append(namespaceOptions, xbargo.NewMenuItem("✓ "+activeContext.Namespace). 121 | WithStyle(xbargo.Style{Color: "red"})) 122 | 123 | } 124 | plugin = plugin.WithElements(xbargo.NewMenuItem("Namespaces").WithSubMenu(namespaceOptions...)) 125 | 126 | return plugin.RunW(os.Stdout) 127 | } 128 | 129 | func execContext(ctx context.Context, command string, args ...string) (*bytes.Buffer, error) { 130 | cmd := exec.CommandContext(ctx, command, args...) 131 | 132 | // tctx starts its own child process, so we need to create a new process 133 | // group for cmd and manually send a kill signal to this process group id 134 | // after timeout is reached. 135 | // 136 | // This doesn't work on Windows but that's fine because xbar is macOS only. 137 | cmd.SysProcAttr = &unix.SysProcAttr{Setpgid: true} 138 | deadline, ok := ctx.Deadline() 139 | if ok { 140 | time.AfterFunc(deadline.Sub(time.Now()), func() { 141 | unix.Kill(-cmd.Process.Pid, syscall.SIGKILL) 142 | }) 143 | } 144 | 145 | b := bytes.NewBuffer(nil) 146 | cmd.Stdout = b 147 | cmd.Stderr = b 148 | 149 | return b, cmd.Run() 150 | } 151 | -------------------------------------------------------------------------------- /config/manager.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "path/filepath" 9 | ) 10 | 11 | type ConfigManager struct { 12 | configFilePath string 13 | } 14 | 15 | type Option func(t *ConfigManager) 16 | 17 | // WithConfigFile returns the option to set the config file path 18 | func WithConfigFile(configFilePath string) Option { 19 | return func(t *ConfigManager) { 20 | t.configFilePath = configFilePath 21 | } 22 | } 23 | 24 | // NewConfigManager returns a new ConfigManager to interact with the tctx config 25 | func NewConfigManager(opts ...Option) (*ConfigManager, error) { 26 | t := ConfigManager{} 27 | 28 | // Apply Options 29 | for _, opt := range opts { 30 | opt(&t) 31 | } 32 | 33 | // Set Default Options 34 | if t.configFilePath == "" { 35 | configFilePath, err := GetDefaultConfigPath() 36 | if err != nil { 37 | return nil, err 38 | } 39 | t.configFilePath = configFilePath 40 | } 41 | 42 | // Attempt creating parent directory if it doesn't yet exist 43 | if _, err := os.Stat(filepath.Dir(t.configFilePath)); os.IsNotExist(err) { 44 | if err := os.Mkdir(filepath.Dir(t.configFilePath), os.ModePerm); err != nil { 45 | return nil, fmt.Errorf("error creating config directory: %w", err) 46 | } 47 | } 48 | 49 | // Create empty config t.configFilePath if none exists 50 | if _, err := t.GetAllContexts(); err != nil { 51 | if !errors.Is(err, os.ErrNotExist) { 52 | return nil, err 53 | } 54 | if err := write(t.configFilePath, &Config{}); err != nil { 55 | return nil, err 56 | } 57 | } 58 | 59 | return &t, nil 60 | } 61 | 62 | // GetContextNames returns the list of configured context names 63 | func (t *ConfigManager) GetContextNames() ([]string, error) { 64 | cfgs, err := t.GetAllContexts() 65 | if err != nil { 66 | return nil, err 67 | } 68 | names := []string{} 69 | for name := range cfgs.Contexts { 70 | names = append(names, name) 71 | } 72 | return names, nil 73 | } 74 | 75 | // GetContext returns the ClusterConfig for a given context names 76 | func (t *ConfigManager) GetContext(name string) (*ClusterConfig, error) { 77 | cfg, err := t.GetAllContexts() 78 | if err != nil { 79 | return nil, fmt.Errorf("could not get all contexts: %w", err) 80 | } 81 | 82 | for k, v := range cfg.Contexts { 83 | if k == name { 84 | return v, nil 85 | } 86 | } 87 | 88 | return nil, fmt.Errorf("context %q does not exist", name) 89 | } 90 | 91 | // GetContext returns the ClusterConfig for a the active context 92 | func (t *ConfigManager) GetActiveContext() (*ClusterConfig, error) { 93 | cfg, err := t.GetAllContexts() 94 | if err != nil { 95 | return nil, err 96 | } 97 | 98 | if len(cfg.Contexts) == 0 { 99 | return nil, fmt.Errorf("no contexts exist: create one with `tctx add`") 100 | } 101 | 102 | if cfg.ActiveContext == "" { 103 | return nil, fmt.Errorf("no active context: set one with `tctx use`") 104 | } 105 | 106 | for k, v := range cfg.Contexts { 107 | if k == cfg.ActiveContext { 108 | return v, nil 109 | } 110 | } 111 | 112 | return nil, fmt.Errorf("context does not exist") 113 | } 114 | 115 | // GetContext returns the name of the active context 116 | func (t *ConfigManager) GetActiveContextName() (string, error) { 117 | cfg, err := t.GetAllContexts() 118 | if err != nil { 119 | return "", err 120 | } 121 | 122 | if len(cfg.Contexts) == 0 { 123 | return "", fmt.Errorf("no contexts exist: create one with `tctx add`") 124 | } 125 | 126 | if cfg.ActiveContext == "" { 127 | return "", fmt.Errorf("no active context: set one with `tctx use`") 128 | } 129 | return cfg.ActiveContext, nil 130 | } 131 | 132 | // GetAllContexts returns the ClusterConfig for all configured contexts 133 | func (t *ConfigManager) GetAllContexts() (*Config, error) { 134 | file, err := os.Open(t.configFilePath) 135 | defer file.Close() 136 | if err != nil { 137 | return nil, err 138 | } 139 | 140 | var result Config 141 | if err := json.NewDecoder(file).Decode(&result); err != nil { 142 | return nil, fmt.Errorf("error parsing config t.configFilePath: %w", err) 143 | } 144 | if result.Contexts == nil { 145 | result.Contexts = map[string]*ClusterConfig{} 146 | } 147 | 148 | return &result, nil 149 | } 150 | 151 | // UpsertContext upserts a context into the configuration file 152 | func (t *ConfigManager) UpsertContext(name string, new *ClusterConfig) error { 153 | allContexts, err := t.GetAllContexts() 154 | if err != nil { 155 | return err 156 | } 157 | 158 | if existing := allContexts.Contexts[name]; existing != nil { 159 | // Merge with existing values 160 | if new.Address != "" { 161 | existing.Address = new.Address 162 | } 163 | if new.Namespace != "" { 164 | existing.Namespace = new.Namespace 165 | } 166 | if new.HeadersProvider != "" { 167 | existing.HeadersProvider = new.HeadersProvider 168 | } 169 | if new.DataConverter != "" { 170 | existing.DataConverter = new.DataConverter 171 | } 172 | if new.TLS != nil { 173 | 174 | if new.TLS.CertPath != "" { 175 | existing.TLS.CertPath = new.TLS.CertPath 176 | } 177 | 178 | if new.TLS.KeyPath != "" { 179 | existing.TLS.KeyPath = new.TLS.KeyPath 180 | } 181 | 182 | if new.TLS.CACertPath != "" { 183 | existing.TLS.CACertPath = new.TLS.CACertPath 184 | } 185 | 186 | if new.TLS.ServerName != "" { 187 | existing.TLS.ServerName = new.TLS.ServerName 188 | } 189 | 190 | // This one is tricky. It'll basically always be false in this operation unless the 191 | // user explicitly sets it to true on the command line due to Go's "defaults". 192 | // In order to properly track this, there likely needs to be a different type to 193 | // represent the options specified on the command line *or* the Config type needs to be 194 | // pointers, where a nil option represents "unchanged" 195 | existing.TLS.DisableHostVerification = new.TLS.DisableHostVerification 196 | 197 | } 198 | if new.Environment != nil { 199 | if existing.Environment == nil { 200 | existing.Environment = make(map[string]string) 201 | } 202 | for k, v := range new.Environment { 203 | existing.Environment[k] = v 204 | } 205 | } 206 | } else { 207 | // Add a new entry 208 | allContexts.Contexts[name] = new 209 | } 210 | 211 | return write(t.configFilePath, allContexts) 212 | } 213 | 214 | // SetActiveContext sets the active context 215 | func (t *ConfigManager) SetActiveContext(name, namespace string) error { 216 | config, err := t.GetAllContexts() 217 | if err != nil { 218 | return fmt.Errorf("could not get contexts: %w", err) 219 | } 220 | 221 | if name != "" { 222 | config.ActiveContext = name 223 | } 224 | // Check that context exists 225 | if _, err := t.GetContext(config.ActiveContext); err != nil { 226 | return fmt.Errorf("error checking for active context: %w", err) 227 | } 228 | 229 | if namespace != "" { 230 | config.Contexts[config.ActiveContext].Namespace = namespace 231 | } 232 | 233 | return write(t.configFilePath, config) 234 | } 235 | 236 | // DeleteContext deletes the context with given name from the config 237 | func (t *ConfigManager) DeleteContext(name string) error { 238 | config, err := t.GetAllContexts() 239 | if err != nil { 240 | return fmt.Errorf("could not get contexts: %w", err) 241 | } 242 | 243 | // Return early if context does not exist 244 | if _, err := t.GetContext(name); err != nil { 245 | return err 246 | } 247 | 248 | if config.ActiveContext == name { 249 | config.ActiveContext = "" 250 | } 251 | delete(config.Contexts, name) 252 | 253 | return write(t.configFilePath, config) 254 | } 255 | 256 | func write(filepath string, config *Config) error { 257 | b, err := json.MarshalIndent(config, "", " ") 258 | if err != nil { 259 | return err 260 | } 261 | return os.WriteFile(filepath, b, os.ModePerm) 262 | } 263 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io/ioutil" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | "testing" 11 | 12 | "github.com/urfave/cli/v2" 13 | ) 14 | 15 | func TestCLI(t *testing.T) { 16 | configDir, err := ioutil.TempDir("", "tctx_test") 17 | if err != nil { 18 | t.Fatal(err) 19 | } 20 | defer func() { 21 | if err := os.RemoveAll(configDir); err != nil { 22 | t.Fatal(err) 23 | } 24 | }() 25 | c := tctxConfigFile(filepath.Join(configDir, "tctx", "config.json")) 26 | 27 | // Check for no error when config is empty 28 | c.Run(t, TestCase{ 29 | Command: "list", 30 | StdOut: "NAME ADDRESS NAMESPACE WEB STATUS", 31 | }) 32 | // Check for error if no active context 33 | c.Run(t, TestCase{ 34 | Command: "exec -- printenv", 35 | ExpectedError: fmt.Errorf("no contexts exist: create one with `tctx add`"), 36 | }) 37 | // Add a context 38 | c.Run(t, TestCase{ 39 | Command: "add -c localhost --namespace default --address localhost:7233", 40 | StdOut: "Context \"localhost\" modified.\nActive namespace is \"default\".\n", 41 | }) 42 | // Add a second context 43 | c.Run(t, TestCase{ 44 | Command: "add -c production --namespace myapp --address temporal.example.com:443 --web_address http://localhost:8080", 45 | StdOut: "Context \"production\" modified.\nActive namespace is \"myapp\".\n", 46 | }) 47 | // Validate new list output 48 | c.Run(t, TestCase{ 49 | Command: "list", 50 | StdOutContains: []string{ 51 | "NAME ADDRESS NAMESPACE WEB STATUS", 52 | "localhost localhost:7233 default", 53 | "production temporal.example.com:443 myapp http://localhost:8080/namespaces/myapp/workflows active", 54 | }, 55 | }) 56 | // Check that environment variables are correctly set 57 | c.Run(t, TestCase{ 58 | Command: "exec -- printenv", 59 | StdOutContains: []string{ 60 | "TEMPORAL_CLI_NAMESPACE=myapp", 61 | "TEMPORAL_CLI_ADDRESS=temporal.example.com:443", 62 | }, 63 | }) 64 | // Switch to localhost context and new namespace 65 | c.Run(t, TestCase{ 66 | Command: "use -c localhost -ns bar", 67 | StdOut: "Context \"localhost\" modified.\nActive namespace is \"bar\".", 68 | }) 69 | // Check for new environment variable values 70 | c.Run(t, TestCase{ 71 | Command: "exec -- printenv", 72 | StdOutContains: []string{ 73 | "TEMPORAL_CLI_NAMESPACE=bar", 74 | "TEMPORAL_CLI_ADDRESS=localhost:7233", 75 | }, 76 | }) 77 | // Delete localhost context 78 | c.Run(t, TestCase{ 79 | Command: "delete -c localhost", 80 | StdOut: "Context \"localhost\" deleted.", 81 | }) 82 | // Deleting the same context should now error 83 | c.Run(t, TestCase{ 84 | Command: "delete -c localhost", 85 | ExpectedError: fmt.Errorf("context \"localhost\" does not exist"), 86 | }) 87 | // Add TLS and plugin config to production context 88 | c.Run(t, TestCase{ 89 | Command: "update -c production --ns test --tls_cert_path foo --tls_key_path bar --tls_ca_path baz --tls_disable_host_verification --tls_server_name qux --hpp foo-cli --dcp bar-cli", 90 | StdOut: "Context \"production\" modified.\nActive namespace is \"test\".", 91 | }) 92 | // Check for new environment variable values 93 | c.Run(t, TestCase{ 94 | Command: "exec -- printenv", 95 | StdOutContains: []string{ 96 | "TEMPORAL_CLI_NAMESPACE=test", 97 | "TEMPORAL_CLI_ADDRESS=temporal.example.com:443", 98 | "TEMPORAL_CLI_TLS_CERT=foo", 99 | "TEMPORAL_CLI_TLS_KEY=bar", 100 | "TEMPORAL_CLI_TLS_CA=baz", 101 | "TEMPORAL_CLI_TLS_DISABLE_HOST_VERIFICATION=true", 102 | "TEMPORAL_CLI_TLS_SERVER_NAME=qux", 103 | "TEMPORAL_CLI_PLUGIN_HEADERS_PROVIDER=foo-cli", 104 | "TEMPORAL_CLI_PLUGIN_DATA_CONVERTER=bar-cli", 105 | }, 106 | }) 107 | 108 | // Create new staging context 109 | c.Run(t, TestCase{ 110 | Command: "add -c staging --namespace staging --address staging:7233", 111 | StdOut: "Context \"staging\" modified.\nActive namespace is \"staging\".\n", 112 | }) 113 | // Switch to production 114 | c.Run(t, TestCase{ 115 | Command: "use -c production -ns test", 116 | StdOut: "Context \"production\" modified.\nActive namespace is \"test\".", 117 | }) 118 | // Execute command with staging context (without switching) 119 | c.Run(t, TestCase{ 120 | Command: "exec -c staging -- printenv", 121 | StdOutContains: []string{ 122 | "TEMPORAL_CLI_NAMESPACE=staging", 123 | "TEMPORAL_CLI_ADDRESS=staging:7233", 124 | }, 125 | }) 126 | // Fail to execute with nonexistent context 127 | c.Run(t, TestCase{ 128 | Command: "exec -c not-a-context -- printenv", 129 | ExpectedError: fmt.Errorf("context \"not-a-context\" does not exist"), 130 | }) 131 | 132 | // Add Additional environment variables 133 | c.Run(t, TestCase{ 134 | Command: "update -c production --ns test --env VAULT_ADDR=https://vault.test.example --env AUTH_ROLE=test_example --env FOO=bar", 135 | StdOut: "Context \"production\" modified.\nActive namespace is \"test\".", 136 | }) 137 | // Check for new environment variables 138 | c.Run(t, TestCase{ 139 | Command: "exec -- printenv", 140 | StdOutContains: []string{ 141 | "VAULT_ADDR=https://vault.test.example", 142 | "AUTH_ROLE=test_example", 143 | "FOO=bar", 144 | }, 145 | }) 146 | } 147 | 148 | func TestNestedRegression(t *testing.T) { 149 | configDir, err := ioutil.TempDir("", "tctx_test") 150 | if err != nil { 151 | t.Fatal(err) 152 | } 153 | defer func() { 154 | if err := os.RemoveAll(configDir); err != nil { 155 | t.Fatal(err) 156 | } 157 | }() 158 | c := tctxConfigFile(filepath.Join(configDir, "tctx", "config.json")) 159 | 160 | // Add a context 161 | tlsCaPath := "/path/to/ca" 162 | c.Run(t, TestCase{ 163 | Command: fmt.Sprintf("add -c regression --namespace default --address localhost:7233 --tls_ca_path %s", tlsCaPath), 164 | StdOut: "Context \"regression\" modified.\nActive namespace is \"default\".\n", 165 | }) 166 | 167 | // Switch to new context 168 | c.Run(t, TestCase{ 169 | Command: "use -c regression", 170 | StdOut: "Context \"regression\" modified.\nActive namespace is \"default\".", 171 | }) 172 | 173 | // Assert that the proper ca path is present 174 | c.Run(t, TestCase{ 175 | Command: "exec -- printenv", 176 | StdOutContains: []string{ 177 | fmt.Sprintf("TEMPORAL_CLI_TLS_CA=%s", tlsCaPath), 178 | }, 179 | }) 180 | 181 | // Update the context, adding a new environment variable 182 | c.Run(t, TestCase{ 183 | Command: "update -c regression --env FOO=bar", 184 | StdOut: "Context \"regression\" modified.\nActive namespace is \"default\".", 185 | }) 186 | 187 | // Assert that the proper ca path is still present 188 | // This is the regression check 189 | c.Run(t, TestCase{ 190 | Command: "exec -- printenv", 191 | StdOutContains: []string{ 192 | fmt.Sprintf("TEMPORAL_CLI_TLS_CA=%s", tlsCaPath), 193 | "FOO=bar", 194 | }, 195 | }) 196 | } 197 | 198 | type TestCase struct { 199 | Command string 200 | ExpectedError error 201 | StdOut string 202 | StdOutContains []string 203 | } 204 | 205 | type tctxConfigFile string 206 | 207 | func (f tctxConfigFile) newApp() (*cli.App, *bytes.Buffer) { 208 | buf := bytes.NewBufferString("") 209 | app := newApp(string(f)) 210 | app.Writer = buf 211 | return app, buf 212 | } 213 | 214 | func (f tctxConfigFile) Run(t *testing.T, tc TestCase) { 215 | t.Helper() 216 | app, buf := f.newApp() 217 | err := app.Run(append([]string{"tctx"}, strings.Split(tc.Command, " ")...)) 218 | 219 | if tc.ExpectedError != nil { 220 | if err == nil { 221 | t.Error("expected CLI to error") 222 | } else if err.Error() != tc.ExpectedError.Error() { 223 | t.Errorf("expected CLI error to be %q, got: %q", tc.ExpectedError, err) 224 | } 225 | } else if err != nil { 226 | t.Errorf("expected no CLI error, got: %q", err) 227 | } 228 | 229 | actualStdOut := buf.String() 230 | assertOutput(t, tc.StdOut, actualStdOut) 231 | 232 | for _, text := range tc.StdOutContains { 233 | if !strings.Contains(actualStdOut, text) { 234 | t.Errorf("expected CLI output to contain %q. Got: \n%s", text, actualStdOut) 235 | } 236 | } 237 | } 238 | 239 | func assertOutput(t *testing.T, expected, actual string) { 240 | t.Helper() 241 | expected = strings.TrimSpace(expected) 242 | actual = strings.TrimSpace(actual) 243 | if expected != "" && expected != actual { 244 | t.Errorf("CLI output did not match expected\n=== expected ===\n%q\n==== actual ====\n%q\n", expected, actual) 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "net/url" 8 | "os" 9 | "os/exec" 10 | "sort" 11 | "strings" 12 | "text/tabwriter" 13 | "time" 14 | 15 | "github.com/urfave/cli/v2" 16 | 17 | "github.com/jlegrone/tctx/config" 18 | 19 | "github.com/jlegrone/tctx/internal/xbar" 20 | ) 21 | 22 | const ( 23 | configPathFlag = "config_path" 24 | contextNameFlag = "context" 25 | addressFlag = "address" 26 | webAddressFlag = "web_address" 27 | namespaceFlag = "namespace" 28 | tlsCertFlag = "tls_cert_path" 29 | tlsKeyFlag = "tls_key_path" 30 | tlsCAFlag = "tls_ca_path" 31 | tlsDisableHostVerificationFlag = "tls_disable_host_verification" 32 | tlsServerNameFlag = "tls_server_name" 33 | headersProviderPluginFlag = "headers_provider_plugin" 34 | dataConverterPluginFlag = "data_converter_plugin" 35 | envFlag = "env" 36 | ) 37 | 38 | func getContextFlag(required bool) *cli.StringFlag { 39 | return &cli.StringFlag{ 40 | Name: contextNameFlag, 41 | Aliases: []string{"c"}, 42 | Usage: "name of the context", 43 | Required: required, 44 | } 45 | } 46 | 47 | func getContextAndNamespaceFlags(required bool, defaultNamespace string) []cli.Flag { 48 | return []cli.Flag{ 49 | getContextFlag(true), 50 | &cli.StringFlag{ 51 | Name: namespaceFlag, 52 | Aliases: []string{"ns"}, 53 | Usage: "Temporal workflow namespace", 54 | Value: defaultNamespace, 55 | Required: required, 56 | }, 57 | } 58 | } 59 | 60 | func getAddOrUpdateFlags(required bool) []cli.Flag { 61 | return append( 62 | getContextAndNamespaceFlags(required, "default"), 63 | &cli.StringFlag{ 64 | Name: addressFlag, 65 | Aliases: []string{"ad"}, 66 | Usage: "host:port for Temporal frontend service", 67 | Required: required, 68 | }, 69 | &cli.StringFlag{ 70 | Name: webAddressFlag, 71 | Aliases: []string{"wad"}, 72 | Usage: "URL for Temporal web UI", 73 | }, 74 | &cli.StringFlag{ 75 | Name: tlsCertFlag, 76 | Usage: "path to x509 certificate", 77 | }, 78 | &cli.StringFlag{ 79 | Name: tlsKeyFlag, 80 | Usage: "path to private key", 81 | }, 82 | &cli.StringFlag{ 83 | Name: tlsCAFlag, 84 | Usage: "path to server CA certificate", 85 | }, 86 | &cli.BoolFlag{ 87 | Name: tlsDisableHostVerificationFlag, 88 | Usage: "disable tls host name verification (tls must be enabled)", 89 | }, 90 | &cli.StringFlag{ 91 | Name: tlsServerNameFlag, 92 | Usage: "override for target server name", 93 | }, 94 | &cli.StringFlag{ 95 | Name: headersProviderPluginFlag, 96 | Aliases: []string{"hpp"}, 97 | Usage: "headers provider plugin executable name", 98 | }, 99 | &cli.StringFlag{ 100 | Name: dataConverterPluginFlag, 101 | Aliases: []string{"dcp"}, 102 | Usage: "data converter plugin executable name", 103 | }, 104 | &cli.StringSliceFlag{ 105 | Name: envFlag, 106 | Usage: "arbitrary environment variables to be set in this context, in the form of KEY=value", 107 | }, 108 | ) 109 | } 110 | 111 | func main() { 112 | userConfigFile, err := config.GetDefaultConfigPath() 113 | if err != nil { 114 | _, _ = fmt.Fprintln(os.Stderr, err) 115 | } 116 | 117 | if err := newApp(userConfigFile).Run(os.Args); err != nil { 118 | _, _ = fmt.Fprintln(os.Stderr, err) 119 | os.Exit(1) 120 | } 121 | } 122 | 123 | func configFromFlags(c *cli.Context) (configPath string, contextName string, clusterConfig *config.ClusterConfig, err error) { 124 | additionalEnvVars, err := parseAdditionalEnvVars(c.StringSlice(envFlag)) 125 | return c.String(configPathFlag), c.String(contextNameFlag), &config.ClusterConfig{ 126 | Address: c.String(addressFlag), 127 | WebAddress: c.String(webAddressFlag), 128 | Namespace: c.String(namespaceFlag), 129 | HeadersProvider: c.String(headersProviderPluginFlag), 130 | DataConverter: c.String(dataConverterPluginFlag), 131 | TLS: &config.TLSConfig{ 132 | CertPath: c.String(tlsCertFlag), 133 | KeyPath: c.String(tlsKeyFlag), 134 | CACertPath: c.String(tlsCAFlag), 135 | DisableHostVerification: c.Bool(tlsDisableHostVerificationFlag), 136 | ServerName: c.String(tlsServerNameFlag), 137 | }, 138 | Environment: additionalEnvVars, 139 | }, 140 | err 141 | } 142 | 143 | func parseAdditionalEnvVars(input []string) (additional map[string]string, err error) { 144 | envVars := make(map[string]string) 145 | if input == nil { 146 | return nil, nil 147 | } 148 | for _, kv := range input { 149 | // Additional Environment Variables are expected to be of form KEY=value 150 | kvSplit := strings.Split(kv, "=") 151 | if len(kvSplit) == 0 || len(kvSplit) == 1 { 152 | return nil, fmt.Errorf("Unable to parse environment variables %v \nEnter environment variables in the following format: --env KEY=value --env FOO=bar", input) 153 | } 154 | envVars[kvSplit[0]] = kvSplit[1] 155 | } 156 | return envVars, nil 157 | } 158 | 159 | func switchContexts(w io.Writer, t *config.ConfigManager, contextName, namespace string) error { 160 | if err := t.SetActiveContext(contextName, namespace); err != nil { 161 | return err 162 | } 163 | 164 | cfg, err := t.GetContext(contextName) 165 | if err != nil { 166 | return err 167 | } 168 | 169 | _, err = fmt.Fprintf(w, "Context %q modified.\nActive namespace is %q.\n", contextName, cfg.Namespace) 170 | return err 171 | } 172 | 173 | func newApp(configFile string) *cli.App { 174 | return &cli.App{ 175 | Name: "tctx", 176 | Usage: "manage Temporal contexts", 177 | EnableBashCompletion: true, 178 | Flags: []cli.Flag{ 179 | &cli.StringFlag{ 180 | Name: configPathFlag, 181 | TakesFile: true, 182 | // require flag when default path could not be computed 183 | Required: configFile == "", 184 | Value: configFile, 185 | }, 186 | }, 187 | Commands: []*cli.Command{ 188 | { 189 | Name: "add", 190 | Usage: "add a new context", 191 | Flags: getAddOrUpdateFlags(true), 192 | Action: func(c *cli.Context) error { 193 | path, name, cfg, err := configFromFlags(c) 194 | if err != nil { 195 | return err 196 | } 197 | 198 | t, err := config.NewConfigManager(config.WithConfigFile(path)) 199 | if err != nil { 200 | return err 201 | } 202 | 203 | // Error if context already exists 204 | existingCfg, _ := t.GetContext(name) 205 | if existingCfg != nil { 206 | return fmt.Errorf("a context with name %q already exists", name) 207 | } 208 | 209 | if err := t.UpsertContext(name, cfg); err != nil { 210 | return err 211 | } 212 | 213 | return switchContexts(c.App.Writer, t, name, cfg.Namespace) 214 | }, 215 | }, 216 | { 217 | Name: "update", 218 | Usage: "update an existing context", 219 | Flags: getAddOrUpdateFlags(false), 220 | Action: func(c *cli.Context) error { 221 | path, name, newCfg, err := configFromFlags(c) 222 | if err != nil { 223 | return err 224 | } 225 | 226 | t, err := config.NewConfigManager(config.WithConfigFile(path)) 227 | if err != nil { 228 | return err 229 | } 230 | 231 | // Check that context already exists 232 | if _, err := t.GetContext(name); err != nil { 233 | return err 234 | } 235 | 236 | if err := t.UpsertContext(name, newCfg); err != nil { 237 | return err 238 | } 239 | 240 | return switchContexts(c.App.Writer, t, name, newCfg.Namespace) 241 | }, 242 | }, 243 | { 244 | Name: "delete", 245 | Aliases: []string{}, 246 | Usage: "remove a context", 247 | Flags: []cli.Flag{ 248 | getContextFlag(true), 249 | }, 250 | Action: func(c *cli.Context) error { 251 | t, err := config.NewConfigManager(config.WithConfigFile(c.String(configPathFlag))) 252 | if err != nil { 253 | return err 254 | } 255 | 256 | contextName := c.String(contextNameFlag) 257 | if err := t.DeleteContext(contextName); err != nil { 258 | return err 259 | } 260 | 261 | _, _ = fmt.Fprintf(c.App.Writer, "Context %q deleted.\n", contextName) 262 | 263 | return nil 264 | }, 265 | }, 266 | { 267 | Name: "list", 268 | Aliases: []string{"ls"}, 269 | Usage: "list contexts", 270 | Action: func(c *cli.Context) error { 271 | t, err := config.NewConfigManager(config.WithConfigFile(c.String(configPathFlag))) 272 | if err != nil { 273 | return err 274 | } 275 | contexts, err := t.GetAllContexts() 276 | if err != nil { 277 | return err 278 | } 279 | 280 | var names []string 281 | for k := range contexts.Contexts { 282 | names = append(names, k) 283 | } 284 | sort.Strings(names) 285 | 286 | w := tabwriter.NewWriter(c.App.Writer, 1, 1, 4, ' ', 0) 287 | if _, err := fmt.Fprintln(w, "NAME\tADDRESS\tNAMESPACE\tWEB\tSTATUS\t"); err != nil { 288 | return err 289 | } 290 | 291 | for _, k := range names { 292 | v := contexts.Contexts[k] 293 | webAddr := "" 294 | if v.WebAddress != "" { 295 | webAddr, err = url.JoinPath(v.WebAddress, "namespaces", v.Namespace, "workflows") 296 | if err != nil { 297 | return err 298 | } 299 | } 300 | row := fmt.Sprintf("%s\t%s\t%s\t%s\t", k, v.Address, v.Namespace, webAddr) 301 | if contexts.ActiveContext == k { 302 | row += "active\t" 303 | } 304 | if _, err := fmt.Fprintln(w, row); err != nil { 305 | return err 306 | } 307 | } 308 | 309 | return w.Flush() 310 | }, 311 | }, 312 | { 313 | Name: "use", 314 | Aliases: []string{"u"}, 315 | Usage: "switch cluster contexts", 316 | Flags: getContextAndNamespaceFlags(false, ""), 317 | Action: func(c *cli.Context) error { 318 | var ( 319 | configPath = c.String(configPathFlag) 320 | contextName = c.String(contextNameFlag) 321 | namespace = c.String(namespaceFlag) 322 | ) 323 | 324 | t, err := config.NewConfigManager(config.WithConfigFile(configPath)) 325 | if err != nil { 326 | return err 327 | } 328 | 329 | return switchContexts(c.App.Writer, t, contextName, namespace) 330 | }, 331 | }, 332 | { 333 | Name: "tctxbar", 334 | Hidden: true, 335 | Flags: []cli.Flag{ 336 | &xbar.ShowClusterFlag, 337 | &xbar.ShowNamespaceFlag, 338 | }, 339 | Action: func(c *cli.Context) error { 340 | executablePath, err := os.Executable() 341 | if err != nil { 342 | return err 343 | } 344 | 345 | t, err := config.NewConfigManager(config.WithConfigFile(c.String(configPathFlag))) 346 | if err != nil { 347 | return err 348 | } 349 | cfg, err := t.GetAllContexts() 350 | if err != nil { 351 | return err 352 | } 353 | 354 | // Define a timeout to avoid blocking menu rendering on querying 355 | // Temporal cluster state. 356 | ctx, cancel := context.WithTimeout(c.Context, time.Second) 357 | defer cancel() 358 | 359 | return xbar.Render(ctx, &xbar.Options{ 360 | Config: cfg, 361 | TctxPath: executablePath, 362 | TctlPath: "tctl", 363 | ShowCluster: c.Bool(xbar.ShowClusterFlag.Name), 364 | ShowNamespace: c.Bool(xbar.ShowNamespaceFlag.Name), 365 | }) 366 | }, 367 | }, 368 | { 369 | Name: "exec", 370 | Aliases: []string{}, 371 | ArgsUsage: "-- [args]", 372 | Usage: "execute a command with temporal environment variables set", 373 | Flags: []cli.Flag{ 374 | getContextFlag(false), 375 | }, 376 | Action: func(c *cli.Context) error { 377 | if c.Args().Len() == 0 { 378 | return cli.ShowCommandHelp(c, "exec") 379 | } 380 | 381 | t, err := config.NewConfigManager(config.WithConfigFile(c.String(configPathFlag))) 382 | if err != nil { 383 | return err 384 | } 385 | 386 | contextName := c.String(contextNameFlag) 387 | if contextName == "" { 388 | contextName, err = t.GetActiveContextName() 389 | if err != nil { 390 | return err 391 | } 392 | } 393 | 394 | cfg, err := t.GetContext(contextName) 395 | if err != nil { 396 | return err 397 | } 398 | 399 | env := os.Environ() 400 | for k, v := range map[string]string{ 401 | "TEMPORAL_CLI_ADDRESS": cfg.Address, 402 | "TEMPORAL_CLI_NAMESPACE": cfg.Namespace, 403 | "TEMPORAL_CLI_TLS_CERT": cfg.GetTLS().CertPath, 404 | "TEMPORAL_CLI_TLS_KEY": cfg.GetTLS().KeyPath, 405 | "TEMPORAL_CLI_TLS_CA": cfg.GetTLS().CACertPath, 406 | "TEMPORAL_CLI_TLS_DISABLE_HOST_VERIFICATION": fmt.Sprintf( 407 | "%t", cfg.GetTLS().DisableHostVerification, 408 | ), 409 | "TEMPORAL_CLI_TLS_SERVER_NAME": cfg.GetTLS().ServerName, 410 | "TEMPORAL_CLI_PLUGIN_HEADERS_PROVIDER": cfg.HeadersProvider, 411 | "TEMPORAL_CLI_PLUGIN_DATA_CONVERTER": cfg.DataConverter, 412 | } { 413 | env = append(env, fmt.Sprintf("%s=%s", k, v)) 414 | } 415 | for k, v := range cfg.Environment { 416 | env = append(env, fmt.Sprintf("%s=%s", k, v)) 417 | } 418 | 419 | cmd := exec.Command(c.Args().First(), c.Args().Tail()...) 420 | cmd.Env = env 421 | cmd.Stdin = c.App.Reader 422 | cmd.Stdout = c.App.Writer 423 | cmd.Stderr = c.App.ErrWriter 424 | 425 | return cmd.Run() 426 | }, 427 | }, 428 | }, 429 | } 430 | } 431 | --------------------------------------------------------------------------------