├── 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 |
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 | [](https://github.com/jlegrone/tctx/actions/workflows/ci.yml)
4 | [](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 |
--------------------------------------------------------------------------------