├── Dockerfile ├── .golangci.yml ├── .gitignore ├── .github └── workflows │ ├── build.yaml │ ├── release.yaml │ └── cla.yaml ├── .goreleaser.yml ├── cmd ├── pre.go ├── autocomplete.go ├── style.go ├── tail.go ├── version.go ├── generate.go ├── queryList.go ├── cluster.go ├── profile.go ├── query.go ├── role.go └── user.go ├── pkg ├── http │ └── http.go ├── model │ ├── status.go │ ├── button │ │ └── button.go │ ├── selection │ │ └── selection.go │ ├── timerange.go │ ├── datetime │ │ └── datetime.go │ ├── tablekeymap.go │ ├── textareakeymap.go │ ├── defaultprofile │ │ └── profile.go │ ├── credential │ │ └── credential.go │ ├── timeinput.go │ └── role │ │ └── role.go ├── config │ └── config.go ├── installer │ ├── model.go │ ├── plans.go │ └── uninstaller.go ├── iterator │ ├── iterator.go │ └── iterator_test.go ├── common │ └── common.go ├── helm │ └── helm.go └── analytics │ └── analytics.go ├── Makefile ├── README.md ├── go.mod └── main.go /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:22.04 2 | RUN apt-get -y update && apt install -y ca-certificates 3 | WORKDIR /app 4 | COPY pb . 5 | ENTRYPOINT [ "./pb" ] 6 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters: 2 | disable-all: true 3 | enable: 4 | - typecheck 5 | - goimports 6 | - misspell 7 | - govet 8 | - revive 9 | - ineffassign 10 | - gomodguard 11 | - gofmt 12 | - unused 13 | 14 | linters-settings: 15 | golint: 16 | min-confidence: 0 17 | 18 | misspell: 19 | locale: US 20 | 21 | issues: 22 | exclude-use-default: false 23 | exclude: 24 | - instead of using struct literal 25 | - should have a package comment 26 | - should have comment or be unexported 27 | - time-naming 28 | - error strings should not be capitalized or end with punctuation or a newline 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | # vendor/ 19 | 20 | # Go workspace file 21 | go.work 22 | 23 | .vscode/ 24 | config.toml 25 | 26 | # build 27 | pb 28 | bin/ 29 | dist/ 30 | 31 | # OS Files 32 | .DS_Store 33 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Go Build and Test 2 | 3 | on: pull_request 4 | 5 | jobs: 6 | build-and-test: 7 | name: Build and Test the Go code 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout the code 11 | uses: actions/checkout@v4 12 | - name: Set up Go 13 | uses: actions/setup-go@v4 14 | with: 15 | go-version: "1.23.0" 16 | - name: Install gofumpt 17 | run: go install mvdan.cc/gofumpt@latest 18 | - name: Run gofumpt 19 | run: gofumpt -l -w . 20 | - name: make verification 21 | run: make verifiers 22 | - name: Build 23 | run: go build -v ./... 24 | - name: Test 25 | run: go test -v ./... 26 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | jobs: 9 | goreleaser: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 16 | 17 | - name: Set up Go 18 | uses: actions/setup-go@v4 19 | with: 20 | go-version: 1.22 21 | 22 | - name: Login to Docker Hub 23 | uses: docker/login-action@v3 24 | with: 25 | username: ${{ secrets.DOCKERHUB_USERNAME }} 26 | password: ${{ secrets.DOCKERHUB_TOKEN }} 27 | 28 | - name: Run GoReleaser 29 | uses: goreleaser/goreleaser-action@v6 30 | with: 31 | distribution: goreleaser # or 'goreleaser-pro' 32 | version: "~> v2" # or 'latest', 'nightly', semver 33 | args: release --parallelism 1 --clean 34 | env: 35 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 36 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | env: 3 | - GO111MODULE=on 4 | - CGO_ENABLED=0 5 | 6 | builds: 7 | - binary: pb 8 | id: pb 9 | main: ./main.go 10 | goos: 11 | - windows 12 | - darwin 13 | - linux 14 | goarch: 15 | - amd64 16 | - arm64 17 | flags: 18 | - -trimpath 19 | - -tags=kqueue 20 | ldflags: 21 | - -s -w -X main.Version=v{{ .Version }} -X main.Commit={{ .ShortCommit }} 22 | 23 | archives: 24 | - format: tar.gz 25 | files: 26 | - README.md 27 | - LICENSE 28 | 29 | dockers: 30 | - id: pb 31 | goos: linux 32 | goarch: amd64 33 | ids: 34 | - pb 35 | image_templates: 36 | - "parseable/pb:{{ .Tag }}" 37 | - "parseable/pb:latest" 38 | skip_push: false 39 | dockerfile: Dockerfile 40 | use: docker 41 | build_flag_templates: 42 | - "--pull" 43 | - "--label=org.opencontainers.image.created={{.Date}}" 44 | - "--label=org.opencontainers.image.title=Parseable" 45 | - "--label=maintainer=Parseable Team " 46 | - "--label=org.opencontainers.image.vendor=Cloudnatively Pvt Ltd" 47 | - "--label=org.opencontainers.image.licenses=AGPL-3.0" 48 | - "--label=org.opencontainers.image.revision={{.FullCommit}}" 49 | - "--label=org.opencontainers.image.version={{.Version}}" 50 | - "--platform=linux/amd64" 51 | -------------------------------------------------------------------------------- /cmd/pre.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Parseable, Inc 2 | // 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package cmd 18 | 19 | import ( 20 | "errors" 21 | "os" 22 | "pb/pkg/config" 23 | 24 | "github.com/spf13/cobra" 25 | ) 26 | 27 | var DefaultProfile config.Profile 28 | 29 | // PreRunDefaultProfile if a profile exists. 30 | // This is required by mostly all commands except profile 31 | func PreRunDefaultProfile(_ *cobra.Command, _ []string) error { 32 | return PreRun() 33 | } 34 | 35 | func PreRun() error { 36 | conf, err := config.ReadConfigFromFile() 37 | if os.IsNotExist(err) { 38 | return errors.New("no config found to run this command. add a profile using pb profile command") 39 | } else if err != nil { 40 | return err 41 | } 42 | 43 | if conf.Profiles == nil || conf.DefaultProfile == "" { 44 | return errors.New("no profile is configured to run this command. please create one using profile command") 45 | } 46 | 47 | DefaultProfile = conf.Profiles[conf.DefaultProfile] 48 | return nil 49 | } 50 | -------------------------------------------------------------------------------- /pkg/http/http.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Parseable, Inc 2 | // 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package cmd 18 | 19 | import ( 20 | "io" 21 | "net/http" 22 | "net/url" 23 | "pb/pkg/config" 24 | "time" 25 | ) 26 | 27 | type HTTPClient struct { 28 | Client http.Client 29 | Profile *config.Profile 30 | } 31 | 32 | func DefaultClient(profile *config.Profile) HTTPClient { 33 | return HTTPClient{ 34 | Client: http.Client{ 35 | Timeout: 60 * time.Second, 36 | }, 37 | Profile: profile, 38 | } 39 | } 40 | 41 | func (client *HTTPClient) baseAPIURL(path string) (x string) { 42 | x, _ = url.JoinPath(client.Profile.URL, "api/v1/", path) 43 | return 44 | } 45 | 46 | func (client *HTTPClient) NewRequest(method string, path string, body io.Reader) (req *http.Request, err error) { 47 | req, err = http.NewRequest(method, client.baseAPIURL(path), body) 48 | if err != nil { 49 | return 50 | } 51 | req.SetBasicAuth(client.Profile.Username, client.Profile.Password) 52 | req.Header.Add("Content-Type", "application/json") 53 | return 54 | } 55 | -------------------------------------------------------------------------------- /cmd/autocomplete.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Parseable, Inc 2 | // 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package cmd 18 | 19 | import ( 20 | "fmt" 21 | "os" 22 | 23 | "github.com/spf13/cobra" 24 | ) 25 | 26 | // AutocompleteCmd represents the autocomplete command 27 | var AutocompleteCmd = &cobra.Command{ 28 | Use: "autocomplete [bash|zsh|powershell]", 29 | Short: "Generate autocomplete script", 30 | Long: `Generate autocomplete script for bash, zsh, or powershell`, 31 | Args: cobra.ExactArgs(1), 32 | RunE: func(cmd *cobra.Command, args []string) error { 33 | var err error 34 | switch args[0] { 35 | case "bash": 36 | err = cmd.Root().GenBashCompletion(os.Stdout) 37 | case "zsh": 38 | err = cmd.Root().GenZshCompletion(os.Stdout) 39 | case "powershell": 40 | err = cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout) 41 | default: 42 | err = fmt.Errorf("unsupported shell type: %s. Only bash, zsh, and powershell are supported", args[0]) 43 | } 44 | 45 | if err != nil { 46 | return fmt.Errorf("error generating autocomplete script: %w", err) 47 | } 48 | 49 | return nil 50 | }, 51 | } 52 | -------------------------------------------------------------------------------- /cmd/style.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Parseable, Inc 2 | // 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package cmd 18 | 19 | import ( 20 | "github.com/charmbracelet/lipgloss" 21 | ) 22 | 23 | // styling for cli outputs 24 | var ( 25 | FocusPrimary = lipgloss.AdaptiveColor{Light: "16", Dark: "226"} 26 | FocusSecondary = lipgloss.AdaptiveColor{Light: "18", Dark: "220"} 27 | 28 | StandardPrimary = lipgloss.AdaptiveColor{Light: "235", Dark: "255"} 29 | StandardSecondary = lipgloss.AdaptiveColor{Light: "238", Dark: "254"} 30 | StandardStyle = lipgloss.NewStyle().Foreground(StandardPrimary) 31 | StandardStyleBold = lipgloss.NewStyle().Foreground(StandardPrimary).Bold(true) 32 | StandardStyleAlt = lipgloss.NewStyle().Foreground(StandardSecondary) 33 | SelectedStyle = lipgloss.NewStyle().Foreground(FocusPrimary).Bold(true) 34 | SelectedStyleAlt = lipgloss.NewStyle().Foreground(FocusSecondary) 35 | SelectedItemOuter = lipgloss.NewStyle().BorderStyle(lipgloss.NormalBorder()).BorderLeft(true).PaddingLeft(1).BorderForeground(FocusPrimary) 36 | ItemOuter = lipgloss.NewStyle().PaddingLeft(1) 37 | 38 | StyleBold = lipgloss.NewStyle().Bold(true) 39 | ) 40 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PWD := $(shell pwd) 2 | GOPATH := $(shell go env GOPATH) 3 | VERSION ?= $(shell git describe --tags) 4 | TAG ?= "parseablehq/pb:$(VERSION)" 5 | LDFLAGS := $(shell go run buildscripts/gen-ldflags.go $(VERSION)) 6 | 7 | GOARCH := $(shell go env GOARCH) 8 | GOOS := $(shell go env GOOS) 9 | GO111MODULE=on 10 | 11 | all: build 12 | 13 | checks: 14 | @echo "Checking dependencies" 15 | @(env bash $(PWD)/buildscripts/checkdeps.sh) 16 | 17 | getdeps: 18 | @GO111MODULE=on 19 | @mkdir -p ${GOPATH}/bin 20 | @echo "Installing golangci-lint" && curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(GOPATH)/bin 21 | @echo "Installing stringer" && go install -v golang.org/x/tools/cmd/stringer@latest 22 | @echo "Installing staticheck" && go install honnef.co/go/tools/cmd/staticcheck@latest 23 | 24 | crosscompile: 25 | @(env bash $(PWD)/buildscripts/cross-compile.sh) 26 | 27 | verifiers: getdeps vet lint 28 | 29 | docker: build 30 | @docker build -t $(TAG) . -f Dockerfile.dev 31 | 32 | vet: 33 | @echo "Running $@" 34 | @GO111MODULE=on go vet $(PWD)/... 35 | 36 | lint: 37 | @echo "Running $@ check" 38 | @GO111MODULE=on ${GOPATH}/bin/golangci-lint run --timeout=5m --config ./.golangci.yml 39 | @GO111MODULE=on ${GOPATH}/bin/staticcheck -tests=false -checks="all,-ST1000,-ST1003,-ST1016,-ST1020,-ST1021,-ST1022,-ST1023,-ST1005" ./... 40 | 41 | # Builds pb locally. 42 | build: checks 43 | @echo "Building pb binary to './pb'" 44 | @GO111MODULE=on CGO_ENABLED=0 go build -trimpath -tags kqueue --ldflags "$(LDFLAGS)" -o $(PWD)/pb 45 | 46 | # Build pb for all supported platforms. 47 | build-release: verifiers crosscompile 48 | @echo "Built releases for version $(VERSION)" 49 | 50 | # Builds pb and installs it to $GOPATH/bin. 51 | install: build 52 | @echo "Installing pb binary to '$(GOPATH)/bin/pb'" 53 | @mkdir -p $(GOPATH)/bin && cp -f $(PWD)/pb $(GOPATH)/bin/pb 54 | @echo "Installation successful. To learn more, try \"pb --help\"." 55 | 56 | clean: 57 | @echo "Cleaning up all the generated files" 58 | @find . -name '*.test' | xargs rm -fv 59 | @find . -name '*~' | xargs rm -fv 60 | @rm -rvf pb 61 | @rm -rvf build 62 | @rm -rvf release 63 | -------------------------------------------------------------------------------- /pkg/model/status.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Parseable, Inc 2 | // 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package model 18 | 19 | import ( 20 | tea "github.com/charmbracelet/bubbletea" 21 | "github.com/charmbracelet/lipgloss" 22 | ) 23 | 24 | var ( 25 | commonStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#FFFFFF", Dark: "#000000"}) 26 | 27 | titleStyle = commonStyle. 28 | Background(lipgloss.AdaptiveColor{Light: "#134074", Dark: "#FFADAD"}). 29 | Padding(0, 1) 30 | 31 | hostStyle = commonStyle. 32 | Background(lipgloss.AdaptiveColor{Light: "#13315C", Dark: "#FFD6A5"}). 33 | Padding(0, 1) 34 | 35 | infoStyle = commonStyle. 36 | Background(lipgloss.AdaptiveColor{Light: "#212529", Dark: "#CAFFBF"}). 37 | AlignHorizontal(lipgloss.Right) 38 | 39 | errorStyle = commonStyle. 40 | Background(lipgloss.AdaptiveColor{Light: "#5A2A27", Dark: "#D4A373"}). 41 | AlignHorizontal(lipgloss.Right) 42 | ) 43 | 44 | type StatusBar struct { 45 | title string 46 | host string 47 | Info string 48 | Error string 49 | width int 50 | } 51 | 52 | func NewStatusBar(host string, width int) StatusBar { 53 | return StatusBar{ 54 | title: "Parseable", 55 | host: host, 56 | Info: "", 57 | Error: "", 58 | width: width, 59 | } 60 | } 61 | 62 | func (m StatusBar) Init() tea.Cmd { 63 | return nil 64 | } 65 | 66 | func (m StatusBar) Update(_ tea.Msg) (tea.Model, tea.Cmd) { 67 | return m, nil 68 | } 69 | 70 | func (m StatusBar) View() string { 71 | var right string 72 | var rightStyle lipgloss.Style 73 | 74 | if m.Error != "" { 75 | right = m.Error 76 | rightStyle = errorStyle 77 | } else { 78 | right = m.Info 79 | rightStyle = infoStyle 80 | } 81 | 82 | left := lipgloss.JoinHorizontal(lipgloss.Bottom, titleStyle.Render(m.title), hostStyle.Render(m.host)) 83 | 84 | leftWidth := lipgloss.Width(left) 85 | rightWidth := m.width - leftWidth 86 | 87 | right = rightStyle.Width(rightWidth).Render(right) 88 | 89 | return lipgloss.JoinHorizontal(lipgloss.Bottom, left, right) 90 | } 91 | -------------------------------------------------------------------------------- /pkg/model/button/button.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Parseable, Inc 2 | // 3 | // This program is free software: you can redistribute it and/or modify 4 | // it under the terms of the GNU Affero General Public License as published by 5 | // the Free Software Foundation, either version 3 of the License, or 6 | // (at your option) any later version. 7 | // 8 | // This program is distributed in the hope that it will be useful 9 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | // GNU Affero General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU Affero General Public License 14 | // along with this program. If not, see . 15 | 16 | package button 17 | 18 | import ( 19 | "strings" 20 | 21 | tea "github.com/charmbracelet/bubbletea" 22 | "github.com/charmbracelet/lipgloss" 23 | ) 24 | 25 | // Pressed is a flag that is enabled when the button is pressed. 26 | type Pressed bool 27 | 28 | // Model is the model for a button. 29 | type Model struct { 30 | text string 31 | FocusStyle lipgloss.Style 32 | BlurredStyle lipgloss.Style 33 | focus bool 34 | Invalid bool 35 | } 36 | 37 | // New returns a new button model. 38 | func New(text string) Model { 39 | return Model{ 40 | text: text, 41 | FocusStyle: lipgloss.NewStyle(), 42 | BlurredStyle: lipgloss.NewStyle(), 43 | } 44 | } 45 | 46 | // Focus sets the focus flag to true. 47 | func (m *Model) Focus() tea.Cmd { 48 | m.focus = true 49 | return nil 50 | } 51 | 52 | // Blur sets the focus flag to false. 53 | func (m *Model) Blur() { 54 | m.focus = false 55 | } 56 | 57 | // Focused returns true if the button is focused. 58 | func (m *Model) Focused() bool { 59 | return m.focus 60 | } 61 | 62 | // Init initializes the button. 63 | func (m Model) Init() tea.Cmd { 64 | return nil 65 | } 66 | 67 | // Update updates the button. 68 | func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { 69 | if !m.focus { 70 | return m, nil 71 | } 72 | 73 | switch msg := msg.(type) { 74 | case tea.KeyMsg: 75 | switch msg.Type { 76 | case tea.KeyEnter: 77 | if m.Invalid { 78 | return m, nil 79 | } 80 | return m, func() tea.Msg { return Pressed(true) } 81 | default: 82 | return m, nil 83 | } 84 | } 85 | 86 | return m, nil 87 | } 88 | 89 | // View renders the button. 90 | func (m Model) View() string { 91 | var b strings.Builder 92 | var text string 93 | if m.Invalid { 94 | text = "X" 95 | } else { 96 | text = m.text 97 | } 98 | 99 | b.WriteString("[ ") 100 | if m.focus { 101 | text = m.FocusStyle.Render(text) 102 | } else { 103 | text = m.BlurredStyle.Render(text) 104 | } 105 | b.WriteString(text) 106 | b.WriteString(" ]") 107 | 108 | return b.String() 109 | } 110 | -------------------------------------------------------------------------------- /.github/workflows/cla.yaml: -------------------------------------------------------------------------------- 1 | name: "CLA Assistant" 2 | on: 3 | issue_comment: 4 | types: [created] 5 | pull_request_target: 6 | types: [opened,closed,synchronize] 7 | 8 | # explicitly configure permissions, in case your GITHUB_TOKEN workflow permissions are set to read-only in repository settings 9 | permissions: 10 | actions: write 11 | contents: write 12 | pull-requests: write 13 | statuses: write 14 | 15 | jobs: 16 | CLAAssistant: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: "CLA Assistant" 20 | if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target' 21 | uses: contributor-assistant/github-action@v2.3.0 22 | env: 23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} 25 | with: 26 | remote-organization-name: 'parseablehq' # enter the remote organization name where the signatures should be stored (Default is storing the signatures in the same repository) 27 | remote-repository-name: '.github' # enter the remote repository name where the signatures should be stored (Default is storing the signatures in the same repository) 28 | path-to-signatures: 'signatures/version1/cla.json' 29 | path-to-document: 'https://github.com/parseablehq/.github/blob/main/CLA.md' # e.g. a CLA or a DCO document 30 | # branch should not be protected 31 | branch: 'main' 32 | allowlist: dependabot[bot],deepsource-autofix[bot],deepsourcebot 33 | 34 | # the followings are the optional inputs - If the optional inputs are not given, then default values will be taken 35 | #remote-organization-name: enter the remote organization name where the signatures should be stored (Default is storing the signatures in the same repository) 36 | #remote-repository-name: enter the remote repository name where the signatures should be stored (Default is storing the signatures in the same repository) 37 | #create-file-commit-message: 'For example: Creating file for storing CLA Signatures' 38 | #signed-commit-message: 'For example: $contributorName has signed the CLA in $owner/$repo#$pullRequestNo' 39 | #custom-notsigned-prcomment: 'pull request comment with Introductory message to ask new contributors to sign' 40 | #custom-pr-sign-comment: 'The signature to be committed in order to sign the CLA' 41 | #custom-allsigned-prcomment: 'pull request comment when all contributors has signed, defaults to **CLA Assistant Lite bot** All Contributors have signed the CLA.' 42 | #lock-pullrequest-aftermerge: false - if you don't want this bot to automatically lock the pull request after merging (default - true) 43 | #use-dco-flag: true - If you are using DCO instead of CLA 44 | -------------------------------------------------------------------------------- /pkg/model/selection/selection.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Parseable, Inc 2 | // 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package selection 18 | 19 | import ( 20 | tea "github.com/charmbracelet/bubbletea" 21 | "github.com/charmbracelet/lipgloss" 22 | ) 23 | 24 | // Model is the model for the selection component 25 | type Model struct { 26 | items []string 27 | focusIndex int 28 | focus bool 29 | FocusStyle lipgloss.Style 30 | BlurredStyle lipgloss.Style 31 | } 32 | 33 | // Focus focuses the selection component 34 | func (m *Model) Focus() tea.Cmd { 35 | m.focus = true 36 | return nil 37 | } 38 | 39 | // Blur blurs the selection component 40 | func (m *Model) Blur() { 41 | m.focus = false 42 | } 43 | 44 | // Focused returns true if the selection component is focused 45 | func (m *Model) Focused() bool { 46 | return m.focus 47 | } 48 | 49 | // Value returns the value of the selection component 50 | func (m *Model) Value() string { 51 | return m.items[m.focusIndex] 52 | } 53 | 54 | // New creates a new selection component 55 | func New(items []string) Model { 56 | m := Model{ 57 | focusIndex: 0, 58 | focus: false, 59 | items: items, 60 | } 61 | 62 | return m 63 | } 64 | 65 | // Init initializes the selection component 66 | func (m Model) Init() tea.Cmd { 67 | return nil 68 | } 69 | 70 | // Update updates the selection component 71 | func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { 72 | if !m.focus { 73 | return m, nil 74 | } 75 | 76 | switch msg := msg.(type) { 77 | case tea.KeyMsg: 78 | switch msg.Type { 79 | case tea.KeyLeft: 80 | if m.focusIndex > 0 { 81 | m.focusIndex-- 82 | } 83 | case tea.KeyRight: 84 | if m.focusIndex < len(m.items)-1 { 85 | m.focusIndex++ 86 | } 87 | } 88 | } 89 | 90 | return m, nil 91 | } 92 | 93 | // View renders the selection component 94 | func (m Model) View() string { 95 | render := make([]string, len(m.items)) 96 | 97 | for idx, item := range m.items { 98 | if idx == m.focusIndex { 99 | render[idx] = m.FocusStyle.Render(item) 100 | } else { 101 | render[idx] = m.BlurredStyle.Render(item) 102 | } 103 | } 104 | 105 | return lipgloss.JoinHorizontal(lipgloss.Center, render...) 106 | } 107 | -------------------------------------------------------------------------------- /cmd/tail.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Parseable, Inc 2 | // 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package cmd 18 | 19 | import ( 20 | "bytes" 21 | "context" 22 | "encoding/base64" 23 | "encoding/json" 24 | "fmt" 25 | "pb/pkg/analytics" 26 | "pb/pkg/config" 27 | internalHTTP "pb/pkg/http" 28 | 29 | "github.com/apache/arrow/go/v13/arrow/array" 30 | "github.com/apache/arrow/go/v13/arrow/flight" 31 | "github.com/spf13/cobra" 32 | "google.golang.org/grpc" 33 | "google.golang.org/grpc/credentials/insecure" 34 | "google.golang.org/grpc/metadata" 35 | ) 36 | 37 | var TailCmd = &cobra.Command{ 38 | Use: "tail stream-name", 39 | Example: " pb tail backend_logs", 40 | Short: "Stream live events from a log stream", 41 | Args: cobra.ExactArgs(1), 42 | PreRunE: PreRunDefaultProfile, 43 | RunE: func(_ *cobra.Command, args []string) error { 44 | name := args[0] 45 | profile := DefaultProfile 46 | return tail(profile, name) 47 | }, 48 | } 49 | 50 | func tail(profile config.Profile, stream string) error { 51 | payload, _ := json.Marshal(struct { 52 | Stream string `json:"stream"` 53 | }{ 54 | Stream: stream, 55 | }) 56 | 57 | // get grpc url for this request 58 | httpClient := internalHTTP.DefaultClient(&DefaultProfile) 59 | about, err := analytics.FetchAbout(&httpClient) 60 | if err != nil { 61 | return err 62 | } 63 | url := profile.GrpcAddr(fmt.Sprint(about.GRPCPort)) 64 | 65 | client, err := flight.NewClientWithMiddleware(url, nil, nil, grpc.WithTransportCredentials(insecure.NewCredentials())) 66 | if err != nil { 67 | return err 68 | } 69 | 70 | authHeader := basicAuth(profile.Username, profile.Password) 71 | resp, err := client.DoGet(metadata.NewOutgoingContext(context.Background(), metadata.New(map[string]string{"Authorization": "Basic " + authHeader})), &flight.Ticket{ 72 | Ticket: payload, 73 | }) 74 | if err != nil { 75 | return err 76 | } 77 | 78 | records, err := flight.NewRecordReader(resp) 79 | if err != nil { 80 | return err 81 | } 82 | defer records.Release() 83 | 84 | for { 85 | record, err := records.Read() 86 | if err != nil { 87 | return err 88 | } 89 | var buf bytes.Buffer 90 | array.RecordToJSON(record, &buf) 91 | fmt.Println(buf.String()) 92 | } 93 | } 94 | 95 | func basicAuth(username, password string) string { 96 | auth := username + ":" + password 97 | return base64.StdEncoding.EncodeToString([]byte(auth)) 98 | } 99 | -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Parseable, Inc 2 | // 3 | // This program is free software: you can redistribute it and/or modify 4 | // it under the terms of the GNU Affero General Public License as published by 5 | // the Free Software Foundation, either version 3 of the License, or 6 | // (at your option) any later version. 7 | // 8 | // This program is distributed in the hope that it will be useful 9 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | // GNU Affero General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU Affero General Public License 14 | // along with this program. If not, see . 15 | 16 | package cmd 17 | 18 | import ( 19 | "encoding/json" 20 | "fmt" 21 | "pb/pkg/analytics" 22 | internalHTTP "pb/pkg/http" 23 | "time" 24 | 25 | "github.com/spf13/cobra" 26 | ) 27 | 28 | // VersionCmd is the command for printing version information 29 | var VersionCmd = &cobra.Command{ 30 | Use: "version", 31 | Short: "Print version", 32 | Long: "Print version and commit information", 33 | Example: " pb version", 34 | Run: func(cmd *cobra.Command, _ []string) { 35 | if cmd.Annotations == nil { 36 | cmd.Annotations = make(map[string]string) 37 | } 38 | 39 | startTime := time.Now() 40 | defer func() { 41 | // Capture the execution time in annotations 42 | cmd.Annotations["executionTime"] = time.Since(startTime).String() 43 | }() 44 | 45 | err := PrintVersion("1.0.0", "abc123") // Replace with actual version and commit values 46 | if err != nil { 47 | cmd.Annotations["error"] = err.Error() 48 | } 49 | }, 50 | } 51 | 52 | func init() { 53 | VersionCmd.Flags().StringVarP(&outputFormat, "output", "o", "text", "Output format (text|json)") 54 | } 55 | 56 | // PrintVersion prints version information 57 | func PrintVersion(version, commit string) error { 58 | client := internalHTTP.DefaultClient(&DefaultProfile) 59 | 60 | // Fetch server information 61 | if err := PreRun(); err != nil { 62 | return fmt.Errorf("error in PreRun: %w", err) 63 | } 64 | 65 | about, err := analytics.FetchAbout(&client) 66 | if err != nil { 67 | return fmt.Errorf("error fetching server information: %w", err) 68 | } 69 | 70 | // Output as JSON if specified 71 | if outputFormat == "json" { 72 | versionInfo := map[string]interface{}{ 73 | "client": map[string]string{ 74 | "version": version, 75 | "commit": commit, 76 | }, 77 | "server": map[string]string{ 78 | "url": DefaultProfile.URL, 79 | "version": about.Version, 80 | "commit": about.Commit, 81 | }, 82 | } 83 | jsonData, err := json.MarshalIndent(versionInfo, "", " ") 84 | if err != nil { 85 | return fmt.Errorf("error generating JSON output: %w", err) 86 | } 87 | fmt.Println(string(jsonData)) 88 | return nil 89 | } 90 | 91 | // Default: Output as text 92 | fmt.Printf("\n%s \n", StandardStyleAlt.Render("pb version")) 93 | fmt.Printf("- %s %s\n", StandardStyleBold.Render("version: "), version) 94 | fmt.Printf("- %s %s\n\n", StandardStyleBold.Render("commit: "), commit) 95 | 96 | fmt.Printf("%s %s \n", StandardStyleAlt.Render("Connected to"), StandardStyleBold.Render(DefaultProfile.URL)) 97 | fmt.Printf("- %s %s\n", StandardStyleBold.Render("version: "), about.Version) 98 | fmt.Printf("- %s %s\n\n", StandardStyleBold.Render("commit: "), about.Commit) 99 | 100 | return nil 101 | } 102 | -------------------------------------------------------------------------------- /pkg/model/timerange.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Parseable, Inc 2 | // 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package model 18 | 19 | import ( 20 | "fmt" 21 | "io" 22 | "strings" 23 | "time" 24 | 25 | "github.com/charmbracelet/bubbles/list" 26 | tea "github.com/charmbracelet/bubbletea" 27 | "github.com/charmbracelet/lipgloss" 28 | ) 29 | 30 | // Items for time range 31 | const ( 32 | TenMinute = -10 * time.Minute 33 | TwentyMinute = -20 * time.Minute 34 | ThirtyMinute = -30 * time.Minute 35 | OneHour = -1 * time.Hour 36 | ThreeHour = -3 * time.Hour 37 | OneDay = -24 * time.Hour 38 | ThreeDay = -72 * time.Hour 39 | OneWeek = -168 * time.Hour 40 | ) 41 | 42 | var ( 43 | timeDurations = []list.Item{ 44 | timeDurationItem{duration: TenMinute, repr: "10 Minutes"}, 45 | timeDurationItem{duration: TwentyMinute, repr: "20 Minutes"}, 46 | timeDurationItem{duration: ThirtyMinute, repr: "30 Minutes"}, 47 | timeDurationItem{duration: OneHour, repr: "1 Hour"}, 48 | timeDurationItem{duration: ThreeHour, repr: "3 Hours"}, 49 | timeDurationItem{duration: OneDay, repr: "1 Day"}, 50 | timeDurationItem{duration: ThreeDay, repr: "3 Days"}, 51 | timeDurationItem{duration: OneWeek, repr: "1 Week"}, 52 | } 53 | 54 | listItemRender = lipgloss.NewStyle().Foreground(StandardSecondary) 55 | listSelectedItemRender = lipgloss.NewStyle().Foreground(FocusPrimary) 56 | ) 57 | 58 | type timeDurationItem struct { 59 | duration time.Duration 60 | repr string 61 | } 62 | 63 | func (i timeDurationItem) FilterValue() string { return i.repr } 64 | 65 | type timeDurationItemDelegate struct{} 66 | 67 | func (d timeDurationItemDelegate) Height() int { return 1 } 68 | func (d timeDurationItemDelegate) Spacing() int { return 0 } 69 | func (d timeDurationItemDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } 70 | func (d timeDurationItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { 71 | i, ok := listItem.(timeDurationItem) 72 | if !ok { 73 | return 74 | } 75 | 76 | fn := listItemRender.Render 77 | if index == m.Index() { 78 | fn = func(s ...string) string { 79 | return listSelectedItemRender.Render("> " + strings.Join(s, " ")) 80 | } 81 | } 82 | 83 | fmt.Fprint(w, fn(i.repr)) 84 | } 85 | 86 | // NewTimeRangeModel creates new range model 87 | func NewTimeRangeModel() list.Model { 88 | list := list.New(timeDurations, timeDurationItemDelegate{}, 20, 10) 89 | list.SetShowPagination(false) 90 | list.SetShowHelp(false) 91 | list.SetShowFilter(false) 92 | list.SetShowTitle(true) 93 | list.Styles.TitleBar = baseStyle 94 | list.Styles.Title = baseStyle.MarginBottom(1) 95 | list.Styles.TitleBar.Align(lipgloss.Left) 96 | list.Title = "Select Time Range" 97 | list.SetShowStatusBar(false) 98 | 99 | return list 100 | } 101 | -------------------------------------------------------------------------------- /pkg/config/config.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Parseable, Inc 2 | // 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package config 18 | 19 | import ( 20 | "errors" 21 | "fmt" 22 | "net" 23 | "net/url" 24 | "os" 25 | path "path/filepath" 26 | 27 | toml "github.com/pelletier/go-toml/v2" 28 | ) 29 | 30 | var ( 31 | configFilename = "config.toml" 32 | configAppName = "parseable" 33 | ) 34 | 35 | // Path returns user directory that can be used for the config file 36 | func Path() (string, error) { 37 | dir, err := os.UserConfigDir() 38 | if err != nil { 39 | return "", err 40 | } 41 | return path.Join(dir, configAppName, configFilename), nil 42 | } 43 | 44 | // Config is the struct that holds the configuration 45 | type Config struct { 46 | Profiles map[string]Profile 47 | DefaultProfile string 48 | } 49 | 50 | // Profile is the struct that holds the profile configuration 51 | type Profile struct { 52 | URL string `json:"url"` 53 | Username string `json:"username"` 54 | Password string `json:"password,omitempty"` 55 | } 56 | 57 | func (p *Profile) GrpcAddr(port string) string { 58 | urlv, _ := url.Parse(p.URL) 59 | return net.JoinHostPort(urlv.Hostname(), port) 60 | } 61 | 62 | // WriteConfigToFile writes the configuration to the config file 63 | func WriteConfigToFile(config *Config) error { 64 | tomlData, _ := toml.Marshal(config) 65 | filePath, err := Path() 66 | if err != nil { 67 | return err 68 | } 69 | // Open or create the file for writing (it will truncate the file if it already exists 70 | err = os.MkdirAll(path.Dir(filePath), os.ModePerm) 71 | if err != nil { 72 | return err 73 | } 74 | 75 | file, err := os.Create(filePath) 76 | if err != nil { 77 | fmt.Println("Error creating the file:", err) 78 | return err 79 | } 80 | defer file.Close() 81 | // Write the data into the file 82 | _, err = file.Write(tomlData) 83 | if err != nil { 84 | fmt.Println("Error writing to the file:", err) 85 | return err 86 | } 87 | return err 88 | } 89 | 90 | // ReadConfigFromFile reads the configuration from the config file 91 | func ReadConfigFromFile() (config *Config, err error) { 92 | filePath, err := Path() 93 | if err != nil { 94 | return &Config{}, err 95 | } 96 | 97 | data, err := os.ReadFile(filePath) 98 | if err != nil { 99 | return &Config{}, err 100 | } 101 | 102 | err = toml.Unmarshal(data, &config) 103 | if err != nil { 104 | return &Config{}, err 105 | } 106 | 107 | return config, nil 108 | } 109 | 110 | func GetProfile() (Profile, error) { 111 | conf, err := ReadConfigFromFile() 112 | if os.IsNotExist(err) { 113 | return Profile{}, errors.New("no config found to run this command. add a profile using pb profile command") 114 | } else if err != nil { 115 | return Profile{}, err 116 | } 117 | 118 | if conf.Profiles == nil || conf.DefaultProfile == "" { 119 | return Profile{}, errors.New("no profile is configured to run this command. please create one using profile command") 120 | } 121 | 122 | return conf.Profiles[conf.DefaultProfile], nil 123 | 124 | } 125 | -------------------------------------------------------------------------------- /pkg/installer/model.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Parseable, Inc 2 | // 3 | // This program is free software: you can redistribute it and/or modify 4 | // it under the terms of the GNU Affero General Public License as published by 5 | // the Free Software Foundation, either version 3 of the License, or 6 | // (at your option) any later version. 7 | // 8 | // This program is distributed in the hope that it will be useful 9 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | // GNU Affero General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU Affero General Public License 14 | // along with this program. If not, see . 15 | 16 | package installer 17 | 18 | // loggingAgent represents the type of logging agent used. 19 | type loggingAgent string 20 | 21 | const ( 22 | // fluentbit specifies Fluent Bit as the logging agent. 23 | fluentbit loggingAgent = "fluentbit" 24 | // vector specifies Vector as the logging agent. 25 | vector loggingAgent = "vector" 26 | // none specifies no logging agent or a custom logging agent. 27 | _ loggingAgent = "I have my agent running / I'll set up later" 28 | ) 29 | 30 | // ParseableInfo represents the info used to authenticate, metadata with Parseable. 31 | type ParseableInfo struct { 32 | Name string // Name for parseable 33 | Namespace string // Namespace for parseable 34 | Username string // Username for authentication. 35 | Password string // Password for authentication. 36 | } 37 | 38 | // ObjectStore represents the type of object storage backend. 39 | type ObjectStore string 40 | 41 | const ( 42 | // S3Store represents an S3-compatible object store. 43 | S3Store ObjectStore = "s3-store" 44 | // LocalStore represents a local file system storage backend. 45 | LocalStore ObjectStore = "local-store" 46 | // BlobStore represents an Azure Blob Storage backend. 47 | BlobStore ObjectStore = "blob-store" 48 | // GcsStore represents a Google Cloud Storage backend. 49 | GcsStore ObjectStore = "gcs-store" 50 | ) 51 | 52 | // ObjectStoreConfig contains the configuration for the object storage backend. 53 | type ObjectStoreConfig struct { 54 | StorageClass string // Storage class of the object store. 55 | ObjectStore ObjectStore // Type of object store being used. 56 | S3Store S3 // S3-specific configuration. 57 | BlobStore Blob // Azure Blob-specific configuration. 58 | GCSStore GCS // GCS-specific configuration. 59 | } 60 | 61 | // S3 contains configuration details for an S3-compatible object store. 62 | type S3 struct { 63 | URL string // URL of the S3-compatible object store. 64 | AccessKey string // Access key for authentication. 65 | SecretKey string // Secret key for authentication. 66 | Bucket string // Bucket name in the S3 store. 67 | Region string // Region of the S3 store. 68 | } 69 | 70 | // GCS contains configuration details for a Google Cloud Storage backend. 71 | type GCS struct { 72 | URL string // URL of the GCS-compatible object store. 73 | AccessKey string // Access key for authentication. 74 | SecretKey string // Secret key for authentication. 75 | Bucket string // Bucket name in the GCS store. 76 | Region string // Region of the GCS store. 77 | } 78 | 79 | // Blob contains configuration details for an Azure Blob Storage backend. 80 | type Blob struct { 81 | AccessKey string // Access key for authentication. 82 | StorageAccountName string // Account name for Azure Blob Storage. 83 | Container string // Container name in the Azure Blob store. 84 | ClientID string // Client ID to authenticate. 85 | ClientSecret string // Client Secret to authenticate. 86 | TenantID string // TenantID 87 | URL string // URL of the Azure Blob store. 88 | } 89 | -------------------------------------------------------------------------------- /pkg/model/datetime/datetime.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Parseable, Inc 2 | // 3 | // This program is free software: you can redistribute it and/or modify 4 | // it under the terms of the GNU Affero General Public License as published by 5 | // the Free Software Foundation, either version 3 of the License, or 6 | // (at your option) any later version. 7 | // 8 | // This program is distributed in the hope that it will be useful 9 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | // GNU Affero General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU Affero General Public License 14 | // along with this program. If not, see . 15 | 16 | package datetime 17 | 18 | import ( 19 | "time" 20 | "unicode" 21 | 22 | "github.com/charmbracelet/bubbles/key" 23 | "github.com/charmbracelet/bubbles/textinput" 24 | tea "github.com/charmbracelet/bubbletea" 25 | ) 26 | 27 | // Model is the model for the datetime component 28 | type Model struct { 29 | time time.Time 30 | input textinput.Model 31 | } 32 | 33 | // Value returns the current value of the datetime component 34 | func (m *Model) Value() string { 35 | return m.time.Format(time.RFC3339) 36 | } 37 | 38 | // ValueUtc returns the current value of the datetime component in UTC 39 | func (m *Model) ValueUtc() string { 40 | return m.time.UTC().Format(time.RFC3339) 41 | } 42 | 43 | // SetTime sets the value of the datetime component 44 | func (m *Model) SetTime(t time.Time) { 45 | m.time = t 46 | m.input.SetValue(m.time.Format(time.DateTime)) 47 | } 48 | 49 | // Time returns the current time of the datetime component 50 | func (m *Model) Time() time.Time { 51 | return m.time 52 | } 53 | 54 | // New creates a new datetime component 55 | func New(prompt string) Model { 56 | input := textinput.New() 57 | input.Width = 20 58 | input.Prompt = prompt 59 | 60 | return Model{ 61 | time: time.Now(), 62 | input: input, 63 | } 64 | } 65 | 66 | // Focus focuses the datetime component 67 | func (m *Model) Focus() tea.Cmd { 68 | m.input.Focus() 69 | return nil 70 | } 71 | 72 | // Blur blurs the datetime component 73 | func (m *Model) Blur() { 74 | m.input.Blur() 75 | } 76 | 77 | // Focused returns true if the datetime component is focused 78 | func (m *Model) Focused() bool { 79 | return m.input.Focused() 80 | } 81 | 82 | // Init initializes the datetime component 83 | func (m Model) Init() tea.Cmd { 84 | return nil 85 | } 86 | 87 | // Update updates the datetime component 88 | func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { 89 | var cmd tea.Cmd 90 | if !m.Focused() { 91 | return m, nil 92 | } 93 | 94 | switch msg := msg.(type) { 95 | case tea.KeyMsg: 96 | // Allow navigation keys to pass through 97 | if key.Matches(msg, m.input.KeyMap.CharacterForward, 98 | m.input.KeyMap.CharacterBackward, 99 | m.input.KeyMap.WordForward, 100 | m.input.KeyMap.WordBackward, 101 | m.input.KeyMap.LineStart, 102 | m.input.KeyMap.LineEnd) { 103 | m.input, cmd = m.input.Update(msg) 104 | return m, cmd 105 | } 106 | // do replace on current cursor 107 | if len(msg.Runes) == 1 && unicode.IsDigit(msg.Runes[0]) { 108 | pos := m.input.Position() 109 | oldValue := m.input.Value() 110 | newValue := []rune(oldValue) 111 | newValue[pos] = msg.Runes[0] 112 | value := string(newValue) 113 | local, _ := time.LoadLocation("Local") 114 | newTime, err := time.ParseInLocation(time.DateTime, value, local) 115 | if err == nil { 116 | m.time = newTime 117 | m.SetTime(newTime) 118 | } 119 | } 120 | } 121 | 122 | return m, nil 123 | } 124 | 125 | // View returns the view of the datetime component 126 | func (m Model) View() string { 127 | return m.input.View() 128 | } 129 | -------------------------------------------------------------------------------- /pkg/model/tablekeymap.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Parseable, Inc 2 | // 3 | // This program is free software: you can redistribute it and/or modify 4 | // it under the terms of the GNU Affero General Public License as published by 5 | // the Free Software Foundation, either version 3 of the License, or 6 | // (at your option) any later version. 7 | // 8 | // This program is distributed in the hope that it will be useful 9 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | // GNU Affero General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU Affero General Public License 14 | // along with this program. If not, see . 15 | 16 | package model 17 | 18 | import ( 19 | "github.com/charmbracelet/bubbles/key" 20 | "github.com/evertras/bubble-table/table" 21 | ) 22 | 23 | type TableKeyMap struct { 24 | RowUp key.Binding 25 | RowDown key.Binding 26 | PageUp key.Binding 27 | PageDown key.Binding 28 | PageFirst key.Binding 29 | PageLast key.Binding 30 | ScrollRight key.Binding 31 | ScrollLeft key.Binding 32 | Filter key.Binding 33 | FilterClear key.Binding 34 | FilterBlur key.Binding 35 | } 36 | 37 | // ShortHelp returns keybindings to be shown in the mini help view. It's part 38 | // of the key.Map interface. 39 | func (k TableKeyMap) ShortHelp() []key.Binding { 40 | return []key.Binding{k.ScrollRight, k.ScrollRight, k.Filter, k.FilterClear} 41 | } 42 | 43 | // FullHelp returns keybindings for the expanded help view. It's part of the 44 | // key.Map interface. 45 | func (k TableKeyMap) FullHelp() [][]key.Binding { 46 | return [][]key.Binding{ 47 | {k.RowUp, k.RowDown, k.PageUp, k.PageDown}, // first column 48 | {k.ScrollLeft, k.ScrollRight, k.PageFirst, k.PageLast}, 49 | {k.FilterClear, k.Filter, k.FilterBlur}, // second column 50 | } 51 | } 52 | 53 | var tableHelpBinds = TableKeyMap{ 54 | RowUp: key.NewBinding( 55 | key.WithKeys("up", "w"), 56 | key.WithHelp("↑/w", "scroll up"), 57 | ), 58 | RowDown: key.NewBinding( 59 | key.WithKeys("down", "s"), 60 | key.WithHelp("↓/s", "scroll down"), 61 | ), 62 | PageUp: key.NewBinding( 63 | key.WithKeys("shift+up", "W", "pgup"), 64 | key.WithHelp("shift ↑/w", "prev page"), 65 | ), 66 | PageDown: key.NewBinding( 67 | key.WithKeys("shift+down", "S", "pgdown"), 68 | key.WithHelp("shift ↓/s", "next page"), 69 | ), 70 | PageFirst: key.NewBinding( 71 | key.WithKeys("home", "ctrl+y"), 72 | key.WithHelp("home/ctrl y", "first page"), 73 | ), 74 | PageLast: key.NewBinding( 75 | key.WithKeys("end", "ctrl+v"), 76 | key.WithHelp("end/ctrl v", "last page"), 77 | ), 78 | ScrollLeft: key.NewBinding( 79 | key.WithKeys("left", "a"), 80 | key.WithHelp("←/a", "scroll left"), 81 | ), 82 | ScrollRight: key.NewBinding( 83 | key.WithKeys("right", "d"), 84 | key.WithHelp("→/d", "scroll right"), 85 | ), 86 | Filter: key.NewBinding( 87 | key.WithKeys("/"), 88 | key.WithHelp("/", "Filter"), 89 | ), 90 | FilterClear: key.NewBinding( 91 | key.WithKeys("esc"), 92 | key.WithHelp("esc", "remove filter"), 93 | ), 94 | FilterBlur: key.NewBinding( 95 | key.WithKeys("esc", "enter"), 96 | key.WithHelp("enter/esc", "blur filter"), 97 | ), 98 | } 99 | 100 | var tableKeyBinds = table.KeyMap{ 101 | RowUp: tableHelpBinds.RowUp, 102 | RowDown: tableHelpBinds.RowDown, 103 | PageUp: tableHelpBinds.PageUp, 104 | PageDown: tableHelpBinds.PageDown, 105 | PageFirst: tableHelpBinds.PageFirst, 106 | PageLast: tableHelpBinds.PageLast, 107 | ScrollLeft: tableHelpBinds.ScrollLeft, 108 | ScrollRight: tableHelpBinds.ScrollRight, 109 | Filter: tableHelpBinds.Filter, 110 | FilterClear: tableHelpBinds.FilterClear, 111 | FilterBlur: tableHelpBinds.FilterBlur, 112 | } 113 | -------------------------------------------------------------------------------- /pkg/model/textareakeymap.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Parseable, Inc 2 | // 3 | // This program is free software: you can redistribute it and/or modify 4 | // it under the terms of the GNU Affero General Public License as published by 5 | // the Free Software Foundation, either version 3 of the License, or 6 | // (at your option) any later version. 7 | // 8 | // This program is distributed in the hope that it will be useful 9 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | // GNU Affero General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU Affero General Public License 14 | // along with this program. If not, see . 15 | 16 | package model 17 | 18 | import ( 19 | "github.com/charmbracelet/bubbles/key" 20 | "github.com/charmbracelet/bubbles/textarea" 21 | ) 22 | 23 | type TextAreaHelpKeys struct{} 24 | 25 | // ShortHelp returns keybindings to be shown in the mini help view. It's part 26 | // of the key.Map interface. 27 | func (k TextAreaHelpKeys) ShortHelp() []key.Binding { 28 | t := textAreaKeyMap 29 | return []key.Binding{t.WordForward, t.WordBackward, t.DeleteWordBackward, t.DeleteWordForward} 30 | } 31 | 32 | // FullHelp returns keybindings for the expanded help view. It's part of the 33 | // key.Map interface. 34 | func (k TextAreaHelpKeys) FullHelp() [][]key.Binding { 35 | t := textAreaKeyMap 36 | return [][]key.Binding{ 37 | {t.CharacterForward, t.CharacterBackward, t.WordForward, t.WordBackward}, // first column 38 | {t.DeleteWordForward, t.DeleteWordBackward, t.DeleteCharacterForward, t.DeleteCharacterBackward}, 39 | {t.LineStart, t.LineEnd, t.InputBegin, t.InputEnd}, // second column 40 | } 41 | } 42 | 43 | var textAreaKeyMap = textarea.KeyMap{ 44 | CharacterForward: key.NewBinding( 45 | key.WithKeys("right", "ctrl+f"), 46 | key.WithHelp("→", "right"), 47 | ), 48 | CharacterBackward: key.NewBinding( 49 | key.WithKeys("left", "ctrl+b"), 50 | key.WithHelp("←", "right"), 51 | ), 52 | WordForward: key.NewBinding( 53 | key.WithKeys("ctrl+right", "alt+f"), 54 | key.WithHelp("ctrl →", "word forward")), 55 | WordBackward: key.NewBinding( 56 | key.WithKeys("ctrl+left", "alt+b"), 57 | key.WithHelp("ctrl ←", "word backward")), 58 | LineNext: key.NewBinding( 59 | key.WithKeys("down", "ctrl+n"), 60 | key.WithHelp("↓", "down")), 61 | LinePrevious: key.NewBinding( 62 | key.WithKeys("up", "ctrl+p"), 63 | key.WithHelp("↑", "up")), 64 | DeleteWordBackward: key.NewBinding( 65 | key.WithKeys("ctrl+backspace", "ctrl+w"), 66 | key.WithHelp("ctrl bkspc", "delete word behind")), 67 | DeleteWordForward: key.NewBinding( 68 | key.WithKeys("ctrl+delete", "alt+d"), 69 | key.WithHelp("ctrl del", "delete word forward")), 70 | DeleteAfterCursor: key.NewBinding( 71 | key.WithKeys("ctrl+k"), 72 | ), 73 | DeleteBeforeCursor: key.NewBinding( 74 | key.WithKeys("ctrl+u"), 75 | ), 76 | InsertNewline: key.NewBinding( 77 | key.WithKeys("enter", "ctrl+m"), 78 | ), 79 | DeleteCharacterBackward: key.NewBinding( 80 | key.WithKeys("backspace", "ctrl+h"), 81 | key.WithHelp("bkspc", "delete backward"), 82 | ), 83 | DeleteCharacterForward: key.NewBinding( 84 | key.WithKeys("delete", "ctrl+d"), 85 | key.WithHelp("del", "delete"), 86 | ), 87 | LineStart: key.NewBinding( 88 | key.WithKeys("home", "ctrl+a"), 89 | key.WithHelp("home", "line start")), 90 | LineEnd: key.NewBinding( 91 | key.WithKeys("end", "ctrl+e"), 92 | key.WithHelp("end", "line end")), 93 | Paste: key.NewBinding( 94 | key.WithKeys("ctrl+v"), 95 | key.WithHelp("ctrl v", "paste")), 96 | InputBegin: key.NewBinding( 97 | key.WithKeys("ctrl+home"), 98 | key.WithHelp("ctrl home", "home")), 99 | InputEnd: key.NewBinding( 100 | key.WithKeys("ctrl+end"), 101 | key.WithHelp("ctrl end", "end")), 102 | 103 | CapitalizeWordForward: key.NewBinding(key.WithKeys("alt+c")), 104 | LowercaseWordForward: key.NewBinding(key.WithKeys("alt+l")), 105 | UppercaseWordForward: key.NewBinding(key.WithKeys("alt+u")), 106 | 107 | TransposeCharacterBackward: key.NewBinding(key.WithKeys("ctrl+t")), 108 | } 109 | -------------------------------------------------------------------------------- /pkg/installer/plans.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Parseable, Inc 2 | // 3 | // This program is free software: you can redistribute it and/or modify 4 | // it under the terms of the GNU Affero General Public License as published by 5 | // the Free Software Foundation, either version 3 of the License, or 6 | // (at your option) any later version. 7 | // 8 | // This program is distributed in the hope that it will be useful 9 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | // GNU Affero General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU Affero General Public License 14 | // along with this program. If not, see . 15 | 16 | package installer 17 | 18 | import ( 19 | "fmt" 20 | 21 | "pb/pkg/common" 22 | 23 | "github.com/manifoldco/promptui" 24 | ) 25 | 26 | type Plan struct { 27 | Name string 28 | IngestionSpeed string 29 | PerDayIngestion string 30 | QueryPerformance string 31 | CPUAndMemorySpecs string 32 | CPU string 33 | Memory string 34 | Mode string 35 | Description string 36 | } 37 | 38 | // Plans define the plans with clear CPU and memory specs for consumption 39 | var Plans = map[string]Plan{ 40 | "Playground": { 41 | Name: "Playground", 42 | Description: "Suitable for testing and PoC", 43 | IngestionSpeed: "Up to 5 MiB/sec", 44 | CPUAndMemorySpecs: "1 vCPU, 1Gi RAM", 45 | CPU: "1", 46 | Memory: "1Gi", 47 | Mode: "Standalone", 48 | }, 49 | "Small": { 50 | Name: "Small", 51 | Description: "Suitable for production grade, small volume workloads", 52 | IngestionSpeed: "Up to 20 MiB/sec", 53 | CPUAndMemorySpecs: "2 vCPUs, 4Gi RAM", 54 | CPU: "2", 55 | Memory: "4Gi", 56 | Mode: "Distributed (1 Query pod, 3 Ingest pod)", 57 | }, 58 | "Medium": { 59 | Name: "Medium", 60 | IngestionSpeed: "Up to 50 MiB/sec", 61 | CPUAndMemorySpecs: "4 vCPUs, 16Gi RAM", 62 | CPU: "4", 63 | Memory: "18Gi", 64 | Mode: "Distributed (1 Query pod, 3 Ingest pod)", 65 | }, 66 | "Large": { 67 | Name: "Large", 68 | IngestionSpeed: "Up to 100 MiB/sec", 69 | CPUAndMemorySpecs: "8 vCPUs, 32Gi RAM", 70 | CPU: "8", 71 | Memory: "16Gi", 72 | Mode: "Distributed (1 Query pod, 3 Ingest pod)", 73 | }, 74 | } 75 | 76 | func promptUserPlanSelection() (Plan, error) { 77 | planList := []Plan{ 78 | Plans["Playground"], 79 | Plans["Small"], 80 | Plans["Medium"], 81 | Plans["Large"], 82 | } 83 | 84 | // Custom template for displaying plans 85 | templates := &promptui.SelectTemplates{ 86 | Label: "{{ . }}", 87 | Active: "▶ {{ .Name | yellow }} ", 88 | Inactive: " {{ .Name | yellow }} ", 89 | Selected: "{{ `Selected plan:` | green }} '{{ .Name | green }}' ✔ ", 90 | Details: ` 91 | --------- Plan Details ---------- 92 | {{ "Plan:" | faint }} {{ .Name }} 93 | {{ "Ingestion Speed:" | faint }} {{ .IngestionSpeed }} 94 | {{ "Infrastructure:" | faint }} {{ .Mode }} 95 | {{ "CPU & Memory:" | faint }} {{ .CPUAndMemorySpecs }} per pod`, 96 | } 97 | 98 | // Add a note about the default plan in the label 99 | label := fmt.Sprintf(common.Yellow + "Select deployment type:") 100 | 101 | prompt := promptui.Select{ 102 | Label: label, 103 | Items: planList, 104 | Templates: templates, 105 | } 106 | 107 | index, _, err := prompt.Run() 108 | if err != nil { 109 | return Plan{}, fmt.Errorf("failed to select deployment type: %w", err) 110 | } 111 | 112 | selectedPlan := planList[index] 113 | fmt.Printf( 114 | common.Cyan+" Ingestion Speed: %s\n"+ 115 | common.Cyan+" Per Day Ingestion: %s\n"+ 116 | common.Cyan+" Query Performance: %s\n"+ 117 | common.Cyan+" CPU & Memory: %s\n"+ 118 | common.Reset, selectedPlan.IngestionSpeed, selectedPlan.PerDayIngestion, 119 | selectedPlan.QueryPerformance, selectedPlan.CPUAndMemorySpecs) 120 | 121 | return selectedPlan, nil 122 | } 123 | -------------------------------------------------------------------------------- /pkg/iterator/iterator.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Parseable, Inc 2 | // 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package iterator 18 | 19 | import ( 20 | "time" 21 | ) 22 | 23 | type MinuteCheckPoint struct { 24 | // minute start time. 25 | time time.Time 26 | } 27 | 28 | type QueryIterator[OK any, ERR any] struct { 29 | rangeStartTime time.Time 30 | rangeEndTime time.Time 31 | ascending bool 32 | index int 33 | windows []MinuteCheckPoint 34 | ready bool 35 | finished bool 36 | queryRunner func(time.Time, time.Time) (OK, ERR) 37 | hasData func(time.Time, time.Time) bool 38 | } 39 | 40 | func NewQueryIterator[OK any, ERR any](startTime time.Time, endTime time.Time, ascending bool, queryRunner func(time.Time, time.Time) (OK, ERR), hasData func(time.Time, time.Time) bool) QueryIterator[OK, ERR] { 41 | iter := QueryIterator[OK, ERR]{ 42 | rangeStartTime: startTime, 43 | rangeEndTime: endTime, 44 | ascending: ascending, 45 | index: -1, 46 | windows: []MinuteCheckPoint{}, 47 | ready: true, 48 | finished: false, 49 | queryRunner: queryRunner, 50 | hasData: hasData, 51 | } 52 | iter.populateNextNonEmpty() 53 | return iter 54 | } 55 | 56 | func (iter *QueryIterator[OK, ERR]) inRange(targetTime time.Time) bool { 57 | return targetTime.Equal(iter.rangeStartTime) || (targetTime.After(iter.rangeStartTime) && targetTime.Before(iter.rangeEndTime)) 58 | } 59 | 60 | func (iter *QueryIterator[OK, ERR]) Ready() bool { 61 | return iter.ready 62 | } 63 | 64 | func (iter *QueryIterator[OK, ERR]) Finished() bool { 65 | return iter.finished && iter.index == len(iter.windows)-1 66 | } 67 | 68 | func (iter *QueryIterator[OK, ERR]) CanFetchPrev() bool { 69 | return iter.index > 0 70 | } 71 | 72 | func (iter *QueryIterator[OK, ERR]) populateNextNonEmpty() { 73 | var inspectMinute MinuteCheckPoint 74 | 75 | // this is initial condition when no checkpoint exists in the window 76 | if len(iter.windows) == 0 { 77 | if iter.ascending { 78 | inspectMinute = MinuteCheckPoint{time: iter.rangeStartTime} 79 | } else { 80 | inspectMinute = MinuteCheckPoint{iter.rangeEndTime.Add(-time.Minute)} 81 | } 82 | } else { 83 | inspectMinute = MinuteCheckPoint{time: nextMinute(iter.windows[len(iter.windows)-1].time, iter.ascending)} 84 | } 85 | 86 | iter.ready = false 87 | for iter.inRange(inspectMinute.time) { 88 | if iter.hasData(inspectMinute.time, inspectMinute.time.Add(time.Minute)) { 89 | iter.windows = append(iter.windows, inspectMinute) 90 | iter.ready = true 91 | return 92 | } 93 | inspectMinute = MinuteCheckPoint{ 94 | time: nextMinute(inspectMinute.time, iter.ascending), 95 | } 96 | } 97 | 98 | // if the loops breaks we have crossed the range with no data 99 | iter.ready = true 100 | iter.finished = true 101 | } 102 | 103 | func (iter *QueryIterator[OK, ERR]) Next() (OK, ERR) { 104 | // This assumes that there is always a next index to fetch if this function is called 105 | iter.index++ 106 | currentMinute := iter.windows[iter.index] 107 | if iter.index == len(iter.windows)-1 { 108 | iter.ready = false 109 | go iter.populateNextNonEmpty() 110 | } 111 | return iter.queryRunner(currentMinute.time, currentMinute.time.Add(time.Minute)) 112 | } 113 | 114 | func (iter *QueryIterator[OK, ERR]) Prev() (OK, ERR) { 115 | if iter.index > 0 { 116 | iter.index-- 117 | } 118 | currentMinute := iter.windows[iter.index] 119 | return iter.queryRunner(currentMinute.time, currentMinute.time.Add(time.Minute)) 120 | } 121 | 122 | func nextMinute(current time.Time, ascending bool) time.Time { 123 | if ascending { 124 | return current.Add(time.Minute) 125 | } 126 | return current.Add(-time.Minute) 127 | } 128 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pb 2 | 3 | Dashboard fatigue is one of key reasons for poor adoption of logging tools among developers. With pb, we intend to bring the familiar command line interface for querying and analyzing log data at scale. 4 | 5 | pb is the command line interface for [Parseable Server](https://github.com/parseablehq/parseable). pb allows you to manage Streams, Users, and Data on Parseable Server. You can use pb to manage multiple Parseable Server instances using Profiles. 6 | 7 | ![pb](https://github.com/parseablehq/.github/blob/main/images/pb/pb.gif?raw=true) 8 | 9 | ## Installation 10 | 11 | pb is available as a single, self contained binary for Mac, Linux, and Windows. You can download the latest version from the [releases page](https://github.com/parseablehq/pb/releases/latest). 12 | 13 | To install pb, download the binary for your platform, un-tar the binary and place it in your `$PATH`. 14 | 15 | ## Usage 16 | 17 | pb is configured with `demo` profile as the default. This means you can directly start using pb against the [demo Parseable Server](https://demo.parseable.com). 18 | 19 | ### Profiles 20 | 21 | To start using pb against your Parseable server, create a profile (a profile is a set of credentials for a Parseable Server instance). You can create a profile using the `pb profile add` command. For example: 22 | 23 | ```bash 24 | pb profile add local http://localhost:8000 admin admin 25 | ``` 26 | 27 | This will create a profile named `local` that points to the Parseable Server at `http://localhost:8000` and uses the username `admin` and password `admin`. 28 | 29 | You can create as many profiles as you like. To avoid having to specify the profile name every time you run a command, pb allows setting a default profile. To set the default profile, use the `pb profile default` command. For example: 30 | 31 | ```bash 32 | pb profile default local 33 | ``` 34 | 35 | ### Query 36 | 37 | By default `pb` sends json data to stdout. 38 | 39 | ```bash 40 | pb query run "select * from backend" --from=1m --to=now 41 | ``` 42 | 43 | or specifying time range in rfc3999 44 | 45 | ```bash 46 | pb query run "select * from backend" --from=2024-01-00T01:40:00.000Z --to=2024-01-00T01:55:00.000Z 47 | ``` 48 | 49 | You can use tools like `jq` and `grep` to further process and filter the output. Some examples: 50 | 51 | ```bash 52 | pb query run "select * from backend" --from=1m --to=now | jq . 53 | pb query run "select host, id, method, status from backend where status = 500" --from=1m --to=now | jq . > 500.json 54 | pb query run "select host, id, method, status from backend where status = 500" | jq '. | select(.method == "PATCH")' 55 | pb query run "select host, id, method, status from backend where status = 500" --from=1m --to=now | grep "POST" | jq . | less 56 | ``` 57 | 58 | #### Save Filter 59 | 60 | To save a query as a filter use the `--save-as` flag followed by a name for the filter. For example: 61 | 62 | ```bash 63 | pb query run "select * from backend" --from=1m --to=now --save-as=FilterName 64 | ``` 65 | 66 | ### List Filter 67 | 68 | To list all filter for the active user run: 69 | 70 | ```bash 71 | pb query list 72 | ``` 73 | 74 | ### Live Tail 75 | 76 | `pb` can be used to tail live data from Parseable Server. To tail live data, use the `pb tail` command. For example: 77 | 78 | ```bash 79 | pb tail backend 80 | ``` 81 | 82 | You can also use the terminal tools like `jq` and `grep` to filter and process the tail output. Some examples: 83 | 84 | ```bash 85 | pb tail backend | jq '. | select(.method == "PATCH")' 86 | pb tail backend | grep "POST" | jq . 87 | ``` 88 | 89 | To stop tailing, press `Ctrl+C`. 90 | 91 | ### Stream Management 92 | 93 | Once a profile is configured, you can use pb to query and manage _that_ Parseable Server instance. For example, to list all the streams on the server, run: 94 | 95 | ```bash 96 | pb stream list 97 | ``` 98 | 99 | ### Users 100 | 101 | To list all the users with their privileges, run: 102 | 103 | ```bash 104 | pb user list 105 | ``` 106 | 107 | You can also use the `pb users` command to manage users. 108 | 109 | ### Version 110 | 111 | Version command prints the version of pb and the Parseable Server it is configured to use. 112 | 113 | ```bash 114 | pb version 115 | ``` 116 | 117 | ### Add Autocomplete 118 | 119 | To enable autocomplete for pb, run the following command according to your shell: 120 | 121 | For bash: 122 | 123 | ```bash 124 | pb autocomplete bash > /etc/bash_completion.d/pb 125 | source /etc/bash_completion.d/pb 126 | ``` 127 | 128 | For zsh: 129 | 130 | ```zsh 131 | pb autocomplete zsh > /usr/local/share/zsh/site-functions/_pb 132 | autoload -U compinit && compinit 133 | ``` 134 | 135 | For powershell 136 | 137 | ```powershell 138 | pb autocomplete powershell > $env:USERPROFILE\Documents\PowerShell\pb_complete.ps1 139 | . $PROFILE 140 | ``` 141 | -------------------------------------------------------------------------------- /pkg/model/defaultprofile/profile.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Parseable, Inc 2 | // 3 | // This program is free software: you can redistribute it and/or modify 4 | // it under the terms of the GNU Affero General Public License as published by 5 | // the Free Software Foundation, either version 3 of the License, or 6 | // (at your option) any later version. 7 | // 8 | // This program is distributed in the hope that it will be useful 9 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | // GNU Affero General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU Affero General Public License 14 | // along with this program. If not, see . 15 | 16 | package defaultprofile 17 | 18 | import ( 19 | "fmt" 20 | "io" 21 | "pb/pkg/config" 22 | 23 | "github.com/charmbracelet/bubbles/list" 24 | tea "github.com/charmbracelet/bubbletea" 25 | "github.com/charmbracelet/lipgloss" 26 | ) 27 | 28 | var ( 29 | // FocusPrimary is the primary focus color 30 | FocusPrimary = lipgloss.AdaptiveColor{Light: "16", Dark: "226"} 31 | // FocusSecondry is the secondry focus color 32 | FocusSecondry = lipgloss.AdaptiveColor{Light: "18", Dark: "220"} 33 | // StandardPrimary is the primary standard color 34 | StandardPrimary = lipgloss.AdaptiveColor{Light: "235", Dark: "255"} 35 | // StandardSecondary is the secondary standard color 36 | StandardSecondary = lipgloss.AdaptiveColor{Light: "238", Dark: "254"} 37 | 38 | focusTitleStyle = lipgloss.NewStyle().Foreground(FocusPrimary) 39 | focusDescStyle = lipgloss.NewStyle().Foreground(FocusSecondry) 40 | focusedOuterStyle = lipgloss.NewStyle().BorderStyle(lipgloss.NormalBorder()).BorderLeft(true).BorderForeground(FocusPrimary) 41 | 42 | standardTitleStyle = lipgloss.NewStyle().Foreground(StandardPrimary) 43 | standardDescStyle = lipgloss.NewStyle().Foreground(StandardSecondary) 44 | ) 45 | 46 | type item struct { 47 | title, url, user string 48 | } 49 | 50 | func (i item) FilterValue() string { return i.title } 51 | 52 | type itemDelegate struct{} 53 | 54 | func (d itemDelegate) Height() int { return 3 } 55 | func (d itemDelegate) Spacing() int { return 1 } 56 | func (d itemDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } 57 | func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { 58 | item, _ := listItem.(item) 59 | 60 | var titleStyle lipgloss.Style 61 | var descStyle lipgloss.Style 62 | 63 | if index == m.Index() { 64 | titleStyle = focusTitleStyle 65 | descStyle = focusDescStyle 66 | } else { 67 | titleStyle = standardTitleStyle 68 | descStyle = standardDescStyle 69 | } 70 | 71 | render := fmt.Sprintf( 72 | "%s\n%s\n%s", 73 | titleStyle.Render(item.title), 74 | descStyle.Render(item.url), 75 | descStyle.Render(item.user), 76 | ) 77 | 78 | if index == m.Index() { 79 | render = focusedOuterStyle.Render(render) 80 | } 81 | 82 | fmt.Fprint(w, render) 83 | } 84 | 85 | // Model for profile selection command 86 | type Model struct { 87 | list list.Model 88 | Choice string 89 | Success bool 90 | } 91 | 92 | func New(profiles map[string]config.Profile) Model { 93 | items := []list.Item{} 94 | for name, profile := range profiles { 95 | i := item{ 96 | title: name, 97 | url: profile.URL, 98 | user: profile.Username, 99 | } 100 | items = append(items, i) 101 | } 102 | 103 | list := list.New(items, itemDelegate{}, 80, 19) 104 | list.SetShowStatusBar(false) 105 | list.SetShowTitle(false) 106 | 107 | list.Styles.PaginationStyle = list.Styles.PaginationStyle.MarginLeft(1).Padding(0) 108 | list.Styles.HelpStyle = list.Styles.HelpStyle.MarginLeft(1).Padding(0) 109 | 110 | list.Paginator.ActiveDot = "● " 111 | list.Paginator.InactiveDot = "○ " 112 | 113 | list.KeyMap.ShowFullHelp.SetEnabled(false) 114 | list.KeyMap.CloseFullHelp.SetEnabled(false) 115 | 116 | list.SetFilteringEnabled(true) 117 | 118 | m := Model{ 119 | list: list, 120 | Choice: "", 121 | Success: false, 122 | } 123 | 124 | return m 125 | } 126 | 127 | func (m Model) Init() tea.Cmd { 128 | return nil 129 | } 130 | 131 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 132 | var cmd tea.Cmd 133 | switch msg := msg.(type) { 134 | case tea.KeyMsg: 135 | switch msg.Type { 136 | case tea.KeyCtrlC: 137 | return m, tea.Quit 138 | default: 139 | if msg.Type == tea.KeyEnter && m.list.FilterState() != list.Filtering { 140 | m.Success = true 141 | m.Choice = m.list.SelectedItem().FilterValue() 142 | return m, tea.Quit 143 | } 144 | m.list, cmd = m.list.Update(msg) 145 | } 146 | } 147 | return m, cmd 148 | } 149 | 150 | func (m Model) View() string { 151 | return lipgloss.NewStyle().PaddingLeft(1).Render(m.list.View()) 152 | } 153 | -------------------------------------------------------------------------------- /pkg/model/credential/credential.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Parseable, Inc 2 | // 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package credential 18 | 19 | import ( 20 | "pb/pkg/model/button" 21 | "strings" 22 | 23 | "github.com/charmbracelet/bubbles/textinput" 24 | tea "github.com/charmbracelet/bubbletea" 25 | "github.com/charmbracelet/lipgloss" 26 | ) 27 | 28 | // Default Style for this widget 29 | var ( 30 | FocusPrimary = lipgloss.AdaptiveColor{Light: "16", Dark: "226"} 31 | FocusSecondary = lipgloss.AdaptiveColor{Light: "18", Dark: "220"} 32 | 33 | StandardPrimary = lipgloss.AdaptiveColor{Light: "235", Dark: "255"} 34 | StandardSecondary = lipgloss.AdaptiveColor{Light: "238", Dark: "254"} 35 | 36 | focusedStyle = lipgloss.NewStyle().Foreground(FocusPrimary) 37 | blurredStyle = lipgloss.NewStyle().Foreground(StandardSecondary) 38 | noStyle = lipgloss.NewStyle() 39 | ) 40 | 41 | type Model struct { 42 | focusIndex int 43 | inputs []textinput.Model 44 | button button.Model 45 | } 46 | 47 | func (m *Model) Values() (string, string) { 48 | if validInputs(&m.inputs) { 49 | return m.inputs[0].Value(), m.inputs[1].Value() 50 | } 51 | return "", "" 52 | } 53 | 54 | func validInputs(inputs *[]textinput.Model) bool { 55 | valid := true 56 | username := (*inputs)[0].Value() 57 | password := (*inputs)[1].Value() 58 | 59 | if strings.Contains(username, " ") || username == "" || password == "" { 60 | valid = false 61 | } 62 | 63 | return valid 64 | } 65 | 66 | func New() Model { 67 | m := Model{ 68 | inputs: make([]textinput.Model, 2), 69 | } 70 | 71 | var t textinput.Model 72 | for i := range m.inputs { 73 | t = textinput.New() 74 | t.Cursor.Style = focusedStyle 75 | t.CharLimit = 32 76 | 77 | switch i { 78 | case 0: 79 | t.Placeholder = "username" 80 | t.Focus() 81 | t.PromptStyle = focusedStyle 82 | t.Prompt = "user: " 83 | t.TextStyle = focusedStyle 84 | case 1: 85 | t.Placeholder = "password" 86 | t.Prompt = "pass: " 87 | t.EchoMode = textinput.EchoPassword 88 | t.EchoCharacter = '•' 89 | t.CharLimit = 64 90 | } 91 | m.inputs[i] = t 92 | } 93 | 94 | button := button.New("Submit") 95 | button.FocusStyle = focusedStyle 96 | button.BlurredStyle = blurredStyle 97 | button.Invalid = true 98 | 99 | m.button = button 100 | 101 | return m 102 | } 103 | 104 | func (m Model) Init() tea.Cmd { 105 | return textinput.Blink 106 | } 107 | 108 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 109 | switch msg := msg.(type) { 110 | case button.Pressed: 111 | if validInputs(&m.inputs) { 112 | return m, tea.Quit 113 | } 114 | 115 | case tea.KeyMsg: 116 | switch msg.String() { 117 | case "ctrl+c", "esc": 118 | return m, tea.Quit 119 | 120 | case "tab", "shift+tab", "enter", "up", "down": 121 | s := msg.String() 122 | 123 | if s == "enter" && m.focusIndex == 2 && !m.button.Invalid { 124 | return m, tea.Quit 125 | } 126 | 127 | if s == "up" || s == "shift+tab" { 128 | m.focusIndex-- 129 | } else { 130 | m.focusIndex++ 131 | } 132 | 133 | if m.focusIndex >= 3 { 134 | m.focusIndex = 0 135 | } else if m.focusIndex < 0 { 136 | m.focusIndex = 2 137 | } 138 | 139 | cmds := make([]tea.Cmd, len(m.inputs)) 140 | for i := 0; i < 2; i++ { 141 | if i == m.focusIndex { 142 | // Set focused state 143 | cmds[i] = m.inputs[i].Focus() 144 | m.inputs[i].PromptStyle = focusedStyle 145 | m.inputs[i].TextStyle = focusedStyle 146 | continue 147 | } 148 | // Remove focused state 149 | m.inputs[i].Blur() 150 | m.inputs[i].PromptStyle = noStyle 151 | m.inputs[i].TextStyle = noStyle 152 | } 153 | 154 | if m.focusIndex == 2 { 155 | m.button.Focus() 156 | } else { 157 | m.button.Blur() 158 | } 159 | 160 | return m, tea.Batch(cmds...) 161 | } 162 | } 163 | 164 | // Handle character input and blinking 165 | cmd := m.updateInputs(msg) 166 | 167 | if validInputs(&m.inputs) { 168 | m.button.Invalid = false 169 | } 170 | 171 | return m, cmd 172 | } 173 | 174 | func (m *Model) updateInputs(msg tea.Msg) tea.Cmd { 175 | cmds := make([]tea.Cmd, len(m.inputs)+1) 176 | // Only text inputs with Focus() set will respond, so it's safe to simply 177 | // update all of them here without any further logic. 178 | for i := range m.inputs { 179 | m.inputs[i], cmds[i] = m.inputs[i].Update(msg) 180 | } 181 | m.button, cmds[2] = m.button.Update(msg) 182 | return tea.Batch(cmds...) 183 | } 184 | 185 | func (m Model) View() string { 186 | var b strings.Builder 187 | 188 | for i := range m.inputs { 189 | b.WriteString(m.inputs[i].View()) 190 | b.WriteRune('\n') 191 | } 192 | b.WriteString(m.button.View()) 193 | return b.String() 194 | } 195 | -------------------------------------------------------------------------------- /pkg/model/timeinput.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Parseable, Inc 2 | // 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package model 18 | 19 | import ( 20 | "fmt" 21 | "pb/pkg/model/datetime" 22 | "time" 23 | 24 | "github.com/charmbracelet/bubbles/key" 25 | "github.com/charmbracelet/bubbles/list" 26 | tea "github.com/charmbracelet/bubbletea" 27 | "github.com/charmbracelet/lipgloss" 28 | ) 29 | 30 | var rangeNavigationMap = []string{ 31 | "list", "start", "end", 32 | } 33 | 34 | type endTimeKeyBind struct { 35 | ResetTime key.Binding 36 | Ok key.Binding 37 | } 38 | 39 | func (k endTimeKeyBind) ShortHelp() []key.Binding { 40 | return []key.Binding{k.ResetTime, k.Ok} 41 | } 42 | 43 | func (k endTimeKeyBind) FullHelp() [][]key.Binding { 44 | return [][]key.Binding{ 45 | {k.ResetTime}, 46 | {k.Ok}, 47 | } 48 | } 49 | 50 | var endHelpBinds = endTimeKeyBind{ 51 | ResetTime: key.NewBinding( 52 | key.WithKeys("ctrl+{"), 53 | key.WithHelp("ctrl+{", "change end time to current time"), 54 | ), 55 | Ok: key.NewBinding( 56 | key.WithKeys("enter"), 57 | key.WithHelp("enter", "save and go back"), 58 | ), 59 | } 60 | 61 | type TimeInputModel struct { 62 | start datetime.Model 63 | end datetime.Model 64 | list list.Model 65 | focus int 66 | } 67 | 68 | func (m *TimeInputModel) StartValueUtc() string { 69 | return m.start.ValueUtc() 70 | } 71 | 72 | func (m *TimeInputModel) EndValueUtc() string { 73 | return m.end.ValueUtc() 74 | } 75 | 76 | func (m *TimeInputModel) SetStart(t time.Time) { 77 | m.start.SetTime(t) 78 | } 79 | 80 | func (m *TimeInputModel) SetEnd(t time.Time) { 81 | m.end.SetTime(t) 82 | } 83 | 84 | func (m *TimeInputModel) focusSelected() { 85 | m.start.Blur() 86 | m.end.Blur() 87 | 88 | switch m.currentFocus() { 89 | case "start": 90 | m.start.Focus() 91 | case "end": 92 | m.end.Focus() 93 | } 94 | } 95 | 96 | func (m *TimeInputModel) Navigate(key tea.KeyMsg) { 97 | switch key.String() { 98 | case "shift+tab": 99 | if m.focus == 0 { 100 | m.focus = len(rangeNavigationMap) 101 | } 102 | m.focus-- 103 | case "tab": 104 | if m.focus == len(rangeNavigationMap)-1 { 105 | m.focus = -1 106 | } 107 | m.focus++ 108 | default: 109 | return 110 | } 111 | } 112 | 113 | func (m *TimeInputModel) currentFocus() string { 114 | return rangeNavigationMap[m.focus] 115 | } 116 | 117 | // NewTimeInputModel creates a new model 118 | func NewTimeInputModel(startTime, endTime time.Time) TimeInputModel { 119 | list := NewTimeRangeModel() 120 | inputStyle := lipgloss.NewStyle().Inherit(baseStyle).Bold(true).Width(6).Align(lipgloss.Center) 121 | 122 | start := datetime.New(inputStyle.Render("start")) 123 | start.SetTime(startTime) 124 | start.Focus() 125 | end := datetime.New(inputStyle.Render("end")) 126 | end.SetTime(endTime) 127 | 128 | return TimeInputModel{ 129 | start: start, 130 | end: end, 131 | list: list, 132 | focus: 0, 133 | } 134 | } 135 | 136 | func (m TimeInputModel) FullHelp() [][]key.Binding { 137 | return endHelpBinds.FullHelp() 138 | } 139 | 140 | func (m TimeInputModel) Init() tea.Cmd { 141 | return nil 142 | } 143 | 144 | func (m TimeInputModel) Update(msg tea.Msg) (TimeInputModel, tea.Cmd) { 145 | var cmd tea.Cmd 146 | key, ok := msg.(tea.KeyMsg) 147 | if !ok { 148 | return m, nil 149 | } 150 | 151 | switch key.Type { 152 | case tea.KeyShiftTab, tea.KeyTab: 153 | m.Navigate(key) 154 | m.focusSelected() 155 | 156 | case tea.KeyCtrlOpenBracket: 157 | m.end.SetTime(time.Now()) 158 | default: 159 | switch m.currentFocus() { 160 | case "list": 161 | m.list, cmd = m.list.Update(key) 162 | duration := m.list.SelectedItem().(timeDurationItem).duration 163 | m.SetStart(m.end.Time().Add(duration)) 164 | case "start": 165 | m.start, cmd = m.start.Update(key) 166 | case "end": 167 | m.end, cmd = m.end.Update(key) 168 | } 169 | } 170 | 171 | return m, cmd 172 | } 173 | 174 | func (m TimeInputModel) View() string { 175 | listStyle := &borderedStyle 176 | startStyle := &borderedStyle 177 | endStyle := &borderedStyle 178 | 179 | switch m.currentFocus() { 180 | 181 | case "list": 182 | listStyle = &borderedFocusStyle 183 | case "start": 184 | startStyle = &borderedFocusStyle 185 | case "end": 186 | endStyle = &borderedFocusStyle 187 | } 188 | 189 | list := lipgloss.NewStyle().PaddingLeft(1).Render(m.list.View()) 190 | 191 | left := listStyle.Render(lipgloss.PlaceHorizontal(27, lipgloss.Left, list)) 192 | right := fmt.Sprintf("%s\n\n%s", 193 | startStyle.Render(m.start.View()), 194 | endStyle.Render(m.end.View()), 195 | ) 196 | center := baseStyle.Render("│\n│\n│\n│") 197 | center = lipgloss.PlaceHorizontal(5, lipgloss.Center, center) 198 | 199 | page := lipgloss.JoinHorizontal(lipgloss.Center, left, center, right) 200 | 201 | return page 202 | } 203 | -------------------------------------------------------------------------------- /cmd/generate.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Parseable, Inc 2 | // 3 | // This program is free software: you can redistribute it and/or modify 4 | // it under the terms of the GNU Affero General Public License as published by 5 | // the Free Software Foundation, either version 3 of the License, or 6 | // (at your option) any later version. 7 | // 8 | // This program is distributed in the hope that it will be useful 9 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | // GNU Affero General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU Affero General Public License 14 | // along with this program. If not, see . 15 | 16 | package cmd 17 | 18 | import ( 19 | "bytes" 20 | "encoding/json" 21 | "fmt" 22 | "io" 23 | "net/http" 24 | "os" 25 | 26 | "pb/pkg/common" 27 | internalHTTP "pb/pkg/http" 28 | 29 | "github.com/spf13/cobra" 30 | ) 31 | 32 | const ( 33 | generateStaticSchemaPath = "/logstream/schema/detect" 34 | ) 35 | 36 | var GenerateSchemaCmd = &cobra.Command{ 37 | Use: "generate", 38 | Short: "Generate Schema for JSON", 39 | Example: "pb schema generate --file=test.json", 40 | RunE: func(cmd *cobra.Command, _ []string) error { 41 | // Get the file path from the `--file` flag 42 | filePath, err := cmd.Flags().GetString("file") 43 | if err != nil { 44 | return fmt.Errorf(common.Red+"failed to read file flag: %w"+common.Reset, err) 45 | } 46 | 47 | if filePath == "" { 48 | return fmt.Errorf(common.Red + "file flag is required" + common.Reset) 49 | } 50 | 51 | // Read the file content 52 | fileContent, err := os.ReadFile(filePath) 53 | if err != nil { 54 | return fmt.Errorf(common.Red+"failed to read file %s: %w"+common.Reset, filePath, err) 55 | } 56 | 57 | // Initialize HTTP client 58 | client := internalHTTP.DefaultClient(&DefaultProfile) 59 | 60 | // Create the HTTP request 61 | req, err := client.NewRequest(http.MethodPost, generateStaticSchemaPath, bytes.NewBuffer(fileContent)) 62 | if err != nil { 63 | return fmt.Errorf(common.Red+"failed to create new request: %w"+common.Reset, err) 64 | } 65 | 66 | // Set Content-Type header 67 | req.Header.Set("Content-Type", "application/json") 68 | 69 | // Execute the request 70 | resp, err := client.Client.Do(req) 71 | if err != nil { 72 | return fmt.Errorf(common.Red+"request execution failed: %w"+common.Reset, err) 73 | } 74 | defer resp.Body.Close() 75 | 76 | // Check for non-200 status codes 77 | if resp.StatusCode != http.StatusOK { 78 | body, _ := io.ReadAll(resp.Body) 79 | fmt.Printf(common.Red+"Error response: %s\n"+common.Reset, string(body)) 80 | return fmt.Errorf(common.Red+"non-200 status code received: %s"+common.Reset, resp.Status) 81 | } 82 | 83 | // Parse and print the response 84 | respBody, err := io.ReadAll(resp.Body) 85 | if err != nil { 86 | return fmt.Errorf(common.Red+"failed to read response body: %w"+common.Reset, err) 87 | } 88 | 89 | var prettyJSON bytes.Buffer 90 | if err := json.Indent(&prettyJSON, respBody, "", " "); err != nil { 91 | return fmt.Errorf(common.Red+"failed to format response as JSON: %w"+common.Reset, err) 92 | } 93 | 94 | fmt.Println(common.Green + prettyJSON.String() + common.Reset) 95 | return nil 96 | }, 97 | } 98 | 99 | var CreateSchemaCmd = &cobra.Command{ 100 | Use: "create", 101 | Short: "Create Schema for a Parseable stream", 102 | Example: "pb schema create --stream=my_stream --file=schema.json", 103 | RunE: func(cmd *cobra.Command, _ []string) error { 104 | // Get the stream name from the `--stream` flag 105 | streamName, err := cmd.Flags().GetString("stream") 106 | if err != nil { 107 | return fmt.Errorf(common.Red+"failed to read stream flag: %w"+common.Reset, err) 108 | } 109 | 110 | if streamName == "" { 111 | return fmt.Errorf(common.Red + "stream flag is required" + common.Reset) 112 | } 113 | 114 | // Get the file path from the `--file` flag 115 | filePath, err := cmd.Flags().GetString("file") 116 | if err != nil { 117 | return fmt.Errorf(common.Red+"failed to read config flag: %w"+common.Reset, err) 118 | } 119 | 120 | if filePath == "" { 121 | return fmt.Errorf(common.Red + "file path flag is required" + common.Reset) 122 | } 123 | 124 | // Read the JSON schema file 125 | schemaContent, err := os.ReadFile(filePath) 126 | if err != nil { 127 | return fmt.Errorf(common.Red+"failed to read schema file %s: %w"+common.Reset, filePath, err) 128 | } 129 | 130 | // Initialize HTTP client 131 | client := internalHTTP.DefaultClient(&DefaultProfile) 132 | 133 | // Construct the API path 134 | apiPath := fmt.Sprintf("/logstream/%s", streamName) 135 | 136 | // Create the HTTP PUT request 137 | req, err := client.NewRequest(http.MethodPut, apiPath, bytes.NewBuffer(schemaContent)) 138 | if err != nil { 139 | return fmt.Errorf(common.Red+"failed to create new request: %w"+common.Reset, err) 140 | } 141 | 142 | // Set custom headers 143 | req.Header.Set("Content-Type", "application/json") 144 | req.Header.Set("X-P-Static-Schema-Flag", "true") 145 | 146 | // Execute the request 147 | resp, err := client.Client.Do(req) 148 | if err != nil { 149 | return fmt.Errorf(common.Red+"request execution failed: %w"+common.Reset, err) 150 | } 151 | defer resp.Body.Close() 152 | 153 | // Check for non-200 status codes 154 | if resp.StatusCode != http.StatusOK { 155 | body, _ := io.ReadAll(resp.Body) 156 | fmt.Printf(common.Red+"Error response: %s\n"+common.Reset, string(body)) 157 | return fmt.Errorf(common.Red+"non-200 status code received: %s"+common.Reset, resp.Status) 158 | } 159 | 160 | // Parse and print the response 161 | respBody, err := io.ReadAll(resp.Body) 162 | if err != nil { 163 | return fmt.Errorf(common.Red+"failed to read response body: %w"+common.Reset, err) 164 | } 165 | 166 | fmt.Println(common.Green + string(respBody) + common.Reset) 167 | return nil 168 | }, 169 | } 170 | 171 | func init() { 172 | // Add the `--file` flag to the command 173 | GenerateSchemaCmd.Flags().StringP("file", "f", "", "Path to the JSON file to generate schema") 174 | CreateSchemaCmd.Flags().StringP("stream", "s", "", "Name of the stream to associate with the schema") 175 | CreateSchemaCmd.Flags().StringP("file", "f", "", "Path to the JSON file to create schema") 176 | } 177 | -------------------------------------------------------------------------------- /pkg/installer/uninstaller.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Parseable, Inc 2 | // 3 | // This program is free software: you can redistribute it and/or modify 4 | // it under the terms of the GNU Affero General Public License as published by 5 | // the Free Software Foundation, either version 3 of the License, or 6 | // (at your option) any later version. 7 | // 8 | // This program is distributed in the hope that it will be useful 9 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | // GNU Affero General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU Affero General Public License 14 | // along with this program. If not, see . 15 | 16 | package installer 17 | 18 | import ( 19 | "bufio" 20 | "context" 21 | "fmt" 22 | "os" 23 | "path/filepath" 24 | "pb/pkg/common" 25 | "pb/pkg/helm" 26 | "strings" 27 | "time" 28 | 29 | "github.com/manifoldco/promptui" 30 | apierrors "k8s.io/apimachinery/pkg/api/errors" 31 | v1 "k8s.io/apimachinery/pkg/apis/meta/v1" 32 | "k8s.io/apimachinery/pkg/util/yaml" 33 | "k8s.io/client-go/kubernetes" 34 | ) 35 | 36 | // Uninstaller uninstalls Parseable from the selected cluster 37 | func Uninstaller(verbose bool) error { 38 | // Define the installer file path 39 | homeDir, err := os.UserHomeDir() 40 | if err != nil { 41 | return fmt.Errorf("failed to get user home directory: %w", err) 42 | } 43 | installerFilePath := filepath.Join(homeDir, ".parseable", "pb", "installer.yaml") 44 | 45 | // Read the installer file 46 | data, err := os.ReadFile(installerFilePath) 47 | if err != nil { 48 | return fmt.Errorf("failed to read installer file: %w", err) 49 | } 50 | 51 | // Unmarshal the installer file content 52 | var entries []common.InstallerEntry 53 | if err := yaml.Unmarshal(data, &entries); err != nil { 54 | return fmt.Errorf("failed to parse installer file: %w", err) 55 | } 56 | 57 | // Prompt the user to select a cluster 58 | clusterNames := make([]string, len(entries)) 59 | for i, entry := range entries { 60 | clusterNames[i] = fmt.Sprintf("[Name: %s] [Namespace: %s]", entry.Name, entry.Namespace) 61 | } 62 | 63 | promptClusterSelect := promptui.Select{ 64 | Label: "Select a cluster to delete", 65 | Items: clusterNames, 66 | Templates: &promptui.SelectTemplates{ 67 | Label: "{{ `Select Cluster` | yellow }}", 68 | Active: "▸ {{ . | yellow }}", // Yellow arrow for active selection 69 | Inactive: " {{ . | yellow }}", 70 | Selected: "{{ `Selected:` | green }} {{ . | green }}", 71 | }, 72 | } 73 | 74 | index, _, err := promptClusterSelect.Run() 75 | if err != nil { 76 | return fmt.Errorf("failed to prompt for cluster selection: %v", err) 77 | } 78 | 79 | selectedCluster := entries[index] 80 | 81 | // Confirm deletion 82 | confirm, err := promptUserConfirmation(fmt.Sprintf(common.Yellow+"Do you still want to proceed with deleting the cluster '%s'?", selectedCluster.Name)) 83 | if err != nil { 84 | return fmt.Errorf("failed to get user confirmation: %v", err) 85 | } 86 | if !confirm { 87 | fmt.Println(common.Yellow + "Uninstall canceled." + common.Reset) 88 | return nil 89 | } 90 | 91 | // Helm application configuration 92 | helmApp := helm.Helm{ 93 | ReleaseName: selectedCluster.Name, 94 | Namespace: selectedCluster.Namespace, 95 | RepoName: "parseable", 96 | RepoURL: "https://charts.parseable.com", 97 | ChartName: "parseable", 98 | Version: selectedCluster.Version, 99 | } 100 | 101 | // Create a spinner 102 | spinner := common.CreateDeploymentSpinner("Uninstalling Parseable in ") 103 | 104 | // Redirect standard output if not in verbose mode 105 | var oldStdout *os.File 106 | if !verbose { 107 | oldStdout = os.Stdout 108 | _, w, _ := os.Pipe() 109 | os.Stdout = w 110 | } 111 | 112 | spinner.Start() 113 | 114 | // Run Helm uninstall 115 | _, err = helm.Uninstall(helmApp, verbose) 116 | spinner.Stop() 117 | 118 | // Restore stdout 119 | if !verbose { 120 | os.Stdout = oldStdout 121 | } 122 | 123 | if err != nil { 124 | return fmt.Errorf("failed to uninstall Parseable: %v", err) 125 | } 126 | 127 | // Call to clean up the secret instead of the namespace 128 | fmt.Printf(common.Yellow+"Cleaning up 'parseable-env-secret' in namespace '%s'...\n"+common.Reset, selectedCluster.Namespace) 129 | cleanupErr := cleanupParseableSecret(selectedCluster.Namespace) 130 | if cleanupErr != nil { 131 | return fmt.Errorf("failed to clean up secret in namespace '%s': %v", selectedCluster.Namespace, cleanupErr) 132 | } 133 | 134 | // Print success banner 135 | fmt.Printf(common.Green+"Successfully uninstalled Parseable from namespace '%s'.\n"+common.Reset, selectedCluster.Namespace) 136 | 137 | return nil 138 | } 139 | 140 | // promptUserConfirmation prompts the user for a yes/no confirmation 141 | func promptUserConfirmation(message string) (bool, error) { 142 | reader := bufio.NewReader(os.Stdin) 143 | fmt.Printf("%s [y/N]: ", message) 144 | response, err := reader.ReadString('\n') 145 | if err != nil { 146 | return false, err 147 | } 148 | response = strings.TrimSpace(strings.ToLower(response)) 149 | return response == "y" || response == "yes", nil 150 | } 151 | 152 | // cleanupParseableSecret deletes the "parseable-env-secret" in the specified namespace using Kubernetes client-go 153 | func cleanupParseableSecret(namespace string) error { 154 | // Load the kubeconfig 155 | config, err := loadKubeConfig() 156 | if err != nil { 157 | return fmt.Errorf("failed to load kubeconfig: %w", err) 158 | } 159 | 160 | // Create the clientset 161 | clientset, err := kubernetes.NewForConfig(config) 162 | if err != nil { 163 | return fmt.Errorf("failed to create Kubernetes client: %v", err) 164 | } 165 | 166 | // Create a context with a timeout for secret deletion 167 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 168 | defer cancel() 169 | 170 | // Define the secret name 171 | secretName := "parseable-env-secret" 172 | 173 | // Delete the secret 174 | err = clientset.CoreV1().Secrets(namespace).Delete(ctx, secretName, v1.DeleteOptions{}) 175 | if err != nil { 176 | if apierrors.IsNotFound(err) { 177 | fmt.Printf("Secret '%s' not found in namespace '%s'. Nothing to delete.\n", secretName, namespace) 178 | return nil 179 | } 180 | return fmt.Errorf("error deleting secret '%s' in namespace '%s': %v", secretName, namespace, err) 181 | } 182 | 183 | // Confirm the deletion 184 | fmt.Printf("Secret '%s' successfully deleted from namespace '%s'.\n", secretName, namespace) 185 | 186 | return nil 187 | } 188 | -------------------------------------------------------------------------------- /pkg/iterator/iterator_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Parseable, Inc 2 | // 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package iterator 18 | 19 | import ( 20 | "testing" 21 | "time" 22 | 23 | "golang.org/x/exp/slices" 24 | ) 25 | 26 | // dummy query provider can be instantiated with counts 27 | type DummyQueryProvider struct { 28 | state map[string]int 29 | } 30 | 31 | func (d *DummyQueryProvider) StartTime() time.Time { 32 | keys := make([]time.Time, 0, len(d.state)) 33 | for k := range d.state { 34 | parsedTime, _ := time.Parse(time.RFC822Z, k) 35 | keys = append(keys, parsedTime) 36 | } 37 | return slices.MinFunc(keys, func(a time.Time, b time.Time) int { 38 | return a.Compare(b) 39 | }) 40 | } 41 | 42 | func (d *DummyQueryProvider) EndTime() time.Time { 43 | keys := make([]time.Time, 0, len(d.state)) 44 | for k := range d.state { 45 | parsedTime, _ := time.Parse(time.RFC822Z, k) 46 | keys = append(keys, parsedTime) 47 | } 48 | maxTime := slices.MaxFunc(keys, func(a time.Time, b time.Time) int { 49 | return a.Compare(b) 50 | }) 51 | 52 | return maxTime.Add(time.Minute) 53 | } 54 | 55 | func (*DummyQueryProvider) QueryRunnerFunc() func(time.Time, time.Time) ([]map[string]interface{}, error) { 56 | return func(_, _ time.Time) ([]map[string]interface{}, error) { 57 | return make([]map[string]interface{}, 0), nil 58 | } 59 | } 60 | 61 | func (d *DummyQueryProvider) HasDataFunc() func(time.Time, time.Time) bool { 62 | return func(t1, _ time.Time) bool { 63 | val, isExists := d.state[t1.Format(time.RFC822Z)] 64 | if isExists && val > 0 { 65 | return true 66 | } 67 | return false 68 | } 69 | } 70 | 71 | func DefaultTestScenario() DummyQueryProvider { 72 | return DummyQueryProvider{ 73 | state: map[string]int{ 74 | "02 Jan 06 15:04 +0000": 10, 75 | "02 Jan 06 15:05 +0000": 0, 76 | "02 Jan 06 15:06 +0000": 0, 77 | "02 Jan 06 15:07 +0000": 10, 78 | "02 Jan 06 15:08 +0000": 0, 79 | "02 Jan 06 15:09 +0000": 3, 80 | "02 Jan 06 15:10 +0000": 0, 81 | "02 Jan 06 15:11 +0000": 0, 82 | "02 Jan 06 15:12 +0000": 1, 83 | }, 84 | } 85 | } 86 | 87 | func TestIteratorConstruct(t *testing.T) { 88 | scenario := DefaultTestScenario() 89 | iter := NewQueryIterator(scenario.StartTime(), scenario.EndTime(), true, scenario.QueryRunnerFunc(), scenario.HasDataFunc()) 90 | 91 | currentWindow := iter.windows[0] 92 | if !(currentWindow.time == scenario.StartTime()) { 93 | t.Fatalf("window time does not match start, expected %s, actual %s", scenario.StartTime().String(), currentWindow.time.String()) 94 | } 95 | } 96 | 97 | func TestIteratorAscending(t *testing.T) { 98 | scenario := DefaultTestScenario() 99 | iter := NewQueryIterator(scenario.StartTime(), scenario.EndTime(), true, scenario.QueryRunnerFunc(), scenario.HasDataFunc()) 100 | 101 | iter.Next() 102 | // busy loop waiting for iter to be ready 103 | for !iter.Ready() { 104 | continue 105 | } 106 | 107 | currentWindow := iter.windows[iter.index] 108 | checkCurrentWindowIndex("02 Jan 06 15:04 +0000", currentWindow, t) 109 | 110 | // next should populate new window 111 | if iter.finished == true { 112 | t.Fatalf("Iter finished before expected") 113 | } 114 | if iter.ready == false { 115 | t.Fatalf("Iter is not ready when it should be") 116 | } 117 | 118 | iter.Next() 119 | // busy loop waiting for iter to be ready 120 | for !iter.Ready() { 121 | continue 122 | } 123 | 124 | currentWindow = iter.windows[iter.index] 125 | checkCurrentWindowIndex("02 Jan 06 15:07 +0000", currentWindow, t) 126 | 127 | iter.Next() 128 | // busy loop waiting for iter to be ready 129 | for !iter.Ready() { 130 | continue 131 | } 132 | 133 | currentWindow = iter.windows[iter.index] 134 | checkCurrentWindowIndex("02 Jan 06 15:09 +0000", currentWindow, t) 135 | 136 | iter.Next() 137 | // busy loop waiting for iter to be ready 138 | for !iter.Ready() { 139 | continue 140 | } 141 | 142 | currentWindow = iter.windows[iter.index] 143 | checkCurrentWindowIndex("02 Jan 06 15:12 +0000", currentWindow, t) 144 | 145 | if iter.finished != true { 146 | t.Fatalf("iter should be finished now but it is not") 147 | } 148 | } 149 | 150 | func TestIteratorDescending(t *testing.T) { 151 | scenario := DefaultTestScenario() 152 | iter := NewQueryIterator(scenario.StartTime(), scenario.EndTime(), false, scenario.QueryRunnerFunc(), scenario.HasDataFunc()) 153 | 154 | iter.Next() 155 | // busy loop waiting for iter to be ready 156 | for !iter.Ready() { 157 | continue 158 | } 159 | 160 | currentWindow := iter.windows[iter.index] 161 | checkCurrentWindowIndex("02 Jan 06 15:12 +0000", currentWindow, t) 162 | 163 | // next should populate new window 164 | if iter.finished == true { 165 | t.Fatalf("Iter finished before expected") 166 | } 167 | if iter.ready == false { 168 | t.Fatalf("Iter is not ready when it should be") 169 | } 170 | 171 | iter.Next() 172 | // busy loop waiting for iter to be ready 173 | for !iter.Ready() { 174 | continue 175 | } 176 | 177 | currentWindow = iter.windows[iter.index] 178 | checkCurrentWindowIndex("02 Jan 06 15:09 +0000", currentWindow, t) 179 | 180 | iter.Next() 181 | // busy loop waiting for iter to be ready 182 | for !iter.Ready() { 183 | continue 184 | } 185 | 186 | currentWindow = iter.windows[iter.index] 187 | checkCurrentWindowIndex("02 Jan 06 15:07 +0000", currentWindow, t) 188 | 189 | iter.Next() 190 | // busy loop waiting for iter to be ready 191 | for !iter.Ready() { 192 | continue 193 | } 194 | 195 | currentWindow = iter.windows[iter.index] 196 | checkCurrentWindowIndex("02 Jan 06 15:04 +0000", currentWindow, t) 197 | 198 | if iter.finished != true { 199 | t.Fatalf("iter should be finished now but it is not") 200 | } 201 | } 202 | 203 | func checkCurrentWindowIndex(expectedValue string, currentWindow MinuteCheckPoint, t *testing.T) { 204 | expectedTime, _ := time.Parse(time.RFC822Z, expectedValue) 205 | if !(currentWindow.time == expectedTime) { 206 | t.Fatalf("window time does not match start, expected %s, actual %s", expectedTime.String(), currentWindow.time.String()) 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /pkg/model/role/role.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Parseable, Inc 2 | // 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package role 18 | 19 | import ( 20 | "fmt" 21 | "pb/pkg/model/button" 22 | "pb/pkg/model/selection" 23 | "strings" 24 | 25 | "github.com/charmbracelet/bubbles/textinput" 26 | tea "github.com/charmbracelet/bubbletea" 27 | "github.com/charmbracelet/lipgloss" 28 | ) 29 | 30 | var ( 31 | privileges = []string{"none", "admin", "editor", "writer", "reader", "ingestor"} 32 | navigationMapStreamTag = []string{"role", "stream", "tag", "button"} 33 | navigationMapStream = []string{"role", "stream", "button"} 34 | navigationMap = []string{"role", "button"} 35 | navigationMapNone = []string{"role"} 36 | ) 37 | 38 | // Style for role selection widget 39 | var ( 40 | FocusPrimary = lipgloss.AdaptiveColor{Light: "16", Dark: "226"} 41 | FocusSecondry = lipgloss.AdaptiveColor{Light: "18", Dark: "220"} 42 | 43 | StandardPrimary = lipgloss.AdaptiveColor{Light: "235", Dark: "255"} 44 | StandardSecondry = lipgloss.AdaptiveColor{Light: "238", Dark: "254"} 45 | 46 | focusedStyle = lipgloss.NewStyle().Foreground(FocusSecondry) 47 | blurredStyle = lipgloss.NewStyle().Foreground(StandardPrimary) 48 | selectionFocusStyle = lipgloss.NewStyle().Border(lipgloss.NormalBorder(), true).BorderForeground(StandardSecondry) 49 | selectionFocusStyleAlt = lipgloss.NewStyle().Border(lipgloss.DoubleBorder(), true).BorderForeground(FocusPrimary) 50 | selectionBlurStyle = lipgloss.NewStyle().Height(3).AlignVertical(lipgloss.Center).MarginLeft(1).MarginRight(1) 51 | ) 52 | 53 | type Model struct { 54 | focusIndex int 55 | navMap *[]string 56 | Selection selection.Model 57 | Stream textinput.Model 58 | Tag textinput.Model 59 | button button.Model 60 | Success bool 61 | } 62 | 63 | func (m *Model) Valid() bool { 64 | switch m.Selection.Value() { 65 | case "admin", "editor", "none": 66 | return true 67 | case "writer", "reader", "ingestor": 68 | return !(strings.Contains(m.Stream.Value(), " ") || m.Stream.Value() == "") 69 | } 70 | return true 71 | } 72 | 73 | func (m *Model) FocusSelected() { 74 | m.Selection.Blur() 75 | m.Selection.FocusStyle = selectionFocusStyle 76 | m.Stream.Blur() 77 | m.Stream.TextStyle = blurredStyle 78 | m.Stream.PromptStyle = blurredStyle 79 | m.Tag.Blur() 80 | m.Tag.TextStyle = blurredStyle 81 | m.Tag.PromptStyle = blurredStyle 82 | m.button.Blur() 83 | 84 | switch (*m.navMap)[m.focusIndex] { 85 | case "role": 86 | m.Selection.Focus() 87 | m.Selection.FocusStyle = selectionFocusStyleAlt 88 | case "stream": 89 | m.Stream.TextStyle = focusedStyle 90 | m.Stream.PromptStyle = focusedStyle 91 | m.Stream.Focus() 92 | case "tag": 93 | m.Tag.TextStyle = focusedStyle 94 | m.Tag.PromptStyle = focusedStyle 95 | m.Tag.Focus() 96 | case "button": 97 | m.button.Focus() 98 | } 99 | } 100 | 101 | func New() Model { 102 | selection := selection.New(privileges) 103 | selection.BlurredStyle = selectionBlurStyle 104 | 105 | button := button.New("Submit") 106 | button.FocusStyle = focusedStyle 107 | button.BlurredStyle = blurredStyle 108 | 109 | stream := textinput.New() 110 | stream.Prompt = "stream: " 111 | 112 | tag := textinput.New() 113 | tag.Prompt = "tag: " 114 | 115 | m := Model{ 116 | focusIndex: 0, 117 | navMap: &navigationMapNone, 118 | Selection: selection, 119 | Stream: stream, 120 | Tag: tag, 121 | button: button, 122 | Success: false, 123 | } 124 | 125 | m.FocusSelected() 126 | return m 127 | } 128 | 129 | func (m Model) Init() tea.Cmd { 130 | return nil 131 | } 132 | 133 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 134 | var cmd tea.Cmd 135 | switch msg := msg.(type) { 136 | case button.Pressed: 137 | m.Success = true 138 | return m, tea.Quit 139 | case tea.KeyMsg: 140 | // special cases for enter key 141 | if msg.Type == tea.KeyEnter { 142 | if m.Selection.Value() == "none" { 143 | m.Success = true 144 | return m, tea.Quit 145 | } 146 | if m.button.Focused() && !m.button.Invalid { 147 | m.button, cmd = m.button.Update(msg) 148 | return m, cmd 149 | } 150 | } 151 | 152 | switch msg.Type { 153 | case tea.KeyCtrlC: 154 | return m, tea.Quit 155 | case tea.KeyDown, tea.KeyTab, tea.KeyEnter: 156 | m.focusIndex++ 157 | if m.focusIndex >= len(*m.navMap) { 158 | m.focusIndex = 0 159 | } 160 | m.FocusSelected() 161 | case tea.KeyUp, tea.KeyShiftTab: 162 | m.focusIndex-- 163 | if m.focusIndex < 0 { 164 | m.focusIndex = len(*m.navMap) - 1 165 | } 166 | m.FocusSelected() 167 | default: 168 | switch (*m.navMap)[m.focusIndex] { 169 | case "role": 170 | m.Selection, cmd = m.Selection.Update(msg) 171 | switch m.Selection.Value() { 172 | case "admin", "editor": 173 | m.navMap = &navigationMap 174 | case "writer": 175 | m.navMap = &navigationMapStream 176 | case "reader": 177 | m.navMap = &navigationMapStreamTag 178 | case "ingestor": 179 | m.navMap = &navigationMapStream 180 | default: 181 | m.navMap = &navigationMapNone 182 | } 183 | case "stream": 184 | m.Stream, cmd = m.Stream.Update(msg) 185 | case "tag": 186 | m.Tag, cmd = m.Tag.Update(msg) 187 | case "button": 188 | m.button, cmd = m.button.Update(msg) 189 | } 190 | m.button.Invalid = !m.Valid() 191 | } 192 | } 193 | return m, cmd 194 | } 195 | 196 | func (m Model) View() string { 197 | var b strings.Builder 198 | 199 | for _, item := range *m.navMap { 200 | switch item { 201 | case "role": 202 | var buffer string 203 | if m.Selection.Focused() { 204 | buffer = lipgloss.JoinHorizontal(lipgloss.Center, "◀ ", m.Selection.View(), " ▶") 205 | } else { 206 | buffer = m.Selection.View() 207 | } 208 | fmt.Fprintln(&b, buffer) 209 | case "stream": 210 | fmt.Fprintln(&b, m.Stream.View()) 211 | case "tag": 212 | fmt.Fprintln(&b, m.Tag.View()) 213 | case "button": 214 | fmt.Fprintln(&b) 215 | fmt.Fprintln(&b, m.button.View()) 216 | } 217 | } 218 | 219 | if m.Selection.Value() == "none" { 220 | fmt.Fprintln(&b, blurredStyle.Render("Press enter to create user without a role")) 221 | } 222 | 223 | return b.String() 224 | } 225 | -------------------------------------------------------------------------------- /cmd/queryList.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Parseable, Inc 2 | // 3 | // This program is free software: you can redistribute it and/or modify 4 | // it under the terms of the GNU Affero General Public License as published by 5 | // the Free Software Foundation, either version 3 of the License, or 6 | // (at your option) any later version. 7 | // 8 | // This program is distributed in the hope that it will be useful 9 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | // GNU Affero General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU Affero General Public License 14 | // along with this program. If not, see . 15 | 16 | package cmd 17 | 18 | import ( 19 | "encoding/json" 20 | "fmt" 21 | "io" 22 | "net/http" 23 | "os" 24 | "pb/pkg/config" 25 | internalHTTP "pb/pkg/http" 26 | "pb/pkg/model" 27 | "strings" 28 | "time" 29 | 30 | "github.com/spf13/cobra" 31 | ) 32 | 33 | var SavedQueryList = &cobra.Command{ 34 | Use: "list", 35 | Example: "pb query list [-o | --output]", 36 | Short: "List of saved queries", 37 | Long: "\nShow the list of saved queries for active user", 38 | PreRunE: PreRunDefaultProfile, 39 | Run: func(_ *cobra.Command, _ []string) { 40 | client := internalHTTP.DefaultClient(&DefaultProfile) 41 | 42 | // Check if the output flag is set 43 | if outputFlag != "" { 44 | // Display all filters if output flag is set 45 | userConfig, err := config.ReadConfigFromFile() 46 | if err != nil { 47 | fmt.Println("Error reading Default Profile") 48 | } 49 | var userProfile config.Profile 50 | if profile, ok := userConfig.Profiles[userConfig.DefaultProfile]; ok { 51 | userProfile = profile 52 | } 53 | 54 | client := &http.Client{ 55 | Timeout: time.Second * 60, 56 | } 57 | userSavedQueries := fetchFilters(client, &userProfile) 58 | // Collect all filter titles in a slice and join with commas 59 | var filterDetails []string 60 | 61 | if outputFlag == "json" { 62 | // If JSON output is requested, marshal the saved queries to JSON 63 | jsonOutput, err := json.MarshalIndent(userSavedQueries, "", " ") 64 | if err != nil { 65 | fmt.Println("Error converting saved queries to JSON:", err) 66 | return 67 | } 68 | if string(jsonOutput) == "null" { 69 | fmt.Println("[]") 70 | return 71 | } 72 | fmt.Println(string(jsonOutput)) 73 | } else { 74 | for _, query := range userSavedQueries { 75 | // Build the line conditionally 76 | var parts []string 77 | if query.Title != "" { 78 | parts = append(parts, query.Title) 79 | } 80 | if query.Stream != "" { 81 | parts = append(parts, query.Stream) 82 | } 83 | if query.Desc != "" { 84 | parts = append(parts, query.Desc) 85 | } 86 | if query.From != "" { 87 | parts = append(parts, query.From) 88 | } 89 | if query.To != "" { 90 | parts = append(parts, query.To) 91 | } 92 | 93 | // Join parts with commas and print each query on a new line 94 | fmt.Println(strings.Join(parts, ", ")) 95 | } 96 | } 97 | // Print all titles as a single line, comma-separated 98 | fmt.Println(strings.Join(filterDetails, " ")) 99 | return 100 | 101 | } 102 | 103 | // Normal Saved Queries Menu if output flag not set 104 | p := model.SavedQueriesMenu() 105 | if _, err := p.Run(); err != nil { 106 | os.Exit(1) 107 | } 108 | 109 | a := model.QueryToApply() 110 | d := model.QueryToDelete() 111 | if a.Stream() != "" { 112 | savedQueryToPbQuery(a.Stream(), a.StartTime(), a.EndTime()) 113 | } 114 | if d.SavedQueryID() != "" { 115 | deleteSavedQuery(&client, d.SavedQueryID(), d.Title()) 116 | } 117 | }, 118 | } 119 | 120 | // Delete a saved query from the list. 121 | func deleteSavedQuery(client *internalHTTP.HTTPClient, savedQueryID, title string) { 122 | fmt.Printf("\nAttempting to delete '%s'", title) 123 | deleteURL := `filters/` + savedQueryID 124 | req, err := client.NewRequest("DELETE", deleteURL, nil) 125 | if err != nil { 126 | fmt.Println("Failed to delete the saved query with error: ", err) 127 | } 128 | 129 | resp, err := client.Client.Do(req) 130 | if err != nil { 131 | return 132 | } 133 | defer resp.Body.Close() 134 | 135 | if resp.StatusCode == 200 { 136 | fmt.Printf("\nSaved Query deleted\n\n") 137 | } 138 | } 139 | 140 | // Convert a saved query to executable pb query 141 | func savedQueryToPbQuery(query string, start string, end string) { 142 | var timeStamps string 143 | if start == "" || end == "" { 144 | timeStamps = `` 145 | } else { 146 | startFormatted := formatToRFC3339(start) 147 | endFormatted := formatToRFC3339(end) 148 | timeStamps = ` --from=` + startFormatted + ` --to=` + endFormatted 149 | } 150 | _ = `pb query run ` + query + timeStamps 151 | } 152 | 153 | // Parses all UTC time format from string to time interface 154 | func parseTimeToFormat(input string) (time.Time, error) { 155 | // List of possible formats 156 | formats := []string{ 157 | time.RFC3339, 158 | "2006-01-02 15:04:05", 159 | "2006-01-02", 160 | "01/02/2006 15:04:05", 161 | "02-Jan-2006 15:04:05 MST", 162 | "2006-01-02T15:04:05Z", 163 | "02-Jan-2006", 164 | } 165 | 166 | var err error 167 | var t time.Time 168 | 169 | for _, format := range formats { 170 | t, err = time.Parse(format, input) 171 | if err == nil { 172 | return t, nil 173 | } 174 | } 175 | 176 | return t, fmt.Errorf("unable to parse time: %s", input) 177 | } 178 | 179 | // Converts to RFC3339 180 | func convertTime(input string) (string, error) { 181 | t, err := parseTimeToFormat(input) 182 | if err != nil { 183 | return "", err 184 | } 185 | 186 | return t.Format(time.RFC3339), nil 187 | } 188 | 189 | // Converts User inputted time to string type RFC3339 time 190 | func formatToRFC3339(time string) string { 191 | var formattedTime string 192 | if len(strings.Fields(time)) > 1 { 193 | newTime := strings.Fields(time)[0:2] 194 | rfc39990time, err := convertTime(strings.Join(newTime, " ")) 195 | if err != nil { 196 | fmt.Println("error formatting time") 197 | } 198 | formattedTime = rfc39990time 199 | } else { 200 | rfc39990time, err := convertTime(time) 201 | if err != nil { 202 | fmt.Println("error formatting time") 203 | } 204 | formattedTime = rfc39990time 205 | } 206 | return formattedTime 207 | } 208 | 209 | func init() { 210 | // Add the output flag to the SavedQueryList command 211 | SavedQueryList.Flags().StringVarP(&outputFlag, "output", "o", "", "Output format (text or json)") 212 | } 213 | 214 | type Item struct { 215 | ID string `json:"id"` 216 | Title string `json:"title"` 217 | Stream string `json:"stream"` 218 | Desc string `json:"desc"` 219 | From string `json:"from,omitempty"` 220 | To string `json:"to,omitempty"` 221 | } 222 | 223 | func fetchFilters(client *http.Client, profile *config.Profile) []Item { 224 | endpoint := fmt.Sprintf("%s/%s", profile.URL, "api/v1/filters") 225 | req, err := http.NewRequest("GET", endpoint, nil) 226 | if err != nil { 227 | fmt.Println("Error creating request:", err) 228 | return nil 229 | } 230 | 231 | req.SetBasicAuth(profile.Username, profile.Password) 232 | req.Header.Add("Content-Type", "application/json") 233 | resp, err := client.Do(req) 234 | if err != nil { 235 | fmt.Println("Error making request:", err) 236 | return nil 237 | } 238 | defer resp.Body.Close() 239 | 240 | body, err := io.ReadAll(resp.Body) 241 | if err != nil { 242 | fmt.Println("Error reading response body:", err) 243 | return nil 244 | } 245 | 246 | var filters []model.Filter 247 | err = json.Unmarshal(body, &filters) 248 | if err != nil { 249 | fmt.Println("Error unmarshalling response:", err) 250 | return nil 251 | } 252 | 253 | // This returns only the SQL type filters 254 | var userSavedQueries []Item 255 | for _, filter := range filters { 256 | 257 | queryBytes, _ := json.Marshal(filter.Query.FilterQuery) 258 | 259 | userSavedQuery := Item{ 260 | ID: filter.FilterID, 261 | Title: filter.FilterName, 262 | Stream: filter.StreamName, 263 | Desc: string(queryBytes), 264 | From: filter.TimeFilter.From, 265 | To: filter.TimeFilter.To, 266 | } 267 | userSavedQueries = append(userSavedQueries, userSavedQuery) 268 | 269 | } 270 | return userSavedQueries 271 | } 272 | -------------------------------------------------------------------------------- /cmd/cluster.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Parseable, Inc 2 | // 3 | // This program is free software: you can redistribute it and/or modify 4 | // it under the terms of the GNU Affero General Public License as published by 5 | // the Free Software Foundation, either version 3 of the License, or 6 | // (at your option) any later version. 7 | // 8 | // This program is distributed in the hope that it will be useful 9 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | // GNU Affero General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU Affero General Public License 14 | // along with this program. If not, see . 15 | 16 | package cmd 17 | 18 | import ( 19 | "context" 20 | "fmt" 21 | "log" 22 | "os" 23 | "pb/pkg/common" 24 | "pb/pkg/helm" 25 | "pb/pkg/installer" 26 | 27 | "github.com/olekukonko/tablewriter" 28 | "github.com/spf13/cobra" 29 | "gopkg.in/yaml.v2" 30 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 31 | "k8s.io/client-go/kubernetes" 32 | ) 33 | 34 | var verbose bool 35 | 36 | var InstallOssCmd = &cobra.Command{ 37 | Use: "install", 38 | Short: "Deploy Parseable", 39 | Example: "pb cluster install", 40 | Run: func(cmd *cobra.Command, _ []string) { 41 | // Add verbose flag 42 | cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Enable verbose logging") 43 | installer.Installer(verbose) 44 | }, 45 | } 46 | 47 | // ListOssCmd lists the Parseable OSS servers 48 | var ListOssCmd = &cobra.Command{ 49 | Use: "list", 50 | Short: "List available Parseable servers", 51 | Example: "pb list", 52 | Run: func(_ *cobra.Command, _ []string) { 53 | _, err := common.PromptK8sContext() 54 | if err != nil { 55 | log.Fatalf("Failed to prompt for kubernetes context: %v", err) 56 | } 57 | 58 | // Read the installer data from the ConfigMap 59 | entries, err := common.ReadInstallerConfigMap() 60 | if err != nil { 61 | log.Fatalf("Failed to list servers: %v", err) 62 | } 63 | 64 | // Check if there are no entries 65 | if len(entries) == 0 { 66 | fmt.Println("No clusters found.") 67 | return 68 | } 69 | 70 | // Display the entries in a table format 71 | table := tablewriter.NewWriter(os.Stdout) 72 | table.SetHeader([]string{"Name", "Namespace", "Version", "Status"}) 73 | 74 | for _, entry := range entries { 75 | table.Append([]string{entry.Name, entry.Namespace, entry.Version, entry.Status}) 76 | } 77 | 78 | table.Render() 79 | }, 80 | } 81 | 82 | // ShowValuesCmd lists the Parseable OSS servers 83 | var ShowValuesCmd = &cobra.Command{ 84 | Use: "show values", 85 | Short: "Show values available in Parseable servers", 86 | Example: "pb show values", 87 | Run: func(_ *cobra.Command, _ []string) { 88 | _, err := common.PromptK8sContext() 89 | if err != nil { 90 | log.Fatalf("Failed to prompt for Kubernetes context: %v", err) 91 | } 92 | 93 | // Read the installer data from the ConfigMap 94 | entries, err := common.ReadInstallerConfigMap() 95 | if err != nil { 96 | log.Fatalf("Failed to list OSS servers: %v", err) 97 | } 98 | 99 | // Check if there are no entries 100 | if len(entries) == 0 { 101 | fmt.Println("No OSS servers found.") 102 | return 103 | } 104 | 105 | // Prompt user to select a cluster 106 | selectedCluster, err := common.PromptClusterSelection(entries) 107 | if err != nil { 108 | log.Fatalf("Failed to select a cluster: %v", err) 109 | } 110 | 111 | values, err := helm.GetReleaseValues(selectedCluster.Name, selectedCluster.Namespace) 112 | if err != nil { 113 | log.Fatalf("Failed to get values for release: %v", err) 114 | } 115 | 116 | // Marshal values to YAML for nice formatting 117 | yamlOutput, err := yaml.Marshal(values) 118 | if err != nil { 119 | log.Fatalf("Failed to marshal values to YAML: %v", err) 120 | } 121 | 122 | // Print the YAML output 123 | fmt.Println(string(yamlOutput)) 124 | 125 | // Print instructions for fetching secret values 126 | fmt.Printf("\nTo get secret values of the Parseable cluster, run the following command:\n") 127 | fmt.Printf("kubectl get secret -n %s parseable-env-secret -o jsonpath='{.data}' | jq -r 'to_entries[] | \"\\(.key): \\(.value | @base64d)\"'\n", selectedCluster.Namespace) 128 | }, 129 | } 130 | 131 | // UninstallOssCmd removes Parseable OSS servers 132 | var UninstallOssCmd = &cobra.Command{ 133 | Use: "uninstall", 134 | Short: "Uninstall Parseable servers", 135 | Example: "pb uninstall", 136 | Run: func(_ *cobra.Command, _ []string) { 137 | _, err := common.PromptK8sContext() 138 | if err != nil { 139 | log.Fatalf("Failed to prompt for Kubernetes context: %v", err) 140 | } 141 | 142 | // Read the installer data from the ConfigMap 143 | entries, err := common.ReadInstallerConfigMap() 144 | if err != nil { 145 | log.Fatalf("Failed to fetch OSS servers: %v", err) 146 | } 147 | 148 | // Check if there are no entries 149 | if len(entries) == 0 { 150 | fmt.Println(common.Yellow + "\nNo Parseable OSS servers found to uninstall.") 151 | return 152 | } 153 | 154 | // Prompt user to select a cluster 155 | selectedCluster, err := common.PromptClusterSelection(entries) 156 | if err != nil { 157 | log.Fatalf("Failed to select a cluster: %v", err) 158 | } 159 | 160 | // Display a warning banner 161 | fmt.Println("\n────────────────────────────────────────────────────────────────────────────") 162 | fmt.Println("⚠️ Deleting this cluster will not delete any data on object storage.") 163 | fmt.Println(" This operation will clean up the Parseable deployment on Kubernetes.") 164 | fmt.Println("────────────────────────────────────────────────────────────────────────────") 165 | 166 | // Confirm uninstallation 167 | fmt.Printf("\nYou have selected to uninstall the cluster '%s' in namespace '%s'.\n", selectedCluster.Name, selectedCluster.Namespace) 168 | if !common.PromptConfirmation(fmt.Sprintf("Do you want to proceed with uninstalling '%s'?", selectedCluster.Name)) { 169 | fmt.Println(common.Yellow + "Uninstall operation canceled.") 170 | return 171 | } 172 | 173 | //Perform uninstallation 174 | if err := uninstallCluster(selectedCluster); err != nil { 175 | log.Fatalf("Failed to uninstall cluster: %v", err) 176 | } 177 | 178 | // Remove entry from ConfigMap 179 | if err := common.RemoveInstallerEntry(selectedCluster.Name); err != nil { 180 | log.Fatalf("Failed to remove entry from ConfigMap: %v", err) 181 | } 182 | 183 | // Delete secret 184 | if err := deleteSecret(selectedCluster.Namespace, "parseable-env-secret"); err != nil { 185 | log.Printf("Warning: Failed to delete secret 'parseable-env-secret': %v", err) 186 | } else { 187 | fmt.Println(common.Green + "Secret 'parseable-env-secret' deleted successfully." + common.Reset) 188 | } 189 | 190 | fmt.Println(common.Green + "Uninstallation completed successfully." + common.Reset) 191 | }, 192 | } 193 | 194 | func uninstallCluster(entry common.InstallerEntry) error { 195 | helmApp := helm.Helm{ 196 | ReleaseName: entry.Name, 197 | Namespace: entry.Namespace, 198 | RepoName: "parseable", 199 | RepoURL: "https://charts.parseable.com", 200 | ChartName: "parseable", 201 | Version: entry.Version, 202 | } 203 | 204 | fmt.Println(common.Yellow + "Starting uninstallation process..." + common.Reset) 205 | 206 | spinner := common.CreateDeploymentSpinner(fmt.Sprintf("Uninstalling Parseable OSS '%s'...", entry.Name)) 207 | spinner.Start() 208 | 209 | _, err := helm.Uninstall(helmApp, false) 210 | spinner.Stop() 211 | 212 | if err != nil { 213 | return fmt.Errorf("failed to uninstall Parseable OSS: %v", err) 214 | } 215 | 216 | fmt.Printf(common.Green+"Successfully uninstalled '%s' from namespace '%s'.\n"+common.Reset, entry.Name, entry.Namespace) 217 | return nil 218 | } 219 | 220 | func deleteSecret(namespace, secretName string) error { 221 | config, err := common.LoadKubeConfig() 222 | if err != nil { 223 | return fmt.Errorf("failed to create Kubernetes client: %v", err) 224 | } 225 | 226 | clientset, err := kubernetes.NewForConfig(config) 227 | if err != nil { 228 | return fmt.Errorf("failed to create Kubernetes client: %w", err) 229 | } 230 | 231 | err = clientset.CoreV1().Secrets(namespace).Delete(context.TODO(), "parseable-env-secret", metav1.DeleteOptions{}) 232 | if err != nil { 233 | return fmt.Errorf("failed to delete secret '%s': %v", secretName, err) 234 | } 235 | 236 | return nil 237 | } 238 | -------------------------------------------------------------------------------- /pkg/common/common.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Parseable, Inc 2 | // 3 | // This program is free software: you can redistribute it and/or modify 4 | // it under the terms of the GNU Affero General Public License as published by 5 | // the Free Software Foundation, either version 3 of the License, or 6 | // (at your option) any later version. 7 | // 8 | // This program is distributed in the hope that it will be useful 9 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | // GNU Affero General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU Affero General Public License 14 | // along with this program. If not, see . 15 | 16 | package common 17 | 18 | import ( 19 | "context" 20 | "fmt" 21 | "os" 22 | "time" 23 | 24 | "github.com/briandowns/spinner" 25 | "github.com/manifoldco/promptui" 26 | "gopkg.in/yaml.v2" 27 | apiErrors "k8s.io/apimachinery/pkg/api/errors" 28 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 29 | "k8s.io/client-go/kubernetes" 30 | "k8s.io/client-go/rest" 31 | "k8s.io/client-go/tools/clientcmd" 32 | ) 33 | 34 | const ( 35 | configMapName = "parseable-installer" 36 | namespace = "pb-system" 37 | dataKey = "installer-data" 38 | ) 39 | 40 | // ANSI escape codes for colors 41 | const ( 42 | Yellow = "\033[33m" 43 | Green = "\033[32m" 44 | Red = "\033[31m" 45 | Reset = "\033[0m" 46 | Blue = "\033[34m" 47 | Cyan = "\033[36m" 48 | ) 49 | 50 | // InstallerEntry represents an entry in the installer.yaml file 51 | type InstallerEntry struct { 52 | Name string `yaml:"name"` 53 | Namespace string `yaml:"namespace"` 54 | Version string `yaml:"version"` 55 | Status string `yaml:"status"` // todo ideally should be a heartbeat 56 | } 57 | 58 | // ReadInstallerConfigMap fetches and parses installer data from a ConfigMap 59 | func ReadInstallerConfigMap() ([]InstallerEntry, error) { 60 | 61 | // Load kubeconfig and create a Kubernetes client 62 | config, err := LoadKubeConfig() 63 | if err != nil { 64 | return nil, fmt.Errorf("failed to load kubeconfig: %w", err) 65 | } 66 | 67 | clientset, err := kubernetes.NewForConfig(config) 68 | if err != nil { 69 | return nil, fmt.Errorf("failed to create Kubernetes client: %w", err) 70 | } 71 | 72 | // Get the ConfigMap 73 | cm, err := clientset.CoreV1().ConfigMaps(namespace).Get(context.TODO(), configMapName, metav1.GetOptions{}) 74 | if err != nil { 75 | if apiErrors.IsNotFound(err) { 76 | fmt.Println(Yellow + "\nNo existing Parseable OSS clusters found.\n" + Reset) 77 | return nil, nil 78 | } 79 | return nil, fmt.Errorf("failed to fetch ConfigMap: %w", err) 80 | } 81 | // Retrieve and parse the installer data 82 | rawData, ok := cm.Data[dataKey] 83 | if !ok { 84 | fmt.Println(Yellow + "\n────────────────────────────────────────────────────────────────────────────") 85 | fmt.Println(Yellow + "⚠️ No Parseable clusters found!") 86 | fmt.Println(Yellow + "To get started, run: `pb install oss`") 87 | fmt.Println(Yellow + "────────────────────────────────────────────────────────────────────────────") 88 | return nil, nil 89 | } 90 | 91 | var entries []InstallerEntry 92 | if err := yaml.Unmarshal([]byte(rawData), &entries); err != nil { 93 | return nil, fmt.Errorf("failed to parse ConfigMap data: %w", err) 94 | } 95 | 96 | return entries, nil 97 | } 98 | 99 | // LoadKubeConfig loads the kubeconfig from the default location 100 | func LoadKubeConfig() (*rest.Config, error) { 101 | kubeconfig := clientcmd.NewDefaultClientConfigLoadingRules().GetDefaultFilename() 102 | return clientcmd.BuildConfigFromFlags("", kubeconfig) 103 | } 104 | 105 | // PromptK8sContext retrieves Kubernetes contexts from kubeconfig. 106 | func PromptK8sContext() (clusterName string, err error) { 107 | kubeconfigPath := os.Getenv("KUBECONFIG") 108 | if kubeconfigPath == "" { 109 | kubeconfigPath = os.Getenv("HOME") + "/.kube/config" 110 | } 111 | 112 | // Load kubeconfig file 113 | config, err := clientcmd.LoadFromFile(kubeconfigPath) 114 | if err != nil { 115 | fmt.Printf("\033[31mError loading kubeconfig: %v\033[0m\n", err) 116 | os.Exit(1) 117 | } 118 | 119 | // Check if P_KUBE_CONTEXT is set 120 | envContext := os.Getenv("P_KUBE_CONTEXT") 121 | if envContext != "" { 122 | // Validate if the context exists in kubeconfig 123 | if _, exists := config.Contexts[envContext]; !exists { 124 | return "", fmt.Errorf("context '%s' not found in kubeconfig", envContext) 125 | } 126 | 127 | // Set current context to the value from P_KUBE_CONTEXT 128 | config.CurrentContext = envContext 129 | err = clientcmd.WriteToFile(*config, kubeconfigPath) 130 | if err != nil { 131 | return "", err 132 | } 133 | 134 | fmt.Printf("\033[32mUsing Kubernetes context from P_KUBE_CONTEXT: %s ✔\033[0m\n", envContext) 135 | return envContext, nil 136 | } 137 | 138 | // Get available contexts from kubeconfig 139 | currentContext := config.Contexts 140 | var contexts []string 141 | for i := range currentContext { 142 | contexts = append(contexts, i) 143 | } 144 | 145 | // Prompt user to select Kubernetes context 146 | promptK8s := promptui.Select{ 147 | Items: contexts, 148 | Templates: &promptui.SelectTemplates{ 149 | Label: "{{ `Select your Kubernetes context` | yellow }}", 150 | Active: "▸ {{ . | yellow }} ", // Yellow arrow and context name for active selection 151 | Inactive: " {{ . | yellow }}", // Default color for inactive items 152 | Selected: "{{ `Selected Kubernetes context:` | green }} '{{ . | green }}' ✔", 153 | }, 154 | } 155 | 156 | _, clusterName, err = promptK8s.Run() 157 | if err != nil { 158 | return "", err 159 | } 160 | 161 | // Set current context as selected 162 | config.CurrentContext = clusterName 163 | err = clientcmd.WriteToFile(*config, kubeconfigPath) 164 | if err != nil { 165 | return "", err 166 | } 167 | 168 | return clusterName, nil 169 | } 170 | 171 | func PromptClusterSelection(entries []InstallerEntry) (InstallerEntry, error) { 172 | clusterNames := make([]string, len(entries)) 173 | for i, entry := range entries { 174 | clusterNames[i] = fmt.Sprintf("[Name: %s] [Namespace: %s] [Version: %s]", entry.Name, entry.Namespace, entry.Version) 175 | } 176 | 177 | prompt := promptui.Select{ 178 | Label: "Select a cluster to uninstall", 179 | Items: clusterNames, 180 | Templates: &promptui.SelectTemplates{ 181 | Label: "{{ `Select Cluster` | yellow }}", 182 | Active: "▸ {{ . | yellow }}", 183 | Inactive: " {{ . | yellow }}", 184 | Selected: "{{ `Selected:` | green }} {{ . | green }}", 185 | }, 186 | } 187 | 188 | index, _, err := prompt.Run() 189 | if err != nil { 190 | return InstallerEntry{}, fmt.Errorf("failed to prompt for cluster selection: %v", err) 191 | } 192 | 193 | return entries[index], nil 194 | } 195 | 196 | func PromptConfirmation(message string) bool { 197 | prompt := promptui.Prompt{ 198 | Label: message, 199 | IsConfirm: true, 200 | } 201 | 202 | _, err := prompt.Run() 203 | return err == nil 204 | } 205 | 206 | func CreateDeploymentSpinner(infoMsg string) *spinner.Spinner { 207 | // Custom spinner with multiple character sets for dynamic effect 208 | spinnerChars := []string{ 209 | "●", "○", "◉", "○", "◉", "○", "◉", "○", "◉", 210 | } 211 | 212 | s := spinner.New( 213 | spinnerChars, 214 | 120*time.Millisecond, 215 | spinner.WithColor(Yellow), 216 | spinner.WithSuffix(" ..."), 217 | ) 218 | 219 | s.Prefix = Yellow + infoMsg 220 | 221 | return s 222 | } 223 | func RemoveInstallerEntry(name string) error { 224 | // Load kubeconfig and create a Kubernetes client 225 | config, err := LoadKubeConfig() 226 | if err != nil { 227 | return fmt.Errorf("failed to load kubeconfig: %w", err) 228 | } 229 | 230 | clientset, err := kubernetes.NewForConfig(config) 231 | if err != nil { 232 | return fmt.Errorf("failed to create Kubernetes client: %w", err) 233 | } 234 | 235 | // Fetch the ConfigMap 236 | configMap, err := clientset.CoreV1().ConfigMaps(namespace).Get(context.TODO(), configMapName, metav1.GetOptions{}) 237 | if err != nil { 238 | return fmt.Errorf("failed to fetch ConfigMap: %v", err) 239 | } 240 | 241 | // Log the current data in the ConfigMap 242 | 243 | // Assuming the entries are stored as YAML or JSON string, unmarshal them into a slice 244 | var entries []map[string]interface{} 245 | if err := yaml.Unmarshal([]byte(configMap.Data["installer-data"]), &entries); err != nil { 246 | return fmt.Errorf("failed to unmarshal installer data: %w", err) 247 | } 248 | 249 | // Find the entry to remove by name 250 | var indexToRemove = -1 251 | for i, entry := range entries { 252 | if entry["name"] == name { 253 | indexToRemove = i 254 | break 255 | } 256 | } 257 | 258 | // Check if the entry was found 259 | if indexToRemove == -1 { 260 | return fmt.Errorf("entry '%s' does not exist in ConfigMap", name) 261 | } 262 | 263 | // Remove the entry 264 | entries = append(entries[:indexToRemove], entries[indexToRemove+1:]...) 265 | 266 | // Marshal the updated entries back into YAML 267 | updatedData, err := yaml.Marshal(entries) 268 | if err != nil { 269 | return fmt.Errorf("failed to marshal updated entries: %w", err) 270 | } 271 | configMap.Data["installer-data"] = string(updatedData) 272 | 273 | // Update the ConfigMap in Kubernetes 274 | _, err = clientset.CoreV1().ConfigMaps(namespace).Update(context.TODO(), configMap, metav1.UpdateOptions{}) 275 | if err != nil { 276 | return fmt.Errorf("failed to update ConfigMap: %v", err) 277 | } 278 | 279 | return nil 280 | } 281 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module pb 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.23.4 6 | 7 | require ( 8 | github.com/apache/arrow/go/v13 v13.0.0 9 | github.com/briandowns/spinner v1.23.1 10 | github.com/charmbracelet/bubbles v0.18.0 11 | github.com/charmbracelet/bubbletea v0.26.6 12 | github.com/charmbracelet/lipgloss v0.12.1 13 | github.com/dustin/go-humanize v1.0.1 14 | github.com/gofrs/flock v0.12.1 15 | github.com/manifoldco/promptui v0.9.0 16 | github.com/oklog/ulid/v2 v2.1.0 17 | github.com/olekukonko/tablewriter v0.0.5 18 | github.com/pkg/errors v0.9.1 19 | github.com/spf13/pflag v1.0.5 20 | golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8 21 | golang.org/x/term v0.25.0 22 | google.golang.org/grpc v1.65.0 23 | gopkg.in/yaml.v2 v2.4.0 24 | gopkg.in/yaml.v3 v3.0.1 25 | helm.sh/helm/v3 v3.16.3 26 | k8s.io/apimachinery v0.32.0 27 | k8s.io/client-go v0.32.0 28 | ) 29 | 30 | require ( 31 | dario.cat/mergo v1.0.1 // indirect 32 | github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 // indirect 33 | github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect 34 | github.com/BurntSushi/toml v1.3.2 // indirect 35 | github.com/MakeNowJust/heredoc v1.0.0 // indirect 36 | github.com/Masterminds/goutils v1.1.1 // indirect 37 | github.com/Masterminds/semver/v3 v3.3.0 // indirect 38 | github.com/Masterminds/sprig/v3 v3.3.0 // indirect 39 | github.com/Masterminds/squirrel v1.5.4 // indirect 40 | github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect 41 | github.com/beorn7/perks v1.0.1 // indirect 42 | github.com/blang/semver/v4 v4.0.0 // indirect 43 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 44 | github.com/chai2010/gettext-go v1.0.2 // indirect 45 | github.com/charmbracelet/x/ansi v0.1.4 // indirect 46 | github.com/charmbracelet/x/input v0.1.0 // indirect 47 | github.com/charmbracelet/x/term v0.1.1 // indirect 48 | github.com/charmbracelet/x/windows v0.1.0 // indirect 49 | github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect 50 | github.com/containerd/containerd v1.7.23 // indirect 51 | github.com/containerd/errdefs v0.3.0 // indirect 52 | github.com/containerd/log v0.1.0 // indirect 53 | github.com/containerd/platforms v0.2.1 // indirect 54 | github.com/cyphar/filepath-securejoin v0.3.4 // indirect 55 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 56 | github.com/distribution/reference v0.6.0 // indirect 57 | github.com/docker/cli v25.0.1+incompatible // indirect 58 | github.com/docker/distribution v2.8.3+incompatible // indirect 59 | github.com/docker/docker v25.0.6+incompatible // indirect 60 | github.com/docker/docker-credential-helpers v0.7.0 // indirect 61 | github.com/docker/go-connections v0.5.0 // indirect 62 | github.com/docker/go-metrics v0.0.1 // indirect 63 | github.com/emicklei/go-restful/v3 v3.11.0 // indirect 64 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 65 | github.com/evanphx/json-patch v5.9.0+incompatible // indirect 66 | github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d // indirect 67 | github.com/fatih/color v1.13.0 // indirect 68 | github.com/felixge/httpsnoop v1.0.4 // indirect 69 | github.com/fxamacker/cbor/v2 v2.7.0 // indirect 70 | github.com/go-errors/errors v1.4.2 // indirect 71 | github.com/go-gorp/gorp/v3 v3.1.0 // indirect 72 | github.com/go-logr/logr v1.4.2 // indirect 73 | github.com/go-logr/stdr v1.2.2 // indirect 74 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 75 | github.com/go-openapi/jsonreference v0.20.2 // indirect 76 | github.com/go-openapi/swag v0.23.0 // indirect 77 | github.com/gobwas/glob v0.2.3 // indirect 78 | github.com/goccy/go-json v0.10.0 // indirect 79 | github.com/gogo/protobuf v1.3.2 // indirect 80 | github.com/golang/protobuf v1.5.4 // indirect 81 | github.com/google/btree v1.0.1 // indirect 82 | github.com/google/flatbuffers v23.1.21+incompatible // indirect 83 | github.com/google/gnostic-models v0.6.8 // indirect 84 | github.com/google/go-cmp v0.6.0 // indirect 85 | github.com/google/gofuzz v1.2.0 // indirect 86 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect 87 | github.com/google/uuid v1.6.0 // indirect 88 | github.com/gorilla/mux v1.8.0 // indirect 89 | github.com/gorilla/websocket v1.5.0 // indirect 90 | github.com/gosuri/uitable v0.0.4 // indirect 91 | github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect 92 | github.com/hashicorp/errwrap v1.1.0 // indirect 93 | github.com/hashicorp/go-multierror v1.1.1 // indirect 94 | github.com/huandu/xstrings v1.5.0 // indirect 95 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 96 | github.com/jmoiron/sqlx v1.4.0 // indirect 97 | github.com/josharian/intern v1.0.0 // indirect 98 | github.com/json-iterator/go v1.1.12 // indirect 99 | github.com/klauspost/compress v1.16.7 // indirect 100 | github.com/klauspost/cpuid/v2 v2.2.3 // indirect 101 | github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect 102 | github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect 103 | github.com/lib/pq v1.10.9 // indirect 104 | github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect 105 | github.com/mailru/easyjson v0.7.7 // indirect 106 | github.com/mattn/go-colorable v0.1.13 // indirect 107 | github.com/mitchellh/copystructure v1.2.0 // indirect 108 | github.com/mitchellh/go-wordwrap v1.0.1 // indirect 109 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 110 | github.com/moby/locker v1.0.1 // indirect 111 | github.com/moby/spdystream v0.5.0 // indirect 112 | github.com/moby/term v0.5.0 // indirect 113 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 114 | github.com/modern-go/reflect2 v1.0.2 // indirect 115 | github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect 116 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 117 | github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect 118 | github.com/opencontainers/go-digest v1.0.0 // indirect 119 | github.com/opencontainers/image-spec v1.1.0 // indirect 120 | github.com/peterbourgon/diskv v2.0.1+incompatible // indirect 121 | github.com/pierrec/lz4/v4 v4.1.17 // indirect 122 | github.com/prometheus/client_golang v1.19.1 // indirect 123 | github.com/prometheus/client_model v0.6.1 // indirect 124 | github.com/prometheus/common v0.55.0 // indirect 125 | github.com/prometheus/procfs v0.15.1 // indirect 126 | github.com/rubenv/sql-migrate v1.7.0 // indirect 127 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 128 | github.com/shopspring/decimal v1.4.0 // indirect 129 | github.com/sirupsen/logrus v1.9.3 // indirect 130 | github.com/spf13/cast v1.7.0 // indirect 131 | github.com/x448/float16 v0.8.4 // indirect 132 | github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect 133 | github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect 134 | github.com/xeipuuv/gojsonschema v1.2.0 // indirect 135 | github.com/xlab/treeprint v1.2.0 // indirect 136 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 137 | github.com/zeebo/xxh3 v1.0.2 // indirect 138 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 // indirect 139 | go.opentelemetry.io/otel v1.28.0 // indirect 140 | go.opentelemetry.io/otel/metric v1.28.0 // indirect 141 | go.opentelemetry.io/otel/trace v1.28.0 // indirect 142 | go.starlark.net v0.0.0-20230525235612-a134d8f9ddca // indirect 143 | golang.org/x/crypto v0.28.0 // indirect 144 | golang.org/x/mod v0.21.0 // indirect 145 | golang.org/x/net v0.30.0 // indirect 146 | golang.org/x/oauth2 v0.23.0 // indirect 147 | golang.org/x/time v0.7.0 // indirect 148 | golang.org/x/tools v0.26.0 // indirect 149 | golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect 150 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 // indirect 151 | google.golang.org/protobuf v1.35.1 // indirect 152 | gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect 153 | gopkg.in/inf.v0 v0.9.1 // indirect 154 | k8s.io/api v0.32.0 // indirect 155 | k8s.io/apiextensions-apiserver v0.31.1 // indirect 156 | k8s.io/apiserver v0.31.1 // indirect 157 | k8s.io/cli-runtime v0.31.1 // indirect 158 | k8s.io/component-base v0.31.1 // indirect 159 | k8s.io/klog/v2 v2.130.1 // indirect 160 | k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect 161 | k8s.io/kubectl v0.31.1 // indirect 162 | k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect 163 | oras.land/oras-go v1.2.5 // indirect 164 | sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect 165 | sigs.k8s.io/kustomize/api v0.17.2 // indirect 166 | sigs.k8s.io/kustomize/kyaml v0.17.1 // indirect 167 | sigs.k8s.io/structured-merge-diff/v4 v4.4.2 // indirect 168 | sigs.k8s.io/yaml v1.4.0 // indirect 169 | ) 170 | 171 | require ( 172 | github.com/atotto/clipboard v0.1.4 // indirect 173 | github.com/evertras/bubble-table v0.15.2 174 | github.com/muesli/termenv v0.15.2 // indirect 175 | github.com/pelletier/go-toml/v2 v2.0.9 176 | github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f // indirect 177 | ) 178 | 179 | require ( 180 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 181 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 182 | github.com/mattn/go-isatty v0.0.20 // indirect 183 | github.com/mattn/go-localereader v0.0.1 // indirect 184 | github.com/mattn/go-runewidth v0.0.15 // indirect 185 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 186 | github.com/muesli/cancelreader v0.2.2 // indirect 187 | github.com/muesli/reflow v0.3.0 // indirect 188 | github.com/rivo/uniseg v0.4.7 // indirect 189 | github.com/spf13/cobra v1.8.1 190 | golang.org/x/sync v0.8.0 // indirect 191 | golang.org/x/sys v0.26.0 // indirect 192 | golang.org/x/text v0.19.0 // indirect 193 | ) 194 | -------------------------------------------------------------------------------- /cmd/profile.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Parseable, Inc 2 | // 3 | // This program is free software: you can redistribute it and/or modify 4 | // it under the terms of the GNU Affero General Public License as published by 5 | // the Free Software Foundation, either version 3 of the License, or 6 | // (at your option) any later version. 7 | // 8 | // This program is distributed in the hope that it will be useful 9 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | // GNU Affero General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU Affero General Public License 14 | // along with this program. If not, see . 15 | 16 | package cmd 17 | 18 | import ( 19 | "encoding/json" 20 | "errors" 21 | "fmt" 22 | "net/url" 23 | "pb/pkg/config" 24 | "pb/pkg/model/credential" 25 | "pb/pkg/model/defaultprofile" 26 | "time" 27 | 28 | tea "github.com/charmbracelet/bubbletea" 29 | "github.com/spf13/cobra" 30 | ) 31 | 32 | // ProfileListItem is a struct to hold the profile list items 33 | type ProfileListItem struct { 34 | title, url, user string 35 | } 36 | 37 | func (item *ProfileListItem) Render(highlight bool) string { 38 | if highlight { 39 | render := fmt.Sprintf( 40 | "%s\n%s\n%s", 41 | SelectedStyle.Render(item.title), 42 | SelectedStyleAlt.Render(fmt.Sprintf("url: %s", item.url)), 43 | SelectedStyleAlt.Render(fmt.Sprintf("user: %s", item.user)), 44 | ) 45 | return SelectedItemOuter.Render(render) 46 | } 47 | render := fmt.Sprintf( 48 | "%s\n%s\n%s", 49 | StandardStyle.Render(item.title), 50 | StandardStyleAlt.Render(fmt.Sprintf("url: %s", item.url)), 51 | StandardStyleAlt.Render(fmt.Sprintf("user: %s", item.user)), 52 | ) 53 | return ItemOuter.Render(render) 54 | } 55 | 56 | // Add an output flag to specify the output format. 57 | var outputFormat string 58 | 59 | // Initialize flags 60 | func init() { 61 | AddProfileCmd.Flags().StringVarP(&outputFormat, "output", "o", "", "Output format (text|json)") 62 | RemoveProfileCmd.Flags().StringVarP(&outputFormat, "output", "o", "", "Output format (text|json)") 63 | DefaultProfileCmd.Flags().StringVarP(&outputFormat, "output", "o", "", "Output format (text|json)") 64 | ListProfileCmd.Flags().StringVarP(&outputFormat, "output", "o", "", "Output format (text|json)") 65 | } 66 | 67 | func outputResult(v interface{}) error { 68 | if outputFormat == "json" { 69 | jsonData, err := json.MarshalIndent(v, "", " ") 70 | if err != nil { 71 | return err 72 | } 73 | fmt.Println(string(jsonData)) 74 | } else { 75 | fmt.Println(v) 76 | } 77 | return nil 78 | } 79 | 80 | var AddProfileCmd = &cobra.Command{ 81 | Use: "add profile-name url ", 82 | Example: " pb profile add local_parseable http://0.0.0.0:8000 admin admin", 83 | Short: "Add a new profile", 84 | Long: "Add a new profile to the config file", 85 | Args: func(cmd *cobra.Command, args []string) error { 86 | if err := cobra.MinimumNArgs(2)(cmd, args); err != nil { 87 | return err 88 | } 89 | return cobra.MaximumNArgs(4)(cmd, args) 90 | }, 91 | RunE: func(cmd *cobra.Command, args []string) error { 92 | if cmd.Annotations == nil { 93 | cmd.Annotations = make(map[string]string) 94 | } 95 | startTime := time.Now() 96 | var commandError error 97 | 98 | // Parsing input and handling errors 99 | name := args[0] 100 | url, err := url.Parse(args[1]) 101 | if err != nil { 102 | commandError = fmt.Errorf("error parsing URL: %s", err) 103 | cmd.Annotations["error"] = commandError.Error() 104 | return commandError 105 | } 106 | 107 | var username, password string 108 | if len(args) < 4 { 109 | _m, err := tea.NewProgram(credential.New()).Run() 110 | if err != nil { 111 | commandError = fmt.Errorf("error reading credentials: %s", err) 112 | cmd.Annotations["error"] = commandError.Error() 113 | return commandError 114 | } 115 | m := _m.(credential.Model) 116 | username, password = m.Values() 117 | } else { 118 | username = args[2] 119 | password = args[3] 120 | } 121 | 122 | profile := config.Profile{URL: url.String(), Username: username, Password: password} 123 | fileConfig, err := config.ReadConfigFromFile() 124 | if err != nil { 125 | newConfig := config.Config{ 126 | Profiles: map[string]config.Profile{name: profile}, 127 | DefaultProfile: name, 128 | } 129 | err = config.WriteConfigToFile(&newConfig) 130 | commandError = err 131 | } else { 132 | if fileConfig.Profiles == nil { 133 | fileConfig.Profiles = make(map[string]config.Profile) 134 | } 135 | fileConfig.Profiles[name] = profile 136 | if fileConfig.DefaultProfile == "" { 137 | fileConfig.DefaultProfile = name 138 | } 139 | commandError = config.WriteConfigToFile(fileConfig) 140 | } 141 | 142 | cmd.Annotations["executionTime"] = time.Since(startTime).String() 143 | if commandError != nil { 144 | cmd.Annotations["error"] = commandError.Error() 145 | return commandError 146 | } 147 | 148 | if outputFormat == "json" { 149 | return outputResult(profile) 150 | } 151 | fmt.Printf("Profile %s added successfully\n", name) 152 | return nil 153 | }, 154 | } 155 | 156 | var RemoveProfileCmd = &cobra.Command{ 157 | Use: "remove profile-name", 158 | Aliases: []string{"rm"}, 159 | Example: " pb profile remove local_parseable", 160 | Args: cobra.ExactArgs(1), 161 | Short: "Delete a profile", 162 | RunE: func(cmd *cobra.Command, args []string) error { 163 | if cmd.Annotations == nil { 164 | cmd.Annotations = make(map[string]string) 165 | } 166 | startTime := time.Now() 167 | 168 | name := args[0] 169 | fileConfig, err := config.ReadConfigFromFile() 170 | if err != nil { 171 | cmd.Annotations["error"] = fmt.Sprintf("error reading config: %s", err) 172 | return err 173 | } 174 | 175 | _, exists := fileConfig.Profiles[name] 176 | if !exists { 177 | msg := fmt.Sprintf("No profile found with the name: %s", name) 178 | cmd.Annotations["error"] = msg 179 | fmt.Println(msg) 180 | return nil 181 | } 182 | 183 | delete(fileConfig.Profiles, name) 184 | if len(fileConfig.Profiles) == 0 { 185 | fileConfig.DefaultProfile = "" 186 | } 187 | 188 | commandError := config.WriteConfigToFile(fileConfig) 189 | cmd.Annotations["executionTime"] = time.Since(startTime).String() 190 | if commandError != nil { 191 | cmd.Annotations["error"] = commandError.Error() 192 | return commandError 193 | } 194 | 195 | if outputFormat == "json" { 196 | return outputResult(fmt.Sprintf("Deleted profile %s", name)) 197 | } 198 | fmt.Printf("Deleted profile %s\n", name) 199 | return nil 200 | }, 201 | } 202 | 203 | var DefaultProfileCmd = &cobra.Command{ 204 | Use: "default profile-name", 205 | Args: cobra.MaximumNArgs(1), 206 | Short: "Set default profile to use with all commands", 207 | Example: " pb profile default local_parseable", 208 | RunE: func(cmd *cobra.Command, args []string) error { 209 | if cmd.Annotations == nil { 210 | cmd.Annotations = make(map[string]string) 211 | } 212 | startTime := time.Now() 213 | 214 | fileConfig, err := config.ReadConfigFromFile() 215 | if err != nil { 216 | cmd.Annotations["error"] = fmt.Sprintf("error reading config: %s", err) 217 | return err 218 | } 219 | 220 | var name string 221 | if len(args) > 0 { 222 | name = args[0] 223 | } else { 224 | model := defaultprofile.New(fileConfig.Profiles) 225 | _m, err := tea.NewProgram(model).Run() 226 | if err != nil { 227 | cmd.Annotations["error"] = fmt.Sprintf("error selecting default profile: %s", err) 228 | return err 229 | } 230 | m := _m.(defaultprofile.Model) 231 | if !m.Success { 232 | return nil 233 | } 234 | name = m.Choice 235 | } 236 | 237 | _, exists := fileConfig.Profiles[name] 238 | if !exists { 239 | commandError := fmt.Sprintf("profile %s does not exist", name) 240 | cmd.Annotations["error"] = commandError 241 | return errors.New(commandError) 242 | } 243 | 244 | fileConfig.DefaultProfile = name 245 | commandError := config.WriteConfigToFile(fileConfig) 246 | cmd.Annotations["executionTime"] = time.Since(startTime).String() 247 | if commandError != nil { 248 | cmd.Annotations["error"] = commandError.Error() 249 | return commandError 250 | } 251 | 252 | if outputFormat == "json" { 253 | return outputResult(fmt.Sprintf("%s is now set as default profile", name)) 254 | } 255 | fmt.Printf("%s is now set as default profile\n", name) 256 | return nil 257 | }, 258 | } 259 | 260 | var ListProfileCmd = &cobra.Command{ 261 | Use: "list profiles", 262 | Short: "List all added profiles", 263 | Example: " pb profile list", 264 | RunE: func(cmd *cobra.Command, _ []string) error { 265 | if cmd.Annotations == nil { 266 | cmd.Annotations = make(map[string]string) 267 | } 268 | startTime := time.Now() 269 | 270 | fileConfig, err := config.ReadConfigFromFile() 271 | if err != nil { 272 | cmd.Annotations["error"] = fmt.Sprintf("error reading config: %s", err) 273 | return err 274 | } 275 | 276 | if outputFormat == "json" { 277 | commandError := outputResult(fileConfig.Profiles) 278 | cmd.Annotations["executionTime"] = time.Since(startTime).String() 279 | if commandError != nil { 280 | cmd.Annotations["error"] = commandError.Error() 281 | return commandError 282 | } 283 | return nil 284 | } 285 | 286 | for key, value := range fileConfig.Profiles { 287 | item := ProfileListItem{key, value.URL, value.Username} 288 | fmt.Println(item.Render(fileConfig.DefaultProfile == key)) 289 | fmt.Println() // Add a blank line after each profile 290 | } 291 | cmd.Annotations["executionTime"] = time.Since(startTime).String() 292 | return nil 293 | }, 294 | } 295 | 296 | func Max(a int, b int) int { 297 | if a >= b { 298 | return a 299 | } 300 | return b 301 | } 302 | -------------------------------------------------------------------------------- /cmd/query.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Parseable, Inc 2 | // 3 | // This program is free software: you can redistribute it and/or modify 4 | // it under the terms of the GNU Affero General Public License as published by 5 | // the Free Software Foundation, either version 3 of the License, or 6 | // (at your option) any later version. 7 | // 8 | // This program is distributed in the hope that it will be useful 9 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | // GNU Affero General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU Affero General Public License 14 | // along with this program. If not, see . 15 | 16 | package cmd 17 | 18 | import ( 19 | "bytes" 20 | "encoding/json" 21 | "fmt" 22 | "io" 23 | "os" 24 | "strings" 25 | "time" 26 | 27 | // "pb/pkg/model" 28 | 29 | //! This dependency is required by the interactive flag Do not remove 30 | // tea "github.com/charmbracelet/bubbletea" 31 | internalHTTP "pb/pkg/http" 32 | 33 | "github.com/spf13/cobra" 34 | ) 35 | 36 | var ( 37 | startFlag = "from" 38 | startFlagShort = "f" 39 | defaultStart = "1m" 40 | 41 | endFlag = "to" 42 | endFlagShort = "t" 43 | defaultEnd = "now" 44 | 45 | outputFlag = "output" 46 | ) 47 | 48 | var query = &cobra.Command{ 49 | Use: "run [query] [flags]", 50 | Example: " pb query run \"select * from frontend\" --from=10m --to=now", 51 | Short: "Run SQL query on a log stream", 52 | Long: "\nRun SQL query on a log stream. Default output format is text. Use --output flag to set output format to json.", 53 | Args: cobra.MaximumNArgs(1), 54 | PreRunE: PreRunDefaultProfile, 55 | RunE: func(command *cobra.Command, args []string) error { 56 | startTime := time.Now() 57 | command.Annotations = map[string]string{ 58 | "startTime": startTime.Format(time.RFC3339), 59 | } 60 | 61 | defer func() { 62 | duration := time.Since(startTime) 63 | command.Annotations["executionTime"] = duration.String() 64 | }() 65 | 66 | if len(args) == 0 || strings.TrimSpace(args[0]) == "" { 67 | fmt.Println("Please enter your query") 68 | fmt.Printf("Example:\n pb query run \"select * from frontend\" --from=10m --to=now\n") 69 | return nil 70 | } 71 | 72 | query := args[0] 73 | start, err := command.Flags().GetString(startFlag) 74 | if err != nil { 75 | command.Annotations["error"] = err.Error() 76 | return err 77 | } 78 | if start == "" { 79 | start = defaultStart 80 | } 81 | 82 | end, err := command.Flags().GetString(endFlag) 83 | if err != nil { 84 | command.Annotations["error"] = err.Error() 85 | return err 86 | } 87 | if end == "" { 88 | end = defaultEnd 89 | } 90 | 91 | outputFormat, err := command.Flags().GetString("output") 92 | if err != nil { 93 | command.Annotations["error"] = err.Error() 94 | return fmt.Errorf("failed to get 'output' flag: %w", err) 95 | } 96 | 97 | client := internalHTTP.DefaultClient(&DefaultProfile) 98 | err = fetchData(&client, query, start, end, outputFormat) 99 | if err != nil { 100 | command.Annotations["error"] = err.Error() 101 | } 102 | return err 103 | }, 104 | } 105 | 106 | func init() { 107 | query.Flags().StringP(startFlag, startFlagShort, defaultStart, "Start time for query.") 108 | query.Flags().StringP(endFlag, endFlagShort, defaultEnd, "End time for query.") 109 | query.Flags().StringVarP(&outputFormat, "output", "o", "", "Output format (text|json)") 110 | } 111 | 112 | var QueryCmd = query 113 | 114 | func fetchData(client *internalHTTP.HTTPClient, query string, startTime, endTime, outputFormat string) error { 115 | queryTemplate := `{ 116 | "query": "%s", 117 | "startTime": "%s", 118 | "endTime": "%s" 119 | }` 120 | finalQuery := fmt.Sprintf(queryTemplate, query, startTime, endTime) 121 | 122 | req, err := client.NewRequest("POST", "query", bytes.NewBuffer([]byte(finalQuery))) 123 | if err != nil { 124 | return fmt.Errorf("failed to create new request: %w", err) 125 | } 126 | 127 | resp, err := client.Client.Do(req) 128 | if err != nil { 129 | return fmt.Errorf("request execution failed: %w", err) 130 | } 131 | defer resp.Body.Close() 132 | 133 | if resp.StatusCode != 200 { 134 | body, _ := io.ReadAll(resp.Body) 135 | fmt.Println(string(body)) 136 | return fmt.Errorf("non-200 status code received: %s", resp.Status) 137 | } 138 | 139 | if outputFormat == "json" { 140 | var jsonResponse []map[string]interface{} 141 | if err := json.NewDecoder(resp.Body).Decode(&jsonResponse); err != nil { 142 | return fmt.Errorf("error decoding JSON response: %w", err) 143 | } 144 | encodedResponse, _ := json.MarshalIndent(jsonResponse, "", " ") 145 | fmt.Println(string(encodedResponse)) 146 | } else { 147 | io.Copy(os.Stdout, resp.Body) 148 | } 149 | return nil 150 | } 151 | 152 | // Returns start and end time for query in RFC3339 format 153 | // func parseTime(start, end string) (time.Time, time.Time, error) { 154 | // if start == defaultStart && end == defaultEnd { 155 | // return time.Now().Add(-1 * time.Minute), time.Now(), nil 156 | // } 157 | 158 | // startTime, err := time.Parse(time.RFC3339, start) 159 | // if err != nil { 160 | // // try parsing as duration 161 | // duration, err := time.ParseDuration(start) 162 | // if err != nil { 163 | // return time.Time{}, time.Time{}, err 164 | // } 165 | // startTime = time.Now().Add(-1 * duration) 166 | // } 167 | 168 | // endTime, err := time.Parse(time.RFC3339, end) 169 | // if err != nil { 170 | // if end == "now" { 171 | // endTime = time.Now() 172 | // } else { 173 | // return time.Time{}, time.Time{}, err 174 | // } 175 | // } 176 | 177 | // return startTime, endTime, nil 178 | // } 179 | 180 | // // create a request body for saving filter without time_filter 181 | // func createFilter(query string, filterName string) (err error) { 182 | // userConfig, err := config.ReadConfigFromFile() 183 | // if err != nil { 184 | // return err 185 | // } 186 | 187 | // var userName string 188 | // if profile, ok := userConfig.Profiles[userConfig.DefaultProfile]; ok { 189 | // userName = profile.Username 190 | // } else { 191 | // fmt.Println("Default profile not found.") 192 | // return 193 | // } 194 | 195 | // index := strings.Index(query, "from") 196 | // fromPart := strings.TrimSpace(query[index+len("from"):]) 197 | // streamName := strings.Fields(fromPart)[0] 198 | 199 | // queryTemplate := `{ 200 | // "filter_type":"sql", 201 | // "filter_query": "%s" 202 | // }` 203 | 204 | // saveFilterTemplate := ` 205 | // { 206 | // "stream_name": "%s", 207 | // "filter_name": "%s", 208 | // "user_id": "%s", 209 | // "query": %s, 210 | // "time_filter": null 211 | // }` 212 | 213 | // queryField := fmt.Sprintf(queryTemplate, query) 214 | 215 | // finalQuery := fmt.Sprintf(saveFilterTemplate, streamName, filterName, userName, queryField) 216 | 217 | // saveFilterToServer(finalQuery) 218 | 219 | // return err 220 | // } 221 | 222 | // // create a request body for saving filter with time_filter 223 | // func createFilterWithTime(query string, filterName string, startTime string, endTime string) (err error) { 224 | // userConfig, err := config.ReadConfigFromFile() 225 | // if err != nil { 226 | // return err 227 | // } 228 | 229 | // var userName string 230 | // if profile, ok := userConfig.Profiles[userConfig.DefaultProfile]; ok { 231 | // userName = profile.Username 232 | // } else { 233 | // fmt.Println("Default profile not found.") 234 | // return 235 | // } 236 | 237 | // index := strings.Index(query, "from") 238 | // fromPart := strings.TrimSpace(query[index+len("from"):]) 239 | // streamName := strings.Fields(fromPart)[0] 240 | 241 | // start, end, err := parseTimeToUTC(startTime, endTime) 242 | // if err != nil { 243 | // fmt.Println("Oops something went wrong!!!!") 244 | // return err 245 | // } 246 | 247 | // queryTemplate := `{ 248 | // "filter_type":"sql", 249 | // "filter_query": "%s" 250 | // }` 251 | 252 | // timeTemplate := `{ 253 | // "from": "%s", 254 | // "to": "%s" 255 | // }` 256 | // timeField := fmt.Sprintf(timeTemplate, start, end) 257 | 258 | // saveFilterTemplate := ` 259 | // { 260 | // "stream_name": "%s", 261 | // "filter_name": "%s", 262 | // "user_id": "%s", 263 | // "query": %s, 264 | // "time_filter": %s 265 | // }` 266 | 267 | // queryField := fmt.Sprintf(queryTemplate, query) 268 | 269 | // finalQuery := fmt.Sprintf(saveFilterTemplate, streamName, filterName, userName, queryField, timeField) 270 | 271 | // saveFilterToServer(finalQuery) 272 | 273 | // return err 274 | // } 275 | 276 | // // fires a request to the server to save the filter with the associated user and stream 277 | // func saveFilterToServer(finalQuery string) (err error) { 278 | // client := DefaultClient() 279 | 280 | // req, err := client.NewRequest("POST", "filters", bytes.NewBuffer([]byte(finalQuery))) 281 | // if err != nil { 282 | // return 283 | // } 284 | 285 | // resp, err := client.client.Do(req) 286 | // if err != nil { 287 | // return 288 | // } 289 | 290 | // if resp.StatusCode != 200 { 291 | // fmt.Printf("\nSomething went wrong") 292 | // } 293 | 294 | // return err 295 | // } 296 | 297 | // // parses a time duration to supported utc format 298 | // func parseTimeToUTC(start, end string) (time.Time, time.Time, error) { 299 | // if start == defaultStart && end == defaultEnd { 300 | // now := time.Now().UTC() 301 | // return now.Add(-1 * time.Minute), now, nil 302 | // } 303 | 304 | // startTime, err := time.Parse(time.RFC3339, start) 305 | // if err != nil { 306 | // duration, err := time.ParseDuration(start) 307 | // if err != nil { 308 | // return time.Time{}, time.Time{}, err 309 | // } 310 | // startTime = time.Now().Add(-1 * duration).UTC() 311 | // } else { 312 | // startTime = startTime.UTC() 313 | // } 314 | 315 | // endTime, err := time.Parse(time.RFC3339, end) 316 | // if err != nil { 317 | // if end == "now" { 318 | // endTime = time.Now().UTC() 319 | // } else { 320 | // return time.Time{}, time.Time{}, err 321 | // } 322 | // } else { 323 | // endTime = endTime.UTC() 324 | // } 325 | 326 | // return startTime, endTime, nil 327 | // } 328 | -------------------------------------------------------------------------------- /cmd/role.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Parseable, Inc 2 | // 3 | // This program is free software: you can redistribute it and/or modify 4 | // it under the terms of the GNU Affero General Public License as published by 5 | // the Free Software Foundation, either version 3 of the License, or 6 | // (at your option) any later version. 7 | // 8 | // This program is distributed in the hope that it will be useful 9 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | // GNU Affero General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU Affero General Public License 14 | // along with this program. If not, see . 15 | 16 | package cmd 17 | 18 | import ( 19 | "bytes" 20 | "encoding/json" 21 | "fmt" 22 | "io" 23 | "pb/pkg/model/role" 24 | "strings" 25 | "sync" 26 | "time" 27 | 28 | internalHTTP "pb/pkg/http" 29 | 30 | tea "github.com/charmbracelet/bubbletea" 31 | "github.com/charmbracelet/lipgloss" 32 | "github.com/spf13/cobra" 33 | ) 34 | 35 | type RoleResource struct { 36 | Stream string `json:"stream,omitempty"` 37 | Tag string `json:"tag,omitempty"` 38 | } 39 | 40 | type RoleData struct { 41 | Privilege string `json:"privilege"` 42 | Resource *RoleResource `json:"resource,omitempty"` 43 | } 44 | 45 | func (user *RoleData) Render() string { 46 | var s strings.Builder 47 | s.WriteString(StandardStyle.Render("Privilege: ")) 48 | s.WriteString(StandardStyleAlt.Render(user.Privilege)) 49 | s.WriteString("\n") 50 | if user.Resource != nil { 51 | if user.Resource.Stream != "" { 52 | s.WriteString(StandardStyle.Render("Stream: ")) 53 | s.WriteString(StandardStyleAlt.Render(user.Resource.Stream)) 54 | s.WriteString("\n") 55 | } 56 | if user.Resource.Tag != "" { 57 | s.WriteString(StandardStyle.Render("Tag: ")) 58 | s.WriteString(StandardStyleAlt.Render(user.Resource.Tag)) 59 | s.WriteString("\n") 60 | } 61 | } 62 | 63 | return s.String() 64 | } 65 | 66 | var AddRoleCmd = &cobra.Command{ 67 | Use: "add role-name", 68 | Example: " pb role add ingestors", 69 | Short: "Add a new role", 70 | Args: cobra.ExactArgs(1), 71 | RunE: func(cmd *cobra.Command, args []string) error { 72 | startTime := time.Now() 73 | cmd.Annotations = make(map[string]string) 74 | defer func() { 75 | cmd.Annotations["executionTime"] = time.Since(startTime).String() 76 | }() 77 | 78 | name := args[0] 79 | 80 | var roles []string 81 | client := internalHTTP.DefaultClient(&DefaultProfile) 82 | if err := fetchRoles(&client, &roles); err != nil { 83 | cmd.Annotations["errors"] = fmt.Sprintf("Error fetching roles: %s", err.Error()) 84 | return err 85 | } 86 | 87 | if strings.Contains(strings.Join(roles, " "), name) { 88 | fmt.Println("role already exists, please use a different name") 89 | return nil 90 | } 91 | 92 | _m, err := tea.NewProgram(role.New()).Run() 93 | if err != nil { 94 | cmd.Annotations["errors"] = fmt.Sprintf("Error initializing program: %s", err.Error()) 95 | return err 96 | } 97 | 98 | m := _m.(role.Model) 99 | privilege := m.Selection.Value() 100 | stream := m.Stream.Value() 101 | tag := m.Tag.Value() 102 | 103 | if !m.Success { 104 | fmt.Println("aborted by user") 105 | return nil 106 | } 107 | 108 | var putBody io.Reader 109 | if privilege != "none" { 110 | roleData := RoleData{Privilege: privilege} 111 | switch privilege { 112 | case "writer", "ingestor": 113 | roleData.Resource = &RoleResource{Stream: stream} 114 | case "reader": 115 | roleData.Resource = &RoleResource{Stream: stream, Tag: tag} 116 | } 117 | roleDataJSON, _ := json.Marshal([]RoleData{roleData}) 118 | putBody = bytes.NewBuffer(roleDataJSON) 119 | } 120 | 121 | req, err := client.NewRequest("PUT", "role/"+name, putBody) 122 | if err != nil { 123 | cmd.Annotations["errors"] = fmt.Sprintf("Error creating request: %s", err.Error()) 124 | return err 125 | } 126 | 127 | resp, err := client.Client.Do(req) 128 | if err != nil { 129 | cmd.Annotations["errors"] = fmt.Sprintf("Error performing request: %s", err.Error()) 130 | return err 131 | } 132 | defer resp.Body.Close() 133 | 134 | bodyBytes, err := io.ReadAll(resp.Body) 135 | if err != nil { 136 | cmd.Annotations["errors"] = fmt.Sprintf("Error reading response: %s", err.Error()) 137 | return err 138 | } 139 | body := string(bodyBytes) 140 | 141 | if resp.StatusCode == 200 { 142 | fmt.Printf("Added role %s", name) 143 | } else { 144 | cmd.Annotations["errors"] = fmt.Sprintf("Request failed - Status: %s, Response: %s", resp.Status, body) 145 | fmt.Printf("Request Failed\nStatus Code: %s\nResponse: %s\n", resp.Status, body) 146 | } 147 | 148 | return nil 149 | }, 150 | } 151 | 152 | var RemoveRoleCmd = &cobra.Command{ 153 | Use: "remove role-name", 154 | Aliases: []string{"rm"}, 155 | Example: " pb role remove ingestor", 156 | Short: "Delete a role", 157 | Args: cobra.ExactArgs(1), 158 | RunE: func(cmd *cobra.Command, args []string) error { 159 | startTime := time.Now() 160 | cmd.Annotations = make(map[string]string) 161 | defer func() { 162 | cmd.Annotations["executionTime"] = time.Since(startTime).String() 163 | }() 164 | 165 | name := args[0] 166 | client := internalHTTP.DefaultClient(&DefaultProfile) 167 | req, err := client.NewRequest("DELETE", "role/"+name, nil) 168 | if err != nil { 169 | cmd.Annotations["errors"] = fmt.Sprintf("Error creating delete request: %s", err.Error()) 170 | return err 171 | } 172 | 173 | resp, err := client.Client.Do(req) 174 | if err != nil { 175 | cmd.Annotations["errors"] = fmt.Sprintf("Error performing delete request: %s", err.Error()) 176 | return err 177 | } 178 | defer resp.Body.Close() 179 | 180 | if resp.StatusCode == 200 { 181 | fmt.Printf("Removed role %s\n", StyleBold.Render(name)) 182 | } else { 183 | bodyBytes, err := io.ReadAll(resp.Body) 184 | if err != nil { 185 | cmd.Annotations["errors"] = fmt.Sprintf("Error reading response: %s", err.Error()) 186 | return err 187 | } 188 | body := string(bodyBytes) 189 | cmd.Annotations["errors"] = fmt.Sprintf("Request failed - Status: %s, Response: %s", resp.Status, body) 190 | fmt.Printf("Request Failed\nStatus Code: %s\nResponse: %s\n", resp.Status, body) 191 | } 192 | 193 | return nil 194 | }, 195 | } 196 | 197 | var ListRoleCmd = &cobra.Command{ 198 | Use: "list", 199 | Short: "List all roles", 200 | Example: " pb role list", 201 | RunE: func(cmd *cobra.Command, _ []string) error { 202 | startTime := time.Now() 203 | cmd.Annotations = make(map[string]string) 204 | defer func() { 205 | cmd.Annotations["executionTime"] = time.Since(startTime).String() 206 | }() 207 | 208 | var roles []string 209 | client := internalHTTP.DefaultClient(&DefaultProfile) 210 | err := fetchRoles(&client, &roles) 211 | if err != nil { 212 | cmd.Annotations["errors"] = fmt.Sprintf("Error fetching roles: %s", err.Error()) 213 | return err 214 | } 215 | 216 | outputFormat, err := cmd.Flags().GetString("output") 217 | if err != nil { 218 | cmd.Annotations["errors"] = fmt.Sprintf("Error retrieving output flag: %s", err.Error()) 219 | return err 220 | } 221 | 222 | roleResponses := make([]struct { 223 | data []RoleData 224 | err error 225 | }, len(roles)) 226 | 227 | var wg sync.WaitGroup 228 | for idx, role := range roles { 229 | wg.Add(1) 230 | go func(idx int, role string) { 231 | defer wg.Done() 232 | roleResponses[idx].data, roleResponses[idx].err = fetchSpecificRole(&client, role) 233 | }(idx, role) 234 | } 235 | wg.Wait() 236 | 237 | if outputFormat == "json" { 238 | allRoles := map[string][]RoleData{} 239 | for idx, roleName := range roles { 240 | if roleResponses[idx].err == nil { 241 | allRoles[roleName] = roleResponses[idx].data 242 | } 243 | } 244 | jsonOutput, err := json.MarshalIndent(allRoles, "", " ") 245 | if err != nil { 246 | cmd.Annotations["errors"] = fmt.Sprintf("Error marshaling JSON output: %s", err.Error()) 247 | return fmt.Errorf("failed to marshal JSON output: %w", err) 248 | } 249 | fmt.Println(string(jsonOutput)) 250 | return nil 251 | } 252 | 253 | fmt.Println() 254 | for idx, roleName := range roles { 255 | fetchRes := roleResponses[idx] 256 | fmt.Print("• ") 257 | fmt.Println(StandardStyleBold.Bold(true).Render(roleName)) 258 | if fetchRes.err == nil { 259 | for _, role := range fetchRes.data { 260 | fmt.Println(lipgloss.NewStyle().PaddingLeft(3).Render(role.Render())) 261 | } 262 | } else { 263 | fmt.Printf("Error fetching role data for %s: %v\n", roleName, fetchRes.err) 264 | cmd.Annotations["errors"] += fmt.Sprintf("Error fetching role data for %s: %v\n", roleName, fetchRes.err) 265 | } 266 | } 267 | 268 | return nil 269 | }, 270 | } 271 | 272 | func fetchRoles(client *internalHTTP.HTTPClient, data *[]string) error { 273 | req, err := client.NewRequest("GET", "role", nil) 274 | if err != nil { 275 | return err 276 | } 277 | 278 | resp, err := client.Client.Do(req) 279 | if err != nil { 280 | return err 281 | } 282 | 283 | bytes, err := io.ReadAll(resp.Body) 284 | if err != nil { 285 | return err 286 | } 287 | defer resp.Body.Close() 288 | 289 | if resp.StatusCode == 200 { 290 | err = json.Unmarshal(bytes, data) 291 | if err != nil { 292 | return err 293 | } 294 | } else { 295 | body := string(bytes) 296 | return fmt.Errorf("request failed\nstatus code: %s\nresponse: %s", resp.Status, body) 297 | } 298 | 299 | return nil 300 | } 301 | 302 | func fetchSpecificRole(client *internalHTTP.HTTPClient, role string) (res []RoleData, err error) { 303 | req, err := client.NewRequest("GET", fmt.Sprintf("role/%s", role), nil) 304 | if err != nil { 305 | return 306 | } 307 | 308 | resp, err := client.Client.Do(req) 309 | if err != nil { 310 | return 311 | } 312 | 313 | bytes, err := io.ReadAll(resp.Body) 314 | if err != nil { 315 | return 316 | } 317 | defer resp.Body.Close() 318 | 319 | if resp.StatusCode == 200 { 320 | err = json.Unmarshal(bytes, &res) 321 | if err != nil { 322 | return 323 | } 324 | } else { 325 | body := string(bytes) 326 | err = fmt.Errorf("request failed\nstatus code: %s\nresponse: %s", resp.Status, body) 327 | return 328 | } 329 | 330 | return 331 | } 332 | 333 | func init() { 334 | // Add the --output flag with default value "text" 335 | ListRoleCmd.Flags().StringP("output", "o", "text", "Output format: 'text' or 'json'") 336 | } 337 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Parseable, Inc 2 | // 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package main 18 | 19 | import ( 20 | "errors" 21 | "fmt" 22 | "os" 23 | "sync" 24 | 25 | pb "pb/cmd" 26 | "pb/pkg/analytics" 27 | "pb/pkg/config" 28 | 29 | "github.com/spf13/cobra" 30 | ) 31 | 32 | var wg sync.WaitGroup 33 | 34 | // populated at build time 35 | var ( 36 | Version string 37 | Commit string 38 | ) 39 | 40 | var ( 41 | versionFlag = "version" 42 | versionFlagShort = "v" 43 | ) 44 | 45 | func defaultInitialProfile() config.Profile { 46 | return config.Profile{ 47 | URL: "https://demo.parseable.com", 48 | Username: "admin", 49 | Password: "admin", 50 | } 51 | } 52 | 53 | // Root command 54 | var cli = &cobra.Command{ 55 | Use: "pb", 56 | Short: "\nParseable command line interface", 57 | Long: "\npb is the command line interface for Parseable", 58 | PersistentPreRunE: analytics.CheckAndCreateULID, 59 | RunE: func(command *cobra.Command, _ []string) error { 60 | if p, _ := command.Flags().GetBool(versionFlag); p { 61 | pb.PrintVersion(Version, Commit) 62 | return nil 63 | } 64 | return errors.New("no command or flag supplied") 65 | }, 66 | PersistentPostRun: func(cmd *cobra.Command, args []string) { 67 | if os.Getenv("PB_ANALYTICS") == "disable" { 68 | return 69 | } 70 | wg.Add(1) 71 | go func() { 72 | defer wg.Done() 73 | analytics.PostRunAnalytics(cmd, "cli", args) 74 | }() 75 | }, 76 | } 77 | 78 | var profile = &cobra.Command{ 79 | Use: "profile", 80 | Short: "Manage different Parseable targets", 81 | Long: "\nuse profile command to configure different Parseable instances. Each profile takes a URL and credentials.", 82 | PersistentPreRunE: combinedPreRun, 83 | PersistentPostRun: func(cmd *cobra.Command, args []string) { 84 | if os.Getenv("PB_ANALYTICS") == "disable" { 85 | return 86 | } 87 | wg.Add(1) 88 | go func() { 89 | defer wg.Done() 90 | analytics.PostRunAnalytics(cmd, "profile", args) 91 | }() 92 | }, 93 | } 94 | 95 | var schema = &cobra.Command{ 96 | Use: "schema", 97 | Short: "Generate or create schemas for JSON data or Parseable streams", 98 | Long: `The "schema" command allows you to either: 99 | - Generate a schema automatically from a JSON file for analysis or integration. 100 | - Create a custom schema for Parseable streams (PB streams) to structure and process your data. 101 | 102 | Examples: 103 | - To generate a schema from a JSON file: 104 | pb schema generate --file=data.json 105 | - To create a schema for a PB stream: 106 | pb schema create --stream-name=my_stream --config=data.json 107 | `, 108 | PersistentPreRunE: combinedPreRun, 109 | PersistentPostRun: func(cmd *cobra.Command, args []string) { 110 | if os.Getenv("PB_ANALYTICS") == "disable" { 111 | return 112 | } 113 | wg.Add(1) 114 | go func() { 115 | defer wg.Done() 116 | analytics.PostRunAnalytics(cmd, "generate", args) 117 | }() 118 | }, 119 | } 120 | 121 | var user = &cobra.Command{ 122 | Use: "user", 123 | Short: "Manage users", 124 | Long: "\nuser command is used to manage users.", 125 | PersistentPreRunE: combinedPreRun, 126 | PersistentPostRun: func(cmd *cobra.Command, args []string) { 127 | if os.Getenv("PB_ANALYTICS") == "disable" { 128 | return 129 | } 130 | wg.Add(1) 131 | go func() { 132 | defer wg.Done() 133 | analytics.PostRunAnalytics(cmd, "user", args) 134 | }() 135 | }, 136 | } 137 | 138 | var role = &cobra.Command{ 139 | Use: "role", 140 | Short: "Manage roles", 141 | Long: "\nrole command is used to manage roles.", 142 | PersistentPreRunE: combinedPreRun, 143 | PersistentPostRun: func(cmd *cobra.Command, args []string) { 144 | if os.Getenv("PB_ANALYTICS") == "disable" { 145 | return 146 | } 147 | wg.Add(1) 148 | go func() { 149 | defer wg.Done() 150 | analytics.PostRunAnalytics(cmd, "role", args) 151 | }() 152 | }, 153 | } 154 | 155 | var stream = &cobra.Command{ 156 | Use: "stream", 157 | Short: "Manage streams", 158 | Long: "\nstream command is used to manage streams.", 159 | PersistentPreRunE: combinedPreRun, 160 | PersistentPostRun: func(cmd *cobra.Command, args []string) { 161 | if os.Getenv("PB_ANALYTICS") == "disable" { 162 | return 163 | } 164 | wg.Add(1) 165 | go func() { 166 | defer wg.Done() 167 | analytics.PostRunAnalytics(cmd, "stream", args) 168 | }() 169 | }, 170 | } 171 | 172 | var query = &cobra.Command{ 173 | Use: "query", 174 | Short: "Run SQL query on a log stream", 175 | Long: "\nRun SQL query on a log stream. Default output format is json. Use -i flag to open interactive table view.", 176 | PersistentPreRunE: combinedPreRun, 177 | PersistentPostRun: func(cmd *cobra.Command, args []string) { 178 | if os.Getenv("PB_ANALYTICS") == "disable" { 179 | return 180 | } 181 | wg.Add(1) 182 | go func() { 183 | defer wg.Done() 184 | analytics.PostRunAnalytics(cmd, "query", args) 185 | }() 186 | }, 187 | } 188 | 189 | var cluster = &cobra.Command{ 190 | Use: "cluster", 191 | Short: "Cluster operations for Parseable.", 192 | Long: "\nCluster operations for Parseable cluster on Kubernetes.", 193 | PersistentPreRunE: combinedPreRun, 194 | PersistentPostRun: func(cmd *cobra.Command, args []string) { 195 | if os.Getenv("PB_ANALYTICS") == "disable" { 196 | return 197 | } 198 | wg.Add(1) 199 | go func() { 200 | defer wg.Done() 201 | analytics.PostRunAnalytics(cmd, "install", args) 202 | }() 203 | }, 204 | } 205 | 206 | var list = &cobra.Command{ 207 | Use: "list", 208 | Short: "List parseable on kubernetes cluster", 209 | Long: "\nlist command is used to list Parseable oss installations.", 210 | PersistentPreRunE: combinedPreRun, 211 | PersistentPostRun: func(cmd *cobra.Command, args []string) { 212 | if os.Getenv("PB_ANALYTICS") == "disable" { 213 | return 214 | } 215 | wg.Add(1) 216 | go func() { 217 | defer wg.Done() 218 | analytics.PostRunAnalytics(cmd, "install", args) 219 | }() 220 | }, 221 | } 222 | 223 | var show = &cobra.Command{ 224 | Use: "show", 225 | Short: "Show outputs values defined when installing Parseable on kubernetes cluster", 226 | Long: "\nshow command is used to get values in Parseable.", 227 | PersistentPreRunE: combinedPreRun, 228 | PersistentPostRun: func(cmd *cobra.Command, args []string) { 229 | if os.Getenv("PB_ANALYTICS") == "disable" { 230 | return 231 | } 232 | wg.Add(1) 233 | go func() { 234 | defer wg.Done() 235 | analytics.PostRunAnalytics(cmd, "install", args) 236 | }() 237 | }, 238 | } 239 | 240 | var uninstall = &cobra.Command{ 241 | Use: "uninstall", 242 | Short: "Uninstall Parseable on kubernetes cluster", 243 | Long: "\nuninstall command is used to uninstall Parseable oss/enterprise on k8s cluster.", 244 | PersistentPreRunE: combinedPreRun, 245 | PersistentPostRun: func(cmd *cobra.Command, args []string) { 246 | if os.Getenv("PB_ANALYTICS") == "disable" { 247 | return 248 | } 249 | wg.Add(1) 250 | go func() { 251 | defer wg.Done() 252 | analytics.PostRunAnalytics(cmd, "uninstall", args) 253 | }() 254 | }, 255 | } 256 | 257 | func main() { 258 | profile.AddCommand(pb.AddProfileCmd) 259 | profile.AddCommand(pb.RemoveProfileCmd) 260 | profile.AddCommand(pb.ListProfileCmd) 261 | profile.AddCommand(pb.DefaultProfileCmd) 262 | 263 | user.AddCommand(pb.AddUserCmd) 264 | user.AddCommand(pb.RemoveUserCmd) 265 | user.AddCommand(pb.ListUserCmd) 266 | user.AddCommand(pb.SetUserRoleCmd) 267 | 268 | role.AddCommand(pb.AddRoleCmd) 269 | role.AddCommand(pb.RemoveRoleCmd) 270 | role.AddCommand(pb.ListRoleCmd) 271 | 272 | stream.AddCommand(pb.AddStreamCmd) 273 | stream.AddCommand(pb.RemoveStreamCmd) 274 | stream.AddCommand(pb.ListStreamCmd) 275 | stream.AddCommand(pb.StatStreamCmd) 276 | 277 | query.AddCommand(pb.QueryCmd) 278 | query.AddCommand(pb.SavedQueryList) 279 | 280 | schema.AddCommand(pb.GenerateSchemaCmd) 281 | schema.AddCommand(pb.CreateSchemaCmd) 282 | 283 | cluster.AddCommand(pb.InstallOssCmd) 284 | cluster.AddCommand(pb.ListOssCmd) 285 | cluster.AddCommand(pb.ShowValuesCmd) 286 | cluster.AddCommand(pb.UninstallOssCmd) 287 | 288 | list.AddCommand(pb.ListOssCmd) 289 | 290 | uninstall.AddCommand(pb.UninstallOssCmd) 291 | 292 | show.AddCommand(pb.ShowValuesCmd) 293 | 294 | cli.AddCommand(profile) 295 | cli.AddCommand(query) 296 | cli.AddCommand(stream) 297 | cli.AddCommand(user) 298 | cli.AddCommand(role) 299 | cli.AddCommand(pb.TailCmd) 300 | cli.AddCommand(cluster) 301 | 302 | cli.AddCommand(pb.AutocompleteCmd) 303 | 304 | // Set as command 305 | pb.VersionCmd.Run = func(_ *cobra.Command, _ []string) { 306 | pb.PrintVersion(Version, Commit) 307 | } 308 | 309 | cli.AddCommand(pb.VersionCmd) 310 | // set as flag 311 | cli.Flags().BoolP(versionFlag, versionFlagShort, false, "Print version") 312 | 313 | cli.CompletionOptions.HiddenDefaultCmd = true 314 | 315 | // create a default profile if file does not exist 316 | if previousConfig, err := config.ReadConfigFromFile(); os.IsNotExist(err) { 317 | conf := config.Config{ 318 | Profiles: map[string]config.Profile{"demo": defaultInitialProfile()}, 319 | DefaultProfile: "demo", 320 | } 321 | err = config.WriteConfigToFile(&conf) 322 | if err != nil { 323 | fmt.Printf("failed to write to file %v\n", err) 324 | os.Exit(1) 325 | } 326 | } else { 327 | // Only update the "demo" profile without overwriting other profiles 328 | demoProfile, exists := previousConfig.Profiles["demo"] 329 | if exists { 330 | // Update fields in the demo profile only 331 | demoProfile.URL = "http://demo.parseable.com" 332 | demoProfile.Username = "admin" 333 | demoProfile.Password = "admin" 334 | previousConfig.Profiles["demo"] = demoProfile 335 | } else { 336 | // Add the "demo" profile if it doesn't exist 337 | previousConfig.Profiles["demo"] = defaultInitialProfile() 338 | previousConfig.DefaultProfile = "demo" // Optional: set as default if needed 339 | } 340 | 341 | // Write the updated configuration back to file 342 | err = config.WriteConfigToFile(previousConfig) 343 | if err != nil { 344 | fmt.Printf("failed to write to existing file %v\n", err) 345 | os.Exit(1) 346 | } 347 | } 348 | 349 | err := cli.Execute() 350 | if err != nil { 351 | os.Exit(1) 352 | } 353 | wg.Wait() 354 | } 355 | 356 | // Wrapper to combine existing pre-run logic and ULID check 357 | func combinedPreRun(cmd *cobra.Command, args []string) error { 358 | err := pb.PreRunDefaultProfile(cmd, args) 359 | if err != nil { 360 | return fmt.Errorf("error initializing default profile: %w", err) 361 | } 362 | 363 | if err := analytics.CheckAndCreateULID(cmd, args); err != nil { 364 | return fmt.Errorf("error while creating ulid: %v", err) 365 | } 366 | 367 | return nil 368 | } 369 | -------------------------------------------------------------------------------- /pkg/helm/helm.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Parseable, Inc 2 | // 3 | // This program is free software: you can redistribute it and/or modify 4 | // it under the terms of the GNU Affero General Public License as published by 5 | // the Free Software Foundation, either version 3 of the License, or 6 | // (at your option) any later version. 7 | // 8 | // This program is distributed in the hope that it will be useful 9 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | // GNU Affero General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU Affero General Public License 14 | // along with this program. If not, see . 15 | 16 | package helm 17 | 18 | import ( 19 | "context" 20 | "fmt" 21 | "log" 22 | "os" 23 | "path/filepath" 24 | "strings" 25 | "time" 26 | 27 | "github.com/gofrs/flock" 28 | "github.com/pkg/errors" 29 | "gopkg.in/yaml.v3" 30 | "helm.sh/helm/v3/pkg/action" 31 | "helm.sh/helm/v3/pkg/chart/loader" 32 | "helm.sh/helm/v3/pkg/cli" 33 | "helm.sh/helm/v3/pkg/cli/values" 34 | "helm.sh/helm/v3/pkg/getter" 35 | "helm.sh/helm/v3/pkg/release" 36 | "helm.sh/helm/v3/pkg/repo" 37 | ) 38 | 39 | type Helm struct { 40 | ReleaseName string 41 | Namespace string 42 | Values []string 43 | RepoName string 44 | ChartName string 45 | RepoURL string 46 | Version string 47 | } 48 | 49 | func ListReleases(namespace string) ([]*release.Release, error) { 50 | settings := cli.New() 51 | 52 | actionConfig := new(action.Configuration) 53 | if err := actionConfig.Init(settings.RESTClientGetter(), namespace, os.Getenv("HELM_DRIVER"), log.Printf); err != nil { 54 | return nil, err 55 | } 56 | 57 | client := action.NewList(actionConfig) 58 | // client.Deployed = true 59 | 60 | return client.Run() 61 | } 62 | 63 | // Apply applies a Helm chart using the provided Helm struct configuration. 64 | // It returns an error if any operation fails, otherwise, it returns nil. 65 | func Apply(h Helm, verbose bool) error { 66 | // Create a logger that does nothing by default 67 | silentLogger := func(_ string, _ ...interface{}) {} 68 | 69 | // Create settings 70 | settings := cli.New() 71 | 72 | // Create action configuration 73 | actionConfig := new(action.Configuration) 74 | 75 | // Choose logging method based on verbose flag 76 | logMethod := silentLogger 77 | if verbose { 78 | logMethod = log.Printf 79 | } 80 | 81 | // Initialize action configuration with chosen logger 82 | if err := actionConfig.Init( 83 | settings.RESTClientGetter(), 84 | h.Namespace, 85 | os.Getenv("HELM_DRIVER"), 86 | logMethod, 87 | ); err != nil { 88 | return fmt.Errorf("failed to initialize Helm configuration: %w", err) 89 | } 90 | 91 | // Create a new Install action 92 | client := action.NewInstall(actionConfig) 93 | // Setting Namespace 94 | settings.SetNamespace(h.Namespace) 95 | settings.EnvVars() 96 | // Add repository 97 | repoAdd(h) 98 | 99 | // RepoUpdate() 100 | 101 | // Locate chart path 102 | cp, err := client.ChartPathOptions.LocateChart(fmt.Sprintf("%s/%s", h.RepoName, h.ChartName), settings) 103 | if err != nil { 104 | return err 105 | } 106 | 107 | // Load chart 108 | chartRequested, err := loader.Load(cp) 109 | if err != nil { 110 | return err 111 | } 112 | 113 | // Set action options 114 | client.ReleaseName = h.ReleaseName 115 | client.Namespace = h.Namespace 116 | client.Version = h.Version 117 | client.CreateNamespace = true 118 | client.Wait = true 119 | client.Timeout = 300 * time.Second 120 | client.WaitForJobs = true 121 | // client.IncludeCRDs = true 122 | 123 | // Merge values 124 | values := values.Options{ 125 | Values: h.Values, 126 | } 127 | 128 | vals, err := values.MergeValues(getter.All(settings)) 129 | if err != nil { 130 | return err 131 | } 132 | // Run the Install action 133 | _, err = client.Run(chartRequested, vals) 134 | if err != nil { 135 | return err 136 | } 137 | return nil 138 | } 139 | 140 | // repoAdd adds a Helm repository. 141 | // It takes a Helm struct as input containing the repository name and URL. 142 | func repoAdd(h Helm) error { 143 | // Initialize CLI settings 144 | settings := cli.New() 145 | 146 | // Get the repository file path 147 | repoFile := settings.RepositoryConfig 148 | 149 | // Ensure the file directory exists as it is required for file locking 150 | err := os.MkdirAll(filepath.Dir(repoFile), os.ModePerm) 151 | if err != nil && !os.IsExist(err) { 152 | return err 153 | } 154 | 155 | // Acquire a file lock for process synchronization 156 | fileLock := flock.New(strings.Replace(repoFile, filepath.Ext(repoFile), ".lock", 1)) 157 | lockCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 158 | defer cancel() 159 | locked, err := fileLock.TryLockContext(lockCtx, time.Second) 160 | 161 | if err == nil && locked { 162 | defer fileLock.Unlock() 163 | } 164 | 165 | if err != nil { 166 | return err 167 | } 168 | 169 | // Read the repository file 170 | b, err := os.ReadFile(repoFile) 171 | if err != nil && !os.IsNotExist(err) { 172 | return err 173 | } 174 | 175 | // Unmarshal repository file content 176 | var f repo.File 177 | if err := yaml.Unmarshal(b, &f); err != nil { 178 | return err 179 | } 180 | 181 | // Create a new repository entry 182 | c := repo.Entry{ 183 | Name: h.RepoName, 184 | URL: h.RepoURL, 185 | } 186 | 187 | // Check if the repository is already added, update it 188 | if f.Has(h.RepoName) { 189 | r, err := repo.NewChartRepository(&c, getter.All(settings)) 190 | if err != nil { 191 | return err 192 | } 193 | 194 | // Download the index file to update helm repo 195 | if _, err := r.DownloadIndexFile(); err != nil { 196 | err := errors.Wrapf(err, "looks like we are unable to update helm repo %q", h.RepoURL) 197 | return err 198 | } 199 | return nil 200 | } 201 | // Create a new chart repository 202 | r, err := repo.NewChartRepository(&c, getter.All(settings)) 203 | if err != nil { 204 | return err 205 | } 206 | 207 | // Download the index file 208 | if _, err := r.DownloadIndexFile(); err != nil { 209 | err := errors.Wrapf(err, "looks like %q is not a valid chart repository or cannot be reached", h.RepoURL) 210 | return err 211 | } 212 | 213 | // Update repository file with the new entry 214 | f.Update(&c) 215 | 216 | // Write the updated repository file 217 | if err := f.WriteFile(repoFile, 0o644); err != nil { 218 | return err 219 | } 220 | return nil 221 | } 222 | 223 | // ListRelease lists Helm releases based on the specified chart name and namespace. 224 | // It returns an error if any operation fails, otherwise, it returns nil. 225 | func ListRelease(releaseName, namespace string) (bool, error) { 226 | settings := cli.New() 227 | 228 | // Initialize action configuration 229 | actionConfig := new(action.Configuration) 230 | if err := actionConfig.Init(settings.RESTClientGetter(), namespace, os.Getenv("HELM_DRIVER"), log.Printf); err != nil { 231 | return false, err 232 | } 233 | 234 | // Create a new List action 235 | client := action.NewList(actionConfig) 236 | 237 | // Run the List action to get releases 238 | releases, err := client.Run() 239 | if err != nil { 240 | return false, err 241 | } 242 | 243 | if len(releases) == 0 { 244 | fmt.Println("No release exist, install app", releaseName) 245 | return false, nil 246 | } 247 | 248 | // Iterate over the releases 249 | for _, release := range releases { 250 | // Check if the release's chart name matches the specified chart name 251 | if release.Name == releaseName { 252 | return true, nil 253 | } 254 | } 255 | 256 | // If no release with the specified chart name is found, return an error 257 | return false, nil 258 | } 259 | 260 | func GetReleaseValues(releaseName, namespace string) (map[string]interface{}, error) { 261 | settings := cli.New() 262 | 263 | // Initialize action configuration 264 | actionConfig := new(action.Configuration) 265 | if err := actionConfig.Init(settings.RESTClientGetter(), namespace, os.Getenv("HELM_DRIVER"), log.Printf); err != nil { 266 | return nil, err 267 | } 268 | 269 | // Create a new get action 270 | client := action.NewGet(actionConfig) 271 | 272 | release, err := client.Run(releaseName) 273 | if err != nil { 274 | return nil, err 275 | } 276 | 277 | return release.Config, nil 278 | } 279 | 280 | // DeleteRelease deletes a Helm release based on the specified chart name and namespace. 281 | func DeleteRelease(chartName, namespace string) error { 282 | settings := cli.New() 283 | settings.SetNamespace(namespace) 284 | settings.EnvVars() 285 | // Initialize action configuration 286 | actionConfig := new(action.Configuration) 287 | if err := actionConfig.Init(settings.RESTClientGetter(), namespace, os.Getenv("HELM_DRIVER"), log.Printf); err != nil { 288 | return err 289 | } 290 | 291 | // Create a new Uninstall action 292 | client := action.NewUninstall(actionConfig) 293 | // Run the Uninstall action to delete the release 294 | _, err := client.Run(chartName) 295 | if err != nil { 296 | return err 297 | } 298 | return nil 299 | } 300 | 301 | func Upgrade(h Helm) error { 302 | settings := cli.New() 303 | 304 | // Initialize action configuration 305 | actionConfig := new(action.Configuration) 306 | if err := actionConfig.Init(settings.RESTClientGetter(), h.Namespace, os.Getenv("HELM_DRIVER"), log.Printf); err != nil { 307 | return err 308 | } 309 | 310 | // Create a new Install action 311 | client := action.NewUpgrade(actionConfig) 312 | // Setting Namespace 313 | settings.SetNamespace(h.Namespace) 314 | settings.EnvVars() 315 | // Add repository 316 | repoAdd(h) 317 | 318 | // RepoUpdate() 319 | 320 | // Locate chart path 321 | cp, err := client.ChartPathOptions.LocateChart(fmt.Sprintf("%s/%s", h.RepoName, h.ChartName), settings) 322 | if err != nil { 323 | return err 324 | } 325 | 326 | // Load chart 327 | chartRequested, err := loader.Load(cp) 328 | if err != nil { 329 | return err 330 | } 331 | 332 | // Set action options 333 | client.Namespace = h.ReleaseName 334 | client.Namespace = h.Namespace 335 | client.Version = h.Version 336 | client.Wait = true 337 | client.Timeout = 300 * time.Second 338 | client.WaitForJobs = true 339 | // client.IncludeCRDs = true 340 | 341 | // Merge values 342 | values := values.Options{ 343 | Values: h.Values, 344 | } 345 | 346 | vals, err := values.MergeValues(getter.All(settings)) 347 | if err != nil { 348 | return err 349 | } 350 | // Run the Install action 351 | _, err = client.Run(h.ReleaseName, chartRequested, vals) 352 | if err != nil { 353 | return err 354 | } 355 | return nil 356 | } 357 | 358 | func Uninstall(h Helm, verbose bool) (*release.UninstallReleaseResponse, error) { 359 | // Create a logger that does nothing by default 360 | silentLogger := func(_ string, _ ...interface{}) {} 361 | 362 | // Create settings 363 | settings := cli.New() 364 | 365 | // Create action configuration 366 | actionConfig := new(action.Configuration) 367 | 368 | // Choose logging method based on verbose flag 369 | logMethod := silentLogger 370 | if verbose { 371 | logMethod = log.Printf 372 | } 373 | 374 | // Initialize action configuration with chosen logger 375 | if err := actionConfig.Init( 376 | settings.RESTClientGetter(), 377 | h.Namespace, 378 | os.Getenv("HELM_DRIVER"), 379 | logMethod, 380 | ); err != nil { 381 | return &release.UninstallReleaseResponse{}, fmt.Errorf("failed to initialize Helm configuration: %w", err) 382 | } 383 | 384 | client := action.NewUninstall(actionConfig) 385 | // Setting Namespace 386 | settings.SetNamespace(h.Namespace) 387 | settings.EnvVars() 388 | 389 | settings.EnvVars() 390 | 391 | client.Wait = true 392 | client.Timeout = 5 * time.Minute 393 | 394 | resp, err := client.Run(h.ReleaseName) 395 | if err != nil { 396 | return &release.UninstallReleaseResponse{}, err 397 | } 398 | 399 | return resp, nil 400 | } 401 | -------------------------------------------------------------------------------- /pkg/analytics/analytics.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Parseable, Inc 2 | // 3 | // This program is free software: you can redistribute it and/or modify 4 | // it under the terms of the GNU Affero General Public License as published by 5 | // the Free Software Foundation, either version 3 of the License, or 6 | // (at your option) any later version. 7 | // 8 | // This program is distributed in the hope that it will be useful 9 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | // GNU Affero General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU Affero General Public License 14 | // along with this program. If not, see . 15 | 16 | package analytics 17 | 18 | import ( 19 | "bytes" 20 | "encoding/json" 21 | "errors" 22 | "fmt" 23 | "io" 24 | "math/rand" 25 | "net/http" 26 | "os" 27 | "os/exec" 28 | "path/filepath" 29 | "runtime" 30 | "strings" 31 | "time" 32 | 33 | "pb/pkg/config" 34 | internalHTTP "pb/pkg/http" 35 | 36 | "github.com/oklog/ulid/v2" 37 | "github.com/spf13/cobra" 38 | "github.com/spf13/pflag" 39 | "gopkg.in/yaml.v2" 40 | ) 41 | 42 | type Event struct { 43 | CLIVersion string `json:"cli_version"` 44 | ULID string `json:"ulid"` 45 | CommitHash string `json:"commit_hash"` 46 | OSName string `json:"os_name"` 47 | OSVersion string `json:"os_version"` 48 | ReportCreatedAt string `json:"report_created_at"` 49 | Command Command `json:"command"` 50 | Errors *string `json:"errors"` 51 | ExecutionTimestamp string `json:"execution_timestamp"` 52 | } 53 | 54 | // About struct 55 | type About struct { 56 | Version string `json:"version"` 57 | UIVersion string `json:"uiVersion"` 58 | Commit string `json:"commit"` 59 | DeploymentID string `json:"deploymentId"` 60 | UpdateAvailable bool `json:"updateAvailable"` 61 | LatestVersion string `json:"latestVersion"` 62 | LLMActive bool `json:"llmActive"` 63 | LLMProvider string `json:"llmProvider"` 64 | OIDCActive bool `json:"oidcActive"` 65 | License string `json:"license"` 66 | Mode string `json:"mode"` 67 | Staging string `json:"staging"` 68 | HotTier string `json:"hotTier"` 69 | GRPCPort int `json:"grpcPort"` 70 | Store Store `json:"store"` 71 | Analytics Analytics `json:"analytics"` 72 | QueryEngine string `json:"queryEngine"` 73 | } 74 | 75 | // Store struct 76 | type Store struct { 77 | Type string `json:"type"` 78 | Path string `json:"path"` 79 | } 80 | 81 | // Analytics struct 82 | type Analytics struct { 83 | ClarityTag string `json:"clarityTag"` 84 | } 85 | 86 | type Command struct { 87 | Name string `json:"name"` 88 | Arguments []string `json:"arguments"` 89 | Flags map[string]string `json:"flags"` 90 | } 91 | 92 | // Config struct for parsing YAML 93 | type Config struct { 94 | ULID string `yaml:"ulid"` 95 | } 96 | 97 | // CheckAndCreateULID checks for a ULID in the config file and creates it if absent. 98 | func CheckAndCreateULID(_ *cobra.Command, _ []string) error { 99 | homeDir, err := os.UserHomeDir() 100 | if err != nil { 101 | fmt.Printf("could not find home directory: %v\n", err) 102 | return err 103 | } 104 | 105 | configPath := filepath.Join(homeDir, ".parseable", "config.yaml") 106 | 107 | // Check if config path exists 108 | if _, err := os.Stat(configPath); os.IsNotExist(err) { 109 | // Create the directory if needed 110 | if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { 111 | fmt.Printf("could not create config directory: %v\n", err) 112 | return err 113 | } 114 | } 115 | 116 | // Read the config file 117 | var config Config 118 | data, err := os.ReadFile(configPath) 119 | if err == nil { 120 | // If the file exists, unmarshal the content 121 | if err := yaml.Unmarshal(data, &config); err != nil { 122 | fmt.Printf("could not parse config file: %v\n", err) 123 | return err 124 | } 125 | } 126 | 127 | // Check if ULID is missing 128 | if config.ULID == "" { 129 | // Generate a new ULID 130 | entropy := ulid.Monotonic(rand.New(rand.NewSource(time.Now().UnixNano())), 0) 131 | ulidInstance := ulid.MustNew(ulid.Timestamp(time.Now()), entropy) 132 | config.ULID = ulidInstance.String() 133 | 134 | newData, err := yaml.Marshal(&config) 135 | if err != nil { 136 | fmt.Printf("could not marshal config data: %v\n", err) 137 | return err 138 | } 139 | 140 | // Write updated config with ULID back to the file 141 | if err := os.WriteFile(configPath, newData, 0o644); err != nil { 142 | fmt.Printf("could not write to config file: %v\n", err) 143 | return err 144 | } 145 | fmt.Printf("Generated and saved new ULID: %s\n", config.ULID) 146 | } 147 | 148 | return nil 149 | } 150 | 151 | func PostRunAnalytics(cmd *cobra.Command, name string, args []string) { 152 | executionTime := cmd.Annotations["executionTime"] 153 | commandError := cmd.Annotations["error"] 154 | flags := make(map[string]string) 155 | cmd.Flags().VisitAll(func(flag *pflag.Flag) { 156 | flags[flag.Name] = flag.Value.String() 157 | }) 158 | 159 | // Call SendEvent in PostRunE 160 | err := sendEvent( 161 | name, 162 | append(args, cmd.Name()), 163 | &commandError, // Pass the error here if there was one 164 | executionTime, 165 | flags, 166 | ) 167 | if err != nil { 168 | fmt.Println("Error sending analytics event:", err) 169 | } 170 | } 171 | 172 | // sendEvent is a placeholder function to simulate sending an event after command execution. 173 | func sendEvent(commandName string, arguments []string, errors *string, executionTimestamp string, flags map[string]string) error { 174 | ulid, err := ReadUULD() 175 | if err != nil { 176 | return fmt.Errorf("could not load ULID: %v", err) 177 | } 178 | 179 | profile, err := GetProfile() 180 | if err != nil { 181 | return fmt.Errorf("failed to get profile: %v", err) 182 | } 183 | 184 | httpClient := internalHTTP.DefaultClient(&profile) 185 | 186 | about, _ := FetchAbout(&httpClient) 187 | // if err != nil { 188 | // return fmt.Errorf("failed to get about metadata for profile: %v", err) 189 | // } 190 | 191 | // Create the Command struct 192 | cmd := Command{ 193 | Name: commandName, 194 | Arguments: arguments, 195 | Flags: flags, 196 | } 197 | 198 | // Populate the Event struct with OS details and timestamp 199 | event := Event{ 200 | CLIVersion: about.Commit, 201 | ULID: ulid, 202 | CommitHash: about.Commit, 203 | OSName: GetOSName(), 204 | OSVersion: GetOSVersion(), 205 | ReportCreatedAt: GetCurrentTimestamp(), 206 | Command: cmd, 207 | Errors: errors, 208 | ExecutionTimestamp: executionTimestamp, 209 | } 210 | 211 | // Marshal the event to JSON for sending 212 | eventJSON, err := json.Marshal(event) 213 | if err != nil { 214 | return fmt.Errorf("failed to marshal event JSON: %v", err) 215 | } 216 | 217 | // Define the target URL for the HTTP request 218 | url := "https://analytics.parseable.io:80/pb" 219 | 220 | // Create the HTTP POST request 221 | req, err := http.NewRequest("POST", url, bytes.NewBuffer(eventJSON)) 222 | if err != nil { 223 | return fmt.Errorf("failed to create HTTP request: %v", err) 224 | } 225 | req.Header.Set("Content-Type", "application/json") 226 | req.Header.Set("X-P-Stream", "pb-usage") 227 | 228 | // Execute the HTTP request 229 | resp, err := httpClient.Client.Do(req) 230 | if err != nil { 231 | return fmt.Errorf("failed to send event: %v", err) 232 | } 233 | defer resp.Body.Close() 234 | 235 | // Check for a non-2xx status code 236 | if resp.StatusCode < 200 || resp.StatusCode >= 300 { 237 | return fmt.Errorf("received non-2xx response: %v", resp.Status) 238 | } 239 | 240 | return nil 241 | } 242 | 243 | // GetOSName retrieves the OS name. 244 | func GetOSName() string { 245 | switch runtime.GOOS { 246 | case "windows": 247 | return "Windows" 248 | case "darwin": 249 | return "macOS" 250 | case "linux": 251 | return getLinuxDistro() 252 | default: 253 | return "Unknown" 254 | } 255 | } 256 | 257 | // GetOSVersion retrieves the OS version. 258 | func GetOSVersion() string { 259 | switch runtime.GOOS { 260 | case "windows": 261 | return getWindowsVersion() 262 | case "darwin": 263 | return getMacOSVersion() 264 | case "linux": 265 | return getLinuxVersion() 266 | default: 267 | return "Unknown" 268 | } 269 | } 270 | 271 | // GetCurrentTimestamp returns the current timestamp in ISO 8601 format. 272 | func GetCurrentTimestamp() string { 273 | return time.Now().Format(time.RFC3339) 274 | } 275 | 276 | // GetFormattedTimestamp formats a given time.Time in ISO 8601 format. 277 | func GetFormattedTimestamp(t time.Time) string { 278 | return t.Format(time.RFC3339) 279 | } 280 | 281 | // getLinuxDistro retrieves the Linux distribution name. 282 | func getLinuxDistro() string { 283 | data, err := os.ReadFile("/etc/os-release") 284 | if err != nil { 285 | return "Linux" 286 | } 287 | for _, line := range strings.Split(string(data), "\n") { 288 | if strings.HasPrefix(line, "NAME=") { 289 | return strings.Trim(line[5:], "\"") 290 | } 291 | } 292 | return "Linux" 293 | } 294 | 295 | // getLinuxVersion retrieves the Linux distribution version. 296 | func getLinuxVersion() string { 297 | data, err := os.ReadFile("/etc/os-release") 298 | if err != nil { 299 | return "Unknown" 300 | } 301 | for _, line := range strings.Split(string(data), "\n") { 302 | if strings.HasPrefix(line, "VERSION_ID=") { 303 | return strings.Trim(line[11:], "\"") 304 | } 305 | } 306 | return "Unknown" 307 | } 308 | 309 | // getMacOSVersion retrieves the macOS version. 310 | func getMacOSVersion() string { 311 | out, err := exec.Command("sw_vers", "-productVersion").Output() 312 | if err != nil { 313 | return "Unknown" 314 | } 315 | return strings.TrimSpace(string(out)) 316 | } 317 | 318 | // getWindowsVersion retrieves the Windows version. 319 | func getWindowsVersion() string { 320 | out, err := exec.Command("cmd", "ver").Output() 321 | if err != nil { 322 | return "Unknown" 323 | } 324 | return strings.TrimSpace(string(out)) 325 | } 326 | 327 | func ReadUULD() (string, error) { 328 | homeDir, err := os.UserHomeDir() 329 | if err != nil { 330 | return "", fmt.Errorf("could not find home directory: %v", err) 331 | } 332 | 333 | configPath := filepath.Join(homeDir, ".parseable", "config.yaml") 334 | 335 | // Check if config path exists 336 | if _, err := os.Stat(configPath); os.IsNotExist(err) { 337 | return "", fmt.Errorf("config file does not exist, please run CheckAndCreateULID first") 338 | } 339 | 340 | // Read the config file 341 | var config Config 342 | data, err := os.ReadFile(configPath) 343 | if err != nil { 344 | return "", fmt.Errorf("could not read config file: %v", err) 345 | } 346 | 347 | // Unmarshal the content to get the ULID 348 | if err := yaml.Unmarshal(data, &config); err != nil { 349 | return "", fmt.Errorf("could not parse config file: %v", err) 350 | } 351 | 352 | if config.ULID == "" { 353 | return "", fmt.Errorf("ULID is missing in config file") 354 | } 355 | 356 | return config.ULID, nil 357 | } 358 | 359 | func FetchAbout(client *internalHTTP.HTTPClient) (about About, err error) { 360 | req, err := client.NewRequest("GET", "about", nil) 361 | if err != nil { 362 | return 363 | } 364 | 365 | resp, err := client.Client.Do(req) 366 | if err != nil { 367 | return 368 | } 369 | 370 | bytes, err := io.ReadAll(resp.Body) 371 | if err != nil { 372 | return 373 | } 374 | defer resp.Body.Close() 375 | 376 | if resp.StatusCode == 200 { 377 | err = json.Unmarshal(bytes, &about) 378 | } else { 379 | body := string(bytes) 380 | body = fmt.Sprintf("Request Failed\nStatus Code: %s\nResponse: %s\n", resp.Status, body) 381 | err = errors.New(body) 382 | } 383 | return 384 | } 385 | 386 | func GetProfile() (config.Profile, error) { 387 | conf, err := config.ReadConfigFromFile() 388 | if os.IsNotExist(err) { 389 | return config.Profile{}, errors.New("no config found to run this command. add a profile using pb profile command") 390 | } else if err != nil { 391 | return config.Profile{}, err 392 | } 393 | 394 | if conf.Profiles == nil || conf.DefaultProfile == "" { 395 | return config.Profile{}, errors.New("no profile is configured to run this command. please create one using profile command") 396 | } 397 | 398 | return conf.Profiles[conf.DefaultProfile], nil 399 | } 400 | -------------------------------------------------------------------------------- /cmd/user.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Parseable, Inc 2 | // 3 | // This program is free software: you can redistribute it and/or modify 4 | // it under the terms of the GNU Affero General Public License as published by 5 | // the Free Software Foundation, either version 3 of the License, or 6 | // (at your option) any later version. 7 | // 8 | // This program is distributed in the hope that it will be useful 9 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | // GNU Affero General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU Affero General Public License 14 | // along with this program. If not, see . 15 | 16 | package cmd 17 | 18 | import ( 19 | "bytes" 20 | "encoding/json" 21 | "fmt" 22 | "io" 23 | internalHTTP "pb/pkg/http" 24 | "strings" 25 | "sync" 26 | "time" 27 | 28 | "github.com/charmbracelet/lipgloss" 29 | "github.com/spf13/cobra" 30 | "golang.org/x/exp/slices" 31 | ) 32 | 33 | type UserData struct { 34 | ID string `json:"id"` 35 | Method string `json:"method"` 36 | } 37 | 38 | type UserRoleData map[string][]RoleData 39 | 40 | var ( 41 | roleFlag = "role" 42 | roleFlagShort = "r" 43 | ) 44 | 45 | var addUser = &cobra.Command{ 46 | Use: "add user-name", 47 | Example: " pb user add bob", 48 | Short: "Add a new user", 49 | Args: cobra.ExactArgs(1), 50 | RunE: func(cmd *cobra.Command, args []string) error { 51 | startTime := time.Now() 52 | cmd.Annotations = make(map[string]string) // Initialize Annotations map 53 | defer func() { 54 | cmd.Annotations["executionTime"] = time.Since(startTime).String() 55 | }() 56 | 57 | name := args[0] 58 | 59 | client := internalHTTP.DefaultClient(&DefaultProfile) 60 | users, err := fetchUsers(&client) 61 | if err != nil { 62 | cmd.Annotations["error"] = err.Error() 63 | return err 64 | } 65 | 66 | if slices.ContainsFunc(users, func(user UserData) bool { 67 | return user.ID == name 68 | }) { 69 | fmt.Println("user already exists") 70 | cmd.Annotations["error"] = "user already exists" 71 | return nil 72 | } 73 | 74 | // fetch all the roles to be applied to this user 75 | rolesToSet := cmd.Flag(roleFlag).Value.String() 76 | rolesToSetArr := strings.Split(rolesToSet, ",") 77 | 78 | // fetch the role names on the server 79 | var rolesOnServer []string 80 | if err := fetchRoles(&client, &rolesOnServer); err != nil { 81 | cmd.Annotations["error"] = err.Error() 82 | return err 83 | } 84 | rolesOnServerArr := strings.Join(rolesOnServer, " ") 85 | 86 | // validate if roles to be applied are actually present on the server 87 | for idx, role := range rolesToSetArr { 88 | rolesToSetArr[idx] = strings.TrimSpace(role) 89 | if !strings.Contains(rolesOnServerArr, rolesToSetArr[idx]) { 90 | fmt.Printf("role %s doesn't exist, please create a role using pb role add %s\n", rolesToSetArr[idx], rolesToSetArr[idx]) 91 | cmd.Annotations["error"] = fmt.Sprintf("role %s doesn't exist", rolesToSetArr[idx]) 92 | return nil 93 | } 94 | } 95 | 96 | var putBody io.Reader 97 | putBodyJSON, _ := json.Marshal(rolesToSetArr) 98 | putBody = bytes.NewBuffer([]byte(putBodyJSON)) 99 | req, err := client.NewRequest("POST", "user/"+name, putBody) 100 | if err != nil { 101 | cmd.Annotations["error"] = err.Error() 102 | return err 103 | } 104 | 105 | resp, err := client.Client.Do(req) 106 | if err != nil { 107 | cmd.Annotations["error"] = err.Error() 108 | return err 109 | } 110 | 111 | bytes, err := io.ReadAll(resp.Body) 112 | if err != nil { 113 | cmd.Annotations["error"] = err.Error() 114 | return err 115 | } 116 | body := string(bytes) 117 | defer resp.Body.Close() 118 | 119 | if resp.StatusCode == 200 { 120 | fmt.Printf("Added user: %s \nPassword is: %s\nRole(s) assigned: %s\n", name, body, rolesToSet) 121 | cmd.Annotations["error"] = "none" 122 | } else { 123 | fmt.Printf("Request Failed\nStatus Code: %s\nResponse: %s\n", resp.Status, body) 124 | cmd.Annotations["error"] = fmt.Sprintf("request failed with status code %s", resp.Status) 125 | } 126 | 127 | return nil 128 | }, 129 | } 130 | 131 | var AddUserCmd = func() *cobra.Command { 132 | addUser.Flags().StringP(roleFlag, roleFlagShort, "", "specify the role(s) to be assigned to the user. Use comma separated values for multiple roles. Example: --role admin,developer") 133 | return addUser 134 | }() 135 | 136 | var RemoveUserCmd = &cobra.Command{ 137 | Use: "remove user-name", 138 | Aliases: []string{"rm"}, 139 | Example: " pb user remove bob", 140 | Short: "Delete a user", 141 | Args: cobra.ExactArgs(1), 142 | RunE: func(cmd *cobra.Command, args []string) error { 143 | startTime := time.Now() 144 | cmd.Annotations = make(map[string]string) 145 | defer func() { 146 | cmd.Annotations["executionTime"] = time.Since(startTime).String() 147 | }() 148 | 149 | name := args[0] 150 | client := internalHTTP.DefaultClient(&DefaultProfile) 151 | req, err := client.NewRequest("DELETE", "user/"+name, nil) 152 | if err != nil { 153 | cmd.Annotations["error"] = err.Error() 154 | return err 155 | } 156 | 157 | resp, err := client.Client.Do(req) 158 | if err != nil { 159 | cmd.Annotations["error"] = err.Error() 160 | return err 161 | } 162 | 163 | if resp.StatusCode == 200 { 164 | fmt.Printf("Removed user %s\n", StyleBold.Render(name)) 165 | cmd.Annotations["error"] = "none" 166 | } else { 167 | body, _ := io.ReadAll(resp.Body) 168 | fmt.Printf("Request Failed\nStatus Code: %s\nResponse: %s\n", resp.Status, string(body)) 169 | cmd.Annotations["error"] = fmt.Sprintf("request failed with status code %s", resp.Status) 170 | } 171 | 172 | return nil 173 | }, 174 | } 175 | 176 | var SetUserRoleCmd = &cobra.Command{ 177 | Use: "set-role user-name roles", 178 | Short: "Set roles for a user", 179 | Example: " pb user set-role bob admin,developer", 180 | PreRunE: func(_ *cobra.Command, args []string) error { 181 | if len(args) < 2 { 182 | return fmt.Errorf("requires at least 2 arguments") 183 | } 184 | return nil 185 | }, 186 | RunE: func(cmd *cobra.Command, args []string) error { 187 | startTime := time.Now() 188 | cmd.Annotations = make(map[string]string) 189 | defer func() { 190 | cmd.Annotations["executionTime"] = time.Since(startTime).String() 191 | }() 192 | 193 | name := args[0] 194 | client := internalHTTP.DefaultClient(&DefaultProfile) 195 | users, err := fetchUsers(&client) 196 | if err != nil { 197 | cmd.Annotations["error"] = err.Error() 198 | return err 199 | } 200 | 201 | if !slices.ContainsFunc(users, func(user UserData) bool { 202 | return user.ID == name 203 | }) { 204 | fmt.Printf("user doesn't exist. Please create the user with `pb user add %s`\n", name) 205 | cmd.Annotations["error"] = "user does not exist" 206 | return nil 207 | } 208 | 209 | rolesToSet := args[1] 210 | rolesToSetArr := strings.Split(rolesToSet, ",") 211 | var rolesOnServer []string 212 | if err := fetchRoles(&client, &rolesOnServer); err != nil { 213 | cmd.Annotations["error"] = err.Error() 214 | return err 215 | } 216 | rolesOnServerArr := strings.Join(rolesOnServer, " ") 217 | 218 | for idx, role := range rolesToSetArr { 219 | rolesToSetArr[idx] = strings.TrimSpace(role) 220 | if !strings.Contains(rolesOnServerArr, rolesToSetArr[idx]) { 221 | fmt.Printf("role %s doesn't exist, please create a role using `pb role add %s`\n", rolesToSetArr[idx], rolesToSetArr[idx]) 222 | cmd.Annotations["error"] = fmt.Sprintf("role %s doesn't exist", rolesToSetArr[idx]) 223 | return nil 224 | } 225 | } 226 | 227 | var putBody io.Reader 228 | putBodyJSON, _ := json.Marshal(rolesToSetArr) 229 | putBody = bytes.NewBuffer([]byte(putBodyJSON)) 230 | req, err := client.NewRequest("PUT", "user/"+name+"/role", putBody) 231 | if err != nil { 232 | cmd.Annotations["error"] = err.Error() 233 | return err 234 | } 235 | 236 | resp, err := client.Client.Do(req) 237 | if err != nil { 238 | cmd.Annotations["error"] = err.Error() 239 | return err 240 | } 241 | 242 | bytes, err := io.ReadAll(resp.Body) 243 | if err != nil { 244 | cmd.Annotations["error"] = err.Error() 245 | return err 246 | } 247 | body := string(bytes) 248 | defer resp.Body.Close() 249 | 250 | if resp.StatusCode == 200 { 251 | fmt.Printf("Added role(s) %s to user %s\n", rolesToSet, name) 252 | cmd.Annotations["error"] = "none" 253 | } else { 254 | fmt.Printf("Request Failed\nStatus Code: %s\nResponse: %s\n", resp.Status, body) 255 | cmd.Annotations["error"] = fmt.Sprintf("request failed with status code %s", resp.Status) 256 | } 257 | 258 | return nil 259 | }, 260 | } 261 | 262 | var ListUserCmd = &cobra.Command{ 263 | Use: "list", 264 | Short: "List all users", 265 | Example: " pb user list", 266 | RunE: func(cmd *cobra.Command, _ []string) error { 267 | startTime := time.Now() 268 | cmd.Annotations = make(map[string]string) 269 | defer func() { 270 | cmd.Annotations["executionTime"] = time.Since(startTime).String() 271 | }() 272 | 273 | client := internalHTTP.DefaultClient(&DefaultProfile) 274 | users, err := fetchUsers(&client) 275 | if err != nil { 276 | cmd.Annotations["error"] = err.Error() 277 | return err 278 | } 279 | 280 | roleResponses := make([]struct { 281 | data []string 282 | err error 283 | }, len(users)) 284 | 285 | wsg := sync.WaitGroup{} 286 | for idx, user := range users { 287 | wsg.Add(1) 288 | out := &roleResponses[idx] 289 | userID := user.ID 290 | client := &client 291 | go func() { 292 | var userRolesData UserRoleData 293 | userRolesData, out.err = fetchUserRoles(client, userID) 294 | if out.err == nil { 295 | for role := range userRolesData { 296 | out.data = append(out.data, role) 297 | } 298 | } 299 | wsg.Done() 300 | }() 301 | } 302 | 303 | wsg.Wait() 304 | 305 | outputFormat, err := cmd.Flags().GetString("output") 306 | if err != nil { 307 | cmd.Annotations["error"] = err.Error() 308 | return err 309 | } 310 | 311 | if outputFormat == "json" { 312 | usersWithRoles := make([]map[string]interface{}, len(users)) 313 | for idx, user := range users { 314 | usersWithRoles[idx] = map[string]interface{}{ 315 | "id": user.ID, 316 | "roles": roleResponses[idx].data, 317 | } 318 | } 319 | jsonOutput, err := json.MarshalIndent(usersWithRoles, "", " ") 320 | if err != nil { 321 | cmd.Annotations["error"] = err.Error() 322 | return fmt.Errorf("failed to marshal JSON output: %w", err) 323 | } 324 | fmt.Println(string(jsonOutput)) 325 | cmd.Annotations["error"] = "none" 326 | return nil 327 | } 328 | 329 | if outputFormat == "text" { 330 | fmt.Println() 331 | for idx, user := range users { 332 | roles := roleResponses[idx] 333 | if roles.err == nil { 334 | roleList := strings.Join(roles.data, ", ") 335 | fmt.Printf("%s, %s\n", user.ID, roleList) 336 | } else { 337 | fmt.Printf("%s, error: %v\n", user.ID, roles.err) 338 | } 339 | } 340 | fmt.Println() 341 | cmd.Annotations["error"] = "none" 342 | return nil 343 | } 344 | 345 | fmt.Println() 346 | for idx, user := range users { 347 | roles := roleResponses[idx] 348 | fmt.Print("• ") 349 | fmt.Println(StandardStyleBold.Bold(true).Render(user.ID)) 350 | if roles.err == nil { 351 | for _, role := range roles.data { 352 | fmt.Println(lipgloss.NewStyle().PaddingLeft(3).Render(role)) 353 | } 354 | } else { 355 | fmt.Println(roles.err) 356 | } 357 | } 358 | fmt.Println() 359 | 360 | cmd.Annotations["error"] = "none" 361 | return nil 362 | }, 363 | } 364 | 365 | func fetchUsers(client *internalHTTP.HTTPClient) (res []UserData, err error) { 366 | req, err := client.NewRequest("GET", "user", nil) 367 | if err != nil { 368 | return 369 | } 370 | 371 | resp, err := client.Client.Do(req) 372 | if err != nil { 373 | return 374 | } 375 | 376 | bytes, err := io.ReadAll(resp.Body) 377 | if err != nil { 378 | return 379 | } 380 | defer resp.Body.Close() 381 | 382 | if resp.StatusCode == 200 { 383 | err = json.Unmarshal(bytes, &res) 384 | if err != nil { 385 | return 386 | } 387 | } else { 388 | body := string(bytes) 389 | err = fmt.Errorf("request failed\nstatus code: %s\nresponse: %s", resp.Status, body) 390 | return 391 | } 392 | 393 | return 394 | } 395 | 396 | func fetchUserRoles(client *internalHTTP.HTTPClient, user string) (res UserRoleData, err error) { 397 | req, err := client.NewRequest("GET", fmt.Sprintf("user/%s/role", user), nil) 398 | if err != nil { 399 | return 400 | } 401 | resp, err := client.Client.Do(req) 402 | if err != nil { 403 | return 404 | } 405 | body, err := io.ReadAll(resp.Body) 406 | if err != nil { 407 | return 408 | } 409 | defer resp.Body.Close() 410 | 411 | err = json.Unmarshal(body, &res) 412 | return 413 | } 414 | 415 | func init() { 416 | // Add the --output flag with shorthand -o, defaulting to empty for default layout 417 | ListUserCmd.Flags().StringP("output", "o", "", "Output format: 'text' or 'json'") 418 | } 419 | --------------------------------------------------------------------------------