├── .tool-versions ├── pkg ├── profile │ ├── terminal.go │ ├── user_profile_file_adapter.go │ ├── azureProfile.json │ ├── interfaces.go │ ├── logger.go │ └── config.go ├── state │ └── state.go ├── types │ ├── manager.go │ ├── models_test.go │ └── models.go ├── finder │ ├── finder.go │ └── finder_test.go ├── storage │ ├── file_adapter.go │ └── file_adapter_test.go ├── errors │ ├── errors_test.go │ └── errors.go ├── tenant │ └── tenant_manager.go └── subscription │ └── subscription_manager.go ├── .github ├── dependabot.yml └── workflows │ ├── ci.build.yml │ ├── cd.release.yml │ ├── ci.lint_test.yml │ ├── dependabot-auto-merge.yml │ └── cd.tag.yml ├── LICENSE ├── main.go ├── .goreleaser.yml ├── go.mod ├── .gitignore ├── README.md ├── cmd └── root.go └── go.sum /.tool-versions: -------------------------------------------------------------------------------- 1 | golang 1.18.3 2 | -------------------------------------------------------------------------------- /pkg/profile/terminal.go: -------------------------------------------------------------------------------- 1 | package profile 2 | 3 | import "fmt" 4 | 5 | // The terminal file contains helper functions primarily for sending messages to the terminal. 6 | 7 | const ( 8 | InfoColor = "\033[0;32m%s\033[0m" 9 | NoticeColor = "\033[0;36m%s\033[0m" 10 | WarningColor = "\033[1;33m%s\033[0m" 11 | ErrorColor = "\033[1;31m%s\033[0m" 12 | DebugColor = "\033[0;36m%s\033[0m" 13 | ) 14 | 15 | // PrintInfo prints an info message to the terminal. 16 | func PrintInfo(message string) { 17 | fmt.Println(fmt.Sprintf(InfoColor, message)) 18 | } 19 | 20 | // PrintNotice prints a notice message to the terminal. 21 | func PrintNotice(message string) { 22 | fmt.Println(fmt.Sprintf(NoticeColor, message)) 23 | } 24 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: / 5 | schedule: 6 | interval: weekly 7 | labels: 8 | - patch 9 | - dependencies 10 | open-pull-requests-limit: 10 11 | pull-request-branch-name: 12 | separator: "-" 13 | commit-message: 14 | prefix: "fix" 15 | include: "scope" 16 | - package-ecosystem: github-actions 17 | directory: .github/workflows 18 | schedule: 19 | interval: weekly 20 | labels: 21 | - patch 22 | - dependencies 23 | open-pull-requests-limit: 10 24 | pull-request-branch-name: 25 | separator: "-" 26 | commit-message: 27 | prefix: "fix" 28 | include: "scope" 29 | -------------------------------------------------------------------------------- /.github/workflows/ci.build.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Continuous Integration: Build Test Binary (no release)" 3 | 4 | on: 5 | pull_request: 6 | 7 | jobs: 8 | goreleaser: 9 | name: "CI: Build Test Release" 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 16 | - uses: arnested/go-version-action@v1 17 | id: go-version 18 | - name: Set up Go 19 | uses: actions/setup-go@v5 20 | with: 21 | go-version: ${{ steps.go-version.outputs.latest }} 22 | - name: Run Tests 23 | run: | 24 | go test -v -race ./... 25 | - name: Build a test release 26 | run: | 27 | go build . 28 | -------------------------------------------------------------------------------- /pkg/state/state.go: -------------------------------------------------------------------------------- 1 | package state 2 | 3 | import "github.com/spf13/viper" 4 | 5 | // StateManager handles all state operations 6 | type StateManager interface { 7 | GetLastContext() (id string, name string) 8 | SetLastContext(id string, name string) error 9 | } 10 | 11 | type ViperStateManager struct { 12 | viper *viper.Viper 13 | } 14 | 15 | func NewViperStateManager(v *viper.Viper) *ViperStateManager { 16 | return &ViperStateManager{viper: v} 17 | } 18 | 19 | func (v *ViperStateManager) GetLastContext() (string, string) { 20 | return v.viper.GetString("lastContextId"), 21 | v.viper.GetString("lastContextDisplayName") 22 | } 23 | 24 | func (v *ViperStateManager) SetLastContext(id string, name string) error { 25 | v.viper.Set("lastContextId", id) 26 | v.viper.Set("lastContextDisplayName", name) 27 | return v.viper.WriteConfig() 28 | } 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright © 2024 Richard Weston 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/cd.release.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Continuous Deployment: Release" 3 | 4 | on: 5 | pull_request: 6 | types: [closed] 7 | 8 | # Allow a subsequently queued workflow run to interrupt a previous run 9 | concurrency: 10 | group: '${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | tag: 15 | uses: ./.github/workflows/cd.tag.yml 16 | goreleaser: 17 | needs: tag 18 | name: "CD: Release" 19 | runs-on: ubuntu-latest 20 | permissions: 21 | contents: write 22 | steps: 23 | - name: Checkout 24 | uses: actions/checkout@v4 25 | with: 26 | fetch-depth: 0 27 | - name: Install Go 28 | uses: actions/setup-go@v5 29 | with: 30 | go-version: stable 31 | - name: Run GoReleaser 32 | uses: goreleaser/goreleaser-action@v6 33 | with: 34 | distribution: goreleaser 35 | version: latest 36 | args: release --clean --config .goreleaser.yml 37 | env: 38 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 39 | HOMEBREW: ${{ secrets.HOMEBREW }} 40 | -------------------------------------------------------------------------------- /pkg/types/manager.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/google/uuid" 7 | "github.com/ktr0731/go-fuzzyfinder" 8 | ) 9 | 10 | // BaseManager provides common functionality for tenant and subscription managers 11 | type BaseManager struct { 12 | Configuration *Configuration 13 | } 14 | 15 | // FuzzyFindHelper is a utility function that can be used by both tenant and subscription managers 16 | func FuzzyFindHelper[T any](items []T, displayFunc func(T) string) (*T, error) { 17 | if len(items) == 0 { 18 | return nil, fmt.Errorf("no items to select from") 19 | } 20 | 21 | idx, err := fuzzyfinder.Find( 22 | items, 23 | func(i int) string { 24 | return displayFunc(items[i]) 25 | }, 26 | ) 27 | if err != nil { 28 | return nil, err 29 | } 30 | 31 | return &items[idx], nil 32 | } 33 | 34 | // IDGetter is an interface that both Tenant and Subscription implement 35 | type IDGetter interface { 36 | GetID() uuid.UUID 37 | } 38 | 39 | // FindByIDHelper is a utility function to find an item by UUID 40 | func FindByIDHelper[T IDGetter](items []T, id uuid.UUID) (*T, error) { 41 | for _, item := range items { 42 | if item.GetID() == id { 43 | return &item, nil 44 | } 45 | } 46 | return nil, fmt.Errorf("item not found") 47 | } 48 | -------------------------------------------------------------------------------- /.github/workflows/ci.lint_test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Continuous Integration: Linting/Go Tests" 3 | 4 | on: 5 | push: 6 | branches: 7 | - "**" 8 | - "!main" 9 | 10 | jobs: 11 | Lint: 12 | name: "CI: Linting" 13 | strategy: 14 | matrix: 15 | os: [ubuntu-latest, macos-latest, windows-latest] 16 | runs-on: ${{ matrix.os }} 17 | steps: 18 | - name: Checkout Code 19 | uses: actions/checkout@v4 20 | with: 21 | fetch-depth: 0 22 | - name: Install Go 23 | uses: actions/setup-go@v5 24 | with: 25 | go-version: stable 26 | - name: go mod tidy 27 | run: go mod tidy 28 | - name: golangci-lint 29 | uses: golangci/golangci-lint-action@v6 30 | 31 | Test: 32 | name: "CI: Go Tests" 33 | strategy: 34 | matrix: 35 | os: [ubuntu-latest, macos-latest, windows-latest] 36 | runs-on: ${{ matrix.os }} 37 | steps: 38 | - name: Checkout Code 39 | uses: actions/checkout@v4 40 | with: 41 | fetch-depth: 0 42 | - name: Install Go 43 | uses: actions/setup-go@v5 44 | with: 45 | go-version: stable 46 | - name: Test 47 | run: | 48 | go mod tidy 49 | go test ./... 50 | -------------------------------------------------------------------------------- /pkg/finder/finder.go: -------------------------------------------------------------------------------- 1 | // Package finder provides utilities for finding and selecting items using fuzzy search 2 | // and ID-based lookups. It is primarily used for interactive selection of Azure 3 | // resources like tenants and subscriptions. 4 | package finder 5 | 6 | import ( 7 | "fmt" 8 | 9 | "github.com/google/uuid" 10 | "github.com/ktr0731/go-fuzzyfinder" 11 | ) 12 | 13 | // IDGetter is an interface that both Tenant and Subscription implement 14 | type IDGetter interface { 15 | GetID() uuid.UUID 16 | } 17 | 18 | // Fuzzy is a utility function that provides interactive fuzzy finding capabilities 19 | func Fuzzy[T any](items []T, displayFunc func(T) string) (*T, error) { 20 | if len(items) == 0 { 21 | return nil, fmt.Errorf("no items to select from") 22 | } 23 | 24 | idx, err := fuzzyfinder.Find( 25 | items, 26 | func(i int) string { 27 | return displayFunc(items[i]) 28 | }, 29 | ) 30 | if err != nil { 31 | return nil, err 32 | } 33 | 34 | return &items[idx], nil 35 | } 36 | 37 | // ByID finds an item by its UUID in a slice of items that implement IDGetter 38 | func ByID[T IDGetter](items []T, id uuid.UUID) (*T, error) { 39 | for _, item := range items { 40 | if item.GetID() == id { 41 | return &item, nil 42 | } 43 | } 44 | return nil, fmt.Errorf("item not found") 45 | } 46 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2024 Richard Weston 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | package main 23 | 24 | import ( 25 | "fmt" 26 | "os" 27 | 28 | "github.com/riweston/aztx/cmd" 29 | ) 30 | 31 | func main() { 32 | if err := cmd.Execute(); err != nil { 33 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) 34 | os.Exit(1) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-auto-merge.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot auto-merge 2 | on: pull_request 3 | 4 | permissions: 5 | contents: write 6 | pull-requests: write 7 | 8 | jobs: 9 | dependabot: 10 | runs-on: ubuntu-latest 11 | if: ${{ github.actor == 'dependabot[bot]' }} 12 | steps: 13 | - name: Dependabot metadata 14 | id: metadata 15 | uses: dependabot/fetch-metadata@v2 16 | with: 17 | github-token: "${{ secrets.GITHUB_TOKEN }}" 18 | 19 | - name: Add patch label for patch updates 20 | if: steps.metadata.outputs.update-type == 'version-update:semver-patch' 21 | run: gh pr edit "$PR_URL" --add-label "patch" 22 | env: 23 | PR_URL: ${{github.event.pull_request.html_url}} 24 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 25 | 26 | - name: Approve PR 27 | if: steps.metadata.outputs.update-type == 'version-update:semver-patch' || steps.metadata.outputs.update-type == 'version-update:semver-minor' 28 | run: gh pr review --approve "$PR_URL" 29 | env: 30 | PR_URL: ${{github.event.pull_request.html_url}} 31 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 32 | 33 | - name: Enable auto-merge 34 | if: steps.metadata.outputs.update-type == 'version-update:semver-patch' || steps.metadata.outputs.update-type == 'version-update:semver-minor' 35 | run: gh pr merge --auto --squash "$PR_URL" 36 | env: 37 | PR_URL: ${{github.event.pull_request.html_url}} 38 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 39 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | builds: 4 | - goos: 5 | - darwin 6 | - linux 7 | - windows 8 | archives: 9 | - formats: 10 | - zip 11 | brews: 12 | - name: aztx 13 | url_template: "https://github.com/riweston/aztx/releases/download/{{ .Tag }}/{{ .ArtifactName }}" 14 | repository: 15 | owner: riweston 16 | name: homebrew-aztx 17 | token: "{{ .Env.HOMEBREW }}" 18 | commit_author: 19 | name: goreleaserbot 20 | email: goreleaser@riweston.io 21 | homepage: "https://github.com/riweston/aztx" 22 | description: "This tool is a helper for azure-cli that leverages fzf for a nice interface to switch between subscription contexts." 23 | license: "MIT" 24 | dependencies: 25 | - name: azure-cli 26 | - name: fzf 27 | scoops: 28 | - name: aztx 29 | url_template: "https://github.com/riweston/aztx/releases/download/{{ .Tag }}/{{ .ArtifactName }}" 30 | repository: 31 | owner: riweston 32 | name: scoop-bucket 33 | token: "{{ .Env.HOMEBREW }}" 34 | commit_author: 35 | name: goreleaserbot 36 | email: goreleaser@riweston.io 37 | homepage: "https://github.com/riweston/aztx" 38 | description: "This tool is a helper for azure-cli that leverages fzf for a nice interface to switch between subscription contexts." 39 | license: "MIT" 40 | depends: 41 | - azure-cli 42 | - fzf 43 | winget: 44 | - name: aztx 45 | url_template: "https://github.com/riweston/aztx/releases/download/{{ .Tag }}/{{ .ArtifactName }}" 46 | package_identifier: "riweston.aztx" 47 | repository: 48 | owner: riweston 49 | name: winget-pkgs 50 | token: "{{ .Env.HOMEBREW }}" 51 | branch: "aztx-{{ .Tag }}" 52 | pull_request: 53 | enabled: true 54 | draft: true 55 | base: 56 | owner: microsoft 57 | name: winget-pkgs 58 | branch: main 59 | commit_author: 60 | name: Richard Weston 61 | email: github@riweston.io 62 | homepage: "https://github.com/riweston/aztx" 63 | short_description: "This tool is a helper for azure-cli that leverages fzf for a nice interface to switch between subscription contexts." 64 | license: "MIT" 65 | publisher: riweston 66 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/riweston/aztx 2 | 3 | go 1.24 4 | 5 | toolchain go1.24.1 6 | 7 | require ( 8 | github.com/charmbracelet/lipgloss v1.1.0 9 | github.com/charmbracelet/log v0.4.2 10 | github.com/google/uuid v1.6.0 11 | github.com/ktr0731/go-fuzzyfinder v0.9.0 12 | github.com/spf13/cobra v1.10.2 13 | github.com/spf13/viper v1.21.0 14 | github.com/stretchr/testify v1.11.1 15 | ) 16 | 17 | require ( 18 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 19 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 20 | github.com/charmbracelet/x/ansi v0.8.0 // indirect 21 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect 22 | github.com/charmbracelet/x/term v0.2.1 // indirect 23 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 24 | github.com/fsnotify/fsnotify v1.9.0 // indirect 25 | github.com/gdamore/encoding v1.0.1 // indirect 26 | github.com/gdamore/tcell/v2 v2.6.0 // indirect 27 | github.com/go-logfmt/logfmt v0.6.0 // indirect 28 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect 29 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 30 | github.com/ktr0731/go-ansisgr v0.1.0 // indirect 31 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 32 | github.com/mattn/go-isatty v0.0.20 // indirect 33 | github.com/mattn/go-runewidth v0.0.16 // indirect 34 | github.com/muesli/termenv v0.16.0 // indirect 35 | github.com/nsf/termbox-go v1.1.1 // indirect 36 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect 37 | github.com/pkg/errors v0.9.1 // indirect 38 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 39 | github.com/rivo/uniseg v0.4.7 // indirect 40 | github.com/sagikazarmark/locafero v0.11.0 // indirect 41 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect 42 | github.com/spf13/afero v1.15.0 // indirect 43 | github.com/spf13/cast v1.10.0 // indirect 44 | github.com/spf13/pflag v1.0.10 // indirect 45 | github.com/subosito/gotenv v1.6.0 // indirect 46 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 47 | go.yaml.in/yaml/v3 v3.0.4 // indirect 48 | golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect 49 | golang.org/x/sys v0.32.0 // indirect 50 | golang.org/x/term v0.31.0 // indirect 51 | golang.org/x/text v0.28.0 // indirect 52 | gopkg.in/yaml.v3 v3.0.1 // indirect 53 | ) 54 | -------------------------------------------------------------------------------- /pkg/storage/file_adapter.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io" 7 | "os" 8 | 9 | pkgerrors "github.com/riweston/aztx/pkg/errors" 10 | "github.com/riweston/aztx/pkg/types" 11 | ) 12 | 13 | // FileAdapter handles file read and write operations. 14 | type FileAdapter struct { 15 | Path string 16 | } 17 | 18 | // FetchDefaultPath sets the path to the default file location. 19 | func (fa *FileAdapter) FetchDefaultPath(defaultFilename string) error { 20 | home, err := os.UserHomeDir() 21 | if err != nil { 22 | return pkgerrors.ErrFetchingHomePath 23 | } 24 | fa.Path = home + defaultFilename 25 | return nil 26 | } 27 | 28 | // Read reads the content of the file. 29 | func (fa *FileAdapter) Read() ([]byte, error) { 30 | if fa.Path == "" { 31 | return nil, pkgerrors.ErrPathIsEmpty 32 | } 33 | if _, err := os.Stat(fa.Path); os.IsNotExist(err) { 34 | return nil, pkgerrors.ErrFileDoesNotExist 35 | } 36 | 37 | file, err := os.Open(fa.Path) 38 | if err != nil { 39 | return nil, err 40 | } 41 | defer file.Close() 42 | return io.ReadAll(file) 43 | } 44 | 45 | // ReadConfig reads and unmarshals configuration from file 46 | func (fa *FileAdapter) ReadConfig() (*types.Configuration, error) { 47 | if fa.Path == "" { 48 | return nil, pkgerrors.ErrPathIsEmpty 49 | } 50 | 51 | data, err := os.ReadFile(fa.Path) 52 | if err != nil { 53 | if os.IsNotExist(err) { 54 | return nil, pkgerrors.ErrFileDoesNotExist 55 | } 56 | return nil, pkgerrors.ErrFileOperation("reading", err) 57 | } 58 | 59 | // Handle BOM (Byte Order Mark) 60 | data = bytes.TrimPrefix(data, []byte("\xef\xbb\xbf")) 61 | 62 | var config types.Configuration 63 | if err := json.Unmarshal(data, &config); err != nil { 64 | return nil, pkgerrors.ErrFileOperation("unmarshaling", err) 65 | } 66 | return &config, nil 67 | } 68 | 69 | // Write writes data to the file at the specified path. 70 | func (fa *FileAdapter) Write(data []byte) error { 71 | if fa.Path == "" { 72 | return pkgerrors.ErrPathIsEmpty 73 | } 74 | return os.WriteFile(fa.Path, data, 0644) 75 | } 76 | 77 | // WriteConfig marshals and writes configuration to file 78 | func (fa *FileAdapter) WriteConfig(config *types.Configuration) error { 79 | if fa.Path == "" { 80 | return pkgerrors.ErrPathIsEmpty 81 | } 82 | 83 | data, err := json.MarshalIndent(config, "", " ") 84 | if err != nil { 85 | return pkgerrors.ErrFileOperation("marshaling", err) 86 | } 87 | 88 | return os.WriteFile(fa.Path, data, 0644) 89 | } 90 | -------------------------------------------------------------------------------- /pkg/profile/user_profile_file_adapter.go: -------------------------------------------------------------------------------- 1 | package profile 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | 7 | "github.com/google/uuid" 8 | "github.com/riweston/aztx/pkg/tenant" 9 | 10 | "github.com/riweston/aztx/pkg/storage" 11 | "github.com/riweston/aztx/pkg/types" 12 | ) 13 | 14 | type UserProfileFileAdapter struct { 15 | fileAdapter *storage.FileAdapter 16 | configuration *types.Configuration 17 | } 18 | 19 | // NewUserProfileFileAdapter creates a new instance with a file adapter. 20 | func NewUserProfileFileAdapter(path string) *UserProfileFileAdapter { 21 | return &UserProfileFileAdapter{ 22 | fileAdapter: &storage.FileAdapter{Path: path}, 23 | } 24 | } 25 | 26 | // Read reads the configuration from the file. 27 | func (u *UserProfileFileAdapter) Read() (*types.Configuration, error) { 28 | if u.configuration != nil { 29 | return u.configuration, nil 30 | } 31 | 32 | data, err := u.fileAdapter.Read() 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | // Unmarshal JSON while handling BOM characters. 38 | var config types.Configuration 39 | err = u.unmarshalConfig(data, &config) 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | u.configuration = &config 45 | return u.configuration, nil 46 | } 47 | 48 | // Write writes the configuration back to the file. 49 | func (u *UserProfileFileAdapter) Write(cfg *types.Configuration) error { 50 | jsonData, err := json.MarshalIndent(cfg, "", " ") 51 | if err != nil { 52 | return err 53 | } 54 | 55 | return u.fileAdapter.Write(jsonData) 56 | } 57 | 58 | func (u *UserProfileFileAdapter) unmarshalConfig(data []byte, cfg *types.Configuration) error { 59 | // Handle BOM (Byte Order Mark) 60 | data = bytes.Replace(data, []byte("\uFEFF"), []byte(""), -1) 61 | return json.Unmarshal(data, cfg) 62 | } 63 | 64 | func (u *UserProfileFileAdapter) GetTenants() ([]types.Tenant, error) { 65 | if u.configuration == nil { 66 | if _, err := u.Read(); err != nil { 67 | return nil, err 68 | } 69 | } 70 | 71 | tenantManager := tenant.Manager{BaseManager: types.BaseManager{Configuration: u.configuration}} 72 | return tenantManager.GetTenants() 73 | } 74 | 75 | func (u *UserProfileFileAdapter) SaveTenantName(id uuid.UUID, customName string) error { 76 | if u.configuration == nil { 77 | if _, err := u.Read(); err != nil { 78 | return err 79 | } 80 | } 81 | 82 | tenantManager := tenant.Manager{BaseManager: types.BaseManager{Configuration: u.configuration}} 83 | return tenantManager.SaveTenantName(id, customName) 84 | } 85 | -------------------------------------------------------------------------------- /pkg/profile/azureProfile.json: -------------------------------------------------------------------------------- 1 | { 2 | "installationId": "e960b7cc-c5d9-11ea-a6f5-00155d82a4f4", 3 | "subscriptions": [ 4 | { 5 | "id": "9e7969ef-4cb8-4a2d-959f-bfdaae452a3d", 6 | "name": "Production Workloads", 7 | "state": "Enabled", 8 | "user": { 9 | "name": "Contoso Ltd", 10 | "type": "user" 11 | }, 12 | "isDefault": false, 13 | "tenantId": "11111111-1111-1111-1111-111111111111", 14 | "environmentName": "AzureCloud", 15 | "homeTenantId": "11111111-1111-1111-1111-111111111111", 16 | "managedByTenants": [] 17 | }, 18 | { 19 | "id": "8aa89ebb-5735-4d1b-9c5c-a8f32a858e99", 20 | "name": "Development Environment", 21 | "state": "Enabled", 22 | "user": { 23 | "name": "Contoso Ltd", 24 | "type": "user" 25 | }, 26 | "isDefault": false, 27 | "tenantId": "11111111-1111-1111-1111-111111111111", 28 | "environmentName": "AzureCloud", 29 | "homeTenantId": "11111111-1111-1111-1111-111111111111", 30 | "managedByTenants": [] 31 | }, 32 | { 33 | "id": "9bb28eee-ebaa-442a-83ba-5511810fb151", 34 | "name": "Fabrikam Production", 35 | "state": "Enabled", 36 | "user": { 37 | "name": "Fabrikam Inc", 38 | "type": "user" 39 | }, 40 | "isDefault": false, 41 | "tenantId": "22222222-2222-2222-2222-222222222222", 42 | "environmentName": "AzureCloud", 43 | "homeTenantId": "22222222-2222-2222-2222-222222222222", 44 | "managedByTenants": [] 45 | }, 46 | { 47 | "id": "7cc65eaa-f64e-442a-8b8a-3211810ac151", 48 | "name": "Fabrikam Development", 49 | "state": "Enabled", 50 | "user": { 51 | "name": "Fabrikam Inc", 52 | "type": "user" 53 | }, 54 | "isDefault": false, 55 | "tenantId": "22222222-2222-2222-2222-222222222222", 56 | "environmentName": "AzureCloud", 57 | "homeTenantId": "22222222-2222-2222-2222-222222222222", 58 | "managedByTenants": [] 59 | }, 60 | { 61 | "id": "8fff24dd-2842-4dbb-8a66-1410c7bc231f", 62 | "name": "Acme Corp Main", 63 | "state": "Enabled", 64 | "user": { 65 | "name": "Acme Corporation", 66 | "type": "user" 67 | }, 68 | "isDefault": false, 69 | "tenantId": "33333333-3333-3333-3333-333333333333", 70 | "environmentName": "AzureCloud", 71 | "homeTenantId": "33333333-3333-3333-3333-333333333333", 72 | "managedByTenants": [] 73 | } 74 | ] 75 | } 76 | -------------------------------------------------------------------------------- /.github/workflows/cd.tag.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Pull Request Tag Creation 3 | on: 4 | workflow_call: 5 | 6 | jobs: 7 | tag: 8 | # Only run on merged PRs to main or when labels change 9 | if: | 10 | (github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'main') 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout Code 14 | uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 17 | 18 | - name: Generate Semantic Version 19 | id: semver 20 | uses: rapidstack/PR-Label-Semver-Action@v1.3.6 21 | 22 | - name: Git Tag Creation 23 | if: github.event.pull_request.merged == true 24 | uses: actions/github-script@v7.0.1 25 | env: 26 | TAG: ${{ steps.semver.outputs.string }} 27 | with: 28 | script: | 29 | const { TAG } = process.env 30 | 31 | console.log(`Attempting to create tag: ${TAG} at SHA: ${context.sha}`); 32 | 33 | try { 34 | // Try to get the tag first 35 | try { 36 | const existingTag = await github.rest.git.getRef({ 37 | owner: context.repo.owner, 38 | repo: context.repo.repo, 39 | ref: `refs/tags/${TAG}` 40 | }); 41 | console.log(`⚠️ Tag ${TAG} already exists at ${existingTag.data.object.sha}`); 42 | 43 | if (existingTag.data.object.sha === context.sha) { 44 | console.log('Tag already points to the current SHA, no action needed'); 45 | return; 46 | } 47 | 48 | throw new Error('Tag exists but points to different SHA'); 49 | } catch (error) { 50 | // 404 means tag doesn't exist, which is what we want 51 | if (error.status !== 404) { 52 | throw error; 53 | } 54 | 55 | // Create new tag 56 | await github.rest.git.createRef({ 57 | owner: context.repo.owner, 58 | repo: context.repo.repo, 59 | ref: `refs/tags/${TAG}`, 60 | sha: context.sha 61 | }); 62 | 63 | console.log(`✅ Successfully created tag ${TAG} at ${context.sha}`); 64 | } 65 | } catch (error) { 66 | console.log('Error details:', error); 67 | core.setFailed(`Failed to manage tag: ${error.message}`); 68 | } 69 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/visualstudiocode,windows,linux,macos,go 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=visualstudiocode,windows,linux,macos,go 4 | 5 | ### Go ### 6 | # Binaries for programs and plugins 7 | *.exe 8 | *.exe~ 9 | *.dll 10 | *.so 11 | *.dylib 12 | 13 | # Test binary, built with `go test -c` 14 | *.test 15 | 16 | # Output of the go coverage tool, specifically when used with LiteIDE 17 | *.out 18 | 19 | # Dependency directories (remove the comment below to include it) 20 | # vendor/ 21 | 22 | ### Go Patch ### 23 | /vendor/ 24 | /Godeps/ 25 | 26 | ### Linux ### 27 | *~ 28 | 29 | # temporary files which can be created if a process still has a handle open of a deleted file 30 | .fuse_hidden* 31 | 32 | # KDE directory preferences 33 | .directory 34 | 35 | # Linux trash folder which might appear on any partition or disk 36 | .Trash-* 37 | 38 | # .nfs files are created when an open file is removed but is still being accessed 39 | .nfs* 40 | 41 | ### macOS ### 42 | # General 43 | .DS_Store 44 | .AppleDouble 45 | .LSOverride 46 | 47 | # Icon must end with two \r 48 | Icon 49 | 50 | 51 | # Thumbnails 52 | ._* 53 | 54 | # Files that might appear in the root of a volume 55 | .DocumentRevisions-V100 56 | .fseventsd 57 | .Spotlight-V100 58 | .TemporaryItems 59 | .Trashes 60 | .VolumeIcon.icns 61 | .com.apple.timemachine.donotpresent 62 | 63 | # Directories potentially created on remote AFP share 64 | .AppleDB 65 | .AppleDesktop 66 | Network Trash Folder 67 | Temporary Items 68 | .apdisk 69 | 70 | ### VisualStudioCode ### 71 | .vscode/* 72 | !.vscode/settings.json 73 | !.vscode/tasks.json 74 | !.vscode/launch.json 75 | !.vscode/extensions.json 76 | *.code-workspace 77 | 78 | # Local History for Visual Studio Code 79 | .history/ 80 | 81 | ### VisualStudioCode Patch ### 82 | # Ignore all local history of files 83 | .history 84 | .ionide 85 | 86 | ### Windows ### 87 | # Windows thumbnail cache files 88 | Thumbs.db 89 | Thumbs.db:encryptable 90 | ehthumbs.db 91 | ehthumbs_vista.db 92 | 93 | # Dump file 94 | *.stackdump 95 | 96 | # Folder config file 97 | [Dd]esktop.ini 98 | 99 | # Recycle Bin used on file shares 100 | $RECYCLE.BIN/ 101 | 102 | # Windows Installer files 103 | *.cab 104 | *.msi 105 | *.msix 106 | *.msm 107 | *.msp 108 | 109 | # Windows shortcuts 110 | *.lnk 111 | 112 | # End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,windows,linux,macos,go 113 | -------------------------------------------------------------------------------- /pkg/profile/interfaces.go: -------------------------------------------------------------------------------- 1 | // Package profile provides interfaces and implementations for managing Azure profiles, 2 | // including tenant and subscription management, configuration storage, and logging. 3 | package profile 4 | 5 | import ( 6 | "github.com/google/uuid" 7 | "github.com/riweston/aztx/pkg/types" 8 | ) 9 | 10 | // StorageAdapter defines the interface for configuration storage operations. 11 | // Implementations of this interface handle reading and writing of Azure configuration data. 12 | type StorageAdapter interface { 13 | // ReadConfig retrieves the current Azure configuration. 14 | // Returns a Configuration object and any error encountered during the read operation. 15 | ReadConfig() (*types.Configuration, error) 16 | 17 | // WriteConfig persists the provided Azure configuration. 18 | // Returns an error if the write operation fails. 19 | WriteConfig(*types.Configuration) error 20 | } 21 | 22 | // TenantService defines the interface for tenant-related operations. 23 | // It provides functionality for managing Azure tenant information. 24 | type TenantService interface { 25 | // GetTenants retrieves all available Azure tenants. 26 | // Returns a slice of Tenant objects and any error encountered. 27 | GetTenants() ([]types.Tenant, error) 28 | 29 | // SaveTenantName updates the display name for a tenant identified by its UUID. 30 | // Returns an error if the save operation fails. 31 | SaveTenantName(uuid.UUID, string) error 32 | } 33 | 34 | // LastContextAdapter defines the interface for managing the most recently used context. 35 | // It provides functionality to persist and retrieve the last used Azure context. 36 | type LastContextAdapter interface { 37 | // ReadLastContextId retrieves the ID of the last used context. 38 | ReadLastContextId() string 39 | 40 | // ReadLastContextDisplayName retrieves the display name of the last used context. 41 | ReadLastContextDisplayName() string 42 | 43 | // WriteLastContext persists both the ID and display name of the current context. 44 | WriteLastContext(string, string) 45 | } 46 | 47 | // Logger defines the interface for logging operations. 48 | // It provides standard logging levels and formatting capabilities. 49 | type Logger interface { 50 | // Info logs informational messages with optional formatting arguments. 51 | Info(msg string, args ...interface{}) 52 | 53 | // Error logs error messages with optional formatting arguments. 54 | Error(msg string, args ...interface{}) 55 | 56 | // Debug logs debug messages with optional formatting arguments. 57 | Debug(msg string, args ...interface{}) 58 | 59 | // Warn logs warning messages with optional formatting arguments. 60 | Warn(msg string, args ...interface{}) 61 | 62 | // Success logs success messages with optional formatting arguments. 63 | Success(msg string, args ...interface{}) 64 | } 65 | -------------------------------------------------------------------------------- /pkg/finder/finder_test.go: -------------------------------------------------------------------------------- 1 | package finder 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/uuid" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | type testItem struct { 11 | id uuid.UUID 12 | name string 13 | } 14 | 15 | func (t testItem) GetID() uuid.UUID { 16 | return t.id 17 | } 18 | 19 | func TestByID(t *testing.T) { 20 | id1 := uuid.MustParse("a1a2a3a4-b1b2-c1c2-d1d2-d3d4d5d6d7d8") 21 | id2 := uuid.MustParse("b1b2b3b4-c1c2-d1d2-e1e2-e3e4e5e6e7e8") 22 | nonExistentID := uuid.MustParse("f1f2f3f4-e1e2-d1d2-c1c2-c3c4c5c6c7c8") 23 | 24 | tests := []struct { 25 | name string 26 | items []testItem 27 | searchID uuid.UUID 28 | want *testItem 29 | wantErr bool 30 | errString string 31 | }{ 32 | { 33 | name: "find existing item", 34 | items: []testItem{ 35 | {id: id1, name: "item1"}, 36 | {id: id2, name: "item2"}, 37 | }, 38 | searchID: id1, 39 | want: &testItem{id: id1, name: "item1"}, 40 | wantErr: false, 41 | errString: "", 42 | }, 43 | { 44 | name: "item not found", 45 | items: []testItem{ 46 | {id: id1, name: "item1"}, 47 | {id: id2, name: "item2"}, 48 | }, 49 | searchID: nonExistentID, 50 | want: nil, 51 | wantErr: true, 52 | errString: "item not found", 53 | }, 54 | { 55 | name: "empty slice returns error", 56 | items: []testItem{}, 57 | searchID: id1, 58 | want: nil, 59 | wantErr: true, 60 | errString: "item not found", 61 | }, 62 | } 63 | 64 | for _, tt := range tests { 65 | t.Run(tt.name, func(t *testing.T) { 66 | got, err := ByID(tt.items, tt.searchID) 67 | if tt.wantErr { 68 | assert.Error(t, err) 69 | assert.Equal(t, tt.errString, err.Error()) 70 | assert.Nil(t, got) 71 | } else { 72 | assert.NoError(t, err) 73 | assert.Equal(t, tt.want, got) 74 | } 75 | }) 76 | } 77 | } 78 | 79 | func TestFuzzy(t *testing.T) { 80 | // We'll only test the error cases since the interactive part shouldn't be tested 81 | tests := []struct { 82 | name string 83 | items []testItem 84 | displayFunc func(testItem) string 85 | wantErr bool 86 | errString string 87 | }{ 88 | { 89 | name: "empty slice returns error", 90 | items: []testItem{}, 91 | wantErr: true, 92 | errString: "no items to select from", 93 | }, 94 | } 95 | 96 | for _, tt := range tests { 97 | t.Run(tt.name, func(t *testing.T) { 98 | displayFunc := func(i testItem) string { return i.name } 99 | _, err := Fuzzy(tt.items, displayFunc) 100 | if tt.wantErr { 101 | assert.Error(t, err) 102 | assert.Equal(t, tt.errString, err.Error()) 103 | } else { 104 | assert.NoError(t, err) 105 | } 106 | }) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /pkg/errors/errors_test.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestErrorWrapping(t *testing.T) { 11 | baseErr := errors.New("base error") 12 | 13 | tests := []struct { 14 | name string 15 | err error 16 | wrapper func(error) error 17 | wantMsg string 18 | }{ 19 | { 20 | name: "wrap configuration read error", 21 | err: baseErr, 22 | wrapper: ErrReadingConfiguration, 23 | wantMsg: "error reading configuration: base error", 24 | }, 25 | { 26 | name: "wrap configuration write error", 27 | err: baseErr, 28 | wrapper: ErrWritingConfiguration, 29 | wantMsg: "error writing configuration: base error", 30 | }, 31 | { 32 | name: "wrap JSON marshalling error", 33 | err: baseErr, 34 | wrapper: ErrMarshallingJSON, 35 | wantMsg: "error marshalling JSON: base error", 36 | }, 37 | { 38 | name: "wrap JSON unmarshalling error", 39 | err: baseErr, 40 | wrapper: ErrUnmarshallingJSON, 41 | wantMsg: "error unmarshalling JSON: base error", 42 | }, 43 | { 44 | name: "wrap file read error", 45 | err: baseErr, 46 | wrapper: ErrReadingFile, 47 | wantMsg: "error reading file: base error", 48 | }, 49 | { 50 | name: "wrap file write error", 51 | err: baseErr, 52 | wrapper: ErrWritingFile, 53 | wantMsg: "error writing file: base error", 54 | }, 55 | { 56 | name: "wrap subscription selection error", 57 | err: baseErr, 58 | wrapper: ErrSelectingSubscription, 59 | wantMsg: "error selecting subscription: base error", 60 | }, 61 | { 62 | name: "wrap previous context error", 63 | err: baseErr, 64 | wrapper: ErrSettingPreviousContext, 65 | wantMsg: "error setting previous context: base error", 66 | }, 67 | } 68 | 69 | for _, tt := range tests { 70 | t.Run(tt.name, func(t *testing.T) { 71 | wrappedErr := tt.wrapper(tt.err) 72 | assert.EqualError(t, wrappedErr, tt.wantMsg) 73 | assert.True(t, errors.Is(wrappedErr, baseErr), "wrapped error should contain the original error") 74 | }) 75 | } 76 | } 77 | 78 | func TestStaticErrors(t *testing.T) { 79 | tests := []struct { 80 | name string 81 | err error 82 | msg string 83 | }{ 84 | { 85 | name: "file does not exist error", 86 | err: ErrFileDoesNotExist, 87 | msg: "file does not exist", 88 | }, 89 | { 90 | name: "fetching home path error", 91 | err: ErrFetchingHomePath, 92 | msg: "could not fetch home directory", 93 | }, 94 | { 95 | name: "path is empty error", 96 | err: ErrPathIsEmpty, 97 | msg: "path is empty", 98 | }, 99 | } 100 | 101 | for _, tt := range tests { 102 | t.Run(tt.name, func(t *testing.T) { 103 | assert.EqualError(t, tt.err, tt.msg) 104 | }) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /pkg/tenant/tenant_manager.go: -------------------------------------------------------------------------------- 1 | package tenant 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/google/uuid" 7 | pkgerrors "github.com/riweston/aztx/pkg/errors" 8 | "github.com/riweston/aztx/pkg/finder" 9 | "github.com/riweston/aztx/pkg/types" 10 | ) 11 | 12 | type Manager struct { 13 | types.BaseManager 14 | } 15 | 16 | // GetTenants retrieves a list of unique tenants from subscriptions. 17 | func (tm *Manager) GetTenants() ([]types.Tenant, error) { 18 | uniqueTenants := make(map[string]types.Tenant) 19 | 20 | for _, sub := range tm.Configuration.Subscriptions { 21 | if sub.TenantID != uuid.Nil { 22 | tenant := types.Tenant{ 23 | ID: sub.TenantID, 24 | Name: sub.User.Name, 25 | } 26 | // Check if we have a custom name for this tenant 27 | for _, t := range tm.Configuration.Tenants { 28 | if t.ID == sub.TenantID && t.CustomName != "" { 29 | tenant.CustomName = t.CustomName 30 | break 31 | } 32 | } 33 | uniqueTenants[sub.TenantID.String()] = tenant 34 | } 35 | } 36 | 37 | if len(uniqueTenants) == 0 { 38 | return nil, pkgerrors.ErrTenantNotFound 39 | } 40 | 41 | tenants := make([]types.Tenant, 0, len(uniqueTenants)) 42 | for _, tenant := range uniqueTenants { 43 | tenants = append(tenants, tenant) 44 | } 45 | return tenants, nil 46 | } 47 | 48 | // FindTenantIndex uses fuzzy finding to let user select a tenant 49 | func (tm *Manager) FindTenantIndex() (*types.Tenant, error) { 50 | tenants, err := tm.GetTenants() 51 | if err != nil { 52 | return nil, fmt.Errorf("failed to get tenants: %w", err) 53 | } 54 | 55 | return finder.Fuzzy(tenants, func(t types.Tenant) string { 56 | if t.CustomName != "" { 57 | return fmt.Sprintf("%s (%s)", t.CustomName, t.ID) 58 | } 59 | return fmt.Sprintf("%s (%s)", t.Name, t.ID) 60 | }) 61 | } 62 | 63 | // SaveTenantName saves or updates a tenant's custom name. 64 | func (tm *Manager) SaveTenantName(id uuid.UUID, customName string) error { 65 | if id == uuid.Nil { 66 | return fmt.Errorf("invalid tenant ID") 67 | } 68 | if customName == "" { 69 | return fmt.Errorf("custom name cannot be empty") 70 | } 71 | 72 | // First verify the tenant exists 73 | tenants, err := tm.GetTenants() 74 | if err != nil { 75 | return fmt.Errorf("failed to verify tenant: %w", err) 76 | } 77 | 78 | _, err = finder.ByID(tenants, id) 79 | if err != nil { 80 | return pkgerrors.ErrTenantNotFound 81 | } 82 | 83 | // Update or add the custom name 84 | found := false 85 | for i, tenant := range tm.Configuration.Tenants { 86 | if tenant.ID == id { 87 | tm.Configuration.Tenants[i].CustomName = customName 88 | found = true 89 | break 90 | } 91 | } 92 | 93 | if !found { 94 | tm.Configuration.Tenants = append(tm.Configuration.Tenants, types.Tenant{ 95 | ID: id, 96 | CustomName: customName, 97 | }) 98 | } 99 | 100 | return nil 101 | } 102 | -------------------------------------------------------------------------------- /pkg/subscription/subscription_manager.go: -------------------------------------------------------------------------------- 1 | package subscription 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/google/uuid" 7 | pkgerrors "github.com/riweston/aztx/pkg/errors" 8 | "github.com/riweston/aztx/pkg/finder" 9 | "github.com/riweston/aztx/pkg/types" 10 | ) 11 | 12 | type Manager struct { 13 | types.BaseManager 14 | } 15 | 16 | // SetDefaultSubscription marks a subscription as default by its UUID. 17 | func (sm *Manager) SetDefaultSubscription(subscriptionID uuid.UUID) error { 18 | for i, sub := range sm.Configuration.Subscriptions { 19 | if sub.ID == subscriptionID { 20 | sm.Configuration.Subscriptions[i].IsDefault = true 21 | } else { 22 | sm.Configuration.Subscriptions[i].IsDefault = false 23 | } 24 | } 25 | return nil 26 | } 27 | 28 | // FindSubscription searches for a subscription by name and returns it. 29 | func (sm *Manager) FindSubscription(name string) (*types.Subscription, error) { 30 | for _, sub := range sm.Configuration.Subscriptions { 31 | if sub.Name == name { 32 | return &sub, nil 33 | } 34 | } 35 | return nil, pkgerrors.ErrSubscriptionNotFound 36 | } 37 | 38 | // FindSubscriptionIndex uses fuzzy finding to let user select a subscription 39 | func (sm *Manager) FindSubscriptionIndex() (int, error) { 40 | if len(sm.Configuration.Subscriptions) == 0 { 41 | return -1, pkgerrors.ErrSubscriptionNotFound 42 | } 43 | 44 | sub, err := finder.Fuzzy(sm.Configuration.Subscriptions, func(s types.Subscription) string { 45 | return fmt.Sprintf("%s (%s)", s.Name, s.ID) 46 | }) 47 | if err != nil { 48 | return -1, err 49 | } 50 | 51 | // Find the index of the selected subscription 52 | for i, s := range sm.Configuration.Subscriptions { 53 | if s.ID == sub.ID { 54 | return i, nil 55 | } 56 | } 57 | 58 | return -1, pkgerrors.ErrSubscriptionNotFound 59 | } 60 | 61 | // FindSubscriptionByID finds a subscription by its ID 62 | func (sm *Manager) FindSubscriptionByID(id uuid.UUID) (*types.Subscription, error) { 63 | return finder.ByID(sm.Configuration.Subscriptions, id) 64 | } 65 | 66 | // FindSubscriptionsByTenant returns subscriptions filtered by tenant ID 67 | func (sm *Manager) FindSubscriptionsByTenant(tenantID uuid.UUID) ([]types.Subscription, error) { 68 | var tenantSubs []types.Subscription 69 | for _, sub := range sm.Configuration.Subscriptions { 70 | if sub.TenantID == tenantID { 71 | tenantSubs = append(tenantSubs, sub) 72 | } 73 | } 74 | if len(tenantSubs) == 0 { 75 | return nil, pkgerrors.ErrSubscriptionNotFound 76 | } 77 | return tenantSubs, nil 78 | } 79 | 80 | // FindSubscriptionIndexByTenant uses fuzzy finding to select a subscription from a specific tenant 81 | func (sm *Manager) FindSubscriptionIndexByTenant(tenantID uuid.UUID) (*types.Subscription, error) { 82 | subs, err := sm.FindSubscriptionsByTenant(tenantID) 83 | if err != nil { 84 | return nil, err 85 | } 86 | 87 | return finder.Fuzzy(subs, func(s types.Subscription) string { 88 | return fmt.Sprintf("%s (%s)", s.Name, s.ID) 89 | }) 90 | } 91 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # aztx - Azure-CLI Context Switcher 2 | 3 | `aztx` is a command-line tool designed to streamline the management of Azure tenant and subscription contexts. It provides an intuitive fuzzy-finder interface for switching between Azure subscriptions and tenants, making it easier to work with multiple Azure environments. 4 | 5 | ## Features 6 | 7 | - 🔍 Fuzzy search interface for finding subscriptions and tenants 8 | - ⚡ Quick context switching between subscriptions 9 | - 🔄 Easy switching to previous context (similar to `cd -`) 10 | - 🎯 Tenant-first selection mode 11 | - 🔧 Configurable logging levels 12 | 13 | ### Demo 14 | 15 | [![asciicast](https://asciinema.org/a/Rk36acdIGN9K6w5WO5Rx74NwA.svg)](https://asciinema.org/a/Rk36acdIGN9K6w5WO5Rx74NwA) 16 | 17 | ## Prerequisites 18 | 19 | > [!NOTE] 20 | > This tool is built on top of the azure-cli and fzf and requires them to be installed and configured. 21 | > If you use the Brew or Scoop package managers, these pre-requisites will be handled during installation. 22 | 23 | - go >=1.16.6 24 | - azure-cli >= 2.22.1 25 | - fzf >= 0.20.0 26 | 27 | ## Installation 28 | 29 | ### [Brew](https://brew.sh/) (Mac/Linux) 30 | 31 | ```sh 32 | brew tap riweston/aztx 33 | brew install aztx 34 | ``` 35 | 36 | ### [Scoop](https://scoop.sh/) (Windows) 37 | 38 | ```sh 39 | scoop bucket add riweston https://github.com/riweston/scoop-bucket.git 40 | scoop update 41 | scoop install riweston/aztx 42 | ``` 43 | 44 | ### [winget](https://learn.microsoft.com/en-us/windows/package-manager/winget/) (Windows) 45 | 46 | ```sh 47 | winget install aztx 48 | ``` 49 | 50 | ### Download Prebuilt Binary 51 | 52 | Download the latest release from the [releases page](https://github.com/riweston/aztx/releases) and add it to your PATH. 53 | 54 | ### Install from Source 55 | 56 | ```sh 57 | go install github.com/riweston/aztx 58 | ``` 59 | 60 | ## Usage 61 | 62 | ### Basic Subscription Switching 63 | 64 | ```sh 65 | # Launch interactive subscription selector 66 | aztx 67 | 68 | # Switch to previous subscription context 69 | aztx - 70 | ``` 71 | 72 | ### Tenant-First Selection 73 | 74 | ```sh 75 | # Select tenant before choosing subscription 76 | aztx --by-tenant 77 | ``` 78 | 79 | ## Configuration 80 | 81 | Configuration is stored in `~/.aztx.yml`. The following options are available: 82 | 83 | ```yaml 84 | # Log level: debug, info, warn, error 85 | log-level: info 86 | 87 | # by-tenant: true, false 88 | by-tenant: false 89 | ``` 90 | 91 | You can also set configuration via environment variables: 92 | - `AZTX_LOG_LEVEL`: Set logging level 93 | - `AZTX_BY_TENANT`: Enable tenant-first selection mode 94 | 95 | ## Contributing 96 | 97 | Contributions are welcome! Please feel free to submit a Pull Request. 98 | 99 | ## License 100 | 101 | This project is licensed under the MIT License - see the LICENSE file for details. 102 | 103 | ## Show your support 104 | 105 | Give a ⭐️ if this project helped you! 106 | -------------------------------------------------------------------------------- /pkg/profile/logger.go: -------------------------------------------------------------------------------- 1 | package profile 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "strings" 8 | "time" 9 | 10 | "github.com/charmbracelet/lipgloss" 11 | "github.com/charmbracelet/log" 12 | ) 13 | 14 | type LogLevel int 15 | 16 | const ( 17 | LevelDebug LogLevel = iota 18 | LevelInfo 19 | LevelWarn 20 | LevelError 21 | ) 22 | 23 | var ( 24 | // Style definitions 25 | successStyle = lipgloss.NewStyle(). 26 | Foreground(lipgloss.Color("2")). 27 | Bold(true) 28 | 29 | infoStyle = lipgloss.NewStyle(). 30 | Foreground(lipgloss.Color("12")) 31 | 32 | warnStyle = lipgloss.NewStyle(). 33 | Foreground(lipgloss.Color("3")). 34 | Bold(true) 35 | 36 | errorStyle = lipgloss.NewStyle(). 37 | Foreground(lipgloss.Color("1")). 38 | Bold(true) 39 | 40 | debugStyle = lipgloss.NewStyle(). 41 | Foreground(lipgloss.Color("8")) 42 | ) 43 | 44 | type DefaultLogger struct { 45 | logger *log.Logger 46 | level LogLevel 47 | writer io.Writer 48 | } 49 | 50 | func NewLogger(level string) *DefaultLogger { 51 | logger := log.NewWithOptions(os.Stderr, log.Options{ 52 | ReportCaller: true, 53 | ReportTimestamp: true, 54 | TimeFormat: time.Kitchen, 55 | Prefix: "aztx", 56 | }) 57 | 58 | return &DefaultLogger{ 59 | logger: logger, 60 | level: parseLevel(level), 61 | writer: os.Stderr, 62 | } 63 | } 64 | 65 | func parseLevel(level string) LogLevel { 66 | switch strings.ToLower(level) { 67 | case "debug": 68 | return LevelDebug 69 | case "info": 70 | return LevelInfo 71 | case "warn": 72 | return LevelWarn 73 | case "error": 74 | return LevelError 75 | default: 76 | return LevelInfo 77 | } 78 | } 79 | 80 | func (l *DefaultLogger) formatMessage(msg string, args ...interface{}) string { 81 | if len(args) > 0 { 82 | return fmt.Sprintf(msg, args...) 83 | } 84 | return msg 85 | } 86 | 87 | func (l *DefaultLogger) Debug(msg string, args ...interface{}) { 88 | if l.level <= LevelDebug { 89 | formattedMsg := l.formatMessage(msg, args...) 90 | l.logger.Debug(debugStyle.Render(formattedMsg)) 91 | } 92 | } 93 | 94 | func (l *DefaultLogger) Info(msg string, args ...interface{}) { 95 | if l.level <= LevelInfo { 96 | formattedMsg := l.formatMessage(msg, args...) 97 | // For Info, we'll use a simpler, user-friendly output 98 | fmt.Println(infoStyle.Render(formattedMsg)) 99 | } 100 | } 101 | 102 | func (l *DefaultLogger) Success(msg string, args ...interface{}) { 103 | if l.level <= LevelInfo { 104 | formattedMsg := l.formatMessage(msg, args...) 105 | fmt.Println(successStyle.Render(formattedMsg)) 106 | } 107 | } 108 | 109 | func (l *DefaultLogger) Warn(msg string, args ...interface{}) { 110 | if l.level <= LevelWarn { 111 | formattedMsg := l.formatMessage(msg, args...) 112 | l.logger.Warn(warnStyle.Render(formattedMsg)) 113 | } 114 | } 115 | 116 | func (l *DefaultLogger) Error(msg string, args ...interface{}) { 117 | if l.level <= LevelError { 118 | formattedMsg := l.formatMessage(msg, args...) 119 | l.logger.Error(errorStyle.Render(formattedMsg)) 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /pkg/types/models_test.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/uuid" 7 | "github.com/riweston/aztx/pkg/errors" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestConfiguration_Validate(t *testing.T) { 12 | validID := uuid.MustParse("a1a2a3a4-b1b2-c1c2-d1d2-d3d4d5d6d7d8") 13 | validTenant := Tenant{ 14 | ID: validID, 15 | Name: "Test Tenant", 16 | } 17 | validSubscription := Subscription{ 18 | ID: validID, 19 | Name: "Test Subscription", 20 | State: "Enabled", 21 | User: struct { 22 | Name string `json:"name"` 23 | Type string `json:"type"` 24 | }{ 25 | Name: "Test User", 26 | Type: "User", 27 | }, 28 | TenantID: validID, 29 | HomeTenantID: validID, 30 | EnvironmentName: "AzureCloud", 31 | } 32 | 33 | tests := []struct { 34 | name string 35 | config *Configuration 36 | wantErr error 37 | }{ 38 | { 39 | name: "nil configuration returns error", 40 | config: nil, 41 | wantErr: errors.ErrEmptyConfiguration, 42 | }, 43 | { 44 | name: "empty installation ID returns error", 45 | config: &Configuration{ 46 | InstallationID: uuid.Nil, 47 | Tenants: []Tenant{}, 48 | Subscriptions: []Subscription{}, 49 | }, 50 | wantErr: errors.ErrOperation("validating installation ID", errors.ErrEmptyConfiguration), 51 | }, 52 | { 53 | name: "valid configuration returns no error", 54 | config: &Configuration{ 55 | InstallationID: validID, 56 | Tenants: []Tenant{validTenant}, 57 | Subscriptions: []Subscription{validSubscription}, 58 | }, 59 | wantErr: nil, 60 | }, 61 | } 62 | 63 | for _, tt := range tests { 64 | t.Run(tt.name, func(t *testing.T) { 65 | err := tt.config.Validate() 66 | if tt.wantErr != nil { 67 | assert.Error(t, err) 68 | assert.Equal(t, tt.wantErr.Error(), err.Error()) 69 | } else { 70 | assert.NoError(t, err) 71 | } 72 | }) 73 | } 74 | } 75 | 76 | func TestTenant_Validate(t *testing.T) { 77 | validID := uuid.MustParse("a1a2a3a4-b1b2-c1c2-d1d2-d3d4d5d6d7d8") 78 | 79 | tests := []struct { 80 | name string 81 | tenant *Tenant 82 | wantErr bool 83 | }{ 84 | { 85 | name: "nil tenant returns error", 86 | tenant: nil, 87 | wantErr: true, 88 | }, 89 | { 90 | name: "empty ID returns error", 91 | tenant: &Tenant{ 92 | ID: uuid.Nil, 93 | Name: "Test Tenant", 94 | }, 95 | wantErr: true, 96 | }, 97 | { 98 | name: "empty name returns error", 99 | tenant: &Tenant{ 100 | ID: validID, 101 | Name: "", 102 | }, 103 | wantErr: true, 104 | }, 105 | { 106 | name: "valid tenant returns no error", 107 | tenant: &Tenant{ 108 | ID: validID, 109 | Name: "Test Tenant", 110 | }, 111 | wantErr: false, 112 | }, 113 | } 114 | 115 | for _, tt := range tests { 116 | t.Run(tt.name, func(t *testing.T) { 117 | err := tt.tenant.Validate() 118 | if tt.wantErr { 119 | assert.Error(t, err) 120 | } else { 121 | assert.NoError(t, err) 122 | } 123 | }) 124 | } 125 | } 126 | 127 | func TestSubscription_Validate(t *testing.T) { 128 | validID := uuid.MustParse("a1a2a3a4-b1b2-c1c2-d1d2-d3d4d5d6d7d8") 129 | 130 | tests := []struct { 131 | name string 132 | subscription *Subscription 133 | wantErr bool 134 | }{ 135 | { 136 | name: "nil subscription returns error", 137 | subscription: nil, 138 | wantErr: true, 139 | }, 140 | { 141 | name: "empty ID returns error", 142 | subscription: &Subscription{ 143 | ID: uuid.Nil, 144 | Name: "Test Subscription", 145 | State: "Enabled", 146 | }, 147 | wantErr: true, 148 | }, 149 | { 150 | name: "empty name returns error", 151 | subscription: &Subscription{ 152 | ID: validID, 153 | Name: "", 154 | State: "Enabled", 155 | }, 156 | wantErr: true, 157 | }, 158 | { 159 | name: "valid subscription returns no error", 160 | subscription: &Subscription{ 161 | ID: validID, 162 | Name: "Test Subscription", 163 | State: "Enabled", 164 | User: struct { 165 | Name string `json:"name"` 166 | Type string `json:"type"` 167 | }{ 168 | Name: "Test User", 169 | Type: "User", 170 | }, 171 | TenantID: validID, 172 | HomeTenantID: validID, 173 | EnvironmentName: "AzureCloud", 174 | }, 175 | wantErr: false, 176 | }, 177 | } 178 | 179 | for _, tt := range tests { 180 | t.Run(tt.name, func(t *testing.T) { 181 | err := tt.subscription.Validate() 182 | if tt.wantErr { 183 | assert.Error(t, err) 184 | } else { 185 | assert.NoError(t, err) 186 | } 187 | }) 188 | } 189 | } 190 | 191 | func TestIDGetter_Implementation(t *testing.T) { 192 | // Test that both Tenant and Subscription implement IDGetter 193 | validID := uuid.MustParse("a1a2a3a4-b1b2-c1c2-d1d2-d3d4d5d6d7d8") 194 | 195 | tenant := Tenant{ID: validID} 196 | subscription := Subscription{ID: validID} 197 | 198 | assert.Equal(t, validID, tenant.GetID()) 199 | assert.Equal(t, validID, subscription.GetID()) 200 | } 201 | -------------------------------------------------------------------------------- /pkg/types/models.go: -------------------------------------------------------------------------------- 1 | // Package types provides the core data structures and models used throughout the aztx application. 2 | // It defines the configuration, tenant, and subscription types that represent Azure resources. 3 | package types 4 | 5 | import ( 6 | "github.com/google/uuid" 7 | "github.com/riweston/aztx/pkg/errors" 8 | ) 9 | 10 | // Configuration represents the root configuration structure for Azure profiles. 11 | // It contains the installation ID, tenants, and subscriptions associated with the Azure account. 12 | type Configuration struct { 13 | InstallationID uuid.UUID `json:"installationId"` // Unique identifier for the installation 14 | Tenants []Tenant `json:"tenants,omitempty"` // List of available Azure tenants 15 | Subscriptions []Subscription `json:"subscriptions"` // List of available Azure subscriptions 16 | } 17 | 18 | // Validate checks if the configuration has valid data. 19 | // Returns an error if any validation check fails. 20 | func (c *Configuration) Validate() error { 21 | if c == nil { 22 | return errors.ErrEmptyConfiguration 23 | } 24 | if c.InstallationID == uuid.Nil { 25 | return errors.ErrOperation("validating installation ID", errors.ErrEmptyConfiguration) 26 | } 27 | for _, tenant := range c.Tenants { 28 | if err := tenant.Validate(); err != nil { 29 | return errors.ErrOperation("validating tenant", err) 30 | } 31 | } 32 | for _, subscription := range c.Subscriptions { 33 | if err := subscription.Validate(); err != nil { 34 | return errors.ErrOperation("validating subscription", err) 35 | } 36 | } 37 | return nil 38 | } 39 | 40 | // Tenant represents an Azure tenant with its associated metadata. 41 | // It includes both the system-assigned name and an optional custom name for better identification. 42 | type Tenant struct { 43 | ID uuid.UUID `json:"tenantId"` // Unique identifier for the tenant 44 | Name string `json:"name"` // System-assigned tenant name 45 | CustomName string `json:"customName,omitempty"` // User-defined custom name for the tenant 46 | } 47 | 48 | // GetID implements the IDGetter interface for Tenant 49 | func (t Tenant) GetID() uuid.UUID { 50 | return t.ID 51 | } 52 | 53 | // Validate checks if the tenant has valid data. 54 | // It ensures that required fields like ID and at least one name (either Name or CustomName) are set. 55 | // Returns an error if any validation check fails. 56 | func (t *Tenant) Validate() error { 57 | if t == nil { 58 | return errors.ErrEmptyConfiguration 59 | } 60 | if t.ID == uuid.Nil { 61 | return errors.ErrInvalidTenantID 62 | } 63 | if t.Name == "" && t.CustomName == "" { 64 | return errors.ErrEmptyTenantName 65 | } 66 | return nil 67 | } 68 | 69 | // Subscription represents an Azure subscription with its associated metadata and relationships. 70 | // It contains detailed information about the subscription, including its state, user information, 71 | // and tenant relationships. 72 | type Subscription struct { 73 | ID uuid.UUID `json:"id"` // Unique identifier for the subscription 74 | Name string `json:"name"` // Display name of the subscription 75 | State string `json:"state"` // Current state of the subscription 76 | User struct { 77 | Name string `json:"name"` // Name of the user associated with the subscription 78 | Type string `json:"type"` // Type of user account 79 | } `json:"user"` 80 | IsDefault bool `json:"isDefault"` // Whether this is the default subscription 81 | TenantID uuid.UUID `json:"tenantId"` // ID of the tenant this subscription belongs to 82 | HomeTenantID uuid.UUID `json:"homeTenantId"` // ID of the home tenant for this subscription 83 | EnvironmentName string `json:"environmentName"` // Name of the Azure environment 84 | ManagedByTenants []struct { 85 | TenantID uuid.UUID `json:"tenantId"` // ID of the tenant managing this subscription 86 | } `json:"managedByTenants"` 87 | } 88 | 89 | // GetID implements the IDGetter interface for Subscription 90 | func (s Subscription) GetID() uuid.UUID { 91 | return s.ID 92 | } 93 | 94 | // Validate checks if the subscription has valid data. 95 | // It ensures that required fields like ID, Name, and TenantID are properly set. 96 | // Returns an error if any validation check fails. 97 | func (s *Subscription) Validate() error { 98 | if s == nil { 99 | return errors.ErrEmptyConfiguration 100 | } 101 | if s.ID == uuid.Nil { 102 | return errors.ErrInvalidSubscriptionID 103 | } 104 | if s.Name == "" { 105 | return errors.ErrOperation("validating subscription name", errors.ErrEmptyConfiguration) 106 | } 107 | if s.TenantID == uuid.Nil { 108 | return errors.ErrInvalidTenantID 109 | } 110 | if s.State == "" { 111 | return errors.ErrOperation("validating subscription state", errors.ErrEmptyConfiguration) 112 | } 113 | if s.User.Name == "" || s.User.Type == "" { 114 | return errors.ErrOperation("validating subscription user", errors.ErrEmptyConfiguration) 115 | } 116 | return nil 117 | } 118 | 119 | // User represents an Azure user 120 | type User struct { 121 | Name string `json:"name"` 122 | } 123 | -------------------------------------------------------------------------------- /pkg/errors/errors.go: -------------------------------------------------------------------------------- 1 | // Package errors provides a centralized error handling system for the aztx application. 2 | // It defines custom error types and error wrapping functions to provide consistent 3 | // error handling and reporting across the application. 4 | package errors 5 | 6 | import ( 7 | "errors" 8 | "fmt" 9 | ) 10 | 11 | var ( 12 | // Storage related errors 13 | 14 | // ErrFileDoesNotExist is returned when attempting to access a non-existent file 15 | ErrFileDoesNotExist = errors.New("file does not exist") 16 | // ErrFetchingHomePath is returned when unable to determine the user's home directory 17 | ErrFetchingHomePath = errors.New("could not fetch home directory") 18 | // ErrPathIsEmpty is returned when a required file path is empty 19 | ErrPathIsEmpty = errors.New("path is empty") 20 | 21 | // Configuration related errors 22 | 23 | // ErrReadingConfiguration wraps errors that occur while reading configuration files 24 | ErrReadingConfiguration = func(err error) error { 25 | return fmt.Errorf("error reading configuration: %w", err) 26 | } 27 | // ErrWritingConfiguration wraps errors that occur while writing configuration files 28 | ErrWritingConfiguration = func(err error) error { 29 | return fmt.Errorf("error writing configuration: %w", err) 30 | } 31 | 32 | // Context related errors 33 | 34 | // ErrNoPreviousContext is returned when attempting to switch to a previous context that doesn't exist 35 | ErrNoPreviousContext = errors.New("no previous context, check ~/.aztx.yml is present and has content") 36 | 37 | // Subscription related errors 38 | 39 | // ErrSubscriptionNotFound is returned when a requested subscription cannot be found 40 | ErrSubscriptionNotFound = errors.New("subscription not found") 41 | 42 | // File operation errors 43 | 44 | // ErrFileOperation wraps errors that occur during file operations with context about the operation 45 | ErrFileOperation = func(op string, err error) error { 46 | return fmt.Errorf("error %s file: %w", op, err) 47 | } 48 | 49 | // Generic operation errors 50 | 51 | // ErrOperation wraps generic operation errors with context about the operation 52 | ErrOperation = func(op string, err error) error { 53 | return fmt.Errorf("error during %s: %w", op, err) 54 | } 55 | 56 | // ErrMarshallingJSON wraps errors that occur during JSON marshalling 57 | ErrMarshallingJSON = func(err error) error { 58 | return fmt.Errorf("error marshalling JSON: %w", err) 59 | } 60 | 61 | // ErrUnmarshallingJSON wraps errors that occur during JSON unmarshalling 62 | ErrUnmarshallingJSON = func(err error) error { 63 | return fmt.Errorf("error unmarshalling JSON: %w", err) 64 | } 65 | 66 | // ErrReadingFile wraps errors that occur while reading files 67 | ErrReadingFile = func(err error) error { 68 | return fmt.Errorf("error reading file: %w", err) 69 | } 70 | 71 | // ErrWritingFile wraps errors that occur while writing files 72 | ErrWritingFile = func(err error) error { 73 | return fmt.Errorf("error writing file: %w", err) 74 | } 75 | 76 | // ErrSelectingSubscription wraps errors that occur during subscription selection 77 | ErrSelectingSubscription = func(err error) error { 78 | return fmt.Errorf("error selecting subscription: %w", err) 79 | } 80 | 81 | // ErrSettingPreviousContext wraps errors that occur while setting the previous context 82 | ErrSettingPreviousContext = func(err error) error { 83 | return fmt.Errorf("error setting previous context: %w", err) 84 | } 85 | 86 | // Context validation errors 87 | 88 | // ErrInvalidContext is returned when a context is missing required fields 89 | ErrInvalidContext = errors.New("invalid context: missing required fields") 90 | // ErrInvalidSubscriptionID is returned when a subscription ID has an invalid format 91 | ErrInvalidSubscriptionID = errors.New("invalid subscription ID format") 92 | 93 | // State related errors 94 | 95 | // ErrNoDefaultSubscription is returned when no default subscription is configured 96 | ErrNoDefaultSubscription = errors.New("no default subscription found in configuration") 97 | 98 | // Validation errors 99 | 100 | // ErrEmptyConfiguration is returned when the configuration is empty or nil 101 | ErrEmptyConfiguration = errors.New("configuration is empty or nil") 102 | // ErrInvalidTenantID is returned when a tenant ID has an invalid format 103 | ErrInvalidTenantID = errors.New("invalid tenant ID format") 104 | // ErrEmptyTenantName is returned when a tenant name is empty 105 | ErrEmptyTenantName = errors.New("tenant name cannot be empty") 106 | 107 | // Tenant operation errors 108 | 109 | // ErrTenantOperation wraps errors that occur during tenant operations 110 | ErrTenantOperation = func(op string, err error) error { 111 | return fmt.Errorf("error during tenant operation %s: %w", op, err) 112 | } 113 | 114 | // ErrTenantNotFound is returned when a tenant is not found 115 | ErrTenantNotFound = errors.New("tenant not found") 116 | ) 117 | 118 | // WrapError is a helper function that wraps an error with operation context. 119 | // It takes an operation name and an error, and returns a new error with additional context. 120 | func WrapError(op string, err error) error { 121 | return fmt.Errorf("operation %s failed: %w", op, err) 122 | } 123 | -------------------------------------------------------------------------------- /pkg/storage/file_adapter_test.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/google/uuid" 11 | pkgerrors "github.com/riweston/aztx/pkg/errors" 12 | "github.com/riweston/aztx/pkg/types" 13 | "github.com/stretchr/testify/assert" 14 | "github.com/stretchr/testify/require" 15 | ) 16 | 17 | func TestFileAdapter_FetchDefaultPath(t *testing.T) { 18 | tests := []struct { 19 | name string 20 | defaultFile string 21 | wantErrType error 22 | wantPathSuffix string 23 | }{ 24 | { 25 | name: "valid default file", 26 | defaultFile: "/test.json", 27 | wantErrType: nil, 28 | wantPathSuffix: "/test.json", 29 | }, 30 | } 31 | 32 | for _, tt := range tests { 33 | t.Run(tt.name, func(t *testing.T) { 34 | fa := &FileAdapter{} 35 | err := fa.FetchDefaultPath(tt.defaultFile) 36 | if tt.wantErrType != nil { 37 | assert.ErrorIs(t, err, tt.wantErrType) 38 | } else { 39 | assert.NoError(t, err) 40 | home, _ := os.UserHomeDir() 41 | assert.Equal(t, home+tt.wantPathSuffix, fa.Path) 42 | } 43 | }) 44 | } 45 | } 46 | 47 | func TestFileAdapter_Read(t *testing.T) { 48 | // Create a temporary directory for test files 49 | tmpDir, err := os.MkdirTemp("", "file_adapter_test") 50 | require.NoError(t, err) 51 | defer os.RemoveAll(tmpDir) 52 | 53 | // Create a test file 54 | testContent := []byte(`{"test": "data"}`) 55 | testFile := filepath.Join(tmpDir, "test.json") 56 | err = os.WriteFile(testFile, testContent, 0644) 57 | require.NoError(t, err) 58 | 59 | tests := []struct { 60 | name string 61 | path string 62 | want []byte 63 | wantErr error 64 | setup func() error 65 | cleanup func() 66 | }{ 67 | { 68 | name: "empty path returns error", 69 | path: "", 70 | want: nil, 71 | wantErr: pkgerrors.ErrPathIsEmpty, 72 | }, 73 | { 74 | name: "non-existent file returns error", 75 | path: filepath.Join(tmpDir, "nonexistent.json"), 76 | want: nil, 77 | wantErr: pkgerrors.ErrFileDoesNotExist, 78 | }, 79 | { 80 | name: "existing file returns content", 81 | path: testFile, 82 | want: testContent, 83 | wantErr: nil, 84 | }, 85 | } 86 | 87 | for _, tt := range tests { 88 | t.Run(tt.name, func(t *testing.T) { 89 | if tt.setup != nil { 90 | err := tt.setup() 91 | require.NoError(t, err) 92 | } 93 | 94 | fa := &FileAdapter{Path: tt.path} 95 | got, err := fa.Read() 96 | 97 | if tt.wantErr != nil { 98 | assert.ErrorIs(t, err, tt.wantErr) 99 | assert.Nil(t, got) 100 | } else { 101 | assert.NoError(t, err) 102 | assert.Equal(t, tt.want, got) 103 | } 104 | 105 | if tt.cleanup != nil { 106 | tt.cleanup() 107 | } 108 | }) 109 | } 110 | } 111 | 112 | func TestFileAdapter_ReadConfig(t *testing.T) { 113 | // Create a temporary directory for test files 114 | tmpDir, err := os.MkdirTemp("", "file_adapter_test") 115 | require.NoError(t, err) 116 | defer os.RemoveAll(tmpDir) 117 | 118 | testID := uuid.MustParse("a1a2a3a4-b1b2-c1c2-d1d2-d3d4d5d6d7d8") 119 | validConfig := &types.Configuration{ 120 | InstallationID: testID, 121 | Tenants: []types.Tenant{}, 122 | Subscriptions: []types.Subscription{}, 123 | } 124 | validConfigJSON := `{"installationId":"a1a2a3a4-b1b2-c1c2-d1d2-d3d4d5d6d7d8","subscriptions":[]}` 125 | invalidConfigJSON := `{"installationId": invalid_json` 126 | 127 | // Create test files 128 | validFile := filepath.Join(tmpDir, "valid.json") 129 | err = os.WriteFile(validFile, []byte(validConfigJSON), 0644) 130 | require.NoError(t, err) 131 | 132 | invalidFile := filepath.Join(tmpDir, "invalid.json") 133 | err = os.WriteFile(invalidFile, []byte(invalidConfigJSON), 0644) 134 | require.NoError(t, err) 135 | 136 | tests := []struct { 137 | name string 138 | path string 139 | want *types.Configuration 140 | wantErr error 141 | }{ 142 | { 143 | name: "empty path returns error", 144 | path: "", 145 | want: nil, 146 | wantErr: pkgerrors.ErrPathIsEmpty, 147 | }, 148 | { 149 | name: "non-existent file returns error", 150 | path: filepath.Join(tmpDir, "nonexistent.json"), 151 | want: nil, 152 | wantErr: pkgerrors.ErrFileDoesNotExist, 153 | }, 154 | { 155 | name: "invalid JSON returns error", 156 | path: invalidFile, 157 | want: nil, 158 | wantErr: pkgerrors.ErrFileOperation("unmarshaling", errors.New("invalid character 'i' looking for beginning of value")), 159 | }, 160 | { 161 | name: "valid config file returns configuration", 162 | path: validFile, 163 | want: validConfig, 164 | wantErr: nil, 165 | }, 166 | } 167 | 168 | for _, tt := range tests { 169 | t.Run(tt.name, func(t *testing.T) { 170 | fa := &FileAdapter{Path: tt.path} 171 | got, err := fa.ReadConfig() 172 | 173 | if tt.wantErr != nil { 174 | if strings.Contains(tt.wantErr.Error(), "error unmarshaling file") { 175 | // For JSON unmarshalling errors, just check that the error message contains the expected prefix 176 | assert.Contains(t, err.Error(), "error unmarshaling file") 177 | } else { 178 | assert.ErrorIs(t, err, tt.wantErr) 179 | } 180 | assert.Nil(t, got) 181 | } else { 182 | assert.NoError(t, err) 183 | // Use ElementsMatch for slice comparisons to handle nil vs empty slice 184 | assert.Equal(t, tt.want.InstallationID, got.InstallationID) 185 | assert.ElementsMatch(t, tt.want.Tenants, got.Tenants) 186 | assert.ElementsMatch(t, tt.want.Subscriptions, got.Subscriptions) 187 | } 188 | }) 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2024 Richard Weston 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | // Package cmd provides the command-line interface for the aztx application. 23 | // It implements the core functionality for switching between Azure tenants and subscriptions 24 | // using a fuzzy finder interface. 25 | package cmd 26 | 27 | import ( 28 | "errors" 29 | "os" 30 | "strings" 31 | 32 | "github.com/ktr0731/go-fuzzyfinder" 33 | pkgerrors "github.com/riweston/aztx/pkg/errors" 34 | "github.com/riweston/aztx/pkg/profile" 35 | "github.com/riweston/aztx/pkg/state" 36 | "github.com/riweston/aztx/pkg/storage" 37 | "github.com/riweston/aztx/pkg/subscription" 38 | "github.com/riweston/aztx/pkg/tenant" 39 | "github.com/riweston/aztx/pkg/types" 40 | 41 | "github.com/spf13/cobra" 42 | "github.com/spf13/viper" 43 | ) 44 | 45 | // rootCmd represents the base command when called without any subcommands 46 | var rootCmd = &cobra.Command{ 47 | Use: "aztx", 48 | Short: "Azure Tenant Context Switcher", 49 | Long: `aztx is a command line tool that helps you switch between Azure tenants and subscriptions. 50 | It provides a fuzzy finder interface to select subscriptions and remembers your last context.`, 51 | Args: cobra.MaximumNArgs(1), 52 | RunE: func(cmd *cobra.Command, args []string) error { 53 | stateManager := state.NewViperStateManager(viper.GetViper()) 54 | storage := storage.FileAdapter{} 55 | if err := storage.FetchDefaultPath("/.azure/azureProfile.json"); err != nil { 56 | return pkgerrors.ErrFileOperation("fetching default profile path", err) 57 | } 58 | 59 | logger := profile.NewLogger(viper.GetString("log-level")) 60 | cfg, err := storage.ReadConfig() 61 | if err != nil { 62 | return pkgerrors.ErrReadingConfiguration(err) 63 | } 64 | 65 | if len(args) > 0 && args[0] == "-" { 66 | adapter := profile.NewConfigurationAdapter(&storage, logger) 67 | if err := adapter.SetPreviousContext(stateManager); err != nil { 68 | return pkgerrors.ErrSettingPreviousContext(err) 69 | } 70 | return nil 71 | } 72 | 73 | // Check if tenant selection is requested 74 | if viper.GetBool("by-tenant") { 75 | tenantManager := tenant.Manager{BaseManager: types.BaseManager{Configuration: cfg}} 76 | selectedTenant, err := tenantManager.FindTenantIndex() 77 | if err != nil { 78 | if errors.Is(err, fuzzyfinder.ErrAbort) { 79 | return nil 80 | } 81 | return pkgerrors.ErrTenantOperation("selecting tenant", err) 82 | } 83 | 84 | subManager := subscription.Manager{BaseManager: types.BaseManager{Configuration: cfg}} 85 | sub, err := subManager.FindSubscriptionIndexByTenant(selectedTenant.ID) 86 | if err != nil { 87 | if errors.Is(err, fuzzyfinder.ErrAbort) { 88 | return nil 89 | } 90 | return pkgerrors.ErrSelectingSubscription(err) 91 | } 92 | 93 | adapter := profile.NewConfigurationAdapter(&storage, logger) 94 | if err := adapter.SetContext(sub.ID); err != nil { 95 | return pkgerrors.ErrOperation("setting context", err) 96 | } 97 | return nil 98 | } 99 | 100 | // Default subscription selection 101 | adapter := profile.NewConfigurationAdapter(&storage, logger) 102 | sub, err := adapter.SelectWithFinder() 103 | if err != nil { 104 | if errors.Is(err, fuzzyfinder.ErrAbort) { 105 | return nil 106 | } 107 | return pkgerrors.ErrSelectingSubscription(err) 108 | } 109 | 110 | if err := adapter.SetContext(sub.ID); err != nil { 111 | return pkgerrors.ErrOperation("setting context", err) 112 | } 113 | 114 | return nil 115 | }, 116 | } 117 | 118 | // Execute adds all child commands to the root command and sets flags appropriately. 119 | // It is called by main.main() and only needs to happen once to the rootCmd. 120 | // Returns an error if the command execution fails. 121 | func Execute() error { 122 | return rootCmd.Execute() 123 | } 124 | 125 | // init initializes the command configuration by setting up flags and binding them to viper. 126 | // It is automatically called by cobra during command initialization. 127 | func init() { 128 | cobra.OnInitialize(initConfig) 129 | rootCmd.PersistentFlags().String("log-level", "info", "Set log level (debug, info, warn, error)") 130 | rootCmd.Flags().Bool("by-tenant", false, "Select tenant before choosing subscription") 131 | 132 | // Bind flags to viper and check for errors 133 | if err := viper.BindPFlag("log-level", rootCmd.PersistentFlags().Lookup("log-level")); err != nil { 134 | logger := profile.NewLogger("error") 135 | logger.Error("Failed to bind log-level flag: %v", err) 136 | os.Exit(1) 137 | } 138 | if err := viper.BindPFlag("by-tenant", rootCmd.Flags().Lookup("by-tenant")); err != nil { 139 | logger := profile.NewLogger("error") 140 | logger.Error("Failed to bind by-tenant flag: %v", err) 141 | os.Exit(1) 142 | } 143 | } 144 | 145 | // initConfig reads in config file and ENV variables if set. 146 | // It looks for a .aztx.yml file in the user's home directory and creates one if it doesn't exist. 147 | // The function will exit with status code 1 if there are any errors accessing the home directory 148 | // or handling the configuration file. 149 | func initConfig() { 150 | home, err := os.UserHomeDir() 151 | if err != nil { 152 | logger := profile.NewLogger("error") 153 | logger.Error("Failed to get home directory: %v", err) 154 | os.Exit(1) 155 | } 156 | 157 | viper.AddConfigPath(home) 158 | viper.SetConfigType("yml") 159 | viper.SetConfigName(".aztx") 160 | viper.SetEnvPrefix("AZTX") 161 | viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) 162 | viper.AutomaticEnv() 163 | 164 | // Create config if it doesn't exist 165 | if err := viper.ReadInConfig(); err != nil { 166 | if _, ok := err.(viper.ConfigFileNotFoundError); ok { 167 | if err := viper.SafeWriteConfigAs(home + "/.aztx.yml"); err != nil { 168 | logger := profile.NewLogger("error") 169 | logger.Error("Failed to write config: %v", err) 170 | os.Exit(1) 171 | } 172 | } else { 173 | logger := profile.NewLogger("error") 174 | logger.Error("Failed to read config: %v", err) 175 | os.Exit(1) 176 | } 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /pkg/profile/config.go: -------------------------------------------------------------------------------- 1 | package profile 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "time" 7 | 8 | "github.com/google/uuid" 9 | "github.com/ktr0731/go-fuzzyfinder" 10 | pkgerrors "github.com/riweston/aztx/pkg/errors" 11 | "github.com/riweston/aztx/pkg/state" 12 | "github.com/riweston/aztx/pkg/subscription" 13 | "github.com/riweston/aztx/pkg/tenant" 14 | "github.com/riweston/aztx/pkg/types" 15 | ) 16 | 17 | type ConfigurationAdapter struct { 18 | storage StorageAdapter 19 | logger Logger 20 | } 21 | 22 | func NewConfigurationAdapter(storage StorageAdapter, logger Logger) *ConfigurationAdapter { 23 | return &ConfigurationAdapter{ 24 | storage: storage, 25 | logger: logger, 26 | } 27 | } 28 | 29 | func (c *ConfigurationAdapter) SelectWithFinder() (*types.Subscription, error) { 30 | if c.storage == nil { 31 | c.logger.Error("storage adapter is nil") 32 | return nil, pkgerrors.ErrEmptyConfiguration 33 | } 34 | 35 | c.logger.Debug("reading azure profile configuration") 36 | config, err := c.storage.ReadConfig() 37 | if err != nil { 38 | c.logger.Error("failed to read configuration: %v", err) 39 | return nil, pkgerrors.WrapError("reading configuration", err) 40 | } 41 | 42 | if len(config.Subscriptions) == 0 { 43 | c.logger.Warn("no subscriptions found in configuration") 44 | return nil, pkgerrors.ErrEmptyConfiguration 45 | } 46 | 47 | c.logger.Debug("initiating subscription selection with fuzzy finder") 48 | subManager := subscription.Manager{BaseManager: types.BaseManager{Configuration: config}} 49 | idx, err := subManager.FindSubscriptionIndex() 50 | if err != nil { 51 | if errors.Is(err, fuzzyfinder.ErrAbort) { 52 | return nil, err 53 | } 54 | c.logger.Error("failed to get subscription selection: %v", err) 55 | return nil, pkgerrors.WrapError("finding subscription", err) 56 | } 57 | 58 | if idx < 0 || idx >= len(config.Subscriptions) { 59 | c.logger.Error("selected subscription index %d is out of bounds", idx) 60 | return nil, pkgerrors.ErrSubscriptionNotFound 61 | } 62 | 63 | selected := &config.Subscriptions[idx] 64 | return selected, nil 65 | } 66 | 67 | func (c *ConfigurationAdapter) SetContext(subscriptionID uuid.UUID) error { 68 | if subscriptionID == uuid.Nil { 69 | c.logger.Error("invalid subscription ID provided") 70 | return pkgerrors.ErrInvalidSubscriptionID 71 | } 72 | 73 | c.logger.Debug("reading configuration to update context") 74 | config, err := c.storage.ReadConfig() 75 | if err != nil { 76 | c.logger.Error("failed to read configuration: %v", err) 77 | return pkgerrors.WrapError("reading configuration", err) 78 | } 79 | 80 | // First verify the target subscription exists 81 | var targetIndex = -1 82 | for i, sub := range config.Subscriptions { 83 | if sub.ID == subscriptionID { 84 | targetIndex = i 85 | break 86 | } 87 | } 88 | 89 | if targetIndex == -1 { 90 | c.logger.Error("subscription %s not found in configuration", subscriptionID) 91 | return pkgerrors.ErrSubscriptionNotFound 92 | } 93 | 94 | // Now that we know the target exists, safely update the default flags 95 | for i := range config.Subscriptions { 96 | if config.Subscriptions[i].IsDefault { 97 | c.logger.Debug("clearing default from subscription: %s", config.Subscriptions[i].Name) 98 | config.Subscriptions[i].IsDefault = false 99 | } 100 | } 101 | 102 | c.logger.Debug("setting new default subscription: %s", config.Subscriptions[targetIndex].Name) 103 | config.Subscriptions[targetIndex].IsDefault = true 104 | 105 | c.logger.Debug("writing updated configuration") 106 | if err := c.storage.WriteConfig(config); err != nil { 107 | c.logger.Error("failed to write configuration: %v", err) 108 | return pkgerrors.WrapError("writing configuration", err) 109 | } 110 | 111 | c.logger.Success("switched context to: %s (%s)", config.Subscriptions[targetIndex].Name, subscriptionID) 112 | return nil 113 | } 114 | 115 | func (c *ConfigurationAdapter) SetPreviousContext(state state.StateManager) error { 116 | if state == nil { 117 | c.logger.Error("state manager is nil") 118 | return pkgerrors.ErrInvalidContext 119 | } 120 | 121 | lastId, lastName := state.GetLastContext() 122 | if lastId == "" || lastName == "" { 123 | c.logger.Warn("no previous context found") 124 | return pkgerrors.ErrNoPreviousContext 125 | } 126 | 127 | c.logger.Debug("reading configuration to switch to previous context") 128 | config, err := c.storage.ReadConfig() 129 | if err != nil { 130 | c.logger.Error("failed to read configuration: %v", err) 131 | return pkgerrors.WrapError("reading configuration", err) 132 | } 133 | 134 | var currentDefault *types.Subscription 135 | for _, sub := range config.Subscriptions { 136 | if sub.IsDefault { 137 | currentDefault = &sub 138 | break 139 | } 140 | } 141 | 142 | if currentDefault == nil { 143 | c.logger.Error("no default subscription found in configuration") 144 | return pkgerrors.ErrNoDefaultSubscription 145 | } 146 | 147 | c.logger.Debug("saving current context: %s", currentDefault.Name) 148 | if err := state.SetLastContext(currentDefault.ID.String(), currentDefault.Name); err != nil { 149 | c.logger.Error("failed to save current context: %v", err) 150 | return pkgerrors.WrapError("saving last context", err) 151 | } 152 | 153 | id, err := uuid.Parse(lastId) 154 | if err != nil { 155 | c.logger.Error("failed to parse previous subscription ID: %v", err) 156 | return pkgerrors.WrapError("parsing subscription ID", err) 157 | } 158 | 159 | c.logger.Debug("switching to previous context: %s", lastName) 160 | return c.SetContext(id) 161 | } 162 | 163 | func (c *ConfigurationAdapter) SaveTenant(id uuid.UUID, name string) error { 164 | if id == uuid.Nil { 165 | return pkgerrors.ErrInvalidTenantID 166 | } 167 | 168 | if name == "" { 169 | return pkgerrors.ErrEmptyTenantName 170 | } 171 | 172 | config, err := c.storage.ReadConfig() 173 | if err != nil { 174 | return pkgerrors.WrapError("reading configuration", err) 175 | } 176 | 177 | tenantManager := tenant.Manager{BaseManager: types.BaseManager{Configuration: config}} 178 | if err := tenantManager.SaveTenantName(id, name); err != nil { 179 | return pkgerrors.WrapError("saving tenant name", err) 180 | } 181 | 182 | if err := c.storage.WriteConfig(config); err != nil { 183 | return pkgerrors.WrapError("writing configuration", err) 184 | } 185 | 186 | return nil 187 | } 188 | 189 | // Add context to key operations 190 | func (c *ConfigurationAdapter) SelectWithFinderContext(ctx context.Context) (*types.Subscription, error) { 191 | select { 192 | case <-ctx.Done(): 193 | return nil, ctx.Err() 194 | default: 195 | return c.SelectWithFinder() 196 | } 197 | } 198 | 199 | func (c *ConfigurationAdapter) SetContextWithTimeout(subscriptionID uuid.UUID, timeout time.Duration) error { 200 | ctx, cancel := context.WithTimeout(context.Background(), timeout) 201 | defer cancel() 202 | 203 | done := make(chan error, 1) 204 | go func() { 205 | done <- c.SetContext(subscriptionID) 206 | }() 207 | 208 | select { 209 | case err := <-done: 210 | return err 211 | case <-ctx.Done(): 212 | return ctx.Err() 213 | } 214 | } 215 | 216 | // GetTenantManager returns a tenant manager instance 217 | func (c *ConfigurationAdapter) GetTenantManager() (*tenant.Manager, error) { 218 | config, err := c.storage.ReadConfig() 219 | if err != nil { 220 | c.logger.Error("failed to read configuration: %v", err) 221 | return nil, pkgerrors.WrapError("reading configuration", err) 222 | } 223 | return &tenant.Manager{BaseManager: types.BaseManager{Configuration: config}}, nil 224 | } 225 | 226 | // SaveTenantName saves a custom name for a tenant 227 | func (c *ConfigurationAdapter) SaveTenantName(id uuid.UUID, name string) error { 228 | // Read the latest configuration 229 | config, err := c.storage.ReadConfig() 230 | if err != nil { 231 | c.logger.Error("failed to read configuration: %v", err) 232 | return pkgerrors.WrapError("reading configuration", err) 233 | } 234 | 235 | // Create tenant manager with the latest configuration 236 | tm := tenant.Manager{BaseManager: types.BaseManager{Configuration: config}} 237 | if err := tm.SaveTenantName(id, name); err != nil { 238 | c.logger.Error("failed to save tenant name: %v", err) 239 | return pkgerrors.WrapError("saving tenant name", err) 240 | } 241 | 242 | // Write the updated configuration back 243 | if err := c.storage.WriteConfig(config); err != nil { 244 | c.logger.Error("failed to write configuration: %v", err) 245 | return pkgerrors.WrapError("writing configuration", err) 246 | } 247 | 248 | c.logger.Success("saved custom name '%s' for tenant %s", name, id) 249 | return nil 250 | } 251 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 2 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 3 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= 4 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= 5 | github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 6 | github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 7 | github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= 8 | github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= 9 | github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= 10 | github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= 11 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= 12 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 13 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 14 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 15 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 16 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 17 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 18 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 19 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 20 | github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= 21 | github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 22 | github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= 23 | github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= 24 | github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= 25 | github.com/gdamore/tcell/v2 v2.6.0 h1:OKbluoP9VYmJwZwq/iLb4BxwKcwGthaa1YNBJIyCySg= 26 | github.com/gdamore/tcell/v2 v2.6.0/go.mod h1:be9omFATkdr0D9qewWW3d+MEvl5dha+Etb5y65J2H8Y= 27 | github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= 28 | github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= 29 | github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= 30 | github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 31 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 32 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 33 | github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 34 | github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 35 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 36 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 37 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 38 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 39 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 40 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 41 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 42 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 43 | github.com/ktr0731/go-ansisgr v0.1.0 h1:fbuupput8739hQbEmZn1cEKjqQFwtCCZNznnF6ANo5w= 44 | github.com/ktr0731/go-ansisgr v0.1.0/go.mod h1:G9lxwgBwH0iey0Dw5YQd7n6PmQTwTuTM/X5Sgm/UrzE= 45 | github.com/ktr0731/go-fuzzyfinder v0.9.0 h1:JV8S118RABzRl3Lh/RsPhXReJWc2q0rbuipzXQH7L4c= 46 | github.com/ktr0731/go-fuzzyfinder v0.9.0/go.mod h1:uybx+5PZFCgMCSDHJDQ9M3nNKx/vccPmGffsXPn2ad8= 47 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 48 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 49 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 50 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 51 | github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 52 | github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 53 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 54 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 55 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 56 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 57 | github.com/nsf/termbox-go v1.1.1 h1:nksUPLCb73Q++DwbYUBEglYBRPZyoXJdrj5L+TkjyZY= 58 | github.com/nsf/termbox-go v1.1.1/go.mod h1:T0cTdVuOwf7pHQNtfhnEbzHbcNyCEcVU4YPpouCbVxo= 59 | github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= 60 | github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 61 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 62 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 63 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 64 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 65 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 66 | github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 67 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 68 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 69 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 70 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 71 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 72 | github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= 73 | github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= 74 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= 75 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= 76 | github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= 77 | github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= 78 | github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= 79 | github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= 80 | github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= 81 | github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= 82 | github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 83 | github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= 84 | github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 85 | github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= 86 | github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= 87 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 88 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 89 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 90 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 91 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 92 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 93 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 94 | go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= 95 | go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 96 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 97 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 98 | golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= 99 | golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= 100 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 101 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 102 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 103 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 104 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 105 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 106 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 107 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 108 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 109 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 110 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 111 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 112 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 113 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 114 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 115 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 116 | golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= 117 | golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 118 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 119 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 120 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 121 | golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= 122 | golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= 123 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 124 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 125 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 126 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 127 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 128 | golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= 129 | golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= 130 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 131 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 132 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 133 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 134 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 135 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 136 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 137 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 138 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 139 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 140 | --------------------------------------------------------------------------------