├── .travis.yml ├── go.mod ├── .gitignore ├── Makefile ├── uuid_test.go ├── uuid.go ├── .goreleaser.yml ├── go.sum ├── settings_test.go ├── Gopkg.lock ├── LICENSE ├── Gopkg.toml ├── urlutils.go ├── formatter.go ├── urlutils_test.go ├── settings.go ├── README.md ├── authutils.go └── armclient.go /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: 4 | - 1.9.x -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/yangl900/armclient-go 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/Azure/go-autorest v10.1.0+incompatible 7 | github.com/atotto/clipboard v0.0.0-20171229224153-bc5958e1c833 8 | github.com/dgrijalva/jwt-go v3.1.0+incompatible 9 | github.com/urfave/cli v1.20.0 10 | ) 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.dll 4 | *.so 5 | *.dylib 6 | bin/ 7 | 8 | # Test binary, build with `go test -c` 9 | *.test 10 | test 11 | test/ 12 | armclient-go 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736 18 | .glide/ -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: test build-linux build-darwin build-windows 2 | 3 | build-linux: 4 | GOARCH=amd64 GOOS=linux go build -o bin/linux/amd64/armclient . 5 | 6 | build-darwin: 7 | GOARCH=amd64 GOOS=darwin go build -o bin/darwin/amd64/armclient . 8 | 9 | build-windows: 10 | GOARCH=amd64 GOOS=windows go build -o bin/windows/amd64/armclient.exe . 11 | 12 | test: 13 | go test -v 14 | 15 | clean: 16 | rm -rf dist/ 17 | rm armclient -------------------------------------------------------------------------------- /uuid_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | func TestNewUUID(t *testing.T) { 9 | a := newUUID() 10 | b := newUUID() 11 | 12 | if a == "" { 13 | t.Error("UUID generated empty") 14 | t.Fail() 15 | } 16 | 17 | if strings.TrimSpace(a) == "" { 18 | t.Error("UUID generated whitespaces") 19 | t.Fail() 20 | } 21 | 22 | if a == b { 23 | t.Error("UUID generated unique values") 24 | t.Fail() 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /uuid.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/rand" 5 | "fmt" 6 | "io" 7 | ) 8 | 9 | func newUUID() string { 10 | uuid := make([]byte, 16) 11 | n, err := io.ReadFull(rand.Reader, uuid) 12 | if n != len(uuid) || err != nil { 13 | return "" 14 | } 15 | // variant bits; see section 4.1.1 16 | uuid[8] = uuid[8]&^0xc0 | 0x80 17 | // version 4 (pseudo-random); see section 4.1.3 18 | uuid[6] = uuid[6]&^0xf0 | 0x40 19 | return fmt.Sprintf("%x-%x-%x-%x-%x", uuid[0:4], uuid[4:6], uuid[6:8], uuid[8:10], uuid[10:]) 20 | } 21 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | # .goreleaser.yml 2 | # Build customization 3 | builds: 4 | - main: . 5 | binary: armclient 6 | goos: 7 | - windows 8 | - darwin 9 | - linux 10 | goarch: 11 | - amd64 12 | # Archive customization 13 | archive: 14 | format: tar.gz 15 | name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}" 16 | replacements: 17 | amd64: 64-bit 18 | darwin: macOS 19 | format_overrides: 20 | - goos: windows 21 | format: zip 22 | files: 23 | - LICENSE 24 | checksum: 25 | name_template: "{{ .ProjectName }}_checksums.txt" -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Azure/go-autorest v10.1.0+incompatible h1:iBTp0IhJrbn6Ouhgq2dxtBjdxT9OfebI9qYuBfCcvHs= 2 | github.com/Azure/go-autorest v10.1.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= 3 | github.com/atotto/clipboard v0.0.0-20171229224153-bc5958e1c833 h1:h/E5ryZTJAtOY6T3K6u/JA1OURt0nk1C4fITywxOp4E= 4 | github.com/atotto/clipboard v0.0.0-20171229224153-bc5958e1c833/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 5 | github.com/dgrijalva/jwt-go v3.1.0+incompatible h1:FFziAwDQQ2dz1XClWMkwvukur3evtZx7x/wMHKM1i20= 6 | github.com/dgrijalva/jwt-go v3.1.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= 7 | github.com/urfave/cli v1.20.0 h1:fDqGv3UG/4jbVl/QkFwEdddtEDjh/5Ov6X+0B/3bPaw= 8 | github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= 9 | -------------------------------------------------------------------------------- /settings_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestReadNotInitilizedSetting(t *testing.T) { 8 | setDefaultSettingsPath("./does-not-exist") 9 | s, err := readSettings() 10 | 11 | if err != nil { 12 | t.Error("Failed to read settings: ", err) 13 | t.Fail() 14 | } 15 | 16 | if s.ActiveTenant != "" { 17 | t.Error("Unexpected settings: ", s) 18 | t.Fail() 19 | } 20 | } 21 | 22 | func TestSettingsSaveAndRead(t *testing.T) { 23 | setDefaultSettingsPath("./test") 24 | 25 | activeTenant := "foo" 26 | s := settings{ 27 | ActiveTenant: activeTenant, 28 | } 29 | 30 | err := saveSettings(s) 31 | if err != nil { 32 | t.Error("Failed to save settings: ", err) 33 | t.Fail() 34 | } 35 | 36 | readback, err := readSettings() 37 | if err != nil { 38 | t.Error("Failed to read settings: ", err) 39 | t.Fail() 40 | } 41 | 42 | if readback.ActiveTenant != activeTenant { 43 | t.Errorf("Active tenant from settings: %s doesn't equal expected: %s", readback.ActiveTenant, activeTenant) 44 | t.Fail() 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Gopkg.lock: -------------------------------------------------------------------------------- 1 | # This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. 2 | 3 | 4 | [[projects]] 5 | name = "github.com/Azure/go-autorest" 6 | packages = [ 7 | "autorest/adal", 8 | "autorest/date" 9 | ] 10 | revision = "5a06e9ddbe3c22262059b8e061777b9934f982bd" 11 | version = "v10.1.0" 12 | 13 | [[projects]] 14 | branch = "master" 15 | name = "github.com/atotto/clipboard" 16 | packages = ["."] 17 | revision = "bc5958e1c8339112fc3347a89f3c482f416a69d3" 18 | 19 | [[projects]] 20 | name = "github.com/dgrijalva/jwt-go" 21 | packages = ["."] 22 | revision = "dbeaa9332f19a944acb5736b4456cfcc02140e29" 23 | version = "v3.1.0" 24 | 25 | [[projects]] 26 | name = "github.com/urfave/cli" 27 | packages = ["."] 28 | revision = "cfb38830724cc34fedffe9a2a29fb54fa9169cd1" 29 | version = "v1.20.0" 30 | 31 | [solve-meta] 32 | analyzer-name = "dep" 33 | analyzer-version = 1 34 | inputs-digest = "2ad7e8336ec0a5bc85d2d2bf56a224bfed3f97995f809c43a550598c13d914f0" 35 | solver-name = "gps-cdcl" 36 | solver-version = 1 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Anders Liu 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 | -------------------------------------------------------------------------------- /Gopkg.toml: -------------------------------------------------------------------------------- 1 | # Gopkg.toml example 2 | # 3 | # Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md 4 | # for detailed Gopkg.toml documentation. 5 | # 6 | # required = ["github.com/user/thing/cmd/thing"] 7 | # ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] 8 | # 9 | # [[constraint]] 10 | # name = "github.com/user/project" 11 | # version = "1.0.0" 12 | # 13 | # [[constraint]] 14 | # name = "github.com/user/project2" 15 | # branch = "dev" 16 | # source = "github.com/myfork/project2" 17 | # 18 | # [[override]] 19 | # name = "github.com/x/y" 20 | # version = "2.4.0" 21 | # 22 | # [prune] 23 | # non-go = false 24 | # go-tests = true 25 | # unused-packages = true 26 | 27 | 28 | [[constraint]] 29 | name = "github.com/Azure/go-autorest" 30 | version = "10.1.0" 31 | 32 | [[constraint]] 33 | branch = "master" 34 | name = "github.com/atotto/clipboard" 35 | 36 | [[constraint]] 37 | name = "github.com/dgrijalva/jwt-go" 38 | version = "3.1.0" 39 | 40 | [[constraint]] 41 | name = "github.com/urfave/cli" 42 | version = "1.20.0" 43 | 44 | [prune] 45 | go-tests = true 46 | unused-packages = true 47 | -------------------------------------------------------------------------------- /urlutils.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/url" 7 | "strings" 8 | ) 9 | 10 | const ( 11 | armEndpoint string = "https://management.azure.com" 12 | ) 13 | 14 | var ( 15 | allowedEndpoints []string = []string{ 16 | "management.azure.com", 17 | "localhost", 18 | } 19 | ) 20 | 21 | func isArmURLPath(urlPath string) bool { 22 | urlPath = strings.ToLower(urlPath) 23 | return strings.HasPrefix(urlPath, "/subscriptions") || 24 | strings.HasPrefix(urlPath, "/tenants") || 25 | strings.HasPrefix(urlPath, "/providers") 26 | } 27 | 28 | func getRequestURL(path string) (string, error) { 29 | u, err := url.ParseRequestURI(path) 30 | 31 | if err != nil || !u.IsAbs() { 32 | if !isArmURLPath(path) { 33 | return "", errors.New("Url path specified is invalid") 34 | } 35 | 36 | return armEndpoint + path, nil 37 | } 38 | 39 | if u.Scheme != "https" && u.Hostname() != "localhost" { 40 | return "", errors.New("Scheme must be https") 41 | } 42 | 43 | isSafeEndpoint := false 44 | for _, v := range allowedEndpoints { 45 | if strings.HasSuffix(u.Hostname(), v) { 46 | isSafeEndpoint = true 47 | } 48 | } 49 | 50 | if !isSafeEndpoint { 51 | return "", fmt.Errorf("'%s' is not an ARM endpoint", u.Hostname()) 52 | } 53 | 54 | if !isArmURLPath(u.Path) { 55 | return "", fmt.Errorf("Url path '%s' is invalid", u.Path) 56 | } 57 | 58 | return path, nil 59 | } 60 | -------------------------------------------------------------------------------- /formatter.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "strings" 9 | "time" 10 | ) 11 | 12 | func prettyJSON(buffer []byte) string { 13 | var prettyJSON string 14 | if len(buffer) > 0 { 15 | var jsonBuffer bytes.Buffer 16 | error := json.Indent(&jsonBuffer, buffer, "", " ") 17 | if error != nil { 18 | return string(buffer) 19 | } 20 | prettyJSON = jsonBuffer.String() 21 | } else { 22 | prettyJSON = "" 23 | } 24 | 25 | return prettyJSON 26 | } 27 | 28 | func responseDetail(response *http.Response, duration time.Duration, reqheaders []string) string { 29 | var buffer bytes.Buffer 30 | fmt.Fprint(&buffer, "---------- Request -----------------------\n") 31 | fmt.Fprintln(&buffer) 32 | fmt.Fprintf(&buffer, "%s %s\n", response.Request.Method, response.Request.URL.String()) 33 | fmt.Fprintf(&buffer, "Host: %s\n", response.Request.URL.Host) 34 | fmt.Fprintf(&buffer, "Authorization: %s...\n", response.Request.Header.Get("Authorization")[0:15]) 35 | fmt.Fprintf(&buffer, "User-Agent: %s\n", response.Request.UserAgent()) 36 | fmt.Fprintf(&buffer, "Accept: %s\n", response.Request.Header.Get("Accept")) 37 | fmt.Fprintf(&buffer, "x-ms-client-request-id: %s\n", response.Request.Header.Get("x-ms-client-request-id")) 38 | 39 | if reqheaders != nil { 40 | for _, h := range reqheaders { 41 | fmt.Fprintf(&buffer, "%s: %s\n", h, response.Request.Header.Get(h)) 42 | } 43 | } 44 | 45 | fmt.Fprintln(&buffer) 46 | fmt.Fprintf(&buffer, "---------- Response (%s) ------------\n", duration.Truncate(time.Millisecond).String()) 47 | fmt.Fprintln(&buffer) 48 | fmt.Fprintf(&buffer, "%s: %s\n", response.Proto, response.Status) 49 | 50 | for name, headers := range response.Header { 51 | for _, h := range headers { 52 | name = strings.ToLower(name) 53 | fmt.Fprintf(&buffer, "%v: %v\n", name, h) 54 | } 55 | } 56 | 57 | return buffer.String() 58 | } 59 | -------------------------------------------------------------------------------- /urlutils_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestGetRequestURL(t *testing.T) { 8 | if _, err := getRequestURL("http://management.azure.com/subscriptions?api-version=2015-01-01"); err == nil { 9 | t.Error("Should not accept http endpoint") 10 | t.Fail() 11 | } 12 | 13 | if _, err := getRequestURL("https://random.azure.com/subscriptions?api-version=2015-01-01"); err == nil { 14 | t.Error("Should not accept non-ARM endpoint") 15 | t.Fail() 16 | } 17 | 18 | if _, err := getRequestURL("https://management.azure.com/?api-version=2015-01-01"); err == nil { 19 | t.Error("Should validate request path") 20 | t.Fail() 21 | } 22 | 23 | if _, err := getRequestURL("https://westus.management.azure.com/subscriptions?api-version=2015-01-01"); err != nil { 24 | t.Error("Should accept westus.management.azure.com", err) 25 | t.Fail() 26 | } 27 | 28 | if _, err := getRequestURL("/subscriptions?api-version=2015-01-01"); err != nil { 29 | t.Error("Should accept /subscriptions?api-version=2015-01-01", err) 30 | t.Fail() 31 | } 32 | } 33 | 34 | func TestArmUrlPath(t *testing.T) { 35 | if isArmURLPath("") { 36 | t.Fail() 37 | } 38 | 39 | if isArmURLPath("/") { 40 | t.Fail() 41 | } 42 | 43 | if isArmURLPath("/foo") { 44 | t.Fail() 45 | } 46 | 47 | if !isArmURLPath("/subscriptions?api-version=2015-01-01") { 48 | t.Error("Failed to match subscriptions request.") 49 | t.Fail() 50 | } 51 | 52 | if !isArmURLPath("/SUBSCRIPTIONS?api-version=2015-01-01") { 53 | t.Error("Failed to match subscriptions request.") 54 | t.Fail() 55 | } 56 | 57 | if !isArmURLPath("/subscriptions/12345678/resourceGroups?api-version=2015-01-01") { 58 | t.Error("Failed to match subscriptions request.") 59 | t.Fail() 60 | } 61 | 62 | if !isArmURLPath("/tenants?api-version=2015-01-01") { 63 | t.Error("Failed to match subscriptions request.") 64 | t.Fail() 65 | } 66 | 67 | if !isArmURLPath("/providers?api-version=2015-01-01") { 68 | t.Error("Failed to match subscriptions request.") 69 | t.Fail() 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /settings.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "log" 8 | "os" 9 | "os/user" 10 | "path/filepath" 11 | ) 12 | 13 | var ( 14 | settingPath string 15 | ) 16 | 17 | type settings struct { 18 | ActiveTenant string `json:"activeTenant"` 19 | } 20 | 21 | func defaultSettingsPath() string { 22 | if settingPath != "" { 23 | return settingPath 24 | } 25 | 26 | usr, err := user.Current() 27 | if err != nil { 28 | log.Fatal(err) 29 | } 30 | 31 | return fmt.Sprintf("%s/.armclient/settings.json", usr.HomeDir) 32 | } 33 | 34 | func setDefaultSettingsPath(path string) { 35 | settingPath = path 36 | } 37 | 38 | func saveSettings(setting settings) error { 39 | path := defaultSettingsPath() 40 | dir := filepath.Dir(path) 41 | err := os.MkdirAll(dir, os.ModePerm) 42 | if err != nil { 43 | return fmt.Errorf("failed to create directory %s: %v", dir, err) 44 | } 45 | 46 | newFile, err := ioutil.TempFile(dir, "tmp") 47 | if err != nil { 48 | return fmt.Errorf("failed to create the temp file: %v", err) 49 | } 50 | tempPath := newFile.Name() 51 | 52 | if err := json.NewEncoder(newFile).Encode(setting); err != nil { 53 | return fmt.Errorf("failed to encode to file %s: %v", tempPath, err) 54 | } 55 | if err := newFile.Close(); err != nil { 56 | return fmt.Errorf("failed to close temp file %s: %v", tempPath, err) 57 | } 58 | 59 | // Atomic replace to avoid multi-writer file corruptions 60 | if err := os.Rename(tempPath, path); err != nil { 61 | return fmt.Errorf("failed to move temporary file to desired output location. src=%s dst=%s: %v", tempPath, path, err) 62 | } 63 | if err := os.Chmod(path, 0600); err != nil { 64 | return fmt.Errorf("failed to chmod the file %s: %v", path, err) 65 | } 66 | return nil 67 | } 68 | 69 | func readSettings() (setting settings, err error) { 70 | path := defaultSettingsPath() 71 | if _, err := os.Stat(path); err == nil { 72 | file, err := os.Open(path) 73 | if err != nil { 74 | return settings{}, fmt.Errorf("failed to open file %s: %v", path, err) 75 | } 76 | defer file.Close() 77 | 78 | dec := json.NewDecoder(file) 79 | if err = dec.Decode(&setting); err != nil { 80 | return settings{}, fmt.Errorf("failed to decode contents of file %s: %v", path, err) 81 | } 82 | return setting, nil 83 | } 84 | 85 | return settings{}, nil 86 | } 87 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Go Report Card](https://goreportcard.com/badge/github.com/yangl900/armclient-go)](https://goreportcard.com/report/github.com/yangl900/armclient-go) [![Build Status](https://travis-ci.org/yangl900/armclient-go.svg?branch=master)](https://travis-ci.org/yangl900/armclient-go) 2 | # armclient 3 | A simple command line tool to invoke the Azure Resource Manager API from any OS. Inspired by original windows version ARMClient (https://github.com/projectkudu/ARMClient). 4 | 5 | # Why we need this 6 | I always loved the windows version ARMClient. It is super useful when exploring Azure Resource Manager APIs. You just work with ARM's REST API directly and with `--verbose` flag to see the raw request & response with headers. 7 | 8 | When I started working on a non-windows platform, there wasn't a similar tool available. Existing ARMClient code is based on a full .NET framework and winform thus porting to .NET Core required siginificant changes. You can do a curl but it's too much work and you still need to handle the Azure AD login manually. So I decided to implement one in Golang and release it for Windows, Linux and MacOS. 9 | 10 | ## Highlights 11 | * Integrated with Azure Cloud Shell. When running armclient in Cloud Shell, sign-in will be taken care automatically. No sign in needed, just run after you install it. 12 | 13 | [![Launch Cloud Shell](https://shell.azure.com/images/launchcloudshell.png "Launch Cloud Shell")](https://shell.azure.com) 14 | 15 | # Installation 16 | armclient is just one binary, just copy and use it. 17 | 18 | For Linux: 19 | ```bash 20 | curl -sL https://github.com/yangl900/armclient-go/releases/download/v0.2.3/armclient-go_linux_64-bit.tar.gz | tar xz 21 | ``` 22 | 23 | For Windows (In PowerShell): 24 | ```powershell 25 | curl https://github.com/yangl900/armclient-go/releases/download/v0.2.3/armclient-go_windows_64- 26 | bit.zip -OutFile armclient.zip 27 | ``` 28 | And unzip the file, the only binary needed is armclient.exe. 29 | 30 | For MacOS: 31 | 32 | Use Homebrew 33 | ``` 34 | brew install yangl900/armclient-go/armclient-go 35 | ``` 36 | 37 | or use a released binary: 38 | ```bash 39 | curl -sL https://github.com/yangl900/armclient-go/releases/download/v0.2.3/armclient-go_macOS_64-bit.tar.gz | tar xz 40 | ``` 41 | 42 | # How to use it 43 | Syntax is exactly the same as the original ARMClient. To *GET* your subscriptions, simply run 44 | 45 | ``` 46 | armclient get /subscriptions?api-version=2018-01-01 47 | ``` 48 | 49 | Output is JSON returned from the Azure Resource Manager endpoint, e.g. 50 | ```json 51 | { 52 | "value": [ 53 | { 54 | "id": "/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxx", 55 | "subscriptionId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxx", 56 | "displayName": "Visual Studio Ultimate with MSDN", 57 | "state": "Enabled", 58 | "subscriptionPolicies": { 59 | "locationPlacementId": "Public_2014-09-01", 60 | "quotaId": "MSDN_2014-09-01", 61 | "spendingLimit": "On" 62 | } 63 | } 64 | ] 65 | } 66 | ``` 67 | If more details of the request are needed, add `--verbose` flag 68 | ``` 69 | ---------- Request ----------------------- 70 | 71 | GET https://management.azure.com/subscriptions?api-version=2015-01-01 72 | Host: management.azure.com 73 | Authorization: Bearer eyJ0eXAi... 74 | User-Agent: github.com/yangl900/armclient-go 75 | Accept: application/json 76 | x-ms-client-request-id: 9e6cceb1-8a4e-40eb-9701-11d341150220 77 | 78 | ---------- Response (215ms) ------------ 79 | 80 | HTTP/1.1: 200 OK 81 | cache-control: no-cache 82 | pragma: no-cache 83 | expires: -1 84 | x-ms-request-id: 64e0fc41-98a3-42c4-808a-ef2fcb7e688c 85 | x-ms-correlation-request-id: 64e0fc41-98a3-42c4-808a-ef2fcb7e688c 86 | x-ms-routing-request-id: WESTUS:20180207T075009Z:64e0fc41-98a3-42c4-808a-ef2fcb7e688c 87 | date: Wed, 07 Feb 2018 07:50:08 GMT 88 | content-type: application/json; charset=utf-8 89 | strict-transport-security: max-age=31536000; includeSubDomains 90 | vary: Accept-Encoding 91 | x-ms-ratelimit-remaining-tenant-reads: 14998 92 | 93 | { 94 | "value": [ 95 | { 96 | "id": "/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxx", 97 | "subscriptionId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxx", 98 | "displayName": "Visual Studio Ultimate with MSDN", 99 | "state": "Enabled", 100 | "subscriptionPolicies": { 101 | "locationPlacementId": "Public_2014-09-01", 102 | "quotaId": "MSDN_2014-09-01", 103 | "spendingLimit": "On" 104 | } 105 | } 106 | ] 107 | } 108 | ``` 109 | 110 | To print out the current tenant access token claims, run 111 | ``` 112 | armclient token 113 | ``` 114 | 115 | Output looks like the following. A token will also be copied to your clipboard automatically (if available). Linux environments require `xclip` to be installed for the clipboard copy. 116 | ```json 117 | { 118 | "aud": "https://management.core.windows.net/", 119 | "iss": "https://sts.windows.net/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxx/", 120 | "iat": 1518072605, 121 | "nbf": 1518072605, 122 | "exp": 1518076505, 123 | "acr": "1", 124 | "appid": "04b07795-8ddb-461a-bbee-02f9e1bf7b46", 125 | "appidacr": "0", 126 | "idp": "live.com", 127 | "name": "Anders Liu", 128 | "scp": "user_impersonation", 129 | "ver": "1.0" 130 | } 131 | ``` 132 | 133 | To print out the raw JWT token, run 134 | ``` 135 | armclient token -r 136 | ``` 137 | 138 | To print the access token of a different tenant use the --tenant parameter 139 | ``` 140 | armclient token --tenant {tenantId or name} 141 | ``` 142 | 143 | ## Input for request body 144 | There are 2 ways to specify an input for a request body, let's take a resource group creation as an example. You can do one of the following. 145 | 146 | 1. Inline the request body in the command line 147 | ``` 148 | armclient put /subscriptions/{subscription}/resourceGroups/{resourceGroup}?api-version=2018-01-01 "{'location':'westus'}" 149 | ``` 150 | 2. Save the request body in a JSON file and use @ as a parameter 151 | ``` 152 | armclient put /subscriptions/{subscription}/resourceGroups/{resourceGroup}?api-version=2018-01-01 @./resourceGroup.json 153 | ``` 154 | 155 | ## Add additional request headers 156 | Use flag `--header` or `-H` for additional request headers. For example: 157 | 158 | ```bash 159 | armclient get /subscriptions?api-version=2018-01-01 -H Custom-Header=my-header-value-123 --verbose 160 | ``` 161 | 162 | ## Target ARM endpoint in a specific region 163 | The absolute URI is accepted, so just specify the complete URI: 164 | 165 | ``` 166 | armclient get https://westus.management.azure.com/subscriptions?api-version=2018-01-01 167 | ``` 168 | 169 | ## Working with multiple Azure AD Directories (tenants) 170 | To list all tenants you have access to: 171 | ```bash 172 | armclient tenant list 173 | ``` 174 | 175 | To set a tenant as your active tenant (defaults to the first tenant): 176 | ```bash 177 | armclient tenant set {tenantID} 178 | ``` 179 | 180 | To show the current active tenant: 181 | ```bash 182 | armclient tenant show 183 | ``` 184 | 185 | # Exploring Azure APIs 186 | For more REST API references please see [Azure REST API documentation](https://docs.microsoft.com/rest/api/). The original [ARMClient wiki](https://github.com/projectkudu/ARMClient/wiki) also has good references. 187 | 188 | # Contribution 189 | Build the project 190 | ``` 191 | make 192 | ``` 193 | 194 | Add dependency 195 | ``` 196 | dep ensure 197 | ``` 198 | 199 | # Credits 200 | - thanks @jeffhollan to enable token copy to clipboard 201 | - thanks @luanshixia to enable Homebrew installation 202 | -------------------------------------------------------------------------------- /authutils.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "io/ioutil" 8 | "log" 9 | "net/http" 10 | "net/url" 11 | "os" 12 | "os/user" 13 | "strings" 14 | 15 | "github.com/Azure/go-autorest/autorest/adal" 16 | ) 17 | 18 | const ( 19 | activeDirectoryEndpoint = "https://login.microsoftonline.com/" 20 | armResource = "https://management.core.windows.net/" 21 | clientAppID = "04b07795-8ddb-461a-bbee-02f9e1bf7b46" 22 | commonTenant = "common" 23 | ) 24 | 25 | type responseJSON struct { 26 | AccessToken string `json:"access_token"` 27 | RefreshToken string `json:"refresh_token"` 28 | Resource string `json:"resource"` 29 | TokenType string `json:"token_type"` 30 | } 31 | 32 | type tenant struct { 33 | ID string `json:"id"` 34 | TenantID string `json:"tenantId"` 35 | ContryCode string `json:"countryCode"` 36 | DisplayName string `json:"displayName"` 37 | } 38 | 39 | type tenantList struct { 40 | Value []tenant `json:"value"` 41 | } 42 | 43 | func defaultTokenCachePath(tenant string) string { 44 | usr, err := user.Current() 45 | if err != nil { 46 | log.Fatal(err) 47 | } 48 | 49 | return fmt.Sprintf("%s/.armclient/accessToken.%s.json", usr.HomeDir, strings.ToLower(tenant)) 50 | } 51 | 52 | func acquireTokenDeviceCodeFlow(oauthConfig adal.OAuthConfig, 53 | applicationID string, 54 | resource string, 55 | callbacks ...adal.TokenRefreshCallback) (*adal.ServicePrincipalToken, error) { 56 | 57 | oauthClient := &http.Client{} 58 | deviceCode, err := adal.InitiateDeviceAuth( 59 | oauthClient, 60 | oauthConfig, 61 | applicationID, 62 | resource) 63 | if err != nil { 64 | return nil, fmt.Errorf("Failed to start device auth flow: %s", err) 65 | } 66 | 67 | fmt.Println(*deviceCode.Message) 68 | 69 | token, err := adal.WaitForUserCompletion(oauthClient, deviceCode) 70 | if err != nil { 71 | return nil, fmt.Errorf("Failed to finish device auth flow: %s", err) 72 | } 73 | 74 | spt, err := adal.NewServicePrincipalTokenFromManualToken( 75 | oauthConfig, 76 | applicationID, 77 | resource, 78 | *token, 79 | callbacks...) 80 | return spt, err 81 | } 82 | 83 | func refreshToken(oauthConfig adal.OAuthConfig, 84 | applicationID string, 85 | resource string, 86 | tokenCachePath string, 87 | callbacks ...adal.TokenRefreshCallback) (*adal.ServicePrincipalToken, error) { 88 | 89 | token, err := adal.LoadToken(tokenCachePath) 90 | if err != nil { 91 | return nil, fmt.Errorf("failed to load token from cache: %v", err) 92 | } 93 | 94 | spt, err := adal.NewServicePrincipalTokenFromManualToken( 95 | oauthConfig, 96 | applicationID, 97 | resource, 98 | *token, 99 | callbacks...) 100 | if err != nil { 101 | return nil, err 102 | } 103 | return spt, spt.Refresh() 104 | } 105 | 106 | func saveToken(spt adal.Token, tenant string) error { 107 | err := adal.SaveToken(defaultTokenCachePath(tenant), 0600, spt) 108 | if err != nil { 109 | return err 110 | } 111 | 112 | return nil 113 | } 114 | 115 | func getTenants(commonTenantToken string) (ret []tenant, e error) { 116 | url, err := getRequestURL("/tenants?api-version=2018-01-01") 117 | if err != nil { 118 | return nil, err 119 | } 120 | 121 | client := &http.Client{} 122 | req, _ := http.NewRequest(http.MethodGet, url, nil) 123 | 124 | req.Header.Set("Authorization", commonTenantToken) 125 | req.Header.Set("User-Agent", userAgentStr) 126 | req.Header.Set("x-ms-client-request-id", newUUID()) 127 | req.Header.Set("Accept", "application/json") 128 | 129 | response, err := client.Do(req) 130 | if err != nil { 131 | return nil, errors.New("Failed to list tenants: " + err.Error()) 132 | } 133 | 134 | defer response.Body.Close() 135 | buf, err := ioutil.ReadAll(response.Body) 136 | 137 | var tenants tenantList 138 | json.Unmarshal(buf, &tenants) 139 | 140 | return tenants.Value, nil 141 | } 142 | 143 | func acquireAuthTokenDeviceFlow(tenantID string) (string, error) { 144 | oauthConfig, err := adal.NewOAuthConfig(activeDirectoryEndpoint, tenantID) 145 | if err != nil { 146 | panic(err) 147 | } 148 | 149 | callback := func(token adal.Token) error { 150 | return saveToken(token, tenantID) 151 | } 152 | 153 | if _, err := os.Stat(defaultTokenCachePath(tenantID)); err == nil { 154 | token, err := adal.LoadToken(defaultTokenCachePath(tenantID)) 155 | if err != nil { 156 | return "", err 157 | } 158 | 159 | var spt *adal.ServicePrincipalToken 160 | if token.IsExpired() { 161 | spt, err = refreshToken(*oauthConfig, clientAppID, armResource, defaultTokenCachePath(tenantID), callback) 162 | if err == nil { 163 | return fmt.Sprintf("%s %s", spt.Token().Type, spt.Token().AccessToken), nil 164 | } 165 | } else { 166 | return fmt.Sprintf("%s %s", token.Type, token.AccessToken), nil 167 | } 168 | } 169 | 170 | if tenantID != commonTenant { 171 | _, err := acquireAuthTokenDeviceFlow(commonTenant) 172 | if err != nil { 173 | return "", err 174 | } 175 | 176 | spt, err := refreshToken(*oauthConfig, clientAppID, armResource, defaultTokenCachePath(commonTenant), callback) 177 | if err != nil { 178 | return "", err 179 | } 180 | 181 | return fmt.Sprintf("%s %s", spt.Token().Type, spt.Token().AccessToken), nil 182 | } 183 | 184 | var spt *adal.ServicePrincipalToken 185 | spt, err = acquireTokenDeviceCodeFlow( 186 | *oauthConfig, 187 | clientAppID, 188 | armResource, 189 | callback) 190 | 191 | if err == nil { 192 | saveToken(spt.Token(), tenantID) 193 | } 194 | 195 | return fmt.Sprintf("%s %s", spt.Token().Type, spt.Token().AccessToken), nil 196 | } 197 | 198 | func acquireAuthTokenMSI(endpoint string) (string, error) { 199 | msiendpoint, _ := url.Parse(endpoint) 200 | 201 | parameters := url.Values{} 202 | parameters.Add("resource", armResource) 203 | 204 | msiendpoint.RawQuery = parameters.Encode() 205 | 206 | req, err := http.NewRequest("GET", msiendpoint.String(), nil) 207 | if err != nil { 208 | return "", err 209 | } 210 | 211 | req.Header.Add("Metadata", "true") 212 | 213 | client := &http.Client{} 214 | resp, err := client.Do(req) 215 | if err != nil { 216 | return "", err 217 | } 218 | 219 | responseBytes, err := ioutil.ReadAll(resp.Body) 220 | defer resp.Body.Close() 221 | if err != nil { 222 | return "", err 223 | } 224 | 225 | var r responseJSON 226 | err = json.Unmarshal(responseBytes, &r) 227 | if err != nil { 228 | return "", err 229 | } 230 | 231 | return r.TokenType + " " + r.AccessToken, nil 232 | } 233 | 234 | func acquireBootstrapToken() (string, error) { 235 | endpoint, hasMsiEndpoint := os.LookupEnv("MSI_ENDPOINT") 236 | 237 | if hasMsiEndpoint { 238 | token, err := acquireAuthTokenMSI(endpoint) 239 | if err != nil { 240 | return "", err 241 | } 242 | 243 | return token, nil 244 | } 245 | 246 | return acquireAuthTokenDeviceFlow(commonTenant) 247 | } 248 | 249 | func acquireAuthTokenCurrentTenant() (string, error) { 250 | userSettings, err := readSettings() 251 | if err != nil { 252 | return "", fmt.Errorf("Failed to read current tennat: %v", err) 253 | } 254 | 255 | tenantID := userSettings.ActiveTenant 256 | if tenantID == "" { 257 | token, err := acquireBootstrapToken() 258 | if err != nil { 259 | return "", err 260 | } 261 | 262 | tenants, err := getTenants(token) 263 | if err != nil { 264 | return "", errors.New("Failed to list tenants: " + err.Error()) 265 | } 266 | 267 | if len(tenants) == 0 { 268 | return "", fmt.Errorf("You don't have access to any tenants (directory)") 269 | } 270 | 271 | userSettings.ActiveTenant = tenants[0].TenantID 272 | tenantID = tenants[0].TenantID 273 | saveSettings(userSettings) 274 | } 275 | 276 | return acquireAuthToken(tenantID) 277 | } 278 | 279 | func acquireAuthToken(tenantID string) (string, error) { 280 | endpoint, hasMsiEndpoint := os.LookupEnv("MSI_ENDPOINT") 281 | 282 | if hasMsiEndpoint { 283 | token, err := acquireAuthTokenMSI(endpoint) 284 | if err != nil { 285 | return "", err 286 | } 287 | 288 | return token, nil 289 | } 290 | 291 | if tenantID == "" { 292 | panic(fmt.Errorf("Tenant ID required for acquire token")) 293 | } 294 | 295 | token, err := acquireAuthTokenDeviceFlow(tenantID) 296 | if err != nil { 297 | log.Println("Failed to login to tenant: ", tenantID) 298 | } 299 | 300 | return token, nil 301 | } 302 | -------------------------------------------------------------------------------- /armclient.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io/ioutil" 9 | "log" 10 | "net/http" 11 | "os" 12 | "path/filepath" 13 | "strings" 14 | "time" 15 | 16 | "github.com/atotto/clipboard" 17 | "github.com/dgrijalva/jwt-go" 18 | "github.com/urfave/cli" 19 | ) 20 | 21 | const ( 22 | appVersion = "0.2.3" 23 | userAgentStr = "github.com/yangl900/armclient-go" 24 | flagVerbose = "verbose" 25 | flagRaw = "raw, r" 26 | flagTenantID = "tenant, t" 27 | flagHeader = "header, H" 28 | flagNoCopy = "nocopy, n" 29 | ) 30 | 31 | func main() { 32 | app := cli.NewApp() 33 | 34 | app.Name = "armclient" 35 | app.Usage = "Command line client for Azure Resource Manager APIs." 36 | app.Version = appVersion 37 | app.Description = ` 38 | Cross-platform open source command line tool for accessing Azure Resource Manager REST APIs. 39 | Github repo: https://github.com/yangl900/armclient-go 40 | 41 | This is a Go implementation of original windows version ARMClient (https://github.com/projectkudu/ARMClient/). 42 | Commands are kept same as much as possible, and now you can enjoy the useful tool on Linux & Mac. 43 | Additionally in Azure Cloud Shell (https://shell.azure.com/), login is handled automatically. It just works.` 44 | 45 | app.Action = func(c *cli.Context) error { 46 | cli.ShowAppHelp(c) 47 | return nil 48 | } 49 | 50 | log.SetOutput(ioutil.Discard) 51 | 52 | verboseFlag := cli.BoolFlag{ 53 | Name: flagVerbose, 54 | Usage: "Output verbose messages like request Uri, headers etc.", 55 | } 56 | 57 | headerFlag := cli.StringSliceFlag{ 58 | Name: flagHeader, 59 | Usage: "Specify additional request headers.", 60 | } 61 | 62 | rawFlag := cli.BoolFlag{ 63 | Name: flagRaw, 64 | Usage: "Print out raw acces token.", 65 | } 66 | 67 | tenantIDFlag := cli.StringFlag{ 68 | Name: flagTenantID, 69 | Usage: "Specify the tenant Id.", 70 | } 71 | 72 | noCopyFlag := cli.BoolFlag{ 73 | Name: flagNoCopy, 74 | Usage: "Do not copy token to clipboard, print claims only.", 75 | } 76 | 77 | app.Flags = []cli.Flag{verboseFlag} 78 | 79 | app.Commands = []cli.Command{ 80 | { 81 | Name: "get", 82 | Action: doRequest, 83 | Usage: "Makes a GET request to ARM endpoint.", 84 | Flags: []cli.Flag{verboseFlag, headerFlag}, 85 | }, 86 | { 87 | Name: "head", 88 | Action: doRequest, 89 | Usage: "Makes a HEAD request to ARM endpoint.", 90 | Flags: []cli.Flag{verboseFlag, headerFlag}, 91 | }, 92 | { 93 | Name: "put", 94 | Action: doRequest, 95 | Usage: "Makes a PUT request to ARM endpoint.", 96 | Flags: []cli.Flag{verboseFlag, headerFlag}, 97 | }, 98 | { 99 | Name: "patch", 100 | Action: doRequest, 101 | Usage: "Makes a PATCH request to ARM endpoint.", 102 | Flags: []cli.Flag{verboseFlag, headerFlag}, 103 | }, 104 | { 105 | Name: "delete", 106 | Action: doRequest, 107 | Usage: "Makes a DELETE request to ARM endpoint.", 108 | Flags: []cli.Flag{verboseFlag, headerFlag}, 109 | }, 110 | { 111 | Name: "post", 112 | Action: doRequest, 113 | Usage: "Makes a POST request to ARM endpoint.", 114 | Flags: []cli.Flag{verboseFlag, headerFlag}, 115 | }, 116 | { 117 | Name: "token", 118 | Action: printToken, 119 | Usage: "Prints the specified tenant access token. If not specified, default to current tenant.", 120 | Flags: []cli.Flag{rawFlag, tenantIDFlag, noCopyFlag}, 121 | }, 122 | { 123 | Name: "tenant", 124 | Action: printTenants, 125 | Usage: "Manage tenants (Azure AD directory) current account have access to. Set / show active tenant.", 126 | Subcommands: []cli.Command{ 127 | { 128 | Name: "set", 129 | Action: setActiveTenant, 130 | Usage: "Sets an active tenant.", 131 | }, 132 | { 133 | Name: "show", 134 | Action: showActiveTenant, 135 | Usage: "Shows current active tenant.", 136 | }, 137 | { 138 | Name: "list", 139 | Action: printTenants, 140 | Usage: "Shows all tenants.", 141 | }, 142 | }, 143 | }, 144 | } 145 | 146 | app.CustomAppHelpTemplate = cli.AppHelpTemplate 147 | 148 | err := app.Run(os.Args) 149 | if err != nil { 150 | fmt.Println(err) 151 | } 152 | } 153 | 154 | func isWriteVerb(verb string) bool { 155 | v := strings.ToUpper(verb) 156 | return v == "PUT" || v == "POST" || v == "PATCH" 157 | } 158 | 159 | func doRequest(c *cli.Context) error { 160 | if len(c.Args()) == 0 { 161 | return errors.New("No path specified") 162 | } 163 | 164 | url, err := getRequestURL(c.Args().First()) 165 | if err != nil { 166 | return err 167 | } 168 | 169 | var reqBody string 170 | if isWriteVerb(c.Command.Name) && c.NArg() > 1 { 171 | reqBody = c.Args().Get(1) 172 | 173 | if strings.HasPrefix(reqBody, "@") { 174 | filePath, _ := filepath.Abs(strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(reqBody, "@"), "'"), "'")) 175 | 176 | if _, err := os.Stat(filePath); err != nil { 177 | return errors.New("File not found: " + filePath) 178 | } 179 | 180 | buffer, err := ioutil.ReadFile(filePath) 181 | if err != nil { 182 | return errors.New("Failed to read file: " + filePath) 183 | } 184 | 185 | reqBody = prettyJSON(buffer) 186 | } else { 187 | reqBody = prettyJSON([]byte(reqBody)) 188 | fmt.Println(reqBody) 189 | } 190 | } 191 | 192 | client := &http.Client{} 193 | req, _ := http.NewRequest(strings.ToUpper(c.Command.Name), url, bytes.NewReader([]byte(reqBody))) 194 | 195 | token, err := acquireAuthTokenCurrentTenant() 196 | if err != nil { 197 | return errors.New("Failed to acquire auth token: " + err.Error()) 198 | } 199 | 200 | req.Header.Set("Authorization", token) 201 | req.Header.Set("User-Agent", userAgentStr) 202 | req.Header.Set("x-ms-client-request-id", newUUID()) 203 | req.Header.Set("Accept", "application/json") 204 | req.Header.Set("Content-Type", "application/json") 205 | 206 | additionalHeaders := c.StringSlice(strings.Split(flagHeader, ",")[0]) 207 | headerNames := make([]string, 0) 208 | if additionalHeaders != nil { 209 | for _, h := range additionalHeaders { 210 | segments := strings.Split(h, "=") 211 | if len(segments) == 2 { 212 | req.Header.Set(segments[0], segments[1]) 213 | headerNames = append(headerNames, segments[0]) 214 | } else { 215 | return fmt.Errorf("Cannot parse specified header '%s'. Value must be in format Header=Value", h) 216 | } 217 | } 218 | } 219 | 220 | start := time.Now() 221 | response, err := client.Do(req) 222 | if err != nil { 223 | return errors.New("Request failed: " + err.Error()) 224 | } 225 | 226 | defer response.Body.Close() 227 | buf, err := ioutil.ReadAll(response.Body) 228 | 229 | if err != nil { 230 | return errors.New("Request failed: " + err.Error()) 231 | } 232 | 233 | if c.GlobalBool(flagVerbose) || c.Bool(flagVerbose) { 234 | fmt.Println(responseDetail(response, time.Now().Sub(start), headerNames)) 235 | } 236 | 237 | fmt.Println(prettyJSON(buf)) 238 | return nil 239 | } 240 | 241 | func printToken(c *cli.Context) error { 242 | tenantID := c.String(strings.Split(flagTenantID, ",")[0]) 243 | 244 | var token string 245 | var err error 246 | if tenantID == "" { 247 | token, err = acquireAuthTokenCurrentTenant() 248 | } else { 249 | token, err = acquireAuthToken(tenantID) 250 | } 251 | 252 | if err != nil { 253 | return errors.New("Failed to get access token: " + err.Error()) 254 | } 255 | 256 | if c.Bool(strings.Split(flagRaw, ",")[0]) { 257 | fmt.Println(token) 258 | clipboard.WriteAll(token) 259 | } else { 260 | segments := strings.Split(token, ".") 261 | 262 | if len(segments) != 3 { 263 | return errors.New("Invalid JWT token retrieved") 264 | } 265 | 266 | decoded, _ := jwt.DecodeSegment(segments[1]) 267 | 268 | fmt.Println(prettyJSON(decoded)) 269 | 270 | if !clipboard.Unsupported && !c.Bool(strings.Split(flagNoCopy, ",")[0]) { 271 | err := clipboard.WriteAll(token) 272 | if err == nil { 273 | fmt.Println("\n\nToken copied to clipboard successfully.") 274 | } 275 | } 276 | } 277 | 278 | return nil 279 | } 280 | 281 | func printTenants(c *cli.Context) error { 282 | token, err := acquireAuthTokenCurrentTenant() 283 | if err != nil { 284 | return errors.New("Failed to get access token: " + err.Error()) 285 | } 286 | 287 | tenants, err := getTenants(token) 288 | if err != nil { 289 | return errors.New("Failed to get tenants: " + err.Error()) 290 | } 291 | 292 | buffer, _ := json.Marshal(tenants) 293 | fmt.Println(prettyJSON(buffer)) 294 | 295 | return nil 296 | } 297 | 298 | func setActiveTenant(c *cli.Context) error { 299 | if c.NArg() == 0 { 300 | return errors.New("No tenant Id specified") 301 | } 302 | 303 | specifiedTenant := c.Args().First() 304 | 305 | token, err := acquireAuthTokenCurrentTenant() 306 | if err != nil { 307 | return errors.New("Failed to get access token: " + err.Error()) 308 | } 309 | 310 | tenants, err := getTenants(token) 311 | if err != nil { 312 | return errors.New("Failed to get tenants: " + err.Error()) 313 | } 314 | 315 | for _, t := range tenants { 316 | if strings.ToLower(t.TenantID) == strings.ToLower(specifiedTenant) { 317 | saveSettings(settings{ActiveTenant: specifiedTenant}) 318 | return nil 319 | } 320 | } 321 | 322 | return fmt.Errorf("You don't have access to specified tenant: %s", specifiedTenant) 323 | } 324 | 325 | func showActiveTenant(c *cli.Context) error { 326 | _, err := acquireAuthTokenCurrentTenant() 327 | if err != nil { 328 | return errors.New("Failed to get access token: " + err.Error()) 329 | } 330 | 331 | setting, err := readSettings() 332 | if err != nil { 333 | return fmt.Errorf("Failed to show current tenant: %v", err) 334 | } 335 | 336 | fmt.Println(setting.ActiveTenant) 337 | 338 | return nil 339 | } 340 | --------------------------------------------------------------------------------